@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.cjs CHANGED
@@ -40,6 +40,7 @@ __export(index_exports, {
40
40
  InvalidQueryError: () => InvalidQueryError,
41
41
  META_EDGE_TYPE: () => META_EDGE_TYPE,
42
42
  META_NODE_TYPE: () => META_NODE_TYPE,
43
+ MigrationError: () => MigrationError,
43
44
  NODE_TYPE_SCHEMA: () => NODE_TYPE_SCHEMA,
44
45
  NodeNotFoundError: () => NodeNotFoundError,
45
46
  QueryClient: () => QueryClient,
@@ -47,39 +48,53 @@ __export(index_exports, {
47
48
  QuerySafetyError: () => QuerySafetyError,
48
49
  RegistryScopeError: () => RegistryScopeError,
49
50
  RegistryViolationError: () => RegistryViolationError,
51
+ SERIALIZATION_TAG: () => SERIALIZATION_TAG,
50
52
  TraversalError: () => TraversalError,
51
53
  ValidationError: () => ValidationError,
52
54
  analyzeQuerySafety: () => analyzeQuerySafety,
55
+ applyMigrationChain: () => applyMigrationChain,
53
56
  buildEdgeQueryPlan: () => buildEdgeQueryPlan,
54
57
  buildEdgeRecord: () => buildEdgeRecord,
55
58
  buildNodeQueryPlan: () => buildNodeQueryPlan,
56
59
  buildNodeRecord: () => buildNodeRecord,
60
+ compileMigrationFn: () => compileMigrationFn,
61
+ compileMigrations: () => compileMigrations,
57
62
  compileSchema: () => compileSchema,
58
63
  computeEdgeDocId: () => computeEdgeDocId,
59
64
  computeNodeDocId: () => computeNodeDocId,
60
65
  createBootstrapRegistry: () => createBootstrapRegistry,
61
66
  createGraphClient: () => createGraphClient,
67
+ createMergedRegistry: () => createMergedRegistry,
62
68
  createRegistry: () => createRegistry,
63
69
  createRegistryFromGraph: () => createRegistryFromGraph,
64
70
  createTraversal: () => createTraversal,
71
+ defaultExecutor: () => defaultExecutor,
65
72
  defineConfig: () => defineConfig,
66
73
  defineViews: () => defineViews,
74
+ deserializeFirestoreTypes: () => deserializeFirestoreTypes,
75
+ destroySandboxWorker: () => destroySandboxWorker,
67
76
  discoverEntities: () => discoverEntities,
68
77
  generateDeterministicUid: () => generateDeterministicUid,
69
78
  generateId: () => generateId,
70
79
  generateIndexConfig: () => generateIndexConfig,
71
80
  generateTypes: () => generateTypes,
72
81
  isAncestorUid: () => isAncestorUid,
82
+ isTaggedValue: () => isTaggedValue,
73
83
  jsonSchemaToFieldMeta: () => jsonSchemaToFieldMeta,
74
84
  matchScope: () => matchScope,
75
85
  matchScopeAny: () => matchScopeAny,
86
+ migrateRecord: () => migrateRecord,
87
+ migrateRecords: () => migrateRecords,
88
+ precompileSource: () => precompileSource,
76
89
  resolveAncestorCollection: () => resolveAncestorCollection,
77
- resolveView: () => resolveView
90
+ resolveView: () => resolveView,
91
+ serializeFirestoreTypes: () => serializeFirestoreTypes,
92
+ validateMigrationChain: () => validateMigrationChain
78
93
  });
79
94
  module.exports = __toCommonJS(index_exports);
80
95
 
81
96
  // src/client.ts
82
- var import_firestore4 = require("@google-cloud/firestore");
97
+ var import_firestore5 = require("@google-cloud/firestore");
83
98
 
84
99
  // src/docid.ts
85
100
  var import_node_crypto = require("crypto");
@@ -207,6 +222,12 @@ var RegistryScopeError = class extends FiregraphError {
207
222
  this.name = "RegistryScopeError";
208
223
  }
209
224
  };
225
+ var MigrationError = class extends FiregraphError {
226
+ constructor(message) {
227
+ super(message, "MIGRATION_ERROR");
228
+ this.name = "MigrationError";
229
+ }
230
+ };
210
231
 
211
232
  // src/query.ts
212
233
  function buildEdgeQueryPlan(params) {
@@ -397,7 +418,7 @@ function createPipelineQueryAdapter(db, collectionPath) {
397
418
  }
398
419
 
399
420
  // src/transaction.ts
400
- var import_firestore2 = require("@google-cloud/firestore");
421
+ var import_firestore3 = require("@google-cloud/firestore");
401
422
 
402
423
  // src/query-safety.ts
403
424
  var SAFE_INDEX_PATTERNS = [
@@ -447,24 +468,248 @@ function analyzeQuerySafety(filters) {
447
468
  };
448
469
  }
449
470
 
471
+ // src/serialization.ts
472
+ var import_firestore2 = require("@google-cloud/firestore");
473
+ var SERIALIZATION_TAG = "__firegraph_ser__";
474
+ var KNOWN_TYPES = /* @__PURE__ */ new Set(["Timestamp", "GeoPoint", "VectorValue", "DocumentReference"]);
475
+ var _docRefWarned = false;
476
+ function isTaggedValue(value) {
477
+ if (value === null || typeof value !== "object") return false;
478
+ const tag = value[SERIALIZATION_TAG];
479
+ return typeof tag === "string" && KNOWN_TYPES.has(tag);
480
+ }
481
+ function isTimestamp(value) {
482
+ return value instanceof import_firestore2.Timestamp;
483
+ }
484
+ function isGeoPoint(value) {
485
+ return value instanceof import_firestore2.GeoPoint;
486
+ }
487
+ function isDocumentReference(value) {
488
+ if (value === null || typeof value !== "object") return false;
489
+ const v = value;
490
+ return typeof v.path === "string" && v.firestore !== void 0 && typeof v.id === "string" && v.constructor?.name === "DocumentReference";
491
+ }
492
+ function isVectorValue(value) {
493
+ if (value === null || typeof value !== "object") return false;
494
+ const v = value;
495
+ return v.constructor?.name === "VectorValue" && Array.isArray(v._values);
496
+ }
497
+ function serializeFirestoreTypes(data) {
498
+ return serializeValue(data);
499
+ }
500
+ function serializeValue(value) {
501
+ if (value === null || value === void 0) return value;
502
+ if (typeof value !== "object") return value;
503
+ if (isTimestamp(value)) {
504
+ return { [SERIALIZATION_TAG]: "Timestamp", seconds: value.seconds, nanoseconds: value.nanoseconds };
505
+ }
506
+ if (isGeoPoint(value)) {
507
+ return { [SERIALIZATION_TAG]: "GeoPoint", latitude: value.latitude, longitude: value.longitude };
508
+ }
509
+ if (isDocumentReference(value)) {
510
+ return { [SERIALIZATION_TAG]: "DocumentReference", path: value.path };
511
+ }
512
+ if (isVectorValue(value)) {
513
+ const v = value;
514
+ const values = typeof v.toArray === "function" ? v.toArray() : v._values;
515
+ return { [SERIALIZATION_TAG]: "VectorValue", values: [...values] };
516
+ }
517
+ if (Array.isArray(value)) {
518
+ return value.map(serializeValue);
519
+ }
520
+ const result = {};
521
+ for (const key of Object.keys(value)) {
522
+ result[key] = serializeValue(value[key]);
523
+ }
524
+ return result;
525
+ }
526
+ function deserializeFirestoreTypes(data, db) {
527
+ return deserializeValue(data, db);
528
+ }
529
+ function deserializeValue(value, db) {
530
+ if (value === null || value === void 0) return value;
531
+ if (typeof value !== "object") return value;
532
+ if (isTimestamp(value) || isGeoPoint(value) || isDocumentReference(value) || isVectorValue(value)) {
533
+ return value;
534
+ }
535
+ if (Array.isArray(value)) {
536
+ return value.map((v) => deserializeValue(v, db));
537
+ }
538
+ const obj = value;
539
+ if (isTaggedValue(obj)) {
540
+ const tag = obj[SERIALIZATION_TAG];
541
+ switch (tag) {
542
+ case "Timestamp":
543
+ if (typeof obj.seconds !== "number" || typeof obj.nanoseconds !== "number") return obj;
544
+ return new import_firestore2.Timestamp(obj.seconds, obj.nanoseconds);
545
+ case "GeoPoint":
546
+ if (typeof obj.latitude !== "number" || typeof obj.longitude !== "number") return obj;
547
+ return new import_firestore2.GeoPoint(obj.latitude, obj.longitude);
548
+ case "VectorValue":
549
+ if (!Array.isArray(obj.values)) return obj;
550
+ return import_firestore2.FieldValue.vector(obj.values);
551
+ case "DocumentReference":
552
+ if (typeof obj.path !== "string") return obj;
553
+ if (db) {
554
+ return db.doc(obj.path);
555
+ }
556
+ if (!_docRefWarned) {
557
+ _docRefWarned = true;
558
+ console.warn(
559
+ "[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."
560
+ );
561
+ }
562
+ return obj;
563
+ default:
564
+ return obj;
565
+ }
566
+ }
567
+ const result = {};
568
+ for (const key of Object.keys(obj)) {
569
+ result[key] = deserializeValue(obj[key], db);
570
+ }
571
+ return result;
572
+ }
573
+
574
+ // src/migration.ts
575
+ async function applyMigrationChain(data, currentVersion, targetVersion, migrations) {
576
+ const sorted = [...migrations].sort((a, b) => a.fromVersion - b.fromVersion);
577
+ let result = { ...data };
578
+ let version = currentVersion;
579
+ for (const step of sorted) {
580
+ if (step.fromVersion === version) {
581
+ try {
582
+ result = await step.up(result);
583
+ } catch (err) {
584
+ if (err instanceof MigrationError) throw err;
585
+ throw new MigrationError(
586
+ `Migration from v${step.fromVersion} to v${step.toVersion} failed: ${err.message}`
587
+ );
588
+ }
589
+ if (!result || typeof result !== "object") {
590
+ throw new MigrationError(
591
+ `Migration from v${step.fromVersion} to v${step.toVersion} returned invalid data (expected object)`
592
+ );
593
+ }
594
+ version = step.toVersion;
595
+ }
596
+ }
597
+ if (version !== targetVersion) {
598
+ throw new MigrationError(
599
+ `Incomplete migration chain: reached v${version} but target is v${targetVersion}`
600
+ );
601
+ }
602
+ return result;
603
+ }
604
+ function validateMigrationChain(migrations, label) {
605
+ if (migrations.length === 0) return;
606
+ const seen = /* @__PURE__ */ new Set();
607
+ for (const step of migrations) {
608
+ if (step.toVersion <= step.fromVersion) {
609
+ throw new MigrationError(
610
+ `${label}: migration step has toVersion (${step.toVersion}) <= fromVersion (${step.fromVersion})`
611
+ );
612
+ }
613
+ if (seen.has(step.fromVersion)) {
614
+ throw new MigrationError(
615
+ `${label}: duplicate migration step for fromVersion ${step.fromVersion}`
616
+ );
617
+ }
618
+ seen.add(step.fromVersion);
619
+ }
620
+ const sorted = [...migrations].sort((a, b) => a.fromVersion - b.fromVersion);
621
+ const targetVersion = Math.max(...migrations.map((m) => m.toVersion));
622
+ let version = 0;
623
+ for (const step of sorted) {
624
+ if (step.fromVersion === version) {
625
+ version = step.toVersion;
626
+ } else if (step.fromVersion > version) {
627
+ throw new MigrationError(
628
+ `${label}: migration chain has a gap \u2014 no step covers v${version} \u2192 v${step.fromVersion}`
629
+ );
630
+ }
631
+ }
632
+ if (version !== targetVersion) {
633
+ throw new MigrationError(
634
+ `${label}: migration chain does not reach v${targetVersion} (stuck at v${version})`
635
+ );
636
+ }
637
+ }
638
+ async function migrateRecord(record, registry, globalWriteBack = "off") {
639
+ const entry = registry.lookup(record.aType, record.axbType, record.bType);
640
+ if (!entry?.migrations?.length || !entry.schemaVersion) {
641
+ return { record, migrated: false, writeBack: "off" };
642
+ }
643
+ const currentVersion = record.v ?? 0;
644
+ if (currentVersion >= entry.schemaVersion) {
645
+ return { record, migrated: false, writeBack: "off" };
646
+ }
647
+ const migratedData = await applyMigrationChain(
648
+ record.data,
649
+ currentVersion,
650
+ entry.schemaVersion,
651
+ entry.migrations
652
+ );
653
+ const writeBack = entry.migrationWriteBack ?? globalWriteBack ?? "off";
654
+ return {
655
+ record: { ...record, data: migratedData, v: entry.schemaVersion },
656
+ migrated: true,
657
+ writeBack
658
+ };
659
+ }
660
+ async function migrateRecords(records, registry, globalWriteBack = "off") {
661
+ return Promise.all(
662
+ records.map((r) => migrateRecord(r, registry, globalWriteBack))
663
+ );
664
+ }
665
+
450
666
  // src/transaction.ts
451
667
  var GraphTransactionImpl = class {
452
- constructor(adapter, registry, scanProtection = "error", scopePath = "") {
668
+ constructor(adapter, registry, scanProtection = "error", scopePath = "", globalWriteBack = "off", db) {
453
669
  this.adapter = adapter;
454
670
  this.registry = registry;
455
671
  this.scanProtection = scanProtection;
456
672
  this.scopePath = scopePath;
673
+ this.globalWriteBack = globalWriteBack;
674
+ this.db = db;
457
675
  }
458
676
  async getNode(uid) {
459
677
  const docId = computeNodeDocId(uid);
460
- return this.adapter.getDoc(docId);
678
+ const record = await this.adapter.getDoc(docId);
679
+ if (!record || !this.registry) return record;
680
+ const result = await migrateRecord(record, this.registry, this.globalWriteBack);
681
+ if (result.migrated && result.writeBack !== "off") {
682
+ const update = {
683
+ data: deserializeFirestoreTypes(result.record.data, this.db),
684
+ updatedAt: import_firestore3.FieldValue.serverTimestamp()
685
+ };
686
+ if (result.record.v !== void 0) {
687
+ update.v = result.record.v;
688
+ }
689
+ this.adapter.updateDoc(docId, update);
690
+ }
691
+ return result.record;
461
692
  }
462
693
  async getEdge(aUid, axbType, bUid) {
463
694
  const docId = computeEdgeDocId(aUid, axbType, bUid);
464
- return this.adapter.getDoc(docId);
695
+ const record = await this.adapter.getDoc(docId);
696
+ if (!record || !this.registry) return record;
697
+ const result = await migrateRecord(record, this.registry, this.globalWriteBack);
698
+ if (result.migrated && result.writeBack !== "off") {
699
+ const update = {
700
+ data: deserializeFirestoreTypes(result.record.data, this.db),
701
+ updatedAt: import_firestore3.FieldValue.serverTimestamp()
702
+ };
703
+ if (result.record.v !== void 0) {
704
+ update.v = result.record.v;
705
+ }
706
+ this.adapter.updateDoc(docId, update);
707
+ }
708
+ return result.record;
465
709
  }
466
710
  async edgeExists(aUid, axbType, bUid) {
467
- const record = await this.getEdge(aUid, axbType, bUid);
711
+ const docId = computeEdgeDocId(aUid, axbType, bUid);
712
+ const record = await this.adapter.getDoc(docId);
468
713
  return record !== null;
469
714
  }
470
715
  checkQuerySafety(filters, allowCollectionScan) {
@@ -478,21 +723,45 @@ var GraphTransactionImpl = class {
478
723
  }
479
724
  async findEdges(params) {
480
725
  const plan = buildEdgeQueryPlan(params);
726
+ let records;
481
727
  if (plan.strategy === "get") {
482
728
  const record = await this.adapter.getDoc(plan.docId);
483
- return record ? [record] : [];
729
+ records = record ? [record] : [];
730
+ } else {
731
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
732
+ records = await this.adapter.query(plan.filters, plan.options);
484
733
  }
485
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
486
- return this.adapter.query(plan.filters, plan.options);
734
+ return this.applyMigrations(records);
487
735
  }
488
736
  async findNodes(params) {
489
737
  const plan = buildNodeQueryPlan(params);
738
+ let records;
490
739
  if (plan.strategy === "get") {
491
740
  const record = await this.adapter.getDoc(plan.docId);
492
- return record ? [record] : [];
741
+ records = record ? [record] : [];
742
+ } else {
743
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
744
+ records = await this.adapter.query(plan.filters, plan.options);
745
+ }
746
+ return this.applyMigrations(records);
747
+ }
748
+ async applyMigrations(records) {
749
+ if (!this.registry || records.length === 0) return records;
750
+ const results = await migrateRecords(records, this.registry, this.globalWriteBack);
751
+ for (const result of results) {
752
+ if (result.migrated && result.writeBack !== "off") {
753
+ const docId = result.record.axbType === NODE_RELATION ? computeNodeDocId(result.record.aUid) : computeEdgeDocId(result.record.aUid, result.record.axbType, result.record.bUid);
754
+ const update = {
755
+ data: deserializeFirestoreTypes(result.record.data, this.db),
756
+ updatedAt: import_firestore3.FieldValue.serverTimestamp()
757
+ };
758
+ if (result.record.v !== void 0) {
759
+ update.v = result.record.v;
760
+ }
761
+ this.adapter.updateDoc(docId, update);
762
+ }
493
763
  }
494
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
495
- return this.adapter.query(plan.filters, plan.options);
764
+ return results.map((r) => r.record);
496
765
  }
497
766
  async putNode(aType, uid, data) {
498
767
  if (this.registry) {
@@ -500,6 +769,12 @@ var GraphTransactionImpl = class {
500
769
  }
501
770
  const docId = computeNodeDocId(uid);
502
771
  const record = buildNodeRecord(aType, uid, data);
772
+ if (this.registry) {
773
+ const entry = this.registry.lookup(aType, NODE_RELATION, aType);
774
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
775
+ record.v = entry.schemaVersion;
776
+ }
777
+ }
503
778
  this.adapter.setDoc(docId, record);
504
779
  }
505
780
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
@@ -508,13 +783,19 @@ var GraphTransactionImpl = class {
508
783
  }
509
784
  const docId = computeEdgeDocId(aUid, axbType, bUid);
510
785
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
786
+ if (this.registry) {
787
+ const entry = this.registry.lookup(aType, axbType, bType);
788
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
789
+ record.v = entry.schemaVersion;
790
+ }
791
+ }
511
792
  this.adapter.setDoc(docId, record);
512
793
  }
513
794
  async updateNode(uid, data) {
514
795
  const docId = computeNodeDocId(uid);
515
796
  this.adapter.updateDoc(docId, {
516
797
  ...data,
517
- updatedAt: import_firestore2.FieldValue.serverTimestamp()
798
+ updatedAt: import_firestore3.FieldValue.serverTimestamp()
518
799
  });
519
800
  }
520
801
  async removeNode(uid) {
@@ -528,7 +809,7 @@ var GraphTransactionImpl = class {
528
809
  };
529
810
 
530
811
  // src/batch.ts
531
- var import_firestore3 = require("@google-cloud/firestore");
812
+ var import_firestore4 = require("@google-cloud/firestore");
532
813
  var GraphBatchImpl = class {
533
814
  constructor(adapter, registry, scopePath = "") {
534
815
  this.adapter = adapter;
@@ -541,6 +822,12 @@ var GraphBatchImpl = class {
541
822
  }
542
823
  const docId = computeNodeDocId(uid);
543
824
  const record = buildNodeRecord(aType, uid, data);
825
+ if (this.registry) {
826
+ const entry = this.registry.lookup(aType, NODE_RELATION, aType);
827
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
828
+ record.v = entry.schemaVersion;
829
+ }
830
+ }
544
831
  this.adapter.setDoc(docId, record);
545
832
  }
546
833
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
@@ -549,13 +836,19 @@ var GraphBatchImpl = class {
549
836
  }
550
837
  const docId = computeEdgeDocId(aUid, axbType, bUid);
551
838
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
839
+ if (this.registry) {
840
+ const entry = this.registry.lookup(aType, axbType, bType);
841
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
842
+ record.v = entry.schemaVersion;
843
+ }
844
+ }
552
845
  this.adapter.setDoc(docId, record);
553
846
  }
554
847
  async updateNode(uid, data) {
555
848
  const docId = computeNodeDocId(uid);
556
849
  this.adapter.updateDoc(docId, {
557
850
  ...data,
558
- updatedAt: import_firestore3.FieldValue.serverTimestamp()
851
+ updatedAt: import_firestore4.FieldValue.serverTimestamp()
559
852
  });
560
853
  }
561
854
  async removeNode(uid) {
@@ -714,7 +1007,7 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
714
1007
  }
715
1008
 
716
1009
  // src/dynamic-registry.ts
717
- var import_node_crypto2 = require("crypto");
1010
+ var import_node_crypto3 = require("crypto");
718
1011
 
719
1012
  // src/json-schema.ts
720
1013
  var import_ajv = __toESM(require("ajv"), 1);
@@ -845,6 +1138,9 @@ function matchSegments(path, pi, pattern, qi) {
845
1138
  function tripleKey(aType, axbType, bType) {
846
1139
  return `${aType}:${axbType}:${bType}`;
847
1140
  }
1141
+ function tripleKeyFor(e) {
1142
+ return tripleKey(e.aType, e.axbType, e.bType);
1143
+ }
848
1144
  function createRegistry(input) {
849
1145
  const map = /* @__PURE__ */ new Map();
850
1146
  let entries;
@@ -860,6 +1156,13 @@ function createRegistry(input) {
860
1156
  `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType}) has invalid targetGraph "${entry.targetGraph}" \u2014 must be a single segment (no "/")`
861
1157
  );
862
1158
  }
1159
+ if (entry.migrations?.length) {
1160
+ const label = `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`;
1161
+ validateMigrationChain(entry.migrations, label);
1162
+ entry.schemaVersion = Math.max(...entry.migrations.map((m) => m.toVersion));
1163
+ } else {
1164
+ entry.schemaVersion = void 0;
1165
+ }
863
1166
  const key = tripleKey(entry.aType, entry.axbType, entry.bType);
864
1167
  const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
865
1168
  map.set(key, { entry, validate: validator });
@@ -911,6 +1214,45 @@ function createRegistry(input) {
911
1214
  }
912
1215
  };
913
1216
  }
1217
+ function createMergedRegistry(base, extension) {
1218
+ const baseKeys = new Set(base.entries().map(tripleKeyFor));
1219
+ return {
1220
+ lookup(aType, axbType, bType) {
1221
+ return base.lookup(aType, axbType, bType) ?? extension.lookup(aType, axbType, bType);
1222
+ },
1223
+ lookupByAxbType(axbType) {
1224
+ const baseResults = base.lookupByAxbType(axbType);
1225
+ const extResults = extension.lookupByAxbType(axbType);
1226
+ if (extResults.length === 0) return baseResults;
1227
+ if (baseResults.length === 0) return extResults;
1228
+ const seen = new Set(baseResults.map(tripleKeyFor));
1229
+ const merged = [...baseResults];
1230
+ for (const entry of extResults) {
1231
+ if (!seen.has(tripleKeyFor(entry))) {
1232
+ merged.push(entry);
1233
+ }
1234
+ }
1235
+ return Object.freeze(merged);
1236
+ },
1237
+ validate(aType, axbType, bType, data, scopePath) {
1238
+ if (baseKeys.has(tripleKey(aType, axbType, bType))) {
1239
+ return base.validate(aType, axbType, bType, data, scopePath);
1240
+ }
1241
+ return extension.validate(aType, axbType, bType, data, scopePath);
1242
+ },
1243
+ entries() {
1244
+ const extEntries = extension.entries();
1245
+ if (extEntries.length === 0) return base.entries();
1246
+ const merged = [...base.entries()];
1247
+ for (const entry of extEntries) {
1248
+ if (!baseKeys.has(tripleKeyFor(entry))) {
1249
+ merged.push(entry);
1250
+ }
1251
+ }
1252
+ return Object.freeze(merged);
1253
+ }
1254
+ };
1255
+ }
914
1256
  function discoveryToEntries(discovery) {
915
1257
  const entries = [];
916
1258
  for (const [name, entity] of discovery.nodes) {
@@ -922,7 +1264,9 @@ function discoveryToEntries(discovery) {
922
1264
  description: entity.description,
923
1265
  titleField: entity.titleField,
924
1266
  subtitleField: entity.subtitleField,
925
- allowedIn: entity.allowedIn
1267
+ allowedIn: entity.allowedIn,
1268
+ migrations: entity.migrations,
1269
+ migrationWriteBack: entity.migrationWriteBack
926
1270
  });
927
1271
  }
928
1272
  for (const [axbType, entity] of discovery.edges) {
@@ -948,7 +1292,9 @@ function discoveryToEntries(discovery) {
948
1292
  titleField: entity.titleField,
949
1293
  subtitleField: entity.subtitleField,
950
1294
  allowedIn: entity.allowedIn,
951
- targetGraph: resolvedTargetGraph
1295
+ targetGraph: resolvedTargetGraph,
1296
+ migrations: entity.migrations,
1297
+ migrationWriteBack: entity.migrationWriteBack
952
1298
  });
953
1299
  }
