@metaobjectsdev/runtime-ts 0.9.0-rc.1 → 0.10.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 (75) hide show
  1. package/dist/drivers/drizzle-driver.d.ts.map +1 -1
  2. package/dist/drivers/drizzle-driver.js +5 -1
  3. package/dist/drivers/drizzle-driver.js.map +1 -1
  4. package/dist/drivers/in-memory-driver.js +2 -0
  5. package/dist/drivers/in-memory-driver.js.map +1 -1
  6. package/dist/drivers/kysely-driver.js +1 -0
  7. package/dist/drivers/kysely-driver.js.map +1 -1
  8. package/dist/drizzle-fastify/index.d.ts +16 -2
  9. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  10. package/dist/drizzle-fastify/index.js +45 -13
  11. package/dist/drizzle-fastify/index.js.map +1 -1
  12. package/dist/drizzle-fastify/mount-m2m.d.ts +29 -0
  13. package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -0
  14. package/dist/drizzle-fastify/mount-m2m.js +94 -0
  15. package/dist/drizzle-fastify/mount-m2m.js.map +1 -0
  16. package/dist/drizzle-fastify/util.d.ts +2 -0
  17. package/dist/drizzle-fastify/util.d.ts.map +1 -1
  18. package/dist/drizzle-fastify/util.js +5 -0
  19. package/dist/drizzle-fastify/util.js.map +1 -1
  20. package/dist/extract-object.d.ts.map +1 -1
  21. package/dist/extract-object.js +14 -5
  22. package/dist/extract-object.js.map +1 -1
  23. package/dist/identity-strategy.d.ts.map +1 -1
  24. package/dist/identity-strategy.js +2 -1
  25. package/dist/identity-strategy.js.map +1 -1
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +5 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/llm-recorder.d.ts +72 -0
  31. package/dist/llm-recorder.d.ts.map +1 -0
  32. package/dist/llm-recorder.js +82 -0
  33. package/dist/llm-recorder.js.map +1 -0
  34. package/dist/n2m-resolver.d.ts +7 -8
  35. package/dist/n2m-resolver.d.ts.map +1 -1
  36. package/dist/n2m-resolver.js +115 -38
  37. package/dist/n2m-resolver.js.map +1 -1
  38. package/dist/object-manager.d.ts +4 -0
  39. package/dist/object-manager.d.ts.map +1 -1
  40. package/dist/object-manager.js +71 -18
  41. package/dist/object-manager.js.map +1 -1
  42. package/dist/persistence-driver.d.ts +3 -0
  43. package/dist/persistence-driver.d.ts.map +1 -1
  44. package/dist/query-builder.d.ts +13 -3
  45. package/dist/query-builder.d.ts.map +1 -1
  46. package/dist/query-builder.js +19 -10
  47. package/dist/query-builder.js.map +1 -1
  48. package/dist/tph.d.ts +14 -0
  49. package/dist/tph.d.ts.map +1 -0
  50. package/dist/tph.js +37 -0
  51. package/dist/tph.js.map +1 -0
  52. package/dist/type-coercer.d.ts.map +1 -1
  53. package/dist/type-coercer.js +91 -8
  54. package/dist/type-coercer.js.map +1 -1
  55. package/dist/validator-runner.d.ts.map +1 -1
  56. package/dist/validator-runner.js +24 -3
  57. package/dist/validator-runner.js.map +1 -1
  58. package/package.json +62 -51
  59. package/src/drivers/drizzle-driver.ts +5 -0
  60. package/src/drivers/in-memory-driver.ts +2 -0
  61. package/src/drivers/kysely-driver.ts +1 -0
  62. package/src/drizzle-fastify/index.ts +55 -14
  63. package/src/drizzle-fastify/mount-m2m.ts +126 -0
  64. package/src/drizzle-fastify/util.ts +6 -0
  65. package/src/extract-object.ts +16 -6
  66. package/src/identity-strategy.ts +2 -1
  67. package/src/index.ts +7 -0
  68. package/src/llm-recorder.ts +166 -0
  69. package/src/n2m-resolver.ts +143 -57
  70. package/src/object-manager.ts +67 -18
  71. package/src/persistence-driver.ts +2 -1
  72. package/src/query-builder.ts +33 -8
  73. package/src/tph.ts +46 -0
  74. package/src/type-coercer.ts +94 -8
  75. package/src/validator-runner.ts +23 -3
