@hraness/sponge 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -9
- package/bin/sponge.js +8 -7
- package/dist/contracts/index.js +24 -6
- package/dist/contracts/validate.js +106 -29
- package/dist/migrations/008.d.ts +3 -0
- package/dist/migrations/008.js +5 -0
- package/dist/migrations/009.d.ts +3 -0
- package/dist/migrations/009.js +8 -0
- package/dist/migrations/010.d.ts +3 -0
- package/dist/migrations/010.js +11 -0
- package/dist/migrations/index.d.ts +15 -0
- package/dist/migrations/index.js +39 -0
- package/dist/src/acquisition/capture.js +87 -28
- package/dist/src/acquisition/cleanup.d.ts +0 -4
- package/dist/src/acquisition/cleanup.js +3 -5
- package/dist/src/acquisition/errors.js +17 -2
- package/dist/src/acquisition/files.d.ts +0 -1
- package/dist/src/acquisition/files.js +14 -11
- package/dist/src/acquisition/index.d.ts +2 -3
- package/dist/src/acquisition/index.js +1 -2
- package/dist/src/acquisition/lock.js +1 -1
- package/dist/src/acquisition/normalize.js +57 -18
- package/dist/src/acquisition/pins.js +22 -4
- package/dist/src/acquisition/process.js +67 -29
- package/dist/src/acquisition/runtime.d.ts +5 -7
- package/dist/src/acquisition/runtime.js +129 -68
- package/dist/src/acquisition/verify-helper.js +10 -3
- package/dist/src/annotations/quote.js +40 -12
- package/dist/src/annotations/store.d.ts +0 -1
- package/dist/src/annotations/store.js +66 -14
- package/dist/src/app/article.d.ts +2 -1
- package/dist/src/app/article.js +66 -70
- package/dist/src/app/bundle-files.d.ts +0 -4
- package/dist/src/app/bundle-files.js +11 -14
- package/dist/src/app/markdown.js +18 -6
- package/dist/src/app/source.d.ts +0 -4
- package/dist/src/app/source.js +18 -14
- package/dist/src/cli/arguments.d.ts +2 -8
- package/dist/src/cli/arguments.js +67 -80
- package/dist/src/cli/commands.d.ts +42 -0
- package/dist/src/cli/commands.js +795 -0
- package/dist/src/cli/config.js +12 -9
- package/dist/src/cli/digest.d.ts +1 -1
- package/dist/src/cli/digest.js +38 -16
- package/dist/src/cli/import.js +25 -4
- package/dist/src/cli/indexing.d.ts +2 -0
- package/dist/src/cli/indexing.js +26 -9
- package/dist/src/cli/main.js +47 -386
- package/dist/src/cli/portability.d.ts +0 -2
- package/dist/src/cli/portability.js +13 -8
- package/dist/src/cli/queue.js +12 -8
- package/dist/src/cli/runtimes.d.ts +1 -3
- package/dist/src/cli/runtimes.js +13 -6
- package/dist/src/cli/terminal.js +1 -1
- package/dist/src/cli/worker.d.ts +7 -0
- package/dist/src/cli/worker.js +54 -15
- package/dist/src/derivations/kinds.d.ts +34 -0
- package/dist/src/derivations/kinds.js +14 -0
- package/dist/src/derivations/types.d.ts +48 -0
- package/dist/src/derivations/types.js +1 -0
- package/dist/src/digest/derivation.d.ts +25 -0
- package/dist/src/digest/derivation.js +124 -0
- package/dist/src/digest/editorial.d.ts +9 -46
- package/dist/src/digest/editorial.js +57 -49
- package/dist/src/digest/endpoint.js +63 -27
- package/dist/src/digest/pipeline.d.ts +49 -28
- package/dist/src/digest/pipeline.js +343 -100
- package/dist/src/digest/schema.d.ts +11 -25
- package/dist/src/digest/schema.js +86 -18
- package/dist/src/errors.d.ts +46 -0
- package/dist/src/errors.js +117 -0
- package/dist/src/identity/query-policy.d.ts +0 -5
- package/dist/src/identity/query-policy.js +61 -9
- package/dist/src/identity/uri.d.ts +6 -5
- package/dist/src/identity/uri.js +37 -18
- package/dist/src/identity/visibility.d.ts +11 -1
- package/dist/src/identity/visibility.js +81 -17
- package/dist/src/importers/apply.js +104 -36
- package/dist/src/importers/custody.js +27 -9
- package/dist/src/importers/inspect.js +125 -38
- package/dist/src/importers/jungle-digest.d.ts +3 -6
- package/dist/src/importers/jungle-digest.js +196 -69
- package/dist/src/importers/jungle-store.d.ts +15 -6
- package/dist/src/importers/jungle-store.js +122 -28
- package/dist/src/importers/jungle.d.ts +14 -14
- package/dist/src/importers/jungle.js +136 -31
- package/dist/src/importers/manifest.js +57 -21
- package/dist/src/importers/verify-helper.js +18 -4
- package/dist/src/indexing/image.js +29 -10
- package/dist/src/indexing/index.d.ts +37 -6
- package/dist/src/indexing/index.js +360 -124
- package/dist/src/indexing/jobs.js +156 -47
- package/dist/src/indexing/prune-run.d.ts +2 -0
- package/dist/src/indexing/prune-run.js +69 -26
- package/dist/src/indexing/prune.d.ts +12 -3
- package/dist/src/indexing/prune.js +85 -35
- package/dist/src/indexing/runner.d.ts +2 -1
- package/dist/src/indexing/runner.js +83 -40
- package/dist/src/indexing/upgrade.d.ts +9 -0
- package/dist/src/indexing/upgrade.js +79 -0
- package/dist/src/inference/asr/bridge.js +6 -2
- package/dist/src/inference/asr/protocol.js +109 -19
- package/dist/src/inference/asr/runtime.js +58 -21
- package/dist/src/inference/asr/windows.js +9 -5
- package/dist/src/inference/embedding/index.d.ts +2 -6
- package/dist/src/inference/embedding/index.js +89 -29
- package/dist/src/ingest/attempt.d.ts +29 -0
- package/dist/src/ingest/attempt.js +55 -0
- package/dist/src/media/capture-helper.js +7 -2
- package/dist/src/media/capture.d.ts +2 -1
- package/dist/src/media/capture.js +124 -43
- package/dist/src/media/handler.d.ts +4 -2
- package/dist/src/media/handler.js +88 -70
- package/dist/src/media/transcribe.js +63 -21
- package/dist/src/metadata/article.js +49 -17
- package/dist/src/models/artifacts.d.ts +10 -8
- package/dist/src/models/artifacts.js +237 -106
- package/dist/src/models/catalog.d.ts +2 -5
- package/dist/src/models/catalog.js +94 -25
- package/dist/src/models/download.js +55 -15
- package/dist/src/models/runtime.d.ts +0 -2
- package/dist/src/models/runtime.js +69 -33
- package/dist/src/pdf/capture-helper.js +3 -1
- package/dist/src/pdf/capture.d.ts +2 -1
- package/dist/src/pdf/capture.js +95 -27
- package/dist/src/pdf/errors.js +12 -1
- package/dist/src/pdf/handler.d.ts +2 -1
- package/dist/src/pdf/handler.js +70 -71
- package/dist/src/pdf/index.d.ts +1 -3
- package/dist/src/pdf/index.js +0 -2
- package/dist/src/pdf/input.js +12 -7
- package/dist/src/pdf/normalize.js +80 -21
- package/dist/src/pdf/source-identity.js +3 -2
- package/dist/src/pdf/source.js +12 -8
- package/dist/src/pdf/verify-helper.js +15 -3
- package/dist/src/portability/backup.d.ts +2 -9
- package/dist/src/portability/backup.js +106 -22
- package/dist/src/portability/database.js +51 -17
- package/dist/src/portability/export.js +35 -9
- package/dist/src/portability/files.d.ts +1 -4
- package/dist/src/portability/files.js +127 -67
- package/dist/src/portability/gc.js +65 -22
- package/dist/src/publish/component.js +1 -1
- package/dist/src/publish/feed.d.ts +2 -2
- package/dist/src/publish/feed.js +75 -29
- package/dist/src/publish/identity.d.ts +5 -1
- package/dist/src/publish/identity.js +31 -11
- package/dist/src/publish/index.d.ts +2 -3
- package/dist/src/publish/index.js +34 -21
- package/dist/src/publish/projection.d.ts +2 -9
- package/dist/src/publish/projection.js +21 -14
- package/dist/src/search/extension.js +3 -1
- package/dist/src/search/hybrid.d.ts +3 -8
- package/dist/src/search/hybrid.js +45 -11
- package/dist/src/search/lexical.js +18 -4
- package/dist/src/search/query.js +56 -25
- package/dist/src/search/vector.js +37 -10
- package/dist/src/service/launch-agent.d.ts +5 -3
- package/dist/src/service/launch-agent.js +272 -83
- package/dist/src/service/lifetime.js +47 -14
- package/dist/src/service/loop.d.ts +3 -3
- package/dist/src/service/loop.js +47 -19
- package/dist/src/store/annotations.d.ts +32 -0
- package/dist/src/store/annotations.js +44 -0
- package/dist/src/store/backups.d.ts +18 -0
- package/dist/src/store/backups.js +110 -0
- package/dist/src/store/blobs.d.ts +24 -0
- package/dist/src/store/blobs.js +139 -0
- package/dist/src/store/changes.d.ts +5 -12
- package/dist/src/store/changes.js +108 -56
- package/dist/src/store/context.d.ts +15 -0
- package/dist/src/store/context.js +19 -0
- package/dist/src/store/derivations.d.ts +42 -0
- package/dist/src/store/derivations.js +104 -0
- package/dist/src/store/files.d.ts +0 -2
- package/dist/src/store/files.js +19 -24
- package/dist/src/store/ids.d.ts +30 -0
- package/dist/src/store/ids.js +34 -0
- package/dist/src/store/imports.d.ts +26 -0
- package/dist/src/store/imports.js +57 -0
- package/dist/src/store/index.d.ts +3 -186
- package/dist/src/store/index.js +2 -809
- package/dist/src/store/lease.d.ts +20 -0
- package/dist/src/store/lease.js +62 -0
- package/dist/src/store/library.d.ts +75 -0
- package/dist/src/store/library.js +174 -0
- package/dist/src/store/open.d.ts +7 -0
- package/dist/src/store/open.js +183 -0
- package/dist/src/store/queue.d.ts +65 -0
- package/dist/src/store/queue.js +327 -0
- package/dist/src/store/revisions.d.ts +12 -0
- package/dist/src/store/revisions.js +189 -0
- package/dist/src/store/search-index.d.ts +63 -0
- package/dist/src/store/search-index.js +190 -0
- package/dist/src/store/sync-ready.d.ts +6 -0
- package/dist/src/store/sync-ready.js +121 -0
- package/dist/src/store/types.d.ts +1 -5
- package/dist/src/store/types.js +1 -8
- package/dist/src/util/canonical.d.ts +2 -0
- package/dist/src/util/canonical.js +17 -0
- package/dist/src/util/exact.d.ts +2 -0
- package/dist/src/util/exact.js +9 -0
- package/dist/src/util/fsync.d.ts +4 -0
- package/dist/src/util/fsync.js +22 -0
- package/dist/src/util/hash.d.ts +4 -0
- package/dist/src/util/hash.js +8 -0
- package/dist/src/worker/basic.d.ts +0 -1
- package/dist/src/worker/basic.js +23 -7
- package/dist/src/worker/custody.d.ts +4 -1
- package/dist/src/worker/custody.js +111 -38
- package/dist/src/worker/engine.d.ts +2 -1
- package/dist/src/worker/engine.js +42 -20
- package/dist/src/worker/identity.js +13 -5
- package/dist/src/worker/journal.d.ts +1 -1
- package/dist/src/worker/journal.js +54 -24
- package/dist/src/worker/lock.d.ts +18 -0
- package/dist/src/worker/lock.js +196 -0
- package/dist/src/worker/runtime-gc.d.ts +4 -3
- package/dist/src/worker/runtime-gc.js +68 -41
- package/dist/src/worker/supervisor-child.js +19 -6
- package/dist/src/worker/supervisor.js +41 -8
- package/native/runtime-manifest.json +4 -4
- package/package.json +10 -2
- package/dist/src/digest/store.d.ts +0 -57
- package/dist/src/digest/store.js +0 -79
- package/dist/src/store/foundation.d.ts +0 -7
- package/dist/src/store/foundation.js +0 -7
|
@@ -2,30 +2,64 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
2
2
|
import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { safeDirectory } from '../store/files.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { blobPath, describe, exact, fileDescriptor, invalid, bounded, maximumBlobBytes, maximumEntries, } from './files.js';
|
|
6
|
+
import { syncDirectory } from '../util/fsync.js';
|
|
7
|
+
import { SpongeError } from '../errors.js';
|
|
8
|
+
function libraryId(store) {
|
|
9
|
+
const db = new DatabaseSync(join(store.root, 'library.sqlite'), { readOnly: true });
|
|
10
|
+
try {
|
|
11
|
+
return String(db.prepare('SELECT uuid FROM library_info WHERE id=1').get()?.uuid);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
db.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function candidate(root, path) {
|
|
18
|
+
const absolute = join(root, path);
|
|
19
|
+
const record = describe(absolute, path, maximumBlobBytes);
|
|
20
|
+
if (path !== blobPath(record.sha256))
|
|
21
|
+
invalid();
|
|
22
|
+
const stat = lstatSync(absolute, { bigint: true });
|
|
23
|
+
return { ...record, device: Number(stat.dev), inode: Number(stat.ino), mtimeNs: String(stat.mtimeNs) };
|
|
9
24
|
}
|
|
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
25
|
function parseReport(value) {
|
|
16
26
|
const r = exact(value, ['format', 'version', 'libraryId', 'revisionEpoch', 'createdAt', 'candidates']);
|
|
17
|
-
if (r.format !== 'sponge-gc' ||
|
|
27
|
+
if (r.format !== 'sponge-gc' ||
|
|
28
|
+
r.version !== 1 ||
|
|
29
|
+
typeof r.libraryId !== 'string' ||
|
|
30
|
+
r.libraryId.length > 256 ||
|
|
31
|
+
typeof r.createdAt !== 'string' ||
|
|
32
|
+
!Number.isFinite(Date.parse(r.createdAt)) ||
|
|
33
|
+
!Array.isArray(r.candidates) ||
|
|
34
|
+
r.candidates.length > maximumEntries)
|
|
18
35
|
invalid();
|
|
19
|
-
const candidates = r.candidates.map(
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
const candidates = r.candidates.map((value) => {
|
|
37
|
+
const row = exact(value, ['path', 'sha256', 'bytes', 'device', 'inode', 'mtimeNs']);
|
|
38
|
+
const file = fileDescriptor({ path: row.path, sha256: row.sha256, bytes: row.bytes });
|
|
39
|
+
if (file.path === 'library.sqlite' || typeof row.mtimeNs !== 'string' || !/^\d{1,30}$/u.test(row.mtimeNs))
|
|
40
|
+
invalid();
|
|
41
|
+
return {
|
|
42
|
+
...file,
|
|
43
|
+
device: bounded(row.device, Number.MAX_SAFE_INTEGER),
|
|
44
|
+
inode: bounded(row.inode, Number.MAX_SAFE_INTEGER),
|
|
45
|
+
mtimeNs: row.mtimeNs,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
if (new Set(candidates.map((c) => c.path)).size !== candidates.length)
|
|
22
49
|
invalid();
|
|
23
|
-
return {
|
|
50
|
+
return {
|
|
51
|
+
format: 'sponge-gc',
|
|
52
|
+
version: 1,
|
|
53
|
+
libraryId: r.libraryId,
|
|
54
|
+
revisionEpoch: bounded(r.revisionEpoch, Number.MAX_SAFE_INTEGER),
|
|
55
|
+
createdAt: r.createdAt,
|
|
56
|
+
candidates,
|
|
57
|
+
};
|
|
24
58
|
}
|
|
25
59
|
export function createGcReport(store) {
|
|
26
60
|
const epoch = store.revisionEpoch;
|
|
27
|
-
return store.withGcFence(epoch, references => {
|
|
28
|
-
const known = new Set(references.map(r => r.relativePath)), candidates = [];
|
|
61
|
+
return store.blobs.withGcFence(epoch, (references) => {
|
|
62
|
+
const known = new Set(references.map((r) => r.relativePath)), candidates = [];
|
|
29
63
|
const root = join(store.root, 'blobs', 'sha256');
|
|
30
64
|
safeDirectory(root);
|
|
31
65
|
let count = 0;
|
|
@@ -35,7 +69,7 @@ export function createGcReport(store) {
|
|
|
35
69
|
safeDirectory(join(root, prefix));
|
|
36
70
|
for (const sha of readdirSync(join(root, prefix)).sort()) {
|
|
37
71
|
if (++count > maximumEntries)
|
|
38
|
-
throw new
|
|
72
|
+
throw new SpongeError('limit-exceeded');
|
|
39
73
|
if (!/^[a-f0-9]{64}$/u.test(sha) || !sha.startsWith(prefix))
|
|
40
74
|
invalid();
|
|
41
75
|
const path = blobPath(sha);
|
|
@@ -43,15 +77,24 @@ export function createGcReport(store) {
|
|
|
43
77
|
candidates.push(candidate(store.root, path));
|
|
44
78
|
}
|
|
45
79
|
}
|
|
46
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
format: 'sponge-gc',
|
|
82
|
+
version: 1,
|
|
83
|
+
libraryId: libraryId(store),
|
|
84
|
+
revisionEpoch: epoch,
|
|
85
|
+
createdAt: new Date().toISOString(),
|
|
86
|
+
candidates,
|
|
87
|
+
};
|
|
47
88
|
});
|
|
48
89
|
}
|
|
49
90
|
export function applyGcReport(store, value) {
|
|
50
91
|
const report = parseReport(value);
|
|
51
|
-
if (report.libraryId !== libraryId(store) ||
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
92
|
+
if (report.libraryId !== libraryId(store) ||
|
|
93
|
+
Date.now() - Date.parse(report.createdAt) > 86400_000 ||
|
|
94
|
+
Date.parse(report.createdAt) > Date.now() + 1000)
|
|
95
|
+
throw new SpongeError('invalid-input');
|
|
96
|
+
return store.blobs.withGcFence(report.revisionEpoch, (references) => {
|
|
97
|
+
const known = new Set(references.map((r) => r.relativePath));
|
|
55
98
|
// Validate the entire report before any deletion. Changes invalidate, never broaden it.
|
|
56
99
|
for (const entry of report.candidates) {
|
|
57
100
|
if (known.has(entry.path) || JSON.stringify(candidate(store.root, entry.path)) !== JSON.stringify(entry))
|
|
@@ -84,7 +84,7 @@ export const componentSource = String.raw `/* sponge-list: renders a Sponge read
|
|
|
84
84
|
if (typeof customElements !== 'undefined' && !customElements.get('sponge-list')) customElements.define('sponge-list', SpongeList);
|
|
85
85
|
})();
|
|
86
86
|
`;
|
|
87
|
-
const html = (s) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
87
|
+
const html = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
88
88
|
/** A complete static page around the component; it carries the feed title only. */
|
|
89
89
|
export function indexHtml(title) {
|
|
90
90
|
return `<!doctype html>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PublicEntry } from '
|
|
1
|
+
import type { PublicEntry } from './projection.js';
|
|
2
2
|
/** feed.json and feed.xml carry the newest entries; every entry also lives in its month's shard. */
|
|
3
3
|
export declare const feedLimit = 100;
|
|
4
4
|
export declare const manifestPath = "index.json", signaturePath = "index.json.sig";
|
|
@@ -12,7 +12,7 @@ export interface SiteOptions {
|
|
|
12
12
|
baseUrl?: string;
|
|
13
13
|
}
|
|
14
14
|
/** Sorted keys, no whitespace, undefined fields dropped: equal values always give equal bytes. */
|
|
15
|
-
export declare
|
|
15
|
+
export declare const canonical: (value: unknown) => string;
|
|
16
16
|
/** An https origin and path, with no credentials, query or fragment, ending in `/`. */
|
|
17
17
|
export declare function baseUrl(value: string): string;
|
|
18
18
|
/** XML 1.0 text and attribute escaping; characters XML cannot carry become U+FFFD. */
|
package/dist/src/publish/feed.js
CHANGED
|
@@ -1,28 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { sha256 } from '../util/hash.js';
|
|
2
|
+
import { canonicalJson } from '../util/canonical.js';
|
|
3
3
|
import { componentSource, indexHtml } from './component.js';
|
|
4
|
+
import { SpongeError } from '../errors.js';
|
|
4
5
|
/** feed.json and feed.xml carry the newest entries; every entry also lives in its month's shard. */
|
|
5
6
|
export const feedLimit = 100;
|
|
6
7
|
export const manifestPath = 'index.json', signaturePath = 'index.json.sig';
|
|
7
8
|
const about = 'https://github.com/hraness/sponge/blob/main/docs/decisions/publish-feed.md';
|
|
8
|
-
const fail = (code = 'integrity') => {
|
|
9
|
+
const fail = (code = 'integrity') => {
|
|
10
|
+
throw new SpongeError(code);
|
|
11
|
+
};
|
|
9
12
|
/** Sorted keys, no whitespace, undefined fields dropped: equal values always give equal bytes. */
|
|
10
|
-
export
|
|
11
|
-
if (Array.isArray(value))
|
|
12
|
-
return '[' + value.map(canonical).join(',') + ']';
|
|
13
|
-
if (value && typeof value === 'object')
|
|
14
|
-
return '{' + Object.keys(value).sort().filter(k => value[k] !== undefined).map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
|
|
15
|
-
if (typeof value === 'number' && !Number.isSafeInteger(value) || !['string', 'number', 'boolean'].includes(typeof value) && value !== null)
|
|
16
|
-
fail();
|
|
17
|
-
return JSON.stringify(value);
|
|
18
|
-
}
|
|
13
|
+
export const canonical = (value) => canonicalJson(value, fail);
|
|
19
14
|
/** An https origin and path, with no credentials, query or fragment, ending in `/`. */
|
|
20
15
|
export function baseUrl(value) {
|
|
21
16
|
let url;
|
|
22
17
|
try {
|
|
23
18
|
url = new URL(value);
|
|
24
19
|
}
|
|
25
|
-
catch {
|
|
20
|
+
catch {
|
|
21
|
+
/* rejected below */
|
|
22
|
+
}
|
|
26
23
|
if (!url || url.protocol !== 'https:' || url.username || url.password || /[?#]/.test(value) || value.length > 2048)
|
|
27
24
|
fail('invalid-input');
|
|
28
25
|
return url.href.endsWith('/') ? url.href : url.href + '/';
|
|
@@ -31,17 +28,42 @@ const web = (uri) => /^https?:\/\//.test(uri);
|
|
|
31
28
|
const xmlInvalid = /[^\t\n\r --�\u{10000}-\u{10ffff}]/gu;
|
|
32
29
|
const xmlEntities = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
33
30
|
/** XML 1.0 text and attribute escaping; characters XML cannot carry become U+FFFD. */
|
|
34
|
-
export function xml(value) {
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
export function xml(value) {
|
|
32
|
+
return value.replace(xmlInvalid, '�').replace(/[&<>"']/g, (c) => xmlEntities[c]);
|
|
33
|
+
}
|
|
34
|
+
const date = (e) => (/^\d{4}-\d{2}-\d{2}$/.test(e.listedOn) ? e.listedOn + 'T00:00:00Z' : fail());
|
|
35
|
+
const summaryText = (e) => [e.digest?.lead, ...(e.digest?.ideas ?? []).map((i) => '- ' + i.claim)].filter(Boolean).join('\n') || e.title;
|
|
37
36
|
function item(e) {
|
|
38
|
-
return {
|
|
39
|
-
|
|
37
|
+
return {
|
|
38
|
+
id: e.uri,
|
|
39
|
+
url: web(e.uri) ? e.uri : undefined,
|
|
40
|
+
title: e.title,
|
|
41
|
+
content_text: summaryText(e),
|
|
42
|
+
summary: e.digest?.lead,
|
|
43
|
+
date_published: date(e),
|
|
44
|
+
authors: e.authors ? [{ name: e.authors }] : undefined,
|
|
45
|
+
_sponge: { uri: e.uri, aliases: e.aliases, kind: e.kind, site: e.site, published: e.published, digest: e.digest },
|
|
46
|
+
};
|
|
40
47
|
}
|
|
41
48
|
function atom(entries, identity, options) {
|
|
42
|
-
const base = options.baseUrl, updated = entries.length
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
const base = options.baseUrl, updated = entries.length
|
|
50
|
+
? date(entries.reduce((a, b) => (b.listedOn > a.listedOn ? b : a)))
|
|
51
|
+
: identity.created + 'T00:00:00Z';
|
|
52
|
+
const lines = [
|
|
53
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
54
|
+
'<feed xmlns="http://www.w3.org/2005/Atom">',
|
|
55
|
+
`<id>tag:sponge.computer,2026:feed/${xml(identity.publicKey)}</id>`,
|
|
56
|
+
`<title>${xml(options.title)}</title>`,
|
|
57
|
+
`<updated>${updated}</updated>`,
|
|
58
|
+
`<author><name>${xml(options.title)}</name></author>`,
|
|
59
|
+
'<generator>Sponge</generator>',
|
|
60
|
+
...(base
|
|
61
|
+
? [
|
|
62
|
+
`<link rel="self" type="application/atom+xml" href="${xml(base + 'feed.xml')}"/>`,
|
|
63
|
+
`<link rel="alternate" type="text/html" href="${xml(base)}"/>`,
|
|
64
|
+
]
|
|
65
|
+
: []),
|
|
66
|
+
];
|
|
45
67
|
for (const e of entries)
|
|
46
68
|
lines.push('<entry>', `<id>${xml(e.uri)}</id>`, `<title>${xml(e.title)}</title>`, ...(web(e.uri) ? [`<link rel="alternate" href="${xml(e.uri)}"/>`] : []), `<updated>${date(e)}</updated>`, ...(e.authors ? [`<author><name>${xml(e.authors)}</name></author>`] : []), ...(e.digest?.lead ? [`<summary>${xml(e.digest.lead)}</summary>`] : []), `<content type="text">${xml(summaryText(e))}</content>`, '</entry>');
|
|
47
69
|
return lines.concat('</feed>', '').join('\n');
|
|
@@ -51,27 +73,51 @@ function atom(entries, identity, options) {
|
|
|
51
73
|
* no clock, no randomness, sorted keys. The manifest lists each file's sha256 and is signed once.
|
|
52
74
|
*/
|
|
53
75
|
export function buildSite(entries, identity, options, sign) {
|
|
54
|
-
if (identity.algorithm !== 'Ed25519' ||
|
|
76
|
+
if (identity.algorithm !== 'Ed25519' ||
|
|
77
|
+
!/^[A-Za-z0-9_-]{43}$/.test(identity.publicKey) ||
|
|
78
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(identity.created))
|
|
55
79
|
fail();
|
|
56
|
-
const base = options.baseUrl, link = (from, to) => base
|
|
80
|
+
const base = options.baseUrl, link = (from, to) => base
|
|
81
|
+
? base + to
|
|
82
|
+
: from.includes('/') && to.startsWith(from.slice(0, from.lastIndexOf('/') + 1))
|
|
83
|
+
? to.slice(from.lastIndexOf('/') + 1)
|
|
84
|
+
: to;
|
|
57
85
|
const months = new Map();
|
|
58
86
|
for (const e of entries) {
|
|
59
87
|
const month = date(e).slice(0, 7);
|
|
60
|
-
months.set(month, [...months.get(month) ?? [], e]);
|
|
88
|
+
months.set(month, [...(months.get(month) ?? []), e]);
|
|
61
89
|
}
|
|
62
90
|
const order = [...months.keys()].sort().reverse(), shard = (month) => `items/${month}.json`;
|
|
63
91
|
const signed = { about, schemaVersion: 1, algorithm: identity.algorithm, publicKey: identity.publicKey };
|
|
64
|
-
const feed = (path, list, next, extra) => Buffer.from(canonical({
|
|
92
|
+
const feed = (path, list, next, extra) => Buffer.from(canonical({
|
|
93
|
+
version: 'https://jsonfeed.org/version/1.1',
|
|
94
|
+
title: options.title,
|
|
95
|
+
home_page_url: base,
|
|
96
|
+
feed_url: base && base + path,
|
|
97
|
+
next_url: next && link(path, next),
|
|
98
|
+
items: list.map(item),
|
|
99
|
+
_sponge: { ...signed, manifest: link(path, manifestPath), ...extra },
|
|
100
|
+
}) + '\n');
|
|
65
101
|
const files = new Map(), head = entries.slice(0, feedLimit), omitted = entries[feedLimit];
|
|
66
|
-
files.set('feed.json', feed('feed.json', head, omitted && shard(date(omitted).slice(0, 7)), {
|
|
102
|
+
files.set('feed.json', feed('feed.json', head, omitted && shard(date(omitted).slice(0, 7)), {
|
|
103
|
+
total: entries.length,
|
|
104
|
+
shards: order.map((m) => ({ month: m, url: link('feed.json', shard(m)), items: months.get(m).length })),
|
|
105
|
+
}));
|
|
67
106
|
order.forEach((m, i) => files.set(shard(m), feed(shard(m), months.get(m), order[i + 1] && shard(order[i + 1]), { month: m })));
|
|
68
107
|
files.set('feed.xml', Buffer.from(atom(head, identity, options)));
|
|
69
|
-
files.set('identity.json', Buffer.from(canonical({
|
|
108
|
+
files.set('identity.json', Buffer.from(canonical({
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
algorithm: identity.algorithm,
|
|
111
|
+
publicKey: identity.publicKey,
|
|
112
|
+
created: identity.created,
|
|
113
|
+
}) + '\n'));
|
|
70
114
|
files.set('sponge-list.js', Buffer.from(componentSource));
|
|
71
115
|
files.set('index.html', Buffer.from(indexHtml(options.title)));
|
|
72
|
-
const listed = [...files.keys()]
|
|
116
|
+
const listed = [...files.keys()]
|
|
117
|
+
.sort()
|
|
118
|
+
.map((path) => ({ path, bytes: files.get(path).length, sha256: sha256(files.get(path)) }));
|
|
73
119
|
const manifest = Buffer.from(canonical({ ...signed, files: listed }) + '\n');
|
|
74
120
|
files.set(manifestPath, manifest);
|
|
75
121
|
files.set(signaturePath, Buffer.from(Buffer.from(sign(manifest)).toString('base64url') + '\n'));
|
|
76
|
-
return new Map([...files.entries()].sort(([a], [b]) => a < b ? -1 : 1));
|
|
122
|
+
return new Map([...files.entries()].sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
77
123
|
}
|
|
@@ -2,5 +2,9 @@ import type { PublicIdentity } from './feed.js';
|
|
|
2
2
|
export interface Identity extends PublicIdentity {
|
|
3
3
|
sign(bytes: Uint8Array): Buffer;
|
|
4
4
|
}
|
|
5
|
-
/**
|
|
5
|
+
/** Library-relative key path. Backups carry it as a typed entry; item exports never do. */
|
|
6
|
+
export declare const publishKeyPath = "keys/publish-ed25519.json", maximumPublishKeyBytes = 4096;
|
|
7
|
+
/** The publishing key never leaves the library (except inside a private backup): `keys/publish-ed25519.json`, mode 0600 inside a 0700 directory. */
|
|
6
8
|
export declare function publishIdentity(root: string): Identity;
|
|
9
|
+
/** Load and verify an existing key without creating one. */
|
|
10
|
+
export declare function loadPublishKey(path: string): Identity;
|
|
@@ -1,24 +1,34 @@
|
|
|
1
|
-
import { chmodSync, closeSync, constants, fstatSync, fsyncSync, linkSync, openSync, readFileSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, closeSync, constants, fstatSync, fsyncSync, linkSync, openSync, readFileSync, unlinkSync, } from 'node:fs';
|
|
2
2
|
import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { pathExists, regularFile, safeDirectory
|
|
5
|
-
import {
|
|
4
|
+
import { pathExists, regularFile, safeDirectory } from '../store/files.js';
|
|
5
|
+
import { syncDirectory } from '../util/fsync.js';
|
|
6
6
|
import { writeAll } from '../portability/files.js';
|
|
7
|
-
|
|
7
|
+
import { SpongeError } from '../errors.js';
|
|
8
|
+
const fail = () => {
|
|
9
|
+
throw new SpongeError('integrity');
|
|
10
|
+
};
|
|
8
11
|
const b64 = (v, n) => typeof v === 'string' && new RegExp(`^[A-Za-z0-9_-]{${n}}$`).test(v) ? v : fail();
|
|
9
|
-
/**
|
|
12
|
+
/** Library-relative key path. Backups carry it as a typed entry; item exports never do. */
|
|
13
|
+
export const publishKeyPath = 'keys/publish-ed25519.json', maximumPublishKeyBytes = 4096;
|
|
14
|
+
/** The publishing key never leaves the library (except inside a private backup): `keys/publish-ed25519.json`, mode 0600 inside a 0700 directory. */
|
|
10
15
|
export function publishIdentity(root) {
|
|
11
16
|
const dir = safeDirectory(join(root, 'keys'), true);
|
|
12
17
|
chmodSync(dir, 0o700);
|
|
13
18
|
const path = join(dir, 'publish-ed25519.json');
|
|
14
19
|
if (!pathExists(path))
|
|
15
20
|
create(dir, path);
|
|
16
|
-
return
|
|
21
|
+
return loadPublishKey(path);
|
|
17
22
|
}
|
|
18
23
|
/** Write a temporary file, then hard-link it into place, so a concurrent first publish keeps one key. */
|
|
19
24
|
function create(dir, path) {
|
|
20
25
|
const jwk = generateKeyPairSync('ed25519').privateKey.export({ format: 'jwk' });
|
|
21
|
-
const body = JSON.stringify({
|
|
26
|
+
const body = JSON.stringify({
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
algorithm: 'Ed25519',
|
|
29
|
+
created: new Date().toISOString().slice(0, 10),
|
|
30
|
+
privateKey: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d },
|
|
31
|
+
}) + '\n';
|
|
22
32
|
const temp = join(dir, randomUUID() + '.tmp'), fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
23
33
|
try {
|
|
24
34
|
writeAll(fd, Buffer.from(body));
|
|
@@ -39,13 +49,17 @@ function create(dir, path) {
|
|
|
39
49
|
unlinkSync(temp);
|
|
40
50
|
syncDirectory(dir);
|
|
41
51
|
}
|
|
42
|
-
|
|
52
|
+
/** Load and verify an existing key without creating one. */
|
|
53
|
+
export function loadPublishKey(path) {
|
|
43
54
|
regularFile(path);
|
|
44
55
|
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
45
56
|
let raw;
|
|
46
57
|
try {
|
|
47
58
|
const stat = fstatSync(fd);
|
|
48
|
-
if (!stat.isFile() ||
|
|
59
|
+
if (!stat.isFile() ||
|
|
60
|
+
(stat.mode & 0o777) !== 0o600 ||
|
|
61
|
+
stat.size > maximumPublishKeyBytes ||
|
|
62
|
+
stat.uid !== process.getuid?.())
|
|
49
63
|
fail();
|
|
50
64
|
raw = readFileSync(fd, 'utf8');
|
|
51
65
|
}
|
|
@@ -59,10 +73,16 @@ function load(path) {
|
|
|
59
73
|
catch {
|
|
60
74
|
fail();
|
|
61
75
|
}
|
|
62
|
-
if (!value ||
|
|
76
|
+
if (!value ||
|
|
77
|
+
value.schemaVersion !== 1 ||
|
|
78
|
+
value.algorithm !== 'Ed25519' ||
|
|
79
|
+
typeof value.created !== 'string' ||
|
|
80
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(value.created) ||
|
|
81
|
+
value.privateKey?.kty !== 'OKP' ||
|
|
82
|
+
value.privateKey?.crv !== 'Ed25519')
|
|
63
83
|
fail();
|
|
64
84
|
const x = b64(value.privateKey.x, 43), key = createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', x, d: b64(value.privateKey.d, 43) }, format: 'jwk' });
|
|
65
85
|
if (createPublicKey(key).export({ format: 'jwk' }).x !== x)
|
|
66
86
|
fail();
|
|
67
|
-
return { algorithm: 'Ed25519', created: value.created, publicKey: x, sign: bytes => sign(null, bytes, key) };
|
|
87
|
+
return { algorithm: 'Ed25519', created: value.created, publicKey: x, sign: (bytes) => sign(null, bytes, key) };
|
|
68
88
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { PublicEntry } from '
|
|
2
|
-
export
|
|
3
|
-
export declare const publishMarker = ".sponge-publish", markerBody = "sponge-publish-v1\n";
|
|
1
|
+
import type { PublicEntry } from './projection.js';
|
|
2
|
+
export declare const publishMarker = ".sponge-publish";
|
|
4
3
|
export interface PublishOptions {
|
|
5
4
|
output: string;
|
|
6
5
|
title?: string;
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
1
|
+
import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { pathExists, regularFile, safeDirectory, verifyFile } from '../store/files.js';
|
|
5
|
+
import { sha256 } from '../util/hash.js';
|
|
6
|
+
import { syncDirectory } from '../util/fsync.js';
|
|
6
7
|
import { writeAll } from '../portability/files.js';
|
|
7
8
|
import { baseUrl, buildSite, manifestPath, signaturePath } from './feed.js';
|
|
8
9
|
import { publishIdentity } from './identity.js';
|
|
9
|
-
|
|
10
|
-
export const publishMarker = '.sponge-publish'
|
|
11
|
-
const
|
|
10
|
+
import { SpongeError } from '../errors.js';
|
|
11
|
+
export const publishMarker = '.sponge-publish';
|
|
12
|
+
const markerBody = 'sponge-publish-v1\n';
|
|
13
|
+
const fail = (code = 'invalid-input') => {
|
|
14
|
+
throw new SpongeError(code);
|
|
15
|
+
};
|
|
12
16
|
/** Drop entries listed fewer than `days` days ago, so listing dates do not reveal what is read right now. */
|
|
13
17
|
export function minAge(entries, days = 0, now = new Date()) {
|
|
14
18
|
if (!Number.isSafeInteger(days) || days < 0 || days > 3650)
|
|
@@ -16,7 +20,7 @@ export function minAge(entries, days = 0, now = new Date()) {
|
|
|
16
20
|
if (!days)
|
|
17
21
|
return entries;
|
|
18
22
|
const cutoff = new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
|
|
19
|
-
return entries.filter(e => e.listedOn <= cutoff);
|
|
23
|
+
return entries.filter((e) => e.listedOn <= cutoff);
|
|
20
24
|
}
|
|
21
25
|
/** Files a previous publish wrote, or a refusal: an unmarked directory or unlisted files are not ours to replace. */
|
|
22
26
|
function previousOutput(target, stat) {
|
|
@@ -26,27 +30,29 @@ function previousOutput(target, stat) {
|
|
|
26
30
|
if (!pathExists(marker))
|
|
27
31
|
fail();
|
|
28
32
|
regularFile(marker);
|
|
29
|
-
verifyFile(marker,
|
|
33
|
+
verifyFile(marker, sha256(markerBody), Buffer.byteLength(markerBody));
|
|
30
34
|
let listed;
|
|
31
35
|
try {
|
|
32
36
|
const manifest = join(target, manifestPath);
|
|
33
37
|
regularFile(manifest);
|
|
34
|
-
listed = new Set(JSON.parse(readFileSync(manifest, 'utf8')).files.map(f => f.path));
|
|
38
|
+
listed = new Set(JSON.parse(readFileSync(manifest, 'utf8')).files.map((f) => f.path));
|
|
35
39
|
}
|
|
36
40
|
catch {
|
|
37
41
|
return fail();
|
|
38
42
|
}
|
|
39
43
|
for (const path of [manifestPath, signaturePath, publishMarker])
|
|
40
44
|
listed.add(path);
|
|
41
|
-
const walk = (dir) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
const walk = (dir) => {
|
|
46
|
+
for (const name of readdirSync(dir)) {
|
|
47
|
+
const path = join(dir, name), s = lstatSync(path), rel = relative(target, path).split(sep).join('/');
|
|
48
|
+
if (s.isSymbolicLink())
|
|
49
|
+
fail('integrity');
|
|
50
|
+
if (s.isDirectory())
|
|
51
|
+
walk(path);
|
|
52
|
+
else if (!s.isFile() || !listed.has(rel))
|
|
53
|
+
fail();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
50
56
|
walk(target);
|
|
51
57
|
}
|
|
52
58
|
function writeTree(root, files) {
|
|
@@ -88,7 +94,7 @@ export function publishSite(source, options) {
|
|
|
88
94
|
throw error;
|
|
89
95
|
}
|
|
90
96
|
const target = join(parent, basename(output)), library = safeDirectory(source.root), inside = relative(library, target);
|
|
91
|
-
if (!inside || !inside.startsWith('..') && !isAbsolute(inside) || !relative(target, library).startsWith('..'))
|
|
97
|
+
if (!inside || (!inside.startsWith('..') && !isAbsolute(inside)) || !relative(target, library).startsWith('..'))
|
|
92
98
|
fail();
|
|
93
99
|
const title = options.title ?? 'Reading list';
|
|
94
100
|
if (!title.trim() || title.length > 200 || /[\u0000-\u001f\u007f]/.test(title))
|
|
@@ -105,7 +111,7 @@ export function publishSite(source, options) {
|
|
|
105
111
|
if (previous)
|
|
106
112
|
previousOutput(target, previous);
|
|
107
113
|
const projection = source.publicProjection(), entries = minAge(projection.entries, options.minAgeDays, options.now), identity = publishIdentity(library);
|
|
108
|
-
const files = buildSite(entries, identity, { title, ...(base ? { baseUrl: base } : {}) }, bytes => identity.sign(bytes));
|
|
114
|
+
const files = buildSite(entries, identity, { title, ...(base ? { baseUrl: base } : {}) }, (bytes) => identity.sign(bytes));
|
|
109
115
|
const id = randomUUID(), temp = join(parent, `.${basename(target)}.${id}.tmp`);
|
|
110
116
|
try {
|
|
111
117
|
writeTree(temp, files);
|
|
@@ -133,5 +139,12 @@ export function publishSite(source, options) {
|
|
|
133
139
|
else
|
|
134
140
|
renameSync(temp, target);
|
|
135
141
|
syncDirectory(parent);
|
|
136
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
output: target,
|
|
144
|
+
entries: entries.length,
|
|
145
|
+
held: projection.entries.length - entries.length,
|
|
146
|
+
skipped: projection.skipped.length,
|
|
147
|
+
files: files.size,
|
|
148
|
+
publicKey: identity.publicKey,
|
|
149
|
+
};
|
|
137
150
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { PublicDigest } from '../derivations/types.js';
|
|
2
3
|
/** One public list entry. Metadata only: no captured text, paths, account realms or local fingerprints. */
|
|
3
4
|
export interface PublicEntry {
|
|
4
5
|
uri: string;
|
|
@@ -9,15 +10,7 @@ export interface PublicEntry {
|
|
|
9
10
|
published?: string;
|
|
10
11
|
site?: string;
|
|
11
12
|
listedOn: string;
|
|
12
|
-
digest?:
|
|
13
|
-
lead?: string;
|
|
14
|
-
ideas: {
|
|
15
|
-
claim: string;
|
|
16
|
-
detail: string;
|
|
17
|
-
kind?: string;
|
|
18
|
-
quotes: string[];
|
|
19
|
-
}[];
|
|
20
|
-
};
|
|
13
|
+
digest?: PublicDigest;
|
|
21
14
|
}
|
|
22
15
|
/** What `sponge publish` would publish now, newest first. Items without a resolvable URI are listed as skipped. */
|
|
23
16
|
export declare function publicProjection(db: DatabaseSync): {
|
|
@@ -1,26 +1,23 @@
|
|
|
1
1
|
import { itemUris, visibility } from '../identity/visibility.js';
|
|
2
|
-
import {
|
|
3
|
-
import { currentEditorial, publicEditorial } from '../digest/editorial.js';
|
|
2
|
+
import { publicDerivation } from '../store/derivations.js';
|
|
4
3
|
/** The first selected (or unmarked) claim among `fields`; string lists are joined. */
|
|
5
4
|
function selectedClaim(db, revisionId, fields) {
|
|
6
5
|
for (const field of fields) {
|
|
7
|
-
const r = db
|
|
8
|
-
|
|
6
|
+
const r = db
|
|
7
|
+
.prepare('SELECT value_json FROM metadata_claims WHERE revision_id=? AND field=? AND selected IS NOT 0 ORDER BY selected IS NOT 1,rowid LIMIT 1')
|
|
8
|
+
.get(revisionId, field);
|
|
9
|
+
const raw = r ? JSON.parse(String(r.value_json)) : undefined, v = Array.isArray(raw) && raw.every((x) => typeof x === 'string') ? raw.join(', ') : raw;
|
|
9
10
|
if (typeof v === 'string' && v.trim() && v.length <= 1024)
|
|
10
11
|
return v.trim();
|
|
11
12
|
}
|
|
12
13
|
return undefined;
|
|
13
14
|
}
|
|
14
|
-
/**
|
|
15
|
-
* The public digest: the accepted fields of the current digest (v2, or a stored v1), else the public subset of an accepted
|
|
16
|
-
* editorial note (lead, claims, verbatim quotes within the v2 limits). Never both, never attributions, and never the note's other content.
|
|
17
|
-
*/
|
|
18
|
-
function publicDigest(db, id) { const d = currentDigest(db, id); if (d)
|
|
19
|
-
return acceptedDigest(d); const e = currentEditorial(db, id); return e && publicEditorial(db, e); }
|
|
20
15
|
/** What `sponge publish` would publish now, newest first. Items without a resolvable URI are listed as skipped. */
|
|
21
16
|
export function publicProjection(db) {
|
|
22
17
|
const entries = [], skipped = [];
|
|
23
|
-
for (const item of db
|
|
18
|
+
for (const item of db
|
|
19
|
+
.prepare('SELECT id,kind,title,current_revision,created_at FROM items WHERE deleted_at IS NULL ORDER BY created_at DESC,id')
|
|
20
|
+
.all()) {
|
|
24
21
|
const id = String(item.id);
|
|
25
22
|
if (visibility(db, id).effective !== 'public')
|
|
26
23
|
continue;
|
|
@@ -28,16 +25,26 @@ export function publicProjection(db) {
|
|
|
28
25
|
skipped.push({ itemId: id, reason: 'not-captured' });
|
|
29
26
|
continue;
|
|
30
27
|
}
|
|
31
|
-
const uris = itemUris(db, id).filter(u => u.resolvable), primary = uris.find(u => u.role === 'primary') ?? uris[0];
|
|
28
|
+
const uris = itemUris(db, id).filter((u) => u.resolvable), primary = uris.find((u) => u.role === 'primary') ?? uris[0];
|
|
32
29
|
if (!primary) {
|
|
33
30
|
skipped.push({ itemId: id, reason: 'no-public-uri' });
|
|
34
31
|
continue;
|
|
35
32
|
}
|
|
36
|
-
const revision = String(item.current_revision), authors = selectedClaim(db, revision, ['authors', 'author']), published = selectedClaim(db, revision, ['publishedAt', 'published']), accepted =
|
|
33
|
+
const revision = String(item.current_revision), authors = selectedClaim(db, revision, ['authors', 'author']), published = selectedClaim(db, revision, ['publishedAt', 'published']), accepted = publicDerivation(db, id);
|
|
37
34
|
let site;
|
|
38
35
|
if (/^https?:/.test(primary.uri))
|
|
39
36
|
site = new URL(primary.uri).hostname.replace(/^www\./, '');
|
|
40
|
-
entries.push({
|
|
37
|
+
entries.push({
|
|
38
|
+
uri: primary.uri,
|
|
39
|
+
aliases: uris.filter((u) => u !== primary).map((u) => u.uri),
|
|
40
|
+
kind: String(item.kind),
|
|
41
|
+
title: String(item.title),
|
|
42
|
+
...(authors ? { authors } : {}),
|
|
43
|
+
...(published ? { published } : {}),
|
|
44
|
+
...(site ? { site } : {}),
|
|
45
|
+
listedOn: String(item.created_at).slice(0, 10),
|
|
46
|
+
...(accepted ? { digest: accepted } : {}),
|
|
47
|
+
});
|
|
41
48
|
}
|
|
42
49
|
return { entries, skipped };
|
|
43
50
|
}
|
|
@@ -12,6 +12,8 @@ export function initializeVectorExtension(db) {
|
|
|
12
12
|
try {
|
|
13
13
|
db.enableLoadExtension(false);
|
|
14
14
|
}
|
|
15
|
-
catch {
|
|
15
|
+
catch {
|
|
16
|
+
/* Database created without extension support is already closed to loading. */
|
|
17
|
+
}
|
|
16
18
|
}
|
|
17
19
|
}
|