@bitcobblers/wod-wiki-tools 0.39.2143 → 0.40.2144
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/dist/bin/wod.cjs +736 -2
- package/dist/bin/wod.cjs.map +1 -1
- package/dist/bin/wod.mjs +2 -2
- package/dist/{chunk-F5SIEOUC.mjs → chunk-464KHLHY.mjs} +737 -6
- package/dist/chunk-464KHLHY.mjs.map +1 -0
- package/dist/{chunk-FYTG3FQK.mjs → chunk-UNDCEABI.mjs} +3 -3
- package/dist/chunk-UNDCEABI.mjs.map +1 -0
- package/dist/index.cjs +736 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -1
- package/dist/index.d.ts +59 -1
- package/dist/index.mjs +2 -2
- package/dist/{scaffoldCatalog-QEHORBWG.mjs → scaffoldCatalog-APBMVJES.mjs} +3 -3
- package/dist/{scaffoldCatalog-QEHORBWG.mjs.map → scaffoldCatalog-APBMVJES.mjs.map} +1 -1
- package/dist/web/playground/assets/main-DXU49DmJ.css +2 -0
- package/dist/web/playground/assets/{main-DiEBwjOo.js → main-DpzRbHM9.js} +103 -103
- package/dist/web/playground/assets/{statementBuilderFlow-c29iM7QE.js → statementBuilderFlow-B0UjTiSn.js} +2 -2
- package/dist/web/playground/assets/{vendor-CiB7wDhT.js → vendor-Dk-k8gVh.js} +67 -67
- package/dist/web/playground/assets/{vendor-codemirror-D4vSrLkz.js → vendor-codemirror-2KNYvyJk.js} +2 -2
- package/dist/web/playground/index.html +4 -4
- package/dist/web/playground/seed/core/chunks/canvas.f625050a.json +1 -0
- package/dist/web/playground/seed/core/chunks/efforts.a58dbed3.json +1 -0
- package/dist/web/playground/seed/core/chunks/equipment.c45026fc.json +1 -0
- package/dist/web/playground/seed/core/manifest.json +14 -14
- package/dist/web/playground/seed/core/report.html +13 -13
- package/dist/web/server/assets/main-DXU49DmJ.css +2 -0
- package/dist/web/server/assets/{main-CIbYk-ix.js → main-imfCx0Nx.js} +73 -72
- package/dist/web/server/assets/{statementBuilderFlow-sXef05V7.js → statementBuilderFlow-BI4ttuZP.js} +2 -2
- package/dist/web/server/assets/{vendor-CiB7wDhT.js → vendor-Dk-k8gVh.js} +67 -67
- package/dist/web/server/assets/{vendor-codemirror-D4vSrLkz.js → vendor-codemirror-2KNYvyJk.js} +2 -2
- package/dist/web/server/index.html +4 -4
- package/dist/web/server/seed/core/chunks/canvas.f625050a.json +1 -0
- package/dist/web/server/seed/core/chunks/efforts.a58dbed3.json +1 -0
- package/dist/web/server/seed/core/chunks/equipment.c45026fc.json +1 -0
- package/dist/web/server/seed/core/manifest.json +14 -14
- package/dist/web/server/seed/core/report.html +13 -13
- package/package.json +8 -7
- package/dist/chunk-F5SIEOUC.mjs.map +0 -1
- package/dist/chunk-FYTG3FQK.mjs.map +0 -1
- package/dist/web/playground/assets/main-C5dNwq9b.css +0 -2
- package/dist/web/playground/seed/core/chunks/canvas.c86bbb5e.json +0 -1
- package/dist/web/playground/seed/core/chunks/efforts.071eefb8.json +0 -1
- package/dist/web/playground/seed/core/chunks/equipment.c4fb1d64.json +0 -1
- package/dist/web/server/assets/main-C5dNwq9b.css +0 -2
- package/dist/web/server/seed/core/chunks/canvas.c86bbb5e.json +0 -1
- package/dist/web/server/seed/core/chunks/efforts.071eefb8.json +0 -1
- package/dist/web/server/seed/core/chunks/equipment.c4fb1d64.json +0 -1
package/dist/bin/wod.cjs
CHANGED
|
@@ -1029,7 +1029,7 @@ var init_compiler = __esm({
|
|
|
1029
1029
|
var VERSION;
|
|
1030
1030
|
var init_version = __esm({
|
|
1031
1031
|
"src/version.ts"() {
|
|
1032
|
-
VERSION = "0.
|
|
1032
|
+
VERSION = "0.40.2144";
|
|
1033
1033
|
}
|
|
1034
1034
|
});
|
|
1035
1035
|
|
|
@@ -2324,6 +2324,661 @@ function resolveDatabaseConfig(options = {}, env) {
|
|
|
2324
2324
|
}
|
|
2325
2325
|
}
|
|
2326
2326
|
|
|
2327
|
+
// src/cli/dbAdmin.ts
|
|
2328
|
+
init_db();
|
|
2329
|
+
var lifecycleSql;
|
|
2330
|
+
function loadSqlLifecycle() {
|
|
2331
|
+
lifecycleSql ??= import(
|
|
2332
|
+
/* @vite-ignore */
|
|
2333
|
+
'@bitcobblers/wod-wiki-storage/sql'
|
|
2334
|
+
);
|
|
2335
|
+
return lifecycleSql;
|
|
2336
|
+
}
|
|
2337
|
+
function fullFs() {
|
|
2338
|
+
return nodeFs();
|
|
2339
|
+
}
|
|
2340
|
+
function parseEnvContent(content) {
|
|
2341
|
+
const g = globalThis;
|
|
2342
|
+
const req = g.require ?? (typeof __require === "function" ? __require : void 0);
|
|
2343
|
+
if (!req) throw new Error("[wod db] .env handling requires a CJS require shim (missing tsup banner?)");
|
|
2344
|
+
return req("node:util").parseEnv(content);
|
|
2345
|
+
}
|
|
2346
|
+
function maskSecrets(message, ...secrets) {
|
|
2347
|
+
let masked = message;
|
|
2348
|
+
for (const secret of secrets) {
|
|
2349
|
+
if (secret) masked = masked.split(secret).join("\u2022\u2022\u2022\u2022\u2022\u2022");
|
|
2350
|
+
}
|
|
2351
|
+
return masked;
|
|
2352
|
+
}
|
|
2353
|
+
var TursoManagement = class _TursoManagement {
|
|
2354
|
+
constructor(organization, token, deps) {
|
|
2355
|
+
this.organization = organization;
|
|
2356
|
+
this.token = token;
|
|
2357
|
+
this.deps = deps;
|
|
2358
|
+
}
|
|
2359
|
+
organization;
|
|
2360
|
+
token;
|
|
2361
|
+
deps;
|
|
2362
|
+
/** Env-only credentials — never flags, never .env-file-only names. */
|
|
2363
|
+
static fromEnv(env, deps = {}) {
|
|
2364
|
+
const read = (key) => {
|
|
2365
|
+
const value = env[key];
|
|
2366
|
+
return value !== void 0 && value.trim() !== "" ? value.trim() : void 0;
|
|
2367
|
+
};
|
|
2368
|
+
const token = read("TURSO_API_TOKEN");
|
|
2369
|
+
const organization = read("TURSO_ORGANIZATION");
|
|
2370
|
+
if (!token || !organization) {
|
|
2371
|
+
throw new Error(
|
|
2372
|
+
"[wod db] management credentials required: set TURSO_API_TOKEN and TURSO_ORGANIZATION in the environment (or .env). Management tokens are never accepted as flags and never printed."
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
return new _TursoManagement(organization, token, deps);
|
|
2376
|
+
}
|
|
2377
|
+
async request(op, method, path, body) {
|
|
2378
|
+
const base = this.deps.apiBase ?? "https://api.turso.tech";
|
|
2379
|
+
let response;
|
|
2380
|
+
try {
|
|
2381
|
+
response = await (this.deps.fetchFn ?? fetch)(`${base}${path}`, {
|
|
2382
|
+
method,
|
|
2383
|
+
headers: { authorization: `Bearer ${this.token}`, ...body !== void 0 ? { "content-type": "application/json" } : {} },
|
|
2384
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
2385
|
+
signal: AbortSignal.timeout(2e4)
|
|
2386
|
+
});
|
|
2387
|
+
} catch (error) {
|
|
2388
|
+
throw new Error(`[wod db] ${op}: ${maskSecrets(error instanceof Error ? error.message : String(error), this.token)}`, { cause: error });
|
|
2389
|
+
}
|
|
2390
|
+
if (!response.ok) {
|
|
2391
|
+
let detail = "";
|
|
2392
|
+
try {
|
|
2393
|
+
const parsed = await response.json();
|
|
2394
|
+
if (typeof parsed === "object" && parsed !== null && "error" in parsed && typeof parsed.error === "string") {
|
|
2395
|
+
detail = parsed.error;
|
|
2396
|
+
}
|
|
2397
|
+
} catch {
|
|
2398
|
+
}
|
|
2399
|
+
throw new Error(
|
|
2400
|
+
`[wod db] ${op}: Turso API ${response.status} ${maskSecrets(detail || response.statusText, this.token)}`
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
if (response.status === 204) return void 0;
|
|
2404
|
+
const text = await response.text();
|
|
2405
|
+
if (text.trim() === "") return void 0;
|
|
2406
|
+
try {
|
|
2407
|
+
return JSON.parse(text);
|
|
2408
|
+
} catch {
|
|
2409
|
+
throw new Error(`[wod db] ${op}: Turso API returned a non-JSON body with status ${response.status}`);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
async getDatabase(name) {
|
|
2413
|
+
try {
|
|
2414
|
+
const payload = await this.request(
|
|
2415
|
+
`get database ${name}`,
|
|
2416
|
+
"GET",
|
|
2417
|
+
`/v1/organizations/${encodeURIComponent(this.organization)}/databases/${encodeURIComponent(name)}`
|
|
2418
|
+
);
|
|
2419
|
+
if (typeof payload !== "object" || payload === null || !("database" in payload)) {
|
|
2420
|
+
throw new Error(`[wod db] get database ${name}: Turso API payload has no database field`);
|
|
2421
|
+
}
|
|
2422
|
+
return toDatabaseInfo(payload.database);
|
|
2423
|
+
} catch (error) {
|
|
2424
|
+
if (error instanceof Error && error.message.includes("Turso API 404")) return void 0;
|
|
2425
|
+
throw error;
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
async createDatabase(input) {
|
|
2429
|
+
const payload = await this.request(
|
|
2430
|
+
`create database ${input.name}`,
|
|
2431
|
+
"POST",
|
|
2432
|
+
`/v1/organizations/${encodeURIComponent(this.organization)}/databases`,
|
|
2433
|
+
input
|
|
2434
|
+
);
|
|
2435
|
+
if (typeof payload !== "object" || payload === null || !("database" in payload)) {
|
|
2436
|
+
throw new Error(`[wod db] create database ${input.name}: Turso API payload has no database field`);
|
|
2437
|
+
}
|
|
2438
|
+
return toDatabaseInfo(payload.database);
|
|
2439
|
+
}
|
|
2440
|
+
/** Mint a database-scoped token (spec: authorization full-access|read-only). */
|
|
2441
|
+
async createToken(name, authorization, expiration) {
|
|
2442
|
+
const payload = await this.request(
|
|
2443
|
+
`mint token for ${name}`,
|
|
2444
|
+
"POST",
|
|
2445
|
+
`/v1/organizations/${encodeURIComponent(this.organization)}/databases/${encodeURIComponent(name)}/auth/tokens?authorization=${authorization}&expiration=${encodeURIComponent(expiration)}`
|
|
2446
|
+
);
|
|
2447
|
+
if (typeof payload !== "object" || payload === null || !("jwt" in payload) || typeof payload.jwt !== "string" || payload.jwt === "") {
|
|
2448
|
+
throw new Error(`[wod db] mint token for ${name}: Turso API returned no token`);
|
|
2449
|
+
}
|
|
2450
|
+
return payload.jwt;
|
|
2451
|
+
}
|
|
2452
|
+
async deleteDatabase(name) {
|
|
2453
|
+
await this.request(
|
|
2454
|
+
`delete database ${name}`,
|
|
2455
|
+
"DELETE",
|
|
2456
|
+
`/v1/organizations/${encodeURIComponent(this.organization)}/databases/${encodeURIComponent(name)}`
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
function toDatabaseInfo(value) {
|
|
2461
|
+
if (typeof value !== "object" || value === null || !("Name" in value) || !("Hostname" in value)) {
|
|
2462
|
+
throw new Error("[wod db] Turso API database payload is missing Name/Hostname");
|
|
2463
|
+
}
|
|
2464
|
+
const name = value.Name;
|
|
2465
|
+
const hostname = value.Hostname;
|
|
2466
|
+
if (typeof name !== "string" || name === "" || typeof hostname !== "string" || hostname === "") {
|
|
2467
|
+
throw new Error("[wod db] Turso API database payload is missing Name/Hostname");
|
|
2468
|
+
}
|
|
2469
|
+
return {
|
|
2470
|
+
name,
|
|
2471
|
+
hostname,
|
|
2472
|
+
dbId: "DbId" in value && typeof value.DbId === "string" ? value.DbId : void 0,
|
|
2473
|
+
group: "group" in value && typeof value.group === "string" ? value.group : void 0
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
function writeSecretFile(fs, path, content) {
|
|
2477
|
+
const tmp = `${path}.${nodeCrypto().randomUUID()}.tmp`;
|
|
2478
|
+
const fd = fs.openSync(tmp, "wx", 384);
|
|
2479
|
+
try {
|
|
2480
|
+
fs.writeSync(fd, content);
|
|
2481
|
+
fs.closeSync(fd);
|
|
2482
|
+
fs.renameSync(tmp, path);
|
|
2483
|
+
} catch (error) {
|
|
2484
|
+
try {
|
|
2485
|
+
fs.closeSync(fd);
|
|
2486
|
+
} catch {
|
|
2487
|
+
}
|
|
2488
|
+
try {
|
|
2489
|
+
fs.unlinkSync(tmp);
|
|
2490
|
+
} catch {
|
|
2491
|
+
}
|
|
2492
|
+
throw error;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
var CONTROL_CHARS = /[\u0000-\u001f\u007f\u0080-\u009f]/;
|
|
2496
|
+
function assertSafePairingFields(pairing) {
|
|
2497
|
+
for (const [what, value] of [["target URL", pairing.url], ["auth token", pairing.authToken], ["database name", pairing.database]]) {
|
|
2498
|
+
if (CONTROL_CHARS.test(value)) {
|
|
2499
|
+
throw new Error(`[wod db] refusing: ${what} contains control characters (hostile API response)`);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
let parsed;
|
|
2503
|
+
try {
|
|
2504
|
+
parsed = new URL(pairing.url);
|
|
2505
|
+
} catch {
|
|
2506
|
+
throw new Error("[wod db] refusing: target URL is not parseable");
|
|
2507
|
+
}
|
|
2508
|
+
if (parsed.protocol !== "libsql:" || parsed.hostname === "" || parsed.search !== "" || parsed.hash !== "") {
|
|
2509
|
+
throw new Error("[wod db] refusing: target URL must be a plain libsql://<hostname>");
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
function writePairingFile(path, pairing, opts = {}) {
|
|
2513
|
+
const fs = fullFs();
|
|
2514
|
+
if (fs.existsSync(path) && !opts.force) {
|
|
2515
|
+
throw new Error(`[wod db] refusing to overwrite existing pairing file ${path} (it contains a credential) \u2014 pass --force to replace it`);
|
|
2516
|
+
}
|
|
2517
|
+
assertSafePairingFields(pairing);
|
|
2518
|
+
writeSecretFile(fs, path, JSON.stringify(pairing, null, 2) + "\n");
|
|
2519
|
+
}
|
|
2520
|
+
var ENV_GUARD_KEYS = [
|
|
2521
|
+
"DB_DRIVER",
|
|
2522
|
+
"DATABASE_URL",
|
|
2523
|
+
"TURSO_DATABASE_URL",
|
|
2524
|
+
"TURSO_AUTH_TOKEN",
|
|
2525
|
+
"LIBSQL_AUTH_TOKEN",
|
|
2526
|
+
"WOD_WIKI_DB",
|
|
2527
|
+
"SQLITE_PATH",
|
|
2528
|
+
"POSTGRES_HOST",
|
|
2529
|
+
"POSTGRES_PORT",
|
|
2530
|
+
"POSTGRES_USER",
|
|
2531
|
+
"POSTGRES_PASSWORD",
|
|
2532
|
+
"POSTGRES_DATABASE",
|
|
2533
|
+
"POSTGRES_SSL"
|
|
2534
|
+
];
|
|
2535
|
+
function appendTursoEnv(envPath2, target, now) {
|
|
2536
|
+
assertSafePairingFields({ url: target.url, authToken: target.authToken, database: "wod-wiki" });
|
|
2537
|
+
const fs = fullFs();
|
|
2538
|
+
const existing = fs.existsSync(envPath2) ? fs.readFileSync(envPath2, "utf8") : "";
|
|
2539
|
+
const parsed = existing ? parseEnvContent(existing) : {};
|
|
2540
|
+
const present = ENV_GUARD_KEYS.filter((key) => typeof parsed[key] === "string" && parsed[key].trim() !== "");
|
|
2541
|
+
if (present.length > 0) {
|
|
2542
|
+
throw new Error(
|
|
2543
|
+
`[wod db] refusing to modify ${envPath2}: conflicting database selectors already set (${present.join(", ")}) \u2014 resolve them manually first; an appended Turso target would be ambiguous`
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
const block = `
|
|
2547
|
+
# wod db pair \u2014 added ${now.toISOString()}
|
|
2548
|
+
DB_DRIVER=turso
|
|
2549
|
+
TURSO_DATABASE_URL=${target.url}
|
|
2550
|
+
TURSO_AUTH_TOKEN=${target.authToken}
|
|
2551
|
+
`;
|
|
2552
|
+
writeSecretFile(fs, envPath2, existing + block);
|
|
2553
|
+
}
|
|
2554
|
+
var DB_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
2555
|
+
function assertDbName(name, what) {
|
|
2556
|
+
if (!DB_NAME_PATTERN.test(name)) {
|
|
2557
|
+
throw new Error(`[wod db] ${what} must match [a-z0-9][a-z0-9-]{0,63}, got "${name}"`);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
function requireOutput(action, output) {
|
|
2561
|
+
if (!output || output === "-") {
|
|
2562
|
+
throw new Error(`[wod db ${action}] a pairing file path is required (-o <file>) \u2014 credentials are never printed`);
|
|
2563
|
+
}
|
|
2564
|
+
return output;
|
|
2565
|
+
}
|
|
2566
|
+
function requireSource(name, action) {
|
|
2567
|
+
if (!name) {
|
|
2568
|
+
throw new Error(`[wod db ${action}] missing source database name \u2014 usage: wod db ${action} <source> [options]`);
|
|
2569
|
+
}
|
|
2570
|
+
return name;
|
|
2571
|
+
}
|
|
2572
|
+
function requireGroup(action, group) {
|
|
2573
|
+
if (!group || group.trim() === "") {
|
|
2574
|
+
throw new Error(`[wod db ${action}] missing --group <name> \u2014 the Turso group must already exist`);
|
|
2575
|
+
}
|
|
2576
|
+
return group.trim();
|
|
2577
|
+
}
|
|
2578
|
+
function requireSourceGroup(action, info, args) {
|
|
2579
|
+
if (info.group) return info.group;
|
|
2580
|
+
if (args.group && args.group.trim() !== "") return args.group.trim();
|
|
2581
|
+
throw new Error(`[wod db ${action}] the Turso API did not report a group for "${info.name}" \u2014 pass --group <name> explicitly`);
|
|
2582
|
+
}
|
|
2583
|
+
function requireWritersRetired(action, source, ack) {
|
|
2584
|
+
if (!ack) {
|
|
2585
|
+
throw new Error(
|
|
2586
|
+
`[wod db ${action}] refusing to read a live source: retire its writers first (stop sync on every device), then pass --writers-retired to acknowledge "${source}" is frozen for the duration of the copy`
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
async function openLibsql(url, authToken, deps) {
|
|
2591
|
+
const [{ createClient }, sql] = await Promise.all([
|
|
2592
|
+
import(
|
|
2593
|
+
/* @vite-ignore */
|
|
2594
|
+
'@libsql/client'
|
|
2595
|
+
),
|
|
2596
|
+
loadSqlLifecycle()
|
|
2597
|
+
]);
|
|
2598
|
+
const connectionUrl = deps.databaseUrl?.(new URL(url).hostname) ?? url;
|
|
2599
|
+
return sql.libsqlDatabase(createClient({ url: connectionUrl, authToken }));
|
|
2600
|
+
}
|
|
2601
|
+
async function closeQuietly(db) {
|
|
2602
|
+
await db.close().catch(() => void 0);
|
|
2603
|
+
}
|
|
2604
|
+
function targetUrl(hostname) {
|
|
2605
|
+
return `libsql://${hostname}`;
|
|
2606
|
+
}
|
|
2607
|
+
function snapshotValueEqual(a, b) {
|
|
2608
|
+
if (a === b) return true;
|
|
2609
|
+
if (typeof a === "number" && typeof b === "number") return a === b || Number.isNaN(a) && Number.isNaN(b);
|
|
2610
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
2611
|
+
const aBytes = a instanceof ArrayBuffer ? new Uint8Array(a) : ArrayBuffer.isView(a) ? new Uint8Array(a.buffer, a.byteOffset, a.byteLength) : void 0;
|
|
2612
|
+
const bBytes = b instanceof ArrayBuffer ? new Uint8Array(b) : ArrayBuffer.isView(b) ? new Uint8Array(b.buffer, b.byteOffset, b.byteLength) : void 0;
|
|
2613
|
+
if (aBytes !== void 0 || bBytes !== void 0) {
|
|
2614
|
+
return aBytes !== void 0 && bBytes !== void 0 && aBytes.length === bBytes.length && aBytes.every((byte, i) => byte === bBytes[i]);
|
|
2615
|
+
}
|
|
2616
|
+
const aKeys = Object.keys(a);
|
|
2617
|
+
const bKeys = Object.keys(b);
|
|
2618
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
2619
|
+
const aRecord = a;
|
|
2620
|
+
const bRecord = b;
|
|
2621
|
+
return aKeys.every((key) => key in bRecord && snapshotValueEqual(aRecord[key], bRecord[key]));
|
|
2622
|
+
}
|
|
2623
|
+
async function assertReplayMatches(sql, target, entries) {
|
|
2624
|
+
const ops = [];
|
|
2625
|
+
for (; ; ) {
|
|
2626
|
+
const window = await sql.fetchSyncOps(target, ops.length === 0 ? 0 : ops[ops.length - 1].rev, 1e4);
|
|
2627
|
+
if (window.length === 0) break;
|
|
2628
|
+
ops.push(...window);
|
|
2629
|
+
}
|
|
2630
|
+
const expected = entries.flatMap((entry, entryIndex) => entry.ops.map((mutation, idx) => ({ opId: `${entry.id}:${idx}`, rev: entryIndex + 1, idx, at: entry.at, mutation })));
|
|
2631
|
+
if (ops.length !== expected.length) {
|
|
2632
|
+
throw new Error(`[wod db migrate] replay verification failed: target has ${ops.length} ops, snapshot has ${expected.length}`);
|
|
2633
|
+
}
|
|
2634
|
+
for (let i = 0; i < expected.length; i++) {
|
|
2635
|
+
const got = ops[i];
|
|
2636
|
+
const want = expected[i];
|
|
2637
|
+
if (got.opId !== want.opId || got.rev !== want.rev || got.idx !== want.idx || got.at !== want.at || !snapshotValueEqual(got.mutation, want.mutation)) {
|
|
2638
|
+
throw new Error(`[wod db migrate] replay verification failed at op ${i} (${want.opId}) \u2014 target diverges from the validated snapshot`);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
var OPS_PER_GROUP = 200;
|
|
2643
|
+
function classifySource(probe, source) {
|
|
2644
|
+
if (probe === "empty") throw new Error(`[wod db migrate] ${source} is empty \u2014 nothing to migrate`);
|
|
2645
|
+
if (probe.kind === "unknown") throw new Error(`[wod db migrate] ${source} holds foreign data \u2014 refusing to touch it`);
|
|
2646
|
+
if (probe.kind === "current") {
|
|
2647
|
+
if (probe.schemaVersion === 0) throw new Error(`[wod db migrate] ${source} is an incomplete migration target (constructing marker) \u2014 it was never validated`);
|
|
2648
|
+
if (probe.schemaVersion > wodWikiStorage.DB_VERSION) throw new Error(`[wod db migrate] ${source} is schema v${probe.schemaVersion}, newer than this CLI (v${wodWikiStorage.DB_VERSION}) \u2014 refusing`);
|
|
2649
|
+
if (probe.schemaVersion === wodWikiStorage.DB_VERSION) throw new Error(`[wod db migrate] ${source} is already at schema v${wodWikiStorage.DB_VERSION} \u2014 nothing to migrate`);
|
|
2650
|
+
return { fromVersion: probe.schemaVersion, legacy: false };
|
|
2651
|
+
}
|
|
2652
|
+
if (probe.schemaVersion > wodWikiStorage.DB_VERSION) {
|
|
2653
|
+
throw new Error(`[wod db migrate] ${source} legacy schema v${probe.schemaVersion} is newer than this CLI (v${wodWikiStorage.DB_VERSION}) \u2014 refusing`);
|
|
2654
|
+
}
|
|
2655
|
+
return { fromVersion: probe.schemaVersion, legacy: true };
|
|
2656
|
+
}
|
|
2657
|
+
async function convertSource(source, fromVersion, legacy) {
|
|
2658
|
+
const fake = await import(
|
|
2659
|
+
/* @vite-ignore */
|
|
2660
|
+
'fake-indexeddb'
|
|
2661
|
+
);
|
|
2662
|
+
const globals = globalThis;
|
|
2663
|
+
for (const [key, value] of Object.entries(fake)) {
|
|
2664
|
+
if (key !== "default") globals[key] = value;
|
|
2665
|
+
}
|
|
2666
|
+
globals.indexedDB = new fake.IDBFactory();
|
|
2667
|
+
const idb = await import(
|
|
2668
|
+
/* @vite-ignore */
|
|
2669
|
+
'@bitcobblers/wod-wiki-storage/idb'
|
|
2670
|
+
);
|
|
2671
|
+
return idb.convertSyncSource(source, fromVersion, legacy);
|
|
2672
|
+
}
|
|
2673
|
+
function snapshotEntries(mutations, snapshotId, at) {
|
|
2674
|
+
const entries = [];
|
|
2675
|
+
for (let start = 0; start < mutations.length; start += OPS_PER_GROUP) {
|
|
2676
|
+
entries.push({ id: `${snapshotId}-${entries.length}`, at, ops: mutations.slice(start, start + OPS_PER_GROUP) });
|
|
2677
|
+
}
|
|
2678
|
+
return entries;
|
|
2679
|
+
}
|
|
2680
|
+
async function runCreate(mgmt, args, deps, io, now) {
|
|
2681
|
+
const output = requireOutput("create", args.output);
|
|
2682
|
+
const group = requireGroup("create", args.group);
|
|
2683
|
+
const dbName = args.dbName ?? `wod-wiki-v${wodWikiStorage.DB_VERSION}`;
|
|
2684
|
+
assertDbName(dbName, "database name");
|
|
2685
|
+
if (await mgmt.getDatabase(dbName)) {
|
|
2686
|
+
throw new Error(`[wod db create] database "${dbName}" already exists \u2014 pair it with \`wod db pair ${dbName}\` or choose --name`);
|
|
2687
|
+
}
|
|
2688
|
+
const created = await mgmt.createDatabase({ name: dbName, group });
|
|
2689
|
+
const url = targetUrl(created.hostname);
|
|
2690
|
+
const token = await mgmt.createToken(dbName, "full-access", "never");
|
|
2691
|
+
const db = await openLibsql(url, token, deps);
|
|
2692
|
+
let probe;
|
|
2693
|
+
try {
|
|
2694
|
+
probe = await (await loadSqlLifecycle()).probeSyncTarget(db);
|
|
2695
|
+
} finally {
|
|
2696
|
+
await closeQuietly(db);
|
|
2697
|
+
}
|
|
2698
|
+
if (probe !== "empty") {
|
|
2699
|
+
throw new Error(`[wod db create] freshly provisioned "${dbName}" probed as ${JSON.stringify(probe)}, expected empty \u2014 refusing to pair`);
|
|
2700
|
+
}
|
|
2701
|
+
const pairing = {
|
|
2702
|
+
wodWikiPairing: 1,
|
|
2703
|
+
kind: "turso",
|
|
2704
|
+
url,
|
|
2705
|
+
authToken: token,
|
|
2706
|
+
database: dbName,
|
|
2707
|
+
schemaVersion: wodWikiStorage.DB_VERSION,
|
|
2708
|
+
createdAt: now.toISOString()
|
|
2709
|
+
};
|
|
2710
|
+
try {
|
|
2711
|
+
writePairingFile(output, pairing, { force: args.force });
|
|
2712
|
+
if (args.writeEnv) appendTursoEnv(envPath(args), { url, authToken: token }, now);
|
|
2713
|
+
} catch (error) {
|
|
2714
|
+
io.stderr(
|
|
2715
|
+
`[wod db create] pairing/config write failed (${error instanceof Error ? error.message : String(error)}) \u2014 the database "${dbName}" was created and was NOT deleted; recover with \`wod db pair ${dbName} --allow-empty -o <file>\`. No settings were changed.`
|
|
2716
|
+
);
|
|
2717
|
+
throw error;
|
|
2718
|
+
}
|
|
2719
|
+
io.stdout(`[wod db create] provisioned ${dbName} (${created.hostname}, group ${group}) \u2014 empty target ready for the browser's first bootstrap`);
|
|
2720
|
+
io.stdout(`[wod db create] pairing file: ${output}${args.writeEnv ? `; wrote ${envPath(args)}` : ""}`);
|
|
2721
|
+
}
|
|
2722
|
+
function envPath(args) {
|
|
2723
|
+
return args.envFile ?? ".env";
|
|
2724
|
+
}
|
|
2725
|
+
async function copyNative(kind, mgmt, args, deps, io, now) {
|
|
2726
|
+
const source = requireSource(args.name, kind);
|
|
2727
|
+
requireWritersRetired(kind, source, args.writersRetired);
|
|
2728
|
+
requireOutput(kind, args.output);
|
|
2729
|
+
const info = await mgmt.getDatabase(source);
|
|
2730
|
+
if (!info) throw new Error(`[wod db ${kind}] database not found: ${source}`);
|
|
2731
|
+
const copyName = args.dbName ?? `${sanitizeDbName(source)}-${kind}-${stamp(now)}`;
|
|
2732
|
+
assertDbName(copyName, "copy name");
|
|
2733
|
+
if (copyName === source) {
|
|
2734
|
+
throw new Error(`[wod db ${kind}] the copy must be a NEW database \u2014 "${copyName}" is the source itself`);
|
|
2735
|
+
}
|
|
2736
|
+
if (await mgmt.getDatabase(copyName)) {
|
|
2737
|
+
throw new Error(`[wod db ${kind}] target "${copyName}" already exists \u2014 choose --name`);
|
|
2738
|
+
}
|
|
2739
|
+
const sql = await loadSqlLifecycle();
|
|
2740
|
+
const group = requireSourceGroup(kind, info, args);
|
|
2741
|
+
const sourceToken = await mgmt.createToken(source, "read-only", "1h");
|
|
2742
|
+
const sourceDb = await openLibsql(targetUrl(info.hostname), sourceToken, deps);
|
|
2743
|
+
try {
|
|
2744
|
+
const before = await sql.databaseFingerprint(sourceDb);
|
|
2745
|
+
const copy = await mgmt.createDatabase({
|
|
2746
|
+
name: copyName,
|
|
2747
|
+
group,
|
|
2748
|
+
seed: { type: "database", name: source }
|
|
2749
|
+
});
|
|
2750
|
+
if (deps.midCopyHook) await deps.midCopyHook(copyName);
|
|
2751
|
+
const copyToken = await mgmt.createToken(copyName, "read-only", "1h");
|
|
2752
|
+
const copyDb = await openLibsql(targetUrl(copy.hostname), copyToken, deps);
|
|
2753
|
+
let after;
|
|
2754
|
+
try {
|
|
2755
|
+
after = await sql.assertDatabaseCopyMatches(sourceDb, copyDb, before);
|
|
2756
|
+
} finally {
|
|
2757
|
+
await closeQuietly(copyDb);
|
|
2758
|
+
}
|
|
2759
|
+
return finishCopy(kind, mgmt, args, io, now, copy, after);
|
|
2760
|
+
} finally {
|
|
2761
|
+
await closeQuietly(sourceDb);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
async function finishCopy(kind, mgmt, args, io, now, copy, after) {
|
|
2765
|
+
io.stdout(
|
|
2766
|
+
`[wod db ${kind}] native copy "${copy.name}" verified: ${after.tables} tables, ${after.rows} rows, digest ${after.digest.slice(0, 8)} \u2014 source retained unchanged`
|
|
2767
|
+
);
|
|
2768
|
+
if (kind === "backup" || after.sync === void 0 || after.sync.schema !== wodWikiStorage.DB_VERSION) {
|
|
2769
|
+
const schemaLabel = after.sync === void 0 ? "(legacy, no sync state)" : `v${after.sync.schema}`;
|
|
2770
|
+
io.stdout(
|
|
2771
|
+
`[wod db ${kind}] copy is schema ${schemaLabel} \u2014 retained as a read-only archive, NOT pairable for sync. Use \`wod db restore ${copy.name}\` for a writable current-schema copy or \`wod db migrate ${copy.name}\` to convert an older schema.`
|
|
2772
|
+
);
|
|
2773
|
+
if (args.output && args.output !== "-") {
|
|
2774
|
+
const roToken = await mgmt.createToken(copy.name, "read-only", "never");
|
|
2775
|
+
writePairingFile(args.output, {
|
|
2776
|
+
wodWikiPairing: 1,
|
|
2777
|
+
kind: "turso",
|
|
2778
|
+
url: targetUrl(copy.hostname),
|
|
2779
|
+
authToken: roToken,
|
|
2780
|
+
database: copy.name,
|
|
2781
|
+
schemaVersion: after.sync === void 0 ? 0 : after.sync.schema,
|
|
2782
|
+
createdAt: now.toISOString()
|
|
2783
|
+
}, { force: args.force });
|
|
2784
|
+
io.stdout(`[wod db ${kind}] read-only archive pairing file: ${args.output}`);
|
|
2785
|
+
}
|
|
2786
|
+
return { info: copy, fingerprint: after };
|
|
2787
|
+
}
|
|
2788
|
+
try {
|
|
2789
|
+
await pairTarget(mgmt, args, io, now, copy.name, copy.hostname, after.sync.schema);
|
|
2790
|
+
} catch (error) {
|
|
2791
|
+
io.stderr(
|
|
2792
|
+
`[wod db ${kind}] pairing/config write failed (${error instanceof Error ? error.message : String(error)}) \u2014 the verified copy "${copy.name}" was NOT deleted; recover with \`wod db pair ${copy.name} -o <file>\`. No settings were changed.`
|
|
2793
|
+
);
|
|
2794
|
+
throw error;
|
|
2795
|
+
}
|
|
2796
|
+
return { info: copy, fingerprint: after };
|
|
2797
|
+
}
|
|
2798
|
+
async function runMigrate(mgmt, args, deps, io, now) {
|
|
2799
|
+
const source = requireSource(args.name, "migrate");
|
|
2800
|
+
requireWritersRetired("migrate", source, args.writersRetired);
|
|
2801
|
+
const output = requireOutput("migrate", args.output);
|
|
2802
|
+
const info = await mgmt.getDatabase(source);
|
|
2803
|
+
if (!info) throw new Error(`[wod db migrate] database not found: ${source}`);
|
|
2804
|
+
const sql = await loadSqlLifecycle();
|
|
2805
|
+
const sourceToken = await mgmt.createToken(source, "read-only", "1h");
|
|
2806
|
+
const sourceDb = await openLibsql(targetUrl(info.hostname), sourceToken, deps);
|
|
2807
|
+
let before;
|
|
2808
|
+
let entries;
|
|
2809
|
+
try {
|
|
2810
|
+
before = await sql.databaseFingerprint(sourceDb);
|
|
2811
|
+
const classified = classifySource(await sql.probeSyncTarget(sourceDb), source);
|
|
2812
|
+
const mutations = await convertSource(sourceDb, classified.fromVersion, classified.legacy);
|
|
2813
|
+
if (mutations.length === 0) {
|
|
2814
|
+
throw new Error("[wod db migrate] conversion produced no rows \u2014 refusing to create an empty target");
|
|
2815
|
+
}
|
|
2816
|
+
entries = snapshotEntries(mutations, `m${classified.fromVersion}-${before.digest.slice(0, 8)}`, now.getTime());
|
|
2817
|
+
sql.assertPolicySnapshot(entries.flatMap((entry) => entry.ops));
|
|
2818
|
+
} finally {
|
|
2819
|
+
await closeQuietly(sourceDb);
|
|
2820
|
+
}
|
|
2821
|
+
const targetName = args.dbName ?? `${sanitizeDbName(source)}-v${wodWikiStorage.DB_VERSION}`;
|
|
2822
|
+
assertDbName(targetName, "target name");
|
|
2823
|
+
if (targetName === source) {
|
|
2824
|
+
throw new Error("[wod db migrate] the target must be a NEW database \u2014 retention keeps the source at its own name");
|
|
2825
|
+
}
|
|
2826
|
+
if (await mgmt.getDatabase(targetName)) {
|
|
2827
|
+
throw new Error(`[wod db migrate] target "${targetName}" already exists \u2014 choose --name (a fresh, exclusively reserved target is required)`);
|
|
2828
|
+
}
|
|
2829
|
+
const created = await mgmt.createDatabase({ name: targetName, group: requireSourceGroup("migrate", info, args) });
|
|
2830
|
+
let token;
|
|
2831
|
+
let committed;
|
|
2832
|
+
try {
|
|
2833
|
+
token = await mgmt.createToken(targetName, "full-access", "never");
|
|
2834
|
+
const target = await openLibsql(targetUrl(created.hostname), token, deps);
|
|
2835
|
+
try {
|
|
2836
|
+
const probe = await sql.probeSyncTarget(target);
|
|
2837
|
+
if (probe !== "empty") throw new Error(`[wod db migrate] target "${targetName}" is not empty (${JSON.stringify(probe)}) \u2014 refusing`);
|
|
2838
|
+
await sql.initSyncTarget(target, 0);
|
|
2839
|
+
committed = await sql.applySyncBatch(target, entries, { expectSchema: 0 });
|
|
2840
|
+
await assertReplayMatches(sql, target, entries);
|
|
2841
|
+
const sourceDbRecheck = await openLibsql(targetUrl(info.hostname), sourceToken, deps);
|
|
2842
|
+
try {
|
|
2843
|
+
await sql.assertDatabaseCopyMatches(sourceDbRecheck, sourceDbRecheck, before);
|
|
2844
|
+
} finally {
|
|
2845
|
+
await closeQuietly(sourceDbRecheck);
|
|
2846
|
+
}
|
|
2847
|
+
await sql.finalizeMigrationTarget(target, wodWikiStorage.DB_VERSION, committed);
|
|
2848
|
+
} finally {
|
|
2849
|
+
await closeQuietly(target);
|
|
2850
|
+
}
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
io.stderr(
|
|
2853
|
+
`[wod db migrate] target "${targetName}" was left INCOMPLETE and is not pairable (constructing marker). Inspect or remove it: \`wod db delete ${targetName} --yes ${targetName}\`. The source "${source}" was not modified.`
|
|
2854
|
+
);
|
|
2855
|
+
throw error;
|
|
2856
|
+
}
|
|
2857
|
+
try {
|
|
2858
|
+
writePairingFile(output, {
|
|
2859
|
+
wodWikiPairing: 1,
|
|
2860
|
+
kind: "turso",
|
|
2861
|
+
url: targetUrl(created.hostname),
|
|
2862
|
+
authToken: token,
|
|
2863
|
+
database: targetName,
|
|
2864
|
+
schemaVersion: wodWikiStorage.DB_VERSION,
|
|
2865
|
+
createdAt: now.toISOString()
|
|
2866
|
+
}, { force: args.force });
|
|
2867
|
+
if (args.writeEnv) appendTursoEnv(envPath(args), { url: targetUrl(created.hostname), authToken: token }, now);
|
|
2868
|
+
} catch (error) {
|
|
2869
|
+
io.stderr(
|
|
2870
|
+
`[wod db migrate] pairing/config write failed (${error instanceof Error ? error.message : String(error)}) \u2014 the target "${targetName}" is complete at v${wodWikiStorage.DB_VERSION} and was NOT deleted; recover with \`wod db pair ${targetName} -o <file>\`. No settings were changed.`
|
|
2871
|
+
);
|
|
2872
|
+
throw error;
|
|
2873
|
+
}
|
|
2874
|
+
io.stdout(
|
|
2875
|
+
`[wod db migrate] ${source} (schema ${before.sync ? `v${before.sync.schema}` : "legacy"}) \u2192 ${targetName} at v${wodWikiStorage.DB_VERSION}: ${entries.length} groups / ${committed} committed, replay + pins + source CAS verified \u2014 source retained`
|
|
2876
|
+
);
|
|
2877
|
+
io.stdout(`[wod db migrate] pairing file: ${output}${args.writeEnv ? `; wrote ${envPath(args)}` : ""}`);
|
|
2878
|
+
}
|
|
2879
|
+
async function runDelete(args, io, deps) {
|
|
2880
|
+
const name = requireSource(args.name, "delete");
|
|
2881
|
+
if (!args.yes) {
|
|
2882
|
+
throw new Error(`[wod db delete] deletion requires exact confirmation: wod db delete ${name} --yes ${name}`);
|
|
2883
|
+
}
|
|
2884
|
+
if (args.yes !== name) {
|
|
2885
|
+
throw new Error(`[wod db delete] confirmation mismatch: --yes "${args.yes}" does not exactly match "${name}" \u2014 nothing was deleted`);
|
|
2886
|
+
}
|
|
2887
|
+
await TursoManagement.fromEnv(args.env, { fetchFn: deps.fetchFn }).deleteDatabase(name);
|
|
2888
|
+
io.stdout(`[wod db delete] deleted ${name}`);
|
|
2889
|
+
}
|
|
2890
|
+
async function pairTarget(mgmt, args, io, now, dbName, hostname, schemaVersion) {
|
|
2891
|
+
const output = requireOutput("pair", args.output);
|
|
2892
|
+
const token = await mgmt.createToken(dbName, "full-access", "never");
|
|
2893
|
+
const url = targetUrl(hostname);
|
|
2894
|
+
writePairingFile(output, {
|
|
2895
|
+
wodWikiPairing: 1,
|
|
2896
|
+
kind: "turso",
|
|
2897
|
+
url,
|
|
2898
|
+
authToken: token,
|
|
2899
|
+
database: dbName,
|
|
2900
|
+
schemaVersion,
|
|
2901
|
+
createdAt: now.toISOString()
|
|
2902
|
+
}, { force: args.force });
|
|
2903
|
+
if (args.writeEnv) appendTursoEnv(envPath(args), { url, authToken: token }, now);
|
|
2904
|
+
io.stdout(`[wod db pair] ${dbName} (${hostname}) at schema v${schemaVersion} \u2014 pairing file: ${output}${args.writeEnv ? `; wrote ${envPath(args)}` : ""}`);
|
|
2905
|
+
}
|
|
2906
|
+
async function runPair(mgmt, args, deps, io, now) {
|
|
2907
|
+
const name = requireSource(args.name, "pair");
|
|
2908
|
+
const info = await mgmt.getDatabase(name);
|
|
2909
|
+
if (!info) throw new Error(`[wod db pair] database not found: ${name}`);
|
|
2910
|
+
const sql = await loadSqlLifecycle();
|
|
2911
|
+
const probeToken = await mgmt.createToken(name, "read-only", "1h");
|
|
2912
|
+
const db = await openLibsql(targetUrl(info.hostname), probeToken, deps);
|
|
2913
|
+
let probe;
|
|
2914
|
+
try {
|
|
2915
|
+
await db.query("SELECT 1");
|
|
2916
|
+
probe = await sql.probeSyncTarget(db);
|
|
2917
|
+
if (probe !== "empty" && probe.kind === "current") {
|
|
2918
|
+
const ops = [];
|
|
2919
|
+
let cursor = 0;
|
|
2920
|
+
for (; ; ) {
|
|
2921
|
+
const window = await sql.fetchSyncOps(db, cursor, 1e4);
|
|
2922
|
+
if (window.length === 0) break;
|
|
2923
|
+
ops.push(...window);
|
|
2924
|
+
cursor = window[window.length - 1].rev;
|
|
2925
|
+
}
|
|
2926
|
+
sql.assertLogPinClosure(ops);
|
|
2927
|
+
}
|
|
2928
|
+
} finally {
|
|
2929
|
+
await closeQuietly(db);
|
|
2930
|
+
}
|
|
2931
|
+
if (probe === "empty") {
|
|
2932
|
+
if (!args.allowEmpty) {
|
|
2933
|
+
throw new Error(`[wod db pair] ${name} is empty \u2014 it is only pairable for a deliberate first bootstrap (--allow-empty), after \`wod db create\`, or once a browser initialized it`);
|
|
2934
|
+
}
|
|
2935
|
+
await pairTarget(mgmt, args, io, now, name, info.hostname, wodWikiStorage.DB_VERSION);
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
if (probe.kind === "unknown") {
|
|
2939
|
+
throw new Error(`[wod db pair] ${name} holds foreign data \u2014 refusing to pair`);
|
|
2940
|
+
}
|
|
2941
|
+
if (probe.kind === "legacy") {
|
|
2942
|
+
throw new Error(`[wod db pair] ${name} is a legacy direct-Turso database (v${probe.schemaVersion}) \u2014 run \`wod db migrate ${name}\` first`);
|
|
2943
|
+
}
|
|
2944
|
+
if (probe.schemaVersion === 0) {
|
|
2945
|
+
throw new Error(`[wod db pair] ${name} is an incomplete migration target (constructing marker) \u2014 it was never validated and stays unpairable`);
|
|
2946
|
+
}
|
|
2947
|
+
if (probe.schemaVersion !== wodWikiStorage.DB_VERSION) {
|
|
2948
|
+
throw new Error(`[wod db pair] ${name} is schema v${probe.schemaVersion}, this CLI manages v${wodWikiStorage.DB_VERSION} \u2014 run \`wod db migrate ${name}\` first`);
|
|
2949
|
+
}
|
|
2950
|
+
await pairTarget(mgmt, args, io, now, name, info.hostname, probe.schemaVersion);
|
|
2951
|
+
}
|
|
2952
|
+
function sanitizeDbName(raw) {
|
|
2953
|
+
const cleaned = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
|
|
2954
|
+
return cleaned === "" ? "wod-wiki" : cleaned.slice(0, 64).replace(/-+$/, "");
|
|
2955
|
+
}
|
|
2956
|
+
function stamp(now) {
|
|
2957
|
+
return now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z").toLowerCase();
|
|
2958
|
+
}
|
|
2959
|
+
async function runDbCommand(args, io, deps = {}) {
|
|
2960
|
+
const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
2961
|
+
const mgmt = () => TursoManagement.fromEnv(args.env, { fetchFn: deps.fetchFn });
|
|
2962
|
+
switch (args.action) {
|
|
2963
|
+
case "create":
|
|
2964
|
+
return runCreate(mgmt(), args, deps, io, now);
|
|
2965
|
+
case "backup":
|
|
2966
|
+
case "restore":
|
|
2967
|
+
await copyNative(args.action, mgmt(), args, deps, io, now);
|
|
2968
|
+
return;
|
|
2969
|
+
case "migrate":
|
|
2970
|
+
return runMigrate(mgmt(), args, deps, io, now);
|
|
2971
|
+
case "delete":
|
|
2972
|
+
return runDelete(args, io, deps);
|
|
2973
|
+
case "pair":
|
|
2974
|
+
return runPair(mgmt(), args, deps, io, now);
|
|
2975
|
+
default:
|
|
2976
|
+
throw new Error(
|
|
2977
|
+
`[wod db] unknown action "${args.action ?? ""}" \u2014 usage: wod db <create|backup|restore|migrate|delete|pair> [name] [options]`
|
|
2978
|
+
);
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2327
2982
|
// src/cli/persist.ts
|
|
2328
2983
|
init_db();
|
|
2329
2984
|
var registeredWidgets = wodWikiCore.KNOWN_SECTION_WIDGETS;
|
|
@@ -3142,6 +3797,9 @@ ${fence}
|
|
|
3142
3797
|
var VALUE_FLAGS = [
|
|
3143
3798
|
["--db", "db"],
|
|
3144
3799
|
["--db-driver", "dbDriver"],
|
|
3800
|
+
["--group", "group"],
|
|
3801
|
+
["--name", "dbName"],
|
|
3802
|
+
["--yes", "yes"],
|
|
3145
3803
|
["--database-url", "databaseUrl"],
|
|
3146
3804
|
["--postgres-host", "postgresHost"],
|
|
3147
3805
|
["--postgres-port", "postgresPort"],
|
|
@@ -3248,6 +3906,18 @@ function parseCliArgs(args) {
|
|
|
3248
3906
|
} else if (arg === "--scaffold") {
|
|
3249
3907
|
result.scaffold = true;
|
|
3250
3908
|
i++;
|
|
3909
|
+
} else if (arg === "--writers-retired") {
|
|
3910
|
+
result.writersRetired = true;
|
|
3911
|
+
i++;
|
|
3912
|
+
} else if (arg === "--allow-empty") {
|
|
3913
|
+
result.allowEmpty = true;
|
|
3914
|
+
i++;
|
|
3915
|
+
} else if (arg === "--write-env") {
|
|
3916
|
+
result.writeEnv = true;
|
|
3917
|
+
i++;
|
|
3918
|
+
} else if (arg === "--force") {
|
|
3919
|
+
result.force = true;
|
|
3920
|
+
i++;
|
|
3251
3921
|
} else if (arg === "--template") {
|
|
3252
3922
|
result.template = args[++i];
|
|
3253
3923
|
i++;
|
|
@@ -3274,7 +3944,7 @@ function parseCliArgs(args) {
|
|
|
3274
3944
|
if (Number.isSafeInteger(v) && v >= 0) result.seedVersion = v;
|
|
3275
3945
|
i++;
|
|
3276
3946
|
} else if (!result.subcommand) {
|
|
3277
|
-
if (arg === "parse" || arg === "run" || arg === "query" || arg === "download" || arg === "export" || arg === "import" || arg === "publish" || arg === "build" || arg === "seed" || arg === "reset" || arg === "init" || arg === "server" || arg === "serve" || arg === "playground" || arg === "init-catalog" || arg === "help" || arg === "version") {
|
|
3947
|
+
if (arg === "parse" || arg === "run" || arg === "query" || arg === "download" || arg === "export" || arg === "import" || arg === "publish" || arg === "build" || arg === "seed" || arg === "reset" || arg === "init" || arg === "db" || arg === "server" || arg === "serve" || arg === "playground" || arg === "init-catalog" || arg === "help" || arg === "version") {
|
|
3278
3948
|
result.subcommand = arg;
|
|
3279
3949
|
} else {
|
|
3280
3950
|
result.target = arg;
|
|
@@ -3347,6 +4017,47 @@ USAGE:
|
|
|
3347
4017
|
wod seed [options]
|
|
3348
4018
|
wod reset [options]
|
|
3349
4019
|
wod init Initialize the configured database (idempotent)
|
|
4020
|
+
wod db <action> [name] [options]
|
|
4021
|
+
Turso database lifecycle (management credentials from
|
|
4022
|
+
the environment only: TURSO_API_TOKEN + TURSO_ORGANIZATION;
|
|
4023
|
+
never flags, never printed):
|
|
4024
|
+
wod db create [--name <db>] --group <g> -o <pairing-file>
|
|
4025
|
+
Provision a fresh empty target (default name
|
|
4026
|
+
wod-wiki-v<schema>) and write its pairing file.
|
|
4027
|
+
wod db backup <source> [--name <copy>] --writers-retired -o <file>
|
|
4028
|
+
wod db restore <source> [--name <copy>] --writers-retired -o <file>
|
|
4029
|
+
Native whole-database copy (Control API seed); verified
|
|
4030
|
+
row- and binary-identical before any token is minted.
|
|
4031
|
+
Backups receive read-only archive credentials. Restore
|
|
4032
|
+
copies are writable only at the current schema;
|
|
4033
|
+
older-schema copies stay read-only until migrated.
|
|
4034
|
+
wod db migrate <source> [--name <target>] --writers-retired -o <file>
|
|
4035
|
+
Logical conversion to a fresh current-schema target via
|
|
4036
|
+
the shared IndexedDB adapter. The target stays
|
|
4037
|
+
unpairable (constructing marker) until replay, pin and
|
|
4038
|
+
source-immutability checks pass; the source is retained.
|
|
4039
|
+
wod db delete <name> --yes <name>
|
|
4040
|
+
Delete a database \u2014 --yes must repeat the exact name.
|
|
4041
|
+
wod db pair <name> [--allow-empty] -o <file> [--write-env]
|
|
4042
|
+
Mint a database-scoped token and write the pairing file
|
|
4043
|
+
for the browser Data settings (0600, exclusive create;
|
|
4044
|
+
--force replaces atomically). Refuses foreign, legacy,
|
|
4045
|
+
newer and unvalidated targets. --write-env opt-in
|
|
4046
|
+
appends DB_DRIVER/TURSO_* to the .env atomically and
|
|
4047
|
+
refuses to replace existing database settings.
|
|
4048
|
+
|
|
4049
|
+
DB OPTIONS (for 'wod db'):
|
|
4050
|
+
--name <db> Explicit target/copy database name
|
|
4051
|
+
--group <g> Turso group for 'create' (must exist); fallback target
|
|
4052
|
+
group when the API does not report one
|
|
4053
|
+
--writers-retired Acknowledge all writers on the source were stopped
|
|
4054
|
+
before backup/restore/migrate read it
|
|
4055
|
+
--allow-empty 'pair' a deliberately empty bootstrap target
|
|
4056
|
+
--write-env Opt-in .env output (atomic, refuses replacement)
|
|
4057
|
+
--force Replace an existing pairing file (stays 0600)
|
|
4058
|
+
-o, --output <file> Pairing file path \u2014 REQUIRED for credential-issuing
|
|
4059
|
+
actions; credentials are never printed to stdout
|
|
4060
|
+
|
|
3350
4061
|
wod server [options]
|
|
3351
4062
|
Serve the app (HTTP API + packaged frontend; alias: serve)
|
|
3352
4063
|
wod playground [options]
|
|
@@ -3475,6 +4186,7 @@ ENVIRONMENT (flags > process env > .env):
|
|
|
3475
4186
|
POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER,
|
|
3476
4187
|
POSTGRES_PASSWORD, POSTGRES_DATABASE, POSTGRES_SSL
|
|
3477
4188
|
TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, LIBSQL_AUTH_TOKEN
|
|
4189
|
+
TURSO_API_TOKEN, TURSO_ORGANIZATION wod db management credentials (env only)
|
|
3478
4190
|
HOST, PORT, PLAYGROUND_DIST server / playground defaults
|
|
3479
4191
|
|
|
3480
4192
|
EXIT CODES:
|
|
@@ -3988,6 +4700,28 @@ async function cliMain(argv = process.argv.slice(2), io = {}) {
|
|
|
3988
4700
|
}
|
|
3989
4701
|
return 0;
|
|
3990
4702
|
}
|
|
4703
|
+
if (parsed.subcommand === "db") {
|
|
4704
|
+
try {
|
|
4705
|
+
await runDbCommand({
|
|
4706
|
+
action: parsed.target,
|
|
4707
|
+
name: parsed.target2,
|
|
4708
|
+
dbName: parsed.dbName,
|
|
4709
|
+
group: parsed.group,
|
|
4710
|
+
yes: parsed.yes,
|
|
4711
|
+
output: parsed.output,
|
|
4712
|
+
writeEnv: parsed.writeEnv === true,
|
|
4713
|
+
writersRetired: parsed.writersRetired === true,
|
|
4714
|
+
allowEmpty: parsed.allowEmpty === true,
|
|
4715
|
+
force: parsed.force === true,
|
|
4716
|
+
envFile: parsed.envFile,
|
|
4717
|
+
env: cliEnv()
|
|
4718
|
+
}, { stdout: writeOut, stderr: writeErr }, io.dbDeps);
|
|
4719
|
+
return 0;
|
|
4720
|
+
} catch (error) {
|
|
4721
|
+
writeErr(error instanceof Error ? error.message : String(error));
|
|
4722
|
+
return 1;
|
|
4723
|
+
}
|
|
4724
|
+
}
|
|
3991
4725
|
if (parsed.subcommand === "server" || parsed.subcommand === "serve" || parsed.subcommand === "playground") {
|
|
3992
4726
|
const mode = parsed.subcommand === "playground" ? "playground" : "server";
|
|
3993
4727
|
const { runCliServer: runCliServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
|