@frontera-sdk/cli 0.1.0 → 1.43.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +4 -2
  2. package/src/api/apps-api.ts +13 -1
  3. package/src/api/automation-api.ts +129 -1
  4. package/src/api/blueprint-authoring-api.ts +574 -0
  5. package/src/api/dataset-api.ts +199 -0
  6. package/src/api/platform-api.ts +300 -0
  7. package/src/automation-template.ts +224 -0
  8. package/src/blueprint/compile.ts +371 -0
  9. package/src/blueprint/dataset-revision.ts +33 -0
  10. package/src/blueprint/diff.ts +223 -0
  11. package/src/blueprint/model.ts +227 -0
  12. package/src/blueprint/projection.ts +254 -0
  13. package/src/blueprint/render.ts +73 -0
  14. package/src/blueprint/scaffold.ts +79 -0
  15. package/src/blueprint/tree.ts +121 -0
  16. package/src/commands/agent/index-commands.ts +87 -1
  17. package/src/commands/app/deploy.ts +43 -3
  18. package/src/commands/app/init.ts +23 -1
  19. package/src/commands/app/pull.ts +12 -35
  20. package/src/commands/automation/index-commands.ts +42 -1
  21. package/src/commands/automation/init.ts +52 -0
  22. package/src/commands/automation/project-root.ts +58 -0
  23. package/src/commands/automation/pull.ts +124 -0
  24. package/src/commands/automation/run.ts +271 -0
  25. package/src/commands/blueprint/authoring.ts +410 -0
  26. package/src/commands/blueprint/bind.ts +228 -0
  27. package/src/commands/blueprint/declarative.ts +1052 -0
  28. package/src/commands/blueprint/grants.ts +164 -0
  29. package/src/commands/dataset/index-commands.ts +431 -0
  30. package/src/commands/knowledge/index-commands.ts +278 -27
  31. package/src/commands/knowledge/upload-batch.ts +146 -0
  32. package/src/commands/knowledge/upload-plan.ts +127 -0
  33. package/src/commands/login.ts +49 -11
  34. package/src/commands/pack/index-commands.ts +373 -0
  35. package/src/commands/registry.ts +19 -2
  36. package/src/commands/secret/index-commands.ts +195 -0
  37. package/src/commands/skill/bundle-commands.ts +327 -0
  38. package/src/commands/skill/index-commands.ts +36 -42
  39. package/src/commands/skill/resolve.ts +34 -0
  40. package/src/dev-env.ts +114 -0
  41. package/src/flag-help.ts +34 -0
  42. package/src/harness.ts +30 -3
  43. package/src/main.ts +10 -3
  44. package/src/render-evidence.ts +152 -0
  45. package/src/template.ts +4 -0
  46. package/src/untar.ts +44 -0
  47. package/src/vendor/sdk-sources.json +13 -11
  48. 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
+ }