@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.
Files changed (171) hide show
  1. package/README.md +25 -0
  2. package/dist/blobs/adapters/database.d.ts.map +1 -1
  3. package/dist/blobs/adapters/database.js +25 -3
  4. package/dist/blobs/adapters/database.js.map +1 -1
  5. package/dist/blobs/adapters/filesystem.d.ts +31 -0
  6. package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
  7. package/dist/blobs/adapters/filesystem.js +140 -0
  8. package/dist/blobs/adapters/filesystem.js.map +1 -0
  9. package/dist/blobs/adapters/s3.d.ts +3 -2
  10. package/dist/blobs/adapters/s3.d.ts.map +1 -1
  11. package/dist/blobs/adapters/s3.js +49 -0
  12. package/dist/blobs/adapters/s3.js.map +1 -1
  13. package/dist/blobs/index.d.ts +1 -0
  14. package/dist/blobs/index.d.ts.map +1 -1
  15. package/dist/blobs/index.js +6 -5
  16. package/dist/blobs/index.js.map +1 -1
  17. package/dist/clients.d.ts +1 -0
  18. package/dist/clients.d.ts.map +1 -1
  19. package/dist/clients.js.map +1 -1
  20. package/dist/compaction.d.ts +1 -1
  21. package/dist/compaction.js +1 -1
  22. package/dist/dialect/base.d.ts +83 -0
  23. package/dist/dialect/base.d.ts.map +1 -0
  24. package/dist/dialect/base.js +144 -0
  25. package/dist/dialect/base.js.map +1 -0
  26. package/dist/dialect/helpers.d.ts +10 -0
  27. package/dist/dialect/helpers.d.ts.map +1 -0
  28. package/dist/dialect/helpers.js +59 -0
  29. package/dist/dialect/helpers.js.map +1 -0
  30. package/dist/dialect/index.d.ts +2 -0
  31. package/dist/dialect/index.d.ts.map +1 -1
  32. package/dist/dialect/index.js +3 -1
  33. package/dist/dialect/index.js.map +1 -1
  34. package/dist/dialect/types.d.ts +38 -46
  35. package/dist/dialect/types.d.ts.map +1 -1
  36. package/dist/{shapes → handlers}/create-handler.d.ts +18 -5
  37. package/dist/handlers/create-handler.d.ts.map +1 -0
  38. package/dist/{shapes → handlers}/create-handler.js +140 -43
  39. package/dist/handlers/create-handler.js.map +1 -0
  40. package/dist/handlers/index.d.ts.map +1 -0
  41. package/dist/handlers/index.js +4 -0
  42. package/dist/handlers/index.js.map +1 -0
  43. package/dist/handlers/registry.d.ts.map +1 -0
  44. package/dist/handlers/registry.js.map +1 -0
  45. package/dist/{shapes → handlers}/types.d.ts +7 -7
  46. package/dist/{shapes → handlers}/types.d.ts.map +1 -1
  47. package/dist/{shapes → handlers}/types.js.map +1 -1
  48. package/dist/helpers/conflict.d.ts +1 -1
  49. package/dist/helpers/conflict.d.ts.map +1 -1
  50. package/dist/helpers/emitted-change.d.ts +1 -1
  51. package/dist/helpers/emitted-change.d.ts.map +1 -1
  52. package/dist/helpers/index.js +4 -4
  53. package/dist/index.d.ts +2 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +17 -16
  56. package/dist/index.js.map +1 -1
  57. package/dist/notify.d.ts +47 -0
  58. package/dist/notify.d.ts.map +1 -0
  59. package/dist/notify.js +85 -0
  60. package/dist/notify.js.map +1 -0
  61. package/dist/proxy/handler.d.ts +1 -1
  62. package/dist/proxy/handler.d.ts.map +1 -1
  63. package/dist/proxy/handler.js +15 -11
  64. package/dist/proxy/handler.js.map +1 -1
  65. package/dist/proxy/index.d.ts +2 -2
  66. package/dist/proxy/index.d.ts.map +1 -1
  67. package/dist/proxy/index.js +3 -3
  68. package/dist/proxy/index.js.map +1 -1
  69. package/dist/proxy/mutation-detector.d.ts +4 -0
  70. package/dist/proxy/mutation-detector.d.ts.map +1 -1
  71. package/dist/proxy/mutation-detector.js +209 -24
  72. package/dist/proxy/mutation-detector.js.map +1 -1
  73. package/dist/proxy/oplog.d.ts +2 -1
  74. package/dist/proxy/oplog.d.ts.map +1 -1
  75. package/dist/proxy/oplog.js +15 -9
  76. package/dist/proxy/oplog.js.map +1 -1
  77. package/dist/proxy/registry.d.ts +0 -11
  78. package/dist/proxy/registry.d.ts.map +1 -1
  79. package/dist/proxy/registry.js +0 -24
  80. package/dist/proxy/registry.js.map +1 -1
  81. package/dist/proxy/types.d.ts +2 -0
  82. package/dist/proxy/types.d.ts.map +1 -1
  83. package/dist/pull.d.ts +4 -3
  84. package/dist/pull.d.ts.map +1 -1
  85. package/dist/pull.js +565 -314
  86. package/dist/pull.js.map +1 -1
  87. package/dist/push.d.ts +15 -3
  88. package/dist/push.d.ts.map +1 -1
  89. package/dist/push.js +359 -229
  90. package/dist/push.js.map +1 -1
  91. package/dist/realtime/index.js +1 -1
  92. package/dist/realtime/types.d.ts +2 -0
  93. package/dist/realtime/types.d.ts.map +1 -1
  94. package/dist/schema.d.ts +11 -1
  95. package/dist/schema.d.ts.map +1 -1
  96. package/dist/snapshot-chunks/db-metadata.d.ts +6 -1
  97. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
  98. package/dist/snapshot-chunks/db-metadata.js +261 -92
  99. package/dist/snapshot-chunks/db-metadata.js.map +1 -1
  100. package/dist/snapshot-chunks/index.d.ts +0 -1
  101. package/dist/snapshot-chunks/index.d.ts.map +1 -1
  102. package/dist/snapshot-chunks/index.js +2 -3
  103. package/dist/snapshot-chunks/index.js.map +1 -1
  104. package/dist/snapshot-chunks/types.d.ts +20 -5
  105. package/dist/snapshot-chunks/types.d.ts.map +1 -1
  106. package/dist/snapshot-chunks.d.ts +12 -8
  107. package/dist/snapshot-chunks.d.ts.map +1 -1
  108. package/dist/snapshot-chunks.js +40 -12
  109. package/dist/snapshot-chunks.js.map +1 -1
  110. package/dist/subscriptions/index.js +1 -1
  111. package/dist/subscriptions/resolve.d.ts +6 -6
  112. package/dist/subscriptions/resolve.d.ts.map +1 -1
  113. package/dist/subscriptions/resolve.js +53 -14
  114. package/dist/subscriptions/resolve.js.map +1 -1
  115. package/package.json +28 -7
  116. package/src/blobs/adapters/database.test.ts +67 -0
  117. package/src/blobs/adapters/database.ts +34 -9
  118. package/src/blobs/adapters/filesystem.test.ts +132 -0
  119. package/src/blobs/adapters/filesystem.ts +189 -0
  120. package/src/blobs/adapters/s3.test.ts +522 -0
  121. package/src/blobs/adapters/s3.ts +55 -2
  122. package/src/blobs/index.ts +1 -0
  123. package/src/clients.ts +1 -0
  124. package/src/compaction.ts +1 -1
  125. package/src/dialect/base.ts +292 -0
  126. package/src/dialect/helpers.ts +61 -0
  127. package/src/dialect/index.ts +2 -0
  128. package/src/dialect/types.ts +50 -54
  129. package/src/{shapes → handlers}/create-handler.ts +219 -64
  130. package/src/{shapes → handlers}/types.ts +10 -7
  131. package/src/helpers/conflict.ts +1 -1
  132. package/src/helpers/emitted-change.ts +1 -1
  133. package/src/index.ts +2 -1
  134. package/src/notify.test.ts +516 -0
  135. package/src/notify.ts +131 -0
  136. package/src/proxy/handler.test.ts +120 -0
  137. package/src/proxy/handler.ts +18 -10
  138. package/src/proxy/index.ts +2 -1
  139. package/src/proxy/mutation-detector.test.ts +71 -0
  140. package/src/proxy/mutation-detector.ts +227 -29
  141. package/src/proxy/oplog.ts +19 -10
  142. package/src/proxy/registry.ts +0 -33
  143. package/src/proxy/types.ts +2 -0
  144. package/src/pull.ts +788 -405
  145. package/src/push.ts +507 -312
  146. package/src/realtime/types.ts +2 -0
  147. package/src/schema.ts +11 -1
  148. package/src/snapshot-chunks/db-metadata.test.ts +169 -0
  149. package/src/snapshot-chunks/db-metadata.ts +347 -105
  150. package/src/snapshot-chunks/index.ts +0 -1
  151. package/src/snapshot-chunks/types.ts +31 -5
  152. package/src/snapshot-chunks.ts +60 -21
  153. package/src/subscriptions/resolve.ts +73 -18
  154. package/dist/shapes/create-handler.d.ts.map +0 -1
  155. package/dist/shapes/create-handler.js.map +0 -1
  156. package/dist/shapes/index.d.ts.map +0 -1
  157. package/dist/shapes/index.js +0 -4
  158. package/dist/shapes/index.js.map +0 -1
  159. package/dist/shapes/registry.d.ts.map +0 -1
  160. package/dist/shapes/registry.js.map +0 -1
  161. package/dist/snapshot-chunks/adapters/s3.d.ts +0 -63
  162. package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
  163. package/dist/snapshot-chunks/adapters/s3.js +0 -50
  164. package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
  165. package/src/snapshot-chunks/adapters/s3.ts +0 -68
  166. /package/dist/{shapes → handlers}/index.d.ts +0 -0
  167. /package/dist/{shapes → handlers}/registry.d.ts +0 -0
  168. /package/dist/{shapes → handlers}/registry.js +0 -0
  169. /package/dist/{shapes → handlers}/types.js +0 -0
  170. /package/src/{shapes → handlers}/index.ts +0 -0
  171. /package/src/{shapes → handlers}/registry.ts +0 -0
