@syncular/server 0.0.1 → 0.0.2-127
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/README.md +25 -0
- package/dist/blobs/adapters/database.d.ts.map +1 -1
- package/dist/blobs/adapters/database.js +25 -3
- package/dist/blobs/adapters/database.js.map +1 -1
- package/dist/blobs/adapters/filesystem.d.ts +31 -0
- package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
- package/dist/blobs/adapters/filesystem.js +140 -0
- package/dist/blobs/adapters/filesystem.js.map +1 -0
- package/dist/blobs/adapters/s3.d.ts +3 -2
- package/dist/blobs/adapters/s3.d.ts.map +1 -1
- package/dist/blobs/adapters/s3.js +49 -0
- package/dist/blobs/adapters/s3.js.map +1 -1
- package/dist/blobs/index.d.ts +1 -0
- package/dist/blobs/index.d.ts.map +1 -1
- package/dist/blobs/index.js +6 -5
- package/dist/blobs/index.js.map +1 -1
- package/dist/clients.d.ts +1 -0
- package/dist/clients.d.ts.map +1 -1
- package/dist/clients.js.map +1 -1
- package/dist/compaction.d.ts +1 -1
- package/dist/compaction.js +1 -1
- package/dist/dialect/base.d.ts +83 -0
- package/dist/dialect/base.d.ts.map +1 -0
- package/dist/dialect/base.js +144 -0
- package/dist/dialect/base.js.map +1 -0
- package/dist/dialect/helpers.d.ts +10 -0
- package/dist/dialect/helpers.d.ts.map +1 -0
- package/dist/dialect/helpers.js +59 -0
- package/dist/dialect/helpers.js.map +1 -0
- package/dist/dialect/index.d.ts +2 -0
- package/dist/dialect/index.d.ts.map +1 -1
- package/dist/dialect/index.js +3 -1
- package/dist/dialect/index.js.map +1 -1
- package/dist/dialect/types.d.ts +38 -46
- package/dist/dialect/types.d.ts.map +1 -1
- package/dist/{shapes → handlers}/create-handler.d.ts +18 -5
- package/dist/handlers/create-handler.d.ts.map +1 -0
- package/dist/{shapes → handlers}/create-handler.js +140 -43
- package/dist/handlers/create-handler.js.map +1 -0
- package/dist/handlers/index.d.ts.map +1 -0
- package/dist/handlers/index.js +4 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/registry.d.ts.map +1 -0
- package/dist/handlers/registry.js.map +1 -0
- package/dist/{shapes → handlers}/types.d.ts +7 -7
- package/dist/{shapes → handlers}/types.d.ts.map +1 -1
- package/dist/{shapes → handlers}/types.js.map +1 -1
- package/dist/helpers/conflict.d.ts +1 -1
- package/dist/helpers/conflict.d.ts.map +1 -1
- package/dist/helpers/emitted-change.d.ts +1 -1
- package/dist/helpers/emitted-change.d.ts.map +1 -1
- package/dist/helpers/index.js +4 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -16
- package/dist/index.js.map +1 -1
- package/dist/notify.d.ts +47 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +85 -0
- package/dist/notify.js.map +1 -0
- package/dist/proxy/handler.d.ts +1 -1
- package/dist/proxy/handler.d.ts.map +1 -1
- package/dist/proxy/handler.js +15 -11
- package/dist/proxy/handler.js.map +1 -1
- package/dist/proxy/index.d.ts +2 -2
- 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/mutation-detector.d.ts +4 -0
- package/dist/proxy/mutation-detector.d.ts.map +1 -1
- package/dist/proxy/mutation-detector.js +209 -24
- package/dist/proxy/mutation-detector.js.map +1 -1
- package/dist/proxy/oplog.d.ts +2 -1
- package/dist/proxy/oplog.d.ts.map +1 -1
- package/dist/proxy/oplog.js +15 -9
- package/dist/proxy/oplog.js.map +1 -1
- package/dist/proxy/registry.d.ts +0 -11
- package/dist/proxy/registry.d.ts.map +1 -1
- package/dist/proxy/registry.js +0 -24
- package/dist/proxy/registry.js.map +1 -1
- package/dist/proxy/types.d.ts +2 -0
- package/dist/proxy/types.d.ts.map +1 -1
- package/dist/pull.d.ts +4 -3
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +565 -314
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +15 -3
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +359 -229
- package/dist/push.js.map +1 -1
- package/dist/realtime/index.js +1 -1
- package/dist/realtime/types.d.ts +2 -0
- package/dist/realtime/types.d.ts.map +1 -1
- package/dist/schema.d.ts +11 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.d.ts +6 -1
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.js +261 -92
- package/dist/snapshot-chunks/db-metadata.js.map +1 -1
- package/dist/snapshot-chunks/index.d.ts +0 -1
- package/dist/snapshot-chunks/index.d.ts.map +1 -1
- package/dist/snapshot-chunks/index.js +2 -3
- package/dist/snapshot-chunks/index.js.map +1 -1
- package/dist/snapshot-chunks/types.d.ts +20 -5
- package/dist/snapshot-chunks/types.d.ts.map +1 -1
- package/dist/snapshot-chunks.d.ts +12 -8
- package/dist/snapshot-chunks.d.ts.map +1 -1
- package/dist/snapshot-chunks.js +40 -12
- package/dist/snapshot-chunks.js.map +1 -1
- package/dist/subscriptions/index.js +1 -1
- package/dist/subscriptions/resolve.d.ts +6 -6
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +53 -14
- package/dist/subscriptions/resolve.js.map +1 -1
- package/package.json +28 -7
- package/src/blobs/adapters/database.test.ts +67 -0
- package/src/blobs/adapters/database.ts +34 -9
- package/src/blobs/adapters/filesystem.test.ts +132 -0
- package/src/blobs/adapters/filesystem.ts +189 -0
- package/src/blobs/adapters/s3.test.ts +522 -0
- package/src/blobs/adapters/s3.ts +55 -2
- package/src/blobs/index.ts +1 -0
- package/src/clients.ts +1 -0
- package/src/compaction.ts +1 -1
- package/src/dialect/base.ts +292 -0
- package/src/dialect/helpers.ts +61 -0
- package/src/dialect/index.ts +2 -0
- package/src/dialect/types.ts +50 -54
- package/src/{shapes → handlers}/create-handler.ts +219 -64
- package/src/{shapes → handlers}/types.ts +10 -7
- package/src/helpers/conflict.ts +1 -1
- package/src/helpers/emitted-change.ts +1 -1
- package/src/index.ts +2 -1
- package/src/notify.test.ts +516 -0
- package/src/notify.ts +131 -0
- package/src/proxy/handler.test.ts +120 -0
- package/src/proxy/handler.ts +18 -10
- package/src/proxy/index.ts +2 -1
- package/src/proxy/mutation-detector.test.ts +71 -0
- package/src/proxy/mutation-detector.ts +227 -29
- package/src/proxy/oplog.ts +19 -10
- package/src/proxy/registry.ts +0 -33
- package/src/proxy/types.ts +2 -0
- package/src/pull.ts +788 -405
- package/src/push.ts +507 -312
- package/src/realtime/types.ts +2 -0
- package/src/schema.ts +11 -1
- package/src/snapshot-chunks/db-metadata.test.ts +169 -0
- package/src/snapshot-chunks/db-metadata.ts +347 -105
- package/src/snapshot-chunks/index.ts +0 -1
- package/src/snapshot-chunks/types.ts +31 -5
- package/src/snapshot-chunks.ts +60 -21
- package/src/subscriptions/resolve.ts +73 -18
- package/dist/shapes/create-handler.d.ts.map +0 -1
- package/dist/shapes/create-handler.js.map +0 -1
- package/dist/shapes/index.d.ts.map +0 -1
- package/dist/shapes/index.js +0 -4
- package/dist/shapes/index.js.map +0 -1
- package/dist/shapes/registry.d.ts.map +0 -1
- package/dist/shapes/registry.js.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.d.ts +0 -63
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.js +0 -50
- package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
- package/src/snapshot-chunks/adapters/s3.ts +0 -68
- /package/dist/{shapes → handlers}/index.d.ts +0 -0
- /package/dist/{shapes → handlers}/registry.d.ts +0 -0
- /package/dist/{shapes → handlers}/registry.js +0 -0
- /package/dist/{shapes → handlers}/types.js +0 -0
- /package/src/{shapes → handlers}/index.ts +0 -0
- /package/src/{shapes → handlers}/registry.ts +0 -0
package/dist/push.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureSyncException, countSyncMetric, distributionSyncMetric, startSyncSpan, } from '@syncular/core';
|
|
1
2
|
import { sql } from 'kysely';
|
|
2
3
|
class RejectCommitError extends Error {
|
|
3
4
|
response;
|
|
@@ -7,12 +8,13 @@ class RejectCommitError extends Error {
|
|
|
7
8
|
this.name = 'RejectCommitError';
|
|
8
9
|
}
|
|
9
10
|
}
|
|
10
|
-
async function readCommitAffectedTables(db, dialect, commitSeq) {
|
|
11
|
+
async function readCommitAffectedTables(db, dialect, commitSeq, partitionId) {
|
|
11
12
|
try {
|
|
12
13
|
const commitsQ = db.selectFrom('sync_commits');
|
|
13
14
|
const row = await commitsQ
|
|
14
15
|
.selectAll()
|
|
15
16
|
.where(sql `commit_seq = ${commitSeq}`)
|
|
17
|
+
.where(sql `partition_id = ${partitionId}`)
|
|
16
18
|
.executeTakeFirst();
|
|
17
19
|
const raw = row?.affected_tables;
|
|
18
20
|
return dialect.dbToArray(raw);
|
|
@@ -21,87 +23,96 @@ async function readCommitAffectedTables(db, dialect, commitSeq) {
|
|
|
21
23
|
// ignore and fall back to scanning changes (best-effort)
|
|
22
24
|
}
|
|
23
25
|
// Fallback: read from changes using dialect-specific implementation
|
|
24
|
-
return dialect.readAffectedTablesFromChanges(db, commitSeq);
|
|
26
|
+
return dialect.readAffectedTablesFromChanges(db, commitSeq, { partitionId });
|
|
27
|
+
}
|
|
28
|
+
function scopeKeysFromEmitted(emitted) {
|
|
29
|
+
const keys = new Set();
|
|
30
|
+
for (const c of emitted) {
|
|
31
|
+
for (const [key, value] of Object.entries(c.scopes)) {
|
|
32
|
+
if (!value)
|
|
33
|
+
continue;
|
|
34
|
+
const prefix = key.replace(/_id$/, '');
|
|
35
|
+
keys.add(`${prefix}:${value}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return Array.from(keys);
|
|
39
|
+
}
|
|
40
|
+
function recordPushMetrics(args) {
|
|
41
|
+
const { status, durationMs, operationCount, emittedChangeCount, affectedTableCount, } = args;
|
|
42
|
+
countSyncMetric('sync.server.push.requests', 1, {
|
|
43
|
+
attributes: { status },
|
|
44
|
+
});
|
|
45
|
+
countSyncMetric('sync.server.push.operations', operationCount, {
|
|
46
|
+
attributes: { status },
|
|
47
|
+
});
|
|
48
|
+
distributionSyncMetric('sync.server.push.duration_ms', durationMs, {
|
|
49
|
+
unit: 'millisecond',
|
|
50
|
+
attributes: { status },
|
|
51
|
+
});
|
|
52
|
+
distributionSyncMetric('sync.server.push.emitted_changes', emittedChangeCount, {
|
|
53
|
+
attributes: { status },
|
|
54
|
+
});
|
|
55
|
+
distributionSyncMetric('sync.server.push.affected_tables', affectedTableCount, {
|
|
56
|
+
attributes: { status },
|
|
57
|
+
});
|
|
25
58
|
}
|
|
26
59
|
export async function pushCommit(args) {
|
|
27
60
|
const { request, dialect } = args;
|
|
28
61
|
const db = args.db;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
const partitionId = args.partitionId ?? 'default';
|
|
63
|
+
const requestedOps = Array.isArray(request.operations)
|
|
64
|
+
? request.operations
|
|
65
|
+
: [];
|
|
66
|
+
const operationCount = requestedOps.length;
|
|
67
|
+
const startedAtMs = Date.now();
|
|
68
|
+
return startSyncSpan({
|
|
69
|
+
name: 'sync.server.push',
|
|
70
|
+
op: 'sync.push',
|
|
71
|
+
attributes: {
|
|
72
|
+
operation_count: operationCount,
|
|
73
|
+
},
|
|
74
|
+
}, async (span) => {
|
|
75
|
+
const finalizeResult = (result) => {
|
|
76
|
+
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
77
|
+
const status = result.response.status;
|
|
78
|
+
span.setAttribute('status', status);
|
|
79
|
+
span.setAttribute('duration_ms', durationMs);
|
|
80
|
+
span.setAttribute('emitted_change_count', result.emittedChanges.length);
|
|
81
|
+
span.setAttribute('affected_table_count', result.affectedTables.length);
|
|
82
|
+
span.setStatus('ok');
|
|
83
|
+
recordPushMetrics({
|
|
84
|
+
status,
|
|
85
|
+
durationMs,
|
|
86
|
+
operationCount,
|
|
87
|
+
emittedChangeCount: result.emittedChanges.length,
|
|
88
|
+
affectedTableCount: result.affectedTables.length,
|
|
89
|
+
});
|
|
90
|
+
return result;
|
|
45
91
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
92
|
+
try {
|
|
93
|
+
if (!request.clientId || !request.clientCommitId) {
|
|
94
|
+
return finalizeResult({
|
|
95
|
+
response: {
|
|
96
|
+
ok: true,
|
|
97
|
+
status: 'rejected',
|
|
98
|
+
results: [
|
|
99
|
+
{
|
|
100
|
+
opIndex: 0,
|
|
101
|
+
status: 'error',
|
|
102
|
+
error: 'INVALID_REQUEST',
|
|
103
|
+
code: 'INVALID_REQUEST',
|
|
104
|
+
retriable: false,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
60
107
|
},
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
return dialect.executeInTransaction(db, async (trx) => {
|
|
67
|
-
const syncTrx = trx;
|
|
68
|
-
// Clean up any stale commit row with null result_json.
|
|
69
|
-
// This can happen when a previous push inserted the commit row but crashed
|
|
70
|
-
// before writing the result (e.g., on D1 without transaction support).
|
|
71
|
-
await syncTrx
|
|
72
|
-
.deleteFrom('sync_commits')
|
|
73
|
-
.where('client_id', '=', request.clientId)
|
|
74
|
-
.where('client_commit_id', '=', request.clientCommitId)
|
|
75
|
-
.where('result_json', 'is', null)
|
|
76
|
-
.execute();
|
|
77
|
-
// Insert commit row (idempotency key)
|
|
78
|
-
const commitRow = {
|
|
79
|
-
actor_id: args.actorId,
|
|
80
|
-
client_id: request.clientId,
|
|
81
|
-
client_commit_id: request.clientCommitId,
|
|
82
|
-
meta: null,
|
|
83
|
-
result_json: null,
|
|
84
|
-
};
|
|
85
|
-
const insertResult = await syncTrx
|
|
86
|
-
.insertInto('sync_commits')
|
|
87
|
-
.values(commitRow)
|
|
88
|
-
.onConflict((oc) => oc.columns(['client_id', 'client_commit_id']).doNothing())
|
|
89
|
-
.executeTakeFirstOrThrow();
|
|
90
|
-
const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
|
|
91
|
-
if (insertedRows === 0) {
|
|
92
|
-
// Existing commit: return cached response (applied or rejected)
|
|
93
|
-
// Use forUpdate() for row locking on databases that support it
|
|
94
|
-
let query = syncTrx.selectFrom('sync_commits')
|
|
95
|
-
.selectAll()
|
|
96
|
-
.where('client_id', '=', request.clientId)
|
|
97
|
-
.where('client_commit_id', '=', request.clientCommitId);
|
|
98
|
-
if (dialect.supportsForUpdate) {
|
|
99
|
-
query = query.forUpdate();
|
|
108
|
+
affectedTables: [],
|
|
109
|
+
scopeKeys: [],
|
|
110
|
+
emittedChanges: [],
|
|
111
|
+
});
|
|
100
112
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return {
|
|
113
|
+
const ops = request.operations ?? [];
|
|
114
|
+
if (!Array.isArray(ops) || ops.length === 0) {
|
|
115
|
+
return finalizeResult({
|
|
105
116
|
response: {
|
|
106
117
|
ok: true,
|
|
107
118
|
status: 'rejected',
|
|
@@ -109,173 +120,292 @@ export async function pushCommit(args) {
|
|
|
109
120
|
{
|
|
110
121
|
opIndex: 0,
|
|
111
122
|
status: 'error',
|
|
112
|
-
error: '
|
|
113
|
-
code: '
|
|
114
|
-
retriable:
|
|
123
|
+
error: 'EMPTY_COMMIT',
|
|
124
|
+
code: 'EMPTY_COMMIT',
|
|
125
|
+
retriable: false,
|
|
115
126
|
},
|
|
116
127
|
],
|
|
117
128
|
},
|
|
118
129
|
affectedTables: [],
|
|
119
|
-
|
|
130
|
+
scopeKeys: [],
|
|
131
|
+
emittedChanges: [],
|
|
132
|
+
});
|
|
120
133
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
return finalizeResult(await dialect.executeInTransaction(db, async (trx) => {
|
|
135
|
+
const syncTrx = trx;
|
|
136
|
+
// Clean up any stale commit row with null result_json.
|
|
137
|
+
// This can happen when a previous push inserted the commit row but crashed
|
|
138
|
+
// before writing the result (e.g., on D1 without transaction support).
|
|
139
|
+
await syncTrx
|
|
140
|
+
.deleteFrom('sync_commits')
|
|
141
|
+
.where('partition_id', '=', partitionId)
|
|
142
|
+
.where('client_id', '=', request.clientId)
|
|
143
|
+
.where('client_commit_id', '=', request.clientCommitId)
|
|
144
|
+
.where('result_json', 'is', null)
|
|
145
|
+
.execute();
|
|
146
|
+
// Insert commit row (idempotency key)
|
|
147
|
+
const commitRow = {
|
|
148
|
+
partition_id: partitionId,
|
|
149
|
+
actor_id: args.actorId,
|
|
150
|
+
client_id: request.clientId,
|
|
151
|
+
client_commit_id: request.clientCommitId,
|
|
152
|
+
meta: null,
|
|
153
|
+
result_json: null,
|
|
132
154
|
};
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
155
|
+
const insertResult = await syncTrx
|
|
156
|
+
.insertInto('sync_commits')
|
|
157
|
+
.values(commitRow)
|
|
158
|
+
.onConflict((oc) => oc
|
|
159
|
+
.columns(['partition_id', 'client_id', 'client_commit_id'])
|
|
160
|
+
.doNothing())
|
|
161
|
+
.executeTakeFirstOrThrow();
|
|
162
|
+
const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
|
|
163
|
+
if (insertedRows === 0) {
|
|
164
|
+
// Existing commit: return cached response (applied or rejected)
|
|
165
|
+
// Use forUpdate() for row locking on databases that support it
|
|
166
|
+
let query = syncTrx.selectFrom('sync_commits')
|
|
167
|
+
.selectAll()
|
|
168
|
+
.where('partition_id', '=', partitionId)
|
|
169
|
+
.where('client_id', '=', request.clientId)
|
|
170
|
+
.where('client_commit_id', '=', request.clientCommitId);
|
|
171
|
+
if (dialect.supportsForUpdate) {
|
|
172
|
+
query = query.forUpdate();
|
|
173
|
+
}
|
|
174
|
+
const existing = await query.executeTakeFirstOrThrow();
|
|
175
|
+
const cached = existing.result_json;
|
|
176
|
+
if (!cached || cached.ok !== true) {
|
|
177
|
+
return {
|
|
178
|
+
response: {
|
|
179
|
+
ok: true,
|
|
180
|
+
status: 'rejected',
|
|
181
|
+
results: [
|
|
182
|
+
{
|
|
183
|
+
opIndex: 0,
|
|
184
|
+
status: 'error',
|
|
185
|
+
error: 'IDEMPOTENCY_CACHE_MISS',
|
|
186
|
+
code: 'INTERNAL',
|
|
187
|
+
retriable: true,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
affectedTables: [],
|
|
192
|
+
scopeKeys: [],
|
|
193
|
+
emittedChanges: [],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const base = {
|
|
197
|
+
...cached,
|
|
198
|
+
commitSeq: Number(existing.commit_seq),
|
|
199
|
+
};
|
|
200
|
+
if (cached.status === 'applied') {
|
|
201
|
+
const tablesFromDb = dialect.dbToArray(existing.affected_tables);
|
|
202
|
+
return {
|
|
203
|
+
response: { ...base, status: 'cached' },
|
|
204
|
+
affectedTables: tablesFromDb.length > 0
|
|
205
|
+
? tablesFromDb
|
|
206
|
+
: await readCommitAffectedTables(trx, dialect, Number(existing.commit_seq), partitionId),
|
|
207
|
+
scopeKeys: [],
|
|
208
|
+
emittedChanges: [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
response: base,
|
|
213
|
+
affectedTables: [],
|
|
214
|
+
scopeKeys: [],
|
|
215
|
+
emittedChanges: [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const insertedCommit = await syncTrx.selectFrom('sync_commits')
|
|
219
|
+
.selectAll()
|
|
220
|
+
.where('partition_id', '=', partitionId)
|
|
221
|
+
.where('client_id', '=', request.clientId)
|
|
222
|
+
.where('client_commit_id', '=', request.clientCommitId)
|
|
223
|
+
.executeTakeFirstOrThrow();
|
|
224
|
+
const commitSeq = Number(insertedCommit.commit_seq);
|
|
225
|
+
const commitId = `${request.clientId}:${request.clientCommitId}`;
|
|
226
|
+
const savepointName = 'sync_apply';
|
|
227
|
+
const useSavepoints = dialect.supportsSavepoints;
|
|
228
|
+
let savepointCreated = false;
|
|
229
|
+
try {
|
|
230
|
+
// Apply the commit under a savepoint so we can roll back app writes on conflict
|
|
231
|
+
// while still persisting the commit-level cached response.
|
|
232
|
+
if (useSavepoints) {
|
|
233
|
+
await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
|
|
234
|
+
savepointCreated = true;
|
|
235
|
+
}
|
|
236
|
+
const allEmitted = [];
|
|
237
|
+
const results = [];
|
|
238
|
+
const affectedTablesSet = new Set();
|
|
239
|
+
for (let i = 0; i < ops.length; i++) {
|
|
240
|
+
const op = ops[i];
|
|
241
|
+
const handler = args.handlers.getOrThrow(op.table);
|
|
242
|
+
const applied = await handler.applyOperation({
|
|
243
|
+
db: trx,
|
|
244
|
+
trx,
|
|
245
|
+
actorId: args.actorId,
|
|
246
|
+
clientId: request.clientId,
|
|
247
|
+
commitId,
|
|
248
|
+
schemaVersion: request.schemaVersion,
|
|
249
|
+
}, op, i);
|
|
250
|
+
if (applied.result.status !== 'applied') {
|
|
251
|
+
results.push(applied.result);
|
|
252
|
+
throw new RejectCommitError({
|
|
253
|
+
ok: true,
|
|
254
|
+
status: 'rejected',
|
|
255
|
+
commitSeq,
|
|
256
|
+
results,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
// Framework-level enforcement: emitted changes must have scopes
|
|
260
|
+
for (const c of applied.emittedChanges ?? []) {
|
|
261
|
+
const scopes = c?.scopes;
|
|
262
|
+
if (!scopes || typeof scopes !== 'object') {
|
|
263
|
+
results.push({
|
|
264
|
+
opIndex: i,
|
|
265
|
+
status: 'error',
|
|
266
|
+
error: 'MISSING_SCOPES',
|
|
267
|
+
code: 'INVALID_SCOPE',
|
|
268
|
+
retriable: false,
|
|
269
|
+
});
|
|
270
|
+
throw new RejectCommitError({
|
|
271
|
+
ok: true,
|
|
272
|
+
status: 'rejected',
|
|
273
|
+
commitSeq,
|
|
274
|
+
results,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
results.push(applied.result);
|
|
279
|
+
allEmitted.push(...applied.emittedChanges);
|
|
280
|
+
for (const c of applied.emittedChanges) {
|
|
281
|
+
affectedTablesSet.add(c.table);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (allEmitted.length > 0) {
|
|
285
|
+
const changeRows = allEmitted.map((c) => ({
|
|
286
|
+
partition_id: partitionId,
|
|
287
|
+
commit_seq: commitSeq,
|
|
288
|
+
table: c.table,
|
|
289
|
+
row_id: c.row_id,
|
|
290
|
+
op: c.op,
|
|
291
|
+
row_json: c.row_json,
|
|
292
|
+
row_version: c.row_version,
|
|
293
|
+
scopes: dialect.scopesToDb(c.scopes),
|
|
294
|
+
}));
|
|
295
|
+
await syncTrx
|
|
296
|
+
.insertInto('sync_changes')
|
|
297
|
+
.values(changeRows)
|
|
298
|
+
.execute();
|
|
299
|
+
}
|
|
300
|
+
const appliedResponse = {
|
|
170
301
|
ok: true,
|
|
171
|
-
status: '
|
|
302
|
+
status: 'applied',
|
|
172
303
|
commitSeq,
|
|
173
304
|
results,
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
});
|
|
305
|
+
};
|
|
306
|
+
const affectedTables = Array.from(affectedTablesSet).sort();
|
|
307
|
+
const appliedCommitUpdate = {
|
|
308
|
+
result_json: appliedResponse,
|
|
309
|
+
change_count: allEmitted.length,
|
|
310
|
+
affected_tables: affectedTables,
|
|
311
|
+
};
|
|
312
|
+
await syncTrx
|
|
313
|
+
.updateTable('sync_commits')
|
|
314
|
+
.set(appliedCommitUpdate)
|
|
315
|
+
.where('commit_seq', '=', commitSeq)
|
|
316
|
+
.execute();
|
|
317
|
+
// Insert table commits for subscription filtering
|
|
318
|
+
if (affectedTables.length > 0) {
|
|
319
|
+
const tableCommits = affectedTables.map((table) => ({
|
|
320
|
+
partition_id: partitionId,
|
|
321
|
+
table,
|
|
322
|
+
commit_seq: commitSeq,
|
|
323
|
+
}));
|
|
324
|
+
await syncTrx
|
|
325
|
+
.insertInto('sync_table_commits')
|
|
326
|
+
.values(tableCommits)
|
|
327
|
+
.onConflict((oc) => oc
|
|
328
|
+
.columns(['partition_id', 'table', 'commit_seq'])
|
|
329
|
+
.doNothing())
|
|
330
|
+
.execute();
|
|
193
331
|
}
|
|
332
|
+
if (useSavepoints) {
|
|
333
|
+
await sql
|
|
334
|
+
.raw(`RELEASE SAVEPOINT ${savepointName}`)
|
|
335
|
+
.execute(trx);
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
response: appliedResponse,
|
|
339
|
+
affectedTables,
|
|
340
|
+
scopeKeys: scopeKeysFromEmitted(allEmitted),
|
|
341
|
+
emittedChanges: allEmitted.map((c) => ({
|
|
342
|
+
table: c.table,
|
|
343
|
+
row_id: c.row_id,
|
|
344
|
+
op: c.op,
|
|
345
|
+
row_json: c.row_json,
|
|
346
|
+
row_version: c.row_version,
|
|
347
|
+
scopes: c.scopes,
|
|
348
|
+
})),
|
|
349
|
+
};
|
|
194
350
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
351
|
+
catch (err) {
|
|
352
|
+
// Roll back app writes but keep the commit row.
|
|
353
|
+
if (savepointCreated) {
|
|
354
|
+
try {
|
|
355
|
+
await sql
|
|
356
|
+
.raw(`ROLLBACK TO SAVEPOINT ${savepointName}`)
|
|
357
|
+
.execute(trx);
|
|
358
|
+
await sql
|
|
359
|
+
.raw(`RELEASE SAVEPOINT ${savepointName}`)
|
|
360
|
+
.execute(trx);
|
|
361
|
+
}
|
|
362
|
+
catch (savepointErr) {
|
|
363
|
+
// If savepoint rollback fails, the transaction may be in an
|
|
364
|
+
// inconsistent state. Log and rethrow to fail the entire commit
|
|
365
|
+
// rather than risk data corruption.
|
|
366
|
+
console.error('[pushCommit] Savepoint rollback failed:', savepointErr);
|
|
367
|
+
throw savepointErr;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!(err instanceof RejectCommitError))
|
|
371
|
+
throw err;
|
|
372
|
+
const rejectedCommitUpdate = {
|
|
373
|
+
result_json: err.response,
|
|
374
|
+
change_count: 0,
|
|
375
|
+
affected_tables: [],
|
|
376
|
+
};
|
|
377
|
+
// Persist the rejected response for commit-level idempotency.
|
|
378
|
+
await syncTrx
|
|
379
|
+
.updateTable('sync_commits')
|
|
380
|
+
.set(rejectedCommitUpdate)
|
|
381
|
+
.where('commit_seq', '=', commitSeq)
|
|
382
|
+
.execute();
|
|
383
|
+
return {
|
|
384
|
+
response: err.response,
|
|
385
|
+
affectedTables: [],
|
|
386
|
+
scopeKeys: [],
|
|
387
|
+
emittedChanges: [],
|
|
388
|
+
};
|
|
199
389
|
}
|
|
200
|
-
}
|
|
201
|
-
if (allEmitted.length > 0) {
|
|
202
|
-
const changeRows = allEmitted.map((c) => ({
|
|
203
|
-
commit_seq: commitSeq,
|
|
204
|
-
table: c.table,
|
|
205
|
-
row_id: c.row_id,
|
|
206
|
-
op: c.op,
|
|
207
|
-
row_json: c.row_json,
|
|
208
|
-
row_version: c.row_version,
|
|
209
|
-
scopes: dialect.scopesToDb(c.scopes),
|
|
210
|
-
}));
|
|
211
|
-
await syncTrx.insertInto('sync_changes').values(changeRows).execute();
|
|
212
|
-
}
|
|
213
|
-
const appliedResponse = {
|
|
214
|
-
ok: true,
|
|
215
|
-
status: 'applied',
|
|
216
|
-
commitSeq,
|
|
217
|
-
results,
|
|
218
|
-
};
|
|
219
|
-
const affectedTables = Array.from(affectedTablesSet).sort();
|
|
220
|
-
const appliedCommitUpdate = {
|
|
221
|
-
result_json: appliedResponse,
|
|
222
|
-
change_count: allEmitted.length,
|
|
223
|
-
affected_tables: affectedTables,
|
|
224
|
-
};
|
|
225
|
-
await syncTrx
|
|
226
|
-
.updateTable('sync_commits')
|
|
227
|
-
.set(appliedCommitUpdate)
|
|
228
|
-
.where('commit_seq', '=', commitSeq)
|
|
229
|
-
.execute();
|
|
230
|
-
// Insert table commits for subscription filtering
|
|
231
|
-
if (affectedTables.length > 0) {
|
|
232
|
-
const tableCommits = affectedTables.map((table) => ({
|
|
233
|
-
table,
|
|
234
|
-
commit_seq: commitSeq,
|
|
235
|
-
}));
|
|
236
|
-
await syncTrx
|
|
237
|
-
.insertInto('sync_table_commits')
|
|
238
|
-
.values(tableCommits)
|
|
239
|
-
.onConflict((oc) => oc.columns(['table', 'commit_seq']).doNothing())
|
|
240
|
-
.execute();
|
|
241
|
-
}
|
|
242
|
-
if (useSavepoints) {
|
|
243
|
-
await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
|
|
244
|
-
}
|
|
245
|
-
return {
|
|
246
|
-
response: appliedResponse,
|
|
247
|
-
affectedTables,
|
|
248
|
-
};
|
|
390
|
+
}));
|
|
249
391
|
}
|
|
250
|
-
catch (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const rejectedCommitUpdate = {
|
|
268
|
-
result_json: err.response,
|
|
269
|
-
change_count: 0,
|
|
270
|
-
affected_tables: [],
|
|
271
|
-
};
|
|
272
|
-
// Persist the rejected response for commit-level idempotency.
|
|
273
|
-
await syncTrx
|
|
274
|
-
.updateTable('sync_commits')
|
|
275
|
-
.set(rejectedCommitUpdate)
|
|
276
|
-
.where('commit_seq', '=', commitSeq)
|
|
277
|
-
.execute();
|
|
278
|
-
return { response: err.response, affectedTables: [] };
|
|
392
|
+
catch (error) {
|
|
393
|
+
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
394
|
+
span.setAttribute('status', 'error');
|
|
395
|
+
span.setAttribute('duration_ms', durationMs);
|
|
396
|
+
span.setStatus('error');
|
|
397
|
+
recordPushMetrics({
|
|
398
|
+
status: 'error',
|
|
399
|
+
durationMs,
|
|
400
|
+
operationCount,
|
|
401
|
+
emittedChangeCount: 0,
|
|
402
|
+
affectedTableCount: 0,
|
|
403
|
+
});
|
|
404
|
+
captureSyncException(error, {
|
|
405
|
+
event: 'sync.server.push',
|
|
406
|
+
operationCount,
|
|
407
|
+
});
|
|
408
|
+
throw error;
|
|
279
409
|
}
|
|
280
410
|
});
|
|
281
411
|
}
|