@soulcraft/sdk 1.4.6 → 1.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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:** Ready to implement
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
- - [ ] All tests passing (`bun test`)
639
- - [ ] TypeScript builds cleanly (`bun run check`)
640
- - [ ] No stubs, no TODOs, no `as any`
641
- - [ ] JSDoc on every exported symbol
642
- - [ ] Module-level `@module` block on every file
643
- - [ ] `package.json` version set to `1.0.0`
644
- - [ ] `README.md` written with usage examples for all three entry points
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
- Files to update in `/media/dpsifr/storage/home/Projects/workshop/`:
653
- 1. `package.json` add `@soulcraft/sdk`, remove `@soulcraft/brainy-client`, `@soulcraft/auth`
654
- 2. `build.sh` — add `sdk` to bundle loop, remove `brainy-client` and `auth`
655
- 3. `auth/better-auth.ts` replace with `createProductAuth('workshop', ...)` from SDK
656
- 4. `server-hono.ts` replace brainy-client handler factories with SDK handlers
657
- 5. `brainy/brainy-singleton.ts` — replace with `sdk.server.instancePool`
658
- 6. `brainy/remote-brainy.ts` — replace with SDK `WsTransport`
659
- 7. `brainy/usage-tracking.ts` replace with `sdk.license.credits.*` + `sdk.billing.*`
660
- 8. All `import { ... } from '@soulcraft/auth'` → `from '@soulcraft/sdk'`
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
- - Deprecate `@soulcraft/brainy-client` on npmjs.com (`npm deprecate`)
677
- - Deprecate `@soulcraft/auth` on npmjs.com
678
- - Update `PLATFORM-HANDOFF.md` thread to RESOLVED
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