@hraness/sponge 0.2.6 → 0.3.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 +21 -9
- package/bin/sponge.js +8 -7
- package/dist/contracts/index.js +24 -6
- package/dist/contracts/validate.js +106 -29
- package/dist/migrations/008.d.ts +3 -0
- package/dist/migrations/008.js +5 -0
- package/dist/migrations/009.d.ts +3 -0
- package/dist/migrations/009.js +8 -0
- package/dist/migrations/010.d.ts +3 -0
- package/dist/migrations/010.js +11 -0
- package/dist/migrations/index.d.ts +15 -0
- package/dist/migrations/index.js +39 -0
- package/dist/src/acquisition/capture.js +87 -28
- package/dist/src/acquisition/cleanup.d.ts +0 -4
- package/dist/src/acquisition/cleanup.js +3 -5
- package/dist/src/acquisition/errors.js +17 -2
- package/dist/src/acquisition/files.d.ts +0 -1
- package/dist/src/acquisition/files.js +14 -11
- package/dist/src/acquisition/index.d.ts +2 -3
- package/dist/src/acquisition/index.js +1 -2
- package/dist/src/acquisition/lock.js +1 -1
- package/dist/src/acquisition/normalize.js +57 -18
- package/dist/src/acquisition/pins.js +22 -4
- package/dist/src/acquisition/process.js +67 -29
- package/dist/src/acquisition/runtime.d.ts +5 -7
- package/dist/src/acquisition/runtime.js +129 -68
- package/dist/src/acquisition/verify-helper.js +10 -3
- package/dist/src/annotations/quote.js +40 -12
- package/dist/src/annotations/store.d.ts +0 -1
- package/dist/src/annotations/store.js +66 -14
- package/dist/src/app/article.d.ts +2 -1
- package/dist/src/app/article.js +66 -70
- package/dist/src/app/bundle-files.d.ts +0 -4
- package/dist/src/app/bundle-files.js +11 -14
- package/dist/src/app/markdown.js +18 -6
- package/dist/src/app/source.d.ts +0 -4
- package/dist/src/app/source.js +18 -14
- package/dist/src/cli/arguments.d.ts +2 -8
- package/dist/src/cli/arguments.js +67 -80
- package/dist/src/cli/commands.d.ts +42 -0
- package/dist/src/cli/commands.js +795 -0
- package/dist/src/cli/config.js +12 -9
- package/dist/src/cli/digest.d.ts +1 -1
- package/dist/src/cli/digest.js +38 -16
- package/dist/src/cli/import.js +25 -4
- package/dist/src/cli/indexing.d.ts +2 -0
- package/dist/src/cli/indexing.js +26 -9
- package/dist/src/cli/main.js +47 -386
- package/dist/src/cli/portability.d.ts +0 -2
- package/dist/src/cli/portability.js +13 -8
- package/dist/src/cli/queue.js +12 -8
- package/dist/src/cli/runtimes.d.ts +1 -3
- package/dist/src/cli/runtimes.js +13 -6
- package/dist/src/cli/terminal.js +1 -1
- package/dist/src/cli/worker.d.ts +7 -0
- package/dist/src/cli/worker.js +54 -15
- package/dist/src/derivations/kinds.d.ts +34 -0
- package/dist/src/derivations/kinds.js +14 -0
- package/dist/src/derivations/types.d.ts +48 -0
- package/dist/src/derivations/types.js +1 -0
- package/dist/src/digest/derivation.d.ts +25 -0
- package/dist/src/digest/derivation.js +124 -0
- package/dist/src/digest/editorial.d.ts +9 -46
- package/dist/src/digest/editorial.js +57 -49
- package/dist/src/digest/endpoint.js +63 -27
- package/dist/src/digest/pipeline.d.ts +49 -28
- package/dist/src/digest/pipeline.js +343 -100
- package/dist/src/digest/schema.d.ts +11 -25
- package/dist/src/digest/schema.js +86 -18
- package/dist/src/errors.d.ts +46 -0
- package/dist/src/errors.js +117 -0
- package/dist/src/identity/query-policy.d.ts +0 -5
- package/dist/src/identity/query-policy.js +61 -9
- package/dist/src/identity/uri.d.ts +6 -5
- package/dist/src/identity/uri.js +37 -18
- package/dist/src/identity/visibility.d.ts +11 -1
- package/dist/src/identity/visibility.js +81 -17
- package/dist/src/importers/apply.js +104 -36
- package/dist/src/importers/custody.js +27 -9
- package/dist/src/importers/inspect.js +125 -38
- package/dist/src/importers/jungle-digest.d.ts +3 -6
- package/dist/src/importers/jungle-digest.js +196 -69
- package/dist/src/importers/jungle-store.d.ts +15 -6
- package/dist/src/importers/jungle-store.js +122 -28
- package/dist/src/importers/jungle.d.ts +14 -14
- package/dist/src/importers/jungle.js +136 -31
- package/dist/src/importers/manifest.js +57 -21
- package/dist/src/importers/verify-helper.js +18 -4
- package/dist/src/indexing/image.js +29 -10
- package/dist/src/indexing/index.d.ts +37 -6
- package/dist/src/indexing/index.js +360 -124
- package/dist/src/indexing/jobs.js +156 -47
- package/dist/src/indexing/prune-run.d.ts +2 -0
- package/dist/src/indexing/prune-run.js +69 -26
- package/dist/src/indexing/prune.d.ts +12 -3
- package/dist/src/indexing/prune.js +85 -35
- package/dist/src/indexing/runner.d.ts +2 -1
- package/dist/src/indexing/runner.js +83 -40
- package/dist/src/indexing/upgrade.d.ts +9 -0
- package/dist/src/indexing/upgrade.js +79 -0
- package/dist/src/inference/asr/bridge.js +6 -2
- package/dist/src/inference/asr/protocol.js +109 -19
- package/dist/src/inference/asr/runtime.js +58 -21
- package/dist/src/inference/asr/windows.js +9 -5
- package/dist/src/inference/embedding/index.d.ts +2 -6
- package/dist/src/inference/embedding/index.js +89 -29
- package/dist/src/ingest/attempt.d.ts +29 -0
- package/dist/src/ingest/attempt.js +55 -0
- package/dist/src/media/capture-helper.js +7 -2
- package/dist/src/media/capture.d.ts +2 -1
- package/dist/src/media/capture.js +124 -43
- package/dist/src/media/handler.d.ts +4 -2
- package/dist/src/media/handler.js +88 -70
- package/dist/src/media/transcribe.js +63 -21
- package/dist/src/metadata/article.js +49 -17
- package/dist/src/models/artifacts.d.ts +10 -8
- package/dist/src/models/artifacts.js +237 -106
- package/dist/src/models/catalog.d.ts +2 -5
- package/dist/src/models/catalog.js +94 -25
- package/dist/src/models/download.js +55 -15
- package/dist/src/models/runtime.d.ts +0 -2
- package/dist/src/models/runtime.js +69 -33
- package/dist/src/pdf/capture-helper.js +3 -1
- package/dist/src/pdf/capture.d.ts +2 -1
- package/dist/src/pdf/capture.js +95 -27
- package/dist/src/pdf/errors.js +12 -1
- package/dist/src/pdf/handler.d.ts +2 -1
- package/dist/src/pdf/handler.js +70 -71
- package/dist/src/pdf/index.d.ts +1 -3
- package/dist/src/pdf/index.js +0 -2
- package/dist/src/pdf/input.js +12 -7
- package/dist/src/pdf/normalize.js +80 -21
- package/dist/src/pdf/source-identity.js +3 -2
- package/dist/src/pdf/source.js +12 -8
- package/dist/src/pdf/verify-helper.js +15 -3
- package/dist/src/portability/backup.d.ts +2 -9
- package/dist/src/portability/backup.js +106 -22
- package/dist/src/portability/database.js +51 -17
- package/dist/src/portability/export.js +35 -9
- package/dist/src/portability/files.d.ts +1 -4
- package/dist/src/portability/files.js +127 -67
- package/dist/src/portability/gc.js +65 -22
- package/dist/src/publish/component.js +1 -1
- package/dist/src/publish/feed.d.ts +2 -2
- package/dist/src/publish/feed.js +75 -29
- package/dist/src/publish/identity.d.ts +5 -1
- package/dist/src/publish/identity.js +31 -11
- package/dist/src/publish/index.d.ts +2 -3
- package/dist/src/publish/index.js +34 -21
- package/dist/src/publish/projection.d.ts +2 -9
- package/dist/src/publish/projection.js +21 -14
- package/dist/src/search/extension.js +3 -1
- package/dist/src/search/hybrid.d.ts +3 -8
- package/dist/src/search/hybrid.js +45 -11
- package/dist/src/search/lexical.js +18 -4
- package/dist/src/search/query.js +56 -25
- package/dist/src/search/vector.js +37 -10
- package/dist/src/service/launch-agent.d.ts +5 -3
- package/dist/src/service/launch-agent.js +272 -83
- package/dist/src/service/lifetime.js +47 -14
- package/dist/src/service/loop.d.ts +3 -3
- package/dist/src/service/loop.js +47 -19
- package/dist/src/store/annotations.d.ts +32 -0
- package/dist/src/store/annotations.js +44 -0
- package/dist/src/store/backups.d.ts +18 -0
- package/dist/src/store/backups.js +110 -0
- package/dist/src/store/blobs.d.ts +24 -0
- package/dist/src/store/blobs.js +139 -0
- package/dist/src/store/changes.d.ts +5 -12
- package/dist/src/store/changes.js +108 -56
- package/dist/src/store/context.d.ts +15 -0
- package/dist/src/store/context.js +19 -0
- package/dist/src/store/derivations.d.ts +42 -0
- package/dist/src/store/derivations.js +104 -0
- package/dist/src/store/files.d.ts +0 -2
- package/dist/src/store/files.js +19 -24
- package/dist/src/store/ids.d.ts +30 -0
- package/dist/src/store/ids.js +34 -0
- package/dist/src/store/imports.d.ts +26 -0
- package/dist/src/store/imports.js +57 -0
- package/dist/src/store/index.d.ts +3 -186
- package/dist/src/store/index.js +2 -809
- package/dist/src/store/lease.d.ts +20 -0
- package/dist/src/store/lease.js +62 -0
- package/dist/src/store/library.d.ts +75 -0
- package/dist/src/store/library.js +174 -0
- package/dist/src/store/open.d.ts +7 -0
- package/dist/src/store/open.js +183 -0
- package/dist/src/store/queue.d.ts +65 -0
- package/dist/src/store/queue.js +327 -0
- package/dist/src/store/revisions.d.ts +12 -0
- package/dist/src/store/revisions.js +189 -0
- package/dist/src/store/search-index.d.ts +63 -0
- package/dist/src/store/search-index.js +190 -0
- package/dist/src/store/sync-ready.d.ts +6 -0
- package/dist/src/store/sync-ready.js +121 -0
- package/dist/src/store/types.d.ts +1 -5
- package/dist/src/store/types.js +1 -8
- package/dist/src/util/canonical.d.ts +2 -0
- package/dist/src/util/canonical.js +17 -0
- package/dist/src/util/exact.d.ts +2 -0
- package/dist/src/util/exact.js +9 -0
- package/dist/src/util/fsync.d.ts +4 -0
- package/dist/src/util/fsync.js +22 -0
- package/dist/src/util/hash.d.ts +4 -0
- package/dist/src/util/hash.js +8 -0
- package/dist/src/worker/basic.d.ts +0 -1
- package/dist/src/worker/basic.js +23 -7
- package/dist/src/worker/custody.d.ts +4 -1
- package/dist/src/worker/custody.js +111 -38
- package/dist/src/worker/engine.d.ts +2 -1
- package/dist/src/worker/engine.js +42 -20
- package/dist/src/worker/identity.js +13 -5
- package/dist/src/worker/journal.d.ts +1 -1
- package/dist/src/worker/journal.js +54 -24
- package/dist/src/worker/lock.d.ts +18 -0
- package/dist/src/worker/lock.js +196 -0
- package/dist/src/worker/runtime-gc.d.ts +4 -3
- package/dist/src/worker/runtime-gc.js +68 -41
- package/dist/src/worker/supervisor-child.js +19 -6
- package/dist/src/worker/supervisor.js +41 -8
- package/native/runtime-manifest.json +4 -4
- package/package.json +10 -2
- package/dist/src/digest/store.d.ts +0 -57
- package/dist/src/digest/store.js +0 -79
- package/dist/src/store/foundation.d.ts +0 -7
- package/dist/src/store/foundation.js +0 -7
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { safeDirectory, text } from './files.js';
|
|
4
|
+
import * as indexing from '../indexing/index.js';
|
|
5
|
+
import * as jobs from '../indexing/jobs.js';
|
|
6
|
+
import * as prune from '../indexing/prune.js';
|
|
7
|
+
import { hybridSearch } from '../search/hybrid.js';
|
|
8
|
+
import { checkOwner, hasGcFence, pendingCleanup, removeStaging } from './lease.js';
|
|
9
|
+
import { transaction } from './context.js';
|
|
10
|
+
import { SpongeError } from '../errors.js';
|
|
11
|
+
/**
|
|
12
|
+
* Embedding index generations, the index jobs that build them under the shared worker lease, retired-generation pruning
|
|
13
|
+
* and hybrid search. The generation and job SQL lives in `src/indexing/`; this is its store surface.
|
|
14
|
+
*/
|
|
15
|
+
export class SearchIndex {
|
|
16
|
+
c;
|
|
17
|
+
/** Index jobs as one `JobKind` of the queue. */
|
|
18
|
+
jobs;
|
|
19
|
+
constructor(c) {
|
|
20
|
+
this.c = c;
|
|
21
|
+
const db = c.db;
|
|
22
|
+
this.jobs = {
|
|
23
|
+
owns: (id) => !!jobs.indexJob(db, id),
|
|
24
|
+
list: () => jobs.listIndexJobs(db),
|
|
25
|
+
attempts: (token) => jobs.indexOwnerAttempts(db, token),
|
|
26
|
+
cancel: (id) => jobs.cancelIndexJob(db, id),
|
|
27
|
+
retry: (id, options) => {
|
|
28
|
+
if (options.automatic || options.notBefore !== undefined)
|
|
29
|
+
throw new SpongeError('invalid-input');
|
|
30
|
+
jobs.retryIndexJob(db, id);
|
|
31
|
+
},
|
|
32
|
+
cancelRequested: (id) => jobs.indexJob(db, id).cancel_requested === 1,
|
|
33
|
+
recover: (attempt) => {
|
|
34
|
+
removeStaging(c.root, attempt.attemptId, (path) => this.checkStaging(attempt, path));
|
|
35
|
+
jobs.recoverIndexAttempt(db, attempt);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
create(options) {
|
|
40
|
+
return indexing.createGeneration(this.c.db, options);
|
|
41
|
+
}
|
|
42
|
+
status(id) {
|
|
43
|
+
return indexing.generationStatus(this.c.db, id);
|
|
44
|
+
}
|
|
45
|
+
inputs(id, limit = 16) {
|
|
46
|
+
return indexing.pendingInputs(this.c.db, id, limit);
|
|
47
|
+
}
|
|
48
|
+
writeBatch(id, fingerprint, batch) {
|
|
49
|
+
indexing.writeBatch(this.c.db, id, fingerprint, batch);
|
|
50
|
+
}
|
|
51
|
+
activate(id) {
|
|
52
|
+
return indexing.activateGeneration(this.c.db, id);
|
|
53
|
+
}
|
|
54
|
+
cancel(id) {
|
|
55
|
+
indexing.cancelGeneration(this.c.db, id);
|
|
56
|
+
}
|
|
57
|
+
submitJob(generationId) {
|
|
58
|
+
return jobs.submitIndexJob(this.c.db, generationId);
|
|
59
|
+
}
|
|
60
|
+
job(id) {
|
|
61
|
+
return jobs.indexJob(this.c.db, id);
|
|
62
|
+
}
|
|
63
|
+
claim(owner, id) {
|
|
64
|
+
return jobs.claimIndexJob(this.c.db, owner, id);
|
|
65
|
+
}
|
|
66
|
+
checkpoint(token, stage) {
|
|
67
|
+
jobs.checkpointIndexAttempt(this.c.db, token, stage);
|
|
68
|
+
}
|
|
69
|
+
writeAttemptBatch(token, fingerprint, batch) {
|
|
70
|
+
jobs.withIndexAttempt(this.c.db, token, (id) => indexing.writeBatch(this.c.db, id, fingerprint, batch));
|
|
71
|
+
}
|
|
72
|
+
/** Coverage catches up with the library before a run reads its pending inputs. */
|
|
73
|
+
refresh(token) {
|
|
74
|
+
return jobs.withIndexAttempt(this.c.db, token, (id) => indexing.syncGeneration(this.c.db, id));
|
|
75
|
+
}
|
|
76
|
+
/** A complete building generation activates; an active one stays active. Content still pending (a capture after the last
|
|
77
|
+
* batch) queues the job again, so the same drain picks it up. */
|
|
78
|
+
complete(token) {
|
|
79
|
+
const db = this.c.db;
|
|
80
|
+
return jobs.withIndexAttempt(db, token, (id) => {
|
|
81
|
+
indexing.syncGeneration(db, id);
|
|
82
|
+
let status = indexing.generationStatus(db, id);
|
|
83
|
+
if (status.state !== 'building' && status.state !== 'active')
|
|
84
|
+
throw new SpongeError('cancelled-index');
|
|
85
|
+
if (status.state === 'building' && !status.pending)
|
|
86
|
+
status = indexing.activateGeneration(db, id);
|
|
87
|
+
jobs.finishIndexAttempt(db, token);
|
|
88
|
+
if (status.pending)
|
|
89
|
+
indexing.armJob(db, id);
|
|
90
|
+
return status;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** The profile's active generation with its coverage and pending counts and its index job, or nulls when none is active. */
|
|
94
|
+
overview(profileId = 'default') {
|
|
95
|
+
const db = this.c.db;
|
|
96
|
+
return transaction(db, () => {
|
|
97
|
+
const id = indexing.activeGeneration(db, profileId);
|
|
98
|
+
if (!id)
|
|
99
|
+
return { generation: null, job: null };
|
|
100
|
+
const job = db.prepare('SELECT id,state,reason FROM index_jobs WHERE generation_id=?').get(id);
|
|
101
|
+
return {
|
|
102
|
+
generation: indexing.generationStatus(db, id),
|
|
103
|
+
job: job
|
|
104
|
+
? { id: String(job.id), state: String(job.state), reason: job.reason === null ? null : String(job.reason) }
|
|
105
|
+
: null,
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
fail(token, reason) {
|
|
110
|
+
jobs.finishIndexAttempt(this.c.db, token, reason);
|
|
111
|
+
}
|
|
112
|
+
settle(token) {
|
|
113
|
+
const db = this.c.db;
|
|
114
|
+
transaction(db, () => {
|
|
115
|
+
checkOwner(db, token);
|
|
116
|
+
const a = db.prepare('SELECT * FROM index_attempts WHERE id=? AND job_id=?').get(token.attemptId, token.jobId);
|
|
117
|
+
if (!a || a.owner_incarnation !== token.ownerIncarnation || a.fence !== token.fence || a.finished_at === null)
|
|
118
|
+
throw new SpongeError('owner-busy');
|
|
119
|
+
removeStaging(this.c.root, token.attemptId, (path) => this.checkStaging(token, path));
|
|
120
|
+
jobs.settleIndexAttempt(db, token);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** An index attempt's staging directory is removed only when its private ownership marker names this attempt and owner. */
|
|
124
|
+
checkStaging(token, path) {
|
|
125
|
+
safeDirectory(path);
|
|
126
|
+
const fd = openSync(join(path, 'ownership.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
127
|
+
try {
|
|
128
|
+
const stat = fstatSync(fd);
|
|
129
|
+
if (!stat.isFile() || stat.size > 65536 || (stat.mode & 0o077) !== 0)
|
|
130
|
+
throw new SpongeError('integrity');
|
|
131
|
+
const marker = JSON.parse(readFileSync(fd, 'utf8'));
|
|
132
|
+
if (marker.schemaVersion !== 1 ||
|
|
133
|
+
marker.requestId !== token.attemptId ||
|
|
134
|
+
marker.ownerIncarnation !== token.ownerIncarnation)
|
|
135
|
+
throw new SpongeError('integrity');
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
closeSync(fd);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Retired-generation pruning; see src/indexing/prune.ts. The plan probes rollback inside a rolled-back savepoint. */
|
|
142
|
+
prunePlan(keep) {
|
|
143
|
+
const db = this.c.db;
|
|
144
|
+
return transaction(db, () => {
|
|
145
|
+
const generations = prune.planPrune(db, keep);
|
|
146
|
+
return {
|
|
147
|
+
generations,
|
|
148
|
+
orphans: prune.orphanVectors(db, generations.filter((g) => g.action === 'prune').map((g) => g.id)),
|
|
149
|
+
stats: prune.pageStats(db),
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
pruneStep(owner, id, keep) {
|
|
154
|
+
text(id, 256);
|
|
155
|
+
const db = this.c.db;
|
|
156
|
+
return transaction(db, () => {
|
|
157
|
+
checkOwner(db, owner);
|
|
158
|
+
if (pendingCleanup(db) || hasGcFence(db))
|
|
159
|
+
throw new SpongeError('owner-busy');
|
|
160
|
+
return prune.pruneStep(db, id, keep);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** One bounded batch of embeddings no retained generation covers. Returns rows deleted; 0 means done. */
|
|
164
|
+
gcStep(owner) {
|
|
165
|
+
const db = this.c.db;
|
|
166
|
+
return transaction(db, () => {
|
|
167
|
+
checkOwner(db, owner);
|
|
168
|
+
if (pendingCleanup(db) || hasGcFence(db))
|
|
169
|
+
throw new SpongeError('owner-busy');
|
|
170
|
+
return prune.gcStep(db);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** VACUUM cannot run in a transaction: fences are checked under the owner token immediately before it. */
|
|
174
|
+
vacuum(owner) {
|
|
175
|
+
const db = this.c.db;
|
|
176
|
+
const stats = transaction(db, () => {
|
|
177
|
+
checkOwner(db, owner);
|
|
178
|
+
if (pendingCleanup(db) || hasGcFence(db))
|
|
179
|
+
throw new SpongeError('owner-busy');
|
|
180
|
+
return prune.pageStats(db);
|
|
181
|
+
});
|
|
182
|
+
prune.vacuumSpace(this.c.root, stats.bytes);
|
|
183
|
+
db.exec('VACUUM');
|
|
184
|
+
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
185
|
+
return prune.pageStats(db);
|
|
186
|
+
}
|
|
187
|
+
search(query, options = {}) {
|
|
188
|
+
return hybridSearch(this.c.db, query, options);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
/**
|
|
3
|
+
* Migration 9's code step: no absolute paths in synced data, split titles, content-addressed objects. Runs inside the
|
|
4
|
+
* migration transaction after the pre-migration backup; every phase is total per row. See docs/decisions/content-ids.md.
|
|
5
|
+
*/
|
|
6
|
+
export declare function syncReady(db: DatabaseSync): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { sha256 } from '../util/hash.js';
|
|
2
|
+
import { chunkDocument, chunkHash } from '../indexing/index.js';
|
|
3
|
+
import { refreshTitle } from './changes.js';
|
|
4
|
+
import { blockId, claimId, isFileLocator, portableLocator, representationId } from './ids.js';
|
|
5
|
+
/**
|
|
6
|
+
* Migration 9's code step: no absolute paths in synced data, split titles, content-addressed objects. Runs inside the
|
|
7
|
+
* migration transaction after the pre-migration backup; every phase is total per row. See docs/decisions/content-ids.md.
|
|
8
|
+
*/
|
|
9
|
+
export function syncReady(db) {
|
|
10
|
+
portableLocators(db);
|
|
11
|
+
splitTitles(db);
|
|
12
|
+
rekeyObjects(db);
|
|
13
|
+
}
|
|
14
|
+
const parse = (json) => {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(String(json));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/** `file:` locators move to `local_sources`; source refs and logged item creations keep only the portable form. */
|
|
23
|
+
function portableLocators(db) {
|
|
24
|
+
for (const r of db
|
|
25
|
+
.prepare("SELECT item_id,locator FROM source_refs WHERE locator LIKE 'file:%' ORDER BY rowid")
|
|
26
|
+
.all()) {
|
|
27
|
+
db.prepare('INSERT INTO local_sources(item_id,locator) VALUES(?,?) ON CONFLICT(item_id) DO NOTHING').run(r.item_id, r.locator);
|
|
28
|
+
db.prepare('UPDATE source_refs SET locator=? WHERE item_id=? AND locator=?').run(portableLocator(String(r.locator)), r.item_id, r.locator);
|
|
29
|
+
}
|
|
30
|
+
for (const c of db.prepare("SELECT seq,value_json FROM changes WHERE entity='item' AND field='created'").all()) {
|
|
31
|
+
const v = parse(c.value_json);
|
|
32
|
+
if (v && typeof v === 'object' && typeof v.locator === 'string' && isFileLocator(v.locator))
|
|
33
|
+
db.prepare('UPDATE changes SET value_json=? WHERE seq=?').run(JSON.stringify({ ...v, locator: portableLocator(v.locator) }), c.seq);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Logged `title` writes become `noteTitle` when the Jungle importer left a receipt for them, else `capturedTitle`. */
|
|
37
|
+
function splitTitles(db) {
|
|
38
|
+
const receipted = db.prepare("SELECT 1 FROM import_receipts WHERE namespace='jungle-title' AND source_key=? AND source_hash=?");
|
|
39
|
+
for (const c of db
|
|
40
|
+
.prepare("SELECT seq,id,entity_id FROM changes WHERE entity='item' AND field='title' ORDER BY seq")
|
|
41
|
+
.all())
|
|
42
|
+
db.prepare('UPDATE changes SET field=? WHERE seq=?').run(receipted.get(c.entity_id, sha256(String(c.id))) ? 'noteTitle' : 'capturedTitle', c.seq);
|
|
43
|
+
const last = db.prepare("SELECT value_json FROM changes WHERE entity='item' AND entity_id=? AND field=? ORDER BY seq DESC LIMIT 1");
|
|
44
|
+
for (const i of db.prepare('SELECT id,title FROM items ORDER BY rowid').all()) {
|
|
45
|
+
const id = String(i.id), captured = last.get(id, 'capturedTitle'), note = last.get(id, 'noteTitle'), text = (r) => {
|
|
46
|
+
const v = r && parse(r.value_json);
|
|
47
|
+
return typeof v === 'string' && Buffer.byteLength(v) <= 4096 ? v : '';
|
|
48
|
+
};
|
|
49
|
+
db.prepare('UPDATE items SET title_captured=?,title_note=? WHERE id=?').run(captured ? text(captured) : note ? '' : String(i.title), text(note) || null, id);
|
|
50
|
+
refreshTitle(db, id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Representations, blocks and claims take content ids; index generations follow the representation ids they name. */
|
|
54
|
+
function rekeyObjects(db) {
|
|
55
|
+
const taken = (table, id) => !!db.prepare(`SELECT 1 FROM ${table} WHERE id=?`).get(id);
|
|
56
|
+
const reps = new Map();
|
|
57
|
+
for (const r of db
|
|
58
|
+
.prepare('SELECT rowid,id,revision_id,kind,mime_type,sha256 FROM representations ORDER BY rowid')
|
|
59
|
+
.all()) {
|
|
60
|
+
const next = representationId(String(r.revision_id), String(r.kind), String(r.mime_type), String(r.sha256));
|
|
61
|
+
// An identical second row keeps its old id rather than failing the migration.
|
|
62
|
+
if (next === r.id || taken('representations', next))
|
|
63
|
+
continue;
|
|
64
|
+
db.prepare('UPDATE representations SET id=? WHERE rowid=?').run(next, r.rowid);
|
|
65
|
+
reps.set(String(r.id), next);
|
|
66
|
+
}
|
|
67
|
+
for (const b of db.prepare('SELECT rowid,id,revision_id,ordinal,kind,text FROM blocks ORDER BY rowid').all()) {
|
|
68
|
+
const next = blockId(String(b.revision_id), Number(b.ordinal), String(b.kind), String(b.text));
|
|
69
|
+
if (next !== b.id && !taken('blocks', next))
|
|
70
|
+
db.prepare('UPDATE blocks SET id=? WHERE rowid=?').run(next, b.rowid);
|
|
71
|
+
}
|
|
72
|
+
let revision, ordinal = 0;
|
|
73
|
+
for (const c of db.prepare('SELECT rowid,* FROM metadata_claims ORDER BY revision_id,rowid').all()) {
|
|
74
|
+
ordinal = c.revision_id === revision ? ordinal + 1 : 0;
|
|
75
|
+
revision = c.revision_id;
|
|
76
|
+
const columns = [
|
|
77
|
+
c.field,
|
|
78
|
+
c.value_json,
|
|
79
|
+
c.source,
|
|
80
|
+
c.observed_at,
|
|
81
|
+
c.rule,
|
|
82
|
+
c.timezone_known,
|
|
83
|
+
c.observed_json,
|
|
84
|
+
c.locator,
|
|
85
|
+
c.selected,
|
|
86
|
+
c.extraction_version,
|
|
87
|
+
c.date_precision,
|
|
88
|
+
];
|
|
89
|
+
const next = claimId(String(c.revision_id), ordinal, columns);
|
|
90
|
+
if (next !== c.id && !taken('metadata_claims', next))
|
|
91
|
+
db.prepare('UPDATE metadata_claims SET id=? WHERE rowid=?').run(next, c.rowid);
|
|
92
|
+
}
|
|
93
|
+
if (!reps.size)
|
|
94
|
+
return;
|
|
95
|
+
for (const g of db.prepare('SELECT id,coverage_json FROM index_generations ORDER BY rowid').all()) {
|
|
96
|
+
const coverage = parse(g.coverage_json);
|
|
97
|
+
if (!coverage || !Array.isArray(coverage.inputs))
|
|
98
|
+
continue;
|
|
99
|
+
const moved = [];
|
|
100
|
+
for (const input of coverage.inputs) {
|
|
101
|
+
const next = input && input.kind === 'document' && reps.get(input.representationId);
|
|
102
|
+
if (!next)
|
|
103
|
+
continue;
|
|
104
|
+
const markdown = db.prepare('SELECT markdown FROM representations WHERE id=?').get(next)?.markdown, text = typeof markdown === 'string' && Number.isSafeInteger(input.ordinal)
|
|
105
|
+
? chunkDocument(markdown)[input.ordinal]
|
|
106
|
+
: undefined;
|
|
107
|
+
const prior = input.sourceId;
|
|
108
|
+
// A chunk hash that did not verify before stays unverifiable: re-keying never repairs a broken generation.
|
|
109
|
+
if (text !== undefined && input.derivationHash === chunkHash(input.representationId, input.ordinal, text))
|
|
110
|
+
input.derivationHash = chunkHash(next, input.ordinal, text);
|
|
111
|
+
input.representationId = next;
|
|
112
|
+
input.sourceId = JSON.stringify([input.revisionId, 'document', next, input.ordinal]);
|
|
113
|
+
moved.push([String(prior), input]);
|
|
114
|
+
}
|
|
115
|
+
if (!moved.length)
|
|
116
|
+
continue;
|
|
117
|
+
db.prepare('UPDATE index_generations SET coverage_json=? WHERE id=?').run(JSON.stringify(coverage), g.id);
|
|
118
|
+
for (const [prior, input] of moved)
|
|
119
|
+
db.prepare('UPDATE vectors SET source_id=?,derivation_hash=? WHERE generation_id=? AND source_id=?').run(input.sourceId, input.derivationHash, g.id, prior);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MetadataClaim, MetadataWarning } from '../../contracts/index.js';
|
|
2
2
|
export type ItemKind = 'article' | 'pdf' | 'media';
|
|
3
|
-
export type FaultPoint = 'staged' | 'published' | 'transaction' | 'committed';
|
|
3
|
+
export type FaultPoint = 'staged' | 'published' | 'transaction' | 'committed' | 'migration' | 'backup';
|
|
4
4
|
export interface StoreOptions {
|
|
5
5
|
fault?: (point: FaultPoint) => void;
|
|
6
6
|
appVersion?: string;
|
|
@@ -84,7 +84,3 @@ export interface ReadableItem {
|
|
|
84
84
|
metadata: MetadataClaim[];
|
|
85
85
|
metadataWarnings: MetadataWarning[];
|
|
86
86
|
}
|
|
87
|
-
export declare class StoreError extends Error {
|
|
88
|
-
readonly code: 'invalid-input' | 'integrity' | 'limit-exceeded' | 'schema-newer' | 'owner-busy' | 'cancelled' | 'unavailable';
|
|
89
|
-
constructor(code: 'invalid-input' | 'integrity' | 'limit-exceeded' | 'schema-newer' | 'owner-busy' | 'cancelled' | 'unavailable', message?: "unavailable" | "cancelled" | "invalid-input" | "integrity" | "limit-exceeded" | "schema-newer" | "owner-busy");
|
|
90
|
-
}
|
package/dist/src/store/types.js
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Sorted keys, no whitespace, undefined fields dropped: equal values always give equal bytes. Publish signatures depend on these exact bytes. */
|
|
2
|
+
export function canonicalJson(value, invalid) {
|
|
3
|
+
if (Array.isArray(value))
|
|
4
|
+
return '[' + value.map((v) => canonicalJson(v, invalid)).join(',') + ']';
|
|
5
|
+
if (value && typeof value === 'object')
|
|
6
|
+
return ('{' +
|
|
7
|
+
Object.keys(value)
|
|
8
|
+
.sort()
|
|
9
|
+
.filter((k) => value[k] !== undefined)
|
|
10
|
+
.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k], invalid))
|
|
11
|
+
.join(',') +
|
|
12
|
+
'}');
|
|
13
|
+
if ((typeof value === 'number' && !Number.isSafeInteger(value)) ||
|
|
14
|
+
(!['string', 'number', 'boolean'].includes(typeof value) && value !== null))
|
|
15
|
+
invalid();
|
|
16
|
+
return JSON.stringify(value);
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A plain object with exactly these keys, or `invalid()`. */
|
|
2
|
+
export function exactKeys(value, keys, invalid) {
|
|
3
|
+
if (!value ||
|
|
4
|
+
typeof value !== 'object' ||
|
|
5
|
+
Array.isArray(value) ||
|
|
6
|
+
Object.keys(value).sort().join(',') !== [...keys].sort().join(','))
|
|
7
|
+
invalid();
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { constants, openSync, fsyncSync, closeSync } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
/** Make a directory's entry changes (create, rename, unlink) durable. */
|
|
4
|
+
export function syncDirectory(path) {
|
|
5
|
+
const fd = openSync(path, constants.O_RDONLY);
|
|
6
|
+
try {
|
|
7
|
+
fsyncSync(fd);
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
closeSync(fd);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** `syncDirectory` without blocking the event loop. */
|
|
14
|
+
export async function syncDirectoryAsync(path) {
|
|
15
|
+
const file = await open(path, constants.O_RDONLY);
|
|
16
|
+
try {
|
|
17
|
+
await file.sync();
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
await file.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Lowercase hex sha256 of a string (UTF-8) or bytes. */
|
|
2
|
+
export declare const sha256: (value: string | Uint8Array) => string;
|
|
3
|
+
/** Domain-tagged content id: sha256 of `sponge:<domain>\n` and the canonical JSON of `value`. Equal values give equal ids; distinct domains never collide. */
|
|
4
|
+
export declare const contentId: (domain: string, value: unknown) => string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from './canonical.js';
|
|
3
|
+
/** Lowercase hex sha256 of a string (UTF-8) or bytes. */
|
|
4
|
+
export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
5
|
+
/** Domain-tagged content id: sha256 of `sponge:<domain>\n` and the canonical JSON of `value`. Equal values give equal ids; distinct domains never collide. */
|
|
6
|
+
export const contentId = (domain, value) => sha256(`sponge:${domain}\n${canonicalJson(value, () => {
|
|
7
|
+
throw new TypeError('contentId: not canonical JSON');
|
|
8
|
+
})}`);
|
|
@@ -7,4 +7,3 @@ export declare function drainLibrary(store: LibraryStore, options: ArticleOption
|
|
|
7
7
|
resolveConnection?: (realm: string) => ArticleOptions['connection'];
|
|
8
8
|
onResult?: (result: ArticleOutcome) => void;
|
|
9
9
|
}): Promise<ArticleOutcome[]>;
|
|
10
|
-
export declare const drainArticles: typeof drainLibrary;
|
package/dist/src/worker/basic.js
CHANGED
|
@@ -6,22 +6,38 @@ import { drainQueue } from './engine.js';
|
|
|
6
6
|
/** Content handlers over the durable sequential worker and its process supervisor. */
|
|
7
7
|
export async function drainLibrary(store, options) {
|
|
8
8
|
const handler = async (context) => {
|
|
9
|
-
const source = store.
|
|
9
|
+
const source = store.queue.source(context.attempt.jobId);
|
|
10
10
|
let connection;
|
|
11
11
|
try {
|
|
12
12
|
connection = options.resolveConnection?.(source?.realm ?? 'public') ?? options.connection;
|
|
13
13
|
}
|
|
14
14
|
catch {
|
|
15
|
-
store.
|
|
16
|
-
store.
|
|
15
|
+
store.queue.fail(context.attempt, 'repair-auth');
|
|
16
|
+
store.queue.settle(context.attempt);
|
|
17
17
|
return { ...failure(context.attempt.attemptId, 'repair-auth', 'acquisition'), cleanup: 'settled' };
|
|
18
18
|
}
|
|
19
19
|
if (source?.kind === 'media') {
|
|
20
20
|
const { capture: _articleCapture, ...mediaOptions } = options;
|
|
21
|
-
return ingestMedia(store, context.attempt, {
|
|
21
|
+
return ingestMedia(store, context.attempt, {
|
|
22
|
+
...mediaOptions,
|
|
23
|
+
connection,
|
|
24
|
+
signal: context.signal,
|
|
25
|
+
runProcess: context.runProcess,
|
|
26
|
+
checkpoint: context.checkpoint,
|
|
27
|
+
});
|
|
22
28
|
}
|
|
23
|
-
return (source?.kind === 'pdf' ? ingestPdf : ingestArticle)(store, context.attempt, {
|
|
29
|
+
return (source?.kind === 'pdf' ? ingestPdf : ingestArticle)(store, context.attempt, {
|
|
30
|
+
...options,
|
|
31
|
+
connection,
|
|
32
|
+
signal: context.signal,
|
|
33
|
+
runProcess: context.runProcess,
|
|
34
|
+
checkpoint: context.checkpoint,
|
|
35
|
+
});
|
|
24
36
|
};
|
|
25
|
-
return drainQueue(store, {
|
|
37
|
+
return drainQueue(store, {
|
|
38
|
+
signal: options.signal,
|
|
39
|
+
jobId: options.jobId,
|
|
40
|
+
onResult: options.onResult,
|
|
41
|
+
handlers: { article: handler, pdf: handler, media: handler },
|
|
42
|
+
});
|
|
26
43
|
}
|
|
27
|
-
export const drainArticles = drainLibrary;
|
|
@@ -7,7 +7,10 @@ export interface WorkerCustody {
|
|
|
7
7
|
export declare function acquireWorkerCustody(root: string): Promise<WorkerCustody>;
|
|
8
8
|
/** Receipts are specific to every pre-launch ticket. Missing receipts never mean absent children. */
|
|
9
9
|
export declare function proveAttemptCleanup(ownerDirectory: string, incarnation: string, attemptId: string): Promise<boolean>;
|
|
10
|
-
export
|
|
10
|
+
export type RecoveryFault = 'guarded' | 'recovered';
|
|
11
|
+
export declare function recoverWorker(store: LibraryStore, options?: {
|
|
12
|
+
fault?: (point: RecoveryFault) => void;
|
|
13
|
+
}): Promise<{
|
|
11
14
|
recovered: boolean;
|
|
12
15
|
attempts: number;
|
|
13
16
|
}>;
|