@typicalday/firegraph 0.3.0 → 0.5.0

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/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  } from "./chunk-KFA7G37W.js";
8
8
 
9
9
  // src/client.ts
10
- import { FieldValue as FieldValue4 } from "@google-cloud/firestore";
10
+ import { FieldValue as FieldValue5 } from "@google-cloud/firestore";
11
11
 
12
12
  // src/docid.ts
13
13
  import { createHash } from "crypto";
@@ -135,6 +135,12 @@ var RegistryScopeError = class extends FiregraphError {
135
135
  this.name = "RegistryScopeError";
136
136
  }
137
137
  };
138
+ var MigrationError = class extends FiregraphError {
139
+ constructor(message) {
140
+ super(message, "MIGRATION_ERROR");
141
+ this.name = "MigrationError";
142
+ }
143
+ };
138
144
 
139
145
  // src/query.ts
140
146
  function buildEdgeQueryPlan(params) {
@@ -325,7 +331,7 @@ function createPipelineQueryAdapter(db, collectionPath) {
325
331
  }
326
332
 
327
333
  // src/transaction.ts
328
- import { FieldValue as FieldValue2 } from "@google-cloud/firestore";
334
+ import { FieldValue as FieldValue3 } from "@google-cloud/firestore";
329
335
 
330
336
  // src/query-safety.ts
331
337
  var SAFE_INDEX_PATTERNS = [
@@ -375,24 +381,248 @@ function analyzeQuerySafety(filters) {
375
381
  };
376
382
  }
377
383
 
384
+ // src/serialization.ts
385
+ import { Timestamp, GeoPoint, FieldValue as FieldValue2 } from "@google-cloud/firestore";
386
+ var SERIALIZATION_TAG = "__firegraph_ser__";
387
+ var KNOWN_TYPES = /* @__PURE__ */ new Set(["Timestamp", "GeoPoint", "VectorValue", "DocumentReference"]);
388
+ var _docRefWarned = false;
389
+ function isTaggedValue(value) {
390
+ if (value === null || typeof value !== "object") return false;
391
+ const tag = value[SERIALIZATION_TAG];
392
+ return typeof tag === "string" && KNOWN_TYPES.has(tag);
393
+ }
394
+ function isTimestamp(value) {
395
+ return value instanceof Timestamp;
396
+ }
397
+ function isGeoPoint(value) {
398
+ return value instanceof GeoPoint;
399
+ }
400
+ function isDocumentReference(value) {
401
+ if (value === null || typeof value !== "object") return false;
402
+ const v = value;
403
+ return typeof v.path === "string" && v.firestore !== void 0 && typeof v.id === "string" && v.constructor?.name === "DocumentReference";
404
+ }
405
+ function isVectorValue(value) {
406
+ if (value === null || typeof value !== "object") return false;
407
+ const v = value;
408
+ return v.constructor?.name === "VectorValue" && Array.isArray(v._values);
409
+ }
410
+ function serializeFirestoreTypes(data) {
411
+ return serializeValue(data);
412
+ }
413
+ function serializeValue(value) {
414
+ if (value === null || value === void 0) return value;
415
+ if (typeof value !== "object") return value;
416
+ if (isTimestamp(value)) {
417
+ return { [SERIALIZATION_TAG]: "Timestamp", seconds: value.seconds, nanoseconds: value.nanoseconds };
418
+ }
419
+ if (isGeoPoint(value)) {
420
+ return { [SERIALIZATION_TAG]: "GeoPoint", latitude: value.latitude, longitude: value.longitude };
421
+ }
422
+ if (isDocumentReference(value)) {
423
+ return { [SERIALIZATION_TAG]: "DocumentReference", path: value.path };
424
+ }
425
+ if (isVectorValue(value)) {
426
+ const v = value;
427
+ const values = typeof v.toArray === "function" ? v.toArray() : v._values;
428
+ return { [SERIALIZATION_TAG]: "VectorValue", values: [...values] };
429
+ }
430
+ if (Array.isArray(value)) {
431
+ return value.map(serializeValue);
432
+ }
433
+ const result = {};
434
+ for (const key of Object.keys(value)) {
435
+ result[key] = serializeValue(value[key]);
436
+ }
437
+ return result;
438
+ }
439
+ function deserializeFirestoreTypes(data, db) {
440
+ return deserializeValue(data, db);
441
+ }
442
+ function deserializeValue(value, db) {
443
+ if (value === null || value === void 0) return value;
444
+ if (typeof value !== "object") return value;
445
+ if (isTimestamp(value) || isGeoPoint(value) || isDocumentReference(value) || isVectorValue(value)) {
446
+ return value;
447
+ }
448
+ if (Array.isArray(value)) {
449
+ return value.map((v) => deserializeValue(v, db));
450
+ }
451
+ const obj = value;
452
+ if (isTaggedValue(obj)) {
453
+ const tag = obj[SERIALIZATION_TAG];
454
+ switch (tag) {
455
+ case "Timestamp":
456
+ if (typeof obj.seconds !== "number" || typeof obj.nanoseconds !== "number") return obj;
457
+ return new Timestamp(obj.seconds, obj.nanoseconds);
458
+ case "GeoPoint":
459
+ if (typeof obj.latitude !== "number" || typeof obj.longitude !== "number") return obj;
460
+ return new GeoPoint(obj.latitude, obj.longitude);
461
+ case "VectorValue":
462
+ if (!Array.isArray(obj.values)) return obj;
463
+ return FieldValue2.vector(obj.values);
464
+ case "DocumentReference":
465
+ if (typeof obj.path !== "string") return obj;
466
+ if (db) {
467
+ return db.doc(obj.path);
468
+ }
469
+ if (!_docRefWarned) {
470
+ _docRefWarned = true;
471
+ console.warn(
472
+ "[firegraph] DocumentReference encountered during migration deserialization but no Firestore instance available. The reference will remain as a tagged object with its path. Enable write-back for full reconstruction."
473
+ );
474
+ }
475
+ return obj;
476
+ default:
477
+ return obj;
478
+ }
479
+ }
480
+ const result = {};
481
+ for (const key of Object.keys(obj)) {
482
+ result[key] = deserializeValue(obj[key], db);
483
+ }
484
+ return result;
485
+ }
486
+
487
+ // src/migration.ts
488
+ async function applyMigrationChain(data, currentVersion, targetVersion, migrations) {
489
+ const sorted = [...migrations].sort((a, b) => a.fromVersion - b.fromVersion);
490
+ let result = { ...data };
491
+ let version = currentVersion;
492
+ for (const step of sorted) {
493
+ if (step.fromVersion === version) {
494
+ try {
495
+ result = await step.up(result);
496
+ } catch (err) {
497
+ if (err instanceof MigrationError) throw err;
498
+ throw new MigrationError(
499
+ `Migration from v${step.fromVersion} to v${step.toVersion} failed: ${err.message}`
500
+ );
501
+ }
502
+ if (!result || typeof result !== "object") {
503
+ throw new MigrationError(
504
+ `Migration from v${step.fromVersion} to v${step.toVersion} returned invalid data (expected object)`
505
+ );
506
+ }
507
+ version = step.toVersion;
508
+ }
509
+ }
510
+ if (version !== targetVersion) {
511
+ throw new MigrationError(
512
+ `Incomplete migration chain: reached v${version} but target is v${targetVersion}`
513
+ );
514
+ }
515
+ return result;
516
+ }
517
+ function validateMigrationChain(migrations, label) {
518
+ if (migrations.length === 0) return;
519
+ const seen = /* @__PURE__ */ new Set();
520
+ for (const step of migrations) {
521
+ if (step.toVersion <= step.fromVersion) {
522
+ throw new MigrationError(
523
+ `${label}: migration step has toVersion (${step.toVersion}) <= fromVersion (${step.fromVersion})`
524
+ );
525
+ }
526
+ if (seen.has(step.fromVersion)) {
527
+ throw new MigrationError(
528
+ `${label}: duplicate migration step for fromVersion ${step.fromVersion}`
529
+ );
530
+ }
531
+ seen.add(step.fromVersion);
532
+ }
533
+ const sorted = [...migrations].sort((a, b) => a.fromVersion - b.fromVersion);
534
+ const targetVersion = Math.max(...migrations.map((m) => m.toVersion));
535
+ let version = 0;
536
+ for (const step of sorted) {
537
+ if (step.fromVersion === version) {
538
+ version = step.toVersion;
539
+ } else if (step.fromVersion > version) {
540
+ throw new MigrationError(
541
+ `${label}: migration chain has a gap \u2014 no step covers v${version} \u2192 v${step.fromVersion}`
542
+ );
543
+ }
544
+ }
545
+ if (version !== targetVersion) {
546
+ throw new MigrationError(
547
+ `${label}: migration chain does not reach v${targetVersion} (stuck at v${version})`
548
+ );
549
+ }
550
+ }
551
+ async function migrateRecord(record, registry, globalWriteBack = "off") {
552
+ const entry = registry.lookup(record.aType, record.axbType, record.bType);
553
+ if (!entry?.migrations?.length || !entry.schemaVersion) {
554
+ return { record, migrated: false, writeBack: "off" };
555
+ }
556
+ const currentVersion = record.v ?? 0;
557
+ if (currentVersion >= entry.schemaVersion) {
558
+ return { record, migrated: false, writeBack: "off" };
559
+ }
560
+ const migratedData = await applyMigrationChain(
561
+ record.data,
562
+ currentVersion,
563
+ entry.schemaVersion,
564
+ entry.migrations
565
+ );
566
+ const writeBack = entry.migrationWriteBack ?? globalWriteBack ?? "off";
567
+ return {
568
+ record: { ...record, data: migratedData, v: entry.schemaVersion },
569
+ migrated: true,
570
+ writeBack
571
+ };
572
+ }
573
+ async function migrateRecords(records, registry, globalWriteBack = "off") {
574
+ return Promise.all(
575
+ records.map((r) => migrateRecord(r, registry, globalWriteBack))
576
+ );
577
+ }
578
+
378
579
  // src/transaction.ts
379
580
  var GraphTransactionImpl = class {
380
- constructor(adapter, registry, scanProtection = "error", scopePath = "") {
581
+ constructor(adapter, registry, scanProtection = "error", scopePath = "", globalWriteBack = "off", db) {
381
582
  this.adapter = adapter;
382
583
  this.registry = registry;
383
584
  this.scanProtection = scanProtection;
384
585
  this.scopePath = scopePath;
586
+ this.globalWriteBack = globalWriteBack;
587
+ this.db = db;
385
588
  }
386
589
  async getNode(uid) {
387
590
  const docId = computeNodeDocId(uid);
388
- return this.adapter.getDoc(docId);
591
+ const record = await this.adapter.getDoc(docId);
592
+ if (!record || !this.registry) return record;
593
+ const result = await migrateRecord(record, this.registry, this.globalWriteBack);
594
+ if (result.migrated && result.writeBack !== "off") {
595
+ const update = {
596
+ data: deserializeFirestoreTypes(result.record.data, this.db),
597
+ updatedAt: FieldValue3.serverTimestamp()
598
+ };
599
+ if (result.record.v !== void 0) {
600
+ update.v = result.record.v;
601
+ }
602
+ this.adapter.updateDoc(docId, update);
603
+ }
604
+ return result.record;
389
605
  }
390
606
  async getEdge(aUid, axbType, bUid) {
391
607
  const docId = computeEdgeDocId(aUid, axbType, bUid);
392
- return this.adapter.getDoc(docId);
608
+ const record = await this.adapter.getDoc(docId);
609
+ if (!record || !this.registry) return record;
610
+ const result = await migrateRecord(record, this.registry, this.globalWriteBack);
611
+ if (result.migrated && result.writeBack !== "off") {
612
+ const update = {
613
+ data: deserializeFirestoreTypes(result.record.data, this.db),
614
+ updatedAt: FieldValue3.serverTimestamp()
615
+ };
616
+ if (result.record.v !== void 0) {
617
+ update.v = result.record.v;
618
+ }
619
+ this.adapter.updateDoc(docId, update);
620
+ }
621
+ return result.record;
393
622
  }
394
623
  async edgeExists(aUid, axbType, bUid) {
395
- const record = await this.getEdge(aUid, axbType, bUid);
624
+ const docId = computeEdgeDocId(aUid, axbType, bUid);
625
+ const record = await this.adapter.getDoc(docId);
396
626
  return record !== null;
397
627
  }
398
628
  checkQuerySafety(filters, allowCollectionScan) {
@@ -406,21 +636,45 @@ var GraphTransactionImpl = class {
406
636
  }
407
637
  async findEdges(params) {
408
638
  const plan = buildEdgeQueryPlan(params);
639
+ let records;
409
640
  if (plan.strategy === "get") {
410
641
  const record = await this.adapter.getDoc(plan.docId);
411
- return record ? [record] : [];
642
+ records = record ? [record] : [];
643
+ } else {
644
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
645
+ records = await this.adapter.query(plan.filters, plan.options);
412
646
  }
413
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
414
- return this.adapter.query(plan.filters, plan.options);
647
+ return this.applyMigrations(records);
415
648
  }
416
649
  async findNodes(params) {
417
650
  const plan = buildNodeQueryPlan(params);
651
+ let records;
418
652
  if (plan.strategy === "get") {
419
653
  const record = await this.adapter.getDoc(plan.docId);
420
- return record ? [record] : [];
654
+ records = record ? [record] : [];
655
+ } else {
656
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
657
+ records = await this.adapter.query(plan.filters, plan.options);
658
+ }
659
+ return this.applyMigrations(records);
660
+ }
661
+ async applyMigrations(records) {
662
+ if (!this.registry || records.length === 0) return records;
663
+ const results = await migrateRecords(records, this.registry, this.globalWriteBack);
664
+ for (const result of results) {
665
+ if (result.migrated && result.writeBack !== "off") {
666
+ const docId = result.record.axbType === NODE_RELATION ? computeNodeDocId(result.record.aUid) : computeEdgeDocId(result.record.aUid, result.record.axbType, result.record.bUid);
667
+ const update = {
668
+ data: deserializeFirestoreTypes(result.record.data, this.db),
669
+ updatedAt: FieldValue3.serverTimestamp()
670
+ };
671
+ if (result.record.v !== void 0) {
672
+ update.v = result.record.v;
673
+ }
674
+ this.adapter.updateDoc(docId, update);
675
+ }
421
676
  }
422
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
423
- return this.adapter.query(plan.filters, plan.options);
677
+ return results.map((r) => r.record);
424
678
  }
425
679
  async putNode(aType, uid, data) {
426
680
  if (this.registry) {
@@ -428,6 +682,12 @@ var GraphTransactionImpl = class {
428
682
  }
429
683
  const docId = computeNodeDocId(uid);
430
684
  const record = buildNodeRecord(aType, uid, data);
685
+ if (this.registry) {
686
+ const entry = this.registry.lookup(aType, NODE_RELATION, aType);
687
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
688
+ record.v = entry.schemaVersion;
689
+ }
690
+ }
431
691
  this.adapter.setDoc(docId, record);
432
692
  }
433
693
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
@@ -436,13 +696,19 @@ var GraphTransactionImpl = class {
436
696
  }
437
697
  const docId = computeEdgeDocId(aUid, axbType, bUid);
438
698
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
699
+ if (this.registry) {
700
+ const entry = this.registry.lookup(aType, axbType, bType);
701
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
702
+ record.v = entry.schemaVersion;
703
+ }
704
+ }
439
705
  this.adapter.setDoc(docId, record);
440
706
  }
441
707
  async updateNode(uid, data) {
442
708
  const docId = computeNodeDocId(uid);
443
709
  this.adapter.updateDoc(docId, {
444
710
  ...data,
445
- updatedAt: FieldValue2.serverTimestamp()
711
+ updatedAt: FieldValue3.serverTimestamp()
446
712
  });
447
713
  }
448
714
  async removeNode(uid) {
@@ -456,7 +722,7 @@ var GraphTransactionImpl = class {
456
722
  };
457
723
 
458
724
  // src/batch.ts
459
- import { FieldValue as FieldValue3 } from "@google-cloud/firestore";
725
+ import { FieldValue as FieldValue4 } from "@google-cloud/firestore";
460
726
  var GraphBatchImpl = class {
461
727
  constructor(adapter, registry, scopePath = "") {
462
728
  this.adapter = adapter;
@@ -469,6 +735,12 @@ var GraphBatchImpl = class {
469
735
  }
470
736
  const docId = computeNodeDocId(uid);
471
737
  const record = buildNodeRecord(aType, uid, data);
738
+ if (this.registry) {
739
+ const entry = this.registry.lookup(aType, NODE_RELATION, aType);
740
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
741
+ record.v = entry.schemaVersion;
742
+ }
743
+ }
472
744
  this.adapter.setDoc(docId, record);
473
745
  }
474
746
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
@@ -477,13 +749,19 @@ var GraphBatchImpl = class {
477
749
  }
478
750
  const docId = computeEdgeDocId(aUid, axbType, bUid);
479
751
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
752
+ if (this.registry) {
753
+ const entry = this.registry.lookup(aType, axbType, bType);
754
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
755
+ record.v = entry.schemaVersion;
756
+ }
757
+ }
480
758
  this.adapter.setDoc(docId, record);
481
759
  }
482
760
  async updateNode(uid, data) {
483
761
  const docId = computeNodeDocId(uid);
484
762
  this.adapter.updateDoc(docId, {
485
763
  ...data,
486
- updatedAt: FieldValue3.serverTimestamp()
764
+ updatedAt: FieldValue4.serverTimestamp()
487
765
  });
488
766
  }
489
767
  async removeNode(uid) {
@@ -642,7 +920,7 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
642
920
  }
643
921
 
644
922
  // src/dynamic-registry.ts
645
- import { createHash as createHash2 } from "crypto";
923
+ import { createHash as createHash3 } from "crypto";
646
924
 
647
925
  // src/json-schema.ts
648
926
  import Ajv from "ajv";
@@ -773,6 +1051,9 @@ function matchSegments(path, pi, pattern, qi) {
773
1051
  function tripleKey(aType, axbType, bType) {
774
1052
  return `${aType}:${axbType}:${bType}`;
775
1053
  }
1054
+ function tripleKeyFor(e) {
1055
+ return tripleKey(e.aType, e.axbType, e.bType);
1056
+ }
776
1057
  function createRegistry(input) {
777
1058
  const map = /* @__PURE__ */ new Map();
778
1059
  let entries;
@@ -788,6 +1069,13 @@ function createRegistry(input) {
788
1069
  `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType}) has invalid targetGraph "${entry.targetGraph}" \u2014 must be a single segment (no "/")`
789
1070
  );
790
1071
  }
1072
+ if (entry.migrations?.length) {
1073
+ const label = `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`;
1074
+ validateMigrationChain(entry.migrations, label);
1075
+ entry.schemaVersion = Math.max(...entry.migrations.map((m) => m.toVersion));
1076
+ } else {
1077
+ entry.schemaVersion = void 0;
1078
+ }
791
1079
  const key = tripleKey(entry.aType, entry.axbType, entry.bType);
792
1080
  const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
793
1081
  map.set(key, { entry, validate: validator });
@@ -839,6 +1127,45 @@ function createRegistry(input) {
839
1127
  }
840
1128
  };
841
1129
  }
