Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/8676-object-metadata-write-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-designer': minor
---

Apply the object-metadata write invariant at the write DOORS instead of at the writers.

objectui#7714 ruled that a half-filled relationship stays client-side and the PUT body never
carries one without a non-empty `reference`, and implemented that ruling by naming the two
writers it knew of. objectui#8057 reproduced the identical defect on a third; a sweep found
nine more. The doors — the three places in this repo that actually PUT `/meta/:type/:name` —
now apply the invariant themselves, so every writer is covered without any list of writers
existing anywhere, and a new door is caught by a gate that derives the door set from the
tree rather than restating it.

Behaviour change for consumers: `MetadataClient.save('object', …)` now throws BEFORE issuing
the request when the body carries a relationship field with a missing, empty or whitespace-only
`reference`. The same document is refused by the server with a 422 on `fields.NAME.reference`,
so nothing that previously succeeded now fails — the refusal moves earlier, names the field,
and leaves the draft in the client instead of wedging every later save of that object. Writes
of every other metadata type are untouched.

New export from `@object-ui/data-objectstack`: `assertObjectMetadataWritable`,
`RELATIONSHIP_TYPES_REQUIRING_REFERENCE` and `OBJECT_METADATA_TYPE`.
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,26 @@ jobs:
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:handler-key-reads

# objectui#7714 ruled one client behaviour and its PR implemented that ruling
# by ENUMERATING the writers it knew of. Two. objectui#8057 hit the same
# defect on a third in that card's own required dogfood, and objectui#8676's
# sweep found nine more — and, the half that outlives the count, the sweep
# shape the question is naturally asked in (`client.save(`) CANNOT SEE them:
# it returns zero over the very file objectui#8057 is about, because the call
# is `client.save<any>(type, ...)`. A hand list of writers is stale the next
# time somebody adds one, and nothing says so.
#
# So this step enumerates DOORS, not writers. The writer set is open and
# nobody has to announce a new member; the transport set is closed and this
# repo owns it. It derives every call that PUTs `/meta/:type/:name` —
# resolving the URL through templates, fields and helper returns, because
# the central door says nothing at its own call site — and requires each one
# that can carry an object document to reach the guard. Parses sources with
# `typescript`, so it needs the install and nothing built.
- name: Verify every object-metadata write door applies the write guard
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:metadata-write-doors

# A build tsconfig that excludes tooling by FILE NAME (`*.test.ts`) stops
# the files that happen to be named that way and nothing else. The first
# shared helper added to a `__tests__/` directory is then a program input,
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guide/ci-cd-pipeline.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"check:unreferenced-sources": "node scripts/check-unreferenced-sources.mjs",
"check:doc-example-readers": "node scripts/check-doc-example-shared-reader.mjs",
"check:handler-key-reads": "node scripts/check-handler-key-read-sites.mjs",
"check:metadata-write-doors": "node scripts/check-object-metadata-write-doors.mjs",
"check:changeset-claims": "node scripts/check-changeset-claims.mjs",
"cli": "node packages/cli/dist/cli.js",
"objectui": "node packages/cli/dist/cli.js",
Expand Down
75 changes: 52 additions & 23 deletions packages/app-shell/src/services/MetadataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
*/

import { stripReadDecorations } from '@objectstack/spec/kernel';
import { viewItemObjectName, type ObjectStackAdapter } from '@object-ui/data-objectstack';
import {
assertObjectMetadataWritable,
RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
viewItemObjectName,
type ObjectStackAdapter,
} from '@object-ui/data-objectstack';
import type { ObjectDefinition, DesignerFieldDefinition } from '@object-ui/types';
// The retired-field-key tombstone registry lives at a dedicated internal
// subpath, not the main barrel — objectui#6527 option B (maintainer ruling,
Expand Down Expand Up @@ -187,24 +192,21 @@ function toObjectPayload(obj: ObjectDefinition, fields?: FieldMetadataPayload[])
};
}

