@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/blob.ts ADDED
@@ -0,0 +1,333 @@
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';
15
+ import type { CompiledClientSchema } from './schema';
16
+ import { quoteIdent } from './schema';
17
+
18
+ /**
19
+ * A blob download result (§5.9.5). The authorized endpoint either serves the
20
+ * bytes inline, or — when the host configured presigned URLs (always-issue) —
21
+ * returns a short-TTL `url` the client MUST fetch directly (no host auth),
22
+ * verify the content address on, and on failure re-request (never fall
23
+ * through). The client core routes on which arm is present.
24
+ */
25
+ export type BlobDownloadResponse =
26
+ | { readonly kind: 'bytes'; readonly bytes: Uint8Array }
27
+ | {
28
+ readonly kind: 'url';
29
+ readonly url: string;
30
+ readonly urlExpiresAtMs?: number;
31
+ };
32
+
33
+ /**
34
+ * A presigned-upload grant (§5.9.3). Either a single PUT `url` the client uses
35
+ * direct-to-storage; or `present` (the blob already exists, skip the PUT); or
36
+ * `none` (no presigned-upload store — the client streams through the direct
37
+ * upload endpoint, a capability choice, not a fallback).
38
+ */
39
+ export type BlobUploadGrant =
40
+ | {
41
+ readonly kind: 'url';
42
+ readonly url: string;
43
+ readonly urlExpiresAtMs?: number;
44
+ }
45
+ | { readonly kind: 'present' }
46
+ | { readonly kind: 'none' };
47
+
48
+ /** The transport seam for blob upload/download (§5.9.3/§5.9.5). */
49
+ export interface BlobTransport {
50
+ /** `PUT <mount>/blobs/{blobId}` — host-authenticated direct upload (§5.9.3). */
51
+ upload(blobId: string, bytes: Uint8Array, mediaType?: string): Promise<void>;
52
+ /**
53
+ * `GET <mount>/blobs/{blobId}` — re-authorized (§5.9.5). Returns inline
54
+ * bytes, or a presigned `url` the client core fetches via `fetchUrl`.
55
+ */
56
+ download(blobId: string): Promise<BlobDownloadResponse>;
57
+ /**
58
+ * §5.9.5 presigned-download fetch: a bare GET of the signed `url`. Present
59
+ * iff the transport can consume URLs. MUST attach NO host authentication —
60
+ * the URL is the entire grant (§5.4). Only called when `download` returned
61
+ * a `url` arm.
62
+ */
63
+ fetchUrl?(url: string): Promise<Uint8Array>;
64
+ /**
65
+ * §5.9.3 presigned-upload grant: `POST /blobs/{blobId}/upload-grant` with
66
+ * the declared size. Present iff the transport supports the grant flow;
67
+ * absent ⇒ the client always streams through `upload`. A `url` grant is
68
+ * PUT via `uploadToUrl`.
69
+ */
70
+ uploadGrant?(
71
+ blobId: string,
72
+ byteLength: number,
73
+ mediaType?: string,
74
+ ): Promise<BlobUploadGrant>;
75
+ /**
76
+ * §5.9.3 direct-to-storage PUT of the granted `url`. MUST attach NO host
77
+ * authentication — the presigned URL is the entire grant (§5.4). Only
78
+ * called when `uploadGrant` returned a `url` arm.
79
+ */
80
+ uploadToUrl?(
81
+ url: string,
82
+ bytes: Uint8Array,
83
+ mediaType?: string,
84
+ ): Promise<void>;
85
+ }
86
+
87
+ export interface CachedBlob {
88
+ readonly blobId: string;
89
+ readonly bytes: Uint8Array;
90
+ readonly byteLength: number;
91
+ readonly mediaType?: string;
92
+ }
93
+
94
+ /** `"sha256:" + hex` of the bytes — the content address (§5.9.1). */
95
+ export async function computeBlobId(bytes: Uint8Array): Promise<string> {
96
+ const digest = await crypto.subtle.digest(
97
+ 'SHA-256',
98
+ bytes.slice().buffer as ArrayBuffer,
99
+ );
100
+ const hex = [...new Uint8Array(digest)]
101
+ .map((b) => b.toString(16).padStart(2, '0'))
102
+ .join('');
103
+ return `sha256:${hex}`;
104
+ }
105
+
106
+ export function ensureBlobSchema(db: ClientDatabase): void {
107
+ db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blobs(
108
+ blob_id TEXT PRIMARY KEY,
109
+ bytes BLOB NOT NULL,
110
+ byte_length INTEGER NOT NULL,
111
+ media_type TEXT,
112
+ refcount INTEGER NOT NULL DEFAULT 0,
113
+ created_at_ms INTEGER NOT NULL,
114
+ last_used_ms INTEGER NOT NULL DEFAULT 0)`);
115
+ // Migrate a cache created before the §5.9.7 B1 LRU column: additive,
116
+ // idempotent (a duplicate-column error on an already-migrated DB is
117
+ // swallowed). last_used_ms drives cap eviction (LRU of zero-ref bodies).
118
+ try {
119
+ db.exec(
120
+ 'ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0',
121
+ );
122
+ } catch {
123
+ // column already exists — the CREATE above included it
124
+ }
125
+ db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
126
+ blob_id TEXT PRIMARY KEY,
127
+ media_type TEXT,
128
+ created_at_ms INTEGER NOT NULL)`);
129
+ }
130
+
131
+ /** Put bytes into the content-addressed cache (idempotent); touches LRU. */
132
+ export function putCachedBlob(
133
+ db: ClientDatabase,
134
+ blobId: string,
135
+ bytes: Uint8Array,
136
+ nowMs: number,
137
+ mediaType?: string,
138
+ ): void {
139
+ db.exec(
140
+ `INSERT INTO _syncular_blobs(
141
+ blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms)
142
+ VALUES (?,?,?,?,0,?,?)
143
+ ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms`,
144
+ [blobId, bytes, bytes.length, mediaType ?? null, nowMs, nowMs],
145
+ );
146
+ }
147
+
148
+ export function getCachedBlob(
149
+ db: ClientDatabase,
150
+ blobId: string,
151
+ nowMs?: number,
152
+ ): CachedBlob | undefined {
153
+ const rows = db.query(
154
+ 'SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?',
155
+ [blobId],
156
+ );
157
+ const row = rows[0];
158
+ if (row === undefined) return undefined;
159
+ // §5.9.7 B1 LRU: a cache-hit read touches "recently used" so a hot image
160
+ // survives a cap trim. Skipped when no clock is supplied (pure read).
161
+ if (nowMs !== undefined) {
162
+ db.exec('UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?', [
163
+ nowMs,
164
+ blobId,
165
+ ]);
166
+ }
167
+ return {
168
+ blobId,
169
+ bytes: row.bytes as Uint8Array,
170
+ byteLength: Number(row.byte_length),
171
+ ...(row.media_type !== null ? { mediaType: row.media_type as string } : {}),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * §5.9.7 B1 size cap + LRU eviction. When the sum of cached body sizes exceeds
177
+ * `maxBytes`, evict **zero-ref, non-pinned** bodies in least-recently-used
178
+ * order until back under the cap. NEVER evicts a referenced body (refcount > 0
179
+ * — it must stay resolvable without a re-download) nor a pending-upload-pinned
180
+ * body (its bytes are the only copy until push, B4). If every over-cap body is
181
+ * referenced or pinned, the cache stays over the cap (correctness beats the
182
+ * cap). Evicting a zero-ref body is always safe: B3 re-enables the fetch from
183
+ * any surviving `blob_ref` value. Returns the evicted blobIds.
184
+ */
185
+ export function enforceBlobCacheCap(
186
+ db: ClientDatabase,
187
+ maxBytes: number,
188
+ ): string[] {
189
+ const totalRow = db.query(
190
+ 'SELECT COALESCE(SUM(byte_length), 0) AS total FROM _syncular_blobs',
191
+ )[0];
192
+ let total = Number(totalRow?.total ?? 0);
193
+ if (total <= maxBytes) return [];
194
+ // Eviction candidates: zero-ref AND not pinned by a pending upload, oldest
195
+ // (LRU) first, then oldest created as a stable tiebreak.
196
+ const candidates = db.query(
197
+ `SELECT blob_id, byte_length FROM _syncular_blobs
198
+ WHERE refcount = 0
199
+ AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
200
+ ORDER BY last_used_ms ASC, created_at_ms ASC`,
201
+ );
202
+ const evicted: string[] = [];
203
+ db.transaction(() => {
204
+ for (const row of candidates) {
205
+ if (total <= maxBytes) break;
206
+ const blobId = row.blob_id as string;
207
+ db.exec('DELETE FROM _syncular_blobs WHERE blob_id = ?', [blobId]);
208
+ total -= Number(row.byte_length);
209
+ evicted.push(blobId);
210
+ }
211
+ });
212
+ return evicted;
213
+ }
214
+
215
+ /** Record a pending upload (§5.9.7 B4); flushed before the next push. */
216
+ export function recordPendingUpload(
217
+ db: ClientDatabase,
218
+ blobId: string,
219
+ nowMs: number,
220
+ mediaType?: string,
221
+ ): void {
222
+ db.exec(
223
+ `INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms)
224
+ VALUES (?,?,?)`,
225
+ [blobId, mediaType ?? null, nowMs],
226
+ );
227
+ }
228
+
229
+ export function listPendingUploads(
230
+ db: ClientDatabase,
231
+ ): { blobId: string; mediaType?: string }[] {
232
+ return db
233
+ .query(
234
+ 'SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms',
235
+ )
236
+ .map((row) => ({
237
+ blobId: row.blob_id as string,
238
+ ...(row.media_type !== null
239
+ ? { mediaType: row.media_type as string }
240
+ : {}),
241
+ }));
242
+ }
243
+
244
+ export function clearPendingUpload(db: ClientDatabase, blobId: string): void {
245
+ db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id = ?', [blobId]);
246
+ }
247
+
248
+ /**
249
+ * All `blob_ref` column names per table (for refcount reconciliation).
250
+ * Blank result ⇒ the schema has no attachments; callers skip reconciliation.
251
+ */
252
+ export function blobRefColumnsBySchema(
253
+ schema: CompiledClientSchema,
254
+ ): Map<string, string[]> {
255
+ const out = new Map<string, string[]>();
256
+ for (const table of schema.tables.values()) {
257
+ const cols = table.columns
258
+ .filter((c) => c.type === 'blob_ref')
259
+ .map((c) => c.name);
260
+ if (cols.length > 0) out.set(table.name, cols);
261
+ }
262
+ return out;
263
+ }
264
+
265
+ export function schemaHasBlobs(schema: CompiledClientSchema): boolean {
266
+ for (const table of schema.tables.values()) {
267
+ if (table.columns.some((c) => c.type === 'blob_ref')) return true;
268
+ }
269
+ return false;
270
+ }
271
+
272
+ /**
273
+ * §5.9.7 B1/B2: recompute cache refcounts from the current `blob_ref` column
274
+ * contents across all synced tables, then delete cache bodies that dropped
275
+ * to zero refs *and* have no pending upload (a pending upload pins its body,
276
+ * B4). Called after every apply/purge that may add or remove references.
277
+ *
278
+ * `deleteOrphans` distinguishes the two B2 transitions: revocation purge
279
+ * passes `true` (drop the now-unauthorized body); a benign apply passes
280
+ * `false` (retain zero-ref bodies as LRU cache entries — the shipped
281
+ * default). Bodies pinned by a pending upload are always retained.
282
+ */
283
+ export function reconcileBlobRefcounts(
284
+ db: ClientDatabase,
285
+ schema: CompiledClientSchema,
286
+ options?: { readonly deleteOrphans?: boolean },
287
+ ): void {
288
+ const byTable = blobRefColumnsBySchema(schema);
289
+ if (byTable.size === 0) return;
290
+ // Count references to each blobId across every blob_ref column.
291
+ const counts = new Map<string, number>();
292
+ for (const [tableName, columns] of byTable) {
293
+ for (const column of columns) {
294
+ const rows = db.query(
295
+ `SELECT ${quoteIdent(column)} AS v FROM ${quoteIdent(tableName)}
296
+ WHERE ${quoteIdent(column)} IS NOT NULL`,
297
+ );
298
+ for (const row of rows) {
299
+ const raw = row.v;
300
+ if (typeof raw !== 'string') continue;
301
+ let ref: BlobRef;
302
+ try {
303
+ ref = parseBlobRef(raw);
304
+ } catch {
305
+ continue;
306
+ }
307
+ counts.set(ref.blobId, (counts.get(ref.blobId) ?? 0) + 1);
308
+ }
309
+ }
310
+ }
311
+ db.transaction(() => {
312
+ // Reset all refcounts, then apply the recomputed counts.
313
+ db.exec('UPDATE _syncular_blobs SET refcount = 0');
314
+ for (const [blobId, count] of counts) {
315
+ db.exec('UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?', [
316
+ count,
317
+ blobId,
318
+ ]);
319
+ }
320
+ if (options?.deleteOrphans === true) {
321
+ // §5.9.7 B2 revocation side: delete zero-ref bodies not pinned by a
322
+ // pending upload.
323
+ db.exec(
324
+ `DELETE FROM _syncular_blobs
325
+ WHERE refcount = 0
326
+ AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)`,
327
+ );
328
+ }
329
+ });
330
+ }
331
+
332
+ export type { BlobRef };
333
+ export { parseBlobRef, serializeBlobRef };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `ClientDatabase` on bun:sqlite — the test backend (REVISE B3: the core
3
+ * must run without a browser). Not exported from the package root so the
4
+ * browser entry never touches `bun:sqlite`.
5
+ */
6
+ import { Database } from 'bun:sqlite';
7
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import {
11
+ assertImageAlias,
12
+ type ClientDatabase,
13
+ runTransaction,
14
+ type SqlRow,
15
+ type SqlValue,
16
+ } from './database';
17
+
18
+ type BunParam = string | number | bigint | Uint8Array | null;
19
+
20
+ function coerceParams(params: readonly SqlValue[]): BunParam[] {
21
+ return params.map((value): BunParam => {
22
+ if (typeof value === 'boolean') return value ? 1 : 0;
23
+ return value;
24
+ });
25
+ }
26
+
27
+ export class BunClientDatabase implements ClientDatabase {
28
+ readonly db: Database;
29
+ #tx = { depth: 0 };
30
+
31
+ constructor(path = ':memory:') {
32
+ this.db = new Database(path);
33
+ }
34
+
35
+ exec(sql: string, params: readonly SqlValue[] = []): void {
36
+ this.db.query(sql).run(...coerceParams(params));
37
+ }
38
+
39
+ query(sql: string, params: readonly SqlValue[] = []): SqlRow[] {
40
+ return this.db.query(sql).all(...coerceParams(params)) as SqlRow[];
41
+ }
42
+
43
+ transaction<T>(fn: () => T): T {
44
+ return runTransaction(this.#tx, (sql) => this.db.run(sql), fn);
45
+ }
46
+
47
+ /**
48
+ * §5.3 image import: bun:sqlite attaches files, not buffers, so the
49
+ * image lands in a private temp file for the duration of the ATTACH.
50
+ */
51
+ withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
52
+ assertImageAlias(alias);
53
+ const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
54
+ const path = join(dir, 'segment.db');
55
+ try {
56
+ writeFileSync(path, bytes);
57
+ this.db.run(`ATTACH DATABASE ? AS ${alias}`, [path]);
58
+ try {
59
+ return fn();
60
+ } finally {
61
+ this.db.run(`DETACH DATABASE ${alias}`);
62
+ }
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ }
67
+
68
+ close(): void {
69
+ this.db.close();
70
+ }
71
+ }
72
+
73
+ export function openBunDatabase(path = ':memory:'): ClientDatabase {
74
+ return new BunClientDatabase(path);
75
+ }