@typicalday/firegraph 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +355 -78
- package/dist/backend-DuvHGgK1.d.cts +1897 -0
- package/dist/backend-DuvHGgK1.d.ts +1897 -0
- package/dist/backend.cjs +365 -5
- package/dist/backend.cjs.map +1 -1
- package/dist/backend.d.cts +25 -5
- package/dist/backend.d.ts +25 -5
- package/dist/backend.js +209 -7
- package/dist/backend.js.map +1 -1
- package/dist/chunk-2DHMNTV6.js +16 -0
- package/dist/chunk-2DHMNTV6.js.map +1 -0
- package/dist/chunk-4MMQ5W74.js +288 -0
- package/dist/chunk-4MMQ5W74.js.map +1 -0
- package/dist/{chunk-5753Y42M.js → chunk-C2QMD7RY.js} +6 -10
- package/dist/chunk-C2QMD7RY.js.map +1 -0
- package/dist/chunk-D4J7Z4FE.js +67 -0
- package/dist/chunk-D4J7Z4FE.js.map +1 -0
- package/dist/chunk-EQJUUVFG.js +14 -0
- package/dist/chunk-EQJUUVFG.js.map +1 -0
- package/dist/chunk-N5HFDWQX.js +23 -0
- package/dist/chunk-N5HFDWQX.js.map +1 -0
- package/dist/chunk-PAD7WFFU.js +573 -0
- package/dist/chunk-PAD7WFFU.js.map +1 -0
- package/dist/chunk-TK64DNVK.js +256 -0
- package/dist/chunk-TK64DNVK.js.map +1 -0
- package/dist/{chunk-NJSOD64C.js → chunk-WRTFC5NG.js} +438 -30
- package/dist/chunk-WRTFC5NG.js.map +1 -0
- package/dist/client-BKi3vk0Q.d.ts +34 -0
- package/dist/client-BrsaXtDV.d.cts +34 -0
- package/dist/cloudflare/index.cjs +1386 -74
- package/dist/cloudflare/index.cjs.map +1 -1
- package/dist/cloudflare/index.d.cts +217 -13
- package/dist/cloudflare/index.d.ts +217 -13
- package/dist/cloudflare/index.js +639 -180
- package/dist/cloudflare/index.js.map +1 -1
- package/dist/codegen/index.d.cts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/errors-BRc3I_eH.d.cts +73 -0
- package/dist/errors-BRc3I_eH.d.ts +73 -0
- package/dist/firestore-enterprise/index.cjs +3877 -0
- package/dist/firestore-enterprise/index.cjs.map +1 -0
- package/dist/firestore-enterprise/index.d.cts +141 -0
- package/dist/firestore-enterprise/index.d.ts +141 -0
- package/dist/firestore-enterprise/index.js +985 -0
- package/dist/firestore-enterprise/index.js.map +1 -0
- package/dist/firestore-standard/index.cjs +3117 -0
- package/dist/firestore-standard/index.cjs.map +1 -0
- package/dist/firestore-standard/index.d.cts +49 -0
- package/dist/firestore-standard/index.d.ts +49 -0
- package/dist/firestore-standard/index.js +283 -0
- package/dist/firestore-standard/index.js.map +1 -0
- package/dist/index.cjs +809 -534
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -100
- package/dist/index.d.ts +24 -100
- package/dist/index.js +184 -531
- package/dist/index.js.map +1 -1
- package/dist/registry-Bc7h6WTM.d.cts +64 -0
- package/dist/registry-C2KUPVZj.d.ts +64 -0
- package/dist/{scope-path-B1G3YiA7.d.ts → scope-path-CROFZGr9.d.cts} +1 -56
- package/dist/{scope-path-B1G3YiA7.d.cts → scope-path-CROFZGr9.d.ts} +1 -56
- package/dist/{serialization-ZZ7RSDRX.js → serialization-OE2PFZMY.js} +6 -4
- package/dist/sqlite/index.cjs +3631 -0
- package/dist/sqlite/index.cjs.map +1 -0
- package/dist/sqlite/index.d.cts +111 -0
- package/dist/sqlite/index.d.ts +111 -0
- package/dist/sqlite/index.js +1164 -0
- package/dist/sqlite/index.js.map +1 -0
- package/package.json +33 -3
- package/dist/backend-U-MLShlg.d.ts +0 -97
- package/dist/backend-np4gEVhB.d.cts +0 -97
- package/dist/chunk-5753Y42M.js.map +0 -1
- package/dist/chunk-NJSOD64C.js.map +0 -1
- package/dist/chunk-R7CRGYY4.js +0 -94
- package/dist/chunk-R7CRGYY4.js.map +0 -1
- package/dist/types-BGWxcpI_.d.cts +0 -736
- package/dist/types-BGWxcpI_.d.ts +0 -736
- /package/dist/{serialization-ZZ7RSDRX.js.map → serialization-OE2PFZMY.js.map} +0 -0
package/dist/backend.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/internal/routing-backend.ts"],"sourcesContent":["/**\n * Routing `StorageBackend` wrapper.\n *\n * `createRoutingBackend(base, { route })` returns a `StorageBackend` that\n * behaves identically to `base` except for `subgraph(parentUid, name)`:\n * each such call consults the caller-supplied `route` function, and if it\n * returns a non-null `StorageBackend`, that backend is used for the child\n * scope.\n *\n * This is the single seam firegraph ships for splitting a logical graph\n * across multiple physical storage backends — e.g. fanning particular\n * subgraph names out to their own Durable Objects to stay under the 10 GB\n * per-DO limit. The routing policy itself, the RPC protocol, and any\n * live-scope index are left to the caller; firegraph only owns the\n * composition primitive and the invariants that come with it.\n *\n * ## Contract — nested routing\n *\n * Whether `route()` returns a routed backend OR `null` (pass-through), the\n * child returned by `subgraph()` is **always** itself wrapped by the same\n * router. Without that self-wrap, a call chain like\n *\n * ```ts\n * router.subgraph(A, 'memories').subgraph(B, 'context')\n * ```\n *\n * would route the first hop correctly but bypass the router on the second\n * hop (since the routed backend's own `.subgraph()` doesn't know about the\n * caller's policy). Keeping routing active through grandchildren is the\n * load-bearing behaviour; `'continues routing on grandchildren …'` in the\n * unit tests locks it in.\n *\n * ## Contract — `route` is synchronous\n *\n * `.subgraph()` is synchronous in firegraph's public API. Making the\n * routing callback async would require rippling Promises through every\n * client-factory call site. Consequence: `route` can only consult data it\n * already has in hand (DO bindings, naming rules, in-memory caches). If\n * you need \"does this DO exist?\" checks, do them lazily — the first read\n * against the returned backend will surface the failure naturally.\n *\n * ## Contract — cross-backend atomicity is not silently degraded\n *\n * The wrapper's `runTransaction` and `createBatch` delegate to `base` —\n * they run entirely on the base backend. `TransactionBackend` and\n * `BatchBackend` deliberately have no `subgraph()` method, so user code\n * physically cannot open a routed child from inside a transaction\n * callback. Any attempt to bypass that (via `as any` / unchecked casts)\n * should surface as `CrossBackendTransactionError` so app code can catch\n * it cleanly — the error type is part of the public surface.\n *\n * ## Contract — `findEdgesGlobal` is base-scope only\n *\n * When delegated, `findEdgesGlobal` runs against the base backend only.\n * It does **not** fan out to routed children — firegraph has no\n * enumeration index for which routed backends exist. Callers who need\n * cross-shard collection-group queries must maintain their own scope\n * directory and query it directly. This keeps the common case (local\n * analytics inside one DO) fast.\n */\n\nimport { FiregraphError } from '../errors.js';\nimport type {\n BulkOptions,\n BulkResult,\n CascadeResult,\n FindEdgesParams,\n GraphReader,\n QueryFilter,\n QueryOptions,\n StoredGraphRecord,\n} from '../types.js';\nimport type {\n BatchBackend,\n StorageBackend,\n TransactionBackend,\n UpdatePayload,\n WritableRecord,\n} from './backend.js';\n\n/**\n * Context passed to a routing callback when `subgraph(parentUid, name)` is\n * called on a routed backend. All four strings describe the *child* scope\n * the caller is requesting, so the router can key its decision off whichever\n * representation is most convenient:\n *\n * - `parentUid` / `subgraphName` — the arguments just passed to `subgraph()`.\n * - `scopePath` — logical, names-only chain (`'memories'`, `'memories/context'`).\n * This is what `allowedIn` patterns match against.\n * - `storageScope` — the materialized-path form (`'A/memories'`,\n * `'A/memories/B/context'`), suitable for use as a DO name or shard key\n * because it's globally unique within a root graph.\n */\nexport interface RoutingContext {\n parentUid: string;\n subgraphName: string;\n scopePath: string;\n storageScope: string;\n}\n\nexport interface RoutingBackendOptions {\n /**\n * Decide whether a `subgraph(parentUid, name)` call should route to a\n * different backend. Return the target backend to route; return `null`\n * (or `undefined`) to fall through to the wrapped base backend.\n *\n * The returned backend is itself wrapped by the same router so that\n * nested `.subgraph()` calls on the returned child continue to be\n * consulted.\n */\n route: (ctx: RoutingContext) => StorageBackend | null | undefined;\n}\n\nfunction assertValidSubgraphArgs(parentNodeUid: string, name: string): void {\n if (!parentNodeUid || parentNodeUid.includes('/')) {\n throw new FiregraphError(\n `Invalid parentNodeUid for subgraph: \"${parentNodeUid}\". ` +\n 'Must be a non-empty string without \"/\".',\n 'INVALID_SUBGRAPH',\n );\n }\n if (!name || name.includes('/')) {\n throw new FiregraphError(\n `Subgraph name must not contain \"/\" and must be non-empty: got \"${name}\". ` +\n 'Use chained .subgraph() calls for nested subgraphs.',\n 'INVALID_SUBGRAPH',\n );\n }\n}\n\nclass RoutingStorageBackend implements StorageBackend {\n readonly collectionPath: string;\n /**\n * Logical (names-only) scope path for *this* wrapper. Tracked\n * independently of `base.scopePath` because a routed backend returned by\n * `options.route()` typically represents its own physical root and has\n * no knowledge of the caller's logical chain. The wrapper is the\n * authoritative source of the logical scope for routing decisions and\n * for satisfying the `StorageBackend.scopePath` contract surfaced to\n * client code.\n */\n readonly scopePath: string;\n /**\n * Materialized-path form of `scopePath` — interleaved `<uid>/<name>`\n * pairs. Not a property on the underlying `StorageBackend` interface\n * (Firestore doesn't produce one), so we track it ourselves from\n * `.subgraph()` arguments. Root routers start with `''`.\n */\n private readonly storageScope: string;\n /**\n * Conditionally installed in the constructor — only present when the\n * wrapped base backend supports it. Declared as an optional instance\n * property (rather than a prototype method) so `typeof router.findEdgesGlobal\n * === 'function'` reflects the base's capability, matching the optional\n * shape in the `StorageBackend` interface.\n */\n findEdgesGlobal?: StorageBackend['findEdgesGlobal'];\n\n constructor(\n private readonly base: StorageBackend,\n private readonly options: RoutingBackendOptions,\n storageScope: string,\n logicalScopePath: string,\n ) {\n this.collectionPath = base.collectionPath;\n this.scopePath = logicalScopePath;\n this.storageScope = storageScope;\n if (base.findEdgesGlobal) {\n // We deliberately do *not* fan out across routed children: we have no\n // enumeration index for which backends exist. Callers needing\n // cross-shard collection-group queries must maintain their own index.\n this.findEdgesGlobal = (params, collectionName) =>\n base.findEdgesGlobal!(params, collectionName);\n }\n }\n\n // --- Pass-through reads ---\n\n getDoc(docId: string): Promise<StoredGraphRecord | null> {\n return this.base.getDoc(docId);\n }\n\n query(filters: QueryFilter[], options?: QueryOptions): Promise<StoredGraphRecord[]> {\n return this.base.query(filters, options);\n }\n\n // --- Pass-through writes ---\n\n setDoc(docId: string, record: WritableRecord): Promise<void> {\n return this.base.setDoc(docId, record);\n }\n\n updateDoc(docId: string, update: UpdatePayload): Promise<void> {\n return this.base.updateDoc(docId, update);\n }\n\n deleteDoc(docId: string): Promise<void> {\n return this.base.deleteDoc(docId);\n }\n\n // --- Transactions / batches run against the base backend only ---\n\n runTransaction<T>(fn: (tx: TransactionBackend) => Promise<T>): Promise<T> {\n // Transactions cannot span base + routed backends (different DBs /\n // DOs / Firestore projects). `TransactionBackend` has no `subgraph()`\n // method, so the user physically cannot open a routed child from\n // inside the callback — the compiler rejects it. At runtime, all\n // reads/writes are confined to the base backend.\n return this.base.runTransaction(fn);\n }\n\n createBatch(): BatchBackend {\n // Same constraint as transactions: `BatchBackend` has no `subgraph()`\n // so all buffered ops target the base backend. The router itself\n // doesn't need to guard anything here.\n return this.base.createBatch();\n }\n\n // --- Subgraphs: the only method that actually routes ---\n\n subgraph(parentNodeUid: string, name: string): StorageBackend {\n assertValidSubgraphArgs(parentNodeUid, name);\n\n const childScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;\n const childStorageScope = this.storageScope\n ? `${this.storageScope}/${parentNodeUid}/${name}`\n : `${parentNodeUid}/${name}`;\n\n const routed = this.options.route({\n parentUid: parentNodeUid,\n subgraphName: name,\n scopePath: childScopePath,\n storageScope: childStorageScope,\n });\n\n if (routed) {\n // The user returned a different backend. We still wrap it so that\n // further `.subgraph()` calls on the returned child continue to\n // consult the router. The routed backend's own `scopePath` / storage\n // layout is its business — for routing purposes we carry *our*\n // logical view forward (`childScopePath`) so grandchildren see a\n // correct context regardless of what `routed.scopePath` happens to\n // be (typically `''` for a freshly-minted per-DO backend).\n return new RoutingStorageBackend(routed, this.options, childStorageScope, childScopePath);\n }\n\n // No route — delegate to the base backend and keep routing in effect\n // for grandchildren.\n const childBase = this.base.subgraph(parentNodeUid, name);\n return new RoutingStorageBackend(childBase, this.options, childStorageScope, childScopePath);\n }\n\n // --- Bulk operations: delegate, but cascade is base-scope only ---\n\n removeNodeCascade(\n uid: string,\n reader: GraphReader,\n options?: BulkOptions,\n ): Promise<CascadeResult> {\n // `removeNodeCascade` on the base backend cannot see rows that live\n // in routed child backends — each routed backend is a different\n // physical store. Callers with routed subgraphs under `uid` are\n // responsible for cascading those themselves (see routing.md).\n return this.base.removeNodeCascade(uid, reader, options);\n }\n\n bulkRemoveEdges(\n params: FindEdgesParams,\n reader: GraphReader,\n options?: BulkOptions,\n ): Promise<BulkResult> {\n return this.base.bulkRemoveEdges(params, reader, options);\n }\n\n // --- Collection-group queries are base-scope only ---\n //\n // `findEdgesGlobal` is installed in the constructor *only* when the base\n // backend supports it, so `typeof router.findEdgesGlobal === 'function'`\n // reflects the base's capability — matching the optional shape declared\n // on `StorageBackend`.\n}\n\n/**\n * Wrap a `StorageBackend` so that `subgraph(parentUid, name)` calls can be\n * routed to a different backend based on a user-supplied callback.\n *\n * See the module docstring for the atomicity rules. In short: transactions\n * and batches opened on a routing backend run entirely on the *base*\n * backend — they cannot span routed children, by design.\n *\n * @example\n * ```ts\n * // `base` is any StorageBackend — e.g. a Firestore-backed one, an\n * // in-process SQLite backend, or the DO backend from firegraph/cloudflare.\n * const routed = createRoutingBackend(base, {\n * route: ({ subgraphName, storageScope }) => {\n * if (subgraphName !== 'memories') return null;\n * return createMyMemoriesBackend(storageScope); // caller-owned\n * },\n * });\n * const client = createGraphClientFromBackend(routed, { registry });\n * ```\n */\nexport function createRoutingBackend(\n base: StorageBackend,\n options: RoutingBackendOptions,\n): StorageBackend {\n if (typeof options?.route !== 'function') {\n throw new FiregraphError(\n 'createRoutingBackend: `options.route` must be a function.',\n 'INVALID_ARGUMENT',\n );\n }\n return new RoutingStorageBackend(base, options, '', base.scopePath);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/internal/routing-backend.ts"],"sourcesContent":["/**\n * Routing `StorageBackend` wrapper.\n *\n * `createRoutingBackend(base, { route })` returns a `StorageBackend` that\n * behaves identically to `base` except for `subgraph(parentUid, name)`:\n * each such call consults the caller-supplied `route` function, and if it\n * returns a non-null `StorageBackend`, that backend is used for the child\n * scope.\n *\n * This is the single seam firegraph ships for splitting a logical graph\n * across multiple physical storage backends — e.g. fanning particular\n * subgraph names out to their own Durable Objects to stay under the 10 GB\n * per-DO limit. The routing policy itself, the RPC protocol, and any\n * live-scope index are left to the caller; firegraph only owns the\n * composition primitive and the invariants that come with it.\n *\n * ## Contract — nested routing\n *\n * Whether `route()` returns a routed backend OR `null` (pass-through), the\n * child returned by `subgraph()` is **always** itself wrapped by the same\n * router. Without that self-wrap, a call chain like\n *\n * ```ts\n * router.subgraph(A, 'memories').subgraph(B, 'context')\n * ```\n *\n * would route the first hop correctly but bypass the router on the second\n * hop (since the routed backend's own `.subgraph()` doesn't know about the\n * caller's policy). Keeping routing active through grandchildren is the\n * load-bearing behaviour; `'continues routing on grandchildren …'` in the\n * unit tests locks it in.\n *\n * ## Contract — `route` is synchronous\n *\n * `.subgraph()` is synchronous in firegraph's public API. Making the\n * routing callback async would require rippling Promises through every\n * client-factory call site. Consequence: `route` can only consult data it\n * already has in hand (DO bindings, naming rules, in-memory caches). If\n * you need \"does this DO exist?\" checks, do them lazily — the first read\n * against the returned backend will surface the failure naturally.\n *\n * ## Contract — cross-backend atomicity is not silently degraded\n *\n * The wrapper's `runTransaction` and `createBatch` delegate to `base` —\n * they run entirely on the base backend. `TransactionBackend` and\n * `BatchBackend` deliberately have no `subgraph()` method, so user code\n * physically cannot open a routed child from inside a transaction\n * callback. Any attempt to bypass that (via `as any` / unchecked casts)\n * should surface as `CrossBackendTransactionError` so app code can catch\n * it cleanly — the error type is part of the public surface.\n *\n * ## Contract — `findEdgesGlobal` is base-scope only\n *\n * When delegated, `findEdgesGlobal` runs against the base backend only.\n * It does **not** fan out to routed children — firegraph has no\n * enumeration index for which routed backends exist. Callers who need\n * cross-shard collection-group queries must maintain their own scope\n * directory and query it directly. This keeps the common case (local\n * analytics inside one DO) fast.\n */\n\nimport { FiregraphError } from '../errors.js';\nimport type {\n AggregateSpec,\n BulkOptions,\n BulkResult,\n BulkUpdatePatch,\n CascadeResult,\n EngineTraversalParams,\n EngineTraversalResult,\n ExpandParams,\n ExpandResult,\n FindEdgesParams,\n FindNearestParams,\n FullTextSearchParams,\n GeoSearchParams,\n GraphReader,\n QueryFilter,\n QueryOptions,\n StoredGraphRecord,\n} from '../types.js';\nimport type {\n BackendCapabilities,\n BatchBackend,\n StorageBackend,\n TransactionBackend,\n UpdatePayload,\n WritableRecord,\n WriteMode,\n} from './backend.js';\nimport { intersectCapabilities } from './backend.js';\n\n/**\n * Context passed to a routing callback when `subgraph(parentUid, name)` is\n * called on a routed backend. All four strings describe the *child* scope\n * the caller is requesting, so the router can key its decision off whichever\n * representation is most convenient:\n *\n * - `parentUid` / `subgraphName` — the arguments just passed to `subgraph()`.\n * - `scopePath` — logical, names-only chain (`'memories'`, `'memories/context'`).\n * This is what `allowedIn` patterns match against.\n * - `storageScope` — the materialized-path form (`'A/memories'`,\n * `'A/memories/B/context'`), suitable for use as a DO name or shard key\n * because it's globally unique within a root graph.\n */\nexport interface RoutingContext {\n parentUid: string;\n subgraphName: string;\n scopePath: string;\n storageScope: string;\n}\n\nexport interface RoutingBackendOptions {\n /**\n * Decide whether a `subgraph(parentUid, name)` call should route to a\n * different backend. Return the target backend to route; return `null`\n * (or `undefined`) to fall through to the wrapped base backend.\n *\n * The returned backend is itself wrapped by the same router so that\n * nested `.subgraph()` calls on the returned child continue to be\n * consulted.\n */\n route: (ctx: RoutingContext) => StorageBackend | null | undefined;\n /**\n * Capability sets for any backend `route()` may return. The root routing\n * wrapper's `capabilities` becomes the intersection of `base.capabilities`\n * and every set passed here, satisfying invariant 5 from\n * `.claude/backend-capabilities.md` (\"a graph mounted across multiple\n * backends declares the intersection of child capability sets\").\n *\n * Capability declarations are required by invariant 3 to be **static** at\n * construction. Because `route()` is consulted dynamically, the wrapper\n * cannot discover routed children's caps after the fact — callers\n * intersecting across backends must enumerate the participants up front.\n *\n * When `undefined` or empty, the routing wrapper mirrors\n * `base.capabilities`. That matches the common single-backend routing\n * use case (route one subgraph name to a peer of the same backend type)\n * without forcing every caller to declare an explicit list. Mixed-backend\n * callers should always populate this — the cap surface won't lie about\n * what's safe across hops.\n */\n routedCapabilities?: ReadonlyArray<BackendCapabilities>;\n}\n\nfunction assertValidSubgraphArgs(parentNodeUid: string, name: string): void {\n if (!parentNodeUid || parentNodeUid.includes('/')) {\n throw new FiregraphError(\n `Invalid parentNodeUid for subgraph: \"${parentNodeUid}\". ` +\n 'Must be a non-empty string without \"/\".',\n 'INVALID_SUBGRAPH',\n );\n }\n if (!name || name.includes('/')) {\n throw new FiregraphError(\n `Subgraph name must not contain \"/\" and must be non-empty: got \"${name}\". ` +\n 'Use chained .subgraph() calls for nested subgraphs.',\n 'INVALID_SUBGRAPH',\n );\n }\n}\n\nclass RoutingStorageBackend implements StorageBackend {\n /**\n * Effective capability set for this wrapper.\n *\n * - **Root wrapper** (`createRoutingBackend(...)` direct return): if the\n * caller supplied `options.routedCapabilities`, the cap set is the\n * intersection of `base.capabilities` and every set in that list. If\n * not, the cap set mirrors `base.capabilities` (suitable when routes\n * target peers of the same backend type — no capability differential\n * to honour).\n * - **Child wrapper** (returned from `subgraph()`): the cap set mirrors\n * the *wrapped* backend (either `base.subgraph(...)` or the backend\n * returned by `route()`). Each child handle reflects what's safe to\n * call against the specific backend it targets — invariant 3 holds\n * per-instance.\n *\n * This satisfies invariant 5 (intersection across mixed-backend graphs)\n * when callers opt in, and falls back to a non-lying mirror when they\n * don't.\n */\n readonly capabilities: BackendCapabilities;\n readonly collectionPath: string;\n /**\n * Logical (names-only) scope path for *this* wrapper. Tracked\n * independently of `base.scopePath` because a routed backend returned by\n * `options.route()` typically represents its own physical root and has\n * no knowledge of the caller's logical chain. The wrapper is the\n * authoritative source of the logical scope for routing decisions and\n * for satisfying the `StorageBackend.scopePath` contract surfaced to\n * client code.\n */\n readonly scopePath: string;\n /**\n * Materialized-path form of `scopePath` — interleaved `<uid>/<name>`\n * pairs. Not a property on the underlying `StorageBackend` interface\n * (Firestore doesn't produce one), so we track it ourselves from\n * `.subgraph()` arguments. Root routers start with `''`.\n */\n private readonly storageScope: string;\n /**\n * Conditionally installed in the constructor — only present when the\n * wrapped base backend supports it. Declared as an optional instance\n * property (rather than a prototype method) so `typeof router.findEdgesGlobal\n * === 'function'` reflects the base's capability, matching the optional\n * shape in the `StorageBackend` interface.\n */\n findEdgesGlobal?: StorageBackend['findEdgesGlobal'];\n /**\n * Same conditional-install pattern as `findEdgesGlobal`. The router's\n * declared capability set is mirrored from the base (or intersected with\n * the user's `routedCapabilities`) — if `query.aggregate` is in that\n * set, the underlying method must be present, otherwise `client.aggregate()`\n * would resolve `UNSUPPORTED_OPERATION` despite the cap claim. This\n * ensures the \"declared capability ⇒ method exists\" invariant holds\n * through routing wrappers (Phase 4 audit C1).\n */\n aggregate?: StorageBackend['aggregate'];\n /**\n * DML pass-throughs. Same conditional-install pattern as `aggregate`:\n * gated on BOTH the base method's existence AND `this.capabilities`\n * advertising `query.dml`. If `routedCapabilities` intersected\n * `query.dml` away (e.g. one routed peer is Firestore Standard which\n * has no pipeline-DML support), the methods are *not* installed even\n * though `base.bulkDelete` exists — otherwise the router would silently\n * outperform what the declared cap set promises across hops. This\n * preserves the \"declared capability ⇒ method exists\" invariant in\n * both directions (Phase 5).\n */\n bulkDelete?: StorageBackend['bulkDelete'];\n bulkUpdate?: StorageBackend['bulkUpdate'];\n /**\n * Multi-source fan-out pass-through. Same conditional-install pattern as\n * `aggregate` and the bulk-DML methods: gated on BOTH the base method's\n * existence AND `this.capabilities` advertising `query.join`. If\n * `routedCapabilities` intersected `query.join` away (e.g. one routed peer\n * is Firestore Standard which has no pipeline-join support), the method is\n * not installed even though `base.expand` exists. This preserves the\n * \"declared capability ⇒ method exists\" invariant in both directions.\n *\n * Like `aggregate` and bulk DML, `expand` runs against the base backend\n * only — it cannot fan out across routed children, since each routed\n * subgraph is a separate physical store. Cross-graph hops (which resolve\n * to per-source subgraph readers) are therefore never dispatched through\n * `expand` by `traverse.ts`; the same constraint applies here, naturally.\n */\n expand?: StorageBackend['expand'];\n /**\n * Engine-level multi-hop traversal pass-through. Same conditional-install\n * pattern as `aggregate`, bulk DML, and `expand`: gated on BOTH the base\n * method's existence AND `this.capabilities` advertising\n * `traversal.serverSide`. If `routedCapabilities` intersected\n * `traversal.serverSide` away (e.g. one routed peer is a SQLite-shaped\n * backend that has no nested-pipeline path), the method is not installed\n * even though `base.runEngineTraversal` exists. This preserves the\n * \"declared capability ⇒ method exists\" invariant in both directions.\n *\n * Like the other extensions, engine traversal runs against the base\n * backend only — a routed child's own `runEngineTraversal` is reached\n * through `.subgraph().runEngineTraversal()` against the routed handle.\n * Cross-graph hops never reach this method anyway: the traversal\n * compiler in `firestore-traverse-compiler.ts` rejects specs whose\n * hops carry `targetGraph`, falling back to the per-hop loop. Routed\n * children are physically distinct backends, so even an \"in-graph\"\n * traversal across a routed-child boundary is structurally a\n * cross-backend hop and never compiles for engine dispatch.\n */\n runEngineTraversal?: StorageBackend['runEngineTraversal'];\n /**\n * Server-side projection pass-through. Same conditional-install pattern as\n * `aggregate`, bulk DML, and `expand`: gated on BOTH the base method's\n * existence AND `this.capabilities` advertising `query.select`. If\n * `routedCapabilities` intersected `query.select` away (e.g. one routed\n * peer doesn't implement projection), the method is not installed even\n * though `base.findEdgesProjected` exists. This preserves the \"declared\n * capability ⇒ method exists\" invariant in both directions.\n *\n * Like `aggregate` and `expand`, projection runs against the base backend\n * only — a routed child's own projection is reached through\n * `.subgraph().findEdgesProjected()` against the routed handle.\n */\n findEdgesProjected?: StorageBackend['findEdgesProjected'];\n /**\n * Vector / nearest-neighbour pass-through. Same conditional-install\n * pattern as `aggregate`, bulk DML, `expand`, and `findEdgesProjected`:\n * gated on BOTH the base method's existence AND `this.capabilities`\n * advertising `search.vector`. If `routedCapabilities` intersected\n * `search.vector` away (e.g. one routed peer is a SQLite-shaped backend\n * that has no native ANN index), the method is not installed even\n * though `base.findNearest` exists. This preserves the \"declared\n * capability ⇒ method exists\" invariant in both directions.\n *\n * Like the other extensions, vector search runs against the base\n * backend only — a routed child's own `findNearest` is reached through\n * `.subgraph().findNearest()` against the routed handle.\n */\n findNearest?: StorageBackend['findNearest'];\n /**\n * Full-text search pass-through. Same conditional-install pattern as\n * `findNearest`: gated on BOTH the base method's existence AND\n * `this.capabilities` advertising `search.fullText`. If\n * `routedCapabilities` intersected `search.fullText` away (e.g. one\n * routed peer is Firestore Standard or a SQLite-shaped backend that\n * has no native FTS index), the method is not installed even though\n * `base.fullTextSearch` exists. This preserves the \"declared\n * capability ⇒ method exists\" invariant in both directions.\n *\n * Like the other extensions, FTS runs against the base backend only —\n * a routed child's own `fullTextSearch` is reached through\n * `.subgraph().fullTextSearch()` against the routed handle.\n */\n fullTextSearch?: StorageBackend['fullTextSearch'];\n /**\n * Geospatial distance pass-through. Same conditional-install pattern\n * as `fullTextSearch`: gated on BOTH the base method's existence AND\n * `this.capabilities` advertising `search.geo`. If `routedCapabilities`\n * intersected `search.geo` away (e.g. one routed peer is Firestore\n * Standard or a SQLite-shaped backend that has no native geo index),\n * the method is not installed even though `base.geoSearch` exists.\n * This preserves the \"declared capability ⇒ method exists\" invariant\n * in both directions.\n *\n * Like the other extensions, geo search runs against the base backend\n * only — a routed child's own `geoSearch` is reached through\n * `.subgraph().geoSearch()` against the routed handle.\n */\n geoSearch?: StorageBackend['geoSearch'];\n\n constructor(\n private readonly base: StorageBackend,\n private readonly options: RoutingBackendOptions,\n storageScope: string,\n logicalScopePath: string,\n /**\n * Explicit cap set for this wrapper. Passed by `subgraph()` so child\n * wrappers mirror the routed child's caps. `createRoutingBackend`\n * leaves it `undefined` so the constructor computes the root-level\n * intersection from `options.routedCapabilities`.\n */\n capabilities?: BackendCapabilities,\n ) {\n this.collectionPath = base.collectionPath;\n this.scopePath = logicalScopePath;\n this.storageScope = storageScope;\n if (capabilities) {\n this.capabilities = capabilities;\n } else if (options.routedCapabilities && options.routedCapabilities.length > 0) {\n this.capabilities = intersectCapabilities([base.capabilities, ...options.routedCapabilities]);\n } else {\n this.capabilities = base.capabilities;\n }\n if (base.findEdgesGlobal) {\n // We deliberately do *not* fan out across routed children: we have no\n // enumeration index for which backends exist. Callers needing\n // cross-shard collection-group queries must maintain their own index.\n this.findEdgesGlobal = (params, collectionName) =>\n base.findEdgesGlobal!(params, collectionName);\n }\n if (base.aggregate && this.capabilities.has('query.aggregate')) {\n // Aggregates are scoped to the base backend — same rationale as\n // `findEdgesGlobal`. A routed child has its own backend with its own\n // `aggregate` method that the user reaches via `.subgraph().aggregate()`;\n // this router-level pass-through covers the base scope only.\n //\n // The cap check matters when `routedCapabilities` intersected\n // `query.aggregate` away: even if `base.aggregate` exists, the router's\n // declared cap set says \"no aggregate\", and installing the method\n // would violate the \"declared capability ⇒ method exists\" invariant\n // in the inverse direction (declared-absent yet runtime-present).\n // The post-Phase-4 audit (M-C) calls this out explicitly.\n this.aggregate = (spec: AggregateSpec, filters: QueryFilter[]) =>\n base.aggregate!(spec, filters);\n }\n if (base.bulkDelete && this.capabilities.has('query.dml')) {\n // Same scope rationale as `aggregate`: bulk DML runs against the base\n // backend only. A routed child's own DML support is reached through\n // `.subgraph().bulkDelete()` against the routed handle.\n this.bulkDelete = (filters: QueryFilter[], options?: BulkOptions) =>\n base.bulkDelete!(filters, options);\n }\n if (base.bulkUpdate && this.capabilities.has('query.dml')) {\n this.bulkUpdate = (filters: QueryFilter[], patch: BulkUpdatePatch, options?: BulkOptions) =>\n base.bulkUpdate!(filters, patch, options);\n }\n if (base.expand && this.capabilities.has('query.join')) {\n // Same scope rationale as `aggregate` and bulk DML: `expand` runs\n // against the base backend only. A routed child's own `expand` is\n // reached through `.subgraph().expand()` against the routed handle.\n this.expand = (params: ExpandParams): Promise<ExpandResult> => base.expand!(params);\n }\n if (base.runEngineTraversal && this.capabilities.has('traversal.serverSide')) {\n // Same scope rationale as the other extensions: engine traversal\n // runs against the base backend only. A routed child's own\n // `runEngineTraversal` is reached through\n // `.subgraph().runEngineTraversal()` against the routed handle.\n // Cross-routed-backend traversal is structurally impossible —\n // the compiler bails on cross-graph hops and the per-hop loop\n // re-resolves the reader at each carry-forward step.\n this.runEngineTraversal = (params: EngineTraversalParams): Promise<EngineTraversalResult> =>\n base.runEngineTraversal!(params);\n }\n if (base.findEdgesProjected && this.capabilities.has('query.select')) {\n // Same scope rationale as `aggregate`, bulk DML, and `expand`:\n // server-side projection runs against the base backend only. A routed\n // child's own projection is reached through\n // `.subgraph().findEdgesProjected()` against the routed handle.\n this.findEdgesProjected = (\n select: ReadonlyArray<string>,\n filters: QueryFilter[],\n options?: QueryOptions,\n ) => base.findEdgesProjected!(select, filters, options);\n }\n if (base.findNearest && this.capabilities.has('search.vector')) {\n // Same scope rationale as the other extensions: vector search runs\n // against the base backend only. A routed child's own `findNearest`\n // is reached through `.subgraph().findNearest()` against the routed\n // handle.\n this.findNearest = (params: FindNearestParams) => base.findNearest!(params);\n }\n if (base.fullTextSearch && this.capabilities.has('search.fullText')) {\n // Same scope rationale as the other extensions: FTS runs against\n // the base backend only. A routed child's own `fullTextSearch` is\n // reached through `.subgraph().fullTextSearch()` against the routed\n // handle.\n this.fullTextSearch = (params: FullTextSearchParams) => base.fullTextSearch!(params);\n }\n if (base.geoSearch && this.capabilities.has('search.geo')) {\n // Same scope rationale as the other extensions: geo search runs\n // against the base backend only. A routed child's own `geoSearch`\n // is reached through `.subgraph().geoSearch()` against the routed\n // handle.\n this.geoSearch = (params: GeoSearchParams) => base.geoSearch!(params);\n }\n }\n\n // --- Pass-through reads ---\n\n getDoc(docId: string): Promise<StoredGraphRecord | null> {\n return this.base.getDoc(docId);\n }\n\n query(filters: QueryFilter[], options?: QueryOptions): Promise<StoredGraphRecord[]> {\n return this.base.query(filters, options);\n }\n\n // --- Pass-through writes ---\n\n setDoc(docId: string, record: WritableRecord, mode: WriteMode): Promise<void> {\n return this.base.setDoc(docId, record, mode);\n }\n\n updateDoc(docId: string, update: UpdatePayload): Promise<void> {\n return this.base.updateDoc(docId, update);\n }\n\n deleteDoc(docId: string): Promise<void> {\n return this.base.deleteDoc(docId);\n }\n\n // --- Transactions / batches run against the base backend only ---\n\n runTransaction<T>(fn: (tx: TransactionBackend) => Promise<T>): Promise<T> {\n // Transactions cannot span base + routed backends (different DBs /\n // DOs / Firestore projects). `TransactionBackend` has no `subgraph()`\n // method, so the user physically cannot open a routed child from\n // inside the callback — the compiler rejects it. At runtime, all\n // reads/writes are confined to the base backend.\n return this.base.runTransaction(fn);\n }\n\n createBatch(): BatchBackend {\n // Same constraint as transactions: `BatchBackend` has no `subgraph()`\n // so all buffered ops target the base backend. The router itself\n // doesn't need to guard anything here.\n return this.base.createBatch();\n }\n\n // --- Subgraphs: the only method that actually routes ---\n\n subgraph(parentNodeUid: string, name: string): StorageBackend {\n assertValidSubgraphArgs(parentNodeUid, name);\n\n const childScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;\n const childStorageScope = this.storageScope\n ? `${this.storageScope}/${parentNodeUid}/${name}`\n : `${parentNodeUid}/${name}`;\n\n const routed = this.options.route({\n parentUid: parentNodeUid,\n subgraphName: name,\n scopePath: childScopePath,\n storageScope: childStorageScope,\n });\n\n if (routed) {\n // The user returned a different backend. We still wrap it so that\n // further `.subgraph()` calls on the returned child continue to\n // consult the router. The routed backend's own `scopePath` / storage\n // layout is its business — for routing purposes we carry *our*\n // logical view forward (`childScopePath`) so grandchildren see a\n // correct context regardless of what `routed.scopePath` happens to\n // be (typically `''` for a freshly-minted per-DO backend). The child\n // wrapper mirrors the routed backend's own cap set: invariant 3\n // (static caps per instance) holds, and the user's view of the\n // child handle reflects what that backend actually supports.\n return new RoutingStorageBackend(\n routed,\n this.options,\n childStorageScope,\n childScopePath,\n routed.capabilities,\n );\n }\n\n // No route — delegate to the base backend and keep routing in effect\n // for grandchildren. Child wrapper mirrors the base subgraph's caps\n // (typically identical to `base.capabilities` itself, but we ask the\n // child explicitly so a backend that narrows caps in subgraphs is\n // honoured).\n const childBase = this.base.subgraph(parentNodeUid, name);\n return new RoutingStorageBackend(\n childBase,\n this.options,\n childStorageScope,\n childScopePath,\n childBase.capabilities,\n );\n }\n\n // --- Bulk operations: delegate, but cascade is base-scope only ---\n\n removeNodeCascade(\n uid: string,\n reader: GraphReader,\n options?: BulkOptions,\n ): Promise<CascadeResult> {\n // `removeNodeCascade` on the base backend cannot see rows that live\n // in routed child backends — each routed backend is a different\n // physical store. Callers with routed subgraphs under `uid` are\n // responsible for cascading those themselves (see routing.md).\n return this.base.removeNodeCascade(uid, reader, options);\n }\n\n bulkRemoveEdges(\n params: FindEdgesParams,\n reader: GraphReader,\n options?: BulkOptions,\n ): Promise<BulkResult> {\n return this.base.bulkRemoveEdges(params, reader, options);\n }\n\n // --- Collection-group queries are base-scope only ---\n //\n // `findEdgesGlobal` is installed in the constructor *only* when the base\n // backend supports it, so `typeof router.findEdgesGlobal === 'function'`\n // reflects the base's capability — matching the optional shape declared\n // on `StorageBackend`.\n}\n\n/**\n * Wrap a `StorageBackend` so that `subgraph(parentUid, name)` calls can be\n * routed to a different backend based on a user-supplied callback.\n *\n * See the module docstring for the atomicity rules. In short: transactions\n * and batches opened on a routing backend run entirely on the *base*\n * backend — they cannot span routed children, by design.\n *\n * @example\n * ```ts\n * // `base` is any StorageBackend — e.g. a Firestore-backed one, an\n * // in-process SQLite backend, or the DO backend from firegraph/cloudflare.\n * const routed = createRoutingBackend(base, {\n * route: ({ subgraphName, storageScope }) => {\n * if (subgraphName !== 'memories') return null;\n * return createMyMemoriesBackend(storageScope); // caller-owned\n * },\n * });\n * const client = createGraphClientFromBackend(routed, { registry });\n * ```\n */\nexport function createRoutingBackend(\n base: StorageBackend,\n options: RoutingBackendOptions,\n): StorageBackend {\n if (typeof options?.route !== 'function') {\n throw new FiregraphError(\n 'createRoutingBackend: `options.route` must be a function.',\n 'INVALID_ARGUMENT',\n );\n }\n return new RoutingStorageBackend(base, options, '', base.scopePath);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAiJA,SAAS,wBAAwB,eAAuB,MAAoB;AAC1E,MAAI,CAAC,iBAAiB,cAAc,SAAS,GAAG,GAAG;AACjD,UAAM,IAAI;AAAA,MACR,wCAAwC,aAAa;AAAA,MAErD;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,QAAQ,KAAK,SAAS,GAAG,GAAG;AAC/B,UAAM,IAAI;AAAA,MACR,kEAAkE,IAAI;AAAA,MAEtE;AAAA,IACF;AAAA,EACF;AACF;AAEA,IAAM,wBAAN,MAAM,uBAAgD;AAAA,EAuKpD,YACmB,MACA,SACjB,cACA,kBAOA,cACA;AAXiB;AACA;AAWjB,SAAK,iBAAiB,KAAK;AAC3B,SAAK,YAAY;AACjB,SAAK,eAAe;AACpB,QAAI,cAAc;AAChB,WAAK,eAAe;AAAA,IACtB,WAAW,QAAQ,sBAAsB,QAAQ,mBAAmB,SAAS,GAAG;AAC9E,WAAK,eAAe,sBAAsB,CAAC,KAAK,cAAc,GAAG,QAAQ,kBAAkB,CAAC;AAAA,IAC9F,OAAO;AACL,WAAK,eAAe,KAAK;AAAA,IAC3B;AACA,QAAI,KAAK,iBAAiB;AAIxB,WAAK,kBAAkB,CAAC,QAAQ,mBAC9B,KAAK,gBAAiB,QAAQ,cAAc;AAAA,IAChD;AACA,QAAI,KAAK,aAAa,KAAK,aAAa,IAAI,iBAAiB,GAAG;AAY9D,WAAK,YAAY,CAAC,MAAqB,YACrC,KAAK,UAAW,MAAM,OAAO;AAAA,IACjC;AACA,QAAI,KAAK,cAAc,KAAK,aAAa,IAAI,WAAW,GAAG;AAIzD,WAAK,aAAa,CAAC,SAAwBA,aACzC,KAAK,WAAY,SAASA,QAAO;AAAA,IACrC;AACA,QAAI,KAAK,cAAc,KAAK,aAAa,IAAI,WAAW,GAAG;AACzD,WAAK,aAAa,CAAC,SAAwB,OAAwBA,aACjE,KAAK,WAAY,SAAS,OAAOA,QAAO;AAAA,IAC5C;AACA,QAAI,KAAK,UAAU,KAAK,aAAa,IAAI,YAAY,GAAG;AAItD,WAAK,SAAS,CAAC,WAAgD,KAAK,OAAQ,MAAM;AAAA,IACpF;AACA,QAAI,KAAK,sBAAsB,KAAK,aAAa,IAAI,sBAAsB,GAAG;AAQ5E,WAAK,qBAAqB,CAAC,WACzB,KAAK,mBAAoB,MAAM;AAAA,IACnC;AACA,QAAI,KAAK,sBAAsB,KAAK,aAAa,IAAI,cAAc,GAAG;AAKpE,WAAK,qBAAqB,CACxB,QACA,SACAA,aACG,KAAK,mBAAoB,QAAQ,SAASA,QAAO;AAAA,IACxD;AACA,QAAI,KAAK,eAAe,KAAK,aAAa,IAAI,eAAe,GAAG;AAK9D,WAAK,cAAc,CAAC,WAA8B,KAAK,YAAa,MAAM;AAAA,IAC5E;AACA,QAAI,KAAK,kBAAkB,KAAK,aAAa,IAAI,iBAAiB,GAAG;AAKnE,WAAK,iBAAiB,CAAC,WAAiC,KAAK,eAAgB,MAAM;AAAA,IACrF;AACA,QAAI,KAAK,aAAa,KAAK,aAAa,IAAI,YAAY,GAAG;AAKzD,WAAK,YAAY,CAAC,WAA4B,KAAK,UAAW,MAAM;AAAA,IACtE;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA5PS;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQjB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAqBA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA;AAAA;AAAA,EA+GA,OAAO,OAAkD;AACvD,WAAO,KAAK,KAAK,OAAO,KAAK;AAAA,EAC/B;AAAA,EAEA,MAAM,SAAwB,SAAsD;AAClF,WAAO,KAAK,KAAK,MAAM,SAAS,OAAO;AAAA,EACzC;AAAA;AAAA,EAIA,OAAO,OAAe,QAAwB,MAAgC;AAC5E,WAAO,KAAK,KAAK,OAAO,OAAO,QAAQ,IAAI;AAAA,EAC7C;AAAA,EAEA,UAAU,OAAe,QAAsC;AAC7D,WAAO,KAAK,KAAK,UAAU,OAAO,MAAM;AAAA,EAC1C;AAAA,EAEA,UAAU,OAA8B;AACtC,WAAO,KAAK,KAAK,UAAU,KAAK;AAAA,EAClC;AAAA;AAAA,EAIA,eAAkB,IAAwD;AAMxE,WAAO,KAAK,KAAK,eAAe,EAAE;AAAA,EACpC;AAAA,EAEA,cAA4B;AAI1B,WAAO,KAAK,KAAK,YAAY;AAAA,EAC/B;AAAA;AAAA,EAIA,SAAS,eAAuB,MAA8B;AAC5D,4BAAwB,eAAe,IAAI;AAE3C,UAAM,iBAAiB,KAAK,YAAY,GAAG,KAAK,SAAS,IAAI,IAAI,KAAK;AACtE,UAAM,oBAAoB,KAAK,eAC3B,GAAG,KAAK,YAAY,IAAI,aAAa,IAAI,IAAI,KAC7C,GAAG,aAAa,IAAI,IAAI;AAE5B,UAAM,SAAS,KAAK,QAAQ,MAAM;AAAA,MAChC,WAAW;AAAA,MACX,cAAc;AAAA,MACd,WAAW;AAAA,MACX,cAAc;AAAA,IAChB,CAAC;AAED,QAAI,QAAQ;AAWV,aAAO,IAAI;AAAA,QACT;AAAA,QACA,KAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA,OAAO;AAAA,MACT;AAAA,IACF;AAOA,UAAM,YAAY,KAAK,KAAK,SAAS,eAAe,IAAI;AACxD,WAAO,IAAI;AAAA,MACT;AAAA,MACA,KAAK;AAAA,MACL;AAAA,MACA;AAAA,MACA,UAAU;AAAA,IACZ;AAAA,EACF;AAAA;AAAA,EAIA,kBACE,KACA,QACA,SACwB;AAKxB,WAAO,KAAK,KAAK,kBAAkB,KAAK,QAAQ,OAAO;AAAA,EACzD;AAAA,EAEA,gBACE,QACA,QACA,SACqB;AACrB,WAAO,KAAK,KAAK,gBAAgB,QAAQ,QAAQ,OAAO;AAAA,EAC1D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQF;AAuBO,SAAS,qBACd,MACA,SACgB;AAChB,MAAI,OAAO,SAAS,UAAU,YAAY;AACxC,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO,IAAI,sBAAsB,MAAM,SAAS,IAAI,KAAK,SAAS;AACpE;","names":["options"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/default-indexes.ts
|
|
2
|
+
var DEFAULT_CORE_INDEXES = Object.freeze([
|
|
3
|
+
{ fields: ["aUid"] },
|
|
4
|
+
{ fields: ["bUid"] },
|
|
5
|
+
{ fields: ["aType"] },
|
|
6
|
+
{ fields: ["bType"] },
|
|
7
|
+
{ fields: ["aUid", "axbType"] },
|
|
8
|
+
{ fields: ["axbType", "bUid"] },
|
|
9
|
+
{ fields: ["aType", "axbType"] },
|
|
10
|
+
{ fields: ["axbType", "bType"] }
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
DEFAULT_CORE_INDEXES
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=chunk-2DHMNTV6.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/default-indexes.ts"],"sourcesContent":["/**\n * Default core index preset.\n *\n * This set covers the query patterns firegraph's query planner emits for\n * built-in operations — `findNodes`, `findEdges`, cascade delete, traversal,\n * and the DO/SQLite path compilers. Apps that need additional indexes\n * (descending timestamps, `data.*` filters, composite fields unique to\n * their query shapes) declare them on `RegistryEntry.indexes` or override\n * this preset wholesale via the backend-specific `coreIndexes` option —\n * `FiregraphDOOptions.coreIndexes` for the DO backend,\n * `BuildSchemaOptions.coreIndexes` for the legacy SQLite backend, and\n * `GenerateIndexOptions.coreIndexes` for the Firestore CLI generator.\n *\n * ## Ownership model\n *\n * This list is firegraph's *recommendation* — not non-negotiable policy.\n * Consumers can:\n *\n * 1. Accept the preset as-is (default).\n * 2. Extend it: `coreIndexes: [...DEFAULT_CORE_INDEXES, ...more]`.\n * 3. Replace it entirely with a tailored set.\n * 4. Disable it (`coreIndexes: []`) and take full responsibility for\n * index coverage — only do this if you're provisioning a complete\n * custom set.\n *\n * ## Per-backend emission\n *\n * The Firestore generator skips single-field entries (Firestore implicitly\n * indexes every field) and emits one composite index per multi-field spec.\n * The SQLite-flavored generators (DO, legacy) emit every spec as-is.\n *\n * ## Why these specific indexes\n *\n * - `aUid` / `bUid` — required for `_fgRemoveNodeCascade`, which scans by\n * each UID side independently. A composite `(aUid, axbType)` also\n * satisfies `aUid`-alone via leading-column prefix, but the single-field\n * form is cheaper for the common case.\n * - `aType` / `bType` — `findNodes({ aType })` and cross-type enumeration.\n * - `(aUid, axbType)` — forward edge lookup (`findEdges({ aUid, axbType })`)\n * and the `get` strategy fallback when only two of three triple fields\n * are present.\n * - `(axbType, bUid)` — reverse edge traversal.\n * - `(aType, axbType)` — type-scoped edge scans (e.g., `findEdges({ aType, axbType })`).\n * - `(axbType, bType)` — scope edges of one relation to a target type.\n */\n\nimport type { IndexSpec } from './types.js';\n\nexport const DEFAULT_CORE_INDEXES: ReadonlyArray<IndexSpec> = Object.freeze([\n { fields: ['aUid'] },\n { fields: ['bUid'] },\n { fields: ['aType'] },\n { fields: ['bType'] },\n { fields: ['aUid', 'axbType'] },\n { fields: ['axbType', 'bUid'] },\n { fields: ['aType', 'axbType'] },\n { fields: ['axbType', 'bType'] },\n]);\n"],"mappings":";AAgDO,IAAM,uBAAiD,OAAO,OAAO;AAAA,EAC1E,EAAE,QAAQ,CAAC,MAAM,EAAE;AAAA,EACnB,EAAE,QAAQ,CAAC,MAAM,EAAE;AAAA,EACnB,EAAE,QAAQ,CAAC,OAAO,EAAE;AAAA,EACpB,EAAE,QAAQ,CAAC,OAAO,EAAE;AAAA,EACpB,EAAE,QAAQ,CAAC,QAAQ,SAAS,EAAE;AAAA,EAC9B,EAAE,QAAQ,CAAC,WAAW,MAAM,EAAE;AAAA,EAC9B,EAAE,QAAQ,CAAC,SAAS,SAAS,EAAE;AAAA,EAC/B,EAAE,QAAQ,CAAC,WAAW,OAAO,EAAE;AACjC,CAAC;","names":[]}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FiregraphError,
|
|
3
|
+
isDeleteSentinel
|
|
4
|
+
} from "./chunk-TK64DNVK.js";
|
|
5
|
+
import {
|
|
6
|
+
SERIALIZATION_TAG
|
|
7
|
+
} from "./chunk-EQJUUVFG.js";
|
|
8
|
+
|
|
9
|
+
// src/internal/sqlite-index-ddl.ts
|
|
10
|
+
var IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
11
|
+
var JSON_PATH_KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
12
|
+
function quoteIdent(name) {
|
|
13
|
+
if (!IDENT_RE.test(name)) {
|
|
14
|
+
throw new FiregraphError(
|
|
15
|
+
`Invalid SQL identifier in index DDL: ${name}. Must match /^[A-Za-z_][A-Za-z0-9_]*$/.`,
|
|
16
|
+
"INVALID_INDEX"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return `"${name}"`;
|
|
20
|
+
}
|
|
21
|
+
function fnv1a32(str) {
|
|
22
|
+
let h = 2166136261;
|
|
23
|
+
for (let i = 0; i < str.length; i++) {
|
|
24
|
+
h ^= str.charCodeAt(i);
|
|
25
|
+
h = Math.imul(h, 16777619);
|
|
26
|
+
}
|
|
27
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
28
|
+
}
|
|
29
|
+
function normalizeFields(fields) {
|
|
30
|
+
return fields.map((f) => {
|
|
31
|
+
if (typeof f === "string") return { path: f, desc: false };
|
|
32
|
+
if (!f.path || typeof f.path !== "string") {
|
|
33
|
+
throw new FiregraphError(
|
|
34
|
+
`IndexSpec field must be a string or { path: string, desc?: boolean }; got ${JSON.stringify(f)}`,
|
|
35
|
+
"INVALID_INDEX"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return { path: f.path, desc: !!f.desc };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function specFingerprint(spec, leadingColumns) {
|
|
42
|
+
const normalized = {
|
|
43
|
+
lead: leadingColumns,
|
|
44
|
+
fields: normalizeFields(spec.fields),
|
|
45
|
+
where: spec.where ?? ""
|
|
46
|
+
};
|
|
47
|
+
return fnv1a32(JSON.stringify(normalized));
|
|
48
|
+
}
|
|
49
|
+
function compileFieldExpr(path, fieldToColumn) {
|
|
50
|
+
const col = fieldToColumn[path];
|
|
51
|
+
if (col) return quoteIdent(col);
|
|
52
|
+
if (path === "data") {
|
|
53
|
+
return `json_extract("data", '$')`;
|
|
54
|
+
}
|
|
55
|
+
if (path.startsWith("data.")) {
|
|
56
|
+
const suffix = path.slice(5);
|
|
57
|
+
const parts = suffix.split(".");
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
if (!JSON_PATH_KEY_RE.test(part)) {
|
|
60
|
+
throw new FiregraphError(
|
|
61
|
+
`IndexSpec data path "${path}" has invalid component "${part}". Each component must match /^[A-Za-z_][A-Za-z0-9_-]*$/.`,
|
|
62
|
+
"INVALID_INDEX"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return `json_extract("data", '$.${suffix}')`;
|
|
67
|
+
}
|
|
68
|
+
throw new FiregraphError(
|
|
69
|
+
`IndexSpec field "${path}" is not a known firegraph field. Use a top-level field (aType, aUid, axbType, bType, bUid, createdAt, updatedAt, v) or a dotted data path like 'data.status'.`,
|
|
70
|
+
"INVALID_INDEX"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
function buildIndexDDL(spec, options) {
|
|
74
|
+
const { table, fieldToColumn, leadingColumns = [] } = options;
|
|
75
|
+
if (!spec.fields || spec.fields.length === 0) {
|
|
76
|
+
throw new FiregraphError("IndexSpec.fields must be a non-empty array", "INVALID_INDEX");
|
|
77
|
+
}
|
|
78
|
+
const normalized = normalizeFields(spec.fields);
|
|
79
|
+
const hash = specFingerprint(spec, leadingColumns);
|
|
80
|
+
const indexName = `${table}_idx_${hash}`;
|
|
81
|
+
const cols = [];
|
|
82
|
+
for (const col of leadingColumns) {
|
|
83
|
+
cols.push(quoteIdent(col));
|
|
84
|
+
}
|
|
85
|
+
for (const f of normalized) {
|
|
86
|
+
const expr = compileFieldExpr(f.path, fieldToColumn);
|
|
87
|
+
cols.push(f.desc ? `${expr} DESC` : expr);
|
|
88
|
+
}
|
|
89
|
+
let ddl = `CREATE INDEX IF NOT EXISTS ${quoteIdent(indexName)} ON ${quoteIdent(table)}(${cols.join(", ")})`;
|
|
90
|
+
if (spec.where) {
|
|
91
|
+
ddl += ` WHERE ${spec.where}`;
|
|
92
|
+
}
|
|
93
|
+
return ddl;
|
|
94
|
+
}
|
|
95
|
+
function dedupeIndexSpecs(specs, leadingColumns = []) {
|
|
96
|
+
const seen = /* @__PURE__ */ new Set();
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const spec of specs) {
|
|
99
|
+
const fp = specFingerprint(spec, leadingColumns);
|
|
100
|
+
if (seen.has(fp)) continue;
|
|
101
|
+
seen.add(fp);
|
|
102
|
+
out.push(spec);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/internal/sqlite-data-ops.ts
|
|
108
|
+
var FIRESTORE_TYPE_NAMES = /* @__PURE__ */ new Set([
|
|
109
|
+
"Timestamp",
|
|
110
|
+
"GeoPoint",
|
|
111
|
+
"VectorValue",
|
|
112
|
+
"DocumentReference",
|
|
113
|
+
"FieldValue"
|
|
114
|
+
]);
|
|
115
|
+
function isFirestoreSpecialType(value) {
|
|
116
|
+
const ctorName = value.constructor?.name;
|
|
117
|
+
if (ctorName && FIRESTORE_TYPE_NAMES.has(ctorName)) return ctorName;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
var JSON_PATH_KEY_RE2 = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
121
|
+
function validateJsonPathKey(key, backendLabel) {
|
|
122
|
+
if (key.length === 0) {
|
|
123
|
+
throw new FiregraphError(
|
|
124
|
+
`${backendLabel}: empty JSON path component is not allowed`,
|
|
125
|
+
"INVALID_QUERY"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!JSON_PATH_KEY_RE2.test(key)) {
|
|
129
|
+
throw new FiregraphError(
|
|
130
|
+
`${backendLabel}: data field path component "${key}" is not a safe JSON-path identifier. Allowed pattern: /^[A-Za-z_][A-Za-z0-9_-]*$/. Use replaceNode/replaceEdge (full-data overwrite) for keys with reserved characters (whitespace, dots, brackets, quotes, etc.).`,
|
|
131
|
+
"INVALID_QUERY"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function jsonBind(value, backendLabel) {
|
|
136
|
+
if (value === void 0) return "null";
|
|
137
|
+
if (value !== null && typeof value === "object") {
|
|
138
|
+
const firestoreType = isFirestoreSpecialType(value);
|
|
139
|
+
if (firestoreType) {
|
|
140
|
+
throw new FiregraphError(
|
|
141
|
+
`${backendLabel} cannot persist a Firestore ${firestoreType} value. Convert to a primitive before writing (e.g. \`ts.toMillis()\` for Timestamp).`,
|
|
142
|
+
"INVALID_ARGUMENT"
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return JSON.stringify(value);
|
|
147
|
+
}
|
|
148
|
+
function compileDataOpsExpr(ops, base, params, backendLabel) {
|
|
149
|
+
if (ops.length === 0) return null;
|
|
150
|
+
const deletes = [];
|
|
151
|
+
const sets = [];
|
|
152
|
+
for (const op of ops) (op.delete ? deletes : sets).push(op);
|
|
153
|
+
let expr = base;
|
|
154
|
+
if (deletes.length > 0) {
|
|
155
|
+
const placeholders = deletes.map(() => "?").join(", ");
|
|
156
|
+
expr = `json_remove(${expr}, ${placeholders})`;
|
|
157
|
+
for (const op of deletes) {
|
|
158
|
+
for (const seg of op.path) validateJsonPathKey(seg, backendLabel);
|
|
159
|
+
params.push(`$.${op.path.join(".")}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (sets.length > 0) {
|
|
163
|
+
const pieces = sets.map(() => "?, json(?)").join(", ");
|
|
164
|
+
expr = `json_set(${expr}, ${pieces})`;
|
|
165
|
+
for (const op of sets) {
|
|
166
|
+
for (const seg of op.path) validateJsonPathKey(seg, backendLabel);
|
|
167
|
+
params.push(`$.${op.path.join(".")}`);
|
|
168
|
+
params.push(jsonBind(op.value, backendLabel));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return expr;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/internal/sqlite-payload-guard.ts
|
|
175
|
+
var FIRESTORE_TYPE_NAMES2 = /* @__PURE__ */ new Set([
|
|
176
|
+
"Timestamp",
|
|
177
|
+
"GeoPoint",
|
|
178
|
+
"VectorValue",
|
|
179
|
+
"DocumentReference",
|
|
180
|
+
"FieldValue"
|
|
181
|
+
]);
|
|
182
|
+
function assertJsonSafePayload(data, label) {
|
|
183
|
+
walk(data, [], label);
|
|
184
|
+
}
|
|
185
|
+
function walk(node, path, label) {
|
|
186
|
+
if (node === null || node === void 0) return;
|
|
187
|
+
if (isDeleteSentinel(node)) {
|
|
188
|
+
throw new FiregraphError(
|
|
189
|
+
`${label} backend cannot persist a deleteField() sentinel inside a full-data payload (replaceNode/replaceEdge or first-insert). The sentinel is only valid inside an updateNode/updateEdge dataOps patch. Path: ${formatPath(path)}.`,
|
|
190
|
+
"INVALID_ARGUMENT"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const t = typeof node;
|
|
194
|
+
if (t === "symbol" || t === "function") {
|
|
195
|
+
throw new FiregraphError(
|
|
196
|
+
`${label} backend cannot persist a value of type ${t}. JSON.stringify drops it silently. Path: ${formatPath(path)}.`,
|
|
197
|
+
"INVALID_ARGUMENT"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (t === "bigint") {
|
|
201
|
+
throw new FiregraphError(
|
|
202
|
+
`${label} backend cannot persist a value of type bigint. JSON.stringify cannot serialize this type (throws TypeError). Path: ${formatPath(path)}.`,
|
|
203
|
+
"INVALID_ARGUMENT"
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (t !== "object") return;
|
|
207
|
+
if (Array.isArray(node)) {
|
|
208
|
+
for (let i = 0; i < node.length; i++) {
|
|
209
|
+
walk(node[i], [...path, String(i)], label);
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const obj = node;
|
|
214
|
+
if (Object.prototype.hasOwnProperty.call(obj, SERIALIZATION_TAG)) {
|
|
215
|
+
const tagValue = obj[SERIALIZATION_TAG];
|
|
216
|
+
throw new FiregraphError(
|
|
217
|
+
`${label} backend cannot persist an object with a \`${SERIALIZATION_TAG}\` key (value: ${formatTagValue(tagValue)}). Recognised tags are valid only on the Firestore backend (migration-sandbox output); a literal \`${SERIALIZATION_TAG}\` field in user data is reserved and not allowed. Path: ${formatPath(path)}.`,
|
|
218
|
+
"INVALID_ARGUMENT"
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const proto = Object.getPrototypeOf(node);
|
|
222
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
223
|
+
const ctor = node.constructor;
|
|
224
|
+
const ctorName = ctor && typeof ctor.name === "string" ? ctor.name : "<anonymous>";
|
|
225
|
+
if (FIRESTORE_TYPE_NAMES2.has(ctorName)) {
|
|
226
|
+
throw new FiregraphError(
|
|
227
|
+
`${label} backend cannot persist a Firestore ${ctorName} value. Convert to a primitive before writing (e.g. \`ts.toMillis()\` for Timestamp, \`{lat,lng}\` for GeoPoint). Path: ${formatPath(path)}.`,
|
|
228
|
+
"INVALID_ARGUMENT"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (node instanceof Date) return;
|
|
232
|
+
throw new FiregraphError(
|
|
233
|
+
`${label} backend cannot persist a class instance of type ${ctorName}. Only plain objects, arrays, and primitives round-trip safely through JSON storage. Path: ${formatPath(path)}.`,
|
|
234
|
+
"INVALID_ARGUMENT"
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
for (const key of Object.keys(obj)) {
|
|
238
|
+
walk(obj[key], [...path, key], label);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function formatPath(path) {
|
|
242
|
+
return path.length === 0 ? "<root>" : path.map((p) => JSON.stringify(p)).join(" > ");
|
|
243
|
+
}
|
|
244
|
+
function formatTagValue(value) {
|
|
245
|
+
if (value === null) return "null";
|
|
246
|
+
if (value === void 0) return "undefined";
|
|
247
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
248
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
249
|
+
return String(value);
|
|
250
|
+
}
|
|
251
|
+
return typeof value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/timestamp.ts
|
|
255
|
+
var GraphTimestampImpl = class _GraphTimestampImpl {
|
|
256
|
+
constructor(seconds, nanoseconds) {
|
|
257
|
+
this.seconds = seconds;
|
|
258
|
+
this.nanoseconds = nanoseconds;
|
|
259
|
+
}
|
|
260
|
+
toDate() {
|
|
261
|
+
return new Date(this.toMillis());
|
|
262
|
+
}
|
|
263
|
+
toMillis() {
|
|
264
|
+
return this.seconds * 1e3 + Math.floor(this.nanoseconds / 1e6);
|
|
265
|
+
}
|
|
266
|
+
toJSON() {
|
|
267
|
+
return { seconds: this.seconds, nanoseconds: this.nanoseconds };
|
|
268
|
+
}
|
|
269
|
+
static fromMillis(ms) {
|
|
270
|
+
const seconds = Math.floor(ms / 1e3);
|
|
271
|
+
const nanoseconds = (ms - seconds * 1e3) * 1e6;
|
|
272
|
+
return new _GraphTimestampImpl(seconds, nanoseconds);
|
|
273
|
+
}
|
|
274
|
+
static now() {
|
|
275
|
+
return _GraphTimestampImpl.fromMillis(Date.now());
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export {
|
|
280
|
+
isFirestoreSpecialType,
|
|
281
|
+
validateJsonPathKey,
|
|
282
|
+
compileDataOpsExpr,
|
|
283
|
+
assertJsonSafePayload,
|
|
284
|
+
GraphTimestampImpl,
|
|
285
|
+
buildIndexDDL,
|
|
286
|
+
dedupeIndexSpecs
|
|
287
|
+
};
|
|
288
|
+
//# sourceMappingURL=chunk-4MMQ5W74.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/internal/sqlite-index-ddl.ts","../src/internal/sqlite-data-ops.ts","../src/internal/sqlite-payload-guard.ts","../src/timestamp.ts"],"sourcesContent":["/**\n * Translator from `IndexSpec` to SQLite `CREATE INDEX` DDL.\n *\n * Shared between the DO SQLite backend (`src/cloudflare/schema.ts`) and the\n * legacy single-table SQLite backend (`src/internal/sqlite-schema.ts`). The\n * two backends differ only in:\n *\n * 1. Their field→column mapping (no `scope` column in the DO schema).\n * 2. Whether a fixed `scope` leading column is prepended to every index\n * (legacy backend only — DO rows are scoped by DO-instance identity).\n *\n * Both differences are handled via the `fieldToColumn` and `leadingColumns`\n * options; the rest of the emission logic is identical.\n *\n * ## JSON path expression indexes\n *\n * Data-field specs (`data.foo`, `data.nested.bar`) compile to\n * `json_extract(\"data\", '$.foo')` expression indexes. The JSON path\n * literal is inlined — not parametrized — so the SQLite query planner can\n * match the index against the expression emitted by the query compiler\n * (which also inlines the literal after this PR). Path components are\n * validated against a safe identifier pattern so inlining is not an\n * injection risk.\n *\n * ## Index naming\n *\n * Names are `{table}_idx_{hash}` where `hash` is a short FNV-1a of a\n * canonicalized spec. This keeps names stable across runs (so\n * `CREATE INDEX IF NOT EXISTS` is idempotent) and prevents collisions\n * between similar specs. The hash includes the field list, per-field\n * direction, and the `where` predicate.\n */\n\nimport { FiregraphError } from '../errors.js';\nimport type { IndexFieldSpec, IndexSpec } from '../types.js';\n\n/**\n * Valid SQLite identifier pattern — used for table and column names.\n * Mirrors the validation in `sqlite-schema.ts` / `cloudflare/schema.ts` so\n * this module doesn't need to import one over the other.\n */\nconst IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n/**\n * Safe JSON path component. Must match `JSON_PATH_KEY_RE` in the SQLite\n * query compilers — an index is only useful if the query emits an\n * identical `json_extract` expression.\n */\nconst JSON_PATH_KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;\n\nfunction quoteIdent(name: string): string {\n if (!IDENT_RE.test(name)) {\n throw new FiregraphError(\n `Invalid SQL identifier in index DDL: ${name}. Must match /^[A-Za-z_][A-Za-z0-9_]*$/.`,\n 'INVALID_INDEX',\n );\n }\n return `\"${name}\"`;\n}\n\n/**\n * FNV-1a 32-bit hash, returned as 8-char hex. Non-cryptographic;\n * used only to produce short, stable index names.\n */\nfunction fnv1a32(str: string): string {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return (h >>> 0).toString(16).padStart(8, '0');\n}\n\nfunction normalizeFields(\n fields: Array<string | IndexFieldSpec>,\n): Array<{ path: string; desc: boolean }> {\n return fields.map((f) => {\n if (typeof f === 'string') return { path: f, desc: false };\n if (!f.path || typeof f.path !== 'string') {\n throw new FiregraphError(\n `IndexSpec field must be a string or { path: string, desc?: boolean }; got ${JSON.stringify(f)}`,\n 'INVALID_INDEX',\n );\n }\n return { path: f.path, desc: !!f.desc };\n });\n}\n\nfunction specFingerprint(spec: IndexSpec, leadingColumns: string[]): string {\n // Canonical form: JSON of normalized fields + leading cols + where.\n // Leading columns are part of the fingerprint so the same spec under\n // two different backends gets distinct names (though in practice only\n // one backend compiles a given spec).\n const normalized = {\n lead: leadingColumns,\n fields: normalizeFields(spec.fields),\n where: spec.where ?? '',\n };\n return fnv1a32(JSON.stringify(normalized));\n}\n\n/**\n * Compile one field path to its SQLite column expression.\n *\n * - Firegraph top-level fields (`aType`, `createdAt`, …) → mapped column.\n * - `data.foo` / `data.foo.bar` → `json_extract(\"data\", '$.foo.bar')`.\n * - `data` alone → `json_extract(\"data\", '$')`.\n */\nfunction compileFieldExpr(path: string, fieldToColumn: Record<string, string>): string {\n const col = fieldToColumn[path];\n if (col) return quoteIdent(col);\n\n if (path === 'data') {\n return `json_extract(\"data\", '$')`;\n }\n if (path.startsWith('data.')) {\n const suffix = path.slice(5);\n const parts = suffix.split('.');\n for (const part of parts) {\n if (!JSON_PATH_KEY_RE.test(part)) {\n throw new FiregraphError(\n `IndexSpec data path \"${path}\" has invalid component \"${part}\". ` +\n `Each component must match /^[A-Za-z_][A-Za-z0-9_-]*$/.`,\n 'INVALID_INDEX',\n );\n }\n }\n // Inline the path literal (no parameter). Validated components above\n // are safe to embed — no quote or escape characters.\n return `json_extract(\"data\", '$.${suffix}')`;\n }\n\n throw new FiregraphError(\n `IndexSpec field \"${path}\" is not a known firegraph field. ` +\n `Use a top-level field (aType, aUid, axbType, bType, bUid, createdAt, updatedAt, v) ` +\n `or a dotted data path like 'data.status'.`,\n 'INVALID_INDEX',\n );\n}\n\nexport interface SqliteIndexDDLOptions {\n /** Target table. */\n table: string;\n /** Map from firegraph field name to SQLite column name. */\n fieldToColumn: Record<string, string>;\n /**\n * Columns prepended to every index's field list (leading ASC). Used by\n * the legacy shared-table SQLite backend to lead every index with\n * `scope`, matching the predicate its query compiler emits.\n *\n * Identifier names only — no JSON paths or expressions.\n */\n leadingColumns?: string[];\n}\n\n/**\n * Emit the `CREATE INDEX IF NOT EXISTS` DDL for one `IndexSpec`.\n *\n * Returns a single SQL string. Name is deterministic (same spec → same\n * name across runs), so re-running the bootstrap is idempotent.\n */\nexport function buildIndexDDL(spec: IndexSpec, options: SqliteIndexDDLOptions): string {\n const { table, fieldToColumn, leadingColumns = [] } = options;\n\n if (!spec.fields || spec.fields.length === 0) {\n throw new FiregraphError('IndexSpec.fields must be a non-empty array', 'INVALID_INDEX');\n }\n\n const normalized = normalizeFields(spec.fields);\n const hash = specFingerprint(spec, leadingColumns);\n const indexName = `${table}_idx_${hash}`;\n\n const cols: string[] = [];\n for (const col of leadingColumns) {\n cols.push(quoteIdent(col));\n }\n for (const f of normalized) {\n const expr = compileFieldExpr(f.path, fieldToColumn);\n cols.push(f.desc ? `${expr} DESC` : expr);\n }\n\n let ddl = `CREATE INDEX IF NOT EXISTS ${quoteIdent(indexName)} ON ${quoteIdent(table)}(${cols.join(', ')})`;\n\n if (spec.where) {\n // The predicate is inlined verbatim. It comes from library/app\n // configuration — never from user data — so we don't attempt to\n // parse, rewrite, or validate it. Callers authoring partial indexes\n // are responsible for writing a valid SQLite WHERE clause.\n ddl += ` WHERE ${spec.where}`;\n }\n\n return ddl;\n}\n\n/**\n * Deduplicate index specs by their deterministic fingerprint. Same spec\n * declared twice (e.g., by core preset + registry entry) collapses to a\n * single DDL statement.\n */\nexport function dedupeIndexSpecs(\n specs: ReadonlyArray<IndexSpec>,\n leadingColumns: string[] = [],\n): IndexSpec[] {\n const seen = new Set<string>();\n const out: IndexSpec[] = [];\n for (const spec of specs) {\n const fp = specFingerprint(spec, leadingColumns);\n if (seen.has(fp)) continue;\n seen.add(fp);\n out.push(spec);\n }\n return out;\n}\n","/**\n * Shared `dataOps` SQL compilation helpers used by both SQLite-style backends\n * (`internal/sqlite-sql.ts` for the shared-table backend and `cloudflare/sql.ts`\n * for the per-DO backend).\n *\n * The two backends differ in identifier quoting and scope handling, but the\n * `data` column lives in JSON in both, the deep-merge / replace contract is\n * identical, and the `json_set` / `json_remove` expression they emit for a\n * `DataPathOp[]` is byte-for-byte the same. Lifting the helpers here keeps\n * that shape in one place — the comment in `cloudflare/sql.ts` used to read\n * \"keep them in sync\"; this module is what they keep in sync against.\n *\n * The helpers take a `backendLabel` parameter so error messages still\n * distinguish `\"SQLite backend\"` (shared-table) from `\"DO SQLite backend\"`\n * (per-Durable-Object). Identifier quoting is the caller's job — the helpers\n * here only emit JSON-path expressions against an opaque `base` argument,\n * never bare column names.\n */\n\nimport { FiregraphError } from '../errors.js';\nimport type { DataPathOp } from './write-plan.js';\n\n/**\n * Constructor names of Firestore special types that don't survive a plain\n * `JSON.stringify` round-trip — they have non-enumerable accessors (e.g.\n * `Timestamp.seconds`) or class identity that JSON loses. Detection is by\n * `constructor.name` to keep this module dependency-free (importing\n * `@google-cloud/firestore` here would pollute the Cloudflare Workers bundle —\n * see tests/unit/bundle-pollution.test.ts).\n */\nexport const FIRESTORE_TYPE_NAMES = new Set([\n 'Timestamp',\n 'GeoPoint',\n 'VectorValue',\n 'DocumentReference',\n 'FieldValue',\n]);\n\nexport function isFirestoreSpecialType(value: object): string | null {\n const ctorName = (value as { constructor?: { name?: string } }).constructor?.name;\n if (ctorName && FIRESTORE_TYPE_NAMES.has(ctorName)) return ctorName;\n return null;\n}\n\n/**\n * Identifiers accepted in `data.<key>` paths and `dataOps` path segments.\n * The pattern (`/^[A-Za-z_][A-Za-z0-9_-]*$/`) covers code-style identifiers\n * (camel, snake, kebab). Silently quoting exotic keys would require symmetric\n * quoting at every read/write call site; any drift produces silent data\n * corruption. Failing loudly at compile time is safer — users with exotic\n * keys can use `replaceNode` / `replaceEdge` (full-data overwrite) instead.\n */\nexport const JSON_PATH_KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;\n\nexport function validateJsonPathKey(key: string, backendLabel: string): void {\n if (key.length === 0) {\n throw new FiregraphError(\n `${backendLabel}: empty JSON path component is not allowed`,\n 'INVALID_QUERY',\n );\n }\n if (!JSON_PATH_KEY_RE.test(key)) {\n throw new FiregraphError(\n `${backendLabel}: data field path component \"${key}\" is not a safe JSON-path identifier. ` +\n `Allowed pattern: /^[A-Za-z_][A-Za-z0-9_-]*$/. Use replaceNode/replaceEdge (full-data overwrite) ` +\n `for keys with reserved characters (whitespace, dots, brackets, quotes, etc.).`,\n 'INVALID_QUERY',\n );\n }\n}\n\n/**\n * Bind a value as a JSON-serializable string for `json(?)` placeholders in\n * the compiled `json_set` expression. `assertJsonSafePayload` already runs\n * eagerly at the write boundary, so the Firestore-special-type rejection\n * here is defense-in-depth — left in place per the team's preference for\n * symmetric guards across the SQLite compilers.\n */\nexport function jsonBind(value: unknown, backendLabel: string): string {\n if (value === undefined) return 'null';\n if (value !== null && typeof value === 'object') {\n const firestoreType = isFirestoreSpecialType(value);\n if (firestoreType) {\n throw new FiregraphError(\n `${backendLabel} cannot persist a Firestore ${firestoreType} value. ` +\n `Convert to a primitive before writing (e.g. \\`ts.toMillis()\\` for Timestamp).`,\n 'INVALID_ARGUMENT',\n );\n }\n }\n return JSON.stringify(value);\n}\n\n/**\n * Build the SQL expression that applies a list of `DataPathOp`s onto an\n * existing JSON column reference (e.g. `\"data\"` or `COALESCE(\"data\", '{}')`).\n *\n * Returns the full expression (already parenthesised where needed) and pushes\n * the bound parameters onto `params` in left-to-right order. Returns `null`\n * when there are no ops at all — the caller picks a fallback expression.\n *\n * Strategy:\n * 1. `json_remove(<base>, '$.a.b', '$.c', …)` strips delete-ops.\n * 2. `json_set(<#1>, '$.x.y', json(?), '$.z', json(?), …)` writes value-ops.\n * `json(?)` ensures non-string values bind as JSON (objects, arrays,\n * numbers, booleans, null).\n */\nexport function compileDataOpsExpr(\n ops: readonly DataPathOp[],\n base: string,\n params: unknown[],\n backendLabel: string,\n): string | null {\n if (ops.length === 0) return null;\n\n const deletes: DataPathOp[] = [];\n const sets: DataPathOp[] = [];\n for (const op of ops) (op.delete ? deletes : sets).push(op);\n\n let expr = base;\n\n if (deletes.length > 0) {\n const placeholders = deletes.map(() => '?').join(', ');\n expr = `json_remove(${expr}, ${placeholders})`;\n for (const op of deletes) {\n for (const seg of op.path) validateJsonPathKey(seg, backendLabel);\n params.push(`$.${op.path.join('.')}`);\n }\n }\n\n if (sets.length > 0) {\n const pieces = sets.map(() => '?, json(?)').join(', ');\n expr = `json_set(${expr}, ${pieces})`;\n for (const op of sets) {\n for (const seg of op.path) validateJsonPathKey(seg, backendLabel);\n params.push(`$.${op.path.join('.')}`);\n params.push(jsonBind(op.value, backendLabel));\n }\n }\n\n return expr;\n}\n","/**\n * Shared eager-validation helper for SQLite-style backends\n * (`internal/sqlite-sql.ts` and `cloudflare/sql.ts`).\n *\n * Both backends serialise `record.data` (and `update.replaceData`) as a raw\n * JSON blob via `JSON.stringify`. Two classes of value silently corrupt that\n * representation and the cross-backend contract:\n *\n * 1. **Firestore special types** (`Timestamp`, `GeoPoint`, `VectorValue`,\n * `DocumentReference`, `FieldValue`). They have non-enumerable accessors\n * or rely on class identity that JSON drops, so they round-trip as `{}`\n * or garbage. Callers must convert to primitives before writing.\n * 2. **`DELETE_FIELD` sentinel.** A `Symbol` is invisible to\n * `JSON.stringify`. If a caller embeds the sentinel in a `replaceNode`\n * payload or in a fresh-insert (no existing row), the field would\n * silently disappear instead of erroring loudly the way it does for the\n * `dataOps` path — so we reject it eagerly here.\n * 3. **Tagged serialization payloads** (`__firegraph_ser__`). These are the\n * sandbox migration boundary marshalling form. They are valid inside\n * Firestore (the Firestore backend re-hydrates them via\n * `deserializeFirestoreTypes`), but on SQLite they would persist as\n * opaque tagged objects that no downstream reader knows how to interpret.\n * Reject them at the boundary so the failure is loud.\n *\n * The Firestore backend does NOT call this — it accepts those types natively\n * and `deserializeFirestoreTypes` rebuilds tagged values into real Firestore\n * objects on its own write path.\n *\n * Detection avoids `instanceof` so this module stays free of\n * `@google-cloud/firestore`. Constructor-name + duck-type matches the\n * approach used by `bindValue`/`jsonBind` elsewhere in the SQLite compilers.\n */\n\nimport { FiregraphError } from '../errors.js';\nimport { SERIALIZATION_TAG } from './serialization-tag.js';\nimport { isDeleteSentinel } from './write-plan.js';\n\nconst FIRESTORE_TYPE_NAMES = new Set([\n 'Timestamp',\n 'GeoPoint',\n 'VectorValue',\n 'DocumentReference',\n 'FieldValue',\n]);\n\n/**\n * Walk `data` and throw on any value that the SQLite-style raw-JSON\n * persistence path can't faithfully serialise. `label` distinguishes the\n * caller in error messages (e.g. `'shared-table SQLite'` vs `'DO SQLite'`).\n *\n * Plain objects recurse. Arrays recurse element-wise. Primitives, `null`,\n * and `undefined` are accepted (mirroring how `flattenPatch` treats them\n * during the merge path).\n */\nexport function assertJsonSafePayload(data: unknown, label: string): void {\n walk(data, [], label);\n}\n\nfunction walk(node: unknown, path: readonly string[], label: string): void {\n if (node === null || node === undefined) return;\n if (isDeleteSentinel(node)) {\n throw new FiregraphError(\n `${label} backend cannot persist a deleteField() sentinel inside a ` +\n `full-data payload (replaceNode/replaceEdge or first-insert). The ` +\n `sentinel is only valid inside an updateNode/updateEdge dataOps patch. ` +\n `Path: ${formatPath(path)}.`,\n 'INVALID_ARGUMENT',\n );\n }\n const t = typeof node;\n if (t === 'symbol' || t === 'function') {\n throw new FiregraphError(\n `${label} backend cannot persist a value of type ${t}. ` +\n `JSON.stringify drops it silently. Path: ${formatPath(path)}.`,\n 'INVALID_ARGUMENT',\n );\n }\n if (t === 'bigint') {\n throw new FiregraphError(\n `${label} backend cannot persist a value of type bigint. ` +\n `JSON.stringify cannot serialize this type (throws TypeError). ` +\n `Path: ${formatPath(path)}.`,\n 'INVALID_ARGUMENT',\n );\n }\n if (t !== 'object') return;\n if (Array.isArray(node)) {\n for (let i = 0; i < node.length; i++) {\n walk(node[i], [...path, String(i)], label);\n }\n return;\n }\n // Reject any object carrying the firegraph serialization tag — both legit\n // tagged Firestore-type payloads (the migration-sandbox output that round-\n // trips through Firestore) and bogus user data that happens to put a\n // literal `__firegraph_ser__` key on a plain object. SQLite has no\n // Timestamp class to rebuild the tag into, and silently writing the\n // envelope would produce an unreadable column.\n const obj = node as Record<string, unknown>;\n if (Object.prototype.hasOwnProperty.call(obj, SERIALIZATION_TAG)) {\n const tagValue = obj[SERIALIZATION_TAG];\n throw new FiregraphError(\n `${label} backend cannot persist an object with a \\`${SERIALIZATION_TAG}\\` ` +\n `key (value: ${formatTagValue(tagValue)}). Recognised tags are valid only on ` +\n `the Firestore backend (migration-sandbox output); a literal ` +\n `\\`${SERIALIZATION_TAG}\\` field in user data is reserved and not allowed. ` +\n `Path: ${formatPath(path)}.`,\n 'INVALID_ARGUMENT',\n );\n }\n // Class instances: reject Firestore special types loudly; reject every\n // other class instance generically (Map, Set, Date are caller's\n // responsibility to convert — Date is allowed in filter binds via\n // `bindValue` but not as a stored payload value because JSON.stringify\n // produces a string, not a real Date).\n const proto = Object.getPrototypeOf(node);\n if (proto !== null && proto !== Object.prototype) {\n const ctor = (node as { constructor?: { name?: string } }).constructor;\n const ctorName = ctor && typeof ctor.name === 'string' ? ctor.name : '<anonymous>';\n if (FIRESTORE_TYPE_NAMES.has(ctorName)) {\n throw new FiregraphError(\n `${label} backend cannot persist a Firestore ${ctorName} value. ` +\n `Convert to a primitive before writing (e.g. \\`ts.toMillis()\\` for ` +\n `Timestamp, \\`{lat,lng}\\` for GeoPoint). Path: ${formatPath(path)}.`,\n 'INVALID_ARGUMENT',\n );\n }\n // Accept Date as an alias for its epoch-ms — it round-trips as an ISO\n // string via JSON.stringify, which the caller chose; not our place to\n // reject. Same for Buffer / typed arrays — they'll JSON-serialize as\n // best they can. Reject only opaque exotic instances that JSON drops.\n if (node instanceof Date) return;\n throw new FiregraphError(\n `${label} backend cannot persist a class instance of type ${ctorName}. ` +\n `Only plain objects, arrays, and primitives round-trip safely through ` +\n `JSON storage. Path: ${formatPath(path)}.`,\n 'INVALID_ARGUMENT',\n );\n }\n for (const key of Object.keys(obj)) {\n walk(obj[key], [...path, key], label);\n }\n}\n\nfunction formatPath(path: readonly string[]): string {\n return path.length === 0 ? '<root>' : path.map((p) => JSON.stringify(p)).join(' > ');\n}\n\nfunction formatTagValue(value: unknown): string {\n if (value === null) return 'null';\n if (value === undefined) return 'undefined';\n if (typeof value === 'string') return JSON.stringify(value);\n if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {\n return String(value);\n }\n return typeof value;\n}\n","/**\n * Backend-agnostic timestamp.\n *\n * Structurally compatible with `@google-cloud/firestore`'s `Timestamp` so\n * that records returned by either the Firestore or SQLite backend can be\n * consumed through the same `StoredGraphRecord` shape.\n *\n * Firestore's native `Timestamp` already satisfies this interface, so\n * existing Firestore consumers see no behavior change. The SQLite backend\n * returns instances of `GraphTimestampImpl` which also satisfies it.\n */\n\nexport interface GraphTimestamp {\n readonly seconds: number;\n readonly nanoseconds: number;\n toDate(): Date;\n toMillis(): number;\n}\n\n/**\n * Concrete `GraphTimestamp` implementation used by non-Firestore backends.\n * Mirrors the surface of Firestore's `Timestamp` enough for typical use.\n */\nexport class GraphTimestampImpl implements GraphTimestamp {\n constructor(\n public readonly seconds: number,\n public readonly nanoseconds: number,\n ) {}\n\n toDate(): Date {\n return new Date(this.toMillis());\n }\n\n toMillis(): number {\n return this.seconds * 1000 + Math.floor(this.nanoseconds / 1e6);\n }\n\n toJSON(): { seconds: number; nanoseconds: number } {\n return { seconds: this.seconds, nanoseconds: this.nanoseconds };\n }\n\n static fromMillis(ms: number): GraphTimestampImpl {\n const seconds = Math.floor(ms / 1000);\n const nanoseconds = (ms - seconds * 1000) * 1e6;\n return new GraphTimestampImpl(seconds, nanoseconds);\n }\n\n static now(): GraphTimestampImpl {\n return GraphTimestampImpl.fromMillis(Date.now());\n }\n}\n\n/**\n * Sentinel returned by `StorageBackend.serverTimestamp()` when the backend\n * has no native server-time concept and just wants a placeholder that the\n * adapter resolves to a concrete time at write commit. SQLite backends\n * substitute the wall-clock millis at the moment of `setDoc`/`updateDoc`.\n */\nexport const SERVER_TIMESTAMP_SENTINEL = Symbol.for('firegraph.serverTimestamp');\nexport type ServerTimestampSentinel = typeof SERVER_TIMESTAMP_SENTINEL;\n\nexport function isServerTimestampSentinel(value: unknown): value is ServerTimestampSentinel {\n return value === SERVER_TIMESTAMP_SENTINEL;\n}\n"],"mappings":"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","names":["JSON_PATH_KEY_RE","FIRESTORE_TYPE_NAMES"]}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SERIALIZATION_TAG,
|
|
3
|
+
isTaggedValue
|
|
4
|
+
} from "./chunk-EQJUUVFG.js";
|
|
5
|
+
|
|
1
6
|
// src/serialization.ts
|
|
2
7
|
import { FieldValue, GeoPoint, Timestamp } from "@google-cloud/firestore";
|
|
3
|
-
var SERIALIZATION_TAG = "__firegraph_ser__";
|
|
4
|
-
var KNOWN_TYPES = /* @__PURE__ */ new Set(["Timestamp", "GeoPoint", "VectorValue", "DocumentReference"]);
|
|
5
8
|
var _docRefWarned = false;
|
|
6
|
-
function isTaggedValue(value) {
|
|
7
|
-
if (value === null || typeof value !== "object") return false;
|
|
8
|
-
const tag = value[SERIALIZATION_TAG];
|
|
9
|
-
return typeof tag === "string" && KNOWN_TYPES.has(tag);
|
|
10
|
-
}
|
|
11
9
|
function isTimestamp(value) {
|
|
12
10
|
return value instanceof Timestamp;
|
|
13
11
|
}
|
|
@@ -110,9 +108,7 @@ function deserializeValue(value, db) {
|
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
export {
|
|
113
|
-
SERIALIZATION_TAG,
|
|
114
|
-
isTaggedValue,
|
|
115
111
|
serializeFirestoreTypes,
|
|
116
112
|
deserializeFirestoreTypes
|
|
117
113
|
};
|
|
118
|
-
//# sourceMappingURL=chunk-
|
|
114
|
+
//# sourceMappingURL=chunk-C2QMD7RY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/serialization.ts"],"sourcesContent":["/**\n * Firestore-aware serialization for the sandbox migration pipeline.\n *\n * Firestore documents can contain special types (Timestamp, GeoPoint,\n * VectorValue, DocumentReference) that don't survive plain JSON\n * round-tripping. This module provides tagged serialization: Firestore\n * types are wrapped in tagged plain objects before JSON marshaling and\n * reconstructed after.\n *\n * Only used by the `defaultExecutor` sandbox path. Static migrations\n * (in-memory functions) receive raw Firestore objects directly.\n */\n\nimport type { DocumentReference, Firestore } from '@google-cloud/firestore';\nimport { FieldValue, GeoPoint, Timestamp } from '@google-cloud/firestore';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n// SERIALIZATION_TAG and isTaggedValue live in `internal/serialization-tag.ts`\n// so Workers-facing code (e.g. `src/internal/write-plan.ts` and the\n// `firegraph/cloudflare` bundle) can recognise tagged values without\n// pulling in `@google-cloud/firestore`. Re-exported here so callers that\n// already import from `src/serialization.ts` keep working.\nexport { isTaggedValue, SERIALIZATION_TAG } from './internal/serialization-tag.js';\nimport { isTaggedValue, SERIALIZATION_TAG } from './internal/serialization-tag.js';\n\n// One-time warning for DocumentReference deserialization without db\nlet _docRefWarned = false;\n\n// ---------------------------------------------------------------------------\n// Detection helpers\n// ---------------------------------------------------------------------------\n\nfunction isTimestamp(value: unknown): value is Timestamp {\n return value instanceof Timestamp;\n}\n\nfunction isGeoPoint(value: unknown): value is GeoPoint {\n return value instanceof GeoPoint;\n}\n\nfunction isDocumentReference(value: unknown): value is DocumentReference {\n // Duck-type check: DocumentReference has path (string) and firestore properties\n if (value === null || typeof value !== 'object') return false;\n const v = value as Record<string, unknown>;\n return (\n typeof v.path === 'string' &&\n v.firestore !== undefined &&\n typeof v.id === 'string' &&\n v.constructor?.name === 'DocumentReference'\n );\n}\n\nfunction isVectorValue(value: unknown): boolean {\n if (value === null || typeof value !== 'object') return false;\n const v = value as Record<string, unknown>;\n return (\n v.constructor?.name === 'VectorValue' && Array.isArray((v as Record<string, unknown>)._values)\n );\n}\n\n// ---------------------------------------------------------------------------\n// Serialize\n// ---------------------------------------------------------------------------\n\n/**\n * Recursively walk a data object and replace Firestore types with tagged\n * plain objects suitable for JSON serialization.\n *\n * Returns a new object tree — the input is never mutated.\n */\nexport function serializeFirestoreTypes(data: Record<string, unknown>): Record<string, unknown> {\n return serializeValue(data) as Record<string, unknown>;\n}\n\nfunction serializeValue(value: unknown): unknown {\n // Primitives\n if (value === null || value === undefined) return value;\n if (typeof value !== 'object') return value;\n\n // Firestore types (check before generic object/array)\n if (isTimestamp(value)) {\n return {\n [SERIALIZATION_TAG]: 'Timestamp',\n seconds: value.seconds,\n nanoseconds: value.nanoseconds,\n };\n }\n if (isGeoPoint(value)) {\n return {\n [SERIALIZATION_TAG]: 'GeoPoint',\n latitude: value.latitude,\n longitude: value.longitude,\n };\n }\n if (isDocumentReference(value)) {\n return { [SERIALIZATION_TAG]: 'DocumentReference', path: (value as DocumentReference).path };\n }\n if (isVectorValue(value)) {\n // Prefer toArray() (public API) over _values (private internal property)\n const v = value as Record<string, unknown>;\n const values =\n typeof v.toArray === 'function' ? (v.toArray as () => number[])() : (v._values as number[]);\n return { [SERIALIZATION_TAG]: 'VectorValue', values: [...values] };\n }\n\n // Arrays\n if (Array.isArray(value)) {\n return value.map(serializeValue);\n }\n\n // Plain objects — recurse\n const result: Record<string, unknown> = {};\n for (const key of Object.keys(value as Record<string, unknown>)) {\n result[key] = serializeValue((value as Record<string, unknown>)[key]);\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Deserialize\n// ---------------------------------------------------------------------------\n\n/**\n * Recursively walk a data object and reconstruct Firestore types from\n * tagged plain objects.\n *\n * @param data - The data to deserialize (typically from JSON.parse)\n * @param db - Optional Firestore instance for DocumentReference reconstruction.\n * If not provided, tagged DocumentReferences are left as-is with a one-time warning.\n *\n * Returns a new object tree — the input is never mutated.\n */\nexport function deserializeFirestoreTypes(\n data: Record<string, unknown>,\n db?: Firestore,\n): Record<string, unknown> {\n return deserializeValue(data, db) as Record<string, unknown>;\n}\n\nfunction deserializeValue(value: unknown, db?: Firestore): unknown {\n if (value === null || value === undefined) return value;\n if (typeof value !== 'object') return value;\n\n // Short-circuit for values that are already real Firestore types.\n // This makes deserializeFirestoreTypes idempotent — safe to call on data\n // that has already been deserialized (e.g., write-back after defaultExecutor\n // already reconstructed types, or static migrations that return raw types).\n if (\n isTimestamp(value) ||\n isGeoPoint(value) ||\n isDocumentReference(value) ||\n isVectorValue(value)\n ) {\n return value;\n }\n\n // Arrays\n if (Array.isArray(value)) {\n return value.map((v) => deserializeValue(v, db));\n }\n\n const obj = value as Record<string, unknown>;\n\n // Check for tagged Firestore type\n if (isTaggedValue(obj)) {\n const tag = obj[SERIALIZATION_TAG] as string;\n\n switch (tag) {\n case 'Timestamp':\n // Validate expected fields before reconstruction\n if (typeof obj.seconds !== 'number' || typeof obj.nanoseconds !== 'number') return obj;\n return new Timestamp(obj.seconds, obj.nanoseconds);\n\n case 'GeoPoint':\n if (typeof obj.latitude !== 'number' || typeof obj.longitude !== 'number') return obj;\n return new GeoPoint(obj.latitude, obj.longitude);\n\n case 'VectorValue':\n if (!Array.isArray(obj.values)) return obj;\n return FieldValue.vector(obj.values as number[]);\n\n case 'DocumentReference':\n if (typeof obj.path !== 'string') return obj;\n if (db) {\n return db.doc(obj.path);\n }\n // No db available — leave as tagged object with one-time warning\n if (!_docRefWarned) {\n _docRefWarned = true;\n console.warn(\n '[firegraph] DocumentReference encountered during migration deserialization ' +\n 'but no Firestore instance available. The reference will remain as a tagged ' +\n 'object with its path. Enable write-back for full reconstruction.',\n );\n }\n return obj;\n\n default:\n // Unknown tag — leave as-is (forward compatibility)\n return obj;\n }\n }\n\n // Plain object — recurse\n const result: Record<string, unknown> = {};\n for (const key of Object.keys(obj)) {\n result[key] = deserializeValue(obj[key], db);\n }\n return result;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/internal/firestore-traverse-compiler.ts
|
|
2
|
+
var MAX_PIPELINE_DEPTH = 5;
|
|
3
|
+
function compileEngineTraversal(params, opts) {
|
|
4
|
+
const maxDepth = opts?.maxDepth ?? MAX_PIPELINE_DEPTH;
|
|
5
|
+
const maxReads = opts?.maxReads ?? params.maxReads;
|
|
6
|
+
if (!Array.isArray(params.hops) || params.hops.length === 0) {
|
|
7
|
+
return { eligible: false, reason: "engine traversal requires at least one hop" };
|
|
8
|
+
}
|
|
9
|
+
if (params.hops.length > maxDepth) {
|
|
10
|
+
return {
|
|
11
|
+
eligible: false,
|
|
12
|
+
reason: `engine traversal depth ${params.hops.length} exceeds MAX_PIPELINE_DEPTH (${maxDepth})`
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (!Array.isArray(params.sources)) {
|
|
16
|
+
return { eligible: false, reason: "engine traversal requires a sources array" };
|
|
17
|
+
}
|
|
18
|
+
const normalizedHops = [];
|
|
19
|
+
for (let i = 0; i < params.hops.length; i++) {
|
|
20
|
+
const hop = params.hops[i];
|
|
21
|
+
if (!hop.axbType || hop.axbType.length === 0) {
|
|
22
|
+
return {
|
|
23
|
+
eligible: false,
|
|
24
|
+
reason: `engine traversal hop ${i} is missing axbType`
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (typeof hop.limitPerSource !== "number" || hop.limitPerSource <= 0 || !Number.isFinite(hop.limitPerSource)) {
|
|
28
|
+
return {
|
|
29
|
+
eligible: false,
|
|
30
|
+
reason: `engine traversal hop ${i} (${hop.axbType}) requires a positive limitPerSource`
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
normalizedHops.push({
|
|
34
|
+
...hop,
|
|
35
|
+
axbType: hop.axbType,
|
|
36
|
+
direction: hop.direction ?? "forward",
|
|
37
|
+
limitPerSource: hop.limitPerSource
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
let estimatedReads = Math.max(1, params.sources.length);
|
|
41
|
+
for (const hop of normalizedHops) {
|
|
42
|
+
estimatedReads *= hop.limitPerSource;
|
|
43
|
+
if (estimatedReads > Number.MAX_SAFE_INTEGER) {
|
|
44
|
+
estimatedReads = Number.MAX_SAFE_INTEGER;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (maxReads !== void 0 && estimatedReads > maxReads) {
|
|
49
|
+
return {
|
|
50
|
+
eligible: false,
|
|
51
|
+
reason: `engine traversal worst-case response size ${estimatedReads} exceeds maxReads budget ${maxReads}`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
eligible: true,
|
|
56
|
+
normalized: {
|
|
57
|
+
sources: params.sources,
|
|
58
|
+
hops: normalizedHops,
|
|
59
|
+
estimatedReads
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
compileEngineTraversal
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=chunk-D4J7Z4FE.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/internal/firestore-traverse-compiler.ts"],"sourcesContent":["/**\n * Pure compiler for engine-level multi-hop traversal.\n *\n * Takes an `EngineTraversalParams` spec and decides whether it can be\n * compiled into one nested-Pipeline round trip. Returns a discriminated\n * union — `{ eligible: true; normalized }` carries the validated spec\n * with defaults filled in; `{ eligible: false; reason }` carries a\n * human-readable explanation that the caller (traversal layer or\n * `engineTraversal: 'force'` test path) can either log, throw, or\n * silently fall back on.\n *\n * The compiler is split out so the validation surface is unit-testable\n * without spinning up a Firestore SDK or a real Pipeline. The actual\n * pipeline construction and result decoding live in\n * `firestore-traverse.ts` and depend on `@google-cloud/firestore`.\n *\n * Eligibility checks (in order):\n *\n * 1. `hops.length` ≥ 1 → otherwise no traversal to run\n * 2. `hops.length` ≤ `maxDepth` (default 5) → pipeline-depth cap\n * 3. Every hop has `limitPerSource` set → required to bound response size\n * 4. Every hop's `axbType` is non-empty → query needs a relation predicate\n * 5. Worst-case response size ≤ `maxReads` budget → prevent runaway tree responses\n *\n * The maxDepth bound is conservative — Firestore Pipelines don't\n * publish a hard limit on `addFields` / `define` nesting depth, but\n * empirically deep nesting starts to slow down planning. Five hops\n * covers the vast majority of real-world traversal specs; specs that\n * exceed it fall back to the per-hop loop with a debug-level signal.\n *\n * The response-size estimate is the conservative top-line:\n * `sources.length × Π(limitPerSource_i)`. This is the worst-case edge\n * count at the deepest hop, which dominates the total tree size for\n * branching factors > 1. We deliberately don't sum over hops — the\n * deepest-hop bound already triggers fallback well before any realistic\n * total response size matters.\n */\n\nimport type { EngineHopSpec, EngineTraversalParams } from '../types.js';\n\n/**\n * Default cap on `addFields` / `define` nesting depth. Traversal specs\n * deeper than this are rejected by the compiler and fall back to the\n * per-hop loop. Configurable per call via `compileEngineTraversal`'s\n * `opts.maxDepth`.\n */\nexport const MAX_PIPELINE_DEPTH = 5;\n\n/**\n * A normalized, validated engine-traversal spec ready for the executor\n * to translate into a nested Pipeline. Mirrors `EngineTraversalParams`\n * but with `direction` defaulted to `'forward'` on every hop and the\n * estimated worst-case response size attached for budget bookkeeping.\n */\nexport interface NormalizedEngineTraversal {\n sources: string[];\n hops: Array<\n Required<Pick<EngineHopSpec, 'axbType' | 'limitPerSource' | 'direction'>> & EngineHopSpec\n >;\n /** Worst-case edge count at the deepest hop — `sources.length × Π(limitPerSource_i)`. */\n estimatedReads: number;\n}\n\nexport type CompilerResult =\n | { eligible: true; normalized: NormalizedEngineTraversal }\n | { eligible: false; reason: string };\n\nexport interface CompilerOptions {\n /** Override the depth cap. Default `MAX_PIPELINE_DEPTH` (5). */\n maxDepth?: number;\n /**\n * Worst-case response-size budget. The compiler refuses to emit when\n * `sources.length × Π(limitPerSource_i)` exceeds this.\n */\n maxReads?: number;\n}\n\n/**\n * Validate an engine-traversal spec. Pure; no SDK interaction.\n *\n * Returns `{ eligible: true; normalized }` with `direction` defaulted\n * and `estimatedReads` attached, or `{ eligible: false; reason }` with\n * a one-line description suitable for logging or for an\n * `UNSUPPORTED_OPERATION` error message.\n */\nexport function compileEngineTraversal(\n params: EngineTraversalParams,\n opts?: CompilerOptions,\n): CompilerResult {\n const maxDepth = opts?.maxDepth ?? MAX_PIPELINE_DEPTH;\n const maxReads = opts?.maxReads ?? params.maxReads;\n\n if (!Array.isArray(params.hops) || params.hops.length === 0) {\n return { eligible: false, reason: 'engine traversal requires at least one hop' };\n }\n if (params.hops.length > maxDepth) {\n return {\n eligible: false,\n reason: `engine traversal depth ${params.hops.length} exceeds MAX_PIPELINE_DEPTH (${maxDepth})`,\n };\n }\n if (!Array.isArray(params.sources)) {\n return { eligible: false, reason: 'engine traversal requires a sources array' };\n }\n\n const normalizedHops: NormalizedEngineTraversal['hops'] = [];\n for (let i = 0; i < params.hops.length; i++) {\n const hop = params.hops[i];\n if (!hop.axbType || hop.axbType.length === 0) {\n return {\n eligible: false,\n reason: `engine traversal hop ${i} is missing axbType`,\n };\n }\n if (\n typeof hop.limitPerSource !== 'number' ||\n hop.limitPerSource <= 0 ||\n !Number.isFinite(hop.limitPerSource)\n ) {\n return {\n eligible: false,\n reason: `engine traversal hop ${i} (${hop.axbType}) requires a positive limitPerSource`,\n };\n }\n normalizedHops.push({\n ...hop,\n axbType: hop.axbType,\n direction: hop.direction ?? 'forward',\n limitPerSource: hop.limitPerSource,\n });\n }\n\n // Worst-case at deepest hop. We multiply iteratively so that an\n // overflowing product short-circuits before bumping into JS's float\n // precision. `Number.MAX_SAFE_INTEGER` is well past any reasonable\n // `maxReads` value, so that's the early-exit threshold even when the\n // caller didn't supply a budget.\n let estimatedReads = Math.max(1, params.sources.length);\n for (const hop of normalizedHops) {\n estimatedReads *= hop.limitPerSource;\n if (estimatedReads > Number.MAX_SAFE_INTEGER) {\n estimatedReads = Number.MAX_SAFE_INTEGER;\n break;\n }\n }\n\n if (maxReads !== undefined && estimatedReads > maxReads) {\n return {\n eligible: false,\n reason: `engine traversal worst-case response size ${estimatedReads} exceeds maxReads budget ${maxReads}`,\n };\n }\n\n return {\n eligible: true,\n normalized: {\n sources: params.sources,\n hops: normalizedHops,\n estimatedReads,\n },\n };\n}\n"],"mappings":";AA8CO,IAAM,qBAAqB;AAuC3B,SAAS,uBACd,QACA,MACgB;AAChB,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,WAAW,MAAM,YAAY,OAAO;AAE1C,MAAI,CAAC,MAAM,QAAQ,OAAO,IAAI,KAAK,OAAO,KAAK,WAAW,GAAG;AAC3D,WAAO,EAAE,UAAU,OAAO,QAAQ,6CAA6C;AAAA,EACjF;AACA,MAAI,OAAO,KAAK,SAAS,UAAU;AACjC,WAAO;AAAA,MACL,UAAU;AAAA,MACV,QAAQ,0BAA0B,OAAO,KAAK,MAAM,gCAAgC,QAAQ;AAAA,IAC9F;AAAA,EACF;AACA,MAAI,CAAC,MAAM,QAAQ,OAAO,OAAO,GAAG;AAClC,WAAO,EAAE,UAAU,OAAO,QAAQ,4CAA4C;AAAA,EAChF;AAEA,QAAM,iBAAoD,CAAC;AAC3D,WAAS,IAAI,GAAG,IAAI,OAAO,KAAK,QAAQ,KAAK;AAC3C,UAAM,MAAM,OAAO,KAAK,CAAC;AACzB,QAAI,CAAC,IAAI,WAAW,IAAI,QAAQ,WAAW,GAAG;AAC5C,aAAO;AAAA,QACL,UAAU;AAAA,QACV,QAAQ,wBAAwB,CAAC;AAAA,MACnC;AAAA,IACF;AACA,QACE,OAAO,IAAI,mBAAmB,YAC9B,IAAI,kBAAkB,KACtB,CAAC,OAAO,SAAS,IAAI,cAAc,GACnC;AACA,aAAO;AAAA,QACL,UAAU;AAAA,QACV,QAAQ,wBAAwB,CAAC,KAAK,IAAI,OAAO;AAAA,MACnD;AAAA,IACF;AACA,mBAAe,KAAK;AAAA,MAClB,GAAG;AAAA,MACH,SAAS,IAAI;AAAA,MACb,WAAW,IAAI,aAAa;AAAA,MAC5B,gBAAgB,IAAI;AAAA,IACtB,CAAC;AAAA,EACH;AAOA,MAAI,iBAAiB,KAAK,IAAI,GAAG,OAAO,QAAQ,MAAM;AACtD,aAAW,OAAO,gBAAgB;AAChC,sBAAkB,IAAI;AACtB,QAAI,iBAAiB,OAAO,kBAAkB;AAC5C,uBAAiB,OAAO;AACxB;AAAA,IACF;AAAA,EACF;AAEA,MAAI,aAAa,UAAa,iBAAiB,UAAU;AACvD,WAAO;AAAA,MACL,UAAU;AAAA,MACV,QAAQ,6CAA6C,cAAc,4BAA4B,QAAQ;AAAA,IACzG;AAAA,EACF;AAEA,SAAO;AAAA,IACL,UAAU;AAAA,IACV,YAAY;AAAA,MACV,SAAS,OAAO;AAAA,MAChB,MAAM;AAAA,MACN;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|