@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.6

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.
@@ -60,6 +60,20 @@ export interface SchemaField {
60
60
  type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
61
61
  enum?: string[];
62
62
  description?: string;
63
+ /**
64
+ * Mirrors `TagFieldSchema.indexed` (core/src/tag-schemas.ts) — this field
65
+ * has a generated column + B-tree index maintained on `notes` (see
66
+ * core/src/indexed-fields.ts). Parsed here (not just there) because
67
+ * `validateNote` uses it: an indexed field is a QUERY CONTRACT, not just a
68
+ * storage hint, so a `type_mismatch` on an indexed field is always
69
+ * enforced (`strict: true` on that ONE warning) regardless of this field's
70
+ * own `strict` flag (vault#553 Decision A). Every other constraint
71
+ * (enum/required/cardinality) on an indexed field stays governed by
72
+ * `strict` as before — only the TYPE contract is unconditional, because
73
+ * that's the one a type-mismatched write can silently poison range
74
+ * queries with (SQLite's TEXT-sorts-above-INTEGER affinity ordering).
75
+ */
76
+ indexed?: boolean;
63
77
  /**
64
78
  * Strict enforcement opt-in (vault#299, Part A). Default `false` — when
65
79
  * unset/false, ALL constraints on this field are advisory: violations
@@ -169,6 +183,7 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
169
183
  if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
170
184
  if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
171
185
  if (typeof f.description === "string") field.description = f.description;
186
+ if (f.indexed === true) field.indexed = true;
172
187
  if (f.strict === true) field.strict = true;
173
188
  if (f.required === true) field.required = true;
174
189
  if (f.cardinality === "one" || f.cardinality === "many") field.cardinality = f.cardinality;
@@ -319,6 +334,20 @@ function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): bo
319
334
  return true;
320
335
  }
321
336
 
337
+ /**
338
+ * Human-readable JSON-shape name for a value, used to name the "got" side of
339
+ * a `type_mismatch` message (vault#553 — "message names field + expected
340
+ * type + got type"). Distinct from SQLite's `json_type()` vocabulary
341
+ * (integer/real/true/false/text/...) — this is the JSON-Schema-ish vocabulary
342
+ * `SchemaField.type` already uses, so the message reads as "expected X, got
343
+ * Y" in the SAME words the schema declares.
344
+ */
345
+ function jsonTypeOf(value: unknown): string {
346
+ if (value === null) return "null";
347
+ if (Array.isArray(value)) return "array";
348
+ return typeof value; // "string" | "number" | "boolean" | "object" | ...
349
+ }
350
+
322
351
  function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
323
352
  if (type === undefined) return true;
324
353
  switch (type) {
@@ -357,11 +386,15 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
357
386
  * → `cardinality_mismatch`
358
387
  *
359
388
  * Every violation carries `strict: true` iff its field declared `strict:true`
360
- * (vault#299). The list itself is the SAME whether or not a field is strict —
361
- * the difference is only the per-warning `strict` flag, which the write path
362
- * uses to decide block-vs-warn. Under `strict:false` this is byte-identical to
363
- * the historical advisory behavior PLUS the new `required`/`cardinality`
364
- * advisory reasons (which fire for any field declaring those, strict or not).
389
+ * (vault#299) EXCEPT a `type_mismatch` on an `indexed: true` field, which is
390
+ * ALWAYS `strict: true` regardless of the field's own `strict` setting
391
+ * (vault#553 Decision A an indexed field's type is a query contract, not
392
+ * just guidance). The list itself is the SAME whether or not a field is
393
+ * strict the difference is only the per-warning `strict` flag, which the
394
+ * write path uses to decide block-vs-warn. Under `strict:false` (and
395
+ * `indexed:false`) this is byte-identical to the historical advisory
396
+ * behavior PLUS the new `required`/`cardinality` advisory reasons (which
397
+ * fire for any field declaring those, strict or not).
365
398
  *
366
399
  * Fields not declared by any ancestor's schema are ignored entirely.
367
400
  */
@@ -397,12 +430,20 @@ export function validateNote(
397
430
  if (absent) continue;
398
431
 
399
432
  if (spec.type && !valueMatchesType(value, spec.type)) {
433
+ // Decision A (vault#553): an INDEXED field's type is a query
434
+ // contract — a type-mismatched write poisons range-query ordering
435
+ // (SQLite's TEXT-sorts-above-INTEGER affinity) regardless of whether
436
+ // this field opted into `strict`. Force `strict: true` on THIS
437
+ // warning alone when the field is indexed; every other constraint
438
+ // (enum/required/cardinality) stays governed by `spec.strict` as
439
+ // before.
440
+ const indexedTypeStrict = spec.indexed === true;
400
441
  warnings.push({
401
442
  field: fieldName,
402
443
  schema: sourceTag,
403
444
  reason: "type_mismatch",
404
- message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
405
- ...strictFlag,
445
+ message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}'), got ${jsonTypeOf(value)}${indexedTypeStrict ? " — indexed fields reject type-mismatched writes (vault#553)" : ""}`,
446
+ ...(strictFlag.strict || indexedTypeStrict ? { strict: true } : {}),
406
447
  });
407
448
  }
408
449
 
@@ -1,9 +1,10 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
- import { rebuildIndexes } from "./indexed-fields.js";
3
+ import { rebuildIndexes, listIndexedFields } from "./indexed-fields.js";
4
+ import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
4
5
  import { transaction } from "./txn.js";
5
6
 
6
- export const SCHEMA_VERSION = 23;
7
+ export const SCHEMA_VERSION = 24;
7
8
 
8
9
  export const SCHEMA_SQL = `
9
10
  -- Notes: the universal record.
@@ -502,6 +503,10 @@ export function initSchema(db: Database): void {
502
503
  // vault#298.
503
504
  migrateToV23(db);
504
505
 
506
+ // Migrate v23 → v24: coerce existing typed-index poison where lossless,
507
+ // leave the rest in place for `doctor` to surface. See vault#553.
508
+ migrateToV24(db);
509
+
505
510
  // Rebuild any generated columns + indexes declared in indexed_fields.
506
511
  // No-op for a fresh vault; idempotent on existing vaults.
507
512
  rebuildIndexes(db);
@@ -1187,6 +1192,146 @@ function migrateToV23(db: Database): void {
1187
1192
  }
1188
1193
  }
1189
1194
 
1195
+ /** Sentinel returned by {@link coerceIndexedValue} when no lossless conversion exists. */
1196
+ const NOT_COERCIBLE = Symbol("not-coercible");
1197
+
1198
+ /** Full JSON-number grammar — used to gate string→number coercion to values that round-trip exactly. */
1199
+ const JSON_NUMBER_RE = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
1200
+
1201
+ /**
1202
+ * Decide the lossless coercion (if any) for a single poisoned indexed-field
1203
+ * value, given `jt` — the SQLite `json_type()` of its CURRENT value (the
1204
+ * SAME vocabulary `doctor`'s `findMixedTypeIndexedFieldNotes` compares
1205
+ * against: "text", "integer", "real", "true", "false", "array", "object")
1206
+ * — and `targetSqliteType`, the field's declared storage class ("INTEGER"
1207
+ * or "TEXT"). Returns the coerced value, or {@link NOT_COERCIBLE} when no
1208
+ * exact round-trip conversion exists — the caller leaves the value
1209
+ * untouched (never deletes or nulls note data).
1210
+ *
1211
+ * Coercible cases (vault#553 Decision D):
1212
+ * - INTEGER target, TEXT source: a clean numeric string ("5", "5.5",
1213
+ * "-3") → the JSON number, ONLY when `String(Number(str)) === str`
1214
+ * (exact round-trip — rejects "5.50", scientific-notation reformats,
1215
+ * and anything outside safe double precision that wouldn't survive the
1216
+ * trip). "true"/"false" (exact string match) → the JSON boolean.
1217
+ * - TEXT target, INTEGER/REAL/boolean source: a number or boolean → its
1218
+ * JSON string form via `String(value)` — always exact for finite JS
1219
+ * numbers and booleans within IEEE-754 double precision.
1220
+ * Never coercible: array/object/null values in either direction, or a TEXT
1221
+ * value that isn't a clean number/boolean string (e.g. "four", "5,000").
1222
+ */
1223
+ function coerceIndexedValue(
1224
+ value: unknown,
1225
+ jt: string,
1226
+ targetSqliteType: string,
1227
+ ): unknown | typeof NOT_COERCIBLE {
1228
+ if (targetSqliteType === "INTEGER") {
1229
+ if (jt !== "text" || typeof value !== "string") return NOT_COERCIBLE;
1230
+ if (value === "true") return true;
1231
+ if (value === "false") return false;
1232
+ if (JSON_NUMBER_RE.test(value)) {
1233
+ const n = Number(value);
1234
+ if (Number.isFinite(n) && String(n) === value) return n;
1235
+ }
1236
+ return NOT_COERCIBLE;
1237
+ }
1238
+ if (targetSqliteType === "TEXT") {
1239
+ if ((jt === "true" || jt === "false") && typeof value === "boolean") {
1240
+ return jt; // "true" / "false" — the json_type string IS the target text
1241
+ }
1242
+ if ((jt === "integer" || jt === "real") && typeof value === "number") {
1243
+ const s = String(value);
1244
+ if (Number(s) === value) return s;
1245
+ }
1246
+ return NOT_COERCIBLE;
1247
+ }
1248
+ return NOT_COERCIBLE;
1249
+ }
1250
+
1251
+ /**
1252
+ * Migrate v23 → v24: typed-index poison coercion (vault#553 Decision D).
1253
+ *
1254
+ * For every declared indexed field (`indexed_fields` — the SSOT from
1255
+ * `core/src/indexed-fields.ts`) and every note whose `metadata.<field>` JSON
1256
+ * type disagrees with the field's declared sqlite storage class — reusing
1257
+ * `doctor`'s `findMixedTypeIndexedFieldNotes` (`core/src/doctor.ts`) as the
1258
+ * DETECTOR, the exact same query that powers the `mixed_type_indexed_field`
1259
+ * finding, so migration and diagnostic can never disagree on what counts as
1260
+ * poisoned:
1261
+ *
1262
+ * - COERCE where {@link coerceIndexedValue} finds a lossless conversion —
1263
+ * rewrites `notes.metadata` in place (the generated `meta_<field>`
1264
+ * column re-derives via `json_extract` automatically; nothing else to
1265
+ * touch).
1266
+ * - LEAVE IN PLACE everything else. NEVER DELETE OR NULL NOTE DATA in a
1267
+ * startup migration — a non-coercible value (e.g. `"hello"` in an
1268
+ * integer field) stays exactly as written and remains visible via
1269
+ * `doctor`'s `mixed_type_indexed_field` finding for the operator to
1270
+ * clean up deliberately.
1271
+ *
1272
+ * Rewrites happen at the JS level — `SELECT metadata` → `JSON.parse` →
1273
+ * mutate the one field → `JSON.stringify` → single-row `UPDATE ... WHERE id
1274
+ * = ?` (two bound params) — rather than SQL `json_set`, so the migration
1275
+ * stays portable to a narrower json1 surface (Cloudflare DO SQLite; flagged
1276
+ * for the wire reviewer to confirm). Detection still uses
1277
+ * `json_extract`/`json_type`/`json_valid`, the same functions the indexed
1278
+ * generated columns and `doctor` already depend on everywhere in this
1279
+ * codebase — not a new cross-runtime risk. One row at a time also means this
1280
+ * never approaches SQLite's bound-parameter ceiling regardless of how many
1281
+ * notes are poisoned (no batched `WHERE id IN (...)`).
1282
+ *
1283
+ * Idempotent: every step is naturally re-runnable (a coerced value now
1284
+ * agrees with the declared type and is never re-detected as a mismatch; a
1285
+ * left-in-place value re-detects identically and is re-skipped identically).
1286
+ * Safe on a vault with zero indexed fields (the field-list guard short-
1287
+ * circuits before any table scan) and safe to run on every boot, matching
1288
+ * the unconditional-migration-function idiom the rest of this file uses.
1289
+ */
1290
+ function migrateToV24(db: Database): void {
1291
+ if (!hasTable(db, "notes") || !hasTable(db, "indexed_fields")) return;
1292
+ const fields = listIndexedFields(db);
1293
+ if (fields.length === 0) return;
1294
+
1295
+ let coerced = 0;
1296
+ let left = 0;
1297
+
1298
+ transaction(db, () => {
1299
+ const readStmt = db.prepare("SELECT metadata FROM notes WHERE id = ?");
1300
+ const updateStmt = db.prepare("UPDATE notes SET metadata = ? WHERE id = ?");
1301
+
1302
+ for (const f of fields) {
1303
+ const mismatches = findMixedTypeIndexedFieldNotes(db, f.field, f.sqliteType);
1304
+ for (const { id, jt } of mismatches) {
1305
+ const row = readStmt.get(id) as { metadata: string | null } | null;
1306
+ if (!row?.metadata) continue;
1307
+ let meta: unknown;
1308
+ try {
1309
+ meta = JSON.parse(row.metadata);
1310
+ } catch {
1311
+ continue; // shouldn't happen — findMixedTypeIndexedFieldNotes already filters to json_valid rows
1312
+ }
1313
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) continue;
1314
+ const obj = meta as Record<string, unknown>;
1315
+ if (!(f.field in obj)) continue;
1316
+ const coercedValue = coerceIndexedValue(obj[f.field], jt, f.sqliteType);
1317
+ if (coercedValue === NOT_COERCIBLE) {
1318
+ left++;
1319
+ continue;
1320
+ }
1321
+ obj[f.field] = coercedValue;
1322
+ updateStmt.run(JSON.stringify(obj), id);
1323
+ coerced++;
1324
+ }
1325
+ }
1326
+ });
1327
+
1328
+ if (coerced > 0 || left > 0) {
1329
+ console.log(
1330
+ `[vault] migrated to schema v24 (vault#553): typed-index poison scan — coerced ${coerced} value(s) to their declared indexed type; left ${left} non-coercible value(s) in place (see \`doctor\`'s mixed_type_indexed_field finding for cleanup).`,
1331
+ );
1332
+ }
1333
+ }
1334
+
1190
1335
  function hasTable(db: Database, name: string): boolean {
1191
1336
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1192
1337
  return !!row;
@@ -468,9 +468,9 @@ update-tag {
468
468
  tag: "meeting",
469
469
  description: "A meeting with notes",
470
470
  fields: {
471
- held_on: { type: "string", indexed: true }, // queryable with operators
472
- status: { type: "string", enum: ["scheduled", "done"] }, // first enum value is the default
473
- rating: { type: "integer" }
471
+ held_on: { type: "string", indexed: true }, // queryable with operators
472
+ status: { type: "string", enum: ["scheduled", "done"], default: "scheduled" }, // explicit default declare one to auto-fill
473
+ rating: { type: "integer" } // no default — stays unset until written
474
474
  }
475
475
  }
476
476
  \`\`\`
@@ -490,16 +490,23 @@ A few behaviors worth knowing before you write at scale:
490
490
  conflict error (re-read, reconcile, retry). For bulk/scripted writes where
491
491
  concurrency is known-safe, pass \`force: true\` to waive the *requirement to
492
492
  supply* it. \`append\`/\`prepend\`-only updates are exempt (no-conflict-by-design).
493
- - **A schema field's default is filled in on write, so it shows up even when you
494
- didn't set it.** When a note gets a tag whose schema declares a field, the
495
- missing field is back-filled: an \`enum\` field its **first listed value**, an
496
- \`integer\` \`0\`, a \`boolean\` \`false\`, a plain string \`""\`. So a
497
- \`rating: { type: "integer" }\` reads as \`0\` on notes nobody rated — that \`0\`
498
- is "unset," not "rated zero." Order an \`enum\`'s values so the first is a sane
499
- default, and don't read a back-filled \`0\`/\`""\`/\`false\` as a real value.
500
- - **Validation is advisory, never blocking.** A type/enum mismatch comes back as
501
- a \`validation_status\` warning on the write response the write still lands.
502
- Read those warnings and self-correct on the next turn.
493
+ - **A schema field only back-fills when you declare an explicit \`default\`.**
494
+ When a note gets a tag whose schema declares a field with a \`default\`, an
495
+ unset field is filled with THAT value. A field with no \`default\` stays
496
+ genuinely absent \`query-notes { metadata: { rating: { exists: false } } }\`
497
+ reliably finds notes that never set \`rating\`. Declare \`default\` on a field
498
+ only when "unset" and "explicitly set to X" should read the same; leave it
499
+ off when you need to tell them apart.
500
+ - **Validation is advisory, never blocking EXCEPT an \`indexed: true\`
501
+ field's type.** A type/enum mismatch on a non-indexed field (or a
502
+ non-\`strict\` constraint on any field) comes back as a \`validation_status\`
503
+ warning and the write still lands — read those warnings and self-correct on
504
+ the next turn. An \`indexed: true\` field's TYPE is always enforced, though:
505
+ writing a string into an indexed \`integer\` field is REJECTED with a
506
+ \`schema_validation\` error, because a bad-typed value would otherwise
507
+ silently poison range queries (\`gt\`/\`gte\`/\`lt\`/\`lte\`) on that field. Mark a
508
+ field \`strict: true\` to enforce its OTHER constraints
509
+ (enum/required/cardinality) the same hard way.
503
510
 
504
511
  (Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
505
512
 
package/core/src/store.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  type ConformanceReport,
36
36
  } from "./conformance.js";
37
37
  import type { SearchMode } from "./search-query.js";
38
+ import { runDoctorScan, type DoctorReport, type DoctorScanOpts } from "./doctor.js";
38
39
 
39
40
  /**
40
41
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -457,10 +458,15 @@ export class BunSqliteStore implements Store {
457
458
  return noteOps.listTags(this.db);
458
459
  }
459
460
 
460
- async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
461
- const result = noteOps.deleteTag(this.db, name);
461
+ async deleteTag(name: string, opts?: noteOps.DeleteTagOpts): Promise<noteOps.DeleteTagResult> {
462
+ const result = noteOps.deleteTag(this.db, name, opts);
463
+ // Referential-integrity refusal (vault#552) — nothing was written;
464
+ // caches and hooks are untouched.
465
+ if ("error" in result) return result;
462
466
  // The deleted tag may have been a parent or child in the hierarchy
463
- // and may have declared `fields` powering schema validation.
467
+ // and may have declared `fields` powering schema validation. A
468
+ // cascade/detach repair also rewrites OTHER tags' parent_names, so
469
+ // busting the hierarchy cache covers both cases.
464
470
  this._tagHierarchy = null;
465
471
  this._schemaConfig = null;
466
472
  // Fire "deleted" only when SOMETHING happened (the underlying
@@ -644,6 +650,14 @@ export class BunSqliteStore implements Store {
644
650
  return count;
645
651
  }
646
652
 
653
+ /**
654
+ * Read-only taxonomy/metadata integrity scan (vault#552). Pure read — no
655
+ * cache invalidation needed since nothing is written.
656
+ */
657
+ async doctor(opts?: DoctorScanOpts): Promise<DoctorReport> {
658
+ return runDoctorScan(this.db, opts);
659
+ }
660
+
647
661
  // ---- Tag Records (post-v14: full identity row) ----
648
662
 
649
663
  async listTagRecords() {
@@ -727,6 +741,24 @@ export class BunSqliteStore implements Store {
727
741
  // Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
728
742
  indexedFieldOps.validateFieldName(fieldName);
729
743
  }
744
+ // Default-conformance pre-validate (vault#553 Decision B) — mirrors the
745
+ // indexed-type/name checks above: fail BEFORE any persistence so a bad
746
+ // `default` never gets written. Runs over EVERY field in the full
747
+ // (already-merged) `nextFields` map, not just indexed ones — a default
748
+ // that doesn't match its own field's type/enum is a tag-schema error
749
+ // regardless of queryability. This is the REST parity path: MCP's
750
+ // `update-tag` tool ALSO calls `collectTagFieldViolations` before
751
+ // reaching here (bundling every violation into one `TagFieldConflictError`
752
+ // — see that function's doc comment), so a conforming MCP call never
753
+ // trips this; a non-conforming one throws there first. REST has no such
754
+ // pre-check, so this is REST's only gate — same asymmetry as the
755
+ // indexed-type/name checks it sits beside.
756
+ for (const [fieldName, spec] of Object.entries(nextFields ?? {})) {
757
+ const violation = tagSchemaOps.validateFieldDefault(fieldName, spec);
758
+ if (violation) {
759
+ throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, violation.message);
760
+ }
761
+ }
730
762
  }
731
763
 
732
764
  // Persist the record + reconcile the indexed-field lifecycle atomically.
@@ -388,3 +388,69 @@ export function findHierarchyCycles(h: TagHierarchy): string[] {
388
388
  }
389
389
  return cycles;
390
390
  }
391
+
392
+ /**
393
+ * Cycle guard for a PROSPECTIVE `parent_names` write (vault#552). Setting
394
+ * `tag`'s parents to include `p` adds the edge `childrenOf[p] += tag` (tag
395
+ * becomes a child of `p`). That closes a cycle iff `tag` can ALREADY reach
396
+ * `p` via the existing hierarchy — i.e. `p` is already in `tag`'s descendant
397
+ * set — because the new edge would then let `p` reach back to itself through
398
+ * `tag`. Self-parent (`parentNames` containing `tag` itself) is caught by the
399
+ * same check: `getTagDescendants` always includes `tag` in its own result.
400
+ *
401
+ * Returns the offending cycle path (`[tag, ...existing chain down to p, p,
402
+ * tag]`, closing the loop) for the FIRST conflicting parent found, or `null`
403
+ * when no cycle would result. `h` must reflect the hierarchy BEFORE this
404
+ * write — the check only depends on OTHER tags' parent_names (edges pointing
405
+ * away from `tag`), never `tag`'s own current parent_names, so passing the
406
+ * pre-write hierarchy is always correct regardless of write ordering.
407
+ */
408
+ export function findParentCycle(
409
+ h: TagHierarchy,
410
+ tag: string,
411
+ parentNames: string[],
412
+ ): string[] | null {
413
+ const descendants = getTagDescendants(h, tag);
414
+ for (const p of parentNames) {
415
+ if (descendants.has(p)) {
416
+ return [...findChildPath(h, tag, p), tag];
417
+ }
418
+ }
419
+ return null;
420
+ }
421
+
422
+ /**
423
+ * BFS shortest path from `from` to `to` following `childrenOf` edges
424
+ * (parent → child). Used only to render a human-legible cycle path for
425
+ * {@link findParentCycle}'s error — the existence of a path is already
426
+ * established by the caller (`to` is a known member of `from`'s descendant
427
+ * set) before this runs, so the fallback return is unreachable in practice
428
+ * and exists only to keep the function total.
429
+ */
430
+ function findChildPath(h: TagHierarchy, from: string, to: string): string[] {
431
+ if (from === to) return [from];
432
+ const queue: string[] = [from];
433
+ const cameFrom = new Map<string, string>();
434
+ const visited = new Set<string>([from]);
435
+ while (queue.length > 0) {
436
+ const current = queue.shift()!;
437
+ const children = h.childrenOf.get(current);
438
+ if (!children) continue;
439
+ for (const child of children) {
440
+ if (visited.has(child)) continue;
441
+ visited.add(child);
442
+ cameFrom.set(child, current);
443
+ if (child === to) {
444
+ const path = [to];
445
+ let node = to;
446
+ while (cameFrom.has(node)) {
447
+ node = cameFrom.get(node)!;
448
+ path.unshift(node);
449
+ }
450
+ return path;
451
+ }
452
+ queue.push(child);
453
+ }
454
+ }
455
+ return [from, to];
456
+ }