Skip to content

Commit 8ed7725

Browse files
authored
Delete a row by its key (#1003)
This PR makes it possible to delete a row by key when using the write function passed to a collection's sync function.
1 parent 997fbae commit 8ed7725

File tree

4 files changed

+161
-15
lines changed

4 files changed

+161
-15
lines changed

.changeset/easy-streets-pump.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
Allow rows to be deleted by key by using the write function passed to a collection's sync function.

packages/db/src/collection/sync.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import { deepEquals } from '../utils'
1212
import { LIVE_QUERY_INTERNAL } from '../query/live/internal.js'
1313
import type { StandardSchemaV1 } from '@standard-schema/spec'
1414
import type {
15-
ChangeMessage,
15+
ChangeMessageOrDeleteKeyMessage,
1616
CleanupFn,
1717
CollectionConfig,
1818
LoadSubsetOptions,
19+
OptimisticChangeMessage,
1920
SyncConfigRes,
2021
} from '../types'
2122
import type { CollectionImpl } from './index.js'
@@ -94,7 +95,12 @@ export class CollectionSyncManager<
9495
deletedKeys: new Set(),
9596
})
9697
},
97-
write: (messageWithoutKey: Omit<ChangeMessage<TOutput>, `key`>) => {
98+
write: (
99+
messageWithOptionalKey: ChangeMessageOrDeleteKeyMessage<
100+
TOutput,
101+
TKey
102+
>,
103+
) => {
98104
const pendingTransaction =
99105
this.state.pendingSyncedTransactions[
100106
this.state.pendingSyncedTransactions.length - 1
@@ -105,12 +111,18 @@ export class CollectionSyncManager<
105111
if (pendingTransaction.committed) {
106112
throw new SyncTransactionAlreadyCommittedWriteError()
107113
}
108-
const key = this.config.getKey(messageWithoutKey.value)
109114

110-
let messageType = messageWithoutKey.type
115+
let key: TKey | undefined = undefined
116+
if (`key` in messageWithOptionalKey) {
117+
key = messageWithOptionalKey.key
118+
} else {
119+
key = this.config.getKey(messageWithOptionalKey.value)
120+
}
121+
122+
let messageType = messageWithOptionalKey.type
111123

112124
// Check if an item with this key already exists when inserting
113-
if (messageWithoutKey.type === `insert`) {
125+
if (messageWithOptionalKey.type === `insert`) {
114126
const insertingIntoExistingSynced = this.state.syncedData.has(key)
115127
const hasPendingDeleteForKey =
116128
pendingTransaction.deletedKeys.has(key)
@@ -124,7 +136,7 @@ export class CollectionSyncManager<
124136
const existingValue = this.state.syncedData.get(key)
125137
if (
126138
existingValue !== undefined &&
127-
deepEquals(existingValue, messageWithoutKey.value)
139+
deepEquals(existingValue, messageWithOptionalKey.value)
128140
) {
129141
// The "insert" is an echo of a value we already have locally.
130142
// Treat it as an update so we preserve optimistic intent without
@@ -142,11 +154,11 @@ export class CollectionSyncManager<
142154
}
143155
}
144156

145-
const message: ChangeMessage<TOutput> = {
146-
...messageWithoutKey,
157+
const message = {
158+
...messageWithOptionalKey,
147159
type: messageType,
148160
key,
149-
}
161+
} as OptimisticChangeMessage<TOutput, TKey>
150162
pendingTransaction.operations.push(message)
151163

152164
if (messageType === `delete`) {

packages/db/src/types.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ export interface SyncConfig<
328328
sync: (params: {
329329
collection: Collection<T, TKey, any, any, any>
330330
begin: () => void
331-
write: (message: Omit<ChangeMessage<T>, `key`>) => void
331+
write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => void
332332
commit: () => void
333333
markReady: () => void
334334
truncate: () => void
@@ -361,12 +361,28 @@ export interface ChangeMessage<
361361
metadata?: Record<string, unknown>
362362
}
363363

364-
export interface OptimisticChangeMessage<
364+
export type DeleteKeyMessage<TKey extends string | number = string | number> =
365+
Omit<ChangeMessage<any, TKey>, `value` | `previousValue` | `type`> & {
366+
type: `delete`
367+
}
368+
369+
export type ChangeMessageOrDeleteKeyMessage<
365370
T extends object = Record<string, unknown>,
366-
> extends ChangeMessage<T> {
367-
// Is this change message part of an active transaction. Only applies to optimistic changes.
368-
isActive?: boolean
369-
}
371+
TKey extends string | number = string | number,
372+
> = Omit<ChangeMessage<T>, `key`> | DeleteKeyMessage<TKey>
373+
374+
export type OptimisticChangeMessage<
375+
T extends object = Record<string, unknown>,
376+
TKey extends string | number = string | number,
377+
> =
378+
| (ChangeMessage<T> & {
379+
// Is this change message part of an active transaction. Only applies to optimistic changes.
380+
isActive?: boolean
381+
})
382+
| (DeleteKeyMessage<TKey> & {
383+
// Is this change message part of an active transaction. Only applies to optimistic changes.
384+
isActive?: boolean
385+
})
370386

371387
/**
372388
* The Standard Schema interface.
@@ -894,3 +910,6 @@ export type WritableDeep<T> = T extends BuiltIns
894910
: T extends object
895911
? WritableObjectDeep<T>
896912
: unknown
913+
914+
export type MakeOptional<T, K extends keyof T> = Omit<T, K> &
915+
Partial<Pick<T, K>>

packages/db/tests/collection.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,6 +1546,116 @@ describe(`Collection`, () => {
15461546
const state = await collection.stateWhenReady()
15471547
expect(state.size).toBe(3)
15481548
})
1549+
1550+
it(`should allow deleting a row by passing only the key to write function`, async () => {
1551+
let testSyncFunctions: any = null
1552+
1553+
const collection = createCollection<{ id: number; value: string }>({
1554+
id: `delete-by-key`,
1555+
getKey: (item) => item.id,
1556+
startSync: true,
1557+
sync: {
1558+
sync: ({ begin, write, commit, markReady }) => {
1559+
// Store the sync functions for testing
1560+
testSyncFunctions = { begin, write, commit, markReady }
1561+
},
1562+
},
1563+
})
1564+
1565+
// Collection should start in loading state
1566+
expect(collection.status).toBe(`loading`)
1567+
expect(collection.size).toBe(0)
1568+
1569+
const { begin, write, commit, markReady } = testSyncFunctions
1570+
1571+
// Insert some initial data
1572+
begin()
1573+
write({ type: `insert`, value: { id: 1, value: `item 1` } })
1574+
write({ type: `insert`, value: { id: 2, value: `item 2` } })
1575+
write({ type: `insert`, value: { id: 3, value: `item 3` } })
1576+
commit()
1577+
1578+
// Verify data was inserted
1579+
expect(collection.size).toBe(3)
1580+
expect(collection.state.get(1)).toEqual({ id: 1, value: `item 1` })
1581+
expect(collection.state.get(2)).toEqual({ id: 2, value: `item 2` })
1582+
expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` })
1583+
1584+
// Delete a row by passing only the key (no value)
1585+
begin()
1586+
write({ type: `delete`, key: 2 })
1587+
commit()
1588+
1589+
// Verify the row is gone
1590+
expect(collection.size).toBe(2)
1591+
expect(collection.state.get(1)).toEqual({ id: 1, value: `item 1` })
1592+
expect(collection.state.get(2)).toBeUndefined()
1593+
expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` })
1594+
1595+
// Delete another row by key only
1596+
begin()
1597+
write({ type: `delete`, key: 1 })
1598+
commit()
1599+
1600+
// Verify both rows are gone
1601+
expect(collection.size).toBe(1)
1602+
expect(collection.state.get(1)).toBeUndefined()
1603+
expect(collection.state.get(2)).toBeUndefined()
1604+
expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` })
1605+
1606+
// Mark as ready
1607+
markReady()
1608+
1609+
// Verify final state
1610+
expect(collection.status).toBe(`ready`)
1611+
expect(collection.size).toBe(1)
1612+
expect(Array.from(collection.state.keys())).toEqual([3])
1613+
})
1614+
1615+
it(`should allow deleting a row by key with string keys`, async () => {
1616+
let testSyncFunctions: any = null
1617+
1618+
const collection = createCollection<{ id: string; name: string }>({
1619+
id: `delete-by-string-key`,
1620+
getKey: (item) => item.id,
1621+
startSync: true,
1622+
sync: {
1623+
sync: ({ begin, write, commit, markReady }) => {
1624+
// Store the sync functions for testing
1625+
testSyncFunctions = { begin, write, commit, markReady }
1626+
},
1627+
},
1628+
})
1629+
1630+
const { begin, write, commit, markReady } = testSyncFunctions
1631+
1632+
// Insert initial data
1633+
begin()
1634+
write({ type: `insert`, value: { id: `a`, name: `Alice` } })
1635+
write({ type: `insert`, value: { id: `b`, name: `Bob` } })
1636+
write({ type: `insert`, value: { id: `c`, name: `Charlie` } })
1637+
commit()
1638+
1639+
// Verify data was inserted
1640+
expect(collection.size).toBe(3)
1641+
expect(collection.state.get(`a`)).toEqual({ id: `a`, name: `Alice` })
1642+
expect(collection.state.get(`b`)).toEqual({ id: `b`, name: `Bob` })
1643+
expect(collection.state.get(`c`)).toEqual({ id: `c`, name: `Charlie` })
1644+
1645+
// Delete by key only
1646+
begin()
1647+
write({ type: `delete`, key: `b` })
1648+
commit()
1649+
1650+
// Verify the row is gone
1651+
expect(collection.size).toBe(2)
1652+
expect(collection.state.get(`a`)).toEqual({ id: `a`, name: `Alice` })
1653+
expect(collection.state.get(`b`)).toBeUndefined()
1654+
expect(collection.state.get(`c`)).toEqual({ id: `c`, name: `Charlie` })
1655+
1656+
markReady()
1657+
expect(collection.status).toBe(`ready`)
1658+
})
15491659
})
15501660

15511661
describe(`Collection isLoadingSubset property`, () => {

0 commit comments

Comments
 (0)