@stream44.studio/encapsulate 0.4.0-rc.38 → 0.4.0-rc.42
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/README.md +2 -2
- package/package.json +4 -4
- package/src/capsule-projectors/{CapsuleModuleProjector.v0.ts → CapsuleModuleProjector.ts} +3 -3
- package/src/encapsulate.ts +89 -28
- package/src/spine-contracts/CapsuleSpineContract.v0/{Membrane.v0.ts → Membrane.ts} +177 -22
- package/src/spine-contracts/CapsuleSpineContract.v0/README.md +72 -4
- package/src/spine-contracts/CapsuleSpineContract.v0/{Static.v0.ts → Static.ts} +235 -28
- package/src/spine-factories/{CapsuleSpineFactory.v0.ts → CapsuleSpineFactory.ts} +172 -39
- package/src/spine-factories/TimingObserver.ts +24 -14
- package/src/{static-analyzer.v0.ts → static-analyzer.ts} +31 -4
- package/structs/Capsule.ts +5 -0
- package/structs/StructFactory.ts +90 -0
|
@@ -65,6 +65,18 @@ const userService = await encapsulate({
|
|
|
65
65
|
value: '1.0.0'
|
|
66
66
|
},
|
|
67
67
|
|
|
68
|
+
// ConstantGetterFunction: eagerly evaluated during property mapping.
|
|
69
|
+
// Receives { constants } (not this) — constants contains all Literal/String/Constant
|
|
70
|
+
// values from the same capsule, resolved Mapping constants, and capsule metadata.
|
|
71
|
+
// The computed result is stored on self like a Constant and included in extractConstants.
|
|
72
|
+
derivedPath: {
|
|
73
|
+
type: CapsulePropertyTypes.ConstantGetterFunction,
|
|
74
|
+
value: function ({ constants }: { constants: any }): string {
|
|
75
|
+
const meta = constants['#@stream44.studio/encapsulate/structs/Capsule']
|
|
76
|
+
return `/workbench/${meta.capsuleName}`
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
68
80
|
// --- Function Properties ---
|
|
69
81
|
|
|
70
82
|
// Function: bound to a self proxy. Receives arguments. Callable on the API.
|
|
@@ -253,6 +265,7 @@ Reference
|
|
|
253
265
|
| `Literal` | read/write | read/write | yes | General-purpose value. Supports any JS type including `Map`, `Set`, etc. |
|
|
254
266
|
| `String` | read/write | read/write | yes | Alias for `Literal`. Semantic hint for string values. |
|
|
255
267
|
| `Constant` | read-only | read | no | Immutable value. Membrane contract throws on assignment. |
|
|
268
|
+
| `ConstantGetterFunction` | read-only | read | no | Eagerly evaluated computed constant. See below. |
|
|
256
269
|
|
|
257
270
|
All value types accept a `value` in their definition. `undefined` means "no default — must be supplied via overrides/options".
|
|
258
271
|
|
|
@@ -263,6 +276,7 @@ All value types accept a `value` in their definition. `undefined` means "no defa
|
|
|
263
276
|
| `Function` | `api.name(...args)` | `function(this, ...args)` | Callable method. Bound to self proxy. |
|
|
264
277
|
| `GetterFunction` | `api.name` (no parens) | `function(this)` | Lazy getter. Evaluated on each access (unless memoized). |
|
|
265
278
|
| `SetterFunction` | `api.name = value` | `function(this, value)` | Triggered on assignment. Enables validation/transformation logic. |
|
|
279
|
+
| `ProxyFunction` | `api.name(...args)` | `{ target(this), invoke(this, ...args) }` | Wraps a target function with argument transformation. See below. |
|
|
266
280
|
|
|
267
281
|
All function types are bound to a **self proxy** where:
|
|
268
282
|
- `this.<prop>` resolves through: self → encapsulatedApi → extendedCapsuleApi
|
|
@@ -290,6 +304,60 @@ Applies to `Function` and `GetterFunction`. Added as a sibling to `type` and `va
|
|
|
290
304
|
|
|
291
305
|
Memoize caches are scoped per spine contract capsule instance and cleared automatically when `run()` completes.
|
|
292
306
|
|
|
307
|
+
#### ProxyFunction
|
|
308
|
+
|
|
309
|
+
`ProxyFunction` wraps calling a target function with argument transformation. Its `value` is an object with two methods:
|
|
310
|
+
|
|
311
|
+
- **`target(this)`** — resolves the function to call (bound to self proxy)
|
|
312
|
+
- **`invoke(this, ...args)`** — transforms the caller's arguments before passing to target (bound to self proxy, may be async)
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
{
|
|
316
|
+
type: CapsulePropertyTypes.ProxyFunction,
|
|
317
|
+
value: {
|
|
318
|
+
target() { return this.service.fetchData }, // resolve target fn
|
|
319
|
+
async invoke(pathname: string) { // transform args
|
|
320
|
+
const origin = this.origin
|
|
321
|
+
return { url: `http://${origin}${pathname}`, headers: { Host: origin } }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
When `api.name(...args)` is called:
|
|
328
|
+
1. `invoke(...args)` runs with self proxy as `this`, returning transformed args
|
|
329
|
+
2. `target()` runs with self proxy as `this`, returning the function to call
|
|
330
|
+
3. The target function is called with the transformed args (awaited if `invoke` returns a promise)
|
|
331
|
+
|
|
332
|
+
#### ConstantGetterFunction
|
|
333
|
+
|
|
334
|
+
`ConstantGetterFunction` is a computed constant — eagerly evaluated during property mapping, with its result stored on `self` like a `Constant`. Unlike `GetterFunction` (bound to `this`, evaluated lazily on each access), `ConstantGetterFunction` receives an explicit `{ constants }` parameter and is evaluated once.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
workbenchDir: {
|
|
338
|
+
type: CapsulePropertyTypes.ConstantGetterFunction,
|
|
339
|
+
value: function ({ constants }: { constants: any }): string {
|
|
340
|
+
const meta = constants['#@stream44.studio/encapsulate/structs/Capsule']
|
|
341
|
+
const capsuleDir = constants.lib.path.dirname(meta.rootCapsule.moduleFilepath)
|
|
342
|
+
const testName = constants.lib.path.basename(meta.rootCapsule.moduleFilepath).replace(/\.[^.]+$/, '')
|
|
343
|
+
return constants.lib.path.join(capsuleDir, '.~o/workbenches', testName)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**What `constants` contains:**
|
|
349
|
+
|
|
350
|
+
| Context | Contents |
|
|
351
|
+
|---|---|
|
|
352
|
+
| During `extractConstants` (parent Mapping) | `Literal`/`String`/`Constant` values from the capsule definition, resolved `Mapping` constants (nested), and capsule metadata including `rootCapsule` |
|
|
353
|
+
| During `mapProperty` (instance creation) | `self` — all previously-processed properties, capsule metadata, resolved Mappings |
|
|
354
|
+
|
|
355
|
+
**Key properties:**
|
|
356
|
+
- **No `this` binding** — receives `{ constants }` as a plain function argument, making dependencies explicit
|
|
357
|
+
- **Available in parent's `constants`** — when a parent maps this capsule, the computed value is included in the `constants` parameter passed to the parent's `options` callback
|
|
358
|
+
- **Mapping constants are nested** — `constants.lib.path` accesses the `path` Constant from a mapped `lib` capsule
|
|
359
|
+
- **Capsule metadata** — `constants['#@stream44.studio/encapsulate/structs/Capsule']` includes `capsuleName`, `moduleFilepath`, and `rootCapsule` (the top-level capsule in the spine)
|
|
360
|
+
|
|
293
361
|
### Mapping
|
|
294
362
|
|
|
295
363
|
`Mapping` composes another capsule as a sub-component.
|
|
@@ -307,7 +375,7 @@ prop: {
|
|
|
307
375
|
- **`value`** — a capsule reference (from `encapsulate()`) or a string URI resolved relative to the current module.
|
|
308
376
|
- **`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
377
|
- **`options({ self, constants })`** — when `options` is a function, it receives `{ self, constants }`.
|
|
310
|
-
- `constants` — all `Literal`/`String` values from the mapped capsule's definition.
|
|
378
|
+
- `constants` — all `Literal`/`String`/`Constant` values from the mapped capsule's definition, computed `ConstantGetterFunction` results, resolved `Mapping` constants (nested by property name), and capsule metadata (`constants['#@stream44.studio/encapsulate/structs/Capsule']` with `rootCapsule`).
|
|
311
379
|
- `self` — always contains the Capsule metadata struct (`self['#@stream44.studio/encapsulate/structs/Capsule']` with `moduleFilepath`, `capsuleName`, etc.). When `depends` is specified, `self` also contains the full parent capsule's resolved sibling mappings.
|
|
312
380
|
- **`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`). Can be declared explicitly or auto-injected by the static analyzer when it detects `self.<name>` references in the options function body.
|
|
313
381
|
- **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.
|
|
@@ -382,13 +450,13 @@ A capsule can inherit properties from a parent capsule:
|
|
|
382
450
|
- `this.self` in a parent function returns the parent's own values (`ownSelf`), not the merged context.
|
|
383
451
|
- Multiple capsules can extend the same parent — each gets a separate parent instance with its own `self`.
|
|
384
452
|
|
|
385
|
-
### Spine Contracts: Static
|
|
453
|
+
### Spine Contracts: Static vs Membrane
|
|
386
454
|
|
|
387
455
|
Both implement the same property mapping logic. The difference is observability.
|
|
388
456
|
|
|
389
|
-
**Static
|
|
457
|
+
**Static** — direct property assignment. No interception. Minimal overhead.
|
|
390
458
|
|
|
391
|
-
**Membrane
|
|
459
|
+
**Membrane** — wraps the API in proxies that emit events for every property access:
|
|
392
460
|
|
|
393
461
|
| Event | Emitted When | Payload |
|
|
394
462
|
|---|---|---|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CapsulePropertyTypes, join } from "../../encapsulate"
|
|
2
|
+
import type { TimingObserverInterface } from "../../spine-factories/TimingObserver"
|
|
2
3
|
|
|
3
4
|
// Type for capsule instance registry - scoped per spine contract instance
|
|
4
5
|
export type CapsuleInstanceRegistry = Map<string, any>
|
|
@@ -19,6 +20,7 @@ export class ContractCapsuleInstanceFactory {
|
|
|
19
20
|
public childEncapsulatedApis?: Record<string, any>[]
|
|
20
21
|
protected runtimeSpineContracts?: Record<string, any>
|
|
21
22
|
protected capsuleInstance?: any
|
|
23
|
+
protected timing?: TimingObserverInterface
|
|
22
24
|
public structInitFunctions: Array<() => any> = []
|
|
23
25
|
public structDisposeFunctions: Array<() => any> = []
|
|
24
26
|
public initFunctions: Array<() => any> = []
|
|
@@ -28,7 +30,7 @@ export class ContractCapsuleInstanceFactory {
|
|
|
28
30
|
protected memoizeCache: Map<string, any> = new Map()
|
|
29
31
|
protected memoizeTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
|
30
32
|
|
|
31
|
-
constructor({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, runtimeSpineContracts, capsuleInstance }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, freezeCapsule?: (capsule: any) => Promise<any>, instanceRegistry?: CapsuleInstanceRegistry, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any>, capsuleInstance?: any }) {
|
|
33
|
+
constructor({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, runtimeSpineContracts, capsuleInstance, timing }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, freezeCapsule?: (capsule: any) => Promise<any>, instanceRegistry?: CapsuleInstanceRegistry, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any>, capsuleInstance?: any, timing?: TimingObserverInterface }) {
|
|
32
34
|
this.spineContractUri = spineContractUri
|
|
33
35
|
this.capsule = capsule
|
|
34
36
|
this.self = self
|
|
@@ -42,6 +44,7 @@ export class ContractCapsuleInstanceFactory {
|
|
|
42
44
|
this.extendedCapsuleInstance = extendedCapsuleInstance
|
|
43
45
|
this.runtimeSpineContracts = runtimeSpineContracts
|
|
44
46
|
this.capsuleInstance = capsuleInstance
|
|
47
|
+
this.timing = timing
|
|
45
48
|
|
|
46
49
|
// Inject importCapsule onto ownSelf so capsule functions can call this.self.importCapsule()
|
|
47
50
|
if (ownSelf && !ownSelf.importCapsule) {
|
|
@@ -82,9 +85,9 @@ export class ContractCapsuleInstanceFactory {
|
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
async mapProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
|
|
88
|
+
async mapProperty({ overrides, options, transitiveOverrides, property }: { overrides: any, options: any, transitiveOverrides?: any, property: any }) {
|
|
86
89
|
if (property.definition.type === CapsulePropertyTypes.Mapping) {
|
|
87
|
-
await this.mapMappingProperty({ overrides, options, property })
|
|
90
|
+
await this.mapMappingProperty({ overrides, options, transitiveOverrides, property })
|
|
88
91
|
} else if (
|
|
89
92
|
property.definition.type === CapsulePropertyTypes.String ||
|
|
90
93
|
property.definition.type === CapsulePropertyTypes.Literal ||
|
|
@@ -93,6 +96,8 @@ export class ContractCapsuleInstanceFactory {
|
|
|
93
96
|
this.mapLiteralProperty({ property })
|
|
94
97
|
} else if (property.definition.type === CapsulePropertyTypes.Function) {
|
|
95
98
|
this.mapFunctionProperty({ property })
|
|
99
|
+
} else if (property.definition.type === CapsulePropertyTypes.ConstantGetterFunction) {
|
|
100
|
+
this.mapConstantGetterFunctionProperty({ property })
|
|
96
101
|
} else if (property.definition.type === CapsulePropertyTypes.GetterFunction) {
|
|
97
102
|
this.mapGetterFunctionProperty({ property })
|
|
98
103
|
} else if (property.definition.type === CapsulePropertyTypes.SetterFunction) {
|
|
@@ -107,6 +112,8 @@ export class ContractCapsuleInstanceFactory {
|
|
|
107
112
|
this.mapDisposeProperty({ property })
|
|
108
113
|
} else if (property.definition.type === CapsulePropertyTypes.OnFreeze) {
|
|
109
114
|
this.mapOnFreezeProperty({ property })
|
|
115
|
+
} else if (property.definition.type === CapsulePropertyTypes.ProxyFunction) {
|
|
116
|
+
this.mapProxyFunctionProperty({ property })
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
119
|
|
|
@@ -134,11 +141,77 @@ export class ContractCapsuleInstanceFactory {
|
|
|
134
141
|
|
|
135
142
|
// Use cst.source.moduleFilepath (always filesystem-relative) for path resolution.
|
|
136
143
|
// encapsulateOptions.moduleFilepath may be an npm URI when loaded from projected files.
|
|
137
|
-
|
|
144
|
+
// However, cst.source.moduleFilepath may be relative to a different package root than
|
|
145
|
+
// the current spineFilesystemRoot (e.g. cross-package capsule references). In that case,
|
|
146
|
+
// fall back to encapsulateOptions.moduleFilepath which is always relative to current spine root.
|
|
147
|
+
let moduleFilepath = this.capsule.cst?.source?.moduleFilepath || this.capsule.encapsulateOptions?.moduleFilepath
|
|
138
148
|
if (!moduleFilepath) throw new Error(`'moduleFilepath' not available on capsule!`)
|
|
139
149
|
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
// moduleFilepath may be a relative filesystem path (e.g. "../../../caps/Foo.ts")
|
|
151
|
+
// or an npm-style URI (e.g. "@stream44.studio/t44-docker.com/caps/Project")
|
|
152
|
+
// after freeze/hoist cycles. join(spineRoot, npmUri) produces a bogus path when
|
|
153
|
+
// the URI is npm-style and spineRoot is narrow. Detect this by checking if the
|
|
154
|
+
// moduleFilepath looks like an npm URI (starts with @, or doesn't start with / or .),
|
|
155
|
+
// and resolve it to get the actual filesystem path.
|
|
156
|
+
let parentPath = join(this.spineFilesystemRoot, moduleFilepath)
|
|
157
|
+
if (!moduleFilepath.startsWith('/') && !moduleFilepath.startsWith('.')) {
|
|
158
|
+
// Looks like an npm-style URI or filesystem-convention path — resolve it
|
|
159
|
+
try {
|
|
160
|
+
// Try with @ prefix first (npm URI convention)
|
|
161
|
+
if (moduleFilepath.startsWith('@')) {
|
|
162
|
+
parentPath = await this.resolve(moduleFilepath, this.spineFilesystemRoot)
|
|
163
|
+
} else {
|
|
164
|
+
// May be a filesystem-convention path like "scope/packages/pkg/path"
|
|
165
|
+
// Try resolving as @scope/pkg/path by extracting scope and package name
|
|
166
|
+
const parts = moduleFilepath.split('/')
|
|
167
|
+
if (parts.length >= 3 && parts[1] === 'packages') {
|
|
168
|
+
const scope = parts[0]
|
|
169
|
+
const pkg = parts[2]
|
|
170
|
+
const subpath = parts.slice(3).join('/')
|
|
171
|
+
const npmUri = subpath ? `@${scope}/${pkg}/${subpath}` : `@${scope}/${pkg}`
|
|
172
|
+
parentPath = await this.resolve(npmUri, this.spineFilesystemRoot)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Fall back to the joined path if resolution fails
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let filepath: string
|
|
180
|
+
try {
|
|
181
|
+
filepath = await this.resolve(property.definition.value, parentPath)
|
|
182
|
+
} catch (resolveError) {
|
|
183
|
+
// cst.source.moduleFilepath may be relative to a different package root than spineFilesystemRoot
|
|
184
|
+
// (e.g. cross-package capsule references). Fall back to encapsulateOptions.moduleFilepath
|
|
185
|
+
// which is always relative to the current spine root.
|
|
186
|
+
const fallbackModuleFilepath = this.capsule.encapsulateOptions?.moduleFilepath
|
|
187
|
+
if (fallbackModuleFilepath && fallbackModuleFilepath !== moduleFilepath) {
|
|
188
|
+
// The fallback moduleFilepath may be a relative path or an npm URI.
|
|
189
|
+
// Apply the same resolution logic as the primary path.
|
|
190
|
+
let fallbackParentPath = join(this.spineFilesystemRoot, fallbackModuleFilepath)
|
|
191
|
+
if (!fallbackModuleFilepath.startsWith('/') && !fallbackModuleFilepath.startsWith('.')) {
|
|
192
|
+
try {
|
|
193
|
+
if (fallbackModuleFilepath.startsWith('@')) {
|
|
194
|
+
fallbackParentPath = await this.resolve!(fallbackModuleFilepath, this.spineFilesystemRoot)
|
|
195
|
+
} else {
|
|
196
|
+
const parts = fallbackModuleFilepath.split('/')
|
|
197
|
+
if (parts.length >= 3 && parts[1] === 'packages') {
|
|
198
|
+
const scope = parts[0]
|
|
199
|
+
const pkg = parts[2]
|
|
200
|
+
const subpath = parts.slice(3).join('/')
|
|
201
|
+
const npmUri = subpath ? `@${scope}/${pkg}/${subpath}` : `@${scope}/${pkg}`
|
|
202
|
+
fallbackParentPath = await this.resolve!(npmUri, this.spineFilesystemRoot)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// Fall back to the joined path
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
filepath = await this.resolve(property.definition.value, fallbackParentPath)
|
|
210
|
+
} else {
|
|
211
|
+
throw resolveError
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
this.timing?.record(`resolveMappedCapsule: importCapsule(${filepath.replace(/^.*\/genesis\//, '')})`)
|
|
142
215
|
mappedCapsule = await this.importCapsule(filepath)
|
|
143
216
|
} else if (
|
|
144
217
|
typeof property.definition.value === 'object' &&
|
|
@@ -152,8 +225,10 @@ export class ContractCapsuleInstanceFactory {
|
|
|
152
225
|
return mappedCapsule
|
|
153
226
|
}
|
|
154
227
|
|
|
155
|
-
protected async extractConstants({ mappedCapsule }: { mappedCapsule: any }) {
|
|
228
|
+
protected async extractConstants({ mappedCapsule, _visited }: { mappedCapsule: any, _visited?: Set<string> }) {
|
|
156
229
|
const constants: Record<string, any> = {}
|
|
230
|
+
const constantGetterFunctions: Array<{ key: string; fn: Function }> = []
|
|
231
|
+
const mappingProperties: Array<{ key: string; def: any }> = []
|
|
157
232
|
|
|
158
233
|
const spineContractDef = mappedCapsule.definition[this.spineContractUri]
|
|
159
234
|
|
|
@@ -172,9 +247,14 @@ export class ContractCapsuleInstanceFactory {
|
|
|
172
247
|
|
|
173
248
|
if (
|
|
174
249
|
type === CapsulePropertyTypes.String ||
|
|
175
|
-
type === CapsulePropertyTypes.Literal
|
|
250
|
+
type === CapsulePropertyTypes.Literal ||
|
|
251
|
+
type === CapsulePropertyTypes.Constant
|
|
176
252
|
) {
|
|
177
253
|
constants[prop] = propValue
|
|
254
|
+
} else if (type === CapsulePropertyTypes.ConstantGetterFunction) {
|
|
255
|
+
constantGetterFunctions.push({ key: prop, fn: propValue })
|
|
256
|
+
} else if (type === CapsulePropertyTypes.Mapping) {
|
|
257
|
+
mappingProperties.push({ key: prop, def: propDef })
|
|
178
258
|
}
|
|
179
259
|
}
|
|
180
260
|
} else {
|
|
@@ -185,17 +265,67 @@ export class ContractCapsuleInstanceFactory {
|
|
|
185
265
|
|
|
186
266
|
if (
|
|
187
267
|
type === CapsulePropertyTypes.String ||
|
|
188
|
-
type === CapsulePropertyTypes.Literal
|
|
268
|
+
type === CapsulePropertyTypes.Literal ||
|
|
269
|
+
type === CapsulePropertyTypes.Constant
|
|
189
270
|
) {
|
|
190
271
|
constants[key] = propValue
|
|
272
|
+
} else if (type === CapsulePropertyTypes.ConstantGetterFunction) {
|
|
273
|
+
constantGetterFunctions.push({ key, fn: propValue })
|
|
274
|
+
} else if (type === CapsulePropertyTypes.Mapping) {
|
|
275
|
+
mappingProperties.push({ key, def: value })
|
|
191
276
|
}
|
|
192
277
|
}
|
|
193
278
|
}
|
|
194
279
|
|
|
280
|
+
// Resolve Mapping properties and extract their constants recursively.
|
|
281
|
+
// Track visited capsules to prevent infinite recursion on circular refs.
|
|
282
|
+
const visited = _visited || new Set<string>()
|
|
283
|
+
const thisCapsuleName = mappedCapsule.encapsulateOptions?.capsuleName
|
|
284
|
+
if (thisCapsuleName) visited.add(thisCapsuleName)
|
|
285
|
+
for (const { key, def } of mappingProperties) {
|
|
286
|
+
try {
|
|
287
|
+
const nestedCapsule = await this.resolveMappedCapsule({
|
|
288
|
+
property: { definition: def, name: key }
|
|
289
|
+
})
|
|
290
|
+
const nestedName = nestedCapsule.encapsulateOptions?.capsuleName
|
|
291
|
+
if (nestedName && visited.has(nestedName)) continue
|
|
292
|
+
constants[key] = await this.extractConstants({ mappedCapsule: nestedCapsule, _visited: visited })
|
|
293
|
+
} catch {
|
|
294
|
+
// Skip mappings that cannot be resolved (e.g. missing dependencies)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Build capsule metadata so ConstantGetterFunctions can access it.
|
|
299
|
+
// rootCapsule is inherited from the parent (same as what makeInstance would receive).
|
|
300
|
+
const capsuleStructKey = '#@stream44.studio/encapsulate/structs/Capsule'
|
|
301
|
+
const relModuleFilepath = mappedCapsule.cst?.source?.moduleFilepath
|
|
302
|
+
|| mappedCapsule.encapsulateOptions?.moduleFilepath
|
|
303
|
+
const moduleFilepath = relModuleFilepath
|
|
304
|
+
? (relModuleFilepath.startsWith('/')
|
|
305
|
+
? relModuleFilepath
|
|
306
|
+
: join(this.spineFilesystemRoot || '', relModuleFilepath))
|
|
307
|
+
: undefined
|
|
308
|
+
constants[capsuleStructKey] = {
|
|
309
|
+
capsuleName: mappedCapsule.encapsulateOptions?.capsuleName,
|
|
310
|
+
moduleFilepath,
|
|
311
|
+
rootCapsule: this.capsuleInstance?.rootCapsule || {
|
|
312
|
+
capsuleName: this.capsule?.encapsulateOptions?.capsuleName,
|
|
313
|
+
moduleFilepath: this.self?.[capsuleStructKey]?.moduleFilepath,
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Evaluate ConstantGetterFunction values with the accumulated constants
|
|
318
|
+
// (including resolved Mapping constants). These receive { constants }
|
|
319
|
+
// (not this) — only static values and nested Mapping constants are available.
|
|
320
|
+
for (const { key, fn } of constantGetterFunctions) {
|
|
321
|
+
constants[key] = fn({ constants })
|
|
322
|
+
}
|
|
323
|
+
|
|
195
324
|
return constants
|
|
196
325
|
}
|
|
197
326
|
|
|
198
|
-
protected async mapMappingProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
|
|
327
|
+
protected async mapMappingProperty({ overrides, options, transitiveOverrides, property }: { overrides: any, options: any, transitiveOverrides?: any, property: any }) {
|
|
328
|
+
this.timing?.record(`mapMappingProperty: ${property.name} → ${typeof property.definition.value === 'string' ? property.definition.value : (property.definition.value?.encapsulateOptions?.capsuleName || 'obj')}`)
|
|
199
329
|
const mappedCapsule = await this.resolveMappedCapsule({ property })
|
|
200
330
|
const constants = await this.extractConstants({ mappedCapsule })
|
|
201
331
|
|
|
@@ -208,9 +338,25 @@ export class ContractCapsuleInstanceFactory {
|
|
|
208
338
|
const minimalSelf = this.self[capsuleStructKey]
|
|
209
339
|
? { [capsuleStructKey]: this.self[capsuleStructKey] }
|
|
210
340
|
: {}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
341
|
+
// During freeze phase, skip calling function-based options callbacks — runtime
|
|
342
|
+
// overrides haven't been applied so self.* references may be incomplete.
|
|
343
|
+
// Use a truthy placeholder so the instance registry still creates a fresh instance
|
|
344
|
+
// (preserving the instance tree for OnFreeze traversal and .sit.json generation).
|
|
345
|
+
// Object-based options and delegateOptions are always applied (they are static).
|
|
346
|
+
const isFreezePhase = !!this.capsuleInstance?.freezePhase
|
|
347
|
+
const hasFunctionOptions = typeof optionsFn === 'function'
|
|
348
|
+
const selfArg = { self: property.definition.depends ? this.self : minimalSelf, constants }
|
|
349
|
+
const delegateOpts = property.definition.delegateOptions
|
|
350
|
+
const resolvedDelegateOptions = delegateOpts
|
|
351
|
+
? (typeof delegateOpts === 'function'
|
|
352
|
+
? (isFreezePhase ? {} : await delegateOpts(selfArg))
|
|
353
|
+
: delegateOpts)
|
|
354
|
+
: undefined
|
|
355
|
+
const mappingOptions = resolvedDelegateOptions
|
|
356
|
+
|| (hasFunctionOptions
|
|
357
|
+
? (isFreezePhase
|
|
358
|
+
? {} // truthy placeholder — signals "has options" without calling the function
|
|
359
|
+
: await optionsFn(selfArg))
|
|
214
360
|
: optionsFn)
|
|
215
361
|
|
|
216
362
|
// Check for existing instance in registry - reuse if available when no options
|
|
@@ -228,6 +374,7 @@ export class ContractCapsuleInstanceFactory {
|
|
|
228
374
|
|
|
229
375
|
// Only reuse if current mapping has no options
|
|
230
376
|
if (!mappingOptions) {
|
|
377
|
+
this.timing?.record(`mapMappingProperty: REGISTRY REUSE ${capsuleName} (deferred proxy)`)
|
|
231
378
|
// Use deferred proxy that resolves from registry when accessed
|
|
232
379
|
// Works for both null (pre-registered) and actual instances
|
|
233
380
|
const apiTarget = this.getApiTarget({ property })
|
|
@@ -310,13 +457,16 @@ export class ContractCapsuleInstanceFactory {
|
|
|
310
457
|
}
|
|
311
458
|
}
|
|
312
459
|
|
|
313
|
-
//
|
|
314
|
-
//
|
|
460
|
+
// Build transitive overrides for the child: merge any inherited transitive
|
|
461
|
+
// overrides with this mapping's nested capsule-name-targeted options.
|
|
462
|
+
// These flow as transitiveOverrides (not overrides) so they take precedence
|
|
463
|
+
// over the child's own Mapping options but runtime overrides remain less specific.
|
|
464
|
+
let mappedTransitiveOverrides: Record<string, any> | undefined = transitiveOverrides
|
|
315
465
|
if (nestedCapsuleOptions) {
|
|
316
|
-
|
|
466
|
+
mappedTransitiveOverrides = { ...(mappedTransitiveOverrides || {}) }
|
|
317
467
|
for (const [capsuleNameKey, capsuleOptions] of Object.entries(nestedCapsuleOptions)) {
|
|
318
|
-
|
|
319
|
-
...(
|
|
468
|
+
mappedTransitiveOverrides[capsuleNameKey] = {
|
|
469
|
+
...(mappedTransitiveOverrides[capsuleNameKey] || {}),
|
|
320
470
|
...capsuleOptions
|
|
321
471
|
}
|
|
322
472
|
}
|
|
@@ -326,11 +476,13 @@ export class ContractCapsuleInstanceFactory {
|
|
|
326
476
|
const mappedInstance = await mappedCapsule.makeInstance({
|
|
327
477
|
overrides: mappedOverrides,
|
|
328
478
|
options: ownMappingOptions,
|
|
479
|
+
transitiveOverrides: mappedTransitiveOverrides,
|
|
329
480
|
runtimeSpineContracts: this.runtimeSpineContracts,
|
|
330
481
|
rootCapsule: this.capsuleInstance?.rootCapsule,
|
|
331
482
|
parentCapsuleSourceUriLineRefInstanceId: this.capsuleInstance?.capsuleSourceUriLineRefInstanceId,
|
|
332
483
|
sit: this.capsuleInstance?.sit,
|
|
333
|
-
skipCache: isCapsuleStruct
|
|
484
|
+
skipCache: isCapsuleStruct,
|
|
485
|
+
freezePhase: this.capsuleInstance?.freezePhase || undefined
|
|
334
486
|
})
|
|
335
487
|
|
|
336
488
|
// Register the instance (replaces null pre-registration marker)
|
|
@@ -390,6 +542,20 @@ export class ContractCapsuleInstanceFactory {
|
|
|
390
542
|
}
|
|
391
543
|
}
|
|
392
544
|
|
|
545
|
+
protected mapConstantGetterFunctionProperty({ property }: { property: any }) {
|
|
546
|
+
const apiTarget = this.getApiTarget({ property })
|
|
547
|
+
// Pass self as constants — at instance creation time self contains capsule
|
|
548
|
+
// metadata, resolved Mappings, and all previously-processed properties.
|
|
549
|
+
const value = property.definition.value({ constants: this.self })
|
|
550
|
+
|
|
551
|
+
// Store eagerly like a Constant — available on self for subsequent properties
|
|
552
|
+
apiTarget[property.name] = value
|
|
553
|
+
this.self[property.name] = value
|
|
554
|
+
if (this.ownSelf) {
|
|
555
|
+
this.ownSelf[property.name] = value
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
393
559
|
protected createSelfProxy() {
|
|
394
560
|
const extendedApi = this.extendedCapsuleInstance?.api
|
|
395
561
|
const ownSelf = this.ownSelf
|
|
@@ -405,21 +571,29 @@ export class ContractCapsuleInstanceFactory {
|
|
|
405
571
|
|
|
406
572
|
// First check if the property exists in target (this.self)
|
|
407
573
|
if (prop in target) {
|
|
574
|
+
// Virtual dispatch: if child has an override for this property,
|
|
575
|
+
// prefer the child's version over self (which may hold the parent's bound fn)
|
|
576
|
+
if (factory.childEncapsulatedApis) {
|
|
577
|
+
for (const childApi of factory.childEncapsulatedApis) {
|
|
578
|
+
if (prop in childApi) return childApi[prop]
|
|
579
|
+
}
|
|
580
|
+
}
|
|
408
581
|
return target[prop]
|
|
409
582
|
}
|
|
410
583
|
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
return factory.encapsulatedApi[prop]
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Fall back to child capsule APIs (for parent→child function delegation)
|
|
584
|
+
// Check child capsule APIs (virtual dispatch —
|
|
585
|
+
// child overrides take precedence over parent's own API)
|
|
417
586
|
if (factory.childEncapsulatedApis) {
|
|
418
587
|
for (const childApi of factory.childEncapsulatedApis) {
|
|
419
588
|
if (prop in childApi) return childApi[prop]
|
|
420
589
|
}
|
|
421
590
|
}
|
|
422
591
|
|
|
592
|
+
// Fall back to own encapsulatedApi
|
|
593
|
+
if (prop in factory.encapsulatedApi) {
|
|
594
|
+
return factory.encapsulatedApi[prop]
|
|
595
|
+
}
|
|
596
|
+
|
|
423
597
|
// Fall back to extended capsule's API
|
|
424
598
|
if (extendedApi && prop in extendedApi) {
|
|
425
599
|
return extendedApi[prop]
|
|
@@ -447,12 +621,12 @@ export class ContractCapsuleInstanceFactory {
|
|
|
447
621
|
getOwnPropertyDescriptor: (target: any, prop: string | symbol) => {
|
|
448
622
|
if (typeof prop === 'symbol') return Object.getOwnPropertyDescriptor(target, prop)
|
|
449
623
|
if (prop in target) return Object.getOwnPropertyDescriptor(target, prop)
|
|
450
|
-
if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
|
|
451
624
|
if (factory.childEncapsulatedApis) {
|
|
452
625
|
for (const childApi of factory.childEncapsulatedApis) {
|
|
453
626
|
if (prop in childApi) return { configurable: true, enumerable: true, writable: true, value: childApi[prop as string] }
|
|
454
627
|
}
|
|
455
628
|
}
|
|
629
|
+
if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
|
|
456
630
|
if (extendedApi && prop in extendedApi) return { configurable: true, enumerable: true, writable: true, value: extendedApi[prop as string] }
|
|
457
631
|
return undefined
|
|
458
632
|
}
|
|
@@ -565,6 +739,38 @@ export class ContractCapsuleInstanceFactory {
|
|
|
565
739
|
}
|
|
566
740
|
}
|
|
567
741
|
|
|
742
|
+
protected mapProxyFunctionProperty({ property }: { property: any }) {
|
|
743
|
+
const apiTarget = this.getApiTarget({ property })
|
|
744
|
+
const selfProxy = this.createSelfProxy()
|
|
745
|
+
|
|
746
|
+
const childTargetFn = property.definition.value.target
|
|
747
|
+
const childInvokeFn = property.definition.value.invoke
|
|
748
|
+
|
|
749
|
+
// Inherit missing parts from parent's ProxyFunction (if extending)
|
|
750
|
+
const parentParts = this.self[`__proxyFn_${property.name}`]
|
|
751
|
+
const targetFn = childTargetFn ?? parentParts?.target
|
|
752
|
+
const invokeFn = childInvokeFn ?? parentParts?.invoke
|
|
753
|
+
|
|
754
|
+
if (!targetFn) throw new Error(`ProxyFunction '${property.name}': target() is required`)
|
|
755
|
+
if (!invokeFn) throw new Error(`ProxyFunction '${property.name}': invoke() is required`)
|
|
756
|
+
|
|
757
|
+
// Store parts for potential child override
|
|
758
|
+
this.self[`__proxyFn_${property.name}`] = { target: targetFn, invoke: invokeFn }
|
|
759
|
+
|
|
760
|
+
apiTarget[property.name] = (...args: any[]) => {
|
|
761
|
+
// 1. Call invoke() bound to selfProxy to transform args
|
|
762
|
+
const transformedArgs = invokeFn.call(selfProxy, ...args)
|
|
763
|
+
// 2. Call target() bound to selfProxy to get the function to call
|
|
764
|
+
const target = targetFn.call(selfProxy)
|
|
765
|
+
// 3. If invoke returned a promise, await it then call target
|
|
766
|
+
if (transformedArgs && typeof transformedArgs.then === 'function') {
|
|
767
|
+
return transformedArgs.then((resolved: any) => target(resolved))
|
|
768
|
+
}
|
|
769
|
+
// 4. Call target with the transformed args
|
|
770
|
+
return target(transformedArgs)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
568
774
|
protected mapSetterFunctionProperty({ property }: { property: any }) {
|
|
569
775
|
const apiTarget = this.getApiTarget({ property })
|
|
570
776
|
const setterFn = property.definition.value
|
|
@@ -637,7 +843,7 @@ export class ContractCapsuleInstanceFactory {
|
|
|
637
843
|
|
|
638
844
|
|
|
639
845
|
|
|
640
|
-
export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, spineFilesystemRoot }: { freezeCapsule?: (capsule: any) => Promise<any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string } = {}) {
|
|
846
|
+
export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, spineFilesystemRoot, timing }: { freezeCapsule?: (capsule: any) => Promise<any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, timing?: TimingObserverInterface } = {}) {
|
|
641
847
|
|
|
642
848
|
const instanceRegistry: CapsuleInstanceRegistry = new Map()
|
|
643
849
|
|
|
@@ -658,7 +864,8 @@ export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, sp
|
|
|
658
864
|
instanceRegistry,
|
|
659
865
|
extendedCapsuleInstance,
|
|
660
866
|
runtimeSpineContracts,
|
|
661
|
-
capsuleInstance
|
|
867
|
+
capsuleInstance,
|
|
868
|
+
timing
|
|
662
869
|
})
|
|
663
870
|
},
|
|
664
871
|
hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {
|