@metaobjectsdev/sdk 0.10.0 → 0.11.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.
@@ -165,6 +165,26 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends`
165
165
  { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } }
166
166
  ```
167
167
 
168
+ **Arrays of value objects** — set `isArray: true` with `@storage: jsonb`. The whole
169
+ array lives in **one** jsonb column (a JSON array), never a native `jsonb[]`. The
170
+ generated Postgres column is typed `.$type<VO[]>()` and the Zod schema is
171
+ `z.array(<VO>InsertSchema)`:
172
+
173
+ ```json
174
+ { "field.object": { "name": "triples", "@objectRef": "Triple",
175
+ "@storage": "jsonb", "isArray": true } }
176
+ ```
177
+
178
+ **Opaque jsonb (no value object)** — when the payload has no fixed shape (freeform
179
+ config, passthrough metadata, an open-keyed map), do NOT use `field.object` (it
180
+ requires `@objectRef`, and a partial VO would let the generated Zod strip unknown
181
+ keys → data loss). Model it as a `field.string` with the physical-type override
182
+ `@dbColumnType: jsonb` — the logical type stays string-bound, the column is jsonb:
183
+
184
+ ```json
185
+ { "field.string": { "name": "metadata", "@dbColumnType": "jsonb" } }
186
+ ```
187
+
168
188
  ## YAML sigil-free authoring + the coercion footgun
169
189
 
170
190
  In YAML, write the fused `type.subType` key with a **map body**, bare reserved
@@ -205,6 +225,12 @@ reference for navigation/typing/codegen only. Referential actions
205
225
  (`@onDelete`/`@onUpdate`) are NOT on `identity.reference` — they live on the
206
226
  `relationship.*` node (see Relationships below).
207
227
 
228
+ `@references` resolves cross-package by **fully-qualified name**
229
+ (`@references: "shared::billing::Account"`), the same rule as `extends`; a bare
230
+ name resolves within the current package. The FK target must be an entity with a
231
+ single-column primary key (the FK points at that PK); a target with a composite
232
+ PK needs the explicit dotted form `@references: "pkg::Target.fieldA,fieldB"`.
233
+
208
234
  ```json
209
235
  { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } }
210
236
  { "identity.secondary": { "name": "byEmail", "@fields": ["email"] } }
@@ -229,6 +255,17 @@ the two halves of one FK.
229
255
  "@cardinality": "many", "@onDelete": "cascade" } }
230
256
  ```
231
257
 
258
+ **Adoption footgun — pin BOTH actions.** `@onDelete` and `@onUpdate` each default to
259
+ `cascade` when omitted, but a plain SQL foreign key is `NO ACTION` on both. If you're
260
+ adopting an existing database (matching metadata to a live schema), omitting these
261
+ makes the metadata declare `CASCADE` where the DB has `NO ACTION` — a perpetual
262
+ `verify --db` drift. Pin **both** explicitly to the DB's real behavior:
263
+
264
+ ```json
265
+ { "relationship.composition": { "name": "author", "@objectRef": "User",
266
+ "@cardinality": "one", "@onDelete": "no-action", "@onUpdate": "no-action" } }
267
+ ```
268
+
232
269
  ## Sources — `source.rdb` + `@kind`
233
270
 
234
271
  `source.rdb` declares where an entity's data lives. Read-only-ness derives from
