@hraness/sponge 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -10
- package/bin/sponge.js +8 -7
- package/dist/contracts/index.js +24 -6
- package/dist/contracts/validate.js +106 -29
- package/dist/migrations/008.d.ts +3 -0
- package/dist/migrations/008.js +5 -0
- package/dist/migrations/009.d.ts +3 -0
- package/dist/migrations/009.js +8 -0
- package/dist/migrations/010.d.ts +3 -0
- package/dist/migrations/010.js +11 -0
- package/dist/migrations/index.d.ts +15 -0
- package/dist/migrations/index.js +39 -0
- package/dist/src/acquisition/capture.js +87 -28
- package/dist/src/acquisition/cleanup.d.ts +0 -4
- package/dist/src/acquisition/cleanup.js +3 -5
- package/dist/src/acquisition/errors.js +17 -2
- package/dist/src/acquisition/files.d.ts +0 -1
- package/dist/src/acquisition/files.js +14 -11
- package/dist/src/acquisition/index.d.ts +2 -3
- package/dist/src/acquisition/index.js +1 -2
- package/dist/src/acquisition/lock.js +1 -1
- package/dist/src/acquisition/normalize.js +57 -18
- package/dist/src/acquisition/pins.js +22 -4
- package/dist/src/acquisition/process.js +67 -29
- package/dist/src/acquisition/runtime.d.ts +5 -7
- package/dist/src/acquisition/runtime.js +129 -68
- package/dist/src/acquisition/verify-helper.js +10 -3
- package/dist/src/annotations/quote.js +40 -12
- package/dist/src/annotations/store.d.ts +0 -1
- package/dist/src/annotations/store.js +66 -14
- package/dist/src/app/article.d.ts +2 -1
- package/dist/src/app/article.js +66 -70
- package/dist/src/app/bundle-files.d.ts +0 -4
- package/dist/src/app/bundle-files.js +11 -14
- package/dist/src/app/markdown.js +18 -6
- package/dist/src/app/source.d.ts +0 -4
- package/dist/src/app/source.js +18 -14
- package/dist/src/cli/arguments.d.ts +2 -8
- package/dist/src/cli/arguments.js +67 -80
- package/dist/src/cli/commands.d.ts +42 -0
- package/dist/src/cli/commands.js +795 -0
- package/dist/src/cli/config.js +12 -9
- package/dist/src/cli/digest.d.ts +1 -1
- package/dist/src/cli/digest.js +39 -17
- package/dist/src/cli/import.js +25 -4
- package/dist/src/cli/indexing.d.ts +2 -0
- package/dist/src/cli/indexing.js +26 -9
- package/dist/src/cli/main.js +47 -386
- package/dist/src/cli/portability.d.ts +0 -2
- package/dist/src/cli/portability.js +13 -8
- package/dist/src/cli/queue.js +12 -8
- package/dist/src/cli/runtimes.d.ts +1 -3
- package/dist/src/cli/runtimes.js +13 -6
- package/dist/src/cli/terminal.js +1 -1
- package/dist/src/cli/worker.d.ts +7 -0
- package/dist/src/cli/worker.js +54 -15
- package/dist/src/derivations/kinds.d.ts +34 -0
- package/dist/src/derivations/kinds.js +14 -0
- package/dist/src/derivations/types.d.ts +48 -0
- package/dist/src/derivations/types.js +1 -0
- package/dist/src/digest/derivation.d.ts +25 -0
- package/dist/src/digest/derivation.js +124 -0
- package/dist/src/digest/editorial.d.ts +10 -46
- package/dist/src/digest/editorial.js +73 -59
- package/dist/src/digest/endpoint.js +63 -27
- package/dist/src/digest/pipeline.d.ts +53 -32
- package/dist/src/digest/pipeline.js +357 -125
- package/dist/src/digest/schema.d.ts +44 -17
- package/dist/src/digest/schema.js +132 -44
- package/dist/src/errors.d.ts +46 -0
- package/dist/src/errors.js +117 -0
- package/dist/src/identity/query-policy.d.ts +0 -5
- package/dist/src/identity/query-policy.js +61 -9
- package/dist/src/identity/uri.d.ts +6 -5
- package/dist/src/identity/uri.js +37 -18
- package/dist/src/identity/visibility.d.ts +11 -1
- package/dist/src/identity/visibility.js +81 -17
- package/dist/src/importers/apply.js +104 -36
- package/dist/src/importers/custody.js +27 -9
- package/dist/src/importers/inspect.js +125 -38
- package/dist/src/importers/jungle-digest.d.ts +31 -24
- package/dist/src/importers/jungle-digest.js +217 -115
- package/dist/src/importers/jungle-store.d.ts +20 -11
- package/dist/src/importers/jungle-store.js +136 -41
- package/dist/src/importers/jungle.d.ts +32 -16
- package/dist/src/importers/jungle.js +145 -32
- package/dist/src/importers/manifest.js +57 -21
- package/dist/src/importers/verify-helper.js +18 -4
- package/dist/src/indexing/image.js +29 -10
- package/dist/src/indexing/index.d.ts +37 -6
- package/dist/src/indexing/index.js +360 -124
- package/dist/src/indexing/jobs.js +156 -47
- package/dist/src/indexing/prune-run.d.ts +2 -0
- package/dist/src/indexing/prune-run.js +69 -26
- package/dist/src/indexing/prune.d.ts +12 -3
- package/dist/src/indexing/prune.js +85 -35
- package/dist/src/indexing/runner.d.ts +2 -1
- package/dist/src/indexing/runner.js +83 -40
- package/dist/src/indexing/upgrade.d.ts +9 -0
- package/dist/src/indexing/upgrade.js +79 -0
- package/dist/src/inference/asr/bridge.js +6 -2
- package/dist/src/inference/asr/protocol.js +109 -19
- package/dist/src/inference/asr/runtime.js +58 -21
- package/dist/src/inference/asr/windows.js +9 -5
- package/dist/src/inference/embedding/index.d.ts +2 -6
- package/dist/src/inference/embedding/index.js +89 -29
- package/dist/src/ingest/attempt.d.ts +29 -0
- package/dist/src/ingest/attempt.js +55 -0
- package/dist/src/media/capture-helper.js +7 -2
- package/dist/src/media/capture.d.ts +2 -1
- package/dist/src/media/capture.js +124 -43
- package/dist/src/media/handler.d.ts +4 -2
- package/dist/src/media/handler.js +88 -70
- package/dist/src/media/transcribe.js +63 -21
- package/dist/src/metadata/article.js +49 -17
- package/dist/src/models/artifacts.d.ts +10 -8
- package/dist/src/models/artifacts.js +237 -106
- package/dist/src/models/catalog.d.ts +2 -5
- package/dist/src/models/catalog.js +94 -25
- package/dist/src/models/download.js +55 -15
- package/dist/src/models/runtime.d.ts +0 -2
- package/dist/src/models/runtime.js +69 -33
- package/dist/src/pdf/capture-helper.js +3 -1
- package/dist/src/pdf/capture.d.ts +2 -1
- package/dist/src/pdf/capture.js +95 -27
- package/dist/src/pdf/errors.js +12 -1
- package/dist/src/pdf/handler.d.ts +2 -1
- package/dist/src/pdf/handler.js +70 -71
- package/dist/src/pdf/index.d.ts +1 -3
- package/dist/src/pdf/index.js +0 -2
- package/dist/src/pdf/input.js +12 -7
- package/dist/src/pdf/normalize.js +80 -21
- package/dist/src/pdf/source-identity.js +3 -2
- package/dist/src/pdf/source.js +12 -8
- package/dist/src/pdf/verify-helper.js +15 -3
- package/dist/src/portability/backup.d.ts +2 -9
- package/dist/src/portability/backup.js +108 -22
- package/dist/src/portability/database.js +51 -17
- package/dist/src/portability/export.js +35 -9
- package/dist/src/portability/files.d.ts +1 -4
- package/dist/src/portability/files.js +127 -67
- package/dist/src/portability/gc.js +65 -22
- package/dist/src/publish/component.js +1 -1
- package/dist/src/publish/feed.d.ts +2 -2
- package/dist/src/publish/feed.js +75 -29
- package/dist/src/publish/identity.d.ts +5 -1
- package/dist/src/publish/identity.js +31 -11
- package/dist/src/publish/index.d.ts +2 -3
- package/dist/src/publish/index.js +34 -21
- package/dist/src/publish/projection.d.ts +2 -9
- package/dist/src/publish/projection.js +21 -14
- package/dist/src/search/extension.js +3 -1
- package/dist/src/search/hybrid.d.ts +3 -8
- package/dist/src/search/hybrid.js +45 -11
- package/dist/src/search/lexical.js +18 -4
- package/dist/src/search/query.js +56 -25
- package/dist/src/search/vector.js +37 -10
- package/dist/src/service/launch-agent.d.ts +5 -3
- package/dist/src/service/launch-agent.js +272 -83
- package/dist/src/service/lifetime.js +47 -14
- package/dist/src/service/loop.d.ts +3 -3
- package/dist/src/service/loop.js +47 -19
- package/dist/src/store/annotations.d.ts +32 -0
- package/dist/src/store/annotations.js +44 -0
- package/dist/src/store/backups.d.ts +18 -0
- package/dist/src/store/backups.js +110 -0
- package/dist/src/store/blobs.d.ts +24 -0
- package/dist/src/store/blobs.js +139 -0
- package/dist/src/store/changes.d.ts +5 -12
- package/dist/src/store/changes.js +108 -56
- package/dist/src/store/context.d.ts +15 -0
- package/dist/src/store/context.js +19 -0
- package/dist/src/store/derivations.d.ts +42 -0
- package/dist/src/store/derivations.js +104 -0
- package/dist/src/store/files.d.ts +0 -2
- package/dist/src/store/files.js +19 -24
- package/dist/src/store/ids.d.ts +30 -0
- package/dist/src/store/ids.js +34 -0
- package/dist/src/store/imports.d.ts +26 -0
- package/dist/src/store/imports.js +57 -0
- package/dist/src/store/index.d.ts +3 -186
- package/dist/src/store/index.js +2 -809
- package/dist/src/store/lease.d.ts +20 -0
- package/dist/src/store/lease.js +62 -0
- package/dist/src/store/library.d.ts +75 -0
- package/dist/src/store/library.js +174 -0
- package/dist/src/store/open.d.ts +7 -0
- package/dist/src/store/open.js +183 -0
- package/dist/src/store/queue.d.ts +65 -0
- package/dist/src/store/queue.js +327 -0
- package/dist/src/store/revisions.d.ts +12 -0
- package/dist/src/store/revisions.js +189 -0
- package/dist/src/store/search-index.d.ts +63 -0
- package/dist/src/store/search-index.js +190 -0
- package/dist/src/store/sync-ready.d.ts +6 -0
- package/dist/src/store/sync-ready.js +121 -0
- package/dist/src/store/types.d.ts +1 -5
- package/dist/src/store/types.js +1 -8
- package/dist/src/util/canonical.d.ts +2 -0
- package/dist/src/util/canonical.js +17 -0
- package/dist/src/util/exact.d.ts +2 -0
- package/dist/src/util/exact.js +9 -0
- package/dist/src/util/fsync.d.ts +4 -0
- package/dist/src/util/fsync.js +22 -0
- package/dist/src/util/hash.d.ts +4 -0
- package/dist/src/util/hash.js +8 -0
- package/dist/src/worker/basic.d.ts +0 -1
- package/dist/src/worker/basic.js +23 -7
- package/dist/src/worker/custody.d.ts +4 -1
- package/dist/src/worker/custody.js +111 -38
- package/dist/src/worker/engine.d.ts +2 -1
- package/dist/src/worker/engine.js +42 -20
- package/dist/src/worker/identity.js +13 -5
- package/dist/src/worker/journal.d.ts +1 -1
- package/dist/src/worker/journal.js +54 -24
- package/dist/src/worker/lock.d.ts +18 -0
- package/dist/src/worker/lock.js +196 -0
- package/dist/src/worker/runtime-gc.d.ts +4 -3
- package/dist/src/worker/runtime-gc.js +68 -41
- package/dist/src/worker/supervisor-child.js +19 -6
- package/dist/src/worker/supervisor.js +41 -8
- package/native/runtime-manifest.json +4 -4
- package/package.json +10 -2
- package/dist/src/digest/store.d.ts +0 -57
- package/dist/src/digest/store.js +0 -77
- package/dist/src/store/foundation.d.ts +0 -7
- package/dist/src/store/foundation.js +0 -7
package/dist/src/service/loop.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { constants, openSync, closeSync, fstatSync, writeSync, renameSync, lstatSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { safeDirectory, regularFile
|
|
3
|
+
import { safeDirectory, regularFile } from '../store/files.js';
|
|
4
|
+
import { syncDirectory } from '../util/fsync.js';
|
|
4
5
|
import { writePrivateExclusive } from '../portability/files.js';
|
|
5
6
|
import { processIdentity } from '../worker/identity.js';
|
|
6
7
|
import { randomUUID } from 'node:crypto';
|
|
7
8
|
import { acquireServiceLifetime } from './lifetime.js';
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
import { SpongeError, codeOf } from '../errors.js';
|
|
10
|
+
/** Fixed event vocabulary plus a stable error code token; source URLs, credentials, content and raw errors never enter logs. */
|
|
11
|
+
export function serviceLog(root, event, code) {
|
|
10
12
|
const directory = safeDirectory(join(root, 'runtime', 'service'), true), path = join(directory, 'worker.log');
|
|
11
13
|
if (existsSync(path)) {
|
|
12
14
|
regularFile(path);
|
|
@@ -26,30 +28,56 @@ export function serviceLog(root, event) {
|
|
|
26
28
|
try {
|
|
27
29
|
const stat = fstatSync(fd);
|
|
28
30
|
if (!stat.isFile() || stat.nlink !== 1)
|
|
29
|
-
throw
|
|
30
|
-
writeSync(fd, JSON.stringify({
|
|
31
|
+
throw new SpongeError('integrity');
|
|
32
|
+
writeSync(fd, JSON.stringify({
|
|
33
|
+
at: new Date().toISOString(),
|
|
34
|
+
event,
|
|
35
|
+
...(code === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { code: typeof code === 'string' && /^[a-z][a-z0-9-]{0,63}$/u.test(code) ? code : 'unclassified' }),
|
|
38
|
+
}) + '\n');
|
|
31
39
|
}
|
|
32
40
|
finally {
|
|
33
41
|
closeSync(fd);
|
|
34
42
|
}
|
|
35
43
|
}
|
|
36
|
-
export function idleWait(ms, signal) {
|
|
37
|
-
resolve
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
export function idleWait(ms, signal) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
if (signal.aborted) {
|
|
47
|
+
resolve();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const done = () => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
signal.removeEventListener('abort', done);
|
|
53
|
+
resolve();
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(done, ms);
|
|
56
|
+
signal.addEventListener('abort', done, { once: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
40
59
|
/** Uses the existing drain engine; it owns locking, cancellation and descendant collection. */
|
|
41
60
|
export async function runWorkerService(store, options) {
|
|
42
61
|
const idleMs = options.idleMs ?? 15000;
|
|
43
|
-
if (!Number.isSafeInteger(idleMs) ||
|
|
44
|
-
|
|
62
|
+
if (!Number.isSafeInteger(idleMs) ||
|
|
63
|
+
idleMs < 1000 ||
|
|
64
|
+
idleMs > 60000 ||
|
|
65
|
+
!/^[a-f0-9-]{36}$/u.test(options.installationId))
|
|
66
|
+
throw new SpongeError('invalid-input');
|
|
45
67
|
const root = safeDirectory(join(store.root, 'runtime', 'service'), true), identity = await (options.identity ?? (() => processIdentity(process.pid)))();
|
|
46
68
|
if (!identity)
|
|
47
|
-
throw
|
|
69
|
+
throw new SpongeError('unavailable');
|
|
48
70
|
const lifetime = await acquireServiceLifetime(root, options.installationId, identity);
|
|
49
71
|
const runId = randomUUID(), runPath = join(root, 'run.json');
|
|
50
|
-
const record = (state) => {
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
const record = (state) => {
|
|
73
|
+
const temp = join(root, `run-${runId}.tmp`);
|
|
74
|
+
writePrivateExclusive(temp, JSON.stringify({ schemaVersion: 1, installationId: options.installationId, runId, identity, state }) + '\n');
|
|
75
|
+
if (existsSync(runPath))
|
|
76
|
+
regularFile(runPath);
|
|
77
|
+
renameSync(temp, runPath);
|
|
78
|
+
syncDirectory(root);
|
|
79
|
+
};
|
|
80
|
+
const log = options.log ?? ((event, code) => serviceLog(store.root, event, code));
|
|
53
81
|
let cycles = 0, cleanup = 'settled';
|
|
54
82
|
try {
|
|
55
83
|
record('running');
|
|
@@ -61,11 +89,11 @@ export async function runWorkerService(store, options) {
|
|
|
61
89
|
log(results.length ? 'drained' : 'idle');
|
|
62
90
|
}
|
|
63
91
|
catch (error) {
|
|
64
|
-
const code = error
|
|
92
|
+
const code = codeOf(error);
|
|
65
93
|
if (code === 'owner-busy')
|
|
66
94
|
log('busy');
|
|
67
95
|
else {
|
|
68
|
-
log('repair-required');
|
|
96
|
+
log('repair-required', code ?? 'unclassified');
|
|
69
97
|
if (code === 'cleanup-required')
|
|
70
98
|
cleanup = 'pending';
|
|
71
99
|
break;
|
|
@@ -76,13 +104,13 @@ export async function runWorkerService(store, options) {
|
|
|
76
104
|
break;
|
|
77
105
|
await (options.wait ?? idleWait)(idleMs, options.signal);
|
|
78
106
|
}
|
|
79
|
-
if (store.
|
|
107
|
+
if (store.queue.pendingCleanup() > 0)
|
|
80
108
|
cleanup = 'pending';
|
|
81
109
|
return { cycles, cleanup };
|
|
82
110
|
}
|
|
83
111
|
finally {
|
|
84
112
|
try {
|
|
85
|
-
if (store.
|
|
113
|
+
if (store.queue.pendingCleanup() > 0)
|
|
86
114
|
cleanup = 'pending';
|
|
87
115
|
record(cleanup === 'settled' ? 'stopped' : 'cleanup-required');
|
|
88
116
|
log('stopped');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as annotations from '../annotations/store.js';
|
|
2
|
+
import { type Context } from './context.js';
|
|
3
|
+
/** Highlights, comments and imported notes on items; every write goes through the change log. */
|
|
4
|
+
export declare class Annotations {
|
|
5
|
+
private readonly c;
|
|
6
|
+
constructor(c: Context);
|
|
7
|
+
addHighlight(input: Parameters<typeof annotations.addHighlight>[1]): annotations.Annotation;
|
|
8
|
+
addComment(input: Parameters<typeof annotations.addComment>[1]): annotations.Annotation;
|
|
9
|
+
update(id: string, body: string): annotations.Annotation;
|
|
10
|
+
delete(id: string): void;
|
|
11
|
+
list(itemId: string): (annotations.Annotation & {
|
|
12
|
+
anchored?: boolean;
|
|
13
|
+
})[];
|
|
14
|
+
/** Export's view: the one annotation reader, without the redundant item id, plus reading state. */
|
|
15
|
+
forExport(itemId: string): {
|
|
16
|
+
annotations: {
|
|
17
|
+
id: string;
|
|
18
|
+
kind: "note" | "highlight" | "comment";
|
|
19
|
+
revisionId: string | null;
|
|
20
|
+
parentId: string | null;
|
|
21
|
+
anchor: unknown;
|
|
22
|
+
body: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
updatedAt: string | null;
|
|
25
|
+
anchored?: boolean;
|
|
26
|
+
}[];
|
|
27
|
+
readingState: {
|
|
28
|
+
state: unknown;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
} | null;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as annotations from '../annotations/store.js';
|
|
2
|
+
import { text } from './files.js';
|
|
3
|
+
import { transaction } from './context.js';
|
|
4
|
+
/** Highlights, comments and imported notes on items; every write goes through the change log. */
|
|
5
|
+
export class Annotations {
|
|
6
|
+
c;
|
|
7
|
+
constructor(c) {
|
|
8
|
+
this.c = c;
|
|
9
|
+
}
|
|
10
|
+
addHighlight(input) {
|
|
11
|
+
text(input.itemId, 256);
|
|
12
|
+
return transaction(this.c.db, () => annotations.addHighlight(this.c.db, input));
|
|
13
|
+
}
|
|
14
|
+
addComment(input) {
|
|
15
|
+
text(input.itemId, 256);
|
|
16
|
+
if (input.parentId !== undefined)
|
|
17
|
+
text(input.parentId, 256);
|
|
18
|
+
return transaction(this.c.db, () => annotations.addComment(this.c.db, input));
|
|
19
|
+
}
|
|
20
|
+
update(id, body) {
|
|
21
|
+
text(id, 256);
|
|
22
|
+
return transaction(this.c.db, () => annotations.updateAnnotation(this.c.db, id, body));
|
|
23
|
+
}
|
|
24
|
+
delete(id) {
|
|
25
|
+
text(id, 256);
|
|
26
|
+
transaction(this.c.db, () => annotations.deleteAnnotation(this.c.db, id));
|
|
27
|
+
}
|
|
28
|
+
list(itemId) {
|
|
29
|
+
text(itemId, 256);
|
|
30
|
+
return annotations.listAnnotations(this.c.db, itemId);
|
|
31
|
+
}
|
|
32
|
+
/** Export's view: the one annotation reader, without the redundant item id, plus reading state. */
|
|
33
|
+
forExport(itemId) {
|
|
34
|
+
text(itemId, 256);
|
|
35
|
+
const list = annotations.listAnnotations(this.c.db, itemId).map(({ itemId: _, ...a }) => a);
|
|
36
|
+
const reading = this.c.db.prepare('SELECT state_json,updated_at FROM reading_state WHERE item_id=?').get(itemId);
|
|
37
|
+
return {
|
|
38
|
+
annotations: list,
|
|
39
|
+
readingState: reading
|
|
40
|
+
? { state: JSON.parse(String(reading.state_json)), updatedAt: String(reading.updated_at) }
|
|
41
|
+
: null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Context } from './context.js';
|
|
2
|
+
/** Online database snapshots, each held open by an active persistent backup fence that keeps GC away. */
|
|
3
|
+
export declare class Backups {
|
|
4
|
+
private readonly c;
|
|
5
|
+
constructor(c: Context);
|
|
6
|
+
/** Online DB snapshot only; P12 pairs this with blobs while the returned fence remains active. */
|
|
7
|
+
snapshot(): Promise<{
|
|
8
|
+
id: string;
|
|
9
|
+
path: string;
|
|
10
|
+
revisionEpoch: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** The fence must still be this backup's active fence: anything else means GC was not held off for the whole backup. */
|
|
13
|
+
finish(id: string): void;
|
|
14
|
+
private removeSnapshot;
|
|
15
|
+
/** Expire active backup fences whose recorded holder is provably dead (exact identity, never age), removing their
|
|
16
|
+
* snapshot files; then remove snapshot files that no active fence protects. Live, unknown and legacy (NULL owner) fences stay. */
|
|
17
|
+
expireFences(): Promise<number>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { backup } from 'node:sqlite';
|
|
2
|
+
import { chmodSync, closeSync, constants, fsyncSync, openSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { text } from './files.js';
|
|
6
|
+
import { syncDirectory } from '../util/fsync.js';
|
|
7
|
+
import { now, revisionEpoch, transaction } from './context.js';
|
|
8
|
+
import { processIdentity, identityAlive } from '../worker/identity.js';
|
|
9
|
+
import { SpongeError } from '../errors.js';
|
|
10
|
+
/** Online database snapshots, each held open by an active persistent backup fence that keeps GC away. */
|
|
11
|
+
export class Backups {
|
|
12
|
+
c;
|
|
13
|
+
constructor(c) {
|
|
14
|
+
this.c = c;
|
|
15
|
+
}
|
|
16
|
+
/** Online DB snapshot only; P12 pairs this with blobs while the returned fence remains active. */
|
|
17
|
+
async snapshot() {
|
|
18
|
+
const { root, db, options } = this.c, id = randomUUID(), identity = await processIdentity(process.pid);
|
|
19
|
+
if (!identity)
|
|
20
|
+
throw new SpongeError('unavailable');
|
|
21
|
+
// The fence names its holder so a crashed backup's fence can be expired on exact death proof.
|
|
22
|
+
const epoch = transaction(db, () => {
|
|
23
|
+
const e = revisionEpoch(db);
|
|
24
|
+
db.prepare("INSERT INTO backup_fences(id,revision_epoch,state,created_at,owner_json) VALUES(?,?,'active',?,?)").run(id, e, now(), JSON.stringify(identity));
|
|
25
|
+
return e;
|
|
26
|
+
});
|
|
27
|
+
const path = join(root, 'backups', id + '.sqlite');
|
|
28
|
+
try {
|
|
29
|
+
await backup(db, path);
|
|
30
|
+
options.fault?.('backup');
|
|
31
|
+
chmodSync(path, 0o600);
|
|
32
|
+
const fd = openSync(path, constants.O_RDONLY);
|
|
33
|
+
try {
|
|
34
|
+
fsyncSync(fd);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
syncDirectory(dirname(path));
|
|
40
|
+
return { id, path, revisionEpoch: epoch };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
// Partial snapshot bytes go first; the fence is dropped only once nothing it protects remains.
|
|
44
|
+
this.removeSnapshot(id);
|
|
45
|
+
transaction(db, () => {
|
|
46
|
+
db.prepare("DELETE FROM backup_fences WHERE id=? AND state='active'").run(id);
|
|
47
|
+
});
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The fence must still be this backup's active fence: anything else means GC was not held off for the whole backup. */
|
|
52
|
+
finish(id) {
|
|
53
|
+
text(id, 256);
|
|
54
|
+
transaction(this.c.db, () => {
|
|
55
|
+
if (this.c.db.prepare("UPDATE backup_fences SET state='complete' WHERE id=? AND state='active'").run(id).changes !==
|
|
56
|
+
1)
|
|
57
|
+
throw new SpongeError('integrity');
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
removeSnapshot(id) {
|
|
61
|
+
const path = join(this.c.root, 'backups', id + '.sqlite');
|
|
62
|
+
for (const suffix of ['', '-wal', '-shm'])
|
|
63
|
+
rmSync(path + suffix, { force: true });
|
|
64
|
+
syncDirectory(dirname(path));
|
|
65
|
+
}
|
|
66
|
+
/** Expire active backup fences whose recorded holder is provably dead (exact identity, never age), removing their
|
|
67
|
+
* snapshot files; then remove snapshot files that no active fence protects. Live, unknown and legacy (NULL owner) fences stay. */
|
|
68
|
+
async expireFences() {
|
|
69
|
+
const db = this.c.db;
|
|
70
|
+
let expired = 0;
|
|
71
|
+
for (const row of db
|
|
72
|
+
.prepare("SELECT id,owner_json FROM backup_fences WHERE state='active' AND owner_json IS NOT NULL ORDER BY id")
|
|
73
|
+
.all()) {
|
|
74
|
+
let identity;
|
|
75
|
+
try {
|
|
76
|
+
identity = JSON.parse(String(row.owner_json));
|
|
77
|
+
if (!Number.isSafeInteger(identity?.pid) ||
|
|
78
|
+
identity.pid < 1 ||
|
|
79
|
+
identity.uid !== process.getuid?.() ||
|
|
80
|
+
typeof identity.started !== 'string' ||
|
|
81
|
+
(await identityAlive(identity)))
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
this.removeSnapshot(String(row.id));
|
|
88
|
+
expired += transaction(db, () => Number(db
|
|
89
|
+
.prepare("DELETE FROM backup_fences WHERE id=? AND state='active' AND owner_json=?")
|
|
90
|
+
.run(String(row.id), String(row.owner_json)).changes));
|
|
91
|
+
}
|
|
92
|
+
// A fence row is committed before its snapshot file exists, so list files first and fences second.
|
|
93
|
+
const dir = join(this.c.root, 'backups'), names = readdirSync(dir);
|
|
94
|
+
const active = new Set(db
|
|
95
|
+
.prepare("SELECT id FROM backup_fences WHERE state='active'")
|
|
96
|
+
.all()
|
|
97
|
+
.map((r) => String(r.id)));
|
|
98
|
+
let removed = false;
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
const match = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.sqlite(?:-wal|-shm)?$/.exec(name);
|
|
101
|
+
if (match && !active.has(match[1])) {
|
|
102
|
+
rmSync(join(dir, name), { force: true });
|
|
103
|
+
removed = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (removed)
|
|
107
|
+
syncDirectory(dir);
|
|
108
|
+
return expired;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Context } from './context.js';
|
|
2
|
+
import type { AttemptToken, BlobReference } from './types.js';
|
|
3
|
+
/** Immutable content-addressed bytes under `blobs/sha256`, and the fence GC holds while it inspects them. */
|
|
4
|
+
export declare class Blobs {
|
|
5
|
+
private readonly c;
|
|
6
|
+
constructor(c: Context);
|
|
7
|
+
/** Stream bytes under a durable staging pin, verify hash and size, then publish create-if-absent. */
|
|
8
|
+
put(token: AttemptToken, expectedHash: string, expectedBytes: number, source: AsyncIterable<Uint8Array>): Promise<BlobReference>;
|
|
9
|
+
/** Bytes checked and returned from the same bounded file descriptor. */
|
|
10
|
+
read(reference: BlobReference): Uint8Array;
|
|
11
|
+
/** Hold the SQLite writer fence across synchronous GC inspection and removal. */
|
|
12
|
+
withGcFence<T>(expectedEpoch: number, callback: (references: readonly BlobReference[]) => T): T;
|
|
13
|
+
/** GC must honor both durable staging pins and active backup fences. */
|
|
14
|
+
hasGcFence(): boolean;
|
|
15
|
+
/** Runtime GC pins: recorded model artifacts and embedding helpers of active or building generations. */
|
|
16
|
+
runtimeReferences(): {
|
|
17
|
+
modelArtifacts: {
|
|
18
|
+
id: string;
|
|
19
|
+
manifest: string;
|
|
20
|
+
}[];
|
|
21
|
+
embeddingHelpers: string[];
|
|
22
|
+
unmappedSpaces: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { closeSync, constants, fsyncSync, linkSync, openSync, statfsSync, unlinkSync, writeSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { limits } from '../../contracts/index.js';
|
|
5
|
+
import { confinedFile, hash, integer, readVerifiedFile, safeDirectory, verifyFile } from './files.js';
|
|
6
|
+
import { syncDirectory } from '../util/fsync.js';
|
|
7
|
+
import { checkAttempt, hasGcFence, ownerSnapshot, pendingCleanup } from './lease.js';
|
|
8
|
+
import { revisionEpoch, transaction } from './context.js';
|
|
9
|
+
import { SpongeError } from '../errors.js';
|
|
10
|
+
/** Immutable content-addressed bytes under `blobs/sha256`, and the fence GC holds while it inspects them. */
|
|
11
|
+
export class Blobs {
|
|
12
|
+
c;
|
|
13
|
+
constructor(c) {
|
|
14
|
+
this.c = c;
|
|
15
|
+
}
|
|
16
|
+
/** Stream bytes under a durable staging pin, verify hash and size, then publish create-if-absent. */
|
|
17
|
+
async put(token, expectedHash, expectedBytes, source) {
|
|
18
|
+
hash(expectedHash);
|
|
19
|
+
integer(expectedBytes, limits.pdfBytes);
|
|
20
|
+
const { root, db, options } = this.c, relativePath = `blobs/sha256/${expectedHash.slice(0, 2)}/${expectedHash}`;
|
|
21
|
+
transaction(db, () => {
|
|
22
|
+
checkAttempt(db, token);
|
|
23
|
+
const pins = db.prepare('SELECT COALESCE(sum(bytes),0) n FROM staging_pins').get();
|
|
24
|
+
if (Number(pins.n) + expectedBytes > limits.stagingBytes)
|
|
25
|
+
throw new SpongeError('limit-exceeded');
|
|
26
|
+
const free = statfsSync(root);
|
|
27
|
+
if (free.bavail * free.bsize < limits.freeReserveBytes + expectedBytes)
|
|
28
|
+
throw new SpongeError('limit-exceeded');
|
|
29
|
+
const existing = db
|
|
30
|
+
.prepare('SELECT bytes FROM staging_pins WHERE attempt_id=? AND sha256=?')
|
|
31
|
+
.get(token.attemptId, expectedHash);
|
|
32
|
+
if (existing && existing.bytes !== expectedBytes)
|
|
33
|
+
throw new SpongeError('integrity');
|
|
34
|
+
db.prepare("INSERT OR IGNORE INTO staging_pins VALUES(?,?,?,'staging')").run(token.attemptId, expectedHash, expectedBytes);
|
|
35
|
+
});
|
|
36
|
+
const stage = safeDirectory(join(root, 'staging', token.attemptId), true), temp = join(stage, randomUUID() + '.tmp');
|
|
37
|
+
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
38
|
+
let count = 0;
|
|
39
|
+
const h = createHash('sha256');
|
|
40
|
+
try {
|
|
41
|
+
for await (const chunk of source) {
|
|
42
|
+
if (!(chunk instanceof Uint8Array))
|
|
43
|
+
throw new SpongeError('invalid-input');
|
|
44
|
+
count += chunk.byteLength;
|
|
45
|
+
if (count > expectedBytes)
|
|
46
|
+
throw new SpongeError('limit-exceeded');
|
|
47
|
+
h.update(chunk);
|
|
48
|
+
let offset = 0;
|
|
49
|
+
while (offset < chunk.byteLength)
|
|
50
|
+
offset += writeSync(fd, chunk, offset, chunk.byteLength - offset);
|
|
51
|
+
}
|
|
52
|
+
if (count !== expectedBytes || h.digest('hex') !== expectedHash)
|
|
53
|
+
throw new SpongeError('integrity');
|
|
54
|
+
fsyncSync(fd);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
unlinkSync(temp);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
syncDirectory(stage);
|
|
63
|
+
options.fault?.('staged');
|
|
64
|
+
const target = join(root, relativePath);
|
|
65
|
+
safeDirectory(dirname(target), true);
|
|
66
|
+
// Atomic create-if-absent publication. A hard-link plus unlink avoids rename overwriting immutable bytes.
|
|
67
|
+
transaction(db, () => {
|
|
68
|
+
checkAttempt(db, token);
|
|
69
|
+
try {
|
|
70
|
+
linkSync(temp, target);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== 'EEXIST')
|
|
74
|
+
throw error;
|
|
75
|
+
verifyFile(target, expectedHash, expectedBytes);
|
|
76
|
+
}
|
|
77
|
+
unlinkSync(temp);
|
|
78
|
+
syncDirectory(dirname(target));
|
|
79
|
+
syncDirectory(stage);
|
|
80
|
+
verifyFile(target, expectedHash, expectedBytes);
|
|
81
|
+
db.prepare("UPDATE staging_pins SET state='published' WHERE attempt_id=? AND sha256=?").run(token.attemptId, expectedHash);
|
|
82
|
+
});
|
|
83
|
+
options.fault?.('published');
|
|
84
|
+
return { sha256: expectedHash, bytes: expectedBytes, relativePath };
|
|
85
|
+
}
|
|
86
|
+
/** Bytes checked and returned from the same bounded file descriptor. */
|
|
87
|
+
read(reference) {
|
|
88
|
+
hash(reference.sha256);
|
|
89
|
+
integer(reference.bytes, limits.pdfBytes);
|
|
90
|
+
if (reference.relativePath !== `blobs/sha256/${reference.sha256.slice(0, 2)}/${reference.sha256}`)
|
|
91
|
+
throw new SpongeError('integrity');
|
|
92
|
+
const path = confinedFile(this.c.root, join(this.c.root, reference.relativePath));
|
|
93
|
+
return readVerifiedFile(path, reference.sha256, reference.bytes);
|
|
94
|
+
}
|
|
95
|
+
/** Hold the SQLite writer fence across synchronous GC inspection and removal. */
|
|
96
|
+
withGcFence(expectedEpoch, callback) {
|
|
97
|
+
integer(expectedEpoch);
|
|
98
|
+
const db = this.c.db;
|
|
99
|
+
return transaction(db, () => {
|
|
100
|
+
if (revisionEpoch(db) !== expectedEpoch || ownerSnapshot(db) !== null || pendingCleanup(db) > 0 || hasGcFence(db))
|
|
101
|
+
throw new SpongeError('owner-busy');
|
|
102
|
+
const references = db
|
|
103
|
+
.prepare('SELECT sha256,bytes,relative_path relativePath FROM assets ORDER BY sha256')
|
|
104
|
+
.all();
|
|
105
|
+
return callback(references);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** GC must honor both durable staging pins and active backup fences. */
|
|
109
|
+
hasGcFence() {
|
|
110
|
+
return hasGcFence(this.c.db);
|
|
111
|
+
}
|
|
112
|
+
/** Runtime GC pins: recorded model artifacts and embedding helpers of active or building generations. */
|
|
113
|
+
runtimeReferences() {
|
|
114
|
+
const db = this.c.db;
|
|
115
|
+
const modelArtifacts = db
|
|
116
|
+
.prepare('SELECT id,manifest_json FROM model_artifacts ORDER BY id')
|
|
117
|
+
.all()
|
|
118
|
+
.map((r) => ({ id: String(r.id), manifest: String(r.manifest_json) }));
|
|
119
|
+
const helpers = new Set();
|
|
120
|
+
let unmapped = 0;
|
|
121
|
+
for (const row of db
|
|
122
|
+
.prepare("SELECT DISTINCT s.identity_json FROM embedding_spaces s JOIN index_generations g ON g.fingerprint=s.fingerprint WHERE g.state='building' OR g.id IN (SELECT generation_id FROM active_indexes)")
|
|
123
|
+
.all()) {
|
|
124
|
+
let identity;
|
|
125
|
+
try {
|
|
126
|
+
identity = JSON.parse(String(row.identity_json));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new SpongeError('integrity');
|
|
130
|
+
}
|
|
131
|
+
const sha = identity?.helperSha256;
|
|
132
|
+
if (typeof sha === 'string' && /^[a-f0-9]{64}$/.test(sha))
|
|
133
|
+
helpers.add(sha);
|
|
134
|
+
else
|
|
135
|
+
unmapped++;
|
|
136
|
+
}
|
|
137
|
+
return { modelArtifacts, embeddingHelpers: [...helpers].sort(), unmappedSpaces: unmapped };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
type Row = Record<string, any>;
|
|
3
2
|
export type Entity = 'item' | 'annotation' | 'reading' | 'derivation';
|
|
4
3
|
/** One field-level write to user state. The sync contract: replaying every change in order rebuilds user state. */
|
|
5
4
|
export interface Change {
|
|
@@ -14,29 +13,23 @@ export interface LoggedChange extends Change {
|
|
|
14
13
|
deviceId: string;
|
|
15
14
|
hlc: string;
|
|
16
15
|
}
|
|
17
|
-
export type ReviewStatus = 'accepted' | 'rejected';
|
|
18
|
-
/** Bound on one change's JSON; imported note bodies (64 KiB) plus anchors fit with room for escaping. */
|
|
19
|
-
export declare const maxChangeBytes = 262144;
|
|
20
|
-
/** Append without applying: for state that already exists (seeding). Callers hold the write transaction. */
|
|
21
|
-
export declare function append(db: DatabaseSync, change: Change): void;
|
|
22
16
|
/** The only write path for user state: tick the clock, apply the row update, append the change. */
|
|
23
17
|
export declare function mutate(db: DatabaseSync, entity: Entity, entityId: string, field: string, value: unknown): void;
|
|
24
18
|
export declare function listChanges(db: DatabaseSync, after?: number, limit?: number): LoggedChange[];
|
|
25
19
|
/** Deterministic source-ref id, so replayed item creation yields the same row on every device. */
|
|
26
20
|
export declare const sourceRefId: (itemId: string) => string;
|
|
27
|
-
/**
|
|
28
|
-
* Review JSON in one canonical shape, whatever order its paths were written in. A digest is reviewed per field;
|
|
29
|
-
* an editorial derivation (a reviewed source note kept whole) has one path, `review.note`.
|
|
30
|
-
*/
|
|
31
|
-
export declare function setReviewPath(review: Row, output: Row, path: string, value: ReviewStatus | null, kind?: string): Row;
|
|
32
21
|
/**
|
|
33
22
|
* Map one change onto its row. Table-driven: every user-state field Sponge writes is listed here,
|
|
34
23
|
* and nothing else writes those columns. Objects (revisions, derivation outputs) must already exist.
|
|
35
24
|
*/
|
|
36
25
|
export declare function applyChange(db: DatabaseSync, change: Change): void;
|
|
26
|
+
/**
|
|
27
|
+
* `items.title` is derived: the user's override, else an importer's owner title, else the captured title. A changed title
|
|
28
|
+
* is re-indexed for lexical search at once; replay on a device without the FTS row leaves indexing to capture.
|
|
29
|
+
*/
|
|
30
|
+
export declare function refreshTitle(db: DatabaseSync, id: string): void;
|
|
37
31
|
/**
|
|
38
32
|
* Seed the log with user state written before schema 5, so another device can rebuild it.
|
|
39
33
|
* Idempotent: a field that already has a change is never seeded again. Rows already exist, so this appends only.
|
|
40
34
|
*/
|
|
41
35
|
export declare function seedChanges(db: DatabaseSync): number;
|
|
42
|
-
export {};
|