@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.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}`;
|
|
@@ -626,11 +791,16 @@ function createRegistry(input) {
|
|
|
626
791
|
lookup(aType, axbType, bType) {
|
|
627
792
|
return map.get(tripleKey(aType, axbType, bType))?.entry;
|
|
628
793
|
},
|
|
629
|
-
validate(aType, axbType, bType, data) {
|
|
794
|
+
validate(aType, axbType, bType, data, scopePath) {
|
|
630
795
|
const rec = map.get(tripleKey(aType, axbType, bType));
|
|
631
796
|
if (!rec) {
|
|
632
797
|
throw new RegistryViolationError(aType, axbType, bType);
|
|
633
798
|
}
|
|
799
|
+
if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
|
|
800
|
+
if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
|
|
801
|
+
throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
634
804
|
if (rec.validate) {
|
|
635
805
|
try {
|
|
636
806
|
rec.validate(data);
|
|
@@ -658,7 +828,8 @@ function discoveryToEntries(discovery) {
|
|
|
658
828
|
jsonSchema: entity.schema,
|
|
659
829
|
description: entity.description,
|
|
660
830
|
titleField: entity.titleField,
|
|
661
|
-
subtitleField: entity.subtitleField
|
|
831
|
+
subtitleField: entity.subtitleField,
|
|
832
|
+
allowedIn: entity.allowedIn
|
|
662
833
|
});
|
|
663
834
|
}
|
|
664
835
|
for (const [axbType, entity] of discovery.edges) {
|
|
@@ -676,7 +847,8 @@ function discoveryToEntries(discovery) {
|
|
|
676
847
|
description: entity.description,
|
|
677
848
|
inverseLabel: topology.inverseLabel,
|
|
678
849
|
titleField: entity.titleField,
|
|
679
|
-
subtitleField: entity.subtitleField
|
|
850
|
+
subtitleField: entity.subtitleField,
|
|
851
|
+
allowedIn: entity.allowedIn
|
|
680
852
|
});
|
|
681
853
|
}
|
|
682
854
|
}
|
|
@@ -697,7 +869,8 @@ var NODE_TYPE_SCHEMA = {
|
|
|
697
869
|
titleField: { type: "string" },
|
|
698
870
|
subtitleField: { type: "string" },
|
|
699
871
|
viewTemplate: { type: "string" },
|
|
700
|
-
viewCss: { type: "string" }
|
|
872
|
+
viewCss: { type: "string" },
|
|
873
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
|
|
701
874
|
},
|
|
702
875
|
additionalProperties: false
|
|
703
876
|
};
|
|
@@ -724,7 +897,8 @@ var EDGE_TYPE_SCHEMA = {
|
|
|
724
897
|
titleField: { type: "string" },
|
|
725
898
|
subtitleField: { type: "string" },
|
|
726
899
|
viewTemplate: { type: "string" },
|
|
727
|
-
viewCss: { type: "string" }
|
|
900
|
+
viewCss: { type: "string" },
|
|
901
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
|
|
728
902
|
},
|
|
729
903
|
additionalProperties: false
|
|
730
904
|
};
|
|
@@ -766,7 +940,8 @@ async function createRegistryFromGraph(reader) {
|
|
|
766
940
|
jsonSchema: data.jsonSchema,
|
|
767
941
|
description: data.description,
|
|
768
942
|
titleField: data.titleField,
|
|
769
|
-
subtitleField: data.subtitleField
|
|
943
|
+
subtitleField: data.subtitleField,
|
|
944
|
+
allowedIn: data.allowedIn
|
|
770
945
|
});
|
|
771
946
|
}
|
|
772
947
|
for (const record of edgeTypes) {
|
|
@@ -783,7 +958,8 @@ async function createRegistryFromGraph(reader) {
|
|
|
783
958
|
description: data.description,
|
|
784
959
|
inverseLabel: data.inverseLabel,
|
|
785
960
|
titleField: data.titleField,
|
|
786
|
-
subtitleField: data.subtitleField
|
|
961
|
+
subtitleField: data.subtitleField,
|
|
962
|
+
allowedIn: data.allowedIn
|
|
787
963
|
});
|
|
788
964
|
}
|
|
789
965
|
}
|
|
@@ -794,9 +970,10 @@ async function createRegistryFromGraph(reader) {
|
|
|
794
970
|
// src/client.ts
|
|
795
971
|
var _standardModeWarned = false;
|
|
796
972
|
var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
|
|
797
|
-
var GraphClientImpl = class {
|
|
798
|
-
constructor(db, collectionPath, options) {
|
|
973
|
+
var GraphClientImpl = class _GraphClientImpl {
|
|
974
|
+
constructor(db, collectionPath, options, scopePath = "") {
|
|
799
975
|
this.db = db;
|
|
976
|
+
this.scopePath = scopePath;
|
|
800
977
|
this.adapter = createFirestoreAdapter(db, collectionPath);
|
|
801
978
|
if (options?.registry && options?.registryMode) {
|
|
802
979
|
throw new DynamicRegistryError(
|
|
@@ -826,6 +1003,7 @@ var GraphClientImpl = class {
|
|
|
826
1003
|
"[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
1004
|
);
|
|
828
1005
|
}
|
|
1006
|
+
this.scanProtection = options?.scanProtection ?? "error";
|
|
829
1007
|
if (this.queryMode === "pipeline") {
|
|
830
1008
|
this.pipelineAdapter = createPipelineQueryAdapter(db, collectionPath);
|
|
831
1009
|
if (this.metaAdapter) {
|
|
@@ -839,6 +1017,7 @@ var GraphClientImpl = class {
|
|
|
839
1017
|
adapter;
|
|
840
1018
|
pipelineAdapter;
|
|
841
1019
|
queryMode;
|
|
1020
|
+
scanProtection;
|
|
842
1021
|
// Static mode
|
|
843
1022
|
staticRegistry;
|
|
844
1023
|
// Dynamic mode
|
|
@@ -847,6 +1026,8 @@ var GraphClientImpl = class {
|
|
|
847
1026
|
dynamicRegistry;
|
|
848
1027
|
metaAdapter;
|
|
849
1028
|
metaPipelineAdapter;
|
|
1029
|
+
// Subgraph scope tracking
|
|
1030
|
+
scopePath;
|
|
850
1031
|
// ---------------------------------------------------------------------------
|
|
851
1032
|
// Registry routing
|
|
852
1033
|
// ---------------------------------------------------------------------------
|
|
@@ -900,6 +1081,19 @@ var GraphClientImpl = class {
|
|
|
900
1081
|
}
|
|
901
1082
|
return this.adapter.query(filters, options);
|
|
902
1083
|
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Check whether a query's filter set is safe (matches a known index pattern).
|
|
1086
|
+
* Throws QuerySafetyError or logs a warning depending on scanProtection config.
|
|
1087
|
+
*/
|
|
1088
|
+
checkQuerySafety(filters, allowCollectionScan) {
|
|
1089
|
+
if (allowCollectionScan || this.scanProtection === "off") return;
|
|
1090
|
+
const result = analyzeQuerySafety(filters);
|
|
1091
|
+
if (result.safe) return;
|
|
1092
|
+
if (this.scanProtection === "error") {
|
|
1093
|
+
throw new QuerySafetyError(result.reason);
|
|
1094
|
+
}
|
|
1095
|
+
console.warn(`[firegraph] Query safety warning: ${result.reason}`);
|
|
1096
|
+
}
|
|
903
1097
|
// ---------------------------------------------------------------------------
|
|
904
1098
|
// GraphReader
|
|
905
1099
|
// ---------------------------------------------------------------------------
|
|
@@ -921,6 +1115,7 @@ var GraphClientImpl = class {
|
|
|
921
1115
|
const record = await this.adapter.getDoc(plan.docId);
|
|
922
1116
|
return record ? [record] : [];
|
|
923
1117
|
}
|
|
1118
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
924
1119
|
return this.executeQuery(plan.filters, plan.options);
|
|
925
1120
|
}
|
|
926
1121
|
async findNodes(params) {
|
|
@@ -929,6 +1124,7 @@ var GraphClientImpl = class {
|
|
|
929
1124
|
const record = await this.adapter.getDoc(plan.docId);
|
|
930
1125
|
return record ? [record] : [];
|
|
931
1126
|
}
|
|
1127
|
+
this.checkQuerySafety(plan.filters, params.allowCollectionScan);
|
|
932
1128
|
return this.executeQuery(plan.filters, plan.options);
|
|
933
1129
|
}
|
|
934
1130
|
// ---------------------------------------------------------------------------
|
|
@@ -937,7 +1133,7 @@ var GraphClientImpl = class {
|
|
|
937
1133
|
async putNode(aType, uid, data) {
|
|
938
1134
|
const registry = this.getRegistryForType(aType);
|
|
939
1135
|
if (registry) {
|
|
940
|
-
registry.validate(aType, NODE_RELATION, aType, data);
|
|
1136
|
+
registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
|
|
941
1137
|
}
|
|
942
1138
|
const adapter = this.getAdapterForType(aType);
|
|
943
1139
|
const docId = computeNodeDocId(uid);
|
|
@@ -947,7 +1143,7 @@ var GraphClientImpl = class {
|
|
|
947
1143
|
async putEdge(aType, aUid, axbType, bType, bUid, data) {
|
|
948
1144
|
const registry = this.getRegistryForType(aType);
|
|
949
1145
|
if (registry) {
|
|
950
|
-
registry.validate(aType, axbType, bType, data);
|
|
1146
|
+
registry.validate(aType, axbType, bType, data, this.scopePath);
|
|
951
1147
|
}
|
|
952
1148
|
const adapter = this.getAdapterForType(aType);
|
|
953
1149
|
const docId = computeEdgeDocId(aUid, axbType, bUid);
|
|
@@ -979,13 +1175,42 @@ var GraphClientImpl = class {
|
|
|
979
1175
|
this.adapter.collectionPath,
|
|
980
1176
|
firestoreTx
|
|
981
1177
|
);
|
|
982
|
-
const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
|
|
1178
|
+
const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
|
|
983
1179
|
return fn(graphTx);
|
|
984
1180
|
});
|
|
985
1181
|
}
|
|
986
1182
|
batch() {
|
|
987
1183
|
const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
|
|
988
|
-
return new GraphBatchImpl(adapter, this.getCombinedRegistry());
|
|
1184
|
+
return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
|
|
1185
|
+
}
|
|
1186
|
+
// ---------------------------------------------------------------------------
|
|
1187
|
+
// Subgraph
|
|
1188
|
+
// ---------------------------------------------------------------------------
|
|
1189
|
+
subgraph(parentNodeUid, name = "graph") {
|
|
1190
|
+
if (!parentNodeUid || parentNodeUid.includes("/")) {
|
|
1191
|
+
throw new FiregraphError(
|
|
1192
|
+
`Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
|
|
1193
|
+
"INVALID_SUBGRAPH"
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
if (name.includes("/")) {
|
|
1197
|
+
throw new FiregraphError(
|
|
1198
|
+
`Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
|
|
1199
|
+
"INVALID_SUBGRAPH"
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
|
|
1203
|
+
const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
|
|
1204
|
+
return new _GraphClientImpl(
|
|
1205
|
+
this.db,
|
|
1206
|
+
subCollectionPath,
|
|
1207
|
+
{
|
|
1208
|
+
registry: this.getCombinedRegistry(),
|
|
1209
|
+
queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
|
|
1210
|
+
scanProtection: this.scanProtection
|
|
1211
|
+
},
|
|
1212
|
+
newScopePath
|
|
1213
|
+
);
|
|
989
1214
|
}
|
|
990
1215
|
// ---------------------------------------------------------------------------
|
|
991
1216
|
// Bulk operations
|
|
@@ -1017,6 +1242,7 @@ var GraphClientImpl = class {
|
|
|
1017
1242
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
1018
1243
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
1019
1244
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
1245
|
+
if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
|
|
1020
1246
|
await this.putNode(META_NODE_TYPE, uid, data);
|
|
1021
1247
|
}
|
|
1022
1248
|
async defineEdgeType(name, topology, jsonSchema, description, options) {
|
|
@@ -1043,6 +1269,7 @@ var GraphClientImpl = class {
|
|
|
1043
1269
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
1044
1270
|
if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
|
|
1045
1271
|
if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
|
|
1272
|
+
if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
|
|
1046
1273
|
await this.putNode(META_EDGE_TYPE, uid, data);
|
|
1047
1274
|
}
|
|
1048
1275
|
async reloadRegistry() {
|
|
@@ -1201,7 +1428,9 @@ var TraversalBuilderImpl = class {
|
|
|
1201
1428
|
}
|
|
1202
1429
|
if (hop.orderBy) params.orderBy = hop.orderBy;
|
|
1203
1430
|
const limit = hop.limit ?? DEFAULT_LIMIT;
|
|
1204
|
-
if (
|
|
1431
|
+
if (hop.filter) {
|
|
1432
|
+
params.limit = 0;
|
|
1433
|
+
} else {
|
|
1205
1434
|
params.limit = limit;
|
|
1206
1435
|
}
|
|
1207
1436
|
let edges = await this.reader.findEdges(params);
|
|
@@ -1451,7 +1680,8 @@ function loadNodeEntity(dir, name) {
|
|
|
1451
1680
|
subtitleField: meta?.subtitleField,
|
|
1452
1681
|
viewDefaults: meta?.viewDefaults,
|
|
1453
1682
|
viewsPath,
|
|
1454
|
-
sampleData
|
|
1683
|
+
sampleData,
|
|
1684
|
+
allowedIn: meta?.allowedIn
|
|
1455
1685
|
};
|
|
1456
1686
|
}
|
|
1457
1687
|
function loadEdgeEntity(dir, name) {
|
|
@@ -1486,7 +1716,8 @@ function loadEdgeEntity(dir, name) {
|
|
|
1486
1716
|
subtitleField: meta?.subtitleField,
|
|
1487
1717
|
viewDefaults: meta?.viewDefaults,
|
|
1488
1718
|
viewsPath,
|
|
1489
|
-
sampleData
|
|
1719
|
+
sampleData,
|
|
1720
|
+
allowedIn: meta?.allowedIn
|
|
1490
1721
|
};
|
|
1491
1722
|
}
|
|
1492
1723
|
function getSubdirectories(dir) {
|
|
@@ -1528,8 +1759,86 @@ function discoverEntities(entitiesDir) {
|
|
|
1528
1759
|
warnings
|
|
1529
1760
|
};
|
|
1530
1761
|
}
|
|
1762
|
+
|
|
1763
|
+
// src/indexes.ts
|
|
1764
|
+
function baseIndexes(collection) {
|
|
1765
|
+
return [
|
|
1766
|
+
{
|
|
1767
|
+
collectionGroup: collection,
|
|
1768
|
+
queryScope: "COLLECTION",
|
|
1769
|
+
fields: [
|
|
1770
|
+
{ fieldPath: "aUid", order: "ASCENDING" },
|
|
1771
|
+
{ fieldPath: "axbType", order: "ASCENDING" }
|
|
1772
|
+
]
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
collectionGroup: collection,
|
|
1776
|
+
queryScope: "COLLECTION",
|
|
1777
|
+
fields: [
|
|
1778
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1779
|
+
{ fieldPath: "bUid", order: "ASCENDING" }
|
|
1780
|
+
]
|
|
1781
|
+
},
|
|
1782
|
+
{
|
|
1783
|
+
collectionGroup: collection,
|
|
1784
|
+
queryScope: "COLLECTION",
|
|
1785
|
+
fields: [
|
|
1786
|
+
{ fieldPath: "aType", order: "ASCENDING" },
|
|
1787
|
+
{ fieldPath: "axbType", order: "ASCENDING" }
|
|
1788
|
+
]
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
collectionGroup: collection,
|
|
1792
|
+
queryScope: "COLLECTION",
|
|
1793
|
+
fields: [
|
|
1794
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1795
|
+
{ fieldPath: "bType", order: "ASCENDING" }
|
|
1796
|
+
]
|
|
1797
|
+
}
|
|
1798
|
+
];
|
|
1799
|
+
}
|
|
1800
|
+
function extractSchemaFields(schema) {
|
|
1801
|
+
const s = schema;
|
|
1802
|
+
if (s.type !== "object" || !s.properties) return [];
|
|
1803
|
+
return Object.keys(s.properties);
|
|
1804
|
+
}
|
|
1805
|
+
function generateIndexConfig(collection, entities) {
|
|
1806
|
+
const indexes = baseIndexes(collection);
|
|
1807
|
+
if (entities) {
|
|
1808
|
+
for (const [, entity] of entities.nodes) {
|
|
1809
|
+
const fields = extractSchemaFields(entity.schema);
|
|
1810
|
+
for (const field of fields) {
|
|
1811
|
+
indexes.push({
|
|
1812
|
+
collectionGroup: collection,
|
|
1813
|
+
queryScope: "COLLECTION",
|
|
1814
|
+
fields: [
|
|
1815
|
+
{ fieldPath: "aType", order: "ASCENDING" },
|
|
1816
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1817
|
+
{ fieldPath: `data.${field}`, order: "ASCENDING" }
|
|
1818
|
+
]
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
for (const [, entity] of entities.edges) {
|
|
1823
|
+
const fields = extractSchemaFields(entity.schema);
|
|
1824
|
+
for (const field of fields) {
|
|
1825
|
+
indexes.push({
|
|
1826
|
+
collectionGroup: collection,
|
|
1827
|
+
queryScope: "COLLECTION",
|
|
1828
|
+
fields: [
|
|
1829
|
+
{ fieldPath: "aUid", order: "ASCENDING" },
|
|
1830
|
+
{ fieldPath: "axbType", order: "ASCENDING" },
|
|
1831
|
+
{ fieldPath: `data.${field}`, order: "ASCENDING" }
|
|
1832
|
+
]
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
return { indexes, fieldOverrides: [] };
|
|
1838
|
+
}
|
|
1531
1839
|
export {
|
|
1532
1840
|
BOOTSTRAP_ENTRIES,
|
|
1841
|
+
DEFAULT_QUERY_LIMIT,
|
|
1533
1842
|
DiscoveryError,
|
|
1534
1843
|
DynamicRegistryError,
|
|
1535
1844
|
EDGE_TYPE_SCHEMA,
|
|
@@ -1542,9 +1851,12 @@ export {
|
|
|
1542
1851
|
NodeNotFoundError,
|
|
1543
1852
|
QueryClient,
|
|
1544
1853
|
QueryClientError,
|
|
1854
|
+
QuerySafetyError,
|
|
1855
|
+
RegistryScopeError,
|
|
1545
1856
|
RegistryViolationError,
|
|
1546
1857
|
TraversalError,
|
|
1547
1858
|
ValidationError,
|
|
1859
|
+
analyzeQuerySafety,
|
|
1548
1860
|
buildEdgeQueryPlan,
|
|
1549
1861
|
buildEdgeRecord,
|
|
1550
1862
|
buildNodeQueryPlan,
|
|
@@ -1562,8 +1874,11 @@ export {
|
|
|
1562
1874
|
discoverEntities,
|
|
1563
1875
|
generateDeterministicUid,
|
|
1564
1876
|
generateId,
|
|
1877
|
+
generateIndexConfig,
|
|
1565
1878
|
generateTypes,
|
|
1566
1879
|
jsonSchemaToFieldMeta,
|
|
1880
|
+
matchScope,
|
|
1881
|
+
matchScopeAny,
|
|
1567
1882
|
resolveView
|
|
1568
1883
|
};
|
|
1569
1884
|
//# sourceMappingURL=index.js.map
|