@frontera-sdk/cli 1.43.6 → 1.43.8
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 +3 -3
- package/src/api/automation-api.ts +86 -6
- package/src/api/blueprint-authoring-api.ts +38 -0
- package/src/automation-template.ts +13 -3
- package/src/blueprint/compile.ts +168 -2
- package/src/blueprint/diff.ts +132 -2
- package/src/blueprint/model.ts +48 -13
- package/src/blueprint/projection.ts +217 -21
- package/src/blueprint/render.ts +24 -1
- package/src/blueprint/scaffold.ts +149 -13
- package/src/commands/automation/build-entry.ts +73 -0
- package/src/commands/automation/dev.ts +453 -0
- package/src/commands/automation/index-commands.ts +5 -55
- package/src/commands/automation/run.ts +127 -10
- package/src/commands/blueprint/declarative.ts +480 -37
- package/src/flag-help.ts +9 -0
- package/src/vendor/sdk-sources.json +10 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.43.
|
|
3
|
+
"version": "1.43.8",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"build:release": "bun run scripts/build-release.ts"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@frontera-sdk/automation": "
|
|
41
|
-
"@frontera-sdk/core": "
|
|
40
|
+
"@frontera-sdk/automation": "1.43.6",
|
|
41
|
+
"@frontera-sdk/core": "1.43.6",
|
|
42
42
|
"gray-matter": "^4.0.3",
|
|
43
43
|
"yaml": "^2.9.0"
|
|
44
44
|
},
|
|
@@ -134,10 +134,75 @@ export class AutomationApi {
|
|
|
134
134
|
* kill switch, not a lock, so an operator can test a fix without re-arming the
|
|
135
135
|
* cron.
|
|
136
136
|
*/
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
/** `version` omitted runs the live one — the behaviour every caller had before
|
|
138
|
+
* the parameter existed, and the one the service still defaults to. */
|
|
139
|
+
run(
|
|
140
|
+
slug: string,
|
|
141
|
+
version?: number,
|
|
142
|
+
dev?: boolean,
|
|
143
|
+
): Promise<{ slug: string; version: number | null; queued: boolean; dev?: boolean }> {
|
|
144
|
+
const body = dev ? { dev: true } : version === undefined ? undefined : { version }
|
|
145
|
+
return this.client.request(`/v1/automations/${encodeURIComponent(slug)}/run`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
body,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Open or take over this automation's dev session. Creates the automation
|
|
152
|
+
* row when absent — an author develops before the first deploy. */
|
|
153
|
+
devSessionOpen(
|
|
154
|
+
slug: string,
|
|
155
|
+
entryFile: string,
|
|
156
|
+
holderId: string,
|
|
157
|
+
): Promise<{
|
|
158
|
+
sessionId: string
|
|
159
|
+
slug: string
|
|
160
|
+
liveVersionId: string | null
|
|
161
|
+
created: boolean
|
|
162
|
+
leaseExpiresAt: string
|
|
163
|
+
leaseMs: number
|
|
164
|
+
}> {
|
|
165
|
+
return this.client.request(
|
|
166
|
+
`/v1/automations/${encodeURIComponent(slug)}/dev/session`,
|
|
167
|
+
{ method: 'POST', body: { entryFile, holderId } },
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Heartbeat and claim in one call: a lost lease invalidates a claim, so
|
|
172
|
+
* asking separately would let a taken-over worker execute one more run. */
|
|
173
|
+
/**
|
|
174
|
+
* Heartbeat, claim, and hand over the manifest — one call.
|
|
175
|
+
*
|
|
176
|
+
* The manifest rides here because this is the only message that comes from
|
|
177
|
+
* the machine holding the file. It is what the claimed run's grants are set
|
|
178
|
+
* from, so a grant added while iterating takes effect on the very next run
|
|
179
|
+
* with no redeploy and no session restart.
|
|
180
|
+
*/
|
|
181
|
+
devPoll(
|
|
182
|
+
slug: string,
|
|
183
|
+
sessionId: string,
|
|
184
|
+
holderId: string,
|
|
185
|
+
manifest: Record<string, unknown>,
|
|
186
|
+
): Promise<{
|
|
187
|
+
held: boolean
|
|
188
|
+
run: { runId: string; runToken: string; grants: string[]; workspaceId: string } | null
|
|
189
|
+
}> {
|
|
190
|
+
const q = `sessionId=${encodeURIComponent(sessionId)}&holderId=${encodeURIComponent(holderId)}`
|
|
191
|
+
return this.client.request(
|
|
192
|
+
`/v1/automations/${encodeURIComponent(slug)}/dev/poll?${q}`,
|
|
193
|
+
{ method: 'POST', body: { manifest } },
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
devSessionClose(
|
|
198
|
+
slug: string,
|
|
199
|
+
sessionId: string,
|
|
200
|
+
holderId: string,
|
|
201
|
+
): Promise<{ closed: boolean }> {
|
|
202
|
+
const q = `sessionId=${encodeURIComponent(sessionId)}&holderId=${encodeURIComponent(holderId)}`
|
|
203
|
+
return this.client.request(
|
|
204
|
+
`/v1/automations/${encodeURIComponent(slug)}/dev/session?${q}`,
|
|
205
|
+
{ method: 'DELETE' },
|
|
141
206
|
)
|
|
142
207
|
}
|
|
143
208
|
|
|
@@ -145,8 +210,23 @@ export class AutomationApi {
|
|
|
145
210
|
return this.client.request<RegistryStatus>('/v1/automations/registry-status')
|
|
146
211
|
}
|
|
147
212
|
|
|
148
|
-
|
|
149
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Run history. `mode` is NOT optional in effect, only in the signature.
|
|
215
|
+
*
|
|
216
|
+
* The route defaults to `live` when it is absent, so a caller that omits it
|
|
217
|
+
* on a dev path reads the wrong history in silence — `automation run --dev`
|
|
218
|
+
* waited out its whole timeout for a run it had already finished, and would
|
|
219
|
+
* report a cron fire that happened during the wait as if it were the dev run.
|
|
220
|
+
*/
|
|
221
|
+
runs(
|
|
222
|
+
slug: string,
|
|
223
|
+
limit?: number,
|
|
224
|
+
mode: 'live' | 'dev' = 'live',
|
|
225
|
+
): Promise<AutomationRunSummary[]> {
|
|
226
|
+
const params = new URLSearchParams()
|
|
227
|
+
if (limit !== undefined) params.set('limit', String(limit))
|
|
228
|
+
if (mode === 'dev') params.set('mode', 'dev')
|
|
229
|
+
const query = params.size > 0 ? `?${params}` : ''
|
|
150
230
|
return this.client.request<AutomationRunSummary[]>(
|
|
151
231
|
`/v1/automations/${encodeURIComponent(slug)}/runs${query}`,
|
|
152
232
|
)
|
|
@@ -266,6 +266,44 @@ export class BlueprintAuthoringApi {
|
|
|
266
266
|
})
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
// ── semantic types ─────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
listSemanticTypes(): Promise<
|
|
272
|
+
Array<{ id?: string; apiName?: string; displayName?: string; dataType?: string }>
|
|
273
|
+
> {
|
|
274
|
+
return this.call('/v1/blueprint/semantic-types?view=draft')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Create, and read the minted id back — the same contract the shared-field create
|
|
279
|
+
* has, and needed by BOTH carrier kinds: a tree that introduces a semantic type
|
|
280
|
+
* alongside the shared field or the property carrying it has no id at plan time.
|
|
281
|
+
*/
|
|
282
|
+
createSemanticType(
|
|
283
|
+
body: Record<string, unknown>,
|
|
284
|
+
expectedRevision: number,
|
|
285
|
+
): Promise<{ semanticType?: { id?: string; apiName?: string }; draftRevision?: number }> {
|
|
286
|
+
return this.call('/v1/blueprint/semantic-types', {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
body: { ...body, expectedRevision },
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
updateSemanticType(apiName: string, body: Record<string, unknown>, expectedRevision: number) {
|
|
293
|
+
return this.call(`/v1/blueprint/semantic-types/${encodeURIComponent(apiName)}`, {
|
|
294
|
+
method: 'PUT',
|
|
295
|
+
body: { ...body, expectedRevision },
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Succeeds with carriers: each reverts to an unconstrained field. */
|
|
300
|
+
deleteSemanticType(apiName: string, expectedRevision: number) {
|
|
301
|
+
return this.call(`/v1/blueprint/semantic-types/${encodeURIComponent(apiName)}`, {
|
|
302
|
+
method: 'DELETE',
|
|
303
|
+
body: { expectedRevision },
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
269
307
|
/**
|
|
270
308
|
* Removing an object type is a STRUCTURAL change, so it goes through preview and
|
|
271
309
|
* apply rather than a plain DELETE: the service refuses an apply whose digest does
|
|
@@ -112,6 +112,16 @@ export function automationScaffoldFiles(name: string): Record<string, string> {
|
|
|
112
112
|
|
|
113
113
|
out['src/index.ts'] = `import { automation } from '@frontera-sdk/automation'
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The object type this reads. CHANGE THIS to one of yours —
|
|
117
|
+
* \`frontera blueprint list\` shows what this workspace can read.
|
|
118
|
+
*
|
|
119
|
+
* Exported, and the starter test imports it, so changing it here changes both.
|
|
120
|
+
* The test used to hard-code the same literal, and every author's first edit
|
|
121
|
+
* broke it with a bare array diff naming neither the cause nor the fix.
|
|
122
|
+
*/
|
|
123
|
+
export const OBJECT_TYPE = 'YourObjectType'
|
|
124
|
+
|
|
115
125
|
export default automation(
|
|
116
126
|
{
|
|
117
127
|
name: '${name}',
|
|
@@ -127,7 +137,7 @@ export default automation(
|
|
|
127
137
|
// fails and the run is retried, this one returns what it returned the first
|
|
128
138
|
// time instead of querying again. Steps are also what the Console draws.
|
|
129
139
|
const found = await ctx.step.run('load', async () => {
|
|
130
|
-
const result = await ctx.blueprint.query(
|
|
140
|
+
const result = await ctx.blueprint.query(OBJECT_TYPE, { limit: 10 })
|
|
131
141
|
await ctx.log(\`loaded \${result.rows.length} row(s)\`)
|
|
132
142
|
return { count: result.rows.length }
|
|
133
143
|
})
|
|
@@ -163,7 +173,7 @@ export default automation(
|
|
|
163
173
|
out['src/index.test.ts'] = `import { expect, test } from 'bun:test'
|
|
164
174
|
import { createTestContext } from '@frontera-sdk/automation'
|
|
165
175
|
|
|
166
|
-
import automation from './index'
|
|
176
|
+
import automation, { OBJECT_TYPE } from './index'
|
|
167
177
|
|
|
168
178
|
// A handler is just a function, so it can be tested without deploying. The
|
|
169
179
|
// context refuses what the platform refuses — a grant the manifest does not
|
|
@@ -183,7 +193,7 @@ test('handles rows', async () => {
|
|
|
183
193
|
grants: ['blueprint:read'],
|
|
184
194
|
// Stub only what this test is about; an object type you do not stub returns
|
|
185
195
|
// no rows, which is a real answer.
|
|
186
|
-
blueprint: {
|
|
196
|
+
blueprint: { [OBJECT_TYPE]: { rows: [{ id: 'a' }, { id: 'b' }], hasMore: false } },
|
|
187
197
|
})
|
|
188
198
|
|
|
189
199
|
await automation.handler(ctx)
|
package/src/blueprint/compile.ts
CHANGED
|
@@ -119,6 +119,50 @@ export function resolveSharedProperty(
|
|
|
119
119
|
return id
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* `semanticType: percentage` → the uuid the wire takes. THREE-VALUED, exactly like
|
|
124
|
+
* `resolveSharedProperty` above and for the same reasons — omission preserves, `null`
|
|
125
|
+
* detaches, a name attaches or replaces — and resolved at the moment of the write so
|
|
126
|
+
* a semantic type created earlier in the same `apply` is already in the map.
|
|
127
|
+
*
|
|
128
|
+
* Its own function rather than a parameterisation of the shared-field resolver: the
|
|
129
|
+
* two namespaces are independent (a shared field and a semantic type may share an
|
|
130
|
+
* apiName), the messages name different files, and collapsing them would make the
|
|
131
|
+
* one map serve two references — which is how a typo in one resolves to the other.
|
|
132
|
+
*
|
|
133
|
+
* `carrier` is what the reference is written ON, and it is a free string because both
|
|
134
|
+
* carrier kinds resolve through here: a field on an object type, and a shared field
|
|
135
|
+
* standing alone.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveSemanticType(
|
|
138
|
+
file: AuthoredFile,
|
|
139
|
+
carrier: string,
|
|
140
|
+
declared: unknown,
|
|
141
|
+
semanticTypeIdByApiName: Map<string, string>,
|
|
142
|
+
): string | null | undefined {
|
|
143
|
+
if (declared === undefined) return undefined
|
|
144
|
+
if (declared === null) return null
|
|
145
|
+
const name = String(declared)
|
|
146
|
+
const id = semanticTypeIdByApiName.get(name)
|
|
147
|
+
if (!id) {
|
|
148
|
+
throw new CliError(
|
|
149
|
+
`${file.path}: ${carrier} names semantic type "${name}", which is not on the draft.`,
|
|
150
|
+
{
|
|
151
|
+
code: 'USAGE',
|
|
152
|
+
hint: `Add semantic-types/${name}.yaml, or correct the name. `
|
|
153
|
+
+ 'A semantic type is referenced by apiName — the tree carries no identifiers.',
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
return id
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Both id maps a write may need to resolve, threaded together so neither is forgotten. */
|
|
161
|
+
export interface AuthoringIds {
|
|
162
|
+
sharedPropertyIdByApiName: Map<string, string>
|
|
163
|
+
semanticTypeIdByApiName: Map<string, string>
|
|
164
|
+
}
|
|
165
|
+
|
|
122
166
|
function columnOf(entry: string | { column: string; field?: string | null }): {
|
|
123
167
|
column: string
|
|
124
168
|
field?: string | null
|
|
@@ -137,7 +181,7 @@ export async function objectTypeCreateBody(
|
|
|
137
181
|
file: AuthoredFile,
|
|
138
182
|
expectedRevision: number,
|
|
139
183
|
resolveDataset: DatasetResolver,
|
|
140
|
-
|
|
184
|
+
ids: AuthoringIds,
|
|
141
185
|
): Promise<Record<string, unknown>> {
|
|
142
186
|
const document = file.document
|
|
143
187
|
const backing = readBacking(file)
|
|
@@ -199,7 +243,10 @@ export async function objectTypeCreateBody(
|
|
|
199
243
|
// an empty organization creates the type and all its fields in one call, so a
|
|
200
244
|
// reference the create body cannot carry is unreachable on exactly that run.
|
|
201
245
|
const sharedPropertyId = resolveSharedProperty(
|
|
202
|
-
file, apiName, property.sharedField, sharedPropertyIdByApiName,
|
|
246
|
+
file, apiName, property.sharedField, ids.sharedPropertyIdByApiName,
|
|
247
|
+
)
|
|
248
|
+
const semanticTypeId = resolveSemanticType(
|
|
249
|
+
file, `field "${apiName}"`, property.semanticType, ids.semanticTypeIdByApiName,
|
|
203
250
|
)
|
|
204
251
|
return {
|
|
205
252
|
apiName,
|
|
@@ -215,6 +262,9 @@ export async function objectTypeCreateBody(
|
|
|
215
262
|
// "no shared field" — and the create body's uuid is not nullable, so
|
|
216
263
|
// sending it would be a 422 rather than a detach.
|
|
217
264
|
...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
|
|
265
|
+
// Same three-state collapse as the shared field beside it: a CREATE has
|
|
266
|
+
// nothing to detach from, and the create body's uuid is not nullable.
|
|
267
|
+
...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
|
|
218
268
|
}
|
|
219
269
|
}),
|
|
220
270
|
columnMappings: Object.entries(backing.mapping).map(([propertyApiName, entry]) => {
|
|
@@ -308,6 +358,56 @@ export function metricCreateBody(
|
|
|
308
358
|
return { ...body, objectTypeApiName: objectType, expectedRevision }
|
|
309
359
|
}
|
|
310
360
|
|
|
361
|
+
/**
|
|
362
|
+
* `POST /v1/blueprint/actions` and its PUT.
|
|
363
|
+
*
|
|
364
|
+
* Carried through as authored, name references and all. The service resolves them
|
|
365
|
+
* against the draft and mints the ids the contract owns — see
|
|
366
|
+
* `action-reference-resolution` and `action-identity` — so translating anything here
|
|
367
|
+
* would put a second resolver on the wire's other end, which is the arrangement
|
|
368
|
+
* `linkTypeStructure`'s neighbours exist to avoid.
|
|
369
|
+
*/
|
|
370
|
+
/**
|
|
371
|
+
* An action file with every id the SERVICE mints removed — the action's own and each
|
|
372
|
+
* child's.
|
|
373
|
+
*
|
|
374
|
+
* Exported because two callers must agree exactly: `actionBody` sends this, and the
|
|
375
|
+
* differ compares against it. Stripping only the top-level id made a pasted Console
|
|
376
|
+
* export plan an `~ action` update on EVERY run — the projection strips child ids too,
|
|
377
|
+
* so the raw file never matched the live side, and the apply then sent a body identical
|
|
378
|
+
* to what was already stored. `replaceBlueprintDraft` has no no-op guard, so each run
|
|
379
|
+
* bumped the revision and changed nothing.
|
|
380
|
+
*
|
|
381
|
+
* Forwarding a pasted id was wrong on its own terms as well: `assignActionIdentity`
|
|
382
|
+
* honours a pinned id, so an export from another organization created an action here
|
|
383
|
+
* carrying THAT deployment's uuid — accepted, because the duplicate check is per-org,
|
|
384
|
+
* and invisible afterwards, because the next `pull` strips it again while contract
|
|
385
|
+
* digests and binding rows keep keying off it.
|
|
386
|
+
*/
|
|
387
|
+
export function actionWithoutMintedIds(document: Record<string, unknown>): Record<string, unknown> {
|
|
388
|
+
const drop = (value: unknown): unknown => {
|
|
389
|
+
if (Array.isArray(value)) return value.map(drop)
|
|
390
|
+
if (!value || typeof value !== 'object') return value
|
|
391
|
+
const out: Record<string, unknown> = {}
|
|
392
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
393
|
+
// `id` only. A REFERENCE ending in `Id` is the service's own resolution of a name
|
|
394
|
+
// and never appears in a projected file, so it is left alone rather than guessed
|
|
395
|
+
// at — the projection is the authority on what a file may carry.
|
|
396
|
+
if (key === 'id') continue
|
|
397
|
+
out[key] = drop(entry)
|
|
398
|
+
}
|
|
399
|
+
return out
|
|
400
|
+
}
|
|
401
|
+
return drop(document) as Record<string, unknown>
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function actionBody(file: AuthoredFile): Record<string, unknown> {
|
|
405
|
+
// `apiName` from the PATH, not the document: the tree's filename is the identity,
|
|
406
|
+
// and a file whose body disagreed with its name would create one artifact and be
|
|
407
|
+
// pruned as another on the next run.
|
|
408
|
+
return { ...actionWithoutMintedIds(file.document), apiName: file.apiName }
|
|
409
|
+
}
|
|
410
|
+
|
|
311
411
|
/** The patch route takes metadata and the definition, never the subject. */
|
|
312
412
|
export function metricPatchBody(
|
|
313
413
|
file: AuthoredFile,
|
|
@@ -327,8 +427,12 @@ export function metricPatchBody(
|
|
|
327
427
|
export function sharedPropertyCreateBody(
|
|
328
428
|
file: AuthoredFile,
|
|
329
429
|
expectedRevision: number,
|
|
430
|
+
semanticTypeIdByApiName: Map<string, string>,
|
|
330
431
|
): Record<string, unknown> {
|
|
331
432
|
const document = file.document
|
|
433
|
+
const semanticTypeId = resolveSemanticType(
|
|
434
|
+
file, `shared field "${file.apiName}"`, document.semanticType, semanticTypeIdByApiName,
|
|
435
|
+
)
|
|
332
436
|
return {
|
|
333
437
|
apiName: requireString(document, 'apiName', file.path),
|
|
334
438
|
displayName: requireString(document, 'displayName', file.path),
|
|
@@ -339,6 +443,11 @@ export function sharedPropertyCreateBody(
|
|
|
339
443
|
// the file instead of arriving as a 422 from the service.
|
|
340
444
|
propertyType: requireString(document, 'propertyType', file.path),
|
|
341
445
|
...optional(document.formatConfig as Record<string, unknown> | undefined, 'formatConfig'),
|
|
446
|
+
// A shared field created with nothing carrying it yet still states what its
|
|
447
|
+
// implementers will mean, so the reference travels the CREATE rather than a
|
|
448
|
+
// follow-up patch: on a first apply against an empty organization there is no
|
|
449
|
+
// second call to make.
|
|
450
|
+
...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
|
|
342
451
|
...optional(document.status as string | undefined, 'status'),
|
|
343
452
|
...optional(document.visibility as string | undefined, 'visibility'),
|
|
344
453
|
expectedRevision,
|
|
@@ -356,6 +465,63 @@ export function sharedPropertyCreateBody(
|
|
|
356
465
|
export function sharedPropertyPatchBody(
|
|
357
466
|
file: AuthoredFile,
|
|
358
467
|
expectedRevision: number,
|
|
468
|
+
semanticTypeIdByApiName: Map<string, string>,
|
|
469
|
+
): Record<string, unknown> {
|
|
470
|
+
const {
|
|
471
|
+
apiName: _apiName,
|
|
472
|
+
// TRANSLATED, never passed through. The file says `semanticType: <name>` and the
|
|
473
|
+
// route takes `semanticTypeId: <uuid> | null`; sending the authored key would
|
|
474
|
+
// leave the route's own key absent, so the patch would report success having
|
|
475
|
+
// changed nothing — the silent no-op this projection exists to prevent.
|
|
476
|
+
semanticType,
|
|
477
|
+
...body
|
|
478
|
+
} = file.document as Record<string, unknown>
|
|
479
|
+
const semanticTypeId = resolveSemanticType(
|
|
480
|
+
file, `shared field "${file.apiName}"`, semanticType, semanticTypeIdByApiName,
|
|
481
|
+
)
|
|
482
|
+
return {
|
|
483
|
+
...body,
|
|
484
|
+
...(semanticTypeId === undefined ? {} : { semanticTypeId }),
|
|
485
|
+
expectedRevision,
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* A semantic type is authored whole: metadata plus one rule, and the create route
|
|
491
|
+
* takes the same fields the bundle carries.
|
|
492
|
+
*
|
|
493
|
+
* The rule is passed through UNVALIDATED. Its grammar is four discriminated schemas
|
|
494
|
+
* the service owns, and restating them here would be a second copy that drifts —
|
|
495
|
+
* the route answers a 400 carrying the path into the rule, which is a better error
|
|
496
|
+
* than any this package could invent.
|
|
497
|
+
*/
|
|
498
|
+
export function semanticTypeCreateBody(
|
|
499
|
+
file: AuthoredFile,
|
|
500
|
+
expectedRevision: number,
|
|
501
|
+
): Record<string, unknown> {
|
|
502
|
+
const document = file.document
|
|
503
|
+
if (document.rule === undefined) {
|
|
504
|
+
throw new CliError(`${file.path} declares no \`rule\`.`, {
|
|
505
|
+
code: 'USAGE',
|
|
506
|
+
hint: 'A semantic type is a rule. `frontera blueprint new semantic-type <name>` writes one that validates.',
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
apiName: requireString(document, 'apiName', file.path),
|
|
511
|
+
displayName: requireString(document, 'displayName', file.path),
|
|
512
|
+
...optional(document.description as string | undefined, 'description'),
|
|
513
|
+
dataType: requireString(document, 'dataType', file.path),
|
|
514
|
+
rule: document.rule,
|
|
515
|
+
...optional(document.status as string | undefined, 'status'),
|
|
516
|
+
...optional(document.visibility as string | undefined, 'visibility'),
|
|
517
|
+
expectedRevision,
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** `apiName` is omitted for the same reason a shared field's is: it means RENAME. */
|
|
522
|
+
export function semanticTypePatchBody(
|
|
523
|
+
file: AuthoredFile,
|
|
524
|
+
expectedRevision: number,
|
|
359
525
|
): Record<string, unknown> {
|
|
360
526
|
const { apiName: _apiName, ...body } = file.document as Record<string, unknown>
|
|
361
527
|
return { ...body, expectedRevision }
|
package/src/blueprint/diff.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { actionWithoutMintedIds } from './compile'
|
|
1
2
|
import { toFiles } from './projection'
|
|
2
3
|
import {
|
|
3
4
|
ARTIFACT_KINDS,
|
|
@@ -5,6 +6,7 @@ import {
|
|
|
5
6
|
type AuthoredFile,
|
|
6
7
|
type DefinitionBundle,
|
|
7
8
|
} from './model'
|
|
9
|
+
import { CliError } from '../errors'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* The plan: what the files say that the live draft does not, expressed as typed
|
|
@@ -48,6 +50,16 @@ export interface Plan {
|
|
|
48
50
|
operations: ModelOperation[]
|
|
49
51
|
/** Artifacts in the draft but absent from the tree. Applied only with `--prune`. */
|
|
50
52
|
prunes: ModelOperation[]
|
|
53
|
+
/**
|
|
54
|
+
* Prunes NOT offered, because the tree has no directory for that kind at all.
|
|
55
|
+
*
|
|
56
|
+
* Reported rather than dropped. Git cannot transport an empty directory, so
|
|
57
|
+
* `rm -rf blueprint/metrics` is indistinguishable from a tree that never modelled
|
|
58
|
+
* metrics — and withholding silently answered `Applied 0 changes` while the metrics
|
|
59
|
+
* stayed live. Saying which prunes were withheld, and why, is the difference between
|
|
60
|
+
* a guard and a surprise.
|
|
61
|
+
*/
|
|
62
|
+
withheld?: ModelOperation[]
|
|
51
63
|
drift: BindingDrift[]
|
|
52
64
|
unchanged: number
|
|
53
65
|
}
|
|
@@ -148,6 +160,37 @@ function describeUpdate(local: Record<string, unknown>, live: Record<string, unk
|
|
|
148
160
|
return parts.length ? parts.join(', ') : 'fields changed'
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Has this artifact changed? Subset for most kinds, EXACT for actions.
|
|
165
|
+
*
|
|
166
|
+
* The asymmetry follows the routes, not a preference. An object type, link type or
|
|
167
|
+
* metric is updated by a patch, so a field the file omits is a field the file has no
|
|
168
|
+
* opinion about — which is what makes `satisfies` a subset comparison and what lets a
|
|
169
|
+
* hand-authored file leave every service-derived value out.
|
|
170
|
+
*
|
|
171
|
+
* An action's PUT replaces the contract WHOLE. There, omitting a field deletes it. Under
|
|
172
|
+
* a subset comparison that deletion was invisible: `plan` reported nothing, and a
|
|
173
|
+
* deletion travelling alongside any other edit was applied without ever being named.
|
|
174
|
+
* Comparing both directions makes the removal a change, which is what it is.
|
|
175
|
+
*
|
|
176
|
+
* Safe because an action file is a total projection — `actionToFile` strips only the ids
|
|
177
|
+
* the service mints and turns references into names, both of which `apply` puts back. A
|
|
178
|
+
* pulled tree therefore still reports no changes, which the round-trip test asserts.
|
|
179
|
+
*/
|
|
180
|
+
function matches(
|
|
181
|
+
kind: ArtifactKind,
|
|
182
|
+
local: Record<string, unknown>,
|
|
183
|
+
live: Record<string, unknown>,
|
|
184
|
+
): boolean {
|
|
185
|
+
if (kind !== 'action') return satisfies(local, live)
|
|
186
|
+
// Compared against what `apply` will actually SEND, which is the file minus every id
|
|
187
|
+
// the service mints. The live side is the projection, which already strips them — so
|
|
188
|
+
// comparing the raw file made a pasted export differ forever, on ids neither side
|
|
189
|
+
// could ever agree about.
|
|
190
|
+
const authored = actionWithoutMintedIds(local)
|
|
191
|
+
return satisfies(authored, live) && satisfies(live, authored)
|
|
192
|
+
}
|
|
193
|
+
|
|
151
194
|
export function diff(
|
|
152
195
|
localFiles: AuthoredFile[],
|
|
153
196
|
live: DefinitionBundle,
|
|
@@ -179,7 +222,7 @@ export function diff(
|
|
|
179
222
|
})
|
|
180
223
|
continue
|
|
181
224
|
}
|
|
182
|
-
if (
|
|
225
|
+
if (matches(kind, file.document, existing.document)) {
|
|
183
226
|
unchanged += 1
|
|
184
227
|
continue
|
|
185
228
|
}
|
|
@@ -194,13 +237,100 @@ export function diff(
|
|
|
194
237
|
}
|
|
195
238
|
for (const file of liveModel.files.filter((candidate) => candidate.kind === kind)) {
|
|
196
239
|
if (localByKey.has(`${kind}:${file.apiName}`)) continue
|
|
197
|
-
|
|
240
|
+
// The live document rides along in `liveDocument`, which is what that field
|
|
241
|
+
// means — `document` is the AUTHORED one, and a delete has no authored side. A
|
|
242
|
+
// delete needs no body at all, but ORDERING the prunes does: an action naming
|
|
243
|
+
// another as its compensation must be deleted first, and only the live side
|
|
244
|
+
// knows which.
|
|
245
|
+
prunes.push({ operation: 'delete', kind, apiName: file.apiName, liveDocument: file.document })
|
|
198
246
|
}
|
|
199
247
|
}
|
|
200
248
|
|
|
201
249
|
return { revision, operations, prunes, drift: driftOf(live), unchanged }
|
|
202
250
|
}
|
|
203
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Every place the tree names a semantic type, as a path a reader can open.
|
|
254
|
+
*
|
|
255
|
+
* Both carrier kinds, because both author the reference: a shared field names one at
|
|
256
|
+
* its top level, and an object type names one per property. `null` is a detach and
|
|
257
|
+
* names nothing.
|
|
258
|
+
*/
|
|
259
|
+
function semanticTypeReferences(files: AuthoredFile[]): Array<{ name: string; carrier: string }> {
|
|
260
|
+
const references: Array<{ name: string; carrier: string }> = []
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
if (file.kind === 'shared-field') {
|
|
263
|
+
const declared = file.document.semanticType
|
|
264
|
+
if (typeof declared === 'string') references.push({ name: declared, carrier: file.path })
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
if (file.kind !== 'object-type') continue
|
|
268
|
+
const properties = file.document.properties
|
|
269
|
+
if (!Array.isArray(properties)) continue
|
|
270
|
+
for (const property of properties) {
|
|
271
|
+
const entry = property as { apiName?: unknown; semanticType?: unknown }
|
|
272
|
+
if (typeof entry.semanticType !== 'string') continue
|
|
273
|
+
references.push({
|
|
274
|
+
name: entry.semanticType,
|
|
275
|
+
carrier: `${file.path} (field "${String(entry.apiName)}")`,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return references
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Refuse a prune that would delete a semantic type the surviving tree still names.
|
|
284
|
+
*
|
|
285
|
+
* The planner compares each authored file against the live draft INDEPENDENTLY of
|
|
286
|
+
* what else the run prunes, so a tree that keeps `shared-fields/status.yaml` carrying
|
|
287
|
+
* `semanticType: code` and drops `semantic-types/code.yaml` plans as `unchanged: 1`
|
|
288
|
+
* plus one prune. Executed, the delete route degrades its carriers — it clears the
|
|
289
|
+
* very reference the surviving file still declares — so the run reports success and
|
|
290
|
+
* leaves the draft disagreeing with the tree. The next `apply` then fails on name
|
|
291
|
+
* resolution, because `code` is no longer in the live id map: the report is right and
|
|
292
|
+
* the recovery is manual.
|
|
293
|
+
*
|
|
294
|
+
* Refused rather than reordered, and refused naming EVERY surviving carrier: the
|
|
295
|
+
* author has to decide whether the type is going or the carriers are, and doing half
|
|
296
|
+
* of it for them is how a rule silently stops being enforced.
|
|
297
|
+
*
|
|
298
|
+
* Called where `--prune` is honoured rather than from `diff` itself. A tree is allowed
|
|
299
|
+
* to be partial — one file, applied on its own, is the normal way to work — and only
|
|
300
|
+
* `--prune` claims the tree is the whole truth.
|
|
301
|
+
*/
|
|
302
|
+
export function assertPrunesLeaveNoDanglingReference(
|
|
303
|
+
localFiles: AuthoredFile[],
|
|
304
|
+
prunes: ModelOperation[],
|
|
305
|
+
): void {
|
|
306
|
+
const pruned = new Set(
|
|
307
|
+
prunes.filter((operation) => operation.kind === 'semantic-type')
|
|
308
|
+
.map((operation) => operation.apiName),
|
|
309
|
+
)
|
|
310
|
+
if (pruned.size === 0) return
|
|
311
|
+
const orphaned = new Map<string, string[]>()
|
|
312
|
+
for (const reference of semanticTypeReferences(localFiles)) {
|
|
313
|
+
if (!pruned.has(reference.name)) continue
|
|
314
|
+
const carriers = orphaned.get(reference.name) ?? []
|
|
315
|
+
carriers.push(reference.carrier)
|
|
316
|
+
orphaned.set(reference.name, carriers)
|
|
317
|
+
}
|
|
318
|
+
if (orphaned.size === 0) return
|
|
319
|
+
const detail = [...orphaned.entries()]
|
|
320
|
+
.map(([name, carriers]) => ` ${name} — still carried by ${carriers.join(', ')}`)
|
|
321
|
+
.join('\n')
|
|
322
|
+
throw new CliError(
|
|
323
|
+
`--prune would delete ${orphaned.size === 1 ? 'a semantic type' : 'semantic types'} the tree `
|
|
324
|
+
+ `still references:\n${detail}`,
|
|
325
|
+
{
|
|
326
|
+
code: 'USAGE',
|
|
327
|
+
hint: 'Delete the carriers\' `semanticType:` lines too, or keep the semantic-types/ file. '
|
|
328
|
+
+ 'Deleting the type on its own clears every reference to it, which the surviving files '
|
|
329
|
+
+ 'would then contradict.',
|
|
330
|
+
},
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
204
334
|
/**
|
|
205
335
|
* Bound object types whose pinned schema digest can be checked against the dataset's
|
|
206
336
|
* current revision. The comparison itself needs the dataset read, so this only
|