@typicalday/firegraph 0.1.0 → 0.3.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 +253 -6
- 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 +326 -38
- package/dist/{index-CG3R68Hu.d.cts → index-CQkofEC_.d.cts} +122 -2
- package/dist/{index-CG3R68Hu.d.ts → index-CQkofEC_.d.ts} +122 -2
- package/dist/index.cjs +557 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +157 -4
- package/dist/index.d.ts +157 -4
- package/dist/index.js +548 -59
- package/dist/index.js.map +1 -1
- package/package.json +25 -23
package/dist/index.js
CHANGED
|
@@ -14,6 +14,16 @@ import { createHash } from "crypto";
|
|
|
14
14
|
|
|
15
15
|
// src/internal/constants.ts
|
|
16
16
|
var NODE_RELATION = "is";
|
|
17
|
+
var DEFAULT_QUERY_LIMIT = 500;
|
|
18
|
+
var BUILTIN_FIELDS = /* @__PURE__ */ new Set([
|
|
19
|
+
"aType",
|
|
20
|
+
"aUid",
|
|
21
|
+
"axbType",
|
|
22
|
+
"bType",
|
|
23
|
+
"bUid",
|
|
24
|
+
"createdAt",
|
|
25
|
+
"updatedAt"
|
|
26
|
+
]);
|
|
17
27
|
var SHARD_SEPARATOR = ":";
|
|
18
28
|
|
|
19
29
|
// src/docid.ts
|
|
@@ -110,6 +120,21 @@ var DynamicRegistryError = class extends FiregraphError {
|
|
|
110
120
|
this.name = "DynamicRegistryError";
|
|
111
121
|
}
|
|
112
122
|
};
|
|
123
|
+
var QuerySafetyError = class extends FiregraphError {
|
|
124
|
+
constructor(message) {
|
|
125
|
+
super(message, "QUERY_SAFETY");
|
|
126
|
+
this.name = "QuerySafetyError";
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
var RegistryScopeError = class extends FiregraphError {
|
|
130
|
+
constructor(aType, axbType, bType, scopePath, allowedIn) {
|
|
131
|
+
super(
|
|
132
|
+
`Type (${aType}) -[${axbType}]-> (${bType}) is not allowed at scope "${scopePath || "root"}". Allowed in: [${allowedIn.join(", ")}]`,
|
|
133
|
+
"REGISTRY_SCOPE"
|
|
134
|
+
);
|
|
135
|
+
this.name = "RegistryScopeError";
|
|
136
|
+
}
|
|
137
|
+
};
|
|
113
138
|
|
|
114
139
|
// src/query.ts
|
|
115
140
|
function buildEdgeQueryPlan(params) {
|
|
@@ -123,27 +148,32 @@ function buildEdgeQueryPlan(params) {
|
|
|
123
148
|
if (axbType) filters.push({ field: "axbType", op: "==", value: axbType });
|
|
124
149
|
if (bType) filters.push({ field: "bType", op: "==", value: bType });
|
|
125
150
|
if (bUid) filters.push({ field: "bUid", op: "==", value: bUid });
|
|
126
|
-
const builtinFields = ["aType", "aUid", "axbType", "bType", "bUid", "createdAt", "updatedAt"];
|
|
127
151
|
if (params.where) {
|
|
128
152
|
for (const clause of params.where) {
|
|
129
|
-
const field =
|
|
153
|
+
const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
|
|
130
154
|
filters.push({ field, op: clause.op, value: clause.value });
|
|
131
155
|
}
|
|
132
156
|
}
|
|
133
157
|
if (filters.length === 0) {
|
|
134
158
|
throw new InvalidQueryError("findEdges requires at least one filter parameter");
|
|
135
159
|
}
|
|
136
|
-
const
|
|
137
|
-
return { strategy: "query", filters, options };
|
|
160
|
+
const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
|
|
161
|
+
return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
|
|
138
162
|
}
|
|
139
163
|
function buildNodeQueryPlan(params) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
164
|
+
const { aType, limit, orderBy } = params;
|
|
165
|
+
const filters = [
|
|
166
|
+
{ field: "aType", op: "==", value: aType },
|
|
167
|
+
{ field: "axbType", op: "==", value: NODE_RELATION }
|
|
168
|
+
];
|
|
169
|
+
if (params.where) {
|
|
170
|
+
for (const clause of params.where) {
|
|
171
|
+
const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
|
|
172
|
+
filters.push({ field, op: clause.op, value: clause.value });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
|
|
176
|
+
return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
|
|
147
177
|
}
|
|
148
178
|
|
|
149
179
|
// src/internal/firestore-adapter.ts
|
|
@@ -296,10 +326,62 @@ function createPipelineQueryAdapter(db, collectionPath) {
|
|
|
296
326
|
|
|
297
327
|
// src/transaction.ts
|
|
298
328
|
import { FieldValue as FieldValue2 } from "@google-cloud/firestore";
|
|
329
|
+
|
|
330
|
+
// src/query-safety.ts
|
|
331
|
+
var SAFE_INDEX_PATTERNS = [
|
|
332
|
+
/* @__PURE__ */ new Set(["aUid", "axbType"]),
|
|
333
|
+
/* @__PURE__ */ new Set(["axbType", "bUid"]),
|
|
334
|
+
/* @__PURE__ */ new Set(["aType", "axbType"]),
|
|
335
|
+
/* @__PURE__ */ new Set(["axbType", "bType"])
|
|
336
|
+
];
|
|
337
|
+
function analyzeQuerySafety(filters) {
|
|
338
|
+
const builtinFieldsPresent = /* @__PURE__ */ new Set();
|
|
339
|
+
let hasDataFilters = false;
|
|
340
|
+
for (const f of filters) {
|
|
341
|
+
if (BUILTIN_FIELDS.has(f.field)) {
|
|
342
|
+
builtinFieldsPresent.add(f.field);
|
|
343
|
+
} else {
|
|
344
|
+
hasDataFilters = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const pattern of SAFE_INDEX_PATTERNS) {
|
|
348
|
+
let matched = true;
|
|
349
|
+
for (const field of pattern) {
|
|
350
|
+
if (!builtinFieldsPresent.has(field)) {
|
|
351
|
+
matched = false;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (matched) {
|
|
356
|
+
return { safe: true };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const presentFields = [...builtinFieldsPresent];
|
|
360
|
+
if (presentFields.length === 0 && hasDataFilters) {
|
|
361
|
+
return {
|
|
362
|
+
safe: false,
|
|
363
|
+
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."
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (hasDataFilters) {
|
|
367
|
+
return {
|
|
368
|
+
safe: false,
|
|
369
|
+
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.`
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
safe: false,
|
|
374
|
+
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.`
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/transaction.ts
|
|
299
379
|
var GraphTransactionImpl = class {
|
|
300
|
-
constructor(adapter, registry) {
|
|
380
|
+
constructor(adapter, registry, scanProtection = "error", scopePath = "") {
|
|
301
381
|
this.adapter = adapter;
|
|
302
382
|
this.registry = registry;
|
|
383
|
+
this.scanProtection = scanProtection;
|
|
384
|
+
this.scopePath = scopePath;
|
|
303
385
|
}
|
|
304
386
|
async getNode(uid) {
|
|
305
387
|
const docId = computeNodeDocId(uid);
|
|
@@ -313,12 +395,22 @@ var GraphTransactionImpl = class {
|
|
|
313
395
|
const record = await this.getEdge(aUid, axbType, bUid);
|
|
314
396
|
return record !== null;
|
|
315
397
|
}
|
|
398
|
+
checkQuerySafety(filters, allowCollectionScan) {
|
|
399
|
+
if (allowCollectionScan || this.scanProtection === "off") return;
|
|
400
|
+
const result = analyzeQuerySafety(filters);
|
|
401
|
+
if (result.safe) return;
|
|
402
|
+
if (this.scanProtection === "error") {
|
|
403
|
+
throw new QuerySafetyError(result.reason);
|
|
404
|
+
}
|
|
405
|
+
console.warn(`[firegraph] Query safety warning: ${result.reason}`);
|
|
406
|
+
}
|
|
316
407
|
async findEdges(params) {
|
|
317
408
|
const plan = buildEdgeQueryPlan(params);
|
|
318
409
|
if (plan.strategy === "get") {
|
|
319
410
|
const record = await this.adapter.getDoc(plan.docId);
|
|
320
411
|
return record ? [record] : [];
|
|
321
412
|
}
|
|
413
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
322
414
|
return this.adapter.query(plan.filters, plan.options);
|
|
323
415
|
}
|
|
324
416
|
async findNodes(params) {
|
|
@@ -327,11 +419,12 @@ var GraphTransactionImpl = class {
|
|
|
327
419
|
const record = await this.adapter.getDoc(plan.docId);
|
|
328
420
|
return record ? [record] : [];
|
|
329
421
|
}
|
|
422
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
330
423
|
return this.adapter.query(plan.filters, plan.options);
|
|
331
424
|
}
|
|
332
425
|
async putNode(aType, uid, data) {
|
|
333
426
|
if (this.registry) {
|
|
334
|
-
this.registry.validate(aType, NODE_RELATION, aType, data);
|
|
427
|
+
this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
335
428
|
}
|
|
336
429
|
const docId = computeNodeDocId(uid);
|
|
337
430
|
const record = buildNodeRecord(aType, uid, data);
|
|
@@ -339,7 +432,7 @@ var GraphTransactionImpl = class {
|
|
|
339
432
|
}
|
|
340
433
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
341
434
|
if (this.registry) {
|
|
342
|
-
this.registry.validate(aType, axbType, bType, data);
|
|
435
|
+
this.registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
343
436
|
}
|
|
344
437
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
345
438
|
const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
|
|
@@ -365,13 +458,14 @@ var GraphTransactionImpl = class {
|
|
|
365
458
|
// src/batch.ts
|
|
366
459
|
import { FieldValue as FieldValue3 } from "@google-cloud/firestore";
|
|
367
460
|
var GraphBatchImpl = class {
|
|
368
|
-
constructor(adapter, registry) {
|
|
461
|
+
constructor(adapter, registry, scopePath = "") {
|
|
369
462
|
this.adapter = adapter;
|
|
370
463
|
this.registry = registry;
|
|
464
|
+
this.scopePath = scopePath;
|
|
371
465
|
}
|
|
372
466
|
async putNode(aType, uid, data) {
|
|
373
467
|
if (this.registry) {
|
|
374
|
-
this.registry.validate(aType, NODE_RELATION, aType, data);
|
|
468
|
+
this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
375
469
|
}
|
|
376
470
|
const docId = computeNodeDocId(uid);
|
|
377
471
|
const record = buildNodeRecord(aType, uid, data);
|
|
@@ -379,7 +473,7 @@ var GraphBatchImpl = class {
|
|
|
379
473
|
}
|
|
380
474
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
381
475
|
if (this.registry) {
|
|
382
|
-
this.registry.validate(aType, axbType, bType, data);
|
|
476
|
+
this.registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
383
477
|
}
|
|
384
478
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
385
479
|
const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
|
|
@@ -471,14 +565,39 @@ async function bulkDeleteDocIds(db, collectionPath, docIds, options) {
|
|
|
471
565
|
return { deleted, batches: completedBatches, errors };
|
|
472
566
|
}
|
|
473
567
|
async function bulkRemoveEdges(db, collectionPath, reader, params, options) {
|
|
474
|
-
const
|
|
568
|
+
const effectiveParams = params.limit !== void 0 ? { ...params, allowCollectionScan: params.allowCollectionScan ?? true } : { ...params, limit: 0, allowCollectionScan: params.allowCollectionScan ?? true };
|
|
569
|
+
const edges = await reader.findEdges(effectiveParams);
|
|
475
570
|
const docIds = edges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
|
|
476
571
|
return bulkDeleteDocIds(db, collectionPath, docIds, options);
|
|
477
572
|
}
|
|
573
|
+
async function deleteSubcollectionsRecursive(db, collectionPath, docId, options) {
|
|
574
|
+
const docRef = db.collection(collectionPath).doc(docId);
|
|
575
|
+
const subcollections = await docRef.listCollections();
|
|
576
|
+
if (subcollections.length === 0) return { deleted: 0, errors: [] };
|
|
577
|
+
let totalDeleted = 0;
|
|
578
|
+
const allErrors = [];
|
|
579
|
+
const subOptions = options ? { batchSize: options.batchSize, maxRetries: options.maxRetries } : void 0;
|
|
580
|
+
for (const subCollRef of subcollections) {
|
|
581
|
+
const subCollPath = subCollRef.path;
|
|
582
|
+
const snapshot = await subCollRef.select().get();
|
|
583
|
+
const subDocIds = snapshot.docs.map((d) => d.id);
|
|
584
|
+
for (const subDocId of subDocIds) {
|
|
585
|
+
const subResult = await deleteSubcollectionsRecursive(db, subCollPath, subDocId, subOptions);
|
|
586
|
+
totalDeleted += subResult.deleted;
|
|
587
|
+
allErrors.push(...subResult.errors);
|
|
588
|
+
}
|
|
589
|
+
if (subDocIds.length > 0) {
|
|
590
|
+
const result = await bulkDeleteDocIds(db, subCollPath, subDocIds, subOptions);
|
|
591
|
+
totalDeleted += result.deleted;
|
|
592
|
+
allErrors.push(...result.errors);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { deleted: totalDeleted, errors: allErrors };
|
|
596
|
+
}
|
|
478
597
|
async function removeNodeCascade(db, collectionPath, reader, uid, options) {
|
|
479
598
|
const [outgoingRaw, incomingRaw] = await Promise.all([
|
|
480
|
-
reader.findEdges({ aUid: uid }),
|
|
481
|
-
reader.findEdges({ bUid: uid })
|
|
599
|
+
reader.findEdges({ aUid: uid, allowCollectionScan: true, limit: 0 }),
|
|
600
|
+
reader.findEdges({ bUid: uid, allowCollectionScan: true, limit: 0 })
|
|
482
601
|
]);
|
|
483
602
|
const outgoing = outgoingRaw.filter((e) => e.axbType !== NODE_RELATION);
|
|
484
603
|
const incoming = incomingRaw.filter((e) => e.axbType !== NODE_RELATION);
|
|
@@ -491,8 +610,18 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
|
|
|
491
610
|
allEdges.push(edge);
|
|
492
611
|
}
|
|
493
612
|
}
|
|
494
|
-
const
|
|
613
|
+
const shouldDeleteSubcollections = options?.deleteSubcollections !== false;
|
|
495
614
|
const nodeDocId = computeNodeDocId(uid);
|
|
615
|
+
let subcollectionResult = { deleted: 0, errors: [] };
|
|
616
|
+
if (shouldDeleteSubcollections) {
|
|
617
|
+
subcollectionResult = await deleteSubcollectionsRecursive(
|
|
618
|
+
db,
|
|
619
|
+
collectionPath,
|
|
620
|
+
nodeDocId,
|
|
621
|
+
options
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
|
|
496
625
|
const allDocIds = [...edgeDocIds, nodeDocId];
|
|
497
626
|
const batchSize = Math.min(options?.batchSize ?? MAX_BATCH_SIZE, MAX_BATCH_SIZE);
|
|
498
627
|
const result = await bulkDeleteDocIds(db, collectionPath, allDocIds, {
|
|
@@ -502,9 +631,12 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
|
|
|
502
631
|
const totalChunks = Math.ceil(allDocIds.length / batchSize);
|
|
503
632
|
const nodeChunkIndex = totalChunks - 1;
|
|
504
633
|
const nodeDeleted = !result.errors.some((e) => e.batchIndex === nodeChunkIndex);
|
|
634
|
+
const topLevelEdgesDeleted = nodeDeleted ? result.deleted - 1 : result.deleted;
|
|
505
635
|
return {
|
|
506
|
-
|
|
507
|
-
|
|
636
|
+
deleted: result.deleted + subcollectionResult.deleted,
|
|
637
|
+
batches: result.batches,
|
|
638
|
+
errors: [...result.errors, ...subcollectionResult.errors],
|
|
639
|
+
edgesDeleted: topLevelEdgesDeleted,
|
|
508
640
|
nodeDeleted
|
|
509
641
|
};
|
|
510
642
|
}
|
|
@@ -604,6 +736,39 @@ function propertyToFieldMeta(name, prop, required) {
|
|
|
604
736
|
return { name, type: "unknown", required, description: prop.description };
|
|
605
737
|
}
|
|
606
738
|
|
|
739
|
+
// src/scope.ts
|
|
740
|
+
function matchScope(scopePath, pattern) {
|
|
741
|
+
if (pattern === "root") return scopePath === "";
|
|
742
|
+
if (pattern === "**") return true;
|
|
743
|
+
const pathSegments = scopePath === "" ? [] : scopePath.split("/");
|
|
744
|
+
const patternSegments = pattern.split("/");
|
|
745
|
+
return matchSegments(pathSegments, 0, patternSegments, 0);
|
|
746
|
+
}
|
|
747
|
+
function matchScopeAny(scopePath, patterns) {
|
|
748
|
+
if (!patterns || patterns.length === 0) return true;
|
|
749
|
+
return patterns.some((p) => matchScope(scopePath, p));
|
|
750
|
+
}
|
|
751
|
+
function matchSegments(path, pi, pattern, qi) {
|
|
752
|
+
if (pi === path.length && qi === pattern.length) return true;
|
|
753
|
+
if (qi === pattern.length) return false;
|
|
754
|
+
const seg = pattern[qi];
|
|
755
|
+
if (seg === "**") {
|
|
756
|
+
if (qi === pattern.length - 1) return true;
|
|
757
|
+
for (let skip = 0; skip <= path.length - pi; skip++) {
|
|
758
|
+
if (matchSegments(path, pi + skip, pattern, qi + 1)) return true;
|
|
759
|
+
}
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
if (pi === path.length) return false;
|
|
763
|
+
if (seg === "*") {
|
|
764
|
+
return matchSegments(path, pi + 1, pattern, qi + 1);
|
|
765
|
+
}
|
|
766
|
+
if (path[pi] === seg) {
|
|
767
|
+
return matchSegments(path, pi + 1, pattern, qi + 1);
|
|
768
|
+
}
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
|
|
607
772
|
// src/registry.ts
|
|
608
773
|
function tripleKey(aType, axbType, bType) {
|
|
609
774
|
return `${aType}:${axbType}:${bType}`;
|
|
@@ -618,19 +783,45 @@ function createRegistry(input) {
|
|
|
618
783
|
}
|
|
619
784
|
const entryList = Object.freeze([...entries]);
|
|
620
785
|
for (const entry of entries) {
|
|
786
|
+
if (entry.targetGraph && entry.targetGraph.includes("/")) {
|
|
787
|
+
throw new ValidationError(
|
|
788
|
+
`Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType}) has invalid targetGraph "${entry.targetGraph}" \u2014 must be a single segment (no "/")`
|
|
789
|
+
);
|
|
790
|
+
}
|
|
621
791
|
const key = tripleKey(entry.aType, entry.axbType, entry.bType);
|
|
622
792
|
const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
|
|
623
793
|
map.set(key, { entry, validate: validator });
|
|
624
794
|
}
|
|
795
|
+
const axbIndex = /* @__PURE__ */ new Map();
|
|
796
|
+
const axbBuild = /* @__PURE__ */ new Map();
|
|
797
|
+
for (const entry of entries) {
|
|
798
|
+
const existing = axbBuild.get(entry.axbType);
|
|
799
|
+
if (existing) {
|
|
800
|
+
existing.push(entry);
|
|
801
|
+
} else {
|
|
802
|
+
axbBuild.set(entry.axbType, [entry]);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
for (const [key, arr] of axbBuild) {
|
|
806
|
+
axbIndex.set(key, Object.freeze(arr));
|
|
807
|
+
}
|
|
625
808
|
return {
|
|
626
809
|
lookup(aType, axbType, bType) {
|
|
627
810
|
return map.get(tripleKey(aType, axbType, bType))?.entry;
|
|
628
811
|
},
|
|
629
|
-
|
|
812
|
+
lookupByAxbType(axbType) {
|
|
813
|
+
return axbIndex.get(axbType) ?? [];
|
|
814
|
+
},
|
|
815
|
+
validate(aType, axbType, bType, data, scopePath) {
|
|
630
816
|
const rec = map.get(tripleKey(aType, axbType, bType));
|
|
631
817
|
if (!rec) {
|
|
632
818
|
throw new RegistryViolationError(aType, axbType, bType);
|
|
633
819
|
}
|
|
820
|
+
if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
|
|
821
|
+
if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
|
|
822
|
+
throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
634
825
|
if (rec.validate) {
|
|
635
826
|
try {
|
|
636
827
|
rec.validate(data);
|
|
@@ -658,7 +849,8 @@ function discoveryToEntries(discovery) {
|
|
|
658
849
|
jsonSchema: entity.schema,
|
|
659
850
|
description: entity.description,
|
|
660
851
|
titleField: entity.titleField,
|
|
661
|
-
subtitleField: entity.subtitleField
|
|
852
|
+
subtitleField: entity.subtitleField,
|
|
853
|
+
allowedIn: entity.allowedIn
|
|
662
854
|
});
|
|
663
855
|
}
|
|
664
856
|
for (const [axbType, entity] of discovery.edges) {
|
|
@@ -666,6 +858,12 @@ function discoveryToEntries(discovery) {
|
|
|
666
858
|
if (!topology) continue;
|
|
667
859
|
const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
|
|
668
860
|
const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
|
|
861
|
+
const resolvedTargetGraph = entity.targetGraph ?? topology.targetGraph;
|
|
862
|
+
if (resolvedTargetGraph && resolvedTargetGraph.includes("/")) {
|
|
863
|
+
throw new ValidationError(
|
|
864
|
+
`Edge "${axbType}" has invalid targetGraph "${resolvedTargetGraph}" \u2014 must be a single segment (no "/")`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
669
867
|
for (const aType of fromTypes) {
|
|
670
868
|
for (const bType of toTypes) {
|
|
671
869
|
entries.push({
|
|
@@ -676,7 +874,9 @@ function discoveryToEntries(discovery) {
|
|
|
676
874
|
description: entity.description,
|
|
677
875
|
inverseLabel: topology.inverseLabel,
|
|
678
876
|
titleField: entity.titleField,
|
|
679
|
-
subtitleField: entity.subtitleField
|
|
877
|
+
subtitleField: entity.subtitleField,
|
|
878
|
+
allowedIn: entity.allowedIn,
|
|
879
|
+
targetGraph: resolvedTargetGraph
|
|
680
880
|
});
|
|
681
881
|
}
|
|
682
882
|
}
|
|
@@ -697,7 +897,8 @@ var NODE_TYPE_SCHEMA = {
|
|
|
697
897
|
titleField: { type: "string" },
|
|
698
898
|
subtitleField: { type: "string" },
|
|
699
899
|
viewTemplate: { type: "string" },
|
|
700
|
-
viewCss: { type: "string" }
|
|
900
|
+
viewCss: { type: "string" },
|
|
901
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
|
|
701
902
|
},
|
|
702
903
|
additionalProperties: false
|
|
703
904
|
};
|
|
@@ -724,7 +925,9 @@ var EDGE_TYPE_SCHEMA = {
|
|
|
724
925
|
titleField: { type: "string" },
|
|
725
926
|
subtitleField: { type: "string" },
|
|
726
927
|
viewTemplate: { type: "string" },
|
|
727
|
-
viewCss: { type: "string" }
|
|
928
|
+
viewCss: { type: "string" },
|
|
929
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
|
|
930
|
+
targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" }
|
|
728
931
|
},
|
|
729
932
|
additionalProperties: false
|
|
730
933
|
};
|
|
@@ -766,7 +969,8 @@ async function createRegistryFromGraph(reader) {
|
|
|
766
969
|
jsonSchema: data.jsonSchema,
|
|
767
970
|
description: data.description,
|
|
768
971
|
titleField: data.titleField,
|
|
769
|
-
subtitleField: data.subtitleField
|
|
972
|
+
subtitleField: data.subtitleField,
|
|
973
|
+
allowedIn: data.allowedIn
|
|
770
974
|
});
|
|
771
975
|
}
|
|
772
976
|
for (const record of edgeTypes) {
|
|
@@ -783,7 +987,9 @@ async function createRegistryFromGraph(reader) {
|
|
|
783
987
|
description: data.description,
|
|
784
988
|
inverseLabel: data.inverseLabel,
|
|
785
989
|
titleField: data.titleField,
|
|
786
|
-
subtitleField: data.subtitleField
|
|
990
|
+
subtitleField: data.subtitleField,
|
|
991
|
+
allowedIn: data.allowedIn,
|
|
992
|
+
targetGraph: data.targetGraph
|
|
787
993
|
});
|
|
788
994
|
}
|
|
789
995
|
}
|
|
@@ -794,9 +1000,10 @@ async function createRegistryFromGraph(reader) {
|
|
|
794
1000
|
// src/client.ts
|
|
795
1001
|
var _standardModeWarned = false;
|
|
796
1002
|
var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
|
|
797
|
-
var GraphClientImpl = class {
|
|
798
|
-
constructor(db, collectionPath, options) {
|
|
1003
|
+
var GraphClientImpl = class _GraphClientImpl {
|
|
1004
|
+
constructor(db, collectionPath, options, scopePath = "") {
|
|
799
1005
|
this.db = db;
|
|
1006
|
+
this.scopePath = scopePath;
|
|
800
1007
|
this.adapter = createFirestoreAdapter(db, collectionPath);
|
|
801
1008
|
if (options?.registry && options?.registryMode) {
|
|
802
1009
|
throw new DynamicRegistryError(
|
|
@@ -826,6 +1033,7 @@ var GraphClientImpl = class {
|
|
|
826
1033
|
"[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"
|
|
827
1034
|
);
|
|
828
1035
|
}
|
|
1036
|
+
this.scanProtection = options?.scanProtection ?? "error";
|
|
829
1037
|
if (this.queryMode === "pipeline") {
|
|
830
1038
|
this.pipelineAdapter = createPipelineQueryAdapter(db, collectionPath);
|
|
831
1039
|
if (this.metaAdapter) {
|
|
@@ -839,6 +1047,7 @@ var GraphClientImpl = class {
|
|
|
839
1047
|
adapter;
|
|
840
1048
|
pipelineAdapter;
|
|
841
1049
|
queryMode;
|
|
1050
|
+
scanProtection;
|
|
842
1051
|
// Static mode
|
|
843
1052
|
staticRegistry;
|
|
844
1053
|
// Dynamic mode
|
|
@@ -847,6 +1056,8 @@ var GraphClientImpl = class {
|
|
|
847
1056
|
dynamicRegistry;
|
|
848
1057
|
metaAdapter;
|
|
849
1058
|
metaPipelineAdapter;
|
|
1059
|
+
// Subgraph scope tracking
|
|
1060
|
+
scopePath;
|
|
850
1061
|
// ---------------------------------------------------------------------------
|
|
851
1062
|
// Registry routing
|
|
852
1063
|
// ---------------------------------------------------------------------------
|
|
@@ -900,6 +1111,19 @@ var GraphClientImpl = class {
|
|
|
900
1111
|
}
|
|
901
1112
|
return this.adapter.query(filters, options);
|
|
902
1113
|
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Check whether a query's filter set is safe (matches a known index pattern).
|
|
1116
|
+
* Throws QuerySafetyError or logs a warning depending on scanProtection config.
|
|
1117
|
+
*/
|
|
1118
|
+
checkQuerySafety(filters, allowCollectionScan) {
|
|
1119
|
+
if (allowCollectionScan || this.scanProtection === "off") return;
|
|
1120
|
+
const result = analyzeQuerySafety(filters);
|
|
1121
|
+
if (result.safe) return;
|
|
1122
|
+
if (this.scanProtection === "error") {
|
|
1123
|
+
throw new QuerySafetyError(result.reason);
|
|
1124
|
+
}
|
|
1125
|
+
console.warn(`[firegraph] Query safety warning: ${result.reason}`);
|
|
1126
|
+
}
|
|
903
1127
|
// ---------------------------------------------------------------------------
|
|
904
1128
|
// GraphReader
|
|
905
1129
|
// ---------------------------------------------------------------------------
|
|
@@ -921,6 +1145,7 @@ var GraphClientImpl = class {
|
|
|
921
1145
|
const record = await this.adapter.getDoc(plan.docId);
|
|
922
1146
|
return record ? [record] : [];
|
|
923
1147
|
}
|
|
1148
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
924
1149
|
return this.executeQuery(plan.filters, plan.options);
|
|
925
1150
|
}
|
|
926
1151
|
async findNodes(params) {
|
|
@@ -929,6 +1154,7 @@ var GraphClientImpl = class {
|
|
|
929
1154
|
const record = await this.adapter.getDoc(plan.docId);
|
|
930
1155
|
return record ? [record] : [];
|
|
931
1156
|
}
|
|
1157
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
932
1158
|
return this.executeQuery(plan.filters, plan.options);
|
|
933
1159
|
}
|
|
934
1160
|
// ---------------------------------------------------------------------------
|
|
@@ -937,7 +1163,7 @@ var GraphClientImpl = class {
|
|
|
937
1163
|
async putNode(aType, uid, data) {
|
|
938
1164
|
const registry = this.getRegistryForType(aType);
|
|
939
1165
|
if (registry) {
|
|
940
|
-
registry.validate(aType, NODE_RELATION, aType, data);
|
|
1166
|
+
registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
941
1167
|
}
|
|
942
1168
|
const adapter = this.getAdapterForType(aType);
|
|
943
1169
|
const docId = computeNodeDocId(uid);
|
|
@@ -947,7 +1173,7 @@ var GraphClientImpl = class {
|
|
|
947
1173
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
948
1174
|
const registry = this.getRegistryForType(aType);
|
|
949
1175
|
if (registry) {
|
|
950
|
-
registry.validate(aType, axbType, bType, data);
|
|
1176
|
+
registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
951
1177
|
}
|
|
952
1178
|
const adapter = this.getAdapterForType(aType);
|
|
953
1179
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
@@ -979,13 +1205,69 @@ var GraphClientImpl = class {
|
|
|
979
1205
|
this.adapter.collectionPath,
|
|
980
1206
|
firestoreTx
|
|
981
1207
|
);
|
|
982
|
-
const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
|
|
1208
|
+
const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
|
|
983
1209
|
return fn(graphTx);
|
|
984
1210
|
});
|
|
985
1211
|
}
|
|
986
1212
|
batch() {
|
|
987
1213
|
const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
|
|
988
|
-
return new GraphBatchImpl(adapter, this.getCombinedRegistry());
|
|
1214
|
+
return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
|
|
1215
|
+
}
|
|
1216
|
+
// ---------------------------------------------------------------------------
|
|
1217
|
+
// Subgraph
|
|
1218
|
+
// ---------------------------------------------------------------------------
|
|
1219
|
+
subgraph(parentNodeUid, name = "graph") {
|
|
1220
|
+
if (!parentNodeUid || parentNodeUid.includes("/")) {
|
|
1221
|
+
throw new FiregraphError(
|
|
1222
|
+
`Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
|
|
1223
|
+
"INVALID_SUBGRAPH"
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
if (name.includes("/")) {
|
|
1227
|
+
throw new FiregraphError(
|
|
1228
|
+
`Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
|
|
1229
|
+
"INVALID_SUBGRAPH"
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
|
|
1233
|
+
const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
|
|
1234
|
+
return new _GraphClientImpl(
|
|
1235
|
+
this.db,
|
|
1236
|
+
subCollectionPath,
|
|
1237
|
+
{
|
|
1238
|
+
registry: this.getCombinedRegistry(),
|
|
1239
|
+
queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
|
|
1240
|
+
scanProtection: this.scanProtection
|
|
1241
|
+
},
|
|
1242
|
+
newScopePath
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
// ---------------------------------------------------------------------------
|
|
1246
|
+
// Collection group query
|
|
1247
|
+
// ---------------------------------------------------------------------------
|
|
1248
|
+
async findEdgesGlobal(params, collectionName) {
|
|
1249
|
+
const name = collectionName ?? this.adapter.collectionPath.split("/").pop();
|
|
1250
|
+
const plan = buildEdgeQueryPlan(params);
|
|
1251
|
+
if (plan.strategy === "get") {
|
|
1252
|
+
throw new FiregraphError(
|
|
1253
|
+
"findEdgesGlobal() requires a query, not a direct document lookup. Omit one of aUid/axbType/bUid to force a query strategy.",
|
|
1254
|
+
"INVALID_QUERY"
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
1258
|
+
const collectionGroupRef = this.db.collectionGroup(name);
|
|
1259
|
+
let q = collectionGroupRef;
|
|
1260
|
+
for (const f of plan.filters) {
|
|
1261
|
+
q = q.where(f.field, f.op, f.value);
|
|
1262
|
+
}
|
|
1263
|
+
if (plan.options?.orderBy) {
|
|
1264
|
+
q = q.orderBy(plan.options.orderBy.field, plan.options.orderBy.direction ?? "asc");
|
|
1265
|
+
}
|
|
1266
|
+
if (plan.options?.limit !== void 0) {
|
|
1267
|
+
q = q.limit(plan.options.limit);
|
|
1268
|
+
}
|
|
1269
|
+
const snap = await q.get();
|
|
1270
|
+
return snap.docs.map((doc) => doc.data());
|
|
989
1271
|
}
|
|
990
1272
|
// ---------------------------------------------------------------------------
|
|
991
1273
|
// Bulk operations
|
|
@@ -1017,6 +1299,7 @@ var GraphClientImpl = class {
|
|
|
1017
1299
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
1018
1300
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
1019
1301
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
1302
|
+
if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
|
|
1020
1303
|
await this.putNode(META_NODE_TYPE, uid, data);
|
|
1021
1304
|
}
|
|
1022
1305
|
async defineEdgeType(name, topology, jsonSchema, description, options) {
|
|
@@ -1038,11 +1321,13 @@ var GraphClientImpl = class {
|
|
|
1038
1321
|
};
|
|
1039
1322
|
if (jsonSchema !== void 0) data.jsonSchema = jsonSchema;
|
|
1040
1323
|
if (topology.inverseLabel !== void 0) data.inverseLabel = topology.inverseLabel;
|
|
1324
|
+
if (topology.targetGraph !== void 0) data.targetGraph = topology.targetGraph;
|
|
1041
1325
|
if (description !== void 0) data.description = description;
|
|
1042
1326
|
if (options?.titleField !== void 0) data.titleField = options.titleField;
|
|
1043
1327
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
1044
1328
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
1045
1329
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
1330
|
+
if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
|
|
1046
1331
|
await this.putNode(META_EDGE_TYPE, uid, data);
|
|
1047
1332
|
}
|
|
1048
1333
|
async reloadRegistry() {
|
|
@@ -1111,6 +1396,10 @@ function generateId() {
|
|
|
1111
1396
|
var DEFAULT_LIMIT = 10;
|
|
1112
1397
|
var DEFAULT_MAX_READS = 100;
|
|
1113
1398
|
var DEFAULT_CONCURRENCY = 5;
|
|
1399
|
+
var _crossGraphWarned = false;
|
|
1400
|
+
function isGraphClient(reader) {
|
|
1401
|
+
return "subgraph" in reader && typeof reader.subgraph === "function";
|
|
1402
|
+
}
|
|
1114
1403
|
var Semaphore = class {
|
|
1115
1404
|
constructor(slots) {
|
|
1116
1405
|
this.slots = slots;
|
|
@@ -1136,9 +1425,10 @@ var Semaphore = class {
|
|
|
1136
1425
|
}
|
|
1137
1426
|
};
|
|
1138
1427
|
var TraversalBuilderImpl = class {
|
|
1139
|
-
constructor(reader, startUid) {
|
|
1428
|
+
constructor(reader, startUid, registry) {
|
|
1140
1429
|
this.reader = reader;
|
|
1141
1430
|
this.startUid = startUid;
|
|
1431
|
+
this.registry = registry;
|
|
1142
1432
|
}
|
|
1143
1433
|
hops = [];
|
|
1144
1434
|
follow(axbType, options) {
|
|
@@ -1155,11 +1445,13 @@ var TraversalBuilderImpl = class {
|
|
|
1155
1445
|
const semaphore = new Semaphore(concurrency);
|
|
1156
1446
|
let totalReads = 0;
|
|
1157
1447
|
let truncated = false;
|
|
1158
|
-
let
|
|
1448
|
+
let sources = [
|
|
1449
|
+
{ uid: this.startUid, reader: this.reader }
|
|
1450
|
+
];
|
|
1159
1451
|
const hopResults = [];
|
|
1160
1452
|
for (let depth = 0; depth < this.hops.length; depth++) {
|
|
1161
1453
|
const hop = this.hops[depth];
|
|
1162
|
-
if (
|
|
1454
|
+
if (sources.length === 0) {
|
|
1163
1455
|
hopResults.push({
|
|
1164
1456
|
axbType: hop.axbType,
|
|
1165
1457
|
depth,
|
|
@@ -1170,9 +1462,12 @@ var TraversalBuilderImpl = class {
|
|
|
1170
1462
|
continue;
|
|
1171
1463
|
}
|
|
1172
1464
|
const hopEdges = [];
|
|
1173
|
-
const sourceCount =
|
|
1465
|
+
const sourceCount = sources.length;
|
|
1174
1466
|
let hopTruncated = false;
|
|
1175
|
-
const
|
|
1467
|
+
const resolvedTargetGraph = this.resolveTargetGraph(hop);
|
|
1468
|
+
const direction = hop.direction ?? "forward";
|
|
1469
|
+
const isCrossGraph = direction === "forward" && !!resolvedTargetGraph;
|
|
1470
|
+
const tasks = sources.map(({ uid, reader: sourceReader }) => async () => {
|
|
1176
1471
|
if (totalReads >= maxReads) {
|
|
1177
1472
|
hopTruncated = true;
|
|
1178
1473
|
return;
|
|
@@ -1184,51 +1479,79 @@ var TraversalBuilderImpl = class {
|
|
|
1184
1479
|
return;
|
|
1185
1480
|
}
|
|
1186
1481
|
totalReads++;
|
|
1187
|
-
const direction2 = hop.direction ?? "forward";
|
|
1188
1482
|
const params = { axbType: hop.axbType };
|
|
1189
|
-
if (
|
|
1483
|
+
if (direction === "forward") {
|
|
1190
1484
|
params.aUid = uid;
|
|
1191
1485
|
if (hop.bType) params.bType = hop.bType;
|
|
1192
1486
|
} else {
|
|
1193
1487
|
params.bUid = uid;
|
|
1194
1488
|
if (hop.aType) params.aType = hop.aType;
|
|
1195
1489
|
}
|
|
1196
|
-
if (
|
|
1490
|
+
if (direction === "forward" && hop.aType) {
|
|
1197
1491
|
params.aType = hop.aType;
|
|
1198
1492
|
}
|
|
1199
|
-
if (
|
|
1493
|
+
if (direction === "reverse" && hop.bType) {
|
|
1200
1494
|
params.bType = hop.bType;
|
|
1201
1495
|
}
|
|
1202
1496
|
if (hop.orderBy) params.orderBy = hop.orderBy;
|
|
1203
1497
|
const limit = hop.limit ?? DEFAULT_LIMIT;
|
|
1204
|
-
if (
|
|
1498
|
+
if (hop.filter) {
|
|
1499
|
+
params.limit = 0;
|
|
1500
|
+
} else {
|
|
1205
1501
|
params.limit = limit;
|
|
1206
1502
|
}
|
|
1207
|
-
let
|
|
1503
|
+
let hopReader;
|
|
1504
|
+
let nextReader;
|
|
1505
|
+
if (isCrossGraph) {
|
|
1506
|
+
if (isGraphClient(this.reader)) {
|
|
1507
|
+
hopReader = this.reader.subgraph(uid, resolvedTargetGraph);
|
|
1508
|
+
nextReader = hopReader;
|
|
1509
|
+
} else {
|
|
1510
|
+
hopReader = sourceReader;
|
|
1511
|
+
nextReader = sourceReader;
|
|
1512
|
+
if (!_crossGraphWarned) {
|
|
1513
|
+
_crossGraphWarned = true;
|
|
1514
|
+
console.warn(
|
|
1515
|
+
`[firegraph] Traversal hop "${hop.axbType}" has targetGraph "${resolvedTargetGraph}" but the reader does not support subgraph(). Cross-graph hop will query the current collection instead. Pass a GraphClient to createTraversal() to enable cross-graph traversal.`
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
} else {
|
|
1520
|
+
hopReader = sourceReader;
|
|
1521
|
+
nextReader = sourceReader;
|
|
1522
|
+
}
|
|
1523
|
+
let edges2 = await hopReader.findEdges(params);
|
|
1208
1524
|
if (hop.filter) {
|
|
1209
|
-
|
|
1210
|
-
|
|
1525
|
+
edges2 = edges2.filter(hop.filter);
|
|
1526
|
+
edges2 = edges2.slice(0, limit);
|
|
1527
|
+
}
|
|
1528
|
+
for (const edge of edges2) {
|
|
1529
|
+
hopEdges.push({ edge, reader: nextReader });
|
|
1211
1530
|
}
|
|
1212
|
-
hopEdges.push(...edges);
|
|
1213
1531
|
} finally {
|
|
1214
1532
|
semaphore.release();
|
|
1215
1533
|
}
|
|
1216
1534
|
});
|
|
1217
1535
|
await Promise.all(tasks.map((task) => task()));
|
|
1536
|
+
const edges = hopEdges.map((h) => h.edge);
|
|
1218
1537
|
hopResults.push({
|
|
1219
1538
|
axbType: hop.axbType,
|
|
1220
1539
|
depth,
|
|
1221
|
-
edges: returnIntermediates ? [...
|
|
1540
|
+
edges: returnIntermediates ? [...edges] : edges,
|
|
1222
1541
|
sourceCount,
|
|
1223
1542
|
truncated: hopTruncated
|
|
1224
1543
|
});
|
|
1225
1544
|
if (hopTruncated) {
|
|
1226
1545
|
truncated = true;
|
|
1227
1546
|
}
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1547
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1548
|
+
for (const { edge, reader: edgeReader } of hopEdges) {
|
|
1549
|
+
const nextUid = direction === "forward" ? edge.bUid : edge.aUid;
|
|
1550
|
+
if (!seen.has(nextUid)) {
|
|
1551
|
+
seen.set(nextUid, edgeReader);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
sources = [...seen.entries()].map(([uid, reader]) => ({ uid, reader }));
|
|
1232
1555
|
}
|
|
1233
1556
|
const lastHop = hopResults[hopResults.length - 1];
|
|
1234
1557
|
return {
|
|
@@ -1238,9 +1561,25 @@ var TraversalBuilderImpl = class {
|
|
|
1238
1561
|
truncated
|
|
1239
1562
|
};
|
|
1240
1563
|
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Resolve the targetGraph for a hop. Priority:
|
|
1566
|
+
* 1. Explicit `hop.targetGraph` (user override)
|
|
1567
|
+
* 2. Registry `targetGraph` for the axbType (if registry available)
|
|
1568
|
+
* 3. undefined (no cross-graph)
|
|
1569
|
+
*/
|
|
1570
|
+
resolveTargetGraph(hop) {
|
|
1571
|
+
if (hop.targetGraph) return hop.targetGraph;
|
|
1572
|
+
if (this.registry) {
|
|
1573
|
+
const entries = this.registry.lookupByAxbType(hop.axbType);
|
|
1574
|
+
for (const entry of entries) {
|
|
1575
|
+
if (entry.targetGraph) return entry.targetGraph;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return void 0;
|
|
1579
|
+
}
|
|
1241
1580
|
};
|
|
1242
|
-
function createTraversal(reader, startUid) {
|
|
1243
|
-
return new TraversalBuilderImpl(reader, startUid);
|
|
1581
|
+
function createTraversal(reader, startUid, registry) {
|
|
1582
|
+
return new TraversalBuilderImpl(reader, startUid, registry);
|
|
1244
1583
|
}
|
|
1245
1584
|
|
|
1246
1585
|
// src/views.ts
|
|
@@ -1451,7 +1790,8 @@ function loadNodeEntity(dir, name) {
|
|
|
1451
1790
|
subtitleField: meta?.subtitleField,
|
|
1452
1791
|
viewDefaults: meta?.viewDefaults,
|
|
1453
1792
|
viewsPath,
|
|
1454
|
-
sampleData
|
|
1793
|
+
sampleData,
|
|
1794
|
+
allowedIn: meta?.allowedIn
|
|
1455
1795
|
};
|
|
1456
1796
|
}
|
|
1457
1797
|
function loadEdgeEntity(dir, name) {
|
|
@@ -1486,7 +1826,9 @@ function loadEdgeEntity(dir, name) {
|
|
|
1486
1826
|
subtitleField: meta?.subtitleField,
|
|
1487
1827
|
viewDefaults: meta?.viewDefaults,
|
|
1488
1828
|
viewsPath,
|
|
1489
|
-
sampleData
|
|
1829
|
+
sampleData,
|
|
1830
|
+
allowedIn: meta?.allowedIn,
|
|
1831
|
+
targetGraph: topology.targetGraph ?? meta?.targetGraph
|
|
1490
1832
|
};
|
|
1491
1833
|
}
|
|
1492
1834
|
function getSubdirectories(dir) {
|
|
@@ -1528,8 +1870,147 @@ function discoverEntities(entitiesDir) {
|
|
|
1528
1870
|
warnings
|
|
1529
1871
|
};
|
|
1530
1872
|
}
|
|
1873
|
+
|
|
1874
|
+
// src/cross-graph.ts
|
|
1875
|
+
function resolveAncestorCollection(collectionPath, uid) {
|
|
1876
|
+
const segments = collectionPath.split("/");
|
|
1877
|
+
for (let i = 1; i < segments.length; i += 2) {
|
|
1878
|
+
if (segments[i] === uid) {
|
|
1879
|
+
return segments.slice(0, i).join("/");
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
return null;
|
|
1883
|
+
}
|
|
1884
|
+
function isAncestorUid(collectionPath, uid) {
|
|
1885
|
+
return resolveAncestorCollection(collectionPath, uid) !== null;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// src/indexes.ts
|
|
1889
|
+
function baseIndexes(collection) {
|
|
1890
|
+
return [
|
|
1891
|
+
{
|
|
1892
|
+
collectionGroup: collection,
|
|
1893
|
+
queryScope: "COLLECTION",
|
|
1894
|
+
fields: [
|
|
1895
|
+
{ fieldPath: "aUid", order: "ASCENDING" },
|
|
1896
|
+
{ fieldPath: "axbType", order: "ASCENDING" }
|
|
1897
|
+
]
|
|
1898
|
+
},
|
|
1899
|
+
{
|
|
1900
|
+
collectionGroup: collection,
|
|
1901
|
+
queryScope: "COLLECTION",
|
|
1902
|
+
fields: [
|
|
1903
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1904
|
+
{ fieldPath: "bUid", order: "ASCENDING" }
|
|
1905
|
+
]
|
|
1906
|
+
},
|
|
1907
|
+
{
|
|
1908
|
+
collectionGroup: collection,
|
|
1909
|
+
queryScope: "COLLECTION",
|
|
1910
|
+
fields: [
|
|
1911
|
+
{ fieldPath: "aType", order: "ASCENDING" },
|
|
1912
|
+
{ fieldPath: "axbType", order: "ASCENDING" }
|
|
1913
|
+
]
|
|
1914
|
+
},
|
|
1915
|
+
{
|
|
1916
|
+
collectionGroup: collection,
|
|
1917
|
+
queryScope: "COLLECTION",
|
|
1918
|
+
fields: [
|
|
1919
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1920
|
+
{ fieldPath: "bType", order: "ASCENDING" }
|
|
1921
|
+
]
|
|
1922
|
+
}
|
|
1923
|
+
];
|
|
1924
|
+
}
|
|
1925
|
+
function extractSchemaFields(schema) {
|
|
1926
|
+
const s = schema;
|
|
1927
|
+
if (s.type !== "object" || !s.properties) return [];
|
|
1928
|
+
return Object.keys(s.properties);
|
|
1929
|
+
}
|
|
1930
|
+
function collectionGroupIndexes(collectionName) {
|
|
1931
|
+
return [
|
|
1932
|
+
{
|
|
1933
|
+
collectionGroup: collectionName,
|
|
1934
|
+
queryScope: "COLLECTION_GROUP",
|
|
1935
|
+
fields: [
|
|
1936
|
+
{ fieldPath: "aUid", order: "ASCENDING" },
|
|
1937
|
+
{ fieldPath: "axbType", order: "ASCENDING" }
|
|
1938
|
+
]
|
|
1939
|
+
},
|
|
1940
|
+
{
|
|
1941
|
+
collectionGroup: collectionName,
|
|
1942
|
+
queryScope: "COLLECTION_GROUP",
|
|
1943
|
+
fields: [
|
|
1944
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1945
|
+
{ fieldPath: "bUid", order: "ASCENDING" }
|
|
1946
|
+
]
|
|
1947
|
+
},
|
|
1948
|
+
{
|
|
1949
|
+
collectionGroup: collectionName,
|
|
1950
|
+
queryScope: "COLLECTION_GROUP",
|
|
1951
|
+
fields: [
|
|
1952
|
+
{ fieldPath: "aType", order: "ASCENDING" },
|
|
1953
|
+
{ fieldPath: "axbType", order: "ASCENDING" }
|
|
1954
|
+
]
|
|
1955
|
+
},
|
|
1956
|
+
{
|
|
1957
|
+
collectionGroup: collectionName,
|
|
1958
|
+
queryScope: "COLLECTION_GROUP",
|
|
1959
|
+
fields: [
|
|
1960
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1961
|
+
{ fieldPath: "bType", order: "ASCENDING" }
|
|
1962
|
+
]
|
|
1963
|
+
}
|
|
1964
|
+
];
|
|
1965
|
+
}
|
|
1966
|
+
function generateIndexConfig(collection, entities, registryEntries) {
|
|
1967
|
+
const indexes = baseIndexes(collection);
|
|
1968
|
+
if (entities) {
|
|
1969
|
+
for (const [, entity] of entities.nodes) {
|
|
1970
|
+
const fields = extractSchemaFields(entity.schema);
|
|
1971
|
+
for (const field of fields) {
|
|
1972
|
+
indexes.push({
|
|
1973
|
+
collectionGroup: collection,
|
|
1974
|
+
queryScope: "COLLECTION",
|
|
1975
|
+
fields: [
|
|
1976
|
+
{ fieldPath: "aType", order: "ASCENDING" },
|
|
1977
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1978
|
+
{ fieldPath: `data.${field}`, order: "ASCENDING" }
|
|
1979
|
+
]
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
for (const [, entity] of entities.edges) {
|
|
1984
|
+
const fields = extractSchemaFields(entity.schema);
|
|
1985
|
+
for (const field of fields) {
|
|
1986
|
+
indexes.push({
|
|
1987
|
+
collectionGroup: collection,
|
|
1988
|
+
queryScope: "COLLECTION",
|
|
1989
|
+
fields: [
|
|
1990
|
+
{ fieldPath: "aUid", order: "ASCENDING" },
|
|
1991
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1992
|
+
{ fieldPath: `data.${field}`, order: "ASCENDING" }
|
|
1993
|
+
]
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
if (registryEntries) {
|
|
1999
|
+
const targetGraphNames = /* @__PURE__ */ new Set();
|
|
2000
|
+
for (const entry of registryEntries) {
|
|
2001
|
+
if (entry.targetGraph) {
|
|
2002
|
+
targetGraphNames.add(entry.targetGraph);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
for (const name of targetGraphNames) {
|
|
2006
|
+
indexes.push(...collectionGroupIndexes(name));
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return { indexes, fieldOverrides: [] };
|
|
2010
|
+
}
|
|
1531
2011
|
export {
|
|
1532
2012
|
BOOTSTRAP_ENTRIES,
|
|
2013
|
+
DEFAULT_QUERY_LIMIT,
|
|
1533
2014
|
DiscoveryError,
|
|
1534
2015
|
DynamicRegistryError,
|
|
1535
2016
|
EDGE_TYPE_SCHEMA,
|
|
@@ -1542,9 +2023,12 @@ export {
|
|
|
1542
2023
|
NodeNotFoundError,
|
|
1543
2024
|
QueryClient,
|
|
1544
2025
|
QueryClientError,
|
|
2026
|
+
QuerySafetyError,
|
|
2027
|
+
RegistryScopeError,
|
|
1545
2028
|
RegistryViolationError,
|
|
1546
2029
|
TraversalError,
|
|
1547
2030
|
ValidationError,
|
|
2031
|
+
analyzeQuerySafety,
|
|
1548
2032
|
buildEdgeQueryPlan,
|
|
1549
2033
|
buildEdgeRecord,
|
|
1550
2034
|
buildNodeQueryPlan,
|
|
@@ -1562,8 +2046,13 @@ export {
|
|
|
1562
2046
|
discoverEntities,
|
|
1563
2047
|
generateDeterministicUid,
|
|
1564
2048
|
generateId,
|
|
2049
|
+
generateIndexConfig,
|
|
1565
2050
|
generateTypes,
|
|
2051
|
+
isAncestorUid,
|
|
1566
2052
|
jsonSchemaToFieldMeta,
|
|
2053
|
+
matchScope,
|
|
2054
|
+
matchScopeAny,
|
|
2055
|
+
resolveAncestorCollection,
|
|
1567
2056
|
resolveView
|
|
1568
2057
|
};
|
|
1569
2058
|
//# sourceMappingURL=index.js.map
|