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
57 changes: 57 additions & 0 deletions .changeset/retire-compound-name-metadata-addressing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/rest': minor
'@objectstack/runtime': minor
'@objectstack/client': minor
---

Retire compound-name metadata addressing (`/meta/:type/:section/:name`)

Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names.
Stage 1 declared the item-name grammar and refuses every slash-bearing name at
the publish door, so the routes removed here addressed only names that can no
longer be created.

**BREAKING — three public REST routes stop answering:**

| stops answering | use instead |
| :-- | :-- |
| `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` |
| `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` |
| `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` |

Each retired route folded its `:section` and `:name` segments back into one
slash-bearing key (`views/all_leads`) that the protocol layer then treated as a
single opaque string — the section half was never stored, filtered or
enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`.

The `@objectstack/runtime` dispatcher stops folding in the same way: its
`/meta` handler requires exactly two path segments for an item and three for
`…/published`, instead of re-joining every trailing segment. A `/meta` path
that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than
falling through to the adapter's anonymous 404.

**FROM → TO for callers.** Address every item through the single-segment route
and percent-encode the name:

```
GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads
```

`@objectstack/client` now calls `encodeURIComponent` on every `/meta` item
address, so SDK callers need no change: the SDK already sends the new spelling.
Encoding is a **no-op** for every name the item-name grammar admits (lowercase
snake_case segments, optionally dot-qualified), so the bytes on the wire are
unchanged for every name that can be written today.

A pre-grammar **residue** row whose stored name contains a slash remains
readable, writable and deletable: `%2F` matches the single-segment pattern and
the parameter is decoded back to the stored spelling before the handler runs.
Nothing that could be stored has become unaddressable.

Two SDK doc comments that promised "compound names pass through unencoded"
(`meta.getPublished`, `meta.publishItem`) are corrected, and the
`SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored
at the compound door and published live — is closed at the source: there is one
door, and it reads every member of the options bag.

<!-- adr-0087: not-required (already-registered metadata-item-name-grammar-enforced) the stage-1 semantic entry already names this exact surface — "the compound `:type/:section/:name` fold" — and carries the re-authoring prescription (dot-qualified, or flattened with an underscore). This stage removes the routes that fold; it adds no new authorable shape and no second migration prescription. -->
60 changes: 43 additions & 17 deletions packages/client/src/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -321,20 +321,46 @@ describe('ObjectStackClient', () => {
);
});

it('meta.getItem/saveItem pass compound names through unencoded (reaches /meta/:type/:section/:name)', async () => {
it('[#12195] meta.getItem/saveItem ENCODE the name — one spelling, one door', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { name: 'views/all_leads' } });
// ⚠️ This pin is INVERTED from what it was, and the inversion is the
// card. It used to require the slash to survive UNENCODED, so the
// request would reach the compound handler
// `/meta/:type/:section/:name` instead of collapsing onto the
// two-segment route. #12176 retired compound-name addressing: that
// handler is gone, so `%2F` is now the correct and only spelling.
//
// Encoding is a no-op for every name #12194's grammar admits (snake
// case, optionally dot-qualified), so this changes nothing a legal
// caller sends. What it changes is a pre-grammar residue name: it now
// reaches the surviving door with its slash intact as `%2F`, which Hono
// decodes back to `views/all_leads` — the capability that used to
// require a second route.
await client.meta.getItem('object', 'views/all_leads');
// The slash must survive: %2F would collapse the request onto the
// 3-segment /meta/:type/:name route and miss the compound handler.
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/meta/object/views/all_leads',
'http://localhost:3000/api/v1/meta/object/views%2Fall_leads',
);
await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' });
expect(String(fetchMock.mock.calls[1][0])).toBe(
'http://localhost:3000/api/v1/meta/object/views/all_leads',
'http://localhost:3000/api/v1/meta/object/views%2Fall_leads',
);
expect(fetchMock.mock.calls[1][1].method).toBe('PUT');
});

