@hraness/sponge 0.1.0 → 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 +36 -6
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { constants, openSync, closeSync, fstatSync, fsyncSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { openLibrary } from '../store/index.js';
|
|
4
|
+
import { safeDirectory } from '../store/files.js';
|
|
5
|
+
import { StoreError } from '../store/types.js';
|
|
6
|
+
import { inspectDatabase, neutralizeRestoredDatabase } from './database.js';
|
|
7
|
+
import { bounded, exact, fileDescriptor, invalid, maximumDatabaseBytes, maximumEntries, maximumTotalBytes, openRead, readExact, transfer, writeAll, describe, newDirectory, discardOwned, syncDirectory, writePrivateExclusive } from './files.js';
|
|
8
|
+
const magic = Buffer.from('SPONGE-BACKUP-1\n');
|
|
9
|
+
const maxManifestBytes = 32 * 1024 ** 2;
|
|
10
|
+
function manifest(value) {
|
|
11
|
+
const r = exact(value, ['format', 'version', 'schemaVersion', 'libraryId', 'revisionEpoch', 'files']);
|
|
12
|
+
if (r.format !== 'sponge-backup' || r.version !== 1 || typeof r.libraryId !== 'string' || r.libraryId.length > 256 || !Array.isArray(r.files) || r.files.length < 1 || r.files.length > maximumEntries + 1)
|
|
13
|
+
invalid();
|
|
14
|
+
const files = r.files.map(fileDescriptor);
|
|
15
|
+
if (files[0]?.path !== 'library.sqlite' || files.slice(1).some(f => f.path === 'library.sqlite') || new Set(files.map(f => f.path)).size !== files.length)
|
|
16
|
+
invalid();
|
|
17
|
+
return { format: 'sponge-backup', version: 1, schemaVersion: bounded(r.schemaVersion, Number.MAX_SAFE_INTEGER), libraryId: r.libraryId, revisionEpoch: bounded(r.revisionEpoch, Number.MAX_SAFE_INTEGER), files };
|
|
18
|
+
}
|
|
19
|
+
function sameSnapshot(info, m) {
|
|
20
|
+
if (info.schemaVersion !== m.schemaVersion || info.libraryId !== m.libraryId || info.revisionEpoch !== m.revisionEpoch)
|
|
21
|
+
invalid();
|
|
22
|
+
const expected = info.blobs.map(b => ({ path: b.relativePath, bytes: b.bytes, sha256: b.sha256 }));
|
|
23
|
+
if (JSON.stringify(expected) !== JSON.stringify(m.files.slice(1)))
|
|
24
|
+
invalid();
|
|
25
|
+
}
|
|
26
|
+
export async function backupLibrary(store, output) {
|
|
27
|
+
if (resolve(output) !== output)
|
|
28
|
+
throw new StoreError('invalid-input');
|
|
29
|
+
safeDirectory(dirname(output));
|
|
30
|
+
const snapshot = await store.createBackupSnapshot();
|
|
31
|
+
let fd, created = false;
|
|
32
|
+
try {
|
|
33
|
+
const info = inspectDatabase(snapshot.path);
|
|
34
|
+
const files = [describe(snapshot.path, 'library.sqlite', maximumDatabaseBytes), ...info.blobs.map(b => ({ path: b.relativePath, bytes: b.bytes, sha256: b.sha256 }))];
|
|
35
|
+
const m = { format: 'sponge-backup', version: 1, schemaVersion: info.schemaVersion, libraryId: info.libraryId, revisionEpoch: info.revisionEpoch, files };
|
|
36
|
+
const json = Buffer.from(JSON.stringify(m));
|
|
37
|
+
if (json.length > maxManifestBytes || files.reduce((n, f) => n + f.bytes, json.length + magic.length + 4) > maximumTotalBytes)
|
|
38
|
+
throw new StoreError('limit-exceeded');
|
|
39
|
+
fd = openSync(output, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
40
|
+
created = true;
|
|
41
|
+
writeAll(fd, magic);
|
|
42
|
+
const length = Buffer.alloc(4);
|
|
43
|
+
length.writeUInt32BE(json.length);
|
|
44
|
+
writeAll(fd, length);
|
|
45
|
+
writeAll(fd, json);
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const input = openRead(file.path === 'library.sqlite' ? snapshot.path : join(store.root, file.path));
|
|
48
|
+
try {
|
|
49
|
+
if (fstatSync(input).size !== file.bytes || transfer(input, file.bytes, fd) !== file.sha256)
|
|
50
|
+
invalid();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
closeSync(input);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
fsyncSync(fd);
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
fd = undefined;
|
|
59
|
+
syncDirectory(dirname(output));
|
|
60
|
+
const result = describe(output, 'archive', maximumTotalBytes);
|
|
61
|
+
return { output, sha256: result.sha256, bytes: result.bytes, itemsSnapshotEpoch: info.revisionEpoch };
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (fd !== undefined) {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
fd = undefined;
|
|
67
|
+
}
|
|
68
|
+
if (created)
|
|
69
|
+
unlinkSync(output);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
if (fd !== undefined)
|
|
74
|
+
closeSync(fd);
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(snapshot.path);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
store.finishBackup(snapshot.id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Versioned length-framed archive: no tar links, executable hooks or path extraction. */
|
|
84
|
+
export async function restoreLibrary(input, into) {
|
|
85
|
+
const fd = openRead(input);
|
|
86
|
+
let target;
|
|
87
|
+
try {
|
|
88
|
+
const size = bounded(fstatSync(fd).size, maximumTotalBytes);
|
|
89
|
+
if (size < magic.length + 4 || !readExact(fd, magic.length).equals(magic))
|
|
90
|
+
invalid();
|
|
91
|
+
const length = bounded(readExact(fd, 4).readUInt32BE(), maxManifestBytes);
|
|
92
|
+
if (!length)
|
|
93
|
+
invalid();
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(readExact(fd, length)));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
invalid();
|
|
100
|
+
}
|
|
101
|
+
const m = manifest(parsed);
|
|
102
|
+
if (m.files.reduce((n, f) => n + f.bytes, magic.length + 4 + length) !== size)
|
|
103
|
+
invalid();
|
|
104
|
+
target = newDirectory(into);
|
|
105
|
+
for (const file of m.files) {
|
|
106
|
+
const path = join(target.root, file.path);
|
|
107
|
+
safeDirectory(dirname(path), true);
|
|
108
|
+
const out = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
109
|
+
try {
|
|
110
|
+
if (transfer(fd, file.bytes, out) !== file.sha256)
|
|
111
|
+
invalid();
|
|
112
|
+
fsyncSync(out);
|
|
113
|
+
syncDirectory(dirname(path));
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
closeSync(out);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
sameSnapshot(inspectDatabase(join(into, 'library.sqlite')), m);
|
|
120
|
+
const state = neutralizeRestoredDatabase(join(into, 'library.sqlite'));
|
|
121
|
+
writePrivateExclusive(join(into, '.sponge-library'), 'sponge-library-v1\n');
|
|
122
|
+
// Mark bindings as deliberately absent. This never installs runtime/auth/model files.
|
|
123
|
+
safeDirectory(join(into, 'runtime'), true);
|
|
124
|
+
writePrivateExclusive(join(into, 'runtime', 'restore.json'), JSON.stringify({ schemaVersion: 1, libraryId: m.libraryId, ownerIncarnation: state.incarnation, connections: 'unresolved', heldJobs: state.heldJobs }) + '\n');
|
|
125
|
+
const store = await openLibrary(into);
|
|
126
|
+
store.close();
|
|
127
|
+
syncDirectory(into);
|
|
128
|
+
return { root: into, libraryId: m.libraryId, ...state };
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (target)
|
|
132
|
+
discardOwned(target);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
closeSync(fd);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type BlobReference } from '../store/types.js';
|
|
2
|
+
export interface SnapshotInfo {
|
|
3
|
+
schemaVersion: number;
|
|
4
|
+
libraryId: string;
|
|
5
|
+
revisionEpoch: number;
|
|
6
|
+
blobs: BlobReference[];
|
|
7
|
+
}
|
|
8
|
+
/** Compare executable schema before any restored SQL mutation, not just claimed migration hashes. */
|
|
9
|
+
export declare function inspectDatabase(path: string): SnapshotInfo;
|
|
10
|
+
/** Only a new private restored database may be passed here. Source backup is immutable. */
|
|
11
|
+
export declare function neutralizeRestoredDatabase(path: string): {
|
|
12
|
+
incarnation: string;
|
|
13
|
+
heldJobs: number;
|
|
14
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { libraryMigrations } from '../store/index.js';
|
|
4
|
+
import { digest, regularFile } from '../store/files.js';
|
|
5
|
+
import { StoreError } from '../store/types.js';
|
|
6
|
+
import { blobPath, bounded, digestString, invalid, maximumBlobBytes, maximumEntries } from './files.js';
|
|
7
|
+
function schema(db) { return JSON.stringify(db.prepare("SELECT type,name,tbl_name,sql FROM sqlite_schema ORDER BY type,name").all()); }
|
|
8
|
+
/** Compare executable schema before any restored SQL mutation, not just claimed migration hashes. */
|
|
9
|
+
export function inspectDatabase(path) {
|
|
10
|
+
regularFile(path);
|
|
11
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
12
|
+
const expected = new DatabaseSync(':memory:');
|
|
13
|
+
try {
|
|
14
|
+
db.exec('PRAGMA trusted_schema=OFF');
|
|
15
|
+
const version = bounded(db.prepare('PRAGMA user_version').get()?.user_version, Number.MAX_SAFE_INTEGER);
|
|
16
|
+
if (version > libraryMigrations.length)
|
|
17
|
+
throw new StoreError('schema-newer');
|
|
18
|
+
if (version < 1)
|
|
19
|
+
invalid();
|
|
20
|
+
for (let i = 0; i < version; i++) {
|
|
21
|
+
expected.exec(libraryMigrations[i]);
|
|
22
|
+
if (db.prepare('SELECT checksum FROM schema_migrations WHERE version=?').get(i + 1)?.checksum !== digest(libraryMigrations[i]))
|
|
23
|
+
invalid();
|
|
24
|
+
}
|
|
25
|
+
if (schema(db) !== schema(expected))
|
|
26
|
+
invalid();
|
|
27
|
+
if (db.prepare('SELECT count(*) n FROM schema_migrations').get()?.n !== version)
|
|
28
|
+
invalid();
|
|
29
|
+
if (db.prepare('PRAGMA quick_check').get()?.quick_check !== 'ok' || db.prepare('PRAGMA foreign_key_check').all().length)
|
|
30
|
+
invalid();
|
|
31
|
+
const info = db.prepare('SELECT uuid,schema_version,revision_epoch FROM library_info WHERE id=1').get();
|
|
32
|
+
if (!info || typeof info.uuid !== 'string' || info.uuid.length > 256 || info.schema_version !== version)
|
|
33
|
+
invalid();
|
|
34
|
+
const count = Number(db.prepare('SELECT count(*) n FROM assets').get()?.n);
|
|
35
|
+
if (count > maximumEntries)
|
|
36
|
+
throw new StoreError('limit-exceeded');
|
|
37
|
+
const blobs = db.prepare('SELECT sha256,bytes,relative_path FROM assets ORDER BY sha256').all().map(row => { const sha256 = digestString(row.sha256); if (row.relative_path !== blobPath(sha256))
|
|
38
|
+
invalid(); return { sha256, bytes: bounded(row.bytes, maximumBlobBytes), relativePath: String(row.relative_path) }; });
|
|
39
|
+
for (const row of db.prepare('SELECT markdown,sha256,bytes FROM representations WHERE markdown IS NOT NULL').iterate()) {
|
|
40
|
+
if (typeof row.markdown !== 'string' || digest(row.markdown) !== row.sha256 || Buffer.byteLength(row.markdown) !== row.bytes)
|
|
41
|
+
invalid();
|
|
42
|
+
}
|
|
43
|
+
return { schemaVersion: version, libraryId: info.uuid, revisionEpoch: bounded(info.revision_epoch, Number.MAX_SAFE_INTEGER), blobs };
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
expected.close();
|
|
47
|
+
db.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Only a new private restored database may be passed here. Source backup is immutable. */
|
|
51
|
+
export function neutralizeRestoredDatabase(path) {
|
|
52
|
+
const inspected = inspectDatabase(path);
|
|
53
|
+
const db = new DatabaseSync(path);
|
|
54
|
+
const incarnation = randomUUID();
|
|
55
|
+
try {
|
|
56
|
+
db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; BEGIN IMMEDIATE');
|
|
57
|
+
// Every nonterminal intent is retained but requires an explicit retry after restore.
|
|
58
|
+
const held = db.prepare(`UPDATE jobs SET state='needs-permission',reason='restore-reconciliation-required'${inspected.schemaVersion >= 2 ? ",retryable=0" : ""} WHERE state NOT IN ('succeeded','failed','cancelled','unsupported')`).run();
|
|
59
|
+
db.prepare("UPDATE attempts SET stage='restored-unexecuted',cleanup='settled',finished_at=COALESCE(finished_at,?) WHERE cleanup='pending' OR finished_at IS NULL").run(new Date().toISOString());
|
|
60
|
+
db.exec("DELETE FROM staging_pins; DELETE FROM backup_fences; UPDATE worker_owner SET incarnation=NULL,fence=fence+1; UPDATE model_artifacts SET available=0;");
|
|
61
|
+
if (inspected.schemaVersion >= 4) {
|
|
62
|
+
db.exec("UPDATE index_jobs SET state='failed',reason='restore-reconciliation-required' WHERE state NOT IN ('succeeded','failed','cancelled')");
|
|
63
|
+
db.prepare("UPDATE index_attempts SET stage='restored-unexecuted',cleanup='settled',finished_at=COALESCE(finished_at,?) WHERE cleanup='pending' OR finished_at IS NULL").run(new Date().toISOString());
|
|
64
|
+
}
|
|
65
|
+
db.exec('COMMIT; PRAGMA journal_mode=DELETE');
|
|
66
|
+
return { incarnation, heldJobs: Number(held.changes) };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
try {
|
|
70
|
+
db.exec('ROLLBACK');
|
|
71
|
+
}
|
|
72
|
+
catch { }
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
db.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LibraryStore } from '../store/index.js';
|
|
2
|
+
/** Export a fixed revision with hash-named local assets. No network or acquisition. */
|
|
3
|
+
export declare function exportItem(store: LibraryStore, itemId: string, output: string): {
|
|
4
|
+
output: string;
|
|
5
|
+
itemId: string;
|
|
6
|
+
revisionId: string;
|
|
7
|
+
assets: number;
|
|
8
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { StoreError } from '../store/types.js';
|
|
3
|
+
import { safeDirectory } from '../store/files.js';
|
|
4
|
+
import { newDirectory, discardOwned, copyVerified, syncDirectory, blobPath, writePrivateExclusive } from './files.js';
|
|
5
|
+
/** Export a fixed revision with hash-named local assets. No network or acquisition. */
|
|
6
|
+
export function exportItem(store, itemId, output) {
|
|
7
|
+
const item = store.read(itemId);
|
|
8
|
+
if (!item)
|
|
9
|
+
throw new StoreError('invalid-input');
|
|
10
|
+
const target = newDirectory(output);
|
|
11
|
+
try {
|
|
12
|
+
safeDirectory(join(output, 'assets'), true);
|
|
13
|
+
const known = new Map();
|
|
14
|
+
for (const asset of item.assets) {
|
|
15
|
+
if (asset.relativePath !== blobPath(asset.sha256))
|
|
16
|
+
throw new StoreError('integrity');
|
|
17
|
+
const path = `assets/${asset.sha256}`;
|
|
18
|
+
if (!known.has(asset.sha256)) {
|
|
19
|
+
copyVerified(join(store.root, asset.relativePath), join(output, path), { path, bytes: asset.bytes, sha256: asset.sha256 });
|
|
20
|
+
known.set(asset.sha256, path);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const markdown = item.markdown.replace(/sponge-asset:([a-f0-9]{64})/gu, (_all, sha) => { const path = known.get(sha); if (!path)
|
|
24
|
+
throw new StoreError('integrity'); return path; });
|
|
25
|
+
writePrivateExclusive(join(output, 'article.md'), markdown);
|
|
26
|
+
// Metadata can contain private descriptive content; exports stay private and explicit.
|
|
27
|
+
const notes = store.readAnnotations(itemId);
|
|
28
|
+
writePrivateExclusive(join(output, 'item.json'), JSON.stringify({ schemaVersion: 1, ...notes, itemId: item.itemId, revisionId: item.revisionId, title: item.title, kind: item.kind, status: item.status, metadata: item.metadata, metadataWarnings: item.metadataWarnings, assets: item.assets.map(a => ({ sha256: a.sha256, bytes: a.bytes, mediaType: a.mediaType, relationship: a.relationship, path: known.get(a.sha256) })) }) + '\n');
|
|
29
|
+
syncDirectory(output);
|
|
30
|
+
return { output, itemId, revisionId: item.revisionId, assets: known.size };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
discardOwned(target);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { syncDirectory } from '../store/files.js';
|
|
2
|
+
export declare const maximumTotalBytes: number, maximumDatabaseBytes: number, maximumBlobBytes: number, maximumEntries = 100000;
|
|
3
|
+
export interface FileRecord {
|
|
4
|
+
path: string;
|
|
5
|
+
bytes: number;
|
|
6
|
+
sha256: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function invalid(): never;
|
|
9
|
+
export declare function exact(value: unknown, keys: string[]): Record<string, unknown>;
|
|
10
|
+
export declare function bounded(value: unknown, max: number): number;
|
|
11
|
+
export declare function digestString(value: unknown): string;
|
|
12
|
+
export declare function blobPath(sha: string): string;
|
|
13
|
+
export declare function fileDescriptor(value: unknown): FileRecord;
|
|
14
|
+
export declare function openRead(path: string): number;
|
|
15
|
+
export declare function writeAll(fd: number, bytes: Uint8Array): void;
|
|
16
|
+
export declare function readExact(fd: number, bytes: number): Buffer;
|
|
17
|
+
/** Stream only expected bytes from one descriptor; never allocate a full PDF or archive. */
|
|
18
|
+
export declare function transfer(input: number, bytes: number, output?: number): string;
|
|
19
|
+
export declare function describe(path: string, relativePath: string, maximum: number): FileRecord;
|
|
20
|
+
export declare function copyVerified(source: string, target: string, record: FileRecord): void;
|
|
21
|
+
export declare function newDirectory(path: string): {
|
|
22
|
+
root: string;
|
|
23
|
+
device: number;
|
|
24
|
+
inode: number;
|
|
25
|
+
};
|
|
26
|
+
export declare function discardOwned(value: {
|
|
27
|
+
root: string;
|
|
28
|
+
device: number;
|
|
29
|
+
inode: number;
|
|
30
|
+
}): void;
|
|
31
|
+
export declare function assertEmptyDirectory(path: string): void;
|
|
32
|
+
export { syncDirectory };
|
|
33
|
+
export declare function writePrivateExclusive(path: string, value: string): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, readSync, writeSync, fsyncSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { safeDirectory, regularFile, syncDirectory } from '../store/files.js';
|
|
5
|
+
import { StoreError } from '../store/types.js';
|
|
6
|
+
export const maximumTotalBytes = 16 * 1024 ** 3, maximumDatabaseBytes = 512 * 1024 ** 2, maximumBlobBytes = 512 * 1024 ** 2, maximumEntries = 100_000;
|
|
7
|
+
export function invalid() { throw new StoreError('integrity'); }
|
|
8
|
+
export function exact(value, keys) { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join(',') !== keys.sort().join(','))
|
|
9
|
+
invalid(); return value; }
|
|
10
|
+
export function bounded(value, max) { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > max)
|
|
11
|
+
invalid(); return value; }
|
|
12
|
+
export function digestString(value) { if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value))
|
|
13
|
+
invalid(); return value; }
|
|
14
|
+
export function blobPath(sha) { return `blobs/sha256/${sha.slice(0, 2)}/${sha}`; }
|
|
15
|
+
export function fileDescriptor(value) { const r = exact(value, ['path', 'bytes', 'sha256']); const sha256 = digestString(r.sha256); if (r.path !== 'library.sqlite' && r.path !== blobPath(sha256))
|
|
16
|
+
invalid(); return { path: String(r.path), bytes: bounded(r.bytes, r.path === 'library.sqlite' ? maximumDatabaseBytes : maximumBlobBytes), sha256 }; }
|
|
17
|
+
export function openRead(path) { safeDirectory(dirname(path)); regularFile(path); const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); const stat = fstatSync(fd); if (!stat.isFile() || stat.nlink !== 1) {
|
|
18
|
+
closeSync(fd);
|
|
19
|
+
invalid();
|
|
20
|
+
} return fd; }
|
|
21
|
+
export function writeAll(fd, bytes) { let n = 0; while (n < bytes.length) {
|
|
22
|
+
const written = writeSync(fd, bytes, n, bytes.length - n);
|
|
23
|
+
if (!written)
|
|
24
|
+
invalid();
|
|
25
|
+
n += written;
|
|
26
|
+
} }
|
|
27
|
+
export function readExact(fd, bytes) { const result = Buffer.alloc(bytes); let n = 0; while (n < bytes) {
|
|
28
|
+
const read = readSync(fd, result, n, bytes - n, null);
|
|
29
|
+
if (!read)
|
|
30
|
+
invalid();
|
|
31
|
+
n += read;
|
|
32
|
+
} return result; }
|
|
33
|
+
/** Stream only expected bytes from one descriptor; never allocate a full PDF or archive. */
|
|
34
|
+
export function transfer(input, bytes, output) { const hash = createHash('sha256'), buffer = Buffer.alloc(64 * 1024); let n = 0; while (n < bytes) {
|
|
35
|
+
const read = readSync(input, buffer, 0, Math.min(buffer.length, bytes - n), null);
|
|
36
|
+
if (!read)
|
|
37
|
+
invalid();
|
|
38
|
+
hash.update(buffer.subarray(0, read));
|
|
39
|
+
if (output !== undefined)
|
|
40
|
+
writeAll(output, buffer.subarray(0, read));
|
|
41
|
+
n += read;
|
|
42
|
+
} return hash.digest('hex'); }
|
|
43
|
+
export function describe(path, relativePath, maximum) { const fd = openRead(path); try {
|
|
44
|
+
const bytes = bounded(fstatSync(fd).size, maximum);
|
|
45
|
+
return { path: relativePath, bytes, sha256: transfer(fd, bytes) };
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
} }
|
|
50
|
+
export function copyVerified(source, target, record) { const input = openRead(source); let output; try {
|
|
51
|
+
if (fstatSync(input).size !== record.bytes)
|
|
52
|
+
invalid();
|
|
53
|
+
safeDirectory(dirname(target), true);
|
|
54
|
+
output = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
55
|
+
if (transfer(input, record.bytes, output) !== record.sha256)
|
|
56
|
+
invalid();
|
|
57
|
+
fsyncSync(output);
|
|
58
|
+
syncDirectory(dirname(target));
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
closeSync(input);
|
|
62
|
+
if (output !== undefined)
|
|
63
|
+
closeSync(output);
|
|
64
|
+
} }
|
|
65
|
+
export function newDirectory(path) { if (resolve(path) !== path)
|
|
66
|
+
throw new StoreError('invalid-input'); safeDirectory(dirname(path)); mkdirSync(path, { mode: 0o700 }); const stat = lstatSync(path); return { root: path, device: stat.dev, inode: stat.ino }; }
|
|
67
|
+
export function discardOwned(value) { const stat = lstatSync(value.root); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== value.device || stat.ino !== value.inode)
|
|
68
|
+
invalid(); rmSync(value.root, { recursive: true }); syncDirectory(dirname(value.root)); }
|
|
69
|
+
export function assertEmptyDirectory(path) { safeDirectory(path); if (readdirSync(path).length)
|
|
70
|
+
invalid(); }
|
|
71
|
+
export { syncDirectory };
|
|
72
|
+
export function writePrivateExclusive(path, value) { safeDirectory(dirname(path)); const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); try {
|
|
73
|
+
writeAll(fd, Buffer.from(value));
|
|
74
|
+
fsyncSync(fd);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
closeSync(fd);
|
|
78
|
+
} syncDirectory(dirname(path)); }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LibraryStore } from '../store/index.js';
|
|
2
|
+
import { type FileRecord } from './files.js';
|
|
3
|
+
interface Candidate extends FileRecord {
|
|
4
|
+
device: number;
|
|
5
|
+
inode: number;
|
|
6
|
+
mtimeNs: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GcReport {
|
|
9
|
+
format: 'sponge-gc';
|
|
10
|
+
version: 1;
|
|
11
|
+
libraryId: string;
|
|
12
|
+
revisionEpoch: number;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
candidates: Candidate[];
|
|
15
|
+
}
|
|
16
|
+
export declare function createGcReport(store: LibraryStore): GcReport;
|
|
17
|
+
export declare function applyGcReport(store: LibraryStore, value: unknown): {
|
|
18
|
+
removed: number;
|
|
19
|
+
bytes: number;
|
|
20
|
+
};
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { safeDirectory } from '../store/files.js';
|
|
5
|
+
import { StoreError } from '../store/types.js';
|
|
6
|
+
import { blobPath, describe, exact, fileDescriptor, invalid, bounded, maximumBlobBytes, maximumEntries, syncDirectory } from './files.js';
|
|
7
|
+
function libraryId(store) { const db = new DatabaseSync(join(store.root, 'library.sqlite'), { readOnly: true }); try {
|
|
8
|
+
return String(db.prepare('SELECT uuid FROM library_info WHERE id=1').get()?.uuid);
|
|
9
|
+
}
|
|
10
|
+
finally {
|
|
11
|
+
db.close();
|
|
12
|
+
} }
|
|
13
|
+
function candidate(root, path) { const absolute = join(root, path); const record = describe(absolute, path, maximumBlobBytes); if (path !== blobPath(record.sha256))
|
|
14
|
+
invalid(); const stat = lstatSync(absolute, { bigint: true }); return { ...record, device: Number(stat.dev), inode: Number(stat.ino), mtimeNs: String(stat.mtimeNs) }; }
|
|
15
|
+
function parseReport(value) {
|
|
16
|
+
const r = exact(value, ['format', 'version', 'libraryId', 'revisionEpoch', 'createdAt', 'candidates']);
|
|
17
|
+
if (r.format !== 'sponge-gc' || r.version !== 1 || typeof r.libraryId !== 'string' || r.libraryId.length > 256 || typeof r.createdAt !== 'string' || !Number.isFinite(Date.parse(r.createdAt)) || !Array.isArray(r.candidates) || r.candidates.length > maximumEntries)
|
|
18
|
+
invalid();
|
|
19
|
+
const candidates = r.candidates.map(value => { const row = exact(value, ['path', 'sha256', 'bytes', 'device', 'inode', 'mtimeNs']); const file = fileDescriptor({ path: row.path, sha256: row.sha256, bytes: row.bytes }); if (file.path === 'library.sqlite' || typeof row.mtimeNs !== 'string' || !/^\d{1,30}$/u.test(row.mtimeNs))
|
|
20
|
+
invalid(); return { ...file, device: bounded(row.device, Number.MAX_SAFE_INTEGER), inode: bounded(row.inode, Number.MAX_SAFE_INTEGER), mtimeNs: row.mtimeNs }; });
|
|
21
|
+
if (new Set(candidates.map(c => c.path)).size !== candidates.length)
|
|
22
|
+
invalid();
|
|
23
|
+
return { format: 'sponge-gc', version: 1, libraryId: r.libraryId, revisionEpoch: bounded(r.revisionEpoch, Number.MAX_SAFE_INTEGER), createdAt: r.createdAt, candidates };
|
|
24
|
+
}
|
|
25
|
+
export function createGcReport(store) {
|
|
26
|
+
const epoch = store.revisionEpoch;
|
|
27
|
+
return store.withGcFence(epoch, references => {
|
|
28
|
+
const known = new Set(references.map(r => r.relativePath)), candidates = [];
|
|
29
|
+
const root = join(store.root, 'blobs', 'sha256');
|
|
30
|
+
safeDirectory(root);
|
|
31
|
+
let count = 0;
|
|
32
|
+
for (const prefix of readdirSync(root).sort()) {
|
|
33
|
+
if (!/^[a-f0-9]{2}$/u.test(prefix))
|
|
34
|
+
invalid();
|
|
35
|
+
safeDirectory(join(root, prefix));
|
|
36
|
+
for (const sha of readdirSync(join(root, prefix)).sort()) {
|
|
37
|
+
if (++count > maximumEntries)
|
|
38
|
+
throw new StoreError('limit-exceeded');
|
|
39
|
+
if (!/^[a-f0-9]{64}$/u.test(sha) || !sha.startsWith(prefix))
|
|
40
|
+
invalid();
|
|
41
|
+
const path = blobPath(sha);
|
|
42
|
+
if (!known.has(path))
|
|
43
|
+
candidates.push(candidate(store.root, path));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { format: 'sponge-gc', version: 1, libraryId: libraryId(store), revisionEpoch: epoch, createdAt: new Date().toISOString(), candidates };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function applyGcReport(store, value) {
|
|
50
|
+
const report = parseReport(value);
|
|
51
|
+
if (report.libraryId !== libraryId(store) || Date.now() - Date.parse(report.createdAt) > 86400_000 || Date.parse(report.createdAt) > Date.now() + 1000)
|
|
52
|
+
throw new StoreError('invalid-input');
|
|
53
|
+
return store.withGcFence(report.revisionEpoch, references => {
|
|
54
|
+
const known = new Set(references.map(r => r.relativePath));
|
|
55
|
+
// Validate the entire report before any deletion. Changes invalidate, never broaden it.
|
|
56
|
+
for (const entry of report.candidates) {
|
|
57
|
+
if (known.has(entry.path) || JSON.stringify(candidate(store.root, entry.path)) !== JSON.stringify(entry))
|
|
58
|
+
invalid();
|
|
59
|
+
}
|
|
60
|
+
for (const entry of report.candidates) {
|
|
61
|
+
unlinkSync(join(store.root, entry.path));
|
|
62
|
+
syncDirectory(dirname(join(store.root, entry.path)));
|
|
63
|
+
}
|
|
64
|
+
return { removed: report.candidates.length, bytes: report.candidates.reduce((n, c) => n + c.bytes, 0) };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { load } from 'sqlite-vec';
|
|
2
|
+
/** Database must be opened with allowExtension:true; loading is disabled on every outcome. */
|
|
3
|
+
export function initializeVectorExtension(db) {
|
|
4
|
+
try {
|
|
5
|
+
load(db);
|
|
6
|
+
return db.prepare('SELECT vec_version() AS version').get()?.version === 'v0.1.9';
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
try {
|
|
13
|
+
db.enableLoadExtension(false);
|
|
14
|
+
}
|
|
15
|
+
catch { /* Database created without extension support is already closed to loading. */ }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type SearchGeneration, type VectorHit } from './vector.js';
|
|
3
|
+
export interface SearchHit {
|
|
4
|
+
itemId: string;
|
|
5
|
+
revisionId: string;
|
|
6
|
+
title: string;
|
|
7
|
+
score: number;
|
|
8
|
+
methods: ('lexical' | 'semantic')[];
|
|
9
|
+
anchor: {
|
|
10
|
+
kind: 'document' | 'image';
|
|
11
|
+
representationId?: string;
|
|
12
|
+
chunk?: number;
|
|
13
|
+
assetSha256?: string;
|
|
14
|
+
page?: number;
|
|
15
|
+
startMs?: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface SearchResult {
|
|
19
|
+
hits: SearchHit[];
|
|
20
|
+
warnings: ('semantic-unavailable' | 'semantic-timeout')[];
|
|
21
|
+
generation?: SearchGeneration;
|
|
22
|
+
}
|
|
23
|
+
export type QueryEmbedder = (query: string, pin: SearchGeneration, signal: AbortSignal) => Promise<{
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
vector: readonly number[];
|
|
26
|
+
}>;
|
|
27
|
+
/** Reciprocal rank fusion after per-channel item collapsing; chunks cannot amplify rank. */
|
|
28
|
+
export declare function fuse(lexicalHits: {
|
|
29
|
+
itemId: string;
|
|
30
|
+
revisionId: string;
|
|
31
|
+
title: string;
|
|
32
|
+
}[], semanticHits: VectorHit[], limit: number): SearchHit[];
|
|
33
|
+
export declare function hybridSearch(db: DatabaseSync, query: string, options?: {
|
|
34
|
+
profileId?: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
semanticTimeoutMs?: number;
|
|
37
|
+
embed?: QueryEmbedder;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}): Promise<SearchResult>;
|