@soulcraft/sdk 1.4.6 → 1.4.8
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/dist/modules/formats/types.d.ts +6 -21
- package/dist/modules/formats/types.d.ts.map +1 -1
- package/dist/modules/formats/types.js +6 -21
- package/dist/modules/formats/types.js.map +1 -1
- package/dist/modules/kits/index.d.ts.map +1 -1
- package/dist/modules/kits/index.js +34 -13
- package/dist/modules/kits/index.js.map +1 -1
- package/dist/modules/kits/types.d.ts +66 -2
- package/dist/modules/kits/types.d.ts.map +1 -1
- package/dist/server/from-license.d.ts +35 -0
- package/dist/server/from-license.d.ts.map +1 -1
- package/dist/server/from-license.js +5 -0
- package/dist/server/from-license.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/docs/ADR-001-sdk-design.md +28 -28
- package/docs/ADR-002-transport-protocol.md +248 -0
- package/docs/ADR-003-instance-strategies.md +216 -0
- package/docs/IMPLEMENTATION-PLAN.md +22 -40
- package/docs/KIT-APP-GUIDE.md +932 -0
- package/package.json +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# ADR-002: Transport Protocol — Wire Format, Auth, and Reconnection
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted
|
|
4
|
+
**Date:** 2026-03-02
|
|
5
|
+
**Supersedes:** None
|
|
6
|
+
**See also:** `ADR-001-sdk-design.md` (Decision 5)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
The SDK must communicate between kit apps (browser) or product backends and a Brainy
|
|
13
|
+
server over a network. Four distinct communication patterns exist across the platform:
|
|
14
|
+
|
|
15
|
+
1. **Stateless RPC** — kit apps call Brainy methods on demand (no persistent connection needed)
|
|
16
|
+
2. **Real-time bidirectional RPC + push** — Venue kit apps need live change events alongside RPC
|
|
17
|
+
3. **Server-push only** — Workshop workspace event stream (VFS/entity mutations)
|
|
18
|
+
4. **In-process** — server-mode SDK calls Brainy directly with zero serialization overhead
|
|
19
|
+
|
|
20
|
+
These patterns have meaningfully different requirements for serialization, auth, and
|
|
21
|
+
error handling. This ADR records the decisions made for each.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Decision 1: Four Transports, Fixed Serialization
|
|
26
|
+
|
|
27
|
+
Four transport implementations are provided. Serialization format is fixed per transport
|
|
28
|
+
and is never configurable:
|
|
29
|
+
|
|
30
|
+
| Transport | Class | Serialization | Direction | Use case |
|
|
31
|
+
|-----------|-------|--------------|-----------|---------|
|
|
32
|
+
| `local` | `LocalTransport` | None (in-process) | — | Server mode, zero overhead |
|
|
33
|
+
| `http` | `HttpTransport` | JSON | Request/response | Stateless RPC — kit apps, simple clients |
|
|
34
|
+
| `ws` | `WsTransport` | MessagePack binary | Bidirectional | Real-time — RPC + change push events |
|
|
35
|
+
| `sse` | `SseTransport` | text/event-stream | Server → Client | Live updates only — VFS/entity events |
|
|
36
|
+
|
|
37
|
+
**Rationale for fixed serialization:**
|
|
38
|
+
- HTTP=JSON: universally debuggable with `curl`, browser DevTools, and server logs.
|
|
39
|
+
The request volume at typical HTTP RPC frequencies makes binary encoding a
|
|
40
|
+
premature optimization with no measurable benefit.
|
|
41
|
+
- WebSocket=MessagePack: bidirectional real-time traffic has high message frequency
|
|
42
|
+
and persistent connections where binary encoding is measurably more efficient.
|
|
43
|
+
MessagePack was already the wire format in `@soulcraft/brainy-client`.
|
|
44
|
+
- Making serialization configurable adds complexity with no practical benefit —
|
|
45
|
+
consumers never need to mix formats within a single transport.
|
|
46
|
+
|
|
47
|
+
**Rejected alternatives:**
|
|
48
|
+
- MessagePack over HTTP: non-standard, incompatible with standard proxies and logging tools.
|
|
49
|
+
- gRPC/Protobuf: over-engineered for the current scale and requires a schema compilation step.
|
|
50
|
+
- Configurable serializer per transport: extra complexity for a use case that has never arisen.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Decision 2: HTTP Transport Wire Format
|
|
55
|
+
|
|
56
|
+
**Endpoint:** `POST {baseUrl}/api/brainy/rpc`
|
|
57
|
+
|
|
58
|
+
**Request body** (JSON):
|
|
59
|
+
```json
|
|
60
|
+
{ "method": "find", "args": [{ "query": "candle kits", "limit": 10 }] }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Success response** (JSON):
|
|
64
|
+
```json
|
|
65
|
+
{ "result": [ ... ] }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Error response** (JSON):
|
|
69
|
+
```json
|
|
70
|
+
{ "error": { "code": "BRAINY_NOT_FOUND", "message": "Entity not found" } }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Auth:** `Authorization: Bearer <capability-token>` for server-to-server calls;
|
|
74
|
+
`credentials: 'include'` (session cookies) for browser kit apps.
|
|
75
|
+
|
|
76
|
+
**Timeout:** 30 seconds via `AbortController`. Throws `SDKTimeoutError` on expiry.
|
|
77
|
+
|
|
78
|
+
**HTTP status mapping:**
|
|
79
|
+
| Status | Error class |
|
|
80
|
+
|--------|------------|
|
|
81
|
+
| 401 | `SDKAuthError` |
|
|
82
|
+
| 403 | `SDKForbiddenError` |
|
|
83
|
+
| network failure | `SDKDisconnectedError` |
|
|
84
|
+
| 200 + `error` body | `SDKRpcError` |
|
|
85
|
+
|
|
86
|
+
The HTTP transport is stateless — `isAlive()` always returns `true`. Errors surface
|
|
87
|
+
as rejected promises from individual `call()` invocations.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Decision 3: WebSocket Transport Wire Format
|
|
92
|
+
|
|
93
|
+
**Endpoint:** `wss://{host}/api/brainy/ws`
|
|
94
|
+
|
|
95
|
+
**Auth:** Capability token sent as `Authorization: Bearer <token>` on the WebSocket
|
|
96
|
+
upgrade request. This is a Bun-specific extension (`@ts-expect-error` in source).
|
|
97
|
+
Standard browser `WebSocket` does not support custom headers on the upgrade request —
|
|
98
|
+
browser kit apps use the HTTP transport or pass the token as a `?token=` query param.
|
|
99
|
+
|
|
100
|
+
**Scope param:** `?scope=<tenantSlug>` on the connection URL. Venue uses this to
|
|
101
|
+
select the correct per-tenant Brainy instance.
|
|
102
|
+
|
|
103
|
+
### Connection handshake
|
|
104
|
+
|
|
105
|
+
After a successful upgrade + auth check the server sends a `ready` message:
|
|
106
|
+
```
|
|
107
|
+
Server → Client: { "type": "ready", "scope": "wicks-and-whiskers" }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`WsTransport.connect()` resolves only after this message. A 15-second timeout
|
|
111
|
+
applies — if no `ready` arrives the connection is aborted with an error.
|
|
112
|
+
|
|
113
|
+
### RPC messages (binary MessagePack)
|
|
114
|
+
|
|
115
|
+
**Client → Server:**
|
|
116
|
+
```
|
|
117
|
+
{ id: "42", method: "vfs.readdir", args: ["/workspace/docs"] }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The `id` is a monotonically incrementing integer cast to string, unique per connection.
|
|
121
|
+
|
|
122
|
+
**Server → Client (RPC response):**
|
|
123
|
+
```
|
|
124
|
+
{ id: "42", result: [ ... ] }
|
|
125
|
+
{ id: "42", error: { code: "VFS_NOT_FOUND", message: "Path not found" } }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Pending RPCs are matched to their response by `id`. A per-call 30-second timeout
|
|
129
|
+
rejects calls that receive no response.
|
|
130
|
+
|
|
131
|
+
### Change push events (binary MessagePack)
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
{ type: "change", event: "add"|"update"|"delete"|"relate"|"unrelate",
|
|
135
|
+
entity?: { ... }, relation?: { ... } }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Push events have no `id` field and are dispatched to all registered `onEvent` handlers.
|
|
139
|
+
|
|
140
|
+
### Reconnection
|
|
141
|
+
|
|
142
|
+
On unexpected disconnection (any close code except 4001, 4003, or explicit `close()`):
|
|
143
|
+
|
|
144
|
+
- All pending RPC calls are rejected with `SDKDisconnectedError`
|
|
145
|
+
- Reconnect is attempted with **exponential backoff**: `min(1000 × 2^attempt, 30_000)` ms
|
|
146
|
+
- Maximum 10 attempts (configurable via constructor)
|
|
147
|
+
- After 10 failed attempts the transport enters `closed` state permanently
|
|
148
|
+
|
|
149
|
+
**Auth failure codes — no retry:**
|
|
150
|
+
| Close code | Meaning | Behaviour |
|
|
151
|
+
|-----------|---------|----------|
|
|
152
|
+
| 4001 | Unauthorized (bad/expired token) | Reject pending calls with `SDKAuthError`, set `closed` |
|
|
153
|
+
| 4003 | Forbidden (insufficient scope) | Reject pending calls with `SDKForbiddenError`, set `closed` |
|
|
154
|
+
|
|
155
|
+
### Connection states
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
disconnected → connecting → connected → disconnected (unexpected) → connecting (reconnect) ...
|
|
159
|
+
→ closed (explicit close() or auth failure)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Decision 4: SSE Transport Wire Format
|
|
165
|
+
|
|
166
|
+
**Endpoint:** `GET {baseUrl}/api/workspace/events[?workspaceId=<id>]`
|
|
167
|
+
|
|
168
|
+
Uses the browser's native `EventSource` API. `EventSource` handles reconnection
|
|
169
|
+
automatically using the server-sent `retry:` interval. Additionally, the SDK applies
|
|
170
|
+
manual exponential backoff (identical schedule to WsTransport) on `onerror` events
|
|
171
|
+
to prevent thundering-herd reconnects on server restart.
|
|
172
|
+
|
|
173
|
+
**Event format** (JSON-encoded in the SSE `data:` field):
|
|
174
|
+
```
|
|
175
|
+
data: {"type":"change","event":"update","entity":{"id":"abc","nounType":"Booking",...}}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Connection confirmation** (internal, not dispatched to listeners):
|
|
179
|
+
```
|
|
180
|
+
data: {"type":"connected","connectionId":"conn-abc123"}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Receive-only:** `call()` throws `SDKError` with code `SSE_NO_RPC`. For outbound
|
|
184
|
+
operations pair this transport with an `HttpTransport`.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Decision 5: Local Transport (In-Process)
|
|
189
|
+
|
|
190
|
+
Used exclusively in server mode. Wraps a native `Brainy` instance. No serialization,
|
|
191
|
+
no network, no error class mapping.
|
|
192
|
+
|
|
193
|
+
**Method resolution:** Dot-separated method paths are resolved by recursive property
|
|
194
|
+
traversal on the Brainy instance. `'vfs.readdir'` → `brain.vfs.readdir(args)`.
|
|
195
|
+
|
|
196
|
+
**Change events:** `onEvent`/`offEvent` delegate to Brainy's built-in change listeners
|
|
197
|
+
directly on the instance. No intermediary.
|
|
198
|
+
|
|
199
|
+
**isAlive():** Always `true`. **close():** No-op.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Decision 6: Shared Error Classes
|
|
204
|
+
|
|
205
|
+
All transports share a common error hierarchy:
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
SDKError (base, has `code: string`)
|
|
209
|
+
├── SDKAuthError (401 / close code 4001)
|
|
210
|
+
├── SDKForbiddenError (403 / close code 4003)
|
|
211
|
+
├── SDKTimeoutError (30s per-call timeout)
|
|
212
|
+
├── SDKDisconnectedError (transport not connected, or unexpected close)
|
|
213
|
+
└── SDKRpcError (server returned { error: { code, message } })
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Consumer code catches `SDKAuthError` to redirect to login, `SDKDisconnectedError`
|
|
217
|
+
to show a reconnecting state, etc.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Decision 7: Capability Tokens for Server-to-Server Auth
|
|
222
|
+
|
|
223
|
+
Transport-level auth uses HMAC-SHA256 capability tokens:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
<base64url(payload)>.<base64url(signature)>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`payload` is `{ sub, scope, iat, exp }` JSON. Default TTL: 1 hour.
|
|
230
|
+
|
|
231
|
+
**Timing-safe verification** uses `crypto.timingSafeEqual` to prevent timing attacks.
|
|
232
|
+
|
|
233
|
+
These tokens are separate from OIDC session tokens. They are used for:
|
|
234
|
+
- Kit apps making RPC calls to the Venue or Workshop backend
|
|
235
|
+
- Server-to-server calls (e.g. Academy → Workshop)
|
|
236
|
+
|
|
237
|
+
Session cookie auth (browser kit apps in same-origin context) uses `credentials: 'include'`
|
|
238
|
+
on the HTTP transport and does not require a capability token.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Related Documents
|
|
243
|
+
|
|
244
|
+
- `ADR-001-sdk-design.md` — Overall SDK architecture (Decision 5: transport rationale)
|
|
245
|
+
- `ADR-003-instance-strategies.md` — How server-side Brainy instances are pooled and selected
|
|
246
|
+
- `src/transports/` — Implementation of all four transports
|
|
247
|
+
- `src/modules/brainy/errors.ts` — Shared error class hierarchy
|
|
248
|
+
- `src/modules/brainy/auth.ts` — Capability token creation and verification
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# ADR-003: Brainy Instance Strategies — Pooling, Lifecycle, and Cortex Activation
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted
|
|
4
|
+
**Date:** 2026-03-02
|
|
5
|
+
**Supersedes:** Workshop's `brainy-singleton.ts`, Venue's inline `tenantCache`
|
|
6
|
+
**See also:** `ADR-001-sdk-design.md` (Decision 4), `ADR-002-transport-protocol.md`
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
Each product manages Brainy instances differently:
|
|
13
|
+
|
|
14
|
+
- **Workshop** — one Brainy per user × workspace. Potentially hundreds of instances
|
|
15
|
+
across all Workshop users. Each user's data is isolated by email hash.
|
|
16
|
+
- **Venue** — one Brainy per tenant (one Brainy for all of "Wicks & Whiskers", another
|
|
17
|
+
for "The Candle Studio"). Tenant count is bounded but tenants can be busy.
|
|
18
|
+
- **Academy** — one Brainy per course section, or per learner × course. Domain-specific
|
|
19
|
+
key logic that doesn't fit either Workshop or Venue's pattern.
|
|
20
|
+
|
|
21
|
+
Before the SDK, each product maintained its own singleton/cache implementation with
|
|
22
|
+
diverging eviction policies, flush-on-shutdown behaviour, and Cortex activation code.
|
|
23
|
+
This caused drift and subtle bugs.
|
|
24
|
+
|
|
25
|
+
`BrainyInstancePool` centralizes the pattern in one place.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Decision 1: Three Built-In Strategies
|
|
30
|
+
|
|
31
|
+
| Strategy | Key | Storage path | Product |
|
|
32
|
+
|----------|-----|-------------|---------|
|
|
33
|
+
| `per-user` | `emailHash:workspaceId` | `{dataPath}/{emailHash}/{workspaceId}/` | Workshop |
|
|
34
|
+
| `per-tenant` | `tenantSlug` | `{dataPath}/{tenantSlug}/` | Venue |
|
|
35
|
+
| `per-scope` | caller-supplied `scopeKey(userId, workspaceId)` | caller-supplied factory | Academy, custom |
|
|
36
|
+
|
|
37
|
+
`per-scope` is a general escape hatch — the caller provides both the key function
|
|
38
|
+
and a `factory: () => Promise<Brainy>` per scope. This avoids a fourth built-in
|
|
39
|
+
strategy that would only serve edge cases.
|
|
40
|
+
|
|
41
|
+
Default max instances: `200` for `per-user`, `50` for `per-tenant`. Both are
|
|
42
|
+
configurable via `maxInstances`.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Decision 2: LRU Eviction with Non-Blocking Flush
|
|
47
|
+
|
|
48
|
+
The pool is backed by `lru-cache`. When the cache is full and a new instance is
|
|
49
|
+
needed, the LRU entry is evicted.
|
|
50
|
+
|
|
51
|
+
**Eviction is synchronous** — `lru-cache`'s `dispose` callback fires synchronously.
|
|
52
|
+
`brain.flush()` is initiated non-blocking from within `dispose` if `flushOnEvict: true`.
|
|
53
|
+
This is intentional: delaying eviction until flush completes would block the incoming
|
|
54
|
+
request that triggered the eviction.
|
|
55
|
+
|
|
56
|
+
**Consequence:** On abrupt process termination, recently evicted instances may not have
|
|
57
|
+
flushed. Products should call `sdk.shutdown()` (which calls `pool.shutdown()`) in their
|
|
58
|
+
`SIGTERM` handler to ensure all pending flushes complete before exit.
|
|
59
|
+
|
|
60
|
+
**The `flushOnEvict` default is `true`.** Setting it to `false` is only appropriate for
|
|
61
|
+
ephemeral development data or when the caller guarantees clean shutdown via explicit
|
|
62
|
+
`pool.flush(key)` calls.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Decision 3: Concurrent Init Deduplication
|
|
67
|
+
|
|
68
|
+
Brainy's cold start takes 25–30 seconds on a dataset of typical size. In a multi-request
|
|
69
|
+
environment it is common for several requests to arrive simultaneously for the same scope
|
|
70
|
+
before the first init completes.
|
|
71
|
+
|
|
72
|
+
Without deduplication each request would spawn a separate Brainy init, creating multiple
|
|
73
|
+
instances pointing at the same storage path — a correctness violation (concurrent writers
|
|
74
|
+
on the same mmap files corrupt data).
|
|
75
|
+
|
|
76
|
+
The pool uses a `pending: Map<string, Promise<Brainy>>` to deduplicate. The first request
|
|
77
|
+
for a key starts the init and stores the Promise. Subsequent requests for the same key
|
|
78
|
+
return the existing Promise and await the same init. Only one Brainy is ever created per key.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Decision 4: Storage Backends and Cortex Activation
|
|
83
|
+
|
|
84
|
+
Two `storage` values are supported:
|
|
85
|
+
|
|
86
|
+
| Value | When to use | Cortex behavior |
|
|
87
|
+
|-------|-------------|-----------------|
|
|
88
|
+
| `'filesystem'` | Local development | No Cortex plugin loaded |
|
|
89
|
+
| `'mmap-filesystem'` | Production (GCE with persistent disk) | Cortex plugin loaded |
|
|
90
|
+
|
|
91
|
+
When `storage: 'mmap-filesystem'`, the pool passes `plugins: ['@soulcraft/cortex']` to
|
|
92
|
+
the Brainy constructor. Cortex intercepts Brainy's storage layer at init time and upgrades
|
|
93
|
+
the filesystem provider to native Rust mmap SSTables. From the pool's perspective Brainy
|
|
94
|
+
is initialized the same way in both modes — the storage upgrade is transparent.
|
|
95
|
+
|
|
96
|
+
**Cortex is a Brainy plugin, not a separate init step.** The pool does not call any
|
|
97
|
+
Cortex activation API directly. License activation is handled by `fromLicense()` which
|
|
98
|
+
writes `.soulcraft.json` before any Brainy instance is created. Cortex reads the JWT
|
|
99
|
+
from `.soulcraft.json` at plugin load time.
|
|
100
|
+
|
|
101
|
+
### Cortex license flow (from Workshop experience)
|
|
102
|
+
|
|
103
|
+
This sequence is critical and order-dependent:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
1. fromLicense({ product }) — exchanges license key with Portal
|
|
107
|
+
2. → Portal returns sc_cortex_<jwt> in the config bundle
|
|
108
|
+
3. → fromLicense() writes { "cortex": "sc_cortex_..." } to .soulcraft.json atomically
|
|
109
|
+
4. Pool._initBrainy() called on first request
|
|
110
|
+
5. → new Brainy({ plugins: ['@soulcraft/cortex'] })
|
|
111
|
+
6. → Cortex plugin loads, reads .soulcraft.json, validates JWT offline
|
|
112
|
+
7. → If valid: upgrades storage to native mmap — logs "Providers: N/10 native"
|
|
113
|
+
8. → brain.init() completes
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**If step 3 is skipped** (`.soulcraft.json` absent or contains a short-code like
|
|
117
|
+
`CX-63QM-UV67` instead of a JWT), Cortex logs a validation error and falls back to
|
|
118
|
+
pure filesystem storage. The SDK continues to function — Brainy works without Cortex —
|
|
119
|
+
but native SIMD acceleration is disabled.
|
|
120
|
+
|
|
121
|
+
**The CX code is a short-code, not the JWT.** `fromLicense()` exchanges the short-code
|
|
122
|
+
for a JWT. The JWT is what Cortex validates offline. Never write a raw `CX-…` code into
|
|
123
|
+
`.soulcraft.json` and expect Cortex to accept it — it will not.
|
|
124
|
+
|
|
125
|
+
### Known issue (Cortex ≤ 2.1.3, fixed in 2.1.5)
|
|
126
|
+
|
|
127
|
+
In Cortex 2.1.3 the public key compiled into the native binary did not match the key
|
|
128
|
+
Portal used to sign JWTs. Every JWT was rejected with `invalid signature` regardless
|
|
129
|
+
of validity. This was fixed in `@soulcraft/cortex@2.1.5` (compile-time key rotation
|
|
130
|
+
by the Cortex team). The minimum required version is now `>=2.1.5` in peer dependencies.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Decision 5: `onInit` Hook for Product-Specific Post-Init Logic
|
|
135
|
+
|
|
136
|
+
The pool config accepts an optional `onInit(brain, storagePath)` async callback
|
|
137
|
+
called after each new Brainy instance completes `init()`. This is the escape hatch
|
|
138
|
+
for product-specific logic that must run once per instance creation:
|
|
139
|
+
|
|
140
|
+
- **Workshop:** VFS integrity check, project metadata migration
|
|
141
|
+
- **Venue:** Tenant schema validation, default entity seeding
|
|
142
|
+
- **Academy:** Course-specific index warming
|
|
143
|
+
|
|
144
|
+
The `onInit` callback runs before the instance is added to the cache. If it throws,
|
|
145
|
+
the init fails, the instance is not cached, and the caller receives the error. A
|
|
146
|
+
subsequent request for the same key will retry the full init sequence.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Decision 6: Graceful Shutdown
|
|
151
|
+
|
|
152
|
+
`sdk.shutdown()` calls `pool.shutdown()` which:
|
|
153
|
+
1. Calls `brain.flush()` on every live instance in parallel
|
|
154
|
+
2. Clears the cache
|
|
155
|
+
3. Clears the pending-init map
|
|
156
|
+
|
|
157
|
+
Products must call `sdk.shutdown()` from their `SIGTERM` handler:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
process.on('SIGTERM', async () => {
|
|
161
|
+
await sdk.shutdown()
|
|
162
|
+
process.exit(0)
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The `fromLicense()` factory also calls `pool.shutdown()` as part of its shutdown
|
|
167
|
+
sequence (billing buffer flush → heartbeat stop → pool flush).
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Decision 7: Storage Path Conventions
|
|
172
|
+
|
|
173
|
+
| Strategy | Path |
|
|
174
|
+
|----------|------|
|
|
175
|
+
| `per-user` | `{dataPath}/{emailHash}/{workspaceId}/` |
|
|
176
|
+
| `per-tenant` | `{dataPath}/{tenantSlug}/` |
|
|
177
|
+
| `per-scope` | Determined by caller's `factory` |
|
|
178
|
+
|
|
179
|
+
`emailHash` is an 8-character SHA-256 hex prefix of the lowercase+trimmed email
|
|
180
|
+
address, computed by `computeEmailHash()`. Using a hash rather than the raw email
|
|
181
|
+
avoids filesystem encoding issues and obscures PII from directory listings.
|
|
182
|
+
|
|
183
|
+
All paths are created with `fs.mkdir(path, { recursive: true })` at init time — the
|
|
184
|
+
pool does not require pre-existing directories.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Decision 8: `BrainyInitializingError` for Non-Blocking Cold Start
|
|
189
|
+
|
|
190
|
+
Products that prefer to return HTTP 503 during cold start (rather than blocking the
|
|
191
|
+
client for 25–30 seconds) can pass a non-blocking flag and catch
|
|
192
|
+
`BrainyInitializingError`:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
app.onError((err, c) => {
|
|
196
|
+
if (err instanceof BrainyInitializingError) {
|
|
197
|
+
return c.json({ error: 'Service starting', retryAfter: err.retryAfter }, 503, {
|
|
198
|
+
'Retry-After': String(err.retryAfter),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
This is optional — the default pool behaviour blocks the request until init completes,
|
|
205
|
+
which is correct for Workshop (users expect their workspace to load, even if slowly).
|
|
206
|
+
Venue may prefer the 503 pattern to avoid long-hanging HTTP requests on the first
|
|
207
|
+
tenant request after a cold deploy.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Related Documents
|
|
212
|
+
|
|
213
|
+
- `ADR-001-sdk-design.md` — Overall SDK architecture (Decision 3: instance strategies)
|
|
214
|
+
- `ADR-002-transport-protocol.md` — How clients connect to the pool's Brainy instances
|
|
215
|
+
- `src/server/instance-pool.ts` — Full implementation
|
|
216
|
+
- `src/server/from-license.ts` — License activation and `.soulcraft.json` write (step 3 above)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @soulcraft/sdk — Detailed Implementation Plan
|
|
2
2
|
|
|
3
|
-
**Status:**
|
|
3
|
+
**Status:** Complete as of v1.4.6
|
|
4
4
|
**Decisions:** See `ADR-001-sdk-design.md` for all architecture decisions and rationale.
|
|
5
5
|
**Research basis:** Full cross-project exploration of Workshop, Venue, Academy, brainy-client,
|
|
6
6
|
and @soulcraft/auth conducted 2026-02-27.
|
|
@@ -38,11 +38,6 @@ A new session MUST read the source file(s) before implementing each module.
|
|
|
38
38
|
|
|
39
39
|
All items done. Repo created, ADR written, scaffold committed.
|
|
40
40
|
|
|
41
|
-
Remaining scaffolding items (minor, do at start of Phase 2):
|
|
42
|
-
- [ ] `vitest.config.ts` — test runner config
|
|
43
|
-
- [ ] `.env.example` — document any env vars the SDK needs
|
|
44
|
-
- [ ] Update `src/modules/*/types.ts` placeholder comments as modules are implemented
|
|
45
|
-
|
|
46
41
|
---
|
|
47
42
|
|
|
48
43
|
## Phase 2 — Core Data Layer
|
|
@@ -634,48 +629,35 @@ renderEmailTemplate(template: string, data: Record<string, unknown>): string
|
|
|
634
629
|
|
|
635
630
|
## Phase 6 — npm Publish + Migrate All Products
|
|
636
631
|
|
|
637
|
-
### 6a. Pre-publish checklist
|
|
638
|
-
- [
|
|
639
|
-
- [
|
|
640
|
-
- [
|
|
641
|
-
- [
|
|
642
|
-
- [
|
|
643
|
-
- [
|
|
644
|
-
- [
|
|
632
|
+
### 6a. Pre-publish checklist ✅ Complete (v1.4.6)
|
|
633
|
+
- [x] All tests passing (`bun test`) — 330 tests, 21 suites
|
|
634
|
+
- [x] TypeScript builds cleanly (`bun run check`)
|
|
635
|
+
- [x] No stubs, no TODOs, no `as any`
|
|
636
|
+
- [x] JSDoc on every exported symbol
|
|
637
|
+
- [x] Module-level `@module` block on every file
|
|
638
|
+
- [x] Published as `@soulcraft/sdk@1.4.6` (first published as `1.0.0`, now at `1.4.6`)
|
|
639
|
+
- [x] `README.md` written with usage examples for all three entry points
|
|
645
640
|
|
|
646
641
|
### 6b. Publish
|
|
647
642
|
```bash
|
|
648
643
|
npm publish --access restricted
|
|
649
644
|
```
|
|
650
645
|
|
|
651
|
-
### 6c. Migrate Workshop
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
9. All `import { createBrainyClient } from '@soulcraft/brainy-client'` → `from '@soulcraft/sdk/client'`
|
|
662
|
-
|
|
663
|
-
### 6d. Migrate Venue
|
|
664
|
-
Files to update in `/home/dpsifr/Projects/venue/`:
|
|
665
|
-
1. `apps/web/package.json` — same package swap
|
|
666
|
-
2. `apps/web/server.ts` — replace brainy-client WS handler with SDK
|
|
667
|
-
3. `apps/web/src/lib/server/auth.ts` — replace with `createProductAuth('venue', ...)`
|
|
668
|
-
4. `apps/web/src/routes/api/brainy/rpc/+server.ts` — replace with SDK HTTP handler
|
|
669
|
-
5. All notification code → `sdk.notifications.*`
|
|
670
|
-
|
|
671
|
-
### 6e. Migrate Academy
|
|
672
|
-
Files to update in `/media/dpsifr/storage/home/Projects/academy/`:
|
|
673
|
-
1. `package.json`, `auth/better-auth.ts`, `server.ts` — same patterns as Workshop
|
|
646
|
+
### 6c. Migrate Workshop ✅ Complete
|
|
647
|
+
Workshop migrated to `@soulcraft/sdk@1.4.6`. Uses `fromLicense({ product: 'workshop' })`.
|
|
648
|
+
All brainy-client and auth imports replaced. Bundle loop updated.
|
|
649
|
+
|
|
650
|
+
### 6d. Migrate Venue ⏳ In progress
|
|
651
|
+
Venue has open actions in the handoff file — `fromLicense()` migration pending.
|
|
652
|
+
See `/home/dpsifr/.strategy/PLATFORM-HANDOFF.md`.
|
|
653
|
+
|
|
654
|
+
### 6e. Migrate Academy ✅ Complete
|
|
655
|
+
Academy migrated to `@soulcraft/sdk@1.4.6`.
|
|
674
656
|
|
|
675
657
|
### 6f. Post-migration
|
|
676
|
-
-
|
|
677
|
-
-
|
|
678
|
-
-
|
|
658
|
+
- `@soulcraft/brainy-client` — deprecated on npmjs.com ✅
|
|
659
|
+
- `@soulcraft/auth` — deprecated on npmjs.com ✅
|
|
660
|
+
- Handoff thread: resolved for Workshop and Academy; Venue still pending
|
|
679
661
|
|
|
680
662
|
---
|
|
681
663
|
|