@metaobjectsdev/runtime-ts 0.9.0 → 0.11.0-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 (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
@@ -0,0 +1,126 @@
1
+ // M:N traversal route — mounts `GET {path}/:id/{relationName}` returning the
2
+ // related target rows reached through a junction table.
3
+ //
4
+ // This is the Drizzle-direct executor for a many-to-many relationship. Codegen
5
+ // derives the static descriptor at BUILD time (the source/target junction FK
6
+ // columns come from the junction entity's two `identity.reference` children via
7
+ // the shared `deriveM2MFields` SSOT — see relation-resolver.ts in codegen-ts),
8
+ // then emits a `mountM2mRoute({...})` call carrying those resolved column names.
9
+ // At runtime this helper performs the same two-stage join the ObjectManager
10
+ // `n2m-resolver` performs, but expressed in Drizzle so it stays self-evident
11
+ // Drizzle code in the user's app (no ObjectManager dependency, no metadata at
12
+ // runtime — ADR-0001 build-time binding).
13
+ //
14
+ // Three modes, identical to the runtime resolver:
15
+ // 1. Hetero (sourceCol != targetCol entity): junction WHERE sourceCol = :id,
16
+ // collect targetCol, target WHERE pk IN (...).
17
+ // 2. Directed self-join: same traversal; codegen picked which junction FK is
18
+ // the source side (via @sourceRefField) → sourceColumn/targetColumn.
19
+ // 3. Symmetric self-join: junction WHERE sourceCol = :id OR targetCol = :id;
20
+ // per row the related id is whichever column is NOT the source id.
21
+
22
+ import type { FastifyInstance, RouteShorthandOptions } from "fastify";
23
+ import { eq, or, inArray } from "drizzle-orm";
24
+ import { parseId } from "./util.js";
25
+
26
+ // Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
27
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
28
+ type AnyDrizzle = any;
29
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
30
+ type AnyTable = any;
31
+
32
+ export interface M2mRouteOptions {
33
+ fastify: FastifyInstance;
34
+ /** Source resource path, e.g. "/posts". The route mounts at `{path}/:id/{relationName}`. */
35
+ path: string;
36
+ /** Navigation member name, e.g. "tags" → GET /posts/:id/tags. */
37
+ relationName: string;
38
+ /** User's Drizzle instance. */
39
+ db: AnyDrizzle;
40
+ /** The junction Drizzle table const (e.g. postTags). */
41
+ junctionTable: AnyTable;
42
+ /** The target Drizzle table const (e.g. tags). */
43
+ targetTable: AnyTable;
44
+ /** Junction FK column holding the SOURCE key (physical column name, e.g. "post_id"). */
45
+ sourceColumn: string;
46
+ /** Junction FK column holding the TARGET key (physical column name, e.g. "tag_id"). */
47
+ targetColumn: string;
48
+ /** Target entity PK column (physical column name, e.g. "id"). Defaults to "id". */
49
+ targetPkColumn?: string;
50
+ /** Undirected self-join: union both junction FK columns on read. */
51
+ symmetric: boolean;
52
+ /** Fastify route-level hooks (auth, etc.). */
53
+ routeOptions?: RouteShorthandOptions;
54
+ }
55
+
56
+ export function mountM2mRoute(opts: M2mRouteOptions): void {
57
+ const targetPk = opts.targetPkColumn ?? "id";
58
+ const route = `${opts.path}/:id/${opts.relationName}`;
59
+ const ro = opts.routeOptions ?? {};
60
+
61
+ opts.fastify.get(route, ro, async (req) => {
62
+ const { id } = req.params as { id: string };
63
+ const sourceId = parseId(id);
64
+
65
+ const srcCol = columnRef(opts.junctionTable, opts.sourceColumn);
66
+ const tgtCol = columnRef(opts.junctionTable, opts.targetColumn);
67
+
68
+ // Stage 1 — junction rows for this source id.
69
+ const joinWhere = opts.symmetric
70
+ ? or(eq(srcCol, sourceId), eq(tgtCol, sourceId))
71
+ : eq(srcCol, sourceId);
72
+ const joinRows = (await opts.db
73
+ .select({ src: srcCol, tgt: tgtCol })
74
+ .from(opts.junctionTable)
75
+ .where(joinWhere)) as Array<{ src: unknown; tgt: unknown }>;
76
+
77
+ // Collect the related target ids. Symmetric: the related endpoint is the
78
+ // column that is NOT the source id (compared by string key to bridge
79
+ // number/bigint-as-string driver skew). Otherwise: always the target column.
80
+ const sourceKey = String(sourceId);
81
+ const relatedIds = new Set<string | number>();
82
+ for (const r of joinRows) {
83
+ if (!opts.symmetric) {
84
+ addId(relatedIds, r.tgt);
85
+ continue;
86
+ }
87
+ const srcIsSource = r.src != null && String(r.src) === sourceKey;
88
+ // Self-loop (a,a): src matches → relate to a itself (single occurrence).
89
+ if (srcIsSource) addId(relatedIds, r.tgt);
90
+ else addId(relatedIds, r.src);
91
+ }
92
+
93
+ if (relatedIds.size === 0) return [];
94
+
95
+ // Stage 2 — load the target rows.
96
+ const pkCol = columnRef(opts.targetTable, targetPk);
97
+ return await opts.db
98
+ .select()
99
+ .from(opts.targetTable)
100
+ .where(inArray(pkCol, [...relatedIds]));
101
+ });
102
+ }
103
+
104
+ function addId(set: Set<string | number>, v: unknown): void {
105
+ if (v === null || v === undefined) return;
106
+ if (typeof v === "number" || typeof v === "string") set.add(v);
107
+ else if (typeof v === "bigint") set.add(Number(v));
108
+ }
109
+
110
+ /**
111
+ * Resolve a physical column name to its Drizzle column object. Drizzle exposes
112
+ * columns on the table object keyed by the TS property name, but the underlying
113
+ * `.name` is the physical column. The descriptor carries physical names (what
114
+ * the junction/target SQL uses), so match on `.name` and fall back to the key.
115
+ */
116
+ // biome-ignore lint/suspicious/noExplicitAny: returns a Drizzle column ref for the loose query builder
117
+ function columnRef(table: AnyTable, physicalName: string): any {
118
+ for (const key of Object.keys(table)) {
119
+ const col = table[key];
120
+ if (col && typeof col === "object" && col.name === physicalName) return col;
121
+ }
122
+ // Fall back to the property-name lookup (covers tables whose TS key == column).
123
+ const direct = table[physicalName];
124
+ if (direct !== undefined) return direct;
125
+ throw new Error(`mountM2mRoute: column '${physicalName}' not found on table`);
126
+ }
@@ -2,6 +2,12 @@
2
2
  * Shared utilities for drizzle-fastify route helpers.
3
3
  */
4
4
 
5
+ /** Coerce a path-param id to number when numeric, else keep the string key. */
6
+ export function parseId(raw: string): number | string {
7
+ const n = Number(raw);
8
+ return Number.isFinite(n) && raw.trim() !== "" ? n : raw;
9
+ }
10
+
5
11
  // Accepts "1" or boolean true (the qs serialization of withCount: 1 from buildFilterQs).
6
12
  export function isTruthyFlag(v: unknown): boolean {
7
13
  if (v === undefined || v === null) return false;
@@ -33,14 +33,11 @@ import {
33
33
  FIELD_SUBTYPE_ENUM,
34
34
  FIELD_SUBTYPE_OBJECT,
35
35
  FIELD_SUBTYPE_STRING,
36
- FIELD_SUBTYPE_CLASS,
37
36
  FIELD_SUBTYPE_UUID,
38
37
  FIELD_SUBTYPE_DATE,
39
38
  FIELD_SUBTYPE_TIME,
40
39
  FIELD_SUBTYPE_TIMESTAMP,
41
40
  FIELD_SUBTYPE_INT,
42
- FIELD_SUBTYPE_SHORT,
43
- FIELD_SUBTYPE_BYTE,
44
41
  FIELD_SUBTYPE_LONG,
45
42
  FIELD_SUBTYPE_CURRENCY,
46
43
  FIELD_SUBTYPE_DOUBLE,
@@ -54,6 +51,8 @@ import {
54
51
  FIELD_ATTR_DEFAULT,
55
52
  FIELD_ATTR_NORMALIZE,
56
53
  FIELD_ATTR_OBJECT_REF,
54
+ FIELD_ATTR_XML_TEXT,
55
+ VALIDATOR_SUBTYPE_NUMERIC,
57
56
  NORMALIZE_DEFAULT,
58
57
  type NormalizeMode,
59
58
  } from "@metaobjectsdev/metadata";
@@ -62,6 +61,8 @@ import {
62
61
  Format,
63
62
  FieldKind,
64
63
  scalar,
64
+ range,
65
+ textContentField,
65
66
  enumField,
66
67
  enumArray,
67
68
  object,
@@ -175,6 +176,18 @@ function fieldSpecFor(
175
176
  // Scalar array: the engine coerces each element; no per-element default fill.
176
177
  return scalarArray(name, kind, required);
177
178
  }
179
+ // @xmlText: a (non-array) scalar field marked to receive its element's XML text content.
180
+ if (ownAttrString(field, FIELD_ATTR_XML_TEXT) === "true") {
181
+ return textContentField(name, kind, required);
182
+ }
183
+ // Numeric range: source the bound from the field's numeric validator (@min/@max) — the single
184
+ // source of truth — so the engine clamps (lenient) / rejects (strict) out-of-range values.
185
+ if (kind === FieldKind.INT || kind === FieldKind.LONG || kind === FieldKind.DOUBLE) {
186
+ const numeric = field.validators().find((v) => v.subType === VALIDATOR_SUBTYPE_NUMERIC);
187
+ if (numeric !== undefined && (numeric.min !== undefined || numeric.max !== undefined)) {
188
+ return range(name, kind, required, numeric.min ?? null, numeric.max ?? null);
189
+ }
190
+ }
178
191
  return scalar(name, kind, required, dv);
179
192
  }
180
193
 
@@ -267,7 +280,6 @@ function scalarArray(name: string, kind: FieldKind, required: boolean): FieldSpe
267
280
  function scalarKind(subType: string): FieldKind {
268
281
  switch (subType) {
269
282
  case FIELD_SUBTYPE_STRING:
270
- case FIELD_SUBTYPE_CLASS:
271
283
  case FIELD_SUBTYPE_UUID:
272
284
  case FIELD_SUBTYPE_DATE:
273
285
  case FIELD_SUBTYPE_TIME:
@@ -277,8 +289,6 @@ function scalarKind(subType: string): FieldKind {
277
289
  case FIELD_SUBTYPE_DECIMAL:
278
290
  return FieldKind.STRING;
279
291
  case FIELD_SUBTYPE_INT:
280
- case FIELD_SUBTYPE_SHORT:
281
- case FIELD_SUBTYPE_BYTE:
282
292
  return FieldKind.INT;
283
293
  case FIELD_SUBTYPE_LONG:
284
294
  case FIELD_SUBTYPE_CURRENCY:
@@ -17,7 +17,8 @@ export type IdentityResolution =
17
17
  | { kind: "preset"; values: Record<string, unknown> };
18
18
 
19
19
  export function resolveIdentity(entity: MetaData, data: Record<string, unknown>): IdentityResolution {
20
- const primary = entity.ownChildren().find(
20
+ // Effective children so a TPH subtype resolves the inherited primary identity.
21
+ const primary = entity.children().find(
21
22
  (c) => c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_PRIMARY,
22
23
  );
23
24
  if (!primary) {
package/src/index.ts CHANGED
@@ -32,3 +32,10 @@ export {
32
32
  assemble,
33
33
  MAX_NEST_DEPTH,
34
34
  } from "./extract-object.js";
35
+
36
+ // LLM call recorder seam + parse-then-persist helper.
37
+ // Format is re-exported here so callers of recordLlmCall can import it from a
38
+ // single location rather than reaching into @metaobjectsdev/render directly.
39
+ export { Format } from "@metaobjectsdev/render";
40
+ export { LlmCallDbRecorder, NullRecorder, recordLlmCall, buildLlmCallRow, persistLlmCallRow, truncateRow } from "./llm-recorder.js";
41
+ export type { LlmRecorder, LlmCallRow, LlmCallInput, RecordLlmCallOptions, RecordLlmCallResult, LlmCallDbRecorderOpts } from "./llm-recorder.js";
@@ -0,0 +1,166 @@
1
+ // LLM call recorder seam + base-row factory + shared persist helper.
2
+ //
3
+ // LlmRecorder is a thin write-side interface for persisting LLM call rows.
4
+ // LlmCallDbRecorder writes via ObjectManager.create (using an entity declared
5
+ // in the caller's own metadata). NullRecorder is the no-op implementation
6
+ // used in unit tests or when tracing is disabled.
7
+ //
8
+ // recordLlmCall is the GENERIC trace path: it builds the base trace row (the
9
+ // envelope + the raw `llmRequest`/`llmResponse` columns declared on the shipped
10
+ // `LlmCallBase`) and persists it. It does NOT parse the response into a typed
11
+ // VO — that extract step + the typed voRequest/voResponse columns live on the
12
+ // generated typed helper (a later layer), so this generic path only ever writes
13
+ // the base field set.
14
+
15
+ import type { ObjectManager } from "./object-manager.js";
16
+
17
+ // =============================================================================
18
+ // Public types
19
+ // =============================================================================
20
+
21
+ export type LlmCallRow = Record<string, unknown>;
22
+
23
+ export interface LlmRecorder {
24
+ record(call: LlmCallRow): Promise<void>;
25
+ }
26
+
27
+ // =============================================================================
28
+ // NullRecorder — no-op (testing / disabled tracing)
29
+ // =============================================================================
30
+
31
+ export class NullRecorder implements LlmRecorder {
32
+ async record(_call: LlmCallRow): Promise<void> {
33
+ // deliberate no-op
34
+ }
35
+ }
36
+
37
+ // =============================================================================
38
+ // LlmCallDbRecorder — persists via ObjectManager
39
+ // =============================================================================
40
+
41
+ export interface LlmCallDbRecorderOpts {
42
+ /** Called when om.create throws. Default: swallow. Telemetry never breaks the app. */
43
+ onError?: (error: unknown) => void;
44
+ }
45
+
46
+ export class LlmCallDbRecorder implements LlmRecorder {
47
+ private readonly om: ObjectManager;
48
+ private readonly entityName: string;
49
+ private readonly onError: (error: unknown) => void;
50
+
51
+ constructor(om: ObjectManager, entityName: string, opts?: LlmCallDbRecorderOpts) {
52
+ this.om = om;
53
+ this.entityName = entityName;
54
+ this.onError = opts?.onError ?? (() => {});
55
+ }
56
+
57
+ async record(call: LlmCallRow): Promise<void> {
58
+ try {
59
+ await this.om.create(this.entityName, call);
60
+ } catch (err) {
61
+ this.onError(err);
62
+ }
63
+ }
64
+ }
65
+
66
+ // =============================================================================
67
+ // recordLlmCall — generic base-row persist
68
+ // =============================================================================
69
+
70
+ export interface LlmCallInput {
71
+ spanId: string;
72
+ traceId: string;
73
+ /** Parent span id; null/absent → this is a root span. */
74
+ parentSpanId?: string;
75
+ /** Logical session/conversation id (gen_ai session grouping). */
76
+ sessionId?: string;
77
+ callType: string;
78
+ /** gen_ai.system — provider name, caller-supplied. */
79
+ system?: string;
80
+ /** ISO 8601 timestamp, supplied by the caller before the LLM call was made. */
81
+ startedAt: string;
82
+ llmRequest: unknown;
83
+ /** Raw response text/body — stored as the raw `llmResponse` column. */
84
+ llmResponseText: string;
85
+ requestModel?: string;
86
+ /** gen_ai.response.model — the model the provider actually used. */
87
+ responseModel?: string;
88
+ inputTokens?: number;
89
+ outputTokens?: number;
90
+ costMinor?: number;
91
+ latencyMs?: number;
92
+ finishReason?: string;
93
+ /** Call outcome, caller-supplied (provider/parse failure → "error"). */
94
+ status: "ok" | "error";
95
+ /** Failure detail (null on success). */
96
+ errorDetail: string | null;
97
+ }
98
+
99
+ export interface RecordLlmCallOptions {
100
+ recorder: LlmRecorder;
101
+ /** Optional scrub/cap applied immediately before persist (PII/secrets). */
102
+ redact?: (row: LlmCallRow) => LlmCallRow;
103
+ }
104
+
105
+ export interface RecordLlmCallResult {
106
+ status: "ok" | "error";
107
+ errorDetail: string | null;
108
+ }
109
+
110
+ /** Build the base trace row (envelope + raw llmRequest/llmResponse) — key set
111
+ * is exactly LlmCallBase's fields. Typed voRequest/voResponse are added by the
112
+ * generated typed helper, never here. */
113
+ export function buildLlmCallRow(input: LlmCallInput): LlmCallRow {
114
+ return {
115
+ traceId: input.traceId,
116
+ spanId: input.spanId,
117
+ parentSpanId: input.parentSpanId ?? null,
118
+ sessionId: input.sessionId ?? null,
119
+ callType: input.callType,
120
+ system: input.system ?? null,
121
+ requestModel: input.requestModel ?? null,
122
+ responseModel: input.responseModel ?? null,
123
+ inputTokens: input.inputTokens ?? null,
124
+ outputTokens: input.outputTokens ?? null,
125
+ costMinor: input.costMinor ?? null,
126
+ latencyMs: input.latencyMs ?? null,
127
+ finishReason: input.finishReason ?? null,
128
+ status: input.status,
129
+ errorDetail: input.errorDetail,
130
+ startedAt: input.startedAt,
131
+ llmRequest: JSON.stringify(input.llmRequest),
132
+ llmResponse: JSON.stringify(input.llmResponseText),
133
+ };
134
+ }
135
+
136
+ /** Shared persist step: redact then record. Used by recordLlmCall AND (later) the
137
+ * generated typed helper, so redaction applies on both paths. */
138
+ export async function persistLlmCallRow(
139
+ recorder: LlmRecorder,
140
+ row: LlmCallRow,
141
+ opts?: { redact?: (row: LlmCallRow) => LlmCallRow },
142
+ ): Promise<void> {
143
+ await recorder.record(opts?.redact ? opts.redact(row) : row);
144
+ }
145
+
146
+ /** Cap the raw `llmRequest`/`llmResponse` string columns to `maxChars`.
147
+ * Adopters compose this into a `redact` to bound trace-row size. Only the two
148
+ * raw string columns are touched; all other fields pass through unchanged. */
149
+ export function truncateRow(row: LlmCallRow, maxChars: number): LlmCallRow {
150
+ const cap = (v: unknown): unknown =>
151
+ typeof v === "string" && v.length > maxChars ? v.slice(0, maxChars) : v;
152
+ return { ...row, llmRequest: cap(row.llmRequest), llmResponse: cap(row.llmResponse) };
153
+ }
154
+
155
+ /** Persist one base trace row (envelope + raw I/O). Generic — does not extract. */
156
+ export async function recordLlmCall(
157
+ input: LlmCallInput,
158
+ opts: RecordLlmCallOptions,
159
+ ): Promise<RecordLlmCallResult> {
160
+ await persistLlmCallRow(
161
+ opts.recorder,
162
+ buildLlmCallRow(input),
163
+ opts.redact ? { redact: opts.redact } : undefined,
164
+ );
165
+ return { status: input.status, errorDetail: input.errorDetail };
166
+ }