@metaobjectsdev/runtime-ts 1.0.5-rc.6 → 1.0.5-rc.8

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
@@ -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",