@syncular/server 0.15.45 → 0.15.46

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 (51) hide show
  1. package/README.md +134 -4
  2. package/dist/admin.d.ts +10 -4
  3. package/dist/admin.js +10 -0
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/context.d.ts +9 -0
  7. package/dist/context.js +2 -0
  8. package/dist/d1-storage.d.ts +10 -1
  9. package/dist/d1-storage.js +216 -0
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/errors.js +43 -1
  12. package/dist/events.d.ts +52 -3
  13. package/dist/handler.js +4 -1
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +4 -0
  16. package/dist/operations-realtime.d.ts +16 -0
  17. package/dist/operations-realtime.js +196 -0
  18. package/dist/operations.d.ts +97 -0
  19. package/dist/operations.js +392 -0
  20. package/dist/postgres-storage.d.ts +11 -2
  21. package/dist/postgres-storage.js +220 -0
  22. package/dist/push.d.ts +8 -2
  23. package/dist/push.js +75 -21
  24. package/dist/reactions.d.ts +167 -0
  25. package/dist/reactions.js +442 -0
  26. package/dist/realtime.js +4 -1
  27. package/dist/sqlite-dialect.d.ts +1 -1
  28. package/dist/sqlite-dialect.js +20 -0
  29. package/dist/sqlite-storage.d.ts +10 -1
  30. package/dist/sqlite-storage.js +215 -0
  31. package/dist/storage.d.ts +109 -0
  32. package/dist/validate.js +1 -0
  33. package/package.json +2 -2
  34. package/src/admin.ts +27 -3
  35. package/src/authoritative-query.ts +218 -0
  36. package/src/context.ts +10 -0
  37. package/src/d1-storage.ts +352 -0
  38. package/src/errors.ts +43 -1
  39. package/src/events.ts +64 -2
  40. package/src/handler.ts +13 -1
  41. package/src/index.ts +32 -0
  42. package/src/operations-realtime.ts +272 -0
  43. package/src/operations.ts +720 -0
  44. package/src/postgres-storage.ts +351 -0
  45. package/src/push.ts +97 -29
  46. package/src/reactions.ts +741 -0
  47. package/src/realtime.ts +7 -1
  48. package/src/sqlite-dialect.ts +20 -0
  49. package/src/sqlite-storage.ts +365 -0
  50. package/src/storage.ts +165 -0
  51. package/src/validate.ts +1 -0
