Skip to content

feat: Universal Content Engine mvp 6 - #741

Open
aXenDeveloper wants to merge 10 commits into
feat/Universal-Content-Engine-mvp-5dfrom
feat/Universal-Content-Engine-mvp-6
Open

feat: Universal Content Engine mvp 6#741
aXenDeveloper wants to merge 10 commits into
feat/Universal-Content-Engine-mvp-5dfrom
feat/Universal-Content-Engine-mvp-6

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

Description

What?

Why?

aXenDeveloper and others added 9 commits August 8, 2026 10:53
Stage 6 starts at the definition layer: three new field shapes, one canonical
field-path vocabulary, and the definition-time validation that makes every
generated table name and every flattened column known before a request runs.

- `field.relation({ multiple, ordered })` for to-many and ordered relations.
  `target` was already a thunk, so a self-relation needs nothing special.
- `field.group({ fields })` for reusable structured groups. Localization is a
  property of the group, never of a leaf: half a logical value on each table
  would mean two revision histories and two permissions for one editor box.
- `field.repeatable({ fields })` for ordered child rows with stable identity.
- `content/paths.ts` is the single leaf-path <-> column mapping every
  subsystem reads, so `seo.title` and `seo_title` cannot drift.
- `content/advanced.ts` resolves the generated junction and child table names
  and refuses every advanced-field mistake at import time.

Nested create/update schemas, leaf-path public allowlisting and the
membership filter land with them. Stage 1-5 behaviour is unchanged: a content
type that declares no advanced field resolves to empty arrays everywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runtime half of Stage 6's storage model. Nothing here is JSONB, an EAV
table or a comma-separated identifier list: a to-many relation is a junction
table with two real foreign keys, a repeatable is a child table with a serial
primary key of its own, and a group is flattened into ordinary columns.

- `advanced-tables.ts` generates both shapes, with PRIMARY KEY
  (itemId, relatedItemId), UNIQUE (itemId, position) and a reverse index on
  the junction; UNIQUE (itemId, position) and ON DELETE CASCADE on the child.
- `advanced-store.ts` is the one read/write layer both kinds share. Positions
  are settled in two passes through a negative parking space, so a reorder can
  never violate the unique index even for an instant - and child identity
  survives it, which is what makes "edit entry 11" and a revision restore mean
  anything.
- Relation and repeatable mutations are wrappers over `update`, so each one
  inherits the no-op rule, the lock, `updatedAt`, and - on an editorial content
  type - the version guard, the revision and the event. The guard runs before a
  single junction row is touched, so a losing writer leaves nothing behind.
- Revisions record collections as identity and groups as nested values, never
  flattened column names and never the expanded related records.
- Public reads batch-load collections one query per field per page, so a list
  never issues one per row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Search, cache and events all read the same vocabulary now - canonical paths -
so none of them needed a Stage 6 code path of its own.

- `search.contentFields` accepts group leaves and repeatable leaves. A
  repeatable leaf resolves to every child's value joined in **position** order,
  because position is what the page renders and an index that disagreed with
  the page would highlight the wrong entry.
- The synchronizer matches `changedFields` against indexed paths *and* their
  containers, so a rewritten FAQ answer (reported as `faq`) still moves the
  document, while a relation change moves nothing unless the relation actually
  contributes to a configured projection.
- Collections are read back only when the configuration names one, so every
  Stage 1-5 content type pays a boolean check and no query.
- Cache and events needed no change at all: `changedFields` already carries
  `seo.description` and `categories`, and the existing locale fan-out already
  treats a shared field as reaching every locale - which a collection is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uite

`example.advanced-article` exercises every Stage 6 shape at once: an
unordered to-many relation, an ordered **self**-relation, a localized group, a
shared group and a repeatable - on a content type that is also localized,
published, editorial, public and searchable.

Migration 0031 is what the definition compiles to, generated rather than
written: two junction tables, one child table, four flattened group columns
across the base and translation tables, and the constraints that make ordering
and integrity facts about Postgres rather than about service code.

