@metaobjectsdev/runtime-ts 1.0.5-rc.1 → 1.0.5-rc.11

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
package/src/hono/index.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  // GET {path} list with ?filter / ?sort / ?limit / ?offset / ?withCount=1
8
8
  // GET {path}/:id findById, 404 if missing
9
9
  // POST {path} create, 400 on Zod validation error, 201 on success
10
- // PATCH {path}/:id update (default), 400 / 404 envelopes
11
- // PUT {path}/:id update (when updateMethod === "put")
10
+ // PATCH {path}/:id update, 400 / 404 envelopes
11
+ // PUT {path}/:id update — the same handler (restrict with updateMethod)
12
12
  // DELETE {path}/:id delete, 204 on success, 404 if missing
13
13
  //
14
14
  // Filter / sort / withCount semantics are shared with drizzle-fastify via
@@ -21,6 +21,7 @@ import { classifyConstraintError, RedactedDatabaseError, logAndRedact } from "..
21
21
  import type { ZodTypeAny } from "zod";
22
22
  import { eq, count, and } from "drizzle-orm";
23
23
  import qs from "qs";
24
+ import { contractErrorCode } from "../drizzle-fastify/util.js";
24
25
  import {
25
26
  parseFilterParams,
26
27
  FilterParseError,
@@ -34,10 +35,12 @@ export type {
34
35
  FilterAllowlist,
35
36
  SortAllowlist,
36
37
  } from "../drizzle-fastify/filter-allowlist.js";
37
- 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";
38
40
  // Back-compat re-export — the unsafe local copy was consolidated onto the one
39
41
  // shared (deprecated) helper so the three adapters can't silently diverge.
40
42
  export { parseId } from "../drizzle-fastify/util.js";
43
+ export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
41
44
 
42
45
  // ---------------------------------------------------------------------------
43
46
  // Loose types — we don't bind to a specific Drizzle backend so the helper
@@ -82,8 +85,9 @@ export interface CrudRoutesOptions {
82
85
  /** Limit which verbs are mounted. Defaults to all five. */
83
86
  expose?: readonly CrudVerb[];
84
87
  /**
85
- * HTTP method for the update verb. Defaults to "patch". Set to "put" to
86
- * preserve a legacy API contract that already uses PUT for updates.
88
+ * Restrict the update verb to ONE HTTP method. Absent (the default) mounts
89
+ * the update handler on both PATCH and PUT, as the cross-port REST contract
90
+ * requires.
87
91
  */
88
92
  updateMethod?: "patch" | "put";
89
93
  filterAllowlist?: FilterAllowlist;
@@ -128,6 +132,24 @@ function readSource(opts: VerbOptions): AnyTable {
128
132
  return opts.readView ?? opts.table;
129
133
  }
130
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
+
131
153
  /** Re-read a just-written row through the replica view so the response carries the
132
154
  * derived columns. Writes always target the TABLE; only the echo changes. With no
133
155
  * `readView`, returns the write row unchanged. */
@@ -147,6 +169,7 @@ async function reReadThroughView(
147
169
  }
148
170
 
149
171
  export function mountListRoute(opts: VerbOptions): void {
172
+ const toWire = readWire(opts);
150
173
  opts.app.get(opts.path, async (c) => {
151
174
  try {
152
175
  const listSrc = readSource(opts);
@@ -182,7 +205,7 @@ export function mountListRoute(opts: VerbOptions): void {
182
205
  // better-sqlite3-only API). Awaiting works on BOTH dialects — this is what
183
206
  // makes the Hono helpers genuinely Postgres-capable, matching the Fastify
184
207
  // adapter, which carried this fix while Hono did not (#286).
185
- const rows = await q;
208
+ const rows = (await q as unknown[]).map(toWire);
186
209
 
187
210
  if (!withCount) return c.json(rows);
188
211
 
@@ -194,7 +217,11 @@ export function mountListRoute(opts: VerbOptions): void {
194
217
  return c.json({ rows, total });
195
218
  } catch (err) {
196
219
  if (err instanceof FilterParseError) {
197
- return c.json({ error: err.code, ...(err.details ?? {}) }, 400);
220
+ // Map to the cross-port wire code, exactly as the Fastify mount does. Emitting
221
+ // the INTERNAL dotted code here made the two flavors answer differently for the
222
+ // same metadata (`filter.unknown_field` vs `invalid_filter_field`), contradicting
223
+ // this package's own "byte-identical on the wire" contract.
224
+ return c.json({ error: contractErrorCode(err.code), ...(err.details ?? {}) }, 400);
198
225
  }
199
226
  throw err;
200
227
  }
@@ -202,6 +229,7 @@ export function mountListRoute(opts: VerbOptions): void {
202
229
  }
203
230
 
204
231
  export function mountGetRoute(opts: VerbOptions): void {
232
+ const toWire = readWire(opts);
205
233
  opts.app.get(`${opts.path}/:id`, async (c) => {
206
234
  const id = c.req.param("id") ?? "";
207
235
  // Compare against the PK's real type — a numeric-LOOKING id on a TEXT pk
@@ -209,19 +237,13 @@ export function mountGetRoute(opts: VerbOptions): void {
209
237
  const getSrc = readSource(opts);
210
238
  const idValue = coerceIdForColumn(getSrc.id, id);
211
239
  if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
212
- // `.get()` is likewise libsql/better-sqlite3-only; `.limit(1)` + await + [0]
213
- // is the portable single-row read (#286).
214
- const rows = await opts.db
215
- .select()
216
- .from(getSrc)
217
- .where(eq(getSrc.id, idValue))
218
- .limit(1);
219
- const row = (rows as unknown[])[0];
220
- 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);
221
242
  });
222
243
  }
223
244
 
224
245
  export function mountCreateRoute(opts: VerbOptions): void {
246
+ const toWire = echoWire(opts);
225
247
  opts.app.post(opts.path, async (c) => {
226
248
  const body = await c.req.json().catch(() => undefined);
227
249
  const parsed = opts.insertSchema.safeParse(body);
@@ -241,11 +263,12 @@ export function mountCreateRoute(opts: VerbOptions): void {
241
263
  }
242
264
  const row = (result as unknown[])[0];
243
265
  // Echo the row through the replica view so derived columns are present (#214).
244
- return c.json(await reReadThroughView(opts, row), 201);
266
+ return c.json(toWire(await reReadThroughView(opts, row)), 201);
245
267
  });
246
268
  }
247
269
 
248
270
  export function mountUpdateRoute(opts: VerbOptions): void {
271
+ const toWire = echoWire(opts);
249
272
  const handler = async (c: Context) => {
250
273
  const id = c.req.param("id") ?? "";
251
274
  const body = await c.req.json().catch(() => undefined);
@@ -253,6 +276,15 @@ export function mountUpdateRoute(opts: VerbOptions): void {
253
276
  if (!parsed.success) {
254
277
  return c.json({ error: "validation", issues: parsed.error.issues }, 400);
255
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
+ }
256
288
  // Compare against the PK's real type (see mountGetRoute) — a numeric-
257
289
  // LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
258
290
  const idValue = coerceIdForColumn(opts.table.id, id);
@@ -270,13 +302,20 @@ export function mountUpdateRoute(opts: VerbOptions): void {
270
302
  return c.json(f.body, f.status as 400 | 409);
271
303
  }
272
304
  const row = (result as unknown[])[0];
273
- 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);
274
306
  };
275
307
  const path = `${opts.path}/:id`;
308
+ // Cross-port REST contract (FR-008): the update verb is reachable via BOTH PATCH
309
+ // and PUT, each routed to the same handler — as the drizzle-fastify flavor and
310
+ // every other port's controller do. `updateMethod` stays an explicit single-verb
311
+ // override for a consumer that wants to restrict the surface.
276
312
  if (opts.updateMethod === "put") {
277
313
  opts.app.put(path, handler);
314
+ } else if (opts.updateMethod === "patch") {
315
+ opts.app.patch(path, handler);
278
316
  } else {
279
317
  opts.app.patch(path, handler);
318
+ opts.app.put(path, handler);
280
319
  }
281
320
  }
282
321
 
@@ -320,3 +359,30 @@ function extractRowCount(result: unknown): number {
320
359
  }
321
360
 
322
361
  export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Metadata endpoint
365
+ // ---------------------------------------------------------------------------
366
+
367
+ import type { MetaData } from "@metaobjectsdev/metadata";
368
+ import { META_ROUTE_PATH, META_CONTENT_TYPE, metaJson } from "../meta-endpoint.js";
369
+
370
+ export interface HonoMetaRouteOptions {
371
+ app: AnyHono;
372
+ root: MetaData;
373
+ prefix?: string;
374
+ }
375
+
376
+ /**
377
+ * Mount `GET {prefix}/_meta` on Hono. Same contract and same opt-in caveat as
378
+ * the Fastify twin; guard the mount path with middleware before calling this:
379
+ *
380
+ * app.use(`/api${META_ROUTE_PATH}`, requireAuth);
381
+ * mountMetaRouteHono({ app, root, prefix: "/api" });
382
+ */
383
+ export function mountMetaRouteHono(opts: HonoMetaRouteOptions): void {
384
+ const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
385
+ opts.app.get(path, (c) =>
386
+ c.body(metaJson(opts.root), 200, { "content-type": META_CONTENT_TYPE }),
387
+ );
388
+ }
@@ -1,5 +1,5 @@
1
1
  // Hono read-only mount — projection (view-backed) entities. GET list +
2
- // GET :id only. POST/PATCH/DELETE return 405. Mirrors the drizzle-fastify
2
+ // GET :id only. POST/PATCH/PUT/DELETE return 405. Mirrors the drizzle-fastify
3
3
  // equivalent so the cross-port API contract holds for projection endpoints.
4
4
 
5
5
  import type { Hono } from "hono";
@@ -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 } 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
 
@@ -183,7 +169,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
183
169
  return c.json({ rows, total });
184
170
  } catch (err) {
185
171
  if (err instanceof FilterParseError) {
186
- return c.json({ error: err.code, message: err.message }, 400);
172
+ // Wire code + the details (so the cross-port-required `field` is present),
173
+ // matching the Fastify projection mount.
174
+ return c.json({ error: contractErrorCode(err.code), message: err.message, ...(err.details ?? {}) }, 400);
187
175
  }
188
176
  throw err;
189
177
  }
@@ -213,7 +201,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
213
201
  .where(colRef !== undefined ? eq(colRef, idValue) : undefined)
214
202
  .limit(1);
215
203
  const row = (rows as unknown[])[0];
216
- return row ? c.json(row) : c.json({ error: "not_found" }, 404);
204
+ return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
217
205
  });
218
206
 
219
207
  // ── Mutations explicitly rejected (405) ───────────────────────────────────
@@ -226,6 +214,10 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
226
214
  app.post(path, reject as any);
227
215
  // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
228
216
  app.patch(`${path}/:id`, reject as any);
217
+ // PUT too — the writable mount serves it, so a projection must reject it rather
218
+ // than 404, which would deny a resource that answers GET on the same path.
219
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
220
+ app.put(`${path}/:id`, reject as any);
229
221
  // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
230
222
  app.delete(`${path}/:id`, reject as any);
231
223
  }
@@ -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) {
package/src/index.ts CHANGED
@@ -48,3 +48,6 @@ export {
48
48
  type ConstraintKind,
49
49
  type ConstraintFailure,
50
50
  } from "./constraint-errors.js";
51
+
52
+ // UI-1 — metadata API contract for the browser read-model.
53
+ export { META_ROUTE_PATH, META_CONTENT_TYPE, metaJson } from "./meta-endpoint.js";
@@ -0,0 +1,35 @@
1
+ // UI-1 — the metadata API contract. Framework-free on purpose: only TypeScript
2
+ // has a web-bound runtime home, so every other port ships this function and lets
3
+ // the host mount it. The durable deliverable is the path + the serialization.
4
+ import { canonicalSerializeEffective, type MetaData } from "@metaobjectsdev/metadata";
5
+
6
+ /**
7
+ * The metadata endpoint's path, mounted under the host's `apiPrefix`.
8
+ * A cross-port contract value — one browser read-model has to work against
9
+ * every backend, so this string is not per-port configurable.
10
+ */
11
+ export const META_ROUTE_PATH = "/_meta";
12
+
13
+ /**
14
+ * The `GET {apiPrefix}/_meta` response content-type header.
15
+ * A TypeScript-local detail, not a cross-port contract: only this port ships
16
+ * HTTP mounts, so this value only has to stay byte-identical across TypeScript's
17
+ * own `mountMetaRoute` (Fastify) and `mountMetaRouteHono` (Hono). Another port's
18
+ * hand-mounted route is free to emit a different content-type
19
+ * (see docs/features/metadata-api.md).
20
+ */
21
+ export const META_CONTENT_TYPE = "application/json; charset=utf-8";
22
+
23
+ /**
24
+ * The `GET {apiPrefix}/_meta` response body: the loaded model as EFFECTIVE
25
+ * canonical JSON.
26
+ *
27
+ * Effective, not raw (spec §3.2): the effective form materializes the
28
+ * super-chain merge, so a browser reading it never resolves `extends` itself.
29
+ * Per ADR-0039 an own-vs-resolving mistake there would silently drop inherited
30
+ * `@columns` / `@pageSize` / `@sortableDefaultOrder` — exactly the attrs a
31
+ * runtime grid reads — and read as "unset" rather than failing.
32
+ */
33
+ export function metaJson(root: MetaData): string {
34
+ return canonicalSerializeEffective(root);
35
+ }
@@ -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})`;
@@ -0,0 +1,152 @@
1
+ // The wire spelling of a field.timestamp, applied where the Drizzle-direct
2
+ // mounts SEND rows.
3
+ //
4
+ // The spelling is the contract file's, not a comment's:
5
+ // `fixtures/persistence-conformance/normalization.md` says what a tz-aware and
6
+ // an `@localTime` field.timestamp look like on the wire (and
7
+ // `docs/features/api-contract.md` defers to it); this module implements it.
8
+ //
9
+ // Nothing upstream of here produces that. Codegen maps a Postgres field.timestamp
10
+ // to `timestamp(…, { mode: "string" })`, and in string mode Drizzle returns the
11
+ // text node-postgres received — Postgres' OUTPUT format, `2026-09-20 12:00:00+00`,
12
+ // rendered in whatever time zone the SESSION runs in. A strict ISO 8601 parser
13
+ // rejects the space, and the other four ports answer the same row as
14
+ // `2026-09-20T12:00:00Z`. Under `timestampMode: "date"` the column yields a Date,
15
+ // whose JSON form pads `.000`. Both are canonicalized here, at the HTTP boundary
16
+ // (ADR-0019's place for wire canonicalization), so the generated routes — which all
17
+ // delegate to these mounts — answer correctly without a regen.
18
+ //
19
+ // Two doors do NOT pass through here. The ObjectManager-driven `./fastify` mount
20
+ // sends what ObjectManager returns and no generated route uses it. A hand-written
21
+ // route that sends a row itself is its author's to map, through `timestampWire`
22
+ // (the codegen comment at the `mode: "string"` decision says so).
23
+ // `packages/integration-tests/src/temporal-parsers.ts` implements the same spelling
24
+ // for the persistence-conformance runner; a change to the rules here must be checked
25
+ // against it.
26
+ //
27
+ // Which keys are timestamps comes from the Drizzle column objects themselves
28
+ // (`columnType` + `withTimezone`), never from the shape of a value: a text column
29
+ // holding timestamp-shaped text is data. A dialect whose columns do not say so —
30
+ // SQLite stores a field.timestamp in a plain `text()` column — is left as it is.
31
+
32
+ import { viewBaseConfig } from "./drizzle-fastify/util.js";
33
+
34
+ // biome-ignore lint/suspicious/noExplicitAny: a Drizzle table or view, structurally typed
35
+ type AnySource = any;
36
+
37
+ /** The symbol drizzle-orm itself reads for a table's columns — a `Symbol.for` read is
38
+ * cross-copy-safe, so this works with two physical copies of drizzle-orm loaded. */
39
+ const DRIZZLE_COLUMNS = Symbol.for("drizzle:Columns");
40
+
41
+ /** pg-core's two timestamp column classes: `mode: "string"` and `mode: "date"`. */
42
+ const TIMESTAMP_COLUMN_TYPES: ReadonlySet<string> = new Set(["PgTimestampString", "PgTimestamp"]);
43
+
44
+ /** `YYYY-MM-DD[ T]HH:MM:SS[.f…][Z|±HH[:MM[:SS]]]` — Postgres' output format and ISO 8601 both. */
45
+ const TIMESTAMP_TEXT =
46
+ /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|([+-])(\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?$/;
47
+
48
+ const pad2 = (n: number): string => n.toString().padStart(2, "0");
49
+
50
+ /** ".fff" with trailing zeros stripped; "" when the millisecond count is zero. */
51
+ function msSuffix(ms: number): string {
52
+ return ms === 0 ? "" : `.${ms.toString().padStart(3, "0").replace(/0+$/, "")}`;
53
+ }
54
+
55
+ function format(
56
+ y: number, mo: number, d: number, h: number, mi: number, s: number, ms: number, withTimezone: boolean,
57
+ ): string {
58
+ return `${y.toString().padStart(4, "0")}-${pad2(mo)}-${pad2(d)}` +
59
+ `T${pad2(h)}:${pad2(mi)}:${pad2(s)}${msSuffix(ms)}${withTimezone ? "Z" : ""}`;
60
+ }
61
+
62
+ function formatUtcFields(date: Date, withTimezone: boolean): string | undefined {
63
+ const y = date.getUTCFullYear();
64
+ // NaN is an Invalid Date's year; out-of-range years have no `YYYY` spelling.
65
+ if (!Number.isInteger(y) || y < 0 || y > 9999) return undefined;
66
+ return format(
67
+ y, date.getUTCMonth() + 1, date.getUTCDate(),
68
+ date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds(),
69
+ withTimezone,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * One timestamp value in its canonical wire spelling. `withTimezone` is the column's:
75
+ * true for a default (instant) field.timestamp, false for `@localTime`. Anything it
76
+ * cannot read — `infinity`, a BC date, a non-string, an `@localTime` value that carries
77
+ * an offset — is returned unchanged.
78
+ */
79
+ export function canonicalTimestamp(value: unknown, withTimezone: boolean): unknown {
80
+ if (value instanceof Date) {
81
+ // Drizzle's PgTimestamp builds a naive value's Date by appending +0000, so the UTC
82
+ // fields ARE the wall clock for @localTime as well as the instant for tz-aware.
83
+ // An Invalid Date fails formatUtcFields' integer-year guard and passes through.
84
+ return formatUtcFields(value, withTimezone) ?? value;
85
+ }
86
+ if (typeof value !== "string") return value;
87
+ const m = TIMESTAMP_TEXT.exec(value);
88
+ if (m === null) return value;
89
+ const [, y, mo, d, h, mi, s, frac, zone, sign, oh, om, os] = m;
90
+ // Truncate to milliseconds (Postgres keeps microseconds; the contract is ms).
91
+ const ms = frac === undefined ? 0 : Number.parseInt(frac.slice(0, 3).padEnd(3, "0"), 10);
92
+ const fields = [y, mo, d, h, mi, s].map(Number) as [number, number, number, number, number, number];
93
+ // An @localTime value is a wall clock. One that carries an offset names two candidate
94
+ // wall clocks, and dropping the offset would silently pick one, so it passes through.
95
+ if (!withTimezone) return zone === undefined ? format(...fields, ms, false) : value;
96
+ // No offset to apply: a value already in UTC, or one with no zone at all (read as UTC —
97
+ // the only reading that means the same instant on every host).
98
+ if (zone === undefined || zone === "Z") return format(...fields, ms, true);
99
+ // Date.parse on an explicit four-digit ISO year avoids Date.UTC's 0–99 → 19xx mapping.
100
+ const local = Date.parse(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`);
101
+ if (Number.isNaN(local)) return value;
102
+ const offsetSeconds = (Number(oh) * 3600 + Number(om ?? 0) * 60 + Number(os ?? 0)) * (sign === "-" ? -1 : 1);
103
+ const utc = formatUtcFields(new Date(local - offsetSeconds * 1000 + ms), true);
104
+ return utc ?? value;
105
+ }
106
+
107
+ /** The Drizzle column objects of a table or view, keyed by the JS property a row uses. */
108
+ function columnsOf(source: AnySource): Record<string, unknown> {
109
+ if (source === undefined || source === null) return {};
110
+ try {
111
+ const cols = source[DRIZZLE_COLUMNS];
112
+ if (cols && typeof cols === "object") return cols as Record<string, unknown>;
113
+ } catch {
114
+ // A view is a proxy whose handler may throw on an unexpected key — try the view shape.
115
+ }
116
+ const fields = viewBaseConfig(source)?.["selectedFields"];
117
+ if (fields && typeof fields === "object") return fields as Record<string, unknown>;
118
+ return {};
119
+ }
120
+
121
+ /** Returned for a source with no timestamp columns — rows pass through as they are. */
122
+ const IDENTITY: (row: unknown) => unknown = (row) => row;
123
+
124
+ /**
125
+ * A function that returns a row with every timestamp column in its wire spelling. The
126
+ * keys are read once, from the Drizzle `sources` the rows were selected from (several
127
+ * when a response can come from either, as a write-through entity's does). A source
128
+ * with no timestamp columns yields the identity, so an untouched row costs nothing.
129
+ */
130
+ export function timestampWire(...sources: AnySource[]): (row: unknown) => unknown {
131
+ const keys = new Map<string, boolean>();
132
+ for (const source of sources) {
133
+ for (const [key, col] of Object.entries(columnsOf(source))) {
134
+ const c = col as { columnType?: unknown; withTimezone?: unknown } | null;
135
+ if (c && typeof c.columnType === "string" && TIMESTAMP_COLUMN_TYPES.has(c.columnType)) {
136
+ keys.set(key, c.withTimezone === true);
137
+ }
138
+ }
139
+ }
140
+ if (keys.size === 0) return IDENTITY;
141
+ // Freeze the mount-time decision into a plain array; the row closure runs once
142
+ // per row per request and iterating an array beats re-walking the Map.
143
+ const columns: readonly [string, boolean][] = [...keys];
144
+ return (row) => {
145
+ if (row === null || typeof row !== "object") return row;
146
+ const out: Record<string, unknown> = { ...(row as Record<string, unknown>) };
147
+ for (const [key, withTimezone] of columns) {
148
+ if (key in out) out[key] = canonicalTimestamp(out[key], withTimezone);
149
+ }
150
+ return out;
151
+ };
152
+ }
@@ -41,6 +41,13 @@ const INT64_STRING_RE = /^-?\d+$/;
41
41
  export interface RunValidatorsOpts {
42
42
  /** Partial-update mode: required-checks only fire for fields whose key is present in `data`. */
43
43
  partial?: boolean;
44
+ /**
45
+ * Fields the store fills on insert — a driver-generated primary key. Exempt from
46
+ * required-on-insert when ABSENT, exactly like a `@default` column: `@required` on a
47
+ * server-generated key is a true statement about the ROW, not a demand on the caller.
48
+ * A present null is still a required failure.
49
+ */
50
+ storeFilled?: readonly string[];
44
51
  }
45
52
 
46
53
  export function runValidators(
@@ -70,6 +77,7 @@ export function runValidators(
70
77
  // EXPLICIT present null — nulling a required field is a deliberate clear the
71
78
  // default cannot cover (FR-035 PATCH-2: present-null on @required → error).
72
79
  if (hasDefault && !present) continue;
80
+ if (!present && opts.storeFilled?.includes(field.name)) continue;
73
81
  errors.push({
74
82
  field: field.name,
75
83
  rule: "required",