@hraness/sponge 0.2.6 → 0.3.1

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.
Files changed (227) hide show
  1. package/README.md +21 -9
  2. package/bin/sponge.js +8 -7
  3. package/dist/contracts/index.js +24 -6
  4. package/dist/contracts/validate.js +106 -29
  5. package/dist/migrations/008.d.ts +3 -0
  6. package/dist/migrations/008.js +5 -0
  7. package/dist/migrations/009.d.ts +3 -0
  8. package/dist/migrations/009.js +8 -0
  9. package/dist/migrations/010.d.ts +3 -0
  10. package/dist/migrations/010.js +11 -0
  11. package/dist/migrations/index.d.ts +15 -0
  12. package/dist/migrations/index.js +39 -0
  13. package/dist/src/acquisition/capture.js +87 -28
  14. package/dist/src/acquisition/cleanup.d.ts +0 -4
  15. package/dist/src/acquisition/cleanup.js +3 -5
  16. package/dist/src/acquisition/errors.js +17 -2
  17. package/dist/src/acquisition/files.d.ts +0 -1
  18. package/dist/src/acquisition/files.js +14 -11
  19. package/dist/src/acquisition/index.d.ts +2 -3
  20. package/dist/src/acquisition/index.js +1 -2
  21. package/dist/src/acquisition/lock.js +1 -1
  22. package/dist/src/acquisition/normalize.js +57 -18
  23. package/dist/src/acquisition/pins.js +22 -4
  24. package/dist/src/acquisition/process.js +67 -29
  25. package/dist/src/acquisition/runtime.d.ts +5 -7
  26. package/dist/src/acquisition/runtime.js +129 -68
  27. package/dist/src/acquisition/verify-helper.js +10 -3
  28. package/dist/src/annotations/quote.js +40 -12
  29. package/dist/src/annotations/store.d.ts +0 -1
  30. package/dist/src/annotations/store.js +66 -14
  31. package/dist/src/app/article.d.ts +2 -1
  32. package/dist/src/app/article.js +66 -70
  33. package/dist/src/app/bundle-files.d.ts +0 -4
  34. package/dist/src/app/bundle-files.js +11 -14
  35. package/dist/src/app/markdown.js +18 -6
  36. package/dist/src/app/source.d.ts +0 -4
  37. package/dist/src/app/source.js +18 -14
  38. package/dist/src/cli/arguments.d.ts +2 -8
  39. package/dist/src/cli/arguments.js +67 -80
  40. package/dist/src/cli/commands.d.ts +42 -0
  41. package/dist/src/cli/commands.js +795 -0
  42. package/dist/src/cli/config.js +12 -9
  43. package/dist/src/cli/digest.d.ts +1 -1
  44. package/dist/src/cli/digest.js +38 -16
  45. package/dist/src/cli/import.js +25 -4
  46. package/dist/src/cli/indexing.d.ts +2 -0
  47. package/dist/src/cli/indexing.js +26 -9
  48. package/dist/src/cli/main.js +47 -386
  49. package/dist/src/cli/portability.d.ts +0 -2
  50. package/dist/src/cli/portability.js +13 -8
  51. package/dist/src/cli/queue.js +12 -8
  52. package/dist/src/cli/runtimes.d.ts +1 -3
  53. package/dist/src/cli/runtimes.js +13 -6
  54. package/dist/src/cli/terminal.js +1 -1
  55. package/dist/src/cli/worker.d.ts +7 -0
  56. package/dist/src/cli/worker.js +54 -15
  57. package/dist/src/derivations/kinds.d.ts +34 -0
  58. package/dist/src/derivations/kinds.js +14 -0
  59. package/dist/src/derivations/types.d.ts +48 -0
  60. package/dist/src/derivations/types.js +1 -0
  61. package/dist/src/digest/derivation.d.ts +25 -0
  62. package/dist/src/digest/derivation.js +124 -0
  63. package/dist/src/digest/editorial.d.ts +9 -46
  64. package/dist/src/digest/editorial.js +57 -49
  65. package/dist/src/digest/endpoint.js +63 -27
  66. package/dist/src/digest/pipeline.d.ts +49 -28
  67. package/dist/src/digest/pipeline.js +369 -102
  68. package/dist/src/digest/schema.d.ts +11 -25
  69. package/dist/src/digest/schema.js +86 -18
  70. package/dist/src/errors.d.ts +46 -0
  71. package/dist/src/errors.js +117 -0
  72. package/dist/src/identity/query-policy.d.ts +0 -5
  73. package/dist/src/identity/query-policy.js +61 -9
  74. package/dist/src/identity/uri.d.ts +6 -5
  75. package/dist/src/identity/uri.js +37 -18
  76. package/dist/src/identity/visibility.d.ts +11 -1
  77. package/dist/src/identity/visibility.js +81 -17
  78. package/dist/src/importers/apply.js +104 -36
  79. package/dist/src/importers/custody.js +27 -9
  80. package/dist/src/importers/inspect.js +125 -38
  81. package/dist/src/importers/jungle-digest.d.ts +3 -6
  82. package/dist/src/importers/jungle-digest.js +196 -69
  83. package/dist/src/importers/jungle-store.d.ts +19 -6
  84. package/dist/src/importers/jungle-store.js +124 -28
  85. package/dist/src/importers/jungle.d.ts +14 -14
  86. package/dist/src/importers/jungle.js +142 -31
  87. package/dist/src/importers/manifest.js +57 -21
  88. package/dist/src/importers/verify-helper.js +18 -4
  89. package/dist/src/indexing/image.js +29 -10
  90. package/dist/src/indexing/index.d.ts +37 -6
  91. package/dist/src/indexing/index.js +360 -124
  92. package/dist/src/indexing/jobs.js +156 -47
  93. package/dist/src/indexing/prune-run.d.ts +2 -0
  94. package/dist/src/indexing/prune-run.js +69 -26
  95. package/dist/src/indexing/prune.d.ts +12 -3
  96. package/dist/src/indexing/prune.js +85 -35
  97. package/dist/src/indexing/runner.d.ts +2 -1
  98. package/dist/src/indexing/runner.js +83 -40
  99. package/dist/src/indexing/upgrade.d.ts +9 -0
  100. package/dist/src/indexing/upgrade.js +79 -0
  101. package/dist/src/inference/asr/bridge.js +6 -2
  102. package/dist/src/inference/asr/protocol.js +109 -19
  103. package/dist/src/inference/asr/runtime.js +58 -21
  104. package/dist/src/inference/asr/windows.js +9 -5
  105. package/dist/src/inference/embedding/index.d.ts +2 -6
  106. package/dist/src/inference/embedding/index.js +89 -29
  107. package/dist/src/ingest/attempt.d.ts +29 -0
  108. package/dist/src/ingest/attempt.js +55 -0
  109. package/dist/src/media/capture-helper.js +7 -2
  110. package/dist/src/media/capture.d.ts +2 -1
  111. package/dist/src/media/capture.js +124 -43
  112. package/dist/src/media/handler.d.ts +4 -2
  113. package/dist/src/media/handler.js +88 -70
  114. package/dist/src/media/transcribe.js +63 -21
  115. package/dist/src/metadata/article.js +49 -17
  116. package/dist/src/models/artifacts.d.ts +10 -8
  117. package/dist/src/models/artifacts.js +237 -106
  118. package/dist/src/models/catalog.d.ts +2 -5
  119. package/dist/src/models/catalog.js +94 -25
  120. package/dist/src/models/download.js +55 -15
  121. package/dist/src/models/runtime.d.ts +0 -2
  122. package/dist/src/models/runtime.js +69 -33
  123. package/dist/src/pdf/capture-helper.js +3 -1
  124. package/dist/src/pdf/capture.d.ts +2 -1
  125. package/dist/src/pdf/capture.js +95 -27
  126. package/dist/src/pdf/errors.js +12 -1
  127. package/dist/src/pdf/handler.d.ts +2 -1
  128. package/dist/src/pdf/handler.js +70 -71
  129. package/dist/src/pdf/index.d.ts +1 -3
  130. package/dist/src/pdf/index.js +0 -2
  131. package/dist/src/pdf/input.js +12 -7
  132. package/dist/src/pdf/normalize.js +80 -21
  133. package/dist/src/pdf/source-identity.js +3 -2
  134. package/dist/src/pdf/source.js +12 -8
  135. package/dist/src/pdf/verify-helper.js +15 -3
  136. package/dist/src/portability/backup.d.ts +2 -9
  137. package/dist/src/portability/backup.js +106 -22
  138. package/dist/src/portability/database.js +51 -17
  139. package/dist/src/portability/export.js +35 -9
  140. package/dist/src/portability/files.d.ts +1 -4
  141. package/dist/src/portability/files.js +127 -67
  142. package/dist/src/portability/gc.js +65 -22
  143. package/dist/src/publish/component.js +1 -1
  144. package/dist/src/publish/feed.d.ts +2 -2
  145. package/dist/src/publish/feed.js +75 -29
  146. package/dist/src/publish/identity.d.ts +5 -1
  147. package/dist/src/publish/identity.js +31 -11
  148. package/dist/src/publish/index.d.ts +2 -3
  149. package/dist/src/publish/index.js +34 -21
  150. package/dist/src/publish/projection.d.ts +2 -9
  151. package/dist/src/publish/projection.js +21 -14
  152. package/dist/src/search/extension.js +3 -1
  153. package/dist/src/search/hybrid.d.ts +3 -8
  154. package/dist/src/search/hybrid.js +45 -11
  155. package/dist/src/search/lexical.js +18 -4
  156. package/dist/src/search/query.js +56 -25
  157. package/dist/src/search/vector.js +37 -10
  158. package/dist/src/service/launch-agent.d.ts +5 -3
  159. package/dist/src/service/launch-agent.js +272 -83
  160. package/dist/src/service/lifetime.js +47 -14
  161. package/dist/src/service/loop.d.ts +3 -3
  162. package/dist/src/service/loop.js +47 -19
  163. package/dist/src/store/annotations.d.ts +32 -0
  164. package/dist/src/store/annotations.js +44 -0
  165. package/dist/src/store/backups.d.ts +18 -0
  166. package/dist/src/store/backups.js +110 -0
  167. package/dist/src/store/blobs.d.ts +24 -0
  168. package/dist/src/store/blobs.js +139 -0
  169. package/dist/src/store/changes.d.ts +5 -12
  170. package/dist/src/store/changes.js +108 -56
  171. package/dist/src/store/context.d.ts +15 -0
  172. package/dist/src/store/context.js +19 -0
  173. package/dist/src/store/derivations.d.ts +42 -0
  174. package/dist/src/store/derivations.js +104 -0
  175. package/dist/src/store/files.d.ts +0 -2
  176. package/dist/src/store/files.js +19 -24
  177. package/dist/src/store/ids.d.ts +30 -0
  178. package/dist/src/store/ids.js +34 -0
  179. package/dist/src/store/imports.d.ts +26 -0
  180. package/dist/src/store/imports.js +57 -0
  181. package/dist/src/store/index.d.ts +3 -186
  182. package/dist/src/store/index.js +2 -809
  183. package/dist/src/store/lease.d.ts +20 -0
  184. package/dist/src/store/lease.js +62 -0
  185. package/dist/src/store/library.d.ts +75 -0
  186. package/dist/src/store/library.js +174 -0
  187. package/dist/src/store/open.d.ts +7 -0
  188. package/dist/src/store/open.js +183 -0
  189. package/dist/src/store/queue.d.ts +65 -0
  190. package/dist/src/store/queue.js +327 -0
  191. package/dist/src/store/revisions.d.ts +12 -0
  192. package/dist/src/store/revisions.js +189 -0
  193. package/dist/src/store/search-index.d.ts +63 -0
  194. package/dist/src/store/search-index.js +190 -0
  195. package/dist/src/store/sync-ready.d.ts +6 -0
  196. package/dist/src/store/sync-ready.js +121 -0
  197. package/dist/src/store/types.d.ts +1 -5
  198. package/dist/src/store/types.js +1 -8
  199. package/dist/src/util/canonical.d.ts +2 -0
  200. package/dist/src/util/canonical.js +17 -0
  201. package/dist/src/util/exact.d.ts +2 -0
  202. package/dist/src/util/exact.js +9 -0
  203. package/dist/src/util/fsync.d.ts +4 -0
  204. package/dist/src/util/fsync.js +22 -0
  205. package/dist/src/util/hash.d.ts +4 -0
  206. package/dist/src/util/hash.js +8 -0
  207. package/dist/src/worker/basic.d.ts +0 -1
  208. package/dist/src/worker/basic.js +23 -7
  209. package/dist/src/worker/custody.d.ts +4 -1
  210. package/dist/src/worker/custody.js +111 -38
  211. package/dist/src/worker/engine.d.ts +2 -1
  212. package/dist/src/worker/engine.js +42 -20
  213. package/dist/src/worker/identity.js +13 -5
  214. package/dist/src/worker/journal.d.ts +1 -1
  215. package/dist/src/worker/journal.js +54 -24
  216. package/dist/src/worker/lock.d.ts +18 -0
  217. package/dist/src/worker/lock.js +196 -0
  218. package/dist/src/worker/runtime-gc.d.ts +4 -3
  219. package/dist/src/worker/runtime-gc.js +68 -41
  220. package/dist/src/worker/supervisor-child.js +19 -6
  221. package/dist/src/worker/supervisor.js +41 -8
  222. package/native/runtime-manifest.json +4 -4
  223. package/package.json +10 -2
  224. package/dist/src/digest/store.d.ts +0 -57
  225. package/dist/src/digest/store.js +0 -79
  226. package/dist/src/store/foundation.d.ts +0 -7
  227. 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 { 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);
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' || 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)
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(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)
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 { format: 'sponge-gc', version: 1, libraryId: r.libraryId, revisionEpoch: bounded(r.revisionEpoch, Number.MAX_SAFE_INTEGER), createdAt: r.createdAt, candidates };
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 StoreError('limit-exceeded');
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 { format: 'sponge-gc', version: 1, libraryId: libraryId(store), revisionEpoch: epoch, createdAt: new Date().toISOString(), candidates };
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) || 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));
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
87
+ const html = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 '../store/foundation.js';
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 function canonical(value: unknown): string;
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. */
@@ -1,28 +1,25 @@
1
- import { createHash } from 'node:crypto';
2
- import { StoreError } from '../store/types.js';
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') => { throw new StoreError(code); };
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 function canonical(value) {
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 { /* rejected below */ }
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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };
33
30
  /** XML 1.0 text and attribute escaping; characters XML cannot carry become U+FFFD. */
