@syncular/server 0.15.45 → 0.15.47

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 (88) hide show
  1. package/README.md +144 -7
  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 +11 -1
  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-bun.d.ts +2 -0
  15. package/dist/index-bun.js +2 -0
  16. package/dist/index-node.d.ts +2 -0
  17. package/dist/index-node.js +2 -0
  18. package/dist/index.d.ts +7 -2
  19. package/dist/index.js +7 -6
  20. package/dist/operations-realtime.d.ts +16 -0
  21. package/dist/operations-realtime.js +196 -0
  22. package/dist/operations.d.ts +97 -0
  23. package/dist/operations.js +392 -0
  24. package/dist/postgres-storage.d.ts +11 -2
  25. package/dist/postgres-storage.js +220 -0
  26. package/dist/pull.js +1 -1
  27. package/dist/push.d.ts +8 -2
  28. package/dist/push.js +75 -21
  29. package/dist/reactions.d.ts +167 -0
  30. package/dist/reactions.js +442 -0
  31. package/dist/realtime.js +4 -1
  32. package/dist/sqlite-blob-store.d.ts +4 -9
  33. package/dist/sqlite-blob-store.js +5 -10
  34. package/dist/sqlite-bun-driver.d.ts +11 -0
  35. package/dist/sqlite-bun-driver.js +27 -0
  36. package/dist/sqlite-bun.d.ts +24 -0
  37. package/dist/sqlite-bun.js +40 -0
  38. package/dist/sqlite-dialect.d.ts +8 -8
  39. package/dist/sqlite-dialect.js +22 -2
  40. package/dist/sqlite-driver.d.ts +26 -0
  41. package/dist/sqlite-driver.js +8 -0
  42. package/dist/sqlite-image.d.ts +7 -9
  43. package/dist/sqlite-image.js +26 -28
  44. package/dist/sqlite-lease-store.d.ts +4 -9
  45. package/dist/sqlite-lease-store.js +5 -10
  46. package/dist/sqlite-node-driver.d.ts +10 -0
  47. package/dist/sqlite-node-driver.js +30 -0
  48. package/dist/sqlite-node.d.ts +24 -0
  49. package/dist/sqlite-node.js +50 -0
  50. package/dist/sqlite-segment-store.d.ts +4 -10
  51. package/dist/sqlite-segment-store.js +6 -9
  52. package/dist/sqlite-storage.d.ts +13 -12
  53. package/dist/sqlite-storage.js +223 -5
  54. package/dist/storage-errors.js +4 -1
  55. package/dist/storage.d.ts +109 -0
  56. package/dist/validate.js +1 -0
  57. package/package.json +18 -3
  58. package/src/admin.ts +27 -3
  59. package/src/authoritative-query.ts +218 -0
  60. package/src/context.ts +12 -1
  61. package/src/d1-storage.ts +352 -0
  62. package/src/errors.ts +43 -1
  63. package/src/events.ts +64 -2
  64. package/src/handler.ts +13 -1
  65. package/src/index-bun.ts +9 -0
  66. package/src/index-node.ts +9 -0
  67. package/src/index.ts +40 -6
  68. package/src/operations-realtime.ts +272 -0
  69. package/src/operations.ts +720 -0
  70. package/src/postgres-storage.ts +351 -0
  71. package/src/pull.ts +1 -1
  72. package/src/push.ts +97 -29
  73. package/src/reactions.ts +741 -0
  74. package/src/realtime.ts +7 -1
  75. package/src/sqlite-blob-store.ts +11 -10
  76. package/src/sqlite-bun-driver.ts +42 -0
  77. package/src/sqlite-bun.ts +53 -0
  78. package/src/sqlite-dialect.ts +27 -7
  79. package/src/sqlite-driver.ts +44 -0
  80. package/src/sqlite-image.ts +44 -49
  81. package/src/sqlite-lease-store.ts +11 -10
  82. package/src/sqlite-node-driver.ts +46 -0
  83. package/src/sqlite-node.ts +62 -0
  84. package/src/sqlite-segment-store.ts +11 -11
  85. package/src/sqlite-storage.ts +378 -7
  86. package/src/storage-errors.ts +4 -1
  87. package/src/storage.ts +165 -0
  88. package/src/validate.ts +1 -0
