sphica 0.5.7 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14187,7 +14187,7 @@ import os from "node:os";
14187
14187
  import path2 from "node:path";
14188
14188
  import { constants as C, DatabaseSync } from "node:sqlite";
14189
14189
  var SCHEMA_GENERATION = 2;
14190
- var SCHEMA_REVISION = 1;
14190
+ var SCHEMA_REVISION = 2;
14191
14191
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
14192
14192
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
14193
14193
  function requireRuntime() {
@@ -14211,7 +14211,7 @@ function prepare(raw, check) {
14211
14211
  const got = raw.prepare("pragma user_version").get()?.user_version;
14212
14212
  if (got === SCHEMA_REVISION)
14213
14213
  return;
14214
- throw new Error(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + ((got ?? 0) < SCHEMA_REVISION ? "Move it aside (it is left unchanged), then run `sphica init`." : "Update sphica."));
14214
+ throw new Error(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + ((got ?? 0) < SCHEMA_REVISION ? "Update the sphica CLI (`npm i -g sphica`), then run `sphica init` to migrate it (records are kept)." : "Update sphica."));
14215
14215
  }
14216
14216
  function generationOf(raw) {
14217
14217
  const has = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'sphica_generation'").get();
@@ -14383,22 +14383,54 @@ function captureAuthorizer(own, action, p1, p2, triggerOrView) {
14383
14383
  return C2.SQLITE_DENY;
14384
14384
  }
14385
14385
  var writes = (action) => action === C2.SQLITE_INSERT || action === C2.SQLITE_UPDATE || action === C2.SQLITE_DELETE;
14386
+ var FORGET_ONLY = new Set(["forget_batch", "source_forgotten"]);
14387
+ var KEPT_BY_INGEST = new Set(["source", "unit_evidence", "unit_adoption"]);
14386
14388
  function ingestAuthorizer(action, p1, p2) {
14387
14389
  if (DDL().has(action))
14388
14390
  return C2.SQLITE_DENY;
14389
- if (writes(action) && p1 === "owner_identity")
14391
+ if (writes(action) && (p1 === "owner_identity" || FORGET_ONLY.has(p1 ?? "")))
14392
+ return C2.SQLITE_DENY;
14393
+ if (action === C2.SQLITE_DELETE && KEPT_BY_INGEST.has(p1 ?? ""))
14390
14394
  return C2.SQLITE_DENY;
14391
14395
  if (action === C2.SQLITE_PRAGMA)
14392
14396
  return readsDataVersion(p1, p2) ? C2.SQLITE_OK : C2.SQLITE_DENY;
14393
14397
  return C2.SQLITE_OK;
14394
14398
  }
14399
+ var FORGET_WRITES = {
14400
+ [C2.SQLITE_INSERT]: new Set(["forget_batch", "source_forgotten", "unit_state", "source_fts"]),
14401
+ [C2.SQLITE_DELETE]: new Set([
14402
+ "source",
14403
+ "external_reference",
14404
+ "unit_evidence",
14405
+ "unit_adoption",
14406
+ "source_processing",
14407
+ "source_fts"
14408
+ ])
14409
+ };
14410
+ var FORGET_UPDATES = {
14411
+ unit: new Set(["lifecycle", "revision"]),
14412
+ unit_state: new Set(["source_id"])
14413
+ };
14414
+ function forgetAuthorizer(action, p1, p2) {
14415
+ if (SHADOW.test(p1 ?? "") && writes(action))
14416
+ return C2.SQLITE_OK;
14417
+ if (action === C2.SQLITE_UPDATE)
14418
+ return FORGET_UPDATES[p1 ?? ""]?.has(p2 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
14419
+ if (action === C2.SQLITE_INSERT || action === C2.SQLITE_DELETE)
14420
+ return FORGET_WRITES[action]?.has(p1 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
14421
+ if (action === C2.SQLITE_PRAGMA)
14422
+ return readsDataVersion(p1, p2) || p1 === "secure_delete" && ["on", "1", "true"].includes((p2 ?? "").toLowerCase()) || p1 === "wal_checkpoint" ? C2.SQLITE_OK : C2.SQLITE_DENY;
14423
+ if (action === C2.SQLITE_READ || action === C2.SQLITE_SELECT || action === C2.SQLITE_FUNCTION || action === C2.SQLITE_TRANSACTION || action === C2.SQLITE_SAVEPOINT)
14424
+ return C2.SQLITE_OK;
14425
+ return C2.SQLITE_DENY;
14426
+ }
14395
14427
  function connectWriter(role, file = dbFile(), create = false) {
14396
14428
  requireRuntime();
14397
14429
  if (!create)
14398
14430
  requireFile(file);
14399
14431
  const raw = new DatabaseSync2(file);
14400
14432
  try {
14401
- prepare(raw, create ? "none" : role === "ingest" ? "revision" : "generation");
14433
+ prepare(raw, create ? "none" : role === "ingest" || role === "forget" ? "revision" : "generation");
14402
14434
  raw.function("sphica_terms", { deterministic: true }, (text) => terms(String(text ?? "")).join(" "));
14403
14435
  } catch (e) {
14404
14436
  raw.close();
@@ -14406,6 +14438,8 @@ function connectWriter(role, file = dbFile(), create = false) {
14406
14438
  }
14407
14439
  if (role === "ingest")
14408
14440
  raw.setAuthorizer(ingestAuthorizer);
14441
+ else if (role === "forget")
14442
+ raw.setAuthorizer(forgetAuthorizer);
14409
14443
  else if (role === "capture") {
14410
14444
  let own = false;
14411
14445
  raw.setAuthorizer((action, p1, p2, _db, triggerOrView) => captureAuthorizer(own, action, p1, p2, triggerOrView));
@@ -14431,6 +14465,7 @@ function openWriter(role, file = dbFile()) {
14431
14465
  // server/src/admin.ts
14432
14466
  var say = (text) => console.log(indent(text));
14433
14467
  var SCHEMA = () => path3.join(dbDir(), "schema.sql");
14468
+ var MIGRATION = (revision) => path3.join(dbDir(), "migrations", `${String(revision).padStart(4, "0")}.sql`);
14434
14469
  var versionOf = (raw) => raw.prepare("pragma user_version").get().user_version;
14435
14470
  function immediate(raw, fn) {
14436
14471
  raw.exec("begin immediate");
@@ -14451,6 +14486,32 @@ function withOwner(file, fn, create = false) {
14451
14486
  raw.close();
14452
14487
  }
14453
14488
  }
14489
+ function migrate(file = dbFile()) {
14490
+ return withOwner(file, (raw) => {
14491
+ const from = versionOf(raw);
14492
+ for (let r2 = from + 1;r2 <= SCHEMA_REVISION; r2++) {
14493
+ const script = MIGRATION(r2);
14494
+ if (!fs3.existsSync(script))
14495
+ throw new Error(`No migration from revision ${r2 - 1}. Move the database aside, then run \`sphica init\`.`);
14496
+ raw.exec("pragma foreign_keys = off");
14497
+ try {
14498
+ immediate(raw, () => {
14499
+ if (versionOf(raw) !== r2 - 1)
14500
+ return;
14501
+ raw.exec(fs3.readFileSync(script, "utf8"));
14502
+ const broken = raw.prepare("pragma foreign_key_check").all().length;
14503
+ if (broken)
14504
+ throw new Error(`Migration to revision ${r2} left ${plural(broken, "broken reference")}; nothing was changed`);
14505
+ if (versionOf(raw) !== r2)
14506
+ throw new Error(`${path3.basename(script)} did not set revision ${r2}`);
14507
+ });
14508
+ } finally {
14509
+ raw.exec("pragma foreign_keys = on");
14510
+ }
14511
+ }
14512
+ return from;
14513
+ });
14514
+ }
14454
14515
  function dbInit(file = dbFile()) {
14455
14516
  if (fs3.existsSync(file)) {
14456
14517
  const look = new DatabaseSync3(file, { readOnly: true });
@@ -14465,8 +14526,12 @@ function dbInit(file = dbFile()) {
14465
14526
  const got = withOwner(file, versionOf);
14466
14527
  if (got === SCHEMA_REVISION)
14467
14528
  say(`Already exists: ${file} (revision ${got})`);
14468
- else
14469
- say(`Already exists: ${file} (revision ${got}; this Sphica expects ${SCHEMA_REVISION}. Move it aside, then run \`sphica init\`.)`);
14529
+ else if (got > SCHEMA_REVISION)
14530
+ say(`Already exists: ${file} (revision ${got}, made by a newer Sphica; this one expects ${SCHEMA_REVISION}. Update sphica.)`);
14531
+ else {
14532
+ migrate(file);
14533
+ say(`Migrated: ${file} (revision ${got} → ${SCHEMA_REVISION})`);
14534
+ }
14470
14535
  return;
14471
14536
  }
14472
14537
  fs3.mkdirSync(path3.dirname(file), { recursive: true, mode: 448 });
@@ -15067,6 +15132,7 @@ function lock() {
15067
15132
  return null;
15068
15133
  }
15069
15134
  var BATCH = 500;
15135
+ var FLUSH_BUDGET_MS = 30000;
15070
15136
  var ROWS = 1000;
15071
15137
  var chunks = (xs, n2 = ROWS) => Array.from({ length: Math.ceil(xs.length / n2) }, (_, i2) => xs.slice(i2 * n2, (i2 + 1) * n2));
15072
15138
  async function write(db, batch, projects) {
@@ -15142,98 +15208,135 @@ async function write(db, batch, projects) {
15142
15208
  }
15143
15209
  var REJECTED = new Set([18, 19, 20, 25]);
15144
15210
  var rejected = (e) => REJECTED.has(sqliteCode(e) ?? -1);
15145
- async function flush(file = dbFile()) {
15146
- const unlock = lock();
15147
- if (!unlock)
15148
- return { sent: 0, deferred: 0, rejected: 0, busy: true };
15149
- const dir = spoolDir();
15150
- let client = null;
15211
+ var queued = (from) => {
15151
15212
  try {
15152
- const held = unregisteredDir();
15153
- const list = (from) => {
15213
+ return fs6.readdirSync(from).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort().map((name) => ({ name, from }));
15214
+ } catch {
15215
+ return [];
15216
+ }
15217
+ };
15218
+ async function sendBatch(db, names) {
15219
+ const held = unregisteredDir();
15220
+ const records = [];
15221
+ for (const { name, from } of names) {
15222
+ let r2;
15223
+ try {
15224
+ r2 = current(JSON.parse(fs6.readFileSync(path7.join(from, name), "utf8")));
15225
+ } catch {
15226
+ fs6.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
15227
+ fs6.renameSync(path7.join(from, name), path7.join(rejectedDir(), name));
15228
+ continue;
15229
+ }
15230
+ if (r2)
15231
+ records.push({ name, from, r: r2 });
15232
+ else
15233
+ fs6.rmSync(path7.join(from, name), { force: true });
15234
+ }
15235
+ const projects = new Map((await db.selectFrom("project").select(["id", "key", "name"]).where("key", "in", [...new Set(records.map((x) => x.r.project))]).execute()).map((p) => [p.key, { id: p.id, name: p.name }]));
15236
+ const known = records.filter((x) => projects.has(x.r.project));
15237
+ const strayed = records.filter((x) => !projects.has(x.r.project));
15238
+ let sent = 0;
15239
+ const bad = [];
15240
+ try {
15241
+ sent = await write(db, known.map((x) => x.r), projects);
15242
+ } catch (e) {
15243
+ if (!rejected(e))
15244
+ throw e;
15245
+ for (const x of known) {
15154
15246
  try {
15155
- return fs6.readdirSync(from).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((name) => ({ name, from }));
15156
- } catch {
15157
- return [];
15247
+ sent += await write(db, [x.r], projects);
15248
+ } catch (e2) {
15249
+ if (!rejected(e2))
15250
+ throw e2;
15251
+ bad.push(x);
15158
15252
  }
15159
- };
15160
- const names = [...list(dir), ...list(held)].sort((a2, b) => a2.name < b.name ? -1 : a2.name > b.name ? 1 : 0).slice(0, BATCH);
15161
- if (names.length === 0)
15162
- return { sent: 0, deferred: 0, rejected: 0 };
15163
- const records = [];
15164
- for (const { name, from } of names) {
15165
- let r2;
15253
+ }
15254
+ }
15255
+ if (bad.length) {
15256
+ fs6.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
15257
+ for (const x of bad) {
15166
15258
  try {
15167
- r2 = current(JSON.parse(fs6.readFileSync(path7.join(from, name), "utf8")));
15168
- } catch {
15169
- fs6.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
15170
- fs6.renameSync(path7.join(from, name), path7.join(rejectedDir(), name));
15171
- continue;
15172
- }
15173
- if (r2)
15174
- records.push({ name, from, r: r2 });
15175
- else
15176
- fs6.rmSync(path7.join(from, name), { force: true });
15177
- }
15178
- const db = openWriter("capture", file);
15179
- client = db;
15180
- const projects = new Map((await db.selectFrom("project").select(["id", "key", "name"]).where("key", "in", [...new Set(records.map((x) => x.r.project))]).execute()).map((p) => [p.key, { id: p.id, name: p.name }]));
15181
- const known = records.filter((x) => projects.has(x.r.project));
15182
- const strayed = records.filter((x) => !projects.has(x.r.project));
15183
- let sent = 0;
15184
- const bad = [];
15185
- try {
15186
- sent = await write(db, known.map((x) => x.r), projects);
15187
- } catch (e) {
15188
- if (!rejected(e))
15189
- throw e;
15190
- for (const x of known) {
15191
- try {
15192
- sent += await write(db, [x.r], projects);
15193
- } catch (e2) {
15194
- if (!rejected(e2))
15195
- throw e2;
15196
- bad.push(x);
15197
- }
15259
+ fs6.renameSync(path7.join(x.from, x.name), path7.join(rejectedDir(), x.name));
15260
+ } catch (e) {
15261
+ if (e.code !== "ENOENT")
15262
+ throw e;
15198
15263
  }
15199
15264
  }
15200
- if (bad.length) {
15201
- fs6.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
15202
- for (const x of bad) {
15203
- try {
15204
- fs6.renameSync(path7.join(x.from, x.name), path7.join(rejectedDir(), x.name));
15205
- } catch (e) {
15206
- if (e.code !== "ENOENT")
15207
- throw e;
15208
- }
15265
+ }
15266
+ if (strayed.length) {
15267
+ fs6.mkdirSync(held, { recursive: true, mode: 448 });
15268
+ for (const x of strayed) {
15269
+ if (x.from === held)
15270
+ continue;
15271
+ try {
15272
+ fs6.renameSync(path7.join(x.from, x.name), path7.join(held, x.name));
15273
+ } catch (e) {
15274
+ if (e.code !== "ENOENT")
15275
+ throw e;
15209
15276
  }
15210
15277
  }
15211
- if (strayed.length) {
15212
- fs6.mkdirSync(held, { recursive: true, mode: 448 });
15213
- for (const x of strayed) {
15214
- if (x.from === held)
15215
- continue;
15216
- try {
15217
- fs6.renameSync(path7.join(x.from, x.name), path7.join(held, x.name));
15218
- } catch (e) {
15219
- if (e.code !== "ENOENT")
15220
- throw e;
15278
+ }
15279
+ const moved = new Set([...bad, ...strayed].map((x) => x.name));
15280
+ for (const x of records)
15281
+ if (!moved.has(x.name))
15282
+ fs6.rmSync(path7.join(x.from, x.name), { force: true });
15283
+ return { sent, rejected: bad.length };
15284
+ }
15285
+ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
15286
+ const deadline = Date.now() + budgetMs;
15287
+ const dir = spoolDir();
15288
+ const held = unregisteredDir();
15289
+ const total = { sent: 0, deferred: 0, rejected: 0 };
15290
+ let batches = 0;
15291
+ let queueBatches = 0;
15292
+ for (let first = true;; first = false) {
15293
+ let unlock = lock();
15294
+ while (!unlock && Date.now() < deadline) {
15295
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, deadline - Date.now())));
15296
+ if (Date.now() < deadline)
15297
+ unlock = lock();
15298
+ }
15299
+ if (!unlock)
15300
+ return first ? { ...total, busy: true } : total;
15301
+ let client = null;
15302
+ const send = async (names) => {
15303
+ client ??= openWriter("capture", file);
15304
+ const r2 = await sendBatch(client, names);
15305
+ total.sent += r2.sent;
15306
+ total.rejected += r2.rejected;
15307
+ batches++;
15308
+ };
15309
+ const late = () => batches > 0 && Date.now() >= deadline;
15310
+ const sentBefore = batches;
15311
+ try {
15312
+ if (first) {
15313
+ prune(held);
15314
+ for (const part of chunks(queued(held), BATCH)) {
15315
+ if (late())
15316
+ break;
15317
+ await send(part);
15221
15318
  }
15222
15319
  }
15320
+ while (queueBatches === 0 || !late()) {
15321
+ const part = queued(dir).slice(0, BATCH);
15322
+ if (part.length === 0)
15323
+ break;
15324
+ await send(part);
15325
+ queueBatches++;
15326
+ }
15327
+ prune(held);
15328
+ total.deferred = queued(held).length;
15329
+ if (batches > sentBefore)
15330
+ writeState({ flushedAt: new Date().toISOString(), error: null, deferred: total.deferred });
15331
+ } catch (e) {
15332
+ writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300) });
15333
+ throw e;
15334
+ } finally {
15335
+ await client?.destroy().catch(() => {});
15336
+ unlock();
15223
15337
  }
15224
- const moved = new Set([...bad, ...strayed].map((x) => x.name));
15225
- for (const x of records)
15226
- if (!moved.has(x.name))
15227
- fs6.rmSync(path7.join(x.from, x.name), { force: true });
15228
- prune(held);
15229
- writeState({ flushedAt: new Date().toISOString(), error: null, deferred: strayed.length });
15230
- return { sent, deferred: strayed.length, rejected: bad.length };
15231
- } catch (e) {
15232
- writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300) });
15233
- throw e;
15234
- } finally {
15235
- await client?.destroy().catch(() => {});
15236
- unlock();
15338
+ if (Date.now() >= deadline || queued(dir).length === 0)
15339
+ return total;
15237
15340
  }
15238
15341
  }
15239
15342
  async function readInput(stream) {
@@ -15736,7 +15839,7 @@ async function doctor(cwd) {
15736
15839
  const x = inspect(file);
15737
15840
  usable2 = x.revision === SCHEMA_REVISION;
15738
15841
  say2("ok", "DB", `${file} (${(x.bytes / 1024 / 1024).toFixed(1)} MB)`);
15739
- say2(usable2 ? "ok" : "fail", "Schema version", usable2 ? `revision ${x.revision}` : `revision ${x.revision}, this Sphica expects ${SCHEMA_REVISION} (${x.revision < SCHEMA_REVISION ? "made by an older Sphica: move it aside, then run sphica init" : "update sphica"})`);
15842
+ say2(usable2 ? "ok" : "fail", "Schema version", usable2 ? `revision ${x.revision}` : `revision ${x.revision}, this Sphica expects ${SCHEMA_REVISION} (${x.revision < SCHEMA_REVISION ? "made by an older Sphica: run sphica init to migrate it" : "update sphica"})`);
15740
15843
  const broken = Object.entries(x.fts).filter(([, v]) => v !== null);
15741
15844
  say2(broken.length ? "fail" : "ok", "Full-text index", broken.length ? `broken: ${broken.map(([k, v]) => `${k} (${plain(v ?? "")})`).join(" / ")}. Rebuild it with sphica doctor --reindex` : "healthy");
15742
15845
  } catch (e) {