@wxxb789/anc 0.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/astro.config.mjs +255 -0
  4. package/bin/anc.mjs +798 -0
  5. package/package.json +93 -0
  6. package/public/_headers +45 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/favicon.svg +9 -0
  9. package/public/fonts/Temml.woff2 +0 -0
  10. package/scripts/build-snapshot.js +69 -0
  11. package/scripts/build-wasm.js +67 -0
  12. package/scripts/build-workspace.js +50 -0
  13. package/scripts/copy-snapshot.js +48 -0
  14. package/scripts/copy-wasm.js +57 -0
  15. package/scripts/emit-redirects.js +64 -0
  16. package/scripts/generate-corpus.js +421 -0
  17. package/scripts/init-repository.js +249 -0
  18. package/scripts/load-config.js +975 -0
  19. package/scripts/markdown-to-artifact.js +1286 -0
  20. package/scripts/preview-site.js +364 -0
  21. package/scripts/publish-set-review.js +157 -0
  22. package/scripts/resolve-links.js +640 -0
  23. package/scripts/run-pagefind.js +125 -0
  24. package/scripts/scan-residue.js +1016 -0
  25. package/scripts/scan-secrets.js +268 -0
  26. package/scripts/seed-gitignore.js +410 -0
  27. package/scripts/snapshot-rows.js +205 -0
  28. package/scripts/validate-content.js +95 -0
  29. package/scripts/vendor-provenance.js +307 -0
  30. package/scripts/verify-output-inventory.js +307 -0
  31. package/scripts/write-report.js +351 -0
  32. package/scripts/write-snapshot.js +136 -0
  33. package/src/components/CollectionExplorer.astro +149 -0
  34. package/src/components/FacetIndex.astro +45 -0
  35. package/src/components/LinkedNotes.astro +94 -0
  36. package/src/components/NoteGraph.astro +434 -0
  37. package/src/components/NoteList.astro +54 -0
  38. package/src/components/SiteMetadata.astro +143 -0
  39. package/src/components/TableOfContents.astro +69 -0
  40. package/src/components/TagBrowser.astro +70 -0
  41. package/src/layouts/Layout.astro +305 -0
  42. package/src/lib/artifact-source.js +72 -0
  43. package/src/lib/build-bindings.js +11 -0
  44. package/src/lib/collection-navigation.js +145 -0
  45. package/src/lib/content.js +92 -0
  46. package/src/lib/diagram-mode.js +18 -0
  47. package/src/lib/graph-layout.js +219 -0
  48. package/src/lib/graph-selection.js +84 -0
  49. package/src/lib/graph.js +42 -0
  50. package/src/lib/link-resolution.js +389 -0
  51. package/src/lib/markdown.js +1469 -0
  52. package/src/lib/math-mode.js +31 -0
  53. package/src/lib/math-output.js +131 -0
  54. package/src/lib/math.js +165 -0
  55. package/src/lib/mermaid-environment.js +475 -0
  56. package/src/lib/mermaid-render.js +769 -0
  57. package/src/lib/preview-model.js +96 -0
  58. package/src/lib/relations.js +242 -0
  59. package/src/lib/rendered-marker.js +55 -0
  60. package/src/lib/route-path.js +192 -0
  61. package/src/lib/routes.js +342 -0
  62. package/src/lib/schema.js +578 -0
  63. package/src/lib/site.js +578 -0
  64. package/src/lib/snapshot-contract.js +216 -0
  65. package/src/lib/snapshot-operations.js +147 -0
  66. package/src/lib/snapshot-queries.js +216 -0
  67. package/src/lib/snapshot-reader.js +210 -0
  68. package/src/lib/snapshot.js +233 -0
  69. package/src/lib/sqlite-warning.js +22 -0
  70. package/src/lib/sqlite.js +14 -0
  71. package/src/lib/tag-browser-model.js +18 -0
  72. package/src/lib/translations.js +533 -0
  73. package/src/lib/wasm-asset.js +47 -0
  74. package/src/lib/worker-protocol.js +93 -0
  75. package/src/pages/404.astro +35 -0
  76. package/src/pages/about.astro +100 -0
  77. package/src/pages/collections/[slug].astro +36 -0
  78. package/src/pages/collections/index.astro +33 -0
  79. package/src/pages/graph.astro +64 -0
  80. package/src/pages/index.astro +33 -0
  81. package/src/pages/notes/[slug].astro +509 -0
  82. package/src/pages/privacy.astro +121 -0
  83. package/src/pages/private.astro +59 -0
  84. package/src/pages/recent.astro +33 -0
  85. package/src/pages/robots.txt.js +15 -0
  86. package/src/pages/rss.xml.js +21 -0
  87. package/src/pages/sitemap.xml.js +20 -0
  88. package/src/pages/tags/[tag].astro +56 -0
  89. package/src/pages/tags/index.astro +37 -0
  90. package/src/scripts/code-copy.js +45 -0
  91. package/src/scripts/diagram-disabled.js +16 -0
  92. package/src/scripts/diagram.js +322 -0
  93. package/src/scripts/graph-client.js +222 -0
  94. package/src/scripts/graph-draw.js +166 -0
  95. package/src/scripts/link-preview.js +334 -0
  96. package/src/scripts/math-disabled.js +18 -0
  97. package/src/scripts/math.js +133 -0
  98. package/src/scripts/preferences.js +123 -0
  99. package/src/scripts/search-dialog.js +651 -0
  100. package/src/scripts/snapshot-client.js +157 -0
  101. package/src/scripts/snapshot-worker.js +341 -0
  102. package/src/scripts/tag-browser.js +153 -0
  103. package/src/scripts/theme-init.js +35 -0
  104. package/src/scripts/toc-active.js +38 -0
  105. package/src/styles/code.css +152 -0
  106. package/src/styles/diagram.css +149 -0
  107. package/src/styles/global.css +1739 -0
  108. package/src/styles/graph.css +317 -0
  109. package/src/styles/math.css +376 -0
  110. package/src/styles/tokens.css +171 -0
  111. package/tsconfig.json +25 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The snapshot's exact schema contract, over a row-reader rather than a driver.
