@metaobjectsdev/runtime-ts 0.15.20 → 0.15.21-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/drizzle-fastify/index.d.ts +1 -1
  2. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  3. package/dist/drizzle-fastify/index.js +26 -5
  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 +13 -6
  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 -7
  10. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  11. package/dist/drizzle-fastify/util.d.ts +31 -1
  12. package/dist/drizzle-fastify/util.d.ts.map +1 -1
  13. package/dist/drizzle-fastify/util.js +43 -1
  14. package/dist/drizzle-fastify/util.js.map +1 -1
  15. package/dist/fastify/index.d.ts +1 -6
  16. package/dist/fastify/index.d.ts.map +1 -1
  17. package/dist/fastify/index.js +13 -11
  18. package/dist/fastify/index.js.map +1 -1
  19. package/dist/hono/index.d.ts +1 -1
  20. package/dist/hono/index.d.ts.map +1 -1
  21. package/dist/hono/index.js +25 -8
  22. package/dist/hono/index.js.map +1 -1
  23. package/dist/hono/mount-read-only.d.ts.map +1 -1
  24. package/dist/hono/mount-read-only.js +8 -7
  25. package/dist/hono/mount-read-only.js.map +1 -1
  26. package/dist/object-manager.d.ts +8 -0
  27. package/dist/object-manager.d.ts.map +1 -1
  28. package/dist/object-manager.js +22 -4
  29. package/dist/object-manager.js.map +1 -1
  30. package/package.json +3 -3
  31. package/src/drizzle-fastify/index.ts +26 -6
  32. package/src/drizzle-fastify/mount-m2m.ts +16 -7
  33. package/src/drizzle-fastify/mount-read-only.ts +8 -7
  34. package/src/drizzle-fastify/util.ts +44 -1
  35. package/src/fastify/index.ts +13 -11
  36. package/src/hono/index.ts +22 -10
  37. package/src/hono/mount-read-only.ts +8 -7
  38. package/src/object-manager.ts +21 -4
package/src/hono/index.ts CHANGED
@@ -33,7 +33,10 @@ export type {
33
33
  FilterAllowlist,
34
34
  SortAllowlist,
35
35
  } from "../drizzle-fastify/filter-allowlist.js";
36
- import { isTruthyFlag } from "../drizzle-fastify/util.js";
36
+ import { isTruthyFlag, coerceIdForColumn } from "../drizzle-fastify/util.js";
37
+ // Back-compat re-export — the unsafe local copy was consolidated onto the one
38
+ // shared (deprecated) helper so the three adapters can't silently diverge.
39
+ export { parseId } from "../drizzle-fastify/util.js";
37
40
 
38
41
  // ---------------------------------------------------------------------------
39
42
  // Loose types — we don't bind to a specific Drizzle backend so the helper