The new suite is the half a mock cannot show. A duplicate junction row is a
23505, a category still in use is a 23503, a cascade really does take the
child rows, and five concurrency tests prove exactly one writer wins a race -
relation vs relation, relation vs scalar, repeatable vs repeatable, reorder vs
reorder - while two different records stay independent.

Three real bugs fell out of running it: to-many relations were being asked for
a base-table foreign key they do not have, the translation model was selecting
a group rather than its leaf columns, and the localized public join was
looking a canonical path up on an aliased table that only carries column keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`target: () => thisContentType` makes a definition's own inferred type
circular, and TypeScript resolves that by widening the whole definition to
`any` - silently. Every nested value type, every leaf-path allowlist check and
every compile-time guarantee Stage 6 adds would disappear, with no error to
say so. `self: true` carries no reference at all, and `defineContentType`
rebinds the thunk on a *copy* of the field map once the definition exists - so
a descriptor const shared by two content types cannot end up pointing both
relations at whichever was declared last.

Also adds the definition-time and type-level suites: 31 runtime cases covering
every advanced-field rule, and 28 type assertions whose interesting half is
negative - a group must not leak its flattened column names, a collection must
not appear on a list row, a private leaf must not appear in a public response,
an index must not accept a repeatable path. One of them pins the variance of
`ContentModel<T>`, which a `UnionToIntersection` in the table types had
already broken once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three editors, all rendered through the AutoForm seam `ContentField` already
uses - so a leaf inside a group is the same input a top-level field of that
kind renders, and Stage 6 grows no second form system.

- **Group** renders as a `fieldset`/`legend`, so a screen reader announces
  "SEO" with every leaf inside it - which is what tells `SEO / Title` from
  `Article / Title`. A nullable group gets a switch, because `seo: null` is a
  different stored state from every leaf happening to be empty.
- **Repeatable** gets Add / Edit / Remove / Move up / Move down as labelled
  buttons. Drag-and-drop may be layered on later but can never be the only way
  to reorder. React keys come from a client-side key that is stable for a row's
  life, never from `id` - an unsaved row has no `id`, and keying off it would
  make React reuse one row's DOM state for another's value.
- **To-many relation** wraps the existing async combobox as an "add one"
  control. Reorder buttons appear only for an `ordered: true` relation, since
  the engine sorts an unordered set by target id whatever the editor does.

