@typicalday/firegraph 0.7.0 → 0.8.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/dist/index.js CHANGED
@@ -1,3 +1,48 @@
1
+ import {
2
+ BOOTSTRAP_ENTRIES,
3
+ DEFAULT_QUERY_LIMIT,
4
+ DynamicRegistryError,
5
+ EDGE_TYPE_SCHEMA,
6
+ EdgeNotFoundError,
7
+ FiregraphError,
8
+ GraphClientImpl,
9
+ InvalidQueryError,
10
+ META_EDGE_TYPE,
11
+ META_NODE_TYPE,
12
+ MigrationError,
13
+ NODE_RELATION,
14
+ NODE_TYPE_SCHEMA,
15
+ NodeNotFoundError,
16
+ QuerySafetyError,
17
+ RegistryScopeError,
18
+ RegistryViolationError,
19
+ TraversalError,
20
+ ValidationError,
21
+ analyzeQuerySafety,
22
+ applyMigrationChain,
23
+ buildEdgeQueryPlan,
24
+ buildNodeQueryPlan,
25
+ compileMigrationFn,
26
+ compileMigrations,
27
+ compileSchema,
28
+ computeEdgeDocId,
29
+ computeNodeDocId,
30
+ createBootstrapRegistry,
31
+ createGraphClientFromBackend,
32
+ createMergedRegistry,
33
+ createRegistry,
34
+ createRegistryFromGraph,
35
+ defaultExecutor,
36
+ destroySandboxWorker,
37
+ generateDeterministicUid,
38
+ jsonSchemaToFieldMeta,
39
+ matchScope,
40
+ matchScopeAny,
41
+ migrateRecord,
42
+ migrateRecords,
43
+ precompileSource,
44
+ validateMigrationChain
45
+ } from "./chunk-YUXOALMR.js";
1
46
  import {
2
47
  generateTypes
3
48
  } from "./chunk-YLGXLEUE.js";
@@ -5,834 +50,303 @@ import {
5
50
  QueryClient,
6
51
  QueryClientError
7
52
  } from "./chunk-KFA7G37W.js";
53
+ import {
54
+ SERIALIZATION_TAG,
55
+ deserializeFirestoreTypes,
56
+ isTaggedValue,
57
+ serializeFirestoreTypes
58
+ } from "./chunk-6OQW5OKO.js";
8
59
 
