@firtoz/drizzle-utils 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @firtoz/drizzle-utils
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`58d2cba`](https://github.com/firtoz/fullstack-toolkit/commit/58d2cbac8ea4e540b5460b7088b6b62e50357558) Thanks [@firtoz](https://github.com/firtoz)! - Add sync mode functionality for IndexedDB and SQLite collections
8
+
9
+ - Introduced support for both eager and on-demand sync modes in Drizzle providers
10
+ - Implemented operation tracking via interceptors to monitor database operations during queries
11
+ - Enhanced DrizzleIndexedDBProvider and DrizzleSqliteProvider to accept interceptors for debugging and testing purposes
12
+ - Added createInsertSchemaWithDefaults and createInsertSchemaWithIdDefault utilities for better schema management
13
+ - Refactored collection utilities to improve data handling and consistency across collections
14
+
3
15
  ## 0.1.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/drizzle-utils",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shared utilities and types for Drizzle-based packages",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -57,11 +57,13 @@
57
57
  "peerDependencies": {
58
58
  "@tanstack/db": "^0.5.0",
59
59
  "drizzle-orm": "^0.44.7",
60
- "drizzle-valibot": "^0.4.2"
60
+ "drizzle-valibot": "^0.4.2",
61
+ "valibot": "^1.0.0"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@tanstack/db": "^0.5.0",
64
65
  "drizzle-orm": "^0.44.7",
65
- "drizzle-valibot": "^0.4.2"
66
+ "drizzle-valibot": "^0.4.2",
67
+ "valibot": "^1.0.0"
66
68
  }
67
69
  }
@@ -1,6 +1,18 @@
1
- import type { Table } from "drizzle-orm";
1
+ import { type Table, SQL, getTableColumns } from "drizzle-orm";
2
2
  import type { BuildSchema } from "drizzle-valibot";
3
- import type { Collection, UtilsRecord } from "@tanstack/db";
3
+ import { createInsertSchema } from "drizzle-valibot";
4
+ import * as v from "valibot";
5
+ import type {
6
+ Collection,
7
+ UtilsRecord,
8
+ CollectionConfig,
9
+ InferSchemaOutput,
10
+ SyncConfig,
11
+ SyncConfigRes,
12
+ SyncMode,
13
+ LoadSubsetOptions,
14
+ } from "@tanstack/db";
15
+ import { DeduplicatedLoadSubset } from "@tanstack/db";
4
16
 
