@metaobjectsdev/runtime-ts 1.0.4 → 1.0.5-rc.10
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/filter-parser.js +6 -2
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +4 -2
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +48 -14
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts +24 -0
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.js +37 -3
- 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 +16 -24
- 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/fastify/index.d.ts +27 -3
- package/dist/fastify/index.d.ts.map +1 -1
- package/dist/fastify/index.js +41 -5
- package/dist/fastify/index.js.map +1 -1
- package/dist/hono/index.d.ts +18 -2
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +66 -16
- 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 +16 -24
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-endpoint.d.ts +28 -0
- package/dist/meta-endpoint.d.ts.map +1 -0
- package/dist/meta-endpoint.js +33 -0
- package/dist/meta-endpoint.js.map +1 -0
- 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/filter-parser.ts +6 -2
- package/src/drizzle-fastify/index.ts +53 -16
- package/src/drizzle-fastify/mount-m2m.ts +53 -3
- package/src/drizzle-fastify/mount-read-only.ts +16 -24
- package/src/drizzle-fastify/util.ts +33 -0
- package/src/fastify/index.ts +59 -9
- package/src/hono/index.ts +84 -18
- package/src/hono/mount-read-only.ts +16 -24
- package/src/identity-strategy.ts +3 -2
- package/src/index.ts +3 -0
- package/src/meta-endpoint.ts +35 -0
- package/src/object-manager.ts +8 -3
- package/src/timestamp-wire.ts +152 -0
- 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
|
|
11
|
-
// PUT {path}/:id update (
|
|
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
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/src/identity-strategy.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
import { MetadataError, ValidationError } from "./errors.js";
|
|
14
14
|
|
|
15
15
|
export type IdentityResolution =
|
|
16
|
-
|
|
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
|
@@ -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
|
+
}
|
package/src/object-manager.ts
CHANGED
|
@@ -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
|
+
}
|
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",
|