954
1300
  }
@@ -956,9 +1302,259 @@ function discoveryToEntries(discovery) {
956
1302
  return entries;
957
1303
  }
958
1304
 
1305
+ // src/sandbox.ts
1306
+ var import_node_worker_threads = require("worker_threads");
1307
+ var import_node_crypto2 = require("crypto");
1308
+ var import_meta = {};
1309
+ var _worker = null;
1310
+ var _requestId = 0;
1311
+ var _pending = /* @__PURE__ */ new Map();
1312
+ var WORKER_SOURCE = [
1313
+ `'use strict';`,
1314
+ `var _wt = require('node:worker_threads');`,
1315
+ `var _mod = require('node:module');`,
1316
+ `var _crypto = require('node:crypto');`,
1317
+ `var parentPort = _wt.parentPort;`,
1318
+ `var workerData = _wt.workerData;`,
1319
+ ``,
1320
+ `// Load SES using the parent module's resolution context`,
1321
+ `var esmRequire = _mod.createRequire(workerData.parentUrl);`,
1322
+ `esmRequire('ses');`,
1323
+ ``,
1324
+ `lockdown({`,
1325
+ ` errorTaming: 'unsafe',`,
1326
+ ` consoleTaming: 'unsafe',`,
1327
+ ` evalTaming: 'safe-eval',`,
1328
+ ` overrideTaming: 'moderate',`,
1329
+ ` stackFiltering: 'verbose'`,
1330
+ `});`,
1331
+ ``,
1332
+ `// Defense-in-depth: verify lockdown() actually hardened JSON.`,
1333
+ `if (!Object.isFrozen(JSON)) {`,
1334
+ ` throw new Error('SES lockdown failed: JSON is not frozen');`,
1335
+ `}`,
1336
+ ``,
1337
+ `var cache = new Map();`,
1338
+ ``,
1339
+ `function hashSource(s) {`,
1340
+ ` return _crypto.createHash('sha256').update(s).digest('hex');`,
1341
+ `}`,
1342
+ ``,
1343
+ `function buildWrapper(source) {`,
1344
+ ` return '(function() {' +`,
1345
+ ` ' var fn = (' + source + ');\\n' +`,
1346
+ ` ' if (typeof fn !== "function") return null;\\n' +`,
1347
+ ` ' return function(jsonIn) {\\n' +`,
1348
+ ` ' var data = JSON.parse(jsonIn);\\n' +`,
1349
+ ` ' var result = fn(data);\\n' +`,
1350
+ ` ' if (result !== null && typeof result === "object" && typeof result.then === "function") {\\n' +`,
1351
+ ` ' return result.then(function(r) { return JSON.stringify(r); });\\n' +`,
1352
+ ` ' }\\n' +`,
1353
+ ` ' return JSON.stringify(result);\\n' +`,
1354
+ ` ' };\\n' +`,
1355
+ ` '})()';`,
1356
+ `}`,
1357
+ ``,
1358
+ `function compileSource(source) {`,
1359
+ ` var key = hashSource(source);`,
1360
+ ` var cached = cache.get(key);`,
1361
+ ` if (cached) return cached;`,
1362
+ ``,
1363
+ ` var compartmentFn;`,
1364
+ ` try {`,
1365
+ ` var c = new Compartment({ JSON: JSON });`,
1366
+ ` compartmentFn = c.evaluate(buildWrapper(source));`,
1367
+ ` } catch (err) {`,
1368
+ ` throw new Error('Failed to compile migration source: ' + (err.message || String(err)));`,
1369
+ ` }`,
1370
+ ``,
1371
+ ` if (typeof compartmentFn !== 'function') {`,
1372
+ ` throw new Error('Migration source did not produce a function: ' + source.slice(0, 80));`,
1373
+ ` }`,
1374
+ ``,
1375
+ ` cache.set(key, compartmentFn);`,
1376
+ ` return compartmentFn;`,
1377
+ `}`,
1378
+ ``,
1379
+ `parentPort.on('message', function(msg) {`,
1380
+ ` var id = msg.id;`,
1381
+ ` try {`,
1382
+ ` if (msg.type === 'compile') {`,
1383
+ ` compileSource(msg.source);`,
1384
+ ` parentPort.postMessage({ id: id, type: 'compiled' });`,
1385
+ ` return;`,
1386
+ ` }`,
1387
+ ` if (msg.type === 'execute') {`,
1388
+ ` var fn = compileSource(msg.source);`,
1389
+ ` var raw;`,
1390
+ ` try {`,
1391
+ ` raw = fn(msg.jsonData);`,
1392
+ ` } catch (err) {`,
1393
+ ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration function threw: ' + (err.message || String(err)) });`,
1394
+ ` return;`,
1395
+ ` }`,
1396
+ ` if (raw !== null && typeof raw === 'object' && typeof raw.then === 'function') {`,
1397
+ ` raw.then(`,
1398
+ ` function(jsonResult) {`,
1399
+ ` if (jsonResult === undefined || jsonResult === null) {`,
1400
+ ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration returned a non-JSON-serializable value' });`,
1401
+ ` } else {`,
1402
+ ` parentPort.postMessage({ id: id, type: 'result', jsonResult: jsonResult });`,
1403
+ ` }`,
1404
+ ` },`,
1405
+ ` function(err) {`,
1406
+ ` parentPort.postMessage({ id: id, type: 'error', message: 'Async migration function threw: ' + (err.message || String(err)) });`,
1407
+ ` }`,
1408
+ ` );`,
1409
+ ` return;`,
1410
+ ` }`,
1411
+ ` if (raw === undefined || raw === null) {`,
1412
+ ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration returned a non-JSON-serializable value' });`,
1413
+ ` } else {`,
1414
+ ` parentPort.postMessage({ id: id, type: 'result', jsonResult: raw });`,
1415
+ ` }`,
1416
+ ` }`,
1417
+ ` } catch (err) {`,
1418
+ ` parentPort.postMessage({ id: id, type: 'error', message: err.message || String(err) });`,
1419
+ ` }`,
1420
+ `});`
1421
+ ].join("\n");
1422
+ function ensureWorker() {
1423
+ if (_worker) return _worker;
1424
+ _worker = new import_node_worker_threads.Worker(WORKER_SOURCE, {
1425
+ eval: true,
1426
+ workerData: { parentUrl: import_meta.url }
1427
+ });
1428
+ _worker.unref();
1429
+ _worker.on("message", (msg) => {
1430
+ if (msg.id === void 0) return;
1431
+ const pending = _pending.get(msg.id);
1432
+ if (!pending) return;
1433
+ _pending.delete(msg.id);
1434
+ if (msg.type === "error") {
1435
+ pending.reject(new MigrationError(msg.message ?? "Unknown sandbox error"));
1436
+ } else {
1437
+ pending.resolve(msg);
1438
+ }
1439
+ });
1440
+ _worker.on("error", (err) => {
1441
+ for (const [, p] of _pending) {
1442
+ p.reject(new MigrationError(`Sandbox worker error: ${err.message}`));
1443
+ }
1444
+ _pending.clear();
1445
+ _worker = null;
1446
+ });
1447
+ _worker.on("exit", (code) => {
1448
+ if (_pending.size > 0) {
1449
+ for (const [, p] of _pending) {
1450
+ p.reject(new MigrationError(`Sandbox worker exited with code ${code}`));
1451
+ }
1452
+ _pending.clear();
1453
+ }
1454
+ _worker = null;
1455
+ });
1456
+ return _worker;
1457
+ }
1458
+ function sendToWorker(msg) {
1459
+ const worker = ensureWorker();
1460
+ if (_requestId >= Number.MAX_SAFE_INTEGER) _requestId = 0;
1461
+ const id = ++_requestId;
1462
+ return new Promise((resolve2, reject) => {
1463
+ _pending.set(id, { resolve: resolve2, reject });
1464
+ worker.postMessage({ ...msg, id });
1465
+ });
1466
+ }
1467
+ var compiledCache = /* @__PURE__ */ new WeakMap();
1468
+ function getExecutorCache(executor) {
1469
+ let cache = compiledCache.get(executor);
1470
+ if (!cache) {
1471
+ cache = /* @__PURE__ */ new Map();
1472
+ compiledCache.set(executor, cache);
1473
+ }
1474
+ return cache;
1475
+ }
1476
+ function hashSource(source) {
1477
+ return (0, import_node_crypto2.createHash)("sha256").update(source).digest("hex");
1478
+ }
1479
+ function defaultExecutor(source) {
1480
+ ensureWorker();
1481
+ return ((data) => {
1482
+ const jsonData = JSON.stringify(serializeFirestoreTypes(data));
1483
+ return sendToWorker({ type: "execute", source, jsonData }).then(
1484
+ (response) => {
1485
+ if (response.jsonResult === void 0 || response.jsonResult === null) {
1486
+ throw new MigrationError("Migration returned a non-JSON-serializable value");
1487
+ }
1488
+ try {
1489
+ return deserializeFirestoreTypes(JSON.parse(response.jsonResult));
1490
+ } catch {
1491
+ throw new MigrationError("Migration returned a non-JSON-serializable value");
1492
+ }
1493
+ }
1494
+ );
1495
+ });
1496
+ }
1497
+ async function precompileSource(source, executor) {
1498
+ if (executor && executor !== defaultExecutor) {
1499
+ try {
1500
+ executor(source);
1501
+ } catch (err) {
1502
+ if (err instanceof MigrationError) throw err;
1503
+ throw new MigrationError(
1504
+ `Failed to compile migration source: ${err.message}`
1505
+ );
1506
+ }
1507
+ return;
1508
+ }
1509
+ await sendToWorker({ type: "compile", source });
1510
+ }
1511
+ function compileMigrationFn(source, executor = defaultExecutor) {
1512
+ const cache = getExecutorCache(executor);
1513
+ const key = hashSource(source);
1514
+ const cached = cache.get(key);
1515
+ if (cached) return cached;
1516
+ try {
1517
+ const fn = executor(source);
1518
+ cache.set(key, fn);
1519
+ return fn;
1520
+ } catch (err) {
1521
+ if (err instanceof MigrationError) throw err;
1522
+ throw new MigrationError(
1523
+ `Failed to compile migration source: ${err.message}`
1524
+ );
1525
+ }
1526
+ }
1527
+ function compileMigrations(stored, executor) {
1528
+ return stored.map((step) => ({
1529
+ fromVersion: step.fromVersion,
1530
+ toVersion: step.toVersion,
1531
+ up: compileMigrationFn(step.up, executor)
1532
+ }));
1533
+ }
1534
+ async function destroySandboxWorker() {
1535
+ if (!_worker) return;
1536
+ const w = _worker;
1537
+ _worker = null;
1538
+ for (const [, p] of _pending) {
1539
+ p.reject(new MigrationError("Sandbox worker terminated"));
1540
+ }
1541
+ _pending.clear();
1542
+ await w.terminate();
1543
+ }
1544
+
959
1545
  // src/dynamic-registry.ts
