@syncular/server 0.0.1-60

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 (211) hide show
  1. package/dist/blobs/adapters/database.d.ts +83 -0
  2. package/dist/blobs/adapters/database.d.ts.map +1 -0
  3. package/dist/blobs/adapters/database.js +180 -0
  4. package/dist/blobs/adapters/database.js.map +1 -0
  5. package/dist/blobs/adapters/s3.d.ts +82 -0
  6. package/dist/blobs/adapters/s3.d.ts.map +1 -0
  7. package/dist/blobs/adapters/s3.js +170 -0
  8. package/dist/blobs/adapters/s3.js.map +1 -0
  9. package/dist/blobs/index.d.ts +9 -0
  10. package/dist/blobs/index.d.ts.map +1 -0
  11. package/dist/blobs/index.js +9 -0
  12. package/dist/blobs/index.js.map +1 -0
  13. package/dist/blobs/manager.d.ts +195 -0
  14. package/dist/blobs/manager.d.ts.map +1 -0
  15. package/dist/blobs/manager.js +440 -0
  16. package/dist/blobs/manager.js.map +1 -0
  17. package/dist/blobs/migrate.d.ts +27 -0
  18. package/dist/blobs/migrate.d.ts.map +1 -0
  19. package/dist/blobs/migrate.js +119 -0
  20. package/dist/blobs/migrate.js.map +1 -0
  21. package/dist/blobs/types.d.ts +54 -0
  22. package/dist/blobs/types.d.ts.map +1 -0
  23. package/dist/blobs/types.js +5 -0
  24. package/dist/blobs/types.js.map +1 -0
  25. package/dist/clients.d.ts +14 -0
  26. package/dist/clients.d.ts.map +1 -0
  27. package/dist/clients.js +7 -0
  28. package/dist/clients.js.map +1 -0
  29. package/dist/compaction.d.ts +27 -0
  30. package/dist/compaction.d.ts.map +1 -0
  31. package/dist/compaction.js +49 -0
  32. package/dist/compaction.js.map +1 -0
  33. package/dist/dialect/index.d.ts +5 -0
  34. package/dist/dialect/index.d.ts.map +1 -0
  35. package/dist/dialect/index.js +5 -0
  36. package/dist/dialect/index.js.map +1 -0
  37. package/dist/dialect/types.d.ts +170 -0
  38. package/dist/dialect/types.d.ts.map +1 -0
  39. package/dist/dialect/types.js +8 -0
  40. package/dist/dialect/types.js.map +1 -0
  41. package/dist/helpers/conflict.d.ts +52 -0
  42. package/dist/helpers/conflict.d.ts.map +1 -0
  43. package/dist/helpers/conflict.js +49 -0
  44. package/dist/helpers/conflict.js.map +1 -0
  45. package/dist/helpers/emitted-change.d.ts +56 -0
  46. package/dist/helpers/emitted-change.d.ts.map +1 -0
  47. package/dist/helpers/emitted-change.js +46 -0
  48. package/dist/helpers/emitted-change.js.map +1 -0
  49. package/dist/helpers/index.d.ts +10 -0
  50. package/dist/helpers/index.d.ts.map +1 -0
  51. package/dist/helpers/index.js +10 -0
  52. package/dist/helpers/index.js.map +1 -0
  53. package/dist/helpers/paginate.d.ts +49 -0
  54. package/dist/helpers/paginate.d.ts.map +1 -0
  55. package/dist/helpers/paginate.js +54 -0
  56. package/dist/helpers/paginate.js.map +1 -0
  57. package/dist/helpers/scope-strings.d.ts +74 -0
  58. package/dist/helpers/scope-strings.d.ts.map +1 -0
  59. package/dist/helpers/scope-strings.js +82 -0
  60. package/dist/helpers/scope-strings.js.map +1 -0
  61. package/dist/index.d.ts +28 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +27 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/migrate.d.ts +14 -0
  66. package/dist/migrate.d.ts.map +1 -0
  67. package/dist/migrate.js +13 -0
  68. package/dist/migrate.js.map +1 -0
  69. package/dist/proxy/handler.d.ts +42 -0
  70. package/dist/proxy/handler.d.ts.map +1 -0
  71. package/dist/proxy/handler.js +99 -0
  72. package/dist/proxy/handler.js.map +1 -0
  73. package/dist/proxy/index.d.ts +9 -0
  74. package/dist/proxy/index.d.ts.map +1 -0
  75. package/dist/proxy/index.js +14 -0
  76. package/dist/proxy/index.js.map +1 -0
  77. package/dist/proxy/mutation-detector.d.ts +31 -0
  78. package/dist/proxy/mutation-detector.d.ts.map +1 -0
  79. package/dist/proxy/mutation-detector.js +61 -0
  80. package/dist/proxy/mutation-detector.js.map +1 -0
  81. package/dist/proxy/oplog.d.ts +30 -0
  82. package/dist/proxy/oplog.d.ts.map +1 -0
  83. package/dist/proxy/oplog.js +110 -0
  84. package/dist/proxy/oplog.js.map +1 -0
  85. package/dist/proxy/registry.d.ts +35 -0
  86. package/dist/proxy/registry.d.ts.map +1 -0
  87. package/dist/proxy/registry.js +49 -0
  88. package/dist/proxy/registry.js.map +1 -0
  89. package/dist/proxy/types.d.ts +44 -0
  90. package/dist/proxy/types.d.ts.map +1 -0
  91. package/dist/proxy/types.js +7 -0
  92. package/dist/proxy/types.js.map +1 -0
  93. package/dist/prune.d.ts +37 -0
  94. package/dist/prune.d.ts.map +1 -0
  95. package/dist/prune.js +112 -0
  96. package/dist/prune.js.map +1 -0
  97. package/dist/pull.d.ts +31 -0
  98. package/dist/pull.d.ts.map +1 -0
  99. package/dist/pull.js +414 -0
  100. package/dist/pull.js.map +1 -0
  101. package/dist/push.d.ts +33 -0
  102. package/dist/push.d.ts.map +1 -0
  103. package/dist/push.js +329 -0
  104. package/dist/push.js.map +1 -0
  105. package/dist/realtime/in-memory.d.ts +13 -0
  106. package/dist/realtime/in-memory.d.ts.map +1 -0
  107. package/dist/realtime/in-memory.js +28 -0
  108. package/dist/realtime/in-memory.js.map +1 -0
  109. package/dist/realtime/index.d.ts +3 -0
  110. package/dist/realtime/index.d.ts.map +1 -0
  111. package/dist/realtime/index.js +2 -0
  112. package/dist/realtime/index.js.map +1 -0
  113. package/dist/realtime/types.d.ts +50 -0
  114. package/dist/realtime/types.d.ts.map +1 -0
  115. package/dist/realtime/types.js +7 -0
  116. package/dist/realtime/types.js.map +1 -0
  117. package/dist/schema.d.ts +164 -0
  118. package/dist/schema.d.ts.map +1 -0
  119. package/dist/schema.js +10 -0
  120. package/dist/schema.js.map +1 -0
  121. package/dist/shapes/create-handler.d.ts +119 -0
  122. package/dist/shapes/create-handler.d.ts.map +1 -0
  123. package/dist/shapes/create-handler.js +327 -0
  124. package/dist/shapes/create-handler.js.map +1 -0
  125. package/dist/shapes/index.d.ts +4 -0
  126. package/dist/shapes/index.d.ts.map +1 -0
  127. package/dist/shapes/index.js +4 -0
  128. package/dist/shapes/index.js.map +1 -0
  129. package/dist/shapes/registry.d.ts +20 -0
  130. package/dist/shapes/registry.d.ts.map +1 -0
  131. package/dist/shapes/registry.js +88 -0
  132. package/dist/shapes/registry.js.map +1 -0
  133. package/dist/shapes/types.d.ts +204 -0
  134. package/dist/shapes/types.d.ts.map +1 -0
  135. package/dist/shapes/types.js +2 -0
  136. package/dist/shapes/types.js.map +1 -0
  137. package/dist/snapshot-chunks/adapters/s3.d.ts +63 -0
  138. package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
  139. package/dist/snapshot-chunks/adapters/s3.js +50 -0
  140. package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
  141. package/dist/snapshot-chunks/db-metadata.d.ts +33 -0
  142. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
  143. package/dist/snapshot-chunks/db-metadata.js +169 -0
  144. package/dist/snapshot-chunks/db-metadata.js.map +1 -0
  145. package/dist/snapshot-chunks/index.d.ts +9 -0
  146. package/dist/snapshot-chunks/index.d.ts.map +1 -0
  147. package/dist/snapshot-chunks/index.js +9 -0
  148. package/dist/snapshot-chunks/index.js.map +1 -0
  149. package/dist/snapshot-chunks/types.d.ts +65 -0
  150. package/dist/snapshot-chunks/types.d.ts.map +1 -0
  151. package/dist/snapshot-chunks/types.js +8 -0
  152. package/dist/snapshot-chunks/types.js.map +1 -0
  153. package/dist/snapshot-chunks.d.ts +59 -0
  154. package/dist/snapshot-chunks.d.ts.map +1 -0
  155. package/dist/snapshot-chunks.js +202 -0
  156. package/dist/snapshot-chunks.js.map +1 -0
  157. package/dist/stats.d.ts +19 -0
  158. package/dist/stats.d.ts.map +1 -0
  159. package/dist/stats.js +57 -0
  160. package/dist/stats.js.map +1 -0
  161. package/dist/subscriptions/index.d.ts +2 -0
  162. package/dist/subscriptions/index.d.ts.map +1 -0
  163. package/dist/subscriptions/index.js +2 -0
  164. package/dist/subscriptions/index.js.map +1 -0
  165. package/dist/subscriptions/resolve.d.ts +35 -0
  166. package/dist/subscriptions/resolve.d.ts.map +1 -0
  167. package/dist/subscriptions/resolve.js +134 -0
  168. package/dist/subscriptions/resolve.js.map +1 -0
  169. package/package.json +80 -0
  170. package/src/blobs/adapters/database.ts +290 -0
  171. package/src/blobs/adapters/s3.ts +271 -0
  172. package/src/blobs/index.ts +9 -0
  173. package/src/blobs/manager.ts +600 -0
  174. package/src/blobs/migrate.ts +150 -0
  175. package/src/blobs/types.ts +70 -0
  176. package/src/clients.ts +21 -0
  177. package/src/compaction.ts +77 -0
  178. package/src/dialect/index.ts +5 -0
  179. package/src/dialect/types.ts +222 -0
  180. package/src/helpers/conflict.ts +64 -0
  181. package/src/helpers/emitted-change.ts +69 -0
  182. package/src/helpers/index.ts +10 -0
  183. package/src/helpers/paginate.ts +82 -0
  184. package/src/helpers/scope-strings.ts +101 -0
  185. package/src/index.ts +28 -0
  186. package/src/migrate.ts +20 -0
  187. package/src/proxy/handler.ts +152 -0
  188. package/src/proxy/index.ts +18 -0
  189. package/src/proxy/mutation-detector.ts +83 -0
  190. package/src/proxy/oplog.ts +144 -0
  191. package/src/proxy/registry.ts +56 -0
  192. package/src/proxy/types.ts +46 -0
  193. package/src/prune.ts +200 -0
  194. package/src/pull.ts +551 -0
  195. package/src/push.ts +457 -0
  196. package/src/realtime/in-memory.ts +33 -0
  197. package/src/realtime/index.ts +5 -0
  198. package/src/realtime/types.ts +55 -0
  199. package/src/schema.ts +172 -0
  200. package/src/shapes/create-handler.ts +590 -0
  201. package/src/shapes/index.ts +3 -0
  202. package/src/shapes/registry.ts +109 -0
  203. package/src/shapes/types.ts +267 -0
  204. package/src/snapshot-chunks/adapters/s3.ts +68 -0
  205. package/src/snapshot-chunks/db-metadata.ts +238 -0
  206. package/src/snapshot-chunks/index.ts +9 -0
  207. package/src/snapshot-chunks/types.ts +79 -0
  208. package/src/snapshot-chunks.ts +301 -0
  209. package/src/stats.ts +104 -0
  210. package/src/subscriptions/index.ts +1 -0
  211. package/src/subscriptions/resolve.ts +185 -0
