@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ClientDatabase` on better-sqlite3 — the Electron-main / plain-Node
|
|
3
|
+
* backend (TODO 2: Node ClientDatabase). Semantics mirror `./bun-database`
|
|
4
|
+
* exactly (synchronous exec/query/transaction with the shared savepoint
|
|
5
|
+
* helper, and the same §5.3 sqlite-image ATTACH path), so the core behaves
|
|
6
|
+
* identically whether it runs on bun:sqlite (tests), sqlite-wasm (browser)
|
|
7
|
+
* or better-sqlite3 (Node/Electron-main).
|
|
8
|
+
*
|
|
9
|
+
* better-sqlite3 is an OPTIONAL peer dependency, not a hard one: the package
|
|
10
|
+
* installs cleanly without it and this module errors helpfully only when a
|
|
11
|
+
* host actually calls `openNodeDatabase()` without having installed the peer.
|
|
12
|
+
* Not exported from the package root, so browser/bun entries never resolve
|
|
13
|
+
* the native module. Subpath export: `@syncular/client/node`.
|
|
14
|
+
*
|
|
15
|
+
* bun CANNOT dlopen better-sqlite3 (ERR_DLOPEN_FAILED, oven-sh/bun#4290), so
|
|
16
|
+
* this adapter is verified under real Node — see the README "Electron-main /
|
|
17
|
+
* plain-Node" section for the one-command recipe and `test/node-database`.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import {
|
|
24
|
+
assertImageAlias,
|
|
25
|
+
type ClientDatabase,
|
|
26
|
+
runTransaction,
|
|
27
|
+
type SqlRow,
|
|
28
|
+
type SqlValue,
|
|
29
|
+
} from './database';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Structural view of the tiny better-sqlite3 surface this binding uses. We
|
|
33
|
+
* type it locally (rather than importing `better-sqlite3`'s types) so the
|
|
34
|
+
* package typechecks without the optional peer installed.
|
|
35
|
+
*/
|
|
36
|
+
interface BetterSqliteStatement {
|
|
37
|
+
run(...params: NodeParam[]): unknown;
|
|
38
|
+
all(...params: NodeParam[]): unknown[];
|
|
39
|
+
}
|
|
40
|
+
interface BetterSqliteDatabase {
|
|
41
|
+
readonly inTransaction: boolean;
|
|
42
|
+
prepare(sql: string): BetterSqliteStatement;
|
|
43
|
+
exec(sql: string): unknown;
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
type BetterSqliteConstructor = new (
|
|
47
|
+
path: string,
|
|
48
|
+
options?: { readonly?: boolean; fileMustExist?: boolean },
|
|
49
|
+
) => BetterSqliteDatabase;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
|
|
53
|
+
* bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
|
|
54
|
+
* coerce booleans to 0/1 exactly like the bun adapter so callers see one
|
|
55
|
+
* uniform bind contract across every backend.
|
|
56
|
+
*/
|
|
57
|
+
type NodeParam = string | number | bigint | Uint8Array | null;
|
|
58
|
+
|
|
59
|
+
function coerceParams(params: readonly SqlValue[]): NodeParam[] {
|
|
60
|
+
return params.map((value): NodeParam => {
|
|
61
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
62
|
+
return value;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* better-sqlite3 returns BLOB columns as Node `Buffer`s. A Buffer IS a
|
|
68
|
+
* Uint8Array subclass, but it can be a view onto a shared pool buffer, so we
|
|
69
|
+
* normalize to a standalone Uint8Array — matching what bun:sqlite hands back
|
|
70
|
+
* and keeping the buffer-ownership assumptions elsewhere (worker transfer,
|
|
71
|
+
* structured clone) honest.
|
|
72
|
+
*/
|
|
73
|
+
function normalizeRow(row: Record<string, unknown>): SqlRow {
|
|
74
|
+
const out: SqlRow = {};
|
|
75
|
+
for (const key in row) {
|
|
76
|
+
const value = row[key];
|
|
77
|
+
if (Buffer.isBuffer(value)) {
|
|
78
|
+
out[key] = new Uint8Array(value); // copies out of the pool
|
|
79
|
+
} else {
|
|
80
|
+
out[key] = value as SqlValue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load the optional peer AND open the database in one guarded step, so BOTH
|
|
88
|
+
* failure modes are turned into a clear, actionable error rather than a raw
|
|
89
|
+
* one:
|
|
90
|
+
*
|
|
91
|
+
* - `require('better-sqlite3')` throwing MODULE_NOT_FOUND — the peer is not
|
|
92
|
+
* installed (the common browser-only-host case), and
|
|
93
|
+
* - `new Database()` throwing ERR_DLOPEN_FAILED — the module resolves but the
|
|
94
|
+
* native addon cannot load, which is exactly what bun does for
|
|
95
|
+
* better-sqlite3 (oven-sh/bun#4290); the addon only dlopens at construction.
|
|
96
|
+
*/
|
|
97
|
+
function openBetterSqlite(path: string): BetterSqliteDatabase {
|
|
98
|
+
const require = createRequire(import.meta.url);
|
|
99
|
+
try {
|
|
100
|
+
const mod = require('better-sqlite3') as
|
|
101
|
+
| BetterSqliteConstructor
|
|
102
|
+
| { default: BetterSqliteConstructor };
|
|
103
|
+
const Database =
|
|
104
|
+
(mod as { default?: BetterSqliteConstructor }).default ??
|
|
105
|
+
(mod as BetterSqliteConstructor);
|
|
106
|
+
return new Database(path);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const code = (error as { code?: string })?.code;
|
|
109
|
+
if (code === 'ERR_DLOPEN_FAILED') {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"openNodeDatabase() requires the 'better-sqlite3' native module, but " +
|
|
112
|
+
'it failed to load. This most commonly means you are running under ' +
|
|
113
|
+
'bun, which cannot dlopen better-sqlite3 (oven-sh/bun#4290) — use ' +
|
|
114
|
+
"the bun:sqlite backend ('@syncular/client/bun') under bun, " +
|
|
115
|
+
"and reserve '@syncular/client/node' for Node/Electron-main. " +
|
|
116
|
+
`Underlying error: ${String(error)}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
throw new Error(
|
|
120
|
+
'openNodeDatabase() requires the optional peer dependency ' +
|
|
121
|
+
"'better-sqlite3', which is not installed. Add it to your app " +
|
|
122
|
+
'(`npm install better-sqlite3` / `bun add better-sqlite3`) — it is ' +
|
|
123
|
+
'kept optional so @syncular/client installs without a native ' +
|
|
124
|
+
`build for browser-only hosts. Underlying error: ${String(error)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class NodeClientDatabase implements ClientDatabase {
|
|
130
|
+
readonly db: BetterSqliteDatabase;
|
|
131
|
+
#tx = { depth: 0 };
|
|
132
|
+
|
|
133
|
+
constructor(path = ':memory:') {
|
|
134
|
+
this.db = openBetterSqlite(path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
exec(sql: string, params: readonly SqlValue[] = []): void {
|
|
138
|
+
this.db.prepare(sql).run(...coerceParams(params));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
query(sql: string, params: readonly SqlValue[] = []): SqlRow[] {
|
|
142
|
+
const rows = this.db.prepare(sql).all(...coerceParams(params));
|
|
143
|
+
return (rows as Record<string, unknown>[]).map(normalizeRow);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
transaction<T>(fn: () => T): T {
|
|
147
|
+
return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
|
|
152
|
+
* buffers, so the image lands in a private temp file for the duration of
|
|
153
|
+
* the ATTACH. Must be called outside any open transaction (SQLite cannot
|
|
154
|
+
* ATTACH inside one).
|
|
155
|
+
*/
|
|
156
|
+
withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
|
|
157
|
+
assertImageAlias(alias);
|
|
158
|
+
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
159
|
+
const path = join(dir, 'segment.db');
|
|
160
|
+
try {
|
|
161
|
+
writeFileSync(path, bytes);
|
|
162
|
+
this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
|
|
163
|
+
try {
|
|
164
|
+
return fn();
|
|
165
|
+
} finally {
|
|
166
|
+
this.db.prepare(`DETACH DATABASE ${alias}`).run();
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
rmSync(dir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
close(): void {
|
|
174
|
+
this.db.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function openNodeDatabase(path = ':memory:'): ClientDatabase {
|
|
179
|
+
return new NodeClientDatabase(path);
|
|
180
|
+
}
|
package/src/outbox.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
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 {
|
|
9
|
+
encodeRow,
|
|
10
|
+
type PushCommitFrame,
|
|
11
|
+
type PushOperation,
|
|
12
|
+
type ScopeMap,
|
|
13
|
+
} from '@syncular/core';
|
|
14
|
+
import type { ClientDatabase } from './database';
|
|
15
|
+
import type { EncryptionConfig } from './encryption';
|
|
16
|
+
import { ClientSyncError } from './errors';
|
|
17
|
+
import {
|
|
18
|
+
type CompiledClientSchema,
|
|
19
|
+
type CompiledClientTable,
|
|
20
|
+
type JsonRowValue,
|
|
21
|
+
jsonToRowValue,
|
|
22
|
+
} from './schema';
|
|
23
|
+
|
|
24
|
+
export interface OutboxOperation {
|
|
25
|
+
readonly table: string;
|
|
26
|
+
readonly rowId: string;
|
|
27
|
+
readonly op: 'upsert' | 'delete';
|
|
28
|
+
/** Optimistic-concurrency token (§6.2); absent = last-write-wins. */
|
|
29
|
+
readonly baseVersion?: number;
|
|
30
|
+
/** Full-row values keyed by column name; present iff `op` is `upsert`. */
|
|
31
|
+
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OutboxCommit {
|
|
35
|
+
readonly seq: number;
|
|
36
|
+
readonly clientCommitId: string;
|
|
37
|
+
readonly createdAtMs: number;
|
|
38
|
+
readonly operations: readonly OutboxOperation[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function appendOutboxCommit(
|
|
42
|
+
db: ClientDatabase,
|
|
43
|
+
clientCommitId: string,
|
|
44
|
+
operations: readonly OutboxOperation[],
|
|
45
|
+
nowMs: number,
|
|
46
|
+
): void {
|
|
47
|
+
if (operations.length === 0) {
|
|
48
|
+
throw new ClientSyncError(
|
|
49
|
+
'sync.empty_commit',
|
|
50
|
+
'a local commit must carry at least one operation (§6.1)',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
db.exec(
|
|
54
|
+
`INSERT INTO _syncular_outbox(client_commit_id, created_at_ms, operations)
|
|
55
|
+
VALUES (?, ?, ?)`,
|
|
56
|
+
[clientCommitId, nowMs, JSON.stringify(operations)],
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Pending commits in FIFO creation order (§7.1). */
|
|
61
|
+
export function listOutbox(db: ClientDatabase): OutboxCommit[] {
|
|
62
|
+
return db
|
|
63
|
+
.query(
|
|
64
|
+
`SELECT seq, client_commit_id, created_at_ms, operations
|
|
65
|
+
FROM _syncular_outbox ORDER BY seq ASC`,
|
|
66
|
+
)
|
|
67
|
+
.map((row) => ({
|
|
68
|
+
seq: row.seq as number,
|
|
69
|
+
clientCommitId: row.client_commit_id as string,
|
|
70
|
+
createdAtMs: row.created_at_ms as number,
|
|
71
|
+
operations: JSON.parse(row.operations as string) as OutboxOperation[],
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function deleteOutboxCommit(
|
|
76
|
+
db: ClientDatabase,
|
|
77
|
+
clientCommitId: string,
|
|
78
|
+
): void {
|
|
79
|
+
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
|
|
80
|
+
clientCommitId,
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* §7.4.4: after a schema bump, a persisted upsert may name a column the new
|
|
86
|
+
* schema no longer has. The value has nowhere to go and there is no
|
|
87
|
+
* migration — surface it as `sync.outbox_incompatible` (client-local, §10.3)
|
|
88
|
+
* so the caller can drop the commit through the rejection channel.
|
|
89
|
+
*/
|
|
90
|
+
export class OutboxEncodeError extends ClientSyncError {
|
|
91
|
+
constructor(message: string) {
|
|
92
|
+
super('sync.outbox_incompatible', message, false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function orderedValues(
|
|
97
|
+
table: CompiledClientTable,
|
|
98
|
+
values: Readonly<Record<string, JsonRowValue>>,
|
|
99
|
+
) {
|
|
100
|
+
// Any persisted key that is not a column of the CURRENT schema means the
|
|
101
|
+
// bump removed (or renamed) it — the commit cannot be expressed now.
|
|
102
|
+
for (const key of Object.keys(values)) {
|
|
103
|
+
if (!table.columnIndex.has(key)) {
|
|
104
|
+
throw new OutboxEncodeError(
|
|
105
|
+
`outbox commit references column ${JSON.stringify(key)} on ${JSON.stringify(table.name)}, which the current schema no longer has (§7.4.4)`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return table.columns.map((column) => {
|
|
110
|
+
const value = values[column.name];
|
|
111
|
+
if (value === undefined) return null;
|
|
112
|
+
return jsonToRowValue(value);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Encode one outbox commit as a `PUSH_COMMIT` frame with the current
|
|
118
|
+
* schema's row codec (§6.1). When `encryption` is configured, encrypted
|
|
119
|
+
* columns (§5.11) are encrypted here — the encode-at-send seam — before the
|
|
120
|
+
* row codec serializes them as ciphertext-envelope `bytes`. Async because
|
|
121
|
+
* WebCrypto is async.
|
|
122
|
+
*/
|
|
123
|
+
export async function encodeOutboxCommit(
|
|
124
|
+
schema: CompiledClientSchema,
|
|
125
|
+
commit: OutboxCommit,
|
|
126
|
+
encryption?: EncryptionConfig,
|
|
127
|
+
): Promise<PushCommitFrame> {
|
|
128
|
+
const operations: PushOperation[] = [];
|
|
129
|
+
for (const op of commit.operations) {
|
|
130
|
+
if (op.op === 'delete') {
|
|
131
|
+
operations.push({
|
|
132
|
+
table: op.table,
|
|
133
|
+
rowId: op.rowId,
|
|
134
|
+
op: 'delete',
|
|
135
|
+
...(op.baseVersion !== undefined
|
|
136
|
+
? { baseVersion: op.baseVersion }
|
|
137
|
+
: {}),
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const table = schema.tables.get(op.table);
|
|
142
|
+
if (table === undefined) {
|
|
143
|
+
// §7.4.4: the bump removed this table — the commit cannot be encoded.
|
|
144
|
+
throw new OutboxEncodeError(
|
|
145
|
+
`outbox commit ${commit.clientCommitId} targets table ${JSON.stringify(op.table)}, which the current schema no longer has (§7.4.4)`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (op.values === undefined) {
|
|
149
|
+
throw new ClientSyncError(
|
|
150
|
+
'sync.invalid_request',
|
|
151
|
+
`outbox upsert on ${op.table}/${op.rowId} has no values`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
let values = orderedValues(table, op.values);
|
|
155
|
+
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
156
|
+
// Lazy: opt-in E2EE never enters an encryption-free app's bundle.
|
|
157
|
+
const { encryptRowValues } = await import('./encryption');
|
|
158
|
+
values = await encryptRowValues(encryption, table, op.rowId, values);
|
|
159
|
+
}
|
|
160
|
+
operations.push({
|
|
161
|
+
table: op.table,
|
|
162
|
+
rowId: op.rowId,
|
|
163
|
+
op: 'upsert',
|
|
164
|
+
...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
|
|
165
|
+
payload: encodeRow(table.columns, values),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
type: 'PUSH_COMMIT',
|
|
170
|
+
clientCommitId: commit.clientCommitId,
|
|
171
|
+
operations,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* §3.3: drop pending commits that write into a revoked scope instead of
|
|
177
|
+
* replaying them into guaranteed rejections. A whole commit is dropped when
|
|
178
|
+
* any of its upserts provably lands in the revoked effective scopes —
|
|
179
|
+
* commits are atomic and their content is pinned by the idempotency key,
|
|
180
|
+
* so ops are never removed individually. Returns dropped commit ids.
|
|
181
|
+
*/
|
|
182
|
+
export function dropOutboxCommitsInScope(
|
|
183
|
+
db: ClientDatabase,
|
|
184
|
+
table: CompiledClientTable,
|
|
185
|
+
effective: ScopeMap,
|
|
186
|
+
): string[] {
|
|
187
|
+
const entries = Object.entries(effective);
|
|
188
|
+
if (entries.length === 0) return [];
|
|
189
|
+
const dropped: string[] = [];
|
|
190
|
+
for (const commit of listOutbox(db)) {
|
|
191
|
+
const inScope = commit.operations.some((op) => {
|
|
192
|
+
if (op.table !== table.name || op.values === undefined) return false;
|
|
193
|
+
return entries.every(([variable, values]) => {
|
|
194
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
195
|
+
if (column === undefined) return false;
|
|
196
|
+
const value = op.values?.[column];
|
|
197
|
+
return typeof value === 'string' && values.includes(value);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
if (inScope) {
|
|
201
|
+
deleteOutboxCommit(db, commit.clientCommitId);
|
|
202
|
+
dropped.push(commit.clientCommitId);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return dropped;
|
|
206
|
+
}
|