960
1546
  var META_NODE_TYPE = "nodeType";
961
1547
  var META_EDGE_TYPE = "edgeType";
1548
+ var STORED_MIGRATION_STEP_SCHEMA = {
1549
+ type: "object",
1550
+ required: ["fromVersion", "toVersion", "up"],
1551
+ properties: {
1552
+ fromVersion: { type: "integer", minimum: 0 },
1553
+ toVersion: { type: "integer", minimum: 1 },
1554
+ up: { type: "string", minLength: 1 }
1555
+ },
1556
+ additionalProperties: false
1557
+ };
962
1558
  var NODE_TYPE_SCHEMA = {
963
1559
  type: "object",
964
1560
  required: ["name", "jsonSchema"],
@@ -970,7 +1566,10 @@ var NODE_TYPE_SCHEMA = {
970
1566
  subtitleField: { type: "string" },
971
1567
  viewTemplate: { type: "string" },
972
1568
  viewCss: { type: "string" },
973
- allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
1569
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
1570
+ schemaVersion: { type: "integer", minimum: 0 },
1571
+ migrations: { type: "array", items: STORED_MIGRATION_STEP_SCHEMA },
1572
+ migrationWriteBack: { type: "string", enum: ["off", "eager", "background"] }
974
1573
  },
975
1574
  additionalProperties: false
976
1575
  };
