@syncular/server 0.0.5-44 → 0.0.6-101

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.
Files changed (96) hide show
  1. package/dist/dialect/base.d.ts +3 -3
  2. package/dist/dialect/base.d.ts.map +1 -1
  3. package/dist/dialect/base.js.map +1 -1
  4. package/dist/dialect/types.d.ts +5 -7
  5. package/dist/dialect/types.d.ts.map +1 -1
  6. package/dist/handlers/collection.d.ts +12 -0
  7. package/dist/handlers/collection.d.ts.map +1 -0
  8. package/dist/handlers/collection.js +64 -0
  9. package/dist/handlers/collection.js.map +1 -0
  10. package/dist/handlers/create-handler.d.ts +10 -10
  11. package/dist/handlers/create-handler.d.ts.map +1 -1
  12. package/dist/handlers/create-handler.js +101 -69
  13. package/dist/handlers/create-handler.js.map +1 -1
  14. package/dist/handlers/index.d.ts +1 -1
  15. package/dist/handlers/index.d.ts.map +1 -1
  16. package/dist/handlers/index.js +1 -1
  17. package/dist/handlers/index.js.map +1 -1
  18. package/dist/handlers/types.d.ts +18 -12
  19. package/dist/handlers/types.d.ts.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/notify.js +1 -1
  25. package/dist/notify.js.map +1 -1
  26. package/dist/proxy/collection.d.ts +9 -0
  27. package/dist/proxy/collection.d.ts.map +1 -0
  28. package/dist/proxy/collection.js +21 -0
  29. package/dist/proxy/collection.js.map +1 -0
  30. package/dist/proxy/handler.d.ts +3 -3
  31. package/dist/proxy/handler.d.ts.map +1 -1
  32. package/dist/proxy/handler.js +2 -1
  33. package/dist/proxy/handler.js.map +1 -1
  34. package/dist/proxy/index.d.ts +1 -1
  35. package/dist/proxy/index.d.ts.map +1 -1
  36. package/dist/proxy/index.js +3 -3
  37. package/dist/proxy/index.js.map +1 -1
  38. package/dist/proxy/oplog.js +1 -1
  39. package/dist/proxy/oplog.js.map +1 -1
  40. package/dist/pull.d.ts +12 -5
  41. package/dist/pull.d.ts.map +1 -1
  42. package/dist/pull.js +101 -55
  43. package/dist/pull.js.map +1 -1
  44. package/dist/push.d.ts +5 -5
  45. package/dist/push.d.ts.map +1 -1
  46. package/dist/push.js +6 -4
  47. package/dist/push.js.map +1 -1
  48. package/dist/subscriptions/cache.d.ts +55 -0
  49. package/dist/subscriptions/cache.d.ts.map +1 -0
  50. package/dist/subscriptions/cache.js +206 -0
  51. package/dist/subscriptions/cache.js.map +1 -0
  52. package/dist/subscriptions/index.d.ts +1 -0
  53. package/dist/subscriptions/index.d.ts.map +1 -1
  54. package/dist/subscriptions/index.js +1 -0
  55. package/dist/subscriptions/index.js.map +1 -1
  56. package/dist/subscriptions/resolve.d.ts +7 -4
  57. package/dist/subscriptions/resolve.d.ts.map +1 -1
  58. package/dist/subscriptions/resolve.js +74 -11
  59. package/dist/subscriptions/resolve.js.map +1 -1
  60. package/dist/sync.d.ts +21 -0
  61. package/dist/sync.d.ts.map +1 -0
  62. package/dist/sync.js +23 -0
  63. package/dist/sync.js.map +1 -0
  64. package/package.json +3 -3
  65. package/src/dialect/base.ts +5 -3
  66. package/src/dialect/types.ts +11 -8
  67. package/src/handlers/collection.ts +121 -0
  68. package/src/handlers/create-handler.ts +163 -109
  69. package/src/handlers/index.ts +1 -1
  70. package/src/handlers/types.ts +29 -12
  71. package/src/index.ts +1 -0
  72. package/src/notify.test.ts +25 -21
  73. package/src/notify.ts +1 -1
  74. package/src/proxy/collection.ts +39 -0
  75. package/src/proxy/handler.test.ts +15 -9
  76. package/src/proxy/handler.ts +4 -4
  77. package/src/proxy/index.ts +8 -3
  78. package/src/proxy/oplog.ts +1 -1
  79. package/src/pull.ts +155 -73
  80. package/src/push.ts +16 -9
  81. package/src/snapshot-chunks/db-metadata.test.ts +6 -3
  82. package/src/subscriptions/cache.ts +318 -0
  83. package/src/subscriptions/index.ts +1 -0
  84. package/src/subscriptions/resolve.test.ts +180 -0
  85. package/src/subscriptions/resolve.ts +94 -18
  86. package/src/sync.ts +101 -0
  87. package/dist/handlers/registry.d.ts +0 -20
  88. package/dist/handlers/registry.d.ts.map +0 -1
  89. package/dist/handlers/registry.js +0 -88
  90. package/dist/handlers/registry.js.map +0 -1
  91. package/dist/proxy/registry.d.ts +0 -35
  92. package/dist/proxy/registry.d.ts.map +0 -1
  93. package/dist/proxy/registry.js +0 -49
  94. package/dist/proxy/registry.js.map +0 -1
  95. package/src/handlers/registry.ts +0 -109
  96. package/src/proxy/registry.ts +0 -56
