@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/src/schema.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
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';
|
|
9
|
+
import { ClientSyncError } from './errors';
|
|
10
|
+
|
|
11
|
+
/** `'prefix:{variable}'` shorthand (column name = variable) or explicit. */
|
|
12
|
+
export type ScopePatternSpec = string | { pattern: string; 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
|
+
|
|
21
|
+
export interface ClientTableSchema {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** Columns in schema-IR declaration order (the row-codec order, §2.4). */
|
|
24
|
+
readonly columns: readonly RowColumn[];
|
|
25
|
+
readonly primaryKey: string;
|
|
26
|
+
/** Scope patterns (§3.1); the variable→column map feeds the §3.3 purge. */
|
|
27
|
+
readonly scopes: readonly ScopePatternSpec[];
|
|
28
|
+
/** Local secondary indexes; absent in the generated schema when a table
|
|
29
|
+
* declares none (typegen omits the key for index-free tables). */
|
|
30
|
+
readonly indexes?: readonly ClientIndexSpec[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ClientSchema {
|
|
34
|
+
readonly version: number;
|
|
35
|
+
readonly tables: readonly ClientTableSchema[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CompiledClientTable {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly columns: readonly RowColumn[];
|
|
41
|
+
readonly primaryKey: string;
|
|
42
|
+
readonly primaryKeyIndex: number;
|
|
43
|
+
readonly columnIndex: ReadonlyMap<string, number>;
|
|
44
|
+
/** Scope variable → local scope column (§3.3 purge mapping). */
|
|
45
|
+
readonly scopeColumnByVariable: ReadonlyMap<string, string>;
|
|
46
|
+
/**
|
|
47
|
+
* Scope variable → the pattern's literal prefix (§3.1). A stored-scope
|
|
48
|
+
* value `v` for this variable has scope key `prefix:v` — the invalidation
|
|
49
|
+
* vocabulary (TODO 3.1 / DESIGN-eviction I2) and the delta-routing key.
|
|
50
|
+
*/
|
|
51
|
+
readonly scopePrefixByVariable: ReadonlyMap<string, string>;
|
|
52
|
+
/** Local secondary indexes to create on the mirror table (declaration
|
|
53
|
+
* order); empty when the table declares none. */
|
|
54
|
+
readonly indexes: readonly ClientIndexSpec[];
|
|
55
|
+
/** §5.11: true when any column is `encrypted`. Drives the encrypt/decrypt
|
|
56
|
+
* seam (skipped entirely when false) and the local-plaintext DDL. */
|
|
57
|
+
readonly hasEncryptedColumns: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CompiledClientSchema {
|
|
61
|
+
readonly version: number;
|
|
62
|
+
readonly tables: ReadonlyMap<string, CompiledClientTable>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const PATTERN_RE = /^([^{}]+):\{([^{}:]+)\}$/;
|
|
66
|
+
|
|
67
|
+
export function compileClientSchema(
|
|
68
|
+
schema: ClientSchema,
|
|
69
|
+
): CompiledClientSchema {
|
|
70
|
+
const tables = new Map<string, CompiledClientTable>();
|
|
71
|
+
for (const table of schema.tables) {
|
|
72
|
+
if (tables.has(table.name)) {
|
|
73
|
+
throw new Error(`duplicate table ${JSON.stringify(table.name)}`);
|
|
74
|
+
}
|
|
75
|
+
const columnIndex = new Map<string, number>();
|
|
76
|
+
table.columns.forEach((column, index) => {
|
|
77
|
+
if (columnIndex.has(column.name)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`table ${table.name}: duplicate column ${JSON.stringify(column.name)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
columnIndex.set(column.name, index);
|
|
83
|
+
});
|
|
84
|
+
const primaryKeyIndex = columnIndex.get(table.primaryKey);
|
|
85
|
+
if (primaryKeyIndex === undefined) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (table.scopes.length === 0) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const scopeColumnByVariable = new Map<string, string>();
|
|
96
|
+
const scopePrefixByVariable = new Map<string, string>();
|
|
97
|
+
for (const spec of table.scopes) {
|
|
98
|
+
const pattern = typeof spec === 'string' ? spec : spec.pattern;
|
|
99
|
+
const match = PATTERN_RE.exec(pattern);
|
|
100
|
+
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`table ${table.name}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const prefix = match[1];
|
|
106
|
+
const variable = match[2];
|
|
107
|
+
const column = typeof spec === 'string' ? variable : spec.column;
|
|
108
|
+
if (!columnIndex.has(column)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`table ${table.name}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const existing = scopeColumnByVariable.get(variable);
|
|
114
|
+
if (existing !== undefined && existing !== column) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`table ${table.name}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
scopeColumnByVariable.set(variable, column);
|
|
120
|
+
scopePrefixByVariable.set(variable, prefix);
|
|
121
|
+
}
|
|
122
|
+
const indexes = table.indexes ?? [];
|
|
123
|
+
for (const index of indexes) {
|
|
124
|
+
for (const column of index.columns) {
|
|
125
|
+
if (!columnIndex.has(column)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
tables.set(table.name, {
|
|
133
|
+
name: table.name,
|
|
134
|
+
columns: table.columns,
|
|
135
|
+
primaryKey: table.primaryKey,
|
|
136
|
+
primaryKeyIndex,
|
|
137
|
+
columnIndex,
|
|
138
|
+
scopeColumnByVariable,
|
|
139
|
+
scopePrefixByVariable,
|
|
140
|
+
indexes,
|
|
141
|
+
hasEncryptedColumns: table.columns.some((c) => c.encrypted === true),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { version: schema.version, tables };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Local DDL
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Hidden per-row column carrying the last known `server_version` (§2.2):
|
|
153
|
+
* `-1` = local optimistic row never confirmed by the server, `≥ 1` =
|
|
154
|
+
* version from a `COMMIT` change or a segment row record (§5.2/§5.6 —
|
|
155
|
+
* segment rows land with their real server version).
|
|
156
|
+
*/
|
|
157
|
+
export const SYNC_VERSION_COLUMN = '_sync_version';
|
|
158
|
+
|
|
159
|
+
/** `_sync_version` for optimistic rows the server has never confirmed. */
|
|
160
|
+
export const OPTIMISTIC_VERSION = -1;
|
|
161
|
+
|
|
162
|
+
export function quoteIdent(name: string): string {
|
|
163
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* §5.11: the app-side type of a column for local (plaintext) storage. For an
|
|
168
|
+
* encrypted column this is `declaredType` — the local mirror stays plaintext,
|
|
169
|
+
* so it stores/reads the real value type, not the wire `bytes`.
|
|
170
|
+
*/
|
|
171
|
+
export function localColumnType(column: RowColumn): RowColumn['type'] {
|
|
172
|
+
if (column.encrypted && column.declaredType !== undefined) {
|
|
173
|
+
return column.declaredType;
|
|
174
|
+
}
|
|
175
|
+
return column.type;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sqlType(column: RowColumn): string {
|
|
179
|
+
switch (localColumnType(column)) {
|
|
180
|
+
case 'string':
|
|
181
|
+
case 'json':
|
|
182
|
+
case 'blob_ref':
|
|
183
|
+
return 'TEXT';
|
|
184
|
+
case 'integer':
|
|
185
|
+
return 'INTEGER';
|
|
186
|
+
case 'float':
|
|
187
|
+
return 'REAL';
|
|
188
|
+
case 'boolean':
|
|
189
|
+
return 'INTEGER';
|
|
190
|
+
case 'bytes':
|
|
191
|
+
case 'crdt':
|
|
192
|
+
// §5.10: a crdt column stores its opaque bytes locally, exactly like
|
|
193
|
+
// a bytes column. The Y.Doc view is an app-level helper (§5.10.4).
|
|
194
|
+
return 'BLOB';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** §7.4.1 persisted local schema-version marker (`_syncular_meta` key). */
|
|
199
|
+
export const LOCAL_SCHEMA_VERSION_KEY = 'localSchemaVersion';
|
|
200
|
+
|
|
201
|
+
function createSyncedTable(
|
|
202
|
+
db: ClientDatabase,
|
|
203
|
+
table: CompiledClientTable,
|
|
204
|
+
): void {
|
|
205
|
+
const columns = table.columns.map((column) => {
|
|
206
|
+
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
207
|
+
const pk = column.name === table.primaryKey ? ' PRIMARY KEY' : '';
|
|
208
|
+
return `${quoteIdent(column.name)} ${sqlType(column)}${notNull}${pk}`;
|
|
209
|
+
});
|
|
210
|
+
columns.push(`${quoteIdent(SYNC_VERSION_COLUMN)} INTEGER NOT NULL DEFAULT 0`);
|
|
211
|
+
db.exec(
|
|
212
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${columns.join(', ')})`,
|
|
213
|
+
);
|
|
214
|
+
// Local secondary indexes (CREATE INDEX subset). Created here so both the
|
|
215
|
+
// initial ensureLocalSchema and the §7.4.3 drop-and-recreate reset path
|
|
216
|
+
// materialize them. IF NOT EXISTS keeps it idempotent; the DROP TABLE in the
|
|
217
|
+
// reset path already removed any stale index alongside its table.
|
|
218
|
+
for (const index of table.indexes) {
|
|
219
|
+
const unique = index.unique ? 'UNIQUE ' : '';
|
|
220
|
+
const cols = index.columns.map((c) => quoteIdent(c)).join(', ');
|
|
221
|
+
db.exec(
|
|
222
|
+
`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)} (${cols})`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create the synced tables plus client bookkeeping tables (outbox,
|
|
229
|
+
* subscription state, meta). Idempotent.
|
|
230
|
+
*/
|
|
231
|
+
export function ensureLocalSchema(
|
|
232
|
+
db: ClientDatabase,
|
|
233
|
+
schema: CompiledClientSchema,
|
|
234
|
+
): void {
|
|
235
|
+
db.transaction(() => {
|
|
236
|
+
for (const table of schema.tables.values()) {
|
|
237
|
+
createSyncedTable(db, table);
|
|
238
|
+
}
|
|
239
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
240
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
241
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
|
|
242
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
243
|
+
client_commit_id TEXT NOT NULL UNIQUE,
|
|
244
|
+
created_at_ms INTEGER NOT NULL,
|
|
245
|
+
operations TEXT NOT NULL)`);
|
|
246
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
|
|
247
|
+
id TEXT PRIMARY KEY,
|
|
248
|
+
tbl TEXT NOT NULL,
|
|
249
|
+
requested_scopes TEXT NOT NULL,
|
|
250
|
+
params TEXT,
|
|
251
|
+
cursor INTEGER NOT NULL DEFAULT -1,
|
|
252
|
+
bootstrap_state TEXT,
|
|
253
|
+
effective_scopes TEXT,
|
|
254
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
255
|
+
reason_code TEXT)`);
|
|
256
|
+
// §4.8 window registry: which units (scope values) of a window base are
|
|
257
|
+
// live locally — the completeness oracle (I3) and the shrink driver
|
|
258
|
+
// (a unit's omission from the next pull unregisters it, §4.1).
|
|
259
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_windows(
|
|
260
|
+
base TEXT NOT NULL,
|
|
261
|
+
unit TEXT NOT NULL,
|
|
262
|
+
sub_id TEXT NOT NULL,
|
|
263
|
+
PRIMARY KEY (base, unit))`);
|
|
264
|
+
// §4.8 E1: units that left the window but still had outbox-pinned rows.
|
|
265
|
+
// Retried when the outbox drains; cancelled if the unit re-enters.
|
|
266
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict(
|
|
267
|
+
sub_id TEXT PRIMARY KEY,
|
|
268
|
+
tbl TEXT NOT NULL,
|
|
269
|
+
effective_scopes TEXT NOT NULL)`);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Bookkeeping tables the schema-bump reset (§7.4.3) MUST NOT drop. */
|
|
274
|
+
const RESERVED_TABLE_PREFIX = '_syncular_';
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* §7.4.3 reset: drop every synced local table (whatever the *previous*
|
|
278
|
+
* generated schema created — discovered from `sqlite_master`, since a
|
|
279
|
+
* bump may add/remove tables) and recreate the synced tables from the
|
|
280
|
+
* NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
|
|
281
|
+
* subscriptions, blob cache) are preserved. Caller owns the surrounding
|
|
282
|
+
* transaction and the subscription-state reset (state.ts).
|
|
283
|
+
*/
|
|
284
|
+
export function dropAndRecreateSyncedTables(
|
|
285
|
+
db: ClientDatabase,
|
|
286
|
+
schema: CompiledClientSchema,
|
|
287
|
+
): void {
|
|
288
|
+
const existing = db.query(
|
|
289
|
+
`SELECT name FROM sqlite_master WHERE type = 'table'
|
|
290
|
+
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'
|
|
291
|
+
AND name NOT LIKE 'sqlite_%'`,
|
|
292
|
+
);
|
|
293
|
+
for (const row of existing) {
|
|
294
|
+
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
295
|
+
}
|
|
296
|
+
for (const table of schema.tables.values()) {
|
|
297
|
+
createSyncedTable(db, table);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Value conversion
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/** RowValue → SQL bind value for the local mirror tables. */
|
|
306
|
+
export function toSqlValue(value: RowValue): SqlValue {
|
|
307
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** SQL cell → RowValue per the column's declared type. */
|
|
312
|
+
export function fromSqlValue(column: RowColumn, value: SqlValue): RowValue {
|
|
313
|
+
if (value === null) return null;
|
|
314
|
+
switch (localColumnType(column)) {
|
|
315
|
+
case 'boolean':
|
|
316
|
+
return value !== 0 && value !== false;
|
|
317
|
+
case 'integer':
|
|
318
|
+
return typeof value === 'bigint' ? Number(value) : (value as number);
|
|
319
|
+
case 'float':
|
|
320
|
+
return value as number;
|
|
321
|
+
case 'bytes':
|
|
322
|
+
case 'crdt':
|
|
323
|
+
return value as Uint8Array;
|
|
324
|
+
case 'string':
|
|
325
|
+
case 'json':
|
|
326
|
+
case 'blob_ref':
|
|
327
|
+
return value as string;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* App-facing record → schema-ordered row values for the codec and the
|
|
333
|
+
* local mirror. Missing keys become NULL; unknown keys fail loud.
|
|
334
|
+
*/
|
|
335
|
+
export function recordToRowValues(
|
|
336
|
+
table: CompiledClientTable,
|
|
337
|
+
record: Readonly<Record<string, unknown>>,
|
|
338
|
+
): RowValue[] {
|
|
339
|
+
for (const key of Object.keys(record)) {
|
|
340
|
+
if (!table.columnIndex.has(key)) {
|
|
341
|
+
throw new ClientSyncError(
|
|
342
|
+
'sync.invalid_request',
|
|
343
|
+
`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return table.columns.map((column) => {
|
|
348
|
+
const value = record[column.name];
|
|
349
|
+
if (value === undefined || value === null) {
|
|
350
|
+
if (!column.nullable) {
|
|
351
|
+
throw new ClientSyncError(
|
|
352
|
+
'sync.invalid_request',
|
|
353
|
+
`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1 full-row payloads)`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
return value as RowValue;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Schema-agnostic JSON form (outbox persistence — the §0 outbox rule)
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
export type JsonRowValue =
|
|
367
|
+
| string
|
|
368
|
+
| number
|
|
369
|
+
| boolean
|
|
370
|
+
| null
|
|
371
|
+
| { readonly $bytes: string };
|
|
372
|
+
|
|
373
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
374
|
+
let out = '';
|
|
375
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
380
|
+
const out = new Uint8Array(hex.length / 2);
|
|
381
|
+
for (let i = 0; i < out.length; i++) {
|
|
382
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function rowValueToJson(value: RowValue): JsonRowValue {
|
|
388
|
+
if (value instanceof Uint8Array) return { $bytes: bytesToHex(value) };
|
|
389
|
+
return value;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function jsonToRowValue(value: JsonRowValue): RowValue {
|
|
393
|
+
if (typeof value === 'object' && value !== null) {
|
|
394
|
+
return hexToBytes(value.$bytes);
|
|
395
|
+
}
|
|
396
|
+
return value;
|
|
397
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
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';
|
|
9
|
+
|
|
10
|
+
export type SubscriptionStatus = 'active' | 'revoked' | 'failed';
|
|
11
|
+
|
|
12
|
+
export interface SubscriptionRecord {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly table: string;
|
|
15
|
+
/** Requested scopes (§3.2), chosen by the app. */
|
|
16
|
+
readonly scopes: ScopeMap;
|
|
17
|
+
/** Host-opaque JSON params, preserved verbatim. */
|
|
18
|
+
readonly params?: string;
|
|
19
|
+
/** Last fully-applied commitSeq; -1 = never synced (§4.3). */
|
|
20
|
+
readonly cursor: number;
|
|
21
|
+
/** Opaque resume token from `SUB_END` (§4.7); present mid-bootstrap. */
|
|
22
|
+
readonly bootstrapState?: string;
|
|
23
|
+
/** Last effective scopes echoed while active (§3.3 purge key). */
|
|
24
|
+
readonly effectiveScopes?: ScopeMap;
|
|
25
|
+
readonly status: SubscriptionStatus;
|
|
26
|
+
/** §10 code when not active (`sync.scope_revoked`, …). */
|
|
27
|
+
readonly reasonCode?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rowToRecord(row: Record<string, unknown>): SubscriptionRecord {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id as string,
|
|
33
|
+
table: row.tbl as string,
|
|
34
|
+
scopes: JSON.parse(row.requested_scopes as string) as ScopeMap,
|
|
35
|
+
...(row.params !== null ? { params: row.params as string } : {}),
|
|
36
|
+
cursor: row.cursor as number,
|
|
37
|
+
...(row.bootstrap_state !== null
|
|
38
|
+
? { bootstrapState: row.bootstrap_state as string }
|
|
39
|
+
: {}),
|
|
40
|
+
...(row.effective_scopes !== null
|
|
41
|
+
? {
|
|
42
|
+
effectiveScopes: JSON.parse(
|
|
43
|
+
row.effective_scopes as string,
|
|
44
|
+
) as ScopeMap,
|
|
45
|
+
}
|
|
46
|
+
: {}),
|
|
47
|
+
status: row.status as SubscriptionStatus,
|
|
48
|
+
...(row.reason_code !== null
|
|
49
|
+
? { reasonCode: row.reason_code as string }
|
|
50
|
+
: {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadSubscriptions(db: ClientDatabase): SubscriptionRecord[] {
|
|
55
|
+
return db
|
|
56
|
+
.query('SELECT * FROM _syncular_subscriptions ORDER BY rowid ASC')
|
|
57
|
+
.map(rowToRecord);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getSubscription(
|
|
61
|
+
db: ClientDatabase,
|
|
62
|
+
id: string,
|
|
63
|
+
): SubscriptionRecord | undefined {
|
|
64
|
+
const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id = ?', [
|
|
65
|
+
id,
|
|
66
|
+
])[0];
|
|
67
|
+
return row === undefined ? undefined : rowToRecord(row);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function saveSubscription(
|
|
71
|
+
db: ClientDatabase,
|
|
72
|
+
record: SubscriptionRecord,
|
|
73
|
+
): void {
|
|
74
|
+
db.exec(
|
|
75
|
+
`INSERT OR REPLACE INTO _syncular_subscriptions(
|
|
76
|
+
id, tbl, requested_scopes, params, cursor, bootstrap_state,
|
|
77
|
+
effective_scopes, status, reason_code)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
79
|
+
[
|
|
80
|
+
record.id,
|
|
81
|
+
record.table,
|
|
82
|
+
JSON.stringify(record.scopes),
|
|
83
|
+
record.params ?? null,
|
|
84
|
+
record.cursor,
|
|
85
|
+
record.bootstrapState ?? null,
|
|
86
|
+
record.effectiveScopes === undefined
|
|
87
|
+
? null
|
|
88
|
+
: JSON.stringify(record.effectiveScopes),
|
|
89
|
+
record.status,
|
|
90
|
+
record.reasonCode ?? null,
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function deleteSubscription(db: ClientDatabase, id: string): void {
|
|
96
|
+
db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* §7.4.3 reset: keep every subscription REGISTRATION (id, table,
|
|
101
|
+
* requested scopes, params — the app's declared intent) but discard all
|
|
102
|
+
* synced state (cursor → -1, no resume token, no effective-scope map,
|
|
103
|
+
* status → active), so the next round fresh-bootstraps exactly the
|
|
104
|
+
* subscriptions the app still wants. Caller owns the transaction.
|
|
105
|
+
*/
|
|
106
|
+
export function resetSubscriptionsForBump(db: ClientDatabase): void {
|
|
107
|
+
db.exec(
|
|
108
|
+
`UPDATE _syncular_subscriptions
|
|
109
|
+
SET cursor = -1, bootstrap_state = NULL, effective_scopes = NULL,
|
|
110
|
+
status = 'active', reason_code = NULL`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getMeta(db: ClientDatabase, key: string): string | undefined {
|
|
115
|
+
const row = db.query('SELECT value FROM _syncular_meta WHERE key = ?', [
|
|
116
|
+
key,
|
|
117
|
+
])[0];
|
|
118
|
+
return row === undefined ? undefined : (row.value as string);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function setMeta(db: ClientDatabase, key: string, value: string): void {
|
|
122
|
+
db.exec('INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?, ?)', [
|
|
123
|
+
key,
|
|
124
|
+
value,
|
|
125
|
+
]);
|
|
126
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
|
|
8
|
+
/** One combined push+pull round trip: SSP2 request bytes → response bytes. */
|
|
9
|
+
export type SyncTransport = (request: Uint8Array) => Promise<Uint8Array>;
|
|
10
|
+
|
|
11
|
+
export interface SegmentFetchRequest {
|
|
12
|
+
readonly segmentId: string;
|
|
13
|
+
readonly table: string;
|
|
14
|
+
/** Canonical JSON (§11.2) of the requested scope map (§5.5 header). */
|
|
15
|
+
readonly requestedScopesJson: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch segment bytes from the direct endpoint (§5.5). `fetchUrl`, when
|
|
20
|
+
* present, is the §5.4 direct-URL capability: its presence makes the
|
|
21
|
+
* client advertise accept bit 3, and the client core then routes
|
|
22
|
+
* url-carrying descriptors through it — capability negotiation, never a
|
|
23
|
+
* fallback pair. A `fetchUrl` implementation MUST NOT attach host
|
|
24
|
+
* authentication (the URL is the entire grant, §5.4).
|
|
25
|
+
*/
|
|
26
|
+
export interface SegmentDownloader {
|
|
27
|
+
(request: SegmentFetchRequest): Promise<Uint8Array>;
|
|
28
|
+
readonly fetchUrl?: (url: string) => Promise<Uint8Array>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RealtimeHandlers {
|
|
32
|
+
/** JSON control frame (§8.1): hello / sync / heartbeat / unknown. */
|
|
33
|
+
onText(text: string): void;
|
|
34
|
+
/** Binary frame: channel tag byte + payload (§8.7) — a `0x00`-tagged
|
|
35
|
+
* standalone SSP2 response (delta) or a `0x01`-tagged round chunk. */
|
|
36
|
+
onBinary(bytes: Uint8Array): void;
|
|
37
|
+
onClose?(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RealtimeSocket {
|
|
41
|
+
/** Send a JSON control message (acks, §8.2). */
|
|
42
|
+
send(text: string): void;
|
|
43
|
+
/** Send one binary message (tagged round chunk, §8.7). The socket is
|
|
44
|
+
* the sync-round transport whenever it is connected — Direction
|
|
45
|
+
* decision 1: one loop, no fallback pair. */
|
|
46
|
+
sendBytes(bytes: Uint8Array): void;
|
|
47
|
+
close(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RealtimeConnector = (
|
|
51
|
+
handlers: RealtimeHandlers,
|
|
52
|
+
) => Promise<RealtimeSocket> | RealtimeSocket;
|