@frontera-sdk/blueprint 1.52.38 → 1.52.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/blueprint",
3
- "version": "1.52.38",
3
+ "version": "1.52.40",
4
4
  "description": "React hooks for reading Blueprint data and invoking governed Actions from inside a Frontera app.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -56,7 +56,7 @@
56
56
  "smoke": "bun run scripts/smoke.ts"
57
57
  },
58
58
  "dependencies": {
59
- "@frontera-sdk/core": "1.52.38"
59
+ "@frontera-sdk/core": "1.52.40"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@tanstack/react-query": "^5.90.21",
@@ -9,7 +9,9 @@ import type {
9
9
  ActionRequestListOptions,
10
10
  ActionRequestPage,
11
11
  SubmitActionInput,
12
+ UnavailableAction,
12
13
  } from './action-types'
14
+ import { unavailableReasonOf } from './action-types'
13
15
 
14
16
  const BASE = '/v1/blueprint/governed-actions'
15
17
 
@@ -76,6 +78,26 @@ export class ActionClient {
76
78
  return this.client.request<ActionDescriptor[]>(`${BASE}/discovery`)
77
79
  }
78
80
 
81
+ /**
82
+ * Why discovery does not offer each Action it leaves out, or `null` when
83
+ * this caller may not be told: only someone who may read the organization's
84
+ * Actions (a member who builds, or the App's dev session on a local
85
+ * runtime) is. A person signed in to an externally hosted App is never
86
+ * asked for it; a 403 is the same `null`. Advisory: discovery and submit
87
+ * stay the decision.
88
+ */
89
+ async unavailable(): Promise<UnavailableAction[] | null> {
90
+ const credential = this.client.config.credential
91
+ if (credential.kind === 'token' && credential.token.startsWith(APP_USER_TOKEN_PREFIX)) return null
92
+ try {
93
+ const rows = await this.client.request<UnavailableAction[]>(`${BASE}/discovery/unavailable`)
94
+ return (Array.isArray(rows) ? rows : []).map((row) => ({ ...row, reason: unavailableReasonOf(row.reason) }))
95
+ } catch (err) {
96
+ if (err instanceof FronteraError && (err.status === 403 || err.status === 404)) return null
97
+ throw err
98
+ }
99
+ }
100
+
79
101
  /**
80
102
  * Submit one Action. Returns as soon as the Request is recorded — NOT when
81
103
  * the effect has landed.
@@ -246,6 +268,13 @@ function listQuery(options: ActionRequestListOptions): Record<string, string> {
246
268
  /**
247
269
  * The wire envelope, assembled from the flat input a caller actually has.
248
270
  *
271
+ * For an edit it carries the subject (`subjectRef`, `expectedSubjectVersion`)
272
+ * and, when the Action declares it, the numeric `expectedVersion` input. The
273
+ * Action's key input is not guessed here (the descriptor does not say which
274
+ * input it is): the platform fills it from `subjectRef.objectId` when `input`
275
+ * leaves it out, and refuses a key or version in `input` that disagrees with
276
+ * the subject.
277
+ *
249
278
  * Exported for the tests: this is the part with a trap in it, and the trap is
250
279
  * silent — a wrong shape comes back as "Action invocation is invalid" with no
251
280
  * field named.
@@ -12,8 +12,12 @@ import {
12
12
  import type { ActionClient } from './action-client'
13
13
  import { blueprintKeys } from './hooks'
14
14
  import {
15
+ actionAvailabilityOf,
15
16
  actionEffectOf,
17
+ actionUnavailableText,
16
18
  isTerminalLifecycle,
19
+ type ActionUnavailableReason,
20
+ type UnavailableAction,
17
21
  type ActionDecisionResult,
18
22
  type ActionDescriptor,
19
23
  type ActionRequest,
@@ -39,6 +43,8 @@ export interface MyActionRequestsFilter extends ApprovalInboxFilter {
39
43
  export const actionKeys = {
40
44
  all: ['blueprint', 'actions'] as const,
41
45
  discovery: () => ['blueprint', 'actions', 'discovery'] as const,
46
+ /** Why discovery leaves Actions out, for a caller who may be told. */
47
+ unavailable: () => ['blueprint', 'actions', 'discovery', 'unavailable'] as const,
42
48
  requests: (lifecycle?: readonly ActionRequestLifecycle[]) =>
43
49
  ['blueprint', 'actions', 'requests', lifecycle?.join(',') ?? 'all'] as const,
44
50
  request: (requestId: string) => ['blueprint', 'actions', 'request', requestId] as const,
@@ -90,27 +96,84 @@ export function useActions(
90
96
  return useQuery({
91
97
  queryKey: actionKeys.discovery(),
92
98
  queryFn: () => client.discover(),
99
+ // Only what the caller can invoke: entries listed just to say why not are for `useAction`.
100
+ select: (entries) => entries.filter((entry) => actionAvailabilityOf(entry).available),
93
101
  ...options,
94
102
  })
95
103
  }
96
104
 
105
+ /** What `useAction` answers: the Action when it can be invoked, else why not. */
106
+ export interface UseActionResult {
107
+ /** The Action, or `null` when this caller cannot invoke it (or while discovery loads). */
108
+ action: ActionDescriptor | null
109
+ /** Whether it can be invoked. `false` while discovery loads too: check `isLoading`. */
110
+ available: boolean
111
+ /**
112
+ * Why not, once known: the platform's reason when this caller may be told,
113
+ * else `unknown`. `null` when available, or while discovery (or its
114
+ * explanation) loads.
115
+ */
116
+ reason: ActionUnavailableReason | null
117
+ /** A sentence for the person (a disabled control's tooltip or the line beside it): the platform's own when it sent one. `null` when available or still loading. */
118
+ reasonText: string | null
119
+ /** What would make it available, for the person building the App; only ever told to a caller who may read the organization's Actions. */
120
+ next: string | null
121
+ isLoading: boolean
122
+ error: Error | null
123
+ }
124
+
97
125
  /**
98
- * One Action by name, or `null` when this user may not invoke it.
126
+ * One Action by name, or `null` when this user may not invoke it, with why.
99
127
  *
100
128
  * `null` rather than a thrown error, because "you may not do this" is an
101
129
  * ordinary state for a UI to be in — it renders nothing, or a disabled control
102
- * with an explanation — not an exception.
130
+ * with an explanation — not an exception. `reasonText` is that explanation: a
131
+ * control that stays visible says it (a tooltip, or a line beside it) rather
132
+ * than sitting disabled with no word of why.
133
+ *
134
+ * Only when discovery leaves the Action out does this ask the platform why
135
+ * (`discovery/unavailable`), and only a caller who may be told is answered: a
136
+ * person signed in to an externally hosted App gets the neutral `unknown`.
103
137
  */
104
138
  export function useAction(
105
139
  apiName: string,
106
140
  options: ReadOptions<ActionDescriptor[]> = {},
107
- ): { action: ActionDescriptor | null; isLoading: boolean; error: Error | null } {
141
+ ): UseActionResult {
142
+ const client = useActionClient()
108
143
  const { data, isLoading, error } = useActions(options)
109
- return {
110
- action: data?.find((entry) => entry.apiName === apiName) ?? null,
111
- isLoading,
112
- error: error ?? null,
113
- }
144
+ const missing = !!data && !data.some((entry) => entry.apiName === apiName)
145
+ const explained = useQuery({
146
+ queryKey: actionKeys.unavailable(),
147
+ queryFn: () => client.unavailable(),
148
+ enabled: missing,
149
+ // Advisory, and a refusal is an answer (null): never retried into a loop.
150
+ retry: false,
151
+ staleTime: 30_000,
152
+ })
153
+ const explanation = missing ? (explained.isPending ? undefined : (explained.data ?? null)) : null
154
+ return { ...actionLookup(data, apiName, explanation), isLoading, error: error ?? null }
155
+ }
156
+
157
+ /**
158
+ * One Action of a discovery answer, by name: it when it can be invoked, else
159
+ * why not. `explained` is the platform's explanation of what discovery left
160
+ * out: `undefined` while it loads, `null` when this caller may not be told.
161
+ */
162
+ export function actionLookup(
163
+ entries: readonly ActionDescriptor[] | undefined,
164
+ apiName: string,
165
+ explained?: readonly UnavailableAction[] | null,
166
+ ): Pick<UseActionResult, 'action' | 'available' | 'reason' | 'reasonText' | 'next'> {
167
+ const none = { action: null, available: false, reason: null, reasonText: null, next: null }
168
+ if (!entries) return none
169
+ const entry = entries.find((candidate) => candidate.apiName === apiName)
170
+ const availability = actionAvailabilityOf(entry)
171
+ if (availability.available) return { action: entry!, available: true, reason: null, reasonText: null, next: null }
172
+ if (entry) return { action: null, available: false, reason: availability.reason, reasonText: actionUnavailableText(availability.reason), next: null }
173
+ if (explained === undefined) return none
174
+ const why = explained?.find((row) => row.apiName === apiName)
175
+ if (!why) return { action: null, available: false, reason: 'unknown', reasonText: actionUnavailableText('unknown'), next: null }
176
+ return { action: null, available: false, reason: why.reason, reasonText: why.message || actionUnavailableText(why.reason), next: why.next ?? null }
114
177
  }
115
178
 
116
179
  /**
@@ -8,34 +8,76 @@
8
8
  * one fact a UI has to show: submitting is not the same as done.
9
9
  */
10
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'
11
+ /**
12
+ * Every state a Request can hold, in the platform's order.
13
+ *
14
+ * A copy of the platform's wire contract, not shared code: this package
15
+ * installs from public npm and cannot import the service. A test pins the two
16
+ * lists together, so a state added on one side fails the build on the other.
17
+ */
18
+ export const ACTION_REQUEST_LIFECYCLES = [
19
+ 'received',
20
+ 'awaiting_approval',
21
+ 'ready',
22
+ 'executing',
23
+ 'finalizing',
24
+ 'succeeded',
25
+ 'rejected',
26
+ 'expired',
27
+ 'cancelled',
28
+ 'failed',
29
+ 'outcome_unknown',
30
+ 'awaiting_resolution',
31
+ 'closed_unknown',
32
+ ] as const
33
+
34
+ /**
35
+ * Where a Request has got to.
36
+ *
37
+ * - `received`: recorded, not yet checked against its approval policy.
38
+ * - `awaiting_approval`: waiting for someone to decide it.
39
+ * - `ready`: cleared to run; a worker picks it up.
40
+ * - `executing`: the write is being attempted now.
41
+ * - `finalizing`: the write committed; the platform is verifying it.
42
+ * - `succeeded`: applied and verified.
43
+ * - `rejected`, `expired`, `cancelled`: closed before anything was attempted.
44
+ * - `failed`: attempted, and the target refused it.
45
+ * - `outcome_unknown`: attempted, and whether it landed could not be
46
+ * established yet; the platform reconciles it against the target.
47
+ * - `awaiting_resolution`: a person has to look at it, because verification or
48
+ * reconciliation could not settle it.
49
+ * - `closed_unknown`: closed by a person without the outcome ever being
50
+ * established. The write may have landed.
51
+ */
52
+ export type ActionRequestLifecycle = typeof ACTION_REQUEST_LIFECYCLES[number]
53
+
54
+ /** Every effect certainty a Request can carry, in the platform's order. Pinned to the platform's list by a test. */
55
+ export const ACTION_EFFECT_CERTAINTIES = [
56
+ 'not_attempted',
57
+ 'in_flight',
58
+ 'confirmed_not_applied',
59
+ 'confirmed_applied',
60
+ 'unknown',
61
+ ] as const
22
62
 
23
63
  /**
24
64
  * 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.
65
+ * finished. `in_flight` is a dispatch under way. `unknown` is the honest state
66
+ * after a dispatch whose outcome could not be established, and a UI must not
67
+ * render it as failure: the write may well have landed.
28
68
  */
29
- export type ActionEffectCertainty =
30
- | 'not_attempted'
31
- | 'confirmed_applied'
32
- | 'confirmed_not_applied'
33
- | 'outcome_unknown'
69
+ export type ActionEffectCertainty = typeof ACTION_EFFECT_CERTAINTIES[number]
34
70
 
71
+ /**
72
+ * The states a Request stops moving in by itself. `failed` and `closed_unknown`
73
+ * can still change if late evidence about the target arrives, but nothing is
74
+ * scheduled to move them, so polling stops there too.
75
+ */
35
76
  const TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([
36
- 'succeeded', 'failed', 'cancelled', 'rejected',
77
+ 'succeeded', 'rejected', 'expired', 'cancelled', 'failed', 'closed_unknown',
37
78
  ])
38
79
 
80
+ /** Whether a Request has settled: nothing will move it on its own. */
39
81
  export function isTerminalLifecycle(lifecycle: ActionRequestLifecycle): boolean {
40
82
  return TERMINAL.has(lifecycle)
41
83
  }
@@ -71,27 +113,41 @@ export function actionEffectOf(
71
113
  if (!request) return 'pending'
72
114
  switch (request.effectCertainty) {
73
115
  case 'confirmed_applied': return 'applied'
74
- case 'confirmed_not_applied': return 'refused'
75
- case 'outcome_unknown': return 'uncertain'
116
+ case 'unknown': return 'uncertain'
117
+ // A refused attempt is final only once the Request has settled: `ready`
118
+ // with this certainty is a retry the platform has already queued.
119
+ case 'confirmed_not_applied':
120
+ if (!request.lifecycle || isTerminalLifecycle(request.lifecycle)) return 'refused'
121
+ return 'pending'
76
122
  default: break
77
123
  }
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'
124
+ // No certainty reported, or one that says nothing about the effect yet
125
+ // (`not_attempted`, `in_flight`). The lifecycle still answers when it has
126
+ // settled: a rejected, expired or cancelled Request never reached the
127
+ // target, and an unknown outcome stays unknown. Anything else is genuinely
128
+ // still in flight.
129
+ switch (request.lifecycle) {
130
+ case 'succeeded': return 'applied'
131
+ case 'outcome_unknown':
132
+ case 'closed_unknown': return 'uncertain'
133
+ case 'rejected':
134
+ case 'expired':
135
+ case 'cancelled':
136
+ case 'failed': return 'refused'
137
+ default: return 'pending'
138
+ }
85
139
  }
86
140
 
87
141
  /**
88
142
  * The record version to send with an edit, read off an object instance.
89
143
  *
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.
144
+ * The version arrives as `_meta.recordVersion`, and only an editable type has
145
+ * one at all. The instance route always sends it; a query's rows carry it only
146
+ * when the query asks (`recordVersions: true`, which Functions, workflows and
147
+ * the CLI ask for), and this SDK's queries do not ask. Reaching into `_meta`
148
+ * by hand is how a caller ends up sending `undefined`, and an edit of an
149
+ * existing record always compares versions: one sent without a version is
150
+ * refused, and nothing changes.
95
151
  *
96
152
  * So a control that edits a row from a table fetches the instance first:
97
153
  *
@@ -175,6 +231,99 @@ export interface ActionDescriptor {
175
231
  inputSchema: Record<string, unknown>
176
232
  }
177
233
 
234
+ /**
235
+ * Why an Action is not offered to this caller, as the platform explains it
236
+ * (`discovery/unavailable`), in the order its gates refuse:
237
+ *
238
+ * - `not_deployed`: published, but no deployment runs it here (never
239
+ * deployed, or switched off).
240
+ * - `no_workspace_grant`: this workspace holds no Blueprint grant on the
241
+ * Action's object type.
242
+ * - `not_a_person`: the caller acts as a key, which belongs to no member.
243
+ * - `not_a_member`: the person is not a member of this workspace.
244
+ * - `business_user`: a Business user holds no governed authority here.
245
+ * - `role_cannot_submit`: the person's role may not submit Action requests.
246
+ * - `capability_not_granted`: the role does not hold the Action's invoke capability.
247
+ * - `agent_not_active`, `agent_no_grant`: for an Agent caller.
248
+ * - `agent_cannot_act`: the Agent may read the Action's object type, but its
249
+ * grant there has no `act`, so it submits no Action on it.
250
+ * - `refused`: refused for a reason not listed.
251
+ * - `unknown`: the platform did not say (the caller may not ask, such as a
252
+ * person signed in to an externally hosted App, or a platform that
253
+ * predates the explanation).
254
+ */
255
+ export type ActionUnavailableReason =
256
+ | 'not_deployed'
257
+ | 'no_workspace_grant'
258
+ | 'not_a_person'
259
+ | 'not_a_member'
260
+ | 'business_user'
261
+ | 'role_cannot_submit'
262
+ | 'capability_not_granted'
263
+ | 'agent_not_active'
264
+ | 'agent_no_grant'
265
+ | 'agent_cannot_act'
266
+ | 'refused'
267
+ | 'unknown'
268
+
269
+ const REASONS: readonly ActionUnavailableReason[] = [
270
+ 'not_deployed', 'no_workspace_grant', 'not_a_person', 'not_a_member', 'business_user',
271
+ 'role_cannot_submit', 'capability_not_granted', 'agent_not_active', 'agent_no_grant', 'agent_cannot_act', 'refused', 'unknown',
272
+ ]
273
+
274
+ /** A reason as the platform sent it: one of the known ones, else `unknown`. */
275
+ export function unavailableReasonOf(value: unknown): ActionUnavailableReason {
276
+ return typeof value === 'string' && (REASONS as readonly string[]).includes(value) ? (value as ActionUnavailableReason) : 'unknown'
277
+ }
278
+
279
+ /**
280
+ * One Action discovery did not offer this caller, and why
281
+ * (`GET /v1/blueprint/governed-actions/discovery/unavailable`). Only a caller
282
+ * who may read the organization's Actions is told; the explanation is
283
+ * advisory, and discovery and submit stay the decision.
284
+ */
285
+ export interface UnavailableAction {
286
+ apiName: string
287
+ displayName: string
288
+ reason: ActionUnavailableReason
289
+ /** One sentence for a person. */
290
+ message: string
291
+ /** What would make it available, when there is something to do. */
292
+ next: string | null
293
+ invokeCapability: string
294
+ /** The role the decision was made for, when the caller is a member. */
295
+ role?: string
296
+ /** For `not_deployed`: the deployment's state. */
297
+ deploymentStatus?: string
298
+ deploymentReason?: string
299
+ }
300
+
301
+ /** Whether a discovery entry can be invoked: discovery offers only what can, so anything but `available` (or no word) cannot. */
302
+ export function actionAvailabilityOf(entry: Pick<ActionDescriptor, 'availability'> | null | undefined): { available: true; reason: null } | { available: false; reason: ActionUnavailableReason } {
303
+ if (!entry) return { available: false, reason: 'unknown' }
304
+ return entry.availability === undefined || entry.availability === 'available' ? { available: true, reason: null } : { available: false, reason: 'not_deployed' }
305
+ }
306
+
307
+ /**
308
+ * A sentence for a control that stays visible but cannot be used, when the
309
+ * platform sent none of its own: show it beside the disabled button, or as
310
+ * its tooltip. `unknown` stays neutral: it says nothing about why.
311
+ */
312
+ export function actionUnavailableText(reason: ActionUnavailableReason): string {
313
+ switch (reason) {
314
+ case 'not_deployed':
315
+ return "This action isn't set up in this workspace yet."
316
+ case 'capability_not_granted':
317
+ case 'role_cannot_submit':
318
+ case 'business_user':
319
+ return "Your role can't do this. Ask your administrator for access."
320
+ case 'not_a_member':
321
+ return "You're not a member of this workspace."
322
+ default:
323
+ return "This action isn't available to you here."
324
+ }
325
+ }
326
+
178
327
  /**
179
328
  * What raised a request, in the only terms a reader can act on.
180
329
  *
@@ -288,6 +437,31 @@ export interface ActionRequest {
288
437
  * other request, and absent from a platform that predates the field.
289
438
  */
290
439
  approval?: ActionRequestApproval | null
440
+ /**
441
+ * Every approval or rejection recorded on the request, oldest first. Empty
442
+ * when nobody has decided yet. Present on reads of a request (single and
443
+ * listed), whatever its approval mode; absent from a platform that predates
444
+ * the field.
445
+ */
446
+ approvalDecisions?: ActionRequestApprovalDecision[]
447
+ }
448
+
449
+ /**
450
+ * One decision recorded on a request: an approval or a rejection.
451
+ *
452
+ * `stage` is the approval-chain step it decided, or `null` when the Action
453
+ * approves in one step. `decidedBy.name` is `null` for someone no longer a
454
+ * member of the organization, and for a person whose App sign-in carried no
455
+ * name. `reason` is what the approver wrote; it is `null` when they wrote none
456
+ * or the stored reason could not be read.
457
+ */
458
+ export interface ActionRequestApprovalDecision {
459
+ stage: string | null
460
+ decision: 'approve' | 'reject'
461
+ decidedBy: { name: string | null }
462
+ /** When the decision was recorded (ISO 8601). */
463
+ decidedAt: string
464
+ reason: string | null
291
465
  }
