@stream44.studio/encapsulate 0.4.0-rc.19 → 0.4.0-rc.21

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.
@@ -1,8 +1,9 @@
1
+ ⚠️ **Disclaimer:** Under active development. Code has not been audited, APIs and interfaces are subject to change.
1
2
 
2
3
  Capsule Spine Contract v0
3
4
  ===
4
5
 
5
- The default spine contract used to incubate spine functionality.
6
+ The first *experimental* spine contract implementation used to incubate the spine approach.
6
7
 
7
8
  A spine contract is a standard and implementation that governs:
8
9
 
@@ -15,14 +16,449 @@ In practice there should only ever be very few spine contracts but there can be
15
16
 
16
17
  This spine contract aims to realize a concrete implementation of the [PrivateData.Space](https://privatedata.space/) model for the purpose of building full-stack distributed JavaScript applications & systems.
17
18
 
19
+ ![Capsule Spine Contract Overview](./Overview.svg)
20
+
18
21
 
19
22
  Example Capsule Source
20
23
  ---
21
24
 
25
+ ```ts
26
+ // A capsule is defined by calling encapsulate() with a definition object and options.
27
+ // The definition is keyed by spine contract URI, then by property contract URI, then by property name.
28
+
29
+ const userService = await encapsulate({
30
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
31
+
32
+ // Property struct marker — attaches Capsule metadata (capsuleName, capsuleSourceLineRef, etc.)
33
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
34
+
35
+ // External struct via property contract delegate — maps a separate capsule as a struct.
36
+ // 'as' aliases the full URI to a short name accessible on the API and via this.
37
+ // 'options' are forwarded to the struct capsule's '#' property contract as overrides.
38
+ '#./structs/Schema.v0': {
39
+ as: '$schema',
40
+ options: {
41
+ '#': { schemaValue: { type: 'user', version: 2 } }
42
+ }
43
+ },
44
+
45
+ // '#' is the default property contract — properties here are exposed directly on the capsule API.
46
+ '#': {
47
+
48
+ // --- Value Properties ---
49
+
50
+ // Literal: a value property. Accessible on the API and via this. Can be overridden at runtime.
51
+ name: {
52
+ type: CapsulePropertyTypes.Literal,
53
+ value: 'default-name'
54
+ },
55
+
56
+ // String: alias for Literal (semantic hint for string values).
57
+ label: {
58
+ type: CapsulePropertyTypes.String,
59
+ value: undefined
60
+ },
61
+
62
+ // Constant: read-only value. Throws on assignment via the Membrane contract.
63
+ VERSION: {
64
+ type: CapsulePropertyTypes.Constant,
65
+ value: '1.0.0'
66
+ },
67
+
68
+ // --- Function Properties ---
69
+
70
+ // Function: bound to a self proxy. Receives arguments. Callable on the API.
71
+ greet: {
72
+ type: CapsulePropertyTypes.Function,
73
+ value: function (this: any, greeting: string): string {
74
+ // 'this' is a proxy over the shared self — accesses all properties
75
+ // including those from extended capsules and overrides.
76
+ return `${greeting}, ${this.name}! (v${this.VERSION})`
77
+ }
78
+ },
79
+
80
+ // GetterFunction: lazily evaluated when the property is accessed (no parentheses).
81
+ fullLabel: {
82
+ type: CapsulePropertyTypes.GetterFunction,
83
+ value: function (this: any): string {
84
+ return `${this.label} [${this.name}]`
85
+ }
86
+ },
87
+
88
+ // SetterFunction: called when the property is assigned to. Enables validation/transformation.
89
+ setName: {
90
+ type: CapsulePropertyTypes.SetterFunction,
91
+ value: function (this: any, newName: string) {
92
+ if (!newName) throw new Error('Name cannot be empty')
93
+ this.name = newName.trim()
94
+ }
95
+ },
96
+
97
+ // Memoize: caches the return value. true = permanent, number = TTL in ms.
98
+ expensiveComputation: {
99
+ type: CapsulePropertyTypes.GetterFunction,
100
+ value: function (this: any): object {
101
+ return { computed: true, name: this.name }
102
+ },
103
+ memoize: true // cached permanently for this run
104
+ },
105
+ timedCache: {
106
+ type: CapsulePropertyTypes.Function,
107
+ value: function (this: any): object {
108
+ return { ts: Date.now(), name: this.name }
109
+ },
110
+ memoize: 5000 // cache expires after 5 seconds
111
+ },
112
+
113
+ // --- Mapping Properties ---
114
+
115
+ // Mapping (capsule reference): composes another capsule as a sub-component.
116
+ // The mapped capsule gets its own instance with its own self context.
117
+ $auth: {
118
+ type: CapsulePropertyTypes.Mapping,
119
+ value: authCapsule, // direct capsule reference
120
+ options: { // static options object forwarded to the mapped capsule
121
+ '#': { realm: 'users' }
122
+ }
123
+ },
124
+
125
+ // Mapping (string URI): resolved relative to this capsule's module filepath.
126
+ $db: {
127
+ type: CapsulePropertyTypes.Mapping,
128
+ value: './Database.v0', // resolved via spine contract's resolve + importCapsule
129
+ options: async ({ self, constants }: { self: any, constants: any }) => {
130
+ // Dynamic options function — receives { self, constants }.
131
+ // 'constants' contains Literal/String values from the mapped capsule.
132
+ // 'self' contains resolved sibling mappings when depends is declared.
133
+ return {
134
+ '#': { connectionString: `db://${constants.dbName}` },
135
+ // Nested capsule-name-targeted options: keys without '#' prefix
136
+ // are matched against capsule names deeper in the mapping tree.
137
+ 'connectionPool': {
138
+ '#': { maxConnections: 10 }
139
+ }
140
+ }
141
+ }
142
+ },
143
+
144
+ // Mapping with depends: declares sibling dependencies that must resolve first.
145
+ // options({ self }) receives the parent capsule's self with resolved siblings.
146
+ // The static analyzer can auto-detect self.<name> references and inject depends.
147
+ $api: {
148
+ type: CapsulePropertyTypes.Mapping,
149
+ value: apiCapsule,
150
+ depends: ['$auth'], // explicit depends — ensures $auth is resolved first
151
+ options: function ({ self }: { self: any }) {
152
+ return {
153
+ '#': {
154
+ authRealm: self.$auth.realm,
155
+ capsuleName: self['#@stream44.studio/encapsulate/structs/Capsule'].capsuleName
156
+ }
157
+ }
158
+ }
159
+ },
160
+
161
+ // --- Lifecycle Properties ---
162
+
163
+ // StructInit: runs once after instantiation, before the handler. For struct capsules.
164
+ // Fires top-down through the extends chain. Not exposed on the API.
165
+ Init: {
166
+ type: CapsulePropertyTypes.StructInit,
167
+ value: async function (this: any) {
168
+ this.ready = true
169
+ }
170
+ },
171
+
172
+ // Dispose: runs after the handler completes for. For struct capsules. Reverse order (bottom-up). Not on API.
173
+ Dispose: {
174
+ type: CapsulePropertyTypes.StructDispose,
175
+ value: async function (this: any) {
176
+ this.ready = true
177
+ }
178
+ },
22
179
 
