@firtoz/db-helpers 0.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -3
- package/src/generic-sync.ts +357 -0
- package/src/index.ts +17 -0
- package/src/ir-evaluator.ts +136 -0
- package/src/memoryCollection.ts +64 -28
- package/src/sync-types.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/db-helpers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "TanStack DB helpers and utilities",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
21
|
"typecheck": "tsc --noEmit -p ./tsconfig.json",
|
|
22
|
+
"test": "bun test",
|
|
22
23
|
"lint": "biome check --write src",
|
|
23
24
|
"lint:ci": "biome ci src",
|
|
24
25
|
"format": "biome format src --write"
|
|
@@ -48,10 +49,15 @@
|
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
51
|
"@standard-schema/spec": ">=1.1.0",
|
|
51
|
-
"@tanstack/db": ">=0.5.
|
|
52
|
+
"@tanstack/db": ">=0.5.33"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@standard-schema/spec": "^1.1.0",
|
|
55
|
-
"@tanstack/db": "^0.5.
|
|
56
|
+
"@tanstack/db": "^0.5.33",
|
|
57
|
+
"bun-types": "^1.3.10",
|
|
58
|
+
"zod": "^4.3.6"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@firtoz/maybe-error": "^1.5.2"
|
|
56
62
|
}
|
|
57
63
|
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import type { CollectionUtils, SyncMessage } from "./sync-types";
|
|
2
|
+
import { exhaustiveGuard } from "@firtoz/maybe-error";
|
|
3
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
4
|
+
import type {
|
|
5
|
+
CollectionConfig,
|
|
6
|
+
InferSchemaOutput,
|
|
7
|
+
SyncConfig,
|
|
8
|
+
SyncConfigRes,
|
|
9
|
+
SyncMode,
|
|
10
|
+
LoadSubsetOptions,
|
|
11
|
+
} from "@tanstack/db";
|
|
12
|
+
import { DeduplicatedLoadSubset } from "@tanstack/db";
|
|
13
|
+
|
|
14
|
+
// WORKAROUND: DeduplicatedLoadSubset has a bug where toggling queries (e.g., isNull/isNotNull)
|
|
15
|
+
// creates invalid expressions like not(or(isNull(...), not(isNull(...))))
|
|
16
|
+
// See: https://github.com/TanStack/db/issues/828
|
|
17
|
+
// TODO: Re-enable once the bug is fixed
|
|
18
|
+
export const USE_DEDUPE = false as boolean;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Base configuration for sync lifecycle management (generic, no Drizzle dependency).
|
|
22
|
+
*/
|
|
23
|
+
export interface GenericBaseSyncConfig {
|
|
24
|
+
readyPromise: Promise<void>;
|
|
25
|
+
syncMode?: SyncMode;
|
|
26
|
+
debug?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Backend-specific implementations required for sync (generic, no Drizzle dependency).
|
|
31
|
+
*/
|
|
32
|
+
export interface GenericSyncBackend<TItem extends object> {
|
|
33
|
+
initialLoad: () => Promise<Array<TItem>>;
|
|
34
|
+
loadSubset: (options: LoadSubsetOptions) => Promise<Array<TItem>>;
|
|
35
|
+
handleInsert: (items: Array<TItem>) => Promise<Array<TItem>>;
|
|
36
|
+
handleUpdate: (
|
|
37
|
+
mutations: Array<{
|
|
38
|
+
key: string;
|
|
39
|
+
changes: Partial<TItem>;
|
|
40
|
+
original: TItem;
|
|
41
|
+
}>,
|
|
42
|
+
) => Promise<Array<TItem>>;
|
|
43
|
+
handleDelete: (
|
|
44
|
+
mutations: Array<{
|
|
45
|
+
key: string;
|
|
46
|
+
modified: TItem;
|
|
47
|
+
original: TItem;
|
|
48
|
+
}>,
|
|
49
|
+
) => Promise<void>;
|
|
50
|
+
handleTruncate?: () => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Return type for createGenericSyncFunction.
|
|
55
|
+
*/
|
|
56
|
+
export type GenericSyncFunctionResult<TItem extends object> = {
|
|
57
|
+
sync: SyncConfig<TItem, string>["sync"];
|
|
58
|
+
onInsert: CollectionConfig<
|
|
59
|
+
TItem,
|
|
60
|
+
string,
|
|
61
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
|
|
62
|
+
any
|
|
63
|
+
>["onInsert"];
|
|
64
|
+
onUpdate: CollectionConfig<
|
|
65
|
+
TItem,
|
|
66
|
+
string,
|
|
67
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
|
|
68
|
+
any
|
|
69
|
+
>["onUpdate"];
|
|
70
|
+
onDelete: CollectionConfig<
|
|
71
|
+
TItem,
|
|
72
|
+
string,
|
|
73
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
|
|
74
|
+
any
|
|
75
|
+
>["onDelete"];
|
|
76
|
+
utils: CollectionUtils<TItem>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates the sync function with common lifecycle management.
|
|
81
|
+
* Generic version -- no Drizzle dependency.
|
|
82
|
+
*/
|
|
83
|
+
export function createGenericSyncFunction<TItem extends object>(
|
|
84
|
+
config: GenericBaseSyncConfig,
|
|
85
|
+
backend: GenericSyncBackend<TItem>,
|
|
86
|
+
): GenericSyncFunctionResult<TItem> {
|
|
87
|
+
type CollectionType = CollectionConfig<
|
|
88
|
+
TItem,
|
|
89
|
+
string,
|
|
90
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
|
|
91
|
+
any
|
|
92
|
+
>;
|
|
93
|
+
|
|
94
|
+
let insertListener: CollectionType["onInsert"];
|
|
95
|
+
let updateListener: CollectionType["onUpdate"];
|
|
96
|
+
let deleteListener: CollectionType["onDelete"];
|
|
97
|
+
|
|
98
|
+
let syncBegin: (() => void) | null = null;
|
|
99
|
+
let syncWrite:
|
|
100
|
+
| ((op: { type: "insert" | "update" | "delete"; value: TItem }) => void)
|
|
101
|
+
| null = null;
|
|
102
|
+
let syncCommit: (() => void) | null = null;
|
|
103
|
+
let syncTruncate: (() => void) | null = null;
|
|
104
|
+
|
|
105
|
+
const syncFn: SyncConfig<TItem, string>["sync"] = (params) => {
|
|
106
|
+
const { begin, write, commit, markReady, truncate } = params;
|
|
107
|
+
|
|
108
|
+
syncBegin = begin;
|
|
109
|
+
syncWrite = write;
|
|
110
|
+
syncCommit = commit;
|
|
111
|
+
syncTruncate = truncate;
|
|
112
|
+
|
|
113
|
+
const initialSync = async () => {
|
|
114
|
+
await config.readyPromise;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const items = await backend.initialLoad();
|
|
118
|
+
|
|
119
|
+
begin();
|
|
120
|
+
|
|
121
|
+
for (const item of items) {
|
|
122
|
+
write({
|
|
123
|
+
type: "insert",
|
|
124
|
+
value: item,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
commit();
|
|
129
|
+
} finally {
|
|
130
|
+
markReady();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (config.syncMode === "eager" || !config.syncMode) {
|
|
135
|
+
initialSync();
|
|
136
|
+
} else {
|
|
137
|
+
markReady();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
insertListener = async (params) => {
|
|
141
|
+
const results = await backend.handleInsert(
|
|
142
|
+
params.transaction.mutations.map((m) => m.modified),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
begin();
|
|
146
|
+
for (const result of results) {
|
|
147
|
+
write({
|
|
148
|
+
type: "insert",
|
|
149
|
+
value: result,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
commit();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
updateListener = async (params) => {
|
|
156
|
+
const results = await backend.handleUpdate(params.transaction.mutations);
|
|
157
|
+
|
|
158
|
+
begin();
|
|
159
|
+
for (const result of results) {
|
|
160
|
+
write({
|
|
161
|
+
type: "update",
|
|
162
|
+
value: result,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
commit();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
deleteListener = async (params) => {
|
|
169
|
+
await backend.handleDelete(params.transaction.mutations);
|
|
170
|
+
|
|
171
|
+
begin();
|
|
172
|
+
for (const item of params.transaction.mutations) {
|
|
173
|
+
write({
|
|
174
|
+
type: "delete",
|
|
175
|
+
value: item.modified,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
commit();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const loadSubset = async (options: LoadSubsetOptions) => {
|
|
182
|
+
await config.readyPromise;
|
|
183
|
+
|
|
184
|
+
const items = await backend.loadSubset(options);
|
|
185
|
+
|
|
186
|
+
begin();
|
|
187
|
+
|
|
188
|
+
for (const item of items) {
|
|
189
|
+
write({
|
|
190
|
+
type: "insert",
|
|
191
|
+
value: item,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
commit();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
let loadSubsetDedupe: DeduplicatedLoadSubset | null = null;
|
|
199
|
+
if (USE_DEDUPE) {
|
|
200
|
+
loadSubsetDedupe = new DeduplicatedLoadSubset({
|
|
201
|
+
loadSubset,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
cleanup: () => {
|
|
207
|
+
insertListener = undefined;
|
|
208
|
+
updateListener = undefined;
|
|
209
|
+
deleteListener = undefined;
|
|
210
|
+
loadSubsetDedupe?.reset();
|
|
211
|
+
},
|
|
212
|
+
loadSubset: loadSubsetDedupe?.loadSubset ?? loadSubset,
|
|
213
|
+
} satisfies SyncConfigRes;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const receiveSync = async (messages: SyncMessage<TItem>[]) => {
|
|
217
|
+
if (messages.length === 0) return;
|
|
218
|
+
if (!syncBegin || !syncWrite || !syncCommit || !syncTruncate) {
|
|
219
|
+
if (config.debug) {
|
|
220
|
+
console.warn(
|
|
221
|
+
"[receiveSync] Sync functions not initialized yet - messages will be dropped",
|
|
222
|
+
messages.length,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
syncBegin();
|
|
228
|
+
for (const msg of messages) {
|
|
229
|
+
switch (msg.type) {
|
|
230
|
+
case "insert":
|
|
231
|
+
syncWrite({ type: "insert", value: msg.value });
|
|
232
|
+
break;
|
|
233
|
+
case "update":
|
|
234
|
+
syncWrite({ type: "update", value: msg.value });
|
|
235
|
+
break;
|
|
236
|
+
case "delete":
|
|
237
|
+
syncWrite({
|
|
238
|
+
type: "delete",
|
|
239
|
+
value: { id: msg.key } as TItem,
|
|
240
|
+
});
|
|
241
|
+
break;
|
|
242
|
+
case "truncate":
|
|
243
|
+
syncTruncate();
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
exhaustiveGuard(msg);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
syncCommit();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const utils: CollectionUtils<TItem> = {
|
|
253
|
+
truncate: async () => {
|
|
254
|
+
if (!backend.handleTruncate) {
|
|
255
|
+
throw new Error("Truncate not supported by this backend");
|
|
256
|
+
}
|
|
257
|
+
if (!syncBegin || !syncTruncate || !syncCommit) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
"Sync functions not initialized - sync function may not have been called yet",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
await backend.handleTruncate();
|
|
263
|
+
syncBegin();
|
|
264
|
+
syncTruncate();
|
|
265
|
+
syncCommit();
|
|
266
|
+
},
|
|
267
|
+
receiveSync,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
sync: syncFn,
|
|
272
|
+
onInsert: async (params) => {
|
|
273
|
+
if (!insertListener) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"insertListener not initialized - sync function may not have been called yet",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return insertListener(params);
|
|
279
|
+
},
|
|
280
|
+
onUpdate: async (params) => {
|
|
281
|
+
if (!updateListener) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
"updateListener not initialized - sync function may not have been called yet",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return updateListener(params);
|
|
287
|
+
},
|
|
288
|
+
onDelete: async (params) => {
|
|
289
|
+
if (!deleteListener) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
"deleteListener not initialized - sync function may not have been called yet",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return deleteListener(params);
|
|
295
|
+
},
|
|
296
|
+
utils,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Generic collection config factory.
|
|
302
|
+
* Combines schema, sync, and event handlers into a collection config.
|
|
303
|
+
* No Drizzle dependency -- uses StandardSchemaV1 directly.
|
|
304
|
+
*/
|
|
305
|
+
export function createGenericCollectionConfig<
|
|
306
|
+
TItem extends object,
|
|
307
|
+
TSchema extends StandardSchemaV1,
|
|
308
|
+
>(config: {
|
|
309
|
+
schema: TSchema;
|
|
310
|
+
getKey: (item: TItem) => string;
|
|
311
|
+
syncResult: GenericSyncFunctionResult<TItem>;
|
|
312
|
+
onInsert?: CollectionConfig<
|
|
313
|
+
TItem,
|
|
314
|
+
string,
|
|
315
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
|
|
316
|
+
any
|
|
317
|
+
>["onInsert"];
|
|
318
|
+
onUpdate?: CollectionConfig<
|
|
319
|
+
TItem,
|
|
320
|
+
string,
|
|
321
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
|
|
322
|
+
any
|
|
323
|
+
>["onUpdate"];
|
|
324
|
+
onDelete?: CollectionConfig<
|
|
325
|
+
TItem,
|
|
326
|
+
string,
|
|
327
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
|
|
328
|
+
any
|
|
329
|
+
>["onDelete"];
|
|
330
|
+
syncMode?: SyncMode;
|
|
331
|
+
}): Omit<
|
|
332
|
+
CollectionConfig<
|
|
333
|
+
TItem,
|
|
334
|
+
string,
|
|
335
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
|
|
336
|
+
any
|
|
337
|
+
>,
|
|
338
|
+
"utils"
|
|
339
|
+
> & {
|
|
340
|
+
schema: TSchema;
|
|
341
|
+
utils: CollectionUtils<InferSchemaOutput<TSchema>>;
|
|
342
|
+
} {
|
|
343
|
+
return {
|
|
344
|
+
schema: config.schema,
|
|
345
|
+
getKey: config.getKey,
|
|
346
|
+
sync: {
|
|
347
|
+
sync: config.syncResult.sync,
|
|
348
|
+
},
|
|
349
|
+
onInsert: config.onInsert ?? config.syncResult.onInsert,
|
|
350
|
+
onUpdate: config.onUpdate ?? config.syncResult.onUpdate,
|
|
351
|
+
onDelete: config.onDelete ?? config.syncResult.onDelete,
|
|
352
|
+
syncMode: config.syncMode,
|
|
353
|
+
utils: config.syncResult.utils as CollectionUtils<
|
|
354
|
+
InferSchemaOutput<TSchema>
|
|
355
|
+
>,
|
|
356
|
+
};
|
|
357
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CollectionUtils,
|
|
3
|
+
ExternalSyncEvent,
|
|
4
|
+
ExternalSyncHandler,
|
|
5
|
+
SyncMessage,
|
|
6
|
+
} from "./sync-types";
|
|
1
7
|
export {
|
|
2
8
|
createMemoryCollection,
|
|
3
9
|
memoryCollectionOptions,
|
|
4
10
|
type MemoryCollection,
|
|
5
11
|
} from "./memoryCollection";
|
|
12
|
+
|
|
13
|
+
export { evaluateExpression, getExpressionValue } from "./ir-evaluator";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
USE_DEDUPE,
|
|
17
|
+
createGenericSyncFunction,
|
|
18
|
+
createGenericCollectionConfig,
|
|
19
|
+
type GenericBaseSyncConfig,
|
|
20
|
+
type GenericSyncBackend,
|
|
21
|
+
type GenericSyncFunctionResult,
|
|
22
|
+
} from "./generic-sync";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { IR } from "@tanstack/db";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Evaluates a TanStack DB IR expression against a plain object item.
|
|
5
|
+
* @internal Exported for testing and reuse by collection backends
|
|
6
|
+
*/
|
|
7
|
+
export function evaluateExpression(
|
|
8
|
+
expression: IR.BasicExpression,
|
|
9
|
+
item: Record<string, unknown>,
|
|
10
|
+
): boolean {
|
|
11
|
+
switch (expression.type) {
|
|
12
|
+
case "ref": {
|
|
13
|
+
const propRef = expression;
|
|
14
|
+
const columnName = propRef.path[propRef.path.length - 1];
|
|
15
|
+
return item[columnName as string] !== undefined;
|
|
16
|
+
}
|
|
17
|
+
case "val": {
|
|
18
|
+
const value = expression;
|
|
19
|
+
return !!value.value;
|
|
20
|
+
}
|
|
21
|
+
case "func": {
|
|
22
|
+
const func = expression;
|
|
23
|
+
|
|
24
|
+
switch (func.name) {
|
|
25
|
+
case "eq": {
|
|
26
|
+
const left = getExpressionValue(func.args[0], item);
|
|
27
|
+
const right = getExpressionValue(func.args[1], item);
|
|
28
|
+
return left === right;
|
|
29
|
+
}
|
|
30
|
+
case "ne": {
|
|
31
|
+
const left = getExpressionValue(func.args[0], item);
|
|
32
|
+
const right = getExpressionValue(func.args[1], item);
|
|
33
|
+
return left !== right;
|
|
34
|
+
}
|
|
35
|
+
case "gt": {
|
|
36
|
+
const left = getExpressionValue(func.args[0], item);
|
|
37
|
+
const right = getExpressionValue(func.args[1], item);
|
|
38
|
+
return left > right;
|
|
39
|
+
}
|
|
40
|
+
case "gte": {
|
|
41
|
+
const left = getExpressionValue(func.args[0], item);
|
|
42
|
+
const right = getExpressionValue(func.args[1], item);
|
|
43
|
+
return left >= right;
|
|
44
|
+
}
|
|
45
|
+
case "lt": {
|
|
46
|
+
const left = getExpressionValue(func.args[0], item);
|
|
47
|
+
const right = getExpressionValue(func.args[1], item);
|
|
48
|
+
return left < right;
|
|
49
|
+
}
|
|
50
|
+
case "lte": {
|
|
51
|
+
const left = getExpressionValue(func.args[0], item);
|
|
52
|
+
const right = getExpressionValue(func.args[1], item);
|
|
53
|
+
return left <= right;
|
|
54
|
+
}
|
|
55
|
+
case "and": {
|
|
56
|
+
return func.args.every((arg) => evaluateExpression(arg, item));
|
|
57
|
+
}
|
|
58
|
+
case "or": {
|
|
59
|
+
return func.args.some((arg) => evaluateExpression(arg, item));
|
|
60
|
+
}
|
|
61
|
+
case "not": {
|
|
62
|
+
return !evaluateExpression(func.args[0], item);
|
|
63
|
+
}
|
|
64
|
+
case "isNull": {
|
|
65
|
+
const value = getExpressionValue(func.args[0], item);
|
|
66
|
+
return value === null || value === undefined;
|
|
67
|
+
}
|
|
68
|
+
case "isNotNull": {
|
|
69
|
+
const value = getExpressionValue(func.args[0], item);
|
|
70
|
+
return value !== null && value !== undefined;
|
|
71
|
+
}
|
|
72
|
+
case "like": {
|
|
73
|
+
const left = String(getExpressionValue(func.args[0], item));
|
|
74
|
+
const right = String(getExpressionValue(func.args[1], item));
|
|
75
|
+
const pattern = right.replace(/%/g, ".*").replace(/_/g, ".");
|
|
76
|
+
return new RegExp(`^${pattern}$`).test(left);
|
|
77
|
+
}
|
|
78
|
+
case "ilike": {
|
|
79
|
+
const left = String(getExpressionValue(func.args[0], item));
|
|
80
|
+
const right = String(getExpressionValue(func.args[1], item));
|
|
81
|
+
const pattern = right.replace(/%/g, ".*").replace(/_/g, ".");
|
|
82
|
+
return new RegExp(`^${pattern}$`, "i").test(left);
|
|
83
|
+
}
|
|
84
|
+
case "in": {
|
|
85
|
+
const left = getExpressionValue(func.args[0], item);
|
|
86
|
+
const right = getExpressionValue(func.args[1], item);
|
|
87
|
+
return Array.isArray(right) && right.includes(left);
|
|
88
|
+
}
|
|
89
|
+
case "isUndefined": {
|
|
90
|
+
const value = getExpressionValue(func.args[0], item);
|
|
91
|
+
return value === null || value === undefined;
|
|
92
|
+
}
|
|
93
|
+
default:
|
|
94
|
+
throw new Error(`Unsupported function: ${func.name}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
default: {
|
|
98
|
+
const _ex: never = expression;
|
|
99
|
+
void _ex;
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Unsupported expression type: ${(expression as { type: string }).type}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Gets the value from an IR expression by resolving refs and vals.
|
|
109
|
+
* @internal Exported for testing and reuse by collection backends
|
|
110
|
+
*/
|
|
111
|
+
export function getExpressionValue(
|
|
112
|
+
expression: IR.BasicExpression,
|
|
113
|
+
item: Record<string, unknown>,
|
|
114
|
+
// biome-ignore lint/suspicious/noExplicitAny: We need any here for dynamic values
|
|
115
|
+
): any {
|
|
116
|
+
switch (expression.type) {
|
|
117
|
+
case "ref": {
|
|
118
|
+
const propRef = expression;
|
|
119
|
+
const columnName = propRef.path[propRef.path.length - 1];
|
|
120
|
+
return item[columnName as string];
|
|
121
|
+
}
|
|
122
|
+
case "val": {
|
|
123
|
+
const value = expression;
|
|
124
|
+
return value.value;
|
|
125
|
+
}
|
|
126
|
+
case "func":
|
|
127
|
+
throw new Error("Cannot get value from func expression");
|
|
128
|
+
default: {
|
|
129
|
+
const _ex: never = expression;
|
|
130
|
+
void _ex;
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Cannot get value from expression type: ${(expression as { type: string }).type}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/memoryCollection.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SyncMessage } from "./sync-types";
|
|
2
|
+
import { exhaustiveGuard } from "@firtoz/maybe-error";
|
|
1
3
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
4
|
import {
|
|
3
5
|
type Collection,
|
|
@@ -16,72 +18,100 @@ type MemoryCollectionConfig<TSchema extends StandardSchemaV1> = Omit<
|
|
|
16
18
|
"onInsert" | "onUpdate" | "onDelete" | "sync" | "schema"
|
|
17
19
|
> & {
|
|
18
20
|
schema: TSchema;
|
|
21
|
+
/** Called when a local mutation is written to the sync layer; use to broadcast to other peers. */
|
|
22
|
+
onBroadcast?: (
|
|
23
|
+
changes: SyncMessage<InferSchemaOutput<TSchema>, string | number>[],
|
|
24
|
+
) => void;
|
|
19
25
|
};
|
|
20
26
|
|
|
21
|
-
type MemoryUtils
|
|
27
|
+
type MemoryUtils<
|
|
28
|
+
TItem = unknown,
|
|
29
|
+
TKey extends string | number = string | number,
|
|
30
|
+
> = {
|
|
22
31
|
truncate: () => Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Apply incoming sync messages without triggering onInsert/onUpdate/onDelete.
|
|
34
|
+
* Uses the sync layer (begin/write/commit) so state updates and subscribeChanges fire,
|
|
35
|
+
* but no rebroadcast occurs.
|
|
36
|
+
*/
|
|
37
|
+
receiveSync: (messages: SyncMessage<TItem, TKey>[]) => Promise<void>;
|
|
23
38
|
};
|
|
24
39
|
|
|
25
40
|
export function memoryCollectionOptions<TSchema extends StandardSchemaV1>(
|
|
26
41
|
config: MemoryCollectionConfig<TSchema>,
|
|
27
42
|
): CollectionConfig<InferSchemaOutput<TSchema>, string | number, TSchema> & {
|
|
28
|
-
utils: MemoryUtils
|
|
43
|
+
utils: MemoryUtils<InferSchemaOutput<TSchema>, string | number>;
|
|
29
44
|
schema: TSchema;
|
|
30
45
|
} {
|
|
31
46
|
type TItem = InferSchemaOutput<TSchema>;
|
|
47
|
+
type TKey = string | number;
|
|
32
48
|
let syncParams: Parameters<SyncConfig<TItem>["sync"]>[0] | null = null;
|
|
33
49
|
|
|
34
50
|
const sync: SyncConfig<TItem>["sync"] = (params) => {
|
|
35
51
|
syncParams = params;
|
|
36
|
-
|
|
37
52
|
params.markReady();
|
|
38
|
-
|
|
39
|
-
// Return cleanup function
|
|
40
53
|
return () => {};
|
|
41
54
|
};
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
const onInsert = async (params: InsertMutationFnParams<TItem>) => {
|
|
56
|
+
const writeChanges = (writes: SyncMessage<TItem, TKey>[]) => {
|
|
45
57
|
if (!syncParams) {
|
|
46
58
|
throw new Error("Sync parameters not initialized");
|
|
47
59
|
}
|
|
48
60
|
syncParams.begin();
|
|
49
|
-
for (const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
for (const msg of writes) {
|
|
62
|
+
switch (msg.type) {
|
|
63
|
+
case "insert":
|
|
64
|
+
syncParams.write({ type: "insert", value: msg.value });
|
|
65
|
+
break;
|
|
66
|
+
case "update":
|
|
67
|
+
syncParams.write({
|
|
68
|
+
type: "update",
|
|
69
|
+
value: msg.value,
|
|
70
|
+
previousValue: msg.previousValue,
|
|
71
|
+
});
|
|
72
|
+
break;
|
|
73
|
+
case "delete":
|
|
74
|
+
syncParams.write({ type: "delete", key: msg.key });
|
|
75
|
+
break;
|
|
76
|
+
case "truncate":
|
|
77
|
+
syncParams.truncate();
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
exhaustiveGuard(msg);
|
|
81
|
+
}
|
|
54
82
|
}
|
|
55
83
|
syncParams.commit();
|
|
56
84
|
};
|
|
57
85
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
86
|
+
const onInsert = async (params: InsertMutationFnParams<TItem>) => {
|
|
87
|
+
const writes: SyncMessage<TItem, TKey>[] = [];
|
|
88
|
+
for (const mutation of params.transaction.mutations) {
|
|
89
|
+
writes.push({ type: "insert", value: mutation.modified });
|
|
61
90
|
}
|
|
62
|
-
|
|
91
|
+
writeChanges(writes);
|
|
92
|
+
config.onBroadcast?.(writes);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const onUpdate = async (params: UpdateMutationFnParams<TItem>) => {
|
|
96
|
+
const writes: SyncMessage<TItem, TKey>[] = [];
|
|
63
97
|
for (const mutation of params.transaction.mutations) {
|
|
64
|
-
|
|
98
|
+
writes.push({
|
|
65
99
|
type: "update",
|
|
66
100
|
value: mutation.modified,
|
|
67
101
|
previousValue: mutation.original,
|
|
68
102
|
});
|
|
69
103
|
}
|
|
70
|
-
|
|
104
|
+
writeChanges(writes);
|
|
105
|
+
config.onBroadcast?.(writes);
|
|
71
106
|
};
|
|
72
107
|
|
|
73
108
|
const onDelete = async (params: DeleteMutationFnParams<TItem>) => {
|
|
74
|
-
|
|
75
|
-
throw new Error("Sync parameters not initialized");
|
|
76
|
-
}
|
|
77
|
-
syncParams.begin();
|
|
109
|
+
const writes: SyncMessage<TItem, TKey>[] = [];
|
|
78
110
|
for (const mutation of params.transaction.mutations) {
|
|
79
|
-
|
|
80
|
-
type: "delete",
|
|
81
|
-
key: mutation.key,
|
|
82
|
-
});
|
|
111
|
+
writes.push({ type: "delete", key: mutation.key as TKey });
|
|
83
112
|
}
|
|
84
|
-
|
|
113
|
+
writeChanges(writes);
|
|
114
|
+
config.onBroadcast?.(writes);
|
|
85
115
|
};
|
|
86
116
|
|
|
87
117
|
const truncate = async () => {
|
|
@@ -93,6 +123,11 @@ export function memoryCollectionOptions<TSchema extends StandardSchemaV1>(
|
|
|
93
123
|
syncParams.commit();
|
|
94
124
|
};
|
|
95
125
|
|
|
126
|
+
const receiveSync = async (messages: SyncMessage<TItem, TKey>[]) => {
|
|
127
|
+
if (messages.length === 0) return;
|
|
128
|
+
writeChanges(messages);
|
|
129
|
+
};
|
|
130
|
+
|
|
96
131
|
return {
|
|
97
132
|
id: config.id,
|
|
98
133
|
schema: config.schema,
|
|
@@ -103,6 +138,7 @@ export function memoryCollectionOptions<TSchema extends StandardSchemaV1>(
|
|
|
103
138
|
onDelete,
|
|
104
139
|
utils: {
|
|
105
140
|
truncate,
|
|
141
|
+
receiveSync,
|
|
106
142
|
},
|
|
107
143
|
};
|
|
108
144
|
}
|
|
@@ -110,7 +146,7 @@ export function memoryCollectionOptions<TSchema extends StandardSchemaV1>(
|
|
|
110
146
|
export type MemoryCollection<TSchema extends StandardSchemaV1> = Collection<
|
|
111
147
|
InferSchemaOutput<TSchema>,
|
|
112
148
|
string | number,
|
|
113
|
-
MemoryUtils
|
|
149
|
+
MemoryUtils<InferSchemaOutput<TSchema>, string | number>,
|
|
114
150
|
TSchema,
|
|
115
151
|
InferSchemaInput<TSchema>
|
|
116
152
|
>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic collection sync types. Used by memory, IndexedDB, SQLite, and other
|
|
3
|
+
* collection backends for a unified sync protocol.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Canonical per-mutation sync message. Use for broadcast and receive across all collection types.
|
|
8
|
+
*/
|
|
9
|
+
export type SyncMessage<
|
|
10
|
+
T = unknown,
|
|
11
|
+
TKey extends string | number = string | number,
|
|
12
|
+
> =
|
|
13
|
+
| { type: "insert"; value: T }
|
|
14
|
+
| { type: "update"; value: T; previousValue: T }
|
|
15
|
+
| { type: "delete"; key: TKey }
|
|
16
|
+
| { type: "truncate" };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* External sync event (batched). Used internally by the sync layer.
|
|
20
|
+
*/
|
|
21
|
+
export type ExternalSyncEvent<T> =
|
|
22
|
+
| { type: "insert"; items: T[] }
|
|
23
|
+
| { type: "update"; items: T[] }
|
|
24
|
+
| { type: "delete"; items: T[] }
|
|
25
|
+
| { type: "truncate" };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Handler for external sync events (internal use).
|
|
29
|
+
*/
|
|
30
|
+
export type ExternalSyncHandler<T> = (event: ExternalSyncEvent<T>) => void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Collection utils: truncate and receiveSync (canonical sync protocol).
|
|
34
|
+
*/
|
|
35
|
+
export interface CollectionUtils<T = unknown> {
|
|
36
|
+
/**
|
|
37
|
+
* Clear all data from the store (truncate).
|
|
38
|
+
* This clears the backend store and updates the local reactive store.
|
|
39
|
+
*/
|
|
40
|
+
truncate: () => Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Apply incoming sync messages without triggering mutation handlers.
|
|
43
|
+
* Use the same SyncMessage[] shape for memory and backend collections.
|
|
44
|
+
*/
|
|
45
|
+
receiveSync: (messages: SyncMessage<T>[]) => Promise<void>;
|
|
46
|
+
}
|