@utaba/deep-memory-storage-neo4j 0.19.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.
@@ -0,0 +1,430 @@
1
+ import { EnsureSchemaResult, EntityReadOptions, GraphTraversalCapabilities } from '@utaba/deep-memory/providers';
2
+ import { UsageSink, StorageRepositoryConfig, StoredRepository, RepositoryFilter, PaginatedResult, StoredRepositorySummary, RepositoryUpdate, DeleteProgressCallback, MemoryVocabulary, PaginationOptions, VocabularyChangeRecord, StoredEntity, StoredEntityUpdate, StorageFindQuery, StoredRelationship, RelationshipQueryOptions, TraversalSpec, TraversalResult, StorageExploreOptions, StorageNeighborhood, StoragePathOptions, StoragePathResult, StorageTimelineOptions, StorageTimelineResult, ExportChunk, ImportChunk, BulkImportOptions, BulkImportResult, RepositoryStats } from '@utaba/deep-memory/types';
3
+
4
+ /** Connection configuration. */
5
+ interface Neo4jConnectionConfig {
6
+ /** Bolt URI, e.g. `bolt://localhost:7687` or `neo4j+s://aura-host`. */
7
+ uri: string;
8
+ /** Username for basic auth. */
9
+ username: string;
10
+ /** Password for basic auth. */
11
+ password: string;
12
+ /**
13
+ * Database name. Per the driver manual, this should be specified explicitly
14
+ * even on single-database Community instances. Defaults to `'neo4j'`.
15
+ */
16
+ database?: string;
17
+ /** User-agent string sent on the Bolt handshake. */
18
+ userAgent?: string;
19
+ /** Maximum time (ms) the driver will retry a managed transaction. */
20
+ maxTransactionRetryTime?: number;
21
+ }
22
+
23
+ /** Configuration for `Neo4jStorageProvider`. */
24
+ interface Neo4jStorageProviderConfig extends Neo4jConnectionConfig {
25
+ /**
26
+ * Optional usage sink. When provided, the provider emits one
27
+ * `OperationUsage` record per public method call. The record's `value` is
28
+ * the aggregated `summary.resultConsumedAfter` (server-side ms) across
29
+ * every Bolt round-trip the operation produced; `unit` is `'server_ms'`.
30
+ *
31
+ * The sink is **never** plumbed through to AI-agent-facing surfaces — MCP
32
+ * tools must not expose RU / server-time figures to model responses.
33
+ */
34
+ reportUsage?: UsageSink;
35
+ /**
36
+ * When `true`, prepend `PROFILE` to every compiled traversal query and
37
+ * surface the resulting plan summary under `details.profile` on the sink
38
+ * record. Defaults to `false` — `PROFILE` more than doubles wall-clock on
39
+ * short traversals (see plan §D14 and the Phase 9 probe results), so the
40
+ * cost is worth paying only when an operator is actively investigating
41
+ * planner behaviour.
42
+ */
43
+ profileTraversals?: boolean;
44
+ }
45
+ declare class Neo4jStorageProvider {
46
+ private readonly connection;
47
+ private readonly traversalExecutor;
48
+ private initialized;
49
+ /**
50
+ * In-process vocabulary cache. Reads hit this map first; writes inside this
51
+ * process invalidate the entry so cache hits stay coherent with the local
52
+ * write. Cross-process staleness is bounded by `VOCABULARY_CACHE_TTL_MS`.
53
+ */
54
+ private readonly vocabularyCache;
55
+ /**
56
+ * Captured at construction so `exportAll`'s `trackIterable` can emit
57
+ * outside the Proxy's promise-resolution path. The Proxy itself relies on
58
+ * the same `safeSink` captured by closure; this field exists for the
59
+ * streaming-iterator case only.
60
+ */
61
+ private readonly reportUsage;
62
+ constructor(config: Neo4jStorageProviderConfig);
63
+ initialize(): Promise<void>;
64
+ dispose(): Promise<void>;
65
+ /**
66
+ * Idempotent constraint / index DDL plus a single `_Meta` schema-version
67
+ * handshake. Safe to call repeatedly — `CREATE ... IF NOT EXISTS` makes
68
+ * each statement a no-op on subsequent runs.
69
+ *
70
+ * Neo4j Community has no per-tenant database concept — `databaseCreated`
71
+ * is always `false`. Operators are responsible for the target database
72
+ * existing before the provider connects.
73
+ */
74
+ ensureSchema(): Promise<EnsureSchemaResult>;
75
+ private readSchemaVersion;
76
+ private writeSchemaVersion;
77
+ /**
78
+ * Create a new repository. Fixed-shape `CREATE` template — every optional
79
+ * field is bound on every call so the server plan-caches one entry across
80
+ * all repository creates. Optional fields bound as `null` are not persisted
81
+ * (Cypher drops null property values on write — symmetric with read).
82
+ *
83
+ * The `(:_Repository) REQUIRE n.repositoryId IS UNIQUE` constraint surfaces
84
+ * duplicates as `Neo.ClientError.Schema.ConstraintValidationFailed`, which
85
+ * `mapDriverError({ kind: 'repository', ... })` routes to
86
+ * `DuplicateRepositoryError`.
87
+ */
88
+ createRepository(config: StorageRepositoryConfig): Promise<StoredRepository>;
89
+ getRepository(repositoryId: string): Promise<StoredRepository | null>;
90
+ listRepositories(filter?: RepositoryFilter): Promise<PaginatedResult<StoredRepositorySummary>>;
91
+ /**
92
+ * Variable-shape Cypher (per D23 trade-off — repository writes are rare so
93
+ * the plan-cache cost is negligible). Projection-on-write returns the
94
+ * updated row in one round-trip; empty-rowset → `RepositoryNotFoundError`.
95
+ */
96
+ updateRepository(repositoryId: string, updates: RepositoryUpdate): Promise<StoredRepository>;
97
+ /**
98
+ * Drop every node and relationship scoped to `repositoryId`, including the
99
+ * `_Repository` node itself. Two-stage chunked wipe driven by app-side loops
100
+ * so the progress callback fires at a useful cadence:
101
+ *
102
+ * 1. Drain relationships in batches via `CALL ( ) { ... } IN TRANSACTIONS`.
103
+ * 2. Drain nodes (entities + system) in batches via the same form with
104
+ * `DETACH DELETE` (catches any straggler edges).
105
+ *
106
+ * `IN TRANSACTIONS` can only run on auto-commit sessions — `executeWrite`
107
+ * fails with `Neo.DatabaseError.Transaction.TransactionStartFailed` per
108
+ * probe P13. The chokepoint's `executeImplicitInTransactions` is the only
109
+ * legitimate entry point for this pattern.
110
+ */
111
+ deleteRepository(repositoryId: string, onProgress?: DeleteProgressCallback): Promise<void>;
112
+ /**
113
+ * Drop every entity and relationship scoped to `repositoryId` but preserve
114
+ * the `_Repository` and `_Vocabulary` / `_VocabularyChangeLog` system nodes.
115
+ * Same chunked-wipe contract as `deleteRepository`, restricted to the
116
+ * `:_Entity` umbrella label for nodes.
117
+ */
118
+ deleteAllContents(repositoryId: string, onProgress?: DeleteProgressCallback): Promise<{
119
+ deletedEntities: number;
120
+ deletedRelationships: number;
121
+ }>;
122
+ /**
123
+ * One-shot pre-count used by `deleteRepository` / `deleteAllContents`. The
124
+ * entity count uses the umbrella `:_Entity` label so system nodes
125
+ * (`_Repository` / `_Vocabulary`) are excluded — that matches the
126
+ * user-facing semantics of the progress callback.
127
+ *
128
+ * The relationship count uses the **directed** pattern `()-[r]->()`: every
129
+ * edge is written in its stored direction (D5), so the directed pattern
130
+ * binds each edge to exactly one row. An undirected `-` pattern would
131
+ * double-count every edge whose endpoints are distinct vertices, because
132
+ * Cypher enumerates the pattern from both directions.
133
+ */
134
+ private countRepositoryContents;
135
+ /**
136
+ * Read the vocabulary for a repository. Cache-aware: cache hits return
137
+ * synchronously with zero Bolt round-trips. The TRACKED_METHODS proxy still
138
+ * fires for every call — the sink record on a cache hit carries
139
+ * `details.calls === 0` and `value === 0`, which is the contract the sink
140
+ * expects to express "this operation ran but did no server work".
141
+ */
142
+ getVocabulary(repositoryId: string): Promise<MemoryVocabulary>;
143
+ /**
144
+ * Cached vocabulary read used by `getVocabulary` and (in later phases) by
145
+ * traversal compilation. The vocabulary is compile-time context for the
146
+ * Cypher compiler — it changes on the order of once per session, but the
147
+ * traversal hot path would otherwise pay one round-trip per call. The cache
148
+ * flips that to one round-trip per TTL window.
149
+ *
150
+ * Reads inside an active usage scope still record a round-trip when a fetch
151
+ * actually happens (cache miss); cache hits emit no round-trip and therefore
152
+ * contribute nothing to the scope.
153
+ */
154
+ private getVocabularyCached;
155
+ /** Drop the cache entry for a repository — call after every vocabulary write. */
156
+ private invalidateVocabularyCache;
157
+ /**
158
+ * Upsert the vocabulary for a repository. Invalidates the in-process cache
159
+ * on success so subsequent reads observe the new state immediately within
160
+ * this process (cross-process staleness is bounded by the 60 s TTL).
161
+ */
162
+ saveVocabulary(repositoryId: string, vocabulary: MemoryVocabulary): Promise<void>;
163
+ /**
164
+ * Page the vocabulary change-log newest first. Writes land in
165
+ * `proposeVocabularyExtension` (out of scope here) — this method only reads
166
+ * the `_VocabularyChangeLog` nodes back, ordered by `proposedAt` to match
167
+ * the audit semantic on `VocabularyChangeRecord`.
168
+ */
169
+ getVocabularyChangeLog(repositoryId: string, options?: PaginationOptions): Promise<PaginatedResult<VocabularyChangeRecord>>;
170
+ /**
171
+ * Create a new entity via fixed-shape `CREATE` + catch on the uniqueness
172
+ * constraint. A `MERGE`-with-discriminator alternative is marginally faster
173
+ * on the happy path but mutates the existing node on collisions, writing
174
+ * a discriminator property onto durable graph state the caller never
175
+ * requested — correctness wins over the marginal perf delta.
176
+ */
177
+ createEntity(repositoryId: string, entity: StoredEntity): Promise<StoredEntity>;
178
+ /** Read a single entity by id; `null` when not found. */
179
+ getEntity(repositoryId: string, entityId: string, options?: EntityReadOptions): Promise<StoredEntity | null>;
180
+ /** Read a single entity by slug; `null` when not found. */
181
+ getEntityBySlug(repositoryId: string, slug: string, options?: EntityReadOptions): Promise<StoredEntity | null>;
182
+ /**
183
+ * Batch read by ids. Absent ids do not appear in the returned `Map`; empty
184
+ * input returns an empty map without a round-trip.
185
+ */
186
+ getEntities(repositoryId: string, entityIds: string[], options?: EntityReadOptions): Promise<Map<string, StoredEntity>>;
187
+ /**
188
+ * Variable-shape projection-on-write update (D23) — single round-trip
189
+ * MATCH+SET+RETURN. Empty record array → `EntityNotFoundError`.
190
+ */
191
+ updateEntity(repositoryId: string, entityId: string, updates: StoredEntityUpdate): Promise<StoredEntity>;
192
+ /**
193
+ * Delete a single entity (and its incident relationships via `DETACH
194
+ * DELETE`). Throws `EntityNotFoundError` when the match returns zero rows.
195
+ */
196
+ deleteEntity(repositoryId: string, entityId: string): Promise<void>;
197
+ /**
198
+ * Bulk delete by ids — single round-trip. Returns the ids actually
199
+ * deleted; missing ids land in `notFound`.
200
+ */
201
+ deleteEntities(repositoryId: string, ids: string[]): Promise<{
202
+ deleted: string[];
203
+ notFound: string[];
204
+ }>;
205
+ /**
206
+ * Delete every entity of a type plus their incident relationships, with
207
+ * exact counts (entity + relationship) returned in one round-trip — a
208
+ * strict improvement over Cosmos's `deletedRelationships: undefined` path
209
+ * (Gremlin would fan out across every partition the type touches).
210
+ */
211
+ deleteEntitiesByType(repositoryId: string, entityType: string): Promise<{
212
+ deletedEntities: number;
213
+ deletedRelationships: number | undefined;
214
+ }>;
215
+ /**
216
+ * Page entities matching a `StorageFindQuery`. Parallel data + count Cypher
217
+ * pair; `total` is always exact because every filter (entity-type, property
218
+ * equality, search term, provenance) is server-side via either a typed
219
+ * predicate or the `dm_entity_text` fulltext index. Search-term queries
220
+ * order by Lucene score descending; non-search queries order by `n.id` to
221
+ * pin pagination determinism across slices.
222
+ */
223
+ findEntities(repositoryId: string, query: StorageFindQuery, options?: EntityReadOptions): Promise<PaginatedResult<StoredEntity>>;
224
+ /**
225
+ * Create a relationship. Both endpoint entities are matched under the
226
+ * repository scope before the edge is created, so cross-repository edges
227
+ * are structurally impossible to write (D3b layer 3). A missing endpoint
228
+ * surfaces as `EntityNotFoundError` carrying the absent id.
229
+ */
230
+ createRelationship(repositoryId: string, relationship: StoredRelationship): Promise<StoredRelationship>;
231
+ /** Read a single relationship by id; `null` when not found. */
232
+ getRelationship(repositoryId: string, relationshipId: string): Promise<StoredRelationship | null>;
233
+ /**
234
+ * Page an entity's incident relationships. `direction: 'out' | 'in'`
235
+ * additionally surfaces edges flagged `bidirectional: true` from the
236
+ * opposite endpoint, mirroring the Cosmos read-time duplication of bidir
237
+ * edges. `propertyFilters` is applied client-side and reports
238
+ * `total: undefined` in that branch — same trade-off as the Cosmos
239
+ * provider, because relationship `properties` is a JSON blob with no
240
+ * per-key index.
241
+ */
242
+ getEntityRelationships(repositoryId: string, entityId: string, options?: RelationshipQueryOptions): Promise<PaginatedResult<StoredRelationship>>;
243
+ /** Drop a single relationship by id. No-op when the id does not match. */
244
+ deleteRelationship(repositoryId: string, relationshipId: string): Promise<void>;
245
+ /**
246
+ * Bulk drop by ids — single round-trip. Returns the ids actually deleted;
247
+ * missing ids land in `notFound`.
248
+ */
249
+ deleteRelationships(repositoryId: string, ids: string[]): Promise<{
250
+ deleted: string[];
251
+ notFound: string[];
252
+ }>;
253
+ /**
254
+ * Drop every relationship of a type in the repository. Returns an exact
255
+ * delete count in a single round-trip.
256
+ */
257
+ deleteRelationshipsByType(repositoryId: string, relationshipType: string): Promise<{
258
+ deletedRelationships: number;
259
+ }>;
260
+ /**
261
+ * Capabilities surface used by the dispatcher to decide whether a given
262
+ * `TraversalSpec` shape is supported natively. Strict improvement over the
263
+ * Cosmos provider in two cells: `supportsAggregation` is `true` (Cypher's
264
+ * native aggregation makes `count` / per-key projection a one-statement
265
+ * shape) and the runtime supports every other traversal lever.
266
+ */
267
+ getCapabilities(): GraphTraversalCapabilities;
268
+ /**
269
+ * Execute a `TraversalSpec` against this repository's subgraph. The
270
+ * provider owns Cypher compilation; the spec stays language-agnostic.
271
+ * `track('traverse')` opens the per-operation usage scope so every
272
+ * `executeQuery` round-trip the executor performs aggregates into a single
273
+ * `OperationUsage` record.
274
+ */
275
+ traverse(repositoryId: string, spec: TraversalSpec): Promise<TraversalResult>;
276
+ /**
277
+ * Internal compile → submit → project pipeline. Shared by the public
278
+ * `traverse` method and by the compiler-model rewrites of
279
+ * `exploreNeighborhood` / `findPaths`, which both consume the raw stored
280
+ * shape to rebuild their storage-level outputs.
281
+ */
282
+ private traverseInternal;
283
+ /**
284
+ * Lower-level compile + submit + parse helper. Fetches the cached
285
+ * vocabulary once (D16) and hands it to the executor; the executor handles
286
+ * the repositoryId-scope rewrite, optional PROFILE prefix, and Path-object
287
+ * parsing.
288
+ */
289
+ private executeRawTraversal;
290
+ /**
291
+ * BFS-like neighbourhood exploration. For each depth `d` from 1 to
292
+ * `options.depth`, compile a cumulative `'all'`-mode spec with `d` discrete
293
+ * `'both'`-direction steps, run it through the executor, and walk one BFS
294
+ * layer client-side from the previous frontier using the returned edges.
295
+ *
296
+ * Round-trips per call: `options.depth`. Server-side step direction is
297
+ * fixed to `'both'` (catches every edge in either direction); the
298
+ * directional + bidirectional filter and entity-type filter run client-side
299
+ * during layer reconstruction — both to preserve the observable contract
300
+ * shared with the Cosmos provider and because the compiler's prefix walk at
301
+ * each depth is intentionally unfiltered so deeper layers stay reachable
302
+ * through any intermediate.
303
+ */
304
+ exploreNeighborhood(repositoryId: string, entityId: string, options: StorageExploreOptions): Promise<StorageNeighborhood>;
305
+ /**
306
+ * Path finding between two entities. Single round-trip via a variable-length
307
+ * `MATCH p = (s)-[*1..N]-(t)` pattern; the compiler's path-binding emission
308
+ * lets the executor recover ordered nodes and relationships via `nodes(p)`
309
+ * / `relationships(p)`. The default `DIFFERENT RELATIONSHIPS` match mode in
310
+ * Cypher 25 prevents edge reuse within a single path — no explicit dedup
311
+ * filter is needed.
312
+ *
313
+ * The traversal walks the graph topologically regardless of relationship
314
+ * directionality (a path is defined by reachability, not semantic
315
+ * direction); entity-type and relationship-property filters apply during
316
+ * compilation, target filtering happens post-fetch.
317
+ */
318
+ findPaths(repositoryId: string, sourceId: string, targetId: string, options: StoragePathOptions): Promise<StoragePathResult>;
319
+ /**
320
+ * Reconstruct the timeline event stream for an entity. One server
321
+ * round-trip: the centre entity's provenance scalars plus every incident
322
+ * edge's id + createdAt arrive in a single tuple via `OPTIONAL MATCH +
323
+ * collect()`. The provider walks the row client-side to emit
324
+ * `entity:created` / `entity:updated` / `relationship:created` events.
325
+ *
326
+ * Cosmos pays two round-trips for the same information because Gremlin
327
+ * cannot bind an aggregated edge list to a vertex projection in one shot —
328
+ * a platform divergence, not an inherent trade-off.
329
+ */
330
+ getTimeline(repositoryId: string, entityId: string, options: StorageTimelineOptions): Promise<StorageTimelineResult>;
331
+ /**
332
+ * Stream every entity and every relationship in the repository as
333
+ * cursor-paginated chunks. Entities come first, then relationships; each
334
+ * chunk carries a monotonic `sequence` and an `isLast` flag.
335
+ *
336
+ * The Proxy-based tracking flow does not apply here. Tracked methods emit
337
+ * one sink record at promise resolution; an `AsyncIterable` returns
338
+ * synchronously, before any chunk has streamed. Instead, `trackIterable`
339
+ * wraps the underlying generator in its own `UsageScope` that opens at
340
+ * iterator creation, records each round-trip as the consumer pulls the
341
+ * next chunk, and emits one sink record when the iterator drains — so the
342
+ * resulting record aggregates server time across every chunk fetch.
343
+ */
344
+ exportAll(repositoryId: string): AsyncIterable<ExportChunk>;
345
+ /**
346
+ * Run a bulk import: every entity then every relationship from the input
347
+ * chunks lands in the repository. `skipExistenceCheck: true` uses CREATE
348
+ * for the absolute peak throughput; `false` (default) uses MERGE for
349
+ * idempotent re-imports.
350
+ *
351
+ * Returns a single aggregate `BulkImportResult` spanning every chunk.
352
+ * Per-row failures land in `result.errors`; surviving rows still count
353
+ * toward `entitiesImported` / `relationshipsImported`.
354
+ */
355
+ importBulk(repositoryId: string, data: ImportChunk[], options?: BulkImportOptions): Promise<BulkImportResult>;
356
+ /**
357
+ * Wrap an `AsyncIterable` so every chunk it produces is generated inside a
358
+ * single shared `UsageScope`. The scope aggregates `summary.resultConsumedAfter`
359
+ * across every round-trip the iterator performs; one sink record fires when
360
+ * the iterator drains (or is closed / thrown into).
361
+ *
362
+ * Implementation mirrors the Cosmos `trackIterable` precedent: each
363
+ * `iter.next()` call re-enters the scope via `runInUsageScope`. The
364
+ * `AsyncLocalStorage` chain links async work performed inside the
365
+ * generator body to the scope for the duration of that step. Between
366
+ * `next()` calls the scope is dormant — the consumer's awaits do not
367
+ * accumulate into it, which is exactly the desired semantics.
368
+ *
369
+ * When the sink is absent the wrap degenerates to a pass-through; the
370
+ * Proxy's `createSafeSink` short-circuit covers the no-sink case at
371
+ * construction time, so this method is only reached when `reportUsage` is
372
+ * present.
373
+ */
374
+ private trackIterable;
375
+ /**
376
+ * Aggregate repository statistics — entity / relationship totals, per-type
377
+ * breakdowns, vocabulary version. Two parallel native-aggregation round-
378
+ * trips (`count(n)` per `entityType`, `count(r)` per `type(r)`); the
379
+ * vocabulary version comes from the cached `_Vocabulary` node so a warm
380
+ * cache costs exactly two round-trips total.
381
+ *
382
+ * Strict improvement over Cosmos's Gremlin `.group().by().by(count())`
383
+ * shape — Cypher's native aggregation collapses each metric to a one-
384
+ * statement plan that hits the `(repositoryId, entityType)` and
385
+ * relationship-property indexes directly.
386
+ */
387
+ getRepositoryStats(repositoryId: string): Promise<RepositoryStats>;
388
+ /**
389
+ * Execute a raw Cypher statement with caller-supplied bindings.
390
+ *
391
+ * ⚠️ ELEVATED PRIVILEGE — SYSTEM-LEVEL OPERATION ⚠️
392
+ *
393
+ * This method is an unscoped pass-through: it does not filter by
394
+ * repository, does not inject the `$rid` binding, and performs no
395
+ * validation on the Cypher string. A single call can read or mutate any
396
+ * node or relationship in the database regardless of which repository it
397
+ * belongs to.
398
+ *
399
+ * DO NOT expose this method to AI agents, end users, or any untrusted
400
+ * caller. It is intended for:
401
+ * - administrative tooling (migrations, diagnostics, repairs)
402
+ * - internal library operations that need cross-repository reach
403
+ *
404
+ * `repositoryId` is accepted for interface symmetry but is intentionally
405
+ * ignored here — the caller is trusted to scope the query themselves.
406
+ * Because the call is cross-repository by design, the emitted usage
407
+ * record carries no `repositoryId` (see `TRACKED_METHODS`).
408
+ *
409
+ * For agent-facing graph queries use {@link traverse}, which enforces the
410
+ * repositoryId scope predicate.
411
+ */
412
+ executeNativeQuery(_repositoryId: string, query: string, params?: Record<string, unknown>): Promise<unknown[]>;
413
+ }
414
+
415
+ declare const SCHEMA_VERSION = 1;
416
+ /**
417
+ * Returns the constraint / index DDL for the deep-memory Neo4j schema as an
418
+ * array of single statements. Each statement is idempotent via
419
+ * `IF NOT EXISTS`. Callers (the provider's `ensureSchema`, or operators
420
+ * running out-of-band against a managed Neo4j) execute the statements one by
421
+ * one.
422
+ *
423
+ * Composite uniqueness and lookup indexes all lead with `repositoryId` so the
424
+ * planner uses it as the cheap discriminator — see D3b layer 1 in the plan.
425
+ * The fulltext index is unfiltered; callers that query it must add an
426
+ * explicit `WHERE node.repositoryId = $rid` post-filter.
427
+ */
428
+ declare function getSchemaCypher(): readonly string[];
429
+
430
+ export { Neo4jStorageProvider, type Neo4jStorageProviderConfig, SCHEMA_VERSION, getSchemaCypher };