@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
@@ -62,15 +62,31 @@ export const operationOptionsSchema = z
62
62
  forceUpdateDependencies: z.boolean().default(false),
63
63
 
64
64
  /**
65
- * Ignore dependencies and operate only on explicitly requested instances.
65
+ * Ignore only changed dependencies and keep failed/undeployed prerequisites.
66
66
  *
67
67
  * **Operation Behavior Impact:**
68
- * - skips dependency inclusion even when dependencies are failed or undeployed;
68
+ * - skips dependency inclusion when dependency is only changed;
69
+ * - still includes failed or undeployed dependencies to preserve safety for missing/broken prerequisites;
70
+ * - useful for targeted updates where stale-but-healthy dependencies are intentionally not traversed.
71
+ *
72
+ * **Usage with other options:**
73
+ * - mutually exclusive with `forceUpdateDependencies`;
74
+ * - mutually exclusive with `ignoreDependencies`;
75
+ * - independent of child/composite inclusion options.
76
+ */
77
+ ignoreChangedDependencies: z.boolean().default(false),
78
+
79
+ /**
80
+ * Ignore all dependencies and operate only on explicitly requested instances.
81
+ *
82
+ * **Operation Behavior Impact:**
83
+ * - skips dependency inclusion entirely, including failed and undeployed prerequisites;
69
84
  * - caller must explicitly include every prerequisite instance to avoid failures;
70
- * - complements on-demand or targeted updates where dependency safety is managed externally.
85
+ * - intended for fully manual execution flows.
71
86
  *
72
87
  * **Usage with other options:**
73
88
  * - mutually exclusive with `forceUpdateDependencies`;
89
+ * - mutually exclusive with `ignoreChangedDependencies`;
74
90
  * - independent of child/composite inclusion options.
75
91
  */
76
92
  ignoreDependencies: z.boolean().default(false),
@@ -89,6 +105,48 @@ export const operationOptionsSchema = z
89
105
  */
90
106
  forceUpdateChildren: z.boolean().default(false),
91
107
 
