@syncular/client 0.30.16 → 0.32.0

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/outcomes.js CHANGED
@@ -1,8 +1,17 @@
1
1
  export const OUTCOMES_TABLE = '_syncular_commit_outcomes';
2
2
  const SELECT_OUTCOME = `SELECT * FROM ${OUTCOMES_TABLE}`;
3
3
  import { RETAINED_ROWS } from './failed-overlay.js';
4
+ /**
5
+ * Durable per-client commit outcomes.
6
+ *
7
+ * The journal is client-local protected database state. A final push result is
8
+ * written in the same SQLite transaction that drains its outbox commit, so a
9
+ * restart can never turn "rejected" into an inferred success. Conflict payloads
10
+ * deliberately stay local; retention never deletes an unresolved failure.
11
+ */
12
+ import { normalizeRejectionDetails, } from '@syncular/core';
4
13
  import { ClientSyncError } from './errors.js';
5
- import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
14
+ import { isBytesEnvelope, mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
6
15
  function encodeResults(results) {
7
16
  const stored = results.map((result) => {
8
17
  if (result.status !== 'conflict')
@@ -17,45 +26,132 @@ function encodeResults(results) {
17
26
  });
18
27
  return JSON.stringify(stored);
19
28
  }
29
+ /** A JSON object that is neither null nor an array. */
30
+ function isRecord(value) {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ }
33
+ function isStoredRow(value) {
34
+ return (isRecord(value) &&
35
+ Object.values(value).every((cell) => cell === null ||
36
+ typeof cell === 'string' ||
37
+ typeof cell === 'boolean' ||
38
+ (typeof cell === 'number' && Number.isFinite(cell)) ||
39
+ isBytesEnvelope(cell)));
40
+ }
41
+ export function isStoredOperation(value) {
42
+ return (isRecord(value) &&
43
+ typeof value.table === 'string' &&
44
+ typeof value.rowId === 'string' &&
45
+ (value.baseVersion === undefined ||
46
+ Number.isSafeInteger(value.baseVersion)) &&
47
+ ((value.op === 'upsert' && isStoredRow(value.values)) ||
48
+ (value.op === 'delete' && value.values === undefined)));
49
+ }
20
50
  function decodeResults(raw) {
51
+ function corrupt() {
52
+ throw new ClientSyncError('sync.local_corrupt', 'persisted commit outcome is invalid');
53
+ }
21
54
  const stored = JSON.parse(raw);
55
+ if (!Array.isArray(stored))
56
+ corrupt();
22
57
  return stored.map((result) => {
23
- if (result.status !== 'conflict')
58
+ if (!isRecord(result))
59
+ corrupt();
60
+ if (result.status === 'applied') {
61
+ if (!Number.isInteger(result.opIndex) ||
62
+ result.opIndex < -2147483648 ||
63
+ result.opIndex > 2147483647)
64
+ corrupt();
24
65
  return result;
25
- return {
26
- status: 'conflict',
27
- conflict: {
28
- ...result.conflict,
29
- // Pre-column-version journal entries carry no conflictColumns.
30
- conflictColumns: result.conflict.conflictColumns ?? [],
31
- serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
32
- },
33
- };
66
+ }
67
+ if (result.status === 'conflict') {
68
+ const conflict = result.conflict;
69
+ if (!isRecord(conflict) ||
70
+ !isStoredRow(conflict.serverRow) ||
71
+ typeof conflict.clientCommitId !== 'string' ||
72
+ !Number.isInteger(conflict.opIndex) ||
73
+ conflict.opIndex < -2147483648 ||
74
+ conflict.opIndex > 2147483647 ||
75
+ typeof conflict.table !== 'string' ||
76
+ typeof conflict.rowId !== 'string' ||
77
+ typeof conflict.code !== 'string' ||
78
+ typeof conflict.message !== 'string' ||
79
+ (conflict.operation !== undefined &&
80
+ !isStoredOperation(conflict.operation)) ||
81
+ !Number.isSafeInteger(conflict.serverVersion) ||
82
+ (conflict.conflictColumns !== undefined &&
83
+ (!Array.isArray(conflict.conflictColumns) ||
84
+ !conflict.conflictColumns.every((column) => typeof column === 'string')))) {
85
+ corrupt();
86
+ }
87
+ return {
88
+ status: 'conflict',
89
+ conflict: {
90
+ ...conflict,
91
+ // Pre-column-version journal entries carry no conflictColumns.
92
+ conflictColumns: conflict.conflictColumns ?? [],
93
+ serverRow: mapRowValues(conflict.serverRow, jsonToRowValue),
94
+ },
95
+ };
96
+ }
97
+ if (result.status === 'error') {
98
+ const rejection = result.rejection;
99
+ if (!isRecord(rejection) ||
100
+ typeof rejection.clientCommitId !== 'string' ||
101
+ !Number.isInteger(rejection.opIndex) ||
102
+ rejection.opIndex < -2147483648 ||
103
+ rejection.opIndex > 2147483647 ||
104
+ typeof rejection.code !== 'string' ||
105
+ typeof rejection.message !== 'string' ||
106
+ typeof rejection.retryable !== 'boolean' ||
107
+ (rejection.operation !== undefined &&
108
+ !isStoredOperation(rejection.operation))) {
109
+ corrupt();
110
+ }
111
+ try {
112
+ const { details, ...fields } = rejection;
113
+ return {
114
+ status: 'error',
115
+ rejection: {
116
+ ...fields,
117
+ ...(details == null
118
+ ? {}
119
+ : { details: normalizeRejectionDetails(details) }),
120
+ },
121
+ };
122
+ }
123
+ catch {
124
+ corrupt();
125
+ }
126
+ }
127
+ corrupt();
34
128
  });
35
129
  }
36
- function parseOutcome(db, row) {
37
- const retainedRows = db
38
- .query(`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`, [row.client_commit_id])
39
- .map((retained) => {
40
- const decode = (value) => value === null
41
- ? null
42
- : Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
43
- return {
44
- table: retained.tbl,
45
- rowId: retained.id,
46
- localRow: decode(retained.intent),
47
- ...(retained.unique_conflicts === null
48
- ? {}
49
- : {
50
- uniqueConflicts: JSON.parse(retained.unique_conflicts).map((conflict) => ({
51
- ...conflict,
52
- serverRow: decode(JSON.stringify(conflict.serverRow)),
53
- })),
54
- }),
55
- serverRow: decode(retained.base),
56
- serverVersion: retained.version === null ? null : retained.version,
57
- };
58
- });
130
+ function parseOutcome(db, row, attachRetainedRows = true) {
131
+ const retainedRows = attachRetainedRows
132
+ ? db
133
+ .query(`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`, [row.client_commit_id])
134
+ .map((retained) => {
135
+ const decode = (value) => value === null
136
+ ? null
137
+ : Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
138
+ return {
139
+ table: retained.tbl,
140
+ rowId: retained.id,
141
+ localRow: decode(retained.intent),
142
+ ...(retained.unique_conflicts === null
143
+ ? {}
144
+ : {
145
+ uniqueConflicts: JSON.parse(retained.unique_conflicts).map((conflict) => ({
146
+ ...conflict,
147
+ serverRow: decode(JSON.stringify(conflict.serverRow)),
148
+ })),
149
+ }),
150
+ serverRow: decode(retained.base),
151
+ serverVersion: retained.version === null ? null : retained.version,
152
+ };
153
+ })
154
+ : [];
59
155
  return {
60
156
  sequence: row.seq,
61
157
  clientCommitId: row.client_commit_id,
@@ -93,6 +189,62 @@ export function commitOutcome(db, clientCommitId) {
93
189
  ])[0];
94
190
  return row === undefined ? undefined : parseOutcome(db, row);
95
191
  }
192
+ /**
193
+ * §7.5 persisted-journal view: the stored outcome WITHOUT the owner-derived
194
+ * `retainedRows` images. A malformed journal row (invalid status, resolution,
195
+ * results, or operations JSON) raises the same static `sync.local_corrupt` the
196
+ * native sidecar reports, never a raw `SyntaxError`.
197
+ */
198
+ export function persistedCommitOutcome(db, clientCommitId) {
199
+ const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
200
+ clientCommitId,
201
+ ])[0];
202
+ if (row === undefined)
203
+ return undefined;
204
+ function corrupt() {
205
+ throw new ClientSyncError('sync.local_corrupt', 'persisted commit outcome is invalid');
206
+ }
207
+ const status = row.status;
208
+ const resolution = row.resolution;
209
+ if ((status !== 'applied' &&
210
+ status !== 'cached' &&
211
+ status !== 'conflict' &&
212
+ status !== 'rejected') ||
213
+ (resolution !== 'active' &&
214
+ resolution !== 'resolved_keep_server' &&
215
+ resolution !== 'superseded' &&
216
+ resolution !== 'dismissed') ||
217
+ !Number.isSafeInteger(row.seq) ||
218
+ typeof row.client_commit_id !== 'string' ||
219
+ !Number.isSafeInteger(row.recorded_at_ms) ||
220
+ typeof row.results !== 'string' ||
221
+ (row.operations !== null && typeof row.operations !== 'string') ||
222
+ (row.resolved_at_ms !== null &&
223
+ !Number.isSafeInteger(row.resolved_at_ms)) ||
224
+ (row.replacement_client_commit_id !== null &&
225
+ typeof row.replacement_client_commit_id !== 'string')) {
226
+ corrupt();
227
+ }
228
+ try {
229
+ const outcome = parseOutcome(db, row, false);
230
+ const hasConflict = outcome.results.some((result) => result.status === 'conflict');
231
+ const hasError = outcome.results.some((result) => result.status === 'error');
232
+ const redacted = outcome.results.length === 0 && outcome.operations === undefined;
233
+ if (((status === 'applied' || status === 'cached') &&
234
+ (hasConflict || hasError)) ||
235
+ (status === 'conflict' && !(hasConflict || redacted)) ||
236
+ (status === 'rejected' && (hasConflict || !(hasError || redacted))))
237
+ corrupt();
238
+ if (outcome.operations !== undefined &&
239
+ (!Array.isArray(outcome.operations) ||
240
+ !outcome.operations.every(isStoredOperation)))
241
+ corrupt();
242
+ return outcome;
243
+ }
244
+ catch {
245
+ corrupt();
246
+ }
247
+ }
96
248
  export function listCommitOutcomes(db, query = {}) {
97
249
  const limit = query.limit;
98
250
  if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
package/dist/schema.d.ts CHANGED
@@ -159,6 +159,14 @@ export declare function recordToRowValues(table: CompiledClientTable, record: Re
159
159
  export type JsonRowValue = string | number | boolean | null | {
160
160
  readonly $bytes: string;
161
161
  };
162
+ /**
163
+ * The canonical §7.1 byte envelope: exactly one `$bytes` key holding a string
164
+ * of hexadecimal digit pairs. Uppercase digits are accepted; encoders emit
165
+ * lowercase. A malformed envelope is a caller error, never a value to coerce.
166
+ */
167
+ export declare function isBytesEnvelope(value: unknown): value is {
168
+ readonly $bytes: string;
169
+ };
162
170
  export declare function hexToBytes(hex: string): Uint8Array;
163
171
  export declare function rowValueToJson(value: RowValue): JsonRowValue;
164
172
  export declare function jsonToRowValue(value: JsonRowValue): RowValue;
package/dist/schema.js CHANGED
@@ -20,6 +20,15 @@ function invalidSchema(message) {
20
20
  throw new Error(message);
21
21
  }
22
22
  export function compileClientSchema(schema) {
23
+ // §7.4.2: the persisted marker is a canonical positive i32 and a replica
24
+ // refuses any requested version outside that range before it touches
25
+ // storage, so a client can never persist a marker it would reject on the
26
+ // next open.
27
+ if (!Number.isInteger(schema.version) ||
28
+ schema.version < 1 ||
29
+ schema.version > 2147483647) {
30
+ throw invalidRequest('generated schema version must be an integer in 1..=2147483647');
31
+ }
23
32
  const tables = new Map();
24
33
  const schemaObjectNames = new Set();
25
34
  for (const table of schema.tables) {
@@ -496,7 +505,13 @@ export function coerceSqlRepresentation(column, value) {
496
505
  switch (localColumnType(column)) {
497
506
  case 'boolean':
498
507
  return value === 0 ? false : value === 1 ? true : value;
499
- case 'integer':
508
+ case 'integer': {
509
+ const integer = typeof value === 'bigint' ? Number(value) : value;
510
+ if (typeof integer === 'number' && !Number.isSafeInteger(integer)) {
511
+ throw invalidRequest('integer value is outside the safe-integer range');
512
+ }
513
+ return integer;
514
+ }
500
515
  case 'float':
501
516
  return typeof value === 'bigint' ? Number(value) : value;
502
517
  default:
@@ -527,6 +542,19 @@ function bytesToHex(bytes) {
527
542
  out += byte.toString(16).padStart(2, '0');
528
543
  return out;
529
544
  }
545
+ /**
546
+ * The canonical §7.1 byte envelope: exactly one `$bytes` key holding a string
547
+ * of hexadecimal digit pairs. Uppercase digits are accepted; encoders emit
548
+ * lowercase. A malformed envelope is a caller error, never a value to coerce.
549
+ */
550
+ export function isBytesEnvelope(value) {
551
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
552
+ return false;
553
+ const record = value;
554
+ return (Object.keys(record).length === 1 &&
555
+ typeof record.$bytes === 'string' &&
556
+ /^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes));
557
+ }
530
558
  export function hexToBytes(hex) {
531
559
  const out = new Uint8Array(hex.length / 2);
532
560
  for (let i = 0; i < out.length; i++) {
@@ -541,6 +569,8 @@ export function rowValueToJson(value) {
541
569
  }
542
570
  export function jsonToRowValue(value) {
543
571
  if (typeof value === 'object' && value !== null) {
572
+ if (!isBytesEnvelope(value))
573
+ throw invalidRequest('an object value must be one $bytes key holding an even-length hexadecimal string');
544
574
  return hexToBytes(value.$bytes);
545
575
  }
546
576
  return value;
package/dist/state.js CHANGED
@@ -2,6 +2,7 @@ export const META_TABLE = '_syncular_meta';
2
2
  import { PENDING_EVICTIONS } from './window.js';
3
3
  import { WINDOWS_TABLE } from './window.js';
4
4
  export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
5
+ import { ClientSyncError } from './errors.js';
5
6
  export const LOCAL_REVISION_KEY = 'localRevision';
6
7
  const MAX_U64 = 18446744073709551615n;
7
8
  function rowToRecord(row) {
@@ -145,12 +146,12 @@ export function getLocalRevision(db) {
145
146
  const raw = getMeta(db, LOCAL_REVISION_KEY);
146
147
  if (raw === undefined)
147
148
  return 0n;
148
- if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
149
- throw new Error(`invalid persisted local revision ${JSON.stringify(raw)}`);
149
+ if (typeof raw !== 'string' || !/^(0|[1-9][0-9]*)$/.test(raw)) {
150
+ throw new ClientSyncError('sync.local_corrupt', 'persisted local revision is invalid');
150
151
  }
151
152
  const revision = BigInt(raw);
152
153
  if (revision > MAX_U64) {
153
- throw new Error(`persisted local revision exceeds u64: ${raw}`);
154
+ throw new ClientSyncError('sync.local_corrupt', 'persisted local revision is invalid');
154
155
  }
155
156
  return revision;
156
157
  }
@@ -161,7 +162,7 @@ export function getLocalRevision(db) {
161
162
  export function bumpLocalRevision(db) {
162
163
  const current = getLocalRevision(db);
163
164
  if (current === MAX_U64) {
164
- throw new Error('local revision exhausted u64');
165
+ throw new ClientSyncError('sync.local_corrupt', 'local revision exhausted u64');
165
166
  }
166
167
  const next = current + 1n;
167
168
  setMeta(db, LOCAL_REVISION_KEY, next.toString());
@@ -55,6 +55,14 @@ export function installSyncScheduler(client, options = {}) {
55
55
  backgroundReady = false;
56
56
  void client
57
57
  .syncUntilIdle(options.maxRounds)
58
+ .then((result) => {
59
+ if (result !== null &&
60
+ typeof result === 'object' &&
61
+ 'budgetExhausted' in result &&
62
+ result.budgetExhausted === true) {
63
+ queueImmediate();
64
+ }
65
+ })
58
66
  .catch((error) => {
59
67
  if (!stopped)
60
68
  report(error);
@@ -88,6 +88,8 @@ export function startSyncWorker(overrides = {}) {
88
88
  .then((summary) => {
89
89
  if (!closed)
90
90
  post({ t: 'event', event: { kind: 'synced', summary } });
91
+ if (summary.budgetExhausted)
92
+ consumeSyncIntent({ kind: 'interactive' });
91
93
  })
92
94
  .catch((error) => {
93
95
  if (!closed) {
@@ -302,6 +304,7 @@ export function startSyncWorker(overrides = {}) {
302
304
  'query',
303
305
  'authoritySnapshot',
304
306
  'querySnapshot',
307
+ 'snapshotRead',
305
308
  'statusSnapshot',
306
309
  'conflicts',
307
310
  'rejections',
@@ -25,7 +25,7 @@ import type { PromiseMethods } from './client.js';
25
25
  */
26
26
  import type { WakeReason } from '@syncular/core';
27
27
  import type { BlobRef, CachedBlob } from './blob.js';
28
- import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
28
+ import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
29
29
  import type { SqlRow, SqlValue } from './database.js';
30
30
  import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
31
31
  import type { EncryptionKeyringConfig } from './encryption.js';
@@ -244,10 +244,11 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
244
244
  discarded: boolean;
245
245
  }>;
246
246
  sync(): Promise<SyncSummary>;
247
- syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
247
+ syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
248
248
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
249
249
  authoritySnapshot(): Promise<import('./authority.js').AuthoritySnapshot>;
250
250
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
251
+ snapshotRead<Row = SqlRow>(spec: SnapshotReadRequest): Promise<SnapshotRead<Row>>;
251
252
  localRevision(): Promise<LocalRevision>;
252
253
  statusSnapshot(): Promise<SyncStatusSnapshot>;
253
254
  diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
@@ -340,6 +340,9 @@ export class SyncClientHandle {
340
340
  querySnapshot(spec) {
341
341
  return this.#call('querySnapshot', [spec]);
342
342
  }
343
+ snapshotRead(spec) {
344
+ return this.#call('snapshotRead', [spec]);
345
+ }
343
346
  localRevision() {
344
347
  return this.#call('localRevision', []);
345
348
  }
@@ -19,7 +19,7 @@ import type { SyncProgress } from './progress.js';
19
19
  */
20
20
  import type { WakeReason } from '@syncular/core';
21
21
  import type { BlobRef, CachedBlob } from './blob.js';
22
- import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
+ import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
23
23
  import type { SqlRow, SqlValue } from './database.js';
24
24
  import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics.js';
25
25
  import type { EncryptionKeyringConfig } from './encryption.js';
@@ -137,7 +137,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
137
137
  discarded: boolean;
138
138
  };
139
139
  sync(): Promise<SyncSummary>;
140
- syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
140
+ syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
141
141
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
142
142
  authoritySnapshot(): import('./authority.js').AuthoritySnapshot;
143
143
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.16",
3
+ "version": "0.32.0",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -97,10 +97,10 @@
97
97
  },
98
98
  "dependencies": {
99
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
100
- "@syncular/core": "0.30.16"
100
+ "@syncular/core": "0.32.0"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.30.16",
103
+ "@syncular/server": "0.32.0",
104
104
  "playwright": "1.63.0",
105
105
  "@electric-sql/pglite": "^0.5.4"
106
106
  }