@highstate/contract 0.16.0 → 0.18.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.
- package/dist/index.js +451 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/compaction.spec.ts +215 -0
- package/src/compaction.ts +381 -0
- package/src/component.spec.ts +78 -2
- package/src/component.ts +29 -16
- package/src/entity.ts +286 -8
- package/src/index.ts +10 -1
- package/src/instance.ts +26 -11
- package/src/meta.ts +5 -2
- package/src/pulumi.ts +13 -1
- package/src/unit.ts +11 -6
- package/src/utils.ts +1 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { compact, decompact } from "./compaction"
|
|
3
|
+
import { HighstateSignature, objectRefSchema, objectWithIdSchema } from "./instance"
|
|
4
|
+
|
|
5
|
+
describe("compaction", () => {
|
|
6
|
+
it("roundtrips primitives", () => {
|
|
7
|
+
expect(decompact(compact(null))).toBe(null)
|
|
8
|
+
expect(decompact(compact(undefined))).toBe(undefined)
|
|
9
|
+
expect(decompact(compact(true))).toBe(true)
|
|
10
|
+
expect(decompact(compact(123))).toBe(123)
|
|
11
|
+
expect(decompact(compact("abc"))).toBe("abc")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("compacts repeated object identities", () => {
|
|
15
|
+
const shared = { x: 1 }
|
|
16
|
+
const value = { a: shared, b: shared }
|
|
17
|
+
|
|
18
|
+
const compacted = compact(value)
|
|
19
|
+
|
|
20
|
+
const compactedRecord = compacted as Record<string, unknown>
|
|
21
|
+
const aResult = objectWithIdSchema.safeParse(compactedRecord.a)
|
|
22
|
+
const bResult = objectRefSchema.safeParse(compactedRecord.b)
|
|
23
|
+
|
|
24
|
+
expect(aResult.success).toBe(true)
|
|
25
|
+
expect(bResult.success).toBe(true)
|
|
26
|
+
|
|
27
|
+
const roundtripped = decompact(compacted)
|
|
28
|
+
expect(roundtripped).toEqual(value)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("preserves object identity on decompact", () => {
|
|
32
|
+
const shared = { x: 1 }
|
|
33
|
+
const value = { a: shared, b: shared }
|
|
34
|
+
|
|
35
|
+
const roundtripped = decompact(compact(value)) as { a: unknown; b: unknown }
|
|
36
|
+
expect(roundtripped).toEqual(value)
|
|
37
|
+
expect(roundtripped.a).toBe(roundtripped.b)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("roundtrips self-referential objects", () => {
|
|
41
|
+
const value: { self?: unknown } = {}
|
|
42
|
+
value.self = value
|
|
43
|
+
|
|
44
|
+
const compacted = compact(value)
|
|
45
|
+
const roundtripped = decompact(compacted) as { self: unknown }
|
|
46
|
+
|
|
47
|
+
expect(roundtripped.self).toBe(roundtripped)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("roundtrips mutual references", () => {
|
|
51
|
+
const a: { name: string; other?: unknown } = { name: "a" }
|
|
52
|
+
const b: { name: string; other?: unknown } = { name: "b" }
|
|
53
|
+
|
|
54
|
+
a.other = b
|
|
55
|
+
b.other = a
|
|
56
|
+
|
|
57
|
+
const compacted = compact({ a, b })
|
|
58
|
+
const roundtripped = decompact(compacted) as { a: { other: unknown }; b: { other: unknown } }
|
|
59
|
+
|
|
60
|
+
expect(roundtripped.a.other).toBe(roundtripped.b)
|
|
61
|
+
expect(roundtripped.b.other).toBe(roundtripped.a)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("does not transform children of defined objects", () => {
|
|
65
|
+
const shared = { x: 1 }
|
|
66
|
+
const value = { a: shared, b: shared }
|
|
67
|
+
|
|
68
|
+
const compacted = compact(value) as Record<string, unknown>
|
|
69
|
+
const aResult = objectWithIdSchema.safeParse(compacted.a)
|
|
70
|
+
expect(aResult.success).toBe(true)
|
|
71
|
+
|
|
72
|
+
const sharedInside = (aResult.data as { value: unknown }).value
|
|
73
|
+
expect(objectWithIdSchema.safeParse(sharedInside).success).toBe(false)
|
|
74
|
+
// Refs to already-defined outer objects are allowed inside the Id value.
|
|
75
|
+
// Nested Id definitions are not.
|
|
76
|
+
expect(sharedInside).toEqual(shared)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("assigns references so outer occurrences win in bfs", () => {
|
|
80
|
+
const shared = { x: 1 }
|
|
81
|
+
const outer = { shared }
|
|
82
|
+
const inner = { shared }
|
|
83
|
+
|
|
84
|
+
// bfs from root sees outer first, so it must be the definition site.
|
|
85
|
+
const compacted = compact({ outer, inner }) as Record<string, unknown>
|
|
86
|
+
|
|
87
|
+
const outerResult = objectWithIdSchema.safeParse(
|
|
88
|
+
(compacted.outer as Record<string, unknown>).shared,
|
|
89
|
+
)
|
|
90
|
+
const innerResult = objectRefSchema.safeParse(
|
|
91
|
+
(compacted.inner as Record<string, unknown>).shared,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if (!outerResult.success || !innerResult.success) {
|
|
95
|
+
throw new Error("Test invariant violation")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
expect(innerResult.data.id).toBe(outerResult.data.id)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("throws on unresolved refs", () => {
|
|
102
|
+
const bad = {
|
|
103
|
+
[HighstateSignature.Ref]: true,
|
|
104
|
+
id: 123,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
expect(() => decompact(bad)).toThrow(/Unresolved compacted ref id/i)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("real world case: nested entity embedding with deduped base", () => {
|
|
111
|
+
const metadata = {
|
|
112
|
+
namespace: "default",
|
|
113
|
+
name: "gw",
|
|
114
|
+
uid: "uid-1",
|
|
115
|
+
labels: { app: "demo" },
|
|
116
|
+
annotations: { a: "b" },
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const resourceBase = {
|
|
120
|
+
clusterId: "c1",
|
|
121
|
+
clusterName: "cluster",
|
|
122
|
+
type: "gateway",
|
|
123
|
+
metadata,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const scopedResource = {
|
|
127
|
+
metadata,
|
|
128
|
+
clusterId: resourceBase.clusterId,
|
|
129
|
+
clusterName: resourceBase.clusterName,
|
|
130
|
+
type: resourceBase.type,
|
|
131
|
+
"k8s.resource.v1": resourceBase,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const gateway = {
|
|
135
|
+
type: "gateway",
|
|
136
|
+
metadata,
|
|
137
|
+
clusterId: resourceBase.clusterId,
|
|
138
|
+
clusterName: resourceBase.clusterName,
|
|
139
|
+
"k8s.scoped-resource.v1": scopedResource,
|
|
140
|
+
"k8s.resource.v1": resourceBase,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const compacted = compact(gateway) as Record<string, unknown>
|
|
144
|
+
|
|
145
|
+
const metadataResult = objectWithIdSchema.safeParse(compacted.metadata)
|
|
146
|
+
expect(metadataResult.success).toBe(true)
|
|
147
|
+
|
|
148
|
+
const scopedResult = compacted["k8s.scoped-resource.v1"] as Record<string, unknown>
|
|
149
|
+
const scopedMetadataRef = objectRefSchema.safeParse(scopedResult.metadata)
|
|
150
|
+
expect(scopedMetadataRef.success).toBe(true)
|
|
151
|
+
if (!metadataResult.success || !scopedMetadataRef.success) {
|
|
152
|
+
throw new Error("Test invariant violation")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
expect(scopedMetadataRef.data.id).toBe(metadataResult.data.id)
|
|
156
|
+
|
|
157
|
+
// Once an object is defined (wrapped with Id), its subtree must remain untransformed.
|
|
158
|
+
const definedMetadataValue = (metadataResult.data as { value: unknown }).value
|
|
159
|
+
expect(objectWithIdSchema.safeParse(definedMetadataValue).success).toBe(false)
|
|
160
|
+
// Refs to already-defined outer objects are allowed inside the Id value.
|
|
161
|
+
// Nested Id definitions are not.
|
|
162
|
+
|
|
163
|
+
expect(compacted).toMatchInlineSnapshot(`
|
|
164
|
+
{
|
|
165
|
+
"clusterId": "c1",
|
|
166
|
+
"clusterName": "cluster",
|
|
167
|
+
"k8s.resource.v1": {
|
|
168
|
+
"348d020e-0d9e-4ae7-9415-b91af99f5339": true,
|
|
169
|
+
"id": 2,
|
|
170
|
+
"value": {
|
|
171
|
+
"clusterId": "c1",
|
|
172
|
+
"clusterName": "cluster",
|
|
173
|
+
"metadata": {
|
|
174
|
+
"6d7f9da0-9cb6-496d-b72e-cf85ee4d9cf8": true,
|
|
175
|
+
"id": 1,
|
|
176
|
+
},
|
|
177
|
+
"type": "gateway",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
"k8s.scoped-resource.v1": {
|
|
181
|
+
"clusterId": "c1",
|
|
182
|
+
"clusterName": "cluster",
|
|
183
|
+
"k8s.resource.v1": {
|
|
184
|
+
"6d7f9da0-9cb6-496d-b72e-cf85ee4d9cf8": true,
|
|
185
|
+
"id": 2,
|
|
186
|
+
},
|
|
187
|
+
"metadata": {
|
|
188
|
+
"6d7f9da0-9cb6-496d-b72e-cf85ee4d9cf8": true,
|
|
189
|
+
"id": 1,
|
|
190
|
+
},
|
|
191
|
+
"type": "gateway",
|
|
192
|
+
},
|
|
193
|
+
"metadata": {
|
|
194
|
+
"348d020e-0d9e-4ae7-9415-b91af99f5339": true,
|
|
195
|
+
"id": 1,
|
|
196
|
+
"value": {
|
|
197
|
+
"annotations": {
|
|
198
|
+
"a": "b",
|
|
199
|
+
},
|
|
200
|
+
"labels": {
|
|
201
|
+
"app": "demo",
|
|
202
|
+
},
|
|
203
|
+
"name": "gw",
|
|
204
|
+
"namespace": "default",
|
|
205
|
+
"uid": "uid-1",
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
"type": "gateway",
|
|
209
|
+
}
|
|
210
|
+
`)
|
|
211
|
+
|
|
212
|
+
const roundtripped = decompact(compacted)
|
|
213
|
+
expect(roundtripped).toEqual(gateway)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { mapValues } from "remeda"
|
|
2
|
+
import { HighstateSignature, objectRefSchema, objectWithIdSchema } from "./instance"
|
|
3
|
+
|
|
4
|
+
export function compact<T>(value: T): unknown {
|
|
5
|
+
const counts = new WeakMap<object, number>()
|
|
6
|
+
const cyclic = new WeakSet<object>()
|
|
7
|
+
const expanded = new WeakSet<object>()
|
|
8
|
+
const inStack = new WeakSet<object>()
|
|
9
|
+
const stack: object[] = []
|
|
10
|
+
|
|
11
|
+
function countIdentities(current: unknown): void {
|
|
12
|
+
if (current === null || typeof current !== "object") {
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
counts.set(current, (counts.get(current) ?? 0) + 1)
|
|
17
|
+
|
|
18
|
+
if (inStack.has(current)) {
|
|
19
|
+
cyclic.add(current)
|
|
20
|
+
|
|
21
|
+
for (const entry of stack) {
|
|
22
|
+
cyclic.add(entry)
|
|
23
|
+
}
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (expanded.has(current)) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
expanded.add(current)
|
|
32
|
+
inStack.add(current)
|
|
33
|
+
stack.push(current)
|
|
34
|
+
|
|
35
|
+
if (Array.isArray(current)) {
|
|
36
|
+
try {
|
|
37
|
+
for (const item of current) {
|
|
38
|
+
countIdentities(item)
|
|
39
|
+
}
|
|
40
|
+
return
|
|
41
|
+
} finally {
|
|
42
|
+
stack.pop()
|
|
43
|
+
inStack.delete(current)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
for (const entryValue of Object.values(current)) {
|
|
49
|
+
countIdentities(entryValue)
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
stack.pop()
|
|
53
|
+
inStack.delete(current)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
countIdentities(value)
|
|
58
|
+
|
|
59
|
+
type DefinitionSite = { parent: object; key: string | number }
|
|
60
|
+
|
|
61
|
+
const ids = new WeakMap<object, number>()
|
|
62
|
+
const definitionSites = new WeakMap<object, DefinitionSite>()
|
|
63
|
+
let nextId = 1
|
|
64
|
+
|
|
65
|
+
function ensureId(current: object): number {
|
|
66
|
+
const existing = ids.get(current)
|
|
67
|
+
if (existing !== undefined) {
|
|
68
|
+
return existing
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const allocated = nextId
|
|
72
|
+
nextId += 1
|
|
73
|
+
ids.set(current, allocated)
|
|
74
|
+
return allocated
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assignDefinitionSitesBfs(root: unknown): void {
|
|
78
|
+
if (root === null || typeof root !== "object") {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const queue: Array<{ parent: object; key: string | number; value: unknown }> = []
|
|
83
|
+
const expandedQueue = new WeakSet<object>()
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(root)) {
|
|
86
|
+
for (let index = 0; index < root.length; index += 1) {
|
|
87
|
+
queue.push({ parent: root, key: index, value: root[index] })
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
for (const [key, entryValue] of Object.entries(root)) {
|
|
91
|
+
queue.push({ parent: root, key, value: entryValue })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
while (queue.length > 0) {
|
|
96
|
+
const current = queue.shift()
|
|
97
|
+
if (current === undefined) {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (current.value === null || typeof current.value !== "object") {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const occurrences = counts.get(current.value) ?? 0
|
|
106
|
+
if (occurrences > 1 && definitionSites.get(current.value) === undefined) {
|
|
107
|
+
definitionSites.set(current.value, { parent: current.parent, key: current.key })
|
|
108
|
+
ensureId(current.value)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (expandedQueue.has(current.value)) {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
expandedQueue.add(current.value)
|
|
116
|
+
|
|
117
|
+
if (Array.isArray(current.value)) {
|
|
118
|
+
for (let index = 0; index < current.value.length; index += 1) {
|
|
119
|
+
queue.push({ parent: current.value, key: index, value: current.value[index] })
|
|
120
|
+
}
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [key, entryValue] of Object.entries(current.value)) {
|
|
125
|
+
queue.push({ parent: current.value, key, value: entryValue })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
assignDefinitionSitesBfs(value)
|
|
131
|
+
|
|
132
|
+
const emitted = new WeakSet<object>()
|
|
133
|
+
|
|
134
|
+
function buildTreeChildren(current: object, rootOfIdValue?: object): unknown {
|
|
135
|
+
if (Array.isArray(current)) {
|
|
136
|
+
return current.map(item => buildTree(item, rootOfIdValue))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return mapValues(current, entryValue => buildTree(entryValue, rootOfIdValue))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildTree(current: unknown, rootOfIdValue?: object): unknown {
|
|
143
|
+
if (current === null || typeof current !== "object") {
|
|
144
|
+
return current
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Inside an Id value, never emit nested Id wrappers.
|
|
148
|
+
// Match existing real-world snapshot: do not introduce refs for nested objects
|
|
149
|
+
// unless the nested object has already been defined earlier.
|
|
150
|
+
if (rootOfIdValue !== undefined && emitted.has(current)) {
|
|
151
|
+
const id = ids.get(current)
|
|
152
|
+
if (id === undefined) {
|
|
153
|
+
throw new Error("Compaction invariant violation: missing id for repeated object")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
[HighstateSignature.Ref]: true,
|
|
158
|
+
id,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Cycles inside an Id value require defining the cyclic objects inline,
|
|
163
|
+
// otherwise we would infinitely recurse.
|
|
164
|
+
if (rootOfIdValue !== undefined && cyclic.has(current) && !emitted.has(current)) {
|
|
165
|
+
const id = ensureId(current)
|
|
166
|
+
emitted.add(current)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
[HighstateSignature.Id]: true,
|
|
170
|
+
id,
|
|
171
|
+
value: buildTreeChildren(current, current),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return buildTreeChildren(current, rootOfIdValue)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildAt(parent: object, key: string | number, current: unknown): unknown {
|
|
179
|
+
if (current === null || typeof current !== "object") {
|
|
180
|
+
return current
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const occurrences = counts.get(current) ?? 0
|
|
184
|
+
if (occurrences <= 1) {
|
|
185
|
+
if (Array.isArray(current)) {
|
|
186
|
+
return current.map((item, index) => buildAt(current, index, item))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return mapValues(current, (entryValue, entryKey) => buildAt(current, entryKey, entryValue))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const site = definitionSites.get(current)
|
|
193
|
+
const shouldDefineHere = site?.parent === parent && site.key === key
|
|
194
|
+
|
|
195
|
+
const id = ids.get(current)
|
|
196
|
+
if (id === undefined) {
|
|
197
|
+
throw new Error("Compaction invariant violation: missing id for repeated object")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!shouldDefineHere || emitted.has(current)) {
|
|
201
|
+
return {
|
|
202
|
+
[HighstateSignature.Ref]: true,
|
|
203
|
+
id,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
emitted.add(current)
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
[HighstateSignature.Id]: true,
|
|
211
|
+
id,
|
|
212
|
+
value: buildTreeChildren(current, current),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Prefer keeping the top-level value unwrapped for stability.
|
|
217
|
+
if (value === null || typeof value !== "object") {
|
|
218
|
+
return value
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const topLevelOccurrences = counts.get(value) ?? 0
|
|
222
|
+
if (topLevelOccurrences > 1) {
|
|
223
|
+
const id = ensureId(value)
|
|
224
|
+
emitted.add(value)
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
[HighstateSignature.Id]: true,
|
|
228
|
+
id,
|
|
229
|
+
value: buildTreeChildren(value, value),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (Array.isArray(value)) {
|
|
234
|
+
return value.map((item, index) => buildAt(value, index, item))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return mapValues(value as Record<string, unknown>, (entryValue, entryKey) =>
|
|
238
|
+
buildAt(value as Record<string, unknown>, entryKey, entryValue),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function decompact<T>(value: unknown): T {
|
|
243
|
+
const rawValuesById = new Map<number, unknown>()
|
|
244
|
+
const placeholdersById = new Map<number, unknown>()
|
|
245
|
+
|
|
246
|
+
function collect(current: unknown, visited: WeakSet<object>): void {
|
|
247
|
+
const result = objectWithIdSchema.safeParse(current)
|
|
248
|
+
if (result.success) {
|
|
249
|
+
const { id } = result.data
|
|
250
|
+
if (rawValuesById.has(id)) {
|
|
251
|
+
throw new Error(`Duplicate compacted id ${id}`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
rawValuesById.set(id, result.data.value)
|
|
255
|
+
collect(result.data.value, visited)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (visited.has(current)) {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
visited.add(current)
|
|
268
|
+
|
|
269
|
+
if (Array.isArray(current)) {
|
|
270
|
+
for (const item of current) {
|
|
271
|
+
collect(item, visited)
|
|
272
|
+
}
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const entryValue of Object.values(current)) {
|
|
277
|
+
collect(entryValue, visited)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function ensurePlaceholder(id: number): unknown {
|
|
282
|
+
const existing = placeholdersById.get(id)
|
|
283
|
+
if (existing !== undefined) {
|
|
284
|
+
return existing
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const raw = rawValuesById.get(id)
|
|
288
|
+
if (raw === undefined) {
|
|
289
|
+
throw new Error(`Unresolved compacted ref id ${id}`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let placeholder: unknown
|
|
293
|
+
if (raw !== null && typeof raw === "object") {
|
|
294
|
+
placeholder = Array.isArray(raw) ? [] : {}
|
|
295
|
+
} else {
|
|
296
|
+
placeholder = raw
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
placeholdersById.set(id, placeholder)
|
|
300
|
+
return placeholder
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function resolve(current: unknown): unknown {
|
|
304
|
+
const refResult = objectRefSchema.safeParse(current)
|
|
305
|
+
if (refResult.success) {
|
|
306
|
+
return ensurePlaceholder(refResult.data.id)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const withIdResult = objectWithIdSchema.safeParse(current)
|
|
310
|
+
if (withIdResult.success) {
|
|
311
|
+
return ensurePlaceholder(withIdResult.data.id)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
315
|
+
return current
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (Array.isArray(current)) {
|
|
319
|
+
return current.map(item => resolve(item))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return mapValues(current, entryValue => resolve(entryValue))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function fillPlaceholder(id: number, visitedIds: Set<number>): void {
|
|
326
|
+
if (visitedIds.has(id)) {
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
visitedIds.add(id)
|
|
331
|
+
|
|
332
|
+
const raw = rawValuesById.get(id)
|
|
333
|
+
if (raw === undefined) {
|
|
334
|
+
throw new Error(`Unresolved compacted ref id ${id}`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const placeholder = ensurePlaceholder(id)
|
|
338
|
+
if (placeholder === null || typeof placeholder !== "object") {
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (raw === null || typeof raw !== "object") {
|
|
343
|
+
throw new Error(`Compaction invariant violation: id ${id} points to non-object value`)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const resolved = resolve(raw)
|
|
347
|
+
|
|
348
|
+
if (Array.isArray(placeholder)) {
|
|
349
|
+
if (!Array.isArray(resolved)) {
|
|
350
|
+
throw new Error(`Compaction invariant violation: id ${id} array placeholder mismatch`)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
placeholder.length = 0
|
|
354
|
+
for (const item of resolved) {
|
|
355
|
+
placeholder.push(item)
|
|
356
|
+
}
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (Array.isArray(resolved) || resolved === null || typeof resolved !== "object") {
|
|
361
|
+
throw new Error(`Compaction invariant violation: id ${id} object placeholder mismatch`)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const [key, entryValue] of Object.entries(resolved)) {
|
|
365
|
+
;(placeholder as Record<string, unknown>)[key] = entryValue
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
collect(value, new WeakSet<object>())
|
|
370
|
+
|
|
371
|
+
for (const id of rawValuesById.keys()) {
|
|
372
|
+
ensurePlaceholder(id)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const visitedIds = new Set<number>()
|
|
376
|
+
for (const id of rawValuesById.keys()) {
|
|
377
|
+
fillPlaceholder(id, visitedIds)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return resolve(value) as T
|
|
381
|
+
}
|
package/src/component.spec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, it } from "vitest"
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest"
|
|
2
2
|
import { z } from "zod"
|
|
3
3
|
import { defineComponent } from "./component"
|
|
4
4
|
import { defineEntity } from "./entity"
|
|
@@ -25,7 +25,7 @@ describe("defineComponent", () => {
|
|
|
25
25
|
|
|
26
26
|
it("should return a component with args", () => {
|
|
27
27
|
const virtualMachine = defineComponent({
|
|
28
|
-
type: "proxmox.
|
|
28
|
+
type: "proxmox.virtual-machine.v1",
|
|
29
29
|
args: {
|
|
30
30
|
cores: z.number(),
|
|
31
31
|
},
|
|
@@ -41,9 +41,21 @@ describe("defineComponent", () => {
|
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
it("should return a component with inputs", () => {
|
|
44
|
+
const resource = defineEntity({
|
|
45
|
+
type: "common.resource.v1",
|
|
46
|
+
|
|
47
|
+
schema: z.object({
|
|
48
|
+
id: z.string(),
|
|
49
|
+
}),
|
|
50
|
+
})
|
|
51
|
+
|
|
44
52
|
const server = defineEntity({
|
|
45
53
|
type: "common.server.v1",
|
|
46
54
|
|
|
55
|
+
includes: {
|
|
56
|
+
resource,
|
|
57
|
+
},
|
|
58
|
+
|
|
47
59
|
schema: z.object({
|
|
48
60
|
endpoint: z.string(),
|
|
49
61
|
}),
|
|
@@ -55,6 +67,24 @@ describe("defineComponent", () => {
|
|
|
55
67
|
},
|
|
56
68
|
})
|
|
57
69
|
|
|
70
|
+
expect(server.model.directInclusions).toEqual([
|
|
71
|
+
{
|
|
72
|
+
field: "resource",
|
|
73
|
+
type: "common.resource.v1",
|
|
74
|
+
required: true,
|
|
75
|
+
multiple: false,
|
|
76
|
+
},
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
const embeddedResult = server.schema.safeParse({
|
|
80
|
+
endpoint: "127.0.0.1",
|
|
81
|
+
resource: {
|
|
82
|
+
id: "test",
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(embeddedResult.success).toBe(true)
|
|
87
|
+
|
|
58
88
|
const cluster = defineComponent({
|
|
59
89
|
type: "talos.cluster.v1",
|
|
60
90
|
inputs: {
|
|
@@ -113,6 +143,52 @@ describe("defineComponent", () => {
|
|
|
113
143
|
})
|
|
114
144
|
})
|
|
115
145
|
|
|
146
|
+
it("should support entities with intersection schemas", () => {
|
|
147
|
+
const resource = defineEntity({
|
|
148
|
+
type: "common.resource.v1",
|
|
149
|
+
|
|
150
|
+
schema: z.object({
|
|
151
|
+
id: z.string(),
|
|
152
|
+
}),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const tagged = z.object({
|
|
156
|
+
tag: z.string(),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const server = defineEntity({
|
|
160
|
+
type: "common.server.v1",
|
|
161
|
+
|
|
162
|
+
includes: {
|
|
163
|
+
resource: { entity: resource, required: false, multiple: true },
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
schema: z.intersection(
|
|
167
|
+
tagged,
|
|
168
|
+
z.object({
|
|
169
|
+
endpoint: z.string(),
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(server.model.directInclusions).toEqual([
|
|
175
|
+
{
|
|
176
|
+
field: "resource",
|
|
177
|
+
type: "common.resource.v1",
|
|
178
|
+
required: false,
|
|
179
|
+
multiple: true,
|
|
180
|
+
},
|
|
181
|
+
])
|
|
182
|
+
|
|
183
|
+
expect(
|
|
184
|
+
server.schema.safeParse({
|
|
185
|
+
tag: "prod",
|
|
186
|
+
endpoint: "127.0.0.1",
|
|
187
|
+
"common.resource.v1": [{ id: "test" }],
|
|
188
|
+
}).success,
|
|
189
|
+
).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
116
192
|
it("should return a component with args, inputs and outputs", () => {
|
|
117
193
|
const server = defineEntity({
|
|
118
194
|
type: "common.server.v1",
|