@syncular/client 0.30.11 → 0.30.13

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/README.md CHANGED
@@ -18,6 +18,13 @@ Ordinary commits use wire version 1 until the caller supplies an acquired
18
18
  partition `logEpoch`. Set `logEpoch` after a restore rotation requires epoch
19
19
  validation.
20
20
 
21
+ Applied and cached acknowledgements drain the send queue while protected local
22
+ intent stays visible until the same row's server image arrives at that commit
23
+ sequence or later. Empty pulls and restart preserve it; later edits stack above
24
+ it. This behavior is automatic, independent of `retainFailedCommits`. The core
25
+ requests a following pull without relying on an origin realtime notification.
26
+ See [optimistic writes](https://syncular.dev/concepts-conflicts/#the-optimistic-outbox).
27
+
21
28
  ## Client-local FTS5 projections
22
29
 
23
30
  Generated schemas may attach `ftsIndexes` to a synced table. The
package/dist/apply.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
23
23
  * (`client.decrypt_failed`) aborts before any local write, so the commit
24
24
  * never half-applies.
25
25
  */
26
- export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: ApplyTransaction): Promise<void>;
26
+ export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => readonly string[] | undefined) => unknown): Promise<void>;
27
27
  /**
28
28
  * §5.2: the segment's column table must match the generated schema for
29
29
  * (table, schemaVersion) — order, names, types, nullability. A mismatch is
@@ -37,6 +37,10 @@ export declare function validateSegmentColumns(schema: CompiledClientSchema, tab
37
37
  * (`sync.scope_revoked`) when the table has no local mapping for a key:
38
38
  * precision or nothing, never clear-the-table.
39
39
  */
40
+ export declare function localScopePredicate(table: CompiledClientTable, effective: ScopeMap): {
41
+ sql: string;
42
+ params: string[];
43
+ } | undefined;
40
44
  export declare function deleteScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap): void;
41
45
  /**
42
46
  * §4.8 window eviction: delete rows matching a departing unit's effective
package/dist/apply.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { RETAINED_ROWS } from './failed-overlay.js';
1
2
  import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
2
3
  /**
3
4
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
@@ -6,7 +7,7 @@ import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
6
7
  */
7
8
  import { decodeRow, } from '@syncular/core';
8
9
  import { ClientSyncError, invalidRequest } from './errors.js';
9
- import { quoteIdent, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
10
+ import { quoteIdent, matchesLocalScopes, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
10
11
  function upsertSql(table) {
11
12
  const names = [
12
13
  ...table.columns.map((column) => quoteIdent(column.name)),
@@ -75,8 +76,14 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
75
76
  });
76
77
  }
77
78
  transaction(() => {
78
- restoreFailedBases(db, schema);
79
- for (const change of resolved) {
79
+ const rowKeys = frame.tables.some((name) => schema.tables.get(name)?.indexes.some((index) => index.unique))
80
+ ? undefined
81
+ : frame.changes.map((change) => JSON.stringify([frame.tables[change.tableIndex], change.rowId]));
82
+ restoreFailedBases(db, schema, false, rowKeys);
83
+ for (const [index, change] of resolved.entries()) {
84
+ const rowId = frame.changes[index].rowId;
85
+ db.exec("UPDATE _syncular_outbox_before_images SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM _syncular_outbox,json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)", [frame.commitSeq, change.table.name, rowId]);
86
+ db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
80
87
  if (change.op === 'delete') {
81
88
  deleteLocalRow(db, change.table, change.rowId);
82
89
  }
@@ -84,6 +91,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
84
91
  upsertLocalRow(db, change.table, change.values, change.rowVersion);
85
92
  }
86
93
  }
94
+ return rowKeys;
87
95
  });
88
96
  }
89
97
  /**
@@ -123,26 +131,31 @@ export function validateSegmentColumns(schema, table, segment) {
123
131
  * (`sync.scope_revoked`) when the table has no local mapping for a key:
124
132
  * precision or nothing, never clear-the-table.
125
133
  */
