@frontera-sdk/cli 1.43.10 → 1.44.1
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/README.md +140 -12
- package/package.json +3 -3
- package/src/adopt.ts +436 -0
- package/src/api/apps-api.ts +30 -0
- package/src/api/blueprint-authoring-api.ts +13 -2
- package/src/api/governed-action-api.ts +192 -0
- package/src/api/platform-api.ts +4 -0
- package/src/blueprint/ontology-edit-plan.ts +195 -0
- package/src/blueprint-types.ts +252 -0
- package/src/commands/action/deploy.ts +135 -0
- package/src/commands/action/grant.ts +68 -0
- package/src/commands/action/index-commands.ts +29 -0
- package/src/commands/action/list.ts +49 -0
- package/src/commands/action/prepare.ts +48 -0
- package/src/commands/action/review.ts +94 -0
- package/src/commands/app/deploy.ts +16 -5
- package/src/commands/app/dev.ts +173 -0
- package/src/commands/app/init.ts +270 -28
- package/src/commands/app/sdk.ts +31 -0
- package/src/commands/app/versions.ts +8 -1
- package/src/commands/blueprint/editable.ts +151 -0
- package/src/commands/blueprint/generate-types.ts +58 -0
- package/src/commands/blueprint/get.ts +29 -34
- package/src/commands/blueprint/list.ts +2 -1
- package/src/commands/registry.ts +12 -0
- package/src/context.ts +4 -4
- package/src/dev-broker.ts +71 -0
- package/src/flag-help.ts +24 -1
- package/src/heal.ts +37 -2
- package/src/manifest.ts +89 -8
- package/src/packaging.ts +6 -0
- package/src/project-bootstrap.ts +176 -0
- package/src/project.ts +68 -35
- package/src/provenance.ts +89 -0
- package/src/render-evidence.ts +28 -0
- package/src/sdk-sync.ts +41 -0
- package/src/shadcn-components.ts +106 -0
- package/src/static-app-validation.ts +67 -0
- package/src/template.ts +211 -32
- package/src/templates/next-app-files.ts +1052 -0
- package/src/templates/next-skills.ts +1216 -0
- package/src/vendor/sdk-sources.json +21 -15
|
@@ -162,13 +162,24 @@ export class BlueprintAuthoringApi {
|
|
|
162
162
|
async getObjectTypeDetail(apiName: string): Promise<{
|
|
163
163
|
id?: string
|
|
164
164
|
properties: Array<{ id?: string; apiName?: string }>
|
|
165
|
+
/**
|
|
166
|
+
* Property API NAMES — the draft stores ids, and the catalog resolves them
|
|
167
|
+
* before it answers. Carried here rather than read back through a second
|
|
168
|
+
* call because `editable` has to show the current set before it can add to
|
|
169
|
+
* or remove from it.
|
|
170
|
+
*/
|
|
171
|
+
editableProperties: string[]
|
|
165
172
|
} | null> {
|
|
166
173
|
const payload = await this.call<{
|
|
167
|
-
objectType?: { id?: string }
|
|
174
|
+
objectType?: { id?: string; editableProperties?: string[] }
|
|
168
175
|
properties?: Array<{ id?: string; apiName?: string }>
|
|
169
176
|
}>(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}?view=draft`)
|
|
170
177
|
if (!payload?.objectType?.id) return null
|
|
171
|
-
return {
|
|
178
|
+
return {
|
|
179
|
+
id: payload.objectType.id,
|
|
180
|
+
properties: payload.properties ?? [],
|
|
181
|
+
editableProperties: payload.objectType.editableProperties ?? [],
|
|
182
|
+
}
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
/** The draft catalog — what an organization key can actually see. */
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
import type { ObjectTypeShape, PublishedActionDefinition } from '../blueprint/ontology-edit-plan'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Action deployment plane.
|
|
6
|
+
*
|
|
7
|
+
* Separate from `BlueprintAuthoringApi` because it is a separate plane: the
|
|
8
|
+
* Blueprint draft-and-release lifecycle publishes the CONTRACT, and these
|
|
9
|
+
* routes arm the WRITE. They carry their own capability family
|
|
10
|
+
* (`actionMutationPlan`, `actionBinding`), their own revisions, and their own
|
|
11
|
+
* state machine, and a credential holding one says nothing about the other.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface Envelope<T> { error?: boolean; data?: T }
|
|
15
|
+
|
|
16
|
+
export class GovernedActionApi {
|
|
17
|
+
constructor(private readonly apiUrl: string, private readonly token: string) {}
|
|
18
|
+
|
|
19
|
+
private async call<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
|
20
|
+
const response = await fetch(`${this.apiUrl}${path}`, {
|
|
21
|
+
method: init.method ?? 'GET',
|
|
22
|
+
headers: {
|
|
23
|
+
authorization: `Bearer ${this.token}`,
|
|
24
|
+
...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
25
|
+
},
|
|
26
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
27
|
+
})
|
|
28
|
+
const text = await response.text()
|
|
29
|
+
let payload: unknown
|
|
30
|
+
try { payload = text ? JSON.parse(text) : null } catch { payload = null }
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const body = payload as { message?: string; code?: string; details?: unknown } | null
|
|
34
|
+
throw new CliError(body?.message ?? `${response.status} from ${path}`, {
|
|
35
|
+
code: body?.code ?? 'FAILURE',
|
|
36
|
+
...(body?.details ? { hint: JSON.stringify(body.details) } : {}),
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
const envelope = payload as Envelope<T>
|
|
40
|
+
return (envelope && envelope.error === false && 'data' in envelope
|
|
41
|
+
? envelope.data
|
|
42
|
+
: payload) as T
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The published Action: its definition and the digest a Binding pins. */
|
|
46
|
+
publishedAction(apiName: string): Promise<{
|
|
47
|
+
definition: PublishedActionDefinition
|
|
48
|
+
contractDigest: string
|
|
49
|
+
availability?: string
|
|
50
|
+
deploymentStatus?: string
|
|
51
|
+
reason?: string
|
|
52
|
+
}> {
|
|
53
|
+
return this.call(`/v1/blueprint/actions/${encodeURIComponent(apiName)}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
listPublishedActions(): Promise<Array<{
|
|
57
|
+
definition: { apiName: string; displayName?: string }
|
|
58
|
+
deploymentStatus?: string
|
|
59
|
+
availability?: string
|
|
60
|
+
reason?: string
|
|
61
|
+
}>> {
|
|
62
|
+
return this.call('/v1/blueprint/actions')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The ACTIVE view, never the draft: a deployment is checked against the
|
|
67
|
+
* release the write path will actually read, and a draft-only property would
|
|
68
|
+
* validate here and fail at dispatch.
|
|
69
|
+
*/
|
|
70
|
+
async activeObjectType(apiName: string): Promise<ObjectTypeShape | null> {
|
|
71
|
+
const payload = await this.call<{
|
|
72
|
+
objectType?: { id?: string; apiName?: string; primaryKeyPropertyId?: string | null }
|
|
73
|
+
properties?: Array<{ id?: string; apiName?: string }>
|
|
74
|
+
}>(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}`)
|
|
75
|
+
if (!payload?.objectType?.id) return null
|
|
76
|
+
return {
|
|
77
|
+
id: payload.objectType.id,
|
|
78
|
+
apiName: payload.objectType.apiName ?? apiName,
|
|
79
|
+
primaryKeyPropertyId: payload.objectType.primaryKeyPropertyId ?? null,
|
|
80
|
+
properties: payload.properties ?? [],
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The first deployment decision, taken against the DRAFT — the only place an
|
|
86
|
+
* Action lives before it has ever published.
|
|
87
|
+
*/
|
|
88
|
+
recordDeploymentDecision(actionApiName: string, reason: string): Promise<{
|
|
89
|
+
stateId: string
|
|
90
|
+
bindingId: string
|
|
91
|
+
actionDefinitionId: string
|
|
92
|
+
actionContractDigest: string
|
|
93
|
+
state: string
|
|
94
|
+
generation: number
|
|
95
|
+
}> {
|
|
96
|
+
return this.call('/v1/blueprint/governed-actions/admin/deployments', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
body: { actionApiName, reason },
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One Action's invoke capability on one role. Not role editing: the service
|
|
104
|
+
* refuses a platform resource, and refuses a capability no published Action
|
|
105
|
+
* declares.
|
|
106
|
+
*/
|
|
107
|
+
setCapabilityGrant(capability: string, role: string, revoke: boolean): Promise<{
|
|
108
|
+
role: string
|
|
109
|
+
capability: string
|
|
110
|
+
actions: string[]
|
|
111
|
+
granted: boolean
|
|
112
|
+
roleMaySubmit: boolean
|
|
113
|
+
}> {
|
|
114
|
+
return this.call('/v1/blueprint/governed-actions/admin/capability-grants', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body: { capability, role, revoke },
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The active catalog, for turning the subject's id into the name routes take. */
|
|
121
|
+
listActiveObjectTypes(): Promise<Array<{ id?: string; apiName?: string }>> {
|
|
122
|
+
return this.call('/v1/blueprint/object-types')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
createMutationPlanRevision(planId: string, plan: unknown): Promise<{ id: string }> {
|
|
126
|
+
return this.call(`/v1/blueprint/governed-actions/admin/mutation-plans/${planId}/revisions`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
body: { expectedRevision: 0, parentRevisionId: null, plan },
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* No `connectorRevisionId`: an ontology edit writes through an internal
|
|
134
|
+
* identity the deployment mints for itself, and the service provisions it
|
|
135
|
+
* precisely when the field is absent.
|
|
136
|
+
*/
|
|
137
|
+
createBindingRevision(bindingId: string, input: {
|
|
138
|
+
actionDefinitionId: string
|
|
139
|
+
actionContractDigest: string
|
|
140
|
+
mutationPlanRevisionId: string
|
|
141
|
+
}): Promise<{ id: string; stateId: string }> {
|
|
142
|
+
return this.call(`/v1/blueprint/governed-actions/admin/bindings/${bindingId}/revisions`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
body: { expectedRevision: 0, parentRevisionId: null, ...input },
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
validateBindingRevision(revisionId: string, expectedStateGeneration: number): Promise<{
|
|
149
|
+
id: string
|
|
150
|
+
status: string
|
|
151
|
+
findings?: unknown
|
|
152
|
+
}> {
|
|
153
|
+
return this.call(
|
|
154
|
+
`/v1/blueprint/governed-actions/admin/binding-revisions/${revisionId}/validate`,
|
|
155
|
+
{ method: 'POST', body: { expectedStateGeneration } },
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
reviewBindingRevision(revisionId: string, input: {
|
|
160
|
+
validationReportId: string
|
|
161
|
+
expectedStateGeneration: number
|
|
162
|
+
}): Promise<unknown> {
|
|
163
|
+
return this.call(
|
|
164
|
+
`/v1/blueprint/governed-actions/admin/binding-revisions/${revisionId}/review`,
|
|
165
|
+
{ method: 'POST', body: input },
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
activateDeployment(stateId: string, input: {
|
|
170
|
+
expectedGeneration: number
|
|
171
|
+
expectedCurrentStateId: string
|
|
172
|
+
expectedCurrentGeneration: number
|
|
173
|
+
reason: string
|
|
174
|
+
}): Promise<unknown> {
|
|
175
|
+
return this.call(
|
|
176
|
+
`/v1/blueprint/governed-actions/admin/deployments/${stateId}/activate`,
|
|
177
|
+
{ method: 'POST', body: input },
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
listBindings(): Promise<{
|
|
182
|
+
entries: Array<{
|
|
183
|
+
bindingId: string
|
|
184
|
+
revisionId: string
|
|
185
|
+
actionDefinitionId: string
|
|
186
|
+
deployment?: { stateId?: string; state?: string; generation?: number }
|
|
187
|
+
}>
|
|
188
|
+
hasMore?: boolean
|
|
189
|
+
}> {
|
|
190
|
+
return this.call('/v1/blueprint/governed-actions/admin/bindings')
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/api/platform-api.ts
CHANGED
|
@@ -79,6 +79,10 @@ export class PlatformApi {
|
|
|
79
79
|
return this.getList<unknown>('/v1/blueprint/object-types')
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
blueprintSchema() {
|
|
83
|
+
return this.get<unknown>('/v1/blueprint/schema')
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
blueprintObjectType(apiName: string) {
|
|
83
87
|
return this.get<unknown>(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}`)
|
|
84
88
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Derive an `ontology_edit` mutation plan from a published Action.
|
|
5
|
+
*
|
|
6
|
+
* The plan is not authored: everything in it is already stated by the Action
|
|
7
|
+
* and the object type it acts on. The Action says which properties it sets and
|
|
8
|
+
* which parameter feeds each one (`businessOutcomes[].expectedChanges`); the
|
|
9
|
+
* object type says which property is the key. Asking an operator to restate
|
|
10
|
+
* that in a hand-written JSON file would be asking them to reproduce, by hand,
|
|
11
|
+
* a mapping the platform can already read — and a typo there binds the write
|
|
12
|
+
* path to the wrong column.
|
|
13
|
+
*
|
|
14
|
+
* Pure and separately tested, because this is the one part of `action deploy`
|
|
15
|
+
* whose output is a contract rather than a call.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface PublishedActionDefinition {
|
|
19
|
+
id: string
|
|
20
|
+
apiName: string
|
|
21
|
+
subject: { objectTypeId: string; mode: 'existing' | 'create' }
|
|
22
|
+
effect: { kind: 'create' | 'update' | 'delete' | 'transition' | 'command' }
|
|
23
|
+
inputs: Array<{ id: string; apiName: string }>
|
|
24
|
+
concurrency: { strategy: string }
|
|
25
|
+
businessOutcomes: Array<{
|
|
26
|
+
expectedChanges?: Array<Record<string, unknown>>
|
|
27
|
+
}>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ObjectTypeShape {
|
|
31
|
+
id: string
|
|
32
|
+
apiName: string
|
|
33
|
+
primaryKeyPropertyId: string | null
|
|
34
|
+
properties: Array<{ id?: string; apiName?: string }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface OntologyEditPlan {
|
|
38
|
+
schemaVersion: 1
|
|
39
|
+
kind: 'ontology_edit'
|
|
40
|
+
targetCompatibility: { kind: 'ontology_edit'; expectedFingerprint: string }
|
|
41
|
+
objectTypeId: string
|
|
42
|
+
objectTypeApiName: string
|
|
43
|
+
op: 'patch' | 'create' | 'delete'
|
|
44
|
+
/** property apiName → parameter apiName. */
|
|
45
|
+
set: Record<string, string>
|
|
46
|
+
pkParameter: string
|
|
47
|
+
expectedVersionParameter?: string
|
|
48
|
+
impactBounds: { maxAffectedRows: number; maxTouchedRows: number }
|
|
49
|
+
capabilities: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class PlanDerivationError extends Error {
|
|
53
|
+
constructor(message: string, readonly hint: string) {
|
|
54
|
+
super(message)
|
|
55
|
+
this.name = 'PlanDerivationError'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The service's `ontologyEditCompatibilityFingerprint`, reproduced.
|
|
61
|
+
*
|
|
62
|
+
* Duplicated rather than imported because the CLI ships as a standalone binary
|
|
63
|
+
* and cannot reach into the service. The shape is two fields — a string and a
|
|
64
|
+
* sorted unique string array — so canonical JSON here is ordinary
|
|
65
|
+
* `JSON.stringify` with the keys already in lexicographic order. A fixture in
|
|
66
|
+
* both test suites pins the same literal digest, so a change to either side
|
|
67
|
+
* fails a test rather than silently deploying a binding the inspector will
|
|
68
|
+
* refuse.
|
|
69
|
+
*/
|
|
70
|
+
export function ontologyEditCompatibilityFingerprint(
|
|
71
|
+
objectTypeId: string,
|
|
72
|
+
usedProperties: readonly string[],
|
|
73
|
+
): string {
|
|
74
|
+
const canonical = JSON.stringify({
|
|
75
|
+
objectTypeId,
|
|
76
|
+
usedProperties: [...new Set(usedProperties)].sort(),
|
|
77
|
+
})
|
|
78
|
+
return `sha256:${createHash('sha256').update(canonical).digest('hex')}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const OP_FOR_EFFECT: Readonly<Record<string, OntologyEditPlan['op']>> = {
|
|
82
|
+
update: 'patch',
|
|
83
|
+
create: 'create',
|
|
84
|
+
delete: 'delete',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function deriveOntologyEditPlan(
|
|
88
|
+
action: PublishedActionDefinition,
|
|
89
|
+
objectType: ObjectTypeShape,
|
|
90
|
+
): OntologyEditPlan {
|
|
91
|
+
const op = OP_FOR_EFFECT[action.effect.kind]
|
|
92
|
+
if (!op) {
|
|
93
|
+
throw new PlanDerivationError(
|
|
94
|
+
`Action "${action.apiName}" has effect kind "${action.effect.kind}", which is not an ontology edit.`,
|
|
95
|
+
'Only create, update and delete write to the Blueprint. transition and command are external effects.',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
if (action.subject.objectTypeId !== objectType.id) {
|
|
99
|
+
throw new PlanDerivationError(
|
|
100
|
+
`Action "${action.apiName}" acts on a different object type than "${objectType.apiName}".`,
|
|
101
|
+
'Re-read the Action; its subject moved.',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const propertyApiNameById = new Map(
|
|
106
|
+
objectType.properties.flatMap((p) => (p.id && p.apiName ? [[p.id, p.apiName] as const] : [])),
|
|
107
|
+
)
|
|
108
|
+
const inputApiNameById = new Map(action.inputs.map((i) => [i.id, i.apiName] as const))
|
|
109
|
+
|
|
110
|
+
// Every property this Action declares it sets, and the parameter that feeds
|
|
111
|
+
// it. Read from the Action rather than from a flag: this IS the rule, and a
|
|
112
|
+
// deployment that wrote a different set would be executing something other
|
|
113
|
+
// than what was published and reviewed.
|
|
114
|
+
const set: Record<string, string> = {}
|
|
115
|
+
for (const outcome of action.businessOutcomes) {
|
|
116
|
+
for (const change of outcome.expectedChanges ?? []) {
|
|
117
|
+
if (change.kind !== 'property_set') continue
|
|
118
|
+
const value = change.value as { kind?: string; fieldId?: string } | undefined
|
|
119
|
+
if (value?.kind !== 'input' || !value.fieldId) continue
|
|
120
|
+
const property = propertyApiNameById.get(String(change.propertyId))
|
|
121
|
+
const parameter = inputApiNameById.get(value.fieldId)
|
|
122
|
+
if (!property || !parameter) continue
|
|
123
|
+
set[property] = parameter
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (op !== 'delete' && Object.keys(set).length === 0) {
|
|
128
|
+
throw new PlanDerivationError(
|
|
129
|
+
`Action "${action.apiName}" sets no properties, so there is nothing to deploy.`,
|
|
130
|
+
'Open the Action and tick at least one property under "What it changes".',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The key is addressed, never set — an editable key could move a row out from
|
|
135
|
+
// under its own identity, and the service refuses one.
|
|
136
|
+
const pkApiName = objectType.primaryKeyPropertyId
|
|
137
|
+
? propertyApiNameById.get(objectType.primaryKeyPropertyId)
|
|
138
|
+
: undefined
|
|
139
|
+
if (!pkApiName) {
|
|
140
|
+
throw new PlanDerivationError(
|
|
141
|
+
`Object type "${objectType.apiName}" has no primary key.`,
|
|
142
|
+
'Set one with `frontera blueprint update object-type`, then publish.',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
const pkParameter = action.inputs.find((i) => i.apiName === pkApiName)?.apiName
|
|
146
|
+
if (!pkParameter) {
|
|
147
|
+
throw new PlanDerivationError(
|
|
148
|
+
`Action "${action.apiName}" takes no "${pkApiName}" parameter, so an edit has no address.`,
|
|
149
|
+
`Add a required "${pkApiName}" parameter to the Action, then publish.`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Only when the Action asked for it. Compare-and-set is the Action's
|
|
154
|
+
// concurrency contract, not a deployment preference.
|
|
155
|
+
const expectedVersionParameter = action.concurrency.strategy === 'expected_version'
|
|
156
|
+
? action.inputs.find((i) => i.apiName === 'expectedVersion')?.apiName
|
|
157
|
+
: undefined
|
|
158
|
+
if (action.concurrency.strategy === 'expected_version' && !expectedVersionParameter) {
|
|
159
|
+
throw new PlanDerivationError(
|
|
160
|
+
`Action "${action.apiName}" declares expected_version concurrency but takes no "expectedVersion" parameter.`,
|
|
161
|
+
'Add it to the Action, or change the concurrency strategy.',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
schemaVersion: 1,
|
|
167
|
+
kind: 'ontology_edit',
|
|
168
|
+
targetCompatibility: {
|
|
169
|
+
kind: 'ontology_edit',
|
|
170
|
+
expectedFingerprint: ontologyEditCompatibilityFingerprint(
|
|
171
|
+
objectType.id,
|
|
172
|
+
Object.keys(set),
|
|
173
|
+
),
|
|
174
|
+
},
|
|
175
|
+
objectTypeId: objectType.id,
|
|
176
|
+
objectTypeApiName: objectType.apiName,
|
|
177
|
+
op,
|
|
178
|
+
set,
|
|
179
|
+
pkParameter,
|
|
180
|
+
...(expectedVersionParameter ? { expectedVersionParameter } : {}),
|
|
181
|
+
// One subject, because the Action itself declares `impact.maxSubjects: 1`
|
|
182
|
+
// and the schema admits no other value.
|
|
183
|
+
impactBounds: { maxAffectedRows: 1, maxTouchedRows: 1 },
|
|
184
|
+
// Properties of the in-process edit path, not choices: it runs in one
|
|
185
|
+
// transaction against our own store, so every one of these is true by
|
|
186
|
+
// construction. They are stated because the inspector checks them.
|
|
187
|
+
capabilities: {
|
|
188
|
+
conditionalMutation: true,
|
|
189
|
+
targetIdempotency: 'transactional_ledger',
|
|
190
|
+
authoritativeReconciliation: true,
|
|
191
|
+
atomicEffect: true,
|
|
192
|
+
readYourWrites: true,
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { CliError, UsageError } from './errors'
|
|
5
|
+
|
|
6
|
+
export const BLUEPRINT_TYPES_MARKER = '// Generated by `frontera blueprint generate-types`. Do not edit.'
|
|
7
|
+
export const DEFAULT_BLUEPRINT_TYPES_OUTPUT = 'src/generated/frontera-blueprint.ts'
|
|
8
|
+
|
|
9
|
+
export type BlueprintDataType = 'string' | 'number' | 'boolean' | 'date' | 'timestamp' | 'json'
|
|
10
|
+
export type BlueprintPropertyType = 'attribute' | 'measure' | 'time'
|
|
11
|
+
|
|
12
|
+
export interface BlueprintSchemaProperty {
|
|
13
|
+
apiName: string
|
|
14
|
+
displayName: string
|
|
15
|
+
description: string | null
|
|
16
|
+
propertyType: BlueprintPropertyType
|
|
17
|
+
dataType: BlueprintDataType
|
|
18
|
+
nullable: boolean
|
|
19
|
+
filterable: boolean
|
|
20
|
+
sortable: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BlueprintSchemaObject {
|
|
24
|
+
apiName: string
|
|
25
|
+
displayName: string
|
|
26
|
+
description: string | null
|
|
27
|
+
properties: BlueprintSchemaProperty[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BlueprintSchemaResponse {
|
|
31
|
+
schemaVersion: 1
|
|
32
|
+
digest: `sha256:${string}`
|
|
33
|
+
objectTypes: BlueprintSchemaObject[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DATA_TYPES = new Set<BlueprintDataType>(['string', 'number', 'boolean', 'date', 'timestamp', 'json'])
|
|
37
|
+
const PROPERTY_TYPES = new Set<BlueprintPropertyType>(['attribute', 'measure', 'time'])
|
|
38
|
+
|
|
39
|
+
function schemaContractError(detail: string): never {
|
|
40
|
+
throw new CliError(`invalid Blueprint schema response: ${detail}`, {
|
|
41
|
+
code: 'BAD_REQUEST',
|
|
42
|
+
hint: 'upgrade the Frontera CLI or confirm the API origin is running a compatible service',
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseBlueprintSchema(input: unknown): BlueprintSchemaResponse {
|
|
47
|
+
if (!input || typeof input !== 'object') return schemaContractError('expected an object')
|
|
48
|
+
const schema = input as Record<string, unknown>
|
|
49
|
+
if (schema.schemaVersion !== 1) {
|
|
50
|
+
return schemaContractError(`unsupported schema version ${String(schema.schemaVersion)}`)
|
|
51
|
+
}
|
|
52
|
+
if (typeof schema.digest !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(schema.digest)) {
|
|
53
|
+
return schemaContractError('digest must be a sha256 value')
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(schema.objectTypes)) return schemaContractError('objectTypes must be an array')
|
|
56
|
+
|
|
57
|
+
const objectNames = new Set<string>()
|
|
58
|
+
for (const [objectIndex, candidate] of schema.objectTypes.entries()) {
|
|
59
|
+
if (!candidate || typeof candidate !== 'object') return schemaContractError(`objectTypes[${objectIndex}] must be an object`)
|
|
60
|
+
const objectType = candidate as Record<string, unknown>
|
|
61
|
+
if (typeof objectType.apiName !== 'string' || !/^[A-Z][A-Za-z0-9]{0,99}$/.test(objectType.apiName)) {
|
|
62
|
+
return schemaContractError(`objectTypes[${objectIndex}].apiName is invalid`)
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
typeof objectType.displayName !== 'string'
|
|
66
|
+
|| !(objectType.description === null || typeof objectType.description === 'string')
|
|
67
|
+
|| !Array.isArray(objectType.properties)
|
|
68
|
+
) {
|
|
69
|
+
return schemaContractError(`objectTypes[${objectIndex}] is malformed`)
|
|
70
|
+
}
|
|
71
|
+
if (objectNames.has(objectType.apiName)) return schemaContractError(`duplicate object apiName ${objectType.apiName}`)
|
|
72
|
+
objectNames.add(objectType.apiName)
|
|
73
|
+
const propertyNames = new Set<string>()
|
|
74
|
+
for (const [propertyIndex, propertyCandidate] of objectType.properties.entries()) {
|
|
75
|
+
if (!propertyCandidate || typeof propertyCandidate !== 'object') {
|
|
76
|
+
return schemaContractError(`objectTypes[${objectIndex}].properties[${propertyIndex}] must be an object`)
|
|
77
|
+
}
|
|
78
|
+
const property = propertyCandidate as Record<string, unknown>
|
|
79
|
+
if (typeof property.apiName !== 'string' || !/^[a-z][A-Za-z0-9]{0,99}$/.test(property.apiName)) {
|
|
80
|
+
return schemaContractError(`objectTypes[${objectIndex}].properties[${propertyIndex}].apiName is invalid`)
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
typeof property.displayName !== 'string'
|
|
84
|
+
|| !(property.description === null || typeof property.description === 'string')
|
|
85
|
+
|| !PROPERTY_TYPES.has(property.propertyType as BlueprintPropertyType)
|
|
86
|
+
|| !DATA_TYPES.has(property.dataType as BlueprintDataType)
|
|
87
|
+
|| typeof property.nullable !== 'boolean'
|
|
88
|
+
|| typeof property.filterable !== 'boolean'
|
|
89
|
+
|| typeof property.sortable !== 'boolean'
|
|
90
|
+
) {
|
|
91
|
+
return schemaContractError(`objectTypes[${objectIndex}].properties[${propertyIndex}] is malformed`)
|
|
92
|
+
}
|
|
93
|
+
if (propertyNames.has(property.apiName)) {
|
|
94
|
+
return schemaContractError(`duplicate property apiName ${property.apiName} on ${objectType.apiName}`)
|
|
95
|
+
}
|
|
96
|
+
propertyNames.add(property.apiName)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return input as BlueprintSchemaResponse
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const TYPE_BY_DATA_TYPE: Readonly<Record<BlueprintDataType, string>> = {
|
|
103
|
+
string: 'string',
|
|
104
|
+
number: 'number',
|
|
105
|
+
boolean: 'boolean',
|
|
106
|
+
date: 'string',
|
|
107
|
+
timestamp: 'string',
|
|
108
|
+
json: 'unknown',
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function jsDoc(value: string | null): string[] {
|
|
112
|
+
if (!value) return []
|
|
113
|
+
const safe = value.replace(/\*\//g, '*\\/').replace(/\r?\n/g, ' ')
|
|
114
|
+
return [`/** ${safe} */`]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stringUnion(values: string[]): string {
|
|
118
|
+
return values.length === 0 ? 'never' : values.map((value) => JSON.stringify(value).replace(/^"|"$/g, "'")).join(' | ')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compareApiName(left: { apiName: string }, right: { apiName: string }): number {
|
|
122
|
+
return left.apiName < right.apiName ? -1 : left.apiName > right.apiName ? 1 : 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveBlueprintTypesOutputPath(
|
|
126
|
+
projectRoot: string,
|
|
127
|
+
output = DEFAULT_BLUEPRINT_TYPES_OUTPUT,
|
|
128
|
+
): string {
|
|
129
|
+
const target = resolve(projectRoot, output)
|
|
130
|
+
const fromRoot = relative(resolve(projectRoot), target)
|
|
131
|
+
if (
|
|
132
|
+
!output
|
|
133
|
+
|| isAbsolute(output)
|
|
134
|
+
|| output.split(/[\\/]/).includes('..')
|
|
135
|
+
|| fromRoot === '..'
|
|
136
|
+
|| fromRoot.startsWith(`..${sep}`)
|
|
137
|
+
) {
|
|
138
|
+
throw new UsageError(
|
|
139
|
+
'Blueprint types output must be a project-relative path without `..`',
|
|
140
|
+
'rerun with a path such as `--output src/generated/frontera-blueprint.ts`',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
if (!output.endsWith('.ts') || output.endsWith('.d.ts')) {
|
|
144
|
+
throw new UsageError(
|
|
145
|
+
'Blueprint types output must be a .ts file',
|
|
146
|
+
'rerun with a path such as `--output src/generated/frontera-blueprint.ts`',
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
const realRoot = realpathSync(projectRoot)
|
|
150
|
+
let existingAncestor = target
|
|
151
|
+
while (!existsSync(existingAncestor)) {
|
|
152
|
+
const parent = dirname(existingAncestor)
|
|
153
|
+
if (parent === existingAncestor) break
|
|
154
|
+
existingAncestor = parent
|
|
155
|
+
}
|
|
156
|
+
const realAncestor = realpathSync(existingAncestor)
|
|
157
|
+
const fromRealRoot = relative(realRoot, realAncestor)
|
|
158
|
+
if (isAbsolute(fromRealRoot) || fromRealRoot === '..' || fromRealRoot.startsWith(`..${sep}`)) {
|
|
159
|
+
throw new UsageError(
|
|
160
|
+
'Blueprint types output resolves outside the App project',
|
|
161
|
+
'choose an output directory inside the current App project and rerun generation',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return target
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface BlueprintTypesWriteResult {
|
|
168
|
+
path: string
|
|
169
|
+
changed: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function writeBlueprintTypesFile(
|
|
173
|
+
projectRoot: string,
|
|
174
|
+
output: string | undefined,
|
|
175
|
+
contents: string,
|
|
176
|
+
check: boolean,
|
|
177
|
+
): BlueprintTypesWriteResult {
|
|
178
|
+
const path = resolveBlueprintTypesOutputPath(projectRoot, output)
|
|
179
|
+
const exists = existsSync(path)
|
|
180
|
+
const current = exists ? readFileSync(path, 'utf8') : null
|
|
181
|
+
|
|
182
|
+
if (current === contents) return { path, changed: false }
|
|
183
|
+
if (check) {
|
|
184
|
+
throw new CliError(
|
|
185
|
+
exists ? `Blueprint types are stale at ${path}` : `Blueprint types are missing at ${path}`,
|
|
186
|
+
{ code: 'CONFLICT', hint: 'run `frontera blueprint generate-types` and commit the result' },
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
if (current !== null && !current.startsWith(`${BLUEPRINT_TYPES_MARKER}\n`)) {
|
|
190
|
+
throw new CliError(`refusing to overwrite ${path}: the file is not generated by Frontera`, {
|
|
191
|
+
code: 'BAD_REQUEST',
|
|
192
|
+
hint: 'choose another --output path or move the authored file yourself',
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
197
|
+
const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
|
|
198
|
+
try {
|
|
199
|
+
writeFileSync(temporary, contents)
|
|
200
|
+
renameSync(temporary, path)
|
|
201
|
+
} finally {
|
|
202
|
+
if (existsSync(temporary)) unlinkSync(temporary)
|
|
203
|
+
}
|
|
204
|
+
return { path, changed: true }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function generateBlueprintTypes(schema: BlueprintSchemaResponse): string {
|
|
208
|
+
if (schema.schemaVersion !== 1) {
|
|
209
|
+
throw new Error(`unsupported Blueprint schema version: ${String(schema.schemaVersion)}`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const objects = [...schema.objectTypes]
|
|
213
|
+
.sort(compareApiName)
|
|
214
|
+
.map((objectType) => ({
|
|
215
|
+
...objectType,
|
|
216
|
+
properties: [...objectType.properties].sort(compareApiName),
|
|
217
|
+
}))
|
|
218
|
+
|
|
219
|
+
const lines = [
|
|
220
|
+
BLUEPRINT_TYPES_MARKER,
|
|
221
|
+
`// Blueprint schema digest: ${schema.digest}`,
|
|
222
|
+
'',
|
|
223
|
+
"import type { BlueprintObjectSchema as __FronteraBlueprintObjectSchema } from '@frontera-sdk/blueprint/types'",
|
|
224
|
+
'',
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
for (const objectType of objects) {
|
|
228
|
+
lines.push(...jsDoc(objectType.description), `export interface ${objectType.apiName} {`)
|
|
229
|
+
for (const property of objectType.properties) {
|
|
230
|
+
lines.push(...jsDoc(property.description).map((line) => ` ${line}`))
|
|
231
|
+
const base = TYPE_BY_DATA_TYPE[property.dataType]
|
|
232
|
+
lines.push(` ${property.apiName}: ${base}${property.nullable ? ' | null' : ''}`)
|
|
233
|
+
}
|
|
234
|
+
lines.push('}', '')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
lines.push("declare module '@frontera-sdk/blueprint/types' {", ' interface BlueprintRegistry {')
|
|
238
|
+
for (const objectType of objects) {
|
|
239
|
+
const filterable = stringUnion(objectType.properties.filter((property) => property.filterable).map((property) => property.apiName))
|
|
240
|
+
const sortable = stringUnion(objectType.properties.filter((property) => property.sortable).map((property) => property.apiName))
|
|
241
|
+
lines.push(
|
|
242
|
+
` ${objectType.apiName}: __FronteraBlueprintObjectSchema<`,
|
|
243
|
+
` ${objectType.apiName},`,
|
|
244
|
+
` ${filterable},`,
|
|
245
|
+
` ${sortable}`,
|
|
246
|
+
' >',
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
lines.push(' }', '}', '')
|
|
250
|
+
|
|
251
|
+
return `${lines.join('\n')}\n`
|
|
252
|
+
}
|