@@ -0,0 +1,121 @@
1
+ import type { SyncCoreDb } from '../schema';
2
+ import type { ServerTableHandler, SyncServerAuth } from './types';
3
+
4
+ export interface ServerHandlerCollection<
5
+ DB extends SyncCoreDb = SyncCoreDb,
6
+ Auth extends SyncServerAuth = SyncServerAuth,
7
+ > {
8
+ handlers: ServerTableHandler<DB, Auth>[];
9
+ byTable: ReadonlyMap<string, ServerTableHandler<DB, Auth>>;
10
+ }
11
+
12
+ export function createServerHandlerCollection<
13
+ DB extends SyncCoreDb = SyncCoreDb,
14
+ Auth extends SyncServerAuth = SyncServerAuth,
15
+ >(handlers: ServerTableHandler<DB, Auth>[]): ServerHandlerCollection<DB, Auth> {
16
+ const byTable = new Map<string, ServerTableHandler<DB, Auth>>();
17
+
18
+ for (const handler of handlers) {
19
+ if (byTable.has(handler.table)) {
20
+ throw new Error(`Table "${handler.table}" is already registered`);
21
+ }
22
+ byTable.set(handler.table, handler);
23
+ }
24
+
25
+ for (const handler of handlers) {
26
+ for (const dep of handler.dependsOn ?? []) {
27
+ if (!byTable.has(dep)) {
28
+ throw new Error(
29
+ `Table "${handler.table}" depends on unknown table "${dep}"`
30
+ );
31
+ }
32
+ }
33
+ }
34
+
35
+ return { handlers, byTable };
36
+ }
37
+
38
+ export function getServerHandler<
39
+ DB extends SyncCoreDb = SyncCoreDb,
40
+ Auth extends SyncServerAuth = SyncServerAuth,
41
+ >(
42
+ collection: ServerHandlerCollection<DB, Auth>,
43
+ table: string
44
+ ): ServerTableHandler<DB, Auth> | undefined {
45
+ return collection.byTable.get(table);
46
+ }
47
+
48
+ export function getServerHandlerOrThrow<
49
+ DB extends SyncCoreDb = SyncCoreDb,
50
+ Auth extends SyncServerAuth = SyncServerAuth,
51
+ >(
52
+ collection: ServerHandlerCollection<DB, Auth>,
53
+ table: string
54
+ ): ServerTableHandler<DB, Auth> {
55
+ const handler = collection.byTable.get(table);
56
+ if (!handler) throw new Error(`Unknown table: ${table}`);
57
+ return handler;
58
+ }
59
+
60
+ function topoSortTables<
61
+ DB extends SyncCoreDb = SyncCoreDb,
62
+ Auth extends SyncServerAuth = SyncServerAuth,
63
+ >(
64
+ collection: ServerHandlerCollection<DB, Auth>,
65
+ targetTable?: string
66
+ ): ServerTableHandler<DB, Auth>[] {
67
+ const visited = new Set<string>();
68
+ const visiting = new Set<string>();
69
+ const sorted: ServerTableHandler<DB, Auth>[] = [];
70
+
71
+ const visit = (table: string) => {
72
+ if (visited.has(table)) return;
73
+ if (visiting.has(table)) {
74
+ throw new Error(
75
+ `Circular dependency detected involving table "${table}"`
76
+ );
77
+ }
78
+
79
+ const handler = collection.byTable.get(table);
80
+ if (!handler) {
81
+ throw new Error(`Unknown table: ${table}`);
82
+ }
83
+
84
+ visiting.add(table);
85
+ for (const dep of handler.dependsOn ?? []) {
86
+ visit(dep);
87
+ }
88
+ visiting.delete(table);
89
+ visited.add(table);
90
+ sorted.push(handler);
91
+ };
92
+
93
+ if (targetTable) {
94
+ visit(targetTable);
95
+ return sorted;
96
+ }
97
+
98
+ for (const table of collection.byTable.keys()) {
99
+ visit(table);
100
+ }
101
+ return sorted;
102
+ }
103
+
104
+ export function getServerBootstrapOrder<
105
+ DB extends SyncCoreDb = SyncCoreDb,
106
+ Auth extends SyncServerAuth = SyncServerAuth,
107
+ >(
108
+ collection: ServerHandlerCollection<DB, Auth>
109
+ ): ServerTableHandler<DB, Auth>[] {
110
+ return topoSortTables(collection);
111
+ }
112
+
113
+ export function getServerBootstrapOrderFor<
114
+ DB extends SyncCoreDb = SyncCoreDb,
115
+ Auth extends SyncServerAuth = SyncServerAuth,
116
+ >(
117
+ collection: ServerHandlerCollection<DB, Auth>,
118
+ table: string
119
+ ): ServerTableHandler<DB, Auth>[] {
120
+ return topoSortTables(collection, table);
121
+ }
@@ -31,6 +31,7 @@ import type {
31
31
  UpdateQueryBuilder,
32
32
  UpdateResult,
33
33
  } from 'kysely';
