@highstate/backend 0.19.1 → 0.21.1

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.
Files changed (134) hide show
  1. package/dist/chunk-b05q6fm2.js +37 -0
  2. package/dist/{chunk-V2NILDHS.js → chunk-gxjwa93h.js} +704 -604
  3. package/dist/{chunk-X2WG3WGL.js → chunk-vzdz6chj.js} +18 -15
  4. package/dist/highstate.manifest.json +4 -4
  5. package/dist/index.js +7350 -3514
  6. package/dist/library/package-resolution-worker.js +121 -10
  7. package/dist/library/worker/main.js +31 -17
  8. package/dist/shared/index.js +254 -4
  9. package/package.json +19 -20
  10. package/prisma/backend/_schema/object.prisma +12 -0
  11. package/prisma/backend/sqlite/migrations/20260222113554_add_object_tracking/migration.sql +7 -0
  12. package/prisma/project/artifact.prisma +3 -0
  13. package/prisma/project/entity.prisma +125 -0
  14. package/prisma/project/instance.prisma +6 -0
  15. package/prisma/project/migrations/20260301210131_add_entity_tracking/migration.sql +70 -0
  16. package/prisma/project/migrations/20260302212734_add_resource_hooks_flag/migration.sql +1 -0
  17. package/prisma/project/operation.prisma +3 -0
  18. package/src/artifact/factory.ts +3 -2
  19. package/src/business/artifact.test.ts +22 -2
  20. package/src/business/artifact.ts +7 -1
  21. package/src/business/entity-snapshot.test.ts +684 -0
  22. package/src/business/entity-snapshot.ts +904 -0
  23. package/src/business/evaluation.test.ts +56 -0
  24. package/src/business/evaluation.ts +102 -22
  25. package/src/business/global-search.test.ts +344 -0
  26. package/src/business/global-search.ts +902 -0
  27. package/src/business/index.ts +4 -0
  28. package/src/business/instance-lock.ts +58 -74
  29. package/src/business/instance-state.test.ts +15 -1
  30. package/src/business/instance-state.ts +37 -14
  31. package/src/business/object-ref-index.test.ts +140 -0
  32. package/src/business/object-ref-index.ts +193 -0
  33. package/src/business/operation.test.ts +15 -1
  34. package/src/business/operation.ts +4 -0
  35. package/src/business/project-model.ts +154 -13
  36. package/src/business/project-unlock.ts +25 -2
  37. package/src/business/project.ts +9 -0
  38. package/src/business/secret.test.ts +35 -2
  39. package/src/business/secret.ts +32 -9
  40. package/src/business/settings.ts +761 -0
  41. package/src/business/unit-output.test.ts +477 -0
  42. package/src/business/unit-output.ts +461 -0
  43. package/src/business/worker.ts +55 -4
  44. package/src/database/_generated/backend/postgresql/browser.ts +6 -0
  45. package/src/database/_generated/backend/postgresql/client.ts +6 -0
  46. package/src/database/_generated/backend/postgresql/internal/class.ts +23 -5
  47. package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +89 -5
  48. package/src/database/_generated/backend/postgresql/internal/prismaNamespaceBrowser.ts +9 -0
  49. package/src/database/_generated/backend/postgresql/models/Object.ts +1076 -0
  50. package/src/database/_generated/backend/postgresql/models.ts +1 -0
  51. package/src/database/_generated/backend/sqlite/browser.ts +6 -0
  52. package/src/database/_generated/backend/sqlite/client.ts +6 -0
  53. package/src/database/_generated/backend/sqlite/internal/class.ts +23 -5
  54. package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +89 -5
  55. package/src/database/_generated/backend/sqlite/internal/prismaNamespaceBrowser.ts +9 -0
  56. package/src/database/_generated/backend/sqlite/models/Object.ts +1074 -0
  57. package/src/database/_generated/backend/sqlite/models.ts +1 -0
  58. package/src/database/_generated/project/browser.ts +23 -0
  59. package/src/database/_generated/project/client.ts +23 -0
  60. package/src/database/_generated/project/commonInputTypes.ts +87 -53
  61. package/src/database/_generated/project/enums.ts +8 -0
  62. package/src/database/_generated/project/internal/class.ts +53 -5
  63. package/src/database/_generated/project/internal/prismaNamespace.ts +367 -13
  64. package/src/database/_generated/project/internal/prismaNamespaceBrowser.ts +48 -1
  65. package/src/database/_generated/project/models/Artifact.ts +199 -11
  66. package/src/database/_generated/project/models/Entity.ts +1274 -0
  67. package/src/database/_generated/project/models/EntitySnapshot.ts +2389 -0
  68. package/src/database/_generated/project/models/EntitySnapshotContent.ts +1260 -0
  69. package/src/database/_generated/project/models/EntitySnapshotReference.ts +1449 -0
  70. package/src/database/_generated/project/models/InstanceState.ts +361 -1
  71. package/src/database/_generated/project/models/Operation.ts +148 -3
  72. package/src/database/_generated/project/models/OperationLog.ts +0 -4
  73. package/src/database/_generated/project/models.ts +4 -0
  74. package/src/database/migration.ts +3 -0
  75. package/src/library/find-package-json.test.ts +77 -0
  76. package/src/library/find-package-json.ts +149 -0
  77. package/src/library/package-resolution-worker.ts +7 -3
  78. package/src/library/worker/evaluator.ts +7 -1
  79. package/src/orchestrator/manager.ts +7 -0
  80. package/src/orchestrator/operation-context.captured-outputs.test.ts +118 -0
  81. package/src/orchestrator/operation-context.ts +154 -16
  82. package/src/orchestrator/operation-plan.destroy.test.md +33 -12
  83. package/src/orchestrator/operation-plan.destroy.test.ts +140 -2
  84. package/src/orchestrator/operation-plan.fixtures.ts +2 -0
  85. package/src/orchestrator/operation-plan.md +4 -1
  86. package/src/orchestrator/operation-plan.ts +286 -92
  87. package/src/orchestrator/operation-plan.update.test.md +286 -11
  88. package/src/orchestrator/operation-plan.update.test.ts +656 -5
  89. package/src/orchestrator/operation-workset.ts +72 -22
  90. package/src/orchestrator/operation.cancel.test.ts +4 -0
  91. package/src/orchestrator/operation.composite.test.ts +341 -0
  92. package/src/orchestrator/operation.destroy.test.ts +4 -0
  93. package/src/orchestrator/operation.output-validation.failure.test.ts +124 -0
  94. package/src/orchestrator/operation.preview.test.ts +4 -0
  95. package/src/orchestrator/operation.refresh.test.ts +4 -0
  96. package/src/orchestrator/operation.test-utils.ts +52 -13
  97. package/src/orchestrator/operation.ts +230 -68
  98. package/src/orchestrator/operation.update.failure.test.ts +4 -0
  99. package/src/orchestrator/operation.update.skip.test.ts +196 -0
  100. package/src/orchestrator/operation.update.test.ts +4 -0
  101. package/src/orchestrator/plan-test-builder.ts +1 -0
  102. package/src/orchestrator/unit-input-values.test.ts +450 -0
  103. package/src/orchestrator/unit-input-values.ts +281 -0
  104. package/src/pubsub/manager.ts +3 -0
  105. package/src/runner/abstractions.ts +23 -54
  106. package/src/runner/factory.ts +3 -3
  107. package/src/runner/force-abort.ts +7 -2
  108. package/src/runner/local.ts +116 -87
  109. package/src/runner/pulumi.ts +3 -5
  110. package/src/services.ts +53 -2
  111. package/src/shared/models/prisma.ts +1 -0
  112. package/src/shared/models/project/entity.ts +121 -0
  113. package/src/shared/models/project/index.ts +1 -0
  114. package/src/shared/models/project/operation.ts +61 -3
  115. package/src/shared/models/project/state.ts +10 -0
  116. package/src/shared/models/project/worker.ts +7 -0
  117. package/src/shared/resolvers/effective-output-type.test.ts +494 -0
  118. package/src/shared/resolvers/effective-output-type.ts +162 -0
  119. package/src/shared/resolvers/index.ts +1 -0
  120. package/src/shared/resolvers/input.ts +59 -9
  121. package/src/shared/utils/index.ts +1 -0
  122. package/src/shared/utils/stable-json.ts +41 -0
  123. package/src/terminal/manager.ts +6 -0
  124. package/src/terminal/run.sh.ts +9 -4
  125. package/src/worker/manager.ts +97 -1
  126. package/LICENSE +0 -21
  127. package/dist/chunk-I7BWSAN6.js +0 -49
  128. package/dist/chunk-I7BWSAN6.js.map +0 -1
  129. package/dist/chunk-V2NILDHS.js.map +0 -1
  130. package/dist/chunk-X2WG3WGL.js.map +0 -1
  131. package/dist/index.js.map +0 -1
  132. package/dist/library/package-resolution-worker.js.map +0 -1
  133. package/dist/library/worker/main.js.map +0 -1
  134. package/dist/shared/index.js.map +0 -1