it('[#12195] a LEGAL name is byte-identical before and after the encoding change', async () => {
// The other half: unifying on `encodeURIComponent` must not have moved
// the wire for any name a caller can actually write. Dotted and flat
// snake_case both pass through untouched.
const { client, fetchMock } = createMockClient({ success: true, data: {} });
await client.meta.getItem('object', 'crm_lead');
await client.meta.getItem('view', 'crm_lead.pipeline');
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/meta/object/crm_lead',
);
expect(String(fetchMock.mock.calls[1][0])).toBe(
'http://localhost:3000/api/v1/meta/view/crm_lead.pipeline',
);
});
});

describe('Reports namespace (#3587 gap closure)', () => {
Expand DownExpand Up@@ -2662,14 +2688,15 @@ describe('[#11391] meta.saveItem query string (unscoped client)', () => {
);
});

it('a compound name keeps its unencoded slash AND gets the query string', async () => {
it('[#12195] a slash-bearing name is ENCODED and still gets the query string', async () => {
const { client, fetchMock } = createMockClient({ success: true });
await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' }, { force: true });
// The slash must still survive (%2F would collapse this onto the
// 3-segment route and miss `PUT /meta/:type/:section/:name`), and the
// compound door reads `?force` too since #11095.
// Inverted by #12195: the slash used to be required to survive raw so
// the request reached `PUT /meta/:type/:section/:name`, which had read
// `?force` since #11095. That door is retired; `%2F` reaches the
// surviving door, which has always read `?force`.
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/meta/object/views/all_leads?force=true',
'http://localhost:3000/api/v1/meta/object/views%2Fall_leads?force=true',
);
});
});
Expand DownExpand Up@@ -2878,16 +2905,15 @@ describe('[#11713] meta.saveItem sends the If-Match header (unscoped client)', (
expect(JSON.parse(init.body)).toEqual({ name: 'customer' });
});

