From cee25b9f1107c89bc4d873af1416ed3438512902 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 11 Dec 2025 14:03:05 +0100 Subject: [PATCH 1/7] Get the key from the message, otherwise compute it from the row value --- packages/db/src/collection/sync.ts | 26 ++++++++++++++++++++------ packages/db/src/types.ts | 12 ++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index de2fbb5e1..29b3c34d5 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -13,6 +13,7 @@ import { LIVE_QUERY_INTERNAL } from '../query/live/internal.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { ChangeMessage, + ChangeMessageOrDeleteKeyMessage, CleanupFn, CollectionConfig, LoadSubsetOptions, @@ -94,7 +95,12 @@ export class CollectionSyncManager< deletedKeys: new Set(), }) }, - write: (messageWithoutKey: Omit, `key`>) => { + write: ( + messageWithOptionalKey: ChangeMessageOrDeleteKeyMessage< + TOutput, + TKey + >, + ) => { const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 @@ -105,12 +111,18 @@ export class CollectionSyncManager< if (pendingTransaction.committed) { throw new SyncTransactionAlreadyCommittedWriteError() } - const key = this.config.getKey(messageWithoutKey.value) - let messageType = messageWithoutKey.type + let key: TKey | undefined = undefined + if (`key` in messageWithOptionalKey) { + key = messageWithOptionalKey.key + } else { + key = this.config.getKey(messageWithOptionalKey.value) + } + + let messageType = messageWithOptionalKey.type // Check if an item with this key already exists when inserting - if (messageWithoutKey.type === `insert`) { + if (messageWithOptionalKey.type === `insert`) { const insertingIntoExistingSynced = this.state.syncedData.has(key) const hasPendingDeleteForKey = pendingTransaction.deletedKeys.has(key) @@ -124,7 +136,7 @@ export class CollectionSyncManager< const existingValue = this.state.syncedData.get(key) if ( existingValue !== undefined && - deepEquals(existingValue, messageWithoutKey.value) + deepEquals(existingValue, messageWithOptionalKey.value) ) { // The "insert" is an echo of a value we already have locally. // Treat it as an update so we preserve optimistic intent without @@ -143,7 +155,9 @@ export class CollectionSyncManager< } const message: ChangeMessage = { - ...messageWithoutKey, + // TODO: this type cast is false because now the message may not contain a value field + // will need to fix the types but that's going to spread... + ...(messageWithOptionalKey as ChangeMessage), type: messageType, key, } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 22f0fd75b..77f51537f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -361,6 +361,16 @@ export interface ChangeMessage< metadata?: Record } +export type DeleteKeyMessage = + Omit, `value` | `previousValue` | `type`> & { + type: `delete` + } + +export type ChangeMessageOrDeleteKeyMessage< + T extends object = Record, + TKey extends string | number = string | number, +> = Omit, `key`> | DeleteKeyMessage + export interface OptimisticChangeMessage< T extends object = Record, > extends ChangeMessage { @@ -894,3 +904,5 @@ export type WritableDeep = T extends BuiltIns : T extends object ? WritableObjectDeep : unknown + +export type MakeOptional = Omit & Partial> \ No newline at end of file From 9c24ac555b3b8ecaa2af168da107783c321a720c Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 11 Dec 2025 14:03:28 +0100 Subject: [PATCH 2/7] Unit tests for deleting a row by key --- packages/db/tests/collection.test.ts | 110 +++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index 8be8b8fa7..ecb60361d 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -1546,6 +1546,116 @@ describe(`Collection`, () => { const state = await collection.stateWhenReady() expect(state.size).toBe(3) }) + + it(`should allow deleting a row by passing only the key to write function`, async () => { + let testSyncFunctions: any = null + + const collection = createCollection<{ id: number; value: string }>({ + id: `delete-by-key`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + // Store the sync functions for testing + testSyncFunctions = { begin, write, commit, markReady } + }, + }, + }) + + // Collection should start in loading state + expect(collection.status).toBe(`loading`) + expect(collection.size).toBe(0) + + const { begin, write, commit, markReady } = testSyncFunctions + + // Insert some initial data + begin() + write({ type: `insert`, value: { id: 1, value: `item 1` } }) + write({ type: `insert`, value: { id: 2, value: `item 2` } }) + write({ type: `insert`, value: { id: 3, value: `item 3` } }) + commit() + + // Verify data was inserted + expect(collection.size).toBe(3) + expect(collection.state.get(1)).toEqual({ id: 1, value: `item 1` }) + expect(collection.state.get(2)).toEqual({ id: 2, value: `item 2` }) + expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` }) + + // Delete a row by passing only the key (no value) + begin() + write({ type: `delete`, key: 2 }) + commit() + + // Verify the row is gone + expect(collection.size).toBe(2) + expect(collection.state.get(1)).toEqual({ id: 1, value: `item 1` }) + expect(collection.state.get(2)).toBeUndefined() + expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` }) + + // Delete another row by key only + begin() + write({ type: `delete`, key: 1 }) + commit() + + // Verify both rows are gone + expect(collection.size).toBe(1) + expect(collection.state.get(1)).toBeUndefined() + expect(collection.state.get(2)).toBeUndefined() + expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` }) + + // Mark as ready + markReady() + + // Verify final state + expect(collection.status).toBe(`ready`) + expect(collection.size).toBe(1) + expect(Array.from(collection.state.keys())).toEqual([3]) + }) + + it(`should allow deleting a row by key with string keys`, async () => { + let testSyncFunctions: any = null + + const collection = createCollection<{ id: string; name: string }>({ + id: `delete-by-string-key`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + // Store the sync functions for testing + testSyncFunctions = { begin, write, commit, markReady } + }, + }, + }) + + const { begin, write, commit, markReady } = testSyncFunctions + + // Insert initial data + begin() + write({ type: `insert`, value: { id: `a`, name: `Alice` } }) + write({ type: `insert`, value: { id: `b`, name: `Bob` } }) + write({ type: `insert`, value: { id: `c`, name: `Charlie` } }) + commit() + + // Verify data was inserted + expect(collection.size).toBe(3) + expect(collection.state.get(`a`)).toEqual({ id: `a`, name: `Alice` }) + expect(collection.state.get(`b`)).toEqual({ id: `b`, name: `Bob` }) + expect(collection.state.get(`c`)).toEqual({ id: `c`, name: `Charlie` }) + + // Delete by key only + begin() + write({ type: `delete`, key: `b` }) + commit() + + // Verify the row is gone + expect(collection.size).toBe(2) + expect(collection.state.get(`a`)).toEqual({ id: `a`, name: `Alice` }) + expect(collection.state.get(`b`)).toBeUndefined() + expect(collection.state.get(`c`)).toEqual({ id: `c`, name: `Charlie` }) + + markReady() + expect(collection.status).toBe(`ready`) + }) }) describe(`Collection isLoadingSubset property`, () => { From 28effd2cf03f435abf8e375338bff8830e8a65e2 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 11 Dec 2025 14:19:16 +0100 Subject: [PATCH 3/7] Improve the precision of the types --- packages/db/src/collection/sync.ts | 10 ++++------ packages/db/src/types.ts | 15 +++++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 29b3c34d5..841e76c1f 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -12,11 +12,11 @@ import { deepEquals } from '../utils' import { LIVE_QUERY_INTERNAL } from '../query/live/internal.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { - ChangeMessage, ChangeMessageOrDeleteKeyMessage, CleanupFn, CollectionConfig, LoadSubsetOptions, + OptimisticChangeMessage, SyncConfigRes, } from '../types' import type { CollectionImpl } from './index.js' @@ -154,13 +154,11 @@ export class CollectionSyncManager< } } - const message: ChangeMessage = { - // TODO: this type cast is false because now the message may not contain a value field - // will need to fix the types but that's going to spread... - ...(messageWithOptionalKey as ChangeMessage), + const message = { + ...messageWithOptionalKey, type: messageType, key, - } + } as OptimisticChangeMessage pendingTransaction.operations.push(message) if (messageType === `delete`) { diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 77f51537f..e1ca92ee6 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -371,12 +371,15 @@ export type ChangeMessageOrDeleteKeyMessage< TKey extends string | number = string | number, > = Omit, `key`> | DeleteKeyMessage -export interface OptimisticChangeMessage< - T extends object = Record, -> extends ChangeMessage { - // Is this change message part of an active transaction. Only applies to optimistic changes. - isActive?: boolean -} +export type OptimisticChangeMessage, TKey extends string | number = string | number> = + | (ChangeMessage & { + // Is this change message part of an active transaction. Only applies to optimistic changes. + isActive?: boolean + }) + | (DeleteKeyMessage & { + // Is this change message part of an active transaction. Only applies to optimistic changes. + isActive?: boolean + }) /** * The Standard Schema interface. From 1149d63fe4ff8a128212e38c875b29e22f097385 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 11 Dec 2025 14:22:03 +0100 Subject: [PATCH 4/7] changeset --- .changeset/easy-streets-pump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/easy-streets-pump.md diff --git a/.changeset/easy-streets-pump.md b/.changeset/easy-streets-pump.md new file mode 100644 index 000000000..7e951b49f --- /dev/null +++ b/.changeset/easy-streets-pump.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Allow rows to be deleted by key by using the write function passed to a collection's sync function. From 0c0b66a5a607913528c91225db34f39ec29f0857 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:24:32 +0000 Subject: [PATCH 5/7] Updated types --- packages/db/src/types.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index e1ca92ee6..60c82f8e5 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -371,7 +371,10 @@ export type ChangeMessageOrDeleteKeyMessage< TKey extends string | number = string | number, > = Omit, `key`> | DeleteKeyMessage -export type OptimisticChangeMessage, TKey extends string | number = string | number> = +export type OptimisticChangeMessage< + T extends object = Record, + TKey extends string | number = string | number, +> = | (ChangeMessage & { // Is this change message part of an active transaction. Only applies to optimistic changes. isActive?: boolean @@ -908,4 +911,5 @@ export type WritableDeep = T extends BuiltIns ? WritableObjectDeep : unknown -export type MakeOptional = Omit & Partial> \ No newline at end of file +export type MakeOptional = Omit & + Partial> From 78deb45a29393650c0494029429b4431d75e1e5d Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 11 Dec 2025 14:35:56 +0100 Subject: [PATCH 6/7] Update type of write in SyncConfig type --- packages/db/src/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 60c82f8e5..a5f528a8c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -328,7 +328,9 @@ export interface SyncConfig< sync: (params: { collection: Collection begin: () => void - write: (message: Omit, `key`>) => void + write: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void commit: () => void markReady: () => void truncate: () => void From cfcf0a073e0c7073f7de651ecf043631f0c510ac Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:38:50 +0000 Subject: [PATCH 7/7] ci: apply automated fixes --- packages/db/src/types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index a5f528a8c..bb78f35eb 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -328,9 +328,7 @@ export interface SyncConfig< sync: (params: { collection: Collection begin: () => void - write: ( - message: ChangeMessageOrDeleteKeyMessage, - ) => void + write: (message: ChangeMessageOrDeleteKeyMessage) => void commit: () => void markReady: () => void truncate: () => void