5
17
  /**
6
18
  * Utility type for branded IDs
@@ -74,3 +86,423 @@ export type InferCollectionFromTable<TTable extends Table> = Collection<
74
86
  id?: IdOf<TTable>;
75
87
  }
76
88
  >;
89
+
90
+ // WORKAROUND: DeduplicatedLoadSubset has a bug where toggling queries (e.g., isNull/isNotNull)
91
+ // creates invalid expressions like not(or(isNull(...), not(isNull(...))))
92
+ // See: https://github.com/TanStack/db/issues/828
93
+ // TODO: Re-enable once the bug is fixed
94
+ export const USE_DEDUPE = false as boolean;
95
+
96
+ /**
97
+ * Base configuration for sync lifecycle management
98
+ */
99
+ export interface BaseSyncConfig<TTable extends Table> {
100
+ /**
101
+ * The Drizzle table definition
102
+ */
103
+ table: TTable;
104
+ /**
105
+ * Promise that resolves when the database is ready
106
+ */
107
+ readyPromise: Promise<void>;
108
+ /**
109
+ * Sync mode: 'eager' (immediate) or 'lazy' (on-demand)
110
+ */
111
+ syncMode?: SyncMode;
112
+ /**
113
+ * Enable debug logging
114
+ */
115
+ debug?: boolean;
116
+ }
117
+
118
+ /**
119
+ * Backend-specific implementations required for sync
120
+ */
121
+ export interface SyncBackend<TTable extends Table> {
122
+ /**
123
+ * Initial data load - should call write() for each item
124
+ */
125
+ initialLoad: (
126
+ write: (value: InferSchemaOutput<SelectSchema<TTable>>) => void,
127
+ ) => Promise<void>;
128
+ /**
129
+ * Load a subset of data based on query options
130
+ */
131
+ loadSubset: (
132
+ options: LoadSubsetOptions,
133
+ write: (value: InferSchemaOutput<SelectSchema<TTable>>) => void,
134
+ ) => Promise<void>;
135
+ /**
136
+ * Handle insert mutations
137
+ */
138
+ handleInsert: (
139
+ mutations: Array<{
140
+ modified: InferSchemaOutput<SelectSchema<TTable>>;
141
+ }>,
142
+ ) => Promise<Array<InferSchemaOutput<SelectSchema<TTable>>>>;
143
+ /**
144
+ * Handle update mutations
145
+ */
146
+ handleUpdate: (
147
+ mutations: Array<{
148
+ key: string;
149
+ changes: Partial<InferSchemaOutput<SelectSchema<TTable>>>;
150
+ original: InferSchemaOutput<SelectSchema<TTable>>;
151
+ }>,
152
+ ) => Promise<Array<InferSchemaOutput<SelectSchema<TTable>>>>;
153
+ /**
154
+ * Handle delete mutations
155
+ */
156
+ handleDelete: (
157
+ mutations: Array<{
158
+ key: string;
159
+ modified: InferSchemaOutput<SelectSchema<TTable>>;
160
+ original: InferSchemaOutput<SelectSchema<TTable>>;
161
+ }>,
162
+ ) => Promise<void>;
163
+ }
164
+
165
+ /**
166
+ * Return type for createSyncFunction that includes both sync config and listeners
167
+ */
168
+ export type SyncFunctionResult<TTable extends Table> = {
169
+ sync: SyncConfig<InferSchemaOutput<SelectSchema<TTable>>, string>["sync"];
170
+ onInsert: CollectionConfig<
171
+ InferSchemaOutput<SelectSchema<TTable>>,
172
+ string,
173
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
174
+ any
175
+ >["onInsert"];
176
+ onUpdate: CollectionConfig<
177
+ InferSchemaOutput<SelectSchema<TTable>>,
178
+ string,
179
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
180
+ any
181
+ >["onUpdate"];
182
+ onDelete: CollectionConfig<
183
+ InferSchemaOutput<SelectSchema<TTable>>,
184
+ string,
185
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
186
+ any
187
+ >["onDelete"];
188
+ };
189
+
190
+ /**
191
+ * Creates the sync function with common lifecycle management
192
+ */
193
+ export function createSyncFunction<TTable extends Table>(
194
+ config: BaseSyncConfig<TTable>,
195
+ backend: SyncBackend<TTable>,
196
+ ): SyncFunctionResult<TTable> {
197
+ type CollectionType = CollectionConfig<
198
+ InferSchemaOutput<SelectSchema<TTable>>,
199
+ string,
200
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
201
+ any
202
+ >;
203
+
204
+ let insertListener: CollectionType["onInsert"];
205
+ let updateListener: CollectionType["onUpdate"];
206
+ let deleteListener: CollectionType["onDelete"];
207
+
208
+ const syncFn: SyncConfig<
209
+ InferSchemaOutput<SelectSchema<TTable>>,
210
+ string
211
+ >["sync"] = (params) => {
212
+ const { begin, write, commit, markReady } = params;
213
+
214
+ const initialSync = async () => {
215
+ await config.readyPromise;
216
+
217
+ try {
218
+ begin();
219
+ await backend.initialLoad((item) => {
220
+ write({
221
+ type: "insert",
222
+ value: item,
223
+ });
224
+ });
225
+ commit();
226
+ } finally {
227
+ markReady();
228
+ }
229
+ };
230
+
231
+ if (config.syncMode === "eager" || !config.syncMode) {
232
+ initialSync();
233
+ } else {
234
+ markReady();
235
+ }
236
+
237
+ insertListener = async (params) => {
238
+ const results = await backend.handleInsert(
239
+ params.transaction.mutations.map((m) => ({
240
+ modified: m.modified,
241
+ })),
242
+ );
243
+
244
+ begin();
245
+ for (const result of results) {
246
+ write({
247
+ type: "insert",
248
+ value: result,
249
+ });
250
+ }
251
+ commit();
252
+ };
253
+
254
+ updateListener = async (params) => {
255
+ const results = await backend.handleUpdate(params.transaction.mutations);
256
+
257
+ begin();
258
+ for (const result of results) {
259
+ write({
260
+ type: "update",
261
+ value: result,
262
+ });
263
+ }
264
+ commit();
265
+ };
266
+
267
+ deleteListener = async (params) => {
268
+ await backend.handleDelete(params.transaction.mutations);
269
+
270
+ begin();
271
+ for (const item of params.transaction.mutations) {
272
+ write({
273
+ type: "delete",
274
+ value: item.modified,
275
+ });
276
+ }
277
+ commit();
278
+ };
279
+
280
+ const loadSubset = async (options: LoadSubsetOptions) => {
281
+ await config.readyPromise;
282
+
283
+ begin();
284
+
285
+ try {
286
+ await backend.loadSubset(options, (item) => {
287
+ write({
288
+ type: "insert",
289
+ value: item,
290
+ });
291
+ });
292
+ commit();
293
+ } catch (error) {
294
+ commit();
295
+ throw error;
296
+ }
297
+ };
298
+
299
+ // Create deduplicated loadSubset wrapper to avoid redundant queries
300
+ let loadSubsetDedupe: DeduplicatedLoadSubset | null = null;
301
+ if (USE_DEDUPE) {
302
+ loadSubsetDedupe = new DeduplicatedLoadSubset({
303
+ loadSubset,
304
+ });
305
+ }
306
+
307
+ return {
308
+ cleanup: () => {
309
+ insertListener = undefined;
310
+ updateListener = undefined;
311
+ deleteListener = undefined;
312
+ loadSubsetDedupe?.reset();
313
+ },
314
+ loadSubset: loadSubsetDedupe?.loadSubset ?? loadSubset,
315
+ } satisfies SyncConfigRes;
316
+ };
317
+
318
+ return {
319
+ sync: syncFn,
320
+ onInsert: async (params) => {
321
+ if (!insertListener) {
322
+ throw new Error(
323
+ "insertListener not initialized - sync function may not have been called yet",
324
+ );
325
+ }
326
+ return insertListener(params);
327
+ },
328
+ onUpdate: async (params) => {
329
+ if (!updateListener) {
330
+ throw new Error(
331
+ "updateListener not initialized - sync function may not have been called yet",
332
+ );
333
+ }
334
+ return updateListener(params);
335
+ },
336
+ onDelete: async (params) => {
337
+ if (!deleteListener) {
338
+ throw new Error(
339
+ "deleteListener not initialized - sync function may not have been called yet",
340
+ );
341
+ }
342
+ return deleteListener(params);
343
+ },
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Creates an insert schema with default value handling
349
+ * Validates that SQL expressions are not used for defaults (IndexedDB compatibility)
350
+ */
351
+ export function createInsertSchemaWithDefaults<TTable extends Table>(
352
+ table: TTable,
353
+ ): v.GenericSchema<unknown> {
354
+ const insertSchema = createInsertSchema(table);
355
+ const columns = getTableColumns(table);
356
+
357
+ // Validate that no SQL expressions are used as defaults
358
+ for (const columnName in columns) {
359
+ const column = columns[columnName];
360
+
361
+ let defaultValue: unknown | undefined;
362
+ if (column.defaultFn) {
363
+ defaultValue = column.defaultFn();
364
+ } else if (column.default !== undefined) {
365
+ defaultValue = column.default;
366
+ }
367
+
368
+ if (defaultValue instanceof SQL) {
369
+ throw new Error(
370
+ `Default value for column ${columnName} is a SQL expression, which is not supported for IndexedDB`,
371
+ );
372
+ }
373
+ }
374
+
375
+ // Transform the schema to apply defaults
376
+ return v.pipe(
377
+ insertSchema,
378
+ v.transform((input) => {
379
+ const result = { ...input } as Record<string, unknown>;
380
+
381
+ for (const columnName in columns) {
382
+ const column = columns[columnName];
383
+ if (result[columnName] !== undefined) continue;
384
+
385
+ let defaultValue: unknown | undefined;
386
+ if (column.defaultFn) {
387
+ defaultValue = column.defaultFn();
388
+ } else if (column.default !== undefined) {
389
+ defaultValue = column.default;
390
+ }
391
+
392
+ if (defaultValue instanceof SQL) {
393
+ throw new Error(
394
+ `Default value for column ${columnName} is a SQL expression, which is not supported for IndexedDB`,
395
+ );
396
+ }
397
+
398
+ if (defaultValue !== undefined) {
399
+ result[columnName] = defaultValue;
400
+ continue;
401
+ }
402
+
403
+ if (column.notNull) {
404
+ throw new Error(`Column ${columnName} is not nullable`);
405
+ }
406
+
407
+ result[columnName] = null;
408
+ }
409
+
410
+ return result;
411
+ }),
412
+ ) as v.GenericSchema<unknown>;
413
+ }
414
+
415
+ /**
416
+ * Creates a minimal insert schema that only applies ID defaults
417
+ * Other defaults (like timestamps) are handled by the database
418
+ */
419
+ export function createInsertSchemaWithIdDefault<TTable extends Table>(
420
+ table: TTable,
421
+ ): v.GenericSchema<unknown> {
422
+ const insertSchema = createInsertSchema(table);
423
+ const columns = getTableColumns(table);
424
+ const idColumn = columns.id;
425
+
426
+ return v.pipe(
427
+ insertSchema,
428
+ v.transform((input) => {
429
+ const result = { ...input } as Record<string, unknown>;
430
+
431
+ // Apply ID default if missing
432
+ if (result.id === undefined && idColumn?.defaultFn) {
433
+ result.id = idColumn.defaultFn();
434
+ }
435
+
436
+ return result;
437
+ }),
438
+ ) as v.GenericSchema<unknown>;
439
+ }
440
+
441
+ /**
442
+ * Standard getKey function for collections
443
+ */
444
+ export function createGetKeyFunction<TTable extends Table>() {
445
+ return (item: InferSchemaOutput<SelectSchema<TTable>>) => {
446
+ const id = (item as { id: string }).id;
447
+ return id;
448
+ };
449
+ }
450
+
451
+ /**
452
+ * Base collection config factory
453
+ * Combines schema, sync, and event handlers into a collection config
454
+ */
455
+ export function createCollectionConfig<
456
+ TTable extends Table,
457
+ TSchema extends v.GenericSchema<unknown>,
458
+ >(config: {
459
+ schema: TSchema;
460
+ getKey: (item: InferSchemaOutput<SelectSchema<TTable>>) => string;
461
+ syncResult: SyncFunctionResult<TTable>;
462
+ onInsert?: CollectionConfig<
463
+ InferSchemaOutput<SelectSchema<TTable>>,
464
+ string,
465
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
466
+ any
467
+ >["onInsert"];
468
+ onUpdate?: CollectionConfig<
469
+ InferSchemaOutput<SelectSchema<TTable>>,
470
+ string,
471
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
472
+ any
473
+ >["onUpdate"];
474
+ onDelete?: CollectionConfig<
475
+ InferSchemaOutput<SelectSchema<TTable>>,
476
+ string,
477
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
478
+ any
479
+ >["onDelete"];
480
+ syncMode?: SyncMode;
481
+ }): CollectionConfig<
482
+ InferSchemaOutput<SelectSchema<TTable>>,
483
+ string,
484
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
485
+ any
486
+ > & {
487
+ schema: TSchema;
488
+ } {
489
+ return {
490
+ schema: config.schema,
491
+ getKey: config.getKey,
492
+ sync: {
493
+ sync: config.syncResult.sync,
494
+ },
495
+ // Merge provided handlers with sync result handlers (provided handlers take precedence)
496
+ onInsert: config.onInsert ?? config.syncResult.onInsert,
497
+ onUpdate: config.onUpdate ?? config.syncResult.onUpdate,
498
+ onDelete: config.onDelete ?? config.syncResult.onDelete,
499
+ syncMode: config.syncMode,
500
+ } as CollectionConfig<
501
+ InferSchemaOutput<SelectSchema<TTable>>,
502
+ string,
503
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
504
+ any
505
+ > & {
506
+ schema: TSchema;
507
+ };
508
+ }
package/src/index.ts CHANGED
@@ -24,9 +24,19 @@ export type {
24
24
  InsertSchema,
25
25
  GetTableFromSchema,
26
26
  InferCollectionFromTable,
27
+ BaseSyncConfig,
28
+ SyncBackend,
27
29
  } from "./collection-utils";
28
30
 
29
- export { makeId } from "./collection-utils";
31
+ export {
32
+ makeId,
33
+ USE_DEDUPE,
34
+ createSyncFunction,
35
+ createInsertSchemaWithDefaults,
36
+ createInsertSchemaWithIdDefault,
37
+ createGetKeyFunction,
38
+ createCollectionConfig,
39
+ } from "./collection-utils";
30
40
 
31
41
  export {
32
42
  createdAtColumn,