@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/src/testing.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { PropertyCondition, WhereNode } from '@frontera-sdk/blueprint/types'
2
3
  import {
3
4
  duplicateStepMessage,
4
5
  duplicateSubmissionMessage,
@@ -9,11 +10,16 @@ import {
9
10
  personConfirmTimeoutRefusal,
10
11
  submitOutsideStepMessage,
11
12
  } from './messages'
13
+ import { FronteraCallError } from './errors'
14
+ import { validateInputValue } from './inputs'
15
+ import { validateManifest } from './manifest'
12
16
  import { asFileHandle, carriesFileHandle } from './runtime-context'
17
+ import { TEST_CONTEXT_CHECK, TEST_CONTEXT_RUN } from './test-check'
13
18
  import type {
14
19
  ActionSubmission,
15
20
  ActionSubmitResult,
16
21
  AutomationContext,
22
+ AutomationManifest,
17
23
  FunctionCaller,
18
24
  PersonConfirmRequest,
19
25
  PersonConfirmResult,
@@ -36,12 +42,18 @@ import type {
36
42
  *
37
43
  * It enforces what the platform enforces, in the platform's own words: a
38
44
  * missing grant and a repeated step name fail here exactly as they fail in
39
- * production, so a green test means something.
45
+ * production, so a green test means something. Through a `defineFunction`
46
+ * handler (`fn.handler(ctx)`) the manifest is checked as a deploy checks it
47
+ * (an input named `siteId` fails here as it fails `frontera local dev`) and
48
+ * a stated `input` as the run route checks it.
40
49
  *
41
- * What it does NOT simulate is resumption. In production a handler is re-entered
42
- * after every step, so code outside a step runs many times; here the handler is
43
- * called once, straight through. Steps still memoize by name within the run, and
44
- * everything the handler did is recorded on `calls`.
50
+ * Resumption is simulated only when asked (`replay: true`). In production a
51
+ * handler is re-entered after every step, so code outside a step runs many
52
+ * times and completed steps hand back their stored (JSON) results. By default
53
+ * the handler is called once, straight through; with `replay` it is run as the
54
+ * platform runs it, so a read outside a step that a later step changes shows
55
+ * up here rather than only on the runtime. Everything the handler did is
56
+ * recorded on `calls`.
45
57
  */
46
58
 
47
59
  /**
@@ -60,10 +72,49 @@ export interface TestCall {
60
72
  label: string
61
73
  /** Present on a step: how it ended. */
62
74
  status?: 'ok' | 'error'
75
+ /** Present on a Blueprint query: the options it was called with, `{}` when none. */
76
+ options?: BlueprintQueryOptions
63
77
  }
64
78
 
79
+ /**
80
+ * What a Blueprint query answers in a test: the object type's rows (a bare
81
+ * array, read as one complete page), a `{ rows, hasMore }` answer, which the
82
+ * query's `where`, `orderBy`, `limit`, `pageToken` and `nearest` are applied
83
+ * to as the platform applies them, or a function of the query's options for
84
+ * an answer the rows cannot express (it may return either form too). Any
85
+ * other shape is refused when queried, naming the object type.
86
+ */
87
+ export type BlueprintStub =
88
+ | ReadonlyArray<Record<string, unknown>>
89
+ | BlueprintQueryResult<never>
90
+ | BlueprintQueryResult<Record<string, unknown>>
91
+ | ((
92
+ options: BlueprintQueryOptions,
93
+ ) =>
94
+ | BlueprintQueryResult<Record<string, unknown>>
95
+ | ReadonlyArray<Record<string, unknown>>
96
+ | Promise<BlueprintQueryResult<Record<string, unknown>> | ReadonlyArray<Record<string, unknown>>>)
97
+
65
98
 
66
99
  export interface TestContextOptions {
100
+ /**
101
+ * Run the handler as the platform does: after each step that completes for
102
+ * the first time (and each `ctx.step.sleep`, top-level `ctx.agent(…).run`
103
+ * and `ctx.person.confirm`), the handler starts again from the top, and the
104
+ * completed ones hand back their stored result, as JSON, without running.
105
+ *
106
+ * What it catches: code outside a step that reads something a step changes.
107
+ * The first pass reads the record before the step writes it; the replay
108
+ * reads it again after, and the handler takes the other branch, exactly as
109
+ * it does on the runtime. A read outside a step also runs once per step, as
110
+ * it does there, so `calls` shows each of them.
111
+ *
112
+ * Applies through a `defineFunction` handler (`fn.handler(ctx)`), and
113
+ * through `run(handler)` for a plain handler. `steps` lists each step once,
114
+ * in the order it first ran. Off by default, so an existing test that counts
115
+ * the calls made outside its steps keeps its numbers.
116
+ */
117
+ replay?: boolean
67
118
  runId?: string
68
119
  workspaceId?: string
69
120
  /** Who asked for the run through an Agent; `null` (no Agent) by default. */
@@ -73,7 +124,10 @@ export interface TestContextOptions {
73
124
  /** How the person answers `ctx.person.confirm`: a fixed answer or a function of the request; `unavailable` by default. */
74
125
  confirm?: PersonConfirmResult | ((request: PersonConfirmRequest) => PersonConfirmResult)
75
126
  /** What the run was started with. Passed through verbatim — a unit test
76
- * states exactly what the handler sees; defaults are `startRun`'s job. */
127
+ * states exactly what the handler sees; defaults are `startRun`'s job.
128
+ * Run through a `defineFunction` handler, it is checked against the
129
+ * manifest's inputs as the run route checks it: an undeclared or
130
+ * mistyped field, or a missing required one, fails the call. */
77
131
  input?: Record<string, unknown>
78
132
  /**
79
133
  * The grants the manifest declares.
@@ -108,9 +162,27 @@ export interface TestContextOptions {
108
162
  * a branch worth testing.
109
163
  */
110
164
  conversation?: ConversationTranscript | null
111
- /** Rows per object type. An unstubbed type returns no rows, which is a real
112
- * answer and usually the branch worth testing. */
113
- blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>
165
+ /**
166
+ * Rows per object type (`{ Site: [row, …] }` or `{ Site: { rows, hasMore } }`),
167
+ * or a function of the query's options. A query's
168
+ * `where`, `orderBy`, `limit`, `pageToken` and `nearest` narrow the rows
169
+ * the way the platform's query does (three-valued: a NULL property matches
170
+ * neither a condition nor its `not`; `$now` and `$now-4h` read at `now`), so
171
+ * a handler that filters on the server is tested filtering. A query the
172
+ * platform refuses for its shape is refused here in its words, whatever the
173
+ * stub: a `nearest` without a `nearby` or `withinBbox` on the same location
174
+ * (through `and` only), a malformed condition, a radius or point out of
175
+ * range. What needs the object type's catalog is refused where the stubbed
176
+ * rows show it: a typo of a stubbed property, a geo operator on a property
177
+ * whose values are not points, a comparison on one whose values are. `select` is not applied: every stubbed
178
+ * property comes back. An unstubbed type returns no rows, which is a real
179
+ * answer and usually the branch worth testing.
180
+ */
181
+ blueprint?: Record<string, BlueprintStub>
182
+ /** "Now" for a `dateRange` condition (default: the real time). */
183
+ now?: Date
184
+ /** The timezone a `dateRange` without one resolves in (default UTC). */
185
+ timezone?: string
114
186
  /**
115
187
  * Per-apiName Action outcomes. An unstubbed Action throws rather than
116
188
  * answering.
@@ -133,13 +205,113 @@ export interface TestContext {
133
205
  /** Step names, in the order they ran. */
134
206
  steps: string[]
135
207
  logs: Array<{ message: string; data?: Record<string, unknown> }>
208
+ /**
209
+ * Run a handler with this context: once, or with `replay`, again from the
210
+ * top after each step until it returns, as the platform runs it. A
211
+ * `defineFunction` handler (`fn.handler(ctx)`) does this by itself.
212
+ */
213
+ run<T>(handler: (ctx: AutomationContext) => Promise<T> | T): Promise<T>
214
+ /** How many times the handler was started: 1 without `replay`. */
215
+ executions: number
216
+ }
217
+
218
+ /**
219
+ * The error the platform refuses a `ctx.action.submit` with, for an `actions`
220
+ * stub to throw: the same `FronteraCallError`, message, `code`, `status` and
221
+ * `details` a run gets, so a test asserts the branch a handler takes on
222
+ * `err.code`.
223
+ *
224
+ * ```ts
225
+ * actions: {
226
+ * reprioritiseServiceRequest: () => {
227
+ * throw actionRefused('ACTION_REQUEST_ALREADY_OPEN', 'Record "SRV-1" already has an open reprioritiseServiceRequest request', {
228
+ * details: { openRequest: { requestId: 'req-1', lifecycle: 'awaiting_approval' } },
229
+ * })
230
+ * },
231
+ * }
232
+ * ```
233
+ *
234
+ * `status` defaults to 409, the status of most Action refusals; pass the one the
235
+ * code has (403 for `ACTION_FORBIDDEN`, 404 for `ACTION_NOT_FOUND`) when a test
236
+ * reads it.
237
+ */
238
+ export function actionRefused(code: string, message: string, options: { status?: number; details?: unknown } = {}): FronteraCallError {
239
+ return new FronteraCallError('ctx.action.submit', options.status ?? 409, message, { code, details: options.details })
240
+ }
241
+
242
+ /** A run that keeps asking for steps without ever returning. */
243
+ const MAX_EXECUTIONS = 10_000
244
+
245
+ /** A memoized step's answer, as the platform stores it: JSON, or the error it ended with. */
246
+ type Memo = { ok: true; json: string | undefined } | { ok: false; error: unknown }
247
+
248
+ /** A promise that never settles: what an execution that reached a new step waits on, as the platform abandons it. */
249
+ const PARKED = new Promise<never>(() => {})
250
+
251
+ /**
252
+ * Refuse what the platform refuses, before the handler runs: a manifest that
253
+ * would not deploy (`validateManifest`, the deploy check, so an input named
254
+ * `siteId` fails here as it fails `frontera local dev`), and an input the
255
+ * run route would turn away (`validateInputValue`), when the test states one.
256
+ */
257
+ function checkLikeThePlatform(manifest: AutomationManifest, input: Record<string, unknown> | undefined): void {
258
+ const deploy = validateManifest(manifest)
259
+ if (!deploy.valid) throw new Error(`Function "${String(manifest.name)}" would not deploy: ${deploy.errors.join('; ')}`)
260
+ if (input === undefined) return
261
+ const run = validateInputValue(manifest.inputs, input)
262
+ if (!run.ok) throw new Error(`Function "${String(manifest.name)}" would refuse this input: ${run.errors.join('; ')}`)
136
263
  }
137
264
 
138
265
  export function createTestContext(options: TestContextOptions = {}): TestContext {
139
266
  const calls: TestCall[] = []
140
267
  const steps: string[] = []
141
268
  const logs: TestContext['logs'] = []
142
- const seenNames = new Set<string>()
269
+ let seenNames = new Set<string>()
270
+ const replay = options.replay === true
271
+ /**
272
+ * Replay's state. `memo` holds each completed step's answer by name, across
273
+ * executions; `ordinal` numbers the durable calls that are not named steps
274
+ * (a top-level agent run, a confirmation), as the runtime names them, per
275
+ * execution. An execution that completes something new ends there: `fresh`
276
+ * counts what is still running in it, and `boundary` starts the next one.
277
+ */
278
+ const memo = new Map<string, Memo>()
279
+ let ordinal = { agent: 0, confirm: 0 }
280
+ let fresh = 0
281
+ let reachedNew = false
282
+ let boundary: (() => void) | null = null
283
+ let executions = 0
284
+ const settleFresh = () => {
285
+ fresh--
286
+ if (fresh === 0 && reachedNew && boundary) boundary()
287
+ }
288
+ const recall = <T>(m: Memo): T => {
289
+ if (!m.ok) throw m.error
290
+ return (m.json === undefined ? undefined : JSON.parse(m.json)) as T
291
+ }
292
+ /**
293
+ * One durable call under replay: its stored answer when an earlier execution
294
+ * completed it; else run it, store the answer, and park this execution so
295
+ * the next one starts from the top. A failure is stored and thrown here, as
296
+ * the platform hands a failed step's error back to the handler.
297
+ */
298
+ const durable = async <T>(key: string, work: () => Promise<T>): Promise<T> => {
299
+ const known = memo.get(key)
300
+ if (known) return recall<T>(known)
301
+ fresh++
302
+ let value: T
303
+ try {
304
+ value = await work()
305
+ } catch (error) {
306
+ memo.set(key, { ok: false, error })
307
+ settleFresh()
308
+ throw error
309
+ }
310
+ memo.set(key, { ok: true, json: value === undefined ? undefined : JSON.stringify(value) })
311
+ reachedNew = true
312
+ settleFresh()
313
+ return PARKED
314
+ }
143
315
  /**
144
316
  * Which step the running code is inside.
145
317
  *
@@ -174,8 +346,9 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
174
346
  async run<T>(name: string, fn: () => Promise<T>): Promise<T> {
175
347
  if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))
176
348
  seenNames.add(name)
349
+ if (replay && memo.has(name)) return recall<T>(memo.get(name)!)
177
350
  steps.push(name)
178
- return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {
351
+ const body = () => stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {
179
352
  try {
180
353
  const out = await fn()
181
354
  // Nothing here replays a step, so the handle would keep working in
@@ -188,15 +361,19 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
188
361
  throw err
189
362
  }
190
363
  })
364
+ return replay ? await durable(name, body) : await body()
191
365
  },
192
366
 
193
367
  async sleep(name: string): Promise<void> {
194
368
  if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))
195
369
  seenNames.add(name)
370
+ if (replay && memo.has(name)) return
196
371
  steps.push(name)
197
372
  // Recorded, never waited: a test suite that really slept out its
198
373
  // backoffs would take minutes to say nothing.
199
374
  calls.push({ kind: 'step', label: name, status: 'ok' })
375
+ // A sleep is a checkpoint on the platform: the handler resumes from the top after it.
376
+ if (replay) await durable(name, async () => undefined)
200
377
  },
201
378
  },
202
379
 
@@ -237,6 +414,17 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
237
414
  return {
238
415
  async run(prompt: string) {
239
416
  requireGrant(`agent:${slug}:run`)
417
+ // At the top level the platform runs an Agent durably, under steps of its own: a replay
418
+ // hands back the answer it stored, by the call's place in the run, without asking again.
419
+ if (replay && !stepScope.getStore()) {
420
+ const key = `agent:${slug}:${ordinal.agent++}`
421
+ if (!memo.has(key)) calls.push({ kind: 'agent', label: slug })
422
+ return await durable(key, async () => {
423
+ const stub = options.agents?.[slug]
424
+ if (!stub) throw new Error(`No agent stub for "${slug}". Pass agents: { '${slug}': () => ({ text: '…' }) } to createTestContext.`)
425
+ return await stub(prompt)
426
+ })
427
+ }
240
428
  calls.push({ kind: 'agent', label: slug })
241
429
  const stub = options.agents?.[slug]
242
430
  // Throwing beats answering with an empty string: a test whose agent
@@ -346,14 +534,28 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
346
534
  blueprint: {
347
535
  async query<T = Record<string, unknown>>(
348
536
  objectType: string,
349
- _options?: BlueprintQueryOptions,
537
+ queryOptions?: BlueprintQueryOptions,
350
538
  ): Promise<BlueprintQueryResult<T>> {
351
539
  requireGrant('blueprint:read')
352
- calls.push({ kind: 'blueprint', label: objectType })
540
+ const asked = queryOptions ?? {}
541
+ // Refused before it is recorded, stubbed or not: a query the platform
542
+ // refuses read nothing, and a stub function would otherwise answer it.
543
+ checkQuery(asked)
544
+ calls.push({ kind: 'blueprint', label: objectType, options: asked })
353
545
  const stub = options.blueprint?.[objectType]
354
546
  // Empty is a real answer, and the branch an author most often forgets
355
547
  // to test — so this one defaults rather than throwing.
356
- return (stub ?? { rows: [], hasMore: false }) as BlueprintQueryResult<T>
548
+ if (!stub) return { rows: [], hasMore: false } as BlueprintQueryResult<T>
549
+ if (typeof stub === 'function') return asPlatformAnswers(objectType, asRowSet(objectType, await stub(asked), 'returned')) as BlueprintQueryResult<T>
550
+ const rowSet = asRowSet(objectType, stub, 'given')
551
+ return asPlatformAnswers(
552
+ objectType,
553
+ applyQuery(objectType, rowSet, asked, {
554
+ now: options.now ?? new Date(),
555
+ timezone: options.timezone ?? 'UTC',
556
+ }),
557
+ rowSet.objectTypeId,
558
+ ) as BlueprintQueryResult<T>
357
559
  },
358
560
  },
359
561
 
@@ -361,7 +563,17 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
361
563
  async confirm(request) {
362
564
  const refused = personConfirmTimeoutRefusal(request.timeoutMinutes)
363
565
  if (refused) throw new Error(refused)
364
- const answer = typeof options.confirm === 'function' ? options.confirm(request) : (options.confirm ?? 'unavailable')
566
+ const ask = () => (typeof options.confirm === 'function' ? options.confirm(request) : (options.confirm ?? 'unavailable'))
567
+ // Asked once and remembered on the platform (its own steps), so a replay gets the same answer.
568
+ if (replay && !stepScope.getStore()) {
569
+ const key = `person.confirm:${ordinal.confirm++}`
570
+ return await durable(key, async () => {
571
+ const answer = ask()
572
+ calls.push({ kind: 'person', label: answer })
573
+ return answer
574
+ })
575
+ }
576
+ const answer = ask()
365
577
  calls.push({ kind: 'person', label: answer })
366
578
  return answer
367
579
  },
@@ -376,5 +588,654 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
376
588
  },
377
589
  }
378
590
 
379
- return { ctx, calls, steps, logs }
591
+ // `defineFunction`'s handler calls this before the author's code: what the
592
+ // platform refuses at deploy (the manifest) and at the run route (the
593
+ // input) is refused here, in the platform's own words.
594
+ Object.defineProperty(ctx, TEST_CONTEXT_CHECK, {
595
+ value: (manifest: AutomationManifest) => checkLikeThePlatform(manifest, options.input),
596
+ enumerable: false,
597
+ })
598
+
599
+ /**
600
+ * Run the handler: once, or under `replay` one execution after another
601
+ * until one returns. An execution ends when everything new it started has
602
+ * completed (its steps park, as the platform abandons that execution); the
603
+ * next starts from the top with a fresh set of step names.
604
+ */
605
+ const run = async <T>(handler: (ctx: AutomationContext) => Promise<T> | T): Promise<T> => {
606
+ if (!replay) {
607
+ executions++
608
+ return await handler(ctx)
609
+ }
610
+ for (let i = 0; i < MAX_EXECUTIONS; i++) {
611
+ executions++
612
+ seenNames = new Set<string>()
613
+ ordinal = { agent: 0, confirm: 0 }
614
+ fresh = 0
615
+ reachedNew = false
616
+ const next = new Promise<'next'>((resolve) => {
617
+ boundary = () => resolve('next')
618
+ })
619
+ const outcome = await Promise.race([
620
+ Promise.resolve()
621
+ .then(() => handler(ctx))
622
+ .then((value) => ({ value }), (error: unknown) => ({ error, failed: true as const })),
623
+ next,
624
+ ])
625
+ boundary = null
626
+ if (outcome === 'next') continue
627
+ if ('failed' in outcome) throw outcome.error
628
+ return outcome.value
629
+ }
630
+ throw new Error(`The handler was started ${MAX_EXECUTIONS} times without returning: does it start a new step on every execution (a step name built from the time, or a random one)?`)
631
+ }
632
+ Object.defineProperty(ctx, TEST_CONTEXT_RUN, { value: run, enumerable: false })
633
+
634
+ return {
635
+ ctx,
636
+ calls,
637
+ steps,
638
+ logs,
639
+ run,
640
+ get executions() {
641
+ return executions
642
+ },
643
+ }
644
+ }
645
+
646
+ /* ------------------------------------------------------------------ *
647
+ * The platform's query over stubbed rows
648
+ *
649
+ * A port of what the Blueprint query compiles to (`compile.ts`,
650
+ * `sql-emit.ts`, `geo-location.ts`, `validate.ts` in the service), evaluated
651
+ * over rows instead of emitted as SQL. Three-valued like SQL: a comparison
652
+ * with a NULL property is unknown, unknown never matches, and `not` of
653
+ * unknown is unknown, so `ne` and `notIn` exclude rows where the property is
654
+ * NULL exactly as the platform does.
655
+ * ------------------------------------------------------------------ */
656
+
657
+ type Truth = boolean | null
658
+
659
+ const DEFAULT_LIMIT = 100
660
+ const MAX_LIMIT = 1000
661
+ const PAGE_PREFIX = 'test-page:'
662
+ const EARTH_RADIUS_METERS = 6_371_008.8
663
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
664
+
665
+ const isNullish = (v: unknown): v is null | undefined => v === null || v === undefined
666
+
667
+ function timeOf(v: unknown): number | null {
668
+ if (v instanceof Date) return v.getTime()
669
+ if (typeof v === 'string' && ISO_DATE.test(v)) {
670
+ const t = Date.parse(v)
671
+ return Number.isNaN(t) ? null : t
672
+ }
673
+ return null
674
+ }
675
+
676
+ /** How two non-null values order, as a typed column against a literal; null when they do not compare. */
677
+ function compareValues(a: unknown, b: unknown): number | null {
678
+ if (typeof a === 'number' || typeof b === 'number') {
679
+ const x = typeof a === 'number' ? a : typeof a === 'string' && a.trim() !== '' ? Number(a) : NaN
680
+ const y = typeof b === 'number' ? b : typeof b === 'string' && b.trim() !== '' ? Number(b) : NaN
681
+ return Number.isNaN(x) || Number.isNaN(y) ? null : x - y
682
+ }
683
+ const ta = timeOf(a)
684
+ const tb = timeOf(b)
685
+ if (ta !== null && tb !== null) return ta - tb
686
+ if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b)
687
+ if (typeof a === 'string' && typeof b === 'string') return a < b ? -1 : a > b ? 1 : 0
688
+ return JSON.stringify(a) === JSON.stringify(b) ? 0 : null
689
+ }
690
+
691
+ function compareOp(op: string, v: unknown, value: unknown): Truth {
692
+ if (isNullish(v) || isNullish(value)) return null
693
+ const c = compareValues(v, value)
694
+ if (c === null) return op === 'ne' ? true : op === 'eq' ? false : null
695
+ switch (op) {
696
+ case 'eq': return c === 0
697
+ case 'ne': return c !== 0
698
+ case 'gt': return c > 0
699
+ case 'gte': return c >= 0
700
+ case 'lt': return c < 0
701
+ case 'lte': return c <= 0
702
+ }
703
+ throw new Error(`blueprint query: unknown operator "${op}"`)
704
+ }
705
+
706
+ function and(values: Truth[]): Truth {
707
+ if (values.includes(false)) return false
708
+ return values.includes(null) ? null : true
709
+ }
710
+
711
+ function or(values: Truth[]): Truth {
712
+ if (values.includes(true)) return true
713
+ return values.includes(null) ? null : false
714
+ }
715
+
716
+ function tzOffsetMs(tz: string, at: Date): number {
717
+ const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
718
+ const p: Record<string, string> = {}
719
+ for (const part of dtf.formatToParts(at)) p[part.type] = part.value
720
+ const asUtc = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), p.hour === '24' ? 0 : Number(p.hour), Number(p.minute), Number(p.second))
721
+ return asUtc - at.getTime()
722
+ }
723
+
724
+ function zonedMidnightUtc(tz: string, y: number, m: number, d: number): Date {
725
+ const naive = Date.UTC(y, m - 1, d)
726
+ const off1 = tzOffsetMs(tz, new Date(naive))
727
+ const off2 = tzOffsetMs(tz, new Date(naive - off1))
728
+ return new Date(naive - off2)
729
+ }
730
+
731
+ /** A date preset as the platform resolves it: an end-exclusive [start, end) window in `timezone`. */
732
+ export function resolveDatePreset(preset: string, timezone: string, now: Date): { start: number; end: number } {
733
+ let parts: Record<string, string>
734
+ try {
735
+ parts = {}
736
+ for (const part of new Intl.DateTimeFormat('en-US', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now)) parts[part.type] = part.value
737
+ } catch {
738
+ throw new Error(`blueprint query: unknown timezone "${timezone}"`)
739
+ }
740
+ const y = Number(parts.year)
741
+ const m = Number(parts.month)
742
+ const d = Number(parts.day)
743
+ const at = (yy: number, mm: number, dd: number) => zonedMidnightUtc(timezone, yy, mm, dd).getTime()
744
+ const tomorrow = at(y, m, d + 1)
745
+ switch (preset) {
746
+ case 'TODAY': return { start: at(y, m, d), end: tomorrow }
747
+ case 'YESTERDAY': return { start: at(y, m, d - 1), end: at(y, m, d) }
748
+ case 'LAST_7_DAYS': return { start: at(y, m, d - 6), end: tomorrow }
749
+ case 'LAST_30_DAYS': return { start: at(y, m, d - 29), end: tomorrow }
750
+ case 'LAST_90_DAYS': return { start: at(y, m, d - 89), end: tomorrow }
751
+ case 'THIS_MONTH': return { start: at(y, m, 1), end: tomorrow }
752
+ case 'LAST_MONTH': return { start: at(y, m - 1, 1), end: at(y, m, 1) }
753
+ case 'THIS_QUARTER': return { start: at(y, Math.floor((m - 1) / 3) * 3 + 1, 1), end: tomorrow }
754
+ case 'THIS_YEAR': return { start: at(y, 1, 1), end: tomorrow }
755
+ }
756
+ throw new Error(`blueprint query: unknown date preset "${preset}"`)
757
+ }
758
+
759
+ function point(v: unknown): { lat: number; lon: number } | null {
760
+ if (!v || typeof v !== 'object') return null
761
+ const { lat, lon } = v as { lat?: unknown; lon?: unknown }
762
+ return typeof lat === 'number' && typeof lon === 'number' && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 ? { lat, lon } : null
763
+ }
764
+
765
+ /** Metres along the sphere, as the platform's haversine measures them. */
766
+ export function distanceMeters(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
767
+ const rad = (x: number) => (x * Math.PI) / 180
768
+ const hav = Math.sin(rad(a.lat - b.lat) / 2) ** 2 + Math.cos(rad(b.lat)) * Math.cos(rad(a.lat)) * Math.sin(rad(a.lon - b.lon) / 2) ** 2
769
+ return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(hav)))
770
+ }
771
+
772
+ function withinBbox(p: { lat: number; lon: number }, box: { west: number; south: number; east: number; north: number }): boolean {
773
+ if (p.lat < box.south || p.lat > box.north) return false
774
+ if (box.west > box.east) return p.lon >= box.west || p.lon <= box.east
775
+ if (p.lon >= box.west && p.lon <= box.east) return true
776
+ return (box.east === 180 && p.lon === -180) || (box.west === -180 && p.lon === 180)
777
+ }
778
+
779
+ /* The platform's refusals of a query's shape (`validate.ts`, `compile.ts`, `geo-location.ts`, `relative-now.ts`, `ctx-query.ts`), in its words. */
780
+
781
+ const MAX_RADIUS_METERS = 20_037_509
782
+ const GEO_OPS = new Set(['nearby', 'withinBbox'])
783
+ const ELEMENT_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte']
784
+ const DATE_PRESETS = ['TODAY', 'YESTERDAY', 'LAST_7_DAYS', 'LAST_30_DAYS', 'LAST_90_DAYS', 'THIS_MONTH', 'LAST_MONTH', 'THIS_QUARTER', 'THIS_YEAR']
785
+ const RELATIVE_NOW = /^\$now(?:\s*([+-])\s*(\d{1,6})\s*(m|min|h|d|w))?$/i
786
+ const RELATIVE_NOW_HINT = 'write "$now", or "$now" with + or - and a whole number of m (minutes), h, d or w, as in "$now-4h"'
787
+ const UNIT_MS: Record<string, number> = { m: 60_000, min: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 }
788
+
789
+ const refuse = (message: string): never => {
790
+ throw new Error(`blueprint query: ${message}`)
791
+ }
792
+
793
+ function shown(value: unknown): string {
794
+ if (typeof value === 'number' || value === undefined) return String(value)
795
+ let text: string
796
+ try {
797
+ text = JSON.stringify(value)
798
+ } catch {
799
+ text = String(value)
800
+ }
801
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text
802
+ }
803
+
804
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
805
+ const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key))
806
+
807
+ function coordinateError(name: string, value: unknown, limit: 90 | 180): string | null {
808
+ if (typeof value !== 'number') return `"${name}" must be a number, got ${shown(value)}`
809
+ if (!Number.isFinite(value)) return `"${name}" must be a finite number, got ${value}`
810
+ if (value < -limit || value > limit) return `"${name}" ${value} is outside [-${limit}, ${limit}]`
811
+ return null
812
+ }
813
+
814
+ /** A caller's point as the platform reads one: exactly `lat` and `lon`, finite and in range. */
815
+ function parsePoint(value: unknown, label: string): { lat: number; lon: number } {
816
+ if (!isPlainObject(value) || !hasExactKeys(value, ['lat', 'lon'])) return refuse(`${label} must be an object with exactly the keys "lat" and "lon", got ${shown(value)}`)
817
+ const error = coordinateError('lat', value.lat, 90) ?? coordinateError('lon', value.lon, 180)
818
+ if (error) refuse(`${label} ${error}`)
819
+ return { lat: value.lat as number, lon: value.lon as number }
820
+ }
821
+
822
+ function parseBbox(value: unknown, label: string): { west: number; south: number; east: number; north: number } {
823
+ if (!isPlainObject(value) || !hasExactKeys(value, ['west', 'south', 'east', 'north'])) {
824
+ return refuse(`${label} must be an object with exactly the keys "west", "south", "east" and "north", got ${shown(value)}`)
825
+ }
826
+ const error = coordinateError('west', value.west, 180) ?? coordinateError('south', value.south, 90) ?? coordinateError('east', value.east, 180) ?? coordinateError('north', value.north, 90)
827
+ if (error) refuse(`${label} ${error}`)
828
+ const box = value as { west: number; south: number; east: number; north: number }
829
+ if (box.south > box.north) refuse(`${label} "south" ${box.south} is north of "north" ${box.north}`)
830
+ return box
831
+ }
832
+
833
+ function parseRadius(value: unknown, label: string): number {
834
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > MAX_RADIUS_METERS) {
835
+ return refuse(`${label} must be a number greater than 0 and at most ${MAX_RADIUS_METERS}, got ${shown(value)}`)
836
+ }
837
+ return value
838
+ }
839
+
840
+ const looksLikeRelativeNow = (value: unknown): value is string => typeof value === 'string' && /^\s*\$now/i.test(value)
841
+
842
+ /** The instant a relative now (`$now`, `$now-4h`) names, or null when the value is not one or is malformed. */
843
+ function relativeNowInstant(value: unknown, now: Date): Date | null {
844
+ if (typeof value !== 'string') return null
845
+ const match = RELATIVE_NOW.exec(value.trim())
846
+ if (!match) return null
847
+ const [, sign, amount, unit] = match
848
+ if (!sign) return new Date(now.getTime())
849
+ const delta = Number(amount) * UNIT_MS[unit!.toLowerCase()]!
850
+ return new Date(now.getTime() + (sign === '-' ? -delta : delta))
851
+ }
852
+
853
+ /** A condition value with a relative now replaced by its ISO instant, read at the query's "now". */
854
+ function resolveRelativeNow(value: unknown, now: Date): unknown {
855
+ const instant = relativeNowInstant(value, now)
856
+ return instant ? instant.toISOString() : value
857
+ }
858
+
859
+ const isCondition = (node: WhereNode): node is PropertyCondition => typeof (node as PropertyCondition).property === 'string'
860
+
861
+ /**
862
+ * Refuse a `where` the platform refuses for its shape alone. What needs the
863
+ * object type's catalog (an operator its property type does not take, a relative
864
+ * now on a property that is not a time) is refused where the stubbed rows show
865
+ * it: see `checkAgainstRows`.
866
+ */
867
+ export function checkWhere(where: WhereNode): void {
868
+ if (!isPlainObject(where)) refuse('where node must be a JSON object (a condition or and/or/not combinator)')
869
+ if (!isCondition(where)) {
870
+ if ('and' in where && Array.isArray(where.and)) return where.and.forEach(checkWhere)
871
+ if ('or' in where && Array.isArray(where.or)) return where.or.forEach(checkWhere)
872
+ if ('not' in where) return checkWhere(where.not)
873
+ return refuse('where node must be a condition or and/or/not')
874
+ }
875
+ const c = where
876
+ if (c.radiusMeters !== undefined && c.op !== 'nearby') refuse(`"${c.op}" on "${c.property}": radiusMeters applies to "nearby" only`)
877
+ const label = `"${c.op}" on "${c.property}":`
878
+ switch (c.op) {
879
+ case 'nearby':
880
+ case 'withinBbox':
881
+ if (c.elementWhere !== undefined) refuse(`${label} a location has no elements; elementWhere applies to arrayAny only`)
882
+ if (c.op === 'nearby') {
883
+ parsePoint(c.value, `${label} value`)
884
+ parseRadius(c.radiusMeters, `${label} radiusMeters`)
885
+ } else {
886
+ parseBbox(c.value, `${label} value`)
887
+ }
888
+ return
889
+ case 'dateRange':
890
+ if (!c.preset) refuse('"dateRange" requires a preset')
891
+ if (!DATE_PRESETS.includes(c.preset as string)) refuse(`unknown date preset "${c.preset}"`)
892
+ return
893
+ case 'arrayAny':
894
+ if (!Array.isArray(c.elementWhere) || c.elementWhere.length === 0) refuse('"arrayAny" requires a non-empty elementWhere array')
895
+ for (const e of c.elementWhere!) {
896
+ if (!isPlainObject(e)) refuse('each element condition must be a JSON object')
897
+ if (!ELEMENT_OPS.includes(e.op)) refuse(`element operator "${e.op}" is not valid; expected one of ${ELEMENT_OPS.join(', ')}`)
898
+ }
899
+ return
900
+ case 'isNull':
901
+ case 'isNotNull':
902
+ case 'eq':
903
+ case 'ne':
904
+ case 'gt':
905
+ case 'gte':
906
+ case 'lt':
907
+ case 'lte':
908
+ case 'in':
909
+ case 'notIn':
910
+ case 'between':
911
+ case 'contains':
912
+ case 'startsWith':
913
+ break
914
+ default:
915
+ refuse(`unknown operator "${(c as { op?: unknown }).op}"`)
916
+ }
917
+ if (c.op === 'between' && (!Array.isArray(c.values) || c.values.length !== 2)) refuse('"between" requires exactly two values')
918
+ for (const value of [c.value, ...(Array.isArray(c.values) ? c.values : [])]) {
919
+ if (looksLikeRelativeNow(value) && !relativeNowInstant(value, new Date(0))) refuse(`"${value}" on "${c.property}" is not a relative now: ${RELATIVE_NOW_HINT}`)
920
+ }
921
+ if ((c.op === 'in' || c.op === 'notIn') && (!Array.isArray(c.values) || c.values.length === 0)) refuse(`"${c.op}" requires a non-empty values array`)
922
+ if ((c.op === 'contains' || c.op === 'startsWith') && typeof c.value !== 'string') refuse(`"${c.op}" requires a string value`)
923
+ }
924
+
925
+ /** Through `and` only, as the platform reads "the object set is bounded by a geo filter on this location". */
926
+ function whereBoundsLocation(where: WhereNode, property: string): boolean {
927
+ if (isCondition(where)) return where.property === property && (where.op === 'nearby' || where.op === 'withinBbox')
928
+ return 'and' in where && where.and.some((node) => whereBoundsLocation(node, property))
929
+ }
930
+
931
+ /**
932
+ * Refuse a query the platform refuses before it reads a row, in its words:
933
+ * the removed `filter`, a malformed `where`, a `nearest` without a geo filter
934
+ * on its location (or with `orderBy`, or extra keys), a `limit` that is not a
935
+ * whole number. Runs for every query, stubbed or not, and before it is recorded
936
+ * in `calls`, because a refused query read nothing.
937
+ */
938
+ export function checkQuery(options: BlueprintQueryOptions): void {
939
+ if ((options as { filter?: unknown }).filter !== undefined) {
940
+ refuse('`filter` is gone: it was a placeholder on the stubbed endpoint and never filtered anything. '
941
+ + 'Use `where` with the Blueprint filter DSL — e.g. { where: { property: "status", op: "eq", value: "open" } }.')
942
+ }
943
+ if (options.where !== undefined) checkWhere(options.where)
944
+ if (options.limit !== undefined) {
945
+ const pageSize = Math.min(Math.max(options.limit, 1), MAX_LIMIT)
946
+ if (!Number.isInteger(pageSize)) refuse(`pageSize must be an integer 1..${MAX_LIMIT}`)
947
+ }
948
+ if (options.nearest === undefined) return
949
+ const nearest = options.nearest as unknown
950
+ if (!isPlainObject(nearest) || typeof nearest.property !== 'string') {
951
+ refuse('"nearest" must be an object { property: "<location apiName>", from: { lat, lon } }')
952
+ }
953
+ const asked = nearest as Record<string, unknown>
954
+ if (!hasExactKeys(asked, ['property', 'from'])) {
955
+ const extra = Object.keys(asked).filter((key) => key !== 'property' && key !== 'from')
956
+ refuse(extra.length > 0 ? `"nearest" takes only "property" and "from"; remove ${extra.map((key) => `"${key}"`).join(', ')}` : '"nearest" needs "from": { lat, lon }')
957
+ }
958
+ const property = asked.property as string
959
+ const from = parsePoint(asked.from, '"nearest.from"')
960
+ if (options.orderBy && options.orderBy.length > 0) {
961
+ refuse('"nearest" orders the rows by distance, closest first, with the primary key breaking ties; it cannot be combined with "orderBy". Send one or the other.')
962
+ }
963
+ if (!options.where || !whereBoundsLocation(options.where, property)) {
964
+ refuse(`"nearest" on "${property}" needs a "nearby" or "withinBbox" condition on "${property}" in the object set, `
965
+ + 'outside any "or" or "not" — for example '
966
+ + `{ "property": "${property}", "op": "nearby", "value": { "lat": ${from.lat}, "lon": ${from.lon} }, "radiusMeters": 5000 }`)
967
+ }
968
+ }
969
+
970
+ function condition(c: PropertyCondition, row: Record<string, unknown>, env: { now: Date; timezone: string }): Truth {
971
+ const v = row[c.property]
972
+ const value = resolveRelativeNow(c.value, env.now)
973
+ const values = Array.isArray(c.values) ? c.values.map((x) => resolveRelativeNow(x, env.now)) : []
974
+ switch (c.op) {
975
+ case 'isNull': return isNullish(v)
976
+ case 'isNotNull': return !isNullish(v)
977
+ case 'eq':
978
+ case 'ne':
979
+ case 'gt':
980
+ case 'gte':
981
+ case 'lt':
982
+ case 'lte':
983
+ return compareOp(c.op, v, value)
984
+ case 'in':
985
+ case 'notIn': {
986
+ if (isNullish(v)) return null
987
+ const found = or(values.map((x) => compareOp('eq', v, x)))
988
+ return c.op === 'in' ? found : found === null ? null : !found
989
+ }
990
+ case 'between':
991
+ return and([compareOp('gte', v, values[0]), compareOp('lte', v, values[1])])
992
+ case 'contains':
993
+ case 'startsWith': {
994
+ // ILIKE: case-insensitive, the value taken literally.
995
+ if (isNullish(v)) return null
996
+ const text = String(v).toLowerCase()
997
+ const needle = String(value).toLowerCase()
998
+ return c.op === 'contains' ? text.includes(needle) : text.startsWith(needle)
999
+ }
1000
+ case 'dateRange': {
1001
+ const { start, end } = resolveDatePreset(c.preset!, c.timezone ?? env.timezone, env.now)
1002
+ if (isNullish(v)) return null
1003
+ const t = timeOf(v)
1004
+ return t === null ? null : t >= start && t < end
1005
+ }
1006
+ case 'arrayAny': {
1007
+ if (isNullish(v)) return null
1008
+ if (!Array.isArray(v)) throw new Error(`blueprint query: "arrayAny" only applies to an array property; "${c.property}" is not one in the stubbed rows`)
1009
+ return v.some((element) => and(c.elementWhere!.map((e) => compareOp(e.op, e.field ? (element as Record<string, unknown> | null)?.[e.field] : element, e.value))) === true)
1010
+ }
1011
+ case 'nearby': {
1012
+ const at = point(v)
1013
+ return at ? distanceMeters(at, c.value as { lat: number; lon: number }) <= c.radiusMeters! : null
1014
+ }
1015
+ case 'withinBbox': {
1016
+ const at = point(v)
1017
+ return at ? withinBbox(at, c.value as { west: number; south: number; east: number; north: number }) : null
1018
+ }
1019
+ }
1020
+ throw new Error(`blueprint query: unknown operator "${(c as { op?: unknown }).op}"`)
1021
+ }
1022
+
1023
+ /** Two names one or two edits apart (`stauts`, `status`), case aside: a typo, not another field. */
1024
+ function nearMiss(a: string, b: string): boolean {
1025
+ const x = a.toLowerCase()
1026
+ const y = b.toLowerCase()
1027
+ if (x === y) return true
1028
+ if (Math.abs(x.length - y.length) > 2 || Math.min(x.length, y.length) < 4) return false
1029
+ // Optimal string alignment distance, capped at 2: a swap of two letters is one edit.
1030
+ const d: number[][] = Array.from({ length: x.length + 1 }, (_, i) => Array.from({ length: y.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)))
1031
+ for (let i = 1; i <= x.length; i++) {
1032
+ for (let j = 1; j <= y.length; j++) {
1033
+ const cost = x[i - 1] === y[j - 1] ? 0 : 1
1034
+ d[i]![j] = Math.min(d[i - 1]![j]! + 1, d[i]![j - 1]! + 1, d[i - 1]![j - 1]! + cost)
1035
+ if (i > 1 && j > 1 && x[i - 1] === y[j - 2] && x[i - 2] === y[j - 1]) d[i]![j] = Math.min(d[i]![j]!, d[i - 2]![j - 2]! + 1)
1036
+ }
1037
+ }
1038
+ return d[x.length]![y.length]! <= 2
1039
+ }
1040
+
1041
+ function properties(node: WhereNode, into: Set<string> = new Set()): Set<string> {
1042
+ if ('and' in node) node.and.forEach((n) => properties(n, into))
1043
+ else if ('or' in node) node.or.forEach((n) => properties(n, into))
1044
+ else if ('not' in node) properties(node.not, into)
1045
+ else into.add(node.property)
1046
+ return into
1047
+ }
1048
+
1049
+ /** Whether a row matches a `where`, in the platform's three-valued logic: true, false, or unknown (null). */
1050
+ export function matchesWhere(node: WhereNode, row: Record<string, unknown>, env: { now?: Date; timezone?: string } = {}): Truth {
1051
+ checkWhere(node)
1052
+ return evaluateWhere(node, row, env)
1053
+ }
1054
+
1055
+ function evaluateWhere(node: WhereNode, row: Record<string, unknown>, env: { now?: Date; timezone?: string }): Truth {
1056
+ const e = { now: env.now ?? new Date(), timezone: env.timezone ?? 'UTC' }
1057
+ const walk = (n: WhereNode): Truth => {
1058
+ if ('and' in n) return and(n.and.map(walk))
1059
+ if ('or' in n) return or(n.or.map(walk))
1060
+ if ('not' in n) {
1061
+ const inner = walk(n.not)
1062
+ return inner === null ? null : !inner
1063
+ }
1064
+ return condition(n, row, e)
1065
+ }
1066
+ return walk(node)
1067
+ }
1068
+
1069
+ /** NULL sorts as the largest value: last ascending, first descending, as the platform pages. */
1070
+ function orderRows(rows: Array<Record<string, unknown>>, orderBy: NonNullable<BlueprintQueryOptions['orderBy']>): Array<Record<string, unknown>> {
1071
+ return rows
1072
+ .map((row, i) => ({ row, i }))
1073
+ .sort((a, b) => {
1074
+ for (const { property, dir } of orderBy) {
1075
+ const x = a.row[property]
1076
+ const y = b.row[property]
1077
+ const sign = dir === 'desc' ? -1 : 1
1078
+ if (isNullish(x) && isNullish(y)) continue
1079
+ if (isNullish(x)) return sign
1080
+ if (isNullish(y)) return -sign
1081
+ const c = compareValues(x, y) ?? 0
1082
+ if (c !== 0) return c * sign
1083
+ }
1084
+ return a.i - b.i
1085
+ })
1086
+ .map(({ row }) => row)
1087
+ }
1088
+
1089
+ /** The object type id a stub that names none answers with: stable per object type, and plainly a test's. */
1090
+ export function testObjectTypeId(objectType: string): string {
1091
+ return `test-object-type:${objectType}`
1092
+ }
1093
+
1094
+ /**
1095
+ * A stub as the answer it stands for: a bare array of rows is one complete
1096
+ * page (`{ rows, hasMore: false }`); a `{ rows, hasMore }` answer is used as
1097
+ * written. Anything else is refused, so a mistyped stub fails the test instead
1098
+ * of answering "no rows" — which is a real answer, and the branch a wrong
1099
+ * shape would silently test.
1100
+ */
1101
+ export function asRowSet(
1102
+ objectType: string,
1103
+ stub: unknown,
1104
+ how: 'given' | 'returned' = 'given',
1105
+ ): BlueprintQueryResult<Record<string, unknown>> {
1106
+ const where = how === 'given' ? `The blueprint stub for ${objectType}` : `The blueprint stub function for ${objectType} returned a value that`
1107
+ const fix = 'Give its rows as an array (`{ ' + objectType + ': [row, …] }`), a `{ rows: [row, …], hasMore: false }` answer, or a function of the query\'s options that returns one.'
1108
+ const checkRows = (rows: readonly unknown[]) => {
1109
+ const bad = rows.findIndex((row) => row === null || typeof row !== 'object' || Array.isArray(row))
1110
+ if (bad >= 0) throw new Error(`${where} has a row ${bad} that is not an object (${describeShape(rows[bad])}). ${fix}`)
1111
+ }
1112
+ if (Array.isArray(stub)) {
1113
+ checkRows(stub)
1114
+ return { rows: stub as Array<Record<string, unknown>>, hasMore: false }
1115
+ }
1116
+ if (stub === null || typeof stub !== 'object') throw new Error(`${where} is ${describeShape(stub)}, not rows. ${fix}`)
1117
+ const answer = stub as Record<string, unknown>
1118
+ if (!('rows' in answer)) {
1119
+ const keys = Object.keys(answer)
1120
+ throw new Error(`${where} has no "rows"${keys.length ? ` (it has ${keys.map((k) => `"${k}"`).join(', ')})` : ' (it is empty)'}. ${fix}`)
1121
+ }
1122
+ if (!Array.isArray(answer.rows)) throw new Error(`${where} has "rows" that is ${describeShape(answer.rows)}, not an array. ${fix}`)
1123
+ checkRows(answer.rows)
1124
+ if ('hasMore' in answer && typeof answer.hasMore !== 'boolean') throw new Error(`${where} has "hasMore" that is ${describeShape(answer.hasMore)}, not true or false. ${fix}`)
1125
+ return { ...(answer as unknown as BlueprintQueryResult<Record<string, unknown>>), hasMore: answer.hasMore === true }
1126
+ }
1127
+
1128
+ function describeShape(value: unknown): string {
1129
+ if (value === null) return 'null'
1130
+ if (Array.isArray(value)) return 'an array'
1131
+ if (typeof value === 'object') return 'an object'
1132
+ if (typeof value === 'string') return 'a string'
1133
+ return `${typeof value === 'undefined' ? 'undefined' : `a ${typeof value}`}`
1134
+ }
1135
+
1136
+ /**
1137
+ * A query's answer as the platform shapes it, so \`subjectOf\` works in a
1138
+ * test: the object type's id whenever rows came back (the stub's own, else
1139
+ * \`testObjectTypeId\`), and on each row \`_meta: { recordVersion }\` (the
1140
+ * row's own \`_meta\` when the stub gives one, \`null\` included for a row
1141
+ * with no version; else \`0\`, as the platform answers for a record nobody
1142
+ * has edited). The stub itself is never changed.
1143
+ */
1144
+ export function asPlatformAnswers(
1145
+ objectType: string,
1146
+ result: BlueprintQueryResult<Record<string, unknown>>,
1147
+ stubObjectTypeId?: string,
1148
+ ): BlueprintQueryResult<Record<string, unknown>> {
1149
+ const rows = (result.rows ?? []).map((row) => (row && typeof row === 'object' && !('_meta' in row) ? { ...row, _meta: { recordVersion: 0 } } : row))
1150
+ const objectTypeId = result.objectTypeId ?? stubObjectTypeId ?? (rows.length ? testObjectTypeId(objectType) : undefined)
1151
+ return { ...result, rows, ...(objectTypeId ? { objectTypeId } : {}) }
1152
+ }
1153
+
1154
+ /**
1155
+ * What the platform refuses against the object type's catalog, refused where
1156
+ * the stubbed rows show it. A stub lists only the fields a test cares about, so
1157
+ * a property no row carries is read as empty (null, as an unset field is), unless
1158
+ * it is one letter or two away from one the rows do carry: then it is a typo,
1159
+ * refused as the platform refuses an unknown property. A property whose stubbed
1160
+ * values are points is a location, and one whose values are something else is
1161
+ * not, which settles which operators it takes.
1162
+ */
1163
+ function checkAgainstRows(objectType: string, rows: ReadonlyArray<Record<string, unknown>>, options: BlueprintQueryOptions): void {
1164
+ if (!rows.length) return
1165
+ const carried = new Set(rows.flatMap((row) => (row && typeof row === 'object' ? Object.keys(row) : [])))
1166
+ const named = new Set<string>([
1167
+ ...(options.where ? properties(options.where) : []),
1168
+ ...(options.orderBy ?? []).map((o) => o.property),
1169
+ ...(options.select ?? []),
1170
+ ...(options.nearest ? [options.nearest.property] : []),
1171
+ ])
1172
+ for (const property of named) {
1173
+ if (carried.has(property)) continue
1174
+ const near = [...carried].find((name) => name !== '_meta' && nearMiss(property, name))
1175
+ if (near) {
1176
+ throw new Error(`blueprint query on ${objectType}: no stubbed row has the property "${property}" (did you mean "${near}"?). The platform refuses an unknown property; if it exists, give the rows a value for it (null when it has none).`)
1177
+ }
1178
+ }
1179
+ // true: every stubbed value is a point; false: some value is not one; undefined: the rows say nothing.
1180
+ const isLocation = (property: string): boolean | undefined => {
1181
+ const values = rows.map((row) => row[property]).filter((v) => !isNullish(v))
1182
+ if (!values.length) return undefined
1183
+ return values.every((v) => point(v) !== null && isPlainObject(v) && hasExactKeys(v, ['lat', 'lon']))
1184
+ }
1185
+ const walk = (node: WhereNode): void => {
1186
+ if ('and' in node) return node.and.forEach(walk)
1187
+ if ('or' in node) return node.or.forEach(walk)
1188
+ if ('not' in node) return walk(node.not)
1189
+ const location = isLocation(node.property)
1190
+ if (GEO_OPS.has(node.op) && location === false) {
1191
+ refuse(`"${node.op}" only applies to a location property; "${node.property}" on ${objectType} is not a location in the stubbed rows`)
1192
+ }
1193
+ if (location === true && !GEO_OPS.has(node.op) && node.op !== 'isNull' && node.op !== 'isNotNull') {
1194
+ refuse(`operator "${node.op}" is not valid for location "${node.property}" on ${objectType}; use nearby, withinBbox, isNull or isNotNull`)
1195
+ }
1196
+ }
1197
+ if (options.where) walk(options.where)
1198
+ if (options.nearest && isLocation(options.nearest.property) === false) {
1199
+ refuse(`"nearest" only applies to a location property; "${options.nearest.property}" on ${objectType} is not a location in the stubbed rows`)
1200
+ }
1201
+ }
1202
+
1203
+ /** A stubbed row set, queried. */
1204
+ export function applyQuery(
1205
+ objectType: string,
1206
+ stub: BlueprintQueryResult<Record<string, unknown>>,
1207
+ options: BlueprintQueryOptions,
1208
+ env: { now: Date; timezone: string },
1209
+ ): BlueprintQueryResult<Record<string, unknown>> {
1210
+ checkQuery(options)
1211
+ const shaped = options.where !== undefined || options.orderBy !== undefined || options.limit !== undefined || options.pageToken !== undefined || options.nearest !== undefined
1212
+ // A query that asks for nothing gets the stub as it was written.
1213
+ if (!shaped) return stub
1214
+ const all = stub.rows ?? []
1215
+ checkAgainstRows(objectType, all, options)
1216
+ let rows = options.where ? all.filter((row) => evaluateWhere(options.where!, row, env) === true) : [...all]
1217
+ if (options.nearest) {
1218
+ const from = options.nearest.from as { lat: number; lon: number }
1219
+ const property = options.nearest.property
1220
+ rows = rows
1221
+ .map((row) => ({ row, at: point(row[property]) }))
1222
+ .filter((r): r is { row: Record<string, unknown>; at: { lat: number; lon: number } } => r.at !== null)
1223
+ .map(({ row, at }) => ({ ...row, _distanceMeters: distanceMeters(at, from) }))
1224
+ .sort((a, b) => (a._distanceMeters as number) - (b._distanceMeters as number))
1225
+ } else if (options.orderBy?.length) {
1226
+ rows = orderRows(rows, options.orderBy)
1227
+ }
1228
+ const limit = Math.min(Math.max(Math.trunc(options.limit ?? DEFAULT_LIMIT), 1), MAX_LIMIT)
1229
+ let offset = 0
1230
+ if (options.pageToken !== undefined) {
1231
+ if (!options.pageToken.startsWith(PAGE_PREFIX) || !/^\d+$/.test(options.pageToken.slice(PAGE_PREFIX.length))) {
1232
+ throw new Error(`blueprint query on ${objectType}: pageToken "${options.pageToken}" was not returned by this test context. Pass the previous result's nextPageToken, or stub the object type with a function of the query's options.`)
1233
+ }
1234
+ offset = Number(options.pageToken.slice(PAGE_PREFIX.length))
1235
+ }
1236
+ const page = rows.slice(offset, offset + limit)
1237
+ const more = offset + limit < rows.length
1238
+ // A stub that says it is one page of more keeps saying so on its last page.
1239
+ if (more) return { rows: page, hasMore: true, nextPageToken: `${PAGE_PREFIX}${offset + limit}` }
1240
+ return stub.hasMore ? { rows: page, hasMore: true, ...(stub.nextPageToken ? { nextPageToken: stub.nextPageToken } : {}) } : { rows: page, hasMore: false }
380
1241
  }