@typicalday/firegraph 0.4.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/README.md +114 -1
- package/dist/codegen/index.d.cts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/editor/server/index.mjs +755 -44
- package/dist/{index-DR3jF5_b.d.cts → index-B9aodfYD.d.cts} +101 -1
- package/dist/{index-DR3jF5_b.d.ts → index-B9aodfYD.d.ts} +101 -1
- package/dist/index.cjs +794 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +164 -4
- package/dist/index.d.ts +164 -4
- package/dist/index.js +776 -41
- package/dist/index.js.map +1 -1
- package/package.json +27 -24
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
923
|
+
import { createHash as createHash3 } from "crypto";
|
|
646
924
|
|
|
647
925
|
// src/json-schema.ts
|
|
648
926
|
import Ajv from "ajv";
|
|
@@ -791,6 +1069,13 @@ function createRegistry(input) {
|
|
|
791
1069
|
`Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType}) has invalid targetGraph "${entry.targetGraph}" \u2014 must be a single segment (no "/")`
|
|
792
1070
|
);
|
|
793
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
|
+
}
|
|
794
1079
|
const key = tripleKey(entry.aType, entry.axbType, entry.bType);
|
|
795
1080
|
const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
|
|
796
1081
|
map.set(key, { entry, validate: validator });
|
|
@@ -892,7 +1177,9 @@ function discoveryToEntries(discovery) {
|
|
|
892
1177
|
description: entity.description,
|
|
893
1178
|
titleField: entity.titleField,
|
|
894
1179
|
subtitleField: entity.subtitleField,
|
|
895
|
-
allowedIn: entity.allowedIn
|
|
1180
|
+
allowedIn: entity.allowedIn,
|
|
1181
|
+
migrations: entity.migrations,
|
|
1182
|
+
migrationWriteBack: entity.migrationWriteBack
|
|
896
1183
|
});
|
|
897
1184
|
}
|
|
898
1185
|
for (const [axbType, entity] of discovery.edges) {
|
|
@@ -918,7 +1205,9 @@ function discoveryToEntries(discovery) {
|
|
|
918
1205
|
titleField: entity.titleField,
|
|
919
1206
|
subtitleField: entity.subtitleField,
|
|
920
1207
|
allowedIn: entity.allowedIn,
|
|
921
|
-
targetGraph: resolvedTargetGraph
|
|
1208
|
+
targetGraph: resolvedTargetGraph,
|
|
1209
|
+
migrations: entity.migrations,
|
|
1210
|
+
migrationWriteBack: entity.migrationWriteBack
|
|
922
1211
|
});
|
|
923
1212
|
}
|
|
924
1213
|
}
|
|
@@ -926,9 +1215,258 @@ function discoveryToEntries(discovery) {
|
|
|
926
1215
|
return entries;
|
|
927
1216
|
}
|
|
928
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
|
+
|
|
929
1457
|
// src/dynamic-registry.ts
|
|
930
1458
|
var META_NODE_TYPE = "nodeType";
|
|
931
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
|
+
};
|
|
932
1470
|
var NODE_TYPE_SCHEMA = {
|
|
933
1471
|
type: "object",
|
|
934
1472
|
required: ["name", "jsonSchema"],
|
|
@@ -940,7 +1478,10 @@ var NODE_TYPE_SCHEMA = {
|
|
|
940
1478
|
subtitleField: { type: "string" },
|
|
941
1479
|
viewTemplate: { type: "string" },
|
|
942
1480
|
viewCss: { type: "string" },
|
|
943
|
-
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"] }
|
|
944
1485
|
},
|
|
945
1486
|
additionalProperties: false
|
|
946
1487
|
};
|
|
@@ -969,7 +1510,10 @@ var EDGE_TYPE_SCHEMA = {
|
|
|
969
1510
|
viewTemplate: { type: "string" },
|
|
970
1511
|
viewCss: { type: "string" },
|
|
971
1512
|
allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
|
|
972
|
-
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"] }
|
|
973
1517
|
},
|
|
974
1518
|
additionalProperties: false
|
|
975
1519
|
};
|
|
@@ -993,15 +1537,33 @@ function createBootstrapRegistry() {
|
|
|
993
1537
|
return createRegistry([...BOOTSTRAP_ENTRIES]);
|
|
994
1538
|
}
|
|
995
1539
|
function generateDeterministicUid(metaType, name) {
|
|
996
|
-
const hash =
|
|
1540
|
+
const hash = createHash3("sha256").update(`${metaType}:${name}`).digest("base64url");
|
|
997
1541
|
return hash.slice(0, 21);
|
|
998
1542
|
}
|
|
999
|
-
async function createRegistryFromGraph(reader) {
|
|
1543
|
+
async function createRegistryFromGraph(reader, executor) {
|
|
1000
1544
|
const [nodeTypes, edgeTypes] = await Promise.all([
|
|
1001
1545
|
reader.findNodes({ aType: META_NODE_TYPE }),
|
|
1002
1546
|
reader.findNodes({ aType: META_EDGE_TYPE })
|
|
1003
1547
|
]);
|
|
1004
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);
|
|
1005
1567
|
for (const record of nodeTypes) {
|
|
1006
1568
|
const data = record.data;
|
|
1007
1569
|
entries.push({
|
|
@@ -1012,13 +1574,16 @@ async function createRegistryFromGraph(reader) {
|
|
|
1012
1574
|
description: data.description,
|
|
1013
1575
|
titleField: data.titleField,
|
|
1014
1576
|
subtitleField: data.subtitleField,
|
|
1015
|
-
allowedIn: data.allowedIn
|
|
1577
|
+
allowedIn: data.allowedIn,
|
|
1578
|
+
migrations: data.migrations ? compileMigrations(data.migrations, executor) : void 0,
|
|
1579
|
+
migrationWriteBack: data.migrationWriteBack
|
|
1016
1580
|
});
|
|
1017
1581
|
}
|
|
1018
1582
|
for (const record of edgeTypes) {
|
|
1019
1583
|
const data = record.data;
|
|
1020
1584
|
const fromTypes = Array.isArray(data.from) ? data.from : [data.from];
|
|
1021
1585
|
const toTypes = Array.isArray(data.to) ? data.to : [data.to];
|
|
1586
|
+
const compiledMigrations = data.migrations ? compileMigrations(data.migrations, executor) : void 0;
|
|
1022
1587
|
for (const aType of fromTypes) {
|
|
1023
1588
|
for (const bType of toTypes) {
|
|
1024
1589
|
entries.push({
|
|
@@ -1031,7 +1596,9 @@ async function createRegistryFromGraph(reader) {
|
|
|
1031
1596
|
titleField: data.titleField,
|
|
1032
1597
|
subtitleField: data.subtitleField,
|
|
1033
1598
|
allowedIn: data.allowedIn,
|
|
1034
|
-
targetGraph: data.targetGraph
|
|
1599
|
+
targetGraph: data.targetGraph,
|
|
1600
|
+
migrations: compiledMigrations,
|
|
1601
|
+
migrationWriteBack: data.migrationWriteBack
|
|
1035
1602
|
});
|
|
1036
1603
|
}
|
|
1037
1604
|
}
|
|
@@ -1047,6 +1614,8 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1047
1614
|
this.db = db;
|
|
1048
1615
|
this.scopePath = scopePath;
|
|
1049
1616
|
this.adapter = createFirestoreAdapter(db, collectionPath);
|
|
1617
|
+
this.globalWriteBack = options?.migrationWriteBack ?? "off";
|
|
1618
|
+
this.migrationSandbox = options?.migrationSandbox;
|
|
1050
1619
|
if (options?.registryMode) {
|
|
1051
1620
|
this.dynamicConfig = options.registryMode;
|
|
1052
1621
|
this.bootstrapRegistry = createBootstrapRegistry();
|
|
@@ -1098,6 +1667,9 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1098
1667
|
metaPipelineAdapter;
|
|
1099
1668
|
// Subgraph scope tracking
|
|
1100
1669
|
scopePath;
|
|
1670
|
+
// Migration settings
|
|
1671
|
+
globalWriteBack;
|
|
1672
|
+
migrationSandbox;
|
|
1101
1673
|
// ---------------------------------------------------------------------------
|
|
1102
1674
|
// Registry routing
|
|
1103
1675
|
// ---------------------------------------------------------------------------
|
|
@@ -1167,37 +1739,114 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1167
1739
|
console.warn(`[firegraph] Query safety warning: ${result.reason}`);
|
|
1168
1740
|
}
|
|
1169
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
|
+
// ---------------------------------------------------------------------------
|
|
1170
1808
|
// GraphReader
|
|
1171
1809
|
// ---------------------------------------------------------------------------
|
|
1172
1810
|
async getNode(uid) {
|
|
1173
1811
|
const docId = computeNodeDocId(uid);
|
|
1174
|
-
|
|
1812
|
+
const record = await this.adapter.getDoc(docId);
|
|
1813
|
+
if (!record) return null;
|
|
1814
|
+
return this.applyMigration(record, docId);
|
|
1175
1815
|
}
|
|
1176
1816
|
async getEdge(aUid, axbType, bUid) {
|
|
1177
1817
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
1178
|
-
|
|
1818
|
+
const record = await this.adapter.getDoc(docId);
|
|
1819
|
+
if (!record) return null;
|
|
1820
|
+
return this.applyMigration(record, docId);
|
|
1179
1821
|
}
|
|
1180
1822
|
async edgeExists(aUid, axbType, bUid) {
|
|
1181
|
-
const
|
|
1823
|
+
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
1824
|
+
const record = await this.adapter.getDoc(docId);
|
|
1182
1825
|
return record !== null;
|
|
1183
1826
|
}
|
|
1184
1827
|
async findEdges(params) {
|
|
1185
1828
|
const plan = buildEdgeQueryPlan(params);
|
|
1829
|
+
let records;
|
|
1186
1830
|
if (plan.strategy === "get") {
|
|
1187
1831
|
const record = await this.adapter.getDoc(plan.docId);
|
|
1188
|
-
|
|
1832
|
+
records = record ? [record] : [];
|
|
1833
|
+
} else {
|
|
1834
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
1835
|
+
records = await this.executeQuery(plan.filters, plan.options);
|
|
1189
1836
|
}
|
|
1190
|
-
this.
|
|
1191
|
-
return this.executeQuery(plan.filters, plan.options);
|
|
1837
|
+
return this.applyMigrations(records);
|
|
1192
1838
|
}
|
|
1193
1839
|
async findNodes(params) {
|
|
1194
1840
|
const plan = buildNodeQueryPlan(params);
|
|
1841
|
+
let records;
|
|
1195
1842
|
if (plan.strategy === "get") {
|
|
1196
1843
|
const record = await this.adapter.getDoc(plan.docId);
|
|
1197
|
-
|
|
1844
|
+
records = record ? [record] : [];
|
|
1845
|
+
} else {
|
|
1846
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
1847
|
+
records = await this.executeQuery(plan.filters, plan.options);
|
|
1198
1848
|
}
|
|
1199
|
-
this.
|
|
1200
|
-
return this.executeQuery(plan.filters, plan.options);
|
|
1849
|
+
return this.applyMigrations(records);
|
|
1201
1850
|
}
|
|
1202
1851
|
// ---------------------------------------------------------------------------
|
|
1203
1852
|
// GraphWriter
|
|
@@ -1210,6 +1859,12 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1210
1859
|
const adapter = this.getAdapterForType(aType);
|
|
1211
1860
|
const docId = computeNodeDocId(uid);
|
|
1212
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
|
+
}
|
|
1213
1868
|
await adapter.setDoc(docId, record);
|
|
1214
1869
|
}
|
|
1215
1870
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
@@ -1220,13 +1875,19 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1220
1875
|
const adapter = this.getAdapterForType(aType);
|
|
1221
1876
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
1222
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
|
+
}
|
|
1223
1884
|
await adapter.setDoc(docId, record);
|
|
1224
1885
|
}
|
|
1225
1886
|
async updateNode(uid, data) {
|
|
1226
1887
|
const docId = computeNodeDocId(uid);
|
|
1227
1888
|
await this.adapter.updateDoc(docId, {
|
|
1228
1889
|
...data,
|
|
1229
|
-
updatedAt:
|
|
1890
|
+
updatedAt: FieldValue5.serverTimestamp()
|
|
1230
1891
|
});
|
|
1231
1892
|
}
|
|
1232
1893
|
async removeNode(uid) {
|
|
@@ -1247,7 +1908,7 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1247
1908
|
this.adapter.collectionPath,
|
|
1248
1909
|
firestoreTx
|
|
1249
1910
|
);
|
|
1250
|
-
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);
|
|
1251
1912
|
return fn(graphTx);
|
|
1252
1913
|
});
|
|
1253
1914
|
}
|
|
@@ -1279,7 +1940,9 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1279
1940
|
{
|
|
1280
1941
|
registry: this.getCombinedRegistry(),
|
|
1281
1942
|
queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
|
|
1282
|
-
scanProtection: this.scanProtection
|
|
1943
|
+
scanProtection: this.scanProtection,
|
|
1944
|
+
migrationWriteBack: this.globalWriteBack,
|
|
1945
|
+
migrationSandbox: this.migrationSandbox
|
|
1283
1946
|
},
|
|
1284
1947
|
newScopePath
|
|
1285
1948
|
);
|
|
@@ -1309,7 +1972,8 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1309
1972
|
q = q.limit(plan.options.limit);
|
|
1310
1973
|
}
|
|
1311
1974
|
const snap = await q.get();
|
|
1312
|
-
|
|
1975
|
+
const records = snap.docs.map((doc) => doc.data());
|
|
1976
|
+
return this.applyMigrations(records);
|
|
1313
1977
|
}
|
|
1314
1978
|
// ---------------------------------------------------------------------------
|
|
1315
1979
|
// Bulk operations
|
|
@@ -1347,6 +2011,10 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1347
2011
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
1348
2012
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
1349
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
|
+
}
|
|
1350
2018
|
await this.putNode(META_NODE_TYPE, uid, data);
|
|
1351
2019
|
}
|
|
1352
2020
|
async defineEdgeType(name, topology, jsonSchema, description, options) {
|
|
@@ -1388,6 +2056,10 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1388
2056
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
1389
2057
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
1390
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
|
+
}
|
|
1391
2063
|
await this.putNode(META_EDGE_TYPE, uid, data);
|
|
1392
2064
|
}
|
|
1393
2065
|
async reloadRegistry() {
|
|
@@ -1397,13 +2069,28 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
1397
2069
|
);
|
|
1398
2070
|
}
|
|
1399
2071
|
const reader = this.createMetaReader();
|
|
1400
|
-
const dynamicOnly = await createRegistryFromGraph(reader);
|
|
2072
|
+
const dynamicOnly = await createRegistryFromGraph(reader, this.migrationSandbox);
|
|
1401
2073
|
if (this.staticRegistry) {
|
|
1402
2074
|
this.dynamicRegistry = createMergedRegistry(this.staticRegistry, dynamicOnly);
|
|
1403
2075
|
} else {
|
|
1404
2076
|
this.dynamicRegistry = dynamicOnly;
|
|
1405
2077
|
}
|
|
1406
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;
|
|
2093
|
+
}
|
|
1407
2094
|
/**
|
|
1408
2095
|
* Create a GraphReader for the meta-collection.
|
|
1409
2096
|
* If meta-collection is the same as main collection, returns `this`.
|
|
@@ -1841,11 +2528,39 @@ function findViewsFile(dir) {
|
|
|
1841
2528
|
}
|
|
1842
2529
|
return void 0;
|
|
1843
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
|
+
}
|
|
1844
2557
|
function loadNodeEntity(dir, name) {
|
|
1845
2558
|
const schema = loadSchema(dir, `node type "${name}"`);
|
|
1846
2559
|
const meta = readJsonIfExists(join(dir, "meta.json"));
|
|
1847
2560
|
const sampleData = readJsonIfExists(join(dir, "sample.json"));
|
|
1848
2561
|
const viewsPath = findViewsFile(dir);
|
|
2562
|
+
const migrationsPath = findMigrationsFile(dir);
|
|
2563
|
+
const migrations = migrationsPath ? loadMigrations(migrationsPath, `node type "${name}"`) : void 0;
|
|
1849
2564
|
return {
|
|
1850
2565
|
kind: "node",
|
|
1851
2566
|
name,
|
|
@@ -1856,7 +2571,9 @@ function loadNodeEntity(dir, name) {
|
|
|
1856
2571
|
viewDefaults: meta?.viewDefaults,
|
|
1857
2572
|
viewsPath,
|
|
1858
2573
|
sampleData,
|
|
1859
|
-
allowedIn: meta?.allowedIn
|
|
2574
|
+
allowedIn: meta?.allowedIn,
|
|
2575
|
+
migrations,
|
|
2576
|
+
migrationWriteBack: meta?.migrationWriteBack
|
|
1860
2577
|
};
|
|
1861
2578
|
}
|
|
1862
2579
|
function loadEdgeEntity(dir, name) {
|
|
@@ -1881,6 +2598,8 @@ function loadEdgeEntity(dir, name) {
|
|
|
1881
2598
|
const meta = readJsonIfExists(join(dir, "meta.json"));
|
|
1882
2599
|
const sampleData = readJsonIfExists(join(dir, "sample.json"));
|
|
1883
2600
|
const viewsPath = findViewsFile(dir);
|
|
2601
|
+
const migrationsPath = findMigrationsFile(dir);
|
|
2602
|
+
const migrations = migrationsPath ? loadMigrations(migrationsPath, `edge type "${name}"`) : void 0;
|
|
1884
2603
|
return {
|
|
1885
2604
|
kind: "edge",
|
|
1886
2605
|
name,
|
|
@@ -1893,7 +2612,9 @@ function loadEdgeEntity(dir, name) {
|
|
|
1893
2612
|
viewsPath,
|
|
1894
2613
|
sampleData,
|
|
1895
2614
|
allowedIn: meta?.allowedIn,
|
|
1896
|
-
targetGraph: topology.targetGraph ?? meta?.targetGraph
|
|
2615
|
+
targetGraph: topology.targetGraph ?? meta?.targetGraph,
|
|
2616
|
+
migrations,
|
|
2617
|
+
migrationWriteBack: meta?.migrationWriteBack
|
|
1897
2618
|
};
|
|
1898
2619
|
}
|
|
1899
2620
|
function getSubdirectories(dir) {
|
|
@@ -2084,6 +2805,7 @@ export {
|
|
|
2084
2805
|
InvalidQueryError,
|
|
2085
2806
|
META_EDGE_TYPE,
|
|
2086
2807
|
META_NODE_TYPE,
|
|
2808
|
+
MigrationError,
|
|
2087
2809
|
NODE_TYPE_SCHEMA,
|
|
2088
2810
|
NodeNotFoundError,
|
|
2089
2811
|
QueryClient,
|
|
@@ -2091,13 +2813,17 @@ export {
|
|
|
2091
2813
|
QuerySafetyError,
|
|
2092
2814
|
RegistryScopeError,
|
|
2093
2815
|
RegistryViolationError,
|
|
2816
|
+
SERIALIZATION_TAG,
|
|
2094
2817
|
TraversalError,
|
|
2095
2818
|
ValidationError,
|
|
2096
2819
|
analyzeQuerySafety,
|
|
2820
|
+
applyMigrationChain,
|
|
2097
2821
|
buildEdgeQueryPlan,
|
|
2098
2822
|
buildEdgeRecord,
|
|
2099
2823
|
buildNodeQueryPlan,
|
|
2100
2824
|
buildNodeRecord,
|
|
2825
|
+
compileMigrationFn,
|
|
2826
|
+
compileMigrations,
|
|
2101
2827
|
compileSchema,
|
|
2102
2828
|
computeEdgeDocId,
|
|
2103
2829
|
computeNodeDocId,
|
|
@@ -2107,18 +2833,27 @@ export {
|
|
|
2107
2833
|
createRegistry,
|
|
2108
2834
|
createRegistryFromGraph,
|
|
2109
2835
|
createTraversal,
|
|
2836
|
+
defaultExecutor,
|
|
2110
2837
|
defineConfig,
|
|
2111
2838
|
defineViews,
|
|
2839
|
+
deserializeFirestoreTypes,
|
|
2840
|
+
destroySandboxWorker,
|
|
2112
2841
|
discoverEntities,
|
|
2113
2842
|
generateDeterministicUid,
|
|
2114
2843
|
generateId,
|
|
2115
2844
|
generateIndexConfig,
|
|
2116
2845
|
generateTypes,
|
|
2117
2846
|
isAncestorUid,
|
|
2847
|
+
isTaggedValue,
|
|
2118
2848
|
jsonSchemaToFieldMeta,
|
|
2119
2849
|
matchScope,
|
|
2120
2850
|
matchScopeAny,
|
|
2851
|
+
migrateRecord,
|
|
2852
|
+
migrateRecords,
|
|
2853
|
+
precompileSource,
|
|
2121
2854
|
resolveAncestorCollection,
|
|
2122
|
-
resolveView
|
|
2855
|
+
resolveView,
|
|
2856
|
+
serializeFirestoreTypes,
|
|
2857
|
+
validateMigrationChain
|
|
2123
2858
|
};
|
|
2124
2859
|
//# sourceMappingURL=index.js.map
|