@ultimat3/query 17.0.0 → 19.0.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 +23 -0
- package/README.md +36 -0
- package/package.json +7 -7
- package/src/errors.ts +48 -0
- package/src/facade.ts +1 -0
- package/src/index.ts +9 -0
- package/src/live.ts +5 -0
- package/src/query.ts +36 -1
- package/src/subscribes.ts +44 -0
package/CLAUDE.md
CHANGED
|
@@ -22,6 +22,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
22
22
|
| `naming.ts` | export name → `/_x/query/<kebab>`. Pure string math. **Paths only** — no tool name |
|
|
23
23
|
| `registry.ts` | export-name registration, `describeQueries()`, and the `registerPrimitiveRegistrar('query', …)` announcement |
|
|
24
24
|
| `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
|
|
25
|
+
| `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
|
|
25
26
|
| `matcher.ts` | change event → minimal patch, `refill` when the window cannot place the row, or `X_MATCHER_UNSUPPORTED` |
|
|
26
27
|
| `pagination.ts` | `paginate()` over core's cursor codec — no offset, ever |
|
|
27
28
|
| `cursor-value.ts` | what a sort value becomes inside a cursor, and what it becomes again |
|
|
@@ -242,6 +243,28 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
242
243
|
`total` is how `SqlSource` says the source already serves one order it can be resumed in. The
|
|
243
244
|
fixture in `search.test.ts` must stay NOT id-ascending: `ROWS` is `a, b, c`, which is why the
|
|
244
245
|
shipped page test could not see this.
|
|
246
|
+
- **`subscribes:` is DECLARED because nothing can derive it, and CROSS-CHECKED because a
|
|
247
|
+
declaration drifts** (`subscribes.ts`, `As of 2026-08-26`). `@ultimat3/realtime` refuses a
|
|
248
|
+
subscription to a table that is not `REPLICA IDENTITY FULL` — logical replication carries no old
|
|
249
|
+
row on an `UPDATE`, so no patch is computable — and nothing emitted the `alter`, so a scaffolded
|
|
250
|
+
app with a live query generated a schema its own preflight rejected (#357). It cannot be derived:
|
|
251
|
+
the relation name is a string inside `sql:`, a callback no generator can invoke without valid
|
|
252
|
+
input, so `QueryDescriptor` carried no table and `x db gen` had no live-query set to read. The
|
|
253
|
+
field is optional and a read that declares nothing is unchanged — the emitter sees no table.
|
|
254
|
+
The cross-check is what makes it worth shipping: `toLiveQuery` asserts `shape.entity` is among the
|
|
255
|
+
declared names at the first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`), one line under
|
|
256
|
+
`assertMatchable` and for its reason — a live read whose old row never arrives cannot be patched,
|
|
257
|
+
so a WARNING would preserve only a subscription that silently stops updating, and the stale
|
|
258
|
+
declaration has already granted the identity to the wrong table. `pg-preflight.ts`'s
|
|
259
|
+
`warnPartialIdentity` warns instead, and that precedent does NOT transfer: it fires for every app
|
|
260
|
+
on the postgres default, which nobody wrote, while this fires only on a declaration an author
|
|
261
|
+
wrote wrong. **Membership, never equality** — a `QueryShape` names one relation and a read may
|
|
262
|
+
join several, and `@ultimat3/db` keeps only the declared names an entity's table matches, so an
|
|
263
|
+
extra name costs nothing and the resolved one going missing is the whole failure. An empty list,
|
|
264
|
+
or one on a read that is not `live: true`, is refused at `query()`
|
|
265
|
+
(`X_QUERY_SUBSCRIBES_INVALID`) beside `assertCacheTtl`: a declaration nothing can act on is wrong
|
|
266
|
+
for every subscriber, and one on a non-live read would never reach the cross-check at all —
|
|
267
|
+
declared and never wired, which is the defect this field exists to close.
|
|
245
268
|
- Policy runs per subscriber for live queries. Never cache a decision across actors.
|
|
246
269
|
- The matcher patches from `QueryShape`, never from SQL text.
|
|
247
270
|
- `paginate` has no `offset` parameter and must never grow one, and it is reachable **only** as
|
package/README.md
CHANGED
|
@@ -151,6 +151,42 @@ forgetting the policy, and the reason is what tells the next reader which of the
|
|
|
151
151
|
| `policy` | evaluated **per subscriber**, on subscribe and on every fanout |
|
|
152
152
|
| cursor | reconnect state: epoch + query hash + version + seek key |
|
|
153
153
|
|
|
154
|
+
### `subscribes:` — the one fact about a live read nothing can derive
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { from, type QueryPolicy, query, t } from '@ultimat3/query';
|
|
158
|
+
|
|
159
|
+
interface PostSummary {
|
|
160
|
+
readonly id: string;
|
|
161
|
+
readonly orgId: string;
|
|
162
|
+
readonly createdAt: Date;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
declare const feedRead: QueryPolicy;
|
|
166
|
+
declare const repo: () => Promise<readonly PostSummary[]>;
|
|
167
|
+
|
|
168
|
+
export const liveFeed = query({
|
|
169
|
+
input: t.object({ orgId: t.uuid }),
|
|
170
|
+
policy: feedRead,
|
|
171
|
+
live: true,
|
|
172
|
+
subscribes: ['posts'], // the table `x db gen` grants REPLICA IDENTITY FULL
|
|
173
|
+
sql: ({ orgId }) => from<PostSummary>('posts', repo).where({ orgId }).orderBy('createdAt'),
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Logical replication carries no old row on an `UPDATE` unless the table is `REPLICA IDENTITY FULL`,
|
|
178
|
+
so no patch can be computed and `@ultimat3/realtime` refuses the subscription. The table name lives
|
|
179
|
+
inside `sql:` — a callback nothing can invoke without valid input — so `x db gen` has no way to
|
|
180
|
+
work it out and needs it declared.
|
|
181
|
+
|
|
182
|
+
Optional: a read that declares nothing behaves exactly as it did, and the generator emits nothing
|
|
183
|
+
for it. It is **checked, not trusted** — `toLiveQuery` refuses at the first subscribe when the
|
|
184
|
+
relation the read resolves to is not among the declared names (`X_QUERY_SUBSCRIBES_DRIFT`), because
|
|
185
|
+
a stale declaration grants the identity to the wrong table and leaves the right one unpatchable in
|
|
186
|
+
silence. An empty list, or one on a read that is not `live: true`, is refused at `query()`
|
|
187
|
+
(`X_QUERY_SUBSCRIBES_INVALID`). Naming more relations than the shape resolves to is legal — a join
|
|
188
|
+
reads relations the shape's single `entity` cannot name.
|
|
189
|
+
|
|
154
190
|
### Cursor tradeoff (the reconnect risk, stated plainly)
|
|
155
191
|
|
|
156
192
|
A cursor is not a snapshot. Resume re-runs the **bounded** query from the seek key
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "19.0.0",
|
|
4
4
|
"description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"engines": {
|
|
31
|
-
"bun": ">=1.
|
|
31
|
+
"bun": ">=1.4.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/cache": "
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/http": "
|
|
41
|
-
"@ultimat3/policy": "
|
|
42
|
-
"@ultimat3/schema": "
|
|
38
|
+
"@ultimat3/cache": "19.0.0",
|
|
39
|
+
"@ultimat3/core": "19.0.0",
|
|
40
|
+
"@ultimat3/http": "19.0.0",
|
|
41
|
+
"@ultimat3/policy": "19.0.0",
|
|
42
|
+
"@ultimat3/schema": "19.0.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/errors.ts
CHANGED
|
@@ -23,6 +23,8 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
23
23
|
X_QUERY_INPUT_UNENCODABLE: 'a query input cannot be carried in a query string',
|
|
24
24
|
X_QUERY_NOT_PAGEABLE: 'a read returned rows with no id, so a cursor cannot name a position',
|
|
25
25
|
X_QUERY_POLICY_MISSING: 'a query was registered without a policy',
|
|
26
|
+
X_QUERY_SUBSCRIBES_DRIFT: 'a live query subscribes to a relation its own sql never reads',
|
|
27
|
+
X_QUERY_SUBSCRIBES_INVALID: 'a query declares subscribed relations no live read can use',
|
|
26
28
|
X_QUERY_UNREGISTERED: 'a query was used before it was registered',
|
|
27
29
|
};
|
|
28
30
|
|
|
@@ -248,6 +250,52 @@ export class MatcherUnsupportedError extends UltimateError {
|
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
252
|
|
|
253
|
+
/**
|
|
254
|
+
* A `subscribes:` declaration that cannot mean anything, refused at `query()` so the file that
|
|
255
|
+
* wrote it is the file that fails — the rule `assertCacheTtl` follows one field over.
|
|
256
|
+
*
|
|
257
|
+
* `problem` is a closed union rather than two classes: both are the same author mistake in the
|
|
258
|
+
* same declaration, and a caller catching one wants the other too.
|
|
259
|
+
*/
|
|
260
|
+
export class QuerySubscribesInvalidError extends UltimateError {
|
|
261
|
+
constructor(problem: 'empty' | 'not-live') {
|
|
262
|
+
super({
|
|
263
|
+
code: 'X_QUERY_SUBSCRIBES_INVALID',
|
|
264
|
+
cause:
|
|
265
|
+
problem === 'empty'
|
|
266
|
+
? 'subscribes: [] names no relation, so no table can be granted REPLICA IDENTITY FULL'
|
|
267
|
+
: 'subscribes: names relations on a read that is not live, and only a live read subscribes',
|
|
268
|
+
fix:
|
|
269
|
+
problem === 'empty'
|
|
270
|
+
? "name the relation the read selects from — subscribes: ['posts'] — or drop the field"
|
|
271
|
+
: 'add `live: true` beside it, or delete `subscribes:` — nothing reads it on a plain read',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The declaration and the read disagreed. Thrown at the first subscribe, where the resolved shape
|
|
278
|
+
* is the first thing in the framework that can answer which relation the read really names.
|
|
279
|
+
*
|
|
280
|
+
* A refusal and not a warning, for the reason `assertMatchable` refuses one line above it: a live
|
|
281
|
+
* query whose old row never arrives cannot be patched, so what a warning would preserve is a
|
|
282
|
+
* subscription that silently stops updating. `x db gen` reads this declaration, so a stale one has
|
|
283
|
+
* already granted REPLICA IDENTITY FULL to the wrong table and left this one without it.
|
|
284
|
+
*/
|
|
285
|
+
export class QuerySubscribesDriftError extends UltimateError {
|
|
286
|
+
constructor(name: string, declared: readonly string[], entity: string) {
|
|
287
|
+
super({
|
|
288
|
+
code: 'X_QUERY_SUBSCRIBES_DRIFT',
|
|
289
|
+
cause:
|
|
290
|
+
`live query "${name}" declares subscribes: [${declared.join(', ')}], ` +
|
|
291
|
+
`and its sql reads "${entity}", which is not among them`,
|
|
292
|
+
fix:
|
|
293
|
+
`add "${entity}" to that query's subscribes:, then run \`x db gen\` — ` +
|
|
294
|
+
'the migration granting REPLICA IDENTITY FULL is keyed on the declared names',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
251
299
|
export class QueryInputInvalidError extends UltimateError {
|
|
252
300
|
constructor(name: string, detail: string) {
|
|
253
301
|
super({
|
package/src/facade.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
|
|
|
25
25
|
return {
|
|
26
26
|
input: def.input,
|
|
27
27
|
policy: def.policy,
|
|
28
|
+
...(def.subscribes === undefined ? {} : { subscribes: def.subscribes }),
|
|
28
29
|
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
29
30
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
30
31
|
...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* authorize or execute on its own. One authz system, structurally.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
// Anchored on purpose, and NOT by the `sideEffects` array: Bun reads any array as `false` and drops
|
|
10
|
+
// the module regardless (oven-sh/bun#40650). `registerPrimitiveRegistrar('query', …)` here is read
|
|
11
|
+
// by @ultimat3/core's registrar table on behalf of `x` and the manifest, and nothing that registers
|
|
12
|
+
// a query imports this module for a binding. `SIDE_EFFECTS_ANCHORS` carries the argument and
|
|
13
|
+
// `bun run side-effects` enforces it.
|
|
14
|
+
import './registry';
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* Flight control for the typed client, and OPT-IN by construction: `client.ts` names `ClientFlight`
|
|
11
18
|
* as a TYPE only, so a caller that never mentions `createClientFlight` pays nothing for the fence,
|
|
@@ -73,6 +80,8 @@ export {
|
|
|
73
80
|
QueryNotPageableError,
|
|
74
81
|
QueryPolicyMissingError,
|
|
75
82
|
QueryRequestFailedError,
|
|
83
|
+
QuerySubscribesDriftError,
|
|
84
|
+
QuerySubscribesInvalidError,
|
|
76
85
|
QueryUnregisteredError,
|
|
77
86
|
} from './errors';
|
|
78
87
|
/** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
|
package/src/live.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { queryHash } from './query';
|
|
|
18
18
|
import { queryName, sourceFor } from './read';
|
|
19
19
|
import type { QueryShape, SeekKey } from './shape';
|
|
20
20
|
import { seekKeyOf } from './shape';
|
|
21
|
+
import { assertSubscribed } from './subscribes';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Reconnect state. Deliberately tiny — an id, a sort key and a version — because
|
|
@@ -123,6 +124,10 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
|
|
|
123
124
|
const shape = source.shape();
|
|
124
125
|
// Fail at subscribe time, not on the first change event nobody can patch.
|
|
125
126
|
assertMatchable(name, shape);
|
|
127
|
+
// The same moment and the same reason, one declaration further out: a `subscribes:` the read has
|
|
128
|
+
// outgrown means `x db gen` granted REPLICA IDENTITY FULL somewhere else, so every UPDATE on this
|
|
129
|
+
// relation arrives without its old row and no patch is computable from it.
|
|
130
|
+
assertSubscribed(name, target.subscribes, shape.entity);
|
|
126
131
|
|
|
127
132
|
const epoch = options.epoch ?? liveEpoch();
|
|
128
133
|
const hash = queryHash(name, input);
|
package/src/query.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { QueryPolicy, QuerySurface } from './policy-gate';
|
|
|
24
24
|
import { policyCapability, policyPermissions } from './policy-gate';
|
|
25
25
|
import { hasDef, queryName, runQuery, stashDef } from './read';
|
|
26
26
|
import type { SqlSource } from './source';
|
|
27
|
+
import { assertSubscribes } from './subscribes';
|
|
27
28
|
|
|
28
29
|
export interface QueryCache {
|
|
29
30
|
/** Tags this read depends on. An action's `invalidates` drops exactly these keys. */
|
|
@@ -72,6 +73,22 @@ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object>
|
|
|
72
73
|
readonly policy: QueryPolicy;
|
|
73
74
|
/** `true` makes the read subscribable — see `toLiveQuery`. */
|
|
74
75
|
readonly live?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* The relations this live read is patched from — the tables `x db gen` grants
|
|
78
|
+
* `REPLICA IDENTITY FULL`, without which logical replication carries no old row on an UPDATE
|
|
79
|
+
* and `@ultimat3/realtime` refuses the subscription.
|
|
80
|
+
*
|
|
81
|
+
* A DECLARATION because nothing can derive it: the relation name lives inside `sql:`, a callback
|
|
82
|
+
* no generator can invoke without valid input, so there is no live-query set to read off the
|
|
83
|
+
* registry (#357). Optional, and a read that declares nothing behaves exactly as before —
|
|
84
|
+
* the emitter sees no table and emits no statement.
|
|
85
|
+
*
|
|
86
|
+
* Checked, never trusted. An empty list or a read that is not live is refused here
|
|
87
|
+
* (`X_QUERY_SUBSCRIBES_INVALID`); a list the resolved shape is missing from is refused at the
|
|
88
|
+
* first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`). Naming MORE relations than the shape resolves to
|
|
89
|
+
* is legal — a join reads relations the shape's single `entity` cannot name.
|
|
90
|
+
*/
|
|
91
|
+
readonly subscribes?: readonly string[];
|
|
75
92
|
sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
|
|
76
93
|
readonly cache?: QueryCache;
|
|
77
94
|
readonly mcp?: QueryMcp;
|
|
@@ -140,6 +157,13 @@ export interface QueryDescriptor {
|
|
|
140
157
|
*/
|
|
141
158
|
readonly permissions: readonly string[];
|
|
142
159
|
readonly tags: readonly string[];
|
|
160
|
+
/**
|
|
161
|
+
* The relations declared on a live read, or `null` when it declared none — the fact
|
|
162
|
+
* `@ultimat3/cli` reads out of the manifest to tell `x db gen` which tables need
|
|
163
|
+
* `REPLICA IDENTITY FULL`. `null` and not `[]`: an empty list is refused at `query()`, so
|
|
164
|
+
* absence has exactly one meaning and the manifest can drop the key rather than write it empty.
|
|
165
|
+
*/
|
|
166
|
+
readonly subscribes: readonly string[] | null;
|
|
143
167
|
readonly ttlMs: number | null;
|
|
144
168
|
readonly rateLimit: QueryRateLimit | null;
|
|
145
169
|
readonly deprecated: Deprecation | null;
|
|
@@ -154,6 +178,7 @@ export interface AnyQueryDef {
|
|
|
154
178
|
readonly input: StandardSchemaV1;
|
|
155
179
|
readonly policy: QueryPolicy;
|
|
156
180
|
readonly live?: boolean;
|
|
181
|
+
readonly subscribes?: readonly string[];
|
|
157
182
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
158
183
|
readonly cache?: QueryCache;
|
|
159
184
|
readonly mcp?: QueryMcp;
|
|
@@ -169,6 +194,8 @@ export interface AnyQuery {
|
|
|
169
194
|
/** The declaration, minus `sql`: readable, and never a way to run it. */
|
|
170
195
|
readonly input: StandardSchemaV1;
|
|
171
196
|
readonly policy: QueryPolicy;
|
|
197
|
+
/** Lifted for the same reason `rateLimit` is: `toLiveQuery` cross-checks it without `defOf`. */
|
|
198
|
+
readonly subscribes?: readonly string[];
|
|
172
199
|
readonly cache?: QueryCache;
|
|
173
200
|
readonly mcp?: QueryMcp;
|
|
174
201
|
/** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
|
|
@@ -212,6 +239,7 @@ export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> =
|
|
|
212
239
|
Query<TInput, TRow>,
|
|
213
240
|
| 'input'
|
|
214
241
|
| 'policy'
|
|
242
|
+
| 'subscribes'
|
|
215
243
|
| 'cache'
|
|
216
244
|
| 'mcp'
|
|
217
245
|
| 'rateLimit'
|
|
@@ -231,7 +259,13 @@ export function query<TInput extends StandardSchemaV1, TRow extends object>(
|
|
|
231
259
|
// cannot carry is wrong for every call, and the file that declared it is where it is repaired.
|
|
232
260
|
assertEncodableInput(def.input);
|
|
233
261
|
assertCacheTtl(def.cache);
|
|
234
|
-
|
|
262
|
+
assertSubscribes(def.subscribes, def.live);
|
|
263
|
+
// Snapshot and freeze BEFORE registration. `build()` stores `def` and `facadeFor()` exposes the
|
|
264
|
+
// same array, so a caller holding the literal it passed could mutate the list AFTER validation
|
|
265
|
+
// — and that list is what the manifest publishes and what `x db gen` grants REPLICA IDENTITY
|
|
266
|
+
// FULL from. A frozen copy makes the declaration the descriptor carries the one that was checked.
|
|
267
|
+
const subscribes = def.subscribes === undefined ? undefined : Object.freeze([...def.subscribes]);
|
|
268
|
+
return build(subscribes === undefined ? def : { ...def, subscribes }, '');
|
|
235
269
|
}
|
|
236
270
|
|
|
237
271
|
/**
|
|
@@ -302,6 +336,7 @@ export function describeQuery(target: AnyQuery): QueryDescriptor {
|
|
|
302
336
|
// The flattened list, beside the label and never instead of it: one is read, one is matched.
|
|
303
337
|
permissions: policyPermissions(target.policy),
|
|
304
338
|
tags: tagKeys(target.cache?.tags ?? []),
|
|
339
|
+
subscribes: target.subscribes ?? null,
|
|
305
340
|
ttlMs: target.cache?.ttlMs ?? null,
|
|
306
341
|
rateLimit: target.rateLimit ?? null,
|
|
307
342
|
deprecated: target.deprecated ?? null,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relations a live read is patched from: the one thing about a query that cannot be derived,
|
|
3
|
+
* and the two assertions that keep the declaration honest. `x db gen` grants those relations
|
|
4
|
+
* `REPLICA IDENTITY FULL`, so a name that has gone stale is worse than none — it grants it to the
|
|
5
|
+
* wrong table and leaves the right one unable to produce a patch, in silence (#357).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { QuerySubscribesDriftError, QuerySubscribesInvalidError } from './errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Judged at `query()`, beside `assertCacheTtl` and for its reason: a declaration nothing can act on
|
|
12
|
+
* is wrong for every subscriber of that read, so it fails on the line that wrote it.
|
|
13
|
+
*
|
|
14
|
+
* A read that is not live is refused rather than ignored. `subscribes:` has exactly one reader and
|
|
15
|
+
* that reader only walks live queries, so an accepted one would be a declaration nothing verifies
|
|
16
|
+
* and nothing emits — the shape of defect this field exists to close.
|
|
17
|
+
*/
|
|
18
|
+
export function assertSubscribes(
|
|
19
|
+
subscribes: readonly string[] | undefined,
|
|
20
|
+
live: boolean | undefined,
|
|
21
|
+
): void {
|
|
22
|
+
if (subscribes === undefined) return;
|
|
23
|
+
if (subscribes.length === 0) throw new QuerySubscribesInvalidError('empty');
|
|
24
|
+
if (live !== true) throw new QuerySubscribesInvalidError('not-live');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Judged at the first subscribe, because `shape.entity` is the first answer the framework has to
|
|
29
|
+
* "which relation does this read actually name" — the string sits inside `sql:`, a callback no
|
|
30
|
+
* static reader can invoke without valid input.
|
|
31
|
+
*
|
|
32
|
+
* Membership, never equality: a `QueryShape` names ONE relation while a read may join several, and
|
|
33
|
+
* `@ultimat3/db` keeps only the declared names an entity's table matches. So a name the shape never
|
|
34
|
+
* resolves to costs nothing, and the resolved one going MISSING is the whole failure.
|
|
35
|
+
*/
|
|
36
|
+
export function assertSubscribed(
|
|
37
|
+
name: string,
|
|
38
|
+
subscribes: readonly string[] | undefined,
|
|
39
|
+
entity: string,
|
|
40
|
+
): void {
|
|
41
|
+
if (subscribes === undefined) return;
|
|
42
|
+
if (subscribes.includes(entity)) return;
|
|
43
|
+
throw new QuerySubscribesDriftError(name, subscribes, entity);
|
|
44
|
+
}
|