34
+ import { sql } from 'kysely';
34
35
  import type { SyncCoreDb } from '../schema';
35
36
  import type {
36
37
  ApplyOperationResult,
@@ -39,6 +40,7 @@ import type {
39
40
  ServerContext,
40
41
  ServerSnapshotContext,
41
42
  ServerTableHandler,
43
+ SyncServerAuth,
42
44
  } from './types';
43
45
 
44
46
  /**
@@ -66,6 +68,14 @@ function isConstraintViolationError(message: string): boolean {
66
68
  );
67
69
  }
68
70
 
71
+ function isMissingColumnReferenceError(message: string): boolean {
72
+ const normalized = message.toLowerCase();
73
+ return (
74
+ normalized.includes('no such column') ||
75
+ (normalized.includes('column') && normalized.includes('does not exist'))
76
+ );
77
+ }
78
+
69
79
  /**
70
80
  * Scope definition for a column - maps scope variable to column name.
71
81
  */
@@ -78,6 +88,7 @@ export interface CreateServerHandlerOptions<
78
88
  ServerDB extends SyncCoreDb,
79
89
  ClientDB,
80
90
  TableName extends keyof ServerDB & keyof ClientDB & string,
91
+ Auth extends SyncServerAuth = SyncServerAuth,
81
92
  ScopeDefs extends
82
93
  readonly SimpleScopeDefinition[] = readonly SimpleScopeDefinition[],
83
94
  > {
@@ -124,7 +135,7 @@ export interface CreateServerHandlerOptions<
124
135
  * })
125
136
  */
126
137
  resolveScopes: (
127
- ctx: ServerContext<ServerDB>
138
+ ctx: ServerContext<ServerDB, Auth>
128
139
  ) => Promise<ScopeValuesFromPatterns<ScopeDefs>>;
129
140
 
130
141
  /**
@@ -132,7 +143,7 @@ export interface CreateServerHandlerOptions<
132
143
  * Use ctx.schemaVersion to handle older client versions.
133
144
  */
134
145
  transformInbound?: (
135
- row: ClientDB[TableName],
146
+ row: Selectable<ClientDB[TableName]>,
136
147
  ctx: { schemaVersion?: number }
137
148
  ) => Updateable<ServerDB[TableName]>;
138
149
 
@@ -141,7 +152,7 @@ export interface CreateServerHandlerOptions<
141
152
  */
142
153
  transformOutbound?: (
143
154
  row: Selectable<ServerDB[TableName]>
144
- ) => ClientDB[TableName];
155
+ ) => Selectable<ClientDB[TableName]>;
145
156
 
146
157
  /**
147
158
  * Optional column codec resolver.
@@ -149,7 +160,7 @@ export interface CreateServerHandlerOptions<
149
160
  * Only used by default snapshot/apply paths when the corresponding
150
161
  * transform hook is not provided.
151
162
  */
