@frontera-sdk/cli 1.52.39 → 1.52.41
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 +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Action's expected changes against the semantic types of the properties
|
|
3
|
+
* they set, from the Blueprint's files: a port of the service's rule
|
|
4
|
+
* (`action-semantic-fit.ts`, with `effective-semantic-type.ts` and
|
|
5
|
+
* `semantic-type-rule.ts`), which the Action draft route applies when an Action
|
|
6
|
+
* is saved, and so the local runtime applies when `local dev` writes a pulled
|
|
7
|
+
* Action. Found here, a pull can say an Action won't run locally before
|
|
8
|
+
* anything is applied, and `blueprint plan` can say so before a write.
|
|
9
|
+
*
|
|
10
|
+
* The same certainty as the service: only an input's declared `enum` members
|
|
11
|
+
* (and its `default`) the type does not allow, and a literal it does not
|
|
12
|
+
* allow. A free-text input onto a constrained property may still be given a
|
|
13
|
+
* good value, and is left to the edit gate. Never stricter than the service:
|
|
14
|
+
* where a value's reading is in doubt (a number this port cannot read
|
|
15
|
+
* exactly), it is not a conflict.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type Doc = Record<string, unknown>
|
|
19
|
+
|
|
20
|
+
const isRecord = (v: unknown): v is Doc => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
21
|
+
const list = (v: unknown): Doc[] => (Array.isArray(v) ? v.filter(isRecord) : [])
|
|
22
|
+
|
|
23
|
+
/** A semantic type as its file states it: `semantic-types/<apiName>.yaml`. */
|
|
24
|
+
export interface SemanticTypeFact {
|
|
25
|
+
apiName: string
|
|
26
|
+
dataType: string
|
|
27
|
+
rule: Doc
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the rule needs from the Blueprint's files, by apiName. */
|
|
31
|
+
export interface SemanticFacts {
|
|
32
|
+
semanticTypes: ReadonlyMap<string, SemanticTypeFact>
|
|
33
|
+
/** Shared field → the semantic type it carries (null: none). */
|
|
34
|
+
sharedFields: ReadonlyMap<string, string | null>
|
|
35
|
+
/** Object type → property → what governs its meaning. */
|
|
36
|
+
objectTypes: ReadonlyMap<string, ReadonlyMap<string, { semanticType?: string | null; sharedField?: string | null }>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One Blueprint file: its path under `blueprint/` or the tree root, and its parsed document. */
|
|
40
|
+
export interface BlueprintDoc {
|
|
41
|
+
path: string
|
|
42
|
+
document: Doc
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The facts from a Blueprint's files (`semantic-types/`, `shared-fields/`, `object-types/`, with or without a `blueprint/` prefix). */
|
|
46
|
+
export function semanticFactsOf(files: Iterable<BlueprintDoc>): SemanticFacts {
|
|
47
|
+
const semanticTypes = new Map<string, SemanticTypeFact>()
|
|
48
|
+
const sharedFields = new Map<string, string | null>()
|
|
49
|
+
const objectTypes = new Map<string, Map<string, { semanticType?: string | null; sharedField?: string | null }>>()
|
|
50
|
+
for (const { path, document: doc } of files) {
|
|
51
|
+
const at = path.replace(/^blueprint\//, '')
|
|
52
|
+
const apiName = typeof doc.apiName === 'string' ? doc.apiName : null
|
|
53
|
+
if (!apiName) continue
|
|
54
|
+
if (at.startsWith('semantic-types/') && typeof doc.dataType === 'string' && isRecord(doc.rule)) {
|
|
55
|
+
semanticTypes.set(apiName, { apiName, dataType: doc.dataType, rule: doc.rule })
|
|
56
|
+
} else if (at.startsWith('shared-fields/')) {
|
|
57
|
+
sharedFields.set(apiName, typeof doc.semanticType === 'string' ? doc.semanticType : null)
|
|
58
|
+
} else if (at.startsWith('object-types/')) {
|
|
59
|
+
const props = new Map<string, { semanticType?: string | null; sharedField?: string | null }>()
|
|
60
|
+
for (const p of list(doc.properties)) {
|
|
61
|
+
if (typeof p.apiName !== 'string') continue
|
|
62
|
+
props.set(p.apiName, {
|
|
63
|
+
...(typeof p.semanticType === 'string' ? { semanticType: p.semanticType } : {}),
|
|
64
|
+
...(typeof p.sharedField === 'string' ? { sharedField: p.sharedField } : {}),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
objectTypes.set(apiName, props)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { semanticTypes, sharedFields, objectTypes }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The semantic type governing one property. The shared field wins outright
|
|
75
|
+
* while one is implemented, including when it carries none: a shared field
|
|
76
|
+
* owns its implementers' meaning (the service's `resolveEffectiveSemanticTypes`).
|
|
77
|
+
*/
|
|
78
|
+
export function effectiveSemanticType(facts: SemanticFacts, objectType: string, property: string): SemanticTypeFact | null {
|
|
79
|
+
const p = facts.objectTypes.get(objectType)?.get(property)
|
|
80
|
+
if (!p) return null
|
|
81
|
+
const name = p.sharedField ? (facts.sharedFields.get(p.sharedField) ?? null) : (p.semanticType ?? null)
|
|
82
|
+
return name ? (facts.semanticTypes.get(name) ?? null) : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A number as a JavaScript number when it reads as one exactly enough to compare; null otherwise. */
|
|
86
|
+
function numberOf(v: unknown): number | null {
|
|
87
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null
|
|
88
|
+
if (typeof v === 'string' && /^[+-]?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(v.trim())) {
|
|
89
|
+
const n = Number(v)
|
|
90
|
+
return Number.isFinite(n) ? n : null
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether a value satisfies the type's rule; `true` where this port can't be sure it doesn't. */
|
|
96
|
+
export function matchesSemanticRule(type: SemanticTypeFact, value: unknown): boolean {
|
|
97
|
+
if (value === null || value === undefined) return true
|
|
98
|
+
const rule = type.rule
|
|
99
|
+
switch (type.dataType) {
|
|
100
|
+
case 'string': {
|
|
101
|
+
if (typeof value !== 'string') return false
|
|
102
|
+
const normalized = value.normalize('NFC')
|
|
103
|
+
const length = [...normalized].length
|
|
104
|
+
if (typeof rule.minLength === 'number' && length < rule.minLength) return false
|
|
105
|
+
if (typeof rule.maxLength === 'number' && length > rule.maxLength) return false
|
|
106
|
+
if (Array.isArray(rule.enum) && !rule.enum.map(String).includes(normalized)) return false
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
case 'number': {
|
|
110
|
+
if (typeof value === 'boolean') return false
|
|
111
|
+
const n = numberOf(value)
|
|
112
|
+
// Beyond what a double reads exactly: the service compares decimals exactly; don't guess.
|
|
113
|
+
if (n === null || !Number.isSafeInteger(Math.trunc(n))) return true
|
|
114
|
+
if (rule.integer === true && !Number.isInteger(n)) return false
|
|
115
|
+
const min = numberOf(rule.minimum)
|
|
116
|
+
const max = numberOf(rule.maximum)
|
|
117
|
+
if (min !== null && n < min) return false
|
|
118
|
+
if (max !== null && n > max) return false
|
|
119
|
+
if (Array.isArray(rule.enum)) {
|
|
120
|
+
const members = rule.enum.map(numberOf)
|
|
121
|
+
if (members.some((m) => m === null)) return true
|
|
122
|
+
if (!members.includes(n)) return false
|
|
123
|
+
}
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
case 'date': {
|
|
127
|
+
if (typeof value !== 'string') return false
|
|
128
|
+
const date = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : Number.isNaN(Date.parse(value)) ? null : new Date(Date.parse(value)).toISOString().slice(0, 10)
|
|
129
|
+
if (date === null) return false
|
|
130
|
+
if (typeof rule.minimum === 'string' && date < rule.minimum) return false
|
|
131
|
+
if (typeof rule.maximum === 'string' && date > rule.maximum) return false
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
case 'timestamp': {
|
|
135
|
+
if (typeof value !== 'string') return false
|
|
136
|
+
const t = Date.parse(value)
|
|
137
|
+
if (Number.isNaN(t)) return false
|
|
138
|
+
if (typeof rule.minimum === 'string' && !Number.isNaN(Date.parse(rule.minimum)) && t < Date.parse(rule.minimum)) return false
|
|
139
|
+
if (typeof rule.maximum === 'string' && !Number.isNaN(Date.parse(rule.maximum)) && t > Date.parse(rule.maximum)) return false
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ActionSemanticConflict {
|
|
147
|
+
/** Where in the Action file (`businessOutcomes[0].expectedChanges[2].value`). */
|
|
148
|
+
path: string
|
|
149
|
+
message: string
|
|
150
|
+
/**
|
|
151
|
+
* The Blueprint files the conflict rests on besides the Action's own, as a
|
|
152
|
+
* pull's lock names them (`blueprint/object-types/Escalation`,
|
|
153
|
+
* `blueprint/semantic-types/escalationSeverityValues`, and the shared field
|
|
154
|
+
* when the type comes through one): with the Action, unchanged since the
|
|
155
|
+
* pull, the conflict is the workspace's (`inheritedFromPull`).
|
|
156
|
+
*/
|
|
157
|
+
refs: string[]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Each expected change whose value the property's semantic type refuses, in
|
|
162
|
+
* the service's words. `subject` is the Action's own object type, for a change
|
|
163
|
+
* that names none.
|
|
164
|
+
*/
|
|
165
|
+
export function actionSemanticConflicts(action: Doc, facts: SemanticFacts): ActionSemanticConflict[] {
|
|
166
|
+
const inputs = new Map(list(action.inputs).flatMap((f) => (typeof f.apiName === 'string' ? [[f.apiName, f] as const] : [])))
|
|
167
|
+
const subject = isRecord(action.subject) && typeof action.subject.objectType === 'string' ? action.subject.objectType : null
|
|
168
|
+
const out: ActionSemanticConflict[] = []
|
|
169
|
+
list(action.businessOutcomes).forEach((outcome, i) => {
|
|
170
|
+
list(outcome.expectedChanges).forEach((change, j) => {
|
|
171
|
+
if (change.kind !== 'property_set' || !isRecord(change.value) || typeof change.property !== 'string') return
|
|
172
|
+
const objectType = typeof change.objectType === 'string' ? change.objectType : subject
|
|
173
|
+
if (!objectType) return
|
|
174
|
+
const type = effectiveSemanticType(facts, objectType, change.property)
|
|
175
|
+
if (!type) return
|
|
176
|
+
const target = `${objectType}.${change.property}`
|
|
177
|
+
const sharedField = facts.objectTypes.get(objectType)?.get(change.property)?.sharedField
|
|
178
|
+
const refs = [`blueprint/object-types/${objectType}`, ...(sharedField ? [`blueprint/shared-fields/${sharedField}`] : []), `blueprint/semantic-types/${type.apiName}`]
|
|
179
|
+
const allows = type.dataType === 'string' && Array.isArray(type.rule.enum) ? ` (${type.rule.enum.map(String).join(', ')})` : ''
|
|
180
|
+
const path = `businessOutcomes[${i}].expectedChanges[${j}].value`
|
|
181
|
+
const refused = (v: unknown) => !matchesSemanticRule(type, v)
|
|
182
|
+
const value = change.value
|
|
183
|
+
if (value.kind === 'literal') {
|
|
184
|
+
if (refused(value.value)) {
|
|
185
|
+
out.push({ path, refs, message: `${target} takes only what its semantic type "${type.apiName}" allows${allows}, and ${JSON.stringify(value.value)} is not one of them, so every request of it is refused when it is submitted.` })
|
|
186
|
+
}
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (value.kind !== 'input' || typeof value.field !== 'string') return
|
|
190
|
+
const input = inputs.get(value.field)
|
|
191
|
+
if (!input) return
|
|
192
|
+
const valueType = isRecord(input.valueType) ? input.valueType : {}
|
|
193
|
+
const declared = Array.isArray(valueType.enum) ? valueType.enum : null
|
|
194
|
+
const candidates = [...(declared ?? []), ...(Object.prototype.hasOwnProperty.call(input, 'default') ? [input.default] : [])]
|
|
195
|
+
const outside = [...new Set(candidates.filter(refused).map((v) => JSON.stringify(v)))]
|
|
196
|
+
if (!outside.length) return
|
|
197
|
+
out.push({
|
|
198
|
+
path,
|
|
199
|
+
refs,
|
|
200
|
+
message: `Input "${value.field}" can be ${outside.join(', ')}, which ${target} refuses: it takes only what its semantic type "${type.apiName}" allows${allows}. A request with ${outside.length === 1 ? 'that value' : 'those values'} is refused when it is submitted; give the input only values the type allows.`,
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
return out
|
|
205
|
+
}
|
package/src/blueprint/compile.ts
CHANGED
|
@@ -161,6 +161,8 @@ export function resolveSemanticType(
|
|
|
161
161
|
export interface AuthoringIds {
|
|
162
162
|
sharedPropertyIdByApiName: Map<string, string>
|
|
163
163
|
semanticTypeIdByApiName: Map<string, string>
|
|
164
|
+
/** Shared field apiName → its `propertyType`, which an implementer that states none takes. */
|
|
165
|
+
sharedPropertyTypeByApiName?: Map<string, string>
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
function columnOf(entry: string | { column: string; field?: string | null }): {
|
|
@@ -210,7 +212,9 @@ export async function objectTypeCreateBody(
|
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
const { revisionId } = await resolveDataset(backing.dataset)
|
|
213
|
-
|
|
215
|
+
// A calculated field (a location from latitude and longitude) reads no column, and the
|
|
216
|
+
// create route has no channel for it: it is added after the type, by `add_field`.
|
|
217
|
+
const properties = (Array.isArray(document.properties) ? document.properties : []).filter((entry) => !(entry as { calculation?: unknown }).calculation)
|
|
214
218
|
|
|
215
219
|
return {
|
|
216
220
|
apiName: requireString(document, 'apiName', file.path),
|
|
@@ -228,6 +232,8 @@ export async function objectTypeCreateBody(
|
|
|
228
232
|
// nothing to carry here — a create has no column to clear.
|
|
229
233
|
...optional((document.rowAccess as string | null | undefined) ?? undefined, 'rowAccess'),
|
|
230
234
|
...optional((document.markingsColumn as string | null | undefined) ?? undefined, 'markingsColumn'),
|
|
235
|
+
// Classified at creation: a PII type is never created as `internal` first.
|
|
236
|
+
...optional(objectTypeSensitivity(document), 'sensitivity'),
|
|
231
237
|
datasetRevisionId: revisionId,
|
|
232
238
|
properties: properties.map((entry) => {
|
|
233
239
|
const property = entry as Record<string, unknown>
|
|
@@ -258,7 +264,7 @@ export async function objectTypeCreateBody(
|
|
|
258
264
|
apiName,
|
|
259
265
|
displayName: String(property.displayName ?? apiName),
|
|
260
266
|
...optional(property.description as string | undefined, 'description'),
|
|
261
|
-
propertyType: (property.
|
|
267
|
+
propertyType: declaredPropertyType(property, ids.sharedPropertyTypeByApiName),
|
|
262
268
|
dataType: String(property.valueType ?? 'string'),
|
|
263
269
|
column,
|
|
264
270
|
...(field === undefined || field === null ? {} : { field }),
|
|
@@ -271,6 +277,8 @@ export async function objectTypeCreateBody(
|
|
|
271
277
|
// Same three-state collapse as the shared field beside it: a CREATE has
|
|
272
278
|
// nothing to detach from, and the create body's uuid is not nullable.
|
|
273
279
|
...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
|
|
280
|
+
// The field's own classification, when the file gives one other than its object type's.
|
|
281
|
+
...fieldSensitivityOf(property, objectTypeSensitivity(document)),
|
|
274
282
|
}
|
|
275
283
|
}),
|
|
276
284
|
columnMappings: Object.entries(backing.mapping).map(([propertyApiName, entry]) => {
|
|
@@ -283,6 +291,29 @@ export async function objectTypeCreateBody(
|
|
|
283
291
|
}
|
|
284
292
|
}
|
|
285
293
|
|
|
294
|
+
/**
|
|
295
|
+
* A field's own classification as its file states it (`sensitivity` on the
|
|
296
|
+
* property), or nothing: absent, `null`, or the same as its object type's
|
|
297
|
+
* classification, the field takes the object type's. Writing the object's own
|
|
298
|
+
* value would pin it, and a draft read could not tell the two apart.
|
|
299
|
+
*/
|
|
300
|
+
export function fieldSensitivityOf(property: Record<string, unknown>, objectSensitivity: unknown): { sensitivity?: string } {
|
|
301
|
+
const own = property.sensitivity
|
|
302
|
+
const objectClass = objectSensitivity && typeof objectSensitivity === 'object' ? (objectSensitivity as { classification?: unknown }).classification : undefined
|
|
303
|
+
return typeof own === 'string' && own !== objectClass ? { sensitivity: own } : {}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* An object type's classification as its file states it: `governance.sensitivity`
|
|
308
|
+
* (as a pull writes it), else a top-level `sensitivity`. The create and update
|
|
309
|
+
* routes take it as a top-level `sensitivity: { classification, categories }`.
|
|
310
|
+
*/
|
|
311
|
+
export function objectTypeSensitivity(document: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
312
|
+
const governance = document.governance as { sensitivity?: unknown } | undefined
|
|
313
|
+
const value = governance && typeof governance === 'object' && governance.sensitivity !== undefined ? governance.sensitivity : document.sensitivity
|
|
314
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
|
|
315
|
+
}
|
|
316
|
+
|
|
286
317
|
/** `PUT /object-types/:apiName` takes metadata only — fields move by draft command. */
|
|
287
318
|
export function objectTypePatchBody(
|
|
288
319
|
file: AuthoredFile,
|
|
@@ -396,6 +427,24 @@ export function metricCreateBody(
|
|
|
396
427
|
* and invisible afterwards, because the next `pull` strips it again while contract
|
|
397
428
|
* digests and binding rows keep keying off it.
|
|
398
429
|
*/
|
|
430
|
+
/**
|
|
431
|
+
* How a new field is read when its file does not say: a date or timestamp is a
|
|
432
|
+
* `time` property (so a metric can take it as its timeseries and a `where` can
|
|
433
|
+
* compare it), anything else an `attribute`. A field that implements a shared
|
|
434
|
+
* field without its own `propertyType` stays `attribute`, as before: the shared
|
|
435
|
+
* field's own type is the draft's to say. Only a field being created takes the
|
|
436
|
+
* default; a field already on the draft keeps what it has unless the file says
|
|
437
|
+
* otherwise (`planProperties` changes only what a file states).
|
|
438
|
+
*/
|
|
439
|
+
export function declaredPropertyType(property: Record<string, unknown>, sharedPropertyTypes?: ReadonlyMap<string, string>): string {
|
|
440
|
+
if (typeof property.propertyType === 'string') return property.propertyType
|
|
441
|
+
// An implementer takes its shared field's type, as the service does when the body leaves it out.
|
|
442
|
+
if (property.sharedField !== undefined && property.sharedField !== null) {
|
|
443
|
+
return (typeof property.sharedField === 'string' && sharedPropertyTypes?.get(property.sharedField)) || 'attribute'
|
|
444
|
+
}
|
|
445
|
+
return property.valueType === 'timestamp' || property.valueType === 'date' ? 'time' : 'attribute'
|
|
446
|
+
}
|
|
447
|
+
|
|
399
448
|
export function actionWithoutMintedIds(document: Record<string, unknown>): Record<string, unknown> {
|
|
400
449
|
const drop = (value: unknown): unknown => {
|
|
401
450
|
if (Array.isArray(value)) return value.map(drop)
|
package/src/blueprint/diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { actionWithoutMintedIds } from './compile'
|
|
2
|
-
import { toFiles } from './projection'
|
|
2
|
+
import { toFiles, withoutImpliedTiming } from './projection'
|
|
3
3
|
import {
|
|
4
4
|
ARTIFACT_KINDS,
|
|
5
5
|
type ArtifactKind,
|
|
@@ -182,13 +182,45 @@ function matches(
|
|
|
182
182
|
local: Record<string, unknown>,
|
|
183
183
|
live: Record<string, unknown>,
|
|
184
184
|
): boolean {
|
|
185
|
+
if (kind === 'object-type') return objectTypeMatches(local, live)
|
|
185
186
|
if (kind !== 'action') return satisfies(local, live)
|
|
186
187
|
// Compared against what `apply` will actually SEND, which is the file minus every id
|
|
187
188
|
// the service mints. The live side is the projection, which already strips them — so
|
|
188
189
|
// comparing the raw file made a pasted export differ forever, on ids neither side
|
|
189
190
|
// could ever agree about.
|
|
191
|
+
// A postcondition's implied `timing: after` is the same Action written or not (`withoutImpliedTiming`).
|
|
190
192
|
const authored = actionWithoutMintedIds(local)
|
|
191
|
-
|
|
193
|
+
for (const outcome of Array.isArray(authored.businessOutcomes) ? authored.businessOutcomes : []) {
|
|
194
|
+
for (const pc of Array.isArray((outcome as { postconditions?: unknown })?.postconditions) ? (outcome as { postconditions: unknown[] }).postconditions : []) withoutImpliedTiming((pc as { assertion?: unknown })?.assertion)
|
|
195
|
+
}
|
|
196
|
+
const stored = structuredClone(live)
|
|
197
|
+
for (const outcome of Array.isArray(stored.businessOutcomes) ? stored.businessOutcomes : []) {
|
|
198
|
+
for (const pc of Array.isArray((outcome as { postconditions?: unknown })?.postconditions) ? (outcome as { postconditions: unknown[] }).postconditions : []) withoutImpliedTiming((pc as { assertion?: unknown })?.assertion)
|
|
199
|
+
}
|
|
200
|
+
return satisfies(authored, stored) && satisfies(stored, authored)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* An object type's file against its live projection, with each field's own
|
|
205
|
+
* classification compared both ways: a file that leaves a field's
|
|
206
|
+
* `sensitivity` out (or gives its object type's) means "takes the object's",
|
|
207
|
+
* so a live field holding its own differs from it, and the projection writes
|
|
208
|
+
* a field at its object's value without one.
|
|
209
|
+
*/
|
|
210
|
+
function objectTypeMatches(local: Record<string, unknown>, live: Record<string, unknown>): boolean {
|
|
211
|
+
const classOf = (doc: Record<string, unknown>) => ((doc.governance as { sensitivity?: { classification?: unknown } } | undefined)?.sensitivity ?? (doc.sensitivity as { classification?: unknown } | undefined))?.classification
|
|
212
|
+
const objectClass = classOf(local)
|
|
213
|
+
const own = (doc: Record<string, unknown>, cls: unknown) => new Map(
|
|
214
|
+
(Array.isArray(doc.properties) ? (doc.properties as Array<Record<string, unknown>>) : [])
|
|
215
|
+
.filter((p) => typeof p.apiName === 'string' && !p.calculation)
|
|
216
|
+
.map((p) => [p.apiName as string, typeof p.sensitivity === 'string' && p.sensitivity !== cls ? p.sensitivity : null] as const),
|
|
217
|
+
)
|
|
218
|
+
const authored = structuredClone(local)
|
|
219
|
+
for (const p of Array.isArray(authored.properties) ? (authored.properties as Array<Record<string, unknown>>) : []) delete p.sensitivity
|
|
220
|
+
if (!satisfies(authored, live)) return false
|
|
221
|
+
const wanted = own(local, objectClass)
|
|
222
|
+
const held = own(live, classOf(live))
|
|
223
|
+
return [...wanted].every(([name, value]) => !held.has(name) || held.get(name) === value)
|
|
192
224
|
}
|
|
193
225
|
|
|
194
226
|
export function diff(
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Blueprint metric query, as `frontera blueprint metric query` and the MCP
|
|
3
|
+
* `blueprint_query_metric` send it to `POST /v1/blueprint/metrics/:apiName/query`
|
|
4
|
+
* (the route an App's `useMetric` and an Agent's `blueprint_query_metric` use).
|
|
5
|
+
*
|
|
6
|
+
* Every part is optional: the metric's measures (all when absent), the
|
|
7
|
+
* dimensions to group by, a time window (an ISO duration back from the newest
|
|
8
|
+
* record, or a start and an end), a grain for a series (the metric's timeseries
|
|
9
|
+
* dimension is added to the dimensions, which the service requires), and a
|
|
10
|
+
* comparison with the window before.
|
|
11
|
+
*
|
|
12
|
+
* A duration window does NOT end at now. The service anchors it on the
|
|
13
|
+
* WATERMARK, the newest value of the metric's timeseries in the data
|
|
14
|
+
* (`timeFilter` in the service's `metric-sql.ts`: `col > max(col) - P7D AND
|
|
15
|
+
* col <= max(col)`), so a "last 7 days" number keeps its data when a load runs
|
|
16
|
+
* behind. `resolveRangeWindow` reads that watermark so a caller can be shown the
|
|
17
|
+
* window it got.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const METRIC_GRAINS = ['hour', 'day', 'week', 'month', 'quarter', 'year'] as const
|
|
21
|
+
export type MetricGrain = (typeof METRIC_GRAINS)[number]
|
|
22
|
+
|
|
23
|
+
export interface MetricQueryOptions {
|
|
24
|
+
measures?: string[]
|
|
25
|
+
dimensions?: string[]
|
|
26
|
+
grain?: string
|
|
27
|
+
/** An ISO-8601 duration back from the newest record (see the header): P7D, PT12H, P3M. */
|
|
28
|
+
range?: string
|
|
29
|
+
from?: string
|
|
30
|
+
to?: string
|
|
31
|
+
compare?: boolean | { start: string; end: string }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MetricQueryBody {
|
|
35
|
+
measures?: string[]
|
|
36
|
+
dimensions?: string[]
|
|
37
|
+
grain?: MetricGrain
|
|
38
|
+
timeRange?: { isoDuration?: string; start?: string; end?: string }
|
|
39
|
+
compare?: true | { start: string; end: string }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the route answers: rows keyed by dimension and measure names. */
|
|
43
|
+
export interface MetricQueryResult {
|
|
44
|
+
data?: Array<Record<string, unknown>>
|
|
45
|
+
rows?: Array<Record<string, unknown>>
|
|
46
|
+
truncated?: boolean
|
|
47
|
+
compareWindow?: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/
|
|
51
|
+
|
|
52
|
+
/** A full ISO timestamp: a bare date is read as its midnight UTC (the query engine refuses a date alone). */
|
|
53
|
+
export function fullTimestamp(raw: string, what: string): string {
|
|
54
|
+
const text = raw.trim()
|
|
55
|
+
const value = /^\d{4}-\d{2}-\d{2}$/.test(text) ? `${text}T00:00:00Z` : text
|
|
56
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/.test(value) || Number.isNaN(Date.parse(value))) {
|
|
57
|
+
throw new Error(`${what} must be an ISO date or timestamp, e.g. 2026-10-01 or 2026-10-01T00:00:00Z (not "${raw}")`)
|
|
58
|
+
}
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const names = (list: string[] | undefined) => [...new Set((list ?? []).map((s) => s.trim()).filter(Boolean))]
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The request body for the options; throws an Error that says what to change.
|
|
66
|
+
* `timeseries` is the metric's timeseries dimension (null when it has none,
|
|
67
|
+
* undefined when it could not be read): a grain adds it to the dimensions.
|
|
68
|
+
*/
|
|
69
|
+
export function metricQueryBody(opts: MetricQueryOptions, timeseries: string | null | undefined): MetricQueryBody {
|
|
70
|
+
const body: MetricQueryBody = {}
|
|
71
|
+
const measures = names(opts.measures)
|
|
72
|
+
if (measures.length) body.measures = measures
|
|
73
|
+
const dimensions = names(opts.dimensions)
|
|
74
|
+
if (opts.grain !== undefined) {
|
|
75
|
+
if (!(METRIC_GRAINS as readonly string[]).includes(opts.grain)) throw new Error(`grain must be one of ${METRIC_GRAINS.join(', ')} (not "${opts.grain}")`)
|
|
76
|
+
if (timeseries === null) throw new Error('this metric has no timeseries dimension, so it has no series by grain: leave the grain out, or give the metric `timeseries:` in its file')
|
|
77
|
+
if (timeseries && !dimensions.includes(timeseries)) dimensions.push(timeseries)
|
|
78
|
+
body.grain = opts.grain as MetricGrain
|
|
79
|
+
}
|
|
80
|
+
if (dimensions.length) body.dimensions = dimensions
|
|
81
|
+
if (opts.range !== undefined && (opts.from !== undefined || opts.to !== undefined)) throw new Error('give the window as a range (P7D) or as from and to, not both')
|
|
82
|
+
if (opts.range !== undefined) {
|
|
83
|
+
const d = opts.range.trim().toUpperCase()
|
|
84
|
+
if (!ISO_DURATION.test(d)) throw new Error(`range must be an ISO-8601 duration, e.g. P7D, PT12H or P3M (not "${opts.range}")`)
|
|
85
|
+
body.timeRange = { isoDuration: d }
|
|
86
|
+
} else if (opts.from !== undefined || opts.to !== undefined) {
|
|
87
|
+
body.timeRange = {
|
|
88
|
+
...(opts.from !== undefined ? { start: fullTimestamp(opts.from, 'from') } : {}),
|
|
89
|
+
...(opts.to !== undefined ? { end: fullTimestamp(opts.to, 'to') } : {}),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (opts.compare === true) {
|
|
93
|
+
if (!body.timeRange) throw new Error('compare reads the window before the one given: give a range (P7D) or from and to')
|
|
94
|
+
body.compare = true
|
|
95
|
+
} else if (opts.compare && typeof opts.compare === 'object') {
|
|
96
|
+
body.compare = { start: fullTimestamp(opts.compare.start, 'compare.start'), end: fullTimestamp(opts.compare.end, 'compare.end') }
|
|
97
|
+
}
|
|
98
|
+
return body
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The rows of an answer, whichever envelope it came in. */
|
|
102
|
+
export function metricRows(result: MetricQueryResult | Array<Record<string, unknown>> | null | undefined): Array<Record<string, unknown>> {
|
|
103
|
+
if (Array.isArray(result)) return result
|
|
104
|
+
if (Array.isArray(result?.data)) return result.data
|
|
105
|
+
if (Array.isArray(result?.rows)) return result.rows
|
|
106
|
+
return []
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The timeseries dimension of a metric row as the service gives it (`definition.timeseries`); null when it has none. */
|
|
110
|
+
export function timeseriesOf(metric: unknown): string | null {
|
|
111
|
+
const m = metric as { definition?: { timeseries?: unknown }; timeseries?: unknown } | null
|
|
112
|
+
const t = m?.definition?.timeseries ?? m?.timeseries
|
|
113
|
+
return typeof t === 'string' && t ? t : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The window a duration range covered, as the service resolved it: after
|
|
118
|
+
* `newest − range`, up to and including `newest`, where `newest` is the latest
|
|
119
|
+
* value of the metric's timeseries in the data. Null bounds when the newest
|
|
120
|
+
* value could not be read; `note` says the rule either way.
|
|
121
|
+
*/
|
|
122
|
+
export interface RangeWindow {
|
|
123
|
+
range: string
|
|
124
|
+
/** The property the window is over, when known. */
|
|
125
|
+
timeseries: string | null
|
|
126
|
+
/** The newest value of it in the data: where the window ends. */
|
|
127
|
+
newest: string | null
|
|
128
|
+
/** Exclusive. */
|
|
129
|
+
after: string | null
|
|
130
|
+
/** Inclusive: the newest value. */
|
|
131
|
+
through: string | null
|
|
132
|
+
note: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What `resolveRangeWindow` reads, so the CLI (`PlatformApi`) and the MCP tool (`serviceCall`) share it. */
|
|
136
|
+
export interface RangeWindowReads {
|
|
137
|
+
/** The metric as `GET /v1/blueprint/metrics/:apiName` answers. */
|
|
138
|
+
metric(): Promise<unknown>
|
|
139
|
+
/** The object types as `GET /v1/blueprint/object-types` answers. */
|
|
140
|
+
objectTypes(): Promise<unknown>
|
|
141
|
+
/** `POST /v1/blueprint/aggregate`. */
|
|
142
|
+
aggregate(body: unknown): Promise<unknown>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const DURATION_PARTS = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `at` moved back by an ISO duration, years and months in calendar units, as the
|
|
149
|
+
* engine's `INTERVAL` arithmetic does (and the service's `subtractIsoDuration`).
|
|
150
|
+
* Null for a duration it does not read (fractional seconds).
|
|
151
|
+
*/
|
|
152
|
+
export function minusDuration(at: Date, duration: string): Date | null {
|
|
153
|
+
const m = DURATION_PARTS.exec(duration)
|
|
154
|
+
if (!m) return null
|
|
155
|
+
const [years, months, weeks, days, hours, minutes, seconds] = m.slice(1, 8).map((v) => (v ? Number(v) : 0)) as number[]
|
|
156
|
+
const out = new Date(at)
|
|
157
|
+
if (years || months) out.setUTCMonth(out.getUTCMonth() - (years! * 12 + months!))
|
|
158
|
+
return new Date(out.getTime() - (((weeks! * 7 + days!) * 24 + hours!) * 60 + minutes!) * 60_000 - seconds! * 1000)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A timestamp as the query engine returns it (`2026-10-20 21:34:00+00`), as ISO; null when it is not one. */
|
|
162
|
+
export function engineTimestamp(raw: unknown): string | null {
|
|
163
|
+
if (raw instanceof Date) return Number.isNaN(raw.getTime()) ? null : raw.toISOString()
|
|
164
|
+
if (typeof raw !== 'string' || !raw.trim()) return null
|
|
165
|
+
const text = raw.trim().replace(/^(\d{4}-\d{2}-\d{2}) /, '$1T').replace(/([+-]\d{2})$/, '$1:00')
|
|
166
|
+
const ms = Date.parse(text)
|
|
167
|
+
return Number.isNaN(ms) ? null : new Date(ms).toISOString()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const listOf = (value: unknown): Array<Record<string, unknown>> => {
|
|
171
|
+
const list = Array.isArray(value) ? value : (value as { data?: unknown } | null)?.data
|
|
172
|
+
return Array.isArray(list) ? list.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object') : []
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The window a `range` covered: reads the newest value of the metric's
|
|
177
|
+
* timeseries with the same grants the metric query ran under (an aggregate over
|
|
178
|
+
* the metric's object type, the set the service's watermark is taken over).
|
|
179
|
+
* Never throws: a read that fails leaves the bounds null and the rule stated.
|
|
180
|
+
*/
|
|
181
|
+
export async function resolveRangeWindow(range: string, reads: RangeWindowReads): Promise<RangeWindow> {
|
|
182
|
+
let timeseries: string | null = null
|
|
183
|
+
let newest: string | null = null
|
|
184
|
+
try {
|
|
185
|
+
const metric = (await reads.metric()) as { objectTypeId?: unknown; objectTypeApiName?: unknown } | null
|
|
186
|
+
timeseries = timeseriesOf(metric)
|
|
187
|
+
const typeId = metric?.objectTypeId
|
|
188
|
+
const objectType = typeof metric?.objectTypeApiName === 'string'
|
|
189
|
+
? metric.objectTypeApiName
|
|
190
|
+
: listOf(await reads.objectTypes()).find((t) => t.id === typeId)?.apiName
|
|
191
|
+
if (timeseries && typeof objectType === 'string') {
|
|
192
|
+
const answer = await reads.aggregate({
|
|
193
|
+
objectSet: { type: 'base', objectType },
|
|
194
|
+
groupBy: [],
|
|
195
|
+
aggregations: [{ fn: 'max', property: timeseries, alias: 'newest' }],
|
|
196
|
+
}) as { rows?: unknown; data?: unknown } | null
|
|
197
|
+
const rows = listOf(answer?.rows ?? answer?.data ?? answer)
|
|
198
|
+
newest = engineTimestamp(rows[0]?.newest)
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// The rule is still worth stating without the timestamps.
|
|
202
|
+
}
|
|
203
|
+
const start = newest ? minusDuration(new Date(newest), range) : null
|
|
204
|
+
const over = timeseries ?? 'the metric’s timeseries'
|
|
205
|
+
return {
|
|
206
|
+
range,
|
|
207
|
+
timeseries,
|
|
208
|
+
newest,
|
|
209
|
+
after: start ? start.toISOString() : null,
|
|
210
|
+
through: start ? newest : null,
|
|
211
|
+
note: start
|
|
212
|
+
? `${range} back from the newest ${over} in the data (${newest}), not from now: after ${start.toISOString()}, through ${newest}.`
|
|
213
|
+
: `${range} is counted back from the newest ${over} in the data, not from now.`,
|
|
214
|
+
}
|
|
215
|
+
}
|