ai-database 2.1.3 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/README.md +880 -669
- package/dist/actions.d.ts +2 -2
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +1 -1
- package/dist/actions.js.map +1 -1
- package/dist/ai-promise-db.d.ts +49 -23
- package/dist/ai-promise-db.d.ts.map +1 -1
- package/dist/ai-promise-db.js +91 -63
- package/dist/ai-promise-db.js.map +1 -1
- package/dist/authorization.d.ts.map +1 -1
- package/dist/authorization.js +38 -30
- package/dist/authorization.js.map +1 -1
- package/dist/cascade-orchestrator.d.ts +404 -0
- package/dist/cascade-orchestrator.d.ts.map +1 -0
- package/dist/cascade-orchestrator.js +828 -0
- package/dist/cascade-orchestrator.js.map +1 -0
- package/dist/cascade-write-strategy.d.ts +584 -0
- package/dist/cascade-write-strategy.d.ts.map +1 -0
- package/dist/cascade-write-strategy.js +590 -0
- package/dist/cascade-write-strategy.js.map +1 -0
- package/dist/ch-adapter.d.ts +358 -0
- package/dist/ch-adapter.d.ts.map +1 -0
- package/dist/ch-adapter.js +929 -0
- package/dist/ch-adapter.js.map +1 -0
- package/dist/client/index.d.ts +42 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +43 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client.d.ts +266 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +81 -0
- package/dist/client.js.map +1 -0
- package/dist/constants.d.ts +64 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +52 -2
- package/dist/constants.js.map +1 -1
- package/dist/dataloader.d.ts +99 -0
- package/dist/dataloader.d.ts.map +1 -0
- package/dist/dataloader.js +225 -0
- package/dist/dataloader.js.map +1 -0
- package/dist/db-provider-port.d.ts +501 -0
- package/dist/db-provider-port.d.ts.map +1 -0
- package/dist/db-provider-port.js +113 -0
- package/dist/db-provider-port.js.map +1 -0
- package/dist/digital-objects-provider.d.ts +49 -0
- package/dist/digital-objects-provider.d.ts.map +1 -0
- package/dist/digital-objects-provider.js +55 -0
- package/dist/digital-objects-provider.js.map +1 -0
- package/dist/do-sqlite-adapter.d.ts +402 -0
- package/dist/do-sqlite-adapter.d.ts.map +1 -0
- package/dist/do-sqlite-adapter.js +745 -0
- package/dist/do-sqlite-adapter.js.map +1 -0
- package/dist/docs-rels/custom-types.d.ts +134 -0
- package/dist/docs-rels/custom-types.d.ts.map +1 -0
- package/dist/docs-rels/custom-types.js +70 -0
- package/dist/docs-rels/custom-types.js.map +1 -0
- package/dist/docs-rels/index.d.ts +16 -0
- package/dist/docs-rels/index.d.ts.map +1 -0
- package/dist/docs-rels/index.js +16 -0
- package/dist/docs-rels/index.js.map +1 -0
- package/dist/docs-rels/migrations/index.d.ts +30 -0
- package/dist/docs-rels/migrations/index.d.ts.map +1 -0
- package/dist/docs-rels/migrations/index.js +128 -0
- package/dist/docs-rels/migrations/index.js.map +1 -0
- package/dist/docs-rels/schema.d.ts +2961 -0
- package/dist/docs-rels/schema.d.ts.map +1 -0
- package/dist/docs-rels/schema.js +244 -0
- package/dist/docs-rels/schema.js.map +1 -0
- package/dist/durable-clickhouse.d.ts.map +1 -1
- package/dist/durable-clickhouse.js +16 -13
- package/dist/durable-clickhouse.js.map +1 -1
- package/dist/durable-promise.d.ts.map +1 -1
- package/dist/durable-promise.js +34 -15
- package/dist/durable-promise.js.map +1 -1
- package/dist/errors.d.ts +127 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +210 -0
- package/dist/errors.js.map +1 -0
- package/dist/eventbridge.d.ts +117 -0
- package/dist/eventbridge.d.ts.map +1 -0
- package/dist/eventbridge.js +238 -0
- package/dist/eventbridge.js.map +1 -0
- package/dist/events.d.ts +2 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +1 -1
- package/dist/events.js.map +1 -1
- package/dist/execution-queue.d.ts.map +1 -1
- package/dist/execution-queue.js +4 -5
- package/dist/execution-queue.js.map +1 -1
- package/dist/index.d.ts +35 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +106 -6
- package/dist/index.js.map +1 -1
- package/dist/linguistic.d.ts +3 -108
- package/dist/linguistic.d.ts.map +1 -1
- package/dist/linguistic.js +3 -372
- package/dist/linguistic.js.map +1 -1
- package/dist/logger.d.ts +132 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +137 -0
- package/dist/logger.js.map +1 -0
- package/dist/memory-provider.d.ts +128 -0
- package/dist/memory-provider.d.ts.map +1 -1
- package/dist/memory-provider.js +592 -257
- package/dist/memory-provider.js.map +1 -1
- package/dist/pg-adapter.d.ts +424 -0
- package/dist/pg-adapter.d.ts.map +1 -0
- package/dist/pg-adapter.js +921 -0
- package/dist/pg-adapter.js.map +1 -0
- package/dist/pipelines-iceberg-emitter.d.ts +327 -0
- package/dist/pipelines-iceberg-emitter.d.ts.map +1 -0
- package/dist/pipelines-iceberg-emitter.js +351 -0
- package/dist/pipelines-iceberg-emitter.js.map +1 -0
- package/dist/provider-capabilities.d.ts +146 -0
- package/dist/provider-capabilities.d.ts.map +1 -0
- package/dist/provider-capabilities.js +214 -0
- package/dist/provider-capabilities.js.map +1 -0
- package/dist/rdb-provider-adapter.d.ts +195 -0
- package/dist/rdb-provider-adapter.d.ts.map +1 -0
- package/dist/rdb-provider-adapter.js +291 -0
- package/dist/rdb-provider-adapter.js.map +1 -0
- package/dist/schema/cascade.d.ts +48 -17
- package/dist/schema/cascade.d.ts.map +1 -1
- package/dist/schema/cascade.js +477 -278
- package/dist/schema/cascade.js.map +1 -1
- package/dist/schema/definition-caches.d.ts +24 -0
- package/dist/schema/definition-caches.d.ts.map +1 -0
- package/dist/schema/definition-caches.js +26 -0
- package/dist/schema/definition-caches.js.map +1 -0
- package/dist/schema/dependency-graph.d.ts +21 -109
- package/dist/schema/dependency-graph.d.ts.map +1 -1
- package/dist/schema/dependency-graph.js +25 -333
- package/dist/schema/dependency-graph.js.map +1 -1
- package/dist/schema/diff.d.ts +103 -0
- package/dist/schema/diff.d.ts.map +1 -0
- package/dist/schema/diff.js +329 -0
- package/dist/schema/diff.js.map +1 -0
- package/dist/schema/entity-operations.d.ts +99 -0
- package/dist/schema/entity-operations.d.ts.map +1 -0
- package/dist/schema/entity-operations.js +818 -0
- package/dist/schema/entity-operations.js.map +1 -0
- package/dist/schema/index.d.ts +28 -34
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +454 -521
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/migration.d.ts +205 -0
- package/dist/schema/migration.d.ts.map +1 -0
- package/dist/schema/migration.js +327 -0
- package/dist/schema/migration.js.map +1 -0
- package/dist/schema/nl-query-generator.d.ts +68 -0
- package/dist/schema/nl-query-generator.d.ts.map +1 -0
- package/dist/schema/nl-query-generator.js +362 -0
- package/dist/schema/nl-query-generator.js.map +1 -0
- package/dist/schema/nl-query.d.ts +65 -0
- package/dist/schema/nl-query.d.ts.map +1 -0
- package/dist/schema/nl-query.js +178 -0
- package/dist/schema/nl-query.js.map +1 -0
- package/dist/schema/parse.d.ts.map +1 -1
- package/dist/schema/parse.js +144 -89
- package/dist/schema/parse.js.map +1 -1
- package/dist/schema/provider.d.ts +37 -0
- package/dist/schema/provider.d.ts.map +1 -1
- package/dist/schema/provider.js +15 -7
- package/dist/schema/provider.js.map +1 -1
- package/dist/schema/resolve.d.ts +46 -5
- package/dist/schema/resolve.d.ts.map +1 -1
- package/dist/schema/resolve.js +237 -95
- package/dist/schema/resolve.js.map +1 -1
- package/dist/schema/search-utils.d.ts +76 -0
- package/dist/schema/search-utils.d.ts.map +1 -0
- package/dist/schema/search-utils.js +86 -0
- package/dist/schema/search-utils.js.map +1 -0
- package/dist/schema/seed.d.ts +53 -0
- package/dist/schema/seed.d.ts.map +1 -0
- package/dist/schema/seed.js +94 -0
- package/dist/schema/seed.js.map +1 -0
- package/dist/schema/semantic.d.ts +10 -0
- package/dist/schema/semantic.d.ts.map +1 -1
- package/dist/schema/semantic.js +192 -86
- package/dist/schema/semantic.js.map +1 -1
- package/dist/schema/sub-apis.d.ts +52 -0
- package/dist/schema/sub-apis.d.ts.map +1 -0
- package/dist/schema/sub-apis.js +216 -0
- package/dist/schema/sub-apis.js.map +1 -0
- package/dist/schema/system-entities.d.ts +42 -0
- package/dist/schema/system-entities.d.ts.map +1 -0
- package/dist/schema/system-entities.js +101 -0
- package/dist/schema/system-entities.js.map +1 -0
- package/dist/schema/types.d.ts +91 -9
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/union-fallback.d.ts.map +1 -1
- package/dist/schema/union-fallback.js +21 -15
- package/dist/schema/union-fallback.js.map +1 -1
- package/dist/schema/value-generators/ai.d.ts +54 -0
- package/dist/schema/value-generators/ai.d.ts.map +1 -0
- package/dist/schema/value-generators/ai.js +136 -0
- package/dist/schema/value-generators/ai.js.map +1 -0
- package/dist/schema/value-generators/index.d.ts +126 -0
- package/dist/schema/value-generators/index.d.ts.map +1 -0
- package/dist/schema/value-generators/index.js +219 -0
- package/dist/schema/value-generators/index.js.map +1 -0
- package/dist/schema/value-generators/placeholder.d.ts +52 -0
- package/dist/schema/value-generators/placeholder.d.ts.map +1 -0
- package/dist/schema/value-generators/placeholder.js +328 -0
- package/dist/schema/value-generators/placeholder.js.map +1 -0
- package/dist/schema/value-generators/types.d.ts +116 -0
- package/dist/schema/value-generators/types.d.ts.map +1 -0
- package/dist/schema/value-generators/types.js +11 -0
- package/dist/schema/value-generators/types.js.map +1 -0
- package/dist/schema/version.d.ts +111 -0
- package/dist/schema/version.d.ts.map +1 -0
- package/dist/schema/version.js +190 -0
- package/dist/schema/version.js.map +1 -0
- package/dist/schema.d.ts +1095 -24
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2852 -40
- package/dist/schema.js.map +1 -1
- package/dist/semantic-vectors.d.ts +39 -0
- package/dist/semantic-vectors.d.ts.map +1 -0
- package/dist/semantic-vectors.js +334 -0
- package/dist/semantic-vectors.js.map +1 -0
- package/dist/semantic.d.ts +29 -1
- package/dist/semantic.d.ts.map +1 -1
- package/dist/semantic.js +26 -16
- package/dist/semantic.js.map +1 -1
- package/dist/telemetry.d.ts +128 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +305 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/tests.d.ts.map +1 -1
- package/dist/tests.js +30 -22
- package/dist/tests.js.map +1 -1
- package/dist/type-guards.d.ts +50 -5
- package/dist/type-guards.d.ts.map +1 -1
- package/dist/type-guards.js +87 -16
- package/dist/type-guards.js.map +1 -1
- package/dist/types.d.ts +33 -245
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +62 -72
- package/dist/types.js.map +1 -1
- package/dist/validation.d.ts +2 -5
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +65 -93
- package/dist/validation.js.map +1 -1
- package/dist/worker/db-provider.d.ts +168 -0
- package/dist/worker/db-provider.d.ts.map +1 -0
- package/dist/worker/db-provider.js +277 -0
- package/dist/worker/db-provider.js.map +1 -0
- package/dist/worker/index.d.ts +35 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +37 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker.d.ts +779 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2786 -0
- package/dist/worker.js.map +1 -0
- package/package.json +46 -16
- package/src/docs-rels/migrations/0001-init.sql +125 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres + pgvector DBProvider adapter (Stack A transactional layer)
|
|
3
|
+
*
|
|
4
|
+
* First-class adapter for Stack A per
|
|
5
|
+
* [ADR-0003](../../../docs/adr/0003-storage-strategy-pg-clickhouse-default.md).
|
|
6
|
+
* Implements the {@link DBProviderPort} surface with:
|
|
7
|
+
*
|
|
8
|
+
* - **Tier 1 (entity CRUD)** against a `things(ns, id, type, data jsonb)` table.
|
|
9
|
+
* - **Tier 2 (graph traversal)** via Actions: subject/object/recipient/...
|
|
10
|
+
* queries against an `actions(ns, id, verb, subject, object, roles jsonb,
|
|
11
|
+
* data jsonb, status, created_at, completed_at)` table.
|
|
12
|
+
* - **Tier 3 (analytics)** declared with caveats: aggregations + time-series
|
|
13
|
+
* work, but adapters running large scans contend with transactional load.
|
|
14
|
+
* `analyticsQuery` is exposed for ad-hoc SQL.
|
|
15
|
+
* - **Tier 4 (vector search)** native via `pgvector` (max 16,000 dims;
|
|
16
|
+
* `cosine`/`l2`/`dot` metrics implemented). Embeddings live in a
|
|
17
|
+
* companion `embeddings` table joined to `things`; callers seed it via
|
|
18
|
+
* {@link PostgresProvider.upsertEmbedding} and query via
|
|
19
|
+
* {@link PostgresProvider.vectorSearch}. Frame-aware role filtering
|
|
20
|
+
* (e.g. "only Things appearing as `subject` of a Verb") is deferred to
|
|
21
|
+
* a follow-up refinement bead.
|
|
22
|
+
* - **SVO Action recording** + **Verb registry** declared via
|
|
23
|
+
* `hasActionRecording: true` and `hasVerbRegistry: true`.
|
|
24
|
+
* - **Sharding**: `partitioned-by-tenant` by default; the `ns` namespace
|
|
25
|
+
* column doubles as the partition key. Callers can run a shared schema
|
|
26
|
+
* across tenants (one row per `(ns, id)`) or partition the underlying
|
|
27
|
+
* `things` / `actions` tables on `ns` for physical isolation. Either
|
|
28
|
+
* way, this adapter writes through `ns`.
|
|
29
|
+
*
|
|
30
|
+
* ## Cascade write path
|
|
31
|
+
*
|
|
32
|
+
* Per the substrate-write-probes verdict (`docs/reviews/2026-05-05-cascade-poc-evaluation.md`),
|
|
33
|
+
* the cascade-scale write path is **bulk-VALUES CTE with `ON CONFLICT DO
|
|
34
|
+
* NOTHING`** — 91 ms p50 for 500 things + 499 actions in one round-trip
|
|
35
|
+
* on Neon HTTP. {@link PostgresProvider.commitBatch} implements that
|
|
36
|
+
* shape directly.
|
|
37
|
+
*
|
|
38
|
+
* ## Driver choice
|
|
39
|
+
*
|
|
40
|
+
* The adapter is driver-agnostic — it consumes any {@link PgExecutor}
|
|
41
|
+
* (a function that takes SQL + positional params and returns rows).
|
|
42
|
+
* Helpers ship for the two common shapes:
|
|
43
|
+
*
|
|
44
|
+
* - {@link createNeonHttpExecutor} — `@neondatabase/serverless` HTTP
|
|
45
|
+
* driver. **Preferred for the cascade workload** per the probes (~2x
|
|
46
|
+
* over Hyperdrive on every short-burst write shape; sublinear scaling
|
|
47
|
+
* above N=100; 91 ms for 500 docs + 499 rels). Works in Cloudflare
|
|
48
|
+
* Workers and Node.
|
|
49
|
+
* - {@link createPgClientExecutor} — wraps a `postgres.js` (Hyperdrive)
|
|
50
|
+
* client. Use when the deployment specifically needs connection
|
|
51
|
+
* pooling / pipelining; **not** the cascade fast path. Hyperdrive's
|
|
52
|
+
* parameterless-only response cache is a **trap** — once `$1` appears,
|
|
53
|
+
* it stops caching. Document this for callers.
|
|
54
|
+
*
|
|
55
|
+
* ## Schema
|
|
56
|
+
*
|
|
57
|
+
* The adapter assumes a schema named `aidb` (configurable). DDL:
|
|
58
|
+
*
|
|
59
|
+
* ```sql
|
|
60
|
+
* CREATE EXTENSION IF NOT EXISTS vector;
|
|
61
|
+
* CREATE SCHEMA IF NOT EXISTS aidb;
|
|
62
|
+
*
|
|
63
|
+
* CREATE TABLE IF NOT EXISTS aidb.things (
|
|
64
|
+
* ns text NOT NULL,
|
|
65
|
+
* id text NOT NULL,
|
|
66
|
+
* type text NOT NULL,
|
|
67
|
+
* data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
68
|
+
* created_at timestamptz NOT NULL DEFAULT now(),
|
|
69
|
+
* updated_at timestamptz NOT NULL DEFAULT now(),
|
|
70
|
+
* CONSTRAINT things_pk PRIMARY KEY (ns, id)
|
|
71
|
+
* );
|
|
72
|
+
* CREATE INDEX IF NOT EXISTS things_type_idx ON aidb.things (ns, type);
|
|
73
|
+
* CREATE INDEX IF NOT EXISTS things_data_gin_idx
|
|
74
|
+
* ON aidb.things USING gin (data jsonb_path_ops);
|
|
75
|
+
*
|
|
76
|
+
* CREATE TABLE IF NOT EXISTS aidb.actions (
|
|
77
|
+
* ns text NOT NULL,
|
|
78
|
+
* id text NOT NULL,
|
|
79
|
+
* verb text NOT NULL,
|
|
80
|
+
* subject text,
|
|
81
|
+
* object text,
|
|
82
|
+
* roles jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
83
|
+
* data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
84
|
+
* status text NOT NULL DEFAULT 'pending',
|
|
85
|
+
* created_at timestamptz NOT NULL DEFAULT now(),
|
|
86
|
+
* completed_at timestamptz,
|
|
87
|
+
* CONSTRAINT actions_pk PRIMARY KEY (ns, id)
|
|
88
|
+
* );
|
|
89
|
+
* CREATE INDEX IF NOT EXISTS actions_verb_idx ON aidb.actions (ns, verb);
|
|
90
|
+
* CREATE INDEX IF NOT EXISTS actions_subject_idx ON aidb.actions (ns, subject);
|
|
91
|
+
* CREATE INDEX IF NOT EXISTS actions_object_idx ON aidb.actions (ns, object);
|
|
92
|
+
*
|
|
93
|
+
* CREATE TABLE IF NOT EXISTS aidb.verbs (
|
|
94
|
+
* name text PRIMARY KEY,
|
|
95
|
+
* data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
96
|
+
* created_at timestamptz NOT NULL DEFAULT now()
|
|
97
|
+
* );
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* `bootstrapSchema(executor)` ships the DDL above so callers can stand
|
|
101
|
+
* up a fresh database in one call.
|
|
102
|
+
*
|
|
103
|
+
* @packageDocumentation
|
|
104
|
+
*/
|
|
105
|
+
import { validateTypeName, validateEntityId, validateEntityData, validateRelationName, validateSearchQuery, validateListOptions, validateSearchOptions, } from './validation.js';
|
|
106
|
+
import { EntityNotFoundError } from './errors.js';
|
|
107
|
+
/**
|
|
108
|
+
* Wrap a `@neondatabase/serverless` HTTP query function as a {@link PgExecutor}.
|
|
109
|
+
*
|
|
110
|
+
* The Neon HTTP driver is **the preferred cascade write path** per the
|
|
111
|
+
* substrate-write-probes Phase 1 verdict — ~2x faster than Hyperdrive on
|
|
112
|
+
* every short-burst write shape, and the only driver/shape combination
|
|
113
|
+
* that achieves sublinear scaling above N=100.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* import { neon } from '@neondatabase/serverless'
|
|
118
|
+
* import { createNeonHttpExecutor, createPostgresProvider } from 'ai-database'
|
|
119
|
+
*
|
|
120
|
+
* const sql = neon(env.DATABASE_URL)
|
|
121
|
+
* const provider = createPostgresProvider({
|
|
122
|
+
* executor: createNeonHttpExecutor(sql),
|
|
123
|
+
* namespace: 'tenant-9',
|
|
124
|
+
* })
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export function createNeonHttpExecutor(neonFn) {
|
|
128
|
+
return async (sql, params) => {
|
|
129
|
+
const result = await neonFn(sql, params ?? []);
|
|
130
|
+
// Neon returns rows directly as an array of objects.
|
|
131
|
+
return Array.isArray(result) ? result : [];
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Wrap a `postgres.js` client as a {@link PgExecutor}.
|
|
136
|
+
*
|
|
137
|
+
* Use this when the deployment requires Hyperdrive (e.g., for
|
|
138
|
+
* connection pooling / WebSocket pipelining). **Not the cascade fast
|
|
139
|
+
* path** — see ADR-0003 and the substrate-write-probes notes:
|
|
140
|
+
* Hyperdrive's response cache only fires on parameterless queries
|
|
141
|
+
* (simple protocol). Once `$1` appears, postgres.js switches to
|
|
142
|
+
* extended protocol and Hyperdrive stops caching.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```ts
|
|
146
|
+
* import postgres from 'postgres'
|
|
147
|
+
* import { createPgClientExecutor, createPostgresProvider } from 'ai-database'
|
|
148
|
+
*
|
|
149
|
+
* const sql = postgres(env.HYPERDRIVE.connectionString)
|
|
150
|
+
* const provider = createPostgresProvider({
|
|
151
|
+
* executor: createPgClientExecutor(sql),
|
|
152
|
+
* namespace: 'tenant-9',
|
|
153
|
+
* })
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export function createPgClientExecutor(client) {
|
|
157
|
+
return async (sql, params) => {
|
|
158
|
+
const result = await client.unsafe(sql, params ?? []);
|
|
159
|
+
return Array.isArray(result) ? result : [];
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// =============================================================================
|
|
163
|
+
// SQL helpers
|
|
164
|
+
// =============================================================================
|
|
165
|
+
const DEFAULT_SCHEMA = 'aidb';
|
|
166
|
+
const DEFAULT_NAMESPACE = 'default';
|
|
167
|
+
const DEFAULT_VECTOR_DIMS = 1536;
|
|
168
|
+
/** Generate a UUID for adapter-issued ids. */
|
|
169
|
+
function genId() {
|
|
170
|
+
return crypto.randomUUID();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Coerce arbitrary jsonb-shaped column values from the executor into
|
|
174
|
+
* a plain object. Drivers vary: neon returns parsed objects, postgres.js
|
|
175
|
+
* may return strings depending on type configuration.
|
|
176
|
+
*/
|
|
177
|
+
function asJsonb(value) {
|
|
178
|
+
if (!value)
|
|
179
|
+
return {};
|
|
180
|
+
if (typeof value === 'string') {
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(value);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (typeof value === 'object')
|
|
189
|
+
return value;
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
function asDate(value) {
|
|
193
|
+
if (!value)
|
|
194
|
+
return undefined;
|
|
195
|
+
if (value instanceof Date)
|
|
196
|
+
return value;
|
|
197
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
198
|
+
const d = new Date(value);
|
|
199
|
+
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
function asString(value) {
|
|
204
|
+
if (value === null || value === undefined)
|
|
205
|
+
return undefined;
|
|
206
|
+
return String(value);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Format a numeric array as a pgvector literal: `'[0.1,0.2,0.3]'`. Cast
|
|
210
|
+
* to `vector` at the SQL site (`$N::vector`).
|
|
211
|
+
*/
|
|
212
|
+
function embeddingLiteral(embedding) {
|
|
213
|
+
// pgvector parses `[v1,v2,...]` syntax. We avoid scientific notation
|
|
214
|
+
// edge cases by relying on JS Number.toString — acceptable for cascade
|
|
215
|
+
// workloads (embeddings are 4-byte floats; JS numbers carry more
|
|
216
|
+
// precision than needed). NaN/Infinity are explicitly rejected.
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (const v of embedding) {
|
|
219
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
220
|
+
throw new Error('embeddingLiteral: embedding values must be finite numbers');
|
|
221
|
+
}
|
|
222
|
+
parts.push(String(v));
|
|
223
|
+
}
|
|
224
|
+
return `[${parts.join(',')}]`;
|
|
225
|
+
}
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// PostgresProvider
|
|
228
|
+
// =============================================================================
|
|
229
|
+
/**
|
|
230
|
+
* Postgres + pgvector adapter implementing {@link DBProviderPort} and
|
|
231
|
+
* {@link DBProviderSVO}.
|
|
232
|
+
*/
|
|
233
|
+
export class PostgresProvider {
|
|
234
|
+
executor;
|
|
235
|
+
namespace;
|
|
236
|
+
schema;
|
|
237
|
+
vectorDimensions;
|
|
238
|
+
_shardingModel;
|
|
239
|
+
driver;
|
|
240
|
+
constructor(options) {
|
|
241
|
+
this.executor = options.executor;
|
|
242
|
+
this.namespace = options.namespace ?? DEFAULT_NAMESPACE;
|
|
243
|
+
this.schema = options.schema ?? DEFAULT_SCHEMA;
|
|
244
|
+
this.vectorDimensions = options.vectorDimensions ?? DEFAULT_VECTOR_DIMS;
|
|
245
|
+
this._shardingModel = options.shardingModel ?? 'partitioned-by-tenant';
|
|
246
|
+
this.driver = options.driver ?? 'pg';
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Tier capability declaration. Declares Tier 3 with caveats and Tier 4
|
|
250
|
+
* (pgvector). `hasActionRecording` and `hasVerbRegistry` are both
|
|
251
|
+
* `true` — see the SVO methods on this class.
|
|
252
|
+
*/
|
|
253
|
+
get capabilities() {
|
|
254
|
+
return {
|
|
255
|
+
adapter: 'pg+pgvector',
|
|
256
|
+
shardingModel: this._shardingModel,
|
|
257
|
+
analytics: {
|
|
258
|
+
hasAggregations: true,
|
|
259
|
+
hasTimeSeries: true,
|
|
260
|
+
// ADR-0003: Postgres "works at moderate scale; long scans contend
|
|
261
|
+
// with transactional load". Cascade ceiling: ~few-thousand
|
|
262
|
+
// inserts/sec on a single instance.
|
|
263
|
+
hasLargeScans: false,
|
|
264
|
+
},
|
|
265
|
+
vectorSearch: {
|
|
266
|
+
maxDimensions: this.vectorDimensions,
|
|
267
|
+
metrics: ['cosine', 'l2', 'dot'],
|
|
268
|
+
implementation: 'native',
|
|
269
|
+
},
|
|
270
|
+
hasActionRecording: true,
|
|
271
|
+
hasVerbRegistry: true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// ===========================================================================
|
|
275
|
+
// Tier 1 — Entity CRUD
|
|
276
|
+
// ===========================================================================
|
|
277
|
+
async get(type, id) {
|
|
278
|
+
validateTypeName(type);
|
|
279
|
+
validateEntityId(id);
|
|
280
|
+
const rows = await this.executor(`SELECT data FROM ${this.schema}.things WHERE ns = $1 AND type = $2 AND id = $3 LIMIT 1`, [this.namespace, type, id]);
|
|
281
|
+
if (rows.length === 0)
|
|
282
|
+
return null;
|
|
283
|
+
const data = asJsonb(rows[0]['data']);
|
|
284
|
+
return { ...data, $id: id, $type: type };
|
|
285
|
+
}
|
|
286
|
+
async list(type, options) {
|
|
287
|
+
validateTypeName(type);
|
|
288
|
+
validateListOptions(options);
|
|
289
|
+
const limit = options?.limit ?? 1000;
|
|
290
|
+
const offset = options?.offset ?? 0;
|
|
291
|
+
// We ORDER BY id by default — adapters can layer richer ordering on
|
|
292
|
+
// top via $.data->>'field' if needed. Field-level orderBy is left to
|
|
293
|
+
// a future bead because it requires query-builder logic.
|
|
294
|
+
const rows = await this.executor(`SELECT id, data FROM ${this.schema}.things
|
|
295
|
+
WHERE ns = $1 AND type = $2
|
|
296
|
+
ORDER BY id
|
|
297
|
+
LIMIT $3 OFFSET $4`, [this.namespace, type, limit, offset]);
|
|
298
|
+
let result = rows.map((row) => {
|
|
299
|
+
const data = asJsonb(row['data']);
|
|
300
|
+
return { ...data, $id: String(row['id']), $type: type };
|
|
301
|
+
});
|
|
302
|
+
// Apply where filter client-side for parity with MemoryProvider.
|
|
303
|
+
// For high-volume paths, callers should use raw SQL via
|
|
304
|
+
// analyticsQuery() — this is the simple convenience path.
|
|
305
|
+
if (options?.where) {
|
|
306
|
+
result = result.filter((entity) => {
|
|
307
|
+
for (const [key, value] of Object.entries(options.where)) {
|
|
308
|
+
if (entity[key] !== value)
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
async search(type, query, options) {
|
|
317
|
+
validateTypeName(type);
|
|
318
|
+
validateSearchQuery(query);
|
|
319
|
+
validateSearchOptions(options);
|
|
320
|
+
const limit = options?.limit ?? 100;
|
|
321
|
+
// Use to_tsvector over the jsonb-as-text representation for a simple
|
|
322
|
+
// full-text path; richer search lands later. ILIKE fallback ensures
|
|
323
|
+
// tests work without `pg_trgm` / `tsvector` configuration.
|
|
324
|
+
const rows = await this.executor(`SELECT id, data FROM ${this.schema}.things
|
|
325
|
+
WHERE ns = $1 AND type = $2 AND data::text ILIKE $3
|
|
326
|
+
ORDER BY id
|
|
327
|
+
LIMIT $4`, [this.namespace, type, `%${query}%`, limit]);
|
|
328
|
+
return rows.map((row) => {
|
|
329
|
+
const data = asJsonb(row['data']);
|
|
330
|
+
return { ...data, $id: String(row['id']), $type: type };
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
async create(type, id, data) {
|
|
334
|
+
validateTypeName(type);
|
|
335
|
+
if (id !== undefined)
|
|
336
|
+
validateEntityId(id);
|
|
337
|
+
validateEntityData(data);
|
|
338
|
+
const entityId = id ?? genId();
|
|
339
|
+
const now = new Date().toISOString();
|
|
340
|
+
const payload = { ...data, createdAt: now, updatedAt: now };
|
|
341
|
+
await this.executor(`INSERT INTO ${this.schema}.things (ns, id, type, data) VALUES ($1, $2, $3, $4::jsonb)
|
|
342
|
+
ON CONFLICT ON CONSTRAINT things_pk DO NOTHING`, [this.namespace, entityId, type, JSON.stringify(payload)]);
|
|
343
|
+
return { ...payload, $id: entityId, $type: type };
|
|
344
|
+
}
|
|
345
|
+
async update(type, id, data) {
|
|
346
|
+
validateTypeName(type);
|
|
347
|
+
validateEntityId(id);
|
|
348
|
+
validateEntityData(data);
|
|
349
|
+
const existing = await this.get(type, id);
|
|
350
|
+
if (!existing)
|
|
351
|
+
throw new EntityNotFoundError(type, id, 'update');
|
|
352
|
+
const { $id: _id, $type: _type, ...rest } = existing;
|
|
353
|
+
const merged = { ...rest, ...data, updatedAt: new Date().toISOString() };
|
|
354
|
+
await this.executor(`UPDATE ${this.schema}.things SET data = $1::jsonb, updated_at = now()
|
|
355
|
+
WHERE ns = $2 AND type = $3 AND id = $4`, [JSON.stringify(merged), this.namespace, type, id]);
|
|
356
|
+
return { ...merged, $id: id, $type: type };
|
|
357
|
+
}
|
|
358
|
+
async delete(type, id) {
|
|
359
|
+
validateTypeName(type);
|
|
360
|
+
validateEntityId(id);
|
|
361
|
+
// postgres.js / neon do not consistently return rowCount through the
|
|
362
|
+
// executor surface; we read existence first to return a stable boolean.
|
|
363
|
+
const existing = await this.get(type, id);
|
|
364
|
+
if (!existing)
|
|
365
|
+
return false;
|
|
366
|
+
await this.executor(`DELETE FROM ${this.schema}.things WHERE ns = $1 AND type = $2 AND id = $3`, [this.namespace, type, id]);
|
|
367
|
+
// Cascade-delete actions where this thing is subject or object.
|
|
368
|
+
await this.executor(`DELETE FROM ${this.schema}.actions
|
|
369
|
+
WHERE ns = $1 AND (subject = $2 OR object = $2)`, [this.namespace, id]);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
// ===========================================================================
|
|
373
|
+
// Tier 2 — Graph traversal via Actions
|
|
374
|
+
// ===========================================================================
|
|
375
|
+
async related(type, id, relation) {
|
|
376
|
+
validateTypeName(type);
|
|
377
|
+
validateEntityId(id);
|
|
378
|
+
validateRelationName(relation);
|
|
379
|
+
// Reads "all things this thing is subject of via this verb, returning
|
|
380
|
+
// the object thing". Mirrors MemoryProvider.related semantics.
|
|
381
|
+
const rows = await this.executor(`SELECT t.id, t.type, t.data
|
|
382
|
+
FROM ${this.schema}.actions a
|
|
383
|
+
JOIN ${this.schema}.things t ON t.ns = a.ns AND t.id = a.object
|
|
384
|
+
WHERE a.ns = $1 AND a.subject = $2 AND a.verb = $3`, [this.namespace, id, relation]);
|
|
385
|
+
return rows.map((row) => {
|
|
386
|
+
const data = asJsonb(row['data']);
|
|
387
|
+
return {
|
|
388
|
+
...data,
|
|
389
|
+
$id: String(row['id']),
|
|
390
|
+
$type: String(row['type']),
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
async relate(fromType, fromId, relation, toType, toId, metadata) {
|
|
395
|
+
validateTypeName(fromType);
|
|
396
|
+
validateEntityId(fromId);
|
|
397
|
+
validateRelationName(relation);
|
|
398
|
+
validateTypeName(toType);
|
|
399
|
+
validateEntityId(toId);
|
|
400
|
+
// relate() lands as a completed Action with the verb as `relation`.
|
|
401
|
+
await this.recordAction({
|
|
402
|
+
verb: relation,
|
|
403
|
+
subject: fromId,
|
|
404
|
+
object: toId,
|
|
405
|
+
data: {
|
|
406
|
+
fromType,
|
|
407
|
+
toType,
|
|
408
|
+
...(metadata ? { metadata } : {}),
|
|
409
|
+
},
|
|
410
|
+
status: 'completed',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
async unrelate(fromType, fromId, relation, toType, toId) {
|
|
414
|
+
validateTypeName(fromType);
|
|
415
|
+
validateEntityId(fromId);
|
|
416
|
+
validateRelationName(relation);
|
|
417
|
+
validateTypeName(toType);
|
|
418
|
+
validateEntityId(toId);
|
|
419
|
+
await this.executor(`DELETE FROM ${this.schema}.actions
|
|
420
|
+
WHERE ns = $1 AND verb = $2 AND subject = $3 AND object = $4`, [this.namespace, relation, fromId, toId]);
|
|
421
|
+
}
|
|
422
|
+
// ===========================================================================
|
|
423
|
+
// SVO Action recording (DBProviderSVO)
|
|
424
|
+
// ===========================================================================
|
|
425
|
+
async recordAction(input) {
|
|
426
|
+
const id = genId();
|
|
427
|
+
const status = input.status ?? 'pending';
|
|
428
|
+
const createdAt = new Date();
|
|
429
|
+
const completedAt = status === 'completed' || status === 'failed' || status === 'cancelled'
|
|
430
|
+
? createdAt
|
|
431
|
+
: undefined;
|
|
432
|
+
await this.executor(`INSERT INTO ${this.schema}.actions
|
|
433
|
+
(ns, id, verb, subject, object, roles, data, status, created_at, completed_at)
|
|
434
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10)`, [
|
|
435
|
+
this.namespace,
|
|
436
|
+
id,
|
|
437
|
+
input.verb,
|
|
438
|
+
input.subject ?? null,
|
|
439
|
+
input.object ?? null,
|
|
440
|
+
JSON.stringify(input.roles ?? {}),
|
|
441
|
+
JSON.stringify(input.data ?? {}),
|
|
442
|
+
status,
|
|
443
|
+
createdAt.toISOString(),
|
|
444
|
+
completedAt ? completedAt.toISOString() : null,
|
|
445
|
+
]);
|
|
446
|
+
const action = {
|
|
447
|
+
id,
|
|
448
|
+
verb: input.verb,
|
|
449
|
+
...(input.subject !== undefined && { subject: input.subject }),
|
|
450
|
+
...(input.object !== undefined && { object: input.object }),
|
|
451
|
+
...(input.roles !== undefined && { roles: input.roles }),
|
|
452
|
+
...(input.data !== undefined && { data: input.data }),
|
|
453
|
+
status,
|
|
454
|
+
createdAt,
|
|
455
|
+
...(completedAt !== undefined && { completedAt }),
|
|
456
|
+
};
|
|
457
|
+
return action;
|
|
458
|
+
}
|
|
459
|
+
async queryActions(query = {}) {
|
|
460
|
+
const conditions = ['ns = $1'];
|
|
461
|
+
const params = [this.namespace];
|
|
462
|
+
let n = 2;
|
|
463
|
+
if (query.verb !== undefined) {
|
|
464
|
+
conditions.push(`verb = $${n++}`);
|
|
465
|
+
params.push(query.verb);
|
|
466
|
+
}
|
|
467
|
+
if (query.subject !== undefined) {
|
|
468
|
+
conditions.push(`subject = $${n++}`);
|
|
469
|
+
params.push(query.subject);
|
|
470
|
+
}
|
|
471
|
+
if (query.object !== undefined) {
|
|
472
|
+
conditions.push(`object = $${n++}`);
|
|
473
|
+
params.push(query.object);
|
|
474
|
+
}
|
|
475
|
+
if (query.role) {
|
|
476
|
+
for (const [role, value] of Object.entries(query.role)) {
|
|
477
|
+
if (role === 'subject' || role === 'object') {
|
|
478
|
+
conditions.push(`${role} = $${n++}`);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
conditions.push(`roles->>'${role}' = $${n++}`);
|
|
482
|
+
}
|
|
483
|
+
params.push(value);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (query.status) {
|
|
487
|
+
const statuses = Array.isArray(query.status) ? query.status : [query.status];
|
|
488
|
+
const placeholders = statuses.map(() => `$${n++}`).join(', ');
|
|
489
|
+
conditions.push(`status IN (${placeholders})`);
|
|
490
|
+
for (const s of statuses)
|
|
491
|
+
params.push(s);
|
|
492
|
+
}
|
|
493
|
+
if (query.since) {
|
|
494
|
+
conditions.push(`created_at >= $${n++}`);
|
|
495
|
+
params.push(query.since.toISOString());
|
|
496
|
+
}
|
|
497
|
+
if (query.until) {
|
|
498
|
+
conditions.push(`created_at <= $${n++}`);
|
|
499
|
+
params.push(query.until.toISOString());
|
|
500
|
+
}
|
|
501
|
+
const limit = query.limit ?? 1000;
|
|
502
|
+
const offset = query.offset ?? 0;
|
|
503
|
+
params.push(limit, offset);
|
|
504
|
+
const rows = await this.executor(`SELECT id, verb, subject, object, roles, data, status, created_at, completed_at
|
|
505
|
+
FROM ${this.schema}.actions
|
|
506
|
+
WHERE ${conditions.join(' AND ')}
|
|
507
|
+
ORDER BY created_at ASC
|
|
508
|
+
LIMIT $${n++} OFFSET $${n++}`, params);
|
|
509
|
+
return rows.map((row) => {
|
|
510
|
+
const action = {
|
|
511
|
+
id: String(row['id']),
|
|
512
|
+
verb: String(row['verb']),
|
|
513
|
+
status: row['status'] ?? 'pending',
|
|
514
|
+
createdAt: asDate(row['created_at']) ?? new Date(0),
|
|
515
|
+
};
|
|
516
|
+
const subject = asString(row['subject']);
|
|
517
|
+
if (subject !== undefined)
|
|
518
|
+
action.subject = subject;
|
|
519
|
+
const object = asString(row['object']);
|
|
520
|
+
if (object !== undefined)
|
|
521
|
+
action.object = object;
|
|
522
|
+
const roles = asJsonb(row['roles']);
|
|
523
|
+
if (Object.keys(roles).length > 0) {
|
|
524
|
+
action.roles = roles;
|
|
525
|
+
}
|
|
526
|
+
const data = asJsonb(row['data']);
|
|
527
|
+
if (Object.keys(data).length > 0) {
|
|
528
|
+
action.data = data;
|
|
529
|
+
}
|
|
530
|
+
const completedAt = asDate(row['completed_at']);
|
|
531
|
+
if (completedAt)
|
|
532
|
+
action.completedAt = completedAt;
|
|
533
|
+
return action;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
// ===========================================================================
|
|
537
|
+
// Verb registry (DBProviderSVO)
|
|
538
|
+
// ===========================================================================
|
|
539
|
+
async defineVerb(def) {
|
|
540
|
+
const verb = {
|
|
541
|
+
name: def.name,
|
|
542
|
+
action: def.action ?? def.name,
|
|
543
|
+
act: def.act ?? `${def.name}s`,
|
|
544
|
+
activity: def.activity ?? `${def.name}ing`,
|
|
545
|
+
event: def.event ?? `${def.name}d`,
|
|
546
|
+
...(def.reverseBy !== undefined && { reverseBy: def.reverseBy }),
|
|
547
|
+
...(def.reverseAt !== undefined && { reverseAt: def.reverseAt }),
|
|
548
|
+
...(def.reverseIn !== undefined && { reverseIn: def.reverseIn }),
|
|
549
|
+
...(def.inverse !== undefined && { inverse: def.inverse }),
|
|
550
|
+
...(def.description !== undefined && { description: def.description }),
|
|
551
|
+
...(def.frame !== undefined && { frame: def.frame }),
|
|
552
|
+
...(def.source !== undefined && { source: def.source }),
|
|
553
|
+
...(def.canonical !== undefined && { canonical: def.canonical }),
|
|
554
|
+
createdAt: new Date(),
|
|
555
|
+
};
|
|
556
|
+
await this.executor(`INSERT INTO ${this.schema}.verbs (name, data) VALUES ($1, $2::jsonb)
|
|
557
|
+
ON CONFLICT (name) DO NOTHING`, [verb.name, JSON.stringify(verb)]);
|
|
558
|
+
return verb;
|
|
559
|
+
}
|
|
560
|
+
async getVerb(name) {
|
|
561
|
+
const rows = await this.executor(`SELECT data, created_at FROM ${this.schema}.verbs WHERE name = $1 LIMIT 1`, [name]);
|
|
562
|
+
if (rows.length === 0)
|
|
563
|
+
return null;
|
|
564
|
+
const data = asJsonb(rows[0]['data']);
|
|
565
|
+
const createdAt = asDate(rows[0]['created_at']) ?? new Date(0);
|
|
566
|
+
return { ...data, createdAt };
|
|
567
|
+
}
|
|
568
|
+
async listVerbs() {
|
|
569
|
+
const rows = await this.executor(`SELECT data, created_at FROM ${this.schema}.verbs ORDER BY name ASC`);
|
|
570
|
+
return rows.map((row) => {
|
|
571
|
+
const data = asJsonb(row['data']);
|
|
572
|
+
const createdAt = asDate(row['created_at']) ?? new Date(0);
|
|
573
|
+
return { ...data, createdAt };
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
// ===========================================================================
|
|
577
|
+
// Cascade write fast path — CTE jsonb-bulk
|
|
578
|
+
// ===========================================================================
|
|
579
|
+
/**
|
|
580
|
+
* Bulk-commit Things and Actions in a single round-trip via a CTE chain
|
|
581
|
+
* with bulk `VALUES (...), (...)` inserts and `ON CONFLICT DO NOTHING`.
|
|
582
|
+
*
|
|
583
|
+
* **This is the cascade-scale write path** per the substrate-write-probes
|
|
584
|
+
* Phase 1 verdict: 91 ms p50 for 500 things + 499 actions on Neon HTTP;
|
|
585
|
+
* sublinear scaling above N=100; ~5,500 cascade-startups/sec/worker
|
|
586
|
+
* write ceiling on the pg portion alone (LLM cost dominates).
|
|
587
|
+
*
|
|
588
|
+
* Both arrays may be empty independently; the SQL omits the
|
|
589
|
+
* corresponding CTE and skips its placeholders. Returns the count of
|
|
590
|
+
* rows actually inserted (excludes conflicts).
|
|
591
|
+
*
|
|
592
|
+
* @example
|
|
593
|
+
* ```ts
|
|
594
|
+
* const { thingsInserted, actionsInserted } = await provider.commitBatch({
|
|
595
|
+
* things: [
|
|
596
|
+
* { type: 'Customer', id: 'c1', data: { name: 'Acme' } },
|
|
597
|
+
* { type: 'Order', id: 'o1', data: { total: 100 } },
|
|
598
|
+
* ],
|
|
599
|
+
* actions: [
|
|
600
|
+
* { id: 'a1', verb: 'placedBy', subject: 'o1', object: 'c1', status: 'completed' },
|
|
601
|
+
* ],
|
|
602
|
+
* })
|
|
603
|
+
* ```
|
|
604
|
+
*/
|
|
605
|
+
async commitBatch(input) {
|
|
606
|
+
const things = input.things ?? [];
|
|
607
|
+
const actions = input.actions ?? [];
|
|
608
|
+
if (things.length === 0 && actions.length === 0) {
|
|
609
|
+
return { thingsInserted: 0, actionsInserted: 0 };
|
|
610
|
+
}
|
|
611
|
+
// Things take 4 columns: ns, id, type, data
|
|
612
|
+
const thingCols = 4;
|
|
613
|
+
// Actions take 10 columns: ns, id, verb, subject, object, roles, data,
|
|
614
|
+
// status, created_at, completed_at
|
|
615
|
+
const actionCols = 10;
|
|
616
|
+
const thingOffset = things.length * thingCols;
|
|
617
|
+
const thingPlaceholders = things
|
|
618
|
+
.map((_, i) => {
|
|
619
|
+
const base = i * thingCols;
|
|
620
|
+
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}::jsonb)`;
|
|
621
|
+
})
|
|
622
|
+
.join(', ');
|
|
623
|
+
const actionPlaceholders = actions
|
|
624
|
+
.map((_, i) => {
|
|
625
|
+
const base = thingOffset + i * actionCols;
|
|
626
|
+
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}::jsonb, $${base + 7}::jsonb, $${base + 8}, $${base + 9}, $${base + 10})`;
|
|
627
|
+
})
|
|
628
|
+
.join(', ');
|
|
629
|
+
const now = new Date();
|
|
630
|
+
const params = [];
|
|
631
|
+
for (const t of things) {
|
|
632
|
+
params.push(this.namespace, t.id, t.type, JSON.stringify(t.data));
|
|
633
|
+
}
|
|
634
|
+
for (const a of actions) {
|
|
635
|
+
const status = a.status ?? 'pending';
|
|
636
|
+
const completedAt = status === 'completed' || status === 'failed' || status === 'cancelled'
|
|
637
|
+
? now.toISOString()
|
|
638
|
+
: null;
|
|
639
|
+
params.push(this.namespace, a.id ?? genId(), a.verb, a.subject ?? null, a.object ?? null, JSON.stringify(a.roles ?? {}), JSON.stringify(a.data ?? {}), status, now.toISOString(), completedAt);
|
|
640
|
+
}
|
|
641
|
+
const ctes = [];
|
|
642
|
+
if (things.length > 0) {
|
|
643
|
+
ctes.push(`inserted_things AS (
|
|
644
|
+
INSERT INTO ${this.schema}.things (ns, id, type, data) VALUES ${thingPlaceholders}
|
|
645
|
+
ON CONFLICT ON CONSTRAINT things_pk DO NOTHING
|
|
646
|
+
RETURNING 1
|
|
647
|
+
)`);
|
|
648
|
+
}
|
|
649
|
+
if (actions.length > 0) {
|
|
650
|
+
ctes.push(`inserted_actions AS (
|
|
651
|
+
INSERT INTO ${this.schema}.actions
|
|
652
|
+
(ns, id, verb, subject, object, roles, data, status, created_at, completed_at)
|
|
653
|
+
VALUES ${actionPlaceholders}
|
|
654
|
+
ON CONFLICT ON CONSTRAINT actions_pk DO NOTHING
|
|
655
|
+
RETURNING 1
|
|
656
|
+
)`);
|
|
657
|
+
}
|
|
658
|
+
const sqlText = `WITH ${ctes.join(', ')}
|
|
659
|
+
SELECT
|
|
660
|
+
${things.length > 0 ? `(SELECT COUNT(*) FROM inserted_things)::int` : '0'} AS things_inserted,
|
|
661
|
+
${actions.length > 0 ? `(SELECT COUNT(*) FROM inserted_actions)::int` : '0'} AS actions_inserted`;
|
|
662
|
+
const rows = await this.executor(sqlText, params);
|
|
663
|
+
const row = rows[0];
|
|
664
|
+
return {
|
|
665
|
+
thingsInserted: Number(row?.['things_inserted'] ?? 0),
|
|
666
|
+
actionsInserted: Number(row?.['actions_inserted'] ?? 0),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
// ===========================================================================
|
|
670
|
+
// Tier 3 — analytics (declared)
|
|
671
|
+
// ===========================================================================
|
|
672
|
+
/**
|
|
673
|
+
* Pass-through for ad-hoc analytical SQL. The query runs verbatim
|
|
674
|
+
* against the underlying executor; callers are responsible for
|
|
675
|
+
* parameter quoting. Use for time-series rollups, aggregations,
|
|
676
|
+
* or callers that want to reach into PG without going through the
|
|
677
|
+
* adapter's CRUD surface.
|
|
678
|
+
*/
|
|
679
|
+
async analyticsQuery(query, params) {
|
|
680
|
+
// We accept a record but currently send positional params only;
|
|
681
|
+
// callers can pre-format SQL with $1, $2 placeholders.
|
|
682
|
+
const positional = params ? Object.values(params) : [];
|
|
683
|
+
return this.executor(query, positional);
|
|
684
|
+
}
|
|
685
|
+
// ===========================================================================
|
|
686
|
+
// Tier 4 — vector search via pgvector
|
|
687
|
+
// ===========================================================================
|
|
688
|
+
/**
|
|
689
|
+
* Upsert an embedding vector for a Thing into the `embeddings` table.
|
|
690
|
+
*
|
|
691
|
+
* The embedding is stored separately from the `things` row so that
|
|
692
|
+
* Tier 1 callers don't pay the vector serialization cost when they
|
|
693
|
+
* don't need it. Callers generate the embedding upstream (out of scope
|
|
694
|
+
* for this adapter) and pass it here.
|
|
695
|
+
*
|
|
696
|
+
* @example
|
|
697
|
+
* ```ts
|
|
698
|
+
* await provider.upsertEmbedding('Document', 'doc-1', new Array(1536).fill(0))
|
|
699
|
+
* ```
|
|
700
|
+
*/
|
|
701
|
+
async upsertEmbedding(type, id, embedding) {
|
|
702
|
+
validateTypeName(type);
|
|
703
|
+
validateEntityId(id);
|
|
704
|
+
if (!Array.isArray(embedding) || embedding.length === 0) {
|
|
705
|
+
throw new Error('upsertEmbedding: embedding must be a non-empty array of numbers');
|
|
706
|
+
}
|
|
707
|
+
if (embedding.length > this.vectorDimensions) {
|
|
708
|
+
throw new Error(`upsertEmbedding: embedding length ${embedding.length} exceeds adapter vectorDimensions ${this.vectorDimensions}`);
|
|
709
|
+
}
|
|
710
|
+
const literal = embeddingLiteral(embedding);
|
|
711
|
+
await this.executor(`INSERT INTO ${this.schema}.embeddings (ns, thing_id, type, embedding)
|
|
712
|
+
VALUES ($1, $2, $3, $4::vector)
|
|
713
|
+
ON CONFLICT (ns, thing_id) DO UPDATE SET embedding = EXCLUDED.embedding, type = EXCLUDED.type`, [this.namespace, id, type, literal]);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Tier 4 — vector search via pgvector operators.
|
|
717
|
+
*
|
|
718
|
+
* Operator selection by metric:
|
|
719
|
+
* - `'cosine'` (default): `embedding <=> $vector` — cosine distance.
|
|
720
|
+
* Score returned is `1 - distance` so higher is more similar.
|
|
721
|
+
* - `'l2'`: `embedding <-> $vector` — Euclidean distance. Score is
|
|
722
|
+
* `-distance` (closer => higher score; never positive).
|
|
723
|
+
* - `'dot'`: `embedding <#> $vector` — negative inner product (pgvector
|
|
724
|
+
* convention so smaller-is-better). Score returned is `-result`
|
|
725
|
+
* (i.e. the inner product itself; higher is more similar).
|
|
726
|
+
*
|
|
727
|
+
* Frame-aware role filtering (e.g. only search Things that appear as
|
|
728
|
+
* `subject` of a particular Action) is **deferred** to a follow-up
|
|
729
|
+
* bead — refinement, not blocking for Phase 1. The current method
|
|
730
|
+
* filters by `type` only.
|
|
731
|
+
*
|
|
732
|
+
* @example
|
|
733
|
+
* ```ts
|
|
734
|
+
* const hits = await provider.vectorSearch('Document', queryVec, {
|
|
735
|
+
* metric: 'cosine',
|
|
736
|
+
* limit: 10,
|
|
737
|
+
* })
|
|
738
|
+
* ```
|
|
739
|
+
*/
|
|
740
|
+
async vectorSearch(type, queryEmbedding, options) {
|
|
741
|
+
validateTypeName(type);
|
|
742
|
+
if (!Array.isArray(queryEmbedding) || queryEmbedding.length === 0) {
|
|
743
|
+
throw new Error('vectorSearch: queryEmbedding must be a non-empty array of numbers');
|
|
744
|
+
}
|
|
745
|
+
if (queryEmbedding.length > this.vectorDimensions) {
|
|
746
|
+
throw new Error(`vectorSearch: query length ${queryEmbedding.length} exceeds adapter vectorDimensions ${this.vectorDimensions}`);
|
|
747
|
+
}
|
|
748
|
+
const metric = options?.metric ?? 'cosine';
|
|
749
|
+
const limit = Math.max(1, options?.limit ?? 10);
|
|
750
|
+
// Operator + score expression per metric. pgvector convention: all
|
|
751
|
+
// operators return "distance" where smaller is more similar; we wrap
|
|
752
|
+
// so the returned `score` is monotonically higher-is-more-similar.
|
|
753
|
+
let operator;
|
|
754
|
+
let scoreExpr;
|
|
755
|
+
switch (metric) {
|
|
756
|
+
case 'cosine':
|
|
757
|
+
operator = '<=>';
|
|
758
|
+
scoreExpr = `1 - (e.embedding <=> $2::vector)`;
|
|
759
|
+
break;
|
|
760
|
+
case 'l2':
|
|
761
|
+
operator = '<->';
|
|
762
|
+
scoreExpr = `-(e.embedding <-> $2::vector)`;
|
|
763
|
+
break;
|
|
764
|
+
case 'dot':
|
|
765
|
+
// pgvector returns negative inner product; flip back so positive
|
|
766
|
+
// dot product => positive score.
|
|
767
|
+
operator = '<#>';
|
|
768
|
+
scoreExpr = `-(e.embedding <#> $2::vector)`;
|
|
769
|
+
break;
|
|
770
|
+
case 'hamming':
|
|
771
|
+
throw new Error('vectorSearch: pgvector adapter does not support hamming metric');
|
|
772
|
+
default:
|
|
773
|
+
throw new Error(`vectorSearch: unsupported metric "${String(metric)}"`);
|
|
774
|
+
}
|
|
775
|
+
const literal = embeddingLiteral(queryEmbedding);
|
|
776
|
+
const sql = `SELECT t.id AS id, t.type AS type, t.data AS data, ${scoreExpr} AS score
|
|
777
|
+
FROM ${this.schema}.embeddings e
|
|
778
|
+
JOIN ${this.schema}.things t ON t.ns = e.ns AND t.id = e.thing_id
|
|
779
|
+
WHERE e.ns = $1 AND t.type = $3
|
|
780
|
+
ORDER BY e.embedding ${operator} $2::vector
|
|
781
|
+
LIMIT $4`;
|
|
782
|
+
const rows = await this.executor(sql, [this.namespace, literal, type, limit]);
|
|
783
|
+
let hits = rows.map((row) => {
|
|
784
|
+
const data = asJsonb(row['data']);
|
|
785
|
+
const score = typeof row['score'] === 'number' ? row['score'] : Number(row['score'] ?? 0);
|
|
786
|
+
return {
|
|
787
|
+
entity: { ...data, $id: String(row['id']), $type: String(row['type']) },
|
|
788
|
+
score,
|
|
789
|
+
};
|
|
790
|
+
});
|
|
791
|
+
if (options?.minScore !== undefined) {
|
|
792
|
+
const min = options.minScore;
|
|
793
|
+
hits = hits.filter((h) => h.score >= min);
|
|
794
|
+
}
|
|
795
|
+
return hits;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Driver / connection metadata for diagnostics.
|
|
799
|
+
*/
|
|
800
|
+
describe() {
|
|
801
|
+
return {
|
|
802
|
+
adapter: 'pg+pgvector',
|
|
803
|
+
driver: this.driver,
|
|
804
|
+
namespace: this.namespace,
|
|
805
|
+
schema: this.schema,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// =============================================================================
|
|
810
|
+
// Schema bootstrap
|
|
811
|
+
// =============================================================================
|
|
812
|
+
/**
|
|
813
|
+
* Run the canonical DDL against an executor. Idempotent — uses
|
|
814
|
+
* `IF NOT EXISTS` throughout, including the `vector` extension. Skip the
|
|
815
|
+
* `vector` extension installation if your database does not have
|
|
816
|
+
* pgvector available (set `withVector: false`).
|
|
817
|
+
*
|
|
818
|
+
* Designed for one-shot cluster bootstrap and for PGLite-style test
|
|
819
|
+
* harnesses. Production deployments typically run schema migrations via
|
|
820
|
+
* a tool like `dbmate` or `node-pg-migrate`; this helper is a
|
|
821
|
+
* convenience, not a migration framework.
|
|
822
|
+
*/
|
|
823
|
+
export async function bootstrapSchema(executor, options = {}) {
|
|
824
|
+
const schema = options.schema ?? DEFAULT_SCHEMA;
|
|
825
|
+
const withVector = options.withVector ?? true;
|
|
826
|
+
const dims = options.vectorDimensions ?? DEFAULT_VECTOR_DIMS;
|
|
827
|
+
if (withVector) {
|
|
828
|
+
try {
|
|
829
|
+
await executor(`CREATE EXTENSION IF NOT EXISTS vector`);
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
// Extension may not be available; downstream vector queries will
|
|
833
|
+
// fail clearly. We swallow here to keep bootstrap idempotent.
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
await executor(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
|
|
837
|
+
await executor(`CREATE TABLE IF NOT EXISTS ${schema}.things (
|
|
838
|
+
ns text NOT NULL,
|
|
839
|
+
id text NOT NULL,
|
|
840
|
+
type text NOT NULL,
|
|
841
|
+
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
842
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
843
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
844
|
+
CONSTRAINT things_pk PRIMARY KEY (ns, id)
|
|
845
|
+
)`);
|
|
846
|
+
await executor(`CREATE INDEX IF NOT EXISTS things_type_idx ON ${schema}.things (ns, type)`);
|
|
847
|
+
await executor(`CREATE TABLE IF NOT EXISTS ${schema}.actions (
|
|
848
|
+
ns text NOT NULL,
|
|
849
|
+
id text NOT NULL,
|
|
850
|
+
verb text NOT NULL,
|
|
851
|
+
subject text,
|
|
852
|
+
object text,
|
|
853
|
+
roles jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
854
|
+
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
855
|
+
status text NOT NULL DEFAULT 'pending',
|
|
856
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
857
|
+
completed_at timestamptz,
|
|
858
|
+
CONSTRAINT actions_pk PRIMARY KEY (ns, id)
|
|
859
|
+
)`);
|
|
860
|
+
await executor(`CREATE INDEX IF NOT EXISTS actions_verb_idx ON ${schema}.actions (ns, verb)`);
|
|
861
|
+
await executor(`CREATE INDEX IF NOT EXISTS actions_subject_idx ON ${schema}.actions (ns, subject)`);
|
|
862
|
+
await executor(`CREATE INDEX IF NOT EXISTS actions_object_idx ON ${schema}.actions (ns, object)`);
|
|
863
|
+
await executor(`CREATE TABLE IF NOT EXISTS ${schema}.verbs (
|
|
864
|
+
name text PRIMARY KEY,
|
|
865
|
+
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
866
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
867
|
+
)`);
|
|
868
|
+
if (withVector) {
|
|
869
|
+
try {
|
|
870
|
+
await executor(`CREATE TABLE IF NOT EXISTS ${schema}.embeddings (
|
|
871
|
+
ns text NOT NULL,
|
|
872
|
+
thing_id text NOT NULL,
|
|
873
|
+
type text NOT NULL DEFAULT '',
|
|
874
|
+
embedding vector(${dims}),
|
|
875
|
+
PRIMARY KEY (ns, thing_id)
|
|
876
|
+
)`);
|
|
877
|
+
// Best-effort: add `type` column to existing embeddings tables that
|
|
878
|
+
// were created before vector search shipped. Idempotent via
|
|
879
|
+
// IF NOT EXISTS (Postgres 9.6+).
|
|
880
|
+
await executor(`ALTER TABLE ${schema}.embeddings ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT ''`);
|
|
881
|
+
await executor(`CREATE INDEX IF NOT EXISTS embeddings_ns_type_idx ON ${schema}.embeddings (ns, type)`);
|
|
882
|
+
// ANN index — ivfflat is the broadly-available default; HNSW is
|
|
883
|
+
// pgvector 0.5+. We try HNSW first (better recall/latency) and
|
|
884
|
+
// fall back to ivfflat. Both are best-effort.
|
|
885
|
+
try {
|
|
886
|
+
await executor(`CREATE INDEX IF NOT EXISTS embeddings_hnsw_cosine_idx
|
|
887
|
+
ON ${schema}.embeddings USING hnsw (embedding vector_cosine_ops)`);
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
// pgvector < 0.5 doesn't have HNSW; skip silently.
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
// Vector extension not present; the embeddings table is optional.
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// Hint for the unused-var linter on `_` destructure pattern downstream.
|
|
898
|
+
void true;
|
|
899
|
+
}
|
|
900
|
+
// =============================================================================
|
|
901
|
+
// Factory
|
|
902
|
+
// =============================================================================
|
|
903
|
+
/**
|
|
904
|
+
* Convenience factory for {@link PostgresProvider}.
|
|
905
|
+
*
|
|
906
|
+
* @example
|
|
907
|
+
* ```ts
|
|
908
|
+
* import { neon } from '@neondatabase/serverless'
|
|
909
|
+
* import { createPostgresProvider, createNeonHttpExecutor } from 'ai-database'
|
|
910
|
+
*
|
|
911
|
+
* const sql = neon(env.DATABASE_URL)
|
|
912
|
+
* const provider = createPostgresProvider({
|
|
913
|
+
* executor: createNeonHttpExecutor(sql),
|
|
914
|
+
* namespace: 'tenant-9',
|
|
915
|
+
* })
|
|
916
|
+
* ```
|
|
917
|
+
*/
|
|
918
|
+
export function createPostgresProvider(options) {
|
|
919
|
+
return new PostgresProvider(options);
|
|
920
|
+
}
|
|
921
|
+
//# sourceMappingURL=pg-adapter.js.map
|