@frontera-sdk/blueprint 1.43.10 → 1.44.0

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 CHANGED
@@ -23,6 +23,32 @@ function App() {
23
23
  }
24
24
  ```
25
25
 
26
+ ## Generate workspace types
27
+
28
+ Inside a Frontera App, run:
29
+
30
+ ```bash
31
+ frontera blueprint generate-types
32
+ ```
33
+
34
+ This writes `src/generated/frontera-blueprint.ts`, which augments the SDK from
35
+ the active Blueprint slice granted to the authenticated workspace. When that
36
+ file is part of the TypeScript project, object names and returned rows are
37
+ inferred automatically; filter, projection, and ordering property names are
38
+ checked as well.
39
+
40
+ ```tsx
41
+ const shipments = useObjects('Shipment', {
42
+ where: { property: 'status', op: 'eq', value: 'delayed' },
43
+ select: ['shipmentId', 'status'],
44
+ })
45
+ ```
46
+
47
+ Commit the generated file. Run `frontera blueprint generate-types --check` in
48
+ an authenticated CI freshness gate; do not generate during install or build.
49
+ Apps without a generated registry retain the existing string-keyed behavior,
50
+ and explicit calls such as `useObjects<MyRow>('Shipment')` remain supported.
51
+
26
52
  ## Filter on the server
27
53
 
28
54
  `where` compiles into the object set, so the server filters and pages.
@@ -31,6 +57,22 @@ total — a filter matching 8,961 records renders 5 of them under "Page 1 of 1".
31
57
  For the same reason a total is its own query (`useAggregate` with a count over
32
58
  the same object set), never `rows.length`.
33
59
 
60
+ ## Page with cursors
61
+
62
+ The query API uses stable cursor pagination, not page numbers. Omit
63
+ `pageToken` for the first request, then pass the response's `nextPageToken` to
64
+ the next request:
65
+
66
+ ```tsx
67
+ const rows = useObjects('Shipment', { pageSize: 25, pageToken })
68
+ const next = rows.data?.nextPageToken
69
+ ```
70
+
71
+ `nextPageToken` is present exactly when `hasMore`. Keep previously received
72
+ tokens in UI state if the experience needs a Previous button. Changing a
73
+ filter, projection, or ordering invalidates that history and must return to the
74
+ first page.
75
+
34
76
  ## Entry points
35
77
 
36
78
  | Import | What it is |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frontera-sdk/blueprint",
3
- "version": "1.43.10",
4
- "description": "React hooks for reading Blueprint data from inside a Frontera app.",
3
+ "version": "1.44.0",
4
+ "description": "React hooks for reading Blueprint data and invoking governed Actions from inside a Frontera app.",
5
5
  "keywords": [
6
6
  "frontera",
7
7
  "blueprint",
@@ -33,6 +33,18 @@
33
33
  "types": "./src/hooks.ts",
34
34
  "import": "./src/hooks.ts"
35
35
  },
36
+ "./action-types": {
37
+ "types": "./src/action-types.ts",
38
+ "import": "./src/action-types.ts"
39
+ },
40
+ "./action-client": {
41
+ "types": "./src/action-client.ts",
42
+ "import": "./src/action-client.ts"
43
+ },
44
+ "./action-hooks": {
45
+ "types": "./src/action-hooks.ts",
46
+ "import": "./src/action-hooks.ts"
47
+ },
36
48
  "./provider": {
37
49
  "types": "./src/provider.tsx",
38
50
  "import": "./src/provider.tsx"
@@ -40,11 +52,11 @@
40
52
  },
41
53
  "scripts": {
42
54
  "test": "bun test",
43
- "typecheck": "bunx tsc --noEmit",
55
+ "typecheck": "bunx tsc --noEmit && bunx tsc --noEmit -p type-tests/tsconfig.json && bunx tsc --noEmit -p type-tests/tsconfig.loose.json",
44
56
  "smoke": "bun run scripts/smoke.ts"
45
57
  },
46
58
  "dependencies": {
47
- "@frontera-sdk/core": "1.43.6"
59
+ "@frontera-sdk/core": "1.43.10"
48
60
  },
49
61
  "peerDependencies": {
50
62
  "@tanstack/react-query": "^5.90.21",
@@ -0,0 +1,175 @@
1
+ import type { FronteraClient } from '@frontera-sdk/core/client'
2
+ import type {
3
+ ActionDescriptor,
4
+ ActionRequest,
5
+ ActionRequestLifecycle,
6
+ SubmitActionInput,
7
+ } from './action-types'
8
+
9
+ const BASE = '/v1/blueprint/governed-actions'
10
+
11
+ /**
12
+ * The governed write plane.
13
+ *
14
+ * Authorized against the PERSON, never the app. A hosted app is handed a token
15
+ * carrying the signed-in user's id, and every call is checked against that
16
+ * user's organization role, their workspace membership, and the workspace's
17
+ * grant on the object type. So the same page can offer a button to one
18
+ * colleague and not another, and neither the app nor its author decides which.
19
+ *
20
+ * A workspace key cannot invoke at all — its principal belongs to no
21
+ * organization member — which is why a scaffolded dev host, holding one, will
22
+ * read fine and refuse every write. See `README` on `frontera app init`.
23
+ */
24
+ export class ActionClient {
25
+ constructor(private readonly client: FronteraClient) {}
26
+
27
+ /**
28
+ * Actions this user may invoke, here, now.
29
+ *
30
+ * An empty list is ambiguous ON PURPOSE — unpublished, undeployed and
31
+ * unpermitted are indistinguishable to a caller, so a probe cannot map what
32
+ * exists. That is right for security and hostile to debugging, so treat an
33
+ * unexpected empty list as a question about the CALLER's permissions first.
34
+ */
35
+ discover(): Promise<ActionDescriptor[]> {
36
+ return this.client.request<ActionDescriptor[]>(`${BASE}/discovery`)
37
+ }
38
+
39
+ /**
40
+ * Submit one Action. Returns as soon as the Request is recorded — NOT when
41
+ * the effect has landed.
42
+ *
43
+ * The write is durable from this point: it survives a closed tab, a restarted
44
+ * server, and a worker that is not running yet. What it does not do is finish
45
+ * synchronously, so a UI that renders success here is lying. Poll the Request
46
+ * (`useActionRequest`) and show the lifecycle.
47
+ */
48
+ async submit(
49
+ action: ActionDescriptor,
50
+ input: SubmitActionInput,
51
+ ): Promise<ActionRequest> {
52
+ const idempotencyKey = input.idempotencyKey ?? mintIdempotencyKey()
53
+ return this.client.request<ActionRequest>(
54
+ `${BASE}/actions/${encodeURIComponent(action.apiName)}/requests`,
55
+ {
56
+ method: 'POST',
57
+ headers: { 'idempotency-key': idempotencyKey },
58
+ // Wrapped, and the wrapper is EXACT: the route accepts a body whose
59
+ // keys are precisely `['invocation']` and refuses anything else with
60
+ // "Governed Action HTTP command body is invalid." — a message that
61
+ // names the body rather than the field, so sending the envelope at the
62
+ // top level reads like a malformed invocation instead of a missing
63
+ // wrapper.
64
+ body: { invocation: buildInvocation(action, input) },
65
+ },
66
+ )
67
+ }
68
+
69
+ requests(lifecycle?: readonly ActionRequestLifecycle[]): Promise<ActionRequest[]> {
70
+ return this.client.request<ActionRequest[]>(`${BASE}/requests`, {
71
+ query: lifecycle?.length ? { lifecycle: lifecycle.join(',') } : undefined,
72
+ })
73
+ }
74
+
75
+ request(requestId: string): Promise<ActionRequest> {
76
+ return this.client.request<ActionRequest>(`${BASE}/requests/${encodeURIComponent(requestId)}`)
77
+ }
78
+
79
+ /**
80
+ * Approve or reject. Separate from `submit` because it is a different act by
81
+ * a different person — an Action with separation of duties refuses a decision
82
+ * from whoever submitted it.
83
+ */
84
+ decide(requestId: string, decision: 'approve' | 'reject', reason: string): Promise<ActionRequest> {
85
+ return this.client.request<ActionRequest>(
86
+ `${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,
87
+ { method: 'POST', body: { decision, reason } },
88
+ )
89
+ }
90
+
91
+ /**
92
+ * Cancel takes NO body. It reads the request and the caller from the URL and
93
+ * the credential; a `reason` sent here is refused as an invalid command body
94
+ * rather than ignored, because the route accepts an exact key set.
95
+ */
96
+ cancel(requestId: string): Promise<ActionRequest> {
97
+ return this.client.request<ActionRequest>(
98
+ `${BASE}/requests/${encodeURIComponent(requestId)}/cancel`,
99
+ { method: 'POST' },
100
+ )
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The wire envelope, assembled from the flat input a caller actually has.
106
+ *
107
+ * Exported for the tests: this is the part with a trap in it, and the trap is
108
+ * silent — a wrong shape comes back as "Action invocation is invalid" with no
109
+ * field named.
110
+ */
111
+ export function buildInvocation(
112
+ action: ActionDescriptor,
113
+ input: SubmitActionInput,
114
+ ): Record<string, unknown> {
115
+ const invocation: Record<string, unknown> = { input: { ...input.input } }
116
+
117
+ if (action.subject.mode === 'existing') {
118
+ if (!input.objectId) {
119
+ throw new Error(
120
+ `"${action.apiName}" changes an existing ${action.subject.objectTypeId}, so it needs an objectId.`,
121
+ )
122
+ }
123
+ invocation.subjectRef = {
124
+ objectTypeId: action.subject.objectTypeId,
125
+ objectId: input.objectId,
126
+ }
127
+ if (input.expectedVersion !== undefined) {
128
+ // A STRING here, deliberately: the subject version is an opaque token,
129
+ // while the compare-and-set parameter below is the numeric record
130
+ // version. Same number, two types, two meanings.
131
+ invocation.expectedSubjectVersion = String(input.expectedVersion)
132
+ }
133
+ } else if (input.objectId) {
134
+ throw new Error(`"${action.apiName}" creates an object, so it takes no objectId.`)
135
+ }
136
+
137
+ // Fed to the Action's own compare-and-set parameter when it declares one,
138
+ // and only then — an Action without it would refuse the unknown key.
139
+ const casParameter = compareAndSetParameter(action)
140
+ if (casParameter && input.expectedVersion !== undefined) {
141
+ const parameters = invocation.input as Record<string, unknown>
142
+ if (!(casParameter in parameters)) parameters[casParameter] = Number(input.expectedVersion)
143
+ }
144
+
145
+ if (input.reason !== undefined) invocation.reason = input.reason
146
+ if (input.correlationId !== undefined) invocation.correlationId = input.correlationId
147
+ return invocation
148
+ }
149
+
150
+ /**
151
+ * The parameter carrying the record version, read off the published schema
152
+ * rather than assumed by name.
153
+ */
154
+ function compareAndSetParameter(action: ActionDescriptor): string | null {
155
+ const input = (action.inputSchema as { properties?: Record<string, unknown> } | undefined)
156
+ ?.properties?.input as { properties?: Record<string, unknown> } | undefined
157
+ const properties = input?.properties
158
+ if (!properties) return null
159
+ return 'expectedVersion' in properties ? 'expectedVersion' : null
160
+ }
161
+
162
+ /**
163
+ * One key per user intent.
164
+ *
165
+ * The service dedupes by this: the same key with a different invocation is
166
+ * REFUSED, and the same key with the same invocation returns the original
167
+ * Request rather than acting twice. So it must be stable across retries of one
168
+ * intent and different between two intents — which is exactly the lifetime of
169
+ * a single `submit` call, not of a component or a session.
170
+ */
171
+ function mintIdempotencyKey(): string {
172
+ const random = globalThis.crypto?.randomUUID?.()
173
+ ?? Math.random().toString(36).slice(2).padEnd(22, '0')
174
+ return `frontera-app-${random}`
175
+ }
@@ -0,0 +1,210 @@
1
+ import { createContext, useContext, useEffect } from 'react'
2
+ import {
3
+ useMutation,
4
+ useQuery,
5
+ useQueryClient,
6
+ type UseMutationResult,
7
+ type UseQueryOptions,
8
+ type UseQueryResult,
9
+ } from '@tanstack/react-query'
10
+
11
+ import type { ActionClient } from './action-client'
12
+ import { blueprintKeys } from './hooks'
13
+ import {
14
+ actionEffectOf,
15
+ isTerminalLifecycle,
16
+ type ActionDescriptor,
17
+ type ActionRequest,
18
+ type ActionRequestLifecycle,
19
+ type SubmitActionInput,
20
+ } from './action-types'
21
+
22
+ export const actionKeys = {
23
+ all: ['blueprint', 'actions'] as const,
24
+ discovery: () => ['blueprint', 'actions', 'discovery'] as const,
25
+ requests: (lifecycle?: readonly ActionRequestLifecycle[]) =>
26
+ ['blueprint', 'actions', 'requests', lifecycle?.join(',') ?? 'all'] as const,
27
+ request: (requestId: string) => ['blueprint', 'actions', 'request', requestId] as const,
28
+ }
29
+
30
+ export const FronteraActionContext = createContext<ActionClient | null>(null)
31
+
32
+ export function useActionClient(): ActionClient {
33
+ const client = useContext(FronteraActionContext)
34
+ if (!client) {
35
+ throw new Error(
36
+ 'No ActionClient in context. Wrap the tree in FronteraActionContext.Provider — createFronteraApp does this for you in a Frontera app.',
37
+ )
38
+ }
39
+ return client
40
+ }
41
+
42
+ type ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn'>
43
+
44
+ /**
45
+ * Actions the signed-in user may invoke.
46
+ *
47
+ * Render buttons from THIS, never from a hard-coded list: the same page must
48
+ * offer different actions to different colleagues, and only the server knows
49
+ * which. A hard-coded button that 403s on click is a worse experience than one
50
+ * that was never drawn.
51
+ *
52
+ * An empty list during development is much more likely to be permissions than
53
+ * a bug in this hook — an Action is hidden unless it is published, deployed,
54
+ * AND its invoke capability is held by the caller's role.
55
+ */
56
+ export function useActions(
57
+ options: ReadOptions<ActionDescriptor[]> = {},
58
+ ): UseQueryResult<ActionDescriptor[], Error> {
59
+ const client = useActionClient()
60
+ return useQuery({
61
+ queryKey: actionKeys.discovery(),
62
+ queryFn: () => client.discover(),
63
+ ...options,
64
+ })
65
+ }
66
+
67
+ /**
68
+ * One Action by name, or `null` when this user may not invoke it.
69
+ *
70
+ * `null` rather than a thrown error, because "you may not do this" is an
71
+ * ordinary state for a UI to be in — it renders nothing, or a disabled control
72
+ * with an explanation — not an exception.
73
+ */
74
+ export function useAction(
75
+ apiName: string,
76
+ options: ReadOptions<ActionDescriptor[]> = {},
77
+ ): { action: ActionDescriptor | null; isLoading: boolean; error: Error | null } {
78
+ const { data, isLoading, error } = useActions(options)
79
+ return {
80
+ action: data?.find((entry) => entry.apiName === apiName) ?? null,
81
+ isLoading,
82
+ error: error ?? null,
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Submit an Action.
88
+ *
89
+ * Resolves when the Request is RECORDED, not when the effect has landed —
90
+ * dispatch runs on a background worker. A button whose success toast fires here
91
+ * is claiming something it does not know, so pass the returned request id to
92
+ * `useActionRequest` and let the lifecycle drive what the user sees.
93
+ *
94
+ * The idempotency key is minted per `mutate` call, which is the correct
95
+ * lifetime: React Query retrying a failed network call reuses the same key and
96
+ * cannot double-apply, while a second click is a second intent and gets its
97
+ * own.
98
+ */
99
+ export function useSubmitAction(
100
+ action: ActionDescriptor | null,
101
+ ): UseMutationResult<ActionRequest, Error, SubmitActionInput> {
102
+ const client = useActionClient()
103
+ const queryClient = useQueryClient()
104
+
105
+ return useMutation<ActionRequest, Error, SubmitActionInput>({
106
+ mutationFn: (input) => {
107
+ if (!action) {
108
+ return Promise.reject(new Error(
109
+ 'This Action is not available to you. Render the control only when `useAction` returns one.',
110
+ ))
111
+ }
112
+ return client.submit(action, input)
113
+ },
114
+ onSuccess: (request) => {
115
+ queryClient.setQueryData(actionKeys.request(request.id), request)
116
+ void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })
117
+ },
118
+ })
119
+ }
120
+
121
+ /**
122
+ * Follow one Request until it settles.
123
+ *
124
+ * Polls while the lifecycle is non-terminal and stops once it is, so a settled
125
+ * Request costs nothing to keep on screen. The interval is deliberately short:
126
+ * the gap between "submitted" and "applied" is the part users find alarming,
127
+ * and the cheapest fix is showing it moving.
128
+ */
129
+ export function useActionRequest(
130
+ requestId: string | null | undefined,
131
+ options: ReadOptions<ActionRequest> & { pollMs?: number } = {},
132
+ ): UseQueryResult<ActionRequest, Error> {
133
+ const client = useActionClient()
134
+ const queryClient = useQueryClient()
135
+ const { pollMs = 1_500, ...queryOptions } = options
136
+
137
+ const result = useQuery({
138
+ queryKey: actionKeys.request(requestId ?? ''),
139
+ queryFn: () => client.request(requestId as string),
140
+ enabled: Boolean(requestId) && queryOptions.enabled !== false,
141
+ refetchInterval: (query) => {
142
+ const lifecycle = query.state.data?.lifecycle
143
+ if (!lifecycle) return pollMs
144
+ return isTerminalLifecycle(lifecycle) ? false : pollMs
145
+ },
146
+ ...queryOptions,
147
+ })
148
+
149
+ /**
150
+ * Refresh what the app is READING once the write has actually landed.
151
+ *
152
+ * Not on submit: at that point the Request is recorded and the object is
153
+ * unchanged, so refetching returns the old values and caches them as fresh —
154
+ * the table would settle on stale data and stay there.
155
+ *
156
+ * Keyed on the CERTAINTY rather than the lifecycle. `confirmed_applied` is
157
+ * the moment the write commits; `succeeded` comes later, after the platform
158
+ * has verified its own promise, and refreshing only then leaves the table
159
+ * showing yesterday's row for the whole verification pass. Nothing about the
160
+ * data changes in that gap.
161
+ *
162
+ * Deliberately not on a refusal: nothing changed, and a refetch there is a
163
+ * request per failure for no new information.
164
+ */
165
+ const landed = actionEffectOf(result.data) === 'applied'
166
+ useEffect(() => {
167
+ if (!landed) return
168
+ void queryClient.invalidateQueries({ queryKey: blueprintKeys.all })
169
+ }, [landed, queryClient])
170
+
171
+ return result
172
+ }
173
+
174
+ /** The queue: Requests in this workspace, optionally narrowed by lifecycle. */
175
+ export function useActionRequests(
176
+ lifecycle?: readonly ActionRequestLifecycle[],
177
+ options: ReadOptions<ActionRequest[]> = {},
178
+ ): UseQueryResult<ActionRequest[], Error> {
179
+ const client = useActionClient()
180
+ return useQuery({
181
+ queryKey: actionKeys.requests(lifecycle),
182
+ queryFn: () => client.requests(lifecycle),
183
+ ...options,
184
+ })
185
+ }
186
+
187
+ /**
188
+ * Approve or reject a Request awaiting a decision.
189
+ *
190
+ * An Action with separation of duties refuses a decision from whoever
191
+ * submitted it, so this will fail for the requester — correctly. Surface that
192
+ * refusal rather than hiding the control: "someone else must approve this" is
193
+ * the information the user needs.
194
+ */
195
+ export function useDecideActionRequest(): UseMutationResult<
196
+ ActionRequest,
197
+ Error,
198
+ { requestId: string; decision: 'approve' | 'reject'; reason: string }
199
+ > {
200
+ const client = useActionClient()
201
+ const queryClient = useQueryClient()
202
+
203
+ return useMutation({
204
+ mutationFn: ({ requestId, decision, reason }) => client.decide(requestId, decision, reason),
205
+ onSuccess: (request) => {
206
+ queryClient.setQueryData(actionKeys.request(request.id), request)
207
+ void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })
208
+ },
209
+ })
210
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Types for the governed write plane.
3
+ *
4
+ * Reads and writes are deliberately separate surfaces. A read is answered from
5
+ * a catalog snapshot; a write is a REQUEST against a durable ledger that may be
6
+ * approved by someone else, dispatched by a background worker minutes later,
7
+ * and reconciled after that. Modelling both as "call the server" would hide the
8
+ * one fact a UI has to show: submitting is not the same as done.
9
+ */
10
+
11
+ /** Where a Request has got to. Only `succeeded` and the failures are terminal. */
12
+ export type ActionRequestLifecycle =
13
+ | 'ready'
14
+ | 'awaiting_approval'
15
+ | 'dispatching'
16
+ | 'finalizing'
17
+ | 'succeeded'
18
+ | 'failed'
19
+ | 'cancelled'
20
+ | 'rejected'
21
+ | 'awaiting_resolution'
22
+
23
+ /**
24
+ * What is known about the effect on the target — NOT whether the request is
25
+ * finished. `outcome_unknown` is the honest state after a dispatch whose
26
+ * outcome could not be established, and a UI must not render it as failure:
27
+ * the write may well have landed.
28
+ */
29
+ export type ActionEffectCertainty =
30
+ | 'not_attempted'
31
+ | 'confirmed_applied'
32
+ | 'confirmed_not_applied'
33
+ | 'outcome_unknown'
34
+
35
+ const TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([
36
+ 'succeeded', 'failed', 'cancelled', 'rejected',
37
+ ])
38
+
39
+ export function isTerminalLifecycle(lifecycle: ActionRequestLifecycle): boolean {
40
+ return TERMINAL.has(lifecycle)
41
+ }
42
+
43
+ /**
44
+ * What to tell the person who pressed the button.
45
+ *
46
+ * Four answers, and they come from the CERTAINTY, not the lifecycle — which is
47
+ * the distinction every app gets wrong, because "succeeded" reads like the
48
+ * finish line and is not.
49
+ *
50
+ * A request reaches `confirmed_applied` the moment the write commits, and then
51
+ * spends a while in `finalizing` while the platform verifies its own promise:
52
+ * it re-reads the target, checks the properties the Action declared it would
53
+ * change, evaluates the postconditions. That verification NEVER undoes the
54
+ * write — its worst outcome is `awaiting_resolution`, which still carries
55
+ * `confirmed_applied` and means a human should look at why the proof was
56
+ * inconclusive. So waiting for `succeeded` before telling someone their ticket
57
+ * exists leaves them staring at a spinner over a ticket that already exists.
58
+ *
59
+ * `uncertain` is the one that must not be collapsed into either neighbour. It
60
+ * means a dispatch was attempted and the outcome could not be established —
61
+ * the connection died mid-commit, and the write may well have landed. Rendering
62
+ * it as failure invites a duplicate; rendering it as success invites a lie. Say
63
+ * it is being checked; the platform reconciles it against the target and the
64
+ * answer arrives on its own.
65
+ */
66
+ export type ActionEffect = 'pending' | 'applied' | 'refused' | 'uncertain'
67
+
68
+ export function actionEffectOf(
69
+ request: Pick<ActionRequest, 'lifecycle' | 'effectCertainty'> | null | undefined,
70
+ ): ActionEffect {
71
+ if (!request) return 'pending'
72
+ switch (request.effectCertainty) {
73
+ case 'confirmed_applied': return 'applied'
74
+ case 'confirmed_not_applied': return 'refused'
75
+ case 'outcome_unknown': return 'uncertain'
76
+ default: break
77
+ }
78
+ // No certainty reported. A terminal lifecycle still answers the question —
79
+ // a rejected or cancelled Request never reached the target at all — while
80
+ // anything else is genuinely still in flight.
81
+ return request.lifecycle && isTerminalLifecycle(request.lifecycle)
82
+ && request.lifecycle !== 'succeeded'
83
+ ? 'refused'
84
+ : 'pending'
85
+ }
86
+
87
+ /**
88
+ * The record version to send with an edit, read off an object instance.
89
+ *
90
+ * The version arrives as `_meta.recordVersion`, and only from the INSTANCE
91
+ * route — a list query's rows do not carry it, and only an editable type has
92
+ * one at all. Reaching into `_meta` by hand is how a caller ends up sending
93
+ * `undefined`, which does not fail: the compare-and-set is simply skipped, and
94
+ * two people overwrite each other with no refusal and no evidence.
95
+ *
96
+ * So a control that edits a row from a table fetches the instance first:
97
+ *
98
+ * ```tsx
99
+ * const instance = useObjectInstance('SupportTicket', selectedId)
100
+ * submit.mutate({ …, expectedVersion: recordVersionOf(instance.data) })
101
+ * ```
102
+ *
103
+ * Returns `undefined` for a type with no overlay, which is correct — there is
104
+ * no version to assert, and the write path does not expect one.
105
+ */
106
+ export function recordVersionOf(instance: unknown): number | undefined {
107
+ if (!instance || typeof instance !== 'object') return undefined
108
+ const meta = (instance as { _meta?: unknown })._meta
109
+ if (!meta || typeof meta !== 'object') return undefined
110
+ const version = (meta as { recordVersion?: unknown }).recordVersion
111
+ return typeof version === 'number' ? version : undefined
112
+ }
113
+
114
+ export interface ActionApprovalPolicy {
115
+ mode: 'none' | 'required'
116
+ threshold?: number
117
+ separationOfDuties?: boolean
118
+ }
119
+
120
+ /**
121
+ * One Action this caller may invoke.
122
+ *
123
+ * Discovery returns ONLY what the caller is authorized for. An Action absent
124
+ * from this list may be unpublished, undeployed, or simply not permitted to
125
+ * this user — the three are indistinguishable here, by design.
126
+ */
127
+ export interface ActionDescriptor {
128
+ actionDefinitionId: string
129
+ apiName: string
130
+ displayName: string
131
+ description: string
132
+ contractDigest: string
133
+ activeReleaseId: string
134
+ availability: string
135
+ subject: { objectTypeId: string; mode: 'existing' | 'create' }
136
+ approval: ActionApprovalPolicy
137
+ /** JSON Schema for the whole invocation envelope, not just the inputs. */
138
+ inputSchema: Record<string, unknown>
139
+ }
140
+
141
+ export interface ActionRequest {
142
+ id: string
143
+ actionDefinitionId: string
144
+ apiName?: string
145
+ lifecycle: ActionRequestLifecycle
146
+ effectCertainty?: ActionEffectCertainty
147
+ createdAt?: string
148
+ updatedAt?: string
149
+ }
150
+
151
+ /**
152
+ * What a caller supplies to invoke an Action.
153
+ *
154
+ * Deliberately flatter than the wire envelope. The service takes a subject
155
+ * reference, a separate string `expectedSubjectVersion`, an `input` map and a
156
+ * `reason`; and an Action with compare-and-set ALSO takes a numeric version as
157
+ * an ordinary input. Two version fields, one string and one number, meaning
158
+ * related but different things, is the kind of contract a hand-written caller
159
+ * gets wrong once and then debugs for an hour. `submit` assembles it.
160
+ */
161
+ export interface SubmitActionInput {
162
+ /** Primary key of the object being changed. Omit only for `mode: 'create'`. */
163
+ objectId?: string
164
+ /** Parameter values, keyed by the Action's parameter API names. */
165
+ input: Record<string, unknown>
166
+ /**
167
+ * The version the caller believes it read. Sent as the subject version AND,
168
+ * when the Action declares a compare-and-set parameter, as that parameter —
169
+ * so a stale write is refused rather than clobbering a concurrent one.
170
+ */
171
+ expectedVersion?: number | string
172
+ /** Required when the Action declares `reason: 'required'`. */
173
+ reason?: string
174
+ correlationId?: string
175
+ /**
176
+ * Reused across retries of the SAME intended effect. Omit and the hook mints
177
+ * one per user intent, which is almost always what you want: a retried
178
+ * network call must not become a second escalation.
179
+ */
180
+ idempotencyKey?: string
181
+ }
@@ -3,6 +3,9 @@ import type {
3
3
  AggregateGroupBy,
4
4
  AggregateRequest,
5
5
  AggregateResponse,
6
+ BlueprintFilterableProperty,
7
+ BlueprintObjectName,
8
+ BlueprintRow,
6
9
  MetricQueryRequest,
7
10
  ObjectInstance,
8
11
  QueryRequest,
@@ -57,19 +60,62 @@ export class BlueprintClient {
57
60
  })
