@metaobjectsdev/runtime-ts 0.5.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 (118) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +102 -0
  3. package/dist/constants.d.ts +5 -0
  4. package/dist/constants.d.ts.map +1 -0
  5. package/dist/constants.js +6 -0
  6. package/dist/constants.js.map +1 -0
  7. package/dist/drivers/drizzle-driver.d.ts +25 -0
  8. package/dist/drivers/drizzle-driver.d.ts.map +1 -0
  9. package/dist/drivers/drizzle-driver.js +405 -0
  10. package/dist/drivers/drizzle-driver.js.map +1 -0
  11. package/dist/drivers/in-memory-driver.d.ts +11 -0
  12. package/dist/drivers/in-memory-driver.d.ts.map +1 -0
  13. package/dist/drivers/in-memory-driver.js +232 -0
  14. package/dist/drivers/in-memory-driver.js.map +1 -0
  15. package/dist/drivers/index.d.ts +4 -0
  16. package/dist/drivers/index.d.ts.map +1 -0
  17. package/dist/drivers/index.js +4 -0
  18. package/dist/drivers/index.js.map +1 -0
  19. package/dist/drivers/kysely-driver.d.ts +12 -0
  20. package/dist/drivers/kysely-driver.d.ts.map +1 -0
  21. package/dist/drivers/kysely-driver.js +203 -0
  22. package/dist/drivers/kysely-driver.js.map +1 -0
  23. package/dist/drizzle-fastify/filter-allowlist.d.ts +19 -0
  24. package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -0
  25. package/dist/drizzle-fastify/filter-allowlist.js +10 -0
  26. package/dist/drizzle-fastify/filter-allowlist.js.map +1 -0
  27. package/dist/drizzle-fastify/filter-parser.d.ts +28 -0
  28. package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -0
  29. package/dist/drizzle-fastify/filter-parser.js +185 -0
  30. package/dist/drizzle-fastify/filter-parser.js.map +1 -0
  31. package/dist/drizzle-fastify/index.d.ts +48 -0
  32. package/dist/drizzle-fastify/index.d.ts.map +1 -0
  33. package/dist/drizzle-fastify/index.js +181 -0
  34. package/dist/drizzle-fastify/index.js.map +1 -0
  35. package/dist/drizzle-fastify/mount-read-only.d.ts +17 -0
  36. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -0
  37. package/dist/drizzle-fastify/mount-read-only.js +159 -0
  38. package/dist/drizzle-fastify/mount-read-only.js.map +1 -0
  39. package/dist/drizzle-fastify/util.d.ts +5 -0
  40. package/dist/drizzle-fastify/util.d.ts.map +1 -0
  41. package/dist/drizzle-fastify/util.js +12 -0
  42. package/dist/drizzle-fastify/util.js.map +1 -0
  43. package/dist/errors.d.ts +68 -0
  44. package/dist/errors.d.ts.map +1 -0
  45. package/dist/errors.js +86 -0
  46. package/dist/errors.js.map +1 -0
  47. package/dist/fastify/index.d.ts +65 -0
  48. package/dist/fastify/index.d.ts.map +1 -0
  49. package/dist/fastify/index.js +118 -0
  50. package/dist/fastify/index.js.map +1 -0
  51. package/dist/identity-strategy.d.ts +10 -0
  52. package/dist/identity-strategy.d.ts.map +1 -0
  53. package/dist/identity-strategy.js +67 -0
  54. package/dist/identity-strategy.js.map +1 -0
  55. package/dist/index.d.ts +9 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +3 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/n2m-resolver.d.ts +27 -0
  60. package/dist/n2m-resolver.d.ts.map +1 -0
  61. package/dist/n2m-resolver.js +103 -0
  62. package/dist/n2m-resolver.js.map +1 -0
  63. package/dist/object-manager.d.ts +51 -0
  64. package/dist/object-manager.d.ts.map +1 -0
  65. package/dist/object-manager.js +355 -0
  66. package/dist/object-manager.js.map +1 -0
  67. package/dist/persistence-driver.d.ts +88 -0
  68. package/dist/persistence-driver.d.ts.map +1 -0
  69. package/dist/persistence-driver.js +5 -0
  70. package/dist/persistence-driver.js.map +1 -0
  71. package/dist/query-builder.d.ts +35 -0
  72. package/dist/query-builder.d.ts.map +1 -0
  73. package/dist/query-builder.js +178 -0
  74. package/dist/query-builder.js.map +1 -0
  75. package/dist/ref-codec.d.ts +8 -0
  76. package/dist/ref-codec.d.ts.map +1 -0
  77. package/dist/ref-codec.js +42 -0
  78. package/dist/ref-codec.js.map +1 -0
  79. package/dist/relation-resolver.d.ts +27 -0
  80. package/dist/relation-resolver.d.ts.map +1 -0
  81. package/dist/relation-resolver.js +136 -0
  82. package/dist/relation-resolver.js.map +1 -0
  83. package/dist/type-coercer.d.ts +5 -0
  84. package/dist/type-coercer.d.ts.map +1 -0
  85. package/dist/type-coercer.js +43 -0
  86. package/dist/type-coercer.js.map +1 -0
  87. package/dist/validator-runner.d.ts +14 -0
  88. package/dist/validator-runner.d.ts.map +1 -0
  89. package/dist/validator-runner.js +155 -0
  90. package/dist/validator-runner.js.map +1 -0
  91. package/dist/view.d.ts +19 -0
  92. package/dist/view.d.ts.map +1 -0
  93. package/dist/view.js +60 -0
  94. package/dist/view.js.map +1 -0
  95. package/package.json +87 -0
  96. package/src/constants.ts +7 -0
  97. package/src/drivers/drizzle-driver.ts +474 -0
  98. package/src/drivers/in-memory-driver.ts +256 -0
  99. package/src/drivers/index.ts +3 -0
  100. package/src/drivers/kysely-driver.ts +240 -0
  101. package/src/drizzle-fastify/filter-allowlist.ts +31 -0
  102. package/src/drizzle-fastify/filter-parser.ts +229 -0
  103. package/src/drizzle-fastify/index.ts +225 -0
  104. package/src/drizzle-fastify/mount-read-only.ts +187 -0
  105. package/src/drizzle-fastify/util.ts +10 -0
  106. package/src/errors.ts +114 -0
  107. package/src/fastify/index.ts +181 -0
  108. package/src/identity-strategy.ts +92 -0
  109. package/src/index.ts +24 -0
  110. package/src/n2m-resolver.ts +152 -0
  111. package/src/object-manager.ts +444 -0
  112. package/src/persistence-driver.ts +92 -0
  113. package/src/query-builder.ts +224 -0
  114. package/src/ref-codec.ts +64 -0
  115. package/src/relation-resolver.ts +171 -0
  116. package/src/type-coercer.ts +39 -0
  117. package/src/validator-runner.ts +168 -0
  118. package/src/view.ts +82 -0
