@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/dist/index.cjs
CHANGED
|
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
BOOTSTRAP_ENTRIES: () => BOOTSTRAP_ENTRIES,
|
|
34
|
+
DEFAULT_QUERY_LIMIT: () => DEFAULT_QUERY_LIMIT,
|
|
34
35
|
DiscoveryError: () => DiscoveryError,
|
|
35
36
|
DynamicRegistryError: () => DynamicRegistryError,
|
|
36
37
|
EDGE_TYPE_SCHEMA: () => EDGE_TYPE_SCHEMA,
|
|
@@ -43,9 +44,12 @@ __export(index_exports, {
|
|
|
43
44
|
NodeNotFoundError: () => NodeNotFoundError,
|
|
44
45
|
QueryClient: () => QueryClient,
|
|
45
46
|
QueryClientError: () => QueryClientError,
|
|
47
|
+
QuerySafetyError: () => QuerySafetyError,
|
|
48
|
+
RegistryScopeError: () => RegistryScopeError,
|
|
46
49
|
RegistryViolationError: () => RegistryViolationError,
|
|
47
50
|
TraversalError: () => TraversalError,
|
|
48
51
|
ValidationError: () => ValidationError,
|
|
52
|
+
analyzeQuerySafety: () => analyzeQuerySafety,
|
|
49
53
|
buildEdgeQueryPlan: () => buildEdgeQueryPlan,
|
|
50
54
|
buildEdgeRecord: () => buildEdgeRecord,
|
|
51
55
|
buildNodeQueryPlan: () => buildNodeQueryPlan,
|
|
@@ -63,8 +67,11 @@ __export(index_exports, {
|
|
|
63
67
|
discoverEntities: () => discoverEntities,
|
|
64
68
|
generateDeterministicUid: () => generateDeterministicUid,
|
|
65
69
|
generateId: () => generateId,
|
|
70
|
+
generateIndexConfig: () => generateIndexConfig,
|
|
66
71
|
generateTypes: () => generateTypes,
|
|
67
72
|
jsonSchemaToFieldMeta: () => jsonSchemaToFieldMeta,
|
|
73
|
+
matchScope: () => matchScope,
|
|
74
|
+
matchScopeAny: () => matchScopeAny,
|
|
68
75
|
resolveView: () => resolveView
|
|
69
76
|
});
|
|
70
77
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -77,6 +84,16 @@ var import_node_crypto = require("crypto");
|
|
|
77
84
|
|
|
78
85
|
// src/internal/constants.ts
|
|
79
86
|
var NODE_RELATION = "is";
|
|
87
|
+
var DEFAULT_QUERY_LIMIT = 500;
|
|
88
|
+
var BUILTIN_FIELDS = /* @__PURE__ */ new Set([
|
|
89
|
+
"aType",
|
|
90
|
+
"aUid",
|
|
91
|
+
"axbType",
|
|
92
|
+
"bType",
|
|
93
|
+
"bUid",
|
|
94
|
+
"createdAt",
|
|
95
|
+
"updatedAt"
|
|
96
|
+
]);
|
|
80
97
|
var SHARD_SEPARATOR = ":";
|
|
81
98
|
|
|
82
99
|
// src/docid.ts
|
|
@@ -173,6 +190,21 @@ var DynamicRegistryError = class extends FiregraphError {
|
|
|
173
190
|
this.name = "DynamicRegistryError";
|
|
174
191
|
}
|
|
175
192
|
};
|
|
193
|
+
var QuerySafetyError = class extends FiregraphError {
|
|
194
|
+
constructor(message) {
|
|
195
|
+
super(message, "QUERY_SAFETY");
|
|
196
|
+
this.name = "QuerySafetyError";
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var RegistryScopeError = class extends FiregraphError {
|
|
200
|
+
constructor(aType, axbType, bType, scopePath, allowedIn) {
|
|
201
|
+
super(
|
|
202
|
+
`Type (${aType}) -[${axbType}]-> (${bType}) is not allowed at scope "${scopePath || "root"}". Allowed in: [${allowedIn.join(", ")}]`,
|
|
203
|
+
"REGISTRY_SCOPE"
|
|
204
|
+
);
|
|
205
|
+
this.name = "RegistryScopeError";
|
|
206
|
+
}
|
|
207
|
+
};
|
|
176
208
|
|
|
177
209
|
// src/query.ts
|
|
178
210
|
function buildEdgeQueryPlan(params) {
|
|
@@ -186,27 +218,32 @@ function buildEdgeQueryPlan(params) {
|
|
|
186
218
|
if (axbType) filters.push({ field: "axbType", op: "==", value: axbType });
|
|
187
219
|
if (bType) filters.push({ field: "bType", op: "==", value: bType });
|
|
188
220
|
if (bUid) filters.push({ field: "bUid", op: "==", value: bUid });
|
|
189
|
-
const builtinFields = ["aType", "aUid", "axbType", "bType", "bUid", "createdAt", "updatedAt"];
|
|
190
221
|
if (params.where) {
|
|
191
222
|
for (const clause of params.where) {
|
|
192
|
-
const field =
|
|
223
|
+
const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
|
|
193
224
|
filters.push({ field, op: clause.op, value: clause.value });
|
|
194
225
|
}
|
|
195
226
|
}
|
|
196
227
|
if (filters.length === 0) {
|
|
197
228
|
throw new InvalidQueryError("findEdges requires at least one filter parameter");
|
|
198
229
|
}
|
|
199
|
-
const
|
|
200
|
-
return { strategy: "query", filters, options };
|
|
230
|
+
const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
|
|
231
|
+
return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
|
|
201
232
|
}
|
|
202
233
|
function buildNodeQueryPlan(params) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
234
|
+
const { aType, limit, orderBy } = params;
|
|
235
|
+
const filters = [
|
|
236
|
+
{ field: "aType", op: "==", value: aType },
|
|
237
|
+
{ field: "axbType", op: "==", value: NODE_RELATION }
|
|
238
|
+
];
|
|
239
|
+
if (params.where) {
|
|
240
|
+
for (const clause of params.where) {
|
|
241
|
+
const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
|
|
242
|
+
filters.push({ field, op: clause.op, value: clause.value });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
|
|
246
|
+
return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
|
|
210
247
|
}
|
|
211
248
|
|
|
212
249
|
// src/internal/firestore-adapter.ts
|
|
@@ -359,10 +396,62 @@ function createPipelineQueryAdapter(db, collectionPath) {
|
|
|
359
396
|
|
|
360
397
|
// src/transaction.ts
|
|
361
398
|
var import_firestore2 = require("@google-cloud/firestore");
|
|
399
|
+
|
|
400
|
+
// src/query-safety.ts
|
|
401
|
+
var SAFE_INDEX_PATTERNS = [
|
|
402
|
+
/* @__PURE__ */ new Set(["aUid", "axbType"]),
|
|
403
|
+
/* @__PURE__ */ new Set(["axbType", "bUid"]),
|
|
404
|
+
/* @__PURE__ */ new Set(["aType", "axbType"]),
|
|
405
|
+
/* @__PURE__ */ new Set(["axbType", "bType"])
|
|
406
|
+
];
|
|
407
|
+
function analyzeQuerySafety(filters) {
|
|
408
|
+
const builtinFieldsPresent = /* @__PURE__ */ new Set();
|
|
409
|
+
let hasDataFilters = false;
|
|
410
|
+
for (const f of filters) {
|
|
411
|
+
if (BUILTIN_FIELDS.has(f.field)) {
|
|
412
|
+
builtinFieldsPresent.add(f.field);
|
|
413
|
+
} else {
|
|
414
|
+
hasDataFilters = true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
for (const pattern of SAFE_INDEX_PATTERNS) {
|
|
418
|
+
let matched = true;
|
|
419
|
+
for (const field of pattern) {
|
|
420
|
+
if (!builtinFieldsPresent.has(field)) {
|
|
421
|
+
matched = false;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (matched) {
|
|
426
|
+
return { safe: true };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const presentFields = [...builtinFieldsPresent];
|
|
430
|
+
if (presentFields.length === 0 && hasDataFilters) {
|
|
431
|
+
return {
|
|
432
|
+
safe: false,
|
|
433
|
+
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."
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (hasDataFilters) {
|
|
437
|
+
return {
|
|
438
|
+
safe: false,
|
|
439
|
+
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.`
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
safe: false,
|
|
444
|
+
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.`
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/transaction.ts
|
|
362
449
|
var GraphTransactionImpl = class {
|
|
363
|
-
constructor(adapter, registry) {
|
|
450
|
+
constructor(adapter, registry, scanProtection = "error", scopePath = "") {
|
|
364
451
|
this.adapter = adapter;
|
|
365
452
|
this.registry = registry;
|
|
453
|
+
this.scanProtection = scanProtection;
|
|
454
|
+
this.scopePath = scopePath;
|
|
366
455
|
}
|
|
367
456
|
async getNode(uid) {
|
|
368
457
|
const docId = computeNodeDocId(uid);
|
|
@@ -376,12 +465,22 @@ var GraphTransactionImpl = class {
|
|
|
376
465
|
const record = await this.getEdge(aUid, axbType, bUid);
|
|
377
466
|
return record !== null;
|
|
378
467
|
}
|
|
468
|
+
checkQuerySafety(filters, allowCollectionScan) {
|
|
469
|
+
if (allowCollectionScan || this.scanProtection === "off") return;
|
|
470
|
+
const result = analyzeQuerySafety(filters);
|
|
471
|
+
if (result.safe) return;
|
|
472
|
+
if (this.scanProtection === "error") {
|
|
473
|
+
throw new QuerySafetyError(result.reason);
|
|
474
|
+
}
|
|
475
|
+
console.warn(`[firegraph] Query safety warning: ${result.reason}`);
|
|
476
|
+
}
|
|
379
477
|
async findEdges(params) {
|
|
380
478
|
const plan = buildEdgeQueryPlan(params);
|
|
381
479
|
if (plan.strategy === "get") {
|
|
382
480
|
const record = await this.adapter.getDoc(plan.docId);
|
|
383
481
|
return record ? [record] : [];
|
|
384
482
|
}
|
|
483
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
385
484
|
return this.adapter.query(plan.filters, plan.options);
|
|
386
485
|
}
|
|
387
486
|
async findNodes(params) {
|
|
@@ -390,11 +489,12 @@ var GraphTransactionImpl = class {
|
|
|
390
489
|
const record = await this.adapter.getDoc(plan.docId);
|
|
391
490
|
return record ? [record] : [];
|
|
392
491
|
}
|
|
492
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
393
493
|
return this.adapter.query(plan.filters, plan.options);
|
|
394
494
|
}
|
|
395
495
|
async putNode(aType, uid, data) {
|
|
396
496
|
if (this.registry) {
|
|
397
|
-
this.registry.validate(aType, NODE_RELATION, aType, data);
|
|
497
|
+
this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
398
498
|
}
|
|
399
499
|
const docId = computeNodeDocId(uid);
|
|
400
500
|
const record = buildNodeRecord(aType, uid, data);
|
|
@@ -402,7 +502,7 @@ var GraphTransactionImpl = class {
|
|
|
402
502
|
}
|
|
403
503
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
404
504
|
if (this.registry) {
|
|
405
|
-
this.registry.validate(aType, axbType, bType, data);
|
|
505
|
+
this.registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
406
506
|
}
|
|
407
507
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
408
508
|
const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
|
|
@@ -428,13 +528,14 @@ var GraphTransactionImpl = class {
|
|
|
428
528
|
// src/batch.ts
|
|
429
529
|
var import_firestore3 = require("@google-cloud/firestore");
|
|
430
530
|
var GraphBatchImpl = class {
|
|
431
|
-
constructor(adapter, registry) {
|
|
531
|
+
constructor(adapter, registry, scopePath = "") {
|
|
432
532
|
this.adapter = adapter;
|
|
433
533
|
this.registry = registry;
|
|
534
|
+
this.scopePath = scopePath;
|
|
434
535
|
}
|
|
435
536
|
async putNode(aType, uid, data) {
|
|
436
537
|
if (this.registry) {
|
|
437
|
-
this.registry.validate(aType, NODE_RELATION, aType, data);
|
|
538
|
+
this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
438
539
|
}
|
|
439
540
|
const docId = computeNodeDocId(uid);
|
|
440
541
|
const record = buildNodeRecord(aType, uid, data);
|
|
@@ -442,7 +543,7 @@ var GraphBatchImpl = class {
|
|
|
442
543
|
}
|
|
443
544
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
444
545
|
if (this.registry) {
|
|
445
|
-
this.registry.validate(aType, axbType, bType, data);
|
|
546
|
+
this.registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
446
547
|
}
|
|
447
548
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
448
549
|
const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
|
|
@@ -534,14 +635,39 @@ async function bulkDeleteDocIds(db, collectionPath, docIds, options) {
|
|
|
534
635
|
return { deleted, batches: completedBatches, errors };
|
|
535
636
|
}
|
|
536
637
|
async function bulkRemoveEdges(db, collectionPath, reader, params, options) {
|
|
537
|
-
const
|
|
638
|
+
const effectiveParams = params.limit !== void 0 ? { ...params, allowCollectionScan: params.allowCollectionScan ?? true } : { ...params, limit: 0, allowCollectionScan: params.allowCollectionScan ?? true };
|
|
639
|
+
const edges = await reader.findEdges(effectiveParams);
|
|
538
640
|
const docIds = edges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
|
|
539
641
|
return bulkDeleteDocIds(db, collectionPath, docIds, options);
|
|
540
642
|
}
|
|
643
|
+
async function deleteSubcollectionsRecursive(db, collectionPath, docId, options) {
|
|
644
|
+
const docRef = db.collection(collectionPath).doc(docId);
|
|
645
|
+
const subcollections = await docRef.listCollections();
|
|
646
|
+
if (subcollections.length === 0) return { deleted: 0, errors: [] };
|
|
647
|
+
let totalDeleted = 0;
|
|
648
|
+
const allErrors = [];
|
|
649
|
+
const subOptions = options ? { batchSize: options.batchSize, maxRetries: options.maxRetries } : void 0;
|
|
650
|
+
for (const subCollRef of subcollections) {
|
|
651
|
+
const subCollPath = subCollRef.path;
|
|
652
|
+
const snapshot = await subCollRef.select().get();
|
|
653
|
+
const subDocIds = snapshot.docs.map((d) => d.id);
|
|
654
|
+
for (const subDocId of subDocIds) {
|
|
655
|
+
const subResult = await deleteSubcollectionsRecursive(db, subCollPath, subDocId, subOptions);
|
|
656
|
+
totalDeleted += subResult.deleted;
|
|
657
|
+
allErrors.push(...subResult.errors);
|
|
658
|
+
}
|
|
659
|
+
if (subDocIds.length > 0) {
|
|
660
|
+
const result = await bulkDeleteDocIds(db, subCollPath, subDocIds, subOptions);
|
|
661
|
+
totalDeleted += result.deleted;
|
|
662
|
+
allErrors.push(...result.errors);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return { deleted: totalDeleted, errors: allErrors };
|
|
666
|
+
}
|
|
541
667
|
async function removeNodeCascade(db, collectionPath, reader, uid, options) {
|
|
542
668
|
const [outgoingRaw, incomingRaw] = await Promise.all([
|
|
543
|
-
reader.findEdges({ aUid: uid }),
|
|
544
|
-
reader.findEdges({ bUid: uid })
|
|
669
|
+
reader.findEdges({ aUid: uid, allowCollectionScan: true, limit: 0 }),
|
|
670
|
+
reader.findEdges({ bUid: uid, allowCollectionScan: true, limit: 0 })
|
|
545
671
|
]);
|
|
546
672
|
const outgoing = outgoingRaw.filter((e) => e.axbType !== NODE_RELATION);
|
|
547
673
|
const incoming = incomingRaw.filter((e) => e.axbType !== NODE_RELATION);
|
|
@@ -554,8 +680,18 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
|
|
|
554
680
|
allEdges.push(edge);
|
|
555
681
|
}
|
|
556
682
|
}
|
|
557
|
-
const
|
|
683
|
+
const shouldDeleteSubcollections = options?.deleteSubcollections !== false;
|
|
558
684
|
const nodeDocId = computeNodeDocId(uid);
|
|
685
|
+
let subcollectionResult = { deleted: 0, errors: [] };
|
|
686
|
+
if (shouldDeleteSubcollections) {
|
|
687
|
+
subcollectionResult = await deleteSubcollectionsRecursive(
|
|
688
|
+
db,
|
|
689
|
+
collectionPath,
|
|
690
|
+
nodeDocId,
|
|
691
|
+
options
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
|
|
559
695
|
const allDocIds = [...edgeDocIds, nodeDocId];
|
|
560
696
|
const batchSize = Math.min(options?.batchSize ?? MAX_BATCH_SIZE, MAX_BATCH_SIZE);
|
|
561
697
|
const result = await bulkDeleteDocIds(db, collectionPath, allDocIds, {
|
|
@@ -565,9 +701,12 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
|
|
|
565
701
|
const totalChunks = Math.ceil(allDocIds.length / batchSize);
|
|
566
702
|
const nodeChunkIndex = totalChunks - 1;
|
|
567
703
|
const nodeDeleted = !result.errors.some((e) => e.batchIndex === nodeChunkIndex);
|
|
704
|
+
const topLevelEdgesDeleted = nodeDeleted ? result.deleted - 1 : result.deleted;
|
|
568
705
|
return {
|
|
569
|
-
|
|
570
|
-
|
|
706
|
+
deleted: result.deleted + subcollectionResult.deleted,
|
|
707
|
+
batches: result.batches,
|
|
708
|
+
errors: [...result.errors, ...subcollectionResult.errors],
|
|
709
|
+
edgesDeleted: topLevelEdgesDeleted,
|
|
571
710
|
nodeDeleted
|
|
572
711
|
};
|
|
573
712
|
}
|
|
@@ -667,6 +806,39 @@ function propertyToFieldMeta(name, prop, required) {
|
|
|
667
806
|
return { name, type: "unknown", required, description: prop.description };
|
|
668
807
|
}
|
|
669
808
|
|
|
809
|
+
// src/scope.ts
|
|
810
|
+
function matchScope(scopePath, pattern) {
|
|
811
|
+
if (pattern === "root") return scopePath === "";
|
|
812
|
+
if (pattern === "**") return true;
|
|
813
|
+
const pathSegments = scopePath === "" ? [] : scopePath.split("/");
|
|
814
|
+
const patternSegments = pattern.split("/");
|
|
815
|
+
return matchSegments(pathSegments, 0, patternSegments, 0);
|
|
816
|
+
}
|
|
817
|
+
function matchScopeAny(scopePath, patterns) {
|
|
818
|
+
if (!patterns || patterns.length === 0) return true;
|
|
819
|
+
return patterns.some((p) => matchScope(scopePath, p));
|
|
820
|
+
}
|
|
821
|
+
function matchSegments(path, pi, pattern, qi) {
|
|
822
|
+
if (pi === path.length && qi === pattern.length) return true;
|
|
823
|
+
if (qi === pattern.length) return false;
|
|
824
|
+
const seg = pattern[qi];
|
|
825
|
+
if (seg === "**") {
|
|
826
|
+
if (qi === pattern.length - 1) return true;
|
|
827
|
+
for (let skip = 0; skip <= path.length - pi; skip++) {
|
|
828
|
+
if (matchSegments(path, pi + skip, pattern, qi + 1)) return true;
|
|
829
|
+
}
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
if (pi === path.length) return false;
|
|
833
|
+
if (seg === "*") {
|
|
834
|
+
return matchSegments(path, pi + 1, pattern, qi + 1);
|
|
835
|
+
}
|
|
836
|
+
if (path[pi] === seg) {
|
|
837
|
+
return matchSegments(path, pi + 1, pattern, qi + 1);
|
|
838
|
+
}
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
|
|
670
842
|
// src/registry.ts
|
|
671
843
|
function tripleKey(aType, axbType, bType) {
|
|
672
844
|
return `${aType}:${axbType}:${bType}`;
|
|
@@ -689,11 +861,16 @@ function createRegistry(input) {
|
|
|
689
861
|
lookup(aType, axbType, bType) {
|
|
690
862
|
return map.get(tripleKey(aType, axbType, bType))?.entry;
|
|
691
863
|
},
|
|
692
|
-
validate(aType, axbType, bType, data) {
|
|
864
|
+
validate(aType, axbType, bType, data, scopePath) {
|
|
693
865
|
const rec = map.get(tripleKey(aType, axbType, bType));
|
|
694
866
|
if (!rec) {
|
|
695
867
|
throw new RegistryViolationError(aType, axbType, bType);
|
|
696
868
|
}
|
|
869
|
+
if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
|
|
870
|
+
if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
|
|
871
|
+
throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
697
874
|
if (rec.validate) {
|
|
698
875
|
try {
|
|
699
876
|
rec.validate(data);
|
|
@@ -721,7 +898,8 @@ function discoveryToEntries(discovery) {
|
|
|
721
898
|
jsonSchema: entity.schema,
|
|
722
899
|
description: entity.description,
|
|
723
900
|
titleField: entity.titleField,
|
|
724
|
-
subtitleField: entity.subtitleField
|
|
901
|
+
subtitleField: entity.subtitleField,
|
|
902
|
+
allowedIn: entity.allowedIn
|
|
725
903
|
});
|
|
726
904
|
}
|
|
727
905
|
for (const [axbType, entity] of discovery.edges) {
|
|
@@ -739,7 +917,8 @@ function discoveryToEntries(discovery) {
|
|
|
739
917
|
description: entity.description,
|
|
740
918
|
inverseLabel: topology.inverseLabel,
|
|
741
919
|
titleField: entity.titleField,
|
|
742
|
-
subtitleField: entity.subtitleField
|
|
920
|
+
subtitleField: entity.subtitleField,
|
|
921
|
+
allowedIn: entity.allowedIn
|
|
743
922
|
});
|
|
744
923
|
}
|
|
745
924
|
}
|
|
@@ -760,7 +939,8 @@ var NODE_TYPE_SCHEMA = {
|
|
|
760
939
|
titleField: { type: "string" },
|
|
761
940
|
subtitleField: { type: "string" },
|
|
762
941
|
viewTemplate: { type: "string" },
|
|
763
|
-
viewCss: { type: "string" }
|
|
942
|
+
viewCss: { type: "string" },
|
|
943
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
|
|
764
944
|
},
|
|
765
945
|
additionalProperties: false
|
|
766
946
|
};
|
|
@@ -787,7 +967,8 @@ var EDGE_TYPE_SCHEMA = {
|
|
|
787
967
|
titleField: { type: "string" },
|
|
788
968
|
subtitleField: { type: "string" },
|
|
789
969
|
viewTemplate: { type: "string" },
|
|
790
|
-
viewCss: { type: "string" }
|
|
970
|
+
viewCss: { type: "string" },
|
|
971
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
|
|
791
972
|
},
|
|
792
973
|
additionalProperties: false
|
|
793
974
|
};
|
|
@@ -829,7 +1010,8 @@ async function createRegistryFromGraph(reader) {
|
|
|
829
1010
|
jsonSchema: data.jsonSchema,
|
|
830
1011
|
description: data.description,
|
|
831
1012
|
titleField: data.titleField,
|
|
832
|
-
subtitleField: data.subtitleField
|
|
1013
|
+
subtitleField: data.subtitleField,
|
|
1014
|
+
allowedIn: data.allowedIn
|
|
833
1015
|
});
|
|
834
1016
|
}
|
|
835
1017
|
for (const record of edgeTypes) {
|
|
@@ -846,7 +1028,8 @@ async function createRegistryFromGraph(reader) {
|
|
|
846
1028
|
description: data.description,
|
|
847
1029
|
inverseLabel: data.inverseLabel,
|
|
848
1030
|
titleField: data.titleField,
|
|
849
|
-
subtitleField: data.subtitleField
|
|
1031
|
+
subtitleField: data.subtitleField,
|
|
1032
|
+
allowedIn: data.allowedIn
|
|
850
1033
|
});
|
|
851
1034
|
}
|
|
852
1035
|
}
|
|
@@ -857,9 +1040,10 @@ async function createRegistryFromGraph(reader) {
|
|
|
857
1040
|
// src/client.ts
|
|
858
1041
|
var _standardModeWarned = false;
|
|
859
1042
|
var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
|
|
860
|
-
var GraphClientImpl = class {
|
|
861
|
-
constructor(db, collectionPath, options) {
|
|
1043
|
+
var GraphClientImpl = class _GraphClientImpl {
|
|
1044
|
+
constructor(db, collectionPath, options, scopePath = "") {
|
|
862
1045
|
this.db = db;
|
|
1046
|
+
this.scopePath = scopePath;
|
|
863
1047
|
this.adapter = createFirestoreAdapter(db, collectionPath);
|
|
864
1048
|
if (options?.registry && options?.registryMode) {
|
|
865
1049
|
throw new DynamicRegistryError(
|
|
@@ -889,6 +1073,7 @@ var GraphClientImpl = class {
|
|
|
889
1073
|
"[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"
|
|
890
1074
|
);
|
|
891
1075
|
}
|
|
1076
|
+
this.scanProtection = options?.scanProtection ?? "error";
|
|
892
1077
|
if (this.queryMode === "pipeline") {
|
|
893
1078
|
this.pipelineAdapter = createPipelineQueryAdapter(db, collectionPath);
|
|
894
1079
|
if (this.metaAdapter) {
|
|
@@ -902,6 +1087,7 @@ var GraphClientImpl = class {
|
|
|
902
1087
|
adapter;
|
|
903
1088
|
pipelineAdapter;
|
|
904
1089
|
queryMode;
|
|
1090
|
+
scanProtection;
|
|
905
1091
|
// Static mode
|
|
906
1092
|
staticRegistry;
|
|
907
1093
|
// Dynamic mode
|
|
@@ -910,6 +1096,8 @@ var GraphClientImpl = class {
|
|
|
910
1096
|
dynamicRegistry;
|
|
911
1097
|
metaAdapter;
|
|
912
1098
|
metaPipelineAdapter;
|
|
1099
|
+
// Subgraph scope tracking
|
|
1100
|
+
scopePath;
|
|
913
1101
|
// ---------------------------------------------------------------------------
|
|
914
1102
|
// Registry routing
|
|
915
1103
|
// ---------------------------------------------------------------------------
|
|
@@ -963,6 +1151,19 @@ var GraphClientImpl = class {
|
|
|
963
1151
|
}
|
|
964
1152
|
return this.adapter.query(filters, options);
|
|
965
1153
|
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Check whether a query's filter set is safe (matches a known index pattern).
|
|
1156
|
+
* Throws QuerySafetyError or logs a warning depending on scanProtection config.
|
|
1157
|
+
*/
|
|
1158
|
+
checkQuerySafety(filters, allowCollectionScan) {
|
|
1159
|
+
if (allowCollectionScan || this.scanProtection === "off") return;
|
|
1160
|
+
const result = analyzeQuerySafety(filters);
|
|
1161
|
+
if (result.safe) return;
|
|
1162
|
+
if (this.scanProtection === "error") {
|
|
1163
|
+
throw new QuerySafetyError(result.reason);
|
|
1164
|
+
}
|
|
1165
|
+
console.warn(`[firegraph] Query safety warning: ${result.reason}`);
|
|
1166
|
+
}
|
|
966
1167
|
// ---------------------------------------------------------------------------
|
|
967
1168
|
// GraphReader
|
|
968
1169
|
// ---------------------------------------------------------------------------
|
|
@@ -984,6 +1185,7 @@ var GraphClientImpl = class {
|
|
|
984
1185
|
const record = await this.adapter.getDoc(plan.docId);
|
|
985
1186
|
return record ? [record] : [];
|
|
986
1187
|
}
|
|
1188
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
987
1189
|
return this.executeQuery(plan.filters, plan.options);
|
|
988
1190
|
}
|
|
989
1191
|
async findNodes(params) {
|
|
@@ -992,6 +1194,7 @@ var GraphClientImpl = class {
|
|
|
992
1194
|
const record = await this.adapter.getDoc(plan.docId);
|
|
993
1195
|
return record ? [record] : [];
|
|
994
1196
|
}
|
|
1197
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
995
1198
|
return this.executeQuery(plan.filters, plan.options);
|
|
996
1199
|
}
|
|
997
1200
|
// ---------------------------------------------------------------------------
|
|
@@ -1000,7 +1203,7 @@ var GraphClientImpl = class {
|
|
|
1000
1203
|
async putNode(aType, uid, data) {
|
|
1001
1204
|
const registry = this.getRegistryForType(aType);
|
|
1002
1205
|
if (registry) {
|
|
1003
|
-
registry.validate(aType, NODE_RELATION, aType, data);
|
|
1206
|
+
registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
1004
1207
|
}
|
|
1005
1208
|
const adapter = this.getAdapterForType(aType);
|
|
1006
1209
|
const docId = computeNodeDocId(uid);
|
|
@@ -1010,7 +1213,7 @@ var GraphClientImpl = class {
|
|
|
1010
1213
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
1011
1214
|
const registry = this.getRegistryForType(aType);
|
|
1012
1215
|
if (registry) {
|
|
1013
|
-
registry.validate(aType, axbType, bType, data);
|
|
1216
|
+
registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
1014
1217
|
}
|
|
1015
1218
|
const adapter = this.getAdapterForType(aType);
|
|
1016
1219
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
@@ -1042,13 +1245,42 @@ var GraphClientImpl = class {
|
|
|
1042
1245
|
this.adapter.collectionPath,
|
|
1043
1246
|
firestoreTx
|
|
1044
1247
|
);
|
|
1045
|
-
const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
|
|
1248
|
+
const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
|
|
1046
1249
|
return fn(graphTx);
|
|
1047
1250
|
});
|
|
1048
1251
|
}
|
|
1049
1252
|
batch() {
|
|
1050
1253
|
const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
|
|
1051
|
-
return new GraphBatchImpl(adapter, this.getCombinedRegistry());
|
|
1254
|
+
return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
|
|
1255
|
+
}
|
|
1256
|
+
// ---------------------------------------------------------------------------
|
|
1257
|
+
// Subgraph
|
|
1258
|
+
// ---------------------------------------------------------------------------
|
|
1259
|
+
subgraph(parentNodeUid, name = "graph") {
|
|
1260
|
+
if (!parentNodeUid || parentNodeUid.includes("/")) {
|
|
1261
|
+
throw new FiregraphError(
|
|
1262
|
+
`Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
|
|
1263
|
+
"INVALID_SUBGRAPH"
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
if (name.includes("/")) {
|
|
1267
|
+
throw new FiregraphError(
|
|
1268
|
+
`Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
|
|
1269
|
+
"INVALID_SUBGRAPH"
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
|
|
1273
|
+
const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
|
|
1274
|
+
return new _GraphClientImpl(
|
|
1275
|
+
this.db,
|
|
1276
|
+
subCollectionPath,
|
|
1277
|
+
{
|
|
1278
|
+
registry: this.getCombinedRegistry(),
|
|
1279
|
+
queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
|
|
1280
|
+
scanProtection: this.scanProtection
|
|
1281
|
+
},
|
|
1282
|
+
newScopePath
|
|
1283
|
+
);
|
|
1052
1284
|
}
|
|
1053
1285
|
// ---------------------------------------------------------------------------
|
|
1054
1286
|
// Bulk operations
|
|
@@ -1080,6 +1312,7 @@ var GraphClientImpl = class {
|
|
|
1080
1312
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
1081
1313
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
1082
1314
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
1315
|
+
if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
|
|
1083
1316
|
await this.putNode(META_NODE_TYPE, uid, data);
|
|
1084
1317
|
}
|
|
1085
1318
|
async defineEdgeType(name, topology, jsonSchema, description, options) {
|
|
@@ -1106,6 +1339,7 @@ var GraphClientImpl = class {
|
|
|
1106
1339
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
1107
1340
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
1108
1341
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
1342
|
+
if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
|
|
1109
1343
|
await this.putNode(META_EDGE_TYPE, uid, data);
|
|
1110
1344
|
}
|
|
1111
1345
|
async reloadRegistry() {
|
|
@@ -1264,7 +1498,9 @@ var TraversalBuilderImpl = class {
|
|
|
1264
1498
|
}
|
|
1265
1499
|
if (hop.orderBy) params.orderBy = hop.orderBy;
|
|
1266
1500
|
const limit = hop.limit ?? DEFAULT_LIMIT;
|
|
1267
|
-
if (
|
|
1501
|
+
if (hop.filter) {
|
|
1502
|
+
params.limit = 0;
|
|
1503
|
+
} else {
|
|
1268
1504
|
params.limit = limit;
|
|
1269
1505
|
}
|
|
1270
1506
|
let edges = await this.reader.findEdges(params);
|
|
@@ -1515,7 +1751,8 @@ function loadNodeEntity(dir, name) {
|
|
|
1515
1751
|
subtitleField: meta?.subtitleField,
|
|
1516
1752
|
viewDefaults: meta?.viewDefaults,
|
|
1517
1753
|
viewsPath,
|
|
1518
|
-
sampleData
|
|
1754
|
+
sampleData,
|
|
1755
|
+
allowedIn: meta?.allowedIn
|
|
1519
1756
|
};
|
|
1520
1757
|
}
|
|
1521
1758
|
function loadEdgeEntity(dir, name) {
|
|
@@ -1550,7 +1787,8 @@ function loadEdgeEntity(dir, name) {
|
|
|
1550
1787
|
subtitleField: meta?.subtitleField,
|
|
1551
1788
|
viewDefaults: meta?.viewDefaults,
|
|
1552
1789
|
viewsPath,
|
|
1553
|
-
sampleData
|
|
1790
|
+
sampleData,
|
|
1791
|
+
allowedIn: meta?.allowedIn
|
|
1554
1792
|
};
|
|
1555
1793
|
}
|
|
1556
1794
|
function getSubdirectories(dir) {
|
|
@@ -1636,6 +1874,83 @@ async function generateTypes(discovery, options = {}) {
|
|
|
1636
1874
|
return chunks.join("\n").trimEnd() + "\n";
|
|
1637
1875
|
}
|
|
1638
1876
|
|
|
1877
|
+
// src/indexes.ts
|
|
1878
|
+
function baseIndexes(collection) {
|
|
1879
|
+
return [
|
|
1880
|
+
{
|
|
1881
|
+
collectionGroup: collection,
|
|
1882
|
+
queryScope: "COLLECTION",
|
|
1883
|
+
fields: [
|
|
1884
|
+
{ fieldPath: "aUid", order: "ASCENDING" },
|
|
1885
|
+
{ fieldPath: "axbType", order: "ASCENDING" }
|
|
1886
|
+
]
|
|
1887
|
+
},
|
|
1888
|
+
{
|
|
1889
|
+
collectionGroup: collection,
|
|
1890
|
+
queryScope: "COLLECTION",
|
|
1891
|
+
fields: [
|
|
1892
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1893
|
+
{ fieldPath: "bUid", order: "ASCENDING" }
|
|
1894
|
+
]
|
|
1895
|
+
},
|
|
1896
|
+
{
|
|
1897
|
+
collectionGroup: collection,
|
|
1898
|
+
queryScope: "COLLECTION",
|
|
1899
|
+
fields: [
|
|
1900
|
+
{ fieldPath: "aType", order: "ASCENDING" },
|
|
1901
|
+
{ fieldPath: "axbType", order: "ASCENDING" }
|
|
1902
|
+
]
|
|
1903
|
+
},
|
|
1904
|
+
{
|
|
1905
|
+
collectionGroup: collection,
|
|
1906
|
+
queryScope: "COLLECTION",
|
|
1907
|
+
fields: [
|
|
1908
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1909
|
+
{ fieldPath: "bType", order: "ASCENDING" }
|
|
1910
|
+
]
|
|
1911
|
+
}
|
|
1912
|
+
];
|
|
1913
|
+
}
|
|
1914
|
+
function extractSchemaFields(schema) {
|
|
1915
|
+
const s = schema;
|
|
1916
|
+
if (s.type !== "object" || !s.properties) return [];
|
|
1917
|
+
return Object.keys(s.properties);
|
|
1918
|
+
}
|
|
1919
|
+
function generateIndexConfig(collection, entities) {
|
|
1920
|
+
const indexes = baseIndexes(collection);
|
|
1921
|
+
if (entities) {
|
|
1922
|
+
for (const [, entity] of entities.nodes) {
|
|
1923
|
+
const fields = extractSchemaFields(entity.schema);
|
|
1924
|
+
for (const field of fields) {
|
|
1925
|
+
indexes.push({
|
|
1926
|
+
collectionGroup: collection,
|
|
1927
|
+
queryScope: "COLLECTION",
|
|
1928
|
+
fields: [
|
|
1929
|
+
{ fieldPath: "aType", order: "ASCENDING" },
|
|
1930
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1931
|
+
{ fieldPath: `data.${field}`, order: "ASCENDING" }
|
|
1932
|
+
]
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
for (const [, entity] of entities.edges) {
|
|
1937
|
+
const fields = extractSchemaFields(entity.schema);
|
|
1938
|
+
for (const field of fields) {
|
|
1939
|
+
indexes.push({
|
|
1940
|
+
collectionGroup: collection,
|
|
1941
|
+
queryScope: "COLLECTION",
|
|
1942
|
+
fields: [
|
|
1943
|
+
{ fieldPath: "aUid", order: "ASCENDING" },
|
|
1944
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1945
|
+
{ fieldPath: `data.${field}`, order: "ASCENDING" }
|
|
1946
|
+
]
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
return { indexes, fieldOverrides: [] };
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1639
1954
|
// src/query-client/client.ts
|
|
1640
1955
|
var import_node_http = __toESM(require("http"), 1);
|
|
1641
1956
|
|
|
@@ -1914,6 +2229,7 @@ var QueryClient = class {
|
|
|
1914
2229
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1915
2230
|
0 && (module.exports = {
|
|
1916
2231
|
BOOTSTRAP_ENTRIES,
|
|
2232
|
+
DEFAULT_QUERY_LIMIT,
|
|
1917
2233
|
DiscoveryError,
|
|
1918
2234
|
DynamicRegistryError,
|
|
1919
2235
|
EDGE_TYPE_SCHEMA,
|
|
@@ -1926,9 +2242,12 @@ var QueryClient = class {
|
|
|
1926
2242
|
NodeNotFoundError,
|
|
1927
2243
|
QueryClient,
|
|
1928
2244
|
QueryClientError,
|
|
2245
|
+
QuerySafetyError,
|
|
2246
|
+
RegistryScopeError,
|
|
1929
2247
|
RegistryViolationError,
|
|
1930
2248
|
TraversalError,
|
|
1931
2249
|
ValidationError,
|
|
2250
|
+
analyzeQuerySafety,
|
|
1932
2251
|
buildEdgeQueryPlan,
|
|
1933
2252
|
buildEdgeRecord,
|
|
1934
2253
|
buildNodeQueryPlan,
|
|
@@ -1946,8 +2265,11 @@ var QueryClient = class {
|
|
|
1946
2265
|
discoverEntities,
|
|
1947
2266
|
generateDeterministicUid,
|
|
1948
2267
|
generateId,
|
|
2268
|
+
generateIndexConfig,
|
|
1949
2269
|
generateTypes,
|
|
1950
2270
|
jsonSchemaToFieldMeta,
|
|
2271
|
+
matchScope,
|
|
2272
|
+
matchScopeAny,
|
|
1951
2273
|
resolveView
|
|
1952
2274
|
});
|
|
1953
2275
|
//# sourceMappingURL=index.cjs.map
|