@ultimat3/query 1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/package.json +38 -0
- package/src/cache.ts +101 -0
- package/src/client.ts +87 -0
- package/src/errors.ts +203 -0
- package/src/facade.ts +40 -0
- package/src/index.ts +86 -0
- package/src/live.ts +165 -0
- package/src/matcher.ts +126 -0
- package/src/mcp-tool.ts +75 -0
- package/src/naming.ts +38 -0
- package/src/pagination.ts +81 -0
- package/src/policy-gate.ts +66 -0
- package/src/query.ts +221 -0
- package/src/read.ts +143 -0
- package/src/registry.ts +69 -0
- package/src/shape.ts +111 -0
- package/src/source.ts +211 -0
- package/src/sql.ts +74 -0
- package/src/stable.ts +56 -0
- package/src/tags.ts +17 -0
package/src/source.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `SqlSource` contract every read must satisfy, plus `from()` — the reference
|
|
3
|
+
* implementation used by tests, fixtures and app queries alike. No ORM backs it: a real
|
|
4
|
+
* app's `sql:` returns `from()` over an `@ultimat3/entity` repo, and a source only has to
|
|
5
|
+
* answer these four questions.
|
|
6
|
+
*/
|
|
7
|
+
import type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
|
|
8
|
+
import { compareRows, compareValues, matchesFilters } from './shape';
|
|
9
|
+
import { columnOf } from './stable';
|
|
10
|
+
|
|
11
|
+
export interface SqlText {
|
|
12
|
+
readonly sql: string;
|
|
13
|
+
readonly params: readonly unknown[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SqlSource<TRow> {
|
|
17
|
+
/** The generated SQL, verbatim. Agents read this to self-correct. */
|
|
18
|
+
toSQL(): SqlText;
|
|
19
|
+
execute(): Promise<readonly TRow[]>;
|
|
20
|
+
/** Required for `live: true`: the matcher patches from the shape, not from SQL. */
|
|
21
|
+
shape(): QueryShape;
|
|
22
|
+
/** Cursor push-down. Absent means pagination slices after execution. */
|
|
23
|
+
seek?(after: SeekKey | null, limit: number): SqlSource<TRow>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RowProvider<TRow> = readonly TRow[] | (() => Promise<readonly TRow[]>);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* In-memory reference source. `from<Post>('posts', rows).where({ orgId }).orderBy('createdAt')`
|
|
30
|
+
* generates real SQL text for `explain()` while executing against the provided rows.
|
|
31
|
+
*/
|
|
32
|
+
export function from<TRow extends object>(entity: string, rows: RowProvider<TRow>): Builder<TRow> {
|
|
33
|
+
return new Builder<TRow>(entity, rows, [], [], null, null, []);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly entity: string,
|
|
39
|
+
private readonly rows: RowProvider<TRow>,
|
|
40
|
+
private readonly filters: readonly Filter[],
|
|
41
|
+
private readonly order: readonly OrderKey[],
|
|
42
|
+
private readonly rowLimit: number | null,
|
|
43
|
+
private readonly after: SeekKey | null,
|
|
44
|
+
private readonly unsupported: readonly string[],
|
|
45
|
+
/** Set by `seek()`. Only a paged read pays for the id tiebreak — see `pageOrder()`. */
|
|
46
|
+
private readonly paged: boolean = false,
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
where(equals: Readonly<Record<string, unknown>>): Builder<TRow> {
|
|
50
|
+
const added = Object.keys(equals)
|
|
51
|
+
.sort()
|
|
52
|
+
.map((column): Filter => ({ column, op: '=', value: equals[column] }));
|
|
53
|
+
return this.derive({ filters: [...this.filters, ...added] });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
compare(column: string, op: FilterOp, value: unknown): Builder<TRow> {
|
|
57
|
+
return this.derive({ filters: [...this.filters, { column, op, value }] });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
orderBy(column: string, direction: 'asc' | 'desc' = 'asc'): Builder<TRow> {
|
|
61
|
+
return this.derive({ order: [...this.order, { column, direction }] });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
limit(rowLimit: number): Builder<TRow> {
|
|
65
|
+
return this.derive({ rowLimit });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Declares a feature the matcher cannot patch — `live` then fails loudly. */
|
|
69
|
+
raw(feature: string): Builder<TRow> {
|
|
70
|
+
return this.derive({ unsupported: [...this.unsupported, feature] });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
seek(after: SeekKey | null, limit: number): Builder<TRow> {
|
|
74
|
+
return this.derive({ after, rowLimit: limit, paged: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
shape(): QueryShape {
|
|
78
|
+
return {
|
|
79
|
+
entity: this.entity,
|
|
80
|
+
filters: this.filters,
|
|
81
|
+
orderBy: this.order,
|
|
82
|
+
limit: this.rowLimit,
|
|
83
|
+
unsupported: this.unsupported,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
toSQL(): SqlText {
|
|
88
|
+
const params: unknown[] = [];
|
|
89
|
+
const clauses = this.filters.map((filter) => {
|
|
90
|
+
if (filter.op === 'in' && Array.isArray(filter.value)) {
|
|
91
|
+
const slots = filter.value.map((item) => {
|
|
92
|
+
params.push(item);
|
|
93
|
+
return `$${params.length}`;
|
|
94
|
+
});
|
|
95
|
+
return `"${filter.column}" in (${slots.join(', ')})`;
|
|
96
|
+
}
|
|
97
|
+
params.push(filter.value);
|
|
98
|
+
return `"${filter.column}" ${filter.op} $${params.length}`;
|
|
99
|
+
});
|
|
100
|
+
if (this.after !== null) clauses.push(this.seekClause(this.after, params));
|
|
101
|
+
const where = clauses.length > 0 ? ` where ${clauses.join(' and ')}` : '';
|
|
102
|
+
const keys = this.pageOrder();
|
|
103
|
+
const order =
|
|
104
|
+
keys.length > 0
|
|
105
|
+
? ` order by ${keys.map((key) => `"${key.column}" ${key.direction}`).join(', ')}`
|
|
106
|
+
: '';
|
|
107
|
+
const limit = this.rowLimit === null ? '' : ` limit ${this.rowLimit}`;
|
|
108
|
+
return { sql: `select * from "${this.entity}"${where}${order}${limit}`, params };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async execute(): Promise<readonly TRow[]> {
|
|
112
|
+
const source = typeof this.rows === 'function' ? await this.rows() : this.rows;
|
|
113
|
+
let result = source.filter((row) => matchesFilters(row, this.filters));
|
|
114
|
+
const keys = this.pageOrder();
|
|
115
|
+
if (keys.length > 0) {
|
|
116
|
+
result = [...result].sort((a, b) => compareRows(a, b, keys));
|
|
117
|
+
}
|
|
118
|
+
if (this.after !== null) {
|
|
119
|
+
const cut = this.after;
|
|
120
|
+
result = result.filter((row) => isAfterKey(row, cut, this.order));
|
|
121
|
+
}
|
|
122
|
+
return this.rowLimit === null ? result : result.slice(0, this.rowLimit);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when the ordering already names `id`, so the tiebreak is neither added nor doubled. */
|
|
126
|
+
private get ordersById(): boolean {
|
|
127
|
+
return this.order.some((key) => key.column === 'id');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The ordering a page is actually served in: the declared keys, then `id` to make it total.
|
|
132
|
+
*
|
|
133
|
+
* Without the tiebreak the database is free to return two rows with the same sort value in
|
|
134
|
+
* either order, while `seekClause()` decides the next page as if they had been ordered by id —
|
|
135
|
+
* so one of the pair comes back twice and the other never does. `execute()` had the same split,
|
|
136
|
+
* sorting by the declared keys and then filtering with the id-aware predicate. One order, read
|
|
137
|
+
* by the SQL, the in-memory sort and the predicate alike, is what closes it.
|
|
138
|
+
*
|
|
139
|
+
* Only a paged read pays for it: an unpaginated `from()` over rows that have no `id` must keep
|
|
140
|
+
* generating exactly the SQL it was asked for.
|
|
141
|
+
*/
|
|
142
|
+
private pageOrder(): readonly OrderKey[] {
|
|
143
|
+
if (!this.paged || this.ordersById) return this.order;
|
|
144
|
+
return [...this.order, { column: 'id', direction: 'asc' }];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The keyset predicate, spelled out per key rather than as a row comparison.
|
|
149
|
+
* `(created_at, id) < ($1, $2)` requires every key to sort the same way, and a
|
|
150
|
+
* listing that is `createdAt desc, id asc` does not — the id-tiebreak-only
|
|
151
|
+
* fallback this replaced returned rows the ordering had already been past, so
|
|
152
|
+
* a mixed listing repeated and skipped rows while `execute()` did the right
|
|
153
|
+
* thing. Same shape as `@ultimat3/entity`'s `seekSql`: one meaning, two drivers.
|
|
154
|
+
*/
|
|
155
|
+
private seekClause(after: SeekKey, params: unknown[]): string {
|
|
156
|
+
const slot = (value: unknown): string => {
|
|
157
|
+
params.push(value);
|
|
158
|
+
return `$${params.length}`;
|
|
159
|
+
};
|
|
160
|
+
// The same `pageOrder()` the ORDER BY is built from, so the predicate can only ever describe
|
|
161
|
+
// the order the rows actually arrive in. The tiebreak is absent when the ordering already
|
|
162
|
+
// named `id`: a second `id` term compares the key to itself, can never be true, and is dead
|
|
163
|
+
// SQL an agent then has to reason about.
|
|
164
|
+
const keys = this.pageOrder();
|
|
165
|
+
const values = this.ordersById ? [...after.key] : [...after.key, after.id];
|
|
166
|
+
const terms = keys.map((key, index) => {
|
|
167
|
+
const equal = keys
|
|
168
|
+
.slice(0, index)
|
|
169
|
+
.map((earlier, position) => `"${earlier.column}" = ${slot(values[position])}`);
|
|
170
|
+
const compare = `"${key.column}" ${key.direction === 'desc' ? '<' : '>'} ${slot(values[index])}`;
|
|
171
|
+
return `(${[...equal, compare].join(' and ')})`;
|
|
172
|
+
});
|
|
173
|
+
return `(${terms.join(' or ')})`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private derive(patch: Partial<BuilderState>): Builder<TRow> {
|
|
177
|
+
return new Builder<TRow>(
|
|
178
|
+
this.entity,
|
|
179
|
+
this.rows,
|
|
180
|
+
patch.filters ?? this.filters,
|
|
181
|
+
patch.order ?? this.order,
|
|
182
|
+
patch.rowLimit === undefined ? this.rowLimit : patch.rowLimit,
|
|
183
|
+
patch.after === undefined ? this.after : patch.after,
|
|
184
|
+
patch.unsupported ?? this.unsupported,
|
|
185
|
+
patch.paged ?? this.paged,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface BuilderState {
|
|
191
|
+
readonly filters: readonly Filter[];
|
|
192
|
+
readonly order: readonly OrderKey[];
|
|
193
|
+
readonly rowLimit: number | null;
|
|
194
|
+
readonly after: SeekKey | null;
|
|
195
|
+
readonly unsupported: readonly string[];
|
|
196
|
+
readonly paged: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Keyset comparison: strictly after the cursor's sort key, id breaking ties. Exported because
|
|
201
|
+
* pagination needs the same answer when a source cannot push the seek down — a second definition
|
|
202
|
+
* of "after" is how one path skips a row the other returns.
|
|
203
|
+
*/
|
|
204
|
+
export function isAfterKey(row: object, cursor: SeekKey, order: readonly OrderKey[]): boolean {
|
|
205
|
+
for (const [index, key] of order.entries()) {
|
|
206
|
+
const result = compareValues(columnOf(row, key.column), cursor.key[index]);
|
|
207
|
+
const signed = key.direction === 'asc' ? result : -result;
|
|
208
|
+
if (signed !== 0) return signed > 0;
|
|
209
|
+
}
|
|
210
|
+
return compareValues(columnOf(row, 'id'), cursor.id) > 0;
|
|
211
|
+
}
|
package/src/sql.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL transparency. An agent that can read the SQL a query generates can fix a
|
|
3
|
+
* slow or wrong read by itself; a query builder that hides its output forces it
|
|
4
|
+
* to guess. `explain` is the read path for `/_x` and for `x db explain`.
|
|
5
|
+
*/
|
|
6
|
+
import type { Ctx } from '@ultimat3/core';
|
|
7
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { AnyQuery, Query } from './query';
|
|
9
|
+
import { queryName, sourceFor } from './query';
|
|
10
|
+
import { listQueries } from './registry';
|
|
11
|
+
import type { QueryShape } from './shape';
|
|
12
|
+
import type { SqlText } from './source';
|
|
13
|
+
|
|
14
|
+
export interface ExplainResult extends SqlText {
|
|
15
|
+
readonly query: string;
|
|
16
|
+
readonly shape: QueryShape;
|
|
17
|
+
readonly live: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Policy is deliberately NOT enforced here: `explain` never returns rows, and the
|
|
22
|
+
* surfaces that expose it (`/_x`, the CLI) are admin-gated in their own right.
|
|
23
|
+
*/
|
|
24
|
+
export async function explain<TInput extends StandardSchemaV1, TRow extends object>(
|
|
25
|
+
target: Query<TInput, TRow>,
|
|
26
|
+
input: unknown,
|
|
27
|
+
ctx?: Ctx,
|
|
28
|
+
): Promise<ExplainResult> {
|
|
29
|
+
const source = await sourceFor(target, input, {
|
|
30
|
+
enforce: false,
|
|
31
|
+
...(ctx === undefined ? {} : { ctx }),
|
|
32
|
+
});
|
|
33
|
+
const text = source.toSQL();
|
|
34
|
+
return {
|
|
35
|
+
query: queryName(target),
|
|
36
|
+
sql: text.sql,
|
|
37
|
+
params: text.params,
|
|
38
|
+
shape: source.shape(),
|
|
39
|
+
live: target.isLive,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface QuerySqlInfo {
|
|
44
|
+
readonly query: string;
|
|
45
|
+
readonly live: boolean;
|
|
46
|
+
/** `null` when no sample input was supplied — SQL depends on arguments. */
|
|
47
|
+
readonly sql: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Dashboard listing. Pass sample inputs keyed by query name to get real SQL;
|
|
52
|
+
* without one, the entry is listed with `sql: null` rather than a guess.
|
|
53
|
+
*/
|
|
54
|
+
export async function describeSql(
|
|
55
|
+
samples: Readonly<Record<string, unknown>> = {},
|
|
56
|
+
queries: readonly AnyQuery[] = listQueries(),
|
|
57
|
+
ctx?: Ctx,
|
|
58
|
+
): Promise<readonly QuerySqlInfo[]> {
|
|
59
|
+
const entries: QuerySqlInfo[] = [];
|
|
60
|
+
for (const target of queries) {
|
|
61
|
+
const name = queryName(target);
|
|
62
|
+
const sample = samples[name];
|
|
63
|
+
if (sample === undefined) {
|
|
64
|
+
entries.push({ query: name, live: target.isLive, sql: null });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const source = await sourceFor(target, sample, {
|
|
68
|
+
enforce: false,
|
|
69
|
+
...(ctx === undefined ? {} : { ctx }),
|
|
70
|
+
});
|
|
71
|
+
entries.push({ query: name, live: target.isLive, sql: source.toSQL().sql });
|
|
72
|
+
}
|
|
73
|
+
return entries;
|
|
74
|
+
}
|
package/src/stable.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic JSON plus a content hash. Query hashes, cursor payloads and
|
|
3
|
+
* cache keys all need byte-stable serialization, so nothing here may depend on
|
|
4
|
+
* key insertion order.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function stableStringify(value: unknown): string {
|
|
12
|
+
if (value === null) return 'null';
|
|
13
|
+
switch (typeof value) {
|
|
14
|
+
case 'string':
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
case 'number':
|
|
17
|
+
return Number.isFinite(value) ? String(value) : 'null';
|
|
18
|
+
case 'boolean':
|
|
19
|
+
return String(value);
|
|
20
|
+
case 'bigint':
|
|
21
|
+
return JSON.stringify(`${value}n`);
|
|
22
|
+
case 'undefined':
|
|
23
|
+
case 'function':
|
|
24
|
+
case 'symbol':
|
|
25
|
+
return 'null';
|
|
26
|
+
default:
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
30
|
+
const record = value as Record<string, unknown>;
|
|
31
|
+
const keys = Object.keys(record)
|
|
32
|
+
.filter((key) => record[key] !== undefined)
|
|
33
|
+
.sort();
|
|
34
|
+
const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
35
|
+
return `{${entries.join(',')}}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** FNV-1a/32 as hex. Identity of a query shape, never a security boundary. */
|
|
39
|
+
export function fnv1a(input: string): string {
|
|
40
|
+
let hash = 0x811c9dc5;
|
|
41
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
42
|
+
hash ^= input.charCodeAt(i);
|
|
43
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
44
|
+
}
|
|
45
|
+
return hash.toString(16).padStart(8, '0');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function fingerprint(value: unknown): string {
|
|
49
|
+
return fnv1a(stableStringify(value));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Column read that works for interfaces without an index signature. */
|
|
53
|
+
export function columnOf(row: object, column: string): unknown {
|
|
54
|
+
const record: unknown = row;
|
|
55
|
+
return isJsonObject(record) ? record[column] : undefined;
|
|
56
|
+
}
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache tags stay opaque here — they belong to @ultimat3/cache. This package only
|
|
3
|
+
* needs the wire string per tag so a read's key can be found by the invalidation
|
|
4
|
+
* graph an action's `invalidates` drives.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CacheTag } from '@ultimat3/cache';
|
|
8
|
+
import { serializeTag } from '@ultimat3/cache';
|
|
9
|
+
|
|
10
|
+
export function tagKey(value: CacheTag): string {
|
|
11
|
+
return serializeTag(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Sorted + de-duplicated: descriptor output must not depend on declaration order. */
|
|
15
|
+
export function tagKeys(tags: readonly CacheTag[]): readonly string[] {
|
|
16
|
+
return [...new Set(tags.map(tagKey))].sort();
|
|
17
|
+
}
|