@syncular/client 0.1.3 → 0.2.1

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/apply.ts ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Local application of server data: `COMMIT` frames (§4.5), rows segments
3
+ * (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
4
+ * contract and the §5.6 first-page rule.
5
+ */
6
+ import {
7
+ type CommitFrame,
8
+ decodeRow,
9
+ type RowsSegment,
10
+ type RowValue,
11
+ type ScopeMap,
12
+ type SegmentRow,
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
+ quoteIdent,
21
+ SYNC_VERSION_COLUMN,
22
+ toSqlValue,
23
+ } from './schema';
24
+
25
+ function upsertSql(table: CompiledClientTable): string {
26
+ const names = [
27
+ ...table.columns.map((column) => quoteIdent(column.name)),
28
+ quoteIdent(SYNC_VERSION_COLUMN),
29
+ ];
30
+ const placeholders = names.map(() => '?').join(', ');
31
+ return `INSERT OR REPLACE INTO ${quoteIdent(table.name)} (${names.join(', ')}) VALUES (${placeholders})`;
32
+ }
33
+
34
+ export function upsertLocalRow(
35
+ db: ClientDatabase,
36
+ table: CompiledClientTable,
37
+ values: readonly RowValue[],
38
+ syncVersion: number,
39
+ ): void {
40
+ db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
41
+ }
42
+
43
+ export function deleteLocalRow(
44
+ db: ClientDatabase,
45
+ table: CompiledClientTable,
46
+ rowId: string,
47
+ ): void {
48
+ db.exec(
49
+ `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
50
+ [rowId],
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
56
+ * Upserts land with `_sync_version = rowVersion`; deletes remove the row.
57
+ * Re-application is idempotent (§1.4 rule 5).
58
+ *
59
+ * §5.11: encrypted columns are decrypted here — the apply seam. Because
60
+ * WebCrypto is async and the local transaction is synchronous, every row is
61
+ * decoded AND decrypted first (outside the transaction), then the resolved
62
+ * plaintext rows are applied atomically. A decrypt failure
63
+ * (`client.decrypt_failed`) aborts before any local write, so the commit
64
+ * never half-applies.
65
+ */
66
+ export async function applyCommitFrame(
67
+ db: ClientDatabase,
68
+ schema: CompiledClientSchema,
69
+ frame: CommitFrame,
70
+ encryption?: EncryptionConfig,
71
+ ): Promise<void> {
72
+ type Resolved =
73
+ | { op: 'delete'; table: CompiledClientTable; rowId: string }
74
+ | {
75
+ op: 'upsert';
76
+ table: CompiledClientTable;
77
+ values: readonly RowValue[];
78
+ rowVersion: number;
79
+ };
80
+ const resolved: Resolved[] = [];
81
+ for (const change of frame.changes) {
82
+ const tableName = frame.tables[change.tableIndex];
83
+ if (tableName === undefined) {
84
+ throw new ClientSyncError(
85
+ 'sync.invalid_request',
86
+ `COMMIT change tableIndex ${change.tableIndex} out of range`,
87
+ );
88
+ }
89
+ const table = schema.tables.get(tableName);
90
+ if (table === undefined) {
91
+ throw new ClientSyncError(
92
+ 'sync.schema_mismatch',
93
+ `COMMIT delivers unknown local table ${JSON.stringify(tableName)}`,
94
+ );
95
+ }
96
+ if (change.op === 'delete') {
97
+ resolved.push({ op: 'delete', table, rowId: change.rowId });
98
+ continue;
99
+ }
100
+ if (change.row === undefined || change.rowVersion === undefined) {
101
+ throw new ClientSyncError(
102
+ 'sync.invalid_request',
103
+ 'upsert change without row payload',
104
+ );
105
+ }
106
+ let values = decodeRow(table.columns, change.row);
107
+ if (encryption !== undefined && table.hasEncryptedColumns) {
108
+ const { decryptRowValues } = await import('./encryption');
109
+ values = await decryptRowValues(encryption, table, values);
110
+ }
111
+ resolved.push({
112
+ op: 'upsert',
113
+ table,
114
+ values,
115
+ rowVersion: change.rowVersion,
116
+ });
117
+ }
118
+ db.transaction(() => {
119
+ for (const change of resolved) {
120
+ if (change.op === 'delete') {
121
+ deleteLocalRow(db, change.table, change.rowId);
122
+ } else {
123
+ upsertLocalRow(db, change.table, change.values, change.rowVersion);
124
+ }
125
+ }
126
+ });
127
+ }
128
+
129
+ /**
130
+ * §5.2: the segment's column table must match the generated schema for
131
+ * (table, schemaVersion) — order, names, types, nullability. A mismatch is
132
+ * fatal (`sync.schema_mismatch`): the descriptor validates, never infers.
133
+ */
134
+ export function validateSegmentColumns(
135
+ schema: CompiledClientSchema,
136
+ table: CompiledClientTable,
137
+ segment: RowsSegment,
138
+ ): void {
139
+ const mismatch = (detail: string): never => {
140
+ throw new ClientSyncError(
141
+ 'sync.schema_mismatch',
142
+ `rows segment for ${JSON.stringify(segment.table)} does not match the generated schema: ${detail}`,
143
+ );
144
+ };
145
+ if (segment.table !== table.name) {
146
+ mismatch(`segment table ${JSON.stringify(segment.table)}`);
147
+ }
148
+ if (segment.schemaVersion !== schema.version) {
149
+ mismatch(`segment schemaVersion ${segment.schemaVersion}`);
150
+ }
151
+ if (segment.columns.length !== table.columns.length) {
152
+ mismatch(`column count ${segment.columns.length}`);
153
+ }
154
+ for (let i = 0; i < table.columns.length; i++) {
155
+ const expected = table.columns[i];
156
+ const actual = segment.columns[i];
157
+ if (expected === undefined || actual === undefined) continue;
158
+ if (
159
+ expected.name !== actual.name ||
160
+ expected.type !== actual.type ||
161
+ expected.nullable !== actual.nullable
162
+ ) {
163
+ mismatch(`column ${i} (${actual.name})`);
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Scope-matched local delete (§3.3 purge / §5.6 first-page rule): delete
170
+ * rows whose generated local scope columns match `effective` — every key's
171
+ * column value must be in the key's value list. Fails closed
172
+ * (`sync.scope_revoked`) when the table has no local mapping for a key:
173
+ * precision or nothing, never clear-the-table.
174
+ */
175
+ export function deleteScopedRows(
176
+ db: ClientDatabase,
177
+ table: CompiledClientTable,
178
+ effective: ScopeMap,
179
+ ): void {
180
+ const entries = Object.entries(effective);
181
+ if (entries.length === 0) return;
182
+ const clauses: string[] = [];
183
+ const params: string[] = [];
184
+ for (const [variable, values] of entries) {
185
+ const column = table.scopeColumnByVariable.get(variable);
186
+ if (column === undefined) {
187
+ throw new ClientSyncError(
188
+ 'sync.scope_revoked',
189
+ `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§3.3 fail-closed)`,
190
+ );
191
+ }
192
+ if (values.length === 0) return;
193
+ clauses.push(
194
+ `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
195
+ );
196
+ params.push(...values);
197
+ }
198
+ db.exec(
199
+ `DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
200
+ params,
201
+ );
202
+ }
203
+
204
+ /**
205
+ * §4.8 window eviction: delete rows matching a departing unit's effective
206
+ * scopes (same local-scope-column rule and fail-closed clause as
207
+ * {@link deleteScopedRows}) EXCEPT rows whose primary key is in
208
+ * `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
209
+ * `true` iff any pinned row was left behind, so the caller knows to defer
210
+ * the rest of the eviction until the outbox drains. Also removes the
211
+ * evicted rows' `server_version` with them (E2 — no residual version
212
+ * cache), which is automatic since the version column is per-row.
213
+ */
214
+ export function evictScopedRows(
215
+ db: ClientDatabase,
216
+ table: CompiledClientTable,
217
+ effective: ScopeMap,
218
+ pinnedRowIds: ReadonlySet<string>,
219
+ ): boolean {
220
+ const entries = Object.entries(effective);
221
+ if (entries.length === 0) return false;
222
+ const clauses: string[] = [];
223
+ const params: string[] = [];
224
+ for (const [variable, values] of entries) {
225
+ const column = table.scopeColumnByVariable.get(variable);
226
+ if (column === undefined) {
227
+ throw new ClientSyncError(
228
+ 'sync.scope_revoked',
229
+ `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`,
230
+ );
231
+ }
232
+ if (values.length === 0) return false;
233
+ clauses.push(
234
+ `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
235
+ );
236
+ params.push(...values);
237
+ }
238
+ const pk = quoteIdent(table.primaryKey);
239
+ let pinnedClause = '';
240
+ if (pinnedRowIds.size > 0) {
241
+ const ids = [...pinnedRowIds];
242
+ pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
243
+ params.push(...ids);
244
+ }
245
+ db.exec(
246
+ `DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}${pinnedClause}`,
247
+ params,
248
+ );
249
+ if (pinnedRowIds.size === 0) return false;
250
+ // A pin still matters only if a pinned row actually falls inside this
251
+ // unit's effective scopes; check by re-selecting the survivors.
252
+ const survivors = db.query(
253
+ `SELECT ${pk} AS pk FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
254
+ params.slice(0, params.length - pinnedRowIds.size),
255
+ );
256
+ for (const row of survivors) {
257
+ if (pinnedRowIds.has(String(row.pk))) return true;
258
+ }
259
+ return false;
260
+ }
261
+
262
+ /** Descriptor fields a sqlite image is validated against (§5.3). */
263
+ export interface SqliteSegmentDescriptor {
264
+ readonly table: string;
265
+ readonly rowCount: number;
266
+ readonly asOfCommitSeq: number;
267
+ readonly scopeDigest: string;
268
+ }
269
+
270
+ const IMAGE_ALIAS = 'syncular_image';
271
+
272
+ function imageInvalid(detail: string): never {
273
+ throw new ClientSyncError(
274
+ 'sync.invalid_request',
275
+ `sqlite segment rejected: ${detail} (§5.3)`,
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Apply a §5.3 sqlite-image segment in ONE local transaction: validate
281
+ * the in-file metadata against the descriptor, validate the data table's
282
+ * column names/order against the generated schema, run the §5.6
283
+ * first-page clear when fresh, then copy every row with a single
284
+ * `INSERT OR REPLACE … SELECT` — `_syncular_version` lands in
285
+ * `_sync_version` exactly like a rows segment's per-row `serverVersion`.
286
+ */
287
+ export function applySqliteSegment(
288
+ db: ClientDatabase,
289
+ schema: CompiledClientSchema,
290
+ table: CompiledClientTable,
291
+ bytes: Uint8Array,
292
+ descriptor: SqliteSegmentDescriptor,
293
+ options: { readonly clearFirst: boolean; readonly effective: ScopeMap },
294
+ ): number {
295
+ const withImage = db.withSqliteImage?.bind(db);
296
+ if (withImage === undefined) {
297
+ throw new ClientSyncError(
298
+ 'sync.invalid_request',
299
+ 'received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)',
300
+ );
301
+ }
302
+ if (descriptor.table !== table.name) {
303
+ imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
304
+ }
305
+ return withImage(bytes, IMAGE_ALIAS, () => {
306
+ // 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
307
+ // SQLite database or lacks the metadata table fails right here.
308
+ let meta: ReturnType<ClientDatabase['query']>;
309
+ try {
310
+ meta = db.query(
311
+ `SELECT format, "table" AS tbl, "schemaVersion" AS sv,
312
+ "asOfCommitSeq" AS pin, "scopeDigest" AS sd,
313
+ "rowCount" AS rc
314
+ FROM ${IMAGE_ALIAS}."_syncular_segment"`,
315
+ );
316
+ } catch {
317
+ imageInvalid('bytes are not a SQLite image with _syncular_segment');
318
+ }
319
+ const record = meta[0];
320
+ if (meta.length !== 1 || record === undefined) {
321
+ imageInvalid('_syncular_segment must contain exactly one row');
322
+ }
323
+ if (record.format !== 1) imageInvalid(`format ${String(record.format)}`);
324
+ if (record.tbl !== table.name) {
325
+ imageInvalid(`image table ${String(record.tbl)}`);
326
+ }
327
+ if (Number(record.sv) !== schema.version) {
328
+ imageInvalid(`schemaVersion ${String(record.sv)}`);
329
+ }
330
+ if (Number(record.pin) !== descriptor.asOfCommitSeq) {
331
+ imageInvalid(`asOfCommitSeq ${String(record.pin)}`);
332
+ }
333
+ if (record.sd !== descriptor.scopeDigest) {
334
+ imageInvalid('scopeDigest mismatch');
335
+ }
336
+ if (Number(record.rc) !== descriptor.rowCount) {
337
+ imageInvalid(`rowCount ${String(record.rc)}`);
338
+ }
339
+
340
+ // 2. Column names and order vs the generated schema (§5.3 rule 3 —
341
+ // sync.schema_mismatch, the §5.2 rule specialized).
342
+ const info = db.query(
343
+ `PRAGMA ${IMAGE_ALIAS}.table_info(${quoteIdent(table.name)})`,
344
+ );
345
+ const expected = [
346
+ ...table.columns.map((column) => column.name),
347
+ '_syncular_version',
348
+ ];
349
+ const actual = info.map((row) => String(row.name));
350
+ if (
351
+ actual.length !== expected.length ||
352
+ expected.some((name, index) => actual[index] !== name)
353
+ ) {
354
+ throw new ClientSyncError(
355
+ 'sync.schema_mismatch',
356
+ `sqlite segment for ${JSON.stringify(table.name)} does not match the generated schema: columns [${actual.join(', ')}] (§5.3)`,
357
+ );
358
+ }
359
+
360
+ // 3. One transaction: fresh-bootstrap clear, then replace-or-upsert.
361
+ const names = table.columns.map((column) => quoteIdent(column.name));
362
+ return db.transaction(() => {
363
+ if (options.clearFirst) {
364
+ deleteScopedRows(db, table, options.effective);
365
+ }
366
+ db.exec(
367
+ `INSERT OR REPLACE INTO ${quoteIdent(table.name)}
368
+ (${[...names, quoteIdent(SYNC_VERSION_COLUMN)].join(', ')})
369
+ SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
370
+ FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`,
371
+ );
372
+ const counted = db.query(
373
+ `SELECT count(*) AS n FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`,
374
+ )[0];
375
+ const applied = Number(counted?.n ?? 0);
376
+ if (applied !== descriptor.rowCount) {
377
+ imageInvalid(
378
+ `image holds ${applied} rows, descriptor says ${descriptor.rowCount}`,
379
+ );
380
+ }
381
+ return applied;
382
+ });
383
+ });
384
+ }
385
+
386
+ /**
387
+ * Apply a decoded rows segment: each block in one local transaction
388
+ * (§5.2/§1.4); `clearFirst` implements the §5.6 fresh-bootstrap first-page
389
+ * delete inside the first block's transaction. Each row record carries its
390
+ * `serverVersion` (§5.2), which lands in `_sync_version` exactly like a
391
+ * `COMMIT` change's `rowVersion` (§5.6) — bootstrapped rows seed §6.2
392
+ * `baseVersion` conflict detection immediately.
393
+ */
394
+ export async function applyRowsSegment(
395
+ db: ClientDatabase,
396
+ schema: CompiledClientSchema,
397
+ table: CompiledClientTable,
398
+ segment: RowsSegment,
399
+ options: { readonly clearFirst: boolean; readonly effective: ScopeMap },
400
+ encryption?: EncryptionConfig,
401
+ ): Promise<number> {
402
+ validateSegmentColumns(schema, table, segment);
403
+ let applied = 0;
404
+ let first = true;
405
+ const blocks: readonly (readonly SegmentRow[])[] =
406
+ segment.blocks.length > 0 ? segment.blocks : [[]];
407
+ for (const block of blocks) {
408
+ // §5.11: decrypt this block's rows before opening the sync transaction
409
+ // (WebCrypto is async; the SQLite transaction is not). Decrypt failure
410
+ // aborts before any write in this block.
411
+ const rows: { values: readonly RowValue[]; serverVersion: number }[] = [];
412
+ for (const row of block) {
413
+ const values =
414
+ encryption !== undefined && table.hasEncryptedColumns
415
+ ? await (async () => {
416
+ const { decryptRowValues } = await import('./encryption');
417
+ return decryptRowValues(encryption, table, row.values);
418
+ })()
419
+ : row.values;
420
+ rows.push({ values, serverVersion: row.serverVersion });
421
+ }
422
+ const clearThisBlock = first && options.clearFirst;
423
+ first = false;
424
+ db.transaction(() => {
425
+ if (clearThisBlock) {
426
+ deleteScopedRows(db, table, options.effective);
427
+ }
428
+ for (const row of rows) {
429
+ upsertLocalRow(db, table, row.values, row.serverVersion);
430
+ applied += 1;
431
+ }
432
+ });
433
+ }
434
+ return applied;
435
+ }