9
- // src/client.ts
10
- import { FieldValue as FieldValue5 } from "@google-cloud/firestore";
11
-
12
- // src/docid.ts
13
- import { createHash } from "crypto";
14
-
15
- // src/internal/constants.ts
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
- ]);
27
- var SHARD_SEPARATOR = ":";
28
-
29
- // src/docid.ts
30
- function computeNodeDocId(uid) {
31
- return uid;
60
+ // src/config.ts
61
+ function defineConfig(config) {
62
+ return config;
32
63
  }
33
- function computeEdgeDocId(aUid, axbType, bUid) {
34
- const composite = `${aUid}${SHARD_SEPARATOR}${axbType}${SHARD_SEPARATOR}${bUid}`;
35
- const hash = createHash("sha256").update(composite).digest("hex");
36
- const shard = hash[0];
37
- return `${shard}${SHARD_SEPARATOR}${aUid}${SHARD_SEPARATOR}${axbType}${SHARD_SEPARATOR}${bUid}`;
64
+ function resolveView(resolverConfig, availableViewNames, context) {
65
+ if (!resolverConfig) return "json";
66
+ const available = new Set(availableViewNames);
67
+ if (context) {
68
+ const contextDefault = resolverConfig[context];
69
+ if (contextDefault && available.has(contextDefault)) {
70
+ return contextDefault;
71
+ }
72
+ }
73
+ if (resolverConfig.default && available.has(resolverConfig.default)) {
74
+ return resolverConfig.default;
75
+ }
76
+ return "json";
38
77
  }
39
78
 
40
- // src/record.ts
41
- import { FieldValue } from "@google-cloud/firestore";
42
- function buildNodeRecord(aType, uid, data) {
43
- const now = FieldValue.serverTimestamp();
44
- return {
45
- aType,
46
- aUid: uid,
47
- axbType: NODE_RELATION,
48
- bType: aType,
49
- bUid: uid,
50
- data,
51
- createdAt: now,
52
- updatedAt: now
53
- };
79
+ // src/cross-graph.ts
80
+ function resolveAncestorCollection(collectionPath, uid) {
81
+ const segments = collectionPath.split("/");
82
+ for (let i = 1; i < segments.length; i += 2) {
83
+ if (segments[i] === uid) {
84
+ return segments.slice(0, i).join("/");
85
+ }
86
+ }
87
+ return null;
54
88
  }
55
- function buildEdgeRecord(aType, aUid, axbType, bType, bUid, data) {
56
- const now = FieldValue.serverTimestamp();
57
- return {
58
- aType,
59
- aUid,
60
- axbType,
61
- bType,
62
- bUid,
63
- data,
64
- createdAt: now,
65
- updatedAt: now
66
- };
89
+ function isAncestorUid(collectionPath, uid) {
90
+ return resolveAncestorCollection(collectionPath, uid) !== null;
67
91
  }
68
92
 
69
- // src/errors.ts
70
- var FiregraphError = class extends Error {
71
- constructor(message, code) {
72
- super(message);
73
- this.code = code;
74
- this.name = "FiregraphError";
75
- }
76
- };
77
- var NodeNotFoundError = class extends FiregraphError {
78
- constructor(uid) {
79
- super(`Node not found: ${uid}`, "NODE_NOT_FOUND");
80
- this.name = "NodeNotFoundError";
81
- }
82
- };
83
- var EdgeNotFoundError = class extends FiregraphError {
84
- constructor(aUid, axbType, bUid) {
85
- super(`Edge not found: ${aUid} -[${axbType}]-> ${bUid}`, "EDGE_NOT_FOUND");
86
- this.name = "EdgeNotFoundError";
87
- }
88
- };
89
- var ValidationError = class extends FiregraphError {
90
- constructor(message, details) {
91
- super(message, "VALIDATION_ERROR");
92
- this.details = details;
93
- this.name = "ValidationError";
94
- }
95
- };
96
- var RegistryViolationError = class extends FiregraphError {
97
- constructor(aType, axbType, bType) {
98
- super(
99
- `Unregistered triple: (${aType}) -[${axbType}]-> (${bType})`,
100
- "REGISTRY_VIOLATION"
101
- );
102
- this.name = "RegistryViolationError";
103
- }
104
- };
105
- var InvalidQueryError = class extends FiregraphError {
106
- constructor(message) {
107
- super(message, "INVALID_QUERY");
108
- this.name = "InvalidQueryError";
109
- }
110
- };
111
- var TraversalError = class extends FiregraphError {
93
+ // src/discover.ts
94
+ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
95
+ import { createRequire } from "module";
96
+ import { join, resolve } from "path";
97
+ var DiscoveryError = class extends FiregraphError {
112
98
  constructor(message) {
113
- super(message, "TRAVERSAL_ERROR");
114
- this.name = "TraversalError";
99
+ super(message, "DISCOVERY_ERROR");
100
+ this.name = "DiscoveryError";
115
101
  }
116
102
  };
117
- var DynamicRegistryError = class extends FiregraphError {
118
- constructor(message) {
119
- super(message, "DYNAMIC_REGISTRY_ERROR");
120
- this.name = "DynamicRegistryError";
103
+ function readJson(filePath) {
104
+ try {
105
+ const raw = readFileSync(filePath, "utf-8");
106
+ return JSON.parse(raw);
107
+ } catch (err) {
108
+ const msg = err instanceof SyntaxError ? `Invalid JSON in ${filePath}: ${err.message}` : `Cannot read ${filePath}: ${err.message}`;
109
+ throw new DiscoveryError(msg);
121
110
  }
122
- };
123
- var QuerySafetyError = class extends FiregraphError {
124
- constructor(message) {
125
- super(message, "QUERY_SAFETY");
126
- this.name = "QuerySafetyError";
111
+ }
112
+ function readJsonIfExists(filePath) {
113
+ if (!existsSync(filePath)) return void 0;
114
+ return readJson(filePath);
115
+ }
116
+ var SCHEMA_SCRIPT_EXTENSIONS = [".ts", ".js", ".mts", ".mjs"];
117
+ function loadSchema(dir, entityLabel) {
118
+ for (const ext of SCHEMA_SCRIPT_EXTENSIONS) {
119
+ const candidate = join(dir, `schema${ext}`);
120
+ if (existsSync(candidate)) {
121
+ return loadSchemaModule(candidate, entityLabel);
122
+ }
127
123
  }
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";
124
+ const jsonPath = join(dir, "schema.json");
125
+ if (existsSync(jsonPath)) {
126
+ return readJson(jsonPath);
136
127
  }
137
- };
138
- var MigrationError = class extends FiregraphError {
139
- constructor(message) {
140
- super(message, "MIGRATION_ERROR");
141
- this.name = "MigrationError";
128
+ throw new DiscoveryError(
129
+ `Missing schema for ${entityLabel} in ${dir}. Provide a schema.ts (or .js/.mts/.mjs) or schema.json file.`
130
+ );
131
+ }
132
+ var _jiti;
133
+ function getJiti() {
134
+ if (!_jiti) {
135
+ const base = typeof __filename !== "undefined" ? __filename : import.meta.url;
136
+ const esmRequire = createRequire(base);
137
+ const { createJiti } = esmRequire("jiti");
138
+ _jiti = createJiti(base, { interopDefault: true });
142
139
  }
143
- };
144
-
145
- // src/query.ts
146
- function buildEdgeQueryPlan(params) {
147
- const { aType, aUid, axbType, bType, bUid, limit, orderBy } = params;
148
- if (aUid && axbType && bUid && !params.where?.length) {
149
- return { strategy: "get", docId: computeEdgeDocId(aUid, axbType, bUid) };
150
- }
151
- const filters = [];
152
- if (aType) filters.push({ field: "aType", op: "==", value: aType });
153
- if (aUid) filters.push({ field: "aUid", op: "==", value: aUid });
154
- if (axbType) filters.push({ field: "axbType", op: "==", value: axbType });
155
- if (bType) filters.push({ field: "bType", op: "==", value: bType });
156
- if (bUid) filters.push({ field: "bUid", op: "==", value: bUid });
157
- if (params.where) {
158
- for (const clause of params.where) {
159
- const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
160
- filters.push({ field, op: clause.op, value: clause.value });
140
+ return _jiti;
141
+ }
142
+ function loadSchemaModule(filePath, entityLabel) {
143
+ try {
144
+ const jiti = getJiti();
145
+ const mod = jiti(filePath);
146
+ const schema = mod && typeof mod === "object" && "default" in mod ? mod.default : mod;
147
+ if (!schema || typeof schema !== "object") {
148
+ throw new DiscoveryError(
149
+ `Schema file ${filePath} for ${entityLabel} must default-export a JSON Schema object.`
150
+ );
161
151
  }
152
+ return schema;
153
+ } catch (err) {
154
+ if (err instanceof DiscoveryError) throw err;
155
+ throw new DiscoveryError(
156
+ `Failed to load schema module ${filePath} for ${entityLabel}: ${err.message}`
157
+ );
162
158
  }
163
- if (filters.length === 0) {
164
- throw new InvalidQueryError("findEdges requires at least one filter parameter");
159
+ }
160
+ var VIEW_EXTENSIONS = [".ts", ".js", ".mts", ".mjs"];
161
+ function findViewsFile(dir) {
162
+ for (const ext of VIEW_EXTENSIONS) {
163
+ const candidate = join(dir, `views${ext}`);
164
+ if (existsSync(candidate)) return candidate;
165
165
  }
166
- const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
167
- return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
166
+ return void 0;
168
167
  }
169
- function buildNodeQueryPlan(params) {
170
- const { aType, limit, orderBy } = params;
171
- const filters = [
172
- { field: "aType", op: "==", value: aType },
173
- { field: "axbType", op: "==", value: NODE_RELATION }
174
- ];
175
- if (params.where) {
176
- for (const clause of params.where) {
177
- const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
178
- filters.push({ field, op: clause.op, value: clause.value });
179
- }
168
+ var MIGRATION_EXTENSIONS = [".ts", ".js", ".mts", ".mjs"];
169
+ function findMigrationsFile(dir) {
170
+ for (const ext of MIGRATION_EXTENSIONS) {
171
+ const candidate = join(dir, `migrations${ext}`);
172
+ if (existsSync(candidate)) return candidate;
180
173
  }
181
- const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
182
- return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
174
+ return void 0;
183
175
  }
184
-
185
- // src/internal/firestore-adapter.ts
186
- function createFirestoreAdapter(db, collectionPath) {
187
- const collectionRef = db.collection(collectionPath);
188
- return {
189
- collectionPath,
190
- async getDoc(docId) {
191
- const snap = await collectionRef.doc(docId).get();
192
- if (!snap.exists) return null;
193
- return snap.data();
194
- },
195
- async setDoc(docId, data) {
196
- await collectionRef.doc(docId).set(data);
197
- },
198
- async updateDoc(docId, data) {
199
- await collectionRef.doc(docId).update(data);
200
- },
201
- async deleteDoc(docId) {
202
- await collectionRef.doc(docId).delete();
203
- },
204
- async query(filters, options) {
205
- let q = collectionRef;
206
- for (const f of filters) {
207
- q = q.where(f.field, f.op, f.value);
208
- }
209
- if (options?.orderBy) {
210
- q = q.orderBy(options.orderBy.field, options.orderBy.direction ?? "asc");
211
- }
212
- if (options?.limit !== void 0) {
213
- q = q.limit(options.limit);
214
- }
215
- const snap = await q.get();
216
- return snap.docs.map((doc) => doc.data());
176
+ function loadMigrations(filePath, entityLabel) {
177
+ try {
178
+ const jiti = getJiti();
179
+ const mod = jiti(filePath);
180
+ const migrations = mod && typeof mod === "object" && "default" in mod ? mod.default : mod;
181
+ if (!Array.isArray(migrations)) {
182
+ throw new DiscoveryError(
183
+ `Migrations file ${filePath} for ${entityLabel} must default-export an array of MigrationStep.`
184
+ );
217
185
  }
218
- };
186
+ return migrations;
187
+ } catch (err) {
188
+ if (err instanceof DiscoveryError) throw err;
189
+ throw new DiscoveryError(
190
+ `Failed to load migrations ${filePath} for ${entityLabel}: ${err.message}`
191
+ );
192
+ }
219
193
  }
220
- function createTransactionAdapter(db, collectionPath, tx) {
221
- const collectionRef = db.collection(collectionPath);
194
+ function loadNodeEntity(dir, name) {
195
+ const schema = loadSchema(dir, `node type "${name}"`);
196
+ const meta = readJsonIfExists(join(dir, "meta.json"));
197
+ const sampleData = readJsonIfExists(join(dir, "sample.json"));
198
+ const viewsPath = findViewsFile(dir);
199
+ const migrationsPath = findMigrationsFile(dir);
200
+ const migrations = migrationsPath ? loadMigrations(migrationsPath, `node type "${name}"`) : void 0;
222
201
  return {
223
- async getDoc(docId) {
224
- const snap = await tx.get(collectionRef.doc(docId));
225
- if (!snap.exists) return null;
226
- return snap.data();
227
- },
228
- setDoc(docId, data) {
229
- tx.set(collectionRef.doc(docId), data);
230
- },
231
- updateDoc(docId, data) {
232
- tx.update(collectionRef.doc(docId), data);
233
- },
234
- deleteDoc(docId) {
235
- tx.delete(collectionRef.doc(docId));
236
- },
237
- async query(filters, options) {
238
- let q = collectionRef;
239
- for (const f of filters) {
240
- q = q.where(f.field, f.op, f.value);
241
- }
242
- if (options?.orderBy) {
243
- q = q.orderBy(options.orderBy.field, options.orderBy.direction ?? "asc");
244
- }
245
- if (options?.limit !== void 0) {
246
- q = q.limit(options.limit);
247
- }
248
- const snap = await tx.get(q);
249
- return snap.docs.map((doc) => doc.data());
250
- }
251
- };
252
- }
253
- function createBatchAdapter(db, collectionPath) {
254
- const collectionRef = db.collection(collectionPath);
255
- const batch = db.batch();
256
- return {
257
- setDoc(docId, data) {
258
- batch.set(collectionRef.doc(docId), data);
259
- },
260
- updateDoc(docId, data) {
261
- batch.update(collectionRef.doc(docId), data);
262
- },
263
- deleteDoc(docId) {
264
- batch.delete(collectionRef.doc(docId));
265
- },
266
- async commit() {
267
- await batch.commit();
268
- }
202
+ kind: "node",
203
+ name,
204
+ schema,
205
+ description: meta?.description,
206
+ titleField: meta?.titleField,
207
+ subtitleField: meta?.subtitleField,
208
+ viewDefaults: meta?.viewDefaults,
209
+ viewsPath,
210
+ sampleData,
211
+ allowedIn: meta?.allowedIn,
212
+ migrations,
213
+ migrationWriteBack: meta?.migrationWriteBack
269
214
  };
270
215
  }
271
-
272
- // src/internal/pipeline-adapter.ts
273
- var _Pipelines = null;
274
- async function getPipelines() {
275
- if (!_Pipelines) {
276
- const mod = await import("@google-cloud/firestore");
277
- _Pipelines = mod.Pipelines;
216
+ function loadEdgeEntity(dir, name) {
217
+ const schema = loadSchema(dir, `edge type "${name}"`);
218
+ const edgePath = join(dir, "edge.json");
219
+ if (!existsSync(edgePath)) {
220
+ throw new DiscoveryError(
221
+ `Missing edge.json for edge type "${name}" in ${dir}. Edge entities must declare topology (from/to node types).`
222
+ );
278
223
  }
279
- return _Pipelines;
280
- }
281
- function buildFilterExpression(P, filter) {
282
- const { field: fieldName, op, value } = filter;
283
- switch (op) {
284
- case "==":
285
- return P.equal(fieldName, value);
286
- case "!=":
287
- return P.notEqual(fieldName, value);
288
- case "<":
289
- return P.lessThan(fieldName, value);
290
- case "<=":
291
- return P.lessThanOrEqual(fieldName, value);
292
- case ">":
293
- return P.greaterThan(fieldName, value);
294
- case ">=":
295
- return P.greaterThanOrEqual(fieldName, value);
296
- case "in":
297
- return P.equalAny(fieldName, value);
298
- case "not-in":
299
- return P.notEqualAny(fieldName, value);
300
- case "array-contains":
301
- return P.arrayContains(fieldName, value);
302
- case "array-contains-any":
303
- return P.arrayContainsAny(fieldName, value);
304
- default:
305
- throw new Error(`Unsupported filter op for pipeline mode: ${op}`);
224
+ const topology = readJson(edgePath);
225
+ if (!topology.from) {
226
+ throw new DiscoveryError(
227
+ `edge.json for "${name}" is missing required "from" field`
228
+ );
306
229
  }
307
- }
308
- function createPipelineQueryAdapter(db, collectionPath) {
230
+ if (!topology.to) {
231
+ throw new DiscoveryError(
232
+ `edge.json for "${name}" is missing required "to" field`
233
+ );
234
+ }
235
+ const meta = readJsonIfExists(join(dir, "meta.json"));
236
+ const sampleData = readJsonIfExists(join(dir, "sample.json"));
237
+ const viewsPath = findViewsFile(dir);
238
+ const migrationsPath = findMigrationsFile(dir);
239
+ const migrations = migrationsPath ? loadMigrations(migrationsPath, `edge type "${name}"`) : void 0;
309
240
  return {
310
- async query(filters, options) {
311
- const P = await getPipelines();
312
- let pipeline = db.pipeline().collection(collectionPath);
313
- if (filters.length === 1) {
314
- pipeline = pipeline.where(buildFilterExpression(P, filters[0]));
315
- } else if (filters.length > 1) {
316
- const [first, second, ...rest] = filters.map((f) => buildFilterExpression(P, f));
317
- pipeline = pipeline.where(P.and(first, second, ...rest));
318
- }
319
- if (options?.orderBy) {
320
- const f = P.field(options.orderBy.field);
321
- const ordering = options.orderBy.direction === "desc" ? f.descending() : f.ascending();
322
- pipeline = pipeline.sort(ordering);
323
- }
324
- if (options?.limit !== void 0) {
325
- pipeline = pipeline.limit(options.limit);
326
- }
327
- const snap = await pipeline.execute();
328
- return snap.results.map((r) => r.data());
329
- }
241
+ kind: "edge",
242
+ name,
243
+ schema,
244
+ topology,
245
+ description: meta?.description,
246
+ titleField: meta?.titleField,
247
+ subtitleField: meta?.subtitleField,
248
+ viewDefaults: meta?.viewDefaults,
249
+ viewsPath,
250
+ sampleData,
251
+ allowedIn: meta?.allowedIn,
252
+ targetGraph: topology.targetGraph ?? meta?.targetGraph,
253
+ migrations,
254
+ migrationWriteBack: meta?.migrationWriteBack
330
255
  };
331
256
  }
332
-
333
- // src/transaction.ts
334
- import { FieldValue as FieldValue3 } from "@google-cloud/firestore";
335
-
336
- // src/query-safety.ts
337
- var SAFE_INDEX_PATTERNS = [
338
- /* @__PURE__ */ new Set(["aUid", "axbType"]),
339
- /* @__PURE__ */ new Set(["axbType", "bUid"]),
340
- /* @__PURE__ */ new Set(["aType", "axbType"]),
341
- /* @__PURE__ */ new Set(["axbType", "bType"])
342
- ];
343
- function analyzeQuerySafety(filters) {
344
- const builtinFieldsPresent = /* @__PURE__ */ new Set();
345
- let hasDataFilters = false;
346
- for (const f of filters) {
347
- if (BUILTIN_FIELDS.has(f.field)) {
348
- builtinFieldsPresent.add(f.field);
349
- } else {
350
- hasDataFilters = true;
351
- }
257
+ function getSubdirectories(dir) {
258
+ if (!existsSync(dir)) return [];
259
+ return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
260
+ }
261
+ function discoverEntities(entitiesDir) {
262
+ const absDir = resolve(entitiesDir);
263
+ if (!existsSync(absDir) || !statSync(absDir).isDirectory()) {
264
+ throw new DiscoveryError(`Entities directory not found: ${entitiesDir}`);
352
265
  }
353
- for (const pattern of SAFE_INDEX_PATTERNS) {
354
- let matched = true;
355
- for (const field of pattern) {
356
- if (!builtinFieldsPresent.has(field)) {
357
- matched = false;
358
- break;
359
- }
360
- }
361
- if (matched) {
362
- return { safe: true };
363
- }
266
+ const nodes = /* @__PURE__ */ new Map();
267
+ const edges = /* @__PURE__ */ new Map();
268
+ const warnings = [];
269
+ const nodesDir = join(absDir, "nodes");
270
+ for (const name of getSubdirectories(nodesDir)) {
271
+ nodes.set(name, loadNodeEntity(join(nodesDir, name), name));
364
272
  }
365
- const presentFields = [...builtinFieldsPresent];
366
- if (presentFields.length === 0 && hasDataFilters) {
367
- return {
368
- safe: false,
369
- 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."
370
- };
273
+ const edgesDir = join(absDir, "edges");
274
+ for (const name of getSubdirectories(edgesDir)) {
275
+ edges.set(name, loadEdgeEntity(join(edgesDir, name), name));
371
276
  }
372
- if (hasDataFilters) {
373
- return {
374
- safe: false,
375
- 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.`
376
- };
277
+ const nodeNames = new Set(nodes.keys());
278
+ for (const [axbType, entity] of edges) {
279
+ const topology = entity.topology;
280
+ const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
281
+ const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
282
+ for (const ref of [...fromTypes, ...toTypes]) {
283
+ if (!nodeNames.has(ref)) {
284
+ warnings.push({
285
+ code: "DANGLING_TOPOLOGY_REF",
286
+ message: `Edge "${axbType}" references node type "${ref}" which was not found in the nodes directory`
287
+ });
288
+ }
289
+ }
377
290
  }
378
291
  return {
379
- safe: false,
380
- 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.`
292
+ result: { nodes, edges },
293
+ warnings
381
294
  };
382
295
  }
383
296
 
384
- // src/serialization.ts
385
- import { Timestamp, GeoPoint, FieldValue as FieldValue2 } from "@google-cloud/firestore";
386
- var SERIALIZATION_TAG = "__firegraph_ser__";
387
- var KNOWN_TYPES = /* @__PURE__ */ new Set(["Timestamp", "GeoPoint", "VectorValue", "DocumentReference"]);
388
- var _docRefWarned = false;
389
- function isTaggedValue(value) {
390
- if (value === null || typeof value !== "object") return false;
391
- const tag = value[SERIALIZATION_TAG];
392
- return typeof tag === "string" && KNOWN_TYPES.has(tag);
393
- }
394
- function isTimestamp(value) {
395
- return value instanceof Timestamp;
396
- }
397
- function isGeoPoint(value) {
398
- return value instanceof GeoPoint;
399
- }
400
- function isDocumentReference(value) {
401
- if (value === null || typeof value !== "object") return false;
402
- const v = value;
403
- return typeof v.path === "string" && v.firestore !== void 0 && typeof v.id === "string" && v.constructor?.name === "DocumentReference";
404
- }
405
- function isVectorValue(value) {
406
- if (value === null || typeof value !== "object") return false;
407
- const v = value;
408
- return v.constructor?.name === "VectorValue" && Array.isArray(v._values);
409
- }
410
- function serializeFirestoreTypes(data) {
411
- return serializeValue(data);
297
+ // src/internal/firestore-backend.ts
298
+ import { FieldValue } from "@google-cloud/firestore";
299
+
300
+ // src/bulk.ts
301
+ var MAX_BATCH_SIZE = 500;
302
+ var DEFAULT_MAX_RETRIES = 3;
303
+ var BASE_DELAY_MS = 200;
304
+ function sleep(ms) {
305
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
412
306
  }
413
- function serializeValue(value) {
414
- if (value === null || value === void 0) return value;
415
- if (typeof value !== "object") return value;
416
- if (isTimestamp(value)) {
417
- return { [SERIALIZATION_TAG]: "Timestamp", seconds: value.seconds, nanoseconds: value.nanoseconds };
418
- }
419
- if (isGeoPoint(value)) {
420
- return { [SERIALIZATION_TAG]: "GeoPoint", latitude: value.latitude, longitude: value.longitude };
421
- }
422
- if (isDocumentReference(value)) {
423
- return { [SERIALIZATION_TAG]: "DocumentReference", path: value.path };
424
- }
425
- if (isVectorValue(value)) {
426
- const v = value;
427
- const values = typeof v.toArray === "function" ? v.toArray() : v._values;
428
- return { [SERIALIZATION_TAG]: "VectorValue", values: [...values] };
429
- }
430
- if (Array.isArray(value)) {
431
- return value.map(serializeValue);
432
- }
433
- const result = {};
434
- for (const key of Object.keys(value)) {
435
- result[key] = serializeValue(value[key]);
307
+ function chunk(arr, size) {
308
+ const chunks = [];
309
+ for (let i = 0; i < arr.length; i += size) {
310
+ chunks.push(arr.slice(i, i + size));
436
311
  }
437
- return result;
438
- }
439
- function deserializeFirestoreTypes(data, db) {
440
- return deserializeValue(data, db);
312
+ return chunks;
441
313
  }
442
- function deserializeValue(value, db) {
443
- if (value === null || value === void 0) return value;
444
- if (typeof value !== "object") return value;
445
- if (isTimestamp(value) || isGeoPoint(value) || isDocumentReference(value) || isVectorValue(value)) {
446
- return value;
447
- }
448
- if (Array.isArray(value)) {
449
- return value.map((v) => deserializeValue(v, db));
450
- }
451
- const obj = value;
452
- if (isTaggedValue(obj)) {
453
- const tag = obj[SERIALIZATION_TAG];
454
- switch (tag) {
455
- case "Timestamp":
456
- if (typeof obj.seconds !== "number" || typeof obj.nanoseconds !== "number") return obj;
457
- return new Timestamp(obj.seconds, obj.nanoseconds);
458
- case "GeoPoint":
459
- if (typeof obj.latitude !== "number" || typeof obj.longitude !== "number") return obj;
460
- return new GeoPoint(obj.latitude, obj.longitude);
461
- case "VectorValue":
462
- if (!Array.isArray(obj.values)) return obj;
463
- return FieldValue2.vector(obj.values);
464
- case "DocumentReference":
465
- if (typeof obj.path !== "string") return obj;
466
- if (db) {
467
- return db.doc(obj.path);
468
- }
469
- if (!_docRefWarned) {
470
- _docRefWarned = true;
471
- console.warn(
472
- "[firegraph] DocumentReference encountered during migration deserialization but no Firestore instance available. The reference will remain as a tagged object with its path. Enable write-back for full reconstruction."
473
- );
474
- }
475
- return obj;
476
- default:
477
- return obj;
478
- }
479
- }
480
- const result = {};
481
- for (const key of Object.keys(obj)) {
482
- result[key] = deserializeValue(obj[key], db);
314
+ async function bulkDeleteDocIds(db, collectionPath, docIds, options) {
315
+ if (docIds.length === 0) {
316
+ return { deleted: 0, batches: 0, errors: [] };
483
317
  }
484
- return result;
485
- }
486
-
487
- // src/migration.ts
488
- async function applyMigrationChain(data, currentVersion, targetVersion, migrations) {
489
- const sorted = [...migrations].sort((a, b) => a.fromVersion - b.fromVersion);
490
- let result = { ...data };
491
- let version = currentVersion;
492
- for (const step of sorted) {
493
- if (step.fromVersion === version) {
318
+ const batchSize = Math.min(options?.batchSize ?? MAX_BATCH_SIZE, MAX_BATCH_SIZE);
319
+ const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
320
+ const onProgress = options?.onProgress;
321
+ const chunks = chunk(docIds, batchSize);
322
+ const errors = [];
323
+ let deleted = 0;
324
+ let completedBatches = 0;
325
+ for (let i = 0; i < chunks.length; i++) {
326
+ const ids = chunks[i];
327
+ let committed = false;
328
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
494
329
  try {
495
- result = await step.up(result);
330
+ const batch = db.batch();
331
+ const collectionRef = db.collection(collectionPath);
332
+ for (const id of ids) {
333
+ batch.delete(collectionRef.doc(id));
334
+ }
335
+ await batch.commit();
336
+ committed = true;
337
+ deleted += ids.length;
338
+ break;
496
339
  } catch (err) {
497
- if (err instanceof MigrationError) throw err;
498
- throw new MigrationError(
499
- `Migration from v${step.fromVersion} to v${step.toVersion} failed: ${err.message}`
500
- );
501
- }
502
- if (!result || typeof result !== "object") {
503
- throw new MigrationError(
504
- `Migration from v${step.fromVersion} to v${step.toVersion} returned invalid data (expected object)`
505
- );
506
- }
507
- version = step.toVersion;
508
- }
509
- }
510
- if (version !== targetVersion) {
511
- throw new MigrationError(
512
- `Incomplete migration chain: reached v${version} but target is v${targetVersion}`
513
- );
514
- }
515
- return result;
516
- }
517
- function validateMigrationChain(migrations, label) {
518
- if (migrations.length === 0) return;
519
- const seen = /* @__PURE__ */ new Set();
520
- for (const step of migrations) {
521
- if (step.toVersion <= step.fromVersion) {
522
- throw new MigrationError(
523
- `${label}: migration step has toVersion (${step.toVersion}) <= fromVersion (${step.fromVersion})`
524
- );
525
- }
526
- if (seen.has(step.fromVersion)) {
527
- throw new MigrationError(
528
- `${label}: duplicate migration step for fromVersion ${step.fromVersion}`
529
- );
530
- }
531
- seen.add(step.fromVersion);
532
- }
533
- const sorted = [...migrations].sort((a, b) => a.fromVersion - b.fromVersion);
534
- const targetVersion = Math.max(...migrations.map((m) => m.toVersion));
535
- let version = 0;
536
- for (const step of sorted) {
537
- if (step.fromVersion === version) {
538
- version = step.toVersion;
539
- } else if (step.fromVersion > version) {
540
- throw new MigrationError(
541
- `${label}: migration chain has a gap \u2014 no step covers v${version} \u2192 v${step.fromVersion}`
542
- );
543
- }
544
- }
545
- if (version !== targetVersion) {
546
- throw new MigrationError(
547
- `${label}: migration chain does not reach v${targetVersion} (stuck at v${version})`
548
- );
549
- }
550
- }
551
- async function migrateRecord(record, registry, globalWriteBack = "off") {
552
- const entry = registry.lookup(record.aType, record.axbType, record.bType);
553
- if (!entry?.migrations?.length || !entry.schemaVersion) {
554
- return { record, migrated: false, writeBack: "off" };
555
- }
556
- const currentVersion = record.v ?? 0;
557
- if (currentVersion >= entry.schemaVersion) {
558
- return { record, migrated: false, writeBack: "off" };
559
- }
560
- const migratedData = await applyMigrationChain(
561
- record.data,
562
- currentVersion,
563
- entry.schemaVersion,
564
- entry.migrations
565
- );
566
- const writeBack = entry.migrationWriteBack ?? globalWriteBack ?? "off";
567
- return {
568
- record: { ...record, data: migratedData, v: entry.schemaVersion },
569
- migrated: true,
570
- writeBack
571
- };
572
- }
573
- async function migrateRecords(records, registry, globalWriteBack = "off") {
574
- return Promise.all(
575
- records.map((r) => migrateRecord(r, registry, globalWriteBack))
576
- );
577
- }
578
-
579
- // src/transaction.ts
580
- var GraphTransactionImpl = class {
581
- constructor(adapter, registry, scanProtection = "error", scopePath = "", globalWriteBack = "off", db) {
582
- this.adapter = adapter;
583
- this.registry = registry;
584
- this.scanProtection = scanProtection;
585
- this.scopePath = scopePath;
586
- this.globalWriteBack = globalWriteBack;
587
- this.db = db;
588
- }
589
- async getNode(uid) {
590
- const docId = computeNodeDocId(uid);
591
- const record = await this.adapter.getDoc(docId);
592
- if (!record || !this.registry) return record;
593
- const result = await migrateRecord(record, this.registry, this.globalWriteBack);
594
- if (result.migrated && result.writeBack !== "off") {
595
- const update = {
596
- data: deserializeFirestoreTypes(result.record.data, this.db),
597
- updatedAt: FieldValue3.serverTimestamp()
598
- };
599
- if (result.record.v !== void 0) {
600
- update.v = result.record.v;
601
- }
602
- this.adapter.updateDoc(docId, update);
603
- }
604
- return result.record;
605
- }
606
- async getEdge(aUid, axbType, bUid) {
607
- const docId = computeEdgeDocId(aUid, axbType, bUid);
608
- const record = await this.adapter.getDoc(docId);
609
- if (!record || !this.registry) return record;
610
- const result = await migrateRecord(record, this.registry, this.globalWriteBack);
611
- if (result.migrated && result.writeBack !== "off") {
612
- const update = {
613
- data: deserializeFirestoreTypes(result.record.data, this.db),
614
- updatedAt: FieldValue3.serverTimestamp()
615
- };
616
- if (result.record.v !== void 0) {
617
- update.v = result.record.v;
618
- }
619
- this.adapter.updateDoc(docId, update);
620
- }
621
- return result.record;
622
- }
623
- async edgeExists(aUid, axbType, bUid) {
624
- const docId = computeEdgeDocId(aUid, axbType, bUid);
625
- const record = await this.adapter.getDoc(docId);
626
- return record !== null;
627
- }
628
- checkQuerySafety(filters, allowCollectionScan) {
629
- if (allowCollectionScan || this.scanProtection === "off") return;
630
- const result = analyzeQuerySafety(filters);
631
- if (result.safe) return;
632
- if (this.scanProtection === "error") {
633
- throw new QuerySafetyError(result.reason);
634
- }
635
- console.warn(`[firegraph] Query safety warning: ${result.reason}`);
636
- }
637
- async findEdges(params) {
638
- const plan = buildEdgeQueryPlan(params);
639
- let records;
640
- if (plan.strategy === "get") {
641
- const record = await this.adapter.getDoc(plan.docId);
642
- records = record ? [record] : [];
643
- } else {
644
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
645
- records = await this.adapter.query(plan.filters, plan.options);
646
- }
647
- return this.applyMigrations(records);
648
- }
649
- async findNodes(params) {
650
- const plan = buildNodeQueryPlan(params);
651
- let records;
652
- if (plan.strategy === "get") {
653
- const record = await this.adapter.getDoc(plan.docId);
654
- records = record ? [record] : [];
655
- } else {
656
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
657
- records = await this.adapter.query(plan.filters, plan.options);
658
- }
659
- return this.applyMigrations(records);
660
- }
661
- async applyMigrations(records) {
662
- if (!this.registry || records.length === 0) return records;
663
- const results = await migrateRecords(records, this.registry, this.globalWriteBack);
664
- for (const result of results) {
665
- if (result.migrated && result.writeBack !== "off") {
666
- const docId = result.record.axbType === NODE_RELATION ? computeNodeDocId(result.record.aUid) : computeEdgeDocId(result.record.aUid, result.record.axbType, result.record.bUid);
667
- const update = {
668
- data: deserializeFirestoreTypes(result.record.data, this.db),
669
- updatedAt: FieldValue3.serverTimestamp()
670
- };
671
- if (result.record.v !== void 0) {
672
- update.v = result.record.v;
673
- }
674
- this.adapter.updateDoc(docId, update);
675
- }
676
- }
677
- return results.map((r) => r.record);
678
- }
679
- async putNode(aType, uid, data) {
680
- if (this.registry) {
681
- this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
682
- }
683
- const docId = computeNodeDocId(uid);
684
- const record = buildNodeRecord(aType, uid, data);
685
- if (this.registry) {
686
- const entry = this.registry.lookup(aType, NODE_RELATION, aType);
687
- if (entry?.schemaVersion && entry.schemaVersion > 0) {
688
- record.v = entry.schemaVersion;
689
- }
690
- }
691
- this.adapter.setDoc(docId, record);
692
- }
693
- async putEdge(aType, aUid, axbType, bType, bUid, data) {
694
- if (this.registry) {
695
- this.registry.validate(aType, axbType, bType, data, this.scopePath);
696
- }
697
- const docId = computeEdgeDocId(aUid, axbType, bUid);
698
- const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
699
- if (this.registry) {
700
- const entry = this.registry.lookup(aType, axbType, bType);
701
- if (entry?.schemaVersion && entry.schemaVersion > 0) {
702
- record.v = entry.schemaVersion;
703
- }
704
- }
705
- this.adapter.setDoc(docId, record);
706
- }
707
- async updateNode(uid, data) {
708
- const docId = computeNodeDocId(uid);
709
- const update = {
710
- updatedAt: FieldValue3.serverTimestamp()
711
- };
712
- for (const [k, v] of Object.entries(data)) {
713
- update[`data.${k}`] = v;
714
- }
715
- this.adapter.updateDoc(docId, update);
716
- }
717
- async removeNode(uid) {
718
- const docId = computeNodeDocId(uid);
719
- this.adapter.deleteDoc(docId);
720
- }
721
- async removeEdge(aUid, axbType, bUid) {
722
- const docId = computeEdgeDocId(aUid, axbType, bUid);
723
- this.adapter.deleteDoc(docId);
724
- }
725
- };
726
-
727
- // src/batch.ts
728
- import { FieldValue as FieldValue4 } from "@google-cloud/firestore";
729
- var GraphBatchImpl = class {
730
- constructor(adapter, registry, scopePath = "") {
731
- this.adapter = adapter;
732
- this.registry = registry;
733
- this.scopePath = scopePath;
734
- }
735
- async putNode(aType, uid, data) {
736
- if (this.registry) {
737
- this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
738
- }
739
- const docId = computeNodeDocId(uid);
740
- const record = buildNodeRecord(aType, uid, data);
741
- if (this.registry) {
742
- const entry = this.registry.lookup(aType, NODE_RELATION, aType);
743
- if (entry?.schemaVersion && entry.schemaVersion > 0) {
744
- record.v = entry.schemaVersion;
745
- }
746
- }
747
- this.adapter.setDoc(docId, record);
748
- }
749
- async putEdge(aType, aUid, axbType, bType, bUid, data) {
750
- if (this.registry) {
751
- this.registry.validate(aType, axbType, bType, data, this.scopePath);
752
- }
753
- const docId = computeEdgeDocId(aUid, axbType, bUid);
754
- const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
755
- if (this.registry) {
756
- const entry = this.registry.lookup(aType, axbType, bType);
757
- if (entry?.schemaVersion && entry.schemaVersion > 0) {
758
- record.v = entry.schemaVersion;
759
- }
760
- }
761
- this.adapter.setDoc(docId, record);
762
- }
763
- async updateNode(uid, data) {
764
- const docId = computeNodeDocId(uid);
765
- const update = {
766
- updatedAt: FieldValue4.serverTimestamp()
767
- };
768
- for (const [k, v] of Object.entries(data)) {
769
- update[`data.${k}`] = v;
770
- }
771
- this.adapter.updateDoc(docId, update);
772
- }
773
- async removeNode(uid) {
774
- const docId = computeNodeDocId(uid);
775
- this.adapter.deleteDoc(docId);
776
- }
777
- async removeEdge(aUid, axbType, bUid) {
778
- const docId = computeEdgeDocId(aUid, axbType, bUid);
779
- this.adapter.deleteDoc(docId);
780
- }
781
- async commit() {
782
- await this.adapter.commit();
783
- }
784
- };
785
-
786
- // src/bulk.ts
787
- var MAX_BATCH_SIZE = 500;
788
- var DEFAULT_MAX_RETRIES = 3;
789
- var BASE_DELAY_MS = 200;
790
- function sleep(ms) {
791
- return new Promise((resolve2) => setTimeout(resolve2, ms));
792
- }
793
- function chunk(arr, size) {
794
- const chunks = [];
795
- for (let i = 0; i < arr.length; i += size) {
796
- chunks.push(arr.slice(i, i + size));
797
- }
798
- return chunks;
799
- }
800
- async function bulkDeleteDocIds(db, collectionPath, docIds, options) {
801
- if (docIds.length === 0) {
802
- return { deleted: 0, batches: 0, errors: [] };
803
- }
804
- const batchSize = Math.min(options?.batchSize ?? MAX_BATCH_SIZE, MAX_BATCH_SIZE);
805
- const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
806
- const onProgress = options?.onProgress;
807
- const chunks = chunk(docIds, batchSize);
808
- const errors = [];
809
- let deleted = 0;
810
- let completedBatches = 0;
811
- for (let i = 0; i < chunks.length; i++) {
812
- const ids = chunks[i];
813
- let committed = false;
814
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
815
- try {
816
- const batch = db.batch();
817
- const collectionRef = db.collection(collectionPath);
818
- for (const id of ids) {
819
- batch.delete(collectionRef.doc(id));
820
- }
821
- await batch.commit();
822
- committed = true;
823
- deleted += ids.length;
824
- break;
825
- } catch (err) {
826
- if (attempt < maxRetries) {
827
- const delay = BASE_DELAY_MS * Math.pow(2, attempt);
828
- await sleep(delay);
829
- } else {
830
- errors.push({
831
- batchIndex: i,
832
- error: err instanceof Error ? err : new Error(String(err)),
833
- operationCount: ids.length
834
- });
835
- }
340
+ if (attempt < maxRetries) {
341
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt);
342
+ await sleep(delay);
343
+ } else {
344
+ errors.push({
345
+ batchIndex: i,
346
+ error: err instanceof Error ? err : new Error(String(err)),
347
+ operationCount: ids.length
348
+ });
349
+ }
836
350
  }
837
351
  }
838
352
  if (committed) {
@@ -925,1044 +439,288 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
925
439
  };
926
440
  }
927
441
 
928
- // src/dynamic-registry.ts
929
- import { createHash as createHash3 } from "crypto";
930
-
931
- // src/json-schema.ts
932
- import Ajv from "ajv";
933
- import addFormats from "ajv-formats";
934
- var ajv = new Ajv({ allErrors: true, strict: false });
935
- addFormats(ajv);
936
- function compileSchema(schema, label) {
937
- const validate = ajv.compile(schema);
938
- return (data) => {
939
- if (!validate(data)) {
940
- const errors = validate.errors ?? [];
941
- const messages = errors.map((err) => `${err.instancePath || "/"}${err.message ? ": " + err.message : ""}`).join("; ");
942
- throw new ValidationError(
943
- `Data validation failed${label ? " for " + label : ""}: ${messages}`,
944
- errors
945
- );
946
- }
947
- };
948
- }
949
- function jsonSchemaToFieldMeta(schema) {
950
- if (!schema || schema.type !== "object" || !schema.properties) return [];
951
- const requiredSet = new Set(
952
- Array.isArray(schema.required) ? schema.required : []
953
- );
954
- return Object.entries(schema.properties).map(
955
- ([name, prop]) => propertyToFieldMeta(name, prop, requiredSet.has(name))
956
- );
957
- }
958
- function propertyToFieldMeta(name, prop, required) {
959
- if (!prop) return { name, type: "unknown", required };
960
- if (Array.isArray(prop.enum)) {
961
- return {
962
- name,
963
- type: "enum",
964
- required,
965
- enumValues: prop.enum,
966
- description: prop.description
967
- };
968
- }
969
- if (Array.isArray(prop.oneOf) || Array.isArray(prop.anyOf)) {
970
- const variants = prop.oneOf ?? prop.anyOf;
971
- const nonNull = variants.filter((v) => v.type !== "null");
972
- if (nonNull.length === 1) {
973
- return propertyToFieldMeta(name, nonNull[0], false);
974
- }
975
- return { name, type: "unknown", required, description: prop.description };
976
- }
977
- const type = prop.type;
978
- if (type === "string") {
979
- return {
980
- name,
981
- type: "string",
982
- required,
983
- minLength: prop.minLength,
984
- maxLength: prop.maxLength,
985
- pattern: prop.pattern,
986
- description: prop.description
987
- };
988
- }
989
- if (type === "number" || type === "integer") {
990
- return {
991
- name,
992
- type: "number",
993
- required,
994
- min: prop.minimum,
995
- max: prop.maximum,
996
- isInt: type === "integer" ? true : void 0,
997
- description: prop.description
998
- };
999
- }
1000
- if (type === "boolean") {
1001
- return { name, type: "boolean", required, description: prop.description };
1002
- }
1003
- if (type === "array") {
1004
- const itemMeta = prop.items ? propertyToFieldMeta("item", prop.items, true) : void 0;
1005
- return {
1006
- name,
1007
- type: "array",
1008
- required,
1009
- itemMeta,
1010
- description: prop.description
1011
- };
1012
- }
1013
- if (type === "object") {
1014
- return {
1015
- name,
1016
- type: "object",
1017
- required,
1018
- fields: jsonSchemaToFieldMeta(prop),
1019
- description: prop.description
1020
- };
1021
- }
1022
- return { name, type: "unknown", required, description: prop.description };
1023
- }
1024
-
1025
- // src/scope.ts
1026
- function matchScope(scopePath, pattern) {
1027
- if (pattern === "root") return scopePath === "";
1028
- if (pattern === "**") return true;
1029
- const pathSegments = scopePath === "" ? [] : scopePath.split("/");
1030
- const patternSegments = pattern.split("/");
1031
- return matchSegments(pathSegments, 0, patternSegments, 0);
1032
- }
1033
- function matchScopeAny(scopePath, patterns) {
1034
- if (!patterns || patterns.length === 0) return true;
1035
- return patterns.some((p) => matchScope(scopePath, p));
1036
- }
1037
- function matchSegments(path, pi, pattern, qi) {
1038
- if (pi === path.length && qi === pattern.length) return true;
1039
- if (qi === pattern.length) return false;
1040
- const seg = pattern[qi];
1041
- if (seg === "**") {
1042
- if (qi === pattern.length - 1) return true;
1043
- for (let skip = 0; skip <= path.length - pi; skip++) {
1044
- if (matchSegments(path, pi + skip, pattern, qi + 1)) return true;
1045
- }
1046
- return false;
1047
- }
1048
- if (pi === path.length) return false;
1049
- if (seg === "*") {
1050
- return matchSegments(path, pi + 1, pattern, qi + 1);
1051
- }
1052
- if (path[pi] === seg) {
1053
- return matchSegments(path, pi + 1, pattern, qi + 1);
1054
- }
1055
- return false;
1056
- }
1057
-
1058
- // src/registry.ts
1059
- function tripleKey(aType, axbType, bType) {
1060
- return `${aType}:${axbType}:${bType}`;
1061
- }
1062
- function tripleKeyFor(e) {
1063
- return tripleKey(e.aType, e.axbType, e.bType);
1064
- }
1065
- function createRegistry(input) {
1066
- const map = /* @__PURE__ */ new Map();
1067
- let entries;
1068
- if (Array.isArray(input)) {
1069
- entries = input;
1070
- } else {
1071
- entries = discoveryToEntries(input);
1072
- }
1073
- const entryList = Object.freeze([...entries]);
1074
- for (const entry of entries) {
1075
- if (entry.targetGraph && entry.targetGraph.includes("/")) {
1076
- throw new ValidationError(
1077
- `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType}) has invalid targetGraph "${entry.targetGraph}" \u2014 must be a single segment (no "/")`
1078
- );
1079
- }
1080
- if (entry.migrations?.length) {
1081
- const label = `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`;
1082
- validateMigrationChain(entry.migrations, label);
1083
- entry.schemaVersion = Math.max(...entry.migrations.map((m) => m.toVersion));
1084
- } else {
1085
- entry.schemaVersion = void 0;
1086
- }
1087
- const key = tripleKey(entry.aType, entry.axbType, entry.bType);
1088
- const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
1089
- map.set(key, { entry, validate: validator });
1090
- }
1091
- const axbIndex = /* @__PURE__ */ new Map();
1092
- const axbBuild = /* @__PURE__ */ new Map();
1093
- for (const entry of entries) {
1094
- const existing = axbBuild.get(entry.axbType);
1095
- if (existing) {
1096
- existing.push(entry);
1097
- } else {
1098
- axbBuild.set(entry.axbType, [entry]);
1099
- }
1100
- }
1101
- for (const [key, arr] of axbBuild) {
1102
- axbIndex.set(key, Object.freeze(arr));
1103
- }
442
+ // src/internal/firestore-adapter.ts
443
+ function createFirestoreAdapter(db, collectionPath) {
444
+ const collectionRef = db.collection(collectionPath);
1104
445
  return {
1105
- lookup(aType, axbType, bType) {
1106
- return map.get(tripleKey(aType, axbType, bType))?.entry;
446
+ collectionPath,
447
+ async getDoc(docId) {
448
+ const snap = await collectionRef.doc(docId).get();
449
+ if (!snap.exists) return null;
450
+ return snap.data();
451
+ },
452
+ async setDoc(docId, data) {
453
+ await collectionRef.doc(docId).set(data);
1107
454
  },
1108
- lookupByAxbType(axbType) {
1109
- return axbIndex.get(axbType) ?? [];
455
+ async updateDoc(docId, data) {
456
+ await collectionRef.doc(docId).update(data);
457
+ },
458
+ async deleteDoc(docId) {
459
+ await collectionRef.doc(docId).delete();
1110
460
  },
1111
- validate(aType, axbType, bType, data, scopePath) {
1112
- const rec = map.get(tripleKey(aType, axbType, bType));
1113
- if (!rec) {
1114
- throw new RegistryViolationError(aType, axbType, bType);
461
+ async query(filters, options) {
462
+ let q = collectionRef;
463
+ for (const f of filters) {
464
+ q = q.where(f.field, f.op, f.value);
1115
465
  }
1116
- if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
1117
- if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
1118
- throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
1119
- }
466
+ if (options?.orderBy) {
467
+ q = q.orderBy(options.orderBy.field, options.orderBy.direction ?? "asc");
1120
468
  }
1121
- if (rec.validate) {
1122
- try {
1123
- rec.validate(data);
1124
- } catch (err) {
1125
- if (err instanceof ValidationError) throw err;
1126
- throw new ValidationError(
1127
- `Data validation failed for (${aType}) -[${axbType}]-> (${bType})`,
1128
- err
1129
- );
1130
- }
469
+ if (options?.limit !== void 0) {
470
+ q = q.limit(options.limit);
1131
471
  }
1132
- },
1133
- entries() {
1134
- return entryList;
472
+ const snap = await q.get();
473
+ return snap.docs.map((doc) => doc.data());
1135
474
  }
1136
475
  };
1137
476
  }
1138
- function createMergedRegistry(base, extension) {
1139
- const baseKeys = new Set(base.entries().map(tripleKeyFor));
477
+ function createTransactionAdapter(db, collectionPath, tx) {
478
+ const collectionRef = db.collection(collectionPath);
1140
479
  return {
1141
- lookup(aType, axbType, bType) {
1142
- return base.lookup(aType, axbType, bType) ?? extension.lookup(aType, axbType, bType);
480
+ async getDoc(docId) {
481
+ const snap = await tx.get(collectionRef.doc(docId));
482
+ if (!snap.exists) return null;
483
+ return snap.data();
1143
484
  },
1144
- lookupByAxbType(axbType) {
1145
- const baseResults = base.lookupByAxbType(axbType);
1146
- const extResults = extension.lookupByAxbType(axbType);
1147
- if (extResults.length === 0) return baseResults;
1148
- if (baseResults.length === 0) return extResults;
1149
- const seen = new Set(baseResults.map(tripleKeyFor));
1150
- const merged = [...baseResults];
1151
- for (const entry of extResults) {
1152
- if (!seen.has(tripleKeyFor(entry))) {
1153
- merged.push(entry);
1154
- }
1155
- }
1156
- return Object.freeze(merged);
485
+ setDoc(docId, data) {
486
+ tx.set(collectionRef.doc(docId), data);
1157
487
  },
1158
- validate(aType, axbType, bType, data, scopePath) {
1159
- if (baseKeys.has(tripleKey(aType, axbType, bType))) {
1160
- return base.validate(aType, axbType, bType, data, scopePath);
1161
- }
1162
- return extension.validate(aType, axbType, bType, data, scopePath);
488
+ updateDoc(docId, data) {
489
+ tx.update(collectionRef.doc(docId), data);
1163
490
  },
1164
- entries() {
1165
- const extEntries = extension.entries();
1166
- if (extEntries.length === 0) return base.entries();
1167
- const merged = [...base.entries()];
1168
- for (const entry of extEntries) {
1169
- if (!baseKeys.has(tripleKeyFor(entry))) {
1170
- merged.push(entry);
1171
- }
491
+ deleteDoc(docId) {
492
+ tx.delete(collectionRef.doc(docId));
493
+ },
494
+ async query(filters, options) {
495
+ let q = collectionRef;
496
+ for (const f of filters) {
497
+ q = q.where(f.field, f.op, f.value);
1172
498
  }
1173
- return Object.freeze(merged);
1174
- }
1175
- };
1176
- }
1177
- function discoveryToEntries(discovery) {
1178
- const entries = [];
1179
- for (const [name, entity] of discovery.nodes) {
1180
- entries.push({
1181
- aType: name,
1182
- axbType: NODE_RELATION,
1183
- bType: name,
1184
- jsonSchema: entity.schema,
1185
- description: entity.description,
1186
- titleField: entity.titleField,
1187
- subtitleField: entity.subtitleField,
1188
- allowedIn: entity.allowedIn,
1189
- migrations: entity.migrations,
1190
- migrationWriteBack: entity.migrationWriteBack
1191
- });
1192
- }
1193
- for (const [axbType, entity] of discovery.edges) {
1194
- const topology = entity.topology;
1195
- if (!topology) continue;
1196
- const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
1197
- const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
1198
- const resolvedTargetGraph = entity.targetGraph ?? topology.targetGraph;
1199
- if (resolvedTargetGraph && resolvedTargetGraph.includes("/")) {
1200
- throw new ValidationError(
1201
- `Edge "${axbType}" has invalid targetGraph "${resolvedTargetGraph}" \u2014 must be a single segment (no "/")`
1202
- );
1203
- }
1204
- for (const aType of fromTypes) {
1205
- for (const bType of toTypes) {
1206
- entries.push({
1207
- aType,
1208
- axbType,
1209
- bType,
1210
- jsonSchema: entity.schema,
1211
- description: entity.description,
1212
- inverseLabel: topology.inverseLabel,
1213
- titleField: entity.titleField,
1214
- subtitleField: entity.subtitleField,
1215
- allowedIn: entity.allowedIn,
1216
- targetGraph: resolvedTargetGraph,
1217
- migrations: entity.migrations,
1218
- migrationWriteBack: entity.migrationWriteBack
1219
- });
499
+ if (options?.orderBy) {
500
+ q = q.orderBy(options.orderBy.field, options.orderBy.direction ?? "asc");
501
+ }
502
+ if (options?.limit !== void 0) {
503
+ q = q.limit(options.limit);
1220
504
  }
505
+ const snap = await tx.get(q);
506
+ return snap.docs.map((doc) => doc.data());
1221
507
  }
1222
- }
1223
- return entries;
508
+ };
1224
509
  }
1225
-
1226
- // src/sandbox.ts
1227
- import { Worker } from "worker_threads";
1228
- import { createHash as createHash2 } from "crypto";
1229
- var _worker = null;
1230
- var _requestId = 0;
1231
- var _pending = /* @__PURE__ */ new Map();
1232
- var WORKER_SOURCE = [
1233
- `'use strict';`,
1234
- `var _wt = require('node:worker_threads');`,
1235
- `var _mod = require('node:module');`,
1236
- `var _crypto = require('node:crypto');`,
1237
- `var parentPort = _wt.parentPort;`,
1238
- `var workerData = _wt.workerData;`,
1239
- ``,
1240
- `// Load SES using the parent module's resolution context`,
1241
- `var esmRequire = _mod.createRequire(workerData.parentUrl);`,
1242
- `esmRequire('ses');`,
1243
- ``,
1244
- `lockdown({`,
1245
- ` errorTaming: 'unsafe',`,
1246
- ` consoleTaming: 'unsafe',`,
1247
- ` evalTaming: 'safe-eval',`,
1248
- ` overrideTaming: 'moderate',`,
1249
- ` stackFiltering: 'verbose'`,
1250
- `});`,
1251
- ``,
1252
- `// Defense-in-depth: verify lockdown() actually hardened JSON.`,
1253
- `if (!Object.isFrozen(JSON)) {`,
1254
- ` throw new Error('SES lockdown failed: JSON is not frozen');`,
1255
- `}`,
1256
- ``,
1257
- `var cache = new Map();`,
1258
- ``,
1259
- `function hashSource(s) {`,
1260
- ` return _crypto.createHash('sha256').update(s).digest('hex');`,
1261
- `}`,
1262
- ``,
1263
- `function buildWrapper(source) {`,
1264
- ` return '(function() {' +`,
1265
- ` ' var fn = (' + source + ');\\n' +`,
1266
- ` ' if (typeof fn !== "function") return null;\\n' +`,
1267
- ` ' return function(jsonIn) {\\n' +`,
1268
- ` ' var data = JSON.parse(jsonIn);\\n' +`,
1269
- ` ' var result = fn(data);\\n' +`,
1270
- ` ' if (result !== null && typeof result === "object" && typeof result.then === "function") {\\n' +`,
1271
- ` ' return result.then(function(r) { return JSON.stringify(r); });\\n' +`,
1272
- ` ' }\\n' +`,
1273
- ` ' return JSON.stringify(result);\\n' +`,
1274
- ` ' };\\n' +`,
1275
- ` '})()';`,
1276
- `}`,
1277
- ``,
1278
- `function compileSource(source) {`,
1279
- ` var key = hashSource(source);`,
1280
- ` var cached = cache.get(key);`,
1281
- ` if (cached) return cached;`,
1282
- ``,
1283
- ` var compartmentFn;`,
1284
- ` try {`,
1285
- ` var c = new Compartment({ JSON: JSON });`,
1286
- ` compartmentFn = c.evaluate(buildWrapper(source));`,
1287
- ` } catch (err) {`,
1288
- ` throw new Error('Failed to compile migration source: ' + (err.message || String(err)));`,
1289
- ` }`,
1290
- ``,
1291
- ` if (typeof compartmentFn !== 'function') {`,
1292
- ` throw new Error('Migration source did not produce a function: ' + source.slice(0, 80));`,
1293
- ` }`,
1294
- ``,
1295
- ` cache.set(key, compartmentFn);`,
1296
- ` return compartmentFn;`,
1297
- `}`,
1298
- ``,
1299
- `parentPort.on('message', function(msg) {`,
1300
- ` var id = msg.id;`,
1301
- ` try {`,
1302
- ` if (msg.type === 'compile') {`,
1303
- ` compileSource(msg.source);`,
1304
- ` parentPort.postMessage({ id: id, type: 'compiled' });`,
1305
- ` return;`,
1306
- ` }`,
1307
- ` if (msg.type === 'execute') {`,
1308
- ` var fn = compileSource(msg.source);`,
1309
- ` var raw;`,
1310
- ` try {`,
1311
- ` raw = fn(msg.jsonData);`,
1312
- ` } catch (err) {`,
1313
- ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration function threw: ' + (err.message || String(err)) });`,
1314
- ` return;`,
1315
- ` }`,
1316
- ` if (raw !== null && typeof raw === 'object' && typeof raw.then === 'function') {`,
1317
- ` raw.then(`,
1318
- ` function(jsonResult) {`,
1319
- ` if (jsonResult === undefined || jsonResult === null) {`,
1320
- ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration returned a non-JSON-serializable value' });`,
1321
- ` } else {`,
1322
- ` parentPort.postMessage({ id: id, type: 'result', jsonResult: jsonResult });`,
1323
- ` }`,
1324
- ` },`,
1325
- ` function(err) {`,
1326
- ` parentPort.postMessage({ id: id, type: 'error', message: 'Async migration function threw: ' + (err.message || String(err)) });`,
1327
- ` }`,
1328
- ` );`,
1329
- ` return;`,
1330
- ` }`,
1331
- ` if (raw === undefined || raw === null) {`,
1332
- ` parentPort.postMessage({ id: id, type: 'error', message: 'Migration returned a non-JSON-serializable value' });`,
1333
- ` } else {`,
1334
- ` parentPort.postMessage({ id: id, type: 'result', jsonResult: raw });`,
1335
- ` }`,
1336
- ` }`,
1337
- ` } catch (err) {`,
1338
- ` parentPort.postMessage({ id: id, type: 'error', message: err.message || String(err) });`,
1339
- ` }`,
1340
- `});`
1341
- ].join("\n");
1342
- function ensureWorker() {
1343
- if (_worker) return _worker;
1344
- _worker = new Worker(WORKER_SOURCE, {
1345
- eval: true,
1346
- workerData: { parentUrl: import.meta.url }
1347
- });
1348
- _worker.unref();
1349
- _worker.on("message", (msg) => {
1350
- if (msg.id === void 0) return;
1351
- const pending = _pending.get(msg.id);
1352
- if (!pending) return;
1353
- _pending.delete(msg.id);
1354
- if (msg.type === "error") {
1355
- pending.reject(new MigrationError(msg.message ?? "Unknown sandbox error"));
1356
- } else {
1357
- pending.resolve(msg);
1358
- }
1359
- });
1360
- _worker.on("error", (err) => {
1361
- for (const [, p] of _pending) {
1362
- p.reject(new MigrationError(`Sandbox worker error: ${err.message}`));
1363
- }
1364
- _pending.clear();
1365
- _worker = null;
1366
- });
1367
- _worker.on("exit", (code) => {
1368
- if (_pending.size > 0) {
1369
- for (const [, p] of _pending) {
1370
- p.reject(new MigrationError(`Sandbox worker exited with code ${code}`));
1371
- }
1372
- _pending.clear();
510
+ function createBatchAdapter(db, collectionPath) {
511
+ const collectionRef = db.collection(collectionPath);
512
+ const batch = db.batch();
513
+ return {
514
+ setDoc(docId, data) {
515
+ batch.set(collectionRef.doc(docId), data);
516
+ },
517
+ updateDoc(docId, data) {
518
+ batch.update(collectionRef.doc(docId), data);
519
+ },
520
+ deleteDoc(docId) {
521
+ batch.delete(collectionRef.doc(docId));
522
+ },
523
+ async commit() {
524
+ await batch.commit();
1373
525
  }
1374
- _worker = null;
1375
- });
1376
- return _worker;
1377
- }
1378
- function sendToWorker(msg) {
1379
- const worker = ensureWorker();
1380
- if (_requestId >= Number.MAX_SAFE_INTEGER) _requestId = 0;
1381
- const id = ++_requestId;
1382
- return new Promise((resolve2, reject) => {
1383
- _pending.set(id, { resolve: resolve2, reject });
1384
- worker.postMessage({ ...msg, id });
1385
- });
526
+ };
1386
527
  }
1387
- var compiledCache = /* @__PURE__ */ new WeakMap();
1388
- function getExecutorCache(executor) {
1389
- let cache = compiledCache.get(executor);
1390
- if (!cache) {
1391
- cache = /* @__PURE__ */ new Map();
1392
- compiledCache.set(executor, cache);
1393
- }
1394
- return cache;
528
+
529
+ // src/internal/pipeline-adapter.ts
530
+ var _Pipelines = null;
531
+ async function getPipelines() {
532
+ if (!_Pipelines) {
533
+ const mod = await import("@google-cloud/firestore");
534
+ _Pipelines = mod.Pipelines;
535
+ }
536
+ return _Pipelines;
1395
537
  }
1396
- function hashSource(source) {
1397
- return createHash2("sha256").update(source).digest("hex");
538
+ function buildFilterExpression(P, filter) {
539
+ const { field: fieldName, op, value } = filter;
540
+ switch (op) {
541
+ case "==":
542
+ return P.equal(fieldName, value);
543
+ case "!=":
544
+ return P.notEqual(fieldName, value);
545
+ case "<":
546
+ return P.lessThan(fieldName, value);
547
+ case "<=":
548
+ return P.lessThanOrEqual(fieldName, value);
549
+ case ">":
550
+ return P.greaterThan(fieldName, value);
551
+ case ">=":
552
+ return P.greaterThanOrEqual(fieldName, value);
553
+ case "in":
554
+ return P.equalAny(fieldName, value);
555
+ case "not-in":
556
+ return P.notEqualAny(fieldName, value);
557
+ case "array-contains":
558
+ return P.arrayContains(fieldName, value);
559
+ case "array-contains-any":
560
+ return P.arrayContainsAny(fieldName, value);
561
+ default:
562
+ throw new Error(`Unsupported filter op for pipeline mode: ${op}`);
563
+ }
1398
564
  }
1399
- function defaultExecutor(source) {
1400
- ensureWorker();
1401
- return ((data) => {
1402
- const jsonData = JSON.stringify(serializeFirestoreTypes(data));
1403
- return sendToWorker({ type: "execute", source, jsonData }).then(
1404
- (response) => {
1405
- if (response.jsonResult === void 0 || response.jsonResult === null) {
1406
- throw new MigrationError("Migration returned a non-JSON-serializable value");
1407
- }
1408
- try {
1409
- return deserializeFirestoreTypes(JSON.parse(response.jsonResult));
1410
- } catch {
1411
- throw new MigrationError("Migration returned a non-JSON-serializable value");
1412
- }
565
+ function createPipelineQueryAdapter(db, collectionPath) {
566
+ return {
567
+ async query(filters, options) {
568
+ const P = await getPipelines();
569
+ let pipeline = db.pipeline().collection(collectionPath);
570
+ if (filters.length === 1) {
571
+ pipeline = pipeline.where(buildFilterExpression(P, filters[0]));
572
+ } else if (filters.length > 1) {
573
+ const [first, second, ...rest] = filters.map((f) => buildFilterExpression(P, f));
574
+ pipeline = pipeline.where(P.and(first, second, ...rest));
1413
575
  }
1414
- );
1415
- });
576
+ if (options?.orderBy) {
577
+ const f = P.field(options.orderBy.field);
578
+ const ordering = options.orderBy.direction === "desc" ? f.descending() : f.ascending();
579
+ pipeline = pipeline.sort(ordering);
580
+ }
581
+ if (options?.limit !== void 0) {
582
+ pipeline = pipeline.limit(options.limit);
583
+ }
584
+ const snap = await pipeline.execute();
585
+ return snap.results.map((r) => r.data());
586
+ }
587
+ };
1416
588
  }
1417
- async function precompileSource(source, executor) {
1418
- if (executor && executor !== defaultExecutor) {
1419
- try {
1420
- executor(source);
1421
- } catch (err) {
1422
- if (err instanceof MigrationError) throw err;
1423
- throw new MigrationError(
1424
- `Failed to compile migration source: ${err.message}`
1425
- );
589
+
590
+ // src/internal/firestore-backend.ts
591
+ function buildFirestoreUpdate(update, db) {
592
+ const out = {
593
+ updatedAt: FieldValue.serverTimestamp()
594
+ };
595
+ if (update.replaceData) {
596
+ out.data = deserializeFirestoreTypes(update.replaceData, db);
597
+ }
598
+ if (update.dataFields) {
599
+ for (const [k, v] of Object.entries(update.dataFields)) {
600
+ out[`data.${k}`] = v;
1426
601
  }
1427
- return;
1428
602
  }
1429
- await sendToWorker({ type: "compile", source });
1430
- }
1431
- function compileMigrationFn(source, executor = defaultExecutor) {
1432
- const cache = getExecutorCache(executor);
1433
- const key = hashSource(source);
1434
- const cached = cache.get(key);
1435
- if (cached) return cached;
1436
- try {
1437
- const fn = executor(source);
1438
- cache.set(key, fn);
1439
- return fn;
1440
- } catch (err) {
1441
- if (err instanceof MigrationError) throw err;
1442
- throw new MigrationError(
1443
- `Failed to compile migration source: ${err.message}`
1444
- );
603
+ if (update.v !== void 0) {
604
+ out.v = update.v;
1445
605
  }
606
+ return out;
1446
607
  }
1447
- function compileMigrations(stored, executor) {
1448
- return stored.map((step) => ({
1449
- fromVersion: step.fromVersion,
1450
- toVersion: step.toVersion,
1451
- up: compileMigrationFn(step.up, executor)
1452
- }));
1453
- }
1454
- async function destroySandboxWorker() {
1455
- if (!_worker) return;
1456
- const w = _worker;
1457
- _worker = null;
1458
- for (const [, p] of _pending) {
1459
- p.reject(new MigrationError("Sandbox worker terminated"));
1460
- }
1461
- _pending.clear();
1462
- await w.terminate();
608
+ function stampWritableRecord(record) {
609
+ const now = FieldValue.serverTimestamp();
610
+ const out = {
611
+ aType: record.aType,
612
+ aUid: record.aUid,
613
+ axbType: record.axbType,
614
+ bType: record.bType,
615
+ bUid: record.bUid,
616
+ data: record.data,
617
+ createdAt: now,
618
+ updatedAt: now
619
+ };
620
+ if (record.v !== void 0) out.v = record.v;
621
+ return out;
1463
622
  }
1464
-
1465
- // src/dynamic-registry.ts
1466
- var META_NODE_TYPE = "nodeType";
1467
- var META_EDGE_TYPE = "edgeType";
1468
- var STORED_MIGRATION_STEP_SCHEMA = {
1469
- type: "object",
1470
- required: ["fromVersion", "toVersion", "up"],
1471
- properties: {
1472
- fromVersion: { type: "integer", minimum: 0 },
1473
- toVersion: { type: "integer", minimum: 1 },
1474
- up: { type: "string", minLength: 1 }
1475
- },
1476
- additionalProperties: false
1477
- };
1478
- var NODE_TYPE_SCHEMA = {
1479
- type: "object",
1480
- required: ["name", "jsonSchema"],
1481
- properties: {
1482
- name: { type: "string", minLength: 1 },
1483
- jsonSchema: { type: "object" },
1484
- description: { type: "string" },
1485
- titleField: { type: "string" },
1486
- subtitleField: { type: "string" },
1487
- viewTemplate: { type: "string" },
1488
- viewCss: { type: "string" },
1489
- allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
1490
- schemaVersion: { type: "integer", minimum: 0 },
1491
- migrations: { type: "array", items: STORED_MIGRATION_STEP_SCHEMA },
1492
- migrationWriteBack: { type: "string", enum: ["off", "eager", "background"] }
1493
- },
1494
- additionalProperties: false
1495
- };
1496
- var EDGE_TYPE_SCHEMA = {
1497
- type: "object",
1498
- required: ["name", "from", "to"],
1499
- properties: {
1500
- name: { type: "string", minLength: 1 },
1501
- from: {
1502
- oneOf: [
1503
- { type: "string", minLength: 1 },
1504
- { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 }
1505
- ]
1506
- },
1507
- to: {
1508
- oneOf: [
1509
- { type: "string", minLength: 1 },
1510
- { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 }
1511
- ]
1512
- },
1513
- jsonSchema: { type: "object" },
1514
- inverseLabel: { type: "string" },
1515
- description: { type: "string" },
1516
- titleField: { type: "string" },
1517
- subtitleField: { type: "string" },
1518
- viewTemplate: { type: "string" },
1519
- viewCss: { type: "string" },
1520
- allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
1521
- targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" },
1522
- schemaVersion: { type: "integer", minimum: 0 },
1523
- migrations: { type: "array", items: STORED_MIGRATION_STEP_SCHEMA },
1524
- migrationWriteBack: { type: "string", enum: ["off", "eager", "background"] }
1525
- },
1526
- additionalProperties: false
623
+ var FirestoreTransactionBackend = class {
624
+ constructor(adapter, db) {
625
+ this.adapter = adapter;
626
+ this.db = db;
627
+ }
628
+ getDoc(docId) {
629
+ return this.adapter.getDoc(docId);
630
+ }
631
+ query(filters, options) {
632
+ return this.adapter.query(filters, options);
633
+ }
634
+ async setDoc(docId, record) {
635
+ this.adapter.setDoc(docId, stampWritableRecord(record));
636
+ }
637
+ async updateDoc(docId, update) {
638
+ this.adapter.updateDoc(docId, buildFirestoreUpdate(update, this.db));
639
+ }
640
+ async deleteDoc(docId) {
641
+ this.adapter.deleteDoc(docId);
642
+ }
1527
643
  };
1528
- var BOOTSTRAP_ENTRIES = [
1529
- {
1530
- aType: META_NODE_TYPE,
1531
- axbType: NODE_RELATION,
1532
- bType: META_NODE_TYPE,
1533
- jsonSchema: NODE_TYPE_SCHEMA,
1534
- description: "Meta-type: defines a node type"
1535
- },
1536
- {
1537
- aType: META_EDGE_TYPE,
1538
- axbType: NODE_RELATION,
1539
- bType: META_EDGE_TYPE,
1540
- jsonSchema: EDGE_TYPE_SCHEMA,
1541
- description: "Meta-type: defines an edge type"
644
+ var FirestoreBatchBackend = class {
645
+ constructor(adapter, db) {
646
+ this.adapter = adapter;
647
+ this.db = db;
1542
648
  }
1543
- ];
1544
- function createBootstrapRegistry() {
1545
- return createRegistry([...BOOTSTRAP_ENTRIES]);
1546
- }
1547
- function generateDeterministicUid(metaType, name) {
1548
- const hash = createHash3("sha256").update(`${metaType}:${name}`).digest("base64url");
1549
- return hash.slice(0, 21);
1550
- }
1551
- async function createRegistryFromGraph(reader, executor) {
1552
- const [nodeTypes, edgeTypes] = await Promise.all([
1553
- reader.findNodes({ aType: META_NODE_TYPE }),
1554
- reader.findNodes({ aType: META_EDGE_TYPE })
1555
- ]);
1556
- const entries = [...BOOTSTRAP_ENTRIES];
1557
- const prevalidations = [];
1558
- for (const record of nodeTypes) {
1559
- const data = record.data;
1560
- if (data.migrations) {
1561
- for (const m of data.migrations) {
1562
- prevalidations.push(precompileSource(m.up, executor));
1563
- }
1564
- }
649
+ setDoc(docId, record) {
650
+ this.adapter.setDoc(docId, stampWritableRecord(record));
1565
651
  }
1566
- for (const record of edgeTypes) {
1567
- const data = record.data;
1568
- if (data.migrations) {
1569
- for (const m of data.migrations) {
1570
- prevalidations.push(precompileSource(m.up, executor));
1571
- }
1572
- }
652
+ updateDoc(docId, update) {
653
+ this.adapter.updateDoc(docId, buildFirestoreUpdate(update, this.db));
1573
654
  }
1574
- await Promise.all(prevalidations);
1575
- for (const record of nodeTypes) {
1576
- const data = record.data;
1577
- entries.push({
1578
- aType: data.name,
1579
- axbType: NODE_RELATION,
1580
- bType: data.name,
1581
- jsonSchema: data.jsonSchema,
1582
- description: data.description,
1583
- titleField: data.titleField,
1584
- subtitleField: data.subtitleField,
1585
- allowedIn: data.allowedIn,
1586
- migrations: data.migrations ? compileMigrations(data.migrations, executor) : void 0,
1587
- migrationWriteBack: data.migrationWriteBack
1588
- });
655
+ deleteDoc(docId) {
656
+ this.adapter.deleteDoc(docId);
1589
657
  }
1590
- for (const record of edgeTypes) {
1591
- const data = record.data;
1592
- const fromTypes = Array.isArray(data.from) ? data.from : [data.from];
1593
- const toTypes = Array.isArray(data.to) ? data.to : [data.to];
1594
- const compiledMigrations = data.migrations ? compileMigrations(data.migrations, executor) : void 0;
1595
- for (const aType of fromTypes) {
1596
- for (const bType of toTypes) {
1597
- entries.push({
1598
- aType,
1599
- axbType: data.name,
1600
- bType,
1601
- jsonSchema: data.jsonSchema,
1602
- description: data.description,
1603
- inverseLabel: data.inverseLabel,
1604
- titleField: data.titleField,
1605
- subtitleField: data.subtitleField,
1606
- allowedIn: data.allowedIn,
1607
- targetGraph: data.targetGraph,
1608
- migrations: compiledMigrations,
1609
- migrationWriteBack: data.migrationWriteBack
1610
- });
1611
- }
1612
- }
658
+ commit() {
659
+ return this.adapter.commit();
1613
660
  }
1614
- return createRegistry(entries);
1615
- }
1616
-
1617
- // src/client.ts
1618
- var _standardModeWarned = false;
1619
- var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
1620
- var GraphClientImpl = class _GraphClientImpl {
1621
- constructor(db, collectionPath, options, scopePath = "") {
661
+ };
662
+ var FirestoreBackendImpl = class _FirestoreBackendImpl {
663
+ constructor(db, collectionPath, queryMode, scopePath) {
1622
664
  this.db = db;
665
+ this.queryMode = queryMode;
666
+ this.collectionPath = collectionPath;
1623
667
  this.scopePath = scopePath;
1624
668
  this.adapter = createFirestoreAdapter(db, collectionPath);
1625
- this.globalWriteBack = options?.migrationWriteBack ?? "off";
1626
- this.migrationSandbox = options?.migrationSandbox;
1627
- if (options?.registryMode) {
1628
- this.dynamicConfig = options.registryMode;
1629
- this.bootstrapRegistry = createBootstrapRegistry();
1630
- if (options.registry) {
1631
- this.staticRegistry = options.registry;
1632
- }
1633
- const metaCollectionPath = options.registryMode.collection;
1634
- if (metaCollectionPath && metaCollectionPath !== collectionPath) {
1635
- this.metaAdapter = createFirestoreAdapter(db, metaCollectionPath);
1636
- }
1637
- } else {
1638
- this.staticRegistry = options?.registry;
1639
- }
1640
- const requestedMode = options?.queryMode ?? "pipeline";
1641
- const isEmulator = !!process.env.FIRESTORE_EMULATOR_HOST;
1642
- if (isEmulator) {
1643
- this.queryMode = "standard";
1644
- } else {
1645
- this.queryMode = requestedMode;
1646
- }
1647
- if (this.queryMode === "standard" && !isEmulator && requestedMode === "standard" && !_standardModeWarned) {
1648
- _standardModeWarned = true;
1649
- console.warn(
1650
- "[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"
1651
- );
1652
- }
1653
- this.scanProtection = options?.scanProtection ?? "error";
1654
- if (this.queryMode === "pipeline") {
669
+ if (queryMode === "pipeline") {
1655
670
  this.pipelineAdapter = createPipelineQueryAdapter(db, collectionPath);
1656
- if (this.metaAdapter) {
1657
- this.metaPipelineAdapter = createPipelineQueryAdapter(
1658
- db,
1659
- options.registryMode.collection
1660
- );
1661
- }
1662
671
  }
1663
672
  }
673
+ collectionPath;
674
+ scopePath;
1664
675
  adapter;
1665
676
  pipelineAdapter;
1666
- queryMode;
1667
- scanProtection;
1668
- // Static mode
1669
- staticRegistry;
1670
- // Dynamic mode
1671
- dynamicConfig;
1672
- bootstrapRegistry;
1673
- dynamicRegistry;
1674
- metaAdapter;
1675
- metaPipelineAdapter;
1676
- // Subgraph scope tracking
1677
- scopePath;
1678
- // Migration settings
1679
- globalWriteBack;
1680
- migrationSandbox;
1681
- // ---------------------------------------------------------------------------
1682
- // Registry routing
1683
- // ---------------------------------------------------------------------------
1684
- /**
1685
- * Get the appropriate registry for validating a write to the given type.
1686
- *
1687
- * - Static-only mode: returns staticRegistry (or undefined if none set)
1688
- * - Dynamic mode (pure or merged):
1689
- * - Meta-types (nodeType, edgeType): validated against bootstrapRegistry
1690
- * - Domain types: validated against dynamicRegistry (falls back to
1691
- * bootstrapRegistry which rejects unknown types)
1692
- * - Merged mode: dynamicRegistry is a merged wrapper (static + dynamic
1693
- * extension), so static entries take priority automatically.
1694
- */
1695
- getRegistryForType(aType) {
1696
- if (!this.dynamicConfig) return this.staticRegistry;
1697
- if (aType === META_NODE_TYPE || aType === META_EDGE_TYPE) {
1698
- return this.bootstrapRegistry;
1699
- }
1700
- return this.dynamicRegistry ?? this.staticRegistry ?? this.bootstrapRegistry;
1701
- }
1702
- /**
1703
- * Get the Firestore adapter for writing the given type.
1704
- * Meta-types route to metaAdapter if a separate collection is configured.
1705
- */
1706
- getAdapterForType(aType) {
1707
- if (this.metaAdapter && (aType === META_NODE_TYPE || aType === META_EDGE_TYPE)) {
1708
- return this.metaAdapter;
1709
- }
1710
- return this.adapter;
1711
- }
1712
- /**
1713
- * Get the combined registry for transaction/batch context.
1714
- * In static-only mode, returns staticRegistry.
1715
- * In dynamic mode, returns dynamicRegistry (which includes bootstrap entries)
1716
- * or falls back to staticRegistry (merged mode) or bootstrapRegistry.
1717
- */
1718
- getCombinedRegistry() {
1719
- if (!this.dynamicConfig) return this.staticRegistry;
1720
- return this.dynamicRegistry ?? this.staticRegistry ?? this.bootstrapRegistry;
677
+ // --- Reads ---
678
+ getDoc(docId) {
679
+ return this.adapter.getDoc(docId);
1721
680
  }
1722
- // ---------------------------------------------------------------------------
1723
- // Query dispatch
1724
- // ---------------------------------------------------------------------------
1725
- /**
1726
- * Dispatch a query to the appropriate adapter based on queryMode.
1727
- * Pipeline queries use the PipelineQueryAdapter; standard queries
1728
- * use the FirestoreAdapter.
1729
- */
1730
- executeQuery(filters, options) {
681
+ query(filters, options) {
1731
682
  if (this.pipelineAdapter) {
1732
683
  return this.pipelineAdapter.query(filters, options);
1733
684
  }
1734
685
  return this.adapter.query(filters, options);
1735
686
  }
1736
- /**
1737
- * Check whether a query's filter set is safe (matches a known index pattern).
1738
- * Throws QuerySafetyError or logs a warning depending on scanProtection config.
1739
- */
1740
- checkQuerySafety(filters, allowCollectionScan) {
1741
- if (allowCollectionScan || this.scanProtection === "off") return;
1742
- const result = analyzeQuerySafety(filters);
1743
- if (result.safe) return;
1744
- if (this.scanProtection === "error") {
1745
- throw new QuerySafetyError(result.reason);
1746
- }
1747
- console.warn(`[firegraph] Query safety warning: ${result.reason}`);
1748
- }
1749
- // ---------------------------------------------------------------------------
1750
- // Migration helpers
1751
- // ---------------------------------------------------------------------------
1752
- /**
1753
- * Apply migration to a single record. Returns the (possibly migrated)
1754
- * record and triggers write-back if applicable.
1755
- */
1756
- async applyMigration(record, docId) {
1757
- const registry = this.getCombinedRegistry();
1758
- if (!registry) return record;
1759
- const result = await migrateRecord(record, registry, this.globalWriteBack);
1760
- if (result.migrated) {
1761
- this.handleWriteBack(result, docId);
1762
- }
1763
- return result.record;
1764
- }
1765
- /**
1766
- * Apply migrations to an array of records. Returns all records
1767
- * (migrated where applicable) and triggers write-backs.
1768
- */
1769
- async applyMigrations(records) {
1770
- const registry = this.getCombinedRegistry();
1771
- if (!registry || records.length === 0) return records;
1772
- const results = await migrateRecords(records, registry, this.globalWriteBack);
1773
- for (const result of results) {
1774
- if (result.migrated) {
1775
- const docId = result.record.axbType === NODE_RELATION ? computeNodeDocId(result.record.aUid) : computeEdgeDocId(result.record.aUid, result.record.axbType, result.record.bUid);
1776
- this.handleWriteBack(result, docId);
1777
- }
1778
- }
1779
- return results.map((r) => r.record);
1780
- }
1781
- /**
1782
- * Handle write-back for a migrated record based on the resolved mode.
1783
- *
1784
- * Both `'eager'` and `'background'` are fire-and-forget (not awaited by
1785
- * the caller). The difference is logging level on failure:
1786
- * - `eager`: logs an error via `console.error`
1787
- * - `background`: logs a warning via `console.warn`
1788
- *
1789
- * For truly synchronous write-back guarantees, use transactions — the
1790
- * `GraphTransactionImpl` performs write-back inline within the transaction.
1791
- */
1792
- handleWriteBack(result, docId) {
1793
- if (result.writeBack === "off") return;
1794
- const doWriteBack = async () => {
1795
- try {
1796
- const update = {
1797
- data: deserializeFirestoreTypes(result.record.data, this.db),
1798
- updatedAt: FieldValue5.serverTimestamp()
1799
- };
1800
- if (result.record.v !== void 0) {
1801
- update.v = result.record.v;
1802
- }
1803
- await this.adapter.updateDoc(docId, update);
1804
- } catch (err) {
1805
- const msg = `[firegraph] Migration write-back failed for ${docId}: ${err.message}`;
1806
- if (result.writeBack === "eager") {
1807
- console.error(msg);
1808
- } else {
1809
- console.warn(msg);
1810
- }
1811
- }
1812
- };
1813
- void doWriteBack();
1814
- }
1815
- // ---------------------------------------------------------------------------
1816
- // GraphReader
1817
- // ---------------------------------------------------------------------------
1818
- async getNode(uid) {
1819
- const docId = computeNodeDocId(uid);
1820
- const record = await this.adapter.getDoc(docId);
1821
- if (!record) return null;
1822
- return this.applyMigration(record, docId);
1823
- }
1824
- async getEdge(aUid, axbType, bUid) {
1825
- const docId = computeEdgeDocId(aUid, axbType, bUid);
1826
- const record = await this.adapter.getDoc(docId);
1827
- if (!record) return null;
1828
- return this.applyMigration(record, docId);
1829
- }
1830
- async edgeExists(aUid, axbType, bUid) {
1831
- const docId = computeEdgeDocId(aUid, axbType, bUid);
1832
- const record = await this.adapter.getDoc(docId);
1833
- return record !== null;
1834
- }
1835
- async findEdges(params) {
1836
- const plan = buildEdgeQueryPlan(params);
1837
- let records;
1838
- if (plan.strategy === "get") {
1839
- const record = await this.adapter.getDoc(plan.docId);
1840
- records = record ? [record] : [];
1841
- } else {
1842
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1843
- records = await this.executeQuery(plan.filters, plan.options);
1844
- }
1845
- return this.applyMigrations(records);
1846
- }
1847
- async findNodes(params) {
1848
- const plan = buildNodeQueryPlan(params);
1849
- let records;
1850
- if (plan.strategy === "get") {
1851
- const record = await this.adapter.getDoc(plan.docId);
1852
- records = record ? [record] : [];
1853
- } else {
1854
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1855
- records = await this.executeQuery(plan.filters, plan.options);
1856
- }
1857
- return this.applyMigrations(records);
1858
- }
1859
- // ---------------------------------------------------------------------------
1860
- // GraphWriter
1861
- // ---------------------------------------------------------------------------
1862
- async putNode(aType, uid, data) {
1863
- const registry = this.getRegistryForType(aType);
1864
- if (registry) {
1865
- registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
1866
- }
1867
- const adapter = this.getAdapterForType(aType);
1868
- const docId = computeNodeDocId(uid);
1869
- const record = buildNodeRecord(aType, uid, data);
1870
- if (registry) {
1871
- const entry = registry.lookup(aType, NODE_RELATION, aType);
1872
- if (entry?.schemaVersion && entry.schemaVersion > 0) {
1873
- record.v = entry.schemaVersion;
1874
- }
1875
- }
1876
- await adapter.setDoc(docId, record);
1877
- }
1878
- async putEdge(aType, aUid, axbType, bType, bUid, data) {
1879
- const registry = this.getRegistryForType(aType);
1880
- if (registry) {
1881
- registry.validate(aType, axbType, bType, data, this.scopePath);
1882
- }
1883
- const adapter = this.getAdapterForType(aType);
1884
- const docId = computeEdgeDocId(aUid, axbType, bUid);
1885
- const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
1886
- if (registry) {
1887
- const entry = registry.lookup(aType, axbType, bType);
1888
- if (entry?.schemaVersion && entry.schemaVersion > 0) {
1889
- record.v = entry.schemaVersion;
1890
- }
1891
- }
1892
- await adapter.setDoc(docId, record);
1893
- }
1894
- async updateNode(uid, data) {
1895
- const docId = computeNodeDocId(uid);
1896
- const update = {
1897
- updatedAt: FieldValue5.serverTimestamp()
1898
- };
1899
- for (const [k, v] of Object.entries(data)) {
1900
- update[`data.${k}`] = v;
1901
- }
1902
- await this.adapter.updateDoc(docId, update);
687
+ // --- Writes ---
688
+ setDoc(docId, record) {
689
+ return this.adapter.setDoc(docId, stampWritableRecord(record));
1903
690
  }
1904
- async removeNode(uid) {
1905
- const docId = computeNodeDocId(uid);
1906
- await this.adapter.deleteDoc(docId);
691
+ updateDoc(docId, update) {
692
+ return this.adapter.updateDoc(docId, buildFirestoreUpdate(update, this.db));
1907
693
  }
1908
- async removeEdge(aUid, axbType, bUid) {
1909
- const docId = computeEdgeDocId(aUid, axbType, bUid);
1910
- await this.adapter.deleteDoc(docId);
694
+ deleteDoc(docId) {
695
+ return this.adapter.deleteDoc(docId);
1911
696
  }
1912
- // ---------------------------------------------------------------------------
1913
- // Transactions & Batches
1914
- // ---------------------------------------------------------------------------
1915
- async runTransaction(fn) {
697
+ // --- Transactions / Batches ---
698
+ runTransaction(fn) {
1916
699
  return this.db.runTransaction(async (firestoreTx) => {
1917
- const adapter = createTransactionAdapter(
1918
- this.db,
1919
- this.adapter.collectionPath,
1920
- firestoreTx
1921
- );
1922
- const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath, this.globalWriteBack, this.db);
1923
- return fn(graphTx);
700
+ const txAdapter = createTransactionAdapter(this.db, this.collectionPath, firestoreTx);
701
+ return fn(new FirestoreTransactionBackend(txAdapter, this.db));
1924
702
  });
1925
703
  }
1926
- batch() {
1927
- const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
1928
- return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
704
+ createBatch() {
705
+ const batchAdapter = createBatchAdapter(this.db, this.collectionPath);
706
+ return new FirestoreBatchBackend(batchAdapter, this.db);
1929
707
  }
1930
- // ---------------------------------------------------------------------------
1931
- // Subgraph
1932
- // ---------------------------------------------------------------------------
1933
- subgraph(parentNodeUid, name = "graph") {
1934
- if (!parentNodeUid || parentNodeUid.includes("/")) {
1935
- throw new FiregraphError(
1936
- `Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
1937
- "INVALID_SUBGRAPH"
1938
- );
1939
- }
1940
- if (name.includes("/")) {
1941
- throw new FiregraphError(
1942
- `Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
1943
- "INVALID_SUBGRAPH"
1944
- );
1945
- }
1946
- const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
1947
- const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
1948
- return new _GraphClientImpl(
1949
- this.db,
1950
- subCollectionPath,
1951
- {
1952
- registry: this.getCombinedRegistry(),
1953
- queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
1954
- scanProtection: this.scanProtection,
1955
- migrationWriteBack: this.globalWriteBack,
1956
- migrationSandbox: this.migrationSandbox
1957
- },
1958
- newScopePath
1959
- );
708
+ // --- Subgraphs ---
709
+ subgraph(parentNodeUid, name) {
710
+ const subPath = `${this.collectionPath}/${parentNodeUid}/${name}`;
711
+ const newScope = this.scopePath ? `${this.scopePath}/${name}` : name;
712
+ return new _FirestoreBackendImpl(this.db, subPath, this.queryMode, newScope);
713
+ }
714
+ // --- Cascade & bulk ---
715
+ removeNodeCascade(uid, reader, options) {
716
+ return removeNodeCascade(this.db, this.collectionPath, reader, uid, options);
1960
717
  }
1961
- // ---------------------------------------------------------------------------
1962
- // Collection group query
1963
- // ---------------------------------------------------------------------------
718
+ bulkRemoveEdges(params, reader, options) {
719
+ return bulkRemoveEdges(this.db, this.collectionPath, reader, params, options);
720
+ }
721
+ // --- Cross-collection ---
1964
722
  async findEdgesGlobal(params, collectionName) {
1965
- const name = collectionName ?? this.adapter.collectionPath.split("/").pop();
723
+ const name = collectionName ?? this.collectionPath.split("/").pop();
1966
724
  const plan = buildEdgeQueryPlan(params);
1967
725
  if (plan.strategy === "get") {
1968
726
  throw new FiregraphError(
@@ -1970,7 +728,6 @@ var GraphClientImpl = class _GraphClientImpl {
1970
728
  "INVALID_QUERY"
1971
729
  );
1972
730
  }
1973
- this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1974
731
  const collectionGroupRef = this.db.collectionGroup(name);
1975
732
  let q = collectionGroupRef;
1976
733
  for (const f of plan.filters) {
@@ -1983,170 +740,35 @@ var GraphClientImpl = class _GraphClientImpl {
1983
740
  q = q.limit(plan.options.limit);
1984
741
  }
1985
742
  const snap = await q.get();
1986
- const records = snap.docs.map((doc) => doc.data());
1987
- return this.applyMigrations(records);
1988
- }
1989
- // ---------------------------------------------------------------------------
1990
- // Bulk operations
1991
- // ---------------------------------------------------------------------------
1992
- async removeNodeCascade(uid, options) {
1993
- return removeNodeCascade(this.db, this.adapter.collectionPath, this, uid, options);
1994
- }
1995
- async bulkRemoveEdges(params, options) {
1996
- return bulkRemoveEdges(this.db, this.adapter.collectionPath, this, params, options);
1997
- }
1998
- // ---------------------------------------------------------------------------
1999
- // Dynamic registry methods
2000
- // ---------------------------------------------------------------------------
2001
- async defineNodeType(name, jsonSchema, description, options) {
2002
- if (!this.dynamicConfig) {
2003
- throw new DynamicRegistryError(
2004
- 'defineNodeType() is only available in dynamic registry mode. Pass registryMode: { mode: "dynamic" } to createGraphClient().'
2005
- );
2006
- }
2007
- if (RESERVED_TYPE_NAMES.has(name)) {
2008
- throw new DynamicRegistryError(
2009
- `Cannot define type "${name}": this name is reserved for the meta-registry.`
2010
- );
2011
- }
2012
- if (this.staticRegistry?.lookup(name, NODE_RELATION, name)) {
2013
- throw new DynamicRegistryError(
2014
- `Cannot define node type "${name}": already defined in the static registry.`
2015
- );
2016
- }
2017
- const uid = generateDeterministicUid(META_NODE_TYPE, name);
2018
- const data = { name, jsonSchema };
2019
- if (description !== void 0) data.description = description;
2020
- if (options?.titleField !== void 0) data.titleField = options.titleField;
2021
- if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
2022
- if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
2023
- if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
2024
- if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
2025
- if (options?.migrationWriteBack !== void 0) data.migrationWriteBack = options.migrationWriteBack;
2026
- if (options?.migrations !== void 0) {
2027
- data.migrations = await this.serializeMigrations(options.migrations);
2028
- }
2029
- await this.putNode(META_NODE_TYPE, uid, data);
2030
- }
2031
- async defineEdgeType(name, topology, jsonSchema, description, options) {
2032
- if (!this.dynamicConfig) {
2033
- throw new DynamicRegistryError(
2034
- 'defineEdgeType() is only available in dynamic registry mode. Pass registryMode: { mode: "dynamic" } to createGraphClient().'
2035
- );
2036
- }
2037
- if (RESERVED_TYPE_NAMES.has(name)) {
2038
- throw new DynamicRegistryError(
2039
- `Cannot define type "${name}": this name is reserved for the meta-registry.`
2040
- );
2041
- }
2042
- if (this.staticRegistry) {
2043
- const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
2044
- const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
2045
- for (const aType of fromTypes) {
2046
- for (const bType of toTypes) {
2047
- if (this.staticRegistry.lookup(aType, name, bType)) {
2048
- throw new DynamicRegistryError(
2049
- `Cannot define edge type "${name}" for (${aType}) -> (${bType}): already defined in the static registry.`
2050
- );
2051
- }
2052
- }
2053
- }
2054
- }
2055
- const uid = generateDeterministicUid(META_EDGE_TYPE, name);
2056
- const data = {
2057
- name,
2058
- from: topology.from,
2059
- to: topology.to
2060
- };
2061
- if (jsonSchema !== void 0) data.jsonSchema = jsonSchema;
2062
- if (topology.inverseLabel !== void 0) data.inverseLabel = topology.inverseLabel;
2063
- if (topology.targetGraph !== void 0) data.targetGraph = topology.targetGraph;
2064
- if (description !== void 0) data.description = description;
2065
- if (options?.titleField !== void 0) data.titleField = options.titleField;
2066
- if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
2067
- if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
2068
- if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
2069
- if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
2070
- if (options?.migrationWriteBack !== void 0) data.migrationWriteBack = options.migrationWriteBack;
2071
- if (options?.migrations !== void 0) {
2072
- data.migrations = await this.serializeMigrations(options.migrations);
2073
- }
2074
- await this.putNode(META_EDGE_TYPE, uid, data);
2075
- }
2076
- async reloadRegistry() {
2077
- if (!this.dynamicConfig) {
2078
- throw new DynamicRegistryError(
2079
- 'reloadRegistry() is only available in dynamic registry mode. Pass registryMode: { mode: "dynamic" } to createGraphClient().'
2080
- );
2081
- }
2082
- const reader = this.createMetaReader();
2083
- const dynamicOnly = await createRegistryFromGraph(reader, this.migrationSandbox);
2084
- if (this.staticRegistry) {
2085
- this.dynamicRegistry = createMergedRegistry(this.staticRegistry, dynamicOnly);
2086
- } else {
2087
- this.dynamicRegistry = dynamicOnly;
2088
- }
743
+ return snap.docs.map((doc) => doc.data());
2089
744
  }
2090
- /**
2091
- * Serialize migration steps for storage in Firestore.
2092
- * Function objects are converted via `.toString()`; strings are stored as-is.
2093
- * Each migration is validated at define-time by pre-compiling in the sandbox.
2094
- */
2095
- async serializeMigrations(migrations) {
2096
- const result = migrations.map((m) => {
2097
- const source = typeof m.up === "function" ? m.up.toString() : m.up;
2098
- return { fromVersion: m.fromVersion, toVersion: m.toVersion, up: source };
2099
- });
2100
- await Promise.all(
2101
- result.map((m) => precompileSource(m.up, this.migrationSandbox))
745
+ };
746
+ function createFirestoreBackend(db, collectionPath, options = {}) {
747
+ const queryMode = options.queryMode ?? "pipeline";
748
+ const scopePath = options.scopePath ?? "";
749
+ return new FirestoreBackendImpl(db, collectionPath, queryMode, scopePath);
750
+ }
751
+
752
+ // src/firestore.ts
753
+ var _standardModeWarned = false;
754
+ function createGraphClient(db, collectionPath, options) {
755
+ const requestedMode = options?.queryMode ?? "pipeline";
756
+ const isEmulator = !!process.env.FIRESTORE_EMULATOR_HOST;
757
+ const effectiveMode = isEmulator ? "standard" : requestedMode;
758
+ if (effectiveMode === "standard" && !isEmulator && requestedMode === "standard" && !_standardModeWarned) {
759
+ _standardModeWarned = true;
760
+ console.warn(
761
+ "[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"
2102
762
  );
2103
- return result;
2104
763
  }
2105
- /**
2106
- * Create a GraphReader for the meta-collection.
2107
- * If meta-collection is the same as main collection, returns `this`.
2108
- * If separate, creates a lightweight reader wrapping the meta adapter.
2109
- */
2110
- createMetaReader() {
2111
- if (!this.metaAdapter) return this;
2112
- const adapter = this.metaAdapter;
2113
- const pipelineAdapter = this.metaPipelineAdapter;
2114
- const executeMetaQuery = (filters, options) => {
2115
- if (pipelineAdapter) return pipelineAdapter.query(filters, options);
2116
- return adapter.query(filters, options);
2117
- };
2118
- return {
2119
- async getNode(uid) {
2120
- return adapter.getDoc(computeNodeDocId(uid));
2121
- },
2122
- async getEdge(aUid, axbType, bUid) {
2123
- return adapter.getDoc(computeEdgeDocId(aUid, axbType, bUid));
2124
- },
2125
- async edgeExists(aUid, axbType, bUid) {
2126
- const record = await adapter.getDoc(computeEdgeDocId(aUid, axbType, bUid));
2127
- return record !== null;
2128
- },
2129
- async findEdges(params) {
2130
- const plan = buildEdgeQueryPlan(params);
2131
- if (plan.strategy === "get") {
2132
- const record = await adapter.getDoc(plan.docId);
2133
- return record ? [record] : [];
2134
- }
2135
- return executeMetaQuery(plan.filters, plan.options);
2136
- },
2137
- async findNodes(params) {
2138
- const plan = buildNodeQueryPlan(params);
2139
- if (plan.strategy === "get") {
2140
- const record = await adapter.getDoc(plan.docId);
2141
- return record ? [record] : [];
2142
- }
2143
- return executeMetaQuery(plan.filters, plan.options);
2144
- }
2145
- };
764
+ const backend = createFirestoreBackend(db, collectionPath, { queryMode: effectiveMode });
765
+ let metaBackend;
766
+ if (options?.registryMode?.collection && options.registryMode.collection !== collectionPath) {
767
+ metaBackend = createFirestoreBackend(db, options.registryMode.collection, {
768
+ queryMode: effectiveMode
769
+ });
2146
770
  }
2147
- };
2148
- function createGraphClient(db, collectionPath, options) {
2149
- return new GraphClientImpl(db, collectionPath, options);
771
+ return new GraphClientImpl(backend, options, metaBackend);
2150
772
  }
2151
773
 
2152
774
  // src/id.ts
@@ -2155,14 +777,167 @@ function generateId() {
2155
777
  return nanoid();
2156
778
  }
2157
779
 
2158
- // src/traverse.ts
2159
- var DEFAULT_LIMIT = 10;
2160
- var DEFAULT_MAX_READS = 100;
2161
- var DEFAULT_CONCURRENCY = 5;
2162
- var _crossGraphWarned = false;
2163
- function isGraphClient(reader) {
2164
- return "subgraph" in reader && typeof reader.subgraph === "function";
2165
- }
780
+ // src/indexes.ts
781
+ function baseIndexes(collection) {
782
+ return [
783
+ {
784
+ collectionGroup: collection,
785
+ queryScope: "COLLECTION",
786
+ fields: [
787
+ { fieldPath: "aUid", order: "ASCENDING" },
788
+ { fieldPath: "axbType", order: "ASCENDING" }
789
+ ]
790
+ },
791
+ {
792
+ collectionGroup: collection,
793
+ queryScope: "COLLECTION",
794
+ fields: [
795
+ { fieldPath: "axbType", order: "ASCENDING" },
796
+ { fieldPath: "bUid", order: "ASCENDING" }
797
+ ]
798
+ },
799
+ {
800
+ collectionGroup: collection,
801
+ queryScope: "COLLECTION",
802
+ fields: [
803
+ { fieldPath: "aType", order: "ASCENDING" },
804
+ { fieldPath: "axbType", order: "ASCENDING" }
805
+ ]
806
+ },
807
+ {
808
+ collectionGroup: collection,
809
+ queryScope: "COLLECTION",
810
+ fields: [
811
+ { fieldPath: "axbType", order: "ASCENDING" },
812
+ { fieldPath: "bType", order: "ASCENDING" }
813
+ ]
814
+ }
815
+ ];
816
+ }
817
+ function extractSchemaFields(schema) {
818
+ const s = schema;
819
+ if (s.type !== "object" || !s.properties) return [];
820
+ return Object.keys(s.properties);
821
+ }
822
+ function collectionGroupIndexes(collectionName) {
823
+ return [
824
+ {
825
+ collectionGroup: collectionName,
826
+ queryScope: "COLLECTION_GROUP",
827
+ fields: [
828
+ { fieldPath: "aUid", order: "ASCENDING" },
829
+ { fieldPath: "axbType", order: "ASCENDING" }
830
+ ]
831
+ },
832
+ {
833
+ collectionGroup: collectionName,
834
+ queryScope: "COLLECTION_GROUP",
835
+ fields: [
836
+ { fieldPath: "axbType", order: "ASCENDING" },
837
+ { fieldPath: "bUid", order: "ASCENDING" }
838
+ ]
839
+ },
840
+ {
841
+ collectionGroup: collectionName,
842
+ queryScope: "COLLECTION_GROUP",
843
+ fields: [
844
+ { fieldPath: "aType", order: "ASCENDING" },
845
+ { fieldPath: "axbType", order: "ASCENDING" }
846
+ ]
847
+ },
848
+ {
849
+ collectionGroup: collectionName,
850
+ queryScope: "COLLECTION_GROUP",
851
+ fields: [
852
+ { fieldPath: "axbType", order: "ASCENDING" },
853
+ { fieldPath: "bType", order: "ASCENDING" }
854
+ ]
855
+ }
856
+ ];
857
+ }
858
+ function generateIndexConfig(collection, entities, registryEntries) {
859
+ const indexes = baseIndexes(collection);
860
+ if (entities) {
861
+ for (const [, entity] of entities.nodes) {
862
+ const fields = extractSchemaFields(entity.schema);
863
+ for (const field of fields) {
864
+ indexes.push({
865
+ collectionGroup: collection,
866
+ queryScope: "COLLECTION",
867
+ fields: [
868
+ { fieldPath: "aType", order: "ASCENDING" },
869
+ { fieldPath: "axbType", order: "ASCENDING" },
870
+ { fieldPath: `data.${field}`, order: "ASCENDING" }
871
+ ]
872
+ });
873
+ }
874
+ }
875
+ for (const [, entity] of entities.edges) {
876
+ const fields = extractSchemaFields(entity.schema);
877
+ for (const field of fields) {
878
+ indexes.push({
879
+ collectionGroup: collection,
880
+ queryScope: "COLLECTION",
881
+ fields: [
882
+ { fieldPath: "aUid", order: "ASCENDING" },
883
+ { fieldPath: "axbType", order: "ASCENDING" },
884
+ { fieldPath: `data.${field}`, order: "ASCENDING" }
885
+ ]
886
+ });
887
+ }
888
+ }
889
+ }
890
+ if (registryEntries) {
891
+ const targetGraphNames = /* @__PURE__ */ new Set();
892
+ for (const entry of registryEntries) {
893
+ if (entry.targetGraph) {
894
+ targetGraphNames.add(entry.targetGraph);
895
+ }
896
+ }
897
+ for (const name of targetGraphNames) {
898
+ indexes.push(...collectionGroupIndexes(name));
899
+ }
900
+ }
901
+ return { indexes, fieldOverrides: [] };
902
+ }
903
+
904
+ // src/record.ts
905
+ import { FieldValue as FieldValue2 } from "@google-cloud/firestore";
906
+ function buildNodeRecord(aType, uid, data) {
907
+ const now = FieldValue2.serverTimestamp();
908
+ return {
909
+ aType,
910
+ aUid: uid,
911
+ axbType: NODE_RELATION,
912
+ bType: aType,
913
+ bUid: uid,
914
+ data,
915
+ createdAt: now,
916
+ updatedAt: now
917
+ };
918
+ }
919
+ function buildEdgeRecord(aType, aUid, axbType, bType, bUid, data) {
920
+ const now = FieldValue2.serverTimestamp();
921
+ return {
922
+ aType,
923
+ aUid,
924
+ axbType,
925
+ bType,
926
+ bUid,
927
+ data,
928
+ createdAt: now,
929
+ updatedAt: now
930
+ };
931
+ }
932
+
933
+ // src/traverse.ts
934
+ var DEFAULT_LIMIT = 10;
935
+ var DEFAULT_MAX_READS = 100;
936
+ var DEFAULT_CONCURRENCY = 5;
937
+ var _crossGraphWarned = false;
938
+ function isGraphClient(reader) {
939
+ return "subgraph" in reader && typeof reader.subgraph === "function";
940
+ }
2166
941
  var Semaphore = class {
2167
942
  constructor(slots) {
2168
943
  this.slots = slots;
@@ -2444,367 +1219,6 @@ function defineViews(input) {
2444
1219
  }
2445
1220
  return { nodes, edges };
2446
1221
  }
2447
-
2448
- // src/config.ts
2449
- function defineConfig(config) {
2450
- return config;
2451
- }
2452
- function resolveView(resolverConfig, availableViewNames, context) {
2453
- if (!resolverConfig) return "json";
2454
- const available = new Set(availableViewNames);
2455
- if (context) {
2456
- const contextDefault = resolverConfig[context];
2457
- if (contextDefault && available.has(contextDefault)) {
2458
- return contextDefault;
2459
- }
2460
- }
2461
- if (resolverConfig.default && available.has(resolverConfig.default)) {
2462
- return resolverConfig.default;
2463
- }
2464
- return "json";
2465
- }
2466
-
2467
- // src/discover.ts
2468
- import { readFileSync, readdirSync, existsSync, statSync } from "fs";
2469
- import { createRequire } from "module";
2470
- import { join, resolve } from "path";
2471
- var DiscoveryError = class extends FiregraphError {
2472
- constructor(message) {
2473
- super(message, "DISCOVERY_ERROR");
2474
- this.name = "DiscoveryError";
2475
- }
2476
- };
2477
- function readJson(filePath) {
2478
- try {
2479
- const raw = readFileSync(filePath, "utf-8");
2480
- return JSON.parse(raw);
2481
- } catch (err) {
2482
- const msg = err instanceof SyntaxError ? `Invalid JSON in ${filePath}: ${err.message}` : `Cannot read ${filePath}: ${err.message}`;
2483
- throw new DiscoveryError(msg);
2484
- }
2485
- }
2486
- function readJsonIfExists(filePath) {
2487
- if (!existsSync(filePath)) return void 0;
2488
- return readJson(filePath);
2489
- }
2490
- var SCHEMA_SCRIPT_EXTENSIONS = [".ts", ".js", ".mts", ".mjs"];
2491
- function loadSchema(dir, entityLabel) {
2492
- for (const ext of SCHEMA_SCRIPT_EXTENSIONS) {
2493
- const candidate = join(dir, `schema${ext}`);
2494
- if (existsSync(candidate)) {
2495
- return loadSchemaModule(candidate, entityLabel);
2496
- }
2497
- }
2498
- const jsonPath = join(dir, "schema.json");
2499
- if (existsSync(jsonPath)) {
2500
- return readJson(jsonPath);
2501
- }
2502
- throw new DiscoveryError(
2503
- `Missing schema for ${entityLabel} in ${dir}. Provide a schema.ts (or .js/.mts/.mjs) or schema.json file.`
2504
- );
2505
- }
2506
- var _jiti;
2507
- function getJiti() {
2508
- if (!_jiti) {
2509
- const base = typeof __filename !== "undefined" ? __filename : import.meta.url;
2510
- const esmRequire = createRequire(base);
2511
- const { createJiti } = esmRequire("jiti");
2512
- _jiti = createJiti(base, { interopDefault: true });
2513
- }
2514
- return _jiti;
2515
- }
2516
- function loadSchemaModule(filePath, entityLabel) {
2517
- try {
2518
- const jiti = getJiti();
2519
- const mod = jiti(filePath);
2520
- const schema = mod && typeof mod === "object" && "default" in mod ? mod.default : mod;
2521
- if (!schema || typeof schema !== "object") {
2522
- throw new DiscoveryError(
2523
- `Schema file ${filePath} for ${entityLabel} must default-export a JSON Schema object.`
2524
- );
2525
- }
2526
- return schema;
2527
- } catch (err) {
2528
- if (err instanceof DiscoveryError) throw err;
2529
- throw new DiscoveryError(
2530
- `Failed to load schema module ${filePath} for ${entityLabel}: ${err.message}`
2531
- );
2532
- }
2533
- }
2534
- var VIEW_EXTENSIONS = [".ts", ".js", ".mts", ".mjs"];
2535
- function findViewsFile(dir) {
2536
- for (const ext of VIEW_EXTENSIONS) {
2537
- const candidate = join(dir, `views${ext}`);
2538
- if (existsSync(candidate)) return candidate;
2539
- }
2540
- return void 0;
2541
- }
2542
- var MIGRATION_EXTENSIONS = [".ts", ".js", ".mts", ".mjs"];
2543
- function findMigrationsFile(dir) {
2544
- for (const ext of MIGRATION_EXTENSIONS) {
2545
- const candidate = join(dir, `migrations${ext}`);
2546
- if (existsSync(candidate)) return candidate;
2547
- }
2548
- return void 0;
2549
- }
2550
- function loadMigrations(filePath, entityLabel) {
2551
- try {
2552
- const jiti = getJiti();
2553
- const mod = jiti(filePath);
2554
- const migrations = mod && typeof mod === "object" && "default" in mod ? mod.default : mod;
2555
- if (!Array.isArray(migrations)) {
2556
- throw new DiscoveryError(
2557
- `Migrations file ${filePath} for ${entityLabel} must default-export an array of MigrationStep.`
2558
- );
2559
- }
2560
- return migrations;
2561
- } catch (err) {
2562
- if (err instanceof DiscoveryError) throw err;
2563
- throw new DiscoveryError(
2564
- `Failed to load migrations ${filePath} for ${entityLabel}: ${err.message}`
2565
- );
2566
- }
2567
- }
2568
- function loadNodeEntity(dir, name) {
2569
- const schema = loadSchema(dir, `node type "${name}"`);
2570
- const meta = readJsonIfExists(join(dir, "meta.json"));
2571
- const sampleData = readJsonIfExists(join(dir, "sample.json"));
2572
- const viewsPath = findViewsFile(dir);
2573
- const migrationsPath = findMigrationsFile(dir);
2574
- const migrations = migrationsPath ? loadMigrations(migrationsPath, `node type "${name}"`) : void 0;
2575
- return {
2576
- kind: "node",
2577
- name,
2578
- schema,
2579
- description: meta?.description,
2580
- titleField: meta?.titleField,
2581
- subtitleField: meta?.subtitleField,
2582
- viewDefaults: meta?.viewDefaults,
2583
- viewsPath,
2584
- sampleData,
2585
- allowedIn: meta?.allowedIn,
2586
- migrations,
2587
- migrationWriteBack: meta?.migrationWriteBack
2588
- };
2589
- }
2590
- function loadEdgeEntity(dir, name) {
2591
- const schema = loadSchema(dir, `edge type "${name}"`);
2592
- const edgePath = join(dir, "edge.json");
2593
- if (!existsSync(edgePath)) {
2594
- throw new DiscoveryError(
2595
- `Missing edge.json for edge type "${name}" in ${dir}. Edge entities must declare topology (from/to node types).`
2596
- );
2597
- }
2598
- const topology = readJson(edgePath);
2599
- if (!topology.from) {
2600
- throw new DiscoveryError(
2601
- `edge.json for "${name}" is missing required "from" field`
2602
- );
2603
- }
2604
- if (!topology.to) {
2605
- throw new DiscoveryError(
2606
- `edge.json for "${name}" is missing required "to" field`
2607
- );
2608
- }
2609
- const meta = readJsonIfExists(join(dir, "meta.json"));
2610
- const sampleData = readJsonIfExists(join(dir, "sample.json"));
2611
- const viewsPath = findViewsFile(dir);
2612
- const migrationsPath = findMigrationsFile(dir);
2613
- const migrations = migrationsPath ? loadMigrations(migrationsPath, `edge type "${name}"`) : void 0;
2614
- return {
2615
- kind: "edge",
2616
- name,
2617
- schema,
2618
- topology,
2619
- description: meta?.description,
2620
- titleField: meta?.titleField,
2621
- subtitleField: meta?.subtitleField,
2622
- viewDefaults: meta?.viewDefaults,
2623
- viewsPath,
2624
- sampleData,
2625
- allowedIn: meta?.allowedIn,
2626
- targetGraph: topology.targetGraph ?? meta?.targetGraph,
2627
- migrations,
2628
- migrationWriteBack: meta?.migrationWriteBack
2629
- };
2630
- }
2631
- function getSubdirectories(dir) {
2632
- if (!existsSync(dir)) return [];
2633
- return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
2634
- }
2635
- function discoverEntities(entitiesDir) {
2636
- const absDir = resolve(entitiesDir);
2637
- if (!existsSync(absDir) || !statSync(absDir).isDirectory()) {
2638
- throw new DiscoveryError(`Entities directory not found: ${entitiesDir}`);
2639
- }
2640
- const nodes = /* @__PURE__ */ new Map();
2641
- const edges = /* @__PURE__ */ new Map();
2642
- const warnings = [];
2643
- const nodesDir = join(absDir, "nodes");
2644
- for (const name of getSubdirectories(nodesDir)) {
2645
- nodes.set(name, loadNodeEntity(join(nodesDir, name), name));
2646
- }
2647
- const edgesDir = join(absDir, "edges");
2648
- for (const name of getSubdirectories(edgesDir)) {
2649
- edges.set(name, loadEdgeEntity(join(edgesDir, name), name));
2650
- }
2651
- const nodeNames = new Set(nodes.keys());
2652
- for (const [axbType, entity] of edges) {
2653
- const topology = entity.topology;
2654
- const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
2655
- const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
2656
- for (const ref of [...fromTypes, ...toTypes]) {
2657
- if (!nodeNames.has(ref)) {
2658
- warnings.push({
2659
- code: "DANGLING_TOPOLOGY_REF",
2660
- message: `Edge "${axbType}" references node type "${ref}" which was not found in the nodes directory`
2661
- });
2662
- }
2663
- }
2664
- }
2665
- return {
2666
- result: { nodes, edges },
2667
- warnings
2668
- };
2669
- }
2670
-
2671
- // src/cross-graph.ts
2672
- function resolveAncestorCollection(collectionPath, uid) {
2673
- const segments = collectionPath.split("/");
2674
- for (let i = 1; i < segments.length; i += 2) {
2675
- if (segments[i] === uid) {
2676
- return segments.slice(0, i).join("/");
2677
- }
2678
- }
2679
- return null;
2680
- }
2681
- function isAncestorUid(collectionPath, uid) {
2682
- return resolveAncestorCollection(collectionPath, uid) !== null;
2683
- }
2684
-
2685
- // src/indexes.ts
2686
- function baseIndexes(collection) {
2687
- return [
2688
- {
2689
- collectionGroup: collection,
2690
- queryScope: "COLLECTION",
2691
- fields: [
2692
- { fieldPath: "aUid", order: "ASCENDING" },
2693
- { fieldPath: "axbType", order: "ASCENDING" }
2694
- ]
2695
- },
2696
- {
2697
- collectionGroup: collection,
2698
- queryScope: "COLLECTION",
2699
- fields: [
2700
- { fieldPath: "axbType", order: "ASCENDING" },
2701
- { fieldPath: "bUid", order: "ASCENDING" }
2702
- ]
2703
- },
2704
- {
2705
- collectionGroup: collection,
2706
- queryScope: "COLLECTION",
2707
- fields: [
2708
- { fieldPath: "aType", order: "ASCENDING" },
2709
- { fieldPath: "axbType", order: "ASCENDING" }
2710
- ]
2711
- },
2712
- {
2713
- collectionGroup: collection,
2714
- queryScope: "COLLECTION",
2715
- fields: [
2716
- { fieldPath: "axbType", order: "ASCENDING" },
2717
- { fieldPath: "bType", order: "ASCENDING" }
2718
- ]
2719
- }
2720
- ];
2721
- }
2722
- function extractSchemaFields(schema) {
2723
- const s = schema;
2724
- if (s.type !== "object" || !s.properties) return [];
2725
- return Object.keys(s.properties);
2726
- }
2727
- function collectionGroupIndexes(collectionName) {
2728
- return [
2729
- {
2730
- collectionGroup: collectionName,
2731
- queryScope: "COLLECTION_GROUP",
2732
- fields: [
2733
- { fieldPath: "aUid", order: "ASCENDING" },
2734
- { fieldPath: "axbType", order: "ASCENDING" }
2735
- ]
2736
- },
2737
- {
2738
- collectionGroup: collectionName,
2739
- queryScope: "COLLECTION_GROUP",
2740
- fields: [
2741
- { fieldPath: "axbType", order: "ASCENDING" },
2742
- { fieldPath: "bUid", order: "ASCENDING" }
2743
- ]
2744
- },
2745
- {
2746
- collectionGroup: collectionName,
2747
- queryScope: "COLLECTION_GROUP",
2748
- fields: [
2749
- { fieldPath: "aType", order: "ASCENDING" },
2750
- { fieldPath: "axbType", order: "ASCENDING" }
2751
- ]
2752
- },
2753
- {
2754
- collectionGroup: collectionName,
2755
- queryScope: "COLLECTION_GROUP",
2756
- fields: [
2757
- { fieldPath: "axbType", order: "ASCENDING" },
2758
- { fieldPath: "bType", order: "ASCENDING" }
2759
- ]
2760
- }
2761
- ];
2762
- }
2763
- function generateIndexConfig(collection, entities, registryEntries) {
2764
- const indexes = baseIndexes(collection);
2765
- if (entities) {
2766
- for (const [, entity] of entities.nodes) {
2767
- const fields = extractSchemaFields(entity.schema);
2768
- for (const field of fields) {
2769
- indexes.push({
2770
- collectionGroup: collection,
2771
- queryScope: "COLLECTION",
2772
- fields: [
2773
- { fieldPath: "aType", order: "ASCENDING" },
2774
- { fieldPath: "axbType", order: "ASCENDING" },
2775
- { fieldPath: `data.${field}`, order: "ASCENDING" }
2776
- ]
2777
- });
2778
- }
2779
- }
2780
- for (const [, entity] of entities.edges) {
2781
- const fields = extractSchemaFields(entity.schema);
2782
- for (const field of fields) {
2783
- indexes.push({
2784
- collectionGroup: collection,
2785
- queryScope: "COLLECTION",
2786
- fields: [
2787
- { fieldPath: "aUid", order: "ASCENDING" },
2788
- { fieldPath: "axbType", order: "ASCENDING" },
2789
- { fieldPath: `data.${field}`, order: "ASCENDING" }
2790
- ]
2791
- });
2792
- }
2793
- }
2794
- }
2795
- if (registryEntries) {
2796
- const targetGraphNames = /* @__PURE__ */ new Set();
2797
- for (const entry of registryEntries) {
2798
- if (entry.targetGraph) {
2799
- targetGraphNames.add(entry.targetGraph);
2800
- }
2801
- }
2802
- for (const name of targetGraphNames) {
2803
- indexes.push(...collectionGroupIndexes(name));
2804
- }
2805
- }
2806
- return { indexes, fieldOverrides: [] };
2807
- }
2808
1222
  export {
2809
1223
  BOOTSTRAP_ENTRIES,
2810
1224
  DEFAULT_QUERY_LIMIT,
@@ -2840,6 +1254,7 @@ export {
2840
1254
  computeNodeDocId,
2841
1255
  createBootstrapRegistry,
2842
1256
  createGraphClient,
1257
+ createGraphClientFromBackend,
2843
1258
  createMergedRegistry,
2844
1259
  createRegistry,
2845
1260
  createRegistryFromGraph,