@typicalday/firegraph 0.1.0 → 0.2.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/bin/firegraph.mjs +47 -0
- package/dist/codegen/index.d.cts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/editor/server/index.mjs +267 -38
- package/dist/{index-CG3R68Hu.d.cts → index-wSlVH5Nv.d.cts} +61 -2
- package/dist/{index-CG3R68Hu.d.ts → index-wSlVH5Nv.d.ts} +61 -2
- package/dist/index.cjs +361 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +92 -3
- package/dist/index.d.ts +92 -3
- package/dist/index.js +354 -39
- package/dist/index.js.map +1 -1
- package/package.json +25 -23
package/bin/firegraph.mjs
CHANGED
|
@@ -86,6 +86,45 @@ if (subcommand === 'editor') {
|
|
|
86
86
|
console.error(`Error: ${err.message}`);
|
|
87
87
|
process.exit(1);
|
|
88
88
|
}
|
|
89
|
+
} else if (subcommand === 'indexes') {
|
|
90
|
+
const args = parseArgs(process.argv.slice(3));
|
|
91
|
+
const entitiesDir = args.entities ? path.resolve(args.entities) : null;
|
|
92
|
+
const collection = args.collection || 'graph';
|
|
93
|
+
const outPath = args.out || null;
|
|
94
|
+
|
|
95
|
+
const distIndex = path.join(__dirname, '..', 'dist', 'index.js');
|
|
96
|
+
const { generateIndexConfig, discoverEntities } = await import(distIndex);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
let entities = undefined;
|
|
100
|
+
if (entitiesDir) {
|
|
101
|
+
const { result, warnings } = discoverEntities(entitiesDir);
|
|
102
|
+
for (const w of warnings) {
|
|
103
|
+
console.warn(` warning: ${w.message}`);
|
|
104
|
+
}
|
|
105
|
+
entities = result;
|
|
106
|
+
const nodeCount = result.nodes.size;
|
|
107
|
+
const edgeCount = result.edges.size;
|
|
108
|
+
if (nodeCount > 0 || edgeCount > 0) {
|
|
109
|
+
console.error(`Discovered ${nodeCount} node type(s) + ${edgeCount} edge type(s)`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const config = generateIndexConfig(collection, entities);
|
|
114
|
+
const output = JSON.stringify(config, null, 2) + '\n';
|
|
115
|
+
|
|
116
|
+
if (outPath) {
|
|
117
|
+
const resolved = path.resolve(outPath);
|
|
118
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
119
|
+
fs.writeFileSync(resolved, output, 'utf-8');
|
|
120
|
+
console.log(`Generated ${config.indexes.length} index(es) → ${resolved}`);
|
|
121
|
+
} else {
|
|
122
|
+
process.stdout.write(output);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`Error: ${err.message}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
89
128
|
} else if (subcommand === '--help' || subcommand === '-h' || !subcommand) {
|
|
90
129
|
console.log('');
|
|
91
130
|
console.log(' Usage: firegraph <command> [options]');
|
|
@@ -94,6 +133,7 @@ if (subcommand === 'editor') {
|
|
|
94
133
|
console.log(' editor Launch the Firegraph Editor UI');
|
|
95
134
|
console.log(' query Query the graph via the editor API');
|
|
96
135
|
console.log(' codegen Generate TypeScript types from entity schemas');
|
|
136
|
+
console.log(' indexes Generate recommended Firestore index definitions');
|
|
97
137
|
console.log('');
|
|
98
138
|
console.log(' Editor options:');
|
|
99
139
|
console.log(' --config <path> Path to firegraph.config.ts (default: auto-discover in cwd)');
|
|
@@ -111,6 +151,11 @@ if (subcommand === 'editor') {
|
|
|
111
151
|
console.log(' --entities <path> Path to entities directory (default: ./entities)');
|
|
112
152
|
console.log(' --out <path> Output file path (default: stdout)');
|
|
113
153
|
console.log('');
|
|
154
|
+
console.log(' Indexes options:');
|
|
155
|
+
console.log(' --entities <path> Path to entities directory (adds per-entity data field indexes)');
|
|
156
|
+
console.log(' --collection <name> Firestore collection name (default: graph)');
|
|
157
|
+
console.log(' --out <path> Output file path (default: stdout)');
|
|
158
|
+
console.log('');
|
|
114
159
|
console.log(' Config file:');
|
|
115
160
|
console.log(' Create a firegraph.config.ts in your project root to avoid passing');
|
|
116
161
|
console.log(' flags every time. CLI flags override config file values.');
|
|
@@ -121,6 +166,8 @@ if (subcommand === 'editor') {
|
|
|
121
166
|
console.log(' npx firegraph editor --entities ./entities # per-entity convention');
|
|
122
167
|
console.log(' npx firegraph codegen --entities ./entities # types to stdout');
|
|
123
168
|
console.log(' npx firegraph codegen --entities ./entities --out src/generated/types.ts');
|
|
169
|
+
console.log(' npx firegraph indexes # 4 base indexes to stdout');
|
|
170
|
+
console.log(' npx firegraph indexes --entities ./entities --out firestore.indexes.json');
|
|
124
171
|
console.log('');
|
|
125
172
|
} else {
|
|
126
173
|
console.error(`Unknown command: ${subcommand}`);
|
package/dist/codegen/index.d.cts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { l as CodegenOptions, J as generateTypes } from '../index-wSlVH5Nv.cjs';
|
|
2
2
|
import '@google-cloud/firestore';
|
package/dist/codegen/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { l as CodegenOptions, J as generateTypes } from '../index-wSlVH5Nv.js';
|
|
2
2
|
import '@google-cloud/firestore';
|
|
@@ -29331,6 +29331,16 @@ import { createHash } from "node:crypto";
|
|
|
29331
29331
|
|
|
29332
29332
|
// src/internal/constants.ts
|
|
29333
29333
|
var NODE_RELATION = "is";
|
|
29334
|
+
var DEFAULT_QUERY_LIMIT = 500;
|
|
29335
|
+
var BUILTIN_FIELDS = /* @__PURE__ */ new Set([
|
|
29336
|
+
"aType",
|
|
29337
|
+
"aUid",
|
|
29338
|
+
"axbType",
|
|
29339
|
+
"bType",
|
|
29340
|
+
"bUid",
|
|
29341
|
+
"createdAt",
|
|
29342
|
+
"updatedAt"
|
|
29343
|
+
]);
|
|
29334
29344
|
var SHARD_SEPARATOR = ":";
|
|
29335
29345
|
|
|
29336
29346
|
// src/docid.ts
|
|
@@ -29409,6 +29419,21 @@ var DynamicRegistryError = class extends FiregraphError {
|
|
|
29409
29419
|
this.name = "DynamicRegistryError";
|
|
29410
29420
|
}
|
|
29411
29421
|
};
|
|
29422
|
+
var QuerySafetyError = class extends FiregraphError {
|
|
29423
|
+
constructor(message) {
|
|
29424
|
+
super(message, "QUERY_SAFETY");
|
|
29425
|
+
this.name = "QuerySafetyError";
|
|
29426
|
+
}
|
|
29427
|
+
};
|
|
29428
|
+
var RegistryScopeError = class extends FiregraphError {
|
|
29429
|
+
constructor(aType, axbType, bType, scopePath, allowedIn) {
|
|
29430
|
+
super(
|
|
29431
|
+
`Type (${aType}) -[${axbType}]-> (${bType}) is not allowed at scope "${scopePath || "root"}". Allowed in: [${allowedIn.join(", ")}]`,
|
|
29432
|
+
"REGISTRY_SCOPE"
|
|
29433
|
+
);
|
|
29434
|
+
this.name = "RegistryScopeError";
|
|
29435
|
+
}
|
|
29436
|
+
};
|
|
29412
29437
|
|
|
29413
29438
|
// src/query.ts
|
|
29414
29439
|
function buildEdgeQueryPlan(params) {
|
|
@@ -29422,27 +29447,32 @@ function buildEdgeQueryPlan(params) {
|
|
|
29422
29447
|
if (axbType) filters.push({ field: "axbType", op: "==", value: axbType });
|
|
29423
29448
|
if (bType) filters.push({ field: "bType", op: "==", value: bType });
|
|
29424
29449
|
if (bUid) filters.push({ field: "bUid", op: "==", value: bUid });
|
|
29425
|
-
const builtinFields = ["aType", "aUid", "axbType", "bType", "bUid", "createdAt", "updatedAt"];
|
|
29426
29450
|
if (params.where) {
|
|
29427
29451
|
for (const clause of params.where) {
|
|
29428
|
-
const field =
|
|
29452
|
+
const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
|
|
29429
29453
|
filters.push({ field, op: clause.op, value: clause.value });
|
|
29430
29454
|
}
|
|
29431
29455
|
}
|
|
29432
29456
|
if (filters.length === 0) {
|
|
29433
29457
|
throw new InvalidQueryError("findEdges requires at least one filter parameter");
|
|
29434
29458
|
}
|
|
29435
|
-
const
|
|
29436
|
-
return { strategy: "query", filters, options };
|
|
29459
|
+
const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
|
|
29460
|
+
return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
|
|
29437
29461
|
}
|
|
29438
29462
|
function buildNodeQueryPlan(params) {
|
|
29439
|
-
|
|
29440
|
-
|
|
29441
|
-
|
|
29442
|
-
|
|
29443
|
-
|
|
29444
|
-
|
|
29445
|
-
|
|
29463
|
+
const { aType, limit, orderBy } = params;
|
|
29464
|
+
const filters = [
|
|
29465
|
+
{ field: "aType", op: "==", value: aType },
|
|
29466
|
+
{ field: "axbType", op: "==", value: NODE_RELATION }
|
|
29467
|
+
];
|
|
29468
|
+
if (params.where) {
|
|
29469
|
+
for (const clause of params.where) {
|
|
29470
|
+
const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
|
|
29471
|
+
filters.push({ field, op: clause.op, value: clause.value });
|
|
29472
|
+
}
|
|
29473
|
+
}
|
|
29474
|
+
const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
|
|
29475
|
+
return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
|
|
29446
29476
|
}
|
|
29447
29477
|
|
|
29448
29478
|
// src/internal/firestore-adapter.ts
|
|
@@ -29595,10 +29625,62 @@ function createPipelineQueryAdapter(db2, collectionPath) {
|
|
|
29595
29625
|
|
|
29596
29626
|
// src/transaction.ts
|
|
29597
29627
|
import { FieldValue as FieldValue2 } from "@google-cloud/firestore";
|
|
29628
|
+
|
|
29629
|
+
// src/query-safety.ts
|
|
29630
|
+
var SAFE_INDEX_PATTERNS = [
|
|
29631
|
+
/* @__PURE__ */ new Set(["aUid", "axbType"]),
|
|
29632
|
+
/* @__PURE__ */ new Set(["axbType", "bUid"]),
|
|
29633
|
+
/* @__PURE__ */ new Set(["aType", "axbType"]),
|
|
29634
|
+
/* @__PURE__ */ new Set(["axbType", "bType"])
|
|
29635
|
+
];
|
|
29636
|
+
function analyzeQuerySafety(filters) {
|
|
29637
|
+
const builtinFieldsPresent = /* @__PURE__ */ new Set();
|
|
29638
|
+
let hasDataFilters = false;
|
|
29639
|
+
for (const f of filters) {
|
|
29640
|
+
if (BUILTIN_FIELDS.has(f.field)) {
|
|
29641
|
+
builtinFieldsPresent.add(f.field);
|
|
29642
|
+
} else {
|
|
29643
|
+
hasDataFilters = true;
|
|
29644
|
+
}
|
|
29645
|
+
}
|
|
29646
|
+
for (const pattern of SAFE_INDEX_PATTERNS) {
|
|
29647
|
+
let matched = true;
|
|
29648
|
+
for (const field of pattern) {
|
|
29649
|
+
if (!builtinFieldsPresent.has(field)) {
|
|
29650
|
+
matched = false;
|
|
29651
|
+
break;
|
|
29652
|
+
}
|
|
29653
|
+
}
|
|
29654
|
+
if (matched) {
|
|
29655
|
+
return { safe: true };
|
|
29656
|
+
}
|
|
29657
|
+
}
|
|
29658
|
+
const presentFields = [...builtinFieldsPresent];
|
|
29659
|
+
if (presentFields.length === 0 && hasDataFilters) {
|
|
29660
|
+
return {
|
|
29661
|
+
safe: false,
|
|
29662
|
+
reason: "Query filters only use data.* fields with no builtin field constraints. This requires a full collection scan. Add aType, aUid, axbType, bType, or bUid filters, or set allowCollectionScan: true."
|
|
29663
|
+
};
|
|
29664
|
+
}
|
|
29665
|
+
if (hasDataFilters) {
|
|
29666
|
+
return {
|
|
29667
|
+
safe: false,
|
|
29668
|
+
reason: `Query filters on [${presentFields.join(", ")}] do not match any indexed pattern. data.* filters without an indexed base require a full collection scan. Safe patterns: (aUid + axbType), (axbType + bUid), (aType + axbType), (axbType + bType). Set allowCollectionScan: true to override.`
|
|
29669
|
+
};
|
|
29670
|
+
}
|
|
29671
|
+
return {
|
|
29672
|
+
safe: false,
|
|
29673
|
+
reason: `Query filters on [${presentFields.join(", ")}] do not match any indexed pattern. This may cause a full collection scan on Firestore Enterprise. Safe patterns: (aUid + axbType), (axbType + bUid), (aType + axbType), (axbType + bType). Set allowCollectionScan: true to override.`
|
|
29674
|
+
};
|
|
29675
|
+
}
|
|
29676
|
+
|
|
29677
|
+
// src/transaction.ts
|
|
29598
29678
|
var GraphTransactionImpl = class {
|
|
29599
|
-
constructor(adapter, registry2) {
|
|
29679
|
+
constructor(adapter, registry2, scanProtection = "error", scopePath = "") {
|
|
29600
29680
|
this.adapter = adapter;
|
|
29601
29681
|
this.registry = registry2;
|
|
29682
|
+
this.scanProtection = scanProtection;
|
|
29683
|
+
this.scopePath = scopePath;
|
|
29602
29684
|
}
|
|
29603
29685
|
async getNode(uid) {
|
|
29604
29686
|
const docId = computeNodeDocId(uid);
|
|
@@ -29612,12 +29694,22 @@ var GraphTransactionImpl = class {
|
|
|
29612
29694
|
const record2 = await this.getEdge(aUid, axbType, bUid);
|
|
29613
29695
|
return record2 !== null;
|
|
29614
29696
|
}
|
|
29697
|
+
checkQuerySafety(filters, allowCollectionScan) {
|
|
29698
|
+
if (allowCollectionScan || this.scanProtection === "off") return;
|
|
29699
|
+
const result = analyzeQuerySafety(filters);
|
|
29700
|
+
if (result.safe) return;
|
|
29701
|
+
if (this.scanProtection === "error") {
|
|
29702
|
+
throw new QuerySafetyError(result.reason);
|
|
29703
|
+
}
|
|
29704
|
+
console.warn(`[firegraph] Query safety warning: ${result.reason}`);
|
|
29705
|
+
}
|
|
29615
29706
|
async findEdges(params) {
|
|
29616
29707
|
const plan = buildEdgeQueryPlan(params);
|
|
29617
29708
|
if (plan.strategy === "get") {
|
|
29618
29709
|
const record2 = await this.adapter.getDoc(plan.docId);
|
|
29619
29710
|
return record2 ? [record2] : [];
|
|
29620
29711
|
}
|
|
29712
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
29621
29713
|
return this.adapter.query(plan.filters, plan.options);
|
|
29622
29714
|
}
|
|
29623
29715
|
async findNodes(params) {
|
|
@@ -29626,11 +29718,12 @@ var GraphTransactionImpl = class {
|
|
|
29626
29718
|
const record2 = await this.adapter.getDoc(plan.docId);
|
|
29627
29719
|
return record2 ? [record2] : [];
|
|
29628
29720
|
}
|
|
29721
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
29629
29722
|
return this.adapter.query(plan.filters, plan.options);
|
|
29630
29723
|
}
|
|
29631
29724
|
async putNode(aType, uid, data) {
|
|
29632
29725
|
if (this.registry) {
|
|
29633
|
-
this.registry.validate(aType, NODE_RELATION, aType, data);
|
|
29726
|
+
this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
29634
29727
|
}
|
|
29635
29728
|
const docId = computeNodeDocId(uid);
|
|
29636
29729
|
const record2 = buildNodeRecord(aType, uid, data);
|
|
@@ -29638,7 +29731,7 @@ var GraphTransactionImpl = class {
|
|
|
29638
29731
|
}
|
|
29639
29732
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
29640
29733
|
if (this.registry) {
|
|
29641
|
-
this.registry.validate(aType, axbType, bType, data);
|
|
29734
|
+
this.registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
29642
29735
|
}
|
|
29643
29736
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
29644
29737
|
const record2 = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
|
|
@@ -29664,13 +29757,14 @@ var GraphTransactionImpl = class {
|
|
|
29664
29757
|
// src/batch.ts
|
|
29665
29758
|
import { FieldValue as FieldValue3 } from "@google-cloud/firestore";
|
|
29666
29759
|
var GraphBatchImpl = class {
|
|
29667
|
-
constructor(adapter, registry2) {
|
|
29760
|
+
constructor(adapter, registry2, scopePath = "") {
|
|
29668
29761
|
this.adapter = adapter;
|
|
29669
29762
|
this.registry = registry2;
|
|
29763
|
+
this.scopePath = scopePath;
|
|
29670
29764
|
}
|
|
29671
29765
|
async putNode(aType, uid, data) {
|
|
29672
29766
|
if (this.registry) {
|
|
29673
|
-
this.registry.validate(aType, NODE_RELATION, aType, data);
|
|
29767
|
+
this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
29674
29768
|
}
|
|
29675
29769
|
const docId = computeNodeDocId(uid);
|
|
29676
29770
|
const record2 = buildNodeRecord(aType, uid, data);
|
|
@@ -29678,7 +29772,7 @@ var GraphBatchImpl = class {
|
|
|
29678
29772
|
}
|
|
29679
29773
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
29680
29774
|
if (this.registry) {
|
|
29681
|
-
this.registry.validate(aType, axbType, bType, data);
|
|
29775
|
+
this.registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
29682
29776
|
}
|
|
29683
29777
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
29684
29778
|
const record2 = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
|
|
@@ -29770,14 +29864,39 @@ async function bulkDeleteDocIds(db2, collectionPath, docIds, options) {
|
|
|
29770
29864
|
return { deleted, batches: completedBatches, errors };
|
|
29771
29865
|
}
|
|
29772
29866
|
async function bulkRemoveEdges(db2, collectionPath, reader, params, options) {
|
|
29773
|
-
const
|
|
29867
|
+
const effectiveParams = params.limit !== void 0 ? { ...params, allowCollectionScan: params.allowCollectionScan ?? true } : { ...params, limit: 0, allowCollectionScan: params.allowCollectionScan ?? true };
|
|
29868
|
+
const edges = await reader.findEdges(effectiveParams);
|
|
29774
29869
|
const docIds = edges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
|
|
29775
29870
|
return bulkDeleteDocIds(db2, collectionPath, docIds, options);
|
|
29776
29871
|
}
|
|
29872
|
+
async function deleteSubcollectionsRecursive(db2, collectionPath, docId, options) {
|
|
29873
|
+
const docRef = db2.collection(collectionPath).doc(docId);
|
|
29874
|
+
const subcollections = await docRef.listCollections();
|
|
29875
|
+
if (subcollections.length === 0) return { deleted: 0, errors: [] };
|
|
29876
|
+
let totalDeleted = 0;
|
|
29877
|
+
const allErrors = [];
|
|
29878
|
+
const subOptions = options ? { batchSize: options.batchSize, maxRetries: options.maxRetries } : void 0;
|
|
29879
|
+
for (const subCollRef of subcollections) {
|
|
29880
|
+
const subCollPath = subCollRef.path;
|
|
29881
|
+
const snapshot = await subCollRef.select().get();
|
|
29882
|
+
const subDocIds = snapshot.docs.map((d) => d.id);
|
|
29883
|
+
for (const subDocId of subDocIds) {
|
|
29884
|
+
const subResult = await deleteSubcollectionsRecursive(db2, subCollPath, subDocId, subOptions);
|
|
29885
|
+
totalDeleted += subResult.deleted;
|
|
29886
|
+
allErrors.push(...subResult.errors);
|
|
29887
|
+
}
|
|
29888
|
+
if (subDocIds.length > 0) {
|
|
29889
|
+
const result = await bulkDeleteDocIds(db2, subCollPath, subDocIds, subOptions);
|
|
29890
|
+
totalDeleted += result.deleted;
|
|
29891
|
+
allErrors.push(...result.errors);
|
|
29892
|
+
}
|
|
29893
|
+
}
|
|
29894
|
+
return { deleted: totalDeleted, errors: allErrors };
|
|
29895
|
+
}
|
|
29777
29896
|
async function removeNodeCascade(db2, collectionPath, reader, uid, options) {
|
|
29778
29897
|
const [outgoingRaw, incomingRaw] = await Promise.all([
|
|
29779
|
-
reader.findEdges({ aUid: uid }),
|
|
29780
|
-
reader.findEdges({ bUid: uid })
|
|
29898
|
+
reader.findEdges({ aUid: uid, allowCollectionScan: true, limit: 0 }),
|
|
29899
|
+
reader.findEdges({ bUid: uid, allowCollectionScan: true, limit: 0 })
|
|
29781
29900
|
]);
|
|
29782
29901
|
const outgoing = outgoingRaw.filter((e) => e.axbType !== NODE_RELATION);
|
|
29783
29902
|
const incoming = incomingRaw.filter((e) => e.axbType !== NODE_RELATION);
|
|
@@ -29790,8 +29909,18 @@ async function removeNodeCascade(db2, collectionPath, reader, uid, options) {
|
|
|
29790
29909
|
allEdges.push(edge);
|
|
29791
29910
|
}
|
|
29792
29911
|
}
|
|
29793
|
-
const
|
|
29912
|
+
const shouldDeleteSubcollections = options?.deleteSubcollections !== false;
|
|
29794
29913
|
const nodeDocId = computeNodeDocId(uid);
|
|
29914
|
+
let subcollectionResult = { deleted: 0, errors: [] };
|
|
29915
|
+
if (shouldDeleteSubcollections) {
|
|
29916
|
+
subcollectionResult = await deleteSubcollectionsRecursive(
|
|
29917
|
+
db2,
|
|
29918
|
+
collectionPath,
|
|
29919
|
+
nodeDocId,
|
|
29920
|
+
options
|
|
29921
|
+
);
|
|
29922
|
+
}
|
|
29923
|
+
const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
|
|
29795
29924
|
const allDocIds = [...edgeDocIds, nodeDocId];
|
|
29796
29925
|
const batchSize = Math.min(options?.batchSize ?? MAX_BATCH_SIZE, MAX_BATCH_SIZE);
|
|
29797
29926
|
const result = await bulkDeleteDocIds(db2, collectionPath, allDocIds, {
|
|
@@ -29801,9 +29930,12 @@ async function removeNodeCascade(db2, collectionPath, reader, uid, options) {
|
|
|
29801
29930
|
const totalChunks = Math.ceil(allDocIds.length / batchSize);
|
|
29802
29931
|
const nodeChunkIndex = totalChunks - 1;
|
|
29803
29932
|
const nodeDeleted = !result.errors.some((e) => e.batchIndex === nodeChunkIndex);
|
|
29933
|
+
const topLevelEdgesDeleted = nodeDeleted ? result.deleted - 1 : result.deleted;
|
|
29804
29934
|
return {
|
|
29805
|
-
|
|
29806
|
-
|
|
29935
|
+
deleted: result.deleted + subcollectionResult.deleted,
|
|
29936
|
+
batches: result.batches,
|
|
29937
|
+
errors: [...result.errors, ...subcollectionResult.errors],
|
|
29938
|
+
edgesDeleted: topLevelEdgesDeleted,
|
|
29807
29939
|
nodeDeleted
|
|
29808
29940
|
};
|
|
29809
29941
|
}
|
|
@@ -29903,6 +30035,39 @@ function propertyToFieldMeta(name, prop, required2) {
|
|
|
29903
30035
|
return { name, type: "unknown", required: required2, description: prop.description };
|
|
29904
30036
|
}
|
|
29905
30037
|
|
|
30038
|
+
// src/scope.ts
|
|
30039
|
+
function matchScope(scopePath, pattern) {
|
|
30040
|
+
if (pattern === "root") return scopePath === "";
|
|
30041
|
+
if (pattern === "**") return true;
|
|
30042
|
+
const pathSegments = scopePath === "" ? [] : scopePath.split("/");
|
|
30043
|
+
const patternSegments = pattern.split("/");
|
|
30044
|
+
return matchSegments(pathSegments, 0, patternSegments, 0);
|
|
30045
|
+
}
|
|
30046
|
+
function matchScopeAny(scopePath, patterns) {
|
|
30047
|
+
if (!patterns || patterns.length === 0) return true;
|
|
30048
|
+
return patterns.some((p) => matchScope(scopePath, p));
|
|
30049
|
+
}
|
|
30050
|
+
function matchSegments(path4, pi, pattern, qi) {
|
|
30051
|
+
if (pi === path4.length && qi === pattern.length) return true;
|
|
30052
|
+
if (qi === pattern.length) return false;
|
|
30053
|
+
const seg = pattern[qi];
|
|
30054
|
+
if (seg === "**") {
|
|
30055
|
+
if (qi === pattern.length - 1) return true;
|
|
30056
|
+
for (let skip = 0; skip <= path4.length - pi; skip++) {
|
|
30057
|
+
if (matchSegments(path4, pi + skip, pattern, qi + 1)) return true;
|
|
30058
|
+
}
|
|
30059
|
+
return false;
|
|
30060
|
+
}
|
|
30061
|
+
if (pi === path4.length) return false;
|
|
30062
|
+
if (seg === "*") {
|
|
30063
|
+
return matchSegments(path4, pi + 1, pattern, qi + 1);
|
|
30064
|
+
}
|
|
30065
|
+
if (path4[pi] === seg) {
|
|
30066
|
+
return matchSegments(path4, pi + 1, pattern, qi + 1);
|
|
30067
|
+
}
|
|
30068
|
+
return false;
|
|
30069
|
+
}
|
|
30070
|
+
|
|
29906
30071
|
// src/registry.ts
|
|
29907
30072
|
function tripleKey(aType, axbType, bType) {
|
|
29908
30073
|
return `${aType}:${axbType}:${bType}`;
|
|
@@ -29925,11 +30090,16 @@ function createRegistry(input) {
|
|
|
29925
30090
|
lookup(aType, axbType, bType) {
|
|
29926
30091
|
return map2.get(tripleKey(aType, axbType, bType))?.entry;
|
|
29927
30092
|
},
|
|
29928
|
-
validate(aType, axbType, bType, data) {
|
|
30093
|
+
validate(aType, axbType, bType, data, scopePath) {
|
|
29929
30094
|
const rec = map2.get(tripleKey(aType, axbType, bType));
|
|
29930
30095
|
if (!rec) {
|
|
29931
30096
|
throw new RegistryViolationError(aType, axbType, bType);
|
|
29932
30097
|
}
|
|
30098
|
+
if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
|
|
30099
|
+
if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
|
|
30100
|
+
throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
|
|
30101
|
+
}
|
|
30102
|
+
}
|
|
29933
30103
|
if (rec.validate) {
|
|
29934
30104
|
try {
|
|
29935
30105
|
rec.validate(data);
|
|
@@ -29957,7 +30127,8 @@ function discoveryToEntries(discovery) {
|
|
|
29957
30127
|
jsonSchema: entity.schema,
|
|
29958
30128
|
description: entity.description,
|
|
29959
30129
|
titleField: entity.titleField,
|
|
29960
|
-
subtitleField: entity.subtitleField
|
|
30130
|
+
subtitleField: entity.subtitleField,
|
|
30131
|
+
allowedIn: entity.allowedIn
|
|
29961
30132
|
});
|
|
29962
30133
|
}
|
|
29963
30134
|
for (const [axbType, entity] of discovery.edges) {
|
|
@@ -29975,7 +30146,8 @@ function discoveryToEntries(discovery) {
|
|
|
29975
30146
|
description: entity.description,
|
|
29976
30147
|
inverseLabel: topology.inverseLabel,
|
|
29977
30148
|
titleField: entity.titleField,
|
|
29978
|
-
subtitleField: entity.subtitleField
|
|
30149
|
+
subtitleField: entity.subtitleField,
|
|
30150
|
+
allowedIn: entity.allowedIn
|
|
29979
30151
|
});
|
|
29980
30152
|
}
|
|
29981
30153
|
}
|
|
@@ -29996,7 +30168,8 @@ var NODE_TYPE_SCHEMA = {
|
|
|
29996
30168
|
titleField: { type: "string" },
|
|
29997
30169
|
subtitleField: { type: "string" },
|
|
29998
30170
|
viewTemplate: { type: "string" },
|
|
29999
|
-
viewCss: { type: "string" }
|
|
30171
|
+
viewCss: { type: "string" },
|
|
30172
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
|
|
30000
30173
|
},
|
|
30001
30174
|
additionalProperties: false
|
|
30002
30175
|
};
|
|
@@ -30023,7 +30196,8 @@ var EDGE_TYPE_SCHEMA = {
|
|
|
30023
30196
|
titleField: { type: "string" },
|
|
30024
30197
|
subtitleField: { type: "string" },
|
|
30025
30198
|
viewTemplate: { type: "string" },
|
|
30026
|
-
viewCss: { type: "string" }
|
|
30199
|
+
viewCss: { type: "string" },
|
|
30200
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
|
|
30027
30201
|
},
|
|
30028
30202
|
additionalProperties: false
|
|
30029
30203
|
};
|
|
@@ -30065,7 +30239,8 @@ async function createRegistryFromGraph(reader) {
|
|
|
30065
30239
|
jsonSchema: data.jsonSchema,
|
|
30066
30240
|
description: data.description,
|
|
30067
30241
|
titleField: data.titleField,
|
|
30068
|
-
subtitleField: data.subtitleField
|
|
30242
|
+
subtitleField: data.subtitleField,
|
|
30243
|
+
allowedIn: data.allowedIn
|
|
30069
30244
|
});
|
|
30070
30245
|
}
|
|
30071
30246
|
for (const record2 of edgeTypes) {
|
|
@@ -30082,7 +30257,8 @@ async function createRegistryFromGraph(reader) {
|
|
|
30082
30257
|
description: data.description,
|
|
30083
30258
|
inverseLabel: data.inverseLabel,
|
|
30084
30259
|
titleField: data.titleField,
|
|
30085
|
-
subtitleField: data.subtitleField
|
|
30260
|
+
subtitleField: data.subtitleField,
|
|
30261
|
+
allowedIn: data.allowedIn
|
|
30086
30262
|
});
|
|
30087
30263
|
}
|
|
30088
30264
|
}
|
|
@@ -30093,9 +30269,10 @@ async function createRegistryFromGraph(reader) {
|
|
|
30093
30269
|
// src/client.ts
|
|
30094
30270
|
var _standardModeWarned = false;
|
|
30095
30271
|
var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
|
|
30096
|
-
var GraphClientImpl = class {
|
|
30097
|
-
constructor(db2, collectionPath, options) {
|
|
30272
|
+
var GraphClientImpl = class _GraphClientImpl {
|
|
30273
|
+
constructor(db2, collectionPath, options, scopePath = "") {
|
|
30098
30274
|
this.db = db2;
|
|
30275
|
+
this.scopePath = scopePath;
|
|
30099
30276
|
this.adapter = createFirestoreAdapter(db2, collectionPath);
|
|
30100
30277
|
if (options?.registry && options?.registryMode) {
|
|
30101
30278
|
throw new DynamicRegistryError(
|
|
@@ -30125,6 +30302,7 @@ var GraphClientImpl = class {
|
|
|
30125
30302
|
"[firegraph] Standard query mode enabled. This is NOT recommended for production:\n - Enterprise Firestore: data.* filters cause full collection scans (high billing)\n - Standard Firestore: data.* filters without composite indexes will fail\n See: https://github.com/typicalday/firegraph#query-modes"
|
|
30126
30303
|
);
|
|
30127
30304
|
}
|
|
30305
|
+
this.scanProtection = options?.scanProtection ?? "error";
|
|
30128
30306
|
if (this.queryMode === "pipeline") {
|
|
30129
30307
|
this.pipelineAdapter = createPipelineQueryAdapter(db2, collectionPath);
|
|
30130
30308
|
if (this.metaAdapter) {
|
|
@@ -30138,6 +30316,7 @@ var GraphClientImpl = class {
|
|
|
30138
30316
|
adapter;
|
|
30139
30317
|
pipelineAdapter;
|
|
30140
30318
|
queryMode;
|
|
30319
|
+
scanProtection;
|
|
30141
30320
|
// Static mode
|
|
30142
30321
|
staticRegistry;
|
|
30143
30322
|
// Dynamic mode
|
|
@@ -30146,6 +30325,8 @@ var GraphClientImpl = class {
|
|
|
30146
30325
|
dynamicRegistry;
|
|
30147
30326
|
metaAdapter;
|
|
30148
30327
|
metaPipelineAdapter;
|
|
30328
|
+
// Subgraph scope tracking
|
|
30329
|
+
scopePath;
|
|
30149
30330
|
// ---------------------------------------------------------------------------
|
|
30150
30331
|
// Registry routing
|
|
30151
30332
|
// ---------------------------------------------------------------------------
|
|
@@ -30199,6 +30380,19 @@ var GraphClientImpl = class {
|
|
|
30199
30380
|
}
|
|
30200
30381
|
return this.adapter.query(filters, options);
|
|
30201
30382
|
}
|
|
30383
|
+
/**
|
|
30384
|
+
* Check whether a query's filter set is safe (matches a known index pattern).
|
|
30385
|
+
* Throws QuerySafetyError or logs a warning depending on scanProtection config.
|
|
30386
|
+
*/
|
|
30387
|
+
checkQuerySafety(filters, allowCollectionScan) {
|
|
30388
|
+
if (allowCollectionScan || this.scanProtection === "off") return;
|
|
30389
|
+
const result = analyzeQuerySafety(filters);
|
|
30390
|
+
if (result.safe) return;
|
|
30391
|
+
if (this.scanProtection === "error") {
|
|
30392
|
+
throw new QuerySafetyError(result.reason);
|
|
30393
|
+
}
|
|
30394
|
+
console.warn(`[firegraph] Query safety warning: ${result.reason}`);
|
|
30395
|
+
}
|
|
30202
30396
|
// ---------------------------------------------------------------------------
|
|
30203
30397
|
// GraphReader
|
|
30204
30398
|
// ---------------------------------------------------------------------------
|
|
@@ -30220,6 +30414,7 @@ var GraphClientImpl = class {
|
|
|
30220
30414
|
const record2 = await this.adapter.getDoc(plan.docId);
|
|
30221
30415
|
return record2 ? [record2] : [];
|
|
30222
30416
|
}
|
|
30417
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
30223
30418
|
return this.executeQuery(plan.filters, plan.options);
|
|
30224
30419
|
}
|
|
30225
30420
|
async findNodes(params) {
|
|
@@ -30228,6 +30423,7 @@ var GraphClientImpl = class {
|
|
|
30228
30423
|
const record2 = await this.adapter.getDoc(plan.docId);
|
|
30229
30424
|
return record2 ? [record2] : [];
|
|
30230
30425
|
}
|
|
30426
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
30231
30427
|
return this.executeQuery(plan.filters, plan.options);
|
|
30232
30428
|
}
|
|
30233
30429
|
// ---------------------------------------------------------------------------
|
|
@@ -30236,7 +30432,7 @@ var GraphClientImpl = class {
|
|
|
30236
30432
|
async putNode(aType, uid, data) {
|
|
30237
30433
|
const registry2 = this.getRegistryForType(aType);
|
|
30238
30434
|
if (registry2) {
|
|
30239
|
-
registry2.validate(aType, NODE_RELATION, aType, data);
|
|
30435
|
+
registry2.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
30240
30436
|
}
|
|
30241
30437
|
const adapter = this.getAdapterForType(aType);
|
|
30242
30438
|
const docId = computeNodeDocId(uid);
|
|
@@ -30246,7 +30442,7 @@ var GraphClientImpl = class {
|
|
|
30246
30442
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
30247
30443
|
const registry2 = this.getRegistryForType(aType);
|
|
30248
30444
|
if (registry2) {
|
|
30249
|
-
registry2.validate(aType, axbType, bType, data);
|
|
30445
|
+
registry2.validate(aType, axbType, bType, data, this.scopePath);
|
|
30250
30446
|
}
|
|
30251
30447
|
const adapter = this.getAdapterForType(aType);
|
|
30252
30448
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
@@ -30278,13 +30474,42 @@ var GraphClientImpl = class {
|
|
|
30278
30474
|
this.adapter.collectionPath,
|
|
30279
30475
|
firestoreTx
|
|
30280
30476
|
);
|
|
30281
|
-
const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
|
|
30477
|
+
const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
|
|
30282
30478
|
return fn(graphTx);
|
|
30283
30479
|
});
|
|
30284
30480
|
}
|
|
30285
30481
|
batch() {
|
|
30286
30482
|
const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
|
|
30287
|
-
return new GraphBatchImpl(adapter, this.getCombinedRegistry());
|
|
30483
|
+
return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
|
|
30484
|
+
}
|
|
30485
|
+
// ---------------------------------------------------------------------------
|
|
30486
|
+
// Subgraph
|
|
30487
|
+
// ---------------------------------------------------------------------------
|
|
30488
|
+
subgraph(parentNodeUid, name = "graph") {
|
|
30489
|
+
if (!parentNodeUid || parentNodeUid.includes("/")) {
|
|
30490
|
+
throw new FiregraphError(
|
|
30491
|
+
`Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
|
|
30492
|
+
"INVALID_SUBGRAPH"
|
|
30493
|
+
);
|
|
30494
|
+
}
|
|
30495
|
+
if (name.includes("/")) {
|
|
30496
|
+
throw new FiregraphError(
|
|
30497
|
+
`Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
|
|
30498
|
+
"INVALID_SUBGRAPH"
|
|
30499
|
+
);
|
|
30500
|
+
}
|
|
30501
|
+
const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
|
|
30502
|
+
const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
|
|
30503
|
+
return new _GraphClientImpl(
|
|
30504
|
+
this.db,
|
|
30505
|
+
subCollectionPath,
|
|
30506
|
+
{
|
|
30507
|
+
registry: this.getCombinedRegistry(),
|
|
30508
|
+
queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
|
|
30509
|
+
scanProtection: this.scanProtection
|
|
30510
|
+
},
|
|
30511
|
+
newScopePath
|
|
30512
|
+
);
|
|
30288
30513
|
}
|
|
30289
30514
|
// ---------------------------------------------------------------------------
|
|
30290
30515
|
// Bulk operations
|
|
@@ -30316,6 +30541,7 @@ var GraphClientImpl = class {
|
|
|
30316
30541
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
30317
30542
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
30318
30543
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
30544
|
+
if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
|
|
30319
30545
|
await this.putNode(META_NODE_TYPE, uid, data);
|
|
30320
30546
|
}
|
|
30321
30547
|
async defineEdgeType(name, topology, jsonSchema, description, options) {
|
|
@@ -30342,6 +30568,7 @@ var GraphClientImpl = class {
|
|
|
30342
30568
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
30343
30569
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
30344
30570
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
30571
|
+
if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
|
|
30345
30572
|
await this.putNode(META_EDGE_TYPE, uid, data);
|
|
30346
30573
|
}
|
|
30347
30574
|
async reloadRegistry() {
|
|
@@ -30524,7 +30751,8 @@ function loadNodeEntity(dir, name) {
|
|
|
30524
30751
|
subtitleField: meta3?.subtitleField,
|
|
30525
30752
|
viewDefaults: meta3?.viewDefaults,
|
|
30526
30753
|
viewsPath,
|
|
30527
|
-
sampleData
|
|
30754
|
+
sampleData,
|
|
30755
|
+
allowedIn: meta3?.allowedIn
|
|
30528
30756
|
};
|
|
30529
30757
|
}
|
|
30530
30758
|
function loadEdgeEntity(dir, name) {
|
|
@@ -30559,7 +30787,8 @@ function loadEdgeEntity(dir, name) {
|
|
|
30559
30787
|
subtitleField: meta3?.subtitleField,
|
|
30560
30788
|
viewDefaults: meta3?.viewDefaults,
|
|
30561
30789
|
viewsPath,
|
|
30562
|
-
sampleData
|
|
30790
|
+
sampleData,
|
|
30791
|
+
allowedIn: meta3?.allowedIn
|
|
30563
30792
|
};
|
|
30564
30793
|
}
|
|
30565
30794
|
function getSubdirectories(dir) {
|