@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/src/schema.ts
CHANGED
|
@@ -92,6 +92,10 @@ const ALLOWED_FTS_TOKENIZERS = new Set([
|
|
|
92
92
|
'trigram',
|
|
93
93
|
]);
|
|
94
94
|
|
|
95
|
+
function invalidSchema(message: string): never {
|
|
96
|
+
throw new Error(message);
|
|
97
|
+
}
|
|
98
|
+
|
|
95
99
|
export function compileClientSchema(
|
|
96
100
|
schema: ClientSchema,
|
|
97
101
|
): CompiledClientSchema {
|
|
@@ -100,10 +104,10 @@ export function compileClientSchema(
|
|
|
100
104
|
for (const table of schema.tables) {
|
|
101
105
|
const tableLabel = `table ${table.name}`;
|
|
102
106
|
if (tables.has(table.name)) {
|
|
103
|
-
|
|
107
|
+
invalidSchema(`duplicate table ${JSON.stringify(table.name)}`);
|
|
104
108
|
}
|
|
105
109
|
if (schemaObjectNames.has(table.name)) {
|
|
106
|
-
|
|
110
|
+
invalidSchema(
|
|
107
111
|
`table ${JSON.stringify(table.name)} conflicts with an index or FTS projection`,
|
|
108
112
|
);
|
|
109
113
|
}
|
|
@@ -111,7 +115,7 @@ export function compileClientSchema(
|
|
|
111
115
|
const columnIndex = new Map<string, number>();
|
|
112
116
|
table.columns.forEach((column, index) => {
|
|
113
117
|
if (columnIndex.has(column.name)) {
|
|
114
|
-
|
|
118
|
+
invalidSchema(
|
|
115
119
|
`${tableLabel}: duplicate column ${JSON.stringify(column.name)}`,
|
|
116
120
|
);
|
|
117
121
|
}
|
|
@@ -119,27 +123,23 @@ export function compileClientSchema(
|
|
|
119
123
|
});
|
|
120
124
|
const primaryKeyIndex = columnIndex.get(table.primaryKey);
|
|
121
125
|
if (primaryKeyIndex === undefined) {
|
|
122
|
-
|
|
126
|
+
invalidSchema(
|
|
123
127
|
`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
|
|
124
128
|
);
|
|
125
129
|
}
|
|
126
130
|
// §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
|
|
127
131
|
// string every implementation produces identically and local storage
|
|
128
132
|
// resolves without deferring to a SQLite build.
|
|
129
|
-
const primaryKeyColumn = table.columns[primaryKeyIndex];
|
|
133
|
+
const primaryKeyColumn = table.columns[primaryKeyIndex] as RowColumn;
|
|
130
134
|
if (
|
|
131
|
-
|
|
132
|
-
primaryKeyColumn.type !== 'string' &&
|
|
133
|
-
primaryKeyColumn.type !== 'integer' &&
|
|
134
|
-
primaryKeyColumn.type !== 'boolean' &&
|
|
135
|
-
primaryKeyColumn.type !== 'json'
|
|
135
|
+
!['string', 'integer', 'boolean', 'json'].includes(primaryKeyColumn.type)
|
|
136
136
|
) {
|
|
137
|
-
|
|
138
|
-
`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible
|
|
137
|
+
invalidSchema(
|
|
138
|
+
`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible type (§2.4)`,
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
if (table.scopes.length === 0) {
|
|
142
|
-
|
|
142
|
+
invalidSchema(
|
|
143
143
|
`${tableLabel}: every synced table declares at least one scope pattern (§3.1)`,
|
|
144
144
|
);
|
|
145
145
|
}
|
|
@@ -149,7 +149,7 @@ export function compileClientSchema(
|
|
|
149
149
|
const pattern = typeof spec === 'string' ? spec : spec.pattern;
|
|
150
150
|
const match = PATTERN_RE.exec(pattern);
|
|
151
151
|
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
152
|
-
|
|
152
|
+
invalidSchema(
|
|
153
153
|
`${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`,
|
|
154
154
|
);
|
|
155
155
|
}
|
|
@@ -157,13 +157,13 @@ export function compileClientSchema(
|
|
|
157
157
|
const variable = match[2];
|
|
158
158
|
const column = typeof spec === 'string' ? variable : spec.column;
|
|
159
159
|
if (!columnIndex.has(column)) {
|
|
160
|
-
|
|
160
|
+
invalidSchema(
|
|
161
161
|
`${tableLabel}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`,
|
|
162
162
|
);
|
|
163
163
|
}
|
|
164
164
|
const existing = scopeColumnByVariable.get(variable);
|
|
165
165
|
if (existing !== undefined && existing !== column) {
|
|
166
|
-
|
|
166
|
+
invalidSchema(
|
|
167
167
|
`${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`,
|
|
168
168
|
);
|
|
169
169
|
}
|
|
@@ -184,60 +184,34 @@ export function compileClientSchema(
|
|
|
184
184
|
});
|
|
185
185
|
for (const alias of ambiguous) columnIndexByCamel.delete(alias);
|
|
186
186
|
const indexes = table.indexes ?? [];
|
|
187
|
-
for (const index of indexes) {
|
|
188
|
-
if (schemaObjectNames.has(index.name)) {
|
|
189
|
-
throw new Error(
|
|
190
|
-
`${tableLabel}: index ${JSON.stringify(index.name)} conflicts with another schema object`,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
schemaObjectNames.add(index.name);
|
|
194
|
-
for (const column of index.columns) {
|
|
195
|
-
if (!columnIndex.has(column)) {
|
|
196
|
-
throw new Error(
|
|
197
|
-
`${tableLabel}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
187
|
const ftsIndexes = table.ftsIndexes ?? [];
|
|
203
|
-
for (const index of ftsIndexes) {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
);
|
|
209
|
-
}
|
|
188
|
+
for (const index of [...indexes, ...ftsIndexes]) {
|
|
189
|
+
const fts = 'tokenize' in index;
|
|
190
|
+
const label = `${tableLabel}: ${fts ? 'FTS projection' : 'index'} ${JSON.stringify(index.name)}`;
|
|
191
|
+
if (schemaObjectNames.has(index.name))
|
|
192
|
+
invalidSchema(`${label} conflicts with another schema object`);
|
|
210
193
|
schemaObjectNames.add(index.name);
|
|
211
|
-
if (index.columns.length
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
194
|
+
if (fts && (!index.columns.length || index.columns.length > 32))
|
|
195
|
+
invalidSchema(`${label} needs between 1 and 32 columns`);
|
|
196
|
+
const seen = new Set<string>();
|
|
197
|
+
for (const name of index.columns) {
|
|
198
|
+
if (fts && seen.has(name))
|
|
199
|
+
invalidSchema(`${label} repeats column ${JSON.stringify(name)}`);
|
|
200
|
+
seen.add(name);
|
|
201
|
+
const column = table.columns[columnIndex.get(name) ?? -1];
|
|
202
|
+
if (!column)
|
|
203
|
+
invalidSchema(
|
|
204
|
+
`${label} names unknown column ${JSON.stringify(name)}`,
|
|
219
205
|
);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
(candidate) => candidate.name === columnName,
|
|
224
|
-
);
|
|
225
|
-
if (column === undefined) {
|
|
226
|
-
throw new Error(
|
|
227
|
-
`${projectionLabel} names unknown column ${JSON.stringify(columnName)}`,
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
if (localColumnType(column) !== 'string') {
|
|
231
|
-
throw new Error(
|
|
232
|
-
`${projectionLabel} column ${JSON.stringify(columnName)} must have string type`,
|
|
206
|
+
if (fts && localColumnType(column) !== 'string')
|
|
207
|
+
invalidSchema(
|
|
208
|
+
`${label} column ${JSON.stringify(name)} must have string type`,
|
|
233
209
|
);
|
|
234
|
-
}
|
|
235
210
|
}
|
|
236
|
-
if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize))
|
|
237
|
-
|
|
238
|
-
`${
|
|
211
|
+
if (fts && !ALLOWED_FTS_TOKENIZERS.has(index.tokenize))
|
|
212
|
+
invalidSchema(
|
|
213
|
+
`${label} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`,
|
|
239
214
|
);
|
|
240
|
-
}
|
|
241
215
|
}
|
|
242
216
|
tables.set(table.name, {
|
|
243
217
|
name: table.name,
|
|
@@ -291,6 +265,21 @@ export function stripSyncColumns(rows: SqlRow[]): SqlRow[] {
|
|
|
291
265
|
});
|
|
292
266
|
}
|
|
293
267
|
|
|
268
|
+
export function matchesLocalScopes(
|
|
269
|
+
table: CompiledClientTable,
|
|
270
|
+
scopes: Readonly<Record<string, readonly string[]>>,
|
|
271
|
+
values: Readonly<Record<string, JsonRowValue>>,
|
|
272
|
+
): boolean {
|
|
273
|
+
const entries = Object.entries(scopes);
|
|
274
|
+
return (
|
|
275
|
+
entries.length > 0 &&
|
|
276
|
+
entries.every(([variable, allowed]) => {
|
|
277
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
278
|
+
return column !== undefined && allowed.includes(String(values[column]));
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
294
283
|
export function quoteIdent(name: string): string {
|
|
295
284
|
return `"${name.replaceAll('"', '""')}"`;
|
|
296
285
|
}
|
|
@@ -382,6 +371,9 @@ function createFtsProjection(
|
|
|
382
371
|
table: CompiledClientTable,
|
|
383
372
|
index: ClientFtsIndexSpec,
|
|
384
373
|
): void {
|
|
374
|
+
const projection = quoteIdent(index.name);
|
|
375
|
+
const sourceTable = quoteIdent(table.name);
|
|
376
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
385
377
|
const existed =
|
|
386
378
|
db.query(
|
|
387
379
|
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
|
|
@@ -390,7 +382,7 @@ function createFtsProjection(
|
|
|
390
382
|
const indexedColumns = index.columns.map(quoteIdent);
|
|
391
383
|
const tokenizer = index.tokenize.replaceAll("'", "''");
|
|
392
384
|
db.exec(
|
|
393
|
-
`CREATE VIRTUAL TABLE IF NOT EXISTS ${
|
|
385
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS ${projection} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`,
|
|
394
386
|
);
|
|
395
387
|
|
|
396
388
|
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
@@ -401,9 +393,9 @@ function createFtsProjection(
|
|
|
401
393
|
db.exec(
|
|
402
394
|
`CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`,
|
|
403
395
|
);
|
|
404
|
-
const sourceId = `CAST(${
|
|
405
|
-
const newSourceId = `CAST(new.${
|
|
406
|
-
const oldSourceId = `CAST(old.${
|
|
396
|
+
const sourceId = `CAST(${primaryKey} AS TEXT)`;
|
|
397
|
+
const newSourceId = `CAST(new.${primaryKey} AS TEXT)`;
|
|
398
|
+
const oldSourceId = `CAST(old.${primaryKey} AS TEXT)`;
|
|
407
399
|
const projectionColumns = [
|
|
408
400
|
quoteIdent(FTS_SOURCE_ID_COLUMN),
|
|
409
401
|
...indexedColumns,
|
|
@@ -413,10 +405,10 @@ function createFtsProjection(
|
|
|
413
405
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
414
406
|
].join(', ');
|
|
415
407
|
const deleteFor = (value: string) =>
|
|
416
|
-
`DELETE FROM ${
|
|
408
|
+
`DELETE FROM ${projection} WHERE rowid=(SELECT id FROM ${mapping} WHERE source_id=${value});DELETE FROM ${mapping} WHERE source_id=${value}`;
|
|
417
409
|
const deleteDisplaced = (select: string) =>
|
|
418
|
-
`DELETE FROM ${
|
|
419
|
-
const insertNew = `INSERT INTO ${mapping}(source_id)VALUES(${newSourceId});INSERT INTO ${
|
|
410
|
+
`DELETE FROM ${projection} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select}));DELETE FROM ${mapping} WHERE source_id IN (${select})`;
|
|
411
|
+
const insertNew = `INSERT INTO ${mapping}(source_id)VALUES(${newSourceId});INSERT INTO ${projection}(rowid,${projectionColumns})VALUES((SELECT id FROM ${mapping} WHERE source_id=${newSourceId}),${newValues})`;
|
|
420
412
|
|
|
421
413
|
// A clean insert cannot already have a projection row because the source
|
|
422
414
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
@@ -445,7 +437,7 @@ function createFtsProjection(
|
|
|
445
437
|
for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
|
|
446
438
|
db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
|
|
447
439
|
}
|
|
448
|
-
const replacementExists = `EXISTS (SELECT 1 FROM ${
|
|
440
|
+
const replacementExists = `EXISTS (SELECT 1 FROM ${sourceTable} WHERE ${primaryKey} = new.${primaryKey})`;
|
|
449
441
|
// Per secondary UNIQUE index: the different-PK rows about to be displaced
|
|
450
442
|
// via that unique key. `=` makes NULL unique values match nothing, which is
|
|
451
443
|
// exactly SQLite's unique-index semantics (NULLs never conflict).
|
|
@@ -454,7 +446,7 @@ function createFtsProjection(
|
|
|
454
446
|
const match = spec.columns
|
|
455
447
|
.map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
|
|
456
448
|
.join(' AND ');
|
|
457
|
-
return `SELECT ${sourceId} FROM ${
|
|
449
|
+
return `SELECT ${sourceId} FROM ${sourceTable} WHERE ${match} AND ${primaryKey} !=new.${primaryKey}`;
|
|
458
450
|
});
|
|
459
451
|
const insertGuardCondition = [
|
|
460
452
|
replacementExists,
|
|
@@ -472,7 +464,7 @@ function createFtsProjection(
|
|
|
472
464
|
when = '',
|
|
473
465
|
) =>
|
|
474
466
|
db.exec(
|
|
475
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${
|
|
467
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${sourceTable}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`,
|
|
476
468
|
);
|
|
477
469
|
createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
|
|
478
470
|
createTrigger('ai', 'AFTER INSERT', insertNew);
|
|
@@ -496,13 +488,13 @@ function createFtsProjection(
|
|
|
496
488
|
|
|
497
489
|
if (!existed) {
|
|
498
490
|
db.exec(
|
|
499
|
-
`INSERT INTO ${
|
|
491
|
+
`INSERT INTO ${projection}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${sourceTable}`,
|
|
500
492
|
);
|
|
501
493
|
}
|
|
502
494
|
if (!mapped || !existed) {
|
|
503
495
|
db.exec(`DELETE FROM ${mapping}`);
|
|
504
496
|
db.exec(
|
|
505
|
-
`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${
|
|
497
|
+
`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${projection}`,
|
|
506
498
|
);
|
|
507
499
|
}
|
|
508
500
|
}
|
|
@@ -558,6 +550,14 @@ export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
|
|
|
558
550
|
'_syncular_outbox_before_images',
|
|
559
551
|
'client_commit_id TEXT NOT NULL,op_index INTEGER NOT NULL,existed INTEGER NOT NULL CHECK(existed IN (0,1)),sync_version INTEGER,values_json TEXT,PRIMARY KEY(client_commit_id,op_index)',
|
|
560
552
|
);
|
|
553
|
+
if (
|
|
554
|
+
!db
|
|
555
|
+
.query('PRAGMA table_info(_syncular_outbox_before_images)')
|
|
556
|
+
.some((row) => row.name === 'delivery_seq')
|
|
557
|
+
)
|
|
558
|
+
db.exec(
|
|
559
|
+
'ALTER TABLE _syncular_outbox_before_images ADD COLUMN delivery_seq INTEGER',
|
|
560
|
+
);
|
|
561
561
|
createTable(
|
|
562
562
|
'_syncular_commit_outcomes',
|
|
563
563
|
"seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,status TEXT NOT NULL CHECK(status IN ('applied','cached','conflict','rejected')),recorded_at_ms INTEGER NOT NULL,results TEXT NOT NULL,operations TEXT,resolution TEXT NOT NULL DEFAULT 'active' CHECK(resolution IN ('active','resolved_keep_server','superseded','dismissed')),resolved_at_ms INTEGER,replacement_client_commit_id TEXT",
|
|
@@ -665,15 +665,8 @@ export function fromSqlValue(column: RowColumn, value: SqlValue): RowValue {
|
|
|
665
665
|
return value !== 0 && value !== false;
|
|
666
666
|
case 'integer':
|
|
667
667
|
return typeof value === 'bigint' ? Number(value) : (value as number);
|
|
668
|
-
|
|
669
|
-
return value as
|
|
670
|
-
case 'bytes':
|
|
671
|
-
case 'crdt':
|
|
672
|
-
return value as Uint8Array;
|
|
673
|
-
case 'string':
|
|
674
|
-
case 'json':
|
|
675
|
-
case 'blob_ref':
|
|
676
|
-
return value as string;
|
|
668
|
+
default:
|
|
669
|
+
return value as RowValue;
|
|
677
670
|
}
|
|
678
671
|
}
|
|
679
672
|
|
|
@@ -695,17 +688,17 @@ export function normalizeRecordKeys(
|
|
|
695
688
|
if (index === undefined) {
|
|
696
689
|
if (key.startsWith('_sync_')) {
|
|
697
690
|
throw invalidRequest(
|
|
698
|
-
`table ${table.name}: ${JSON.stringify(key)} is an internal sync column
|
|
691
|
+
`table ${table.name}: ${JSON.stringify(key)} is an internal sync column; omit it from mutation values`,
|
|
699
692
|
);
|
|
700
693
|
}
|
|
701
694
|
throw invalidRequest(
|
|
702
|
-
`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values
|
|
695
|
+
`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`,
|
|
703
696
|
);
|
|
704
697
|
}
|
|
705
698
|
const sqlName = (table.columns[index] as RowColumn).name;
|
|
706
699
|
if (normalized.has(sqlName)) {
|
|
707
700
|
throw invalidRequest(
|
|
708
|
-
`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values
|
|
701
|
+
`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values`,
|
|
709
702
|
);
|
|
710
703
|
}
|
|
711
704
|
normalized.set(sqlName, value);
|
package/src/worker-entry.ts
CHANGED
|
@@ -165,6 +165,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
165
165
|
if (
|
|
166
166
|
closed ||
|
|
167
167
|
client === undefined ||
|
|
168
|
+
offline ||
|
|
168
169
|
client.securityLifecycle() === 'preflight'
|
|
169
170
|
) {
|
|
170
171
|
return;
|
|
@@ -189,6 +190,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
189
190
|
!autoSync ||
|
|
190
191
|
closed ||
|
|
191
192
|
client === undefined ||
|
|
193
|
+
offline ||
|
|
192
194
|
client.securityLifecycle() === 'preflight' ||
|
|
193
195
|
intent.kind === 'none'
|
|
194
196
|
) {
|
|
@@ -235,41 +237,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
235
237
|
return client;
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
function assertOnline(): void {
|
|
239
|
-
if (offline) {
|
|
240
|
-
throw new ClientSyncError(
|
|
241
|
-
'sync.transport_failed',
|
|
242
|
-
'the worker transport is offline (setOffline(true))',
|
|
243
|
-
true,
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function gateOffline<T>(inner: (request: T) => Promise<Uint8Array>) {
|
|
249
|
-
return (request: T): Promise<Uint8Array> => {
|
|
250
|
-
assertOnline();
|
|
251
|
-
return inner(request);
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** Offline gate that preserves the §5.4 `fetchUrl` capability marker. */
|
|
256
|
-
function gateSegmentsOffline(inner: SegmentDownloader): SegmentDownloader {
|
|
257
|
-
const fetchUrl = inner.fetchUrl;
|
|
258
|
-
return Object.assign(gateOffline(inner), {
|
|
259
|
-
...(fetchUrl !== undefined
|
|
260
|
-
? {
|
|
261
|
-
fetchUrl: (
|
|
262
|
-
url: string,
|
|
263
|
-
onProgress?: (bytesReceived: number) => void,
|
|
264
|
-
) => {
|
|
265
|
-
assertOnline();
|
|
266
|
-
return fetchUrl(url, onProgress);
|
|
267
|
-
},
|
|
268
|
-
}
|
|
269
|
-
: {}),
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
240
|
async function init(config: WorkerInitConfig): Promise<WorkerInitResult> {
|
|
274
241
|
if (client !== undefined) {
|
|
275
242
|
throw new ClientSyncError(
|
|
@@ -308,11 +275,10 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
308
275
|
}
|
|
309
276
|
}
|
|
310
277
|
}
|
|
311
|
-
const transport =
|
|
278
|
+
const transport =
|
|
312
279
|
overrides.createTransport !== undefined
|
|
313
280
|
? overrides.createTransport(config)
|
|
314
|
-
: httpSyncTransport(config.endpoints.syncUrl, http)
|
|
315
|
-
);
|
|
281
|
+
: httpSyncTransport(config.endpoints.syncUrl, http);
|
|
316
282
|
const segments =
|
|
317
283
|
overrides.createSegments !== undefined
|
|
318
284
|
? overrides.createSegments(config)
|
|
@@ -329,22 +295,15 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
329
295
|
// `{clientId}` URL placeholder — start the client first, then attach
|
|
330
296
|
// the connector through a mutable slot read at connect time.
|
|
331
297
|
let realtimeConnector: RealtimeConnector | undefined;
|
|
298
|
+
offline = config.transportEnabled === false;
|
|
332
299
|
const started = new SyncClient({
|
|
300
|
+
transportEnabled: !offline,
|
|
333
301
|
database,
|
|
334
302
|
schema: config.schema,
|
|
335
303
|
transport,
|
|
336
|
-
...(segments !== undefined
|
|
337
|
-
? { segments: gateSegmentsOffline(segments) }
|
|
338
|
-
: {}),
|
|
304
|
+
...(segments !== undefined ? { segments } : {}),
|
|
339
305
|
...(blobs !== undefined ? { blobs } : {}),
|
|
340
306
|
realtime: (handlers) => {
|
|
341
|
-
if (offline) {
|
|
342
|
-
throw new ClientSyncError(
|
|
343
|
-
'sync.transport_failed',
|
|
344
|
-
'the worker realtime channel is offline (setOffline(true))',
|
|
345
|
-
true,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
307
|
if (realtimeConnector === undefined) {
|
|
349
308
|
throw new ClientSyncError(
|
|
350
309
|
'sync.invalid_request',
|
|
@@ -515,8 +474,12 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
515
474
|
},
|
|
516
475
|
setOffline: (value) => {
|
|
517
476
|
offline = value;
|
|
518
|
-
|
|
519
|
-
|
|
477
|
+
requireClient().setTransportEnabled(!value);
|
|
478
|
+
if (offline) {
|
|
479
|
+
if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
|
|
480
|
+
backgroundTimer = undefined;
|
|
481
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
482
|
+
} else if (client !== undefined) {
|
|
520
483
|
const snapshot = client.diagnosticsSnapshot();
|
|
521
484
|
post({
|
|
522
485
|
t: 'event',
|
package/src/worker-host.ts
CHANGED
|
@@ -194,6 +194,7 @@ export interface SyncClientHandleConfig {
|
|
|
194
194
|
readonly encryption?: EncryptionKeyringConfig;
|
|
195
195
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
196
196
|
readonly securityPreflight?: boolean;
|
|
197
|
+
readonly transportEnabled?: boolean;
|
|
197
198
|
readonly clientId?: string;
|
|
198
199
|
readonly limits?: SyncClientLimits;
|
|
199
200
|
readonly retainFailedCommits?: boolean;
|
|
@@ -947,6 +948,9 @@ function buildInitConfig(
|
|
|
947
948
|
...(config.encryption !== undefined
|
|
948
949
|
? { encryption: config.encryption }
|
|
949
950
|
: {}),
|
|
951
|
+
...(config.transportEnabled !== undefined
|
|
952
|
+
? { transportEnabled: config.transportEnabled }
|
|
953
|
+
: {}),
|
|
950
954
|
...(config.securityPreflight !== undefined
|
|
951
955
|
? { securityPreflight: config.securityPreflight }
|
|
952
956
|
: {}),
|
package/src/worker-protocol.ts
CHANGED
|
@@ -112,6 +112,7 @@ export interface WorkerInitConfig {
|
|
|
112
112
|
readonly encryption?: EncryptionKeyringConfig;
|
|
113
113
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
114
114
|
readonly securityPreflight?: boolean;
|
|
115
|
+
readonly transportEnabled?: boolean;
|
|
115
116
|
readonly clientId?: string;
|
|
116
117
|
readonly limits?: SyncClientLimits;
|
|
117
118
|
readonly retainFailedCommits?: boolean;
|