126
- export function deleteScopedRows(db, table, effective) {
134
+ export function localScopePredicate(table, effective) {
127
135
  const entries = Object.entries(effective);
128
- if (entries.length === 0)
136
+ if (!entries.length || entries.some(([, values]) => !values.length))
129
137
  return;
130
- const clauses = [];
131
138
  const params = [];
132
- for (const [variable, values] of entries) {
139
+ const sql = entries
140
+ .map(([variable, values]) => {
133
141
  const column = table.scopeColumnByVariable.get(variable);
134
- if (column === undefined) {
135
- throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§3.3 fail-closed)`);
136
- }
137
- if (values.length === 0)
138
- return;
139
- clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
142
+ if (column === undefined)
143
+ throw new ClientSyncError('sync.scope_revoked', 'table has no local scope-column mapping');
140
144
  params.push(...values);
141
- }
142
- for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params)) {
145
+ return `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`;
146
+ })
147
+ .join(' AND ');
148
+ return { sql, params };
149
+ }
150
+ export function deleteScopedRows(db, table, effective) {
151
+ const scope = localScopePredicate(table, effective);
152
+ if (!scope)
153
+ return;
154
+ const { sql, params } = scope;
155
+ for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`, params)) {
143
156
  retainedBaseWrite(db, table, String(row.id));
144
157
  }
145
- db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params);
158
+ db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
146
159
  }
147
160
  /**
148
161
  * §4.8 window eviction: delete rows matching a departing unit's effective
@@ -155,21 +168,10 @@ export function deleteScopedRows(db, table, effective) {
155
168
  * cache), which is automatic since the version column is per-row.
156
169
  */
157
170
  export function evictScopedRows(db, table, effective, pinnedRowIds) {
158
- const entries = Object.entries(effective);
159
- if (entries.length === 0)
171
+ const scope = localScopePredicate(table, effective);
172
+ if (!scope)
160
173
  return { remaining: false, deferred: false };
161
- const clauses = [];
162
- const params = [];
163
- for (const [variable, values] of entries) {
164
- const column = table.scopeColumnByVariable.get(variable);
165
- if (column === undefined) {
166
- throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`);
167
- }
168
- if (values.length === 0)
169
- return { remaining: false, deferred: false };
170
- clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
171
- params.push(...values);
172
- }
174
+ const { sql, params } = scope;
173
175
  const pk = quoteIdent(table.primaryKey);
174
176
  let pinnedClause = '';
175
177
  if (pinnedRowIds.size > 0) {
@@ -178,10 +180,16 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
178
180
  params.push(...ids);
179
181
  }
180
182
  const target = quoteIdent(table.name);
181
- db.exec(`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024)`, params);
182
- const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`, params).length > 0;
183
+ for (const row of db.query(`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL AND tbl=?`, [table.name])) {
184
+ if (!pinnedRowIds.has(row.id) &&
185
+ [row.intent, row.base].some((json) => json !== null &&
186
+ matchesLocalScopes(table, effective, JSON.parse(json))))
187
+ db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq IS NOT NULL`, [table.name, row.id]);
188
+ }
189
+ db.exec(`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1024)`, params);
190
+ const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1`, params).length > 0;
183
191
  const deferred = remaining ||
184
- db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
192
+ db.query(`SELECT 1 FROM ${target} WHERE ${sql} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
185
193
  return { remaining, deferred };
186
194
  }
187
195
  const IMAGE_ALIAS = 'syncular_image';
@@ -199,7 +207,7 @@ function imageInvalid(detail) {
199
207
  export async function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
200
208
  const withImage = db.withSqliteImage?.bind(db);
201
209
  if (withImage === undefined) {
202
- throw invalidRequest('received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)');
210
+ throw invalidRequest('database cannot import images; disable accept bit 2 (§4.2)');
203
211
  }
204
212
  if (descriptor.table !== table.name) {
205
213
  imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
@@ -339,12 +347,12 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
339
347
  if (!clearThisBlock && rows.length === 0)
340
348
  continue;
341
349
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
342
- restoreFailedBases(db, schema);
350
+ const protectedRows = restoreFailedBases(db, schema);
343
351
  if (clearThisBlock) {
344
352
  deleteScopedRows(db, table, options.effective);
345
353
  }
346
354
  for (const row of rows) {
347
- upsertLocalRow(db, table, row.values, row.serverVersion);
355
+ upsertLocalRow(db, table, row.values, row.serverVersion, protectedRows);
348
356
  applied += 1;
349
357
  if (applied % 1024 === 0)
350
358
  options.onProgress?.(applied);
package/dist/client.d.ts CHANGED
@@ -104,6 +104,7 @@ export interface SyncClientLimits {
104
104
  readonly outcomeRetentionMaxEntries?: number;
105
105
  }
106
106
  export interface SyncClientConfig {
107
+ readonly transportEnabled?: boolean;
107
108
  /** Keep rejected/conflicting local intent visible until explicit resolution. */
108
109
  readonly retainFailedCommits?: boolean;
109
110
  readonly database: ClientDatabase;
@@ -295,6 +296,8 @@ export declare class SyncClient {
295
296
  /** Acquire leadership, create local tables, resolve the clientId. */
296
297
  start(): Promise<void>;
297
298
  close(): Promise<void>;
299
+ /** Host-owned transport policy; the local replica remains available. */
300
+ setTransportEnabled(enabled: boolean): void;
298
301
  /** Current fail-closed local-replica security state. */
299
302
  securityLifecycle(): SecurityLifecycle;
300
303
  /**