@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.
Files changed (233) hide show
  1. package/README.md +227 -709
  2. package/dist/apply.d.ts +80 -0
  3. package/dist/apply.js +301 -0
  4. package/dist/blob.d.ts +127 -0
  5. package/dist/blob.js +201 -0
  6. package/dist/bun-database.d.ts +22 -0
  7. package/dist/bun-database.js +61 -0
  8. package/dist/client.d.ts +320 -69
  9. package/dist/client.js +1627 -163
  10. package/dist/content-type.d.ts +2 -0
  11. package/dist/content-type.js +2 -0
  12. package/dist/database.d.ts +35 -80
  13. package/dist/database.js +32 -947
  14. package/dist/encryption.d.ts +47 -0
  15. package/dist/encryption.js +75 -0
  16. package/dist/errors.d.ts +8 -22
  17. package/dist/errors.js +10 -207
  18. package/dist/http.d.ts +32 -0
  19. package/dist/http.js +196 -0
  20. package/dist/index.d.ts +28 -16
  21. package/dist/index.js +25 -12
  22. package/dist/invalidation.d.ts +69 -0
  23. package/dist/invalidation.js +84 -0
  24. package/dist/leader-lock.d.ts +28 -0
  25. package/dist/leader-lock.js +38 -0
  26. package/dist/multi-tab.d.ts +134 -0
  27. package/dist/multi-tab.js +399 -0
  28. package/dist/node-database.d.ts +41 -0
  29. package/dist/node-database.js +133 -0
  30. package/dist/outbox.d.ts +56 -0
  31. package/dist/outbox.js +140 -0
  32. package/dist/schema.d.ts +107 -0
  33. package/dist/schema.js +267 -0
  34. package/dist/state.d.ts +40 -0
  35. package/dist/state.js +77 -0
  36. package/dist/transport.d.ts +44 -0
  37. package/dist/transport.js +7 -0
  38. package/dist/wasm-database.d.ts +30 -0
  39. package/dist/wasm-database.js +170 -0
  40. package/dist/window.d.ts +64 -0
  41. package/dist/window.js +0 -0
  42. package/dist/worker-entry.d.ts +16 -2
  43. package/dist/worker-entry.js +300 -456
  44. package/dist/worker-host.d.ts +174 -0
  45. package/dist/worker-host.js +536 -0
  46. package/dist/worker-protocol.d.ts +173 -61
  47. package/dist/worker-protocol.js +7 -16
  48. package/package.json +45 -112
  49. package/src/apply.ts +435 -0
  50. package/src/blob.ts +333 -0
  51. package/src/bun-database.ts +75 -0
  52. package/src/client.ts +2247 -277
  53. package/src/content-type.ts +2 -0
  54. package/src/database.ts +63 -1389
  55. package/src/encryption.ts +123 -0
  56. package/src/errors.ts +11 -265
  57. package/src/http.ts +254 -0
  58. package/src/index.ts +25 -20
  59. package/src/invalidation.ts +128 -0
  60. package/src/leader-lock.ts +68 -0
  61. package/src/multi-tab.ts +550 -0
  62. package/src/node-database.ts +180 -0
  63. package/src/outbox.ts +206 -0
  64. package/src/schema.ts +397 -0
  65. package/src/state.ts +126 -0
  66. package/src/transport.ts +52 -0
  67. package/src/wasm-database.ts +279 -0
  68. package/src/window.ts +0 -0
  69. package/src/worker-entry.ts +391 -545
  70. package/src/worker-host.ts +800 -0
  71. package/src/worker-protocol.ts +204 -99
  72. package/dist/auth-leases.d.ts +0 -11
  73. package/dist/auth-leases.d.ts.map +0 -1
  74. package/dist/auth-leases.js +0 -140
  75. package/dist/auth-leases.js.map +0 -1
  76. package/dist/blob-limits.d.ts +0 -15
  77. package/dist/blob-limits.d.ts.map +0 -1
  78. package/dist/blob-limits.js +0 -66
  79. package/dist/blob-limits.js.map +0 -1
  80. package/dist/bridge-client.d.ts +0 -69
  81. package/dist/bridge-client.d.ts.map +0 -1
  82. package/dist/bridge-client.js +0 -282
  83. package/dist/bridge-client.js.map +0 -1
  84. package/dist/client-config.d.ts +0 -6
  85. package/dist/client-config.d.ts.map +0 -1
  86. package/dist/client-config.js +0 -18
  87. package/dist/client-config.js.map +0 -1
  88. package/dist/client.d.ts.map +0 -1
  89. package/dist/client.js.map +0 -1
  90. package/dist/command-history.d.ts +0 -35
  91. package/dist/command-history.d.ts.map +0 -1
  92. package/dist/command-history.js +0 -378
  93. package/dist/command-history.js.map +0 -1
  94. package/dist/console-diagnostics.d.ts +0 -33
  95. package/dist/console-diagnostics.d.ts.map +0 -1
  96. package/dist/console-diagnostics.js +0 -496
  97. package/dist/console-diagnostics.js.map +0 -1
  98. package/dist/crdt-yjs/index.d.ts +0 -4
  99. package/dist/crdt-yjs/index.d.ts.map +0 -1
  100. package/dist/crdt-yjs/index.js +0 -4
  101. package/dist/crdt-yjs/index.js.map +0 -1
  102. package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
  103. package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
  104. package/dist/crdt-yjs/webview-host-facade.js +0 -284
  105. package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
  106. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
  107. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
  108. package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
  109. package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
  110. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
  111. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
  112. package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
  113. package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
  114. package/dist/database.d.ts.map +0 -1
  115. package/dist/database.js.map +0 -1
  116. package/dist/diagnostics.d.ts +0 -10
  117. package/dist/diagnostics.d.ts.map +0 -1
  118. package/dist/diagnostics.js +0 -83
  119. package/dist/diagnostics.js.map +0 -1
  120. package/dist/errors.d.ts.map +0 -1
  121. package/dist/errors.js.map +0 -1
  122. package/dist/generated-bridge.d.ts +0 -365
  123. package/dist/generated-bridge.d.ts.map +0 -1
  124. package/dist/generated-bridge.js +0 -250
  125. package/dist/generated-bridge.js.map +0 -1
  126. package/dist/index.d.ts.map +0 -1
  127. package/dist/index.js.map +0 -1
  128. package/dist/mutations.d.ts +0 -72
  129. package/dist/mutations.d.ts.map +0 -1
  130. package/dist/mutations.js +0 -63
  131. package/dist/mutations.js.map +0 -1
  132. package/dist/network.d.ts +0 -3
  133. package/dist/network.d.ts.map +0 -1
  134. package/dist/network.js +0 -17
  135. package/dist/network.js.map +0 -1
  136. package/dist/react/index.d.ts +0 -169
  137. package/dist/react/index.d.ts.map +0 -1
  138. package/dist/react/index.js +0 -628
  139. package/dist/react/index.js.map +0 -1
  140. package/dist/react-native/index.d.ts +0 -35
  141. package/dist/react-native/index.d.ts.map +0 -1
  142. package/dist/react-native/index.js +0 -49
  143. package/dist/react-native/index.js.map +0 -1
  144. package/dist/runtime-contract.d.ts +0 -13
  145. package/dist/runtime-contract.d.ts.map +0 -1
  146. package/dist/runtime-contract.js +0 -24
  147. package/dist/runtime-contract.js.map +0 -1
  148. package/dist/rust-client.d.ts +0 -106
  149. package/dist/rust-client.d.ts.map +0 -1
  150. package/dist/rust-client.js +0 -938
  151. package/dist/rust-client.js.map +0 -1
  152. package/dist/sentry.d.ts +0 -35
  153. package/dist/sentry.d.ts.map +0 -1
  154. package/dist/sentry.js +0 -155
  155. package/dist/sentry.js.map +0 -1
  156. package/dist/sql-safety.d.ts +0 -3
  157. package/dist/sql-safety.d.ts.map +0 -1
  158. package/dist/sql-safety.js +0 -62
  159. package/dist/sql-safety.js.map +0 -1
  160. package/dist/syncular-runtime-artifacts.json +0 -61
  161. package/dist/tauri/index.d.ts +0 -35
  162. package/dist/tauri/index.d.ts.map +0 -1
  163. package/dist/tauri/index.js +0 -114
  164. package/dist/tauri/index.js.map +0 -1
  165. package/dist/types.d.ts +0 -1096
  166. package/dist/types.d.ts.map +0 -1
  167. package/dist/types.js +0 -2
  168. package/dist/types.js.map +0 -1
  169. package/dist/wasm/.syncular-wasm-profile +0 -1
  170. package/dist/wasm/syncular-runtime-artifact.json +0 -21
  171. package/dist/wasm/syncular.d.ts +0 -207
  172. package/dist/wasm/syncular.js +0 -2341
  173. package/dist/wasm/syncular_bg.wasm +0 -0
  174. package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
  175. package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
  176. package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
  177. package/dist/wasm-bindings/runtime-contract.js +0 -112
  178. package/dist/wasm-bindings/runtime-contract.js.map +0 -1
  179. package/dist/wasm-core/.syncular-wasm-profile +0 -1
  180. package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
  181. package/dist/wasm-core/syncular.d.ts +0 -162
  182. package/dist/wasm-core/syncular.js +0 -1847
  183. package/dist/wasm-core/syncular_bg.wasm +0 -0
  184. package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
  185. package/dist/wasm-perf/.syncular-wasm-profile +0 -1
  186. package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
  187. package/dist/wasm-perf/syncular.d.ts +0 -207
  188. package/dist/wasm-perf/syncular.js +0 -2341
  189. package/dist/wasm-perf/syncular_bg.wasm +0 -0
  190. package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
  191. package/dist/wasm-runtime.d.ts +0 -23
  192. package/dist/wasm-runtime.d.ts.map +0 -1
  193. package/dist/wasm-runtime.js +0 -69
  194. package/dist/wasm-runtime.js.map +0 -1
  195. package/dist/worker-client.d.ts +0 -123
  196. package/dist/worker-client.d.ts.map +0 -1
  197. package/dist/worker-client.js +0 -1735
  198. package/dist/worker-client.js.map +0 -1
  199. package/dist/worker-entry.d.ts.map +0 -1
  200. package/dist/worker-entry.js.map +0 -1
  201. package/dist/worker-protocol.d.ts.map +0 -1
  202. package/dist/worker-protocol.js.map +0 -1
  203. package/dist/worker-realtime.d.ts +0 -39
  204. package/dist/worker-realtime.d.ts.map +0 -1
  205. package/dist/worker-realtime.js +0 -677
  206. package/dist/worker-realtime.js.map +0 -1
  207. package/src/auth-leases.ts +0 -251
  208. package/src/blob-limits.ts +0 -98
  209. package/src/bridge-client.ts +0 -512
  210. package/src/client-config.ts +0 -29
  211. package/src/command-history.ts +0 -623
  212. package/src/console-diagnostics.ts +0 -617
  213. package/src/crdt-yjs/index.ts +0 -3
  214. package/src/crdt-yjs/webview-host-facade.ts +0 -477
  215. package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
  216. package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
  217. package/src/diagnostics.ts +0 -116
  218. package/src/generated-bridge.ts +0 -741
  219. package/src/mutations.ts +0 -168
  220. package/src/network.ts +0 -32
  221. package/src/react/index.ts +0 -1036
  222. package/src/react-native/index.ts +0 -152
  223. package/src/runtime-contract.ts +0 -48
  224. package/src/rust-client.ts +0 -1491
  225. package/src/sentry.ts +0 -215
  226. package/src/sql-safety.ts +0 -61
  227. package/src/tauri/index.ts +0 -211
  228. package/src/types.ts +0 -1397
  229. package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
  230. package/src/wasm-bindings/runtime-contract.ts +0 -158
  231. package/src/wasm-runtime.ts +0 -145
  232. package/src/worker-client.ts +0 -2289
  233. 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
+ }
@@ -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
+ }
@@ -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 {};