@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.
Files changed (65) hide show
  1. package/dist/drizzle-fastify/filter-parser.js +6 -2
  2. package/dist/drizzle-fastify/filter-parser.js.map +1 -1
  3. package/dist/drizzle-fastify/index.d.ts +4 -2
  4. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  5. package/dist/drizzle-fastify/index.js +48 -14
  6. package/dist/drizzle-fastify/index.js.map +1 -1
  7. package/dist/drizzle-fastify/mount-m2m.d.ts +24 -0
  8. package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
  9. package/dist/drizzle-fastify/mount-m2m.js +37 -3
  10. package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
  11. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
  12. package/dist/drizzle-fastify/mount-read-only.js +16 -24
  13. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  14. package/dist/drizzle-fastify/util.d.ts +9 -0
  15. package/dist/drizzle-fastify/util.d.ts.map +1 -1
  16. package/dist/drizzle-fastify/util.js +32 -0
  17. package/dist/drizzle-fastify/util.js.map +1 -1
  18. package/dist/fastify/index.d.ts +27 -3
  19. package/dist/fastify/index.d.ts.map +1 -1
  20. package/dist/fastify/index.js +41 -5
  21. package/dist/fastify/index.js.map +1 -1
  22. package/dist/hono/index.d.ts +18 -2
  23. package/dist/hono/index.d.ts.map +1 -1
  24. package/dist/hono/index.js +66 -16
  25. package/dist/hono/index.js.map +1 -1
  26. package/dist/hono/mount-read-only.d.ts.map +1 -1
  27. package/dist/hono/mount-read-only.js +16 -24
  28. package/dist/hono/mount-read-only.js.map +1 -1
  29. package/dist/identity-strategy.d.ts +4 -1
  30. package/dist/identity-strategy.d.ts.map +1 -1
  31. package/dist/identity-strategy.js +1 -1
  32. package/dist/identity-strategy.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/meta-endpoint.d.ts +28 -0
  38. package/dist/meta-endpoint.d.ts.map +1 -0
  39. package/dist/meta-endpoint.js +33 -0
  40. package/dist/meta-endpoint.js.map +1 -0
  41. package/dist/object-manager.js +6 -2
  42. package/dist/object-manager.js.map +1 -1
  43. package/dist/timestamp-wire.d.ts +17 -0
  44. package/dist/timestamp-wire.d.ts.map +1 -0
  45. package/dist/timestamp-wire.js +144 -0
  46. package/dist/timestamp-wire.js.map +1 -0
  47. package/dist/validator-runner.d.ts +7 -0
  48. package/dist/validator-runner.d.ts.map +1 -1
  49. package/dist/validator-runner.js +2 -0
  50. package/dist/validator-runner.js.map +1 -1
  51. package/package.json +3 -3
  52. package/src/drizzle-fastify/filter-parser.ts +6 -2
  53. package/src/drizzle-fastify/index.ts +53 -16
  54. package/src/drizzle-fastify/mount-m2m.ts +53 -3
  55. package/src/drizzle-fastify/mount-read-only.ts +16 -24
  56. package/src/drizzle-fastify/util.ts +33 -0
  57. package/src/fastify/index.ts +59 -9
  58. package/src/hono/index.ts +84 -18
  59. package/src/hono/mount-read-only.ts +16 -24
  60. package/src/identity-strategy.ts +3 -2
  61. package/src/index.ts +3 -0
  62. package/src/meta-endpoint.ts +35 -0
  63. package/src/object-manager.ts +8 -3
  64. package/src/timestamp-wire.ts +152 -0
  65. 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
- throw new FilterParseError("filter.invalid_value", `Expected array for "${key}".`, { key });
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
- * HTTP method for the update verb. Defaults to "patch". Set to "put" to
71
- * preserve a legacy API contract that already uses PUT for updates.
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
- // Await + take the first row rather than `.get()` — `.get()` is a
231
- // libsql/better-sqlite3-only method; the node-postgres builder is thenable
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
- return await opts.db
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(inArray(pkCol, [...relatedIds]));
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 = getViewConfig(view);
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 = getViewConfig(view);
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
- reply.code(400).send({ error: contractErrorCode(err.code), message: err.message });
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 ?? reply.code(404).send({ error: "not_found" });
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
+ }
@@ -59,9 +59,9 @@ export interface CrudRoutesOptions {
59
59
  */
60
60
  routeOptions?: RouteShorthandOptions;
61
61
  /**
62
- * HTTP method for the update verb. Defaults to "patch" (semantic partial-
63
- * update). Set to "put" to preserve a legacy API contract that already
64
- * uses PUT for updates.
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
- return reply.code(400).send({ error: contractErrorCode(sortParse.error) });
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, handler as any);
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, handler as any);
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
+ }