@@ -41,6 +41,11 @@ export default defineConfig({
41
41
  dialect: "postgres", // "postgres" | "sqlite" | "d1" (D1 is TS-only)
42
42
  apiPrefix: "/api", // flows to routes AND client fetch URLs
43
43
  columnNamingStrategy: "snake_case", // "snake_case" (default) | "literal" | "kebab-case"
44
+ timestampMode: "string", // "string" (default, ISO-8601 wire contract) | "date" (Drizzle native Date)
45
+ pluralizeCollections: true, // default; table VARS auto-pluralize (AgentConfig → agentConfigs)
46
+ collectionNameOverrides: { // per-entity escape hatch for names the rule gets wrong
47
+ AuditLog: "auditLog", LlmTierConfig: "llmTierConfig",
48
+ },
44
49
  generators: [
45
50
  entityFile(), queriesFile(), routesFile(), barrel(),
46
51
  formFile(), tanstackQuery(), tanstackGrid(),
@@ -48,6 +53,13 @@ export default defineConfig({
48
53
  });
49
54
  ```
50
55
 
56
+ Naming + timestamp knobs are **codegen config**, not metadata attributes — a
57
+ collection variable name and a Drizzle column mode are per-port rendering choices
58
+ with no meaning to the other language ports, so they carry no cross-port
59
+ conformance cost. `collectionNameOverrides` wins over `pluralizeCollections` and is
60
+ applied consistently to the table declaration, every FK reference, the `relations()`
61
+ block, and the inferred types.
62
+
51
63
  A second file, `.metaobjects/config.json`, holds static project state parseable by
52
64
  non-TS tooling; `meta init` scaffolds both plus the `metaobjects/` source dir.
53
65
 
@@ -133,3 +145,21 @@ Deterministic per dialect: `field.string` + `@maxLength` → `varchar(N)`,
133
145
  (Postgres) + `gen_random_uuid()`, `field.enum` → `varchar` + `CHECK`. Override a
134
146
  field's physical column name with `@column` on the field; the DB schema name lives
135
147
  on `source.rdb` via `@schema`.
148
+
149
+ ### Value-object jsonb columns
150
+
151
+ A `field.object` with `@storage: jsonb` (or the default `subdocument`) becomes a
152
+ single typed jsonb column — the referenced value-object's TS type is carried onto
153
+ the Drizzle column via `.$type<>()`, and its Zod schema is the VO's `InsertSchema`:
154
+
155
+ ```ts
156
+ // field.object @objectRef=LlmConfig @storage=jsonb
157
+ llmConfigJson: jsonb("llm_config_json").$type<LlmConfig>(),
158
+ // field.object @objectRef=Triple @storage=jsonb isArray=true
159
+ triples: jsonb("triples").$type<Triple[]>(), // one jsonb column, NOT a native jsonb[]
160
+ ```
161
+
162
+ The VO type, its Zod `InsertSchema`, and this `.$type<>()` all import the VO from
163
+ the same module (layout/package/`extStyle`-aware resolution). An opaque jsonb column
164
+ (`field.string @dbColumnType: jsonb`) gets no `.$type<>()` — it stays `unknown`,
165
+ which is the correct shape for freeform payloads with no fixed VO.
@@ -123,6 +123,47 @@ For the `xml`-format example above with payload `{ displayName: "Ada", postCount
123
123
  bytes. You render the prompt, call your LLM client (provider-agnostic — codegen
124
124
  emits no provider-side schema), then parse the response.
125
125
 
126
+ ## Conditional content: data and flags, never branched prose
127
+
128
+ When a prompt's wording varies along some dimension — audience, tier, mode,
129
+ locale, entitlement, a domain variant — do NOT branch the prose in code and
130
+ concatenate strings. Branching prompt text in a service is the anti-pattern this
131
+ pillar exists to remove: it scatters the same distinction across call sites, each
132
+ re-encoded and free to drift, and none of it snapshot-tested. The variation
133
+ belongs in exactly two places, with a third for the rare genuine divergence:
134
+
135
+ - **Vocabulary as payload data.** The words and values that differ become typed
136
+ payload fields, pre-computed once from the varying dimension — a noun, a label,
137
+ a set of verbs (a list), an example. The template stays single and references
138
+ `{{term}}` / `{{#items}}…{{/items}}`. The prose *structure* is identical across
139
+ variants; only the data differs, so there is nothing to branch.
140
+ - **Presence as boolean flags.** When a whole block exists-or-not for a variant,
141
+ gate it with a section flag the payload sets: `{{#showBlock}}…{{/showBlock}}`.
142
+ Reserve flags for entire blocks — never mid-sentence word swaps, which are
143
+ vocabulary.
144
+ - **Variant text only when prose truly diverges.** If a section's wording — not
145
+ just its vocabulary — genuinely differs, select a per-variant text through the
146
+ provider seam (a `@textRef` variant, or an included partial) so the shared
147
+ prose still lives in one place. Expect to need this rarely.
148
+
149
+ A single resolver maps the varying dimension to that payload (the flags + the
150
+ vocabulary), so the distinction is defined ONCE and every template that depends
151
+ on it stays consistent.
152
+
153
+ ```
154
+ // WRONG — prose branched and concatenated in a service:
155
+ if (tier.isPremium()) sb.append("Your plan includes priority support.");
156
+ else sb.append("Upgrade any time for priority support.");
157
+ ```
158
+ ```mustache
159
+ {{! RIGHT — text in the template; the variant is data + a flag }}
160
+ {{supportLine}}
161
+ {{#isPremium}}(Priority queue enabled.){{/isPremium}}
162
+ ```
163
+
164
+ This stays deterministic and golden-testable per variant: render the template
165
+ against each value of the dimension and snapshot every variant.
166
+
126
167
  ## `verify` fails the build on prompt-drift
127
168
 
128
169
  For every template, the verify step resolves the text, parses each `{{...}}`
@@ -66,7 +66,40 @@ A clean run is silent; a failure names the drifted table/column. Bias toward
66
66
  trusting the tool — a drift failure almost always means the metadata changed and the
67
67
  DB didn't follow.
68
68
 
69
+ ## Index modeling (Postgres)
70
+
71
+ Secondary indexes carry physical-shape attributes contributed by the db provider
72
+ (they live on `identity.secondary`, not core):
73
+
74
+ - `@orders` — per-key sort direction, positional to `@fields` (`["asc", "desc"]`).
75
+ Omit for all-ascending; drives `DESC`-ordered index keys (e.g. a recency index).
76
+ - `@where` — a partial-index predicate (raw SQL, e.g. `"delivered_at IS NULL"`),
77
+ emitted as `WHERE (<pred>)`. The index then covers only matching rows.
78
+
79
+ ```json
80
+ { "identity.secondary": { "@fields": ["userId", "createdAt"],
81
+ "@orders": ["asc", "desc"], "@where": "archived_at IS NULL" } }
82
+ ```
83
+
84
+ ## Adopting an existing database (non-destructive)
85
+
86
+ `meta verify --db` / `meta migrate` can reach **zero drift** against a hand-built
87
+ schema without a rewrite:
88
+
89
+ - **`meta migrate --from-db`** reverse-engineers a baseline from the live DB so the
90
+ first diff is empty.
91
+ - **Auto schema-scope** — the diff manages only the schemas the metadata *declares*
92
+ (via `source.rdb @schema`); tables in undeclared schemas belong to another owner
93
+ and are left untouched. This is what lets several apps share one database, each
94
+ owning its own schema, with a clean per-owner `verify --db` and no manual ignore
95
+ lists. A downstream app that extends the toolkit's DB declares its own `@schema`,
96
+ models only its tables, and runs its own migrate/verify against that scope.
97
+ - **`identity.reference @constraintName`** pins a foreign-key constraint name so the
98
+ metadata can match an existing DB's naming convention without a destructive
99
+ rename.
100
+
69
101
  ## Not yet shipped
70
102
 
71
- Triggers, generated columns, partial/exclusion/check constraints, MySQL, and data
103
+ Triggers, generated columns, exclusion + CHECK constraints, MySQL, and data
72
104
  migrations (column-type changes needing data transformation error out with a hint).
105
+ (Partial + descending **indexes** *are* supported — see Index modeling above.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Workspace helpers and agent-docs utilities for MetaObjects projects.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -56,7 +56,7 @@
56
56
  "access": "public"
57
57
  },
58
58
  "dependencies": {
59
- "@metaobjectsdev/metadata": "0.10.0",
59
+ "@metaobjectsdev/metadata": "0.11.0",
60
60
  "zod": "^3.23.0"
61
61
  },
62
62
  "devDependencies": {