diff --git a/.changeset/30348-seed-receipt-landed-ids.md b/.changeset/30348-seed-receipt-landed-ids.md new file mode 100644 index 00000000000..03071ea59d2 --- /dev/null +++ b/.changeset/30348-seed-receipt-landed-ids.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/runtime': patch +--- + +feat(spec,metadata-protocol): the publish receipt's `seedApplied.landed` names the rows a seed created and the rows it only matched, per object, capped at 200 ids per list + +Clause-②: yes (widening) +Metadata-protocol: added + +- **`@objectstack/spec`: `seedApplied.landed`** on both publish responses (`PublishMetaItemResponseSchema`, `PublishPackageDraftsResponseSchema`), one shared schema `SeedAppliedLandedSchema`: per object, `inserted` (the rows this publish created) and `matched` (pre-existing rows a seed record's key matched: updated, left as they were, or refused an update), each a `SeedAppliedIdListSchema` `{ ids, total, truncated }`. `ids` carries at most `SEED_APPLIED_LANDED_IDS_CAP` (200) ids, in the loader's record order (datasets in load order, records as authored); past it `truncated` is `true` and `total` is the uncut count. Absent `landed` means "not reported" (a load that threw, or a producer that reports no ids), never "nothing landed". Only `inserted` names rows the seed created: a `matched` row may be a customer's own record that shares the seed's natural key, and under `ignore` (the default) such a row is matched without being written. +- **`@objectstack/spec`: `SeedLoadResult.insertedIds`**, the ids among `landedIds` whose row the load created. Per load: a row an earlier load inserted is matched on a replay, so a reader tracking a seed's rows across publishes accumulates `insertedIds` over every load. `landedIds`' TSDoc now says what each seed mode puts in it. +- **`@objectstack/metadata-protocol`**: `applySeedBodies` returns `landed`, so `publishPackageDrafts` and `publishMetaItem` both carry it; the loader records which landed rows it inserted. `seedAppliedLanded(results)` is exported, the one projection both receipt producers fold the loader's ids through. +- **`@objectstack/runtime`**: the package-publish door's fallback seed apply (for protocols that do not self-apply) reports the same `landed`. +- Accepted cost (objectstack-ai/cloud#3598 ruling A): row ids appear on the public publish responses, bounded by the cap. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index c754d2e1799..3c8ca94a3eb 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,7 +1,7 @@ --- title: Protocol schema — API Protocol reference navTitle: Protocol -description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 141 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 143 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilit ## 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, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringClientSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublicFormSubmitResponseSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsRequestSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, 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, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringClient, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublicFormSubmitResponse, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsRequest, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, 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, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringClientSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublicFormSubmitResponseSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsRequestSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SeedAppliedIdListSchema, SeedAppliedLandedSchema, 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, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringClient, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublicFormSubmitResponse, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsRequest, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SeedAppliedIdList, SeedAppliedLanded, 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); @@ -2546,6 +2546,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **updated** | `integer` | ✅ | Rows updated by the externalId-keyed upsert. | | **error** | `string` | optional | Single failure message, present when the seed apply threw before the loader ran (including "no readable seed bodies"). | | **errors** | `any[]` | optional | Per-record failures reported by the seed loader. Present only when the loader ran and returned a non-empty error list. | +| **landed** | `{ object: string; inserted: object; matched: object }[]` | optional | Per object, the stored rows the seed landed on: `inserted` (created by this publish) and `matched` (pre-existing rows a seed key matched), each capped at `SEED_APPLIED_LANDED_IDS_CAP` (200) ids with `total` and `truncated` stating any cut. One entry per object the loader ran, empty lists included. ABSENT means "not reported" — the apply failed before or while loading (see `error`), or the producer reports no ids — never "no rows landed". | ### Nested Shape: `PublishMetaItemResponse.materializeApplied` @@ -2646,6 +2647,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **error** | `string` | optional | Single failure message, present when the apply failed before the loader ran (including "no readable seed bodies"). When the failure is the seed bodies' own schema refusal, this is a one-sentence headline and the per-path detail rides `issues[]`. | | **errors** | `any[]` | optional | Per-record failures reported by the seed loader, plus any seed-body read failures. May be present and empty on a clean load. | | **issues** | `{ path: string; message: string; code?: string }[]` | optional | Structured spec-validation findings behind `error`, present when the apply was refused by the seed bodies' own schema — the declared 422 `seedRequestValidationError` mints. The per-path detail lives HERE, once; `error` stays a one-sentence headline. Absent on non-validation failures (driver faults, unreadable bodies), whose whole story is `error` / `errors[]`. | +| **landed** | `{ object: string; inserted: object; matched: object }[]` | optional | Per object, the stored rows the seed landed on: `inserted` (created by this publish) and `matched` (pre-existing rows a seed key matched), each capped at `SEED_APPLIED_LANDED_IDS_CAP` (200) ids with `total` and `truncated` stating any cut. One entry per object the loader ran, empty lists included. ABSENT means "not reported" — the apply failed before or while loading (see `error`), or the producer reports no ids — never "no rows landed". | ### Nested Shape: `PublishPackageDraftsResponse.materializeApplied` @@ -2965,6 +2967,48 @@ Answer to a device registration: the device id the client keeps | **pageType** | `string` | optional | The page's own declared `type` (`record` \| `app` \| `home` \| …) when the served document carries one — a routing hint, deliberately typed as a plain string so a future page type cannot invalidate an already-produced search response. | +--- + +## SeedAppliedIdList + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **ids** | `string[]` | ✅ | Row ids in the loader's record order (datasets in load order, records as authored), each once — at most `SEED_APPLIED_LANDED_IDS_CAP` (200). | +| **total** | `integer` | ✅ | How many ids the list holds before the cap. Equals `ids.length` unless `truncated`. | +| **truncated** | `boolean` | ✅ | True when the cap cut the list: `total` ids exist and only the first `ids.length` are carried. | + + +--- + +## SeedAppliedLanded + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The object whose rows the lists name. | +| **inserted** | `{ ids: string[]; total: integer; truncated: boolean }` | ✅ | Rows this publish created. The only ones safe to treat as the seed's own. | +| **matched** | `{ ids: string[]; total: integer; truncated: boolean }` | ✅ | Pre-existing rows a seed record's key matched — updated, left as they were, or refused an update. May be a customer's row; never clear these on this receipt's word. | + +### Nested Shape: `SeedAppliedLanded.inserted` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **ids** | `string[]` | ✅ | Row ids in the loader's record order (datasets in load order, records as authored), each once — at most `SEED_APPLIED_LANDED_IDS_CAP` (200). | +| **total** | `integer` | ✅ | How many ids the list holds before the cap. Equals `ids.length` unless `truncated`. | +| **truncated** | `boolean` | ✅ | True when the cap cut the list: `total` ids exist and only the first `ids.length` are carried. | + +### Nested Shape: `SeedAppliedLanded.matched` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **ids** | `string[]` | ✅ | Row ids in the loader's record order (datasets in load order, records as authored), each once — at most `SEED_APPLIED_LANDED_IDS_CAP` (200). | +| **total** | `integer` | ✅ | How many ids the list holds before the cap. Equals `ids.length` unless `truncated`. | +| **truncated** | `boolean` | ✅ | True when the cap cut the list: `total` ids exist and only the first `ids.length` are carried. | + + --- ## SetPresenceRequest diff --git a/content/docs/references/data/seed-loader.mdx b/content/docs/references/data/seed-loader.mdx index ff04fc0ead4..231395ac6d1 100644 --- a/content/docs/references/data/seed-loader.mdx +++ b/content/docs/references/data/seed-loader.mdx @@ -164,6 +164,7 @@ Result of loading a single dataset | **referencesDropped** | `integer` | optional (default: `0`) | Reference fields dropped from records that were still written | | **summariesStale** | `integer` | optional (default: `0`) | Roll-up summary values left stale by writes for this dataset | | **landedIds** | `string[]` | optional (default: `[]`) | Ids of the stored rows this dataset landed on: inserted, updated, or matched on replay | +| **insertedIds** | `string[]` | optional (default: `[]`) | Ids among landedIds whose row this load created (a successful insert); every other landed id was a pre-existing row a record key matched | | **errors** | `{ sourceObject: string; field: string; targetObject: string; targetField: string; … }[]` | optional (default: `[]`) | Reference resolution errors | ### Nested Shape: `SeedLoadResult.errors[number]` @@ -302,6 +303,7 @@ Result of loading a single dataset | **referencesDropped** | `integer` | optional (default: `0`) | Reference fields dropped from records that were still written | | **summariesStale** | `integer` | optional (default: `0`) | Roll-up summary values left stale by writes for this dataset | | **landedIds** | `string[]` | optional (default: `[]`) | Ids of the stored rows this dataset landed on: inserted, updated, or matched on replay | +| **insertedIds** | `string[]` | optional (default: `[]`) | Ids among landedIds whose row this load created (a successful insert); every other landed id was a pre-existing row a record key matched | | **errors** | `{ sourceObject: string; field: string; targetObject: string; targetField: string; … }[]` | optional (default: `[]`) | Reference resolution errors | ### Nested Shape: `SeedLoaderResult.errors[number]` diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 16bf933b4ea..ff660bc1b9a 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1623 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1625 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/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 36 | 486 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 36 | 488 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 16 | 81 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,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) | 39 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **210** | **1623** | 14 protocol modules | +| **Total** | **210** | **1625** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **36 pages, 486 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **36 pages, 488 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -92,7 +92,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `EnsureCustomizationPackageRequest`, `EnsureCustomizationPackageResponse`, `GetPackageCustomizationsRequest`, `GetPackageCustomizationsResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportBundle`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `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`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringClient`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublicFormSubmitResponse`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsRequest`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `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`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringClient`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublicFormSubmitResponse`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsRequest`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SeedAppliedIdList`, `SeedAppliedLanded`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`push-delivery.zod.ts`](/docs/references/api/push-delivery) | `PushDeliveryData`, `PushRoute` | | [`push-relay.zod.ts`](/docs/references/api/push-relay) | `PushRelayDelivery`, `PushRelayError`, `PushRelayErrorResponse`, `PushRelayPayload`, `PushRelayPriority`, `PushRelayRequest`, `PushRelayResponse`, `PushRelayResult`, `PushRelayResultStatus` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 21eda271dce..12717707e6a 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 499 | +| `api/` | 501 | diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 2224a7574a3..2eef7938f07 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -15,6 +15,11 @@ export { recordNotFoundError } from './protocol.js'; // still be quoted (a malformed seed body). Exporting is enabling-only — no // behaviour in this package changes. export { clientFacingFailureText, seedRequestValidationError } from './protocol.js'; +// [#30348] The `seedApplied.landed` projection (rows created vs matched, per +// object, capped), exported for the same second producer: the runtime's +// fallback seed apply reports on the same receipt, so it folds the loader's +// ids through this one function instead of a copy. +export { seedAppliedLanded, type SeedLandingResult } from './seed-applied-landed.js'; // [#7823] The write-response half of the `internal: true` guarantee — THE // single helper every generic write ingress routes its response records // through (A-prime ruling, 2026-08-13). Tripwire-enforced; see the module diff --git a/packages/metadata-protocol/src/protocol-history-and-publish.ts b/packages/metadata-protocol/src/protocol-history-and-publish.ts index 5fb8328812d..7e3ff0c1f0f 100644 --- a/packages/metadata-protocol/src/protocol-history-and-publish.ts +++ b/packages/metadata-protocol/src/protocol-history-and-publish.ts @@ -45,6 +45,7 @@ import type { MutationProjectionOutcome } from './protocol-mutation-events.js'; import { withPendingAudit } from './protocol-audit-entry.js'; import type { PublishMetaItemRequest } from './protocol-door-requests.js'; import type { ProtocolHost } from './protocol-host.js'; +import type { SeedAppliedLandedParsed } from '@objectstack/spec/api'; export class ProtocolHistoryAndPublishFacet { /** The body of `ObjectStackProtocolImplementation#historyMetaItem`; the method's docblock there is its contract. */ @@ -162,6 +163,8 @@ export class ProtocolHistoryAndPublishFacet { updated: number; error?: string; errors?: unknown[]; + /** [#30348] Per object, the rows created vs matched — `SeedAppliedLandedSchema`. */ + landed?: SeedAppliedLandedParsed[]; }; /** * Present when a publish-time materializer is registered for this type @@ -344,7 +347,7 @@ export class ProtocolHistoryAndPublishFacet { version: string; seq: number; message?: string; - seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; + seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; landed?: SeedAppliedLandedParsed[] }; materializeApplied?: PublishMaterializeResult; projectionApplied?: MutationProjectionOutcome; advisories?: RuntimeAuthoringIssue[]; @@ -1044,12 +1047,12 @@ export class ProtocolHistoryAndPublishFacet { actor?: string; skipSeedApply?: boolean; }): Promise<{ - seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; + seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; landed?: SeedAppliedLandedParsed[] }; materializeApplied?: PublishMaterializeResult; projectionApplied?: MutationProjectionOutcome; }> { const out: { - seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; + seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; landed?: SeedAppliedLandedParsed[] }; materializeApplied?: PublishMaterializeResult; projectionApplied?: MutationProjectionOutcome; } = {}; diff --git a/packages/metadata-protocol/src/protocol-publish-batch.ts b/packages/metadata-protocol/src/protocol-publish-batch.ts index 0b43a2f1c55..7a2c8267655 100644 --- a/packages/metadata-protocol/src/protocol-publish-batch.ts +++ b/packages/metadata-protocol/src/protocol-publish-batch.ts @@ -49,6 +49,8 @@ import { } from './protocol.js'; import { CLOSURE_CONTEXT_KEY_BY_TYPE, type RuntimePendingDeclarations } from './runtime-authoring-gate.js'; import type { SeedLoadPartialWrites } from './seed-loader.js'; +import type { SeedAppliedLandedParsed } from '@objectstack/spec/api'; +import { seedAppliedLanded } from './seed-applied-landed.js'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -74,6 +76,7 @@ export async function applySeedBodies( ): Promise<{ success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; issues?: Array<{ path: string; message: string; code?: string | undefined }>; + landed?: SeedAppliedLandedParsed[]; }> { // The loader's reader of what a THROWN load had written. Unset until the // loader module is imported, which no write precedes. @@ -128,6 +131,13 @@ export async function applySeedBodies( inserted: r.summary.totalInserted, updated: r.summary.totalUpdated, ...(r.errors?.length ? { errors: r.errors } : {}), + // [#30348] Which rows landed, per object: created by this publish + // vs pre-existing rows a seed key matched — the loader's own ids, + // capped and folded by the one projection both receipt producers + // share. Only on this path: a load that THREW (the catch below) + // reports counts but no ids, so `landed` stays absent there — + // "not reported", never "nothing landed". + landed: seedAppliedLanded(r.results), }; } catch (e: any) { // [#8333 · P9] `seedApplied.error` rides on a **200** publish diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index de7412e0287..2f782bc07fb 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -74,6 +74,7 @@ import type { // the published declaration, and the pure half of the import (what a package // body holds, what is copied, what is refused) lives beside this file. import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api'; +import type { SeedAppliedLandedParsed } from '@objectstack/spec/api'; import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; // [#24395] The save's authoring surface. import { @@ -11076,6 +11077,8 @@ export class ObjectStackProtocolImplementation implements updated: number; error?: string; errors?: unknown[]; + /** [#30348] Per object, the rows created vs matched — `SeedAppliedLandedSchema`. */ + landed?: SeedAppliedLandedParsed[]; }; /** * Present when a publish-time materializer is registered for this type @@ -11192,7 +11195,7 @@ export class ObjectStackProtocolImplementation implements actor?: string; skipSeedApply?: boolean; }): Promise<{ - seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; + seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; landed?: SeedAppliedLandedParsed[] }; materializeApplied?: PublishMaterializeResult; projectionApplied?: MutationProjectionOutcome; }> { @@ -11213,6 +11216,7 @@ export class ObjectStackProtocolImplementation implements ): Promise<{ success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; issues?: Array<{ path: string; message: string; code?: string | undefined }>; + landed?: SeedAppliedLandedParsed[]; }> { // [#25467 cut 14] The body moved to `protocol-publish-batch.ts` (see `PublishBatchHost`). const readEngine = () => this.engine; @@ -11531,7 +11535,7 @@ export class ObjectStackProtocolImplementation implements }>; failed: Array<{ type: string; name: string; error: string; code?: string }>; /** Aggregate result of materializing every published `seed` (absent when no seeds). */ - seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; + seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; landed?: SeedAppliedLandedParsed[] }; /** * ADR-0086 P2 — aggregate result of publish-time materializers across the * batch (e.g. `flow`). Absent when no published item had a registered diff --git a/packages/metadata-protocol/src/seed-applied-landed.test.ts b/packages/metadata-protocol/src/seed-applied-landed.test.ts new file mode 100644 index 00000000000..679c8bce24c --- /dev/null +++ b/packages/metadata-protocol/src/seed-applied-landed.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SEED_APPLIED_LANDED_IDS_CAP, SeedAppliedLandedSchema } from '@objectstack/spec/api'; +import { seedAppliedLanded, type SeedLandingResult } from './seed-applied-landed.js'; + +/** + * [#30348] `seedAppliedLanded` — the one projection both `seedApplied` + * producers fold the loader's per-dataset results through, pinned on its own, + * without an engine: the junk it skips, the empty answer, the cap boundary, and + * the cross-dataset merge that keeps a row this batch created out of + * `matched`. The publish doors' pins (`@objectstack/objectql`'s + * `publish-seed-receipt-landed-ids.test.ts`) read the same answer off a real + * engine; these pin the cases a door cannot stage cleanly. + */ + +const ids = (prefix: string, n: number) => Array.from({ length: n }, (_, i) => `${prefix}${i}`); +const list = (xs: string[], total = xs.length) => ({ ids: xs, total, truncated: total > xs.length }); +const NONE = list([]); + +describe('seedAppliedLanded (#30348)', () => { + it('no results: an empty list, never undefined', () => { + expect(seedAppliedLanded([])).toEqual([]); + }); + + it('skips junk entries — a null, a missing or non-string object — and keeps the real ones', () => { + const junk = [ + null, + undefined, + {}, + { object: 42, landedIds: ['x'] }, + { landedIds: ['y'], insertedIds: ['y'] }, + { object: 'account', landedIds: ['a1'], insertedIds: ['a1'] }, + ] as unknown as SeedLandingResult[]; + + expect(seedAppliedLanded(junk)).toEqual([{ object: 'account', inserted: list(['a1']), matched: NONE }]); + }); + + it('a dataset that landed nothing still reports its object, with two empty lists', () => { + expect(seedAppliedLanded([{ object: 'account', landedIds: [], insertedIds: [] }])) + .toEqual([{ object: 'account', inserted: NONE, matched: NONE }]); + // A result predating `insertedIds` (both keys absent) reads the same. + expect(seedAppliedLanded([{ object: 'account' }])).toEqual([{ object: 'account', inserted: NONE, matched: NONE }]); + }); + + it('splits one dataset: inserted ids under `inserted`, every other landed id under `matched`', () => { + expect(seedAppliedLanded([{ object: 'account', landedIds: ['c9', 'a1', 'a2'], insertedIds: ['a1', 'a2'] }])) + .toEqual([{ object: 'account', inserted: list(['a1', 'a2']), matched: list(['c9']) }]); + }); + + it('the cap: exactly 200 is carried whole and not truncated; 201 carries the first 200 and says so', () => { + expect(SEED_APPLIED_LANDED_IDS_CAP).toBe(200); + const exact = ids('r', SEED_APPLIED_LANDED_IDS_CAP); + const over = ids('r', SEED_APPLIED_LANDED_IDS_CAP + 1); + + const [atCap] = seedAppliedLanded([{ object: 'account', landedIds: exact, insertedIds: exact }]); + expect(atCap.inserted).toEqual({ ids: exact, total: 200, truncated: false }); + + const [cut] = seedAppliedLanded([{ object: 'account', landedIds: over, insertedIds: [] }]); + expect(cut.matched).toEqual({ ids: over.slice(0, 200), total: 201, truncated: true }); + expect(cut.inserted).toEqual(NONE); + + // What the projection emits is what the spec schema carries. + expect(SeedAppliedLandedSchema.parse(cut)).toEqual(cut); + expect(SeedAppliedLandedSchema.parse(atCap)).toEqual(atCap); + }); + + it('the cap is per list: 201 inserted and 201 matched cut each list on its own', () => { + const ins = ids('i', 201); + const mat = ids('m', 201); + const [entry] = seedAppliedLanded([{ object: 'account', landedIds: [...ins, ...mat], insertedIds: ins }]); + expect(entry.inserted).toEqual({ ids: ins.slice(0, 200), total: 201, truncated: true }); + expect(entry.matched).toEqual({ ids: mat.slice(0, 200), total: 201, truncated: true }); + }); + + it('two datasets of one object: a row the first inserted and the second matched stays `inserted` only', () => { + const results: SeedLandingResult[] = [ + { object: 'account', landedIds: ['a1', 'a2'], insertedIds: ['a1', 'a2'] }, + // Its key matched `a1`, the row the first dataset just created, and a customer row `c9`. + { object: 'account', landedIds: ['a1', 'c9', 'a3'], insertedIds: ['a3'] }, + ]; + + expect(seedAppliedLanded(results)).toEqual([ + { object: 'account', inserted: list(['a1', 'a2', 'a3']), matched: list(['c9']) }, + ]); + }); + + it('a row an EARLIER dataset matched and a later one inserted is `inserted`, never both', () => { + // The rule is order-free: membership in any dataset's `insertedIds` decides. + const [entry] = seedAppliedLanded([ + { object: 'account', landedIds: ['x'], insertedIds: [] }, + { object: 'account', landedIds: ['x'], insertedIds: ['x'] }, + ]); + expect(entry).toEqual({ object: 'account', inserted: list(['x']), matched: NONE }); + }); + + it('objects keep the order they first loaded in; ids dedupe, first occurrence kept', () => { + const out = seedAppliedLanded([ + { object: 'contact', landedIds: ['k1'], insertedIds: ['k1'] }, + { object: 'account', landedIds: ['a1', 'a1'], insertedIds: ['a1', 'a1'] }, + { object: 'contact', landedIds: ['k2', 'k1'], insertedIds: ['k2'] }, + ]); + expect(out.map((e) => e.object)).toEqual(['contact', 'account']); + expect(out[0]).toEqual({ object: 'contact', inserted: list(['k1', 'k2']), matched: NONE }); + expect(out[1]).toEqual({ object: 'account', inserted: list(['a1']), matched: NONE }); + }); +}); diff --git a/packages/metadata-protocol/src/seed-applied-landed.ts b/packages/metadata-protocol/src/seed-applied-landed.ts new file mode 100644 index 00000000000..1884875a7f2 --- /dev/null +++ b/packages/metadata-protocol/src/seed-applied-landed.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30348] `seedApplied.landed` — which stored rows a published seed landed + * on, per object, split into the rows this publish CREATED and the + * pre-existing rows a seed key MATCHED (objectstack-ai/cloud#3598 ruling A). + * + * One projection for both producers of the receipt: this package's + * `applySeedBodies` (the in-protocol apply both publish doors run) and the + * route-level fallback apply in `@objectstack/runtime`'s package-publish + * door. Two copies would let the receipt's two producers disagree about what + * "landed" means, which is the one thing a reader of the ids cannot check. + * + * The ids are the loader's own (`SeedLoadResult.landedIds` / + * `insertedIds`); nothing here re-derives a row from the dataset. The shape + * and the cap are the spec's ({@link SeedAppliedLandedSchema}, + * {@link SEED_APPLIED_LANDED_IDS_CAP}). + */ + +import { SEED_APPLIED_LANDED_IDS_CAP } from '@objectstack/spec/api'; +import type { SeedAppliedIdListParsed, SeedAppliedLandedParsed } from '@objectstack/spec/api'; + +/** The members of a loader dataset result this projection reads. */ +export interface SeedLandingResult { + readonly object: string; + readonly landedIds?: readonly string[]; + readonly insertedIds?: readonly string[]; +} + +function cappedList(ids: readonly string[]): SeedAppliedIdListParsed { + return { + ids: ids.slice(0, SEED_APPLIED_LANDED_IDS_CAP), + total: ids.length, + truncated: ids.length > SEED_APPLIED_LANDED_IDS_CAP, + }; +} + +/** + * Fold the loader's per-dataset results into one entry per object, in the + * order the objects first loaded; ids keep the loader's record order (dataset + * order, then each dataset's own `landedIds` / `insertedIds` order), first + * occurrence kept. Several datasets of one object merge; an + * id is listed once; an id some dataset of the object INSERTED is never also + * listed as matched (a second dataset's key matching the row the first one + * just created does not make it a customer's row). An object whose datasets + * landed nothing still gets an entry with two empty lists: the loader ran + * and reported, which is not the same answer as an absent key. + */ +export function seedAppliedLanded(results: ReadonlyArray): SeedAppliedLandedParsed[] { + const byObject = new Map; landed: Set }>(); + for (const r of results) { + if (!r || typeof r.object !== 'string') continue; + let acc = byObject.get(r.object); + if (!acc) { + acc = { inserted: new Set(), landed: new Set() }; + byObject.set(r.object, acc); + } + for (const id of r.insertedIds ?? []) acc.inserted.add(id); + for (const id of r.landedIds ?? []) acc.landed.add(id); + } + return [...byObject].map(([object, { inserted, landed }]) => ({ + object, + inserted: cappedList([...inserted]), + matched: cappedList([...landed].filter((id) => !inserted.has(id))), + })); +} diff --git a/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts b/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts index 6e737eed455..b2ef380357f 100644 --- a/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts +++ b/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts @@ -98,8 +98,9 @@ function createFaithfulEngine(opts: { refuseInsert?: (object: string, row: any) return { engine, store }; } -function createMetadata(): IMetadataService { +function createMetadata(extra: Record = {}): IMetadataService { const objects: Record = { + ...extra, lid_team: { name: 'lid_team', fields: { name: { type: 'text' } } }, lid_case: { name: 'lid_case', fields: { name: { type: 'text' }, status: { type: 'text' } } }, // A junction keyed on its REFERENCE: the stored `team` is the resolved @@ -278,3 +279,115 @@ describe('SeedLoadResult.landedIds — the rows a seed landed on (#23354)', () = for (const r of result.results) expect(r.landedIds).toEqual([]); }); }); + +/** + * #30348 — `SeedLoadResult.insertedIds`: which of the landed rows THIS load + * created. A landed id is either a row an insert created or a pre-existing + * row a record's key matched (updated, left as it was, or refused an update); + * the publish receipt splits them so a reader clears only the seed's own rows, + * never a customer's row that happens to share the seed's natural key. + */ +describe('SeedLoadResult.insertedIds — the landed rows this load created (#30348)', () => { + const inserted = (result: { results: Array<{ object: string; insertedIds?: string[] }> }, object: string): string[] => + result.results.filter((r) => r.object === object).flatMap((r) => r.insertedIds ?? []); + + it('a fresh load created every row it landed on: insertedIds equals landedIds, and the spec parse keeps it', async () => { + const { engine, store } = createFaithfulEngine(); + const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + expect(result.success).toBe(true); + for (const object of ['lid_team', 'lid_case', 'lid_membership']) { + expect(inserted(result, object)).toEqual(idsOf(store[object])); + expect(inserted(result, object)).toEqual(landed(result, object)); + } + for (const r of result.results) expect(SeedLoadResultSchema.parse(r).insertedIds).toEqual(r.insertedIds); + }); + + it('a replay that skips or updates created nothing: every landed id is matched, none inserted', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + for (const row of store.lid_case) row.status = 'edited at runtime'; + + const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + // Positive control: both paths ran — cases updated, teams skipped. + expect(replay.results.find((r) => r.object === 'lid_case')!.updated).toBe(2); + expect(replay.results.find((r) => r.object === 'lid_team')!.skipped).toBe(2); + expect(landed(replay, 'lid_case')).toEqual(idsOf(store.lid_case)); + expect(landed(replay, 'lid_team')).toEqual(idsOf(store.lid_team)); + for (const r of replay.results) expect(r.insertedIds).toEqual([]); + }); + + it('under `ignore`, a pre-existing row the key matched is landed but NOT inserted; a fresh record is inserted', async () => { + const { engine, store } = createFaithfulEngine(); + // A customer's own row, written before any seed, sharing the seed's key. + store.lid_case = [{ id: 'customer-row', name: 'CASE-1', status: 'customer edit' }]; + const seeds = [{ + object: 'lid_case', + externalId: 'name', + mode: 'ignore', + records: [{ name: 'CASE-1', status: 'open' }, { name: 'CASE-2', status: 'open' }], + }] as any[]; + + const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds, config: CONFIG }); + + const case2 = store.lid_case.find((r) => r.name === 'CASE-2')!.id; + // Positive control: `ignore` left the customer's row as it was. + expect(store.lid_case.find((r) => r.id === 'customer-row')!.status).toBe('customer edit'); + expect(landed(result, 'lid_case')).toEqual(['customer-row', case2]); + expect(inserted(result, 'lid_case')).toEqual([case2]); + }); + + it('a matched row whose update the engine refused is landed but not inserted', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + for (const row of store.lid_case) row.status = 'edited at runtime'; + (engine.update as any).mockImplementation(async () => { + throw Object.assign(new Error('Validation failed: transition refused'), { code: 'VALIDATION_FAILED', status: 400 }); + }); + + const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + expect(replay.results.find((r) => r.object === 'lid_case')!.errored).toBe(2); + expect(landed(replay, 'lid_case')).toEqual(idsOf(store.lid_case)); + expect(inserted(replay, 'lid_case')).toEqual([]); + }); + + it('the self-referencing (sequential) write path reports its inserts too', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata({ + lid_node: { name: 'lid_node', fields: { name: { type: 'text' }, parent: { type: 'lookup', reference: 'lid_node' } } }, + }); + const seeds = [{ + object: 'lid_node', + externalId: 'name', + mode: 'upsert', + records: [{ name: 'root' }, { name: 'leaf', parent: 'root' }], + }] as any[]; + + const first = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds, config: CONFIG }); + expect(first.success).toBe(true); + expect(inserted(first, 'lid_node')).toEqual(idsOf(store.lid_node)); + expect(inserted(first, 'lid_node')).toHaveLength(2); + + const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds, config: CONFIG }); + expect(landed(replay, 'lid_node')).toEqual(idsOf(store.lid_node)); + expect(inserted(replay, 'lid_node')).toEqual([]); + }); + + it('a refused insert and a dry run create nothing', async () => { + const { engine, store } = createFaithfulEngine({ refuseInsert: (o, row) => o === 'lid_case' && row.name === 'CASE-2' }); + const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: SEEDS, config: CONFIG }); + expect(inserted(result, 'lid_case')).toEqual(idsOf(store.lid_case)); + expect(inserted(result, 'lid_case')).toHaveLength(1); + + const dry = createFaithfulEngine(); + const dryRun = await new SeedLoaderService(dry.engine, createMetadata(), createLogger()).load({ + seeds: SEEDS, + config: { ...CONFIG, dryRun: true }, + }); + for (const r of dryRun.results) expect(r.insertedIds).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 76509986905..4140f681fe2 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1328,6 +1328,12 @@ export class SeedLoaderService implements ISeedLoaderService { // dropped the link ("empty externalId, so no internal id"). const deferredStart = deferredUpdates.length; const internalIdByRecordIndex = new Map(); + /** + * [#30348] The record indexes whose row THIS load created — the insert + * sites mark them beside {@link noteLanded}; every other landed index is a + * pre-existing row a record's key matched. Published as `insertedIds`. + */ + const insertedRecordIndexes = new Set(); /** * Record the id row `recordIndex` landed with. Every write site below goes * through here, so a re-identified row (#21665) enters @@ -1436,6 +1442,7 @@ export class SeedLoaderService implements ISeedLoaderService { written.inserted++; const internalId = this.extractId(res.record); if (internalId) noteLanded(recordIndex, internalId); // [commit 9a884c6e4] + if (internalId) insertedRecordIndexes.add(recordIndex); // [#30348] if (externalIdValue && internalId) { insertedRecords.get(objectName)!.set(externalIdValue, internalId); } @@ -2149,6 +2156,7 @@ export class SeedLoaderService implements ISeedLoaderService { const externalIdValue = this.externalIdKey(record, externalId); const internalId = result.id; if (internalId) noteLanded(i, String(internalId)); // [commit 9a884c6e4] + if (internalId && result.action === 'inserted') insertedRecordIndexes.add(i); // [#30348] if (externalIdValue && internalId) { insertedRecords.get(objectName)!.set(externalIdValue, String(internalId)); } @@ -2268,6 +2276,17 @@ export class SeedLoaderService implements ISeedLoaderService { .sort(([a], [b]) => a - b) .map(([, id]) => id), )]; + // [#30348] The subset this load CREATED, same order and dedupe. An id is + // here only when the record that landed on it was an insert, so a replay + // that matches the rows an earlier load inserted reports them as landed + // and not inserted — the publish receipt tells "created" from "matched" + // by exactly this. + const insertedIds = [...new Set( + [...internalIdByRecordIndex.entries()] + .filter(([recordIndex]) => insertedRecordIndexes.has(recordIndex)) + .sort(([a], [b]) => a - b) + .map(([, id]) => id), + )]; if (this.loadProgress) this.loadProgress.loading = undefined; return { @@ -2283,6 +2302,7 @@ export class SeedLoaderService implements ISeedLoaderService { referencesDropped, summariesStale: this.summariesStale - summariesStaleAtStart, landedIds, + insertedIds, errors, }; } diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index 11e3885544f..5800200be70 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -316,7 +316,19 @@ describe('publishMetaItem response conforms to PublishMetaItemResponseSchema (#7 // rather than their types is what makes the case a measurement: a // receipt whose numbers stopped arriving would still be "a number" if // the schema ever loosened them. - expect(parsed.seedApplied).toEqual({ success: true, inserted: 1, updated: 0 }); + // [#30348] `landed` rides through the parse too: one entry for the + // seeded object, the one created row under `inserted`. The ids are + // checked against the engine in `publish-seed-receipt-landed-ids.test.ts`. + const landedId = parsed.seedApplied?.landed?.[0]?.inserted.ids[0]; + expect(typeof landedId).toBe('string'); + expect(parsed.seedApplied).toEqual({ + success: true, inserted: 1, updated: 0, + landed: [{ + object: 'sys_metadata', + inserted: { ids: [landedId], total: 1, truncated: false }, + matched: { ids: [], total: 0, truncated: false }, + }], + }); }); it('all three receipts at once: the fully-loaded body still strips nothing', async () => { diff --git a/packages/objectql/src/publish-package-drafts-seed-receipt-counts.test.ts b/packages/objectql/src/publish-package-drafts-seed-receipt-counts.test.ts index b95b482cd02..064618da3ff 100644 --- a/packages/objectql/src/publish-package-drafts-seed-receipt-counts.test.ts +++ b/packages/objectql/src/publish-package-drafts-seed-receipt-counts.test.ts @@ -194,7 +194,7 @@ describe('publish-drafts seedApplied counts what was written', () => { const { p, engine } = await makeProtocol(); await stage(p, [{ type: 'seed', name: 'r_good', item: goodSeed(['R1', 'R2']) }]); const first: any = await p.publishPackageDrafts({ packageId: PKG }); - expect(first.seedApplied).toEqual({ success: true, inserted: 2, updated: 0 }); + expect(first.seedApplied).toEqual({ success: true, inserted: 2, updated: 0, landed: expect.any(Array) }); // [#30348] ids pinned in publish-seed-receipt-landed-ids.test.ts // The same natural keys with a changed column: an upsert UPDATE each. await stage(p, [ @@ -228,7 +228,7 @@ describe('publish-drafts seedApplied counts what was written', () => { const res: any = await p.publishPackageDrafts({ packageId: PKG }); expect(await rowsAtEngine(engine)).toEqual(['R1', 'R2', 'R3']); - expect(res.seedApplied).toEqual({ success: true, inserted: 3, updated: 0 }); + expect(res.seedApplied).toEqual({ success: true, inserted: 3, updated: 0, landed: expect.any(Array) }); // [#30348] ids pinned in publish-seed-receipt-landed-ids.test.ts }); it('the FIRST seed fails, a good one follows: nothing was written, and the receipt says 0', async () => { diff --git a/packages/objectql/src/publish-seed-receipt-landed-ids.test.ts b/packages/objectql/src/publish-seed-receipt-landed-ids.test.ts new file mode 100644 index 00000000000..c888047034a --- /dev/null +++ b/packages/objectql/src/publish-seed-receipt-landed-ids.test.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30348] The publish receipt names the rows a seed landed on — + * `seedApplied.landed`, per object, split into `inserted` (rows THIS publish + * created) and `matched` (pre-existing rows a seed key matched). + * + * objectstack-ai/cloud#3598 ruling A: cloud clears an AI build's sample data + * by the ids the publish wrote, and the receipt used to carry only counts. The + * split is the half that keeps that safe: under `ignore` (the create-only + * default since #30081) a seed record whose natural key matches an existing + * row — possibly a customer's own — lands on it without writing, and such a + * row must never be listed as one the seed created. + * + * Every id is checked against the ENGINE (`engine.find` on the seeded object, + * or the id the test wrote itself), never against a value derived from the + * code under test. Driven through the real doors — `publishPackageDrafts` and + * `publishMetaItem` — with the real protocol, a real ObjectQL engine and the + * real `SeedLoaderService` over an in-memory driver (harness copied from + * `publish-package-drafts-seed-receipt-counts.test.ts` — copied, not shared, + * so each file can fail on its own). + */ +// Pays the lint root's first load at collection, never inside a clocked case. +import './__fixtures__/lint-root-preload.testkit.js'; +import { describe, it, expect } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, SEED_APPLIED_LANDED_IDS_CAP } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQL } from './engine.js'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; + +const sysMetadataObject: ServiceObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver }; +} + +const PKG = 'com.objectstack.qa.seedlanded'; +const ITEM = 'seedlanded_item'; +const MISSING = 'seedlanded_nope'; + +/** + * The real protocol over a real engine, with the package installed and ITS + * object published through the same door — so the seeded table is one the + * batch itself put there, exactly as the reproduction staged it. + */ +async function makeProtocol() { + const engine = new ObjectQL(); + const { driver } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any, 'test-package'); + engine.registry.installPackage({ id: PKG, name: 'R', version: '1.0.0' } as never, 'code'); + const p = new ObjectStackProtocolImplementation(engine); + await stage(p, [{ type: 'object', name: ITEM, item: { + name: ITEM, label: 'R', sharingModel: 'public_read_write', + fields: { name: { name: 'name', label: 'Name', type: 'text' } }, + } }]); + const objectPublish: any = await p.publishPackageDrafts({ packageId: PKG }); + expect(objectPublish.outcome).toBe('published'); + return { p, engine }; +} + +/** `PUT /meta/:type/:name?mode=draft&package=PKG` for each draft. */ +async function stage( + p: ObjectStackProtocolImplementation, + drafts: Array<{ type: string; name: string; item: unknown }>, +): Promise { + for (const d of drafts) { + await (p as any).saveMetaItem({ type: d.type, name: d.name, item: d.item, mode: 'draft', packageId: PKG }); + } +} + +/** The seeded object's rows, read at the engine, in stored (insertion) order. */ +async function rowsOf(engine: ObjectQL): Promise> { + return (await engine.find(ITEM, { context: { isSystem: true } })) as Array<{ id: string; name: string }>; +} + +const seed = (names: string[], mode = 'ignore') => ({ object: ITEM, mode, records: names.map((name) => ({ name })) }); +const list = (ids: string[], total = ids.length) => ({ ids, total, truncated: total > ids.length }); +const NONE = list([]); + +describe('seedApplied.landed — the rows a published seed landed on (#30348)', () => { + it('a fresh seed: every row it created is under `inserted`, per object, exactly as the engine holds them', async () => { + const { p, engine } = await makeProtocol(); + await stage(p, [{ type: 'seed', name: 'l_fresh', item: seed(['R1', 'R2', 'R3']) }]); + + const res: any = await p.publishPackageDrafts({ packageId: PKG }); + + const rows = await rowsOf(engine); + expect(rows.map((r) => r.name)).toEqual(['R1', 'R2', 'R3']); + expect(res.seedApplied.success).toBe(true); + expect(res.seedApplied.landed).toEqual([{ object: ITEM, inserted: list(rows.map((r) => r.id)), matched: NONE }]); + // The wire contract carries it: the declared schema parses the real + // response and keeps the key (a schema without it would strip it). + expect(PublishPackageDraftsResponseSchema.parse(res).seedApplied?.landed).toEqual(res.seedApplied.landed); + }); + + it('under `ignore`, a pre-existing row the seed key matched is `matched`, never `inserted`', async () => { + const { p, engine } = await makeProtocol(); + // A customer's own row, written before the seed, sharing its natural key. + const customer: any = await engine.insert(ITEM, { name: 'R1' }, { context: { isSystem: true } } as any); + await stage(p, [{ type: 'seed', name: 'l_ignore', item: seed(['R1', 'R2']) }]); + + const res: any = await p.publishPackageDrafts({ packageId: PKG }); + + const rows = await rowsOf(engine); + const created = rows.find((r) => r.name === 'R2')!.id; + // Positive control: `ignore` wrote no second R1 — the customer's row is the only one. + expect(rows.filter((r) => r.name === 'R1').map((r) => r.id)).toEqual([customer.id]); + expect(res.seedApplied.inserted).toBe(1); + expect(res.seedApplied.landed).toEqual([{ object: ITEM, inserted: list([created]), matched: list([customer.id]) }]); + }); + + it('a re-publish matches the rows the FIRST publish created: `matched`, and nothing `inserted`', async () => { + const { p, engine } = await makeProtocol(); + await stage(p, [{ type: 'seed', name: 'l_again', item: seed(['R1', 'R2']) }]); + const first: any = await p.publishPackageDrafts({ packageId: PKG }); + const ids = (await rowsOf(engine)).map((r) => r.id); + expect(first.seedApplied.landed).toEqual([{ object: ITEM, inserted: list(ids), matched: NONE }]); + + await stage(p, [{ type: 'seed', name: 'l_again', item: seed(['R1', 'R2']) }]); + const again: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect((await rowsOf(engine)).map((r) => r.id)).toEqual(ids); + expect(again.seedApplied.inserted).toBe(0); + expect(again.seedApplied.landed).toEqual([{ object: ITEM, inserted: NONE, matched: list(ids) }]); + }); + + it('over the cap: the first SEED_APPLIED_LANDED_IDS_CAP ids, `truncated: true`, and the uncut `total`', async () => { + expect(SEED_APPLIED_LANDED_IDS_CAP).toBe(200); + const { p, engine } = await makeProtocol(); + const names = Array.from({ length: SEED_APPLIED_LANDED_IDS_CAP + 5 }, (_, i) => `R${String(i).padStart(3, '0')}`); + await stage(p, [{ type: 'seed', name: 'l_big', item: seed(names) }]); + + const res: any = await p.publishPackageDrafts({ packageId: PKG }); + + const rows = await rowsOf(engine); + expect(rows).toHaveLength(names.length); + const byName = new Map(rows.map((r) => [r.name, r.id])); + const inRecordOrder = names.map((n) => byName.get(n)!); + expect(res.seedApplied.inserted).toBe(names.length); + expect(res.seedApplied.landed).toEqual([{ + object: ITEM, + inserted: { ids: inRecordOrder.slice(0, SEED_APPLIED_LANDED_IDS_CAP), total: names.length, truncated: true }, + matched: NONE, + }]); + // The schema holds the same cap: one id more is refused, not carried. + const over = { ...res, seedApplied: { ...res.seedApplied, landed: [{ + object: ITEM, inserted: { ids: inRecordOrder, total: names.length, truncated: false }, matched: NONE, + }] } }; + expect(PublishPackageDraftsResponseSchema.safeParse(over).success).toBe(false); + expect(PublishPackageDraftsResponseSchema.safeParse(res).success).toBe(true); + }); + + it('two seeds of one object fold into ONE entry; an id the batch inserted is never also `matched`', async () => { + const { p, engine } = await makeProtocol(); + await stage(p, [ + { type: 'seed', name: 'l_a', item: seed(['R1', 'R2']) }, + // Its R1 matches the row the first seed just created. + { type: 'seed', name: 'l_b', item: seed(['R1', 'R3']) }, + ]); + + const res: any = await p.publishPackageDrafts({ packageId: PKG }); + + const rows = await rowsOf(engine); + expect(rows.map((r) => r.name).sort()).toEqual(['R1', 'R2', 'R3']); + expect(res.seedApplied.landed).toHaveLength(1); + expect(res.seedApplied.landed[0].object).toBe(ITEM); + expect([...res.seedApplied.landed[0].inserted.ids].sort()).toEqual(rows.map((r) => r.id).sort()); + expect(res.seedApplied.landed[0].matched).toEqual(NONE); + }); + + it('a seed with no records: the loader ran and reported, so one entry with two empty lists', async () => { + const { p, engine } = await makeProtocol(); + await stage(p, [{ type: 'seed', name: 'l_empty', item: seed([]) }]); + + const res: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect(await rowsOf(engine)).toEqual([]); + expect(res.seedApplied.success).toBe(true); + expect(res.seedApplied.inserted).toBe(0); + // Reported-and-empty, which is a different answer from an absent key. + expect(res.seedApplied.landed).toEqual([{ object: ITEM, inserted: NONE, matched: NONE }]); + expect(PublishPackageDraftsResponseSchema.parse(res).seedApplied?.landed).toEqual(res.seedApplied.landed); + }); + + it('a publish with no seed carries no `seedApplied` at all, so no `landed`', async () => { + const { p } = await makeProtocol(); + await stage(p, [{ type: 'object', name: ITEM, item: { + name: ITEM, label: 'R2', sharingModel: 'public_read_write', + fields: { name: { name: 'name', label: 'Name', type: 'text' } }, + } }]); + + const res: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect(res.outcome).toBe('published'); + expect('seedApplied' in res).toBe(false); + }); + + it('a load that threw reports no ids: `landed` absent means "not reported", never "nothing landed"', async () => { + const { p, engine } = await makeProtocol(); + await stage(p, [ + { type: 'seed', name: 'a_good', item: seed(['R1']) }, + { type: 'seed', name: 'z_missing', item: { object: MISSING, mode: 'ignore', records: [{ name: 'Z' }] } }, + ]); + + const res: any = await p.publishPackageDrafts({ packageId: PKG }); + + // Positive control: the good seed's row DID land; only the ids are unreported. + expect((await rowsOf(engine)).map((r) => r.name)).toEqual(['R1']); + expect(res.seedApplied.success).toBe(false); + expect(res.seedApplied.inserted).toBe(1); + expect('landed' in res.seedApplied).toBe(false); + }); + + it('the single-item door (`publishMetaItem`) carries the same `landed`, and its schema keeps it', async () => { + const { p, engine } = await makeProtocol(); + const customer: any = await engine.insert(ITEM, { name: 'R1' }, { context: { isSystem: true } } as any); + await stage(p, [{ type: 'seed', name: 'l_single', item: seed(['R1', 'R2']) }]); + + const res: any = await (p as any).publishMetaItem({ type: 'seed', name: 'l_single', packageId: PKG }); + + const created = (await rowsOf(engine)).find((r) => r.name === 'R2')!.id; + expect(res.seedApplied.landed).toEqual([{ object: ITEM, inserted: list([created]), matched: list([customer.id]) }]); + expect(PublishMetaItemResponseSchema.parse(res).seedApplied?.landed).toEqual(res.seedApplied.landed); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 24765d51f2f..ac4353741fe 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -97,7 +97,8 @@ import type { MetadataProtocol, PackageProtocol } from '@objectstack/spec/api'; // a second copy of `metadata-protocol`'s `applySeedBodies`, and a second // private restatement of "when may a caught sentence be quoted" is where the // two copies start answering differently. -import { clientFacingFailureText, seedRequestValidationError } from '@objectstack/metadata-protocol'; +import { clientFacingFailureText, seedAppliedLanded, seedRequestValidationError } from '@objectstack/metadata-protocol'; +import type { SeedAppliedLandedParsed } from '@objectstack/spec/api'; // [#15591] The DECLARED removal of our OWN read-time annotations, imported // from the list that defines them (`METADATA_READ_DECORATIONS`) rather than // re-spelled here. `applyPublishedSeeds` below re-parses a SERVED document, and @@ -3840,7 +3841,7 @@ async function applyPublishedSeeds( deps: DomainHandlerDeps, names: string[], _context: HttpProtocolContext, -): Promise<{ success: boolean; inserted?: number; updated?: number; errors?: unknown[]; error?: string }> { +): Promise<{ success: boolean; inserted?: number; updated?: number; errors?: unknown[]; error?: string; landed?: SeedAppliedLandedParsed[] }> { // [#4127] `protocol` keeps its `any` — no written contract, so this is where // the ledger honestly ends. `metadata` and `ql` are both evidenced now, // `objectql` as of batch 3: it is the same instance the `data` slot holds. @@ -3976,5 +3977,9 @@ _context: HttpProtocolContext, inserted: r.summary.totalInserted, updated: r.summary.totalUpdated, errors: [...readErrors, ...(r.errors ?? [])], + // [#30348] The same per-object split (created vs matched, capped) the + // in-protocol apply reports — one projection, imported, so the + // receipt's two producers cannot disagree about which rows landed. + landed: seedAppliedLanded(r.results), }; } diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 09564c4eee6..76c422b75e3 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -1064,6 +1064,7 @@ "RouterConfigSchema (const)", "RuntimeAuthoringIssue (type)", "RuntimeAuthoringIssueSchema (const)", + "SEED_APPLIED_LANDED_IDS_CAP (const)", "SERVICE_SELF_INFO_KEY (const)", "STANDARD_SYNONYM_WAIVERS (const)", "SaveMetaItemRequest (type)", @@ -1076,6 +1077,12 @@ "SearchAllPageHitSchema (const)", "SearchAllResponse (type)", "SearchAllResponseSchema (const)", + "SeedAppliedIdList (type)", + "SeedAppliedIdListParsed (type)", + "SeedAppliedIdListSchema (const)", + "SeedAppliedLanded (type)", + "SeedAppliedLandedParsed (type)", + "SeedAppliedLandedSchema (const)", "ServiceInfo (type)", "ServiceInfoSchema (const)", "ServiceSelfInfo (type)", diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 9175f9200e6..1fb560d760d 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -100,6 +100,7 @@ "data/Seed:mode = \"ignore\"", "data/SeedColumn:applyOn = \"always\"", "data/SeedLoadResult:errors = []", + "data/SeedLoadResult:insertedIds = []", "data/SeedLoadResult:landedIds = []", "data/SeedLoadResult:referencesDropped = 0", "data/SeedLoadResult:summariesStale = 0", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 0170378fc39..89611bb643e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1762,6 +1762,12 @@ "api/SearchAllResponse:totalHits", "api/SearchAllResponse:totalObjects", "api/SearchAllResponse:truncated", + "api/SeedAppliedIdList:ids", + "api/SeedAppliedIdList:total", + "api/SeedAppliedIdList:truncated", + "api/SeedAppliedLanded:inserted", + "api/SeedAppliedLanded:matched", + "api/SeedAppliedLanded:object", "api/ServiceInfo:enabled", "api/ServiceInfo:handlerReady", "api/ServiceInfo:message", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 4c26bd133ed..39e71826242 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -903,6 +903,7 @@ "data/SeedLoadResult:errored", "data/SeedLoadResult:errors", "data/SeedLoadResult:inserted", + "data/SeedLoadResult:insertedIds", "data/SeedLoadResult:landedIds", "data/SeedLoadResult:mode", "data/SeedLoadResult:object", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 06d2d826fae..b65d726a349 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -780,6 +780,10 @@ "SearchAllPageHitSchema": "api/SearchAllPageHit", "SearchAllResponse": "api/SearchAllResponse", "SearchAllResponseSchema": "api/SearchAllResponse", + "SeedAppliedIdList": "api/SeedAppliedIdList", + "SeedAppliedIdListSchema": "api/SeedAppliedIdList", + "SeedAppliedLanded": "api/SeedAppliedLanded", + "SeedAppliedLandedSchema": "api/SeedAppliedLanded", "ServiceInfo": "api/ServiceInfo", "ServiceInfoSchema": "api/ServiceInfo", "ServiceSelfInfo": "api/ServiceSelfInfo", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 4a74fce1563..4926520e040 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -1026,6 +1026,7 @@ "RouterConfigSchema": "src/api/router.zod.ts#RouterConfigSchema (const)", "RuntimeAuthoringIssue": "src/api/protocol.zod.ts#RuntimeAuthoringIssue (type)", "RuntimeAuthoringIssueSchema": "src/api/protocol.zod.ts#RuntimeAuthoringIssueSchema (const)", + "SEED_APPLIED_LANDED_IDS_CAP": "src/api/protocol.zod.ts#SEED_APPLIED_LANDED_IDS_CAP (const)", "SERVICE_SELF_INFO_KEY": "src/api/discovery.zod.ts#SERVICE_SELF_INFO_KEY (const)", "STANDARD_SYNONYM_WAIVERS": "src/api/error-code-ledger.zod.ts#STANDARD_SYNONYM_WAIVERS (const)", "SaveMetaItemRequest": "src/api/protocol.zod.ts#SaveMetaItemRequest (type)", @@ -1038,6 +1039,12 @@ "SearchAllPageHitSchema": "src/api/protocol.zod.ts#SearchAllPageHitSchema (const)", "SearchAllResponse": "src/api/protocol.zod.ts#SearchAllResponse (type)", "SearchAllResponseSchema": "src/api/protocol.zod.ts#SearchAllResponseSchema (const)", + "SeedAppliedIdList": "src/api/protocol.zod.ts#SeedAppliedIdList (type)", + "SeedAppliedIdListParsed": "src/api/protocol.zod.ts#SeedAppliedIdListParsed (type)", + "SeedAppliedIdListSchema": "src/api/protocol.zod.ts#SeedAppliedIdListSchema (const)", + "SeedAppliedLanded": "src/api/protocol.zod.ts#SeedAppliedLanded (type)", + "SeedAppliedLandedParsed": "src/api/protocol.zod.ts#SeedAppliedLandedParsed (type)", + "SeedAppliedLandedSchema": "src/api/protocol.zod.ts#SeedAppliedLandedSchema (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 6bbfa98c9ff..fcd33a06f3e 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -423,6 +423,8 @@ "api/SearchAllHit", "api/SearchAllPageHit", "api/SearchAllResponse", + "api/SeedAppliedIdList", + "api/SeedAppliedLanded", "api/ServiceInfo", "api/ServiceSelfInfo", "api/ServiceStatus", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index e265f4a725b..0f17294d7a9 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -3020,3 +3020,55 @@ describe('ListAiConversationsResponseSchema declares the next-page signal', () = expect(ListAiConversationsRequestSchema.parse({ limit: 20, cursor: 'c1' })).toEqual({ limit: 20, cursor: 'c1' }); }); }); + +// ========================================== +// [#30348] seedApplied.landed — the rows a published seed landed on +// ========================================== + +import { + SEED_APPLIED_LANDED_IDS_CAP, + SeedAppliedLandedSchema, + PublishPackageDraftsResponseSchema as LandedBatchSchema, +} from './protocol.zod'; + +describe('seedApplied.landed (#30348) — one shared schema on both publish responses', () => { + const single = { success: true, version: 'hmac-sha256:x', seq: 1 }; + const batch = { + success: true, outcome: 'published', publishedCount: 1, failedCount: 0, + published: [{ type: 'seed', name: 'demo', version: 'hmac-sha256:y' }], failed: [], + }; + const list = (ids: string[], total = ids.length) => ({ ids, total, truncated: total > ids.length }); + const landed = [{ object: 'account', inserted: list(['a1', 'a2']), matched: list(['c9']) }]; + + it('both receipts carry the same `landed` through the parse', () => { + const one = PublishMetaItemResponseSchema.parse({ ...single, seedApplied: { success: true, inserted: 2, updated: 0, landed } }); + const many = LandedBatchSchema.parse({ ...batch, seedApplied: { success: true, inserted: 2, updated: 0, landed } }); + expect(one.seedApplied?.landed).toEqual(landed); + expect(many.seedApplied?.landed).toEqual(landed); + }); + + it('existing receipts — no `landed` — parse unchanged, and gain no key', () => { + const before = { success: true, inserted: 3, updated: 1 }; + const one = PublishMetaItemResponseSchema.parse({ ...single, seedApplied: before }); + const many = LandedBatchSchema.parse({ ...batch, seedApplied: { success: false, error: 'seed apply failed' } }); + expect(one.seedApplied).toEqual(before); + expect('landed' in one.seedApplied!).toBe(false); + expect(many.seedApplied).toEqual({ success: false, error: 'seed apply failed' }); + // And a receipt with no seed carries no `seedApplied` at all. + expect('seedApplied' in PublishMetaItemResponseSchema.parse(single)).toBe(false); + }); + + it('the cap is declared: 200 ids per list; one more is refused, and a cut says so', () => { + expect(SEED_APPLIED_LANDED_IDS_CAP).toBe(200); + const ids = Array.from({ length: SEED_APPLIED_LANDED_IDS_CAP }, (_, i) => `r${i}`); + const cut = { object: 'account', inserted: { ids, total: 250, truncated: true }, matched: list([]) }; + expect(SeedAppliedLandedSchema.parse(cut)).toEqual(cut); + expect(SeedAppliedLandedSchema.safeParse({ ...cut, inserted: { ids: [...ids, 'r200'], total: 250, truncated: true } }).success).toBe(false); + }); + + it('`total` and `truncated` are required on each list, and both lists on each entry', () => { + expect(SeedAppliedLandedSchema.safeParse({ object: 'a', inserted: { ids: [] }, matched: list([]) }).success).toBe(false); + expect(SeedAppliedLandedSchema.safeParse({ object: 'a', inserted: { ids: [], total: 0 }, matched: list([]) }).success).toBe(false); + expect(SeedAppliedLandedSchema.safeParse({ object: 'a', inserted: list([]) }).success).toBe(false); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 9df8ee99117..65aab6040cc 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1408,6 +1408,96 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), })); +/** + * [#30348] The most ids one {@link SeedAppliedIdListSchema} list carries: 200 + * per list, per object. The cloud seed-replay ledger's own cap on + * `seeded_record_ids` (objectstack-ai/cloud#3598) is the bound this mirrors in + * intent; it could not be read from the platform, so the number follows the + * platform's existing bounds on a record-id list — + * `EXPLAIN_BATCH_MAX_RECORD_IDS` (200, the cap on record ids crossing the + * public API) and the seed loader's own 200-row insert batch. Past it a list + * says so ({@link SeedAppliedIdListSchema}'s `truncated` and `total`); ids are + * never dropped silently. + */ +export const SEED_APPLIED_LANDED_IDS_CAP = 200; + +/** + * [#30348] One capped list of row ids on a `seedApplied` receipt. + * + * `ids` is the first {@link SEED_APPLIED_LANDED_IDS_CAP} ids in the loader's + * record order, each once (its first occurrence): datasets in the order the + * loader ran them, and within a dataset the order its records are authored + * (`SeedLoadResult.landedIds` / `insertedIds`). It is NOT the order the writes + * completed, since the loader buffers inserts and flushes them after the + * skips that follow them. `total` is how many ids the list holds before + * the cap, and `truncated` is `true` exactly when `total` exceeds `ids.length` + * — the receipt states a cut instead of making a reader count. + */ +export const SeedAppliedIdListSchema = lazySchema(() => z.object({ + ids: z.array(z.string()).max(SEED_APPLIED_LANDED_IDS_CAP).describe( + 'Row ids in the loader\'s record order (datasets in load order, records as authored), each once ' + + '— at most `SEED_APPLIED_LANDED_IDS_CAP` (200).', + ), + total: z.number().int().min(0).describe( + 'How many ids the list holds before the cap. Equals `ids.length` unless `truncated`.', + ), + truncated: z.boolean().describe( + 'True when the cap cut the list: `total` ids exist and only the first `ids.length` are carried.', + ), +})); +export type SeedAppliedIdList = z.input; +/** Post-parse shape of {@link SeedAppliedIdList} — defaults applied, transforms run (ADR-0122). */ +export type SeedAppliedIdListParsed = z.infer; + +/** + * [#30348] Which stored rows a published seed landed on, for one object — + * the per-object element of `seedApplied.landed` on BOTH publish responses + * ({@link PublishMetaItemResponseSchema}, {@link PublishPackageDraftsResponseSchema}), + * declared once so the two cannot drift. + * + * The seed loader's own answer (`SeedLoadResult.landedIds` / `insertedIds`), + * split by what the publish did to the row: + * + * - `inserted` — rows THIS publish created. The only list a reader may treat + * as the seed's own rows (e.g. to clear sample data later). + * - `matched` — rows that already existed and that a seed record's key + * matched: updated, left as they were (a no-op replay, or `ignore` — the + * create-only default since #30081), or matched for an update the engine + * then refused. Such a row may be a customer's own record whose natural key + * the seed happens to share, or a row an EARLIER publish inserted; ⛔ never + * clear it on this receipt's word. + * + * Per mode: `insert` / `replace` put every row they write in `inserted`; + * `ignore` and `upsert` put a created row in `inserted` and a key-matched one in + * `matched`; `update` creates nothing, so only `matched`. A record whose write + * failed and that matched no row lands nowhere and is in neither list (its + * failure is in `errors[]`). An object appears once even when several + * datasets of the batch target it; an id `inserted` lists is never also in + * `matched`. + */ +export const SeedAppliedLandedSchema = lazySchema(() => z.object({ + object: z.string().describe('The object whose rows the lists name.'), + inserted: SeedAppliedIdListSchema.describe( + 'Rows this publish created. The only ones safe to treat as the seed\'s own.', + ), + matched: SeedAppliedIdListSchema.describe( + 'Pre-existing rows a seed record\'s key matched — updated, left as they were, or refused an ' + + 'update. May be a customer\'s row; never clear these on this receipt\'s word.', + ), +})); +export type SeedAppliedLanded = z.input; +/** Post-parse shape of {@link SeedAppliedLanded} — defaults applied, transforms run (ADR-0122). */ +export type SeedAppliedLandedParsed = z.infer; + +/** [#30348] The `seedApplied.landed` key's description — one text for both publish responses. */ +const SEED_APPLIED_LANDED_DESCRIPTION = + 'Per object, the stored rows the seed landed on: `inserted` (created by this publish) and ' + + '`matched` (pre-existing rows a seed key matched), each capped at ' + + '`SEED_APPLIED_LANDED_IDS_CAP` (200) ids with `total` and `truncated` stating any cut. One ' + + 'entry per object the loader ran, empty lists included. ABSENT means "not reported" — the ' + + 'apply failed before or while loading (see `error`), or the producer reports no ids — never ' + + '"no rows landed".'; + /** * Publish Metadata Item Response * @@ -1434,6 +1524,16 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ * * - `seedApplied` — only when the published type is `seed`. Publishing a seed * is what makes its rows live, so the row materialization rides along. + * [#30348] Its `landed` key names the rows, per object + * ({@link SeedAppliedLandedSchema}): `inserted` — the rows this publish + * created, the only ones a reader may treat as the seed's own — and + * `matched` — pre-existing rows a seed key matched, possibly a customer's. + * Each list carries at most {@link SEED_APPLIED_LANDED_IDS_CAP} (200) ids + * and declares a cut with `truncated: true` beside the uncut `total`; + * absent `landed` means "not reported", never "nothing landed". The same + * key, same schema, rides {@link PublishPackageDraftsResponseSchema}'s + * `seedApplied`. Accepted cost (objectstack-ai/cloud#3598 ruling A): row + * ids on the public publish responses, bounded by the cap. * - `materializeApplied` — only when an ADR-0086 P2 publish materializer is * registered for the type (e.g. `flow`, whose draft credentials become the * live ones). @@ -1492,6 +1592,7 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ 'Per-record failures reported by the seed loader. Present only when the ' + 'loader ran and returned a non-empty error list.', ), + landed: z.array(SeedAppliedLandedSchema).optional().describe(SEED_APPLIED_LANDED_DESCRIPTION), }).optional().describe( 'Outcome of materializing a published `seed` body into data rows. Present ' + 'ONLY when the published type is `seed` — publishing a seed is what makes ' @@ -1792,6 +1893,7 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ + '. Absent on non-validation failures (driver faults, ' + 'unreadable bodies), whose whole story is `error` / `errors[]`.', ), + landed: z.array(SeedAppliedLandedSchema).optional().describe(SEED_APPLIED_LANDED_DESCRIPTION), }).optional().describe( 'Aggregate outcome of materializing EVERY published `seed` body in one ' + 'multi-pass loader run (cross-seed references need the whole set). ' diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts index 3f77d785300..fea17a62106 100644 --- a/packages/spec/src/data/seed-loader.zod.ts +++ b/packages/spec/src/data/seed-loader.zod.ts @@ -416,6 +416,16 @@ export const SeedLoadResultSchema = lazySchema(() => z.object({ * and left as it was. Each id appears once, in record order. Empty on a * dry run, which writes nothing and matches nothing. * + * [#30348] Exactly what the loader fills, per mode: `insert` / `replace` — + * each row an insert created; `ignore` (the default since #30081) — each row + * an insert created AND each existing row a record's key matched, which the + * mode then leaves as it was; `upsert` — created, updated, and matched-but- + * unchanged rows; `update` — updated and matched-but-unchanged rows (an + * unmatched record writes nothing and lands nowhere). A matched row whose + * update the engine REFUSED is listed too: the row exists and its key + * resolved. A record whose insert failed lands nowhere and is not listed. + * {@link insertedIds} says which of these ids this load created. + * * It is the loader's own answer to "which rows did this seed write?" — the * reference and key resolution behind it (natural keys, references resolved * to the target's id, per-organization re-identification) is the loader's, @@ -431,6 +441,24 @@ export const SeedLoadResultSchema = lazySchema(() => z.object({ landedIds: z.array(z.string()).default([]) .describe('Ids of the stored rows this dataset landed on: inserted, updated, or matched on replay'), + /** + * [#30348] The ids among {@link landedIds} whose row THIS load created — an + * insert that succeeded, in record order, each once. Every other landed id + * is a row that already existed and that a record's key matched: updated, + * left as it was (`ignore`, the create-only default since #30081, or a no-op + * replay), or matched for an update the engine refused. Such a row may be + * one no seed ever wrote — a customer's record that shares the seed's + * natural key — so only this list answers "which rows did this seed + * create?". Empty on a dry run. + * + * Per load, not per lifetime: a row an earlier load inserted is MATCHED on a + * replay, so a reader that tracks the seed's rows across loads (a publish + * receipt's ledger, objectstack-ai/cloud#3598) accumulates these ids over + * every load rather than reading the latest one alone. + */ + insertedIds: z.array(z.string()).default([]) + .describe('Ids among landedIds whose row this load created (a successful insert); every other landed id was a pre-existing row a record key matched'), + /** Reference resolution errors for this object */ errors: z.array(ReferenceResolutionErrorSchema).default([]) .describe('Reference resolution errors'),