@frontera-sdk/cli 1.53.5 → 1.53.7
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 -4
- package/src/api/apps-api.ts +2 -2
- package/src/api/blueprint-authoring-api.ts +19 -19
- package/src/api/governed-action-api.ts +78 -3
- package/src/api/platform-api.ts +20 -0
- package/src/blueprint/dataset-revision.ts +66 -0
- package/src/blueprint/diff.ts +134 -6
- package/src/blueprint/publish-plan.ts +253 -0
- package/src/blueprint/render.ts +19 -0
- package/src/commands/action/deploy.ts +143 -25
- package/src/commands/action/index-commands.ts +3 -3
- package/src/commands/action/list.ts +3 -3
- package/src/commands/action/prepare.ts +5 -3
- package/src/commands/action/retire.ts +78 -26
- package/src/commands/action/review.ts +86 -25
- package/src/commands/app/checks.ts +1 -1
- package/src/commands/app/deploy.ts +9 -9
- package/src/commands/app/deployments.ts +1 -1
- package/src/commands/app/dev.ts +79 -14
- package/src/commands/app/promote.ts +1 -1
- package/src/commands/app/pull.ts +4 -2
- package/src/commands/app/rollback.ts +1 -1
- package/src/commands/app/save.ts +3 -7
- package/src/commands/app/shared.ts +59 -2
- package/src/commands/app/unpin.ts +1 -1
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/blueprint/authoring.ts +110 -24
- package/src/commands/blueprint/bind.ts +10 -46
- package/src/commands/blueprint/declarative.ts +124 -30
- package/src/commands/blueprint/editable.ts +3 -5
- package/src/commands/knowledge/index-commands.ts +15 -2
- package/src/commands/local/check.ts +21 -13
- package/src/commands/local/data.ts +1 -1
- package/src/commands/local/dev.ts +1 -21
- package/src/commands/local/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/steps.ts +11 -7
- package/src/commands/local/sync-request.ts +45 -8
- package/src/commands/local/sync.ts +23 -4
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/commands/types.ts +2 -0
- package/src/device-login.ts +19 -1
- package/src/exit.ts +5 -0
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-actions.ts +6 -5
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-blueprint-schemas.ts +2 -2
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-dev-references.ts +0 -23
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +121 -19
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-link.ts +76 -0
- package/src/local/local-sync-precheck.ts +1 -1
- package/src/local/local-sync-push.ts +8 -2
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +7 -1
- package/src/mcp/mcp-apps.ts +96 -20
- package/src/mcp/tools/agent-tools.ts +29 -7
- package/src/mcp/tools/app-tools.ts +3 -3
- package/src/mcp/tools/project-tools.ts +2 -1
- package/src/project.ts +47 -11
- package/src/remote-guard.ts +4 -1
- package/src/templates/next-app-files.ts +4 -1
- package/src/templates/next-skills.ts +9 -5
- package/src/vendor/kit-assets.json +8 -8
- package/src/vendor/sdk-sources.json +17 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.53.
|
|
3
|
+
"version": "1.53.7",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
42
|
"@faker-js/faker": "10.6.0",
|
|
43
|
-
"@frontera-sdk/core": "1.53.
|
|
44
|
-
"@frontera-sdk/functions": "1.53.
|
|
43
|
+
"@frontera-sdk/core": "1.53.7",
|
|
44
|
+
"@frontera-sdk/functions": "1.53.7",
|
|
45
45
|
"ai": "^6.0.116",
|
|
46
46
|
"axe-core": "4.12.1",
|
|
47
47
|
"gray-matter": "^4.0.3",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"yaml": "^2.9.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@frontera-sdk/forge-contracts": "1.53.
|
|
52
|
+
"@frontera-sdk/forge-contracts": "1.53.7",
|
|
53
53
|
"@types/bun": "^1.3.14",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
}
|
package/src/api/apps-api.ts
CHANGED
|
@@ -159,8 +159,8 @@ export class AppsApi {
|
|
|
159
159
|
* `pull` used `ensureApp`, so a typo or a slug the platform had since
|
|
160
160
|
* renamed produced a brand new empty app instead of an error.
|
|
161
161
|
*/
|
|
162
|
-
resolveApp(slugOrId: string): Promise<{ id: string; slug: string; displayName: string }> {
|
|
163
|
-
return this.client.request<{ id: string; slug: string; displayName: string }>(
|
|
162
|
+
resolveApp(slugOrId: string): Promise<{ id: string; slug: string; displayName: string; kind?: 'code' | 'chat' }> {
|
|
163
|
+
return this.client.request<{ id: string; slug: string; displayName: string; kind?: 'code' | 'chat' }>(
|
|
164
164
|
`/v1/platform-apps/resolve/${encodeURIComponent(slugOrId)}`,
|
|
165
165
|
)
|
|
166
166
|
}
|
|
@@ -3,6 +3,8 @@ import { organizationHeaders } from '../organization'
|
|
|
3
3
|
import { credentialFailure } from './credential-failure'
|
|
4
4
|
import { formatValidationDetails } from './validation-detail'
|
|
5
5
|
import type { DefinitionBundle } from '../blueprint/model'
|
|
6
|
+
import type { ValidationReport } from '../blueprint/publish-plan'
|
|
7
|
+
import { withBlockers } from '../exit'
|
|
6
8
|
|
|
7
9
|
/** A row of `GET /v1/blueprint/markings`. */
|
|
8
10
|
export interface MarkingRow {
|
|
@@ -75,10 +77,14 @@ export class BlueprintAuthoringApi {
|
|
|
75
77
|
})
|
|
76
78
|
if (credential) throw credential
|
|
77
79
|
if (res.status === 409) {
|
|
80
|
+
// The blockers ride in `details`: printed under the message, and kept whole for `--json`.
|
|
78
81
|
throw new CliError(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
withBlockers(
|
|
83
|
+
payload?.message
|
|
84
|
+
?? 'The shared draft moved since this change was computed. Re-run to pick up the current revision.',
|
|
85
|
+
payload?.details,
|
|
86
|
+
),
|
|
87
|
+
{ code: 'CONFLICT', hint: 'frontera blueprint status', ...(payload?.details !== undefined ? { details: payload.details } : {}) },
|
|
82
88
|
)
|
|
83
89
|
}
|
|
84
90
|
if (res.status === 404) {
|
|
@@ -351,42 +357,36 @@ export class BlueprintAuthoringApi {
|
|
|
351
357
|
|
|
352
358
|
// ── release lifecycle ─────────────────────────────────────────────────────
|
|
353
359
|
|
|
354
|
-
validate(): Promise<{
|
|
355
|
-
id?: string
|
|
360
|
+
validate(): Promise<ValidationReport & {
|
|
356
361
|
reportId?: string
|
|
357
|
-
draftRevision?: number
|
|
358
362
|
draftDigest?: string
|
|
359
363
|
baseReleaseDigest?: string
|
|
360
364
|
catalogProjection?: { objectTypes?: unknown[]; linkTypes?: unknown[] }
|
|
361
|
-
evolution?: { changes?: unknown[] }
|
|
362
365
|
}> {
|
|
363
366
|
return this.call('/v1/blueprint/lifecycle/validate', { method: 'POST', body: {} })
|
|
364
367
|
}
|
|
365
368
|
|
|
369
|
+
/**
|
|
370
|
+
* One POST, with every instruction already in it. Publish never asks the service
|
|
371
|
+
* which changes need one: each refused publish is kept as a failed attempt, so the
|
|
372
|
+
* change ids come from the validation report (`publish-plan.ts`).
|
|
373
|
+
*/
|
|
366
374
|
publish(input: {
|
|
367
375
|
expectedRevision: number
|
|
368
376
|
validationReportId: string
|
|
369
377
|
releaseLabel: string
|
|
370
378
|
releaseNotes: string
|
|
371
|
-
instruction
|
|
372
|
-
}) {
|
|
373
|
-
|
|
374
|
-
// Publish REQUIRES the field even when empty; rollback treats it as optional.
|
|
375
|
-
// Dropping the default here failed typebox before authorization ever ran.
|
|
376
|
-
return this.postWithMigrations(
|
|
377
|
-
'/v1/blueprint/lifecycle/publish',
|
|
378
|
-
{ migrationInstructions: [], ...body },
|
|
379
|
-
instruction,
|
|
380
|
-
)
|
|
379
|
+
migrationInstructions: Array<{ changeId: string; instruction: string }>
|
|
380
|
+
}): Promise<unknown> {
|
|
381
|
+
return this.call('/v1/blueprint/lifecycle/publish', { method: 'POST', body: input })
|
|
381
382
|
}
|
|
382
383
|
|
|
383
384
|
/**
|
|
384
|
-
* POST a
|
|
385
|
+
* POST a rollback, negotiating migration instructions once.
|
|
385
386
|
*
|
|
386
387
|
* A release that discards changes is refused until each discarded change carries an
|
|
387
388
|
* explanation, and the service names the change ids it wants. Rather than make the
|
|
388
389
|
* caller guess them, this asks, then retries with one instruction per named change.
|
|
389
|
-
* Shared by publish and rollback because the refusal is identical for both.
|
|
390
390
|
*/
|
|
391
391
|
private async postWithMigrations(
|
|
392
392
|
path: string,
|
|
@@ -74,6 +74,31 @@ export interface DeployableActionEntry {
|
|
|
74
74
|
warnings?: Array<{ code: string; path?: string; message: string }>
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** An Action's current deployment, as `GET …/admin/deployments?actionDefinitionId=` answers it. */
|
|
78
|
+
export interface CurrentDeployment {
|
|
79
|
+
stateId: string
|
|
80
|
+
state: string
|
|
81
|
+
generation: number
|
|
82
|
+
bindingId: string
|
|
83
|
+
bindingRevisionId: string | null
|
|
84
|
+
/** The contract the deployment was decided for; a later edit of the Action does not move it. */
|
|
85
|
+
actionContractDigest: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The write path calling another system an Action runs, drains or last ran; `planKind` is null when the service cannot read it. */
|
|
89
|
+
export interface ExternalWritePath {
|
|
90
|
+
stateId: string
|
|
91
|
+
state: string
|
|
92
|
+
planKind: string | null
|
|
93
|
+
/** The organization it was read in, which a person's calls then name. */
|
|
94
|
+
organizationId?: string
|
|
95
|
+
/**
|
|
96
|
+
* Asked with one of the Action's Binding revisions: why the caller's review
|
|
97
|
+
* would not turn that one on in this one's place, or null when it would.
|
|
98
|
+
*/
|
|
99
|
+
reviewRefusal?: 'key_built' | 'reviewer_built' | null
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
export interface ActionDiscovery {
|
|
78
103
|
actionDefinitionId: string
|
|
79
104
|
apiName: string
|
|
@@ -414,7 +439,7 @@ export class GovernedActionApi {
|
|
|
414
439
|
actionDefinitionId: string
|
|
415
440
|
actionContractDigest: string
|
|
416
441
|
mutationPlanRevisionId: string
|
|
417
|
-
}): Promise<{ id: string; stateId: string }> {
|
|
442
|
+
}): Promise<{ id: string; stateId: string; organizationId?: string }> {
|
|
418
443
|
return this.call(`/v1/blueprint/governed-actions/admin/bindings/${bindingId}/revisions`, {
|
|
419
444
|
method: 'POST',
|
|
420
445
|
body: { expectedRevision: 0, parentRevisionId: null, ...input },
|
|
@@ -453,6 +478,7 @@ export class GovernedActionApi {
|
|
|
453
478
|
id: string
|
|
454
479
|
bindingId: string
|
|
455
480
|
actionDefinitionId: string
|
|
481
|
+
mutationPlanRevisionId: string
|
|
456
482
|
stateId: string
|
|
457
483
|
state: string
|
|
458
484
|
stateGeneration: number
|
|
@@ -486,14 +512,63 @@ export class GovernedActionApi {
|
|
|
486
512
|
return (published?.definition as { id?: string } | undefined)?.id ?? null
|
|
487
513
|
}
|
|
488
514
|
|
|
515
|
+
/**
|
|
516
|
+
* The write path calling another system that the Action runs, drains or last
|
|
517
|
+
* ran, or null. While there is one, the service turns a new one on only
|
|
518
|
+
* after a second person reviews it, and never one an API key built. An
|
|
519
|
+
* older platform has no such route and no such rule, so it answers null.
|
|
520
|
+
*/
|
|
521
|
+
async externalWritePath(actionDefinitionId: string, bindingRevisionId?: string): Promise<ExternalWritePath | null> {
|
|
522
|
+
const revision = bindingRevisionId ? `&bindingRevisionId=${encodeURIComponent(bindingRevisionId)}` : ''
|
|
523
|
+
try {
|
|
524
|
+
return await this.call(`/v1/blueprint/governed-actions/admin/deployments/external-write-path?actionDefinitionId=${encodeURIComponent(actionDefinitionId)}${revision}`)
|
|
525
|
+
} catch (err) {
|
|
526
|
+
if (err instanceof CliError && err.code === 'NOT_FOUND') return null
|
|
527
|
+
throw err
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
489
531
|
/** The Action's current deployment decision, or null when it has none. */
|
|
490
|
-
async currentDeployment(actionDefinitionId: string): Promise<
|
|
491
|
-
const page = await this.call<{ entries?:
|
|
532
|
+
async currentDeployment(actionDefinitionId: string): Promise<CurrentDeployment | null> {
|
|
533
|
+
const page = await this.call<{ entries?: CurrentDeployment[] }>(
|
|
492
534
|
`/v1/blueprint/governed-actions/admin/deployments?actionDefinitionId=${encodeURIComponent(actionDefinitionId)}`,
|
|
493
535
|
)
|
|
494
536
|
return page?.entries?.[0] ?? null
|
|
495
537
|
}
|
|
496
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Switch the Action's current deployment off: the service records a NEW
|
|
541
|
+
* disabled deployment under the fresh `stateId` and `bindingId`, and the one
|
|
542
|
+
* it replaces stops being current (a running one drains).
|
|
543
|
+
*/
|
|
544
|
+
disableDeployment(stateId: string, input: {
|
|
545
|
+
bindingId: string
|
|
546
|
+
actionDefinitionId: string
|
|
547
|
+
actionContractDigest: string
|
|
548
|
+
expectedCurrentStateId: string | null
|
|
549
|
+
expectedCurrentGeneration: number | null
|
|
550
|
+
reason: string
|
|
551
|
+
}): Promise<{ stateId: string; state: string; generation: number }> {
|
|
552
|
+
return this.call(`/v1/blueprint/governed-actions/admin/deployments/${encodeURIComponent(stateId)}/disable`, {
|
|
553
|
+
method: 'POST',
|
|
554
|
+
body: input,
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The Action's draining deployments. None is current, so the deployment list
|
|
560
|
+
* omits them, while publish still refuses a changed Action until each is
|
|
561
|
+
* retired. Read per binding, from the state of its newest revision.
|
|
562
|
+
*/
|
|
563
|
+
async drainingDeployments(actionDefinitionId: string): Promise<Array<{ stateId: string; generation: number }>> {
|
|
564
|
+
// ponytail: the first page of bindings only (50, in id order), so an Action deployed more than 50 times may hide one; page the list, or let the service filter by state, when that happens.
|
|
565
|
+
const { entries } = await this.listBindings(actionDefinitionId)
|
|
566
|
+
const states = await Promise.all(entries.map((entry) => this.getBindingRevision(entry.revisionId)))
|
|
567
|
+
return states
|
|
568
|
+
.filter((revision) => revision.state === 'draining' && revision.actionDefinitionId === actionDefinitionId)
|
|
569
|
+
.map((revision) => ({ stateId: revision.stateId, generation: revision.stateGeneration }))
|
|
570
|
+
}
|
|
571
|
+
|
|
497
572
|
/** Stand a deployment decision down: one that was never bound, or a draining one (the service refuses the rest). */
|
|
498
573
|
retireDeployment(stateId: string, expectedGeneration: number, reason: string): Promise<{ stateId?: string; state?: string; generation?: number }> {
|
|
499
574
|
return this.call(`/v1/blueprint/governed-actions/admin/deployments/${encodeURIComponent(stateId)}/retire`, {
|
package/src/api/platform-api.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { MetricQueryBody, MetricQueryResult } from '../blueprint/metric-que
|
|
|
4
4
|
import { organizationHeaders, selectedOrganization } from '../organization'
|
|
5
5
|
|
|
6
6
|
import { CliError } from '../errors'
|
|
7
|
+
import type { SavedServer } from '../local/local-mcp-servers'
|
|
7
8
|
import { BLUEPRINT_SCHEMA_CAPABILITIES } from '../blueprint-types'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -291,6 +292,11 @@ export class PlatformApi {
|
|
|
291
292
|
return this.get<{ server: unknown }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`)
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
/** Created, or replaced in place: `created` says which. */
|
|
296
|
+
saveMcpServer(slug: string, definition: Record<string, unknown>): Promise<SavedServer> {
|
|
297
|
+
return this.client.request<SavedServer>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'PUT', body: definition })
|
|
298
|
+
}
|
|
299
|
+
|
|
294
300
|
/** Archived, not deleted: the Function runs its tools started still name it. */
|
|
295
301
|
archiveMcpServer(slug: string): Promise<{ slug: string; archived: boolean }> {
|
|
296
302
|
return this.client.request<{ slug: string; archived: boolean }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'DELETE' })
|
|
@@ -721,6 +727,20 @@ export class PlatformApi {
|
|
|
721
727
|
|
|
722
728
|
// ── Knowledge ─────────────────────────────────────────────────────────────
|
|
723
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Whether the platform runs the Knowledge app. Its routes answer 404 before any sign-in check where it does not, so
|
|
732
|
+
* any other answer, a refusal of this key included, means it runs. No answer counts as not running.
|
|
733
|
+
*/
|
|
734
|
+
async knowledgeAppRuns(): Promise<boolean> {
|
|
735
|
+
try {
|
|
736
|
+
await this.get<unknown>('/v1/knowledge-app/me')
|
|
737
|
+
return true
|
|
738
|
+
} catch (err) {
|
|
739
|
+
const status = (err as { status?: number }).status
|
|
740
|
+
return status !== undefined && status !== 404
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
724
744
|
knowledgeBases(workspaceId: string) {
|
|
725
745
|
return this.getList<unknown>(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`)
|
|
726
746
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Which revision of a dataset is CURRENT.
|
|
3
5
|
*
|
|
@@ -31,3 +33,67 @@ export function pickCurrentRevision<T extends { id?: string }>(
|
|
|
31
33
|
}
|
|
32
34
|
return revisions[0]
|
|
33
35
|
}
|
|
36
|
+
|
|
37
|
+
/** A file's `backing.mapping` entry: a column, or a struct field within one. */
|
|
38
|
+
export type MappingEntry = string | { column: string; field?: string | null }
|
|
39
|
+
|
|
40
|
+
export interface ColumnMapping {
|
|
41
|
+
propertyId: string
|
|
42
|
+
column: string
|
|
43
|
+
field?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every property that reads a column, paired with the column the mapping names.
|
|
48
|
+
* Shared by `bind` and by `apply`'s rebind, which send the same draft command.
|
|
49
|
+
*/
|
|
50
|
+
export function columnMappingsFrom(
|
|
51
|
+
properties: ReadonlyArray<{ id?: string; apiName?: string; calculation?: unknown }>,
|
|
52
|
+
mapping: Readonly<Record<string, MappingEntry | null | undefined>>,
|
|
53
|
+
): { columnMappings: ColumnMapping[]; unmapped: string[] } {
|
|
54
|
+
const columnMappings: ColumnMapping[] = []
|
|
55
|
+
const unmapped: string[] = []
|
|
56
|
+
for (const property of properties) {
|
|
57
|
+
const name = property.apiName
|
|
58
|
+
// A calculated field (a location from latitude and longitude) reads no column.
|
|
59
|
+
if (!name || property.calculation) continue
|
|
60
|
+
const entry = mapping[name]
|
|
61
|
+
if (entry === undefined || entry === null) {
|
|
62
|
+
unmapped.push(name)
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
// Read in the same call as the names, so a property without an id means the draft moved mid-command.
|
|
66
|
+
if (!property.id) {
|
|
67
|
+
throw new CliError(`Property "${name}" is no longer on the draft.`, {
|
|
68
|
+
code: 'CONFLICT',
|
|
69
|
+
hint: 'Re-run the command against the current draft.',
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
const resolved = typeof entry === 'string' ? { column: entry } : entry
|
|
73
|
+
columnMappings.push({
|
|
74
|
+
propertyId: property.id,
|
|
75
|
+
column: resolved.column,
|
|
76
|
+
...(resolved.field === undefined || resolved.field === null ? {} : { field: resolved.field }),
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
return { columnMappings, unmapped }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The `update_source_binding` draft command that pins a type to a Dataset revision. */
|
|
83
|
+
export function datasetBindingCommand(
|
|
84
|
+
objectId: string,
|
|
85
|
+
datasetRevisionId: string,
|
|
86
|
+
columnMappings: ColumnMapping[],
|
|
87
|
+
acceptContractChange: boolean,
|
|
88
|
+
): Record<string, unknown> {
|
|
89
|
+
return {
|
|
90
|
+
kind: 'update_source_binding',
|
|
91
|
+
objectId,
|
|
92
|
+
binding: {
|
|
93
|
+
kind: 'dataset',
|
|
94
|
+
datasetRevisionId,
|
|
95
|
+
columnMappings,
|
|
96
|
+
...(acceptContractChange ? { acceptContractChange: true } : {}),
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/blueprint/diff.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { actionWithoutMintedIds } from './compile'
|
|
2
|
+
import { columnMappingsFrom, type ColumnMapping, type MappingEntry } from './dataset-revision'
|
|
2
3
|
import { toFiles, withoutImpliedTiming } from './projection'
|
|
3
4
|
import {
|
|
4
5
|
ARTIFACT_KINDS,
|
|
@@ -61,11 +62,132 @@ export interface Plan {
|
|
|
61
62
|
*/
|
|
62
63
|
withheld?: ModelOperation[]
|
|
63
64
|
drift: BindingDrift[]
|
|
65
|
+
/** Object types whose `backing.editable` differs from the draft's editable set. */
|
|
66
|
+
editable: EditableChange[]
|
|
67
|
+
/** Object types whose `backing.dataset` names another Dataset than the draft reads. */
|
|
68
|
+
rebind: RebindChange[]
|
|
64
69
|
unchanged: number
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/**
|
|
73
|
+
* One object type moving to the Dataset its file names.
|
|
74
|
+
*
|
|
75
|
+
* Its own list for the reason `EditableChange` is: it travels its own draft command
|
|
76
|
+
* (`update_source_binding`, the one `bind` sends). Either it carries that command's
|
|
77
|
+
* pin and mapping, or the reason it cannot be sent and what to run instead.
|
|
78
|
+
*/
|
|
79
|
+
export type RebindChange = {
|
|
80
|
+
objectApiName: string
|
|
81
|
+
objectId: string
|
|
82
|
+
/** The Dataset the draft reads; null for a type with no Dataset yet. */
|
|
83
|
+
from: string | null
|
|
84
|
+
to: string
|
|
85
|
+
} & (
|
|
86
|
+
| { datasetRevisionId: string; columnMappings: ColumnMapping[] }
|
|
87
|
+
| { refused: string; next: string }
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
/** What the Datasets say, read once per plan. */
|
|
91
|
+
export interface DatasetIndex {
|
|
92
|
+
nameByRevisionId: Map<string, string>
|
|
93
|
+
/** Dataset name → its current revision; `columns` absent when the revision list did not carry them. */
|
|
94
|
+
current: Map<string, { id: string; schemaDigest?: string; columns?: string[] }>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The object types whose file names a Dataset the draft does not read.
|
|
99
|
+
*
|
|
100
|
+
* Each property keeps the column it reads: the file's `backing.mapping` first, then
|
|
101
|
+
* the draft's current mapping. A mapped column the new revision does not publish is
|
|
102
|
+
* refused here, before any write; a column whose type moved is the service's refusal.
|
|
103
|
+
* A pin whose Dataset this credential cannot name is left alone, never guessed.
|
|
104
|
+
*/
|
|
105
|
+
export function rebindChanges(files: AuthoredFile[], live: DefinitionBundle, datasets: DatasetIndex): RebindChange[] {
|
|
106
|
+
const changes: RebindChange[] = []
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
if (file.kind !== 'object-type') continue
|
|
109
|
+
const backing = file.document.backing as { dataset?: unknown; mapping?: Record<string, MappingEntry | null> } | undefined
|
|
110
|
+
const to = backing?.dataset
|
|
111
|
+
if (typeof to !== 'string' || !to) continue
|
|
112
|
+
const object = live.objects.find((candidate) => candidate.apiName === file.apiName)
|
|
113
|
+
if (!object) continue
|
|
114
|
+
const pin = object.governance.sourceMappings[0] as
|
|
115
|
+
| { datasetRevisionId?: string; propertyMappings?: Array<{ propertyId: string; sourcePath: string; sourceField?: string | null }> }
|
|
116
|
+
| undefined
|
|
117
|
+
const from = pin?.datasetRevisionId ? datasets.nameByRevisionId.get(pin.datasetRevisionId) : null
|
|
118
|
+
if (from === undefined || from === to) continue
|
|
119
|
+
|
|
120
|
+
const base = { objectApiName: file.apiName, objectId: object.id, from, to }
|
|
121
|
+
const next = `frontera blueprint bind ${file.apiName} --dataset ${to} --plan ./map.json`
|
|
122
|
+
const target = datasets.current.get(to)
|
|
123
|
+
if (!target) {
|
|
124
|
+
changes.push({ ...base, refused: `no Dataset named "${to}" is visible`, next: 'frontera dataset list' })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
const kept: Record<string, MappingEntry> = {}
|
|
128
|
+
for (const entry of pin?.propertyMappings ?? []) {
|
|
129
|
+
const name = object.properties.find((property) => property.id === entry.propertyId)?.apiName
|
|
130
|
+
if (name) kept[name] = entry.sourceField ? { column: entry.sourcePath, field: entry.sourceField } : entry.sourcePath
|
|
131
|
+
}
|
|
132
|
+
const { columnMappings, unmapped } = columnMappingsFrom(object.properties, { ...kept, ...backing?.mapping })
|
|
133
|
+
if (unmapped.length) {
|
|
134
|
+
changes.push({ ...base, refused: `no column mapped for ${unmapped.join(', ')}`, next })
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
const published = target.columns ? new Set(target.columns) : null
|
|
138
|
+
const missing = published ? columnMappings.filter((mapping) => !published.has(mapping.column)) : []
|
|
139
|
+
if (missing.length) {
|
|
140
|
+
const nameOf = (id: string) => object.properties.find((property) => property.id === id)?.apiName ?? id
|
|
141
|
+
changes.push({
|
|
142
|
+
...base,
|
|
143
|
+
refused: `"${to}" does not publish ${missing.map((mapping) => `${mapping.column} (read by ${nameOf(mapping.propertyId)})`).join(', ')}`,
|
|
144
|
+
next,
|
|
145
|
+
})
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
changes.push({ ...base, datasetRevisionId: target.id, columnMappings })
|
|
149
|
+
}
|
|
150
|
+
return changes
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One object type's editable set, as its file declares it and as the draft has it.
|
|
155
|
+
*
|
|
156
|
+
* Its own list rather than an object-type update, because it travels its own draft
|
|
157
|
+
* command (`set_editable_properties`), not the object-type patch, and because a type
|
|
158
|
+
* created in this run has an editable set only after its create answers.
|
|
159
|
+
*/
|
|
160
|
+
export interface EditableChange {
|
|
161
|
+
objectApiName: string
|
|
162
|
+
/** API names, sorted. Empty for a type the draft does not have yet. */
|
|
163
|
+
from: string[]
|
|
164
|
+
to: string[]
|
|
165
|
+
}
|
|
166
|
+
|
|
67
167
|
export function isEmpty(plan: Plan): boolean {
|
|
68
|
-
return plan.operations.length === 0 && plan.prunes.length === 0
|
|
168
|
+
return plan.operations.length === 0 && plan.prunes.length === 0 && plan.editable.length === 0 && plan.rebind.length === 0
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The editable sets the files declare that the draft does not have.
|
|
173
|
+
*
|
|
174
|
+
* Only a file that states `backing.editable` has an opinion, as for every other
|
|
175
|
+
* field: one that leaves the key out keeps whatever the draft holds, and
|
|
176
|
+
* `editable: []` asks for none.
|
|
177
|
+
*/
|
|
178
|
+
export function editableChanges(files: AuthoredFile[], live: DefinitionBundle): EditableChange[] {
|
|
179
|
+
const changes: EditableChange[] = []
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
if (file.kind !== 'object-type') continue
|
|
182
|
+
const declared = (file.document.backing as { editable?: unknown } | undefined)?.editable
|
|
183
|
+
if (!Array.isArray(declared)) continue
|
|
184
|
+
const to = [...new Set(declared.map(String))].sort()
|
|
185
|
+
const object = live.objects.find((candidate) => candidate.apiName === file.apiName)
|
|
186
|
+
const ids = (object?.governance.sourceMappings[0] as { editableProperties?: string[] } | undefined)?.editableProperties ?? []
|
|
187
|
+
const from = ids.flatMap((id) => object?.properties.find((property) => property.id === id)?.apiName ?? []).sort()
|
|
188
|
+
if (from.join('\n') !== to.join('\n')) changes.push({ objectApiName: file.apiName, from, to })
|
|
189
|
+
}
|
|
190
|
+
return changes
|
|
69
191
|
}
|
|
70
192
|
|
|
71
193
|
/**
|
|
@@ -91,9 +213,9 @@ export function isEmpty(plan: Plan): boolean {
|
|
|
91
213
|
* field the plan never called changed.
|
|
92
214
|
*/
|
|
93
215
|
export function satisfies(local: unknown, live: unknown, key?: string): boolean {
|
|
94
|
-
// `backing` is
|
|
95
|
-
//
|
|
96
|
-
//
|
|
216
|
+
// `backing` is compared on its own: `editableChanges` and `rebindChanges` plan
|
|
217
|
+
// the editable set and a new Dataset. A column edited in place stays `bind`'s,
|
|
218
|
+
// so `apply` never repoints a property because a mapping line changed.
|
|
97
219
|
if (key === 'backing') return true
|
|
98
220
|
|
|
99
221
|
if (Array.isArray(local)) {
|
|
@@ -227,6 +349,8 @@ export function diff(
|
|
|
227
349
|
localFiles: AuthoredFile[],
|
|
228
350
|
live: DefinitionBundle,
|
|
229
351
|
revision: number,
|
|
352
|
+
/** Absent when the Datasets were not read: no rebind is planned. */
|
|
353
|
+
datasets?: DatasetIndex,
|
|
230
354
|
): Plan {
|
|
231
355
|
const liveModel = toFiles(live)
|
|
232
356
|
const liveByKey = new Map(liveModel.files.map((file) => [`${file.kind}:${file.apiName}`, file]))
|
|
@@ -234,6 +358,10 @@ export function diff(
|
|
|
234
358
|
|
|
235
359
|
const operations: ModelOperation[] = []
|
|
236
360
|
const prunes: ModelOperation[] = []
|
|
361
|
+
const editable = editableChanges(localFiles, live)
|
|
362
|
+
const rebind = datasets ? rebindChanges(localFiles, live, datasets) : []
|
|
363
|
+
// A type whose only difference is its editable set or its Dataset is listed there, not as unchanged.
|
|
364
|
+
const editableNames = new Set([...editable, ...rebind].map((change) => change.objectApiName))
|
|
237
365
|
let unchanged = 0
|
|
238
366
|
|
|
239
367
|
// Fixed kind order so the plan reads the same way every run, and so creations of
|
|
@@ -255,7 +383,7 @@ export function diff(
|
|
|
255
383
|
continue
|
|
256
384
|
}
|
|
257
385
|
if (matches(kind, file.document, existing.document)) {
|
|
258
|
-
unchanged += 1
|
|
386
|
+
if (kind !== 'object-type' || !editableNames.has(file.apiName)) unchanged += 1
|
|
259
387
|
continue
|
|
260
388
|
}
|
|
261
389
|
operations.push({
|
|
@@ -278,7 +406,7 @@ export function diff(
|
|
|
278
406
|
}
|
|
279
407
|
}
|
|
280
408
|
|
|
281
|
-
return { revision, operations, prunes, drift: driftOf(live), unchanged }
|
|
409
|
+
return { revision, operations, prunes, drift: driftOf(live), editable, rebind, unchanged }
|
|
282
410
|
}
|
|
283
411
|
|
|
284
412
|
/**
|