@syncular/client 0.30.13 → 0.30.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { BLOB_COMMIT_REFS } from './blob.js';
2
+ import { hasUniqueIndex } from './schema.js';
3
+ import { OUTCOMES_TABLE } from './outcomes.js';
4
+ import { OUTBOX_TABLE } from './outbox.js';
1
5
  import { RETAINED_ROWS } from './failed-overlay.js';
2
6
  import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
3
7
  import { ProgressEmitter, } from './progress.js';
@@ -126,6 +130,7 @@ function realtimeErrorCode(error) {
126
130
  }
127
131
  export class SyncClient {
128
132
  #config;
133
+ #authorityReads;
129
134
  #db;
130
135
  #schema;
131
136
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
@@ -233,6 +238,8 @@ export class SyncClient {
233
238
  this.#retainFailedCommits = config.retainFailedCommits === true;
234
239
  this.#db = config.database;
235
240
  this.#schema = compileClientSchema(config.schema);
241
+ this.#authorityReads = config.authorityReads;
242
+ this.#authorityReads?.validate(this.#schema);
236
243
  const realtimePolicy = config.realtimePolicy ?? 'optional';
237
244
  if (realtimePolicy !== 'required' &&
238
245
  realtimePolicy !== 'optional' &&
@@ -478,6 +485,13 @@ export class SyncClient {
478
485
  }
479
486
  /** §2.1 reset after the server reports a different log continuity. */
480
487
  #runLogEpochReset(logEpoch) {
488
+ if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
489
+ this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
490
+ this.#setSyncNeeded(true);
491
+ this.#emitSyncNeeded('startup');
492
+ this.#emitSyncIntent({ kind: 'interactive' });
493
+ return [];
494
+ }
481
495
  const subscriptions = loadSubscriptions(this.#db);
482
496
  const pending = listOutbox(this.#db);
483
497
  this.#setUpgrading(true);
@@ -530,6 +544,7 @@ export class SyncClient {
530
544
  });
531
545
  }
532
546
  async close() {
547
+ this.#started = false;
533
548
  this.#emitSyncIntent({ kind: 'none' });
534
549
  this.#devtoolsUnregister?.();
535
550
  this.#devtoolsUnregister = undefined;
@@ -537,7 +552,6 @@ export class SyncClient {
537
552
  this.#abortPendingRound('client closed mid-round');
538
553
  await this.#lease?.release();
539
554
  this.#lease = undefined;
540
- this.#started = false;
541
555
  this.#progress.clear();
542
556
  this.#syncNeededListeners.clear();
543
557
  this.#syncIntentListeners.clear();
@@ -685,6 +699,13 @@ export class SyncClient {
685
699
  this.#requireStarted();
686
700
  return getLocalRevision(this.#db);
687
701
  }
702
+ /** Read the creation-time plain authority policy, including preflight. */
703
+ authoritySnapshot() {
704
+ this.#requireStarted();
705
+ if (!this.#authorityReads || arguments.length)
706
+ throw new ClientSyncError('client.authority_read_forbidden', 'authority read denied');
707
+ return this.#authorityReads.snapshot(this.#db, this.#schema);
708
+ }
688
709
  /**
689
710
  * Read rows, window answerability, and revision from one SQLite snapshot.
690
711
  * Reactive integrations use this instead of composing `query()` and
@@ -703,24 +724,13 @@ export class SyncClient {
703
724
  const missing = [];
704
725
  for (const requested of spec.coverage ?? []) {
705
726
  const baseKey = windowBaseKey(requested.base);
706
- const live = new Map(loadWindowUnits(this.#db, baseKey).map((entry) => [
707
- entry.unit,
708
- entry.subId,
709
- ]));
727
+ const state = this.windowState(requested.base);
710
728
  for (const unit of new Set(requested.units)) {
711
- const subId = live.get(unit);
712
729
  const ref = { baseKey, unit };
713
- if (subId === undefined) {
730
+ if (!state.units.includes(unit))
714
731
  missing.push(ref);
715
- continue;
716
- }
717
- const sub = getSubscription(this.#db, subId);
718
- if (sub === undefined ||
719
- sub.status !== 'active' ||
720
- sub.cursor < 0 ||
721
- sub.bootstrapState !== undefined) {
732
+ else if (state.pending.includes(unit))
722
733
  pending.push(ref);
723
- }
724
734
  }
725
735
  }
726
736
  return {
@@ -1033,7 +1043,7 @@ export class SyncClient {
1033
1043
  item.id.length === 0 ||
1034
1044
  typeof item.table !== 'string' ||
1035
1045
  item.table.length === 0) {
1036
- throw invalidRequest('diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
1046
+ throw invalidRequest('diagnosticsSnapshot needs subscription id and table');
1037
1047
  }
1038
1048
  const registeredSubscription = subscriptions.get(item.id);
1039
1049
  if (registeredSubscription !== undefined &&
@@ -1128,8 +1138,8 @@ export class SyncClient {
1128
1138
  try {
1129
1139
  const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
1130
1140
  const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
1131
- const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
1132
- const outcome = this.#db.query('SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes')[0];
1141
+ const outboxBytes = Number(this.#db.query(`SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`)[0]?.bytes ?? 0);
1142
+ const outcome = this.#db.query(`SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`)[0];
1133
1143
  const blobBytes = this.#hasBlobs
1134
1144
  ? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
1135
1145
  : 0;
@@ -1925,7 +1935,7 @@ export class SyncClient {
1925
1935
  catch {
1926
1936
  continue;
1927
1937
  }
1928
- this.#db.exec('INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)', [clientCommitId, blobId, blobId]);
1938
+ this.#db.exec(`INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`, [clientCommitId, blobId, blobId]);
1929
1939
  }
1930
1940
  }
1931
1941
  try {
@@ -2143,7 +2153,7 @@ export class SyncClient {
2143
2153
  };
2144
2154
  }
2145
2155
  if (this.#schemaFloor !== undefined) {
2146
- throw invalidRequest('local rebootstrap cannot bypass an active schema-floor stop; update the application first');
2156
+ throw invalidRequest('local rebootstrap needs a schema-floor update');
2147
2157
  }
2148
2158
  const pending = listOutbox(this.#db);
2149
2159
  const resetSubscriptions = loadSubscriptions(this.#db).length;
@@ -2250,7 +2260,7 @@ export class SyncClient {
2250
2260
  async #encodeOutboxForPush() {
2251
2261
  // Pin before the first encryption await: mutations can append while a
2252
2262
  // round is encoding, and belong to the next request.
2253
- const bounds = this.#db.query('SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox')[0];
2263
+ const bounds = this.#db.query(`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`)[0];
2254
2264
  const pendingCount = bounds.count;
2255
2265
  const throughSeq = bounds.last_seq ?? 0;
2256
2266
  const pushFrames = [];
@@ -3140,14 +3150,7 @@ export class SyncClient {
3140
3150
  }
3141
3151
  }
3142
3152
  finally {
3143
- try {
3144
- // §7.1: local reads see outbox state applied optimistically — replay
3145
- // the still-pending commits on top of the freshly applied server state.
3146
- this.#replayOutbox();
3147
- }
3148
- finally {
3149
- this.#endDiagnosticsDeferral();
3150
- }
3153
+ this.#endDiagnosticsDeferral();
3151
3154
  }
3152
3155
  if (errorFrame !== undefined)
3153
3156
  throw errorFrame;
@@ -3276,13 +3279,16 @@ export class SyncClient {
3276
3279
  if (this.#retainFailedCommits)
3277
3280
  retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
3278
3281
  this.#rollbackFailedCommit(commit, batch);
3282
+ this.#replayOutbox(commit.operations.map((operation) => JSON.stringify(hasUniqueIndex(this.#table(operation.table))
3283
+ ? [operation.table]
3284
+ : [operation.table, operation.rowId])));
3279
3285
  });
3280
3286
  batch.status();
3281
3287
  summary.rejected.push(frame.clientCommitId);
3282
3288
  return true;
3283
3289
  }
3284
3290
  #outboxCommitExists(clientCommitId) {
3285
- return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1', [clientCommitId]).length > 0);
3291
+ return (this.#db.query(`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`, [clientCommitId]).length > 0);
3286
3292
  }
3287
3293
  #decodeServerRow(tableName, payload) {
3288
3294
  if (tableName === undefined)
@@ -3320,7 +3326,7 @@ export class SyncClient {
3320
3326
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
3321
3327
  this.#recordCommitChanges(batch, frame);
3322
3328
  const result = fn();
3323
- this.#replayOutbox(result);
3329
+ this.#replayOutbox(result, false);
3324
3330
  return result;
3325
3331
  }));
3326
3332
  summary.commitsApplied += 1;
@@ -3381,7 +3387,7 @@ export class SyncClient {
3381
3387
  (clearFirst && this.#scopedRowsExist(table, effective)))
3382
3388
  batch.table(table.name);
3383
3389
  const result = fn();
3384
- this.#replayOutbox();
3390
+ this.#replayOutbox(result, false);
3385
3391
  return result;
3386
3392
  }),
3387
3393
  };
@@ -3513,11 +3519,12 @@ export class SyncClient {
3513
3519
  return matchesLocalScopes(table, effective, values);
3514
3520
  }));
3515
3521
  if (covered.length) {
3516
- restoreFailedBases(this.#db, this.#schema);
3522
+ const scope = covered.map((row) => JSON.stringify([sub.table, String(row.id)]));
3523
+ restoreFailedBases(this.#db, this.#schema, scope);
3517
3524
  for (const row of covered)
3518
3525
  this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
3519
3526
  batch.table(sub.table);
3520
- this.#replayOutbox();
3527
+ this.#replayOutbox(scope, false);
3521
3528
  }
3522
3529
  }
3523
3530
  if (completed && registered !== undefined) {
@@ -3560,9 +3567,11 @@ export class SyncClient {
3560
3567
  if (lastEffective !== undefined &&
3561
3568
  Object.keys(lastEffective).length > 0) {
3562
3569
  try {
3563
- restoreFailedBases(this.#db, this.#schema);
3564
- this.#dropRetainedRows(batch, (name, values) => name === table.name &&
3565
- matchesLocalScopes(table, lastEffective, values));
3570
+ const scope = [
3571
+ JSON.stringify([table.name]),
3572
+ ...this.#dropRetainedRows(batch, (name, values) => name === table.name &&
3573
+ matchesLocalScopes(table, lastEffective, values)),
3574
+ ];
3566
3575
  deleteScopedRows(this.#db, table, lastEffective);
3567
3576
  batch.scopeMap(table, lastEffective);
3568
3577
  const pendingById = new Map(listOutbox(this.#db).map((commit) => [
@@ -3600,6 +3609,7 @@ export class SyncClient {
3600
3609
  batch.rejections();
3601
3610
  batch.outcomes();
3602
3611
  }
3612
+ this.#replayOutbox(scope);
3603
3613
  this.#deleteUnreferencedBlobs();
3604
3614
  }
3605
3615
  catch (error) {
@@ -3772,7 +3782,7 @@ export class SyncClient {
3772
3782
  ? OPTIMISTIC_VERSION
3773
3783
  : local[SYNC_VERSION_COLUMN];
3774
3784
  if (replay &&
3775
- table.indexes.some((index) => index.unique) &&
3785
+ hasUniqueIndex(table) &&
3776
3786
  uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
3777
3787
  column.name,
3778
3788
  values[index] ?? null,
@@ -3786,33 +3796,35 @@ export class SyncClient {
3786
3796
  this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
3787
3797
  }
3788
3798
  #dropRetainedRows(batch, matches) {
3789
- for (const commit of dropFailedRows(this.#db, matches)) {
3799
+ const scope = [];
3800
+ for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
3790
3801
  batch.conflicts();
3791
3802
  batch.rejections();
3792
3803
  batch.outcomes();
3793
3804
  this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
3794
3805
  this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
3795
- for (const op of commit.operations)
3806
+ for (const op of commit.operations) {
3796
3807
  batch.table(op.table);
3808
+ scope.push(JSON.stringify([op.table, op.rowId]));
3809
+ }
3797
3810
  }
3811
+ return scope;
3798
3812
  }
3799
- #replayOutbox(rowKeys) {
3800
- const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
3801
- const pending = [
3802
- ...failedOverlayCommits(this.#db, false, rowKeys),
3803
- ...listOutbox(this.#db, rowKeys),
3804
- ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3805
- if (pending.length === 0 && acknowledged.length === 0)
3813
+ #replayOutbox(scope, restoreBases = true) {
3814
+ if (scope?.length === 0)
3806
3815
  return;
3807
3816
  this.#applyBatch((batch) => {
3808
- this.#db.transaction(() => {
3809
- restoreFailedBases(this.#db, this.#schema, false, rowKeys);
3810
- restoreFailedBases(this.#db, this.#schema, true, rowKeys);
3811
- for (const commit of [...acknowledged, ...pending])
3812
- this.#applyOperationsLocally(rowKeys === undefined
3813
- ? commit.operations
3814
- : commit.operations.filter((op) => rowKeys.includes(JSON.stringify([op.table, op.rowId]))), batch, true);
3815
- });
3817
+ if (restoreBases)
3818
+ restoreFailedBases(this.#db, this.#schema, scope);
3819
+ const acknowledged = failedOverlayCommits(this.#db, true, scope, this.#schema);
3820
+ const pending = [
3821
+ ...failedOverlayCommits(this.#db, false, scope, this.#schema),
3822
+ ...listOutbox(this.#db, scope),
3823
+ ].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
3824
+ for (const commit of [...acknowledged, ...pending])
3825
+ this.#applyOperationsLocally(commit.operations.filter((op) => scope === undefined ||
3826
+ scope.includes(JSON.stringify([op.table, op.rowId])) ||
3827
+ scope.includes(JSON.stringify([op.table]))), batch, true);
3816
3828
  });
3817
3829
  }
3818
3830
  #deleteUnreferencedBlobs() {
@@ -3830,13 +3842,13 @@ export class SyncClient {
3830
3842
  }
3831
3843
  #requireStarted() {
3832
3844
  if (!this.#started) {
3833
- throw invalidRequest('call SyncClient.start() first');
3845
+ throw invalidRequest('call start() first');
3834
3846
  }
3835
3847
  }
3836
3848
  #requireActive() {
3837
3849
  this.#requireStarted();
3838
3850
  if (this.#securityLifecycle === 'preflight') {
3839
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'security preflight must complete before accessing protected data; call activateSecurity');
3851
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'protected reads require activateSecurity');
3840
3852
  }
3841
3853
  }
3842
3854
  }
@@ -10,6 +10,9 @@ export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledCli
10
10
  export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
11
11
  /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
12
12
  export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
13
- export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean, rowKeys?: readonly string[]): boolean;
14
- export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, rowKeys?: readonly string[]): OutboxCommit[];
15
- export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
13
+ /** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
14
+ export type OverlayScope = readonly string[];
15
+ export declare function overlayScopePredicate(table: string, id: string): string;
16
+ export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, scope?: OverlayScope): boolean;
17
+ export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, scope?: OverlayScope, schema?: CompiledClientSchema): OutboxCommit[];
18
+ export declare function dropFailedRows(db: ClientDatabase, schema: CompiledClientSchema, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
@@ -1,3 +1,6 @@
1
+ import { hasUniqueIndex } from './schema.js';
2
+ import { OUTCOMES_TABLE } from './outcomes.js';
3
+ import { OUTBOX_TABLE } from './outbox.js';
1
4
  export const RETAINED_ROWS = '_syncular_failed_rows';
2
5
  import { ClientSyncError } from './errors.js';
3
6
  import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
@@ -17,7 +20,7 @@ export function retainedBaseWrite(db, table, rowId, values, version) {
17
20
  table.name,
18
21
  rowId,
19
22
  ]);
20
- if (!table.indexes.some((index) => index.unique))
23
+ if (!hasUniqueIndex(table))
21
24
  return;
22
25
  for (const retained of db.query(`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`, [table.name])) {
23
26
  const prior = retained.unique_conflicts === null
@@ -87,28 +90,54 @@ export function uniqueConflicts(db, table, values) {
87
90
  serverVersion: Number(row[SYNC_VERSION_COLUMN]),
88
91
  })));
89
92
  }
90
- function retainedRows(db, rowKeys) {
91
- return db.query(`SELECT * FROM ${RETAINED_ROWS} WHERE ?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1)) ORDER BY commit_seq,at,commit_id,idx`, [rowKeys ? JSON.stringify(rowKeys) : null]);
93
+ export function overlayScopePredicate(table, id) {
94
+ return `?1 IS NULL OR json_array(${table},${id}) IN(SELECT value FROM json_each(?1)) OR json_array(${table}) IN(SELECT value FROM json_each(?1))`;
92
95
  }
93
- export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
96
+ function retainedRows(db, scope, acknowledged) {
97
+ return db.query(`SELECT * FROM ${RETAINED_ROWS} WHERE (${overlayScopePredicate('tbl', 'id')})${acknowledged === undefined ? '' : ` AND commit_seq IS${acknowledged ? ' NOT' : ''} NULL`} ORDER BY commit_seq,at,commit_id,idx`, [scope ? JSON.stringify(scope) : null]);
98
+ }
99
+ function retainedTable(schema, name) {
100
+ const table = schema.tables.get(name);
101
+ if (!table)
102
+ throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
103
+ return table;
104
+ }
105
+ export function restoreFailedBases(db, schema, scope) {
94
106
  const seen = new Set();
95
- for (const row of retainedRows(db, rowKeys)) {
107
+ for (const row of retainedRows(db, scope)) {
96
108
  const key = JSON.stringify([row.tbl, row.id]);
97
- if (!absentIntent && seen.has(key))
109
+ if (seen.has(key))
98
110
  continue;
99
111
  seen.add(key);
100
- const table = schema.tables.get(row.tbl);
101
- if (!table)
102
- throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
103
- const json = absentIntent ? row.intent : row.base;
104
- if (absentIntent && row.intent !== null) {
105
- const values = JSON.parse(row.intent);
106
- const conflicts = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue));
107
- // Hydrate a pre-0.30.11 failure after its winner was already imported.
108
- // Pending and retained overlays cannot supply authoritative row evidence.
109
- if (row.commit_seq === null && row.unique_conflicts === null) {
110
- const authorized = conflicts.filter((conflict) => conflict.serverVersion >= 0 &&
111
- !db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM _syncular_outbox,json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
112
+ const table = retainedTable(schema, row.tbl);
113
+ if (row.base === null)
114
+ deleteLocalRow(db, table, row.id, false);
115
+ else {
116
+ const values = JSON.parse(String(row.base));
117
+ upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
118
+ }
119
+ }
120
+ return seen.size > 0;
121
+ }
122
+ export function failedOverlayCommits(db, acknowledged = false, scope, schema) {
123
+ const commits = new Map();
124
+ for (const row of retainedRows(db, scope, acknowledged)) {
125
+ const id = row.commit_id;
126
+ let operation = JSON.parse(row.op);
127
+ if (schema && operation.op === 'upsert') {
128
+ const table = retainedTable(schema, operation.table);
129
+ if (acknowledged) {
130
+ if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId]).length)
131
+ operation = {
132
+ ...operation,
133
+ values: JSON.parse(row.intent),
134
+ };
135
+ }
136
+ else if (row.unique_conflicts === null && row.intent !== null) {
137
+ // Hydrate legacy failure evidence from authoritative unique-key peers.
138
+ const values = JSON.parse(row.intent);
139
+ const authorized = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue)).filter((conflict) => conflict.serverVersion >= 0 &&
140
+ !db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM ${OUTBOX_TABLE},json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
112
141
  if (authorized.length)
113
142
  db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
114
143
  JSON.stringify(authorized.map((conflict) => ({
@@ -119,35 +148,7 @@ export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
119
148
  row.idx,
120
149
  ]);
121
150
  }
122
- if (conflicts.length)
123
- continue;
124
- }
125
- if (absentIntent &&
126
- (json === null ||
127
- db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [row.id]).length))
128
- continue;
129
- if (json === null)
130
- deleteLocalRow(db, table, row.id, false);
131
- else {
132
- const values = JSON.parse(String(json));
133
- if (absentIntent) {
134
- const operation = JSON.parse(row.op);
135
- if (row.commit_seq === null &&
136
- !table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
137
- continue;
138
- }
139
- upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
140
151
  }
141
- }
142
- return seen.size > 0;
143
- }
144
- export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
145
- const commits = new Map();
146
- for (const row of retainedRows(db, rowKeys)) {
147
- if ((row.commit_seq !== null) !== acknowledged)
148
- continue;
149
- const id = row.commit_id;
150
- const operation = JSON.parse(row.op);
151
152
  const prior = commits.get(id);
152
153
  if (prior)
153
154
  prior.operations.push(operation);
@@ -161,7 +162,7 @@ export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
161
162
  }
162
163
  return [...commits.values()];
163
164
  }
164
- export function dropFailedRows(db, matches) {
165
+ export function dropFailedRows(db, schema, matches) {
165
166
  const ids = new Set();
166
167
  for (const row of retainedRows(db)) {
167
168
  for (const json of [row.intent, row.base]) {
@@ -176,9 +177,10 @@ export function dropFailedRows(db, matches) {
176
177
  ...failedOverlayCommits(db),
177
178
  ...failedOverlayCommits(db, true),
178
179
  ].filter((commit) => ids.has(commit.clientCommitId));
180
+ restoreFailedBases(db, schema, dropped.flatMap((commit) => commit.operations.map((op) => JSON.stringify([op.table, op.rowId]))));
179
181
  for (const id of ids) {
180
182
  db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
181
- db.exec("UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?", [id]);
183
+ db.exec(`UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`, [id]);
182
184
  }
183
185
  return dropped;
184
186
  }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
10
10
  */
11
+ export { defineAuthorityReads, type AuthorityReadPolicy, type AuthorityReadDeclaration, type AuthoritySnapshot, } from './authority.js';
11
12
  export * from './apply.js';
12
13
  export * from './availability.js';
13
14
  export * from './blob.js';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
10
10
  */
11
+ export { defineAuthorityReads, } from './authority.js';
11
12
  export * from './apply.js';
12
13
  export * from './availability.js';
13
14
  export * from './blob.js';
package/dist/outbox.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export declare const OUTBOX_TABLE = "_syncular_outbox";
2
+ export declare const OUTBOX_IMAGES = "_syncular_outbox_before_images";
3
+ import { type OverlayScope } from './failed-overlay.js';
1
4
  /**
2
5
  * The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
3
6
  * outbox rule): local mutations are persisted in a schema-agnostic JSON
@@ -40,7 +43,7 @@ export interface OutboxBeforeImage {
40
43
  }
41
44
  export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
42
45
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
43
- export declare function listOutbox(db: ClientDatabase, rowKeys?: readonly string[]): OutboxCommit[];
46
+ export declare function listOutbox(db: ClientDatabase, scope?: OverlayScope): OutboxCommit[];
44
47
  /** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
45
48
  export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
46
49
  /** Routine status reads never load operation bodies. */
package/dist/outbox.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { BLOB_COMMIT_REFS } from './blob.js';
2
+ export const OUTBOX_TABLE = '_syncular_outbox';
3
+ export const OUTBOX_IMAGES = '_syncular_outbox_before_images';
4
+ import { overlayScopePredicate } from './failed-overlay.js';
1
5
  /**
2
6
  * The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
3
7
  * outbox rule): local mutations are persisted in a schema-agnostic JSON
@@ -12,15 +16,15 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
12
16
  if (operations.length === 0) {
13
17
  throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
14
18
  }
15
- db.exec('INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)', [clientCommitId, nowMs, JSON.stringify(operations)]);
19
+ db.exec(`INSERT INTO ${OUTBOX_TABLE}(client_commit_id,created_at_ms,operations)VALUES(?,?,?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
16
20
  replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
17
21
  }
18
22
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
19
- export function listOutbox(db, rowKeys) {
23
+ export function listOutbox(db, scope) {
20
24
  return db
21
- .query(rowKeys === undefined
22
- ? 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC'
23
- : "SELECT * FROM _syncular_outbox WHERE EXISTS(SELECT 1 FROM json_each(operations) WHERE json_array(json_extract(value,'$.table'),json_extract(value,'$.rowId')) IN(SELECT value FROM json_each(?))) ORDER BY seq", rowKeys && [JSON.stringify(rowKeys)])
25
+ .query(scope === undefined
26
+ ? `SELECT * FROM ${OUTBOX_TABLE} ORDER BY seq ASC`
27
+ : `SELECT * FROM ${OUTBOX_TABLE} WHERE EXISTS(SELECT 1 FROM json_each(operations) WHERE ${overlayScopePredicate("json_extract(value,'$.table')", "json_extract(value,'$.rowId')")}) ORDER BY seq`, scope && [JSON.stringify(scope)])
24
28
  .map(decodeOutboxRow);
25
29
  }
26
30
  function decodeOutboxRow(row) {
@@ -35,7 +39,7 @@ function decodeOutboxRow(row) {
35
39
  export function* iterateOutbox(db, throughSeq) {
36
40
  let afterSeq = 0;
37
41
  while (afterSeq < throughSeq) {
38
- const rows = db.query('SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32', [afterSeq, throughSeq]);
42
+ const rows = db.query(`SELECT * FROM ${OUTBOX_TABLE} WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
39
43
  if (rows.length === 0)
40
44
  return;
41
45
  for (const row of rows) {
@@ -47,21 +51,23 @@ export function* iterateOutbox(db, throughSeq) {
47
51
  }
48
52
  /** Routine status reads never load operation bodies. */
49
53
  export function countOutbox(db) {
50
- return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]
54
+ return db.query(`SELECT COUNT(*)AS count FROM ${OUTBOX_TABLE}`)[0]
51
55
  .count;
52
56
  }
53
57
  export function deleteOutboxCommit(db, clientCommitId) {
54
- db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
58
+ db.exec(`DELETE FROM ${BLOB_COMMIT_REFS} WHERE commit_id=?`, [
55
59
  clientCommitId,
56
60
  ]);
57
- db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?', [clientCommitId]);
58
- db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
61
+ db.exec(`DELETE FROM ${OUTBOX_IMAGES} WHERE client_commit_id=?`, [
62
+ clientCommitId,
63
+ ]);
64
+ db.exec(`DELETE FROM ${OUTBOX_TABLE} WHERE client_commit_id=?`, [
59
65
  clientCommitId,
60
66
  ]);
61
67
  }
62
68
  export function listOutboxBeforeImages(db, clientCommitId) {
63
69
  return db
64
- .query('SELECT op_index,existed,sync_version,values_json,delivery_seq FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index', [clientCommitId])
70
+ .query(`SELECT op_index,existed,sync_version,values_json,delivery_seq FROM ${OUTBOX_IMAGES} WHERE client_commit_id=? ORDER BY op_index`, [clientCommitId])
65
71
  .map((row) => ({
66
72
  opIndex: row.op_index,
67
73
  existed: row.existed === 1,
@@ -80,7 +86,7 @@ export function listOutboxBeforeImages(db, clientCommitId) {
80
86
  }
81
87
  export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
82
88
  for (const image of replacements) {
83
- db.exec('INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?) ON CONFLICT(client_commit_id,op_index)DO UPDATE SET existed=excluded.existed,sync_version=excluded.sync_version,values_json=excluded.values_json', [
89
+ db.exec(`INSERT INTO ${OUTBOX_IMAGES}(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?) ON CONFLICT(client_commit_id,op_index)DO UPDATE SET existed=excluded.existed,sync_version=excluded.sync_version,values_json=excluded.values_json`, [
84
90
  clientCommitId,
85
91
  image.opIndex,
86
92
  image.existed ? 1 : 0,
@@ -1,3 +1,4 @@
1
+ export declare const OUTCOMES_TABLE = "_syncular_commit_outcomes";
1
2
  /**
2
3
  * Durable per-client commit outcomes.
3
4
  *
package/dist/outcomes.js CHANGED
@@ -1,3 +1,5 @@
1
+ export const OUTCOMES_TABLE = '_syncular_commit_outcomes';
2
+ const SELECT_OUTCOME = `SELECT * FROM ${OUTCOMES_TABLE}`;
1
3
  import { RETAINED_ROWS } from './failed-overlay.js';
2
4
  import { ClientSyncError } from './errors.js';
3
5
  import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
@@ -74,7 +76,7 @@ function parseOutcome(db, row) {
74
76
  };
75
77
  }
76
78
  export function recordCommitOutcome(db, outcome) {
77
- db.exec("INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')", [
79
+ db.exec(`INSERT INTO ${OUTCOMES_TABLE}(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')`, [
78
80
  outcome.clientCommitId,
79
81
  outcome.status,
80
82
  outcome.recordedAtMs,
@@ -86,7 +88,9 @@ export function recordCommitOutcome(db, outcome) {
86
88
  return commitOutcome(db, outcome.clientCommitId);
87
89
  }
88
90
  export function commitOutcome(db, clientCommitId) {
89
- const row = db.query('SELECT seq,client_commit_id,status,recorded_at_ms,results,operations,resolution,resolved_at_ms,replacement_client_commit_id FROM _syncular_commit_outcomes WHERE client_commit_id=?', [clientCommitId])[0];
91
+ const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
92
+ clientCommitId,
93
+ ])[0];
90
94
  return row === undefined ? undefined : parseOutcome(db, row);
91
95
  }
92
96
  export function listCommitOutcomes(db, query = {}) {
@@ -97,11 +101,11 @@ export function listCommitOutcomes(db, query = {}) {
97
101
  const where = query.activeOnly
98
102
  ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
99
103
  : '';
100
- const rows = db.query(`SELECT seq,client_commit_id,status,recorded_at_ms,results,operations,resolution,resolved_at_ms,replacement_client_commit_id FROM _syncular_commit_outcomes ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
104
+ const rows = db.query(`${SELECT_OUTCOME} ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
101
105
  return rows.map((row) => parseOutcome(db, row));
102
106
  }
103
107
  export function persistCommitOutcomeResolution(db, input, nowMs) {
104
- db.exec("UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'", [
108
+ db.exec(`UPDATE ${OUTCOMES_TABLE} SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'`, [
105
109
  input.resolution,
106
110
  nowMs,
107
111
  input.replacementClientCommitId ?? null,
@@ -117,13 +121,14 @@ export function pruneCommitOutcomes(db, maxEntries) {
117
121
  if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
118
122
  throw new ClientSyncError('sync.invalid_request', 'outcome retention maxEntries must be a positive safe integer');
119
123
  }
120
- const count = db.query('SELECT COUNT(*) AS count FROM _syncular_commit_outcomes')[0]?.count;
124
+ const count = db.query(`SELECT COUNT(*) AS count FROM ${OUTCOMES_TABLE}`)[0]
125
+ ?.count;
121
126
  const excess = Math.max(0, (count ?? 0) - maxEntries);
122
127
  if (excess === 0)
123
128
  return 0;
124
- const candidates = db.query("SELECT seq FROM _syncular_commit_outcomes WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?", [excess]);
129
+ const candidates = db.query(`SELECT seq FROM ${OUTCOMES_TABLE} WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?`, [excess]);
125
130
  for (const candidate of candidates) {
126
- db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
131
+ db.exec(`DELETE FROM ${OUTCOMES_TABLE} WHERE seq=?`, [
127
132
  candidate.seq,
128
133
  ]);
129
134
  }