@metaobjectsdev/runtime-ts 1.0.13 → 1.1.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/decimal-wire.d.ts +54 -0
  2. package/dist/decimal-wire.d.ts.map +1 -0
  3. package/dist/decimal-wire.js +158 -0
  4. package/dist/decimal-wire.js.map +1 -0
  5. package/dist/drizzle-fastify/filter-allowlist.d.ts +4 -2
  6. package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -1
  7. package/dist/drizzle-fastify/filter-allowlist.js +2 -1
  8. package/dist/drizzle-fastify/filter-allowlist.js.map +1 -1
  9. package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
  10. package/dist/drizzle-fastify/filter-parser.js +23 -9
  11. package/dist/drizzle-fastify/filter-parser.js.map +1 -1
  12. package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -1
  13. package/dist/drizzle-fastify/filter-value-format.js +4 -1
  14. package/dist/drizzle-fastify/filter-value-format.js.map +1 -1
  15. package/dist/drizzle-fastify/index.d.ts +8 -0
  16. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  17. package/dist/drizzle-fastify/index.js +4 -2
  18. package/dist/drizzle-fastify/index.js.map +1 -1
  19. package/dist/drizzle-fastify/mount-m2m.d.ts +2 -0
  20. package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
  21. package/dist/drizzle-fastify/mount-m2m.js +2 -1
  22. package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
  23. package/dist/drizzle-fastify/mount-read-only.d.ts +25 -1
  24. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
  25. package/dist/drizzle-fastify/mount-read-only.js +43 -28
  26. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  27. package/dist/extract-object.js +5 -7
  28. package/dist/extract-object.js.map +1 -1
  29. package/dist/hono/index.d.ts +8 -0
  30. package/dist/hono/index.d.ts.map +1 -1
  31. package/dist/hono/index.js +4 -2
  32. package/dist/hono/index.js.map +1 -1
  33. package/dist/hono/mount-read-only.d.ts +25 -1
  34. package/dist/hono/mount-read-only.d.ts.map +1 -1
  35. package/dist/hono/mount-read-only.js +51 -36
  36. package/dist/hono/mount-read-only.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/n2m-resolver.d.ts.map +1 -1
  42. package/dist/n2m-resolver.js +4 -3
  43. package/dist/n2m-resolver.js.map +1 -1
  44. package/dist/net-format.d.ts +8 -0
  45. package/dist/net-format.d.ts.map +1 -0
  46. package/dist/net-format.js +34 -0
  47. package/dist/net-format.js.map +1 -0
  48. package/dist/object-manager.d.ts +20 -7
  49. package/dist/object-manager.d.ts.map +1 -1
  50. package/dist/object-manager.js +68 -13
  51. package/dist/object-manager.js.map +1 -1
  52. package/dist/query-builder.d.ts.map +1 -1
  53. package/dist/query-builder.js +11 -2
  54. package/dist/query-builder.js.map +1 -1
  55. package/dist/relation-resolver.d.ts.map +1 -1
  56. package/dist/relation-resolver.js +11 -11
  57. package/dist/relation-resolver.js.map +1 -1
  58. package/dist/type-coercer.d.ts +1 -1
  59. package/dist/type-coercer.d.ts.map +1 -1
  60. package/dist/type-coercer.js +114 -5
  61. package/dist/type-coercer.js.map +1 -1
  62. package/dist/validator-runner.d.ts.map +1 -1
  63. package/dist/validator-runner.js +192 -35
  64. package/dist/validator-runner.js.map +1 -1
  65. package/package.json +3 -3
  66. package/src/decimal-wire.ts +154 -0
  67. package/src/drizzle-fastify/filter-allowlist.ts +5 -1
  68. package/src/drizzle-fastify/filter-parser.ts +22 -9
  69. package/src/drizzle-fastify/filter-value-format.ts +4 -0
  70. package/src/drizzle-fastify/index.ts +11 -2
  71. package/src/drizzle-fastify/mount-m2m.ts +4 -1
  72. package/src/drizzle-fastify/mount-read-only.ts +68 -31
  73. package/src/extract-object.ts +5 -7
  74. package/src/hono/index.ts +11 -2
  75. package/src/hono/mount-read-only.ts +76 -37
  76. package/src/index.ts +2 -1
  77. package/src/n2m-resolver.ts +4 -2
  78. package/src/net-format.ts +37 -0
  79. package/src/object-manager.ts +80 -18
  80. package/src/query-builder.ts +14 -1
  81. package/src/relation-resolver.ts +15 -14
  82. package/src/type-coercer.ts +116 -5
  83. package/src/validator-runner.ts +195 -32
@@ -15,7 +15,8 @@ import {
15
15
  ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, PLUS_OFFSET_HINT,
16
16
  looksLikeUnencodedPlusOffset, matchesAnyTemporal, matchesFormat, utcIsoIfZoned, utcIsoInstant,
17
17
  } from "./filter-value-format.js";