3
+ *
4
+ * Both drivers that can open a snapshot — the native build reader and the
5
+ * browser WASM reader — must accept and reject the same files. Expressing the
6
+ * check as "run these SQL statements and give me rows" lets one implementation
7
+ * serve both without either importing the other; `scripts/write-snapshot.ts`
8
+ * adapts `node:sqlite`, and the browser Worker adapts the WASM connection.
9
+ *
10
+ * The check is deliberately not satisfied by the header constants: a file can
11
+ * carry the right `application_id` and `user_version` and still have an
12
+ * unexpected column type, dropped `NOT NULL`, foreign key, `STRICT`/
13
+ * `WITHOUT ROWID` option, `CHECK`, or explicit index — added, removed, or
14
+ * reshaped (columns, uniqueness, partiality) — and a query against it would then
15
+ * return something whose meaning differs from the contract.
16
+ *
17
+ * Every statement issued here is a read-only `PRAGMA` or `sqlite_schema`
18
+ * query, so the reader can stay read-only. `CHECK` clauses are the one
19
+ * declaration SQLite exposes only in the stored DDL text; they are matched
20
+ * after case and whitespace folding, which is stable because the writer
21
+ * applies `SNAPSHOT_SCHEMA_SQL` verbatim.
22
+ */
23
+ import { SNAPSHOT_APPLICATION_ID, SNAPSHOT_EXPLICIT_INDEX, SNAPSHOT_TABLE_SHAPES, SNAPSHOT_TABLES, SNAPSHOT_USER_VERSION, } from "./snapshot.js";
24
+ /** Collapse case and whitespace so stored DDL compares by clause, not layout. */
25
+ function foldSql(sql) {
26
+ return sql.replace(/\s+/g, ' ').trim().toUpperCase();
27
+ }
28
+ /** `from -> table.to`, so foreign keys compare as a set, not in pragma order. */
29
+ function foreignKeyText(key) {
30
+ return `${key.from} -> ${key.table}.${key.to}`;
31
+ }
32
+ /** Order foreign keys by their printed form; `PRAGMA` order is not stable. */
33
+ function byForeignKeyText(left, right) {
34
+ const leftText = foreignKeyText(left);
35
+ const rightText = foreignKeyText(right);
36
+ return leftText < rightText ? -1 : leftText > rightText ? 1 : 0;
37
+ }
38
+ /** Fail unless `PRAGMA table_info` matches the declared columns exactly. */
39
+ function assertColumns(read, table, columns) {
40
+ const actual = read(`PRAGMA table_info(${table})`);
41
+ if (actual.length !== columns.length) {
42
+ throw new Error(`snapshot table ${table} has columns [${actual.map((row) => String(row['name'])).join(', ')}], ` +
43
+ `expected [${columns.map((column) => column.name).join(', ')}]`);
44
+ }
45
+ for (const [index, column] of columns.entries()) {
46
+ const row = actual[index];
47
+ const actualText = `name=${String(row['name'])} type=${String(row['type'])} ` +
48
+ `notnull=${String(row['notnull'])} pk=${String(row['pk'])}`;
49
+ const expectedText = `name=${column.name} type=${column.type} ` +
50
+ `notnull=${column.notNull ? 1 : 0} pk=${column.pk}`;
51
+ if (actualText !== expectedText) {
52
+ throw new Error(`snapshot table ${table} column ${index + 1} is ${actualText}, expected ${expectedText}`);
53
+ }
54
+ }
55
+ }
56
+ /** Fail unless `PRAGMA table_list` reports the declared kind and options. */
57
+ function assertTableOptions(listed, table, shape) {
58
+ const row = listed.find((candidate) => candidate['name'] === table);
59
+ if (row === undefined)
60
+ throw new Error(`snapshot table ${table} is absent from PRAGMA table_list`);
61
+ const kind = String(row['type']);
62
+ if (kind !== 'table') {
63
+ throw new Error(`snapshot table ${table} is a ${kind}, expected an ordinary table`);
64
+ }
65
+ const strict = Number(row['strict']) === 1;
66
+ if (strict !== shape.strict) {
67
+ throw new Error(`snapshot table ${table} is ${strict ? 'STRICT' : 'not STRICT'}, ` +
68
+ `expected ${shape.strict ? 'STRICT' : 'not STRICT'}`);
69
+ }
70
+ const withoutRowid = Number(row['wr']) === 1;
71
+ if (withoutRowid !== shape.withoutRowid) {
72
+ throw new Error(`snapshot table ${table} is ${withoutRowid ? 'WITHOUT ROWID' : 'a rowid table'}, ` +
73
+ `expected ${shape.withoutRowid ? 'WITHOUT ROWID' : 'a rowid table'}`);
74
+ }
75
+ }
76
+ /** Fail unless `PRAGMA foreign_key_list` matches the declared references. */
77
+ function assertForeignKeys(read, table, expected) {
78
+ const actual = read(`PRAGMA foreign_key_list(${table})`)
79
+ .map((row) => ({ from: String(row['from']), table: String(row['table']), to: String(row['to']) }))
80
+ .sort(byForeignKeyText);
81
+ const wanted = expected.map((key) => ({ ...key })).sort(byForeignKeyText);
82
+ const actualText = actual.map(foreignKeyText);
83
+ const wantedText = wanted.map(foreignKeyText);
84
+ if (actualText.join(',') !== wantedText.join(',')) {
85
+ throw new Error(`snapshot table ${table} has foreign keys [${actualText.join(', ')}], ` +
86
+ `expected [${wantedText.join(', ')}]`);
87
+ }
88
+ }
89
+ /** Fail unless every declared `CHECK` clause survives in the stored DDL. */
90
+ function assertChecks(storedSql, table, checks) {
91
+ const folded = foldSql(storedSql);
92
+ for (const check of checks) {
93
+ if (!folded.includes(foldSql(check))) {
94
+ throw new Error(`snapshot table ${table} is missing the ${check} constraint`);
95
+ }
96
+ }
97
+ }
98
+ /** Fail unless the one explicit secondary index matches its declared definition. */
99
+ function assertExplicitIndex(read) {
100
+ const index = SNAPSHOT_EXPLICIT_INDEX;
101
+ const row = read(`PRAGMA index_list(${index.table})`).find((candidate) => candidate['name'] === index.name);
102
+ if (row === undefined) {
103
+ throw new Error(`snapshot table ${index.table} is missing the ${index.name} index`);
104
+ }
105
+ const unique = Number(row['unique']) === 1;
106
+ if (unique !== index.unique) {
107
+ throw new Error(`snapshot index ${index.name} is ${unique ? 'unique' : 'not unique'}, ` +
108
+ `expected ${index.unique ? 'unique' : 'not unique'}`);
109
+ }
110
+ const partial = Number(row['partial']) === 1;
111
+ if (partial !== index.partial) {
112
+ throw new Error(`snapshot index ${index.name} is ${partial ? 'partial' : 'full'}, ` +
113
+ `expected ${index.partial ? 'partial' : 'full'}`);
114
+ }
115
+ const columns = read(`PRAGMA index_info(${index.name})`)
116
+ .map((info) => ({ seqno: Number(info['seqno']), name: String(info['name']) }))
117
+ .sort((left, right) => left.seqno - right.seqno)
118
+ .map((info) => info.name);
119
+ if (columns.join(',') !== index.columns.join(',')) {
120
+ throw new Error(`snapshot index ${index.name} is on [${columns.join(', ')}], expected [${index.columns.join(', ')}]`);
121
+ }
122
+ }
123
+ /**
124
+ * Fail unless every declared `UNIQUE` constraint survives as exactly one
125
+ * implicit index, matched by its indexed columns.
126
+ *
127
+ * SQLite names these `sqlite_autoindex_*` and the reader query filters every
128
+ * `sqlite_` name out, so this is the only check that sees them: an added
129
+ * `UNIQUE (node_id, tag_id)` — the reverse membership index the accepted
130
+ * design forbids — appears here as an extra origin-`u` row and fails closed.
131
+ * Matched by columns rather than name because SQLite generates the names.
132
+ */
133
+ function assertUniqueIndexes(read, table, expected) {
134
+ const actual = read(`PRAGMA index_list(${table})`)
135
+ .filter((row) => String(row['origin']) === 'u')
136
+ .map((row) => {
137
+ const name = String(row['name']);
138
+ const columns = read(`PRAGMA index_info(${name})`)
139
+ .map((info) => ({ seqno: Number(info['seqno']), name: String(info['name']) }))
140
+ .sort((left, right) => left.seqno - right.seqno)
141
+ .map((info) => info.name)
142
+ .join(',');
143
+ return columns;
144
+ })
145
+ .sort();
146
+ const wanted = [...expected].sort();
147
+ if (actual.join('|') !== wanted.join('|')) {
148
+ const printed = (sets) => sets.map((set) => `(${set})`).join(', ');
149
+ throw new Error(`snapshot table ${table} has implicit unique indexes on [${printed(actual)}], ` +
150
+ `expected [${printed(wanted)}]`);
151
+ }
152
+ }
153
+ /**
154
+ * Fail unless the connection carries exactly the accepted schema.
155
+ *
156
+ * @param read A function that executes one SQL statement on the imported
157
+ * snapshot. It must not write. `PRAGMA` statements and reads only.
158
+ * @throws {Error} naming the first discrepancy, so a mismatch fails closed.
159
+ */
160
+ export function assertSnapshotRows(read) {
161
+ const applicationId = read('PRAGMA application_id')[0]?.['application_id'];
162
+ if (applicationId !== SNAPSHOT_APPLICATION_ID) {
163
+ throw new Error(`snapshot application_id is ${String(applicationId)}, expected ${SNAPSHOT_APPLICATION_ID}`);
164
+ }
165
+ const userVersion = read('PRAGMA user_version')[0]?.['user_version'];
166
+ if (userVersion !== SNAPSHOT_USER_VERSION) {
167
+ throw new Error(`snapshot user_version is ${String(userVersion)}, expected ${SNAPSHOT_USER_VERSION}`);
168
+ }
169
+ const objects = read("SELECT type, name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY type, name");
170
+ const tables = objects
171
+ .filter((row) => row['type'] === 'table')
172
+ .map((row) => String(row['name']))
173
+ .sort();
174
+ const expectedTables = [...SNAPSHOT_TABLES].sort();
175
+ if (tables.join(',') !== expectedTables.join(',')) {
176
+ throw new Error(`snapshot tables are [${tables.join(', ')}], expected [${SNAPSHOT_TABLES.join(', ')}]`);
177
+ }
178
+ // `sqlite_schema` hides the UNIQUE constraints' implicit `sqlite_` indexes
179
+ // behind the query's name filter, so the index rows that remain are exactly
180
+ // the explicitly created ones. The filter matches `sqlite_` literally — the
181
+ // underscore is escaped, because an unescaped LIKE `_` is a wildcard and
182
+ // would also hide a user-creatable name such as `sqliteX`. This must equal
183
+ // the one accepted index: an added explicit reverse membership index, a
184
+ // renamed accepted index, or a missing accepted index each change the storage
185
+ // contract. `assertExplicitIndex` below still checks that index's declared
186
+ // shape, and `assertUniqueIndexes` checks the implicit indexes this filter
187
+ // hides, so a added `UNIQUE` constraint cannot slip through either spelling.
188
+ const indexes = objects
189
+ .filter((row) => row['type'] === 'index')
190
+ .map((row) => String(row['name']))
191
+ .sort();
192
+ const expectedIndexes = [SNAPSHOT_EXPLICIT_INDEX.name];
193
+ if (indexes.join(',') !== expectedIndexes.join(',')) {
194
+ throw new Error(`snapshot explicit indexes are [${indexes.join(', ')}], expected [${expectedIndexes.join(', ')}]`);
195
+ }
196
+ const otherObjects = objects.filter((row) => row['type'] !== 'table' && row['type'] !== 'index');
197
+ if (otherObjects.length > 0) {
198
+ throw new Error(`snapshot carries unexpected schema objects: ${otherObjects
199
+ .map((row) => `${String(row['type'])} ${String(row['name'])}`)
200
+ .join(', ')}`);
201
+ }
202
+ // The stored DDL is the only place a CHECK clause survives; the table-list
203
+ // pragma is the only place STRICT/WITHOUT ROWID survive.
204
+ const storedSql = new Map(objects
205
+ .filter((row) => row['type'] === 'table')
206
+ .map((row) => [String(row['name']), String(row['sql'] ?? '')]));
207
+ const listed = read('PRAGMA table_list');
208
+ for (const [table, shape] of Object.entries(SNAPSHOT_TABLE_SHAPES)) {
209
+ assertColumns(read, table, shape.columns);
210
+ assertTableOptions(listed, table, shape);
211
+ assertForeignKeys(read, table, shape.foreignKeys);
212
+ assertChecks(storedSql.get(table) ?? '', table, shape.checks);
213
+ assertUniqueIndexes(read, table, shape.uniqueConstraints);
214
+ }
215
+ assertExplicitIndex(read);
216
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The named snapshot operations, over a minimal row-reader adapter.
3
+ *
4
+ * One implementation serves the browser Worker and any native caller: the
5
+ * adapter only has to run a fixed SQL string with bound parameters and return
6
+ * rows. The semantics are `docs/core-design/sqlite-contract.md`'s: cursor
7
+ * pagination continues after the last returned slug, graph selection precedes
8
+ * induced-edge extraction, and graph degree counts distinct adjacent notes.
9
+ */
10
+ import { SNAPSHOT_QUERIES, normalizePageSize, pageOf, } from "./snapshot-queries.js";
11
+ import { rankGlobal, inducedEdges, selectLocal, GLOBAL_NODE_LIMIT } from "./graph-selection.js";
12
+ function summary(row) {
13
+ return selectionSummary(selectionNode(row));
14
+ }
15
+ function slugOf(db, slug) {
16
+ const row = db.select(SNAPSHOT_QUERIES.nodeBySlug, [slug])[0];
17
+ return row === undefined ? undefined : Number(row['id']);
18
+ }
19
+ /** Preview one published note, or `null` for an unknown or withheld slug. */
20
+ export function preview(db, slug) {
21
+ const node = db.select(SNAPSHOT_QUERIES.previewNode, [slug])[0];
22
+ if (node === undefined)
23
+ return null;
24
+ const aliases = db.select(SNAPSHOT_QUERIES.previewAliases, [Number(node['id'])]).map((row) => String(row['alias']));
25
+ return {
26
+ slug: String(node['slug']),
27
+ title: String(node['title']),
28
+ excerpt: String(node['excerpt']),
29
+ language: String(node['language']),
30
+ aliases,
31
+ };
32
+ }
33
+ /** One page of outgoing or backlinks for a known subject. */
34
+ export function edgePage(db, direction, slug, cursor, pageSize) {
35
+ const id = slugOf(db, slug);
36
+ if (id === undefined)
37
+ return { known: false, page: { notes: [], nextCursor: null } };
38
+ const size = normalizePageSize(pageSize);
39
+ const first = direction === 'outgoing' ? SNAPSHOT_QUERIES.outgoingFirst : SNAPSHOT_QUERIES.backlinksFirst;
40
+ const after = direction === 'outgoing' ? SNAPSHOT_QUERIES.outgoingAfter : SNAPSHOT_QUERIES.backlinksAfter;
41
+ const rows = cursor === undefined || cursor === null
42
+ ? db.select(first, [id, size + 1])
43
+ : db.select(after, [id, cursor, size + 1]);
44
+ const { items, nextCursor } = pageOf(rows.map(summary), size);
45
+ return { known: true, page: { notes: items, nextCursor } };
46
+ }
47
+ /** One page of notes carrying a canonical tag, or an unknown-tag result. */
48
+ export function tagPage(db, tagKey, cursor, pageSize) {
49
+ const tag = db.select(SNAPSHOT_QUERIES.tagByKey, [tagKey])[0];
50
+ if (tag === undefined)
51
+ return { known: false };
52
+ const size = normalizePageSize(pageSize);
53
+ const rows = cursor === undefined || cursor === null
54
+ ? db.select(SNAPSHOT_QUERIES.byTagFirst, [tagKey, size + 1])
55
+ : db.select(SNAPSHOT_QUERIES.byTagAfter, [tagKey, cursor, size + 1]);
56
+ const { items, nextCursor } = pageOf(rows.map(summary), size);
57
+ return { known: true, tag: { key: String(tag['key']), label: String(tag['label']) }, notes: items, nextCursor };
58
+ }
59
+ /** One node as the shared selection contract sees it. */
60
+ function selectionNode(row) {
61
+ return {
62
+ id: Number(row['id']),
63
+ slug: String(row['slug']),
64
+ title: String(row['title']),
65
+ language: String(row['language']),
66
+ };
67
+ }
68
+ /**
69
+ * The directed edges whose two endpoints are all in `ids`.
70
+ *
71
+ * Bound placeholders, never interpolated data. This is the contract's "after
72
+ * choosing the displayed nodes, query all directed edges whose two endpoints
73
+ * belong to that set": scanning every edge in the corpus made a 10,000-note
74
+ * local graph O(corpus) per request and missed the warm target.
75
+ */
76
+ function edgesAmongIds(db, ids) {
77
+ if (ids.length === 0)
78
+ return [];
79
+ const placeholders = ids.map(() => '?').join(',');
80
+ const sql = `SELECT s.slug AS source, t.slug AS target FROM edges AS e ` +
81
+ `JOIN nodes AS s ON s.id = e.source_id JOIN nodes AS t ON t.id = e.target_id ` +
82
+ `WHERE e.source_id IN (${placeholders}) AND e.target_id IN (${placeholders}) ` +
83
+ `ORDER BY source, target`;
84
+ return db.select(sql, [...ids, ...ids]).map((row) => ({
85
+ from: row.source,
86
+ to: row.target,
87
+ }));
88
+ }
89
+ /** The public summary of one selection node. */
90
+ function selectionSummary(node) {
91
+ return { slug: node.slug, title: node.title, language: node.language };
92
+ }
93
+ /** `nodeDegrees`/`tagNodeDegrees` rows as the rank function's degree lookup. */
94
+ function degreeMap(rows) {
95
+ return new Map(rows.map((row) => [Number(row['id']), Number(row['degree'])]));
96
+ }
97
+ /**
98
+ * The one-hop neighbourhood of a known center.
99
+ *
100
+ * The SQL `UNION` deduplicates incoming/outgoing and the shared selection
101
+ * contract sorts, limits, and extracts induced edges, so the Worker and the
102
+ * build cannot disagree about the drawn set.
103
+ */
104
+ export function localGraph(db, slug) {
105
+ const center = db.select(SNAPSHOT_QUERIES.nodeBySlug, [slug])[0];
106
+ if (center === undefined)
107
+ return null;
108
+ const centerNode = selectionNode(center);
109
+ const neighbours = db
110
+ .select(SNAPSHOT_QUERIES.neighbors, [centerNode.id, centerNode.id])
111
+ .map(selectionNode);
112
+ // Choose the drawn set first, then read only the edges among it.
113
+ const preliminary = selectLocal(centerNode, neighbours, []);
114
+ const ids = [centerNode.id, ...preliminary.drawn.map((node) => node.id)];
115
+ const selected = new Set([centerNode.slug, ...preliminary.drawn.map((node) => node.slug)]);
116
+ return {
117
+ center: selectionSummary(centerNode),
118
+ nodes: preliminary.drawn.map(selectionSummary),
119
+ edges: inducedEdges(selected, edgesAmongIds(db, ids)),
120
+ omitted: preliminary.omitted,
121
+ };
122
+ }
123
+ /**
124
+ * The ranked global graph, optionally restricted to one tag's members.
125
+ *
126
+ * Ranking has one authority: `rankGlobal` orders by degree in the candidate
127
+ * graph, descending, then title/slug. The unfiltered case takes degrees from
128
+ * one aggregate over the edge set; the filtered case takes the tag's member
129
+ * rows and their in-tag degrees from tag-scoped statements, so filtering never
130
+ * pulls the corpus' edge rows into JS. Only after the drawn set is chosen are
131
+ * the directed edges among it read.
132
+ */
133
+ export function globalGraph(db, tagKey) {
134
+ const filtered = tagKey !== undefined && tagKey !== null;
135
+ const candidates = db
136
+ .select(filtered ? SNAPSHOT_QUERIES.tagNodes : SNAPSHOT_QUERIES.allNodes, filtered ? [tagKey] : [])
137
+ .map(selectionNode);
138
+ const degrees = degreeMap(db.select(filtered ? SNAPSHOT_QUERIES.tagNodeDegrees : SNAPSHOT_QUERIES.nodeDegrees, filtered ? [tagKey] : []));
139
+ const ranked = rankGlobal(candidates, (node) => degrees.get(node.id) ?? 0);
140
+ const selected = ranked.slice(0, GLOBAL_NODE_LIMIT);
141
+ const selectedSlugs = new Set(selected.map((node) => node.slug));
142
+ return {
143
+ nodes: selected.map(selectionSummary),
144
+ edges: inducedEdges(selectedSlugs, edgesAmongIds(db, selected.map((node) => node.id))),
145
+ omitted: ranked.length - selected.length,
146
+ };
147
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Named query surface over the public snapshot.
3
+ *
4
+ * One module owns the fixed, parameterized SQL and the typed shape of every
5
+ * result, so the native build driver (`node:sqlite`) and the browser WASM driver
6
+ * run the same statements rather than two reconstructions that can disagree.
7
+ * Execution is the adapter's job; this file is SQL, row types, and the pure
8
+ * pagination/selection rules.
9
+ *
10
+ * `docs/core-design/sqlite-contract.md` owns the semantics, especially:
11
+ * pagination continues **after the last returned slug** and never from the
12
+ * lookahead row, and graph selection happens **before** induced-edge extraction.
13
+ */
14
+ import { isNoteSlug } from "./route-path.js";
15
+ /**
16
+ * Every statement the snapshot contract exposes. Named parameters are positional
17
+ * (`?`) rather than named because both drivers bind arrays identically and no
18
+ * statement needs the same value twice.
19
+ */
20
+ export const SNAPSHOT_QUERIES = {
21
+ previewNode: `
22
+ SELECT id, slug, title, excerpt, language
23
+ FROM nodes
24
+ WHERE slug = ?`.trim(),
25
+ previewAliases: `
26
+ SELECT alias
27
+ FROM aliases
28
+ WHERE node_id = ?
29
+ ORDER BY ordinal`.trim(),
30
+ outgoingFirst: `
31
+ SELECT n.slug, n.title, n.language
32
+ FROM edges AS e
33
+ JOIN nodes AS n ON n.id = e.target_id
34
+ WHERE e.source_id = ?
35
+ ORDER BY n.slug
36
+ LIMIT ?`.trim(),
37
+ outgoingAfter: `
38
+ SELECT n.slug, n.title, n.language
39
+ FROM edges AS e
40
+ JOIN nodes AS n ON n.id = e.target_id
41
+ WHERE e.source_id = ? AND n.slug > ?
42
+ ORDER BY n.slug
43
+ LIMIT ?`.trim(),
44
+ backlinksFirst: `
45
+ SELECT n.slug, n.title, n.language
46
+ FROM edges AS e
47
+ JOIN nodes AS n ON n.id = e.source_id
48
+ WHERE e.target_id = ?
49
+ ORDER BY n.slug
50
+ LIMIT ?`.trim(),
51
+ backlinksAfter: `
52
+ SELECT n.slug, n.title, n.language
53
+ FROM edges AS e
54
+ JOIN nodes AS n ON n.id = e.source_id
55
+ WHERE e.target_id = ? AND n.slug > ?
56
+ ORDER BY n.slug
57
+ LIMIT ?`.trim(),
58
+ tagByKey: `
59
+ SELECT id, key, label
60
+ FROM tags
61
+ WHERE key = ?`.trim(),
62
+ byTagFirst: `
63
+ SELECT n.slug, n.title, n.language
64
+ FROM tags AS t
65
+ JOIN node_tags AS nt ON nt.tag_id = t.id
66
+ JOIN nodes AS n ON n.id = nt.node_id
67
+ WHERE t.key = ?
68
+ ORDER BY n.slug
69
+ LIMIT ?`.trim(),
70
+ byTagAfter: `
71
+ SELECT n.slug, n.title, n.language
72
+ FROM tags AS t
73
+ JOIN node_tags AS nt ON nt.tag_id = t.id
74
+ JOIN nodes AS n ON n.id = nt.node_id
75
+ WHERE t.key = ? AND n.slug > ?
76
+ ORDER BY n.slug
77
+ LIMIT ?`.trim(),
78
+ /**
79
+ * One-hop neighbours, incoming and outgoing unioned once. The union is over
80
+ * ids, so a reciprocal pair appears once and the center is never a neighbour.
81
+ */
82
+ neighbors: `
83
+ WITH neighbors(id) AS (
84
+ SELECT target_id FROM edges WHERE source_id = ?
85
+ UNION
86
+ SELECT source_id FROM edges WHERE target_id = ?
87
+ )
88
+ SELECT n.id, n.slug, n.title, n.language
89
+ FROM neighbors AS x
90
+ JOIN nodes AS n ON n.id = x.id
91
+ ORDER BY n.slug`.trim(),
92
+ nodeBySlug: `
93
+ SELECT id, slug, title, language
94
+ FROM nodes
95
+ WHERE slug = ?`.trim(),
96
+ allNodes: `
97
+ SELECT id, slug, title, language
98
+ FROM nodes
99
+ ORDER BY slug`.trim(),
100
+ allEdges: `
101
+ SELECT source_id, target_id
102
+ FROM edges
103
+ ORDER BY source_id, target_id`.trim(),
104
+ /**
105
+ * Every tagged note, with its tag's canonical key and label.
106
+ *
107
+ * The build reads this once and groups in memory, which is the access path
108
+ * `docs/core-design/sqlite-contract.md` names for "build-time tags for all
109
+ * notes": `node_tags` is scanned through its primary key and the two joins
110
+ * probe `tags` and `nodes` by primary key, so the cost is the membership
111
+ * count and never an N-per-page scan. `slug` before `key` is the grouping
112
+ * order the reader wants; the `ORDER BY` is explicit rather than left to the
113
+ * tables' storage order, so two readers see the rows in one order.
114
+ */
115
+ allNodeTags: `
116
+ SELECT n.slug, t.key, t.label
117
+ FROM node_tags AS nt
118
+ JOIN tags AS t ON t.id = nt.tag_id
119
+ JOIN nodes AS n ON n.id = nt.node_id
120
+ ORDER BY n.slug, t.key`.trim(),
121
+ /** Distinct adjacent notes per node, as one aggregate over the edge set. */
122
+ nodeDegrees: `
123
+ SELECT node_id AS id, COUNT(DISTINCT neighbour) AS degree FROM (
124
+ SELECT source_id AS node_id, target_id AS neighbour FROM edges
125
+ UNION
126
+ SELECT target_id AS node_id, source_id AS neighbour FROM edges
127
+ )
128
+ GROUP BY node_id`.trim(),
129
+ /** A tag's member nodes. The tag key is the only parameter. */
130
+ tagNodes: `
131
+ SELECT n.id, n.slug, n.title, n.language
132
+ FROM tags AS t
133
+ JOIN node_tags AS nt ON nt.tag_id = t.id
134
+ JOIN nodes AS n ON n.id = nt.node_id
135
+ WHERE t.key = ?
136
+ ORDER BY n.slug`.trim(),
137
+ /**
138
+ * Distinct adjacent notes per node **within one tag's subgraph**, mirroring
139
+ * `nodeDegrees` scoped to a tag: both endpoints must be members, and the
140
+ * `UNION` plus `COUNT(DISTINCT neighbour)` make a reciprocal pair one
141
+ * neighbour, as `selectGlobal` computes it. Each branch is driven by the
142
+ * tag's memberships: the first searches out-edges through the edge primary
143
+ * key, the second searches in-edges through `edges_by_target`, and both probe
144
+ * the other endpoint's membership by primary key. Cost follows the members'
145
+ * edges rather than a member-by-member `IN (members)` list, which makes
146
+ * SQLite probe the member cross-product and degrades with tag size. A member
147
+ * with no in-tag edge has no row; callers treat a missing id as degree 0,
148
+ * which is also how `nodeDegrees` covers an isolated node.
149
+ */
150
+ tagNodeDegrees: `
151
+ WITH members(id) AS (
152
+ SELECT nt.node_id
153
+ FROM tags AS t
154
+ JOIN node_tags AS nt ON nt.tag_id = t.id
155
+ WHERE t.key = ?
156
+ )
157
+ SELECT node_id AS id, COUNT(DISTINCT neighbour) AS degree FROM (
158
+ SELECT e.source_id AS node_id, e.target_id AS neighbour
159
+ FROM edges AS e
160
+ JOIN members AS s ON s.id = e.source_id
161
+ JOIN members AS g ON g.id = e.target_id
162
+ UNION
163
+ SELECT e.target_id AS node_id, e.source_id AS neighbour
164
+ FROM edges AS e
165
+ JOIN members AS g ON g.id = e.target_id
166
+ JOIN members AS s ON s.id = e.source_id
167
+ )
168
+ GROUP BY node_id`.trim(),
169
+ };
170
+ /**
171
+ * Turn up to `pageSize + 1` ordered rows into one page.
172
+ *
173
+ * `nextCursor` is the last **returned** slug, never the lookahead row's; a
174
+ * cursor built from the extra row skips a real result on the next request.
175
+ */
176
+ export function pageOf(rows, pageSize) {
177
+ const items = rows.slice(0, pageSize);
178
+ const nextCursor = rows.length > pageSize ? (items.at(-1)?.slug ?? null) : null;
179
+ return { items, nextCursor };
180
+ }
181
+ /**
182
+ * Whether a slug is a plausible lookup key before a query is attempted.
183
+ *
184
+ * Uses the shared note-slug grammar (`isNoteSlug`) rather than only a length
185
+ * bound: SQL binds the value as a parameter, so a traversal string cannot
186
+ * escape, but a request that is not a slug is a caller defect that should fail
187
+ * as `bad-argument` rather than silently return "no match".
188
+ */
189
+ export function isLookupSlug(value) {
190
+ return typeof value === 'string' && value.length > 0 && isNoteSlug(value);
191
+ }
192
+ /** Route-key characters a canonical tag key may carry; no separators or controls. */
193
+ const LOOKUP_TAG_INVALID = /[/\p{Cc}\p{Cf}]/u;
194
+ /**
195
+ * Whether a canonical tag key is plausible before a query is attempted.
196
+ *
197
+ * Wider than a slug because a tag key may be any script (including CJK and
198
+ * combining marks) and may contain `_`; it must still be one path segment.
199
+ */
200
+ export function isLookupTagKey(value) {
201
+ return (typeof value === 'string' &&
202
+ value.length > 0 &&
203
+ value.length <= 128 &&
204
+ !LOOKUP_TAG_INVALID.test(value));
205
+ }
206
+ /**
207
+ * Bound a requested page size to the accepted range. The Worker validates this
208
+ * rather than trusting a caller-supplied number; LIMIT bounds output, not work.
209
+ */
210
+ export const DEFAULT_PAGE_SIZE = 50;
211
+ export const MAX_PAGE_SIZE = 200;
212
+ export function normalizePageSize(value) {
213
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0)
214
+ return DEFAULT_PAGE_SIZE;
215
+ return Math.min(value, MAX_PAGE_SIZE);
216
+ }