Repository navigation
Commit 2ec6827
feat(metadata-protocol): the draft save records draftChanges per key path; the save declares its surface (?surface=studio|api, copilot in-process only) (#24418)
Fixes #24395
Clause-②: yes (widening)
## What this does
`GetMetaItemResponseSchema.draftChanges` landed in `@objectstack/spec`
(#24383) with no writer. This PR is the writer. Its first deliverable is
a save request that declares its authoring surface.
- **Surface input (spec).** `MetadataAuthoringSurfaceSchema` (`copilot |
studio | api`) is now one exported schema.
`SaveMetaItemRequestSchema.surface` uses it, and so does
`draftChanges[].by` (the inline enum is gone, same values). An absent
`surface` stamps `api`.
- **HTTP spelling: `?surface=studio|api`** on `RestServer`'s `PUT
/meta/:type/:name` and on the runtime dispatcher's. It joins the closed
query set `META_ITEM_SAVE_PARAMS` and its arity list, and is read once
in `metaSaveRequestOptions` beside `?mode=`. Both doors answer `400
VALIDATION_ERROR` for `?surface=copilot` (only an in-process tool may
state it), for any other value (a case variant included, never folded to
`api`), and for a repeated `?surface=`. No new error code: in-process, a
value outside the set is `400 INVALID_REQUEST`, the save door's existing
request-envelope refusal.
- **Record (repository).** `SysMetadataRepository.put` with `state:
'draft'` records the entries inside `resolveWriteHead`'s transaction. It
compares the new body with the body it replaces: the draft row, else the
active row the draft's publish would replace (that package's row, else
the package-less one), else nothing, which records `[]`. A path is
object keys only, and an array is one leaf. The keep rule: every entry
at or under a changed path is replaced, so no two entries share a path.
A save that changes no path leaves the column as it was. `actor` is
`opts.actor ?? null`, the value the history event carries. `at` is the
write's own `now` (`toISOString()`). The pure rules live in
`packages/metadata-protocol/src/draft-changes.ts`.
- **Who records.** `saveMetaItem` passes `draftChangesBy: surface ??
'api'` only on an authoring draft save. A `migrate-stored` rewrite (the
only caller that states a `source`) records nothing, and neither does an
active save.
- **Serve.** The strict draft read (`getMetaItem` with `state: 'draft'`
from a stored row) adds `draftChanges` beside `item`, taken from the row
it serves. It is absent on the active read, the cached branch,
`?preview=draft`, and a draft row that recorded none, and never `[]`.
Neither HTTP door needed a transport change (review 6253694122 ③ 4 held:
measured below).
- **Storage home: a nullable `sys_metadata.draft_changes` column**
(`SysMetadataObject`, JSON text), on the draft row and never inside
`metadata`. Publish (`promoteDraft`'s drain) and discard
(`discardDraftInItsScope`) delete the row, and the entries go with it.
## Where it landed outside the claim's file surface, and why
- `packages/rest/src/meta-item-read-gate.ts` (`META_ITEM_SAVE_PARAMS`)
and `packages/rest/src/meta-item-write-routes.ts` (the REST door's arity
list). Both doors' closed query sets live there; without them
`?surface=` is refused as an unknown parameter (measured before).
- `packages/metadata-protocol/src/draft-changes.ts` (new), holding the
recorder's pure functions, and its test.
- `packages/platform-objects/src/apps/translations/*.generated.ts`: the
new column's label, regenerated by `node scripts/check-i18n-bundles.mjs
--write`. The zh-CN, ja-JP and es-ES values are hand-translated, which
is allowed for values.
- `packages/spec/src/type-alias-convention.pin.test.ts`: the ADR-0122
isomorphic pin for the new enum (`check:spec-parsed-alias` asked for
it), with the count moving from 788 to 789.
- `.changeset/24298-draft-key-provenance.md`, **a deliberate correction
of a pending release note, which needs a person's confirmation.** Its
bullet said "No server writes the key yet". That sentence becomes false
the moment this PR ships, and both notes are pending for the same
release. I rewrote that one sentence. `check-empty-changeset` stays red
on it by design, and its own text says this class is not to be restored
from base. The seat or maintainer confirms or rejects the correction.
- Generated by tooling, never by hand:
`content/docs/references/api/protocol.mdx`,
`content/docs/references/index.mdx`,
`packages/spec/{api-surface,authorable-surface,declaration-map,export-origins,json-schema.manifest}/api.json`
(`check:generated --fix` plus the spec build).
- Pins: the protocol-level block is appended to
`protocol.packageless-save-address.test.ts`, which reuses that file's
ledgered engine double rather than registering a new one. The both-doors
block is appended to
`packages/runtime/src/domains/meta-save-preconditions-parity.test.ts`,
which runs over a real SQL store.
## Verification (M, booted)
**REST door, booted `pnpm dev:crm --fresh --database file:persist.db`
(SQLite), before and after:**
| case | before (`d79dc4883c`) | after |
|---|---|---|
| `PUT ?mode=draft` | 200; read has no `draftChanges` | 200;
`[{path:[],by:"api",actor:"Voj5…",at:"…Z"}]` |
| `?surface=studio` / `api` | 400 unknown parameter (`Supported
parameters: force, mode, package.`) | 200; `by:"studio"` / `by:"api"` |
| `?surface=copilot` | 400 unknown parameter | 400 `VALIDATION_ERROR`,
nothing written |
| `?surface=STUDIO`, a repeated `surface` | 400 unknown parameter | 400
`VALIDATION_ERROR` (value / arity) |
| first draft save over an active row | n/a | `[["columns"],api]` only |
| second save (studio, label) | n/a | `[["columns"],api],
[["label"],studio]` |
| third save (studio, columns back) | n/a | `[["label"],studio],
[["columns"],studio]` (replaced at the path, one entry) |
| active read · cached branch (ETag) · `?preview=draft` | absent |
absent |
| draft row saved BEFORE the writer (same DB file, booted again on the
new build) | n/a | absent; its next save records `[["label"],studio]` |
| `POST …/publish` → rows | n/a | `[('active', None)]`; next draft
records only `["description"]` |
| `DELETE ?state=draft` → rows | n/a | `[('active', None)]`; next draft
starts clean |
**Existing deployment gains the column:** the `before` boot created
`sys_metadata` without `draft_changes` (`pragma table_info` listed 24
columns, no `draft_changes`). Booting the after-build on the same file
added it, with no manual step, and the pre-writer row reads `NULL`.
**Dispatcher (a real `createHonoApp` catch-all over the real protocol),
on SqliteWasm and on the memory driver, with an in-process
`saveMetaItem`:** before, every `?surface=` was 400 unknown (`Supported
parameters: mode, package.`) and no read carried the key. After: none,
`studio` and `api` stamp `by`, while `copilot`, `Studio` and `bogus` are
400 `VALIDATION_ERROR`. In-process `surface: 'copilot'` stamps
`by:"copilot"` with its actor, and `surface: 'robot'` is refused
`INVALID_REQUEST` 400. The copilot first save over an active row gives
`[label, columns]` by copilot, and then a studio save of `label` gives
`[columns:copilot, label:studio]`. The active read and `?preview=draft`
are absent. After publish and after discard, the rows are `active:null`
only and the next draft starts clean. The memory driver gives the same
results.
**Tests (merged with `origin/main` at `78652c09`, which carries
#24401):** full suites, each `VERDICT command-exit 0` through
`os-verify-lock`:
- `@objectstack/metadata-protocol`: 247 files, 20686 tests.
- `@objectstack/spec`: 762 files, 22361 tests, re-run after the alias
fix.
- `@objectstack/metadata-core` and `@objectstack/platform-objects`.
- `@objectstack/rest`: 303 files, 6365 tests.
- `@objectstack/runtime`: 424 files, 7442 tests.
Typecheck exit 0 for all six touched packages. `pnpm --filter
@objectstack/spec check:generated` passes after a rebuild on the merged
tree.
**Ablations** (`scripts/ablation-replace.mjs`: each mutation proven on
disk, restored by blob hash equal to HEAD with `git diff HEAD` empty;
the subjects resolve through `src`, so there is no dist hop):
1. The recorder (`if (state === 'draft' && opts.draftChangesBy !==
undefined)` → `if (false && …)`) turned 4 pins red in the protocol
block.
2. The replace rule (`entries.filter(…isAtOrUnder…)` → `[...entries]`)
turned 4 red: 3 unit pins and the protocol's two-save pin.
3. The `copilot` refusal (both guards: the explicit branch and the
closed set) turned exactly the REST `?surface=copilot` refusal pin red.
**Gates, on the final head `a3925372f`:** `node
scripts/pm/dispatch-gates.mjs --commands` with no paths derives 116
families. All 116 ran, each exit code captured before any pipe, and
`--ran` reconciles them: 116 run, 0 NOT-MEASURED (a derived zero), 115
exit 0.
- The one red is by design: `check-empty-changeset` (exit 1), the 24298
note correction above.
- Six spec gates first answered exit 3 (PREREQUISITE NOT MET: the spec
`dist` predated the pin-test edit). After `pnpm --filter
@objectstack/spec build` each one exited 0: `check:api-surface`,
`check:dual-source-exports`, `check:entry-nameability`,
`check:exported-any`, `check:generated` and `check:skill-examples`.
- `check:spec-parsed-alias` went red on an earlier head and was fixed:
the alias became `z.input`, with its isomorphic pin.
## Acceptance notes
- **First-save baseline for an item with no stored published row.** A
draft of an item that only a code package ships (no `sys_metadata`
active row) records `[]`, the whole body, per the claim's "else
nothing". The `draftChanges` describe now says "the stored published
row" so declared equals enforced. For Studio this means a copilot draft
of a packaged view marks every key as the copilot's. It is raised in the
report's open questions.
- An active save accepts and validates `?surface=`, but records nothing,
since there is no draft row to record on.
- Downstream, not in this PR: cloud's `stageDraft` / `applyDraft` pass
`surface: 'copilot'`, and objectui's Studio sends `?surface=studio` and
reads the key (objectstack-ai/objectui#12725 item 3).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e67ac4b commit 2ec6827
38 files changed
Lines changed: 882 additions & 47 deletions
File tree
- .changeset
- content/docs/references
- api
- packages
- metadata-core/src/objects
- metadata-protocol/src
- objectql/src
- platform-objects/src/apps/translations
- rest/src
- runtime/src/domains
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
0 commit comments