@firtoz/drizzle-durable-sqlite 0.2.1 → 1.0.1

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.
@@ -0,0 +1,442 @@
1
+ import type {
2
+ PartialSyncRowShape,
3
+ PartialSyncServerBridgeStore,
4
+ RangeCondition,
5
+ SyncRange,
6
+ SyncRangeSort,
7
+ } from "@firtoz/collection-sync";
8
+ import { compareInterestValues } from "@firtoz/collection-sync/partial-sync-interest";
9
+ import {
10
+ defaultPredicateColumnValue,
11
+ matchesPredicate,
12
+ } from "@firtoz/collection-sync/partial-sync-predicate-match";
13
+ import type { SyncMessage } from "@firtoz/db-helpers";
14
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
15
+ import {
16
+ and,
17
+ asc,
18
+ count,
19
+ desc,
20
+ eq,
21
+ gt,
22
+ lt,
23
+ max,
24
+ or,
25
+ type InferSelectModel,
26
+ type SQL,
27
+ } from "drizzle-orm";
28
+ import { getTableColumns } from "drizzle-orm";
29
+ import type { SQLiteTable } from "drizzle-orm/sqlite-core";
30
+ import type {
31
+ ChangelogOperation,
32
+ DrizzleChangelogHelper,
33
+ } from "./drizzle-partial-sync-changelog";
34
+ import type { PartialSyncSqliteDatabase } from "./partial-sync-sqlite-db";
35
+ import {
36
+ predicateWhereFromConditions,
37
+ sortColumnFromConfig,
38
+ type PartialSyncTableConfig,
39
+ } from "./partial-sync-predicate-sql";
40
+
41
+ export type CreateDrizzlePartialSyncStoreOptions<
42
+ TSchema extends Record<string, unknown>,
43
+ TRow extends PartialSyncRowShape,
44
+ > = {
45
+ db: PartialSyncSqliteDatabase<TSchema>;
46
+ table: SQLiteTable;
47
+ columnConfig: PartialSyncTableConfig;
48
+ changelogHelper: DrizzleChangelogHelper<TSchema>;
49
+ deserializeJson: (raw: string) => unknown;
50
+ /** Column name on `table` used for `changesSince` watermark (e.g. `updatedAt`). */
51
+ updatedAtColumnName: keyof TRow & string;
52
+ };
53
+
54
+ export function createDrizzlePartialSyncStore<
55
+ TSchema extends Record<string, unknown>,
56
+ TRow extends PartialSyncRowShape,
57
+ >(
58
+ options: CreateDrizzlePartialSyncStoreOptions<TSchema, TRow>,
59
+ ): PartialSyncServerBridgeStore<TRow> {
60
+ const { db, table, columnConfig, changelogHelper, deserializeJson } = options;
61
+ const tableColumns = getTableColumns(table);
62
+ const idCol = tableColumns.id;
63
+ const updatedAtCol = tableColumns[options.updatedAtColumnName];
64
+ if (idCol === undefined) {
65
+ throw new Error("Partial sync table must have an id column");
66
+ }
67
+ if (updatedAtCol === undefined) {
68
+ throw new Error(
69
+ `Partial sync table missing updatedAt column: ${String(options.updatedAtColumnName)}`,
70
+ );
71
+ }
72
+
73
+ async function* queryRange(opts: {
74
+ sort: SyncRangeSort;
75
+ limit: number;
76
+ afterCursor: unknown | null;
77
+ chunkSize: number;
78
+ }): AsyncIterable<TRow[]> {
79
+ let remaining = opts.limit;
80
+ let cursor = opts.afterCursor;
81
+ const sortColumn = sortColumnFromConfig(
82
+ table,
83
+ opts.sort.column,
84
+ columnConfig,
85
+ );
86
+ while (remaining > 0) {
87
+ const currentLimit = Math.min(opts.chunkSize, remaining);
88
+ const directionExpr =
89
+ opts.sort.direction === "asc" ? asc(sortColumn) : desc(sortColumn);
90
+ const whereCursor =
91
+ cursor === null
92
+ ? undefined
93
+ : opts.sort.direction === "asc"
94
+ ? gt(sortColumn, cursor as never)
95
+ : lt(sortColumn, cursor as never);
96
+ const rows = await db
97
+ .select()
98
+ .from(table)
99
+ .where(whereCursor ? and(whereCursor) : undefined)
100
+ .orderBy(directionExpr, asc(idCol))
101
+ .limit(currentLimit);
102
+ if (rows.length === 0) break;
103
+ yield rows as TRow[];
104
+ remaining -= rows.length;
105
+ if (rows.length < currentLimit) break;
106
+ const last = rows[rows.length - 1] as Record<string, unknown>;
107
+ cursor = last[opts.sort.column];
108
+ }
109
+ }
110
+
111
+ async function* queryByOffset(opts: {
112
+ sort: SyncRangeSort;
113
+ limit: number;
114
+ offset: number;
115
+ chunkSize: number;
116
+ }): AsyncIterable<TRow[]> {
117
+ let remaining = opts.limit;
118
+ let sqlOffset = opts.offset;
119
+ const sortColumn = sortColumnFromConfig(
120
+ table,
121
+ opts.sort.column,
122
+ columnConfig,
123
+ );
124
+ while (remaining > 0) {
125
+ const currentLimit = Math.min(opts.chunkSize, remaining);
126
+ const directionExpr =
127
+ opts.sort.direction === "asc" ? asc(sortColumn) : desc(sortColumn);
128
+ const rows = await db
129
+ .select()
130
+ .from(table)
131
+ .orderBy(directionExpr, asc(idCol))
132
+ .limit(currentLimit)
133
+ .offset(sqlOffset);
134
+ if (rows.length === 0) break;
135
+ yield rows as TRow[];
136
+ remaining -= rows.length;
137
+ sqlOffset += rows.length;
138
+ if (rows.length < currentLimit) break;
139
+ }
140
+ }
141
+
142
+ async function getTotalCount(): Promise<number> {
143
+ const rows = await db.select({ c: count() }).from(table);
144
+ return rows[0]?.c ?? 0;
145
+ }
146
+
147
+ function getSortValue(row: TRow, column: string): unknown {
148
+ return (row as Record<string, unknown>)[column];
149
+ }
150
+
151
+ async function* queryByPredicate(opts: {
152
+ conditions: RangeCondition[];
153
+ sort?: SyncRangeSort;
154
+ limit?: number;
155
+ chunkSize: number;
156
+ }): AsyncIterable<TRow[]> {
157
+ const limit = opts.limit ?? opts.chunkSize;
158
+ let remaining = limit;
159
+ let offset = 0;
160
+ const where = predicateWhereFromConditions(
161
+ table,
162
+ opts.conditions,
163
+ columnConfig,
164
+ );
165
+ const sortColumnName = opts.sort?.column ?? columnConfig.sortableColumns[0];
166
+ if (sortColumnName === undefined) {
167
+ throw new Error("queryByPredicate requires sort or sortableColumns[0]");
168
+ }
169
+ const sortColumn = sortColumnFromConfig(
170
+ table,
171
+ sortColumnName,
172
+ columnConfig,
173
+ );
174
+ const directionExpr =
175
+ opts.sort?.direction === "desc" ? desc(sortColumn) : asc(sortColumn);
176
+ while (remaining > 0) {
177
+ const currentLimit = Math.min(opts.chunkSize, remaining);
178
+ const rows = await db
179
+ .select()
180
+ .from(table)
181
+ .where(where)
182
+ .orderBy(directionExpr, asc(idCol))
183
+ .limit(currentLimit)
184
+ .offset(offset);
185
+ if (rows.length === 0) break;
186
+ yield rows as TRow[];
187
+ remaining -= rows.length;
188
+ offset += rows.length;
189
+ if (rows.length < currentLimit) break;
190
+ }
191
+ }
192
+
193
+ async function getPredicateCount(
194
+ conditions: RangeCondition[],
195
+ ): Promise<number> {
196
+ const where = predicateWhereFromConditions(table, conditions, columnConfig);
197
+ const rows = await db.select({ c: count() }).from(table).where(where);
198
+ return rows[0]?.c ?? 0;
199
+ }
200
+
201
+ function strictlyBeforeInSortOrder(
202
+ sortCol: ReturnType<typeof sortColumnFromConfig>,
203
+ idColSQLite: typeof idCol,
204
+ rowSort: unknown,
205
+ rowId: unknown,
206
+ direction: "asc" | "desc",
207
+ ): SQL {
208
+ if (direction === "asc") {
209
+ return or(
210
+ lt(sortCol, rowSort as never),
211
+ and(eq(sortCol, rowSort as never), lt(idColSQLite, rowId as never)),
212
+ ) as SQL;
213
+ }
214
+ return or(
215
+ gt(sortCol, rowSort as never),
216
+ and(eq(sortCol, rowSort as never), lt(idColSQLite, rowId as never)),
217
+ ) as SQL;
218
+ }
219
+
220
+ async function rowInIndexRange(
221
+ row: TRow,
222
+ indexRange: Extract<SyncRange, { kind: "index" }>,
223
+ ): Promise<boolean> {
224
+ const sortName = indexRange.sort.column;
225
+ const rowRec = row as Record<string, unknown>;
226
+ const rowSort = rowRec[sortName];
227
+ const rowId = rowRec.id;
228
+ const sortCol = sortColumnFromConfig(table, sortName, columnConfig);
229
+
230
+ if (indexRange.mode === "cursor") {
231
+ const ac = indexRange.afterCursor;
232
+ if (ac !== null) {
233
+ const cmp = compareInterestValues(rowSort, ac);
234
+ if (indexRange.sort.direction === "asc" && cmp <= 0) return false;
235
+ if (indexRange.sort.direction === "desc" && cmp >= 0) return false;
236
+ }
237
+ }
238
+
239
+ const before = strictlyBeforeInSortOrder(
240
+ sortCol,
241
+ idCol,
242
+ rowSort,
243
+ rowId,
244
+ indexRange.sort.direction,
245
+ );
246
+ let whereRank: SQL = before;
247
+ if (indexRange.mode === "cursor" && indexRange.afterCursor !== null) {
248
+ const eligible =
249
+ indexRange.sort.direction === "asc"
250
+ ? gt(sortCol, indexRange.afterCursor as never)
251
+ : lt(sortCol, indexRange.afterCursor as never);
252
+ whereRank = and(eligible, before) as SQL;
253
+ }
254
+ const rankRows = await db
255
+ .select({ c: count() })
256
+ .from(table)
257
+ .where(whereRank);
258
+ const rank = rankRows[0]?.c ?? 0;
259
+
260
+ if (indexRange.mode === "offset") {
261
+ return (
262
+ rank >= indexRange.offset && rank < indexRange.offset + indexRange.limit
263
+ );
264
+ }
265
+ return rank < indexRange.limit;
266
+ }
267
+
268
+ async function changelogEntryMatchesRange(
269
+ op: ChangelogOperation,
270
+ payloadJson: unknown,
271
+ range: SyncRange,
272
+ ): Promise<boolean> {
273
+ if (range.kind === "predicate") {
274
+ const conds = range.conditions;
275
+ switch (op) {
276
+ case "delete": {
277
+ if (payloadJson === null || typeof payloadJson !== "string") {
278
+ return false;
279
+ }
280
+ const prev = deserializeJson(payloadJson) as TRow;
281
+ return matchesPredicate(prev, conds, defaultPredicateColumnValue);
282
+ }
283
+ case "insert": {
284
+ if (payloadJson === null || typeof payloadJson !== "string") {
285
+ return false;
286
+ }
287
+ const value = deserializeJson(payloadJson) as TRow;
288
+ return matchesPredicate(value, conds, defaultPredicateColumnValue);
289
+ }
290
+ case "update": {
291
+ if (payloadJson === null || typeof payloadJson !== "string") {
292
+ return false;
293
+ }
294
+ const parsed = deserializeJson(payloadJson) as {
295
+ value: TRow;
296
+ previousValue: TRow;
297
+ };
298
+ return (
299
+ matchesPredicate(
300
+ parsed.value,
301
+ conds,
302
+ defaultPredicateColumnValue,
303
+ ) ||
304
+ matchesPredicate(
305
+ parsed.previousValue,
306
+ conds,
307
+ defaultPredicateColumnValue,
308
+ )
309
+ );
310
+ }
311
+ default:
312
+ exhaustiveGuard(op);
313
+ }
314
+ }
315
+ if (range.kind === "index") {
316
+ switch (op) {
317
+ case "delete": {
318
+ if (payloadJson === null || typeof payloadJson !== "string") {
319
+ return false;
320
+ }
321
+ const prev = deserializeJson(payloadJson) as TRow;
322
+ return rowInIndexRange(prev, range);
323
+ }
324
+ case "insert": {
325
+ if (payloadJson === null || typeof payloadJson !== "string") {
326
+ return false;
327
+ }
328
+ const value = deserializeJson(payloadJson) as TRow;
329
+ return rowInIndexRange(value, range);
330
+ }
331
+ case "update": {
332
+ if (payloadJson === null || typeof payloadJson !== "string") {
333
+ return false;
334
+ }
335
+ const parsed = deserializeJson(payloadJson) as {
336
+ value: TRow;
337
+ previousValue: TRow;
338
+ };
339
+ return (
340
+ (await rowInIndexRange(parsed.value, range)) ||
341
+ (await rowInIndexRange(parsed.previousValue, range))
342
+ );
343
+ }
344
+ default:
345
+ exhaustiveGuard(op);
346
+ }
347
+ }
348
+ return false;
349
+ }
350
+
351
+ async function changesSince(opts: {
352
+ range: SyncRange;
353
+ sinceVersion: number;
354
+ chunkSize: number;
355
+ }): Promise<{ changes: SyncMessage<TRow>[]; totalCount: number } | null> {
356
+ const totalCount = await getTotalCount();
357
+ const maxRow = await db.select({ m: max(updatedAtCol) }).from(table);
358
+ const m = maxRow[0]?.m;
359
+ const maxMs = m instanceof Date ? m.getTime() : Number(m ?? 0);
360
+ if (opts.sinceVersion >= maxMs) {
361
+ return { changes: [], totalCount };
362
+ }
363
+ const logRows = await changelogHelper.selectAfterVersion(opts.sinceVersion);
364
+ if (logRows.length === 0) {
365
+ return null;
366
+ }
367
+ const changes: SyncMessage<TRow>[] = [];
368
+ for (const entry of logRows) {
369
+ const row = entry as Record<string, unknown>;
370
+ const op = row.operation;
371
+ const rowId = row.rowId;
372
+ const payloadJson = row.payloadJson;
373
+ if (typeof op !== "string" || typeof rowId !== "string") {
374
+ throw new Error("Invalid changelog row shape");
375
+ }
376
+ if (op !== "insert" && op !== "update" && op !== "delete") {
377
+ throw new Error(`Unknown changelog operation: ${op}`);
378
+ }
379
+ if (
380
+ !(await changelogEntryMatchesRange(
381
+ op as ChangelogOperation,
382
+ payloadJson,
383
+ opts.range,
384
+ ))
385
+ ) {
386
+ continue;
387
+ }
388
+ switch (op) {
389
+ case "delete":
390
+ changes.push({ type: "delete", key: rowId });
391
+ break;
392
+ case "insert": {
393
+ if (payloadJson === null || typeof payloadJson !== "string") break;
394
+ const value = deserializeJson(payloadJson) as TRow;
395
+ changes.push({ type: "insert", value });
396
+ break;
397
+ }
398
+ case "update": {
399
+ if (payloadJson === null || typeof payloadJson !== "string") break;
400
+ const parsed = deserializeJson(payloadJson) as {
401
+ value: TRow;
402
+ previousValue: TRow;
403
+ };
404
+ changes.push({
405
+ type: "update",
406
+ value: parsed.value,
407
+ previousValue: parsed.previousValue,
408
+ });
409
+ break;
410
+ }
411
+ default:
412
+ exhaustiveGuard(op);
413
+ }
414
+ }
415
+ return { changes, totalCount };
416
+ }
417
+
418
+ async function getRow(key: string | number): Promise<TRow | undefined> {
419
+ const rows = await db
420
+ .select()
421
+ .from(table)
422
+ .where(eq(idCol, key as never))
423
+ .limit(1);
424
+ const r = rows[0];
425
+ return r !== undefined ? (r as TRow) : undefined;
426
+ }
427
+
428
+ return {
429
+ queryRange,
430
+ queryByOffset,
431
+ getTotalCount,
432
+ getSortValue,
433
+ queryByPredicate,
434
+ getPredicateCount,
435
+ changesSince,
436
+ getRow,
437
+ };
438
+ }
439
+
440
+ /** Infer row type from a Drizzle SQLite table. */
441
+ export type InferPartialSyncRow<TTable extends SQLiteTable> =
442
+ InferSelectModel<TTable>;
@@ -1,6 +1,4 @@
1
1
  import type {
2
- Collection,
3
- InferSchemaInput,
4
2
  InferSchemaOutput,
5
3
  SyncMode,
6
4
  CollectionConfig,
@@ -72,15 +70,6 @@ export type DurableSqliteCollectionConfigResult<TTable extends Table> = Omit<
72
70
  utils: CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>;
73
71
  };
