@stream44.studio/encapsulate 0.2.0-rc.2 → 0.2.0-rc.4

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.

Potentially problematic release.


This version of @stream44.studio/encapsulate might be problematic. Click here for more details.

@@ -1,5 +1,8 @@
1
1
  import { CapsulePropertyTypes, join } from "../../encapsulate"
2
2
 
3
+ // Type for capsule instance registry - scoped per spine contract instance
4
+ export type CapsuleInstanceRegistry = Map<string, any>
5
+
3
6
  export class ContractCapsuleInstanceFactory {
4
7
 
5
8
  protected spineContractUri: string
@@ -10,16 +13,24 @@ export class ContractCapsuleInstanceFactory {
10
13
  protected importCapsule?: (filepath: string) => Promise<any>
11
14
  protected spineFilesystemRoot?: string
12
15
  protected freezeCapsule?: (capsule: any) => Promise<any>
16
+ protected instanceRegistry?: CapsuleInstanceRegistry
17
+ protected extendedCapsuleInstance?: any
18
+ protected ownSelf?: any
19
+ protected runtimeSpineContracts?: Record<string, any>
13
20
 
14
- constructor({ spineContractUri, capsule, self, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule }: { spineContractUri: string, capsule: any, self: any, encapsulatedApi: Record<string, any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, freezeCapsule?: (capsule: any) => Promise<any> }) {
21
+ constructor({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, runtimeSpineContracts }: { 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> }) {
15
22
  this.spineContractUri = spineContractUri
16
23
  this.capsule = capsule
17
24
  this.self = self
25
+ this.ownSelf = ownSelf
18
26
  this.encapsulatedApi = encapsulatedApi
19
27
  this.resolve = resolve
20
28
  this.importCapsule = importCapsule
21
29
  this.spineFilesystemRoot = spineFilesystemRoot
22
30
  this.freezeCapsule = freezeCapsule
31
+ this.instanceRegistry = instanceRegistry
32
+ this.extendedCapsuleInstance = extendedCapsuleInstance
33
+ this.runtimeSpineContracts = runtimeSpineContracts
23
34
  }
24
35
 
25
36
  async mapProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
@@ -59,7 +70,11 @@ export class ContractCapsuleInstanceFactory {
59
70
  if (!this.spineFilesystemRoot) throw new Error(`'spineFilesystemRoot' not set!`)
60
71
  if (!this.importCapsule) throw new Error(`'importCapsule' not set!`)
61
72
 
62
- const parentPath = join(this.spineFilesystemRoot, this.capsule.cst.source.moduleFilepath)
73
+ // Use encapsulateOptions.moduleFilepath (always available) instead of cst.source.moduleFilepath
74
+ const moduleFilepath = this.capsule.encapsulateOptions?.moduleFilepath || this.capsule.cst?.source?.moduleFilepath
75
+ if (!moduleFilepath) throw new Error(`'moduleFilepath' not available on capsule!`)
76
+
77
+ const parentPath = join(this.spineFilesystemRoot, moduleFilepath)
63
78
  const filepath = await this.resolve(property.definition.value, parentPath)
64
79
  mappedCapsule = await this.importCapsule(filepath)
65
80
  } else if (
@@ -121,9 +136,63 @@ export class ContractCapsuleInstanceFactory {
121
136
  const mappedCapsule = await this.resolveMappedCapsule({ property })
122
137
  const constants = await this.extractConstants({ mappedCapsule })
123
138
 
124
- const mappingOptions = await property.definition.options?.({
125
- constants
126
- })
139
+ // delegateOptions is set by encapsulate.ts for property contract delegates
140
+ // options can be a function or an object for regular mappings
141
+ const mappingOptions = property.definition.delegateOptions
142
+ || (typeof property.definition.options === 'function'
143
+ ? await property.definition.options({ constants })
144
+ : property.definition.options)
145
+
146
+ // Check for existing instance in registry - reuse if available when no options
147
+ // Pre-registration with null allows parent capsules to "claim" a slot before child capsules process
148
+ const capsuleName = mappedCapsule.encapsulateOptions?.capsuleName
149
+
150
+ if (capsuleName && this.instanceRegistry) {
151
+ if (this.instanceRegistry.has(capsuleName)) {
152
+ const existingEntry = this.instanceRegistry.get(capsuleName)
153
+
154
+ // Only reuse if current mapping has no options
155
+ if (!mappingOptions) {
156
+ // Use deferred proxy that resolves from registry when accessed
157
+ // Works for both null (pre-registered) and actual instances
158
+ const apiTarget = this.getApiTarget({ property })
159
+ const registry = this.instanceRegistry
160
+ apiTarget[property.name] = new Proxy({} as any, {
161
+ get: (_target: any, apiProp: string | symbol) => {
162
+ if (typeof apiProp === 'symbol') return undefined
163
+ const resolvedInstance = registry.get(capsuleName)
164
+ if (!resolvedInstance) {
165
+ throw new Error(`Capsule instance not yet resolved: ${capsuleName}`)
166
+ }
167
+ // Access through .api if it exists (for capsule instances with getters)
168
+ if (resolvedInstance.api && apiProp in resolvedInstance.api) {
169
+ return resolvedInstance.api[apiProp]
170
+ }
171
+ return resolvedInstance[apiProp]
172
+ }
173
+ })
174
+ this.self[property.name] = new Proxy({} as any, {
175
+ get: (_target, prop) => {
176
+ if (typeof prop === 'symbol') return undefined
177
+ const resolvedInstance = registry.get(capsuleName)
178
+ if (!resolvedInstance) {
179
+ throw new Error(`Capsule instance not yet resolved: ${capsuleName}`)
180
+ }
181
+ const value = resolvedInstance.api?.[prop] ?? resolvedInstance[prop]
182
+ if (value && typeof value === 'object' && value.api) {
183
+ return value.api
184
+ }
185
+ return value
186
+ }
187
+ })
188
+ return
189
+ }
190
+ // If current mapping has options, fall through to create new instance
191
+ } else {
192
+ // Pre-register as null to claim the slot for this capsule
193
+ this.instanceRegistry.set(capsuleName, null)
194
+ }
195
+ }
127
196
 
128
197
  // Transform overrides if this mapping has a propertyContractDelegate
129
198
  let mappedOverrides = overrides
@@ -152,9 +221,16 @@ export class ContractCapsuleInstanceFactory {
152
221
  const apiTarget = this.getApiTarget({ property })
153
222
  const mappedInstance = await mappedCapsule.makeInstance({
154
223
  overrides: mappedOverrides,
155
- options: mappingOptions
224
+ options: mappingOptions,
225
+ runtimeSpineContracts: this.runtimeSpineContracts
156
226
  })
157
227
 
228
+ // Register the instance (replaces null pre-registration marker)
229
+ // Always register to make instance available for child capsules with deferred proxies
230
+ if (capsuleName && this.instanceRegistry) {
231
+ this.instanceRegistry.set(capsuleName, mappedInstance)
232
+ }
233
+
158
234
  apiTarget[property.name] = mappedInstance
159
235
  // Use proxy to unwrap .api for this.self so internal references work
160
236
  this.self[property.name] = mappedInstance.api ? new Proxy(mappedInstance.api, {
@@ -186,24 +262,33 @@ export class ContractCapsuleInstanceFactory {
186
262
 
187
263
  protected mapLiteralProperty({ property }: { property: any }) {
188
264
  const apiTarget = this.getApiTarget({ property })
189
- const value = typeof this.self[property.name] !== 'undefined'
190
- ? this.self[property.name]
265
+ // Use existing value from self if defined, otherwise use property definition
266
+ // This preserves values set by child capsules in the extends chain
267
+ const existingValue = this.self[property.name]
268
+ const value = existingValue !== undefined
269
+ ? existingValue
191
270
  : property.definition.value
192
271
 
193
272
  // Assign to both apiTarget and self so getter functions can access via this
194
273
  apiTarget[property.name] = value
195
- this.self[property.name] = value
274
+ // Only update self if it wasn't already set (preserve child values)
275
+ if (existingValue === undefined) {
276
+ this.self[property.name] = value
277
+ }
196
278
  }
197
279
 
198
- protected mapFunctionProperty({ property }: { property: any }) {
199
- const apiTarget = this.getApiTarget({ property })
200
-
201
- // Create a proxy for this.self that intercepts property access
202
- // Prefer this.self (which has unwrapped APIs) over encapsulatedApi
203
- const selfProxy = new Proxy(this.self, {
280
+ protected createSelfProxy() {
281
+ const extendedApi = this.extendedCapsuleInstance?.api
282
+ const ownSelf = this.ownSelf
283
+ return new Proxy(this.self, {
204
284
  get: (target: any, prop: string | symbol) => {
205
285
  if (typeof prop === 'symbol') return target[prop]
206
286
 
287
+ // 'self' property returns ownSelf (only this capsule's own properties)
288
+ if (prop === 'self' && ownSelf) {
289
+ return ownSelf
290
+ }
291
+
207
292
  // First check if the property exists in target (this.self)
208
293
  if (prop in target) {
209
294
  return target[prop]
@@ -214,36 +299,26 @@ export class ContractCapsuleInstanceFactory {
214
299
  return this.encapsulatedApi[prop]
215
300
  }
216
301
 
302
+ // Fall back to extended capsule's API
303
+ if (extendedApi && prop in extendedApi) {
304
+ return extendedApi[prop]
305
+ }
306
+
217
307
  return undefined
218
308
  }
219
309
  })
310
+ }
220
311
 
312
+ protected mapFunctionProperty({ property }: { property: any }) {
313
+ const apiTarget = this.getApiTarget({ property })
314
+ const selfProxy = this.createSelfProxy()
221
315
  apiTarget[property.name] = property.definition.value.bind(selfProxy)
222
316
  }
223
317
 
224
318
  protected mapGetterFunctionProperty({ property }: { property: any }) {
225
319
  const apiTarget = this.getApiTarget({ property })
226
320
  const getterFn = property.definition.value
227
-
228
- // Create a proxy for this.self that intercepts property access
229
- // Prefer this.self (which has unwrapped APIs) over encapsulatedApi
230
- const selfProxy = new Proxy(this.self, {
231
- get: (target: any, prop: string | symbol) => {
232
- if (typeof prop === 'symbol') return target[prop]
233
-
234
- // First check if the property exists in target (this.self)
235
- if (prop in target) {
236
- return target[prop]
237
- }
238
-
239
- // Fall back to encapsulatedApi
240
- if (prop in this.encapsulatedApi) {
241
- return this.encapsulatedApi[prop]
242
- }
243
-
244
- return undefined
245
- }
246
- })
321
+ const selfProxy = this.createSelfProxy()
247
322
 
248
323
  // Define a lazy getter that calls the function only when accessed with proper this context
249
324
  Object.defineProperty(apiTarget, property.name, {
@@ -253,6 +328,18 @@ export class ContractCapsuleInstanceFactory {
253
328
  enumerable: true,
254
329
  configurable: true
255
330
  })
331
+
332
+ // Also define the getter on ownSelf so this.self.propertyName works for getter functions
333
+ // This ensures this.self accesses the getter, not a raw value
334
+ if (this.ownSelf) {
335
+ Object.defineProperty(this.ownSelf, property.name, {
336
+ get: () => {
337
+ return getterFn.call(selfProxy)
338
+ },
339
+ enumerable: true,
340
+ configurable: true
341
+ })
342
+ }
256
343
  }
257
344
 
258
345
  async freeze(options: any): Promise<any> {
@@ -266,18 +353,25 @@ export class ContractCapsuleInstanceFactory {
266
353
 
267
354
  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 } = {}) {
268
355
 
356
+ const instanceRegistry: CapsuleInstanceRegistry = new Map()
357
+
269
358
  return {
270
359
  '#': CapsuleSpineContract['#'],
271
- makeContractCapsuleInstance: ({ spineContractUri, capsule, self, encapsulatedApi }: { spineContractUri: string, capsule: any, self: any, encapsulatedApi: Record<string, any> }) => {
360
+ instanceRegistry,
361
+ makeContractCapsuleInstance: ({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, extendedCapsuleInstance, runtimeSpineContracts }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any> }) => {
272
362
  return new ContractCapsuleInstanceFactory({
273
363
  spineContractUri,
274
364
  capsule,
275
365
  self,
366
+ ownSelf,
276
367
  encapsulatedApi,
277
368
  resolve,
278
369
  importCapsule,
279
370
  spineFilesystemRoot,
280
- freezeCapsule
371
+ freezeCapsule,
372
+ instanceRegistry,
373
+ extendedCapsuleInstance,
374
+ runtimeSpineContracts
281
375
  })
282
376
  },
283
377
  hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {
@@ -1,5 +1,6 @@
1
1
  import { join, dirname } from 'path'
2
2
  import { writeFile, mkdir, readFile, stat } from 'fs/promises'
3
+ import { createRequire } from 'module'
3
4
  import { Spine, SpineRuntime, CapsulePropertyTypes, makeImportStack, merge } from "../encapsulate"
4
5
  import { StaticAnalyzer } from "../../src/static-analyzer.v0"
5
6
  import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector.v0"
@@ -7,8 +8,49 @@ import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModu
7
8
 
8
9
  export { merge }
9
10
 
10
- // TODO: Make portable so we can run on other runtimes
11
- const resolve = Bun.resolve
11
+ // Custom resolve function that uses createRequire for proper package resolution
12
+ async function resolve(uri: string, fromPath: string, spineRoot?: string): Promise<string> {
13
+ try {
14
+ // Create a require function from the parent file's directory
15
+ const require = createRequire(fromPath)
16
+ const result = require.resolve(uri)
17
+ return result
18
+ } catch (error: any) {
19
+ // If standard resolution fails and uri is a scoped package, try resolving
20
+ // the package root first, then append the subpath relative to it.
21
+ if (uri.startsWith('@')) {
22
+ const match = uri.match(/^(@[^/]+\/[^/]+)\/(.+)$/)
23
+ if (match) {
24
+ const [, packageName, subpath] = match
25
+ try {
26
+ const require = createRequire(fromPath)
27
+ const packageJsonPath = require.resolve(join(packageName, 'package.json'))
28
+ const packageRoot = dirname(packageJsonPath)
29
+ const fsPath = join(packageRoot, subpath + '.ts')
30
+ await stat(fsPath)
31
+ return fsPath
32
+ } catch {
33
+ // Fall through
34
+ }
35
+ }
36
+
37
+ // Fallback: transform @scope/package/path to scope/packages/package/path relative to spineRoot
38
+ if (spineRoot) {
39
+ const transformed = uri.replace(/^@([^/]+)\/([^/]+)\/(.+)$/, '$1/packages/$2/$3')
40
+ const fsPath = join(spineRoot, transformed + '.ts')
41
+ try {
42
+ await stat(fsPath)
43
+ return fsPath
44
+ } catch {
45
+ // File doesn't exist, fall through to Bun.resolve
46
+ }
47
+ }
48
+ }
49
+
50
+ // Final fallback to Bun.resolve
51
+ return await Bun.resolve(uri, fromPath)
52
+ }
53
+ }
12
54
 
13
55
 
14
56
  export async function CapsuleSpineFactory({
@@ -67,10 +109,10 @@ export async function CapsuleSpineFactory({
67
109
  resolve: async (uri: string, parentFilepath: string) => {
68
110
  // For relative paths, join with parent directory first
69
111
  if (/^\.\.?\//.test(uri)) {
70
- return await resolve(join(parentFilepath, '..', uri), spineFilesystemRoot)
112
+ return await resolve(join(parentFilepath, '..', uri), spineFilesystemRoot, spineFilesystemRoot)
71
113
  }
72
- // For absolute/package paths, use Bun.resolve directly
73
- return await resolve(uri, parentFilepath)
114
+ // For absolute/package paths, use custom resolve with spine root
115
+ return await resolve(uri, parentFilepath, spineFilesystemRoot)
74
116
  },
75
117
  importCapsule: async (filepath: string) => {
76
118
  const shortPath = filepath.replace(/^.*\/genesis\//, '')
@@ -126,11 +168,15 @@ export async function CapsuleSpineFactory({
126
168
 
127
169
  let snapshotValues = {}
128
170
 
171
+ // Create a new set per freezeCapsule call to track circular dependencies within this projection tree
172
+ const projectingCapsules = new Set<string>()
173
+
129
174
  const projected = await projector.projectCapsule({
130
175
  capsule,
131
176
  capsules,
132
177
  snapshotValues,
133
- spineContractUri
178
+ spineContractUri,
179
+ projectingCapsules
134
180
  })
135
181
 
136
182
  return snapshotValues
@@ -281,6 +327,7 @@ export async function CapsuleSpineFactory({
281
327
  run,
282
328
  freeze,
283
329
  loadCapsule,
330
+ spineContractInstances, // Expose for testing
284
331
  hoistSnapshot: async ({ snapshot }: { snapshot: any }) => {
285
332
 
286
333
  timing?.recordMajor('HOIST SNAPSHOT: START')