Skip to content

Commit ce8e10d

Browse files
committed
Published using @Stream44 Studio
Signed-off-by: Christoph <christoph@christoph.diy>
1 parent 8392fa8 commit ce8e10d

7 files changed

Lines changed: 64 additions & 37 deletions

File tree

‎LICENSE.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
encapsulate: Object encapsulation & mapping system with runtime kernel for TypeScript
3+
encapsulate: Object encapsulation & mapping library with runtime kernel for TypeScript
44

55
Copyright 2026 Christoph Dorn - https://Christoph.diy
66

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stream44.studio/encapsulate",
3-
"version": "0.4.0-rc.25",
3+
"version": "0.4.0-rc.26",
44
"license": "MIT",
55
"repository": {
66
"type": "git",

‎src/encapsulate.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ type TCapsuleMakeInstanceOptions = {
5555
moduleFilepath: string
5656
},
5757
parentCapsuleSourceUriLineRefInstanceId?: string,
58-
sit?: { capsuleInstances: Record<string, { capsuleName: string, capsuleSourceUriLineRef: string, parentCapsuleSourceUriLineRefInstanceId: string }> }
58+
sit?: { capsuleInstances: Record<string, { capsuleName: string, capsuleSourceUriLineRef: string, parentCapsuleSourceUriLineRefInstanceId: string }> },
59+
skipCache?: boolean
5960
}
6061