@@ -0,0 +1,590 @@
1
+ /**
2
+ * @syncular/server - Declarative server handler helper
3
+ */
4
+
5
+ import type {
6
+ ScopePattern,
7
+ ScopeValues,
8
+ ScopeDefinition as SimpleScopeDefinition,
9
+ StoredScopes,
10
+ SyncOperation,
11
+ } from '@syncular/core';
12
+ import { extractScopeVars, normalizeScopes } from '@syncular/core';
13
+ import type {
14
+ DeleteQueryBuilder,
15
+ DeleteResult,
16
+ Insertable,
17
+ InsertQueryBuilder,
18
+ InsertResult,
19
+ Selectable,
20
+ SelectQueryBuilder,
21
+ Updateable,
22
+ UpdateQueryBuilder,
23
+ UpdateResult,
24
+ } from 'kysely';
25
+ import type { SyncCoreDb } from '../schema';
26
+ import type {
27
+ ApplyOperationResult,
28
+ EmittedChange,
29
+ ServerApplyOperationContext,
30
+ ServerContext,
31
+ ServerSnapshotContext,
32
+ ServerTableHandler,
33
+ } from './types';
34
+
35
+ /**
36
+ * Authorization result from authorize callback.
37
+ */
38
+ type AuthorizeResult =
39
+ | true
40
+ | { error: string; code: string; retriable?: boolean };
41
+
42
+ function classifyConstraintViolationCode(message: string): string {
43
+ const normalized = message.toLowerCase();
44
+ if (normalized.includes('not null')) return 'NOT_NULL_CONSTRAINT';
45
+ if (normalized.includes('unique')) return 'UNIQUE_CONSTRAINT';
46
+ if (normalized.includes('foreign key')) return 'FOREIGN_KEY_CONSTRAINT';
47
+ return 'CONSTRAINT_VIOLATION';
48
+ }
49
+
50
+ function isConstraintViolationError(message: string): boolean {
51
+ const normalized = message.toLowerCase();
52
+ return (
53
+ normalized.includes('constraint') ||
54
+ normalized.includes('not null') ||
55
+ normalized.includes('foreign key') ||
56
+ normalized.includes('unique')
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Scope definition for a column - maps scope variable to column name.
62
+ */
63
+ export type ScopeColumnMap = Record<string, string>;
64
+
65
+ /**
66
+ * Options for creating a declarative server handler.
67
+ */
68
+ export interface CreateServerHandlerOptions<
69
+ ServerDB extends SyncCoreDb,
70
+ ClientDB,
71
+ TableName extends keyof ServerDB & keyof ClientDB & string,
72
+ > {
73
+ /** Table name in the database */
74
+ table: TableName;
75
+
76
+ /**
77
+ * Scope definitions for this table.
78
+ * Can be simple strings (column auto-derived) or objects with explicit mapping.
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * // Simple: column auto-derived from placeholder
83
+ * scopes: ['user:{user_id}', 'org:{org_id}']
84
+ *
85
+ * // Explicit: when column differs from pattern variable
86
+ * scopes: [
87
+ * { pattern: 'user:{user_id}', column: 'owner_id' }
88
+ * ]
89
+ * ```
90
+ */
91
+ scopes: SimpleScopeDefinition[];
92
+
93
+ /** Primary key column name (default: 'id') */
94
+ primaryKey?: string;
95
+
96
+ /** Version column name (default: 'server_version') */
97
+ versionColumn?: string;
98
+
99
+ /** Tables this handler depends on (for bootstrap ordering) */
100
+ dependsOn?: string[];
101
+
102
+ /** TTL for cached snapshot chunks in ms */
103
+ snapshotChunkTtlMs?: number;
104
+
105
+ /**
106
+ * Resolve allowed scope values for the current actor.
107
+ * Called per request to determine what the actor can access.
108
+ *
109
+ * @example
110
+ * resolveScopes: async (ctx) => ({
111
+ * user_id: [ctx.actorId],
112
+ * project_id: await getProjectsForUser(ctx.db, ctx.actorId),
113
+ * })
114
+ */
115
+ resolveScopes: (ctx: ServerContext<ServerDB>) => Promise<ScopeValues>;
116
+
117
+ /**
118
+ * Transform inbound row from client to server format.
119
+ * Use ctx.schemaVersion to handle older client versions.
120
+ */
121
+ transformInbound?: (
122
+ row: ClientDB[TableName],
123
+ ctx: { schemaVersion?: number }
124
+ ) => Updateable<ServerDB[TableName]>;
125
+
126
+ /**
127
+ * Transform outbound row from server to client format.
128
+ */
129
+ transformOutbound?: (
130
+ row: Selectable<ServerDB[TableName]>
131
+ ) => ClientDB[TableName];
132
+
133
+ /**
134
+ * Authorize an operation before applying.
135
+ * Return true to allow, or an error object to reject.
136
+ */
137
+ authorize?: (
138
+ ctx: ServerApplyOperationContext<ServerDB>,
139
+ op: SyncOperation
140
+ ) => Promise<AuthorizeResult>;
141
+
142
+ /**
143
+ * Override: Build snapshot query.
144
+ */
145
+ snapshot?: ServerTableHandler<ServerDB>['snapshot'];
146
+
147
+ /**
148
+ * Override: Apply operation.
149
+ */
150
+ applyOperation?: ServerTableHandler<ServerDB>['applyOperation'];
151
+
152
+ /**
153
+ * Custom scope extraction from row (for complex scope logic).
154
+ */
155
+ extractScopes?: (row: Record<string, unknown>) => StoredScopes;
156
+ }
157
+
158
+ /**
159
+ * Create a declarative server table handler with sensible defaults.
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * import { createServerHandler } from '@syncular/server';
164
+ * import type { ServerDb } from '../db';
165
+ * import type { ClientDb } from '../../shared/client-db.generated';
166
+ *
167
+ * export const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
168
+ * table: 'tasks',
169
+ * scopes: ['user:{user_id}'], // column auto-derived from placeholder
170
+ * resolveScopes: async (ctx) => ({
171
+ * user_id: [ctx.actorId],
172
+ * }),
173
+ * });
174
+ *
175
+ * // With custom column mapping:
176
+ * export const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
177
+ * table: 'tasks',
178
+ * scopes: [{ pattern: 'user:{user_id}', column: 'owner_id' }],
179
+ * resolveScopes: async (ctx) => ({
180
+ * user_id: [ctx.actorId],
181
+ * }),
182
+ * });
183
+ * ```
184
+ */
185
+ export function createServerHandler<
186
+ ServerDB extends SyncCoreDb,
187
+ ClientDB,
188
+ TableName extends keyof ServerDB & keyof ClientDB & string,
189
+ >(
190
+ options: CreateServerHandlerOptions<ServerDB, ClientDB, TableName>
191
+ ): ServerTableHandler<ServerDB> {
192
+ type OverloadParameters<T> = T extends (...args: infer A) => unknown
193
+ ? A
194
+ : never;
195
+
196
+ type UpdateSetObject = Extract<
197
+ OverloadParameters<
198
+ UpdateQueryBuilder<ServerDB, TableName, TableName, UpdateResult>['set']
199
+ >,
200
+ [unknown]
201
+ >[0];
202
+
203
+ const {
204
+ table,
205
+ scopes: scopeDefs,
206
+ primaryKey = 'id',
207
+ versionColumn = 'server_version',
208
+ dependsOn,
209
+ snapshotChunkTtlMs,
210
+ resolveScopes,
211
+ transformInbound,
212
+ transformOutbound,
213
+ authorize,
214
+ extractScopes: customExtractScopes,
215
+ } = options;
216
+
217
+ // Normalize scopes to pattern map and extract patterns/columns
218
+ const scopeColumnMap = normalizeScopes(scopeDefs);
219
+ const scopePatterns = Object.keys(scopeColumnMap) as ScopePattern[];
220
+ const scopeColumns: ScopeColumnMap = {};
221
+
222
+ for (const [pattern, columnName] of Object.entries(scopeColumnMap)) {
223
+ const vars = extractScopeVars(pattern);
224
+ if (vars.length !== 1) {
225
+ throw new Error(
226
+ `Scope pattern "${pattern}" must contain exactly one placeholder (got ${vars.length}).`
227
+ );
228
+ }
229
+ const varName = vars[0]!;
230
+ const existing = scopeColumns[varName];
231
+ if (existing && existing !== columnName) {
232
+ throw new Error(
233
+ `Scope variable "${varName}" is mapped to multiple columns: "${existing}" and "${columnName}".`
234
+ );
235
+ }
236
+ scopeColumns[varName] = columnName;
237
+ }
238
+
239
+ // Default extractScopes from scope columns
240
+ const defaultExtractScopes = (row: Record<string, unknown>): StoredScopes => {
241
+ const scopes: StoredScopes = {};
242
+ for (const [varName, columnName] of Object.entries(scopeColumns)) {
243
+ const raw = row[columnName];
244
+ if (raw === null || raw === undefined) continue;
245
+ const value = String(raw);
246
+ if (value.length > 0) {
247
+ scopes[varName] = value;
248
+ }
249
+ }
250
+ return scopes;
251
+ };
252
+
253
+ const extractScopesImpl = customExtractScopes ?? defaultExtractScopes;
254
+
255
+ // Default snapshot implementation
256
+ const defaultSnapshot = async (
257
+ ctx: ServerSnapshotContext<ServerDB>,
258
+ _params: Record<string, unknown> | undefined
259
+ ): Promise<{ rows: unknown[]; nextCursor: string | null }> => {
260
+ const trx = ctx.db;
261
+ const { ref } = trx.dynamic;
262
+ const scopeValues = ctx.scopeValues;
263
+
264
+ const pageSize = Math.max(1, Math.min(10_000, ctx.limit));
265
+
266
+ // Build dynamic WHERE conditions
267
+ const whereConditions: Array<{ column: string; values: string[] }> = [];
268
+ for (const [varName, columnName] of Object.entries(scopeColumns)) {
269
+ const values = scopeValues[varName];
270
+ if (values === undefined) continue;
271
+ const normalized = Array.isArray(values) ? values : [values];
272
+ if (normalized.length === 0) continue;
273
+ whereConditions.push({ column: columnName, values: normalized });
274
+ }
275
+
276
+ let q = trx.selectFrom(table).selectAll() as SelectQueryBuilder<
277
+ ServerDB,
278
+ keyof ServerDB & string,
279
+ Record<string, unknown>
280
+ >;
281
+
282
+ for (const cond of whereConditions) {
283
+ if (cond.values.length === 1) {
284
+ q = q.where(ref<string>(cond.column), '=', cond.values[0]);
285
+ } else {
286
+ q = q.where(ref<string>(cond.column), 'in', cond.values);
287
+ }
288
+ }
289
+
290
+ if (ctx.cursor !== null) {
291
+ q = q.where(ref<string>(primaryKey), '>', ctx.cursor);
292
+ }
293
+
294
+ const rows = await q
295
+ .orderBy(ref<string>(primaryKey), 'asc')
296
+ .limit(pageSize + 1)
297
+ .execute();
298
+
299
+ const hasMore = rows.length > pageSize;
300
+ const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
301
+ const lastRow = pageRows[pageRows.length - 1] as
302
+ | (typeof rows)[number]
303
+ | undefined;
304
+ const nextCursor = hasMore
305
+ ? ((lastRow as Record<string, unknown> | undefined)?.[primaryKey] as
306
+ | string
307
+ | undefined)
308
+ : null;
309
+
310
+ // Transform outbound if provided
311
+ const outputRows = transformOutbound
312
+ ? pageRows.map((r) =>
313
+ transformOutbound(r as Selectable<ServerDB[TableName]>)
314
+ )
315
+ : pageRows;
316
+
317
+ return {
318
+ rows: outputRows,
319
+ nextCursor:
320
+ typeof nextCursor === 'string' && nextCursor.length > 0
321
+ ? nextCursor
322
+ : null,
323
+ };
324
+ };
325
+
326
+ // Default applyOperation implementation
327
+ const defaultApplyOperation = async (
328
+ ctx: ServerApplyOperationContext<ServerDB>,
329
+ op: SyncOperation,
330
+ opIndex: number
331
+ ): Promise<ApplyOperationResult> => {
332
+ const trx = ctx.trx;
333
+ const { ref } = trx.dynamic;
334
+
335
+ if (op.table !== table) {
336
+ return {
337
+ result: {
338
+ opIndex,
339
+ status: 'error',
340
+ error: `UNKNOWN_TABLE:${op.table}`,
341
+ code: 'UNKNOWN_TABLE',
342
+ retriable: false,
343
+ },
344
+ emittedChanges: [],
345
+ };
346
+ }
347
+
348
+ // Run authorization if provided
349
+ if (authorize) {
350
+ const authResult = await authorize(ctx, op);
351
+ if (authResult !== true) {
352
+ return {
353
+ result: {
354
+ opIndex,
355
+ status: 'error',
356
+ error: authResult.error,
357
+ code: authResult.code,
358
+ retriable: authResult.retriable ?? false,
359
+ },
360
+ emittedChanges: [],
361
+ };
362
+ }
363
+ }
364
+
365
+ // Handle delete
366
+ if (op.op === 'delete') {
367
+ const existing = await (
368
+ trx.selectFrom(table).selectAll() as SelectQueryBuilder<
369
+ ServerDB,
370
+ keyof ServerDB & string,
371
+ Record<string, unknown>
372
+ >
373
+ )
374
+ .where(ref<string>(primaryKey), '=', op.row_id)
375
+ .executeTakeFirst();
376
+
377
+ if (!existing) {
378
+ return { result: { opIndex, status: 'applied' }, emittedChanges: [] };
379
+ }
380
+
381
+ // Extract scopes from existing row for the delete emission
382
+ const scopes = extractScopesImpl(existing as Record<string, unknown>);
383
+
384
+ await (
385
+ trx.deleteFrom(table) as DeleteQueryBuilder<
386
+ ServerDB,
387
+ keyof ServerDB & string,
388
+ DeleteResult
389
+ >
390
+ )
391
+ .where(ref<string>(primaryKey), '=', op.row_id)
392
+ .execute();
393
+
394
+ const emitted: EmittedChange = {
395
+ table,
396
+ row_id: op.row_id,
397
+ op: 'delete',
398
+ row_json: null,
399
+ row_version: null,
400
+ scopes,
401
+ };
402
+
403
+ return {
404
+ result: { opIndex, status: 'applied' },
405
+ emittedChanges: [emitted],
406
+ };
407
+ }
408
+
409
+ // Handle upsert
410
+ const rawPayload = op.payload ?? {};
411
+ const payload = transformInbound
412
+ ? transformInbound(rawPayload as ClientDB[TableName], {
413
+ schemaVersion: ctx.schemaVersion,
414
+ })
415
+ : (rawPayload as Updateable<ServerDB[TableName]>);
416
+
417
+ // Check for existing row
418
+ const existing = await (
419
+ trx.selectFrom(table).selectAll() as SelectQueryBuilder<
420
+ ServerDB,
421
+ keyof ServerDB & string,
422
+ Record<string, unknown>
423
+ >
424
+ )
425
+ .where(ref<string>(primaryKey), '=', op.row_id)
426
+ .executeTakeFirst();
427
+
428
+ const existingRow = existing as Record<string, unknown> | undefined;
429
+ const existingVersion =
430
+ (existingRow?.[versionColumn] as number | undefined) ?? 0;
431
+
432
+ // Check version conflict
433
+ if (
434
+ existing &&
435
+ op.base_version != null &&
436
+ existingVersion !== op.base_version
437
+ ) {
438
+ return {
439
+ result: {
440
+ opIndex,
441
+ status: 'conflict',
442
+ message: `Version conflict: server=${existingVersion}, base=${op.base_version}`,
443
+ server_version: existingVersion,
444
+ server_row: transformOutbound
445
+ ? transformOutbound(existing as Selectable<ServerDB[TableName]>)
446
+ : existing,
447
+ },
448
+ emittedChanges: [],
449
+ };
450
+ }
451
+
452
+ // If the client provided a base version, they expected this row to exist.
453
+ // A missing row usually indicates stale local state after a server reset.
454
+ if (!existing && op.base_version != null) {
455
+ return {
456
+ result: {
457
+ opIndex,
458
+ status: 'error',
459
+ error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
460
+ code: 'ROW_MISSING',
461
+ retriable: false,
462
+ },
463
+ emittedChanges: [],
464
+ };
465
+ }
466
+
467
+ const nextVersion = existingVersion + 1;
468
+
469
+ let updated: Record<string, unknown> | undefined;
470
+ let constraintError: { message: string; code: string } | null = null;
471
+
472
+ try {
473
+ if (existing) {
474
+ // Update - merge payload with existing
475
+ const updateSet: Record<string, unknown> = {
476
+ ...payload,
477
+ [versionColumn]: nextVersion,
478
+ };
479
+ // Don't update primary key or scope columns
480
+ delete updateSet[primaryKey];
481
+ for (const col of Object.values(scopeColumns)) {
482
+ delete updateSet[col];
483
+ }
484
+
485
+ await (
486
+ trx.updateTable(table) as UpdateQueryBuilder<
487
+ ServerDB,
488
+ TableName,
489
+ TableName,
490
+ UpdateResult
491
+ >
492
+ )
493
+ .set(updateSet as UpdateSetObject)
494
+ .where(ref<string>(primaryKey), '=', op.row_id)
495
+ .execute();
496
+ } else {
497
+ // Insert
498
+ const insertValues: Record<string, unknown> = {
499
+ ...payload,
500
+ [primaryKey]: op.row_id,
501
+ [versionColumn]: 1,
502
+ };
503
+
504
+ await (
505
+ trx.insertInto(table) as InsertQueryBuilder<
506
+ ServerDB,
507
+ TableName,
508
+ InsertResult
509
+ >
510
+ )
511
+ .values(insertValues as Insertable<ServerDB[TableName]>)
512
+ .execute();
513
+ }
514
+
515
+ // Read back the updated row
516
+ updated = await (
517
+ trx.selectFrom(table).selectAll() as SelectQueryBuilder<
518
+ ServerDB,
519
+ keyof ServerDB & string,
520
+ Record<string, unknown>
521
+ >
522
+ )
523
+ .where(ref<string>(primaryKey), '=', op.row_id)
524
+ .executeTakeFirstOrThrow();
525
+ } catch (err) {
526
+ const message = err instanceof Error ? err.message : String(err);
527
+ if (!isConstraintViolationError(message)) {
528
+ throw err;
529
+ }
530
+
531
+ constraintError = {
532
+ message,
533
+ code: classifyConstraintViolationCode(message),
534
+ };
535
+ }
536
+
537
+ if (constraintError) {
538
+ return {
539
+ result: {
540
+ opIndex,
541
+ status: 'error',
542
+ error: constraintError.message,
543
+ code: constraintError.code,
544
+ retriable: false,
545
+ },
546
+ emittedChanges: [],
547
+ };
548
+ }
549
+
550
+ if (!updated) {
551
+ throw new Error('Updated row is missing after applyOperation');
552
+ }
553
+
554
+ const updatedRow = updated;
555
+ const rowVersion = (updatedRow[versionColumn] as number) ?? 1;
556
+
557
+ // Extract scopes from updated row
558
+ const scopes = extractScopesImpl(updatedRow);
559
+
560
+ // Transform outbound for emitted change
561
+ const rowJson = transformOutbound
562
+ ? transformOutbound(updated as Selectable<ServerDB[TableName]>)
563
+ : updated;
564
+
565
+ const emitted: EmittedChange = {
566
+ table,
567
+ row_id: op.row_id,
568
+ op: 'upsert',
569
+ row_json: rowJson,
570
+ row_version: rowVersion,
571
+ scopes,
572
+ };
573
+
574
+ return {
575
+ result: { opIndex, status: 'applied' },
576
+ emittedChanges: [emitted],
577
+ };
578
+ };
579
+
580
+ return {
581
+ table,
582
+ scopePatterns,
583
+ dependsOn,
584
+ snapshotChunkTtlMs,
585
+ resolveScopes,
586
+ extractScopes: extractScopesImpl,
587
+ snapshot: options.snapshot ?? defaultSnapshot,
588
+ applyOperation: options.applyOperation ?? defaultApplyOperation,
589
+ };
590
+ }
@@ -0,0 +1,3 @@
1
+ export * from './create-handler';
2
+ export * from './registry';
3
+ export * from './types';
@@ -0,0 +1,109 @@
1
+ import type { SyncCoreDb } from '../schema';
2
+ import type { ServerTableHandler } from './types';
3
+
4
+ export class TableRegistry<DB extends SyncCoreDb = SyncCoreDb> {
5
+ private tables = new Map<string, ServerTableHandler<DB>>();
6
+
7
+ register(handler: ServerTableHandler<DB>): this {
8
+ if (this.tables.has(handler.table)) {
9
+ throw new Error(`Table "${handler.table}" is already registered`);
10
+ }
11
+
12
+ // Validate dependencies exist
13
+ for (const dep of handler.dependsOn ?? []) {
14
+ if (!this.tables.has(dep)) {
15
+ throw new Error(
16
+ `Table "${handler.table}" depends on unknown table "${dep}"`
17
+ );
18
+ }
19
+ }
20
+
21
+ this.tables.set(handler.table, handler);
22
+ return this;
23
+ }
24
+
25
+ get(table: string): ServerTableHandler<DB> | undefined {
26
+ return this.tables.get(table);
27
+ }
28
+
29
+ getOrThrow(table: string): ServerTableHandler<DB> {
30
+ const handler = this.tables.get(table);
31
+ if (!handler) throw new Error(`Unknown table: ${table}`);
32
+ return handler;
33
+ }
34
+
35
+ getAll(): ServerTableHandler<DB>[] {
36
+ return Array.from(this.tables.values());
37
+ }
38
+
39
+ /**
40
+ * Return tables in topological order (parents before children).
41
+ * Throws if a circular dependency is detected.
42
+ */
43
+ getBootstrapOrder(): ServerTableHandler<DB>[] {
44
+ const visited = new Set<string>();
45
+ const sorted: ServerTableHandler<DB>[] = [];
46
+ const visiting = new Set<string>();
47
+
48
+ const visit = (table: string) => {
49
+ if (visited.has(table)) return;
50
+ if (visiting.has(table)) {
51
+ throw new Error(
52
+ `Circular dependency detected involving table "${table}"`
53
+ );
54
+ }
55
+
56
+ visiting.add(table);
57
+ const handler = this.tables.get(table);
58
+ if (handler) {
59
+ for (const dep of handler.dependsOn ?? []) {
60
+ visit(dep);
61
+ }
62
+ visited.add(table);
63
+ visiting.delete(table);
64
+ sorted.push(handler);
65
+ }
66
+ };
67
+
68
+ for (const table of this.tables.keys()) {
69
+ visit(table);
70
+ }
71
+
72
+ return sorted;
73
+ }
74
+
75
+ /**
76
+ * Return bootstrap order for a target table and its dependencies.
77
+ * Parents are returned before children.
78
+ */
79
+ getBootstrapOrderFor(table: string): ServerTableHandler<DB>[] {
80
+ const visited = new Set<string>();
81
+ const sorted: ServerTableHandler<DB>[] = [];
82
+ const visiting = new Set<string>();
83
+
84
+ const visit = (name: string) => {
85
+ if (visited.has(name)) return;
86
+ if (visiting.has(name)) {
87
+ throw new Error(
88
+ `Circular dependency detected involving table "${name}"`
89
+ );
90
+ }
91
+
92
+ const handler = this.tables.get(name);
93
+ if (!handler) {
94
+ throw new Error(`Unknown table: ${name}`);
95
+ }
96
+
97
+ visiting.add(name);
98
+ for (const dep of handler.dependsOn ?? []) {
99
+ visit(dep);
100
+ }
101
+ visiting.delete(name);
102
+ visited.add(name);
103
+ sorted.push(handler);
104
+ };
105
+
106
+ visit(table);
107
+ return sorted;
108
+ }
109
+ }