18
- import { FIELD_SUBTYPE_TIMESTAMP } from "@metaobjectsdev/metadata";
18
+ import { FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_TIMESTAMP } from "@metaobjectsdev/metadata";
19
+ import { canonicalDecimal } from "../decimal-wire.js";
19
20
 
20
21
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
21
22
  type AnyTable = any;
@@ -215,14 +216,14 @@ function compileOp(
215
216
  throw new FilterParseError("filter.unsupported_op", `Op "${op}" not supported for field "${field}".`, { field, op, allowed: rule.ops });
216
217
  }
217
218
  switch (op as FilterOp) {
218
- case "eq": return eq(col as any, coerce(value, rule, col, field, op));
219
- case "ne": return ne(col as any, coerce(value, rule, col, field, op));
220
- case "gt": return gt(col as any, coerce(value, rule, col, field, op));
221
- case "gte": return gte(col as any, coerce(value, rule, col, field, op));
222
- case "lt": return lt(col as any, coerce(value, rule, col, field, op));
223
- case "lte": return lte(col as any, coerce(value, rule, col, field, op));
219
+ case "eq": return eq(col as any, coerce(value, rule, col, field, op, dialect));
220
+ case "ne": return ne(col as any, coerce(value, rule, col, field, op, dialect));
221
+ case "gt": return gt(col as any, coerce(value, rule, col, field, op, dialect));
222
+ case "gte": return gte(col as any, coerce(value, rule, col, field, op, dialect));
223
+ case "lt": return lt(col as any, coerce(value, rule, col, field, op, dialect));
224
+ case "lte": return lte(col as any, coerce(value, rule, col, field, op, dialect));
224
225
  case "in": {
225
- const list = String(value).split(",").map((v) => coerce(v.trim(), rule, col, field, op));
226
+ const list = String(value).split(",").map((v) => coerce(v.trim(), rule, col, field, op, dialect));
226
227
  if (list.length > maxInList) {
227
228
  throw new FilterParseError("filter.in_too_large", `In-list size ${list.length} exceeds limit ${maxInList}.`, { field, limit: maxInList });
228
229
  }
@@ -327,10 +328,22 @@ function assertWellFormed(s: string, rule: FilterFieldRule, col: unknown, field:
327
328
  }
328
329
 
329
330
  /** Check a comparison value against the field's rule, then coerce it for binding. */
330
- function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: string, op: string): unknown {
331
+ function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: string, op: string, dialect: SqlDialect): unknown {
331
332
  if (value === null || value === undefined) return null;
332
333
  const s = typeof value === "string" ? value : String(value);
333
334
  assertWellFormed(s, rule, col, field, op);
335
+ // A decimal operand is exact text, never a number (ADR-0019 Amendment 1, W2): parsed with
336
+ // the JSON-number grammar and bound as its canonical string. MySQL compares a DECIMAL column
337
+ // with a string operand as DOUBLES, so there the operand is cast to a wide DECIMAL first.
338
+ // SQLite and D1 hold a decimal as a double (W4) and order every number before any text, so a
339
+ // text operand against a computed column (a view's sum or average has no NUMERIC affinity)
340
+ // would compare false in the wrong direction: the operand is bound as the number it is, exact
341
+ // to the 15 digits W4 promises.
342
+ if (rule.format === FIELD_SUBTYPE_DECIMAL) {
343
+ const canonical = canonicalDecimal(s) as string;
344
+ if (dialect === "mysql") return sql`CAST(${canonical} AS DECIMAL(65,30))`;
345
+ return dialect === "sqlite" ? Number(canonical) : canonical;
346
+ }
334
347
  // A timestamp bound is compared in the same UTC spelling the generated schemas store:
335
348
  // on SQLite/D1 the column is TEXT and compares as text. An instant field (`instant`, i.e.
336
349
  // not @localTime) reads a zoneless or date-only bound as UTC, as its write schema does —
@@ -13,11 +13,13 @@
13
13
 
14
14
  import {
15
15
  FIELD_SUBTYPE_DATE,
16
+ FIELD_SUBTYPE_DECIMAL,
16
17
  FIELD_SUBTYPE_TIME,
17
18
  FIELD_SUBTYPE_TIMESTAMP,
18
19
  FIELD_SUBTYPE_UUID,
19
20
  } from "@metaobjectsdev/metadata";
20
21
  import type { FilterValueFormat } from "./filter-allowlist.js";
22
+ import { canonicalDecimal } from "../decimal-wire.js";
21
23
 
22
24
  const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
23
25
  const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/;
@@ -45,6 +47,7 @@ const CHECKS: Readonly<Record<FilterValueFormat, (s: string) => boolean>> = {
45
47
  [FIELD_SUBTYPE_TIME]: (s) => TIME_RE.test(s),
46
48
  [FIELD_SUBTYPE_TIMESTAMP]: isTimestamp,
47
49
  [FIELD_SUBTYPE_UUID]: (s) => UUID_RE.test(s),
50
+ [FIELD_SUBTYPE_DECIMAL]: (s) => canonicalDecimal(s) !== undefined,
48
51
  };
49
52
 
50
53
  /** The `expected` member of the `invalid_filter_value` envelope, per format. */
@@ -53,6 +56,7 @@ export const FORMAT_EXPECTED: Readonly<Record<FilterValueFormat, string>> = {
53
56
  [FIELD_SUBTYPE_TIME]: "time (HH:MM[:SS[.fff]])",
54
57
  [FIELD_SUBTYPE_TIMESTAMP]: "timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])",
55
58
  [FIELD_SUBTYPE_UUID]: "uuid (8-4-4-4-12 hex)",
59
+ [FIELD_SUBTYPE_DECIMAL]: "decimal number (digits, optional fraction, optional exponent)",
56
60
  };
57
61
 
58
62
  /** `expected` for a temporal rule that does not say WHICH temporal format it is. */
@@ -29,11 +29,13 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
29
29
  import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js";
30
30
  import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js";
31
31
  import { timestampWire } from "../timestamp-wire.js";
32
+ import { withDecimals } from "../decimal-wire.js";
32
33
  import { supportsReturning, type SqlDialect } from "../sql-dialect.js";
33
34
  import { withContractErrorHandler } from "./route-error-handler.js";
34
35
  import { validationErrorBody } from "../route-errors.js";
35
36
  export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
36
37
  export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
38
+ export { decimalWire, withDecimals, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js";
37
39
 
38
40
  // ---------------------------------------------------------------------------
39
41
  // Loose types — we don't bind to a specific Drizzle backend so the helper
@@ -84,6 +86,13 @@ export interface CrudRoutesOptions {
84
86
  updateMethod?: "patch" | "put";
85
87
  filterAllowlist?: FilterAllowlist;
86
88
  sortAllowlist?: SortAllowlist;
89
+ /**
90
+ * The names of the entity's `field.decimal` fields. A decimal is a canonical string on the
91
+ * wire on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale
92
+ * (`"100.5000"`) and SQLite hands back a number, so each row the routes send is rewritten
93
+ * under these keys. The generated route passes them; absent, rows are sent as read.
94
+ */
95
+ decimalColumns?: readonly string[];
87
96
  /** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */
88
97
  dialect?: SqlDialect;
89
98
  /**
@@ -122,7 +131,7 @@ function readSource(opts: VerbOptions): AnyTable {
122
131
  * non-timestamp of the same name.
123
132
  */
124
133
  function readWire(opts: VerbOptions): (row: unknown) => unknown {
125
- return timestampWire(readSource(opts));
134
+ return withDecimals(timestampWire(readSource(opts)), opts.decimalColumns);
126
135
  }
127
136
 
128
137
  /**
@@ -131,7 +140,7 @@ function readWire(opts: VerbOptions): (row: unknown) => unknown {
131
140
  * against the union of both sources.
132
141
  */
133
142
  function echoWire(opts: VerbOptions): (row: unknown) => unknown {
134
- return timestampWire(opts.table, opts.readView);
143
+ return withDecimals(timestampWire(opts.table, opts.readView), opts.decimalColumns);
135
144
  }
136
145
 
137
146
  /** #214 — after a write to the base table, re-read the row THROUGH the replica
@@ -34,6 +34,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify";
34
34
  import { and, eq, or, inArray } from "drizzle-orm";
35
35
  import { coerceIdForColumn } from "./util.js";
36
36
  import { timestampWire } from "../timestamp-wire.js";
37
+ import { withDecimals } from "../decimal-wire.js";
37
38
  import { withContractErrorHandler } from "./route-error-handler.js";
38
39
 
39
40
  // Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
@@ -60,6 +61,8 @@ export interface M2mRouteOptions {
60
61
  targetColumn: string;
61
62
  /** Target entity PK column (physical column name, e.g. "id"). Defaults to "id". */
62
63
  targetPkColumn?: string;
64
+ /** The names of the TARGET entity's `field.decimal` fields, sent as canonical strings (ADR-0019 Amendment 1). */
65
+ decimalColumns?: readonly string[];
63
66
  /** Undirected self-join: union both junction FK columns on read. */
64
67
  symmetric: boolean;
65
68
  /**
@@ -87,7 +90,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
87
90
  const route = `${opts.path}/:id/${opts.relationName}`;
88
91
  // Route-scoped contract error handler — see route-error-handler.ts.
89
92
  const ro = withContractErrorHandler(opts.routeOptions);
90
- const toWire = timestampWire(opts.targetTable);
93
+ const toWire = withDecimals(timestampWire(opts.targetTable), opts.decimalColumns);
91
94
 
92
95
  opts.fastify.get(route, ro, async (req, reply) => {
93
96
  const { id } = req.params as { id: string };
@@ -6,6 +6,7 @@ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError
6
6
  import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
7
7
  import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js";
8
8
  import { timestampWire } from "../timestamp-wire.js";
9
+ import { withDecimals } from "../decimal-wire.js";
9
10
  import { withContractErrorHandler } from "./route-error-handler.js";
10
11
 
11
12
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
@@ -20,7 +21,15 @@ export interface MountReadOnlyOptions {
20
21
  readonly filterAllowlist: FilterAllowlist;
21
22
  readonly sortAllowlist: SortAllowlist;
22
23
  readonly dialect: SqlDialect;
23
- /** Override default ID column name (defaults to "id"). */
24
+ /**
25
+ * The column `GET /:id` addresses a row by: the KEY of that column in the Drizzle view
26
+ * (the field name), not its physical name. Defaults to "id". For a view declared with
27
+ * no columns (the raw-SQL path) it is the physical column name instead, because there
28
+ * is no key to look up.
29
+ *
30
+ * A view that declares columns and has none under this key has no row to address, so
31
+ * `GET /:id` answers `404 not_found` for every id.
32
+ */
24
33
  readonly idColumn?: string;
25
34
  /**
26
35
  * Fastify route-level hooks applied to every route this mounts — the same option
@@ -33,15 +42,31 @@ export interface MountReadOnlyOptions {
33
42
  * mount from an enclosing plugin scope works here too, and needs no option at all.)
34
43
  */
35
44
  readonly routeOptions?: RouteShorthandOptions;
45
+ /**
46
+ * False for an object with no single-column primary identity: an `object.report`, or
47
+ * a keyless projection. Mounts the list route and the collection POST refusal only,
48
+ * and no `/:id` route of any verb. Default true, which is today's behaviour.
49
+ */
50
+ readonly itemRoutes?: boolean;
51
+ /** The noun in the 405 message, which is free prose. Default "projection". */
52
+ readonly resource?: "projection" | "report";
53
+ /**
54
+ * The names of the view's `field.decimal` fields. A decimal is a canonical string on the wire
55
+ * on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale
56
+ * (`"100.5000"`), and SQLite hands back a number, a REAL for a computed one (a report's ratio,
57
+ * average or sum). Each row is rewritten under these keys. The generated route passes them;
58
+ * absent, rows are sent as read. Default none.
59
+ */
60
+ readonly decimalColumns?: readonly string[];
36
61
  }
37
62
 
38
- const REJECT_MUTATION = async (
63
+ const rejectMutation = (resource: string) => async (
39
64
  request: { method: string },
40
65
  reply: { code: (n: number) => { send: (b: unknown) => unknown } },
41
66
  ) => {
42
67
  reply
43
68
  .code(405)
44
- .send({ error: "method_not_allowed", message: `${request.method} is not supported on a projection (read-only).` });
69
+ .send({ error: "method_not_allowed", message: `${request.method} is not supported on a ${resource} (read-only).` });
45
70
  };
46
71
 
47
72
  function resolveViewName(view: AnyView): string | undefined {
@@ -128,11 +153,14 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
128
153
  // Route-scoped contract error handler: an unexpected error answers
129
154
  // `500 { error: "internal" }` rather than Fastify's default (which echoes the SQL).
130
155
  const ro = withContractErrorHandler(opts.routeOptions);
156
+ const reject = rejectMutation(opts.resource ?? "projection");
157
+ const itemRoutes = opts.itemRoutes !== false;
131
158
 
132
159
  const viewName = resolveViewName(view);
133
160
  const useRawSql = isEmptyColumnView(view) && !!viewName;
134
161
  // The raw-SQL branch has no declared columns, so nothing names a timestamp there.
135
- const toWire = timestampWire(view);
162
+ const timestamps = timestampWire(view);
163
+ const toWire = withDecimals(timestamps, opts.decimalColumns);
136
164
 
137
165
  // ── List ──────────────────────────────────────────────────────────────────
138
166
  fastify.get(path, ro, async (req, reply) => {
@@ -205,37 +233,46 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
205
233
  }
206
234
  });
207
235
 
208
- // ── Get by ID ─────────────────────────────────────────────────────────────
209
- fastify.get(`${path}/:id`, ro, async (req, reply) => {
210
- const { id } = req.params as { id: string };
211
- if (useRawSql) {
212
- // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
213
- const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
214
- const row = rows[0] ? camelizeRow(rows[0]) : undefined;
215
- return row ?? reply.code(404).send({ error: "not_found" });
216
- }
217
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
218
- const colRef = (view as any)[idCol];
219
- // Compare against the PK's real type — a uuid/text key must NOT go through Number().
220
- const idValue = coerceIdForColumn(colRef, id);
221
- if (idValue === undefined) {
222
- return reply.code(400).send({ error: "invalid_id" });
223
- }
224
- // Await + first row rather than `.get()` (libsql/better-sqlite3-only).
225
- const rows = await db.select().from(view).where(
226
- colRef !== undefined ? eq(colRef, idValue) : undefined
227
- ).limit(1);
228
- const row = (rows as unknown[])[0];
229
- return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
230
- });
236
+ // A keyless object (`itemRoutes: false`) has nothing to address by id: no `/:id` route
237
+ // of any verb, so the framework's own 404 answers.
238
+ if (itemRoutes) {
239
+ // ── Get by ID ─────────────────────────────────────────────────────────────
240
+ fastify.get(`${path}/:id`, ro, async (req, reply) => {
241
+ const { id } = req.params as { id: string };
242
+ if (useRawSql) {
243
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
244
+ const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
245
+ const row = rows[0] ? camelizeRow(rows[0]) : undefined;
246
+ return row ?? reply.code(404).send({ error: "not_found" });
247
+ }
248
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
249
+ const colRef = (view as any)[idCol];
250
+ // No column under `idColumn`: there is nothing to compare the id against, and a query
251
+ // with no WHERE would answer with the view's first row. No row is addressable.
252
+ if (colRef === undefined) {
253
+ return reply.code(404).send({ error: "not_found" });
254
+ }
255
+ // Compare against the PK's real type — a uuid/text key must NOT go through Number().
256
+ const idValue = coerceIdForColumn(colRef, id);
257
+ if (idValue === undefined) {
258
+ return reply.code(400).send({ error: "invalid_id" });
259
+ }
260
+ // Await + first row rather than `.get()` (libsql/better-sqlite3-only).
261
+ const rows = await db.select().from(view).where(eq(colRef, idValue)).limit(1);
262
+ const row = (rows as unknown[])[0];
263
+ return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
264
+ });
265
+ }
231
266
 
232
267
  // ── Mutations explicitly rejected (405) ───────────────────────────────────
233
268
  // PUT is here because the WRITABLE mount serves it (an alias of PATCH), so a
234
269
  // projection must reject it the same way the other three are rejected. Omitting it
235
270
  // left `PUT /<collection>/:id` falling through to Fastify's 404 — telling a caller
236
271
  // the resource does not exist when it plainly does and answers GET.
237
- fastify.post(path, ro, REJECT_MUTATION);
238
- fastify.patch(`${path}/:id`, ro, REJECT_MUTATION);
239
- fastify.put(`${path}/:id`, ro, REJECT_MUTATION);
240
- fastify.delete(`${path}/:id`, ro, REJECT_MUTATION);
272
+ fastify.post(path, ro, reject);
273
+ if (itemRoutes) {
274
+ fastify.patch(`${path}/:id`, ro, reject);
275
+ fastify.put(`${path}/:id`, ro, reject);
276
+ fastify.delete(`${path}/:id`, ro, reject);
277
+ }
241
278
  }
@@ -30,7 +30,7 @@ import {
30
30
  isMetaRoot,
31
31
  type MetaData,
32
32
  TYPE_OBJECT,
33
- resolveObjectRef,
33
+ refTarget,
34
34
  FIELD_SUBTYPE_ENUM,
35
35
  FIELD_SUBTYPE_OBJECT,
36
36
  FIELD_SUBTYPE_STRING,
@@ -378,10 +378,9 @@ function attrString(node: MetaData, attr: string): string | null {
378
378
  }
379
379
 
380
380
  /**
381
- * Resolve a field's `@objectRef` to its MetaObject via the canonical ADR-0042 package-local
382
- * resolver (`resolveObjectRef` from `@metaobjectsdev/metadata`): an FQN ref (contains `::`)
383
- * matches EXACTLY on `resolutionKey()`; a bare ref resolves in the field's OWN declaring
384
- * package (`field.parent?.package ?? field.parent?.fileDefaultPackage`), else root-level.
381
+ * Resolve a field's `@objectRef` to its MetaObject: the target the loader bound
382
+ * (`refTarget` from `@metaobjectsdev/metadata`, ADR-0059 R7), resolved in the package of
383
+ * the node that declared the attribute, never by short name.
385
384
  *
386
385
  * #228: this previously called `MetaRoot.findObject(name)` directly — a bare-`name`-only
387
386
  * lookup — and, for an FQN ref (which never matches any object's bare `.name`), fell back to
@@ -404,8 +403,7 @@ function resolveFieldObjectRef(field: MetaField): MetaObject | undefined {
404
403
  // class check fails for a real root, so every field.object @objectRef falls to
405
404
  // the opaque-leaf guard and extraction silently stops descending.
406
405
  if (!isMetaRoot(root)) return undefined;
407
- const referrerPkg = field.parent?.package ?? field.parent?.fileDefaultPackage ?? "";
408
- const { node } = resolveObjectRef(root, ref, referrerPkg);
406
+ const node = refTarget(field, FIELD_ATTR_OBJECT_REF);
409
407
  return node !== undefined && node.type === TYPE_OBJECT ? (node as MetaObject) : undefined;
410
408
  }
411
409
 
package/src/hono/index.ts CHANGED
@@ -45,6 +45,7 @@ export type {
45
45
  import { isTruthyFlag, coerceIdForColumn, firstRow, extractRowCount } from "../drizzle-fastify/util.js";
46
46
  import { supportsReturning, type SqlDialect } from "../sql-dialect.js";
47
47
  import { timestampWire } from "../timestamp-wire.js";
48
+ import { withDecimals } from "../decimal-wire.js";
48
49
  // Every handler below is wrapped in guardRoute (an unexpected error answers
49
50
  // `500 { error: "internal" }`, logged server-side) and reads its body through
50
51
  // readJsonBody (a malformed body answers `400 { error: "invalid_json" }`).
@@ -53,6 +54,7 @@ import { guardRoute, readJsonBody } from "./route-guard.js";
53
54
  // shared (deprecated) helper so the three adapters can't silently diverge.
54
55
  export { parseId } from "../drizzle-fastify/util.js";
55
56
  export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
57
+ export { decimalWire, withDecimals, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js";
56
58
 
57
59
  // ---------------------------------------------------------------------------
58
60
  // Loose types — we don't bind to a specific Drizzle backend so the helper
@@ -104,6 +106,13 @@ export interface CrudRoutesOptions {
104
106
  updateMethod?: "patch" | "put";
105
107
  filterAllowlist?: FilterAllowlist;
106
108
  sortAllowlist?: SortAllowlist;
109
+ /**
110
+ * The names of the entity's `field.decimal` fields. A decimal is a canonical string on the
111
+ * wire on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale
112
+ * (`"100.5000"`) and SQLite hands back a number, so each row the routes send is rewritten
113
+ * under these keys. The generated route passes them; absent, rows are sent as read.
114
+ */
115
+ decimalColumns?: readonly string[];
107
116
  /** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */
108
117
  dialect?: SqlDialect;
109
118
  }
@@ -150,7 +159,7 @@ function readSource(opts: VerbOptions): AnyTable {
150
159
  * non-timestamp of the same name.
151
160
  */
152
161
  function readWire(opts: VerbOptions): (row: unknown) => unknown {
153
- return timestampWire(readSource(opts));
162
+ return withDecimals(timestampWire(readSource(opts)), opts.decimalColumns);
154
163
  }
155
164
 
156
165
  /**
@@ -159,7 +168,7 @@ function readWire(opts: VerbOptions): (row: unknown) => unknown {
159
168
  * against the union of both sources.
160
169
  */
161
170
  function echoWire(opts: VerbOptions): (row: unknown) => unknown {
162
- return timestampWire(opts.table, opts.readView);
171
+ return withDecimals(timestampWire(opts.table, opts.readView), opts.decimalColumns);
163
172
  }
164
173
 
165
174
  /** Re-read a just-written row through the replica view so the response carries the
@@ -13,6 +13,7 @@ import type {
13
13
  } from "../drizzle-fastify/filter-allowlist.js";
14
14
  import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode, viewBaseConfig } from "../drizzle-fastify/util.js";
15
15
  import { timestampWire } from "../timestamp-wire.js";
16
+ import { withDecimals } from "../decimal-wire.js";
16
17
  // An unexpected error on a mounted route answers `500 { error: "internal" }`.
17
18
  import { guardRoute } from "./route-guard.js";
18
19
 
@@ -30,8 +31,32 @@ export interface MountReadOnlyOptions {
30
31
  readonly filterAllowlist: FilterAllowlist;
31
32
  readonly sortAllowlist: SortAllowlist;
32
33
  readonly dialect: SqlDialect;
33
- /** Override default ID column name (defaults to "id"). */
34
+ /**
35
+ * The column `GET /:id` addresses a row by: the KEY of that column in the Drizzle view
36
+ * (the field name), not its physical name. Defaults to "id". For a view declared with
37
+ * no columns (the raw-SQL path) it is the physical column name instead, because there
38
+ * is no key to look up.
39
+ *
40
+ * A view that declares columns and has none under this key has no row to address, so
41
+ * `GET /:id` answers `404 not_found` for every id.
42
+ */
34
43
  readonly idColumn?: string;
44
+ /**
45
+ * False for an object with no single-column primary identity: an `object.report`, or
46
+ * a keyless projection. Mounts the list route and the collection POST refusal only,
47
+ * and no `/:id` route of any verb. Default true, which is today's behaviour.
48
+ */
49
+ readonly itemRoutes?: boolean;
50
+ /** The noun in the 405 message, which is free prose. Default "projection". */
51
+ readonly resource?: "projection" | "report";
52
+ /**
53
+ * The names of the view's `field.decimal` fields. A decimal is a canonical string on the wire
54
+ * on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale
55
+ * (`"100.5000"`), and SQLite hands back a number, a REAL for a computed one (a report's ratio,
56
+ * average or sum). Each row is rewritten under these keys. The generated route passes them;
57
+ * absent, rows are sent as read. Default none.
58
+ */
59
+ readonly decimalColumns?: readonly string[];
35
60
  }
36
61
 
37
62
  function resolveViewName(view: AnyView): string | undefined {
@@ -108,11 +133,13 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
108
133
  export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
109
134
  const { app, path, db, view, filterAllowlist, sortAllowlist, dialect } = opts;
110
135
  const idCol = opts.idColumn ?? "id";
136
+ const itemRoutes = opts.itemRoutes !== false;
111
137
 
112
138
  const viewName = resolveViewName(view);
113
139
  const useRawSql = isEmptyColumnView(view) && !!viewName;
114
140
  // The raw-SQL branch has no declared columns, so nothing names a timestamp there.
115
- const toWire = timestampWire(view);
141
+ const timestamps = timestampWire(view);
142
+ const toWire = withDecimals(timestamps, opts.decimalColumns);
116
143
 
117
144
  // ── List ──────────────────────────────────────────────────────────────────
118
145
  app.get(path, guardRoute(async (c) => {
@@ -180,47 +207,59 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
180
207
  }
181
208
  }));
182
209
 
183
- // ── Get by ID ─────────────────────────────────────────────────────────────
184
- app.get(`${path}/:id`, guardRoute(async (c) => {
185
- const id = c.req.param("id") ?? "";
186
- if (useRawSql) {
187
- // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
188
- const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
189
- const row = rows[0] ? camelizeRow(rows[0]) : undefined;
190
- return row ? c.json(row) : c.json({ error: "not_found" }, 404);
191
- }
192
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle view column ref
193
- const colRef = (view as any)[idCol];
194
- // Compare against the PK's real type — a uuid/text key must NOT go through Number().
195
- const idValue = coerceIdForColumn(colRef, id);
196
- if (idValue === undefined) {
197
- return c.json({ error: "invalid_id" }, 400);
198
- }
199
- // `.get()` is likewise libsql/better-sqlite3-only; `.limit(1)` + await + [0]
200
- // is the portable single-row read (#286).
201
- const rows = await db
202
- .select()
203
- .from(view)
204
- .where(colRef !== undefined ? eq(colRef, idValue) : undefined)
205
- .limit(1);
206
- const row = (rows as unknown[])[0];
207
- return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
208
- }));
210
+ // A keyless object (`itemRoutes: false`) has nothing to address by id: no `/:id` route
211
+ // of any verb, so the framework's own 404 answers.
212
+ if (itemRoutes) {
213
+ // ── Get by ID ─────────────────────────────────────────────────────────────
214
+ app.get(`${path}/:id`, guardRoute(async (c) => {
215
+ const id = c.req.param("id") ?? "";
216
+ if (useRawSql) {
217
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
218
+ const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
219
+ const row = rows[0] ? camelizeRow(rows[0]) : undefined;
220
+ return row ? c.json(row) : c.json({ error: "not_found" }, 404);
221
+ }
222
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle view column ref
223
+ const colRef = (view as any)[idCol];
224
+ // No column under `idColumn`: there is nothing to compare the id against, and a query
225
+ // with no WHERE would answer with the view's first row. No row is addressable.
226
+ if (colRef === undefined) {
227
+ return c.json({ error: "not_found" }, 404);
228
+ }
229
+ // Compare against the PK's real type — a uuid/text key must NOT go through Number().
230
+ const idValue = coerceIdForColumn(colRef, id);
231
+ if (idValue === undefined) {
232
+ return c.json({ error: "invalid_id" }, 400);
233
+ }
234
+ // `.get()` is likewise libsql/better-sqlite3-only; `.limit(1)` + await + [0]
235
+ // is the portable single-row read (#286).
236
+ const rows = await db
237
+ .select()
238
+ .from(view)
239
+ .where(eq(colRef, idValue))
240
+ .limit(1);
241
+ const row = (rows as unknown[])[0];
242
+ return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
243
+ }));
244
+ }
209
245
 
210
246
  // ── Mutations explicitly rejected (405) ───────────────────────────────────
247
+ const resource = opts.resource ?? "projection";
211
248
  const reject = (c: { req: { method: string }; json: (body: unknown, status: number) => unknown }) =>
212
249
  c.json(
213
- { error: "method_not_allowed", message: `${c.req.method} is not supported on a projection (read-only).` },
250
+ { error: "method_not_allowed", message: `${c.req.method} is not supported on a ${resource} (read-only).` },
214
251
  405,
215
252
  );
216
253
  // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
217
254
  app.post(path, reject as any);
218
- // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
219
- app.patch(`${path}/:id`, reject as any);
220
- // PUT too — the writable mount serves it, so a projection must reject it rather
221
- // than 404, which would deny a resource that answers GET on the same path.
222
- // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
223
- app.put(`${path}/:id`, reject as any);
224
- // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
225
- app.delete(`${path}/:id`, reject as any);
255
+ if (itemRoutes) {
256
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
257
+ app.patch(`${path}/:id`, reject as any);
258
+ // PUT too — the writable mount serves it, so a projection must reject it rather
259
+ // than 404, which would deny a resource that answers GET on the same path.
260
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
261
+ app.put(`${path}/:id`, reject as any);
262
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
263
+ app.delete(`${path}/:id`, reject as any);
264
+ }
226
265
  }
package/src/index.ts CHANGED
@@ -12,7 +12,8 @@ export type { Filter, FilterValue, QueryOpts } from "./query-builder.js";
12
12
 
13
13
  export type { FieldViewSpec, EntityViewSpec } from "./view.js";
14
14
 
15
- export type { ValidationResult } from "./validator-runner.js";
15
+ export { runValidators } from "./validator-runner.js";
16
+ export type { ValidationResult, RunValidatorsOpts } from "./validator-runner.js";
16
17
  export type { ValidationFailure } from "./errors.js";
17
18
  export {
18
19
  RuntimeError,
@@ -22,6 +22,7 @@ import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot } from "@meta
22
22
  import {
23
23
  TYPE_OBJECT, TYPE_FIELD, TYPE_RELATIONSHIP,
24
24
  RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH,
25
+ refTarget,
25
26
  CARDINALITY_MANY,
26
27
  DEFAULT_COLUMN_NAMING_STRATEGY,
27
28
  resolveColumnName,
@@ -68,8 +69,9 @@ export function resolveN2mDescriptor(
68
69
  if (child.attr(RELATIONSHIP_ATTR_THROUGH) === undefined) continue; // 1:N many — not M:N.
69
70
 
70
71
  const rel = child as MetaRelationship;
71
- const targetEntityName = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
72
- const joinEntityName = rel.attr(RELATIONSHIP_ATTR_THROUGH) as string | undefined;
72
+ // ADR-0059 R7: the loader bound @objectRef/@through once; read those targets.
73
+ const targetEntityName = refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF)?.name;
74
+ const joinEntityName = refTarget(rel, RELATIONSHIP_ATTR_THROUGH)?.name;
73
75
  if (!targetEntityName || !joinEntityName) {
74
76
  throw new MetadataError(
75
77
  `M:N relationship '${relationName}' on '${sourceEntity.name}' requires @objectRef + @through`,
@@ -0,0 +1,37 @@
1
+ // Run-time format checks for `field.uri` and `field.inet` (#234, ADR-0037 — Contract A).
2
+ //
3
+ // These are deliberately explicit, engine-neutral rules rather than `new URL()` or a
4
+ // platform IP parser: the Python `run_validators` runner applies the SAME patterns, so the
5
+ // two run-time runners agree on every input and not only on the pinned
6
+ // validation-conformance probe set.
7
+
8
+ // The IPv4 and IPv6 literal patterns the generated Zod schema uses
9
+ // (`codegen-ts/src/templates/net-regex.ts`) — no hostnames, no CIDR, no padding, no
10
+ // leading-zero octet; IPv6 includes the embedded-IPv4 tails.
11
+ const IPV4_RE =
12
+ /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
13
+ const IPV6_RE =
14
+ /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/;
15
+
16
+ /** True when `value` is an IPv4 or IPv6 literal. */
17
+ export function isInetLiteral(value: string): boolean {
18
+ return IPV4_RE.test(value) || IPV6_RE.test(value);
19
+ }
20
+
21
+ // Leading/trailing C0 controls and spaces, which a URL parser strips before parsing.
22
+ const URI_PAD_RE = /^[\u0000- ]+|[\u0000- ]+$/g;
23
+ // scheme ":" remainder — the remainder may hold anything, including a newline.
24
+ const URI_SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:([\s\S]*)$/;
25
+ const URI_AUTHORITY_END_RE = /[/?#]/;
26
+
27
+ /**
28
+ * True when `value` is an absolute URI: a scheme, a non-empty remainder and — when the
29
+ * remainder opens an authority with `//` — a non-empty authority. Padding is stripped first.
30
+ */
31
+ export function isAbsoluteUri(value: string): boolean {
32
+ const rest = URI_SCHEME_RE.exec(value.replace(URI_PAD_RE, ""))?.[1];
33
+ if (rest === undefined || rest.length === 0) return false;
34
+ if (!rest.startsWith("//")) return true;
35
+ const authority = rest.slice(2).split(URI_AUTHORITY_END_RE)[0] ?? "";
36
+ return authority.length > 0;
37
+ }