@@ -156,10 +159,14 @@ export function mountListRoute(opts: VerbOptions): void {
156
159
  export function mountGetRoute(opts: VerbOptions): void {
157
160
  opts.app.get(`${opts.path}/:id`, async (c) => {
158
161
  const id = c.req.param("id") ?? "";
162
+ // Compare against the PK's real type — a numeric-LOOKING id on a TEXT pk
163
+ // must stay a string ('0123' ≠ '123'), or affinity matches the WRONG row.
164
+ const idValue = coerceIdForColumn(opts.table.id, id);
165
+ if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
159
166
  const row = await opts.db
160
167
  .select()
161
168
  .from(opts.table)
162
- .where(eq(opts.table.id, parseId(id)))
169
+ .where(eq(opts.table.id, idValue))
163
170
  .get();
164
171
  return row ? c.json(row) : c.json({ error: "not_found" }, 404);
165
172
  });
@@ -186,10 +193,14 @@ export function mountUpdateRoute(opts: VerbOptions): void {
186
193
  if (!parsed.success) {
187
194
  return c.json({ error: "validation", issues: parsed.error.issues }, 400);
188
195
  }
196
+ // Compare against the PK's real type (see mountGetRoute) — a numeric-
197
+ // LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
198
+ const idValue = coerceIdForColumn(opts.table.id, id);
199
+ if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
189
200
  const result = await opts.db
190
201
  .update(opts.table)
191
202
  .set(parsed.data)
192
- .where(eq(opts.table.id, parseId(id)))
203
+ .where(eq(opts.table.id, idValue))
193
204
  .returning();
194
205
  const row = (result as unknown[])[0];
195
206
  return row ? c.json(row) : c.json({ error: "not_found" }, 404);
@@ -205,9 +216,13 @@ export function mountUpdateRoute(opts: VerbOptions): void {
205
216
  export function mountDeleteRoute(opts: VerbOptions): void {
206
217
  opts.app.delete(`${opts.path}/:id`, async (c) => {
207
218
  const id = c.req.param("id") ?? "";
219
+ // Compare against the PK's real type (see mountGetRoute) — a numeric-
220
+ // LOOKING id on a TEXT pk would otherwise DELETE the wrong row (data loss).
221
+ const idValue = coerceIdForColumn(opts.table.id, id);
222
+ if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
208
223
  const result = await opts.db
209
224
  .delete(opts.table)
210
- .where(eq(opts.table.id, parseId(id)));
225
+ .where(eq(opts.table.id, idValue));
211
226
  const affected = extractRowCount(result);
212
227
  if (affected > 0) {
213
228
  // 204 No Content — body must be empty.
@@ -221,17 +236,14 @@ function extractRowCount(result: unknown): number {
221
236
  if (typeof result === "number") return result;
222
237
  if (Array.isArray(result)) return result.length;
223
238
  if (result && typeof result === "object") {
224
- const obj = result as { rowsAffected?: number | bigint; rowCount?: number };
239
+ const obj = result as { rowsAffected?: number | bigint; rowCount?: number; changes?: number };
225
240
  if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
226
241
  if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
227
242
  if (typeof obj.rowCount === "number") return obj.rowCount;
243
+ // bun:sqlite / better-sqlite3 run() result shape.
244
+ if (typeof obj.changes === "number") return obj.changes;
228
245
  }
229
246
  return 0;
230
247
  }
231
248
 
232
- export function parseId(raw: string): number | string {
233
- const n = Number(raw);
234
- return Number.isFinite(n) && raw.trim() !== "" ? n : raw;
235
- }
236
-
237
249
  export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
@@ -10,7 +10,7 @@ import type {
10
10
  FilterAllowlist,
11
11
  SortAllowlist,
12
12
  } from "../drizzle-fastify/filter-allowlist.js";
13
- import { isTruthyFlag } from "../drizzle-fastify/util.js";
13
+ import { isTruthyFlag, coerceIdForColumn, rawIdLiteral } from "../drizzle-fastify/util.js";
14
14
 
15
15
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
16
16
  type AnyView = any;
@@ -155,21 +155,22 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
155
155
  app.get(`${path}/:id`, async (c) => {
156
156
  const id = c.req.param("id") ?? "";
157
157
  if (useRawSql) {
158
- const numericId = Number(id);
159
- if (!Number.isFinite(numericId)) {
160
- return c.json({ error: "invalid_id" }, 400);
161
- }
162
158
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
163
- const rows = (await db.all(sql.raw(`SELECT * FROM "${viewName}" WHERE "${idCol}" = ${numericId} LIMIT 1`))) as any[];
159
+ const rows = (await db.all(sql.raw(`SELECT * FROM "${viewName}" WHERE "${idCol}" = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
164
160
  const row = rows[0] ? camelizeRow(rows[0]) : undefined;
165
161
  return row ? c.json(row) : c.json({ error: "not_found" }, 404);
166
162
  }
167
163
  // biome-ignore lint/suspicious/noExplicitAny: Drizzle view column ref
168
164
  const colRef = (view as any)[idCol];
165
+ // Compare against the PK's real type — a uuid/text key must NOT go through Number().
166
+ const idValue = coerceIdForColumn(colRef, id);
167
+ if (idValue === undefined) {
168
+ return c.json({ error: "invalid_id" }, 400);
169
+ }
169
170
  const row = await db
170
171
  .select()
171
172
  .from(view)
172
- .where(colRef !== undefined ? eq(colRef, Number(id)) : undefined)
173
+ .where(colRef !== undefined ? eq(colRef, idValue) : undefined)
173
174
  .get();
174
175
  return row ? c.json(row) : c.json({ error: "not_found" }, 404);
175
176
  });
@@ -96,10 +96,24 @@ export class ObjectManager {
96
96
  return { $and: [filter, disc] } as Filter;
97
97
  }
98
98
 
99
+ /**
100
+ * Coerce a raw string id (typically an HTTP path param) to the PK field's
101
+ * DECLARED type: a numeric-subtype pk gets Number(); a string/uuid pk keeps
102
+ * the string EXACTLY — '0123' and '123' are different keys, and Number()-ing
103
+ * them conflates the two (SQLite affinity then matches the WRONG row).
104
+ * Non-string ids and composite-pk entities pass through untouched.
105
+ */
106
+ private coerceIdArg(entity: MetaData, id: unknown): unknown {
107
+ if (typeof id !== "string") return id;
108
+ const pkFields = resolvePkFields(entity);
109
+ if (pkFields.length !== 1) return id;
110
+ return coercePkValue(entity, pkFields[0]!, id);
111
+ }
112
+
99
113
  async findById(entityName: string, id: unknown, opts: ReadOpts = {}): Promise<Row | null> {
100
114
  const entity = this.requireEntity(entityName);
101
115
  const pkField = resolvePkFields(entity)[0]!;
102
- return this.findFirst(entityName, { [pkField]: id as string | number }, opts);
116
+ return this.findFirst(entityName, { [pkField]: this.coerceIdArg(entity, id) as string | number }, opts);
103
117
  }
104
118
 
105
119
  async findFirst(entityName: string, filter: Filter, opts: ReadOpts = {}): Promise<Row | null> {
@@ -204,7 +218,7 @@ export class ObjectManager {
204
218
 
205
219
  const coerced = coerceRowOnWrite(entity, restricted, driver.dialect);
206
220
  // FR-017 TPH: scope the by-id update to the subtype (cross-subtype → not found).
207
- const spec = buildUpdateSpec(entity, coerced, id, this.columnNamingStrategy, this.tphScope(entity));
221
+ const spec = buildUpdateSpec(entity, coerced, this.coerceIdArg(entity, id), this.columnNamingStrategy, this.tphScope(entity));
208
222
  const dbRow = await driver.update(spec);
209
223
  if (dbRow === null) {
210
224
  const mode = opts.ifMissing ?? DEFAULT_IF_MISSING;
@@ -218,7 +232,7 @@ export class ObjectManager {
218
232
  const entity = this.requireEntity(entityName);
219
233
  const driver = opts.tx ?? this.driver;
220
234
  // FR-017 TPH: scope the by-id delete to the subtype (cross-subtype → not found).
221
- const spec = buildDeleteSpec(entity, id, this.columnNamingStrategy, this.tphScope(entity));
235
+ const spec = buildDeleteSpec(entity, this.coerceIdArg(entity, id), this.columnNamingStrategy, this.tphScope(entity));
222
236
  const n = await driver.delete(spec);
223
237
  if (n === 0) {
224
238
  const mode = opts.ifMissing ?? DEFAULT_IF_MISSING;
@@ -498,7 +512,10 @@ const NUMERIC_SUBTYPES = new Set([
498
512
  FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
499
513
  ]);
500
514
 
501
- // decodeRef always returns strings; numeric PK fields need coercion back to number.
515
+ // Coerce a raw string PK value to the field's declared type: numeric subtypes
516
+ // get Number(); everything else keeps the string exactly. Serves decodeRef
517
+ // (which always returns strings) AND raw HTTP path params via coerceIdArg —
518
+ // a string/uuid pk must never be Number()-ed ('0123' ≠ '123').
502
519
  function coercePkValue(entity: MetaData, fieldName: string, rawValue: string): string | number {
503
520
  // ADR-0039: resolving — the PK field may be inherited from a BaseEntity via extends.
504
521
  const field = entity.children().find((c) => c.type === TYPE_FIELD && c.name === fieldName);