108
+ /**
109
+ * Run only ghost cleanup destroy phase and skip update phase entirely.
110
+ *
111
+ * **Operation Behavior Impact:**
112
+ * - applies to update operations only;
113
+ * - skips update phase even if normal update candidates exist;
114
+ * - keeps only ghost cleanup destroy phase for affected substantive composites.
115
+ *
116
+ * **Usage with other options:**
117
+ * - mutually exclusive with `firstDestroyGhosts`;
118
+ * - mutually exclusive with `ignoreGhosts`.
119
+ */
120
+ onlyDestroyGhosts: z.boolean().default(false),
121
+
122
+ /**
123
+ * Run ghost cleanup destroy phase before update phase.
124
+ *
125
+ * **Operation Behavior Impact:**
126
+ * - applies to update operations only;
127
+ * - keeps both phases but changes order to destroy ghosts first;
128
+ * - useful when stale ghost resources should be removed prior to update execution.
129
+ *
130
+ * **Usage with other options:**
131
+ * - mutually exclusive with `onlyDestroyGhosts`;
132
+ * - mutually exclusive with `ignoreGhosts`.
133
+ */
134
+ firstDestroyGhosts: z.boolean().default(false),
135
+
136
+ /**
137
+ * Ignore ghost cleanup and skip destroy phase for ghosts.
138
+ *
139
+ * **Operation Behavior Impact:**
140
+ * - applies to update operations only;
141
+ * - disables ghost cleanup destroy phase generation;
142
+ * - update phase remains unchanged.
143
+ *
144
+ * **Usage with other options:**
145
+ * - mutually exclusive with `onlyDestroyGhosts`;
146
+ * - mutually exclusive with `firstDestroyGhosts`.
147
+ */
148
+ ignoreGhosts: z.boolean().default(false),
149
+
92
150
  /**
93
151
  * Include dependent instances when destroying instances.
94
152
  *
@@ -11,6 +11,7 @@ import { z } from "zod"
11
11
  export const stableInstanceInputSchema = z.object({
12
12
  stateId: z.string(),
13
13
  output: z.string(),
14
+ path: z.string().optional(),
14
15
  })
15
16
 
16
17
  /**
@@ -77,6 +78,15 @@ export const instanceStateEventSchema = z.discriminatedUnion("type", [
77
78
  stateId: z.string(),
78
79
  patch: z.custom<Partial<InstanceState>>(),
79
80
  }),
81
+ z.object({
82
+ type: z.literal("patched-batch"),
83
+ patches: z.array(
84
+ z.object({
85
+ stateId: z.string(),
86
+ patch: z.custom<Partial<InstanceState>>(),
87
+ }),
88
+ ),
89
+ }),
80
90
  z.object({
81
91
  type: z.literal("deleted"),
82
92
  stateId: z.string(),
@@ -52,6 +52,13 @@ export const workerVersionStatusSchema = z.enum([
52
52
 
53
53
  export type WorkerVersionStatus = z.infer<typeof workerVersionStatusSchema>
54
54
 
55
+ export const workerVersionStatusEventSchema = z.object({
56
+ workerVersionId: z.cuid2(),
57
+ status: workerVersionStatusSchema,
58
+ })
59
+
60
+ export type WorkerVersionStatusEvent = z.infer<typeof workerVersionStatusEventSchema>
61
+
55
62
  export const workerVersionOutputSchema = z.object({
56
63
  id: z.cuid2(),
57
64
  digest: z.string(),
@@ -0,0 +1,494 @@
1
+ import type {
2
+ ComponentModel,
3
+ EntityModel,
4
+ HubInput,
5
+ InstanceInput,
6
+ InstanceModel,
7
+ } from "@highstate/contract"
8
+ import { describe, expect, test } from "vitest"
9
+ import { type InstanceTypeContext, resolveEffectiveOutputType } from "./effective-output-type"
10
+
11
+ function createEntity(
12
+ type: EntityModel["type"],
13
+ inclusions?: EntityModel["inclusions"],
14
+ ): EntityModel {
15
+ return {
16
+ type,
17
+ inclusions,
18
+ schema: {},
19
+ meta: {
20
+ title: type,
21
+ },
22
+ definitionHash: 1,
23
+ }
24
+ }
25
+
26
+ function createInputSpec(
27
+ type: EntityModel["type"],
28
+ options?: { fromInput?: string; multiple?: boolean },
29
+ ) {
30
+ return {
31
+ type,
32
+ fromInput: options?.fromInput,
33
+ required: true,
34
+ multiple: options?.multiple ?? false,
35
+ meta: {
36
+ title: type,
37
+ },
38
+ }
39
+ }
40
+
41
+ function createComponent(options: {
42
+ type: ComponentModel["type"]
43
+ inputs?: Record<string, ReturnType<typeof createInputSpec>>
44
+ outputs?: Record<string, ReturnType<typeof createInputSpec>>
45
+ }): ComponentModel {
46
+ return {
47
+ type: options.type,
48
+ kind: "unit",
49
+ args: {},
50
+ inputs: options.inputs ?? {},
51
+ outputs: options.outputs ?? {},
52
+ meta: {
53
+ title: options.type,
54
+ defaultNamePrefix: "test",
55
+ },
56
+ definitionHash: 1,
57
+ }
58
+ }
59
+
60
+ function createInstance(options: {
61
+ id: InstanceModel["id"]
62
+ type: InstanceModel["type"]
63
+ inputs?: Record<string, InstanceInput[]>
64
+ hubInputs?: Record<string, HubInput[]>
65
+ injectionInputs?: HubInput[]
66
+ }): InstanceModel {
67
+ return {
68
+ id: options.id,
69
+ name: "instance",
70
+ type: options.type,
71
+ kind: "unit",
72
+ inputs: options.inputs,
73
+ hubInputs: options.hubInputs,
74
+ injectionInputs: options.injectionInputs,
75
+ }
76
+ }
77
+
78
+ function createResolver(contexts: Record<string, InstanceTypeContext>) {
79
+ return (instanceId: string) => contexts[instanceId]
80
+ }
81
+
82
+ describe("resolveEffectiveOutputType", () => {
83
+ test.concurrent("returns declared output type when no fromInput is used", () => {
84
+ const entities = {
85
+ "example.base.v1": createEntity("example.base.v1"),
86
+ }
87
+
88
+ const instance = createInstance({
89
+ id: "example.writer.v1:writer-1",
90
+ type: "example.writer.v1",
91
+ })
92
+
93
+ const component = createComponent({
94
+ type: "example.writer.v1",
95
+ outputs: {
96
+ value: createInputSpec("example.base.v1"),
97
+ },
98
+ })
99
+
100
+ const resolved = resolveEffectiveOutputType({
101
+ input: { instanceId: instance.id, output: "value" },
102
+ fallbackType: "example.fallback.v1",
103
+ getInstanceContext: createResolver({
104
+ [instance.id]: {
105
+ instance,
106
+ component,
107
+ entities,
108
+ },
109
+ }),
110
+ })
111
+
112
+ expect(resolved).toBe("example.base.v1")
113
+ })
114
+
115
+ test.concurrent("forwards output type from a single direct input", () => {
116
+ const entities = {
117
+ "example.base.v1": createEntity("example.base.v1"),
118
+ "example.child.v1": createEntity("example.child.v1"),
119
+ }
120
+
121
+ const source = createInstance({
122
+ id: "example.source.v1:source-1",
123
+ type: "example.source.v1",
124
+ })
125
+
126
+ const sourceComponent = createComponent({
127
+ type: "example.source.v1",
128
+ outputs: {
129
+ value: createInputSpec("example.child.v1"),
130
+ },
131
+ })
132
+
133
+ const forwarder = createInstance({
134
+ id: "example.forwarder.v1:forwarder-1",
135
+ type: "example.forwarder.v1",
136
+ inputs: {
137
+ source: [{ instanceId: source.id, output: "value" }],
138
+ },
139
+ })
140
+
141
+ const forwarderComponent = createComponent({
142
+ type: "example.forwarder.v1",
143
+ inputs: {
144
+ source: createInputSpec("example.base.v1"),
145
+ },
146
+ outputs: {
147
+ value: createInputSpec("example.base.v1", { fromInput: "source" }),
148
+ },
149
+ })
150
+
151
+ const resolved = resolveEffectiveOutputType({
152
+ input: { instanceId: forwarder.id, output: "value" },
153
+ fallbackType: "example.base.v1",
154
+ getInstanceContext: createResolver({
155
+ [source.id]: {
156
+ instance: source,
157
+ component: sourceComponent,
158
+ entities,
159
+ },
160
+ [forwarder.id]: {
161
+ instance: forwarder,
162
+ component: forwarderComponent,
163
+ entities,
164
+ },
165
+ }),
166
+ })
167
+
168
+ expect(resolved).toBe("example.child.v1")
169
+ })
170
+
171
+ test.concurrent("falls back when forwarded source input has hub inputs", () => {
172
+ const entities = {
173
+ "example.base.v1": createEntity("example.base.v1"),
174
+ "example.child.v1": createEntity("example.child.v1"),
175
+ }
176
+
177
+ const source = createInstance({
178
+ id: "example.source.v1:source-1",
179
+ type: "example.source.v1",
180
+ })
181
+
182
+ const sourceComponent = createComponent({
183
+ type: "example.source.v1",
184
+ outputs: {
185
+ value: createInputSpec("example.child.v1"),
186
+ },
187
+ })
188
+
189
+ const forwarder = createInstance({
190
+ id: "example.forwarder.v1:forwarder-1",
191
+ type: "example.forwarder.v1",
192
+ inputs: {
193
+ source: [{ instanceId: source.id, output: "value" }],
194
+ },
195
+ hubInputs: {
196
+ source: [{ hubId: "hub1" }],
197
+ },
198
+ })
199
+
200
+ const forwarderComponent = createComponent({
201
+ type: "example.forwarder.v1",
202
+ inputs: {
203
+ source: createInputSpec("example.base.v1"),
204
+ },
205
+ outputs: {
206
+ value: createInputSpec("example.base.v1", { fromInput: "source" }),
207
+ },
208
+ })
209
+
210
+ const resolved = resolveEffectiveOutputType({
211
+ input: { instanceId: forwarder.id, output: "value" },
212
+ fallbackType: "example.fallback.v1",
213
+ getInstanceContext: createResolver({
214
+ [source.id]: {
215
+ instance: source,
216
+ component: sourceComponent,
217
+ entities,
218
+ },
219
+ [forwarder.id]: {
220
+ instance: forwarder,
221
+ component: forwarderComponent,
222
+ entities,
223
+ },
224
+ }),
225
+ })
226
+
227
+ expect(resolved).toBe("example.base.v1")
228
+ })
229
+
230
+ test.concurrent("falls back when forwarded source input has multiple direct inputs", () => {
231
+ const entities = {
232
+ "example.base.v1": createEntity("example.base.v1"),
233
+ "example.child.v1": createEntity("example.child.v1"),
234
+ }
235
+
236
+ const source = createInstance({
237
+ id: "example.source.v1:source-1",
238
+ type: "example.source.v1",
239
+ })
240
+
241
+ const sourceComponent = createComponent({
242
+ type: "example.source.v1",
243
+ outputs: {
244
+ value: createInputSpec("example.child.v1"),
245
+ },
246
+ })
247
+
248
+ const forwarder = createInstance({
249
+ id: "example.forwarder.v1:forwarder-1",
250
+ type: "example.forwarder.v1",
251
+ inputs: {
252
+ source: [
253
+ { instanceId: source.id, output: "value" },
254
+ { instanceId: source.id, output: "value" },
255
+ ],
256
+ },
257
+ })
258
+
259
+ const forwarderComponent = createComponent({
260
+ type: "example.forwarder.v1",
261
+ inputs: {
262
+ source: createInputSpec("example.base.v1"),
263
+ },
264
+ outputs: {
265
+ value: createInputSpec("example.base.v1", { fromInput: "source" }),
266
+ },
267
+ })
268
+
269
+ const resolved = resolveEffectiveOutputType({
270
+ input: { instanceId: forwarder.id, output: "value" },
271
+ fallbackType: "example.fallback.v1",
272
+ getInstanceContext: createResolver({
273
+ [source.id]: {
274
+ instance: source,
275
+ component: sourceComponent,
276
+ entities,
277
+ },
278
+ [forwarder.id]: {
279
+ instance: forwarder,
280
+ component: forwarderComponent,
281
+ entities,
282
+ },
283
+ }),
284
+ })
285
+
286
+ expect(resolved).toBe("example.base.v1")
287
+ })
288
+
289
+ test.concurrent("applies path traversal to the resolved type", () => {
290
+ const entities = {
291
+ "example.base.v1": createEntity("example.base.v1", [
292
+ {
293
+ type: "example.child.v1",
294
+ field: "child",
295
+ required: true,
296
+ multiple: false,
297
+ },
298
+ ]),
299
+ "example.child.v1": createEntity("example.child.v1", [
300
+ {
301
+ type: "example.leaf.v1",
302
+ field: "leaf",
303
+ required: true,
304
+ multiple: false,
305
+ },
306
+ ]),
307
+ "example.leaf.v1": createEntity("example.leaf.v1"),
308
+ }
309
+
310
+ const instance = createInstance({
311
+ id: "example.writer.v1:writer-1",
312
+ type: "example.writer.v1",
313
+ })
314
+
315
+ const component = createComponent({
316
+ type: "example.writer.v1",
317
+ outputs: {
318
+ value: createInputSpec("example.base.v1"),
319
+ },
320
+ })
321
+
322
+ const resolved = resolveEffectiveOutputType({
323
+ input: {
324
+ instanceId: instance.id,
325
+ output: "value",
326
+ path: "child.leaf",
327
+ },
328
+ fallbackType: "example.fallback.v1",
329
+ getInstanceContext: createResolver({
330
+ [instance.id]: {
331
+ instance,
332
+ component,
333
+ entities,
334
+ },
335
+ }),
336
+ })
337
+
338
+ expect(resolved).toBe("example.leaf.v1")
339
+ })
340
+
341
+ test.concurrent("returns root type when path is invalid", () => {
342
+ const entities = {
343
+ "example.base.v1": createEntity("example.base.v1", [
344
+ {
345
+ type: "example.child.v1",
346
+ field: "child",
347
+ required: true,
348
+ multiple: false,
349
+ },
350
+ ]),
351
+ "example.child.v1": createEntity("example.child.v1"),
352
+ }
353
+
354
+ const instance = createInstance({
355
+ id: "example.writer.v1:writer-1",
356
+ type: "example.writer.v1",
357
+ })
358
+
359
+ const component = createComponent({
360
+ type: "example.writer.v1",
361
+ outputs: {
362
+ value: createInputSpec("example.base.v1"),
363
+ },
364
+ })
365
+
366
+ const resolved = resolveEffectiveOutputType({
367
+ input: {
368
+ instanceId: instance.id,
369
+ output: "value",
370
+ path: "missing.segment",
371
+ },
372
+ fallbackType: "example.fallback.v1",
373
+ getInstanceContext: createResolver({
374
+ [instance.id]: {
375
+ instance,
376
+ component,
377
+ entities,
378
+ },
379
+ }),
380
+ })
381
+
382
+ expect(resolved).toBe("example.base.v1")
383
+ })
384
+
385
+ test.concurrent(
386
+ "uses direct inclusion when no path is provided and fallback matches inclusion type",
387
+ () => {
388
+ const entities = {
389
+ "example.identity.v1": createEntity("example.identity.v1", [
390
+ {
391
+ type: "example.peer.v1",
392
+ field: "peer",
393
+ required: true,
394
+ multiple: false,
395
+ },
396
+ ]),
397
+ "example.peer.v1": createEntity("example.peer.v1"),
398
+ }
399
+
400
+ const identity = createInstance({
401
+ id: "example.identity.v1:identity-1",
402
+ type: "example.identity.v1",
403
+ })
404
+
405
+ const component = createComponent({
406
+ type: "example.identity.v1",
407
+ outputs: {
408
+ identity: createInputSpec("example.identity.v1"),
409
+ },
410
+ })
411
+
412
+ const resolved = resolveEffectiveOutputType({
413
+ input: {
414
+ instanceId: identity.id,
415
+ output: "identity",
416
+ },
417
+ fallbackType: "example.peer.v1",
418
+ getInstanceContext: createResolver({
419
+ [identity.id]: {
420
+ instance: identity,
421
+ component,
422
+ entities,
423
+ },
424
+ }),
425
+ })
426
+
427
+ expect(resolved).toBe("example.peer.v1")
428
+ },
429
+ )
430
+
431
+ test.concurrent("handles forwarding cycles without recursion errors", () => {
432
+ const entities = {
433
+ "example.base.v1": createEntity("example.base.v1"),
434
+ }
435
+
436
+ const instanceA = createInstance({
437
+ id: "example.a.v1:a-1",
438
+ type: "example.a.v1",
439
+ inputs: {
440
+ source: [{ instanceId: "example.b.v1:b-1", output: "value" }],
441
+ },
442
+ })
443
+
444
+ const instanceB = createInstance({
445
+ id: "example.b.v1:b-1",
446
+ type: "example.b.v1",
447
+ inputs: {
448
+ source: [{ instanceId: "example.a.v1:a-1", output: "value" }],
449
+ },
450
+ })
451
+
452
+ const componentA = createComponent({
453
+ type: "example.a.v1",
454
+ inputs: {
455
+ source: createInputSpec("example.base.v1"),
456
+ },
457
+ outputs: {
458
+ value: createInputSpec("example.base.v1", { fromInput: "source" }),
459
+ },
460
+ })
461
+
462
+ const componentB = createComponent({
463
+ type: "example.b.v1",
464
+ inputs: {
465
+ source: createInputSpec("example.base.v1"),
466
+ },
467
+ outputs: {
468
+ value: createInputSpec("example.base.v1", { fromInput: "source" }),
469
+ },
470
+ })
471
+
472
+ const resolved = resolveEffectiveOutputType({
473
+ input: {
474
+ instanceId: instanceA.id,
475
+ output: "value",
476
+ },
477
+ fallbackType: "example.fallback.v1",
478
+ getInstanceContext: createResolver({
479
+ [instanceA.id]: {
480
+ instance: instanceA,
481
+ component: componentA,
482
+ entities,
483
+ },
484
+ [instanceB.id]: {
485
+ instance: instanceB,
486
+ component: componentB,
487
+ entities,
488
+ },
489
+ }),
490
+ })
491
+
492
+ expect(resolved).toBe("example.base.v1")
493
+ })
494
+ })