@firtoz/drizzle-utils 1.1.0 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @firtoz/drizzle-utils
2
2
 
3
+ ## 1.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#70](https://github.com/firtoz/fullstack-toolkit/pull/70) [`138c394`](https://github.com/firtoz/fullstack-toolkit/commit/138c3944b491ebf2e76b7f2c00d651fd5d788bac) Thanks [@firtoz](https://github.com/firtoz)! - Raise TanStack DB peer range to `>=0.6.3` where applicable. `createGenericCollectionConfig` now sets `defaultIndexType: BasicIndex` and `autoIndex: "eager"` so Drizzle-backed collections match pre-0.6 indexing defaults for `orderBy`/`limit` live queries. Re-enable `DeduplicatedLoadSubset` (`USE_DEDUPE`) with `@tanstack/db` 0.6.4.
8
+
9
+ - Updated dependencies [[`138c394`](https://github.com/firtoz/fullstack-toolkit/commit/138c3944b491ebf2e76b7f2c00d651fd5d788bac)]:
10
+ - @firtoz/db-helpers@2.1.1
11
+
12
+ ## 1.2.0
13
+
14
+ ### Minor Changes
15
+
16
+ - [#64](https://github.com/firtoz/fullstack-toolkit/pull/64) [`556555a`](https://github.com/firtoz/fullstack-toolkit/commit/556555a2e09030a8658be8c07b5881e72be64b2f) Thanks [@firtoz](https://github.com/firtoz)! - **`@firtoz/drizzle-utils`:** Export `DrizzleSqliteTableCollection`; extend `BaseSyncConfig` / `createCollectionConfig` for typed `getSyncPersistKey` and `getKey` as `IdOf<TTable>`; avoid executing `syncableTable` default functions during table definition for Worker/DO globals; export collection helper types for Drizzle-backed TanStack collections.
17
+
18
+ **`@firtoz/drizzle-indexeddb` (major):** `deferLocalPersistence`, `handleBatchPut`, and related collection options; `receiveSync` persistence aligned with generic sync and partial-sync traffic; remove debug ingest usage. **Breaking** alongside the `@firtoz/drizzle-utils` sync/collection typing changes above (including `DrizzleSqliteTableCollection` and `BaseSyncConfig` expectations).
19
+
20
+ ### Patch Changes
21
+
22
+ - Updated dependencies [[`556555a`](https://github.com/firtoz/fullstack-toolkit/commit/556555a2e09030a8658be8c07b5881e72be64b2f)]:
23
+ - @firtoz/db-helpers@2.1.0
24
+
3
25
  ## 1.1.0
4
26
 
5
27
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/drizzle-utils",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Shared utilities and types for Drizzle-based packages",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -25,7 +25,7 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "scripts": {
28
- "typecheck": "tsc --noEmit -p ./tsconfig.json",
28
+ "typecheck": "tsgo --noEmit -p ./tsconfig.json",
29
29
  "lint": "biome check --write src",
30
30
  "lint:ci": "biome ci src",
31
31
  "format": "biome format src --write"
@@ -53,19 +53,19 @@
53
53
  "access": "public"
54
54
  },
55
55
  "peerDependencies": {
56
- "@tanstack/db": ">=0.5.33",
57
- "drizzle-orm": ">=0.45.1",
56
+ "@tanstack/db": ">=0.6.3",
57
+ "drizzle-orm": ">=0.45.2",
58
58
  "drizzle-valibot": ">=0.4.0",
59
59
  "valibot": ">=1.3.1"
60
60
  },
61
61
  "devDependencies": {
62
- "@tanstack/db": "^0.5.33",
63
- "drizzle-orm": "^0.45.1",
62
+ "@tanstack/db": "^0.6.4",
63
+ "drizzle-orm": "^0.45.2",
64
64
  "drizzle-valibot": "^0.4.2",
65
65
  "valibot": "^1.3.1"
66
66
  },
67
67
  "dependencies": {
68
- "@firtoz/db-helpers": "^2.0.0",
68
+ "@firtoz/db-helpers": "^2.1.1",
69
69
  "@firtoz/maybe-error": "^1.5.2"
70
70
  }
71
71
  }
@@ -108,7 +108,7 @@ export const USE_DEDUPE = _USE_DEDUPE;
108
108
  * Extends the generic (Drizzle-free) config with a Drizzle table reference.
109
109
  */
110
110
  export interface BaseSyncConfig<TTable extends Table>
111
- extends GenericBaseSyncConfig {
111
+ extends GenericBaseSyncConfig<InferSchemaOutput<SelectSchema<TTable>>> {
112
112
  table: TTable;
113
113
  }
114
114
 
@@ -236,10 +236,9 @@ export function createInsertSchemaWithIdDefault<TTable extends Table>(
236
236
  * Standard getKey function for collections
237
237
  */
238
238
  export function createGetKeyFunction<TTable extends Table>() {
239
- return (item: InferSchemaOutput<SelectSchema<TTable>>) => {
240
- const id = (item as { id: string }).id;
241
- return id;
242
- };
239
+ type TItem = InferSchemaOutput<SelectSchema<TTable>>;
240
+ type TKey = IdOf<TTable>;
241
+ return (item: TItem): TKey => (item as { id: TKey }).id;
243
242
  }
244
243
 
245
244
  /**
@@ -251,7 +250,7 @@ export function createCollectionConfig<
251
250
  TSchema extends v.GenericSchema<unknown>,
252
251
  >(config: {
253
252
  schema: TSchema;
254
- getKey: (item: InferSchemaOutput<SelectSchema<TTable>>) => string;
253
+ getKey: (item: InferSchemaOutput<SelectSchema<TTable>>) => IdOf<TTable>;
255
254
  syncResult: SyncFunctionResult<TTable>;
256
255
  onInsert?: CollectionConfig<
257
256
  InferSchemaOutput<SelectSchema<TTable>>,
@@ -270,17 +269,30 @@ export function createCollectionConfig<
270
269
  >["onDelete"];
271
270
  syncMode?: SyncMode;
272
271
  }): Omit<
273
- CollectionConfig<InferSchemaOutput<SelectSchema<TTable>>, string, TSchema>,
272
+ CollectionConfig<
273
+ InferSchemaOutput<SelectSchema<TTable>>,
274
+ IdOf<TTable>,
275
+ TSchema,
276
+ CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>
277
+ >,
274
278
  "utils"
275
279
  > & {
276
280
  schema: TSchema;
277
281
  utils: CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>;
278
282
  } {
279
283
  type TItem = InferSchemaOutput<SelectSchema<TTable>>;
280
- type ReturnType = Omit<CollectionConfig<TItem, string, TSchema>, "utils"> & {
284
+ type ReturnType = Omit<
285
+ CollectionConfig<TItem, IdOf<TTable>, TSchema, CollectionUtils<TItem>>,
286
+ "utils"
287
+ > & {
281
288
  schema: TSchema;
282
289
  utils: CollectionUtils<TItem>;
283
290
  };
284
291
 
285
- return createGenericCollectionConfig<TItem, TSchema>(config) as ReturnType;
292
+ const { getKey: getId, ...rest } = config;
293
+ return createGenericCollectionConfig<TItem, TSchema>({
294
+ ...rest,
295
+ // Generic sync is typed with string keys; runtime id may be number — same value as Drizzle row id.
296
+ getKey: (item: TItem) => getId(item) as string,
297
+ }) as ReturnType;
286
298
  }
@@ -0,0 +1,29 @@
1
+ import type { CollectionUtils } from "@firtoz/db-helpers";
2
+ import type {
3
+ Collection,
4
+ InferSchemaInput,
5
+ InferSchemaOutput,
6
+ } from "@tanstack/db";
7
+ import type {
8
+ IdOf,
9
+ InsertToSelectSchema,
10
+ SelectSchema,
11
+ } from "./collection-utils";
12
+ import type { TableWithRequiredFields } from "./syncableTable";
13
+
14
+ /**
15
+ * TanStack {@link Collection} for a syncable Drizzle SQLite table (`syncableTable` columns).
16
+ *
17
+ * Shared by `@firtoz/drizzle-sqlite-wasm` (async driver) and `@firtoz/drizzle-durable-sqlite`
18
+ * (Durable Object sync driver). Import this type from `@firtoz/drizzle-utils` rather than
19
+ * duplicating it per package.
20
+ */
21
+ export type DrizzleSqliteTableCollection<
22
+ TTable extends TableWithRequiredFields,
23
+ > = Collection<
24
+ InferSchemaOutput<SelectSchema<TTable>>,
25
+ IdOf<TTable>,
26
+ CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>,
27
+ InsertToSelectSchema<TTable>,
28
+ InferSchemaInput<InsertToSelectSchema<TTable>>
29
+ >;
package/src/index.ts CHANGED
@@ -49,6 +49,8 @@ export {
49
49
 
50
50
  export type { TableWithRequiredFields } from "./syncableTable";
51
51
 
52
+ export type { DrizzleSqliteTableCollection } from "./drizzle-sqlite-table-collection";
53
+
52
54
  export type {
53
55
  SQLOperation,
54
56
  SQLInterceptor,
@@ -1,5 +1,7 @@
1
+ import type { ReceiveSyncDurableOp } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
1
3
  import type { InferSchemaOutput } from "@tanstack/db";
2
- import { and, eq, type SQL } from "drizzle-orm";
4
+ import { and, eq, getTableColumns, sql, type SQL } from "drizzle-orm";
3
5
  import type {
4
6
  SQLiteInsertValue,
5
7
  SQLiteUpdateSetSource,
@@ -14,6 +16,22 @@ import type { SQLInterceptor } from "./types";
14
16
 
15
17
  export type SqliteDriverMode = "async" | "sync";
16
18
 
19
+ /**
20
+ * `ON CONFLICT DO UPDATE` set map using SQLite `excluded.*` so inserts are idempotent
21
+ * (matches IndexedDB `put` / replayed partial-sync rows already present from `initialLoad`).
22
+ */
23
+ function sqliteExcludedUpsertSet<TTable extends TableWithRequiredFields>(
24
+ table: TTable,
25
+ ): SQLiteUpdateSetSource<TTable> {
26
+ const cols = getTableColumns(table);
27
+ const set: Record<string, SQL> = {};
28
+ for (const [jsName, col] of Object.entries(cols)) {
29
+ if (jsName === "id") continue;
30
+ set[jsName] = sql.raw(`excluded."${col.name}"`);
31
+ }
32
+ return set as SQLiteUpdateSetSource<TTable>;
33
+ }
34
+
17
35
  export interface SqliteTableSyncBackendConfig<
18
36
  TTable extends TableWithRequiredFields,
19
37
  > {
@@ -35,12 +53,17 @@ export interface SqliteTableSyncBackendConfig<
35
53
  export function createSqliteTableSyncBackend<
36
54
  TTable extends TableWithRequiredFields,
37
55
  >(config: SqliteTableSyncBackendConfig<TTable>): SyncBackend<TTable> {
56
+ type TItem = InferSchemaOutput<SelectSchema<TTable>>;
38
57
  const table = config.table;
39
58
  const driverMode = config.driverMode;
40
59
 
41
60
  let transactionQueue = Promise.resolve();
42
- const queueTransaction = <T>(fn: () => Promise<T>): Promise<T> => {
43
- const result = transactionQueue.then(fn, fn);
61
+ const queueTransaction = <T>(
62
+ _label: string,
63
+ fn: () => Promise<T>,
64
+ ): Promise<T> => {
65
+ const run = (): Promise<T> => fn();
66
+ const result = transactionQueue.then(run, run);
44
67
  transactionQueue = result.then(
45
68
  () => {},
46
69
  () => {},
@@ -183,7 +206,7 @@ export function createSqliteTableSyncBackend<
183
206
  handleInsert: async (items) => {
184
207
  const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
185
208
 
186
- await queueTransaction(async () => {
209
+ await queueTransaction("handleInsert", async () => {
187
210
  if (driverMode === "sync") {
188
211
  config.drizzle.transaction((tx: typeof config.drizzle) => {
189
212
  for (const itemToInsert of items) {
@@ -198,6 +221,10 @@ export function createSqliteTableSyncBackend<
198
221
  .values(
199
222
  itemToInsert as unknown as SQLiteInsertValue<typeof table>,
200
223
  )
224
+ .onConflictDoUpdate({
225
+ target: table.id,
226
+ set: sqliteExcludedUpsertSet(table),
227
+ })
201
228
  .returning()
202
229
  .all() as Array<InferSchemaOutput<SelectSchema<TTable>>>;
203
230
  if (config.debug) {
@@ -226,6 +253,10 @@ export function createSqliteTableSyncBackend<
226
253
  .values(
227
254
  itemToInsert as unknown as SQLiteInsertValue<typeof table>,
228
255
  )
256
+ .onConflictDoUpdate({
257
+ target: table.id,
258
+ set: sqliteExcludedUpsertSet(table),
259
+ })
229
260
  .returning()) as Array<
230
261
  InferSchemaOutput<SelectSchema<TTable>>
231
262
  >;
@@ -254,7 +285,7 @@ export function createSqliteTableSyncBackend<
254
285
  handleUpdate: async (mutations) => {
255
286
  const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
256
287
 
257
- await queueTransaction(async () => {
288
+ await queueTransaction("handleUpdate", async () => {
258
289
  if (driverMode === "sync") {
259
290
  config.drizzle.transaction((tx: typeof config.drizzle) => {
260
291
  for (const mutation of mutations) {
@@ -327,7 +358,7 @@ export function createSqliteTableSyncBackend<
327
358
  },
328
359
 
329
360
  handleDelete: async (mutations) => {
330
- await queueTransaction(async () => {
361
+ await queueTransaction("handleDelete", async () => {
331
362
  if (driverMode === "sync") {
332
363
  config.drizzle.transaction((tx: typeof config.drizzle) => {
333
364
  for (const mutation of mutations) {
@@ -350,6 +381,149 @@ export function createSqliteTableSyncBackend<
350
381
  );
351
382
  }
352
383
 
384
+ if (config.checkpoint) {
385
+ await config.checkpoint();
386
+ }
387
+ });
388
+ },
389
+ handleTruncate: async () => {
390
+ await queueTransaction("handleTruncate", async () => {
391
+ if (driverMode === "sync") {
392
+ config.drizzle.transaction((tx: typeof config.drizzle) => {
393
+ tx.delete(table).run();
394
+ });
395
+ } else {
396
+ await config.drizzle.transaction(
397
+ async (tx: typeof config.drizzle) => {
398
+ await tx.delete(table);
399
+ },
400
+ );
401
+ }
402
+
403
+ if (config.checkpoint) {
404
+ await config.checkpoint();
405
+ }
406
+ });
407
+ },
408
+
409
+ applyReceiveSyncDurableWrites: async (
410
+ ops: ReceiveSyncDurableOp<TItem>[],
411
+ ) => {
412
+ if (ops.length === 0) return;
413
+
414
+ await queueTransaction("applyReceiveSyncDurableWrites", async () => {
415
+ if (driverMode === "sync") {
416
+ config.drizzle.transaction((tx: typeof config.drizzle) => {
417
+ for (const op of ops) {
418
+ switch (op.type) {
419
+ case "insert": {
420
+ if (config.debug) {
421
+ console.log(
422
+ `[${new Date().toISOString()}] receiveSync batch insert`,
423
+ op.value,
424
+ );
425
+ }
426
+ tx.insert(table)
427
+ .values(
428
+ op.value as unknown as SQLiteInsertValue<typeof table>,
429
+ )
430
+ .onConflictDoUpdate({
431
+ target: table.id,
432
+ set: sqliteExcludedUpsertSet(table),
433
+ })
434
+ .run();
435
+ break;
436
+ }
437
+ case "update": {
438
+ if (config.debug) {
439
+ console.log(
440
+ `[${new Date().toISOString()}] receiveSync batch update`,
441
+ op,
442
+ );
443
+ }
444
+ const updateTime = new Date();
445
+ tx.update(table)
446
+ .set({
447
+ ...op.changes,
448
+ updatedAt: updateTime,
449
+ } as SQLiteUpdateSetSource<typeof table>)
450
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
451
+ .where(eq(table.id, op.key as any))
452
+ .run();
453
+ break;
454
+ }
455
+ case "delete":
456
+ tx.delete(table)
457
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
458
+ .where(eq(table.id, op.key as any))
459
+ .run();
460
+ break;
461
+ case "truncate":
462
+ tx.delete(table).run();
463
+ break;
464
+ default:
465
+ exhaustiveGuard(op);
466
+ }
467
+ }
468
+ });
469
+ } else {
470
+ await config.drizzle.transaction(
471
+ async (tx: typeof config.drizzle) => {
472
+ for (const op of ops) {
473
+ switch (op.type) {
474
+ case "insert": {
475
+ if (config.debug) {
476
+ console.log(
477
+ `[${new Date().toISOString()}] receiveSync batch insert`,
478
+ op.value,
479
+ );
480
+ }
481
+ await tx
482
+ .insert(table)
483
+ .values(
484
+ op.value as unknown as SQLiteInsertValue<typeof table>,
485
+ )
486
+ .onConflictDoUpdate({
487
+ target: table.id,
488
+ set: sqliteExcludedUpsertSet(table),
489
+ });
490
+ break;
491
+ }
492
+ case "update": {
493
+ if (config.debug) {
494
+ console.log(
495
+ `[${new Date().toISOString()}] receiveSync batch update`,
496
+ op,
497
+ );
498
+ }
499
+ const updateTime = new Date();
500
+ await tx
501
+ .update(table)
502
+ .set({
503
+ ...op.changes,
504
+ updatedAt: updateTime,
505
+ } as SQLiteUpdateSetSource<typeof table>)
506
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
507
+ .where(eq(table.id, op.key as any));
508
+ break;
509
+ }
510
+ case "delete":
511
+ await tx
512
+ .delete(table)
513
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
514
+ .where(eq(table.id, op.key as any));
515
+ break;
516
+ case "truncate":
517
+ await tx.delete(table);
518
+ break;
519
+ default:
520
+ exhaustiveGuard(op);
521
+ }
522
+ }
523
+ },
524
+ );
525
+ }
526
+
353
527
  if (config.checkpoint) {
354
528
  await config.checkpoint();
355
529
  }
@@ -75,14 +75,12 @@ export const syncableTable = <
75
75
  for (const columnName in tableColumns) {
76
76
  const column = tableColumns[columnName];
77
77
 
78
- let defaultValue: unknown | undefined;
78
+ // Avoid executing defaultFn at module initialization time.
79
+ // In Cloudflare Workers this can trigger disallowed global-scope APIs.
79
80
  if (column.defaultFn) {
80
- defaultValue = column.defaultFn();
81
- } else if (column.default !== undefined) {
82
- defaultValue = column.default;
81
+ continue;
83
82
  }
84
-
85
- if (defaultValue instanceof SQL) {
83
+ if (column.default instanceof SQL) {
86
84
  throw new Error(
87
85
  `Default value for column ${tableName}.${columnName} is a SQL expression, which is not supported for IndexedDB.\n\nYou can use a default value or a default function instead.`,
88
86
  );