@@ -0,0 +1,442 @@
1
+ import { emitEvent } from './events.js';
2
+ export const MAX_REACTIONS_PER_COMMIT = 100;
3
+ export const MAX_REACTION_PAYLOAD_BYTES = 64 * 1024;
4
+ export const MAX_REACTION_FAILURE_DETAILS_BYTES = 8 * 1024;
5
+ export const DEFAULT_REACTION_MAX_ATTEMPTS = 10;
6
+ export const DEFAULT_REACTION_LEASE_MS = 30_000;
7
+ export const DEFAULT_REACTION_INITIAL_BACKOFF_MS = 1_000;
8
+ export const DEFAULT_REACTION_MAX_BACKOFF_MS = 5 * 60_000;
9
+ export const DEFAULT_REACTION_RETENTION = {
10
+ completedRetentionMs: 30 * 24 * 60 * 60 * 1000,
11
+ deadLetterRetentionMs: 90 * 24 * 60 * 60 * 1000,
12
+ batchSize: 1_000,
13
+ };
14
+ function normalizedJson(value, path, depth = 0) {
15
+ if (depth > 16)
16
+ throw new Error(`${path} exceeds the maximum JSON depth`);
17
+ if (value === null ||
18
+ typeof value === 'boolean' ||
19
+ typeof value === 'string') {
20
+ return value;
21
+ }
22
+ if (typeof value === 'number') {
23
+ if (!Number.isFinite(value))
24
+ throw new Error(`${path} must be finite`);
25
+ return value;
26
+ }
27
+ if (Array.isArray(value)) {
28
+ for (const key of Reflect.ownKeys(value)) {
29
+ if (key === 'length')
30
+ continue;
31
+ if (typeof key !== 'string' ||
32
+ !/^(?:0|[1-9][0-9]*)$/.test(key) ||
33
+ Number(key) >= value.length) {
34
+ throw new Error(`${path} arrays cannot carry extra properties`);
35
+ }
36
+ }
37
+ const output = [];
38
+ for (let index = 0; index < value.length; index += 1) {
39
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
40
+ if (descriptor === undefined || !('value' in descriptor)) {
41
+ throw new Error(`${path}[${index}] must be a plain JSON value`);
42
+ }
43
+ output.push(normalizedJson(descriptor.value, `${path}[${index}]`, depth + 1));
44
+ }
45
+ return output;
46
+ }
47
+ if (typeof value !== 'object') {
48
+ throw new Error(`${path} must contain only JSON values`);
49
+ }
50
+ const prototype = Object.getPrototypeOf(value);
51
+ if (prototype !== Object.prototype && prototype !== null) {
52
+ throw new Error(`${path} must contain only plain JSON objects`);
53
+ }
54
+ const output = {};
55
+ const entries = [];
56
+ for (const key of Reflect.ownKeys(value)) {
57
+ if (typeof key !== 'string') {
58
+ throw new Error(`${path} cannot contain symbol keys`);
59
+ }
60
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
61
+ if (descriptor === undefined ||
62
+ !descriptor.enumerable ||
63
+ !('value' in descriptor)) {
64
+ throw new Error(`${path}.${key} must be an enumerable data property`);
65
+ }
66
+ entries.push([key, descriptor.value]);
67
+ }
68
+ for (const [key, entry] of entries.sort(([left], [right]) => left.localeCompare(right))) {
69
+ Object.defineProperty(output, key, {
70
+ value: normalizedJson(entry, `${path}.${key}`, depth + 1),
71
+ enumerable: true,
72
+ configurable: true,
73
+ writable: true,
74
+ });
75
+ }
76
+ return output;
77
+ }
78
+ function boundedJson(value, path, maxBytes) {
79
+ const normalized = normalizedJson(value, path);
80
+ if (new TextEncoder().encode(JSON.stringify(normalized)).byteLength > maxBytes) {
81
+ throw new Error(`${path} exceeds ${maxBytes} persisted bytes`);
82
+ }
83
+ return normalized;
84
+ }
85
+ function assertName(value, field, maxBytes) {
86
+ if (!/^[A-Za-z][A-Za-z0-9._:-]*$/.test(value) ||
87
+ new TextEncoder().encode(value).byteLength > maxBytes) {
88
+ throw new Error(`${field} must be a code-like string no longer than ${maxBytes} bytes`);
89
+ }
90
+ }
91
+ /** Stable handler idempotency key for one planned item in a client commit. */
92
+ export function reactionIdempotencyKey(partition, clientId, clientCommitId, plannerKey) {
93
+ return JSON.stringify([partition, clientId, clientCommitId, plannerKey]);
94
+ }
95
+ /** Internal push seam, exported for focused planner tests and custom hosts. */
96
+ export async function prepareReactions(planner, input) {
97
+ const planned = await planner(input);
98
+ if (!Array.isArray(planned)) {
99
+ throw new Error('reaction planner must return an array');
100
+ }
101
+ if (planned.length > MAX_REACTIONS_PER_COMMIT) {
102
+ throw new Error(`reaction planner returned more than ${MAX_REACTIONS_PER_COMMIT} records`);
103
+ }
104
+ const keys = new Set();
105
+ return planned.map((reaction, index) => {
106
+ assertName(reaction.type, `reaction[${index}].type`, 128);
107
+ if (reaction.key.length === 0 ||
108
+ new TextEncoder().encode(reaction.key).byteLength > 256) {
109
+ throw new Error(`reaction[${index}].key must be non-empty and no longer than 256 bytes`);
110
+ }
111
+ if (keys.has(reaction.key)) {
112
+ throw new Error(`reaction planner returned duplicate key at index ${index}`);
113
+ }
114
+ keys.add(reaction.key);
115
+ if (!Number.isSafeInteger(reaction.version) ||
116
+ reaction.version < 1 ||
117
+ reaction.version > 2_147_483_647) {
118
+ throw new Error(`reaction[${index}].version must be a positive int32`);
119
+ }
120
+ const maxAttempts = reaction.maxAttempts ?? DEFAULT_REACTION_MAX_ATTEMPTS;
121
+ if (!Number.isSafeInteger(maxAttempts) ||
122
+ maxAttempts < 1 ||
123
+ maxAttempts > 100) {
124
+ throw new Error(`reaction[${index}].maxAttempts must be from 1 through 100`);
125
+ }
126
+ return {
127
+ idempotencyKey: reactionIdempotencyKey(input.partition, input.clientId, input.clientCommitId, reaction.key),
128
+ type: reaction.type,
129
+ version: reaction.version,
130
+ payload: boundedJson(reaction.payload, `reaction[${index}].payload`, MAX_REACTION_PAYLOAD_BYTES),
131
+ maxAttempts,
132
+ };
133
+ });
134
+ }
135
+ class ReactionDeliveryError extends Error {
136
+ code;
137
+ details;
138
+ constructor(name, code, details) {
139
+ super(code);
140
+ this.name = name;
141
+ assertName(code, `${name}.code`, 128);
142
+ this.code = code;
143
+ if (details !== undefined) {
144
+ this.details = boundedJson(details, `${name}.details`, MAX_REACTION_FAILURE_DETAILS_BYTES);
145
+ }
146
+ }
147
+ }
148
+ /** A handler failure that should be retried until its attempt limit. */
149
+ export class RetryableReactionError extends ReactionDeliveryError {
150
+ constructor(code, details) {
151
+ super('RetryableReactionError', code, details);
152
+ }
153
+ }
154
+ /** A handler failure that should be dead-lettered immediately. */
155
+ export class PermanentReactionError extends ReactionDeliveryError {
156
+ constructor(code, details) {
157
+ super('PermanentReactionError', code, details);
158
+ }
159
+ }
160
+ function requiredLifecycle(storage) {
161
+ if (storage.claimReactions === undefined ||
162
+ storage.completeReaction === undefined ||
163
+ storage.extendReactionLease === undefined ||
164
+ storage.failReaction === undefined) {
165
+ throw new Error('storage does not implement durable reaction delivery');
166
+ }
167
+ }
168
+ /** Host-driven worker. Call `runOnce` from the host scheduler or queue wake. */
169
+ export class ReactionRunner {
170
+ #options;
171
+ #types;
172
+ #clock;
173
+ #leaseDurationMs;
174
+ #batchSize;
175
+ #initialBackoffMs;
176
+ #maxBackoffMs;
177
+ constructor(options) {
178
+ requiredLifecycle(options.storage);
179
+ assertName(options.workerId, 'workerId', 128);
180
+ this.#types = Object.keys(options.handlers).sort();
181
+ if (this.#types.length === 0 || this.#types.length > 64) {
182
+ throw new Error('reaction runner requires from 1 through 64 handlers');
183
+ }
184
+ for (const type of this.#types)
185
+ assertName(type, 'handler type', 128);
186
+ this.#leaseDurationMs =
187
+ options.leaseDurationMs ?? DEFAULT_REACTION_LEASE_MS;
188
+ this.#batchSize = options.batchSize ?? 10;
189
+ this.#initialBackoffMs =
190
+ options.initialBackoffMs ?? DEFAULT_REACTION_INITIAL_BACKOFF_MS;
191
+ this.#maxBackoffMs =
192
+ options.maxBackoffMs ?? DEFAULT_REACTION_MAX_BACKOFF_MS;
193
+ for (const [name, value] of [
194
+ ['leaseDurationMs', this.#leaseDurationMs],
195
+ ['batchSize', this.#batchSize],
196
+ ['initialBackoffMs', this.#initialBackoffMs],
197
+ ['maxBackoffMs', this.#maxBackoffMs],
198
+ ]) {
199
+ if (!Number.isSafeInteger(value) || value < 1) {
200
+ throw new Error(`${name} must be a positive safe integer`);
201
+ }
202
+ }
203
+ if (this.#batchSize > 100)
204
+ throw new Error('batchSize cannot exceed 100');
205
+ if (this.#initialBackoffMs > this.#maxBackoffMs) {
206
+ throw new Error('initialBackoffMs cannot exceed maxBackoffMs');
207
+ }
208
+ this.#options = options;
209
+ this.#clock = options.clock ?? Date.now;
210
+ }
211
+ async runOnce() {
212
+ const claim = this.#options.storage.claimReactions;
213
+ const complete = this.#options.storage.completeReaction;
214
+ const extend = this.#options.storage.extendReactionLease;
215
+ const fail = this.#options.storage.failReaction;
216
+ if (claim === undefined ||
217
+ complete === undefined ||
218
+ extend === undefined ||
219
+ fail === undefined) {
220
+ throw new Error('storage lost durable reaction delivery support');
221
+ }
222
+ const leaseOwner = `${this.#options.workerId}:${crypto.randomUUID()}`;
223
+ const reactions = await claim.call(this.#options.storage, this.#options.partition, {
224
+ leaseOwner,
225
+ types: this.#types,
226
+ nowMs: this.#clock(),
227
+ leaseDurationMs: this.#leaseDurationMs,
228
+ limit: this.#batchSize,
229
+ });
230
+ let completed = 0;
231
+ let retried = 0;
232
+ let deadLettered = 0;
233
+ let lostLeases = 0;
234
+ for (const reaction of reactions) {
235
+ const events = this.#options.events;
236
+ const startedAtMs = this.#clock();
237
+ const stillOwned = await extend.call(this.#options.storage, this.#options.partition, reaction.idempotencyKey, leaseOwner, Math.min(Number.MAX_SAFE_INTEGER, startedAtMs + this.#leaseDurationMs));
238
+ if (!stillOwned) {
239
+ lostLeases += 1;
240
+ continue;
241
+ }
242
+ if (events !== undefined) {
243
+ emitEvent(events, {
244
+ type: 'reaction.started',
245
+ atMs: startedAtMs,
246
+ partition: this.#options.partition,
247
+ workerId: this.#options.workerId,
248
+ idempotencyKey: reaction.idempotencyKey,
249
+ reactionType: reaction.type,
250
+ version: reaction.version,
251
+ attempt: reaction.attempts,
252
+ });
253
+ }
254
+ let handlerFailed = false;
255
+ let handlerError;
256
+ try {
257
+ const handler = this.#options.handlers[reaction.type];
258
+ if (handler === undefined) {
259
+ throw new PermanentReactionError('reaction.handler_missing');
260
+ }
261
+ await handler({
262
+ partition: this.#options.partition,
263
+ idempotencyKey: reaction.idempotencyKey,
264
+ type: reaction.type,
265
+ version: reaction.version,
266
+ payload: reaction.payload,
267
+ attempt: reaction.attempts,
268
+ maxAttempts: reaction.maxAttempts,
269
+ sourceClientId: reaction.sourceClientId,
270
+ sourceClientCommitId: reaction.sourceClientCommitId,
271
+ sourceCommitSeq: reaction.sourceCommitSeq,
272
+ extendLease: async () => {
273
+ const renewed = await extend.call(this.#options.storage, this.#options.partition, reaction.idempotencyKey, leaseOwner, Math.min(Number.MAX_SAFE_INTEGER, this.#clock() + this.#leaseDurationMs));
274
+ if (!renewed)
275
+ throw new Error('reaction lease ownership lost');
276
+ },
277
+ });
278
+ }
279
+ catch (error) {
280
+ handlerFailed = true;
281
+ handlerError = error;
282
+ }
283
+ if (!handlerFailed) {
284
+ const atMs = this.#clock();
285
+ const acknowledged = await complete.call(this.#options.storage, this.#options.partition, reaction.idempotencyKey, leaseOwner, atMs);
286
+ if (!acknowledged) {
287
+ lostLeases += 1;
288
+ continue;
289
+ }
290
+ completed += 1;
291
+ if (events !== undefined) {
292
+ emitEvent(events, {
293
+ type: 'reaction.completed',
294
+ atMs,
295
+ partition: this.#options.partition,
296
+ workerId: this.#options.workerId,
297
+ idempotencyKey: reaction.idempotencyKey,
298
+ reactionType: reaction.type,
299
+ version: reaction.version,
300
+ attempt: reaction.attempts,
301
+ });
302
+ }
303
+ continue;
304
+ }
305
+ const atMs = this.#clock();
306
+ const permanent = handlerError instanceof PermanentReactionError;
307
+ const failure = {
308
+ code: handlerError instanceof ReactionDeliveryError
309
+ ? handlerError.code
310
+ : 'reaction.handler_failed',
311
+ atMs,
312
+ ...(handlerError instanceof ReactionDeliveryError &&
313
+ handlerError.details !== undefined
314
+ ? { details: handlerError.details }
315
+ : {}),
316
+ };
317
+ const deadLetter = permanent || reaction.attempts >= reaction.maxAttempts;
318
+ const retryAtMs = deadLetter
319
+ ? undefined
320
+ : Math.min(Number.MAX_SAFE_INTEGER, atMs +
321
+ Math.min(this.#maxBackoffMs, this.#initialBackoffMs *
322
+ 2 ** Math.min(30, reaction.attempts - 1)));
323
+ const recorded = await fail.call(this.#options.storage, this.#options.partition, reaction.idempotencyKey, {
324
+ leaseOwner,
325
+ failure,
326
+ ...(retryAtMs !== undefined ? { retryAtMs } : {}),
327
+ });
328
+ if (!recorded) {
329
+ lostLeases += 1;
330
+ continue;
331
+ }
332
+ if (retryAtMs !== undefined) {
333
+ retried += 1;
334
+ if (events !== undefined) {
335
+ emitEvent(events, {
336
+ type: 'reaction.retried',
337
+ atMs,
338
+ partition: this.#options.partition,
339
+ workerId: this.#options.workerId,
340
+ idempotencyKey: reaction.idempotencyKey,
341
+ reactionType: reaction.type,
342
+ version: reaction.version,
343
+ attempt: reaction.attempts,
344
+ nextAttemptAtMs: retryAtMs,
345
+ errorCode: failure.code,
346
+ });
347
+ }
348
+ }
349
+ else {
350
+ deadLettered += 1;
351
+ if (events !== undefined) {
352
+ emitEvent(events, {
353
+ type: 'reaction.dead_lettered',
354
+ atMs,
355
+ partition: this.#options.partition,
356
+ workerId: this.#options.workerId,
357
+ idempotencyKey: reaction.idempotencyKey,
358
+ reactionType: reaction.type,
359
+ version: reaction.version,
360
+ attempt: reaction.attempts,
361
+ errorCode: failure.code,
362
+ });
363
+ }
364
+ }
365
+ }
366
+ return {
367
+ claimed: reactions.length,
368
+ completed,
369
+ retried,
370
+ deadLettered,
371
+ lostLeases,
372
+ };
373
+ }
374
+ }
375
+ /** Explicit operator action for a dead-lettered reaction. */
376
+ export async function retryDeadLetterReaction(options) {
377
+ if (options.storage.retryReaction === undefined) {
378
+ throw new Error('storage does not implement durable reaction retry');
379
+ }
380
+ return options.storage.retryReaction(options.partition, options.idempotencyKey, options.nowMs ?? Date.now());
381
+ }
382
+ /** Delete one bounded batch of aged completed and dead-lettered rows. */
383
+ export async function pruneReactions(options) {
384
+ const retention = {
385
+ ...DEFAULT_REACTION_RETENTION,
386
+ ...options.retention,
387
+ };
388
+ if (!Number.isSafeInteger(options.nowMs)) {
389
+ throw new Error('nowMs must be a safe integer');
390
+ }
391
+ for (const [name, value] of [
392
+ ['completedRetentionMs', retention.completedRetentionMs],
393
+ ['deadLetterRetentionMs', retention.deadLetterRetentionMs],
394
+ ]) {
395
+ if (!Number.isSafeInteger(value) || value < 0) {
396
+ throw new Error(`${name} must be a non-negative safe integer`);
397
+ }
398
+ }
399
+ if (!Number.isSafeInteger(retention.batchSize) ||
400
+ retention.batchSize < 1 ||
401
+ retention.batchSize > 10_000) {
402
+ throw new Error('batchSize must be from 1 through 10000');
403
+ }
404
+ const prune = options.storage.pruneReactions;
405
+ if (prune === undefined) {
406
+ throw new Error('storage does not implement durable reaction pruning');
407
+ }
408
+ const completedBeforeMs = Math.max(Number.MIN_SAFE_INTEGER, options.nowMs - retention.completedRetentionMs);
409
+ const deadLetterBeforeMs = Math.max(Number.MIN_SAFE_INTEGER, options.nowMs - retention.deadLetterRetentionMs);
410
+ const removed = await prune.call(options.storage, options.partition, {
411
+ completedBeforeMs,
412
+ deadLetterBeforeMs,
413
+ limit: retention.batchSize,
414
+ });
415
+ const result = {
416
+ completedBeforeMs,
417
+ deadLetterBeforeMs,
418
+ removedCompleted: removed.completed,
419
+ removedDeadLetter: removed.deadLetter,
420
+ mayHaveMore: removed.completed + removed.deadLetter === retention.batchSize,
421
+ };
422
+ if (options.events !== undefined) {
423
+ emitEvent(options.events, {
424
+ type: 'reaction.prune_completed',
425
+ atMs: options.nowMs,
426
+ partition: options.partition,
427
+ limit: retention.batchSize,
428
+ ...result,
429
+ });
430
+ }
431
+ return result;
432
+ }
433
+ /** Helper used by the push path after commit sequence allocation. */
434
+ export function toNewReactions(prepared, source) {
435
+ return prepared.map((reaction) => ({
436
+ ...reaction,
437
+ sourceClientId: source.clientId,
438
+ sourceClientCommitId: source.clientCommitId,
439
+ sourceCommitSeq: source.commitSeq,
440
+ createdAtMs: source.createdAtMs,
441
+ }));
442
+ }
package/dist/realtime.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * the `sync` wake-up (§8.3).
15
15
  */
16
16
  import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
17
- import { RESOLVER_OUTAGE } from './context.js';
17
+ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context.js';
18
18
  import { SyncError, syncError } from './errors.js';
19
19
  import { emitEvent } from './events.js';
20
20
  import { createSyncResponseStream } from './handler.js';
@@ -770,6 +770,9 @@ export class RealtimeHub {
770
770
  async connect(options) {
771
771
  const { storage } = this.#config;
772
772
  const clock = this.#config.clock ?? Date.now;
773
+ if (options.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
774
+ throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
775
+ }
773
776
  const record = await storage.getClientRecord(options.partition, options.clientId);
774
777
  if (record !== undefined && record.actorId !== options.actorId) {
775
778
  throw syncError('sync.invalid_client_id', 'clientId is bound to a different actor in this partition (§1.5)');
@@ -30,7 +30,7 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
30
30
  * the Postgres storage documents (§3.1, performance-by-
31
31
  * construction).
32
32
  */
33
- export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
33
+ export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
34
34
  /** Split the DDL into individual statements (D1 applies them one by one). */
35
35
  export declare function sqliteDdlStatements(): string[];
36
36
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
@@ -49,6 +49,26 @@ CREATE TABLE IF NOT EXISTS sync_push_results(
49
49
  client_commit_id TEXT NOT NULL, result TEXT NOT NULL,
50
50
  PRIMARY KEY(partition, client_id, client_commit_id)
51
51
  );
52
+ CREATE TABLE IF NOT EXISTS sync_reactions(
53
+ partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,
54
+ type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,
55
+ source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,
56
+ source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
57
+ available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,
58
+ attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,
59
+ lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,
60
+ last_failure TEXT,
61
+ PRIMARY KEY(partition, idempotency_key),
62
+ CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))
63
+ );
64
+ CREATE INDEX IF NOT EXISTS sync_reactions_due
65
+ ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);
66
+ CREATE INDEX IF NOT EXISTS sync_reactions_lease
67
+ ON sync_reactions(partition, status, lease_expires_at_ms);
68
+ CREATE INDEX IF NOT EXISTS sync_reactions_completed
69
+ ON sync_reactions(partition, status, completed_at_ms, idempotency_key);
70
+ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
71
+ ON sync_reactions(partition, status, available_at_ms, idempotency_key);
52
72
  CREATE TABLE IF NOT EXISTS sync_clients(
53
73
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
54
74
  cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { Database } from 'bun:sqlite';
10
10
  import type { CompiledSchema, CompiledTable } from './schema.js';
11
- import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
11
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
12
12
  export declare class SqliteServerStorage implements ServerStorage {
13
13
  #private;
14
14
  readonly db: Database;
@@ -20,12 +20,21 @@ export declare class SqliteServerStorage implements ServerStorage {
20
20
  /** Internal: write a row + refresh its scope-index entries. */
21
21
  writeRow(partition: string, table: string, row: StoredRow): void;
22
22
  getMaxCommitSeq(partition: string): Promise<number>;
23
+ queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
23
24
  getHorizonSeq(partition: string): Promise<number>;
24
25
  setHorizonSeq(partition: string, seq: number): Promise<void>;
25
26
  pruneCommitsThrough(partition: string, seq: number): Promise<number>;
26
27
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
27
28
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
28
29
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
30
+ claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
31
+ completeReaction(partition: string, idempotencyKey: string, leaseOwner: string, completedAtMs: number): Promise<boolean>;
32
+ extendReactionLease(partition: string, idempotencyKey: string, leaseOwner: string, leaseExpiresAtMs: number): Promise<boolean>;
33
+ failReaction(partition: string, idempotencyKey: string, update: ReactionFailureUpdate): Promise<boolean>;
34
+ retryReaction(partition: string, idempotencyKey: string, nowMs: number): Promise<boolean>;
35
+ getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
36
+ listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
37
+ pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
29
38
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
30
39
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
31
40
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;