@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 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
- doc_id=?`. With N batches per document, only the rows inserted by the LAST
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
- - **Connections / Events** — OAuth integrations (Notion, GDrive, Slack…) and an organization event bus
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
- `client.memory` is the agent's long-term memory surface. The first iteration is a **scaffolding placeholder** every method returns `not_implemented`. The public types and signatures defined here are stable and the contract callers can already type their integrations against. The real backend will be [mem0](https://mem0.ai) (`mem0ApiKey` / `MEM0_API_KEY`).
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 modules are part of the public surface but currently return a `not_implemented` error. They will land iteratively as the platform rolls out:
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
- // Push an HTTP job to another agent (QStash-backed under the hood).
549
- await stackbone.queues.publish({
550
- url: 'https://agent-b.stackbone.ai/invoke',
551
- body: { leadId: 42 },
552
- retries: 3,
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_*` | facade HTTP client | `http_unauthorized`, `http_timeout`, `http_rate_limited`, `http_server_error` |
647
- | `*_missing` | configuration | `database_not_configured`, `agent_id_missing`, `openrouter_key_missing`, `database_url_missing` |
648
- | `not_implemented` | stubbed module surface | returned by every "coming soon" method until it ships |
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
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var crypto = require('crypto');
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 resolveAdminUrl = /* @__PURE__ */ __name((override) => override ?? process.env["STACKBONE_TEST_POSTGRES_URL"] ?? process.env["STACKBONE_POSTGRES_URL"] ?? process.env["DATABASE_URL"] ?? "postgres://stackbone:stackbone@localhost:5432/stackbone", "resolveAdminUrl");
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 adminUrl = resolveAdminUrl(options.connectionString);
36
- const schemaName = generateSchemaName();
37
- const admin = postgres__default.default(adminUrl, {
38
- max: 1,
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 dropSchema(adminUrl, schemaName).catch(() => void 0);
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: tagConnectionStringWithSchema(adminUrl, schemaName),
44
+ connectionString,
76
45
  drizzle: db,
77
46
  dispose
78
47
  };
@@ -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"]}