@firtoz/drizzle-durable-sqlite 0.2.1 → 1.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/CHANGELOG.md +18 -0
- package/README.md +21 -11
- package/package.json +14 -10
- package/src/drizzle-mutation-store.ts +105 -0
- package/src/drizzle-partial-sync-changelog.ts +65 -0
- package/src/drizzle-partial-sync-store.ts +442 -0
- package/src/durable-sqlite-collection.ts +5 -12
- package/src/durable-sqlite-sync-server.ts +91 -0
- package/src/index.ts +47 -1
- package/src/partial-sync-predicate-sql.ts +157 -0
- package/src/partial-sync-sqlite-db.ts +8 -0
- package/src/queryable-durable-object.ts +413 -0
- package/src/syncable-durable-object.ts +284 -0
|
@@ -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
|
|
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";
|