@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.
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/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
+ }
@@ -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;