@syncular/client 0.1.2 → 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/outbox.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
|
|
3
|
+
* outbox rule): local mutations are persisted in a schema-agnostic JSON
|
|
4
|
+
* form and encoded with the *current* generated row codec only when a push
|
|
5
|
+
* request is built — a commit recorded under schema N replays after an
|
|
6
|
+
* upgrade to N+1 by re-encoding.
|
|
7
|
+
*/
|
|
8
|
+
import { encodeRow, } from '@syncular/core';
|
|
9
|
+
import { ClientSyncError } from './errors.js';
|
|
10
|
+
import { jsonToRowValue, } from './schema.js';
|
|
11
|
+
export function appendOutboxCommit(db, clientCommitId, operations, nowMs) {
|
|
12
|
+
if (operations.length === 0) {
|
|
13
|
+
throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
|
|
14
|
+
}
|
|
15
|
+
db.exec(`INSERT INTO _syncular_outbox(client_commit_id, created_at_ms, operations)
|
|
16
|
+
VALUES (?, ?, ?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
|
|
17
|
+
}
|
|
18
|
+
/** Pending commits in FIFO creation order (§7.1). */
|
|
19
|
+
export function listOutbox(db) {
|
|
20
|
+
return db
|
|
21
|
+
.query(`SELECT seq, client_commit_id, created_at_ms, operations
|
|
22
|
+
FROM _syncular_outbox ORDER BY seq ASC`)
|
|
23
|
+
.map((row) => ({
|
|
24
|
+
seq: row.seq,
|
|
25
|
+
clientCommitId: row.client_commit_id,
|
|
26
|
+
createdAtMs: row.created_at_ms,
|
|
27
|
+
operations: JSON.parse(row.operations),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
export function deleteOutboxCommit(db, clientCommitId) {
|
|
31
|
+
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
|
|
32
|
+
clientCommitId,
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* §7.4.4: after a schema bump, a persisted upsert may name a column the new
|
|
37
|
+
* schema no longer has. The value has nowhere to go and there is no
|
|
38
|
+
* migration — surface it as `sync.outbox_incompatible` (client-local, §10.3)
|
|
39
|
+
* so the caller can drop the commit through the rejection channel.
|
|
40
|
+
*/
|
|
41
|
+
export class OutboxEncodeError extends ClientSyncError {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super('sync.outbox_incompatible', message, false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function orderedValues(table, values) {
|
|
47
|
+
// Any persisted key that is not a column of the CURRENT schema means the
|
|
48
|
+
// bump removed (or renamed) it — the commit cannot be expressed now.
|
|
49
|
+
for (const key of Object.keys(values)) {
|
|
50
|
+
if (!table.columnIndex.has(key)) {
|
|
51
|
+
throw new OutboxEncodeError(`outbox commit references column ${JSON.stringify(key)} on ${JSON.stringify(table.name)}, which the current schema no longer has (§7.4.4)`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return table.columns.map((column) => {
|
|
55
|
+
const value = values[column.name];
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
return null;
|
|
58
|
+
return jsonToRowValue(value);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Encode one outbox commit as a `PUSH_COMMIT` frame with the current
|
|
63
|
+
* schema's row codec (§6.1). When `encryption` is configured, encrypted
|
|
64
|
+
* columns (§5.11) are encrypted here — the encode-at-send seam — before the
|
|
65
|
+
* row codec serializes them as ciphertext-envelope `bytes`. Async because
|
|
66
|
+
* WebCrypto is async.
|
|
67
|
+
*/
|
|
68
|
+
export async function encodeOutboxCommit(schema, commit, encryption) {
|
|
69
|
+
const operations = [];
|
|
70
|
+
for (const op of commit.operations) {
|
|
71
|
+
if (op.op === 'delete') {
|
|
72
|
+
operations.push({
|
|
73
|
+
table: op.table,
|
|
74
|
+
rowId: op.rowId,
|
|
75
|
+
op: 'delete',
|
|
76
|
+
...(op.baseVersion !== undefined
|
|
77
|
+
? { baseVersion: op.baseVersion }
|
|
78
|
+
: {}),
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const table = schema.tables.get(op.table);
|
|
83
|
+
if (table === undefined) {
|
|
84
|
+
// §7.4.4: the bump removed this table — the commit cannot be encoded.
|
|
85
|
+
throw new OutboxEncodeError(`outbox commit ${commit.clientCommitId} targets table ${JSON.stringify(op.table)}, which the current schema no longer has (§7.4.4)`);
|
|
86
|
+
}
|
|
87
|
+
if (op.values === undefined) {
|
|
88
|
+
throw new ClientSyncError('sync.invalid_request', `outbox upsert on ${op.table}/${op.rowId} has no values`);
|
|
89
|
+
}
|
|
90
|
+
let values = orderedValues(table, op.values);
|
|
91
|
+
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
92
|
+
// Lazy: opt-in E2EE never enters an encryption-free app's bundle.
|
|
93
|
+
const { encryptRowValues } = await import('./encryption.js');
|
|
94
|
+
values = await encryptRowValues(encryption, table, op.rowId, values);
|
|
95
|
+
}
|
|
96
|
+
operations.push({
|
|
97
|
+
table: op.table,
|
|
98
|
+
rowId: op.rowId,
|
|
99
|
+
op: 'upsert',
|
|
100
|
+
...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
|
|
101
|
+
payload: encodeRow(table.columns, values),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
type: 'PUSH_COMMIT',
|
|
106
|
+
clientCommitId: commit.clientCommitId,
|
|
107
|
+
operations,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* §3.3: drop pending commits that write into a revoked scope instead of
|
|
112
|
+
* replaying them into guaranteed rejections. A whole commit is dropped when
|
|
113
|
+
* any of its upserts provably lands in the revoked effective scopes —
|
|
114
|
+
* commits are atomic and their content is pinned by the idempotency key,
|
|
115
|
+
* so ops are never removed individually. Returns dropped commit ids.
|
|
116
|
+
*/
|
|
117
|
+
export function dropOutboxCommitsInScope(db, table, effective) {
|
|
118
|
+
const entries = Object.entries(effective);
|
|
119
|
+
if (entries.length === 0)
|
|
120
|
+
return [];
|
|
121
|
+
const dropped = [];
|
|
122
|
+
for (const commit of listOutbox(db)) {
|
|
123
|
+
const inScope = commit.operations.some((op) => {
|
|
124
|
+
if (op.table !== table.name || op.values === undefined)
|
|
125
|
+
return false;
|
|
126
|
+
return entries.every(([variable, values]) => {
|
|
127
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
128
|
+
if (column === undefined)
|
|
129
|
+
return false;
|
|
130
|
+
const value = op.values?.[column];
|
|
131
|
+
return typeof value === 'string' && values.includes(value);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
if (inScope) {
|
|
135
|
+
deleteOutboxCommit(db, commit.clientCommitId);
|
|
136
|
+
dropped.push(commit.clientCommitId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return dropped;
|
|
140
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client schema IR (SPEC.md §2.4, §3.1) — the same shape the server
|
|
3
|
+
* compiles, hand-written until codegen (B5) emits it. Drives local table
|
|
4
|
+
* DDL, the generated row codec, mutation helpers, and the §3.3 purge
|
|
5
|
+
* mapping (scope variable → local column).
|
|
6
|
+
*/
|
|
7
|
+
import type { RowColumn, RowValue } from '@syncular/core';
|
|
8
|
+
import type { ClientDatabase, SqlValue } from './database.js';
|
|
9
|
+
/** `'prefix:{variable}'` shorthand (column name = variable) or explicit. */
|
|
10
|
+
export type ScopePatternSpec = string | {
|
|
11
|
+
pattern: string;
|
|
12
|
+
column: string;
|
|
13
|
+
};
|
|
14
|
+
/** One local secondary index (the CREATE INDEX migration subset). */
|
|
15
|
+
export interface ClientIndexSpec {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly columns: readonly string[];
|
|
18
|
+
readonly unique: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ClientTableSchema {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
/** Columns in schema-IR declaration order (the row-codec order, §2.4). */
|
|
23
|
+
readonly columns: readonly RowColumn[];
|
|
24
|
+
readonly primaryKey: string;
|
|
25
|
+
/** Scope patterns (§3.1); the variable→column map feeds the §3.3 purge. */
|
|
26
|
+
readonly scopes: readonly ScopePatternSpec[];
|
|
27
|
+
/** Local secondary indexes; absent in the generated schema when a table
|
|
28
|
+
* declares none (typegen omits the key for index-free tables). */
|
|
29
|
+
readonly indexes?: readonly ClientIndexSpec[];
|
|
30
|
+
}
|
|
31
|
+
export interface ClientSchema {
|
|
32
|
+
readonly version: number;
|
|
33
|
+
readonly tables: readonly ClientTableSchema[];
|
|
34
|
+
}
|
|
35
|
+
export interface CompiledClientTable {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly columns: readonly RowColumn[];
|
|
38
|
+
readonly primaryKey: string;
|
|
39
|
+
readonly primaryKeyIndex: number;
|
|
40
|
+
readonly columnIndex: ReadonlyMap<string, number>;
|
|
41
|
+
/** Scope variable → local scope column (§3.3 purge mapping). */
|
|
42
|
+
readonly scopeColumnByVariable: ReadonlyMap<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Scope variable → the pattern's literal prefix (§3.1). A stored-scope
|
|
45
|
+
* value `v` for this variable has scope key `prefix:v` — the invalidation
|
|
46
|
+
* vocabulary (TODO 3.1 / DESIGN-eviction I2) and the delta-routing key.
|
|
47
|
+
*/
|
|
48
|
+
readonly scopePrefixByVariable: ReadonlyMap<string, string>;
|
|
49
|
+
/** Local secondary indexes to create on the mirror table (declaration
|
|
50
|
+
* order); empty when the table declares none. */
|
|
51
|
+
readonly indexes: readonly ClientIndexSpec[];
|
|
52
|
+
/** §5.11: true when any column is `encrypted`. Drives the encrypt/decrypt
|
|
53
|
+
* seam (skipped entirely when false) and the local-plaintext DDL. */
|
|
54
|
+
readonly hasEncryptedColumns: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface CompiledClientSchema {
|
|
57
|
+
readonly version: number;
|
|
58
|
+
readonly tables: ReadonlyMap<string, CompiledClientTable>;
|
|
59
|
+
}
|
|
60
|
+
export declare function compileClientSchema(schema: ClientSchema): CompiledClientSchema;
|
|
61
|
+
/**
|
|
62
|
+
* Hidden per-row column carrying the last known `server_version` (§2.2):
|
|
63
|
+
* `-1` = local optimistic row never confirmed by the server, `≥ 1` =
|
|
64
|
+
* version from a `COMMIT` change or a segment row record (§5.2/§5.6 —
|
|
65
|
+
* segment rows land with their real server version).
|
|
66
|
+
*/
|
|
67
|
+
export declare const SYNC_VERSION_COLUMN = "_sync_version";
|
|
68
|
+
/** `_sync_version` for optimistic rows the server has never confirmed. */
|
|
69
|
+
export declare const OPTIMISTIC_VERSION = -1;
|
|
70
|
+
export declare function quoteIdent(name: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* §5.11: the app-side type of a column for local (plaintext) storage. For an
|
|
73
|
+
* encrypted column this is `declaredType` — the local mirror stays plaintext,
|
|
74
|
+
* so it stores/reads the real value type, not the wire `bytes`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function localColumnType(column: RowColumn): RowColumn['type'];
|
|
77
|
+
/** §7.4.1 persisted local schema-version marker (`_syncular_meta` key). */
|
|
78
|
+
export declare const LOCAL_SCHEMA_VERSION_KEY = "localSchemaVersion";
|
|
79
|
+
/**
|
|
80
|
+
* Create the synced tables plus client bookkeeping tables (outbox,
|
|
81
|
+
* subscription state, meta). Idempotent.
|
|
82
|
+
*/
|
|
83
|
+
export declare function ensureLocalSchema(db: ClientDatabase, schema: CompiledClientSchema): void;
|
|
84
|
+
/**
|
|
85
|
+
* §7.4.3 reset: drop every synced local table (whatever the *previous*
|
|
86
|
+
* generated schema created — discovered from `sqlite_master`, since a
|
|
87
|
+
* bump may add/remove tables) and recreate the synced tables from the
|
|
88
|
+
* NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
|
|
89
|
+
* subscriptions, blob cache) are preserved. Caller owns the surrounding
|
|
90
|
+
* transaction and the subscription-state reset (state.ts).
|
|
91
|
+
*/
|
|
92
|
+
export declare function dropAndRecreateSyncedTables(db: ClientDatabase, schema: CompiledClientSchema): void;
|
|
93
|
+
/** RowValue → SQL bind value for the local mirror tables. */
|
|
94
|
+
export declare function toSqlValue(value: RowValue): SqlValue;
|
|
95
|
+
/** SQL cell → RowValue per the column's declared type. */
|
|
96
|
+
export declare function fromSqlValue(column: RowColumn, value: SqlValue): RowValue;
|
|
97
|
+
/**
|
|
98
|
+
* App-facing record → schema-ordered row values for the codec and the
|
|
99
|
+
* local mirror. Missing keys become NULL; unknown keys fail loud.
|
|
100
|
+
*/
|
|
101
|
+
export declare function recordToRowValues(table: CompiledClientTable, record: Readonly<Record<string, unknown>>): RowValue[];
|
|
102
|
+
export type JsonRowValue = string | number | boolean | null | {
|
|
103
|
+
readonly $bytes: string;
|
|
104
|
+
};
|
|
105
|
+
export declare function hexToBytes(hex: string): Uint8Array;
|
|
106
|
+
export declare function rowValueToJson(value: RowValue): JsonRowValue;
|
|
107
|
+
export declare function jsonToRowValue(value: JsonRowValue): RowValue;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ClientSyncError } from './errors.js';
|
|
2
|
+
const PATTERN_RE = /^([^{}]+):\{([^{}:]+)\}$/;
|
|
3
|
+
export function compileClientSchema(schema) {
|
|
4
|
+
const tables = new Map();
|
|
5
|
+
for (const table of schema.tables) {
|
|
6
|
+
if (tables.has(table.name)) {
|
|
7
|
+
throw new Error(`duplicate table ${JSON.stringify(table.name)}`);
|
|
8
|
+
}
|
|
9
|
+
const columnIndex = new Map();
|
|
10
|
+
table.columns.forEach((column, index) => {
|
|
11
|
+
if (columnIndex.has(column.name)) {
|
|
12
|
+
throw new Error(`table ${table.name}: duplicate column ${JSON.stringify(column.name)}`);
|
|
13
|
+
}
|
|
14
|
+
columnIndex.set(column.name, index);
|
|
15
|
+
});
|
|
16
|
+
const primaryKeyIndex = columnIndex.get(table.primaryKey);
|
|
17
|
+
if (primaryKeyIndex === undefined) {
|
|
18
|
+
throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
|
|
19
|
+
}
|
|
20
|
+
if (table.scopes.length === 0) {
|
|
21
|
+
throw new Error(`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`);
|
|
22
|
+
}
|
|
23
|
+
const scopeColumnByVariable = new Map();
|
|
24
|
+
const scopePrefixByVariable = new Map();
|
|
25
|
+
for (const spec of table.scopes) {
|
|
26
|
+
const pattern = typeof spec === 'string' ? spec : spec.pattern;
|
|
27
|
+
const match = PATTERN_RE.exec(pattern);
|
|
28
|
+
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
29
|
+
throw new Error(`table ${table.name}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`);
|
|
30
|
+
}
|
|
31
|
+
const prefix = match[1];
|
|
32
|
+
const variable = match[2];
|
|
33
|
+
const column = typeof spec === 'string' ? variable : spec.column;
|
|
34
|
+
if (!columnIndex.has(column)) {
|
|
35
|
+
throw new Error(`table ${table.name}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`);
|
|
36
|
+
}
|
|
37
|
+
const existing = scopeColumnByVariable.get(variable);
|
|
38
|
+
if (existing !== undefined && existing !== column) {
|
|
39
|
+
throw new Error(`table ${table.name}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`);
|
|
40
|
+
}
|
|
41
|
+
scopeColumnByVariable.set(variable, column);
|
|
42
|
+
scopePrefixByVariable.set(variable, prefix);
|
|
43
|
+
}
|
|
44
|
+
const indexes = table.indexes ?? [];
|
|
45
|
+
for (const index of indexes) {
|
|
46
|
+
for (const column of index.columns) {
|
|
47
|
+
if (!columnIndex.has(column)) {
|
|
48
|
+
throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
tables.set(table.name, {
|
|
53
|
+
name: table.name,
|
|
54
|
+
columns: table.columns,
|
|
55
|
+
primaryKey: table.primaryKey,
|
|
56
|
+
primaryKeyIndex,
|
|
57
|
+
columnIndex,
|
|
58
|
+
scopeColumnByVariable,
|
|
59
|
+
scopePrefixByVariable,
|
|
60
|
+
indexes,
|
|
61
|
+
hasEncryptedColumns: table.columns.some((c) => c.encrypted === true),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { version: schema.version, tables };
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Local DDL
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Hidden per-row column carrying the last known `server_version` (§2.2):
|
|
71
|
+
* `-1` = local optimistic row never confirmed by the server, `≥ 1` =
|
|
72
|
+
* version from a `COMMIT` change or a segment row record (§5.2/§5.6 —
|
|
73
|
+
* segment rows land with their real server version).
|
|
74
|
+
*/
|
|
75
|
+
export const SYNC_VERSION_COLUMN = '_sync_version';
|
|
76
|
+
/** `_sync_version` for optimistic rows the server has never confirmed. */
|
|
77
|
+
export const OPTIMISTIC_VERSION = -1;
|
|
78
|
+
export function quoteIdent(name) {
|
|
79
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* §5.11: the app-side type of a column for local (plaintext) storage. For an
|
|
83
|
+
* encrypted column this is `declaredType` — the local mirror stays plaintext,
|
|
84
|
+
* so it stores/reads the real value type, not the wire `bytes`.
|
|
85
|
+
*/
|
|
86
|
+
export function localColumnType(column) {
|
|
87
|
+
if (column.encrypted && column.declaredType !== undefined) {
|
|
88
|
+
return column.declaredType;
|
|
89
|
+
}
|
|
90
|
+
return column.type;
|
|
91
|
+
}
|
|
92
|
+
function sqlType(column) {
|
|
93
|
+
switch (localColumnType(column)) {
|
|
94
|
+
case 'string':
|
|
95
|
+
case 'json':
|
|
96
|
+
case 'blob_ref':
|
|
97
|
+
return 'TEXT';
|
|
98
|
+
case 'integer':
|
|
99
|
+
return 'INTEGER';
|
|
100
|
+
case 'float':
|
|
101
|
+
return 'REAL';
|
|
102
|
+
case 'boolean':
|
|
103
|
+
return 'INTEGER';
|
|
104
|
+
case 'bytes':
|
|
105
|
+
case 'crdt':
|
|
106
|
+
// §5.10: a crdt column stores its opaque bytes locally, exactly like
|
|
107
|
+
// a bytes column. The Y.Doc view is an app-level helper (§5.10.4).
|
|
108
|
+
return 'BLOB';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** §7.4.1 persisted local schema-version marker (`_syncular_meta` key). */
|
|
112
|
+
export const LOCAL_SCHEMA_VERSION_KEY = 'localSchemaVersion';
|
|
113
|
+
function createSyncedTable(db, table) {
|
|
114
|
+
const columns = table.columns.map((column) => {
|
|
115
|
+
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
116
|
+
const pk = column.name === table.primaryKey ? ' PRIMARY KEY' : '';
|
|
117
|
+
return `${quoteIdent(column.name)} ${sqlType(column)}${notNull}${pk}`;
|
|
118
|
+
});
|
|
119
|
+
columns.push(`${quoteIdent(SYNC_VERSION_COLUMN)} INTEGER NOT NULL DEFAULT 0`);
|
|
120
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${columns.join(', ')})`);
|
|
121
|
+
// Local secondary indexes (CREATE INDEX subset). Created here so both the
|
|
122
|
+
// initial ensureLocalSchema and the §7.4.3 drop-and-recreate reset path
|
|
123
|
+
// materialize them. IF NOT EXISTS keeps it idempotent; the DROP TABLE in the
|
|
124
|
+
// reset path already removed any stale index alongside its table.
|
|
125
|
+
for (const index of table.indexes) {
|
|
126
|
+
const unique = index.unique ? 'UNIQUE ' : '';
|
|
127
|
+
const cols = index.columns.map((c) => quoteIdent(c)).join(', ');
|
|
128
|
+
db.exec(`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)} (${cols})`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Create the synced tables plus client bookkeeping tables (outbox,
|
|
133
|
+
* subscription state, meta). Idempotent.
|
|
134
|
+
*/
|
|
135
|
+
export function ensureLocalSchema(db, schema) {
|
|
136
|
+
db.transaction(() => {
|
|
137
|
+
for (const table of schema.tables.values()) {
|
|
138
|
+
createSyncedTable(db, table);
|
|
139
|
+
}
|
|
140
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
141
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
142
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
|
|
143
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
144
|
+
client_commit_id TEXT NOT NULL UNIQUE,
|
|
145
|
+
created_at_ms INTEGER NOT NULL,
|
|
146
|
+
operations TEXT NOT NULL)`);
|
|
147
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
|
|
148
|
+
id TEXT PRIMARY KEY,
|
|
149
|
+
tbl TEXT NOT NULL,
|
|
150
|
+
requested_scopes TEXT NOT NULL,
|
|
151
|
+
params TEXT,
|
|
152
|
+
cursor INTEGER NOT NULL DEFAULT -1,
|
|
153
|
+
bootstrap_state TEXT,
|
|
154
|
+
effective_scopes TEXT,
|
|
155
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
156
|
+
reason_code TEXT)`);
|
|
157
|
+
// §4.8 window registry: which units (scope values) of a window base are
|
|
158
|
+
// live locally — the completeness oracle (I3) and the shrink driver
|
|
159
|
+
// (a unit's omission from the next pull unregisters it, §4.1).
|
|
160
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_windows(
|
|
161
|
+
base TEXT NOT NULL,
|
|
162
|
+
unit TEXT NOT NULL,
|
|
163
|
+
sub_id TEXT NOT NULL,
|
|
164
|
+
PRIMARY KEY (base, unit))`);
|
|
165
|
+
// §4.8 E1: units that left the window but still had outbox-pinned rows.
|
|
166
|
+
// Retried when the outbox drains; cancelled if the unit re-enters.
|
|
167
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict(
|
|
168
|
+
sub_id TEXT PRIMARY KEY,
|
|
169
|
+
tbl TEXT NOT NULL,
|
|
170
|
+
effective_scopes TEXT NOT NULL)`);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** Bookkeeping tables the schema-bump reset (§7.4.3) MUST NOT drop. */
|
|
174
|
+
const RESERVED_TABLE_PREFIX = '_syncular_';
|
|
175
|
+
/**
|
|
176
|
+
* §7.4.3 reset: drop every synced local table (whatever the *previous*
|
|
177
|
+
* generated schema created — discovered from `sqlite_master`, since a
|
|
178
|
+
* bump may add/remove tables) and recreate the synced tables from the
|
|
179
|
+
* NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
|
|
180
|
+
* subscriptions, blob cache) are preserved. Caller owns the surrounding
|
|
181
|
+
* transaction and the subscription-state reset (state.ts).
|
|
182
|
+
*/
|
|
183
|
+
export function dropAndRecreateSyncedTables(db, schema) {
|
|
184
|
+
const existing = db.query(`SELECT name FROM sqlite_master WHERE type = 'table'
|
|
185
|
+
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'
|
|
186
|
+
AND name NOT LIKE 'sqlite_%'`);
|
|
187
|
+
for (const row of existing) {
|
|
188
|
+
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
189
|
+
}
|
|
190
|
+
for (const table of schema.tables.values()) {
|
|
191
|
+
createSyncedTable(db, table);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Value conversion
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
/** RowValue → SQL bind value for the local mirror tables. */
|
|
198
|
+
export function toSqlValue(value) {
|
|
199
|
+
if (typeof value === 'boolean')
|
|
200
|
+
return value ? 1 : 0;
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
/** SQL cell → RowValue per the column's declared type. */
|
|
204
|
+
export function fromSqlValue(column, value) {
|
|
205
|
+
if (value === null)
|
|
206
|
+
return null;
|
|
207
|
+
switch (localColumnType(column)) {
|
|
208
|
+
case 'boolean':
|
|
209
|
+
return value !== 0 && value !== false;
|
|
210
|
+
case 'integer':
|
|
211
|
+
return typeof value === 'bigint' ? Number(value) : value;
|
|
212
|
+
case 'float':
|
|
213
|
+
return value;
|
|
214
|
+
case 'bytes':
|
|
215
|
+
case 'crdt':
|
|
216
|
+
return value;
|
|
217
|
+
case 'string':
|
|
218
|
+
case 'json':
|
|
219
|
+
case 'blob_ref':
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* App-facing record → schema-ordered row values for the codec and the
|
|
225
|
+
* local mirror. Missing keys become NULL; unknown keys fail loud.
|
|
226
|
+
*/
|
|
227
|
+
export function recordToRowValues(table, record) {
|
|
228
|
+
for (const key of Object.keys(record)) {
|
|
229
|
+
if (!table.columnIndex.has(key)) {
|
|
230
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return table.columns.map((column) => {
|
|
234
|
+
const value = record[column.name];
|
|
235
|
+
if (value === undefined || value === null) {
|
|
236
|
+
if (!column.nullable) {
|
|
237
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1 full-row payloads)`);
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
return value;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
function bytesToHex(bytes) {
|
|
245
|
+
let out = '';
|
|
246
|
+
for (const byte of bytes)
|
|
247
|
+
out += byte.toString(16).padStart(2, '0');
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
export function hexToBytes(hex) {
|
|
251
|
+
const out = new Uint8Array(hex.length / 2);
|
|
252
|
+
for (let i = 0; i < out.length; i++) {
|
|
253
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
export function rowValueToJson(value) {
|
|
258
|
+
if (value instanceof Uint8Array)
|
|
259
|
+
return { $bytes: bytesToHex(value) };
|
|
260
|
+
return value;
|
|
261
|
+
}
|
|
262
|
+
export function jsonToRowValue(value) {
|
|
263
|
+
if (typeof value === 'object' && value !== null) {
|
|
264
|
+
return hexToBytes(value.$bytes);
|
|
265
|
+
}
|
|
266
|
+
return value;
|
|
267
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable client sync state: per-subscription cursor, bootstrap resume
|
|
3
|
+
* token (round-tripped opaquely, §4.7), and the last-echoed effective
|
|
4
|
+
* scopes the §3.3 purge contract is keyed on — persisted per subscription
|
|
5
|
+
* exactly for that purpose.
|
|
6
|
+
*/
|
|
7
|
+
import type { ScopeMap } from '@syncular/core';
|
|
8
|
+
import type { ClientDatabase } from './database.js';
|
|
9
|
+
export type SubscriptionStatus = 'active' | 'revoked' | 'failed';
|
|
10
|
+
export interface SubscriptionRecord {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly table: string;
|
|
13
|
+
/** Requested scopes (§3.2), chosen by the app. */
|
|
14
|
+
readonly scopes: ScopeMap;
|
|
15
|
+
/** Host-opaque JSON params, preserved verbatim. */
|
|
16
|
+
readonly params?: string;
|
|
17
|
+
/** Last fully-applied commitSeq; -1 = never synced (§4.3). */
|
|
18
|
+
readonly cursor: number;
|
|
19
|
+
/** Opaque resume token from `SUB_END` (§4.7); present mid-bootstrap. */
|
|
20
|
+
readonly bootstrapState?: string;
|
|
21
|
+
/** Last effective scopes echoed while active (§3.3 purge key). */
|
|
22
|
+
readonly effectiveScopes?: ScopeMap;
|
|
23
|
+
readonly status: SubscriptionStatus;
|
|
24
|
+
/** §10 code when not active (`sync.scope_revoked`, …). */
|
|
25
|
+
readonly reasonCode?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function loadSubscriptions(db: ClientDatabase): SubscriptionRecord[];
|
|
28
|
+
export declare function getSubscription(db: ClientDatabase, id: string): SubscriptionRecord | undefined;
|
|
29
|
+
export declare function saveSubscription(db: ClientDatabase, record: SubscriptionRecord): void;
|
|
30
|
+
export declare function deleteSubscription(db: ClientDatabase, id: string): void;
|
|
31
|
+
/**
|
|
32
|
+
* §7.4.3 reset: keep every subscription REGISTRATION (id, table,
|
|
33
|
+
* requested scopes, params — the app's declared intent) but discard all
|
|
34
|
+
* synced state (cursor → -1, no resume token, no effective-scope map,
|
|
35
|
+
* status → active), so the next round fresh-bootstraps exactly the
|
|
36
|
+
* subscriptions the app still wants. Caller owns the transaction.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resetSubscriptionsForBump(db: ClientDatabase): void;
|
|
39
|
+
export declare function getMeta(db: ClientDatabase, key: string): string | undefined;
|
|
40
|
+
export declare function setMeta(db: ClientDatabase, key: string, value: string): void;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
function rowToRecord(row) {
|
|
2
|
+
return {
|
|
3
|
+
id: row.id,
|
|
4
|
+
table: row.tbl,
|
|
5
|
+
scopes: JSON.parse(row.requested_scopes),
|
|
6
|
+
...(row.params !== null ? { params: row.params } : {}),
|
|
7
|
+
cursor: row.cursor,
|
|
8
|
+
...(row.bootstrap_state !== null
|
|
9
|
+
? { bootstrapState: row.bootstrap_state }
|
|
10
|
+
: {}),
|
|
11
|
+
...(row.effective_scopes !== null
|
|
12
|
+
? {
|
|
13
|
+
effectiveScopes: JSON.parse(row.effective_scopes),
|
|
14
|
+
}
|
|
15
|
+
: {}),
|
|
16
|
+
status: row.status,
|
|
17
|
+
...(row.reason_code !== null
|
|
18
|
+
? { reasonCode: row.reason_code }
|
|
19
|
+
: {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function loadSubscriptions(db) {
|
|
23
|
+
return db
|
|
24
|
+
.query('SELECT * FROM _syncular_subscriptions ORDER BY rowid ASC')
|
|
25
|
+
.map(rowToRecord);
|
|
26
|
+
}
|
|
27
|
+
export function getSubscription(db, id) {
|
|
28
|
+
const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id = ?', [
|
|
29
|
+
id,
|
|
30
|
+
])[0];
|
|
31
|
+
return row === undefined ? undefined : rowToRecord(row);
|
|
32
|
+
}
|
|
33
|
+
export function saveSubscription(db, record) {
|
|
34
|
+
db.exec(`INSERT OR REPLACE INTO _syncular_subscriptions(
|
|
35
|
+
id, tbl, requested_scopes, params, cursor, bootstrap_state,
|
|
36
|
+
effective_scopes, status, reason_code)
|
|
37
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
38
|
+
record.id,
|
|
39
|
+
record.table,
|
|
40
|
+
JSON.stringify(record.scopes),
|
|
41
|
+
record.params ?? null,
|
|
42
|
+
record.cursor,
|
|
43
|
+
record.bootstrapState ?? null,
|
|
44
|
+
record.effectiveScopes === undefined
|
|
45
|
+
? null
|
|
46
|
+
: JSON.stringify(record.effectiveScopes),
|
|
47
|
+
record.status,
|
|
48
|
+
record.reasonCode ?? null,
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
export function deleteSubscription(db, id) {
|
|
52
|
+
db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* §7.4.3 reset: keep every subscription REGISTRATION (id, table,
|
|
56
|
+
* requested scopes, params — the app's declared intent) but discard all
|
|
57
|
+
* synced state (cursor → -1, no resume token, no effective-scope map,
|
|
58
|
+
* status → active), so the next round fresh-bootstraps exactly the
|
|
59
|
+
* subscriptions the app still wants. Caller owns the transaction.
|
|
60
|
+
*/
|
|
61
|
+
export function resetSubscriptionsForBump(db) {
|
|
62
|
+
db.exec(`UPDATE _syncular_subscriptions
|
|
63
|
+
SET cursor = -1, bootstrap_state = NULL, effective_scopes = NULL,
|
|
64
|
+
status = 'active', reason_code = NULL`);
|
|
65
|
+
}
|
|
66
|
+
export function getMeta(db, key) {
|
|
67
|
+
const row = db.query('SELECT value FROM _syncular_meta WHERE key = ?', [
|
|
68
|
+
key,
|
|
69
|
+
])[0];
|
|
70
|
+
return row === undefined ? undefined : row.value;
|
|
71
|
+
}
|
|
72
|
+
export function setMeta(db, key, value) {
|
|
73
|
+
db.exec('INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?, ?)', [
|
|
74
|
+
key,
|
|
75
|
+
value,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport seams (REVISE B3): request/response bytes, segment download,
|
|
3
|
+
* and the realtime attach surface matching §8's client side. Tests use
|
|
4
|
+
* loopback implementations that call the server library directly — the
|
|
5
|
+
* loopback doctrine; HTTP/WebSocket bindings live in `./http`.
|
|
6
|
+
*/
|
|
7
|
+
/** One combined push+pull round trip: SSP2 request bytes → response bytes. */
|
|
8
|
+
export type SyncTransport = (request: Uint8Array) => Promise<Uint8Array>;
|
|
9
|
+
export interface SegmentFetchRequest {
|
|
10
|
+
readonly segmentId: string;
|
|
11
|
+
readonly table: string;
|
|
12
|
+
/** Canonical JSON (§11.2) of the requested scope map (§5.5 header). */
|
|
13
|
+
readonly requestedScopesJson: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Fetch segment bytes from the direct endpoint (§5.5). `fetchUrl`, when
|
|
17
|
+
* present, is the §5.4 direct-URL capability: its presence makes the
|
|
18
|
+
* client advertise accept bit 3, and the client core then routes
|
|
19
|
+
* url-carrying descriptors through it — capability negotiation, never a
|
|
20
|
+
* fallback pair. A `fetchUrl` implementation MUST NOT attach host
|
|
21
|
+
* authentication (the URL is the entire grant, §5.4).
|
|
22
|
+
*/
|
|
23
|
+
export interface SegmentDownloader {
|
|
24
|
+
(request: SegmentFetchRequest): Promise<Uint8Array>;
|
|
25
|
+
readonly fetchUrl?: (url: string) => Promise<Uint8Array>;
|
|
26
|
+
}
|
|
27
|
+
export interface RealtimeHandlers {
|
|
28
|
+
/** JSON control frame (§8.1): hello / sync / heartbeat / unknown. */
|
|
29
|
+
onText(text: string): void;
|
|
30
|
+
/** Binary frame: channel tag byte + payload (§8.7) — a `0x00`-tagged
|
|
31
|
+
* standalone SSP2 response (delta) or a `0x01`-tagged round chunk. */
|
|
32
|
+
onBinary(bytes: Uint8Array): void;
|
|
33
|
+
onClose?(): void;
|
|
34
|
+
}
|
|
35
|
+
export interface RealtimeSocket {
|
|
36
|
+
/** Send a JSON control message (acks, §8.2). */
|
|
37
|
+
send(text: string): void;
|
|
38
|
+
/** Send one binary message (tagged round chunk, §8.7). The socket is
|
|
39
|
+
* the sync-round transport whenever it is connected — Direction
|
|
40
|
+
* decision 1: one loop, no fallback pair. */
|
|
41
|
+
sendBytes(bytes: Uint8Array): void;
|
|
42
|
+
close(): void;
|
|
43
|
+
}
|
|
44
|
+
export type RealtimeConnector = (handlers: RealtimeHandlers) => Promise<RealtimeSocket> | RealtimeSocket;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport seams (REVISE B3): request/response bytes, segment download,
|
|
3
|
+
* and the realtime attach surface matching §8's client side. Tests use
|
|
4
|
+
* loopback implementations that call the server library directly — the
|
|
5
|
+
* loopback doctrine; HTTP/WebSocket bindings live in `./http`.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|