@@ -1,18 +1,45 @@
1
1
  /**
2
- * SQLite storage via `bun:sqlite` (dev-speed, dependency-free).
2
+ * SQLite server storage over the shared synchronous driver.
3
3
  *
4
4
  * Scope fanout is index-first: both the commit log and the
5
5
  * current-row table carry a (table, variable, value) inverted index; reads
6
6
  * select candidates from the index and verify the full multi-variable
7
7
  * match against the stored scope map — never a log scan.
8
8
  */
9
- import { Database } from 'bun:sqlite';
9
+ import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
10
10
  import { syncError } from './errors.js';
11
11
  import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
12
12
  import { matchesEffective } from './scopes.js';
13
13
  import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
14
+ import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
14
15
  import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
15
16
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
17
+ function toStoredReaction(record) {
18
+ return {
19
+ idempotencyKey: record.idempotency_key,
20
+ type: record.type,
21
+ version: record.version,
22
+ payload: JSON.parse(record.payload),
23
+ sourceClientId: record.source_client_id,
24
+ sourceClientCommitId: record.source_client_commit_id,
25
+ sourceCommitSeq: record.source_commit_seq,
26
+ createdAtMs: record.created_at_ms,
27
+ maxAttempts: record.max_attempts,
28
+ status: record.status,
29
+ attempts: record.attempts,
30
+ availableAtMs: record.available_at_ms,
31
+ ...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
32
+ ...(record.lease_expires_at_ms !== null
33
+ ? { leaseExpiresAtMs: record.lease_expires_at_ms }
34
+ : {}),
35
+ ...(record.completed_at_ms !== null
36
+ ? { completedAtMs: record.completed_at_ms }
37
+ : {}),
38
+ ...(record.last_failure !== null
39
+ ? { lastFailure: JSON.parse(record.last_failure) }
40
+ : {}),
41
+ };
42
+ }
16
43
  class SqliteTransaction {
17
44
  #storage;
18
45
  #partition;
@@ -35,7 +62,7 @@ class SqliteTransaction {
35
62
  }
36
63
  getPushResult(clientId, clientCommitId) {
37
64
  this.#assertOpen();
38
- // One shared bun:sqlite connection this read runs inside this
65
+ // One shared SQLite connection: this read runs inside this
39
66
  // transaction's BEGIN IMMEDIATE.
40
67
  return this.#storage.getPushResult(this.#partition, clientId, clientCommitId);
41
68
  }
@@ -118,6 +145,17 @@ class SqliteTransaction {
118
145
  .query('INSERT OR IGNORE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)')
119
146
  .run(this.#partition, clientId, clientCommitId, serializePushResult(result));
120
147
  }
148
+ async enqueueReactions(reactions) {
149
+ this.#assertOpen();
150
+ const statement = this.#storage.db.query(`INSERT INTO sync_reactions(
151
+ partition, idempotency_key, type, version, payload,
152
+ source_client_id, source_client_commit_id, source_commit_seq,
153
+ created_at_ms, available_at_ms, status, attempts, max_attempts
154
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',0,?)`);
155
+ for (const reaction of reactions) {
156
+ statement.run(this.#partition, reaction.idempotencyKey, reaction.type, reaction.version, JSON.stringify(reaction.payload), reaction.sourceClientId, reaction.sourceClientCommitId, reaction.sourceCommitSeq, reaction.createdAtMs, reaction.createdAtMs, reaction.maxAttempts);
157
+ }
158
+ }
121
159
  async commit() {
122
160
  this.#assertOpen();
123
161
  try {
@@ -156,13 +194,30 @@ class SqliteTransaction {
156
194
  }
157
195
  export class SqliteServerStorage {
158
196
  db;
159
- /** One bun:sqlite connection can own only one transaction at a time. */
197
+ /** One SQLite connection can own only one transaction at a time. */
160
198
  #transactionTail = Promise.resolve();
161
199
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
162
200
  #tables;
163
201
  #schemaVersion;
202
+ async #serializeReactionWrite(operation) {
203
+ const previous = this.#transactionTail;
204
+ let release;
205
+ this.#transactionTail = new Promise((resolve) => {
206
+ release = resolve;
207
+ });
208
+ await previous;
209
+ try {
210
+ return operation();
211
+ }
212
+ finally {
213
+ release();
214
+ }
215
+ }
164
216
  constructor(db = ':memory:') {
165
- this.db = typeof db === 'string' ? new Database(db) : db;
217
+ if (typeof db === 'string') {
218
+ throw new SqliteAdapterRequiredError();
219
+ }
220
+ this.db = db;
166
221
  this.db.exec(SQLITE_DDL);
167
222
  }
168
223
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
@@ -310,6 +365,40 @@ export class SqliteServerStorage {
310
365
  .get(partition);
311
366
  return row?.max_commit_seq ?? 0;
312
367
  }
368
+ async queryAuthoritative(partition, query) {
369
+ if (this.#tables === undefined) {
370
+ throw new Error('ensureSchema(schema) must run before registered queries');
371
+ }
372
+ const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
373
+ const previous = this.#transactionTail;
374
+ let release;
375
+ this.#transactionTail = new Promise((resolve) => {
376
+ release = resolve;
377
+ });
378
+ await previous;
379
+ let open = false;
380
+ try {
381
+ this.db.exec('BEGIN');
382
+ open = true;
383
+ const rows = this.db
384
+ .query(prepared.sql)
385
+ .all(...prepared.params);
386
+ const cursor = this.db
387
+ .query('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
388
+ .get(partition);
389
+ this.db.exec('COMMIT');
390
+ open = false;
391
+ return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
392
+ }
393
+ catch (error) {
394
+ if (open)
395
+ this.db.exec('ROLLBACK');
396
+ throw error;
397
+ }
398
+ finally {
399
+ release();
400
+ }
401
+ }
313
402
  async getHorizonSeq(partition) {
314
403
  const row = this.db
315
404
  .query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -361,6 +450,135 @@ export class SqliteServerStorage {
361
450
  throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
362
451
  }
363
452
  }
453
+ async claimReactions(partition, query) {
454
+ if (query.types.length === 0 || query.limit <= 0)
455
+ return [];
456
+ return this.#serializeReactionWrite(() => {
457
+ const typeParams = query.types.map(() => '?').join(',');
458
+ const records = this.db
459
+ .query(`UPDATE sync_reactions
460
+ SET status='leased', attempts=attempts+1,
461
+ lease_owner=?, lease_expires_at_ms=?, completed_at_ms=NULL
462
+ WHERE (partition, idempotency_key) IN (
463
+ SELECT partition, idempotency_key
464
+ FROM sync_reactions
465
+ WHERE partition=? AND type IN (${typeParams})
466
+ AND ((status='pending' AND available_at_ms<=?)
467
+ OR (status='leased' AND lease_expires_at_ms<=?))
468
+ ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
469
+ ELSE available_at_ms END,
470
+ created_at_ms, idempotency_key
471
+ LIMIT ?
472
+ )
473
+ RETURNING *`)
474
+ .all(query.leaseOwner, Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs), partition, ...query.types, query.nowMs, query.nowMs, query.limit);
475
+ return records
476
+ .map(toStoredReaction)
477
+ .sort((a, b) => a.createdAtMs - b.createdAtMs ||
478
+ a.idempotencyKey.localeCompare(b.idempotencyKey));
479
+ });
480
+ }
481
+ async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
482
+ return this.#serializeReactionWrite(() => {
483
+ const result = this.db
484
+ .query(`UPDATE sync_reactions
485
+ SET status='completed', completed_at_ms=?,
486
+ lease_owner=NULL, lease_expires_at_ms=NULL
487
+ WHERE partition=? AND idempotency_key=?
488
+ AND status='leased' AND lease_owner=?`)
489
+ .run(completedAtMs, partition, idempotencyKey, leaseOwner);
490
+ return Number(result.changes) === 1;
491
+ });
492
+ }
493
+ async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
494
+ return this.#serializeReactionWrite(() => {
495
+ const result = this.db
496
+ .query(`UPDATE sync_reactions SET lease_expires_at_ms=?
497
+ WHERE partition=? AND idempotency_key=?
498
+ AND status='leased' AND lease_owner=?`)
499
+ .run(leaseExpiresAtMs, partition, idempotencyKey, leaseOwner);
500
+ return Number(result.changes) === 1;
501
+ });
502
+ }
503
+ async failReaction(partition, idempotencyKey, update) {
504
+ const retry = update.retryAtMs !== undefined;
505
+ return this.#serializeReactionWrite(() => {
506
+ const result = this.db
507
+ .query(`UPDATE sync_reactions
508
+ SET status=?, available_at_ms=?, last_failure=?,
509
+ lease_owner=NULL, lease_expires_at_ms=NULL
510
+ WHERE partition=? AND idempotency_key=?
511
+ AND status='leased' AND lease_owner=?`)
512
+ .run(retry ? 'pending' : 'dead-letter', update.retryAtMs ?? update.failure.atMs, JSON.stringify(update.failure), partition, idempotencyKey, update.leaseOwner);
513
+ return Number(result.changes) === 1;
514
+ });
515
+ }
516
+ async retryReaction(partition, idempotencyKey, nowMs) {
517
+ return this.#serializeReactionWrite(() => {
518
+ const result = this.db
519
+ .query(`UPDATE sync_reactions
520
+ SET status='pending', attempts=0, available_at_ms=?,
521
+ last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
522
+ completed_at_ms=NULL
523
+ WHERE partition=? AND idempotency_key=? AND status='dead-letter'`)
524
+ .run(nowMs, partition, idempotencyKey);
525
+ return Number(result.changes) === 1;
526
+ });
527
+ }
528
+ async getReaction(partition, idempotencyKey) {
529
+ const record = this.db
530
+ .query('SELECT * FROM sync_reactions WHERE partition=? AND idempotency_key=?')
531
+ .get(partition, idempotencyKey);
532
+ return record === null ? undefined : toStoredReaction(record);
533
+ }
534
+ async listReactions(partition, query) {
535
+ const where = ['partition=?'];
536
+ const params = [partition];
537
+ if (query.statuses !== undefined && query.statuses.length > 0) {
538
+ where.push(`status IN (${query.statuses.map(() => '?').join(',')})`);
539
+ params.push(...query.statuses);
540
+ }
541
+ if (query.types !== undefined && query.types.length > 0) {
542
+ where.push(`type IN (${query.types.map(() => '?').join(',')})`);
543
+ params.push(...query.types);
544
+ }
545
+ params.push(query.limit);
546
+ const records = this.db
547
+ .query(`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
548
+ ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT ?`)
549
+ .all(...params);
550
+ return records.map(toStoredReaction);
551
+ }
552
+ async pruneReactions(partition, query) {
553
+ if (query.limit <= 0)
554
+ return { completed: 0, deadLetter: 0 };
555
+ return this.#serializeReactionWrite(() => {
556
+ const records = this.db
557
+ .query(`DELETE FROM sync_reactions
558
+ WHERE partition=? AND idempotency_key IN (
559
+ SELECT idempotency_key FROM sync_reactions
560
+ WHERE partition=?
561
+ AND ((status='completed' AND completed_at_ms IS NOT NULL
562
+ AND completed_at_ms<?)
563
+ OR (status='dead-letter' AND available_at_ms<?))
564
+ ORDER BY CASE WHEN status='completed' THEN completed_at_ms
565
+ ELSE available_at_ms END,
566
+ idempotency_key
567
+ LIMIT ?
568
+ )
569
+ AND ((status='completed' AND completed_at_ms IS NOT NULL
570
+ AND completed_at_ms<?)
571
+ OR (status='dead-letter' AND available_at_ms<?))
572
+ RETURNING status`)
573
+ .all(partition, partition, query.completedBeforeMs, query.deadLetterBeforeMs, query.limit, query.completedBeforeMs, query.deadLetterBeforeMs);
574
+ return {
575
+ completed: records.filter((record) => record.status === 'completed')
576
+ .length,
577
+ deadLetter: records.filter((record) => record.status === 'dead-letter')
578
+ .length,
579
+ };
580
+ });
581
+ }
364
582
  async readCommitWindow(partition, query) {
365
583
  const variables = Object.keys(query.scopeFilter).sort();
366
584
  const firstVariable = variables[0];
@@ -44,7 +44,10 @@ export function isSqliteConstraintError(error) {
44
44
  return true;
45
45
  }
46
46
  const errno = candidate?.errno;
47
- return typeof errno === 'number' && (errno & 0xff) === 19;
47
+ if (typeof errno === 'number' && (errno & 0xff) === 19)
48
+ return true;
49
+ const errcode = candidate?.errcode;
50
+ return typeof errcode === 'number' && (errcode & 0xff) === 19;
48
51
  }
