@metaobjectsdev/runtime-ts 1.0.5-rc.7 → 1.0.5-rc.9
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metaobjectsdev/runtime-ts",
|
|
3
|
-
"version": "1.0.5-rc.
|
|
3
|
+
"version": "1.0.5-rc.9",
|
|
4
4
|
"description": "Node-side runtime helpers for MetaObjects: Fastify route builders, Drizzle filter/sort integration, Kysely drivers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"access": "public"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@metaobjectsdev/metadata": "1.0.5-rc.
|
|
68
|
-
"@metaobjectsdev/render": "1.0.5-rc.
|
|
67
|
+
"@metaobjectsdev/metadata": "1.0.5-rc.9",
|
|
68
|
+
"@metaobjectsdev/render": "1.0.5-rc.9",
|
|
69
69
|
"qs": "^6.13.0"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
@@ -22,8 +22,10 @@ import qs from "qs";
|
|
|
22
22
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
23
23
|
export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
24
24
|
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
25
|
-
import { isTruthyFlag, contractErrorCode, coerceIdForColumn } from "./util.js";
|
|
25
|
+
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow } from "./util.js";
|
|
26
|
+
import { timestampWire } from "../timestamp-wire.js";
|
|
26
27
|
export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
|
|
28
|
+
export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
|
|
27
29
|
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
29
31
|
// Loose types — we don't bind to a specific Drizzle backend so the helper
|
|
@@ -106,6 +108,24 @@ function readSource(opts: VerbOptions): AnyTable {
|
|
|
106
108
|
return opts.readView ?? opts.table;
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Wire mapper for rows READ through `readSource` — canonicalized against
|
|
113
|
+
* exactly that source, since a view may shadow a table column with a
|
|
114
|
+
* non-timestamp of the same name.
|
|
115
|
+
*/
|
|
116
|
+
function readWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
117
|
+
return timestampWire(readSource(opts));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Wire mapper for a WRITE ECHO: `reReadThroughView` answers with the replica
|
|
122
|
+
* view's row but falls back to the table row, so the echo is canonicalized
|
|
123
|
+
* against the union of both sources.
|
|
124
|
+
*/
|
|
125
|
+
function echoWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
126
|
+
return timestampWire(opts.table, opts.readView);
|
|
127
|
+
}
|
|
128
|
+
|
|
109
129
|
/** #214 — after a write to the base table, re-read the row THROUGH the replica
|
|
110
130
|
* view by PK so the response carries derived (origin.passthrough) columns. When
|
|
111
131
|
* there is no `readView`, return the write row unchanged (current behaviour). */
|
|
@@ -141,6 +161,7 @@ function routeOpts(opts: VerbOptions): RouteShorthandOptions {
|
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
export function mountListRoute(opts: VerbOptions): void {
|
|
164
|
+
const toWire = readWire(opts);
|
|
144
165
|
opts.fastify.get(opts.path, routeOpts(opts), async (req, reply) => {
|
|
145
166
|
try {
|
|
146
167
|
// #214 — a write-through entity reads through its replica view.
|
|
@@ -196,7 +217,7 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
196
217
|
// drizzle-orm node-postgres query builder is thenable but has no `.all()`
|
|
197
218
|
// method (that is a libsql/better-sqlite3-only API). Awaiting works on
|
|
198
219
|
// both dialects and is what makes this helper genuinely Postgres-capable.
|
|
199
|
-
const rows = await q;
|
|
220
|
+
const rows = (await q as unknown[]).map(toWire);
|
|
200
221
|
|
|
201
222
|
if (!withCount) return rows;
|
|
202
223
|
|
|
@@ -216,6 +237,7 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
216
237
|
}
|
|
217
238
|
|
|
218
239
|
export function mountGetRoute(opts: VerbOptions): void {
|
|
240
|
+
const toWire = readWire(opts);
|
|
219
241
|
opts.fastify.get(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
|
|
220
242
|
const { id } = req.params as { id: string };
|
|
221
243
|
const discCond = discriminatorCond(opts);
|
|
@@ -228,20 +250,13 @@ export function mountGetRoute(opts: VerbOptions): void {
|
|
|
228
250
|
return reply.code(400).send({ error: "invalid_id" });
|
|
229
251
|
}
|
|
230
252
|
const idCond = eq(src.id, idValue);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// but has no `.get()`. Awaiting works on both dialects.
|
|
234
|
-
const rows = await opts.db
|
|
235
|
-
.select()
|
|
236
|
-
.from(src)
|
|
237
|
-
.where(discCond ? and(idCond, discCond) : idCond)
|
|
238
|
-
.limit(1);
|
|
239
|
-
const row = (rows as unknown[])[0];
|
|
240
|
-
return row ?? reply.code(404).send({ error: "not_found" });
|
|
253
|
+
const row = await firstRow(opts.db, src, discCond ? and(idCond, discCond) : idCond);
|
|
254
|
+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
|
|
241
255
|
});
|
|
242
256
|
}
|
|
243
257
|
|
|
244
258
|
export function mountCreateRoute(opts: VerbOptions): void {
|
|
259
|
+
const toWire = echoWire(opts);
|
|
245
260
|
opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => {
|
|
246
261
|
const parsed = opts.insertSchema.safeParse(req.body);
|
|
247
262
|
if (!parsed.success) {
|
|
@@ -265,11 +280,12 @@ export function mountCreateRoute(opts: VerbOptions): void {
|
|
|
265
280
|
const row = (result as unknown[])[0];
|
|
266
281
|
// #214 — read-your-writes: re-read through the replica view so the response
|
|
267
282
|
// carries derived (origin.passthrough) columns the base table excludes.
|
|
268
|
-
return reply.code(201).send(await reReadThroughView(opts, row));
|
|
283
|
+
return reply.code(201).send(toWire(await reReadThroughView(opts, row)));
|
|
269
284
|
});
|
|
270
285
|
}
|
|
271
286
|
|
|
272
287
|
export function mountUpdateRoute(opts: VerbOptions): void {
|
|
288
|
+
const toWire = echoWire(opts);
|
|
273
289
|
const handler = async (
|
|
274
290
|
req: { params: unknown; body: unknown },
|
|
275
291
|
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
|
|
@@ -304,13 +320,8 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
304
320
|
return reply.code(400).send({ error: "invalid_id" });
|
|
305
321
|
}
|
|
306
322
|
const noopCond = eq(src.id, noopId);
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
.from(src)
|
|
310
|
-
.where(discCond ? and(noopCond, discCond) : noopCond)
|
|
311
|
-
.limit(1);
|
|
312
|
-
const row = (rows as unknown[])[0];
|
|
313
|
-
return row ?? reply.code(404).send({ error: "not_found" });
|
|
323
|
+
const row = await firstRow(opts.db, src, discCond ? and(noopCond, discCond) : noopCond);
|
|
324
|
+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
|
|
314
325
|
}
|
|
315
326
|
// Compare against the PK's real type (see mountGetRoute) — a numeric-
|
|
316
327
|
// LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
|
|
@@ -334,7 +345,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
334
345
|
const row = (result as unknown[])[0];
|
|
335
346
|
if (row == null) return reply.code(404).send({ error: "not_found" });
|
|
336
347
|
// #214 — re-read through the replica view so the response carries derived columns.
|
|
337
|
-
return await reReadThroughView(opts, row);
|
|
348
|
+
return toWire(await reReadThroughView(opts, row));
|
|
338
349
|
};
|
|
339
350
|
const path = `${opts.path}/:id`;
|
|
340
351
|
const ro = routeOpts(opts);
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
34
34
|
import { and, eq, or, inArray } from "drizzle-orm";
|
|
35
35
|
import { coerceIdForColumn } from "./util.js";
|
|
36
|
+
import { timestampWire } from "../timestamp-wire.js";
|
|
36
37
|
|
|
37
38
|
// Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
|
|
38
39
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
|
|
@@ -84,6 +85,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
|
|
|
84
85
|
const targetPk = opts.targetPkColumn ?? "id";
|
|
85
86
|
const route = `${opts.path}/:id/${opts.relationName}`;
|
|
86
87
|
const ro = opts.routeOptions ?? {};
|
|
88
|
+
const toWire = timestampWire(opts.targetTable);
|
|
87
89
|
|
|
88
90
|
opts.fastify.get(route, ro, async (req, reply) => {
|
|
89
91
|
const { id } = req.params as { id: string };
|
|
@@ -148,10 +150,11 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
|
|
|
148
150
|
const pkCol = columnRef(opts.targetTable, targetPk);
|
|
149
151
|
const byPk = inArray(pkCol, [...relatedIds]);
|
|
150
152
|
const disc = opts.targetDiscriminator;
|
|
151
|
-
|
|
153
|
+
const rows = await opts.db
|
|
152
154
|
.select()
|
|
153
155
|
.from(opts.targetTable)
|
|
154
156
|
.where(disc ? and(byPk, eq(columnRef(opts.targetTable, disc.column), disc.value)) : byPk);
|
|
157
|
+
return (rows as unknown[]).map(toWire);
|
|
155
158
|
});
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -3,19 +3,12 @@ import { sql, eq, and, count } from "drizzle-orm";
|
|
|
3
3
|
import qs from "qs";
|
|
4
4
|
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
5
5
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
6
|
-
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral } from "./util.js";
|
|
6
|
+
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js";
|
|
7
|
+
import { timestampWire } from "../timestamp-wire.js";
|
|
7
8
|
|
|
8
9
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
|
|
9
10
|
type AnyView = any;
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
* Drizzle v0.45 stores view config under this well-known Symbol.
|
|
13
|
-
* Accessing `view._` on a proxy-wrapped view (empty-column .existing()) throws
|
|
14
|
-
* because the proxy tries to spread `subquery._.selectedFields` which is undefined.
|
|
15
|
-
* Using the symbol bypasses the proxy entirely.
|
|
16
|
-
*/
|
|
17
|
-
const VIEW_BASE_CONFIG = Symbol.for("drizzle:ViewBaseConfig");
|
|
18
|
-
|
|
19
12
|
export interface MountReadOnlyOptions {
|
|
20
13
|
readonly fastify: FastifyInstance;
|
|
21
14
|
readonly path: string;
|
|
@@ -49,18 +42,8 @@ const REJECT_MUTATION = async (
|
|
|
49
42
|
.send({ error: "method_not_allowed", message: `${request.method} is not supported on a projection (read-only).` });
|
|
50
43
|
};
|
|
51
44
|
|
|
52
|
-
function getViewConfig(view: AnyView): Record<string, unknown> | undefined {
|
|
53
|
-
try {
|
|
54
|
-
const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
|
|
55
|
-
if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
|
|
56
|
-
} catch {
|
|
57
|
-
// ignore — proxy handler may throw on unexpected shapes
|
|
58
|
-
}
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
45
|
function resolveViewName(view: AnyView): string | undefined {
|
|
63
|
-
const cfg =
|
|
46
|
+
const cfg = viewBaseConfig(view);
|
|
64
47
|
if (cfg) {
|
|
65
48
|
if (typeof cfg["name"] === "string") return cfg["name"] as string;
|
|
66
49
|
}
|
|
@@ -78,7 +61,7 @@ function resolveViewName(view: AnyView): string | undefined {
|
|
|
78
61
|
* `SELECT FROM ...` (invalid SQL), and we must fall back to raw SQL.
|
|
79
62
|
*/
|
|
80
63
|
function isEmptyColumnView(view: AnyView): boolean {
|
|
81
|
-
const cfg =
|
|
64
|
+
const cfg = viewBaseConfig(view);
|
|
82
65
|
if (cfg) {
|
|
83
66
|
const fields = cfg["selectedFields"] as Record<string, unknown> | undefined;
|
|
84
67
|
return fields !== undefined && Object.keys(fields).length === 0;
|
|
@@ -139,6 +122,8 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
139
122
|
|
|
140
123
|
const viewName = resolveViewName(view);
|
|
141
124
|
const useRawSql = isEmptyColumnView(view) && !!viewName;
|
|
125
|
+
// The raw-SQL branch has no declared columns, so nothing names a timestamp there.
|
|
126
|
+
const toWire = timestampWire(view);
|
|
142
127
|
|
|
143
128
|
// ── List ──────────────────────────────────────────────────────────────────
|
|
144
129
|
fastify.get(path, ro, async (req, reply) => {
|
|
@@ -189,7 +174,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
189
174
|
// node-postgres builder is thenable. (The useRawSql branch above keeps
|
|
190
175
|
// db.all(sql.raw(...)) because that is the libsql raw-exec API, only
|
|
191
176
|
// reachable for `.existing()` empty-column views which are sqlite-only.)
|
|
192
|
-
const rows = await q;
|
|
177
|
+
const rows = (await q as unknown[]).map(toWire);
|
|
193
178
|
|
|
194
179
|
if (!withCount) return rows;
|
|
195
180
|
|
|
@@ -231,7 +216,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
231
216
|
colRef !== undefined ? eq(colRef, idValue) : undefined
|
|
232
217
|
).limit(1);
|
|
233
218
|
const row = (rows as unknown[])[0];
|
|
234
|
-
return row
|
|
219
|
+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
|
|
235
220
|
});
|
|
236
221
|
|
|
237
222
|
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
|
@@ -81,3 +81,36 @@ export function contractErrorCode(internal: string): string {
|
|
|
81
81
|
return internal;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Drizzle v0.45 stores view config under this well-known Symbol. Accessing
|
|
87
|
+
* `view._` on a proxy-wrapped view (empty-column `.existing()`) throws because
|
|
88
|
+
* the proxy tries to spread `subquery._.selectedFields` which is undefined; the
|
|
89
|
+
* symbol read bypasses the proxy. The proxy handler can still throw on
|
|
90
|
+
* unexpected shapes, so the read is guarded. One reader for the private shape —
|
|
91
|
+
* the two read-only mounts and the timestamp-wire column lookup share it.
|
|
92
|
+
*/
|
|
93
|
+
const VIEW_BASE_CONFIG = Symbol.for("drizzle:ViewBaseConfig");
|
|
94
|
+
|
|
95
|
+
export function viewBaseConfig(view: unknown): Record<string, unknown> | undefined {
|
|
96
|
+
try {
|
|
97
|
+
const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
|
|
98
|
+
if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore — proxy handler may throw on unexpected shapes
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The portable one-row read: `.limit(1)` + await + first element. Awaiting is
|
|
107
|
+
* what makes this work on both dialects — the node-postgres query builder is
|
|
108
|
+
* thenable but has no `.get()` (that is a libsql/better-sqlite3-only API, #286).
|
|
109
|
+
* The caller builds the WHERE (including any TPH discriminator), so the
|
|
110
|
+
* condition stays with the mount that owns it.
|
|
111
|
+
*/
|
|
112
|
+
export async function firstRow(db: unknown, src: unknown, cond: unknown): Promise<unknown> {
|
|
113
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over the user's Drizzle client
|
|
114
|
+
const rows: unknown = await (db as any).select().from(src).where(cond).limit(1);
|
|
115
|
+
return (rows as unknown[])[0];
|
|
116
|
+
}
|
package/src/hono/index.ts
CHANGED
|
@@ -35,10 +35,12 @@ export type {
|
|
|
35
35
|
FilterAllowlist,
|
|
36
36
|
SortAllowlist,
|
|
37
37
|
} from "../drizzle-fastify/filter-allowlist.js";
|
|
38
|
-
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";
|
|
39
40
|
// Back-compat re-export — the unsafe local copy was consolidated onto the one
|
|
40
41
|
// shared (deprecated) helper so the three adapters can't silently diverge.
|
|
41
42
|
export { parseId } from "../drizzle-fastify/util.js";
|
|
43
|
+
export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
|
|
42
44
|
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
46
|
// Loose types — we don't bind to a specific Drizzle backend so the helper
|
|
@@ -130,6 +132,24 @@ function readSource(opts: VerbOptions): AnyTable {
|
|
|
130
132
|
return opts.readView ?? opts.table;
|
|
131
133
|
}
|
|
132
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
|
+
|
|
133
153
|
/** Re-read a just-written row through the replica view so the response carries the
|
|
134
154
|
* derived columns. Writes always target the TABLE; only the echo changes. With no
|
|
135
155
|
* `readView`, returns the write row unchanged. */
|
|
@@ -149,6 +169,7 @@ async function reReadThroughView(
|
|
|
149
169
|
}
|
|
150
170
|
|
|
151
171
|
export function mountListRoute(opts: VerbOptions): void {
|
|
172
|
+
const toWire = readWire(opts);
|
|
152
173
|
opts.app.get(opts.path, async (c) => {
|
|
153
174
|
try {
|
|
154
175
|
const listSrc = readSource(opts);
|
|
@@ -184,7 +205,7 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
184
205
|
// better-sqlite3-only API). Awaiting works on BOTH dialects — this is what
|
|
185
206
|
// makes the Hono helpers genuinely Postgres-capable, matching the Fastify
|
|
186
207
|
// adapter, which carried this fix while Hono did not (#286).
|
|
187
|
-
const rows = await q;
|
|
208
|
+
const rows = (await q as unknown[]).map(toWire);
|
|
188
209
|
|
|
189
210
|
if (!withCount) return c.json(rows);
|
|
190
211
|
|
|
@@ -208,6 +229,7 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
208
229
|
}
|
|
209
230
|
|
|
210
231
|
export function mountGetRoute(opts: VerbOptions): void {
|
|
232
|
+
const toWire = readWire(opts);
|
|
211
233
|
opts.app.get(`${opts.path}/:id`, async (c) => {
|
|
212
234
|
const id = c.req.param("id") ?? "";
|
|
213
235
|
// Compare against the PK's real type — a numeric-LOOKING id on a TEXT pk
|
|
@@ -215,19 +237,13 @@ export function mountGetRoute(opts: VerbOptions): void {
|
|
|
215
237
|
const getSrc = readSource(opts);
|
|
216
238
|
const idValue = coerceIdForColumn(getSrc.id, id);
|
|
217
239
|
if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const rows = await opts.db
|
|
221
|
-
.select()
|
|
222
|
-
.from(getSrc)
|
|
223
|
-
.where(eq(getSrc.id, idValue))
|
|
224
|
-
.limit(1);
|
|
225
|
-
const row = (rows as unknown[])[0];
|
|
226
|
-
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);
|
|
227
242
|
});
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
export function mountCreateRoute(opts: VerbOptions): void {
|
|
246
|
+
const toWire = echoWire(opts);
|
|
231
247
|
opts.app.post(opts.path, async (c) => {
|
|
232
248
|
const body = await c.req.json().catch(() => undefined);
|
|
233
249
|
const parsed = opts.insertSchema.safeParse(body);
|
|
@@ -247,11 +263,12 @@ export function mountCreateRoute(opts: VerbOptions): void {
|
|
|
247
263
|
}
|
|
248
264
|
const row = (result as unknown[])[0];
|
|
249
265
|
// Echo the row through the replica view so derived columns are present (#214).
|
|
250
|
-
return c.json(await reReadThroughView(opts, row), 201);
|
|
266
|
+
return c.json(toWire(await reReadThroughView(opts, row)), 201);
|
|
251
267
|
});
|
|
252
268
|
}
|
|
253
269
|
|
|
254
270
|
export function mountUpdateRoute(opts: VerbOptions): void {
|
|
271
|
+
const toWire = echoWire(opts);
|
|
255
272
|
const handler = async (c: Context) => {
|
|
256
273
|
const id = c.req.param("id") ?? "";
|
|
257
274
|
const body = await c.req.json().catch(() => undefined);
|
|
@@ -259,6 +276,15 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
259
276
|
if (!parsed.success) {
|
|
260
277
|
return c.json({ error: "validation", issues: parsed.error.issues }, 400);
|
|
261
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
|
+
}
|
|
262
288
|
// Compare against the PK's real type (see mountGetRoute) — a numeric-
|
|
263
289
|
// LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
|
|
264
290
|
const idValue = coerceIdForColumn(opts.table.id, id);
|
|
@@ -276,7 +302,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
276
302
|
return c.json(f.body, f.status as 400 | 409);
|
|
277
303
|
}
|
|
278
304
|
const row = (result as unknown[])[0];
|
|
279
|
-
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);
|
|
280
306
|
};
|
|
281
307
|
const path = `${opts.path}/:id`;
|
|
282
308
|
// Cross-port REST contract (FR-008): the update verb is reachable via BOTH PATCH
|
|
@@ -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, contractErrorCode } 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
|
|
|
@@ -215,7 +201,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
215
201
|
.where(colRef !== undefined ? eq(colRef, idValue) : undefined)
|
|
216
202
|
.limit(1);
|
|
217
203
|
const row = (rows as unknown[])[0];
|
|
218
|
-
return row ? c.json(row) : c.json({ error: "not_found" }, 404);
|
|
204
|
+
return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
|
|
219
205
|
});
|
|
220
206
|
|
|
221
207
|
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
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/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})`;
|