1130
+ function createMergedRegistry(base, extension) {
1131
+ const baseKeys = new Set(base.entries().map(tripleKeyFor));
1132
+ return {
1133
+ lookup(aType, axbType, bType) {
1134
+ return base.lookup(aType, axbType, bType) ?? extension.lookup(aType, axbType, bType);
1135
+ },
1136
+ lookupByAxbType(axbType) {
1137
+ const baseResults = base.lookupByAxbType(axbType);
1138
+ const extResults = extension.lookupByAxbType(axbType);
1139
+ if (extResults.length === 0) return baseResults;
1140
+ if (baseResults.length === 0) return extResults;
1141
+ const seen = new Set(baseResults.map(tripleKeyFor));
1142
+ const merged = [...baseResults];
1143
+ for (const entry of extResults) {
1144
+ if (!seen.has(tripleKeyFor(entry))) {
1145
+ merged.push(entry);
1146
+ }
1147
+ }
1148
+ return Object.freeze(merged);
1149
+ },
1150
+ validate(aType, axbType, bType, data, scopePath) {
1151
+ if (baseKeys.has(tripleKey(aType, axbType, bType))) {
1152
+ return base.validate(aType, axbType, bType, data, scopePath);
1153
+ }
1154
+ return extension.validate(aType, axbType, bType, data, scopePath);
1155
+ },
1156
+ entries() {
1157
+ const extEntries = extension.entries();
1158
+ if (extEntries.length === 0) return base.entries();
1159
+ const merged = [...base.entries()];
1160
+ for (const entry of extEntries) {
1161
+ if (!baseKeys.has(tripleKeyFor(entry))) {
1162
+ merged.push(entry);
1163
+ }
1164
+ }
1165
+ return Object.freeze(merged);
1166
+ }
1167
+ };
1168
+ }
842
1169
  function discoveryToEntries(discovery) {
843
1170
  const entries = [];
844
1171
  for (const [name, entity] of discovery.nodes) {
@@ -850,7 +1177,9 @@ function discoveryToEntries(discovery) {
850
1177
  description: entity.description,
851
1178
  titleField: entity.titleField,
852
1179
  subtitleField: entity.subtitleField,
853
- allowedIn: entity.allowedIn
1180
+ allowedIn: entity.allowedIn,
1181
+ migrations: entity.migrations,
1182
+ migrationWriteBack: entity.migrationWriteBack
854
1183
  });
855
1184
  }
