@hraness/sponge 0.1.2 → 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.
- package/README.md +163 -17
- package/bin/sponge.js +9 -2
- package/dist/contracts/index.d.ts +164 -0
- package/dist/contracts/index.js +6 -0
- package/dist/contracts/validate.d.ts +14 -0
- package/dist/contracts/validate.js +187 -0
- package/dist/migrations/001.d.ts +3 -0
- package/dist/migrations/001.js +33 -0
- package/dist/migrations/002.d.ts +3 -0
- package/dist/migrations/002.js +6 -0
- package/dist/migrations/003.d.ts +3 -0
- package/dist/migrations/003.js +10 -0
- package/dist/migrations/004.d.ts +3 -0
- package/dist/migrations/004.js +9 -0
- package/dist/src/acquisition/capture.d.ts +29 -0
- package/dist/src/acquisition/capture.js +160 -0
- package/dist/src/acquisition/cleanup.d.ts +6 -0
- package/dist/src/acquisition/cleanup.js +20 -0
- package/dist/src/acquisition/errors.d.ts +3 -0
- package/dist/src/acquisition/errors.js +20 -0
- package/dist/src/acquisition/files.d.ts +4 -0
- package/dist/src/acquisition/files.js +49 -0
- package/dist/src/acquisition/index.d.ts +6 -0
- package/dist/src/acquisition/index.js +4 -0
- package/dist/src/acquisition/lock.d.ts +1 -0
- package/dist/src/acquisition/lock.js +2 -0
- package/dist/src/acquisition/normalize.d.ts +11 -0
- package/dist/src/acquisition/normalize.js +62 -0
- package/dist/src/acquisition/pins.d.ts +3 -0
- package/dist/src/acquisition/pins.js +9 -0
- package/dist/src/acquisition/process.d.ts +16 -0
- package/dist/src/acquisition/process.js +63 -0
- package/dist/src/acquisition/runtime.d.ts +53 -0
- package/dist/src/acquisition/runtime.js +216 -0
- package/dist/src/acquisition/verify-helper.d.ts +1 -0
- package/dist/src/acquisition/verify-helper.js +19 -0
- package/dist/src/app/article.d.ts +23 -0
- package/dist/src/app/article.js +82 -0
- package/dist/src/app/bundle-files.d.ts +8 -0
- package/dist/src/app/bundle-files.js +66 -0
- package/dist/src/app/markdown.d.ts +6 -0
- package/dist/src/app/markdown.js +90 -0
- package/dist/src/app/source.d.ts +15 -0
- package/dist/src/app/source.js +40 -0
- package/dist/src/cli/arguments.d.ts +9 -0
- package/dist/src/cli/arguments.js +106 -0
- package/dist/src/cli/config.d.ts +8 -0
- package/dist/src/cli/config.js +45 -0
- package/dist/src/cli/import.d.ts +4 -0
- package/dist/src/cli/import.js +3 -0
- package/dist/src/cli/indexing.d.ts +7 -0
- package/dist/src/cli/indexing.js +21 -0
- package/dist/src/cli/main.d.ts +1 -0
- package/dist/src/cli/main.js +333 -0
- package/dist/src/cli/portability.d.ts +9 -0
- package/dist/src/cli/portability.js +39 -0
- package/dist/src/cli/queue.d.ts +2 -0
- package/dist/src/cli/queue.js +23 -0
- package/dist/src/cli/terminal.d.ts +2 -0
- package/dist/src/cli/terminal.js +4 -0
- package/dist/src/cli/worker.d.ts +16 -0
- package/dist/src/cli/worker.js +62 -0
- package/dist/src/importers/apply.d.ts +29 -0
- package/dist/src/importers/apply.js +85 -0
- package/dist/src/importers/custody.d.ts +6 -0
- package/dist/src/importers/custody.js +35 -0
- package/dist/src/importers/inspect.d.ts +21 -0
- package/dist/src/importers/inspect.js +97 -0
- package/dist/src/importers/manifest.d.ts +22 -0
- package/dist/src/importers/manifest.js +43 -0
- package/dist/src/importers/verify-helper.d.ts +1 -0
- package/dist/src/importers/verify-helper.js +27 -0
- package/dist/src/indexing/image.d.ts +10 -0
- package/dist/src/indexing/image.js +34 -0
- package/dist/src/indexing/index.d.ts +53 -0
- package/dist/src/indexing/index.js +123 -0
- package/dist/src/indexing/jobs.d.ts +14 -0
- package/dist/src/indexing/jobs.js +49 -0
- package/dist/src/indexing/runner.d.ts +23 -0
- package/dist/src/indexing/runner.js +114 -0
- package/dist/src/inference/asr/bridge.d.ts +1 -0
- package/dist/src/inference/asr/bridge.js +16 -0
- package/dist/src/inference/asr/protocol.d.ts +23 -0
- package/dist/src/inference/asr/protocol.js +60 -0
- package/dist/src/inference/asr/runtime.d.ts +29 -0
- package/dist/src/inference/asr/runtime.js +103 -0
- package/dist/src/inference/asr/windows.d.ts +9 -0
- package/dist/src/inference/asr/windows.js +41 -0
- package/dist/src/inference/embedding/index.d.ts +47 -0
- package/dist/src/inference/embedding/index.js +122 -0
- package/dist/src/media/capture-helper.d.ts +1 -0
- package/dist/src/media/capture-helper.js +16 -0
- package/dist/src/media/capture.d.ts +32 -0
- package/dist/src/media/capture.js +193 -0
- package/dist/src/media/handler.d.ts +22 -0
- package/dist/src/media/handler.js +82 -0
- package/dist/src/media/transcribe.d.ts +29 -0
- package/dist/src/media/transcribe.js +93 -0
- package/dist/src/metadata/article.d.ts +5 -0
- package/dist/src/metadata/article.js +51 -0
- package/dist/src/models/artifacts.d.ts +36 -0
- package/dist/src/models/artifacts.js +274 -0
- package/dist/src/models/catalog.d.ts +35 -0
- package/dist/src/models/catalog.js +71 -0
- package/dist/src/models/download.d.ts +9 -0
- package/dist/src/models/download.js +147 -0
- package/dist/src/models/runtime.d.ts +24 -0
- package/dist/src/models/runtime.js +37 -0
- package/dist/src/pdf/capture-helper.d.ts +1 -0
- package/dist/src/pdf/capture-helper.js +12 -0
- package/dist/src/pdf/capture.d.ts +29 -0
- package/dist/src/pdf/capture.js +156 -0
- package/dist/src/pdf/errors.d.ts +2 -0
- package/dist/src/pdf/errors.js +13 -0
- package/dist/src/pdf/handler.d.ts +24 -0
- package/dist/src/pdf/handler.js +84 -0
- package/dist/src/pdf/index.d.ts +4 -0
- package/dist/src/pdf/index.js +4 -0
- package/dist/src/pdf/input.d.ts +2 -0
- package/dist/src/pdf/input.js +46 -0
- package/dist/src/pdf/normalize.d.ts +10 -0
- package/dist/src/pdf/normalize.js +64 -0
- package/dist/src/pdf/source-identity.d.ts +4 -0
- package/dist/src/pdf/source-identity.js +12 -0
- package/dist/src/pdf/source.d.ts +6 -0
- package/dist/src/pdf/source.js +22 -0
- package/dist/src/pdf/verify-helper.d.ts +1 -0
- package/dist/src/pdf/verify-helper.js +17 -0
- package/dist/src/portability/backup.d.ts +23 -0
- package/dist/src/portability/backup.js +138 -0
- package/dist/src/portability/database.d.ts +14 -0
- package/dist/src/portability/database.js +78 -0
- package/dist/src/portability/export.d.ts +8 -0
- package/dist/src/portability/export.js +36 -0
- package/dist/src/portability/files.d.ts +33 -0
- package/dist/src/portability/files.js +78 -0
- package/dist/src/portability/gc.d.ts +21 -0
- package/dist/src/portability/gc.js +66 -0
- package/dist/src/search/extension.d.ts +3 -0
- package/dist/src/search/extension.js +17 -0
- package/dist/src/search/hybrid.d.ts +39 -0
- package/dist/src/search/hybrid.js +118 -0
- package/dist/src/search/query.d.ts +12 -0
- package/dist/src/search/query.js +47 -0
- package/dist/src/search/vector.d.ts +21 -0
- package/dist/src/search/vector.js +21 -0
- package/dist/src/service/launch-agent.d.ts +47 -0
- package/dist/src/service/launch-agent.js +179 -0
- package/dist/src/service/lifetime.d.ts +7 -0
- package/dist/src/service/lifetime.js +73 -0
- package/dist/src/service/loop.d.ts +21 -0
- package/dist/src/service/loop.js +94 -0
- package/dist/src/store/files.d.ts +12 -0
- package/dist/src/store/files.js +101 -0
- package/dist/src/store/index.d.ts +118 -0
- package/dist/src/store/index.js +727 -0
- package/dist/src/store/types.d.ts +90 -0
- package/dist/src/store/types.js +8 -0
- package/dist/src/worker/basic.d.ts +10 -0
- package/dist/src/worker/basic.js +27 -0
- package/dist/src/worker/custody.d.ts +13 -0
- package/dist/src/worker/custody.js +185 -0
- package/dist/src/worker/engine.d.ts +28 -0
- package/dist/src/worker/engine.js +78 -0
- package/dist/src/worker/identity.d.ts +7 -0
- package/dist/src/worker/identity.js +27 -0
- package/dist/src/worker/journal.d.ts +5 -0
- package/dist/src/worker/journal.js +30 -0
- package/dist/src/worker/supervisor-child.d.ts +1 -0
- package/dist/src/worker/supervisor-child.js +33 -0
- package/dist/src/worker/supervisor.d.ts +3 -0
- package/dist/src/worker/supervisor.js +47 -0
- package/native/runtime-manifest.json +40 -0
- package/package.json +31 -5
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import { DatabaseSync, backup } from 'node:sqlite';
|
|
2
|
+
import { chmodSync, closeSync, constants, readFileSync, fstatSync, readdirSync, fsyncSync, linkSync, mkdirSync, openSync, writeFileSync, rmSync, statfsSync, unlinkSync, writeSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, parse } from 'node:path';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
import { sql as sql1 } from '../../migrations/001.js';
|
|
6
|
+
import { sql as sql2 } from '../../migrations/002.js';
|
|
7
|
+
import { sql as sql3 } from '../../migrations/003.js';
|
|
8
|
+
import { sql as sql4, version } from '../../migrations/004.js';
|
|
9
|
+
import * as indexJobs from '../indexing/jobs.js';
|
|
10
|
+
import { limits } from '../../contracts/index.js';
|
|
11
|
+
import { validateMetadataClaim, validateMetadataWarnings } from '../../contracts/validate.js';
|
|
12
|
+
import { confinedFile, digest, hash, integer, regularFile, safeDirectory, syncDirectory, text, verifyFile, pathExists, readVerifiedFile } from './files.js';
|
|
13
|
+
import { StoreError } from './types.js';
|
|
14
|
+
import * as indexing from '../indexing/index.js';
|
|
15
|
+
import { hybridSearch } from '../search/hybrid.js';
|
|
16
|
+
import { initializeVectorExtension } from '../search/extension.js';
|
|
17
|
+
export * from './types.js';
|
|
18
|
+
const now = () => new Date().toISOString();
|
|
19
|
+
const migrations = [sql1, sql2, sql3, sql4];
|
|
20
|
+
export const libraryMigrations = Object.freeze([...migrations]);
|
|
21
|
+
export async function openLibrary(path, options = {}) {
|
|
22
|
+
if (path === parse(path).root)
|
|
23
|
+
throw new StoreError('invalid-input');
|
|
24
|
+
const root = safeDirectory(path, true);
|
|
25
|
+
if (root === parse(root).root)
|
|
26
|
+
throw new StoreError('invalid-input');
|
|
27
|
+
const marker = join(root, '.sponge-library'), dbPath = confinedFile(root, join(root, 'library.sqlite'));
|
|
28
|
+
if (pathExists(marker)) {
|
|
29
|
+
regularFile(marker);
|
|
30
|
+
verifyFile(marker, digest('sponge-library-v1\n'), Buffer.byteLength('sponge-library-v1\n'));
|
|
31
|
+
}
|
|
32
|
+
else if (readdirSync(root).length)
|
|
33
|
+
throw new StoreError('invalid-input');
|
|
34
|
+
for (const suffix of ['', '-wal', '-shm'])
|
|
35
|
+
if (pathExists(dbPath + suffix))
|
|
36
|
+
regularFile(dbPath + suffix);
|
|
37
|
+
if (pathExists(dbPath)) {
|
|
38
|
+
const inspect = new DatabaseSync(dbPath, { readOnly: true });
|
|
39
|
+
try {
|
|
40
|
+
if (Number(inspect.prepare('PRAGMA user_version').get()?.user_version) > version)
|
|
41
|
+
throw new StoreError('schema-newer');
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
inspect.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
chmodSync(root, 0o700);
|
|
48
|
+
if (!pathExists(marker)) {
|
|
49
|
+
writeFileSync(marker, 'sponge-library-v1\n', { flag: 'wx', mode: 0o600 });
|
|
50
|
+
const fd = openSync(marker, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
51
|
+
try {
|
|
52
|
+
fsyncSync(fd);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
syncDirectory(root);
|
|
58
|
+
}
|
|
59
|
+
for (const part of ['runtime', 'staging', 'blobs', 'blobs/sha256', 'backups']) {
|
|
60
|
+
const p = safeDirectory(join(root, part), true);
|
|
61
|
+
chmodSync(p, 0o700);
|
|
62
|
+
}
|
|
63
|
+
const db = new DatabaseSync(dbPath, { allowExtension: true });
|
|
64
|
+
chmodSync(dbPath, 0o600);
|
|
65
|
+
try {
|
|
66
|
+
db.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
|
|
67
|
+
const current = Number(db.prepare('PRAGMA user_version').get()?.user_version);
|
|
68
|
+
if (current > version)
|
|
69
|
+
throw new StoreError('schema-newer');
|
|
70
|
+
if (current < version) {
|
|
71
|
+
const lock = join(root, 'runtime', 'migration.lock');
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === 'EEXIST')
|
|
77
|
+
throw new StoreError('owner-busy');
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
// Recheck under the migration lock: another opener may already have migrated.
|
|
82
|
+
const actual = Number(db.prepare('PRAGMA user_version').get()?.user_version);
|
|
83
|
+
if (actual > version)
|
|
84
|
+
throw new StoreError('schema-newer');
|
|
85
|
+
if (actual === 0 && db.prepare("SELECT name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").all().length)
|
|
86
|
+
throw new StoreError('integrity');
|
|
87
|
+
for (let prior = 1; prior <= actual; prior++) {
|
|
88
|
+
if (db.prepare('SELECT checksum FROM schema_migrations WHERE version=?').get(prior)?.checksum !== digest(migrations[prior - 1]))
|
|
89
|
+
throw new StoreError('integrity');
|
|
90
|
+
}
|
|
91
|
+
if (actual > 0 && db.prepare('SELECT schema_version FROM library_info WHERE id=1').get()?.schema_version !== actual)
|
|
92
|
+
throw new StoreError('integrity');
|
|
93
|
+
if (actual < version) {
|
|
94
|
+
// SQLite backup incorporates WAL; never copy the main file alone.
|
|
95
|
+
const backupPath = join(root, 'backups', `before-schema-${version}-${randomUUID()}.sqlite`);
|
|
96
|
+
await backup(db, backupPath);
|
|
97
|
+
chmodSync(backupPath, 0o600);
|
|
98
|
+
const fd = openSync(backupPath, constants.O_RDONLY);
|
|
99
|
+
try {
|
|
100
|
+
fsyncSync(fd);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
syncDirectory(dirname(backupPath));
|
|
106
|
+
}
|
|
107
|
+
for (let target = actual + 1; target <= version; target++) {
|
|
108
|
+
db.exec('BEGIN IMMEDIATE');
|
|
109
|
+
try {
|
|
110
|
+
db.exec(migrations[target - 1]);
|
|
111
|
+
if (target === 1)
|
|
112
|
+
db.prepare('INSERT INTO library_info(id,uuid,schema_version,created_by) VALUES(1,?,?,?)').run(randomUUID(), target, options.appVersion ?? 'development');
|
|
113
|
+
else
|
|
114
|
+
db.prepare('UPDATE library_info SET schema_version=? WHERE id=1').run(target);
|
|
115
|
+
db.prepare('INSERT INTO schema_migrations VALUES(?,?,?)').run(target, digest(migrations[target - 1]), now());
|
|
116
|
+
db.exec(`PRAGMA user_version=${target}; COMMIT`);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
db.exec('ROLLBACK');
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
rmSync(lock, { recursive: true });
|
|
126
|
+
syncDirectory(join(root, 'runtime'));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (let applied = 1; applied <= version; applied++)
|
|
130
|
+
if (db.prepare('SELECT checksum FROM schema_migrations WHERE version=?').get(applied)?.checksum !== digest(migrations[applied - 1]))
|
|
131
|
+
throw new StoreError('integrity');
|
|
132
|
+
if (db.prepare('SELECT schema_version FROM library_info WHERE id=1').get()?.schema_version !== version)
|
|
133
|
+
throw new StoreError('integrity');
|
|
134
|
+
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
|
|
135
|
+
for (const suffix of ['', '-wal', '-shm'])
|
|
136
|
+
if (pathExists(dbPath + suffix))
|
|
137
|
+
chmodSync(dbPath + suffix, 0o600);
|
|
138
|
+
syncDirectory(root);
|
|
139
|
+
initializeVectorExtension(db);
|
|
140
|
+
return new LibraryStore(root, db, options);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
db.close();
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Storage CAS authority only. The worker separately establishes OS/process custody. */
|
|
148
|
+
export class LibraryStore {
|
|
149
|
+
root;
|
|
150
|
+
db;
|
|
151
|
+
options;
|
|
152
|
+
closed = false;
|
|
153
|
+
constructor(root, db, options) {
|
|
154
|
+
this.root = root;
|
|
155
|
+
this.db = db;
|
|
156
|
+
this.options = options;
|
|
157
|
+
}
|
|
158
|
+
createIndexGeneration(options) { return indexing.createGeneration(this.db, options); }
|
|
159
|
+
submitIndexJob(generationId) { return indexJobs.submitIndexJob(this.db, generationId); }
|
|
160
|
+
indexJob(id) { return indexJobs.indexJob(this.db, id); }
|
|
161
|
+
claimIndexJob(owner, id) { return indexJobs.claimIndexJob(this.db, owner, id); }
|
|
162
|
+
checkpointIndexAttempt(token, stage) { indexJobs.checkpointIndexAttempt(this.db, token, stage); }
|
|
163
|
+
writeIndexAttemptBatch(token, fingerprint, batch) { indexJobs.withIndexAttempt(this.db, token, id => indexing.writeBatch(this.db, id, fingerprint, batch)); }
|
|
164
|
+
completeIndexAttempt(token) { return indexJobs.withIndexAttempt(this.db, token, id => { const status = indexing.activateGeneration(this.db, id); indexJobs.finishIndexAttempt(this.db, token); return status; }); }
|
|
165
|
+
failIndexAttempt(token, reason) { indexJobs.finishIndexAttempt(this.db, token, reason); }
|
|
166
|
+
settleIndexAttempt(token) { this.transaction(() => { this.checkOwner(token); const a = this.db.prepare('SELECT * FROM index_attempts WHERE id=? AND job_id=?').get(token.attemptId, token.jobId); if (!a || a.owner_incarnation !== token.ownerIncarnation || a.fence !== token.fence || a.finished_at === null)
|
|
167
|
+
throw new StoreError('owner-busy'); const path = join(this.root, 'staging', token.attemptId); if (pathExists(path)) {
|
|
168
|
+
this.checkIndexStaging(token, path);
|
|
169
|
+
rmSync(path, { recursive: true });
|
|
170
|
+
syncDirectory(dirname(path));
|
|
171
|
+
} indexJobs.settleIndexAttempt(this.db, token); }); }
|
|
172
|
+
checkIndexStaging(token, path) { safeDirectory(path); const fd = openSync(join(path, 'ownership.json'), constants.O_RDONLY | constants.O_NOFOLLOW); try {
|
|
173
|
+
const stat = fstatSync(fd);
|
|
174
|
+
if (!stat.isFile() || stat.size > 65536 || (stat.mode & 0o077) !== 0)
|
|
175
|
+
throw new StoreError('integrity');
|
|
176
|
+
const marker = JSON.parse(readFileSync(fd, 'utf8'));
|
|
177
|
+
if (marker.schemaVersion !== 1 || marker.requestId !== token.attemptId || marker.ownerIncarnation !== token.ownerIncarnation)
|
|
178
|
+
throw new StoreError('integrity');
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
closeSync(fd);
|
|
182
|
+
} }
|
|
183
|
+
indexStatus(id) { return indexing.generationStatus(this.db, id); }
|
|
184
|
+
indexInputs(id, limit = 16) { return indexing.pendingInputs(this.db, id, limit); }
|
|
185
|
+
writeIndexBatch(id, fingerprint, batch) { indexing.writeBatch(this.db, id, fingerprint, batch); }
|
|
186
|
+
activateIndex(id) { return indexing.activateGeneration(this.db, id); }
|
|
187
|
+
cancelIndex(id) { indexing.cancelGeneration(this.db, id); }
|
|
188
|
+
searchHybrid(query, options = {}) { return hybridSearch(this.db, query, options); }
|
|
189
|
+
readAnnotations(itemId) { text(itemId, 256); const annotations = this.db.prepare('SELECT id,revision_id,anchor_json,body,created_at FROM annotations WHERE item_id=? ORDER BY created_at,id').all(itemId).map(row => ({ id: String(row.id), revisionId: row.revision_id === null ? null : String(row.revision_id), anchor: JSON.parse(String(row.anchor_json)), body: String(row.body), createdAt: String(row.created_at) })); const reading = this.db.prepare('SELECT state_json,updated_at FROM reading_state WHERE item_id=?').get(itemId); return { annotations, readingState: reading ? { state: JSON.parse(String(reading.state_json)), updatedAt: String(reading.updated_at) } : null }; }
|
|
190
|
+
lookupImportReceipt(namespace, sourceKey, sourceHash) {
|
|
191
|
+
text(namespace, 256);
|
|
192
|
+
text(sourceKey, 8192);
|
|
193
|
+
hash(sourceHash);
|
|
194
|
+
const row = this.db.prepare('SELECT item_id itemId,outcome FROM import_receipts WHERE namespace=? AND source_key=? AND source_hash=?').get(namespace, sourceKey, sourceHash);
|
|
195
|
+
return row ? { itemId: String(row.itemId), outcome: String(row.outcome) } : null;
|
|
196
|
+
}
|
|
197
|
+
close() {
|
|
198
|
+
if (!this.closed) {
|
|
199
|
+
this.db.close();
|
|
200
|
+
this.closed = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
transaction(fn) {
|
|
204
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
205
|
+
try {
|
|
206
|
+
const result = fn();
|
|
207
|
+
this.db.exec('COMMIT');
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
this.db.exec('ROLLBACK');
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
get revisionEpoch() { return Number(this.db.prepare('SELECT revision_epoch FROM library_info WHERE id=1').get()?.revision_epoch); }
|
|
216
|
+
bumpEpoch() { this.db.exec('UPDATE library_info SET revision_epoch=revision_epoch+1 WHERE id=1'); }
|
|
217
|
+
acquireOwner(ownerIncarnation) {
|
|
218
|
+
text(ownerIncarnation, 256);
|
|
219
|
+
return this.transaction(() => {
|
|
220
|
+
const old = this.db.prepare('SELECT * FROM worker_owner WHERE id=1').get();
|
|
221
|
+
if (old.incarnation !== null && old.incarnation !== ownerIncarnation)
|
|
222
|
+
throw new StoreError('owner-busy');
|
|
223
|
+
if (old.incarnation === ownerIncarnation)
|
|
224
|
+
return { ownerIncarnation, fence: Number(old.fence) };
|
|
225
|
+
this.db.prepare('UPDATE worker_owner SET incarnation=?,fence=fence+1 WHERE id=1').run(ownerIncarnation);
|
|
226
|
+
return { ownerIncarnation, fence: Number(old.fence) + 1 };
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
releaseOwner(token) {
|
|
230
|
+
this.transaction(() => {
|
|
231
|
+
this.checkOwner(token);
|
|
232
|
+
if (this.ownerAttempts(token).some(attempt => attempt.cleanup === 'pending'))
|
|
233
|
+
throw new StoreError('owner-busy');
|
|
234
|
+
this.db.prepare('UPDATE worker_owner SET incarnation=NULL WHERE id=1').run();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
ownerSnapshot() {
|
|
238
|
+
const row = this.db.prepare('SELECT incarnation,fence FROM worker_owner WHERE id=1').get();
|
|
239
|
+
return row.incarnation === null ? null : { ownerIncarnation: String(row.incarnation), fence: Number(row.fence) };
|
|
240
|
+
}
|
|
241
|
+
ownerAttempts(token) {
|
|
242
|
+
this.checkOwner(token);
|
|
243
|
+
return [...this.db.prepare('SELECT a.*,j.state,j.cancel_requested FROM attempts a JOIN jobs j ON j.id=a.job_id WHERE a.owner_incarnation=? AND a.fence=? ORDER BY a.created_at,a.id').all(token.ownerIncarnation, token.fence).map(a => ({
|
|
244
|
+
...token, attemptId: String(a.id), jobId: String(a.job_id), stage: String(a.stage), cleanup: a.cleanup, finished: a.finished_at !== null, jobState: String(a.state), cancelRequested: a.cancel_requested === 1,
|
|
245
|
+
})), ...indexJobs.indexOwnerAttempts(this.db, token)];
|
|
246
|
+
}
|
|
247
|
+
/** Requires external custody proof for EVERY pending attempt, even after its job committed.
|
|
248
|
+
* IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
|
|
249
|
+
recoverOwner(expected, provedAttemptIds) {
|
|
250
|
+
if (!Array.isArray(provedAttemptIds) || new Set(provedAttemptIds).size !== provedAttemptIds.length)
|
|
251
|
+
throw new StoreError('invalid-input');
|
|
252
|
+
for (const id of provedAttemptIds)
|
|
253
|
+
text(id, 256);
|
|
254
|
+
this.transaction(() => {
|
|
255
|
+
this.checkOwner(expected);
|
|
256
|
+
const pending = this.ownerAttempts(expected).filter(a => a.cleanup === 'pending');
|
|
257
|
+
const proof = new Set(provedAttemptIds);
|
|
258
|
+
if (proof.size !== pending.length || pending.some(a => !proof.has(a.attemptId)))
|
|
259
|
+
throw new StoreError('owner-busy');
|
|
260
|
+
for (const attempt of pending) {
|
|
261
|
+
const path = join(this.root, 'staging', attempt.attemptId);
|
|
262
|
+
if (pathExists(path)) {
|
|
263
|
+
if (attempt.kind === 'index')
|
|
264
|
+
this.checkIndexStaging(attempt, path);
|
|
265
|
+
else
|
|
266
|
+
safeDirectory(path);
|
|
267
|
+
rmSync(path, { recursive: true });
|
|
268
|
+
syncDirectory(dirname(path));
|
|
269
|
+
}
|
|
270
|
+
if (attempt.kind === 'index') {
|
|
271
|
+
indexJobs.recoverIndexAttempt(this.db, attempt);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
this.db.prepare('DELETE FROM staging_pins WHERE attempt_id=?').run(attempt.attemptId);
|
|
275
|
+
this.db.prepare("UPDATE attempts SET cleanup='settled',stage='settled',finished_at=COALESCE(finished_at,?) WHERE id=?").run(now(), attempt.attemptId);
|
|
276
|
+
if (!attempt.finished)
|
|
277
|
+
this.db.prepare("UPDATE jobs SET state=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'failed' END,reason='unclassified',retryable=0,retry_after_ms=NULL WHERE id=? AND state IN ('running','cleanup-pending')").run(attempt.jobId);
|
|
278
|
+
}
|
|
279
|
+
this.db.prepare('UPDATE worker_owner SET incarnation=NULL,fence=fence+1 WHERE id=1').run();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
checkpointAttempt(token, stage) {
|
|
283
|
+
const stages = ['queued', 'acquiring', 'extracting', 'indexing', 'promoting', 'cleanup', 'settled'];
|
|
284
|
+
if (!stages.includes(stage) || stage === 'settled')
|
|
285
|
+
throw new StoreError('invalid-input');
|
|
286
|
+
this.transaction(() => {
|
|
287
|
+
const attempt = this.checkAttempt(token);
|
|
288
|
+
if (stages.indexOf(stage) < stages.indexOf(String(attempt.stage)) || !stages.includes(String(attempt.stage)))
|
|
289
|
+
throw new StoreError('invalid-input');
|
|
290
|
+
this.db.prepare('UPDATE attempts SET stage=? WHERE id=?').run(stage, token.attemptId);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
jobCancellationRequested(jobId) {
|
|
294
|
+
text(jobId, 256);
|
|
295
|
+
const index = indexJobs.indexJob(this.db, jobId);
|
|
296
|
+
if (index)
|
|
297
|
+
return index.cancel_requested === 1;
|
|
298
|
+
const row = this.db.prepare('SELECT j.cancel_requested,i.deleted_at FROM jobs j JOIN items i ON i.id=j.item_id WHERE j.id=?').get(jobId);
|
|
299
|
+
if (!row)
|
|
300
|
+
throw new StoreError('unavailable');
|
|
301
|
+
return row.cancel_requested === 1 || row.deleted_at !== null;
|
|
302
|
+
}
|
|
303
|
+
retryJob(jobId, options = {}) {
|
|
304
|
+
if (indexJobs.indexJob(this.db, jobId)) {
|
|
305
|
+
if (options.automatic || options.notBefore !== undefined)
|
|
306
|
+
throw new StoreError('invalid-input');
|
|
307
|
+
indexJobs.retryIndexJob(this.db, jobId);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
text(jobId, 256);
|
|
311
|
+
if (options.automatic !== undefined && typeof options.automatic !== 'boolean')
|
|
312
|
+
throw new StoreError('invalid-input');
|
|
313
|
+
if (options.notBefore !== undefined && (typeof options.notBefore !== 'string' || !Number.isFinite(Date.parse(options.notBefore))))
|
|
314
|
+
throw new StoreError('invalid-input');
|
|
315
|
+
this.transaction(() => {
|
|
316
|
+
const job = this.db.prepare('SELECT j.*,i.deleted_at FROM jobs j JOIN items i ON i.id=j.item_id WHERE j.id=?').get(jobId);
|
|
317
|
+
if (!job || job.deleted_at || job.cancel_requested || !['failed', 'needs-auth', 'needs-permission', 'unsupported'].includes(String(job.state)))
|
|
318
|
+
throw new StoreError('unavailable');
|
|
319
|
+
if (this.db.prepare("SELECT count(*) n FROM attempts WHERE job_id=? AND (cleanup='pending' OR finished_at IS NULL)").get(jobId)?.n)
|
|
320
|
+
throw new StoreError('owner-busy');
|
|
321
|
+
if (this.db.prepare("SELECT count(*) n FROM jobs WHERE item_id=? AND id<>? AND state IN ('queued','running','delayed','cleanup-pending')").get(String(job.item_id), jobId)?.n)
|
|
322
|
+
throw new StoreError('owner-busy');
|
|
323
|
+
let due = options.notBefore === undefined ? Date.now() : Date.parse(options.notBefore);
|
|
324
|
+
if (options.automatic) {
|
|
325
|
+
if (job.retryable !== 1 || !['unavailable', 'rate-limited'].includes(String(job.reason)) || Number(job.attempt_count) >= 3)
|
|
326
|
+
throw new StoreError('unavailable');
|
|
327
|
+
const backoff = Math.max(Number(job.retry_after_ms ?? 0), 1000 * 2 ** Math.max(0, Number(job.attempt_count) - 1));
|
|
328
|
+
due = Math.max(due, Date.now() + backoff);
|
|
329
|
+
}
|
|
330
|
+
const notBefore = new Date(due).toISOString();
|
|
331
|
+
this.db.prepare("UPDATE jobs SET state=?,not_before=?,reason=NULL,retryable=0,retry_after_ms=NULL WHERE id=?").run(due > Date.now() ? 'delayed' : 'queued', notBefore, jobId);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
checkOwner(token) {
|
|
335
|
+
text(token.ownerIncarnation, 256);
|
|
336
|
+
integer(token.fence);
|
|
337
|
+
const owner = this.db.prepare('SELECT * FROM worker_owner WHERE id=1').get();
|
|
338
|
+
if (owner?.incarnation !== token.ownerIncarnation || owner?.fence !== token.fence)
|
|
339
|
+
throw new StoreError('owner-busy');
|
|
340
|
+
}
|
|
341
|
+
submitSource(input) {
|
|
342
|
+
text(input.sourceKey);
|
|
343
|
+
text(input.locator, 16384);
|
|
344
|
+
text(input.idempotencyKey, 256);
|
|
345
|
+
if (!['article', 'pdf', 'media'].includes(input.kind))
|
|
346
|
+
throw new StoreError('invalid-input');
|
|
347
|
+
const realm = input.realm ?? '';
|
|
348
|
+
if (realm)
|
|
349
|
+
text(realm, 256);
|
|
350
|
+
if (input.intent !== undefined && !['save', 'refresh'].includes(input.intent))
|
|
351
|
+
throw new StoreError('invalid-input');
|
|
352
|
+
return this.transaction(() => {
|
|
353
|
+
const prior = this.db.prepare('SELECT s.*,r.source_key,r.realm_key,i.kind,i.deleted_at FROM submissions s JOIN source_refs r ON r.id=s.source_id JOIN items i ON i.id=s.item_id WHERE idempotency_key=?').get(input.idempotencyKey);
|
|
354
|
+
if (prior) {
|
|
355
|
+
if (prior.deleted_at)
|
|
356
|
+
throw new StoreError('unavailable');
|
|
357
|
+
if (prior.kind !== input.kind)
|
|
358
|
+
throw new StoreError('invalid-input');
|
|
359
|
+
if (prior.source_key !== input.sourceKey || prior.realm_key !== realm || prior.intent !== (input.intent ?? 'save'))
|
|
360
|
+
throw new StoreError('invalid-input');
|
|
361
|
+
return { itemId: prior.item_id, jobId: prior.job_id, submissionId: prior.id, duplicate: true };
|
|
362
|
+
}
|
|
363
|
+
let source = this.db.prepare('SELECT r.*,i.deleted_at,i.kind FROM source_refs r JOIN items i ON i.id=r.item_id WHERE source_key=? AND realm_key=?').get(input.sourceKey, realm);
|
|
364
|
+
if (source?.deleted_at)
|
|
365
|
+
throw new StoreError('unavailable');
|
|
366
|
+
if (source && source.kind !== input.kind)
|
|
367
|
+
throw new StoreError('invalid-input');
|
|
368
|
+
if (!source) {
|
|
369
|
+
const itemId = randomUUID(), sourceId = randomUUID();
|
|
370
|
+
this.db.prepare('INSERT INTO items(id,kind,created_at) VALUES(?,?,?)').run(itemId, input.kind, now());
|
|
371
|
+
this.db.prepare('INSERT INTO source_refs(id,item_id,source_key,realm_key,locator) VALUES(?,?,?,?,?)').run(sourceId, itemId, input.sourceKey, realm, input.locator);
|
|
372
|
+
source = { id: sourceId, item_id: itemId };
|
|
373
|
+
}
|
|
374
|
+
const active = this.db.prepare("SELECT id FROM jobs WHERE item_id=? AND state IN ('queued','running','delayed','cleanup-pending') ORDER BY created_at LIMIT 1").get(source.item_id);
|
|
375
|
+
const jobId = active ? String(active.id) : randomUUID();
|
|
376
|
+
if (!active)
|
|
377
|
+
this.db.prepare("INSERT INTO jobs(id,item_id,state,created_at) VALUES(?,?,'queued',?)").run(jobId, source.item_id, now());
|
|
378
|
+
const submissionId = randomUUID();
|
|
379
|
+
this.db.prepare('INSERT INTO submissions VALUES(?,?,?,?,?,?,?)').run(submissionId, input.idempotencyKey, source.id, source.item_id, jobId, now(), input.intent ?? 'save');
|
|
380
|
+
return { itemId: source.item_id, jobId, submissionId, duplicate: false };
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
listJobs() { return [...this.db.prepare('SELECT j.* FROM jobs j JOIN items i ON i.id=j.item_id WHERE i.deleted_at IS NULL ORDER BY j.created_at,j.id').all(), ...indexJobs.listIndexJobs(this.db)].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id))); }
|
|
384
|
+
sourceForJob(jobId) { text(jobId, 256); const row = this.db.prepare('SELECT i.id itemId,i.kind,r.source_key sourceKey,r.locator,r.realm_key realm FROM jobs j JOIN items i ON i.id=j.item_id JOIN source_refs r ON r.item_id=i.id WHERE j.id=? AND i.deleted_at IS NULL ORDER BY r.id LIMIT 1').get(jobId); return row ?? null; }
|
|
385
|
+
claimNext(token) { this.checkOwner(token); const row = this.db.prepare("SELECT j.id FROM jobs j JOIN items i ON i.id=j.item_id WHERE j.state IN ('queued','delayed') AND j.cancel_requested=0 AND i.deleted_at IS NULL AND (j.not_before IS NULL OR j.not_before<=?) ORDER BY j.priority DESC,j.created_at,j.id LIMIT 1").get(now()); return row ? this.claimJob(token, String(row.id)) : null; }
|
|
386
|
+
claimJob(token, jobId) {
|
|
387
|
+
text(jobId, 256);
|
|
388
|
+
return this.transaction(() => {
|
|
389
|
+
this.checkOwner(token);
|
|
390
|
+
if (this.db.prepare("SELECT count(*) AS n FROM index_attempts WHERE cleanup='pending'").get()?.n)
|
|
391
|
+
throw new StoreError('owner-busy');
|
|
392
|
+
const job = this.db.prepare('SELECT j.*,i.deleted_at FROM jobs j JOIN items i ON i.id=j.item_id WHERE j.id=?').get(jobId);
|
|
393
|
+
if (!job || !['queued', 'delayed'].includes(String(job.state)) || job.cancel_requested || job.deleted_at || (job.not_before && String(job.not_before) > now()))
|
|
394
|
+
throw new StoreError('unavailable');
|
|
395
|
+
const attemptId = randomUUID();
|
|
396
|
+
this.db.prepare("UPDATE jobs SET state='running',attempt_count=attempt_count+1 WHERE id=?").run(jobId);
|
|
397
|
+
this.db.prepare("INSERT INTO attempts(id,job_id,owner_incarnation,fence,stage,cleanup,created_at) VALUES(?,?,?,?,'acquiring','pending',?)").run(attemptId, jobId, token.ownerIncarnation, token.fence, now());
|
|
398
|
+
return { ...token, jobId, attemptId };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
checkAttempt(token, itemId) {
|
|
402
|
+
this.checkOwner(token);
|
|
403
|
+
text(token.attemptId, 256);
|
|
404
|
+
text(token.jobId, 256);
|
|
405
|
+
const row = this.db.prepare('SELECT a.*,j.item_id,j.state,j.cancel_requested,i.deleted_at FROM attempts a JOIN jobs j ON j.id=a.job_id JOIN items i ON i.id=j.item_id WHERE a.id=? AND a.job_id=?').get(token.attemptId, token.jobId);
|
|
406
|
+
if (!row || row.owner_incarnation !== token.ownerIncarnation || row.fence !== token.fence || row.finished_at || row.state !== 'running')
|
|
407
|
+
throw new StoreError('owner-busy');
|
|
408
|
+
if (row.cancel_requested || row.deleted_at)
|
|
409
|
+
throw new StoreError('cancelled');
|
|
410
|
+
if (itemId && row.item_id !== itemId)
|
|
411
|
+
throw new StoreError('invalid-input');
|
|
412
|
+
return row;
|
|
413
|
+
}
|
|
414
|
+
requestCancellation(jobId) {
|
|
415
|
+
text(jobId, 256);
|
|
416
|
+
if (indexJobs.indexJob(this.db, jobId)) {
|
|
417
|
+
indexJobs.cancelIndexJob(this.db, jobId);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
this.transaction(() => {
|
|
421
|
+
const job = this.db.prepare('SELECT state FROM jobs WHERE id=?').get(jobId);
|
|
422
|
+
if (!job)
|
|
423
|
+
throw new StoreError('unavailable');
|
|
424
|
+
if (['succeeded', 'cancelled'].includes(String(job.state)))
|
|
425
|
+
return;
|
|
426
|
+
this.db.prepare("UPDATE jobs SET cancel_requested=1,state=CASE WHEN state IN ('queued','delayed','needs-auth','needs-permission','unsupported','failed') THEN 'cancelled' ELSE state END WHERE id=?").run(jobId);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
failAttempt(token, reason = 'unclassified', options = {}) {
|
|
430
|
+
if (options.retryable !== undefined && typeof options.retryable !== 'boolean')
|
|
431
|
+
throw new StoreError('invalid-input');
|
|
432
|
+
if (options.retryAfterMs !== undefined)
|
|
433
|
+
integer(options.retryAfterMs, 86400000);
|
|
434
|
+
if (!/^[a-z][a-z-]{0,63}$/.test(reason))
|
|
435
|
+
throw new StoreError('invalid-input');
|
|
436
|
+
this.transaction(() => {
|
|
437
|
+
this.checkOwner(token);
|
|
438
|
+
const a = this.db.prepare('SELECT a.*,j.cancel_requested FROM attempts a JOIN jobs j ON j.id=a.job_id WHERE a.id=? AND a.job_id=?').get(token.attemptId, token.jobId);
|
|
439
|
+
if (!a || a.owner_incarnation !== token.ownerIncarnation || a.fence !== token.fence || a.finished_at)
|
|
440
|
+
throw new StoreError('owner-busy');
|
|
441
|
+
this.db.prepare("UPDATE jobs SET state=?,reason=?,retryable=?,retry_after_ms=? WHERE id=?").run(a.cancel_requested ? 'cancelled' : reason === 'repair-auth' ? 'needs-auth' : reason === 'grant-permission' ? 'needs-permission' : reason === 'unsupported' ? 'unsupported' : 'failed', reason, Number(options.retryable === true), options.retryAfterMs ?? null, token.jobId);
|
|
442
|
+
this.db.prepare("UPDATE attempts SET stage='cleanup',finished_at=? WHERE id=?").run(now(), token.attemptId);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
/** Caller must first settle owned external children. This only releases storage staging pins. */
|
|
446
|
+
settleAttempt(token) {
|
|
447
|
+
this.transaction(() => {
|
|
448
|
+
this.checkOwner(token);
|
|
449
|
+
const a = this.db.prepare('SELECT * FROM attempts WHERE id=? AND job_id=?').get(token.attemptId, token.jobId);
|
|
450
|
+
if (!a || a.owner_incarnation !== token.ownerIncarnation || a.fence !== token.fence || !a.finished_at)
|
|
451
|
+
throw new StoreError('owner-busy');
|
|
452
|
+
const path = join(this.root, 'staging', token.attemptId);
|
|
453
|
+
if (pathExists(path)) {
|
|
454
|
+
safeDirectory(path);
|
|
455
|
+
rmSync(path, { recursive: true });
|
|
456
|
+
syncDirectory(dirname(path));
|
|
457
|
+
}
|
|
458
|
+
this.db.prepare('DELETE FROM staging_pins WHERE attempt_id=?').run(token.attemptId);
|
|
459
|
+
this.db.prepare("UPDATE attempts SET cleanup='settled',stage='settled' WHERE id=?").run(token.attemptId);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
async putBlob(token, expectedHash, expectedBytes, source) {
|
|
463
|
+
hash(expectedHash);
|
|
464
|
+
integer(expectedBytes, limits.pdfBytes);
|
|
465
|
+
const relativePath = `blobs/sha256/${expectedHash.slice(0, 2)}/${expectedHash}`;
|
|
466
|
+
this.transaction(() => {
|
|
467
|
+
this.checkAttempt(token);
|
|
468
|
+
const pins = this.db.prepare("SELECT COALESCE(sum(bytes),0) n FROM staging_pins").get();
|
|
469
|
+
if (Number(pins.n) + expectedBytes > limits.stagingBytes)
|
|
470
|
+
throw new StoreError('limit-exceeded');
|
|
471
|
+
const free = statfsSync(this.root);
|
|
472
|
+
if (free.bavail * free.bsize < limits.freeReserveBytes + expectedBytes)
|
|
473
|
+
throw new StoreError('limit-exceeded');
|
|
474
|
+
const existing = this.db.prepare('SELECT bytes FROM staging_pins WHERE attempt_id=? AND sha256=?').get(token.attemptId, expectedHash);
|
|
475
|
+
if (existing && existing.bytes !== expectedBytes)
|
|
476
|
+
throw new StoreError('integrity');
|
|
477
|
+
this.db.prepare("INSERT OR IGNORE INTO staging_pins VALUES(?,?,?,'staging')").run(token.attemptId, expectedHash, expectedBytes);
|
|
478
|
+
});
|
|
479
|
+
const stage = safeDirectory(join(this.root, 'staging', token.attemptId), true), temp = join(stage, randomUUID() + '.tmp');
|
|
480
|
+
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
481
|
+
let count = 0;
|
|
482
|
+
const h = createHash('sha256');
|
|
483
|
+
try {
|
|
484
|
+
for await (const chunk of source) {
|
|
485
|
+
if (!(chunk instanceof Uint8Array))
|
|
486
|
+
throw new StoreError('invalid-input');
|
|
487
|
+
count += chunk.byteLength;
|
|
488
|
+
if (count > expectedBytes)
|
|
489
|
+
throw new StoreError('limit-exceeded');
|
|
490
|
+
h.update(chunk);
|
|
491
|
+
let offset = 0;
|
|
492
|
+
while (offset < chunk.byteLength)
|
|
493
|
+
offset += writeSync(fd, chunk, offset, chunk.byteLength - offset);
|
|
494
|
+
}
|
|
495
|
+
if (count !== expectedBytes || h.digest('hex') !== expectedHash)
|
|
496
|
+
throw new StoreError('integrity');
|
|
497
|
+
fsyncSync(fd);
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
closeSync(fd);
|
|
501
|
+
unlinkSync(temp);
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
closeSync(fd);
|
|
505
|
+
syncDirectory(stage);
|
|
506
|
+
this.options.fault?.('staged');
|
|
507
|
+
const target = join(this.root, relativePath);
|
|
508
|
+
safeDirectory(dirname(target), true);
|
|
509
|
+
// Atomic create-if-absent publication. A hard-link plus unlink avoids rename overwriting immutable bytes.
|
|
510
|
+
this.transaction(() => {
|
|
511
|
+
this.checkAttempt(token);
|
|
512
|
+
try {
|
|
513
|
+
linkSync(temp, target);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
if (error.code !== 'EEXIST')
|
|
517
|
+
throw error;
|
|
518
|
+
verifyFile(target, expectedHash, expectedBytes);
|
|
519
|
+
}
|
|
520
|
+
unlinkSync(temp);
|
|
521
|
+
syncDirectory(dirname(target));
|
|
522
|
+
syncDirectory(stage);
|
|
523
|
+
verifyFile(target, expectedHash, expectedBytes);
|
|
524
|
+
this.db.prepare("UPDATE staging_pins SET state='published' WHERE attempt_id=? AND sha256=?").run(token.attemptId, expectedHash);
|
|
525
|
+
});
|
|
526
|
+
this.options.fault?.('published');
|
|
527
|
+
return { sha256: expectedHash, bytes: expectedBytes, relativePath };
|
|
528
|
+
}
|
|
529
|
+
commitRevision(input) {
|
|
530
|
+
hash(input.bundleHash);
|
|
531
|
+
if (typeof input.markdown !== 'string' || Buffer.byteLength(input.markdown) > limits.markdownBytes)
|
|
532
|
+
throw new StoreError('limit-exceeded');
|
|
533
|
+
text(input.itemId, 256);
|
|
534
|
+
text(input.extractor.name, 256);
|
|
535
|
+
text(input.extractor.version, 128);
|
|
536
|
+
if (input.title)
|
|
537
|
+
text(input.title, 4096);
|
|
538
|
+
if (!['complete', 'partial'].includes(input.status) || input.assets.length > limits.imagesPerItem + 1)
|
|
539
|
+
throw new StoreError('invalid-input');
|
|
540
|
+
const metadata = input.metadata ?? [];
|
|
541
|
+
if (metadata.length > limits.metadataClaims || Buffer.byteLength(JSON.stringify(metadata)) > limits.metadataBytes)
|
|
542
|
+
throw new StoreError('limit-exceeded');
|
|
543
|
+
for (const claim of metadata) {
|
|
544
|
+
try {
|
|
545
|
+
validateMetadataClaim(claim);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
throw new StoreError('invalid-input');
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const metadataWarnings = input.metadataWarnings ?? [];
|
|
552
|
+
try {
|
|
553
|
+
validateMetadataWarnings(metadataWarnings);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
throw new StoreError('invalid-input');
|
|
557
|
+
}
|
|
558
|
+
const imported = input.importedState;
|
|
559
|
+
if (imported) {
|
|
560
|
+
text(imported.namespace, 256);
|
|
561
|
+
text(imported.sourceKey, 8192);
|
|
562
|
+
hash(imported.sourceHash);
|
|
563
|
+
text(imported.importerVersion, 128);
|
|
564
|
+
if (!['private', 'published'].includes(imported.privacy) || !Array.isArray(imported.notes) || imported.notes.length > 1000 || Buffer.byteLength(JSON.stringify(imported)) > 4 * 1024 ** 2)
|
|
565
|
+
throw new StoreError('limit-exceeded');
|
|
566
|
+
const noteIds = new Set();
|
|
567
|
+
for (const note of imported.notes) {
|
|
568
|
+
text(note.id, 256);
|
|
569
|
+
text(note.body, 65536);
|
|
570
|
+
if (noteIds.has(note.id))
|
|
571
|
+
throw new StoreError('invalid-input');
|
|
572
|
+
noteIds.add(note.id);
|
|
573
|
+
}
|
|
574
|
+
if (imported.readingState !== undefined && (!imported.readingState || Array.isArray(imported.readingState) || typeof imported.readingState !== 'object'))
|
|
575
|
+
throw new StoreError('invalid-input');
|
|
576
|
+
}
|
|
577
|
+
for (const asset of input.assets) {
|
|
578
|
+
hash(asset.sha256);
|
|
579
|
+
const transcript = asset.mediaType === 'application/json' && asset.relationship === 'transcript';
|
|
580
|
+
integer(asset.bytes, transcript ? 4 * 1024 ** 2 : asset.mediaType === 'application/pdf' ? limits.pdfBytes : limits.imageBytes);
|
|
581
|
+
if (!/^image\/[a-z0-9.+-]+$/.test(asset.mediaType) && asset.mediaType !== 'application/pdf' && !transcript)
|
|
582
|
+
throw new StoreError('invalid-input');
|
|
583
|
+
if (asset.relativePath !== `blobs/sha256/${asset.sha256.slice(0, 2)}/${asset.sha256}`)
|
|
584
|
+
throw new StoreError('integrity');
|
|
585
|
+
verifyFile(confinedFile(this.root, join(this.root, asset.relativePath)), asset.sha256, asset.bytes);
|
|
586
|
+
}
|
|
587
|
+
const payloadHash = digest(JSON.stringify({ markdown: input.markdown, title: input.title ?? '', extractor: input.extractor, status: input.status, assets: input.assets, metadata, warnings: input.warnings ?? [], ...(metadataWarnings.length ? { metadataWarnings } : {}) }));
|
|
588
|
+
const result = this.transaction(() => {
|
|
589
|
+
this.checkAttempt(input, input.itemId);
|
|
590
|
+
for (const asset of input.assets) {
|
|
591
|
+
const pin = this.db.prepare("SELECT bytes FROM staging_pins WHERE attempt_id=? AND sha256=? AND state='published'").get(input.attemptId, asset.sha256);
|
|
592
|
+
if (!pin || pin.bytes !== asset.bytes)
|
|
593
|
+
throw new StoreError('integrity');
|
|
594
|
+
}
|
|
595
|
+
const existing = this.db.prepare('SELECT id,payload_hash FROM revisions WHERE item_id=? AND bundle_hash=?').get(input.itemId, input.bundleHash);
|
|
596
|
+
const item = this.db.prepare('SELECT current_revision FROM items WHERE id=?').get(input.itemId);
|
|
597
|
+
const revisionId = existing ? String(existing.id) : randomUUID();
|
|
598
|
+
if (!existing) {
|
|
599
|
+
this.db.prepare('INSERT INTO revisions(id,item_id,parent_id,bundle_hash,extractor_name,extractor_version,captured_at,status,warnings_json,payload_hash,metadata_warnings_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(revisionId, input.itemId, item.current_revision ?? null, input.bundleHash, input.extractor.name, input.extractor.version, now(), input.status, JSON.stringify(input.warnings ?? []), payloadHash, JSON.stringify(metadataWarnings));
|
|
600
|
+
for (const [ordinal, asset] of input.assets.entries()) {
|
|
601
|
+
const existingAsset = this.db.prepare('SELECT bytes FROM assets WHERE sha256=?').get(asset.sha256);
|
|
602
|
+
if (existingAsset && existingAsset.bytes !== asset.bytes)
|
|
603
|
+
throw new StoreError('integrity');
|
|
604
|
+
this.db.prepare('INSERT OR IGNORE INTO assets VALUES(?,?,?,?)').run(asset.sha256, asset.bytes, asset.mediaType, asset.relativePath);
|
|
605
|
+
this.db.prepare('INSERT INTO revision_assets VALUES(?,?,?,?,?,?,?)').run(revisionId, asset.sha256, ordinal, asset.relationship ?? 'image', null, asset.alt ?? null, asset.caption ?? null);
|
|
606
|
+
}
|
|
607
|
+
this.db.prepare('INSERT INTO representations(id,revision_id,kind,mime_type,sha256,bytes,markdown,extractor_version) VALUES(?,?,?,?,?,?,?,?)').run(randomUUID(), revisionId, 'markdown', 'text/markdown', digest(input.markdown), Buffer.byteLength(input.markdown), input.markdown, input.extractor.version);
|
|
608
|
+
for (const claim of metadata)
|
|
609
|
+
this.db.prepare('INSERT INTO metadata_claims(id,revision_id,field,value_json,source,observed_at,rule,timezone_known,observed_json,locator,selected,extraction_version,date_precision) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)').run(randomUUID(), revisionId, claim.field, JSON.stringify(claim.value), claim.source, claim.observedAt, claim.rule ?? null, claim.timezoneKnown === undefined ? null : Number(claim.timezoneKnown), claim.observed === undefined ? null : JSON.stringify(claim.observed), claim.locator ?? null, claim.selected === undefined ? null : Number(claim.selected), claim.extractionVersion ?? null, claim.datePrecision ?? null);
|
|
610
|
+
this.db.prepare('INSERT INTO blocks(id,revision_id,ordinal,kind,text) VALUES(?,?,0,?,?)').run(randomUUID(), revisionId, 'document', input.markdown);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
if (existing.payload_hash !== payloadHash)
|
|
614
|
+
throw new StoreError('integrity');
|
|
615
|
+
const prior = this.db.prepare("SELECT sha256 FROM representations WHERE revision_id=? AND kind='markdown'").get(revisionId);
|
|
616
|
+
if (prior?.sha256 !== digest(input.markdown))
|
|
617
|
+
throw new StoreError('integrity');
|
|
618
|
+
}
|
|
619
|
+
if (item.current_revision !== revisionId) {
|
|
620
|
+
this.db.prepare('UPDATE items SET current_revision=?,title=? WHERE id=?').run(revisionId, input.title ?? '', input.itemId);
|
|
621
|
+
this.db.prepare('DELETE FROM readable_fts WHERE item_id=?').run(input.itemId);
|
|
622
|
+
const stored = this.db.prepare("SELECT markdown FROM representations WHERE revision_id=? AND kind='markdown'").get(revisionId);
|
|
623
|
+
this.db.prepare('INSERT INTO readable_fts(item_id,revision_id,title,markdown) VALUES(?,?,?,?)').run(input.itemId, revisionId, input.title ?? '', String(stored.markdown));
|
|
624
|
+
this.bumpEpoch();
|
|
625
|
+
}
|
|
626
|
+
if (imported) {
|
|
627
|
+
const receipt = this.lookupImportReceipt(imported.namespace, imported.sourceKey, imported.sourceHash);
|
|
628
|
+
if (receipt && receipt.itemId !== input.itemId)
|
|
629
|
+
throw new StoreError('integrity');
|
|
630
|
+
for (const note of imported.notes) {
|
|
631
|
+
const id = digest(JSON.stringify(['import-note-v1', imported.namespace, imported.sourceKey, note.id]));
|
|
632
|
+
const prior = this.db.prepare('SELECT item_id,body FROM annotations WHERE id=?').get(id);
|
|
633
|
+
if (prior && (prior.item_id !== input.itemId || prior.body !== note.body))
|
|
634
|
+
throw new StoreError('integrity');
|
|
635
|
+
if (!prior)
|
|
636
|
+
this.db.prepare('INSERT INTO annotations VALUES(?,?,?,?,?,?)').run(id, input.itemId, revisionId, JSON.stringify({ kind: 'imported-note', sourceId: note.id }), note.body, now());
|
|
637
|
+
}
|
|
638
|
+
const state = JSON.stringify({ ...imported.readingState, sourcePrivacy: imported.privacy });
|
|
639
|
+
const prior = this.db.prepare('SELECT state_json FROM reading_state WHERE item_id=?').get(input.itemId);
|
|
640
|
+
// Existing reading choices remain authoritative; import never overwrites them.
|
|
641
|
+
if (!prior)
|
|
642
|
+
this.db.prepare('INSERT INTO reading_state VALUES(?,?,?)').run(input.itemId, state, now());
|
|
643
|
+
else {
|
|
644
|
+
const existing = JSON.parse(String(prior.state_json));
|
|
645
|
+
const sourcePrivacy = existing.sourcePrivacy === 'published' && imported.privacy === 'published' ? 'published' : 'private';
|
|
646
|
+
this.db.prepare('UPDATE reading_state SET state_json=?,updated_at=? WHERE item_id=?').run(JSON.stringify({ ...existing, sourcePrivacy }), now(), input.itemId);
|
|
647
|
+
}
|
|
648
|
+
this.db.prepare('INSERT OR IGNORE INTO import_receipts VALUES(?,?,?,?,?,?)').run(imported.namespace, imported.sourceKey, imported.sourceHash, imported.importerVersion, input.itemId, 'imported');
|
|
649
|
+
}
|
|
650
|
+
this.db.prepare("UPDATE jobs SET state='succeeded',reason=NULL WHERE id=?").run(input.jobId);
|
|
651
|
+
this.db.prepare("UPDATE attempts SET stage='cleanup',finished_at=? WHERE id=?").run(now(), input.attemptId);
|
|
652
|
+
this.options.fault?.('transaction');
|
|
653
|
+
return { revisionId, duplicate: !!existing };
|
|
654
|
+
});
|
|
655
|
+
this.options.fault?.('committed');
|
|
656
|
+
return result;
|
|
657
|
+
}
|
|
658
|
+
list() { return this.db.prepare('SELECT id itemId,kind,title,current_revision revisionId FROM items WHERE deleted_at IS NULL ORDER BY created_at,id').all(); }
|
|
659
|
+
read(itemId) {
|
|
660
|
+
text(itemId, 256);
|
|
661
|
+
const item = this.db.prepare("SELECT i.id itemId,i.kind,i.title,r.id revisionId,r.status,p.markdown FROM items i JOIN revisions r ON r.id=i.current_revision JOIN representations p ON p.revision_id=r.id AND p.kind='markdown' WHERE i.id=? AND i.deleted_at IS NULL").get(itemId);
|
|
662
|
+
if (!item)
|
|
663
|
+
return null;
|
|
664
|
+
const assets = this.db.prepare('SELECT a.*,r.relationship,r.alt,r.caption FROM revision_assets r JOIN assets a ON a.sha256=r.sha256 WHERE r.revision_id=? ORDER BY r.ordinal').all(item.revisionId).map(a => ({ sha256: String(a.sha256), bytes: Number(a.bytes), relativePath: String(a.relative_path), mediaType: String(a.media_type), relationship: String(a.relationship), ...(a.alt ? { alt: String(a.alt) } : {}), ...(a.caption ? { caption: String(a.caption) } : {}) }));
|
|
665
|
+
const metadata = this.db.prepare('SELECT * FROM metadata_claims WHERE revision_id=? ORDER BY rowid').all(item.revisionId).map(c => ({ field: String(c.field), value: JSON.parse(String(c.value_json)), source: String(c.source), observedAt: String(c.observed_at), ...(c.rule ? { rule: String(c.rule) } : {}), ...(c.timezone_known === null ? {} : { timezoneKnown: !!c.timezone_known }), ...(c.observed_json === null ? {} : { observed: JSON.parse(String(c.observed_json)) }), ...(c.locator === null ? {} : { locator: String(c.locator) }), ...(c.selected === null ? {} : { selected: !!c.selected }), ...(c.extraction_version === null ? {} : { extractionVersion: String(c.extraction_version) }), ...(c.date_precision === null ? {} : { datePrecision: String(c.date_precision) }) }));
|
|
666
|
+
const metadataWarnings = JSON.parse(String(this.db.prepare('SELECT metadata_warnings_json FROM revisions WHERE id=?').get(item.revisionId)?.metadata_warnings_json));
|
|
667
|
+
validateMetadataWarnings(metadataWarnings);
|
|
668
|
+
return { ...item, assets, metadata, metadataWarnings };
|
|
669
|
+
}
|
|
670
|
+
readBlob(reference) {
|
|
671
|
+
hash(reference.sha256);
|
|
672
|
+
integer(reference.bytes, limits.pdfBytes);
|
|
673
|
+
if (reference.relativePath !== `blobs/sha256/${reference.sha256.slice(0, 2)}/${reference.sha256}`)
|
|
674
|
+
throw new StoreError('integrity');
|
|
675
|
+
const path = confinedFile(this.root, join(this.root, reference.relativePath));
|
|
676
|
+
return readVerifiedFile(path, reference.sha256, reference.bytes);
|
|
677
|
+
}
|
|
678
|
+
search(query, count = 20) { text(query, 4096); integer(count, 100); const literal = '"' + query.replaceAll('"', '""') + '"'; return this.db.prepare('SELECT i.id itemId,i.title,i.current_revision revisionId FROM readable_fts f JOIN items i ON i.id=f.item_id WHERE readable_fts MATCH ? AND i.deleted_at IS NULL AND i.current_revision=f.revision_id ORDER BY rank,i.id LIMIT ?').all(literal, count); }
|
|
679
|
+
delete(itemId) {
|
|
680
|
+
text(itemId, 256);
|
|
681
|
+
this.transaction(() => {
|
|
682
|
+
const row = this.db.prepare('UPDATE items SET deleted_at=? WHERE id=? AND deleted_at IS NULL').run(now(), itemId);
|
|
683
|
+
if (row.changes) {
|
|
684
|
+
this.db.prepare('DELETE FROM readable_fts WHERE item_id=?').run(itemId);
|
|
685
|
+
this.db.prepare("UPDATE jobs SET cancel_requested=1,state=CASE WHEN state IN ('queued','delayed','needs-auth','needs-permission','unsupported','failed') THEN 'cancelled' ELSE state END WHERE item_id=? AND state NOT IN ('succeeded','failed','cancelled')").run(itemId);
|
|
686
|
+
this.bumpEpoch();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
/** Online DB snapshot only; P12 pairs this with blobs while the returned fence remains active. */
|
|
691
|
+
async createBackupSnapshot() {
|
|
692
|
+
const id = randomUUID();
|
|
693
|
+
const epoch = this.transaction(() => { const e = this.revisionEpoch; this.db.prepare("INSERT INTO backup_fences VALUES(?,?,'active',?)").run(id, e, now()); return e; });
|
|
694
|
+
const path = join(this.root, 'backups', id + '.sqlite');
|
|
695
|
+
try {
|
|
696
|
+
await backup(this.db, path);
|
|
697
|
+
chmodSync(path, 0o600);
|
|
698
|
+
const fd = openSync(path, constants.O_RDONLY);
|
|
699
|
+
try {
|
|
700
|
+
fsyncSync(fd);
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
closeSync(fd);
|
|
704
|
+
}
|
|
705
|
+
syncDirectory(dirname(path));
|
|
706
|
+
return { id, path, revisionEpoch: epoch };
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
this.db.prepare('DELETE FROM backup_fences WHERE id=?').run(id);
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
finishBackup(id) { text(id, 256); this.db.prepare("UPDATE backup_fences SET state='complete' WHERE id=?").run(id); }
|
|
714
|
+
/** Hold the SQLite writer fence across synchronous GC inspection and removal. */
|
|
715
|
+
withGcFence(expectedEpoch, callback) {
|
|
716
|
+
integer(expectedEpoch);
|
|
717
|
+
return this.transaction(() => {
|
|
718
|
+
if (this.revisionEpoch !== expectedEpoch || this.ownerSnapshot() !== null || this.pendingCleanupCount() > 0 || this.hasGcFence())
|
|
719
|
+
throw new StoreError('owner-busy');
|
|
720
|
+
const references = this.db.prepare('SELECT sha256,bytes,relative_path relativePath FROM assets ORDER BY sha256').all();
|
|
721
|
+
return callback(references);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
/** GC must honor both durable staging pins and active backup fences. */
|
|
725
|
+
pendingCleanupCount() { return Number(this.db.prepare("SELECT (SELECT count(*) FROM attempts WHERE cleanup='pending')+(SELECT count(*) FROM index_attempts WHERE cleanup='pending') AS n").get()?.n); }
|
|
726
|
+
hasGcFence() { return Number(this.db.prepare("SELECT (SELECT count(*) FROM staging_pins)+(SELECT count(*) FROM backup_fences WHERE state='active')+(SELECT count(*) FROM index_attempts WHERE cleanup='pending')+(SELECT count(*) FROM index_jobs WHERE state IN ('running','cleanup-pending')) n").get()?.n) > 0; }
|
|
727
|
+
}
|