@syncular/client 0.18.0 → 0.20.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.
- package/README.md +112 -19
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +32 -8
- package/dist/blob.d.ts +12 -39
- package/dist/blob.js +63 -134
- package/dist/client.d.ts +6 -3
- package/dist/client.js +95 -58
- package/dist/http.d.ts +0 -8
- package/dist/http.js +36 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/outbox.js +3 -0
- package/dist/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/schema.js +3 -0
- package/dist/transport.d.ts +2 -1
- package/dist/wasm-database.js +41 -1
- package/dist/worker-entry.d.ts +2 -0
- package/dist/worker-entry.js +34 -7
- package/dist/worker-host.d.ts +3 -0
- package/dist/worker-host.js +13 -1
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +4 -3
- package/src/apply.ts +39 -13
- package/src/blob.ts +86 -157
- package/src/client.ts +110 -69
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/outbox.ts +3 -0
- package/src/progress.ts +62 -0
- package/src/schema.ts +5 -0
- package/src/transport.ts +5 -1
- package/src/wasm-database.ts +79 -1
- package/src/worker-entry.ts +38 -7
- package/src/worker-host.ts +17 -1
- package/src/worker-protocol.ts +2 -0
package/dist/worker-host.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SyncProgress, type SyncProgressListener } from './progress.js';
|
|
1
2
|
import type { PromiseMethods } from './client.js';
|
|
2
3
|
/**
|
|
3
4
|
* Main-thread side of the worker mode and the
|
|
@@ -179,6 +180,8 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
179
180
|
* unsubscribe function.
|
|
180
181
|
*/
|
|
181
182
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
183
|
+
onProgress(listener: SyncProgressListener): () => void;
|
|
184
|
+
progressSnapshot(): SyncProgress | undefined;
|
|
182
185
|
onChange(listener: ClientChangeListener): () => void;
|
|
183
186
|
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
184
187
|
/**
|
package/dist/worker-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ProgressEmitter, } from './progress.js';
|
|
1
2
|
import { registerDevtools } from './devtools.js';
|
|
2
3
|
import { ClientDiagnosticsEmitter, withClientDiagnosticsHost, } from './diagnostics.js';
|
|
3
4
|
import { ClientSyncError } from './errors.js';
|
|
@@ -68,6 +69,7 @@ export class SyncClientHandle {
|
|
|
68
69
|
#core;
|
|
69
70
|
#follower;
|
|
70
71
|
#invalidation;
|
|
72
|
+
#progress = new ProgressEmitter();
|
|
71
73
|
#changes;
|
|
72
74
|
#presence;
|
|
73
75
|
#diagnostics;
|
|
@@ -156,7 +158,10 @@ export class SyncClientHandle {
|
|
|
156
158
|
}
|
|
157
159
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
158
160
|
__dispatchEvent(event) {
|
|
159
|
-
if (event.kind === '
|
|
161
|
+
if (event.kind === 'progress') {
|
|
162
|
+
this.#progress.emit(event.progress);
|
|
163
|
+
}
|
|
164
|
+
else if (event.kind === 'presence') {
|
|
160
165
|
for (const listener of this.#presence) {
|
|
161
166
|
try {
|
|
162
167
|
listener(event.scopeKey);
|
|
@@ -189,6 +194,12 @@ export class SyncClientHandle {
|
|
|
189
194
|
onInvalidate(listener) {
|
|
190
195
|
return this.#invalidation.on(listener);
|
|
191
196
|
}
|
|
197
|
+
onProgress(listener) {
|
|
198
|
+
return this.#progress.on(listener);
|
|
199
|
+
}
|
|
200
|
+
progressSnapshot() {
|
|
201
|
+
return this.#progress.snapshot();
|
|
202
|
+
}
|
|
192
203
|
onChange(listener) {
|
|
193
204
|
return this.#changes.on(listener);
|
|
194
205
|
}
|
|
@@ -349,6 +360,7 @@ export class SyncClientHandle {
|
|
|
349
360
|
if (this.#closed)
|
|
350
361
|
return;
|
|
351
362
|
this.#closed = true;
|
|
363
|
+
this.#progress.clear();
|
|
352
364
|
this.#devtoolsUnregister();
|
|
353
365
|
if (this.#follower !== undefined) {
|
|
354
366
|
this.#follower.close();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SyncProgress } from './progress.js';
|
|
1
2
|
/**
|
|
2
3
|
* The worker RPC protocol: the whole
|
|
3
4
|
* client core runs in a Web Worker; the UI thread talks to it through
|
|
@@ -151,6 +152,9 @@ export interface WorkerErrorShape {
|
|
|
151
152
|
readonly retryable: boolean;
|
|
152
153
|
}
|
|
153
154
|
export type SyncWorkerEvent = {
|
|
155
|
+
readonly kind: 'progress';
|
|
156
|
+
readonly progress: SyncProgress;
|
|
157
|
+
} | {
|
|
154
158
|
readonly kind: 'sync-needed';
|
|
155
159
|
readonly reason: 'startup' | 'hello' | WakeReason;
|
|
156
160
|
} | {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,9 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.20.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.20.0",
|
|
96
|
+
"playwright": "1.63.0"
|
|
96
97
|
}
|
|
97
98
|
}
|
package/src/apply.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type ScopeMap,
|
|
12
12
|
type SegmentRow,
|
|
13
13
|
} from '@syncular/core';
|
|
14
|
-
import type { ClientDatabase } from './database';
|
|
14
|
+
import type { ClientDatabase, SqlValue } from './database';
|
|
15
15
|
import type { EncryptionConfig } from './encryption';
|
|
16
16
|
import { ClientSyncError } from './errors';
|
|
17
17
|
import {
|
|
@@ -303,6 +303,7 @@ export function applySqliteSegment(
|
|
|
303
303
|
readonly clearFirst: boolean;
|
|
304
304
|
readonly effective: ScopeMap;
|
|
305
305
|
readonly transaction?: ApplyTransaction;
|
|
306
|
+
readonly onProgress?: (rowsProcessed: number) => void;
|
|
306
307
|
},
|
|
307
308
|
): number {
|
|
308
309
|
const withImage = db.withSqliteImage?.bind(db);
|
|
@@ -381,23 +382,45 @@ export function applySqliteSegment(
|
|
|
381
382
|
if (options.clearFirst) {
|
|
382
383
|
deleteScopedRows(db, table, options.effective);
|
|
383
384
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
385
|
+
const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
|
|
386
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
387
|
+
let after: SqlValue | undefined;
|
|
388
|
+
let applied = 0;
|
|
389
|
+
for (;;) {
|
|
390
|
+
const boundary = db.query(
|
|
391
|
+
`SELECT ${primaryKey} AS boundary FROM ${source}
|
|
392
|
+
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
393
|
+
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
|
|
394
|
+
after === undefined ? [] : [after],
|
|
395
|
+
)[0]?.boundary;
|
|
396
|
+
const predicates: string[] = [];
|
|
397
|
+
const params: SqlValue[] = [];
|
|
398
|
+
if (after !== undefined) {
|
|
399
|
+
predicates.push(`${primaryKey} > ?`);
|
|
400
|
+
params.push(after);
|
|
401
|
+
}
|
|
402
|
+
if (boundary !== undefined) {
|
|
403
|
+
predicates.push(`${primaryKey} <= ?`);
|
|
404
|
+
params.push(boundary);
|
|
405
|
+
}
|
|
406
|
+
db.exec(
|
|
407
|
+
`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
|
|
408
|
+
SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
|
|
409
|
+
FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
|
|
410
|
+
ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`,
|
|
411
|
+
params,
|
|
412
|
+
);
|
|
413
|
+
applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
|
|
414
|
+
if (boundary === undefined) break;
|
|
415
|
+
options.onProgress?.(applied);
|
|
416
|
+
after = boundary;
|
|
417
|
+
}
|
|
396
418
|
if (applied !== descriptor.rowCount) {
|
|
397
419
|
imageInvalid(
|
|
398
420
|
`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`,
|
|
399
421
|
);
|
|
400
422
|
}
|
|
423
|
+
options.onProgress?.(applied);
|
|
401
424
|
return applied;
|
|
402
425
|
});
|
|
403
426
|
});
|
|
@@ -420,6 +443,7 @@ export async function applyRowsSegment(
|
|
|
420
443
|
readonly clearFirst: boolean;
|
|
421
444
|
readonly effective: ScopeMap;
|
|
422
445
|
readonly transaction?: ApplyTransaction;
|
|
446
|
+
readonly onProgress?: (rowsProcessed: number) => void;
|
|
423
447
|
},
|
|
424
448
|
encryption?: EncryptionConfig,
|
|
425
449
|
): Promise<number> {
|
|
@@ -453,8 +477,10 @@ export async function applyRowsSegment(
|
|
|
453
477
|
for (const row of rows) {
|
|
454
478
|
upsertLocalRow(db, table, row.values, row.serverVersion);
|
|
455
479
|
applied += 1;
|
|
480
|
+
if (applied % 1024 === 0) options.onProgress?.(applied);
|
|
456
481
|
}
|
|
457
482
|
});
|
|
458
483
|
}
|
|
484
|
+
options.onProgress?.(applied);
|
|
459
485
|
return applied;
|
|
460
486
|
}
|
package/src/blob.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side blob cache + transport (SPEC.md §5.9.7).
|
|
3
3
|
*
|
|
4
|
-
* Blob bytes are cached
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
4
|
+
* Blob bytes are cached by `blobId`. Retention reads live references directly
|
|
5
|
+
* from `blob_ref` columns and keeps explicit pending-commit dependencies.
|
|
6
|
+
* A small upload row queues each staged body for transfer before push.
|
|
12
7
|
*/
|
|
13
8
|
import { type BlobRef, parseBlobRef, serializeBlobRef } from '@syncular/core';
|
|
14
9
|
import type { ClientDatabase } from './database';
|
|
@@ -109,36 +104,43 @@ export async function computeBlobId(bytes: Uint8Array): Promise<string> {
|
|
|
109
104
|
}
|
|
110
105
|
|
|
111
106
|
export function ensureBlobSchema(db: ClientDatabase): void {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
'
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
|
|
107
|
+
const existing = db.query(
|
|
108
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs'",
|
|
109
|
+
);
|
|
110
|
+
if (existing.length > 0) {
|
|
111
|
+
const columns = db
|
|
112
|
+
.query('PRAGMA table_info(_syncular_blobs)')
|
|
113
|
+
.map((row) => row.name)
|
|
114
|
+
.sort();
|
|
115
|
+
const expected = [
|
|
116
|
+
'blob_id',
|
|
117
|
+
'byte_length',
|
|
118
|
+
'bytes',
|
|
119
|
+
'created_at_ms',
|
|
120
|
+
'media_type',
|
|
121
|
+
].sort();
|
|
122
|
+
if (
|
|
123
|
+
columns.length !== expected.length ||
|
|
124
|
+
columns.some((column, index) => column !== expected[index])
|
|
125
|
+
) {
|
|
126
|
+
throw new ClientSyncError(
|
|
127
|
+
'sync.schema_mismatch',
|
|
128
|
+
'Local blob schema is incompatible',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
db.exec(`CREATE TABLE _syncular_blobs(
|
|
133
|
+
blob_id TEXT PRIMARY KEY,
|
|
134
|
+
bytes BLOB NOT NULL,
|
|
135
|
+
byte_length INTEGER NOT NULL,
|
|
136
|
+
media_type TEXT,
|
|
137
|
+
created_at_ms INTEGER NOT NULL)`);
|
|
129
138
|
}
|
|
130
139
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
|
|
131
|
-
blob_id TEXT PRIMARY KEY,
|
|
132
|
-
media_type TEXT,
|
|
133
|
-
created_at_ms INTEGER NOT NULL)`);
|
|
134
|
-
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
|
|
135
|
-
commit_id TEXT NOT NULL, blob_id TEXT NOT NULL, PRIMARY KEY(commit_id, blob_id))`);
|
|
136
|
-
db.exec(
|
|
137
|
-
'CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)',
|
|
138
|
-
);
|
|
140
|
+
blob_id TEXT PRIMARY KEY, media_type TEXT, created_at_ms INTEGER NOT NULL)`);
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
/** Put bytes into the content-addressed cache
|
|
143
|
+
/** Put bytes into the content-addressed cache without rewriting an existing body. */
|
|
142
144
|
export function putCachedBlob(
|
|
143
145
|
db: ClientDatabase,
|
|
144
146
|
blobId: string,
|
|
@@ -148,17 +150,15 @@ export function putCachedBlob(
|
|
|
148
150
|
): void {
|
|
149
151
|
db.exec(
|
|
150
152
|
`INSERT INTO _syncular_blobs(
|
|
151
|
-
blob_id, bytes, byte_length, media_type,
|
|
152
|
-
VALUES (
|
|
153
|
-
|
|
154
|
-
[blobId, bytes, bytes.length, mediaType ?? null, nowMs, nowMs],
|
|
153
|
+
blob_id, bytes, byte_length, media_type, created_at_ms)
|
|
154
|
+
VALUES (?,?,?,?,?) ON CONFLICT(blob_id) DO NOTHING`,
|
|
155
|
+
[blobId, bytes, bytes.length, mediaType ?? null, nowMs],
|
|
155
156
|
);
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
export function getCachedBlob(
|
|
159
160
|
db: ClientDatabase,
|
|
160
161
|
blobId: string,
|
|
161
|
-
nowMs?: number,
|
|
162
162
|
): CachedBlob | undefined {
|
|
163
163
|
const rows = db.query(
|
|
164
164
|
'SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?',
|
|
@@ -166,14 +166,6 @@ export function getCachedBlob(
|
|
|
166
166
|
);
|
|
167
167
|
const row = rows[0];
|
|
168
168
|
if (row === undefined) return undefined;
|
|
169
|
-
// §5.9.7 B1 LRU: a cache-hit read touches "recently used" so a hot image
|
|
170
|
-
// survives a cap trim. Skipped when no clock is supplied (pure read).
|
|
171
|
-
if (nowMs !== undefined) {
|
|
172
|
-
db.exec('UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?', [
|
|
173
|
-
nowMs,
|
|
174
|
-
blobId,
|
|
175
|
-
]);
|
|
176
|
-
}
|
|
177
169
|
return {
|
|
178
170
|
blobId,
|
|
179
171
|
bytes: row.bytes as Uint8Array,
|
|
@@ -183,17 +175,13 @@ export function getCachedBlob(
|
|
|
183
175
|
}
|
|
184
176
|
|
|
185
177
|
/**
|
|
186
|
-
* §5.9.7 B1 size cap
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* — it must stay resolvable without a re-download) nor a pending-upload-pinned
|
|
190
|
-
* body (its bytes are the only copy until push, B4). If every over-cap body is
|
|
191
|
-
* referenced or pinned, the cache stays over the cap (correctness beats the
|
|
192
|
-
* cap). Evicting a zero-ref body is always safe: B3 re-enables the fetch from
|
|
193
|
-
* any surviving `blob_ref` value. Returns the evicted blobIds.
|
|
178
|
+
* §5.9.7 B1 size cap. When the sum of cached body sizes exceeds `maxBytes`,
|
|
179
|
+
* evict unreferenced, non-pinned bodies by creation timestamp, then blob ID,
|
|
180
|
+
* until back under the cap. Returns the evicted blobIds.
|
|
194
181
|
*/
|
|
195
182
|
export function enforceBlobCacheCap(
|
|
196
183
|
db: ClientDatabase,
|
|
184
|
+
schema: CompiledClientSchema,
|
|
197
185
|
maxBytes: number,
|
|
198
186
|
): string[] {
|
|
199
187
|
const totalRow = db.query(
|
|
@@ -201,14 +189,13 @@ export function enforceBlobCacheCap(
|
|
|
201
189
|
)[0];
|
|
202
190
|
let total = Number(totalRow?.total ?? 0);
|
|
203
191
|
if (total <= maxBytes) return [];
|
|
204
|
-
//
|
|
205
|
-
// (LRU) first, then oldest created as a stable tiebreak.
|
|
192
|
+
// Oldest eligible body first, with blob ID as a stable tie.
|
|
206
193
|
const candidates = db.query(
|
|
207
194
|
`SELECT blob_id, byte_length FROM _syncular_blobs
|
|
208
|
-
WHERE
|
|
209
|
-
AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
|
|
195
|
+
WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
|
|
210
196
|
AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
|
|
211
|
-
|
|
197
|
+
AND blob_id NOT IN (${visibleBlobIdsSql(schema)})
|
|
198
|
+
ORDER BY created_at_ms ASC, blob_id ASC`,
|
|
212
199
|
);
|
|
213
200
|
const evicted: string[] = [];
|
|
214
201
|
db.transaction(() => {
|
|
@@ -223,20 +210,6 @@ export function enforceBlobCacheCap(
|
|
|
223
210
|
return evicted;
|
|
224
211
|
}
|
|
225
212
|
|
|
226
|
-
/** Record a pending upload (§5.9.7 B4); flushed before the next push. */
|
|
227
|
-
export function recordPendingUpload(
|
|
228
|
-
db: ClientDatabase,
|
|
229
|
-
blobId: string,
|
|
230
|
-
nowMs: number,
|
|
231
|
-
mediaType?: string,
|
|
232
|
-
): void {
|
|
233
|
-
db.exec(
|
|
234
|
-
`INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms)
|
|
235
|
-
VALUES (?,?,?)`,
|
|
236
|
-
[blobId, mediaType ?? null, nowMs],
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
213
|
export function listPendingUploads(
|
|
241
214
|
db: ClientDatabase,
|
|
242
215
|
): { blobId: string; mediaType?: string }[] {
|
|
@@ -267,13 +240,25 @@ export function listPendingUploads(
|
|
|
267
240
|
});
|
|
268
241
|
}
|
|
269
242
|
|
|
243
|
+
export function recordPendingUpload(
|
|
244
|
+
db: ClientDatabase,
|
|
245
|
+
blobId: string,
|
|
246
|
+
nowMs: number,
|
|
247
|
+
mediaType?: string,
|
|
248
|
+
): void {
|
|
249
|
+
db.exec(
|
|
250
|
+
`INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms)
|
|
251
|
+
VALUES (?,?,?)`,
|
|
252
|
+
[blobId, mediaType ?? null, nowMs],
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
270
256
|
export function clearPendingUpload(db: ClientDatabase, blobId: string): void {
|
|
271
257
|
db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id = ?', [blobId]);
|
|
272
258
|
}
|
|
273
259
|
|
|
274
260
|
/**
|
|
275
|
-
* All `blob_ref` column names per table
|
|
276
|
-
* Blank result ⇒ the schema has no attachments; callers skip reconciliation.
|
|
261
|
+
* All `blob_ref` column names per table.
|
|
277
262
|
*/
|
|
278
263
|
export function blobRefColumnsBySchema(
|
|
279
264
|
schema: CompiledClientSchema,
|
|
@@ -295,91 +280,35 @@ export function schemaHasBlobs(schema: CompiledClientSchema): boolean {
|
|
|
295
280
|
return false;
|
|
296
281
|
}
|
|
297
282
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
* to zero refs *and* have no pending upload (a pending upload pins its body,
|
|
302
|
-
* B4). Called after every apply/purge that may add or remove references.
|
|
303
|
-
*
|
|
304
|
-
* `blobId` narrows a fresh-download refresh to the inserted cache row.
|
|
305
|
-
* `deleteOrphans` distinguishes the two B2 transitions: revocation purge
|
|
306
|
-
* passes `true` (drop the now-unauthorized body); a benign apply passes
|
|
307
|
-
* `false` (retain zero-ref bodies as LRU cache entries — the shipped
|
|
308
|
-
* default). Bodies pinned by a pending upload are always retained.
|
|
309
|
-
*/
|
|
310
|
-
export function reconcileBlobRefcounts(
|
|
311
|
-
db: ClientDatabase,
|
|
312
|
-
schema: CompiledClientSchema,
|
|
313
|
-
options?: {
|
|
314
|
-
readonly blobId?: string;
|
|
315
|
-
readonly deleteOrphans?: boolean;
|
|
316
|
-
},
|
|
317
|
-
): void {
|
|
318
|
-
const byTable = blobRefColumnsBySchema(schema);
|
|
319
|
-
if (byTable.size === 0) return;
|
|
320
|
-
if (options?.blobId !== undefined) {
|
|
321
|
-
let count = 0;
|
|
322
|
-
for (const [tableName, columns] of byTable) {
|
|
323
|
-
for (const column of columns) {
|
|
324
|
-
const identifier = quoteIdent(column);
|
|
325
|
-
const row = db.query(
|
|
326
|
-
`SELECT count(*) AS n FROM ${quoteIdent(tableName)}
|
|
327
|
-
WHERE CASE
|
|
328
|
-
WHEN typeof(${identifier}) = 'text' AND json_valid(${identifier})
|
|
329
|
-
THEN json_extract(${identifier}, '$.blobId')
|
|
330
|
-
END = ?`,
|
|
331
|
-
[options.blobId],
|
|
332
|
-
)[0];
|
|
333
|
-
count += Number(row?.n ?? 0);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
db.exec('UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?', [
|
|
337
|
-
count,
|
|
338
|
-
options.blobId,
|
|
339
|
-
]);
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
// Count references to each blobId across every blob_ref column.
|
|
343
|
-
const counts = new Map<string, number>();
|
|
344
|
-
for (const [tableName, columns] of byTable) {
|
|
283
|
+
function visibleBlobIdsSql(schema: CompiledClientSchema): string {
|
|
284
|
+
const selects: string[] = [];
|
|
285
|
+
for (const [tableName, columns] of blobRefColumnsBySchema(schema)) {
|
|
345
286
|
for (const column of columns) {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
287
|
+
const identifier = quoteIdent(column);
|
|
288
|
+
selects.push(
|
|
289
|
+
`SELECT json_extract(${identifier}, '$.blobId') AS blob_id
|
|
290
|
+
FROM ${quoteIdent(tableName)}
|
|
291
|
+
WHERE typeof(${identifier}) = 'text'
|
|
292
|
+
AND json_valid(${identifier})
|
|
293
|
+
AND json_type(${identifier}, '$.blobId') = 'text'`,
|
|
349
294
|
);
|
|
350
|
-
for (const row of rows) {
|
|
351
|
-
const raw = row.v;
|
|
352
|
-
if (typeof raw !== 'string') continue;
|
|
353
|
-
let ref: BlobRef;
|
|
354
|
-
try {
|
|
355
|
-
ref = parseBlobRef(raw);
|
|
356
|
-
} catch {
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
counts.set(ref.blobId, (counts.get(ref.blobId) ?? 0) + 1);
|
|
360
|
-
}
|
|
361
295
|
}
|
|
362
296
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
|
|
379
|
-
AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)`,
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
});
|
|
297
|
+
return selects.length === 0
|
|
298
|
+
? 'SELECT NULL AS blob_id WHERE 0'
|
|
299
|
+
: selects.join(' UNION ');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function deleteUnreferencedCachedBlobs(
|
|
303
|
+
db: ClientDatabase,
|
|
304
|
+
schema: CompiledClientSchema,
|
|
305
|
+
): void {
|
|
306
|
+
db.exec(
|
|
307
|
+
`DELETE FROM _syncular_blobs
|
|
308
|
+
WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
|
|
309
|
+
AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
|
|
310
|
+
AND blob_id NOT IN (${visibleBlobIdsSql(schema)})`,
|
|
311
|
+
);
|
|
383
312
|
}
|
|
384
313
|
|
|
385
314
|
export type { BlobRef };
|