@frontera-sdk/cli 0.1.0 → 1.43.5
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/package.json +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- package/src/commands/blueprint/reserved.ts +0 -40
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { toFiles } from './projection'
|
|
2
|
+
import {
|
|
3
|
+
ARTIFACT_KINDS,
|
|
4
|
+
type ArtifactKind,
|
|
5
|
+
type AuthoredFile,
|
|
6
|
+
type DefinitionBundle,
|
|
7
|
+
} from './model'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The plan: what the files say that the live draft does not, expressed as typed
|
|
11
|
+
* operations the executor dispatches one by one.
|
|
12
|
+
*
|
|
13
|
+
* Comparison is by `apiName`, the only key both sides share — the files carry no ids
|
|
14
|
+
* (see `model.ts`). It runs over PARSED structures rather than serialised text, so
|
|
15
|
+
* reformatting a YAML file, reordering its keys or requoting a string never shows up
|
|
16
|
+
* as a change.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type OperationKind = 'create' | 'update' | 'delete'
|
|
20
|
+
|
|
21
|
+
export interface ModelOperation {
|
|
22
|
+
operation: OperationKind
|
|
23
|
+
kind: ArtifactKind
|
|
24
|
+
apiName: string
|
|
25
|
+
/** The authored document, for a create or an update. Absent on a delete. */
|
|
26
|
+
document?: Record<string, unknown>
|
|
27
|
+
/** One line of human-readable detail, e.g. `+1 property (tier)`. */
|
|
28
|
+
detail?: string
|
|
29
|
+
/**
|
|
30
|
+
* The artifact as the draft currently has it, on an update.
|
|
31
|
+
*
|
|
32
|
+
* Carried on the operation because the executor has to know what ALREADY agrees:
|
|
33
|
+
* the service refuses a command that would change nothing, so sending an unchanged
|
|
34
|
+
* field is not merely wasteful, it fails the run.
|
|
35
|
+
*/
|
|
36
|
+
liveDocument?: Record<string, unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BindingDrift {
|
|
40
|
+
objectApiName: string
|
|
41
|
+
datasetRevisionId: string
|
|
42
|
+
pinnedDigest: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Plan {
|
|
46
|
+
/** The draft revision this plan was computed against. */
|
|
47
|
+
revision: number
|
|
48
|
+
operations: ModelOperation[]
|
|
49
|
+
/** Artifacts in the draft but absent from the tree. Applied only with `--prune`. */
|
|
50
|
+
prunes: ModelOperation[]
|
|
51
|
+
drift: BindingDrift[]
|
|
52
|
+
unchanged: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isEmpty(plan: Plan): boolean {
|
|
56
|
+
return plan.operations.length === 0 && plan.prunes.length === 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Does the live artifact already say everything the file declares?
|
|
61
|
+
*
|
|
62
|
+
* A SUBSET comparison, not an equality one, and this is the difference between a
|
|
63
|
+
* tool that converges and one that reports a change forever. The service normalises
|
|
64
|
+
* on creation: it derives each property's `baseType` from the pinned column, decides
|
|
65
|
+
* `unique` and `uniqueBasis` from what the source vouches for (ADR 0009 decision 5),
|
|
66
|
+
* and defaults `status` and `visibility`. None of that is in a hand-authored file,
|
|
67
|
+
* and none of it should be — a file that had to restate every derived value would
|
|
68
|
+
* have to be regenerated after every apply.
|
|
69
|
+
*
|
|
70
|
+
* So a field the file does not mention is a field the file has no opinion about.
|
|
71
|
+
* What that costs is stated rather than hidden: DELETING a line from a file is not a
|
|
72
|
+
* change this can see. Removing a property is done by removing it from
|
|
73
|
+
* `properties`, which IS seen, because the property set is compared by name below.
|
|
74
|
+
*
|
|
75
|
+
* Exported because the EXECUTOR must answer the same question with the same predicate:
|
|
76
|
+
* the plan decides an artifact changed with this, so the executor deciding WHICH field
|
|
77
|
+
* changed has to agree. A second comparison there — a `JSON.stringify` equality, say —
|
|
78
|
+
* disagrees about key order and about fields the file omits, and refuses a run naming a
|
|
79
|
+
* field the plan never called changed.
|
|
80
|
+
*/
|
|
81
|
+
export function satisfies(local: unknown, live: unknown, key?: string): boolean {
|
|
82
|
+
// `backing` is owned by `bind`, which travels the probe and the contract-change
|
|
83
|
+
// gate. Letting it into the plan would mean `apply` silently rebinding a type
|
|
84
|
+
// because somebody edited a column name in a file.
|
|
85
|
+
if (key === 'backing') return true
|
|
86
|
+
|
|
87
|
+
if (Array.isArray(local)) {
|
|
88
|
+
if (!Array.isArray(live)) return false
|
|
89
|
+
const named = local.every((entry) => isNamed(entry))
|
|
90
|
+
if (!named) return canonical(local) === canonical(live)
|
|
91
|
+
// Compared by apiName, so reordering `properties:` is not a change and adding
|
|
92
|
+
// or removing one is.
|
|
93
|
+
const liveByName = new Map(
|
|
94
|
+
live.filter(isNamed).map((entry) => [entry.apiName, entry as Record<string, unknown>]),
|
|
95
|
+
)
|
|
96
|
+
const localNames = local.filter(isNamed).map((entry) => entry.apiName)
|
|
97
|
+
if (localNames.length !== liveByName.size) return false
|
|
98
|
+
return localNames.every((name) => {
|
|
99
|
+
const counterpart = liveByName.get(name)
|
|
100
|
+
return counterpart !== undefined
|
|
101
|
+
&& satisfies(local.find((entry) => isNamed(entry) && entry.apiName === name), counterpart)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (local && typeof local === 'object') {
|
|
106
|
+
if (!live || typeof live !== 'object') return false
|
|
107
|
+
return Object.entries(local as Record<string, unknown>).every(([field, value]) =>
|
|
108
|
+
satisfies(value, (live as Record<string, unknown>)[field], field))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return canonical(local) === canonical(live)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isNamed(value: unknown): value is { apiName: string } {
|
|
115
|
+
return Boolean(value) && typeof value === 'object'
|
|
116
|
+
&& typeof (value as { apiName?: unknown }).apiName === 'string'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function canonical(value: unknown): string {
|
|
120
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
121
|
+
if (value && typeof value === 'object') {
|
|
122
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
123
|
+
.filter(([, entry]) => entry !== undefined)
|
|
124
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
125
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
|
|
126
|
+
return `{${entries.join(',')}}`
|
|
127
|
+
}
|
|
128
|
+
return JSON.stringify(value) ?? 'null'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function propertyNames(document: Record<string, unknown>): string[] {
|
|
132
|
+
const properties = document.properties
|
|
133
|
+
if (!Array.isArray(properties)) return []
|
|
134
|
+
return properties
|
|
135
|
+
.map((property) => (property as { apiName?: unknown }).apiName)
|
|
136
|
+
.filter((name): name is string => typeof name === 'string')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `+1 property (tier)` / `-1 property (legacyCode)`, or a bare field count. */
|
|
140
|
+
function describeUpdate(local: Record<string, unknown>, live: Record<string, unknown>): string {
|
|
141
|
+
const before = new Set(propertyNames(live))
|
|
142
|
+
const after = propertyNames(local)
|
|
143
|
+
const added = after.filter((name) => !before.has(name))
|
|
144
|
+
const removed = [...before].filter((name) => !after.includes(name))
|
|
145
|
+
const parts: string[] = []
|
|
146
|
+
if (added.length) parts.push(`+${added.length} propert${added.length === 1 ? 'y' : 'ies'} (${added.join(', ')})`)
|
|
147
|
+
if (removed.length) parts.push(`-${removed.length} propert${removed.length === 1 ? 'y' : 'ies'} (${removed.join(', ')})`)
|
|
148
|
+
return parts.length ? parts.join(', ') : 'fields changed'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function diff(
|
|
152
|
+
localFiles: AuthoredFile[],
|
|
153
|
+
live: DefinitionBundle,
|
|
154
|
+
revision: number,
|
|
155
|
+
): Plan {
|
|
156
|
+
const liveModel = toFiles(live)
|
|
157
|
+
const liveByKey = new Map(liveModel.files.map((file) => [`${file.kind}:${file.apiName}`, file]))
|
|
158
|
+
const localByKey = new Map(localFiles.map((file) => [`${file.kind}:${file.apiName}`, file]))
|
|
159
|
+
|
|
160
|
+
const operations: ModelOperation[] = []
|
|
161
|
+
const prunes: ModelOperation[] = []
|
|
162
|
+
let unchanged = 0
|
|
163
|
+
|
|
164
|
+
// Fixed kind order so the plan reads the same way every run, and so creations of
|
|
165
|
+
// a kind that others reference come first within the listing.
|
|
166
|
+
for (const kind of ARTIFACT_KINDS) {
|
|
167
|
+
for (const file of localFiles.filter((candidate) => candidate.kind === kind)) {
|
|
168
|
+
const key = `${kind}:${file.apiName}`
|
|
169
|
+
const existing = liveByKey.get(key)
|
|
170
|
+
if (!existing) {
|
|
171
|
+
operations.push({
|
|
172
|
+
operation: 'create',
|
|
173
|
+
kind,
|
|
174
|
+
apiName: file.apiName,
|
|
175
|
+
document: file.document,
|
|
176
|
+
...(kind === 'object-type'
|
|
177
|
+
? { detail: `${propertyNames(file.document).length} properties` }
|
|
178
|
+
: {}),
|
|
179
|
+
})
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (satisfies(file.document, existing.document)) {
|
|
183
|
+
unchanged += 1
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
operations.push({
|
|
187
|
+
operation: 'update',
|
|
188
|
+
kind,
|
|
189
|
+
apiName: file.apiName,
|
|
190
|
+
document: file.document,
|
|
191
|
+
liveDocument: existing.document,
|
|
192
|
+
detail: describeUpdate(file.document, existing.document),
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
for (const file of liveModel.files.filter((candidate) => candidate.kind === kind)) {
|
|
196
|
+
if (localByKey.has(`${kind}:${file.apiName}`)) continue
|
|
197
|
+
prunes.push({ operation: 'delete', kind, apiName: file.apiName })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { revision, operations, prunes, drift: driftOf(live), unchanged }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Bound object types whose pinned schema digest can be checked against the dataset's
|
|
206
|
+
* current revision. The comparison itself needs the dataset read, so this only
|
|
207
|
+
* collects the pins; `plan` fills in the verdict.
|
|
208
|
+
*/
|
|
209
|
+
export function driftOf(live: DefinitionBundle): BindingDrift[] {
|
|
210
|
+
const pins: BindingDrift[] = []
|
|
211
|
+
for (const object of live.objects) {
|
|
212
|
+
const mapping = object.governance.sourceMappings[0] as
|
|
213
|
+
| { datasetRevisionId?: string; datasetSchemaDigest?: string }
|
|
214
|
+
| undefined
|
|
215
|
+
if (!mapping?.datasetRevisionId || !mapping.datasetSchemaDigest) continue
|
|
216
|
+
pins.push({
|
|
217
|
+
objectApiName: object.apiName,
|
|
218
|
+
datasetRevisionId: mapping.datasetRevisionId,
|
|
219
|
+
pinnedDigest: mapping.datasetSchemaDigest,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
return pins
|
|
223
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two shapes this package moves between: the DEFINITION BUNDLE the service
|
|
3
|
+
* speaks, and the AUTHORED MODEL a person keeps in files.
|
|
4
|
+
*
|
|
5
|
+
* They differ in exactly one way, and every rule in `projection.ts` follows from it:
|
|
6
|
+
* the bundle is UUID-keyed, and the files carry no UUID at all. The service mints
|
|
7
|
+
* every identifier — object ids, property ids, and a source system for an
|
|
8
|
+
* organization's first object type — so a UUID written into a shared tree would be
|
|
9
|
+
* correct in the deployment that minted it and wrong in every other. `apiName` is
|
|
10
|
+
* the only key that means the same thing everywhere.
|
|
11
|
+
*
|
|
12
|
+
* The bundle types here are deliberately loose where the CLI does not reason about
|
|
13
|
+
* the contents. `unknown` on a field means "carried through untouched", which is
|
|
14
|
+
* what a projection must do with anything it does not itself interpret — the
|
|
15
|
+
* alternative is a second copy of the service's schema that silently drops a field
|
|
16
|
+
* the day the service adds one.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A property as the bundle carries it. */
|
|
20
|
+
export interface BundleProperty {
|
|
21
|
+
id: string
|
|
22
|
+
apiName: string
|
|
23
|
+
[field: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An object type as the bundle carries it. */
|
|
27
|
+
export interface BundleObject {
|
|
28
|
+
id: string
|
|
29
|
+
apiName: string
|
|
30
|
+
properties: BundleProperty[]
|
|
31
|
+
primaryKeyPropertyId: string
|
|
32
|
+
titlePropertyId: string
|
|
33
|
+
governance: {
|
|
34
|
+
sourceMappings: unknown[]
|
|
35
|
+
[field: string]: unknown
|
|
36
|
+
}
|
|
37
|
+
[field: string]: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A link type (`relationships[]` in the bundle) as the bundle carries it. */
|
|
41
|
+
export interface BundleRelationship {
|
|
42
|
+
id: string
|
|
43
|
+
apiName: string
|
|
44
|
+
fromObjectId: string
|
|
45
|
+
toObjectId: string
|
|
46
|
+
fromPropertyId: string
|
|
47
|
+
toPropertyId: string
|
|
48
|
+
[field: string]: unknown
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BundleNamed {
|
|
52
|
+
id: string
|
|
53
|
+
apiName: string
|
|
54
|
+
[field: string]: unknown
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DefinitionBundle {
|
|
58
|
+
schemaVersion: number
|
|
59
|
+
objects: BundleObject[]
|
|
60
|
+
sourceSystems: BundleNamed[]
|
|
61
|
+
relationships: BundleRelationship[]
|
|
62
|
+
metrics: BundleNamed[]
|
|
63
|
+
/**
|
|
64
|
+
* Optional in both bundle versions: an organization that has never authored one
|
|
65
|
+
* carries no key at all, and the schemas are strict.
|
|
66
|
+
*/
|
|
67
|
+
sharedProperties?: BundleNamed[]
|
|
68
|
+
/** v2 only. Absent on a v1 bundle rather than empty — the schemas are strict. */
|
|
69
|
+
actions?: BundleNamed[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The authorable kinds, in the order a reader meets them — which is also the order
|
|
74
|
+
* `apply` writes them in, so a kind that others reference comes first. A property
|
|
75
|
+
* names its shared field, so shared fields are created before object types.
|
|
76
|
+
*
|
|
77
|
+
* Two things are deliberately absent, for the same reason: a file the CLI can write
|
|
78
|
+
* but never apply is worse than no file, because it breaks the round trip the tree
|
|
79
|
+
* promises and fails only at the end of a deployment.
|
|
80
|
+
*
|
|
81
|
+
* **Source systems** have no route of their own — `createDraftObjectType` synthesises
|
|
82
|
+
* one for an organization's first object type and nothing else ever creates one.
|
|
83
|
+
*
|
|
84
|
+
* **Actions** carry several uuid references the projection cannot turn into names:
|
|
85
|
+
* `subject.objectTypeId`, `effect.authoritySourceSystemId` and `compensationActionId`
|
|
86
|
+
* at least. Emitting them as raw uuids makes the tree unportable — the identifiers are
|
|
87
|
+
* per-deployment — and the create route takes the contract whole, so there is nowhere
|
|
88
|
+
* to resolve them back. `blueprint create|update|delete action` still authors them
|
|
89
|
+
* imperatively; only the FILE path is withheld, and withheld visibly rather than
|
|
90
|
+
* shipped broken.
|
|
91
|
+
*/
|
|
92
|
+
export const ARTIFACT_KINDS = [
|
|
93
|
+
'shared-field',
|
|
94
|
+
'object-type',
|
|
95
|
+
'link-type',
|
|
96
|
+
'metric',
|
|
97
|
+
] as const
|
|
98
|
+
|
|
99
|
+
export type ArtifactKind = (typeof ARTIFACT_KINDS)[number]
|
|
100
|
+
|
|
101
|
+
/** `object-type` → `object-types/`. Plural directory, singular command word. */
|
|
102
|
+
export const KIND_DIRECTORY: Record<ArtifactKind, string> = {
|
|
103
|
+
'shared-field': 'shared-fields',
|
|
104
|
+
'object-type': 'object-types',
|
|
105
|
+
'link-type': 'link-types',
|
|
106
|
+
metric: 'metrics',
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const DIRECTORY_KIND: Record<string, ArtifactKind> = Object.fromEntries(
|
|
110
|
+
Object.entries(KIND_DIRECTORY).map(([kind, directory]) => [directory, kind as ArtifactKind]),
|
|
111
|
+
) as Record<string, ArtifactKind>
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What an `apiName` may be, per kind — the same rules the service enforces
|
|
115
|
+
* (`catalog-types.ts:258-259`): object types are PascalCase, everything else is
|
|
116
|
+
* camelCase.
|
|
117
|
+
*
|
|
118
|
+
* Checked in the CLI as well as the service, and not only to fail earlier: an apiName
|
|
119
|
+
* becomes a FILE NAME here, so `../../escaped` wrote outside the tree root before this
|
|
120
|
+
* existed. A name that cannot name a file is refused before anything is written.
|
|
121
|
+
*/
|
|
122
|
+
export const API_NAME_RULE: Record<ArtifactKind, RegExp> = {
|
|
123
|
+
'shared-field': /^[a-z][A-Za-z0-9]{0,99}$/,
|
|
124
|
+
'object-type': /^[A-Z][A-Za-z0-9]{0,99}$/,
|
|
125
|
+
'link-type': /^[a-z][A-Za-z0-9]{0,99}$/,
|
|
126
|
+
metric: /^[a-z][A-Za-z0-9]{0,99}$/,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function describeApiNameRule(kind: ArtifactKind): string {
|
|
130
|
+
return kind === 'object-type'
|
|
131
|
+
? 'PascalCase: a capital letter, then letters and digits'
|
|
132
|
+
: 'camelCase: a lowercase letter, then letters and digits'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One authored file: its kind, its apiName, and the document itself. */
|
|
136
|
+
export interface AuthoredFile {
|
|
137
|
+
kind: ArtifactKind
|
|
138
|
+
apiName: string
|
|
139
|
+
/** Path relative to the tree root, e.g. `object-types/Customer.yaml`. */
|
|
140
|
+
path: string
|
|
141
|
+
document: Record<string, unknown>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface AuthoredModel {
|
|
145
|
+
schemaVersion: number
|
|
146
|
+
files: AuthoredFile[]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Everything the compiler needs from the live draft to resolve apiName → UUID. */
|
|
150
|
+
export interface LiveIndex {
|
|
151
|
+
objectIdByApiName: Map<string, string>
|
|
152
|
+
/** `<objectApiName>.<propertyApiName>` → property id. */
|
|
153
|
+
propertyIdByPath: Map<string, string>
|
|
154
|
+
sourceSystemIdByApiName: Map<string, string>
|
|
155
|
+
relationshipIdByApiName: Map<string, string>
|
|
156
|
+
metricIdByApiName: Map<string, string>
|
|
157
|
+
actionIdByApiName: Map<string, string>
|
|
158
|
+
/**
|
|
159
|
+
* The one reference a FILE writes by name that the wire takes as a uuid.
|
|
160
|
+
*
|
|
161
|
+
* A property entry says `sharedField: accountCode`; the object-create body and
|
|
162
|
+
* the field commands say `sharedPropertyId`. The authored tree speaks FIELD and
|
|
163
|
+
* the wire speaks property — this map is where the two meet, and it is the only
|
|
164
|
+
* reference a file writes that is not addressed by name on both sides.
|
|
165
|
+
*/
|
|
166
|
+
sharedPropertyIdByApiName: Map<string, string>
|
|
167
|
+
/** The live object, by apiName — the merge reads `governance.sourceMappings` off it. */
|
|
168
|
+
objectByApiName: Map<string, BundleObject>
|
|
169
|
+
relationshipByApiName: Map<string, BundleRelationship>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function emptyIndex(): LiveIndex {
|
|
173
|
+
return {
|
|
174
|
+
objectIdByApiName: new Map(),
|
|
175
|
+
propertyIdByPath: new Map(),
|
|
176
|
+
sourceSystemIdByApiName: new Map(),
|
|
177
|
+
relationshipIdByApiName: new Map(),
|
|
178
|
+
metricIdByApiName: new Map(),
|
|
179
|
+
actionIdByApiName: new Map(),
|
|
180
|
+
sharedPropertyIdByApiName: new Map(),
|
|
181
|
+
objectByApiName: new Map(),
|
|
182
|
+
relationshipByApiName: new Map(),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function indexBundle(bundle: DefinitionBundle): LiveIndex {
|
|
187
|
+
const index = emptyIndex()
|
|
188
|
+
for (const object of bundle.objects) {
|
|
189
|
+
index.objectIdByApiName.set(object.apiName, object.id)
|
|
190
|
+
index.objectByApiName.set(object.apiName, object)
|
|
191
|
+
for (const property of object.properties) {
|
|
192
|
+
index.propertyIdByPath.set(`${object.apiName}.${property.apiName}`, property.id)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const system of bundle.sourceSystems) index.sourceSystemIdByApiName.set(system.apiName, system.id)
|
|
196
|
+
for (const link of bundle.relationships) {
|
|
197
|
+
index.relationshipIdByApiName.set(link.apiName, link.id)
|
|
198
|
+
index.relationshipByApiName.set(link.apiName, link)
|
|
199
|
+
}
|
|
200
|
+
for (const metric of bundle.metrics) index.metricIdByApiName.set(metric.apiName, metric.id)
|
|
201
|
+
for (const action of bundle.actions ?? []) index.actionIdByApiName.set(action.apiName, action.id)
|
|
202
|
+
for (const shared of bundle.sharedProperties ?? []) {
|
|
203
|
+
index.sharedPropertyIdByApiName.set(shared.apiName, shared.id)
|
|
204
|
+
}
|
|
205
|
+
return index
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* An unresolved reference, collected rather than thrown.
|
|
210
|
+
*
|
|
211
|
+
* All of them are reported at once: fixing references one error per run is the
|
|
212
|
+
* failure mode that makes a declarative tool feel worse than the imperative commands
|
|
213
|
+
* it replaces.
|
|
214
|
+
*/
|
|
215
|
+
export interface RefError {
|
|
216
|
+
/** The file the reference is written in. */
|
|
217
|
+
path: string
|
|
218
|
+
/** Where in that file, dotted — e.g. `from.property`. */
|
|
219
|
+
field: string
|
|
220
|
+
/** The name that resolved to nothing. */
|
|
221
|
+
name: string
|
|
222
|
+
message: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type Result<T> =
|
|
226
|
+
| { ok: true; value: T }
|
|
227
|
+
| { ok: false; errors: RefError[] }
|