@metaobjectsdev/runtime-ts 1.0.4 → 1.0.5-rc.10
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/dist/drizzle-fastify/filter-parser.js +6 -2
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +4 -2
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +48 -14
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts +24 -0
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.js +37 -3
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +16 -24
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/drizzle-fastify/util.d.ts +9 -0
- package/dist/drizzle-fastify/util.d.ts.map +1 -1
- package/dist/drizzle-fastify/util.js +32 -0
- package/dist/drizzle-fastify/util.js.map +1 -1
- package/dist/fastify/index.d.ts +27 -3
- package/dist/fastify/index.d.ts.map +1 -1
- package/dist/fastify/index.js +41 -5
- package/dist/fastify/index.js.map +1 -1
- package/dist/hono/index.d.ts +18 -2
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +66 -16
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +16 -24
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/identity-strategy.d.ts +4 -1
- package/dist/identity-strategy.d.ts.map +1 -1
- package/dist/identity-strategy.js +1 -1
- package/dist/identity-strategy.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-endpoint.d.ts +28 -0
- package/dist/meta-endpoint.d.ts.map +1 -0
- package/dist/meta-endpoint.js +33 -0
- package/dist/meta-endpoint.js.map +1 -0
- package/dist/object-manager.js +6 -2
- package/dist/object-manager.js.map +1 -1
- package/dist/timestamp-wire.d.ts +17 -0
- package/dist/timestamp-wire.d.ts.map +1 -0
- package/dist/timestamp-wire.js +144 -0
- package/dist/timestamp-wire.js.map +1 -0
- package/dist/validator-runner.d.ts +7 -0
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +2 -0
- package/dist/validator-runner.js.map +1 -1
- package/package.json +3 -3
- package/src/drizzle-fastify/filter-parser.ts +6 -2
- package/src/drizzle-fastify/index.ts +53 -16
- package/src/drizzle-fastify/mount-m2m.ts +53 -3
- package/src/drizzle-fastify/mount-read-only.ts +16 -24
- package/src/drizzle-fastify/util.ts +33 -0
- package/src/fastify/index.ts +59 -9
- package/src/hono/index.ts +84 -18
- package/src/hono/mount-read-only.ts +16 -24
- package/src/identity-strategy.ts +3 -2
- package/src/index.ts +3 -0
- package/src/meta-endpoint.ts +35 -0
- package/src/object-manager.ts +8 -3
- package/src/timestamp-wire.ts +152 -0
- package/src/validator-runner.ts +8 -0
|
@@ -261,7 +261,11 @@ function ensureArray(v: unknown, key: string): unknown[] {
|
|
|
261
261
|
}
|
|
262
262
|
// Defensive — qs.parse normally produces an object/array; this branch is
|
|
263
263
|
// only reachable if a caller hand-constructs a malformed query object.
|
|
264
|
-
|
|
264
|
+
//
|
|
265
|
+
// `field` (not `key`) because this maps to the cross-port `invalid_filter_value`
|
|
266
|
+
// envelope, which REQUIRES `field` naming the offending filter key — here the
|
|
267
|
+
// `or`/`and` connector that was handed a non-array.
|
|
268
|
+
throw new FilterParseError("filter.invalid_value", `Expected array for "${key}".`, { field: key });
|
|
265
269
|
}
|
|
266
270
|
|
|
267
271
|
function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist): SQLWrapper[] {
|
|
@@ -290,7 +294,7 @@ function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist):
|
|
|
290
294
|
);
|
|
291
295
|
const order = orderRaw.toLowerCase();
|
|
292
296
|
if (order !== "asc" && order !== "desc") {
|
|
293
|
-
throw new FilterParseError("sort.invalid_order", `Sort order must be asc|desc, got "${orderRaw}".`, { expected: "asc | desc" });
|
|
297
|
+
throw new FilterParseError("sort.invalid_order", `Sort order must be asc|desc, got "${orderRaw}".`, { field, expected: "asc | desc" });
|
|
294
298
|
}
|
|
295
299
|
const col = table[field];
|
|
296
300
|
return [order === "asc" ? asc(col as any) : desc(col as any)];
|
|
@@ -22,8 +22,10 @@ import qs from "qs";
|
|
|
22
22
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
23
23
|
export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
24
24
|
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
25
|
-
import { isTruthyFlag, contractErrorCode, coerceIdForColumn } from "./util.js";
|
|
25
|
+
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow } from "./util.js";
|
|
26
|
+
import { timestampWire } from "../timestamp-wire.js";
|
|
26
27
|
export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
|
|
28
|
+
export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
|
|
27
29
|
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
29
31
|
// Loose types — we don't bind to a specific Drizzle backend so the helper
|
|
@@ -67,8 +69,9 @@ export interface CrudRoutesOptions {
|
|
|
67
69
|
*/
|
|
68
70
|
routeOptions?: RouteShorthandOptions;
|
|
69
71
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
+
* Restrict the update verb to ONE HTTP method. Absent (the default) mounts
|
|
73
|
+
* the update handler on both PATCH and PUT, as the cross-port REST contract
|
|
74
|
+
* requires.
|
|
72
75
|
*/
|
|
73
76
|
updateMethod?: "patch" | "put";
|
|
74
77
|
filterAllowlist?: FilterAllowlist;
|
|
@@ -105,6 +108,24 @@ function readSource(opts: VerbOptions): AnyTable {
|
|
|
105
108
|
return opts.readView ?? opts.table;
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Wire mapper for rows READ through `readSource` — canonicalized against
|
|
113
|
+
* exactly that source, since a view may shadow a table column with a
|
|
114
|
+
* non-timestamp of the same name.
|
|
115
|
+
*/
|
|
116
|
+
function readWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
117
|
+
return timestampWire(readSource(opts));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Wire mapper for a WRITE ECHO: `reReadThroughView` answers with the replica
|
|
122
|
+
* view's row but falls back to the table row, so the echo is canonicalized
|
|
123
|
+
* against the union of both sources.
|
|
124
|
+
*/
|
|
125
|
+
function echoWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
126
|
+
return timestampWire(opts.table, opts.readView);
|
|
127
|
+
}
|
|
128
|
+
|
|
108
129
|
/** #214 — after a write to the base table, re-read the row THROUGH the replica
|
|
109
130
|
* view by PK so the response carries derived (origin.passthrough) columns. When
|
|
110
131
|
* there is no `readView`, return the write row unchanged (current behaviour). */
|
|
@@ -140,6 +161,7 @@ function routeOpts(opts: VerbOptions): RouteShorthandOptions {
|
|
|
140
161
|
}
|
|
141
162
|
|
|
142
163
|
export function mountListRoute(opts: VerbOptions): void {
|
|
164
|
+
const toWire = readWire(opts);
|
|
143
165
|
opts.fastify.get(opts.path, routeOpts(opts), async (req, reply) => {
|
|
144
166
|
try {
|
|
145
167
|
// #214 — a write-through entity reads through its replica view.
|
|
@@ -195,7 +217,7 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
195
217
|
// drizzle-orm node-postgres query builder is thenable but has no `.all()`
|
|
196
218
|
// method (that is a libsql/better-sqlite3-only API). Awaiting works on
|
|
197
219
|
// both dialects and is what makes this helper genuinely Postgres-capable.
|
|
198
|
-
const rows = await q;
|
|
220
|
+
const rows = (await q as unknown[]).map(toWire);
|
|
199
221
|
|
|
200
222
|
if (!withCount) return rows;
|
|
201
223
|
|
|
@@ -215,6 +237,7 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
215
237
|
}
|
|
216
238
|
|
|
217
239
|
export function mountGetRoute(opts: VerbOptions): void {
|
|
240
|
+
const toWire = readWire(opts);
|
|
218
241
|
opts.fastify.get(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
|
|
219
242
|
const { id } = req.params as { id: string };
|
|
220
243
|
const discCond = discriminatorCond(opts);
|
|
@@ -227,20 +250,13 @@ export function mountGetRoute(opts: VerbOptions): void {
|
|
|
227
250
|
return reply.code(400).send({ error: "invalid_id" });
|
|
228
251
|
}
|
|
229
252
|
const idCond = eq(src.id, idValue);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// but has no `.get()`. Awaiting works on both dialects.
|
|
233
|
-
const rows = await opts.db
|
|
234
|
-
.select()
|
|
235
|
-
.from(src)
|
|
236
|
-
.where(discCond ? and(idCond, discCond) : idCond)
|
|
237
|
-
.limit(1);
|
|
238
|
-
const row = (rows as unknown[])[0];
|
|
239
|
-
return row ?? reply.code(404).send({ error: "not_found" });
|
|
253
|
+
const row = await firstRow(opts.db, src, discCond ? and(idCond, discCond) : idCond);
|
|
254
|
+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
|
|
240
255
|
});
|
|
241
256
|
}
|
|
242
257
|
|
|
243
258
|
export function mountCreateRoute(opts: VerbOptions): void {
|
|
259
|
+
const toWire = echoWire(opts);
|
|
244
260
|
opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => {
|
|
245
261
|
const parsed = opts.insertSchema.safeParse(req.body);
|
|
246
262
|
if (!parsed.success) {
|
|
@@ -264,11 +280,12 @@ export function mountCreateRoute(opts: VerbOptions): void {
|
|
|
264
280
|
const row = (result as unknown[])[0];
|
|
265
281
|
// #214 — read-your-writes: re-read through the replica view so the response
|
|
266
282
|
// carries derived (origin.passthrough) columns the base table excludes.
|
|
267
|
-
return reply.code(201).send(await reReadThroughView(opts, row));
|
|
283
|
+
return reply.code(201).send(toWire(await reReadThroughView(opts, row)));
|
|
268
284
|
});
|
|
269
285
|
}
|
|
270
286
|
|
|
271
287
|
export function mountUpdateRoute(opts: VerbOptions): void {
|
|
288
|
+
const toWire = echoWire(opts);
|
|
272
289
|
const handler = async (
|
|
273
290
|
req: { params: unknown; body: unknown },
|
|
274
291
|
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
|
|
@@ -286,6 +303,26 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
286
303
|
const { [opts.discriminator.column]: _omit, ...rest } = data;
|
|
287
304
|
data = rest;
|
|
288
305
|
}
|
|
306
|
+
// A patch that strips to NOTHING is a no-op update, not a failure, and it
|
|
307
|
+
// must never reach the driver. Drizzle's `.set({})` throws `No values to
|
|
308
|
+
// set`; that throw IS caught below, and `classifyConstraintError` rightly
|
|
309
|
+
// declines it — an empty update is a programming error, not a constraint
|
|
310
|
+
// violation — so it took the redact path and answered 500 `database error`.
|
|
311
|
+
// The strip above is precisely what produces the empty object, so a body
|
|
312
|
+
// naming only the discriminator (the one key a client is most likely to
|
|
313
|
+
// send at a TPH subtype route) was a guaranteed 500. Answer as a READ does,
|
|
314
|
+
// including the 404: a no-op patch must not become a hole through which one
|
|
315
|
+
// subtype's route reports another subtype's row.
|
|
316
|
+
if (Object.keys(data).length === 0) {
|
|
317
|
+
const src = readSource(opts);
|
|
318
|
+
const noopId = coerceIdForColumn(src.id, id);
|
|
319
|
+
if (noopId === undefined) {
|
|
320
|
+
return reply.code(400).send({ error: "invalid_id" });
|
|
321
|
+
}
|
|
322
|
+
const noopCond = eq(src.id, noopId);
|
|
323
|
+
const row = await firstRow(opts.db, src, discCond ? and(noopCond, discCond) : noopCond);
|
|
324
|
+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
|
|
325
|
+
}
|
|
289
326
|
// Compare against the PK's real type (see mountGetRoute) — a numeric-
|
|
290
327
|
// LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
|
|
291
328
|
const idValue = coerceIdForColumn(opts.table.id, id);
|
|
@@ -308,7 +345,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
308
345
|
const row = (result as unknown[])[0];
|
|
309
346
|
if (row == null) return reply.code(404).send({ error: "not_found" });
|
|
310
347
|
// #214 — re-read through the replica view so the response carries derived columns.
|
|
311
|
-
return await reReadThroughView(opts, row);
|
|
348
|
+
return toWire(await reReadThroughView(opts, row));
|
|
312
349
|
};
|
|
313
350
|
const path = `${opts.path}/:id`;
|
|
314
351
|
const ro = routeOpts(opts);
|
|
@@ -18,10 +18,22 @@
|
|
|
18
18
|
// the source side (via @sourceRefField) → sourceColumn/targetColumn.
|
|
19
19
|
// 3. Symmetric self-join: junction WHERE sourceCol = :id OR targetCol = :id;
|
|
20
20
|
// per row the related id is whichever column is NOT the source id.
|
|
21
|
+
//
|
|
22
|
+
// A target that is a TPH SUBTYPE lives in its discriminator base's table, and the
|
|
23
|
+
// junction FK can only point at that base table — so it can hold the id of a row of
|
|
24
|
+
// another subtype. `targetDiscriminator` ANDs the subtype predicate into stage 2 so
|
|
25
|
+
// only rows of the declared target come back.
|
|
26
|
+
//
|
|
27
|
+
// The SOURCE has the mirror problem. An M:N declared ON a subtype mounts under that
|
|
28
|
+
// subtype's segment (`/auths/bridge/:id/linkedAuths`), but the junction FK still points
|
|
29
|
+
// at the shared base table, so a SIBLING subtype's id traverses it just as well and the
|
|
30
|
+
// subtype segment in the path would be decorative. `sourceDiscriminator` adds a stage 0
|
|
31
|
+
// that checks the source row's own discriminator first.
|
|
21
32
|
|
|
22
33
|
import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
23
|
-
import { eq, or, inArray } from "drizzle-orm";
|
|
34
|
+
import { and, eq, or, inArray } from "drizzle-orm";
|
|
24
35
|
import { coerceIdForColumn } from "./util.js";
|
|
36
|
+
import { timestampWire } from "../timestamp-wire.js";
|
|
25
37
|
|
|
26
38
|
// Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
|
|
27
39
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
|
|
@@ -49,6 +61,22 @@ export interface M2mRouteOptions {
|
|
|
49
61
|
targetPkColumn?: string;
|
|
50
62
|
/** Undirected self-join: union both junction FK columns on read. */
|
|
51
63
|
symmetric: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The target is a TPH subtype: `targetTable` is its discriminator base's table, and
|
|
66
|
+
* stage 2 keeps only rows whose discriminator `column` (physical name) equals `value`.
|
|
67
|
+
* Absent → every related row is returned, behaviour unchanged.
|
|
68
|
+
*/
|
|
69
|
+
targetDiscriminator?: { column: string; value: string };
|
|
70
|
+
/**
|
|
71
|
+
* The SOURCE is a TPH subtype: this route is mounted under the subtype's path segment,
|
|
72
|
+
* but the junction FK addresses the shared base table, so an id belonging to a SIBLING
|
|
73
|
+
* subtype would traverse it successfully and return that sibling's relations. Stage 0
|
|
74
|
+
* reads the source row's discriminator from `table` (the base table the row lives in)
|
|
75
|
+
* and yields an empty list when it is not `value` — the id names no row of THIS
|
|
76
|
+
* subtype, so it has no relations, which is a 200 with `[]` rather than an error.
|
|
77
|
+
* Absent → no source check, behaviour unchanged.
|
|
78
|
+
*/
|
|
79
|
+
sourceDiscriminator?: { table: AnyTable; pkColumn: string; column: string; value: string };
|
|
52
80
|
/** Fastify route-level hooks (auth, etc.). */
|
|
53
81
|
routeOptions?: RouteShorthandOptions;
|
|
54
82
|
}
|
|
@@ -57,6 +85,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
|
|
|
57
85
|
const targetPk = opts.targetPkColumn ?? "id";
|
|
58
86
|
const route = `${opts.path}/:id/${opts.relationName}`;
|
|
59
87
|
const ro = opts.routeOptions ?? {};
|
|
88
|
+
const toWire = timestampWire(opts.targetTable);
|
|
60
89
|
|
|
61
90
|
opts.fastify.get(route, ro, async (req, reply) => {
|
|
62
91
|
const { id } = req.params as { id: string };
|
|
@@ -72,6 +101,24 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
|
|
|
72
101
|
return reply.code(400).send({ error: "invalid_id" });
|
|
73
102
|
}
|
|
74
103
|
|
|
104
|
+
// Stage 0 — the source id must name a row of THIS subtype. Skipping it would let
|
|
105
|
+
// `/auths/bridge/{a Copay's id}/linkedAuths` return that Copay's relations, because
|
|
106
|
+
// the junction FK cannot tell the subtypes apart: it points at the shared base table.
|
|
107
|
+
const srcDisc = opts.sourceDiscriminator;
|
|
108
|
+
if (srcDisc !== undefined) {
|
|
109
|
+
const srcPk = columnRef(srcDisc.table, srcDisc.pkColumn);
|
|
110
|
+
const ownId = coerceIdForColumn(srcPk, id);
|
|
111
|
+
if (ownId === undefined) {
|
|
112
|
+
return reply.code(400).send({ error: "invalid_id" });
|
|
113
|
+
}
|
|
114
|
+
const owner = await opts.db
|
|
115
|
+
.select({ d: columnRef(srcDisc.table, srcDisc.column) })
|
|
116
|
+
.from(srcDisc.table)
|
|
117
|
+
.where(eq(srcPk, ownId))
|
|
118
|
+
.limit(1);
|
|
119
|
+
if (owner.length === 0 || String(owner[0]?.d) !== srcDisc.value) return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
75
122
|
// Stage 1 — junction rows for this source id.
|
|
76
123
|
const joinWhere = opts.symmetric
|
|
77
124
|
? or(eq(srcCol, sourceId), eq(tgtCol, sourceId))
|
|
@@ -101,10 +148,13 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
|
|
|
101
148
|
|
|
102
149
|
// Stage 2 — load the target rows.
|
|
103
150
|
const pkCol = columnRef(opts.targetTable, targetPk);
|
|
104
|
-
|
|
151
|
+
const byPk = inArray(pkCol, [...relatedIds]);
|
|
152
|
+
const disc = opts.targetDiscriminator;
|
|
153
|
+
const rows = await opts.db
|
|
105
154
|
.select()
|
|
106
155
|
.from(opts.targetTable)
|
|
107
|
-
.where(
|
|
156
|
+
.where(disc ? and(byPk, eq(columnRef(opts.targetTable, disc.column), disc.value)) : byPk);
|
|
157
|
+
return (rows as unknown[]).map(toWire);
|
|
108
158
|
});
|
|
109
159
|
}
|
|
110
160
|
|
|
@@ -3,19 +3,12 @@ import { sql, eq, and, count } from "drizzle-orm";
|
|
|
3
3
|
import qs from "qs";
|
|
4
4
|
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
5
5
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
6
|
-
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral } from "./util.js";
|
|
6
|
+
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js";
|
|
7
|
+
import { timestampWire } from "../timestamp-wire.js";
|
|
7
8
|
|
|
8
9
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
|
|
9
10
|
type AnyView = any;
|
|
10
11
|
|
|
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
12
|
export interface MountReadOnlyOptions {
|
|
20
13
|
readonly fastify: FastifyInstance;
|
|
21
14
|
readonly path: string;
|
|
@@ -49,18 +42,8 @@ const REJECT_MUTATION = async (
|
|
|
49
42
|
.send({ error: "method_not_allowed", message: `${request.method} is not supported on a projection (read-only).` });
|
|
50
43
|
};
|
|
51
44
|
|
|
52
|
-
function getViewConfig(view: AnyView): Record<string, unknown> | undefined {
|
|
53
|
-
try {
|
|
54
|
-
const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
|
|
55
|
-
if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
|
|
56
|
-
} catch {
|
|
57
|
-
// ignore — proxy handler may throw on unexpected shapes
|
|
58
|
-
}
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
45
|
function resolveViewName(view: AnyView): string | undefined {
|
|
63
|
-
const cfg =
|
|
46
|
+
const cfg = viewBaseConfig(view);
|
|
64
47
|
if (cfg) {
|
|
65
48
|
if (typeof cfg["name"] === "string") return cfg["name"] as string;
|
|
66
49
|
}
|
|
@@ -78,7 +61,7 @@ function resolveViewName(view: AnyView): string | undefined {
|
|
|
78
61
|
* `SELECT FROM ...` (invalid SQL), and we must fall back to raw SQL.
|
|
79
62
|
*/
|
|
80
63
|
function isEmptyColumnView(view: AnyView): boolean {
|
|
81
|
-
const cfg =
|
|
64
|
+
const cfg = viewBaseConfig(view);
|
|
82
65
|
if (cfg) {
|
|
83
66
|
const fields = cfg["selectedFields"] as Record<string, unknown> | undefined;
|
|
84
67
|
return fields !== undefined && Object.keys(fields).length === 0;
|
|
@@ -139,6 +122,8 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
139
122
|
|
|
140
123
|
const viewName = resolveViewName(view);
|
|
141
124
|
const useRawSql = isEmptyColumnView(view) && !!viewName;
|
|
125
|
+
// The raw-SQL branch has no declared columns, so nothing names a timestamp there.
|
|
126
|
+
const toWire = timestampWire(view);
|
|
142
127
|
|
|
143
128
|
// ── List ──────────────────────────────────────────────────────────────────
|
|
144
129
|
fastify.get(path, ro, async (req, reply) => {
|
|
@@ -189,7 +174,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
189
174
|
// node-postgres builder is thenable. (The useRawSql branch above keeps
|
|
190
175
|
// db.all(sql.raw(...)) because that is the libsql raw-exec API, only
|
|
191
176
|
// reachable for `.existing()` empty-column views which are sqlite-only.)
|
|
192
|
-
const rows = await q;
|
|
177
|
+
const rows = (await q as unknown[]).map(toWire);
|
|
193
178
|
|
|
194
179
|
if (!withCount) return rows;
|
|
195
180
|
|
|
@@ -201,7 +186,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
201
186
|
return { rows, total };
|
|
202
187
|
} catch (err) {
|
|
203
188
|
if (err instanceof FilterParseError) {
|
|
204
|
-
|
|
189
|
+
// Spread the details so the cross-port-required `field` reaches the wire on a
|
|
190
|
+
// projection route too (F20) — the writable mount already does this.
|
|
191
|
+
reply.code(400).send({ error: contractErrorCode(err.code), message: err.message, ...(err.details ?? {}) });
|
|
205
192
|
return;
|
|
206
193
|
}
|
|
207
194
|
throw err;
|
|
@@ -229,11 +216,16 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
229
216
|
colRef !== undefined ? eq(colRef, idValue) : undefined
|
|
230
217
|
).limit(1);
|
|
231
218
|
const row = (rows as unknown[])[0];
|
|
232
|
-
return row
|
|
219
|
+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
|
|
233
220
|
});
|
|
234
221
|
|
|
235
222
|
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
|
223
|
+
// PUT is here because the WRITABLE mount serves it (a full-replace update), so a
|
|
224
|
+
// projection must reject it the same way the other three are rejected. Omitting it
|
|
225
|
+
// left `PUT /<collection>/:id` falling through to Fastify's 404 — telling a caller
|
|
226
|
+
// the resource does not exist when it plainly does and answers GET.
|
|
236
227
|
fastify.post(path, ro, REJECT_MUTATION);
|
|
237
228
|
fastify.patch(`${path}/:id`, ro, REJECT_MUTATION);
|
|
229
|
+
fastify.put(`${path}/:id`, ro, REJECT_MUTATION);
|
|
238
230
|
fastify.delete(`${path}/:id`, ro, REJECT_MUTATION);
|
|
239
231
|
}
|
|
@@ -81,3 +81,36 @@ export function contractErrorCode(internal: string): string {
|
|
|
81
81
|
return internal;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Drizzle v0.45 stores view config under this well-known Symbol. Accessing
|
|
87
|
+
* `view._` on a proxy-wrapped view (empty-column `.existing()`) throws because
|
|
88
|
+
* the proxy tries to spread `subquery._.selectedFields` which is undefined; the
|
|
89
|
+
* symbol read bypasses the proxy. The proxy handler can still throw on
|
|
90
|
+
* unexpected shapes, so the read is guarded. One reader for the private shape —
|
|
91
|
+
* the two read-only mounts and the timestamp-wire column lookup share it.
|
|
92
|
+
*/
|
|
93
|
+
const VIEW_BASE_CONFIG = Symbol.for("drizzle:ViewBaseConfig");
|
|
94
|
+
|
|
95
|
+
export function viewBaseConfig(view: unknown): Record<string, unknown> | undefined {
|
|
96
|
+
try {
|
|
97
|
+
const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
|
|
98
|
+
if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore — proxy handler may throw on unexpected shapes
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The portable one-row read: `.limit(1)` + await + first element. Awaiting is
|
|
107
|
+
* what makes this work on both dialects — the node-postgres query builder is
|
|
108
|
+
* thenable but has no `.get()` (that is a libsql/better-sqlite3-only API, #286).
|
|
109
|
+
* The caller builds the WHERE (including any TPH discriminator), so the
|
|
110
|
+
* condition stays with the mount that owns it.
|
|
111
|
+
*/
|
|
112
|
+
export async function firstRow(db: unknown, src: unknown, cond: unknown): Promise<unknown> {
|
|
113
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over the user's Drizzle client
|
|
114
|
+
const rows: unknown = await (db as any).select().from(src).where(cond).limit(1);
|
|
115
|
+
return (rows as unknown[])[0];
|
|
116
|
+
}
|
package/src/fastify/index.ts
CHANGED
|
@@ -59,9 +59,9 @@ export interface CrudRoutesOptions {
|
|
|
59
59
|
*/
|
|
60
60
|
routeOptions?: RouteShorthandOptions;
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
* update
|
|
64
|
-
*
|
|
62
|
+
* Restrict the update verb to ONE HTTP method. Absent (the default) mounts
|
|
63
|
+
* the update handler on both PATCH and PUT, as the cross-port REST contract
|
|
64
|
+
* requires.
|
|
65
65
|
*/
|
|
66
66
|
updateMethod?: "patch" | "put";
|
|
67
67
|
/**
|
|
@@ -130,7 +130,9 @@ export function mountListRoute(opts: SingleVerbOptions): void {
|
|
|
130
130
|
if (opts.sortAllowlist && typeof parsed["sort"] === "string") {
|
|
131
131
|
const sortParse = parseSort(parsed["sort"], opts.sortAllowlist);
|
|
132
132
|
if (sortParse.error) {
|
|
133
|
-
|
|
133
|
+
// `field` is REQUIRED on the invalid_sort envelope cross-port (F20 ruling) —
|
|
134
|
+
// it names the offending sort field instead of making the caller guess.
|
|
135
|
+
return reply.code(400).send({ error: contractErrorCode(sortParse.error), field: sortParse.field });
|
|
134
136
|
}
|
|
135
137
|
if (sortParse.orderBy) readOpts.orderBy = sortParse.orderBy;
|
|
136
138
|
}
|
|
@@ -153,15 +155,15 @@ export function mountListRoute(opts: SingleVerbOptions): void {
|
|
|
153
155
|
function parseSort(
|
|
154
156
|
spec: string,
|
|
155
157
|
sortAllowlist: SortAllowlist,
|
|
156
|
-
): { orderBy?: [string, "asc" | "desc"]; error?: string } {
|
|
158
|
+
): { orderBy?: [string, "asc" | "desc"]; error?: string; field?: string } {
|
|
157
159
|
const [field, orderRaw] = spec.split(":");
|
|
158
|
-
if (!field || !sortAllowlist[field]) return { error: "sort.unknown_field" };
|
|
160
|
+
if (!field || !sortAllowlist[field]) return { error: "sort.unknown_field", field: field ?? "" };
|
|
159
161
|
// Precedence (caller order > declared @sortableDefaultOrder > asc) comes from the
|
|
160
162
|
// shared sortOrderSpec, so this mount cannot answer one declaration differently from
|
|
161
163
|
// the drizzle mount again. Validation stays here because this mount reports a bad
|
|
162
164
|
// order as an error CODE rather than a thrown FilterParseError.
|
|
163
165
|
const order = sortOrderSpec(sortAllowlist, field, orderRaw).toLowerCase();
|
|
164
|
-
if (order !== "asc" && order !== "desc") return { error: "sort.invalid_order" };
|
|
166
|
+
if (order !== "asc" && order !== "desc") return { error: "sort.invalid_order", field };
|
|
165
167
|
return { orderBy: [field, order] };
|
|
166
168
|
}
|
|
167
169
|
|
|
@@ -225,11 +227,19 @@ export function mountUpdateRoute(opts: SingleVerbOptions): void {
|
|
|
225
227
|
};
|
|
226
228
|
const path = `${opts.path}/:id`;
|
|
227
229
|
const ro = routeOpts(opts);
|
|
230
|
+
// Cross-port REST contract (FR-008): the update verb is reachable via BOTH PATCH
|
|
231
|
+
// and PUT, each routed to the same handler — as the drizzle-fastify flavor and
|
|
232
|
+
// every other port's controller do. `updateMethod` stays an explicit single-verb
|
|
233
|
+
// override for a consumer that wants to restrict the surface.
|
|
228
234
|
// biome-ignore lint/suspicious/noExplicitAny: handler signature is generic by design
|
|
235
|
+
const h = handler as any;
|
|
229
236
|
if (opts.updateMethod === "put") {
|
|
230
|
-
opts.fastify.put(path, ro,
|
|
237
|
+
opts.fastify.put(path, ro, h);
|
|
238
|
+
} else if (opts.updateMethod === "patch") {
|
|
239
|
+
opts.fastify.patch(path, ro, h);
|
|
231
240
|
} else {
|
|
232
|
-
opts.fastify.patch(path, ro,
|
|
241
|
+
opts.fastify.patch(path, ro, h);
|
|
242
|
+
opts.fastify.put(path, ro, h);
|
|
233
243
|
}
|
|
234
244
|
}
|
|
235
245
|
|
|
@@ -256,3 +266,43 @@ export function mountDeleteRoute(opts: SingleVerbOptions): void {
|
|
|
256
266
|
// shared (deprecated) helper so the three adapters can't silently diverge.
|
|
257
267
|
// The mounts above no longer use it: ObjectManager coerces ids per metadata.
|
|
258
268
|
export { parseId } from "../drizzle-fastify/util.js";
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Metadata endpoint
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
275
|
+
import { META_ROUTE_PATH, META_CONTENT_TYPE, metaJson } from "../meta-endpoint.js";
|
|
276
|
+
|
|
277
|
+
export interface MetaRouteOptions {
|
|
278
|
+
fastify: FastifyInstance;
|
|
279
|
+
/** The loaded model root. */
|
|
280
|
+
root: MetaData;
|
|
281
|
+
/** Mounted under this prefix; defaults to "" (the bare `/_meta`). */
|
|
282
|
+
prefix?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Mount `GET {prefix}/_meta`, serving the model as effective canonical JSON.
|
|
287
|
+
*
|
|
288
|
+
* Opt-in by design. `/_meta` publishes the SHAPE of the model — entity and field
|
|
289
|
+
* names, types, validators, layouts — though no row data. Guard it with your own
|
|
290
|
+
* middleware exactly as you would the generated routes (#367):
|
|
291
|
+
*
|
|
292
|
+
* app.register(async (s) => {
|
|
293
|
+
* s.addHook("preHandler", requireAuth);
|
|
294
|
+
* mountMetaRoute({ fastify: s, root, prefix: "/api" });
|
|
295
|
+
* });
|
|
296
|
+
*
|
|
297
|
+
* The body is serialized once per call. If that ever matters, cache it in the
|
|
298
|
+
* host — the model is immutable after load.
|
|
299
|
+
*/
|
|
300
|
+
export function mountMetaRoute(opts: MetaRouteOptions): void {
|
|
301
|
+
const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
|
|
302
|
+
opts.fastify.get(path, async (_req, reply) => {
|
|
303
|
+
return reply
|
|
304
|
+
.code(200)
|
|
305
|
+
.header("content-type", META_CONTENT_TYPE)
|
|
306
|
+
.send(metaJson(opts.root));
|
|
307
|
+
});
|
|
308
|
+
}
|