diff --git a/.changeset/declare-search-clone-response-contracts.md b/.changeset/declare-search-clone-response-contracts.md new file mode 100644 index 0000000000..2fb9ee7dea --- /dev/null +++ b/.changeset/declare-search-clone-response-contracts.md @@ -0,0 +1,39 @@ +--- +'@objectstack/spec': minor +'@objectstack/client': minor +--- + +Declare the `search` and `data.clone` route response contracts, and bind the SDK to them (#11924) + +Two of the four SDK routes #8140 had to leave as deliberate `Promise` holes now have real +contracts. Their shapes were always stable and server-produced — they were declared inline on the +implementation (`@objectstack/metadata-protocol`'s `searchAll` / `cloneData`), reachable from no +spec export — and per the maintainer ruling on #11924 they are now declared in `@objectstack/spec` +exactly as produced, with conformance coverage on both the producer and the mounted route (#3877: +no route-ledger `responseSchema` row is filled without conformance coverage; both rows are filled +as part of this change). + +**`@objectstack/spec` (additive):** + +- `SearchAllResponseSchema` / `SearchAllHitSchema` (+ `SearchAllResponse` / `SearchAllHit` types, + `@objectstack/spec/api`) — the WHOLE body of `GET /api/v1/search`, answered bare: + `{ query, hits, totalObjects, totalHits, truncated }` with hits of + `{ object, id, title, snippet?, record }`. ⚠️ Deliberately distinct from `SearchResult` / + `SearchHit` in `@objectstack/spec/contracts`, which type the per-object `ISearchService.search` + (hits of `score` / `document`) — reaching for that same-named neighbour was the near-miss trap + #8140 left a compile-time guard against, and the guard stands unchanged. +- `CloneDataResponseSchema` (+ `CloneDataResponse`, `@objectstack/spec/api`) — the whole 201 body + of `POST /data/:object/:id/clone`: `{ object, id, sourceId, record }`, `CreateDataResponse`'s + structural sibling plus `sourceId` (`id` is the NEW record's, `sourceId` the copied record's). + No `droppedFields` member — unlike `createData`, the clone producer emits none. + +**`@objectstack/client` (return-type narrowing, same nature as the #8140 batch):** `search` is +now `Promise` and `data.clone` is `clone(…): Promise>` +(a new exported interface mirroring `CloneDataResponseSchema`, beside `CreateDataResult`). Nothing +changes at runtime — no request, response, unwrapping or error path is touched — but code that +compiled against the previous `any` returns (arbitrary property reads, assignments to unrelated +types) can stop compiling; in particular a result assigned to the per-object `SearchResult` is now +refused at compile time, which is the trap the erasure used to hide. + +The `automation.create` / `automation.update` pair is explicitly NOT declared here — it returns to +the decision inbox with a consumer-survey reading per the same ruling. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 536a3112aa..c3e4e11841 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -12,8 +12,8 @@ description: Protocol protocol schemas ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -340,6 +340,20 @@ const result = AiAgentCapabilitiesSchema.parse(data); | **reason** | `string` | optional | Reason if denied | +--- + +## CloneDataResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The object name. | +| **id** | `string` | ✅ | The ID of the newly created clone. | +| **sourceId** | `string` | ✅ | The ID of the record the clone was copied from. | +| **record** | `Record` | ✅ | The created clone, including server-generated fields. Engine-owned values (injected system/audit columns, autonumbers, computed formula/summary fields) are re-derived by the insert path rather than copied from the source; caller-supplied `overrides` win over copied values. | + + --- ## CreateAiConversationRequest @@ -1484,6 +1498,36 @@ List packages response | **message** | `string` | optional | | +--- + +## SearchAllHit + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Name of the object the hit belongs to. | +| **id** | `string` | ✅ | ID of the matched record. | +| **title** | `string` | ✅ | Display title for the hit, resolved in order: the object's `titleFormat` template → the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → conventional name fields → first/last name → the record ID as a string. | +| **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match, #7643) — absence is a correct answer, not a miss. | +| **record** | `Record` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". | + + +--- + +## SearchAllResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **query** | `string` | ✅ | The TRIMMED query text the sweep ran with — empty string when the request carried none (the no-scan short-circuit). | +| **hits** | `{ object: string; id: string; title: string; snippet?: string; … }[]` | ✅ | Matched records across objects, in scan order, capped at the overall `limit` (default 20, max 100) with at most `perObject` (default 5, max 25) per object. | +| **totalObjects** | `number` | ✅ | Number of objects the sweep actually SCANNED (searchable, API-enabled, with a resolvable search-field set) — not the number of objects with hits. An object whose table was never provisioned is skipped and not counted (#8896). | +| **totalHits** | `number` | ✅ | Number of hits returned — equals `hits.length`. NOT a deployment-wide total-match count: matches beyond `limit` / `perObject` are not counted. | +| **truncated** | `boolean` | ✅ | True when the sweep stopped at the overall `limit` — more matches may exist beyond the returned set. | + + --- ## SetPresenceRequest diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index c6caaf155c..49eb4e6576 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1588 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 29 | 420 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 29 | 423 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 288 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 152 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1585** | 14 protocol modules | +| **Total** | **199** | **1588** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **29 pages, 420 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **29 pages, 423 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -86,7 +86,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`odata.zod.ts`](/docs/references/api/odata) | `ODataConfig`, `ODataError`, `ODataFilterFunction`, `ODataMetadata`, `ODataQuery`, `ODataResponse` | | [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `GetInstalledPackageResponse`, `ListInstalledPackagesRequest`, `ListInstalledPackagesResponse`, `PackageApiErrorCode`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageRollbackResponse`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `HandlerStatus`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `RouteCoverageEntry`, `RouteCoverageReport`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index e2de913d9b..d5dc8b5e06 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 413 | +| `api/` | 416 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 | diff --git a/packages/client/exported-any-returns.json b/packages/client/exported-any-returns.json index 6e1b528343..aeeefea169 100644 --- a/packages/client/exported-any-returns.json +++ b/packages/client/exported-any-returns.json @@ -62,8 +62,6 @@ "ObjectStackClient.auth.accounts.unlink": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.automation.trigger": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.automation.create": "#11924 — DELIBERATE `Promise`: `POST /automation` ends `deps.success(body)`, echoing the caller's own unvalidated bytes, and `IAutomationService.registerFlow` returns nothing, so the service contract has no return shape to relay. This needs a DECISION (keep echoing, or answer the registered `FlowParsed`), not an annotation.", - "ObjectStackClient.automation.update": "#11924 — DELIBERATE `Promise`: `PUT /automation/:name` ends `deps.success(definition)` where `definition = body.definition ?? body`. Same missing contract as `automation.create`, and the two should be answered together since they are one route class.", - "ObjectStackClient.search": "#11924 — DELIBERATE `Promise`: `GET /api/v1/search` relays `protocol.searchAll(...)` verbatim and that shape is declared only as an inline annotation in `@objectstack/metadata-protocol`, which `packages/client` does not depend on. ⛔ `SearchResult` from `@objectstack/spec/contracts` is the VERIFIED near-miss: it contracts the per-object `ISearchService.search`, whose hits carry `score`/`document`, not this route's `object`/`title`/`snippet`/`record`. Binding it would typecheck and ship a false declaration; `return-type-precision.test.ts` holds a compile-time guard on that mismatch.", - "ObjectStackClient.data.clone": "#11924 — DELIBERATE `Promise`: `POST /data/:object/:id/clone` answers `{ object, id, sourceId, record }`, stable and server-produced but declared in no spec module. It is the structural sibling of this package's own `CreateDataResult` plus `sourceId`, which is exactly what makes minting that equivalence inside a consumer the wrong move — it would create an undeclared second contract." + "ObjectStackClient.automation.update": "#11924 — DELIBERATE `Promise`: `PUT /automation/:name` ends `deps.success(definition)` where `definition = body.definition ?? body`. Same missing contract as `automation.create`, and the two should be answered together since they are one route class." } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 65548bfe4b..e71a772b67 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -77,6 +77,9 @@ import { CrossObjectBatchOperation, CrossObjectBatchRequest, CrossObjectBatchResponse, + // [#11924] The GLOBAL cross-object search body — NOT the per-object + // `SearchResult` in `@objectstack/spec/contracts` (the #8140 near-miss trap). + SearchAllResponse, } from '@objectstack/spec/api'; import type { ApprovalRequestRow, @@ -374,6 +377,20 @@ export interface CreateDataResult { droppedFields?: DroppedFieldsEvent[]; } +/** + * Spec: CloneDataResponseSchema (#11924) + * + * `CreateDataResult`'s structural sibling plus `sourceId` — `id` names the NEW + * record, `sourceId` the record it was copied from. No `droppedFields`: unlike + * `createData`, the clone producer emits no write-observability event. + */ +export interface CloneDataResult { + object: string; + id: string; + sourceId: string; + record: T; +} + /** Spec: UpdateDataResponseSchema */ export interface UpdateDataResult { object: string; @@ -4238,28 +4255,27 @@ export class ObjectStackClient { * object the caller can read. 501s on kernels without `searchAll`. * (#3587 gap closure) * - * [#8140] ⛔ `Promise` is DELIBERATE — a missing CONTRACT, not a - * missing annotation. The response shape - * (`{ query, hits, totalObjects, totalHits, truncated }`) is declared - * INLINE on the implementation (`@objectstack/metadata-protocol`'s - * `searchAll`), not in `@objectstack/spec`, and `metadata-protocol` is not - * a dependency of this package — so there is nothing reachable to bind. - * ⚠️ `SearchResult` (`@objectstack/spec/contracts`) is a NEAR-MISS trap: it - * types the per-object `ISearchService.search`, whose `hits` carry + * [#11924] Bound to `SearchAllResponse` (`@objectstack/spec/api`), the + * contract #8140 had to leave missing: the route answers the whole body + * BARE, relaying `searchAll`'s `{ query, hits, totalObjects, totalHits, + * truncated }` verbatim, and conformance coverage on both the producer and + * the mount is what entitles the declaration (#3877). + * ⚠️ `SearchResult` (`@objectstack/spec/contracts`) is STILL the near-miss + * trap: it types the per-object `ISearchService.search`, whose `hits` carry * `score`/`document`, not this route's `object`/`title`/`snippet`/`record`. - * Binding it would typecheck and be false. + * `return-type-precision.test.ts` pins the mismatch. */ search = async ( q: string, opts?: { objects?: string[]; limit?: number; perObject?: number }, - ): Promise => { + ): Promise => { const params = new URLSearchParams(); params.set('q', q); if (opts?.objects?.length) params.set('objects', opts.objects.join(',')); if (opts?.limit !== undefined) params.set('limit', String(opts.limit)); if (opts?.perObject !== undefined) params.set('perObject', String(opts.perObject)); const res = await this.fetch(`${this.baseUrl}/api/v1/search?${params.toString()}`); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }; /** @@ -5136,20 +5152,22 @@ export class ObjectStackClient { * `overrides` are applied on top of the copied values — e.g. a new name * or a cleared unique field. (#3587 gap closure) * - * [#8140] ⛔ `Promise` is DELIBERATE — a missing CONTRACT. The route - * returns `{ object, id, sourceId, record }`, produced inline by - * `@objectstack/metadata-protocol`'s `cloneData` and declared nowhere in - * `@objectstack/spec`. It is the structural sibling of this file's own - * `CreateDataResult` plus `sourceId`, but writing that equivalence - * here would mint an undeclared contract in a consumer. - */ - clone: async (object: string, id: string, overrides?: Record): Promise => { + * [#11924] Bound to `CloneDataResult`, the declared mirror of + * `CloneDataResponseSchema` (`@objectstack/spec/api`) — the contract #8140 + * had to leave missing. The route answers `{ object, id, sourceId, + * record }` BARE with 201, relaying `cloneData` verbatim; `id` is the NEW + * record's, `sourceId` the copied record's. The caller-supplied generic + * follows its `data.*` siblings (`T = any` — the payload is the caller's + * object shape); conformance coverage on both the producer and the mount + * is what entitles the declaration (#3877). + */ + clone: async (object: string, id: string, overrides?: Record): Promise> => { const route = this.getRoute('data'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(id)}/clone`, { method: 'POST', body: JSON.stringify(overrides ? { overrides } : {}) }, ); - return this.unwrapResponse(res); + return this.unwrapResponse>(res); }, /** @@ -5963,6 +5981,12 @@ export type { CrossObjectBatchOperation, CrossObjectBatchRequest, CrossObjectBatchResponse, + // [#11924] The global cross-object search body + hit — the RIGHT names to + // reach for on `client.search` (the same-named `SearchResult` in + // `@objectstack/spec/contracts` types the per-object `ISearchService.search` + // and is the wrong shape for this route — the #8140 near-miss trap). + SearchAllHit, + SearchAllResponse, } from '@objectstack/spec/api'; // Approval runtime types (ADR-0019) — surfaced so SDK consumers can type the diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 79d9211e40..b7da7996f4 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -33,6 +33,8 @@ import { describe, it, expect, expectTypeOf, vi } from 'vitest'; import { ObjectStackClient, ScopedProjectClient } from './index'; +import type { CloneDataResult } from './index'; +import type { SearchAllResponse } from '@objectstack/spec/api'; import type { AutomationResult, DelegableScope, @@ -123,6 +125,17 @@ export async function returnTypePrecisionPins(): Promise { total: number; }>(); + // ── [#11924] the two contracts #8140 had to leave missing ───────────── + // `search` and `data.clone` were deliberate `Promise` holes: their + // shapes were declared inline on the implementation, reachable from no + // spec export. #11924 authored `SearchAllResponseSchema` / + // `CloneDataResponseSchema` in `@objectstack/spec/api` (with conformance + // coverage on producer and mount — #3877), so the bindings now exist to + // pin. `search` binds the WHOLE body (the route answers bare); + // `data.clone` follows its `data.*` siblings' caller-supplied generic. + expectTypeOf(await client.search('acme')).toEqualTypeOf(); + expectTypeOf(await client.data.clone('lead', 'rec_1')).toEqualTypeOf>(); + // ── shape class 7: z.input vs z.infer, decided by the CONTRACT ───────── // `ISecurityService.explain` declares `Promise` (the // `z.input` form) and the route relays it with `res.json(decision)` — no @@ -146,10 +159,24 @@ export async function returnTypePrecisionPins(): Promise { // @ts-expect-error a flow definition is not an execution log const wrongFlow: ExecutionLog = await client.automation.getFlow('flow_a'); + // [#11924] The near-miss trap, now pinned at the BINDING too: the global + // search body must not satisfy the per-object `SearchResult` contract. + // Before the binding this suppression was unused (`any` satisfied it). + // @ts-expect-error the global-search hits carry object/title/snippet/record, not score/document + const wrongSearch: SearchResult = await client.search('acme'); + + // [#11924] And the family distinction: a create's body is NOT a clone's — + // `CreateDataResult` lacks `sourceId`, so reading a clone through the + // create type would compile only against an erased return. + // @ts-expect-error a create result carries no `sourceId` + const wrongClone: CloneDataResult = await client.data.create('lead', {}); + void wrongScope; void wrongTables; void wrongReport; void wrongFlow; + void wrongSearch; + void wrongClone; } /** @@ -159,10 +186,13 @@ export async function returnTypePrecisionPins(): Promise { * is the WRONG type for it — it contracts the per-object * `ISearchService.search`, whose hits carry `score` / `document`, while the * cross-object route answers hits of `object` / `id` / `title` / `snippet` / - * `record`. Binding it would compile and be false. `client.search` therefore - * stays `Promise` deliberately (a missing contract, not a missing - * annotation) and this guard exists so the next sweep does not "finish" the - * card by reaching for the same-named neighbour. + * `record`. Binding it would compile and be false. [#11924] `client.search` + * is now bound to the RIGHT contract (`SearchAllResponse`, + * `@objectstack/spec/api` — authored with conformance coverage), but the trap + * itself is unchanged: both exports still exist one import apart, so this + * guard stays exactly as #8140 left it, and the binding-side twin above + * (`wrongSearch`) pins the same mismatch in the direction the new declaration + * makes expressible. */ type GlobalSearchHit = { object: string; diff --git a/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts b/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts new file mode 100644 index 0000000000..0d3262496d --- /dev/null +++ b/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#11924] `searchAll` / `cloneData` conform to their newly declared response +// schemas — the PRODUCER half of the conformance coverage that entitles the +// route-ledger rows for `GET /api/v1/search` and +// `POST /data/:object/:id/clone` to name a `responseSchema` at all (#3877: +// ⛔ no row filled without conformance coverage; `packages/rest` carries the +// relay half against the real mounts). +// +// Both routes answer BARE — the REST handlers relay these methods' returns +// verbatim (`res.json(result)` / `res.status(201).json(result)`) — so what is +// measured here IS the wire body. The maintainer ruling (2026-08-25) is +// "declare them as they actually are": the schema is judged against what the +// real producer emits over a fixture engine, not against a hand-built body +// shaped to match the declaration. +// +// The two assertions per surface are deliberately different questions, the +// same split as `discovery-schema-conformance.test.ts`: +// +// * `safeParse` judges VALUES — required keys present, declared types hold; +// * the key-set subset check judges KEYS — nothing is emitted that the spec +// never declared (a plain `z.object` STRIPS unknown keys, so the parse +// alone is blind in exactly that direction). +// +// Key-set checks run on the JSON round-trip of the return value, because that +// is the body a caller holds: `searchAll` builds hits as +// `{ …, snippet: }`, and JSON serialization is what +// drops the undefined-valued key on the wire. + +import { describe, it, expect, vi } from 'vitest'; +import { CloneDataResponseSchema, SearchAllHitSchema, SearchAllResponseSchema } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** What a caller actually holds after the REST relay: the JSON body. */ +function overTheWire(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +function declaredKeys(schema: unknown): Set { + return new Set(Object.keys((schema as { shape: Record }).shape)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// searchAll — the real sweep over a fixture object set +// ───────────────────────────────────────────────────────────────────────────── + +const text = (name: string) => ({ name, type: 'text' }); + +/** + * A protocol over a fixed object set, rows served verbatim by `find` (the + * engine double filters nothing — recall is the engine's contract, pinned in + * `protocol.search-case-fold.test.ts`; this file is about the emitted SHAPE). + */ +function makeSearchProtocol() { + const lead = { + name: 'lead', + fields: { name: text('name'), notes: text('notes') }, + }; + const rows = [ + // Searchable column contains the term → `snippet` present. + { id: 'lead_1', name: 'Acme Industrial', notes: 'acme is evaluating the pilot' }, + // No searchable column contains the term → `snippet` key serializes away. + { id: 'lead_2', name: 'Beta Corp', notes: 'no matching text here' }, + ]; + const engine = { + registry: { + getObject: (n: string) => (n === 'lead' ? lead : undefined), + getAllObjects: () => [lead], + }, + find: vi.fn(async () => rows), + }; + return new ObjectStackProtocolImplementation(engine as never); +} + +describe('[#11924] searchAll conforms to SearchAllResponseSchema', () => { + it('emits a body that parses, as returned and over the wire', async () => { + const body = await makeSearchProtocol().searchAll({ q: 'acme' }); + + const raw = SearchAllResponseSchema.safeParse(body); + expect(raw.error?.issues ?? []).toEqual([]); + expect(raw.success).toBe(true); + + const wire = SearchAllResponseSchema.safeParse(overTheWire(body)); + expect(wire.error?.issues ?? []).toEqual([]); + expect(wire.success).toBe(true); + + // The members the declaration names carry the sweep's real answers. + expect(body.query).toBe('acme'); + expect(body.hits.length).toBe(2); + expect(body.totalObjects).toBe(1); + expect(body.totalHits).toBe(body.hits.length); + expect(body.truncated).toBe(false); + }); + + it('emits no top-level key the spec does not declare', async () => { + const body = overTheWire(await makeSearchProtocol().searchAll({ q: 'acme' })); + const declared = declaredKeys(SearchAllResponseSchema); + const undeclared = Object.keys(body).filter((k) => !declared.has(k)); + expect(undeclared, 'keys emitted by searchAll that SearchAllResponseSchema never declares').toEqual([]); + }); + + it('emits no hit-level key the spec does not declare — and `snippet` is genuinely conditional', async () => { + const body = overTheWire(await makeSearchProtocol().searchAll({ q: 'acme' })); + const declared = declaredKeys(SearchAllHitSchema); + for (const hit of body.hits) { + const undeclared = Object.keys(hit).filter((k) => !declared.has(k)); + expect(undeclared, `keys on hit ${hit.id} that SearchAllHitSchema never declares`).toEqual([]); + } + // Both branches of the optional member, measured on one body: the row + // whose searchable column contains the term carries an excerpt; the + // row matched without one serializes the key away (#7643 — absence is + // a correct answer, not a miss). + const withSnippet = body.hits.find((h: { id: string }) => h.id === 'lead_1'); + const withoutSnippet = body.hits.find((h: { id: string }) => h.id === 'lead_2'); + // Term matching is case-folded but the excerpt preserves SOURCE casing + // — 'acme' matched the `name` column's 'Acme Industrial'. + expect(withSnippet?.snippet?.toLowerCase()).toContain('acme'); + expect(withoutSnippet).toBeDefined(); + expect(Object.keys(withoutSnippet!)).not.toContain('snippet'); + }); + + it('the blank-query short-circuit parses too — the one body built by a different return', async () => { + // `searchAll` has exactly two return statements; this is the other one. + const body = await makeSearchProtocol().searchAll({ q: ' ' }); + const parsed = SearchAllResponseSchema.safeParse(body); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(body).toEqual({ query: '', hits: [], totalObjects: 0, totalHits: 0, truncated: false }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// cloneData — the real clone over a fixture engine +// ───────────────────────────────────────────────────────────────────────────── + +function makeCloneProtocol() { + const customer = { + name: 'customer', + fields: { + name: text('name'), + notes: text('notes'), + }, + }; + const source = { id: 'cus_1', name: 'Acme Industrial', notes: 'source row' }; + const inserts: Array<{ object: string; data: Record }> = []; + const engine = { + registry: { getObject: (n: string) => (n === 'customer' ? customer : undefined) }, + findOne: vi.fn(async () => ({ ...source })), + insert: vi.fn(async (object: string, data: Record) => { + inserts.push({ object, data }); + return { id: 'cus_2', ...data, created_at: '2026-08-25T00:00:00Z' }; + }), + }; + return { p: new ObjectStackProtocolImplementation(engine as never), inserts }; +} + +describe('[#11924] cloneData conforms to CloneDataResponseSchema', () => { + it('emits a body that parses, as returned and over the wire', async () => { + const { p } = makeCloneProtocol(); + const body = await p.cloneData({ object: 'customer', id: 'cus_1', overrides: { name: 'Acme Copy' } }); + + const raw = CloneDataResponseSchema.safeParse(body); + expect(raw.error?.issues ?? []).toEqual([]); + expect(raw.success).toBe(true); + + const wire = CloneDataResponseSchema.safeParse(overTheWire(body)); + expect(wire.error?.issues ?? []).toEqual([]); + expect(wire.success).toBe(true); + + // The members the declaration names carry the clone's real answers: + // `id` is the NEW record's, `sourceId` the copied record's — the pair + // whose distinction is the whole reason this is not CreateDataResponse. + expect(body.object).toBe('customer'); + expect(body.id).toBe('cus_2'); + expect(body.sourceId).toBe('cus_1'); + expect(body.record).toMatchObject({ id: 'cus_2', name: 'Acme Copy', notes: 'source row' }); + }); + + it('emits no top-level key the spec does not declare — in particular no `droppedFields`', async () => { + const { p } = makeCloneProtocol(); + const body = overTheWire(await p.cloneData({ object: 'customer', id: 'cus_1' })); + const declared = declaredKeys(CloneDataResponseSchema); + const undeclared = Object.keys(body).filter((k) => !declared.has(k)); + expect(undeclared, 'keys emitted by cloneData that CloneDataResponseSchema never declares').toEqual([]); + // Pinned by name because the declaration deliberately omits it: unlike + // `createData`, this producer reports no write-observability event, and + // the schema must not promise one it cannot measure (#11924). + expect(body).not.toHaveProperty('droppedFields'); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 4c05f0f3e3..7ba904c505 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -253,7 +253,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ // its `note`, and re-point the platform-checklist item that this comment's // counterpart clause sends to the platform path // (`access-security.capability-declaration-lifecycle`). - { route: 'POST /api/v1/data/:object/:id/clone', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.clone' }, + { route: 'POST /api/v1/data/:object/:id/clone', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.clone', + responseSchema: 'CloneDataResponseSchema', + note: '[#11924] answers BARE with 201 (res.status(201).json(result), no envelope), so the named schema is the whole body. Filled with its conformance coverage: search-clone-route-schema-conformance.test.ts drives this mount, and metadata-protocol\'s search-clone-schema-conformance.test.ts parses the real cloneData producer' }, { route: 'POST /api/v1/data/:object/import', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.import' }, { route: 'POST /api/v1/data/:object/import/jobs', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.createImportJob' }, { route: 'POST /api/v1/data/import/jobs/:jobId/cancel', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.cancelImportJob' }, @@ -265,7 +267,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'file-stream response; the SDK returns the raw Response rather than a JSON envelope' }, // ── search ──────────────────────────────────────────────────────────────── - { route: 'GET /api/v1/search', family: 'search', source: 'route-manager', disposition: 'sdk', client: 'search' }, + { route: 'GET /api/v1/search', family: 'search', source: 'route-manager', disposition: 'sdk', client: 'search', + responseSchema: 'SearchAllResponseSchema', + note: '[#11924] answers BARE (res.json(result), no envelope), so the named schema is the whole body. ⚠️ NOT `SearchResult` — that exported contract types the per-object ISearchService.search (hits of score/document), the #8140 near-miss trap. Filled with its conformance coverage: search-clone-route-schema-conformance.test.ts drives this mount, and metadata-protocol\'s search-clone-schema-conformance.test.ts parses the real searchAll producer' }, // ── email ───────────────────────────────────────────────────────────────── { route: 'POST /api/v1/email/send', family: 'email', source: 'route-manager', disposition: 'sdk', client: 'email.send' }, diff --git a/packages/rest/src/search-clone-route-schema-conformance.test.ts b/packages/rest/src/search-clone-route-schema-conformance.test.ts new file mode 100644 index 0000000000..9b0132b3fd --- /dev/null +++ b/packages/rest/src/search-clone-route-schema-conformance.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#11924] `GET /api/v1/search` and `POST /data/:object/:id/clone` answer +// bodies that conform to their newly declared `@objectstack/spec/api` schemas +// — the RELAY half of the conformance coverage that entitles these two +// route-ledger rows to name a `responseSchema` (#3877: ⛔ no row filled +// without conformance coverage). +// +// The split, same as the discovery gates: the PRODUCER half lives in +// `packages/metadata-protocol/src/search-clone-schema-conformance.test.ts`, +// which drives the REAL `searchAll` / `cloneData` over a fixture engine and +// parses what they emit. This half proves the mounted routes RELAY those +// returns bare — `res.json(result)` / `res.status(201).json(result)`, no +// `{ success, data }` envelope, no re-shaping — so the schema each row names +// describes the WHOLE wire body, and proves the rows point at the very schema +// objects this suite parses with. +// +// The protocol doubles below return bodies in the producers' measured shape +// (the fixture mirrors what the producer half proves the real methods emit); +// what is under test here is the relay and the ledger binding, never the +// producer. + +import { describe, it, expect, vi } from 'vitest'; +import * as specApi from '@objectstack/spec/api'; +import { CloneDataResponseSchema, SearchAllResponseSchema } from '@objectstack/spec/api'; +import { RestServer } from './rest-server.js'; +import { REST_ROUTE_LEDGER } from './rest-route-ledger.js'; + +const ANON_API = { api: { requireAuth: false } }; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; +} + +/** The producers' measured return shapes — see the producer-half suite. */ +const SEARCH_BODY = { + query: 'acme', + hits: [ + { + object: 'lead', + id: 'lead_1', + title: 'Acme Industrial', + snippet: '…acme is evaluating the pilot…', + record: { id: 'lead_1', name: 'Acme Industrial' }, + }, + // `snippet` genuinely conditional: absent when no searchable column + // literally contains a term (#7643). + { + object: 'lead', + id: 'lead_2', + title: 'Beta Corp', + record: { id: 'lead_2', name: 'Beta Corp' }, + }, + ], + totalObjects: 1, + totalHits: 2, + truncated: false, +}; + +const CLONE_BODY = { + object: 'account', + id: 'acc_2', + sourceId: 'acc_1', + record: { id: 'acc_2', name: 'Acme Copy' }, +}; + +function makeProtocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', + routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + searchAll: vi.fn().mockResolvedValue(SEARCH_BODY), + cloneData: vi.fn().mockResolvedValue(CLONE_BODY), + }; +} + +function routeFor(rest: RestServer, method: string, path: string) { + return (rest as any).getRoutes().find((r: any) => r.method === method && r.path === path); +} + +async function dispatch(method: string, path: string, req: Record) { + const protocol = makeProtocol(); + const rest = new RestServer(mockServer() as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + const route = routeFor(rest, method, path); + if (!route) throw new Error(`route not registered: ${method} ${path}`); + const res = mockRes(); + await route.handler({ headers: {}, params: {}, query: {}, ...req }, res); + const calls = res.json.mock.calls; + return { protocol, res, body: calls[calls.length - 1]?.[0] }; +} + +describe('[#11924] GET /api/v1/search — declared body, relayed bare', () => { + it('answers a body that parses against `SearchAllResponseSchema`, relayed from `searchAll`', async () => { + const { protocol, body } = await dispatch('GET', '/api/v1/search', { query: { q: 'acme' } }); + + expect(protocol.searchAll).toHaveBeenCalled(); + const parsed = SearchAllResponseSchema.safeParse(body); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + // Relay, not re-shape: the wire body IS the producer's return. + expect(body).toBe(SEARCH_BODY); + }); + + it('describes the WHOLE body — this mount answers bare, no envelope', async () => { + const { body } = await dispatch('GET', '/api/v1/search', { query: { q: 'acme' } }); + expect(body).not.toHaveProperty('success'); + expect(body).not.toHaveProperty('data'); + }); +}); + +describe('[#11924] POST /data/:object/:id/clone — declared body, relayed bare at 201', () => { + it('answers a 201 body that parses against `CloneDataResponseSchema`, relayed from `cloneData`', async () => { + const { protocol, res, body } = await dispatch('POST', '/api/v1/data/:object/:id/clone', { + params: { object: 'account', id: 'acc_1' }, + body: { overrides: { name: 'Acme Copy' } }, + }); + + expect(protocol.cloneData).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + const parsed = CloneDataResponseSchema.safeParse(body); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + expect(body).toBe(CLONE_BODY); + }); + + it('describes the WHOLE body — this mount answers bare, no envelope', async () => { + const { body } = await dispatch('POST', '/api/v1/data/:object/:id/clone', { + params: { object: 'account', id: 'acc_1' }, + body: {}, + }); + expect(body).not.toHaveProperty('success'); + expect(body).not.toHaveProperty('data'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// [#11924] The ledger rows point AT this suite +// ═══════════════════════════════════════════════════════════════════════════ +// +// Same closure as the discovery gate's #5791 pin: `responseSchema` may only be +// written where conformance coverage runs, that rule lives in a JSDoc which +// cannot fail a build, so the loop is closed here — in the file the rows name. +// `packages/client/src/route-ledger-response-schema.test.ts` carries the +// resolve-against-live-exports half for every ledger. +describe('[#11924] the ledger declares the schemas this suite parses with', () => { + it('the search row names `SearchAllResponseSchema`, resolving to the very object asserted above', () => { + const row = REST_ROUTE_LEDGER.find((e) => e.route === 'GET /api/v1/search'); + expect(row, 'the search row must exist in REST_ROUTE_LEDGER').toBeDefined(); + expect(row!.responseSchema).toBe('SearchAllResponseSchema'); + // Identity, not just resolvability: a name resolving to some OTHER + // schema would satisfy the client-side resolver and still describe the + // wrong contract — `SearchResult` sits one import away (#8140's trap). + expect((specApi as unknown as Record)[row!.responseSchema!]) + .toBe(SearchAllResponseSchema); + }); + + it('the clone row names `CloneDataResponseSchema`, resolving to the very object asserted above', () => { + const row = REST_ROUTE_LEDGER.find((e) => e.route === 'POST /api/v1/data/:object/:id/clone'); + expect(row, 'the clone row must exist in REST_ROUTE_LEDGER').toBeDefined(); + expect(row!.responseSchema).toBe('CloneDataResponseSchema'); + expect((specApi as unknown as Record)[row!.responseSchema!]) + .toBe(CloneDataResponseSchema); + }); +}); diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index be081e11ed..fc8608882e 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -163,6 +163,8 @@ "CheckPermissionRequestSchema (const)", "CheckPermissionResponse (type)", "CheckPermissionResponseSchema (const)", + "CloneDataResponse (type)", + "CloneDataResponseSchema (const)", "CodeGenerationTemplate (type)", "CodeGenerationTemplateSchema (const)", "CompleteChunkedUploadRequest (type)", @@ -864,6 +866,10 @@ "ScheduledExport (type)", "ScheduledExportParsed (type)", "ScheduledExportSchema (const)", + "SearchAllHit (type)", + "SearchAllHitSchema (const)", + "SearchAllResponse (type)", + "SearchAllResponseSchema (const)", "ServiceInfo (type)", "ServiceInfoSchema (const)", "ServiceSelfInfo (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 1b714acd69..ecad60c646 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -308,6 +308,10 @@ "api/CheckPermissionRequest:recordId", "api/CheckPermissionResponse:allowed", "api/CheckPermissionResponse:reason", + "api/CloneDataResponse:id", + "api/CloneDataResponse:object", + "api/CloneDataResponse:record", + "api/CloneDataResponse:sourceId", "api/CodeGenerationTemplate:language", "api/CodeGenerationTemplate:name", "api/CodeGenerationTemplate:template", @@ -1538,6 +1542,16 @@ "api/ScheduledExport:object", "api/ScheduledExport:schedule", "api/ScheduledExport:templateId", + "api/SearchAllHit:id", + "api/SearchAllHit:object", + "api/SearchAllHit:record", + "api/SearchAllHit:snippet", + "api/SearchAllHit:title", + "api/SearchAllResponse:hits", + "api/SearchAllResponse:query", + "api/SearchAllResponse:totalHits", + "api/SearchAllResponse:totalObjects", + "api/SearchAllResponse:truncated", "api/ServiceInfo:enabled", "api/ServiceInfo:handlerReady", "api/ServiceInfo:message", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 205d153201..24b9317546 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -163,6 +163,8 @@ "CheckPermissionRequestSchema": "src/api/protocol.zod.ts#CheckPermissionRequestSchema (const)", "CheckPermissionResponse": "src/api/protocol.zod.ts#CheckPermissionResponse (type)", "CheckPermissionResponseSchema": "src/api/protocol.zod.ts#CheckPermissionResponseSchema (const)", + "CloneDataResponse": "src/api/protocol.zod.ts#CloneDataResponse (type)", + "CloneDataResponseSchema": "src/api/protocol.zod.ts#CloneDataResponseSchema (const)", "CodeGenerationTemplate": "src/api/documentation.zod.ts#CodeGenerationTemplate (type)", "CodeGenerationTemplateSchema": "src/api/documentation.zod.ts#CodeGenerationTemplateSchema (const)", "CompleteChunkedUploadRequest": "src/api/storage.zod.ts#CompleteChunkedUploadRequest (type)", @@ -864,6 +866,10 @@ "ScheduledExport": "src/api/export.zod.ts#ScheduledExport (type)", "ScheduledExportParsed": "src/api/export.zod.ts#ScheduledExportParsed (type)", "ScheduledExportSchema": "src/api/export.zod.ts#ScheduledExportSchema (const)", + "SearchAllHit": "src/api/protocol.zod.ts#SearchAllHit (type)", + "SearchAllHitSchema": "src/api/protocol.zod.ts#SearchAllHitSchema (const)", + "SearchAllResponse": "src/api/protocol.zod.ts#SearchAllResponse (type)", + "SearchAllResponseSchema": "src/api/protocol.zod.ts#SearchAllResponseSchema (const)", "ServiceInfo": "src/api/discovery.zod.ts#ServiceInfo (type)", "ServiceInfoSchema": "src/api/discovery.zod.ts#ServiceInfoSchema (const)", "ServiceSelfInfo": "src/api/discovery.zod.ts#ServiceSelfInfo (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index b9de5711c7..a5fe08bfe0 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -70,6 +70,7 @@ "api/CapabilityDescriptor", "api/CheckPermissionRequest", "api/CheckPermissionResponse", + "api/CloneDataResponse", "api/CodeGenerationTemplate", "api/CompleteChunkedUploadRequest", "api/CompleteChunkedUploadResponse", @@ -358,6 +359,8 @@ "api/ScheduleExportRequest", "api/ScheduleExportResponse", "api/ScheduledExport", + "api/SearchAllHit", + "api/SearchAllResponse", "api/ServiceInfo", "api/ServiceSelfInfo", "api/ServiceStatus", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 9ce3b2c4a6..f666799577 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1467,6 +1467,36 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ ), })); +/** + * Clone Data Response (#11924) + * + * The WHOLE body of `POST /data/:object/:id/clone` — the route answers BARE + * (`res.status(201).json(result)`, no `{ success, data }` envelope) and relays + * `@objectstack/metadata-protocol`'s `cloneData` return verbatim, so this + * schema names the entire body where the route ledger references it. + * + * Declared AS PRODUCED (maintainer ruling 2026-08-25 on #11924: the shape is + * stable and server-produced): `cloneData` builds exactly + * `{ object, id, sourceId, record }` — the structural sibling of + * {@link CreateDataResponseSchema} plus `sourceId`. A clone IS a create (the + * copy runs the insert path: engine-owned columns re-derived, the #3043 + * readonly ingress strip applied, internal fields omitted from the response, + * #7823) — but unlike `createData` the producer emits no `droppedFields` + * member, so none is declared: a key the producer never writes would be a + * promise conformance cannot measure. + */ +export const CloneDataResponseSchema = lazySchema(() => z.object({ + object: z.string().describe('The object name.'), + id: z.string().describe('The ID of the newly created clone.'), + sourceId: z.string().describe('The ID of the record the clone was copied from.'), + record: z.record(z.string(), z.unknown()).describe( + 'The created clone, including server-generated fields. Engine-owned values ' + + '(injected system/audit columns, autonumbers, computed formula/summary fields) are ' + + 're-derived by the insert path rather than copied from the source; caller-supplied ' + + '`overrides` win over copied values.' + ), +})); + /** * One field-level finding from a validate-only run — the same * `{ field, code, message }` triple the engine's `ValidationError.fields` @@ -1597,6 +1627,82 @@ export const DeleteDataResponseSchema = lazySchema(() => z.object({ success: z.boolean().describe('Whether deletion succeeded'), })); +// ========================================== +// Global Cross-Object Search +// ========================================== + +/** + * One hit of the global cross-object search (#11924). + * + * ⚠️ NOT the per-object search-service hit. `SearchHit` / `SearchResult` + * (`@objectstack/spec/contracts`, `search-service.ts`) contract the pluggable + * `ISearchService.search` engine surface, whose hits carry `score` / + * `document`. THIS shape is the `GET /api/v1/search` palette answer — + * `object` / `id` / `title` / `snippet` / `record` — produced by + * `@objectstack/metadata-protocol`'s `searchAll`. Binding the same-named + * neighbour would typecheck and be false; the compile-time guard #8140 left in + * `packages/client/src/return-type-precision.test.ts` pins the mismatch. + */ +export const SearchAllHitSchema = lazySchema(() => z.object({ + object: z.string().describe('Name of the object the hit belongs to.'), + id: z.string().describe('ID of the matched record.'), + title: z.string().describe( + 'Display title for the hit, resolved in order: the object\'s `titleFormat` template → ' + + 'the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias ' + + '`displayNameField` still honored) → conventional name fields → first/last name → ' + + 'the record ID as a string.' + ), + snippet: z.string().optional().describe( + 'Excerpt cut around the first matched term in a searchable text column, ellipsized at ' + + 'both ends when truncated. ABSENT when no source column literally contains a term ' + + '(e.g. a pinyin companion match, #7643) — absence is a correct answer, not a miss.' + ), + record: z.record(z.string(), z.unknown()).describe( + 'The matched record as the engine\'s find path returns it (row-level security applied, ' + + 'internal fields already stripped). Object-specific — no cross-object field shape is ' + + 'promised beyond "a record of the named object".' + ), +})); + +/** + * Global Cross-Object Search Response (#11924) + * + * The WHOLE body of `GET /api/v1/search` — the route answers BARE + * (`res.json(result)`, no `{ success, data }` envelope) and relays + * `@objectstack/metadata-protocol`'s `searchAll` return verbatim, so this + * schema names the entire body where the route ledger references it. + * + * Declared AS PRODUCED (maintainer ruling 2026-08-25 on #11924: the shape is + * stable and server-produced). One query sweeps every searchable, + * API-enabled object the caller can read (ADR-0061 Tier 1: the server + * resolves which fields to search from object metadata); an empty/blank `q` + * short-circuits to `{ query: '', hits: [], totalObjects: 0, totalHits: 0, + * truncated: false }` without scanning. + */ +export const SearchAllResponseSchema = lazySchema(() => z.object({ + query: z.string().describe( + 'The TRIMMED query text the sweep ran with — empty string when the request carried ' + + 'none (the no-scan short-circuit).' + ), + hits: z.array(SearchAllHitSchema).describe( + 'Matched records across objects, in scan order, capped at the overall `limit` ' + + '(default 20, max 100) with at most `perObject` (default 5, max 25) per object.' + ), + totalObjects: z.number().describe( + 'Number of objects the sweep actually SCANNED (searchable, API-enabled, with a ' + + 'resolvable search-field set) — not the number of objects with hits. An object ' + + 'whose table was never provisioned is skipped and not counted (#8896).' + ), + totalHits: z.number().describe( + 'Number of hits returned — equals `hits.length`. NOT a deployment-wide total-match ' + + 'count: matches beyond `limit` / `perObject` are not counted.' + ), + truncated: z.boolean().describe( + 'True when the sweep stopped at the overall `limit` — more matches may exist beyond ' + + 'the returned set.' + ), +})); + // ========================================== // Batch Operations // ========================================== @@ -2468,6 +2574,8 @@ export type GetDataRequest = z.input; export type GetDataResponse = z.input; export type CreateDataRequest = z.input; export type CreateDataResponse = z.input; +/** The `POST /data/:object/:id/clone` body — see {@link CloneDataResponseSchema} (#11924). */ +export type CloneDataResponse = z.input; export type ValidateDataIssue = z.input; export type ValidateDataRequest = z.input; export type ValidateDataResponse = z.input; @@ -2475,6 +2583,13 @@ export type UpdateDataRequest = z.input; export type UpdateDataResponse = z.input; export type DeleteDataRequest = z.input; export type DeleteDataResponse = z.input; +/** + * The `GET /api/v1/search` body and its hit element (#11924). ⚠️ Distinct from + * the per-object `SearchResult` / `SearchHit` in `@objectstack/spec/contracts` + * — see {@link SearchAllHitSchema} for the trap. + */ +export type SearchAllHit = z.input; +export type SearchAllResponse = z.input; export type BatchDataRequest = z.input; /** Post-parse shape of {@link BatchDataRequest} — defaults applied, transforms run (ADR-0122). */ diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 885dde0148..c00d62ddaf 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -267,7 +267,7 @@ import type * as M170 from './ui/component.zod.js'; import type * as M183 from './api/sortability.zod.js'; // --------------------------------------------------------------------------- -// 834 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 837 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -493,6 +493,9 @@ export type Iso144 = Assert, z.i export type Iso145 = Assert, z.infer< typeof M28.CreateDataResponseSchema > >>; export type Iso146 = Assert, z.infer< typeof M28.UpdateDataResponseSchema > >>; export type Iso147 = Assert, z.infer< typeof M28.DeleteDataResponseSchema > >>; +export type Iso859 = Assert, z.infer< typeof M28.CloneDataResponseSchema > >>; +export type Iso860 = Assert, z.infer< typeof M28.SearchAllHitSchema > >>; +export type Iso861 = Assert, z.infer< typeof M28.SearchAllResponseSchema > >>; export type Iso148 = Assert, z.infer< typeof M28.CreateManyDataResponseSchema > >>; export type Iso150 = Assert, z.infer< typeof M28.CheckPermissionResponseSchema > >>; export type Iso151 = Assert, z.infer< typeof M28.GetEffectivePermissionsResponseSchema > >>; @@ -1672,7 +1675,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 834 isomorphic pins', () => { + it('still declares all 837 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -1979,9 +1982,22 @@ describe('ADR-0122 type-alias convention', () => { // the default-free object-* family exactly the way that family's comment // prescribes: `ObjectGridPropsParsed` declared, the Iso839 pin deleted. // -1 converted to an `XParsed` pair; the Iso number stays vacant. + // + // 834 -> 837 is #11924's `CloneDataResponseSchema` / + // `SearchAllHitSchema` / `SearchAllResponseSchema` — the `data.clone` and + // global-search route response contracts, declared as produced. (Authored + // as 835 -> 838; restated from the post-merge base after #12039's -1 + // landed first — the two changes touch disjoint pins.) Isomorphism + // MEASURED, not assumed: all three trees are plain `z.object`s of + // `z.string()` (some `.optional()`), `z.number()`, `z.boolean()`, + // `z.record(z.string(), z.unknown())` and one `z.array` of the hit + // object — no `.default()`, `.transform()`, `.catch()` or `.pipe()` + // anywhere, so the two shapes coincide and ADR-0122 gives each a pin + // rather than an `XParsed`. Ids `Iso859`/`Iso860`/`Iso861`, the next + // free ones — ids are claims about pins, not positions. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert