@metaobjectsdev/runtime-ts 1.0.12 → 1.1.0

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 (83) hide show
  1. package/dist/decimal-wire.d.ts +54 -0
  2. package/dist/decimal-wire.d.ts.map +1 -0
  3. package/dist/decimal-wire.js +158 -0
  4. package/dist/decimal-wire.js.map +1 -0
  5. package/dist/drizzle-fastify/filter-allowlist.d.ts +4 -2
  6. package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -1
  7. package/dist/drizzle-fastify/filter-allowlist.js +2 -1
  8. package/dist/drizzle-fastify/filter-allowlist.js.map +1 -1
  9. package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
  10. package/dist/drizzle-fastify/filter-parser.js +23 -9
  11. package/dist/drizzle-fastify/filter-parser.js.map +1 -1
  12. package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -1
  13. package/dist/drizzle-fastify/filter-value-format.js +4 -1
  14. package/dist/drizzle-fastify/filter-value-format.js.map +1 -1
  15. package/dist/drizzle-fastify/index.d.ts +8 -0
  16. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  17. package/dist/drizzle-fastify/index.js +4 -2
  18. package/dist/drizzle-fastify/index.js.map +1 -1
  19. package/dist/drizzle-fastify/mount-m2m.d.ts +2 -0
  20. package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
  21. package/dist/drizzle-fastify/mount-m2m.js +2 -1
  22. package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
  23. package/dist/drizzle-fastify/mount-read-only.d.ts +25 -1
  24. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
  25. package/dist/drizzle-fastify/mount-read-only.js +43 -28
  26. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  27. package/dist/extract-object.js +5 -7
  28. package/dist/extract-object.js.map +1 -1
  29. package/dist/hono/index.d.ts +8 -0
  30. package/dist/hono/index.d.ts.map +1 -1
  31. package/dist/hono/index.js +4 -2
  32. package/dist/hono/index.js.map +1 -1
  33. package/dist/hono/mount-read-only.d.ts +25 -1
  34. package/dist/hono/mount-read-only.d.ts.map +1 -1
  35. package/dist/hono/mount-read-only.js +51 -36
  36. package/dist/hono/mount-read-only.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/n2m-resolver.d.ts.map +1 -1
  42. package/dist/n2m-resolver.js +4 -3
  43. package/dist/n2m-resolver.js.map +1 -1
  44. package/dist/net-format.d.ts +8 -0
  45. package/dist/net-format.d.ts.map +1 -0
  46. package/dist/net-format.js +34 -0
  47. package/dist/net-format.js.map +1 -0
  48. package/dist/object-manager.d.ts +20 -7
  49. package/dist/object-manager.d.ts.map +1 -1
  50. package/dist/object-manager.js +68 -13
  51. package/dist/object-manager.js.map +1 -1
  52. package/dist/query-builder.d.ts.map +1 -1
  53. package/dist/query-builder.js +11 -2
  54. package/dist/query-builder.js.map +1 -1
  55. package/dist/relation-resolver.d.ts.map +1 -1
  56. package/dist/relation-resolver.js +11 -11
  57. package/dist/relation-resolver.js.map +1 -1
  58. package/dist/type-coercer.d.ts +1 -1
  59. package/dist/type-coercer.d.ts.map +1 -1
  60. package/dist/type-coercer.js +114 -5
  61. package/dist/type-coercer.js.map +1 -1
  62. package/dist/validator-runner.d.ts.map +1 -1
  63. package/dist/validator-runner.js +192 -35
  64. package/dist/validator-runner.js.map +1 -1
  65. package/package.json +3 -3
  66. package/src/decimal-wire.ts +154 -0
  67. package/src/drizzle-fastify/filter-allowlist.ts +5 -1
  68. package/src/drizzle-fastify/filter-parser.ts +22 -9
  69. package/src/drizzle-fastify/filter-value-format.ts +4 -0
  70. package/src/drizzle-fastify/index.ts +11 -2
  71. package/src/drizzle-fastify/mount-m2m.ts +4 -1
  72. package/src/drizzle-fastify/mount-read-only.ts +68 -31
  73. package/src/extract-object.ts +5 -7
  74. package/src/hono/index.ts +11 -2
  75. package/src/hono/mount-read-only.ts +76 -37
  76. package/src/index.ts +2 -1
  77. package/src/n2m-resolver.ts +4 -2
  78. package/src/net-format.ts +37 -0
  79. package/src/object-manager.ts +80 -18
  80. package/src/query-builder.ts +14 -1
  81. package/src/relation-resolver.ts +15 -14
  82. package/src/type-coercer.ts +116 -5
  83. package/src/validator-runner.ts +195 -32
@@ -1,6 +1,9 @@
1
1
  import type { MetaData } from "@metaobjectsdev/metadata";
2
+ import { physicalNamingGuideUrl } from "@metaobjectsdev/metadata/release-docs";
2
3
  import {
3
4
  TYPE_OBJECT, TYPE_FIELD,
5
+ OBJECT_SUBTYPE_REPORT,
6
+ isMetaObject, isMetaRoot, isReadOnlySource, reportReadModel,
4
7
  FIELD_SUBTYPE_INT,
5
8
  FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
6
9
  } from "@metaobjectsdev/metadata";
@@ -15,7 +18,7 @@ import {
15
18
  } from "./query-builder.js";
16
19
  import { tphSubtypeOf, type TphSubtype } from "./tph.js";
17
20
  import {
18
- buildNameMap, resolveTableName, DEFAULT_COLUMN_NAMING_STRATEGY,
21
+ buildNameMap, resolveTableName, physicalNamingOf, physicalNamingRemovedMessage,
19
22
  type ColumnNamingStrategy, type EntityNameMap,
20
23
  } from "@metaobjectsdev/metadata";
21
24
  import { coerceRowOnRead, coerceRowOnWrite } from "./type-coercer.js";
@@ -41,12 +44,6 @@ import { VALID_ENTITY_NAME, DEFAULT_IF_MISSING } from "./constants.js";
41
44
  export interface ObjectManagerOptions {
42
45
  metadata: MetaData;
43
46
  driver: PersistenceDriver;
44
- /**
45
- * Column-naming strategy for fields with no `@column` override. Defaults to
46
- * `"snake_case"`. Must agree with the codegen / migration config that
47
- * created the schema (a mismatch yields columns the runtime can't address).
48
- */
49
- columnNamingStrategy?: ColumnNamingStrategy;
50
47
  }
51
48
 
52
49
  export interface ReadOpts extends QueryOpts {
@@ -70,7 +67,15 @@ export class ObjectManager {
70
67
  constructor(opts: ObjectManagerOptions) {
71
68
  this.metadata = opts.metadata;
72
69
  this.driver = opts.driver;
73
- this.columnNamingStrategy = opts.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY;
70
+ // ADR-0058: the column rule is the project's one setting (`naming` in
71
+ // .metaobjects/config.json), carried by the loaded root — the same value codegen and
72
+ // `meta migrate` read, so the runtime cannot address columns the schema does not have.
73
+ // The 1.0 option is refused rather than dropped: its value may be the one that matches.
74
+ const removedNaming = (opts as { columnNamingStrategy?: unknown }).columnNamingStrategy;
75
+ if (removedNaming !== undefined) {
76
+ throw new Error(physicalNamingRemovedMessage("the ObjectManager columnNamingStrategy option", String(removedNaming), physicalNamingGuideUrl()));
77
+ }
78
+ this.columnNamingStrategy = physicalNamingOf(this.metadata).columns;
74
79
  }
75
80
 
76
81
  private nameMap(entity: MetaData): EntityNameMap {
@@ -111,7 +116,7 @@ export class ObjectManager {
111
116
  }
112
117
 
113
118
  async findById(entityName: string, id: unknown, opts: ReadOpts = {}): Promise<Row | null> {
114
- const entity = this.requireEntity(entityName);
119
+ const entity = this.requireIdentified(entityName, "findById");
115
120
  const pkField = resolvePkFields(entity)[0]!;
116
121
  return this.findFirst(entityName, { [pkField]: this.coerceIdArg(entity, id) as string | number }, opts);
117
122
  }
@@ -151,7 +156,7 @@ export class ObjectManager {
151
156
 
152
157
  async load(refString: string): Promise<Row | null> {
153
158
  const { entity: entityName, pkValues } = decodeRef(refString);
154
- const entity = this.requireEntity(entityName);
159
+ const entity = this.requireIdentified(entityName, "load");
155
160
  const pkFields = resolvePkFields(entity);
156
161
  if (pkValues.length !== pkFields.length) {
157
162
  throw new MetadataError(
@@ -169,12 +174,12 @@ export class ObjectManager {
169
174
  }
170
175
 
171
176
  refOf(entityName: string, record: Row): string {
172
- const entity = this.requireEntity(entityName);
177
+ const entity = this.requireIdentified(entityName, "refOf");
173
178
  return encodeRef(entityName, record, resolvePkFields(entity));
174
179
  }
175
180
 
176
181
  async create(entityName: string, data: Row, opts: WriteOpts = {}): Promise<Row> {
177
- const entity = this.requireEntity(entityName);
182
+ const entity = this.requireIdentified(entityName, "create");
178
183
  const driver = opts.tx ?? this.driver;
179
184
 
180
185
  const restricted0 = this.applyViewRestriction(entity, data, opts.view);
@@ -197,7 +202,7 @@ export class ObjectManager {
197
202
  }
198
203
 
199
204
  async update(entityName: string, id: unknown, data: Row, opts: WriteOpts = {}): Promise<Row | null> {
200
- const entity = this.requireEntity(entityName);
205
+ const entity = this.requireIdentified(entityName, "update");
201
206
  const driver = opts.tx ?? this.driver;
202
207
 
203
208
  const restricted0 = this.applyViewRestriction(entity, data, opts.view);
@@ -229,7 +234,7 @@ export class ObjectManager {
229
234
  }
230
235
 
231
236
  async delete(entityName: string, id: unknown, opts: WriteOpts = {}): Promise<boolean> {
232
- const entity = this.requireEntity(entityName);
237
+ const entity = this.requireIdentified(entityName, "delete");
233
238
  const driver = opts.tx ?? this.driver;
234
239
  // FR-017 TPH: scope the by-id delete to the subtype (cross-subtype → not found).
235
240
  const spec = buildDeleteSpec(entity, this.coerceIdArg(entity, id), this.columnNamingStrategy, this.tphScope(entity));
@@ -243,7 +248,7 @@ export class ObjectManager {
243
248
  }
244
249
 
245
250
  async createMany(entityName: string, dataArray: Row[], opts: WriteOpts = {}): Promise<Row[]> {
246
- const entity = this.requireEntity(entityName);
251
+ const entity = this.requireIdentified(entityName, "createMany");
247
252
  const driver = opts.tx ?? this.driver;
248
253
 
249
254
  // Validate + identity-resolve every row before any insert so a late failure can't leave partial state.
@@ -274,7 +279,7 @@ export class ObjectManager {
274
279
  }
275
280
 
276
281
  async updateMany(entityName: string, filter: Filter, partial: Row, opts: WriteOpts = {}): Promise<number> {
277
- const entity = this.requireEntity(entityName);
282
+ const entity = this.requireIdentified(entityName, "updateMany");
278
283
  const driver = opts.tx ?? this.driver;
279
284
  const restricted = this.applyViewRestriction(entity, partial, opts.view);
280
285
  const v = runValidators(entity, restricted, { partial: true });
@@ -292,7 +297,7 @@ export class ObjectManager {
292
297
  }
293
298
 
294
299
  async deleteMany(entityName: string, filter: Filter, opts: WriteOpts = {}): Promise<number> {
295
- const entity = this.requireEntity(entityName);
300
+ const entity = this.requireIdentified(entityName, "deleteMany");
296
301
  const driver = opts.tx ?? this.driver;
297
302
  const spec: DeleteManySpec = {
298
303
  table: resolveTableName(entity),
@@ -304,7 +309,7 @@ export class ObjectManager {
304
309
  async transaction<T>(fn: (txOm: ObjectManager) => Promise<T>): Promise<T> {
305
310
  return this.driver.transaction(async (txDriver) => {
306
311
  const txOm = new ObjectManager({
307
- metadata: this.metadata, driver: txDriver, columnNamingStrategy: this.columnNamingStrategy,
312
+ metadata: this.metadata, driver: txDriver,
308
313
  });
309
314
  return fn(txOm);
310
315
  });
@@ -464,6 +469,12 @@ export class ObjectManager {
464
469
  }
465
470
 
466
471
  private requireEntity(entityName: string): MetaData {
472
+ const entity = this.requireObject(entityName);
473
+ return entity.subType === OBJECT_SUBTYPE_REPORT ? this.reportReadModelOf(entity) : entity;
474
+ }
475
+
476
+ /** The declared object node, exactly as loaded (a report is NOT swapped for its read model). */
477
+ private requireObject(entityName: string): MetaData {
467
478
  if (!VALID_ENTITY_NAME.test(entityName)) {
468
479
  throw new UnsafeNameError(
469
480
  `Unsafe entity name '${entityName}'`,
@@ -478,6 +489,57 @@ export class ObjectManager {
478
489
  return entity;
479
490
  }
480
491
 
492
+ /**
493
+ * FR-044: a report declares no fields — its read shape is derived — so it is read
494
+ * through a detached read model carrying one real field per derived field and the
495
+ * report's own read-only source. Everything downstream (column list, filter and
496
+ * sort resolution, the name map, read coercion) then treats it as it treats a
497
+ * projection. The model is built once per report node and never joins the tree.
498
+ *
499
+ * A report with no read-only source of its own has no view (Table A), so there is
500
+ * nothing to read: refused here rather than falling through to a default table name.
501
+ */
502
+ private reportReadModelOf(report: MetaData): MetaData {
503
+ if (!isMetaObject(report) || !isMetaRoot(this.metadata)) {
504
+ throw new MetadataError(
505
+ `Report '${report.name}' cannot be read: the ObjectManager's metadata is not a loaded root`,
506
+ { entity: report.name },
507
+ );
508
+ }
509
+ let model: MetaData;
510
+ try {
511
+ model = reportReadModel(report, this.metadata);
512
+ } catch (cause) {
513
+ const detail = cause instanceof Error ? cause.message : String(cause);
514
+ throw new MetadataError(`Report '${report.name}' cannot be read: ${detail}`, { entity: report.name, cause });
515
+ }
516
+ if (!model.children().some((c) => isReadOnlySource(c))) {
517
+ throw new MetadataError(
518
+ `Report '${report.name}' is not served: it declares no read-only source, so it has no view to read`,
519
+ { entity: report.name },
520
+ );
521
+ }
522
+ return model;
523
+ }
524
+
525
+ /**
526
+ * Resolve an entity for an operation that needs an identity or writes: get-by-id,
527
+ * reference encode/decode, and every create/update/delete. A report (FR-044) is an
528
+ * aggregate over a view — it has no primary key and no write target — so these are
529
+ * refused by name before anything else is looked at (a sourceless report included).
530
+ */
531
+ private requireIdentified(entityName: string, op: string): MetaData {
532
+ const entity = this.requireObject(entityName);
533
+ if (entity.subType === OBJECT_SUBTYPE_REPORT) {
534
+ throw new MetadataError(
535
+ `${op} is not supported on '${entityName}': a report is read-only and has no identity ` +
536
+ `(read it with findMany, findFirst or count)`,
537
+ { entity: entityName },
538
+ );
539
+ }
540
+ return entity;
541
+ }
542
+
481
543
  private toJsRow(entity: MetaData, dbRow: Row): Row {
482
544
  const { dbToJs } = this.nameMap(entity);
483
545
  const out: Row = {};
@@ -3,6 +3,7 @@ import {
3
3
  TYPE_FIELD, TYPE_IDENTITY,
4
4
  IDENTITY_SUBTYPE_PRIMARY,
5
5
  IDENTITY_ATTR_FIELDS,
6
+ OBJECT_SUBTYPE_REPORT,
6
7
  DEFAULT_COLUMN_NAMING_STRATEGY,
7
8
  resolveTableName, resolveColumnName,
8
9
  } from "@metaobjectsdev/metadata";
@@ -193,7 +194,19 @@ export function buildSelectSpec(
193
194
  strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
194
195
  ): SelectSpec {
195
196
  const allFields = projectedFields ?? listFieldNames(entity);
196
- const pkFields = resolvePkFields(entity);
197
+ // A report's read model (FR-044) has no identity, so there is no key to add to the
198
+ // column list. Scoped to the report subtype: every other object still requires one.
199
+ const isReport = entity.subType === OBJECT_SUBTYPE_REPORT;
200
+ // A report node as DECLARED has no field children (its shape is derived); only its
201
+ // read model does. Selecting from the bare node would be a query with no columns.
202
+ if (isReport && listFieldNames(entity).length === 0) {
203
+ throw new MetadataError(
204
+ `Report '${entity.name}' has no fields to select: a report is read through its read model ` +
205
+ `(reportReadModel), not through the declared node`,
206
+ { entity: entity.name },
207
+ );
208
+ }
209
+ const pkFields = isReport ? [] : resolvePkFields(entity);
197
210
  const fieldSet = new Set<string>(allFields);
198
211
  for (const pk of pkFields) fieldSet.add(pk);
199
212
 
@@ -6,6 +6,7 @@ import {
6
6
  CARDINALITY_ONE, CARDINALITY_MANY,
7
7
  DEFAULT_COLUMN_NAMING_STRATEGY,
8
8
  resolveRelationshipReference,
9
+ refTarget,
9
10
  } from "@metaobjectsdev/metadata";
10
11
  import { MetadataError } from "./errors.js";
11
12
  import {
@@ -31,7 +32,7 @@ export interface RelationDescriptor {
31
32
  */
32
33
  function findReferenceFkField(
33
34
  holder: MetaData,
34
- targetName: string,
35
+ targetName: MetaData,
35
36
  relationshipName: string,
36
37
  sourceRefField?: string,
37
38
  ): string | undefined {
@@ -68,26 +69,26 @@ export function resolveRelationDescriptor(
68
69
  { entity: sourceEntity.name },
69
70
  );
70
71
  }
71
- // ADR-0039: resolving — @sourceRefField may be inherited via extends.
72
- const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
73
- const fkField = findReferenceFkField(sourceEntity, targetEntityName, child.name, declaredRefField);
74
- if (!fkField) {
72
+ // ADR-0059 R7: the target the loader bound, never a short-name match.
73
+ const target = refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF);
74
+ if (!target || target.type !== TYPE_OBJECT) {
75
75
  throw new MetadataError(
76
- `Relationship '${relationName}' on '${sourceEntity.name}' has no identity.reference targeting '${targetEntityName}'`,
76
+ `Target entity '${targetEntityName}' not found for relation '${relationName}' on '${sourceEntity.name}'`,
77
77
  { entity: sourceEntity.name },
78
78
  );
79
79
  }
80
- // ADR-0039: effective children — resolve rather than rely on root being unextended.
81
- const target = root.children().find((c) => c.type === TYPE_OBJECT && c.name === targetEntityName);
82
- if (!target) {
80
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
81
+ const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
82
+ const fkField = findReferenceFkField(sourceEntity, target, child.name, declaredRefField);
83
+ if (!fkField) {
83
84
  throw new MetadataError(
84
- `Target entity '${targetEntityName}' not found for relation '${relationName}' on '${sourceEntity.name}'`,
85
+ `Relationship '${relationName}' on '${sourceEntity.name}' has no identity.reference targeting '${targetEntityName}'`,
85
86
  { entity: sourceEntity.name },
86
87
  );
87
88
  }
88
89
  return {
89
90
  cardinality: "one",
90
- targetEntityName,
91
+ targetEntityName: target.name,
91
92
  sourceField: fkField,
92
93
  targetField: resolvePkFields(target)[0]!,
93
94
  };
@@ -103,15 +104,15 @@ export function resolveRelationDescriptor(
103
104
  // ADR-0039: effective attrs — @cardinality/@objectRef may be inherited.
104
105
  const card = child.attr(RELATIONSHIP_ATTR_CARDINALITY);
105
106
  if (card !== CARDINALITY_ONE) continue;
106
- const targetEntityName = child.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
107
- if (targetEntityName !== sourceEntity.name) continue;
107
+ // ADR-0059 R7: compare the bound target, not a name.
108
+ if (refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF) !== sourceEntity) continue;
108
109
  const inverseName = inversePluralName(other.name);
109
110
  if (inverseName !== relationName) continue;
110
111
  // ADR-0039: resolving — @sourceRefField may be inherited via extends. This
111
112
  // FK lives on `other` and belongs to `other`'s own relationship `child` —
112
113
  // not to anything declared on sourceEntity (see #368 module comment).
113
114
  const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
114
- const fkField = findReferenceFkField(other, sourceEntity.name, child.name, declaredRefField);
115
+ const fkField = findReferenceFkField(other, sourceEntity, child.name, declaredRefField);
115
116
  if (!fkField) {
116
117
  throw new MetadataError(
117
118
  `Inverse relationship for '${relationName}' on '${sourceEntity.name}': entity '${other.name}' has no identity.reference targeting '${sourceEntity.name}'`,
@@ -3,6 +3,10 @@
3
3
  // - SQLite has no native boolean: booleans are stored as 0/1 ints, so we map
4
4
  // boolean↔int on the way in/out. MySQL's BOOLEAN is TINYINT(1) and mysql2 reads it
5
5
  // back as 0/1, so it takes the same mapping.
6
+ // - field.decimal is the canonical decimal STRING on every dialect (ADR-0019 Amendment 1): a
7
+ // string Postgres/MySQL hand back loses its scale (`100.5000` -> `100.5`), a number SQLite/D1
8
+ // hand back is rendered at 15 significant digits, and a value written as a number or in
9
+ // exponent form is bound as its exact canonical string.
6
10
  // - JSONB-backed object/map fields: the driver (node-postgres) does NOT
7
11
  // serialize a plain JS object to a jsonb column — it must arrive as a JSON
8
12
  // string, or the write fails / writes "[object Object]". A `field.object`
@@ -14,25 +18,32 @@
14
18
  // strings for temporal columns directly, and the temporal read parsers pin the
15
19
  // wire form.
16
20
 
17
- import type { MetaData } from "@metaobjectsdev/metadata";
21
+ import { refTarget, type MetaData } from "@metaobjectsdev/metadata";
18
22
  import {
19
23
  TYPE_FIELD,
20
24
  FIELD_SUBTYPE_BOOLEAN,
25
+ FIELD_SUBTYPE_DECIMAL,
26
+ FIELD_SUBTYPE_MAP,
21
27
  FIELD_SUBTYPE_ENUM,
22
28
  FIELD_SUBTYPE_OBJECT,
23
29
  FIELD_SUBTYPE_TIMESTAMP,
24
30
  FIELD_ATTR_STORAGE,
31
+ FIELD_ATTR_OBJECT_REF,
32
+ OBJECT_SUBTYPE_VALUE,
25
33
  FIELD_ATTR_DB_COLUMN_TYPE,
26
34
  FIELD_ATTR_INT_VALUE_MAP,
35
+ FIELD_ATTR_PRECISION,
36
+ FIELD_ATTR_SCALE,
27
37
  STORAGE_JSONB,
28
38
  STORAGE_FLATTENED,
29
39
  DB_COLUMN_TYPE_JSONB,
30
40
  } from "@metaobjectsdev/metadata";
31
41
  import type { Dialect, Row } from "./persistence-driver.js";
42
+ import { decimalFits, decimalToWire, parseDecimalInput } from "./decimal-wire.js";
32
43
 
33
44
  export function coerceRowOnRead(entity: MetaData, row: Row, dialect: Dialect): Row {
34
45
  const hydrated = parseJsonTextArrays(entity, deserializeJsonbObjectFields(entity, row));
35
- const decoded = decodeIntBackedEnums(entity, hydrated);
46
+ const decoded = canonicalizeDecimals(entity, decodeIntBackedEnums(entity, hydrated));
36
47
  if (dialect !== "sqlite" && dialect !== "mysql") return decoded;
37
48
  return mapBooleansFromInt(entity, decoded);
38
49
  }
@@ -130,7 +141,7 @@ function encodeIntBackedEnums(entity: MetaData, row: Row): Row {
130
141
  }
131
142
 
132
143
  export function coerceRowOnWrite(entity: MetaData, row: Row, dialect: Dialect): Row {
133
- const jsonbColumned = serializeJsonbColumns(entity, row);
144
+ const jsonbColumned = serializeJsonbColumns(entity, bindDecimals(entity, row));
134
145
  // Dialect-independent: an int-backed enum's column is INTEGER on every dialect
135
146
  // (SQLite has one integer storage class), so the symbol→int encode is not gated
136
147
  // on the dialect the way the boolean mapping below is.
@@ -212,8 +223,10 @@ function mapBooleansFromInt(entity: MetaData, row: Row): Row {
212
223
  if (child.type !== TYPE_FIELD) continue;
213
224
  if (child.subType !== FIELD_SUBTYPE_BOOLEAN) continue;
214
225
  const v = out[child.name];
215
- if (v === 0) out[child.name] = false;
216
- else if (v === 1) out[child.name] = true;
226
+ // A report's boolean measure (`@agg: any` / `all`) under a condition is a MySQL BIGINT, which
227
+ // the pool's `bigNumberStrings` hands back as text; a plain TINYINT(1) arrives as a number.
228
+ if (v === 0 || v === "0") out[child.name] = false;
229
+ else if (v === 1 || v === "1") out[child.name] = true;
217
230
  }
218
231
  return out;
219
232
  }
@@ -279,3 +292,101 @@ function parseJsonTextArrays(entity: MetaData, row: Row): Row {
279
292
  }
280
293
  return out ?? row;
281
294
  }
295
+
296
+ /** The `object.value` a jsonb `field.object` references, or undefined (not a value object, or unresolved). */
297
+ function valueObjectOf(field: MetaData): MetaData | undefined {
298
+ // A field.map of value objects binds @objectRef the same way (its values are the value objects).
299
+ if (field.type !== TYPE_FIELD || (field.subType !== FIELD_SUBTYPE_OBJECT && field.subType !== FIELD_SUBTYPE_MAP)) return undefined;
300
+ // ADR-0059 R7: the object the loader bound @objectRef to.
301
+ const target = refTarget(field, FIELD_ATTR_OBJECT_REF);
302
+ return target?.subType === OBJECT_SUBTYPE_VALUE ? target : undefined;
303
+ }
304
+
305
+ /**
306
+ * Apply `convert` to every `field.decimal` of `owner` present in `row`, and recurse into a
307
+ * `field.object` whose value object declares decimals (captain ruling C1: a decimal inside a
308
+ * jsonb value object follows the top-level rule). `convert` answers the new value; a row, an
309
+ * object or an array is copied only when something changed.
310
+ */
311
+ function mapDecimals(
312
+ owner: MetaData,
313
+ row: Row,
314
+ convert: (field: MetaData, value: unknown) => unknown,
315
+ ): Row {
316
+ let out: Row | null = null;
317
+ // Effective children (ADR-0039): a decimal may be inherited via extends, and a report's
318
+ // derived ratio / average / sum is a decimal field of the report itself.
319
+ for (const child of owner.children()) {
320
+ if (child.type !== TYPE_FIELD) continue;
321
+ if (!(child.name in row)) continue;
322
+ const v = row[child.name];
323
+ if (v === null || v === undefined) continue;
324
+ let next: unknown = v;
325
+ if (child.subType === FIELD_SUBTYPE_DECIMAL) {
326
+ next = convert(child, v);
327
+ } else {
328
+ const vo = valueObjectOf(child);
329
+ if (vo === undefined) continue;
330
+ const one = (el: unknown): unknown =>
331
+ el !== null && typeof el === "object" && !Array.isArray(el) ? mapDecimals(vo, el as Row, convert) : el;
332
+ if (child.subType === FIELD_SUBTYPE_MAP) {
333
+ // The rule applies to EACH map value; keys are arbitrary text, never compared with the
334
+ // value object's member names (a key named `amount` is just a key).
335
+ if (typeof v === "object" && !Array.isArray(v)) {
336
+ let mapped: Row | null = null;
337
+ for (const [key, el] of Object.entries(v as Row)) {
338
+ const m = one(el);
339
+ if (m !== el) (mapped ??= { ...(v as Row) })[key] = m;
340
+ }
341
+ if (mapped !== null) next = mapped;
342
+ }
343
+ } else if (Array.isArray(v)) {
344
+ const mapped = v.map(one);
345
+ if (mapped.some((el, i) => el !== v[i])) next = mapped;
346
+ } else {
347
+ next = one(v);
348
+ }
349
+ }
350
+ if (next === v) continue;
351
+ out ??= { ...row };
352
+ out[child.name] = next;
353
+ }
354
+ return out ?? row;
355
+ }
356
+
357
+ /** Read side: every `field.decimal` the row carries becomes its canonical string. */
358
+ function canonicalizeDecimals(entity: MetaData, row: Row): Row {
359
+ return mapDecimals(entity, row, (_field, v) => decimalToWire(v));
360
+ }
361
+
362
+ /**
363
+ * Write side: a `field.decimal` value is bound as its exact canonical string, and a decimal
364
+ * declared `isArray` as an array of them. A string in the JSON-number grammar or a number a double
365
+ * holds exactly is accepted; anything else, or a value the declared `@precision` / `@scale` cannot
366
+ * hold (W3), is refused here rather than reaching a column that would round it, reject it with a
367
+ * driver error, or store garbage. A decimal inside a jsonb value object is bound the same way.
368
+ */
369
+ function bindDecimals(entity: MetaData, row: Row): Row {
370
+ return mapDecimals(entity, row, (child, v) => {
371
+ // ADR-0039: resolving — a decimal, its isArray and its @precision / @scale may be inherited.
372
+ const precision = child.attr(FIELD_ATTR_PRECISION);
373
+ const scale = child.attr(FIELD_ATTR_SCALE);
374
+ const bind = (value: unknown): string => {
375
+ const canonical = parseDecimalInput(value);
376
+ if (canonical === undefined) {
377
+ throw new Error(`field.decimal '${child.name}' cannot be written: ${JSON.stringify(value)} is not a decimal number (a string in the JSON-number grammar, or a number a double holds exactly: send a string for more than 15 significant digits)`);
378
+ }
379
+ if (!decimalFits(canonical, typeof precision === "number" ? precision : undefined, typeof scale === "number" ? scale : undefined)) {
380
+ throw new Error(`field.decimal '${child.name}' cannot be written: ${canonical} does not fit @precision ${String(precision)} / @scale ${String(scale ?? 0)}`);
381
+ }
382
+ return canonical;
383
+ };
384
+ if (child.resolvedIsArray()) {
385
+ if (!Array.isArray(v)) {
386
+ throw new Error(`field.decimal '${child.name}' cannot be written: ${JSON.stringify(v)} is not an array of decimal numbers`);
387
+ }
388
+ return v.map((element) => (element === null ? null : bind(element)));
389
+ }
390
+ return bind(v);
391
+ });
392
+ }