@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.
- package/dist/dialect/base.d.ts +3 -3
- package/dist/dialect/base.d.ts.map +1 -1
- package/dist/dialect/base.js.map +1 -1
- package/dist/dialect/types.d.ts +5 -7
- package/dist/dialect/types.d.ts.map +1 -1
- package/dist/handlers/collection.d.ts +12 -0
- package/dist/handlers/collection.d.ts.map +1 -0
- package/dist/handlers/collection.js +64 -0
- package/dist/handlers/collection.js.map +1 -0
- package/dist/handlers/create-handler.d.ts +10 -10
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +101 -69
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/handlers/index.d.ts +1 -1
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +1 -1
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/types.d.ts +18 -12
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/notify.js +1 -1
- package/dist/notify.js.map +1 -1
- package/dist/proxy/collection.d.ts +9 -0
- package/dist/proxy/collection.d.ts.map +1 -0
- package/dist/proxy/collection.js +21 -0
- package/dist/proxy/collection.js.map +1 -0
- package/dist/proxy/handler.d.ts +3 -3
- package/dist/proxy/handler.d.ts.map +1 -1
- package/dist/proxy/handler.js +2 -1
- package/dist/proxy/handler.js.map +1 -1
- package/dist/proxy/index.d.ts +1 -1
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +3 -3
- package/dist/proxy/index.js.map +1 -1
- package/dist/proxy/oplog.js +1 -1
- package/dist/proxy/oplog.js.map +1 -1
- package/dist/pull.d.ts +12 -5
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +101 -55
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +5 -5
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +6 -4
- package/dist/push.js.map +1 -1
- package/dist/subscriptions/cache.d.ts +55 -0
- package/dist/subscriptions/cache.d.ts.map +1 -0
- package/dist/subscriptions/cache.js +206 -0
- package/dist/subscriptions/cache.js.map +1 -0
- package/dist/subscriptions/index.d.ts +1 -0
- package/dist/subscriptions/index.d.ts.map +1 -1
- package/dist/subscriptions/index.js +1 -0
- package/dist/subscriptions/index.js.map +1 -1
- package/dist/subscriptions/resolve.d.ts +7 -4
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +74 -11
- package/dist/subscriptions/resolve.js.map +1 -1
- package/dist/sync.d.ts +21 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +23 -0
- package/dist/sync.js.map +1 -0
- package/package.json +3 -3
- package/src/dialect/base.ts +5 -3
- package/src/dialect/types.ts +11 -8
- package/src/handlers/collection.ts +121 -0
- package/src/handlers/create-handler.ts +163 -109
- package/src/handlers/index.ts +1 -1
- package/src/handlers/types.ts +29 -12
- package/src/index.ts +1 -0
- package/src/notify.test.ts +25 -21
- package/src/notify.ts +1 -1
- package/src/proxy/collection.ts +39 -0
- package/src/proxy/handler.test.ts +15 -9
- package/src/proxy/handler.ts +4 -4
- package/src/proxy/index.ts +8 -3
- package/src/proxy/oplog.ts +1 -1
- package/src/pull.ts +155 -73
- package/src/push.ts +16 -9
- package/src/snapshot-chunks/db-metadata.test.ts +6 -3
- package/src/subscriptions/cache.ts +318 -0
- package/src/subscriptions/index.ts +1 -0
- package/src/subscriptions/resolve.test.ts +180 -0
- package/src/subscriptions/resolve.ts +94 -18
- package/src/sync.ts +101 -0
- package/dist/handlers/registry.d.ts +0 -20
- package/dist/handlers/registry.d.ts.map +0 -1
- package/dist/handlers/registry.js +0 -88
- package/dist/handlers/registry.js.map +0 -1
- package/dist/proxy/registry.d.ts +0 -35
- package/dist/proxy/registry.d.ts.map +0 -1
- package/dist/proxy/registry.js +0 -49
- package/dist/proxy/registry.js.map +0 -1
- package/src/handlers/registry.ts +0 -109
- 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
|
-
|
|
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<
|
|
220
|
-
|
|
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
|
-
|
|
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 (!
|
|
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,
|
|
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 (
|
|
580
|
-
|
|
581
|
-
const
|
|
521
|
+
if (op.base_version != null) {
|
|
522
|
+
const expectedVersion = op.base_version;
|
|
523
|
+
const conditionalUpdateSet: Record<string, unknown> = {
|
|
582
524
|
...payload,
|
|
583
|
-
[versionColumn]:
|
|
525
|
+
[versionColumn]: expectedVersion + 1,
|
|
584
526
|
};
|
|
585
|
-
|
|
586
|
-
delete updateSet[primaryKey];
|
|
527
|
+
delete conditionalUpdateSet[primaryKey];
|
|
587
528
|
for (const col of Object.values(scopeColumns)) {
|
|
588
|
-
delete
|
|
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(
|
|
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
|
-
|
|
605
|
-
const insertValues: Record<string, unknown> = {
|
|
586
|
+
const updateSet: Record<string, unknown> = {
|
|
606
587
|
...payload,
|
|
607
|
-
[
|
|
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.
|
|
596
|
+
trx.updateTable(table) as UpdateQueryBuilder<
|
|
613
597
|
ServerDB,
|
|
614
598
|
TableName,
|
|
615
|
-
|
|
599
|
+
TableName,
|
|
600
|
+
UpdateResult
|
|
616
601
|
>
|
|
617
602
|
)
|
|
618
|
-
.
|
|
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
|
}
|
package/src/handlers/index.ts
CHANGED
package/src/handlers/types.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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<
|
|
72
|
-
extends
|
|
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<
|
|
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>;
|