@syncular/client 0.0.6-144 → 0.0.6-152

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/src/mutations.ts CHANGED
@@ -31,6 +31,7 @@ import type { Insertable, Kysely, Transaction, Updateable } from 'kysely';
31
31
  import { sql } from 'kysely';
32
32
  import { enqueueOutboxCommit } from './outbox';
33
33
  import type {
34
+ SyncClientLocalMutationArgs,
34
35
  SyncClientPlugin,
35
36
  SyncClientPluginContext,
36
37
  } from './plugins/types';
@@ -402,6 +403,9 @@ export interface OutboxCommitConfig<DB extends SyncClientDb> {
402
403
  omitColumns?: string[];
403
404
  codecs?: ColumnCodecSource;
404
405
  codecDialect?: ColumnCodecDialect;
406
+ plugins?: SyncClientPlugin[];
407
+ actorId?: string;
408
+ clientId?: string;
405
409
  }
406
410
 
407
411
  export function createOutboxCommit<DB extends SyncClientDb>(
@@ -411,6 +415,11 @@ export function createOutboxCommit<DB extends SyncClientDb>(
411
415
  const versionColumn = config.versionColumn ?? 'server_version';
412
416
  const omitColumns = config.omitColumns ?? [];
413
417
  const codecDialect = config.codecDialect ?? 'sqlite';
418
+ const sortedPlugins = sortPlugins(config.plugins ?? []);
419
+ const pluginContext: SyncClientPluginContext = {
420
+ actorId: config.actorId ?? 'unknown',
421
+ clientId: config.clientId ?? 'unknown',
422
+ };
414
423
 
415
424
  return async (fn) => {
416
425
  const operations: SyncOperation[] = [];
@@ -420,6 +429,42 @@ export function createOutboxCommit<DB extends SyncClientDb>(
420
429
  op: SyncOpKind;
421
430
  }> = [];
422
431
 
432
+ const transformLocalOperation = async (
433
+ operation: SyncOperation
434
+ ): Promise<SyncOperation> => {
435
+ if (sortedPlugins.length === 0) return operation;
436
+
437
+ const transformed = await runBeforeApplyLocalMutationsPlugins({
438
+ plugins: sortedPlugins,
439
+ ctx: pluginContext,
440
+ operations: [operation],
441
+ });
442
+
443
+ if (transformed.length !== 1) {
444
+ throw new Error(
445
+ 'beforeApplyLocalMutations must return exactly one operation per input operation'
446
+ );
447
+ }
448
+
449
+ const [nextOperation] = transformed;
450
+ if (!nextOperation) {
451
+ throw new Error(
452
+ 'beforeApplyLocalMutations returned an empty operation'
453
+ );
454
+ }
455
+ if (
456
+ nextOperation.table !== operation.table ||
457
+ nextOperation.row_id !== operation.row_id ||
458
+ nextOperation.op !== operation.op
459
+ ) {
460
+ throw new Error(
461
+ 'beforeApplyLocalMutations cannot change operation table, row_id, or op'
462
+ );
463
+ }
464
+
465
+ return nextOperation;
466
+ };
467
+
423
468
  const { result, receipt } = await config.db
424
469
  .transaction()
425
470
  .execute(async (trx) => {
@@ -469,14 +514,6 @@ export function createOutboxCommit<DB extends SyncClientDb>(
469
514
  typeof rawId === 'string' && rawId ? rawId : randomId();
470
515
 
471
516
  const row = { ...raw, [idColumn]: id };
472
- const dbRow = applyCodecsToDbRow(
473
- row,
474
- resolveTableCodecs(table, row),
475
- codecDialect
476
- );
477
-
478
- await dynamicInsert(trx, table, dbRow);
479
-
480
517
  const payload = sanitizePayload(row, {
481
518
  omit: [
482
519
  idColumn,
@@ -484,6 +521,27 @@ export function createOutboxCommit<DB extends SyncClientDb>(
484
521
  ...omitColumns,
485
522
  ],
486
523
  });
524
+ const localSanitized = sanitizePayload(row, {
525
+ omit: [idColumn, ...(versionColumn ? [versionColumn] : [])],
526
+ });
527
+ const localOp = await transformLocalOperation({
528
+ table,
529
+ row_id: id,
530
+ op: 'upsert',
531
+ payload: localSanitized,
532
+ base_version: null,
533
+ });
534
+ const localPayload = isRecord(localOp.payload)
535
+ ? localOp.payload
536
+ : {};
537
+ const localRow = { ...localPayload, [idColumn]: id };
538
+ const dbRow = applyCodecsToDbRow(
539
+ localRow,
540
+ resolveTableCodecs(table, localRow),
541
+ codecDialect
542
+ );
543
+
544
+ await dynamicInsert(trx, table, dbRow);
487
545
 
488
546
  operations.push({
489
547
  table: table,
@@ -503,6 +561,7 @@ export function createOutboxCommit<DB extends SyncClientDb>(
503
561
  }
504
562
  const ids: string[] = [];
505
563
  const toInsert: Record<string, unknown>[] = [];
564
+ const toLocalInsert: Record<string, unknown>[] = [];
506
565
 
507
566
  for (const values of rows) {
508
567
  const raw = isRecord(values) ? values : {};
@@ -513,18 +572,6 @@ export function createOutboxCommit<DB extends SyncClientDb>(
513
572
  toInsert.push({ ...raw, [idColumn]: id });
514
573
  }
515
574
 
516
- const dbRows = toInsert.map((row) =>
517
- applyCodecsToDbRow(
518
- row,
519
- resolveTableCodecs(table, row),
520
- codecDialect
521
- )
522
- );
523
-
524
- if (dbRows.length > 0) {
525
- await dynamicInsert(trx, table, dbRows);
526
- }
527
-
528
575
  for (let i = 0; i < toInsert.length; i++) {
529
576
  const row = toInsert[i]!;
530
577
  const id = ids[i]!;
@@ -535,6 +582,20 @@ export function createOutboxCommit<DB extends SyncClientDb>(
535
582
  ...omitColumns,
536
583
  ],
537
584
  });
585
+ const localSanitized = sanitizePayload(row, {
586
+ omit: [idColumn, ...(versionColumn ? [versionColumn] : [])],
587
+ });
588
+ const localOp = await transformLocalOperation({
589
+ table,
590
+ row_id: id,
591
+ op: 'upsert',
592
+ payload: localSanitized,
593
+ base_version: null,
594
+ });
595
+ const localPayload = isRecord(localOp.payload)
596
+ ? localOp.payload
597
+ : {};
598
+ toLocalInsert.push({ ...localPayload, [idColumn]: id });
538
599
 
539
600
  operations.push({
540
601
  table: table,
@@ -547,6 +608,18 @@ export function createOutboxCommit<DB extends SyncClientDb>(
547
608
  localMutations.push({ table, rowId: id, op: 'upsert' });
548
609
  }
549
610
 
611
+ const dbRows = toLocalInsert.map((row) =>
612
+ applyCodecsToDbRow(
613
+ row,
614
+ resolveTableCodecs(table, row),
615
+ codecDialect
616
+ )
617
+ );
618
+
619
+ if (dbRows.length > 0) {
620
+ await dynamicInsert(trx, table, dbRows);
621
+ }
622
+
550
623
  return ids;
551
624
  },
552
625
 
@@ -559,12 +632,25 @@ export function createOutboxCommit<DB extends SyncClientDb>(
559
632
  ...omitColumns,
560
633
  ],
561
634
  });
635
+ const localSanitized = sanitizePayload(rawPatch, {
636
+ omit: [idColumn, ...(versionColumn ? [versionColumn] : [])],
637
+ });
562
638
 
563
639
  const hasExplicitBaseVersion =
564
640
  !!opts && hasOwn(opts, 'baseVersion');
641
+ const localOp = await transformLocalOperation({
642
+ table,
643
+ row_id: id,
644
+ op: 'upsert',
645
+ payload: localSanitized,
646
+ base_version: null,
647
+ });
648
+ const localPayload = isRecord(localOp.payload)
649
+ ? localOp.payload
650
+ : {};
565
651
  const dbPatch = applyCodecsToDbRow(
566
- sanitized,
567
- resolveTableCodecs(table, sanitized),
652
+ localPayload,
653
+ resolveTableCodecs(table, localPayload),
568
654
  codecDialect
569
655
  );
570
656
 
@@ -630,12 +716,25 @@ export function createOutboxCommit<DB extends SyncClientDb>(
630
716
  ...omitColumns,
631
717
  ],
632
718
  });
719
+ const localSanitized = sanitizePayload(rawPatch, {
720
+ omit: [idColumn, ...(versionColumn ? [versionColumn] : [])],
721
+ });
633
722
 
634
723
  const hasExplicitBaseVersion =
635
724
  !!opts && hasOwn(opts, 'baseVersion');
725
+ const localOp = await transformLocalOperation({
726
+ table,
727
+ row_id: id,
728
+ op: 'upsert',
729
+ payload: localSanitized,
730
+ base_version: null,
731
+ });
732
+ const localPayload = isRecord(localOp.payload)
733
+ ? localOp.payload
734
+ : {};
636
735
  const dbPatch = applyCodecsToDbRow(
637
- sanitized,
638
- resolveTableCodecs(table, sanitized),
736
+ localPayload,
737
+ resolveTableCodecs(table, localPayload),
639
738
  codecDialect
640
739
  );
641
740
 
@@ -732,6 +831,43 @@ function clonePushRequest(request: SyncPushRequest): SyncPushRequest {
732
831
  return JSON.parse(JSON.stringify(request)) as SyncPushRequest;
733
832
  }
734
833
 
834
+ function cloneLocalMutationArgs(
835
+ args: SyncClientLocalMutationArgs
836
+ ): SyncClientLocalMutationArgs {
837
+ if (typeof structuredClone === 'function') {
838
+ return structuredClone(args);
839
+ }
840
+ return JSON.parse(JSON.stringify(args)) as SyncClientLocalMutationArgs;
841
+ }
842
+
843
+ function sortPlugins(plugins: readonly SyncClientPlugin[]): SyncClientPlugin[] {
844
+ return [...plugins].sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50));
845
+ }
846
+
847
+ async function runBeforeApplyLocalMutationsPlugins(args: {
848
+ plugins: readonly SyncClientPlugin[];
849
+ ctx: SyncClientPluginContext;
850
+ operations: SyncOperation[];
851
+ }): Promise<SyncOperation[]> {
852
+ if (args.plugins.length === 0) {
853
+ return args.operations;
854
+ }
855
+
856
+ let transformedArgs: SyncClientLocalMutationArgs = cloneLocalMutationArgs({
857
+ operations: args.operations,
858
+ });
859
+
860
+ for (const plugin of args.plugins) {
861
+ if (!plugin.beforeApplyLocalMutations) continue;
862
+ transformedArgs = await plugin.beforeApplyLocalMutations(
863
+ args.ctx,
864
+ transformedArgs
865
+ );
866
+ }
867
+
868
+ return transformedArgs.operations;
869
+ }
870
+
735
871
  export function createPushCommit<DB = AnyDb>(
736
872
  config: PushCommitConfig
737
873
  ): MutationsCommitFn<DB, PushCommitMeta, undefined> {
@@ -915,10 +1051,7 @@ export function createPushCommit<DB = AnyDb>(
915
1051
  };
916
1052
 
917
1053
  const plugins = config.plugins ?? [];
918
- // Sort plugins by priority (lower numbers first, default 50)
919
- const sortedPlugins = [...plugins].sort(
920
- (a, b) => (a.priority ?? 50) - (b.priority ?? 50)
921
- );
1054
+ const sortedPlugins = sortPlugins(plugins);
922
1055
  const ctx: SyncClientPluginContext = {
923
1056
  actorId: config.actorId ?? 'unknown',
924
1057
  clientId: config.clientId,
@@ -1,4 +1,6 @@
1
1
  import type {
2
+ SyncChange,
3
+ SyncOperation,
2
4
  SyncPullRequest,
3
5
  SyncPullResponse,
4
6
  SyncPushRequest,
@@ -10,6 +12,22 @@ export interface SyncClientPluginContext {
10
12
  clientId: string;
11
13
  }
12
14
 
15
+ export interface SyncClientWsDeliveryMetadata {
16
+ commitSeq?: number;
17
+ actorId?: string | null;
18
+ createdAt?: string | null;
19
+ }
20
+
21
+ export interface SyncClientWsDeliveryArgs {
22
+ changes: SyncChange[];
23
+ cursor: number;
24
+ metadata?: SyncClientWsDeliveryMetadata;
25
+ }
26
+
27
+ export interface SyncClientLocalMutationArgs {
28
+ operations: SyncOperation[];
29
+ }
30
+
13
31
  /**
14
32
  * Plugin priority levels for ordering execution.
15
33
  * Lower numbers execute first.
@@ -43,6 +61,16 @@ export interface SyncClientPlugin {
43
61
  request: SyncPushRequest
44
62
  ): Promise<SyncPushRequest> | SyncPushRequest;
45
63
 
64
+ /**
65
+ * Called before applying local optimistic mutations to the local DB.
66
+ * Use this when local payloads need shaping different from server push payloads
67
+ * (for example CRDT envelopes that should not be written as SQL columns).
68
+ */
69
+ beforeApplyLocalMutations?(
70
+ ctx: SyncClientPluginContext,
71
+ args: SyncClientLocalMutationArgs
72
+ ): Promise<SyncClientLocalMutationArgs> | SyncClientLocalMutationArgs;
73
+
46
74
  /**
47
75
  * Called after receiving a push response from the server.
48
76
  * Receives both the request and the response to allow opIndex correlation.
@@ -60,4 +88,14 @@ export interface SyncClientPlugin {
60
88
  ctx: SyncClientPluginContext,
61
89
  args: { request: SyncPullRequest; response: SyncPullResponse }
62
90
  ): Promise<SyncPullResponse> | SyncPullResponse;
91
+
92
+ /**
93
+ * Called for inline WS-delivered changes before applying to the local DB.
94
+ * Use this when a plugin needs equivalent transforms for realtime payloads
95
+ * (for example decryption or CRDT materialization).
96
+ */
97
+ beforeApplyWsChanges?(
98
+ ctx: SyncClientPluginContext,
99
+ args: SyncClientWsDeliveryArgs
100
+ ): Promise<SyncClientWsDeliveryArgs> | SyncClientWsDeliveryArgs;
63
101
  }