@frontera-sdk/functions 1.52.39 → 1.52.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,8 +114,31 @@ reply is a test that passes while asserting nothing. An object type you did not
114
114
  stub returns no rows, because that is a real answer and usually the branch worth
115
115
  testing.
116
116
 
117
- What it does not simulate is resumption: in production your handler is re-entered
118
- after every step, and here it runs once, straight through.
117
+ By default the handler runs once, straight through. In production it is
118
+ re-entered after every step: code outside a step runs again, and each completed
119
+ step hands back its stored result without running. Pass `replay: true` to run it
120
+ that way in a test:
121
+
122
+ ```ts
123
+ const replayed = createTestContext({ replay: true, grants: ['blueprint:read'] })
124
+ await handler.handler(replayed.ctx)
125
+ expect(replayed.steps).toEqual(['load', 'nothing-to-do']) // each step once
126
+ expect(replayed.executions).toBeGreaterThan(1) // the handler started again
127
+ ```
128
+
129
+ With `replay`, the handler starts again after each step that completes for the
130
+ first time, and after each `ctx.step.sleep`, each `ctx.agent(…).run` outside a
131
+ step and each `ctx.person.confirm`. A completed step returns its stored result as
132
+ JSON, as the platform stores it (a `Date` comes back as a string), and a failed
133
+ step throws its error again. So a read outside a step that a later step changes
134
+ takes the other branch here, as it does on the platform. `calls` records every
135
+ pass's reads, `steps` lists each step once in the order it first ran, and
136
+ `executions` counts how many times the handler started (`1` without `replay`).
137
+ A `defineFunction` handler replays through `handler.handler(ctx)`; for a plain
138
+ handler function, use the returned `run(handler)`. A handler that starts a new
139
+ step on every pass, such as one whose step name includes the time, fails the
140
+ test instead of running forever. `replay` is off by default, so a test that
141
+ counts the calls made outside its steps keeps its numbers.
119
142
 
120
143
  ## License
121
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/functions",
3
- "version": "1.52.39",
3
+ "version": "1.52.41",
4
4
  "description": "Author Frontera functions: manifest, triggers and the typed handler contract.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -42,7 +42,7 @@
42
42
  "typescript": "^5.9.3"
43
43
  },
44
44
  "dependencies": {
45
- "@frontera-sdk/blueprint": "1.52.39",
45
+ "@frontera-sdk/blueprint": "1.52.41",
46
46
  "cron-parser": "^5.0.6"
47
47
  }
48
48
  }
package/src/define.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  AutomationTrigger,
6
6
  InputsSchema,
7
7
  } from './types'
8
+ import { TEST_CONTEXT_CHECK, TEST_CONTEXT_RUN } from './test-check'
8
9
 
