@stream44.studio/encapsulate 0.4.0-rc.13 → 0.4.0-rc.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream44.studio/encapsulate",
3
- "version": "0.4.0-rc.13",
3
+ "version": "0.4.0-rc.15",
4
4
  "license": "BSD-2-Clause-Patent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -103,11 +103,15 @@ type TSpineContext = {
103
103
  export const CapsulePropertyTypes = {
104
104
  Function: 'Function' as const,
105
105
  GetterFunction: 'GetterFunction' as const,
106
+ SetterFunction: 'SetterFunction' as const,
106
107
  String: 'String' as const,
107
108
  Mapping: 'Mapping' as const,
108
109
  Literal: 'Literal' as const,
109
110
  Constant: 'Constant' as const,
110
111
  StructInit: 'StructInit' as const,
112
+ StructDispose: 'StructDispose' as const,
113
+ Init: 'Init' as const,
114
+ Dispose: 'Dispose' as const,
111
115
  }
112
116
 
113
117
  // ##################################################
@@ -232,26 +236,57 @@ export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpin
232
236
  }
233
237
  }
234
238
 
235
- // Run StructInit functions for the entire capsule tree (top-down)
236
- // Top-down means: child capsule's StructInit runs before extended parent's StructInit
239
+ // Run StructInit functions for struct capsules and Init functions for non-struct capsules
240
+ // StructInit: fires for struct-mapped capsules and any capsules they extend (top-down)
241
+ // Init: fires for non-struct capsules (those without StructInit)
237
242
  const structInitVisited = new Set<any>()