856
1185
  for (const [axbType, entity] of discovery.edges) {
@@ -876,7 +1205,9 @@ function discoveryToEntries(discovery) {
876
1205
  titleField: entity.titleField,
877
1206
  subtitleField: entity.subtitleField,
878
1207
  allowedIn: entity.allowedIn,
879
- targetGraph: resolvedTargetGraph
1208
+ targetGraph: resolvedTargetGraph,
1209
+ migrations: entity.migrations,
1210
+ migrationWriteBack: entity.migrationWriteBack
880
1211
  });
881
1212
  }
882
1213
  }
@@ -884,9 +1215,258 @@ function discoveryToEntries(discovery) {
884
1215
  return entries;
885
1216
  }
886
1217
 
1218
+ // src/sandbox.ts
1219
+ import { Worker } from "worker_threads";
1220
+ import { createHash as createHash2 } from "crypto";
1221
+ var _worker = null;
1222
+ var _requestId = 0;
1223
+ var _pending = /* @__PURE__ */ new Map();
1224
+ var WORKER_SOURCE = [
1225
+ `'use strict';`,
1226
+ `var _wt = require('node:worker_threads');`,
1227
+ `var _mod = require('node:module');`,
1228
+ `var _crypto = require('node:crypto');`,
1229
+ `var parentPort = _wt.parentPort;`,
1230
+ `var workerData = _wt.workerData;`,
1231
+ ``,
1232
+ `// Load SES using the parent module's resolution context`,
1233
+ `var esmRequire = _mod.createRequire(workerData.parentUrl);`,
1234
+ `esmRequire('ses');`,
1235
+ ``,
1236
+ `lockdown({`,
1237
+ ` errorTaming: 'unsafe',`,
1238
+ ` consoleTaming: 'unsafe',`,
1239
+ ` evalTaming: 'safe-eval',`,
1240
+ ` overrideTaming: 'moderate',`,
1241
+ ` stackFiltering: 'verbose'`,
1242
+ `});`,
1243
+ ``,
1244
+ `// Defense-in-depth: verify lockdown() actually hardened JSON.`,
1245
+ `if (!Object.isFrozen(JSON)) {`,
1246
+ ` throw new Error('SES lockdown failed: JSON is not frozen');`,
1247
+ `}`,
1248
+ ``,
1249
+ `var cache = new Map();`,
1250
+ ``,
1251
+ `function hashSource(s) {`,
1252
+ ` return _crypto.createHash('sha256').update(s).digest('hex');`,
1253
+ `}`,
1254
+ ``,
1255
+ `function buildWrapper(source) {`,
1256
+ ` return '(function() {' +`,
1257
+ ` ' var fn = (' + source + ');\\n' +`,
1258
+ ` ' if (typeof fn !== "function") return null;\\n' +`,
1259
+ ` ' return function(jsonIn) {\\n' +`,
1260
+ ` ' var data = JSON.parse(jsonIn);\\n' +`,
1261
+ ` ' var result = fn(data);\\n' +`,
1262
+ ` ' if (result !== null && typeof result === "object" && typeof result.then === "function") {\\n' +`,
1263
+ ` ' return result.then(function(r) { return JSON.stringify(r); });\\n' +`,
1264
+ ` ' }\\n' +`,
1265
+ ` ' return JSON.stringify(result);\\n' +`,
1266
+ ` ' };\\n' +`,
1267
+ ` '})()';`,
1268
+ `}`,
1269
+ ``,
1270
+ `function compileSource(source) {`,
1271
+ ` var key = hashSource(source);`,
1272
+ ` var cached = cache.get(key);`,
1273
+ ` if (cached) return cached;`,
1274
+ ``,
1275
+ ` var compartmentFn;`,
1276
+ ` try {`,
1277
+ ` var c = new Compartment({ JSON: JSON });`,
1278
+ ` compartmentFn = c.evaluate(buildWrapper(source));`,
1279
+ ` } catch (err) {`,
1280
+ ` throw new Error('Failed to compile migration source: ' + (err.message || String(err)));`,
1281
+ ` }`,
1282
+ ``,
1283
+ ` if (typeof compartmentFn !== 'function') {`,
1284
+ ` throw new Error('Migration source did not produce a function: ' + source.slice(0, 80));`,
1285
+ ` }`,
1286
+ ``,
1287
+ ` cache.set(key, compartmentFn);`,
1288
+ ` return compartmentFn;`,
1289
+ `}`,
1290
+ ``,
1291
+ `parentPort.on('message', function(msg) {`,
1292
+ ` var id = msg.id;`,
1293
+ ` try {`,
1294
+ ` if (msg.type === 'compile') {`,
1295
+ ` compileSource(msg.source);`,
1296
+ ` parentPort.postMessage({ id: id, type: 'compiled' });`,
1297
+ ` return;`,
1298
+ ` }`,
1299
+ ` if (msg.type === 'execute') {`,
1300
+ ` var fn = compileSource(msg.source);`,
1301
+ ` var raw;`,
1302
+ ` try {`,
1303
+ ` raw = fn(msg.jsonData);`,
1304
+ ` } catch (err) {`,
1305
+ ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration function threw: ' + (err.message || String(err)) });`,
1306
+ ` return;`,
1307
+ ` }`,
1308
+ ` if (raw !== null && typeof raw === 'object' && typeof raw.then === 'function') {`,
1309
+ ` raw.then(`,
1310
+ ` function(jsonResult) {`,
1311
+ ` if (jsonResult === undefined || jsonResult === null) {`,
1312
+ ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration returned a non-JSON-serializable value' });`,
1313
+ ` } else {`,
1314
+ ` parentPort.postMessage({ id: id, type: 'result', jsonResult: jsonResult });`,
1315
+ ` }`,
1316
+ ` },`,
1317
+ ` function(err) {`,
1318
+ ` parentPort.postMessage({ id: id, type: 'error', message: 'Async migration function threw: ' + (err.message || String(err)) });`,
1319
+ ` }`,
1320
+ ` );`,
1321
+ ` return;`,
1322
+ ` }`,
1323
+ ` if (raw === undefined || raw === null) {`,
1324
+ ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration returned a non-JSON-serializable value' });`,
1325
+ ` } else {`,
1326
+ ` parentPort.postMessage({ id: id, type: 'result', jsonResult: raw });`,
1327
+ ` }`,
1328
+ ` }`,
1329
+ ` } catch (err) {`,
1330
+ ` parentPort.postMessage({ id: id, type: 'error', message: err.message || String(err) });`,
1331
+ ` }`,
1332
+ `});`
1333
+ ].join("\n");
1334
+ function ensureWorker() {
1335
+ if (_worker) return _worker;
1336
+ _worker = new Worker(WORKER_SOURCE, {
1337
+ eval: true,
1338
+ workerData: { parentUrl: import.meta.url }
1339
+ });
1340
+ _worker.unref();
1341
+ _worker.on("message", (msg) => {
1342
+ if (msg.id === void 0) return;
1343
+ const pending = _pending.get(msg.id);
1344
+ if (!pending) return;
1345
+ _pending.delete(msg.id);
1346
+ if (msg.type === "error") {
1347
+ pending.reject(new MigrationError(msg.message ?? "Unknown sandbox error"));
1348
+ } else {
1349
+ pending.resolve(msg);
1350
+ }
1351
+ });
1352
+ _worker.on("error", (err) => {
1353
+ for (const [, p] of _pending) {
1354
+ p.reject(new MigrationError(`Sandbox worker error: ${err.message}`));
1355
+ }
1356
+ _pending.clear();
1357
+ _worker = null;
1358
+ });
1359
+ _worker.on("exit", (code) => {
1360
+ if (_pending.size > 0) {
1361
+ for (const [, p] of _pending) {
1362
+ p.reject(new MigrationError(`Sandbox worker exited with code ${code}`));
1363
+ }
1364
+ _pending.clear();
1365
+ }
1366
+ _worker = null;
1367
+ });
1368
+ return _worker;
1369
+ }
1370
+ function sendToWorker(msg) {
1371
+ const worker = ensureWorker();
1372
+ if (_requestId >= Number.MAX_SAFE_INTEGER) _requestId = 0;
1373
+ const id = ++_requestId;
1374
+ return new Promise((resolve2, reject) => {
1375
+ _pending.set(id, { resolve: resolve2, reject });
1376
+ worker.postMessage({ ...msg, id });
1377
+ });
1378
+ }
1379
+ var compiledCache = /* @__PURE__ */ new WeakMap();
1380
+ function getExecutorCache(executor) {
1381
+ let cache = compiledCache.get(executor);
1382
+ if (!cache) {
1383
+ cache = /* @__PURE__ */ new Map();
1384
+ compiledCache.set(executor, cache);
1385
+ }
1386
+ return cache;
1387
+ }
1388
+ function hashSource(source) {
1389
+ return createHash2("sha256").update(source).digest("hex");
1390
+ }
1391
+ function defaultExecutor(source) {
1392
+ ensureWorker();
1393
+ return ((data) => {
1394
+ const jsonData = JSON.stringify(serializeFirestoreTypes(data));
1395
+ return sendToWorker({ type: "execute", source, jsonData }).then(
1396
+ (response) => {
1397
+ if (response.jsonResult === void 0 || response.jsonResult === null) {
1398
+ throw new MigrationError("Migration returned a non-JSON-serializable value");
1399
+ }
1400
+ try {
1401
+ return deserializeFirestoreTypes(JSON.parse(response.jsonResult));
1402
+ } catch {
1403
+ throw new MigrationError("Migration returned a non-JSON-serializable value");
1404
+ }
1405
+ }
1406
+ );
1407
+ });
1408
+ }
1409
+ async function precompileSource(source, executor) {
1410
+ if (executor && executor !== defaultExecutor) {
1411
+ try {
1412
+ executor(source);
1413
+ } catch (err) {
1414
+ if (err instanceof MigrationError) throw err;
1415
+ throw new MigrationError(
1416
+ `Failed to compile migration source: ${err.message}`
1417
+ );
1418
+ }
1419
+ return;
1420
+ }
1421
+ await sendToWorker({ type: "compile", source });
1422
+ }
1423
+ function compileMigrationFn(source, executor = defaultExecutor) {
1424
+ const cache = getExecutorCache(executor);
1425
+ const key = hashSource(source);
1426
+ const cached = cache.get(key);
1427
+ if (cached) return cached;
1428
+ try {
1429
+ const fn = executor(source);
1430
+ cache.set(key, fn);
1431
+ return fn;
1432
+ } catch (err) {
1433
+ if (err instanceof MigrationError) throw err;
1434
+ throw new MigrationError(
1435
+ `Failed to compile migration source: ${err.message}`
1436
+ );
1437
+ }
1438
+ }
1439
+ function compileMigrations(stored, executor) {
1440
+ return stored.map((step) => ({
1441
+ fromVersion: step.fromVersion,
1442
+ toVersion: step.toVersion,
1443
+ up: compileMigrationFn(step.up, executor)
1444
+ }));
1445
+ }
1446
+ async function destroySandboxWorker() {
1447
+ if (!_worker) return;
1448
+ const w = _worker;
1449
+ _worker = null;
1450
+ for (const [, p] of _pending) {
1451
+ p.reject(new MigrationError("Sandbox worker terminated"));
1452
+ }
1453
+ _pending.clear();
1454
+ await w.terminate();
1455
+ }
1456
+
887
1457
  // src/dynamic-registry.ts