6162
type TCapsule = {
@@ -554,13 +555,15 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
554555
encapsulateOptions,
555556
cst,
556557
crt: crts?.[capsuleSourceLineRef],
557-
makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts, sharedSelf, rootCapsule, parentCapsuleSourceUriLineRefInstanceId, sit }: TCapsuleMakeInstanceOptions = {}) => {
558+
makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts, sharedSelf, rootCapsule, parentCapsuleSourceUriLineRefInstanceId, sit, skipCache }: TCapsuleMakeInstanceOptions = {}) => {
558559

559560
// Create cache key based on parameters
560561
// When sharedSelf is provided, we must NOT cache because each extending capsule
561562
// needs its own instance with its own 'this' context (sharedSelf).
562563
// This is critical for the pattern where multiple structs extend the same parent.
563-
const cacheKey = sharedSelf ? null : JSON.stringify({
564+
// When skipCache is true (property contract delegates like structs/Capsule),
565+
// each parent capsule must get its own unique instance.
566+
const cacheKey = (sharedSelf || skipCache) ? null : JSON.stringify({
564567
overrides,
565568
options,
566569
hasRuntimeContracts: !!runtimeSpineContracts

‎src/spine-contracts/CapsuleSpineContract.v0/Membrane.v0.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,11 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
149149

150150
// Check for existing instance in registry - reuse if available (regardless of options)
151151
// Pre-registration with null allows parent capsules to "claim" a slot before child capsules process
152+
// Property contract delegates (structs) always get a fresh instance per parent capsule
152153
const capsuleName = mappedCapsule.encapsulateOptions?.capsuleName
154+
const isCapsuleStruct = property.definition.propertyContractDelegate === '#@stream44.studio/encapsulate/structs/Capsule'
153155

154-
if (capsuleName && this.instanceRegistry) {
156+
if (capsuleName && this.instanceRegistry && !isCapsuleStruct) {
155157
if (this.instanceRegistry.has(capsuleName)) {
156158
const existingEntry = this.instanceRegistry.get(capsuleName)
157159

@@ -275,12 +277,16 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
275277
overrides: mappedOverrides,
276278
options: ownMappingOptions,
277279
runtimeSpineContracts: this.runtimeSpineContracts,
278-
rootCapsule: this.capsuleInstance?.rootCapsule
280+
rootCapsule: this.capsuleInstance?.rootCapsule,
281+
parentCapsuleSourceUriLineRefInstanceId: this.capsuleInstance?.capsuleSourceUriLineRefInstanceId,
282+
sit: this.capsuleInstance?.sit,
283+
skipCache: isCapsuleStruct
279284
})
280285

281286
// Register the instance (replaces null pre-registration marker)
282287
// Always register to make instance available for child capsules with deferred proxies
283-
if (capsuleName && this.instanceRegistry) {
288+
// Property contract delegates skip registry (each parent gets its own instance)
289+
if (capsuleName && this.instanceRegistry && !isCapsuleStruct) {
284290
this.instanceRegistry.set(capsuleName, mappedCapsuleInstance)
285291
}
286292

‎src/spine-contracts/CapsuleSpineContract.v0/Static.v0.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,11 @@ export class ContractCapsuleInstanceFactory {
206206

207207
// Check for existing instance in registry - reuse if available when no options
208208
// Pre-registration with null allows parent capsules to "claim" a slot before child capsules process
209+
// Property contract delegates (structs) always get a fresh instance per parent capsule
209210
const capsuleName = mappedCapsule.encapsulateOptions?.capsuleName
211+
const isCapsuleStruct = property.definition.propertyContractDelegate === '#@stream44.studio/encapsulate/structs/Capsule'
210212

211-
if (capsuleName && this.instanceRegistry) {
213+
if (capsuleName && this.instanceRegistry && !isCapsuleStruct) {
212214
if (this.instanceRegistry.has(capsuleName)) {
213215
const existingEntry = this.instanceRegistry.get(capsuleName)
214216

@@ -315,12 +317,14 @@ export class ContractCapsuleInstanceFactory {
315317
runtimeSpineContracts: this.runtimeSpineContracts,
316318
rootCapsule: this.capsuleInstance?.rootCapsule,
317319
parentCapsuleSourceUriLineRefInstanceId: this.capsuleInstance?.capsuleSourceUriLineRefInstanceId,
318-
sit: this.capsuleInstance?.sit
320+
sit: this.capsuleInstance?.sit,
321+
skipCache: isCapsuleStruct
319322
})
320323

321324
// Register the instance (replaces null pre-registration marker)
322325
// Always register to make instance available for child capsules with deferred proxies
323-
if (capsuleName && this.instanceRegistry) {
326+
// Property contract delegates skip registry (each parent gets its own instance)
327+
if (capsuleName && this.instanceRegistry && !isCapsuleStruct) {
324328
this.instanceRegistry.set(capsuleName, mappedInstance)
325329
}
326330

‎src/spine-factories/CapsuleSpineFactory.v0.ts‎

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -642,33 +642,28 @@ export async function CapsuleSpineFactory({
642642
const rootInstance = await capsule.makeInstance()
643643
const capsuleInstances: Record<string, { capsuleName: string, capsuleSourceUriLineRef: string, parentCapsuleSourceUriLineRefInstanceId: string }> = {}
644644

645-
// If the root instance has a sit with pre-populated capsuleInstances, use it directly
646-
if (rootInstance.sit?.capsuleInstances && Object.keys(rootInstance.sit.capsuleInstances).length > 0) {
647-
Object.assign(capsuleInstances, rootInstance.sit.capsuleInstances)
648-
} else {
649-
// Iterative stack-based collection from instance tree
650-
const stack: Array<{ instance: any, parentId: string }> = [{ instance: rootInstance, parentId: '' }]
651-
const visited = new Set<string>()
652-
while (stack.length > 0) {
653-
const { instance, parentId } = stack.pop()!
654-
if (!instance?.capsuleSourceUriLineRefInstanceId) continue
655-
const id = instance.capsuleSourceUriLineRefInstanceId
656-
if (visited.has(id)) continue
657-
visited.add(id)
658-
659-
capsuleInstances[id] = {
660-
capsuleName: instance.capsuleName || '',
661-
capsuleSourceUriLineRef: instance.capsuleSourceUriLineRef || '',
662-
parentCapsuleSourceUriLineRefInstanceId: parentId
663-
}
645+
// Iterative stack-based collection from instance tree
646+
const stack: Array<{ instance: any, parentId: string }> = [{ instance: rootInstance, parentId: '' }]
647+
const visited = new Set<string>()
648+
while (stack.length > 0) {
649+
const { instance, parentId } = stack.pop()!
650+
if (!instance?.capsuleSourceUriLineRefInstanceId) continue
651+
const id = instance.capsuleSourceUriLineRefInstanceId
652+
if (visited.has(id)) continue
653+
visited.add(id)
654+
655+
capsuleInstances[id] = {
656+
capsuleName: instance.capsuleName || '',
657+
capsuleSourceUriLineRef: instance.capsuleSourceUriLineRef || '',
658+
parentCapsuleSourceUriLineRefInstanceId: parentId
659+
}
664660

665-
if (instance.extendedCapsuleInstance) {
666-
stack.push({ instance: instance.extendedCapsuleInstance, parentId: id })
667-
}
668-
if (instance.mappedCapsuleInstances) {
669-
for (const mapped of instance.mappedCapsuleInstances) {
670-
stack.push({ instance: mapped, parentId: id })
671-
}
661+
if (instance.extendedCapsuleInstance) {
662+
stack.push({ instance: instance.extendedCapsuleInstance, parentId: id })
663+
}
664+
if (instance.mappedCapsuleInstances) {
665+
for (const mapped of instance.mappedCapsuleInstances) {
666+
stack.push({ instance: mapped, parentId: id })
672667
}
673668
}
674669
}

‎tests/10-CapsuleSpines/main.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,25 @@ describe('CapsuleSpine Linking Features', () => {
190190
// from capsules without options (service1, service2 reuse Service)
191191
expect(instanceIds.length).toBeGreaterThanOrEqual(expectedCapsules.length)
192192

193+
// Every capsule instance that declares structs/Capsule should get its own
194+
// structs/Capsule instance (just like a property contract mapping).
195+
// The structs/Capsule instance should NOT be shared across parents.
196+
const structsCapsuleInstances = instanceEntries.filter(
197+
([, e]) => (e as any).capsuleName === '@stream44.studio/encapsulate/structs/Capsule'
198+
)
199+
// Count non-structs/Capsule instances — each one declares structs/Capsule,
200+
// so there should be one structs/Capsule instance per parent capsule instance
201+
const nonStructInstances = instanceEntries.filter(
202+
([, e]) => (e as any).capsuleName !== '@stream44.studio/encapsulate/structs/Capsule'
203+
)
204+
expect(structsCapsuleInstances.length).toBe(nonStructInstances.length)
205+
206+
// Each structs/Capsule instance must have a unique parent
207+
const structsCapsuleParents = structsCapsuleInstances.map(
208+
([, e]) => (e as any).parentCapsuleSourceUriLineRefInstanceId
209+
)
210+
expect(new Set(structsCapsuleParents).size).toBe(structsCapsuleParents.length)
211+
193212
// Verify membrane events contain expected properties
194213
expect(membraneEvents.length).toBeGreaterThan(0)
195214

0 commit comments

Comments
 (0)