292
466
 
293
467
  /** One step of a request's approval chain. */
@@ -406,14 +580,31 @@ export interface ActionDecisionResult extends ActionRequest {
406
580
  * gets wrong once and then debugs for an hour. `submit` assembles it.
407
581
  */
408
582
  export interface SubmitActionInput {
409
- /** Primary key of the object being changed. Omit only for `mode: 'create'`. */
583
+ /**
584
+ * Primary key of the object being changed, sent as the request's subject
585
+ * (`subjectRef.objectId`). Omit only for `mode: 'create'`.
586
+ *
587
+ * The platform fills the Action's input named like the object type's
588
+ * primary key (`id`, `ticketId`, …) from it when `input` leaves that out,
589
+ * so `input` need not repeat the key. If `input` does carry it, the two must
590
+ * be the same key: a request where they differ is refused, naming the input.
591
+ */
410
592
  objectId?: string
411
- /** Parameter values, keyed by the Action's parameter API names. */
593
+ /**
594
+ * Parameter values, keyed by the Action's parameter API names. Leave out
595
+ * the key input and `expectedVersion`: they are filled from `objectId` and
596
+ * `expectedVersion` (see those).
597
+ */
412
598
  input: Record<string, unknown>
413
599
  /**
414
- * The version the caller believes it read. Sent as the subject version AND,
415
- * when the Action declares a compare-and-set parameter, as that parameter —
416
- * so a stale write is refused rather than clobbering a concurrent one.
600
+ * The record version the change was decided on: `recordVersionOf` of the
601
+ * instance the person saw. Sent as the subject version
602
+ * (`expectedSubjectVersion`, as text) AND, when the Action declares an
603
+ * `expectedVersion` input, as that input (a number) — so a stale write is
604
+ * refused rather than clobbering a concurrent one. An edit sent without it
605
+ * is refused, never applied unchecked. When `input.expectedVersion` is given
606
+ * as well and differs, the platform refuses the request rather than
607
+ * picking one.
417
608
  */
418
609
  expectedVersion?: number | string
419
610
  /** Required when the Action declares `reason: 'required'`. */
package/src/types.ts CHANGED
@@ -240,6 +240,11 @@ export interface QueryRequest {
240
240
  * this list matches `select`.
241
241
  */
242
242
  export interface QueryResponse<TRow = Record<string, unknown>> {
243
+ /**
244
+ * The records, as the platform sends them; the SDK changes no value. A
245
+ * timestamp property is ISO 8601 text in UTC (`'2026-10-17T09:17:00.000Z'`,
246
+ * which `new Date()` reads), a date property `'YYYY-MM-DD'`.
247
+ */
243
248
  rows: TRow[]
244
249
  properties: Array<{
245
250
  apiName: string