@@ -0,0 +1,118 @@
1
+ import pino from "pino"
2
+ import { describe, expect, test } from "vitest"
3
+ import { OperationContext } from "./operation-context"
4
+
5
+ describe("OperationContext.updateCapturedOutputValuesFromUnitOutputs", () => {
6
+ test("captures single + multiple outputs and clears missing ones", () => {
7
+ const context = new (
8
+ OperationContext as unknown as {
9
+ new (...args: unknown[]): OperationContext
10
+ }
11
+ )(
12
+ { id: "project" },
13
+ {
14
+ components: {
15
+ "component.v1": {
16
+ outputs: {
17
+ single: { type: "entity.single.v1" },
18
+ multi: { type: "entity.multi.v1", multiple: true },
19
+ },
20
+ },
21
+ },
22
+ entities: {},
23
+ },
24
+ pino({ level: "silent" }),
25
+ )
26
+
27
+ context.updateCapturedOutputValuesFromUnitOutputs({
28
+ instanceId: "component.v1:one",
29
+ instanceType: "component.v1",
30
+ outputs: {
31
+ single: { value: { a: 1 } },
32
+ multi: { value: [{ b: 2 }, { b: 3 }] },
33
+ },
34
+ })
35
+
36
+ expect(context.getCapturedOutputValues("component.v1:one", "single")).toEqual([
37
+ { ok: true, value: { a: 1 } },
38
+ ])
39
+ expect(context.getCapturedOutputValues("component.v1:one", "multi")).toEqual([
40
+ { ok: true, value: { b: 2 } },
41
+ { ok: true, value: { b: 3 } },
42
+ ])
43
+
44
+ context.updateCapturedOutputValuesFromUnitOutputs({
45
+ instanceId: "component.v1:one",
46
+ instanceType: "component.v1",
47
+ outputs: {
48
+ single: { value: null },
49
+ multi: { value: undefined },
50
+ },
51
+ })
52
+
53
+ expect(context.getCapturedOutputValues("component.v1:one", "single")).toEqual([])
54
+ expect(context.getCapturedOutputValues("component.v1:one", "multi")).toEqual([])
55
+ })
56
+
57
+ test("throws when multiple output is not an array", () => {
58
+ const context = new (
59
+ OperationContext as unknown as {
60
+ new (...args: unknown[]): OperationContext
61
+ }
62
+ )(
63
+ { id: "project" },
64
+ {
65
+ components: {
66
+ "component.v1": {
67
+ outputs: {
68
+ multi: { type: "entity.multi.v1", multiple: true },
69
+ },
70
+ },
71
+ },
72
+ entities: {},
73
+ },
74
+ pino({ level: "silent" }),
75
+ )
76
+
77
+ expect(() =>
78
+ context.updateCapturedOutputValuesFromUnitOutputs({
79
+ instanceId: "component.v1:one",
80
+ instanceType: "component.v1",
81
+ outputs: {
82
+ multi: { value: { b: 1 } },
83
+ },
84
+ }),
85
+ ).toThrow('Output "multi" for instance "component.v1:one" must be an array')
86
+ })
87
+
88
+ test("throws when output item is not an object", () => {
89
+ const context = new (
90
+ OperationContext as unknown as {
91
+ new (...args: unknown[]): OperationContext
92
+ }
93
+ )(
94
+ { id: "project" },
95
+ {
96
+ components: {
97
+ "component.v1": {
98
+ outputs: {
99
+ multi: { type: "entity.multi.v1", multiple: true },
100
+ },
101
+ },
102
+ },
103
+ entities: {},
104
+ },
105
+ pino({ level: "silent" }),
106
+ )
107
+
108
+ expect(() =>
109
+ context.updateCapturedOutputValuesFromUnitOutputs({
110
+ instanceId: "component.v1:one",
111
+ instanceType: "component.v1",
112
+ outputs: {
113
+ multi: { value: [123] },
114
+ },
115
+ }),
116
+ ).toThrow('Output "multi" for instance "component.v1:one" must contain objects')
117
+ })
118
+ })
@@ -1,5 +1,10 @@
1
1
  import type { Logger } from "pino"
