@ultimat3/query 30.6.0 → 30.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -0
- package/package.json +8 -8
- package/src/contract-test.ts +17 -7
- package/src/exercise-declarations.ts +38 -0
- package/src/exercise.ts +78 -21
- package/src/index.ts +4 -0
package/CLAUDE.md
CHANGED
|
@@ -16,6 +16,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
16
16
|
| `read.ts` | **the one read path** (`runQuery`, `sourceFor`) + the private declaration store `sql` lives in |
|
|
17
17
|
| `contract-test.ts` | `.contract()`, the generated suite. The three `X_CONTRACT_*` codes and `X_CONTRACT_DRIFT` are borrowed (`QUERY_BORROWED_ERROR_CODES`) |
|
|
18
18
|
| `exercise.ts` | the exercise twin. "Reached" means `sql()` built a source, read off the refusal (`beforeSql`), because `read.ts` keeps no counter. No transaction: a read writes nothing |
|
|
19
|
+
| `exercise-declarations.ts` | action's twin: `.contract({ exercise })` takes the app's `defineExercise()` value and reads the entry by the query's name (`apps/web/exercise.ts`, the one home) |
|
|
19
20
|
| `declared-errors.ts` | `judgedSql` / `judgedRead`, around `sql` and the source's `execute` in `read.ts`. `errors:` is checked where it is lifted (`facade.ts`), not in `query.ts` |
|
|
20
21
|
| `declared-errors-contract.ts`, `openapi-errors.ts` | `.contract()`'s source scan; core's builder bound to http's status |
|
|
21
22
|
| `http-pin.ts` | a read's pinned `http.path`, its check at `query()`, `servedQueryPath`, `queryHttpPinOf` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.8.0",
|
|
4
4
|
"description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"test": "bun test"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@ultimat3/cache": "30.
|
|
51
|
-
"@ultimat3/core": "30.
|
|
52
|
-
"@ultimat3/db": "30.
|
|
53
|
-
"@ultimat3/entity": "30.
|
|
54
|
-
"@ultimat3/http": "30.
|
|
55
|
-
"@ultimat3/policy": "30.
|
|
56
|
-
"@ultimat3/schema": "30.
|
|
50
|
+
"@ultimat3/cache": "30.8.0",
|
|
51
|
+
"@ultimat3/core": "30.8.0",
|
|
52
|
+
"@ultimat3/db": "30.8.0",
|
|
53
|
+
"@ultimat3/entity": "30.8.0",
|
|
54
|
+
"@ultimat3/http": "30.8.0",
|
|
55
|
+
"@ultimat3/policy": "30.8.0",
|
|
56
|
+
"@ultimat3/schema": "30.8.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/src/contract-test.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
8
|
import { admitsAnonymous } from '@ultimat3/policy';
|
|
9
9
|
import { declaredErrorsContract } from './declared-errors-contract';
|
|
10
|
-
import type { QueryExerciseOptions } from './exercise';
|
|
11
10
|
import { exerciseQuery, queryExerciseTestName, queryExerciseUndeclared } from './exercise';
|
|
11
|
+
import type { QueryContractExercise } from './exercise-declarations';
|
|
12
|
+
import { isQueryExerciseDeclarations, resolveQueryExercise } from './exercise-declarations';
|
|
12
13
|
import type { AnyQuery } from './query';
|
|
13
14
|
import { queryName } from './read';
|
|
14
15
|
|
|
@@ -20,10 +21,12 @@ export interface QueryContractTest {
|
|
|
20
21
|
|
|
21
22
|
export interface QueryContractTestOptions {
|
|
22
23
|
/**
|
|
23
|
-
* Who the generated runs read as
|
|
24
|
-
* `
|
|
24
|
+
* Who the generated runs read as. In an app, the `exercise` export of `apps/web/exercise.ts` —
|
|
25
|
+
* the `defineExercise()` value `x exercise` reads — and this query's entry in it is the one
|
|
26
|
+
* used: one declaration, both runners. One entry inline (`{ actor }`, `{ off: '<reason>' }`) is
|
|
27
|
+
* for a test binding rows of its own world. Required unless the policy admits an anonymous reader.
|
|
25
28
|
*/
|
|
26
|
-
readonly exercise?:
|
|
29
|
+
readonly exercise?: QueryContractExercise;
|
|
27
30
|
/** The context each run starts from; the actor is swapped in, the rest kept. */
|
|
28
31
|
readonly ctx?: Ctx;
|
|
29
32
|
}
|
|
@@ -37,7 +40,7 @@ export function queryContractTestsFor(
|
|
|
37
40
|
|
|
38
41
|
function exerciseContract(target: AnyQuery, options: QueryContractTestOptions): QueryContractTest {
|
|
39
42
|
const name = queryName(target);
|
|
40
|
-
const exercise = options.exercise;
|
|
43
|
+
const exercise = resolveQueryExercise(name, options.exercise);
|
|
41
44
|
if (exercise !== undefined && 'off' in exercise) {
|
|
42
45
|
const reason = exercise.off.trim();
|
|
43
46
|
if (reason.length > 0) {
|
|
@@ -45,14 +48,21 @@ function exerciseContract(target: AnyQuery, options: QueryContractTestOptions):
|
|
|
45
48
|
}
|
|
46
49
|
return {
|
|
47
50
|
name: queryExerciseTestName(name),
|
|
48
|
-
run: () => Promise.reject(queryExerciseUndeclared(name,
|
|
51
|
+
run: () => Promise.reject(queryExerciseUndeclared(name, 'blank')),
|
|
49
52
|
};
|
|
50
53
|
}
|
|
51
54
|
const chosen = exercise ?? (admitsAnonymous(target.policy) ? { actor: null } : undefined);
|
|
52
55
|
return {
|
|
53
56
|
name: queryExerciseTestName(name),
|
|
54
57
|
run: async () => {
|
|
55
|
-
if (chosen === undefined)
|
|
58
|
+
if (chosen === undefined) {
|
|
59
|
+
const declared = options.exercise;
|
|
60
|
+
const why =
|
|
61
|
+
declared !== undefined && isQueryExerciseDeclarations(declared)
|
|
62
|
+
? 'no-entry'
|
|
63
|
+
: 'undeclared';
|
|
64
|
+
throw queryExerciseUndeclared(name, why);
|
|
65
|
+
}
|
|
56
66
|
await exerciseQuery(target, chosen, options.ctx);
|
|
57
67
|
},
|
|
58
68
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One declaration of a read's exercise: the app's `defineExercise()` value (apps/web/exercise.ts,
|
|
3
|
+
* the file `x exercise` reads), handed whole to `.contract({ exercise })`, resolved here by the
|
|
4
|
+
* query's own name. Action's twin (`exercise-declarations.ts` there): a sideways import is refused.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { QueryExerciseOptions } from './exercise';
|
|
8
|
+
|
|
9
|
+
export type { QueryExerciseOptions };
|
|
10
|
+
|
|
11
|
+
/** The one file an app declares its exercise in — every refusal's fix names it. */
|
|
12
|
+
export const QUERY_EXERCISE_DECLARATIONS_FILE = 'apps/web/exercise.ts';
|
|
13
|
+
|
|
14
|
+
/** `defineExercise()`'s value, as far as a contract reads it: the entries, by primitive name. */
|
|
15
|
+
export interface QueryExerciseDeclarations {
|
|
16
|
+
readonly kind: 'exercise-overrides';
|
|
17
|
+
readonly entries: ReadonlyMap<string, QueryExerciseOptions>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What `.contract({ exercise })` takes: the app's declarations, or one entry inline. */
|
|
21
|
+
export type QueryContractExercise = QueryExerciseOptions | QueryExerciseDeclarations;
|
|
22
|
+
|
|
23
|
+
export const isQueryExerciseDeclarations = (
|
|
24
|
+
value: QueryContractExercise,
|
|
25
|
+
): value is QueryExerciseDeclarations => 'kind' in value && value.kind === 'exercise-overrides';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The entry `name` reads: the declarations' own, or the inline one. `undefined` when none. A
|
|
29
|
+
* `'query:<name>'` key wins over the bare name — the one `x exercise` reads for a query whose
|
|
30
|
+
* name another kind shares (`@ultimat3/testing`'s `overrideFor`; action's twin keeps the same rule).
|
|
31
|
+
*/
|
|
32
|
+
export function resolveQueryExercise(
|
|
33
|
+
name: string,
|
|
34
|
+
exercise: QueryContractExercise | undefined,
|
|
35
|
+
): QueryExerciseOptions | undefined {
|
|
36
|
+
if (exercise === undefined || !isQueryExerciseDeclarations(exercise)) return exercise;
|
|
37
|
+
return exercise.entries.get(`query:${name}`) ?? exercise.entries.get(name);
|
|
38
|
+
}
|
package/src/exercise.ts
CHANGED
|
@@ -10,25 +10,37 @@ import {
|
|
|
10
10
|
ctxOf,
|
|
11
11
|
exerciseReplay,
|
|
12
12
|
isUltimateError,
|
|
13
|
+
renderDrawnInput,
|
|
14
|
+
renderExerciseKey,
|
|
13
15
|
renderFixShellArg,
|
|
14
16
|
runExercise,
|
|
17
|
+
sampleGapBinding,
|
|
15
18
|
tryUseContext,
|
|
16
19
|
UltimateError,
|
|
17
20
|
} from '@ultimat3/core';
|
|
18
|
-
import {
|
|
21
|
+
import type { ExerciseRequestOption } from '@ultimat3/http';
|
|
22
|
+
import { statusFor, withRequestAs, withScratchRateLimitStore } from '@ultimat3/http';
|
|
23
|
+
import { admitsAnonymous } from '@ultimat3/policy';
|
|
19
24
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
20
25
|
import { validateAsync } from '@ultimat3/schema';
|
|
21
|
-
import { arbitrary } from '@ultimat3/schema/sample';
|
|
26
|
+
import { arbitrary, describeSampleGap, sampleGaps } from '@ultimat3/schema/sample';
|
|
22
27
|
import { QueryAuditSinkMissingError } from './audit-errors';
|
|
23
28
|
import { QueryDeniedError } from './errors';
|
|
29
|
+
import { QUERY_EXERCISE_DECLARATIONS_FILE } from './exercise-declarations';
|
|
24
30
|
import type { AnyQuery } from './query';
|
|
25
31
|
import { queryName, sourceFor } from './read';
|
|
26
32
|
|
|
27
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Who reads, and what binds the parts a generated value cannot. Same shape as an action's: an
|
|
35
|
+
* omitted `actor` is `x exercise`'s pick, and here the signed-out reader where the policy admits
|
|
36
|
+
* one, else `X_CONTRACT_EXERCISE_UNDECLARED`.
|
|
37
|
+
*/
|
|
28
38
|
export interface QueryExerciseActorOptions {
|
|
29
|
-
readonly actor
|
|
39
|
+
readonly actor?: Actor | null | undefined;
|
|
30
40
|
readonly input?: (drawn: DrawnInput) => unknown;
|
|
31
41
|
readonly runs?: number;
|
|
42
|
+
/** What each run's request carries in; the read then runs inside it (action's `request`). */
|
|
43
|
+
readonly request?: ExerciseRequestOption;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
export interface QueryExerciseOffOptions {
|
|
@@ -72,6 +84,7 @@ export async function exerciseQuery(
|
|
|
72
84
|
ctx?: Ctx,
|
|
73
85
|
): Promise<QueryExerciseReport> {
|
|
74
86
|
const name = queryName(target);
|
|
87
|
+
const actor = readerOf(target, options, name);
|
|
75
88
|
const base = ctx ?? tryUseContext() ?? ctxOf({});
|
|
76
89
|
// Counted in a scratch store: a spent bucket would make the file's next real call a 429.
|
|
77
90
|
const verdict = await withScratchRateLimitStore(() =>
|
|
@@ -83,13 +96,16 @@ export async function exerciseQuery(
|
|
|
83
96
|
bind: bindDrawn(options.input),
|
|
84
97
|
accepts: (input) => accepts(target.input, input),
|
|
85
98
|
attempt: async (input) => {
|
|
99
|
+
const { request } = options;
|
|
86
100
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
actor:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
// On a declared request the reader is ON it, so `sourceFor` takes no actor.
|
|
102
|
+
if (request === undefined) {
|
|
103
|
+
await (await sourceFor(target, input, { ctx: base, actor, surface: 'http' })).execute();
|
|
104
|
+
} else {
|
|
105
|
+
await withRequestAs(name, actor, request, async () => {
|
|
106
|
+
await (await sourceFor(target, input, { surface: 'http' })).execute();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
93
109
|
return { ok: true, reached: true };
|
|
94
110
|
} catch (error) {
|
|
95
111
|
return { ok: false, reached: !beforeSql(error), error };
|
|
@@ -102,7 +118,7 @@ export async function exerciseQuery(
|
|
|
102
118
|
case 'pass':
|
|
103
119
|
return verdict.report;
|
|
104
120
|
case 'drift':
|
|
105
|
-
throw queryDrawsRefused(name);
|
|
121
|
+
throw queryDrawsRefused(name, target.input);
|
|
106
122
|
case 'unreached':
|
|
107
123
|
throw queryUnreached(name, verdict.outcomes);
|
|
108
124
|
case 'defect':
|
|
@@ -134,27 +150,68 @@ function queryUnreached(name: string, outcomes: readonly string[]): UltimateErro
|
|
|
134
150
|
return new UltimateError({
|
|
135
151
|
code: 'X_CONTRACT_EXERCISE_UNREACHED',
|
|
136
152
|
cause: `none of ${outcomes.length} generated runs of query ${name} reached its sql() (meta.outcomes lists what each one hit)`,
|
|
137
|
-
fix:
|
|
153
|
+
fix: `defineExercise({ ${renderFixShellArg(name, '<name>')}: { actor } }) # in ${QUERY_EXERCISE_DECLARATIONS_FILE}, an actor the policy permits`,
|
|
138
154
|
meta: { outcomes },
|
|
139
155
|
});
|
|
140
156
|
}
|
|
141
157
|
|
|
142
|
-
|
|
158
|
+
/** An entry's reader; none declared is the signed-out reader where the policy admits one. */
|
|
159
|
+
function readerOf(target: AnyQuery, options: QueryExerciseActorOptions, name: string) {
|
|
160
|
+
if (options.actor !== undefined) return options.actor;
|
|
161
|
+
if (admitsAnonymous(target.policy)) return null;
|
|
162
|
+
throw queryExerciseUndeclared(name, 'no-actor');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Action's `everyDrawRefused`, for a read: the field no draw can fill, named and bound. */
|
|
166
|
+
function queryDrawsRefused(name: string, schema: StandardSchemaV1): UltimateError {
|
|
167
|
+
const gaps = sampleGaps(schema);
|
|
168
|
+
const described = gaps.map((path) => describeSampleGap(schema, path));
|
|
169
|
+
const input =
|
|
170
|
+
gaps.length === 0
|
|
171
|
+
? "(drawn) => ({ ...drawn, field: '<a value the refinement accepts>' })"
|
|
172
|
+
: renderDrawnInput(gaps.map((path, i) => sampleGapBinding(path, described[i] ?? path)));
|
|
143
173
|
return new UltimateError({
|
|
144
174
|
code: 'X_CONTRACT_DRIFT',
|
|
145
|
-
cause:
|
|
146
|
-
|
|
175
|
+
cause:
|
|
176
|
+
gaps.length === 0
|
|
177
|
+
? `query ${name}: its input schema refused every generated value, so the read was never exercised`
|
|
178
|
+
: `query ${name}: its input schema refused every generated value — no value can be generated for ${described.join(', ')} — so the read was never exercised`,
|
|
179
|
+
fix: `defineExercise({ ${renderExerciseKey(name)}: { input: ${input} } }) # in ${QUERY_EXERCISE_DECLARATIONS_FILE}`,
|
|
147
180
|
});
|
|
148
181
|
}
|
|
149
182
|
|
|
150
|
-
/**
|
|
151
|
-
export
|
|
183
|
+
/** Why no exercise could run: action's four (`ExerciseUndeclaredWhy`), for a read. */
|
|
184
|
+
export type QueryExerciseUndeclaredWhy = 'blank' | 'undeclared' | 'no-entry' | 'no-actor';
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A guarded read with no exercise. The fix is the paste in the ONE home, `apps/web/exercise.ts`
|
|
188
|
+
* — the file `x exercise` reads and `.contract({ exercise })` is handed — never an inline option:
|
|
189
|
+
* an entry declared only in a test is one `x exercise` cannot see. Action's twin.
|
|
190
|
+
*/
|
|
191
|
+
export function queryExerciseUndeclared(
|
|
192
|
+
name: string,
|
|
193
|
+
why: QueryExerciseUndeclaredWhy,
|
|
194
|
+
): UltimateError {
|
|
152
195
|
const call = renderFixShellArg(name, '<name>');
|
|
196
|
+
const refuses = `query ${name} refuses an anonymous reader`;
|
|
153
197
|
return new UltimateError({
|
|
154
198
|
code: 'X_CONTRACT_EXERCISE_UNDECLARED',
|
|
155
|
-
cause:
|
|
156
|
-
|
|
157
|
-
: `query ${name} refuses an anonymous reader and its .contract() names no actor to read as`,
|
|
158
|
-
fix: `${call}.contract({ exercise: { actor } }) — or ${call}.contract({ exercise: { off: '<why this read cannot run in a test>' } })`,
|
|
199
|
+
cause: queryUndeclaredCause(name, why, refuses),
|
|
200
|
+
fix: `defineExercise({ ${call}: { actor } }) # in ${QUERY_EXERCISE_DECLARATIONS_FILE}, then ${call}.contract({ exercise }) with that file's export — or { off: '<why this read cannot run in a test>' } there`,
|
|
159
201
|
});
|
|
160
202
|
}
|
|
203
|
+
|
|
204
|
+
/** What was missing, said in the file it is missing from. A `switch`, never a key lookup on a table. */
|
|
205
|
+
function queryUndeclaredCause(
|
|
206
|
+
name: string,
|
|
207
|
+
why: QueryExerciseUndeclaredWhy,
|
|
208
|
+
refuses: string,
|
|
209
|
+
): string {
|
|
210
|
+
switch (why) {
|
|
211
|
+
case 'blank':
|
|
212
|
+
return `${name}.contract({ exercise: { off } }) gives a blank reason, so the skip is silent`;
|
|
213
|
+
case 'no-entry':
|
|
214
|
+
return `${refuses} and ${QUERY_EXERCISE_DECLARATIONS_FILE} declares no entry for it, so no actor to read as is known`;
|
|
215
|
+
}
|
|
216
|
+
return `${refuses} and its .contract() names no actor to read as`;
|
|
217
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -120,6 +120,10 @@ export type {
|
|
|
120
120
|
QueryExerciseReport,
|
|
121
121
|
} from './exercise';
|
|
122
122
|
export { exerciseQuery } from './exercise';
|
|
123
|
+
export type {
|
|
124
|
+
QueryContractExercise,
|
|
125
|
+
QueryExerciseDeclarations,
|
|
126
|
+
} from './exercise-declarations';
|
|
123
127
|
/** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
|
|
124
128
|
export { toQueryRoute } from './http';
|
|
125
129
|
export type { QueryHttp } from './http-pin';
|