238
- async function runStructInits(instance: any) {
243
+ const structInstances: any[] = [] // Track struct instances for StructDispose
244
+ const nonStructInstances: any[] = [] // Track non-struct instances for Dispose
245
+
246
+ async function runStructInits(instance: any, isStructContext: boolean = false) {
239
247
  if (!instance || structInitVisited.has(instance)) return
240
248
  structInitVisited.add(instance)
241
249
 
242
- // Run this capsule's own StructInit functions first (top-down)
243
- if (instance.structInitFunctions?.length) {
244
- for (const fn of instance.structInitFunctions) {
245
- await fn()
250
+ // Determine if this instance is a struct capsule (has StructInit functions)
251
+ const hasStructInit = instance.structInitFunctions?.length > 0
252
+ const isStruct = hasStructInit || isStructContext
253
+
254
+ if (isStruct) {
255
+ // This is a struct capsule - run StructInit
256
+ structInstances.push(instance)
257
+ if (instance.structInitFunctions?.length) {
258
+ for (const fn of instance.structInitFunctions) {
259
+ await fn()
260
+ }
261
+ // Sync self values back to encapsulatedApi for spine contracts that use
262
+ // direct assignment (e.g. Static contract) rather than getters
263
+ if (instance.spineContractCapsuleInstances) {
264
+ for (const sci of Object.values(instance.spineContractCapsuleInstances) as any[]) {
265
+ if (sci.self && sci.encapsulatedApi) {
266
+ for (const key of Object.keys(sci.encapsulatedApi)) {
267
+ if (key in sci.self && sci.encapsulatedApi[key] !== sci.self[key]) {
268
+ sci.encapsulatedApi[key] = sci.self[key]
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }
246
274
  }
247
- // Sync self values back to encapsulatedApi for spine contracts that use
248
- // direct assignment (e.g. Static contract) rather than getters
249
- if (instance.spineContractCapsuleInstances) {
250
- for (const sci of Object.values(instance.spineContractCapsuleInstances) as any[]) {
251
- if (sci.self && sci.encapsulatedApi) {
252
- for (const key of Object.keys(sci.encapsulatedApi)) {
253
- if (key in sci.self && sci.encapsulatedApi[key] !== sci.self[key]) {
254
- sci.encapsulatedApi[key] = sci.self[key]
275
+ } else {
276
+ // This is a non-struct capsule - run Init
277
+ nonStructInstances.push(instance)
278
+ if (instance.initFunctions?.length) {
279
+ for (const fn of instance.initFunctions) {
280
+ await fn()
281
+ }
282
+ // Sync self values back to encapsulatedApi
283
+ if (instance.spineContractCapsuleInstances) {
284
+ for (const sci of Object.values(instance.spineContractCapsuleInstances) as any[]) {
285
+ if (sci.self && sci.encapsulatedApi) {
286
+ for (const key of Object.keys(sci.encapsulatedApi)) {
287
+ if (key in sci.self && sci.encapsulatedApi[key] !== sci.self[key]) {
288
+ sci.encapsulatedApi[key] = sci.self[key]
289
+ }
255
290
  }
256
291
  }
257
292
  }
@@ -259,15 +294,15 @@ export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpin
259
294
  }
260
295
  }
261
296
 
262
- // Recurse into extended capsule instance
297
+ // Recurse into extended capsule instance (inherits struct context)
263
298
  if (instance.extendedCapsuleInstance) {
264
- await runStructInits(instance.extendedCapsuleInstance)
299
+ await runStructInits(instance.extendedCapsuleInstance, isStruct)
265
300
  }
266
301
 
267
- // Recurse into mapped capsule instances
302
+ // Recurse into mapped capsule instances (each determines its own struct status)
268
303
  if (instance.mappedCapsuleInstances?.length) {
269
304
  for (const mappedInstance of instance.mappedCapsuleInstances) {
270
- await runStructInits(mappedInstance)
305
+ await runStructInits(mappedInstance, false)
271
306
  }
272
307
  }
273
308
  }
@@ -278,6 +313,38 @@ export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpin
278
313
 
279
314
  const result = await handler({ apis, capsules })
280
315
 
316
+ // Run StructDispose for struct capsules (reverse order - bottom-up)
317
+ for (let i = structInstances.length - 1; i >= 0; i--) {
318
+ const instance = structInstances[i]
319
+ if (instance.structDisposeFunctions?.length) {
320
+ for (const fn of instance.structDisposeFunctions) {
321
+ await fn()
322
+ }
323
+ }
324
+ }
325
+
326
+ // Run Dispose for non-struct capsules (reverse order - bottom-up)
327
+ for (let i = nonStructInstances.length - 1; i >= 0; i--) {
328
+ const instance = nonStructInstances[i]
329
+ if (instance.disposeFunctions?.length) {
330
+ for (const fn of instance.disposeFunctions) {
331
+ await fn()
332
+ }
333
+ }
334
+ }
335
+
336
+ // Clear all memoize timeouts to prevent memory leaks
337
+ for (const [, entry] of Object.entries(capsules)) {
338
+ const instance = (entry as any).instance
339
+ if (instance?.spineContractCapsuleInstances) {
340
+ for (const sci of Object.values(instance.spineContractCapsuleInstances) as any[]) {
341
+ if (typeof sci.clearMemoizeTimeouts === 'function') {
342
+ sci.clearMemoizeTimeouts()
343
+ }
344
+ }
345
+ }
346
+ }
347
+
281
348
  return result
282
349
  },
283
350
 
@@ -750,6 +817,9 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
750
817
  spineContractCapsuleInstances,
751
818
  extendedCapsuleInstance,
752
819
  structInitFunctions: [] as Array<() => any>,
820
+ structDisposeFunctions: [] as Array<() => any>,
821
+ initFunctions: [] as Array<() => any>,
822
+ disposeFunctions: [] as Array<() => any>,
753
823
  mappedCapsuleInstances: [] as Array<any>,
754
824
  rootCapsule: resolvedRootCapsule
755
825
  }
@@ -809,12 +879,21 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
809
879
  }
810
880
  ownSelf['#@stream44.studio/encapsulate/structs/Capsule'] = capsuleMetadataStruct
811
881
 
812
- // Collect StructInit functions and mapped capsule instances from all spine contract capsule instances
882
+ // Collect lifecycle functions and mapped capsule instances from all spine contract capsule instances
813
883
  for (const spineContractCapsuleInstance of Object.values(spineContractCapsuleInstances)) {
814
884
  const sci = spineContractCapsuleInstance as any
815
885
  if (sci.structInitFunctions?.length) {
816
886
  capsuleInstance.structInitFunctions.push(...sci.structInitFunctions)
817
887
  }
888
+ if (sci.structDisposeFunctions?.length) {
889
+ capsuleInstance.structDisposeFunctions.push(...sci.structDisposeFunctions)
890
+ }
891
+ if (sci.initFunctions?.length) {
892
+ capsuleInstance.initFunctions.push(...sci.initFunctions)
893
+ }
894
+ if (sci.disposeFunctions?.length) {
895
+ capsuleInstance.disposeFunctions.push(...sci.disposeFunctions)
896
+ }
818
897
  if (sci.mappedCapsuleInstances?.length) {
819
898
  capsuleInstance.mappedCapsuleInstances.push(...sci.mappedCapsuleInstances)
820
899
  }
@@ -953,7 +1032,10 @@ function relative(from: string, to: string): string {
953
1032
  }
954
1033
 
955
1034
  function isObject(item: any): boolean {
956
- return item && typeof item === 'object' && !Array.isArray(item)
1035
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return false
1036
+ // Only deep-merge plain objects — preserve instances like Map, Set, Date, etc.
1037
+ const proto = Object.getPrototypeOf(item)
1038
+ return proto === Object.prototype || proto === null
957
1039
  }
958
1040
 
959
1041
  export function merge<T = any>(target: T, ...sources: any[]): T {
@@ -204,6 +204,23 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
204
204
  }
205
205
  }
206
206
 
207
+ // Separate nested capsule-name-targeted options from own options
208
+ // Keys starting with '#' are own options for the mapped capsule
209
+ // Non-'#' keys are matched against capsule names in the mapping tree
210
+ let ownMappingOptions: Record<string, any> | undefined = undefined
211
+ let nestedCapsuleOptions: Record<string, any> | undefined = undefined
212
+ if (mappingOptions) {
213
+ for (const [key, value] of Object.entries(mappingOptions)) {
214
+ if (key.startsWith('#')) {
215
+ if (!ownMappingOptions) ownMappingOptions = {}
216
+ ownMappingOptions[key] = value
217
+ } else {
218
+ if (!nestedCapsuleOptions) nestedCapsuleOptions = {}
219
+ nestedCapsuleOptions[key] = value
220
+ }
221
+ }
222
+ }
223
+
207
224
  // Transform overrides if this mapping has a propertyContractDelegate
208
225
  let mappedOverrides = overrides
209
226
  if (property.definition.propertyContractDelegate) {
@@ -229,9 +246,21 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
229
246
  }
230
247
  }
231
248
 
249
+ // Merge nested capsule-name-targeted options into overrides
250
+ // These will be picked up when child capsules with matching names are instantiated
251
+ if (nestedCapsuleOptions) {
252
+ mappedOverrides = { ...mappedOverrides }
253
+ for (const [capsuleNameKey, capsuleOptions] of Object.entries(nestedCapsuleOptions)) {
254
+ mappedOverrides[capsuleNameKey] = {
255
+ ...(mappedOverrides[capsuleNameKey] || {}),
256
+ ...capsuleOptions
257
+ }
258
+ }
259
+ }
260
+
232
261
  const mappedCapsuleInstance = await mappedCapsule.makeInstance({
233
262
  overrides: mappedOverrides,
234
- options: mappingOptions,
263
+ options: ownMappingOptions,
235
264
  runtimeSpineContracts: this.runtimeSpineContracts,
236
265
  rootCapsule: this.capsuleInstance?.rootCapsule
237
266
  })
@@ -429,6 +458,25 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
429
458
  protected mapFunctionProperty({ property }: { property: any }) {
430
459
  const selfProxy = this.createSelfProxy()
431
460
  const boundFunction = property.definition.value.bind(selfProxy)
461
+ const memoizeOption = property.definition.memoize
462
+ const shouldMemoize = memoizeOption === true || typeof memoizeOption === 'number'
463
+ const memoizeTtl = typeof memoizeOption === 'number' ? memoizeOption : null
464
+ const cacheKey = `function:${property.name}`
465
+
466
+ // Helper to set up TTL expiration
467
+ const setupTtlExpiration = () => {
468
+ if (memoizeTtl !== null) {
469
+ // Clear any existing timeout for this key
470
+ if (this.memoizeTimeouts.has(cacheKey)) {
471
+ clearTimeout(this.memoizeTimeouts.get(cacheKey))
472
+ }
473
+ const timeout = setTimeout(() => {
474
+ this.memoizeCache.delete(cacheKey)
475
+ this.memoizeTimeouts.delete(cacheKey)
476
+ }, memoizeTtl)
477
+ this.memoizeTimeouts.set(cacheKey, timeout)
478
+ }
479
+ }
432
480
 
433
481
  const valueKey = `__value_${property.name}`
434
482
  Object.defineProperty(this.encapsulatedApi, valueKey, {
@@ -441,6 +489,48 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
441
489
  Object.defineProperty(this.encapsulatedApi, property.name, {
442
490
  get: () => {
443
491
  return (...args: any[]) => {
492
+ // Check memoize cache first (only for no-arg calls or first call)
493
+ if (shouldMemoize && this.memoizeCache.has(cacheKey)) {
494
+ const cachedResult = this.memoizeCache.get(cacheKey)
495
+
496
+ const callEvent: any = {
497
+ event: 'call',
498
+ eventIndex: this.incrementEventIndex(),
499
+ target: {
500
+ capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
501
+ spineContractCapsuleInstanceId: this.id,
502
+ prop: property.name,
503
+ },
504
+ args,
505
+ memoized: true
506
+ }
507
+
508
+ if (this.capsuleSourceNameRef) {
509
+ callEvent.target.capsuleSourceNameRef = this.capsuleSourceNameRef
510
+ }
511
+ if (this.capsuleSourceNameRefHash) {
512
+ callEvent.target.capsuleSourceNameRefHash = this.capsuleSourceNameRefHash
513
+ }
514
+
515
+ this.addCallerContextToEvent(callEvent)
516
+ this.onMembraneEvent?.(callEvent)
517
+
518
+ const resultEvent: any = {
519
+ event: 'call-result',
520
+ eventIndex: this.incrementEventIndex(),
521
+ callEventIndex: callEvent.eventIndex,
522
+ target: {
523
+ spineContractCapsuleInstanceId: this.id,
524
+ },
525
+ result: cachedResult,
526
+ memoized: true
527
+ }
528
+
529
+ this.onMembraneEvent?.(resultEvent)
530
+
531
+ return cachedResult
532
+ }
533
+
444
534
  const callEvent: any = {
445
535
  event: 'call',
446
536
  eventIndex: this.incrementEventIndex(),
@@ -464,6 +554,12 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
464
554
 
465
555
  const result = boundFunction(...args)
466
556
 
557
+ // Store in memoize cache if memoize is enabled
558
+ if (shouldMemoize) {
559
+ this.memoizeCache.set(cacheKey, result)
560
+ setupTtlExpiration()
561
+ }
562
+
467
563
  const resultEvent: any = {
468
564
  event: 'call-result',
469
565
  eventIndex: this.incrementEventIndex(),
@@ -487,12 +583,65 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
487
583
  protected mapGetterFunctionProperty({ property }: { property: any }) {
488
584
  const getterFn = property.definition.value
489
585
  const selfProxy = this.createSelfProxy()
586
+ const memoizeOption = property.definition.memoize
587
+ const shouldMemoize = memoizeOption === true || typeof memoizeOption === 'number'
588
+ const memoizeTtl = typeof memoizeOption === 'number' ? memoizeOption : null
589
+ const cacheKey = `getter:${property.name}`
590
+
591
+ // Helper to set up TTL expiration
592
+ const setupTtlExpiration = () => {
593
+ if (memoizeTtl !== null) {
594
+ // Clear any existing timeout for this key
595
+ if (this.memoizeTimeouts.has(cacheKey)) {
596
+ clearTimeout(this.memoizeTimeouts.get(cacheKey))
597
+ }
598
+ const timeout = setTimeout(() => {
599
+ this.memoizeCache.delete(cacheKey)
600
+ this.memoizeTimeouts.delete(cacheKey)
601
+ }, memoizeTtl)
602
+ this.memoizeTimeouts.set(cacheKey, timeout)
603
+ }
604
+ }
490
605
 
491
606
  Object.defineProperty(this.encapsulatedApi, property.name, {
492
607
  get: () => {
608
+ // Check memoize cache first
609
+ if (shouldMemoize && this.memoizeCache.has(cacheKey)) {
610
+ const cachedResult = this.memoizeCache.get(cacheKey)
611
+
612
+ const event: any = {
613
+ event: 'get',
614
+ eventIndex: this.incrementEventIndex(),
615
+ target: {
616
+ capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
617
+ spineContractCapsuleInstanceId: this.id,
618
+ prop: property.name,
619
+ },
620
+ value: cachedResult,
621
+ memoized: true
622
+ }
623
+
624
+ if (this.capsuleSourceNameRef) {
625
+ event.target.capsuleSourceNameRef = this.capsuleSourceNameRef
626
+ }
627
+ if (this.capsuleSourceNameRefHash) {
628
+ event.target.capsuleSourceNameRefHash = this.capsuleSourceNameRefHash
629
+ }
630
+
631
+ this.addCallerContextToEvent(event)
632
+ this.onMembraneEvent?.(event)
633
+ return cachedResult
634
+ }
635
+
493
636
  // Call the getter function lazily when accessed with proper this context
494
637
  const result = getterFn.call(selfProxy)
495
638
 
639
+ // Store in memoize cache if memoize is enabled
640
+ if (shouldMemoize) {
641
+ this.memoizeCache.set(cacheKey, result)
642
+ setupTtlExpiration()
643
+ }
644
+
496
645
  const event: any = {
497
646
  event: 'get',
498
647
  eventIndex: this.incrementEventIndex(),
@@ -524,7 +673,16 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
524
673
  if (this.ownSelf) {
525
674
  Object.defineProperty(this.ownSelf, property.name, {
526
675
  get: () => {
527
- return getterFn.call(selfProxy)
676
+ // For ownSelf, also respect memoization
677
+ if (shouldMemoize && this.memoizeCache.has(cacheKey)) {
678
+ return this.memoizeCache.get(cacheKey)
679
+ }
680
+ const result = getterFn.call(selfProxy)
681
+ if (shouldMemoize) {
682
+ this.memoizeCache.set(cacheKey, result)
683
+ setupTtlExpiration()
684
+ }
685
+ return result
528
686
  },
529
687
  enumerable: true,
530
688
  configurable: true
@@ -19,7 +19,12 @@ export class ContractCapsuleInstanceFactory {
19
19
  protected runtimeSpineContracts?: Record<string, any>
20
20
  protected capsuleInstance?: any
21
21
  public structInitFunctions: Array<() => any> = []
22
+ public structDisposeFunctions: Array<() => any> = []
23
+ public initFunctions: Array<() => any> = []
24
+ public disposeFunctions: Array<() => any> = []
22
25
  public mappedCapsuleInstances: Array<any> = []
26
+ protected memoizeCache: Map<string, any> = new Map()
27
+ protected memoizeTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map()
23
28
 
24
29
  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 }) {
25
30
  this.spineContractUri = spineContractUri
@@ -50,8 +55,16 @@ export class ContractCapsuleInstanceFactory {
50
55
  this.mapFunctionProperty({ property })
51
56
  } else if (property.definition.type === CapsulePropertyTypes.GetterFunction) {
52
57
  this.mapGetterFunctionProperty({ property })
58
+ } else if (property.definition.type === CapsulePropertyTypes.SetterFunction) {
59
+ this.mapSetterFunctionProperty({ property })
53
60
  } else if (property.definition.type === CapsulePropertyTypes.StructInit) {
54
61
  this.mapStructInitProperty({ property })
62
+ } else if (property.definition.type === CapsulePropertyTypes.StructDispose) {
63
+ this.mapStructDisposeProperty({ property })
64
+ } else if (property.definition.type === CapsulePropertyTypes.Init) {
65
+ this.mapInitProperty({ property })
66
+ } else if (property.definition.type === CapsulePropertyTypes.Dispose) {
67
+ this.mapDisposeProperty({ property })
55
68
  }
56
69
  }
57
70
 
@@ -201,6 +214,23 @@ export class ContractCapsuleInstanceFactory {
201
214
  }
202
215
  }
203
216
 
217
+ // Separate nested capsule-name-targeted options from own options
218
+ // Keys starting with '#' are own options for the mapped capsule
219
+ // Non-'#' keys are matched against capsule names in the mapping tree
220
+ let ownMappingOptions: Record<string, any> | undefined = undefined
221
+ let nestedCapsuleOptions: Record<string, any> | undefined = undefined
222
+ if (mappingOptions) {
223
+ for (const [key, value] of Object.entries(mappingOptions)) {
224
+ if (key.startsWith('#')) {
225
+ if (!ownMappingOptions) ownMappingOptions = {}
226
+ ownMappingOptions[key] = value
227
+ } else {
228
+ if (!nestedCapsuleOptions) nestedCapsuleOptions = {}
229
+ nestedCapsuleOptions[key] = value
230
+ }
231
+ }
232
+ }
233
+
204
234
  // Transform overrides if this mapping has a propertyContractDelegate
205
235
  let mappedOverrides = overrides
206
236
  if (property.definition.propertyContractDelegate) {
@@ -225,10 +255,22 @@ export class ContractCapsuleInstanceFactory {
225
255
  }
226
256
  }
227
257
 
258
+ // Merge nested capsule-name-targeted options into overrides
259
+ // These will be picked up when child capsules with matching names are instantiated
260
+ if (nestedCapsuleOptions) {
261
+ mappedOverrides = { ...mappedOverrides }
262
+ for (const [capsuleNameKey, capsuleOptions] of Object.entries(nestedCapsuleOptions)) {
263
+ mappedOverrides[capsuleNameKey] = {
264
+ ...(mappedOverrides[capsuleNameKey] || {}),
265
+ ...capsuleOptions
266
+ }
267
+ }
268
+ }
269
+
228
270
  const apiTarget = this.getApiTarget({ property })
229
271
  const mappedInstance = await mappedCapsule.makeInstance({
230
272
  overrides: mappedOverrides,
231
- options: mappingOptions,
273
+ options: ownMappingOptions,
232
274
  runtimeSpineContracts: this.runtimeSpineContracts,
233
275
  rootCapsule: this.capsuleInstance?.rootCapsule
234
276
  })
@@ -321,18 +363,82 @@ export class ContractCapsuleInstanceFactory {
321
363
  protected mapFunctionProperty({ property }: { property: any }) {
322
364
  const apiTarget = this.getApiTarget({ property })
323
365
  const selfProxy = this.createSelfProxy()
324
- apiTarget[property.name] = property.definition.value.bind(selfProxy)
366
+ const boundFunction = property.definition.value.bind(selfProxy)
367
+ const memoizeOption = property.definition.memoize
368
+ const shouldMemoize = memoizeOption === true || typeof memoizeOption === 'number'
369
+ const memoizeTtl = typeof memoizeOption === 'number' ? memoizeOption : null
370
+ const cacheKey = `function:${property.name}`
371
+
372
+ if (shouldMemoize) {
373
+ // Wrap the function to support memoization
374
+ apiTarget[property.name] = (...args: any[]) => {
375
+ if (this.memoizeCache.has(cacheKey)) {
376
+ return this.memoizeCache.get(cacheKey)
377
+ }
378
+ const result = boundFunction(...args)
379
+ this.memoizeCache.set(cacheKey, result)
380
+
381
+ // Set up TTL expiration if specified
382
+ if (memoizeTtl !== null) {
383
+ // Clear any existing timeout for this key
384
+ if (this.memoizeTimeouts.has(cacheKey)) {
385
+ clearTimeout(this.memoizeTimeouts.get(cacheKey))
386
+ }
387
+ const timeout = setTimeout(() => {
388
+ this.memoizeCache.delete(cacheKey)
389
+ this.memoizeTimeouts.delete(cacheKey)
390
+ }, memoizeTtl)
391
+ this.memoizeTimeouts.set(cacheKey, timeout)
392
+ }
393
+
394
+ return result
395
+ }
396
+ } else {
397
+ apiTarget[property.name] = boundFunction
398
+ }
325
399
  }
326
400
 
327
401
  protected mapGetterFunctionProperty({ property }: { property: any }) {
328
402
  const apiTarget = this.getApiTarget({ property })
329
403
  const getterFn = property.definition.value
330
404
  const selfProxy = this.createSelfProxy()
405
+ const memoizeOption = property.definition.memoize
406
+ const shouldMemoize = memoizeOption === true || typeof memoizeOption === 'number'
407
+ const memoizeTtl = typeof memoizeOption === 'number' ? memoizeOption : null
408
+ const cacheKey = `getter:${property.name}`
409
+
410
+ // Helper to set up TTL expiration
411
+ const setupTtlExpiration = () => {
412
+ if (memoizeTtl !== null) {
413
+ // Clear any existing timeout for this key
414
+ if (this.memoizeTimeouts.has(cacheKey)) {
415
+ clearTimeout(this.memoizeTimeouts.get(cacheKey))
416
+ }
417
+ const timeout = setTimeout(() => {
418
+ this.memoizeCache.delete(cacheKey)
419
+ this.memoizeTimeouts.delete(cacheKey)
420
+ }, memoizeTtl)
421
+ this.memoizeTimeouts.set(cacheKey, timeout)
422
+ }
423
+ }
331
424
 
332
425
  // Define a lazy getter that calls the function only when accessed with proper this context
333
426
  Object.defineProperty(apiTarget, property.name, {
334
427
  get: () => {
335
- return getterFn.call(selfProxy)
428
+ // Check memoize cache first
429
+ if (shouldMemoize && this.memoizeCache.has(cacheKey)) {
430
+ return this.memoizeCache.get(cacheKey)
431
+ }
432
+
433
+ const result = getterFn.call(selfProxy)
434
+
435
+ // Store in memoize cache if memoize is enabled
436
+ if (shouldMemoize) {
437
+ this.memoizeCache.set(cacheKey, result)
438
+ setupTtlExpiration()
439
+ }
440
+
441
+ return result
336
442
  },
337
443
  enumerable: true,
338
444
  configurable: true
@@ -343,7 +449,42 @@ export class ContractCapsuleInstanceFactory {
343
449
  if (this.ownSelf) {
344
450
  Object.defineProperty(this.ownSelf, property.name, {
345
451
  get: () => {
346
- return getterFn.call(selfProxy)
452
+ // For ownSelf, also respect memoization
453
+ if (shouldMemoize && this.memoizeCache.has(cacheKey)) {
454
+ return this.memoizeCache.get(cacheKey)
455
+ }
456
+ const result = getterFn.call(selfProxy)
457
+ if (shouldMemoize) {
458
+ this.memoizeCache.set(cacheKey, result)
459
+ setupTtlExpiration()
460
+ }
461
+ return result
462
+ },
463
+ enumerable: true,
464
+ configurable: true
465
+ })
466
+ }
467
+ }
468
+
469
+ protected mapSetterFunctionProperty({ property }: { property: any }) {
470
+ const apiTarget = this.getApiTarget({ property })
471
+ const setterFn = property.definition.value
472
+ const selfProxy = this.createSelfProxy()
473
+
474
+ // Define a setter that calls the function when the property is assigned
475
+ Object.defineProperty(apiTarget, property.name, {
476
+ set: (value: any) => {
477
+ setterFn.call(selfProxy, value)
478
+ },
479
+ enumerable: true,
480
+ configurable: true
481
+ })
482
+
483
+ // Also define the setter on ownSelf so this.self.propertyName = value works
484
+ if (this.ownSelf) {
485
+ Object.defineProperty(this.ownSelf, property.name, {
486
+ set: (value: any) => {
487
+ setterFn.call(selfProxy, value)
347
488
  },
348
489
  enumerable: true,
349
490
  configurable: true
@@ -357,10 +498,35 @@ export class ContractCapsuleInstanceFactory {
357
498
  this.structInitFunctions.push(boundFunction)
358
499
  }
359
500
 
501
+ protected mapStructDisposeProperty({ property }: { property: any }) {
502
+ const selfProxy = this.createSelfProxy()
503
+ const boundFunction = property.definition.value.bind(selfProxy)
504
+ this.structDisposeFunctions.push(boundFunction)
505
+ }
506
+
507
+ protected mapInitProperty({ property }: { property: any }) {
508
+ const selfProxy = this.createSelfProxy()
509
+ const boundFunction = property.definition.value.bind(selfProxy)
510
+ this.initFunctions.push(boundFunction)
511
+ }
512
+
513
+ protected mapDisposeProperty({ property }: { property: any }) {
514
+ const selfProxy = this.createSelfProxy()
515
+ const boundFunction = property.definition.value.bind(selfProxy)
516
+ this.disposeFunctions.push(boundFunction)
517
+ }
518
+
360
519
  async freeze(options: any): Promise<any> {
361
520
  return this.freezeCapsule?.(options) || {}
362
521
  }
363
522
 
523
+ public clearMemoizeTimeouts() {
524
+ for (const timeout of this.memoizeTimeouts.values()) {
525
+ clearTimeout(timeout)
526
+ }
527
+ this.memoizeTimeouts.clear()
528
+ }
529
+
364
530
  }
365
531
 
366
532