@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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- 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
|
+
}
|