152
- columnCodecs?: ColumnCodecSource;
163
+ codecs?: ColumnCodecSource;
153
164
 
154
165
  /**
155
166
  * Dialect used for codec dialect overrides.
@@ -162,19 +173,19 @@ export interface CreateServerHandlerOptions<
162
173
  * Return true to allow, or an error object to reject.
163
174
  */
164
175
  authorize?: (
165
- ctx: ServerApplyOperationContext<ServerDB>,
176
+ ctx: ServerApplyOperationContext<ServerDB, Auth>,
166
177
  op: SyncOperation
167
178
  ) => Promise<AuthorizeResult>;
168
179
 
169
180
  /**
170
181
  * Override: Build snapshot query.
171
182
  */
172
- snapshot?: ServerTableHandler<ServerDB>['snapshot'];
183
+ snapshot?: ServerTableHandler<ServerDB, Auth>['snapshot'];
173
184
 
174
185
  /**
175
186
  * Override: Apply operation.
176
187
  */
177
- applyOperation?: ServerTableHandler<ServerDB>['applyOperation'];
188
+ applyOperation?: ServerTableHandler<ServerDB, Auth>['applyOperation'];
178
189
 
179
190
  /**
180
191
  * Custom scope extraction from row (for complex scope logic).
@@ -213,11 +224,18 @@ export function createServerHandler<
213
224
  ServerDB extends SyncCoreDb,
214
225
  ClientDB,
215
226
  TableName extends keyof ServerDB & keyof ClientDB & string,
227
+ Auth extends SyncServerAuth = SyncServerAuth,
216
228
  ScopeDefs extends
217
229
  readonly SimpleScopeDefinition[] = readonly SimpleScopeDefinition[],
218
230
  >(
219
- options: CreateServerHandlerOptions<ServerDB, ClientDB, TableName, ScopeDefs>
220
- ): ServerTableHandler<ServerDB> {
231
+ options: CreateServerHandlerOptions<
232
+ ServerDB,
233
+ ClientDB,
234
+ TableName,
235
+ Auth,
236
+ ScopeDefs
237
+ >
238
+ ): ServerTableHandler<ServerDB, Auth> {
221
239
  type OverloadParameters<T> = T extends (...args: infer A) => unknown
222
240
  ? A
223
241
  : never;
@@ -239,20 +257,20 @@ export function createServerHandler<
239
257
  resolveScopes,
240
258
  transformInbound,
241
259
  transformOutbound,
242
- columnCodecs,
260
+ codecs,
243
261
  codecDialect = 'sqlite',
244
262
  authorize,
245
263
  extractScopes: customExtractScopes,
246
264
  } = options;
247
265
  const codecCache = new Map<string, ReturnType<typeof toTableColumnCodecs>>();
248
266
  const resolveTableCodecs = (row: Record<string, unknown>) => {
249
- if (!columnCodecs) return {};
267
+ if (!codecs) return {};
250
268
  const columns = Object.keys(row);
251
269
  if (columns.length === 0) return {};
252
270
  const cacheKey = columns.slice().sort().join('\u0000');
253
271
  const cached = codecCache.get(cacheKey);
254
272
  if (cached) return cached;
255
- const resolved = toTableColumnCodecs(table, columnCodecs, columns, {
273
+ const resolved = toTableColumnCodecs(table, codecs, columns, {
256
274
  dialect: codecDialect,
257
275
  });
258
276
  codecCache.set(cacheKey, resolved);
@@ -298,7 +316,7 @@ export function createServerHandler<
298
316
  const extractScopesImpl = customExtractScopes ?? defaultExtractScopes;
299
317
 
300
318
  const resolveScopesImpl = async (
301
- ctx: ServerContext<ServerDB>
319
+ ctx: ServerContext<ServerDB, Auth>
302
320
  ): Promise<ScopeValues> => {
303
321
  const resolved = await resolveScopes(ctx);
304
322
  const normalized: ScopeValues = {};
@@ -312,7 +330,7 @@ export function createServerHandler<
312
330
 
313
331
  const applyOutboundTransform = (
314
332
  row: Selectable<ServerDB[TableName]>
315
- ): ClientDB[TableName] => {
333
+ ): Selectable<ClientDB[TableName]> => {
316
334
  if (transformOutbound) {
317
335
  return transformOutbound(row);
318
336
  }
@@ -323,7 +341,7 @@ export function createServerHandler<
323
341
  resolveTableCodecs(recordRow),
324
342
  codecDialect
325
343
  );
326
- return transformed as ClientDB[TableName];
344
+ return transformed as Selectable<ClientDB[TableName]>;
327
345
  };
328
346
 
329
347
  const applyInboundTransform = (
@@ -331,7 +349,7 @@ export function createServerHandler<
331
349
  schemaVersion: number | undefined
332
350
  ): Updateable<ServerDB[TableName]> => {
333
351
  if (transformInbound) {
334
- return transformInbound(row as ClientDB[TableName], {
352
+ return transformInbound(row as Selectable<ClientDB[TableName]>, {
335
353
  schemaVersion,
336
354
  });
337
355
  }
@@ -346,7 +364,7 @@ export function createServerHandler<
346
364
 
347
365
  // Default snapshot implementation
348
366
  const defaultSnapshot = async (
349
- ctx: ServerSnapshotContext<ServerDB>,
367
+ ctx: ServerSnapshotContext<ServerDB, string, Auth>,
350
368
  _params: Record<string, unknown> | undefined
351
369
  ): Promise<{ rows: unknown[]; nextCursor: string | null }> => {
352
370
  const trx = ctx.db;
@@ -415,7 +433,7 @@ export function createServerHandler<
415
433
 
416
434
  // Default applyOperation implementation
417
435
  const defaultApplyOperation = async (
418
- ctx: ServerApplyOperationContext<ServerDB>,
436
+ ctx: ServerApplyOperationContext<ServerDB, Auth>,
419
437
  op: SyncOperation,
420
438
  opIndex: number
421
439
  ): Promise<ApplyOperationResult> => {
@@ -496,96 +514,19 @@ export function createServerHandler<
496
514
  : {};
497
515
  const payload = applyInboundTransform(payloadRecord, ctx.schemaVersion);
498
516
 
499
- // Check whether the row exists and fetch only version metadata for hot path.
500
- const existingRow = await (
501
- trx.selectFrom(table) as SelectQueryBuilder<
502
- ServerDB,
503
- keyof ServerDB & string,
504
- Record<string, unknown>
505
- >
506
- )
507
- .select(ref<string>(versionColumn))
508
- .where(ref<string>(primaryKey), '=', op.row_id)
509
- .executeTakeFirst();
510
-
511
- const hasExistingRow = existingRow !== undefined;
512
- const existingVersion =
513
- (existingRow?.[versionColumn] as number | undefined) ?? 0;
514
-
515
- // Check version conflict
516
- if (
517
- hasExistingRow &&
518
- op.base_version != null &&
519
- existingVersion !== op.base_version
520
- ) {
521
- const conflictRow = await (
522
- trx.selectFrom(table).selectAll() as SelectQueryBuilder<
523
- ServerDB,
524
- keyof ServerDB & string,
525
- Record<string, unknown>
526
- >
527
- )
528
- .where(ref<string>(primaryKey), '=', op.row_id)
529
- .executeTakeFirst();
530
-
531
- if (!conflictRow) {
532
- return {
533
- result: {
534
- opIndex,
535
- status: 'error',
536
- error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
537
- code: 'ROW_MISSING',
538
- retriable: false,
539
- },
540
- emittedChanges: [],
541
- };
542
- }
543
-
544
- return {
545
- result: {
546
- opIndex,
547
- status: 'conflict',
548
- message: `Version conflict: server=${existingVersion}, base=${op.base_version}`,
549
- server_version: existingVersion,
550
- server_row: applyOutboundTransform(
551
- conflictRow as Selectable<ServerDB[TableName]>
552
- ),
553
- },
554
- emittedChanges: [],
555
- };
556
- }
557
-
558
- // If the client provided a base version, they expected this row to exist.
559
- // A missing row usually indicates stale local state after a server reset.
560
- if (!hasExistingRow && op.base_version != null) {
561
- return {
562
- result: {
563
- opIndex,
564
- status: 'error',
565
- error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
566
- code: 'ROW_MISSING',
567
- retriable: false,
568
- },
569
- emittedChanges: [],
570
- };
571
- }
572
-
573
- const nextVersion = existingVersion + 1;
574
-
575
517
  let updated: Record<string, unknown> | undefined;
576
518
  let constraintError: { message: string; code: string } | null = null;
577
519
 
578
520
  try {
579
- if (hasExistingRow) {
580
- // Update - merge payload with existing
581
- const updateSet: Record<string, unknown> = {
521
+ if (op.base_version != null) {
522
+ const expectedVersion = op.base_version;
523
+ const conditionalUpdateSet: Record<string, unknown> = {
582
524
  ...payload,
583
- [versionColumn]: nextVersion,
525
+ [versionColumn]: expectedVersion + 1,
584
526
  };
585
- // Don't update primary key or scope columns
586
- delete updateSet[primaryKey];
527
+ delete conditionalUpdateSet[primaryKey];
587
528
  for (const col of Object.values(scopeColumns)) {
588
- delete updateSet[col];
529
+ delete conditionalUpdateSet[col];
589
530
  }
590
531
 
591
532
  updated = (await (
@@ -596,31 +537,144 @@ export function createServerHandler<
596
537
  UpdateResult
597
538
  >
598
539
  )
599
- .set(updateSet as UpdateSetObject)
540
+ .set(conditionalUpdateSet as UpdateSetObject)
600
541
  .where(ref<string>(primaryKey), '=', op.row_id)
542
+ .where(ref<string>(versionColumn), '=', expectedVersion)
601
543
  .returningAll()
602
544
  .executeTakeFirst()) as Record<string, unknown> | undefined;
545
+
546
+ if (!updated) {
547
+ const conflictRow = await (
548
+ trx.selectFrom(table).selectAll() as SelectQueryBuilder<
549
+ ServerDB,
550
+ keyof ServerDB & string,
551
+ Record<string, unknown>
552
+ >
553
+ )
554
+ .where(ref<string>(primaryKey), '=', op.row_id)
555
+ .executeTakeFirst();
556
+
557
+ if (!conflictRow) {
558
+ return {
559
+ result: {
560
+ opIndex,
561
+ status: 'error',
562
+ error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
563
+ code: 'ROW_MISSING',
564
+ retriable: false,
565
+ },
566
+ emittedChanges: [],
567
+ };
568
+ }
569
+
570
+ const existingVersion =
571
+ (conflictRow[versionColumn] as number | undefined) ?? 0;
572
+ return {
573
+ result: {
574
+ opIndex,
575
+ status: 'conflict',
576
+ message: `Version conflict: server=${existingVersion}, base=${expectedVersion}`,
577
+ server_version: existingVersion,
578
+ server_row: applyOutboundTransform(
579
+ conflictRow as Selectable<ServerDB[TableName]>
580
+ ),
581
+ },
582
+ emittedChanges: [],
583
+ };
584
+ }
603
585
  } else {
604
- // Insert
605
- const insertValues: Record<string, unknown> = {
586
+ const updateSet: Record<string, unknown> = {
606
587
  ...payload,
607
- [primaryKey]: op.row_id,
608
- [versionColumn]: 1,
588
+ [versionColumn]: sql`${sql.ref(versionColumn)} + 1`,
609
589
  };
590
+ delete updateSet[primaryKey];
591
+ for (const col of Object.values(scopeColumns)) {
592
+ delete updateSet[col];
593
+ }
610
594
 
611
595
  updated = (await (
612
- trx.insertInto(table) as InsertQueryBuilder<
596
+ trx.updateTable(table) as UpdateQueryBuilder<
613
597
  ServerDB,
614
598
  TableName,
615
- InsertResult
599
+ TableName,
600
+ UpdateResult
616
601
  >
617
602
  )
618
- .values(insertValues as Insertable<ServerDB[TableName]>)
603
+ .set(updateSet as UpdateSetObject)
604
+ .where(ref<string>(primaryKey), '=', op.row_id)
619
605
  .returningAll()
620
606
  .executeTakeFirst()) as Record<string, unknown> | undefined;
607
+
608
+ if (!updated) {
609
+ const insertValues: Record<string, unknown> = {
610
+ ...payload,
611
+ [primaryKey]: op.row_id,
612
+ [versionColumn]: 1,
613
+ };
614
+
615
+ try {
616
+ updated = (await (
617
+ trx.insertInto(table) as InsertQueryBuilder<
618
+ ServerDB,
619
+ TableName,
620
+ InsertResult
621
+ >
622
+ )
623
+ .values(insertValues as Insertable<ServerDB[TableName]>)
624
+ .returningAll()
625
+ .executeTakeFirst()) as Record<string, unknown> | undefined;
626
+ } catch (err) {
627
+ const message = err instanceof Error ? err.message : String(err);
628
+ if (!isConstraintViolationError(message)) {
629
+ throw err;
630
+ }
631
+ updated = (await (
632
+ trx.updateTable(table) as UpdateQueryBuilder<
633
+ ServerDB,
634
+ TableName,
635
+ TableName,
636
+ UpdateResult
637
+ >
638
+ )
639
+ .set(updateSet as UpdateSetObject)
640
+ .where(ref<string>(primaryKey), '=', op.row_id)
641
+ .returningAll()
642
+ .executeTakeFirst()) as Record<string, unknown> | undefined;
643
+ if (!updated) {
644
+ constraintError = {
645
+ message,
646
+ code: classifyConstraintViolationCode(message),
647
+ };
648
+ }
649
+ }
650
+ }
621
651
  }
622
652
  } catch (err) {
623
653
  const message = err instanceof Error ? err.message : String(err);
654
+ if (op.base_version != null && isMissingColumnReferenceError(message)) {
655
+ const row = await (
656
+ trx.selectFrom(table).selectAll() as SelectQueryBuilder<
657
+ ServerDB,
658
+ keyof ServerDB & string,
659
+ Record<string, unknown>
660
+ >
661
+ )
662
+ .where(ref<string>(primaryKey), '=', op.row_id)
663
+ .executeTakeFirst();
664
+ if (!row) {
665
+ return {
666
+ result: {
667
+ opIndex,
668
+ status: 'error',
669
+ error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
670
+ code: 'ROW_MISSING',
671
+ retriable: false,
672
+ },
673
+ emittedChanges: [],
674
+ };
675
+ }
676
+ }
677
+
624
678
  if (!isConstraintViolationError(message)) {
625
679
  throw err;
626
680
  }
@@ -1,3 +1,3 @@
1
+ export * from './collection';
1
2
  export * from './create-handler';
2
- export * from './registry';
3
3
  export * from './types';
@@ -11,6 +11,11 @@ import type { ZodSchema, z } from 'zod';
11
11
  import type { DbExecutor } from '../dialect/types';
12
12
  import type { SyncCoreDb } from '../schema';
13
13
 
14
+ export interface SyncServerAuth {
15
+ actorId: string;
16
+ partitionId?: string;
17
+ }
18
+
14
19
  /**
15
20
  * Emitted change to be stored in the oplog.
16
21
  * Uses JSONB scopes instead of scope_keys array.
@@ -41,11 +46,16 @@ export interface ApplyOperationResult {
41
46
  /**
42
47
  * Context for server operations.
43
48
  */
