@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.
- 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/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,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
|
|