@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.
- package/dist/drizzle-fastify/index.d.ts +1 -0
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +30 -21
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.js +4 -1
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +8 -23
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/drizzle-fastify/util.d.ts +9 -0
- package/dist/drizzle-fastify/util.d.ts.map +1 -1
- package/dist/drizzle-fastify/util.js +32 -0
- package/dist/drizzle-fastify/util.js.map +1 -1
- package/dist/hono/index.d.ts +1 -0
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +38 -13
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +8 -22
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/identity-strategy.d.ts +4 -1
- package/dist/identity-strategy.d.ts.map +1 -1
- package/dist/identity-strategy.js +1 -1
- package/dist/identity-strategy.js.map +1 -1
- package/dist/object-manager.js +6 -2
- package/dist/object-manager.js.map +1 -1
- package/dist/timestamp-wire.d.ts +17 -0
- package/dist/timestamp-wire.d.ts.map +1 -0
- package/dist/timestamp-wire.js +144 -0
- package/dist/timestamp-wire.js.map +1 -0
- package/dist/validator-runner.d.ts +7 -0
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +2 -0
- package/dist/validator-runner.js.map +1 -1
- package/package.json +3 -3
- package/src/drizzle-fastify/index.ts +32 -21
- package/src/drizzle-fastify/mount-m2m.ts +4 -1
- package/src/drizzle-fastify/mount-read-only.ts +8 -23
- package/src/drizzle-fastify/util.ts +33 -0
- package/src/hono/index.ts +39 -13
- package/src/hono/mount-read-only.ts +8 -22
- package/src/identity-strategy.ts +3 -2
- package/src/object-manager.ts +8 -3
- package/src/timestamp-wire.ts +152 -0
- 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
|
+
}
|
package/src/validator-runner.ts
CHANGED
|
@@ -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",
|