Skip to content

Commit 7b2d15b

Browse files
cursoragentsamwillis
authored andcommitted
Add local-only collection with optimistic in-memory sync support
Implement true loopback sync for local-only collections Add initial data support for local-only collections Refactor local-only collection with optimistic updates and custom callbacks Checkpoint before follow-up message Update local-only sync to support custom key types Update local-only collection tests with extended generic types Refactor local-only sync function signature for improved readability Add explicit key type parameter to localOnlyCollectionOptions Checkpoint before follow-up message Checkpoint before follow-up message Add localOnly collection type for in-memory collections Add localOnly collection type for in-memory collections Refactor local-only collection handlers to call user handlers before confirming transactions fix changeset
1 parent d65a4c2 commit 7b2d15b

File tree

5 files changed

+813
-0
lines changed

5 files changed

+813
-0
lines changed

.changeset/local-only-collection.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db-collections": patch
3+
---
4+
5+
Add localOnly collection type for in-memory collections with loopback sync.

packages/db-collections/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ export {
1515
type StorageApi,
1616
type StorageEventApi,
1717
} from "./local-storage"
18+
export {
19+
localOnlyCollectionOptions,
20+
type LocalOnlyCollectionConfig,
21+
type LocalOnlyCollectionUtils,
22+
} from "./local-only"
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import type {
2+
DeleteMutationFnParams,
3+
InsertMutationFnParams,
4+
OperationType,
5+
ResolveType,
6+
SyncConfig,
7+
UpdateMutationFnParams,
8+
UtilsRecord,
9+
} from "@tanstack/db"
10+
import type { StandardSchemaV1 } from "@standard-schema/spec"
11+
12+
/**
13+
* Configuration interface for Local-only collection options
14+
* @template TExplicit - The explicit type of items in the collection (highest priority)
15+
* @template TSchema - The schema type for validation and type inference (second priority)
16+
* @template TFallback - The fallback type if no explicit or schema type is provided
17+
* @template TKey - The type of the key returned by getKey
18+
*
19+
* @remarks
20+
* Type resolution follows a priority order:
21+
* 1. If you provide an explicit type via generic parameter, it will be used
22+
* 2. If no explicit type is provided but a schema is, the schema's output type will be inferred
23+
* 3. If neither explicit type nor schema is provided, the fallback type will be used
24+
*
25+
* You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.
26+
*/
27+
export interface LocalOnlyCollectionConfig<
28+
TExplicit = unknown,
29+
TSchema extends StandardSchemaV1 = never,
30+
TFallback extends Record<string, unknown> = Record<string, unknown>,
31+
TKey extends string | number = string | number,
32+
> {
33+
/**
34+
* Standard Collection configuration properties
35+
*/
36+
id?: string
37+
schema?: TSchema
38+
getKey: (item: ResolveType<TExplicit, TSchema, TFallback>) => TKey
39+
40+
/**
41+
* Optional initial data to populate the collection with on creation
42+
* This data will be applied during the initial sync process
43+
*/
44+
initialData?: Array<ResolveType<TExplicit, TSchema, TFallback>>
45+
46+
/**
47+
* Optional asynchronous handler function called after an insert operation
48+
* @param params Object containing transaction and mutation information
49+
* @returns Promise resolving to any value
50+
*/
51+
onInsert?: (
52+
params: InsertMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
53+
) => Promise<any>
54+
55+
/**
56+
* Optional asynchronous handler function called after an update operation
57+
* @param params Object containing transaction and mutation information
58+
* @returns Promise resolving to any value
59+
*/
60+
onUpdate?: (
61+
params: UpdateMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
62+
) => Promise<any>
63+
64+
/**
65+
* Optional asynchronous handler function called after a delete operation
66+
* @param params Object containing transaction and mutation information
67+
* @returns Promise resolving to any value
68+
*/
69+
onDelete?: (
70+
params: DeleteMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
71+
) => Promise<any>
72+
}
73+
74+
/**
75+
* Local-only collection utilities type (currently empty but matches the pattern)
76+
*/
77+
export interface LocalOnlyCollectionUtils extends UtilsRecord {}
78+
79+
/**
80+
* Creates Local-only collection options for use with a standard Collection
81+
* This is an in-memory collection that doesn't sync but uses a loopback sync config
82+
* that immediately "syncs" all optimistic changes to the collection.
83+
*
84+
* @template TExplicit - The explicit type of items in the collection (highest priority)
85+
* @template TSchema - The schema type for validation and type inference (second priority)
86+
* @template TFallback - The fallback type if no explicit or schema type is provided
87+
* @template TKey - The type of the key returned by getKey
88+
* @param config - Configuration options for the Local-only collection
89+
* @returns Collection options with utilities
90+
*/
91+
export function localOnlyCollectionOptions<
92+
TExplicit = unknown,
93+
TSchema extends StandardSchemaV1 = never,
94+
TFallback extends Record<string, unknown> = Record<string, unknown>,
95+
TKey extends string | number = string | number,
96+
>(config: LocalOnlyCollectionConfig<TExplicit, TSchema, TFallback, TKey>) {
97+
type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>
98+
99+
const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config
100+
101+
// Create the sync configuration with transaction confirmation capability
102+
const syncResult = createLocalOnlySync<ResolvedType, TKey>(initialData)
103+
104+
// Create wrapper handlers that call user handlers first, then confirm transactions
105+
const wrappedOnInsert = async (
106+
params: InsertMutationFnParams<ResolvedType>
107+
) => {
108+
// Call user handler first if provided
109+
let handlerResult
110+
if (onInsert) {
111+
handlerResult = (await onInsert(params)) ?? {}
112+
}
113+
114+
// Then synchronously confirm the transaction by looping through mutations
115+
syncResult.confirmOperationsSync(params.transaction.mutations)
116+
117+
return handlerResult
118+
}
119+
120+
const wrappedOnUpdate = async (
121+
params: UpdateMutationFnParams<ResolvedType>
122+
) => {
123+
// Call user handler first if provided
124+
let handlerResult
125+
if (onUpdate) {
126+
handlerResult = (await onUpdate(params)) ?? {}
127+
}
128+
129+
// Then synchronously confirm the transaction by looping through mutations
130+
syncResult.confirmOperationsSync(params.transaction.mutations)
131+
132+
return handlerResult
133+
}
134+
135+
const wrappedOnDelete = async (
136+
params: DeleteMutationFnParams<ResolvedType>
137+
) => {
138+
// Call user handler first if provided
139+
let handlerResult
140+
if (onDelete) {
141+
handlerResult = (await onDelete(params)) ?? {}
142+
}
143+
144+
// Then synchronously confirm the transaction by looping through mutations
145+
syncResult.confirmOperationsSync(params.transaction.mutations)
146+
147+
return handlerResult
148+
}
149+
150+
return {
151+
...restConfig,
152+
sync: syncResult.sync,
153+
onInsert: wrappedOnInsert,
154+
onUpdate: wrappedOnUpdate,
155+
onDelete: wrappedOnDelete,
156+
utils: {} as LocalOnlyCollectionUtils,
157+
startSync: true,
158+
gcTime: 0,
159+
}
160+
}
161+
162+
/**
163+
* Internal function to create Local-only sync configuration with transaction confirmation
164+
* This captures the sync functions and provides synchronous confirmation of operations
165+
*/
166+
function createLocalOnlySync<T extends object, TKey extends string | number>(
167+
initialData?: Array<T>
168+
) {
169+
// Capture sync functions for transaction confirmation
170+
let syncBegin: (() => void) | null = null
171+
let syncWrite: ((message: { type: OperationType; value: T }) => void) | null =
172+
null
173+
let syncCommit: (() => void) | null = null
174+
175+
const sync: SyncConfig<T, TKey> = {
176+
sync: (params) => {
177+
const { begin, write, commit } = params
178+
179+
// Capture sync functions for later use by confirmOperationsSync
180+
syncBegin = begin
181+
syncWrite = write
182+
syncCommit = commit
183+
184+
// Apply initial data if provided
185+
if (initialData && initialData.length > 0) {
186+
begin()
187+
initialData.forEach((item) => {
188+
write({
189+
type: `insert`,
190+
value: item,
191+
})
192+
})
193+
commit()
194+
}
195+
196+
// Return empty unsubscribe function - no ongoing sync needed
197+
return () => {}
198+
},
199+
getSyncMetadata: () => ({}),
200+
}
201+
202+
/**
203+
* Synchronously confirms optimistic operations by immediately writing through sync
204+
* This loops through transaction mutations and applies them to move from optimistic to synced state
205+
*/
206+
const confirmOperationsSync = (mutations: Array<any>) => {
207+
if (!syncBegin || !syncWrite || !syncCommit) {
208+
return // Sync not initialized yet, which is fine
209+
}
210+
211+
// Immediately write back through sync interface
212+
syncBegin()
213+
mutations.forEach((mutation) => {
214+
if (syncWrite) {
215+
syncWrite({
216+
type: mutation.type,
217+
value: mutation.modified,
218+
})
219+
}
220+
})
221+
syncCommit()
222+
}
223+
224+
return {
225+
sync,
226+
confirmOperationsSync,
227+
}
228+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, expectTypeOf, it } from "vitest"
2+
import { createCollection } from "@tanstack/db"
3+
import { localOnlyCollectionOptions } from "../src/local-only"
4+
import type { LocalOnlyCollectionUtils } from "../src/local-only"
5+
import type { Collection } from "@tanstack/db"
6+
7+
interface TestItem extends Record<string, unknown> {
8+
id: number
9+
name: string
10+
completed?: boolean
11+
}
12+
13+
describe(`LocalOnly Collection Types`, () => {
14+
it(`should have correct return type from localOnlyCollectionOptions`, () => {
15+
const config = {
16+
id: `test-local-only`,
17+
getKey: (item: TestItem) => item.id,
18+
}
19+
20+
const options = localOnlyCollectionOptions<
21+
TestItem,
22+
never,
23+
TestItem,
24+
number
25+
>(config)
26+
27+
// Test that options has the expected structure
28+
expectTypeOf(options).toHaveProperty(`sync`)
29+
expectTypeOf(options).toHaveProperty(`onInsert`)
30+
expectTypeOf(options).toHaveProperty(`onUpdate`)
31+
expectTypeOf(options).toHaveProperty(`onDelete`)
32+
expectTypeOf(options).toHaveProperty(`utils`)
33+
expectTypeOf(options).toHaveProperty(`getKey`)
34+
35+
// Test that getKey returns the correct type
36+
expectTypeOf(options.getKey).toMatchTypeOf<(item: TestItem) => number>()
37+
})
38+
39+
it(`should be compatible with createCollection`, () => {
40+
const config = {
41+
id: `test-local-only`,
42+
getKey: (item: TestItem) => item.id,
43+
}
44+
45+
const options = localOnlyCollectionOptions<
46+
TestItem,
47+
never,
48+
TestItem,
49+
number
50+
>(config)
51+
52+
const collection = createCollection<
53+
TestItem,
54+
number,
55+
LocalOnlyCollectionUtils
56+
>(options)
57+
58+
// Test that the collection has the expected type
59+
expectTypeOf(collection).toMatchTypeOf<
60+
Collection<TestItem, number, LocalOnlyCollectionUtils>
61+
>()
62+
})
63+
64+
it(`should work with custom callbacks`, () => {
65+
const configWithCallbacks = {
66+
id: `test-with-callbacks`,
67+
getKey: (item: TestItem) => item.id,
68+
onInsert: () => Promise.resolve({}),
69+
onUpdate: () => Promise.resolve({}),
70+
onDelete: () => Promise.resolve({}),
71+
}
72+
73+
const options = localOnlyCollectionOptions<
74+
TestItem,
75+
never,
76+
TestItem,
77+
number
78+
>(configWithCallbacks)
79+
const collection = createCollection<
80+
TestItem,
81+
number,
82+
LocalOnlyCollectionUtils
83+
>(options)
84+
85+
expectTypeOf(collection).toMatchTypeOf<
86+
Collection<TestItem, number, LocalOnlyCollectionUtils>
87+
>()
88+
})
89+
90+
it(`should work with initial data`, () => {
91+
const configWithInitialData = {
92+
id: `test-with-initial-data`,
93+
getKey: (item: TestItem) => item.id,
94+
initialData: [{ id: 1, name: `Test` }] as Array<TestItem>,
95+
}
96+
97+
const options = localOnlyCollectionOptions<
98+
TestItem,
99+
never,
100+
TestItem,
101+
number
102+
>(configWithInitialData)
103+
const collection = createCollection<
104+
TestItem,
105+
number,
106+
LocalOnlyCollectionUtils
107+
>(options)
108+
109+
expectTypeOf(collection).toMatchTypeOf<
110+
Collection<TestItem, number, LocalOnlyCollectionUtils>
111+
>()
112+
})
113+
114+
it(`should infer key type from getKey function`, () => {
115+
const config = {
116+
id: `test-string-key`,
117+
getKey: (item: TestItem) => `item-${item.id}`,
118+
}
119+
120+
const options = localOnlyCollectionOptions<
121+
TestItem,
122+
never,
123+
TestItem,
124+
string
125+
>(config)
126+
const collection = createCollection<
127+
TestItem,
128+
string,
129+
LocalOnlyCollectionUtils
130+
>(options)
131+
132+
expectTypeOf(collection).toMatchTypeOf<
133+
Collection<TestItem, string, LocalOnlyCollectionUtils>
134+
>()
135+
expectTypeOf(options.getKey).toMatchTypeOf<(item: TestItem) => string>()
136+
})
137+
})

0 commit comments

Comments
 (0)