@heroiclands/package-build 25.5.3 → 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 +6 -0
- package/engine/content-session.mjs +46 -13
- package/engine/sql-tables.mjs +379 -214
- package/package.json +1 -1
- package/types/engine/content-session.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +37 -8
package/CHANGELOG.md
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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;
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -217,23 +217,25 @@ export function findSqlBlocks(markdown) {
|
|
|
217
217
|
return blocks;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
/**
|
|
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
|
|
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
|
|
234
|
+
return projected;
|
|
233
235
|
return {
|
|
234
|
-
...
|
|
236
|
+
...projected,
|
|
235
237
|
data: {
|
|
236
|
-
...
|
|
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 {
|
|
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
|
-
{
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
602
|
-
connection,
|
|
603
|
-
`${prefix}entries`,
|
|
760
|
+
const select =
|
|
604
761
|
`SELECT *, CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
|
|
605
|
-
|
|
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
|
-
|
|
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
|
|
1285
|
-
"
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "25.5.
|
|
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",
|
|
@@ -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
|
-
*
|
|
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.
|
|
@@ -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 {
|
|
71
|
-
* close: () => Promise<void>}>} The open database.
|
|
72
|
+
* @returns {ReturnType<typeof openDatabase>} The open database.
|
|
72
73
|
*/
|
|
73
|
-
export function openNotesDatabase(records: object[],
|
|
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
|
-
}):
|
|
83
|
-
|
|
84
|
-
|
|
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 {};
|