888
1458
  var META_NODE_TYPE = "nodeType";
889
1459
  var META_EDGE_TYPE = "edgeType";
1460
+ var STORED_MIGRATION_STEP_SCHEMA = {
1461
+ type: "object",
1462
+ required: ["fromVersion", "toVersion", "up"],
1463
+ properties: {
1464
+ fromVersion: { type: "integer", minimum: 0 },
1465
+ toVersion: { type: "integer", minimum: 1 },
1466
+ up: { type: "string", minLength: 1 }
1467
+ },
1468
+ additionalProperties: false
1469
+ };
890
1470
  var NODE_TYPE_SCHEMA = {
891
1471
  type: "object",
892
1472
  required: ["name", "jsonSchema"],
@@ -898,7 +1478,10 @@ var NODE_TYPE_SCHEMA = {
898
1478
  subtitleField: { type: "string" },
899
1479
  viewTemplate: { type: "string" },
900
1480
  viewCss: { type: "string" },
901
- allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
1481
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
1482
+ schemaVersion: { type: "integer", minimum: 0 },
1483
+ migrations: { type: "array", items: STORED_MIGRATION_STEP_SCHEMA },
1484
+ migrationWriteBack: { type: "string", enum: ["off", "eager", "background"] }
902
1485
  },
903
1486
  additionalProperties: false
904
1487
  };