it('OCC-guards a COMPOUND name too — unlike `mode`, this reaches both doors', async () => {
it('[#12195] OCC-guards a slash-bearing name too, at the one surviving door', async () => {
const { client, fetchMock } = createMockClient({ success: true });
await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' }, { ifMatch: OCC_TOKEN });
// The compound-name door `PUT /meta/:type/:section/:name` reads
// `if-match` and strips ETag quotes exactly as the single-segment door
// does — measured in rest-server.ts. `mode` is the member that does NOT
// reach it; this one does, so the slash must survive AND the pin must
// ride along.
// This case used to say "unlike `mode`, this reaches BOTH doors" — the
// compound door read `if-match` while never reading `mode`. There is
// one door now, so every member of the options bag reaches it and the
// per-member carve-out is gone. The name is encoded like every other.
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/meta/object/views/all_leads',
'http://localhost:3000/api/v1/meta/object/views%2Fall_leads',
);
expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN);
});
Expand Down
87 changes: 50 additions & 37 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -601,11 +601,11 @@ export interface SaveMetaItemOptions {
* empty spelling would pin the write against the empty string and refuse
* every save with a 409 the caller never asked for.
*
* ✅ REACHES BOTH DOORS — unlike `mode` below. The compound-name twin
* `PUT /meta/:type/:section/:name` reads `if-match` and strips ETag-style
* quotes exactly as the single-segment door does, so
* `saveItem('object', 'views/all_leads', item, { ifMatch })` is
* OCC-guarded like any other save.
* [#12195] There is ONE door now. The compound-name twin
* `PUT /meta/:type/:section/:name` — which this note used to pair with —
* is retired, and every name reaches `PUT /meta/:type/:name`
* percent-encoded, so `if-match` behaviour no longer varies by how the
* name is spelled.
*
* Same member name, same header, same truthy guard as the sibling
* first-party `@object-ui/data-objectstack` `MetadataClient.save`, whose
Expand DownExpand Up@@ -645,21 +645,21 @@ export interface SaveMetaItemOptions {
* on the wire that the server ignores. Same shape the first-party
* `@object-ui/data-objectstack` `MetadataClient.save` already uses.
*
* ⚠️ COMPOUND NAMES DO NOT STAGE. `mode` reaches only the single-segment
* `PUT /meta/:type/:name`. Its compound-name twin
* `PUT /meta/:type/:section/:name` — the door a `name` containing a slash
* lands on, e.g. `saveItem('object', 'views/all_leads', item)` — never
* reads this parameter, so `{ mode: 'draft' }` there is IGNORED and the
* write is PUBLISHED LIVE, answered 200. It is not refused; there is no
* signal at the call site. Filed as objectstack#11712 and deliberately not
* repaired from this side: threading it is the route's decision, and a
* client-side guess would be a second place the two doors disagree.
* [#12195] REACHES EVERY SAVE — the carve-out this note used to carry is
* GONE, and it is worth recording why rather than deleting it silently.
*
* ⛔ Do not "fix" this by rejecting compound names here. `force` and
* `packageId` DO reach both doors (measured: the compound handler reads
* and threads `?force` since objectstack#11095 and `?package` alongside
* it), so refusing the whole bag on a compound name would break the two
* parameters that work in order to warn about the one that does not.
* `mode` used to reach only the single-segment `PUT /meta/:type/:name`.
* A `name` containing a slash landed on the compound-name twin
* `PUT /meta/:type/:section/:name`, which never read this parameter — so
* `{ mode: 'draft' }` there was IGNORED and the write was PUBLISHED LIVE,
* answered 200, with no signal at the call site (objectstack#11712).
*
* Two changes closed it at the source rather than from this side. Stage 1
* (#12194) made a slash-bearing name unwritable at all, and this stage
* retired the twin and unified this file on `encodeURIComponent`, so every
* save now arrives at the one door that reads `mode`. A name that would
* once have forked to the silent-publish door is now refused `400
* INVALID_REQUEST` by the grammar — loud, at the door, before any write.
*/
mode?: 'draft' | 'publish';
}
Expand DownExpand Up@@ -911,7 +911,7 @@ export class ObjectStackClient {
const params = new URLSearchParams();
if (options?.packageId) params.set('package', options.packageId);
const qs = params.toString();
const url = `${this.baseUrl}${route}/${type}/${name}${qs ? `?${qs}` : ''}`;
const url = `${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs ? `?${qs}` : ''}`;
const res = await this.fetch(url);
return this.unwrapResponse<GetMetaItemResponse>(res);
},
Expand DownExpand Up@@ -953,10 +953,16 @@ export class ObjectStackClient {
// omits the `headers` key altogether, so a save without `ifMatch`
// hands `fetch` the same `init` it always did.
const headers = metaSaveHeaders(options);
// `type`/`name` stay UNENCODED — a compound name's slash must survive
// so the request reaches `PUT /meta/:type/:section/:name` instead of
// collapsing onto the 3-segment route (pinned in client.test.ts).
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}${query}`, {
// [#12195] ENCODED, like every other `/meta` item address in this file.
// This site used to leave `type`/`name` RAW so a compound name's slash
// would survive into a separate path segment and reach
// `PUT /meta/:type/:section/:name`. That door is retired, and encoding
// is now the single spelling: a legal name (#12194's grammar — snake
// case, optionally dot-qualified) contains nothing `encodeURIComponent`
// alters, so this is byte-identical for every name that can be written,
// and a pre-grammar residue name reaches the single-segment door with
// its slash intact as `%2F` instead of forking the request.
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${query}`, {
method: 'PUT',
body: JSON.stringify(item),
...(headers ? { headers } : {}),
Expand DownExpand Up@@ -1090,13 +1096,19 @@ export class ObjectStackClient {
*/

/**
* ADR-0033: the published version of a metadata item. Compound names are
* passed through unencoded (e.g. `getPublished('lead', 'views/all_leads')`),
* matching how `getItem` addresses sub-resources.
* ADR-0033: the published version of a metadata item.
*
* [#12195] The name is percent-encoded, like every other `/meta` item
* address in this file. This docblock used to promise the opposite — that
* a compound name passed through UNENCODED, `getPublished('lead',
* 'views/all_leads')`, so its slash would reach the compound arity
* `GET /meta/:type/:section/:name/published`. That arity is retired and a
* slash-bearing name is refused at the publish door (#12194), so there is
* one spelling and one door.
*/
getPublished: async (type: string, name: string) => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/published`);
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/published`);
return this.unwrapResponse<any>(res);
},

Expand DownExpand Up@@ -1177,7 +1189,7 @@ export class ObjectStackClient {
*/
getReferences: async (type: string, name: string) => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/references`);
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`);
return this.unwrapResponse<any>(res);
},

Expand All@@ -1200,15 +1212,16 @@ export class ObjectStackClient {
getAudit: async (type: string, name: string, opts?: { limit?: number }) => {
const route = this.getRoute('metadata');
const qs = opts?.limit !== undefined ? `?limit=${opts.limit}` : '';
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/audit${qs}`);
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs}`);
return this.unwrapResponse<any>(res);
},

/**
* ADR-0033: promote a single item's pending draft overlay to live —
* the per-item flow beside `packages.publishDrafts`' package-scoped one.
* 404 [no_draft] when there is nothing to publish. Compound names pass
* through unencoded, like `getItem`.
* 404 [no_draft] when there is nothing to publish. [#12195] The name is
* percent-encoded, like `getItem` — this line used to promise unencoded
* pass-through for compound names, whose arity is now retired.
*
* The resolved `version` is the ADR-0008 optimistic-concurrency token, the
* same carrier `saveItem` returns and with the same job: pass it back as
Expand All@@ -1229,7 +1242,7 @@ export class ObjectStackClient {
opts?: { message?: string },
): Promise<PublishMetaItemResponse> => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/publish`, {
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`, {
method: 'POST',
body: JSON.stringify(opts?.message ? { message: opts.message } : {}),
});
Expand All@@ -1241,7 +1254,7 @@ export class ObjectStackClient {
*/
rollbackItem: async (type: string, name: string, toVersion: number, opts?: { message?: string }) => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/rollback`, {
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`, {
method: 'POST',
body: JSON.stringify({ toVersion, ...(opts?.message ? { message: opts.message } : {}) }),
});
Expand All@@ -1258,7 +1271,7 @@ export class ObjectStackClient {
if (opts?.from !== undefined) params.set('from', String(opts.from));
if (opts?.to !== undefined) params.set('to', String(opts.to));
const qs = params.toString();
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/diff${qs ? `?${qs}` : ''}`);
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ''}`);
return this.unwrapResponse<any>(res);
}
};
Expand DownExpand Up@@ -5581,7 +5594,7 @@ export class ScopedProjectClient {
const params = new URLSearchParams();
if (options?.packageId) params.set('package', options.packageId);
const qs = params.toString();
const res = await this.parent._fetch(this.url(`/meta/${type}/${name}${qs ? `?${qs}` : ''}`));
const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs ? `?${qs}` : ''}`));
return this.parent._unwrap<GetMetaItemResponse>(res);
},
/**
Expand All@@ -5608,7 +5621,7 @@ export class ScopedProjectClient {
// Header half of the same bag, through the same one builder the twin
// calls — see {@link metaSaveHeaders}.
const headers = metaSaveHeaders(options);
const res = await this.parent._fetch(this.url(`/meta/${type}/${name}${query}`), {
const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${query}`), {
method: 'PUT',
body: JSON.stringify(item),
...(headers ? { headers } : {}),
Expand Down
Loading
Loading