@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
@@ -0,0 +1,80 @@
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 { type CommitFrame, type RowsSegment, type RowValue, type ScopeMap } from '@syncular/core';
7
+ import type { ClientDatabase } from './database.js';
8
+ import type { EncryptionConfig } from './encryption.js';
9
+ import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
10
+ export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number): void;
11
+ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string): void;
12
+ /**
13
+ * Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
14
+ * Upserts land with `_sync_version = rowVersion`; deletes remove the row.
15
+ * Re-application is idempotent (§1.4 rule 5).
16
+ *
17
+ * §5.11: encrypted columns are decrypted here — the apply seam. Because
18
+ * WebCrypto is async and the local transaction is synchronous, every row is
19
+ * decoded AND decrypted first (outside the transaction), then the resolved
20
+ * plaintext rows are applied atomically. A decrypt failure
21
+ * (`client.decrypt_failed`) aborts before any local write, so the commit
22
+ * never half-applies.
23
+ */
24
+ export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig): Promise<void>;
25
+ /**
26
+ * §5.2: the segment's column table must match the generated schema for
27
+ * (table, schemaVersion) — order, names, types, nullability. A mismatch is
28
+ * fatal (`sync.schema_mismatch`): the descriptor validates, never infers.
29
+ */
30
+ export declare function validateSegmentColumns(schema: CompiledClientSchema, table: CompiledClientTable, segment: RowsSegment): void;
31
+ /**
32
+ * Scope-matched local delete (§3.3 purge / §5.6 first-page rule): delete
33
+ * rows whose generated local scope columns match `effective` — every key's
34
+ * column value must be in the key's value list. Fails closed
35
+ * (`sync.scope_revoked`) when the table has no local mapping for a key:
36
+ * precision or nothing, never clear-the-table.
37
+ */
38
+ export declare function deleteScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap): void;
39
+ /**
40
+ * §4.8 window eviction: delete rows matching a departing unit's effective
41
+ * scopes (same local-scope-column rule and fail-closed clause as
42
+ * {@link deleteScopedRows}) EXCEPT rows whose primary key is in
43
+ * `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
44
+ * `true` iff any pinned row was left behind, so the caller knows to defer
45
+ * the rest of the eviction until the outbox drains. Also removes the
46
+ * evicted rows' `server_version` with them (E2 — no residual version
47
+ * cache), which is automatic since the version column is per-row.
48
+ */
49
+ export declare function evictScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap, pinnedRowIds: ReadonlySet<string>): boolean;
50
+ /** Descriptor fields a sqlite image is validated against (§5.3). */
51
+ export interface SqliteSegmentDescriptor {
52
+ readonly table: string;
53
+ readonly rowCount: number;
54
+ readonly asOfCommitSeq: number;
55
+ readonly scopeDigest: string;
56
+ }
57
+ /**
58
+ * Apply a §5.3 sqlite-image segment in ONE local transaction: validate
59
+ * the in-file metadata against the descriptor, validate the data table's
60
+ * column names/order against the generated schema, run the §5.6
61
+ * first-page clear when fresh, then copy every row with a single
62
+ * `INSERT OR REPLACE … SELECT` — `_syncular_version` lands in
63
+ * `_sync_version` exactly like a rows segment's per-row `serverVersion`.
64
+ */
65
+ export declare function applySqliteSegment(db: ClientDatabase, schema: CompiledClientSchema, table: CompiledClientTable, bytes: Uint8Array, descriptor: SqliteSegmentDescriptor, options: {
66
+ readonly clearFirst: boolean;
67
+ readonly effective: ScopeMap;
68
+ }): number;
69
+ /**
70
+ * Apply a decoded rows segment: each block in one local transaction
71
+ * (§5.2/§1.4); `clearFirst` implements the §5.6 fresh-bootstrap first-page
72
+ * delete inside the first block's transaction. Each row record carries its
73
+ * `serverVersion` (§5.2), which lands in `_sync_version` exactly like a
74
+ * `COMMIT` change's `rowVersion` (§5.6) — bootstrapped rows seed §6.2
75
+ * `baseVersion` conflict detection immediately.
76
+ */
77
+ export declare function applyRowsSegment(db: ClientDatabase, schema: CompiledClientSchema, table: CompiledClientTable, segment: RowsSegment, options: {
78
+ readonly clearFirst: boolean;
79
+ readonly effective: ScopeMap;
80
+ }, encryption?: EncryptionConfig): Promise<number>;
package/dist/apply.js ADDED
@@ -0,0 +1,301 @@
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 { decodeRow, } from '@syncular/core';
7
+ import { ClientSyncError } from './errors.js';
8
+ import { quoteIdent, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
9
+ function upsertSql(table) {
10
+ const names = [
11
+ ...table.columns.map((column) => quoteIdent(column.name)),
12
+ quoteIdent(SYNC_VERSION_COLUMN),
13
+ ];
14
+ const placeholders = names.map(() => '?').join(', ');
15
+ return `INSERT OR REPLACE INTO ${quoteIdent(table.name)} (${names.join(', ')}) VALUES (${placeholders})`;
16
+ }
17
+ export function upsertLocalRow(db, table, values, syncVersion) {
18
+ db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
19
+ }
20
+ export function deleteLocalRow(db, table, rowId) {
21
+ db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId]);
22
+ }
23
+ /**
24
+ * Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
25
+ * Upserts land with `_sync_version = rowVersion`; deletes remove the row.
26
+ * Re-application is idempotent (§1.4 rule 5).
27
+ *
28
+ * §5.11: encrypted columns are decrypted here — the apply seam. Because
29
+ * WebCrypto is async and the local transaction is synchronous, every row is
30
+ * decoded AND decrypted first (outside the transaction), then the resolved
31
+ * plaintext rows are applied atomically. A decrypt failure
32
+ * (`client.decrypt_failed`) aborts before any local write, so the commit
33
+ * never half-applies.
34
+ */
35
+ export async function applyCommitFrame(db, schema, frame, encryption) {
36
+ const resolved = [];
37
+ for (const change of frame.changes) {
38
+ const tableName = frame.tables[change.tableIndex];
39
+ if (tableName === undefined) {
40
+ throw new ClientSyncError('sync.invalid_request', `COMMIT change tableIndex ${change.tableIndex} out of range`);
41
+ }
42
+ const table = schema.tables.get(tableName);
43
+ if (table === undefined) {
44
+ throw new ClientSyncError('sync.schema_mismatch', `COMMIT delivers unknown local table ${JSON.stringify(tableName)}`);
45
+ }
46
+ if (change.op === 'delete') {
47
+ resolved.push({ op: 'delete', table, rowId: change.rowId });
48
+ continue;
49
+ }
50
+ if (change.row === undefined || change.rowVersion === undefined) {
51
+ throw new ClientSyncError('sync.invalid_request', 'upsert change without row payload');
52
+ }
53
+ let values = decodeRow(table.columns, change.row);
54
+ if (encryption !== undefined && table.hasEncryptedColumns) {
55
+ const { decryptRowValues } = await import('./encryption.js');
56
+ values = await decryptRowValues(encryption, table, values);
57
+ }
58
+ resolved.push({
59
+ op: 'upsert',
60
+ table,
61
+ values,
62
+ rowVersion: change.rowVersion,
63
+ });
64
+ }
65
+ db.transaction(() => {
66
+ for (const change of resolved) {
67
+ if (change.op === 'delete') {
68
+ deleteLocalRow(db, change.table, change.rowId);
69
+ }
70
+ else {
71
+ upsertLocalRow(db, change.table, change.values, change.rowVersion);
72
+ }
73
+ }
74
+ });
75
+ }
76
+ /**
77
+ * §5.2: the segment's column table must match the generated schema for
78
+ * (table, schemaVersion) — order, names, types, nullability. A mismatch is
79
+ * fatal (`sync.schema_mismatch`): the descriptor validates, never infers.
80
+ */
81
+ export function validateSegmentColumns(schema, table, segment) {
82
+ const mismatch = (detail) => {
83
+ throw new ClientSyncError('sync.schema_mismatch', `rows segment for ${JSON.stringify(segment.table)} does not match the generated schema: ${detail}`);
84
+ };
85
+ if (segment.table !== table.name) {
86
+ mismatch(`segment table ${JSON.stringify(segment.table)}`);
87
+ }
88
+ if (segment.schemaVersion !== schema.version) {
89
+ mismatch(`segment schemaVersion ${segment.schemaVersion}`);
90
+ }
91
+ if (segment.columns.length !== table.columns.length) {
92
+ mismatch(`column count ${segment.columns.length}`);
93
+ }
94
+ for (let i = 0; i < table.columns.length; i++) {
95
+ const expected = table.columns[i];
96
+ const actual = segment.columns[i];
97
+ if (expected === undefined || actual === undefined)
98
+ continue;
99
+ if (expected.name !== actual.name ||
100
+ expected.type !== actual.type ||
101
+ expected.nullable !== actual.nullable) {
102
+ mismatch(`column ${i} (${actual.name})`);
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Scope-matched local delete (§3.3 purge / §5.6 first-page rule): delete
108
+ * rows whose generated local scope columns match `effective` — every key's
109
+ * column value must be in the key's value list. Fails closed
110
+ * (`sync.scope_revoked`) when the table has no local mapping for a key:
111
+ * precision or nothing, never clear-the-table.
112
+ */
113
+ export function deleteScopedRows(db, table, effective) {
114
+ const entries = Object.entries(effective);
115
+ if (entries.length === 0)
116
+ return;
117
+ const clauses = [];
118
+ const params = [];
119
+ for (const [variable, values] of entries) {
120
+ const column = table.scopeColumnByVariable.get(variable);
121
+ if (column === undefined) {
122
+ throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§3.3 fail-closed)`);
123
+ }
124
+ if (values.length === 0)
125
+ return;
126
+ clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
127
+ params.push(...values);
128
+ }
129
+ db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params);
130
+ }
131
+ /**
132
+ * §4.8 window eviction: delete rows matching a departing unit's effective
133
+ * scopes (same local-scope-column rule and fail-closed clause as
134
+ * {@link deleteScopedRows}) EXCEPT rows whose primary key is in
135
+ * `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
136
+ * `true` iff any pinned row was left behind, so the caller knows to defer
137
+ * the rest of the eviction until the outbox drains. Also removes the
138
+ * evicted rows' `server_version` with them (E2 — no residual version
139
+ * cache), which is automatic since the version column is per-row.
140
+ */
141
+ export function evictScopedRows(db, table, effective, pinnedRowIds) {
142
+ const entries = Object.entries(effective);
143
+ if (entries.length === 0)
144
+ return false;
145
+ const clauses = [];
146
+ const params = [];
147
+ for (const [variable, values] of entries) {
148
+ const column = table.scopeColumnByVariable.get(variable);
149
+ if (column === undefined) {
150
+ throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`);
151
+ }
152
+ if (values.length === 0)
153
+ return false;
154
+ clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
155
+ params.push(...values);
156
+ }
157
+ const pk = quoteIdent(table.primaryKey);
158
+ let pinnedClause = '';
159
+ if (pinnedRowIds.size > 0) {
160
+ const ids = [...pinnedRowIds];
161
+ pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
162
+ params.push(...ids);
163
+ }
164
+ db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}${pinnedClause}`, params);
165
+ if (pinnedRowIds.size === 0)
166
+ return false;
167
+ // A pin still matters only if a pinned row actually falls inside this
168
+ // unit's effective scopes; check by re-selecting the survivors.
169
+ const survivors = db.query(`SELECT ${pk} AS pk FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params.slice(0, params.length - pinnedRowIds.size));
170
+ for (const row of survivors) {
171
+ if (pinnedRowIds.has(String(row.pk)))
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+ const IMAGE_ALIAS = 'syncular_image';
177
+ function imageInvalid(detail) {
178
+ throw new ClientSyncError('sync.invalid_request', `sqlite segment rejected: ${detail} (§5.3)`);
179
+ }
180
+ /**
181
+ * Apply a §5.3 sqlite-image segment in ONE local transaction: validate
182
+ * the in-file metadata against the descriptor, validate the data table's
183
+ * column names/order against the generated schema, run the §5.6
184
+ * first-page clear when fresh, then copy every row with a single
185
+ * `INSERT OR REPLACE … SELECT` — `_syncular_version` lands in
186
+ * `_sync_version` exactly like a rows segment's per-row `serverVersion`.
187
+ */
188
+ export function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
189
+ const withImage = db.withSqliteImage?.bind(db);
190
+ if (withImage === undefined) {
191
+ throw new ClientSyncError('sync.invalid_request', 'received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)');
192
+ }
193
+ if (descriptor.table !== table.name) {
194
+ imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
195
+ }
196
+ return withImage(bytes, IMAGE_ALIAS, () => {
197
+ // 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
198
+ // SQLite database or lacks the metadata table fails right here.
199
+ let meta;
200
+ try {
201
+ meta = db.query(`SELECT format, "table" AS tbl, "schemaVersion" AS sv,
202
+ "asOfCommitSeq" AS pin, "scopeDigest" AS sd,
203
+ "rowCount" AS rc
204
+ FROM ${IMAGE_ALIAS}."_syncular_segment"`);
205
+ }
206
+ catch {
207
+ imageInvalid('bytes are not a SQLite image with _syncular_segment');
208
+ }
209
+ const record = meta[0];
210
+ if (meta.length !== 1 || record === undefined) {
211
+ imageInvalid('_syncular_segment must contain exactly one row');
212
+ }
213
+ if (record.format !== 1)
214
+ imageInvalid(`format ${String(record.format)}`);
215
+ if (record.tbl !== table.name) {
216
+ imageInvalid(`image table ${String(record.tbl)}`);
217
+ }
218
+ if (Number(record.sv) !== schema.version) {
219
+ imageInvalid(`schemaVersion ${String(record.sv)}`);
220
+ }
221
+ if (Number(record.pin) !== descriptor.asOfCommitSeq) {
222
+ imageInvalid(`asOfCommitSeq ${String(record.pin)}`);
223
+ }
224
+ if (record.sd !== descriptor.scopeDigest) {
225
+ imageInvalid('scopeDigest mismatch');
226
+ }
227
+ if (Number(record.rc) !== descriptor.rowCount) {
228
+ imageInvalid(`rowCount ${String(record.rc)}`);
229
+ }
230
+ // 2. Column names and order vs the generated schema (§5.3 rule 3 —
231
+ // sync.schema_mismatch, the §5.2 rule specialized).
232
+ const info = db.query(`PRAGMA ${IMAGE_ALIAS}.table_info(${quoteIdent(table.name)})`);
233
+ const expected = [
234
+ ...table.columns.map((column) => column.name),
235
+ '_syncular_version',
236
+ ];
237
+ const actual = info.map((row) => String(row.name));
238
+ if (actual.length !== expected.length ||
239
+ expected.some((name, index) => actual[index] !== name)) {
240
+ throw new ClientSyncError('sync.schema_mismatch', `sqlite segment for ${JSON.stringify(table.name)} does not match the generated schema: columns [${actual.join(', ')}] (§5.3)`);
241
+ }
242
+ // 3. One transaction: fresh-bootstrap clear, then replace-or-upsert.
243
+ const names = table.columns.map((column) => quoteIdent(column.name));
244
+ return db.transaction(() => {
245
+ if (options.clearFirst) {
246
+ deleteScopedRows(db, table, options.effective);
247
+ }
248
+ db.exec(`INSERT OR REPLACE INTO ${quoteIdent(table.name)}
249
+ (${[...names, quoteIdent(SYNC_VERSION_COLUMN)].join(', ')})
250
+ SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
251
+ FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`);
252
+ const counted = db.query(`SELECT count(*) AS n FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`)[0];
253
+ const applied = Number(counted?.n ?? 0);
254
+ if (applied !== descriptor.rowCount) {
255
+ imageInvalid(`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`);
256
+ }
257
+ return applied;
258
+ });
259
+ });
260
+ }
261
+ /**
262
+ * Apply a decoded rows segment: each block in one local transaction
263
+ * (§5.2/§1.4); `clearFirst` implements the §5.6 fresh-bootstrap first-page
264
+ * delete inside the first block's transaction. Each row record carries its
265
+ * `serverVersion` (§5.2), which lands in `_sync_version` exactly like a
266
+ * `COMMIT` change's `rowVersion` (§5.6) — bootstrapped rows seed §6.2
267
+ * `baseVersion` conflict detection immediately.
268
+ */
269
+ export async function applyRowsSegment(db, schema, table, segment, options, encryption) {
270
+ validateSegmentColumns(schema, table, segment);
271
+ let applied = 0;
272
+ let first = true;
273
+ const blocks = segment.blocks.length > 0 ? segment.blocks : [[]];
274
+ for (const block of blocks) {
275
+ // §5.11: decrypt this block's rows before opening the sync transaction
276
+ // (WebCrypto is async; the SQLite transaction is not). Decrypt failure
277
+ // aborts before any write in this block.
278
+ const rows = [];
279
+ for (const row of block) {
280
+ const values = encryption !== undefined && table.hasEncryptedColumns
281
+ ? await (async () => {
282
+ const { decryptRowValues } = await import('./encryption.js');
283
+ return decryptRowValues(encryption, table, row.values);
284
+ })()
285
+ : row.values;
286
+ rows.push({ values, serverVersion: row.serverVersion });
287
+ }
288
+ const clearThisBlock = first && options.clearFirst;
289
+ first = false;
290
+ db.transaction(() => {
291
+ if (clearThisBlock) {
292
+ deleteScopedRows(db, table, options.effective);
293
+ }
294
+ for (const row of rows) {
295
+ upsertLocalRow(db, table, row.values, row.serverVersion);
296
+ applied += 1;
297
+ }
298
+ });
299
+ }
300
+ return applied;
301
+ }
package/dist/blob.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Client-side blob cache + transport (SPEC.md §5.9.7).
3
+ *
4
+ * Blob bytes are cached content-addressed by `blobId` and refcounted by the
5
+ * local rows whose `blob_ref` columns reference them (B1). The cache is
6
+ * derived from live-row references: after any apply/purge, refcounts are
7
+ * reconciled from the current `blob_ref` column contents, and a body whose
8
+ * only referencing rows were revocation-purged is deleted (B2, evicted ≠
9
+ * revoked). BlobRefs stay resolvable at any time (B3): the `blobId` in the
10
+ * row value is the whole download key. Pending uploads are tracked in the
11
+ * outbox-adjacent uploads table (B4) and flushed before push.
12
+ */
13
+ import { type BlobRef, parseBlobRef, serializeBlobRef } from '@syncular/core';
14
+ import type { ClientDatabase } from './database.js';
15
+ import type { CompiledClientSchema } from './schema.js';
16
+ /**
17
+ * A blob download result (§5.9.5). The authorized endpoint either serves the
18
+ * bytes inline, or — when the host configured presigned URLs (always-issue) —
19
+ * returns a short-TTL `url` the client MUST fetch directly (no host auth),
20
+ * verify the content address on, and on failure re-request (never fall
21
+ * through). The client core routes on which arm is present.
22
+ */
23
+ export type BlobDownloadResponse = {
24
+ readonly kind: 'bytes';
25
+ readonly bytes: Uint8Array;
26
+ } | {
27
+ readonly kind: 'url';
28
+ readonly url: string;
29
+ readonly urlExpiresAtMs?: number;
30
+ };
31
+ /**
32
+ * A presigned-upload grant (§5.9.3). Either a single PUT `url` the client uses
33
+ * direct-to-storage; or `present` (the blob already exists, skip the PUT); or
34
+ * `none` (no presigned-upload store — the client streams through the direct
35
+ * upload endpoint, a capability choice, not a fallback).
36
+ */
37
+ export type BlobUploadGrant = {
38
+ readonly kind: 'url';
39
+ readonly url: string;
40
+ readonly urlExpiresAtMs?: number;
41
+ } | {
42
+ readonly kind: 'present';
43
+ } | {
44
+ readonly kind: 'none';
45
+ };
46
+ /** The transport seam for blob upload/download (§5.9.3/§5.9.5). */
47
+ export interface BlobTransport {
48
+ /** `PUT <mount>/blobs/{blobId}` — host-authenticated direct upload (§5.9.3). */
49
+ upload(blobId: string, bytes: Uint8Array, mediaType?: string): Promise<void>;
50
+ /**
51
+ * `GET <mount>/blobs/{blobId}` — re-authorized (§5.9.5). Returns inline
52
+ * bytes, or a presigned `url` the client core fetches via `fetchUrl`.
53
+ */
54
+ download(blobId: string): Promise<BlobDownloadResponse>;
55
+ /**
56
+ * §5.9.5 presigned-download fetch: a bare GET of the signed `url`. Present
57
+ * iff the transport can consume URLs. MUST attach NO host authentication —
58
+ * the URL is the entire grant (§5.4). Only called when `download` returned
59
+ * a `url` arm.
60
+ */
61
+ fetchUrl?(url: string): Promise<Uint8Array>;
62
+ /**
63
+ * §5.9.3 presigned-upload grant: `POST /blobs/{blobId}/upload-grant` with
64
+ * the declared size. Present iff the transport supports the grant flow;
65
+ * absent ⇒ the client always streams through `upload`. A `url` grant is
66
+ * PUT via `uploadToUrl`.
67
+ */
68
+ uploadGrant?(blobId: string, byteLength: number, mediaType?: string): Promise<BlobUploadGrant>;
69
+ /**
70
+ * §5.9.3 direct-to-storage PUT of the granted `url`. MUST attach NO host
71
+ * authentication — the presigned URL is the entire grant (§5.4). Only
72
+ * called when `uploadGrant` returned a `url` arm.
73
+ */
74
+ uploadToUrl?(url: string, bytes: Uint8Array, mediaType?: string): Promise<void>;
75
+ }
76
+ export interface CachedBlob {
77
+ readonly blobId: string;
78
+ readonly bytes: Uint8Array;
79
+ readonly byteLength: number;
80
+ readonly mediaType?: string;
81
+ }
82
+ /** `"sha256:" + hex` of the bytes — the content address (§5.9.1). */
83
+ export declare function computeBlobId(bytes: Uint8Array): Promise<string>;
84
+ export declare function ensureBlobSchema(db: ClientDatabase): void;
85
+ /** Put bytes into the content-addressed cache (idempotent); touches LRU. */
86
+ export declare function putCachedBlob(db: ClientDatabase, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): void;
87
+ export declare function getCachedBlob(db: ClientDatabase, blobId: string, nowMs?: number): CachedBlob | undefined;
88
+ /**
89
+ * §5.9.7 B1 size cap + LRU eviction. When the sum of cached body sizes exceeds
90
+ * `maxBytes`, evict **zero-ref, non-pinned** bodies in least-recently-used
91
+ * order until back under the cap. NEVER evicts a referenced body (refcount > 0
92
+ * — it must stay resolvable without a re-download) nor a pending-upload-pinned
93
+ * body (its bytes are the only copy until push, B4). If every over-cap body is
94
+ * referenced or pinned, the cache stays over the cap (correctness beats the
95
+ * cap). Evicting a zero-ref body is always safe: B3 re-enables the fetch from
96
+ * any surviving `blob_ref` value. Returns the evicted blobIds.
97
+ */
98
+ export declare function enforceBlobCacheCap(db: ClientDatabase, maxBytes: number): string[];
99
+ /** Record a pending upload (§5.9.7 B4); flushed before the next push. */
100
+ export declare function recordPendingUpload(db: ClientDatabase, blobId: string, nowMs: number, mediaType?: string): void;
101
+ export declare function listPendingUploads(db: ClientDatabase): {
102
+ blobId: string;
103
+ mediaType?: string;
104
+ }[];
105
+ export declare function clearPendingUpload(db: ClientDatabase, blobId: string): void;
106
+ /**
107
+ * All `blob_ref` column names per table (for refcount reconciliation).
108
+ * Blank result ⇒ the schema has no attachments; callers skip reconciliation.
109
+ */
110
+ export declare function blobRefColumnsBySchema(schema: CompiledClientSchema): Map<string, string[]>;
111
+ export declare function schemaHasBlobs(schema: CompiledClientSchema): boolean;
112
+ /**
113
+ * §5.9.7 B1/B2: recompute cache refcounts from the current `blob_ref` column
114
+ * contents across all synced tables, then delete cache bodies that dropped
115
+ * to zero refs *and* have no pending upload (a pending upload pins its body,
116
+ * B4). Called after every apply/purge that may add or remove references.
117
+ *
118
+ * `deleteOrphans` distinguishes the two B2 transitions: revocation purge
119
+ * passes `true` (drop the now-unauthorized body); a benign apply passes
120
+ * `false` (retain zero-ref bodies as LRU cache entries — the shipped
121
+ * default). Bodies pinned by a pending upload are always retained.
122
+ */
123
+ export declare function reconcileBlobRefcounts(db: ClientDatabase, schema: CompiledClientSchema, options?: {
124
+ readonly deleteOrphans?: boolean;
125
+ }): void;
126
+ export type { BlobRef };
127
+ export { parseBlobRef, serializeBlobRef };