package/src/push.ts CHANGED
@@ -1,4 +1,12 @@
1
- import type { SyncPushRequest, SyncPushResponse } from '@syncular/core';
1
+ import {
2
+ captureSyncException,
3
+ countSyncMetric,
4
+ distributionSyncMetric,
5
+ type SyncChange,
6
+ type SyncPushRequest,
7
+ type SyncPushResponse,
8
+ startSyncSpan,
9
+ } from '@syncular/core';
2
10
  import type {
3
11
  Insertable,
4
12
  Kysely,
@@ -8,8 +16,8 @@ import type {
8
16
  } from 'kysely';
9
17
  import { sql } from 'kysely';
10
18
  import type { ServerSyncDialect } from './dialect/types';
19
+ import type { TableRegistry } from './handlers/registry';
11
20
  import type { SyncCoreDb } from './schema';
12
- import type { TableRegistry } from './shapes/registry';
13
21
 
14
22
  // biome-ignore lint/complexity/noBannedTypes: Kysely uses `{}` as the initial "no selected columns yet" marker.
15
23
  type EmptySelection = {};
@@ -21,6 +29,17 @@ export interface PushCommitResult {
21
29
  * Empty for rejected commits and for commits that emit no changes.
22
30
  */
23
31
  affectedTables: string[];
32
+ /**
33
+ * Scope keys derived from emitted changes (e.g. "org:abc", "team:xyz").
34
+ * Computed in-transaction so callers don't need an extra DB query.
35
+ * Empty for rejected/cached commits.
36
+ */
37
+ scopeKeys: string[];
38
+ /**
39
+ * Changes emitted by this commit. Available for WS data delivery.
40
+ * Empty for rejected/cached commits.
41
+ */
42
+ emittedChanges: SyncChange[];
24
43
  }
25
44
 
26
45
  class RejectCommitError extends Error {
@@ -33,7 +52,8 @@ class RejectCommitError extends Error {
33
52
  async function readCommitAffectedTables<DB extends SyncCoreDb>(
34
53
  db: Kysely<DB>,
35
54
  dialect: ServerSyncDialect,
36
- commitSeq: number
55
+ commitSeq: number,
56
+ partitionId: string
37
57
  ): Promise<string[]> {
38
58
  try {
39
59
  const commitsQ = db.selectFrom('sync_commits') as SelectQueryBuilder<
@@ -45,6 +65,7 @@ async function readCommitAffectedTables<DB extends SyncCoreDb>(
45
65
  const row = await commitsQ
46
66
  .selectAll()
47
67
  .where(sql<SqlBool>`commit_seq = ${commitSeq}`)
68
+ .where(sql<SqlBool>`partition_id = ${partitionId}`)
48
69
  .executeTakeFirst();
49
70
 
50
71
  const raw = row?.affected_tables;
@@ -54,335 +75,509 @@ async function readCommitAffectedTables<DB extends SyncCoreDb>(
54
75
  }
55
76
 
56
77
  // Fallback: read from changes using dialect-specific implementation
57
- return dialect.readAffectedTablesFromChanges(db, commitSeq);
78
+ return dialect.readAffectedTablesFromChanges(db, commitSeq, { partitionId });
79
+ }
80
+
81
+ function scopeKeysFromEmitted(
82
+ emitted: Array<{ scopes: Record<string, string> }>
83
+ ): string[] {
84
+ const keys = new Set<string>();
85
+ for (const c of emitted) {
86
+ for (const [key, value] of Object.entries(c.scopes)) {
87
+ if (!value) continue;
88
+ const prefix = key.replace(/_id$/, '');
89
+ keys.add(`${prefix}:${value}`);
90
+ }
91
+ }
92
+ return Array.from(keys);
93
+ }
94
+
95
+ function recordPushMetrics(args: {
96
+ status: string;
97
+ durationMs: number;
98
+ operationCount: number;
99
+ emittedChangeCount: number;
100
+ affectedTableCount: number;
101
+ }): void {
102
+ const {
103
+ status,
104
+ durationMs,
105
+ operationCount,
106
+ emittedChangeCount,
107
+ affectedTableCount,
108
+ } = args;
109
+
110
+ countSyncMetric('sync.server.push.requests', 1, {
111
+ attributes: { status },
112
+ });
113
+ countSyncMetric('sync.server.push.operations', operationCount, {
114
+ attributes: { status },
115
+ });
116
+ distributionSyncMetric('sync.server.push.duration_ms', durationMs, {
117
+ unit: 'millisecond',
118
+ attributes: { status },
119
+ });
120
+ distributionSyncMetric(
121
+ 'sync.server.push.emitted_changes',
122
+ emittedChangeCount,
123
+ {
124
+ attributes: { status },
125
+ }
126
+ );
127
+ distributionSyncMetric(
128
+ 'sync.server.push.affected_tables',
129
+ affectedTableCount,
130
+ {
131
+ attributes: { status },
132
+ }
133
+ );
58
134
  }
59
135
 
60
136
  export async function pushCommit<DB extends SyncCoreDb>(args: {
61
137
  db: Kysely<DB>;
62
138
  dialect: ServerSyncDialect;
63
- shapes: TableRegistry<DB>;
139
+ handlers: TableRegistry<DB>;
64
140
  actorId: string;
141
+ partitionId?: string;
65
142
  request: SyncPushRequest;
66
143
  }): Promise<PushCommitResult> {
67
144
  const { request, dialect } = args;
68
145
  const db = args.db;
69
-
70
- if (!request.clientId || !request.clientCommitId) {
71
- return {
72
- response: {
73
- ok: true,
74
- status: 'rejected',
75
- results: [
76
- {
77
- opIndex: 0,
78
- status: 'error',
79
- error: 'INVALID_REQUEST',
80
- code: 'INVALID_REQUEST',
81
- retriable: false,
82
- },
83
- ],
146
+ const partitionId = args.partitionId ?? 'default';
147
+ const requestedOps = Array.isArray(request.operations)
148
+ ? request.operations
149
+ : [];
150
+ const operationCount = requestedOps.length;
151
+ const startedAtMs = Date.now();
152
+
153
+ return startSyncSpan(
154
+ {
155
+ name: 'sync.server.push',
156
+ op: 'sync.push',
157
+ attributes: {
158
+ operation_count: operationCount,
84
159
  },
85
- affectedTables: [],
86
- };
87
- }
88
-
89
- const ops = request.operations ?? [];
90
- if (!Array.isArray(ops) || ops.length === 0) {
91
- return {
92
- response: {
93
- ok: true,
94
- status: 'rejected',
95
- results: [
96
- {
97
- opIndex: 0,
98
- status: 'error',
99
- error: 'EMPTY_COMMIT',
100
- code: 'EMPTY_COMMIT',
101
- retriable: false,
102
- },
103
- ],
104
- },
105
- affectedTables: [],
106
- };
107
- }
108
-
109
- return dialect.executeInTransaction(db, async (trx) => {
110
- type SyncTrx = Pick<
111
- Kysely<SyncCoreDb>,
112
- 'selectFrom' | 'insertInto' | 'updateTable' | 'deleteFrom'
113
- >;
114
-
115
- const syncTrx = trx as SyncTrx;
116
-
117
- // Clean up any stale commit row with null result_json.
118
- // This can happen when a previous push inserted the commit row but crashed
119
- // before writing the result (e.g., on D1 without transaction support).
120
- await syncTrx
121
- .deleteFrom('sync_commits')
122
- .where('client_id', '=', request.clientId)
123
- .where('client_commit_id', '=', request.clientCommitId)
124
- .where('result_json', 'is', null)
125
- .execute();
126
-
127
- // Insert commit row (idempotency key)
128
- const commitRow: Insertable<SyncCoreDb['sync_commits']> = {
129
- actor_id: args.actorId,
130
- client_id: request.clientId,
131
- client_commit_id: request.clientCommitId,
132
- meta: null,
133
- result_json: null,
134
- };
135
-
136
- const insertResult = await syncTrx
137
- .insertInto('sync_commits')
138
- .values(commitRow)
139
- .onConflict((oc) =>
140
- oc.columns(['client_id', 'client_commit_id']).doNothing()
141
- )
142
- .executeTakeFirstOrThrow();
143
-
144
- const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
145
- if (insertedRows === 0) {
146
- // Existing commit: return cached response (applied or rejected)
147
- // Use forUpdate() for row locking on databases that support it
148
- let query = (
149
- syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
150
- SyncCoreDb,
151
- 'sync_commits',
152
- EmptySelection
153
- >
154
- )
155
- .selectAll()
156
- .where('client_id', '=', request.clientId)
157
- .where('client_commit_id', '=', request.clientCommitId);
158
-
159
- if (dialect.supportsForUpdate) {
160
- query = query.forUpdate();
161
- }
162
-
163
- const existing = await query.executeTakeFirstOrThrow();
164
-
165
- const cached = existing.result_json as SyncPushResponse | null;
166
- if (!cached || cached.ok !== true) {
167
- return {
168
- response: {
169
- ok: true,
170
- status: 'rejected',
171
- results: [
172
- {
173
- opIndex: 0,
174
- status: 'error',
175
- error: 'IDEMPOTENCY_CACHE_MISS',
176
- code: 'INTERNAL',
177
- retriable: true,
178
- },
179
- ],
180
- },
181
- affectedTables: [],
182
- };
183
- }
184
-
185
- const base: SyncPushResponse = {
186
- ...cached,
187
- commitSeq: Number(existing.commit_seq),
160
+ },
161
+ async (span) => {
162
+ const finalizeResult = (result: PushCommitResult): PushCommitResult => {
163
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
164
+ const status = result.response.status;
165
+
166
+ span.setAttribute('status', status);
167
+ span.setAttribute('duration_ms', durationMs);
168
+ span.setAttribute('emitted_change_count', result.emittedChanges.length);
169
+ span.setAttribute('affected_table_count', result.affectedTables.length);
170
+ span.setStatus('ok');
171
+
172
+ recordPushMetrics({
173
+ status,
174
+ durationMs,
175
+ operationCount,
176
+ emittedChangeCount: result.emittedChanges.length,
177
+ affectedTableCount: result.affectedTables.length,
178
+ });
179
+
180
+ return result;
188
181
  };
189
182
 
190
- if (cached.status === 'applied') {
191
- const tablesFromDb = dialect.dbToArray(existing.affected_tables);
192
- return {
193
- response: { ...base, status: 'cached' },
194
- affectedTables:
195
- tablesFromDb.length > 0
196
- ? tablesFromDb
197
- : await readCommitAffectedTables(
198
- trx,
199
- dialect,
200
- Number(existing.commit_seq)
201
- ),
202
- };
203
- }
204
-
205
- return { response: base, affectedTables: [] };
206
- }
207
-
208
- const insertedCommit = await (
209
- syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
210
- SyncCoreDb,
211
- 'sync_commits',
212
- EmptySelection
213
- >
214
- )
215
- .selectAll()
216
- .where('client_id', '=', request.clientId)
217
- .where('client_commit_id', '=', request.clientCommitId)
218
- .executeTakeFirstOrThrow();
219
-
220
- const commitSeq = Number(insertedCommit.commit_seq);
221
- const commitId = `${request.clientId}:${request.clientCommitId}`;
222
-
223
- const savepointName = 'sync_apply';
224
- const useSavepoints = dialect.supportsSavepoints;
225
- let savepointCreated = false;
226
-
227
- try {
228
- // Apply the commit under a savepoint so we can roll back app writes on conflict
229
- // while still persisting the commit-level cached response.
230
- if (useSavepoints) {
231
- await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
232
- savepointCreated = true;
233
- }
234
-
235
- const allEmitted = [];
236
- const results = [];
237
- const affectedTablesSet = new Set<string>();
238
-
239
- for (let i = 0; i < ops.length; i++) {
240
- const op = ops[i]!;
241
- const handler = args.shapes.getOrThrow(op.table);
242
- const applied = await handler.applyOperation(
243
- {
244
- db: trx,
245
- trx,
246
- actorId: args.actorId,
247
- clientId: request.clientId,
248
- commitId,
249
- schemaVersion: request.schemaVersion,
250
- },
251
- op,
252
- i
253
- );
254
-
255
- if (applied.result.status !== 'applied') {
256
- results.push(applied.result);
257
- throw new RejectCommitError({
258
- ok: true,
259
- status: 'rejected',
260
- commitSeq,
261
- results,
183
+ try {
184
+ if (!request.clientId || !request.clientCommitId) {
185
+ return finalizeResult({
186
+ response: {
187
+ ok: true,
188
+ status: 'rejected',
189
+ results: [
190
+ {
191
+ opIndex: 0,
192
+ status: 'error',
193
+ error: 'INVALID_REQUEST',
194
+ code: 'INVALID_REQUEST',
195
+ retriable: false,
196
+ },
197
+ ],
198
+ },
199
+ affectedTables: [],
200
+ scopeKeys: [],
201
+ emittedChanges: [],
262
202
  });
263
203
  }
264
204
 
265
- // Framework-level enforcement: emitted changes must have scopes
266
- for (const c of applied.emittedChanges ?? []) {
267
- const scopes = c?.scopes;
268
- if (!scopes || typeof scopes !== 'object') {
269
- results.push({
270
- opIndex: i,
271
- status: 'error' as const,
272
- error: 'MISSING_SCOPES',
273
- code: 'INVALID_SCOPE',
274
- retriable: false,
275
- });
276
- throw new RejectCommitError({
205
+ const ops = request.operations ?? [];
206
+ if (!Array.isArray(ops) || ops.length === 0) {
207
+ return finalizeResult({
208
+ response: {
277
209
  ok: true,
278
210
  status: 'rejected',
279
- commitSeq,
280
- results,
281
- });
282
- }
283
- }
284
-
285
- results.push(applied.result);
286
- allEmitted.push(...applied.emittedChanges);
287
- for (const c of applied.emittedChanges) {
288
- affectedTablesSet.add(c.table);
211
+ results: [
212
+ {
213
+ opIndex: 0,
214
+ status: 'error',
215
+ error: 'EMPTY_COMMIT',
216
+ code: 'EMPTY_COMMIT',
217
+ retriable: false,
218
+ },
219
+ ],
220
+ },
221
+ affectedTables: [],
222
+ scopeKeys: [],
223
+ emittedChanges: [],
224
+ });
289
225
  }
290
- }
291
-
292
- if (allEmitted.length > 0) {
293
- const changeRows: Array<Insertable<SyncCoreDb['sync_changes']>> =
294
- allEmitted.map((c) => ({
295
- commit_seq: commitSeq,
296
- table: c.table,
297
- row_id: c.row_id,
298
- op: c.op,
299
- row_json: c.row_json,
300
- row_version: c.row_version,
301
- scopes: dialect.scopesToDb(c.scopes),
302
- }));
303
-
304
- await syncTrx.insertInto('sync_changes').values(changeRows).execute();
305
- }
306
-
307
- const appliedResponse: SyncPushResponse = {
308
- ok: true,
309
- status: 'applied',
310
- commitSeq,
311
- results,
312
- };
313
-
314
- const affectedTables = Array.from(affectedTablesSet).sort();
315
226
 
316
- const appliedCommitUpdate: Updateable<SyncCoreDb['sync_commits']> = {
317
- result_json: appliedResponse,
318
- change_count: allEmitted.length,
319
- affected_tables: affectedTables,
320
- };
321
-
322
- await syncTrx
323
- .updateTable('sync_commits')
324
- .set(appliedCommitUpdate)
325
- .where('commit_seq', '=', commitSeq)
326
- .execute();
327
-
328
- // Insert table commits for subscription filtering
329
- if (affectedTables.length > 0) {
330
- const tableCommits: Array<
331
- Insertable<SyncCoreDb['sync_table_commits']>
332
- > = affectedTables.map((table) => ({
333
- table,
334
- commit_seq: commitSeq,
335
- }));
336
-
337
- await syncTrx
338
- .insertInto('sync_table_commits')
339
- .values(tableCommits)
340
- .onConflict((oc) => oc.columns(['table', 'commit_seq']).doNothing())
341
- .execute();
342
- }
343
-
344
- if (useSavepoints) {
345
- await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
346
- }
347
-
348
- return {
349
- response: appliedResponse,
350
- affectedTables,
351
- };
352
- } catch (err) {
353
- // Roll back app writes but keep the commit row.
354
- if (savepointCreated) {
355
- try {
356
- await sql.raw(`ROLLBACK TO SAVEPOINT ${savepointName}`).execute(trx);
357
- await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
358
- } catch (savepointErr) {
359
- // If savepoint rollback fails, the transaction may be in an
360
- // inconsistent state. Log and rethrow to fail the entire commit
361
- // rather than risk data corruption.
362
- console.error(
363
- '[pushCommit] Savepoint rollback failed:',
364
- savepointErr
365
- );
366
- throw savepointErr;
367
- }
227
+ return finalizeResult(
228
+ await dialect.executeInTransaction(db, async (trx) => {
229
+ type SyncTrx = Pick<
230
+ Kysely<SyncCoreDb>,
231
+ 'selectFrom' | 'insertInto' | 'updateTable' | 'deleteFrom'
232
+ >;
233
+
234
+ const syncTrx = trx as SyncTrx;
235
+
236
+ // Clean up any stale commit row with null result_json.
237
+ // This can happen when a previous push inserted the commit row but crashed
238
+ // before writing the result (e.g., on D1 without transaction support).
239
+ await syncTrx
240
+ .deleteFrom('sync_commits')
241
+ .where('partition_id', '=', partitionId)
242
+ .where('client_id', '=', request.clientId)
243
+ .where('client_commit_id', '=', request.clientCommitId)
244
+ .where('result_json', 'is', null)
245
+ .execute();
246
+
247
+ // Insert commit row (idempotency key)
248
+ const commitRow: Insertable<SyncCoreDb['sync_commits']> = {
249
+ partition_id: partitionId,
250
+ actor_id: args.actorId,
251
+ client_id: request.clientId,
252
+ client_commit_id: request.clientCommitId,
253
+ meta: null,
254
+ result_json: null,
255
+ };
256
+
257
+ const insertResult = await syncTrx
258
+ .insertInto('sync_commits')
259
+ .values(commitRow)
260
+ .onConflict((oc) =>
261
+ oc
262
+ .columns(['partition_id', 'client_id', 'client_commit_id'])
263
+ .doNothing()
264
+ )
265
+ .executeTakeFirstOrThrow();
266
+
267
+ const insertedRows = Number(
268
+ insertResult.numInsertedOrUpdatedRows ?? 0
269
+ );
270
+ if (insertedRows === 0) {
271
+ // Existing commit: return cached response (applied or rejected)
272
+ // Use forUpdate() for row locking on databases that support it
273
+ let query = (
274
+ syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
275
+ SyncCoreDb,
276
+ 'sync_commits',
277
+ EmptySelection
278
+ >
279
+ )
280
+ .selectAll()
281
+ .where('partition_id', '=', partitionId)
282
+ .where('client_id', '=', request.clientId)
283
+ .where('client_commit_id', '=', request.clientCommitId);
284
+
285
+ if (dialect.supportsForUpdate) {
286
+ query = query.forUpdate();
287
+ }
288
+
289
+ const existing = await query.executeTakeFirstOrThrow();
290
+
291
+ const cached = existing.result_json as SyncPushResponse | null;
292
+ if (!cached || cached.ok !== true) {
293
+ return {
294
+ response: {
295
+ ok: true,
296
+ status: 'rejected',
297
+ results: [
298
+ {
299
+ opIndex: 0,
300
+ status: 'error',
301
+ error: 'IDEMPOTENCY_CACHE_MISS',
302
+ code: 'INTERNAL',
303
+ retriable: true,
304
+ },
305
+ ],
306
+ },
307
+ affectedTables: [],
308
+ scopeKeys: [],
309
+ emittedChanges: [],
310
+ };
311
+ }
312
+
313
+ const base: SyncPushResponse = {
314
+ ...cached,
315
+ commitSeq: Number(existing.commit_seq),
316
+ };
317
+
318
+ if (cached.status === 'applied') {
319
+ const tablesFromDb = dialect.dbToArray(
320
+ existing.affected_tables
321
+ );
322
+ return {
323
+ response: { ...base, status: 'cached' },
324
+ affectedTables:
325
+ tablesFromDb.length > 0
326
+ ? tablesFromDb
327
+ : await readCommitAffectedTables(
328
+ trx,
329
+ dialect,
330
+ Number(existing.commit_seq),
331
+ partitionId
332
+ ),
333
+ scopeKeys: [],
334
+ emittedChanges: [],
335
+ };
336
+ }
337
+
338
+ return {
339
+ response: base,
340
+ affectedTables: [],
341
+ scopeKeys: [],
342
+ emittedChanges: [],
343
+ };
344
+ }
345
+
346
+ const insertedCommit = await (
347
+ syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
348
+ SyncCoreDb,
349
+ 'sync_commits',
350
+ EmptySelection
351
+ >
352
+ )
353
+ .selectAll()
354
+ .where('partition_id', '=', partitionId)
355
+ .where('client_id', '=', request.clientId)
356
+ .where('client_commit_id', '=', request.clientCommitId)
357
+ .executeTakeFirstOrThrow();
358
+
359
+ const commitSeq = Number(insertedCommit.commit_seq);
360
+ const commitId = `${request.clientId}:${request.clientCommitId}`;
361
+
362
+ const savepointName = 'sync_apply';
363
+ const useSavepoints = dialect.supportsSavepoints;
364
+ let savepointCreated = false;
365
+
366
+ try {
367
+ // Apply the commit under a savepoint so we can roll back app writes on conflict
368
+ // while still persisting the commit-level cached response.
369
+ if (useSavepoints) {
370
+ await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
371
+ savepointCreated = true;
372
+ }
373
+
374
+ const allEmitted = [];
375
+ const results = [];
376
+ const affectedTablesSet = new Set<string>();
377
+
378
+ for (let i = 0; i < ops.length; i++) {
379
+ const op = ops[i]!;
380
+ const handler = args.handlers.getOrThrow(op.table);
381
+ const applied = await handler.applyOperation(
382
+ {
383
+ db: trx,
384
+ trx,
385
+ actorId: args.actorId,
386
+ clientId: request.clientId,
387
+ commitId,
388
+ schemaVersion: request.schemaVersion,
389
+ },
390
+ op,
391
+ i
392
+ );
393
+
394
+ if (applied.result.status !== 'applied') {
395
+ results.push(applied.result);
396
+ throw new RejectCommitError({
397
+ ok: true,
398
+ status: 'rejected',
399
+ commitSeq,
400
+ results,
401
+ });
402
+ }
403
+
404
+ // Framework-level enforcement: emitted changes must have scopes
405
+ for (const c of applied.emittedChanges ?? []) {
406
+ const scopes = c?.scopes;
407
+ if (!scopes || typeof scopes !== 'object') {
408
+ results.push({
409
+ opIndex: i,
410
+ status: 'error' as const,
411
+ error: 'MISSING_SCOPES',
412
+ code: 'INVALID_SCOPE',
413
+ retriable: false,
414
+ });
415
+ throw new RejectCommitError({
416
+ ok: true,
417
+ status: 'rejected',
418
+ commitSeq,
419
+ results,
420
+ });
421
+ }
422
+ }
423
+
424
+ results.push(applied.result);
425
+ allEmitted.push(...applied.emittedChanges);
426
+ for (const c of applied.emittedChanges) {
427
+ affectedTablesSet.add(c.table);
428
+ }
429
+ }
430
+
431
+ if (allEmitted.length > 0) {
432
+ const changeRows: Array<
433
+ Insertable<SyncCoreDb['sync_changes']>
434
+ > = allEmitted.map((c) => ({
435
+ partition_id: partitionId,
436
+ commit_seq: commitSeq,
437
+ table: c.table,
438
+ row_id: c.row_id,
439
+ op: c.op,
440
+ row_json: c.row_json,
441
+ row_version: c.row_version,
442
+ scopes: dialect.scopesToDb(c.scopes),
443
+ }));
444
+
445
+ await syncTrx
446
+ .insertInto('sync_changes')
447
+ .values(changeRows)
448
+ .execute();
449
+ }
450
+
451
+ const appliedResponse: SyncPushResponse = {
452
+ ok: true,
453
+ status: 'applied',
454
+ commitSeq,
455
+ results,
456
+ };
457
+
458
+ const affectedTables = Array.from(affectedTablesSet).sort();
459
+
460
+ const appliedCommitUpdate: Updateable<
461
+ SyncCoreDb['sync_commits']
462
+ > = {
463
+ result_json: appliedResponse,
464
+ change_count: allEmitted.length,
465
+ affected_tables: affectedTables,
466
+ };
467
+
468
+ await syncTrx
469
+ .updateTable('sync_commits')
470
+ .set(appliedCommitUpdate)
471
+ .where('commit_seq', '=', commitSeq)
472
+ .execute();
473
+
474
+ // Insert table commits for subscription filtering
475
+ if (affectedTables.length > 0) {
476
+ const tableCommits: Array<
477
+ Insertable<SyncCoreDb['sync_table_commits']>
478
+ > = affectedTables.map((table) => ({
479
+ partition_id: partitionId,
480
+ table,
481
+ commit_seq: commitSeq,
482
+ }));
483
+
484
+ await syncTrx
485
+ .insertInto('sync_table_commits')
486
+ .values(tableCommits)
487
+ .onConflict((oc) =>
488
+ oc
489
+ .columns(['partition_id', 'table', 'commit_seq'])
490
+ .doNothing()
491
+ )
492
+ .execute();
493
+ }
494
+
495
+ if (useSavepoints) {
496
+ await sql
497
+ .raw(`RELEASE SAVEPOINT ${savepointName}`)
498
+ .execute(trx);
499
+ }
500
+
501
+ return {
502
+ response: appliedResponse,
503
+ affectedTables,
504
+ scopeKeys: scopeKeysFromEmitted(allEmitted),
505
+ emittedChanges: allEmitted.map((c) => ({
506
+ table: c.table,
507
+ row_id: c.row_id,
508
+ op: c.op,
509
+ row_json: c.row_json,
510
+ row_version: c.row_version,
511
+ scopes: c.scopes,
512
+ })),
513
+ };
514
+ } catch (err) {
515
+ // Roll back app writes but keep the commit row.
516
+ if (savepointCreated) {
517
+ try {
518
+ await sql
519
+ .raw(`ROLLBACK TO SAVEPOINT ${savepointName}`)
520
+ .execute(trx);
521
+ await sql
522
+ .raw(`RELEASE SAVEPOINT ${savepointName}`)
523
+ .execute(trx);
524
+ } catch (savepointErr) {
525
+ // If savepoint rollback fails, the transaction may be in an
526
+ // inconsistent state. Log and rethrow to fail the entire commit
527
+ // rather than risk data corruption.
528
+ console.error(
529
+ '[pushCommit] Savepoint rollback failed:',
530
+ savepointErr
531
+ );
532
+ throw savepointErr;
533
+ }
534
+ }
535
+
536
+ if (!(err instanceof RejectCommitError)) throw err;
537
+
538
+ const rejectedCommitUpdate: Updateable<
539
+ SyncCoreDb['sync_commits']
540
+ > = {
541
+ result_json: err.response,
542
+ change_count: 0,
543
+ affected_tables: [],
544
+ };
545
+
546
+ // Persist the rejected response for commit-level idempotency.
547
+ await syncTrx
548
+ .updateTable('sync_commits')
549
+ .set(rejectedCommitUpdate)
550
+ .where('commit_seq', '=', commitSeq)
551
+ .execute();
552
+
553
+ return {
554
+ response: err.response,
555
+ affectedTables: [],
556
+ scopeKeys: [],
557
+ emittedChanges: [],
558
+ };
559
+ }
560
+ })
561
+ );
562
+ } catch (error) {
563
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
564
+ span.setAttribute('status', 'error');
565
+ span.setAttribute('duration_ms', durationMs);
566
+ span.setStatus('error');
567
+
568
+ recordPushMetrics({
569
+ status: 'error',
570
+ durationMs,
571
+ operationCount,
572
+ emittedChangeCount: 0,
573
+ affectedTableCount: 0,
574
+ });
575
+ captureSyncException(error, {
576
+ event: 'sync.server.push',
577
+ operationCount,
578
+ });
579
+ throw error;
368
580
  }
369
-
370
- if (!(err instanceof RejectCommitError)) throw err;
371
-
372
- const rejectedCommitUpdate: Updateable<SyncCoreDb['sync_commits']> = {
373
- result_json: err.response,
374
- change_count: 0,
375
- affected_tables: [],
376
- };
377
-
378
- // Persist the rejected response for commit-level idempotency.
379
- await syncTrx
380
- .updateTable('sync_commits')
381
- .set(rejectedCommitUpdate)
382
- .where('commit_seq', '=', commitSeq)
383
- .execute();
384
-
385
- return { response: err.response, affectedTables: [] };
386
581
  }
387
- });
582
+ );
388
583
  }