58
61
  }
59
62
 
60
- instance<TProps = Record<string, unknown>>(
61
- objectType: string,
63
+ instance<
64
+ TLegacy extends object = never,
65
+ const TObject extends BlueprintObjectName = BlueprintObjectName,
66
+ >(
67
+ objectType: TObject,
62
68
  pk: string,
63
- ): Promise<ObjectInstance<TProps>> {
64
- return this.client.request<ObjectInstance<TProps>>(
69
+ ): Promise<ObjectInstance<[TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy>> {
70
+ return this.client.request<ObjectInstance<[TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy>>(
65
71
  `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/instances/${encodeURIComponent(pk)}`,
66
72
  )
67
73
  }
68
74
 
69
- propertyValues(objectType: string, property: string): Promise<string[]> {
70
- return this.client.request<string[]>(
71
- `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/properties/${encodeURIComponent(property)}/values`,
72
- )
75
+ /**
76
+ * Distinct values for one property — the source for a filter's options.
77
+ *
78
+ * The service answers `{ kind: 'values', values, truncated }`, not a bare
79
+ * array. This returned the envelope while claiming `string[]`, so every
80
+ * caller that trusted the type got an object where it expected a list — and
81
+ * `.map` on it threw at runtime in code that type-checked.
82
+ *
83
+ * `truncated` is dropped here deliberately: it means the distinct set hit the
84
+ * service's cap, which a filter cannot act on beyond showing what it has.
85
+ * Use `propertyValuesWithTruncation` when it matters.
86
+ */
87
+ async propertyValues<const TObject extends BlueprintObjectName = BlueprintObjectName>(
88
+ objectType: TObject,
89
+ property: BlueprintFilterableProperty<TObject>,
90
+ ): Promise<string[]> {
91
+ return (await this.propertyValuesWithTruncation(objectType, property)).values
92
+ }
93
+
94
+ /**
95
+ * Narrowed against the committed contract, like `instance` above: a property
96
+ * the workspace does not expose for filtering has no distinct-value endpoint,
97
+ * and asking for one is a 400 that only shows up when a filter is opened.
98
+ * Before generation, `BlueprintFilterableProperty` is `string` and this is the
99
+ * loose signature it always was.
100
+ */
101
+ propertyValuesWithTruncation<const TObject extends BlueprintObjectName = BlueprintObjectName>(
102
+ objectType: TObject,
103
+ property: BlueprintFilterableProperty<TObject>,
104
+ ): Promise<{ values: string[]; truncated: boolean }> {
105
+ return this.client
106
+ .request<{ kind?: string; values?: string[]; truncated?: boolean }>(
107
+ `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/properties/${encodeURIComponent(property)}/values`,
108
+ )
109
+ // `Array.isArray`, not a truthiness check: reading `.values` off an ARRAY
110
+ // returns `Array.prototype.values` — the iterator function — so a payload
111
+ // in the older bare-array shape would hand every caller a function where
112
+ // it expected a list, which is a worse failure than the one being fixed.
113
+ .then((payload) => ({
114
+ values: Array.isArray(payload?.values)
115
+ ? payload.values
116
+ : Array.isArray(payload) ? (payload as string[]) : [],
117
+ truncated: payload?.truncated === true,
118
+ }))
73
119
  }
74
120
 
75
121
  metricQuery(apiName: string, request: MetricQueryRequest = {}): Promise<AggregateResponse> {
package/src/hooks.ts CHANGED
@@ -6,9 +6,15 @@ import { objectsOf } from './types'
6
6
  import type {
7
7
  AggregateRequest,
8
8
  AggregateResponse,
9
+ BlueprintFilterableProperty,
10
+ BlueprintObjectName,
11
+ BlueprintRow,
12
+ BlueprintSortableProperty,
13
+ MetricQueryRequest,
9
14
  ObjectInstance,
10
15
  QueryRequest,
11
16
  QueryResponse,
17
+ TypedWhereNode,
12
18
  WhereNode,
13
19
  } from './types'
14
20
 
@@ -26,6 +32,8 @@ export const blueprintKeys = {
26
32
  ['blueprint', 'aggregate', JSON.stringify(request)] as const,
27
33
  instance: (objectType: string, pk: string) =>
28
34
  ['blueprint', 'instance', objectType, pk] as const,
35
+ metric: (apiName: string, request: MetricQueryRequest) =>
36
+ ['blueprint', 'metric', apiName, JSON.stringify(request)] as const,
29
37
  }
30
38
 
31
39
  export const FronteraBlueprintContext = createContext<BlueprintClient | null>(null)
@@ -40,7 +48,7 @@ export function useBlueprintClient(): BlueprintClient {
40
48
  return client
41
49
  }
42
50
 
43
- type ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn'>
51
+ type ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn' | 'select'>
44
52
 
45
53
  /** Run an arbitrary object-set query. */
46
54
  export function useObjectQuery<TRow = Record<string, unknown>>(
@@ -62,15 +70,69 @@ export function useObjectQuery<TRow = Record<string, unknown>>(
62
70
  * pages. Filtering the returned rows in the component instead is the classic
63
71
  * mistake: it narrows only the page you happened to fetch, so a facet showing
64
72
  * 8,961 matches renders 5 rows and claims "Page 1 of 1".
73
+ *
74
+ * Paging is a CURSOR, not a page number. Hold the token from the previous
75
+ * response and pass it back; `hasMore` is false and `nextPageToken` absent on
76
+ * the last page. There is no total and no page count — an unordered scan cannot
77
+ * produce one, which is exactly why offset paging was removed rather than left
78
+ * to mislead.
79
+ *
80
+ * ```tsx
81
+ * const [token, setToken] = useState<string | undefined>()
82
+ * const page = useObjects<Ticket>('SupportTicket', { pageSize: 25, pageToken: token })
83
+ * // next: setToken(page.data?.nextPageToken)
84
+ * // restart: setToken(undefined)
85
+ * ```
65
86
  */
66
- export function useObjects<TRow = Record<string, unknown>>(
67
- objectType: string,
68
- options: ReadOptions<QueryResponse<TRow>> &
69
- Omit<QueryRequest, 'objectSet'> & { where?: WhereNode } = {},
70
- ): UseQueryResult<QueryResponse<TRow>, Error> {
71
- const { select, orderBy, page, pageSize, where, ...queryOptions } = options
72
- return useObjectQuery<TRow>(
73
- { objectSet: objectsOf(objectType, where), select, orderBy, page, pageSize },
87
+ type EffectiveRow<TLegacy, TObject extends BlueprintObjectName> =
88
+ [TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy
89
+
90
+ type EffectiveFilterable<TLegacy, TObject extends BlueprintObjectName> =
91
+ [TLegacy] extends [never]
92
+ ? BlueprintFilterableProperty<TObject>
93
+ : Extract<keyof TLegacy, string>
94
+
95
+ type EffectiveSortable<TLegacy, TObject extends BlueprintObjectName> =
96
+ [TLegacy] extends [never]
97
+ ? BlueprintSortableProperty<TObject>
98
+ : Extract<keyof TLegacy, string>
99
+
100
+ type SelectedRow<TRow, TSelect> =
101
+ undefined extends TSelect
102
+ ? TRow
103
+ : TSelect extends readonly (Extract<keyof TRow, string>)[]
104
+ ? Pick<TRow, TSelect[number]>
105
+ : TRow
106
+
107
+ type ObjectsOptions<
108
+ TLegacy,
109
+ TObject extends BlueprintObjectName,
110
+ TSelect extends readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined,
111
+ > = ReadOptions<QueryResponse<SelectedRow<EffectiveRow<TLegacy, TObject>, TSelect>>> &
112
+ Omit<QueryRequest, 'objectSet' | 'select' | 'orderBy'> & {
113
+ select?: TSelect
114
+ orderBy?: Array<{ property: EffectiveSortable<TLegacy, TObject>; dir: 'asc' | 'desc' }>
115
+ where?: TypedWhereNode<EffectiveRow<TLegacy, TObject>, EffectiveFilterable<TLegacy, TObject>>
116
+ }
117
+
118
+ export function useObjects<
119
+ TLegacy extends object = never,
120
+ const TObject extends BlueprintObjectName = BlueprintObjectName,
121
+ const TSelect extends readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined =
122
+ readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined,
123
+ >(
124
+ objectType: TObject,
125
+ options: ObjectsOptions<NoInfer<TLegacy>, TObject, TSelect> = {},
126
+ ): UseQueryResult<QueryResponse<SelectedRow<EffectiveRow<TLegacy, TObject>, TSelect>>, Error> {
127
+ const { select, orderBy, pageSize, pageToken, where, ...queryOptions } = options
128
+ return useObjectQuery<SelectedRow<EffectiveRow<TLegacy, TObject>, TSelect>>(
129
+ {
130
+ objectSet: objectsOf(objectType, where as WhereNode | undefined),
131
+ select: select ? [...select] : undefined,
132
+ orderBy,
133
+ pageSize,
134
+ pageToken,
135
+ },
74
136
  queryOptions,
75
137
  )
76
138
  }
@@ -87,16 +149,47 @@ export function useAggregate(
87
149
  })
88
150
  }
89
151
 
90
- export function useObjectInstance<TProps = Record<string, unknown>>(
91
- objectType: string,
152
+ export function useObjectInstance<
153
+ TLegacy extends object = never,
154
+ const TObject extends BlueprintObjectName = BlueprintObjectName,
155
+ >(
156
+ objectType: TObject,
92
157
  pk: string | null | undefined,
93
- options: ReadOptions<ObjectInstance<TProps>> = {},
94
- ): UseQueryResult<ObjectInstance<TProps>, Error> {
158
+ options: ReadOptions<ObjectInstance<EffectiveRow<TLegacy, TObject>>> = {},
159
+ ): UseQueryResult<ObjectInstance<EffectiveRow<TLegacy, TObject>>, Error> {
95
160
  const client = useBlueprintClient()
96
161
  return useQuery({
97
162
  queryKey: blueprintKeys.instance(objectType, pk ?? ''),
98
- queryFn: () => client.instance<TProps>(objectType, pk as string),
163
+ queryFn: () => client.instance<TLegacy, TObject>(objectType, pk as string),
99
164
  enabled: Boolean(pk) && options.enabled !== false,
100
165
  ...options,
101
166
  })
102
167
  }
168
+
169
+ /**
170
+ * One metric the organization has already defined.
171
+ *
172
+ * A metric exists so every reader computes it the same way. Deriving the same
173
+ * figure from raw columns in a component is how two dashboards end up
174
+ * disagreeing about one number, and it is where the arithmetic bugs live — one
175
+ * app shipped a share ratio whose numerator omitted the filter its denominator
176
+ * applied, reading 551.7%, next to a defined metric that had been there all
177
+ * along.
178
+ *
179
+ * This hook existed only as `client.metricQuery` for a while, so apps that
180
+ * wanted a metric hand-rolled a hook around `useBlueprintClient` — the exact
181
+ * detour the guidance tells authors not to take. Reach for this instead;
182
+ * `useAggregate` is for figures nobody has defined yet.
183
+ */
184
+ export function useMetric(
185
+ apiName: string,
186
+ request: MetricQueryRequest = {},
187
+ options: ReadOptions<AggregateResponse> = {},
188
+ ): UseQueryResult<AggregateResponse, Error> {
189
+ const client = useBlueprintClient()
190
+ return useQuery({
191
+ queryKey: blueprintKeys.metric(apiName, request),
192
+ queryFn: () => client.metricQuery(apiName, request),
193
+ ...options,
194
+ })
195
+ }
package/src/provider.tsx CHANGED
@@ -1,11 +1,13 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import type { FronteraClient } from '@frontera-sdk/core/client'
3
3
 
4
+ import { ActionClient } from './action-client'
5
+ import { FronteraActionContext } from './action-hooks'
4
6
  import { BlueprintClient } from './blueprint-client'
5
7
  import { FronteraBlueprintContext } from './hooks'
6
8
 
7
9
  /**
8
- * Plug Blueprint reads into `createFronteraApp`.
10
+ * Plug Blueprint reads AND governed writes into `createFronteraApp`.
9
11
  *
10
12
  * The dependency runs one way — `@frontera-sdk/blueprint` knows about
11
13
  * `@frontera-sdk/core`, never the reverse — so the app entry point composes the
@@ -16,8 +18,15 @@ import { FronteraBlueprintContext } from './hooks'
16
18
  * createFronteraApp(<App />, { providers: [blueprintProvider] })
17
19
  * ```
18
20
  *
19
- * The client is rebuilt whenever the host rotates the credential, because the
20
- * `FronteraClient` it wraps is replaced rather than mutated.
21
+ * Both clients come from ONE provider deliberately. Splitting them would mean
22
+ * an app that reads compiles and an app that acts throws at runtime with
23
+ * "no ActionClient in context" — a failure no type checks and every author
24
+ * hits exactly once, on the line where they added their first button.
25
+ *
26
+ * Both are rebuilt whenever the host rotates the credential, because the
27
+ * `FronteraClient` they wrap is replaced rather than mutated. That matters more
28
+ * for writes than reads: a submit carrying a stale token is refused after the
29
+ * user has already confirmed the thing they wanted to happen.
21
30
  */
22
31
  export function blueprintProvider(
23
32
  value: { client: FronteraClient },
@@ -25,7 +34,9 @@ export function blueprintProvider(
25
34
  ): ReactNode {
26
35
  return (
27
36
  <FronteraBlueprintContext.Provider value={new BlueprintClient(value.client)}>
28
- {children}
37
+ <FronteraActionContext.Provider value={new ActionClient(value.client)}>
38
+ {children}
39
+ </FronteraActionContext.Provider>
29
40
  </FronteraBlueprintContext.Provider>
30
41
  )
31
42
  }
package/src/types.ts CHANGED
@@ -25,6 +25,75 @@ export type DatePreset =
25
25
  | 'TODAY' | 'YESTERDAY' | 'LAST_7_DAYS' | 'LAST_30_DAYS' | 'LAST_90_DAYS'
26
26
  | 'THIS_MONTH' | 'LAST_MONTH' | 'THIS_QUARTER' | 'THIS_YEAR'
27
27
 
28
+ /**
29
+ * Workspace-specific object types are added here by the App-local generated
30
+ * file. An empty registry deliberately degrades to the existing string-keyed
31
+ * SDK so Apps do not need code generation to remain compatible.
32
+ */
33
+ export interface BlueprintRegistry {}
34
+
35
+ export interface BlueprintObjectSchema<
36
+ TRow,
37
+ TFilterable extends keyof TRow & string,
38
+ TSortable extends keyof TRow & string,
39
+ > {
40
+ row: TRow
41
+ filterable: TFilterable
42
+ sortable: TSortable
43
+ }
44
+
45
+ type RegisteredObjectName = Extract<keyof BlueprintRegistry, string>
46
+
47
+ export type BlueprintObjectName =
48
+ [RegisteredObjectName] extends [never] ? string : RegisteredObjectName
49
+
50
+ export type BlueprintRow<TObject extends BlueprintObjectName> =
51
+ TObject extends keyof BlueprintRegistry
52
+ ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer TRow, any, any>
53
+ ? TRow
54
+ : never
55
+ : Record<string, unknown>
56
+
57
+ export type BlueprintFilterableProperty<TObject extends BlueprintObjectName> =
58
+ TObject extends keyof BlueprintRegistry
59
+ ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer _TRow, infer TFilterable, infer _TSortable>
60
+ ? TFilterable
61
+ : never
62
+ : string
63
+
64
+ export type BlueprintSortableProperty<TObject extends BlueprintObjectName> =
65
+ TObject extends keyof BlueprintRegistry
66
+ ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer _TRow, infer _TFilterable, infer TSortable>
67
+ ? TSortable
68
+ : never
69
+ : string
70
+
71
+ type PropertyValue<TRow, TProperty extends string> =
72
+ TProperty extends keyof TRow ? TRow[TProperty] : unknown
73
+
74
+ export type TypedPropertyCondition<TRow, TProperty extends string> =
75
+ TProperty extends unknown
76
+ ? {
77
+ property: TProperty
78
+ op: ConditionOp
79
+ value?: PropertyValue<TRow, TProperty>
80
+ values?: Array<PropertyValue<TRow, TProperty>>
81
+ preset?: DatePreset
82
+ timezone?: string
83
+ }
84
+ : never
85
+
86
+ export type TypedWhereNode<TRow, TProperty extends string> =
87
+ | TypedPropertyCondition<TRow, TProperty>
88
+ | { and: Array<TypedWhereNode<TRow, TProperty>> }
89
+ | { or: Array<TypedWhereNode<TRow, TProperty>> }
90
+ | { not: TypedWhereNode<TRow, TProperty> }
91
+
92
+ export type BlueprintWhereNode<TObject extends BlueprintObjectName> = TypedWhereNode<
93
+ BlueprintRow<TObject>,
94
+ BlueprintFilterableProperty<TObject>
95
+ >
96
+
28
97
  export interface PropertyCondition {
29
98
  property: string
30
99
  op: ConditionOp
@@ -59,8 +128,17 @@ export interface QueryRequest {
59
128
  objectSet: ObjectSetExpr
60
129
  select?: string[]
61
130
  orderBy?: OrderBy[]
62
- page?: number
63
131
  pageSize?: number
132
+ /**
133
+ * Cursor for the next page — the previous response's `nextPageToken`.
134
+ *
135
+ * There is no `page`. Offset paging was removed because it has no defined
136
+ * meaning over an unordered scan, and the service now REFUSES a request
137
+ * carrying it (`INVALID_PAGE`) rather than quietly serving page 1 forever.
138
+ * This SDK declared `page` for a while after that, so every caller following
139
+ * the types sent a parameter guaranteed to fail.
140
+ */
141
+ pageToken?: string
64
142
  }
65
143
 
66
144
  /**
@@ -70,8 +148,21 @@ export interface QueryRequest {
70
148
  */
71
149
  export interface QueryResponse<TRow = Record<string, unknown>> {
72
150
  rows: TRow[]
73
- properties: Array<{ apiName: string; displayName?: string; dataType?: string }>
74
- totalCount?: number
151
+ properties: Array<{
152
+ apiName: string
153
+ displayName?: string
154
+ propertyType?: string
155
+ dataType?: string
156
+ }>
157
+ objectType: string
158
+ pageSize: number
159
+ /** Whether another page exists. `nextPageToken` is present exactly when this is true. */
160
+ hasMore: boolean
161
+ /**
162
+ * Pass back as `pageToken`. Its ABSENCE is how a scan learns it has finished
163
+ * — there is no total, so a caller that waits for one waits forever.
164
+ */
165
+ nextPageToken?: string
75
166
  }
76
167
 
77
168
  export const AGGREGATE_FUNCTIONS = [