@stream44.studio/encapsulate 0.4.0-rc.39 → 0.4.0-rc.43
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 +1 -1
- package/src/encapsulate.ts +85 -25
- package/src/spine-contracts/CapsuleSpineContract.v0/Membrane.ts +82 -14
- package/src/spine-contracts/CapsuleSpineContract.v0/README.md +43 -1
- package/src/spine-contracts/CapsuleSpineContract.v0/Static.ts +118 -18
- package/src/spine-factories/CapsuleSpineFactory.ts +139 -35
- package/src/spine-factories/TimingObserver.ts +24 -14
- package/src/static-analyzer.ts +16 -4
- package/structs/Capsule.ts +5 -0
package/package.json
CHANGED
package/src/encapsulate.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
|
|
2
|
+
import type { TimingObserverInterface } from './spine-factories/TimingObserver'
|
|
3
|
+
|
|
2
4
|
// CACHE_BUST_VERSION: Increment this whenever CST cache must be invalidated due to structural changes
|
|
3
5
|
// This ensures projected capsules are regenerated when the CST format changes
|
|
4
6
|
const CACHE_BUST_VERSION = 22
|
|
@@ -7,7 +9,7 @@ type TSpineOptions = {
|
|
|
7
9
|
spineFilesystemRoot?: string,
|
|
8
10
|
spineContracts: Record<string, any>,
|
|
9
11
|
staticAnalyzer?: any,
|
|
10
|
-
timing?:
|
|
12
|
+
timing?: TimingObserverInterface,
|
|
11
13
|
projectionContext?: {
|
|
12
14
|
capsuleModuleProjectionPackage?: string,
|
|
13
15
|
capsuleModuleProjectionRoot?: string,
|
|
@@ -56,6 +58,9 @@ type TCapsuleSnapshot = {
|
|
|
56
58
|
type TCapsuleMakeInstanceOptions = {
|
|
57
59
|
overrides?: Record<string, any>,
|
|
58
60
|
options?: Record<string, any>,
|
|
61
|
+
/** Nested capsule-name-targeted options from a parent mapping.
|
|
62
|
+
* Merged AFTER options so they take precedence over intermediate defaults. */
|
|
63
|
+
transitiveOverrides?: Record<string, any>,
|
|
59
64
|
runtimeSpineContracts?: Record<string, any>,
|
|
60
65
|
sharedSelf?: Record<string, any>,
|
|
61
66
|
rootCapsule?: {
|
|
@@ -65,7 +70,8 @@ type TCapsuleMakeInstanceOptions = {
|
|
|
65
70
|
},
|
|
66
71
|
parentCapsuleSourceUriLineRefInstanceId?: string,
|
|
67
72
|
sit?: { capsuleInstances: Record<string, { capsuleName: string, capsuleSourceUriLineRef: string, parentCapsuleSourceUriLineRefInstanceId: string }> },
|
|
68
|
-
skipCache?: boolean
|
|
73
|
+
skipCache?: boolean,
|
|
74
|
+
freezePhase?: boolean
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
type TCapsule = {
|
|
@@ -115,6 +121,7 @@ type TSpineContext = {
|
|
|
115
121
|
export const CapsulePropertyTypes = {
|
|
116
122
|
Function: 'Function' as const,
|
|
117
123
|
GetterFunction: 'GetterFunction' as const,
|
|
124
|
+
ConstantGetterFunction: 'ConstantGetterFunction' as const,
|
|
118
125
|
SetterFunction: 'SetterFunction' as const,
|
|
119
126
|
String: 'String' as const,
|
|
120
127
|
Mapping: 'Mapping' as const,
|
|
@@ -460,7 +467,7 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
|
|
|
460
467
|
snapshot.capsules[capsule.cst.source.capsuleName] = snapshot.capsules[capsuleSourceLineRef]
|
|
461
468
|
}
|
|
462
469
|
|
|
463
|
-
const capsuleInstance = await capsule.makeInstance()
|
|
470
|
+
const capsuleInstance = await capsule.makeInstance({ freezePhase: true })
|
|
464
471
|
|
|
465
472
|
// Run OnFreeze functions so capsules can perform side effects
|
|
466
473
|
// (e.g. file projection) at build/freeze time
|
|
@@ -666,7 +673,9 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
666
673
|
encapsulateOptions,
|
|
667
674
|
cst,
|
|
668
675
|
crt: crts?.[capsuleSourceLineRef],
|
|
669
|
-
makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts, sharedSelf, rootCapsule, parentCapsuleSourceUriLineRefInstanceId, sit, skipCache }: TCapsuleMakeInstanceOptions = {}) => {
|
|
676
|
+
makeInstance: async ({ overrides = {}, options = {}, transitiveOverrides, runtimeSpineContracts, sharedSelf, rootCapsule, parentCapsuleSourceUriLineRefInstanceId, sit, skipCache, freezePhase }: TCapsuleMakeInstanceOptions = {}) => {
|
|
677
|
+
|
|
678
|
+
spine.spineOptions.timing?.record(`makeInstance: ${capsuleName || capsuleSourceLineRef}${freezePhase ? ' [freeze]' : ''}${sharedSelf ? ' [extends]' : ''}`)
|
|
670
679
|
|
|
671
680
|
// Create cache key based on parameters
|
|
672
681
|
// When sharedSelf is provided, we must NOT cache because each extending capsule
|
|
@@ -674,15 +683,14 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
674
683
|
// This is critical for the pattern where multiple structs extend the same parent.
|
|
675
684
|
// When skipCache is true (property contract delegates like structs/Capsule),
|
|
676
685
|
// each parent capsule must get its own unique instance.
|
|
677
|
-
const cacheKey = (sharedSelf || skipCache) ? null :
|
|
678
|
-
overrides,
|
|
679
|
-
|
|
680
|
-
hasRuntimeContracts: !!runtimeSpineContracts
|
|
681
|
-
})
|
|
686
|
+
const cacheKey = (sharedSelf || skipCache) ? null : computeCacheKey(
|
|
687
|
+
overrides, options, transitiveOverrides, !!runtimeSpineContracts
|
|
688
|
+
)
|
|
682
689
|
|
|
683
690
|
// Check if we already have a pending or completed instance creation
|
|
684
691
|
// Skip cache when sharedSelf is provided (cacheKey is null)
|
|
685
692
|
if (cacheKey && instanceCache.has(cacheKey)) {
|
|
693
|
+
spine.spineOptions.timing?.record(`makeInstance: CACHE HIT ${capsuleName || capsuleSourceLineRef}`)
|
|
686
694
|
return instanceCache.get(cacheKey)!
|
|
687
695
|
}
|
|
688
696
|
|
|
@@ -724,6 +732,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
724
732
|
const propDefTyped = propDef as Record<string, any>
|
|
725
733
|
const aliasName = propDefTyped.as
|
|
726
734
|
let delegateOptions = propDefTyped.options
|
|
735
|
+
const delegateDepends = propDefTyped.depends
|
|
727
736
|
const contractKey = aliasName || ('#' + propContractUri.substring(1))
|
|
728
737
|
|
|
729
738
|
if (!propertyContractDefinitions[spineContractUri]['#']) {
|
|
@@ -739,6 +748,9 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
739
748
|
value: delegateCapsuleObj || delegateUri,
|
|
740
749
|
propertyContractDelegate: propContractUri,
|
|
741
750
|
as: aliasName,
|
|
751
|
+
// Forward depends from the struct declaration so the delegate
|
|
752
|
+
// options function can access the required parent properties
|
|
753
|
+
depends: delegateDepends,
|
|
742
754
|
// Pass options from the property contract delegate to the mapped capsule
|
|
743
755
|
delegateOptions
|
|
744
756
|
}
|
|
@@ -775,12 +787,19 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
775
787
|
}
|
|
776
788
|
}
|
|
777
789
|
|
|
778
|
-
// Merge in order: overrides
|
|
790
|
+
// Merge in order: overrides, options, then transitive overrides.
|
|
791
|
+
// - Overrides (from run()) are general defaults — options are more specific.
|
|
792
|
+
// - Transitive overrides (nested capsule-name-targeted options from a parent)
|
|
793
|
+
// are the most specific and win over intermediate capsule options.
|
|
779
794
|
mergeByContract(overrides?.[encapsulateOptions.capsuleSourceLineRef], 'Overrides')
|
|
780
795
|
if (encapsulateOptions.capsuleName) {
|
|
781
796
|
mergeByContract(overrides?.[encapsulateOptions.capsuleName], 'Overrides')
|
|
782
797
|
}
|
|
783
798
|
mergeByContract(options, 'Options')
|
|
799
|
+
mergeByContract(transitiveOverrides?.[encapsulateOptions.capsuleSourceLineRef], 'TransitiveOverrides')
|
|
800
|
+
if (encapsulateOptions.capsuleName) {
|
|
801
|
+
mergeByContract(transitiveOverrides?.[encapsulateOptions.capsuleName], 'TransitiveOverrides')
|
|
802
|
+
}
|
|
784
803
|
|
|
785
804
|
// Extract default values from property definitions (Literal/String types)
|
|
786
805
|
// This ensures child capsule's default values are available before parent is instantiated
|
|
@@ -790,7 +809,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
790
809
|
if (propertyContractUri !== '#') continue
|
|
791
810
|
for (const [propertyName, propertyDef] of Object.entries(properties as Record<string, any>)) {
|
|
792
811
|
if (propertyDef.type === CapsulePropertyTypes.Literal ||
|
|
793
|
-
propertyDef.type === CapsulePropertyTypes.String
|
|
812
|
+
propertyDef.type === CapsulePropertyTypes.String ||
|
|
813
|
+
propertyDef.type === CapsulePropertyTypes.Constant) {
|
|
794
814
|
if (propertyDef.value !== undefined) {
|
|
795
815
|
defaultPropertyValues[propertyName] = propertyDef.value
|
|
796
816
|
}
|
|
@@ -847,6 +867,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
847
867
|
capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
|
|
848
868
|
capsuleSourceNameRefHash: cst?.capsuleSourceNameRefHash,
|
|
849
869
|
moduleFilepath: originalAbsoluteModuleFilepath,
|
|
870
|
+
spineFilesystemRoot: spine.spineOptions.spineFilesystemRoot,
|
|
850
871
|
// Root capsule metadata will be populated after extends chain is resolved
|
|
851
872
|
rootCapsule: {
|
|
852
873
|
capsuleName: undefined as string | undefined,
|
|
@@ -926,9 +947,11 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
926
947
|
})
|
|
927
948
|
}
|
|
928
949
|
|
|
950
|
+
spine.spineOptions.timing?.record(`makeInstance: extends → ${extendsCapsule.encapsulateOptions?.capsuleName || '?'}`)
|
|
929
951
|
extendedCapsuleInstance = await extendsCapsule.makeInstance({
|
|
930
952
|
overrides,
|
|
931
953
|
options,
|
|
954
|
+
transitiveOverrides,
|
|
932
955
|
runtimeSpineContracts,
|
|
933
956
|
sharedSelf: self,
|
|
934
957
|
rootCapsule: rootCapsule || {
|
|
@@ -937,9 +960,10 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
937
960
|
moduleFilepath: absoluteModuleFilepath
|
|
938
961
|
},
|
|
939
962
|
parentCapsuleSourceUriLineRefInstanceId: parentCapsuleSourceUriLineRefInstanceId
|
|
940
|
-
?
|
|
941
|
-
:
|
|
942
|
-
sit
|
|
963
|
+
? sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + (cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef))
|
|
964
|
+
: sha256(cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef),
|
|
965
|
+
sit,
|
|
966
|
+
freezePhase
|
|
943
967
|
})
|
|
944
968
|
|
|
945
969
|
// Propagate this (child) capsule's encapsulatedApi to all parent spine contract
|
|
@@ -975,8 +999,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
975
999
|
// child: sha256(parentCapsuleSourceUriLineRefInstanceId + ":" + capsuleSourceUriLineRef)
|
|
976
1000
|
const capsuleSourceUriLineRef = cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef
|
|
977
1001
|
const capsuleSourceUriLineRefInstanceId = parentCapsuleSourceUriLineRefInstanceId
|
|
978
|
-
?
|
|
979
|
-
:
|
|
1002
|
+
? sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + capsuleSourceUriLineRef)
|
|
1003
|
+
: sha256(capsuleSourceUriLineRef)
|
|
980
1004
|
|
|
981
1005
|
// Register this instance in the sit structure if provided
|
|
982
1006
|
if (sit) {
|
|
@@ -1001,7 +1025,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
1001
1025
|
capsuleSourceUriLineRefInstanceId,
|
|
1002
1026
|
capsuleName: encapsulateOptions.capsuleName,
|
|
1003
1027
|
capsuleSourceUriLineRef,
|
|
1004
|
-
sit
|
|
1028
|
+
sit,
|
|
1029
|
+
freezePhase
|
|
1005
1030
|
}
|
|
1006
1031
|
|
|
1007
1032
|
// Set capsule metadata struct on self early so it's available in options() callbacks during mapping
|
|
@@ -1012,6 +1037,14 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
1012
1037
|
}
|
|
1013
1038
|
ownSelf['#@stream44.studio/encapsulate/structs/Capsule'] = capsuleMetadataStruct
|
|
1014
1039
|
|
|
1040
|
+
// Promote metadata values to top-level self properties so struct delegate
|
|
1041
|
+
// proxies (which read from the capsule instance's own self/api) can access them.
|
|
1042
|
+
for (const [metaKey, metaValue] of Object.entries(capsuleMetadataStruct)) {
|
|
1043
|
+
if (metaKey !== 'rootCapsule' && metaValue !== undefined && self[metaKey] === undefined) {
|
|
1044
|
+
self[metaKey] = metaValue
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1015
1048
|
// Use runtime spine contracts if provided, otherwise fall back to encapsulation spine contracts
|
|
1016
1049
|
const activeSpineContracts = runtimeSpineContracts || spine.spineContracts
|
|
1017
1050
|
|
|
@@ -1056,6 +1089,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
|
|
|
1056
1089
|
await spineContractCapsuleInstance.mapProperty({
|
|
1057
1090
|
overrides,
|
|
1058
1091
|
options,
|
|
1092
|
+
transitiveOverrides,
|
|
1059
1093
|
property: {
|
|
1060
1094
|
name: propertyName,
|
|
1061
1095
|
definition: propertyDefinition,
|
|
@@ -1230,15 +1264,41 @@ function relative(from: string, to: string): string {
|
|
|
1230
1264
|
return result || '.'
|
|
1231
1265
|
}
|
|
1232
1266
|
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1267
|
+
// Fast cache key computation for makeInstance — avoids JSON.stringify for the common empty case
|
|
1268
|
+
const _emptyObj = {}
|
|
1269
|
+
const _cacheKeyEmpty = '{"overrides":{},"options":{},"hasRuntimeContracts":false}'
|
|
1270
|
+
const _cacheKeyEmptyWithRuntime = '{"overrides":{},"options":{},"hasRuntimeContracts":true}'
|
|
1271
|
+
function _isEmptyObj(obj: any): boolean { for (const _ in obj) return false; return true }
|
|
1272
|
+
const _cacheKeyLastRef = { overrides: _emptyObj as any, options: _emptyObj as any, transitiveOverrides: undefined as any, hasRuntime: false, key: _cacheKeyEmpty }
|
|
1273
|
+
function computeCacheKey(overrides: any, options: any, transitiveOverrides: any, hasRuntimeContracts: boolean): string {
|
|
1274
|
+
// Reference-equality shortcut: if same objects as last call, reuse key
|
|
1275
|
+
if (overrides === _cacheKeyLastRef.overrides && options === _cacheKeyLastRef.options
|
|
1276
|
+
&& transitiveOverrides === _cacheKeyLastRef.transitiveOverrides && hasRuntimeContracts === _cacheKeyLastRef.hasRuntime) {
|
|
1277
|
+
return _cacheKeyLastRef.key
|
|
1240
1278
|
}
|
|
1241
|
-
|
|
1279
|
+
// Fast-path: empty overrides + empty options + no transitive = constant string
|
|
1280
|
+
if (!transitiveOverrides && _isEmptyObj(overrides) && _isEmptyObj(options)) {
|
|
1281
|
+
const key = hasRuntimeContracts ? _cacheKeyEmptyWithRuntime : _cacheKeyEmpty
|
|
1282
|
+
_cacheKeyLastRef.overrides = overrides; _cacheKeyLastRef.options = options
|
|
1283
|
+
_cacheKeyLastRef.transitiveOverrides = transitiveOverrides; _cacheKeyLastRef.hasRuntime = hasRuntimeContracts
|
|
1284
|
+
_cacheKeyLastRef.key = key
|
|
1285
|
+
return key
|
|
1286
|
+
}
|
|
1287
|
+
const key = JSON.stringify({ overrides, options, transitiveOverrides, hasRuntimeContracts })
|
|
1288
|
+
_cacheKeyLastRef.overrides = overrides; _cacheKeyLastRef.options = options
|
|
1289
|
+
_cacheKeyLastRef.transitiveOverrides = transitiveOverrides; _cacheKeyLastRef.hasRuntime = hasRuntimeContracts
|
|
1290
|
+
_cacheKeyLastRef.key = key
|
|
1291
|
+
return key
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const _sha256Cache = new Map<string, string>()
|
|
1295
|
+
const _createHash = require('crypto').createHash
|
|
1296
|
+
function sha256(input: string): string {
|
|
1297
|
+
let result = _sha256Cache.get(input)
|
|
1298
|
+
if (result !== undefined) return result
|
|
1299
|
+
result = _createHash('sha256').update(input).digest('hex') as string
|
|
1300
|
+
_sha256Cache.set(input, result)
|
|
1301
|
+
return result
|
|
1242
1302
|
}
|
|
1243
1303
|
|
|
1244
1304
|
function isObject(item: any): boolean {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CapsulePropertyTypes } from "../../encapsulate"
|
|
2
2
|
import { ContractCapsuleInstanceFactory, CapsuleInstanceRegistry } from "./Static"
|
|
3
|
+
import type { TimingObserverInterface } from "../../spine-factories/TimingObserver"
|
|
3
4
|
|
|
4
5
|
type CallerContext = {
|
|
5
6
|
capsuleSourceLineRef: string
|
|
@@ -86,7 +87,8 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
86
87
|
runtimeSpineContracts,
|
|
87
88
|
instanceRegistry,
|
|
88
89
|
extendedCapsuleInstance,
|
|
89
|
-
capsuleInstance
|
|
90
|
+
capsuleInstance,
|
|
91
|
+
timing
|
|
90
92
|
}: {
|
|
91
93
|
spineContractUri: string
|
|
92
94
|
capsule: any
|
|
@@ -109,8 +111,9 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
109
111
|
instanceRegistry?: CapsuleInstanceRegistry
|
|
110
112
|
extendedCapsuleInstance?: any
|
|
111
113
|
capsuleInstance?: any
|
|
114
|
+
timing?: TimingObserverInterface
|
|
112
115
|
}) {
|
|
113
|
-
super({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, capsuleInstance })
|
|
116
|
+
super({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, capsuleInstance, timing })
|
|
114
117
|
this.getEventIndex = getEventIndex
|
|
115
118
|
this.incrementEventIndex = incrementEventIndex
|
|
116
119
|
this.getCurrentCallerContext = getCurrentCallerContext
|
|
@@ -137,7 +140,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
137
140
|
return ctx
|
|
138
141
|
}
|
|
139
142
|
|
|
140
|
-
protected async mapMappingProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
|
|
143
|
+
protected async mapMappingProperty({ overrides, options, transitiveOverrides, property }: { overrides: any, options: any, transitiveOverrides?: any, property: any }) {
|
|
141
144
|
|
|
142
145
|
const mappedCapsule = await this.resolveMappedCapsule({ property })
|
|
143
146
|
const constants = await this.extractConstants({ mappedCapsule })
|
|
@@ -151,9 +154,23 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
151
154
|
const minimalSelf = this.self[capsuleStructKey]
|
|
152
155
|
? { [capsuleStructKey]: this.self[capsuleStructKey] }
|
|
153
156
|
: {}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
// During freeze phase, skip calling function-based options callbacks — runtime
|
|
158
|
+
// overrides haven't been applied so self.* references may be incomplete.
|
|
159
|
+
// Use a truthy placeholder so the instance registry still creates a fresh instance.
|
|
160
|
+
const isFreezePhase = !!this.capsuleInstance?.freezePhase
|
|
161
|
+
const hasFunctionOptions = typeof optionsFn === 'function'
|
|
162
|
+
const selfArg = { self: property.definition.depends ? this.self : minimalSelf, constants }
|
|
163
|
+
const delegateOpts = property.definition.delegateOptions
|
|
164
|
+
const resolvedDelegateOptions = delegateOpts
|
|
165
|
+
? (typeof delegateOpts === 'function'
|
|
166
|
+
? (isFreezePhase ? {} : await delegateOpts(selfArg))
|
|
167
|
+
: delegateOpts)
|
|
168
|
+
: undefined
|
|
169
|
+
const mappingOptions = resolvedDelegateOptions
|
|
170
|
+
|| (hasFunctionOptions
|
|
171
|
+
? (isFreezePhase
|
|
172
|
+
? {} // truthy placeholder
|
|
173
|
+
: await optionsFn(selfArg))
|
|
157
174
|
: optionsFn)
|
|
158
175
|
|
|
159
176
|
// Check for existing instance in registry - reuse if available (regardless of options)
|
|
@@ -270,13 +287,14 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
270
287
|
}
|
|
271
288
|
}
|
|
272
289
|
|
|
273
|
-
//
|
|
274
|
-
//
|
|
290
|
+
// Build transitive overrides for the child: merge any inherited transitive
|
|
291
|
+
// overrides with this mapping's nested capsule-name-targeted options.
|
|
292
|
+
let mappedTransitiveOverrides: Record<string, any> | undefined = transitiveOverrides
|
|
275
293
|
if (nestedCapsuleOptions) {
|
|
276
|
-
|
|
294
|
+
mappedTransitiveOverrides = { ...(mappedTransitiveOverrides || {}) }
|
|
277
295
|
for (const [capsuleNameKey, capsuleOptions] of Object.entries(nestedCapsuleOptions)) {
|
|
278
|
-
|
|
279
|
-
...(
|
|
296
|
+
mappedTransitiveOverrides[capsuleNameKey] = {
|
|
297
|
+
...(mappedTransitiveOverrides[capsuleNameKey] || {}),
|
|
280
298
|
...capsuleOptions
|
|
281
299
|
}
|
|
282
300
|
}
|
|
@@ -285,11 +303,13 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
285
303
|
const mappedCapsuleInstance = await mappedCapsule.makeInstance({
|
|
286
304
|
overrides: mappedOverrides,
|
|
287
305
|
options: ownMappingOptions,
|
|
306
|
+
transitiveOverrides: mappedTransitiveOverrides,
|
|
288
307
|
runtimeSpineContracts: this.runtimeSpineContracts,
|
|
289
308
|
rootCapsule: this.capsuleInstance?.rootCapsule,
|
|
290
309
|
parentCapsuleSourceUriLineRefInstanceId: this.capsuleInstance?.capsuleSourceUriLineRefInstanceId,
|
|
291
310
|
sit: this.capsuleInstance?.sit,
|
|
292
|
-
skipCache: isCapsuleStruct
|
|
311
|
+
skipCache: isCapsuleStruct,
|
|
312
|
+
freezePhase: this.capsuleInstance?.freezePhase || undefined
|
|
293
313
|
})
|
|
294
314
|
|
|
295
315
|
// Register the instance (replaces null pre-registration marker)
|
|
@@ -481,6 +501,51 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
481
501
|
})
|
|
482
502
|
}
|
|
483
503
|
|
|
504
|
+
protected override mapConstantGetterFunctionProperty({ property }: { property: any }) {
|
|
505
|
+
const value = property.definition.value({ constants: this.self })
|
|
506
|
+
|
|
507
|
+
// Store eagerly on self like a Constant
|
|
508
|
+
this.self[property.name] = value
|
|
509
|
+
if (this.ownSelf) {
|
|
510
|
+
this.ownSelf[property.name] = value
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Wrap with membrane event tracking (read-only like Constant)
|
|
514
|
+
Object.defineProperty(this.encapsulatedApi, property.name, {
|
|
515
|
+
get: () => {
|
|
516
|
+
const currentValue = this.self[property.name]
|
|
517
|
+
|
|
518
|
+
const event: any = {
|
|
519
|
+
event: 'get',
|
|
520
|
+
eventIndex: this.incrementEventIndex(),
|
|
521
|
+
membrane: 'external',
|
|
522
|
+
target: {
|
|
523
|
+
capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
|
|
524
|
+
spineContractCapsuleInstanceId: this.id,
|
|
525
|
+
prop: property.name,
|
|
526
|
+
},
|
|
527
|
+
value: currentValue
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (this.capsuleSourceNameRef) {
|
|
531
|
+
event.target.capsuleSourceNameRef = this.capsuleSourceNameRef
|
|
532
|
+
}
|
|
533
|
+
if (this.capsuleSourceNameRefHash) {
|
|
534
|
+
event.target.capsuleSourceNameRefHash = this.capsuleSourceNameRefHash
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
this.addCallerContextToEvent(event)
|
|
538
|
+
this.onMembraneEvent?.(event)
|
|
539
|
+
return currentValue
|
|
540
|
+
},
|
|
541
|
+
set: () => {
|
|
542
|
+
throw new Error(`Cannot set ConstantGetterFunction property '${property.name}'`)
|
|
543
|
+
},
|
|
544
|
+
enumerable: true,
|
|
545
|
+
configurable: true
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
484
549
|
protected mapFunctionProperty({ property }: { property: any }) {
|
|
485
550
|
const selfProxy = this.createSelfProxy()
|
|
486
551
|
const boundFunction = property.definition.value.bind(selfProxy)
|
|
@@ -1001,7 +1066,8 @@ export function CapsuleSpineContract({
|
|
|
1001
1066
|
spineFilesystemRoot,
|
|
1002
1067
|
resolve,
|
|
1003
1068
|
importCapsule,
|
|
1004
|
-
npmUriForFilepath
|
|
1069
|
+
npmUriForFilepath,
|
|
1070
|
+
timing
|
|
1005
1071
|
}: {
|
|
1006
1072
|
onMembraneEvent?: (event: any) => void
|
|
1007
1073
|
freezeCapsule?: (capsule: any) => Promise<any>
|
|
@@ -1010,6 +1076,7 @@ export function CapsuleSpineContract({
|
|
|
1010
1076
|
resolve?: (uri: string, parentFilepath: string) => Promise<string>
|
|
1011
1077
|
importCapsule?: (filepath: string) => Promise<any>
|
|
1012
1078
|
npmUriForFilepath?: (filepath: string) => Promise<string | null>
|
|
1079
|
+
timing?: TimingObserverInterface
|
|
1013
1080
|
} = {}) {
|
|
1014
1081
|
|
|
1015
1082
|
let eventIndex = 0
|
|
@@ -1070,7 +1137,8 @@ export function CapsuleSpineContract({
|
|
|
1070
1137
|
runtimeSpineContracts,
|
|
1071
1138
|
instanceRegistry,
|
|
1072
1139
|
extendedCapsuleInstance,
|
|
1073
|
-
capsuleInstance
|
|
1140
|
+
capsuleInstance,
|
|
1141
|
+
timing
|
|
1074
1142
|
})
|
|
1075
1143
|
},
|
|
1076
1144
|
hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {
|
|
@@ -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
|
|
|
@@ -316,6 +329,35 @@ When `api.name(...args)` is called:
|
|
|
316
329
|
2. `target()` runs with self proxy as `this`, returning the function to call
|
|
317
330
|
3. The target function is called with the transformed args (awaited if `invoke` returns a promise)
|
|
318
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
|
+
|
|
319
361
|
### Mapping
|
|
320
362
|
|
|
321
363
|
`Mapping` composes another capsule as a sub-component.
|
|
@@ -333,7 +375,7 @@ prop: {
|
|
|
333
375
|
- **`value`** — a capsule reference (from `encapsulate()`) or a string URI resolved relative to the current module.
|
|
334
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).
|
|
335
377
|
- **`options({ self, constants })`** — when `options` is a function, it receives `{ self, constants }`.
|
|
336
|
-
- `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`).
|
|
337
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.
|
|
338
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.
|
|
339
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.
|
|
@@ -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) {
|
|
@@ -206,6 +211,7 @@ export class ContractCapsuleInstanceFactory {
|
|
|
206
211
|
throw resolveError
|
|
207
212
|
}
|
|
208
213
|
}
|
|
214
|
+
this.timing?.record(`resolveMappedCapsule: importCapsule(${filepath.replace(/^.*\/genesis\//, '')})`)
|
|
209
215
|
mappedCapsule = await this.importCapsule(filepath)
|
|
210
216
|
} else if (
|
|
211
217
|
typeof property.definition.value === 'object' &&
|
|
@@ -219,8 +225,10 @@ export class ContractCapsuleInstanceFactory {
|
|
|
219
225
|
return mappedCapsule
|
|
220
226
|
}
|
|
221
227
|
|
|
222
|
-
protected async extractConstants({ mappedCapsule }: { mappedCapsule: any }) {
|
|
228
|
+
protected async extractConstants({ mappedCapsule, _visited }: { mappedCapsule: any, _visited?: Set<string> }) {
|
|
223
229
|
const constants: Record<string, any> = {}
|
|
230
|
+
const constantGetterFunctions: Array<{ key: string; fn: Function }> = []
|
|
231
|
+
const mappingProperties: Array<{ key: string; def: any }> = []
|
|
224
232
|
|
|
225
233
|
const spineContractDef = mappedCapsule.definition[this.spineContractUri]
|
|
226
234
|
|
|
@@ -239,9 +247,14 @@ export class ContractCapsuleInstanceFactory {
|
|
|
239
247
|
|
|
240
248
|
if (
|
|
241
249
|
type === CapsulePropertyTypes.String ||
|
|
242
|
-
type === CapsulePropertyTypes.Literal
|
|
250
|
+
type === CapsulePropertyTypes.Literal ||
|
|
251
|
+
type === CapsulePropertyTypes.Constant
|
|
243
252
|
) {
|
|
244
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 })
|
|
245
258
|
}
|
|
246
259
|
}
|
|
247
260
|
} else {
|
|
@@ -252,17 +265,67 @@ export class ContractCapsuleInstanceFactory {
|
|
|
252
265
|
|
|
253
266
|
if (
|
|
254
267
|
type === CapsulePropertyTypes.String ||
|
|
255
|
-
type === CapsulePropertyTypes.Literal
|
|
268
|
+
type === CapsulePropertyTypes.Literal ||
|
|
269
|
+
type === CapsulePropertyTypes.Constant
|
|
256
270
|
) {
|
|
257
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 })
|
|
258
276
|
}
|
|
259
277
|
}
|
|
260
278
|
}
|
|
261
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
|
+
|
|
262
324
|
return constants
|
|
263
325
|
}
|
|
264
326
|
|
|
265
|
-
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')}`)
|
|
266
329
|
const mappedCapsule = await this.resolveMappedCapsule({ property })
|
|
267
330
|
const constants = await this.extractConstants({ mappedCapsule })
|
|
268
331
|
|
|
@@ -275,9 +338,25 @@ export class ContractCapsuleInstanceFactory {
|
|
|
275
338
|
const minimalSelf = this.self[capsuleStructKey]
|
|
276
339
|
? { [capsuleStructKey]: this.self[capsuleStructKey] }
|
|
277
340
|
: {}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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))
|
|
281
360
|
: optionsFn)
|
|
282
361
|
|
|
283
362
|
// Check for existing instance in registry - reuse if available when no options
|
|
@@ -295,6 +374,7 @@ export class ContractCapsuleInstanceFactory {
|
|
|
295
374
|
|
|
296
375
|
// Only reuse if current mapping has no options
|
|
297
376
|
if (!mappingOptions) {
|
|
377
|
+
this.timing?.record(`mapMappingProperty: REGISTRY REUSE ${capsuleName} (deferred proxy)`)
|
|
298
378
|
// Use deferred proxy that resolves from registry when accessed
|
|
299
379
|
// Works for both null (pre-registered) and actual instances
|
|
300
380
|
const apiTarget = this.getApiTarget({ property })
|
|
@@ -377,13 +457,16 @@ export class ContractCapsuleInstanceFactory {
|
|
|
377
457
|
}
|
|
378
458
|
}
|
|
379
459
|
|
|
380
|
-
//
|
|
381
|
-
//
|
|
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
|
|
382
465
|
if (nestedCapsuleOptions) {
|
|
383
|
-
|
|
466
|
+
mappedTransitiveOverrides = { ...(mappedTransitiveOverrides || {}) }
|
|
384
467
|
for (const [capsuleNameKey, capsuleOptions] of Object.entries(nestedCapsuleOptions)) {
|
|
385
|
-
|
|
386
|
-
...(
|
|
468
|
+
mappedTransitiveOverrides[capsuleNameKey] = {
|
|
469
|
+
...(mappedTransitiveOverrides[capsuleNameKey] || {}),
|
|
387
470
|
...capsuleOptions
|
|
388
471
|
}
|
|
389
472
|
}
|
|
@@ -393,11 +476,13 @@ export class ContractCapsuleInstanceFactory {
|
|
|
393
476
|
const mappedInstance = await mappedCapsule.makeInstance({
|
|
394
477
|
overrides: mappedOverrides,
|
|
395
478
|
options: ownMappingOptions,
|
|
479
|
+
transitiveOverrides: mappedTransitiveOverrides,
|
|
396
480
|
runtimeSpineContracts: this.runtimeSpineContracts,
|
|
397
481
|
rootCapsule: this.capsuleInstance?.rootCapsule,
|
|
398
482
|
parentCapsuleSourceUriLineRefInstanceId: this.capsuleInstance?.capsuleSourceUriLineRefInstanceId,
|
|
399
483
|
sit: this.capsuleInstance?.sit,
|
|
400
|
-
skipCache: isCapsuleStruct
|
|
484
|
+
skipCache: isCapsuleStruct,
|
|
485
|
+
freezePhase: this.capsuleInstance?.freezePhase || undefined
|
|
401
486
|
})
|
|
402
487
|
|
|
403
488
|
// Register the instance (replaces null pre-registration marker)
|
|
@@ -457,6 +542,20 @@ export class ContractCapsuleInstanceFactory {
|
|
|
457
542
|
}
|
|
458
543
|
}
|
|
459
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
|
+
|
|
460
559
|
protected createSelfProxy() {
|
|
461
560
|
const extendedApi = this.extendedCapsuleInstance?.api
|
|
462
561
|
const ownSelf = this.ownSelf
|
|
@@ -744,7 +843,7 @@ export class ContractCapsuleInstanceFactory {
|
|
|
744
843
|
|
|
745
844
|
|
|
746
845
|
|
|
747
|
-
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 } = {}) {
|
|
748
847
|
|
|
749
848
|
const instanceRegistry: CapsuleInstanceRegistry = new Map()
|
|
750
849
|
|
|
@@ -765,7 +864,8 @@ export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, sp
|
|
|
765
864
|
instanceRegistry,
|
|
766
865
|
extendedCapsuleInstance,
|
|
767
866
|
runtimeSpineContracts,
|
|
768
|
-
capsuleInstance
|
|
867
|
+
capsuleInstance,
|
|
868
|
+
timing
|
|
769
869
|
})
|
|
770
870
|
},
|
|
771
871
|
hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { join, dirname, relative, resolve as pathResolve } from 'path'
|
|
2
|
-
import { writeFile, mkdir, readFile, stat } from 'fs/promises'
|
|
2
|
+
import { writeFile, mkdir, readFile, stat, access } from 'fs/promises'
|
|
3
|
+
import { createHash } from 'crypto'
|
|
3
4
|
import { Spine, SpineRuntime, CapsulePropertyTypes, makeImportStack, merge } from "../encapsulate"
|
|
4
5
|
import { StaticAnalyzer } from "../../src/static-analyzer"
|
|
5
6
|
import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector"
|
|
7
|
+
import { TimingObserver, type TimingObserverInterface } from "./TimingObserver"
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
export { merge }
|
|
@@ -366,6 +368,31 @@ function createNpmUriForFilepath(): (filepath: string) => Promise<string | null>
|
|
|
366
368
|
}
|
|
367
369
|
}
|
|
368
370
|
|
|
371
|
+
// Cross-factory cache for module imports — avoids re-importing the same file
|
|
372
|
+
// across factory instances. The capsule() call still runs per-factory (spine-specific),
|
|
373
|
+
// but the import() is only done once per filepath globally.
|
|
374
|
+
const _moduleImportCache = new Map<string, Promise<any>>()
|
|
375
|
+
|
|
376
|
+
// Cross-factory stat cache — avoids redundant filesystem stat() calls for the same
|
|
377
|
+
// file across factory instances within a single process. Source file stats are cached
|
|
378
|
+
// permanently (source files don't change within a single build). Cache file stats are
|
|
379
|
+
// invalidated on write.
|
|
380
|
+
const _statCache = new Map<string, Promise<{ mtime: Date } | null>>()
|
|
381
|
+
function cachedStat(filepath: string): Promise<{ mtime: Date } | null> {
|
|
382
|
+
let cached = _statCache.get(filepath)
|
|
383
|
+
if (!cached) {
|
|
384
|
+
cached = stat(filepath).then(
|
|
385
|
+
s => ({ mtime: s.mtime }),
|
|
386
|
+
() => null
|
|
387
|
+
)
|
|
388
|
+
_statCache.set(filepath, cached)
|
|
389
|
+
}
|
|
390
|
+
return cached
|
|
391
|
+
}
|
|
392
|
+
function invalidateStatCache(filepath: string) {
|
|
393
|
+
_statCache.delete(filepath)
|
|
394
|
+
}
|
|
395
|
+
|
|
369
396
|
export async function CapsuleSpineFactory({
|
|
370
397
|
spineFilesystemRoot,
|
|
371
398
|
capsuleModuleProjectionRoot,
|
|
@@ -383,13 +410,16 @@ export async function CapsuleSpineFactory({
|
|
|
383
410
|
onMembraneEvent?: (event: any) => void,
|
|
384
411
|
enableCallerStackInference?: boolean,
|
|
385
412
|
spineContracts: Record<string, any>,
|
|
386
|
-
timing?:
|
|
413
|
+
timing?: TimingObserverInterface | null
|
|
387
414
|
}) {
|
|
388
415
|
|
|
389
416
|
if (capsuleModuleProjectionRoot) capsuleModuleProjectionRoot = capsuleModuleProjectionRoot.replace(/^file:\/\//, '')
|
|
390
417
|
if (spineFilesystemRoot) spineFilesystemRoot = spineFilesystemRoot.replace(/^file:\/\//, '')
|
|
391
418
|
|
|
392
|
-
|
|
419
|
+
// Auto-create timing when ENCAPSULATE_TRACE env var is set
|
|
420
|
+
const timing: TimingObserverInterface | undefined = timingParam || (
|
|
421
|
+
process.env.ENCAPSULATE_TRACE ? TimingObserver() : undefined
|
|
422
|
+
)
|
|
393
423
|
|
|
394
424
|
timing?.recordMajor('CAPSULE SPINE FACTORY: INITIALIZATION')
|
|
395
425
|
|
|
@@ -397,6 +427,7 @@ export async function CapsuleSpineFactory({
|
|
|
397
427
|
const registry = new Map<string, Promise<any>>()
|
|
398
428
|
|
|
399
429
|
return {
|
|
430
|
+
has(id: string) { return registry.has(id) },
|
|
400
431
|
async ensure(id: string, createHandler: () => Promise<any>) {
|
|
401
432
|
if (!registry.has(id)) {
|
|
402
433
|
registry.set(id, createHandler())
|
|
@@ -419,6 +450,7 @@ export async function CapsuleSpineFactory({
|
|
|
419
450
|
const sourceSpine: { encapsulate?: any } = {}
|
|
420
451
|
const npmUriForFilepath = createNpmUriForFilepath()
|
|
421
452
|
const commonSpineContractOpts = {
|
|
453
|
+
timing,
|
|
422
454
|
spineFilesystemRoot,
|
|
423
455
|
npmUriForFilepath,
|
|
424
456
|
resolve: async (uri: string, parentFilepath: string) => {
|
|
@@ -430,24 +462,25 @@ export async function CapsuleSpineFactory({
|
|
|
430
462
|
return await resolve(uri, parentFilepath, spineFilesystemRoot)
|
|
431
463
|
},
|
|
432
464
|
importCapsule: (() => {
|
|
433
|
-
|
|
465
|
+
const importFn = async (filepath: string) => {
|
|
434
466
|
const shortPath = filepath.replace(/^.*\/genesis\//, '')
|
|
435
467
|
|
|
436
468
|
timing?.record(`importCapsule: Called for ${shortPath}`)
|
|
437
469
|
const result = await registry.ensure(filepath, async () => {
|
|
438
470
|
timing?.recordMajor(`importCapsule: Starting import for ${shortPath}`)
|
|
439
471
|
const importStart = Date.now()
|
|
440
|
-
|
|
472
|
+
if (!_moduleImportCache.has(filepath)) {
|
|
473
|
+
_moduleImportCache.set(filepath, import(filepath))
|
|
474
|
+
}
|
|
475
|
+
const exports = await _moduleImportCache.get(filepath)!
|
|
441
476
|
const importDuration = Date.now() - importStart
|
|
442
477
|
timing?.recordMajor(`importCapsule: import() took ${importDuration}ms for ${shortPath}`)
|
|
443
478
|
|
|
444
|
-
if (importDuration > 10) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
console.log(timing.chalk.red(` Consider using dynamic imports to load heavy dependencies only when needed.\n`))
|
|
450
|
-
}
|
|
479
|
+
if (importDuration > 10 && timing) {
|
|
480
|
+
console.log(timing.chalk.red(`\n⚠️ WARNING: Slow module load detected!`))
|
|
481
|
+
console.log(timing.chalk.red(` Module: ${filepath}`))
|
|
482
|
+
console.log(timing.chalk.red(` Load time: ${importDuration}ms`))
|
|
483
|
+
console.log(timing.chalk.red(` Consider using dynamic imports to load heavy dependencies only when needed.\n`))
|
|
451
484
|
}
|
|
452
485
|
|
|
453
486
|
if (typeof exports.capsule !== 'function') throw new Error(`Module at '${filepath}' does not export 'capsule'!`)
|
|
@@ -467,6 +500,7 @@ export async function CapsuleSpineFactory({
|
|
|
467
500
|
})
|
|
468
501
|
return result
|
|
469
502
|
}
|
|
503
|
+
return importFn
|
|
470
504
|
})(),
|
|
471
505
|
encapsulateOpts: {
|
|
472
506
|
CapsulePropertyTypes
|
|
@@ -570,10 +604,7 @@ export async function CapsuleSpineFactory({
|
|
|
570
604
|
|
|
571
605
|
timing?.recordMajor('SPINE: INITIALIZATION')
|
|
572
606
|
|
|
573
|
-
|
|
574
|
-
spineFilesystemRoot,
|
|
575
|
-
timing,
|
|
576
|
-
staticAnalyzer: staticAnalysisEnabled ? StaticAnalyzer({
|
|
607
|
+
const staticAnalyzer = staticAnalysisEnabled ? StaticAnalyzer({
|
|
577
608
|
timing,
|
|
578
609
|
cacheStore: {
|
|
579
610
|
writeFile: async (filepath: string, content: string) => {
|
|
@@ -581,6 +612,7 @@ export async function CapsuleSpineFactory({
|
|
|
581
612
|
const centralPath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
|
|
582
613
|
await mkdir(dirname(centralPath), { recursive: true })
|
|
583
614
|
await writeFile(centralPath, content, 'utf-8')
|
|
615
|
+
invalidateStatCache(centralPath)
|
|
584
616
|
// Also write to local project cache if available
|
|
585
617
|
if (capsuleModuleProjectionRoot) {
|
|
586
618
|
try {
|
|
@@ -604,29 +636,20 @@ export async function CapsuleSpineFactory({
|
|
|
604
636
|
return content
|
|
605
637
|
},
|
|
606
638
|
getStats: async (filepath: string) => {
|
|
607
|
-
|
|
608
|
-
try {
|
|
609
|
-
const stats = await stat(filepath)
|
|
610
|
-
return { mtime: stats.mtime }
|
|
611
|
-
} catch (error) {
|
|
612
|
-
// File doesn't exist
|
|
613
|
-
return null
|
|
614
|
-
}
|
|
639
|
+
return cachedStat(join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath))
|
|
615
640
|
},
|
|
616
641
|
},
|
|
617
642
|
spineStore: {
|
|
618
643
|
getStats: async (filepath: string) => {
|
|
619
|
-
|
|
620
|
-
try {
|
|
621
|
-
const stats = await stat(filepath)
|
|
622
|
-
return { mtime: stats.mtime }
|
|
623
|
-
} catch (error) {
|
|
624
|
-
// File doesn't exist
|
|
625
|
-
return null
|
|
626
|
-
}
|
|
644
|
+
return cachedStat(join(spineFilesystemRoot, filepath))
|
|
627
645
|
},
|
|
628
646
|
},
|
|
629
|
-
}) : undefined
|
|
647
|
+
}) : undefined
|
|
648
|
+
|
|
649
|
+
let { encapsulate, freeze, capsules } = await Spine({
|
|
650
|
+
spineFilesystemRoot,
|
|
651
|
+
timing,
|
|
652
|
+
staticAnalyzer,
|
|
630
653
|
spineContracts: spineContractInstances.encapsulation,
|
|
631
654
|
projectionContext: capsuleModuleProjectionRoot ? {
|
|
632
655
|
capsuleModuleProjectionPackage,
|
|
@@ -682,10 +705,16 @@ export async function CapsuleSpineFactory({
|
|
|
682
705
|
return capsule
|
|
683
706
|
}
|
|
684
707
|
|
|
708
|
+
// Track capsule refs known at freeze time so we can identify dynamic imports later
|
|
709
|
+
let frozenCapsuleRefs: Set<string> | null = null
|
|
710
|
+
|
|
685
711
|
// Wrap freeze to also write spine instance (.sit.json) files
|
|
686
712
|
const wrappedFreeze = async function () {
|
|
687
713
|
const snapshot = await freeze()
|
|
688
714
|
|
|
715
|
+
// Record capsule refs at freeze time (before any dynamic imports)
|
|
716
|
+
frozenCapsuleRefs = new Set(Object.keys(capsules))
|
|
717
|
+
|
|
689
718
|
// Write spine instance files if capsuleModuleProjectionRoot is available
|
|
690
719
|
if (capsuleModuleProjectionRoot) {
|
|
691
720
|
try {
|
|
@@ -722,6 +751,10 @@ export async function CapsuleSpineFactory({
|
|
|
722
751
|
}
|
|
723
752
|
}
|
|
724
753
|
|
|
754
|
+
// Check if any CSTs were regenerated — if not, we can skip SIT writes
|
|
755
|
+
// when existing files are still valid
|
|
756
|
+
const cstsChanged = !staticAnalyzer || staticAnalyzer.hasCacheMisses()
|
|
757
|
+
|
|
725
758
|
for (const [, capsule] of Object.entries(uniqueCapsules)) {
|
|
726
759
|
const cst = capsule.cst
|
|
727
760
|
const rootCapsuleName = cst?.source?.capsuleName
|
|
@@ -737,6 +770,16 @@ export async function CapsuleSpineFactory({
|
|
|
737
770
|
const sitDir = join(capsuleModuleProjectionRoot, '.~o/encapsulate.dev/spine-instances', dirName)
|
|
738
771
|
const sitFilePath = join(sitDir, `root-capsule.sit.json`)
|
|
739
772
|
|
|
773
|
+
// Skip SIT generation if CSTs are unchanged and the file already exists
|
|
774
|
+
if (!cstsChanged) {
|
|
775
|
+
try {
|
|
776
|
+
await access(sitFilePath)
|
|
777
|
+
continue // File exists and no CSTs changed — skip
|
|
778
|
+
} catch {
|
|
779
|
+
// File doesn't exist — need to generate even though CSTs are cached
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
740
783
|
// Build the capsules map
|
|
741
784
|
const capsuleEntries: Record<string, { capsuleSourceUriLineRef: string }> = {}
|
|
742
785
|
for (const [, cap] of Object.entries(uniqueCapsules)) {
|
|
@@ -750,7 +793,7 @@ export async function CapsuleSpineFactory({
|
|
|
750
793
|
|
|
751
794
|
// Collect capsuleInstances from the cached root instance using an
|
|
752
795
|
// iterative stack — each instance stores its ID and parent ID from init
|
|
753
|
-
const rootInstance = await capsule.makeInstance()
|
|
796
|
+
const rootInstance = await capsule.makeInstance({ freezePhase: true })
|
|
754
797
|
const capsuleInstances: Record<string, { capsuleName: string, capsuleSourceUriLineRef: string, parentCapsuleSourceUriLineRefInstanceId: string }> = {}
|
|
755
798
|
|
|
756
799
|
// Iterative stack-based collection from instance tree
|
|
@@ -790,8 +833,18 @@ export async function CapsuleSpineFactory({
|
|
|
790
833
|
capsuleInstances
|
|
791
834
|
}
|
|
792
835
|
|
|
836
|
+
const sitContent = JSON.stringify(sitData, null, 2)
|
|
837
|
+
|
|
838
|
+
// Compare-before-write: skip disk write if content is identical
|
|
839
|
+
try {
|
|
840
|
+
const existing = await readFile(sitFilePath, 'utf-8')
|
|
841
|
+
if (existing === sitContent) continue
|
|
842
|
+
} catch {
|
|
843
|
+
// File doesn't exist — write it
|
|
844
|
+
}
|
|
845
|
+
|
|
793
846
|
await mkdir(sitDir, { recursive: true })
|
|
794
|
-
await writeFile(sitFilePath,
|
|
847
|
+
await writeFile(sitFilePath, sitContent, 'utf-8')
|
|
795
848
|
}
|
|
796
849
|
} catch (error) {
|
|
797
850
|
// Spine instance file writing is best-effort
|
|
@@ -802,6 +855,55 @@ export async function CapsuleSpineFactory({
|
|
|
802
855
|
return snapshot
|
|
803
856
|
}
|
|
804
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Write a dynamic SIT file capturing capsules that were loaded at runtime
|
|
860
|
+
* via importCapsule (not part of the static capsule tree).
|
|
861
|
+
* Call this after run() to record which dynamic capsules were used.
|
|
862
|
+
* The filename includes a content hash so unchanged combos produce the same file.
|
|
863
|
+
*/
|
|
864
|
+
const writeDynamicSit = async function () {
|
|
865
|
+
if (!capsuleModuleProjectionRoot || !frozenCapsuleRefs) return
|
|
866
|
+
|
|
867
|
+
// Find capsules that were added after freeze (dynamic imports)
|
|
868
|
+
const dynamicCapsuleRefs: string[] = []
|
|
869
|
+
for (const key of Object.keys(capsules)) {
|
|
870
|
+
if (!frozenCapsuleRefs.has(key) && key.includes(':') && /:\d+$/.test(key)) {
|
|
871
|
+
dynamicCapsuleRefs.push(key)
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (dynamicCapsuleRefs.length === 0) return
|
|
876
|
+
|
|
877
|
+
// Build a sorted list of dynamic capsule entries for deterministic output
|
|
878
|
+
const dynamicEntries: Record<string, { capsuleSourceUriLineRef: string, capsuleName?: string }> = {}
|
|
879
|
+
for (const ref of dynamicCapsuleRefs.sort()) {
|
|
880
|
+
const cap = capsules[ref]
|
|
881
|
+
dynamicEntries[ref] = {
|
|
882
|
+
capsuleSourceUriLineRef: ref,
|
|
883
|
+
capsuleName: cap?.cst?.source?.capsuleName
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const sitData = { dynamicCapsules: dynamicEntries }
|
|
888
|
+
const sitContent = JSON.stringify(sitData, null, 2)
|
|
889
|
+
|
|
890
|
+
// Content hash for the filename — same dynamic import combo = same file
|
|
891
|
+
const contentHash = createHash('sha256').update(sitContent).digest('hex').slice(0, 16)
|
|
892
|
+
const sitDir = join(capsuleModuleProjectionRoot, '.~o/encapsulate.dev/spine-instances')
|
|
893
|
+
const sitFilePath = join(sitDir, `dynamic-imports.${contentHash}.sit.json`)
|
|
894
|
+
|
|
895
|
+
// Skip write if file already exists (hash match = identical content)
|
|
896
|
+
try {
|
|
897
|
+
await access(sitFilePath)
|
|
898
|
+
return // Already exists with same content
|
|
899
|
+
} catch {
|
|
900
|
+
// File doesn't exist — write it
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
await mkdir(sitDir, { recursive: true })
|
|
904
|
+
await writeFile(sitFilePath, sitContent, 'utf-8')
|
|
905
|
+
}
|
|
906
|
+
|
|
805
907
|
return {
|
|
806
908
|
commonSpineContractOpts,
|
|
807
909
|
CapsulePropertyTypes,
|
|
@@ -809,7 +911,9 @@ export async function CapsuleSpineFactory({
|
|
|
809
911
|
encapsulate,
|
|
810
912
|
run,
|
|
811
913
|
freeze: wrappedFreeze,
|
|
914
|
+
writeDynamicSit,
|
|
812
915
|
loadCapsule,
|
|
916
|
+
staticAnalyzer,
|
|
813
917
|
spineContractInstances, // Expose for testing
|
|
814
918
|
hoistSnapshot: async ({ snapshot }: { snapshot: any }) => {
|
|
815
919
|
|
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
|
|
4
|
-
export
|
|
5
|
-
|
|
4
|
+
export type TimingOptions = { color?: 'red' | 'green' | 'yellow' | 'cyan' | 'gray' }
|
|
5
|
+
|
|
6
|
+
export type TimingObserverInterface = {
|
|
7
|
+
record: (step: string, opts?: TimingOptions) => void,
|
|
8
|
+
recordMajor: (step: string, opts?: TimingOptions) => void,
|
|
9
|
+
chalk: typeof chalk,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function TimingObserver(): TimingObserverInterface {
|
|
13
|
+
let lastTime = performance.now()
|
|
14
|
+
|
|
15
|
+
function format(msg: string, diff: string, opts?: TimingOptions): string {
|
|
16
|
+
const line = `[+${diff}ms] ${msg}`
|
|
17
|
+
if (opts?.color) return (chalk as any)[opts.color](line)
|
|
18
|
+
return line
|
|
19
|
+
}
|
|
6
20
|
|
|
7
21
|
return {
|
|
8
22
|
chalk,
|
|
9
|
-
record: (step: string) => {
|
|
10
|
-
const now =
|
|
11
|
-
const diff = now - lastTime
|
|
23
|
+
record: (step: string, opts?: TimingOptions) => {
|
|
24
|
+
const now = performance.now()
|
|
25
|
+
const diff = (now - lastTime).toFixed(1)
|
|
12
26
|
lastTime = now
|
|
13
|
-
|
|
14
|
-
const line = `[+${diff}ms] ${step}`
|
|
15
|
-
console.log(diff > 10 ? chalk.red(line) : line)
|
|
27
|
+
console.log(format(` ${step}`, diff, opts))
|
|
16
28
|
},
|
|
17
|
-
recordMajor: (step: string) => {
|
|
18
|
-
const now =
|
|
19
|
-
const diff = now - lastTime
|
|
29
|
+
recordMajor: (step: string, opts?: TimingOptions) => {
|
|
30
|
+
const now = performance.now()
|
|
31
|
+
const diff = (now - lastTime).toFixed(1)
|
|
20
32
|
lastTime = now
|
|
21
|
-
|
|
22
|
-
const line = `[+${diff}ms] ${step}`
|
|
23
|
-
console.log(diff > 10 ? chalk.red(line) : chalk.cyan(line))
|
|
33
|
+
console.log(format(`★ ${step}`, diff, opts))
|
|
24
34
|
}
|
|
25
35
|
}
|
|
26
36
|
}
|
package/src/static-analyzer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join, normalize, dirname, resolve, relative } from 'path'
|
|
|
3
3
|
import { readFile, stat } from 'fs/promises'
|
|
4
4
|
import * as ts from 'typescript'
|
|
5
5
|
import { createHash } from 'crypto'
|
|
6
|
+
import type { TimingObserverInterface } from './spine-factories/TimingObserver'
|
|
6
7
|
|
|
7
8
|
// Known exports from @stream44.studio/encapsulate/encapsulate that can be imported
|
|
8
9
|
const ENCAPSULATE_MODULE_EXPORTS = new Set([
|
|
@@ -227,7 +228,7 @@ export function StaticAnalyzer({
|
|
|
227
228
|
cacheStore,
|
|
228
229
|
spineStore
|
|
229
230
|
}: {
|
|
230
|
-
timing?:
|
|
231
|
+
timing?: TimingObserverInterface,
|
|
231
232
|
cacheStore?: {
|
|
232
233
|
writeFile?: (filepath: string, content: string) => Promise<void>,
|
|
233
234
|
readFile?: (filepath: string) => Promise<string | undefined>,
|
|
@@ -240,8 +241,16 @@ export function StaticAnalyzer({
|
|
|
240
241
|
|
|
241
242
|
timing?.record('StaticAnalyzer: Initialized')
|
|
242
243
|
|
|
244
|
+
let _cacheMissCount = 0
|
|
245
|
+
|
|
243
246
|
return {
|
|
244
247
|
|
|
248
|
+
/** Returns true if any parseModule call resulted in a cache miss (CST regeneration) */
|
|
249
|
+
hasCacheMisses: () => _cacheMissCount > 0,
|
|
250
|
+
|
|
251
|
+
/** Reset the cache miss counter (call before a new freeze cycle) */
|
|
252
|
+
resetCacheMisses: () => { _cacheMissCount = 0 },
|
|
253
|
+
|
|
245
254
|
parseModule: async ({ spineOptions, encapsulateOptions }: { spineOptions: any, encapsulateOptions: any }) => {
|
|
246
255
|
|
|
247
256
|
const moduleFilepath = join(spineOptions.spineFilesystemRoot, encapsulateOptions.moduleFilepath)
|
|
@@ -282,7 +291,8 @@ export function StaticAnalyzer({
|
|
|
282
291
|
// Check cache bust version - if mismatch, regenerate
|
|
283
292
|
const cachedVersion = cachedCsts?.[capsuleSourceLineRef]?.cacheBustVersion
|
|
284
293
|
if (encapsulateOptions.cacheBustVersion !== undefined && cachedVersion !== encapsulateOptions.cacheBustVersion) {
|
|
285
|
-
|
|
294
|
+
_cacheMissCount++
|
|
295
|
+
timing?.record(`StaticAnalyzer: Cache BUST (version mismatch: ${cachedVersion} !== ${encapsulateOptions.cacheBustVersion}) for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
|
|
286
296
|
} else {
|
|
287
297
|
timing?.record(`StaticAnalyzer: Cache HIT for ${encapsulateOptions.moduleFilepath}`)
|
|
288
298
|
return {
|
|
@@ -293,10 +303,12 @@ export function StaticAnalyzer({
|
|
|
293
303
|
}
|
|
294
304
|
}
|
|
295
305
|
}
|
|
296
|
-
|
|
306
|
+
_cacheMissCount++
|
|
307
|
+
timing?.record(`StaticAnalyzer: Cache MISS for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
|
|
297
308
|
} catch (error) {
|
|
298
309
|
// Cache miss or error, continue with normal parsing
|
|
299
|
-
|
|
310
|
+
_cacheMissCount++
|
|
311
|
+
timing?.record(`StaticAnalyzer: Cache error for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
|
|
300
312
|
}
|
|
301
313
|
}
|
|
302
314
|
|
package/structs/Capsule.ts
CHANGED