@heroiclands/package-build 25.5.2 → 25.5.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.5.4
4
+
5
+ **Builds and commands**
6
+
7
+ - Content builds use less memory when preparing, checking and compiling notes.
8
+
9
+ ## 25.5.3
10
+
11
+ **Verse**
12
+
13
+ - Every `--- {.canto}` break draws a short centred rule on the site, in Foundry journals and in the book, whether or not it carries a title.
14
+ - Decoded verse ends each canto mark with a line break, so the decoded text can be encoded again without the next word running into the mark.
15
+ - Metre checks count only the verse itself, skipping speaker labels and stage directions.
16
+ - Metre checks read a word's inherited word class, and accept every beat count a word with several senses can take.
17
+ - A stanza ending in a footnote reference keeps its stanza mark in the native script, and the reference stays on the roman reading.
18
+ - A line holding only spaces ends a verse stanza, just as an empty line does.
19
+
3
20
  ## 25.5.2
4
21
 
5
22
  **Languages and scripts**
@@ -239,7 +239,9 @@
239
239
  .verse
240
240
  .verse-canto-break {
241
241
  display: block;
242
- height: 0.5em;
242
+ width: 25%;
243
+ margin: 1.5em auto;
244
+ border-block-start: 1px solid currentColor;
243
245
  }
244
246
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
245
247
  .verse-paired
@@ -411,7 +411,7 @@ Foundry renders a secret section; the website renders an expandable spoiler, and
411
411
 
412
412
  A `.verse` div sets a version of a work, whether poetry or prose. `/` ends a
413
413
  logical line. Source newlines otherwise join with whitespace, so prose can wrap
414
- across source lines. A blank line or `//` ends a stanza (a paragraph); a line
414
+ across source lines. A blank line, including one containing only spaces, or `//` ends a stanza (a paragraph); a line
415
415
  of `--- {.canto}`, preceded by a blank line, ends a canto (a section). The last line before either
416
416
  boundary needs no `/`. A literal slash in text is written `\/`; slashes inside
417
417
  Markdown syntax, such as a link destination, remain syntax.
@@ -488,6 +488,9 @@ language's stress rules; it reports an error at the line:
488
488
 
489
489
  > `` `scan=` counts one language’s beats; this verse line contains another language ``
490
490
 
491
+ Speaker labels and stage directions (`.speaker` and `.direction`) are presentation
492
+ only: their text is not scanned, and a line containing only those spans is skipped.
493
+
491
494
  Beats are counted this way:
492
495
 
493
496
  - **Syllables** are vowel groups. A vowel is a letter whose base is `a`, `e`,
@@ -500,8 +503,10 @@ Beats are counted this way:
500
503
  - **A one-syllable noun, verb, adjective, numeral or name has a beat.** A
501
504
  one-syllable particle, preposition, conjunction, adverb or pronoun has none.
502
505
  The `class` of the lexicon entry decides, so the language's lexicon is read
503
- through `lang=` as it is for the word check, and a form the language's rules
504
- derive takes its word's class. A word whose entries differ in class is a
506
+ through `lang=` as it is for the word check, including words inherited from
507
+ its family. All entries for a spelling in the nearest lexicon supply their
508
+ classes. A form the
509
+ language's own rules derive takes its word's class. A word whose entries differ in class is a
505
510
  beat that may be left out.
506
511
  - **A one-syllable word the lexicon does not hold is a content word** and has a
507
512
  beat. The word check reports the word separately.
@@ -649,6 +654,8 @@ definitions.
649
654
  #### Canto headings
650
655
 
651
656
  A canto break stands alone after a blank line, with a stanza on both sides.
657
+ It draws a short centred rule on the site, in Foundry journals and in the book,
658
+ with extra space above and below to distinguish it from a stanza break.
652
659
  Its optional `title` appears as a heading and its optional `#id` is an anchor
653
660
  within the work, without starting a separate Foundry journal page:
654
661
 
@@ -542,8 +542,11 @@ The title is shown once as heading text; the encoder receives its Roman `***`
542
542
  canto control. Authored `***` inside verse is a finding. The canto stays within
543
543
  the work and does not open a separate journal page.
544
544
 
545
- The blank line before a canto retains its stanza mark in the encoded text.
546
- Whitespace after the canto control adds no extra line mark.
545
+ The blank line before a canto retains its stanza mark in the encoded text,
546
+ including when the last word has a footnote reference.
547
+ Whitespace after the canto control adds no extra line mark. Decoding writes
548
+ `***` followed by an LF newline, so the next word starts on a new line and
549
+ the decoded Roman text can be encoded again.
547
550
 
548
551
  ### Versions of a work
549
552
 
@@ -336,7 +336,7 @@ Caption classes are unrestricted. The built-in `border` class draws the block as
336
336
  A `.verse` div reaches the page as `.verse`, with one `.verse-stanza` per
337
337
  stanza and `.verse-line` per logical line. Indentation uses `i1` through `i8`;
338
338
  deeper levels carry their complete `--verse-indent`. `.verse-canto-break`
339
- separates cantos. A work-labelled div adds `.verse-labelled`, and each stanza
339
+ draws a short centred rule between cantos. A work-labelled div adds `.verse-labelled`, and each stanza
340
340
  opens with a small `.verse-label` shared across its presentations.
341
341
 
342
342
  A concrete div encoding adds `.verse-paired`. Each `.verse-row` aligns one
@@ -2595,7 +2595,13 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2595
2595
  });
2596
2596
  if (positions.length)
2597
2597
  patches.push({ from: positions[0], to: positions.at(-1) + 1, text: edit.text });
2598
- else if (edit.from === edit.to && edit.from === base + line.to)
2598
+ else if (
2599
+ (edit.from === edit.to && edit.from === base + line.to) ||
2600
+ (edit.from > base + (line.offsets.at(-1) ?? line.from) &&
2601
+ edit.to <= base + line.to)
2602
+ )
2603
+ // Trailing source whitespace has no logical-text offsets.
2604
+ // It may still carry a stanza control after a footnote hole.
2599
2605
  patches.push({ from: line.text.length, to: line.text.length, text: edit.text });
2600
2606
  }
2601
2607
  let result = line.text;
