@frontera-sdk/functions 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/README.md +25 -2
- package/package.json +2 -2
- package/src/define.ts +24 -9
- package/src/errors.ts +82 -0
- package/src/index.ts +3 -1
- package/src/runtime-context.ts +43 -34
- package/src/subject.ts +50 -0
- package/src/test-check.ts +16 -0
- package/src/testing.ts +877 -16
- package/src/types.ts +38 -1
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
|
-
*
|
|
42
|
-
* after every step, so code outside a step runs many
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
537
|
+
queryOptions?: BlueprintQueryOptions,
|
|
350
538
|
): Promise<BlueprintQueryResult<T>> {
|
|
351
539
|
requireGrant('blueprint:read')
|
|
352
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|