From 9d28d74ab7100f9832d9be3766ad3c91b318d029 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:30:37 +0000 Subject: [PATCH 1/4] wip: deleteItem carriers --- packages/client/src/index.ts | 186 +++++++++++++++++++++- packages/client/src/zz-probe-door.test.ts | 51 ++++++ 2 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/zz-probe-door.test.ts diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4b978eef5c..99326295c5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -740,6 +740,133 @@ function metaSaveHeaders(options?: SaveMetaItemOptions): Record return { 'If-Match': String(options.ifMatch) }; } +/** + * Request options for `meta.deleteItem` — the carriers the REST reset door + * reads, made reachable from the SDK (#12181). + * + * `DELETE /meta/:type/:name` ("reset metadata item to artifact default") + * reads THREE carriers. This bag declares TWO of them, and the third's + * absence is a ruling rather than an oversight: + * + * - {@link DeleteMetaItemOptions.ifMatch} — the ADR-0008 OCC pin. The door + * already reads `If-Match` and threads it as `parentVersion`, and + * `DeleteMetaItemRequest.parentVersion` names that header in the spec text + * itself. Without an argument for it, every SDK reset was last-write-wins + * on the one verb whose whole job is destroying an overlay row. + * - {@link DeleteMetaItemOptions.state} — `?state=draft`, the NARROWER + * reset: discard the pending draft and leave the published overlay + * serving. Its absence did not make the SDK safer, it made the SDK's only + * reachable reset the full one. + * + * ⛔ `?dropStorage=true` is deliberately NOT a member, and adding it "for + * completeness" reverses a decision. It is the one carrier of the three that + * ADDS destructive reach — it drops the object's physical table after the + * metadata row goes — no caller was measured needing it from this client, + * and the door's repeated-parameter refusal exists because of that + * destructiveness. Maintainer-seat ruling on #12181: a destructive surface + * with no measured pull is not published. A caller that needs it is a + * separate, separately reviewable widening. + * + * Same bag on BOTH `deleteItem` declarations — the unscoped client and the + * environment-scoped twin — for the reason #7019 states: the scoped mount is + * not a second implementation, it is one `registerForBase` call replayed + * against `/environments/:environmentId`, so a bag on one client only would + * be a fresh divergence, not half a fix. + */ +export interface DeleteMetaItemOptions { + /** + * `If-Match: ` — the ADR-0008 optimistic-concurrency pin, and + * the one member here that is NOT a query parameter. + * + * Echo back the `version` a previous `saveItem` (or `publishItem`) + * resolved, and a concurrent edit is refused with `409 + * metadata_conflict` instead of silently resetting the row the other + * author just wrote. Omit for the last-write-wins behaviour every reset + * had before this member existed — the pin is opt-in on the door too + * (Studio's "Reset" button is deliberately unpinned). + * + * Opaque: echo it verbatim, never parse it. + * + * Only a non-empty token reaches the wire. `undefined` and `''` both OMIT + * the header rather than sending an empty `If-Match`: the door reads the + * header's PRESENCE as "pin this reset", so an empty spelling would pin + * against the empty string and refuse a reset the caller never asked to + * pin. + * + * Same member name, same header, same truthy guard as the sibling + * first-party `@object-ui/data-objectstack` `MetadataClient.reset`, and + * as {@link SaveMetaItemOptions.ifMatch} on this same client. + */ + ifMatch?: string; + /** + * `?state=draft` — discard ONLY the pending draft overlay, leaving the + * still-active overlay serving. + * + * `'active'` is the explicit spelling of the default and deliberately + * sends NOTHING: the door acts on `state=draft` alone and treats every + * other value as active, so emitting `?state=active` would put a value on + * the wire the server ignores. Same shape as + * {@link SaveMetaItemOptions.mode}, and the same vocabulary the spec + * declares (`DeleteMetaItemRequest.state`: `'active' | 'draft'`). + * + * This is the LESS destructive reset, not a new destructive one: without + * it the only reachable reset is the full one, which drops the published + * overlay too. + */ + state?: 'active' | 'draft'; +} + +/** + * Compose the `meta.deleteItem` query string — the ONE builder both + * `deleteItem` declarations call, for the same reason + * {@link DeleteMetaItemOptions} is one type: the twins must not be able to + * drift in what they put on the wire. + * + * Returns `''` (not `'?'`) when nothing is set, so an options-less call is + * BYTE-IDENTICAL to what this method sent before the bag existed. + * + * `URLSearchParams.set` (never `append`) is load-bearing: the door REFUSES a + * repeated `state` (#6877), so a builder that could emit the key twice would + * turn a caller's option into a 400. + */ +function metaDeleteQuery(options?: DeleteMetaItemOptions): string { + if (!options) return ''; + const params = new URLSearchParams(); + // Only `'draft'` is actionable server-side; `'active'` is the default + // said out loud and sends nothing. + if (options.state === 'draft') params.set('state', 'draft'); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +/** + * Compose the `meta.deleteItem` request headers — the ONE builder both + * `deleteItem` declarations call, for the same reason {@link metaDeleteQuery} + * is one function. + * + * Returns `undefined` (never `{}`) when nothing is set, so the call site can + * omit the `headers` key entirely and an options-less reset stays + * BYTE-IDENTICAL to what it sent before this existed. + * + * ⛔ `ifMatch` must never be added to {@link metaDeleteQuery}. It is a + * header; the reset door reads no `?ifMatch=` parameter, so a query spelling + * would look set at the call site and protect nothing. + * + * Deliberately a sibling of {@link metaSaveHeaders} rather than a call into + * it: the two methods carry two separately-ruled option bags (#11713 for + * `saveItem`, #12181 for this one), so neither type may quietly acquire the + * other's members. The two builders are pinned IN STEP by a test instead — + * one token in, identical header bytes out. + */ +function metaDeleteHeaders(options?: DeleteMetaItemOptions): Record | undefined { + if (!options?.ifMatch) return undefined; + // Verbatim and UNQUOTED — the door tolerates ETag-style quotes by + // stripping them, but the token a save resolves carries none, and + // wrapping it here would put bytes on the wire the sibling first-party + // client does not send. + return { 'If-Match': String(options.ifMatch) }; +} + export class ObjectStackClient { private baseUrl: string; private token?: string; @@ -997,14 +1124,39 @@ export class ObjectStackClient { }, /** - * Delete a metadata item + * Delete a metadata item — reset it to its artifact default by removing + * the ADR-0005 customization overlay row. + * * @param type - Metadata type (e.g., 'object', 'plugin') * @param name - Item name (snake_case identifier) - */ - deleteItem: async (type: string, name: string): Promise<{ type: string; name: string; deleted: boolean }> => { + * @param options - {@link DeleteMetaItemOptions}: the ADR-0008 OCC pin + * (`ifMatch`) and the narrower draft-only discard (`state: 'draft'`). + * + * PIN THE RESET. This verb destroys a row: unpinned, a reset issued + * against a version somebody else has already replaced silently destroys + * their edit and answers 200. Echo the `version` a previous `saveItem` + * resolved as `options.ifMatch` and the same situation answers `409 + * metadata_conflict` instead — the door has always read the header + * (`DeleteMetaItemRequest.parentVersion` describes it), this client just + * had no argument for it until #12181. + */ + deleteItem: async ( + type: string, + name: string, + options?: DeleteMetaItemOptions, + ): Promise<{ type: string; name: string; deleted: boolean }> => { const route = this.getRoute('metadata'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`, { + // `query`, not `qs` — it carries its own `?`; see `saveItem`'s note on + // the three meanings `qs` holds in this file. + const query = metaDeleteQuery(options); + // The OCC token rides a HEADER, not the query string — see + // {@link metaDeleteHeaders}. `undefined` when unset, and the spread + // then omits the `headers` key altogether, so an unpinned reset hands + // `fetch` the same `init` it always did. + const headers = metaDeleteHeaders(options); + const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${query}`, { method: 'DELETE', + ...(headers ? { headers } : {}), }); return this.unwrapResponse(res); }, @@ -5729,9 +5881,31 @@ export class ScopedEnvironmentClient { }); return this.parent._unwrap(res); }, - deleteItem: async (type: string, name: string): Promise<{ type: string; name: string; deleted: boolean }> => { - const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}`), { + /** + * Reset a metadata item to its artifact default, scoped to this + * environment. + * + * `options` is the SAME {@link DeleteMetaItemOptions} bag the unscoped + * twin takes, and reaches the SAME handler: the scoped mount is not a + * second implementation, it is one `registerForBase` call replayed + * against `/environments/:environmentId` (see `RestServer`), so this door + * reads `?state=` — and the `If-Match` header — byte-identically. A bag + * on only one of the two clients would be a fresh divergence of the kind + * #7019 rules against, not half a fix. + */ + deleteItem: async ( + type: string, + name: string, + options?: DeleteMetaItemOptions, + ): Promise<{ type: string; name: string; deleted: boolean }> => { + // `query`, not `qs` — it carries its own `?`; see the unscoped twin. + const query = metaDeleteQuery(options); + // Header half of the same bag, through the same one builder the twin + // calls — see {@link metaDeleteHeaders}. + const headers = metaDeleteHeaders(options); + const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${query}`), { method: 'DELETE', + ...(headers ? { headers } : {}), }); return this.parent._unwrap(res); }, diff --git a/packages/client/src/zz-probe-door.test.ts b/packages/client/src/zz-probe-door.test.ts new file mode 100644 index 0000000000..0cbb5aedac --- /dev/null +++ b/packages/client/src/zz-probe-door.test.ts @@ -0,0 +1,51 @@ +// TEMPORARY PROBE — delete before commit. +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/runtime'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +describe('probe: real /meta write door', () => { + let baseUrl: string; + let kernel: LiteKernel; + + beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(new ObjectQLPlugin()); + kernel.use({ + metadata: { name: 'test-auth', version: '1.0.0' }, + async init(ctx: any) { + ctx.registerService('auth', { + api: { getSession: async () => ({ user: { id: 'test-user' } }) }, + }); + }, + } as any); + kernel.use(new HonoServerPlugin({ port: 0 })); + kernel.use(createRestApiPlugin({ api: { api: { requireAuth: false } as any } })); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + ql.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); + const httpServer = kernel.getService('http.server'); + baseUrl = `http://localhost:${httpServer.getPort!()}`; + // eslint-disable-next-line no-console + console.log('PROBE baseUrl', baseUrl); + }, 60_000); + + afterAll(async () => { + if (kernel) await Promise.race([kernel.shutdown(), new Promise((r) => setTimeout(r, 10_000))]); + }, 30_000); + + it('reports what a PUT and DELETE answer', async () => { + const put = await fetch(`${baseUrl}/api/v1/meta/view/probe_view`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'probe_view', label: 'Probe', object: 'task' }), + }); + const putBody = (await put.text()).slice(0, 400); + const del = await fetch(`${baseUrl}/api/v1/meta/view/probe_view`, { method: 'DELETE' }); + const delBody = (await del.text()).slice(0, 400); + expect({ put: put.status, putBody, del: del.status, delBody }).toEqual('SHOW-ME'); + }, 60_000); +}); From f2709a21403b06014480ff4f28c195599c269712 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:37:03 +0000 Subject: [PATCH 2/4] feat(client): meta.deleteItem sends If-Match and ?state=draft on both declarations --- packages/client/package.json | 2 + .../src/meta-delete-item-carriers.test.ts | 607 ++++++++++++++++++ packages/client/src/zz-probe-door.test.ts | 51 -- pnpm-lock.yaml | 6 + 4 files changed, 615 insertions(+), 51 deletions(-) create mode 100644 packages/client/src/meta-delete-item-carriers.test.ts delete mode 100644 packages/client/src/zz-probe-door.test.ts diff --git a/packages/client/package.json b/packages/client/package.json index 86b6f93a93..5764b160cd 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -29,6 +29,8 @@ "@hono/node-server": "^2.1.1", "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/hono": "workspace:*", + "@objectstack/metadata-core": "workspace:*", + "@objectstack/metadata-protocol": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/runtime": "workspace:*", diff --git a/packages/client/src/meta-delete-item-carriers.test.ts b/packages/client/src/meta-delete-item-carriers.test.ts new file mode 100644 index 0000000000..f9c044d982 --- /dev/null +++ b/packages/client/src/meta-delete-item-carriers.test.ts @@ -0,0 +1,607 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12181] `meta.deleteItem` sends the carriers the REST reset door reads — + * the `If-Match` OCC pin and `?state=draft` — on BOTH declarations. + * + * ## The defect + * + * `DELETE /meta/:type/:name` ("reset metadata item to artifact default") + * reads three carriers off the request: the `If-Match` header (threaded into + * the protocol call as `parentVersion`), `?state=`, and `?dropStorage=`. Both + * `deleteItem` declarations on this client took exactly `(type, name)` — no + * options bag, nothing that became a header — so a first-party SDK caller + * could reach none of them. The sharpest consequence is the first: on the one + * verb whose whole job is destroying an overlay row, a concurrent edit was + * silently destroyed instead of answering 409, and + * `DeleteMetaItemRequest.parentVersion` describes that pin in the spec text + * itself. + * + * ## What this card ships, and what it deliberately does NOT + * + * Two of the three carriers, per the 2026-08-28 dispatch ruling: + * + * - `ifMatch` — a pure data-protection gap; the door already reads it and + * the sibling first-party client (`@object-ui/data-objectstack` + * `MetadataClient.reset`) already sends it. + * - `state: 'draft'` — makes the NARROWER reset reachable. Its absence did + * not make the SDK safer; it forced every caller onto the full reset, + * which drops the published overlay too. + * + * ⛔ `?dropStorage` is WITHHELD on purpose — the one carrier that ADDS + * destructive reach, with no measured caller. Its absence is pinned below + * (`the withheld third carrier`) so a later "completeness" patch has to argue + * with a test rather than with a comment. + * + * ## ⚠️ The instrument trap this file is shaped around + * + * The two declarations are TEXTUALLY IDENTICAL — `deleteItem: async (type: + * string, name: string)` appeared twice in one file. So a global count is not + * evidence: 2 → 1 is equally consistent with "half the fix landed". Every + * claim here is therefore made TWICE, once per client — the unscoped + * `ObjectStackClient.meta` and the environment-scoped + * `ScopedEnvironmentClient.meta` twin — and the `IN STEP` cases compare the + * two against each other rather than restating a literal. + * + * ## Why the second half of this file boots a real door + * + * A mock-fetch pin can only show what the client PUT ON THE WIRE. The card's + * claim is about what the door does with it — that the same stale reset is a + * silent 200 unpinned and a 409 pinned. So the reproduction drives the REAL + * registered route handler (`RestServer`), the REAL + * `ObjectStackProtocolImplementation`, and REAL `sys_metadata*` tables on a + * real SQLite engine. The only stub is the auth boundary + * (`resolveExecCtx` — "better-auth says this bearer holds + * `manage_metadata`"), which is the same seam every neighbouring `/meta` door + * test stubs (`packages/rest/src/meta-write-actor-identity.test.ts`), and the + * transport, which is a bridge from the client's `fetch` into the handler + * rather than a socket. Everything the card is about — the header read at + * `rest-server.ts`, `refuseRepeatedQueryParams`, the `?state` parse, the + * threading into `deleteMetaItem`, and the repository's parent-version check + * — is real code running here. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +// The REAL stores the protocol writes to — not a mirror. +import { + SysMetadataObject, + SysMetadataHistoryObject, + SysMetadataAuditObject, +} from '@objectstack/metadata-core'; +import { RestServer } from '@objectstack/runtime'; +import { ObjectStackClient } from './index'; + +// --------------------------------------------------------------------------- +// Part 1 — what the CLIENT puts on the wire (both declarations) +// --------------------------------------------------------------------------- + +const OCC_TOKEN = 'sha256:' + 'c'.repeat(64); + +function createMockClient(body: any, status = 200) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: async () => body, + headers: new Headers(), + }); + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }); + return { client, fetchMock }; +} + +/** Pull the headers object the client handed `fetch` on its Nth call. */ +function headersOfCall(fetchMock: ReturnType, i = 0): Record { + return (fetchMock.mock.calls[i]?.[1]?.headers ?? {}) as Record; +} + +/** + * The header NAMES that call put on the wire, sorted. + * + * The byte-identity claim has to be spelled this way rather than as "the + * `init` has no `headers` key": the client's private `fetch` always hands the + * mock a merged header object. `metaDeleteHeaders` returning `undefined` is + * what keeps that merge byte-identical to an unpinned reset. + */ +function headerNamesOf(fetchMock: ReturnType, i = 0): string[] { + return Object.keys(headersOfCall(fetchMock, i)).sort(); +} + +/** What an un-pinned reset sends on this bare mock client. */ +const BASELINE_HEADERS = ['Content-Type']; + +const RESET_OK = { success: true, reset: true, message: 'Customization overlay deleted' }; + +describe('[#12181] meta.deleteItem carriers — UNSCOPED ObjectStackClient.meta', () => { + it('sends `ifMatch` as the If-Match request header, verbatim', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'shared_grid', { ifMatch: OCC_TOKEN }); + // THE assertion this card exists for, on declaration #1. + expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN); + // Verbatim and unquoted — the door strips ETag quotes rather than + // requiring them, and the sibling first-party client sends none. + expect(headersOfCall(fetchMock)['If-Match']).not.toContain('"'); + expect(fetchMock.mock.calls[0][1].method).toBe('DELETE'); + }); + + it('ABSENT when the caller does not pin: no If-Match, and no `headers` key at all', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'shared_grid'); + expect(headersOfCall(fetchMock)['If-Match']).toBeUndefined(); + // Byte-identity, not merely "no If-Match": last-write-wins stays the + // default, exactly as the door and the spec text describe. + expect(headerNamesOf(fetchMock)).toEqual(BASELINE_HEADERS); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/view/shared_grid', + ); + }); + + it("ABSENT for an empty token — `''` never reaches the wire", async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'shared_grid', { ifMatch: '' }); + // An empty `If-Match` is not a no-op on the door: presence means "pin + // this reset", so an emitted empty header would pin against the empty + // string and refuse a reset the caller never asked to pin. + expect(headersOfCall(fetchMock)['If-Match']).toBeUndefined(); + expect(headerNamesOf(fetchMock)).toEqual(BASELINE_HEADERS); + }); + + it('is a HEADER, not a query parameter: the URL is byte-identical to an unpinned reset', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'shared_grid', { ifMatch: OCC_TOKEN }); + // `?ifMatch=` is read by no door. If it appeared here it would look + // set at the call site and protect nothing. + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/view/shared_grid', + ); + }); + + it("sends `?state=draft` — and `'active'` deliberately sends NOTHING", async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'shared_grid', { state: 'draft' }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/view/shared_grid?state=draft', + ); + // `'active'` is the explicit spelling of the default: the door acts on + // `state=draft` alone, so emitting `?state=active` would put a value + // on the wire the server ignores. + await client.meta.deleteItem('view', 'shared_grid', { state: 'active' }); + expect(String(fetchMock.mock.calls[1][0])).toBe( + 'http://localhost:3000/api/v1/meta/view/shared_grid', + ); + }); + + it('carries both carriers at once without disturbing either', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'shared_grid', { ifMatch: OCC_TOKEN, state: 'draft' }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/view/shared_grid?state=draft', + ); + expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN); + }); + + it('encodes the item name like every other /meta address in this file', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'views/all_leads', { ifMatch: OCC_TOKEN }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/view/views%2Fall_leads', + ); + expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN); + }); +}); + +describe('[#12181] meta.deleteItem carriers — ENVIRONMENT-SCOPED twin', () => { + it('sends the If-Match header on the scoped client too', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.environment('env-123').meta.deleteItem('view', 'shared_grid', { ifMatch: OCC_TOKEN }); + // THE assertion this card exists for, on declaration #2 — asserted + // separately from #1 on purpose: the two are textually identical, and + // a fix that landed on one only is exactly what this card guards. + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/environments/env-123/meta/view/shared_grid', + ); + expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN); + }); + + it('sends `?state=draft` on the scoped client too', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.environment('env-123').meta.deleteItem('view', 'shared_grid', { state: 'draft' }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/environments/env-123/meta/view/shared_grid?state=draft', + ); + }); + + it('ABSENT on the scoped client when the caller does not pin', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.environment('env-123').meta.deleteItem('view', 'shared_grid'); + expect(headersOfCall(fetchMock)['If-Match']).toBeUndefined(); + expect(headerNamesOf(fetchMock)).toEqual(BASELINE_HEADERS); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/environments/env-123/meta/view/shared_grid', + ); + }); + + it('IN STEP with the unscoped twin: identical header and identical query for identical options', async () => { + // The divergence this card is about is a fix landing on one twin only. + // Comparing the two keeps holding if either path changes, rather than + // restating a literal on both sides. + const { client, fetchMock } = createMockClient(RESET_OK); + const opts = { ifMatch: OCC_TOKEN, state: 'draft' } as const; + await client.meta.deleteItem('view', 'shared_grid', opts); + await client.environment('env-123').meta.deleteItem('view', 'shared_grid', opts); + expect(headersOfCall(fetchMock, 1)['If-Match']).toBe(headersOfCall(fetchMock, 0)['If-Match']); + expect(headersOfCall(fetchMock, 0)['If-Match']).toBe(OCC_TOKEN); + const queryOf = (i: number) => new URL(String(fetchMock.mock.calls[i][0])).search; + expect(queryOf(1)).toBe(queryOf(0)); + expect(queryOf(0)).toBe('?state=draft'); + }); + + it('IN STEP when unpinned too: neither twin adds a header or a query', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'shared_grid'); + await client.environment('env-123').meta.deleteItem('view', 'shared_grid'); + expect(headerNamesOf(fetchMock, 0)).toEqual(BASELINE_HEADERS); + expect(headerNamesOf(fetchMock, 1)).toEqual(BASELINE_HEADERS); + expect(new URL(String(fetchMock.mock.calls[0][0])).search).toBe(''); + expect(new URL(String(fetchMock.mock.calls[1][0])).search).toBe(''); + }); +}); + +describe('[#12181] the withheld third carrier', () => { + it('`dropStorage` is not a member of the bag, and never reaches the wire', async () => { + const { client, fetchMock } = createMockClient(RESET_OK); + await client.meta.deleteItem('view', 'shared_grid', { + // @ts-expect-error — `dropStorage` is deliberately NOT a member of + // `DeleteMetaItemOptions` (2026-08-28 ruling on #12181: the one + // carrier that ADDS destructive reach, with no measured caller). + // This is the type-level half of the withholding; the runtime half + // is below. Adding the member turns this line into an "unused + // @ts-expect-error" error, so the withholding cannot be undone + // silently. + dropStorage: true, + }); + // …and nothing leaks onto the URL through the excess property either. + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/view/shared_grid', + ); + expect(String(fetchMock.mock.calls[0][0])).not.toContain('dropStorage'); + }); +}); + +// --------------------------------------------------------------------------- +// Part 2 — the REAL door: what actually happens to a concurrent edit +// --------------------------------------------------------------------------- + +const ADMIN = 'usr_admin_12181'; +/** + * `registry.registerObject` takes `(schema, packageId, …)`. Passed explicitly + * rather than left off: the argument is REQUIRED by the signature. + */ +const TEST_PACKAGE_ID = 'objectstack-test'; + +const TASK = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const VIEW = (name: string, label: string) => ({ + name, + label, + object: 'task', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], +}); + +function createMockHttpServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => {}, close: async () => {}, + }; +} + +function makeRes() { + const res: any = { + _status: 200, + write: () => true, + end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +/** Match a request path against the registered `/:param` route patterns. */ +function matchRoute(routes: any[], method: string, pathname: string) { + for (const route of routes) { + if (String(route.method).toUpperCase() !== method) continue; + const pattern = String(route.path).split('/'); + const actual = pathname.split('/'); + if (pattern.length !== actual.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < pattern.length; i++) { + if (pattern[i].startsWith(':')) params[pattern[i].slice(1)] = decodeURIComponent(actual[i]); + else if (pattern[i] !== actual[i]) { ok = false; break; } + } + if (ok) return { route, params }; + } + return undefined; +} + +/** + * A `fetch` that hands the client's request to the REAL registered handler. + * + * Faithful where fidelity is load-bearing for this card: header names are + * lowercased the way an HTTP server delivers them (so the door's + * `req.headers['if-match']` read is the one exercised, not its `If-Match` + * fallback), and a repeated query key arrives as an ARRAY — the shape + * `refuseRepeatedQueryParams` exists to catch. + */ +function doorFetch(rest: RestServer) { + const routes = (rest as any).getRoutes(); + return async (input: any, init: any = {}) => { + const url = new URL(String(input)); + const hit = matchRoute(routes, String(init.method ?? 'GET').toUpperCase(), url.pathname); + if (!hit) throw new Error(`no route registered for ${init.method ?? 'GET'} ${url.pathname}`); + const query: Record = {}; + for (const key of new Set(url.searchParams.keys())) { + const all = url.searchParams.getAll(key); + query[key] = all.length > 1 ? all : all[0]; + } + const headers: Record = {}; + for (const [k, v] of Object.entries((init.headers ?? {}) as Record)) { + headers[k.toLowerCase()] = String(v); + } + const res = makeRes(); + await hit.route.handler( + { + params: hit.params, + query, + headers, + body: init.body ? JSON.parse(String(init.body)) : undefined, + } as any, + res, + ); + const status = res._status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: async () => res._json, + headers: new Headers(), + } as any; + }; +} + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** Boot the real stack: real engine, real tables, real protocol, real routes. */ +async function bootDoor() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); + await engine.init(); + engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + // Real DDL — the overlay rows the assertions read are physically there. + await engine.syncSchemas(); + + const protocol: any = new ObjectStackProtocolImplementation(engine as any); + + /** + * The PROBE. Records the request the door hands the protocol, so + * "`parentVersion` was not sent" is measured on the same instrument that + * shows it BEING sent two cases later — the positive control that keeps + * an absence assertion honest. + */ + const deleteRequests: any[] = []; + const realDelete = protocol.deleteMetaItem.bind(protocol); + protocol.deleteMetaItem = async (request: any) => { + deleteRequests.push(request); + return realDelete(request); + }; + + const rest = new RestServer( + createMockHttpServer() as any, + protocol as any, + { api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'auto' } } as any, + ); + // The ONLY stub: the auth boundary. Everything downstream — the capability + // gate's verdict, the header read, the query parse, the protocol and the + // repository's parent-version check — is the real code. + (rest as any).resolveExecCtx = async () => ({ + userId: ADMIN, + systemPermissions: ['manage_metadata'], + }); + rest.registerRoutes(); + + const client = new ObjectStackClient({ baseUrl: 'http://door.test', fetch: doorFetch(rest) }); + return { engine, protocol, rest, client, deleteRequests }; +} + +/** The overlay rows for one item, straight out of `sys_metadata`. */ +async function overlayRows(engine: any, name: string) { + return engine.find('sys_metadata', { where: { name }, context: { isSystem: true } }); +} + +describe('[#12181] the real reset door: a concurrent edit is destroyed unpinned, refused pinned', () => { + it('both mounts of the reset door are registered — the twins really do reach one handler', async () => { + const { rest } = await bootDoor(); + const paths = (rest as any).getRoutes() + .filter((r: any) => String(r.method).toUpperCase() === 'DELETE') + .map((r: any) => r.path); + expect(paths).toContain('/api/v1/meta/:type/:name'); + expect(paths).toContain('/api/v1/environments/:environmentId/meta/:type/:name'); + }, 60_000); + + it('UNPINNED (the only reset the SDK could express before this card): the stale reset SUCCEEDS and the row is gone', async () => { + const { engine, client, deleteRequests } = await bootDoor(); + + // Author A writes, and reads back the OCC token the docstring names. + const first: any = await client.meta.saveItem('view', 'race_probe', VIEW('race_probe', 'A')); + expect(first.success).toBe(true); + const staleToken = first.version; + expect(typeof staleToken).toBe('string'); + + // Author B edits the same item. A's token is now stale. + const second: any = await client.meta.saveItem('view', 'race_probe', VIEW('race_probe', 'B')); + expect(second.version).not.toBe(staleToken); + expect(await overlayRows(engine, 'race_probe')).toHaveLength(1); + + // A resets, holding a version that is no longer current. This is the + // BEFORE state of the card: with no options bag there was no other + // call to make. + const reset: any = await client.meta.deleteItem('view', 'race_probe'); + + // Silently destroyed: success, and B's edit is gone from the store. + expect(reset.success).toBe(true); + expect(reset.reset).toBe(true); + expect(await overlayRows(engine, 'race_probe')).toHaveLength(0); + // The probe: no pin ever reached the protocol. + expect(deleteRequests).toHaveLength(1); + expect(deleteRequests[0]).not.toHaveProperty('parentVersion'); + }, 60_000); + + it('PINNED: the same stale reset is REFUSED 409 metadata_conflict, and the other author\'s row survives', async () => { + const { engine, client, deleteRequests } = await bootDoor(); + + const first: any = await client.meta.saveItem('view', 'race_probe', VIEW('race_probe', 'A')); + const staleToken = first.version; + const second: any = await client.meta.saveItem('view', 'race_probe', VIEW('race_probe', 'B')); + expect(second.version).not.toBe(staleToken); + + // Do literally what the docstring prescribes — impossible before this + // card, because there was no argument to pass it in. + const err: any = await client.meta + .deleteItem('view', 'race_probe', { ifMatch: staleToken }) + .then( + () => { throw new Error('expected the stale reset to be refused'); }, + (e: any) => e, + ); + + // Assert the ENVELOPE the caller branches on, not merely that + // something threw: a bare `.toThrow()` stays green against an error + // from a client that never sent the header at all. + expect(err.code).toBe('METADATA_CONFLICT'); + expect(err.httpStatus).toBe(409); + + // The point of the pin: the other author's row is still there. + expect(await overlayRows(engine, 'race_probe')).toHaveLength(1); + // POSITIVE CONTROL for the previous case's absence assertion — the + // same probe, on the same door, sees the token arrive. + expect(deleteRequests).toHaveLength(1); + expect(deleteRequests[0].parentVersion).toBe(staleToken); + }, 60_000); + + it('PINNED with the CURRENT version: the reset is allowed through', async () => { + // The other half of the pin — it refuses a stale write, not every + // write. Without this, "always 409" would pass the case above. + const { engine, client } = await bootDoor(); + const saved: any = await client.meta.saveItem('view', 'fresh_probe', VIEW('fresh_probe', 'A')); + const reset: any = await client.meta.deleteItem('view', 'fresh_probe', { ifMatch: saved.version }); + expect(reset.success).toBe(true); + expect(await overlayRows(engine, 'fresh_probe')).toHaveLength(0); + }, 60_000); + + it('the ENVIRONMENT-SCOPED twin pins against the same door, with the same verdict', async () => { + // Declaration #2, driven through the real scoped mount — the fix has + // to be proven on both clients separately, not counted once. + const { engine, client, deleteRequests } = await bootDoor(); + const scoped = client.environment('env-123').meta; + + const first: any = await scoped.saveItem('view', 'scoped_race', VIEW('scoped_race', 'A')); + const staleToken = first.version; + await scoped.saveItem('view', 'scoped_race', VIEW('scoped_race', 'B')); + + const err: any = await scoped + .deleteItem('view', 'scoped_race', { ifMatch: staleToken }) + .then( + () => { throw new Error('expected the stale scoped reset to be refused'); }, + (e: any) => e, + ); + expect(err.code).toBe('METADATA_CONFLICT'); + expect(err.httpStatus).toBe(409); + expect(await overlayRows(engine, 'scoped_race')).toHaveLength(1); + expect(deleteRequests[0].parentVersion).toBe(staleToken); + + // …and unpinned, the scoped twin destroys it exactly like the + // unscoped one — same handler, same last-write-wins default. + const reset: any = await scoped.deleteItem('view', 'scoped_race'); + expect(reset.success).toBe(true); + expect(await overlayRows(engine, 'scoped_race')).toHaveLength(0); + }, 60_000); +}); + +describe('[#12181] the real reset door: `?state=draft` discards ONLY the pending draft', () => { + it('the narrow reset leaves the published overlay serving; the full reset does not', async () => { + const { engine, client, deleteRequests } = await bootDoor(); + + // A published overlay, then a pending draft on top of it. + await client.meta.saveItem('view', 'draft_probe', VIEW('draft_probe', 'published')); + await client.meta.saveItem('view', 'draft_probe', VIEW('draft_probe', 'pending'), { mode: 'draft' }); + const before = await overlayRows(engine, 'draft_probe'); + // Two rows: the active overlay and the draft. + expect(before.length).toBe(2); + expect(before.map((r: any) => r.state).sort()).toEqual(['active', 'draft']); + + // The narrow reset — unreachable from this SDK before this card. + const discarded: any = await client.meta.deleteItem('view', 'draft_probe', { state: 'draft' }); + expect(discarded.success).toBe(true); + // The door parsed `?state=draft` and threaded it into the protocol + // call. (Positive control for the sibling case below, where the same + // probe shows the key ABSENT on a full reset.) + expect(deleteRequests[0].state).toBe('draft'); + + // THE claim: the published overlay is untouched, and only the draft is + // gone. + const after = await overlayRows(engine, 'draft_probe'); + expect(after).toHaveLength(1); + expect(after[0].state).toBe('active'); + + // A second draft discard has nothing left to discard — the door says + // so rather than falling through to the active row. + const again: any = await client.meta.deleteItem('view', 'draft_probe', { state: 'draft' }); + expect(again.reset).toBe(false); + expect(await overlayRows(engine, 'draft_probe')).toHaveLength(1); + + // …and the FULL reset — the only one the SDK could express before — + // takes the published overlay with it. This is why withholding + // `?state=draft` did not make the client safer. + const full: any = await client.meta.deleteItem('view', 'draft_probe'); + expect(full.reset).toBe(true); + expect(await overlayRows(engine, 'draft_probe')).toHaveLength(0); + // The probe again: `state` is absent on the full reset — measured on + // the same instrument that showed it present above. + expect(deleteRequests[deleteRequests.length - 1]).not.toHaveProperty('state'); + }, 60_000); + + it('the scoped twin reaches the same narrow reset', async () => { + const { engine, client, deleteRequests } = await bootDoor(); + const scoped = client.environment('env-123').meta; + + await scoped.saveItem('view', 'scoped_draft', VIEW('scoped_draft', 'published')); + await scoped.saveItem('view', 'scoped_draft', VIEW('scoped_draft', 'pending'), { mode: 'draft' }); + + const discarded: any = await scoped.deleteItem('view', 'scoped_draft', { state: 'draft' }); + expect(discarded.success).toBe(true); + expect(deleteRequests[0].state).toBe('draft'); + const after = await overlayRows(engine, 'scoped_draft'); + expect(after).toHaveLength(1); + expect(after[0].state).toBe('active'); + }, 60_000); +}); diff --git a/packages/client/src/zz-probe-door.test.ts b/packages/client/src/zz-probe-door.test.ts deleted file mode 100644 index 0cbb5aedac..0000000000 --- a/packages/client/src/zz-probe-door.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// TEMPORARY PROBE — delete before commit. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { LiteKernel } from '@objectstack/core'; -import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; -import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; -import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; -import { createRestApiPlugin } from '@objectstack/runtime'; -import type { IHttpServer } from '@objectstack/spec/contracts'; - -describe('probe: real /meta write door', () => { - let baseUrl: string; - let kernel: LiteKernel; - - beforeAll(async () => { - kernel = new LiteKernel(); - kernel.use(new ObjectQLPlugin()); - kernel.use({ - metadata: { name: 'test-auth', version: '1.0.0' }, - async init(ctx: any) { - ctx.registerService('auth', { - api: { getSession: async () => ({ user: { id: 'test-user' } }) }, - }); - }, - } as any); - kernel.use(new HonoServerPlugin({ port: 0 })); - kernel.use(createRestApiPlugin({ api: { api: { requireAuth: false } as any } })); - await kernel.bootstrap(); - const ql = kernel.getService('objectql'); - ql.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); - const httpServer = kernel.getService('http.server'); - baseUrl = `http://localhost:${httpServer.getPort!()}`; - // eslint-disable-next-line no-console - console.log('PROBE baseUrl', baseUrl); - }, 60_000); - - afterAll(async () => { - if (kernel) await Promise.race([kernel.shutdown(), new Promise((r) => setTimeout(r, 10_000))]); - }, 30_000); - - it('reports what a PUT and DELETE answer', async () => { - const put = await fetch(`${baseUrl}/api/v1/meta/view/probe_view`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'probe_view', label: 'Probe', object: 'task' }), - }); - const putBody = (await put.text()).slice(0, 400); - const del = await fetch(`${baseUrl}/api/v1/meta/view/probe_view`, { method: 'DELETE' }); - const delBody = (await del.text()).slice(0, 400); - expect({ put: put.status, putBody, del: del.status, delBody }).toEqual('SHOW-ME'); - }, 60_000); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 707c373090..08ac498d5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -631,6 +631,12 @@ importers: '@objectstack/hono': specifier: workspace:* version: link:../adapters/hono + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../metadata-core + '@objectstack/metadata-protocol': + specifier: workspace:* + version: link:../metadata-protocol '@objectstack/objectql': specifier: workspace:* version: link:../objectql From 2540771a448148aafdd6dee4b3a1cde8f05c8f25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:43:31 +0000 Subject: [PATCH 3/4] test(client): pin both deleteItem carriers against the real reset door; changeset --- .changeset/meta-delete-item-carriers.md | 47 +++++++++++++++++++ .../src/meta-delete-item-carriers.test.ts | 9 ++-- 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 .changeset/meta-delete-item-carriers.md diff --git a/.changeset/meta-delete-item-carriers.md b/.changeset/meta-delete-item-carriers.md new file mode 100644 index 0000000000..dbb8cd475e --- /dev/null +++ b/.changeset/meta-delete-item-carriers.md @@ -0,0 +1,47 @@ +--- +"@objectstack/client": minor +--- + +feat(client): `meta.deleteItem` can pin a reset (`If-Match`) and discard only the pending draft (`?state=draft`) (#12181) + +Accept-set widening on a published SDK surface: both `deleteItem` declarations +— the unscoped `ObjectStackClient.meta` and the environment-scoped +`ScopedEnvironmentClient.meta` twin — take a third, optional +`DeleteMetaItemOptions` argument. Existing calls are unchanged: with the bag +omitted, the request is byte-identical to what this method has always sent +(no header key, no query string). + +FROM → TO: + +```ts +// FROM — the only reset a first-party SDK caller could express +await client.meta.deleteItem('view', 'shared_grid'); + +// TO — pin the reset against the version you read (ADR-0008 OCC) +const saved = await client.meta.saveItem('view', 'shared_grid', spec); +await client.meta.deleteItem('view', 'shared_grid', { ifMatch: saved.version }); +// concurrent edit → 409 metadata_conflict, instead of silently resetting it + +// TO — discard ONLY the pending draft; the published overlay keeps serving +await client.meta.deleteItem('view', 'shared_grid', { state: 'draft' }); +``` + +Why it matters: `DELETE /meta/:type/:name` has always read the `If-Match` +header and threaded it as `parentVersion` (the spec's own +`DeleteMetaItemRequest.parentVersion` describes the pin), and the sibling +first-party client `@object-ui/data-objectstack` `MetadataClient.reset` +already sent it — but this client had no argument for it, so every SDK reset +was last-write-wins on the one verb whose whole job is destroying an overlay +row. `state: 'draft'` reaches the NARROWER reset; without it the only +reachable reset was the full one, which drops the published overlay too. + +⛔ `?dropStorage=true` is deliberately NOT part of this bag. It is the one +carrier the reset door reads that ADDS destructive reach — it drops the +object's physical table — no caller was measured needing it from this client, +and the door's repeated-parameter refusal exists because of that +destructiveness. A caller that needs it is a separate, separately reviewable +widening. + +`state: 'active'` is the explicit spelling of the default and deliberately +sends nothing; an empty `ifMatch` (`''`) omits the header rather than pinning +against the empty string. diff --git a/packages/client/src/meta-delete-item-carriers.test.ts b/packages/client/src/meta-delete-item-carriers.test.ts index f9c044d982..af902a6351 100644 --- a/packages/client/src/meta-delete-item-carriers.test.ts +++ b/packages/client/src/meta-delete-item-carriers.test.ts @@ -253,13 +253,14 @@ describe('[#12181] the withheld third carrier', () => { it('`dropStorage` is not a member of the bag, and never reaches the wire', async () => { const { client, fetchMock } = createMockClient(RESET_OK); await client.meta.deleteItem('view', 'shared_grid', { - // @ts-expect-error — `dropStorage` is deliberately NOT a member of + // `dropStorage` is deliberately NOT a member of // `DeleteMetaItemOptions` (2026-08-28 ruling on #12181: the one // carrier that ADDS destructive reach, with no measured caller). // This is the type-level half of the withholding; the runtime half - // is below. Adding the member turns this line into an "unused - // @ts-expect-error" error, so the withholding cannot be undone - // silently. + // is below. Adding the member turns the directive on the next line + // into an "unused '@ts-expect-error'" error (TS2578), so the + // withholding cannot be undone silently. + // @ts-expect-error — dropStorage is not part of this bag, on purpose. dropStorage: true, }); // …and nothing leaks onto the URL through the excess property either. From 8b36b518027f92c858641050e9643726f6189ecc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:49:45 +0000 Subject: [PATCH 4/4] chore(client): resolve the new test's producer imports from source (vitest alias + tsc paths) --- packages/client/tsconfig.json | 29 ++++++++++++++++++++++++++- packages/client/vitest.config.ts | 34 +++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index e327ef860d..d880003e4f 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -4,7 +4,34 @@ "outDir": "./dist", "rootDir": "./src", "lib": ["ES2020", "DOM", "DOM.Iterable"], - "types": ["node"] + "types": ["node"], + // [#12181] `@objectstack/metadata-core` and `@objectstack/metadata-protocol` + // are imported by `src/meta-delete-item-carriers.test.ts` (the real-door + // reproduction), i.e. by the TEST program `tsconfig.test.json`, which + // extends this file. Without these rules tsc resolves those specifiers + // through each dependency's `exports` map — `dist/index.d.ts`, A BUILD + // ARTIFACT — so the verdict would be about the last `pnpm build` rather + // than about the producer's source in the checkout. + // `check:type-source-resolution` refuses exactly that, and its header + // states why the dangerous case is a typecheck that PASSES. + // + // ONE rule per package, not two: both publish a single entry point (their + // `exports` maps carry only `"."` besides, for metadata-core, a `./testing` + // subpath nothing here imports), and a rule pointing at files that are not + // on disk is worse than absent — tsc falls back to node resolution, i.e. to + // `dist`, silently. ⛔ Never spell a key with a star not preceded by a + // slash (`@objectstack/metadata-core*`): it matches the bare name AND every + // subpath, folds them onto one target, and type-checks green against the + // wrong module. + // + // `rootDir` stays `./src`: this BUILD program excludes `**/*.test.ts`, so + // no file in it imports either specifier and neither producer's source is + // pulled in here. The test program that does pull them in already sets + // `rootDir` to the workspace root. + "paths": { + "@objectstack/metadata-core": ["../metadata-core/src/index.ts"], + "@objectstack/metadata-protocol": ["../metadata-protocol/src/index.ts"] + } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts index bec789cca8..f3d7d26f2d 100644 --- a/packages/client/vitest.config.ts +++ b/packages/client/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ test: { @@ -9,5 +10,36 @@ export default defineConfig({ 'tests/integration/**', ], environment: 'node', - } + }, + resolve: { + // [#12181] Both entries exist for `meta-delete-item-carriers.test.ts`, the + // suite here that drives the REAL reset door: it boots + // `ObjectStackProtocolImplementation` and registers the real + // `sys_metadata*` object definitions, so it imports two sibling packages as + // VALUES. + // + // Unaliased, those specifiers resolve through the workspace link to + // `dist/` — a BUILD ARTIFACT — which would make this suite's verdict a + // function of build state rather than of the source in the checkout. The + // loud failure (a missing export) is the mild half; a dist merely BEHIND + // lets the suite run GREEN against the producer's old behaviour with + // nothing in the output saying so, and this suite's whole job is to assert + // what the door and the protocol do with a carrier the client now sends. + // `pnpm check:test-source-alias` refuses exactly that. + // + // Array form with anchored patterns, deliberately: the object form matches + // by PREFIX, so a bare key with a FILE replacement would also swallow any + // subpath and resolve it to `…/src/index.ts/` (ENOTDIR) at run + // time, in a config that looks right. + alias: [ + { + find: /^@objectstack\/metadata-core$/, + replacement: path.resolve(__dirname, '../metadata-core/src/index.ts'), + }, + { + find: /^@objectstack\/metadata-protocol$/, + replacement: path.resolve(__dirname, '../metadata-protocol/src/index.ts'), + }, + ], + }, });