diff --git a/.changeset/package-disable-reaches-metadata-and-data.md b/.changeset/package-disable-reaches-metadata-and-data.md new file mode 100644 index 0000000000..92a8c3e810 --- /dev/null +++ b/.changeset/package-disable-reaches-metadata-and-data.md @@ -0,0 +1,78 @@ +--- +"@objectstack/metadata-protocol": patch +"@objectstack/objectql": patch +"@objectstack/runtime": patch +"@objectstack/spec": patch +--- + +fix(engine-core): disabling a package now stops its objects being served, and a failed uninstall stops answering 200 (#7557) + +## Disabling a package is now an enforcement for its objects + +A package at `status: 'disabled'` had its nav entries and views correctly +dropped, while `GET /api/v1/data/` still answered **200 with every +row** and `GET /api/v1/meta/objects` still listed the object. The status was +consulted by some readers and skipped by others, so "disabled" meant different +things depending on which surface you asked. + +Both skips were deliberate and both gave the same reason — filtering objects +"would break data queries that depend on their schema". That conflated two +different kinds of reader, and they are now separated explicitly: + +- **Resolution readers keep serving.** `registry.getObject` and + `registry.listItems('object')` still return a disabled package's objects. + Migrations, cross-package references and the runtime authoring gate's object + universe all resolve through them, and blanking them would break authoring + that has nothing to do with the disabled package. Disable remains reversible + and still destroys no data. +- **API readers now stop.** The `/meta/*` listing drops the objects (the + `object`/`objects` exemption in `getMetaItems` is gone; `package` is still + never filtered, or a disabled package could never be re-enabled), and the data + plane refuses. + +**The data-plane refusal is loud, not silent.** `assertObjectRegistered` — the +single gate every `findData`/`getData` entry point funnels through — now answers +a new error code: + +``` +404 { "error": { "code": "OBJECT_PACKAGE_DISABLED", + "message": "Object 'x' belongs to a disabled package and is not + being served. Re-enable the package to restore access." } } +``` + +The 404 status matches the closest existing sibling, `OBJECT_API_DISABLED` for +`enable.apiEnabled: false`, so "this object exists but is switched off" keeps +one status across both switches. The distinct **code** is what makes it +actionable: a bare `OBJECT_NOT_FOUND` sends a caller — an AI agent especially — +hunting for a typo or re-creating an object that is merely switched off, while +this one names the cause and therefore the fix. `OBJECT_PACKAGE_DISABLED` is +registered in the ADR-0112 ledger under `@objectstack/metadata-protocol`. + +If you have a client that treats a disabled package's objects as queryable, it +now receives a 404 with the code above instead of rows. Re-enabling the package +restores every surface. + +## A failed uninstall is no longer wrapped in a 200 + +`DELETE /packages/:id` on the dispatcher door stated `success: true` +unconditionally and forwarded the protocol's own `{ success: false, +deletedCount: 0 }` underneath it, so the status line and the payload disagreed +and any caller reading the status recorded an uninstall that had not happened. +Per-item failures now answer **400 `PACKAGE_DELETE_PARTIAL`**, carrying the +failed items and the uninstall cleanup outcomes (a failed permission revocation +is a ghost grant, so it must survive the failure path). + +The rule is copied deliberately from the direct-mount REST door of the same +route, which already answered this way — two doors to one route answering +differently is how the divergence arrived. That includes its carve-out: **zero +metadata rows is still a successful uninstall**, because a runtime-registered +package that never published metadata has nothing in `sys_metadata`. The +failure predicate is therefore `failedCount > 0`, not `!persisted.success`. + +An all-rows-failed uninstall now answers 400 rather than the 404 its zero +`deletedCount` previously implied. + +**Not fixed here:** the separate persistence defect where `deletePackage` finds +zero rows while package-bound `sys_metadata` rows demonstrably exist, leaving +them behind on an otherwise-clean uninstall. That is a `sys_metadata` query +defect one layer below this handler and is reported for its own fix; see #7557. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index f679f8514b..4d1c6b8eef 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -44,7 +44,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. | @@ -79,7 +79,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ rows: Record[]; fields: object[]; sql?: string }` | ✅ | | @@ -93,7 +93,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sql: string; params: any[] }` | ✅ | | diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index 92ffb29975..350be76b82 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -117,7 +117,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ session: object; user: object; token?: string }` | ✅ | | @@ -153,7 +153,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 2e6edb1c13..f82f9e67f4 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -119,7 +119,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition | @@ -144,7 +144,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; deleted: boolean }` | ✅ | | @@ -187,7 +187,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition | @@ -213,7 +213,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| … +2 more>; … }` | ✅ | Full execution log with step details | @@ -241,7 +241,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ flows: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -269,7 +269,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ runs: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -295,7 +295,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; enabled: boolean }` | ✅ | | @@ -325,7 +325,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | | @@ -351,7 +351,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition | diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index 2af91a31ee..5c40c9768e 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -55,7 +55,7 @@ const result = BatchConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | @@ -122,7 +122,7 @@ const result = BatchConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 8b45a70174..d7d1d77761 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +255 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +256 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **message** | `string` | ✅ | Readable error message | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | @@ -215,6 +215,7 @@ const result = ApiErrorSchema.parse(data); * `OBJECT_API_DISABLED` * `OBJECT_API_METHOD_NOT_ALLOWED` * `OBJECT_OVERLAY_PACKAGE_MISMATCH` +* `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` @@ -309,7 +310,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | @@ -348,7 +349,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id?: string; success: boolean; errors?: object[]; index?: number; … }[]` | ✅ | Results for each item in the batch | @@ -390,7 +391,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **id** | `string` | ✅ | ID of the deleted record | @@ -442,7 +443,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of matching records | | **pagination** | `{ total?: number; limit?: number; offset?: number; cursor?: string; … }` | ✅ | Pagination info | @@ -458,7 +459,7 @@ const result = ApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if processed | | **success** | `boolean` | ✅ | | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -491,7 +492,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | ✅ | The requested or modified record | diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 608f68034d..980787b5c6 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -287,6 +287,7 @@ const result = ErrorCode.parse(data); * `OBJECT_API_DISABLED` * `OBJECT_API_METHOD_NOT_ALLOWED` * `OBJECT_OVERLAY_PACKAGE_MISMATCH` +* `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index eb9c3f5e01..d42ae97b35 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -57,7 +57,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; estimatedRecords?: integer; createdAt: string }` | ✅ | | @@ -157,7 +157,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; totalRecords?: integer; … }` | ✅ | | @@ -231,7 +231,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; downloadUrl: string; fileName: string; fileSize: integer; … }` | ✅ | | @@ -449,7 +449,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ totalRecords: integer; validRecords: integer; invalidRecords: integer; duplicateRecords: integer; … }` | ✅ | | @@ -488,7 +488,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobs: object[]; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -546,7 +546,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; name: string; enabled: boolean; nextRunAt?: string; … }` | ✅ | | diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 8434563637..33cc8906ee 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -51,7 +51,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }` | ✅ | Full App Configuration | @@ -65,7 +65,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; icon?: string; description?: string }[]` | ✅ | List of available concepts (Objects, Apps, Flows) | @@ -92,7 +92,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; succeeded: integer; failed: integer; errors?: object[] }` | ✅ | Bulk operation result | @@ -117,7 +117,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string }` | ✅ | | @@ -131,7 +131,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items this item depends on | @@ -145,7 +145,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items that depend on this item | @@ -159,7 +159,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | optional | Effective metadata with all overlays applied | @@ -173,7 +173,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ exists: boolean }` | ✅ | | @@ -200,7 +200,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `any` | ✅ | Exported metadata bundle | @@ -228,7 +228,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; imported: integer; skipped: integer; failed: integer; … }` | ✅ | Import result | @@ -242,7 +242,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string; definition: Record }` | ✅ | Metadata item | @@ -256,7 +256,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of metadata definitions | @@ -270,7 +270,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Array of metadata item names | @@ -284,7 +284,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; baseType: string; baseName: string; packageId?: string; … }` | optional | Overlay definition, undefined if none | @@ -348,7 +348,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ items: object[]; total: integer; page: integer; pageSize: integer }` | ✅ | Paginated query result | @@ -406,7 +406,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; label: string; description?: string; filePatterns: string[]; … }` | optional | Type info | @@ -420,7 +420,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Registered metadata type identifiers | @@ -446,7 +446,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ valid: boolean; errors?: object[]; warnings?: object[] }` | ✅ | Validation result | @@ -460,7 +460,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` | ✅ | Full Object Schema | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 72ebf83155..e3afb88273 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -57,7 +57,7 @@ Get installed package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }` | ✅ | Installed package details | @@ -89,7 +89,7 @@ List installed packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packages: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -143,7 +143,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: object[]; message?: string }` | ✅ | | @@ -185,7 +185,7 @@ Rollback package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; restoredVersion?: string; message?: string }` | ✅ | | @@ -220,7 +220,7 @@ Upgrade package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; phase: string; plan?: object; snapshotId?: string; … }` | ✅ | | @@ -250,7 +250,7 @@ Resolve dependencies response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ dependencies: object[]; canProceed: boolean; requiredActions: object[]; installOrder: string[]; … }` | ✅ | Dependency resolution result with topological sort | @@ -277,7 +277,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packageId: string; success: boolean; message?: string }` | ✅ | | @@ -309,7 +309,7 @@ Upload artifact response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; artifactRef?: object; submissionId?: string; message?: string }` | ✅ | | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 9ac5238a63..d9c5a5ddfb 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -280,7 +280,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -428,7 +428,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -1528,7 +1528,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index 87d0fe7cfc..bafd14469f 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -46,7 +46,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ fileId: string; key: string; size: integer; mimeType: string; … }` | ✅ | | @@ -72,7 +72,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ url: string }` | ✅ | | @@ -101,7 +101,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ path: string; name: string; size: integer; mimeType: string; … }` | ✅ | Uploaded file metadata | @@ -147,7 +147,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; resumeToken: string; fileId: string; totalChunks: integer; … }` | ✅ | | @@ -161,7 +161,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadUrl: string; downloadUrl?: string; fileId: string; method: Enum<'PUT' \| 'POST'>; … }` | ✅ | | @@ -175,7 +175,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ key: string }` | ✅ | | @@ -202,7 +202,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ chunkIndex: integer; eTag: string; bytesReceived: integer }` | ✅ | | @@ -216,7 +216,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +260 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; fileId: string; filename: string; totalSize: integer; … }` | ✅ | | diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 34cf743b03..70127fc24a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4324,14 +4324,28 @@ export class ObjectStackProtocolImplementation implements // MetadataService merges above can re-introduce them (e.g. an app/view // persisted in sys_metadata). Re-apply the filter on the final merged // set so a disabled package's metadata stops surfacing in the console. - // Never filter `package` (the Packages page must list disabled packages - // to re-enable them) nor `object`/`objects` (filtering objects would - // break data queries that depend on their schema). - if ( - request.type !== 'package' && - request.type !== 'object' && - request.type !== 'objects' - ) { + // + // Never filter `package`: the Packages page must list disabled packages + // to re-enable them, so filtering it would make disable irreversible. + // + // [#7557] `object` USED to be exempt here too, on the reasoning that + // "filtering objects would break data queries that depend on their + // schema". That conflated two different readers. Data queries resolve + // schema through `registry.getObject` / `listItems('object')` — the + // registry primitives, which this filter does not touch and which + // deliberately keep serving a disabled package's objects so migrations, + // cross-package references and the runtime authoring gate (`protocol.ts` + // resolution context) still see a complete object universe. NOTHING + // resolves a query's schema through `getMetaItems`, which is the API + // READ surface. So the exemption bought no safety and cost the + // enforcement: `/meta/objects` kept listing a disabled package's objects + // while its nav and views correctly vanished. + // + // The data plane is refused separately and loudly, in + // `assertObjectRegistered` (`OBJECT_PACKAGE_DISABLED`) — absence here + // and refusal there are the two halves of one answer, pinned together + // by `protocol.package-disable-enforcement.test.ts`. + if (request.type !== 'package') { items = (items as any[]).filter( (it) => !this.engine.registry.isPackageDisabled((it as any)?._packageId), ); @@ -5192,7 +5206,44 @@ export class ObjectStackProtocolImplementation implements } return; } - if (registry.getObject(object)) return; + if (registry.getObject(object)) { + // [#7557] Registered is not the same as SERVING. A disabled package + // keeps its objects registered on purpose — disable is reversible + // and destroys no data, and the schema stays resolvable for the + // machinery that needs it (the runtime authoring gate resolves its + // object universe through `listItems('object')`, migrations and + // cross-package references through `getObject`). What must stop is + // the API serving its rows, which until now it did: nav and views + // dropped on disable while `GET /data/` still answered 200 + // with every row. + // + // Refused LOUDLY rather than by silent absence. The 404 status + // matches the closest sibling in this codebase — `OBJECT_API_DISABLED` + // for `enable.apiEnabled: false` (rest-server.ts) — so "this object + // exists but is switched off" keeps ONE status across both switches, + // and it keeps the data plane consistent with the metadata listing, + // which now drops the object too (`getMetaItems`). The distinct code + // is what makes it loud: a bare `OBJECT_NOT_FOUND` sends a caller — + // an AI agent especially — hunting for a typo or re-creating an + // object that is merely switched off, while this one names the cause + // and therefore the fix (re-enable the package). + // + // Optional-called: registry doubles across the test suites implement + // `isPackageDisabled` but not this, and a host whose registry cannot + // answer must not have every read refused. + if (typeof registry.isObjectPackageDisabled === 'function' + && registry.isObjectPackageDisabled(object)) { + const disabled: any = new Error( + `Object '${object}' belongs to a disabled package and is not being served. ` + + 'Re-enable the package to restore access.', + ); + disabled.code = 'OBJECT_PACKAGE_DISABLED'; + disabled.status = 404; + disabled.object = object; + throw disabled; + } + return; + } const err: any = new Error(`Object '${object}' not found`); err.code = 'OBJECT_NOT_FOUND'; err.status = 404; diff --git a/packages/objectql/src/package-disable-enforcement.test.ts b/packages/objectql/src/package-disable-enforcement.test.ts new file mode 100644 index 0000000000..beb735bbef --- /dev/null +++ b/packages/objectql/src/package-disable-enforcement.test.ts @@ -0,0 +1,401 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from './registry.js'; +// [#4550 / #5480] The producer's OWN write-verb dispatch decisions, so the +// engine double cannot accept a call `ObjectQL.delete` / `ObjectQL.update` +// refuses — a double looser than the implementation is no test at all. +import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; +import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; + +/** + * #7557 (A) — disabling a package must reach the metadata listing and the data + * plane, not just nav and views. + * + * Measured on `origin/main` against a real running server (showcase app, a + * runtime-installed package with three `sys_metadata`-bound objects and three + * rows), BEFORE the fix: + * + * PATCH /packages//disable -> 200, status='disabled' + * GET /api/v1/data/ -> 200, rows=3 ← served anyway + * GET /api/v1/meta/objects -> object STILL listed + * nav / views -> correctly gone + * + * So `status` was consulted by SOME readers and skipped by others. The skip was + * deliberate on both sides and both sides gave the same reason: `listItems` + * returns early for `object`/`objects` before reaching its disable filter + * (`registry.ts`), and `getMetaItems` exempted the same two types explicitly, + * "filtering objects would break data queries that depend on their schema". + * + * That reason conflated two different readers, which is what this file pins + * apart. There are two classes of object reader and they must answer + * DIFFERENTLY: + * + * - RESOLUTION readers — `registry.getObject`, `registry.listItems('object')` + * — keep serving a disabled package's objects. Disable is reversible and + * destroys no data; migrations, cross-package references and the runtime + * authoring gate's object universe all resolve through these, and blanking + * them would break authoring that has nothing to do with the disabled + * package. This is the honest negative the card asked to preserve. + * + * - API readers — `getMetaItems` (the `/meta/*` listing) and the data plane + * (`assertObjectRegistered`, which every `findData`/`getData` entry point + * funnels through) — must stop. Absence in the listing and refusal on the + * data plane are two halves of ONE answer. + * + * ONE test walks EVERY reader (`READERS` below) rather than one test per + * surface: this bug IS a divergence between readers of a shared predicate, so a + * new reader that forgets to consult it has to fail here. Adding a reader means + * adding a row to that table and stating which class it is in. + * + * The data-plane refusal is asserted on `code` + `status` (ADR-0112), never on + * "it threw": a bare `OBJECT_NOT_FOUND` would satisfy "it threw" while sending + * a caller — an AI agent especially — hunting for a typo or re-creating an + * object that is merely switched off. + * + * --------------------------------------------------------------------------- + * Reverse verification, direction predicted BEFORE running + * --------------------------------------------------------------------------- + * Reverting `getMetaItems`' filter to its `request.type !== 'object' && + * request.type !== 'objects'` form turns the `/meta` listing rows red and + * leaves every resolution row green. Removing the `isObjectPackageDisabled` + * branch from `assertObjectRegistered` turns the data-plane rows red the same + * way. Neither touches the other, which is the point of splitting them: the two + * halves of the enforcement are independently pinned, and the resolution rows + * are the control that keeps a future "fix" from over-reaching into the + * registry primitives. + */ + +const PKG = 'app.disabled_demo'; +const OTHER_PKG = 'app.healthy_demo'; + +const objectBody = (name: string) => ({ + name, + label: 'Demo', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + }, +}); + +const rowKey = (w: Record) => + [w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|'); + +const matchesWhere = (row: Record, where: Record) => + Object.entries(where).every(([k, v]) => { + if (v === null) return row[k] === null || row[k] === undefined; + return row[k] === v; + }); + +/** + * In-memory `sys_metadata` engine carrying a REAL `SchemaRegistry` — the + * predicate under test lives on the registry, so a registry double would be + * measuring the double. + */ +function makeHarness() { + const registry = new SchemaRegistry({ multiTenant: false }); + (registry as any).logLevel = 'silent'; + const rows = new Map(); + const historyRows: any[] = []; + let nextId = 0; + + const findRow = (w: Record) => { + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const engine: any = { + registry, + async findOne(table: string, o: { where: Record }) { + if (table === 'sys_metadata_history') return historyRows.find((h) => matchesWhere(h, o.where)) ?? null; + if (table !== 'sys_metadata') return null; + return findRow(o.where)?.row ?? null; + }, + async find(table: string, o: { where: Record }) { + if (table === 'sys_metadata_history') return historyRows.filter((h) => matchesWhere(h, o.where)); + if (table !== 'sys_metadata') return []; + return Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + const h = { id: `h_${++nextId}`, ...(data as any) }; + historyRows.push(h); + return { id: h.id }; + } + if (table !== 'sys_metadata') return { id: `rec_${++nextId}`, ...(data as any) }; + const row = { id: `r_${++nextId}`, ...(data as any) }; + rows.set(rowKey(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, o: { where: Record }) { + assertEngineUpdateDispatch(data, o); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(o.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(rowKey(merged), merged); + return { id: merged.id }; + }, + async delete(table: string, o?: Record) { + assertEngineDeleteDispatch(o); + if (table !== 'sys_metadata') return { deleted: 0 }; + const found = findRow(((o as any)?.where ?? {}) as Record); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async syncObjectSchema() { /* no physical storage in this double */ }, + }; + + const protocol = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); + return { protocol, engine, registry, rows }; +} + +/** + * A package that owns an object plus one non-object item, and a SECOND, healthy + * package that owns its own of each. The healthy package is not decoration: it + * is what separates "disable hid the right things" from "disable emptied the + * listing", which every assertion below would otherwise pass for the wrong + * reason. + */ +async function seed(protocol: any, registry: SchemaRegistry) { + for (const [pkg, obj, app] of [ + [PKG, 'demo_widget', 'demo_app'], + [OTHER_PKG, 'healthy_widget', 'healthy_app'], + ] as const) { + registry.installPackage({ id: pkg, name: pkg, version: '1.0.0' } as any); + await protocol.saveMetaItem({ type: 'object', name: obj, packageId: pkg, item: objectBody(obj) }); + // `app` rather than `view` as the non-object specimen: a `view` is saved as + // an ADR-0017 CONTAINER and only becomes independently listable once the + // registrar expands it into ViewItems, which this metadata-only harness has + // no registrar to do — so `getMetaItems({type:'view'})` is legitimately + // empty here and would make the control assertion pass for the wrong + // reason. `app` is the same disable-filtered listing without that + // indirection. (Nav/views are covered end-to-end by the live repro recorded + // in the PR body.) + await protocol.saveMetaItem({ + type: 'app', + name: app, + packageId: pkg, + item: { name: app, label: 'Demo App' }, + }); + } +} + +const listItemsOf = async (protocol: any, type: string): Promise => { + const res = await protocol.getMetaItems({ type }); + return (Array.isArray(res) ? res : res?.items ?? res?.data ?? []) as any[]; +}; + +const listNames = async (protocol: any, type: string): Promise => + (await listItemsOf(protocol, type)).map((i) => i?.name).filter(Boolean); + +/** + * Items a listing serves for one package. Counted by `_packageId` rather than + * by name because `view` is enumerated in its EXPANDED form (ADR-0017 splits a + * container into independent ViewItems), so the name a listing returns is not + * necessarily the name it was saved under — while ownership is stable either + * way, and ownership is exactly what disable keys on. + */ +const servedFor = async (protocol: any, type: string, pkg: string): Promise => + (await listItemsOf(protocol, type)).filter((i) => i?._packageId === pkg).length; + +/** + * Every reader of the disable predicate, and the class it belongs to. + * `enforces: true` = an API reader that must stop serving a disabled package's + * object. `enforces: false` = a resolution reader that must keep serving it. + */ +const READERS: Array<{ + reader: string; + enforces: boolean; + why: string; + serves: (h: { protocol: any; registry: SchemaRegistry }) => Promise; +}> = [ + { + reader: 'getMetaItems({ type: "object" }) — GET /meta/objects', + enforces: true, + why: 'the API listing the console and every SDK client reads', + serves: async ({ protocol }) => (await listNames(protocol, 'object')).includes('demo_widget'), + }, + { + reader: 'getMetaItems({ type: "objects" }) — the plural spelling of the same route', + enforces: true, + why: 'folded to the singular before the filter; pinned so the fold cannot silently stop folding', + serves: async ({ protocol }) => (await listNames(protocol, 'objects')).includes('demo_widget'), + }, + { + reader: 'getMetaItems({ type: "app" }) — a NON-object listing that ALREADY honoured disable', + enforces: true, + why: 'the pre-existing behaviour the objects half was made consistent with; regression guard', + serves: async ({ protocol }) => (await servedFor(protocol, 'app', PKG)) > 0, + }, + { + reader: 'assertObjectRegistered — the data plane (findData / getData funnel here)', + enforces: true, + why: 'the symptom itself: 200 with all rows for a disabled package', + serves: async ({ protocol }) => { + try { + (protocol as any).assertObjectRegistered('demo_widget'); + return true; + } catch { + return false; + } + }, + }, + { + reader: 'registry.getObject — object RESOLUTION', + enforces: false, + why: 'migrations and cross-package references resolve schema through it; disable destroys no data', + serves: async ({ registry }) => registry.getObject('demo_widget') !== undefined, + }, + { + reader: 'registry.listItems("object") — the runtime authoring gate\'s object universe', + enforces: false, + why: 'protocol.ts resolves its reference-validation context here; blanking it breaks unrelated authoring', + serves: async ({ registry }) => + (registry.listItems('object')).some((o) => o?.name === 'demo_widget'), + }, +]; + +/** + * Walk EVERY reader and report the whole table in one assertion. + * + * Deliberately not `expect()` inside the loop: the first failing reader would + * throw and the remaining rows would never run, so a change that broke three + * readers would be indistinguishable from one that broke one — in a file whose + * entire purpose is showing which readers diverged from each other. + */ +const walk = async ( + h: { protocol: any; registry: SchemaRegistry }, + expected: (r: (typeof READERS)[number]) => boolean, +): Promise> => { + const out: Record = {}; + for (const r of READERS) { + const label = `${r.reader} → ${expected(r) ? 'serves' : 'refuses'}`; + out[label] = (await r.serves(h)) === expected(r); + } + return out; +}; + +const allTrue = (rows: Record): Record => + Object.fromEntries(Object.keys(rows).map((k) => [k, true])); + +describe('#7557 (A) — a disabled package stops serving, on every API reader at once', () => { + it('every reader agrees BEFORE the disable — the control', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + + const rows = await walk(h, () => true); + expect(rows).toEqual(allTrue(rows)); + }); + + it('after disable: API readers stop, resolution readers keep serving', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + // `enforces` readers must refuse, resolution readers must keep serving — + // the two classes asserted in ONE table so a divergence names itself. + const rows = await walk(h, (r) => !r.enforces); + expect(rows).toEqual(allTrue(rows)); + }); + + it('the healthy package is untouched — disable hid the right things, not everything', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + expect(await listNames(h.protocol, 'object')).toContain('healthy_widget'); + expect(await servedFor(h.protocol, 'app', OTHER_PKG)).toBeGreaterThan(0); + expect(() => (h.protocol as any).assertObjectRegistered('healthy_widget')).not.toThrow(); + }); + + it('the data-plane refusal is loud: OBJECT_PACKAGE_DISABLED / 404, not a bare not-found', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + let err: any; + try { + (h.protocol as any).assertObjectRegistered('demo_widget'); + } catch (e) { + err = e; + } + + // ADR-0112: the assertion is `code` + `status`, never "it threw" — a bare + // OBJECT_NOT_FOUND would pass "it threw" while telling the caller the wrong + // thing about a package that is merely switched off. + expect(err).toBeDefined(); + expect(err.code).toBe('OBJECT_PACKAGE_DISABLED'); + expect(err.status).toBe(404); + expect(err.object).toBe('demo_widget'); + // The code must be DISTINGUISHABLE from the genuinely-absent case, which is + // the whole reason it is its own code. + expect(err.code).not.toBe('OBJECT_NOT_FOUND'); + // …and the message must name the remedy, not just the state. + expect(String(err.message)).toMatch(/re-enable/i); + }); + + it('an object that is genuinely absent still answers OBJECT_NOT_FOUND', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + let err: any; + try { + (h.protocol as any).assertObjectRegistered('never_existed'); + } catch (e) { + err = e; + } + expect(err?.code).toBe('OBJECT_NOT_FOUND'); + expect(err?.status).toBe(404); + }); + + it('re-enable restores every reader — disable is reversible and destroys nothing', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + + const storedBefore = h.rows.size; + + h.registry.disablePackage(PKG); + h.registry.enablePackage(PKG); + + const rows = await walk(h, () => true); + expect(rows).toEqual(allTrue(rows)); + // The card's honest negative, pinned: the disable/enable cycle wrote and + // destroyed no metadata rows. + expect(h.rows.size).toBe(storedBefore); + }); + + it('the `package` type is never filtered — otherwise disable would be irreversible', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + // The Packages page must keep listing a disabled package, or there is no + // surface left to re-enable it from. + const pkgs = h.registry.getAllPackages().map((p: any) => p?.manifest?.id ?? p?.id); + expect(pkgs).toContain(PKG); + }); +}); + +describe('#7557 (A) — isObjectPackageDisabled resolves names the way the READ path does', () => { + it('answers for a bare short name, the spelling the data plane dispatches on', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + + expect(h.registry.isObjectPackageDisabled('demo_widget')).toBe(false); + h.registry.disablePackage(PKG); + expect(h.registry.isObjectPackageDisabled('demo_widget')).toBe(true); + expect(h.registry.isObjectPackageDisabled('healthy_widget')).toBe(false); + }); + + it('an unknown name is not "disabled" — that case belongs to OBJECT_NOT_FOUND', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + expect(h.registry.isObjectPackageDisabled('never_existed')).toBe(false); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index c049d375f6..f8c7f2ac3d 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -2157,6 +2157,30 @@ export class SchemaRegistry { return pkg?.enabled === false || pkg?.status === 'disabled'; } + /** + * [#7557] Whether `name` addresses an object whose OWNING package is + * disabled — the object-shaped question {@link isPackageDisabled} cannot + * answer on its own, because an object's `_packageId` is a decoration + * applied by {@link getAllObjects} and is absent from a bare + * {@link getObject} read. + * + * Name resolution deliberately reuses {@link resolveObjectKey}, the same + * name→FQN half the READ path uses. That is the #6808 lesson applied to + * enforcement: a gate that resolved names its own way could refuse an entry + * `getObject` never serves — or, worse, wave through the one it does. One + * resolution, so the read and the refusal cannot disagree about which + * contributor entry a bare name addresses. + * + * Asks about the OWNER (`ownership: 'own'`), not about every contributor: an + * overlay contributed by a disabled package must not take the object it + * decorates offline with it. + */ + isObjectPackageDisabled(name: string): boolean { + const fqn = this.resolveObjectKey(name); + if (fqn === undefined) return false; + return this.isPackageDisabled(this.getObjectOwner(fqn)?.packageId); + } + /** * Get all registered metadata types (Kinds) */ diff --git a/packages/runtime/src/domains/packages-uninstall-envelope.test.ts b/packages/runtime/src/domains/packages-uninstall-envelope.test.ts new file mode 100644 index 0000000000..f27da3c55c --- /dev/null +++ b/packages/runtime/src/domains/packages-uninstall-envelope.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7557 (B, envelope half) — `DELETE /packages/:id` must not wrap a failed + * uninstall in a 200. + * + * Reproduced on `origin/main` against a real running server (dev showcase app, + * a runtime-installed package with three package-bound `sys_metadata` rows): + * + * DELETE /api/v1/packages/com.repro.b + * -> HTTP 200 + * { "success": true, + * "data": { "success": true, "registryRemoved": true, + * "persisted": { "success": false, "deletedCount": 0, + * "failedCount": 0, … } } } + * …and the three `sys_metadata` rows were still there afterwards. + * + * Two independent problems live in that one line, and this file pins only the + * first: + * + * 1. **The envelope** (fixed here). The handler stated `success: true` + * unconditionally and forwarded the protocol's own `success: false` + * underneath it, so the status line and the payload disagreed. Any caller + * that reads the status — which is every caller that does not go digging + * into `persisted` — recorded an uninstall that had not happened. + * + * 2. **The persistence** (NOT fixed here — reported for transfer). The + * protocol's own `deletePackage` found ZERO rows to delete while three + * package-bound rows demonstrably existed, so it returned + * `deletedCount: 0, failedCount: 0` and the rows survived. That is a + * `sys_metadata` query defect in `@objectstack/metadata-protocol`, one + * layer below this handler; patching it from here would be papering over + * the producer. See the issue thread for the evidence. + * + * Because (2) is untouched, the failure this file pins is specifically the + * PER-ITEM one (`failedCount > 0`) — the shape where the protocol itself + * reports that rows resisted deletion. The silent-miss shape from (2) has + * `failedCount: 0` and legitimately still reads as success at this layer; it + * stops being a lie only once the producer stops missing rows. + * + * ## Why the rule is spelled the way it is + * + * `DELETE /packages/:id` has TWO doors: this dispatcher handler and the + * direct-mount REST registrar (`packages/rest/src/package-routes.ts`), which + * shadows it only when a `package` service is registered. The REST door already + * answered `400 PACKAGE_DELETE_PARTIAL` for per-item failures; this one + * answered 200. Two doors to one route answering differently is how the + * divergence arrived, so the rule here is copied from that door deliberately — + * including its carve-out that ZERO metadata rows is still a SUCCESSFUL + * uninstall (a runtime-registered package that never published metadata has + * nothing in `sys_metadata`), which is why the predicate is `failedCount > 0` + * and not `!persisted.success`. + * + * --------------------------------------------------------------------------- + * Reverse verification, direction predicted BEFORE running + * --------------------------------------------------------------------------- + * Restoring the old unconditional `deps.success({ success: true, … })` turns + * the two failure cases below red (200 where 400 is required) and leaves every + * success case green — the success cases assert a 200 the old code produced + * unconditionally, so they cannot distinguish the versions on their own. That + * asymmetry is intended: the success cases exist to constrain the fix from + * over-reaching into "any non-empty `failed` array 400s", which would break the + * never-published-metadata uninstall. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const authed = (caps: string[] = ['manage_metadata']): any => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: caps }, +}); + +function make(deletePackageResult: any, opts: { registryRemoved?: boolean } = {}) { + const registry = { + getAllPackages: vi.fn().mockReturnValue([]), + getPackage: vi.fn().mockReturnValue({ id: 'pkg-a', manifest: { id: 'pkg-a', name: 'A' } }), + uninstallPackage: vi.fn().mockReturnValue(opts.registryRemoved ?? true), + }; + const protocol = { + deletePackage: vi.fn().mockResolvedValue(deletePackageResult), + }; + const kernel: any = { + context: { + getService: (name: string) => + name === 'objectql' ? { registry } + : name === 'protocol' ? protocol + : null, + }, + }; + return { dispatcher: new HttpDispatcher(kernel), registry, protocol }; +} + +const uninstall = (d: HttpDispatcher) => + d.handlePackages('/pkg-a', 'DELETE', undefined, {}, authed()); + +describe('#7557 (B) — a failed uninstall is reported as a failure, not wrapped in a 200', () => { + it('per-item failures answer 400 PACKAGE_DELETE_PARTIAL, not 200', async () => { + const { dispatcher } = make({ + success: false, + deletedCount: 2, + failedCount: 1, + deleted: [{ type: 'object', name: 'a', state: 'active' }], + failed: [{ type: 'object', name: 'stuck', error: 'locked', code: 'ITEM_LOCKED' }], + cleanups: [], + }); + + const r = await uninstall(dispatcher); + + // THE LINE THAT WAS RED: this was a 200. + expect(r.response?.status).toBe(400); + expect(r.response?.body?.error?.code).toBe('PACKAGE_DELETE_PARTIAL'); + // The status and the payload must now AGREE — the whole defect was that + // they did not. + expect(r.response?.body?.success).toBe(false); + }); + + it('the per-item detail rides on the failure, so a caller learns WHICH item stuck', async () => { + const { dispatcher } = make({ + success: false, + deletedCount: 0, + failedCount: 1, + deleted: [], + failed: [{ type: 'object', name: 'stuck', error: 'locked', code: 'ITEM_LOCKED' }], + cleanups: [{ name: 'security.package-permissions', success: false, removed: 0 }], + }); + + const r = await uninstall(dispatcher); + const details = r.response?.body?.error?.details ?? r.response?.body?.error; + + expect(r.response?.status).toBe(400); + expect(JSON.stringify(details)).toContain('stuck'); + // Cleanup outcomes are a SECURITY signal (a failed permission revocation + // is a ghost grant) — they must survive the failure path, not only the + // success path. + expect(JSON.stringify(details)).toContain('security.package-permissions'); + }); + + it('an all-rows-failed uninstall is a 400, NOT the 404 its zero deletedCount would imply', async () => { + // `deletedCount === 0` is also the not-found shape. Answering "not + // found" for a package whose rows are demonstrably present and + // demonstrably stuck is the same lie one layer over, so the failure + // check has to come first. + const { dispatcher } = make({ + success: false, + deletedCount: 0, + failedCount: 3, + deleted: [], + failed: [ + { type: 'object', name: 'a', error: 'locked' }, + { type: 'object', name: 'b', error: 'locked' }, + { type: 'view', name: 'c', error: 'locked' }, + ], + cleanups: [], + }, { registryRemoved: false }); + + const r = await uninstall(dispatcher); + + expect(r.response?.status).toBe(400); + expect(r.response?.status).not.toBe(404); + expect(r.response?.body?.error?.code).toBe('PACKAGE_DELETE_PARTIAL'); + }); + + it('a clean uninstall still answers 200', async () => { + const { dispatcher } = make({ + success: true, deletedCount: 3, failedCount: 0, deleted: [], failed: [], cleanups: [], + }); + + const r = await uninstall(dispatcher); + + expect(r.response?.status).toBe(200); + expect(r.response?.body?.success).toBe(true); + }); + + it('zero metadata rows is still a SUCCESSFUL uninstall — the carve-out, kept', async () => { + // A runtime-registered package that never published metadata: nothing + // in `sys_metadata`, so `deletePackage` reports `success: false` merely + // because `deleted.length === 0`. The registry removal is the real + // outcome here, and it succeeded. Failing this would break every + // uninstall of a package that ships no metadata — which is why the + // predicate is `failedCount > 0`, not `!persisted.success`. + const { dispatcher } = make({ + success: false, deletedCount: 0, failedCount: 0, deleted: [], failed: [], cleanups: [], + }, { registryRemoved: true }); + + const r = await uninstall(dispatcher); + + expect(r.response?.status).toBe(200); + }); + + it('an unknown package — nothing in the registry, nothing persisted — still 404s', async () => { + const { dispatcher } = make({ + success: false, deletedCount: 0, failedCount: 0, deleted: [], failed: [], cleanups: [], + }, { registryRemoved: false }); + + const r = await uninstall(dispatcher); + + expect(r.response?.status).toBe(404); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 02ae1263be..e91093af2a 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -790,6 +790,46 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin } const deletedCount = (persisted as any)?.deletedCount ?? 0; + const failedCount = (persisted as any)?.failedCount ?? 0; + + // [#7557] A failed persistence used to ride inside a 200: this + // handler stated `success: true` unconditionally and forwarded the + // protocol's own `{ success: false, deletedCount: 0 }` underneath + // it, so the status line and the payload disagreed and every caller + // that checks the status (which is every caller that does not go + // digging into `persisted`) recorded an uninstall that had not + // happened. + // + // The failure rule is the one the REST twin of this route already + // uses (`packages/rest/src/package-routes.ts`), stated the same way + // on purpose — DELETE /packages/:id has TWO doors (this dispatcher + // and the direct-mount REST registrar, which shadows it only when a + // `package` service is registered), and two doors answering one + // request differently is how this divergence arrived. Zero metadata + // rows is still a successful uninstall — a runtime-registered + // package that never published metadata has nothing in + // `sys_metadata` — so only PER-ITEM failures make it a failure. + // + // Checked BEFORE the not-found test below, which asks + // `deletedCount === 0`: an uninstall where every row failed to + // delete also has `deletedCount === 0`, and answering "not found" + // for a package whose rows are demonstrably present and demonstrably + // stuck is the same lie one layer over. + if (failedCount > 0) { + return { + handled: true, + response: deps.error( + `Deleting ${id} left ${failedCount} item(s) behind.`, + 400, + { + code: 'PACKAGE_DELETE_PARTIAL', + registryRemoved, + failed: (persisted as any)?.failed, + cleanups: (persisted as any)?.cleanups, + }, + ), + }; + } if (!registryRemoved && deletedCount === 0) { return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; } diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index ae298a36e4..4e22b4f594 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -175,6 +175,12 @@ export const ERROR_CODE_LEDGER = { 'EXPIRED_OR_REVOKED', // share link 'INVALID_OR_EXPIRED', // share-link token 'NEEDS_PASSWORD', // share link requires a password + // [#7557] `DELETE /packages/:id` on the DISPATCHER door — per-item failures + // used to ride inside a 200 with a hardcoded `success: true`. Second + // EMITTER of the code `@objectstack/rest` already registers for the + // direct-mount door of the same route; the two doors now state the same + // failure the same way. Provenance, not identity (see above). + 'PACKAGE_DELETE_PARTIAL', 'PROJECT_MEMBERSHIP_REQUIRED', 'RECORD_GONE', // share link resolves but the record was deleted 'ROUTE_NOT_FOUND', @@ -275,7 +281,8 @@ export const ERROR_CODE_LEDGER = { 'NOT_CREATABLE', 'NOT_OVERRIDABLE', 'OBJECT_OVERLAY_PACKAGE_MISMATCH', // [ADR-0029 D9.9] object overlay row bound to a package that does not own the object - 'ROLLED_BACK', // atomic data-batch row was written, then undone by the batch rollback (#4793) + 'OBJECT_PACKAGE_DISABLED', // [#7557] object is registered but its owning package is disabled — data plane refuses rather than serving rows + 'ROLLED_BACK', // atomic data-batch row was written, then undone by the batch rollback (#4793) 'UNSUPPORTED_QUERY_PARAM', 'VALIDATION_FAILED', 'VERSION_NOT_FOUND',