/**
* Field types whose `reference` — the target object a relationship links to —
* `@objectstack/spec` requires to be present and non-empty.
*
* Re-measured for objectui#7714 against the 17.3.0 artifact by parsing
* `{ type, label: 'L' }` for every one of `FieldType`'s 49 declared members:
* exactly two are refused at path `reference`, both with code `custom`, and
* the other 47 are not refused at all on that minimal document.
*
* ⛔ Deliberately NOT "parse every field through `FieldSchema` before the PUT".
* That would refuse plugin-registered keys the SERVER accepts — measured on the
* installed 17.2.0, `x_plugin_thing` is `unrecognized_keys` to the schema while
* the server that sent it takes it back — which is the same reason
* {@link RETIRED_FIELD_KEYS} is a named list rather than a schema filter. This
* guard states one invariant; it is not a client-side revalidation of the
* document.
/*
* `RELATIONSHIP_TYPES_REQUIRING_REFERENCE` — the field types whose `reference`
* `@objectstack/spec` requires to be present and non-empty — is imported above
* and no longer declared here.
*
* objectui#8676: it used to be declared here AND word-for-word again in
* `plugin-designer`'s `MetadataFieldsPage`. Two remembered copies of one
* contract fact is the same hazard as a remembered list of writers, so both
* writers now read the single declaration in `@object-ui/data-objectstack`,
* beside the write doors, where a pin DERIVES the set from the installed spec
* on every run instead of restating a measurement. The reasoning that used to
* sit here — including ⛔ why this is one invariant and not a client-side
* revalidation of the document through `FieldSchema` — moved with it, to
* `object-metadata-write-guard.ts`.
*/
const RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ['lookup', 'master_detail'];

/**
* Why THIS value cannot be a target, and what the contract does about it —
Expand Down Expand Up @@ -627,6 +629,35 @@ export class MetadataService {
return [];
}

/**
* The ONE place this class puts metadata on the wire (objectui#8676).
*
* `@objectstack/client`'s `meta.saveItem` is the third of the three in-repo
* transports that can PUT `/meta/:type/:name`, and the only one that lives in
* a package this repo does not own — so the invariant cannot be pushed down
* into it the way it is pushed into `MetadataClient.save`. This method is the
* compensating seam: the SDK door is reached through it and through nothing
* else in this class, so the guard runs once rather than three times, and
* `scripts/check-object-metadata-write-doors.mjs` has one site to judge.
*
* ⚠ The guard here is a BACKSTOP, not the primary refusal for the two
* object-shaped callers. `saveObject` and `saveFields` both build their
* `fields` through {@link toFieldsMap}, which refuses the same half-filled
* relationship EARLIER and with the designer-facing four-state wording those
* writers' pins assert. Nothing here replaces that; this covers the callers
* that do NOT pass through a conversion — `saveMetadataItem`, whose `category`
* is a runtime value and can be `'object'`, and whoever calls it next.
*/
private async putMetadataItem(
category: string,
name: string,
data: Record<string, unknown>,
): Promise<void> {
assertObjectMetadataWritable(category, data, 'MetadataService');
const client = this.adapter.getClient();
await client.meta.saveItem(category, name, data);
}

