@syncular/client 0.1.3 → 0.2.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/README.md +227 -709
- package/dist/apply.d.ts +80 -0
- package/dist/apply.js +301 -0
- package/dist/blob.d.ts +127 -0
- package/dist/blob.js +201 -0
- package/dist/bun-database.d.ts +22 -0
- package/dist/bun-database.js +61 -0
- package/dist/client.d.ts +320 -69
- package/dist/client.js +1627 -163
- package/dist/content-type.d.ts +2 -0
- package/dist/content-type.js +2 -0
- package/dist/database.d.ts +35 -80
- package/dist/database.js +32 -947
- package/dist/encryption.d.ts +47 -0
- package/dist/encryption.js +75 -0
- package/dist/errors.d.ts +8 -22
- package/dist/errors.js +10 -207
- package/dist/http.d.ts +32 -0
- package/dist/http.js +196 -0
- package/dist/index.d.ts +28 -16
- package/dist/index.js +25 -12
- package/dist/invalidation.d.ts +69 -0
- package/dist/invalidation.js +84 -0
- package/dist/leader-lock.d.ts +28 -0
- package/dist/leader-lock.js +38 -0
- package/dist/multi-tab.d.ts +134 -0
- package/dist/multi-tab.js +399 -0
- package/dist/node-database.d.ts +41 -0
- package/dist/node-database.js +133 -0
- package/dist/outbox.d.ts +56 -0
- package/dist/outbox.js +140 -0
- package/dist/schema.d.ts +107 -0
- package/dist/schema.js +267 -0
- package/dist/state.d.ts +40 -0
- package/dist/state.js +77 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +7 -0
- package/dist/wasm-database.d.ts +30 -0
- package/dist/wasm-database.js +170 -0
- package/dist/window.d.ts +64 -0
- package/dist/window.js +0 -0
- package/dist/worker-entry.d.ts +16 -2
- package/dist/worker-entry.js +300 -456
- package/dist/worker-host.d.ts +174 -0
- package/dist/worker-host.js +536 -0
- package/dist/worker-protocol.d.ts +173 -61
- package/dist/worker-protocol.js +7 -16
- package/package.json +45 -112
- package/src/apply.ts +435 -0
- package/src/blob.ts +333 -0
- package/src/bun-database.ts +75 -0
- package/src/client.ts +2247 -277
- package/src/content-type.ts +2 -0
- package/src/database.ts +63 -1389
- package/src/encryption.ts +123 -0
- package/src/errors.ts +11 -265
- package/src/http.ts +254 -0
- package/src/index.ts +25 -20
- package/src/invalidation.ts +128 -0
- package/src/leader-lock.ts +68 -0
- package/src/multi-tab.ts +550 -0
- package/src/node-database.ts +180 -0
- package/src/outbox.ts +206 -0
- package/src/schema.ts +397 -0
- package/src/state.ts +126 -0
- package/src/transport.ts +52 -0
- package/src/wasm-database.ts +279 -0
- package/src/window.ts +0 -0
- package/src/worker-entry.ts +391 -545
- package/src/worker-host.ts +800 -0
- package/src/worker-protocol.ts +204 -99
- package/dist/auth-leases.d.ts +0 -11
- package/dist/auth-leases.d.ts.map +0 -1
- package/dist/auth-leases.js +0 -140
- package/dist/auth-leases.js.map +0 -1
- package/dist/blob-limits.d.ts +0 -15
- package/dist/blob-limits.d.ts.map +0 -1
- package/dist/blob-limits.js +0 -66
- package/dist/blob-limits.js.map +0 -1
- package/dist/bridge-client.d.ts +0 -69
- package/dist/bridge-client.d.ts.map +0 -1
- package/dist/bridge-client.js +0 -282
- package/dist/bridge-client.js.map +0 -1
- package/dist/client-config.d.ts +0 -6
- package/dist/client-config.d.ts.map +0 -1
- package/dist/client-config.js +0 -18
- package/dist/client-config.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/command-history.d.ts +0 -35
- package/dist/command-history.d.ts.map +0 -1
- package/dist/command-history.js +0 -378
- package/dist/command-history.js.map +0 -1
- package/dist/console-diagnostics.d.ts +0 -33
- package/dist/console-diagnostics.d.ts.map +0 -1
- package/dist/console-diagnostics.js +0 -496
- package/dist/console-diagnostics.js.map +0 -1
- package/dist/crdt-yjs/index.d.ts +0 -4
- package/dist/crdt-yjs/index.d.ts.map +0 -1
- package/dist/crdt-yjs/index.js +0 -4
- package/dist/crdt-yjs/index.js.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
- package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.js +0 -284
- package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
- package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
- package/dist/database.d.ts.map +0 -1
- package/dist/database.js.map +0 -1
- package/dist/diagnostics.d.ts +0 -10
- package/dist/diagnostics.d.ts.map +0 -1
- package/dist/diagnostics.js +0 -83
- package/dist/diagnostics.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/generated-bridge.d.ts +0 -365
- package/dist/generated-bridge.d.ts.map +0 -1
- package/dist/generated-bridge.js +0 -250
- package/dist/generated-bridge.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mutations.d.ts +0 -72
- package/dist/mutations.d.ts.map +0 -1
- package/dist/mutations.js +0 -63
- package/dist/mutations.js.map +0 -1
- package/dist/network.d.ts +0 -3
- package/dist/network.d.ts.map +0 -1
- package/dist/network.js +0 -17
- package/dist/network.js.map +0 -1
- package/dist/react/index.d.ts +0 -169
- package/dist/react/index.d.ts.map +0 -1
- package/dist/react/index.js +0 -628
- package/dist/react/index.js.map +0 -1
- package/dist/react-native/index.d.ts +0 -35
- package/dist/react-native/index.d.ts.map +0 -1
- package/dist/react-native/index.js +0 -49
- package/dist/react-native/index.js.map +0 -1
- package/dist/runtime-contract.d.ts +0 -13
- package/dist/runtime-contract.d.ts.map +0 -1
- package/dist/runtime-contract.js +0 -24
- package/dist/runtime-contract.js.map +0 -1
- package/dist/rust-client.d.ts +0 -106
- package/dist/rust-client.d.ts.map +0 -1
- package/dist/rust-client.js +0 -938
- package/dist/rust-client.js.map +0 -1
- package/dist/sentry.d.ts +0 -35
- package/dist/sentry.d.ts.map +0 -1
- package/dist/sentry.js +0 -155
- package/dist/sentry.js.map +0 -1
- package/dist/sql-safety.d.ts +0 -3
- package/dist/sql-safety.d.ts.map +0 -1
- package/dist/sql-safety.js +0 -62
- package/dist/sql-safety.js.map +0 -1
- package/dist/syncular-runtime-artifacts.json +0 -61
- package/dist/tauri/index.d.ts +0 -35
- package/dist/tauri/index.d.ts.map +0 -1
- package/dist/tauri/index.js +0 -114
- package/dist/tauri/index.js.map +0 -1
- package/dist/types.d.ts +0 -1096
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/wasm/.syncular-wasm-profile +0 -1
- package/dist/wasm/syncular-runtime-artifact.json +0 -21
- package/dist/wasm/syncular.d.ts +0 -207
- package/dist/wasm/syncular.js +0 -2341
- package/dist/wasm/syncular_bg.wasm +0 -0
- package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
- package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
- package/dist/wasm-bindings/runtime-contract.js +0 -112
- package/dist/wasm-bindings/runtime-contract.js.map +0 -1
- package/dist/wasm-core/.syncular-wasm-profile +0 -1
- package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
- package/dist/wasm-core/syncular.d.ts +0 -162
- package/dist/wasm-core/syncular.js +0 -1847
- package/dist/wasm-core/syncular_bg.wasm +0 -0
- package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
- package/dist/wasm-perf/.syncular-wasm-profile +0 -1
- package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
- package/dist/wasm-perf/syncular.d.ts +0 -207
- package/dist/wasm-perf/syncular.js +0 -2341
- package/dist/wasm-perf/syncular_bg.wasm +0 -0
- package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-runtime.d.ts +0 -23
- package/dist/wasm-runtime.d.ts.map +0 -1
- package/dist/wasm-runtime.js +0 -69
- package/dist/wasm-runtime.js.map +0 -1
- package/dist/worker-client.d.ts +0 -123
- package/dist/worker-client.d.ts.map +0 -1
- package/dist/worker-client.js +0 -1735
- package/dist/worker-client.js.map +0 -1
- package/dist/worker-entry.d.ts.map +0 -1
- package/dist/worker-entry.js.map +0 -1
- package/dist/worker-protocol.d.ts.map +0 -1
- package/dist/worker-protocol.js.map +0 -1
- package/dist/worker-realtime.d.ts +0 -39
- package/dist/worker-realtime.d.ts.map +0 -1
- package/dist/worker-realtime.js +0 -677
- package/dist/worker-realtime.js.map +0 -1
- package/src/auth-leases.ts +0 -251
- package/src/blob-limits.ts +0 -98
- package/src/bridge-client.ts +0 -512
- package/src/client-config.ts +0 -29
- package/src/command-history.ts +0 -623
- package/src/console-diagnostics.ts +0 -617
- package/src/crdt-yjs/index.ts +0 -3
- package/src/crdt-yjs/webview-host-facade.ts +0 -477
- package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
- package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
- package/src/diagnostics.ts +0 -116
- package/src/generated-bridge.ts +0 -741
- package/src/mutations.ts +0 -168
- package/src/network.ts +0 -32
- package/src/react/index.ts +0 -1036
- package/src/react-native/index.ts +0 -152
- package/src/runtime-contract.ts +0 -48
- package/src/rust-client.ts +0 -1491
- package/src/sentry.ts +0 -215
- package/src/sql-safety.ts +0 -61
- package/src/tauri/index.ts +0 -211
- package/src/types.ts +0 -1397
- package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
- package/src/wasm-bindings/runtime-contract.ts +0 -158
- package/src/wasm-runtime.ts +0 -145
- package/src/worker-client.ts +0 -2289
- package/src/worker-realtime.ts +0 -843
package/dist/apply.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
3
|
+
* (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
|
|
4
|
+
* contract and the §5.6 first-page rule.
|
|
5
|
+
*/
|
|
6
|
+
import { type CommitFrame, type RowsSegment, type RowValue, type ScopeMap } from '@syncular/core';
|
|
7
|
+
import type { ClientDatabase } from './database.js';
|
|
8
|
+
import type { EncryptionConfig } from './encryption.js';
|
|
9
|
+
import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
|
|
10
|
+
export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number): void;
|
|
11
|
+
export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string): void;
|
|
12
|
+
/**
|
|
13
|
+
* Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
|
|
14
|
+
* Upserts land with `_sync_version = rowVersion`; deletes remove the row.
|
|
15
|
+
* Re-application is idempotent (§1.4 rule 5).
|
|
16
|
+
*
|
|
17
|
+
* §5.11: encrypted columns are decrypted here — the apply seam. Because
|
|
18
|
+
* WebCrypto is async and the local transaction is synchronous, every row is
|
|
19
|
+
* decoded AND decrypted first (outside the transaction), then the resolved
|
|
20
|
+
* plaintext rows are applied atomically. A decrypt failure
|
|
21
|
+
* (`client.decrypt_failed`) aborts before any local write, so the commit
|
|
22
|
+
* never half-applies.
|
|
23
|
+
*/
|
|
24
|
+
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* §5.2: the segment's column table must match the generated schema for
|
|
27
|
+
* (table, schemaVersion) — order, names, types, nullability. A mismatch is
|
|
28
|
+
* fatal (`sync.schema_mismatch`): the descriptor validates, never infers.
|
|
29
|
+
*/
|
|
30
|
+
export declare function validateSegmentColumns(schema: CompiledClientSchema, table: CompiledClientTable, segment: RowsSegment): void;
|
|
31
|
+
/**
|
|
32
|
+
* Scope-matched local delete (§3.3 purge / §5.6 first-page rule): delete
|
|
33
|
+
* rows whose generated local scope columns match `effective` — every key's
|
|
34
|
+
* column value must be in the key's value list. Fails closed
|
|
35
|
+
* (`sync.scope_revoked`) when the table has no local mapping for a key:
|
|
36
|
+
* precision or nothing, never clear-the-table.
|
|
37
|
+
*/
|
|
38
|
+
export declare function deleteScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap): void;
|
|
39
|
+
/**
|
|
40
|
+
* §4.8 window eviction: delete rows matching a departing unit's effective
|
|
41
|
+
* scopes (same local-scope-column rule and fail-closed clause as
|
|
42
|
+
* {@link deleteScopedRows}) EXCEPT rows whose primary key is in
|
|
43
|
+
* `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
|
|
44
|
+
* `true` iff any pinned row was left behind, so the caller knows to defer
|
|
45
|
+
* the rest of the eviction until the outbox drains. Also removes the
|
|
46
|
+
* evicted rows' `server_version` with them (E2 — no residual version
|
|
47
|
+
* cache), which is automatic since the version column is per-row.
|
|
48
|
+
*/
|
|
49
|
+
export declare function evictScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap, pinnedRowIds: ReadonlySet<string>): boolean;
|
|
50
|
+
/** Descriptor fields a sqlite image is validated against (§5.3). */
|
|
51
|
+
export interface SqliteSegmentDescriptor {
|
|
52
|
+
readonly table: string;
|
|
53
|
+
readonly rowCount: number;
|
|
54
|
+
readonly asOfCommitSeq: number;
|
|
55
|
+
readonly scopeDigest: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Apply a §5.3 sqlite-image segment in ONE local transaction: validate
|
|
59
|
+
* the in-file metadata against the descriptor, validate the data table's
|
|
60
|
+
* column names/order against the generated schema, run the §5.6
|
|
61
|
+
* first-page clear when fresh, then copy every row with a single
|
|
62
|
+
* `INSERT OR REPLACE … SELECT` — `_syncular_version` lands in
|
|
63
|
+
* `_sync_version` exactly like a rows segment's per-row `serverVersion`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function applySqliteSegment(db: ClientDatabase, schema: CompiledClientSchema, table: CompiledClientTable, bytes: Uint8Array, descriptor: SqliteSegmentDescriptor, options: {
|
|
66
|
+
readonly clearFirst: boolean;
|
|
67
|
+
readonly effective: ScopeMap;
|
|
68
|
+
}): number;
|
|
69
|
+
/**
|
|
70
|
+
* Apply a decoded rows segment: each block in one local transaction
|
|
71
|
+
* (§5.2/§1.4); `clearFirst` implements the §5.6 fresh-bootstrap first-page
|
|
72
|
+
* delete inside the first block's transaction. Each row record carries its
|
|
73
|
+
* `serverVersion` (§5.2), which lands in `_sync_version` exactly like a
|
|
74
|
+
* `COMMIT` change's `rowVersion` (§5.6) — bootstrapped rows seed §6.2
|
|
75
|
+
* `baseVersion` conflict detection immediately.
|
|
76
|
+
*/
|
|
77
|
+
export declare function applyRowsSegment(db: ClientDatabase, schema: CompiledClientSchema, table: CompiledClientTable, segment: RowsSegment, options: {
|
|
78
|
+
readonly clearFirst: boolean;
|
|
79
|
+
readonly effective: ScopeMap;
|
|
80
|
+
}, encryption?: EncryptionConfig): Promise<number>;
|
package/dist/apply.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
3
|
+
* (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
|
|
4
|
+
* contract and the §5.6 first-page rule.
|
|
5
|
+
*/
|
|
6
|
+
import { decodeRow, } from '@syncular/core';
|
|
7
|
+
import { ClientSyncError } from './errors.js';
|
|
8
|
+
import { quoteIdent, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
|
|
9
|
+
function upsertSql(table) {
|
|
10
|
+
const names = [
|
|
11
|
+
...table.columns.map((column) => quoteIdent(column.name)),
|
|
12
|
+
quoteIdent(SYNC_VERSION_COLUMN),
|
|
13
|
+
];
|
|
14
|
+
const placeholders = names.map(() => '?').join(', ');
|
|
15
|
+
return `INSERT OR REPLACE INTO ${quoteIdent(table.name)} (${names.join(', ')}) VALUES (${placeholders})`;
|
|
16
|
+
}
|
|
17
|
+
export function upsertLocalRow(db, table, values, syncVersion) {
|
|
18
|
+
db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
|
|
19
|
+
}
|
|
20
|
+
export function deleteLocalRow(db, table, rowId) {
|
|
21
|
+
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId]);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
|
|
25
|
+
* Upserts land with `_sync_version = rowVersion`; deletes remove the row.
|
|
26
|
+
* Re-application is idempotent (§1.4 rule 5).
|
|
27
|
+
*
|
|
28
|
+
* §5.11: encrypted columns are decrypted here — the apply seam. Because
|
|
29
|
+
* WebCrypto is async and the local transaction is synchronous, every row is
|
|
30
|
+
* decoded AND decrypted first (outside the transaction), then the resolved
|
|
31
|
+
* plaintext rows are applied atomically. A decrypt failure
|
|
32
|
+
* (`client.decrypt_failed`) aborts before any local write, so the commit
|
|
33
|
+
* never half-applies.
|
|
34
|
+
*/
|
|
35
|
+
export async function applyCommitFrame(db, schema, frame, encryption) {
|
|
36
|
+
const resolved = [];
|
|
37
|
+
for (const change of frame.changes) {
|
|
38
|
+
const tableName = frame.tables[change.tableIndex];
|
|
39
|
+
if (tableName === undefined) {
|
|
40
|
+
throw new ClientSyncError('sync.invalid_request', `COMMIT change tableIndex ${change.tableIndex} out of range`);
|
|
41
|
+
}
|
|
42
|
+
const table = schema.tables.get(tableName);
|
|
43
|
+
if (table === undefined) {
|
|
44
|
+
throw new ClientSyncError('sync.schema_mismatch', `COMMIT delivers unknown local table ${JSON.stringify(tableName)}`);
|
|
45
|
+
}
|
|
46
|
+
if (change.op === 'delete') {
|
|
47
|
+
resolved.push({ op: 'delete', table, rowId: change.rowId });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (change.row === undefined || change.rowVersion === undefined) {
|
|
51
|
+
throw new ClientSyncError('sync.invalid_request', 'upsert change without row payload');
|
|
52
|
+
}
|
|
53
|
+
let values = decodeRow(table.columns, change.row);
|
|
54
|
+
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
55
|
+
const { decryptRowValues } = await import('./encryption.js');
|
|
56
|
+
values = await decryptRowValues(encryption, table, values);
|
|
57
|
+
}
|
|
58
|
+
resolved.push({
|
|
59
|
+
op: 'upsert',
|
|
60
|
+
table,
|
|
61
|
+
values,
|
|
62
|
+
rowVersion: change.rowVersion,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
db.transaction(() => {
|
|
66
|
+
for (const change of resolved) {
|
|
67
|
+
if (change.op === 'delete') {
|
|
68
|
+
deleteLocalRow(db, change.table, change.rowId);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* §5.2: the segment's column table must match the generated schema for
|
|
78
|
+
* (table, schemaVersion) — order, names, types, nullability. A mismatch is
|
|
79
|
+
* fatal (`sync.schema_mismatch`): the descriptor validates, never infers.
|
|
80
|
+
*/
|
|
81
|
+
export function validateSegmentColumns(schema, table, segment) {
|
|
82
|
+
const mismatch = (detail) => {
|
|
83
|
+
throw new ClientSyncError('sync.schema_mismatch', `rows segment for ${JSON.stringify(segment.table)} does not match the generated schema: ${detail}`);
|
|
84
|
+
};
|
|
85
|
+
if (segment.table !== table.name) {
|
|
86
|
+
mismatch(`segment table ${JSON.stringify(segment.table)}`);
|
|
87
|
+
}
|
|
88
|
+
if (segment.schemaVersion !== schema.version) {
|
|
89
|
+
mismatch(`segment schemaVersion ${segment.schemaVersion}`);
|
|
90
|
+
}
|
|
91
|
+
if (segment.columns.length !== table.columns.length) {
|
|
92
|
+
mismatch(`column count ${segment.columns.length}`);
|
|
93
|
+
}
|
|
94
|
+
for (let i = 0; i < table.columns.length; i++) {
|
|
95
|
+
const expected = table.columns[i];
|
|
96
|
+
const actual = segment.columns[i];
|
|
97
|
+
if (expected === undefined || actual === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
if (expected.name !== actual.name ||
|
|
100
|
+
expected.type !== actual.type ||
|
|
101
|
+
expected.nullable !== actual.nullable) {
|
|
102
|
+
mismatch(`column ${i} (${actual.name})`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Scope-matched local delete (§3.3 purge / §5.6 first-page rule): delete
|
|
108
|
+
* rows whose generated local scope columns match `effective` — every key's
|
|
109
|
+
* column value must be in the key's value list. Fails closed
|
|
110
|
+
* (`sync.scope_revoked`) when the table has no local mapping for a key:
|
|
111
|
+
* precision or nothing, never clear-the-table.
|
|
112
|
+
*/
|
|
113
|
+
export function deleteScopedRows(db, table, effective) {
|
|
114
|
+
const entries = Object.entries(effective);
|
|
115
|
+
if (entries.length === 0)
|
|
116
|
+
return;
|
|
117
|
+
const clauses = [];
|
|
118
|
+
const params = [];
|
|
119
|
+
for (const [variable, values] of entries) {
|
|
120
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
121
|
+
if (column === undefined) {
|
|
122
|
+
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)`);
|
|
123
|
+
}
|
|
124
|
+
if (values.length === 0)
|
|
125
|
+
return;
|
|
126
|
+
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
127
|
+
params.push(...values);
|
|
128
|
+
}
|
|
129
|
+
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* §4.8 window eviction: delete rows matching a departing unit's effective
|
|
133
|
+
* scopes (same local-scope-column rule and fail-closed clause as
|
|
134
|
+
* {@link deleteScopedRows}) EXCEPT rows whose primary key is in
|
|
135
|
+
* `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
|
|
136
|
+
* `true` iff any pinned row was left behind, so the caller knows to defer
|
|
137
|
+
* the rest of the eviction until the outbox drains. Also removes the
|
|
138
|
+
* evicted rows' `server_version` with them (E2 — no residual version
|
|
139
|
+
* cache), which is automatic since the version column is per-row.
|
|
140
|
+
*/
|
|
141
|
+
export function evictScopedRows(db, table, effective, pinnedRowIds) {
|
|
142
|
+
const entries = Object.entries(effective);
|
|
143
|
+
if (entries.length === 0)
|
|
144
|
+
return false;
|
|
145
|
+
const clauses = [];
|
|
146
|
+
const params = [];
|
|
147
|
+
for (const [variable, values] of entries) {
|
|
148
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
149
|
+
if (column === undefined) {
|
|
150
|
+
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)`);
|
|
151
|
+
}
|
|
152
|
+
if (values.length === 0)
|
|
153
|
+
return false;
|
|
154
|
+
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
155
|
+
params.push(...values);
|
|
156
|
+
}
|
|
157
|
+
const pk = quoteIdent(table.primaryKey);
|
|
158
|
+
let pinnedClause = '';
|
|
159
|
+
if (pinnedRowIds.size > 0) {
|
|
160
|
+
const ids = [...pinnedRowIds];
|
|
161
|
+
pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
|
|
162
|
+
params.push(...ids);
|
|
163
|
+
}
|
|
164
|
+
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}${pinnedClause}`, params);
|
|
165
|
+
if (pinnedRowIds.size === 0)
|
|
166
|
+
return false;
|
|
167
|
+
// A pin still matters only if a pinned row actually falls inside this
|
|
168
|
+
// unit's effective scopes; check by re-selecting the survivors.
|
|
169
|
+
const survivors = db.query(`SELECT ${pk} AS pk FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params.slice(0, params.length - pinnedRowIds.size));
|
|
170
|
+
for (const row of survivors) {
|
|
171
|
+
if (pinnedRowIds.has(String(row.pk)))
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const IMAGE_ALIAS = 'syncular_image';
|
|
177
|
+
function imageInvalid(detail) {
|
|
178
|
+
throw new ClientSyncError('sync.invalid_request', `sqlite segment rejected: ${detail} (§5.3)`);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Apply a §5.3 sqlite-image segment in ONE local transaction: validate
|
|
182
|
+
* the in-file metadata against the descriptor, validate the data table's
|
|
183
|
+
* column names/order against the generated schema, run the §5.6
|
|
184
|
+
* first-page clear when fresh, then copy every row with a single
|
|
185
|
+
* `INSERT OR REPLACE … SELECT` — `_syncular_version` lands in
|
|
186
|
+
* `_sync_version` exactly like a rows segment's per-row `serverVersion`.
|
|
187
|
+
*/
|
|
188
|
+
export function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
|
|
189
|
+
const withImage = db.withSqliteImage?.bind(db);
|
|
190
|
+
if (withImage === undefined) {
|
|
191
|
+
throw new ClientSyncError('sync.invalid_request', 'received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)');
|
|
192
|
+
}
|
|
193
|
+
if (descriptor.table !== table.name) {
|
|
194
|
+
imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
|
|
195
|
+
}
|
|
196
|
+
return withImage(bytes, IMAGE_ALIAS, () => {
|
|
197
|
+
// 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
|
|
198
|
+
// SQLite database or lacks the metadata table fails right here.
|
|
199
|
+
let meta;
|
|
200
|
+
try {
|
|
201
|
+
meta = db.query(`SELECT format, "table" AS tbl, "schemaVersion" AS sv,
|
|
202
|
+
"asOfCommitSeq" AS pin, "scopeDigest" AS sd,
|
|
203
|
+
"rowCount" AS rc
|
|
204
|
+
FROM ${IMAGE_ALIAS}."_syncular_segment"`);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
imageInvalid('bytes are not a SQLite image with _syncular_segment');
|
|
208
|
+
}
|
|
209
|
+
const record = meta[0];
|
|
210
|
+
if (meta.length !== 1 || record === undefined) {
|
|
211
|
+
imageInvalid('_syncular_segment must contain exactly one row');
|
|
212
|
+
}
|
|
213
|
+
if (record.format !== 1)
|
|
214
|
+
imageInvalid(`format ${String(record.format)}`);
|
|
215
|
+
if (record.tbl !== table.name) {
|
|
216
|
+
imageInvalid(`image table ${String(record.tbl)}`);
|
|
217
|
+
}
|
|
218
|
+
if (Number(record.sv) !== schema.version) {
|
|
219
|
+
imageInvalid(`schemaVersion ${String(record.sv)}`);
|
|
220
|
+
}
|
|
221
|
+
if (Number(record.pin) !== descriptor.asOfCommitSeq) {
|
|
222
|
+
imageInvalid(`asOfCommitSeq ${String(record.pin)}`);
|
|
223
|
+
}
|
|
224
|
+
if (record.sd !== descriptor.scopeDigest) {
|
|
225
|
+
imageInvalid('scopeDigest mismatch');
|
|
226
|
+
}
|
|
227
|
+
if (Number(record.rc) !== descriptor.rowCount) {
|
|
228
|
+
imageInvalid(`rowCount ${String(record.rc)}`);
|
|
229
|
+
}
|
|
230
|
+
// 2. Column names and order vs the generated schema (§5.3 rule 3 —
|
|
231
|
+
// sync.schema_mismatch, the §5.2 rule specialized).
|
|
232
|
+
const info = db.query(`PRAGMA ${IMAGE_ALIAS}.table_info(${quoteIdent(table.name)})`);
|
|
233
|
+
const expected = [
|
|
234
|
+
...table.columns.map((column) => column.name),
|
|
235
|
+
'_syncular_version',
|
|
236
|
+
];
|
|
237
|
+
const actual = info.map((row) => String(row.name));
|
|
238
|
+
if (actual.length !== expected.length ||
|
|
239
|
+
expected.some((name, index) => actual[index] !== name)) {
|
|
240
|
+
throw new ClientSyncError('sync.schema_mismatch', `sqlite segment for ${JSON.stringify(table.name)} does not match the generated schema: columns [${actual.join(', ')}] (§5.3)`);
|
|
241
|
+
}
|
|
242
|
+
// 3. One transaction: fresh-bootstrap clear, then replace-or-upsert.
|
|
243
|
+
const names = table.columns.map((column) => quoteIdent(column.name));
|
|
244
|
+
return db.transaction(() => {
|
|
245
|
+
if (options.clearFirst) {
|
|
246
|
+
deleteScopedRows(db, table, options.effective);
|
|
247
|
+
}
|
|
248
|
+
db.exec(`INSERT OR REPLACE INTO ${quoteIdent(table.name)}
|
|
249
|
+
(${[...names, quoteIdent(SYNC_VERSION_COLUMN)].join(', ')})
|
|
250
|
+
SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
|
|
251
|
+
FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`);
|
|
252
|
+
const counted = db.query(`SELECT count(*) AS n FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`)[0];
|
|
253
|
+
const applied = Number(counted?.n ?? 0);
|
|
254
|
+
if (applied !== descriptor.rowCount) {
|
|
255
|
+
imageInvalid(`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`);
|
|
256
|
+
}
|
|
257
|
+
return applied;
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Apply a decoded rows segment: each block in one local transaction
|
|
263
|
+
* (§5.2/§1.4); `clearFirst` implements the §5.6 fresh-bootstrap first-page
|
|
264
|
+
* delete inside the first block's transaction. Each row record carries its
|
|
265
|
+
* `serverVersion` (§5.2), which lands in `_sync_version` exactly like a
|
|
266
|
+
* `COMMIT` change's `rowVersion` (§5.6) — bootstrapped rows seed §6.2
|
|
267
|
+
* `baseVersion` conflict detection immediately.
|
|
268
|
+
*/
|
|
269
|
+
export async function applyRowsSegment(db, schema, table, segment, options, encryption) {
|
|
270
|
+
validateSegmentColumns(schema, table, segment);
|
|
271
|
+
let applied = 0;
|
|
272
|
+
let first = true;
|
|
273
|
+
const blocks = segment.blocks.length > 0 ? segment.blocks : [[]];
|
|
274
|
+
for (const block of blocks) {
|
|
275
|
+
// §5.11: decrypt this block's rows before opening the sync transaction
|
|
276
|
+
// (WebCrypto is async; the SQLite transaction is not). Decrypt failure
|
|
277
|
+
// aborts before any write in this block.
|
|
278
|
+
const rows = [];
|
|
279
|
+
for (const row of block) {
|
|
280
|
+
const values = encryption !== undefined && table.hasEncryptedColumns
|
|
281
|
+
? await (async () => {
|
|
282
|
+
const { decryptRowValues } = await import('./encryption.js');
|
|
283
|
+
return decryptRowValues(encryption, table, row.values);
|
|
284
|
+
})()
|
|
285
|
+
: row.values;
|
|
286
|
+
rows.push({ values, serverVersion: row.serverVersion });
|
|
287
|
+
}
|
|
288
|
+
const clearThisBlock = first && options.clearFirst;
|
|
289
|
+
first = false;
|
|
290
|
+
db.transaction(() => {
|
|
291
|
+
if (clearThisBlock) {
|
|
292
|
+
deleteScopedRows(db, table, options.effective);
|
|
293
|
+
}
|
|
294
|
+
for (const row of rows) {
|
|
295
|
+
upsertLocalRow(db, table, row.values, row.serverVersion);
|
|
296
|
+
applied += 1;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return applied;
|
|
301
|
+
}
|
package/dist/blob.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side blob cache + transport (SPEC.md §5.9.7).
|
|
3
|
+
*
|
|
4
|
+
* Blob bytes are cached content-addressed by `blobId` and refcounted by the
|
|
5
|
+
* local rows whose `blob_ref` columns reference them (B1). The cache is
|
|
6
|
+
* derived from live-row references: after any apply/purge, refcounts are
|
|
7
|
+
* reconciled from the current `blob_ref` column contents, and a body whose
|
|
8
|
+
* only referencing rows were revocation-purged is deleted (B2, evicted ≠
|
|
9
|
+
* revoked). BlobRefs stay resolvable at any time (B3): the `blobId` in the
|
|
10
|
+
* row value is the whole download key. Pending uploads are tracked in the
|
|
11
|
+
* outbox-adjacent uploads table (B4) and flushed before push.
|
|
12
|
+
*/
|
|
13
|
+
import { type BlobRef, parseBlobRef, serializeBlobRef } from '@syncular/core';
|
|
14
|
+
import type { ClientDatabase } from './database.js';
|
|
15
|
+
import type { CompiledClientSchema } from './schema.js';
|
|
16
|
+
/**
|
|
17
|
+
* A blob download result (§5.9.5). The authorized endpoint either serves the
|
|
18
|
+
* bytes inline, or — when the host configured presigned URLs (always-issue) —
|
|
19
|
+
* returns a short-TTL `url` the client MUST fetch directly (no host auth),
|
|
20
|
+
* verify the content address on, and on failure re-request (never fall
|
|
21
|
+
* through). The client core routes on which arm is present.
|
|
22
|
+
*/
|
|
23
|
+
export type BlobDownloadResponse = {
|
|
24
|
+
readonly kind: 'bytes';
|
|
25
|
+
readonly bytes: Uint8Array;
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: 'url';
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly urlExpiresAtMs?: number;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* A presigned-upload grant (§5.9.3). Either a single PUT `url` the client uses
|
|
33
|
+
* direct-to-storage; or `present` (the blob already exists, skip the PUT); or
|
|
34
|
+
* `none` (no presigned-upload store — the client streams through the direct
|
|
35
|
+
* upload endpoint, a capability choice, not a fallback).
|
|
36
|
+
*/
|
|
37
|
+
export type BlobUploadGrant = {
|
|
38
|
+
readonly kind: 'url';
|
|
39
|
+
readonly url: string;
|
|
40
|
+
readonly urlExpiresAtMs?: number;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'present';
|
|
43
|
+
} | {
|
|
44
|
+
readonly kind: 'none';
|
|
45
|
+
};
|
|
46
|
+
/** The transport seam for blob upload/download (§5.9.3/§5.9.5). */
|
|
47
|
+
export interface BlobTransport {
|
|
48
|
+
/** `PUT <mount>/blobs/{blobId}` — host-authenticated direct upload (§5.9.3). */
|
|
49
|
+
upload(blobId: string, bytes: Uint8Array, mediaType?: string): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* `GET <mount>/blobs/{blobId}` — re-authorized (§5.9.5). Returns inline
|
|
52
|
+
* bytes, or a presigned `url` the client core fetches via `fetchUrl`.
|
|
53
|
+
*/
|
|
54
|
+
download(blobId: string): Promise<BlobDownloadResponse>;
|
|
55
|
+
/**
|
|
56
|
+
* §5.9.5 presigned-download fetch: a bare GET of the signed `url`. Present
|
|
57
|
+
* iff the transport can consume URLs. MUST attach NO host authentication —
|
|
58
|
+
* the URL is the entire grant (§5.4). Only called when `download` returned
|
|
59
|
+
* a `url` arm.
|
|
60
|
+
*/
|
|
61
|
+
fetchUrl?(url: string): Promise<Uint8Array>;
|
|
62
|
+
/**
|
|
63
|
+
* §5.9.3 presigned-upload grant: `POST /blobs/{blobId}/upload-grant` with
|
|
64
|
+
* the declared size. Present iff the transport supports the grant flow;
|
|
65
|
+
* absent ⇒ the client always streams through `upload`. A `url` grant is
|
|
66
|
+
* PUT via `uploadToUrl`.
|
|
67
|
+
*/
|
|
68
|
+
uploadGrant?(blobId: string, byteLength: number, mediaType?: string): Promise<BlobUploadGrant>;
|
|
69
|
+
/**
|
|
70
|
+
* §5.9.3 direct-to-storage PUT of the granted `url`. MUST attach NO host
|
|
71
|
+
* authentication — the presigned URL is the entire grant (§5.4). Only
|
|
72
|
+
* called when `uploadGrant` returned a `url` arm.
|
|
73
|
+
*/
|
|
74
|
+
uploadToUrl?(url: string, bytes: Uint8Array, mediaType?: string): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export interface CachedBlob {
|
|
77
|
+
readonly blobId: string;
|
|
78
|
+
readonly bytes: Uint8Array;
|
|
79
|
+
readonly byteLength: number;
|
|
80
|
+
readonly mediaType?: string;
|
|
81
|
+
}
|
|
82
|
+
/** `"sha256:" + hex` of the bytes — the content address (§5.9.1). */
|
|
83
|
+
export declare function computeBlobId(bytes: Uint8Array): Promise<string>;
|
|
84
|
+
export declare function ensureBlobSchema(db: ClientDatabase): void;
|
|
85
|
+
/** Put bytes into the content-addressed cache (idempotent); touches LRU. */
|
|
86
|
+
export declare function putCachedBlob(db: ClientDatabase, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): void;
|
|
87
|
+
export declare function getCachedBlob(db: ClientDatabase, blobId: string, nowMs?: number): CachedBlob | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* §5.9.7 B1 size cap + LRU eviction. When the sum of cached body sizes exceeds
|
|
90
|
+
* `maxBytes`, evict **zero-ref, non-pinned** bodies in least-recently-used
|
|
91
|
+
* order until back under the cap. NEVER evicts a referenced body (refcount > 0
|
|
92
|
+
* — it must stay resolvable without a re-download) nor a pending-upload-pinned
|
|
93
|
+
* body (its bytes are the only copy until push, B4). If every over-cap body is
|
|
94
|
+
* referenced or pinned, the cache stays over the cap (correctness beats the
|
|
95
|
+
* cap). Evicting a zero-ref body is always safe: B3 re-enables the fetch from
|
|
96
|
+
* any surviving `blob_ref` value. Returns the evicted blobIds.
|
|
97
|
+
*/
|
|
98
|
+
export declare function enforceBlobCacheCap(db: ClientDatabase, maxBytes: number): string[];
|
|
99
|
+
/** Record a pending upload (§5.9.7 B4); flushed before the next push. */
|
|
100
|
+
export declare function recordPendingUpload(db: ClientDatabase, blobId: string, nowMs: number, mediaType?: string): void;
|
|
101
|
+
export declare function listPendingUploads(db: ClientDatabase): {
|
|
102
|
+
blobId: string;
|
|
103
|
+
mediaType?: string;
|
|
104
|
+
}[];
|
|
105
|
+
export declare function clearPendingUpload(db: ClientDatabase, blobId: string): void;
|
|
106
|
+
/**
|
|
107
|
+
* All `blob_ref` column names per table (for refcount reconciliation).
|
|
108
|
+
* Blank result ⇒ the schema has no attachments; callers skip reconciliation.
|
|
109
|
+
*/
|
|
110
|
+
export declare function blobRefColumnsBySchema(schema: CompiledClientSchema): Map<string, string[]>;
|
|
111
|
+
export declare function schemaHasBlobs(schema: CompiledClientSchema): boolean;
|
|
112
|
+
/**
|
|
113
|
+
* §5.9.7 B1/B2: recompute cache refcounts from the current `blob_ref` column
|
|
114
|
+
* contents across all synced tables, then delete cache bodies that dropped
|
|
115
|
+
* to zero refs *and* have no pending upload (a pending upload pins its body,
|
|
116
|
+
* B4). Called after every apply/purge that may add or remove references.
|
|
117
|
+
*
|
|
118
|
+
* `deleteOrphans` distinguishes the two B2 transitions: revocation purge
|
|
119
|
+
* passes `true` (drop the now-unauthorized body); a benign apply passes
|
|
120
|
+
* `false` (retain zero-ref bodies as LRU cache entries — the shipped
|
|
121
|
+
* default). Bodies pinned by a pending upload are always retained.
|
|
122
|
+
*/
|
|
123
|
+
export declare function reconcileBlobRefcounts(db: ClientDatabase, schema: CompiledClientSchema, options?: {
|
|
124
|
+
readonly deleteOrphans?: boolean;
|
|
125
|
+
}): void;
|
|
126
|
+
export type { BlobRef };
|
|
127
|
+
export { parseBlobRef, serializeBlobRef };
|