package/src/errors.ts ADDED
@@ -0,0 +1,114 @@
1
+ // All errors extend RuntimeError and JSON-serialize via toJSON() — admin UIs and MCP tools
2
+ // surface them as structured JSON, not Error.message strings.
3
+
4
+ export interface ValidationFailure {
5
+ field: string;
6
+ rule: string;
7
+ message: string;
8
+ expected?: unknown;
9
+ received?: unknown;
10
+ }
11
+
12
+ interface BaseOpts {
13
+ code?: string;
14
+ entity?: string;
15
+ cause?: unknown;
16
+ }
17
+
18
+ export class RuntimeError extends Error {
19
+ readonly code: string;
20
+ readonly entity?: string;
21
+ override readonly cause?: unknown;
22
+
23
+ constructor(message: string, opts: BaseOpts & { code: string }) {
24
+ super(message);
25
+ this.name = "RuntimeError";
26
+ this.code = opts.code;
27
+ if (opts.entity !== undefined) this.entity = opts.entity;
28
+ if (opts.cause !== undefined) this.cause = opts.cause;
29
+ }
30
+
31
+ toJSON(): Record<string, unknown> {
32
+ const out: Record<string, unknown> = { name: this.name, code: this.code, message: this.message };
33
+ if (this.entity !== undefined) out.entity = this.entity;
34
+ return out;
35
+ }
36
+ }
37
+
38
+ export class ValidationError extends RuntimeError {
39
+ readonly errors: ValidationFailure[];
40
+
41
+ constructor(message: string, opts: { entity?: string; errors: ValidationFailure[]; cause?: unknown }) {
42
+ super(message, { code: "validation_failed", ...(opts.entity !== undefined ? { entity: opts.entity } : {}), ...(opts.cause !== undefined ? { cause: opts.cause } : {}) });
43
+ this.name = "ValidationError";
44
+ this.errors = opts.errors;
45
+ }
46
+
47
+ override toJSON(): Record<string, unknown> {
48
+ return { ...super.toJSON(), errors: this.errors };
49
+ }
50
+ }
51
+
52
+ export class NotFoundError extends RuntimeError {
53
+ readonly id: unknown;
54
+
55
+ constructor(message: string, opts: { entity: string; id: unknown; cause?: unknown }) {
56
+ super(message, { code: "not_found", entity: opts.entity, ...(opts.cause !== undefined ? { cause: opts.cause } : {}) });
57
+ this.name = "NotFoundError";
58
+ this.id = opts.id;
59
+ }
60
+
61
+ override toJSON(): Record<string, unknown> {
62
+ return { ...super.toJSON(), id: this.id };
63
+ }
64
+ }
65
+
66
+ export class ConstraintViolationError extends RuntimeError {
67
+ readonly kind: "unique" | "foreign_key" | "not_null" | "check";
68
+ readonly table: string;
69
+ readonly field?: string;
70
+ readonly detail?: string;
71
+
72
+ constructor(message: string, opts: {
73
+ kind: "unique" | "foreign_key" | "not_null" | "check";
74
+ table: string;
75
+ field?: string;
76
+ detail?: string;
77
+ cause?: unknown;
78
+ }) {
79
+ super(message, { code: "constraint_violation", ...(opts.cause !== undefined ? { cause: opts.cause } : {}) });
80
+ this.name = "ConstraintViolationError";
81
+ this.kind = opts.kind;
82
+ this.table = opts.table;
83
+ if (opts.field !== undefined) this.field = opts.field;
84
+ if (opts.detail !== undefined) this.detail = opts.detail;
85
+ }
86
+
87
+ override toJSON(): Record<string, unknown> {
88
+ const out: Record<string, unknown> = { ...super.toJSON(), kind: this.kind, table: this.table };
89
+ if (this.field !== undefined) out.field = this.field;
90
+ if (this.detail !== undefined) out.detail = this.detail;
91
+ return out;
92
+ }
93
+ }
94
+
95
+ export class MetadataError extends RuntimeError {
96
+ constructor(message: string, opts: { entity?: string; cause?: unknown } = {}) {
97
+ super(message, { code: "metadata_error", ...(opts.entity !== undefined ? { entity: opts.entity } : {}), ...(opts.cause !== undefined ? { cause: opts.cause } : {}) });
98
+ this.name = "MetadataError";
99
+ }
100
+ }
101
+
102
+ export class UnsafeNameError extends RuntimeError {
103
+ readonly value: string;
104
+
105
+ constructor(message: string, opts: { value: string }) {
106
+ super(message, { code: "unsafe_name" });
107
+ this.name = "UnsafeNameError";
108
+ this.value = opts.value;
109
+ }
110
+
111
+ override toJSON(): Record<string, unknown> {
112
+ return { ...super.toJSON(), value: this.value };
113
+ }
114
+ }
@@ -0,0 +1,181 @@
1
+ // Fastify adapter for ObjectManager-driven CRUD.
2
+ //
3
+ // Generated route files call `mountCrudRoutes(...)` from here so all the
4
+ // boilerplate (validation, 404 mapping, id parsing, pagination) lives in
5
+ // one tested place rather than being duplicated per entity.
6
+ //
7
+ // Fastify is an OPTIONAL peer dependency. Consumers that don't expose REST
8
+ // pay no runtime cost — this subpath is only loaded when imported.
9
+ //
10
+ // Type-only Fastify imports keep the optional-peer story clean: the .d.ts
11
+ // reference is needed for the public API, but at runtime there's no
12
+ // `require("fastify")`.
13
+
14
+ import type { FastifyInstance } from "fastify";
15
+ import type { ZodTypeAny } from "zod";
16
+ import type { ObjectManager } from "../object-manager.js";
17
+ import type { Row } from "../persistence-driver.js";
18
+ import type { RouteShorthandOptions } from "fastify";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Public surface
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type CrudVerb = "list" | "get" | "create" | "update" | "delete";
25
+
26
+ export interface CrudRoutesOptions {
27
+ fastify: FastifyInstance;
28
+ /** REST resource path, e.g. "/subscribers". */
29
+ path: string;
30
+ /** Entity name from metadata, e.g. "Subscriber". */
31
+ entity: string;
32
+ /** Zod schema for create payloads (typically `<Entity>InsertSchema`). */
33
+ insertSchema: ZodTypeAny;
34
+ /** Zod schema for update payloads (typically `<Entity>UpdateSchema`). */
35
+ updateSchema: ZodTypeAny;
36
+ /**
37
+ * ObjectManager accessor. A function rather than the instance so callers
38
+ * can keep their lazy-init pattern (load metadata once at startup).
39
+ */
40
+ om: () => Promise<ObjectManager>;
41
+ /**
42
+ * Limit which verbs are mounted. Defaults to all five. Useful for
43
+ * read-only resources (`expose: ["list", "get"]`).
44
+ */
45
+ expose?: readonly CrudVerb[];
46
+ /**
47
+ * Fastify route-level hooks applied to every mounted verb. The most common
48
+ * use is `preHandler` for auth/authz:
49
+ *
50
+ * routeOptions: { preHandler: requireAdminAuth }
51
+ *
52
+ * Anything Fastify accepts on a route options object is valid here
53
+ * (preHandler, onRequest, preValidation, preParsing, schema, etc.).
54
+ */
55
+ routeOptions?: RouteShorthandOptions;
56
+ /**
57
+ * HTTP method for the update verb. Defaults to "patch" (semantic partial-
58
+ * update). Set to "put" to preserve a legacy API contract that already
59
+ * uses PUT for updates.
60
+ */
61
+ updateMethod?: "patch" | "put";
62
+ }
63
+
64
+ const ALL_VERBS: readonly CrudVerb[] = ["list", "get", "create", "update", "delete"];
65
+
66
+ /**
67
+ * Mount the 5 standard REST endpoints for an entity:
68
+ *
69
+ * GET {path} → list, with ?limit & ?offset query params
70
+ * GET {path}/:id → findById
71
+ * POST {path} → create (validated via insertSchema)
72
+ * PATCH {path}/:id → partial update (validated via updateSchema)
73
+ * DELETE {path}/:id → delete
74
+ */
75
+ export function mountCrudRoutes(opts: CrudRoutesOptions): void {
76
+ const verbs = new Set<CrudVerb>(opts.expose ?? ALL_VERBS);
77
+ if (verbs.has("list")) mountListRoute(opts);
78
+ if (verbs.has("get")) mountGetRoute(opts);
79
+ if (verbs.has("create")) mountCreateRoute(opts);
80
+ if (verbs.has("update")) mountUpdateRoute(opts);
81
+ if (verbs.has("delete")) mountDeleteRoute(opts);
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Per-verb helpers — exposed so consumers can mix custom routes with the
86
+ // generated CRUD. Each takes the same options bag (minus `expose`).
87
+ // ---------------------------------------------------------------------------
88
+
89
+ type SingleVerbOptions = Omit<CrudRoutesOptions, "expose">;
90
+
91
+ /**
92
+ * Build the route options Fastify wants. Just the consumer's routeOptions
93
+ * (typed loosely to avoid Fastify's elaborate route-options union here).
94
+ * Returns an empty object if nothing was provided.
95
+ */
96
+ function routeOpts(opts: SingleVerbOptions): RouteShorthandOptions {
97
+ return opts.routeOptions ?? {};
98
+ }
99
+
100
+ export function mountListRoute(opts: SingleVerbOptions): void {
101
+ opts.fastify.get(opts.path, routeOpts(opts), async (req) => {
102
+ const { limit, offset } = req.query as { limit?: string; offset?: string };
103
+ const readOpts: { limit?: number; offset?: number } = {};
104
+ if (limit !== undefined) readOpts.limit = Number(limit);
105
+ if (offset !== undefined) readOpts.offset = Number(offset);
106
+ return (await opts.om()).findMany(opts.entity, undefined, readOpts);
107
+ });
108
+ }
109
+
110
+ export function mountGetRoute(opts: SingleVerbOptions): void {
111
+ opts.fastify.get(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
112
+ const { id } = req.params as { id: string };
113
+ const row = await (await opts.om()).findById(opts.entity, parseId(id));
114
+ return row ?? reply.code(404).send({ error: "not_found" });
115
+ });
116
+ }
117
+
118
+ export function mountCreateRoute(opts: SingleVerbOptions): void {
119
+ opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => {
120
+ const parsed = opts.insertSchema.safeParse(req.body);
121
+ if (!parsed.success) {
122
+ return reply.code(400).send({ error: "validation", issues: parsed.error.issues });
123
+ }
124
+ // Zod returns `unknown` for parsed.data; the schema is authored from the
125
+ // same metadata that drives ObjectManager, so the shape is guaranteed
126
+ // compatible — cast at the trust boundary.
127
+ const row = await (await opts.om()).create(opts.entity, parsed.data as Row);
128
+ return reply.code(201).send(row);
129
+ });
130
+ }
131
+
132
+ export function mountUpdateRoute(opts: SingleVerbOptions): void {
133
+ const handler = async (
134
+ req: { params: unknown; body: unknown },
135
+ reply: {
136
+ code: (n: number) => { send: (b: unknown) => unknown };
137
+ },
138
+ ) => {
139
+ const { id } = req.params as { id: string };
140
+ const parsed = opts.updateSchema.safeParse(req.body);
141
+ if (!parsed.success) {
142
+ return reply.code(400).send({ error: "validation", issues: parsed.error.issues });
143
+ }
144
+ // Use ifMissing: "ignore" so the helper itself owns the 404 mapping
145
+ // (consistent with mountDeleteRoute). ObjectManager's default behavior
146
+ // throws NotFoundError, which Fastify would surface as 500.
147
+ const row = await (await opts.om()).update(opts.entity, parseId(id), parsed.data as Row, {
148
+ ifMissing: "ignore",
149
+ });
150
+ return row ?? reply.code(404).send({ error: "not_found" });
151
+ };
152
+ const path = `${opts.path}/:id`;
153
+ const ro = routeOpts(opts);
154
+ // biome-ignore lint/suspicious/noExplicitAny: handler signature is generic by design
155
+ if (opts.updateMethod === "put") {
156
+ opts.fastify.put(path, ro, handler as any);
157
+ } else {
158
+ opts.fastify.patch(path, ro, handler as any);
159
+ }
160
+ }
161
+
162
+ export function mountDeleteRoute(opts: SingleVerbOptions): void {
163
+ opts.fastify.delete(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
164
+ const { id } = req.params as { id: string };
165
+ const deleted = await (await opts.om()).delete(opts.entity, parseId(id));
166
+ return deleted ? reply.code(204).send() : reply.code(404).send({ error: "not_found" });
167
+ });
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Helpers
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Coerce a `:id` path param. Numeric strings parse as Number (covers long/int
176
+ * PKs); anything else passes through as a string (UUIDs, string keys).
177
+ */
178
+ export function parseId(raw: string): number | string {
179
+ const n = Number(raw);
180
+ return Number.isFinite(n) && raw.trim() !== "" ? n : raw;
181
+ }
@@ -0,0 +1,92 @@
1
+ // Generation strategies:
2
+ // increment → driver-generated by default; caller MAY supply PK (e.g., seeding fixtures, importing legacy data).
3
+ // uuid → generated here via crypto.randomUUID() unless caller supplies one.
4
+ // assigned → caller must provide PK.
5
+ // composite → always treated as 'assigned' regardless of the @generation hint.
6
+
7
+ import type { MetaData } from "@metaobjectsdev/metadata";
8
+ import {
9
+ TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY,
10
+ IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
11
+ GENERATION_INCREMENT, GENERATION_UUID, GENERATION_ASSIGNED,
12
+ } from "@metaobjectsdev/metadata";
13
+ import { MetadataError, ValidationError } from "./errors.js";
14
+
15
+ export type IdentityResolution =
16
+ | { kind: "driver-generated"; values: Record<string, unknown> }
17
+ | { kind: "preset"; values: Record<string, unknown> };
18
+
19
+ export function resolveIdentity(entity: MetaData, data: Record<string, unknown>): IdentityResolution {
20
+ const primary = entity.ownChildren().find(
21
+ (c) => c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_PRIMARY,
22
+ );
23
+ if (!primary) {
24
+ throw new MetadataError(
25
+ `Entity '${entity.name}' has no primary identity — cannot resolve PK for create`,
26
+ { entity: entity.name },
27
+ );
28
+ }
29
+ const fieldsAttr = primary.ownAttr(IDENTITY_ATTR_FIELDS);
30
+ if (!Array.isArray(fieldsAttr) || fieldsAttr.length === 0) {
31
+ throw new MetadataError(
32
+ `Entity '${entity.name}' primary identity has no @fields`,
33
+ { entity: entity.name },
34
+ );
35
+ }
36
+ const pkFields = fieldsAttr.map(String);
37
+ const isComposite = pkFields.length > 1;
38
+ const generation = primary.ownAttr(IDENTITY_ATTR_GENERATION);
39
+
40
+ if (isComposite || generation === undefined || generation === GENERATION_ASSIGNED) {
41
+ const missing = pkFields.filter((f) => data[f] === undefined || data[f] === null);
42
+ if (missing.length > 0) {
43
+ throw new ValidationError(
44
+ `Missing required PK field(s) on '${entity.name}': ${missing.join(", ")}`,
45
+ {
46
+ entity: entity.name,
47
+ errors: missing.map((field) => ({
48
+ field,
49
+ rule: "required",
50
+ message: `PK field '${field}' must be provided when @generation is 'assigned' (or unset, or composite)`,
51
+ })),
52
+ },
53
+ );
54
+ }
55
+ const values: Record<string, unknown> = {};
56
+ for (const f of pkFields) values[f] = data[f];
57
+ return { kind: "preset", values };
58
+ }
59
+
60
+ if (generation === GENERATION_INCREMENT) {
61
+ const provided = pkFields.filter((f) => data[f] !== undefined && data[f] !== null);
62
+ if (provided.length === pkFields.length) {
63
+ const values: Record<string, unknown> = {};
64
+ for (const f of pkFields) values[f] = data[f];
65
+ return { kind: "preset", values };
66
+ }
67
+ return { kind: "driver-generated", values: {} };
68
+ }
69
+
70
+ if (generation === GENERATION_UUID) {
71
+ const values: Record<string, unknown> = {};
72
+ for (const f of pkFields) {
73
+ values[f] = data[f] !== undefined && data[f] !== null ? data[f] : crypto.randomUUID();
74
+ }
75
+ return { kind: "preset", values };
76
+ }
77
+
78
+ // Unknown @generation values fall back to assigned-style: require the caller to supply the PK.
79
+ const missing = pkFields.filter((f) => data[f] === undefined || data[f] === null);
80
+ if (missing.length > 0) {
81
+ throw new ValidationError(
82
+ `Missing PK field(s) on '${entity.name}': ${missing.join(", ")}`,
83
+ {
84
+ entity: entity.name,
85
+ errors: missing.map((field) => ({ field, rule: "required", message: `PK field '${field}' is required` })),
86
+ },
87
+ );
88
+ }
89
+ const values: Record<string, unknown> = {};
90
+ for (const f of pkFields) values[f] = data[f];
91
+ return { kind: "preset", values };
92
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export { ObjectManager } from "./object-manager.js";
2
+ export type { ObjectManagerOptions, ReadOpts, WriteOpts } from "./object-manager.js";
3
+
4
+ export type {
5
+ PersistenceDriver, Dialect, Row, PrimitiveValue,
6
+ WhereClause, OrderBy,
7
+ SelectSpec, CountSpec, InsertSpec, InsertManySpec,
8
+ UpdateSpec, UpdateManySpec, DeleteSpec, DeleteManySpec,
9
+ } from "./persistence-driver.js";
10
+
11
+ export type { Filter, FilterValue, QueryOpts } from "./query-builder.js";
12
+
13
+ export type { FieldViewSpec, EntityViewSpec } from "./view.js";
14
+
15
+ export type { ValidationResult } from "./validator-runner.js";
16
+ export type { ValidationFailure } from "./errors.js";
17
+ export {
18
+ RuntimeError,
19
+ ValidationError,
20
+ NotFoundError,
21
+ ConstraintViolationError,
22
+ MetadataError,
23
+ UnsafeNameError,
24
+ } from "./errors.js";
@@ -0,0 +1,152 @@
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 { MetaData } from "@metaobjectsdev/metadata";
5
+ import {
6
+ TYPE_OBJECT, TYPE_FIELD, TYPE_RELATIONSHIP,
7
+ RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF,
8
+ RELATIONSHIP_ATTR_JOIN_ENTITY, RELATIONSHIP_ATTR_JOIN_FIELDS,
9
+ CARDINALITY_MANY,
10
+ resolveColumnName,
11
+ } from "@metaobjectsdev/metadata";
12
+ import { MetadataError } from "./errors.js";
13
+ import { buildSelectSpec, resolvePkFields } from "./query-builder.js";
14
+ import type { SelectSpec, PrimitiveValue, Row } from "./persistence-driver.js";
15
+
16
+ export interface N2mDescriptor {
17
+ /** Entity that declares the relationship (source of the lookup; its PK feeds sourceJoinField). */
18
+ sourceEntityName: string;
19
+ targetEntityName: string;
20
+ joinEntityName: string;
21
+ /** Field name on the join entity holding the source-side FK. */
22
+ sourceJoinField: string;
23
+ /** Field name on the join entity holding the target-side FK. */
24
+ targetJoinField: string;
25
+ }
26
+
27
+ export interface N2mLazyOutput {
28
+ joinSpec: SelectSpec;
29
+ /** Caller runs joinSpec, then passes the rows here to build the target spec. */
30
+ makeTargetSpec: (joinRows: Row[]) => SelectSpec | null;
31
+ }
32
+
33
+ export interface N2mBatchOutput {
34
+ joinSpec: SelectSpec;
35
+ makeTargetSpec: (joinRows: Row[]) => SelectSpec | null;
36
+ }
37
+
38
+ /** Returns null if the named relationship is not N:M — caller should try resolveRelationDescriptor. */
39
+ export function resolveN2mDescriptor(
40
+ sourceEntity: MetaData,
41
+ relationName: string,
42
+ root: MetaData,
43
+ ): N2mDescriptor | null {
44
+ for (const child of sourceEntity.ownChildren()) {
45
+ if (child.type !== TYPE_RELATIONSHIP) continue;
46
+ if (child.name !== relationName) continue;
47
+ if (child.ownAttr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_MANY) continue;
48
+ const targetEntityName = child.ownAttr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
49
+ const joinEntityName = child.ownAttr(RELATIONSHIP_ATTR_JOIN_ENTITY) as string | undefined;
50
+ const joinFields = child.ownAttr(RELATIONSHIP_ATTR_JOIN_FIELDS);
51
+ if (!targetEntityName || !joinEntityName || !Array.isArray(joinFields) || joinFields.length !== 2) {
52
+ throw new MetadataError(
53
+ `N:M relationship '${relationName}' on '${sourceEntity.name}' requires @objectRef + @joinEntity + @joinFields: [sourceFk, targetFk]`,
54
+ { entity: sourceEntity.name },
55
+ );
56
+ }
57
+ const targetExists = root.ownChildren().some((c) => c.type === TYPE_OBJECT && c.name === targetEntityName);
58
+ if (!targetExists) {
59
+ throw new MetadataError(`Target entity '${targetEntityName}' not found`, { entity: sourceEntity.name });
60
+ }
61
+ const joinExists = root.ownChildren().some((c) => c.type === TYPE_OBJECT && c.name === joinEntityName);
62
+ if (!joinExists) {
63
+ throw new MetadataError(`Join entity '${joinEntityName}' not found`, { entity: sourceEntity.name });
64
+ }
65
+ return {
66
+ sourceEntityName: sourceEntity.name,
67
+ targetEntityName,
68
+ joinEntityName,
69
+ sourceJoinField: String(joinFields[0]),
70
+ targetJoinField: String(joinFields[1]),
71
+ };
72
+ }
73
+ return null;
74
+ }
75
+
76
+ export function buildN2mLazySpecs(
77
+ desc: N2mDescriptor,
78
+ sourceRecord: Row,
79
+ root: MetaData,
80
+ ): N2mLazyOutput {
81
+ const joinEntity = mustGetEntity(root, desc.joinEntityName);
82
+ const targetEntity = mustGetEntity(root, desc.targetEntityName);
83
+ const sourcePkField = resolvePkFields(mustGetEntity(root, desc.sourceEntityName))[0]!;
84
+ const sourcePkValue = sourceRecord[sourcePkField];
85
+
86
+ const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourcePkValue as PrimitiveValue }, {});
87
+
88
+ const makeTargetSpec = (joinRows: Row[]): SelectSpec | null => {
89
+ const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity);
90
+ if (targetIds.length === 0) return null;
91
+ const targetPkField = resolvePkFields(targetEntity)[0]!;
92
+ return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {});
93
+ };
94
+
95
+ return { joinSpec, makeTargetSpec };
96
+ }
97
+
98
+ export function buildN2mBatchSpecs(
99
+ desc: N2mDescriptor,
100
+ sourceRecords: Row[],
101
+ root: MetaData,
102
+ ): N2mBatchOutput {
103
+ const joinEntity = mustGetEntity(root, desc.joinEntityName);
104
+ const targetEntity = mustGetEntity(root, desc.targetEntityName);
105
+ const sourcePkField = resolvePkFields(mustGetEntity(root, desc.sourceEntityName))[0]!;
106
+ const sourceIds = collectIds(sourceRecords, sourcePkField);
107
+
108
+ const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourceIds }, {});
109
+
110
+ const makeTargetSpec = (joinRows: Row[]): SelectSpec | null => {
111
+ const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity);
112
+ if (targetIds.length === 0) return null;
113
+ const targetPkField = resolvePkFields(targetEntity)[0]!;
114
+ return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {});
115
+ };
116
+
117
+ return { joinSpec, makeTargetSpec };
118
+ }
119
+
120
+ function mustGetEntity(root: MetaData, name: string): MetaData {
121
+ const e = root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === name);
122
+ if (!e) throw new MetadataError(`Entity '${name}' not found`, { entity: name });
123
+ return e;
124
+ }
125
+
126
+ function collectIds(records: Row[], pkField: string): (string | number)[] {
127
+ const seen = new Set<PrimitiveValue>();
128
+ for (const r of records) {
129
+ const v = r[pkField];
130
+ if (v === null || v === undefined) continue;
131
+ seen.add(v as PrimitiveValue);
132
+ }
133
+ return [...seen] as (string | number)[];
134
+ }
135
+
136
+ function collectTargetIds(joinRows: Row[], targetJoinField: string, joinEntity: MetaData): (string | number)[] {
137
+ // joinRows are raw column-keyed (driver hasn't been to-JS-row'd yet); resolve the metadata field name to its DB column.
138
+ const dbColumn = resolveJoinColumnName(joinEntity, targetJoinField);
139
+ const seen = new Set<PrimitiveValue>();
140
+ for (const r of joinRows) {
141
+ const v = r[dbColumn];
142
+ if (v === null || v === undefined) continue;
143
+ seen.add(v as PrimitiveValue);
144
+ }
145
+ return [...seen] as (string | number)[];
146
+ }
147
+
148
+ export function resolveJoinColumnName(joinEntity: MetaData, fieldName: string): string {
149
+ const field = joinEntity.ownChildren().find((c) => c.type === TYPE_FIELD && c.name === fieldName);
150
+ if (!field) throw new MetadataError(`Join field '${fieldName}' not on '${joinEntity.name}'`);
151
+ return resolveColumnName(field);
152
+ }