74
72
 
75
- export type DurableSqliteCollection<TTable extends TableWithRequiredFields> =
76
- Collection<
77
- InferSchemaOutput<SelectSchema<TTable>>,
78
- IdOf<TTable>,
79
- CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>,
80
- InsertToSelectSchema<TTable>,
81
- InferSchemaInput<InsertToSelectSchema<TTable>>
82
- >;
83
-
84
73
  /**
85
74
  * TanStack DB collection configuration for a table stored in Durable Object SQLite via Drizzle.
86
75
  *
@@ -101,6 +90,9 @@ export function durableSqliteCollectionOptions<
101
90
 
102
91
  const table = config.drizzle._.fullSchema[tableName] as TTable;
103
92
 
93
+ type TItem = InferSchemaOutput<SelectSchema<TTable>>;
94
+ const getKey = createGetKeyFunction<TTable>();
95
+
104
96
  const backend = createSqliteTableSyncBackend({
105
97
  drizzle: config.drizzle,
106
98
  table,
@@ -115,6 +107,7 @@ export function durableSqliteCollectionOptions<
115
107
  readyPromise: config.readyPromise ?? Promise.resolve(),
116
108
  syncMode: config.syncMode,
117
109
  debug: config.debug,
110
+ getSyncPersistKey: (item: TItem) => String(getKey(item)),
118
111
  };
119
112
 
120
113
  const syncResult = createSyncFunction(baseSyncConfig, backend);
@@ -123,7 +116,7 @@ export function durableSqliteCollectionOptions<
123
116
 
124
117
  return createCollectionConfig({
125
118
  schema,
126
- getKey: createGetKeyFunction<TTable>(),
119
+ getKey,
127
120
  syncResult,
128
121
  onInsert: config.debug
129
122
  ? async (params) => {
@@ -0,0 +1,91 @@
1
+ import type { SyncMessage } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
3
+
4
+ type MutationIntent<TItem> =
5
+ | {
6
+ clientMutationId: string;
7
+ type: "insert";
8
+ value: TItem;
9
+ }
10
+ | {
11
+ clientMutationId: string;
12
+ type: "update";
13
+ key: string | number;
14
+ value: TItem;
15
+ previousValue: TItem;
16
+ }
17
+ | {
18
+ clientMutationId: string;
19
+ type: "delete";
20
+ key: string | number;
21
+ }
22
+ | {
23
+ clientMutationId: string;
24
+ type: "truncate";
25
+ };
26
+
27
+ export interface DurableCollectionLike<TItem> {
28
+ insert: (item: Partial<TItem>) => { isPersisted: { promise: Promise<void> } };
29
+ update: (
30
+ key: string | number,
31
+ updater: (draft: TItem) => void,
32
+ ) => { isPersisted: { promise: Promise<void> } };
33
+ delete: (key: string | number) => { isPersisted: { promise: Promise<void> } };
34
+ utils: {
35
+ truncate: () => Promise<void>;
36
+ };
37
+ }
38
+
39
+ export async function applyDurableMutationIntents<
40
+ TItem extends { id: string | number },
41
+ >(
42
+ collection: DurableCollectionLike<TItem>,
43
+ intents: MutationIntent<TItem>[],
44
+ ): Promise<{
45
+ changes: SyncMessage<TItem>[];
46
+ acceptedMutationIds: string[];
47
+ }> {
48
+ const changes: SyncMessage<TItem>[] = [];
49
+ const acceptedMutationIds: string[] = [];
50
+
51
+ for (const intent of intents) {
52
+ switch (intent.type) {
53
+ case "insert": {
54
+ const tx = collection.insert(intent.value);
55
+ await tx.isPersisted.promise;
56
+ changes.push({ type: "insert", value: intent.value });
57
+ acceptedMutationIds.push(intent.clientMutationId);
58
+ break;
59
+ }
60
+ case "update": {
61
+ const tx = collection.update(intent.key, (draft) => {
62
+ Object.assign(draft, intent.value);
63
+ });
64
+ await tx.isPersisted.promise;
65
+ changes.push({
66
+ type: "update",
67
+ value: intent.value,
68
+ previousValue: intent.previousValue,
69
+ });
70
+ acceptedMutationIds.push(intent.clientMutationId);
71
+ break;
72
+ }
73
+ case "delete": {
74
+ const tx = collection.delete(intent.key);
75
+ await tx.isPersisted.promise;
76
+ changes.push({ type: "delete", key: intent.key });
77
+ acceptedMutationIds.push(intent.clientMutationId);
78
+ break;
79
+ }
80
+ case "truncate":
81
+ await collection.utils.truncate();
82
+ changes.push({ type: "truncate" });
83
+ acceptedMutationIds.push(intent.clientMutationId);
84
+ break;
85
+ default:
86
+ exhaustiveGuard(intent);
87
+ }
88
+ }
89
+
90
+ return { changes, acceptedMutationIds };
91
+ }
package/src/index.ts CHANGED
@@ -4,8 +4,54 @@ export {
4
4
  type DurableDrizzleSchema,
5
5
  type DurableSqliteCollectionConfig,
6
6
  type DurableSqliteCollectionConfigResult,
7
- type DurableSqliteCollection,
8
7
  type ValidTableNames,
9
8
  type SQLOperation,
10
9
  type SQLInterceptor,
11
10
  } from "./durable-sqlite-collection";
11
+
12
+ export {
13
+ applyDurableMutationIntents,
14
+ type DurableCollectionLike,
15
+ } from "./durable-sqlite-sync-server";
16
+
17
+ export {
18
+ SyncableDurableObject,
19
+ type SyncableDurableObjectConfig,
20
+ type SyncableDurableObjectSyncRow,
21
+ } from "./syncable-durable-object";
22
+
23
+ export {
24
+ QueryableDurableObject,
25
+ type QueryableDurableObjectConfig,
26
+ } from "./queryable-durable-object";
27
+
28
+ export {
29
+ createDrizzleChangelogHelper,
30
+ type ChangelogOperation,
31
+ type DrizzleChangelogHelper,
32
+ type DrizzleChangelogHelperOptions,
33
+ } from "./drizzle-partial-sync-changelog";
34
+
35
+ export type { PartialSyncSqliteDatabase } from "./partial-sync-sqlite-db";
36
+
37
+ export {
38
+ createDrizzleMutationStore,
39
+ type CreateDrizzleMutationStoreOptions,
40
+ } from "./drizzle-mutation-store";
41
+
42
+ export {
43
+ createDrizzlePartialSyncStore,
44
+ type CreateDrizzlePartialSyncStoreOptions,
45
+ type InferPartialSyncRow,
46
+ } from "./drizzle-partial-sync-store";
47
+
48
+ export {
49
+ coercePredicateScalar,
50
+ columnRefForPredicate,
51
+ predicateWhereFromConditions,
52
+ rangeConditionToSQL,
53
+ sortColumnFromConfig,
54
+ type PartialSyncColumnKind,
55
+ type PartialSyncTableColumnConfig,
56
+ type PartialSyncTableConfig,
57
+ } from "./partial-sync-predicate-sql";