@syncular/server 0.0.6-185 → 0.0.6-201
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/blobs/adapters/database.d.ts.map +1 -1
- package/dist/blobs/adapters/database.js +3 -2
- package/dist/blobs/adapters/database.js.map +1 -1
- package/dist/blobs/manager.d.ts.map +1 -1
- package/dist/blobs/manager.js +12 -7
- package/dist/blobs/manager.js.map +1 -1
- package/dist/handlers/create-handler.d.ts +6 -0
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +7 -2
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/handlers/types.d.ts +10 -0
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/notify.d.ts +30 -0
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +100 -0
- package/dist/notify.js.map +1 -1
- package/dist/pull.d.ts +11 -0
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +123 -25
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +10 -0
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +529 -443
- package/dist/push.js.map +1 -1
- package/package.json +2 -2
- package/src/blobs/adapters/database.ts +6 -4
- package/src/blobs/manager.ts +17 -11
- package/src/handlers/create-handler.ts +15 -1
- package/src/handlers/types.ts +12 -0
- package/src/notify.test.ts +125 -1
- package/src/notify.ts +155 -1
- package/src/pull.ts +200 -34
- package/src/push.ts +743 -572
package/src/push.ts
CHANGED
|
@@ -7,20 +7,14 @@ import {
|
|
|
7
7
|
type SyncPushResponse,
|
|
8
8
|
startSyncSpan,
|
|
9
9
|
} from '@syncular/core';
|
|
10
|
-
import type {
|
|
11
|
-
Insertable,
|
|
12
|
-
Kysely,
|
|
13
|
-
SelectQueryBuilder,
|
|
14
|
-
SqlBool,
|
|
15
|
-
Updateable,
|
|
16
|
-
} from 'kysely';
|
|
10
|
+
import type { Insertable, Kysely, SelectQueryBuilder, SqlBool } from 'kysely';
|
|
17
11
|
import { sql } from 'kysely';
|
|
18
12
|
import {
|
|
19
13
|
coerceNumber,
|
|
20
14
|
parseJsonValue,
|
|
21
15
|
toDialectJsonValue,
|
|
22
16
|
} from './dialect/helpers';
|
|
23
|
-
import type { ServerSyncDialect } from './dialect/types';
|
|
17
|
+
import type { DbExecutor, ServerSyncDialect } from './dialect/types';
|
|
24
18
|
import type { ServerHandlerCollection } from './handlers/collection';
|
|
25
19
|
import type { SyncServerAuth } from './handlers/types';
|
|
26
20
|
import {
|
|
@@ -31,6 +25,10 @@ import type { SyncCoreDb } from './schema';
|
|
|
31
25
|
|
|
32
26
|
// biome-ignore lint/complexity/noBannedTypes: Kysely uses `{}` as the initial "no selected columns yet" marker.
|
|
33
27
|
type EmptySelection = {};
|
|
28
|
+
type SyncMetadataTrx = Pick<
|
|
29
|
+
Kysely<SyncCoreDb>,
|
|
30
|
+
'selectFrom' | 'insertInto' | 'updateTable' | 'deleteFrom'
|
|
31
|
+
>;
|
|
34
32
|
|
|
35
33
|
export interface PushCommitResult {
|
|
36
34
|
response: SyncPushResponse;
|
|
@@ -102,7 +100,7 @@ function assertOperationIdentityUnchanged(
|
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
async function readCommitAffectedTables<DB extends SyncCoreDb>(
|
|
105
|
-
db:
|
|
103
|
+
db: DbExecutor<DB>,
|
|
106
104
|
dialect: ServerSyncDialect,
|
|
107
105
|
commitSeq: number,
|
|
108
106
|
partitionId: string
|
|
@@ -185,6 +183,588 @@ function recordPushMetrics(args: {
|
|
|
185
183
|
);
|
|
186
184
|
}
|
|
187
185
|
|
|
186
|
+
function createRejectedPushResult(
|
|
187
|
+
error: SyncPushResponse['results'][number]
|
|
188
|
+
): PushCommitResult {
|
|
189
|
+
return {
|
|
190
|
+
response: {
|
|
191
|
+
ok: true,
|
|
192
|
+
status: 'rejected',
|
|
193
|
+
results: [error],
|
|
194
|
+
},
|
|
195
|
+
affectedTables: [],
|
|
196
|
+
scopeKeys: [],
|
|
197
|
+
emittedChanges: [],
|
|
198
|
+
commitActorId: null,
|
|
199
|
+
commitCreatedAt: null,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function createRejectedPushResponse(
|
|
204
|
+
error: SyncPushResponse['results'][number],
|
|
205
|
+
commitSeq?: number
|
|
206
|
+
): SyncPushResponse {
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
status: 'rejected',
|
|
210
|
+
...(commitSeq !== undefined ? { commitSeq } : {}),
|
|
211
|
+
results: [error],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validatePushRequest(
|
|
216
|
+
request: SyncPushRequest
|
|
217
|
+
): SyncPushResponse['results'][number] | null {
|
|
218
|
+
if (!request.clientId || !request.clientCommitId) {
|
|
219
|
+
return {
|
|
220
|
+
opIndex: 0,
|
|
221
|
+
status: 'error',
|
|
222
|
+
error: 'INVALID_REQUEST',
|
|
223
|
+
code: 'INVALID_REQUEST',
|
|
224
|
+
retriable: false,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const ops = request.operations ?? [];
|
|
229
|
+
if (!Array.isArray(ops) || ops.length === 0) {
|
|
230
|
+
return {
|
|
231
|
+
opIndex: 0,
|
|
232
|
+
status: 'error',
|
|
233
|
+
error: 'EMPTY_COMMIT',
|
|
234
|
+
code: 'EMPTY_COMMIT',
|
|
235
|
+
retriable: false,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function shouldUseSavepoints<
|
|
243
|
+
DB extends SyncCoreDb,
|
|
244
|
+
Auth extends SyncServerAuth,
|
|
245
|
+
>(args: {
|
|
246
|
+
dialect: ServerSyncDialect;
|
|
247
|
+
handlers: ServerHandlerCollection<DB, Auth>;
|
|
248
|
+
operations: SyncPushRequest['operations'];
|
|
249
|
+
}): boolean {
|
|
250
|
+
if (!args.dialect.supportsSavepoints) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if ((args.operations?.length ?? 0) !== 1) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const singleOp = args.operations?.[0];
|
|
259
|
+
if (!singleOp) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const singleOpHandler = args.handlers.byTable.get(singleOp.table);
|
|
264
|
+
if (!singleOpHandler) {
|
|
265
|
+
throw new Error(`Unknown table: ${singleOp.table}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return !singleOpHandler.canRejectSingleOperationWithoutSavepoint;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function persistEmittedChanges<DB extends SyncCoreDb>(args: {
|
|
272
|
+
trx: DbExecutor<DB>;
|
|
273
|
+
dialect: ServerSyncDialect;
|
|
274
|
+
partitionId: string;
|
|
275
|
+
commitSeq: number;
|
|
276
|
+
emittedChanges: PushCommitResult['emittedChanges'];
|
|
277
|
+
}): Promise<void> {
|
|
278
|
+
if (args.emittedChanges.length === 0) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const syncTrx = args.trx as Pick<Kysely<SyncCoreDb>, 'insertInto'>;
|
|
283
|
+
const changeRows: Array<Insertable<SyncCoreDb['sync_changes']>> =
|
|
284
|
+
args.emittedChanges.map((change) => ({
|
|
285
|
+
partition_id: args.partitionId,
|
|
286
|
+
commit_seq: args.commitSeq,
|
|
287
|
+
table: change.table,
|
|
288
|
+
row_id: change.row_id,
|
|
289
|
+
op: change.op,
|
|
290
|
+
row_json: toDialectJsonValue(args.dialect, change.row_json),
|
|
291
|
+
row_version: change.row_version,
|
|
292
|
+
scopes: args.dialect.scopesToDb(change.scopes),
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
await syncTrx.insertInto('sync_changes').values(changeRows).execute();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function persistCommitOutcome<DB extends SyncCoreDb>(args: {
|
|
299
|
+
trx: DbExecutor<DB>;
|
|
300
|
+
dialect: ServerSyncDialect;
|
|
301
|
+
partitionId: string;
|
|
302
|
+
commitSeq: number;
|
|
303
|
+
response: SyncPushResponse;
|
|
304
|
+
affectedTables: string[];
|
|
305
|
+
emittedChangeCount: number;
|
|
306
|
+
}): Promise<void> {
|
|
307
|
+
const syncTrx = args.trx as Pick<
|
|
308
|
+
Kysely<SyncCoreDb>,
|
|
309
|
+
'insertInto' | 'updateTable'
|
|
310
|
+
>;
|
|
311
|
+
|
|
312
|
+
await syncTrx
|
|
313
|
+
.updateTable('sync_commits')
|
|
314
|
+
.set({
|
|
315
|
+
result_json: toDialectJsonValue(args.dialect, args.response),
|
|
316
|
+
change_count: args.emittedChangeCount,
|
|
317
|
+
affected_tables: args.dialect.arrayToDb(args.affectedTables) as string[],
|
|
318
|
+
})
|
|
319
|
+
.where('commit_seq', '=', args.commitSeq)
|
|
320
|
+
.execute();
|
|
321
|
+
|
|
322
|
+
if (args.affectedTables.length === 0) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await syncTrx
|
|
327
|
+
.insertInto('sync_table_commits')
|
|
328
|
+
.values(
|
|
329
|
+
args.affectedTables.map((table) => ({
|
|
330
|
+
partition_id: args.partitionId,
|
|
331
|
+
table,
|
|
332
|
+
commit_seq: args.commitSeq,
|
|
333
|
+
}))
|
|
334
|
+
)
|
|
335
|
+
.onConflict((oc) =>
|
|
336
|
+
oc.columns(['partition_id', 'table', 'commit_seq']).doNothing()
|
|
337
|
+
)
|
|
338
|
+
.execute();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function loadExistingCommitResult<DB extends SyncCoreDb>(args: {
|
|
342
|
+
trx: DbExecutor<DB>;
|
|
343
|
+
syncTrx: SyncMetadataTrx;
|
|
344
|
+
dialect: ServerSyncDialect;
|
|
345
|
+
request: SyncPushRequest;
|
|
346
|
+
partitionId: string;
|
|
347
|
+
}): Promise<PushCommitResult> {
|
|
348
|
+
let query = (
|
|
349
|
+
args.syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
|
|
350
|
+
SyncCoreDb,
|
|
351
|
+
'sync_commits',
|
|
352
|
+
EmptySelection
|
|
353
|
+
>
|
|
354
|
+
)
|
|
355
|
+
.selectAll()
|
|
356
|
+
.where('partition_id', '=', args.partitionId)
|
|
357
|
+
.where('client_id', '=', args.request.clientId)
|
|
358
|
+
.where('client_commit_id', '=', args.request.clientCommitId);
|
|
359
|
+
|
|
360
|
+
if (args.dialect.supportsForUpdate) {
|
|
361
|
+
query = query.forUpdate();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const existing = await query.executeTakeFirstOrThrow();
|
|
365
|
+
const parsedCached = parseJsonValue(existing.result_json);
|
|
366
|
+
|
|
367
|
+
if (!isSyncPushResponse(parsedCached)) {
|
|
368
|
+
return createRejectedPushResult({
|
|
369
|
+
opIndex: 0,
|
|
370
|
+
status: 'error',
|
|
371
|
+
error: 'IDEMPOTENCY_CACHE_MISS',
|
|
372
|
+
code: 'INTERNAL',
|
|
373
|
+
retriable: true,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const base: SyncPushResponse = {
|
|
378
|
+
...parsedCached,
|
|
379
|
+
commitSeq: Number(existing.commit_seq),
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
if (parsedCached.status === 'applied') {
|
|
383
|
+
const tablesFromDb = args.dialect.dbToArray(existing.affected_tables);
|
|
384
|
+
return {
|
|
385
|
+
response: { ...base, status: 'cached' },
|
|
386
|
+
affectedTables:
|
|
387
|
+
tablesFromDb.length > 0
|
|
388
|
+
? tablesFromDb
|
|
389
|
+
: await readCommitAffectedTables(
|
|
390
|
+
args.trx,
|
|
391
|
+
args.dialect,
|
|
392
|
+
Number(existing.commit_seq),
|
|
393
|
+
args.partitionId
|
|
394
|
+
),
|
|
395
|
+
scopeKeys: [],
|
|
396
|
+
emittedChanges: [],
|
|
397
|
+
commitActorId:
|
|
398
|
+
typeof existing.actor_id === 'string' ? existing.actor_id : null,
|
|
399
|
+
commitCreatedAt:
|
|
400
|
+
typeof existing.created_at === 'string' ? existing.created_at : null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
response: base,
|
|
406
|
+
affectedTables: [],
|
|
407
|
+
scopeKeys: [],
|
|
408
|
+
emittedChanges: [],
|
|
409
|
+
commitActorId:
|
|
410
|
+
typeof existing.actor_id === 'string' ? existing.actor_id : null,
|
|
411
|
+
commitCreatedAt:
|
|
412
|
+
typeof existing.created_at === 'string' ? existing.created_at : null,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function insertPendingCommit(args: {
|
|
417
|
+
syncTrx: SyncMetadataTrx;
|
|
418
|
+
dialect: ServerSyncDialect;
|
|
419
|
+
commitRow: Insertable<SyncCoreDb['sync_commits']>;
|
|
420
|
+
}): Promise<number | null> {
|
|
421
|
+
if (args.dialect.supportsInsertReturning) {
|
|
422
|
+
const insertedCommit = await args.syncTrx
|
|
423
|
+
.insertInto('sync_commits')
|
|
424
|
+
.values(args.commitRow)
|
|
425
|
+
.onConflict((oc) =>
|
|
426
|
+
oc
|
|
427
|
+
.columns(['partition_id', 'client_id', 'client_commit_id'])
|
|
428
|
+
.doNothing()
|
|
429
|
+
)
|
|
430
|
+
.returning(['commit_seq'])
|
|
431
|
+
.executeTakeFirst();
|
|
432
|
+
|
|
433
|
+
if (!insertedCommit) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return coerceNumber(insertedCommit.commit_seq) ?? 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const insertResult = await args.syncTrx
|
|
441
|
+
.insertInto('sync_commits')
|
|
442
|
+
.values(args.commitRow)
|
|
443
|
+
.onConflict((oc) =>
|
|
444
|
+
oc.columns(['partition_id', 'client_id', 'client_commit_id']).doNothing()
|
|
445
|
+
)
|
|
446
|
+
.executeTakeFirstOrThrow();
|
|
447
|
+
|
|
448
|
+
const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
|
|
449
|
+
if (insertedRows === 0) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return coerceNumber(insertResult.insertId) ?? 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function applyCommitOperations<
|
|
457
|
+
DB extends SyncCoreDb,
|
|
458
|
+
Auth extends SyncServerAuth,
|
|
459
|
+
>(args: {
|
|
460
|
+
trx: DbExecutor<DB>;
|
|
461
|
+
handlers: ServerHandlerCollection<DB, Auth>;
|
|
462
|
+
pushPlugins: readonly SyncServerPushPlugin<DB, Auth>[];
|
|
463
|
+
auth: Auth;
|
|
464
|
+
request: SyncPushRequest;
|
|
465
|
+
actorId: string;
|
|
466
|
+
commitId: string;
|
|
467
|
+
commitSeq: number;
|
|
468
|
+
}): Promise<{
|
|
469
|
+
results: SyncPushResponse['results'];
|
|
470
|
+
emittedChanges: PushCommitResult['emittedChanges'];
|
|
471
|
+
affectedTables: string[];
|
|
472
|
+
}> {
|
|
473
|
+
const ops = args.request.operations ?? [];
|
|
474
|
+
const allEmitted: PushCommitResult['emittedChanges'] = [];
|
|
475
|
+
const results: SyncPushResponse['results'] = [];
|
|
476
|
+
const affectedTablesSet = new Set<string>();
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < ops.length; ) {
|
|
479
|
+
const op = ops[i]!;
|
|
480
|
+
const handler = args.handlers.byTable.get(op.table);
|
|
481
|
+
if (!handler) {
|
|
482
|
+
throw new Error(`Unknown table: ${op.table}`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const operationCtx = {
|
|
486
|
+
db: args.trx,
|
|
487
|
+
trx: args.trx,
|
|
488
|
+
actorId: args.actorId,
|
|
489
|
+
auth: args.auth,
|
|
490
|
+
clientId: args.request.clientId,
|
|
491
|
+
commitId: args.commitId,
|
|
492
|
+
schemaVersion: args.request.schemaVersion,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
let transformedOp = op;
|
|
496
|
+
for (const plugin of args.pushPlugins) {
|
|
497
|
+
if (!plugin.beforeApplyOperation) continue;
|
|
498
|
+
const nextOp = await plugin.beforeApplyOperation({
|
|
499
|
+
ctx: operationCtx,
|
|
500
|
+
tableHandler: handler,
|
|
501
|
+
op: transformedOp,
|
|
502
|
+
opIndex: i,
|
|
503
|
+
});
|
|
504
|
+
assertOperationIdentityUnchanged(plugin.name, op, nextOp);
|
|
505
|
+
transformedOp = nextOp;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let appliedBatch:
|
|
509
|
+
| Awaited<ReturnType<typeof handler.applyOperation>>[]
|
|
510
|
+
| null = null;
|
|
511
|
+
let consumed = 1;
|
|
512
|
+
|
|
513
|
+
if (args.pushPlugins.length === 0 && handler.applyOperationBatch) {
|
|
514
|
+
const batchInput = [];
|
|
515
|
+
for (let j = i; j < ops.length; j++) {
|
|
516
|
+
const nextOp = ops[j]!;
|
|
517
|
+
if (nextOp.table !== op.table) break;
|
|
518
|
+
batchInput.push({ op: nextOp, opIndex: j });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (batchInput.length > 1) {
|
|
522
|
+
appliedBatch = await handler.applyOperationBatch(
|
|
523
|
+
operationCtx,
|
|
524
|
+
batchInput
|
|
525
|
+
);
|
|
526
|
+
consumed = Math.max(1, appliedBatch.length);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!appliedBatch) {
|
|
531
|
+
let appliedSingle = await handler.applyOperation(
|
|
532
|
+
operationCtx,
|
|
533
|
+
transformedOp,
|
|
534
|
+
i
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
for (const plugin of args.pushPlugins) {
|
|
538
|
+
if (!plugin.afterApplyOperation) continue;
|
|
539
|
+
appliedSingle = await plugin.afterApplyOperation({
|
|
540
|
+
ctx: operationCtx,
|
|
541
|
+
tableHandler: handler,
|
|
542
|
+
op: transformedOp,
|
|
543
|
+
opIndex: i,
|
|
544
|
+
applied: appliedSingle,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
appliedBatch = [appliedSingle];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (appliedBatch.length === 0) {
|
|
552
|
+
throw new Error(
|
|
553
|
+
`Handler "${op.table}" returned no results from applyOperationBatch`
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
for (const applied of appliedBatch) {
|
|
558
|
+
if (applied.result.status !== 'applied') {
|
|
559
|
+
results.push(applied.result);
|
|
560
|
+
throw new RejectCommitError(
|
|
561
|
+
createRejectedPushResponse(applied.result, args.commitSeq)
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for (const change of applied.emittedChanges ?? []) {
|
|
566
|
+
const scopes = change?.scopes;
|
|
567
|
+
if (!scopes || typeof scopes !== 'object') {
|
|
568
|
+
const error: SyncPushResponse['results'][number] = {
|
|
569
|
+
opIndex: applied.result.opIndex,
|
|
570
|
+
status: 'error',
|
|
571
|
+
error: 'MISSING_SCOPES',
|
|
572
|
+
code: 'INVALID_SCOPE',
|
|
573
|
+
retriable: false,
|
|
574
|
+
};
|
|
575
|
+
results.push(error);
|
|
576
|
+
throw new RejectCommitError(
|
|
577
|
+
createRejectedPushResponse(error, args.commitSeq)
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
results.push(applied.result);
|
|
583
|
+
allEmitted.push(...applied.emittedChanges);
|
|
584
|
+
for (const change of applied.emittedChanges) {
|
|
585
|
+
affectedTablesSet.add(change.table);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
i += consumed;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
results,
|
|
594
|
+
emittedChanges: allEmitted,
|
|
595
|
+
affectedTables: Array.from(affectedTablesSet).sort(),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function executePushCommitInExecutor<
|
|
600
|
+
DB extends SyncCoreDb,
|
|
601
|
+
Auth extends SyncServerAuth,
|
|
602
|
+
>(args: {
|
|
603
|
+
trx: DbExecutor<DB>;
|
|
604
|
+
dialect: ServerSyncDialect;
|
|
605
|
+
handlers: ServerHandlerCollection<DB, Auth>;
|
|
606
|
+
pushPlugins: readonly SyncServerPushPlugin<DB, Auth>[];
|
|
607
|
+
auth: Auth;
|
|
608
|
+
request: SyncPushRequest;
|
|
609
|
+
}): Promise<PushCommitResult> {
|
|
610
|
+
const { trx, dialect, handlers, request, pushPlugins } = args;
|
|
611
|
+
const actorId = args.auth.actorId;
|
|
612
|
+
const partitionId = args.auth.partitionId ?? 'default';
|
|
613
|
+
const ops = request.operations ?? [];
|
|
614
|
+
const syncTrx = trx as SyncMetadataTrx;
|
|
615
|
+
|
|
616
|
+
if (!dialect.supportsSavepoints) {
|
|
617
|
+
await syncTrx
|
|
618
|
+
.deleteFrom('sync_commits')
|
|
619
|
+
.where('partition_id', '=', partitionId)
|
|
620
|
+
.where('client_id', '=', request.clientId)
|
|
621
|
+
.where('client_commit_id', '=', request.clientCommitId)
|
|
622
|
+
.where('result_json', 'is', null)
|
|
623
|
+
.execute();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const commitCreatedAt = new Date().toISOString();
|
|
627
|
+
const commitRow: Insertable<SyncCoreDb['sync_commits']> = {
|
|
628
|
+
partition_id: partitionId,
|
|
629
|
+
actor_id: actorId,
|
|
630
|
+
client_id: request.clientId,
|
|
631
|
+
client_commit_id: request.clientCommitId,
|
|
632
|
+
created_at: commitCreatedAt,
|
|
633
|
+
meta: null,
|
|
634
|
+
result_json: null,
|
|
635
|
+
};
|
|
636
|
+
let commitSeq =
|
|
637
|
+
(await insertPendingCommit({
|
|
638
|
+
syncTrx,
|
|
639
|
+
dialect,
|
|
640
|
+
commitRow,
|
|
641
|
+
})) ?? 0;
|
|
642
|
+
if (commitSeq === 0) {
|
|
643
|
+
return loadExistingCommitResult({
|
|
644
|
+
trx,
|
|
645
|
+
syncTrx,
|
|
646
|
+
dialect,
|
|
647
|
+
request,
|
|
648
|
+
partitionId,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (commitSeq <= 0) {
|
|
653
|
+
const insertedCommitRow = await (
|
|
654
|
+
syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
|
|
655
|
+
SyncCoreDb,
|
|
656
|
+
'sync_commits',
|
|
657
|
+
EmptySelection
|
|
658
|
+
>
|
|
659
|
+
)
|
|
660
|
+
.selectAll()
|
|
661
|
+
.where('partition_id', '=', partitionId)
|
|
662
|
+
.where('client_id', '=', request.clientId)
|
|
663
|
+
.where('client_commit_id', '=', request.clientCommitId)
|
|
664
|
+
.executeTakeFirstOrThrow();
|
|
665
|
+
commitSeq = Number(insertedCommitRow.commit_seq);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const commitId = `${request.clientId}:${request.clientCommitId}`;
|
|
669
|
+
const savepointName = `sync_apply_${commitSeq}`;
|
|
670
|
+
const useSavepoints = shouldUseSavepoints({
|
|
671
|
+
dialect,
|
|
672
|
+
handlers,
|
|
673
|
+
operations: ops,
|
|
674
|
+
});
|
|
675
|
+
let savepointCreated = false;
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
if (useSavepoints) {
|
|
679
|
+
await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
|
|
680
|
+
savepointCreated = true;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const applied = await applyCommitOperations({
|
|
684
|
+
trx,
|
|
685
|
+
handlers,
|
|
686
|
+
pushPlugins,
|
|
687
|
+
auth: args.auth,
|
|
688
|
+
request,
|
|
689
|
+
actorId,
|
|
690
|
+
commitId,
|
|
691
|
+
commitSeq,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const appliedResponse: SyncPushResponse = {
|
|
695
|
+
ok: true,
|
|
696
|
+
status: 'applied',
|
|
697
|
+
commitSeq,
|
|
698
|
+
results: applied.results,
|
|
699
|
+
};
|
|
700
|
+
await persistEmittedChanges({
|
|
701
|
+
trx,
|
|
702
|
+
dialect,
|
|
703
|
+
partitionId,
|
|
704
|
+
commitSeq,
|
|
705
|
+
emittedChanges: applied.emittedChanges,
|
|
706
|
+
});
|
|
707
|
+
await persistCommitOutcome({
|
|
708
|
+
trx,
|
|
709
|
+
dialect,
|
|
710
|
+
partitionId,
|
|
711
|
+
commitSeq,
|
|
712
|
+
response: appliedResponse,
|
|
713
|
+
affectedTables: applied.affectedTables,
|
|
714
|
+
emittedChangeCount: applied.emittedChanges.length,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
if (useSavepoints) {
|
|
718
|
+
await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
response: appliedResponse,
|
|
723
|
+
affectedTables: applied.affectedTables,
|
|
724
|
+
scopeKeys: scopeKeysFromEmitted(applied.emittedChanges),
|
|
725
|
+
emittedChanges: applied.emittedChanges,
|
|
726
|
+
commitActorId: actorId,
|
|
727
|
+
commitCreatedAt,
|
|
728
|
+
};
|
|
729
|
+
} catch (error) {
|
|
730
|
+
if (savepointCreated) {
|
|
731
|
+
try {
|
|
732
|
+
await sql.raw(`ROLLBACK TO SAVEPOINT ${savepointName}`).execute(trx);
|
|
733
|
+
await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
|
|
734
|
+
} catch (savepointError) {
|
|
735
|
+
console.error(
|
|
736
|
+
'[pushCommit] Savepoint rollback failed:',
|
|
737
|
+
savepointError
|
|
738
|
+
);
|
|
739
|
+
throw savepointError;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!(error instanceof RejectCommitError)) {
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
await persistCommitOutcome({
|
|
748
|
+
trx,
|
|
749
|
+
dialect,
|
|
750
|
+
partitionId,
|
|
751
|
+
commitSeq,
|
|
752
|
+
response: error.response,
|
|
753
|
+
affectedTables: [],
|
|
754
|
+
emittedChangeCount: 0,
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
return {
|
|
758
|
+
response: error.response,
|
|
759
|
+
affectedTables: [],
|
|
760
|
+
scopeKeys: [],
|
|
761
|
+
emittedChanges: [],
|
|
762
|
+
commitActorId: actorId,
|
|
763
|
+
commitCreatedAt,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
188
768
|
export async function pushCommit<
|
|
189
769
|
DB extends SyncCoreDb,
|
|
190
770
|
Auth extends SyncServerAuth,
|
|
@@ -195,16 +775,16 @@ export async function pushCommit<
|
|
|
195
775
|
plugins?: readonly SyncServerPushPlugin<DB, Auth>[];
|
|
196
776
|
auth: Auth;
|
|
197
777
|
request: SyncPushRequest;
|
|
778
|
+
suppressTelemetry?: boolean;
|
|
198
779
|
}): Promise<PushCommitResult> {
|
|
199
780
|
const { db, dialect, handlers, request } = args;
|
|
200
781
|
const pushPlugins = sortServerPushPlugins(args.plugins);
|
|
201
|
-
const actorId = args.auth.actorId;
|
|
202
|
-
const partitionId = args.auth.partitionId ?? 'default';
|
|
203
782
|
const requestedOps = Array.isArray(request.operations)
|
|
204
783
|
? request.operations
|
|
205
784
|
: [];
|
|
206
785
|
const operationCount = requestedOps.length;
|
|
207
786
|
const startedAtMs = Date.now();
|
|
787
|
+
const suppressTelemetry = args.suppressTelemetry === true;
|
|
208
788
|
|
|
209
789
|
return startSyncSpan(
|
|
210
790
|
{
|
|
@@ -225,587 +805,178 @@ export async function pushCommit<
|
|
|
225
805
|
span.setAttribute('affected_table_count', result.affectedTables.length);
|
|
226
806
|
span.setStatus('ok');
|
|
227
807
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
808
|
+
if (!suppressTelemetry) {
|
|
809
|
+
recordPushMetrics({
|
|
810
|
+
status,
|
|
811
|
+
durationMs,
|
|
812
|
+
operationCount,
|
|
813
|
+
emittedChangeCount: result.emittedChanges.length,
|
|
814
|
+
affectedTableCount: result.affectedTables.length,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
235
817
|
|
|
236
818
|
return result;
|
|
237
819
|
};
|
|
238
820
|
|
|
239
821
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
ok: true,
|
|
244
|
-
status: 'rejected',
|
|
245
|
-
results: [
|
|
246
|
-
{
|
|
247
|
-
opIndex: 0,
|
|
248
|
-
status: 'error',
|
|
249
|
-
error: 'INVALID_REQUEST',
|
|
250
|
-
code: 'INVALID_REQUEST',
|
|
251
|
-
retriable: false,
|
|
252
|
-
},
|
|
253
|
-
],
|
|
254
|
-
},
|
|
255
|
-
affectedTables: [],
|
|
256
|
-
scopeKeys: [],
|
|
257
|
-
emittedChanges: [],
|
|
258
|
-
commitActorId: null,
|
|
259
|
-
commitCreatedAt: null,
|
|
260
|
-
});
|
|
822
|
+
const validationError = validatePushRequest(request);
|
|
823
|
+
if (validationError) {
|
|
824
|
+
return finalizeResult(createRejectedPushResult(validationError));
|
|
261
825
|
}
|
|
262
826
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
827
|
+
return finalizeResult(
|
|
828
|
+
await dialect.executeInTransaction(db, async (trx) =>
|
|
829
|
+
executePushCommitInExecutor({
|
|
830
|
+
trx,
|
|
831
|
+
dialect,
|
|
832
|
+
handlers,
|
|
833
|
+
pushPlugins,
|
|
834
|
+
auth: args.auth,
|
|
835
|
+
request,
|
|
836
|
+
})
|
|
837
|
+
)
|
|
838
|
+
);
|
|
839
|
+
} catch (error) {
|
|
840
|
+
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
841
|
+
span.setAttribute('status', 'error');
|
|
842
|
+
span.setAttribute('duration_ms', durationMs);
|
|
843
|
+
span.setStatus('error');
|
|
844
|
+
|
|
845
|
+
if (!suppressTelemetry) {
|
|
846
|
+
recordPushMetrics({
|
|
847
|
+
status: 'error',
|
|
848
|
+
durationMs,
|
|
849
|
+
operationCount,
|
|
850
|
+
emittedChangeCount: 0,
|
|
851
|
+
affectedTableCount: 0,
|
|
852
|
+
});
|
|
853
|
+
captureSyncException(error, {
|
|
854
|
+
event: 'sync.server.push',
|
|
855
|
+
operationCount,
|
|
284
856
|
});
|
|
285
857
|
}
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
);
|
|
862
|
+
}
|
|
286
863
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
864
|
+
export async function pushCommitBatch<
|
|
865
|
+
DB extends SyncCoreDb,
|
|
866
|
+
Auth extends SyncServerAuth,
|
|
867
|
+
>(args: {
|
|
868
|
+
db: Kysely<DB>;
|
|
869
|
+
dialect: ServerSyncDialect;
|
|
870
|
+
handlers: ServerHandlerCollection<DB, Auth>;
|
|
871
|
+
plugins?: readonly SyncServerPushPlugin<DB, Auth>[];
|
|
872
|
+
auth: Auth;
|
|
873
|
+
requests: SyncPushRequest[];
|
|
874
|
+
suppressTelemetry?: boolean;
|
|
875
|
+
}): Promise<PushCommitResult[]> {
|
|
876
|
+
const { db, dialect, handlers, requests } = args;
|
|
877
|
+
const pushPlugins = sortServerPushPlugins(args.plugins);
|
|
878
|
+
const startedAtMs = Date.now();
|
|
879
|
+
const suppressTelemetry = args.suppressTelemetry === true;
|
|
880
|
+
const totalOperationCount = requests.reduce((count, request) => {
|
|
881
|
+
const operations = Array.isArray(request.operations)
|
|
882
|
+
? request.operations
|
|
883
|
+
: [];
|
|
884
|
+
return count + operations.length;
|
|
885
|
+
}, 0);
|
|
308
886
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
>
|
|
328
|
-
)
|
|
329
|
-
.selectAll()
|
|
330
|
-
.where('partition_id', '=', partitionId)
|
|
331
|
-
.where('client_id', '=', request.clientId)
|
|
332
|
-
.where('client_commit_id', '=', request.clientCommitId);
|
|
333
|
-
|
|
334
|
-
if (dialect.supportsForUpdate) {
|
|
335
|
-
query = query.forUpdate();
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const existing = await query.executeTakeFirstOrThrow();
|
|
339
|
-
|
|
340
|
-
const parsedCached = parseJsonValue(existing.result_json);
|
|
341
|
-
if (!isSyncPushResponse(parsedCached)) {
|
|
342
|
-
return {
|
|
343
|
-
response: {
|
|
344
|
-
ok: true,
|
|
345
|
-
status: 'rejected',
|
|
346
|
-
results: [
|
|
347
|
-
{
|
|
348
|
-
opIndex: 0,
|
|
349
|
-
status: 'error',
|
|
350
|
-
error: 'IDEMPOTENCY_CACHE_MISS',
|
|
351
|
-
code: 'INTERNAL',
|
|
352
|
-
retriable: true,
|
|
353
|
-
},
|
|
354
|
-
],
|
|
355
|
-
},
|
|
356
|
-
affectedTables: [],
|
|
357
|
-
scopeKeys: [],
|
|
358
|
-
emittedChanges: [],
|
|
359
|
-
commitActorId:
|
|
360
|
-
typeof existing.actor_id === 'string'
|
|
361
|
-
? existing.actor_id
|
|
362
|
-
: null,
|
|
363
|
-
commitCreatedAt:
|
|
364
|
-
typeof existing.created_at === 'string'
|
|
365
|
-
? existing.created_at
|
|
366
|
-
: null,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const base: SyncPushResponse = {
|
|
371
|
-
...parsedCached,
|
|
372
|
-
commitSeq: Number(existing.commit_seq),
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
if (parsedCached.status === 'applied') {
|
|
376
|
-
const tablesFromDb = dialect.dbToArray(
|
|
377
|
-
existing.affected_tables
|
|
378
|
-
);
|
|
379
|
-
return {
|
|
380
|
-
response: { ...base, status: 'cached' },
|
|
381
|
-
affectedTables:
|
|
382
|
-
tablesFromDb.length > 0
|
|
383
|
-
? tablesFromDb
|
|
384
|
-
: await readCommitAffectedTables(
|
|
385
|
-
trx,
|
|
386
|
-
dialect,
|
|
387
|
-
Number(existing.commit_seq),
|
|
388
|
-
partitionId
|
|
389
|
-
),
|
|
390
|
-
scopeKeys: [],
|
|
391
|
-
emittedChanges: [],
|
|
392
|
-
commitActorId:
|
|
393
|
-
typeof existing.actor_id === 'string'
|
|
394
|
-
? existing.actor_id
|
|
395
|
-
: null,
|
|
396
|
-
commitCreatedAt:
|
|
397
|
-
typeof existing.created_at === 'string'
|
|
398
|
-
? existing.created_at
|
|
399
|
-
: null,
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
return {
|
|
404
|
-
response: base,
|
|
405
|
-
affectedTables: [],
|
|
406
|
-
scopeKeys: [],
|
|
407
|
-
emittedChanges: [],
|
|
408
|
-
commitActorId:
|
|
409
|
-
typeof existing.actor_id === 'string'
|
|
410
|
-
? existing.actor_id
|
|
411
|
-
: null,
|
|
412
|
-
commitCreatedAt:
|
|
413
|
-
typeof existing.created_at === 'string'
|
|
414
|
-
? existing.created_at
|
|
415
|
-
: null,
|
|
416
|
-
};
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
const loadPersistedCommitMetadata = async (
|
|
420
|
-
seq: number
|
|
421
|
-
): Promise<{
|
|
422
|
-
commitActorId: string | null;
|
|
423
|
-
commitCreatedAt: string | null;
|
|
424
|
-
}> => {
|
|
425
|
-
const persisted = await (
|
|
426
|
-
syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
|
|
427
|
-
SyncCoreDb,
|
|
428
|
-
'sync_commits',
|
|
429
|
-
EmptySelection
|
|
430
|
-
>
|
|
431
|
-
)
|
|
432
|
-
.selectAll()
|
|
433
|
-
.where('commit_seq', '=', seq)
|
|
434
|
-
.where('partition_id', '=', partitionId)
|
|
435
|
-
.executeTakeFirst();
|
|
436
|
-
|
|
437
|
-
return {
|
|
438
|
-
commitActorId:
|
|
439
|
-
typeof persisted?.actor_id === 'string'
|
|
440
|
-
? persisted.actor_id
|
|
441
|
-
: null,
|
|
442
|
-
commitCreatedAt:
|
|
443
|
-
typeof persisted?.created_at === 'string'
|
|
444
|
-
? persisted.created_at
|
|
445
|
-
: null,
|
|
446
|
-
};
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
let commitSeq = 0;
|
|
450
|
-
if (dialect.supportsInsertReturning) {
|
|
451
|
-
const insertedCommit = await syncTrx
|
|
452
|
-
.insertInto('sync_commits')
|
|
453
|
-
.values(commitRow)
|
|
454
|
-
.onConflict((oc) =>
|
|
455
|
-
oc
|
|
456
|
-
.columns(['partition_id', 'client_id', 'client_commit_id'])
|
|
457
|
-
.doNothing()
|
|
458
|
-
)
|
|
459
|
-
.returning(['commit_seq'])
|
|
460
|
-
.executeTakeFirst();
|
|
461
|
-
|
|
462
|
-
if (!insertedCommit) {
|
|
463
|
-
return loadExistingCommit();
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
commitSeq = coerceNumber(insertedCommit.commit_seq) ?? 0;
|
|
467
|
-
} else {
|
|
468
|
-
const insertResult = await syncTrx
|
|
469
|
-
.insertInto('sync_commits')
|
|
470
|
-
.values(commitRow)
|
|
471
|
-
.onConflict((oc) =>
|
|
472
|
-
oc
|
|
473
|
-
.columns(['partition_id', 'client_id', 'client_commit_id'])
|
|
474
|
-
.doNothing()
|
|
475
|
-
)
|
|
476
|
-
.executeTakeFirstOrThrow();
|
|
477
|
-
|
|
478
|
-
const insertedRows = Number(
|
|
479
|
-
insertResult.numInsertedOrUpdatedRows ?? 0
|
|
480
|
-
);
|
|
481
|
-
if (insertedRows === 0) {
|
|
482
|
-
return loadExistingCommit();
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
commitSeq = coerceNumber(insertResult.insertId) ?? 0;
|
|
887
|
+
return startSyncSpan(
|
|
888
|
+
{
|
|
889
|
+
name: 'sync.server.push_batch',
|
|
890
|
+
op: 'sync.push.batch',
|
|
891
|
+
attributes: {
|
|
892
|
+
commit_count: requests.length,
|
|
893
|
+
operation_count: totalOperationCount,
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
async (span) => {
|
|
897
|
+
try {
|
|
898
|
+
const results = await dialect.executeInTransaction(db, async (trx) => {
|
|
899
|
+
const executed: PushCommitResult[] = [];
|
|
900
|
+
for (const request of requests) {
|
|
901
|
+
const validationError = validatePushRequest(request);
|
|
902
|
+
if (validationError) {
|
|
903
|
+
executed.push(createRejectedPushResult(validationError));
|
|
904
|
+
continue;
|
|
486
905
|
}
|
|
487
906
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
let useSavepoints = dialect.supportsSavepoints;
|
|
507
|
-
if (useSavepoints && ops.length === 1) {
|
|
508
|
-
const singleOpHandler = handlers.byTable.get(ops[0]!.table);
|
|
509
|
-
if (!singleOpHandler) {
|
|
510
|
-
throw new Error(`Unknown table: ${ops[0]!.table}`);
|
|
511
|
-
}
|
|
512
|
-
if (singleOpHandler.canRejectSingleOperationWithoutSavepoint) {
|
|
513
|
-
useSavepoints = false;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
let savepointCreated = false;
|
|
517
|
-
|
|
518
|
-
try {
|
|
519
|
-
// Apply the commit under a savepoint so we can roll back app writes on conflict
|
|
520
|
-
// while still persisting the commit-level cached response.
|
|
521
|
-
if (useSavepoints) {
|
|
522
|
-
await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
|
|
523
|
-
savepointCreated = true;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const allEmitted = [];
|
|
527
|
-
const results = [];
|
|
528
|
-
const affectedTablesSet = new Set<string>();
|
|
529
|
-
|
|
530
|
-
for (let i = 0; i < ops.length; ) {
|
|
531
|
-
const op = ops[i]!;
|
|
532
|
-
const handler = handlers.byTable.get(op.table);
|
|
533
|
-
if (!handler) {
|
|
534
|
-
throw new Error(`Unknown table: ${op.table}`);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const operationCtx = {
|
|
538
|
-
db: trx,
|
|
539
|
-
trx,
|
|
540
|
-
actorId,
|
|
541
|
-
auth: args.auth,
|
|
542
|
-
clientId: request.clientId,
|
|
543
|
-
commitId,
|
|
544
|
-
schemaVersion: request.schemaVersion,
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
let transformedOp = op;
|
|
548
|
-
for (const plugin of pushPlugins) {
|
|
549
|
-
if (!plugin.beforeApplyOperation) continue;
|
|
550
|
-
const nextOp = await plugin.beforeApplyOperation({
|
|
551
|
-
ctx: operationCtx,
|
|
552
|
-
tableHandler: handler,
|
|
553
|
-
op: transformedOp,
|
|
554
|
-
opIndex: i,
|
|
555
|
-
});
|
|
556
|
-
assertOperationIdentityUnchanged(plugin.name, op, nextOp);
|
|
557
|
-
transformedOp = nextOp;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
let appliedBatch:
|
|
561
|
-
| Awaited<ReturnType<typeof handler.applyOperation>>[]
|
|
562
|
-
| null = null;
|
|
563
|
-
let consumed = 1;
|
|
564
|
-
|
|
565
|
-
if (
|
|
566
|
-
pushPlugins.length === 0 &&
|
|
567
|
-
handler.applyOperationBatch &&
|
|
568
|
-
dialect.supportsInsertReturning
|
|
569
|
-
) {
|
|
570
|
-
const batchInput = [];
|
|
571
|
-
for (let j = i; j < ops.length; j++) {
|
|
572
|
-
const nextOp = ops[j]!;
|
|
573
|
-
if (nextOp.table !== op.table) break;
|
|
574
|
-
batchInput.push({ op: nextOp, opIndex: j });
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (batchInput.length > 1) {
|
|
578
|
-
appliedBatch = await handler.applyOperationBatch(
|
|
579
|
-
operationCtx,
|
|
580
|
-
batchInput
|
|
581
|
-
);
|
|
582
|
-
consumed = Math.max(1, appliedBatch.length);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
if (!appliedBatch) {
|
|
587
|
-
let appliedSingle = await handler.applyOperation(
|
|
588
|
-
operationCtx,
|
|
589
|
-
transformedOp,
|
|
590
|
-
i
|
|
591
|
-
);
|
|
592
|
-
|
|
593
|
-
for (const plugin of pushPlugins) {
|
|
594
|
-
if (!plugin.afterApplyOperation) continue;
|
|
595
|
-
appliedSingle = await plugin.afterApplyOperation({
|
|
596
|
-
ctx: operationCtx,
|
|
597
|
-
tableHandler: handler,
|
|
598
|
-
op: transformedOp,
|
|
599
|
-
opIndex: i,
|
|
600
|
-
applied: appliedSingle,
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
appliedBatch = [appliedSingle];
|
|
605
|
-
}
|
|
606
|
-
if (appliedBatch.length === 0) {
|
|
607
|
-
throw new Error(
|
|
608
|
-
`Handler "${op.table}" returned no results from applyOperationBatch`
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
for (const applied of appliedBatch) {
|
|
613
|
-
if (applied.result.status !== 'applied') {
|
|
614
|
-
results.push(applied.result);
|
|
615
|
-
throw new RejectCommitError({
|
|
616
|
-
ok: true,
|
|
617
|
-
status: 'rejected',
|
|
618
|
-
commitSeq,
|
|
619
|
-
results,
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Framework-level enforcement: emitted changes must have scopes
|
|
624
|
-
for (const c of applied.emittedChanges ?? []) {
|
|
625
|
-
const scopes = c?.scopes;
|
|
626
|
-
if (!scopes || typeof scopes !== 'object') {
|
|
627
|
-
results.push({
|
|
628
|
-
opIndex: applied.result.opIndex,
|
|
629
|
-
status: 'error' as const,
|
|
630
|
-
error: 'MISSING_SCOPES',
|
|
631
|
-
code: 'INVALID_SCOPE',
|
|
632
|
-
retriable: false,
|
|
633
|
-
});
|
|
634
|
-
throw new RejectCommitError({
|
|
635
|
-
ok: true,
|
|
636
|
-
status: 'rejected',
|
|
637
|
-
commitSeq,
|
|
638
|
-
results,
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
results.push(applied.result);
|
|
644
|
-
allEmitted.push(...applied.emittedChanges);
|
|
645
|
-
for (const c of applied.emittedChanges) {
|
|
646
|
-
affectedTablesSet.add(c.table);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
i += consumed;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
if (allEmitted.length > 0) {
|
|
654
|
-
const changeRows: Array<
|
|
655
|
-
Insertable<SyncCoreDb['sync_changes']>
|
|
656
|
-
> = allEmitted.map((c) => ({
|
|
657
|
-
partition_id: partitionId,
|
|
658
|
-
commit_seq: commitSeq,
|
|
659
|
-
table: c.table,
|
|
660
|
-
row_id: c.row_id,
|
|
661
|
-
op: c.op,
|
|
662
|
-
row_json: toDialectJsonValue(dialect, c.row_json),
|
|
663
|
-
row_version: c.row_version,
|
|
664
|
-
scopes: dialect.scopesToDb(c.scopes),
|
|
665
|
-
}));
|
|
666
|
-
|
|
667
|
-
await syncTrx
|
|
668
|
-
.insertInto('sync_changes')
|
|
669
|
-
.values(changeRows)
|
|
670
|
-
.execute();
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
const appliedResponse: SyncPushResponse = {
|
|
674
|
-
ok: true,
|
|
675
|
-
status: 'applied',
|
|
676
|
-
commitSeq,
|
|
677
|
-
results,
|
|
678
|
-
};
|
|
679
|
-
|
|
680
|
-
const affectedTables = Array.from(affectedTablesSet).sort();
|
|
681
|
-
|
|
682
|
-
const appliedCommitUpdate: Updateable<
|
|
683
|
-
SyncCoreDb['sync_commits']
|
|
684
|
-
> = {
|
|
685
|
-
result_json: toDialectJsonValue(dialect, appliedResponse),
|
|
686
|
-
change_count: allEmitted.length,
|
|
687
|
-
affected_tables: dialect.arrayToDb(affectedTables) as string[],
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
await syncTrx
|
|
691
|
-
.updateTable('sync_commits')
|
|
692
|
-
.set(appliedCommitUpdate)
|
|
693
|
-
.where('commit_seq', '=', commitSeq)
|
|
694
|
-
.execute();
|
|
695
|
-
|
|
696
|
-
// Insert table commits for subscription filtering
|
|
697
|
-
if (affectedTables.length > 0) {
|
|
698
|
-
const tableCommits: Array<
|
|
699
|
-
Insertable<SyncCoreDb['sync_table_commits']>
|
|
700
|
-
> = affectedTables.map((table) => ({
|
|
701
|
-
partition_id: partitionId,
|
|
702
|
-
table,
|
|
703
|
-
commit_seq: commitSeq,
|
|
704
|
-
}));
|
|
705
|
-
|
|
706
|
-
await syncTrx
|
|
707
|
-
.insertInto('sync_table_commits')
|
|
708
|
-
.values(tableCommits)
|
|
709
|
-
.onConflict((oc) =>
|
|
710
|
-
oc
|
|
711
|
-
.columns(['partition_id', 'table', 'commit_seq'])
|
|
712
|
-
.doNothing()
|
|
713
|
-
)
|
|
714
|
-
.execute();
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
if (useSavepoints) {
|
|
718
|
-
await sql
|
|
719
|
-
.raw(`RELEASE SAVEPOINT ${savepointName}`)
|
|
720
|
-
.execute(trx);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const commitMetadata =
|
|
724
|
-
await loadPersistedCommitMetadata(commitSeq);
|
|
725
|
-
|
|
726
|
-
return {
|
|
727
|
-
response: appliedResponse,
|
|
728
|
-
affectedTables,
|
|
729
|
-
scopeKeys: scopeKeysFromEmitted(allEmitted),
|
|
730
|
-
emittedChanges: allEmitted.map((c) => ({
|
|
731
|
-
table: c.table,
|
|
732
|
-
row_id: c.row_id,
|
|
733
|
-
op: c.op,
|
|
734
|
-
row_json: c.row_json,
|
|
735
|
-
row_version: c.row_version,
|
|
736
|
-
scopes: c.scopes,
|
|
737
|
-
})),
|
|
738
|
-
...commitMetadata,
|
|
739
|
-
};
|
|
740
|
-
} catch (err) {
|
|
741
|
-
// Roll back app writes but keep the commit row.
|
|
742
|
-
if (savepointCreated) {
|
|
743
|
-
try {
|
|
744
|
-
await sql
|
|
745
|
-
.raw(`ROLLBACK TO SAVEPOINT ${savepointName}`)
|
|
746
|
-
.execute(trx);
|
|
747
|
-
await sql
|
|
748
|
-
.raw(`RELEASE SAVEPOINT ${savepointName}`)
|
|
749
|
-
.execute(trx);
|
|
750
|
-
} catch (savepointErr) {
|
|
751
|
-
// If savepoint rollback fails, the transaction may be in an
|
|
752
|
-
// inconsistent state. Log and rethrow to fail the entire commit
|
|
753
|
-
// rather than risk data corruption.
|
|
754
|
-
console.error(
|
|
755
|
-
'[pushCommit] Savepoint rollback failed:',
|
|
756
|
-
savepointErr
|
|
757
|
-
);
|
|
758
|
-
throw savepointErr;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
if (!(err instanceof RejectCommitError)) throw err;
|
|
763
|
-
|
|
764
|
-
const rejectedCommitUpdate: Updateable<
|
|
765
|
-
SyncCoreDb['sync_commits']
|
|
766
|
-
> = {
|
|
767
|
-
result_json: toDialectJsonValue(dialect, err.response),
|
|
768
|
-
change_count: 0,
|
|
769
|
-
affected_tables: dialect.arrayToDb([]) as string[],
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
// Persist the rejected response for commit-level idempotency.
|
|
773
|
-
await syncTrx
|
|
774
|
-
.updateTable('sync_commits')
|
|
775
|
-
.set(rejectedCommitUpdate)
|
|
776
|
-
.where('commit_seq', '=', commitSeq)
|
|
777
|
-
.execute();
|
|
778
|
-
|
|
779
|
-
const commitMetadata =
|
|
780
|
-
await loadPersistedCommitMetadata(commitSeq);
|
|
781
|
-
|
|
782
|
-
return {
|
|
783
|
-
response: err.response,
|
|
784
|
-
affectedTables: [],
|
|
785
|
-
scopeKeys: [],
|
|
786
|
-
emittedChanges: [],
|
|
787
|
-
...commitMetadata,
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
})
|
|
907
|
+
executed.push(
|
|
908
|
+
await executePushCommitInExecutor({
|
|
909
|
+
trx,
|
|
910
|
+
dialect,
|
|
911
|
+
handlers,
|
|
912
|
+
pushPlugins,
|
|
913
|
+
auth: args.auth,
|
|
914
|
+
request,
|
|
915
|
+
})
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
return executed;
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
922
|
+
const emittedChangeCount = results.reduce(
|
|
923
|
+
(count, result) => count + result.emittedChanges.length,
|
|
924
|
+
0
|
|
791
925
|
);
|
|
926
|
+
const affectedTableCount = results.reduce(
|
|
927
|
+
(count, result) => count + result.affectedTables.length,
|
|
928
|
+
0
|
|
929
|
+
);
|
|
930
|
+
const status = results.every(
|
|
931
|
+
(result) => result.response.status === 'cached'
|
|
932
|
+
)
|
|
933
|
+
? 'cached'
|
|
934
|
+
: results.every(
|
|
935
|
+
(result) =>
|
|
936
|
+
result.response.status === 'applied' ||
|
|
937
|
+
result.response.status === 'cached'
|
|
938
|
+
)
|
|
939
|
+
? 'applied'
|
|
940
|
+
: 'rejected';
|
|
941
|
+
|
|
942
|
+
span.setAttribute('status', status);
|
|
943
|
+
span.setAttribute('duration_ms', durationMs);
|
|
944
|
+
span.setAttribute('commit_count', results.length);
|
|
945
|
+
span.setAttribute('emitted_change_count', emittedChangeCount);
|
|
946
|
+
span.setAttribute('affected_table_count', affectedTableCount);
|
|
947
|
+
span.setStatus('ok');
|
|
948
|
+
|
|
949
|
+
if (!suppressTelemetry) {
|
|
950
|
+
recordPushMetrics({
|
|
951
|
+
status,
|
|
952
|
+
durationMs,
|
|
953
|
+
operationCount: totalOperationCount,
|
|
954
|
+
emittedChangeCount,
|
|
955
|
+
affectedTableCount,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return results;
|
|
792
960
|
} catch (error) {
|
|
793
961
|
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
794
962
|
span.setAttribute('status', 'error');
|
|
795
963
|
span.setAttribute('duration_ms', durationMs);
|
|
796
964
|
span.setStatus('error');
|
|
797
965
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
966
|
+
if (!suppressTelemetry) {
|
|
967
|
+
recordPushMetrics({
|
|
968
|
+
status: 'error',
|
|
969
|
+
durationMs,
|
|
970
|
+
operationCount: totalOperationCount,
|
|
971
|
+
emittedChangeCount: 0,
|
|
972
|
+
affectedTableCount: 0,
|
|
973
|
+
});
|
|
974
|
+
captureSyncException(error, {
|
|
975
|
+
event: 'sync.server.push_batch',
|
|
976
|
+
commitCount: requests.length,
|
|
977
|
+
operationCount: totalOperationCount,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
809
980
|
throw error;
|
|
810
981
|
}
|
|
811
982
|
}
|