@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 +7 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +44 -36
- package/dist/client.d.ts +3 -0
- package/dist/client.js +205 -181
- package/dist/failed-overlay.d.ts +4 -3
- package/dist/failed-overlay.js +58 -43
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +14 -24
- package/dist/outcomes.js +6 -5
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +63 -72
- package/dist/worker-entry.js +14 -35
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/apply.ts +81 -50
- package/src/client.ts +263 -226
- package/src/failed-overlay.ts +68 -57
- package/src/outbox.ts +19 -28
- package/src/outcomes.ts +7 -6
- package/src/schema.ts +81 -88
- package/src/worker-entry.ts +13 -50
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
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?:
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
134
|
+
export function localScopePredicate(table, effective) {
|
|
127
135
|
const entries = Object.entries(effective);
|
|
128
|
-
if (entries.length
|
|
136
|
+
if (!entries.length || entries.some(([, values]) => !values.length))
|
|
129
137
|
return;
|
|
130
|
-
const clauses = [];
|
|
131
138
|
const params = [];
|
|
132
|
-
|
|
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',
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
159
|
-
if (
|
|
171
|
+
const scope = localScopePredicate(table, effective);
|
|
172
|
+
if (!scope)
|
|
160
173
|
return { remaining: false, deferred: false };
|
|
161
|
-
const
|
|
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.
|
|
182
|
-
|
|
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 ${
|
|
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('
|
|
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
|
/**
|