@@ -69,7 +69,7 @@ export const CONTENT_TARGETS = Object.freeze(["index", "lint", "links", "compile
69
69
  /**
70
70
  * Capture one source tree for index, lint, links and compilation.
71
71
  * No source or parser state is persisted or reused by another invocation.
72
- * Prepared SQL databases close within their existing preparation functions.
72
+ * Session-owned SQL resources close after both audiences settle, or on dispose.
73
73
  * @param {object} [options]
74
74
  * @param {import("../content-config.mjs").ContentBuildConfig} [options.config] - Exact resolved configuration.
75
75
  * @param {string} [options.contentBase] - Content root, defaulting to configuration.
@@ -118,7 +118,13 @@ export async function createContentSession({
118
118
  );
119
119
  const syntaxCache = new Map();
120
120
  const prepared = new Map();
121
+ const settledSqlAudiences = new Set();
121
122
  let disposed = false;
123
+ let sqlScope;
124
+ const releaseSqlScope = () => {
125
+ sqlScope?.dispose();
126
+ sqlScope = undefined;
127
+ };
122
128
  const active = () => {
123
129
  if (disposed) throw new Error("Content session has been disposed");
124
130
  };
@@ -200,17 +206,36 @@ export async function createContentSession({
200
206
  async sqlTables(audience = "all") {
201
207
  if (audience !== "all" && audience !== "public")
202
208
  throw new Error(`Unknown SQL audience: ${audience}`);
203
- return memo(`sql:${audience}`, () =>
204
- sql.prepareTreeSqlTables(tree, {
205
- config,
206
- records,
207
- parsed,
208
- audience,
209
- skipDirectories: scope,
210
- foreignIndex: foreign.index,
211
- syntax,
212
- }),
213
- );
209
+ return memo(`sql:${audience}`, async () => {
210
+ try {
211
+ return await sql.prepareTreeSqlTables(tree, {
212
+ config,
213
+ records,
214
+ parsed,
215
+ audience,
216
+ skipDirectories: scope,
217
+ foreignIndex: foreign.index,
218
+ syntax,
219
+ database: async () => {
220
+ active();
221
+ if (!sqlScope) {
222
+ const dependencies = await sql.cachedDependencies(config);
223
+ active();
224
+ sqlScope ??= sql.createNotesDatabaseScope(records, {
225
+ dependencies,
226
+ contentBase: tree,
227
+ });
228
+ }
229
+ return sqlScope.database(audience);
230
+ },
231
+ });
232
+ } finally {
233
+ // Each audience has one cached preparation. Both settling
234
+ // drains every query while retaining the rendered results.
235
+ settledSqlAudiences.add(audience);
236
+ if (settledSqlAudiences.size === 2) releaseSqlScope();
237
+ }
238
+ });
214
239
  },
215
240
  async linkTables() {
216
241
  return memo("link-tables", () =>
@@ -289,9 +314,17 @@ export async function createContentSession({
289
314
  },
290
315
  /** Release this invocation's derived views; captured inputs stay unchanged. */
291
316
  dispose() {
317
+ if (disposed) return;
292
318
  disposed = true;
319
+ // A preparation can still be querying after its database opens.
320
+ // Keep the promises before clearing memoized results, and drain
321
+ // both successful and failed work before closing native resources.
322
+ const pending = [...prepared.values()];
323
+ void Promise.allSettled(pending).then(() => {
324
+ releaseSqlScope();
325
+ syntaxCache.clear();
326
+ });
293
327
  prepared.clear();
294
- syntaxCache.clear();
295
328
  },
296
329
  };
297
330
  return session;
@@ -569,7 +569,8 @@ export function parseVerse(body, { inline = false, footnotes = new Set() } = {})
569
569
  const length = source[at] === "\r" && source[at + 1] === "\n" ? 2 : 1;
570
570
  if (text && !/\s$/.test(text)) append(" ", at);
571
571
  at += length;
572
- while (source[at] === " ") at++;
572
+ // Leave blank physical lines at their start so they end the stanza.
573
+ if (!/^[ \t]*(?:\r\n|\r|\n|$)/.test(source.slice(at))) while (source[at] === " ") at++;
573
574
  continue;
574
575
  }