@@ -999,7 +1598,10 @@ var EDGE_TYPE_SCHEMA = {
999
1598
  viewTemplate: { type: "string" },
1000
1599
  viewCss: { type: "string" },
1001
1600
  allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
1002
- targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" }
1601
+ targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" },
1602
+ schemaVersion: { type: "integer", minimum: 0 },
1603
+ migrations: { type: "array", items: STORED_MIGRATION_STEP_SCHEMA },
1604
+ migrationWriteBack: { type: "string", enum: ["off", "eager", "background"] }
1003
1605
  },
1004
1606
  additionalProperties: false
1005
1607
  };
@@ -1023,15 +1625,33 @@ function createBootstrapRegistry() {
1023
1625
  return createRegistry([...BOOTSTRAP_ENTRIES]);
1024
1626
  }
1025
1627
  function generateDeterministicUid(metaType, name) {
1026
- const hash = (0, import_node_crypto2.createHash)("sha256").update(`${metaType}:${name}`).digest("base64url");
1628
+ const hash = (0, import_node_crypto3.createHash)("sha256").update(`${metaType}:${name}`).digest("base64url");
1027
1629
  return hash.slice(0, 21);
1028
1630
  }
1029
- async function createRegistryFromGraph(reader) {
1631
+ async function createRegistryFromGraph(reader, executor) {
1030
1632
  const [nodeTypes, edgeTypes] = await Promise.all([
1031
1633
  reader.findNodes({ aType: META_NODE_TYPE }),
1032
1634
  reader.findNodes({ aType: META_EDGE_TYPE })
1033
1635
  ]);
1034
1636
  const entries = [...BOOTSTRAP_ENTRIES];
1637
+ const prevalidations = [];
1638
+ for (const record of nodeTypes) {
1639
+ const data = record.data;
1640
+ if (data.migrations) {
1641
+ for (const m of data.migrations) {
1642
+ prevalidations.push(precompileSource(m.up, executor));
1643
+ }
1644
+ }
1645
+ }
1646
+ for (const record of edgeTypes) {
1647
+ const data = record.data;
1648
+ if (data.migrations) {
1649
+ for (const m of data.migrations) {
1650
+ prevalidations.push(precompileSource(m.up, executor));
1651
+ }
1652
+ }
1653
+ }
1654
+ await Promise.all(prevalidations);
1035
1655
  for (const record of nodeTypes) {
1036
1656
  const data = record.data;
1037
1657
  entries.push({
@@ -1042,13 +1662,16 @@ async function createRegistryFromGraph(reader) {
1042
1662
  description: data.description,
1043
1663
  titleField: data.titleField,
1044
1664
  subtitleField: data.subtitleField,
1045
- allowedIn: data.allowedIn
1665
+ allowedIn: data.allowedIn,
1666
+ migrations: data.migrations ? compileMigrations(data.migrations, executor) : void 0,
1667
+ migrationWriteBack: data.migrationWriteBack
1046
1668
  });
1047
1669
  }
1048
1670
  for (const record of edgeTypes) {
1049
1671
  const data = record.data;
1050
1672
  const fromTypes = Array.isArray(data.from) ? data.from : [data.from];
1051
1673
  const toTypes = Array.isArray(data.to) ? data.to : [data.to];
1674
+ const compiledMigrations = data.migrations ? compileMigrations(data.migrations, executor) : void 0;
1052
1675
  for (const aType of fromTypes) {
1053
1676
  for (const bType of toTypes) {
1054
1677
  entries.push({
@@ -1061,7 +1684,9 @@ async function createRegistryFromGraph(reader) {
1061
1684
  titleField: data.titleField,
1062
1685
  subtitleField: data.subtitleField,
1063
1686
  allowedIn: data.allowedIn,
1064
- targetGraph: data.targetGraph
1687
+ targetGraph: data.targetGraph,
1688
+ migrations: compiledMigrations,
1689
+ migrationWriteBack: data.migrationWriteBack
1065
1690
  });
1066
1691
  }
1067
1692
  }
@@ -1077,14 +1702,14 @@ var GraphClientImpl = class _GraphClientImpl {
1077
1702
  this.db = db;
1078
1703
  this.scopePath = scopePath;
1079
1704
  this.adapter = createFirestoreAdapter(db, collectionPath);
1080
- if (options?.registry && options?.registryMode) {
1081
- throw new DynamicRegistryError(
1082
- 'Cannot provide both "registry" and "registryMode". Use "registry" for static mode or "registryMode" for dynamic mode.'
1083
- );
1084
- }
1705
+ this.globalWriteBack = options?.migrationWriteBack ?? "off";
1706
+ this.migrationSandbox = options?.migrationSandbox;
1085
1707
  if (options?.registryMode) {
1086
1708
  this.dynamicConfig = options.registryMode;
1087
1709
  this.bootstrapRegistry = createBootstrapRegistry();
1710
+ if (options.registry) {
1711
+ this.staticRegistry = options.registry;
1712
+ }
1088
1713
  const metaCollectionPath = options.registryMode.collection;
1089
1714
  if (metaCollectionPath && metaCollectionPath !== collectionPath) {
1090
1715
  this.metaAdapter = createFirestoreAdapter(db, metaCollectionPath);
@@ -1130,24 +1755,29 @@ var GraphClientImpl = class _GraphClientImpl {
1130
1755
  metaPipelineAdapter;
1131
1756
  // Subgraph scope tracking
1132
1757
  scopePath;
1758
+ // Migration settings
1759
+ globalWriteBack;
1760
+ migrationSandbox;
1133
1761
  // ---------------------------------------------------------------------------
1134
1762
  // Registry routing
1135
1763
  // ---------------------------------------------------------------------------
1136
1764
  /**
1137
1765
  * Get the appropriate registry for validating a write to the given type.
1138
1766
  *
1139
- * - Static mode: returns staticRegistry (or undefined if none set)
1140
- * - Dynamic mode:
1767
+ * - Static-only mode: returns staticRegistry (or undefined if none set)
1768
+ * - Dynamic mode (pure or merged):
1141
1769
  * - Meta-types (nodeType, edgeType): validated against bootstrapRegistry
1142
1770
  * - Domain types: validated against dynamicRegistry (falls back to
1143
1771
  * bootstrapRegistry which rejects unknown types)
1772
+ * - Merged mode: dynamicRegistry is a merged wrapper (static + dynamic
1773
+ * extension), so static entries take priority automatically.
1144
1774
  */
1145
1775
  getRegistryForType(aType) {
1146
1776
  if (!this.dynamicConfig) return this.staticRegistry;
1147
1777
  if (aType === META_NODE_TYPE || aType === META_EDGE_TYPE) {
1148
1778
  return this.bootstrapRegistry;
1149
1779
  }
1150
- return this.dynamicRegistry ?? this.bootstrapRegistry;
1780
+ return this.dynamicRegistry ?? this.staticRegistry ?? this.bootstrapRegistry;
1151
1781
  }
1152
1782
  /**
1153
1783
  * Get the Firestore adapter for writing the given type.
@@ -1161,13 +1791,13 @@ var GraphClientImpl = class _GraphClientImpl {
1161
1791
  }
1162
1792
  /**
1163
1793
  * Get the combined registry for transaction/batch context.
1164
- * In static mode, returns staticRegistry.
1794
+ * In static-only mode, returns staticRegistry.
1165
1795
  * In dynamic mode, returns dynamicRegistry (which includes bootstrap entries)
1166
- * or bootstrapRegistry if not yet reloaded.
1796
+ * or falls back to staticRegistry (merged mode) or bootstrapRegistry.
1167
1797
  */
1168
1798
  getCombinedRegistry() {
1169
1799
  if (!this.dynamicConfig) return this.staticRegistry;
1170
- return this.dynamicRegistry ?? this.bootstrapRegistry;
1800
+ return this.dynamicRegistry ?? this.staticRegistry ?? this.bootstrapRegistry;
1171
1801
  }
1172
1802
  // ---------------------------------------------------------------------------
1173
1803
  // Query dispatch
@@ -1197,37 +1827,114 @@ var GraphClientImpl = class _GraphClientImpl {
1197
1827
  console.warn(`[firegraph] Query safety warning: ${result.reason}`);
1198
1828
  }
1199
1829
  // ---------------------------------------------------------------------------
1830
+ // Migration helpers
1831
+ // ---------------------------------------------------------------------------
1832
+ /**
1833
+ * Apply migration to a single record. Returns the (possibly migrated)
1834
+ * record and triggers write-back if applicable.
1835
+ */
1836
+ async applyMigration(record, docId) {
1837
+ const registry = this.getCombinedRegistry();
1838
+ if (!registry) return record;
1839
+ const result = await migrateRecord(record, registry, this.globalWriteBack);
1840
+ if (result.migrated) {
1841
+ this.handleWriteBack(result, docId);
1842
+ }
1843
+ return result.record;
1844
+ }
1845
+ /**
1846
+ * Apply migrations to an array of records. Returns all records
1847
+ * (migrated where applicable) and triggers write-backs.
1848
+ */
1849
+ async applyMigrations(records) {
1850
+ const registry = this.getCombinedRegistry();
1851
+ if (!registry || records.length === 0) return records;
1852
+ const results = await migrateRecords(records, registry, this.globalWriteBack);
1853
+ for (const result of results) {
1854
+ if (result.migrated) {
1855
+ const docId = result.record.axbType === NODE_RELATION ? computeNodeDocId(result.record.aUid) : computeEdgeDocId(result.record.aUid, result.record.axbType, result.record.bUid);
1856
+ this.handleWriteBack(result, docId);
1857
+ }
1858
+ }
1859
+ return results.map((r) => r.record);
1860
+ }
1861
+ /**
1862
+ * Handle write-back for a migrated record based on the resolved mode.
1863
+ *
1864
+ * Both `'eager'` and `'background'` are fire-and-forget (not awaited by
1865
+ * the caller). The difference is logging level on failure:
1866
+ * - `eager`: logs an error via `console.error`
1867
+ * - `background`: logs a warning via `console.warn`
1868
+ *
1869
+ * For truly synchronous write-back guarantees, use transactions — the
1870
+ * `GraphTransactionImpl` performs write-back inline within the transaction.
1871
+ */
1872
+ handleWriteBack(result, docId) {
1873
+ if (result.writeBack === "off") return;
1874
+ const doWriteBack = async () => {
1875
+ try {
1876
+ const update = {
1877
+ data: deserializeFirestoreTypes(result.record.data, this.db),
1878
+ updatedAt: import_firestore5.FieldValue.serverTimestamp()
1879
+ };
1880
+ if (result.record.v !== void 0) {
1881
+ update.v = result.record.v;
1882
+ }
1883
+ await this.adapter.updateDoc(docId, update);
1884
+ } catch (err) {
1885
+ const msg = `[firegraph] Migration write-back failed for ${docId}: ${err.message}`;
1886
+ if (result.writeBack === "eager") {
1887
+ console.error(msg);
1888
+ } else {
1889
+ console.warn(msg);
1890
+ }
1891
+ }
1892
+ };
1893
+ void doWriteBack();
1894
+ }
1895
+ // ---------------------------------------------------------------------------
1200
1896
  // GraphReader
1201
1897
  // ---------------------------------------------------------------------------
1202
1898
  async getNode(uid) {
1203
1899
  const docId = computeNodeDocId(uid);
1204
- return this.adapter.getDoc(docId);
1900
+ const record = await this.adapter.getDoc(docId);
1901
+ if (!record) return null;
1902
+ return this.applyMigration(record, docId);
1205
1903
  }
1206
1904
  async getEdge(aUid, axbType, bUid) {
1207
1905
  const docId = computeEdgeDocId(aUid, axbType, bUid);
1208
- return this.adapter.getDoc(docId);
1906
+ const record = await this.adapter.getDoc(docId);
1907
+ if (!record) return null;
1908
+ return this.applyMigration(record, docId);
1209
1909
  }
1210
1910
  async edgeExists(aUid, axbType, bUid) {
1211
- const record = await this.getEdge(aUid, axbType, bUid);
1911
+ const docId = computeEdgeDocId(aUid, axbType, bUid);
1912
+ const record = await this.adapter.getDoc(docId);
1212
1913
  return record !== null;
1213
1914
  }
1214
1915
  async findEdges(params) {
1215
1916
  const plan = buildEdgeQueryPlan(params);
1917
+ let records;
1216
1918
  if (plan.strategy === "get") {
1217
1919
  const record = await this.adapter.getDoc(plan.docId);
1218
- return record ? [record] : [];
1920
+ records = record ? [record] : [];
1921
+ } else {
1922
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1923
+ records = await this.executeQuery(plan.filters, plan.options);
1219
1924
  }
1220
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1221
- return this.executeQuery(plan.filters, plan.options);
1925
+ return this.applyMigrations(records);
1222
1926
  }
1223
1927
  async findNodes(params) {
1224
1928
  const plan = buildNodeQueryPlan(params);
1929
+ let records;
1225
1930
  if (plan.strategy === "get") {
1226
1931
  const record = await this.adapter.getDoc(plan.docId);
1227
- return record ? [record] : [];
1932
+ records = record ? [record] : [];
1933
+ } else {
1934
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1935
+ records = await this.executeQuery(plan.filters, plan.options);
1228
1936
  }
1229
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1230
- return this.executeQuery(plan.filters, plan.options);
1937
+ return this.applyMigrations(records);
1231
1938
  }
1232
1939
  // ---------------------------------------------------------------------------
1233
1940
  // GraphWriter
@@ -1240,6 +1947,12 @@ var GraphClientImpl = class _GraphClientImpl {
1240
1947
  const adapter = this.getAdapterForType(aType);
1241
1948
  const docId = computeNodeDocId(uid);
1242
1949
  const record = buildNodeRecord(aType, uid, data);
1950
+ if (registry) {
1951
+ const entry = registry.lookup(aType, NODE_RELATION, aType);
1952
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
1953
+ record.v = entry.schemaVersion;
1954
+ }
1955
+ }
1243
1956
  await adapter.setDoc(docId, record);
1244
1957
  }
1245
1958
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
@@ -1250,13 +1963,19 @@ var GraphClientImpl = class _GraphClientImpl {
1250
1963
  const adapter = this.getAdapterForType(aType);
1251
1964
  const docId = computeEdgeDocId(aUid, axbType, bUid);
1252
1965
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
1966
+ if (registry) {
1967
+ const entry = registry.lookup(aType, axbType, bType);
1968
+ if (entry?.schemaVersion && entry.schemaVersion > 0) {
1969
+ record.v = entry.schemaVersion;
1970
+ }
1971
+ }
1253
1972
  await adapter.setDoc(docId, record);
1254
1973
  }
1255
1974
  async updateNode(uid, data) {
1256
1975
  const docId = computeNodeDocId(uid);
1257
1976
  await this.adapter.updateDoc(docId, {
1258
1977
  ...data,
1259
- updatedAt: import_firestore4.FieldValue.serverTimestamp()
1978
+ updatedAt: import_firestore5.FieldValue.serverTimestamp()
1260
1979
  });
1261
1980
  }
1262
1981
  async removeNode(uid) {
@@ -1277,7 +1996,7 @@ var GraphClientImpl = class _GraphClientImpl {
1277
1996
  this.adapter.collectionPath,
1278
1997
  firestoreTx
1279
1998
  );
1280
- const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
1999
+ const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath, this.globalWriteBack, this.db);
1281
2000
  return fn(graphTx);
1282
2001
  });
1283
2002
  }
@@ -1309,7 +2028,9 @@ var GraphClientImpl = class _GraphClientImpl {
1309
2028
  {
1310
2029
  registry: this.getCombinedRegistry(),
1311
2030
  queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
1312
- scanProtection: this.scanProtection
2031
+ scanProtection: this.scanProtection,
2032
+ migrationWriteBack: this.globalWriteBack,
2033
+ migrationSandbox: this.migrationSandbox
1313
2034
  },
1314
2035
  newScopePath
1315
2036
  );
@@ -1339,7 +2060,8 @@ var GraphClientImpl = class _GraphClientImpl {
1339
2060
  q = q.limit(plan.options.limit);
1340
2061
  }
1341
2062
  const snap = await q.get();
1342
- return snap.docs.map((doc) => doc.data());
2063
+ const records = snap.docs.map((doc) => doc.data());
2064
+ return this.applyMigrations(records);
1343
2065
  }
1344
2066
  // ---------------------------------------------------------------------------
1345
2067
  // Bulk operations
@@ -1364,6 +2086,11 @@ var GraphClientImpl = class _GraphClientImpl {
1364
2086
  `Cannot define type "${name}": this name is reserved for the meta-registry.`
1365
2087
  );
1366
2088
  }
2089
+ if (this.staticRegistry?.lookup(name, NODE_RELATION, name)) {
2090
+ throw new DynamicRegistryError(
2091
+ `Cannot define node type "${name}": already defined in the static registry.`
2092
+ );
2093
+ }
1367
2094
  const uid = generateDeterministicUid(META_NODE_TYPE, name);
1368
2095
  const data = { name, jsonSchema };
1369
2096
  if (description !== void 0) data.description = description;
@@ -1372,6 +2099,10 @@ var GraphClientImpl = class _GraphClientImpl {
1372
2099
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1373
2100
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1374
2101
  if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
2102
+ if (options?.migrationWriteBack !== void 0) data.migrationWriteBack = options.migrationWriteBack;
2103
+ if (options?.migrations !== void 0) {
2104
+ data.migrations = await this.serializeMigrations(options.migrations);
2105
+ }
1375
2106
  await this.putNode(META_NODE_TYPE, uid, data);
1376
2107
  }
1377
2108
  async defineEdgeType(name, topology, jsonSchema, description, options) {
@@ -1385,6 +2116,19 @@ var GraphClientImpl = class _GraphClientImpl {
1385
2116
  `Cannot define type "${name}": this name is reserved for the meta-registry.`
1386
2117
  );
1387
2118
  }
2119
+ if (this.staticRegistry) {
2120
+ const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
2121
+ const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
2122
+ for (const aType of fromTypes) {
2123
+ for (const bType of toTypes) {
2124
+ if (this.staticRegistry.lookup(aType, name, bType)) {
2125
+ throw new DynamicRegistryError(
2126
+ `Cannot define edge type "${name}" for (${aType}) -> (${bType}): already defined in the static registry.`
2127
+ );
2128
+ }
2129
+ }
2130
+ }
2131
+ }
1388
2132
  const uid = generateDeterministicUid(META_EDGE_TYPE, name);
1389
2133
  const data = {
1390
2134
  name,
@@ -1400,6 +2144,10 @@ var GraphClientImpl = class _GraphClientImpl {
1400
2144
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1401
2145
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1402
2146
  if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
2147
+ if (options?.migrationWriteBack !== void 0) data.migrationWriteBack = options.migrationWriteBack;
2148
+ if (options?.migrations !== void 0) {
2149
+ data.migrations = await this.serializeMigrations(options.migrations);
2150
+ }
1403
2151
  await this.putNode(META_EDGE_TYPE, uid, data);
1404
2152
  }
1405
2153
  async reloadRegistry() {
@@ -1409,7 +2157,27 @@ var GraphClientImpl = class _GraphClientImpl {
1409
2157
  );
1410
2158
  }
1411
2159
  const reader = this.createMetaReader();
1412
- this.dynamicRegistry = await createRegistryFromGraph(reader);
2160
+ const dynamicOnly = await createRegistryFromGraph(reader, this.migrationSandbox);
2161
+ if (this.staticRegistry) {
2162
+ this.dynamicRegistry = createMergedRegistry(this.staticRegistry, dynamicOnly);
2163
+ } else {
2164
+ this.dynamicRegistry = dynamicOnly;
2165
+ }
2166
+ }
2167
+ /**
2168
+ * Serialize migration steps for storage in Firestore.
2169
+ * Function objects are converted via `.toString()`; strings are stored as-is.
2170
+ * Each migration is validated at define-time by pre-compiling in the sandbox.
2171
+ */
2172
+ async serializeMigrations(migrations) {
2173
+ const result = migrations.map((m) => {
2174
+ const source = typeof m.up === "function" ? m.up.toString() : m.up;
2175
+ return { fromVersion: m.fromVersion, toVersion: m.toVersion, up: source };
2176
+ });
2177
+ await Promise.all(
2178
+ result.map((m) => precompileSource(m.up, this.migrationSandbox))
2179
+ );
2180
+ return result;
1413
2181
  }
1414
2182
  /**
1415
2183
  * Create a GraphReader for the meta-collection.
@@ -1777,7 +2545,7 @@ function resolveView(resolverConfig, availableViewNames, context) {
1777
2545
  var import_node_fs = require("fs");
1778
2546
  var import_node_module = require("module");
1779
2547
  var import_node_path = require("path");
1780
- var import_meta = {};
2548
+ var import_meta2 = {};
1781
2549
  var DiscoveryError = class extends FiregraphError {
1782
2550
  constructor(message) {
1783
2551
  super(message, "DISCOVERY_ERROR");
@@ -1816,7 +2584,7 @@ function loadSchema(dir, entityLabel) {
1816
2584
  var _jiti;
1817
2585
  function getJiti() {
1818
2586
  if (!_jiti) {
1819
- const base = typeof __filename !== "undefined" ? __filename : import_meta.url;
2587
+ const base = typeof __filename !== "undefined" ? __filename : import_meta2.url;
1820
2588
  const esmRequire = (0, import_node_module.createRequire)(base);
1821
2589
  const { createJiti } = esmRequire("jiti");
1822
2590
  _jiti = createJiti(base, { interopDefault: true });
@@ -1849,11 +2617,39 @@ function findViewsFile(dir) {
1849
2617
  }
1850
2618
  return void 0;
1851
2619
  }
2620
+ var MIGRATION_EXTENSIONS = [".ts", ".js", ".mts", ".mjs"];
2621
+ function findMigrationsFile(dir) {
2622
+ for (const ext of MIGRATION_EXTENSIONS) {
2623
+ const candidate = (0, import_node_path.join)(dir, `migrations${ext}`);
2624
+ if ((0, import_node_fs.existsSync)(candidate)) return candidate;
2625
+ }
2626
+ return void 0;
2627
+ }
2628
+ function loadMigrations(filePath, entityLabel) {
2629
+ try {
2630
+ const jiti = getJiti();
2631
+ const mod = jiti(filePath);
2632
+ const migrations = mod && typeof mod === "object" && "default" in mod ? mod.default : mod;
2633
+ if (!Array.isArray(migrations)) {
2634
+ throw new DiscoveryError(
2635
+ `Migrations file ${filePath} for ${entityLabel} must default-export an array of MigrationStep.`
2636
+ );
2637
+ }
2638
+ return migrations;
2639
+ } catch (err) {
2640
+ if (err instanceof DiscoveryError) throw err;
2641
+ throw new DiscoveryError(
2642
+ `Failed to load migrations ${filePath} for ${entityLabel}: ${err.message}`
2643
+ );
2644
+ }
2645
+ }
1852
2646
  function loadNodeEntity(dir, name) {
1853
2647
  const schema = loadSchema(dir, `node type "${name}"`);
1854
2648
  const meta = readJsonIfExists((0, import_node_path.join)(dir, "meta.json"));
1855
2649
  const sampleData = readJsonIfExists((0, import_node_path.join)(dir, "sample.json"));
1856
2650
  const viewsPath = findViewsFile(dir);
2651
+ const migrationsPath = findMigrationsFile(dir);
2652
+ const migrations = migrationsPath ? loadMigrations(migrationsPath, `node type "${name}"`) : void 0;
1857
2653
  return {
1858
2654
  kind: "node",
1859
2655
  name,
@@ -1864,7 +2660,9 @@ function loadNodeEntity(dir, name) {
1864
2660
  viewDefaults: meta?.viewDefaults,
1865
2661
  viewsPath,
1866
2662
  sampleData,
1867
- allowedIn: meta?.allowedIn
2663
+ allowedIn: meta?.allowedIn,
2664
+ migrations,
2665
+ migrationWriteBack: meta?.migrationWriteBack
1868
2666
  };
1869
2667
  }
1870
2668
  function loadEdgeEntity(dir, name) {
@@ -1889,6 +2687,8 @@ function loadEdgeEntity(dir, name) {
1889
2687
  const meta = readJsonIfExists((0, import_node_path.join)(dir, "meta.json"));
1890
2688
  const sampleData = readJsonIfExists((0, import_node_path.join)(dir, "sample.json"));
1891
2689
  const viewsPath = findViewsFile(dir);
2690
+ const migrationsPath = findMigrationsFile(dir);
2691
+ const migrations = migrationsPath ? loadMigrations(migrationsPath, `edge type "${name}"`) : void 0;
1892
2692
  return {
1893
2693
  kind: "edge",
1894
2694
  name,
@@ -1901,7 +2701,9 @@ function loadEdgeEntity(dir, name) {
1901
2701
  viewsPath,
1902
2702
  sampleData,
1903
2703
  allowedIn: meta?.allowedIn,
1904
- targetGraph: topology.targetGraph ?? meta?.targetGraph
2704
+ targetGraph: topology.targetGraph ?? meta?.targetGraph,
2705
+ migrations,
2706
+ migrationWriteBack: meta?.migrationWriteBack
1905
2707
  };
1906
2708
  }
1907
2709
  function getSubdirectories(dir) {
@@ -2412,6 +3214,7 @@ var QueryClient = class {
2412
3214
  InvalidQueryError,
2413
3215
  META_EDGE_TYPE,
2414
3216
  META_NODE_TYPE,
3217
+ MigrationError,
2415
3218
  NODE_TYPE_SCHEMA,
2416
3219
  NodeNotFoundError,
2417
3220
  QueryClient,
@@ -2419,33 +3222,47 @@ var QueryClient = class {
2419
3222
  QuerySafetyError,
2420
3223
  RegistryScopeError,
2421
3224
  RegistryViolationError,
3225
+ SERIALIZATION_TAG,
2422
3226
  TraversalError,
2423
3227
  ValidationError,
2424
3228
  analyzeQuerySafety,
3229
+ applyMigrationChain,
2425
3230
  buildEdgeQueryPlan,
2426
3231
  buildEdgeRecord,
2427
3232
  buildNodeQueryPlan,
2428
3233
  buildNodeRecord,
3234
+ compileMigrationFn,
3235
+ compileMigrations,
2429
3236
  compileSchema,
2430
3237
  computeEdgeDocId,
2431
3238
  computeNodeDocId,
2432
3239
  createBootstrapRegistry,
2433
3240
  createGraphClient,
3241
+ createMergedRegistry,
2434
3242
  createRegistry,
2435
3243
  createRegistryFromGraph,
2436
3244
  createTraversal,
3245
+ defaultExecutor,
2437
3246
  defineConfig,
2438
3247
  defineViews,
3248
+ deserializeFirestoreTypes,
3249
+ destroySandboxWorker,
2439
3250
  discoverEntities,
2440
3251
  generateDeterministicUid,
2441
3252
  generateId,
2442
3253
  generateIndexConfig,
2443
3254
  generateTypes,
2444
3255
  isAncestorUid,
3256
+ isTaggedValue,
2445
3257
  jsonSchemaToFieldMeta,
2446
3258
  matchScope,
2447
3259
  matchScopeAny,
3260
+ migrateRecord,
3261
+ migrateRecords,
3262
+ precompileSource,
2448
3263
  resolveAncestorCollection,
2449
- resolveView
3264
+ resolveView,
3265
+ serializeFirestoreTypes,
3266
+ validateMigrationChain
2450
3267
  });
2451
3268
  //# sourceMappingURL=index.cjs.map