34
- export function xml(value) { return value.replace(xmlInvalid, '�').replace(/[&<>"']/g, c => xmlEntities[c]); }
35
- const date = (e) => /^\d{4}-\d{2}-\d{2}$/.test(e.listedOn) ? e.listedOn + 'T00:00:00Z' : fail();
36
- const summaryText = (e) => [e.digest?.lead, ...(e.digest?.ideas ?? []).map(i => '- ' + i.claim)].filter(Boolean).join('\n') || e.title;
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 { id: e.uri, url: web(e.uri) ? e.uri : undefined, title: e.title, content_text: summaryText(e), summary: e.digest?.lead, date_published: date(e), authors: e.authors ? [{ name: e.authors }] : undefined,
39
- _sponge: { uri: e.uri, aliases: e.aliases, kind: e.kind, site: e.site, published: e.published, digest: e.digest } };
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 ? date(entries.reduce((a, b) => b.listedOn > a.listedOn ? b : a)) : identity.created + 'T00:00:00Z';
43
- const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<feed xmlns="http://www.w3.org/2005/Atom">', `<id>tag:sponge.computer,2026:feed/${xml(identity.publicKey)}</id>`, `<title>${xml(options.title)}</title>`, `<updated>${updated}</updated>`, `<author><name>${xml(options.title)}</name></author>`, '<generator>Sponge</generator>',
44
- ...(base ? [`<link rel="self" type="application/atom+xml" href="${xml(base + 'feed.xml')}"/>`, `<link rel="alternate" type="text/html" href="${xml(base)}"/>`] : [])];
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' || !/^[A-Za-z0-9_-]{43}$/.test(identity.publicKey) || !/^\d{4}-\d{2}-\d{2}$/.test(identity.created))
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 ? base + to : from.includes('/') && to.startsWith(from.slice(0, from.lastIndexOf('/') + 1)) ? to.slice(from.lastIndexOf('/') + 1) : to;
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({ version: 'https://jsonfeed.org/version/1.1', title: options.title, home_page_url: base, feed_url: base && base + path, next_url: next && link(path, next), items: list.map(item), _sponge: { ...signed, manifest: link(path, manifestPath), ...extra } }) + '\n');
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)), { total: entries.length, shards: order.map(m => ({ month: m, url: link('feed.json', shard(m)), items: months.get(m).length })) }));
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({ schemaVersion: 1, algorithm: identity.algorithm, publicKey: identity.publicKey, created: identity.created }) + '\n'));
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()].sort().map(path => ({ path, bytes: files.get(path).length, sha256: createHash('sha256').update(files.get(path)).digest('hex') }));
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
- /** The publishing key never leaves the library: `keys/publish-ed25519.json`, mode 0600 inside a 0700 directory. */
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, syncDirectory } from '../store/files.js';
5
- import { StoreError } from '../store/types.js';
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
- const fail = () => { throw new StoreError('integrity'); };
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
- /** The publishing key never leaves the library: `keys/publish-ed25519.json`, mode 0600 inside a 0700 directory. */
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 load(path);
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({ schemaVersion: 1, algorithm: 'Ed25519', created: new Date().toISOString().slice(0, 10), privateKey: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d } }) + '\n';
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
- function load(path) {
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() || (stat.mode & 0o777) !== 0o600 || stat.size > 4096 || stat.uid !== process.getuid?.())
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 || value.schemaVersion !== 1 || value.algorithm !== 'Ed25519' || typeof value.created !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.created) || value.privateKey?.kty !== 'OKP' || value.privateKey?.crv !== 'Ed25519')
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 '../store/foundation.js';
2
- export { buildSite, canonical, xml, feedLimit } from './feed.js';
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 { digest, pathExists, regularFile, safeDirectory, syncDirectory, verifyFile } from '../store/files.js';
5
- import { StoreError } from '../store/types.js';
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
- export { buildSite, canonical, xml, feedLimit } from './feed.js';
10
- export const publishMarker = '.sponge-publish', markerBody = 'sponge-publish-v1\n';
11
- const fail = (code = 'invalid-input') => { throw new StoreError(code); };
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, digest(markerBody), Buffer.byteLength(markerBody));
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) => { for (const name of readdirSync(dir)) {
42
- const path = join(dir, name), s = lstatSync(path), rel = relative(target, path).split(sep).join('/');
43
- if (s.isSymbolicLink())
44
- fail('integrity');
45
- if (s.isDirectory())
46
- walk(path);
47
- else if (!s.isFile() || !listed.has(rel))
48
- fail();
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 { output: target, entries: entries.length, held: projection.entries.length - entries.length, skipped: projection.skipped.length, files: files.size, publicKey: identity.publicKey };
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 { acceptedDigest, currentDigest } from '../digest/store.js';
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.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').get(revisionId, field);
8
- const raw = r ? JSON.parse(String(r.value_json)) : undefined, v = Array.isArray(raw) && raw.every(x => typeof x === 'string') ? raw.join(', ') : raw;
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.prepare('SELECT id,kind,title,current_revision,created_at FROM items WHERE deleted_at IS NULL ORDER BY created_at DESC,id').all()) {
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 = publicDigest(db, id);
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({ uri: primary.uri, aliases: uris.filter(u => u !== primary).map(u => u.uri), kind: String(item.kind), title: String(item.title), ...(authors ? { authors } : {}), ...(published ? { published } : {}), ...(site ? { site } : {}), listedOn: String(item.created_at).slice(0, 10), ...(accepted ? { digest: accepted } : {}) });
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 { /* Database created without extension support is already closed to loading. */ }
15
+ catch {
16
+ /* Database created without extension support is already closed to loading. */
17
+ }
16
18
  }
17
19
  }