9
10
  /**
10
11
  * Declare a Function: a manifest and the handler the runner executes.
@@ -38,16 +39,30 @@ export function defineFunction(
38
39
  }
39
40
  }
40
41
 
42
+ const frozen = Object.freeze({
43
+ ...manifest,
44
+ trigger: Object.freeze(trigger),
45
+ grants: Object.freeze([...(manifest.grants ?? [])]),
46
+ ...(inputs ? { inputs } : {}),
47
+ concurrency: manifest.concurrency ?? 1,
48
+ retries: manifest.retries ?? 0,
49
+ })
50
+
41
51
  return Object.freeze({
42
- manifest: Object.freeze({
43
- ...manifest,
44
- trigger: Object.freeze(trigger),
45
- grants: Object.freeze([...(manifest.grants ?? [])]),
46
- ...(inputs ? { inputs } : {}),
47
- concurrency: manifest.concurrency ?? 1,
48
- retries: manifest.retries ?? 0,
49
- }),
50
- handler,
52
+ manifest: frozen,
53
+ // A unit test's ctx (`createTestContext`) checks the manifest and the
54
+ // input the way the platform does before the handler runs, so a test
55
+ // refuses what a deploy or a run would. Any other ctx passes straight
56
+ // through: production never carries the check.
57
+ // With `replay`, it also runs the handler as the platform does: again from
58
+ // the top after each step, until it returns.
59
+ handler: (async (ctx) => {
60
+ const check = (ctx as unknown as Record<symbol, unknown> | null)?.[TEST_CONTEXT_CHECK]
61
+ if (typeof check === 'function') (check as (m: AutomationManifest) => void)(frozen)
62
+ const run = (ctx as unknown as Record<symbol, unknown> | null)?.[TEST_CONTEXT_RUN]
63
+ if (typeof run === 'function') return (run as (h: AutomationHandler) => ReturnType<AutomationHandler>)(handler)
64
+ return handler(ctx)
65
+ }) as AutomationHandler,
51
66
  })
52
67
  }
53
68
 
package/src/errors.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A `ctx` call the platform refused.
3
+ *
4
+ * The message is the one a refused call has always thrown
5
+ * (`ctx.action.submit → 409 Record "SRV-1" already has an open … request`), so
6
+ * a log line or a trace reads the same. What it adds is what a handler branches
7
+ * on: the platform's error `code` (`ACTION_REQUEST_ALREADY_OPEN`), the HTTP
8
+ * `status` and the refusal's `details`, the same code a workflow step reads as
9
+ * `$steps.<id>.error.code`. Matching on the wording breaks the day it changes;
10
+ * the code does not.
11
+ *
12
+ * Codes are not enumerated: the platform adds new ones, and an SDK that refused
13
+ * unknown codes would need a release each time.
14
+ */
15
+ export class FronteraCallError extends Error {
16
+ /** The platform's error code, e.g. `ACTION_REQUEST_ALREADY_OPEN`. */
17
+ readonly code: string
18
+ /** The HTTP status the platform answered with. */
19
+ readonly status: number
20
+ /** What the platform said beside the message, e.g. `{ openRequest: { requestId, lifecycle } }`; absent when it said nothing more. */
21
+ readonly details?: unknown
22
+ /** The call that was refused, e.g. `ctx.action.submit`. */
23
+ readonly call: string
24
+
25
+ constructor(call: string, status: number, message: string, options: { code?: string; details?: unknown } = {}) {
26
+ super(`${call} → ${status} ${message}`)
27
+ this.name = 'FronteraCallError'
28
+ this.call = call
29
+ this.status = status
30
+ this.code = options.code ?? codeForStatus(status)
31
+ if (options.details !== undefined) this.details = options.details
32
+ }
33
+ }
34
+
35
+ /** The code of a refusal whose body names none: a proxy's 502, say. */
36
+ function codeForStatus(status: number): string {
37
+ switch (status) {
38
+ case 400: return 'BAD_REQUEST'
39
+ case 401: return 'UNAUTHORIZED'
40
+ case 403: return 'FORBIDDEN'
41
+ case 404: return 'NOT_FOUND'
42
+ case 409: return 'CONFLICT'
43
+ case 410: return 'GONE'
44
+ case 429: return 'RATE_LIMITED'
45
+ case 503: return 'SERVICE_UNAVAILABLE'
46
+ default: return status >= 500 ? 'INTERNAL_ERROR' : 'REQUEST_FAILED'
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Whether `err` is a refused `ctx` call, optionally with this code.
52
+ *
53
+ * Reads the fields rather than `instanceof`, so a refusal thrown by another
54
+ * copy of the SDK (a test helper's, a bundled one) still matches.
55
+ */
56
+ export function isFronteraCallError(err: unknown, code?: string): err is FronteraCallError {
57
+ if (!err || typeof err !== 'object') return false
58
+ const e = err as { name?: unknown; code?: unknown; status?: unknown }
59
+ if (e.name !== 'FronteraCallError' || typeof e.code !== 'string' || typeof e.status !== 'number') return false
60
+ return code === undefined || e.code === code
61
+ }
62
+
63
+ /**
64
+ * The refusal in a response body: the platform answers `{ error, message, code, details }`.
65
+ * A body that is not one of ours gives its text as the message, because an
66
+ * empty message would be worse than a noisy one.
67
+ */
68
+ export function refusalOf(body: string): { message: string; code?: string; details?: unknown } {
69
+ try {
70
+ const parsed = JSON.parse(body) as { message?: unknown; code?: unknown; details?: unknown }
71
+ if (parsed && typeof parsed === 'object' && typeof parsed.message === 'string' && parsed.message) {
72
+ return {
73
+ message: parsed.message,
74
+ ...(typeof parsed.code === 'string' && parsed.code ? { code: parsed.code } : {}),
75
+ ...(parsed.details !== undefined ? { details: parsed.details } : {}),
76
+ }
77
+ }
78
+ } catch {
79
+ // Not JSON. Fall through to the body.
80
+ }
81
+ return { message: body }
82
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,9 @@ export {
9
9
  missingGrantMessage,
10
10
  submitOutsideStepMessage,
11
11
  } from './messages'
12
- export { createTestContext } from './testing'
12
+ export { FronteraCallError, isFronteraCallError } from './errors'
13
+ export { actionRefused, createTestContext } from './testing'
14
+ export { subjectOf } from './subject'
13
15
  export type { TestCall, TestContext, TestContextOptions } from './testing'
14
16
  export {
15
17
  DEFAULT_MAX_FILES,
@@ -31,6 +31,7 @@ import {
31
31
  personConfirmTimeoutRefusal,
32
32
  signedUrlRemovedMessage,
33
33
  } from './messages'
34
+ import { FronteraCallError, refusalOf } from './errors'
34
35
  import type {
35
36
  ActionSubmission,
36
37
  ActionSubmitResult,
@@ -77,7 +78,7 @@ function jsonSize(value: unknown): number | null {
77
78
  * escapes: `\u0000 cannot be converted to text`, and `Unicode low surrogate
78
79
  * must follow a high surrogate`. Both verified against the repo's own Postgres.
79
80
  */
80
- const NUL = /\u0000/g
81
+ const NUL = new RegExp(String.fromCharCode(0), 'g')
81
82
  const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
82
83
 
83
84
  /**
@@ -446,7 +447,17 @@ type AgentTurnStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'parked'
446
447
  * check, so it must come from inside the step, never from a replay's clock.
447
448
  * `fingerprint` and `slug` identify the call it was made for — see
448
449
  * `agentFingerprint`. Absent on a start memoized before they existed. */
449
- type AgentStart = ({ ok: true; invocationId: string; startedAt: number } | { ok: false; message: string }) & {
450
+ /** A refusal as a step memoizes it: the error's fields, since a step's value must be plain data. */
451
+ type Refusal = { call: string; status: number; message: string; code: string; details?: unknown }
452
+ const memoizedRefusal = (err: FronteraCallError): Refusal => ({
453
+ call: err.call,
454
+ status: err.status,
455
+ message: err.message.slice(`${err.call} → ${err.status} `.length),
456
+ code: err.code,
457
+ ...(err.details !== undefined ? { details: err.details } : {}),
458
+ })
459
+
460
+ type AgentStart = ({ ok: true; invocationId: string; startedAt: number } | { ok: false; message: string; refusal?: Refusal }) & {
450
461
  fingerprint?: string
451
462
  slug?: string
452
463
  }
@@ -467,7 +478,7 @@ function agentFingerprint(slug: string, prompt: string, options: AgentRunOptions
467
478
  * it, so the loop's decisions replay identically. */
468
479
  type AgentRead =
469
480
  | { done: true; text: string }
470
- | { done: true; error: string }
481
+ | { done: true; error: string; refusal?: Refusal }
471
482
  | { done: false; now: number }
472
483
 
473
484
  interface Deps {
@@ -693,17 +704,17 @@ export function buildContext(deps: Deps): AutomationContext {
693
704
  * for and cannot act on. This unwraps it and falls back to the raw body when
694
705
  * the response is not one of ours (a proxy 502, say), because an empty message
695
706
  * would be worse than a noisy one.
707
+ *
708
+ * Thrown as a `FronteraCallError`, which carries the platform's `code`,
709
+ * `status` and `details`, so a handler branches on
710
+ * `err.code === 'ACTION_REQUEST_ALREADY_OPEN'` instead of the wording.
696
711
  */
697
- const refusal = async (res: Response): Promise<string> => {
698
- const body = await res.text()
699
- try {
700
- const parsed = JSON.parse(body) as { message?: unknown }
701
- if (typeof parsed.message === 'string' && parsed.message) return parsed.message
702
- } catch {
703
- // Not JSON. Fall through to the body.
704
- }
705
- return body
712
+ const refused = async (call: string, res: Response): Promise<FronteraCallError> => {
713
+ const r = refusalOf(await res.text())
714
+ return new FronteraCallError(call, res.status, r.message, r)
706
715
  }
716
+ /** A memoized refusal thrown again on a replay, as the error it was. */
717
+ const rebuilt = (r: Refusal) => new FronteraCallError(r.call, r.status, r.message, r)
707
718
 
708
719
  /**
709
720
  * Record one ctx call as a row: timed, and with a bounded summary of what it
@@ -908,15 +919,15 @@ export function buildContext(deps: Deps): AutomationContext {
908
919
  })
909
920
  // A refusal before the turn starts (token, grant, budget, files) is
910
921
  // still a plain non-2xx, on either shape.
911
- if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)
922
+ if (!res.ok) throw await refused(`agent ${slug}`, res)
912
923
  // The kept-alive body is leading spaces and one JSON value; `JSON.parse`
913
924
  // skips the whitespace. The plain body is `{ data }`.
914
925
  const body = JSON.parse(await res.text()) as
915
926
  | { ok: true; data: { text: string } }
916
- | { ok: false; status: number; message: string }
927
+ | { ok: false; status: number; message: string; code?: string; details?: unknown }
917
928
  | { ok?: undefined; data: { text: string } }
918
929
  // The same words the plain route's non-2xx would have produced.
919
- if (body.ok === false) throw new Error(`agent ${slug} → ${body.status} ${body.message}`)
930
+ if (body.ok === false) throw new FronteraCallError(`agent ${slug}`, body.status, body.message, body)
920
931
  return body.data
921
932
  },
922
933
  agentDetail(slug, prompt, options),
@@ -955,15 +966,15 @@ export function buildContext(deps: Deps): AutomationContext {
955
966
  if (stepScope.getStore()) {
956
967
  throw new Error('ctx.person.confirm must be called at the top level of the handler, not inside ctx.step.run')
957
968
  }
958
- const refused = personConfirmTimeoutRefusal(request.timeoutMinutes)
959
- if (refused) throw new Error(refused)
969
+ const badTimeout = personConfirmTimeoutRefusal(request.timeoutMinutes)
970
+ if (badTimeout) throw new Error(badTimeout)
960
971
  const base = `person.confirm:${confirmCalls++}`
961
972
  const opened = (await deps.step.run(`${base}:open`, async () => {
962
973
  const res = await scoped('/ctx/person-confirmation', {
963
974
  method: 'POST',
964
975
  body: JSON.stringify({ key: base, ...request }),
965
976
  })
966
- if (!res.ok) throw new Error(`person.confirm → ${res.status} ${await refusal(res)}`)
977
+ if (!res.ok) throw await refused('person.confirm', res)
967
978
  return ((await res.json()) as { data: { id?: string; status: string; expiresAt?: string } }).data
968
979
  })) as { id?: string; status: string; expiresAt?: string }
969
980
  if (opened.status !== 'pending' || !opened.id) return opened.status as PersonConfirmResult
@@ -971,7 +982,7 @@ export function buildContext(deps: Deps): AutomationContext {
971
982
  const read = (await deps.step.run(`${base}:read:${i}`, async () => {
972
983
  const res = await scoped(`/ctx/person-confirmation/${opened.id}`, { method: 'GET' }).catch(() => null)
973
984
  // Refused permanently: waiting on it would only spend the run's steps.
974
- if (res && res.status >= 400 && res.status < 500) throw new Error(`person.confirm → ${res.status} ${await refusal(res)}`)
985
+ if (res && res.status >= 400 && res.status < 500) throw await refused('person.confirm', res)
975
986
  // Unreachable or failing for now: read again after the next wait.
976
987
  const status = res?.ok ? ((await res.json()) as { data: { status: string } }).data.status : 'pending'
977
988
  return { status, now: Date.now() }
@@ -1036,9 +1047,10 @@ export function buildContext(deps: Deps): AutomationContext {
1036
1047
  // A refusal is an answer, not a transient fault: returned, so the
1037
1048
  // platform does not retry a call the service has already turned down,
1038
1049
  // and thrown below in the words the synchronous path uses.
1039
- const message = `agent ${slug} → ${res.status} ${await refusal(res)}`
1050
+ const err = await refused(`agent ${slug}`, res)
1051
+ const message = err.message
1040
1052
  await recordStep({ kind: 'agent', label, status: 'error', detail: { message }, durationMs: Date.now() - startedAt })
1041
- return { ok: false, message, fingerprint, slug }
1053
+ return { ok: false, message, refusal: memoizedRefusal(err), fingerprint, slug }
1042
1054
  }
1043
1055
  const { invocationId } = ((await res.json()) as { data: { invocationId: string } }).data
1044
1056
  return { ok: true, invocationId, startedAt, fingerprint, slug }
@@ -1050,13 +1062,13 @@ export function buildContext(deps: Deps): AutomationContext {
1050
1062
  if (start.fingerprint !== undefined && start.fingerprint !== fingerprint) {
1051
1063
  throw new Error(agentReplayMismatchMessage(slug, start.slug ?? slug, callNumber))
1052
1064
  }
1053
- if (!start.ok) throw new Error(start.message)
1065
+ if (!start.ok) throw start.refusal ? rebuilt(start.refusal) : new Error(start.message)
1054
1066
 
1055
1067
  const giveUpAt = start.startedAt + waitMs + AGENT_SETTLE_GRACE_MS
1056
1068
  for (let i = 0; ; i++) {
1057
1069
  const read = (await deps.step.run(`${base}:result:${i}`, async (): Promise<AgentRead> => {
1058
1070
  const now = Date.now()
1059
- const settle = async (verdict: { text: string } | { error: string }): Promise<AgentRead> => {
1071
+ const settle = async (verdict: { text: string } | { error: string; refusal?: Refusal }): Promise<AgentRead> => {
1060
1072
  await recordStep(
1061
1073
  'text' in verdict
1062
1074
  ? { kind: 'agent', label, status: 'ok', durationMs: now - start.startedAt, detail: summarize(detailOf, verdict) }
@@ -1086,14 +1098,15 @@ export function buildContext(deps: Deps): AutomationContext {
1086
1098
  } else if (res && res.status < 500) {
1087
1099
  // Not transient: the run's token is gone, or the turn is not this
1088
1100
  // run's. Waiting longer cannot change the answer.
1089
- return settle({ error: `agent ${slug} → ${res.status} ${await refusal(res)}` })
1101
+ const err = await refused(`agent ${slug}`, res)
1102
+ return settle({ error: err.message, refusal: memoizedRefusal(err) })
1090
1103
  }
1091
1104
  if (now >= giveUpAt) return settle({ error: `agent ${slug} → 400 ${agentTimeoutMessage(slug, waitMs)}` })
1092
1105
  return { done: false, now }
1093
1106
  })) as AgentRead
1094
1107
 
1095
1108
  if (read.done) {
1096
- if ('error' in read) throw new Error(read.error)
1109
+ if ('error' in read) throw read.refusal ? rebuilt(read.refusal) : new Error(read.error)
1097
1110
  return { text: read.text }
1098
1111
  }
1099
1112
  const sliceMs = Math.max(1_000, Math.min(AGENT_WAIT_SLICE_MS, giveUpAt - read.now))
@@ -1149,7 +1162,7 @@ export function buildContext(deps: Deps): AutomationContext {
1149
1162
  method: 'POST',
1150
1163
  body: JSON.stringify({ fileId: ref.fileId }),
1151
1164
  })
1152
- if (!res.ok) throw new Error(`file ${ref.fileId} → ${res.status} ${await refusal(res)}`)
1165
+ if (!res.ok) throw await refused(`file ${ref.fileId}`, res)
1153
1166
  const file = ((await res.json()) as {
1154
1167
  data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }
1155
1168
  }).data.file
@@ -1200,11 +1213,7 @@ export function buildContext(deps: Deps): AutomationContext {
1200
1213
  method: 'POST',
1201
1214
  body: JSON.stringify({ install, capability, input: input ?? {} }),
1202
1215
  })
1203
- if (!res.ok) {
1204
- throw new Error(
1205
- `ctx.plugin("${install}").call("${capability}") → ${res.status} ${await refusal(res)}`,
1206
- )
1207
- }
1216
+ if (!res.ok) throw await refused(`ctx.plugin("${install}").call("${capability}")`, res)
1208
1217
  // Two levels: the service envelope's data, then PluginCallResult's own data.
1209
1218
  return ((await res.json()) as { data: PluginCallResult<T> }).data
1210
1219
  },
@@ -1241,7 +1250,7 @@ export function buildContext(deps: Deps): AutomationContext {
1241
1250
  method: 'POST',
1242
1251
  body: JSON.stringify(req),
1243
1252
  })
1244
- if (!res.ok) throw new Error(`ctx.http → ${res.status} ${await refusal(res)}`)
1253
+ if (!res.ok) throw await refused('ctx.http', res)
1245
1254
  return ((await res.json()) as { data: HttpResponse }).data
1246
1255
  },
1247
1256
  // The upstream status, which is the fact this call is read for — an API
@@ -1328,7 +1337,7 @@ export function buildContext(deps: Deps): AutomationContext {
1328
1337
  // that second call must be allowed through to the plane, where the
1329
1338
  // key — unchanged — makes it a replay rather than a second effect.
1330
1339
  scope.submitted.delete(submissionIdentity)
1331
- throw new Error(`ctx.action.submit → ${res.status} ${await refusal(res)}`)
1340
+ throw await refused('ctx.action.submit', res)
1332
1341
  }
1333
1342
  try {
1334
1343
  return ((await res.json()) as { data: ActionSubmitResult }).data
@@ -1367,7 +1376,7 @@ export function buildContext(deps: Deps): AutomationContext {
1367
1376
  method: 'POST',
1368
1377
  body: JSON.stringify({ objectType, ...(options ?? {}) }),
1369
1378
  })
1370
- if (!res.ok) throw new Error(`blueprint query → ${res.status} ${await refusal(res)}`)
1379
+ if (!res.ok) throw await refused('blueprint query', res)
1371
1380
  // `T` is an author-supplied shape for rows the warehouse returns
1372
1381
  // untyped. The cast is the honest boundary: nothing here can verify
1373
1382
  // it, and pretending otherwise would just move the lie deeper.
@@ -1397,7 +1406,7 @@ export function buildContext(deps: Deps): AutomationContext {
1397
1406
  // PLATFORM recorded as this run's trigger, so there is nothing an
1398
1407
  // author could name to read someone else's.
1399
1408
  const res = await scoped('/ctx/conversation-transcript', { method: 'POST', body: '{}' })
1400
- if (!res.ok) throw new Error(`ctx.conversation.transcript → ${res.status} ${await refusal(res)}`)
1409
+ if (!res.ok) throw await refused('ctx.conversation.transcript', res)
1401
1410
  const transcript = brandTranscript(
1402
1411
  ((await res.json()) as { data: { transcript: ConversationTranscript | null } }).data.transcript,
1403
1412
  )
package/src/subject.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { BlueprintQueryResult } from './types'
2
+
3
+ /**
4
+ * The subject half of an Action submission, for one row of a query.
5
+ *
6
+ * An Action that changes an existing record needs `subjectRef` (which record)
7
+ * and `expectedSubjectVersion` (the version you read it at). Both come from the
8
+ * same read, so build them from it:
9
+ *
10
+ * ```ts
11
+ * const found = await ctx.blueprint.query('WorkOrder', {
12
+ * where: { property: 'id', op: 'eq', value: ctx.input.work_order_id },
13
+ * limit: 1,
14
+ * })
15
+ * const order = found.rows[0]
16
+ * if (!order) return { submitted: false }
17
+ * await ctx.step.run('assign', () => ctx.action.submit({
18
+ * action: 'assignTechnician',
19
+ * ...subjectOf(found, order, order.id),
20
+ * input: { technician: ctx.input.technician_id },
21
+ * }))
22
+ * ```
23
+ *
24
+ * `objectId` is the row's primary key value. Throws, naming the reason, when
25
+ * the row carries no version: its type has no editable properties (so no
26
+ * Action can change it), or the row changed while it was read (read again).
27
+ */
28
+ export function subjectOf(
29
+ result: Pick<BlueprintQueryResult<unknown>, 'objectTypeId'>,
30
+ row: unknown,
31
+ objectId: string | number,
32
+ ): { subjectRef: { objectTypeId: string; objectId: string }; expectedSubjectVersion: string } {
33
+ if (!result.objectTypeId) {
34
+ throw new Error('subjectOf: pass the query result the row came from — it carries the object type id.')
35
+ }
36
+ const meta = row && typeof row === 'object' ? (row as { _meta?: unknown })._meta : undefined
37
+ const version = meta && typeof meta === 'object'
38
+ ? (meta as { recordVersion?: unknown }).recordVersion
39
+ : undefined
40
+ if (typeof version !== 'number') {
41
+ throw new Error(
42
+ 'subjectOf: this row has no record version. Either its object type has no editable properties, '
43
+ + 'so no Action can change it, or the record changed while it was read — query it again.',
44
+ )
45
+ }
46
+ return {
47
+ subjectRef: { objectTypeId: result.objectTypeId, objectId: String(objectId) },
48
+ expectedSubjectVersion: String(version),
49
+ }
50
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Where a unit test's ctx keeps the check `defineFunction`'s handler runs
3
+ * before the author's code: the manifest's deploy rules and the run's input
4
+ * rules, as the platform applies them. A registered symbol, so a vendored
5
+ * copy of the SDK and the package find the same one; never enumerable, so it
6
+ * is not part of what the handler sees.
7
+ */
8
+ export const TEST_CONTEXT_CHECK: unique symbol = Symbol.for('frontera.functions.test-context-check') as never
9
+
10
+ /**
11
+ * Where a unit test's ctx keeps how to run a handler (`createTestContext`'s
12
+ * `run`): once, or with `replay`, again from the top after each step as the
13
+ * platform does. `defineFunction`'s handler hands its code to it, so
14
+ * `fn.handler(ctx)` replays without the test changing how it calls it.
15
+ */
16
+ export const TEST_CONTEXT_RUN: unique symbol = Symbol.for('frontera.functions.test-context-run') as never