@frontera-sdk/blueprint 1.43.9 → 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 +42 -0
- package/package.json +16 -4
- package/src/action-client.ts +175 -0
- package/src/action-hooks.ts +210 -0
- package/src/action-types.ts +181 -0
- package/src/blueprint-client.ts +54 -8
- package/src/hooks.ts +107 -14
- package/src/provider.tsx +15 -4
- package/src/types.ts +94 -3
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.
|
|
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.
|
|
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
|
+
}
|
package/src/blueprint-client.ts
CHANGED
|
@@ -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<
|
|
61
|
-
|
|
63
|
+
instance<
|
|
64
|
+
TLegacy extends object = never,
|
|
65
|
+
const TObject extends BlueprintObjectName = BlueprintObjectName,
|
|
66
|
+
>(
|
|
67
|
+
objectType: TObject,
|
|
62
68
|
pk: string,
|
|
63
|
-
): Promise<ObjectInstance<
|
|
64
|
-
return this.client.request<ObjectInstance<
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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<
|
|
91
|
-
|
|
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<
|
|
94
|
-
): UseQueryResult<ObjectInstance<
|
|
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<
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
{
|
|
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<{
|
|
74
|
-
|
|
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 = [
|