@@ -927,7 +1510,10 @@ var EDGE_TYPE_SCHEMA = {
927
1510
  viewTemplate: { type: "string" },
928
1511
  viewCss: { type: "string" },
929
1512
  allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
930
- targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" }
1513
+ targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" },
1514
+ schemaVersion: { type: "integer", minimum: 0 },
1515
+ migrations: { type: "array", items: STORED_MIGRATION_STEP_SCHEMA },
1516
+ migrationWriteBack: { type: "string", enum: ["off", "eager", "background"] }
931
1517
  },
932
1518
  additionalProperties: false
933
1519
  };
@@ -951,15 +1537,33 @@ function createBootstrapRegistry() {
951
1537
  return createRegistry([...BOOTSTRAP_ENTRIES]);
952
1538
  }
953
1539
  function generateDeterministicUid(metaType, name) {
954
- const hash = createHash2("sha256").update(`${metaType}:${name}`).digest("base64url");
1540
+ const hash = createHash3("sha256").update(`${metaType}:${name}`).digest("base64url");
955
1541
  return hash.slice(0, 21);
956
1542
  }
957
- async function createRegistryFromGraph(reader) {
1543
+ async function createRegistryFromGraph(reader, executor) {
958
1544
  const [nodeTypes, edgeTypes] = await Promise.all([
959
1545
  reader.findNodes({ aType: META_NODE_TYPE }),
960
1546
  reader.findNodes({ aType: META_EDGE_TYPE })
961
1547
  ]);
962
1548
  const entries = [...BOOTSTRAP_ENTRIES];
1549
+ const prevalidations = [];
1550
+ for (const record of nodeTypes) {
1551
+ const data = record.data;
1552
+ if (data.migrations) {
1553
+ for (const m of data.migrations) {
1554
+ prevalidations.push(precompileSource(m.up, executor));
1555
+ }
1556
+ }
1557
+ }
1558
+ for (const record of edgeTypes) {
1559
+ const data = record.data;
1560
+ if (data.migrations) {
1561
+ for (const m of data.migrations) {
1562
+ prevalidations.push(precompileSource(m.up, executor));
1563
+ }
1564
+ }
1565
+ }
1566
+ await Promise.all(prevalidations);
963
1567
  for (const record of nodeTypes) {
964
1568
  const data = record.data;
965
1569
  entries.push({
@@ -970,13 +1574,16 @@ async function createRegistryFromGraph(reader) {
970
1574
  description: data.description,
971
1575
  titleField: data.titleField,
972
1576
  subtitleField: data.subtitleField,
973
- allowedIn: data.allowedIn
1577
+ allowedIn: data.allowedIn,
1578
+ migrations: data.migrations ? compileMigrations(data.migrations, executor) : void 0,
1579
+ migrationWriteBack: data.migrationWriteBack
974
1580
  });
975
1581
  }
976
1582
  for (const record of edgeTypes) {
977
1583
  const data = record.data;
978
1584
  const fromTypes = Array.isArray(data.from) ? data.from : [data.from];
979
1585
  const toTypes = Array.isArray(data.to) ? data.to : [data.to];
1586
+ const compiledMigrations = data.migrations ? compileMigrations(data.migrations, executor) : void 0;
980
1587
  for (const aType of fromTypes) {
981
1588
  for (const bType of toTypes) {
982
1589
  entries.push({
@@ -989,7 +1596,9 @@ async function createRegistryFromGraph(reader) {
989
1596
  titleField: data.titleField,
990
1597
  subtitleField: data.subtitleField,
991
1598
  allowedIn: data.allowedIn,
992
- targetGraph: data.targetGraph
1599
+ targetGraph: data.targetGraph,
1600
+ migrations: compiledMigrations,
1601
+ migrationWriteBack: data.migrationWriteBack
993
1602
  });
994
1603
  }
995
1604
  }
@@ -1005,14 +1614,14 @@ var GraphClientImpl = class _GraphClientImpl {
1005
1614
  this.db = db;
1006
1615
  this.scopePath = scopePath;
1007
1616
  this.adapter = createFirestoreAdapter(db, collectionPath);
1008
- if (options?.registry && options?.registryMode) {
1009
- throw new DynamicRegistryError(
1010
- 'Cannot provide both "registry" and "registryMode". Use "registry" for static mode or "registryMode" for dynamic mode.'
1011
- );
1012
- }
1617
+ this.globalWriteBack = options?.migrationWriteBack ?? "off";
1618
+ this.migrationSandbox = options?.migrationSandbox;
1013
1619
  if (options?.registryMode) {
1014
1620
  this.dynamicConfig = options.registryMode;
1015
1621
  this.bootstrapRegistry = createBootstrapRegistry();
1622
+ if (options.registry) {
1623
+ this.staticRegistry = options.registry;
1624
+ }
1016
1625
  const metaCollectionPath = options.registryMode.collection;
1017
1626
  if (metaCollectionPath && metaCollectionPath !== collectionPath) {
1018
1627
  this.metaAdapter = createFirestoreAdapter(db, metaCollectionPath);
@@ -1058,24 +1667,29 @@ var GraphClientImpl = class _GraphClientImpl {
1058
1667
  metaPipelineAdapter;
1059
1668
  // Subgraph scope tracking
1060
1669
  scopePath;
1670
+ // Migration settings
1671
+ globalWriteBack;
1672
+ migrationSandbox;
1061
1673
  // ---------------------------------------------------------------------------
1062
1674
  // Registry routing
1063
1675
  // ---------------------------------------------------------------------------
1064
1676
  /**
1065
1677
  * Get the appropriate registry for validating a write to the given type.
1066
1678
  *
1067
- * - Static mode: returns staticRegistry (or undefined if none set)
1068
- * - Dynamic mode:
1679
+ * - Static-only mode: returns staticRegistry (or undefined if none set)
1680
+ * - Dynamic mode (pure or merged):
1069
1681
  * - Meta-types (nodeType, edgeType): validated against bootstrapRegistry
1070
1682
  * - Domain types: validated against dynamicRegistry (falls back to
1071
1683
  * bootstrapRegistry which rejects unknown types)
1684
+ * - Merged mode: dynamicRegistry is a merged wrapper (static + dynamic
1685
+ * extension), so static entries take priority automatically.
1072
1686
  */
1073
1687
  getRegistryForType(aType) {
1074
1688
  if (!this.dynamicConfig) return this.staticRegistry;
1075
1689
  if (aType === META_NODE_TYPE || aType === META_EDGE_TYPE) {
1076
1690
  return this.bootstrapRegistry;
1077
1691
  }
1078
- return this.dynamicRegistry ?? this.bootstrapRegistry;
1692
+ return this.dynamicRegistry ?? this.staticRegistry ?? this.bootstrapRegistry;
1079
1693
  }
1080
1694
  /**
1081
1695
  * Get the Firestore adapter for writing the given type.
@@ -1089,13 +1703,13 @@ var GraphClientImpl = class _GraphClientImpl {
1089
1703
  }
1090
1704
  /**
1091
1705
  * Get the combined registry for transaction/batch context.
1092
- * In static mode, returns staticRegistry.
1706
+ * In static-only mode, returns staticRegistry.
1093
1707
  * In dynamic mode, returns dynamicRegistry (which includes bootstrap entries)
1094
- * or bootstrapRegistry if not yet reloaded.
1708
+ * or falls back to staticRegistry (merged mode) or bootstrapRegistry.
1095
1709
  */
1096
1710
  getCombinedRegistry() {
1097
1711
  if (!this.dynamicConfig) return this.staticRegistry;
1098
- return this.dynamicRegistry ?? this.bootstrapRegistry;
1712
+ return this.dynamicRegistry ?? this.staticRegistry ?? this.bootstrapRegistry;
1099
1713
  }
1100
1714
  // ---------------------------------------------------------------------------
1101
1715
  // Query dispatch
@@ -1125,37 +1739,114 @@ var GraphClientImpl = class _GraphClientImpl {
1125
1739
  console.warn(`[firegraph] Query safety warning: ${result.reason}`);
1126
1740
  }
1127
1741
  // ---------------------------------------------------------------------------
1742
+ // Migration helpers
1743
+ // ---------------------------------------------------------------------------
1744
+ /**
1745
+ * Apply migration to a single record. Returns the (possibly migrated)
1746
+ * record and triggers write-back if applicable.
1747
+ */
1748
+ async applyMigration(record, docId) {
1749
+ const registry = this.getCombinedRegistry();
1750
+ if (!registry) return record;
1751
+ const result = await migrateRecord(record, registry, this.globalWriteBack);
1752
+ if (result.migrated) {
1753
+ this.handleWriteBack(result, docId);
1754
+ }
1755
+ return result.record;
1756
+ }
1757
+ /**
1758
+ * Apply migrations to an array of records. Returns all records
1759
+ * (migrated where applicable) and triggers write-backs.
1760
+ */
1761
+ async applyMigrations(records) {
1762
+ const registry = this.getCombinedRegistry();
1763
+ if (!registry || records.length === 0) return records;
1764
+ const results = await migrateRecords(records, registry, this.globalWriteBack);
1765
+ for (const result of results) {
1766
+ if (result.migrated) {
1767
+ const docId = result.record.axbType === NODE_RELATION ? computeNodeDocId(result.record.aUid) : computeEdgeDocId(result.record.aUid, result.record.axbType, result.record.bUid);
1768
+ this.handleWriteBack(result, docId);
1769
+ }
1770
+ }
1771
+ return results.map((r) => r.record);
1772
+ }
1773
+ /**
1774
+ * Handle write-back for a migrated record based on the resolved mode.
1775
+ *
1776
+ * Both `'eager'` and `'background'` are fire-and-forget (not awaited by
1777
+ * the caller). The difference is logging level on failure:
1778
+ * - `eager`: logs an error via `console.error`
1779
+ * - `background`: logs a warning via `console.warn`
1780
+ *
1781
+ * For truly synchronous write-back guarantees, use transactions — the
1782
+ * `GraphTransactionImpl` performs write-back inline within the transaction.
1783
+ */
1784
+ handleWriteBack(result, docId) {
1785
+ if (result.writeBack === "off") return;
1786
+ const doWriteBack = async () => {
1787
+ try {
1788
+ const update = {
1789
+ data: deserializeFirestoreTypes(result.record.data, this.db),
1790
+ updatedAt: FieldValue5.serverTimestamp()
1791
+ };
1792
+ if (result.record.v !== void 0) {
1793
+ update.v = result.record.v;
1794
+ }
1795
+ await this.adapter.updateDoc(docId, update);
1796
+ } catch (err) {
1797
+ const msg = `[firegraph] Migration write-back failed for ${docId}: ${err.message}`;
1798
+ if (result.writeBack === "eager") {
1799
+ console.error(msg);
1800
+ } else {
1801
+ console.warn(msg);
1802
+ }
1803
+ }
1804
+ };
1805
+ void doWriteBack();
1806
+ }
1807
+ // ---------------------------------------------------------------------------
1128
1808
  // GraphReader
1129
1809
  // ---------------------------------------------------------------------------
1130
1810
  async getNode(uid) {
1131
1811
  const docId = computeNodeDocId(uid);
1132
- return this.adapter.getDoc(docId);
1812
+ const record = await this.adapter.getDoc(docId);
1813
+ if (!record) return null;
1814
+ return this.applyMigration(record, docId);
1133
1815
  }
1134
1816
  async getEdge(aUid, axbType, bUid) {
1135
1817
  const docId = computeEdgeDocId(aUid, axbType, bUid);
1136
- return this.adapter.getDoc(docId);
1818
+ const record = await this.adapter.getDoc(docId);
1819
+ if (!record) return null;
1820
+ return this.applyMigration(record, docId);
1137
1821
  }
1138
1822
  async edgeExists(aUid, axbType, bUid) {
1139
- const record = await this.getEdge(aUid, axbType, bUid);
1823
+ const docId = computeEdgeDocId(aUid, axbType, bUid);
1824
+ const record = await this.adapter.getDoc(docId);
1140
1825
  return record !== null;
1141
1826
  }
1142
1827
  async findEdges(params) {
1143
1828
  const plan = buildEdgeQueryPlan(params);
1829
+ let records;
1144
1830
  if (plan.strategy === "get") {
1145
1831
  const record = await this.adapter.getDoc(plan.docId);
1146
- return record ? [record] : [];
1832
+ records = record ? [record] : [];
1833
+ } else {
1834
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1835
+ records = await this.executeQuery(plan.filters, plan.options);
1147
1836
  }
1148
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1149
- return this.executeQuery(plan.filters, plan.options);
1837
+ return this.applyMigrations(records);
1150
1838
  }
1151
1839
  async findNodes(params) {
1152
1840
  const plan = buildNodeQueryPlan(params);
1841
+ let records;
1153
1842
  if (plan.strategy === "get") {
1154
1843
  const record = await this.adapter.getDoc(plan.docId);
1155
- return record ? [record] : [];
1844
+ records = record ? [record] : [];
1845
+ } else {
1846
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1847
+ records = await this.executeQuery(plan.filters, plan.options);
1156
1848
  }
1157
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1158
- return this.executeQuery(plan.filters, plan.options);
1849
+ return this.applyMigrations(records);
1159
1850
  }
1160
1851
  // ---------------------------------------------------------------------------
1161
1852
  // GraphWriter
@@ -1168,6 +1859,12 @@ var GraphClientImpl = class _GraphClientImpl {
1168
1859
  const adapter = this.getAdapterForType(aType);
1169
1860
  const docId = computeNodeDocId(uid);
1170
1861
  const record = buildNodeRecord(aType, uid, data);
1862
+ if (registry) {
1863
+ const entry = registry.lookup(aType, NODE_RELATION, aType);
1864
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
1865
+ record.v = entry.schemaVersion;
1866
+ }
1867
+ }
1171
1868
  await adapter.setDoc(docId, record);
1172
1869
  }
1173
1870
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
@@ -1178,13 +1875,19 @@ var GraphClientImpl = class _GraphClientImpl {
1178
1875
  const adapter = this.getAdapterForType(aType);
1179
1876
  const docId = computeEdgeDocId(aUid, axbType, bUid);
1180
1877
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
1878
+ if (registry) {
1879
+ const entry = registry.lookup(aType, axbType, bType);
1880
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
1881
+ record.v = entry.schemaVersion;
1882
+ }
1883
+ }
1181
1884
  await adapter.setDoc(docId, record);
1182
1885
  }
1183
1886
  async updateNode(uid, data) {
1184
1887
  const docId = computeNodeDocId(uid);
1185
1888
  await this.adapter.updateDoc(docId, {
1186
1889
  ...data,
1187
- updatedAt: FieldValue4.serverTimestamp()
1890
+ updatedAt: FieldValue5.serverTimestamp()
1188
1891
  });
1189
1892
  }
1190
1893
  async removeNode(uid) {
@@ -1205,7 +1908,7 @@ var GraphClientImpl = class _GraphClientImpl {
1205
1908
  this.adapter.collectionPath,
1206
1909
  firestoreTx
1207
1910
  );
1208
- const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
1911
+ const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath, this.globalWriteBack, this.db);
1209
1912
  return fn(graphTx);
1210
1913
  });
1211
1914
  }
@@ -1237,7 +1940,9 @@ var GraphClientImpl = class _GraphClientImpl {
1237
1940
  {
1238
1941
  registry: this.getCombinedRegistry(),
1239
1942
  queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
1240
- scanProtection: this.scanProtection
1943
+ scanProtection: this.scanProtection,
1944
+ migrationWriteBack: this.globalWriteBack,
1945
+ migrationSandbox: this.migrationSandbox
1241
1946
  },
1242
1947
  newScopePath
1243
1948
  );
@@ -1267,7 +1972,8 @@ var GraphClientImpl = class _GraphClientImpl {
1267
1972
  q = q.limit(plan.options.limit);
1268
1973
  }
1269
1974
  const snap = await q.get();
1270
- return snap.docs.map((doc) => doc.data());
1975
+ const records = snap.docs.map((doc) => doc.data());
1976
+ return this.applyMigrations(records);
1271
1977
  }
1272
1978
  // ---------------------------------------------------------------------------
1273
1979
  // Bulk operations
@@ -1292,6 +1998,11 @@ var GraphClientImpl = class _GraphClientImpl {
1292
1998
  `Cannot define type "${name}": this name is reserved for the meta-registry.`
1293
1999
  );
1294
2000
  }
2001
+ if (this.staticRegistry?.lookup(name, NODE_RELATION, name)) {
2002
+ throw new DynamicRegistryError(
2003
+ `Cannot define node type "${name}": already defined in the static registry.`
2004
+ );
2005
+ }
1295
2006
  const uid = generateDeterministicUid(META_NODE_TYPE, name);
1296
2007
  const data = { name, jsonSchema };
1297
2008
  if (description !== void 0) data.description = description;
@@ -1300,6 +2011,10 @@ var GraphClientImpl = class _GraphClientImpl {
1300
2011
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1301
2012
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1302
2013
  if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
2014
+ if (options?.migrationWriteBack !== void 0) data.migrationWriteBack = options.migrationWriteBack;
2015
+ if (options?.migrations !== void 0) {
2016
+ data.migrations = await this.serializeMigrations(options.migrations);
2017
+ }
1303
2018
  await this.putNode(META_NODE_TYPE, uid, data);
1304
2019
  }
1305
2020
  async defineEdgeType(name, topology, jsonSchema, description, options) {
@@ -1313,6 +2028,19 @@ var GraphClientImpl = class _GraphClientImpl {
1313
2028
  `Cannot define type "${name}": this name is reserved for the meta-registry.`
1314
2029
  );
1315
2030
  }
2031
+ if (this.staticRegistry) {
2032
+ const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
2033
+ const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
2034
+ for (const aType of fromTypes) {
2035
+ for (const bType of toTypes) {
2036
+ if (this.staticRegistry.lookup(aType, name, bType)) {
2037
+ throw new DynamicRegistryError(
2038
+ `Cannot define edge type "${name}" for (${aType}) -> (${bType}): already defined in the static registry.`
2039
+ );
2040
+ }
2041
+ }
2042
+ }
2043
+ }
1316
2044
  const uid = generateDeterministicUid(META_EDGE_TYPE, name);
1317
2045
  const data = {
1318
2046
  name,
@@ -1328,6 +2056,10 @@ var GraphClientImpl = class _GraphClientImpl {
1328
2056
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1329
2057
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1330
2058
  if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
2059
+ if (options?.migrationWriteBack !== void 0) data.migrationWriteBack = options.migrationWriteBack;
2060
+ if (options?.migrations !== void 0) {
2061
+ data.migrations = await this.serializeMigrations(options.migrations);
2062
+ }
1331
2063
  await this.putNode(META_EDGE_TYPE, uid, data);
1332
2064
  }
1333
2065
  async reloadRegistry() {
@@ -1337,7 +2069,27 @@ var GraphClientImpl = class _GraphClientImpl {
1337
2069
  );
1338
2070
  }
1339
2071
  const reader = this.createMetaReader();
1340
- this.dynamicRegistry = await createRegistryFromGraph(reader);
2072
+ const dynamicOnly = await createRegistryFromGraph(reader, this.migrationSandbox);
2073
+ if (this.staticRegistry) {
2074
+ this.dynamicRegistry = createMergedRegistry(this.staticRegistry, dynamicOnly);
2075
+ } else {
2076
+ this.dynamicRegistry = dynamicOnly;
2077
+ }
2078
+ }
2079
+ /**
2080
+ * Serialize migration steps for storage in Firestore.
2081
+ * Function objects are converted via `.toString()`; strings are stored as-is.
2082
+ * Each migration is validated at define-time by pre-compiling in the sandbox.
2083
+ */
2084
+ async serializeMigrations(migrations) {
2085
+ const result = migrations.map((m) => {
2086
+ const source = typeof m.up === "function" ? m.up.toString() : m.up;
2087
+ return { fromVersion: m.fromVersion, toVersion: m.toVersion, up: source };
2088
+ });
2089
+ await Promise.all(
2090
+ result.map((m) => precompileSource(m.up, this.migrationSandbox))
2091
+ );
2092
+ return result;
1341
2093
  }
1342
2094
  /**
1343
2095
  * Create a GraphReader for the meta-collection.
@@ -1776,11 +2528,39 @@ function findViewsFile(dir) {
1776
2528
  }
1777
2529
  return void 0;
1778
2530
  }
2531
+ var MIGRATION_EXTENSIONS = [".ts", ".js", ".mts", ".mjs"];
2532
+ function findMigrationsFile(dir) {
2533
+ for (const ext of MIGRATION_EXTENSIONS) {
2534
+ const candidate = join(dir, `migrations${ext}`);
2535
+ if (existsSync(candidate)) return candidate;
2536
+ }
2537
+ return void 0;
2538
+ }
2539
+ function loadMigrations(filePath, entityLabel) {
2540
+ try {
2541
+ const jiti = getJiti();
2542
+ const mod = jiti(filePath);
2543
+ const migrations = mod && typeof mod === "object" && "default" in mod ? mod.default : mod;
2544
+ if (!Array.isArray(migrations)) {
2545
+ throw new DiscoveryError(
2546
+ `Migrations file ${filePath} for ${entityLabel} must default-export an array of MigrationStep.`
2547
+ );
2548
+ }
2549
+ return migrations;
2550
+ } catch (err) {
2551
+ if (err instanceof DiscoveryError) throw err;
2552
+ throw new DiscoveryError(
2553
+ `Failed to load migrations ${filePath} for ${entityLabel}: ${err.message}`
2554
+ );
2555
+ }
2556
+ }
1779
2557
  function loadNodeEntity(dir, name) {
1780
2558
  const schema = loadSchema(dir, `node type "${name}"`);
1781
2559
  const meta = readJsonIfExists(join(dir, "meta.json"));
1782
2560
  const sampleData = readJsonIfExists(join(dir, "sample.json"));
1783
2561
  const viewsPath = findViewsFile(dir);
2562
+ const migrationsPath = findMigrationsFile(dir);
2563
+ const migrations = migrationsPath ? loadMigrations(migrationsPath, `node type "${name}"`) : void 0;
1784
2564
  return {
1785
2565
  kind: "node",
1786
2566
  name,
@@ -1791,7 +2571,9 @@ function loadNodeEntity(dir, name) {
1791
2571
  viewDefaults: meta?.viewDefaults,
1792
2572
  viewsPath,
1793
2573
  sampleData,
1794
- allowedIn: meta?.allowedIn
2574
+ allowedIn: meta?.allowedIn,
2575
+ migrations,
2576
+ migrationWriteBack: meta?.migrationWriteBack
1795
2577
  };
1796
2578
  }
1797
2579
  function loadEdgeEntity(dir, name) {
@@ -1816,6 +2598,8 @@ function loadEdgeEntity(dir, name) {
1816
2598
  const meta = readJsonIfExists(join(dir, "meta.json"));
1817
2599
  const sampleData = readJsonIfExists(join(dir, "sample.json"));
1818
2600
  const viewsPath = findViewsFile(dir);
2601
+ const migrationsPath = findMigrationsFile(dir);
2602
+ const migrations = migrationsPath ? loadMigrations(migrationsPath, `edge type "${name}"`) : void 0;
1819
2603
  return {
1820
2604
  kind: "edge",
1821
2605
  name,
@@ -1828,7 +2612,9 @@ function loadEdgeEntity(dir, name) {
1828
2612
  viewsPath,
1829
2613
  sampleData,
1830
2614
  allowedIn: meta?.allowedIn,
1831
- targetGraph: topology.targetGraph ?? meta?.targetGraph
2615
+ targetGraph: topology.targetGraph ?? meta?.targetGraph,
2616
+ migrations,
2617
+ migrationWriteBack: meta?.migrationWriteBack
1832
2618
  };
1833
2619
  }
1834
2620
  function getSubdirectories(dir) {
@@ -2019,6 +2805,7 @@ export {
2019
2805
  InvalidQueryError,
2020
2806
  META_EDGE_TYPE,
2021
2807
  META_NODE_TYPE,
2808
+ MigrationError,
2022
2809
  NODE_TYPE_SCHEMA,
2023
2810
  NodeNotFoundError,
2024
2811
  QueryClient,
@@ -2026,33 +2813,47 @@ export {
2026
2813
  QuerySafetyError,
2027
2814
  RegistryScopeError,
2028
2815
  RegistryViolationError,
2816
+ SERIALIZATION_TAG,
2029
2817
  TraversalError,
2030
2818
  ValidationError,
2031
2819
  analyzeQuerySafety,
2820
+ applyMigrationChain,
2032
2821
  buildEdgeQueryPlan,
2033
2822
  buildEdgeRecord,
2034
2823
  buildNodeQueryPlan,
2035
2824
  buildNodeRecord,
2825
+ compileMigrationFn,
2826
+ compileMigrations,
2036
2827
  compileSchema,
2037
2828
  computeEdgeDocId,
2038
2829
  computeNodeDocId,
2039
2830
  createBootstrapRegistry,
2040
2831
  createGraphClient,
2832
+ createMergedRegistry,
2041
2833
  createRegistry,
2042
2834
  createRegistryFromGraph,
2043
2835
  createTraversal,
2836
+ defaultExecutor,
2044
2837
  defineConfig,
2045
2838
  defineViews,
2839
+ deserializeFirestoreTypes,
2840
+ destroySandboxWorker,
2046
2841
  discoverEntities,
2047
2842
  generateDeterministicUid,
2048
2843
  generateId,
2049
2844
  generateIndexConfig,
2050
2845
  generateTypes,
2051
2846
  isAncestorUid,
2847
+ isTaggedValue,
2052
2848
  jsonSchemaToFieldMeta,
2053
2849
  matchScope,
2054
2850
  matchScopeAny,
2851
+ migrateRecord,
2852
+ migrateRecords,
2853
+ precompileSource,
2055
2854
  resolveAncestorCollection,
2056
- resolveView
2855
+ resolveView,
2856
+ serializeFirestoreTypes,
2857
+ validateMigrationChain
2057
2858
  };
2058
2859
  //# sourceMappingURL=index.js.map