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
30 changes: 30 additions & 0 deletions .changeset/reconcile-packages-post-and-ui-view-dialect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/rest": minor
"@objectstack/client": patch
---

fix(rest,client)!: reconcile the two REST↔client mismatches the #3587 audit
ledgered (#3610, #3611)

**#3610 — `POST /api/v1/packages` publish-vs-install collision.** The REST
package registrar claimed the bare `POST /packages` for *marketplace publish*
(`{manifest, metadata}`), while the dispatcher packages domain gives the same
verb+path *install* semantics — and REST registers first in the production
stack (first-match-wins), so every `client.packages.install` call landed on
the publish handler and 400'd. Marketplace publish moves to
`POST /api/v1/packages/publish` (breaking for direct callers; a repo-wide and
objectui-wide sweep found zero). The dispatcher's `POST /packages/:id/publish`
(ADR-0033 draft publish) is two segments — different shape, no clash. The
dispatcher already writes both stores on install (`protocol.installPackage`)
and fully uninstalls on DELETE (`protocol.deletePackage`), so the remaining
REST GET/GET/DELETE shadows stay — they are compatible.

**#3611 — UI view dialect split.** `meta.getView` spoke the `?type=` query
dialect that only the dispatcher `/ui` domain understands; the REST surface
mounts only the path form `/ui/view/:object/:type`, so the query form 404'd
wherever REST serves (e.g. project-scoped bases). The client now sends the
path form both surfaces accept; a URL-pinning test keeps it that way.

REST route ledger updated: the two `mismatch` rows are resolved (packages
publish row is `server-only` publisher tooling; the ui row flips to `sdk`).
The ledger now carries zero mismatches.
11 changes: 10 additions & 1 deletion content/docs/api/metadata-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,16 @@ List installed packages.

### `POST /packages`

Publish a package (manifest + metadata) to the package registry.
Install a package from its manifest (SDK: `client.packages.install`). Re-installing an
already-installed `id` returns **409 Conflict** unless `overwrite: true`.

**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, settings?: { ... }, enableOnInstall?: true, overwrite?: false }`
**Response**: `{ package: { id: "plugin-auth", version: "1.0.0", ... }, message?: "..." }`

### `POST /packages/publish`

Publish a package (manifest + metadata) to the package marketplace registry. This is
publisher tooling, not part of the app SDK surface.

**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, metadata: { objects: [...], views: [...], ... } }`
**Response**: `{ success: true, message: "...", package: { id: "plugin-auth", version: "1.0.0" } }`
Expand Down
14 changes: 14 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ describe('ObjectStackClient', () => {
expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta/object/customer', expect.any(Object));
expect(result.name).toBe('customer');
});

it('meta.getView speaks the path-param dialect both surfaces accept (#3611)', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { type: 'list' } });
await client.meta.getView('customer');
// NOT the ?type= query dialect — REST mounts only /ui/view/:object/:type,
// so the query form 404s wherever REST is the serving surface.
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/ui/view/customer/list',
);
await client.meta.getView('customer', 'form');
expect(String(fetchMock.mock.calls[1][0])).toBe(
'http://localhost:3000/api/v1/ui/view/customer/form',
);
});
});

describe('Approvals namespace (ADR-0019)', () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,14 @@ export class ObjectStackClient {

getView: async (object: string, type: 'list' | 'form' = 'list') => {
const route = this.getRoute('ui');
const res = await this.fetch(`${this.baseUrl}${route}/view/${object}?type=${type}`);
// Path-param dialect (#3611): both surfaces accept it — the dispatcher
// /ui domain takes /view/:object/:type?, and the REST server mounts
// ONLY /ui/view/:object/:type. The old ?type= query dialect matched
// nothing on REST, so it 404'd wherever REST is the serving surface
// (e.g. project-scoped bases).
const res = await this.fetch(
`${this.baseUrl}${route}/view/${encodeURIComponent(object)}/${type}`,
);
return this.unwrapResponse(res);
},

Expand Down
14 changes: 11 additions & 3 deletions packages/rest/src/package-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,18 @@ export interface PackageRoutesOptions {
*
* Provides endpoints for publishing, retrieving, and managing packages.
* Routes:
* - POST /api/v1/packages - Publish a package
* - POST /api/v1/packages/publish - Publish a package to the marketplace registry
* - GET /api/v1/packages - List all packages (merges registry + database)
* - GET /api/v1/packages/:id - Get a specific package
* - DELETE /api/v1/packages/:id - Delete a package
*
* Marketplace publish lives at `/packages/publish`, NOT at the bare
* `POST /packages` (#3610): that verb+path is the dispatcher packages
* domain's *install* route, and this registrar registers first in the
* production stack (first-match-wins), so claiming it here silently
* swallowed every `client.packages.install` call with a 400. The
* dispatcher's own `POST /packages/:id/publish` (ADR-0033 draft publish)
* is two segments — different shape, no clash.
*/
export function registerPackageRoutes(
server: IHttpServer,
Expand All @@ -45,8 +53,8 @@ export function registerPackageRoutes(
) {
const packagesPath = `${basePath}/packages`;

// POST /api/v1/packages - Publish a package
server.post(packagesPath, async (req, res) => {
// POST /api/v1/packages/publish - Publish a package to the marketplace
server.post(`${packagesPath}/publish`, async (req, res) => {
try {
const { manifest, metadata } = req.body || {};

Expand Down
8 changes: 4 additions & 4 deletions packages/rest/src/rest-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
note: 'compound-name save; same encoding barrier as the compound read' },

// ── ui ────────────────────────────────────────────────────────────────────
{ route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'mismatch',
note: 'path-param dialect; meta.getView sends /ui/view/:object?type= (query dialect), which this pattern cannot matchthe dispatcher /ui domain answers it instead. Reconcile shapes or retire one dialect.' },
{ route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView',
note: 'was mismatch — meta.getView spoke the ?type= query dialect this pattern cannot match; since #3611 the client sends the path form both surfaces accept' },

// ── data CRUD ─────────────────────────────────────────────────────────────
{ route: 'GET /api/v1/data/:object', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.find' },
Expand Down Expand Up @@ -232,8 +232,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
{ route: 'POST /api/v1/data/:object/deleteMany', family: 'batch', source: 'route-manager', disposition: 'sdk', client: 'data.deleteMany' },

// ── packages (direct-mount registrar, service-gated) ──────────────────────
{ route: 'POST /api/v1/packages', family: 'packages', source: 'direct-mount', disposition: 'mismatch',
note: 'REST handler is registry publish ({manifest, metadata}); the dispatcher /packages domain gives POST /packages install semantics with a different body. REST registers first, so packages.install traffic lands here — reconcile before relying on either.' },
{ route: 'POST /api/v1/packages/publish', family: 'packages', source: 'direct-mount', disposition: 'server-only',
note: 'marketplace registry publish ({manifest, metadata}) — publisher tooling, not app-SDK surface. Moved off the bare POST /packages in #3610: that verb+path is the dispatcher install route, and REST registering it first swallowed every packages.install call with a 400.' },
{ route: 'GET /api/v1/packages', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.list',
note: 'shadows the dispatcher twin (registered first); merges registry + database packages' },
{ route: 'GET /api/v1/packages/:id', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.get',
Expand Down
Loading