@@ -1,28 +1,48 @@
1
- // Two-stage N:M: first query the join entity for FK pairs, then query the target entity for the rows.
2
- // The relationship declares @joinEntity + @joinFields: [sourceJoinField, targetJoinField].
3
-
4
- import type { ColumnNamingStrategy, MetaData } from "@metaobjectsdev/metadata";
1
+ // Two-stage M:N resolution.
2
+ //
3
+ // A M:N relationship declares only the slim FR-018 vocabulary on the source
4
+ // entity: `@cardinality: "many"` + `@objectRef: <target>` + `@through:
5
+ // <junction>` (plus optional `@sourceRefField` / `@symmetric` for self-joins).
6
+ // It does NOT restate the junction FK columns — those are DERIVED from the
7
+ // junction entity's two `identity.reference` children via the shared
8
+ // `deriveM2MFields` helper (the SSOT for FK direction, the same one the loader
9
+ // validator + every other port use). This kills the pre-FR-018 stopgap that
10
+ // read `@joinEntity` / `@joinFields` off the relationship.
11
+ //
12
+ // Resolution has three modes (see the FR-018 design):
13
+ // 1. Hetero (source != target): junction WHERE sourceField (IN|=) source.pk,
14
+ // collect targetField, then target WHERE pk IN (...).
15
+ // 2. Directed self-join (`@sourceRefField`): identical traversal; the helper
16
+ // has already picked which junction FK is the source side.
17
+ // 3. Symmetric self-join (`@symmetric: true`): single-row storage, union on
18
+ // read — junction WHERE sourceField (IN|=) id OR targetField (IN|=) id;
19
+ // for each row the related id is whichever FK column is NOT the source id.
20
+
21
+ import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
5
22
  import {
6
23
  TYPE_OBJECT, TYPE_FIELD, TYPE_RELATIONSHIP,
7
- RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF,
8
- RELATIONSHIP_ATTR_JOIN_ENTITY, RELATIONSHIP_ATTR_JOIN_FIELDS,
24
+ RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH,
9
25
  CARDINALITY_MANY,
10
26
  DEFAULT_COLUMN_NAMING_STRATEGY,
11
27
  resolveColumnName,
28
+ deriveM2MFields,
12
29
  } from "@metaobjectsdev/metadata";
30
+ import type { MetaRelationship } from "@metaobjectsdev/metadata";
13
31
  import { MetadataError } from "./errors.js";
14
32
  import { buildSelectSpec, resolvePkFields } from "./query-builder.js";
15
- import type { SelectSpec, PrimitiveValue, Row } from "./persistence-driver.js";
33
+ import type { SelectSpec, WhereClause, PrimitiveValue, Row } from "./persistence-driver.js";
16
34
 
17
35
  export interface N2mDescriptor {
18
- /** Entity that declares the relationship (source of the lookup; its PK feeds sourceJoinField). */
36
+ /** Entity that declares the relationship (source of the lookup; its PK feeds sourceField). */
19
37
  sourceEntityName: string;
20
38
  targetEntityName: string;
21
39
  joinEntityName: string;
22
- /** Field name on the join entity holding the source-side FK. */
40
+ /** Junction FK field holding the source-side key (derived from the junction's references). */
23
41
  sourceJoinField: string;
24
- /** Field name on the join entity holding the target-side FK. */
42
+ /** Junction FK field holding the target-side key (derived from the junction's references). */
25
43
  targetJoinField: string;
44
+ /** Undirected self-join: union both junction FK columns at read time. */
45
+ symmetric: boolean;
26
46
  }
27
47
 
28
48
  export interface N2mLazyOutput {
@@ -31,12 +51,9 @@ export interface N2mLazyOutput {
31
51
  makeTargetSpec: (joinRows: Row[]) => SelectSpec | null;
32
52
  }
33
53
 
34
- export interface N2mBatchOutput {
35
- joinSpec: SelectSpec;
36
- makeTargetSpec: (joinRows: Row[]) => SelectSpec | null;
37
- }
54
+ export type N2mBatchOutput = N2mLazyOutput;
38
55
 
39
- /** Returns null if the named relationship is not N:M — caller should try resolveRelationDescriptor. */
56
+ /** Returns null if the named relationship is not M:N — caller should try resolveRelationDescriptor. */
40
57
  export function resolveN2mDescriptor(
41
58
  sourceEntity: MetaData,
42
59
  relationName: string,
@@ -46,29 +63,37 @@ export function resolveN2mDescriptor(
46
63
  if (child.type !== TYPE_RELATIONSHIP) continue;
47
64
  if (child.name !== relationName) continue;
48
65
  if (child.ownAttr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_MANY) continue;
49
- const targetEntityName = child.ownAttr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
50
- const joinEntityName = child.ownAttr(RELATIONSHIP_ATTR_JOIN_ENTITY) as string | undefined;
51
- const joinFields = child.ownAttr(RELATIONSHIP_ATTR_JOIN_FIELDS);
52
- if (!targetEntityName || !joinEntityName || !Array.isArray(joinFields) || joinFields.length !== 2) {
66
+ if (child.ownAttr(RELATIONSHIP_ATTR_THROUGH) === undefined) continue; // 1:N many — not M:N.
67
+
68
+ const rel = child as MetaRelationship;
69
+ const targetEntityName = rel.ownAttr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
70
+ const joinEntityName = rel.ownAttr(RELATIONSHIP_ATTR_THROUGH) as string | undefined;
71
+ if (!targetEntityName || !joinEntityName) {
53
72
  throw new MetadataError(
54
- `N:M relationship '${relationName}' on '${sourceEntity.name}' requires @objectRef + @joinEntity + @joinFields: [sourceFk, targetFk]`,
73
+ `M:N relationship '${relationName}' on '${sourceEntity.name}' requires @objectRef + @through`,
55
74
  { entity: sourceEntity.name },
56
75
  );
57
76
  }
58
- const targetExists = root.ownChildren().some((c) => c.type === TYPE_OBJECT && c.name === targetEntityName);
59
- if (!targetExists) {
60
- throw new MetadataError(`Target entity '${targetEntityName}' not found`, { entity: sourceEntity.name });
61
- }
62
- const joinExists = root.ownChildren().some((c) => c.type === TYPE_OBJECT && c.name === joinEntityName);
63
- if (!joinExists) {
64
- throw new MetadataError(`Join entity '${joinEntityName}' not found`, { entity: sourceEntity.name });
77
+
78
+ // Derive the [sourceFK, targetFK] junction columns from the junction's two
79
+ // identity.reference children (handles hetero / directed / symmetric).
80
+ let fields;
81
+ try {
82
+ fields = deriveM2MFields(rel, sourceEntity as MetaObject, root as MetaRoot);
83
+ } catch (e) {
84
+ throw new MetadataError(
85
+ `M:N relationship '${relationName}' on '${sourceEntity.name}': ${(e as Error).message}`,
86
+ { entity: sourceEntity.name },
87
+ );
65
88
  }
89
+
66
90
  return {
67
91
  sourceEntityName: sourceEntity.name,
68
92
  targetEntityName,
69
93
  joinEntityName,
70
- sourceJoinField: String(joinFields[0]),
71
- targetJoinField: String(joinFields[1]),
94
+ sourceJoinField: fields.sourceField,
95
+ targetJoinField: fields.targetField,
96
+ symmetric: rel.symmetric,
72
97
  };
73
98
  }
74
99
  return null;
@@ -80,21 +105,7 @@ export function buildN2mLazySpecs(
80
105
  root: MetaData,
81
106
  strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
82
107
  ): N2mLazyOutput {
83
- const joinEntity = mustGetEntity(root, desc.joinEntityName);
84
- const targetEntity = mustGetEntity(root, desc.targetEntityName);
85
- const sourcePkField = resolvePkFields(mustGetEntity(root, desc.sourceEntityName))[0]!;
86
- const sourcePkValue = sourceRecord[sourcePkField];
87
-
88
- const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourcePkValue as PrimitiveValue }, {}, undefined, strategy);
89
-
90
- const makeTargetSpec = (joinRows: Row[]): SelectSpec | null => {
91
- const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity, strategy);
92
- if (targetIds.length === 0) return null;
93
- const targetPkField = resolvePkFields(targetEntity)[0]!;
94
- return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {}, undefined, strategy);
95
- };
96
-
97
- return { joinSpec, makeTargetSpec };
108
+ return buildSpecs(desc, sourceRecord, root, strategy);
98
109
  }
99
110
 
100
111
  export function buildN2mBatchSpecs(
@@ -103,51 +114,126 @@ export function buildN2mBatchSpecs(
103
114
  root: MetaData,
104
115
  strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
105
116
  ): N2mBatchOutput {
117
+ return buildSpecs(desc, sourceRecords, root, strategy);
118
+ }
119
+
120
+ // Single + batch share one code path: a single record is the one-element case.
121
+ function buildSpecs(
122
+ desc: N2mDescriptor,
123
+ source: Row | Row[],
124
+ root: MetaData,
125
+ strategy: ColumnNamingStrategy,
126
+ ): N2mLazyOutput {
106
127
  const joinEntity = mustGetEntity(root, desc.joinEntityName);
107
128
  const targetEntity = mustGetEntity(root, desc.targetEntityName);
108
- const sourcePkField = resolvePkFields(mustGetEntity(root, desc.sourceEntityName))[0]!;
109
- const sourceIds = collectIds(sourceRecords, sourcePkField);
129
+ const sourceEntity = mustGetEntity(root, desc.sourceEntityName);
130
+ const sourcePkField = resolvePkFields(sourceEntity)[0]!;
131
+
132
+ const records = Array.isArray(source) ? source : [source];
133
+ const sourceIds = collectIds(records, sourcePkField);
134
+ const sourceIdSet = new Set<PrimitiveValue>(sourceIds);
135
+
136
+ const sourceCol = resolveJoinColumnName(joinEntity, desc.sourceJoinField, strategy);
137
+ const targetCol = resolveJoinColumnName(joinEntity, desc.targetJoinField, strategy);
110
138
 
111
- const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourceIds }, {}, undefined, strategy);
139
+ // buildSelectSpec compiles a filter on the entity's fields; for symmetric we
140
+ // need an OR across two columns, which the filter DSL can't express. So we
141
+ // build the join spec directly off buildSelectSpec then swap in the where.
142
+ const joinSpec = buildSelectSpec(joinEntity, undefined, {}, undefined, strategy);
143
+ joinSpec.where = desc.symmetric
144
+ ? { kind: "or", clauses: [inOrEq(sourceCol, sourceIds), inOrEq(targetCol, sourceIds)] }
145
+ : inOrEq(sourceCol, sourceIds);
112
146
 
113
147
  const makeTargetSpec = (joinRows: Row[]): SelectSpec | null => {
114
- const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity, strategy);
148
+ const targetIds = desc.symmetric
149
+ ? collectSymmetricTargetIds(joinRows, sourceCol, targetCol, sourceIdSet)
150
+ : collectColumnIds(joinRows, targetCol);
115
151
  if (targetIds.length === 0) return null;
116
152
  const targetPkField = resolvePkFields(targetEntity)[0]!;
117
- return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {}, undefined, strategy);
153
+ // PK values are always string|number; the IN filter type excludes boolean.
154
+ const ids = targetIds.filter((v): v is string | number => typeof v !== "boolean");
155
+ return buildSelectSpec(targetEntity, { [targetPkField]: ids }, {}, undefined, strategy);
118
156
  };
119
157
 
120
158
  return { joinSpec, makeTargetSpec };
121
159
  }
122
160
 
161
+ /** `= x` for a single id, `IN (...)` for many (degenerate empty → IN [] = no rows). */
162
+ function inOrEq(column: string, ids: PrimitiveValue[]): WhereClause {
163
+ return ids.length === 1
164
+ ? { kind: "eq", column, value: ids[0]! }
165
+ : { kind: "in", column, values: ids };
166
+ }
167
+
123
168
  function mustGetEntity(root: MetaData, name: string): MetaData {
124
169
  const e = root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === name);
125
170
  if (!e) throw new MetadataError(`Entity '${name}' not found`, { entity: name });
126
171
  return e;
127
172
  }
128
173
 
129
- function collectIds(records: Row[], pkField: string): (string | number)[] {
174
+ function collectIds(records: Row[], pkField: string): PrimitiveValue[] {
130
175
  const seen = new Set<PrimitiveValue>();
131
176
  for (const r of records) {
132
177
  const v = r[pkField];
133
178
  if (v === null || v === undefined) continue;
134
179
  seen.add(v as PrimitiveValue);
135
180
  }
136
- return [...seen] as (string | number)[];
181
+ return [...seen];
137
182
  }
138
183
 
139
- function collectTargetIds(
140
- joinRows: Row[], targetJoinField: string, joinEntity: MetaData, strategy: ColumnNamingStrategy,
141
- ): (string | number)[] {
142
- // joinRows are raw column-keyed (driver hasn't been to-JS-row'd yet); resolve the metadata field name to its DB column.
143
- const dbColumn = resolveJoinColumnName(joinEntity, targetJoinField, strategy);
184
+ /** Distinct values of a raw (column-keyed) join column. */
185
+ function collectColumnIds(joinRows: Row[], dbColumn: string): PrimitiveValue[] {
144
186
  const seen = new Set<PrimitiveValue>();
145
187
  for (const r of joinRows) {
146
188
  const v = r[dbColumn];
147
189
  if (v === null || v === undefined) continue;
148
190
  seen.add(v as PrimitiveValue);
149
191
  }
150
- return [...seen] as (string | number)[];
192
+ return [...seen];
193
+ }
194
+
195
+ /**
196
+ * Symmetric union-on-read: this gathers the set of related ids to FETCH for the
197
+ * second-stage query. For each junction row (a,b) that surfaced via the
198
+ * `a IN ids OR b IN ids` join filter, the related endpoint is the column that is
199
+ * NOT a source — EXCEPT when BOTH columns are sources (two mutually-related
200
+ * records queried in the same batch), where both must be fetched so the
201
+ * eager-include grouping can attach a→b AND b→a. A self-loop row (a==b, a a
202
+ * source) yields a itself.
203
+ *
204
+ * Membership is compared by string-coerced key: the source ids come from the
205
+ * in-process source record (e.g. a JS number) while the junction FK values come
206
+ * straight off the driver, where a BIGINT key arrives as a string. Comparing
207
+ * raw would miss the match (number 1 !== string "1"); string keys bridge it.
208
+ */
209
+ function collectSymmetricTargetIds(
210
+ joinRows: Row[], sourceCol: string, targetCol: string, sourceIds: Set<PrimitiveValue>,
211
+ ): PrimitiveValue[] {
212
+ const sourceKeys = new Set<string>([...sourceIds].map(String));
213
+ const seen = new Set<PrimitiveValue>();
214
+ const add = (v: PrimitiveValue | null | undefined): void => {
215
+ if (v === null || v === undefined) return;
216
+ seen.add(v);
217
+ };
218
+ for (const r of joinRows) {
219
+ const a = r[sourceCol] as PrimitiveValue | null | undefined;
220
+ const b = r[targetCol] as PrimitiveValue | null | undefined;
221
+ const aIsSource = a !== null && a !== undefined && sourceKeys.has(String(a));
222
+ const bIsSource = b !== null && b !== undefined && sourceKeys.has(String(b));
223
+ // When a is a source, b is its related id; when b is a source, a is its
224
+ // related id. Both can hold at once (mutually-related batch members) — fetch
225
+ // both endpoints then. Falls back to "the non-matched column" when only one
226
+ // side matched (the common single-source-lookup case).
227
+ if (aIsSource) add(b);
228
+ if (bIsSource) add(a);
229
+ if (!aIsSource && !bIsSource) {
230
+ // Row surfaced via the join filter but neither column string-matches a
231
+ // source id (e.g. number/string skew not bridged here) — keep prior
232
+ // behavior: take whichever side is present.
233
+ add(a ?? b);
234
+ }
235
+ }
236
+ return [...seen];
151
237
  }
152
238
 
153
239
  export function resolveJoinColumnName(
@@ -1,7 +1,7 @@
1
1
  import type { MetaData } from "@metaobjectsdev/metadata";
2
2
  import {
3
3
  TYPE_OBJECT, TYPE_FIELD,
4
- FIELD_SUBTYPE_INT, FIELD_SUBTYPE_SHORT, FIELD_SUBTYPE_BYTE,
4
+ FIELD_SUBTYPE_INT,
5
5
  FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
6
6
  } from "@metaobjectsdev/metadata";
7
7
  import type {
@@ -11,8 +11,9 @@ import type {
11
11
  import {
12
12
  buildSelectSpec, buildCountSpec, buildInsertSpec, buildUpdateSpec, buildDeleteSpec,
13
13
  resolvePkFields, compileFilter,
14
- type Filter, type QueryOpts,
14
+ type Filter, type QueryOpts, type DiscriminatorScope,
15
15
  } from "./query-builder.js";
16
+ import { tphSubtypeOf, type TphSubtype } from "./tph.js";
16
17
  import {
17
18
  buildNameMap, resolveTableName, DEFAULT_COLUMN_NAMING_STRATEGY,
18
19
  type ColumnNamingStrategy, type EntityNameMap,
@@ -81,6 +82,20 @@ export class ObjectManager {
81
82
  return m;
82
83
  }
83
84
 
85
+ /** TPH discriminator scope for a subtype entity, or undefined for non-TPH. */
86
+ private tphScope(entity: MetaData): DiscriminatorScope | undefined {
87
+ const t = tphSubtypeOf(entity);
88
+ return t ? { field: t.field, value: t.value } : undefined;
89
+ }
90
+
91
+ /** AND the TPH discriminator predicate into a filter (subtype-scoped reads). */
92
+ private scopeFilter(filter: Filter | undefined, tph: TphSubtype | null): Filter | undefined {
93
+ if (tph === null) return filter;
94
+ const disc = { [tph.field]: { $eq: tph.value } } as Filter;
95
+ if (filter === undefined) return disc;
96
+ return { $and: [filter, disc] } as Filter;
97
+ }
98
+
84
99
  async findById(entityName: string, id: unknown, opts: ReadOpts = {}): Promise<Row | null> {
85
100
  const entity = this.requireEntity(entityName);
86
101
  const pkField = resolvePkFields(entity)[0]!;
@@ -90,7 +105,8 @@ export class ObjectManager {
90
105
  async findFirst(entityName: string, filter: Filter, opts: ReadOpts = {}): Promise<Row | null> {
91
106
  const entity = this.requireEntity(entityName);
92
107
  const driver = opts.tx ?? this.driver;
93
- const spec = buildSelectSpec(entity, filter, { ...opts, limit: 1 }, undefined, this.columnNamingStrategy);
108
+ const scoped = this.scopeFilter(filter, tphSubtypeOf(entity));
109
+ const spec = buildSelectSpec(entity, scoped, { ...opts, limit: 1 }, undefined, this.columnNamingStrategy);
94
110
  const row = await driver.selectOne(spec);
95
111
  if (row === null) return null;
96
112
  const jsRow = this.toJsRow(entity, row);
@@ -103,7 +119,8 @@ export class ObjectManager {
103
119
  async findMany(entityName: string, filter?: Filter, opts: ReadOpts = {}): Promise<Row[]> {
104
120
  const entity = this.requireEntity(entityName);
105
121
  const driver = opts.tx ?? this.driver;
106
- const spec = buildSelectSpec(entity, filter, opts, undefined, this.columnNamingStrategy);
122
+ const scoped = this.scopeFilter(filter, tphSubtypeOf(entity));
123
+ const spec = buildSelectSpec(entity, scoped, opts, undefined, this.columnNamingStrategy);
107
124
  const rows = (await driver.selectMany(spec)).map((r) => this.toJsRow(entity, r));
108
125
  if (opts.include && opts.include.length > 0) {
109
126
  await this.attachIncludes(entity, rows, opts.include, driver);
@@ -114,7 +131,8 @@ export class ObjectManager {
114
131
  async count(entityName: string, filter?: Filter, opts: Pick<ReadOpts, "tx"> = {}): Promise<number> {
115
132
  const entity = this.requireEntity(entityName);
116
133
  const driver = opts.tx ?? this.driver;
117
- return driver.count(buildCountSpec(entity, filter, this.columnNamingStrategy));
134
+ const scoped = this.scopeFilter(filter, tphSubtypeOf(entity));
135
+ return driver.count(buildCountSpec(entity, scoped, this.columnNamingStrategy));
118
136
  }
119
137
 
120
138
  async load(refString: string): Promise<Row | null> {
@@ -145,7 +163,11 @@ export class ObjectManager {
145
163
  const entity = this.requireEntity(entityName);
146
164
  const driver = opts.tx ?? this.driver;
147
165
 
148
- const restricted = this.applyViewRestriction(entity, data, opts.view);
166
+ const restricted0 = this.applyViewRestriction(entity, data, opts.view);
167
+ // FR-017 TPH: a subtype create injects its discriminator value (the entity
168
+ // names the subtype; the caller never sets it).
169
+ const tph = tphSubtypeOf(entity);
170
+ const restricted = tph ? { ...restricted0, [tph.field]: tph.value } : restricted0;
149
171
  const ident = resolveIdentity(entity, restricted);
150
172
  const merged: Row = { ...restricted, ...ident.values };
151
173
 
@@ -164,7 +186,15 @@ export class ObjectManager {
164
186
  const entity = this.requireEntity(entityName);
165
187
  const driver = opts.tx ?? this.driver;
166
188
 
167
- const restricted = this.applyViewRestriction(entity, data, opts.view);
189
+ const restricted0 = this.applyViewRestriction(entity, data, opts.view);
190
+ // FR-017 TPH: the discriminator is immutable — a subtype can't be changed
191
+ // into another subtype, so strip it from the patch before validating/writing.
192
+ const tph = tphSubtypeOf(entity);
193
+ let restricted = restricted0;
194
+ if (tph && tph.field in restricted0) {
195
+ const { [tph.field]: _omit, ...rest } = restricted0;
196
+ restricted = rest;
197
+ }
168
198
 
169
199
  // Partial mode: only validate fields the caller actually passed; absent keys are untouched.
170
200
  const validation = runValidators(entity, restricted, { partial: true });
@@ -173,7 +203,8 @@ export class ObjectManager {
173
203
  }
174
204
 
175
205
  const coerced = coerceRowOnWrite(entity, restricted, driver.dialect);
176
- const spec = buildUpdateSpec(entity, coerced, id, this.columnNamingStrategy);
206
+ // 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));
177
208
  const dbRow = await driver.update(spec);
178
209
  if (dbRow === null) {
179
210
  const mode = opts.ifMissing ?? DEFAULT_IF_MISSING;
@@ -186,7 +217,8 @@ export class ObjectManager {
186
217
  async delete(entityName: string, id: unknown, opts: WriteOpts = {}): Promise<boolean> {
187
218
  const entity = this.requireEntity(entityName);
188
219
  const driver = opts.tx ?? this.driver;
189
- const spec = buildDeleteSpec(entity, id, this.columnNamingStrategy);
220
+ // 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));
190
222
  const n = await driver.delete(spec);
191
223
  if (n === 0) {
192
224
  const mode = opts.ifMissing ?? DEFAULT_IF_MISSING;
@@ -334,16 +366,33 @@ export class ObjectManager {
334
366
  const sourceJoinDbCol = resolveJoinColumnName(joinEntity, n2m.sourceJoinField, this.columnNamingStrategy);
335
367
  const targetJoinDbCol = resolveJoinColumnName(joinEntity, n2m.targetJoinField, this.columnNamingStrategy);
336
368
  const targetPk = resolvePkFields(target)[0]!;
337
- const targetById = new Map(targetRows.map((r) => [r[targetPk], r]));
338
- const grouped = new Map<unknown, Row[]>();
369
+ // Key everything by String(): the join rows are raw (a BIGINT FK arrives
370
+ // as a string from the driver) while the JS rows' keys are coerced, so a
371
+ // number↔string mismatch would silently drop matches.
372
+ const key = (v: unknown): string | null => (v === null || v === undefined ? null : String(v));
373
+ const targetById = new Map(targetRows.map((r) => [key(r[targetPk]), r]));
374
+ const sourceKeys = new Set(records.map((r) => key(r[sourcePk])));
375
+ const grouped = new Map<string, Row[]>();
376
+ const attach = (ownerKey: string | null, relatedKey: string | null): void => {
377
+ if (ownerKey === null) return;
378
+ const t = relatedKey === null ? undefined : targetById.get(relatedKey);
379
+ if (!t) return;
380
+ if (!grouped.has(ownerKey)) grouped.set(ownerKey, []);
381
+ grouped.get(ownerKey)!.push(t);
382
+ };
339
383
  for (const j of joinRows) {
340
- const sk = j[sourceJoinDbCol];
341
- const tk = j[targetJoinDbCol];
342
- if (!grouped.has(sk)) grouped.set(sk, []);
343
- const t = targetById.get(tk);
344
- if (t) grouped.get(sk)!.push(t);
384
+ const a = key(j[sourceJoinDbCol]);
385
+ const b = key(j[targetJoinDbCol]);
386
+ if (n2m.symmetric) {
387
+ // Union-on-read: a row (a,b) relates a↔b. Attach to whichever
388
+ // endpoint(s) are in this batch; the related id is the OTHER one.
389
+ if (a !== null && sourceKeys.has(a)) attach(a, b);
390
+ if (b !== null && a !== b && sourceKeys.has(b)) attach(b, a);
391
+ } else {
392
+ attach(a, b);
393
+ }
345
394
  }
346
- for (const r of records) r[inc] = grouped.get(r[sourcePk]) ?? [];
395
+ for (const r of records) r[inc] = grouped.get(key(r[sourcePk]) ?? "\0") ?? [];
347
396
  continue;
348
397
  }
349
398
 
@@ -444,7 +493,7 @@ function formatValidationMessage(entityName: string, errors: { field: string; ru
444
493
  }
445
494
 
446
495
  const NUMERIC_SUBTYPES = new Set([
447
- FIELD_SUBTYPE_INT, FIELD_SUBTYPE_SHORT, FIELD_SUBTYPE_BYTE,
496
+ FIELD_SUBTYPE_INT,
448
497
  FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
449
498
  ]);
450
499
 
@@ -12,7 +12,8 @@ export type WhereClause =
12
12
  | { kind: "in"; column: string; values: PrimitiveValue[] }
13
13
  | { kind: "like"; column: string; pattern: string }
14
14
  | { kind: "isNull"; column: string; not: boolean }
15
- | { kind: "and"; clauses: WhereClause[] };
15
+ | { kind: "and"; clauses: WhereClause[] }
16
+ | { kind: "or"; clauses: WhereClause[] };
16
17
 
17
18
  export interface OrderBy {
18
19
  column: string;
@@ -38,13 +38,15 @@ export interface QueryOpts {
38
38
  }
39
39
 
40
40
  export function resolvePkFields(entity: MetaData): string[] {
41
- const primary = entity.ownChildren().find(
41
+ // Effective children (own + inherited via super) so a TPH subtype resolves
42
+ // the discriminator base's primary identity (FR-017).
43
+ const primary = entity.children().find(
42
44
  (c) => c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_PRIMARY,
43
45
  );
44
46
  if (!primary) {
45
47
  throw new MetadataError(`Entity '${entity.name}' has no primary identity`, { entity: entity.name });
46
48
  }
47
- const attr = primary.ownAttr(IDENTITY_ATTR_FIELDS);
49
+ const attr = primary.attr(IDENTITY_ATTR_FIELDS);
48
50
  if (!Array.isArray(attr) || attr.length === 0) {
49
51
  throw new MetadataError(`Entity '${entity.name}' primary identity has no @fields`, { entity: entity.name });
50
52
  }
@@ -53,14 +55,15 @@ export function resolvePkFields(entity: MetaData): string[] {
53
55
 
54
56
  function listFieldNames(entity: MetaData): string[] {
55
57
  const out: string[] = [];
56
- for (const child of entity.ownChildren()) {
58
+ // Effective children so a TPH subtype's column set is base fields + own.
59
+ for (const child of entity.children()) {
57
60
  if (child.type === TYPE_FIELD) out.push(child.name);
58
61
  }
59
62
  return out;
60
63
  }
61
64
 
62
65
  function getField(entity: MetaData, fieldName: string): MetaData {
63
- const f = entity.ownChildren().find((c) => c.type === TYPE_FIELD && c.name === fieldName);
66
+ const f = entity.children().find((c) => c.type === TYPE_FIELD && c.name === fieldName);
64
67
  if (!f) {
65
68
  throw new MetadataError(
66
69
  `Unknown field '${fieldName}' on entity '${entity.name}'`,
@@ -208,9 +211,32 @@ export function buildInsertSpec(
208
211
  };
209
212
  }
210
213
 
214
+ /**
215
+ * A TPH discriminator scope: AND'd into the by-id where so a subtype-scoped
216
+ * update/delete only matches rows of that subtype (a row of a different
217
+ * subtype is invisible → not found). `field` is the discriminator FIELD name;
218
+ * the column is resolved via the entity's naming strategy.
219
+ */
220
+ export interface DiscriminatorScope {
221
+ field: string;
222
+ value: PrimitiveValue;
223
+ }
224
+
225
+ function byIdWhere(
226
+ entity: MetaData, id: unknown, scope: DiscriminatorScope | undefined,
227
+ strategy: ColumnNamingStrategy,
228
+ ): WhereClause {
229
+ const pkColumn = resolveColumnName(getField(entity, resolvePkFields(entity)[0]!), strategy);
230
+ const pkEq: WhereClause = { kind: "eq", column: pkColumn, value: id as PrimitiveValue };
231
+ if (scope === undefined) return pkEq;
232
+ const discColumn = resolveColumnName(getField(entity, scope.field), strategy);
233
+ return { kind: "and", clauses: [pkEq, { kind: "eq", column: discColumn, value: scope.value }] };
234
+ }
235
+
211
236
  export function buildUpdateSpec(
212
237
  entity: MetaData, data: Row, id: unknown,
213
238
  strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
239
+ scope?: DiscriminatorScope,
214
240
  ): UpdateSpec {
215
241
  const values = rowToColumns(entity, data, strategy);
216
242
  const pkFields = resolvePkFields(entity);
@@ -220,11 +246,10 @@ export function buildUpdateSpec(
220
246
  { entity: entity.name },
221
247
  );
222
248
  }
223
- const pkColumn = resolveColumnName(getField(entity, pkFields[0]!), strategy);
224
249
  return {
225
250
  table: resolveTableName(entity),
226
251
  values,
227
- where: { kind: "eq", column: pkColumn, value: id as PrimitiveValue },
252
+ where: byIdWhere(entity, id, scope, strategy),
228
253
  returning: listFieldNames(entity).map((f) => resolveColumnName(getField(entity, f), strategy)),
229
254
  };
230
255
  }
@@ -232,6 +257,7 @@ export function buildUpdateSpec(
232
257
  export function buildDeleteSpec(
233
258
  entity: MetaData, id: unknown,
234
259
  strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
260
+ scope?: DiscriminatorScope,
235
261
  ): DeleteSpec {
236
262
  const pkFields = resolvePkFields(entity);
237
263
  if (pkFields.length !== 1) {
@@ -240,9 +266,8 @@ export function buildDeleteSpec(
240
266
  { entity: entity.name },
241
267
  );
242
268
  }
243
- const pkColumn = resolveColumnName(getField(entity, pkFields[0]!), strategy);
244
269
  return {
245
270
  table: resolveTableName(entity),
246
- where: { kind: "eq", column: pkColumn, value: id as PrimitiveValue },
271
+ where: byIdWhere(entity, id, scope, strategy),
247
272
  };
248
273
  }
package/src/tph.ts ADDED
@@ -0,0 +1,46 @@
1
+ // FR-017 — table-per-hierarchy (TPH) discriminator resolution for the runtime.
2
+ //
3
+ // A TPH SUBTYPE is an entity that declares `@discriminatorValue` and `extends`
4
+ // (transitively) a base that declares `@discriminator`. The ObjectManager uses
5
+ // this to:
6
+ // - inject the discriminator value on create (the entity names the subtype;
7
+ // the caller never sets it);
8
+ // - scope every read/update/delete to the subtype (a row of a different
9
+ // subtype is invisible), mirroring the generated per-subtype route's
10
+ // cross-subtype 404 and the drizzle-fastify `discriminator` option;
11
+ // - treat the discriminator as immutable (stripped from update patches).
12
+ //
13
+ // The discriminator FIELD name lives on the base (`@discriminator`); the VALUE
14
+ // lives on the subtype (`@discriminatorValue`). For deep hierarchies the base
15
+ // may be any ancestor, so we walk the resolved super chain to find it.
16
+
17
+ import type { MetaData } from "@metaobjectsdev/metadata";
18
+ import { OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE } from "@metaobjectsdev/metadata";
19
+
20
+ export interface TphSubtype {
21
+ /** The discriminator field NAME (declared on the base via `@discriminator`). */
22
+ field: string;
23
+ /** This subtype's discriminator VALUE (declared via `@discriminatorValue`). */
24
+ value: string;
25
+ }
26
+
27
+ /**
28
+ * If `entity` is a TPH subtype, return its discriminator field + value; else
29
+ * null. A subtype declares `@discriminatorValue` and inherits `@discriminator`
30
+ * from an ancestor in its resolved super chain.
31
+ */
32
+ export function tphSubtypeOf(entity: MetaData): TphSubtype | null {
33
+ const value = entity.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE);
34
+ if (value === undefined || value === null) return null;
35
+
36
+ // Walk the resolved super chain to find the @discriminator-bearing ancestor.
37
+ let ancestor: MetaData | undefined = entity.superResolved;
38
+ while (ancestor !== undefined) {
39
+ const field = ancestor.ownAttr(OBJECT_ATTR_DISCRIMINATOR);
40
+ if (field !== undefined && field !== null) {
41
+ return { field: String(field), value: String(value) };
42
+ }
43
+ ancestor = ancestor.superResolved;
44
+ }
45
+ return null;
46
+ }