/**
* Persist a metadata item (upsert) for any category.
*
Expand All @@ -649,8 +680,7 @@ export class MetadataService {
* private copy of "which object is this?".
*/
async saveMetadataItem(category: string, name: string, data: Record<string, unknown>): Promise<void> {
const client = this.adapter.getClient();
await client.meta.saveItem(category, name, data);
await this.putMetadataItem(category, name, data);
this.adapter.invalidateCache(`${category}:${name}`);
if (category === 'view') {
const objectName = viewItemObjectName(data);
Expand Down Expand Up @@ -750,9 +780,8 @@ export class MetadataService {
* its own terms (ADR-0049 shape).
*/
async saveObject(obj: ObjectDefinition, existingFields: FieldMetadataPayload[]): Promise<void> {
const client = this.adapter.getClient();
const payload = toObjectPayload(obj, existingFields);
await client.meta.saveItem('object', obj.name, payload);
await this.putMetadataItem('object', obj.name, payload as unknown as Record<string, unknown>);
this.adapter.invalidateCache(`object:${obj.name}`);
}

Expand Down Expand Up @@ -889,7 +918,7 @@ export class MetadataService {
fields: toFieldsMap(fields.map((field) => toFieldPayload(field, previousFieldEntry(previousFields, field.name)))),
}) as Record<string, unknown>;

await client.meta.saveItem('object', objectName, updatedObject);
await this.putMetadataItem('object', objectName, updatedObject);
this.adapter.invalidateCache(`object:${objectName}`);
}

Expand Down
6 changes: 6 additions & 0 deletions packages/app-shell/src/views/metadata-admin/external/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
*/

import { createAuthenticatedFetch } from '@object-ui/auth';
// objectui#8676 - this module is the SECOND of the three in-repo doors that PUT
// `/meta/:type/:name`, and the one no `client.save(` or `.saveItem(` sweep can
// see: it is a hand-rolled fetch. It writes `object` metadata, so it applies the
// same invariant `MetadataClient.save` applies, from the same module.
import { assertObjectMetadataWritable } from '@object-ui/data-objectstack';
import type {
GenerateDraftOpts,
ObjectDraft,
Expand Down Expand Up @@ -187,6 +192,7 @@ export async function validateDatasource(
* draft's `definition` is the parseable ObjectSchema body.
*/
export async function importObjectDraft(draft: ObjectDraft): Promise<void> {
assertObjectMetadataWritable('object', draft.definition, 'importObjectDraft');
const res = await authFetch(
`${serverBase()}/api/v1/meta/object/${encodeURIComponent(draft.name)}`,
{
Expand Down
48 changes: 48 additions & 0 deletions packages/data-objectstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,54 @@ still succeeds, but non-atomically via the fallback above. Treat the advertised
capability as the floor for the atomicity guarantee, not as a connection
prerequisite.

## Object-Metadata Write Guard

`MetadataClient.save` refuses an `object` document whose `fields` carry a
relationship field (`lookup`, `master_detail`) with a missing, empty or
whitespace-only `reference`, **before** issuing the request:

```ts
import { MetadataClient } from '@object-ui/data-objectstack';

const client = new MetadataClient({ baseUrl: '/api/v1' });

await client.save('object', 'account', {
name: 'account',
fields: { owner: { type: 'lookup', label: 'Owner' } },
});
// throws: MetadataClient.save refused this object metadata write: the field
// `owner` is a `lookup` and carries no `reference` key at all ...
```

Nothing that previously succeeded now fails. `@objectstack/spec` refuses the same
document at the server with a 422 on `fields.owner.reference`, and that refusal
blocks every *later* save of the object for as long as the half-filled field
rides along in the draft. The guard moves the identical refusal earlier, names
the field while it is still on screen, and leaves the draft in the client. Writes
of every other metadata type are untouched, and the guard never strips the
offending field — a dropped field reported as saved would be a silent deletion.

Hosts that write object metadata through their own transport can apply the same
invariant at their own door:

```ts
import { assertObjectMetadataWritable } from '@object-ui/data-objectstack';

async function uploadObject(name: string, body: unknown) {
assertObjectMetadataWritable('object', body, 'uploadObject');
await fetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
```

`RELATIONSHIP_TYPES_REQUIRING_REFERENCE` and `OBJECT_METADATA_TYPE` are exported
beside it. The relationship-type set is derived from the installed
`@objectstack/spec` by this package's own pin, so it follows the contract rather
than a remembered list.

## User-Scoped State Adapter

In addition to the main `DataSource` adapter, this package ships
Expand Down
11 changes: 11 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6629,6 +6629,17 @@ export { MetadataClient, readSaveAdvisories } from './metadata-client';
// `getDraft` that produces the envelope, because the unwrap-and-strip is part
// of that method's contract rather than a detail of any one view.
export { extractDraftBody } from './draft-envelope';
// objectui#8676 - the object-metadata write invariant, exported so the two DOORS
// that do not run through `MetadataClient.save` can apply the same one. It is
// exported for DOORS, not for writers: a writer that calls it by hand is a
// writer that can forget to, which is the enumeration failure this closes.
// `scripts/check-object-metadata-write-doors.mjs` derives the door set and
// fails when a door does not reach this function.
export {
assertObjectMetadataWritable,
RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
OBJECT_METADATA_TYPE,
} from './object-metadata-write-guard';
export type {
RuntimeAuthoringIssue,
MetadataSaveAdvisoryEvent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#8676 — `MetadataClient.save` is a DOOR, and it applies the
* object-metadata write invariant.
*
* ## What this pins that the guard's own suite cannot
*
* The guard's suite proves the function refuses. This proves the DOOR REACHES
* IT — which is the half objectui#7714 lost. That ruling's invariant was
* implemented, correct, and pinned, and it still did not hold for twelve of
* fifteen write call sites, because nothing connected the two facts. So the
* load-bearing assertion here is not "it throws": it is ⭐ **no request was
* issued**. A guard that fires after the bytes leave has not held the draft
* client-side, which is the behaviour the ruling actually names.
*
* ⚠ Every refusal below is measured against a LIT CONTROL on the same harness —
* the same call with a usable target, observed to reach `fetch` and resolve. A
* "no request was issued" assertion is worthless beside a harness that never
* issues one.
*/

import { describe, expect, it, vi } from 'vitest';
import { MetadataClient } from './metadata-client';

function okResponse(): Response {
return new Response(JSON.stringify({ success: true, version: 'v1' }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}

function harness() {
const fetchImpl = vi.fn(async () => okResponse());
const client = new MetadataClient({
baseUrl: 'http://test.local',
fetch: fetchImpl as unknown as typeof fetch,
});
return { client, fetchImpl };
}

const HALF_FILLED = {
name: 'account',
label: 'Account',
fields: {
title: { type: 'text', label: 'Title' },
owner: { type: 'lookup', label: 'Owner' },
},
};

const COMPLETE = {
name: 'account',
label: 'Account',
fields: {
title: { type: 'text', label: 'Title' },
owner: { type: 'lookup', label: 'Owner', reference: 'contact' },
},
};

describe('MetadataClient.save — the door applies the object-metadata write guard', () => {
it('refuses a half-filled relationship AND ISSUES NO REQUEST', async () => {
const { client, fetchImpl } = harness();
await expect(client.save('object', 'account', HALF_FILLED, { mode: 'draft' }))
.rejects.toThrow(/`owner`/);
// ⭐ The discriminating assertion. A guard that ran after the request would
// satisfy the rejection above and fail this line.
expect(fetchImpl).not.toHaveBeenCalled();
});

it('CONTROL — the same harness DOES issue the PUT when the target is usable', async () => {
const { client, fetchImpl } = harness();
await client.save('object', 'account', COMPLETE, { mode: 'draft' });
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toContain('/meta/object/account');
expect(init.method).toBe('PUT');
});

it('CONTROL — a non-object type with the same field shape still reaches the wire', async () => {
// The door serves every metadata type. The guard must not leak into them.
const { client, fetchImpl } = harness();
await client.save('view', 'account_list', HALF_FILLED);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});

it('refuses the ARRAY `fields` shape at the door too, and still issues nothing', async () => {
const { client, fetchImpl } = harness();
const body = { name: 'account', fields: [{ name: 'owner', type: 'lookup', label: 'Owner' }] };
await expect(client.save('object', 'account', body)).rejects.toThrow(/`owner`/);
expect(fetchImpl).not.toHaveBeenCalled();
});

it('the message names the door, so an author sees where the write stopped', async () => {
const { client } = harness();
await expect(client.save('object', 'account', HALF_FILLED))
.rejects.toThrow(/^MetadataClient\.save refused/);
});
});
9 changes: 9 additions & 0 deletions packages/data-objectstack/src/metadata-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
*/

import { GetMetaItemLayeredResponseSchema } from '@objectstack/spec/api';
// objectui#8676 - the object-metadata write invariant, applied HERE because this
// is a DOOR and not a writer. Every `client.save('object', ...)` call site in the
// repo passes through this one method, so guarding it covers a writer set that
// nothing has to enumerate. See that module's docblock for why the writers are
// deliberately not listed anywhere.
import { assertObjectMetadataWritable } from './object-metadata-write-guard';
import type {
GetMetaItemLayeredResponse,
RuntimeAuthoringIssue,
Expand Down Expand Up @@ -944,6 +950,9 @@ export class MetadataClient {
' The PUT /meta/:type/:name route requires a name segment.',
);
}
// objectui#8676 - before the request, so a refused body issues no PUT and the
// half-filled draft stays in the client (objectui#7714's ruled behaviour).
assertObjectMetadataWritable(type, item, 'MetadataClient.save');
const params: string[] = [];
if (options.force) params.push('force=true');
if (options.mode === 'draft') params.push('mode=draft');
Expand Down
Loading
Loading