@stackbone/sdk 0.1.0-alpha.2 → 0.1.0-alpha.3
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 +275 -1
- package/README.md +31 -97
- package/db/testing/index.cjs +12 -43
- package/db/testing/index.cjs.map +1 -1
- package/db/testing/index.d.cts +12 -21
- package/db/testing/index.d.ts +12 -21
- package/db/testing/index.js +12 -43
- package/db/testing/index.js.map +1 -1
- package/index.cjs +3849 -19181
- package/index.cjs.map +1 -1
- package/index.d.cts +1466 -878
- package/index.d.ts +1466 -878
- package/index.js +3833 -19170
- package/index.js.map +1 -1
- package/observability/index.cjs +596 -0
- package/observability/index.cjs.map +1 -0
- package/observability/index.d.cts +175 -0
- package/observability/index.d.ts +175 -0
- package/observability/index.js +587 -0
- package/observability/index.js.map +1 -0
- package/package.json +14 -2
- package/rag/migrations/index.cjs +1 -1
- package/rag/migrations/index.cjs.map +1 -1
- package/rag/migrations/index.js +1 -1
- package/rag/migrations/index.js.map +1 -1
- package/rag/schema.cjs +1 -1
- package/rag/schema.cjs.map +1 -1
- package/rag/schema.js +1 -1
- package/rag/schema.js.map +1 -1
- package/stackbone-sdk-0.1.0-alpha.3.tgz +0 -0
- package/stackbone-sdk-0.1.0-alpha.2.tgz +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,280 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- `ResolvedConfig` is now an opaque, typed snapshot — the live `env`
|
|
13
|
+
channel is gone. Every consumer used to read environment variables with
|
|
14
|
+
the pattern `resolved.config.fieldX ?? resolved.env['FIELDX']`, which
|
|
15
|
+
spread two pieces of knowledge across ~20 call sites: the exact env-var
|
|
16
|
+
name and the precedence rule. The resolver now owns both. After this
|
|
17
|
+
change, modules read typed fields like `resolved.stackboneApiUrl`,
|
|
18
|
+
`resolved.s3Bucket`, `resolved.databaseUrl`, `resolved.requireContract`,
|
|
19
|
+
etc. — none of them touch `process.env` directly.
|
|
20
|
+
|
|
21
|
+
The resolver is the **single** seam that reads `process.env` in the
|
|
22
|
+
library. New env-driven settings go in two places: a typed field on
|
|
23
|
+
`ResolvedConfig` and the matching `config.xxx ?? env['XXX']` (or parsing
|
|
24
|
+
logic) inside `resolveConfig`. The compiler then flags every consumer
|
|
25
|
+
that needs the new field.
|
|
26
|
+
|
|
27
|
+
Runtime knobs previously read inline from `contract/contract-handshake.ts`
|
|
28
|
+
(`STACKBONE_REQUIRE_CONTRACT`, `STACKBONE_CONTRACT_TTL_MS`,
|
|
29
|
+
`STACKBONE_DEBUG`) are now resolved up-front and threaded into the per-
|
|
30
|
+
client `ContractStore`. The database singleton in
|
|
31
|
+
`surfaces/agent-local/database/shared-handle.ts` takes an explicit
|
|
32
|
+
`connectionString` parameter that `DatabaseModule` pulls off
|
|
33
|
+
`resolved.databaseUrl`; the only remaining read of
|
|
34
|
+
`STACKBONE_POSTGRES_URL` is inside `resolveConfig`.
|
|
35
|
+
|
|
36
|
+
Booleans (`STACKBONE_DEBUG=1`) and numbers (`STACKBONE_CONTRACT_TTL_MS=30000`)
|
|
37
|
+
get parsed at resolve time, so consumers receive `boolean` / `number`,
|
|
38
|
+
not raw strings — one parse, one place, no drift.
|
|
39
|
+
|
|
40
|
+
Behavioural change worth flagging: `ResolvedConfig` is a snapshot, not
|
|
41
|
+
a live view. Tests that mutated `process.env` AFTER `createClient()` to
|
|
42
|
+
exercise late-binding behaviour now need to rebuild the client (or pass
|
|
43
|
+
the new value through the config object). The new `src/config.spec.ts`
|
|
44
|
+
pins both the snapshot semantics and the deletion contract — `.env` is
|
|
45
|
+
no longer a property on `ResolvedConfig`, and reading it is both a type
|
|
46
|
+
error and a runtime `undefined`. Two integration / handshake tests
|
|
47
|
+
(`STACKBONE_REQUIRE_CONTRACT=0`, `STACKBONE_CONTRACT_TTL_MS`,
|
|
48
|
+
`STACKBONE_DEBUG=1`) now construct a fresh `ContractStore` per env-var
|
|
49
|
+
variation to keep the resolve-once contract explicit.
|
|
50
|
+
|
|
51
|
+
- `SdkError.code` is now a typed catalog (`SdkErrorCode`) declared in one
|
|
52
|
+
place — `src/errors/codes.ts`. Every literal `code` value that ships on a
|
|
53
|
+
`Result<T>` envelope (and the `errorMapping.prefix` slot on the transport)
|
|
54
|
+
is now compiler-checked against the catalog, so a future PR that adds
|
|
55
|
+
`secrets_not_foud` (typo) or `ai_provdier_down` (typo) fails to build
|
|
56
|
+
instead of silently shipping past the README contract. The catalog also
|
|
57
|
+
ties `HttpClient`'s `<prefix>_<reason>` projection to `SdkErrorPrefix`, so
|
|
58
|
+
a surface that wires `errorMapping: { prefix: '<unknown>' }` no longer
|
|
59
|
+
compiles. The wire-level shape of every code is unchanged: this is a
|
|
60
|
+
type-level constraint, not a rename.
|
|
61
|
+
|
|
62
|
+
Adding a new code is a single-file edit in `src/errors/codes.ts` (either
|
|
63
|
+
a new suffix under an existing prefix, or a freestanding code under
|
|
64
|
+
`SDK_ERROR_CODES_STANDALONE`). The compiler then flags every call site
|
|
65
|
+
that needs the new literal — and the new exhaustiveness test in
|
|
66
|
+
`src/lib/http-client.spec.ts` asserts every `<prefix>_<reason>` the
|
|
67
|
+
transport can emit is still a member of the catalog so README/code drift
|
|
68
|
+
fails loudly.
|
|
69
|
+
|
|
70
|
+
Two new symbols on the public barrel (`@stackbone/sdk`):
|
|
71
|
+
- `SdkErrorCode` — the union projection of the catalog. Use it as the
|
|
72
|
+
return type when you write a helper that produces an `SdkError`, or
|
|
73
|
+
as a discriminant in a `switch` over `result.error.code`.
|
|
74
|
+
- `isSdkErrorCode(raw: string)` — runtime narrowing for wire strings
|
|
75
|
+
that come from a decoded body. Returns `true` only when `raw` is a
|
|
76
|
+
catalog member; lets the SDK widen upstream errors safely.
|
|
77
|
+
|
|
78
|
+
RAG's `toRagError(cause, message, fallbackCode)` helper now constrains
|
|
79
|
+
`fallbackCode` to `SdkErrorCode` and checks `cause.code` against the
|
|
80
|
+
catalog before reusing it. The catalog and the helper are not folded
|
|
81
|
+
into one file — the catalog is the inventory, `toRagError` stays in the
|
|
82
|
+
RAG folder as a Postgres-tagged-error classifier.
|
|
83
|
+
|
|
84
|
+
- Contract gating membership is now an explicit rule, and the handshake
|
|
85
|
+
cache no longer lives in a process-wide static. Two things changed:
|
|
86
|
+
1. **Membership rule (Option B from the audit)** — a surface gates iff
|
|
87
|
+
`MODULE_CAPABILITIES` (in `@stackbone/validators`) advertises a row
|
|
88
|
+
for its module id. `MODULE_CAPABILITIES` is now the single source of
|
|
89
|
+
truth: adding a surface to gating means adding a `Capability` and a
|
|
90
|
+
`<module>: '<capability>'` row first, then wiring
|
|
91
|
+
`createModuleGate('<module>', resolved, store)` in the surface
|
|
92
|
+
constructor. The new docstring at the top of
|
|
93
|
+
`contract/capability-registry.ts` spells out the rule and the steps.
|
|
94
|
+
Today's gated set stays the same (`database`, `rag`, `queues`,
|
|
95
|
+
`events`, `secrets`, `config`, `approval`, `storage`, `ai`). The
|
|
96
|
+
four ungated surfaces (`memory`, `connections`, `prompts`,
|
|
97
|
+
`observability`) now carry a one-paragraph comment on their class
|
|
98
|
+
header documenting **why** they are not gated — either the runtime
|
|
99
|
+
does not yet exist with a defined capability (`memory`,
|
|
100
|
+
`connections`, `prompts`) or the surface stays local / talks to
|
|
101
|
+
OpenTelemetry, not the Stackbone Agent Protocol (`observability`).
|
|
102
|
+
|
|
103
|
+
2. **Per-client handshake store** — the `ContractHandshake` static
|
|
104
|
+
namespace is gone. In its place: `createContractStore()` returns a
|
|
105
|
+
`ContractStore` with three methods (`get`, `peek`, `gatingEnabled`)
|
|
106
|
+
that owns its own cache, single-flight map, TTL eviction and debug
|
|
107
|
+
bookkeeping. `StackboneClient` instantiates exactly one store per
|
|
108
|
+
instance and threads it through every gated surface via
|
|
109
|
+
`createModuleGate(module, resolved, store)`. Two clients
|
|
110
|
+
constructed in the same process no longer share handshake cache or
|
|
111
|
+
suppression-warning state — including `client.contract`, which now
|
|
112
|
+
reads from the per-client store's `peek()`.
|
|
113
|
+
|
|
114
|
+
Removed test globals (the visible sign that the cache had escaped its
|
|
115
|
+
owner): `ContractHandshake.__resetForTests` and
|
|
116
|
+
`__resetModuleGateWarningsForTests`. Specs that previously called them
|
|
117
|
+
in `beforeEach`/`afterEach` now simply construct a fresh
|
|
118
|
+
`createContractStore()` (or, for client-level coverage, a fresh
|
|
119
|
+
`createClient(...)`); state isolation is intrinsic to construction.
|
|
120
|
+
`module-gate.spec.ts` gained two coverage tests for the new shape:
|
|
121
|
+
"two gates pinned to the same store share suppression-warning state"
|
|
122
|
+
and "two gates pinned to different stores warn independently". The E2E
|
|
123
|
+
suite (`integration/handshake.spec.ts`) loses its global-reset
|
|
124
|
+
bookkeeping entirely.
|
|
125
|
+
|
|
126
|
+
Wire protocol is unchanged: `GET /api/contract` returns the same JSON
|
|
127
|
+
shape, `client.contract` exposes the same read-only `ContractResponse |
|
|
128
|
+
null` getter, gate-blocked calls still return the same `Result`
|
|
129
|
+
envelope with `capability_unavailable` / `contract_version_unsupported`
|
|
130
|
+
/ `contract_unreachable` codes, and the `STACKBONE_REQUIRE_CONTRACT=0`
|
|
131
|
+
/ `STACKBONE_CONTRACT_TTL_MS` / `STACKBONE_DEBUG=1` escape hatches
|
|
132
|
+
behave identically. Surface constructors keep the same `gate?:
|
|
133
|
+
ModuleGate` test seam — only the internal default (now backed by a
|
|
134
|
+
per-client store instead of a static singleton) changed.
|
|
135
|
+
|
|
136
|
+
- The cross-surface shared-handle seam is now an explicit, documented
|
|
137
|
+
pattern on the public surfaces. Two surfaces produce a reusable
|
|
138
|
+
resource and expose it through a canonical accessor:
|
|
139
|
+
- `client.database.shared(): DrizzleClient` — process-wide Drizzle
|
|
140
|
+
handle bound to `STACKBONE_POSTGRES_URL`. Same input → same
|
|
141
|
+
instance. `client.database.raw()` stays as the creator-facing
|
|
142
|
+
escape hatch and is now documented as an alias of `shared()`.
|
|
143
|
+
- `client.ai.shared(): Result<OpenAI>` — process-wide OpenRouter
|
|
144
|
+
`OpenAI` client used by every namespace under `client.ai`. Same
|
|
145
|
+
input → same instance; reflects the `clientOverride` injected at
|
|
146
|
+
construction time. The previous private `client()` accessor folded
|
|
147
|
+
into this public name.
|
|
148
|
+
|
|
149
|
+
These accessors are the **only** way cross-surface SDK consumers
|
|
150
|
+
should reach the shared pool / OpenAI client. RAG already consumed
|
|
151
|
+
the database pool via a module-private file (`database/internal.ts`)
|
|
152
|
+
— RAG now reaches it through `client.database.shared().$client`
|
|
153
|
+
instead. The renamed `database/shared-handle.ts` keeps the singleton
|
|
154
|
+
implementation but has zero sibling-relative importers outside its
|
|
155
|
+
own folder and its own spec; cross-surface friction (memory and
|
|
156
|
+
queues will need the same handles per the consolidation ADRs) now
|
|
157
|
+
has one named extension point instead of a per-consumer escape
|
|
158
|
+
hatch.
|
|
159
|
+
|
|
160
|
+
Public API of `client.database`, `client.ai`, and `client.rag` is
|
|
161
|
+
unchanged — method signatures and `Result` envelopes match the
|
|
162
|
+
previous release. `RagModule` constructor adds a
|
|
163
|
+
`() => DatabaseModule` accessor alongside the existing
|
|
164
|
+
`() => AiModule`, mirroring the wiring `client.ts` produces; tests
|
|
165
|
+
pass both verbatim.
|
|
166
|
+
|
|
167
|
+
- **BREAKING** — Control-plane facades (`client.secrets`, `client.config`,
|
|
168
|
+
`client.approval`) now surface HTTP failures with surface-specific error
|
|
169
|
+
codes instead of the generic `http_*` family. The transport (`HttpClient`)
|
|
170
|
+
absorbs response validation, status→domain remapping, and querystring
|
|
171
|
+
serialisation, so a facade method now reads as "POST to /path with body X,
|
|
172
|
+
validate against schema Y, error prefix Z" instead of a 30–80 line
|
|
173
|
+
hand-rolled pipeline per surface. Migration is mechanical: pattern-match
|
|
174
|
+
the surface prefix instead of `http_*`.
|
|
175
|
+
- 401 → `<prefix>_unauthorized` (was `http_unauthorized`).
|
|
176
|
+
- 403 → `<prefix>_forbidden` (was `http_forbidden`).
|
|
177
|
+
- 404 → `<prefix>_not_found` (was `http_not_found`).
|
|
178
|
+
- 408 → `<prefix>_timeout` (was `http_timeout`).
|
|
179
|
+
- 429 → `<prefix>_rate_limited` (was `http_rate_limited`).
|
|
180
|
+
- 5xx → `<prefix>_unavailable` (was `http_server_error`).
|
|
181
|
+
- other 4xx → `<prefix>_invalid_request` (was `http_client_error`).
|
|
182
|
+
- schema-drift on a 2xx body → `<prefix>_invalid_response`.
|
|
183
|
+
|
|
184
|
+
The raw HTTP status stays accessible at `result.error.meta.status` for
|
|
185
|
+
callers that still want to pattern-match on the wire. Endpoint-specific
|
|
186
|
+
overrides (e.g. a POST whose `409` means "already exists") are wired by
|
|
187
|
+
facades via the new `errorOverrides` option on the transport request.
|
|
188
|
+
|
|
189
|
+
Transport-level errors (`http_timeout`, `http_aborted`,
|
|
190
|
+
`http_network_error`, `http_parse_error`, `http_request_failed`,
|
|
191
|
+
`http_invalid_response`, `http_invalid_request`) keep the `http_*`
|
|
192
|
+
prefix the README documents — they are not surface-specific.
|
|
193
|
+
|
|
194
|
+
Internal cleanup: `src/lib/facade-helpers.ts` is gone (the two utilities
|
|
195
|
+
it held — `remapHttpNotFound` and `validateStringArray` — were either
|
|
196
|
+
absorbed by the transport or relocated to `lib/http-client.ts` as a
|
|
197
|
+
named export). The per-method bodies on the control-plane facades
|
|
198
|
+
shrank substantially: `secrets.get` and `config.get` lost their
|
|
199
|
+
hand-rolled `value` type-checks and remap blocks, batch endpoints
|
|
200
|
+
pushed array validation + comma-join down to the transport, and the
|
|
201
|
+
approval list endpoint dropped its `pickDefinedAsStrings` helper for
|
|
202
|
+
the transport's built-in scalar coercion. File-level line counts barely
|
|
203
|
+
move because the public type definitions dominate each facade (notably
|
|
204
|
+
on `approval.ts` where 80+ lines are interface exports), but the
|
|
205
|
+
per-method logic now reads like the design target: "POST to /path,
|
|
206
|
+
validate against schema, error prefix is X".
|
|
207
|
+
|
|
208
|
+
- **BREAKING** — Platform-side observability primitives moved off the main
|
|
209
|
+
`@stackbone/sdk` barrel and into a dedicated subpath entrypoint,
|
|
210
|
+
`@stackbone/sdk/observability`. The main barrel previously exposed both the
|
|
211
|
+
handler-scoped `createStructuredLogger` (the per-invocation `Logger` the
|
|
212
|
+
runtime injects into creator handlers) and the platform/run-scoped
|
|
213
|
+
`createPlatformLogger` factory side-by-side, with no signal about which
|
|
214
|
+
belonged to which audience. Creators only ever consume the run-scoped
|
|
215
|
+
surface through `client.observability.*` (the live accessors stay on
|
|
216
|
+
`StackboneClient` unchanged); only platform/runtime tooling and integration
|
|
217
|
+
tests need to construct the primitives directly. The symbols that moved:
|
|
218
|
+
- Per-run logger — `createPlatformLogger`, `LOG_LEVELS`, `defaultRunsDir`,
|
|
219
|
+
plus the types `LogLevelName`, `LogLevelNumber`, `LogRecord`, `PinoLike`,
|
|
220
|
+
`PlatformLogger`, `PlatformLoggerMode`, `PlatformLoggerOptions`.
|
|
221
|
+
- OTel span processor — `RunStepsSpanProcessor`, `RUN_STEP_TYPES`,
|
|
222
|
+
`RUN_ID_ATTRIBUTE`, `STEP_TYPE_ATTRIBUTE`, plus the types
|
|
223
|
+
`ReadableSpanLike`, `RunStepType`, `RunStepsSpanProcessorOptions`,
|
|
224
|
+
`SpanContextLike`, `PostgresLike`.
|
|
225
|
+
- Run-cost aggregator — `aggregateRunCost`, plus the types
|
|
226
|
+
`AggregateRunCostOptions`, `AggregateRunCostResult`.
|
|
227
|
+
|
|
228
|
+
Migration is mechanical:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
// Before
|
|
232
|
+
import { createPlatformLogger, RunStepsSpanProcessor } from '@stackbone/sdk';
|
|
233
|
+
// After
|
|
234
|
+
import { createPlatformLogger, RunStepsSpanProcessor } from '@stackbone/sdk/observability';
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The handler-scoped `createStructuredLogger`, `Logger`, `LoggerBindings`,
|
|
238
|
+
`LogSink` and `StructuredLoggerOptions` stay on the main `@stackbone/sdk`
|
|
239
|
+
barrel: those are the surface the runtime wrapper hands to creator
|
|
240
|
+
handlers via `InvokeContext.logger`. JSONL output shape, OTLP/HTTP/JSON
|
|
241
|
+
serialisation and `~/.stackbone/dev/runs/<runId>.jsonl` paths are
|
|
242
|
+
unchanged — only the import path moved.
|
|
243
|
+
|
|
244
|
+
- **BREAKING** — SDK source tree reorganised under `src/surfaces/` by **where
|
|
245
|
+
each surface's state lives** instead of "wraps SDK vs makes HTTP fetch". The
|
|
246
|
+
old `src/modules/` and `src/facades/` directories are gone. New layout:
|
|
247
|
+
- `src/surfaces/agent-local/` — state in the agent's own Postgres
|
|
248
|
+
(`database`, `rag`).
|
|
249
|
+
- `src/surfaces/external/` — managed partner SDKs the SDK wraps directly
|
|
250
|
+
(`ai`, `storage`, `observability`).
|
|
251
|
+
- `src/surfaces/control-plane/` — thin HTTP calls to the Stackbone control
|
|
252
|
+
plane (`approval`, `secrets`, `config`).
|
|
253
|
+
- `src/surfaces/pending/` — surface types are exported but the runtime is
|
|
254
|
+
not built yet (`queues`, `memory`, `prompts`, `connections`, `events`).
|
|
255
|
+
|
|
256
|
+
Subpath entrypoints (`@stackbone/sdk/db`, `@stackbone/sdk/db/testing`,
|
|
257
|
+
`@stackbone/sdk/queues/types`, `@stackbone/sdk/rag/migrations`,
|
|
258
|
+
`@stackbone/sdk/rag/schema`) keep working — only the underlying file
|
|
259
|
+
paths moved.
|
|
260
|
+
|
|
261
|
+
- The five pending surfaces (`queues`, `memory`, `prompts`, `connections`,
|
|
262
|
+
`events`) now live under `src/surfaces/pending/` and are exposed as live
|
|
263
|
+
`client.X` accessors that return `not_implemented` on every method. The
|
|
264
|
+
earlier draft of this refactor dropped the accessors entirely, but MVP
|
|
265
|
+
agent code needs to be authored against the eventual contract today and
|
|
266
|
+
switch over the day the backing runtime ships — keeping the getters as
|
|
267
|
+
placeholders makes that one-line change instead of a refactor. Public
|
|
268
|
+
types continue to ship from `@stackbone/sdk` (`PublishRequest`,
|
|
269
|
+
`MemoryItem`, `MemoryHit`, `Prompt`, `CreatePromptRequest`, …) so
|
|
270
|
+
creators can keep modelling integrations. `queues` and `events` go
|
|
271
|
+
through the contract gate (they are listed in `MODULE_CAPABILITIES`);
|
|
272
|
+
`memory`, `prompts` and `connections` are intentionally ungated.
|
|
273
|
+
|
|
274
|
+
- **BREAKING** — `@stackbone/sdk/db/testing` `createTestDatabase` now spins up
|
|
275
|
+
a real Postgres container via `@testcontainers/postgresql` instead of
|
|
276
|
+
carving a random schema out of a shared dev DB. Callers no longer pass a
|
|
277
|
+
`connectionString`; pass an optional `image` (defaults to
|
|
278
|
+
`pgvector/pgvector:pg17`) if you need extensions beyond pgvector. Tests are
|
|
279
|
+
self-contained: no `docker compose up` ceremony, no `STACKBONE_*_POSTGRES_URL`
|
|
280
|
+
env, no schema bookkeeping. Aligns the SDK helper with the rest of the
|
|
281
|
+
monorepo (see `apps/api/src/db/__tests__/setup-postgres.ts`). Docker must
|
|
282
|
+
be running on the host.
|
|
283
|
+
|
|
10
284
|
## [0.1.0-alpha.2] - 2026-05-18
|
|
11
285
|
|
|
12
286
|
### Fixed
|
|
@@ -14,7 +288,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
14
288
|
- `client.rag.ingest` (batched mode, e.g. when an `onProgress` callback or a
|
|
15
289
|
`jobWriter` is wired): every chunk batch was wrapping its INSERT in a
|
|
16
290
|
transaction that first ran `DELETE FROM rag_chunks WHERE namespace=? AND
|
|
17
|
-
|
|
291
|
+
doc_id=?`. With N batches per document, only the rows inserted by the LAST
|
|
18
292
|
batch survived — every earlier slice was wiped by the next batch's DELETE.
|
|
19
293
|
Split the DELETE into a single `clearDocument` call before the loop;
|
|
20
294
|
`ingestDocument` now only INSERTs its slice.
|
package/README.md
CHANGED
|
@@ -13,15 +13,12 @@ Official TypeScript SDK for [Stackbone](https://stackbone.ai) — the marketplac
|
|
|
13
13
|
- **Database** — Direct Postgres access through a lazy Drizzle ORM wrapper bound to the agent's connection string, with full type-safe queries, transactions, and `sql` template literals — Drizzle is re-exported via `@stackbone/sdk/db` so the agent's `package.json` only depends on `@stackbone/sdk`
|
|
14
14
|
- **Storage** — S3-compatible object storage (Cloudflare R2 in prod, MinIO in dev) with automatic per-agent key prefixing and signed URLs
|
|
15
15
|
- **AI** — Wrapper around the official `openai` SDK pointed at OpenRouter, so 300+ chat, embedding and image models are reachable through a single OpenAI-compatible API
|
|
16
|
-
- **Queues** _(coming soon)_ — Cross-container HTTP push via QStash, with typed job payloads through module augmentation
|
|
17
16
|
- **RAG** — Document parsing, chunking and `pgvector`-backed retrieval through a flat, ceremony-free API
|
|
18
|
-
- **Memory** _(surface defined; runtime pending)_ — Long-term agent memory backed by mem0: ingest text or whole conversations, semantic search, per-user / per-session / per-agent scoping, GDPR delete-all, audit history and session lifecycle hooks
|
|
19
|
-
- **Prompts** _(surface defined; runtime pending)_ — Managed prompts stored in the Stackbone control plane: fetch by name with optional version pinning, compile Mustache `{{var}}` templates against a variables map, and full CRUD so prompts can evolve without rebuilding the container
|
|
20
17
|
- **Observability** — OpenTelemetry span processor + run-cost aggregator + per-run logger
|
|
21
18
|
- **Approval** — Human-in-the-loop inbox: fire-and-forget approval requests, HMAC-signed decision callbacks, and an LLM tool wrapper that gates execution behind human review
|
|
22
19
|
- **Secrets** — Read organization-encrypted secrets registered in the dashboard, no client-side cache so rotations propagate immediately
|
|
23
20
|
- **Config** — Typed reads of dynamic per-agent configuration the user set in the dashboard
|
|
24
|
-
- **
|
|
21
|
+
- **Coming soon (types only)** — `queues` (cross-container HTTP push via QStash), `memory` (mem0-backed long-term memory), `prompts` (managed prompts with `{{var}}` templates), `connections` (OAuth integrations) and `events` (org-wide event bus). Public types are exported from `@stackbone/sdk` today; the runtime is not wired yet, so there is no live `client.X` accessor.
|
|
25
22
|
- **TypeScript-first** — Full type definitions and a uniform `Result<T>` envelope on every method (no thrown errors at the SDK boundary)
|
|
26
23
|
- **Lazy initialization** — Modules and partner SDKs are constructed on first access, so env vars rotated by the control plane after `createClient()` are still picked up
|
|
27
24
|
|
|
@@ -470,95 +467,26 @@ const { data: cfg } = await stackbone.config.getMany<{
|
|
|
470
467
|
|
|
471
468
|
`secrets.get` returns `secrets_not_found` (with the name in `meta`) when the secret is missing; `config.get` returns `config_not_found` symmetrically. Both `getMany` calls return whatever subset the organization has registered — no error for missing entries.
|
|
472
469
|
|
|
473
|
-
### Memory
|
|
470
|
+
### Memory (pending)
|
|
474
471
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
Memories are scoped along three axes:
|
|
478
|
-
|
|
479
|
-
- `'user'` — long-term, persists across sessions of a given end user. Default.
|
|
480
|
-
- `'session'` — short-lived; collapsed (or dropped) when `endSession()` runs.
|
|
481
|
-
- `'agent'` — shared across every user of the agent (preferences, global knowledge).
|
|
482
|
-
|
|
483
|
-
```ts
|
|
484
|
-
// Ingest a fact (raw text or an OpenAI-shaped conversation).
|
|
485
|
-
const { data: memory } = await stackbone.memory.add(
|
|
486
|
-
'The user prefers replies in Spanish and addresses them by their last name.',
|
|
487
|
-
{ userId: 'user_42', scope: 'user', metadata: { source: 'onboarding' } },
|
|
488
|
-
);
|
|
489
|
-
|
|
490
|
-
// Or ingest a whole conversation — the backend summarises it into facts.
|
|
491
|
-
await stackbone.memory.add(
|
|
492
|
-
[
|
|
493
|
-
{ role: 'user', content: 'Call me Mr. Torres please.' },
|
|
494
|
-
{ role: 'assistant', content: 'Got it, Mr. Torres.' },
|
|
495
|
-
],
|
|
496
|
-
{ userId: 'user_42', sessionId: 'sess_abc', scope: 'session' },
|
|
497
|
-
);
|
|
498
|
-
|
|
499
|
-
// Semantic search across the user's memories.
|
|
500
|
-
const { data: hits } = await stackbone.memory.search('how should I address this user?', {
|
|
501
|
-
userId: 'user_42',
|
|
502
|
-
limit: 5,
|
|
503
|
-
threshold: 0.7,
|
|
504
|
-
includeScopes: ['user', 'agent'],
|
|
505
|
-
});
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
Management — read, paginate, edit and audit a single fact:
|
|
509
|
-
|
|
510
|
-
```ts
|
|
511
|
-
const { data: memory } = await stackbone.memory.get(memoryId);
|
|
512
|
-
|
|
513
|
-
const { data: page } = await stackbone.memory.list({
|
|
514
|
-
userId: 'user_42',
|
|
515
|
-
limit: 50,
|
|
516
|
-
cursor: previousCursor,
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
await stackbone.memory.update(memoryId, {
|
|
520
|
-
content: 'The user prefers replies in Spanish.',
|
|
521
|
-
metadata: { reviewed: true },
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
const { data: events } = await stackbone.memory.history(memoryId);
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
Deletion — single fact, all data for a user (GDPR), or end-of-session collapse:
|
|
528
|
-
|
|
529
|
-
```ts
|
|
530
|
-
// Forget one fact.
|
|
531
|
-
await stackbone.memory.delete(memoryId);
|
|
532
|
-
|
|
533
|
-
// GDPR: forget everything we have on this user.
|
|
534
|
-
await stackbone.memory.deleteAll({ userId: 'user_42' });
|
|
535
|
-
|
|
536
|
-
// Close a session and consolidate its facts into long-term memory (default).
|
|
537
|
-
const { data } = await stackbone.memory.endSession('sess_abc', { persist: true });
|
|
538
|
-
// data: { sessionId, persisted }
|
|
539
|
-
```
|
|
540
|
-
|
|
541
|
-
> Until the mem0 integration ships, every method returns `{ data: null, error: { code: 'not_implemented', ... } }`. The shape of the responses described here is the contract callers can already write code against.
|
|
472
|
+
Long-term memory is a pending surface: its public types (`AddMemoryRequest`, `MemoryItem`, `MemoryHit`, `MemoryScope`, …) are exported from `@stackbone/sdk` so creators can type their integrations today, but there is no live `client.memory` accessor yet. The mem0-backed runtime will be added in a future release and promoted into the Features list above. See the [Coming Soon](#coming-soon) section for the full pending set.
|
|
542
473
|
|
|
543
474
|
### Coming Soon
|
|
544
475
|
|
|
545
|
-
The following
|
|
476
|
+
The following surfaces have stable public **types** (so creators can write code against them today) but no runtime yet. They live under `src/surfaces/pending/` in the SDK source and do NOT appear as `client.X` accessors — importing the types directly from `@stackbone/sdk` is the supported path while the runtime is in flight:
|
|
546
477
|
|
|
547
478
|
```ts
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
// Emit an event to the organization event bus.
|
|
556
|
-
await stackbone.events.emit('lead.qualified', { leadId: 42 });
|
|
557
|
-
|
|
558
|
-
// List OAuth connections the user attached (Notion, GDrive, Slack…).
|
|
559
|
-
const { data: connections } = await stackbone.connections.list();
|
|
479
|
+
import type {
|
|
480
|
+
PublishRequest,
|
|
481
|
+
AddMemoryRequest,
|
|
482
|
+
MemoryHit,
|
|
483
|
+
Prompt,
|
|
484
|
+
CreatePromptRequest,
|
|
485
|
+
} from '@stackbone/sdk';
|
|
560
486
|
```
|
|
561
487
|
|
|
488
|
+
The pending surfaces are `queues` (cross-container HTTP push), `memory` (mem0-backed long-term memory), `prompts` (managed prompts with `{{var}}` templates), `connections` (OAuth integrations) and `events` (organisation event bus). When their runtime lands they will be promoted into the live `client.X` set described in the Features section above.
|
|
489
|
+
|
|
562
490
|
## Configuration
|
|
563
491
|
|
|
564
492
|
`createClient` accepts a single configuration object. Every field is optional and falls back to an environment variable, which the platform injects into the agent container at boot:
|
|
@@ -634,18 +562,24 @@ console.log(result.data.choices[0]?.message.content);
|
|
|
634
562
|
|
|
635
563
|
Each module ships its own stable code prefix so you can pattern-match without parsing the message:
|
|
636
564
|
|
|
637
|
-
| Prefix | Source | Examples
|
|
638
|
-
| ----------------- | ---------------------- |
|
|
639
|
-
| `ai_*` | `client.ai` | `ai_unauthorized`, `ai_credits_exhausted`, `ai_rate_limited`, `ai_aborted`, `ai_no_image_generated`
|
|
640
|
-
| `s3_*` | `client.storage` | `s3_credentials_missing`, `s3_invalid_key`, `s3_error`
|
|
641
|
-
| `rag_*` | `client.rag` | `rag_invalid_request`, `rag_dim_mismatch`, `rag_embedding_failed`, `rag_error`
|
|
642
|
-
| `approval_*` | `client.approval` | `approval_invalid_request`, `approval_invalid_signature`, `approval_signature_expired`, `approval_signing_key_missing`, `approval_invalid_payload`, `approval_tool_execute_failed` |
|
|
643
|
-
| `secrets_*` | `client.secrets` | `secrets_invalid_request`, `secrets_not_found`, `secrets_invalid_response`
|
|
644
|
-
| `config_*` | `client.config` | `config_invalid_request`, `config_not_found`, `config_invalid_response`
|
|
645
|
-
| `memory_*` | `client.memory` | reserved for the future mem0-backed implementation; today every method returns `not_implemented`
|
|
646
|
-
| `http_*` |
|
|
647
|
-
|
|
|
648
|
-
| `
|
|
565
|
+
| Prefix | Source | Examples |
|
|
566
|
+
| ----------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
567
|
+
| `ai_*` | `client.ai` | `ai_unauthorized`, `ai_credits_exhausted`, `ai_rate_limited`, `ai_aborted`, `ai_no_image_generated` |
|
|
568
|
+
| `s3_*` | `client.storage` | `s3_credentials_missing`, `s3_invalid_key`, `s3_error` |
|
|
569
|
+
| `rag_*` | `client.rag` | `rag_invalid_request`, `rag_dim_mismatch`, `rag_embedding_failed`, `rag_error` |
|
|
570
|
+
| `approval_*` | `client.approval` | `approval_invalid_request`, `approval_invalid_signature`, `approval_signature_expired`, `approval_signing_key_missing`, `approval_invalid_payload`, `approval_tool_execute_failed`, `approval_unauthorized`, `approval_forbidden`, `approval_not_found`, `approval_rate_limited`, `approval_unavailable` |
|
|
571
|
+
| `secrets_*` | `client.secrets` | `secrets_invalid_request`, `secrets_not_found`, `secrets_invalid_response`, `secrets_unauthorized`, `secrets_forbidden`, `secrets_rate_limited`, `secrets_unavailable` |
|
|
572
|
+
| `config_*` | `client.config` | `config_invalid_request`, `config_not_found`, `config_invalid_response`, `config_unauthorized`, `config_forbidden`, `config_rate_limited`, `config_unavailable` |
|
|
573
|
+
| `memory_*` | `client.memory` | reserved for the future mem0-backed implementation; today every method returns `not_implemented` |
|
|
574
|
+
| `http_*` | transport-level errors | `http_timeout`, `http_aborted`, `http_network_error`, `http_parse_error`, `http_request_failed` — surface-level statuses (401/403/404/429/5xx) remap to `<surface>_*` codes |
|
|
575
|
+
| `database_*` | `client.database` | `database_not_configured` (raised when `STACKBONE_POSTGRES_URL` is unset) |
|
|
576
|
+
| `observability_*` | `client.observability` | `observability_close_run_failed` |
|
|
577
|
+
| `contract_*` | gated surfaces | `contract_malformed`, `contract_unreachable`, `contract_version_unsupported` — emitted by any gated call when the contract handshake fails |
|
|
578
|
+
| `capability_*` | gated surfaces | `capability_unavailable` — emitted when the negotiated contract advertises no support for the surface |
|
|
579
|
+
| `*_missing` | configuration | `agent_id_missing`, `openrouter_key_missing`, `database_url_missing`, `stackbone_api_url_missing` |
|
|
580
|
+
| `not_implemented` | stubbed module surface | returned by every "coming soon" method until it ships |
|
|
581
|
+
|
|
582
|
+
> The canonical inventory of every code lives in `src/errors/codes.ts` as a typed catalog (`SdkErrorCode`). The table above is the human-readable summary; adding or removing a code is a single-file edit there and the compiler refuses any literal `code` value not declared in the catalog. Pattern-match against `SdkErrorCode` (exported from `@stackbone/sdk`) for full type narrowing, or call `isSdkErrorCode(raw)` to widen a wire string back into the catalog.
|
|
649
583
|
|
|
650
584
|
## TypeScript Support
|
|
651
585
|
|
package/db/testing/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var postgresql = require('@testcontainers/postgresql');
|
|
4
4
|
var postgresJs = require('drizzle-orm/postgres-js');
|
|
5
5
|
var migrator = require('drizzle-orm/postgres-js/migrator');
|
|
6
6
|
var postgres = require('postgres');
|
|
@@ -11,46 +11,14 @@ var postgres__default = /*#__PURE__*/_interopDefault(postgres);
|
|
|
11
11
|
|
|
12
12
|
var __defProp = Object.defineProperty;
|
|
13
13
|
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
14
|
-
var
|
|
15
|
-
var generateSchemaName = /* @__PURE__ */ __name(() => `stackbone_test_${crypto.randomBytes(8).toString("hex")}`, "generateSchemaName");
|
|
16
|
-
var tagConnectionStringWithSchema = /* @__PURE__ */ __name((url, schemaName) => {
|
|
17
|
-
const u = new URL(url);
|
|
18
|
-
u.searchParams.set("schema", schemaName);
|
|
19
|
-
return u.toString();
|
|
20
|
-
}, "tagConnectionStringWithSchema");
|
|
21
|
-
var dropSchema = /* @__PURE__ */ __name(async (adminUrl, schemaName) => {
|
|
22
|
-
const admin = postgres__default.default(adminUrl, {
|
|
23
|
-
max: 1,
|
|
24
|
-
onnotice: /* @__PURE__ */ __name(() => void 0, "onnotice")
|
|
25
|
-
});
|
|
26
|
-
try {
|
|
27
|
-
await admin.unsafe(`DROP SCHEMA "${schemaName.replace(/"/g, '""')}" CASCADE`);
|
|
28
|
-
} finally {
|
|
29
|
-
await admin.end({
|
|
30
|
-
timeout: 5
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
}, "dropSchema");
|
|
14
|
+
var DEFAULT_IMAGE = "pgvector/pgvector:pg17";
|
|
34
15
|
var createTestDatabase = /* @__PURE__ */ __name(async (options) => {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
onnotice: /* @__PURE__ */ __name(() => void 0, "onnotice")
|
|
40
|
-
});
|
|
41
|
-
try {
|
|
42
|
-
await admin`CREATE SCHEMA ${admin(schemaName)}`;
|
|
43
|
-
} finally {
|
|
44
|
-
await admin.end({
|
|
45
|
-
timeout: 5
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
const client = postgres__default.default(adminUrl, {
|
|
16
|
+
const image = options.image ?? DEFAULT_IMAGE;
|
|
17
|
+
const container = await new postgresql.PostgreSqlContainer(image).withDatabase("stackbone_test").withUsername("stackbone").withPassword("stackbone").start();
|
|
18
|
+
const connectionString = container.getConnectionUri();
|
|
19
|
+
const client = postgres__default.default(connectionString, {
|
|
49
20
|
max: 5,
|
|
50
|
-
onnotice: /* @__PURE__ */ __name(() => void 0, "onnotice")
|
|
51
|
-
connection: {
|
|
52
|
-
search_path: schemaName
|
|
53
|
-
}
|
|
21
|
+
onnotice: /* @__PURE__ */ __name(() => void 0, "onnotice")
|
|
54
22
|
});
|
|
55
23
|
const db = postgresJs.drizzle(client);
|
|
56
24
|
let disposed = false;
|
|
@@ -60,19 +28,20 @@ var createTestDatabase = /* @__PURE__ */ __name(async (options) => {
|
|
|
60
28
|
await client.end({
|
|
61
29
|
timeout: 5
|
|
62
30
|
}).catch(() => void 0);
|
|
63
|
-
await
|
|
31
|
+
await container.stop({
|
|
32
|
+
timeout: 5e3
|
|
33
|
+
}).catch(() => void 0);
|
|
64
34
|
}, "dispose");
|
|
65
35
|
try {
|
|
66
36
|
await migrator.migrate(db, {
|
|
67
|
-
migrationsFolder: options.migrationsDir
|
|
68
|
-
migrationsSchema: schemaName
|
|
37
|
+
migrationsFolder: options.migrationsDir
|
|
69
38
|
});
|
|
70
39
|
} catch (err) {
|
|
71
40
|
await dispose();
|
|
72
41
|
throw err;
|
|
73
42
|
}
|
|
74
43
|
return {
|
|
75
|
-
connectionString
|
|
44
|
+
connectionString,
|
|
76
45
|
drizzle: db,
|
|
77
46
|
dispose
|
|
78
47
|
};
|
package/db/testing/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../libs/sdk/src/db/testing/index.ts"],"names":["resolveAdminUrl","override","process","env","generateSchemaName","randomBytes","toString","tagConnectionStringWithSchema","url","schemaName","u","URL","searchParams","set","dropSchema","adminUrl","admin","postgres","max","onnotice","undefined","unsafe","replace","end","timeout","createTestDatabase","options","connectionString","client","connection","search_path","db","drizzle","disposed","dispose","catch","migrate","migrationsFolder","migrationsDir","migrationsSchema","err"],"mappings":"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","file":"index.cjs","sourcesContent":["// Public API for `@stackbone/sdk/db/testing`.\n//\n// `createTestDatabase` provisions an ephemeral, randomly-named Postgres schema\n// inside a shared developer database (the docker-compose `stackbone-postgres`\n// container in dev, or whatever Postgres the consumer points at via env or\n// `connectionString`) and applies a drizzle-kit-shaped migrations folder\n// against it. Returns a ready-to-query Drizzle instance plus a `dispose()`\n// that drops the schema and closes the pool — guaranteeing that two parallel\n// tests neither bleed rows nor leak schemas after the suite ends.\n//\n// Why a schema instead of a fresh database or a testcontainer:\n// - Schema creation is ~milliseconds (CREATE DATABASE on Postgres takes a\n// real lock and copies template1).\n// - One pre-pulled Postgres image (the dev compose container) avoids the\n// per-suite testcontainer boot cost (~3-5s) on every `nx test`.\n// - `search_path` isolation keeps every creator's migration SQL — which\n// references unqualified table names — applying to the throwaway schema\n// without rewriting their `.sql` files.\n//\n// Why not reuse `apps/cli/src/dev/db-migrator.ts`:\n// - That module owns the platform's advisory-lock + `__stackbone_migrations__`\n// journal table, which is shared across the whole DB and explicitly NOT\n// scoped to a schema. Tests run inside their own schema, so the standard\n// drizzle migrator (`drizzle-orm/postgres-js/migrator`) with\n// `migrationsSchema` pointed at the throwaway schema is the right primitive.\n\nimport { randomBytes } from 'node:crypto';\nimport { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';\nimport { migrate } from 'drizzle-orm/postgres-js/migrator';\nimport postgres, { type Sql } from 'postgres';\n\n/** Inputs for `createTestDatabase`. */\nexport interface CreateTestDatabaseOptions {\n /**\n * Drizzle-kit-shaped migrations directory: `meta/_journal.json` plus one\n * `<tag>.sql` file per entry. Required — the helper applies the migrations\n * before returning so the first query a test runs already sees the schema.\n */\n migrationsDir: string;\n /**\n * Optional Postgres URL pointing at the shared dev DB. Falls back, in order,\n * to:\n * 1. `STACKBONE_TEST_POSTGRES_URL` (preferred — lets CI override\n * independently of the agent's runtime URL).\n * 2. `STACKBONE_POSTGRES_URL` (the env var the rest of the SDK reads).\n * 3. `DATABASE_URL` (the env var loaded by `node --env-file=.env`).\n * 4. `postgres://stackbone:stackbone@localhost:5432/stackbone` (the\n * docker-compose default — the same DB `pnpm db:migrate` hits).\n */\n connectionString?: string;\n}\n\n/** Result of `createTestDatabase`. */\nexport interface TestDatabase {\n /**\n * Postgres URL pinned to the throwaway schema via the `?schema=<name>` query\n * parameter. Useful for sub-processes (drizzle-kit studio, a worker) that\n * need to share the same isolation.\n */\n connectionString: string;\n /** Drizzle handle bound to the throwaway schema, ready to query. */\n drizzle: PostgresJsDatabase<Record<string, never>>;\n /**\n * Tears the test DB down: closes the pool, then drops the schema with\n * CASCADE so dependent tables/indexes/sequences vanish in one statement.\n * Idempotent — calling it twice is a no-op.\n */\n dispose: () => Promise<void>;\n}\n\n/**\n * Resolves the admin Postgres URL. See `connectionString` on\n * `CreateTestDatabaseOptions` for the env-fallback chain.\n */\nconst resolveAdminUrl = (override?: string): string =>\n override ??\n process.env['STACKBONE_TEST_POSTGRES_URL'] ??\n process.env['STACKBONE_POSTGRES_URL'] ??\n process.env['DATABASE_URL'] ??\n 'postgres://stackbone:stackbone@localhost:5432/stackbone';\n\n/**\n * Generates a random schema name short enough to fit Postgres's 63-char\n * identifier limit while still being unique across parallel workers.\n */\nconst generateSchemaName = (): string => `stackbone_test_${randomBytes(8).toString('hex')}`;\n\n/**\n * Encodes a `?schema=<name>` query parameter into the URL so callers (and\n * sub-processes) can opt into the same isolation by reading their own URL.\n * Metadata for the caller — libpq itself does not honour it.\n */\nconst tagConnectionStringWithSchema = (url: string, schemaName: string): string => {\n const u = new URL(url);\n u.searchParams.set('schema', schemaName);\n return u.toString();\n};\n\n/** Drops the throwaway schema via a one-shot admin connection. */\nconst dropSchema = async (adminUrl: string, schemaName: string): Promise<void> => {\n const admin = postgres(adminUrl, { max: 1, onnotice: () => undefined });\n try {\n await admin.unsafe(`DROP SCHEMA \"${schemaName.replace(/\"/g, '\"\"')}\" CASCADE`);\n } finally {\n await admin.end({ timeout: 5 });\n }\n};\n\n/**\n * Provisions an ephemeral, randomly-named schema inside the shared dev\n * Postgres, applies the supplied migrations directory against it, and hands\n * back a Drizzle instance + `dispose()` for tear-down. See module header for\n * design rationale.\n */\nexport const createTestDatabase = async (\n options: CreateTestDatabaseOptions,\n): Promise<TestDatabase> => {\n const adminUrl = resolveAdminUrl(options.connectionString);\n const schemaName = generateSchemaName();\n\n // Step 1 — admin client to bootstrap the schema. Tiny pool because we only\n // run one DDL statement before tearing the connection down.\n const admin = postgres(adminUrl, { max: 1, onnotice: () => undefined });\n try {\n await admin`CREATE SCHEMA ${admin(schemaName)}`;\n } finally {\n await admin.end({ timeout: 5 });\n }\n\n // Step 2 — scoped client. `search_path` is set at connection time so every\n // statement (DDL from the migrations and the consumer's own queries) lands\n // inside the throwaway schema without qualifying table names.\n const client: Sql = postgres(adminUrl, {\n max: 5,\n onnotice: () => undefined,\n connection: { search_path: schemaName },\n });\n const db = drizzle(client);\n\n let disposed = false;\n const dispose = async (): Promise<void> => {\n if (disposed) return;\n disposed = true;\n await client.end({ timeout: 5 }).catch(() => undefined);\n await dropSchema(adminUrl, schemaName).catch(() => undefined);\n };\n\n // Step 3 — apply migrations. `migrationsSchema` keeps Drizzle's journal\n // table inside the throwaway schema too, so the schema-drop in `dispose`\n // wipes both creator-owned tables and Drizzle's own bookkeeping in one\n // statement. On failure, dispose so a broken fixture does not leak a\n // half-bootstrapped schema for the rest of the suite.\n try {\n await migrate(db, {\n migrationsFolder: options.migrationsDir,\n migrationsSchema: schemaName,\n });\n } catch (err) {\n await dispose();\n throw err;\n }\n\n return {\n connectionString: tagConnectionStringWithSchema(adminUrl, schemaName),\n drizzle: db,\n dispose,\n };\n};\n"]}
|
|
1
|
+
{"version":3,"sources":["../../../../../libs/sdk/src/db/testing/index.ts"],"names":["DEFAULT_IMAGE","createTestDatabase","options","image","container","PostgreSqlContainer","withDatabase","withUsername","withPassword","start","connectionString","getConnectionUri","client","postgres","max","onnotice","undefined","db","drizzle","disposed","dispose","end","timeout","catch","stop","migrate","migrationsFolder","migrationsDir","err"],"mappings":";;;;;;;;;;;;;AAoDA,IAAMA,aAAAA,GAAgB,wBAAA;AAOf,IAAMC,kBAAAA,iCACXC,OAAAA,KAAAA;AAEA,EAAA,MAAMC,KAAAA,GAAQD,QAAQC,KAAAA,IAASH,aAAAA;AAE/B,EAAA,MAAMI,SAAAA,GAAwC,MAAM,IAAIC,8BAAAA,CAAoBF,KAAAA,CAAAA,CACzEG,YAAAA,CAAa,gBAAA,CAAA,CACbC,aAAa,WAAA,CAAA,CACbC,YAAAA,CAAa,WAAA,EACbC,KAAAA,EAAK;AAER,EAAA,MAAMC,gBAAAA,GAAmBN,UAAUO,gBAAAA,EAAgB;AACnD,EAAA,MAAMC,MAAAA,GAAcC,0BAASH,gBAAAA,EAAkB;IAAEI,GAAAA,EAAK,CAAA;AAAGC,IAAAA,QAAAA,+BAAgBC,MAAAA,EAAN,UAAA;GAAgB,CAAA;AACnF,EAAA,MAAMC,EAAAA,GAAKC,mBAAQN,MAAAA,CAAAA;AAEnB,EAAA,IAAIO,QAAAA,GAAW,KAAA;AACf,EAAA,MAAMC,0BAAU,MAAA,CAAA,YAAA;AACd,IAAA,IAAID,QAAAA,EAAU;AACdA,IAAAA,QAAAA,GAAW,IAAA;AACX,IAAA,MAAMP,OAAOS,GAAAA,CAAI;MAAEC,OAAAA,EAAS;KAAE,CAAA,CAAGC,KAAAA,CAAM,MAAMP,MAAAA,CAAAA;AAC7C,IAAA,MAAMZ,UAAUoB,IAAAA,CAAK;MAAEF,OAAAA,EAAS;KAAK,CAAA,CAAGC,KAAAA,CAAM,MAAMP,MAAAA,CAAAA;EACtD,CAAA,EALgB,SAAA,CAAA;AAOhB,EAAA,IAAI;AACF,IAAA,MAAMS,iBAAQR,EAAAA,EAAI;AAAES,MAAAA,gBAAAA,EAAkBxB,OAAAA,CAAQyB;KAAc,CAAA;AAC9D,EAAA,CAAA,CAAA,OAASC,GAAAA,EAAK;AACZ,IAAA,MAAMR,OAAAA,EAAAA;AACN,IAAA,MAAMQ,GAAAA;AACR,EAAA;AAEA,EAAA,OAAO;AAAElB,IAAAA,gBAAAA;IAAkBQ,OAAAA,EAASD,EAAAA;AAAIG,IAAAA;AAAQ,GAAA;AAClD,CAAA,EA/BkC,oBAAA","file":"index.cjs","sourcesContent":["// Public API for `@stackbone/sdk/db/testing`.\n//\n// `createTestDatabase` spins up an ephemeral Postgres container via\n// `@testcontainers/postgresql`, applies a drizzle-kit-shaped migrations folder\n// against it and hands back a ready-to-query Drizzle instance plus a\n// `dispose()` that stops the container. Every call gets its own container, so\n// parallel tests never bleed rows nor share state.\n//\n// Why testcontainers instead of a shared dev DB:\n// - One self-contained primitive: `pnpm test` just works, no\n// `docker compose up` ceremony required of contributors or CI.\n// - True isolation per suite — schemas inside a shared DB still share the\n// server's stats, locks and configuration; containers do not.\n// - Aligns with the rest of the Stackbone monorepo (see\n// `apps/api/src/db/__tests__/setup-postgres.ts`).\n\nimport { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';\nimport { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';\nimport { migrate } from 'drizzle-orm/postgres-js/migrator';\nimport postgres, { type Sql } from 'postgres';\n\n/** Inputs for `createTestDatabase`. */\nexport interface CreateTestDatabaseOptions {\n /**\n * Drizzle-kit-shaped migrations directory: `meta/_journal.json` plus one\n * `<tag>.sql` file per entry. Required — the helper applies the migrations\n * before returning so the first query a test runs already sees the schema.\n */\n migrationsDir: string;\n /**\n * Postgres image to spin up. Defaults to `pgvector/pgvector:pg17` so RAG\n * and AI starters that need the `vector` extension work out of the box.\n * Override with `postgres:17` for a vanilla minimum-footprint container,\n * or with a custom tag (e.g. `stackbone/postgres:local`) when the test\n * suite needs the bundled `pgmq` / `pg_cron` extensions.\n */\n image?: string;\n}\n\n/** Result of `createTestDatabase`. */\nexport interface TestDatabase {\n /** Postgres URL of the running container, ready for sub-processes. */\n connectionString: string;\n /** Drizzle handle bound to the container's database, ready to query. */\n drizzle: PostgresJsDatabase<Record<string, never>>;\n /**\n * Tears the test DB down: closes the pool and stops the container.\n * Idempotent — calling it twice is a no-op.\n */\n dispose: () => Promise<void>;\n}\n\nconst DEFAULT_IMAGE = 'pgvector/pgvector:pg17';\n\n/**\n * Spins up an ephemeral Postgres container, applies the supplied migrations\n * directory against it, and hands back a Drizzle instance + `dispose()` for\n * tear-down. See module header for design rationale.\n */\nexport const createTestDatabase = async (\n options: CreateTestDatabaseOptions,\n): Promise<TestDatabase> => {\n const image = options.image ?? DEFAULT_IMAGE;\n\n const container: StartedPostgreSqlContainer = await new PostgreSqlContainer(image)\n .withDatabase('stackbone_test')\n .withUsername('stackbone')\n .withPassword('stackbone')\n .start();\n\n const connectionString = container.getConnectionUri();\n const client: Sql = postgres(connectionString, { max: 5, onnotice: () => undefined });\n const db = drizzle(client);\n\n let disposed = false;\n const dispose = async (): Promise<void> => {\n if (disposed) return;\n disposed = true;\n await client.end({ timeout: 5 }).catch(() => undefined);\n await container.stop({ timeout: 5000 }).catch(() => undefined);\n };\n\n try {\n await migrate(db, { migrationsFolder: options.migrationsDir });\n } catch (err) {\n await dispose();\n throw err;\n }\n\n return { connectionString, drizzle: db, dispose };\n};\n"]}
|