180
+ // Init: like StructInit but for non-struct capsules (those without StructInit).
181
+ Init: {
182
+ type: CapsulePropertyTypes.Init,
183
+ value: false
184
+ },
185
+
186
+ // Dispose: runs after the handler completes (those without StructInit). Reverse order (bottom-up). Not on API.
187
+ Dispose: {
188
+ type: CapsulePropertyTypes.Dispose,
189
+ value: async function (this: any) {
190
+ this.ready = false
191
+ }
192
+ },
193
+
194
+ // --- this.self ---
195
+ // Inside any function, 'this' resolves the full merged context (child + parent).
196
+ // 'this.self' resolves only the current capsule's own properties (ownSelf).
197
+ // Useful in parent capsules to distinguish own values from child overrides.
198
+ getOwnName: {
199
+ type: CapsulePropertyTypes.GetterFunction,
200
+ value: function (this: any): string {
201
+ return this.self.name // own capsule's name, not child's override
202
+ }
203
+ }
204
+ }
205
+ }
206
+ }, {
207
+ importMeta: import.meta,
208
+ importStack: makeImportStack(),
209
+ capsuleName: 'UserService', // optional name — enables override targeting by name
210
+ extendsCapsule: baseCapsule, // inherits properties from another capsule (reference or string URI)
211
+ ambientReferences: { authCapsule, apiCapsule } // capsules referenced in the definition that need CST tracking
212
+ })
213
+ ```
23
214
 
24
215
 
25
216
  Reference
26
217
  ---
27
218
 
219
+ ### Capsule Definition Structure
220
+
221
+ ```
222
+ {
223
+ '<spineContractUri>': {
224
+ '<propertyContractUri>': { ...properties } | { as?, options? },
225
+ '#': { ...properties }
226
+ }
227
+ }
228
+ ```
229
+
230
+ - **Spine contract URI** — prefixed with `#`. Identifies which spine contract governs property mapping. e.g. `'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0'`
231
+ - **Property contract URI** — keys starting with `#` within a spine contract. `'#'` is the default contract whose properties are exposed directly on the API. Non-default URIs (e.g. `'#./MyStruct.v0'`) are resolved as capsule mappings and mounted as sub-components.
232
+ - **Property definition** — `{ type, value, ...options }` where `type` is a `CapsulePropertyTypes` member.
233
+
234
+ ### Encapsulate Options
235
+
236
+ | Option | Type | Description |
237
+ |---|---|---|
238
+ | `importMeta` | `{ url: string }` | `import.meta` of the defining module. Used to derive `moduleFilepath`. |
239
+ | `importStack` | `string` | Stack trace from `makeImportStack()`. Used to derive `importStackLine`. |
240
+ | `importStackLine` | `number` | Explicit line number (alternative to `importStack`). |
241
+ | `moduleFilepath` | `string` | Explicit module path (alternative to `importMeta`). |
242
+ | `capsuleName` | `string` | Optional name. Enables targeting via overrides/options by name instead of `capsuleSourceLineRef`. |
243
+ | `extendsCapsule` | `TCapsule \| string` | Parent capsule to inherit from. String URIs are resolved relative to the module. |
244
+ | `ambientReferences` | `Record<string, any>` | Named capsule references used in the definition. Required for static analysis to track cross-capsule dependencies. |
245
+ | `cst` / `crt` | `any` | Pre-computed CST/CRT. Bypasses static analysis. |
246
+
247
+ ### CapsulePropertyTypes
248
+
249
+ #### Value Types
250
+
251
+ | Type | API Access | `this` Access | Overridable | Description |
252
+ |---|---|---|---|---|
253
+ | `Literal` | read/write | read/write | yes | General-purpose value. Supports any JS type including `Map`, `Set`, etc. |
254
+ | `String` | read/write | read/write | yes | Alias for `Literal`. Semantic hint for string values. |
255
+ | `Constant` | read-only | read | no | Immutable value. Membrane contract throws on assignment. |
256
+
257
+ All value types accept a `value` in their definition. `undefined` means "no default — must be supplied via overrides/options".
258
+
259
+ #### Function Types
260
+
261
+ | Type | API Access | Signature | Description |
262
+ |---|---|---|---|
263
+ | `Function` | `api.name(...args)` | `function(this, ...args)` | Callable method. Bound to self proxy. |
264
+ | `GetterFunction` | `api.name` (no parens) | `function(this)` | Lazy getter. Evaluated on each access (unless memoized). |
265
+ | `SetterFunction` | `api.name = value` | `function(this, value)` | Triggered on assignment. Enables validation/transformation logic. |
266
+
267
+ All function types are bound to a **self proxy** where:
268
+ - `this.<prop>` resolves through: self → encapsulatedApi → extendedCapsuleApi
269
+ - `this.self.<prop>` resolves only the current capsule's own properties (`ownSelf`)
270
+
271
+ #### Lifecycle Types
272
+
273
+ | Type | When | Order | On API | Description |
274
+ |---|---|---|---|---|
275
+ | `StructInit` | Before handler | Top-down (child → extended parent) | No | Initialization for struct capsules. Async supported. |
276
+ | `StructDispose` | After handler | Bottom-up (reverse of init) | No | Cleanup for struct capsules. Async supported. |
277
+ | `Init` | Before handler | Top-down | No | Initialization for non-struct capsules (those without `StructInit`). |
278
+ | `Dispose` | After handler | Bottom-up | No | Cleanup for non-struct capsules. |
279
+
280
+ Multiple lifecycle functions per capsule are supported. They execute in definition order.
281
+
282
+ #### Memoize Option
283
+
284
+ Applies to `Function` and `GetterFunction`. Added as a sibling to `type` and `value`:
285
+
286
+ ```ts
287
+ { type: CapsulePropertyTypes.GetterFunction, value: fn, memoize: true } // permanent cache
288
+ { type: CapsulePropertyTypes.Function, value: fn, memoize: 5000 } // TTL in ms
289
+ ```
290
+
291
+ Memoize caches are scoped per spine contract capsule instance and cleared automatically when `run()` completes.
292
+
293
+ ### Mapping
294
+
295
+ `Mapping` composes another capsule as a sub-component.
296
+
297
+ ```ts
298
+ prop: {
299
+ type: CapsulePropertyTypes.Mapping,
300
+ value: capsuleRef | './relative/path',
301
+ options: { '#': { key: value } } // static object
302
+ options: async ({ self, constants }) => ({ '#': { ... } }) // dynamic function
303
+ depends: ['siblingPropName'] // optional
304
+ }
305
+ ```
306
+
307
+ - **`value`** — a capsule reference (from `encapsulate()`) or a string URI resolved relative to the current module.
308
+ - **`options`** — forwarded to the mapped capsule. Keys starting with `'#'` target the mapped capsule's own property contracts. Keys without `'#'` are matched against capsule names deeper in the mapping tree (nested capsule-name-targeted options).
309
+ - **`options({ self, constants })`** — when `options` is a function, it receives `{ self, constants }`.
310
+ - `constants` — all `Literal`/`String` values from the mapped capsule's definition.
311
+ - `self` — the parent capsule's `self` object with resolved sibling mappings. Only populated when `depends` is specified (empty `{}` otherwise).
312
+ - **`depends`** — array of sibling property names that must be resolved before this mapping's `options` function runs. Enables `options({ self })` to access already-resolved siblings (e.g. `self.$auth.realm`) and the Capsule metadata struct (e.g. `self['#@stream44.studio/encapsulate/structs/Capsule'].capsuleName`). Can be declared explicitly or auto-injected by the static analyzer when it detects `self.<name>` references in the options function body.
313
+ - **Instance reuse** — named capsules are registered in an instance registry. If a capsule with the same name is mapped multiple times without options, the existing instance is reused via a deferred proxy.
314
+
315
+ Mapped capsules are accessible via `this.<prop>` (unwrapped API) and `api.<prop>` (raw instance with `.api`).
316
+
317
+ ### importCapsule
318
+
319
+ `this.self.importCapsule()` dynamically loads and initializes a capsule by URI at runtime — without pre-declaring it as a `Mapping` property. The imported capsule is **not** mapped onto the parent capsule's API.
320
+
321
+ ```ts
322
+ run: {
323
+ type: CapsulePropertyTypes.Function,
324
+ value: async function (this: any) {
325
+ const { capsule, api } = await this.self.importCapsule({
326
+ uri: '@scope/package/caps/MyCapsule', // or './relative/path'
327
+ options: { '#': { key: 'value' } }, // optional — forwarded to makeInstance
328
+ overrides: { ... } // optional — forwarded to makeInstance
329
+ })
330
+
331
+ // Use the imported capsule's API directly
332
+ await api.doSomething()
333
+ }
334
+ }
335
+ ```
336
+
337
+ - **`uri`** — a string URI resolved using the same mechanism as `Mapping` values (relative paths, `@scope/package/path`, etc.).
338
+ - **`options`** — forwarded to the imported capsule's `makeInstance()`. Same structure as mapping options.
339
+ - **`overrides`** — forwarded to the imported capsule's `makeInstance()`. Same structure as runtime overrides.
340
+ - **Returns** `{ capsule, api }` — the resolved capsule object and its initialized API.
341
+ - The imported capsule receives the caller's runtime spine contracts and root capsule context.
342
+ - Init lifecycle functions are executed on the imported capsule instance before returning.
343
+ - The imported capsule is **not** registered in the instance registry and **not** mounted on the parent's API or `self`.
344
+
345
+ Use `importCapsule` when you need to work with arbitrary capsules determined at runtime without declaring them all upfront as `Mapping` properties.
346
+
347
+ ### Property Contract Delegates
348
+
349
+ Non-default property contract URIs (e.g. `'#./MyStruct.v0'`) are resolved as capsule mappings and automatically mounted.
350
+
351
+ ```ts
352
+ '#./MyStruct.v0': {
353
+ as: '$myStruct', // alias — accessible as api.$myStruct and this.$myStruct
354
+ options: { '#': { key: value } } // forwarded to the struct capsule
355
+ }
356
+ ```
357
+
358
+ Without `as`, the property is accessible via `api['#./MyStruct.v0']`. The delegate's properties are also mounted under `api['#./MyStruct.v0']` as a namespace.
359
+
360
+ Overrides targeting a property contract delegate use the delegate URI as key:
361
+
362
+ ```ts
363
+ overrides: {
364
+ 'capsuleName': {
365
+ '#./MyStruct.v0': { key: 'overridden' }
366
+ }
367
+ }
368
+ ```
369
+
370
+ ### Extends
371
+
372
+ A capsule can inherit properties from a parent capsule:
373
+
374
+ ```ts
375
+ { extendsCapsule: parentCapsule } // direct reference
376
+ { extendsCapsule: './BaseCapsule.v0' } // string URI, resolved relative to module
377
+ ```
378
+
379
+ - Child and parent share the same `self` object. Parent functions see child's property values.
380
+ - Child properties take precedence over parent properties with the same name.
381
+ - The API uses a proxy: local properties are checked first, then the extended capsule's API.
382
+ - `this.self` in a parent function returns the parent's own values (`ownSelf`), not the merged context.
383
+ - Multiple capsules can extend the same parent — each gets a separate parent instance with its own `self`.
384
+
385
+ ### Spine Contracts: Static.v0 vs Membrane.v0
386
+
387
+ Both implement the same property mapping logic. The difference is observability.
388
+
389
+ **Static.v0** — direct property assignment. No interception. Minimal overhead.
390
+
391
+ **Membrane.v0** — wraps the API in proxies that emit events for every property access:
392
+
393
+ | Event | Emitted When | Payload |
394
+ |---|---|---|
395
+ | `get` | Property read | `{ target, value, eventIndex }` |
396
+ | `set` | Property write | `{ target, value, eventIndex }` |
397
+ | `call` | Function invoked | `{ target, args, eventIndex }` |
398
+ | `call-result` | Function returns | `{ target, result, callEventIndex }` |
399
+
400
+ Events include `caller` context (source capsule, property, filepath, line) when `enableCallerStackInference` is enabled. Memoized results are tagged with `memoized: true`.
401
+
402
+ ### SpineRuntime & run()
403
+
404
+ ```ts
405
+ const { run } = await SpineRuntime({ spineContracts, capsules, snapshot? })
406
+
407
+ const result = await run({
408
+ overrides: {
409
+ 'capsuleName': { '#': { prop: 'value' } } // by name
410
+ 'path/to/file.ts:42': { '#': { prop: 'value' } } // by capsuleSourceLineRef
411
+ },
412
+ options: {
413
+ 'capsuleName': { '#': { prop: 'value' } }
414
+ }
415
+ }, async ({ apis }) => {
416
+ return apis['capsuleName'].greet('Hello')
417
+ })
418
+ ```
419
+
420
+ - **`overrides`** — merged into `self` before instantiation. Applied by `capsuleSourceLineRef` first, then by `capsuleName`.
421
+ - **`options`** — passed to `makeInstance()`. Same structure as overrides.
422
+ - **`apis`** — proxy-wrapped capsule instances. Nested `.api` layers are automatically unwrapped.
423
+
424
+ Lifecycle: instantiate → StructInit/Init → handler → StructDispose/Dispose → clear memoize timeouts.
425
+
426
+ ### Capsule Module Format
427
+
428
+ Capsule source files export a `capsule` function:
429
+
430
+ ```ts
431
+ export async function capsule({ encapsulate, CapsulePropertyTypes, makeImportStack }) {
432
+ return await encapsulate({ ... }, {
433
+ importMeta: import.meta,
434
+ importStack: makeImportStack(),
435
+ capsuleName: capsule['#']
436
+ })
437
+ }
438
+ capsule['#'] = '@my-org/my-package/MyCapsule'
439
+ ```
440
+
441
+ The `capsule['#']` convention provides a stable identifier for the capsule used in resolution and registry.
442
+
443
+ ### Capsule Metadata Struct
444
+
445
+ Every capsule with `'#@stream44.studio/encapsulate/structs/Capsule': {}` gets metadata injected into `self` and exposed on the API:
446
+
447
+ ```ts
448
+ api['#@stream44.studio/encapsulate/structs/Capsule'] = {
449
+ capsuleName,
450
+ capsuleSourceLineRef, // absolute path:line
451
+ moduleFilepath, // absolute path
452
+ rootCapsule: { // the top-level capsule in the extends chain
453
+ capsuleName,
454
+ capsuleSourceLineRef,
455
+ moduleFilepath
456
+ }
457
+ }
458
+ ```
459
+
460
+ `capsuleSourceNameRefHash` is also available when static analysis is enabled.
461
+
462
+ ---
28
463
 
