@frontera-sdk/cli 0.1.0 → 1.43.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- package/src/commands/blueprint/reserved.ts +0 -40
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// The committed payload, exactly as `template.ts` reads it. NOT
|
|
5
|
+
// `../scripts/sync-sdk` — `scripts/` is build tooling, not part of the shipped
|
|
6
|
+
// CLI, and importing it from `src/` would drag it into the bundle.
|
|
7
|
+
import vendored from './vendor/sdk-sources.json'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The vendored SDK files an automation project carries — a FILE list, not a
|
|
11
|
+
* directory list, and that distinction is load-bearing.
|
|
12
|
+
*
|
|
13
|
+
* Derived by tracing imports rather than assumed:
|
|
14
|
+
*
|
|
15
|
+
* automation/index.ts → ./define, ./manifest, ./types
|
|
16
|
+
* automation/define.ts → ./types
|
|
17
|
+
* automation/manifest.ts → cron-parser
|
|
18
|
+
* automation/types.ts → @frontera-sdk/blueprint/types
|
|
19
|
+
* blueprint/types.ts → nothing
|
|
20
|
+
*
|
|
21
|
+
* So `frontera/core/` is never reached, and the REST of `frontera/blueprint/`
|
|
22
|
+
* must be excluded: `hooks.ts` and `provider.tsx` import React and
|
|
23
|
+
* `@tanstack/react-query`, which are peerDependencies an automation project has
|
|
24
|
+
* no reason to install. Vendoring the whole directory — as `APP_SDK_DIRS` does,
|
|
25
|
+
* because an app HAS React — would fail `tsc` in a freshly scaffolded project.
|
|
26
|
+
*/
|
|
27
|
+
export const AUTOMATION_SDK_FILES = [
|
|
28
|
+
'frontera/automation/index.ts',
|
|
29
|
+
'frontera/automation/define.ts',
|
|
30
|
+
'frontera/automation/manifest.ts',
|
|
31
|
+
'frontera/automation/types.ts',
|
|
32
|
+
// `index.ts` re-exports both, so omitting them breaks the whole package for a
|
|
33
|
+
// scaffolded project, not just the parts that use them. `testing.ts` is what
|
|
34
|
+
// lets an author unit-test a handler; `messages.ts` is the wording it refuses
|
|
35
|
+
// in, shared with the runner.
|
|
36
|
+
'frontera/automation/messages.ts',
|
|
37
|
+
'frontera/automation/testing.ts',
|
|
38
|
+
'frontera/blueprint/types.ts',
|
|
39
|
+
] as const
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The files a new automation project contains.
|
|
43
|
+
*
|
|
44
|
+
* The SDK is written into `src/frontera/` rather than declared as a dependency:
|
|
45
|
+
* `@frontera-sdk/automation` is `private: true` and `workspace:*`, so it resolves
|
|
46
|
+
* nowhere outside this monorepo — which is the entire problem Phase B exists to
|
|
47
|
+
* solve. `package.json` therefore asks for nothing but public npm.
|
|
48
|
+
*/
|
|
49
|
+
export function automationScaffoldFiles(name: string): Record<string, string> {
|
|
50
|
+
const files = vendored.files as Record<string, string>
|
|
51
|
+
const out: Record<string, string> = {}
|
|
52
|
+
for (const rel of AUTOMATION_SDK_FILES) {
|
|
53
|
+
const content = files[rel]
|
|
54
|
+
// Fail loudly. A silently missing SDK file produces a project whose imports
|
|
55
|
+
// resolve to nothing — discovered by the author, not by us. This is the
|
|
56
|
+
// failure `cron-parser` caused in SEB-289.
|
|
57
|
+
if (content === undefined) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`vendored SDK is missing ${rel} — run \`bun run sync:sdk\` in packages/frontera-cli`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
out[`src/${rel}`] = content
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
out['package.json'] = JSON.stringify({
|
|
66
|
+
name,
|
|
67
|
+
private: true,
|
|
68
|
+
type: 'module',
|
|
69
|
+
scripts: {
|
|
70
|
+
typecheck: 'tsc --noEmit',
|
|
71
|
+
// A handler is testable without deploying — `createTestContext` is what
|
|
72
|
+
// the starter test uses — so the project ships with somewhere to run it.
|
|
73
|
+
test: 'bun test',
|
|
74
|
+
deploy: 'frontera automation deploy src/index.ts',
|
|
75
|
+
},
|
|
76
|
+
dependencies: {
|
|
77
|
+
// The vendored `frontera/automation/manifest.ts` imports it to validate
|
|
78
|
+
// cron expressions at author time. Omitting it leaves every generated
|
|
79
|
+
// project with an unresolvable import and a failing `typecheck`.
|
|
80
|
+
'cron-parser': '^5.0.6',
|
|
81
|
+
},
|
|
82
|
+
// `@types/bun` is what makes `bun:test` resolve in the starter test; the
|
|
83
|
+
// scaffold would otherwise ship a test file its own typecheck rejects.
|
|
84
|
+
devDependencies: { typescript: '^5.6.0', '@types/bun': 'latest' },
|
|
85
|
+
}, null, 2) + '\n'
|
|
86
|
+
|
|
87
|
+
out['tsconfig.json'] = JSON.stringify({
|
|
88
|
+
compilerOptions: {
|
|
89
|
+
target: 'ES2022',
|
|
90
|
+
module: 'ESNext',
|
|
91
|
+
moduleResolution: 'bundler',
|
|
92
|
+
strict: true,
|
|
93
|
+
noEmit: true,
|
|
94
|
+
skipLibCheck: true,
|
|
95
|
+
// No DOM and no node types: an automation runs server-side under Bun and
|
|
96
|
+
// its handler touches neither. Keeping the surface empty means a scaffolded
|
|
97
|
+
// project needs no @types packages to typecheck.
|
|
98
|
+
types: ['bun'],
|
|
99
|
+
// Mirrors how the CLI resolves these when it builds the entry file. The
|
|
100
|
+
// specifier is the contract; where it resolves is an implementation
|
|
101
|
+
// detail, so publishing the packages later is deleting these lines.
|
|
102
|
+
baseUrl: '.',
|
|
103
|
+
paths: {
|
|
104
|
+
'@frontera-sdk/automation': ['./src/frontera/automation/index.ts'],
|
|
105
|
+
'@frontera-sdk/automation/*': ['./src/frontera/automation/*'],
|
|
106
|
+
'@frontera-sdk/blueprint/types': ['./src/frontera/blueprint/types.ts'],
|
|
107
|
+
'@frontera-sdk/blueprint/*': ['./src/frontera/blueprint/*'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
include: ['src'],
|
|
111
|
+
}, null, 2) + '\n'
|
|
112
|
+
|
|
113
|
+
out['src/index.ts'] = `import { automation } from '@frontera-sdk/automation'
|
|
114
|
+
|
|
115
|
+
export default automation(
|
|
116
|
+
{
|
|
117
|
+
name: '${name}',
|
|
118
|
+
// A starter schedule — every five minutes, so you can see it run.
|
|
119
|
+
// CHANGE OR REMOVE THIS before anyone relies on the automation.
|
|
120
|
+
trigger: { cron: '*/5 * * * *' },
|
|
121
|
+
// Grants are enforced twice: the runner refuses a ctx call the manifest does
|
|
122
|
+
// not list, and Blueprint grants decide which object types are visible.
|
|
123
|
+
grants: ['blueprint:read'],
|
|
124
|
+
},
|
|
125
|
+
async (ctx) => {
|
|
126
|
+
// Wrap work in a STEP. A step runs at most once per run: if a later step
|
|
127
|
+
// fails and the run is retried, this one returns what it returned the first
|
|
128
|
+
// time instead of querying again. Steps are also what the Console draws.
|
|
129
|
+
const found = await ctx.step.run('load', async () => {
|
|
130
|
+
const result = await ctx.blueprint.query('YourObjectType', { limit: 10 })
|
|
131
|
+
await ctx.log(\`loaded \${result.rows.length} row(s)\`)
|
|
132
|
+
return { count: result.rows.length }
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// A branch is a branch: each arm is its own step, so both are visible in
|
|
136
|
+
// the Console — including the one today's data did not take.
|
|
137
|
+
if (found.count === 0) {
|
|
138
|
+
return await ctx.step.run('nothing-to-do', async () => ({ handled: 0 }))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Names must be UNIQUE within a run. The platform memoizes by name, so a
|
|
142
|
+
// repeated one would hand back the first result — inside a loop, put the
|
|
143
|
+
// index in the name.
|
|
144
|
+
for (const i of [0, 1]) {
|
|
145
|
+
await ctx.step.run(\`handle:\${i}\`, async () => {
|
|
146
|
+
await ctx.log(\`handling batch \${i}\`)
|
|
147
|
+
return { batch: i }
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Anything OUTSIDE a step re-runs each time the platform resumes the
|
|
152
|
+
// handler, which it does after every step — so keep calls that cost
|
|
153
|
+
// something inside one.
|
|
154
|
+
//
|
|
155
|
+
// Whatever you return is stored as the run's result and shown in the
|
|
156
|
+
// Console. Returning nothing is fine; returning something is how you see
|
|
157
|
+
// what happened without reading logs.
|
|
158
|
+
return { handled: found.count, checkedAt: new Date().toISOString() }
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
`
|
|
162
|
+
|
|
163
|
+
out['src/index.test.ts'] = `import { expect, test } from 'bun:test'
|
|
164
|
+
import { createTestContext } from '@frontera-sdk/automation'
|
|
165
|
+
|
|
166
|
+
import automation from './index'
|
|
167
|
+
|
|
168
|
+
// A handler is just a function, so it can be tested without deploying. The
|
|
169
|
+
// context refuses what the platform refuses — a grant the manifest does not
|
|
170
|
+
// declare, a step name used twice — so a passing test means something.
|
|
171
|
+
test('handles an empty result', async () => {
|
|
172
|
+
const { ctx, steps } = createTestContext({ grants: ['blueprint:read'] })
|
|
173
|
+
|
|
174
|
+
const result = await automation.handler(ctx)
|
|
175
|
+
|
|
176
|
+
expect(result).toEqual({ handled: 0 })
|
|
177
|
+
// The steps it took, in order — including the arm a day with no data hits.
|
|
178
|
+
expect(steps).toEqual(['load', 'nothing-to-do'])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('handles rows', async () => {
|
|
182
|
+
const { ctx, steps } = createTestContext({
|
|
183
|
+
grants: ['blueprint:read'],
|
|
184
|
+
// Stub only what this test is about; an object type you do not stub returns
|
|
185
|
+
// no rows, which is a real answer.
|
|
186
|
+
blueprint: { YourObjectType: { rows: [{ id: 'a' }, { id: 'b' }], hasMore: false } },
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
await automation.handler(ctx)
|
|
190
|
+
|
|
191
|
+
expect(steps).toEqual(['load', 'handle:0', 'handle:1'])
|
|
192
|
+
})
|
|
193
|
+
`
|
|
194
|
+
|
|
195
|
+
out['.gitignore'] = 'node_modules\n.env\n.env.*\n'
|
|
196
|
+
|
|
197
|
+
out['README.md'] = `# ${name}
|
|
198
|
+
|
|
199
|
+
\`\`\`bash
|
|
200
|
+
bun install
|
|
201
|
+
bun run typecheck
|
|
202
|
+
frontera automation deploy src/index.ts
|
|
203
|
+
\`\`\`
|
|
204
|
+
|
|
205
|
+
The SDK is vendored into \`src/frontera/\` and resolved by the \`paths\` in
|
|
206
|
+
\`tsconfig.json\`. Do not replace those imports with a package dependency — the
|
|
207
|
+
packages are unpublished, and the vendored copy is what makes this project build
|
|
208
|
+
on a machine that has never seen the Frontera monorepo.
|
|
209
|
+
|
|
210
|
+
\`frontera automation pull ${name}\` recovers this project from any deployed
|
|
211
|
+
version. A \`.env\` here is never uploaded.
|
|
212
|
+
`
|
|
213
|
+
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Write a new automation project to `target`. */
|
|
218
|
+
export function scaffoldAutomation(target: string, name: string): void {
|
|
219
|
+
for (const [rel, content] of Object.entries(automationScaffoldFiles(name))) {
|
|
220
|
+
const full = join(target, rel)
|
|
221
|
+
mkdirSync(dirname(full), { recursive: true })
|
|
222
|
+
writeFileSync(full, content)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
import type { ArtifactKind, AuthoredFile, RefError, Result } from './model'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Authored document → the body its route takes.
|
|
6
|
+
*
|
|
7
|
+
* Not a reference resolver, and that is worth saying because an earlier design made
|
|
8
|
+
* it one: every per-artifact route speaks `apiName`, not UUID — `linkTypeStructureBody`
|
|
9
|
+
* takes `fromObjectType` / `fromProperty` as names, and object types are addressed by
|
|
10
|
+
* apiName in the path. So nothing here mints or looks up an id. UUIDs are needed only
|
|
11
|
+
* by the object DRAFT COMMANDS, which address an `objectId`, and those are resolved
|
|
12
|
+
* from the live draft where they are issued.
|
|
13
|
+
*
|
|
14
|
+
* What this module does is the shape mapping, which is real: the file mirrors the
|
|
15
|
+
* DEFINITION BUNDLE (`valueType`, `unique`, `pluralDisplayName`) while the create
|
|
16
|
+
* route takes an authoring shape (`dataType`, `isUnique`, `pluralName`). Keeping the
|
|
17
|
+
* file bundle-shaped is what makes `pull` lossless; keeping the translation here is
|
|
18
|
+
* what stops that choice leaking into every command.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface DatasetResolver {
|
|
22
|
+
/** Dataset name → its current revision id and schema digest. */
|
|
23
|
+
(name: string): Promise<{ revisionId: string; schemaDigest: string }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `backing:` as a file writes it — a dataset NAME, and one column per property. */
|
|
27
|
+
export interface AuthoredBacking {
|
|
28
|
+
dataset: string
|
|
29
|
+
mapping: Record<string, string | { column: string; field?: string | null }>
|
|
30
|
+
editable?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requireString(
|
|
34
|
+
document: Record<string, unknown>,
|
|
35
|
+
field: string,
|
|
36
|
+
path: string,
|
|
37
|
+
): string {
|
|
38
|
+
const value = document[field]
|
|
39
|
+
if (typeof value !== 'string' || !value) {
|
|
40
|
+
throw new CliError(`${path} is missing \`${field}\`.`, {
|
|
41
|
+
code: 'USAGE',
|
|
42
|
+
hint: `Every artifact declares ${field}; \`frontera blueprint new\` writes one that validates.`,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function optional<T>(value: T | undefined, key: string): Record<string, T> {
|
|
49
|
+
return value === undefined ? {} : { [key]: value } as Record<string, T>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readBacking(file: AuthoredFile): AuthoredBacking | undefined {
|
|
53
|
+
const backing = file.document.backing
|
|
54
|
+
if (backing === undefined) return undefined
|
|
55
|
+
if (!backing || typeof backing !== 'object' || Array.isArray(backing)) {
|
|
56
|
+
throw new CliError(`${file.path} has a \`backing\` that is not a mapping.`, {
|
|
57
|
+
code: 'USAGE',
|
|
58
|
+
hint: 'backing: { dataset: <name>, mapping: { <property>: <column> } }',
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
const candidate = backing as Record<string, unknown>
|
|
62
|
+
if (typeof candidate.dataset !== 'string' || !candidate.dataset) {
|
|
63
|
+
throw new CliError(`${file.path} has a \`backing\` that names no dataset.`, {
|
|
64
|
+
code: 'USAGE',
|
|
65
|
+
hint: 'backing.dataset is the dataset NAME — the revision it pins is resolved at apply time.',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
dataset: candidate.dataset,
|
|
70
|
+
mapping: (candidate.mapping ?? {}) as AuthoredBacking['mapping'],
|
|
71
|
+
...(Array.isArray(candidate.editable) ? { editable: candidate.editable as string[] } : {}),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `sharedField: accountCode` → the uuid the wire takes. THREE-VALUED, because the
|
|
77
|
+
* authored key has three states and the wire has the same three:
|
|
78
|
+
*
|
|
79
|
+
* | Authored | Returned | Wire |
|
|
80
|
+
* |---|---|---|
|
|
81
|
+
* | key absent | `undefined` | omit `sharedPropertyId` — unchanged |
|
|
82
|
+
* | `sharedField: null` | `null` | `sharedPropertyId: null` — detach |
|
|
83
|
+
* | `sharedField: <name>` | the uuid | `sharedPropertyId: <uuid>` |
|
|
84
|
+
*
|
|
85
|
+
* Collapsing `null` into `undefined` looked harmless and broke detach outright: the
|
|
86
|
+
* plan saw the change, the executor then omitted the only member the patch had, and
|
|
87
|
+
* `update_field_metadata` went out as `patch: {}` — which the route refuses on
|
|
88
|
+
* `minProperties: 1`. The wire models the distinction deliberately
|
|
89
|
+
* (`sharedPropertyId: t.Optional(t.Nullable(uuid))`), so the tree can state it.
|
|
90
|
+
*
|
|
91
|
+
* The name is resolved against what the draft holds AT THE MOMENT OF THE WRITE, not
|
|
92
|
+
* at plan time: a shared field created earlier in the same `apply` has no id when
|
|
93
|
+
* the plan is computed, and the executor adds it to this map as each create answers.
|
|
94
|
+
*
|
|
95
|
+
* An unresolvable name names both halves of the reference, because either one may be
|
|
96
|
+
* the typo and an error naming only the field sends the reader to the wrong file.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveSharedProperty(
|
|
99
|
+
file: AuthoredFile,
|
|
100
|
+
propertyApiName: string,
|
|
101
|
+
declared: unknown,
|
|
102
|
+
sharedPropertyIdByApiName: Map<string, string>,
|
|
103
|
+
): string | null | undefined {
|
|
104
|
+
if (declared === undefined) return undefined
|
|
105
|
+
if (declared === null) return null
|
|
106
|
+
const name = String(declared)
|
|
107
|
+
const id = sharedPropertyIdByApiName.get(name)
|
|
108
|
+
if (!id) {
|
|
109
|
+
throw new CliError(
|
|
110
|
+
`${file.path}: field "${propertyApiName}" names shared field "${name}", `
|
|
111
|
+
+ 'which is not on the draft.',
|
|
112
|
+
{
|
|
113
|
+
code: 'USAGE',
|
|
114
|
+
hint: `Add shared-fields/${name}.yaml, or correct the name. `
|
|
115
|
+
+ 'A shared field is referenced by apiName — the tree carries no identifiers.',
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return id
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function columnOf(entry: string | { column: string; field?: string | null }): {
|
|
123
|
+
column: string
|
|
124
|
+
field?: string | null
|
|
125
|
+
} {
|
|
126
|
+
return typeof entry === 'string' ? { column: entry } : entry
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The create body for `POST /v1/blueprint/object-types`.
|
|
131
|
+
*
|
|
132
|
+
* `datasetRevisionId` is resolved from the backing's dataset NAME. That indirection is
|
|
133
|
+
* the whole reason the tree is portable: `customers` means the same thing in two
|
|
134
|
+
* deployments, and the revision it currently points at does not.
|
|
135
|
+
*/
|
|
136
|
+
export async function objectTypeCreateBody(
|
|
137
|
+
file: AuthoredFile,
|
|
138
|
+
expectedRevision: number,
|
|
139
|
+
resolveDataset: DatasetResolver,
|
|
140
|
+
sharedPropertyIdByApiName: Map<string, string>,
|
|
141
|
+
): Promise<Record<string, unknown>> {
|
|
142
|
+
const document = file.document
|
|
143
|
+
const backing = readBacking(file)
|
|
144
|
+
if (!backing) {
|
|
145
|
+
throw new CliError(`${file.path} declares no \`backing\`, so it cannot be created.`, {
|
|
146
|
+
code: 'USAGE',
|
|
147
|
+
hint: 'An object type reads through a Dataset. Add backing.dataset and a column per property.',
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
// The create route builds an object type from an explicit body — apiName, display
|
|
151
|
+
// names, properties, keys, backing — and has no channel for `validations` or
|
|
152
|
+
// `lifecycle`. Sending a file that declares them would create the type WITHOUT them
|
|
153
|
+
// and report success, which is the silent-drop this design keeps having to root out.
|
|
154
|
+
const validations = document.validations
|
|
155
|
+
if (Array.isArray(validations) && validations.length > 0) {
|
|
156
|
+
throw new CliError(`${file.path} declares validations, which the create route cannot express.`, {
|
|
157
|
+
code: 'USAGE',
|
|
158
|
+
hint: 'Create the type without them and add validations in the Console, or remove them from the file.',
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
if (document.lifecycle !== undefined) {
|
|
162
|
+
throw new CliError(`${file.path} declares a lifecycle, which the create route cannot express.`, {
|
|
163
|
+
code: 'USAGE',
|
|
164
|
+
hint: 'Create the type without it and author the lifecycle in the Console.',
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { revisionId } = await resolveDataset(backing.dataset)
|
|
169
|
+
const properties = Array.isArray(document.properties) ? document.properties : []
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
apiName: requireString(document, 'apiName', file.path),
|
|
173
|
+
displayName: requireString(document, 'displayName', file.path),
|
|
174
|
+
pluralName: requireString(document, 'pluralDisplayName', file.path),
|
|
175
|
+
...optional(document.description as string | undefined, 'description'),
|
|
176
|
+
...optional(document.icon as string | undefined, 'icon'),
|
|
177
|
+
...optional(document.color as string | undefined, 'color'),
|
|
178
|
+
...optional(document.groups as string[] | undefined, 'groups'),
|
|
179
|
+
...optional(document.status as string | undefined, 'status'),
|
|
180
|
+
...optional(document.visibility as string | undefined, 'visibility'),
|
|
181
|
+
datasetRevisionId: revisionId,
|
|
182
|
+
properties: properties.map((entry) => {
|
|
183
|
+
const property = entry as Record<string, unknown>
|
|
184
|
+
const apiName = String(property.apiName)
|
|
185
|
+
const mapped = backing.mapping[apiName]
|
|
186
|
+
if (mapped === undefined) {
|
|
187
|
+
throw new CliError(
|
|
188
|
+
`${file.path}: property "${apiName}" has no column in \`backing.mapping\`.`,
|
|
189
|
+
{
|
|
190
|
+
code: 'USAGE',
|
|
191
|
+
// The service refuses a name match deliberately (ADR 0008); saying so
|
|
192
|
+
// here stops the reader looking for the setting that turns it on.
|
|
193
|
+
hint: 'Every property names the column it reads. There is no name-match fallback.',
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
const { column, field } = columnOf(mapped)
|
|
198
|
+
// Attached AT CREATION, not by a later add-field command: a first apply against
|
|
199
|
+
// an empty organization creates the type and all its fields in one call, so a
|
|
200
|
+
// reference the create body cannot carry is unreachable on exactly that run.
|
|
201
|
+
const sharedPropertyId = resolveSharedProperty(
|
|
202
|
+
file, apiName, property.sharedField, sharedPropertyIdByApiName,
|
|
203
|
+
)
|
|
204
|
+
return {
|
|
205
|
+
apiName,
|
|
206
|
+
displayName: String(property.displayName ?? apiName),
|
|
207
|
+
...optional(property.description as string | undefined, 'description'),
|
|
208
|
+
propertyType: (property.propertyType as string | undefined) ?? 'attribute',
|
|
209
|
+
dataType: String(property.valueType ?? 'string'),
|
|
210
|
+
column,
|
|
211
|
+
...(field === undefined || field === null ? {} : { field }),
|
|
212
|
+
...optional(property.formatConfig as Record<string, unknown> | undefined, 'formatConfig'),
|
|
213
|
+
...(property.unique === undefined ? {} : { isUnique: Boolean(property.unique) }),
|
|
214
|
+
// A CREATE has nothing to detach from, so an authored `null` is simply
|
|
215
|
+
// "no shared field" — and the create body's uuid is not nullable, so
|
|
216
|
+
// sending it would be a 422 rather than a detach.
|
|
217
|
+
...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
|
|
218
|
+
}
|
|
219
|
+
}),
|
|
220
|
+
columnMappings: Object.entries(backing.mapping).map(([propertyApiName, entry]) => {
|
|
221
|
+
const { column, field } = columnOf(entry)
|
|
222
|
+
return { propertyApiName, column, ...(field === undefined ? {} : { field }) }
|
|
223
|
+
}),
|
|
224
|
+
primaryKey: requireString(document, 'primaryKey', file.path),
|
|
225
|
+
titleKey: requireString(document, 'title', file.path),
|
|
226
|
+
expectedRevision,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `PUT /object-types/:apiName` takes metadata only — fields move by draft command. */
|
|
231
|
+
export function objectTypePatchBody(
|
|
232
|
+
file: AuthoredFile,
|
|
233
|
+
expectedRevision: number,
|
|
234
|
+
): Record<string, unknown> {
|
|
235
|
+
const document = file.document
|
|
236
|
+
return {
|
|
237
|
+
...optional(document.displayName as string | undefined, 'displayName'),
|
|
238
|
+
...optional(document.pluralDisplayName as string | undefined, 'pluralName'),
|
|
239
|
+
...optional(document.description as string | undefined, 'description'),
|
|
240
|
+
...optional(document.icon as string | undefined, 'icon'),
|
|
241
|
+
...optional(document.color as string | undefined, 'color'),
|
|
242
|
+
...optional(document.groups as string[] | undefined, 'groups'),
|
|
243
|
+
...optional(document.status as string | undefined, 'status'),
|
|
244
|
+
...optional(document.visibility as string | undefined, 'visibility'),
|
|
245
|
+
expectedRevision,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function endpoint(document: Record<string, unknown>, side: 'from' | 'to', path: string) {
|
|
250
|
+
const value = document[side]
|
|
251
|
+
if (!value || typeof value !== 'object') {
|
|
252
|
+
throw new CliError(`${path} has no \`${side}\` endpoint.`, {
|
|
253
|
+
code: 'USAGE',
|
|
254
|
+
hint: `${side}: { objectType: <ApiName>, property: <apiName>, displayName: <text> }`,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
return value as Record<string, unknown>
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function linkTypeStructure(
|
|
261
|
+
file: AuthoredFile,
|
|
262
|
+
expectedRevision: number,
|
|
263
|
+
): Record<string, unknown> {
|
|
264
|
+
const document = file.document
|
|
265
|
+
const from = endpoint(document, 'from', file.path)
|
|
266
|
+
const to = endpoint(document, 'to', file.path)
|
|
267
|
+
return {
|
|
268
|
+
cardinality: String(document.cardinality ?? 'one_to_many'),
|
|
269
|
+
fromObjectType: String(from.objectType),
|
|
270
|
+
toObjectType: String(to.objectType),
|
|
271
|
+
fromProperty: String(from.property),
|
|
272
|
+
toProperty: String(to.property),
|
|
273
|
+
fromDisplayName: String(from.displayName ?? to.objectType),
|
|
274
|
+
toDisplayName: String(to.displayName ?? from.objectType),
|
|
275
|
+
...optional(from.phrase as string | undefined, 'fromPhrase'),
|
|
276
|
+
...optional(to.phrase as string | undefined, 'toPhrase'),
|
|
277
|
+
...optional(document.description as string | undefined, 'description'),
|
|
278
|
+
expectedRevision,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function linkTypeCreateBody(
|
|
283
|
+
file: AuthoredFile,
|
|
284
|
+
expectedRevision: number,
|
|
285
|
+
): Record<string, unknown> {
|
|
286
|
+
return { apiName: file.apiName, ...linkTypeStructure(file, expectedRevision) }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Metrics and actions are carried through as authored.
|
|
291
|
+
*
|
|
292
|
+
* Their routes take the same shape the bundle carries, so translating would only
|
|
293
|
+
* create a place for the two to drift.
|
|
294
|
+
*/
|
|
295
|
+
export function metricCreateBody(
|
|
296
|
+
file: AuthoredFile,
|
|
297
|
+
expectedRevision: number,
|
|
298
|
+
): Record<string, unknown> {
|
|
299
|
+
const { objectType, ...body } = file.document as Record<string, unknown>
|
|
300
|
+
if (typeof objectType !== 'string' || !objectType) {
|
|
301
|
+
throw new CliError(`${file.path} names no \`objectType\`.`, {
|
|
302
|
+
code: 'USAGE',
|
|
303
|
+
hint: 'A metric measures one object type, named by apiName.',
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
// `objectType` in the file, `objectTypeApiName` on the wire — the route's own name
|
|
307
|
+
// for the same thing.
|
|
308
|
+
return { ...body, objectTypeApiName: objectType, expectedRevision }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The patch route takes metadata and the definition, never the subject. */
|
|
312
|
+
export function metricPatchBody(
|
|
313
|
+
file: AuthoredFile,
|
|
314
|
+
expectedRevision: number,
|
|
315
|
+
): Record<string, unknown> {
|
|
316
|
+
const { objectType: _objectType, apiName: _apiName, ...body } = file.document as Record<string, unknown>
|
|
317
|
+
return { ...body, expectedRevision }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A shared field is authored whole: the create route takes the same fields the
|
|
322
|
+
* bundle carries, so the file is passed through rather than translated.
|
|
323
|
+
*
|
|
324
|
+
* It names no column and no dataset, which is why there is no backing to resolve —
|
|
325
|
+
* a shared field says what a field MEANS, and nothing about where it is read.
|
|
326
|
+
*/
|
|
327
|
+
export function sharedPropertyCreateBody(
|
|
328
|
+
file: AuthoredFile,
|
|
329
|
+
expectedRevision: number,
|
|
330
|
+
): Record<string, unknown> {
|
|
331
|
+
const document = file.document
|
|
332
|
+
return {
|
|
333
|
+
apiName: requireString(document, 'apiName', file.path),
|
|
334
|
+
displayName: requireString(document, 'displayName', file.path),
|
|
335
|
+
...optional(document.description as string | undefined, 'description'),
|
|
336
|
+
valueType: requireString(document, 'valueType', file.path),
|
|
337
|
+
// Required like `valueType`: a shared field states how it is read, and the
|
|
338
|
+
// create route refuses a body without it. Caught here so the failure names
|
|
339
|
+
// the file instead of arriving as a 422 from the service.
|
|
340
|
+
propertyType: requireString(document, 'propertyType', file.path),
|
|
341
|
+
...optional(document.formatConfig as Record<string, unknown> | undefined, 'formatConfig'),
|
|
342
|
+
...optional(document.status as string | undefined, 'status'),
|
|
343
|
+
...optional(document.visibility as string | undefined, 'visibility'),
|
|
344
|
+
expectedRevision,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The patch route treats `apiName` as a RENAME, so it is omitted here.
|
|
350
|
+
*
|
|
351
|
+
* The file's apiName is its filename (`readTree` refuses a tree where they disagree),
|
|
352
|
+
* and the route is addressed by that same name — so sending it could only ever
|
|
353
|
+
* restate what is already true, while a future edit to one of the two would make it
|
|
354
|
+
* silently rename the artifact instead.
|
|
355
|
+
*/
|
|
356
|
+
export function sharedPropertyPatchBody(
|
|
357
|
+
file: AuthoredFile,
|
|
358
|
+
expectedRevision: number,
|
|
359
|
+
): Record<string, unknown> {
|
|
360
|
+
const { apiName: _apiName, ...body } = file.document as Record<string, unknown>
|
|
361
|
+
return { ...body, expectedRevision }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Collected rather than thrown — see `RefError`. */
|
|
365
|
+
export function ok<T>(value: T): Result<T> {
|
|
366
|
+
return { ok: true, value }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function failed<T>(errors: RefError[]): Result<T> {
|
|
370
|
+
return { ok: false, errors }
|
|
371
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which revision of a dataset is CURRENT.
|
|
3
|
+
*
|
|
4
|
+
* The dataset says so itself, in `currentRevisionId`. Sorting its revisions and taking
|
|
5
|
+
* the highest number is a DIFFERENT answer after a rollback — the live revision is then
|
|
6
|
+
* not the newest one — and a binding pinned that way reads a revision the organization
|
|
7
|
+
* has stepped off, silently, until somebody notices the columns are stale.
|
|
8
|
+
*
|
|
9
|
+
* One implementation because there were two and they disagreed: `apply` read
|
|
10
|
+
* `currentRevisionId` while `bind` took `revisions[0]`, so the same tree pinned
|
|
11
|
+
* differently depending on which command reached the dataset first.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface DatasetRef {
|
|
15
|
+
currentRevisionId?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param revisions ordered highest revision number first, as `datasetRevisions` returns
|
|
20
|
+
* them. The order is only the FALLBACK: it answers a payload that names no current
|
|
21
|
+
* revision, and a list that does not contain the named one — which is what a truncated
|
|
22
|
+
* page looks like.
|
|
23
|
+
*/
|
|
24
|
+
export function pickCurrentRevision<T extends { id?: string }>(
|
|
25
|
+
dataset: DatasetRef,
|
|
26
|
+
revisions: readonly T[],
|
|
27
|
+
): T | undefined {
|
|
28
|
+
if (dataset.currentRevisionId) {
|
|
29
|
+
const named = revisions.find((revision) => revision.id === dataset.currentRevisionId)
|
|
30
|
+
if (named) return named
|
|
31
|
+
}
|
|
32
|
+
return revisions[0]
|
|
33
|
+
}
|