@metaobjectsdev/runtime-ts 1.0.5-rc.7 → 1.0.5-rc.9

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 (46) hide show
  1. package/dist/drizzle-fastify/index.d.ts +1 -0
  2. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  3. package/dist/drizzle-fastify/index.js +30 -21
  4. package/dist/drizzle-fastify/index.js.map +1 -1
  5. package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
  6. package/dist/drizzle-fastify/mount-m2m.js +4 -1
  7. package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
  8. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
  9. package/dist/drizzle-fastify/mount-read-only.js +8 -23
  10. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  11. package/dist/drizzle-fastify/util.d.ts +9 -0
  12. package/dist/drizzle-fastify/util.d.ts.map +1 -1
  13. package/dist/drizzle-fastify/util.js +32 -0
  14. package/dist/drizzle-fastify/util.js.map +1 -1
  15. package/dist/hono/index.d.ts +1 -0
  16. package/dist/hono/index.d.ts.map +1 -1
  17. package/dist/hono/index.js +38 -13
  18. package/dist/hono/index.js.map +1 -1
  19. package/dist/hono/mount-read-only.d.ts.map +1 -1
  20. package/dist/hono/mount-read-only.js +8 -22
  21. package/dist/hono/mount-read-only.js.map +1 -1
  22. package/dist/identity-strategy.d.ts +4 -1
  23. package/dist/identity-strategy.d.ts.map +1 -1
  24. package/dist/identity-strategy.js +1 -1
  25. package/dist/identity-strategy.js.map +1 -1
  26. package/dist/object-manager.js +6 -2
  27. package/dist/object-manager.js.map +1 -1
  28. package/dist/timestamp-wire.d.ts +17 -0
  29. package/dist/timestamp-wire.d.ts.map +1 -0
  30. package/dist/timestamp-wire.js +144 -0
  31. package/dist/timestamp-wire.js.map +1 -0
  32. package/dist/validator-runner.d.ts +7 -0
  33. package/dist/validator-runner.d.ts.map +1 -1
  34. package/dist/validator-runner.js +2 -0
  35. package/dist/validator-runner.js.map +1 -1
  36. package/package.json +3 -3
  37. package/src/drizzle-fastify/index.ts +32 -21
  38. package/src/drizzle-fastify/mount-m2m.ts +4 -1
  39. package/src/drizzle-fastify/mount-read-only.ts +8 -23
  40. package/src/drizzle-fastify/util.ts +33 -0
  41. package/src/hono/index.ts +39 -13
  42. package/src/hono/mount-read-only.ts +8 -22
  43. package/src/identity-strategy.ts +3 -2
  44. package/src/object-manager.ts +8 -3
  45. package/src/timestamp-wire.ts +152 -0
  46. package/src/validator-runner.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/runtime-ts",
3
- "version": "1.0.5-rc.7",
3
+ "version": "1.0.5-rc.9",
4
4
  "description": "Node-side runtime helpers for MetaObjects: Fastify route builders, Drizzle filter/sort integration, Kysely drivers.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -64,8 +64,8 @@
64
64
  "access": "public"
65
65
  },
66
66
  "dependencies": {
67
- "@metaobjectsdev/metadata": "1.0.5-rc.7",
68
- "@metaobjectsdev/render": "1.0.5-rc.7",
67
+ "@metaobjectsdev/metadata": "1.0.5-rc.9",
68
+ "@metaobjectsdev/render": "1.0.5-rc.9",
69
69
  "qs": "^6.13.0"
70
70
  },