575
576
  if (!text && source[at] === " ") {
@@ -229,8 +229,9 @@ function languageNotes(languages, contentPackage, records) {
229
229
 
230
230
  /**
231
231
  * Each configured language's lexicon, read from the language note its `note`
232
- * names and its family chain. The nearest exact NFC form supplies the whole
233
- * word; grammar, registers and other settings remain the daughter's own.
232
+ * names and its family chain. All entries for an exact NFC form come from
233
+ * the nearest lexicon declaring it; grammar, registers and other settings
234
+ * remain the daughter's own.
234
235
  * A language with no `note`, or one naming no language note, has none.
235
236
  *
236
237
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
@@ -259,8 +260,10 @@ export function lexiconsFromRecords(records, config = {}) {
259
260
  const chain = lineageChain(key, lineage);
260
261
  let data = resolvedNativeData(record.data, record.package ?? config.contentPackage);
261
262
  if (chain.length > 1) {
262
- const words = new Map();
263
- for (const ancestor of chain)
263
+ const words = [];
264
+ const inherited = new Set();
265
+ for (const ancestor of chain) {
266
+ const forms = new Set();
264
267
  for (const word of listOf(
265
268
  resolvedNativeData(
266
269
  byKey.get(ancestor).data,
@@ -268,12 +271,15 @@ export function lexiconsFromRecords(records, config = {}) {
268
271
  )?.words,
269
272
  )) {
270
273
  if (!isMap(word) || typeof word.form !== "string") continue;
271
- // The nearest complete definition wins before word matching
272
- // folds case or splits compounds. Only words are inherited.
273
274
  const form = word.form.normalize("NFC");
274
- if (!words.has(form)) words.set(form, word);
275
+ // Retain every sense in the nearest generation, in authored
276
+ // order, before hiding that form in more distant ancestors.
277
+ if (!inherited.has(form)) words.push(word);
278
+ forms.add(form);
275
279
  }
276
- data = { ...data, words: [...words.values()] };
280
+ for (const form of forms) inherited.add(form);
281
+ }
282
+ data = { ...data, words };
277
283
  }
278
284
  lexicons.set(name, lexiconOf(name, data));
279
285
  }
@@ -256,7 +256,7 @@ export function compileRows(definition, metadata = {}) {
256
256
  const prefix = d.separator ? [separatorRow] : [];
257
257
  const controlRows = [];
258
258
  if (controls.verseStanza) controlRows.push({ from: "\n\n", to: controls.verseStanza });
259
- if (controls.verseCanto) controlRows.push({ from: "***", to: controls.verseCanto });
259
+ if (controls.verseCanto) controlRows.push({ from: "***\n", to: controls.verseCanto });
260
260
  const shared = tier(sharedRows, [], [...prefix, ...controlRows]);
261
261
  const faces = new Map();
262
262
  if (d.faces !== undefined && !object(d.faces))
@@ -1297,7 +1297,9 @@ function workVerse(token, poem, ctx) {
1297
1297
  `#heading(level: 3, outlined: false, bookmarked: false)[${renderInline({ children: group.titleChildren }, ctx)}]`
1298
1298
  : "";
1299
1299
  const anchor = group.id ? ` <${sectionLabel(ctx.anchorPrefix, group.id)}>` : "";
1300
- return `${mark ? scriptWrapped(mark, nativeClasses, nativeAttributes) + "\n" : ""}#v(0.5em)${heading ? "\n" + heading : ""}${anchor}`;
1300
+ const rule =
1301
+ "#v(0.75em)\n#align(center)[#line(length: 25%, stroke: 0.4pt)]\n#v(0.75em)";
1302
+ return `${mark ? scriptWrapped(mark, nativeClasses, nativeAttributes) + "\n" : ""}${rule}${heading ? "\n" + heading : ""}${anchor}`;
1301
1303
  }
1302
1304
  const label = labelled ? `#text(size: 0.75em, fill: luma(45%))[${group.label}]` : "";
1303
1305
  const lineText = (line, roman = false) => {
@@ -217,23 +217,25 @@ export function findSqlBlocks(markdown) {
217
217
  return blocks;
218
218
  }
219
219
 
220
- /** Keep encoding source keys as case-sensitive values in the SQL projection.
220
+ /** Omit the Foundry export from SQL; the canonical index stays intact.
221
+ * Keep encoding source keys as case-sensitive values in the SQL projection.
221
222
  * The canonical index retains its authored mapping object; DuckDB structs
222
223
  * cannot distinguish a and A as field names.
223
224
  */
224
225
  function sqlRecord(record) {
225
- const mappings = record?.data?.mappings;
226
+ const { foundry, ...projected } = record;
227
+ const mappings = projected?.data?.mappings;
226
228
  if (
227
229
  record?.type !== "doc" ||
228
230
  !mappings ||
229
231
  typeof mappings !== "object" ||
230
232
  Array.isArray(mappings)
231
233
  )
232
- return record;
234
+ return projected;
233
235
  return {
234
- ...record,
236
+ ...projected,
235
237
  data: {
236
- ...record.data,
238
+ ...projected.data,
237
239
  mappings: Object.entries(mappings).map(([from, value]) => ({
238
240
  from,
239
241
  codes: Array.isArray(value) ? value : [value],
@@ -286,223 +288,366 @@ function sqlRecord(record) {
286
288
  * @param {object} [opts.addressContext] - Address resolution context.
287
289
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes from
288
290
  * local and dependency relations.
291
+ * @param {boolean} [opts.testMemoryLimit] - Test-only fixed 256 MB budget;
292
+ * production always uses 2 GB. This is not authored configuration.
289
293
  * @param {string} [opts.contentBase] - The directory the records' paths are
290
294
  * relative to, so a refused key is reported at its file and line.
291
- * @returns {Promise<{query: (sql: string) => Promise<object[]>,
292
- * close: () => Promise<void>}>} The open database.
295
+ * @returns {ReturnType<typeof openDatabase>} The open database.
293
296
  */
294
- export async function openNotesDatabase(
297
+ export async function openNotesDatabase(records, opts = {}) {
298
+ return openDatabase(records, opts);
299
+ }
300
+
301
+ /**
302
+ * Open one audience connection, optionally in a session-owned instance.
303
+ * @param {object[]} records - Content-index records.
304
+ * @param {NonNullable<Parameters<typeof openNotesDatabase>[1]>} [opts] - Load options.
305
+ * @param {object} [shared] - Session-owned SQL resources.
306
+ */
307
+ async function openDatabase(
295
308
  records,
296
- { dir, dependencies = [], addressContext, audience = "all", contentBase } = {},
309
+ {
310
+ dir,
311
+ dependencies = [],
312
+ addressContext,
313
+ audience = "all",
314
+ contentBase,
315
+ testMemoryLimit = false,
316
+ } = {},
317
+ shared,
297
318
  ) {
298
319
  const { DuckDBInstance } = await import("@duckdb/node-api");
299
- const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
320
+ const base = shared?.base ?? dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
300
321
  fs.mkdirSync(base, { recursive: true });
301
322
  const jsonl = path.join(base, "notes.jsonl");
302
323
  const encoded = records.map((record) => sqlRecord(encodeAddresses(record)));
303
324
 
304
- const instance = await DuckDBInstance.create(":memory:");
305
- const connection = await instance.connect();
306
- await connection.run("SET threads=1");
325
+ const memoryLimit = testMemoryLimit === true ? "256 MB" : "2 GB";
326
+ let instance;
327
+ let connection;
328
+ let load = "local relations";
329
+ const close = () => {
330
+ try {
331
+ connection?.closeSync();
332
+ } finally {
333
+ try {
334
+ if (!shared) instance?.closeSync();
335
+ else shared.connections.delete(connection);
336
+ } finally {
337
+ if (!shared && !dir) fs.rmSync(base, { recursive: true, force: true });
338
+ }
339
+ }
340
+ };
307
341
  try {
308
- await createRelations(connection, jsonl, encoded, "", audience);
309
- } catch (err) {
310
- // DuckDB names the clashing key but not the notes that spell it.
311
- if (/Duplicate name ".*" in struct/.test(String(err?.message))) {
312
- const lines = keyCaseCollisionDiagnostics(encoded, { contentBase });
313
- if (lines.length) {
314
- const located = new Error(lines.join("\n"));
315
- /** @type {any} */ (located).located = true;
316
- throw located;
342
+ instance =
343
+ shared?.instance ??
344
+ (await DuckDBInstance.create(":memory:", { memory_limit: memoryLimit }));
345
+ if (shared) shared.instance = instance;
346
+ connection = await instance.connect();
347
+ if (shared) shared.connections.add(connection);
348
+ await connection.run("SET threads=1");
349
+ if (shared) {
350
+ // Each connection resolves main.* and dependency schemas inside its
351
+ // own catalog. Only the raw wrapped records live in the base catalog.
352
+ const catalog = `"__sql_${audience}"`;
353
+ await connection.run(`ATTACH ':memory:' AS ${catalog}`);
354
+ await connection.run(`USE ${catalog}`);
355
+ }
356
+ try {
357
+ await createRelations(connection, jsonl, encoded, "", audience, shared);
358
+ } catch (err) {
359
+ // DuckDB names the clashing key but not the notes that spell it.
360
+ if (/Duplicate name ".*" in struct/.test(String(err?.message))) {
361
+ const lines = keyCaseCollisionDiagnostics(encoded, { contentBase });
362
+ if (lines.length) {
363
+ const located = new Error(lines.join("\n"));
364
+ /** @type {any} */ (located).located = true;
365
+ throw located;
366
+ }
317
367
  }
368
+ throw err;
318
369
  }
319
- throw err;
320
- }
321
- await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
322
- await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
323
- await createRunRelation(connection, path.join(base, "runs.jsonl"), encoded, "", audience);
324
- await createNativeRelation(connection, path.join(base, "natives.jsonl"), encoded, "", audience);
325
- await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
326
-
327
- // One schema per declared dependency, so `FROM sohl.notes` reads the notes
328
- // that package published. Quoted, because a package id may carry a hyphen
329
- // (`sohl-thalorna`) and an unquoted identifier may not. Both views are
330
- // created, so `FROM sohl.entries` reads a dependency's stubs the same way.
331
- for (const dep of dependencies) {
332
- if (!dep?.id || !dep?.file || !fs.existsSync(dep.file)) continue;
333
- const schema = `"${String(dep.id).replace(/"/g, '""')}"`;
334
- await connection.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
335
- // A published index writes each Address as an object; a fence compares
336
- // the string an author writes, so each is flattened to it on load.
337
- const flat = path.join(
338
- base,
339
- `dependency-${String(dep.id).replace(/[^a-z0-9]/gi, "_")}.jsonl`,
340
- );
341
- const depRecords = fs
342
- .readFileSync(dep.file, "utf8")
343
- .split("\n")
344
- .filter((line) => line.trim())
345
- .map((line) => sqlRecord(flattenPublishedAddresses(JSON.parse(line))));
346
- await createRelations(connection, flat, depRecords, `${schema}.`, audience);
347
370
  await createEventRelation(
348
371
  connection,
349
- flat.replace(/\.jsonl$/, "-events.jsonl"),
350
- depRecords,
351
- `${schema}.`,
352
- audience,
353
- );
354
- await createNameRelation(
355
- connection,
356
- flat.replace(/\.jsonl$/, "-names.jsonl"),
357
- depRecords,
358
- `${schema}.`,
359
- audience,
360
- );
361
- await createRunRelation(
362
- connection,
363
- flat.replace(/\.jsonl$/, "-runs.jsonl"),
364
- depRecords,
365
- `${schema}.`,
372
+ path.join(base, "events.jsonl"),
373
+ encoded,
374
+ "",
366
375
  audience,
367
376
  );
377
+ await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
378
+ await createRunRelation(connection, path.join(base, "runs.jsonl"), encoded, "", audience);
368
379
  await createNativeRelation(
369
380
  connection,
370
- flat.replace(/\.jsonl$/, "-natives.jsonl"),
371
- depRecords,
372
- `${schema}.`,
373
- audience,
374
- );
375
- await createWordRelations(
376
- connection,
377
- flat.replace(/\.jsonl$/, ""),
378
- depRecords,
379
- `${schema}.`,
381
+ path.join(base, "natives.jsonl"),
382
+ encoded,
383
+ "",
380
384
  audience,
381
385
  );
382
- }
386
+ await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
383
387
 
384
- // The market scale as a relation, so a table prints `village` beside the
385
- // number a note wrote without a second copy of the scale living in authored
386
- // content. Three columns, because the scale states three things and a join
387
- // that could only give back the name would send the next author to copy the
388
- // rest by hand.
389
- await connection.run(
390
- "CREATE VIEW market AS SELECT * FROM (VALUES " +
391
- MARKET_CLASSES.map(
392
- (c) =>
393
- `(${c.value}, '${c.name.replace(/'/g, "''")}', ` +
394
- `'${c.trade.replace(/'/g, "''")}')`,
395
- ).join(", ") +
396
- ") AS t(value, name, trade)",
397
- );
388
+ // One schema per declared dependency, so `FROM sohl.notes` reads the notes
389
+ // that package published. Quoted, because a package id may carry a hyphen
390
+ // (`sohl-thalorna`) and an unquoted identifier may not. Both views are
391
+ // created, so `FROM sohl.entries` reads a dependency's stubs the same way.
392
+ for (const dep of dependencies) {
393
+ if (!dep?.id || !dep?.file || !fs.existsSync(dep.file)) continue;
394
+ load = `dependency ${dep.id} relations`;
395
+ const schema = `"${String(dep.id).replace(/"/g, '""')}"`;
396
+ await connection.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
397
+ // A published index writes each Address as an object; a fence compares
398
+ // the string an author writes, so each is flattened to it on load.
399
+ const flat = path.join(
400
+ base,
401
+ `dependency-${String(dep.id).replace(/[^a-z0-9]/gi, "_")}.jsonl`,
402
+ );
403
+ const depRecords = fs
404
+ .readFileSync(dep.file, "utf8")
405
+ .split("\n")
406
+ .filter((line) => line.trim())
407
+ .map((line) => sqlRecord(flattenPublishedAddresses(JSON.parse(line))));
408
+ await createRelations(connection, flat, depRecords, `${schema}.`, audience, shared);
409
+ await createEventRelation(
410
+ connection,
411
+ flat.replace(/\.jsonl$/, "-events.jsonl"),
412
+ depRecords,
413
+ `${schema}.`,
414
+ audience,
415
+ );
416
+ await createNameRelation(
417
+ connection,
418
+ flat.replace(/\.jsonl$/, "-names.jsonl"),
419
+ depRecords,
420
+ `${schema}.`,
421
+ audience,
422
+ );
423
+ await createRunRelation(
424
+ connection,
425
+ flat.replace(/\.jsonl$/, "-runs.jsonl"),
426
+ depRecords,
427
+ `${schema}.`,
428
+ audience,
429
+ );
430
+ await createNativeRelation(
431
+ connection,
432
+ flat.replace(/\.jsonl$/, "-natives.jsonl"),
433
+ depRecords,
434
+ `${schema}.`,
435
+ audience,
436
+ );
437
+ await createWordRelations(
438
+ connection,
439
+ flat.replace(/\.jsonl$/, ""),
440
+ depRecords,
441
+ `${schema}.`,
442
+ audience,
443
+ );
444
+ }
398
445
 
399
- // The stub-inclusive reading of `notes`, in a schema of its own. Putting it
400
- // on the search path re-runs an authored query with unqualified `notes`
401
- // resolving to `entries`, which is how a fence that would gain rows from
402
- // the wider relation is found without parsing anybody's SQL — see
403
- // {@link prepareSqlTables}.
404
- await connection.run(`CREATE SCHEMA IF NOT EXISTS ${WITH_STUBS_SCHEMA}`);
405
- await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
406
- for (const relation of [
407
- "events",
408
- "names",
409
- "runs",
410
- "words",
411
- "affixes",
412
- "fields",
413
- "glossary",
414
- "language_lineage",
415
- "dictionary",
416
- "terms",
417
- ...LANGUAGE_RELATIONS.map(([name]) => name),
418
- ])
446
+ load = "shared SQL views";
447
+ // The market scale as a relation, so a table prints `village` beside the
448
+ // number a note wrote without a second copy of the scale living in authored
449
+ // content. Three columns, because the scale states three things and a join
450
+ // that could only give back the name would send the next author to copy the
451
+ // rest by hand.
419
452
  await connection.run(
420
- `CREATE VIEW ${WITH_STUBS_SCHEMA}.${relation} AS SELECT * FROM main.${relation}`,
453
+ "CREATE VIEW market AS SELECT * FROM (VALUES " +
454
+ MARKET_CLASSES.map(
455
+ (c) =>
456
+ `(${c.value}, '${c.name.replace(/'/g, "''")}', ` +
457
+ `'${c.trade.replace(/'/g, "''")}')`,
458
+ ).join(", ") +
459
+ ") AS t(value, name, trade)",
421
460
  );
422
461
 
423
- // Whether this index holds a stub at all, asked once. A corpus with none
424
- // pays nothing for the comparison above, which is every package that has
425
- // not started writing them.
426
- const hasStubs =
427
- (
428
- await connection.runAndReadAll("SELECT count(*) AS n FROM entries WHERE state = 'stub'")
429
- ).getRowObjects()[0].n > 0;
462
+ // The stub-inclusive reading of `notes`, in a schema of its own. Putting it
463
+ // on the search path re-runs an authored query with unqualified `notes`
464
+ // resolving to `entries`, which is how a fence that would gain rows from
465
+ // the wider relation is found without parsing anybody's SQL — see
466
+ // {@link prepareSqlTables}.
467
+ await connection.run(`CREATE SCHEMA IF NOT EXISTS ${WITH_STUBS_SCHEMA}`);
468
+ await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
469
+ for (const relation of [
470
+ "events",
471
+ "names",
472
+ "runs",
473
+ "words",
474
+ "affixes",
475
+ "fields",
476
+ "glossary",
477
+ "language_lineage",
478
+ "dictionary",
479
+ "terms",
480
+ ...LANGUAGE_RELATIONS.map(([name]) => name),
481
+ ])
482
+ await connection.run(
483
+ `CREATE VIEW ${WITH_STUBS_SCHEMA}.${relation} AS SELECT * FROM main.${relation}`,
484
+ );
430
485
 
431
- /**
432
- * How many rows a query loses by selecting `FROM notes` rather than
433
- * `FROM entries`.
434
- *
435
- * Answered by **running the author's query again** with unqualified `notes`
436
- * resolving to the stub-inclusive relation, rather than by reading the
437
- * `WHERE` clause. Reading it would mean parsing somebody else's SQL to work
438
- * out which types a fence covers, and being wrong about that is exactly the
439
- * silent failure the two views exist to prevent. Re-running is exact, and
440
- * it costs nothing at all for a corpus with no stub, which is the common
441
- * case and the only one that is asked twice for nothing.
442
- *
443
- * @param {string} sql - The query, as authored.
444
- * @param {number} rows - How many rows it selected as written.
445
- * @returns {Promise<number>} How many rows it would gain.
446
- */
447
- async function stubsExcluded(sql, rows) {
448
- if (!hasStubs) return 0;
449
- try {
450
- await connection.run(`SET search_path='${WITH_STUBS_SCHEMA}'`);
451
- const wider = await connection.runAndReadAll(sql);
452
- return Math.max(0, wider.getRowObjects().length - rows);
453
- } catch {
454
- // A query that cannot run under the wider relation has nothing to
455
- // say about stubs, and the failure it does have is reported by the
456
- // run that matters.
457
- return 0;
458
- } finally {
459
- await connection.run("RESET search_path");
486
+ // Whether this index holds a stub at all, asked once. A corpus with none
487
+ // pays nothing for the comparison above, which is every package that has
488
+ // not started writing them.
489
+ const hasStubs =
490
+ (
491
+ await connection.runAndReadAll(
492
+ "SELECT count(*) AS n FROM entries WHERE state = 'stub'",
493
+ )
494
+ ).getRowObjects()[0].n > 0;
495
+
496
+ /**
497
+ * How many rows a query loses by selecting `FROM notes` rather than
498
+ * `FROM entries`.
499
+ *
500
+ * Answered by **running the author's query again** with unqualified `notes`
501
+ * resolving to the stub-inclusive relation, rather than by reading the
502
+ * `WHERE` clause. Reading it would mean parsing somebody else's SQL to work
503
+ * out which types a fence covers, and being wrong about that is exactly the
504
+ * silent failure the two views exist to prevent. Re-running is exact, and
505
+ * it costs nothing at all for a corpus with no stub, which is the common
506
+ * case and the only one that is asked twice for nothing.
507
+ *
508
+ * @param {string} sql - The query, as authored.
509
+ * @param {number} rows - How many rows it selected as written.
510
+ * @returns {Promise<number>} How many rows it would gain.
511
+ */
512
+ async function stubsExcluded(sql, rows) {
513
+ if (!hasStubs) return 0;
514
+ try {
515
+ const schema =
516
+ shared ? `__sql_${audience}.${WITH_STUBS_SCHEMA}` : WITH_STUBS_SCHEMA;
517
+ await connection.run(`SET search_path='${schema}'`);
518
+ const wider = await connection.runAndReadAll(sql);
519
+ return Math.max(0, wider.getRowObjects().length - rows);
520
+ } catch {
521
+ // A query that cannot run under the wider relation has nothing to
522
+ // say about stubs, and the failure it does have is reported by the
523
+ // run that matters.
524
+ return 0;
525
+ } finally {
526
+ await connection.run(shared ? `USE "__sql_${audience}".main` : "RESET search_path");
527
+ }
528
+ }
529
+
530
+ return {
531
+ addressContext: addressContext ?? {
532
+ package: records.find((record) => record.package)?.package,
533
+ types: new Set(Object.keys(NOTE_VOCABULARY)),
534
+ system: "note",
535
+ },
536
+ stubsExcluded,
537
+ // Each language code's note, for a page whose language is read from
538
+ // its frontmatter.
539
+ languageNotes: languageNotesByCode(encoded, (record) =>
540
+ (
541
+ typeof record.package === "string" &&
542
+ typeof record.type === "string" &&
543
+ typeof record.shortcode === "string"
544
+ ) ?
545
+ renderAddress({
546
+ package: record.package,
547
+ system: "note",
548
+ type: record.type,
549
+ shortcode: record.shortcode,
550
+ })
551
+ : undefined,
552
+ ),
553
+ async query(sql) {
554
+ const reader = await connection.runAndReadAll(sql);
555
+ return {
556
+ // From the result's schema, not from the rows: a query that
557
+ // selects nothing still has columns, and reporting *that* is
558
+ // what tells a stale query from an empty category.
559
+ columnNames: reader.columnNames(),
560
+ rows: reader
561
+ .getRowObjects()
562
+ .map((row) =>
563
+ Object.fromEntries(
564
+ Object.entries(row).map(([key, value]) => [key, toPlain(value)]),
565
+ ),
566
+ ),
567
+ };
568
+ },
569
+ async close() {
570
+ close();
571
+ },
572
+ };
573
+ } catch (err) {
574
+ close();
575
+ if (/out of memory|memory limit/i.test(String(err?.message))) {
576
+ const message =
577
+ `SQL memory_limit ${memoryLimit} exhausted loading ${load}` +
578
+ (err.sqlLoad ? ` (${err.sqlLoad})` : "") +
579
+ ".";
580
+ throw Object.assign(new Error(message, { cause: err }), {
581
+ findings: [{ code: "sql-memory-limit", severity: "error", message }],
582
+ });
460
583
  }
584
+ throw err;
461
585
  }
586
+ }
462
587
 
588
+ /**
589
+ * Own the SQL resources of one content session. Raw local and dependency JSON
590
+ * tables are loaded once; each audience derives its relations unchanged in a
591
+ * separate catalog/connection. Openings are serialized, queries are not.
592
+ * @param {object[]} records - The session's captured index.
593
+ * @param {Omit<NonNullable<Parameters<typeof openNotesDatabase>[1]>, "audience"|"dir">} [options] - Shared load options.
594
+ * @returns {{database: (audience: "all"|"public") => Promise<Awaited<ReturnType<typeof openNotesDatabase>>>, dispose: () => void}}
595
+ */
596
+ export function createNotesDatabaseScope(records, options = {}) {
597
+ const shared = {
598
+ base: fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-session-")),
599
+ instance: undefined,
600
+ connections: new Set(),
601
+ tables: new Map(),
602
+ };
603
+ const databases = new Map();
604
+ let queue = Promise.resolve();
605
+ let disposed = false;
606
+ let failed = false;
607
+ let released = false;
608
+ const release = () => {
609
+ if (released) return;
610
+ released = true;
611
+ try {
612
+ for (const connection of shared.connections) connection.closeSync();
613
+ } finally {
614
+ shared.connections.clear();
615
+ shared.instance?.closeSync();
616
+ shared.instance = undefined;
617
+ fs.rmSync(shared.base, { recursive: true, force: true });
618
+ }
619
+ };
463
620
  return {
464
- addressContext: addressContext ?? {
465
- package: records.find((record) => record.package)?.package,
466
- types: new Set(Object.keys(NOTE_VOCABULARY)),
467
- system: "note",
468
- },
469
- stubsExcluded,
470
- // Each language code's note, for a page whose language is read from
471
- // its frontmatter.
472
- languageNotes: languageNotesByCode(encoded, (record) =>
473
- (
474
- typeof record.package === "string" &&
475
- typeof record.type === "string" &&
476
- typeof record.shortcode === "string"
477
- ) ?
478
- renderAddress({
479
- package: record.package,
480
- system: "note",
481
- type: record.type,
482
- shortcode: record.shortcode,
483
- })
484
- : undefined,
485
- ),
486
- async query(sql) {
487
- const reader = await connection.runAndReadAll(sql);
488
- return {
489
- // From the result's schema, not from the rows: a query that
490
- // selects nothing still has columns, and reporting *that* is
491
- // what tells a stale query from an empty category.
492
- columnNames: reader.columnNames(),
493
- rows: reader
494
- .getRowObjects()
495
- .map((row) =>
496
- Object.fromEntries(
497
- Object.entries(row).map(([key, value]) => [key, toPlain(value)]),
498
- ),
499
- ),
500
- };
621
+ database(audience) {
622
+ if (disposed || failed) return Promise.reject(new Error("SQL scope has been disposed"));
623
+ if (!databases.has(audience)) {
624
+ const pending = queue.then(async () => {
625
+ if (disposed || failed) throw new Error("SQL scope has been disposed");
626
+ try {
627
+ return await openDatabase(records, { ...options, audience }, shared);
628
+ } catch (error) {
629
+ failed = true;
630
+ // The failed opening closed its own connection. An
631
+ // earlier audience can still be preparing results;
632
+ // its owner drains those preparations before dispose.
633
+ if (!shared.connections.size) release();
634
+ throw error;
635
+ }
636
+ });
637
+ databases.set(audience, pending);
638
+ queue = pending.then(
639
+ () => {},
640
+ () => {},
641
+ );
642
+ }
643
+ return databases.get(audience);
501
644
  },
502
- async close() {
503
- connection.closeSync();
504
- instance.closeSync();
505
- if (!dir) fs.rmSync(base, { recursive: true, force: true });
645
+ dispose() {
646
+ if (disposed) return;
647
+ disposed = true;
648
+ // A caller may dispose while a lazy opening is still in flight.
649
+ // Drain it before releasing its native connection and instance.
650
+ void queue.then(release);
506
651
  },
507
652
  };
508
653
  }
@@ -570,21 +715,35 @@ function withNameSort(record) {
570
715
  * @param {"all"|"public"} [audience] - Whether to leave out GM notes.
571
716
  * @returns {Promise<void>}
572
717
  */
573
- async function createRelations(connection, file, records, prefix = "", audience = "all") {
718
+ async function createRelations(connection, file, records, prefix = "", audience = "all", shared) {
574
719
  // Every record contributes its schema; only visible records contribute rows.
575
- fs.writeFileSync(
576
- file,
577
- records
578
- .map((record) =>
579
- JSON.stringify({
580
- record: sortKeysDeep(withNameSort(record)),
581
- visible: audience !== "public" || !isGmNote(record),
582
- }),
583
- )
584
- .join("\n"),
585
- );
586
- const source = readJsonAuto(file);
587
- const read = records.length ? `SELECT record.* FROM (${source}) WHERE visible` : source;
720
+ let source;
721
+ if (shared?.tables.has(prefix)) {
722
+ source = `SELECT * FROM ${shared.tables.get(prefix)}`;
723
+ } else {
724
+ fs.writeFileSync(
725
+ file,
726
+ records
727
+ .map((record) =>
728
+ JSON.stringify({
729
+ record: sortKeysDeep(withNameSort(record)),
730
+ visible:
731
+ shared ? !isGmNote(record) : audience !== "public" || !isGmNote(record),
732
+ }),
733
+ )
734
+ .join("\n"),
735
+ );
736
+ source = readJsonAuto(file);
737
+ if (shared) {
738
+ await connection.run("CREATE SCHEMA IF NOT EXISTS memory.__sql_base");
739
+ const table = `memory.__sql_base.source${shared.tables.size}`;
740
+ await createTableFromFile(connection, table, source);
741
+ shared.tables.set(prefix, table);
742
+ source = `SELECT * FROM ${table}`;
743
+ }
744
+ }
745
+ const filter = !shared || audience === "public" ? " WHERE visible" : "";
746
+ const read = records.length ? `SELECT record.* FROM (${source})${filter}` : source;
588
747
  const described = await connection.runAndReadAll(`DESCRIBE ${read}`);
589
748
  const columns = new Set(described.getRowObjects().map((row) => String(row.column_name)));
590
749
  // A row with no address is a stub. With no address column anywhere, every
@@ -598,12 +757,11 @@ async function createRelations(connection, file, records, prefix = "", audience
598
757
  "COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'draft'), false) " +
599
758
  "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'draft', false)"
600
759
  : "false";
601
- await createTableFromFile(
602
- connection,
603
- `${prefix}entries`,
760
+ const select =
604
761
  `SELECT *, CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
605
- `ELSE 'full' END AS state FROM (${read})`,
606
- );
762
+ `ELSE 'full' END AS state FROM (${read})`;
763
+ if (shared) await connection.run(`CREATE VIEW ${prefix}entries AS ${select}`);
764
+ else await createTableFromFile(connection, `${prefix}entries`, select);
607
765
  await connection.run(
608
766
  `CREATE VIEW ${prefix}notes AS SELECT * FROM ${prefix}entries WHERE state <> 'stub'`,
609
767
  );
@@ -766,7 +924,12 @@ async function createTypedRelation(connection, file, name, rows, columns, intege
766
924
  * @returns {Promise<void>}
767
925
  */
768
926
  async function createTableFromFile(connection, name, select) {
769
- await connection.run(`CREATE TABLE ${name} AS ${select}`);
927
+ try {
928
+ await connection.run(`CREATE TABLE ${name} AS ${select}`);
929
+ } catch (err) {
930
+ err.sqlLoad = name;
931
+ throw err;
932
+ }
770
933
  }
771
934
 
772
935
  /**
@@ -1281,8 +1444,9 @@ async function noteLabels(db) {
1281
1444
  try {
1282
1445
  schemas = (
1283
1446
  await db.query(
1284
- "SELECT DISTINCT schema_name FROM duckdb_tables() WHERE table_name = 'entries' " +
1285
- "ORDER BY schema_name",
1447
+ "SELECT DISTINCT table_schema AS schema_name FROM information_schema.tables " +
1448
+ "WHERE table_catalog = current_database() AND table_name = 'entries' " +
1449
+ "ORDER BY table_schema",
1286
1450
  )
1287
1451
  ).rows.map((row) => String(row.schema_name));
1288
1452
  } catch {
@@ -1640,7 +1804,8 @@ export async function cachedDependencies(config) {
1640
1804
  * @param {object} [opts.database] - An open notes database over `records`, from
1641
1805
  * {@link openNotesDatabase} with this call's `audience` and the declared
1642
1806
  * dependencies from {@link cachedDependencies}. It is queried and left open;
1643
- * absent, one is opened and closed here.
1807
+ * absent, one is opened and closed here. A lazy async factory is also
1808
+ * accepted and called only when this preparation needs SQL.
1644
1809
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes.
1645
1810
  * @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes the
1646
1811
  * generated sections take in, where the caller holds them already; absent,
@@ -1767,7 +1932,7 @@ export async function prepareTreeSqlTables(
1767
1932
  // `sql` directive never reaches this line, and one whose dependency has not
1768
1933
  // been fetched already fails earlier with a message naming the fetch.
1769
1934
  const db =
1770
- database ??
1935
+ (typeof database === "function" ? await database() : database) ??
1771
1936
  (await openNotesDatabase(allRecords, {
1772
1937
  dependencies: await cachedDependencies(config),
1773
1938
  audience,
@@ -52,7 +52,7 @@
52
52
  import { nearest } from "./near-miss.mjs";
53
53
  import { isNoteRecord } from "./index-records.mjs";
54
54
  import { METRE_KINDS, METRE_NAME } from "./metre-declaration.mjs";
55
- import { languageRecordsByName } from "./encoding-lexicon.mjs";
55
+ import { lexiconsFromRecords } from "./encoding-lexicon.mjs";
56
56
  import { STRESS_LONGER, STRESS_MARKS, STRESS_TWO } from "./language-terms.mjs";
57
57
  import { parseWordClass } from "./word-classes.mjs";
58
58
  import { derivedForms, foldWord } from "./word-forms.mjs";
@@ -169,8 +169,8 @@ const isRule = (stress) =>
169
169
  */
170
170
  export function metreBooksFromRecords(records, config = {}) {
171
171
  const books = new Map();
172
- for (const [language, record] of languageRecordsByName([...(records ?? [])], config)) {
173
- const data = isMap(record.data) ? record.data : {};
172
+ for (const [language, lexicon] of lexiconsFromRecords(records, config)) {
173
+ const data = lexicon.data;
174
174
  const sounds = isMap(data.sounds) ? data.sounds : {};
175
175
  if (!isRule(sounds.stress)) continue;
176
176
  const syllables = isMap(sounds.syllables) ? sounds.syllables : {};
@@ -358,9 +358,29 @@ export function metreFindings(blocks, lines, ctx) {
358
358
  ({ offset }) =>
359
359
  !model.layout.some(([from, to]) => offset >= from && offset < to),
360
360
  );
361
- const tokens =
361
+ const parsed =
362
362
  verseParser.parseInline(units.map((unit) => unit.text).join(""), {})[0]
363
363
  ?.children ?? [];
364
+ let presentationDepth = 0;
365
+ const tokens = parsed.filter((token) => {
366
+ if (presentationDepth) {
367
+ presentationDepth += token.nesting;
368
+ return false;
369
+ }
370
+ if (
371
+ token.type === "span_open" &&
372
+ token
373
+ .attrGet("class")
374
+ ?.split(/\s+/)
375
+ .some((name) => name === "speaker" || name === "direction")
376
+ ) {
377
+ presentationDepth = 1;
378
+ return false;
379
+ }
380
+ return true;
381
+ });
382
+ const text = inlineText(tokens);
383
+ if (!text.trim()) continue;
364
384
  const tag = (name) => (ctx.languages?.[name]?.tag ?? name).toLowerCase();
365
385
  const foreign = tokens.find(
366
386
  (token) =>
@@ -375,7 +395,7 @@ export function metreFindings(blocks, lines, ctx) {
375
395
  );
376
396
  continue;
377
397
  }
378
- const beats = lineBeats(inlineText(tokens), book);
398
+ const beats = lineBeats(text, book);
379
399
  if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
380
400
  warnings.push({
381
401
  ...sourcePosition(reading.from),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.5.2",
3
+ "version": "25.5.4",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -1444,7 +1444,9 @@ a.tag:hover {
1444
1444
  .single-content .verse .verse-line.i8 { --verse-indent: 10em; }
1445
1445
  .single-content .verse .verse-canto-break {
1446
1446
  display: block;
1447
- height: 0.5em;
1447
+ width: 25%;
1448
+ margin: 1.5em auto;
1449
+ border-block-start: 1px solid currentColor;
1448
1450
  }
1449
1451
  .single-content .verse-paired .verse-row {
1450
1452
  display: grid;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Capture one source tree for index, lint, links and compilation.
3
3
  * No source or parser state is persisted or reused by another invocation.
4
- * Prepared SQL databases close within their existing preparation functions.
4
+ * Session-owned SQL resources close after both audiences settle, or on dispose.
5
5
  * @param {object} [options]
6
6
  * @param {import("../content-config.mjs").ContentBuildConfig} [options.config] - Exact resolved configuration.
7
7
  * @param {string} [options.contentBase] - Content root, defaulting to configuration.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Each configured language's lexicon, read from the language note its `note`
3
- * names and its family chain. The nearest exact NFC form supplies the whole
4
- * word; grammar, registers and other settings remain the daughter's own.
3
+ * names and its family chain. All entries for an exact NFC form come from
4
+ * the nearest lexicon declaring it; grammar, registers and other settings
5
+ * remain the daughter's own.
5
6
  * A language with no `note`, or one naming no language note, has none.
6
7
  *
7
8
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
@@ -65,12 +65,13 @@ export function findSqlBlocks(markdown: string): Array<{
65
65
  * @param {object} [opts.addressContext] - Address resolution context.
66
66
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes from
67
67
  * local and dependency relations.
68
+ * @param {boolean} [opts.testMemoryLimit] - Test-only fixed 256 MB budget;
69
+ * production always uses 2 GB. This is not authored configuration.
68
70
  * @param {string} [opts.contentBase] - The directory the records' paths are
69
71
  * relative to, so a refused key is reported at its file and line.
70
- * @returns {Promise<{query: (sql: string) => Promise<object[]>,
71
- * close: () => Promise<void>}>} The open database.
72
+ * @returns {ReturnType<typeof openDatabase>} The open database.
72
73
  */
73
- export function openNotesDatabase(records: object[], { dir, dependencies, addressContext, audience, contentBase }?: {
74
+ export function openNotesDatabase(records: object[], opts?: {
74
75
  dir?: string | undefined;
75
76
  dependencies?: {
76
77
  id: string;
@@ -78,11 +79,21 @@ export function openNotesDatabase(records: object[], { dir, dependencies, addres
78
79
  }[] | undefined;
79
80
  addressContext?: object | undefined;
80
81
  audience?: "all" | "public" | undefined;
82
+ testMemoryLimit?: boolean | undefined;
81
83
  contentBase?: string | undefined;
82
- }): Promise<{
83
- query: (sql: string) => Promise<object[]>;
84
- close: () => Promise<void>;
85
- }>;
84
+ }): ReturnType<typeof openDatabase>;
85
+ /**
86
+ * Own the SQL resources of one content session. Raw local and dependency JSON
87
+ * tables are loaded once; each audience derives its relations unchanged in a
88
+ * separate catalog/connection. Openings are serialized, queries are not.
89
+ * @param {object[]} records - The session's captured index.
90
+ * @param {Omit<NonNullable<Parameters<typeof openNotesDatabase>[1]>, "audience"|"dir">} [options] - Shared load options.
91
+ * @returns {{database: (audience: "all"|"public") => Promise<Awaited<ReturnType<typeof openNotesDatabase>>>, dispose: () => void}}
92
+ */
93
+ export function createNotesDatabaseScope(records: object[], options?: Omit<NonNullable<Parameters<typeof openNotesDatabase>[1]>, "audience" | "dir">): {
94
+ database: (audience: "all" | "public") => Promise<Awaited<ReturnType<typeof openNotesDatabase>>>;
95
+ dispose: () => void;
96
+ };
86
97
  /**
87
98
  * Run one authored query and shape its result for the renderer.
88
99
  *
@@ -301,7 +312,8 @@ export function cachedDependencies(config?: object): Promise<Array<{
301
312
  * @param {object} [opts.database] - An open notes database over `records`, from
302
313
  * {@link openNotesDatabase} with this call's `audience` and the declared
303
314
  * dependencies from {@link cachedDependencies}. It is queried and left open;
304
- * absent, one is opened and closed here.
315
+ * absent, one is opened and closed here. A lazy async factory is also
316
+ * accepted and called only when this preparation needs SQL.
305
317
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes.
306
318
  * @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes the
307
319
  * generated sections take in, where the caller holds them already; absent,
@@ -376,3 +388,20 @@ export const SQL_FENCE_ATTRIBUTES: Readonly<Record<string, Readonly<{
376
388
  default: string;
377
389
  summary: string;
378
390
  }>>>;
391
+ /**
392
+ * Open one audience connection, optionally in a session-owned instance.
393
+ * @param {object[]} records - Content-index records.
394
+ * @param {NonNullable<Parameters<typeof openNotesDatabase>[1]>} [opts] - Load options.
395
+ * @param {object} [shared] - Session-owned SQL resources.
396
+ */
397
+ declare function openDatabase(records: object[], { dir, dependencies, addressContext, audience, contentBase, testMemoryLimit, }?: NonNullable<Parameters<typeof openNotesDatabase>[1]>, shared?: object): Promise<{
398
+ addressContext: object;
399
+ stubsExcluded: (sql: string, rows: number) => Promise<number>;
400
+ languageNotes: Map<string, string>;
401
+ query(sql: any): Promise<{
402
+ columnNames: any;
403
+ rows: any;
404
+ }>;
405
+ close(): Promise<void>;
406
+ }>;
407
+ export {};