Skip to content

Commit 6ffdd74

Browse files
dmealingclaude
andcommitted
docs(agent-context): an agent can work out any database, store or framework from the scaffolded skills
A cold agent in an adopter's project has only what `meta init` wrote. The tested recipes for MongoDB and MySQL lived in this repository's docs/ and were reachable only through GitHub links, and nothing told an agent that an unsupported stack is a normal, supported path. - New universal reference `metaobjects-codegen/references/any-stack.md`: find out what is built in (with the exact discovery commands), split the app by layer, then persistence (MySQL is a dialect; protocol- compatible databases use that dialect; any other store is a sourceless entity plus a property bag plus an owned repository generator, with each port's predicate and contract), an HTTP framework (config, Hono on most hosts, eject and retarget, or write a routes generator), a UI framework over runtime-web, and how to prove it works. - `typescript-mysql.md` and `typescript-document-store.md` ship the MySQL guide and the tested MongoDB repository generator inside the skill, scoped to TypeScript stacks. A new test keeps both byte-equal to the recipes the integration suite runs. - Entry points: the always-on file gains a bullet for this case; the codegen table lists any-stack first; the TypeScript reference names its topic files; the authoring skill's unmanaged-store section points at the playbook instead of a URL and says the dialect is config, not metadata; and the `meta gen --list` header names the playbook, for agents that decide before opening any skill. The agent-context corpus is regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 459fb41 commit 6ffdd74

50 files changed

Lines changed: 1560 additions & 29 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎agent-context/skills/metaobjects-authoring/SKILL.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -579,9 +579,14 @@ a registered `attr.properties` bag, for example `"@mongo": { "collection": "orde
579579
A sourceless entity gets no table, queries, routes or migration. It still gets its wire
580580
contract in every port: the create and PATCH schemas and the filter allowlist. Keep the
581581
`identity.primary`, because it is what makes the record addressable. Without it the object
582-
is a shape only. The data access layer is a generator the adopter owns. The recipe, with a
583-
MongoDB repository generator run end to end, is
584-
<https://github.com/metaobjectsdev/metaobjects/blob/main/docs/recipes/document-graph-and-wide-column-stores.md>.
582+
is a shape only. The data access layer is a generator you own: the procedure is in the
583+
`metaobjects-codegen` skill, `references/any-stack.md`, and on TypeScript
584+
`references/typescript-document-store.md` carries a complete, tested MongoDB repository
585+
generator to copy.
586+
587+
**The database dialect is codegen config, not metadata.** MySQL, Postgres, SQLite and D1
588+
entities are all modeled with `source.rdb`; `dialect` in the codegen config picks the target.
589+
Do not encode the database in the model.
585590

586591
### Derived/computed columns: reach for an ENTITY READ-VIEW first
587592

‎agent-context/skills/metaobjects-codegen/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ exact config, generator names and commands for this project; read it. The other
3333

3434
| File | Open it when |
3535
|---|---|
36+
| `references/any-stack.md` | **the database, data store, HTTP framework or UI framework is not one a reference generator targets** — MySQL, MongoDB, DynamoDB, Express, NestJS, Next.js, Vue, … |
3637
| `references/owning-generators.md` | generated output is wrong; your framework is not the one a reference emits for; you write or review a generator (the `own*()` accessor table) |
3738
| `references/choosing-layers.md` | the generated API does not fit, you need joined or derived data, or you are adopting onto existing code |
3839
| `references/template-or-code.md` | choosing between a code generator and a Mustache template |
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Your database, store or framework is not built in — work it out
2+
3+
> Part of the `metaobjects-codegen` skill. Read it when the app's database, data store, HTTP
4+
> framework or UI framework is not one a reference generator targets. That is a normal
5+
> situation with a known procedure, not a dead end and not a reason to hand-write the app.
6+
7+
MetaObjects is a **core** (the model, the loader, `verify`, prompt render and reply extract,
8+
and `meta migrate` for Postgres/SQLite/D1) plus **generators you own**. The reference
9+
generators cover some stacks. Every other stack is reached the same way: keep the model as the
10+
source of truth, generate what is built in, and write or retarget a generator for the rest.
11+
Nothing below needs a new MetaObjects release, a new metamodel attribute, or permission.
12+
13+
## 1. Find out what is built in — do not guess
14+
15+
```bash
16+
npx meta gen --list --format json --probe # every generator: layer, framework, what it emits for YOUR model
17+
npx meta types --type source --kind subtype # the registered sources: only source.rdb
18+
```
19+
20+
The dialects are `postgres`, `sqlite`, `d1` (TypeScript only) and `mysql` (codegen and runtime
21+
only). The HTTP reference generators are `routes` (Fastify) and `routes-hono` (Hono). The UI
22+
reference generators target React and TanStack. Anything else is this page.
23+
24+
## 2. Split the app into layers and decide each one
25+
26+
| Layer | Always available | When your target is not built in |
27+
|---|---|---|
28+
| **model** — types, create/PATCH validation, filter and sort allowlists | yes, in every port, for every store | — |
29+
| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java) | §3 |
30+
| **api** — the HTTP surface | Fastify, Hono | §4 |
31+
| **client** — hooks, forms, grids | React, TanStack | §5 |
32+
33+
The model layer never depends on the store or the framework. Generate it first
34+
(`meta eject entity`, wire `entityFile()`); everything you write later imports it.
35+
36+
## 3. Persistence
37+
38+
**MySQL (TypeScript).** Set `dialect: "mysql"`. The generated tables, queries and routes
39+
target `drizzle-orm/mysql-core`, and the ObjectManager runs over `kyselyDriver` or
40+
`drizzleDriver`. `meta migrate` does not own a MySQL schema, so you write the DDL. Setup,
41+
the column-type table and the behaviour differences are in `typescript-mysql.md`. On Java,
42+
OMDB's `MySQLDriver` reads and writes MySQL; the generated DTOs and controllers are
43+
dialect-neutral.
44+
45+
**A SQL database that speaks another dialect's protocol.** Use that dialect: CockroachDB,
46+
Neon, Supabase, AlloyDB and Aurora PostgreSQL are `postgres`; Turso and libSQL are `sqlite`
47+
(or `d1` on Cloudflare); MariaDB and PlanetScale are `mysql`. Test the generated queries
48+
against the real server before relying on them.
49+
50+
**Any other store** — MongoDB, Cassandra, DynamoDB, Firestore, Neo4j, Redis, Elasticsearch,
51+
a SQL database no dialect covers, or a remote API. Model each stored record as a
52+
**sourceless entity** and generate its data access with a generator you own:
53+
54+
1. An `object.entity` with an `identity.primary` and **no `source.*` child**. With no source,
55+
nothing generates a table, queries or routes for it, and `meta migrate` ignores it. It still
56+
gets its whole wire contract in every port: the type, the create and PATCH schemas, and the
57+
filter allowlist.
58+
2. Store-specific settings go in a property bag of your own: any `@name` whose value is a JSON
59+
object is accepted, for example `"@mongo": { "collection": "orders" }` or
60+
`"@dynamo": { "table": "orders", "partitionKey": "customerId" }`. Your generator reads it.
61+
**Do not invent a document or graph source subtype, or a `@collection` attribute** — the loader
62+
refuses unregistered vocabulary (`ERR_UNKNOWN_ATTR`), and a bag needs no registration.
63+
3. Embedded documents are value objects: `field.object` + `@objectRef` to an `object.value`,
64+
`isArray: true` for a list. They validate as part of the parent.
65+
4. Write a repository generator: select objects with the port's sourceless predicate, and emit
66+
create / findById / list / patch / delete over the store's driver, validating with the
67+
generated schemas and checking filters against the generated allowlist. A complete,
68+
tested MongoDB generator for TypeScript is in `typescript-document-store.md`; for
69+
Cassandra, DynamoDB or an HTTP API only the driver calls change.
70+
71+
| Port | Sourceless predicate | Build the repository on |
72+
|---|---|---|
73+
| TypeScript | `isSourcelessEntity` (`@metaobjectsdev/codegen-ts`) | `<E>InsertSchema`, `<E>UpdateSchema`, `<E>FilterAllowlist` |
74+
| Python | `is_sourceless_entity` | `<E>Create`, `<E>Patch`, `<e>_filter_allowlist` |
75+
| Java / Kotlin | `RestSurfaceGate.isSourcelessEntity` | `<E>Dto`, `<E>Patch`, `<E>FilterAllowlist` |
76+
| C# | `InstanceArtifacts.IsSourcelessEntity` | the entity POCO, `<E>FilterAllowlist` |
77+
78+
What a non-relational store does not get: schema management (create collections and indexes
79+
the store's own way — or write a generator for its optional schema, such as a MongoDB
80+
`$jsonSchema`), generated routes and client hooks (your repository is the seam; see §4), and
81+
join traversal (`identity.reference` loads and documents, but nothing follows it across stores).
82+
83+
## 4. An HTTP framework that is not built in
84+
85+
Try these in order:
86+
87+
1. **Config first.** Several apparent framework mismatches are one setting: `apiPrefix`,
88+
`extStyle` (`"none"` for Turbopack and other bundlers that do not rewrite `.js`
89+
specifiers), `clientDirective` (React Server Components), `outDir`, per-target output.
90+
2. **Hono reaches most hosts.** `routes-hono` emits Hono handlers that take the database as an
91+
injected dependency, and Hono runs on Node, Bun, Deno, Cloudflare Workers and inside a
92+
Next.js route handler (`hono/vercel`). Mount the generated Hono app inside your framework
93+
rather than rewriting routes.
94+
3. **Eject the nearest reference and retarget it.** `meta eject routes` (or `routes-hono`)
95+
copies the generator and its HTTP adapter into your repo; change the emit to your
96+
framework's handler shape (Express, Koa, NestJS, Elysia, tRPC, …). The handlers call
97+
the generated queries, which carry no framework coupling. The TypeScript procedure is in
98+
`typescript-retargeting.md`.
99+
4. **Or write a routes generator** from scratch over the generated queries or your
100+
repository — the same ~20-line generator shape as any other (the top of this skill).
101+
102+
Never hand-write per-entity routes the model describes: a generator gives every entity the
103+
same surface, and `meta verify --codegen` keeps it in step with the model.
104+
105+
## 5. A UI framework that is not built in
106+
107+
`@metaobjectsdev/runtime-web` is framework-free: the fetch contract, filter query strings,
108+
currency and grid config. Write a generator that emits your framework's hooks or components
109+
(Vue composables, Svelte stores, Solid resources, Angular services) over it and over the
110+
generated types. Angular has source-only reference packages in the MetaObjects repository.
111+
112+
## 6. Prove it works
113+
114+
- `meta verify --codegen` fails when committed output is stale — it re-runs your generator.
115+
- Typecheck the generator (`npx tsc -p tsconfig.codegen.json`); `meta gen` loads it untyped.
116+
- Write a test that **executes the generated code against the real store or framework** —
117+
create, read back, patch, a refused filter, a validation failure. A generator that emits
118+
plausible text is not proven until its output runs.

‎agent-context/skills/metaobjects-codegen/references/owning-generators.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ that generator and retarget it instead; see "Your framework isn't the default" b
5858

5959
## Your framework isn't the default — the retargeting procedure
6060

61+
(Database or store not built in either? `any-stack.md` covers persistence, API and client together.)
62+
6163
If the shipped templates do not emit for your stack, retargeting is the **normal first
6264
move** — not a workaround and not a sign of a bug. Owning a generator is the supported
6365
path to any framework; MetaObjects does not ship a codegen package per framework and is
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# A document store on TypeScript — the MongoDB repository generator
2+
3+
> Part of the `metaobjects-codegen` skill's TypeScript reference. Read it when records live
4+
> in MongoDB — or in any store MetaObjects does not manage (Cassandra, DynamoDB, Firestore,
5+
> an HTTP API): the shape is the same and only the driver calls change. The modeling rules
6+
> are in `any-stack.md` §3.
7+
8+
This generator emits `<Entity>.repository.ts` for every **sourceless entity** (an
9+
`object.entity` with an `identity.primary` and no `source.*`). It was run end to end against
10+
MongoDB 7 with the `mongodb` 7.x Node driver: create, list by an enum value and by `in`, patch,
11+
and delete work; a filter on an undeclared field is refused; a create with a value outside the
12+
enum fails Zod validation.
13+
14+
## Use it
15+
16+
1. Save the file below as `codegen/generators/mongo-repository.ts` — it is yours to edit.
17+
2. `npx meta eject entity`, then wire both generators:
18+
19+
```ts
20+
import { entityFile } from "./codegen/generators/entity.js";
21+
import { mongoRepositoryGenerator } from "./codegen/generators/mongo-repository.js";
22+
23+
export default defineConfig({
24+
outDir: "src/generated",
25+
generators: [entityFile(), mongoRepositoryGenerator()],
26+
});
27+
```
28+
29+
3. `npm install mongodb`, then `npx meta gen`. Use the repository from your routes:
30+
`const orders = orderRepository(client.db("shop")); await orders.create(body);`
31+
32+
What the repository does, built on the generated contract:
33+
34+
- `create` validates with `<E>InsertSchema` and mints the id when the identity has
35+
`@generation: uuid`. The identity is stored as the document's `_id`.
36+
- `patch` validates with `<E>UpdateSchema`: an absent key is left unchanged, and an explicit
37+
`null` unsets the field.
38+
- `list` accepts `{ field: value }` or `{ field: { op: value } }` and refuses any field or
39+
operator `<E>FilterAllowlist` does not allow.
40+
- The collection name comes from the entity's `"@mongo": { "collection": "…" }` bag, and
41+
defaults to the camel-cased plural of the entity name.
42+
43+
## The generator
44+
45+
```ts
46+
// A MongoDB repository per SOURCELESS entity: an object.entity with an identity.primary and
47+
// no source.* — a record MetaObjects models but does not store. The entity generator emits
48+
// its wire contract (<E>InsertSchema, <E>UpdateSchema, <E>FilterAllowlist); this emits the
49+
// data access over it. The collection name comes from an author-supplied `@mongo` property
50+
// bag on the entity: `"@mongo": { "collection": "orders" }`.
51+
import { isSourcelessEntity, perEntity, type Generator } from "@metaobjectsdev/codegen-ts";
52+
import type { MetaObject } from "@metaobjectsdev/metadata";
53+
54+
function collectionOf(obj: MetaObject): string {
55+
const bag = obj.attr("mongo");
56+
if (bag !== null && typeof bag === "object" && "collection" in bag && typeof bag.collection === "string") {
57+
return bag.collection;
58+
}
59+
return `${obj.name.charAt(0).toLowerCase()}${obj.name.slice(1)}s`;
60+
}
61+
62+
function render(obj: MetaObject): string {
63+
const E = obj.name;
64+
const e = `${E.charAt(0).toLowerCase()}${E.slice(1)}`;
65+
const pk = obj.primaryIdentity()?.fields[0] ?? "id";
66+
const generated = obj.primaryIdentity()?.attr("generation") === "uuid";
67+
const newId = generated ? `crypto.randomUUID()` : `(input as { ${pk}: string }).${pk}`;
68+
return `// GENERATED by codegen/generators/mongo-repository.ts — edit the generator, not this file.
69+
import type { Collection, Db, Filter } from "mongodb";
70+
import {
71+
${E}InsertSchema, ${E}UpdateSchema, ${E}FilterAllowlist,
72+
type ${E}, type ${E}Create, type ${E}Patch,
73+
} from "./${E}.js";
74+
75+
type ${E}Doc = Omit<${E}, "${pk}"> & { _id: string };
76+
77+
const OPS = { eq: "$eq", ne: "$ne", gt: "$gt", gte: "$gte", lt: "$lt", lte: "$lte", in: "$in" } as const;
78+
79+
function toEntity({ _id, ...rest }: ${E}Doc): ${E} {
80+
return { ${pk}: _id, ...rest } as ${E};
81+
}
82+
83+
/** A query \`{ field: value }\` or \`{ field: { op: value } }\`, checked against ${E}FilterAllowlist. */
84+
export type ${E}Query = Record<string, unknown>;
85+
86+
function toMongoFilter(query: ${E}Query): Filter<${E}Doc> {
87+
const out: Record<string, unknown> = {};
88+
for (const [field, raw] of Object.entries(query)) {
89+
const rule = (${E}FilterAllowlist as Record<string, { ops: readonly string[] }>)[field];
90+
if (rule === undefined) throw new Error(\`${E}: "\${field}" is not filterable\`);
91+
const clauses = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : { eq: raw };
92+
const mongo: Record<string, unknown> = {};
93+
for (const [op, value] of Object.entries(clauses)) {
94+
if (!rule.ops.includes(op) || !(op in OPS)) throw new Error(\`${E}: "\${op}" is not allowed on "\${field}"\`);
95+
mongo[OPS[op as keyof typeof OPS]] = value;
96+
}
97+
out[field === "${pk}" ? "_id" : field] = mongo;
98+
}
99+
return out as Filter<${E}Doc>;
100+
}
101+
102+
export function ${e}Repository(db: Db) {
103+
const docs: Collection<${E}Doc> = db.collection<${E}Doc>(${JSON.stringify(collectionOf(obj))});
104+
return {
105+
async create(input: ${E}Create): Promise<${E}> {
106+
const row = ${E}InsertSchema.parse(input);
107+
const _id = ${newId};
108+
await docs.insertOne({ ...row, _id } as ${E}Doc);
109+
return toEntity({ ...row, _id } as ${E}Doc);
110+
},
111+
112+
async findById(id: string): Promise<${E} | null> {
113+
const doc = await docs.findOne({ _id: id } as Filter<${E}Doc>);
114+
return doc === null ? null : toEntity(doc);
115+
},
116+
117+
async list(query: ${E}Query = {}): Promise<${E}[]> {
118+
return (await docs.find(toMongoFilter(query)).toArray()).map(toEntity);
119+
},
120+
121+
/** PATCH: an absent key is untouched, an explicit null clears the field. */
122+
async patch(id: string, patch: ${E}Patch): Promise<${E} | null> {
123+
const changes = ${E}UpdateSchema.parse(patch) as Record<string, unknown>;
124+
const $set: Record<string, unknown> = {};
125+
const $unset: Record<string, ""> = {};
126+
for (const [k, v] of Object.entries(changes)) {
127+
if (v === undefined) continue;
128+
if (v === null) $unset[k] = "";
129+
else $set[k] = v;
130+
}
131+
const doc = await docs.findOneAndUpdate(
132+
{ _id: id } as Filter<${E}Doc>,
133+
{ $set, $unset },
134+
{ returnDocument: "after" },
135+
);
136+
return doc === null ? null : toEntity(doc);
137+
},
138+
139+
async delete(id: string): Promise<boolean> {
140+
return (await docs.deleteOne({ _id: id } as Filter<${E}Doc>)).deletedCount === 1;
141+
},
142+
};
143+
}
144+
`;
145+
}
146+
147+
export function mongoRepositoryGenerator(): Generator {
148+
return {
149+
name: "mongo-repository",
150+
filter: isSourcelessEntity,
151+
generate: perEntity((obj) => ({ path: `${obj.name}.repository.ts`, content: render(obj) })),
152+
};
153+
}
154+
```

0 commit comments

Comments
 (0)