71
71
  "peerDependencies": {
@@ -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
@@ -106,6 +108,24 @@ function readSource(opts: VerbOptions): AnyTable {
106
108
  return opts.readView ?? opts.table;
107
109
  }
108
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
+
109
129
  /** #214 — after a write to the base table, re-read the row THROUGH the replica
110
130
  * view by PK so the response carries derived (origin.passthrough) columns. When
111
131
  * there is no `readView`, return the write row unchanged (current behaviour). */
@@ -141,6 +161,7 @@ function routeOpts(opts: VerbOptions): RouteShorthandOptions {
141
161
  }
142
162
 
143
163
  export function mountListRoute(opts: VerbOptions): void {
164
+ const toWire = readWire(opts);
144
165
  opts.fastify.get(opts.path, routeOpts(opts), async (req, reply) => {
145
166
  try {
146
167
  // #214 — a write-through entity reads through its replica view.
@@ -196,7 +217,7 @@ export function mountListRoute(opts: VerbOptions): void {
196
217
  // drizzle-orm node-postgres query builder is thenable but has no `.all()`
197
218
  // method (that is a libsql/better-sqlite3-only API). Awaiting works on
198
219
  // both dialects and is what makes this helper genuinely Postgres-capable.
199
- const rows = await q;
220
+ const rows = (await q as unknown[]).map(toWire);
200
221
 
201
222
  if (!withCount) return rows;
202
223
 
@@ -216,6 +237,7 @@ export function mountListRoute(opts: VerbOptions): void {
216
237
  }
217
238
 
218
239
  export function mountGetRoute(opts: VerbOptions): void {
240
+ const toWire = readWire(opts);
219
241
  opts.fastify.get(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
220
242
  const { id } = req.params as { id: string };
221
243
  const discCond = discriminatorCond(opts);
@@ -228,20 +250,13 @@ export function mountGetRoute(opts: VerbOptions): void {
228
250
  return reply.code(400).send({ error: "invalid_id" });
229
251
  }
230
252
  const idCond = eq(src.id, idValue);
231
- // Await + take the first row rather than `.get()` — `.get()` is a
232
- // libsql/better-sqlite3-only method; the node-postgres builder is thenable
233
- // but has no `.get()`. Awaiting works on both dialects.
234
- const rows = await opts.db
235
- .select()
236
- .from(src)
237
- .where(discCond ? and(idCond, discCond) : idCond)
238
- .limit(1);
239
- const row = (rows as unknown[])[0];
240
- 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" });
241
255
  });
242
256
  }
243
257
 
244
258
  export function mountCreateRoute(opts: VerbOptions): void {
259
+ const toWire = echoWire(opts);
245
260
  opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => {
246
261
  const parsed = opts.insertSchema.safeParse(req.body);
247
262
  if (!parsed.success) {
@@ -265,11 +280,12 @@ export function mountCreateRoute(opts: VerbOptions): void {
265
280
  const row = (result as unknown[])[0];
266
281
  // #214 — read-your-writes: re-read through the replica view so the response
267
282
  // carries derived (origin.passthrough) columns the base table excludes.
268
- return reply.code(201).send(await reReadThroughView(opts, row));
283
+ return reply.code(201).send(toWire(await reReadThroughView(opts, row)));
269
284
  });
270
285
  }
271
286
 
272
287
  export function mountUpdateRoute(opts: VerbOptions): void {
288
+ const toWire = echoWire(opts);
273
289
  const handler = async (
274
290
  req: { params: unknown; body: unknown },
275
291
  reply: { code: (n: number) => { send: (b: unknown) => unknown } },
@@ -304,13 +320,8 @@ export function mountUpdateRoute(opts: VerbOptions): void {
304
320
  return reply.code(400).send({ error: "invalid_id" });
305
321
  }
306
322
  const noopCond = eq(src.id, noopId);
307
- const rows = await opts.db
308
- .select()
309
- .from(src)
310
- .where(discCond ? and(noopCond, discCond) : noopCond)
311
- .limit(1);
312
- const row = (rows as unknown[])[0];
313
- return row ?? reply.code(404).send({ error: "not_found" });
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" });
314
325
  }
315
326
  // Compare against the PK's real type (see mountGetRoute) — a numeric-
316
327
  // LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
@@ -334,7 +345,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
334
345
  const row = (result as unknown[])[0];
335
346
  if (row == null) return reply.code(404).send({ error: "not_found" });
336
347
  // #214 — re-read through the replica view so the response carries derived columns.
337
- return await reReadThroughView(opts, row);
348
+ return toWire(await reReadThroughView(opts, row));
338
349
  };
339
350
  const path = `${opts.path}/:id`;
340
351
  const ro = routeOpts(opts);
@@ -33,6 +33,7 @@
33
33
  import type { FastifyInstance, RouteShorthandOptions } from "fastify";
34
34
  import { and, eq, or, inArray } from "drizzle-orm";
35
35
  import { coerceIdForColumn } from "./util.js";
36
+ import { timestampWire } from "../timestamp-wire.js";
36
37
 
37
38
  // Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
38
39
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
@@ -84,6 +85,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
84
85
  const targetPk = opts.targetPkColumn ?? "id";
85
86
  const route = `${opts.path}/:id/${opts.relationName}`;
86
87
  const ro = opts.routeOptions ?? {};
88
+ const toWire = timestampWire(opts.targetTable);
87
89
 
88
90
  opts.fastify.get(route, ro, async (req, reply) => {
89
91
  const { id } = req.params as { id: string };
@@ -148,10 +150,11 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
148
150
  const pkCol = columnRef(opts.targetTable, targetPk);
149
151
  const byPk = inArray(pkCol, [...relatedIds]);
150
152
  const disc = opts.targetDiscriminator;
151
- return await opts.db
153
+ const rows = await opts.db
152
154
  .select()
153
155
  .from(opts.targetTable)
154
156
  .where(disc ? and(byPk, eq(columnRef(opts.targetTable, disc.column), disc.value)) : byPk);
157
+ return (rows as unknown[]).map(toWire);
155
158
  });
156
159
  }
157
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
 
@@ -231,7 +216,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
231
216
  colRef !== undefined ? eq(colRef, idValue) : undefined
232
217
  ).limit(1);
233
218
  const row = (rows as unknown[])[0];
234
- return row ?? reply.code(404).send({ error: "not_found" });
219
+ return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
235
220
  });
236
221
 
237
222
  // ── Mutations explicitly rejected (405) ───────────────────────────────────
@@ -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/hono/index.ts CHANGED
@@ -35,10 +35,12 @@ export type {
35
35
  FilterAllowlist,
36
36
  SortAllowlist,
37
37
  } from "../drizzle-fastify/filter-allowlist.js";
38
- import { isTruthyFlag, coerceIdForColumn } from "../drizzle-fastify/util.js";
38
+ import { isTruthyFlag, coerceIdForColumn, firstRow } from "../drizzle-fastify/util.js";
39
+ import { timestampWire } from "../timestamp-wire.js";
39
40
  // Back-compat re-export — the unsafe local copy was consolidated onto the one
40
41
  // shared (deprecated) helper so the three adapters can't silently diverge.
41
42
  export { parseId } from "../drizzle-fastify/util.js";
43
+ export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
42
44
 
43
45
  // ---------------------------------------------------------------------------
44
46
  // Loose types — we don't bind to a specific Drizzle backend so the helper
@@ -130,6 +132,24 @@ function readSource(opts: VerbOptions): AnyTable {
130
132
  return opts.readView ?? opts.table;
131
133
  }
132
134
 
135
+ /**
136
+ * Wire mapper for rows READ through `readSource` — canonicalized against
137
+ * exactly that source, since a view may shadow a table column with a
138
+ * non-timestamp of the same name.
139
+ */
140
+ function readWire(opts: VerbOptions): (row: unknown) => unknown {
141
+ return timestampWire(readSource(opts));
142
+ }
143
+
144
+ /**
145
+ * Wire mapper for a WRITE ECHO: `reReadThroughView` answers with the replica
146
+ * view's row but falls back to the table row, so the echo is canonicalized
147
+ * against the union of both sources.
148
+ */
149
+ function echoWire(opts: VerbOptions): (row: unknown) => unknown {
150
+ return timestampWire(opts.table, opts.readView);
151
+ }
152
+
133
153
  /** Re-read a just-written row through the replica view so the response carries the
134
154
  * derived columns. Writes always target the TABLE; only the echo changes. With no
135
155
  * `readView`, returns the write row unchanged. */
@@ -149,6 +169,7 @@ async function reReadThroughView(
149
169
  }
150
170
 
151
171
  export function mountListRoute(opts: VerbOptions): void {
172
+ const toWire = readWire(opts);
152
173
  opts.app.get(opts.path, async (c) => {
153
174
  try {
154
175
  const listSrc = readSource(opts);
@@ -184,7 +205,7 @@ export function mountListRoute(opts: VerbOptions): void {
184
205
  // better-sqlite3-only API). Awaiting works on BOTH dialects — this is what
185
206
  // makes the Hono helpers genuinely Postgres-capable, matching the Fastify
186
207
  // adapter, which carried this fix while Hono did not (#286).
187
- const rows = await q;
208
+ const rows = (await q as unknown[]).map(toWire);
188
209
 
189
210
  if (!withCount) return c.json(rows);
190
211
 
@@ -208,6 +229,7 @@ export function mountListRoute(opts: VerbOptions): void {
208
229
  }
209
230
 
210
231
  export function mountGetRoute(opts: VerbOptions): void {
232
+ const toWire = readWire(opts);
211
233
  opts.app.get(`${opts.path}/:id`, async (c) => {
212
234
  const id = c.req.param("id") ?? "";
213
235
  // Compare against the PK's real type — a numeric-LOOKING id on a TEXT pk
@@ -215,19 +237,13 @@ export function mountGetRoute(opts: VerbOptions): void {
215
237
  const getSrc = readSource(opts);
216
238
  const idValue = coerceIdForColumn(getSrc.id, id);
217
239
  if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
218
- // `.get()` is likewise libsql/better-sqlite3-only; `.limit(1)` + await + [0]
219
- // is the portable single-row read (#286).
220
- const rows = await opts.db
221
- .select()
222
- .from(getSrc)
223
- .where(eq(getSrc.id, idValue))
224
- .limit(1);
225
- const row = (rows as unknown[])[0];
226
- return row ? c.json(row) : c.json({ error: "not_found" }, 404);
240
+ const row = await firstRow(opts.db, getSrc, eq(getSrc.id, idValue));
241
+ return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
227
242
  });
228
243
  }
229
244
 
230
245
  export function mountCreateRoute(opts: VerbOptions): void {
246
+ const toWire = echoWire(opts);
231
247
  opts.app.post(opts.path, async (c) => {
232
248
  const body = await c.req.json().catch(() => undefined);
233
249
  const parsed = opts.insertSchema.safeParse(body);
@@ -247,11 +263,12 @@ export function mountCreateRoute(opts: VerbOptions): void {
247
263
  }
248
264
  const row = (result as unknown[])[0];
249
265
  // Echo the row through the replica view so derived columns are present (#214).
250
- return c.json(await reReadThroughView(opts, row), 201);
266
+ return c.json(toWire(await reReadThroughView(opts, row)), 201);
251
267
  });
252
268
  }
253
269
 
254
270
  export function mountUpdateRoute(opts: VerbOptions): void {
271
+ const toWire = echoWire(opts);
255
272
  const handler = async (c: Context) => {
256
273
  const id = c.req.param("id") ?? "";
257
274
  const body = await c.req.json().catch(() => undefined);
@@ -259,6 +276,15 @@ export function mountUpdateRoute(opts: VerbOptions): void {
259
276
  if (!parsed.success) {
260
277
  return c.json({ error: "validation", issues: parsed.error.issues }, 400);
261
278
  }
279
+ // An empty patch is a no-op update, answered as a read — the Fastify mount's
280
+ // rule; its block in drizzle-fastify/index.ts carries the full rationale.
281
+ if (Object.keys(parsed.data as Record<string, unknown>).length === 0) {
282
+ const src = readSource(opts);
283
+ const noopId = coerceIdForColumn(src.id, id);
284
+ if (noopId === undefined) return c.json({ error: "invalid_id" }, 400);
285
+ const row = await firstRow(opts.db, src, eq(src.id, noopId));
286
+ return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
287
+ }
262
288
  // Compare against the PK's real type (see mountGetRoute) — a numeric-
263
289
  // LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
264
290
  const idValue = coerceIdForColumn(opts.table.id, id);
@@ -276,7 +302,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
276
302
  return c.json(f.body, f.status as 400 | 409);
277
303
  }
278
304
  const row = (result as unknown[])[0];
279
- return row ? c.json(await reReadThroughView(opts, row)) : c.json({ error: "not_found" }, 404);
305
+ return row ? c.json(toWire(await reReadThroughView(opts, row))) : c.json({ error: "not_found" }, 404);
280
306
  };
281
307
  const path = `${opts.path}/:id`;
282
308
  // Cross-port REST contract (FR-008): the update verb is reachable via BOTH PATCH
@@ -10,20 +10,14 @@ import type {
10
10
  FilterAllowlist,
11
11
  SortAllowlist,
12
12
  } from "../drizzle-fastify/filter-allowlist.js";
13
- import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode } from "../drizzle-fastify/util.js";
13
+ import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode, viewBaseConfig } from "../drizzle-fastify/util.js";
14
+ import { timestampWire } from "../timestamp-wire.js";
14
15
 
15
16
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
16
17
  type AnyView = any;
17
18
  // biome-ignore lint/suspicious/noExplicitAny: generic Hono app
18
19
  type AnyHono = Hono<any, any, any>;
19
20
 
20
- /**
21
- * Drizzle v0.45 stores view config under this well-known Symbol. Mirrors
22
- * the same workaround the Fastify mount uses — accessing `view._` on a
23
- * proxy-wrapped view throws.
24
- */
25
- const VIEW_BASE_CONFIG = Symbol.for("drizzle:ViewBaseConfig");
26
-
27
21
  export interface MountReadOnlyOptions {
28
22
  readonly app: AnyHono;
29
23
  readonly path: string;
@@ -37,18 +31,8 @@ export interface MountReadOnlyOptions {
37
31
  readonly idColumn?: string;
38
32
  }
39
33
 
40
- function getViewConfig(view: AnyView): Record<string, unknown> | undefined {
41
- try {
42
- const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
43
- if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
44
- } catch {
45
- // ignore — proxy handler may throw on unexpected shapes
46
- }
47
- return undefined;
48
- }
49
-
50
34
  function resolveViewName(view: AnyView): string | undefined {
51
- const cfg = getViewConfig(view);
35
+ const cfg = viewBaseConfig(view);
52
36
  if (cfg) {
53
37
  if (typeof cfg["name"] === "string") return cfg["name"] as string;
54
38
  }
@@ -60,7 +44,7 @@ function resolveViewName(view: AnyView): string | undefined {
60
44
  }
61
45
 
62
46
  function isEmptyColumnView(view: AnyView): boolean {
63
- const cfg = getViewConfig(view);
47
+ const cfg = viewBaseConfig(view);
64
48
  if (cfg) {
65
49
  const fields = cfg["selectedFields"] as Record<string, unknown> | undefined;
66
50
  return fields !== undefined && Object.keys(fields).length === 0;
@@ -119,6 +103,8 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
119
103
 
120
104
  const viewName = resolveViewName(view);
121
105
  const useRawSql = isEmptyColumnView(view) && !!viewName;
106
+ // The raw-SQL branch has no declared columns, so nothing names a timestamp there.
107
+ const toWire = timestampWire(view);
122
108
 
123
109
  // ── List ──────────────────────────────────────────────────────────────────
124
110
  app.get(path, async (c) => {
@@ -172,7 +158,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
172
158
  // better-sqlite3-only API). Awaiting works on BOTH dialects — this is what
173
159
  // makes the Hono helpers genuinely Postgres-capable, matching the Fastify
174
160
  // adapter, which carried this fix while Hono did not (#286).
175
- const rows = await q;
161
+ const rows = (await q as unknown[]).map(toWire);
176
162
 
177
163
  if (!withCount) return c.json(rows);
178
164
 
@@ -215,7 +201,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
215
201
  .where(colRef !== undefined ? eq(colRef, idValue) : undefined)
216
202
  .limit(1);
217
203
  const row = (rows as unknown[])[0];
218
- return row ? c.json(row) : c.json({ error: "not_found" }, 404);
204
+ return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
219
205
  });
220
206
 
221
207
  // ── Mutations explicitly rejected (405) ───────────────────────────────────
@@ -13,7 +13,8 @@ import {
13
13
  import { MetadataError, ValidationError } from "./errors.js";
14
14
 
15
15
  export type IdentityResolution =
16
- | { kind: "driver-generated"; values: Record<string, unknown> }
16
+ /** `fields` are the PK fields the store fills on insert — absent from `values` by design. */
17
+ | { kind: "driver-generated"; values: Record<string, unknown>; fields: readonly string[] }
17
18
  | { kind: "preset"; values: Record<string, unknown> };
18
19
 
19
20
  export function resolveIdentity(entity: MetaData, data: Record<string, unknown>): IdentityResolution {
@@ -67,7 +68,7 @@ export function resolveIdentity(entity: MetaData, data: Record<string, unknown>)
67
68
  for (const f of pkFields) values[f] = data[f];
68
69
  return { kind: "preset", values };
69
70
  }
70
- return { kind: "driver-generated", values: {} };
71
+ return { kind: "driver-generated", values: {}, fields: pkFields };
71
72
  }
72
73
 
73
74
  if (generation === GENERATION_UUID) {
@@ -21,7 +21,7 @@ import {
21
21
  import { coerceRowOnRead, coerceRowOnWrite } from "./type-coercer.js";
22
22
  import { decodeRef, encodeRef } from "./ref-codec.js";
23
23
  import { runValidators } from "./validator-runner.js";
24
- import { resolveIdentity } from "./identity-strategy.js";
24
+ import { resolveIdentity, type IdentityResolution } from "./identity-strategy.js";
25
25
  import {
26
26
  viewFieldNames,
27
27
  fieldViewSpec, entityViewSpec,
@@ -185,7 +185,7 @@ export class ObjectManager {
185
185
  const ident = resolveIdentity(entity, restricted);
186
186
  const merged: Row = { ...restricted, ...ident.values };
187
187
 
188
- const validation = runValidators(entity, merged);
188
+ const validation = runValidators(entity, merged, storeFilledOpts(ident));
189
189
  if (!validation.ok) {
190
190
  throw new ValidationError(formatValidationMessage(entityName, validation.errors), { entity: entityName, errors: validation.errors });
191
191
  }
@@ -252,7 +252,7 @@ export class ObjectManager {
252
252
  const restricted = this.applyViewRestriction(entity, data, opts.view);
253
253
  const ident = resolveIdentity(entity, restricted);
254
254
  const merged: Row = { ...restricted, ...ident.values };
255
- const v = runValidators(entity, merged);
255
+ const v = runValidators(entity, merged, storeFilledOpts(ident));
256
256
  if (!v.ok) {
257
257
  throw new ValidationError(formatValidationMessage(entityName, v.errors), { entity: entityName, errors: v.errors });
258
258
  }
@@ -502,6 +502,11 @@ function requireNonEmptyFilter(
502
502
  return where;
503
503
  }
504
504
 
505
+ /** A driver-generated key is filled by the store on insert, so an absent one is not a required failure. */
506
+ function storeFilledOpts(ident: IdentityResolution): { storeFilled?: readonly string[] } {
507
+ return ident.kind === "driver-generated" ? { storeFilled: ident.fields } : {};
508
+ }
509
+
505
510
  function formatValidationMessage(entityName: string, errors: { field: string; rule: string }[]): string {
506
511
  const summary = errors.map((e) => `${e.field}: ${e.rule}`).join("; ");
507
512
  return `Validation failed for ${entityName} (${summary})`;