44
- export interface ServerContext<DB extends SyncCoreDb = SyncCoreDb> {
49
+ export interface ServerContext<
50
+ DB extends SyncCoreDb = SyncCoreDb,
51
+ Auth extends SyncServerAuth = SyncServerAuth,
52
+ > {
45
53
  /** Database connection (transaction in applyOperation) */
46
54
  db: DbExecutor<DB>;
47
55
  /** Actor ID (user ID from auth) */
48
56
  actorId: string;
57
+ /** Full auth payload returned by authenticate */
58
+ auth: Auth;
49
59
  }
50
60
 
51
61
  /**
@@ -54,7 +64,8 @@ export interface ServerContext<DB extends SyncCoreDb = SyncCoreDb> {
54
64
  export interface ServerSnapshotContext<
55
65
  DB extends SyncCoreDb = SyncCoreDb,
56
66
  ScopeKeys extends string = string,
57
- > extends ServerContext<DB> {
67
+ Auth extends SyncServerAuth = SyncServerAuth,
68
+ > extends ServerContext<DB, Auth> {
58
69
  /** Database executor for the snapshot */
59
70
  db: DbExecutor<DB>;
60
71
  /** Effective scope values for this subscription */
@@ -68,8 +79,10 @@ export interface ServerSnapshotContext<
68
79
  /**
69
80
  * Context passed to applyOperation method.
70
81
  */
71
- export interface ServerApplyOperationContext<DB extends SyncCoreDb = SyncCoreDb>
72
- extends ServerContext<DB> {
82
+ export interface ServerApplyOperationContext<
83
+ DB extends SyncCoreDb = SyncCoreDb,
84
+ Auth extends SyncServerAuth = SyncServerAuth,
85
+ > extends ServerContext<DB, Auth> {
73
86
  /** Database executor for the operation */
74
87
  trx: DbExecutor<DB>;
75
88
  /** Client/device identifier */
@@ -127,6 +140,7 @@ interface ServerScopeConfig {
127
140
  */
128
141
  export interface ServerHandlerOptions<
129
142
  DB extends SyncCoreDb = SyncCoreDb,
143
+ Auth extends SyncServerAuth = SyncServerAuth,
130
144
  Scopes extends Record<ScopePattern, Record<string, string>> = Record<
131
145
  ScopePattern,
132
146
  Record<string, string>
@@ -159,7 +173,7 @@ export interface ServerHandlerOptions<
159
173
  * project_id: ctx.user.projectIds,
160
174
  * })
161
175
  */
162
- resolveScopes: (ctx: ServerContext<DB>) => Promise<ScopeValues>;
176
+ resolveScopes: (ctx: ServerContext<DB, Auth>) => Promise<ScopeValues>;
163
177
 
164
178
  /**
165
179
  * Optional Zod schema for subscription parameters.
@@ -191,7 +205,7 @@ export interface ServerHandlerOptions<
191
205
  */
192
206
  transformInbound?: (
193
207
  payload: Record<string, unknown>,
194
- ctx: ServerApplyOperationContext<DB>
208
+ ctx: ServerApplyOperationContext<DB, Auth>
195
209
  ) => Partial<DB[TableName & keyof DB]>;
196
210
 
197
211
  /**
@@ -206,7 +220,7 @@ export interface ServerHandlerOptions<
206
220
  * Default uses keyset pagination ordered by primary key.
207
221
  */
208
222
  snapshot?: (
209
- ctx: ServerSnapshotContext<DB>,
223
+ ctx: ServerSnapshotContext<DB, string, Auth>,
210
224
  params: Params extends ZodSchema ? z.infer<Params> : undefined
211
225
  ) => Promise<{ rows: unknown[]; nextCursor: string | null }>;
212
226
 
@@ -214,7 +228,7 @@ export interface ServerHandlerOptions<
214
228
  * Custom apply operation implementation.
215
229
  */
216
230
  applyOperation?: (
217
- ctx: ServerApplyOperationContext<DB>,
231
+ ctx: ServerApplyOperationContext<DB, Auth>,
218
232
  op: SyncOperation,
219
233
  opIndex: number
220
234
  ) => Promise<ApplyOperationResult>;
@@ -224,7 +238,10 @@ export interface ServerHandlerOptions<
224
238
  * Server-side table handler for snapshots and mutations.
225
239
  * This is the internal handler interface used by the sync engine.
226
240
  */
227
- export interface ServerTableHandler<DB extends SyncCoreDb = SyncCoreDb> {
241
+ export interface ServerTableHandler<
242
+ DB extends SyncCoreDb = SyncCoreDb,
243
+ Auth extends SyncServerAuth = SyncServerAuth,
244
+ > {
228
245
  /** Table name */
229
246
  table: string;
230
247
 
@@ -244,7 +261,7 @@ export interface ServerTableHandler<DB extends SyncCoreDb = SyncCoreDb> {
244
261
  /**
245
262
  * Resolve allowed scope values for the current actor.
246
263
  */
247
- resolveScopes: (ctx: ServerContext<DB>) => Promise<ScopeValues>;
264
+ resolveScopes: (ctx: ServerContext<DB, Auth>) => Promise<ScopeValues>;
248
265
 
249
266
  /**
250
267
  * Extract stored scopes from a row.
@@ -255,7 +272,7 @@ export interface ServerTableHandler<DB extends SyncCoreDb = SyncCoreDb> {
255
272
  * Build a bootstrap snapshot page.
256
273
  */
257
274
  snapshot(
258
- ctx: ServerSnapshotContext<DB>,
275
+ ctx: ServerSnapshotContext<DB, string, Auth>,
259
276
  params: Record<string, unknown> | undefined
260
277
  ): Promise<{ rows: unknown[]; nextCursor: string | null }>;
261
278
 
@@ -263,7 +280,7 @@ export interface ServerTableHandler<DB extends SyncCoreDb = SyncCoreDb> {
263
280
  * Apply a single operation.
264
281
  */
265
282
  applyOperation(
266
- ctx: ServerApplyOperationContext<DB>,
283
+ ctx: ServerApplyOperationContext<DB, Auth>,
267
284
  op: SyncOperation,
268
285
  opIndex: number
269
286
  ): Promise<ApplyOperationResult>;