mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
@@ -49,6 +49,22 @@ const RETRIES = 3
|
||||
// what restores the dropped territories (Greenland, Falklands, French Guiana, …).
|
||||
const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "XKX":"XK", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}
|
||||
|
||||
/**
|
||||
* Countries whose sub-national level is not the one everybody else's ADM1 is.
|
||||
*
|
||||
* geoBoundaries' GBR ADM1 is the four constituent countries — England, Scotland,
|
||||
* Wales, Northern Ireland — so an atlas built from it has no polygon below
|
||||
* "England", and a London borough marked as visited has nothing to light up
|
||||
* (#1974). Its ADM2 is the 216 counties and unitary authorities, which is the
|
||||
* granularity the rest of the world gets from ADM1 and the granularity the
|
||||
* pre-3.1.0 Natural Earth layer had.
|
||||
*
|
||||
* Deliberately a short, hand-picked list. ADM2 everywhere would multiply the
|
||||
* bundle several times over for no gain: for most countries ADM1 already is the
|
||||
* level people think in.
|
||||
*/
|
||||
const ADM_LEVEL = { GBR: 'ADM2' }
|
||||
|
||||
const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)
|
||||
|
||||
// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
|
||||
@@ -216,7 +232,7 @@ async function main() {
|
||||
|
||||
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`).
|
||||
console.log('[atlas-geo] downloading ADM1 (regions)…')
|
||||
const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, 'ADM1')))
|
||||
const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, ADM_LEVEL[a3] || 'ADM1')))
|
||||
const adm1Features = []
|
||||
let withCodes = 0
|
||||
COUNTRIES.forEach((a3, idx) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Prints the per-domain `thresholds` block for vitest.config.ts from the last
|
||||
* coverage run, at each domain's measured value minus one point of slack.
|
||||
*
|
||||
* npm run test:coverage # writes coverage/coverage-summary.json
|
||||
* node scripts/coverage-thresholds.mjs
|
||||
*
|
||||
* The gate is a RATCHET: raise an entry when you improve a domain, never lower one
|
||||
* to make a build pass. Use this when you have genuinely raised coverage and want the
|
||||
* new floor written down, not to re-baseline a regression away.
|
||||
*
|
||||
* It reads coverage-summary.json rather than the text reporter on purpose. The text
|
||||
* reporter prints one row per DIRECTORY, so a domain with subdirectories (plugins,
|
||||
* memories, auth) reads several points higher there than it actually is, and a
|
||||
* threshold copied off it would be wrong in the loose direction.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const summaryPath = path.join(process.cwd(), 'coverage', 'coverage-summary.json');
|
||||
if (!fs.existsSync(summaryPath)) {
|
||||
console.error(`no ${summaryPath} — run "npm run test:coverage" first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const METRICS = [
|
||||
['statements', 'statements'],
|
||||
['branches', 'branches'],
|
||||
['functions', 'functions'],
|
||||
['lines', 'lines'],
|
||||
];
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
||||
const domains = new Map();
|
||||
for (const [file, entry] of Object.entries(summary)) {
|
||||
if (file === 'total') continue;
|
||||
const match = file.split('\\').join('/').match(/\/src\/nest\/([a-z0-9-]+)\//);
|
||||
if (!match) continue;
|
||||
const totals = domains.get(match[1]) ?? new Map(METRICS.map(([k]) => [k, { covered: 0, total: 0 }]));
|
||||
for (const [key, metric] of METRICS) {
|
||||
totals.get(key).covered += entry[metric].covered;
|
||||
totals.get(key).total += entry[metric].total;
|
||||
}
|
||||
domains.set(match[1], totals);
|
||||
}
|
||||
|
||||
// A domain with no branches at all reads as 100%, which is the right floor for it.
|
||||
const floorOf = ({ covered, total }) => (total === 0 ? 100 : Math.max(0, Math.floor((100 * covered) / total) - 1));
|
||||
|
||||
for (const domain of [...domains.keys()].sort()) {
|
||||
const totals = domains.get(domain);
|
||||
const parts = METRICS.map(([key]) => `${key}: ${floorOf(totals.get(key))}`).join(', ');
|
||||
console.log(` 'src/nest/${domain}/**/*.ts': { ${parts} },`);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Turn hand-built spreads into auto-layout templates.
|
||||
*
|
||||
* Reads the book from a journey and writes the spreads out as data: every
|
||||
* measurement divided by the page it was drawn on, so the same template lays
|
||||
* out on an A4 landscape book as it does on the 210mm square it was designed
|
||||
* for. Run from server/, output goes to the client.
|
||||
*/
|
||||
const Database = require('better-sqlite3')
|
||||
const fs = require('fs')
|
||||
|
||||
const JOURNEY = Number(process.argv[2] || 7)
|
||||
const OUT = process.argv[3] || '../client/src/components/Studio/bookTemplates.data.ts'
|
||||
|
||||
const db = new Database('data/travel.db', { readonly: true })
|
||||
const row = db.prepare('SELECT document FROM journey_books WHERE journey_id = ?').get(JOURNEY)
|
||||
db.close()
|
||||
if (!row) throw new Error(`no book on journey ${JOURNEY}`)
|
||||
|
||||
/*
|
||||
* Through the contract on the way in, so a template carries every field the
|
||||
* document has — including any the schema gained since the spread was drawn.
|
||||
* Without it a template written today is missing tomorrow's defaults and does
|
||||
* not typecheck as a BookElement.
|
||||
*/
|
||||
const { normalizeBookDocument } = require('@trek/shared')
|
||||
const doc = normalizeBookDocument(JSON.parse(row.document))
|
||||
const PW = doc.page.pageWidth
|
||||
const PH = doc.page.pageHeight
|
||||
|
||||
const r4 = n => Math.round(n * 10000) / 10000
|
||||
|
||||
/** A frame as fractions of one page's width and of the page height. */
|
||||
function relFrame(f) {
|
||||
return { x: r4(f.x / PW), y: r4(f.y / PH), w: r4(f.w / PW), h: r4(f.h / PH) }
|
||||
}
|
||||
|
||||
/** Type size as a fraction of the page height, so it scales with the sheet. */
|
||||
const relSize = n => r4(n / PH)
|
||||
|
||||
function element(e) {
|
||||
// The id travels with the template and is replaced when it is applied —
|
||||
// dropping it here would leave the element off the BookElement union.
|
||||
const out = { ...e, frame: relFrame(e.frame) }
|
||||
if (typeof out.size === 'number') out.size = relSize(out.size)
|
||||
if (typeof out.radius === 'number') out.radius = r4(out.radius / PW)
|
||||
if (typeof out.strokeWidth === 'number') out.strokeWidth = r4(out.strokeWidth / PW)
|
||||
return out
|
||||
}
|
||||
|
||||
const spreads = doc.spreads
|
||||
.map((s, i) => ({ index: i, role: s.role, background: s.background, elements: s.elements.map(element) }))
|
||||
.filter(s => s.role === 'inner')
|
||||
|
||||
const body = spreads.map((s, i) => ` {
|
||||
id: 'ref-${i + 1}',
|
||||
background: ${JSON.stringify(s.background)},
|
||||
elements: ${JSON.stringify(s.elements, null, 6).replace(/\n/g, '\n ')},
|
||||
},`).join('\n')
|
||||
|
||||
const file = `import type { BookElement } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The spread templates, as data.
|
||||
*
|
||||
* ── Where these come from ────────────────────────────────────────────────
|
||||
*
|
||||
* Not from this file's author. They were built by hand in Studio and read back
|
||||
* out of the document — which is the point: what a page should look like is a
|
||||
* matter of taste, and taste is not something a layout function arrives at by
|
||||
* reasoning. The editor is the design tool, and this is its output.
|
||||
*
|
||||
* To add one: build the spread in Studio, then run
|
||||
*
|
||||
* node scripts/extract-templates.cjs <journeyId>
|
||||
*
|
||||
* from server/ and commit what it writes here.
|
||||
*
|
||||
* ── Why the numbers are fractions ────────────────────────────────────────
|
||||
*
|
||||
* Every frame is divided by the page it was drawn on — x and w by one page's
|
||||
* width, y and h by its height — and type sizes by the page height. A template
|
||||
* built on a 210mm square therefore lays out on an A4 landscape book without
|
||||
* being redrawn, which it would otherwise have to be for every trim size the
|
||||
* picker offers.
|
||||
*
|
||||
* ── What gets filled in ──────────────────────────────────────────────────
|
||||
*
|
||||
* Elements carrying a \`binding\` take their text from the entry; empty photo
|
||||
* frames take its photographs in order; the day, coordinate and country marks
|
||||
* take what the entry knows about its stop. Everything else — the panels, the
|
||||
* rules, the shapes that run off the page — is the design, and is kept as it
|
||||
* was drawn.
|
||||
*/
|
||||
|
||||
export interface SpreadTemplate {
|
||||
id: string
|
||||
background: string | null
|
||||
/** Frames and sizes are fractions — see the note above. */
|
||||
elements: BookElement[]
|
||||
}
|
||||
|
||||
export const SPREAD_TEMPLATES: SpreadTemplate[] = [
|
||||
${body}
|
||||
]
|
||||
`
|
||||
|
||||
fs.writeFileSync(OUT, file)
|
||||
console.log(`wrote ${spreads.length} templates to ${OUT}`)
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Regenerates every derived copy of the plugin permission facts from the single source,
|
||||
* server/src/nest/plugins/protocol/envelope.ts.
|
||||
*
|
||||
* node --import tsx server/scripts/gen-plugin-facts.ts # write
|
||||
* node --import tsx server/scripts/gen-plugin-facts.ts --check # exit 1 on drift
|
||||
*
|
||||
* It *imports* envelope.ts rather than scraping it. The guard it replaces
|
||||
* (plugin-sdk/test/permissions-parity.test.ts) pulled the block out with a regex, which
|
||||
* goes quietly null the moment someone reformats a closing brace — and that test only ran
|
||||
* from prepublishOnly, so the failure would have surfaced as a broken npm release.
|
||||
*
|
||||
* The generated files are CHECKED IN: publish-plugin-sdk runs inside a standalone
|
||||
* plugin-sdk/ checkout with no server present, so a build-time artefact would ship an SDK
|
||||
* with an empty permission list.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
HOOK_PERMISSION, KNOWN_METHODS, KNOWN_PERMISSIONS, METHOD_PERMISSION,
|
||||
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX,
|
||||
} from '../src/nest/plugins/protocol/envelope';
|
||||
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
|
||||
|
||||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
const HEADER = [
|
||||
'// GENERATED — do not edit by hand.',
|
||||
'// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts',
|
||||
'// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const list = (xs: readonly string[]) => xs.map((x) => ` '${x}',`).join('\n');
|
||||
const pairs = (o: Readonly<Record<string, string>>) =>
|
||||
Object.entries(o).map(([k, v]) => ` ${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: '${v}',`).join('\n');
|
||||
|
||||
/**
|
||||
* Types here are deliberately WIDE — Readonly<Record<string, string>> and string[] —
|
||||
* matching exactly what plugin-sdk published before this file existed. An `as const`
|
||||
* literal would be TS7053 under plugin-sdk/tsconfig.json's "strict": true, where
|
||||
* grantGaps indexes HOOK_PERMISSION[key] with a plain string, and it would be a
|
||||
* source-breaking type change for any plugin author doing a dynamic lookup. Strictness
|
||||
* belongs on the host side, where the data source lives.
|
||||
*/
|
||||
const EVENT_FAMILIES = [...new Set([...Object.keys(ENTITY_ID_KEYS), ...Object.keys(SNAPSHOT_GRANT)])].sort();
|
||||
|
||||
const SDK_FACTS = `${HEADER}
|
||||
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
||||
${pairs(HOOK_PERMISSION)}
|
||||
};
|
||||
|
||||
export const KNOWN_PERMISSIONS: string[] = [
|
||||
${list(KNOWN_PERMISSIONS)}
|
||||
];
|
||||
|
||||
export const METHOD_PERMISSION: Readonly<Record<string, string>> = {
|
||||
${pairs(METHOD_PERMISSION)}
|
||||
};
|
||||
|
||||
export const KNOWN_METHODS: string[] = [
|
||||
${list(KNOWN_METHODS)}
|
||||
];
|
||||
|
||||
export const USER_DATA_PERMISSION = '${USER_DATA_PERMISSION}';
|
||||
export const EVENTS_PERMISSION = '${EVENTS_PERMISSION}';
|
||||
export const JOBS_PERMISSION = '${JOBS_PERMISSION}';
|
||||
export const HTTP_OUTBOUND_PREFIX = '${HTTP_OUTBOUND_PREFIX}';
|
||||
|
||||
/**
|
||||
* Core-event catalog. Delivery names are the WebSocket broadcast names,
|
||||
* \`<family>:<verb>\` (e.g. \`place:created\`). A subscribed plugin receives
|
||||
* \`{event, tripId, entity?, entityId?, snapshot?}\`; \`snapshot\` is delivered only
|
||||
* when the plugin also holds EVENT_SNAPSHOT_GRANT[family]. Delete/reorder/bulk
|
||||
* events carry no snapshot.
|
||||
*/
|
||||
export const EVENT_FAMILIES: readonly string[] = [
|
||||
${list(EVENT_FAMILIES)}
|
||||
];
|
||||
|
||||
export const EVENT_SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
|
||||
${pairs(SNAPSHOT_GRANT)}
|
||||
};
|
||||
`;
|
||||
|
||||
const SHARED_FACTS = `${HEADER}
|
||||
/** Widened on purpose so client callers can .includes() with a plain string. */
|
||||
export const PLUGIN_PERMISSIONS: readonly string[] = [
|
||||
${list(KNOWN_PERMISSIONS)}
|
||||
];
|
||||
|
||||
export const PLUGIN_HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
||||
${pairs(HOOK_PERMISSION)}
|
||||
};
|
||||
`;
|
||||
|
||||
const OUTPUTS: Array<[string, string]> = [
|
||||
['plugin-sdk/src/generated/host-facts.ts', SDK_FACTS],
|
||||
['shared/src/plugin-permissions.ts', SHARED_FACTS],
|
||||
];
|
||||
|
||||
/**
|
||||
* Coverage assertions the type system cannot make, because the targets are not TypeScript
|
||||
* unions. Both failure modes are silent today: a new permission reaches the consent screen
|
||||
* as a raw code with no translation, and `trek-plugin permissions` prints it with no hint.
|
||||
*/
|
||||
function coverageProblems(): string[] {
|
||||
const problems: string[] = [];
|
||||
|
||||
// Match the whole quoted key, not a prefix of it. `includes('plugins.perm.db:own')`
|
||||
// also matches 'plugins.perm.db:ownX', and several permissions are prefixes of others
|
||||
// ('db:read:files' vs 'db:read:files:content'), so a substring test passes on exactly
|
||||
// the drift it is supposed to catch.
|
||||
const enAdmin = fs.readFileSync(path.join(REPO, 'shared/src/i18n/en/admin.ts'), 'utf8');
|
||||
const missingI18n = KNOWN_PERMISSIONS.filter((p) => !enAdmin.includes(`'admin.plugins.perm.${p}':`));
|
||||
if (missingI18n.length) {
|
||||
problems.push(
|
||||
`shared/src/i18n/en/admin.ts is missing 'admin.plugins.perm.<permission>' for: ${missingI18n.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ui = fs.readFileSync(path.join(REPO, 'plugin-sdk/src/cli/ui.ts'), 'utf8');
|
||||
const missingHint = KNOWN_PERMISSIONS.filter((p) => !ui.includes(`'${p}'`));
|
||||
if (missingHint.length) {
|
||||
problems.push(
|
||||
`plugin-sdk/src/cli/ui.ts PERMISSION_FAMILIES does not cover: ${missingHint.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
const check = process.argv.includes('--check');
|
||||
let failed = false;
|
||||
|
||||
for (const [rel, content] of OUTPUTS) {
|
||||
const abs = path.join(REPO, rel);
|
||||
const current = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
|
||||
if (current === content) continue;
|
||||
|
||||
if (check) {
|
||||
console.error(`DRIFT: ${rel} does not match envelope.ts.`);
|
||||
console.error(' Run: node --import tsx server/scripts/gen-plugin-facts.ts');
|
||||
failed = true;
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
console.log(`wrote ${rel}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const problem of coverageProblems()) {
|
||||
console.error(`COVERAGE: ${problem}`);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
if (check) console.log('plugin facts are in sync with envelope.ts');
|
||||
@@ -181,11 +181,47 @@ async function main() {
|
||||
|
||||
// Backup
|
||||
const backupPath = `${dbPath}.backup-${Date.now()}`;
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
console.log(`\nBackup created: ${backupPath}`);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
// The rollback copy has to be a consistent snapshot, not a plain file copy:
|
||||
// this script is documented as running against a live server, so in WAL mode
|
||||
// the committed tail sits in the -wal sidecar a copy would leave behind.
|
||||
// VACUUM INTO snapshots even while the server writes; the checkpoint plus
|
||||
// copy is only the fallback for when it cannot (disk, lock).
|
||||
try {
|
||||
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`);
|
||||
} catch {
|
||||
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
}
|
||||
console.log(`\nBackup created: ${backupPath}`);
|
||||
|
||||
// journal_mode lives in the file header, so hardcoding WAL here would quietly
|
||||
// undo a deliberate DELETE/TRUNCATE setup (network storage) on the first key
|
||||
// rotation. This script ships without src/ in the image and cannot import
|
||||
// src/app-config, so it reads the same variables directly — keep the defaults
|
||||
// in step with parsers.resolveDurability().
|
||||
const journalModes = ['DELETE', 'TRUNCATE', 'PERSIST', 'MEMORY', 'WAL', 'OFF'];
|
||||
const syncLevels = ['OFF', 'NORMAL', 'FULL', 'EXTRA'];
|
||||
|
||||
const wantedJournal = (process.env.TREK_DB_JOURNAL_MODE ?? '').trim().toUpperCase();
|
||||
const journalMode = journalModes.includes(wantedJournal) ? wantedJournal : 'WAL';
|
||||
if (wantedJournal && journalMode !== wantedJournal) {
|
||||
console.warn(`TREK_DB_JOURNAL_MODE="${process.env.TREK_DB_JOURNAL_MODE}" is not a SQLite journal mode — using ${journalMode}.`);
|
||||
}
|
||||
|
||||
const wantedSync = (process.env.TREK_DB_SYNCHRONOUS ?? '').trim().toUpperCase();
|
||||
const defaultSync = journalMode === 'WAL' ? 'NORMAL' : 'FULL';
|
||||
const synchronous = syncLevels.includes(wantedSync) ? wantedSync : defaultSync;
|
||||
if (wantedSync && synchronous !== wantedSync) {
|
||||
console.warn(`TREK_DB_SYNCHRONOUS="${process.env.TREK_DB_SYNCHRONOUS}" is not a SQLite synchronous level — using ${synchronous}.`);
|
||||
}
|
||||
|
||||
db.pragma(`journal_mode = ${journalMode}`);
|
||||
db.pragma(`synchronous = ${synchronous}`);
|
||||
console.log(`Journal mode: ${db.pragma('journal_mode', { simple: true })}\n`);
|
||||
|
||||
const result: MigrationResult = { migrated: 0, alreadyMigrated: 0, skipped: 0, errors: [] };
|
||||
|
||||
@@ -237,8 +273,24 @@ async function main() {
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token ---
|
||||
for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
|
||||
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token,
|
||||
// plus the instance-wide provider keys (#1939) ---
|
||||
//
|
||||
// The last two mirror INSTANCE_API_KEY_NAMES in
|
||||
// src/nest/settings/instance-api-keys.ts. Spelled out rather than imported,
|
||||
// because that module pulls in DatabaseService and this script deliberately
|
||||
// stays independent of src/ (see the crypto note above). Miss one and a
|
||||
// rotation leaves it encrypted under the old key, which reads back as "no
|
||||
// key" and silently drops the install to OpenStreetMap, so the copy is
|
||||
// pinned by tests/unit/db/migrate-encryption-parity.test.ts.
|
||||
for (const key of [
|
||||
'oidc_client_secret',
|
||||
'smtp_pass',
|
||||
'admin_webhook_url',
|
||||
'admin_ntfy_token',
|
||||
'maps_api_key',
|
||||
'unsplash_api_key',
|
||||
]) {
|
||||
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
if (!row?.value) continue;
|
||||
const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`);
|
||||
@@ -248,7 +300,25 @@ async function main() {
|
||||
}
|
||||
|
||||
// --- users: api key columns + synology credentials ---
|
||||
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
|
||||
// unsplash_api_key was missing here for as long as the column has existed. It
|
||||
// matters now that the resolver reads it as the per-user fallback (#1939): left
|
||||
// out of a rotation it stays encrypted under the old key and reads back as unset.
|
||||
// airtrail_api_key arrived with a later migration, so an older database may
|
||||
// not have the column at all — filter the list against the real table rather
|
||||
// than letting one missing column throw the whole rotation.
|
||||
const userColumns = new Set(
|
||||
(db.prepare("SELECT name FROM pragma_table_info('users')").all() as { name: string }[]).map((r) => r.name),
|
||||
);
|
||||
const apiKeyColumns = [
|
||||
'maps_api_key',
|
||||
'unsplash_api_key',
|
||||
'openweather_api_key',
|
||||
'immich_api_key',
|
||||
'synology_password',
|
||||
'synology_sid',
|
||||
'synology_did',
|
||||
'airtrail_api_key',
|
||||
].filter((c) => userColumns.has(c));
|
||||
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
|
||||
|
||||
for (const user of users) {
|
||||
@@ -271,8 +341,70 @@ async function main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- app_settings: storage.backends ---
|
||||
// Storage backend secrets live encrypted INSIDE the storage.backends JSON
|
||||
// array (BACKENDS_KEY in src/nest/storage/storage-registry.service.ts is
|
||||
// its own app_settings row — 'storage.categories' is a sibling row with no
|
||||
// secrets), on the fields the shared type registry marks `secret` (only
|
||||
// options.secretAccessKey for s3 today; src/nest/storage/storage-secrets.ts
|
||||
// encrypts it with the same enc:v1: scheme as the api-key columns above).
|
||||
// This script can't import storageSecretFields from @trek/shared (kept
|
||||
// independent of the app, see the header note), so the field list per
|
||||
// backend type is pinned here. Miss one and that backend's secret stays
|
||||
// encrypted under the old key after rotation, then fails to decrypt on the
|
||||
// next read (StorageBackendError) — pinned by
|
||||
// tests/unit/db/migrate-encryption-parity.test.ts.
|
||||
const STORAGE_BACKEND_SECRET_FIELDS: Record<string, readonly string[]> = {
|
||||
s3: ['secretAccessKey'],
|
||||
};
|
||||
{
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'storage.backends'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
if (row?.value) {
|
||||
let backends: unknown;
|
||||
try {
|
||||
backends = JSON.parse(row.value);
|
||||
} catch {
|
||||
result.errors.push('app_settings.storage.backends: invalid JSON — skipping');
|
||||
backends = undefined;
|
||||
}
|
||||
if (backends !== undefined) {
|
||||
if (Array.isArray(backends)) {
|
||||
let changed = false;
|
||||
for (const backend of backends as Record<string, unknown>[]) {
|
||||
if (!backend || typeof backend !== 'object') continue;
|
||||
const type = typeof backend.type === 'string' ? backend.type : undefined;
|
||||
const fields = type ? STORAGE_BACKEND_SECRET_FIELDS[type] : undefined;
|
||||
if (!fields || fields.length === 0) continue;
|
||||
const options = backend.options;
|
||||
if (!options || typeof options !== 'object') continue;
|
||||
const name = typeof backend.name === 'string' ? backend.name : '?';
|
||||
for (const field of fields) {
|
||||
const raw = (options as Record<string, unknown>)[field];
|
||||
if (typeof raw !== 'string' || !raw) continue;
|
||||
const newVal = migrateApiKeyValue(raw, `app_settings.storage.backends[${name}].${field}`);
|
||||
if (newVal !== null) {
|
||||
(options as Record<string, unknown>)[field] = newVal;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
db.prepare("UPDATE app_settings SET value = ? WHERE key = 'storage.backends'").run(
|
||||
JSON.stringify(backends),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
result.errors.push('app_settings.storage.backends: not an array — skipping');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- settings: per-user encrypted keys ---
|
||||
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token'];
|
||||
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token', 'carto_api_key', 'llm_api_key'];
|
||||
const settingRows = db.prepare(
|
||||
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`
|
||||
).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
|
||||
@@ -285,6 +417,106 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- plugins: OAuth tokens and secret settings fields ---
|
||||
// The plugin host encrypts with the same enc:v1: scheme as everything above:
|
||||
// plugin-oauth.service stores both tokens encrypted, and plugins.service
|
||||
// encrypts every settings field the manifest marked secret (instance scope in
|
||||
// plugins.config, user scope in plugin_user_config.config). Left out of a
|
||||
// rotation, a connected plugin reads back as "not connected" and a secret
|
||||
// setting as unset, with nothing in the log to say why. Every table here
|
||||
// arrived with a later migration, so each block checks that it exists.
|
||||
const tableExists = (name: string): boolean =>
|
||||
!!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
|
||||
|
||||
if (tableExists('plugin_oauth_tokens')) {
|
||||
const tokenRows = db
|
||||
.prepare('SELECT plugin_id, user_id, access_token, refresh_token FROM plugin_oauth_tokens')
|
||||
.all() as { plugin_id: string; user_id: number; access_token: string | null; refresh_token: string | null }[];
|
||||
for (const row of tokenRows) {
|
||||
for (const col of ['access_token', 'refresh_token'] as const) {
|
||||
const raw = row[col];
|
||||
if (!raw) continue;
|
||||
const newVal = migrateApiKeyValue(raw, `plugin_oauth_tokens[${row.plugin_id},${row.user_id}].${col}`);
|
||||
if (newVal !== null) {
|
||||
db.prepare(`UPDATE plugin_oauth_tokens SET ${col} = ? WHERE plugin_id = ? AND user_id = ?`).run(
|
||||
newVal,
|
||||
row.plugin_id,
|
||||
row.user_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tableExists('plugin_settings_fields')) {
|
||||
// Which keys are secret is per plugin and per scope, and it lives in the DB
|
||||
// rather than in this script, so nothing has to be pinned by hand here.
|
||||
const secretFields = db
|
||||
.prepare("SELECT plugin_id, field_key, scope FROM plugin_settings_fields WHERE secret = 1")
|
||||
.all() as { plugin_id: string; field_key: string; scope: string | null }[];
|
||||
const secretKeysFor = (pluginId: string, scope: string): string[] =>
|
||||
secretFields
|
||||
.filter((f) => f.plugin_id === pluginId && (f.scope ?? 'instance') === scope)
|
||||
.map((f) => f.field_key);
|
||||
|
||||
const migrateConfigJson = (raw: string | null, keys: string[], label: string): string | null => {
|
||||
if (!raw || keys.length === 0) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
result.errors.push(`${label}: invalid JSON — skipping`);
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
result.errors.push(`${label}: not an object — skipping`);
|
||||
return null;
|
||||
}
|
||||
const config = parsed as Record<string, unknown>;
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
const value = config[key];
|
||||
if (typeof value !== 'string' || !value) continue;
|
||||
const newVal = migrateApiKeyValue(value, `${label}.${key}`);
|
||||
if (newVal !== null) {
|
||||
config[key] = newVal;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? JSON.stringify(config) : null;
|
||||
};
|
||||
|
||||
if (tableExists('plugins')) {
|
||||
const pluginRows = db.prepare('SELECT id, config FROM plugins').all() as { id: string; config: string | null }[];
|
||||
for (const row of pluginRows) {
|
||||
const next = migrateConfigJson(row.config, secretKeysFor(row.id, 'instance'), `plugins[${row.id}].config`);
|
||||
if (next !== null) {
|
||||
db.prepare('UPDATE plugins SET config = ? WHERE id = ?').run(next, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tableExists('plugin_user_config')) {
|
||||
const userConfigRows = db
|
||||
.prepare('SELECT plugin_id, user_id, config FROM plugin_user_config')
|
||||
.all() as { plugin_id: string; user_id: number; config: string | null }[];
|
||||
for (const row of userConfigRows) {
|
||||
const next = migrateConfigJson(
|
||||
row.config,
|
||||
secretKeysFor(row.plugin_id, 'user'),
|
||||
`plugin_user_config[${row.plugin_id},${row.user_id}].config`,
|
||||
);
|
||||
if (next !== null) {
|
||||
db.prepare('UPDATE plugin_user_config SET config = ? WHERE plugin_id = ? AND user_id = ?').run(
|
||||
next,
|
||||
row.plugin_id,
|
||||
row.user_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- trip_album_links: passphrase ---
|
||||
const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
|
||||
for (const row of albumLinks) {
|
||||
|
||||
Reference in New Issue
Block a user