@metaobjectsdev/runtime-ts 0.5.0-rc.1
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 +189 -0
- package/README.md +102 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/drivers/drizzle-driver.d.ts +25 -0
- package/dist/drivers/drizzle-driver.d.ts.map +1 -0
- package/dist/drivers/drizzle-driver.js +405 -0
- package/dist/drivers/drizzle-driver.js.map +1 -0
- package/dist/drivers/in-memory-driver.d.ts +11 -0
- package/dist/drivers/in-memory-driver.d.ts.map +1 -0
- package/dist/drivers/in-memory-driver.js +232 -0
- package/dist/drivers/in-memory-driver.js.map +1 -0
- package/dist/drivers/index.d.ts +4 -0
- package/dist/drivers/index.d.ts.map +1 -0
- package/dist/drivers/index.js +4 -0
- package/dist/drivers/index.js.map +1 -0
- package/dist/drivers/kysely-driver.d.ts +12 -0
- package/dist/drivers/kysely-driver.d.ts.map +1 -0
- package/dist/drivers/kysely-driver.js +203 -0
- package/dist/drivers/kysely-driver.js.map +1 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts +19 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -0
- package/dist/drizzle-fastify/filter-allowlist.js +10 -0
- package/dist/drizzle-fastify/filter-allowlist.js.map +1 -0
- package/dist/drizzle-fastify/filter-parser.d.ts +28 -0
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -0
- package/dist/drizzle-fastify/filter-parser.js +185 -0
- package/dist/drizzle-fastify/filter-parser.js.map +1 -0
- package/dist/drizzle-fastify/index.d.ts +48 -0
- package/dist/drizzle-fastify/index.d.ts.map +1 -0
- package/dist/drizzle-fastify/index.js +181 -0
- package/dist/drizzle-fastify/index.js.map +1 -0
- package/dist/drizzle-fastify/mount-read-only.d.ts +17 -0
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -0
- package/dist/drizzle-fastify/mount-read-only.js +159 -0
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -0
- package/dist/drizzle-fastify/util.d.ts +5 -0
- package/dist/drizzle-fastify/util.d.ts.map +1 -0
- package/dist/drizzle-fastify/util.js +12 -0
- package/dist/drizzle-fastify/util.js.map +1 -0
- package/dist/errors.d.ts +68 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +86 -0
- package/dist/errors.js.map +1 -0
- package/dist/fastify/index.d.ts +65 -0
- package/dist/fastify/index.d.ts.map +1 -0
- package/dist/fastify/index.js +118 -0
- package/dist/fastify/index.js.map +1 -0
- package/dist/identity-strategy.d.ts +10 -0
- package/dist/identity-strategy.d.ts.map +1 -0
- package/dist/identity-strategy.js +67 -0
- package/dist/identity-strategy.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/n2m-resolver.d.ts +27 -0
- package/dist/n2m-resolver.d.ts.map +1 -0
- package/dist/n2m-resolver.js +103 -0
- package/dist/n2m-resolver.js.map +1 -0
- package/dist/object-manager.d.ts +51 -0
- package/dist/object-manager.d.ts.map +1 -0
- package/dist/object-manager.js +355 -0
- package/dist/object-manager.js.map +1 -0
- package/dist/persistence-driver.d.ts +88 -0
- package/dist/persistence-driver.d.ts.map +1 -0
- package/dist/persistence-driver.js +5 -0
- package/dist/persistence-driver.js.map +1 -0
- package/dist/query-builder.d.ts +35 -0
- package/dist/query-builder.d.ts.map +1 -0
- package/dist/query-builder.js +178 -0
- package/dist/query-builder.js.map +1 -0
- package/dist/ref-codec.d.ts +8 -0
- package/dist/ref-codec.d.ts.map +1 -0
- package/dist/ref-codec.js +42 -0
- package/dist/ref-codec.js.map +1 -0
- package/dist/relation-resolver.d.ts +27 -0
- package/dist/relation-resolver.d.ts.map +1 -0
- package/dist/relation-resolver.js +136 -0
- package/dist/relation-resolver.js.map +1 -0
- package/dist/type-coercer.d.ts +5 -0
- package/dist/type-coercer.d.ts.map +1 -0
- package/dist/type-coercer.js +43 -0
- package/dist/type-coercer.js.map +1 -0
- package/dist/validator-runner.d.ts +14 -0
- package/dist/validator-runner.d.ts.map +1 -0
- package/dist/validator-runner.js +155 -0
- package/dist/validator-runner.js.map +1 -0
- package/dist/view.d.ts +19 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +60 -0
- package/dist/view.js.map +1 -0
- package/package.json +87 -0
- package/src/constants.ts +7 -0
- package/src/drivers/drizzle-driver.ts +474 -0
- package/src/drivers/in-memory-driver.ts +256 -0
- package/src/drivers/index.ts +3 -0
- package/src/drivers/kysely-driver.ts +240 -0
- package/src/drizzle-fastify/filter-allowlist.ts +31 -0
- package/src/drizzle-fastify/filter-parser.ts +229 -0
- package/src/drizzle-fastify/index.ts +225 -0
- package/src/drizzle-fastify/mount-read-only.ts +187 -0
- package/src/drizzle-fastify/util.ts +10 -0
- package/src/errors.ts +114 -0
- package/src/fastify/index.ts +181 -0
- package/src/identity-strategy.ts +92 -0
- package/src/index.ts +24 -0
- package/src/n2m-resolver.ts +152 -0
- package/src/object-manager.ts +444 -0
- package/src/persistence-driver.ts +92 -0
- package/src/query-builder.ts +224 -0
- package/src/ref-codec.ts +64 -0
- package/src/relation-resolver.ts +171 -0
- package/src/type-coercer.ts +39 -0
- package/src/validator-runner.ts +168 -0
- package/src/view.ts +82 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {
|
|
2
|
+
eq, ne, gt, gte, lt, lte, inArray, like, ilike, isNull, not, and, or, asc, desc,
|
|
3
|
+
type SQL, type SQLWrapper,
|
|
4
|
+
} from "drizzle-orm";
|
|
5
|
+
import type { FilterAllowlist, FilterOp, FilterFieldRule, SortAllowlist } from "./filter-allowlist.js";
|
|
6
|
+
|
|
7
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
|
|
8
|
+
type AnyTable = any;
|
|
9
|
+
|
|
10
|
+
export interface ParseFilterOpts {
|
|
11
|
+
query: Record<string, unknown>;
|
|
12
|
+
table: AnyTable;
|
|
13
|
+
allowlist: FilterAllowlist;
|
|
14
|
+
sortAllowlist: SortAllowlist;
|
|
15
|
+
dialect: "sqlite" | "postgres";
|
|
16
|
+
maxNesting?: number;
|
|
17
|
+
maxInListSize?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ParseFilterResult {
|
|
21
|
+
where?: SQL;
|
|
22
|
+
orderBy?: SQLWrapper[];
|
|
23
|
+
limit?: number;
|
|
24
|
+
offset?: number;
|
|
25
|
+
/** Search predicate — OR(like('%term%')) across @filterable string fields. */
|
|
26
|
+
searchWhere?: SQL;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class FilterParseError extends Error {
|
|
30
|
+
constructor(public readonly code: string, message: string, public readonly details?: Record<string, unknown>) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "FilterParseError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MAX_NESTING = 5;
|
|
37
|
+
const DEFAULT_MAX_IN_LIST = 100;
|
|
38
|
+
|
|
39
|
+
export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult {
|
|
40
|
+
const result: ParseFilterResult = {};
|
|
41
|
+
|
|
42
|
+
const limit = opts.query.limit;
|
|
43
|
+
if (limit !== undefined) {
|
|
44
|
+
const n = Number(limit);
|
|
45
|
+
if (Number.isFinite(n)) result.limit = n;
|
|
46
|
+
}
|
|
47
|
+
const offset = opts.query.offset;
|
|
48
|
+
if (offset !== undefined) {
|
|
49
|
+
const n = Number(offset);
|
|
50
|
+
if (Number.isFinite(n)) result.offset = n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opts.query.filter && typeof opts.query.filter === "object") {
|
|
54
|
+
const where = parseNode(
|
|
55
|
+
opts.query.filter as Record<string, unknown>,
|
|
56
|
+
opts.table, opts.allowlist, opts.dialect,
|
|
57
|
+
opts.maxNesting ?? DEFAULT_MAX_NESTING,
|
|
58
|
+
opts.maxInListSize ?? DEFAULT_MAX_IN_LIST,
|
|
59
|
+
0,
|
|
60
|
+
);
|
|
61
|
+
if (where) result.where = where;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof opts.query.sort === "string") {
|
|
65
|
+
result.orderBy = parseSort(opts.query.sort, opts.table, opts.sortAllowlist);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof opts.query.search === "string" && opts.query.search !== "") {
|
|
69
|
+
const term = `%${opts.query.search}%`;
|
|
70
|
+
const stringCols = Object.entries(opts.allowlist)
|
|
71
|
+
.filter(([, rule]) => (rule as FilterFieldRule).subType === "string")
|
|
72
|
+
.map(([field]) => opts.table[field]);
|
|
73
|
+
if (stringCols.length > 0) {
|
|
74
|
+
// Case-insensitive on Postgres (ilike), case-sensitive on SQLite (like) —
|
|
75
|
+
// matches the dispatch the existing [like] op uses (parseNode, below).
|
|
76
|
+
const matcher = opts.dialect === "postgres" ? ilike : like;
|
|
77
|
+
const parts = stringCols.map((col) => matcher(col, term));
|
|
78
|
+
// or() is defined as returning SQL | undefined but will always return
|
|
79
|
+
// SQL when given a non-empty array. parts[0] is always defined here
|
|
80
|
+
// because stringCols.length > 0 guarantees at least one element.
|
|
81
|
+
// biome-ignore lint/style/noNonNullAssertion: parts is guaranteed non-empty
|
|
82
|
+
result.searchWhere = (parts.length === 1 ? parts[0] : or(...parts)) as SQL;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseNode(
|
|
90
|
+
node: Record<string, unknown>,
|
|
91
|
+
table: AnyTable,
|
|
92
|
+
allowlist: FilterAllowlist,
|
|
93
|
+
dialect: "sqlite" | "postgres",
|
|
94
|
+
maxNesting: number,
|
|
95
|
+
maxInList: number,
|
|
96
|
+
depth: number,
|
|
97
|
+
): SQL | undefined {
|
|
98
|
+
if (depth > maxNesting) {
|
|
99
|
+
throw new FilterParseError("filter.nesting_too_deep", `Filter nesting depth exceeds limit (${maxNesting}).`, { limit: maxNesting });
|
|
100
|
+
}
|
|
101
|
+
const parts: SQL[] = [];
|
|
102
|
+
for (const [key, value] of Object.entries(node)) {
|
|
103
|
+
if (key === "or") {
|
|
104
|
+
const subs = ensureArray(value, "or").map((sub) =>
|
|
105
|
+
parseNode(sub as Record<string, unknown>, table, allowlist, dialect, maxNesting, maxInList, depth + 1)
|
|
106
|
+
).filter((s): s is SQL => !!s);
|
|
107
|
+
if (subs.length > 0) parts.push(or(...subs)!);
|
|
108
|
+
} else if (key === "and") {
|
|
109
|
+
const subs = ensureArray(value, "and").map((sub) =>
|
|
110
|
+
parseNode(sub as Record<string, unknown>, table, allowlist, dialect, maxNesting, maxInList, depth + 1)
|
|
111
|
+
).filter((s): s is SQL => !!s);
|
|
112
|
+
if (subs.length > 0) parts.push(and(...subs)!);
|
|
113
|
+
} else {
|
|
114
|
+
// key is a field name
|
|
115
|
+
const rule = allowlist[key];
|
|
116
|
+
if (!rule) {
|
|
117
|
+
throw new FilterParseError("filter.unknown_field", `Unknown filter field "${key}".`, { field: key, allowed: Object.keys(allowlist) });
|
|
118
|
+
}
|
|
119
|
+
const col = table[key];
|
|
120
|
+
if (col === undefined) {
|
|
121
|
+
throw new FilterParseError("filter.unknown_field", `Table has no column for field "${key}".`, { field: key });
|
|
122
|
+
}
|
|
123
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
124
|
+
for (const [opKey, opValue] of Object.entries(value)) {
|
|
125
|
+
const expr = compileOp(col, rule, key, opKey, opValue, dialect, maxInList);
|
|
126
|
+
if (expr) parts.push(expr);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
// Bare value = eq sugar
|
|
130
|
+
const expr = compileOp(col, rule, key, "eq", value, dialect, maxInList);
|
|
131
|
+
if (expr) parts.push(expr);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (parts.length === 0) return undefined;
|
|
136
|
+
if (parts.length === 1) return parts[0];
|
|
137
|
+
return and(...parts);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compileOp(
|
|
141
|
+
col: unknown,
|
|
142
|
+
rule: FilterFieldRule,
|
|
143
|
+
field: string,
|
|
144
|
+
op: string,
|
|
145
|
+
value: unknown,
|
|
146
|
+
dialect: "sqlite" | "postgres",
|
|
147
|
+
maxInList: number,
|
|
148
|
+
): SQL | undefined {
|
|
149
|
+
if (!rule.ops.includes(op as FilterOp)) {
|
|
150
|
+
throw new FilterParseError("filter.unsupported_op", `Op "${op}" not supported for field "${field}".`, { field, op, allowed: rule.ops });
|
|
151
|
+
}
|
|
152
|
+
switch (op as FilterOp) {
|
|
153
|
+
case "eq": return eq(col as any, coerce(value, rule.subType, field, op));
|
|
154
|
+
case "ne": return ne(col as any, coerce(value, rule.subType, field, op));
|
|
155
|
+
case "gt": return gt(col as any, coerce(value, rule.subType, field, op));
|
|
156
|
+
case "gte": return gte(col as any, coerce(value, rule.subType, field, op));
|
|
157
|
+
case "lt": return lt(col as any, coerce(value, rule.subType, field, op));
|
|
158
|
+
case "lte": return lte(col as any, coerce(value, rule.subType, field, op));
|
|
159
|
+
case "in": {
|
|
160
|
+
const list = String(value).split(",").map((v) => coerce(v.trim(), rule.subType, field, op));
|
|
161
|
+
if (list.length > maxInList) {
|
|
162
|
+
throw new FilterParseError("filter.in_too_large", `In-list size ${list.length} exceeds limit ${maxInList}.`, { field, limit: maxInList });
|
|
163
|
+
}
|
|
164
|
+
return inArray(col as any, list);
|
|
165
|
+
}
|
|
166
|
+
case "like": {
|
|
167
|
+
const s = String(value);
|
|
168
|
+
if (s.startsWith("%") && !rule.leadingWildcard) {
|
|
169
|
+
throw new FilterParseError("filter.leading_wildcard_disallowed", `Leading wildcard not allowed for field "${field}".`, { field });
|
|
170
|
+
}
|
|
171
|
+
return dialect === "postgres" ? ilike(col as any, s) : like(col as any, s);
|
|
172
|
+
}
|
|
173
|
+
case "isNull": {
|
|
174
|
+
// isNull's value is always coerced as boolean (true/false), regardless of the
|
|
175
|
+
// field's declared subType — the operator is "is the value null?", not "is X
|
|
176
|
+
// equal to null?". Field subtype is irrelevant.
|
|
177
|
+
const b = coerce(value, "boolean", field, op) as boolean;
|
|
178
|
+
return b ? isNull(col as any) : not(isNull(col as any));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function coerce(value: unknown, subType: string, field: string, op: string): unknown {
|
|
184
|
+
if (value === null || value === undefined) return null;
|
|
185
|
+
const s = typeof value === "string" ? value : String(value);
|
|
186
|
+
switch (subType) {
|
|
187
|
+
case "string": return s;
|
|
188
|
+
case "boolean": {
|
|
189
|
+
if (s === "true" || s === "1") return true;
|
|
190
|
+
if (s === "false" || s === "0") return false;
|
|
191
|
+
throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires boolean, got "${s}".`, { field, op, expected: "boolean" });
|
|
192
|
+
}
|
|
193
|
+
case "number": {
|
|
194
|
+
const n = Number(s);
|
|
195
|
+
if (!Number.isFinite(n)) {
|
|
196
|
+
throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires number, got "${s}".`, { field, op, expected: "number" });
|
|
197
|
+
}
|
|
198
|
+
return n;
|
|
199
|
+
}
|
|
200
|
+
case "datetime": return s;
|
|
201
|
+
default: return s;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ensureArray(v: unknown, key: string): unknown[] {
|
|
206
|
+
if (Array.isArray(v)) return v;
|
|
207
|
+
if (v && typeof v === "object") {
|
|
208
|
+
// qs.parse may produce { "0": ..., "1": ... } shape for filter[or][0]...
|
|
209
|
+
return Object.values(v);
|
|
210
|
+
}
|
|
211
|
+
// Defensive — qs.parse normally produces an object/array; this branch is
|
|
212
|
+
// only reachable if a caller hand-constructs a malformed query object.
|
|
213
|
+
throw new FilterParseError("filter.invalid_value", `Expected array for "${key}".`, { key });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist): SQLWrapper[] {
|
|
217
|
+
const colonIdx = spec.indexOf(":");
|
|
218
|
+
const field = colonIdx === -1 ? spec : spec.slice(0, colonIdx);
|
|
219
|
+
const orderRaw = colonIdx === -1 ? "asc" : spec.slice(colonIdx + 1);
|
|
220
|
+
const order = orderRaw.toLowerCase();
|
|
221
|
+
if (!sortAllowlist[field]) {
|
|
222
|
+
throw new FilterParseError("sort.unknown_field", `Unknown sort field "${field}".`, { field, allowed: Object.keys(sortAllowlist) });
|
|
223
|
+
}
|
|
224
|
+
if (order !== "asc" && order !== "desc") {
|
|
225
|
+
throw new FilterParseError("sort.invalid_order", `Sort order must be asc|desc, got "${orderRaw}".`, { expected: "asc | desc" });
|
|
226
|
+
}
|
|
227
|
+
const col = table[field];
|
|
228
|
+
return [order === "asc" ? asc(col as any) : desc(col as any)];
|
|
229
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Drizzle-direct Fastify adapter — replaces the ObjectManager-flavored
|
|
2
|
+
// mountCrudRoutes for consumers that already use Drizzle directly.
|
|
3
|
+
//
|
|
4
|
+
// Why: most TS apps stay on Drizzle for SQL queries. Routing CRUD through
|
|
5
|
+
// ObjectManager + a Drizzle driver was indirection for no real win — generated
|
|
6
|
+
// routes can just call Drizzle directly and stay self-evident as Drizzle code.
|
|
7
|
+
//
|
|
8
|
+
// What this gives you: one helper, same surface as before, but the runtime
|
|
9
|
+
// dependency is Drizzle + Zod, not ObjectManager.
|
|
10
|
+
//
|
|
11
|
+
// GET {path} list with ?limit / ?offset
|
|
12
|
+
// GET {path}/:id findById, 404 if missing
|
|
13
|
+
// POST {path} create, 400 on Zod validation error
|
|
14
|
+
// PATCH {path}/:id update, 400 on validation, 404 if missing
|
|
15
|
+
// DELETE {path}/:id delete, 204 on success, 404 if missing
|
|
16
|
+
|
|
17
|
+
import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
18
|
+
import type { ZodTypeAny } from "zod";
|
|
19
|
+
import { eq, count, and } from "drizzle-orm";
|
|
20
|
+
import qs from "qs";
|
|
21
|
+
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
22
|
+
export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
23
|
+
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
24
|
+
import { isTruthyFlag } from "./util.js";
|
|
25
|
+
export { isTruthyFlag } from "./util.js";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Loose types — we don't bind to a specific Drizzle backend so the helper
|
|
29
|
+
// works across libsql / better-sqlite3 / pg / etc.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
|
|
33
|
+
type AnyDrizzle = any;
|
|
34
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
|
|
35
|
+
type AnyTable = any;
|
|
36
|
+
|
|
37
|
+
export type CrudVerb = "list" | "get" | "create" | "update" | "delete";
|
|
38
|
+
|
|
39
|
+
export interface CrudRoutesOptions {
|
|
40
|
+
fastify: FastifyInstance;
|
|
41
|
+
/** REST resource path, e.g. "/subscribers". */
|
|
42
|
+
path: string;
|
|
43
|
+
/** User's Drizzle instance. */
|
|
44
|
+
db: AnyDrizzle;
|
|
45
|
+
/** Drizzle table const. The helper requires this to have an `id` column. */
|
|
46
|
+
table: AnyTable;
|
|
47
|
+
/** Zod schema for create payloads (typically `<Entity>InsertSchema`). */
|
|
48
|
+
insertSchema: ZodTypeAny;
|
|
49
|
+
/** Zod schema for update payloads (typically `<Entity>UpdateSchema`). */
|
|
50
|
+
updateSchema: ZodTypeAny;
|
|
51
|
+
/** Limit which verbs are mounted. Defaults to all five. */
|
|
52
|
+
expose?: readonly CrudVerb[];
|
|
53
|
+
/**
|
|
54
|
+
* Fastify route-level hooks applied to every mounted verb (preHandler,
|
|
55
|
+
* onRequest, schema validation, etc.). Most common use is auth:
|
|
56
|
+
* routeOptions: { preHandler: requireAuthHook }
|
|
57
|
+
*/
|
|
58
|
+
routeOptions?: RouteShorthandOptions;
|
|
59
|
+
/**
|
|
60
|
+
* HTTP method for the update verb. Defaults to "patch". Set to "put" to
|
|
61
|
+
* preserve a legacy API contract that already uses PUT for updates.
|
|
62
|
+
*/
|
|
63
|
+
updateMethod?: "patch" | "put";
|
|
64
|
+
filterAllowlist?: FilterAllowlist;
|
|
65
|
+
sortAllowlist?: SortAllowlist;
|
|
66
|
+
/** Dialect — required if filterAllowlist or sortAllowlist is set (for like/ilike dispatch). */
|
|
67
|
+
dialect?: "sqlite" | "postgres";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ALL_VERBS: readonly CrudVerb[] = ["list", "get", "create", "update", "delete"];
|
|
71
|
+
|
|
72
|
+
export function mountCrudRoutes(opts: CrudRoutesOptions): void {
|
|
73
|
+
const verbs = new Set<CrudVerb>(opts.expose ?? ALL_VERBS);
|
|
74
|
+
if (verbs.has("list")) mountListRoute(opts);
|
|
75
|
+
if (verbs.has("get")) mountGetRoute(opts);
|
|
76
|
+
if (verbs.has("create")) mountCreateRoute(opts);
|
|
77
|
+
if (verbs.has("update")) mountUpdateRoute(opts);
|
|
78
|
+
if (verbs.has("delete")) mountDeleteRoute(opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type VerbOptions = Omit<CrudRoutesOptions, "expose">;
|
|
82
|
+
|
|
83
|
+
function routeOpts(opts: VerbOptions): RouteShorthandOptions {
|
|
84
|
+
return opts.routeOptions ?? {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function mountListRoute(opts: VerbOptions): void {
|
|
88
|
+
opts.fastify.get(opts.path, routeOpts(opts), async (req, reply) => {
|
|
89
|
+
try {
|
|
90
|
+
let q = opts.db.select().from(opts.table).$dynamic();
|
|
91
|
+
// Re-parse the raw URL with qs so bracketed filter notation and the
|
|
92
|
+
// top-level withCount flag are available.
|
|
93
|
+
const rawSearch = req.raw.url?.includes("?")
|
|
94
|
+
? req.raw.url.slice(req.raw.url.indexOf("?") + 1)
|
|
95
|
+
: "";
|
|
96
|
+
const qsParsed = qs.parse(rawSearch) as Record<string, unknown>;
|
|
97
|
+
const withCount = isTruthyFlag(qsParsed.withCount);
|
|
98
|
+
|
|
99
|
+
let where: ReturnType<typeof parseFilterParams>["where"];
|
|
100
|
+
if (opts.filterAllowlist && opts.sortAllowlist) {
|
|
101
|
+
const parsed = parseFilterParams({
|
|
102
|
+
query: qsParsed,
|
|
103
|
+
table: opts.table,
|
|
104
|
+
allowlist: opts.filterAllowlist,
|
|
105
|
+
sortAllowlist: opts.sortAllowlist,
|
|
106
|
+
dialect: opts.dialect ?? "sqlite",
|
|
107
|
+
});
|
|
108
|
+
const combinedWhere = parsed.where && parsed.searchWhere
|
|
109
|
+
? and(parsed.where, parsed.searchWhere)
|
|
110
|
+
: (parsed.where ?? parsed.searchWhere);
|
|
111
|
+
if (combinedWhere) { q = q.where(combinedWhere); where = combinedWhere; }
|
|
112
|
+
if (parsed.orderBy) q = q.orderBy(...parsed.orderBy);
|
|
113
|
+
if (parsed.limit !== undefined) q = q.limit(parsed.limit);
|
|
114
|
+
if (parsed.offset !== undefined) q = q.offset(parsed.offset);
|
|
115
|
+
} else {
|
|
116
|
+
// Legacy path — no allowlists configured. Only limit/offset.
|
|
117
|
+
const { limit, offset } = req.query as { limit?: string; offset?: string };
|
|
118
|
+
if (limit !== undefined) q = q.limit(Number(limit));
|
|
119
|
+
if (offset !== undefined) q = q.offset(Number(offset));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const rows = await q.all();
|
|
123
|
+
|
|
124
|
+
if (!withCount) return rows;
|
|
125
|
+
|
|
126
|
+
// Count query: same WHERE, no limit/offset/orderBy.
|
|
127
|
+
let cq = opts.db.select({ c: count() }).from(opts.table).$dynamic();
|
|
128
|
+
if (where) cq = cq.where(where);
|
|
129
|
+
const countRow = (await cq.all())[0] as { c: number } | undefined;
|
|
130
|
+
const total = countRow?.c ?? 0;
|
|
131
|
+
return { rows, total };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err instanceof FilterParseError) {
|
|
134
|
+
return reply.code(400).send({ error: err.code, ...(err.details ?? {}) });
|
|
135
|
+
}
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function mountGetRoute(opts: VerbOptions): void {
|
|
142
|
+
opts.fastify.get(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
|
|
143
|
+
const { id } = req.params as { id: string };
|
|
144
|
+
const row = await opts.db
|
|
145
|
+
.select()
|
|
146
|
+
.from(opts.table)
|
|
147
|
+
.where(eq(opts.table.id, parseId(id)))
|
|
148
|
+
.get();
|
|
149
|
+
return row ?? reply.code(404).send({ error: "not_found" });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function mountCreateRoute(opts: VerbOptions): void {
|
|
154
|
+
opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => {
|
|
155
|
+
const parsed = opts.insertSchema.safeParse(req.body);
|
|
156
|
+
if (!parsed.success) {
|
|
157
|
+
return reply.code(400).send({ error: "validation", issues: parsed.error.issues });
|
|
158
|
+
}
|
|
159
|
+
const result = await opts.db.insert(opts.table).values(parsed.data).returning();
|
|
160
|
+
const row = (result as unknown[])[0];
|
|
161
|
+
return reply.code(201).send(row);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function mountUpdateRoute(opts: VerbOptions): void {
|
|
166
|
+
const handler = async (
|
|
167
|
+
req: { params: unknown; body: unknown },
|
|
168
|
+
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
|
|
169
|
+
) => {
|
|
170
|
+
const { id } = req.params as { id: string };
|
|
171
|
+
const parsed = opts.updateSchema.safeParse(req.body);
|
|
172
|
+
if (!parsed.success) {
|
|
173
|
+
return reply.code(400).send({ error: "validation", issues: parsed.error.issues });
|
|
174
|
+
}
|
|
175
|
+
const result = await opts.db
|
|
176
|
+
.update(opts.table)
|
|
177
|
+
.set(parsed.data)
|
|
178
|
+
.where(eq(opts.table.id, parseId(id)))
|
|
179
|
+
.returning();
|
|
180
|
+
const row = (result as unknown[])[0];
|
|
181
|
+
return row ?? reply.code(404).send({ error: "not_found" });
|
|
182
|
+
};
|
|
183
|
+
const path = `${opts.path}/:id`;
|
|
184
|
+
const ro = routeOpts(opts);
|
|
185
|
+
// biome-ignore lint/suspicious/noExplicitAny: handler signature is generic
|
|
186
|
+
if (opts.updateMethod === "put") {
|
|
187
|
+
opts.fastify.put(path, ro, handler as any);
|
|
188
|
+
} else {
|
|
189
|
+
opts.fastify.patch(path, ro, handler as any);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function mountDeleteRoute(opts: VerbOptions): void {
|
|
194
|
+
opts.fastify.delete(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
|
|
195
|
+
const { id } = req.params as { id: string };
|
|
196
|
+
const result = await opts.db
|
|
197
|
+
.delete(opts.table)
|
|
198
|
+
.where(eq(opts.table.id, parseId(id)));
|
|
199
|
+
// Both libsql and pg drivers expose a rows-affected counter, in different
|
|
200
|
+
// shapes. Treat anything > 0 as "found and deleted."
|
|
201
|
+
const affected = extractRowCount(result);
|
|
202
|
+
return affected > 0
|
|
203
|
+
? reply.code(204).send()
|
|
204
|
+
: reply.code(404).send({ error: "not_found" });
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function extractRowCount(result: unknown): number {
|
|
209
|
+
if (typeof result === "number") return result;
|
|
210
|
+
if (Array.isArray(result)) return result.length;
|
|
211
|
+
if (result && typeof result === "object") {
|
|
212
|
+
const obj = result as { rowsAffected?: number | bigint; rowCount?: number };
|
|
213
|
+
if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
|
|
214
|
+
if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
|
|
215
|
+
if (typeof obj.rowCount === "number") return obj.rowCount;
|
|
216
|
+
}
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function parseId(raw: string): number | string {
|
|
221
|
+
const n = Number(raw);
|
|
222
|
+
return Number.isFinite(n) && raw.trim() !== "" ? n : raw;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import { sql, eq, and, count } from "drizzle-orm";
|
|
3
|
+
import qs from "qs";
|
|
4
|
+
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
5
|
+
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
6
|
+
import { isTruthyFlag } from "./util.js";
|
|
7
|
+
|
|
8
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
|
|
9
|
+
type AnyView = any;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Drizzle v0.45 stores view config under this well-known Symbol.
|
|
13
|
+
* Accessing `view._` on a proxy-wrapped view (empty-column .existing()) throws
|
|
14
|
+
* because the proxy tries to spread `subquery._.selectedFields` which is undefined.
|
|
15
|
+
* Using the symbol bypasses the proxy entirely.
|
|
16
|
+
*/
|
|
17
|
+
const VIEW_BASE_CONFIG = Symbol.for("drizzle:ViewBaseConfig");
|
|
18
|
+
|
|
19
|
+
export interface MountReadOnlyOptions {
|
|
20
|
+
readonly fastify: FastifyInstance;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic Drizzle client
|
|
23
|
+
readonly db: any;
|
|
24
|
+
readonly view: AnyView;
|
|
25
|
+
readonly filterAllowlist: FilterAllowlist;
|
|
26
|
+
readonly sortAllowlist: SortAllowlist;
|
|
27
|
+
readonly dialect: "postgres" | "sqlite";
|
|
28
|
+
/** Override default ID column name (defaults to "id"). */
|
|
29
|
+
readonly idColumn?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const REJECT_MUTATION = async (
|
|
33
|
+
request: { method: string },
|
|
34
|
+
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
|
|
35
|
+
) => {
|
|
36
|
+
reply
|
|
37
|
+
.code(405)
|
|
38
|
+
.send({ error: "method_not_allowed", message: `${request.method} is not supported on a projection (read-only).` });
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function getViewConfig(view: AnyView): Record<string, unknown> | undefined {
|
|
42
|
+
try {
|
|
43
|
+
const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
|
|
44
|
+
if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore — proxy handler may throw on unexpected shapes
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveViewName(view: AnyView): string | undefined {
|
|
52
|
+
const cfg = getViewConfig(view);
|
|
53
|
+
if (cfg) {
|
|
54
|
+
if (typeof cfg["name"] === "string") return cfg["name"] as string;
|
|
55
|
+
}
|
|
56
|
+
// Fallback for non-proxy shapes
|
|
57
|
+
const v = view as Record<string, unknown>;
|
|
58
|
+
return (
|
|
59
|
+
(typeof v["__tableName"] === "string" ? v["__tableName"] as string : undefined) ??
|
|
60
|
+
(typeof v["_name"] === "string" ? v["_name"] as string : undefined)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Detect whether a Drizzle view was declared with `.existing()` and an empty
|
|
66
|
+
* column schema (`{}`). In that case, `db.select().from(view)` generates
|
|
67
|
+
* `SELECT FROM ...` (invalid SQL), and we must fall back to raw SQL.
|
|
68
|
+
*/
|
|
69
|
+
function isEmptyColumnView(view: AnyView): boolean {
|
|
70
|
+
const cfg = getViewConfig(view);
|
|
71
|
+
if (cfg) {
|
|
72
|
+
const fields = cfg["selectedFields"] as Record<string, unknown> | undefined;
|
|
73
|
+
return fields !== undefined && Object.keys(fields).length === 0;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function snakeToCamel(s: string): string {
|
|
79
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Normalise a raw SQL result row's keys from snake_case to camelCase. */
|
|
83
|
+
function camelizeRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
84
|
+
const out: Record<string, unknown> = {};
|
|
85
|
+
for (const [k, v] of Object.entries(row)) {
|
|
86
|
+
out[snakeToCamel(k)] = v;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
92
|
+
const { fastify, path, db, view, filterAllowlist, sortAllowlist, dialect } = opts;
|
|
93
|
+
const idCol = opts.idColumn ?? "id";
|
|
94
|
+
|
|
95
|
+
const viewName = resolveViewName(view);
|
|
96
|
+
const useRawSql = isEmptyColumnView(view) && !!viewName;
|
|
97
|
+
|
|
98
|
+
// ── List ──────────────────────────────────────────────────────────────────
|
|
99
|
+
fastify.get(path, async (req, reply) => {
|
|
100
|
+
try {
|
|
101
|
+
if (useRawSql) {
|
|
102
|
+
// .existing() view with no column schema — use raw SQL.
|
|
103
|
+
// Simple limit/offset only; filter/sort via allowlist is not available
|
|
104
|
+
// because column refs don't exist. This is sufficient for projection
|
|
105
|
+
// endpoints that return small full-table results.
|
|
106
|
+
const url = req.raw.url ?? "";
|
|
107
|
+
const qIdx = url.indexOf("?");
|
|
108
|
+
const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
|
|
109
|
+
const parsed = qs.parse(queryString) as Record<string, unknown>;
|
|
110
|
+
const limitVal = Math.min(1000, Math.max(1, Number(typeof parsed["limit"] === "string" ? parsed["limit"] : 1000)));
|
|
111
|
+
const offsetVal = Math.max(0, Number(typeof parsed["offset"] === "string" ? parsed["offset"] : 0));
|
|
112
|
+
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
113
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
114
|
+
const rows = await db.all(sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[];
|
|
115
|
+
const camelRows = rows.map((r: Record<string, unknown>) => camelizeRow(r));
|
|
116
|
+
if (!withCount) return camelRows;
|
|
117
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
118
|
+
const countRows = await db.all(sql.raw(`SELECT COUNT(*) AS c FROM "${viewName}"`)) as any[];
|
|
119
|
+
const total: number = Number(countRows[0]?.c ?? 0);
|
|
120
|
+
return { rows: camelRows, total };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const url = req.raw.url ?? "";
|
|
124
|
+
const qIdx = url.indexOf("?");
|
|
125
|
+
const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
|
|
126
|
+
const parsed = qs.parse(queryString) as Record<string, unknown>;
|
|
127
|
+
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
128
|
+
const result = parseFilterParams({
|
|
129
|
+
query: parsed,
|
|
130
|
+
table: view,
|
|
131
|
+
allowlist: filterAllowlist,
|
|
132
|
+
sortAllowlist,
|
|
133
|
+
dialect,
|
|
134
|
+
});
|
|
135
|
+
const combinedWhere = result.where && result.searchWhere
|
|
136
|
+
? and(result.where, result.searchWhere)
|
|
137
|
+
: (result.where ?? result.searchWhere);
|
|
138
|
+
let q = db.select().from(view);
|
|
139
|
+
if (combinedWhere) q = q.where(combinedWhere);
|
|
140
|
+
if (result.orderBy) q = q.orderBy(...result.orderBy);
|
|
141
|
+
if (result.limit !== undefined) q = q.limit(result.limit);
|
|
142
|
+
if (result.offset !== undefined) q = q.offset(result.offset);
|
|
143
|
+
const rows = await q.all();
|
|
144
|
+
|
|
145
|
+
if (!withCount) return rows;
|
|
146
|
+
|
|
147
|
+
// Count query: same WHERE, no limit/offset/orderBy.
|
|
148
|
+
let cq = db.select({ c: count() }).from(view);
|
|
149
|
+
if (combinedWhere) cq = cq.where(combinedWhere);
|
|
150
|
+
const countRow = (await cq.all())[0] as { c: number } | undefined;
|
|
151
|
+
const total = countRow?.c ?? 0;
|
|
152
|
+
return { rows, total };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof FilterParseError) {
|
|
155
|
+
reply.code(400).send({ error: err.code, message: err.message });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── Get by ID ─────────────────────────────────────────────────────────────
|
|
163
|
+
fastify.get(`${path}/:id`, async (req, reply) => {
|
|
164
|
+
const { id } = req.params as { id: string };
|
|
165
|
+
if (useRawSql) {
|
|
166
|
+
const numericId = Number(id);
|
|
167
|
+
if (!Number.isFinite(numericId)) {
|
|
168
|
+
return reply.code(400).send({ error: "invalid_id" });
|
|
169
|
+
}
|
|
170
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
171
|
+
const rows = await db.all(sql.raw(`SELECT * FROM "${viewName}" WHERE "${idCol}" = ${numericId} LIMIT 1`)) as any[];
|
|
172
|
+
const row = rows[0] ? camelizeRow(rows[0]) : undefined;
|
|
173
|
+
return row ?? reply.code(404).send({ error: "not_found" });
|
|
174
|
+
}
|
|
175
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
|
|
176
|
+
const colRef = (view as any)[idCol];
|
|
177
|
+
const row = await db.select().from(view).where(
|
|
178
|
+
colRef !== undefined ? eq(colRef, Number(id)) : undefined
|
|
179
|
+
).get();
|
|
180
|
+
return row ?? reply.code(404).send({ error: "not_found" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
|
184
|
+
fastify.post(path, REJECT_MUTATION);
|
|
185
|
+
fastify.patch(`${path}/:id`, REJECT_MUTATION);
|
|
186
|
+
fastify.delete(`${path}/:id`, REJECT_MUTATION);
|
|
187
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for drizzle-fastify route helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Accepts "1" or boolean true (the qs serialization of withCount: 1 from buildFilterQs).
|
|
6
|
+
export function isTruthyFlag(v: unknown): boolean {
|
|
7
|
+
if (v === undefined || v === null) return false;
|
|
8
|
+
if (typeof v === "boolean") return v;
|
|
9
|
+
return String(v) === "1";
|
|
10
|
+
}
|