Each editor controls the nested value the API takes, so nothing is flattened on
submit and nothing re-nested on load. The revision diff learned to render the
three shapes too, rather than `[object Object]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven pages under `dev/content-engine/`, registered in `meta.json` after the
localization set:

- **advanced-modeling** - the three shapes, the canonical-path vocabulary, and
  why a content type that declares none is unchanged.
- **relations** - to-one, to-many, ordered, self, the junction table's
  constraints, `onDelete` per direction, and the membership filter.
- **structured-fields** - leaves, canonical paths, partial updates, the four
  nullability states and the two rules that keep them distinguishable, and why
  localization belongs to the group.
- **repeatable-fields** - the child table, identity vs position, the two-pass
  reorder, the atomic form save, and the one lock.
- **advanced-modeling-public-api** - leaf-level allowlisting, relations as
  identifiers, and why there is no expansion to configure.
- **advanced-modeling-migrations** - what is generated, and two existing-data
  migrations written so the destructive statement is in a *different file*
  from the copy, with a verification step between.
- **advanced-modeling-limitations** - every refusal, with the reason and the
  thing to do instead.

`fields.mdx` gains the three new rows and points at them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two pure-function suites, and both found a real bug.

`paths.test.ts` pins the one leaf-path <-> column mapping every subsystem
reads: what is and is not a path, the camelCase compilation, the flatten, the
fold back, and the two directions of a nullable group (`seo: null` writes NULL
to every leaf; every leaf NULL reads back as `seo: null`).

`advanced-projection.test.ts` covers the diff, the column patch, the snapshot,
the search document and the public projector. It caught:

- `diffChangedPaths` reporting a collection as a changed *column* when handed
  a field map that still contained one. Harmless at the call sites, which pass
  the shared half - but a function that is only correct because of who calls it
  is a function waiting to be called by somebody else.
- `contentSearchDocument` normalizing a repeatable's joined text a second
  time, collapsing the newlines *between* children into single spaces. The
  values were all there; the boundaries were not, so two adjacent answers ran
  together into a phrase neither of them contains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…self

`references: { related: () => thisContent.table.id }` names the model inside
its own initializer, and TypeScript resolves that by widening the model to
`any` - silently taking every typed service, schema and column map with it.
The definition-level version of this trap was fixed by `self: true`; this is
the same trap one layer down.

`ContentReferences` now subtracts self-relations from the map it demands, and
the table generators resolve them from the table being built - the only place
that reference exists anyway. Which meant `self` had to become a literal type
parameter, which meant the "exactly one of `self` and `target`" rule had to
move out of the signature (a union there defeats literal inference) and into
`defineContentType`, before the rebind - the only moment a supplied target and
the placeholder are still distinguishable.

Also adds the route-level suite the example plugin was missing: mounting the
generated admin and public routes converts every request and response schema to
JSON Schema, so a nested group or repeatable array Zod could not describe fails
at `pnpm test` rather than at the first request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vitnode-docs Ready Ready Preview Aug 8, 2026 12:27pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

const document = contentTranslationSearchDocument(
definition,
{ base: input.row, locale, translation: translation.values },
{ pluginId: input.pluginId },

P2 Badge Merge advanced values into localized search documents

When a localized searchable type indexes a repeatable leaf such as the example's faq.answer, this call supplies only the base row and translation row. Unlike the non-localized path, syncContentLocalizedSearch never loads or merges model.service(c).advanced(itemId), so the repeatable container is always absent and its configured text is omitted from every localized search document—even after an FAQ change triggers an upsert.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +33 to +34
const toRows = (value: unknown): EditorRow[] =>
Array.isArray(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Load existing collections before rendering editors

When editing an existing record, ContentTableView passes its findMany row directly to the form, while the detail route still uses findById; neither read includes collection tables. Consequently field.value is undefined here, so existing repeatables render as empty (and relation sets have the same problem); interacting with such a field then submits a replacement derived from an empty list and can delete previously stored entries. Load the newly added findDetail/advanced data before opening the editor.

Useful? React with 👍 / 👎.

Comment on lines +775 to +779
service.update(
itemId,
{ [field]: [...next] } as ContentUpdateInput<TDefinition>,
options,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve editorial semantics in collection shortcuts

For editorial content types, the documented service.relations.* shortcuts accept expectedVersion, but this dispatches to the plain createContentService.update implementation, which ignores that option and does not create a revision or emit editorial effects. A stale shortcut write therefore succeeds without advancing the editorial version/history or notifying listeners; bind these shortcuts to the editorial mutation path or avoid exposing them for editorial definitions.

AGENTS.md reference: AGENTS.md:L34-L36

Useful? React with 👍 / 👎.

Comment on lines +782 to +785
add: async (itemId, relatedItemId, options) => {
const current = await read(itemId, options);

return await write(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read collection state after acquiring the row lock

For non-editorial records, concurrent granular mutations can lose data because add reads the collection before service.update starts its transaction and acquires the row lock. If two calls add different targets from the same initial state, both compute stale replacement arrays; the writes serialize, but the second replacement removes the first addition. Perform the read-modify-write inside the locked transaction (the repeatable create, delete, and update helpers have the same race).

Useful? React with 👍 / 👎.

Comment on lines +335 to +339
const repeatableSchema = (fieldValue: ContentFieldDescriptor): z.ZodType =>
z
.array(repeatableRowSchema(fieldValue))
.min(contentRepeatableMin(fieldValue))
.max(contentRepeatableMax(fieldValue));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject duplicate repeatable child identifiers

An update payload may contain the same existing child id more than once because this array schema enforces only length bounds. The writer then updates that single database row repeatedly and settles it into only the last requested position, returning success with fewer children than the accepted payload described. Add a refinement requiring all present child IDs to be distinct, analogous to relationSetSchema.

Useful? React with 👍 / 👎.

**Translation revisions lost every localized group.** Stage 6 taught the base
snapshot about groups and left the translation one running each localized field
through the scalar coercion - which returns `null` for an object. So a
translation revision recorded `seo: null` for every record that had SEO, and
restoring one blanked it. Both halves now share `toFieldSnapshot` and one
`projectSnapshotFields`, and the leaf read that made that possible -
`readContentLeaf` - is also what lets the diff work against a logical row, so
the restore no longer reports an unchanged group as changed.

**Localized search documents were missing their collections.** Three paths build
one - live sync, translation effects, base editorial effects - and none supplied
the shared collections, so a document made of `faq.answer` contained none of it,
and editing one localized leaf silently rewrote the document without it. The
rebuild was worse: it classified indexed names through the top-level field maps,
where neither `seo.description` nor `faq.question` is a key, and dropped both.
`contentSearchAdvancedValues` is now the single place that decides whether a
document depends on collections, and `resolveSearchSources` resolves each path to
the column or child table it actually lives in. Collections are batched per page
and deduplicated across locales.

**Collection helpers read before taking the lock.** `add` read the current list,
then called `update`, which opened the transaction - so two concurrent additions
each computed from the same empty list and one disappeared. The read now happens
after `SELECT ... FOR UPDATE`, inside the transaction that writes. The plain
service merges; the editorial service - which now has its own `relations` and
`repeatable` - arbitrates on `expectedVersion`. `ContentWriteOptions` no longer
offers a version the plain service would have ignored.

**The typed collection API was `Record<string, …>`.** `service.relations.typo`
compiled. Both maps are now keyed by the content type's actual field names, and a
repeatable's create/update/list values are inferred from its own leaves.

Also: the localized preview read had the same top-level classification bug as the
rebuild, and the public projection now loads only the collections it exposes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Object.entries(filterShape(fields)).filter(([name]) =>
publicFilterable.has(name),

P1 Badge Include group leaves in public filter schemas

resolvePublicApi accepts filterable group paths such as syndication.priority, but filterShape(fields) only emits top-level field names and therefore has no entry for that path. The generated HTTP schema silently strips the requested filter, returning an unfiltered list, while direct service calls later reject it as unknown. Resolve canonical paths to their leaf descriptors when constructing this schema.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// A to-many relation has no foreign key *here*: its two are on the
// generated junction table, and its picker resolves through
// `model.advancedTables` rather than through a column on this row.
if (isContentRelationCollection(fieldValue)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve to-many targets for the picker

Skipping collection relations removes them from the references map consumed by service.options, while the new relation-set editor calls that method through /options/{field}. Consequently, opening the picker for any multiple: true relation reaches the !target branch and returns an error, so editors cannot add targets. Resolve these entries from the junction table as the comment describes, or provide a separate collection-options path.

Useful? React with 👍 / 👎.

Comment on lines +241 to +243
const relatedReference: ColumnReferenceThunk | undefined = fieldValue.self
? itemReference
: referenceThunks[entry.field];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify to-many references against declared targets

For a non-self collection relation, this accepts the supplied reference thunk without checking that its table matches fieldValue.target().tableName. A mistaken references.categories thunk therefore creates a valid foreign key to the wrong content table; target validation then follows that actual foreign key, so writes succeed with IDs from the wrong model despite the definition and generated types claiming otherwise. Apply the same runtime target-table check used for to-one relations before building the junction.

Useful? React with 👍 / 👎.

Comment on lines +37 to +39
typeof values.id === "number"
? `saved-${values.id}`
: `draft-${index}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep stable keys for unsaved repeatable rows

When two newly added rows are reordered, their keys are regenerated from their new array positions, so React keeps component instances attached to positions rather than to the logical rows. Stateful leaf controls such as AutoFormNullableNumber retain their old local input text while the form values move, displaying or subsequently writing values for the wrong child. Assign each draft row an identity that persists across renders and reorders instead of deriving it from index.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant