@highstate/backend 0.19.1 → 0.20.0

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 (120) hide show
  1. package/dist/{chunk-V2NILDHS.js → chunk-52MY2TCE.js} +347 -19
  2. package/dist/chunk-52MY2TCE.js.map +1 -0
  3. package/dist/{chunk-I7BWSAN6.js → chunk-UAWBPTDW.js} +3 -3
  4. package/dist/{chunk-I7BWSAN6.js.map → chunk-UAWBPTDW.js.map} +1 -1
  5. package/dist/highstate.manifest.json +4 -4
  6. package/dist/index.js +4159 -785
  7. package/dist/index.js.map +1 -1
  8. package/dist/library/worker/main.js +5 -2
  9. package/dist/library/worker/main.js.map +1 -1
  10. package/dist/shared/index.js +2 -2
  11. package/package.json +7 -7
  12. package/prisma/backend/_schema/object.prisma +12 -0
  13. package/prisma/backend/sqlite/migrations/20260222113554_add_object_tracking/migration.sql +7 -0
  14. package/prisma/project/artifact.prisma +3 -0
  15. package/prisma/project/entity.prisma +125 -0
  16. package/prisma/project/instance.prisma +6 -0
  17. package/prisma/project/migrations/20260301210131_add_entity_tracking/migration.sql +70 -0
  18. package/prisma/project/migrations/20260302212734_add_resource_hooks_flag/migration.sql +1 -0
  19. package/prisma/project/operation.prisma +3 -0
  20. package/src/business/artifact.test.ts +22 -2
  21. package/src/business/artifact.ts +7 -1
  22. package/src/business/entity-snapshot.test.ts +684 -0
  23. package/src/business/entity-snapshot.ts +904 -0
  24. package/src/business/evaluation.test.ts +56 -0
  25. package/src/business/evaluation.ts +102 -22
  26. package/src/business/global-search.test.ts +344 -0
  27. package/src/business/global-search.ts +902 -0
  28. package/src/business/index.ts +4 -0
  29. package/src/business/instance-lock.ts +58 -74
  30. package/src/business/instance-state.test.ts +15 -1
  31. package/src/business/instance-state.ts +37 -14
  32. package/src/business/object-ref-index.test.ts +140 -0
  33. package/src/business/object-ref-index.ts +193 -0
  34. package/src/business/operation.test.ts +15 -1
  35. package/src/business/operation.ts +4 -0
  36. package/src/business/project-model.ts +154 -13
  37. package/src/business/project-unlock.ts +25 -2
  38. package/src/business/project.ts +9 -0
  39. package/src/business/secret.test.ts +35 -2
  40. package/src/business/secret.ts +32 -9
  41. package/src/business/settings.ts +761 -0
  42. package/src/business/unit-output.test.ts +477 -0
  43. package/src/business/unit-output.ts +461 -0
  44. package/src/business/worker.ts +55 -4
  45. package/src/database/_generated/backend/postgresql/browser.ts +6 -0
  46. package/src/database/_generated/backend/postgresql/client.ts +6 -0
  47. package/src/database/_generated/backend/postgresql/internal/class.ts +23 -5
  48. package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +89 -5
  49. package/src/database/_generated/backend/postgresql/internal/prismaNamespaceBrowser.ts +9 -0
  50. package/src/database/_generated/backend/postgresql/models/Object.ts +1076 -0
  51. package/src/database/_generated/backend/postgresql/models.ts +1 -0
  52. package/src/database/_generated/backend/sqlite/browser.ts +6 -0
  53. package/src/database/_generated/backend/sqlite/client.ts +6 -0
  54. package/src/database/_generated/backend/sqlite/internal/class.ts +23 -5
  55. package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +89 -5
  56. package/src/database/_generated/backend/sqlite/internal/prismaNamespaceBrowser.ts +9 -0
  57. package/src/database/_generated/backend/sqlite/models/Object.ts +1074 -0
  58. package/src/database/_generated/backend/sqlite/models.ts +1 -0
  59. package/src/database/_generated/project/browser.ts +23 -0
  60. package/src/database/_generated/project/client.ts +23 -0
  61. package/src/database/_generated/project/commonInputTypes.ts +87 -53
  62. package/src/database/_generated/project/enums.ts +8 -0
  63. package/src/database/_generated/project/internal/class.ts +53 -5
  64. package/src/database/_generated/project/internal/prismaNamespace.ts +367 -13
  65. package/src/database/_generated/project/internal/prismaNamespaceBrowser.ts +48 -1
  66. package/src/database/_generated/project/models/Artifact.ts +199 -11
  67. package/src/database/_generated/project/models/Entity.ts +1274 -0
  68. package/src/database/_generated/project/models/EntitySnapshot.ts +2389 -0
  69. package/src/database/_generated/project/models/EntitySnapshotContent.ts +1260 -0
  70. package/src/database/_generated/project/models/EntitySnapshotReference.ts +1449 -0
  71. package/src/database/_generated/project/models/InstanceState.ts +361 -1
  72. package/src/database/_generated/project/models/Operation.ts +148 -3
  73. package/src/database/_generated/project/models/OperationLog.ts +0 -4
  74. package/src/database/_generated/project/models.ts +4 -0
  75. package/src/database/migration.ts +3 -0
  76. package/src/library/worker/evaluator.ts +7 -1
  77. package/src/orchestrator/manager.ts +7 -0
  78. package/src/orchestrator/operation-context.captured-outputs.test.ts +118 -0
  79. package/src/orchestrator/operation-context.ts +154 -16
  80. package/src/orchestrator/operation-plan.destroy.test.md +33 -12
  81. package/src/orchestrator/operation-plan.destroy.test.ts +140 -2
  82. package/src/orchestrator/operation-plan.fixtures.ts +2 -0
  83. package/src/orchestrator/operation-plan.md +4 -1
  84. package/src/orchestrator/operation-plan.ts +286 -92
  85. package/src/orchestrator/operation-plan.update.test.md +286 -11
  86. package/src/orchestrator/operation-plan.update.test.ts +656 -5
  87. package/src/orchestrator/operation-workset.ts +72 -22
  88. package/src/orchestrator/operation.cancel.test.ts +4 -0
  89. package/src/orchestrator/operation.composite.test.ts +341 -0
  90. package/src/orchestrator/operation.destroy.test.ts +4 -0
  91. package/src/orchestrator/operation.output-validation.failure.test.ts +124 -0
  92. package/src/orchestrator/operation.preview.test.ts +4 -0
  93. package/src/orchestrator/operation.refresh.test.ts +4 -0
  94. package/src/orchestrator/operation.test-utils.ts +52 -13
  95. package/src/orchestrator/operation.ts +228 -68
  96. package/src/orchestrator/operation.update.failure.test.ts +4 -0
  97. package/src/orchestrator/operation.update.skip.test.ts +110 -0
  98. package/src/orchestrator/operation.update.test.ts +4 -0
  99. package/src/orchestrator/plan-test-builder.ts +1 -0
  100. package/src/orchestrator/unit-input-values.test.ts +450 -0
  101. package/src/orchestrator/unit-input-values.ts +281 -0
  102. package/src/pubsub/manager.ts +3 -0
  103. package/src/runner/abstractions.ts +23 -54
  104. package/src/runner/local.ts +109 -85
  105. package/src/services.ts +52 -1
  106. package/src/shared/models/prisma.ts +1 -0
  107. package/src/shared/models/project/entity.ts +121 -0
  108. package/src/shared/models/project/index.ts +1 -0
  109. package/src/shared/models/project/operation.ts +61 -3
  110. package/src/shared/models/project/state.ts +10 -0
  111. package/src/shared/models/project/worker.ts +7 -0
  112. package/src/shared/resolvers/effective-output-type.test.ts +494 -0
  113. package/src/shared/resolvers/effective-output-type.ts +162 -0
  114. package/src/shared/resolvers/index.ts +1 -0
  115. package/src/shared/resolvers/input.ts +59 -9
  116. package/src/shared/utils/index.ts +1 -0
  117. package/src/shared/utils/stable-json.ts +41 -0
  118. package/src/terminal/manager.ts +6 -0
  119. package/src/worker/manager.ts +97 -1
  120. package/dist/chunk-V2NILDHS.js.map +0 -1
@@ -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"
@@ -49,7 +51,7 @@ describe("OperationPlan - Update Operations", () => {
49
51
  )
50
52
 
51
53
  operationPlanTest(
52
- "1a. should ignore dependencies when option enabled",
54
+ "1a. should skip only changed dependencies when ignoreChangedDependencies enabled",
53
55
  async ({ testBuilder, expect }) => {
54
56
  // arrange
55
57
  const { context, operation } = await testBuilder()
@@ -59,6 +61,93 @@ describe("OperationPlan - Update Operations", () => {
59
61
  .depends("C", "B")
60
62
  .depends("B", "A")
61
63
  .states({ A: "upToDate", B: "changed", C: "upToDate" })
64
+ .options({ ignoreChangedDependencies: true })
65
+ .request("update", "C")
66
+ .build()
67
+
68
+ // act
69
+ const plan = createOperationPlan(
70
+ context,
71
+ operation.type,
72
+ operation.requestedInstanceIds,
73
+ operation.options,
74
+ )
75
+
76
+ // assert
77
+ expect(plan).toMatchInlineSnapshot(`
78
+ [
79
+ {
80
+ "instances": [
81
+ {
82
+ "id": "component.v1:C",
83
+ "message": "explicitly requested",
84
+ "parentId": undefined,
85
+ },
86
+ ],
87
+ "type": "update",
88
+ },
89
+ ]
90
+ `)
91
+ },
92
+ )
93
+
94
+ operationPlanTest(
95
+ "1b. should still include undeployed dependencies when ignoreChangedDependencies enabled",
96
+ async ({ testBuilder, expect }) => {
97
+ // arrange
98
+ const { context, operation } = await testBuilder()
99
+ .unit("A")
100
+ .unit("B")
101
+ .unit("C")
102
+ .depends("C", "B")
103
+ .depends("B", "A")
104
+ .states({ A: "changed", B: "undeployed", C: "upToDate" })
105
+ .options({ ignoreChangedDependencies: true })
106
+ .request("update", "C")
107
+ .build()
108
+
109
+ // act
110
+ const plan = createOperationPlan(
111
+ context,
112
+ operation.type,
113
+ operation.requestedInstanceIds,
114
+ operation.options,
115
+ )
116
+
117
+ // assert
118
+ expect(plan).toMatchInlineSnapshot(`
119
+ [
120
+ {
121
+ "instances": [
122
+ {
123
+ "id": "component.v1:B",
124
+ "message": "undeployed and required by "component.v1:C"",
125
+ "parentId": undefined,
126
+ },
127
+ {
128
+ "id": "component.v1:C",
129
+ "message": "explicitly requested",
130
+ "parentId": undefined,
131
+ },
132
+ ],
133
+ "type": "update",
134
+ },
135
+ ]
136
+ `)
137
+ },
138
+ )
139
+
140
+ operationPlanTest(
141
+ "1c. should ignore all dependencies when ignoreDependencies enabled",
142
+ async ({ testBuilder, expect }) => {
143
+ // arrange
144
+ const { context, operation } = await testBuilder()
145
+ .unit("A")
146
+ .unit("B")
147
+ .unit("C")
148
+ .depends("C", "B")
149
+ .depends("B", "A")
150
+ .states({ A: "changed", B: "undeployed", C: "upToDate" })
62
151
  .options({ ignoreDependencies: true })
63
152
  .request("update", "C")
64
153
  .build()
@@ -90,7 +179,167 @@ describe("OperationPlan - Update Operations", () => {
90
179
  )
91
180
 
92
181
  operationPlanTest(
93
- "2. should not propagate beyond compositional inclusion",
182
+ "1d. should tolerate missing parent instance in model when walking substantive ancestors",
183
+ async ({ createContext, createTestOperation, expect }) => {
184
+ // arrange
185
+ const missingParentId = getInstanceId("composite.v1", "MissingParent")
186
+ const parentId = getInstanceId("composite.v1", "Parent")
187
+ const childId = getInstanceId("component.v1", "Child")
188
+ const requestedId = getInstanceId("component.v1", "Requested")
189
+
190
+ const context = await createContext(
191
+ [
192
+ {
193
+ id: parentId,
194
+ name: "Parent",
195
+ type: "composite.v1",
196
+ kind: "composite",
197
+ parentId: missingParentId,
198
+ inputs: {},
199
+ args: {},
200
+ outputs: {},
201
+ resolvedInputs: {},
202
+ resolvedOutputs: {},
203
+ },
204
+ {
205
+ id: childId,
206
+ name: "Child",
207
+ type: "component.v1",
208
+ kind: "unit",
209
+ parentId,
210
+ inputs: {},
211
+ args: {},
212
+ outputs: {},
213
+ resolvedInputs: {},
214
+ resolvedOutputs: {},
215
+ },
216
+ {
217
+ id: requestedId,
218
+ name: "Requested",
219
+ type: "component.v1",
220
+ kind: "unit",
221
+ parentId: undefined,
222
+ inputs: {
223
+ dependency: [{ instanceId: childId, output: "default" }],
224
+ },
225
+ args: {},
226
+ outputs: {},
227
+ resolvedInputs: {
228
+ dependency: [{ instanceId: childId, output: "default" }],
229
+ },
230
+ resolvedOutputs: {},
231
+ },
232
+ ],
233
+ [
234
+ {
235
+ id: parentId,
236
+ instanceId: parentId,
237
+ status: "deployed",
238
+ source: "resident",
239
+ kind: "composite",
240
+ hasResourceHooks: false,
241
+ parentId: null,
242
+ parentInstanceId: missingParentId,
243
+ selfHash: null,
244
+ inputHash: null,
245
+ outputHash: null,
246
+ dependencyOutputHash: null,
247
+ statusFields: null,
248
+ exportedArtifactIds: null,
249
+ inputHashNonce: null,
250
+ currentResourceCount: null,
251
+ model: null,
252
+ resolvedInputs: null,
253
+ lastOperationState: undefined,
254
+ evaluationState: null,
255
+ },
256
+ {
257
+ id: childId,
258
+ instanceId: childId,
259
+ status: "undeployed",
260
+ source: "resident",
261
+ kind: "unit",
262
+ hasResourceHooks: false,
263
+ parentId: null,
264
+ parentInstanceId: parentId,
265
+ selfHash: null,
266
+ inputHash: null,
267
+ outputHash: null,
268
+ dependencyOutputHash: null,
269
+ statusFields: null,
270
+ exportedArtifactIds: null,
271
+ inputHashNonce: null,
272
+ currentResourceCount: null,
273
+ model: null,
274
+ resolvedInputs: null,
275
+ lastOperationState: undefined,
276
+ evaluationState: {} as InstanceState["evaluationState"],
277
+ },
278
+ {
279
+ id: requestedId,
280
+ instanceId: requestedId,
281
+ status: "deployed",
282
+ source: "resident",
283
+ kind: "unit",
284
+ hasResourceHooks: false,
285
+ parentId: null,
286
+ parentInstanceId: null,
287
+ selfHash: null,
288
+ inputHash: null,
289
+ outputHash: null,
290
+ dependencyOutputHash: null,
291
+ statusFields: null,
292
+ exportedArtifactIds: null,
293
+ inputHashNonce: null,
294
+ currentResourceCount: null,
295
+ model: null,
296
+ resolvedInputs: null,
297
+ lastOperationState: undefined,
298
+ evaluationState: {} as InstanceState["evaluationState"],
299
+ },
300
+ ],
301
+ )
302
+
303
+ const operation = createTestOperation("update", [requestedId])
304
+
305
+ // act
306
+ const plan = createOperationPlan(
307
+ context,
308
+ operation.type,
309
+ operation.requestedInstanceIds,
310
+ operation.options,
311
+ )
312
+
313
+ // assert
314
+ expect(plan).toMatchInlineSnapshot(`
315
+ [
316
+ {
317
+ "instances": [
318
+ {
319
+ "id": "component.v1:Child",
320
+ "message": "undeployed and required by "component.v1:Requested"",
321
+ "parentId": "composite.v1:Parent",
322
+ },
323
+ {
324
+ "id": "component.v1:Requested",
325
+ "message": "explicitly requested",
326
+ "parentId": undefined,
327
+ },
328
+ {
329
+ "id": "composite.v1:Parent",
330
+ "message": "parent of included child "component.v1:Child"",
331
+ "parentId": "composite.v1:MissingParent",
332
+ },
333
+ ],
334
+ "type": "update",
335
+ },
336
+ ]
337
+ `)
338
+ },
339
+ )
340
+
341
+ operationPlanTest(
342
+ "2. should include full ancestor chain for compositional inclusion",
94
343
  async ({ testBuilder, expect }) => {
95
344
  // arrange
96
345
  const { context, operation } = await testBuilder()
@@ -140,6 +389,11 @@ describe("OperationPlan - Update Operations", () => {
140
389
  "message": "parent of included child "component.v1:A"",
141
390
  "parentId": "composite.v1:GrandParent",
142
391
  },
392
+ {
393
+ "id": "composite.v1:GrandParent",
394
+ "message": "parent of included child "composite.v1:Parent"",
395
+ "parentId": undefined,
396
+ },
143
397
  ],
144
398
  "type": "update",
145
399
  },
@@ -210,10 +464,37 @@ describe("OperationPlan - Update Operations", () => {
210
464
  .depends("C", "B")
211
465
  .depends("B", "A")
212
466
  .states({ A: "upToDate", B: "upToDate", C: "upToDate" })
213
- .options({ forceUpdateDependencies: true, ignoreDependencies: true })
467
+ .options({ forceUpdateDependencies: true, ignoreChangedDependencies: true })
214
468
  .request("update", "C")
215
469
  .build()
216
470
 
471
+ // act & assert
472
+ expect(() =>
473
+ createOperationPlan(
474
+ context,
475
+ operation.type,
476
+ operation.requestedInstanceIds,
477
+ operation.options,
478
+ ),
479
+ ).toThrowErrorMatchingInlineSnapshot(
480
+ "[Error: Operation options are invalid: forceUpdateDependencies and ignoreChangedDependencies cannot both be enabled.]",
481
+ )
482
+ },
483
+ )
484
+
485
+ operationPlanTest(
486
+ "3b. should reject conflicting force dependency and ignore options",
487
+ async ({ testBuilder, expect }) => {
488
+ // arrange
489
+ const { context, operation } = await testBuilder()
490
+ .unit("A")
491
+ .unit("B")
492
+ .depends("B", "A")
493
+ .states({ A: "upToDate", B: "upToDate" })
494
+ .options({ forceUpdateDependencies: true, ignoreDependencies: true })
495
+ .request("update", "B")
496
+ .build()
497
+
217
498
  // act & assert
218
499
  expect(() =>
219
500
  createOperationPlan(
@@ -228,6 +509,33 @@ describe("OperationPlan - Update Operations", () => {
228
509
  },
229
510
  )
230
511
 
512
+ operationPlanTest(
513
+ "3c. should reject conflicting ignore changed and ignore options",
514
+ async ({ testBuilder, expect }) => {
515
+ // arrange
516
+ const { context, operation } = await testBuilder()
517
+ .unit("A")
518
+ .unit("B")
519
+ .depends("B", "A")
520
+ .states({ A: "upToDate", B: "upToDate" })
521
+ .options({ ignoreChangedDependencies: true, ignoreDependencies: true })
522
+ .request("update", "B")
523
+ .build()
524
+
525
+ // act & assert
526
+ expect(() =>
527
+ createOperationPlan(
528
+ context,
529
+ operation.type,
530
+ operation.requestedInstanceIds,
531
+ operation.options,
532
+ ),
533
+ ).toThrowErrorMatchingInlineSnapshot(
534
+ "[Error: Operation options are invalid: ignoreChangedDependencies and ignoreDependencies cannot both be enabled.]",
535
+ )
536
+ },
537
+ )
538
+
231
539
  operationPlanTest(
232
540
  "4. should include outdated children of substantive composite",
233
541
  async ({ testBuilder, expect }) => {
@@ -332,7 +640,7 @@ describe("OperationPlan - Update Operations", () => {
332
640
  )
333
641
 
334
642
  operationPlanTest(
335
- "5a. should skip ghost children in update phase when forcing child updates",
643
+ "5a. should cleanup ghost children when forceUpdateChildren is enabled",
336
644
  async ({ testBuilder, expect }) => {
337
645
  // arrange
338
646
  const { context, operation } = await testBuilder()
@@ -378,6 +686,249 @@ describe("OperationPlan - Update Operations", () => {
378
686
  },
379
687
  )
380
688
 
689
+ operationPlanTest(
690
+ "5b. should cleanup ghosts from nested child composites during parent composite update",
691
+ async ({ testBuilder, expect }) => {
692
+ // arrange
693
+ const { context, operation } = await testBuilder()
694
+ .composite("Parent")
695
+ .composite("ChildComposite")
696
+ .unit("GhostLeaf")
697
+ .children("Parent", "ChildComposite")
698
+ .children("ChildComposite", "GhostLeaf")
699
+ .states({
700
+ Parent: "upToDate",
701
+ ChildComposite: "upToDate",
702
+ GhostLeaf: "ghost",
703
+ })
704
+ .request("update", "Parent")
705
+ .build()
706
+
707
+ // act
708
+ const plan = createOperationPlan(
709
+ context,
710
+ operation.type,
711
+ operation.requestedInstanceIds,
712
+ operation.options,
713
+ )
714
+
715
+ // assert
716
+ expect(plan).toMatchInlineSnapshot(`
717
+ [
718
+ {
719
+ "instances": [
720
+ {
721
+ "id": "composite.v1:Parent",
722
+ "message": "explicitly requested",
723
+ "parentId": undefined,
724
+ },
725
+ {
726
+ "id": "composite.v1:ChildComposite",
727
+ "message": "included in operation",
728
+ "parentId": "composite.v1:Parent",
729
+ },
730
+ {
731
+ "id": "component.v1:GhostLeaf",
732
+ "message": "ghost cleanup",
733
+ "parentId": "composite.v1:ChildComposite",
734
+ },
735
+ ],
736
+ "type": "destroy",
737
+ },
738
+ ]
739
+ `)
740
+ },
741
+ )
742
+
743
+ operationPlanTest(
744
+ "5c. should run only ghost destroy phase when onlyDestroyGhosts is enabled",
745
+ async ({ testBuilder, expect }) => {
746
+ // arrange
747
+ const { context, operation } = await testBuilder()
748
+ .composite("Parent")
749
+ .unit("Child1")
750
+ .unit("GhostChild")
751
+ .children("Parent", "Child1", "GhostChild")
752
+ .states({
753
+ Parent: "upToDate",
754
+ Child1: "changed",
755
+ GhostChild: "ghost",
756
+ })
757
+ .options({ onlyDestroyGhosts: true })
758
+ .request("update", "Parent")
759
+ .build()
760
+
761
+ // act
762
+ const plan = createOperationPlan(
763
+ context,
764
+ operation.type,
765
+ operation.requestedInstanceIds,
766
+ operation.options,
767
+ )
768
+
769
+ // assert
770
+ expect(plan).toMatchInlineSnapshot(`
771
+ [
772
+ {
773
+ "instances": [
774
+ {
775
+ "id": "composite.v1:Parent",
776
+ "message": "explicitly requested",
777
+ "parentId": undefined,
778
+ },
779
+ {
780
+ "id": "component.v1:GhostChild",
781
+ "message": "ghost cleanup",
782
+ "parentId": "composite.v1:Parent",
783
+ },
784
+ ],
785
+ "type": "destroy",
786
+ },
787
+ ]
788
+ `)
789
+ },
790
+ )
791
+
792
+ operationPlanTest(
793
+ "5d. should place ghost destroy phase before update when firstDestroyGhosts is enabled",
794
+ async ({ testBuilder, expect }) => {
795
+ // arrange
796
+ const { context, operation } = await testBuilder()
797
+ .composite("Parent")
798
+ .unit("Child1")
799
+ .unit("GhostChild")
800
+ .children("Parent", "Child1", "GhostChild")
801
+ .states({
802
+ Parent: "upToDate",
803
+ Child1: "changed",
804
+ GhostChild: "ghost",
805
+ })
806
+ .options({ firstDestroyGhosts: true })
807
+ .request("update", "Parent")
808
+ .build()
809
+
810
+ // act
811
+ const plan = createOperationPlan(
812
+ context,
813
+ operation.type,
814
+ operation.requestedInstanceIds,
815
+ operation.options,
816
+ )
817
+
818
+ // assert
819
+ expect(plan).toMatchInlineSnapshot(`
820
+ [
821
+ {
822
+ "instances": [
823
+ {
824
+ "id": "composite.v1:Parent",
825
+ "message": "explicitly requested",
826
+ "parentId": undefined,
827
+ },
828
+ {
829
+ "id": "component.v1:GhostChild",
830
+ "message": "ghost cleanup",
831
+ "parentId": "composite.v1:Parent",
832
+ },
833
+ ],
834
+ "type": "destroy",
835
+ },
836
+ {
837
+ "instances": [
838
+ {
839
+ "id": "composite.v1:Parent",
840
+ "message": "explicitly requested",
841
+ "parentId": undefined,
842
+ },
843
+ {
844
+ "id": "component.v1:Child1",
845
+ "message": "changed and child of included parent",
846
+ "parentId": "composite.v1:Parent",
847
+ },
848
+ ],
849
+ "type": "update",
850
+ },
851
+ ]
852
+ `)
853
+ },
854
+ )
855
+
856
+ operationPlanTest(
857
+ "5e. should skip ghost destroy phase when ignoreGhosts is enabled",
858
+ async ({ testBuilder, expect }) => {
859
+ // arrange
860
+ const { context, operation } = await testBuilder()
861
+ .composite("Parent")
862
+ .unit("Child1")
863
+ .unit("GhostChild")
864
+ .children("Parent", "Child1", "GhostChild")
865
+ .states({
866
+ Parent: "upToDate",
867
+ Child1: "changed",
868
+ GhostChild: "ghost",
869
+ })
870
+ .options({ ignoreGhosts: true })
871
+ .request("update", "Parent")
872
+ .build()
873
+
874
+ // act
875
+ const plan = createOperationPlan(
876
+ context,
877
+ operation.type,
878
+ operation.requestedInstanceIds,
879
+ operation.options,
880
+ )
881
+
882
+ // assert
883
+ expect(plan).toMatchInlineSnapshot(`
884
+ [
885
+ {
886
+ "instances": [
887
+ {
888
+ "id": "composite.v1:Parent",
889
+ "message": "explicitly requested",
890
+ "parentId": undefined,
891
+ },
892
+ {
893
+ "id": "component.v1:Child1",
894
+ "message": "changed and child of included parent",
895
+ "parentId": "composite.v1:Parent",
896
+ },
897
+ ],
898
+ "type": "update",
899
+ },
900
+ ]
901
+ `)
902
+ },
903
+ )
904
+
905
+ operationPlanTest(
906
+ "5f. should reject mutually exclusive ghost options",
907
+ async ({ testBuilder, expect }) => {
908
+ // arrange
909
+ const { context, operation } = await testBuilder()
910
+ .composite("Parent")
911
+ .unit("GhostChild")
912
+ .children("Parent", "GhostChild")
913
+ .states({ Parent: "upToDate", GhostChild: "ghost" })
914
+ .options({ onlyDestroyGhosts: true, ignoreGhosts: true })
915
+ .request("update", "Parent")
916
+ .build()
917
+
918
+ // act & assert
919
+ expect(() =>
920
+ createOperationPlan(
921
+ context,
922
+ operation.type,
923
+ operation.requestedInstanceIds,
924
+ operation.options,
925
+ ),
926
+ ).toThrow(
927
+ "Operation options are invalid: only one of onlyDestroyGhosts, firstDestroyGhosts, ignoreGhosts can be enabled.",
928
+ )
929
+ },
930
+ )
931
+
381
932
  operationPlanTest(
382
933
  "6. should handle complex nested hierarchy correctly",
383
934
  async ({ testBuilder, expect }) => {
@@ -750,7 +1301,7 @@ describe("OperationPlan - Update Operations", () => {
750
1301
  )
751
1302
 
752
1303
  operationPlanTest(
753
- "13. should isolate boundaries in deep composite hierarchies",
1304
+ "13. should include deep ancestor chain without ancestor siblings",
754
1305
  async ({ testBuilder, expect }) => {
755
1306
  // arrange
756
1307
  const { context, operation } = await testBuilder()
@@ -797,6 +1348,16 @@ describe("OperationPlan - Update Operations", () => {
797
1348
  "message": "parent of included child "component.v1:Child"",
798
1349
  "parentId": "composite.v1:GrandParent",
799
1350
  },
1351
+ {
1352
+ "id": "composite.v1:GrandParent",
1353
+ "message": "parent of included child "composite.v1:Parent"",
1354
+ "parentId": "composite.v1:GreatGrandParent",
1355
+ },
1356
+ {
1357
+ "id": "composite.v1:GreatGrandParent",
1358
+ "message": "parent of included child "composite.v1:GrandParent"",
1359
+ "parentId": undefined,
1360
+ },
800
1361
  ],
801
1362
  "type": "update",
802
1363
  },
@@ -1180,4 +1741,94 @@ describe("OperationPlan - Update Operations", () => {
1180
1741
  `)
1181
1742
  },
1182
1743
  )
1744
+
1745
+ operationPlanTest(
1746
+ "20. should skip explicitly requested empty nested composites",
1747
+ async ({ testBuilder, expect }) => {
1748
+ // arrange
1749
+ const { context, operation } = await testBuilder()
1750
+ .composite("Root")
1751
+ .composite("Level1")
1752
+ .composite("Level2")
1753
+ .children("Root", "Level1")
1754
+ .children("Level1", "Level2")
1755
+ .states({
1756
+ Root: "upToDate",
1757
+ Level1: "upToDate",
1758
+ Level2: "upToDate",
1759
+ })
1760
+ .request("update", "Root")
1761
+ .build()
1762
+
1763
+ // act
1764
+ const plan = createOperationPlan(
1765
+ context,
1766
+ operation.type,
1767
+ operation.requestedInstanceIds,
1768
+ operation.options,
1769
+ )
1770
+
1771
+ // assert
1772
+ expect(plan).toMatchInlineSnapshot(`[]`)
1773
+ },
1774
+ )
1775
+
1776
+ operationPlanTest(
1777
+ "21. should keep non-empty branch while skipping empty nested composites",
1778
+ async ({ testBuilder, expect }) => {
1779
+ // arrange
1780
+ const { context, operation } = await testBuilder()
1781
+ .composite("Root")
1782
+ .composite("EmptyLevel1")
1783
+ .composite("EmptyLevel2")
1784
+ .composite("NonEmptyLevel1")
1785
+ .unit("Leaf")
1786
+ .children("Root", "EmptyLevel1", "NonEmptyLevel1")
1787
+ .children("EmptyLevel1", "EmptyLevel2")
1788
+ .children("NonEmptyLevel1", "Leaf")
1789
+ .states({
1790
+ Root: "upToDate",
1791
+ EmptyLevel1: "upToDate",
1792
+ EmptyLevel2: "upToDate",
1793
+ NonEmptyLevel1: "upToDate",
1794
+ Leaf: "changed",
1795
+ })
1796
+ .request("update", "Root")
1797
+ .build()
1798
+
1799
+ // act
1800
+ const plan = createOperationPlan(
1801
+ context,
1802
+ operation.type,
1803
+ operation.requestedInstanceIds,
1804
+ operation.options,
1805
+ )
1806
+
1807
+ // assert
1808
+ expect(plan).toMatchInlineSnapshot(`
1809
+ [
1810
+ {
1811
+ "instances": [
1812
+ {
1813
+ "id": "composite.v1:Root",
1814
+ "message": "explicitly requested",
1815
+ "parentId": undefined,
1816
+ },
1817
+ {
1818
+ "id": "component.v1:Leaf",
1819
+ "message": "changed and child of included parent",
1820
+ "parentId": "composite.v1:NonEmptyLevel1",
1821
+ },
1822
+ {
1823
+ "id": "composite.v1:NonEmptyLevel1",
1824
+ "message": "parent of included child "component.v1:Leaf"",
1825
+ "parentId": "composite.v1:Root",
1826
+ },
1827
+ ],
1828
+ "type": "update",
1829
+ },
1830
+ ]
1831
+ `)
1832
+ },
1833
+ )
1183
1834
  })