464
+ (c) 2026 [Christoph.diy](https://christoph.diy) • Code: [MIT](../../../LICENSE.txt) • Text: [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) • Created with [Stream44.Studio](https://Stream44.Studio)
@@ -40,6 +40,42 @@ export class ContractCapsuleInstanceFactory {
40
40
  this.extendedCapsuleInstance = extendedCapsuleInstance
41
41
  this.runtimeSpineContracts = runtimeSpineContracts
42
42
  this.capsuleInstance = capsuleInstance
43
+
44
+ // Inject importCapsule onto ownSelf so capsule functions can call this.self.importCapsule()
45
+ if (ownSelf && !ownSelf.importCapsule) {
46
+ ownSelf.importCapsule = this.handleImportCapsule.bind(this)
47
+ }
48
+ }
49
+
50
+ async handleImportCapsule({ uri, options, overrides }: { uri: string, options?: Record<string, any>, overrides?: Record<string, any> }): Promise<{ capsule: any, api: any }> {
51
+ // Resolve the URI to a capsule object using the same mechanism as mappings
52
+ const resolvedCapsule = await this.resolveMappedCapsule({
53
+ property: {
54
+ name: '__importCapsule__',
55
+ definition: { value: uri }
56
+ }
57
+ })
58
+
59
+ // Instantiate the capsule with the caller's runtime spine contracts and optional overrides/options
60
+ const instance = await resolvedCapsule.makeInstance({
61
+ overrides: overrides || {},
62
+ options: options,
63
+ runtimeSpineContracts: this.runtimeSpineContracts,
64
+ rootCapsule: this.capsuleInstance?.rootCapsule
65
+ })
66
+
67
+ // Run init functions on the imported capsule instance
68
+ if (instance.initFunctions?.length) {
69
+ for (const fn of instance.initFunctions) {
70
+ await fn()
71
+ }
72
+ }
73
+
74
+ // Return capsule and unwrapped api without mapping onto the parent
75
+ return {
76
+ capsule: resolvedCapsule,
77
+ api: instance.api || instance
78
+ }
43
79
  }
44
80
 
45
81
  async mapProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
@@ -158,10 +194,12 @@ export class ContractCapsuleInstanceFactory {
158
194
 
159
195
  // delegateOptions is set by encapsulate.ts for property contract delegates
160
196
  // options can be a function or an object for regular mappings
197
+ // Always pass { self, constants } - self is populated when depends is specified, empty otherwise
198
+ const optionsFn = property.definition.options
161
199
  const mappingOptions = property.definition.delegateOptions
162
- || (typeof property.definition.options === 'function'
163
- ? await property.definition.options({ constants })
164
- : property.definition.options)
200
+ || (typeof optionsFn === 'function'
201
+ ? await optionsFn({ self: property.definition.depends ? this.self : {}, constants })
202
+ : optionsFn)
165
203
 
166
204
  // Check for existing instance in registry - reuse if available when no options
167
205
  // Pre-registration with null allows parent capsules to "claim" a slot before child capsules process
@@ -486,13 +486,31 @@ export async function CapsuleSpineFactory({
486
486
  timing,
487
487
  cacheStore: {
488
488
  writeFile: async (filepath: string, content: string) => {
489
- filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
490
- await mkdir(dirname(filepath), { recursive: true })
491
- await writeFile(filepath, content, 'utf-8')
489
+ // Write to central cache (spineFilesystemRoot)
490
+ const centralPath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
491
+ await mkdir(dirname(centralPath), { recursive: true })
492
+ await writeFile(centralPath, content, 'utf-8')
493
+ // Also write to local project cache if available
494
+ if (capsuleModuleProjectionRoot) {
495
+ try {
496
+ const localPath = join(capsuleModuleProjectionRoot, '.~o/encapsulate.dev/static-analysis', filepath)
497
+ await mkdir(dirname(localPath), { recursive: true })
498
+ await writeFile(localPath, content, 'utf-8')
499
+ } catch { /* local write is best-effort */ }
500
+ }
492
501
  },
493
502
  readFile: async (filepath: string) => {
494
- filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
495
- return readFile(filepath, 'utf-8')
503
+ const centralPath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
504
+ const content = await readFile(centralPath, 'utf-8')
505
+ // Sync to local project cache on read (covers cache-hit paths)
506
+ if (content && capsuleModuleProjectionRoot) {
507
+ try {
508
+ const localPath = join(capsuleModuleProjectionRoot, '.~o/encapsulate.dev/static-analysis', filepath)
509
+ await mkdir(dirname(localPath), { recursive: true })
510
+ await writeFile(localPath, content, 'utf-8')
511
+ } catch { /* local sync is best-effort */ }
512
+ }
513
+ return content
496
514
  },
497
515
  getStats: async (filepath: string) => {
498
516
  filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)