2
- import type { InstanceStateService, ProjectModelService } from "../business"
2
+ import type {
3
+ CapturedEntitySnapshotValue,
4
+ EntitySnapshotService,
5
+ InstanceStateService,
6
+ ProjectModelService,
7
+ } from "../business"
3
8
  import type { LibraryBackend } from "../library"
4
9
  import {
5
10
  type ComponentModel,
@@ -7,6 +12,8 @@ import {
7
12
  type InstanceInput,
8
13
  type InstanceModel,
9
14
  isUnitModel,
15
+ parseInstanceId,
16
+ type VersionedName,
10
17
  } from "@highstate/contract"
11
18
  import { BetterLock } from "better-lock"
12
19
  import { mapValues, unique } from "remeda"
@@ -24,6 +31,8 @@ import {
24
31
  type StableInstanceInput,
25
32
  } from "../shared"
26
33
 
34
+ type RawPulumiOutputs = Record<string, { value: unknown; secret?: boolean }>
35
+
27
36
  export class OperationContext {
28
37
  private readonly instanceMap = new Map<InstanceId, InstanceModel>()
29
38
  private readonly instanceChildrenMap = new Map<InstanceId, InstanceModel[]>()
@@ -48,6 +57,8 @@ export class OperationContext {
48
57
  Record<InstanceId, ResolvedInstanceInput[]>
49
58
  >()
50
59
 
60
+ private readonly capturedOutputValueMap = new Map<string, CapturedEntitySnapshotValue[]>()
61
+
51
62
  private constructor(
52
63
  public readonly project: ProjectOutput,
53
64
  public readonly library: LibraryModel,
@@ -55,7 +66,7 @@ export class OperationContext {
55
66
  ) {}
56
67
 
57
68
  public getInstance(instanceId: InstanceId): InstanceModel {
58
- const instance = this.instanceMap.get(instanceId)
69
+ const instance = this.tryGetInstance(instanceId)
59
70
  if (!instance) {
60
71
  throw new Error(`Instance with ID ${instanceId} not found in the operation context`)
61
72
  }
@@ -63,6 +74,42 @@ export class OperationContext {
63
74
  return instance
64
75
  }
65
76
 
77
+ public tryGetInstance(instanceId: InstanceId): InstanceModel | undefined {
78
+ return this.instanceMap.get(instanceId)
79
+ }
80
+
81
+ public tryGetInstanceForDestroy(instanceId: InstanceId): InstanceModel | undefined {
82
+ const modelInstance = this.tryGetInstance(instanceId)
83
+ if (modelInstance) {
84
+ return modelInstance
85
+ }
86
+
87
+ const state = this.stateMap.get(instanceId)
88
+ if (!state) {
89
+ return undefined
90
+ }
91
+
92
+ const stateModel = state.model ?? state.lastOperationState?.model
93
+ if (stateModel) {
94
+ return stateModel
95
+ }
96
+
97
+ const [type, name] = parseInstanceId(instanceId)
98
+
99
+ return {
100
+ id: instanceId,
101
+ name,
102
+ type,
103
+ kind: state.kind,
104
+ parentId: state.parentInstanceId ?? undefined,
105
+ inputs: {},
106
+ args: {},
107
+ outputs: {},
108
+ resolvedInputs: {},
109
+ resolvedOutputs: {},
110
+ }
111
+ }
112
+
66
113
  public isGhostInstance(instanceId: InstanceId): boolean {
67
114
  return this.ghostInstanceIds.has(instanceId)
68
115
  }
@@ -85,6 +132,56 @@ export class OperationContext {
85
132
  return this.resolvedInstanceInputs.get(instanceId)
86
133
  }
87
134
 
135
+ public getCapturedOutputValues(
136
+ instanceId: InstanceId,
137
+ output: string,
138
+ ): CapturedEntitySnapshotValue[] {
139
+ const key = `${instanceId}:${output}`
140
+ return this.capturedOutputValueMap.get(key) ?? []
141
+ }
142
+
143
+ public updateCapturedOutputValuesFromUnitOutputs(options: {
144
+ instanceId: InstanceId
145
+ instanceType: VersionedName
146
+ outputs: RawPulumiOutputs
147
+ }): void {
148
+ const component = this.library.components[options.instanceType]
149
+ if (!component) {
150
+ return
151
+ }
152
+
153
+ for (const [outputName, outputSpec] of Object.entries(component.outputs ?? {})) {
154
+ const raw = options.outputs[outputName]?.value
155
+
156
+ if (raw === undefined || raw === null) {
157
+ this.capturedOutputValueMap.set(`${options.instanceId}:${outputName}`, [])
158
+ continue
159
+ }
160
+
161
+ const items = outputSpec.multiple ? raw : [raw]
162
+ if (outputSpec.multiple && !Array.isArray(raw)) {
163
+ throw new Error(
164
+ `Output "${outputName}" for instance "${options.instanceId}" must be an array`,
165
+ )
166
+ }
167
+
168
+ const values = (items as unknown[]).map(item => {
169
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
170
+ throw new Error(
171
+ `Output "${outputName}" for instance "${options.instanceId}" must contain objects`,
172
+ )
173
+ }
174
+
175
+ return {
176
+ ok: true,
177
+ value: item as Record<string, unknown>,
178
+ } satisfies CapturedEntitySnapshotValue
179
+ })
180
+
181
+ this.capturedOutputValueMap.set(`${options.instanceId}:${outputName}`, values)
182
+ }
183
+ }
184
+
88
185
  public setState(state: InstanceState): void {
89
186
  this.stateMap.set(state.instanceId, state)
90
187
  this.stateIdMap.set(state.id, state.instanceId)
@@ -127,6 +224,7 @@ export class OperationContext {
127
224
  return {
128
225
  stateId: state.id,
129
226
  output: input.output,
227
+ path: input.path,
130
228
  }
131
229
  }),
132
230
  )
@@ -247,22 +345,49 @@ export class OperationContext {
247
345
  }
248
346
  }
249
347
 
250
- /**
251
- * Gets a map of instance IDs to state IDs for dependencies of the given instance.
252
- *
253
- * @param instanceId The instance ID to get the state ID map for.
254
- * @returns A map of instance IDs to state IDs.
255
- */
256
- public getInstanceIdToStateIdMap(instanceId: InstanceId): Record<InstanceId, string> {
257
- const map: Record<InstanceId, string> = {}
258
- const dependencies = this.getDependencies(instanceId).map(i => i.id)
348
+ private async captureEntitySnapshotsAtOperationStart(options: {
349
+ projectId: string
350
+ entitySnapshotService: EntitySnapshotService
351
+ }): Promise<void> {
352
+ const keys: {
353
+ stateId: string
354
+ output: string
355
+ instanceId: InstanceId
356
+ operationId?: string
357
+ }[] = []
358
+
359
+ for (const [instanceId, inputs] of this.resolvedInstanceInputs.entries()) {
360
+ for (const inputGroup of Object.values(inputs ?? {})) {
361
+ for (const input of inputGroup) {
362
+ if (input.input.instanceId === instanceId) {
363
+ continue
364
+ }
259
365
 
260
- for (const dep of dependencies) {
261
- const state = this.getState(dep)
262
- map[dep] = state.id
366
+ const dependencyState = this.getState(input.input.instanceId)
367
+ keys.push({
368
+ stateId: dependencyState.id,
369
+ output: input.input.output,
370
+ instanceId: input.input.instanceId,
371
+ operationId: dependencyState.lastOperationState?.operationId,
372
+ })
373
+ }
374
+ }
375
+ }
376
+
377
+ if (keys.length === 0) {
378
+ return
263
379
  }
264
380
 
265
- return map
381
+ const captured = await options.entitySnapshotService.reconstructLatestExportedOutputValues(
382
+ options.projectId,
383
+ keys.map(k => ({ stateId: k.stateId, output: k.output, operationId: k.operationId })),
384
+ this.library,
385
+ )
386
+
387
+ for (const key of keys) {
388
+ const snapshotValues = captured.get(`${key.stateId}:${key.output}`) ?? []
389
+ this.capturedOutputValueMap.set(`${key.instanceId}:${key.output}`, snapshotValues)
390
+ }
266
391
  }
267
392
 
268
393
  getUnfinishedOperationStates(): InstanceState[] {
@@ -281,6 +406,7 @@ export class OperationContext {
281
406
  libraryBackend: LibraryBackend,
282
407
  instanceStateService: InstanceStateService,
283
408
  projectModelService: ProjectModelService,
409
+ entitySnapshotService: EntitySnapshotService | undefined,
284
410
  logger: Logger,
285
411
  ): Promise<OperationContext> {
286
412
  const [{ instances, virtualInstances, hubs, ghostInstances }, project] =
@@ -348,11 +474,16 @@ export class OperationContext {
348
474
  kind: "instance",
349
475
  instance,
350
476
  component: library.components[instance.type],
477
+ entities: library.entities,
351
478
  })
352
479
  }
353
480
 
354
481
  for (const hub of hubs) {
355
- context.inputResolverNodes.set(`hub:${hub.id}`, { kind: "hub", hub })
482
+ context.inputResolverNodes.set(`hub:${hub.id}`, {
483
+ kind: "hub",
484
+ hub,
485
+ entities: library.entities,
486
+ })
356
487
  }
357
488
 
358
489
  context.inputResolver = new InputResolver(context.inputResolverNodes, logger)
@@ -388,6 +519,13 @@ export class OperationContext {
388
519
 
389
520
  await context.inputHashResolver.process()
390
521
 
522
+ if (entitySnapshotService) {
523
+ await context.captureEntitySnapshotsAtOperationStart({
524
+ projectId,
525
+ entitySnapshotService,
526
+ })
527
+ }
528
+
391
529
  return context
392
530
  }
393
531
  }
@@ -77,9 +77,9 @@ graph RL
77
77
 
78
78
  **Destroy Phase**: `C`, `B`
79
79
 
80
- ### Example 4: Composite Boundary Isolation
80
+ ### Example 4: Full Ancestor Chain for Compositional Inclusion
81
81
 
82
- **Test**: `should not propagate beyond compositional inclusion`
82
+ **Test**: `should include full ancestor chain for compositional inclusion`
83
83
 
84
84
  ```mermaid
85
85
  graph RL
@@ -99,10 +99,10 @@ graph RL
99
99
  1. `A` explicitly requested;
100
100
  2. `B` depends on `A`, cascade enabled → `B` included (dependent of `A`);
101
101
  3. `A` is child of `Parent` → `Parent` included (compositional);
102
- 4. `Parent` is child of `GrandParent` → `GrandParent` NOT included (compositional boundary);
103
- 5. `C` is sibling of `Parent` but `GrandParent` is not included → `C` NOT included.
102
+ 4. `Parent` is child of `GrandParent` → `GrandParent` included as ancestor;
103
+ 5. `C` is sibling of `Parent`, but ancestor inclusion does not auto-include siblings → `C` NOT included.
104
104
 
105
- **Destroy Phase**: `B`, `A`, `Parent`
105
+ **Destroy Phase**: `B`, `A`, `Parent`, `GrandParent`
106
106
 
107
107
  ### Example 5: Substantive Composite with Mixed Child States
108
108
 
@@ -150,9 +150,9 @@ graph RL
150
150
  1. `Child1` explicitly requested;
151
151
  2. No instances depend on `Child1` (Child1 depends on Child3, not vice versa);
152
152
  3. `Child1` is child of `Parent1` → `Parent1` included (compositional);
153
- 4. `Parent1` is child of `GrandParent` → `GrandParent` NOT included (compositional boundary).
153
+ 4. `Parent1` is child of `GrandParent` → `GrandParent` included as ancestor.
154
154
 
155
- **Destroy Phase**: `Child1`, `Parent1`
155
+ **Destroy Phase**: `Child1`, `Parent1`, `GrandParent`
156
156
 
157
157
  ### Example 7: Request Child with Isolated Destroy
158
158
 
@@ -250,9 +250,9 @@ graph RL
250
250
 
251
251
  **Destroy Phase**: `C`, `B`, `A`
252
252
 
253
- ### Example 11: Deep Nesting with Boundary Isolation
253
+ ### Example 11: Deep Nesting with Ancestor Chain Inclusion
254
254
 
255
- **Test**: `should isolate boundaries in deep composite hierarchies`
255
+ **Test**: `should include deep ancestor chain without ancestor siblings`
256
256
 
257
257
  ```mermaid
258
258
  graph RL
@@ -272,10 +272,11 @@ graph RL
272
272
  1. `Child` explicitly requested;
273
273
  2. No instances depend on `Child`;
274
274
  3. `Child` is child of `Parent` → `Parent` included (compositional);
275
- 4. `Parent` is child of `GrandParent` → `GrandParent` NOT included (compositional boundary);
276
- 5. `Uncle` and `GreatUncle` not affected.
275
+ 4. `Parent` is child of `GrandParent` → `GrandParent` included as ancestor;
276
+ 5. `GrandParent` is child of `GreatGrandParent` `GreatGrandParent` included as ancestor;
277
+ 6. `Uncle` and `GreatUncle` are ancestor siblings and are not auto-included.
277
278
 
278
- **Destroy Phase**: `Child`, `Parent`
279
+ **Destroy Phase**: `Child`, `Parent`, `GrandParent`, `GreatGrandParent`
279
280
 
280
281
  ### Example 12: Diamond Dependency Pattern
281
282
 
@@ -355,3 +356,23 @@ graph RL
355
356
  4. `Child2` is sibling of `Child1` in substantive composite, partial destruction enabled → `Child2` excluded.
356
357
 
357
358
  **Destroy Phase**: `Child1`, `Parent`, `ExternalX`
359
+
360
+ ### Example 15: State-Only Dependent Missing from Model
361
+
362
+ **Test**: `should include state-only dependents when model instance is missing`
363
+
364
+ ```mermaid
365
+ graph RL
366
+ Requested["requested 🚀"]
367
+ Dependent["dependent ✅ (state-only)"]
368
+
369
+ Dependent --> Requested
370
+ ```
371
+
372
+ **Decision Steps**:
373
+
374
+ 1. `requested` explicitly requested;
375
+ 2. `dependent` is not present in current project model, but exists in state with a dependency on `requested`;
376
+ 3. destroy cascade uses state relationships, so `dependent` is included as dependent of destroyed `requested`.
377
+
378
+ **Destroy Phase**: `dependent`, `requested`
@@ -1,3 +1,5 @@
1
+ import type { InstanceState } from "../shared"
2
+ import { getInstanceId } from "@highstate/contract"
1
3
  import { describe } from "vitest"
2
4
  import { createOperationPlan } from "./operation-plan"
3
5
  import { operationPlanTest } from "./operation-plan.fixtures"
@@ -140,7 +142,7 @@ describe("OperationPlan - Destroy Operations", () => {
140
142
  )
141
143
 
142
144
  operationPlanTest(
143
- "4. should not propagate beyond compositional inclusion",
145
+ "4. should include full ancestor chain for compositional inclusion",
144
146
  async ({ testBuilder, expect }) => {
145
147
  // arrange
146
148
  const { context, operation } = await testBuilder()
@@ -190,6 +192,11 @@ describe("OperationPlan - Destroy Operations", () => {
190
192
  "message": "parent of included child "component.v1:A"",
191
193
  "parentId": "composite.v1:GrandParent",
192
194
  },
195
+ {
196
+ "id": "composite.v1:GrandParent",
197
+ "message": "parent of included child "composite.v1:Parent"",
198
+ "parentId": undefined,
199
+ },
193
200
  ],
194
201
  "type": "destroy",
195
202
  },
@@ -307,6 +314,11 @@ describe("OperationPlan - Destroy Operations", () => {
307
314
  "message": "parent of included child "component.v1:Child1"",
308
315
  "parentId": "composite.v1:GrandParent",
309
316
  },
317
+ {
318
+ "id": "composite.v1:GrandParent",
319
+ "message": "parent of included child "composite.v1:Parent1"",
320
+ "parentId": undefined,
321
+ },
310
322
  ],
311
323
  "type": "destroy",
312
324
  },
@@ -542,7 +554,7 @@ describe("OperationPlan - Destroy Operations", () => {
542
554
  )
543
555
 
544
556
  operationPlanTest(
545
- "11. should isolate boundaries in deep composite hierarchies",
557
+ "11. should include deep ancestor chain without ancestor siblings",
546
558
  async ({ testBuilder, expect }) => {
547
559
  // arrange
548
560
  const { context, operation } = await testBuilder()
@@ -589,6 +601,16 @@ describe("OperationPlan - Destroy Operations", () => {
589
601
  "message": "parent of included child "component.v1:Child"",
590
602
  "parentId": "composite.v1:GrandParent",
591
603
  },
604
+ {
605
+ "id": "composite.v1:GrandParent",
606
+ "message": "parent of included child "composite.v1:Parent"",
607
+ "parentId": "composite.v1:GreatGrandParent",
608
+ },
609
+ {
610
+ "id": "composite.v1:GreatGrandParent",
611
+ "message": "parent of included child "composite.v1:GrandParent"",
612
+ "parentId": undefined,
613
+ },
592
614
  ],
593
615
  "type": "destroy",
594
616
  },
@@ -772,4 +794,120 @@ describe("OperationPlan - Destroy Operations", () => {
772
794
  `)
773
795
  },
774
796
  )
797
+
798
+ operationPlanTest(
799
+ "15. should include state-only dependents when model instance is missing",
800
+ async ({ createContext, createTestOperation, expect }) => {
801
+ // arrange
802
+ const requestedId = getInstanceId("component.v1", "requested")
803
+ const dependentId = getInstanceId("component.v1", "dependent")
804
+
805
+ const context = await createContext(
806
+ [
807
+ {
808
+ id: requestedId,
809
+ name: "requested",
810
+ type: "component.v1",
811
+ kind: "unit",
812
+ parentId: undefined,
813
+ inputs: {},
814
+ args: {},
815
+ outputs: {},
816
+ resolvedInputs: {},
817
+ resolvedOutputs: {},
818
+ },
819
+ ],
820
+ [
821
+ {
822
+ id: requestedId,
823
+ instanceId: requestedId,
824
+ status: "deployed",
825
+ source: "resident",
826
+ kind: "unit",
827
+ hasResourceHooks: false,
828
+ parentId: null,
829
+ parentInstanceId: null,
830
+ selfHash: null,
831
+ inputHash: null,
832
+ outputHash: null,
833
+ dependencyOutputHash: null,
834
+ statusFields: null,
835
+ exportedArtifactIds: null,
836
+ inputHashNonce: null,
837
+ currentResourceCount: null,
838
+ model: null,
839
+ resolvedInputs: null,
840
+ lastOperationState: undefined,
841
+ evaluationState: {} as InstanceState["evaluationState"],
842
+ },
843
+ {
844
+ id: dependentId,
845
+ instanceId: dependentId,
846
+ status: "deployed",
847
+ source: "resident",
848
+ kind: "unit",
849
+ hasResourceHooks: false,
850
+ parentId: null,
851
+ parentInstanceId: null,
852
+ selfHash: null,
853
+ inputHash: null,
854
+ outputHash: null,
855
+ dependencyOutputHash: null,
856
+ statusFields: null,
857
+ exportedArtifactIds: null,
858
+ inputHashNonce: null,
859
+ currentResourceCount: null,
860
+ model: {
861
+ id: dependentId,
862
+ name: "dependent",
863
+ type: "component.v1",
864
+ kind: "unit",
865
+ parentId: undefined,
866
+ inputs: {},
867
+ args: {},
868
+ outputs: {},
869
+ resolvedInputs: {},
870
+ resolvedOutputs: {},
871
+ },
872
+ resolvedInputs: {
873
+ dependency: [{ stateId: requestedId, output: "default" }],
874
+ },
875
+ lastOperationState: undefined,
876
+ evaluationState: {} as InstanceState["evaluationState"],
877
+ },
878
+ ],
879
+ )
880
+
881
+ const operation = createTestOperation("destroy", [requestedId])
882
+
883
+ // act
884
+ const plan = createOperationPlan(
885
+ context,
886
+ operation.type,
887
+ operation.requestedInstanceIds,
888
+ operation.options,
889
+ )
890
+
891
+ // assert
892
+ expect(plan).toMatchInlineSnapshot(`
893
+ [
894
+ {
895
+ "instances": [
896
+ {
897
+ "id": "component.v1:dependent",
898
+ "message": "dependent of destroyed "component.v1:requested"",
899
+ "parentId": undefined,
900
+ },
901
+ {
902
+ "id": "component.v1:requested",
903
+ "message": "explicitly requested",
904
+ "parentId": undefined,
905
+ },
906
+ ],
907
+ "type": "destroy",
908
+ },
909
+ ]
910
+ `)
911
+ },
912
+ )
775
913
  })
@@ -85,6 +85,7 @@ export const operationPlanTest = test.extend<{
85
85
  options: {
86
86
  forceUpdateDependencies: false,
87
87
  ignoreDependencies: false,
88
+ ignoreChangedDependencies: false,
88
89
  forceUpdateChildren: false,
89
90
  destroyDependentInstances: true,
90
91
  invokeDestroyTriggers: true,
@@ -197,6 +198,7 @@ export const operationPlanTest = test.extend<{
197
198
  libraryBackend,
198
199
  instanceStateService,
199
200
  projectModelService,
201
+ undefined,
200
202
  logger,
201
203
  )
202
204
  }
@@ -41,10 +41,12 @@ Different operation types are assembled from phases as follows:
41
41
  - Then an `update phase` containing the same instances to be recreated
42
42
 
43
43
  **Preview Operation:**
44
+
44
45
  - Single `update phase` calculated using update phase rules but not executed
45
46
  - **Restriction**: Only allowed for "edge" instances (instances that depend on others but no instances depend on them)
46
47
 
47
48
  **Refresh Operation:**
49
+
48
50
  - Single `refresh phase` calculated using update phase rules for state refresh
49
51
  - **Key difference**: No destroy phase is created, even if ghost cleanup would normally occur
50
52
 
@@ -108,7 +110,8 @@ Since composite instances cannot be directly depended upon, message conflicts ar
108
110
  ### Propagation Rules
109
111
 
110
112
  - **Substantive composite inclusions** trigger further dependency resolution and parent propagation
111
- - **Compositional composite inclusions** do NOT trigger further propagation (boundary isolation)
113
+ - **Compositional composite inclusions** still propagate upward through the full parent chain
114
+ - **Ancestor siblings are not auto-included** unless they are independently required by other rules
112
115
 
113
116
  ### Ghost Cleanup Rules
114
117