49
52
  /** PostgreSQL SQLSTATE class 23: integrity constraint violation. */
50
53
  export function isPostgresConstraintError(error) {
package/dist/storage.d.ts CHANGED
@@ -59,6 +59,71 @@ export interface StoredCommit {
59
59
  readonly actorId: string;
60
60
  readonly changes: readonly StoredChange[];
61
61
  }
62
+ /** JSON values accepted by the durable reaction payload/failure store. */
63
+ export type DurableJsonValue = null | boolean | number | string | readonly DurableJsonValue[] | {
64
+ readonly [key: string]: DurableJsonValue;
65
+ };
66
+ export type ReactionStatus = 'pending' | 'leased' | 'completed' | 'dead-letter';
67
+ export interface ReactionFailure {
68
+ /** Stable application or Syncular error identity. */
69
+ readonly code: string;
70
+ readonly atMs: number;
71
+ /** Bounded JSON metadata. Diagnostic exception text is never persisted. */
72
+ readonly details?: {
73
+ readonly [key: string]: DurableJsonValue;
74
+ };
75
+ }
76
+ /** A validated reaction row written with its source commit. */
77
+ export interface NewReaction {
78
+ readonly idempotencyKey: string;
79
+ readonly type: string;
80
+ readonly version: number;
81
+ readonly payload: DurableJsonValue;
82
+ readonly sourceClientId: string;
83
+ readonly sourceClientCommitId: string;
84
+ readonly sourceCommitSeq: number;
85
+ readonly createdAtMs: number;
86
+ readonly maxAttempts: number;
87
+ }
88
+ /** Durable reaction state returned to workers and administrative readers. */
89
+ export interface StoredReaction extends NewReaction {
90
+ readonly status: ReactionStatus;
91
+ /** Incremented atomically when a worker claims the reaction. */
92
+ readonly attempts: number;
93
+ readonly availableAtMs: number;
94
+ readonly leaseOwner?: string;
95
+ readonly leaseExpiresAtMs?: number;
96
+ readonly completedAtMs?: number;
97
+ readonly lastFailure?: ReactionFailure;
98
+ }
99
+ export interface ReactionClaimQuery {
100
+ /** Opaque token unique to this claim operation. */
101
+ readonly leaseOwner: string;
102
+ readonly types: readonly string[];
103
+ readonly nowMs: number;
104
+ readonly leaseDurationMs: number;
105
+ readonly limit: number;
106
+ }
107
+ export interface ReactionListQuery {
108
+ readonly statuses?: readonly ReactionStatus[];
109
+ readonly types?: readonly string[];
110
+ readonly limit: number;
111
+ }
112
+ export interface ReactionFailureUpdate {
113
+ readonly leaseOwner: string;
114
+ readonly failure: ReactionFailure;
115
+ /** Present for retry; absent moves the row to the dead-letter state. */
116
+ readonly retryAtMs?: number;
117
+ }
118
+ export interface ReactionPruneQuery {
119
+ readonly completedBeforeMs: number;
120
+ readonly deadLetterBeforeMs: number;
121
+ readonly limit: number;
122
+ }
123
+ export interface PrunedReactionCounts {
124
+ readonly completed: number;
125
+ readonly deadLetter: number;
126
+ }
62
127
  /** Persisted push outcome for idempotent replay (§2.3, §6.3). */
63
128
  export interface StoredPushResult {
64
129
  readonly status: 'applied' | 'rejected';
@@ -166,6 +231,20 @@ export interface ScopeActivityQuery {
166
231
  readonly value: string;
167
232
  readonly limit: number;
168
233
  }
234
+ /** A registered SELECT executed against the authoritative row projection. */
235
+ export type AuthoritativeQueryValue = string | number | bigint | boolean | Uint8Array | null;
236
+ export interface AuthoritativeQueryRequest {
237
+ /** Generated, positional SQLite-family SQL. It never comes from the request. */
238
+ readonly sql: string;
239
+ readonly params: readonly AuthoritativeQueryValue[];
240
+ /** Generated dependency set, used to validate and partition every relation. */
241
+ readonly tables: readonly string[];
242
+ }
243
+ /** One transactionally consistent authoritative query snapshot. */
244
+ export interface AuthoritativeQueryResult {
245
+ readonly rows: readonly Readonly<Record<string, unknown>>[];
246
+ readonly maxCommitSeq: number;
247
+ }
169
248
  /**
170
249
  * One transaction per push commit (§6.4): all row writes, the appended
171
250
  * commit (with its scope-index entries), and the idempotency record either
@@ -222,6 +301,12 @@ export interface StorageTransaction {
222
301
  deleteRow(table: string, rowId: string): Promise<void>;
223
302
  /** Allocates the next per-partition commitSeq and appends the commit. */
224
303
  appendCommit(commit: NewCommit): Promise<number>;
304
+ /**
305
+ * Persist reaction rows in this authoritative transaction. Required when
306
+ * the host configures a reaction planner. Reactions survive commit-log
307
+ * pruning because they live outside the commit/change tables.
308
+ */
309
+ enqueueReactions?(reactions: readonly NewReaction[]): Promise<void>;
225
310
  /**
226
311
  * Persist an idempotency outcome only when the key is still absent. The
227
312
  * first writer wins; callers read the canonical value after commit.
@@ -272,6 +357,24 @@ export interface ServerStorage {
272
357
  * when a persisted result exists but cannot be read (§6.3).
273
358
  */
274
359
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
360
+ /**
361
+ * Atomically lease due or expired-lease reactions for one worker. Optional
362
+ * for compatibility with custom storages; reaction runners fail closed when
363
+ * any lifecycle method is absent.
364
+ */
365
+ claimReactions?(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
366
+ /** Acknowledge only while `leaseOwner` still owns the lease. */
367
+ completeReaction?(partition: string, idempotencyKey: string, leaseOwner: string, completedAtMs: number): Promise<boolean>;
368
+ /** Extend only while `leaseOwner` still owns the active lease. */
369
+ extendReactionLease?(partition: string, idempotencyKey: string, leaseOwner: string, leaseExpiresAtMs: number): Promise<boolean>;
370
+ /** Retry or dead-letter only while `leaseOwner` still owns the lease. */
371
+ failReaction?(partition: string, idempotencyKey: string, update: ReactionFailureUpdate): Promise<boolean>;
372
+ /** Reset a dead-lettered row for an explicit operator retry. */
373
+ retryReaction?(partition: string, idempotencyKey: string, nowMs: number): Promise<boolean>;
374
+ getReaction?(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
375
+ listReactions?(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
376
+ /** Delete a bounded set of aged terminal rows. Never deletes active work. */
377
+ pruneReactions?(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
275
378
  /**
276
379
  * Matching commits in the window, oldest first, each carrying only its
277
380
  * matching changes for `table`. Stops once accumulated matching changes
@@ -280,6 +383,12 @@ export interface ServerStorage {
280
383
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
281
384
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
282
385
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
386
+ /**
387
+ * Optional registered-query capability. The implementation MUST replace
388
+ * every generated app-table relation with a partition-filtered relation and
389
+ * return rows plus maxCommitSeq from one consistent database snapshot.
390
+ */
391
+ queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
283
392
  /**
284
393
  * Optional trusted-host exact lookup through a declared relational index.
285
394
  * This capability is outside client scope/subscription authorization and
package/dist/validate.js CHANGED
@@ -25,6 +25,7 @@ import { normalizeRejectionDetails, } from '@syncular/core';
25
25
  export const RESERVED_VALIDATION_CODE_PREFIXES = [
26
26
  'sync.',
27
27
  'blob.',
28
+ 'operation.',
28
29
  'presence.',
29
30
  'client.',
30
31
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.45",
3
+ "version": "0.15.47",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -27,13 +27,25 @@
27
27
  },
28
28
  "exports": {
29
29
  ".": {
30
- "bun": "./src/index.ts",
30
+ "bun": "./src/index-bun.ts",
31
+ "node": {
32
+ "types": "./dist/index-node.d.ts",
33
+ "default": "./dist/index-node.js"
34
+ },
31
35
  "browser": "./dist/index.js",
32
36
  "import": {
33
37
  "types": "./dist/index.d.ts",
34
38
  "default": "./dist/index.js"
35
39
  }
36
40
  },
41
+ "./sqlite": {
42
+ "bun": "./src/sqlite-bun.ts",
43
+ "node": {
44
+ "types": "./dist/sqlite-node.d.ts",
45
+ "default": "./dist/sqlite-node.js"
46
+ },
47
+ "types": "./dist/sqlite-node.d.ts"
48
+ },
37
49
  "./pglite": {
38
50
  "bun": "./src/pg-executor-pglite.ts",
39
51
  "browser": "./dist/pg-executor-pglite.js",
@@ -52,8 +64,11 @@
52
64
  "!dist/**/*.test.js",
53
65
  "!dist/**/*.test.d.ts"
54
66
  ],
67
+ "scripts": {
68
+ "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
+ },
55
70
  "dependencies": {
56
- "@syncular/core": "0.15.45"
71
+ "@syncular/core": "0.15.47"
57
72
  },
58
73
  "devDependencies": {
59
74
  "@electric-sql/pglite": "^0.5.4"
package/src/admin.ts CHANGED
@@ -4,9 +4,8 @@
4
4
  * `ServerStorage`, the optional segment/blob store stats, and an in-memory
5
5
  * event ring. It delivers the 80% operator value (who's connected, what's
6
6
  * flowing, horizon health, the event tail) as a handful of queries in the
7
- * server package — no separate
8
- * UI package, no framework, no wire-protocol surface (SPEC.md is untouched;
9
- * this is host surface, mirrored in the server README).
7
+ * server package — no separate UI package, no framework, and no wire-protocol
8
+ * surface. This host surface is mirrored in the server README.
10
9
  *
11
10
  * Nothing here is on the sync hot path. Every method is a plain read; the
12
11
  * additive optional storage/store methods it depends on are documented as
@@ -25,8 +24,10 @@ import type { SegmentStore, SegmentStoreStats } from './segment-store';
25
24
  import type {
26
25
  ClientRecord,
27
26
  CommitMetadata,
27
+ ReactionStatus,
28
28
  ScopeCommitActivity,
29
29
  ServerStorage,
30
+ StoredReaction,
30
31
  } from './storage';
31
32
 
32
33
  /** A connected/known client as the console sees it (§4.5, §8.1). */
@@ -137,6 +138,12 @@ export interface AdminScopeActivityOptions {
137
138
  readonly limit?: number;
138
139
  }
139
140
 
141
+ export interface AdminListReactionsOptions {
142
+ readonly statuses?: readonly ReactionStatus[];
143
+ readonly types?: readonly string[];
144
+ readonly limit?: number;
145
+ }
146
+
140
147
  export interface AdminStats {
141
148
  readonly segments?: SegmentStoreStats;
142
149
  readonly blobs?: BlobStoreStats;
@@ -164,6 +171,7 @@ export interface SyncularAdminOptions {
164
171
 
165
172
  const DEFAULT_COMMIT_LIMIT = 50;
166
173
  const DEFAULT_SCOPE_LIMIT = 50;
174
+ const DEFAULT_REACTION_LIMIT = 100;
167
175
  const DEFAULT_CLIENT_EVENT_LIMIT = 100;
168
176
  const DEFAULT_METRICS_WINDOW_MS = 5 * 60 * 1000;
169
177
  const DEFAULT_METRICS_BUCKETS = 30;
@@ -313,6 +321,22 @@ export class SyncularAdmin {
313
321
  });
314
322
  }
315
323
 
324
+ /** Pending, leased, completed, and dead-lettered durable reactions. */
325
+ async listReactions(
326
+ partition: string,
327
+ options: AdminListReactionsOptions = {},
328
+ ): Promise<StoredReaction[]> {
329
+ const read = required(
330
+ this.#storage.listReactions?.bind(this.#storage),
331
+ 'storage',
332
+ );
333
+ return read(partition, {
334
+ limit: options.limit ?? DEFAULT_REACTION_LIMIT,
335
+ ...(options.statuses !== undefined ? { statuses: options.statuses } : {}),
336
+ ...(options.types !== undefined ? { types: options.types } : {}),
337
+ });
338
+ }
339
+
316
340
  /**
317
341
  * Inspect a single row: current server_version, stored scopes, and the
318
342
  * blobIds it references (when the store tracks references). Payload bytes