diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b07b00..154586b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,9 +72,12 @@ jobs: - name: Build shadcn registry run: pnpm build:registry + - name: Install Chromium for App Kit hydration smoke + run: pnpm --filter blocks exec playwright install --with-deps chromium + - name: Verify representative registry installs env: - SMOKE_CASE: ai,schema-builder,command-palette + SMOKE_CASE: ai,schema-builder,command-palette,app-kit-data-default,app-kit-data-custom,app-kit-event-studio-next16 SMOKE_REUSE_PACKED_ARTIFACTS: '1' run: pnpm --filter @constructive-io/registry smoke:install diff --git a/apps/blocks/package.json b/apps/blocks/package.json index c45e527..e316064 100644 --- a/apps/blocks/package.json +++ b/apps/blocks/package.json @@ -39,11 +39,15 @@ "@constructive-io/schema-builder": "workspace:*", "@constructive-io/sheets": "workspace:*", "@constructive-io/ui": "workspace:*", + "@tanstack/charts": "0.6.4", + "@tanstack/charts-scales": "0.6.4", + "@tanstack/react-charts": "0.6.4", "@tanstack/react-form": "^1.27.7", "@tanstack/react-query": "^5.90.16", "ai": "^7.0.26", "clsx": "^2.1.1", "dompurify": "^3.3.1", + "d3-scale": "4.0.2", "gql-ast": "^3.3.3", "graphql": "16.13.0", "lucide-react": "^0.525.0", diff --git a/apps/blocks/registry.json b/apps/blocks/registry.json index a1296c6..14c2f5a 100644 --- a/apps/blocks/registry.json +++ b/apps/blocks/registry.json @@ -1051,6 +1051,370 @@ "type": "registry:lib" } ] + }, + { + "name": "app-kit-core", + "type": "registry:block", + "title": "Constructive App Kit Core", + "description": "Typed, server-safe application resources, queries, actions, scope partitioning, schema validation, and opt-in client runtime.", + "docs": "Install this root to define Constructive-native application contracts without choosing a view family. Import the client runtime explicitly when a connected view needs TanStack Query. Credentials never belong in definitions or AppScope.\n\nGuide: https://constructive-io.github.io/blocks/blocks/app-kit/", + "dependencies": [ + "@tanstack/react-query" + ], + "meta": { + "constructive": { + "version": 1, + "family": "app-kit", + "kind": "runtime", + "boundary": "mixed", + "provider": "app-kit", + "dataShapes": ["record", "collection", "aggregate", "temporal"], + "intents": ["define-resources", "load-data", "execute-actions", "validate-schema"], + "capabilities": ["resource-contracts", "query-contracts", "action-contracts", "scoped-cache", "schema-validation", "navigation-adapters"], + "slots": ["record-opener", "url-state-adapter"], + "events": ["query-settled", "action-settled", "scope-changed"], + "compatibleWith": ["app-kit-data", "app-kit-board", "app-kit-dashboard", "app-kit-calendar", "app-kit-workflow", "app-kit-event-studio"] + } + }, + "files": [ + { + "path": "registry/constructive/blocks/app-kit/core/contracts.ts", + "target": "src/blocks/app-kit/core/contracts.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/app-kit/core/navigation.ts", + "target": "src/blocks/app-kit/core/navigation.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/app-kit/core/schema-validation.ts", + "target": "src/blocks/app-kit/core/schema-validation.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/app-kit/core/scope.ts", + "target": "src/blocks/app-kit/core/scope.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/app-kit/core/runtime.tsx", + "target": "src/blocks/app-kit/core/runtime.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/core/index.ts", + "target": "src/blocks/app-kit/core/index.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "app-kit-data", + "type": "registry:block", + "title": "Constructive App Kit Data Views", + "description": "Controlled and connected tables, lists, cards, record details, forms, relations, collection controls, and action bars.", + "docs": "Use this root for record and collection applications. Loaders stay server-driven, relation pickers search remotely, ambiguous identities fail read-only, and every view distinguishes loading, empty, denied, and error states.\n\nGuide: https://constructive-io.github.io/blocks/blocks/app-kit/", + "dependencies": [ + "@tanstack/react-query", + "lucide-react" + ], + "meta": { + "constructive": { + "version": 1, + "family": "app-kit", + "kind": "view", + "boundary": "client", + "provider": "app-kit", + "dataShapes": ["record", "collection"], + "intents": ["browse", "search", "filter", "sort", "edit", "relate", "bulk-act"], + "capabilities": ["table", "list", "cards", "record-detail", "generated-form", "relation-picker", "relation-panel", "pagination", "bulk-actions"], + "slots": ["cell-renderer", "record-renderer", "form-field", "toolbar", "footer"], + "events": ["state-change", "open-record", "submit", "link-record", "unlink-record", "bulk-action"], + "compatibleWith": ["app-kit-core", "app-kit-board", "app-kit-dashboard", "app-kit-calendar", "app-kit-workflow", "app-kit-event-studio"] + } + }, + "files": [ + { + "path": "registry/constructive/blocks/app-kit/data/types.ts", + "target": "src/blocks/app-kit/data/types.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/app-kit/data/states.tsx", + "target": "src/blocks/app-kit/data/states.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/data/controls.tsx", + "target": "src/blocks/app-kit/data/controls.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/data/collections.tsx", + "target": "src/blocks/app-kit/data/collections.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/data/detail-form.tsx", + "target": "src/blocks/app-kit/data/detail-form.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/data/relations.tsx", + "target": "src/blocks/app-kit/data/relations.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/data/action-bars.tsx", + "target": "src/blocks/app-kit/data/action-bars.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/data/index.ts", + "target": "src/blocks/app-kit/data/index.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "app-kit-board", + "type": "registry:block", + "title": "Constructive App Kit Board", + "description": "A typed controlled board and connected resource wrapper with semantic moves, keyboard alternatives, and mutation rollback.", + "docs": "Board movement is disabled until the host supplies an explicit semantic action. Pointer and keyboard moves use the same action contract, restore focus, and roll back optimistic state on denial or failure.\n\nGuide: https://constructive-io.github.io/blocks/blocks/app-kit/", + "dependencies": [ + "lucide-react" + ], + "meta": { + "constructive": { + "version": 1, + "family": "app-kit", + "kind": "view", + "boundary": "client", + "provider": "app-kit", + "dataShapes": ["collection", "board"], + "intents": ["plan", "prioritize", "move-records", "inspect"], + "capabilities": ["board", "typed-columns", "semantic-move", "keyboard-move", "optimistic-rollback"], + "slots": ["card"], + "events": ["open-record", "move-record", "move-denied"], + "compatibleWith": ["app-kit-core", "app-kit-data", "app-kit-dashboard", "app-kit-calendar", "app-kit-workflow", "app-kit-event-studio"] + } + }, + "files": [ + { + "path": "registry/constructive/blocks/app-kit/board/board.tsx", + "target": "src/blocks/app-kit/board/board.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/board/connected-board.tsx", + "target": "src/blocks/app-kit/board/connected-board.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/board/index.ts", + "target": "src/blocks/app-kit/board/index.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "app-kit-dashboard", + "type": "registry:block", + "title": "Constructive App Kit Dashboard", + "description": "Explicit analytical widgets and a versioned, replaceable layout store on the accessible SVG and SSR foundation from TanStack Charts.", + "docs": "Dashboard widgets always use explicit analytical loaders; they never derive totals from a paginated collection page. Runtime customization is limited to an approved typed catalog, while layout persistence is replaceable.\n\nGuide: https://constructive-io.github.io/blocks/blocks/app-kit/", + "dependencies": [ + "@tanstack/charts@0.6.4", + "@tanstack/charts-scales@0.6.4", + "@tanstack/react-charts@0.6.4", + "d3-scale@4.0.2", + "lucide-react" + ], + "meta": { + "constructive": { + "version": 1, + "family": "app-kit", + "kind": "view", + "boundary": "client", + "provider": "app-kit", + "dataShapes": ["aggregate", "series", "breakdown"], + "intents": ["monitor", "analyze", "compare", "configure-layout"], + "capabilities": ["kpi", "bar-chart", "line-chart", "breakdown-table", "widget-catalog", "layout-store", "layout-persistence"], + "slots": ["widget"], + "events": ["layout-change"], + "compatibleWith": ["app-kit-core", "app-kit-data", "app-kit-board", "app-kit-calendar", "app-kit-workflow", "app-kit-event-studio"] + } + }, + "files": [ + { + "path": "registry/constructive/blocks/app-kit/dashboard/widgets.tsx", + "target": "src/blocks/app-kit/dashboard/widgets.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/dashboard/layout-store.ts", + "target": "src/blocks/app-kit/dashboard/layout-store.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/app-kit/dashboard/dashboard.tsx", + "target": "src/blocks/app-kit/dashboard/dashboard.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/dashboard/connected-dashboard.tsx", + "target": "src/blocks/app-kit/dashboard/connected-dashboard.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/dashboard/persisted-dashboard.tsx", + "target": "src/blocks/app-kit/dashboard/persisted-dashboard.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/dashboard/index.ts", + "target": "src/blocks/app-kit/dashboard/index.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "app-kit-calendar", + "type": "registry:block", + "title": "Constructive App Kit Calendar", + "description": "Localized controlled month and agenda views with explicit timezones and connected range-query loading.", + "docs": "The host owns record opening and editing, while the connected wrapper loads only the visible range. Recurrence, resource scheduling, and drag rescheduling are intentionally outside V1.\n\nGuide: https://constructive-io.github.io/blocks/blocks/app-kit/", + "dependencies": [ + "lucide-react" + ], + "meta": { + "constructive": { + "version": 1, + "family": "app-kit", + "kind": "view", + "boundary": "client", + "provider": "app-kit", + "dataShapes": ["temporal", "collection"], + "intents": ["schedule", "browse-by-date", "inspect"], + "capabilities": ["temporal", "month", "agenda", "range-query", "localization", "timezone"], + "events": ["range-change", "view-change", "open-record"], + "compatibleWith": ["app-kit-core", "app-kit-data", "app-kit-board", "app-kit-dashboard", "app-kit-workflow", "app-kit-event-studio"] + } + }, + "files": [ + { + "path": "registry/constructive/blocks/app-kit/calendar/calendar.tsx", + "target": "src/blocks/app-kit/calendar/calendar.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/calendar/connected-calendar.tsx", + "target": "src/blocks/app-kit/calendar/connected-calendar.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/calendar/index.ts", + "target": "src/blocks/app-kit/calendar/index.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "app-kit-workflow", + "type": "registry:block", + "title": "Constructive App Kit Workflow UI", + "description": "Controlled and connected action surfaces, confirmation and input dialogs, bulk execution, and a multi-step stepper.", + "docs": "This root composes application and server actions into accessible interaction surfaces; it is not a durable workflow engine. Validation, permission failures, cancellation, and partial errors remain explicit.\n\nGuide: https://constructive-io.github.io/blocks/blocks/app-kit/", + "dependencies": [ + "lucide-react" + ], + "meta": { + "constructive": { + "version": 1, + "family": "app-kit", + "kind": "composition", + "boundary": "client", + "provider": "app-kit", + "dataShapes": ["record", "collection", "action-input"], + "intents": ["execute-action", "confirm", "collect-input", "bulk-execute", "guide-steps"], + "capabilities": ["action-button", "action-menu", "input-dialog", "confirmation-dialog", "bulk-execution", "stepper"], + "slots": ["action-input", "confirmation-body", "step-content"], + "events": ["action-start", "action-settled", "step-change", "cancel"], + "compatibleWith": ["app-kit-core", "app-kit-data", "app-kit-board", "app-kit-dashboard", "app-kit-calendar", "app-kit-event-studio"] + } + }, + "files": [ + { + "path": "registry/constructive/blocks/app-kit/workflow/actions.tsx", + "target": "src/blocks/app-kit/workflow/actions.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/workflow/connected-actions.tsx", + "target": "src/blocks/app-kit/workflow/connected-actions.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/workflow/stepper.tsx", + "target": "src/blocks/app-kit/workflow/stepper.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/workflow/index.ts", + "target": "src/blocks/app-kit/workflow/index.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "app-kit-event-studio", + "type": "registry:block", + "title": "Constructive App Kit Event Studio", + "description": "An opt-in page-scale proof starter combining analytical, collection, board, calendar, relation, form, and action views.", + "docs": "Event Studio pairs with the supported b2b:storage blueprint recipe and demonstrates explicit analytical loaders, semantic board movement, localized range queries, searchable relations, permission-aware actions, and host-controlled URL state. Supply adapters generated from the final executable GraphQL schema.\n\nGuide: https://constructive-io.github.io/blocks/blocks/app-kit/", + "dependencies": [ + "lucide-react", + "zod" + ], + "meta": { + "constructive": { + "version": 1, + "family": "app-kit", + "kind": "starter", + "boundary": "mixed", + "provider": "app-kit", + "dataShapes": ["record", "collection", "aggregate", "temporal", "board"], + "intents": ["manage-events", "analyze", "schedule", "publish", "relate-people", "configure-views"], + "capabilities": ["event-studio", "dashboard", "collections", "record-detail", "generated-form", "relations", "board", "calendar", "actions", "url-state"], + "slots": ["record-opener", "dashboard-layout-store", "url-state-adapter"], + "events": ["view-change", "open-record", "move-session", "publish-session", "schedule-session", "link-person"], + "compatibleWith": ["app-kit-core", "app-kit-data", "app-kit-board", "app-kit-dashboard", "app-kit-calendar", "app-kit-workflow"] + } + }, + "files": [ + { + "path": "registry/constructive/blocks/app-kit/event-studio/definitions.ts", + "target": "src/blocks/app-kit/event-studio/definitions.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/app-kit/event-studio/state.ts", + "target": "src/blocks/app-kit/event-studio/state.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/app-kit/event-studio/event-studio.tsx", + "target": "src/blocks/app-kit/event-studio/event-studio.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/app-kit/event-studio/index.ts", + "target": "src/blocks/app-kit/event-studio/index.ts", + "type": "registry:lib" + } + ] } ] } diff --git a/apps/blocks/src/app/blocks/app-kit/page.tsx b/apps/blocks/src/app/blocks/app-kit/page.tsx new file mode 100644 index 0000000..095b9e8 --- /dev/null +++ b/apps/blocks/src/app/blocks/app-kit/page.tsx @@ -0,0 +1,1115 @@ +import type { Metadata } from 'next'; + +import { Badge } from '@constructive-io/ui/badge'; + +import { ApplicationDocPagination } from '@/components/docs/application-doc-pagination'; +import { CodeBlock } from '@/components/docs/code-block'; +import { APP_KIT_CATALOG } from '@/lib/app-kit-catalog'; +import { registryAdd } from '@/lib/install-mode'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +const TITLE = 'Constructive App Kit'; +const DESCRIPTION = + 'A source-installed application-composition layer for building arbitrary Constructive-native apps from typed resources, queries, actions, and domain-neutral views.'; + +const ROOT_INSTALLS = APP_KIT_CATALOG + .filter(({ name }) => name !== 'app-kit-event-studio') + .map(({ name }) => registryAdd(name)) + .join('\n'); + +const CORE_ENTRYPOINTS = `// Server-safe definitions and build-time validation +import { + defineAction, + defineQuery, + defineResource, + validateAppResource, + type AppScope, +} from '@/blocks/app-kit/core' + +// Client-only TanStack Query runtime +import { + AppKitProvider, + useAppAction, + useAppQuery, +} from '@/blocks/app-kit/core/runtime'`; + +const SCOPE_EXAMPLE = `const scope: AppScope = { + endpointId: 'data', + databaseId, + sessionPartition: authenticatedSession.id, + organizationId, + schemaRevision, + securityRevision, +} + +// Tokens, cookies, headers, and CSRF values stay in the injected transport. +// They never belong in AppScope, definitions, URLs, stores, or query keys. +// Query keys retain an opaque deterministic input fingerprint, not raw input.`; + +const CORE_SIGNATURES = `type AppScope = Readonly<{ + endpointId: string + databaseId: string + sessionPartition: string + organizationId?: string | null + tenantId?: string | null + schemaRevision: string + securityRevision: string +}> + +defineQuery( + definition: AppQueryDefinition +): AppQueryDefinition + +defineAction( + definition: AppActionDefinition +): AppActionDefinition + +defineResource( + definition: AppResourceDefinition +): AppResourceDefinition + +validateAppResource(resource, { meta, introspection }): AppResourceValidationResult + +AppKitProvider({ scope, queryClient, children }) +useAppQuery(definition, input, { enabled?, staleTime? }): UseQueryResult +useAppAction(definition, options?): { + execute(input): Promise> + cancel() + reset() + evaluatePresentation(input, context?) + visible + disabledReason + confirmation + mutation +}`; + +const EXECUTION_CONTRACT = `type AppExecutionContext = Readonly<{ + input: TInput // plain, credential-free input + scope: AppScope // cache and authorization partition identity + signal: AbortSignal // forward this to fetch or the transport +}> + +type AppErrorKind = + | 'authentication' | 'authorization' | 'cancelled' | 'conflict' + | 'graphql' | 'network' | 'not-found' | 'validation' | 'unknown' + +type AppError = Readonly<{ + message: string + kind: AppErrorKind + code?: string + retryable?: boolean + fieldErrors?: readonly Readonly<{ field: string; message: string }>[] + details?: Readonly> +}> + +type AppResult = ( + | Readonly<{ ok: true; data: T }> + | Readonly<{ ok: false; error: AppError }> +) & Readonly<{ + __constructiveAppKitResult: { + kind: 'constructive.app-kit/result' + version: 1 + } +}> + +type AppExecutorResult = T | AppResult + +appSuccess(data: T): AppResult +appFailure(error: AppError): AppResult +normalizeAppError(error: unknown, fallback?: string): AppError`; + +const CORE_DEFINITIONS_EXAMPLE = `import { z } from 'zod' + +import { + defineAction, + defineQuery, + defineResource +} from '@/blocks/app-kit/core' +import type { + AppCollectionPage, + AppCollectionQueryInput +} from '@/blocks/app-kit/data' +import { taskTransport } from './task-transport' + +export type Task = Readonly<{ + id: string + title: string + status: 'TODO' | 'DOING' | 'DONE' +}> + +const list = defineQuery>({ + id: 'tasks.list', + execute: (context) => taskTransport.list(context), + staleTime: 30_000 +}) + +const complete = defineAction({ + id: 'tasks.complete', + inputSchema: z.object({ taskId: z.string().min(1) }), + execute: (context) => taskTransport.complete(context), + presentation: { + label: 'Complete task', + confirmation: { + title: 'Complete this task?', + description: 'The task will move to Done.' + } + }, + invalidate: ({ input }) => [ + { queryId: 'tasks.list' }, + { queryId: 'tasks.detail', input: input.taskId, exact: true } + ] +}) + +export const taskResource = defineResource< + Task, + string, + AppCollectionQueryInput, + AppCollectionPage, + { complete: typeof complete } +>({ + id: 'tasks', + label: 'Task', + pluralLabel: 'Tasks', + source: { + schemaName: 'app', + tableName: 'tasks', + graphQLTypeName: 'Task', + listFieldName: 'tasks', + updateMutationName: 'updateTask' + }, + fields: [ + { key: 'id', databaseName: 'id', graphQLName: 'id', label: 'ID', kind: 'string', readOnly: true }, + { key: 'title', databaseName: 'title', graphQLName: 'title', label: 'Title', kind: 'string' }, + { + key: 'status', + databaseName: 'status', + graphQLName: 'status', + label: 'Status', + kind: 'enum', + options: [ + { label: 'To do', value: 'TODO' }, + { label: 'Doing', value: 'DOING' }, + { label: 'Done', value: 'DONE' } + ] + } + ], + displayField: 'title', + identity: { fields: ['id'], read: (task) => task.id, serialize: (id) => id }, + forms: { update: { fields: [{ field: 'title', required: true }, { field: 'status' }] } }, + queries: { list }, + actions: { complete } +}) + +export const taskApp = { actions: { complete }, queries: { list }, resource: taskResource }`; + +const CORE_RUNTIME_EXAMPLE = `'use client' + +import * as React from 'react' +import { QueryClient } from '@tanstack/react-query' + +import type { AppScope } from '@/blocks/app-kit/core' +import { AppKitProvider, useAppQuery } from '@/blocks/app-kit/core/runtime' +import { DEFAULT_APP_COLLECTION_STATE } from '@/blocks/app-kit/data' +import { taskApp } from './task-app' + +function TaskCount() { + const result = useAppQuery(taskApp.queries.list, DEFAULT_APP_COLLECTION_STATE) + if (result.isPending) return

Loading tasks…

+ if (result.error) return

{result.error.message}

+ return

{result.data?.pageInfo.totalCount ?? result.data?.items.length ?? 0} tasks

+} + +export function TaskRuntime({ scope }: Readonly<{ scope: AppScope }>) { + const [queryClient] = React.useState(() => new QueryClient()) + return ( + + + + ) +}`; + +const DATA_CONTRACT = `// Shared controlled collection props. +AppDataTable | AppDataList | AppDataCards + common props: { + resource + state: AppDataState> + getRowKey(record) + selectedKeys? + onSelectionChange? + onOpenRecord? + toolbar? + footer? | renderFooter(page)? + density? + surface? + } + +AppDataTable({ ...common, columns?: readonly AppColumn[] }) +AppDataList({ ...common, renderRecord?: (record) => ReactNode }) +AppDataCards({ ...common, renderRecord?: (record) => ReactNode }) + +// onSelectionChange enables selection in table, list, and cards; +// selectedKeys controls which record keys are checked. +// columns is table-only; renderRecord customizes list and card bodies. + +ConnectedAppDataTable | ConnectedAppDataList | ConnectedAppDataCards + props: Omit & { + query: AppQueryDefinition> + queryInput: AppCollectionQueryInput + enabled?: boolean + } + +AppRecordDetail({ resource, state, renderField?, actions?, surface?, onRetry? }) +ConnectedAppRecordDetail({ resource, query, identity, enabled?, renderField?, actions?, surface? }) +AppRecordForm({ resource, mode, values, onChange, onSubmit, errors?, inputRenderers? }) +ConnectedAppRecordForm({ resource, mode, initialValues, action, toInput, onCompleted?, resetKey? }) + +type AppCollectionState = Readonly<{ + search: string + filters: readonly { id: string; value: string }[] + sort: readonly { id: string; direction: 'asc' | 'desc' }[] + page: number + pageSize: number +}> + +type AppFilterDefinition = Readonly<{ + id: string + label: string + options: readonly Readonly<{ label: string; value: string }>[] +}> + +type AppSortDefinition = Readonly<{ id: string; label: string }> +type AppPageInfo = Readonly<{ + page: number + pageSize: number + totalCount?: number + hasNextPage: boolean + hasPreviousPage: boolean +}> + +AppCollectionToolbar({ + state, onStateChange, filters?, sorts?, actions?, searchLabel? +}) +AppPagination({ pageInfo: AppPageInfo, onPageChange }) + +// Search, filter, and sort changes reset page to 1. Pagination only emits page.`; + +const DATA_EXAMPLE = `import { + AppDataTable, + ConnectedAppDataTable +} from '@/blocks/app-kit/data' +import { taskApp } from './task-app' + +// Controlled: the host owns the data state. + task.id} + onOpenRecord={openTask} + resource={taskApp.resource} + state={taskState} +/> + +// Connected: the wrapper owns query execution and state mapping. + task.id} + onOpenRecord={openTask} + query={taskApp.queries.list} + queryInput={collectionState} + resource={taskApp.resource} +/>`; + +const RELATION_CONTRACT = `AppRelationPicker({ + label, options, search, onSearchChange, value?, onValueChange, + loading?, error?, hasMore?, onLoadMore?, disabled?, placeholder? +}) +ConnectedAppRelationPicker({ + label, query, search, onSearchChange, value?, onValueChange, + pageSize?, debounceMs?, disabled?, placeholder? +}) + +AppRelationPanel({ + title, state, getRecordKey, renderRecord, + onOpenRecord?, onUnlink?, canUnlink?, picker?, onRetry? +}) +ConnectedAppRelationPanel({ + title, query, input, getRecordKey, renderRecord, + enabled?, onOpenRecord?, onUnlink?, canUnlink?, picker? +})`; + +const RELATION_EXAMPLE = `import type { AppRelationOption } from '@/blocks/app-kit/data' + +type Person = Readonly<{ id: string; name: string }> + +const [search, setSearch] = React.useState('') +const [assignee, setAssignee] = React.useState | null>(null) + +// Controlled options. + + +// Connected server search. +`; + +const BOARD_CONTRACT = `AppBoard({ + columns, records, getRecordId, getRecordLabel, getColumnId, + renderCard?, onOpenRecord?, onMove?, canMove?, density?, surface? +}) + +ConnectedAppBoard({ + columns, query, queryInput, getRecordId, getRecordLabel, getColumnId, + renderCard?, onOpenRecord?, canMove?, + moveAction?: { + definition: AppActionDefinition + input: (move: AppBoardMove) => TMoveInput + } +})`; + +const BOARD_EXAMPLE = `const boardProps = { + columns: taskStatusColumns, + getColumnId: (task: Task) => task.status, + getRecordId: (task: Task) => task.id, + getRecordLabel: (task: Task) => task.title +} + +// Controlled semantic mutation. + + +// Connected mutation; omitting moveAction makes the board read-only. + ({ + taskId: recordId, + status: toColumnId + }) + }} +/>`; + +const DASHBOARD_CONTRACT = `const APP_DASHBOARD_LAYOUT_VERSION = 1 as const + +type AppDashboardWidgetSize = 'third' | 'half' | 'wide' | 'full' +interface AppDashboardPlacement { + widgetId: string + order: number + size: AppDashboardWidgetSize + hidden?: boolean +} +interface AppDashboardLayout { + version: typeof APP_DASHBOARD_LAYOUT_VERSION + placements: readonly AppDashboardPlacement[] +} + +interface AppDashboardLayoutStore { + load(key: string): AppDashboardLayout | null | Promise + save(key: string, layout: AppDashboardLayout): void | Promise +} + +interface ConstructiveAppDashboardLayoutAdapter { + loadLayout(key: string): AppDashboardLayout | null | Promise + saveLayout(key: string, layout: AppDashboardLayout): void | Promise +} + +parseAppDashboardLayout(value: unknown): AppDashboardLayout | null +reconcileAppDashboardLayout(layout, widgetIds, defaultSizes?): AppDashboardLayout +createLocalStorageAppDashboardLayoutStore(storage?): AppDashboardLayoutStore +createConstructiveAppDashboardLayoutStore( + adapter: ConstructiveAppDashboardLayoutAdapter +): AppDashboardLayoutStore + +AppDashboard({ + catalog: AppDashboardWidgetCatalog + layout: AppDashboardLayout + onLayoutChange? + renderWidget? + density? + surface? +}) + +ConnectedAppDashboard({ + widgets: readonly ( + | AppKpiWidgetDefinition & { query: AppQueryDefinition; input: TInput } + | AppRowsWidgetDefinition & { query: AppQueryDefinition; input: TInput } + )[] + layout + onLayoutChange? +}) + +PersistedAppDashboard({ catalog, layoutKey, layoutStore, onLayoutError? })`; + +const DASHBOARD_EXAMPLE = `// Controlled inert payloads. +const catalog = createAppDashboardWidgetCatalog([{ + id: 'open-count', + kind: 'kpi', + title: 'Open tasks', + state: { status: 'ready', value: { value: 42 } } +}]) + + +// Connected explicit analytical loader. + + +// Versioned persistence is optional and replaceable. +const layoutStore = createLocalStorageAppDashboardLayoutStore() +`; + +const CALENDAR_CONTRACT = `AppCalendar({ + events, month, onMonthChange, timeZone, locale?, weekStartsOn?, + view, onViewChange, onOpenRecord?, density?, surface? +}) + +type AppCalendarRange = { + startDate: string // inclusive local date + endDate: string // exclusive local date + timeZone: string +} + +ConnectedAppCalendar({ + query: AppQueryDefinition[]> + month, onMonthChange, timeZone, locale?, weekStartsOn?, + view, onViewChange, onOpenRecord?, density?, surface? +})`; + +const CALENDAR_EXAMPLE = `const calendarProps = { + month, + onMonthChange: setMonth, + onOpenRecord: openTask, + onViewChange: setView, + timeZone: 'America/Los_Angeles', + view +} + +// Controlled events. + + +// Connected range query. The wrapper derives the visible range. +`; + +const WORKFLOW_CONTRACT = `AppActionButton({ action: AppActionItem, size?, variant? }) +AppActionMenu({ actions: readonly AppActionItem[], label?, onActionComplete? }) +AppActionDialog({ + open, onOpenChange, title, description, submitLabel, + confirmation?, disabledReason?, children, onSubmit +}) +AppBulkActionBar({ selectedCount, actions, onClearSelection }) + +ConnectedAppActionButton({ definition, input, label?, size?, variant? }) +ConnectedAppActionMenu({ actions: readonly { definition, input, label? }[] }) +ConnectedAppActionDialog({ definition, input, open, onOpenChange, children, title?, description? }) +ConnectedAppBulkActionBar({ selection, actions, onClearSelection }) + +AppWorkflowStepper({ + steps, activeStep, onActiveStepChange, + onComplete?, completeLabel?, canContinue?, busy?, error? +})`; + +const WORKFLOW_EXAMPLE = `// Controlled host command. + completeTask(task.id) +}} /> + +// Connected App Kit action with validation, policy, and invalidation. + + +// The stepper is controlled UI, not a durable workflow engine. + }, + { id: 'review', title: 'Review', content: } + ]} +/>`; + +const FIELD_CONTRACT = `type AppFieldKind = + | 'string' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'enum' + | 'string-array' | 'integer-array' | 'float-array' | 'boolean-array' + | 'date-array' | 'datetime-array' | 'enum-array' + | 'json' | 'custom' + +type AppFieldDefinition = Readonly<{ + key: keyof TRecord & string + databaseName: string + graphQLName: string + label: string + description?: string + kind: AppFieldKind + nullable?: boolean + arrayElementNullable?: boolean + readOnly?: boolean + options?: readonly { label: string; value: string }[] +}>`; + +const NAVIGATION_CONTRACT = `type AppRecordLocator = Readonly<{ + resourceId: string + identity: (record: TRecord) => string +}> + +createAppRouteRecordOpener({ + locator, + href: (target) => string, + navigate: (href, target) => void +}): (record) => void + +createAppStackRecordOpener({ + locator, + card: (target) => TCard, + open: (card: TCard, target) => void +}): (record) => void + +type AppUrlStateAdapter = Readonly<{ + read(search: string | URLSearchParams): TState + write(state: TState, current?: string | URLSearchParams): URLSearchParams +}> + +defineAppUrlStateAdapter({ + keys: readonly string[], + decode: (params: URLSearchParams) => TState, + encode: (state: TState) => URLSearchParams +}): AppUrlStateAdapter`; + +const NAVIGATION_EXAMPLE = `const viewState = defineAppUrlStateAdapter<{ + view: 'table' | 'board' + search: string + selected?: string +}>({ + keys: ['view', 'q', 'selected'], + decode: (params) => ({ + view: params.get('view') === 'board' ? 'board' : 'table', + search: params.get('q') ?? '', + selected: params.get('selected') ?? undefined + }), + encode: (state) => { + const params = new URLSearchParams({ view: state.view }) + if (state.search) params.set('q', state.search) + if (state.selected) params.set('selected', state.selected) + return params + } +}) + +const openTaskRoute = createAppRouteRecordOpener({ + locator: { resourceId: 'tasks', identity: (task: Task) => task.id }, + href: ({ resourceId, identity }) => \`/app/\${resourceId}/\${identity}\`, + navigate: (href) => router.push(href) +}) + +// Stack is an optional host adapter; App Kit never imports it. +const openTaskCard = createAppStackRecordOpener({ + locator: { resourceId: 'tasks', identity: (task: Task) => task.id }, + card: ({ identity }) => ({ id: \`task:\${identity}\`, type: 'task-detail' }), + open: (card) => stack.open(card) +})`; + +const VIEW_REFERENCES = [ + { + id: 'data', + title: 'Records and collections', + root: 'app-kit-data', + summary: 'Controlled views receive explicit data state; connected wrappers map one typed query or action into the same surface. Toolbar and pagination controls only emit collection-state changes, so the loader remains server-driven.', + contract: DATA_CONTRACT, + example: DATA_EXAMPLE, + }, + { + id: 'relations', + title: 'Relation search and linking', + root: 'app-kit-data', + summary: 'Pickers search existing records and panels render linked records. Link and unlink mutations remain explicit host or App Kit actions.', + contract: RELATION_CONTRACT, + example: RELATION_EXAMPLE, + }, + { + id: 'board', + title: 'Categorical boards', + root: 'app-kit-board', + summary: 'Both layers use the same semantic move contract; the connected board only enables movement when a move action is supplied.', + contract: BOARD_CONTRACT, + example: BOARD_EXAMPLE, + }, + { + id: 'dashboard', + title: 'Analytical dashboards', + root: 'app-kit-dashboard', + summary: 'Controlled widgets receive computed payloads, while connected widgets use explicit analytical loaders and render through TanStack Charts. Optional versioned stores persist catalog-limited layout only.', + contract: DASHBOARD_CONTRACT, + example: DASHBOARD_EXAMPLE, + }, + { + id: 'calendar', + title: 'Month and agenda calendars', + root: 'app-kit-calendar', + summary: 'The host controls month, timezone, view, and record opening; the connected wrapper loads the derived visible range.', + contract: CALENDAR_CONTRACT, + example: CALENDAR_EXAMPLE, + }, + { + id: 'workflow', + title: 'Actions and multi-step UI', + root: 'app-kit-workflow', + summary: 'Controlled actions wrap host commands, connected actions consume App Kit definitions, and the stepper remains controlled UI.', + contract: WORKFLOW_CONTRACT, + example: WORKFLOW_EXAMPLE, + }, +] as const; +const VIEW_CONTRACTS = [ + ['Records', 'AppDataTable · AppDataList · AppDataCards · AppRecordDetail · AppRecordForm', 'ConnectedAppDataTable · ConnectedAppDataList · ConnectedAppDataCards · ConnectedAppRecordDetail · ConnectedAppRecordForm'], + ['Relations', 'AppRelationPicker · AppRelationPanel', 'ConnectedAppRelationPicker · ConnectedAppRelationPanel'], + ['Board', 'AppBoard', 'ConnectedAppBoard'], + ['Dashboard', 'AppDashboard', 'ConnectedAppDashboard · PersistedAppDashboard'], + ['Calendar', 'AppCalendar', 'ConnectedAppCalendar'], + ['Actions', 'AppActionButton · AppActionMenu · AppActionDialog · AppBulkActionBar', 'ConnectedAppActionButton · ConnectedAppActionMenu · ConnectedAppActionDialog · ConnectedAppBulkActionBar'], +] as const; + +const SELECTIONS = [ + { + shape: 'Records and collections', + geometry: 'Table, list, cards, detail, forms, relations', + root: 'app-kit-data', + }, + { + shape: 'Categorical state', + geometry: 'Board columns with a semantic move action', + root: 'app-kit-board', + }, + { + shape: 'Analytical results', + geometry: 'KPI, bar, line, and breakdown widgets', + root: 'app-kit-dashboard', + }, + { + shape: 'Time-bounded records', + geometry: 'Localized month and agenda views', + root: 'app-kit-calendar', + }, + { + shape: 'Application commands', + geometry: 'Buttons, menus, dialogs, bulk actions, and steppers', + root: 'app-kit-workflow', + }, +] as const; + +export default function AppKitPage() { + return ( +
+
+

Application composition

+

+ Constructive App Kit +

+

+ {DESCRIPTION} Console Kit, Sheets, Stack navigation, and platform feature + packs remain optional capabilities that you add when the application + needs them. +

+
+ +
+
+
+

+ One resource model, independently installed views +

+

+ Server-safe definitions describe identity, final GraphQL fields, + loaders, forms, relations, and available actions. Client entrypoints + bind them to TanStack Query and controlled views; credentials stay in + host closures and never enter definitions, URLs, stores, or cache keys. +

+
+ +
    + {[ + ['01', 'Validate', 'Use _meta for database facts and final executable GraphQL introspection for names, types, relations, and operation roots during generation or build.'], + ['02', 'Bind', 'Partition loaders and mutations with endpoint, database, session, organization, and schema revision scope.'], + ['03', 'Compose', 'Choose controlled view geometry from the data shape and workflow, then let the host own routing and shareable state.'], + ].map(([step, title, body]) => ( +
  1. + {step} +

    {title}

    +

    {body}

    +
  2. + ))} +
+
+ +
+
+

+ Public contract reference +

+

+ Definitions and validation stay importable from a server module. The + runtime has its own client entrypoint, so a generator can validate a + resource without pulling React or a provider into server code. +

+
+ +
+ {CORE_ENTRYPOINTS} + {SCOPE_EXAMPLE} +
+ +
+ + + + + + + + + + {VIEW_CONTRACTS.map(([family, controlled, connected]) => ( + + + + + + ))} + +
FamilyControlled componentConnected wrapper
{family}{controlled}{connected}
+
+ +
+ + {CORE_SIGNATURES} + + + {CORE_DEFINITIONS_EXAMPLE} + +
+ +
+ + {CORE_RUNTIME_EXAMPLE} + + + {EXECUTION_CONTRACT} + +
+ +

+ Query and action executors receive the same credential-free input, + scope, and AbortSignal. + Returning a raw value means success; use the branded result helpers + when a transport needs to preserve authorization, validation, partial + GraphQL, or cancellation failures as structured application errors. +

+

+ The installed source files are the type-checking authority. The signatures + above mirror the public definitions in{' '} + core/contracts.ts,{' '} + core/schema-validation.ts, + and core/runtime.tsx so + you can choose the right boundary before installation. +

+
+ +
+
+

+ Host routing, Stack, and URL state adapters +

+

+ Record views expose callbacks instead of choosing navigation. The + route and Stack openers convert the same resource identity into a + host target, while the URL adapter owns only its declared keys and + preserves unrelated search parameters when it writes state. +

+
+
+ + {NAVIGATION_CONTRACT} + + + {NAVIGATION_EXAMPLE} + +
+
+ +
+
+

+ Controlled and connected view contracts +

+

+ Every connected wrapper resolves remote state through App Kit and then + renders its controlled counterpart. These compositions share the Task + definitions above; routing, URL state, transport credentials, and + permission policy stay in the host. +

+
+ +
+ {VIEW_REFERENCES.map((reference) => ( +
+
+
+

+ {reference.title} +

+ {reference.root} +
+

+ {reference.summary} +

+
+
+ + {reference.contract} + + + {reference.example} + +
+
+ ))} +
+
+ +
+
+

+ Install only the roots the application uses +

+

+ This catalog is projected from the same versioned{' '} + meta.constructive{' '} + contract validated during registry compilation. Skills use that + catalog for discovery; this page and the installed source own the API + details. +

+
+ +
    + {APP_KIT_CATALOG.map((item) => ( +
  • +
    +
    +

    {item.title}

    +

    + @constructive/{item.name} +

    +
    +
    + {item.metadata.kind} + {item.metadata.boundary} +
    +
    +

    + {item.description} +

    +

    + {item.metadata.capabilities.join(' · ')} +

    +
  • + ))} +
+ + + {ROOT_INSTALLS} + +
+ +
+
+

+ Select by shape, geometry, and workflow +

+

+ Department names do not determine composition. Start from what the + query returns, how people need to inspect it, and which server action + changes it. +

+
+
+ + + + + + + + + + {SELECTIONS.map((selection) => ( + + + + + + ))} + +
Data shapePresentation geometryRoot
{selection.shape}{selection.geometry}{selection.root}
+
+
+ +
+
+

+ Schema and form compatibility +

+

+ validateAppResource(){' '} + compares the declared PostgreSQL names and facts with final GraphQL + type, field, relation, identity, nullability, list shape, enum, and + operation evidence. Its result reports separate read, create, update, + and delete capabilities plus field-level issues; it never grants an + operation or introspects at runtime. +

+
+
+ + {FIELD_CONTRACT} + +
+

Scalar and list semantics

+

+ json is one + GraphQL scalar even when its runtime value contains an object or + array. nullable{' '} + still describes whether the scalar field itself may be GraphQL + null. It does not use{' '} + arrayElementNullable, + and JSON or custom scalar editing requires an explicit input + renderer. +

+

+ A *-array kind + declares one GraphQL list dimension.{' '} + nullable controls + whether the list itself may be null;{' '} + arrayElementNullable{' '} + controls inner nulls and defaults to false. Generated forms accept + JSON array syntax and validate every scalar or enum element before + submitting. +

+
+
+
    +
  • + String, integer, float, boolean, date, datetime, enum, and their + scalar-array forms have generated presentation. Editable enum and + enum-array fields require non-empty, unique declared options. +
  • +
  • + Resource forms declare ordered create and update fields separately. + Required presentation may tighten a nullable field for one workflow, + but it cannot weaken database or executable-schema requirements. +
  • +
  • + Missing or ambiguous identity disables writes. Composite identity + keeps its declared field order, and relation pickers link existing + records through server search rather than nested creation. +
  • +
+
+ +
+
+
+

Event Studio starter

+ Page-scale recipe +
+

+ Event Studio composes org-scoped programs, sessions, people, venues, + and the explicit session_people relation into analytical widgets, a + semantic session board, month and agenda schedules, searchable + collections, details, forms, relations, and publish or schedule + actions. The paired skill recipe provisions through the supported B2B + b2b:storage + blueprint path, so the starter contains no raw SQL. +

+

+ It is an opt-in proof application and integration fixture. No App + Kit capability root depends on it, and ordinary application + composition should select the smaller roots above. +

+ + {registryAdd('app-kit-event-studio')} + +
+
+ +
+

Runtime boundaries

+
    +
  • + Remote state belongs to TanStack Query. AppScope changes create a new + cache partition, cancellation uses AbortSignal, and actions invalidate + explicit cross-view targets. +
  • +
  • + URL or controlled props own shareable view, filter, and selection + state. Local reducers own transient interaction; App Kit adds no global + Zustand store. +
  • +
  • + Runtime introspection is out of scope. Agents validate generated + contracts against _meta and the final inflected GraphQL schema before + the application ships. +
  • +
  • + V1 has no subscriptions or interval polling. Action invalidation, + manual refresh, and focus or reconnect refetching provide freshness. +
  • +
+
+
+ + +
+ ); +} + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: withBase('/blocks/app-kit') }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: withBase('/blocks/app-kit'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/app-kit/public-contracts.test.ts b/apps/blocks/src/app/blocks/app-kit/public-contracts.test.ts new file mode 100644 index 0000000..e467fbb --- /dev/null +++ b/apps/blocks/src/app/blocks/app-kit/public-contracts.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import { + appFailure, + appSuccess, + createAppRouteRecordOpener, + createAppStackRecordOpener, + defineAction, + defineAppUrlStateAdapter, + defineQuery, + defineResource, + isAppResult, + type AppExecutionContext, + type AppFieldKind, + type AppResult, + type AppScope +} from '@/blocks/app-kit/core'; +import type { + AppCollectionPage, + AppCollectionQueryInput, + AppCollectionViewProps, + AppFilterDefinition, + AppRelationOption, + AppSortDefinition +} from '@/blocks/app-kit/data'; +import { + APP_DASHBOARD_LAYOUT_VERSION, + createConstructiveAppDashboardLayoutStore, + createDefaultAppDashboardLayout, + createLocalStorageAppDashboardLayoutStore, + type ConstructiveAppDashboardLayoutAdapter +} from '@/blocks/app-kit/dashboard'; + +type Task = Readonly<{ + id: string; + title: string; + status: 'TODO' | 'DONE'; + tags: readonly string[] | null; + milestones: readonly (string | null)[]; + configuration: unknown; +}>; + +const scope: AppScope = { + databaseId: 'database-fixture', + endpointId: 'data', + organizationId: 'organization-fixture', + schemaRevision: 'schema-fixture', + securityRevision: 'security-fixture', + sessionPartition: 'session-fixture' +}; + +const emptyPage: AppCollectionPage = { + items: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + page: 1, + pageSize: 25, + totalCount: 0 + } +}; + +const listTasks = defineQuery< + AppCollectionQueryInput, + AppCollectionPage +>({ + id: 'tasks.list', + execute: ({ signal }) => + signal.aborted + ? appFailure({ kind: 'cancelled', message: 'Cancelled.' }) + : appSuccess(emptyPage) +}); + +const completeTask = defineAction<{ taskId: string }, Task>({ + id: 'tasks.complete', + execute: ({ input }) => + appFailure({ + code: 'NOT_IMPLEMENTED', + details: { taskId: input.taskId }, + kind: 'unknown', + message: 'Contract fixture only.' + }), + presentation: { label: 'Complete task' } +}); + +const taskResource = defineResource< + Task, + string, + AppCollectionQueryInput, + AppCollectionPage, + { complete: typeof completeTask } +>({ + actions: { complete: completeTask }, + displayField: 'title', + fields: [ + { + databaseName: 'id', + graphQLName: 'id', + key: 'id', + kind: 'string', + label: 'ID', + readOnly: true + }, + { + databaseName: 'title', + graphQLName: 'title', + key: 'title', + kind: 'string', + label: 'Title' + }, + { + databaseName: 'status', + graphQLName: 'status', + key: 'status', + kind: 'enum', + label: 'Status', + options: [ + { label: 'To do', value: 'TODO' }, + { label: 'Done', value: 'DONE' } + ] + }, + { + databaseName: 'tags', + graphQLName: 'tags', + key: 'tags', + kind: 'string-array', + label: 'Tags', + nullable: true + }, + { + arrayElementNullable: true, + databaseName: 'milestones', + graphQLName: 'milestones', + key: 'milestones', + kind: 'date-array', + label: 'Milestones' + }, + { + databaseName: 'configuration', + graphQLName: 'configuration', + key: 'configuration', + kind: 'json', + label: 'Configuration', + readOnly: true + } + ], + forms: { + update: { fields: [{ field: 'title', required: true }, { field: 'status' }] } + }, + id: 'tasks', + identity: { + fields: ['id'], + read: (task) => task.id, + serialize: (identity) => identity + }, + label: 'Task', + pluralLabel: 'Tasks', + queries: { list: listTasks }, + source: { + graphQLTypeName: 'Task', + listFieldName: 'tasks', + schemaName: 'app', + tableName: 'tasks', + updateMutationName: 'updateTask' + } +}); + +const collectionProps = { + getRowKey: (task: Task) => task.id, + onSelectionChange: (_keys: readonly string[]) => undefined, + resource: taskResource, + selectedKeys: ['task-1'], + state: { data: emptyPage, status: 'ready' } +} satisfies AppCollectionViewProps; + +const filters = [ + { + id: 'status', + label: 'Status', + options: [{ label: 'Done', value: 'DONE' }] + } +] satisfies readonly AppFilterDefinition[]; + +const sorts = [ + { id: 'title', label: 'Title' } +] satisfies readonly AppSortDefinition[]; + +describe('App Kit documentation public-contract fixture', () => { + it('keeps resource fields, collection controls, and executor results typed', async () => { + const fieldKinds = taskResource.fields.map((field) => field.kind); + expect(fieldKinds).toEqual([ + 'string', + 'string', + 'enum', + 'string-array', + 'date-array', + 'json' + ] satisfies readonly AppFieldKind[]); + expect(collectionProps.selectedKeys).toEqual(['task-1']); + expect(filters[0].options[0].value).toBe('DONE'); + expect(sorts[0].id).toBe('title'); + + const result = await listTasks.execute({ + input: { filters: [], page: 1, pageSize: 25, search: '', sort: [] }, + scope, + signal: new AbortController().signal + }); + expect(isAppResult(result)).toBe(true); + expectTypeOf(result).toMatchTypeOf | AppResult>>(); + expectTypeOf['signal']>() + .toEqualTypeOf(); + }); + + it('keeps relation, host-navigation, URL-state, and layout adapters composable', async () => { + type Person = Readonly<{ id: string; name: string }>; + const assignee = { + label: 'Ada Lovelace', + record: { id: 'person-1', name: 'Ada Lovelace' }, + value: 'person-1' + } satisfies AppRelationOption; + + const navigateCalls: string[] = []; + const openRoute = createAppRouteRecordOpener({ + href: ({ identity, resourceId }) => `/app/${resourceId}/${identity}`, + locator: { identity: (task: Task) => task.id, resourceId: 'tasks' }, + navigate: (href) => navigateCalls.push(href) + }); + const stackCards: { id: string; type: string }[] = []; + const openStack = createAppStackRecordOpener({ + card: ({ identity }) => ({ id: `task:${identity}`, type: 'task-detail' }), + locator: { identity: (task: Task) => task.id, resourceId: 'tasks' }, + open: (card) => stackCards.push(card) + }); + const urlState = defineAppUrlStateAdapter<{ search: string }>({ + decode: (params) => ({ search: params.get('q') ?? '' }), + encode: (state) => new URLSearchParams(state.search ? { q: state.search } : {}), + keys: ['q'] + }); + const task: Task = { + configuration: {}, + id: 'task-1', + milestones: [], + status: 'TODO', + tags: null, + title: 'Write the fixture' + }; + + openRoute(task); + openStack(task); + expect(navigateCalls).toEqual(['/app/tasks/task-1']); + expect(stackCards).toEqual([{ id: 'task:task-1', type: 'task-detail' }]); + expect(urlState.write({ search: 'fixture' }, '?tab=tasks').toString()) + .toBe('tab=tasks&q=fixture'); + expect(assignee.record.id).toBe('person-1'); + + const savedLayouts: unknown[] = []; + const adapter = { + loadLayout: () => null, + saveLayout: (_key, layout) => { + savedLayouts.push(layout); + } + } satisfies ConstructiveAppDashboardLayoutAdapter; + const constructiveStore = createConstructiveAppDashboardLayoutStore(adapter); + const localStore = createLocalStorageAppDashboardLayoutStore(undefined); + const layout = createDefaultAppDashboardLayout(['task-count']); + await constructiveStore.save('tasks.overview', layout); + + expect(layout.version).toBe(APP_DASHBOARD_LAYOUT_VERSION); + expect(savedLayouts).toEqual([layout]); + expect(localStore.load('tasks.overview')).toBeNull(); + }); +}); diff --git a/apps/blocks/src/app/blocks/page.tsx b/apps/blocks/src/app/blocks/page.tsx index fe79855..23bcb64 100644 --- a/apps/blocks/src/app/blocks/page.tsx +++ b/apps/blocks/src/app/blocks/page.tsx @@ -12,6 +12,12 @@ const TITLE = 'Setup'; const DESCRIPTION = 'Choose npm package distribution or source installation through the shadcn CLI.'; const APPLICATION_CATALOG = [ + { + href: '/blocks/app-kit', + title: 'App Kit', + description: + 'Compose arbitrary Constructive-native apps from typed resources, queries, actions, and domain-neutral views.', + }, { href: '/blocks/features', title: 'Feature packs', @@ -74,9 +80,9 @@ export default function SetupPage() { Application blocks

- Start with a capability-aligned feature pack, add a composed - workflow block, or install the full Next.js console with its - route-neutral app shell and dynamic data explorer. + Start with App Kit for application composition, then add focused + feature packs, workflow blocks, or Console Kit when the application + needs those platform capabilities.

    diff --git a/apps/blocks/src/app/layout.tsx b/apps/blocks/src/app/layout.tsx index d6046cf..dd07787 100644 --- a/apps/blocks/src/app/layout.tsx +++ b/apps/blocks/src/app/layout.tsx @@ -19,7 +19,7 @@ const openSans = Open_Sans({ const SITE_TITLE = 'Constructive Blocks'; const SITE_DESCRIPTION = - 'A shadcn-compatible registry of Constructive UI primitives, feature packs, billing blocks, and Console Kit.'; + 'A shadcn-compatible registry of Constructive App Kit, UI primitives, feature packs, billing blocks, and Console Kit.'; export const metadata: Metadata = { metadataBase: new URL(SITE_ORIGIN), diff --git a/apps/blocks/src/app/opengraph-image.png/route.tsx b/apps/blocks/src/app/opengraph-image.png/route.tsx index c384f3d..f6e8eb0 100644 --- a/apps/blocks/src/app/opengraph-image.png/route.tsx +++ b/apps/blocks/src/app/opengraph-image.png/route.tsx @@ -29,7 +29,7 @@ export function GET() {
    @constructive
    Constructive Blocks
    - UI primitives, feature packs, billing blocks, and Console Kit for the shadcn CLI. + App Kit, UI primitives, feature packs, billing blocks, and Console Kit for the shadcn CLI.
    diff --git a/apps/blocks/src/app/sitemap.test.ts b/apps/blocks/src/app/sitemap.test.ts index a50f256..6434c44 100644 --- a/apps/blocks/src/app/sitemap.test.ts +++ b/apps/blocks/src/app/sitemap.test.ts @@ -20,13 +20,14 @@ describe('sitemap', () => { APPLICATION_BLOCKS.length + SOURCE_BLOCKS.length + AI_COMPONENTS.length + - 8, + 9, ); expect(entries.map(({ url }) => url)).toEqual([ 'http://localhost:3005/', 'http://localhost:3005/blocks', 'http://localhost:3005/blocks/styling', 'http://localhost:3005/blocks/features', + 'http://localhost:3005/blocks/app-kit', ...FEATURE_PACK_DOCS.map(({ id }) => `http://localhost:3005/blocks/features/${id}`), 'http://localhost:3005/blocks/command-palette', 'http://localhost:3005/blocks/ai', diff --git a/apps/blocks/src/app/sitemap.ts b/apps/blocks/src/app/sitemap.ts index 6d8aadc..529986e 100644 --- a/apps/blocks/src/app/sitemap.ts +++ b/apps/blocks/src/app/sitemap.ts @@ -16,6 +16,7 @@ export default function sitemap(): MetadataRoute.Sitemap { '/blocks', '/blocks/styling', '/blocks/features', + '/blocks/app-kit', ...FEATURE_PACK_DOCS.map(({ id }) => `/blocks/features/${id}`), '/blocks/command-palette', '/blocks/ai', @@ -38,6 +39,7 @@ export default function sitemap(): MetadataRoute.Sitemap { : path === '/blocks' || path === '/blocks/styling' || path === '/blocks/features' || + path === '/blocks/app-kit' || path === '/blocks/command-palette' || path === '/blocks/ai' || SOURCE_BLOCKS.some(({ name }) => path === `/blocks/${name}`) || diff --git a/apps/blocks/src/blocks/app-kit/board/board.test.tsx b/apps/blocks/src/blocks/app-kit/board/board.test.tsx new file mode 100644 index 0000000..172e06b --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/board/board.test.tsx @@ -0,0 +1,246 @@ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient } from '@tanstack/react-query'; +import { describe, expect, it, vi } from 'vitest'; +import { defineAction, defineQuery, type AppScope } from '../core'; +import { AppKitProvider } from '../core/runtime'; +import { AppBoard } from './board'; +import { ConnectedAppBoard } from './connected-board'; + +type Ticket = { id: string; title: string; status: 'planned' | 'live' }; + +const columns = [ + { id: 'planned', title: 'Planned' }, + { id: 'live', title: 'Live' } +] as const; + +const ticket: Ticket = { id: 'ticket-1', title: 'Opening keynote', status: 'planned' }; + +function board(onMove = vi.fn()) { + return ( + record.status} + getRecordId={(record) => record.id} + getRecordLabel={(record) => record.title} + onMove={onMove} + records={[ticket]} + renderCard={(record) =>

    {record.status}

    } + /> + ); +} + +describe('AppBoard', () => { + it('offers the semantic move action through a keyboard-operable menu', async () => { + const user = userEvent.setup(); + const onMove = vi.fn().mockResolvedValue(undefined); + render(board(onMove)); + + const trigger = screen.getByRole('button', { name: 'Move Opening keynote' }); + trigger.focus(); + await user.keyboard('{Enter}'); + const destination = await screen.findByRole('menuitem', { name: /Live/ }); + destination.focus(); + await user.keyboard('{Enter}'); + + await waitFor(() => expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ + recordId: 'ticket-1', + fromColumnId: 'planned', + toColumnId: 'live' + }))); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('restores focus to the moved record after an async controlled update', async () => { + const user = userEvent.setup(); + let finishMove!: () => void; + const moveFinished = new Promise((resolve) => { + finishMove = resolve; + }); + + function ControlledBoard() { + const [records, setRecords] = React.useState([ticket]); + return ( + record.status} + getRecordId={(record) => record.id} + getRecordLabel={(record) => record.title} + onMove={async ({ recordId, toColumnId }) => { + setRecords((current) => current.map((record) => ( + record.id === recordId ? { ...record, status: toColumnId } : record + ))); + await moveFinished; + }} + records={records} + /> + ); + } + + render(); + const originalTrigger = screen.getByRole('button', { name: 'Move Opening keynote' }); + originalTrigger.focus(); + await user.keyboard('{Enter}'); + await user.keyboard('{ArrowDown}{Enter}'); + + await waitFor(() => expect( + screen.getByRole('heading', { name: 'Live' }).closest('section') + ).toHaveTextContent('Opening keynote')); + expect(originalTrigger.isConnected).toBe(false); + + finishMove(); + await waitFor(() => expect( + screen.getByRole('button', { name: 'Move Opening keynote' }) + ).toHaveFocus()); + }); + + it('restores focus to the destination column when a successful move removes the record', async () => { + const user = userEvent.setup(); + + function FilteredBoard() { + const [records, setRecords] = React.useState([ticket]); + return ( + <> + + record.status} + getRecordId={(record) => record.id} + getRecordLabel={(record) => record.title} + onMove={async ({ recordId }) => { + setRecords((current) => current.filter((record) => record.id !== recordId)); + }} + records={records} + /> + + ); + } + + render(); + await user.click(screen.getByRole('button', { name: 'Move Opening keynote' })); + await user.click(await screen.findByRole('menuitem', { name: /Live/ })); + + const destinationColumn = screen.getByRole('heading', { name: 'Live' }).closest('section'); + await waitFor(() => expect(destinationColumn).toHaveFocus()); + expect(screen.queryByText('Opening keynote')).not.toBeInTheDocument(); + + const restore = screen.getByRole('button', { name: 'Restore record' }); + await user.click(restore); + expect(await screen.findByText('Opening keynote')).toBeInTheDocument(); + expect(restore).toHaveFocus(); + }); + + it('keeps the multi-column loading state inside its own mobile scroll boundary', () => { + const query = defineQuery({ + id: 'tickets.loading-board', + execute: () => new Promise(() => undefined) + }); + const scope: AppScope = { + databaseId: 'events', + endpointId: 'graphql', + schemaRevision: 'schema-1', + securityRevision: 'security-1', + sessionPartition: 'user-1' + }; + + render( + + record.status} + getRecordId={(record) => record.id} + getRecordLabel={(record) => record.title} + query={query} + queryInput={undefined} + /> + + ); + + expect(screen.getByRole('status', { name: 'Loading board' })).toHaveClass( + 'max-w-full', + 'overflow-x-auto' + ); + }); + + it('keeps the controlled record in place and reports a rejected optimistic move', async () => { + const user = userEvent.setup(); + const onMove = vi.fn().mockRejectedValue(new Error('Move denied by policy')); + render(board(onMove)); + + await user.click(screen.getByRole('button', { name: 'Move Opening keynote' })); + await user.click(await screen.findByRole('menuitem', { name: /Live/ })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Move denied by policy'); + expect(screen.getByRole('heading', { name: 'Planned' }).closest('section')) + .toHaveTextContent('Opening keynote'); + expect(screen.getByRole('heading', { name: 'Live' }).closest('section')) + .not.toHaveTextContent('Opening keynote'); + await waitFor(() => expect( + screen.getByRole('button', { name: 'Move Opening keynote' }) + ).toHaveFocus()); + }); + + it('does not expose move affordances without an explicit move action', () => { + render( + record.status} + getRecordId={(record) => record.id} + getRecordLabel={(record) => record.title} + records={[ticket]} + /> + ); + expect(screen.queryByRole('button', { name: 'Move Opening keynote' })).not.toBeInTheDocument(); + }); + + it('evaluates the semantic action presentation policy for each typed move input', async () => { + const scope: AppScope = { + databaseId: 'events', + endpointId: 'graphql', + schemaRevision: 'schema-1', + securityRevision: 'security-1', + sessionPartition: 'user-1' + }; + const query = defineQuery({ + id: 'tickets.board', + execute: () => [ticket] + }); + const move = defineAction< + { ticketId: string; status: Ticket['status'] }, + Ticket + >({ + id: 'tickets.move', + execute: () => ticket, + presentation: { + disabledReason: ({ input }) => + input.status === 'live' ? 'Publishing is not allowed.' : undefined, + label: 'Move ticket' + } + }); + + render( + + record.status} + getRecordId={(record) => record.id} + getRecordLabel={(record) => record.title} + moveAction={{ + definition: move, + input: ({ recordId, toColumnId }) => ({ + status: toColumnId, + ticketId: recordId + }) + }} + query={query} + queryInput={undefined} + /> + + ); + + expect( + await screen.findByRole('button', { name: 'Move Opening keynote' }) + ).toBeDisabled(); + }); +}); diff --git a/apps/blocks/src/blocks/app-kit/board/board.tsx b/apps/blocks/src/blocks/app-kit/board/board.tsx new file mode 100644 index 0000000..16de923 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/board/board.tsx @@ -0,0 +1,315 @@ +'use client'; + +import * as React from 'react'; +import { ArrowRightIcon, GripVerticalIcon, MoreHorizontalIcon } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; +import { Button } from '@constructive-io/ui/button'; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle +} from '@constructive-io/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@constructive-io/ui/dropdown-menu'; + +export interface AppBoardColumn { + id: TColumnId; + title: string; + description?: string; +} + +export interface AppBoardMove { + record: TRecord; + recordId: string; + fromColumnId: TColumnId; + toColumnId: TColumnId; +} + +export interface AppBoardProps { + columns: readonly AppBoardColumn[]; + records: readonly TRecord[]; + getRecordId: (record: TRecord) => string; + getRecordLabel: (record: TRecord) => string; + getColumnId: (record: TRecord) => TColumnId; + renderCard?: (record: TRecord) => React.ReactNode; + onOpenRecord?: (record: TRecord) => void; + onMove?: (move: AppBoardMove) => void | Promise; + canMove?: (move: AppBoardMove) => boolean; + density?: 'compact' | 'comfortable'; + surface?: 'page' | 'card' | 'embedded'; + className?: string; +} + +interface AppBoardFocusRequest { + recordId: string; + columnId: TColumnId; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : 'The record could not be moved.'; +} + +/** + * A controlled, resource-agnostic board. Moving is unavailable unless the + * host supplies a semantic `onMove` action; native drag and the keyboard menu + * both call the same action contract. + */ +export function AppBoard({ + columns, + records, + getRecordId, + getRecordLabel, + getColumnId, + renderCard, + onOpenRecord, + onMove, + canMove, + density = 'comfortable', + surface = 'page', + className +}: AppBoardProps) { + const boardId = React.useId(); + const [draggedRecordId, setDraggedRecordId] = React.useState(null); + const [pendingRecordIds, setPendingRecordIds] = React.useState>( + () => new Set() + ); + const [moveError, setMoveError] = React.useState(null); + const [focusRequest, setFocusRequest] = React.useState | null>(null); + const boardRef = React.useRef(null); + const columnRefs = React.useRef(new Map()); + const moveTriggerRefs = React.useRef(new Map()); + const recordCardRefs = React.useRef(new Map()); + + const recordsById = React.useMemo( + () => new Map(records.map((record) => [getRecordId(record), record])), + [getRecordId, records] + ); + + React.useLayoutEffect(() => { + if (!focusRequest || pendingRecordIds.has(focusRequest.recordId)) return; + const record = recordsById.get(focusRequest.recordId); + const trigger = record + ? moveTriggerRefs.current.get(focusRequest.recordId) + : undefined; + const target = + (trigger && !trigger.disabled ? trigger : undefined) ?? + (record + ? recordCardRefs.current.get(focusRequest.recordId) + : undefined) ?? + columnRefs.current.get(focusRequest.columnId) ?? + boardRef.current; + if (!target) { + setFocusRequest(null); + return; + } + target.focus(); + setFocusRequest(null); + }, [focusRequest, pendingRecordIds, recordsById]); + + const moveRecord = React.useCallback(async ( + record: TRecord, + toColumnId: TColumnId + ) => { + if (!onMove) return; + + const recordId = getRecordId(record); + const fromColumnId = getColumnId(record); + const move = { record, recordId, fromColumnId, toColumnId }; + if (fromColumnId === toColumnId || canMove?.(move) === false) return; + + setMoveError(null); + setPendingRecordIds((current) => new Set(current).add(recordId)); + let completed = false; + try { + await onMove(move); + completed = true; + } catch (error) { + // The connected action owns optimistic cache rollback. This local error + // stays next to the board so a rejected drop is never silent. + setMoveError(errorMessage(error)); + } finally { + setPendingRecordIds((current) => { + const next = new Set(current); + next.delete(recordId); + return next; + }); + setFocusRequest({ + columnId: completed ? toColumnId : fromColumnId, + recordId + }); + } + }, [canMove, getColumnId, getRecordId, onMove]); + + return ( +
    + {moveError ? ( + + Move failed + {moveError} + + ) : null} + +
    + {columns.map((column) => { + const headingId = `${boardId}-column-${column.id}`; + const columnRecords = records.filter( + (record) => getColumnId(record) === column.id + ); + + return ( +
    event.preventDefault() : undefined} + onDrop={onMove ? (event) => { + event.preventDefault(); + const recordId = event.dataTransfer.getData('text/plain') || draggedRecordId; + const record = recordId ? recordsById.get(recordId) : undefined; + setDraggedRecordId(null); + if (record) void moveRecord(record, column.id); + } : undefined} + ref={(node) => { + if (node) columnRefs.current.set(column.id, node); + else columnRefs.current.delete(column.id); + }} + tabIndex={-1} + > +
    +
    +

    + {column.title} +

    + {column.description ? ( +

    + {column.description} +

    + ) : null} +
    + + {columnRecords.length} + +
    + +
    + {columnRecords.length === 0 ? ( +

    + No records in {column.title}. +

    + ) : columnRecords.map((record) => { + const recordId = getRecordId(record); + const label = getRecordLabel(record); + const pending = pendingRecordIds.has(recordId); + const movableColumns = columns.filter((candidate) => { + const move = { + record, + recordId, + fromColumnId: column.id, + toColumnId: candidate.id + }; + return candidate.id !== column.id && canMove?.(move) !== false; + }); + + return ( + setDraggedRecordId(null)} + onDragStart={onMove ? (event) => { + setDraggedRecordId(recordId); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', recordId); + } : undefined} + ref={(node) => { + if (node) recordCardRefs.current.set(recordId, node); + else recordCardRefs.current.delete(recordId); + }} + tabIndex={-1} + variant="flat" + > + + + {onOpenRecord ? ( + + ) : label} + + {onMove ? ( + + + ) : null} + + {renderCard ? ( + + {renderCard(record)} + + ) : null} + + ); + })} +
    +
    + ); + })} +
    +
    + ); +} diff --git a/apps/blocks/src/blocks/app-kit/board/connected-board.tsx b/apps/blocks/src/blocks/app-kit/board/connected-board.tsx new file mode 100644 index 0000000..cd7cfed --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/board/connected-board.tsx @@ -0,0 +1,134 @@ +'use client'; + +import * as React from 'react'; +import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; +import { Skeleton } from '@constructive-io/ui/skeleton'; +import type { + AppActionDefinition, + AppQueryDefinition +} from '../core/contracts'; +import { useAppAction, useAppQuery } from '../core/runtime'; +import { AppBoard, type AppBoardMove, type AppBoardProps } from './board'; + +export interface AppBoardMoveAction< + TRecord, + TColumnId extends string, + TInput, + TOutput, + TOptimistic = unknown +> { + definition: AppActionDefinition; + input: (move: AppBoardMove) => TInput; +} + +export interface ConnectedAppBoardProps< + TRecord, + TColumnId extends string, + TQueryInput, + TMoveInput = never, + TMoveOutput = never, + TOptimistic = unknown +> extends Omit, 'records' | 'onMove'> { + query: AppQueryDefinition; + queryInput: TQueryInput; + moveAction?: AppBoardMoveAction< + TRecord, + TColumnId, + TMoveInput, + TMoveOutput, + TOptimistic + >; +} + +function BoardLoading({ columns }: { columns: number }) { + return ( +
    + {Array.from({ length: Math.max(columns, 1) }, (_, index) => ( + + ))} +
    + ); +} + +function MovableConnectedBoard< + TRecord, + TColumnId extends string, + TMoveInput, + TMoveOutput, + TOptimistic +>({ + moveAction, + ...props +}: AppBoardProps & { + moveAction: AppBoardMoveAction< + TRecord, + TColumnId, + TMoveInput, + TMoveOutput, + TOptimistic + >; +}) { + const action = useAppAction(moveAction.definition); + const { canMove, ...boardProps } = props; + + return ( + { + if (canMove?.(move) === false) return false; + const presentation = action.evaluatePresentation( + moveAction.input(move) + ); + return presentation.visible && !presentation.disabledReason; + }} + onMove={async (move) => { + const result = await action.execute(moveAction.input(move)); + if (!result.ok) throw new Error(result.error.message); + }} + /> + ); +} + +/** Query-connected board; mutation support exists only with an explicit move action. */ +export function ConnectedAppBoard< + TRecord, + TColumnId extends string, + TQueryInput, + TMoveInput = never, + TMoveOutput = never, + TOptimistic = unknown +>({ + query, + queryInput, + moveAction, + ...props +}: ConnectedAppBoardProps< + TRecord, + TColumnId, + TQueryInput, + TMoveInput, + TMoveOutput, + TOptimistic +>) { + const result = useAppQuery(query, queryInput); + if (result.isPending) return ; + if (result.error) { + const denied = + result.error.appError.kind === 'authorization' || + result.error.appError.kind === 'authentication'; + return ( + + {denied ? 'Access denied' : 'Board unavailable'} + {result.error.message} + + ); + } + + const boardProps = { ...props, records: result.data ?? [] }; + if (!moveAction) return ; + return ; +} diff --git a/apps/blocks/src/blocks/app-kit/board/index.ts b/apps/blocks/src/blocks/app-kit/board/index.ts new file mode 100644 index 0000000..9815cce --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/board/index.ts @@ -0,0 +1,2 @@ +export * from './board'; +export * from './connected-board'; diff --git a/apps/blocks/src/blocks/app-kit/calendar/calendar.test.tsx b/apps/blocks/src/blocks/app-kit/calendar/calendar.test.tsx new file mode 100644 index 0000000..bac67e7 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/calendar/calendar.test.tsx @@ -0,0 +1,106 @@ +import { QueryClient } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { defineQuery, type AppScope } from '../core/contracts'; +import { AppKitProvider } from '../core/runtime'; +import { + AppCalendar, + appCalendarDateKey, + getAppCalendarRange, + shiftAppCalendarMonth +} from './calendar'; +import { ConnectedAppCalendar } from './connected-calendar'; + +const month = { year: 2026, month: 8 } as const; + +describe('AppCalendar', () => { + it('builds exclusive visible-grid ranges across year boundaries', () => { + expect(getAppCalendarRange({ year: 2026, month: 12 }, 'Asia/Tokyo')).toEqual({ + startDate: '2026-11-29', + endDate: '2027-01-03', + timeZone: 'Asia/Tokyo' + }); + expect(getAppCalendarRange({ year: 2026, month: 12 }, 'Asia/Tokyo', 1)).toEqual({ + startDate: '2026-11-30', + endDate: '2027-01-04', + timeZone: 'Asia/Tokyo' + }); + expect(shiftAppCalendarMonth({ year: 2026, month: 1 }, -1)) + .toEqual({ year: 2025, month: 12 }); + }); + + it('groups the same instant by the explicitly selected timezone', () => { + const instant = '2026-08-03T23:30:00.000Z'; + expect(appCalendarDateKey(instant, 'Asia/Tokyo')).toBe('2026-08-04'); + expect(appCalendarDateKey(instant, 'America/Los_Angeles')).toBe('2026-08-03'); + }); + + it('localizes the heading while keeping record opening host-controlled', async () => { + const user = userEvent.setup(); + const onOpenRecord = vi.fn(); + render( + + ); + expect(screen.getByRole('heading', { name: /août 2026/i })).toBeInTheDocument(); + expect(screen.getByText('Asia/Tokyo')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Open Atelier/ })); + expect(onOpenRecord).toHaveBeenCalledWith( + { id: 'session-1' }, + expect.objectContaining({ id: 'session-1' }) + ); + }); + + it('passes the visible grid range, week start, and timezone to a connected query', async () => { + const execute = vi.fn().mockResolvedValue([]); + const query = defineQuery({ id: 'sessions.range', execute }); + const scope: AppScope = { + endpointId: 'graphql', + databaseId: 'events', + sessionPartition: 'user-1', + organizationId: 'org-1', + schemaRevision: 'schema-1', + securityRevision: 'security-1' + }; + render( + + + + ); + + await waitFor(() => expect(execute).toHaveBeenCalled()); + expect(execute.mock.calls[0]![0]).toEqual(expect.objectContaining({ + input: { + startDate: '2026-07-27', + endDate: '2026-09-07', + timeZone: 'America/New_York' + }, + scope + })); + expect(execute.mock.calls[0]![0].signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/apps/blocks/src/blocks/app-kit/calendar/calendar.tsx b/apps/blocks/src/blocks/app-kit/calendar/calendar.tsx new file mode 100644 index 0000000..be8a9c1 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/calendar/calendar.tsx @@ -0,0 +1,435 @@ +'use client'; + +import * as React from 'react'; +import { CalendarDaysIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { Button } from '@constructive-io/ui/button'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from '@constructive-io/ui/empty'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui/tabs'; +import { cn } from '@/lib/utils'; + +export interface AppCalendarMonth { + year: number; + /** One-based ISO month (1–12). */ + month: number; +} + +export interface AppCalendarRange { + /** Inclusive ISO local date. */ + startDate: string; + /** Exclusive ISO local date. */ + endDate: string; + timeZone: string; +} + +export interface AppCalendarEvent { + id: string; + title: string; + startsAt: string; + endsAt?: string; + record: TRecord; + description?: string; +} + +export interface AppCalendarProps { + events: readonly AppCalendarEvent[]; + month: AppCalendarMonth; + onMonthChange: (month: AppCalendarMonth) => void; + timeZone: string; + locale?: string; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + view: 'month' | 'agenda'; + onViewChange: (view: 'month' | 'agenda') => void; + onOpenRecord?: (record: TRecord, event: AppCalendarEvent) => void; + density?: 'compact' | 'comfortable'; + surface?: 'page' | 'card' | 'embedded'; + className?: string; +} + +interface CalendarDay { + date: Date; + dateKey: string; + dayNumber: number; + inMonth: boolean; +} + +const ISO_CALENDAR_LOCALE = 'en-US-u-ca-iso8601-nu-latn'; + +function pad(value: number) { + return String(value).padStart(2, '0'); +} + +function plainDateKey(year: number, month: number, day: number) { + return `${year}-${pad(month)}-${pad(day)}`; +} + +function monthDate(month: AppCalendarMonth) { + return new Date(Date.UTC(month.year, month.month - 1, 1, 12)); +} + +function calendarGrid( + month: AppCalendarMonth, + weekStartsOn: number +): Readonly<{ cellCount: number; start: Date }> { + const first = monthDate(month); + const daysInMonth = new Date(Date.UTC(month.year, month.month, 0, 12)).getUTCDate(); + const leading = (first.getUTCDay() - weekStartsOn + 7) % 7; + return { + cellCount: Math.ceil((leading + daysInMonth) / 7) * 7, + start: new Date(Date.UTC(month.year, month.month - 1, 1 - leading, 12)) + }; +} + +export function shiftAppCalendarMonth( + month: AppCalendarMonth, + offset: number +): AppCalendarMonth { + const value = new Date(Date.UTC(month.year, month.month - 1 + offset, 1, 12)); + return { year: value.getUTCFullYear(), month: value.getUTCMonth() + 1 }; +} + +export function getAppCalendarRange( + month: AppCalendarMonth, + timeZone: string, + weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0 +): AppCalendarRange { + const { cellCount, start } = calendarGrid(month, weekStartsOn); + const end = new Date(start); + end.setUTCDate(start.getUTCDate() + cellCount); + return { + startDate: plainDateKey( + start.getUTCFullYear(), + start.getUTCMonth() + 1, + start.getUTCDate() + ), + endDate: plainDateKey( + end.getUTCFullYear(), + end.getUTCMonth() + 1, + end.getUTCDate() + ), + timeZone + }; +} + +export function appCalendarDateKey(instant: string | Date, timeZone: string) { + const parts = new Intl.DateTimeFormat(ISO_CALENDAR_LOCALE, { + calendar: 'iso8601', + day: '2-digit', + month: '2-digit', + timeZone, + year: 'numeric' + }).formatToParts(typeof instant === 'string' ? new Date(instant) : instant); + const get = (type: Intl.DateTimeFormatPartTypes) => ( + parts.find((part) => part.type === type)?.value ?? '' + ); + return `${get('year')}-${get('month')}-${get('day')}`; +} + +function calendarDays(month: AppCalendarMonth, weekStartsOn: number): CalendarDay[] { + const { cellCount, start } = calendarGrid(month, weekStartsOn); + + return Array.from({ length: cellCount }, (_, index) => { + const date = new Date(start); + date.setUTCDate(start.getUTCDate() + index); + const valueMonth = date.getUTCMonth() + 1; + return { + date, + dateKey: plainDateKey(date.getUTCFullYear(), valueMonth, date.getUTCDate()), + dayNumber: date.getUTCDate(), + inMonth: date.getUTCFullYear() === month.year && valueMonth === month.month + }; + }); +} + +function weekdayLabels(locale: string, weekStartsOn: number) { + const sunday = new Date(Date.UTC(2024, 0, 7, 12)); + const formatter = new Intl.DateTimeFormat(locale, { + weekday: 'short', + timeZone: 'UTC' + }); + return Array.from({ length: 7 }, (_, index) => { + const date = new Date(sunday); + date.setUTCDate(sunday.getUTCDate() + ((weekStartsOn + index) % 7)); + return formatter.format(date); + }); +} + +function formatMonth(month: AppCalendarMonth, locale: string) { + return new Intl.DateTimeFormat(locale, { + month: 'long', + timeZone: 'UTC', + year: 'numeric' + }).format(monthDate(month)); +} + +function formatDay(date: Date, locale: string) { + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'long', + timeZone: 'UTC', + weekday: 'long', + year: 'numeric' + }).format(date); +} + +function formatTime(instant: string, locale: string, timeZone: string) { + return new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: '2-digit', + timeZone, + timeZoneName: 'short' + }).format(new Date(instant)); +} + +function AppMonthView({ + days, + eventsByDay, + locale, + timeZone, + weekStartsOn, + onOpenRecord +}: { + days: readonly CalendarDay[]; + eventsByDay: ReadonlyMap[]>; + locale: string; + timeZone: string; + weekStartsOn: number; + onOpenRecord?: AppCalendarProps['onOpenRecord']; +}) { + const todayKey = appCalendarDateKey(new Date(), timeZone); + return ( +
    + + + + {weekdayLabels(locale, weekStartsOn).map((weekday) => ( + + ))} + + + + {Array.from({ length: days.length / 7 }, (_, week) => ( + + {days.slice(week * 7, week * 7 + 7).map((day) => { + const events = eventsByDay.get(day.dateKey) ?? []; + return ( + + ); + })} + + ))} + +
    + {weekday} +
    +
    + +
    + {events.slice(0, 3).map((event) => ( + + ))} + {events.length > 3 ? ( + + +{events.length - 3} more + + ) : null} +
    +
    +
    +
    + ); +} + +function AppAgendaView({ + days, + eventsByDay, + locale, + timeZone, + onOpenRecord, + onShowNextMonth, + idPrefix +}: { + days: readonly CalendarDay[]; + eventsByDay: ReadonlyMap[]>; + locale: string; + timeZone: string; + onOpenRecord?: AppCalendarProps['onOpenRecord']; + onShowNextMonth: () => void; + idPrefix: string; +}) { + const activeDays = days.filter((day) => day.inMonth && (eventsByDay.get(day.dateKey)?.length ?? 0) > 0); + if (activeDays.length === 0) { + return ( + + + + No events this month + There are no scheduled records in this calendar range. + + + + + + ); + } + + return ( +
    + {activeDays.map((day) => ( +
    +

    + {formatDay(day.date, locale)} +

    +
    + {(eventsByDay.get(day.dateKey) ?? []).map((event) => ( + + ))} +
    +
    + ))} +
    + ); +} + +/** Localized month and agenda views over host-provided, range-loaded events. */ +export function AppCalendar({ + events, + month, + onMonthChange, + timeZone, + locale = 'en-US', + weekStartsOn = 0, + view, + onViewChange, + onOpenRecord, + density = 'comfortable', + surface = 'page', + className +}: AppCalendarProps) { + const calendarId = React.useId(); + const days = React.useMemo( + () => calendarDays(month, weekStartsOn), + [month, weekStartsOn] + ); + const eventsByDay = React.useMemo(() => { + const grouped = new Map[]>(); + for (const event of [...events].sort((left, right) => left.startsAt.localeCompare(right.startsAt))) { + const key = appCalendarDateKey(event.startsAt, timeZone); + const existing = grouped.get(key) ?? []; + existing.push(event); + grouped.set(key, existing); + } + return grouped; + }, [events, timeZone]); + const heading = formatMonth(month, locale); + + return ( +
    +
    +
    + +

    + {heading} +

    + +
    + {timeZone} +
    + + onViewChange(value as 'month' | 'agenda')} value={view}> + + Month + Agenda + + + + + + onMonthChange(shiftAppCalendarMonth(month, 1))} + timeZone={timeZone} + /> + + +
    + ); +} diff --git a/apps/blocks/src/blocks/app-kit/calendar/connected-calendar.tsx b/apps/blocks/src/blocks/app-kit/calendar/connected-calendar.tsx new file mode 100644 index 0000000..86d335e --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/calendar/connected-calendar.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; +import { Skeleton } from '@constructive-io/ui/skeleton'; +import type { AppQueryDefinition } from '../core/contracts'; +import { useAppQuery } from '../core/runtime'; +import { + AppCalendar, + getAppCalendarRange, + type AppCalendarEvent, + type AppCalendarProps, + type AppCalendarRange +} from './calendar'; + +export interface ConnectedAppCalendarProps extends Omit< + AppCalendarProps, + 'events' +> { + query: AppQueryDefinition< + AppCalendarRange, + readonly AppCalendarEvent[] + >; +} + +/** Range-connected calendar with explicit timezone in both UI and query input. */ +export function ConnectedAppCalendar({ + query, + month, + timeZone, + weekStartsOn = 0, + ...props +}: ConnectedAppCalendarProps) { + const range = getAppCalendarRange(month, timeZone, weekStartsOn); + const result = useAppQuery(query, range); + if (result.isPending) { + return ( +
    + + +
    + ); + } + if (result.error) { + const denied = + result.error.appError.kind === 'authorization' || + result.error.appError.kind === 'authentication'; + return ( + + {denied ? 'Access denied' : 'Calendar unavailable'} + {result.error.message} + + ); + } + + return ( + + ); +} diff --git a/apps/blocks/src/blocks/app-kit/calendar/index.ts b/apps/blocks/src/blocks/app-kit/calendar/index.ts new file mode 100644 index 0000000..c16f40c --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/calendar/index.ts @@ -0,0 +1,2 @@ +export * from './calendar'; +export * from './connected-calendar'; diff --git a/apps/blocks/src/blocks/app-kit/core/contracts.test.ts b/apps/blocks/src/blocks/app-kit/core/contracts.test.ts new file mode 100644 index 0000000..aeada0e --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/contracts.test.ts @@ -0,0 +1,1126 @@ +import { describe, expect, it } from 'vitest'; + +import { + createAppScopeFingerprint, + createAppScopeQueryKey, + defineQuery, + defineResource, + validateAppResource, + type AppGraphQLIntrospection, + type AppIntrospectionTypeRef, + type AppMetaQuery, + type AppScope +} from './index'; + +type Program = Record & { + flags: (boolean | null)[]; + id: string; + importantDates: (string | null)[]; + locale: string; + milestones: (string | null)[]; + stageHistory: (string | null)[]; + title: string; + stage: string; + tags: (string | null)[]; + settings: Record; + metric: unknown; +}; + +const listPrograms = defineQuery({ + id: 'programs.list', + execute: () => [] +}); + +function programResource(identity = true) { + return defineResource({ + id: identity ? 'programs' : 'programs-read-only', + label: 'Program', + pluralLabel: 'Programs', + source: { + schemaName: 'events', + tableName: 'programs', + graphQLTypeName: 'EventProgram', + listFieldName: 'eventProgramsConnection', + detailFieldName: 'eventProgramByIdAndLocale', + createMutationName: identity ? 'createEventProgram' : undefined, + updateMutationName: identity ? 'updateEventProgramByIdAndLocale' : undefined + }, + fields: [ + { key: 'id', databaseName: 'id', graphQLName: 'id', kind: 'string', label: 'ID' }, + { key: 'locale', databaseName: 'locale', graphQLName: 'locale', kind: 'string', label: 'Locale' }, + { key: 'title', databaseName: 'title', graphQLName: 'displayTitle', kind: 'string', label: 'Title' }, + { + key: 'stage', + databaseName: 'stage', + graphQLName: 'stage', + kind: 'enum', + label: 'Stage', + options: [ + { label: 'Draft', value: 'DRAFT' }, + { label: 'Published', value: 'PUBLISHED' } + ] + }, + { key: 'tags', databaseName: 'tags', graphQLName: 'tags', kind: 'string-array', label: 'Tags', arrayElementNullable: true }, + { key: 'flags', databaseName: 'flags', graphQLName: 'flags', kind: 'boolean-array', label: 'Flags', arrayElementNullable: true }, + { + key: 'importantDates', + databaseName: 'important_dates', + graphQLName: 'importantDates', + kind: 'date-array', + label: 'Important dates', + arrayElementNullable: true + }, + { + key: 'milestones', + databaseName: 'milestones', + graphQLName: 'milestones', + kind: 'datetime-array', + label: 'Milestones', + arrayElementNullable: true + }, + { + key: 'stageHistory', + databaseName: 'stage_history', + graphQLName: 'stageHistory', + kind: 'enum-array', + label: 'Stage history', + arrayElementNullable: true, + options: [ + { label: 'Draft', value: 'DRAFT' }, + { label: 'Published', value: 'PUBLISHED' } + ] + }, + { key: 'settings', databaseName: 'settings', graphQLName: 'settings', kind: 'json', label: 'Settings' }, + { key: 'metric', databaseName: 'metric', graphQLName: 'metric', kind: 'custom', label: 'Metric' } + ], + displayField: 'title', + forms: identity + ? { + create: { + fields: [ + { field: 'title', required: true }, + { field: 'stage', required: true } + ] + }, + update: { fields: [{ field: 'title' }, { field: 'stage' }] } + } + : undefined, + identity: identity + ? { + fields: ['id', 'locale'], + read: (record) => ({ id: record.id, locale: record.locale }), + serialize: (value) => `${value.id}:${value.locale}` + } + : undefined, + queries: { list: listPrograms }, + relations: [ + { + cardinality: 'many', + fieldName: 'sessionsByProgramIdAndLocale', + graphQLName: 'sessionsByProgramIdAndLocale', + id: 'sessions', + label: 'Sessions', + targetTableName: 'sessions', + targetGraphQLTypeName: 'Session', + targetResourceId: 'sessions' + } + ] + }); +} + +const meta: AppMetaQuery = { + _meta: { + tables: [ + { + name: 'programs', + schemaName: 'events', + query: { + all: 'eventProgramsConnection', + create: 'createEventProgram', + one: 'eventProgramByIdAndLocale', + update: 'updateEventProgramByIdAndLocale' + }, + inflection: { + connection: 'EventProgramConnection', + tableType: 'EventProgram' + }, + fields: [ + { name: 'id', isPrimaryKey: true, type: { gqlType: 'ID', pgType: 'uuid' } }, + { name: 'locale', isPrimaryKey: true, type: { gqlType: 'String', pgType: 'text' } }, + { name: 'title', type: { gqlType: 'String', pgType: 'text' } }, + { + name: 'stage', + enumValues: { name: 'program_stage', values: ['draft', 'published'] }, + type: { gqlType: 'ProgramStage', pgType: 'program_stage' } + }, + { name: 'tags', type: { gqlType: 'String', isArray: true, pgType: 'text[]' } }, + { name: 'flags', type: { gqlType: 'Boolean', isArray: true, pgType: 'boolean[]' } }, + { name: 'important_dates', type: { gqlType: 'Date', isArray: true, pgType: 'date[]' } }, + { name: 'milestones', type: { gqlType: 'DateTime', isArray: true, pgType: 'timestamptz[]' } }, + { + name: 'stage_history', + enumValues: { name: 'program_stage', values: ['draft', 'published'] }, + type: { gqlType: 'ProgramStage', isArray: true, pgType: 'program_stage[]' } + }, + { name: 'settings', type: { gqlType: 'JSON', pgType: 'jsonb' } }, + { name: 'metric', type: { gqlType: 'EventMetric', pgType: 'event_metric' } } + ], + primaryKeyConstraints: [{ fields: [{ name: 'id' }, { name: 'locale' }] }], + relations: { + hasMany: [ + { + fieldName: 'sessionsByProgramIdAndLocale', + referencedBy: { name: 'sessions' }, + type: 'Session' + } + ] + } + } + ] + } +}; + +const scalar = (name: string) => ({ kind: 'SCALAR', name }); +const introspection: AppGraphQLIntrospection = { + __schema: { + queryType: { name: 'EventsQueryRoot' }, + mutationType: { name: 'EventsMutationRoot' }, + types: [ + { + kind: 'OBJECT', + name: 'EventsQueryRoot', + fields: [ + { name: 'eventProgramsConnection', type: { name: 'EventProgramConnection' } }, + { name: 'eventProgramByIdAndLocale', type: { name: 'EventProgram' } } + ] + }, + { + kind: 'OBJECT', + name: 'EventsMutationRoot', + fields: [ + { name: 'createEventProgram', type: { name: 'CreateEventProgramPayload' } }, + { name: 'updateEventProgramByIdAndLocale', type: { name: 'UpdateEventProgramPayload' } } + ] + }, + { + kind: 'OBJECT', + name: 'EventProgram', + fields: [ + { name: 'id', type: scalar('ID') }, + { name: 'locale', type: scalar('String') }, + { name: 'displayTitle', type: scalar('String') }, + { name: 'stage', type: { kind: 'ENUM', name: 'ProgramStage' } }, + { + name: 'tags', + type: { kind: 'LIST', ofType: scalar('String') } + }, + { + name: 'flags', + type: { kind: 'LIST', ofType: scalar('Boolean') } + }, + { + name: 'importantDates', + type: { kind: 'LIST', ofType: scalar('Date') } + }, + { + name: 'milestones', + type: { kind: 'LIST', ofType: scalar('DateTime') } + }, + { + name: 'stageHistory', + type: { + kind: 'LIST', + ofType: { kind: 'ENUM', name: 'ProgramStage' } + } + }, + { name: 'settings', type: scalar('JSON') }, + { name: 'metric', type: scalar('EventMetric') }, + { name: 'sessionsByProgramIdAndLocale', type: { name: 'SessionConnection' } } + ] + }, + { + kind: 'OBJECT', + name: 'EventProgramConnection', + fields: [ + { name: 'nodes', type: { kind: 'LIST', ofType: { name: 'EventProgram' } } }, + { name: 'pageInfo', type: { kind: 'OBJECT', name: 'PageInfo' } } + ] + }, + { + kind: 'OBJECT', + name: 'SessionConnection', + fields: [ + { name: 'nodes', type: { kind: 'LIST', ofType: { name: 'Session' } } }, + { name: 'pageInfo', type: { kind: 'OBJECT', name: 'PageInfo' } } + ] + }, + { + kind: 'OBJECT', + name: 'PageInfo', + fields: [ + { name: 'hasNextPage', type: scalar('Boolean') }, + { name: 'hasPreviousPage', type: scalar('Boolean') } + ] + }, + { + enumValues: [{ name: 'DRAFT' }, { name: 'PUBLISHED' }], + kind: 'ENUM', + name: 'ProgramStage' + } + ] + } +}; + +describe('App Kit resource contracts', () => { + it('validates final inflected names, custom roots, enums, arrays, custom scalars, composite identity, and relations', () => { + const result = validateAppResource(programResource(), { introspection, meta }); + + expect(result.compatible).toBe(true); + expect(result.capabilities).toEqual({ + create: true, + delete: false, + read: true, + update: true + }); + expect(result.issues).toEqual([]); + for (const key of [ + 'tags', + 'flags', + 'importantDates', + 'milestones', + 'stageHistory' + ]) { + expect(result.fields.find((field) => field.key === key)?.editable).toBe( + true + ); + } + expect(result.fields.find((field) => field.key === 'settings')).toMatchObject({ + editable: false, + reason: expect.stringContaining('renderer') + }); + expect(result.fields.find((field) => field.key === 'metric')?.editable).toBe(false); + }); + + it('rejects mismatched, nested, and unknown enum values inside scalar arrays', () => { + const base = programResource(); + const resource = defineResource({ + ...base, + id: 'programs-invalid-arrays', + fields: base.fields.map((field) => + field.key === 'stageHistory' + ? { + ...field, + options: [ + ...(field.options ?? []), + { label: 'Archived', value: 'ARCHIVED' } + ] + } + : field + ) + }); + const driftedIntrospection: AppGraphQLIntrospection = { + __schema: { + ...introspection.__schema, + types: (introspection.__schema?.types ?? []).map((type) => + type.name === 'EventProgram' + ? { + ...type, + fields: type.fields?.map((field) => { + if (field.name === 'flags') { + return { + ...field, + type: { kind: 'LIST', ofType: scalar('String') } + }; + } + if (field.name === 'importantDates') { + return { + ...field, + type: { + kind: 'LIST', + ofType: { + kind: 'LIST', + ofType: scalar('Date') + } + } + }; + } + return field; + }) + } + : type + ) + } + }; + + const result = validateAppResource(resource, { + introspection: driftedIntrospection, + meta + }); + + expect(result.compatible).toBe(false); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'GRAPHQL_FIELD_KIND_MISMATCH', + path: '__schema.types.EventProgram.fields.flags.type' + }), + expect.objectContaining({ + code: 'GRAPHQL_FIELD_ARRAY_MISMATCH', + path: '__schema.types.EventProgram.fields.importantDates.type' + }), + expect.objectContaining({ + code: 'GRAPHQL_ENUM_VALUE_MISSING', + path: '__schema.types.ProgramStage.enumValues.ARCHIVED' + }) + ]) + ); + }); + + it('reconciles array element nullability with the final GraphQL list', () => { + const base = programResource(); + const resource = defineResource({ + ...base, + id: 'programs-non-null-array-elements', + fields: base.fields.map((field) => + field.key === 'tags' + ? { ...field, arrayElementNullable: false } + : field + ) + }); + + const result = validateAppResource(resource, { introspection, meta }); + + expect(result.compatible).toBe(false); + expect(result.issues).toContainEqual( + expect.objectContaining({ + code: 'GRAPHQL_ARRAY_ELEMENT_NULLABILITY_MISMATCH', + path: 'fields.tags.arrayElementNullable' + }) + ); + }); + + it('rejects missing or duplicate enum options and marks those fields read-only', () => { + const base = programResource(); + const resource = defineResource({ + ...base, + id: 'programs-invalid-enum-options', + fields: base.fields.map((field) => { + if (field.key === 'stage') return { ...field, options: [] }; + if (field.key === 'stageHistory') { + return { + ...field, + options: [ + { label: 'Draft', value: 'DRAFT' }, + { label: 'Draft again', value: 'DRAFT' } + ] + }; + } + return field; + }) + }); + + const result = validateAppResource(resource, { introspection, meta }); + + expect(result.compatible).toBe(false); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'RESOURCE_ENUM_OPTIONS_MISSING', + path: 'fields.stage.options' + }), + expect.objectContaining({ + code: 'RESOURCE_ENUM_OPTIONS_DUPLICATE', + path: 'fields.stageHistory.options' + }) + ]) + ); + expect(result.fields.find((field) => field.key === 'stage')?.editable).toBe(false); + expect( + result.fields.find((field) => field.key === 'stageHistory')?.editable + ).toBe(false); + }); + + it('allows an explicitly read-only enum to omit edit options', () => { + const base = programResource(); + const resource = defineResource({ + ...base, + id: 'programs-read-only-enum', + fields: base.fields.map((field) => + field.key === 'stage' + ? { ...field, options: [], readOnly: true } + : field + ) + }); + + const result = validateAppResource(resource, { introspection, meta }); + + expect(result.compatible).toBe(true); + expect(result.issues).toEqual([]); + expect(result.fields.find((field) => field.key === 'stage')).toMatchObject({ + editable: false, + reason: 'The resource marks this field read-only.' + }); + }); + + it('does not mistake a one-to-one domain object with a nodes field for a connection', () => { + const base = programResource(); + const resource = defineResource({ + ...base, + id: 'programs-with-session-summary', + relations: [{ + ...base.relations![0]!, + cardinality: 'one' as const, + fieldName: 'sessionSummary', + graphQLName: 'sessionSummary', + targetGraphQLTypeName: 'SessionSummary' + }] + }); + const originalTable = meta._meta?.tables?.[0]; + const domainMeta: AppMetaQuery = { + _meta: { + tables: originalTable ? [{ + ...originalTable, + relations: { + hasOne: [{ + fieldName: 'sessionSummary', + isUnique: true, + referencedBy: { name: 'sessions' }, + type: 'SessionSummary' + }] + } + }] : [] + } + }; + const domainIntrospection: AppGraphQLIntrospection = { + __schema: { + ...introspection.__schema, + types: [ + ...(introspection.__schema?.types ?? []).map((type) => ( + type.name === 'EventProgram' + ? { + ...type, + fields: type.fields?.map((field) => ( + field.name === 'sessionsByProgramIdAndLocale' + ? { + name: 'sessionSummary', + type: { kind: 'OBJECT', name: 'SessionSummary' } + } + : field + )) + } + : type + )), + { + kind: 'OBJECT', + name: 'SessionSummary', + fields: [ + { name: 'nodes', type: { kind: 'LIST', ofType: scalar('String') } }, + { name: 'summary', type: scalar('String') } + ] + } + ] + } + }; + + const result = validateAppResource(resource, { + introspection: domainIntrospection, + meta: domainMeta + }); + + expect(result.compatible).toBe(true); + expect(result.issues.map((issue) => issue.code)).not.toEqual( + expect.arrayContaining([ + 'GRAPHQL_RELATION_TYPE_MISMATCH', + 'GRAPHQL_RELATION_CARDINALITY_MISMATCH' + ]) + ); + }); + + it('accepts provisioned _meta names while treating executable GraphQL names as authoritative', () => { + type PropagationBatch = Record & { + accessionId: string; + batchCode: string; + id: string; + organizationId: string; + }; + type Person = Record & { + displayName: string; + email: string | null; + id: string; + }; + const batches = defineResource({ + id: 'propagation-batches', + label: 'Propagation batch', + pluralLabel: 'Propagation batches', + source: { + schemaName: 'tenant-app-public', + tableName: 'propagation_batches', + graphQLTypeName: 'PropagationBatch', + listFieldName: 'propagationBatches', + createMutationName: 'createPropagationBatch', + updateMutationName: 'updatePropagationBatch', + deleteMutationName: 'deletePropagationBatch' + }, + fields: [ + { key: 'id', databaseName: 'id', graphQLName: 'id', kind: 'string', label: 'ID' }, + { key: 'organizationId', databaseName: 'org_id', graphQLName: 'orgId', kind: 'string', label: 'Organization' }, + { key: 'batchCode', databaseName: 'batch_code', graphQLName: 'batchCode', kind: 'string', label: 'Batch code' }, + { key: 'accessionId', databaseName: 'accession_id', graphQLName: 'accessionId', kind: 'string', label: 'Accession' } + ], + displayField: 'batchCode', + identity: { + fields: ['id'], + read: (record) => record.id, + serialize: String + }, + queries: { + list: defineQuery({ + id: 'propagation-batches.list', + execute: () => [] + }) + }, + relations: [ + { + id: 'accession', + label: 'Accession', + fieldName: 'accession', + graphQLName: 'accession', + targetTableName: 'accessions', + targetGraphQLTypeName: 'Accession', + targetResourceId: 'accessions', + cardinality: 'one' + } + ] + }); + const people = defineResource({ + id: 'people-current-meta', + label: 'Person', + pluralLabel: 'People', + source: { + schemaName: 'tenant-app-public', + tableName: 'people', + graphQLTypeName: 'Person', + listFieldName: 'persons' + }, + fields: [ + { key: 'id', databaseName: 'id', graphQLName: 'id', kind: 'string', label: 'ID' }, + { key: 'displayName', databaseName: 'display_name', graphQLName: 'displayName', kind: 'string', label: 'Name' }, + { key: 'email', databaseName: 'email', graphQLName: 'email', kind: 'string', label: 'Email', nullable: true } + ], + displayField: 'displayName', + identity: { + fields: ['id'], + read: (record) => record.id, + serialize: String + }, + queries: { + list: defineQuery({ + id: 'people-current-meta.list', + execute: () => [] + }) + } + }); + const currentMeta: AppMetaQuery = { + _meta: { + tables: [ + { + name: 'PropagationBatch', + schemaName: 'tenant-app-public', + query: { + all: 'propagationbatchs', + one: 'propagationBatch', + create: 'createPropagationBatch', + update: 'updatePropagationBatch', + delete: 'deletePropagationBatch' + }, + inflection: { + tableType: 'PropagationBatch', + connection: 'PropagationBatchConnection' + }, + fields: [ + { name: 'id', isNotNull: true, type: { gqlType: 'UUID', pgType: 'uuid', isNotNull: true } }, + { name: 'orgId', isNotNull: true, type: { gqlType: 'UUID', pgType: 'uuid', isNotNull: true } }, + { name: 'batchCode', isNotNull: true, type: { gqlType: 'String', pgType: 'text', isNotNull: true } }, + { name: 'accessionId', isNotNull: true, type: { gqlType: 'UUID', pgType: 'uuid', isNotNull: true } } + ], + constraints: { primaryKey: { fields: [{ name: 'id' }] } }, + relations: { + belongsTo: [ + { + fieldName: 'accessionsByMyAccessionId', + type: 'accessions', + references: { name: 'accessions' } + } + ] + } + }, + { + name: 'Person', + schemaName: 'tenant-app-public', + query: { all: 'persons', one: 'person' }, + inflection: { tableType: 'Person', connection: 'PersonConnection' }, + fields: [ + { name: 'id', isNotNull: true, type: { gqlType: 'UUID', pgType: 'uuid', isNotNull: true } }, + { name: 'displayName', isNotNull: true, type: { gqlType: 'String', pgType: 'text', isNotNull: true } }, + { name: 'email', isNotNull: false, type: { gqlType: 'citext', pgType: 'citext', isNotNull: false } } + ], + constraints: { primaryKey: { fields: [{ name: 'id' }] } } + } + ] + } + }; + const nonNull = (type: AppIntrospectionTypeRef): AppIntrospectionTypeRef => ({ + kind: 'NON_NULL', + ofType: type + }); + const currentIntrospection: AppGraphQLIntrospection = { + __schema: { + queryType: { name: 'Query' }, + mutationType: { name: 'Mutation' }, + types: [ + { + name: 'Query', + kind: 'OBJECT', + fields: [ + { name: 'propagationBatches', type: { name: 'PropagationBatchConnection' } }, + { name: 'persons', type: { name: 'PersonConnection' } } + ] + }, + { + name: 'Mutation', + kind: 'OBJECT', + fields: [ + { name: 'createPropagationBatch' }, + { name: 'updatePropagationBatch' }, + { name: 'deletePropagationBatch' } + ] + }, + { + name: 'PropagationBatch', + kind: 'OBJECT', + fields: [ + { name: 'id', type: nonNull(scalar('UUID')) }, + { name: 'orgId', type: nonNull(scalar('UUID')) }, + { name: 'batchCode', type: nonNull(scalar('String')) }, + { name: 'accessionId', type: nonNull(scalar('UUID')) }, + { name: 'accession', type: { kind: 'OBJECT', name: 'Accession' } } + ] + }, + { name: 'Accession', kind: 'OBJECT', fields: [] }, + { + name: 'PropagationBatchConnection', + kind: 'OBJECT', + fields: [ + { name: 'nodes', type: { kind: 'LIST', ofType: { kind: 'OBJECT', name: 'PropagationBatch' } } }, + { name: 'pageInfo', type: { kind: 'OBJECT', name: 'PageInfo' } } + ] + }, + { + name: 'Person', + kind: 'OBJECT', + fields: [ + { name: 'id', type: nonNull(scalar('UUID')) }, + { name: 'displayName', type: nonNull(scalar('String')) }, + { name: 'email', type: scalar('String') } + ] + }, + { + name: 'PersonConnection', + kind: 'OBJECT', + fields: [ + { name: 'nodes', type: { kind: 'LIST', ofType: { kind: 'OBJECT', name: 'Person' } } }, + { name: 'pageInfo', type: { kind: 'OBJECT', name: 'PageInfo' } } + ] + }, + { + name: 'PageInfo', + kind: 'OBJECT', + fields: [ + { name: 'hasNextPage', type: scalar('Boolean') }, + { name: 'hasPreviousPage', type: scalar('Boolean') } + ] + } + ] + } + }; + + const batchResult = validateAppResource(batches, { + introspection: currentIntrospection, + meta: currentMeta + }); + expect(batchResult.compatible).toBe(true); + expect(batchResult.capabilities).toEqual({ + create: true, + delete: true, + read: true, + update: true + }); + expect(batchResult.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'META_LIST_INFLECTION_MISMATCH', severity: 'warning' }), + expect.objectContaining({ code: 'META_RELATION_FIELD_HINT_MISMATCH', severity: 'warning' }) + ]) + ); + expect(batchResult.issues.some((issue) => issue.severity === 'error')).toBe(false); + + const personResult = validateAppResource(people, { + introspection: currentIntrospection, + meta: currentMeta + }); + expect(personResult.compatible).toBe(true); + expect(personResult.issues).toContainEqual( + expect.objectContaining({ code: 'META_FIELD_GRAPHQL_TYPE_HINT_MISMATCH', severity: 'warning' }) + ); + }); + + it('keeps resources without identity read-only without rejecting their read contract', () => { + const originalTable = meta._meta?.tables?.[0]; + const noPrimaryKeyMeta: AppMetaQuery = { + _meta: { + tables: originalTable + ? [ + { + ...originalTable, + fields: originalTable.fields?.map((field) => + field ? { ...field, isPrimaryKey: false } : field + ), + primaryKeyConstraints: [] + } + ] + : [] + } + }; + const result = validateAppResource(programResource(false), { + introspection, + meta: noPrimaryKeyMeta + }); + + expect(result.compatible).toBe(true); + expect(result.capabilities).toEqual({ + create: false, + delete: false, + read: true, + update: false + }); + expect(result.issues).toContainEqual( + expect.objectContaining({ code: 'IDENTITY_MISSING', severity: 'warning' }) + ); + }); + + it('rejects final-schema kind, nullability, list, and relation-shape drift', () => { + const originalTable = meta._meta?.tables?.[0]; + const driftedMeta: AppMetaQuery = { + _meta: { + tables: originalTable + ? [ + { + ...originalTable, + fields: originalTable.fields?.map((field) => + field?.name === 'title' + ? { ...field, isNotNull: true } + : field + ), + relations: { + hasOne: originalTable.relations?.hasMany, + hasMany: [] + } + } + ] + : [] + } + }; + const driftedSchema: AppGraphQLIntrospection = { + __schema: { + ...introspection.__schema, + types: (introspection.__schema?.types ?? []).map((type) => { + if (type.name === 'EventsQueryRoot') { + return { + ...type, + fields: type.fields?.map((field) => + field.name === 'eventProgramsConnection' + ? { ...field, type: { name: 'EventProgram' } } + : field + ) + }; + } + if (type.name === 'EventProgram') { + return { + ...type, + fields: type.fields?.map((field) => { + if (field.name === 'displayTitle') { + return { ...field, type: scalar('Int') }; + } + if (field.name === 'sessionsByProgramIdAndLocale') { + return { ...field, type: { name: 'Venue' } }; + } + return field; + }) + }; + } + return type; + }) + } + }; + + const result = validateAppResource(programResource(), { + introspection: driftedSchema, + meta: driftedMeta + }); + expect(result.compatible).toBe(false); + expect(result.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'GRAPHQL_LIST_TYPE_MISMATCH', + 'META_FIELD_GRAPHQL_TYPE_HINT_MISMATCH', + 'GRAPHQL_FIELD_KIND_MISMATCH', + 'GRAPHQL_FIELD_NULLABILITY_MISMATCH', + 'META_RELATION_CARDINALITY_MISMATCH', + 'GRAPHQL_RELATION_TYPE_MISMATCH', + 'GRAPHQL_RELATION_CARDINALITY_MISMATCH' + ]) + ); + }); + + it.each([ + ['BigInt', 'bigint', 'integer'], + ['Decimal', 'numeric', 'float'] + ] as const)( + 'requires lossless %s values to use string fields', + (graphQLScalar, pgType, lossyKind) => { + const originalTable = meta._meta?.tables?.[0]; + const numericMeta: AppMetaQuery = { + _meta: { + tables: originalTable + ? [{ + ...originalTable, + fields: originalTable.fields?.map((field) => + field?.name === 'metric' + ? { ...field, type: { gqlType: graphQLScalar, pgType } } + : field + ) + }] + : [] + } + }; + const numericIntrospection: AppGraphQLIntrospection = { + __schema: { + ...introspection.__schema, + types: (introspection.__schema?.types ?? []).map((type) => + type.name === 'EventProgram' + ? { + ...type, + fields: type.fields?.map((field) => + field.name === 'metric' + ? { ...field, type: scalar(graphQLScalar) } + : field + ) + } + : type + ) + } + }; + const base = programResource(); + const withMetricKind = (kind: 'float' | 'integer' | 'string') => + defineResource({ + ...base, + id: `programs-${graphQLScalar.toLowerCase()}-${kind}`, + fields: base.fields.map((field) => + field.key === 'metric' ? { ...field, kind } : field + ) + }); + + const lossy = validateAppResource(withMetricKind(lossyKind), { + introspection: numericIntrospection, + meta: numericMeta + }); + expect(lossy.compatible).toBe(false); + expect(lossy.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'META_FIELD_KIND_MISMATCH', + 'GRAPHQL_FIELD_KIND_MISMATCH' + ]) + ); + + const lossless = validateAppResource(withMetricKind('string'), { + introspection: numericIntrospection, + meta: numericMeta + }); + expect(lossless.compatible).toBe(true); + expect(lossless.fields.find((field) => field.key === 'metric')?.editable) + .toBe(true); + } + ); + + it('keeps mutation capabilities independent when one operation is absent', () => { + const withoutCreate: AppGraphQLIntrospection = { + __schema: { + ...introspection.__schema, + types: (introspection.__schema?.types ?? []).map((type) => + type.name === 'EventsMutationRoot' + ? { + ...type, + fields: type.fields?.filter( + (field) => field.name !== 'createEventProgram' + ) + } + : type + ) + } + }; + + const result = validateAppResource(programResource(), { + introspection: withoutCreate, + meta + }); + expect(result.compatible).toBe(false); + expect(result.capabilities).toEqual({ + create: false, + delete: false, + read: true, + update: true + }); + }); + + it('rejects unknown or duplicate typed form fields during definition', () => { + const base = programResource(); + expect(() => + defineResource({ + ...base, + id: 'programs-invalid-form', + forms: { + create: { + fields: [{ field: 'missing' }, { field: 'missing' }] + } + } + } as never) + ).toThrow(/form field "missing" is not declared/u); + + expect(() => + defineResource({ + ...base, + id: 'programs-duplicate-form', + forms: { + update: { fields: [{ field: 'title' }, { field: 'title' }] } + } + }) + ).toThrow(/declares field "title" more than once/u); + }); + + it('reports absent operations, relations, enum tokens, and mismatched composite identities', () => { + const resource = programResource(); + const originalTable = meta._meta?.tables?.[0]; + const brokenMeta: AppMetaQuery = { + _meta: { + tables: originalTable + ? [ + { + ...originalTable, + primaryKeyConstraints: [{ fields: [{ name: 'id' }] }], + relations: { ...originalTable.relations, hasMany: [] } + } + ] + : [] + } + }; + const brokenSchema: AppGraphQLIntrospection = { + __schema: { + ...introspection.__schema, + types: (introspection.__schema?.types ?? []).map((type) => { + if ( + type.name === 'EventsQueryRoot' || + type.name === 'EventsMutationRoot' + ) { + return { ...type, fields: [] }; + } + if (type.name === 'ProgramStage') { + return { ...type, enumValues: [{ name: 'DRAFT' }] }; + } + return type; + }) + } + }; + + const result = validateAppResource(resource, { + introspection: brokenSchema, + meta: brokenMeta + }); + expect(result.compatible).toBe(false); + expect(result.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'GRAPHQL_LIST_FIELD_MISSING', + 'GRAPHQL_DETAIL_FIELD_MISSING', + 'GRAPHQL_MUTATION_FIELD_MISSING', + 'GRAPHQL_ENUM_VALUE_MISSING', + 'META_RELATION_MISSING', + 'IDENTITY_PRIMARY_KEY_MISMATCH' + ]) + ); + }); +}); + +describe('AppScope query partitioning', () => { + it('changes the key for every security-relevant partition and excludes credentials by construction', () => { + const base: AppScope = { + databaseId: 'db-a', + endpointId: 'endpoint-a', + organizationId: 'org-a', + schemaRevision: 'schema-a', + securityRevision: 'security-a', + sessionPartition: 'session-a', + tenantId: 'tenant-a' + }; + const variants: AppScope[] = [ + base, + { ...base, endpointId: 'endpoint-b' }, + { ...base, databaseId: 'db-b' }, + { ...base, sessionPartition: 'session-b' }, + { ...base, organizationId: 'org-b' }, + { ...base, tenantId: 'tenant-b' }, + { ...base, schemaRevision: 'schema-b' }, + { ...base, securityRevision: 'security-b' } + ]; + const serialized = variants.map((scope) => + JSON.stringify(createAppScopeQueryKey(scope)) + ); + expect(new Set(serialized).size).toBe(variants.length); + expect(serialized.join(' ')).not.toContain('token'); + }); + + it('requires both schema and security revisions', () => { + const valid: AppScope = { + databaseId: 'db-a', + endpointId: 'endpoint-a', + schemaRevision: 'schema-a', + securityRevision: 'security-a', + sessionPartition: 'session-a' + }; + + expect(() => + createAppScopeQueryKey({ ...valid, schemaRevision: '' }) + ).toThrow(/schemaRevision/u); + expect(() => + createAppScopeQueryKey({ ...valid, securityRevision: '' }) + ).toThrow(/securityRevision/u); + }); + + it('fingerprints unusual stable identifiers without delimiter collisions', () => { + const base: AppScope = { + databaseId: 'db-a', + endpointId: 'endpoint-a', + schemaRevision: 'schema-a', + securityRevision: 'security-a', + sessionPartition: 'session-a' + }; + const first = createAppScopeFingerprint({ + ...base, + databaseId: 'db-a\u001fdatabase-b', + endpointId: 'endpoint-a' + }); + const second = createAppScopeFingerprint({ + ...base, + databaseId: 'database-b', + endpointId: 'endpoint-a\u001fdb-a' + }); + + expect(first).not.toBe(second); + }); +}); diff --git a/apps/blocks/src/blocks/app-kit/core/contracts.ts b/apps/blocks/src/blocks/app-kit/core/contracts.ts new file mode 100644 index 0000000..2fa9d87 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/contracts.ts @@ -0,0 +1,523 @@ +export type AppErrorKind = + | 'authentication' + | 'authorization' + | 'cancelled' + | 'conflict' + | 'graphql' + | 'network' + | 'not-found' + | 'validation' + | 'unknown'; + +export type AppFieldError = Readonly<{ + field: string; + message: string; +}>; + +export type AppError = Readonly<{ + message: string; + code?: string; + kind: AppErrorKind; + retryable?: boolean; + fieldErrors?: readonly AppFieldError[]; + details?: Readonly>; +}>; + +export const APP_RESULT_DISCRIMINATOR = 'constructive.app-kit/result' as const; +export const APP_RESULT_VERSION = 1 as const; + +export type AppResultMetadata = Readonly<{ + kind: typeof APP_RESULT_DISCRIMINATOR; + version: typeof APP_RESULT_VERSION; +}>; + +export type AppResult = ( + | Readonly<{ ok: true; data: T }> + | Readonly<{ ok: false; error: AppError }> +) & + Readonly<{ __constructiveAppKitResult: AppResultMetadata }>; + +export type AppExecutorResult = T | AppResult; + +function appResultMetadata(): AppResultMetadata { + return { + kind: APP_RESULT_DISCRIMINATOR, + version: APP_RESULT_VERSION + }; +} + +export function appSuccess(data: T): AppResult { + return { + __constructiveAppKitResult: appResultMetadata(), + data, + ok: true + }; +} + +export function appFailure(error: AppError): AppResult { + return { + __constructiveAppKitResult: appResultMetadata(), + error, + ok: false + }; +} + +export function isAppResult(value: unknown): value is AppResult { + if (!value || typeof value !== 'object') return false; + const metadata = (value as Readonly>) + .__constructiveAppKitResult; + if (!metadata || typeof metadata !== 'object') return false; + const candidate = metadata as Readonly>; + if ( + candidate.kind !== APP_RESULT_DISCRIMINATOR || + candidate.version !== APP_RESULT_VERSION + ) { + return false; + } + if (!('ok' in value) || typeof value.ok !== 'boolean') return false; + return value.ok + ? 'data' in value + : 'error' in value && isAppError(value.error); +} + +export function normalizeAppError( + error: unknown, + fallback = 'The operation could not be completed.' +): AppError { + if (isAppError(error)) return error; + + if ( + typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError' + ) { + return { + code: 'CANCELLED', + kind: 'cancelled', + message: 'The operation was cancelled.', + retryable: true + }; + } + + if (error instanceof Error) { + return { + kind: 'unknown', + message: error.message || fallback + }; + } + + if (error && typeof error === 'object' && 'message' in error) { + const candidate = error as { + message?: unknown; + code?: unknown; + retryable?: unknown; + }; + return { + code: typeof candidate.code === 'string' ? candidate.code : undefined, + kind: 'unknown', + message: + typeof candidate.message === 'string' && candidate.message.length > 0 + ? candidate.message + : fallback, + retryable: + typeof candidate.retryable === 'boolean' + ? candidate.retryable + : undefined + }; + } + + return { kind: 'unknown', message: fallback }; +} + +export function isAppError(value: unknown): value is AppError { + if (!value || typeof value !== 'object') return false; + const candidate = value as { kind?: unknown; message?: unknown }; + return ( + typeof candidate.kind === 'string' && + typeof candidate.message === 'string' + ); +} + +/** + * A secret-free identity for a Constructive application data boundary. + * Credentials and raw session tokens never belong in this value. + */ +export type AppScope = Readonly<{ + endpointId: string; + databaseId: string; + sessionPartition: string; + organizationId?: string | null; + tenantId?: string | null; + schemaRevision: string; + securityRevision: string; +}>; + +export type AppExecutionContext = Readonly<{ + /** Plain credential-free input; authentication belongs in the executor closure. */ + input: TInput; + scope: AppScope; + signal: AbortSignal; +}>; + +export type AppQueryDefinition = Readonly<{ + id: string; + execute: ( + context: AppExecutionContext + ) => AppExecutorResult | Promise>; + staleTime?: number; +}>; + +export type AppInputSchema = Readonly<{ + safeParse: (input: unknown) => + | Readonly<{ success: true; data: TInput }> + | Readonly<{ + success: false; + error: Readonly<{ + message?: string; + issues?: readonly Readonly<{ + message: string; + path?: readonly PropertyKey[]; + }>[]; + }>; + }>; +}>; + +export type AppActionPresentationContext = Readonly<{ + input: TInput; + scope: AppScope; + context?: TContext; +}>; + +export type AppActionPresentation = Readonly<{ + label: string; + description?: string; + visible?: ( + context: AppActionPresentationContext + ) => boolean; + disabledReason?: ( + context: AppActionPresentationContext + ) => string | undefined; + confirmation?: Readonly<{ + title: string; + description: string; + confirmLabel?: string; + destructive?: boolean; + }>; +}>; + +export type AppInvalidationTarget = Readonly<{ + queryId: string; + input?: unknown; + exact?: boolean; +}>; + +export type AppOptimisticContext = Readonly<{ + input: TInput; + scope: AppScope; + /** Scope-bound cache access; foreign AppScope keys are rejected at runtime. */ + queryClient: AppQueryCache; +}>; + +declare const appScopedQueryKeyBrand: unique symbol; + +/** A key produced by App Kit's scope-aware key factories. */ +export type AppScopedQueryKey = readonly unknown[] & + Readonly<{ [appScopedQueryKeyBrand]: true }>; + +export type AppQueryCacheFilters = Readonly<{ + exact?: boolean; + queryKey?: AppScopedQueryKey; +}>; + +export type AppQueryCacheUpdater = + | TData + | ((current: TData | undefined) => TData | undefined); + +/** + * The optimistic cache surface deliberately excludes raw QueryClient access. + * Implementations bind every operation to the AppScope that started the action. + */ +export type AppQueryCache = Readonly<{ + cancelQueries: (filters?: AppQueryCacheFilters) => Promise; + getQueryData: ( + queryKey: AppScopedQueryKey + ) => TData | undefined; + setQueryData: ( + queryKey: AppScopedQueryKey, + updater: AppQueryCacheUpdater + ) => unknown; +}>; + +export type AppActionDefinition< + TInput, + TOutput, + TOptimistic = unknown, + TContext = unknown +> = Readonly<{ + id: string; + inputSchema?: AppInputSchema; + execute: ( + context: AppExecutionContext + ) => AppExecutorResult | Promise>; + presentation?: AppActionPresentation; + concurrency?: 'block' | 'replace'; + invalidate?: + | readonly AppInvalidationTarget[] + | ((context: Readonly<{ + input: TInput; + output: TOutput; + scope: AppScope; + }>) => readonly AppInvalidationTarget[]); + optimistic?: Readonly<{ + /** + * App Kit journals scope-bound setQueryData writes until this resolves and + * restores them automatically if it throws. Treat cached values as + * immutable and make every optimistic change through setQueryData. + */ + apply: ( + context: AppOptimisticContext + ) => TOptimistic | Promise; + rollback: ( + context: AppOptimisticContext & + Readonly<{ + optimisticContext: TOptimistic; + error: AppError; + }> + ) => void | Promise; + settle?: ( + context: AppOptimisticContext & + Readonly<{ + optimisticContext: TOptimistic; + result: AppResult; + }> + ) => void | Promise; + }>; +}>; + +export type AppFieldKind = + | 'string' + | 'integer' + | 'float' + | 'boolean' + | 'date' + | 'datetime' + | 'enum' + | 'string-array' + | 'integer-array' + | 'float-array' + | 'boolean-array' + | 'date-array' + | 'datetime-array' + | 'enum-array' + | 'json' + | 'custom'; + +export type AppFieldOption = Readonly<{ + label: string; + value: string; +}>; + +export type AppFieldDefinition< + TRecord extends Record, + TKey extends keyof TRecord & string = keyof TRecord & string +> = Readonly<{ + key: TKey; + databaseName: string; + graphQLName: string; + label: string; + description?: string; + kind: AppFieldKind; + nullable?: boolean; + /** + * Defaults to false. Set true only for a final `[T]` list and include null + * in the corresponding record-property element type. + */ + arrayElementNullable?: boolean; + readOnly?: boolean; + options?: readonly AppFieldOption[]; +}>; + +export type AppIdentityDefinition< + TRecord extends Record, + TIdentity +> = Readonly<{ + fields: readonly (keyof TRecord & string)[]; + read: (record: TRecord) => TIdentity; + serialize: (identity: TIdentity) => string; +}>; + +export type AppRelationCardinality = 'one' | 'many'; + +export type AppRelationDefinition = Readonly<{ + id: string; + label: string; + fieldName: string; + /** Explicit final-schema name when it differs from a legacy fieldName. */ + graphQLName?: string; + /** Database target used to reconcile the relation with `_meta`. */ + targetTableName?: string; + /** Final executable GraphQL target type for strict relation validation. */ + targetGraphQLTypeName: string; + targetResourceId: string; + cardinality: AppRelationCardinality; + linkActionId?: string; + unlinkActionId?: string; +}>; + +export type AppFormFieldDefinition< + TRecord extends Record, + TKey extends keyof TRecord & string = keyof TRecord & string +> = Readonly<{ + field: TKey; + required?: boolean; +}>; + +export type AppResourceFormDefinition< + TRecord extends Record +> = Readonly<{ + fields: readonly AppFormFieldDefinition[]; +}>; + +export type AppResourceFormsDefinition< + TRecord extends Record +> = Readonly<{ + create?: AppResourceFormDefinition; + update?: AppResourceFormDefinition; +}>; + +export type AppResourceSource = Readonly<{ + schemaName: string; + tableName: string; + graphQLTypeName: string; + listFieldName: string; + detailFieldName?: string; + createMutationName?: string; + updateMutationName?: string; + deleteMutationName?: string; +}>; + +export type AppResourceDefinition< + TRecord extends Record, + TIdentity = unknown, + TListInput = unknown, + TListOutput = unknown, + TActions extends Readonly> = Readonly< + Record + > +> = Readonly<{ + /** Type-only inference anchor; no runtime value is required. */ + __types?: Readonly<{ + record: TRecord; + identity: TIdentity; + }>; + id: string; + label: string; + pluralLabel: string; + source: AppResourceSource; + fields: readonly AppFieldDefinition[]; + displayField: keyof TRecord & string; + identity?: AppIdentityDefinition; + relations?: readonly AppRelationDefinition[]; + forms?: AppResourceFormsDefinition; + queries: Readonly<{ + list: AppQueryDefinition; + detail?: AppQueryDefinition; + }>; + actions?: TActions; +}>; + +function assertDefinitionId(kind: string, id: string): void { + if (!/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(id)) { + throw new Error( + `${kind} id "${id}" must start with a lowercase letter and contain only lowercase letters, numbers, dots, underscores, or hyphens.` + ); + } +} + +export function defineQuery( + definition: AppQueryDefinition +): AppQueryDefinition { + assertDefinitionId('Query', definition.id); + return Object.freeze(definition); +} + +export function defineAction< + TInput, + TOutput, + TOptimistic = unknown, + TContext = unknown +>( + definition: AppActionDefinition +): AppActionDefinition { + assertDefinitionId('Action', definition.id); + return Object.freeze(definition); +} + +export function defineResource< + TRecord extends Record, + TIdentity = unknown, + TListInput = unknown, + TListOutput = unknown, + TActions extends Readonly> = Readonly< + Record + > +>( + definition: AppResourceDefinition< + TRecord, + TIdentity, + TListInput, + TListOutput, + TActions + > +): AppResourceDefinition< + TRecord, + TIdentity, + TListInput, + TListOutput, + TActions +> { + assertDefinitionId('Resource', definition.id); + + const fieldKeys = new Set(); + for (const field of definition.fields) { + if (fieldKeys.has(field.key)) { + throw new Error( + `Resource "${definition.id}" declares field "${field.key}" more than once.` + ); + } + fieldKeys.add(field.key); + } + + if (!fieldKeys.has(definition.displayField)) { + throw new Error( + `Resource "${definition.id}" displayField "${definition.displayField}" is not declared in fields.` + ); + } + + for (const identityField of definition.identity?.fields ?? []) { + if (!fieldKeys.has(identityField)) { + throw new Error( + `Resource "${definition.id}" identity field "${identityField}" is not declared in fields.` + ); + } + } + + for (const [formKind, form] of Object.entries(definition.forms ?? {})) { + const formFields = new Set(); + for (const formField of form?.fields ?? []) { + if (!fieldKeys.has(formField.field)) { + throw new Error( + `Resource "${definition.id}" ${formKind} form field "${formField.field}" is not declared in fields.` + ); + } + if (formFields.has(formField.field)) { + throw new Error( + `Resource "${definition.id}" ${formKind} form declares field "${formField.field}" more than once.` + ); + } + formFields.add(formField.field); + } + } + + return Object.freeze(definition); +} diff --git a/apps/blocks/src/blocks/app-kit/core/index.ts b/apps/blocks/src/blocks/app-kit/core/index.ts new file mode 100644 index 0000000..33ed00d --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/index.ts @@ -0,0 +1,4 @@ +export * from './contracts'; +export * from './navigation'; +export * from './scope'; +export * from './schema-validation'; diff --git a/apps/blocks/src/blocks/app-kit/core/navigation.test.ts b/apps/blocks/src/blocks/app-kit/core/navigation.test.ts new file mode 100644 index 0000000..c527d9f --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/navigation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createAppRouteRecordOpener, + createAppStackRecordOpener, + defineAppUrlStateAdapter +} from './navigation'; + +type ViewState = Readonly<{ + search: string; + page: number; + selected?: string; +}>; + +describe('App Kit host adapters', () => { + it('round-trips URL-backed view state while preserving host parameters', () => { + const adapter = defineAppUrlStateAdapter({ + keys: ['q', 'page', 'selected'], + decode: (params) => ({ + page: Number(params.get('page') ?? 1), + search: params.get('q') ?? '', + selected: params.get('selected') ?? undefined + }), + encode: (state) => { + const params = new URLSearchParams(); + if (state.search) params.set('q', state.search); + if (state.page !== 1) params.set('page', String(state.page)); + if (state.selected) params.set('selected', state.selected); + return params; + } + }); + const state = { page: 3, search: 'keynote', selected: 'session-4' }; + const written = adapter.write(state, '?tab=schedule&q=old&page=1'); + + expect(written.get('tab')).toBe('schedule'); + expect(adapter.read(written)).toEqual(state); + }); + + it('delegates route opening with an explicit resource identity', () => { + const navigate = vi.fn(); + const openRecord = createAppRouteRecordOpener({ + locator: { resourceId: 'sessions', identity: (record: { id: string }) => record.id }, + href: ({ resourceId, identity }) => `/app/${resourceId}/${identity}`, + navigate + }); + openRecord({ id: 'session-7' }); + + expect(navigate).toHaveBeenCalledWith( + '/app/sessions/session-7', + expect.objectContaining({ identity: 'session-7', resourceId: 'sessions' }) + ); + }); + + it('delegates deep-linked Stack cards without depending on Stack types', () => { + const open = vi.fn(); + const openRecord = createAppStackRecordOpener({ + locator: { resourceId: 'people', identity: (record: { id: string }) => record.id }, + card: ({ identity }) => ({ id: `person:${identity}`, type: 'person-detail' }), + open + }); + openRecord({ id: 'person-2' }); + + expect(open).toHaveBeenCalledWith( + { id: 'person:person-2', type: 'person-detail' }, + expect.objectContaining({ identity: 'person-2', resourceId: 'people' }) + ); + }); +}); diff --git a/apps/blocks/src/blocks/app-kit/core/navigation.ts b/apps/blocks/src/blocks/app-kit/core/navigation.ts new file mode 100644 index 0000000..abf5ab4 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/navigation.ts @@ -0,0 +1,76 @@ +export type AppRecordLocator = Readonly<{ + resourceId: string; + identity: (record: TRecord) => string; +}>; + +export type AppRecordTarget = Readonly<{ + resourceId: string; + identity: string; + record: TRecord; +}>; + +function targetFor( + locator: AppRecordLocator, + record: TRecord +): AppRecordTarget { + return { + identity: locator.identity(record), + record, + resourceId: locator.resourceId + }; +} + +export function createAppRouteRecordOpener(config: Readonly<{ + locator: AppRecordLocator; + href: (target: AppRecordTarget) => string; + navigate: (href: string, target: AppRecordTarget) => void; +}>): (record: TRecord) => void { + return (record) => { + const target = targetFor(config.locator, record); + config.navigate(config.href(target), target); + }; +} + +/** + * Creates a Stack-compatible opener without importing Stack. The host decides + * the card type and how it is opened, so App Kit remains router-independent. + */ +export function createAppStackRecordOpener(config: Readonly<{ + locator: AppRecordLocator; + card: (target: AppRecordTarget) => TCard; + open: (card: TCard, target: AppRecordTarget) => void; +}>): (record: TRecord) => void { + return (record) => { + const target = targetFor(config.locator, record); + config.open(config.card(target), target); + }; +} + +export type AppUrlStateAdapter = Readonly<{ + read: (search: string | URLSearchParams) => TState; + write: (state: TState, current?: string | URLSearchParams) => URLSearchParams; +}>; + +export function defineAppUrlStateAdapter(config: Readonly<{ + keys: readonly string[]; + decode: (params: URLSearchParams) => TState; + encode: (state: TState) => URLSearchParams; +}>): AppUrlStateAdapter { + const asParams = (value: string | URLSearchParams | undefined) => + value instanceof URLSearchParams + ? new URLSearchParams(value) + : new URLSearchParams(value?.startsWith('?') ? value.slice(1) : value); + + return Object.freeze({ + read(search: string | URLSearchParams) { + return config.decode(asParams(search)); + }, + write(state: TState, current?: string | URLSearchParams) { + const result = asParams(current); + for (const key of config.keys) result.delete(key); + const encoded = config.encode(state); + for (const [key, value] of encoded) result.append(key, value); + return result; + } + }); +} diff --git a/apps/blocks/src/blocks/app-kit/core/runtime.test.tsx b/apps/blocks/src/blocks/app-kit/core/runtime.test.tsx new file mode 100644 index 0000000..59ba557 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/runtime.test.tsx @@ -0,0 +1,1169 @@ +import * as React from 'react'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { describe, expect, it, vi } from 'vitest'; + +import { + APP_RESULT_DISCRIMINATOR, + appFailure, + appSuccess, + defineAction, + defineQuery, + isAppResult, + type AppScope +} from './contracts'; +import { + AppKitProvider, + createAppQueryKey, + createAppQueryRootKey, + useAppAction, + useAppQuery +} from './runtime'; +import { + createAppQueryInputFingerprint, + findAppCredentialInputPath +} from './scope'; + +const scope: AppScope = { + databaseId: 'db-a', + endpointId: 'graphql-a', + organizationId: 'org-a', + schemaRevision: 'schema-a', + securityRevision: 'security-a', + sessionPartition: 'session-a' +}; + +function createClient() { + return new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false } + } + }); +} + +function wrapper(queryClient = createClient(), appScope = scope) { + return ({ children }: Readonly<{ children: React.ReactNode }>) => ( + + {children} + + ); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, reject, resolve }; +} + +describe('AppResult transport boundaries', () => { + it('recognizes helper results after structured clone and JSON round trips', () => { + const success = appSuccess({ id: 'record-1' }); + const failure = appFailure({ + code: 'FORBIDDEN', + kind: 'authorization', + message: 'Denied.' + }); + + const roundTrips = [ + structuredClone(success), + JSON.parse(JSON.stringify(success)) as unknown, + structuredClone(failure), + JSON.parse(JSON.stringify(failure)) as unknown + ]; + expect(roundTrips.every((result) => isAppResult(result))).toBe(true); + }); + + it('keeps unversioned and wrong-version lookalikes as ordinary records', () => { + const ordinary = { data: { id: 'record-1' }, ok: true as const }; + const wrongVersion = { + __constructiveAppKitResult: { + kind: APP_RESULT_DISCRIMINATOR, + version: 2 + }, + data: { id: 'record-2' }, + ok: true as const + }; + + expect(isAppResult(ordinary)).toBe(false); + expect(isAppResult(wrongVersion)).toBe(false); + }); +}); + +describe('useAppQuery', () => { + it('keeps the canonical query-input fingerprint stable', () => { + expect(createAppQueryInputFingerprint(undefined)).toBe( + '660b96a3639b8562f6c34e81b90d7ac6b6fde3df6cc3374d63e9bf03b110b00c' + ); + expect(createAppQueryInputFingerprint('abc')).toBe( + '7f31e07e6f698baf333bc71b2d9637187996447d51e260566c373788381a9340' + ); + expect(createAppQueryInputFingerprint('Hồ Chí Minh')).toBe( + 'd2de482d75e1304fb488e476bc3f068db77d16da6f619d3917c6842e0b18691a' + ); + expect(createAppQueryInputFingerprint('a'.repeat(100))).toBe( + '7f888f7324cc8601671ced1dd7f5b8b0a019546bf58c73173b7d4daa77e62e4e' + ); + }); + + it('keeps complete and credential-shaped input values out of cache keys', () => { + const first = createAppQueryKey(scope, 'records.secure', { + authorization: 'Bearer first-secret', + filter: { status: 'open' }, + nested: { password: 'first-password' } + }); + const rotated = createAppQueryKey(scope, 'records.secure', { + authorization: 'Bearer rotated-secret', + filter: { status: 'open' }, + nested: { password: 'rotated-password' } + }); + const differentFilter = createAppQueryKey(scope, 'records.secure', { + authorization: 'Bearer first-secret', + filter: { status: 'closed' }, + nested: { password: 'first-password' } + }); + + expect(first).toEqual(rotated); + expect(first).not.toEqual(differentFilter); + expect(JSON.stringify(first)).not.toMatch( + /first-secret|first-password|authorization|status|open/u + ); + }); + + it('rejects sparse arrays and hidden or symbolic query-input properties', () => { + const sparse = Array(1); + const hidden = Object.defineProperty({}, 'hidden', { + enumerable: false, + value: 'value' + }); + const symbolic = { visible: 'value' } as Record; + symbolic[Symbol('hidden')] = 'value'; + const decoratedArray = ['value'] as string[] & { label?: string }; + decoratedArray.label = 'extra'; + + expect(() => createAppQueryInputFingerprint(sparse)).toThrow(/sparse/u); + expect(() => createAppQueryInputFingerprint(hidden)).toThrow( + /non-enumerable/u + ); + expect(() => createAppQueryInputFingerprint(symbolic)).toThrow(/symbol/u); + expect(() => createAppQueryInputFingerprint(decoratedArray)).toThrow( + /dense indexed entries/u + ); + expect(createAppQueryInputFingerprint([])).not.toBe( + createAppQueryInputFingerprint([undefined]) + ); + }); + + it('recognizes common transport credential field spellings', () => { + expect( + findAppCredentialInputPath({ headers: { 'x-api-key': 'secret' } }) + ).toBe('headers.x-api-key'); + expect(findAppCredentialInputPath({ csrf_token: 'secret' })).toBe( + 'csrf_token' + ); + expect(findAppCredentialInputPath({ private_key: 'secret' })).toBe( + 'private_key' + ); + }); + + it('does not reuse cached data across database or session partitions', async () => { + const queryClient = createClient(); + const execute = vi.fn(({ scope: currentScope }: { scope: AppScope }) => + currentScope.databaseId + ); + const query = defineQuery, string>({ + id: 'records.detail', + execute + }); + + const first = renderHook(() => useAppQuery(query, { id: 'same' }), { + wrapper: wrapper(queryClient, scope) + }); + await waitFor(() => expect(first.result.current.data).toBe('db-a')); + first.unmount(); + + const secondScope = { + ...scope, + databaseId: 'db-b', + sessionPartition: 'session-b' + }; + const second = renderHook(() => useAppQuery(query, { id: 'same' }), { + wrapper: wrapper(queryClient, secondScope) + }); + await waitFor(() => expect(second.result.current.data).toBe('db-b')); + + expect(execute).toHaveBeenCalledTimes(2); + expect( + queryClient.getQueryData(createAppQueryKey(scope, query.id, { id: 'same' })) + ).toBe('db-a'); + expect( + queryClient.getQueryData( + createAppQueryKey(secondScope, query.id, { id: 'same' }) + ) + ).toBe('db-b'); + }); + + it('aborts stale work when AppScope changes', async () => { + const queryClient = createClient(); + const aborted = vi.fn(); + const query = defineQuery({ + id: 'records.slow', + execute: ({ scope: currentScope, signal }) => { + if (currentScope.databaseId === 'db-b') return 'db-b'; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted(); + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + } + }); + + function Probe() { + const result = useAppQuery(query, undefined); + return {result.data ?? 'loading'}; + } + const view = render( + + + + ); + view.rerender( + + + + ); + + await waitFor(() => expect(screen.getByText('db-b')).toBeInTheDocument()); + expect(aborted).toHaveBeenCalledTimes(1); + }); + + it('rejects credential-shaped inputs before the query executor runs', async () => { + const execute = vi.fn(() => 'should-not-run'); + const query = defineQuery, string>({ + id: 'records.credential-input', + execute + }); + const hook = renderHook( + () => + useAppQuery(query, { + headers: { authorization: 'Bearer secret-query-token' } + }), + { wrapper: wrapper() } + ); + + await waitFor(() => expect(hook.result.current.isError).toBe(true)); + expect(hook.result.current.error?.appError).toMatchObject({ + code: 'CREDENTIAL_IN_INPUT', + fieldErrors: [{ field: 'headers.authorization' }], + kind: 'validation' + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it('does not unwrap ordinary records that happen to use ok and data fields', async () => { + const record = { + data: { id: 'record-1' }, + ok: true as const + }; + const query = defineQuery({ + id: 'records.result-shaped', + execute: () => record + }); + const hook = renderHook(() => useAppQuery(query, undefined), { + wrapper: wrapper() + }); + + await waitFor(() => expect(hook.result.current.data).toBe(record)); + }); +}); + +describe('useAppAction', () => { + it('keeps a successful result when its host observer throws', async () => { + const onResult = vi.fn(() => { + throw new Error('Host result observer failed.'); + }); + const action = defineAction({ + id: 'records.throwing-success-observer', + execute: () => 'saved' + }); + const hook = renderHook(() => useAppAction(action, { onResult }), { + wrapper: wrapper() + }); + + const result = await act(() => hook.result.current.execute(undefined)); + + expect(result).toMatchObject({ data: 'saved', ok: true }); + expect(onResult).toHaveBeenCalledOnce(); + expect(onResult).toHaveBeenCalledWith(result, undefined); + await waitFor(() => + expect(hook.result.current.mutation.status).toBe('success') + ); + }); + + it('keeps a preflight failure when its host observer throws', async () => { + const onResult = vi.fn(() => { + throw new Error('Host result observer failed.'); + }); + const execute = vi.fn(() => 'should-not-run'); + const action = defineAction, string>({ + id: 'records.throwing-preflight-observer', + execute + }); + const hook = renderHook(() => useAppAction(action, { onResult }), { + wrapper: wrapper() + }); + + const result = await act(() => + hook.result.current.execute({ accessToken: 'secret-action-token' }) + ); + + expect(result).toMatchObject({ + error: { code: 'CREDENTIAL_IN_INPUT', kind: 'validation' }, + ok: false + }); + expect(onResult).toHaveBeenCalledOnce(); + expect(onResult).toHaveBeenCalledWith(result, { + accessToken: 'secret-action-token' + }); + expect(execute).not.toHaveBeenCalled(); + expect(hook.result.current.mutation.status).toBe('idle'); + }); + + it('rejects credential-shaped inputs before TanStack creates a mutation', async () => { + const queryClient = createClient(); + const execute = vi.fn(() => 'should-not-run'); + const action = defineAction< + Readonly<{ recordId: string; accessToken: string }>, + string + >({ + id: 'records.credential-input', + execute + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + const result = await act(() => + hook.result.current.execute({ + accessToken: 'secret-action-token', + recordId: 'record-1' + }) + ); + + expect(result).toMatchObject({ + error: { + code: 'CREDENTIAL_IN_INPUT', + fieldErrors: [{ field: 'accessToken' }], + kind: 'validation' + }, + ok: false + }); + expect(execute).not.toHaveBeenCalled(); + expect(queryClient.getMutationCache().getAll()).toHaveLength(0); + }); + + it('fails closed on containers and hidden properties the credential scanner cannot inspect', async () => { + class CredentialContainer { + authorization = 'Bearer class-secret'; + } + const formData = new FormData(); + formData.set('authorization', 'Bearer form-secret'); + const hidden = Object.defineProperty({}, 'authorization', { + enumerable: false, + value: 'Bearer hidden-secret' + }); + const symbolic = {} as Record; + symbolic[Symbol('authorization')] = 'Bearer symbol-secret'; + const inputs: readonly unknown[] = [ + formData, + new Map([['authorization', 'Bearer map-secret']]), + new CredentialContainer(), + hidden, + symbolic + ]; + const queryClient = createClient(); + const execute = vi.fn(() => 'should-not-run'); + const action = defineAction({ + id: 'records.unsupported-credential-container', + execute + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + for (const input of inputs) { + expect(await act(() => hook.result.current.execute(input))).toMatchObject({ + error: { code: 'UNSUPPORTED_INPUT', kind: 'validation' }, + ok: false + }); + } + expect(execute).not.toHaveBeenCalled(); + expect(queryClient.getMutationCache().getAll()).toHaveLength(0); + }); + + it('rejects credentials introduced by action input parsing', async () => { + const queryClient = createClient(); + const execute = vi.fn(() => 'should-not-run'); + const action = defineAction, string>({ + id: 'records.transformed-credential-input', + execute, + inputSchema: { + safeParse: () => ({ + data: { + authorization: 'Bearer transformed-secret', + recordId: 'record-1' + } as unknown as Readonly<{ recordId: string }>, + success: true + }) + } + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + expect( + await act(() => hook.result.current.execute({ recordId: 'record-1' })) + ).toMatchObject({ + error: { + code: 'CREDENTIAL_IN_INPUT', + fieldErrors: [{ field: 'authorization' }], + kind: 'validation' + }, + ok: false + }); + expect(execute).not.toHaveBeenCalled(); + expect(queryClient.getMutationCache().getAll()).toHaveLength(0); + }); + + it('keeps full action inputs out of TanStack mutation variables and context', async () => { + const queryClient = createClient(); + const action = defineAction, string>({ + id: 'records.cache-safe-input', + execute: ({ input }) => input.recordId + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + expect( + await act(() => + hook.result.current.execute({ + note: 'private-domain-value', + recordId: 'record-1' + }) + ) + ).toMatchObject({ data: 'record-1', ok: true }); + + const mutationState = queryClient.getMutationCache().getAll()[0]?.state; + expect(mutationState?.variables).toEqual({ executionId: 1 }); + expect( + JSON.stringify({ + context: mutationState?.context, + variables: mutationState?.variables + }) + ).not.toMatch( + /private-domain-value|record-1|note|recordId/u + ); + }); + + it('treats an ordinary error-shaped record as successful action data', async () => { + const record = { + error: { kind: 'domain', message: 'This is record data.' }, + ok: false as const + }; + const action = defineAction({ + id: 'records.error-shaped', + execute: () => record + }); + const hook = renderHook(() => useAppAction(action), { wrapper: wrapper() }); + + expect(await act(() => hook.result.current.execute(undefined))).toMatchObject({ + data: record, + ok: true + }); + }); + + it('evaluates typed presentation policy with input, scope, and host context', () => { + const action = defineAction< + { ownerId: string }, + string, + unknown, + { recordId: string } + >({ + id: 'sessions.contextual-action', + execute: () => 'done', + presentation: { + disabledReason: ({ context, input, scope: currentScope }) => + input.ownerId === currentScope.sessionPartition && context?.recordId + ? undefined + : 'Only the owner can run this action.', + label: 'Run action', + visible: ({ context }) => context?.recordId !== 'archived' + } + }); + const hook = renderHook( + () => + useAppAction(action, { + presentationContext: { recordId: 'session-1' }, + presentationInput: { ownerId: scope.sessionPartition } + }), + { wrapper: wrapper() } + ); + + expect(hook.result.current.visible).toBe(true); + expect(hook.result.current.disabledReason).toBeUndefined(); + expect( + hook.result.current.evaluatePresentation( + { ownerId: 'another-session' }, + { recordId: 'session-1' } + ) + ).toEqual({ + disabledReason: 'Only the owner can run this action.', + visible: true + }); + }); + + it('validates input before execution', async () => { + const execute = vi.fn(() => 'saved'); + const action = defineAction<{ title: string }, string>({ + id: 'programs.save', + inputSchema: { + safeParse: () => ({ + error: { + issues: [{ message: 'Title is required.', path: ['title'] }], + message: 'Invalid program.' + }, + success: false + }) + }, + execute + }); + const hook = renderHook(() => useAppAction(action), { wrapper: wrapper() }); + + const result = await act(() => hook.result.current.execute({ title: '' })); + expect(result).toMatchObject({ + error: { + fieldErrors: [{ field: 'title', message: 'Title is required.' }], + kind: 'validation' + }, + ok: false + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it('blocks double submission by default', async () => { + const pending = deferred(); + const action = defineAction({ + id: 'sessions.publish', + execute: () => pending.promise + }); + const hook = renderHook(() => useAppAction(action), { wrapper: wrapper() }); + + let first!: Promise; + let second!: Awaited>; + await act(async () => { + first = hook.result.current.execute(1); + second = await hook.result.current.execute(2); + }); + expect(second).toMatchObject({ + error: { code: 'ACTION_IN_PROGRESS', kind: 'conflict' }, + ok: false + }); + + await act(async () => { + pending.resolve('published'); + await first; + }); + }); + + it('never inherits host mutation retries for non-idempotent actions', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: 1 }, + queries: { retry: false } + } + }); + const execute = vi.fn(() => { + throw new Error('Network failed after submission.'); + }); + const action = defineAction({ + id: 'sessions.non-idempotent', + execute + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + expect(await act(() => hook.result.current.execute(undefined))).toMatchObject({ + error: { message: 'Network failed after submission.' }, + ok: false + }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('normalizes cancellation before TanStack callbacks observe the error', async () => { + const action = defineAction({ + id: 'sessions.cancelable', + execute: ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')) + ); + }) + }); + const hook = renderHook(() => useAppAction(action), { wrapper: wrapper() }); + + let execution!: Promise>>; + await act(async () => { + execution = hook.result.current.execute(undefined); + await Promise.resolve(); + hook.result.current.cancel(); + }); + const result = await act(() => execution); + expect(result).toMatchObject({ + error: { code: 'CANCELLED', kind: 'cancelled' }, + ok: false + }); + await waitFor(() => + expect(hook.result.current.mutation.error?.appError.kind).toBe('cancelled') + ); + }); + + it('preserves GraphQL partial-error and authorization details', async () => { + const graphqlAction = defineAction({ + id: 'sessions.partial', + execute: () => + appFailure({ + code: 'PARTIAL_GRAPHQL_RESULT', + details: { paths: ['session.venue'] }, + kind: 'graphql', + message: 'Some requested fields failed.' + }) + }); + const deniedAction = defineAction({ + id: 'sessions.denied', + execute: () => + appFailure({ + code: 'FORBIDDEN', + kind: 'authorization', + message: 'Publishing is not allowed.' + }) + }); + const graphql = renderHook(() => useAppAction(graphqlAction), { + wrapper: wrapper() + }); + const denied = renderHook(() => useAppAction(deniedAction), { + wrapper: wrapper() + }); + + expect(await act(() => graphql.result.current.execute(undefined))).toMatchObject({ + error: { details: { paths: ['session.venue'] }, kind: 'graphql' }, + ok: false + }); + expect(await act(() => denied.result.current.execute(undefined))).toMatchObject({ + error: { code: 'FORBIDDEN', kind: 'authorization' }, + ok: false + }); + }); + + it('rolls optimistic data back after failure', async () => { + const queryClient = createClient(); + const queryKey = createAppQueryKey(scope, 'sessions.list', { page: 1 }); + queryClient.setQueryData(queryKey, ['draft']); + const rollback = vi.fn(); + const action = defineAction({ + id: 'sessions.optimistic-publish', + execute: () => + appFailure({ kind: 'authorization', message: 'Denied by RLS.' }), + optimistic: { + apply: ({ queryClient: cache }) => { + const previous = cache.getQueryData(queryKey) ?? []; + cache.setQueryData(queryKey, ['published']); + return previous; + }, + rollback: ({ optimisticContext, queryClient: cache }) => { + rollback(); + cache.setQueryData(queryKey, optimisticContext); + } + } + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + const result = await act(() => hook.result.current.execute('session-1')); + expect(result.ok).toBe(false); + expect(rollback).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(queryKey)).toEqual(['draft']); + }); + + it('rolls back and settles when a completed optimistic apply returns void', async () => { + const apply = vi.fn(() => undefined); + const rollback = vi.fn(); + const settle = vi.fn(); + const action = defineAction({ + id: 'sessions.void-optimistic-context', + execute: () => + appFailure({ kind: 'authorization', message: 'Denied by RLS.' }), + optimistic: { apply, rollback, settle } + }); + const hook = renderHook(() => useAppAction(action), { wrapper: wrapper() }); + + expect(await act(() => hook.result.current.execute(undefined))).toMatchObject({ + error: { kind: 'authorization' }, + ok: false + }); + expect(apply).toHaveBeenCalledTimes(1); + expect(rollback).toHaveBeenCalledWith( + expect.objectContaining({ optimisticContext: undefined }) + ); + expect(settle).toHaveBeenCalledWith( + expect.objectContaining({ + optimisticContext: undefined, + result: expect.objectContaining({ ok: false }) + }) + ); + }); + + it('restores every touched cache entry when optimistic apply throws', async () => { + const queryClient = createClient(); + const firstKey = createAppQueryKey(scope, 'sessions.detail', { id: 'first' }); + const secondKey = createAppQueryKey(scope, 'sessions.detail', { id: 'second' }); + queryClient.setQueryData(firstKey, 'first-baseline'); + queryClient.setQueryData(secondKey, 'second-baseline'); + const execute = vi.fn(() => 'should-not-run'); + const rollback = vi.fn(); + const settle = vi.fn(); + const action = defineAction({ + id: 'sessions.partial-optimistic-apply', + execute, + optimistic: { + apply: ({ queryClient: cache }) => { + cache.setQueryData(firstKey, 'first-optimistic'); + cache.setQueryData(secondKey, 'second-optimistic'); + throw new Error('Optimistic projection failed.'); + }, + rollback, + settle + } + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + expect(await act(() => hook.result.current.execute(undefined))).toMatchObject({ + error: { message: 'Optimistic projection failed.' }, + ok: false + }); + expect(queryClient.getQueryData(firstKey)).toBe('first-baseline'); + expect(queryClient.getQueryData(secondKey)).toBe('second-baseline'); + expect(execute).not.toHaveBeenCalled(); + expect(rollback).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + }); + + it('restores local optimistic writes when a later foreign-scope guard throws', async () => { + const queryClient = createClient(); + const foreignScope: AppScope = { + ...scope, + organizationId: 'org-b', + securityRevision: 'security-b' + }; + const localKey = createAppQueryKey(scope, 'sessions.list', { page: 1 }); + const foreignKey = createAppQueryKey(foreignScope, 'sessions.list', { + page: 1 + }); + queryClient.setQueryData(localKey, ['local-baseline']); + queryClient.setQueryData(foreignKey, ['foreign-baseline']); + const execute = vi.fn(() => 'should-not-run'); + const action = defineAction({ + id: 'sessions.partial-foreign-scope-apply', + execute, + optimistic: { + apply: ({ queryClient: cache }) => { + cache.setQueryData(localKey, ['local-optimistic']); + cache.setQueryData(foreignKey, ['foreign-breach']); + }, + rollback: vi.fn() + } + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + expect(await act(() => hook.result.current.execute(undefined))).toMatchObject({ + error: { message: expect.stringMatching(/AppScope/u) }, + ok: false + }); + expect(queryClient.getQueryData(localKey)).toEqual(['local-baseline']); + expect(queryClient.getQueryData(foreignKey)).toEqual(['foreign-baseline']); + expect(execute).not.toHaveBeenCalled(); + }); + + it('rejects optimistic reads, writes, and cancellation outside the action scope', async () => { + const queryClient = createClient(); + const foreignScope: AppScope = { + ...scope, + organizationId: 'org-b', + securityRevision: 'security-b' + }; + const localKey = createAppQueryKey(scope, 'sessions.list', { page: 1 }); + const foreignKey = createAppQueryKey(foreignScope, 'sessions.list', { + page: 1 + }); + queryClient.setQueryData(localKey, ['local']); + queryClient.setQueryData(foreignKey, ['foreign']); + const action = defineAction({ + id: 'sessions.scope-bound-cache', + execute: () => 'saved', + optimistic: { + apply: ({ queryClient: cache }) => { + expect(cache.getQueryData(localKey)).toEqual(['local']); + expect(() => cache.getQueryData(foreignKey)).toThrow(/AppScope/u); + expect(() => cache.setQueryData(foreignKey, ['breach'])).toThrow( + /AppScope/u + ); + expect(() => cache.cancelQueries({ queryKey: foreignKey })).toThrow( + /AppScope/u + ); + cache.setQueryData(localKey, ['optimistic-local']); + }, + rollback: vi.fn() + } + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + expect(await act(() => hook.result.current.execute(undefined))).toMatchObject({ + data: 'saved', + ok: true + }); + expect(queryClient.getQueryData(localKey)).toEqual(['optimistic-local']); + expect(queryClient.getQueryData(foreignKey)).toEqual(['foreign']); + }); + + it('keeps optimistic writes and invalidation in the scope that started the action', async () => { + const queryClient = createClient(); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + const secondScope: AppScope = { + ...scope, + organizationId: 'org-b', + securityRevision: 'security-b' + }; + const input = { page: 1 }; + const firstKey = createAppQueryKey(scope, 'sessions.list', input); + const secondKey = createAppQueryKey(secondScope, 'sessions.list', input); + queryClient.setQueryData(firstKey, ['scope-a']); + queryClient.setQueryData(secondKey, ['scope-b']); + const pending = deferred(); + const action = defineAction({ + id: 'sessions.scoped-optimistic', + execute: () => pending.promise, + invalidate: [{ queryId: 'sessions.list' }], + optimistic: { + apply: ({ queryClient: cache, scope: actionScope }) => { + const key = createAppQueryKey(actionScope, 'sessions.list', input); + const previous = cache.getQueryData(key) ?? []; + cache.setQueryData(key, ['optimistic']); + return previous; + }, + rollback: ({ optimisticContext, queryClient: cache, scope: actionScope }) => { + cache.setQueryData( + createAppQueryKey(actionScope, 'sessions.list', input), + optimisticContext + ); + } + } + }); + let activeScope = scope; + const dynamicWrapper = ({ children }: Readonly<{ children: React.ReactNode }>) => ( + + {children} + + ); + const hook = renderHook(() => useAppAction(action), { + wrapper: dynamicWrapper + }); + + let execution!: Promise>>; + act(() => { + execution = hook.result.current.execute(undefined); + }); + await waitFor(() => + expect(queryClient.getQueryData(firstKey)).toEqual(['optimistic']) + ); + + activeScope = secondScope; + hook.rerender(); + expect(queryClient.getQueryData(secondKey)).toEqual(['scope-b']); + + await act(async () => { + pending.resolve('saved'); + await execution; + }); + expect(queryClient.getQueryData(secondKey)).toEqual(['scope-b']); + expect(invalidate).toHaveBeenCalledWith({ + exact: false, + queryKey: createAppQueryRootKey(scope, 'sessions.list') + }); + expect(invalidate).not.toHaveBeenCalledWith({ + exact: false, + queryKey: createAppQueryRootKey(secondScope, 'sessions.list') + }); + }); + + it('invalidates only the declared scoped query targets', async () => { + const queryClient = createClient(); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + const action = defineAction<{ id: string }, string>({ + id: 'sessions.schedule', + execute: ({ input }) => `scheduled:${input.id}`, + inputSchema: { + safeParse: (input) => ({ + data: { id: (input as { id: string }).id.trim() }, + success: true + }) + }, + invalidate: ({ input }) => [ + { queryId: 'sessions.detail', input: { id: input.id } }, + { queryId: 'sessions.calendar' } + ] + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + await act(() => hook.result.current.execute({ id: ' session-3 ' })); + expect(invalidate).toHaveBeenCalledWith({ + exact: true, + queryKey: createAppQueryKey(scope, 'sessions.detail', { id: 'session-3' }) + }); + expect(invalidate).toHaveBeenCalledWith({ + exact: false, + queryKey: expect.arrayContaining([ + 'constructive-app-kit', + scope.databaseId, + scope.sessionPartition, + 'sessions.calendar' + ]) + }); + }); + + it('reports invalidation failure without rolling back a committed action', async () => { + const queryClient = createClient(); + vi.spyOn(queryClient, 'invalidateQueries').mockRejectedValueOnce( + new Error('Cache refresh failed.') + ); + const queryKey = createAppQueryKey(scope, 'sessions.detail', { + id: 'session-1' + }); + queryClient.setQueryData(queryKey, 'baseline'); + const rollback = vi.fn(); + const onPostCommitError = vi.fn(); + const action = defineAction({ + id: 'sessions.committed-invalidation-failure', + execute: () => 'server-committed', + invalidate: [{ queryId: 'sessions.detail' }], + optimistic: { + apply: ({ queryClient: cache }) => { + const previous = cache.getQueryData(queryKey) ?? 'baseline'; + cache.setQueryData(queryKey, 'optimistic'); + return previous; + }, + rollback + } + }); + const hook = renderHook( + () => useAppAction(action, { onPostCommitError }), + { wrapper: wrapper(queryClient) } + ); + + expect(await act(() => hook.result.current.execute(undefined))).toMatchObject({ + data: 'server-committed', + ok: true + }); + expect(rollback).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(queryKey)).toBe('optimistic'); + expect(onPostCommitError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ message: 'Cache refresh failed.' }), + phase: 'invalidation' + }) + ); + await waitFor(() => expect(hook.result.current.mutation.isSuccess).toBe(true)); + }); + + it('reports settle failure without changing a committed action to failure', async () => { + const queryClient = createClient(); + const rollback = vi.fn(); + const onPostCommitError = vi.fn(); + const action = defineAction({ + id: 'sessions.committed-settle-failure', + execute: () => 'server-committed', + optimistic: { + apply: () => undefined, + rollback, + settle: () => { + throw new Error('Optimistic settle failed.'); + } + } + }); + const hook = renderHook( + () => useAppAction(action, { onPostCommitError }), + { wrapper: wrapper(queryClient) } + ); + + expect(await act(() => hook.result.current.execute(undefined))).toMatchObject({ + data: 'server-committed', + ok: true + }); + expect(rollback).not.toHaveBeenCalled(); + expect(onPostCommitError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ message: 'Optimistic settle failed.' }), + phase: 'settle' + }) + ); + await waitFor(() => expect(hook.result.current.mutation.isSuccess).toBe(true)); + }); + + it('finishes a replaced optimistic transaction before applying its replacement', async () => { + const firstRollback = deferred(); + const queryClient = createClient(); + const queryKey = createAppQueryKey(scope, 'sessions.detail', { id: 'session-1' }); + queryClient.setQueryData(queryKey, 'baseline'); + const signals = new Map(); + const apply = vi.fn(({ input }: { input: string }) => { + const previous = queryClient.getQueryData(queryKey) ?? 'baseline'; + queryClient.setQueryData(queryKey, `optimistic:${input}`); + return previous; + }); + const rollback = vi.fn(async ({ input, optimisticContext }: { + input: string; + optimisticContext: string; + }) => { + if (input === 'first') await firstRollback.promise; + queryClient.setQueryData(queryKey, optimisticContext); + }); + const settle = vi.fn(); + const action = defineAction({ + concurrency: 'replace', + id: 'sessions.replace-interleaving', + execute: ({ input, signal }) => + new Promise((_resolve, reject) => { + signals.set(input, signal); + signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + optimistic: { + apply, + rollback, + settle + } + }); + const hook = renderHook(() => useAppAction(action), { + wrapper: wrapper(queryClient) + }); + + let first!: Promise>>; + act(() => { + first = hook.result.current.execute('first'); + }); + await waitFor(() => expect(signals.has('first')).toBe(true)); + expect(queryClient.getQueryData(queryKey)).toBe('optimistic:first'); + + let second!: Promise>>; + act(() => { + second = hook.result.current.execute('second'); + }); + expect(signals.get('first')?.aborted).toBe(true); + await waitFor(() => expect(rollback).toHaveBeenCalledTimes(1)); + expect(signals.has('second')).toBe(false); + expect(apply).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(queryKey)).toBe('optimistic:first'); + + await act(async () => { + firstRollback.resolve(); + await first; + }); + await waitFor(() => expect(signals.has('second')).toBe(true)); + expect(apply).toHaveBeenCalledTimes(2); + expect(queryClient.getQueryData(queryKey)).toBe('optimistic:second'); + expect(hook.result.current.mutation.isPending).toBe(true); + + act(() => hook.result.current.cancel()); + expect(await act(() => second)).toMatchObject({ + error: { kind: 'cancelled' }, + ok: false + }); + expect(signals.get('second')?.aborted).toBe(true); + expect(queryClient.getQueryData(queryKey)).toBe('baseline'); + expect(settle).toHaveBeenCalledTimes(2); + }); + + it('cancels a pending action before starting a replacement in another AppScope', async () => { + const queryClient = createClient(); + const secondScope: AppScope = { + ...scope, + organizationId: 'org-b', + securityRevision: 'security-b' + }; + const execute = vi.fn( + ({ scope: actionScope, signal }: { scope: AppScope; signal: AbortSignal }) => { + if (actionScope.organizationId === 'org-b') return 'saved-in-org-b'; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')) + ); + }); + } + ); + const action = defineAction({ + id: 'sessions.scope-transition', + execute + }); + let activeScope = scope; + const dynamicWrapper = ({ children }: Readonly<{ children: React.ReactNode }>) => ( + + {children} + + ); + const hook = renderHook(() => useAppAction(action), { + wrapper: dynamicWrapper + }); + + let first!: Promise>>; + act(() => { + first = hook.result.current.execute(undefined); + }); + await waitFor(() => expect(execute).toHaveBeenCalledTimes(1)); + + activeScope = secondScope; + hook.rerender(); + let second!: Promise>>; + act(() => { + second = hook.result.current.execute(undefined); + }); + + expect(await act(() => first)).toMatchObject({ + error: { kind: 'cancelled' }, + ok: false + }); + expect(await act(() => second)).toMatchObject({ + data: 'saved-in-org-b', + ok: true + }); + expect(execute).toHaveBeenCalledTimes(2); + }); + +}); diff --git a/apps/blocks/src/blocks/app-kit/core/runtime.tsx b/apps/blocks/src/blocks/app-kit/core/runtime.tsx new file mode 100644 index 0000000..00b5b4a --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/runtime.tsx @@ -0,0 +1,775 @@ +'use client'; + +import * as React from 'react'; +import { + QueryClient, + QueryClientProvider, + useMutation, + useQuery, + useQueryClient, + type QueryState, + type UseQueryResult +} from '@tanstack/react-query'; + +import { + appFailure, + appSuccess, + isAppResult, + normalizeAppError, + type AppActionDefinition, + type AppError, + type AppExecutorResult, + type AppInputSchema, + type AppQueryCache, + type AppQueryCacheFilters, + type AppQueryCacheUpdater, + type AppQueryDefinition, + type AppResult, + type AppScopedQueryKey, + type AppScope +} from './contracts'; +import { + createAppScopeFingerprint, + createAppQueryKey, + createAppQueryRootKey, + createAppScopeQueryKey, + findAppCredentialInputPath, + type AppScopeQueryKey +} from './scope'; + +const AppScopeContext = React.createContext(null); + +export class AppRuntimeError extends Error { + readonly appError: AppError; + + constructor(appError: AppError) { + super(appError.message); + this.name = 'AppRuntimeError'; + this.appError = appError; + } +} + +export type AppKitProviderProps = Readonly<{ + scope: AppScope; + queryClient: QueryClient; + children: React.ReactNode; +}>; + +export function AppKitProvider({ + scope, + queryClient, + children +}: AppKitProviderProps) { + const scopeKey = createAppScopeFingerprint(scope); + const stableScope = React.useMemo(() => scope, [scopeKey]); + return ( + + + {children} + + + ); +} + +export function useAppScope(): AppScope { + const scope = React.useContext(AppScopeContext); + if (!scope) { + throw new Error('App Kit hooks must be rendered inside AppKitProvider.'); + } + return scope; +} + +async function unwrapExecutorResult( + value: AppExecutorResult | Promise> +): Promise { + const result = await value; + if (!isAppResult(result)) return result; + if (result.ok) return result.data; + throw new AppRuntimeError(result.error); +} + +async function executeApp( + operation: () => AppExecutorResult | Promise> +): Promise { + try { + return await unwrapExecutorResult(operation()); + } catch (error) { + if (error instanceof AppRuntimeError) throw error; + throw new AppRuntimeError(normalizeAppError(error)); + } +} + +async function executeWithAbortSignal( + signal: AbortSignal, + operation: () => AppExecutorResult | Promise> +): Promise> { + if (signal.aborted) throw new DOMException('Aborted', 'AbortError'); + let abort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + abort = () => reject(new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', abort, { once: true }); + }); + try { + return await Promise.race([Promise.resolve().then(operation), aborted]); + } finally { + if (abort) signal.removeEventListener('abort', abort); + } +} + +function credentialInputError(path: string): AppRuntimeError { + return new AppRuntimeError({ + code: 'CREDENTIAL_IN_INPUT', + fieldErrors: [ + { + field: path, + message: 'Capture credentials in the query or action executor closure.' + } + ], + kind: 'validation', + message: + 'App Kit inputs must be credential-free. Capture authentication in the executor closure instead.' + }); +} + +function inspectCredentialFreeInput(input: unknown): string | undefined { + try { + return findAppCredentialInputPath(input); + } catch (error) { + throw new AppRuntimeError({ + code: 'UNSUPPORTED_INPUT', + kind: 'validation', + message: + error instanceof Error + ? error.message + : 'App Kit inputs must use supported plain values.' + }); + } +} + +function assertCredentialFreeInput(input: unknown): void { + const credentialPath = inspectCredentialFreeInput(input); + if (credentialPath) throw credentialInputError(credentialPath); +} + +function queryKeyStartsWithScope( + queryKey: readonly unknown[], + scopeKey: AppScopeQueryKey +): boolean { + return scopeKey.every((part, index) => Object.is(queryKey[index], part)); +} + +type ScopeBoundQueryCacheObserver = Readonly<{ + beforeSet?: (queryKey: AppScopedQueryKey) => void; +}>; + +function createScopeBoundQueryCache( + queryClient: QueryClient, + scope: AppScope, + observer: ScopeBoundQueryCacheObserver = {} +): AppQueryCache { + const scopeKey = createAppScopeQueryKey(scope); + const assertScopedKey = (queryKey: readonly unknown[]) => { + if (!queryKeyStartsWithScope(queryKey, scopeKey)) { + throw new Error( + 'Optimistic cache access must use a key created for the AppScope that started the action.' + ); + } + }; + + const cache: AppQueryCache = { + cancelQueries: (filters: AppQueryCacheFilters = {}) => { + const queryKey = filters.queryKey ?? scopeKey; + assertScopedKey(queryKey); + return queryClient.cancelQueries({ + exact: filters.exact, + queryKey + }); + }, + getQueryData: (queryKey: AppScopedQueryKey) => { + assertScopedKey(queryKey); + return queryClient.getQueryData(queryKey); + }, + setQueryData: ( + queryKey: AppScopedQueryKey, + updater: AppQueryCacheUpdater + ) => { + assertScopedKey(queryKey); + observer.beforeSet?.(queryKey); + return queryClient.setQueryData(queryKey, updater); + } + }; + return Object.freeze(cache); +} + +type OptimisticCacheSnapshot = Readonly<{ + queryKey: AppScopedQueryKey; + state: QueryState | null; +}>; + +type OptimisticCacheTransaction = Readonly<{ + commit: () => void; + queryCache: AppQueryCache; + restore: () => void; +}>; + +function createOptimisticCacheTransaction( + queryClient: QueryClient, + scope: AppScope +): OptimisticCacheTransaction { + const snapshots = new Map(); + let active = true; + const snapshotQuery = (queryKey: AppScopedQueryKey) => { + if (!active) return; + const fingerprint = JSON.stringify(queryKey); + if (snapshots.has(fingerprint)) return; + const query = queryClient.getQueryCache().find({ exact: true, queryKey }); + snapshots.set(fingerprint, { + queryKey, + state: query?.state ?? null + }); + }; + const queryCache = createScopeBoundQueryCache(queryClient, scope, { + beforeSet: snapshotQuery + }); + + return { + commit: () => { + active = false; + snapshots.clear(); + }, + queryCache, + restore: () => { + try { + for (const snapshot of [...snapshots.values()].reverse()) { + if (!snapshot.state) { + queryClient.removeQueries({ + exact: true, + queryKey: snapshot.queryKey + }); + continue; + } + const query = queryClient.getQueryCache().find({ + exact: true, + queryKey: snapshot.queryKey + }); + if (!query) { + throw new Error( + 'App Kit could not restore an optimistic cache entry that was removed during apply.' + ); + } + query.setState(snapshot.state); + } + } finally { + active = false; + snapshots.clear(); + } + } + }; +} + +export type UseAppQueryOptions = Readonly<{ + enabled?: boolean; + staleTime?: number; +}>; + +export function useAppQuery( + definition: AppQueryDefinition, + input: TInput, + options: UseAppQueryOptions = {} +): UseQueryResult { + const scope = useAppScope(); + const credentialPath = inspectCredentialFreeInput(input); + return useQuery({ + enabled: options.enabled, + queryKey: createAppQueryKey(scope, definition.id, input), + queryFn: ({ signal }) => { + if (credentialPath) throw credentialInputError(credentialPath); + return executeApp(() => definition.execute({ input, scope, signal })); + }, + refetchInterval: false, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + staleTime: options.staleTime ?? definition.staleTime + }); +} + +function validationError(schema: AppInputSchema, input: unknown) { + const parsed = schema.safeParse(input); + if (parsed.success) return parsed.data; + const fieldErrors = parsed.error.issues?.map((issue) => ({ + field: issue.path?.map(String).join('.') ?? '', + message: issue.message + })); + throw new AppRuntimeError({ + code: 'INVALID_INPUT', + fieldErrors, + kind: 'validation', + message: parsed.error.message ?? fieldErrors?.[0]?.message ?? 'The action input is invalid.' + }); +} + +function parseInput( + schema: AppInputSchema | undefined, + input: TInput +): TInput { + return schema ? validationError(schema, input) : input; +} + +export type UseAppActionOptions = Readonly<{ + /** Observes the final result; callback failures never change action execution. */ + onResult?: (result: AppResult, input: TInput) => void; + /** Reports UI synchronization failures after the server action committed. */ + onPostCommitError?: (context: Readonly<{ + error: AppError; + input: TInput; + output: TOutput; + phase: 'invalidation' | 'settle'; + scope: AppScope; + }>) => void; + presentationInput?: TInput; + presentationContext?: TContext; +}>; + +export type AppActionPresentationState = Readonly<{ + visible: boolean; + disabledReason?: string; +}>; + +export type AppActionMutationState = Readonly<{ + data: TOutput | undefined; + error: AppRuntimeError | null; + failureCount: number; + failureReason: AppRuntimeError | null; + isError: boolean; + isIdle: boolean; + isPaused: boolean; + isPending: boolean; + isSuccess: boolean; + status: 'error' | 'idle' | 'pending' | 'success'; + submittedAt: number; +}>; + +export type UseAppActionResult< + TInput, + TOutput, + TOptimistic = unknown, + TContext = unknown +> = Readonly<{ + execute: (input: TInput) => Promise>; + cancel: () => void; + reset: () => void; + evaluatePresentation: ( + input: TInput, + context?: TContext + ) => AppActionPresentationState; + visible: boolean; + disabledReason?: string; + confirmation?: NonNullable< + AppActionDefinition['presentation'] + >['confirmation']; + mutation: AppActionMutationState; +}>; + +type AppActionMutationVariables = Readonly<{ + executionId: number; +}>; + +type AppActionOptimisticMutationContext = + | Readonly<{ applied: false }> + | Readonly<{ applied: true; value: TOptimistic }>; + +type AppActionMutationContext = Readonly<{ + executionId: number; + optimistic: AppActionOptimisticMutationContext; +}>; + +type AppActionExecution< + TInput, + TOutput, + TOptimistic, + TContext +> = Readonly<{ + controller: AbortController; + definition: AppActionDefinition; + input: TInput; + onPostCommitError?: UseAppActionOptions< + TInput, + TOutput, + TContext + >['onPostCommitError']; + queryCache: AppQueryCache; + runtimeQueryClient: QueryClient; + scope: AppScope; + scopeKey: string; +}>; + +function reportPostCommitError< + TInput, + TOutput, + TOptimistic, + TContext +>( + execution: AppActionExecution, + output: TOutput, + phase: 'invalidation' | 'settle', + error: unknown +): void { + const appError = normalizeAppError( + error, + phase === 'invalidation' + ? 'The action committed, but related cached views could not be refreshed.' + : 'The action committed, but optimistic cleanup could not be completed.' + ); + try { + execution.onPostCommitError?.({ + error: appError, + input: execution.input, + output, + phase, + scope: execution.scope + }); + } catch { + // A host diagnostic callback must never change the committed action result. + } +} + +function notifyActionResult( + onResult: UseAppActionOptions['onResult'], + result: AppResult, + input: TInput +): void { + try { + onResult?.(result, input); + } catch { + // A host observer must never change or duplicate the action result. + } +} + +export function useAppAction< + TInput, + TOutput, + TOptimistic = unknown, + TContext = unknown +>( + definition: AppActionDefinition, + options: UseAppActionOptions = {} +): UseAppActionResult { + const scope = useAppScope(); + const queryClient = useQueryClient(); + const scopeKey = createAppScopeFingerprint(scope); + const queryCache = React.useMemo( + () => createScopeBoundQueryCache(queryClient, scope), + [queryClient, scopeKey] + ); + const executionCounterRef = React.useRef(0); + const currentExecutionRef = React.useRef; + controller: AbortController; + id: number; + scopeKey: string; + }> | null>(null); + const executionsRef = React.useRef( + new Map< + number, + AppActionExecution + >() + ); + const pendingRef = React.useRef(false); + + React.useEffect( + () => () => currentExecutionRef.current?.controller.abort(), + [] + ); + + const getExecution = React.useCallback((executionId: number) => { + const execution = executionsRef.current.get(executionId); + if (!execution) { + throw new AppRuntimeError({ + code: 'ACTION_EXECUTION_MISSING', + kind: 'unknown', + message: 'The action execution context is no longer available.' + }); + } + return execution; + }, []); + + const mutation = useMutation< + TOutput, + AppRuntimeError, + AppActionMutationVariables, + AppActionMutationContext + >({ + mutationKey: [...createAppScopeQueryKey(scope), 'action', definition.id], + retry: false, + mutationFn: async ({ executionId }) => { + const execution = getExecution(executionId); + return executeApp(() => + executeWithAbortSignal(execution.controller.signal, () => + execution.definition.execute({ + input: execution.input, + scope: execution.scope, + signal: execution.controller.signal + }) + ) + ); + }, + onMutate: async ({ executionId }) => { + const execution = getExecution(executionId); + if (!execution.definition.optimistic) { + return { executionId, optimistic: { applied: false } }; + } + const transaction = createOptimisticCacheTransaction( + execution.runtimeQueryClient, + execution.scope + ); + try { + const value = await execution.definition.optimistic.apply({ + input: execution.input, + queryClient: transaction.queryCache, + scope: execution.scope + }); + transaction.commit(); + return { executionId, optimistic: { applied: true, value } }; + } catch (error) { + try { + transaction.restore(); + } catch (recoveryError) { + throw new AppRuntimeError({ + code: 'OPTIMISTIC_RECOVERY_FAILED', + details: { + applyError: normalizeAppError(error), + recoveryError: normalizeAppError(recoveryError) + }, + kind: 'unknown', + message: + 'The optimistic update failed and App Kit could not restore its cache snapshot.' + }); + } + if (error instanceof AppRuntimeError) throw error; + throw new AppRuntimeError(normalizeAppError(error)); + } + }, + onError: async (runtimeError, variables, context) => { + const execution = getExecution(variables.executionId); + if (execution.definition.optimistic && context?.optimistic.applied) { + await execution.definition.optimistic.rollback({ + error: + runtimeError instanceof AppRuntimeError + ? runtimeError.appError + : normalizeAppError(runtimeError), + input: execution.input, + optimisticContext: context.optimistic.value, + queryClient: execution.queryCache, + scope: execution.scope + }); + } + }, + onSuccess: async (output, variables, context) => { + const execution = getExecution(variables.executionId); + try { + const targets = + typeof execution.definition.invalidate === 'function' + ? execution.definition.invalidate({ + input: execution.input, + output, + scope: execution.scope + }) + : execution.definition.invalidate ?? []; + await Promise.all( + targets.map((target) => + execution.runtimeQueryClient.invalidateQueries({ + exact: target.exact ?? target.input !== undefined, + queryKey: + target.input === undefined + ? createAppQueryRootKey(execution.scope, target.queryId) + : createAppQueryKey( + execution.scope, + target.queryId, + target.input + ) + }) + ) + ); + } catch (error) { + reportPostCommitError(execution, output, 'invalidation', error); + } + }, + onSettled: async (output, runtimeError, variables, context) => { + const execution = getExecution(variables.executionId); + if (execution.definition.optimistic?.settle && context?.optimistic.applied) { + const result = runtimeError + ? appFailure( + runtimeError instanceof AppRuntimeError + ? runtimeError.appError + : normalizeAppError(runtimeError) + ) + : appSuccess(output as TOutput); + try { + await execution.definition.optimistic.settle({ + input: execution.input, + optimisticContext: context.optimistic.value, + queryClient: execution.queryCache, + result, + scope: execution.scope + }); + } catch (error) { + if (!runtimeError) { + reportPostCommitError( + execution, + output as TOutput, + 'settle', + error + ); + } + } + } + } + }); + + const evaluatePresentation = React.useCallback( + (input: TInput, context?: TContext): AppActionPresentationState => ({ + disabledReason: definition.presentation?.disabledReason?.({ + context, + input, + scope + }), + visible: + definition.presentation?.visible?.({ context, input, scope }) ?? true + }), + [definition.presentation, scope] + ); + + const execute = React.useCallback( + async (input: TInput): Promise> => { + let parsedInput: TInput; + try { + assertCredentialFreeInput(input); + parsedInput = parseInput(definition.inputSchema, input); + assertCredentialFreeInput(parsedInput); + } catch (error) { + const appError = + error instanceof AppRuntimeError + ? error.appError + : normalizeAppError(error); + const result = appFailure(appError); + notifyActionResult(options.onResult, result, input); + return result; + } + + const previousExecution = currentExecutionRef.current; + const previousIsSameScope = previousExecution?.scopeKey === scopeKey; + if (pendingRef.current && previousIsSameScope) { + if ((definition.concurrency ?? 'block') !== 'replace') { + const result = appFailure({ + code: 'ACTION_IN_PROGRESS', + kind: 'conflict', + message: 'This action is already in progress.', + retryable: true + }); + notifyActionResult(options.onResult, result, input); + return result; + } + } + + const executionId = ++executionCounterRef.current; + const controller = new AbortController(); + let completeExecution!: () => void; + const completion = new Promise((resolve) => { + completeExecution = resolve; + }); + currentExecutionRef.current = { + completion, + controller, + id: executionId, + scopeKey + }; + pendingRef.current = true; + if ( + previousExecution && + (!previousIsSameScope || (definition.concurrency ?? 'block') === 'replace') + ) { + previousExecution.controller.abort(); + } + try { + // Finish the replaced optimistic transaction before applying the next + // one so its rollback cannot overwrite the newer optimistic state. + await previousExecution?.completion; + if (controller.signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + executionsRef.current.set(executionId, { + controller, + definition, + input: parsedInput, + onPostCommitError: options.onPostCommitError, + queryCache, + runtimeQueryClient: queryClient, + scope, + scopeKey + }); + const data = await mutation.mutateAsync({ executionId }); + const result = appSuccess(data); + notifyActionResult(options.onResult, result, parsedInput); + return result; + } catch (error) { + const appError = + error instanceof AppRuntimeError + ? error.appError + : normalizeAppError(error); + const result = appFailure(appError); + notifyActionResult(options.onResult, result, parsedInput); + return result; + } finally { + executionsRef.current.delete(executionId); + completeExecution(); + if (currentExecutionRef.current?.id === executionId) { + currentExecutionRef.current = null; + pendingRef.current = false; + } + } + }, + [definition, mutation, options, queryCache, queryClient, scope, scopeKey] + ); + + const hasPresentationInput = Object.prototype.hasOwnProperty.call( + options, + 'presentationInput' + ); + const presentationState = hasPresentationInput + ? evaluatePresentation( + options.presentationInput as TInput, + options.presentationContext + ) + : { visible: true }; + + return { + cancel: () => currentExecutionRef.current?.controller.abort(), + confirmation: definition.presentation?.confirmation, + disabledReason: presentationState.disabledReason, + evaluatePresentation, + execute, + mutation: { + data: mutation.data, + error: mutation.error, + failureCount: mutation.failureCount, + failureReason: mutation.failureReason, + isError: mutation.isError, + isIdle: mutation.isIdle, + isPaused: mutation.isPaused, + isPending: mutation.isPending, + isSuccess: mutation.isSuccess, + status: mutation.status, + submittedAt: mutation.submittedAt + }, + reset: mutation.reset, + visible: presentationState.visible + }; +} + +export { + createAppQueryKey, + createAppQueryRootKey, + createAppScopeFingerprint, + createAppScopeQueryKey +}; +export type { QueryClient }; diff --git a/apps/blocks/src/blocks/app-kit/core/schema-validation.ts b/apps/blocks/src/blocks/app-kit/core/schema-validation.ts new file mode 100644 index 0000000..6b35ce2 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/schema-validation.ts @@ -0,0 +1,1054 @@ +import type { + AppFieldDefinition, + AppFieldKind, + AppRelationCardinality, + AppResourceDefinition +} from './contracts'; + +export type AppMetaField = Readonly<{ + name?: string | null; + isNotNull?: boolean | null; + isPrimaryKey?: boolean | null; + enumValues?: + | readonly string[] + | Readonly<{ name?: string | null; values?: readonly string[] | null }> + | null; + type?: Readonly<{ + pgType?: string | null; + gqlType?: string | null; + isArray?: boolean | null; + isNotNull?: boolean | null; + }> | null; +}>; + +export type AppMetaRelation = Readonly<{ + fieldName?: string | null; + isUnique?: boolean | null; + type?: string | null; + references?: Readonly<{ name?: string | null }> | null; + referencedBy?: Readonly<{ name?: string | null }> | null; + rightTable?: Readonly<{ name?: string | null }> | null; +}>; + +export type AppMetaTable = Readonly<{ + name?: string | null; + schemaName?: string | null; + query?: Readonly<{ + all?: string | null; + one?: string | null; + create?: string | null; + update?: string | null; + delete?: string | null; + }> | null; + inflection?: Readonly<{ + tableType?: string | null; + connection?: string | null; + }> | null; + fields?: readonly (AppMetaField | null)[] | null; + constraints?: + | Readonly<{ + primaryKey?: Readonly<{ + fields?: readonly (AppMetaField | null)[] | null; + }> | null; + }> + | readonly (Readonly<{ + __typename?: string | null; + fields?: readonly (AppMetaField | null)[] | null; + }> | null)[] + | null; + primaryKeyConstraints?: readonly (Readonly<{ + fields?: readonly (AppMetaField | null)[] | null; + }> | null)[] | null; + relations?: Readonly<{ + belongsTo?: readonly (AppMetaRelation | null)[] | null; + has?: readonly (AppMetaRelation | null)[] | null; + hasOne?: readonly (AppMetaRelation | null)[] | null; + hasMany?: readonly (AppMetaRelation | null)[] | null; + manyToMany?: readonly (AppMetaRelation | null)[] | null; + }> | null; +}>; + +export type AppMetaQuery = Readonly<{ + _meta?: Readonly<{ + tables?: readonly (AppMetaTable | null)[] | null; + }> | null; +}>; + +export type AppIntrospectionTypeRef = Readonly<{ + name?: string | null; + kind?: string | null; + ofType?: AppIntrospectionTypeRef | null; +}>; + +export type AppIntrospectionField = Readonly<{ + name: string; + type?: AppIntrospectionTypeRef | null; +}>; + +export type AppIntrospectionType = Readonly<{ + name: string; + kind?: string | null; + fields?: readonly AppIntrospectionField[] | null; + enumValues?: readonly Readonly<{ name: string }>[] | null; +}>; + +export type AppGraphQLIntrospection = Readonly<{ + __schema?: Readonly<{ + queryType?: Readonly<{ name?: string | null }> | null; + mutationType?: Readonly<{ name?: string | null }> | null; + types?: readonly AppIntrospectionType[] | null; + }> | null; +}>; + +export type AppResourceValidationIssue = Readonly<{ + code: string; + message: string; + path: string; + severity: 'error' | 'warning'; +}>; + +export type AppValidatedField = Readonly<{ + key: string; + databaseName: string; + graphQLName: string; + editable: boolean; + reason?: string; +}>; + +export type AppResourceValidationResult = Readonly<{ + compatible: boolean; + capabilities: Readonly<{ + read: boolean; + create: boolean; + update: boolean; + delete: boolean; + }>; + issues: readonly AppResourceValidationIssue[]; + fields: readonly AppValidatedField[]; +}>; + +type AppMetaRelationKind = + | 'belongsTo' + | 'has' + | 'hasOne' + | 'hasMany' + | 'manyToMany'; + +type IndexedMetaRelation = Readonly<{ + kind: AppMetaRelationKind; + relation: AppMetaRelation; +}>; + +// GraphQL transports lossless PostgreSQL numerics as strings. Keeping them in +// the string family prevents generated inputs from coercing them through JS +// Number and losing precision. +const STRING_SCALARS = new Set([ + 'BigFloat', + 'BigInt', + 'Decimal', + 'ID', + 'String', + 'UUID' +]); +const INTEGER_SCALARS = new Set(['Int']); +const FLOAT_SCALARS = new Set(['Float']); +const BOOLEAN_SCALARS = new Set(['Boolean']); +const DATE_SCALARS = new Set(['Date']); +const DATETIME_SCALARS = new Set(['DateTime', 'Datetime']); +const JSON_SCALARS = new Set(['JSON', 'JSONB', 'JSONValue', 'Json']); +const KNOWN_SCALARS = new Set([ + ...STRING_SCALARS, + ...INTEGER_SCALARS, + ...FLOAT_SCALARS, + ...BOOLEAN_SCALARS, + ...DATE_SCALARS, + ...DATETIME_SCALARS, + ...JSON_SCALARS +]); + +function baseTypeName(type: AppIntrospectionTypeRef | null | undefined) { + let current = type; + while (current?.ofType) current = current.ofType; + return current?.name ?? null; +} + +function isNonNullType(type: AppIntrospectionTypeRef | null | undefined) { + return type?.kind === 'NON_NULL'; +} + +function listType(type: AppIntrospectionTypeRef | null | undefined) { + let current = type; + while (current?.kind === 'NON_NULL') current = current.ofType; + return current?.kind === 'LIST'; +} + +function listDepth(type: AppIntrospectionTypeRef | null | undefined) { + let current = type; + let depth = 0; + while (current) { + if (current.kind === 'NON_NULL') { + current = current.ofType; + continue; + } + if (current.kind !== 'LIST') break; + depth += 1; + current = current.ofType; + } + return depth; +} + +function listElementNullable( + type: AppIntrospectionTypeRef | null | undefined +): boolean | null { + let current = type; + while (current?.kind === 'NON_NULL') current = current.ofType; + if (current?.kind !== 'LIST') return null; + return current.ofType?.kind !== 'NON_NULL'; +} + +function namedTypeKind( + type: AppIntrospectionTypeRef | null | undefined, + types: ReadonlyMap +) { + let current = type; + while (current?.ofType) current = current.ofType; + if (current?.kind) return current.kind; + return current?.name ? types.get(current.name)?.kind ?? null : null; +} + +function fieldRequiresCustomRenderer(kind: AppFieldKind): boolean { + return kind === 'json' || kind === 'custom'; +} + +function scalarKind(kind: AppFieldKind): AppFieldKind { + if (kind === 'string-array') return 'string'; + if (kind === 'integer-array') return 'integer'; + if (kind === 'float-array') return 'float'; + if (kind === 'boolean-array') return 'boolean'; + if (kind === 'date-array') return 'date'; + if (kind === 'datetime-array') return 'datetime'; + if (kind === 'enum-array') return 'enum'; + return kind; +} + +function graphQLScalarSupportsKind(kind: AppFieldKind, scalar: string) { + switch (scalarKind(kind)) { + case 'string': + return STRING_SCALARS.has(scalar); + case 'integer': + return INTEGER_SCALARS.has(scalar); + case 'float': + return FLOAT_SCALARS.has(scalar); + case 'boolean': + return BOOLEAN_SCALARS.has(scalar); + case 'date': + return DATE_SCALARS.has(scalar); + case 'datetime': + return DATETIME_SCALARS.has(scalar); + case 'json': + return JSON_SCALARS.has(scalar); + case 'custom': + return !KNOWN_SCALARS.has(scalar); + case 'enum': + return false; + default: + return false; + } +} + +function normalizedPgType(value: string | null | undefined) { + return (value ?? '') + .replace(/\[\]$/u, '') + .replace(/Array$/u, '') + .split('.') + .at(-1) + ?.replace(/^"|"$/gu, '') + .toLowerCase() ?? ''; +} + +function normalizedIdentifier(value: string | null | undefined) { + return (value ?? '').replace(/[^a-z0-9]/giu, '').toLowerCase(); +} + +function pgTypeSupportsKind(kind: AppFieldKind, pgType: string) { + const scalar = scalarKind(kind); + if (scalar === 'custom' || scalar === 'enum') return true; + if (scalar === 'string') { + return /^(?:bigint|bigserial|bpchar|char|citext|decimal|inet|int8|name|numeric|serial8|text|uuid|varchar|character varying)$/u.test(pgType); + } + if (scalar === 'integer') { + return /^(?:int2|int4|integer|serial|serial2|serial4|smallint)$/u.test(pgType); + } + if (scalar === 'float') { + return /^(?:double precision|float4|float8|real)$/u.test(pgType); + } + if (scalar === 'boolean') return /^(?:bool|boolean)$/u.test(pgType); + if (scalar === 'date') return pgType === 'date'; + if (scalar === 'datetime') { + return /^(?:timestamp|timestamp with time zone|timestamp without time zone|timestamptz)$/u.test(pgType); + } + if (scalar === 'json') return /^(?:json|jsonb)$/u.test(pgType); + return true; +} + +function primaryKeyNames(table: AppMetaTable): Readonly<{ + ambiguous: boolean; + names: readonly string[]; +}> { + if (table.constraints && 'primaryKey' in table.constraints) { + const fields = table.constraints.primaryKey?.fields ?? []; + return { + ambiguous: false, + names: fields.map((field) => field?.name ?? '').filter(Boolean) + }; + } + if (table.constraints) { + const constraints = table.constraints as readonly (Readonly<{ + __typename?: string | null; + fields?: readonly (AppMetaField | null)[] | null; + }> | null)[]; + const keys = constraints.filter( + (constraint) => constraint?.__typename === 'MetaPrimaryKeyConstraint' + ); + return { + ambiguous: keys.length > 1, + names: + keys[0]?.fields?.map((field) => field?.name ?? '').filter(Boolean) ?? [] + }; + } + const legacy = table.primaryKeyConstraints?.filter(Boolean) ?? []; + if (legacy.length > 0) { + return { + ambiguous: legacy.length > 1, + names: + legacy[0]?.fields?.map((field) => field?.name ?? '').filter(Boolean) ?? [] + }; + } + return { + ambiguous: false, + names: + table.fields + ?.filter((field) => field?.isPrimaryKey) + .map((field) => field?.name ?? '') + .filter(Boolean) ?? [] + }; +} + +function indexedRelations(table: AppMetaTable | undefined): IndexedMetaRelation[] { + if (!table?.relations) return []; + const result: IndexedMetaRelation[] = []; + const seen = new Set(); + const kinds: readonly AppMetaRelationKind[] = [ + 'belongsTo', + 'has', + 'hasOne', + 'hasMany', + 'manyToMany' + ]; + for (const kind of kinds) { + for (const relation of table.relations[kind] ?? []) { + if (!relation) continue; + const key = `${relation.fieldName ?? ''}:${metaRelationTarget({ kind, relation }) ?? ''}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ kind, relation }); + } + } + return result; +} + +function metaRelationCardinality({ + kind, + relation +}: IndexedMetaRelation): AppRelationCardinality { + if (kind === 'belongsTo' || kind === 'hasOne') return 'one'; + if (kind === 'has') return relation.isUnique ? 'one' : 'many'; + return 'many'; +} + +function metaRelationTarget({ kind, relation }: IndexedMetaRelation) { + if (kind === 'belongsTo') return relation.references?.name ?? null; + if (kind === 'manyToMany') return relation.rightTable?.name ?? null; + return relation.referencedBy?.name ?? null; +} + +function connectionNodeTypeName( + type: AppIntrospectionType | undefined, + types: ReadonlyMap +) { + if (type?.kind !== 'OBJECT') return null; + const pageInfoField = type.fields?.find((field) => field.name === 'pageInfo'); + const pageInfoTypeName = baseTypeName(pageInfoField?.type); + const pageInfoType = pageInfoTypeName ? types.get(pageInfoTypeName) : undefined; + const pageInfoFields = new Set(pageInfoType?.fields?.map((field) => field.name)); + if ( + listType(pageInfoField?.type) || + pageInfoType?.kind !== 'OBJECT' || + !pageInfoFields.has('hasNextPage') || + !pageInfoFields.has('hasPreviousPage') + ) { + return null; + } + + const nodes = type.fields?.find((field) => field.name === 'nodes'); + if (nodes && listType(nodes.type)) return baseTypeName(nodes.type); + + const edges = type.fields?.find((field) => field.name === 'edges'); + if (!edges || !listType(edges.type)) return null; + const edgeTypeName = baseTypeName(edges.type); + const edgeType = edgeTypeName ? types.get(edgeTypeName) : undefined; + const node = edgeType?.fields?.find((field) => field.name === 'node'); + return node ? baseTypeName(node.type) : null; +} + +function isConnectionType( + type: AppIntrospectionType | undefined, + types: ReadonlyMap +) { + return connectionNodeTypeName(type, types) !== null; +} + +function relationTargetTypeName( + field: AppIntrospectionField, + types: ReadonlyMap +) { + const returnedName = baseTypeName(field.type); + if (listType(field.type)) return returnedName; + const returnedType = returnedName ? types.get(returnedName) : undefined; + return connectionNodeTypeName(returnedType, types) ?? returnedName; +} + +/** + * Reconciles an explicit resource definition with already-fetched `_meta` and + * final GraphQL introspection. It performs no network access and is intended + * for agent generation, CI, and build-time checks. + */ +export function validateAppResource< + TRecord extends Record, + TIdentity, + TListInput, + TListOutput, + TActions extends Readonly> +>( + resource: AppResourceDefinition< + TRecord, + TIdentity, + TListInput, + TListOutput, + TActions + >, + evidence: Readonly<{ + meta: AppMetaQuery; + introspection: AppGraphQLIntrospection; + }> +): AppResourceValidationResult { + const issues: AppResourceValidationIssue[] = []; + const addIssue = ( + code: string, + path: string, + message: string, + severity: 'error' | 'warning' = 'error' + ) => issues.push({ code, message, path, severity }); + + const tables = (evidence.meta._meta?.tables ?? []).filter( + (candidate): candidate is AppMetaTable => + Boolean(candidate) && + (candidate?.schemaName ?? 'public') === resource.source.schemaName + ); + // Current Constructive `_meta` exposes the inflected GraphQL table type as + // `name`. Older snapshots exposed the physical PostgreSQL table name. Prefer + // the current contract while retaining legacy evidence compatibility. + const currentTableMatches = tables.filter( + (candidate) => + candidate.name === resource.source.graphQLTypeName || + candidate.inflection?.tableType === resource.source.graphQLTypeName + ); + const legacyTableMatches = tables.filter( + (candidate) => candidate.name === resource.source.tableName + ); + const tableMatches = + currentTableMatches.length > 0 ? currentTableMatches : legacyTableMatches; + const table = tableMatches.length === 1 ? tableMatches[0] : undefined; + const metaTableName = table?.name ?? resource.source.graphQLTypeName; + + if (tableMatches.length > 1) { + addIssue( + 'META_TABLE_AMBIGUOUS', + `_meta.tables.${resource.source.schemaName}.${resource.source.graphQLTypeName}`, + `More than one _meta table matches ${resource.source.graphQLTypeName}; the database source cannot be reconciled safely.` + ); + } + + if (!table && tableMatches.length === 0) { + addIssue( + 'META_TABLE_MISSING', + `_meta.tables.${resource.source.schemaName}.${resource.source.graphQLTypeName}`, + `The resource ${resource.source.schemaName}.${resource.source.tableName} (${resource.source.graphQLTypeName}) is absent from _meta.` + ); + } + + if ( + table?.inflection?.tableType && + table.inflection.tableType !== resource.source.graphQLTypeName + ) { + addIssue( + 'META_TYPE_INFLECTION_MISMATCH', + `_meta.tables.${metaTableName}.inflection.tableType`, + `The resource uses ${resource.source.graphQLTypeName}, while the advisory _meta inflection is ${table.inflection.tableType}; the final executable schema is authoritative.`, + 'warning' + ); + } + if (table?.query?.all && table.query.all !== resource.source.listFieldName) { + addIssue( + 'META_LIST_INFLECTION_MISMATCH', + `_meta.tables.${metaTableName}.query.all`, + `The resource uses ${resource.source.listFieldName}, while the advisory _meta list root is ${table.query.all}; the final executable schema is authoritative.`, + 'warning' + ); + } + + const schema = evidence.introspection.__schema; + const types = new Map( + (schema?.types ?? []).map((type) => [type.name, type] as const) + ); + const recordType = types.get(resource.source.graphQLTypeName); + if (!recordType || recordType.kind !== 'OBJECT') { + addIssue( + 'GRAPHQL_TYPE_MISSING', + `__schema.types.${resource.source.graphQLTypeName}`, + `The final GraphQL schema does not expose ${resource.source.graphQLTypeName} as an object.` + ); + } + + const queryRootName = schema?.queryType?.name ?? ''; + const queryRoot = types.get(queryRootName); + if (!queryRoot || queryRoot.kind !== 'OBJECT') { + addIssue( + 'GRAPHQL_QUERY_ROOT_MISSING', + '__schema.queryType', + 'The final GraphQL query root is absent.' + ); + } + + const listField = queryRoot?.fields?.find( + (field) => field.name === resource.source.listFieldName + ); + if (!listField) { + addIssue( + 'GRAPHQL_LIST_FIELD_MISSING', + `__schema.types.${queryRootName}.fields.${resource.source.listFieldName}`, + `The configured list field ${resource.source.listFieldName} is absent from the final GraphQL schema.` + ); + } else { + const returnedName = baseTypeName(listField.type); + const returnsRecordList = + listType(listField.type) && returnedName === resource.source.graphQLTypeName; + const returnsConnection = isConnectionType( + returnedName ? types.get(returnedName) : undefined, + types + ); + const returnedRecordType = returnsConnection + ? relationTargetTypeName(listField, types) + : returnedName; + if ( + !returnsRecordList && + (!returnsConnection || returnedRecordType !== resource.source.graphQLTypeName) + ) { + addIssue( + 'GRAPHQL_LIST_TYPE_MISMATCH', + `__schema.types.${queryRootName}.fields.${resource.source.listFieldName}.type`, + `The list field resolves to ${returnedRecordType ?? returnedName ?? 'an unknown record type'} instead of ${resource.source.graphQLTypeName}.` + ); + } + } + + if (resource.source.detailFieldName) { + if ( + table?.query?.one && + table.query.one !== resource.source.detailFieldName + ) { + addIssue( + 'META_DETAIL_INFLECTION_MISMATCH', + `_meta.tables.${metaTableName}.query.one`, + `The resource uses ${resource.source.detailFieldName}, while the advisory _meta detail root is ${table.query.one}; the final executable schema is authoritative.`, + 'warning' + ); + } + const detailField = queryRoot?.fields?.find( + (field) => field.name === resource.source.detailFieldName + ); + if (!detailField) { + addIssue( + 'GRAPHQL_DETAIL_FIELD_MISSING', + `__schema.types.${queryRootName}.fields.${resource.source.detailFieldName}`, + `The configured detail field ${resource.source.detailFieldName} is absent from the final GraphQL schema.` + ); + } else if (baseTypeName(detailField.type) !== resource.source.graphQLTypeName) { + addIssue( + 'GRAPHQL_DETAIL_TYPE_MISMATCH', + `__schema.types.${queryRootName}.fields.${resource.source.detailFieldName}.type`, + `The detail field returns ${baseTypeName(detailField.type) ?? 'an unknown type'} instead of ${resource.source.graphQLTypeName}.` + ); + } + } + + const operationEntries = [ + ['create', resource.source.createMutationName], + ['update', resource.source.updateMutationName], + ['delete', resource.source.deleteMutationName] + ] as const; + const mutationRootName = schema?.mutationType?.name ?? ''; + const mutationRoot = types.get(mutationRootName); + if ( + operationEntries.some(([, mutationName]) => Boolean(mutationName)) && + (!mutationRoot || mutationRoot.kind !== 'OBJECT') + ) { + addIssue( + 'GRAPHQL_MUTATION_ROOT_MISSING', + '__schema.mutationType', + 'The final GraphQL mutation root is absent.' + ); + } + for (const [kind, mutationName] of operationEntries) { + if (!mutationName) continue; + if (table?.query && table.query[kind] !== mutationName) { + addIssue( + 'META_MUTATION_INFLECTION_MISMATCH', + `_meta.tables.${metaTableName}.query.${kind}`, + `The configured ${kind} mutation ${mutationName} differs from the advisory _meta operation ${table.query[kind] ?? ''}; the final executable schema is authoritative.`, + 'warning' + ); + } + if (!mutationRoot?.fields?.some((field) => field.name === mutationName)) { + addIssue( + 'GRAPHQL_MUTATION_FIELD_MISSING', + `__schema.types.${mutationRootName || ''}.fields.${mutationName}`, + `The configured mutation ${mutationName} is absent from the final GraphQL schema.` + ); + } + } + if (resource.forms?.create && !resource.source.createMutationName) { + addIssue( + 'FORM_OPERATION_MISSING', + 'forms.create', + 'The create form has no configured create mutation.' + ); + } + if (resource.forms?.update && !resource.source.updateMutationName) { + addIssue( + 'FORM_OPERATION_MISSING', + 'forms.update', + 'The update form has no configured update mutation.' + ); + } + + const metaFields = (table?.fields ?? []).filter( + (field): field is AppMetaField => Boolean(field?.name) + ); + const graphQLFields = new Map( + (recordType?.fields ?? []).map((field) => [field.name, field] as const) + ); + + const fields: AppValidatedField[] = resource.fields.map((field) => { + // Current `_meta` field names are GraphQL-facing; legacy evidence used + // physical PostgreSQL names. Prefer the final-facing name when both exist. + const metaField = + metaFields.find((candidate) => candidate.name === field.graphQLName) ?? + metaFields.find((candidate) => candidate.name === field.databaseName); + const graphQLField = graphQLFields.get(field.graphQLName); + if (!metaField) { + addIssue( + 'META_FIELD_MISSING', + `_meta.tables.${metaTableName}.fields.${field.graphQLName}`, + `Neither current _meta field ${field.graphQLName} nor legacy database field ${field.databaseName} is present.` + ); + } + if (!graphQLField) { + addIssue( + 'GRAPHQL_FIELD_MISSING', + `__schema.types.${resource.source.graphQLTypeName}.fields.${field.graphQLName}`, + `Final GraphQL field ${field.graphQLName} is absent from ${resource.source.graphQLTypeName}.` + ); + } + + const graphQLTypeName = baseTypeName(graphQLField?.type); + if ( + graphQLField && + metaField?.type?.gqlType && + graphQLTypeName !== metaField.type.gqlType + ) { + addIssue( + 'META_FIELD_GRAPHQL_TYPE_HINT_MISMATCH', + `__schema.types.${resource.source.graphQLTypeName}.fields.${field.graphQLName}.type`, + `Field ${field.graphQLName} resolves to ${graphQLTypeName ?? 'an unknown type'}, while the advisory _meta GraphQL type is ${metaField.type.gqlType}; the final executable schema is authoritative.`, + 'warning' + ); + } + + const definitionIsArray = field.kind.endsWith('-array'); + if (!definitionIsArray && field.arrayElementNullable !== undefined) { + addIssue( + 'RESOURCE_ARRAY_ELEMENT_NULLABILITY_INVALID', + `fields.${field.key}.arrayElementNullable`, + `Field ${field.key} is not an array, so it cannot declare array element nullability.` + ); + } + if ( + metaField?.type?.isArray !== undefined && + metaField.type.isArray !== null && + metaField.type.isArray !== definitionIsArray + ) { + addIssue( + 'META_FIELD_ARRAY_MISMATCH', + `fields.${field.key}.kind`, + `Field ${field.key} does not agree with the _meta array shape.` + ); + } + const graphQLListDepth = listDepth(graphQLField?.type); + const graphQLArrayShapeMatches = definitionIsArray + ? graphQLListDepth === 1 + : graphQLListDepth === 0; + if (graphQLField && !graphQLArrayShapeMatches) { + addIssue( + 'GRAPHQL_FIELD_ARRAY_MISMATCH', + `__schema.types.${resource.source.graphQLTypeName}.fields.${field.graphQLName}.type`, + `Field ${field.graphQLName} does not agree with the final GraphQL one-dimensional list shape.` + ); + } + const graphQLElementNullable = listElementNullable(graphQLField?.type); + const declaredElementNullable = field.arrayElementNullable ?? false; + if ( + definitionIsArray && + graphQLListDepth === 1 && + graphQLElementNullable !== null && + graphQLElementNullable !== declaredElementNullable + ) { + addIssue( + 'GRAPHQL_ARRAY_ELEMENT_NULLABILITY_MISMATCH', + `fields.${field.key}.arrayElementNullable`, + `Field ${field.graphQLName} ${graphQLElementNullable ? 'allows' : 'rejects'} null array elements in the final GraphQL schema, but the resource contract ${declaredElementNullable ? 'allows' : 'rejects'} them.` + ); + } + + const enumOptions = field.options ?? []; + const enumOptionValues = enumOptions.map((option) => option.value); + const enumOptionsMissing = + scalarKind(field.kind) === 'enum' && + field.readOnly !== true && + enumOptions.length === 0; + const enumOptionsDuplicate = + scalarKind(field.kind) === 'enum' && + field.readOnly !== true && + new Set(enumOptionValues).size !== enumOptionValues.length; + if (enumOptionsMissing) { + addIssue( + 'RESOURCE_ENUM_OPTIONS_MISSING', + `fields.${field.key}.options`, + `Enum field ${field.key} needs at least one declared option before it can be edited.` + ); + } + if (enumOptionsDuplicate) { + addIssue( + 'RESOURCE_ENUM_OPTIONS_DUPLICATE', + `fields.${field.key}.options`, + `Enum field ${field.key} has duplicate option values and cannot be reconciled safely.` + ); + } + + const pgType = normalizedPgType(metaField?.type?.pgType); + if (pgType && !pgTypeSupportsKind(field.kind, pgType)) { + addIssue( + 'META_FIELD_KIND_MISMATCH', + `fields.${field.key}.kind`, + `Field ${field.key} is configured as ${field.kind}, which is incompatible with PostgreSQL type ${metaField?.type?.pgType}.` + ); + } + + if (graphQLField && graphQLTypeName) { + const finalKind = namedTypeKind(graphQLField.type, types); + if (scalarKind(field.kind) === 'enum') { + if (finalKind !== 'ENUM') { + addIssue( + 'GRAPHQL_FIELD_KIND_MISMATCH', + `__schema.types.${resource.source.graphQLTypeName}.fields.${field.graphQLName}.type`, + `Enum field ${field.graphQLName} does not resolve to a final GraphQL enum.` + ); + } else { + const enumType = types.get(graphQLTypeName); + const enumValues = new Set( + enumType?.enumValues?.map((value) => value.name) + ); + for (const option of field.options ?? []) { + if (!enumValues.has(option.value)) { + addIssue( + 'GRAPHQL_ENUM_VALUE_MISSING', + `__schema.types.${graphQLTypeName}.enumValues.${option.value}`, + `Enum token ${option.value} is absent from the final GraphQL schema.` + ); + } + } + } + } else if ( + finalKind !== 'SCALAR' || + !graphQLScalarSupportsKind(field.kind, graphQLTypeName) + ) { + addIssue( + 'GRAPHQL_FIELD_KIND_MISMATCH', + `__schema.types.${resource.source.graphQLTypeName}.fields.${field.graphQLName}.type`, + `Field ${field.graphQLName} is configured as ${field.kind}, which is incompatible with final GraphQL ${finalKind ?? 'type'} ${graphQLTypeName}.` + ); + } + } + + const metaNotNull = metaField?.isNotNull ?? metaField?.type?.isNotNull; + const graphQLNotNull = isNonNullType(graphQLField?.type); + if ( + graphQLField && + metaNotNull !== undefined && + metaNotNull !== null && + metaNotNull !== graphQLNotNull + ) { + addIssue( + 'GRAPHQL_FIELD_NULLABILITY_MISMATCH', + `__schema.types.${resource.source.graphQLTypeName}.fields.${field.graphQLName}.type`, + `Field ${field.graphQLName} nullability disagrees with the database fact for ${field.databaseName}.` + ); + } + if ( + graphQLField && + field.nullable !== undefined && + field.nullable === graphQLNotNull + ) { + addIssue( + 'RESOURCE_FIELD_NULLABILITY_MISMATCH', + `fields.${field.key}.nullable`, + `Field ${field.key} nullability disagrees with the final GraphQL field ${field.graphQLName}.` + ); + } + + const customReadOnly = fieldRequiresCustomRenderer(field.kind); + const enumReadOnly = enumOptionsMissing || enumOptionsDuplicate; + return { + key: field.key, + databaseName: field.databaseName, + graphQLName: field.graphQLName, + editable: !field.readOnly && !customReadOnly && !enumReadOnly, + reason: customReadOnly + ? `${field.kind} fields require an explicit input renderer.` + : enumReadOnly + ? 'Enum fields require non-empty, unique declared options.' + : field.readOnly + ? 'The resource marks this field read-only.' + : undefined + }; + }); + + const metaRelations = indexedRelations(table); + for (const relation of resource.relations ?? []) { + const finalFieldName = relation.graphQLName ?? relation.fieldName; + const exactMetaRelation = metaRelations.find( + (candidate) => + candidate.relation.fieldName === finalFieldName || + candidate.relation.fieldName === relation.fieldName + ); + const targetMetaRelations = relation.targetTableName + ? metaRelations.filter( + (candidate) => + normalizedIdentifier(metaRelationTarget(candidate)) === + normalizedIdentifier(relation.targetTableName) + ) + : []; + const metaRelation = + exactMetaRelation ?? + (targetMetaRelations.length === 1 ? targetMetaRelations[0] : undefined); + if (!exactMetaRelation && targetMetaRelations.length > 1) { + addIssue( + 'META_RELATION_AMBIGUOUS', + `_meta.tables.${metaTableName}.relations.${finalFieldName}`, + `More than one _meta relation targets ${relation.targetTableName}; relation ${finalFieldName} cannot be reconciled safely.` + ); + } + if (!metaRelation) { + addIssue( + 'META_RELATION_MISSING', + `_meta.tables.${metaTableName}.relations.${finalFieldName}`, + `Relation ${finalFieldName} is absent from _meta relation facts.` + ); + } else { + if (metaRelation.relation.fieldName !== finalFieldName) { + addIssue( + 'META_RELATION_FIELD_HINT_MISMATCH', + `_meta.tables.${metaTableName}.relations.${metaRelation.relation.fieldName ?? ''}`, + `The advisory _meta relation field is ${metaRelation.relation.fieldName ?? ''}, while the final executable field is ${finalFieldName}.`, + 'warning' + ); + } + if (metaRelationCardinality(metaRelation) !== relation.cardinality) { + addIssue( + 'META_RELATION_CARDINALITY_MISMATCH', + `relations.${relation.id}.cardinality`, + `Relation ${finalFieldName} is ${metaRelationCardinality(metaRelation)} in _meta, not ${relation.cardinality}.` + ); + } + const targetTable = metaRelationTarget(metaRelation); + if ( + relation.targetTableName && + targetTable && + normalizedIdentifier(targetTable) !== + normalizedIdentifier(relation.targetTableName) + ) { + addIssue( + 'META_RELATION_TARGET_MISMATCH', + `relations.${relation.id}.targetTableName`, + `Relation ${finalFieldName} targets database table ${targetTable}, not ${relation.targetTableName}.` + ); + } + } + + const graphQLRelation = graphQLFields.get(finalFieldName); + if (!graphQLRelation) { + addIssue( + 'GRAPHQL_RELATION_MISSING', + `__schema.types.${resource.source.graphQLTypeName}.fields.${finalFieldName}`, + `Relation ${finalFieldName} is absent from the final GraphQL type.` + ); + continue; + } + const relationTypeName = baseTypeName(graphQLRelation.type); + const relationTargetType = relationTargetTypeName(graphQLRelation, types); + if ( + relation.targetGraphQLTypeName && + relationTargetType !== relation.targetGraphQLTypeName + ) { + addIssue( + 'GRAPHQL_RELATION_TYPE_MISMATCH', + `__schema.types.${resource.source.graphQLTypeName}.fields.${finalFieldName}.type`, + `Relation ${finalFieldName} targets ${relationTargetType ?? 'an unknown type'} instead of ${relation.targetGraphQLTypeName}.` + ); + } + const relationReturnsMany = + listType(graphQLRelation.type) || + isConnectionType( + relationTypeName ? types.get(relationTypeName) : undefined, + types + ); + if ( + (relation.cardinality === 'many' && !relationReturnsMany) || + (relation.cardinality === 'one' && relationReturnsMany) + ) { + addIssue( + 'GRAPHQL_RELATION_CARDINALITY_MISMATCH', + `__schema.types.${resource.source.graphQLTypeName}.fields.${finalFieldName}.type`, + `Relation ${finalFieldName} does not have the configured ${relation.cardinality} shape in the final GraphQL schema.` + ); + } + } + + const primaryKey = primaryKeyNames(table ?? {}); + if (!resource.identity) { + addIssue( + 'IDENTITY_MISSING', + 'identity', + 'The resource has no stable identity and will remain read-only.', + 'warning' + ); + } else { + const configuredIdentityFields = resource.identity.fields.map((key) => { + const field = resource.fields.find((candidate) => candidate.key === key); + return { + databaseName: field?.databaseName ?? key, + graphQLName: field?.graphQLName ?? key + }; + }); + const expected = [...primaryKey.names].sort(); + const configuredDatabaseNames = configuredIdentityFields + .map((field) => field.databaseName) + .sort(); + const configuredGraphQLNames = configuredIdentityFields + .map((field) => field.graphQLName) + .sort(); + const matchesDatabaseNames = + expected.length === configuredDatabaseNames.length && + expected.every( + (name, index) => name === configuredDatabaseNames[index] + ); + const matchesGraphQLNames = + expected.length === configuredGraphQLNames.length && + expected.every((name, index) => name === configuredGraphQLNames[index]); + if ( + primaryKey.ambiguous || + expected.length === 0 || + (!matchesDatabaseNames && !matchesGraphQLNames) + ) { + addIssue( + 'IDENTITY_PRIMARY_KEY_MISMATCH', + 'identity.fields', + 'The resource identity does not match one unambiguous _meta primary key, so writes are unsafe.' + ); + } + } + + const compatible = issues.every((issue) => issue.severity !== 'error'); + const identitySafe = Boolean(resource.identity) && !issues.some( + (issue) => + issue.severity === 'error' && + issue.code === 'IDENTITY_PRIMARY_KEY_MISMATCH' + ); + const sharedWriteBlockingCodes = new Set([ + 'GRAPHQL_FIELD_ARRAY_MISMATCH', + 'GRAPHQL_FIELD_KIND_MISMATCH', + 'GRAPHQL_FIELD_MISSING', + 'GRAPHQL_FIELD_NULLABILITY_MISMATCH', + 'GRAPHQL_TYPE_MISSING', + 'IDENTITY_PRIMARY_KEY_MISMATCH', + 'META_FIELD_ARRAY_MISMATCH', + 'META_FIELD_GRAPHQL_TYPE_HINT_MISMATCH', + 'META_FIELD_KIND_MISMATCH', + 'META_FIELD_MISSING', + 'META_TABLE_MISSING', + 'META_TYPE_INFLECTION_MISMATCH', + 'RESOURCE_FIELD_NULLABILITY_MISMATCH' + ]); + const sharedWriteSafe = identitySafe && !issues.some( + (issue) => + issue.severity === 'error' && sharedWriteBlockingCodes.has(issue.code) + ); + const operationAvailable = ( + kind: 'create' | 'update' | 'delete', + name: string | undefined + ) => + sharedWriteSafe && + Boolean(name) && + !issues.some( + (issue) => + issue.severity === 'error' && + issue.code === 'GRAPHQL_MUTATION_ROOT_MISSING' + ) && + !issues.some( + (issue) => + issue.severity === 'error' && + (issue.path === `forms.${kind}` || + issue.path.includes(`query.${kind}`) || + (name ? issue.path.includes(`fields.${name}`) : false)) + ); + const read = !issues.some( + (issue) => + issue.severity === 'error' && + ![ + 'FORM_OPERATION_MISSING', + 'GRAPHQL_MUTATION_FIELD_MISSING', + 'META_MUTATION_INFLECTION_MISMATCH' + ].includes(issue.code) + ); + return { + capabilities: { + create: operationAvailable('create', resource.source.createMutationName), + delete: operationAvailable('delete', resource.source.deleteMutationName), + read, + update: operationAvailable('update', resource.source.updateMutationName) + }, + compatible, + issues, + fields + }; +} diff --git a/apps/blocks/src/blocks/app-kit/core/scope-isolation.test.tsx b/apps/blocks/src/blocks/app-kit/core/scope-isolation.test.tsx new file mode 100644 index 0000000..99a1fbf --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/scope-isolation.test.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { describe, expect, it, vi } from 'vitest'; + +import { defineQuery, type AppScope } from './contracts'; +import { + AppKitProvider, + createAppQueryKey, + useAppQuery +} from './runtime'; + +const baseScope: AppScope = { + databaseId: 'database-a', + endpointId: 'endpoint-a', + organizationId: 'organization-a', + schemaRevision: 'schema-a', + securityRevision: 'security-a', + sessionPartition: 'session-a', + tenantId: 'tenant-a' +}; + +const scopeDimensions = [ + ['endpointId', 'endpoint-b'], + ['databaseId', 'database-b'], + ['sessionPartition', 'session-b'], + ['organizationId', 'organization-b'], + ['tenantId', 'tenant-b'], + ['schemaRevision', 'schema-b'], + ['securityRevision', 'security-b'] +] as const satisfies readonly (readonly [keyof AppScope, string])[]; + +function createClient() { + return new QueryClient({ + defaultOptions: { + queries: { + gcTime: Infinity, + retry: false, + staleTime: Infinity + } + } + }); +} + +describe('AppScope live query isolation', () => { + it.each(scopeDimensions)( + 'partitions live query data when %s changes', + async (dimension, nextValue) => { + const queryClient = createClient(); + const query = defineQuery, string>({ + id: 'records.by-scope', + execute: vi.fn(({ scope }) => String(scope[dimension] ?? '')) + }); + const input = { id: 'same-record' }; + + function Probe() { + const result = useAppQuery(query, input); + return {result.data ?? 'loading'}; + } + + const view = render( + + + + ); + const initialValue = String(baseScope[dimension] ?? ''); + await screen.findByText(initialValue); + + const nextScope = { ...baseScope, [dimension]: nextValue }; + view.rerender( + + + + ); + await screen.findByText(nextValue); + + expect(query.execute).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect( + queryClient.getQueryData( + createAppQueryKey(baseScope, query.id, input) + ) + ).toBe(initialValue); + expect( + queryClient.getQueryData( + createAppQueryKey(nextScope, query.id, input) + ) + ).toBe(nextValue); + }); + } + ); +}); diff --git a/apps/blocks/src/blocks/app-kit/core/scope.ts b/apps/blocks/src/blocks/app-kit/core/scope.ts new file mode 100644 index 0000000..9d8e31c --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/core/scope.ts @@ -0,0 +1,439 @@ +import type { AppScope, AppScopedQueryKey } from './contracts'; + +export const APP_QUERY_KEY_NAMESPACE = 'constructive-app-kit' as const; +export const APP_QUERY_KEY_VERSION = '2026-08' as const; + +export type AppScopeQueryKey = AppScopedQueryKey & + readonly [ + typeof APP_QUERY_KEY_NAMESPACE, + typeof APP_QUERY_KEY_VERSION, + endpointId: string, + databaseId: string, + sessionPartition: string, + organizationId: string, + tenantId: string, + schemaRevision: string, + securityRevision: string + ]; + +export type AppQueryRootKey = AppScopedQueryKey & + readonly [...AppScopeQueryKey, 'query', string]; + +export type AppQueryKey = AppScopedQueryKey & + readonly [...AppScopeQueryKey, 'query', string, 'input', string]; + +const CREDENTIAL_FIELD_NAMES = new Set([ + 'accesstoken', + 'accesskey', + 'apikey', + 'authentication', + 'authorization', + 'bearertoken', + 'clientsecret', + 'cookie', + 'credential', + 'credentials', + 'csrftoken', + 'idtoken', + 'password', + 'passwd', + 'privatekey', + 'proxyauthorization', + 'refreshtoken', + 'secret', + 'secretaccesskey', + 'sessiontoken', + 'signingkey', + 'setcookie', + 'token', + 'xapikey', + 'xauthtoken', + 'xcsrftoken' +]); + +const SHA_256_CONSTANTS = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +] as const; + +function normalizeFieldName(name: string): string { + return name.replace(/[^a-z0-9]/giu, '').toLocaleLowerCase('en-US'); +} + +function isCredentialFieldName(name: string): boolean { + return CREDENTIAL_FIELD_NAMES.has(normalizeFieldName(name)); +} + +function inputPath(parent: string, field: string): string { + return parent ? `${parent}.${field}` : field; +} + +function unsupportedInput(path: string, reason: string): never { + throw new Error(`App input at ${path || 'input'} is unsupported: ${reason}`); +} + +function dataPropertyValue( + input: object, + key: PropertyKey, + path: string +): unknown { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor) return unsupportedInput(path, 'the property descriptor is missing.'); + if (!descriptor.enumerable) { + return unsupportedInput(path, 'non-enumerable properties are not allowed.'); + } + if (!('value' in descriptor)) { + return unsupportedInput(path, 'accessor properties are not allowed.'); + } + return descriptor.value; +} + +function canonicalArrayValues(input: readonly unknown[], path: string): unknown[] { + const values = new Map(); + for (const key of Reflect.ownKeys(input)) { + if (key === 'length') continue; + if (typeof key === 'symbol') { + return unsupportedInput(path, 'symbol properties are not allowed.'); + } + const index = Number(key); + if ( + !Number.isSafeInteger(index) || + index < 0 || + String(index) !== key || + index >= input.length + ) { + return unsupportedInput( + inputPath(path, key), + 'arrays may contain only dense indexed entries.' + ); + } + values.set(index, dataPropertyValue(input, key, inputPath(path, key))); + } + if (values.size !== input.length) { + return unsupportedInput(path, 'sparse arrays are not allowed.'); + } + return Array.from({ length: input.length }, (_value, index) => + values.get(index) + ); +} + +function canonicalObjectEntries( + input: object, + path: string +): readonly Readonly<{ field: string; value: unknown }>[] { + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + return unsupportedInput( + path, + 'use a plain object, dense array, Date, or scalar value.' + ); + } + + const entries: Readonly<{ field: string; value: unknown }>[] = []; + for (const key of Reflect.ownKeys(input)) { + if (typeof key === 'symbol') { + return unsupportedInput(path, 'symbol properties are not allowed.'); + } + entries.push({ + field: key, + value: dataPropertyValue(input, key, inputPath(path, key)) + }); + } + return entries.sort((left, right) => + left.field < right.field ? -1 : left.field > right.field ? 1 : 0 + ); +} + +function assertUndecoratedDate(input: Date, path: string): void { + if (Reflect.ownKeys(input).length > 0) { + unsupportedInput(path, 'Date values may not have custom own properties.'); + } + if (Number.isNaN(input.getTime())) { + unsupportedInput(path, 'invalid Date values are not allowed.'); + } +} + +/** @internal Finds credential-shaped fields that must live in executor closures. */ +export function findAppCredentialInputPath( + input: unknown, + parentPath = '', + ancestors = new WeakSet() +): string | undefined { + if (typeof input === 'symbol' || typeof input === 'function') { + return unsupportedInput( + parentPath, + `${typeof input} values are not allowed.` + ); + } + if (!input || typeof input !== 'object') return undefined; + if (input instanceof Date) { + assertUndecoratedDate(input, parentPath); + return undefined; + } + if (ancestors.has(input)) { + return unsupportedInput(parentPath, 'circular references are not allowed.'); + } + + ancestors.add(input); + try { + if (Array.isArray(input)) { + const values = canonicalArrayValues(input, parentPath || 'input'); + for (let index = 0; index < values.length; index += 1) { + const found = findAppCredentialInputPath( + values[index], + `${parentPath}[${index}]`, + ancestors + ); + if (found) return found; + } + return undefined; + } + + for (const { field, value } of canonicalObjectEntries( + input, + parentPath || 'input' + )) { + const path = inputPath(parentPath, field); + if (isCredentialFieldName(field)) return path; + const found = findAppCredentialInputPath( + value, + path, + ancestors + ); + if (found) return found; + } + return undefined; + } finally { + ancestors.delete(input); + } +} + +function serializeQueryInput( + input: unknown, + path = 'input', + ancestors = new WeakSet() +): string { + if (input === null) return '["null"]'; + if (input === undefined) return '["undefined"]'; + if (typeof input === 'boolean') return `["boolean",${String(input)}]`; + if (typeof input === 'string') return `["string",${JSON.stringify(input)}]`; + if (typeof input === 'number') { + if (Number.isNaN(input)) return '["number","NaN"]'; + if (input === Number.POSITIVE_INFINITY) return '["number","Infinity"]'; + if (input === Number.NEGATIVE_INFINITY) return '["number","-Infinity"]'; + if (Object.is(input, -0)) return '["number","-0"]'; + return `["number",${String(input)}]`; + } + if (typeof input === 'bigint') { + return `["bigint",${JSON.stringify(input.toString())}]`; + } + if (typeof input === 'symbol' || typeof input === 'function') { + throw new Error( + `App query input at ${path} must be serializable; ${typeof input} values are unsupported.` + ); + } + if (!input || typeof input !== 'object') { + throw new Error(`App query input at ${path} could not be serialized.`); + } + if (input instanceof Date) { + assertUndecoratedDate(input, path); + return `["date",${JSON.stringify(input.toISOString())}]`; + } + if (ancestors.has(input)) { + throw new Error(`App query input at ${path} contains a circular reference.`); + } + + ancestors.add(input); + try { + if (Array.isArray(input)) { + return `["array",${canonicalArrayValues(input, path) + .map((value, index) => + serializeQueryInput(value, `${path}[${index}]`, ancestors) + ) + .join(',')}]`; + } + + const entries = canonicalObjectEntries(input, path).map(({ field, value }) => { + const serializedValue = isCredentialFieldName(field) + ? '["credential","redacted"]' + : serializeQueryInput(value, inputPath(path, field), ancestors); + return `[${JSON.stringify(field)},${serializedValue}]`; + }); + return `["object",${entries.join(',')}]`; + } finally { + ancestors.delete(input); + } +} + +function rotateRight(value: number, places: number): number { + return (value >>> places) | (value << (32 - places)); +} + +function sha256(value: string): string { + const message = new TextEncoder().encode(value); + const bitLength = message.length * 8; + const byteLength = Math.ceil((message.length + 9) / 64) * 64; + const padded = new Uint8Array(byteLength); + padded.set(message); + padded[message.length] = 0x80; + const paddedView = new DataView(padded.buffer); + paddedView.setUint32(byteLength - 8, Math.floor(bitLength / 0x100000000)); + paddedView.setUint32(byteLength - 4, bitLength >>> 0); + + const state = [ + 0x6a09e667, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19 + ]; + const words = new Uint32Array(64); + + for (let offset = 0; offset < byteLength; offset += 64) { + for (let index = 0; index < 16; index += 1) { + words[index] = paddedView.getUint32(offset + index * 4); + } + for (let index = 16; index < 64; index += 1) { + const previous = words[index - 15] ?? 0; + const earlier = words[index - 2] ?? 0; + const sigma0 = + rotateRight(previous, 7) ^ + rotateRight(previous, 18) ^ + (previous >>> 3); + const sigma1 = + rotateRight(earlier, 17) ^ + rotateRight(earlier, 19) ^ + (earlier >>> 10); + words[index] = + ((words[index - 16] ?? 0) + + sigma0 + + (words[index - 7] ?? 0) + + sigma1) >>> + 0; + } + + let [a, b, c, d, e, f, g, h] = state; + for (let index = 0; index < 64; index += 1) { + const upperSigma1 = + rotateRight(e ?? 0, 6) ^ + rotateRight(e ?? 0, 11) ^ + rotateRight(e ?? 0, 25); + const choose = ((e ?? 0) & (f ?? 0)) ^ (~(e ?? 0) & (g ?? 0)); + const temporary1 = + ((h ?? 0) + + upperSigma1 + + choose + + (SHA_256_CONSTANTS[index] ?? 0) + + (words[index] ?? 0)) >>> + 0; + const upperSigma0 = + rotateRight(a ?? 0, 2) ^ + rotateRight(a ?? 0, 13) ^ + rotateRight(a ?? 0, 22); + const majority = + ((a ?? 0) & (b ?? 0)) ^ + ((a ?? 0) & (c ?? 0)) ^ + ((b ?? 0) & (c ?? 0)); + const temporary2 = (upperSigma0 + majority) >>> 0; + + h = g; + g = f; + f = e; + e = ((d ?? 0) + temporary1) >>> 0; + d = c; + c = b; + b = a; + a = (temporary1 + temporary2) >>> 0; + } + + state[0] = ((state[0] ?? 0) + (a ?? 0)) >>> 0; + state[1] = ((state[1] ?? 0) + (b ?? 0)) >>> 0; + state[2] = ((state[2] ?? 0) + (c ?? 0)) >>> 0; + state[3] = ((state[3] ?? 0) + (d ?? 0)) >>> 0; + state[4] = ((state[4] ?? 0) + (e ?? 0)) >>> 0; + state[5] = ((state[5] ?? 0) + (f ?? 0)) >>> 0; + state[6] = ((state[6] ?? 0) + (g ?? 0)) >>> 0; + state[7] = ((state[7] ?? 0) + (h ?? 0)) >>> 0; + } + + return state.map((word) => word.toString(16).padStart(8, '0')).join(''); +} + +/** + * Returns an opaque deterministic fingerprint, so complete query inputs and + * any accidentally supplied credential value never enter TanStack cache keys. + */ +export function createAppQueryInputFingerprint(input: unknown): string { + return sha256(serializeQueryInput(input)); +} + +function assertScopePart(name: string, value: string): void { + if (value.trim().length === 0) { + throw new Error(`AppScope.${name} must be a non-empty stable identifier.`); + } +} + +export function createAppScopeQueryKey(scope: AppScope): AppScopeQueryKey { + assertScopePart('endpointId', scope.endpointId); + assertScopePart('databaseId', scope.databaseId); + assertScopePart('sessionPartition', scope.sessionPartition); + assertScopePart('schemaRevision', scope.schemaRevision); + assertScopePart('securityRevision', scope.securityRevision); + + return [ + APP_QUERY_KEY_NAMESPACE, + APP_QUERY_KEY_VERSION, + scope.endpointId, + scope.databaseId, + scope.sessionPartition, + scope.organizationId ?? '', + scope.tenantId ?? '', + scope.schemaRevision, + scope.securityRevision + ] as unknown as AppScopeQueryKey; +} + +/** Collision-safe fingerprint for memoization and local transient state only. */ +export function createAppScopeFingerprint(scope: AppScope): string { + return JSON.stringify(createAppScopeQueryKey(scope)); +} + +export function createAppQueryRootKey( + scope: AppScope, + queryId: string +): AppQueryRootKey { + return [ + ...createAppScopeQueryKey(scope), + 'query', + queryId + ] as unknown as AppQueryRootKey; +} + +export function createAppQueryKey( + scope: AppScope, + queryId: string, + input: TInput +): AppQueryKey { + return [ + ...createAppQueryRootKey(scope, queryId), + 'input', + createAppQueryInputFingerprint(input) + ] as unknown as AppQueryKey; +} diff --git a/apps/blocks/src/blocks/app-kit/dashboard/connected-dashboard.tsx b/apps/blocks/src/blocks/app-kit/dashboard/connected-dashboard.tsx new file mode 100644 index 0000000..2cb611d --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/connected-dashboard.tsx @@ -0,0 +1,139 @@ +'use client'; + +import * as React from 'react'; +import type { AppQueryDefinition } from '../core/contracts'; +import { useAppQuery } from '../core/runtime'; +import { + AppDashboard, + createAppDashboardWidgetCatalog, + type AppDashboardProps +} from './dashboard'; +import { + AppDashboardWidgetContent, + type AppDashboardRowsPayload, + type AppDashboardWidget, + type AppDashboardWidgetState, + type AppKpiWidgetDefinition, + type AppKpiWidgetPayload, + type AppRowsWidgetDefinition +} from './widgets'; + +export type ConnectedAppDashboardWidget = + | (AppKpiWidgetDefinition & Readonly<{ + query: AppQueryDefinition; + input: TInput; + }>) + | (AppRowsWidgetDefinition & Readonly<{ + query: AppQueryDefinition; + input: TInput; + }>); + +export interface ConnectedAppDashboardProps extends Omit< + AppDashboardProps, + 'catalog' | 'renderWidget' +> { + widgets: readonly ConnectedAppDashboardWidget[]; +} + +function stateFromResult(result: ReturnType< + typeof useAppQuery +>): AppDashboardWidgetState { + if (result.isPending) return { status: 'loading' }; + if ( + result.error?.appError.kind === 'authorization' || + result.error?.appError.kind === 'authentication' + ) { + return { status: 'denied', message: result.error.message }; + } + if (result.error) return { status: 'error', message: result.error.message }; + return { status: 'ready', value: result.data! }; +} + +function ConnectedKpiWidgetContent({ + widget +}: { + widget: AppKpiWidgetDefinition & Readonly<{ + query: AppQueryDefinition; + input: TInput; + }>; +}) { + const result = useAppQuery(widget.query, widget.input); + const { query: _query, input: _input, ...definition } = widget; + const resolved: AppDashboardWidget = { + ...definition, + state: stateFromResult(result) + }; + return ; +} + +function ConnectedRowsWidgetContent({ + widget +}: { + widget: AppRowsWidgetDefinition & Readonly<{ + query: AppQueryDefinition; + input: TInput; + }>; +}) { + const result = useAppQuery(widget.query, widget.input); + const { query: _query, input: _input, ...definition } = widget; + let state = stateFromResult(result); + if ( + state.status === 'ready' + && Array.isArray(state.value.rows) + && state.value.rows.length === 0 + ) { + state = { status: 'empty' }; + } + const resolved: AppDashboardWidget = { ...definition, state }; + return ; +} + +function ConnectedWidgetContent({ + widget +}: { + widget: ConnectedAppDashboardWidget; +}) { + return widget.kind === 'kpi' + ? + : ; +} + +function catalogWidget( + widget: ConnectedAppDashboardWidget +): AppDashboardWidget { + if (widget.kind === 'kpi') { + const { query: _query, input: _input, ...definition } = widget; + return { ...definition, state: { status: 'loading' } }; + } + const { query: _query, input: _input, ...definition } = widget; + return { ...definition, state: { status: 'loading' } }; +} + +/** + * Connects each catalog-owned presentation definition to an inert value/row + * query. Loader output is validated before any key reaches a renderer. + */ +export function ConnectedAppDashboard({ + widgets, + ...props +}: ConnectedAppDashboardProps) { + const catalog = React.useMemo( + () => createAppDashboardWidgetCatalog(widgets.map(catalogWidget)), + [widgets] + ); + const definitions = React.useMemo( + () => new Map(widgets.map((widget) => [widget.id, widget])), + [widgets] + ); + + return ( + { + const definition = definitions.get(widget.id); + return definition ? : null; + }} + /> + ); +} diff --git a/apps/blocks/src/blocks/app-kit/dashboard/dashboard.test.tsx b/apps/blocks/src/blocks/app-kit/dashboard/dashboard.test.tsx new file mode 100644 index 0000000..34c47c1 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/dashboard.test.tsx @@ -0,0 +1,233 @@ +import { QueryClient } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { defineQuery, type AppScope } from '../core/contracts'; +import { AppKitProvider } from '../core/runtime'; +import { ConnectedAppDashboard } from './connected-dashboard'; +import { + AppDashboard, + createAppDashboardWidgetCatalog +} from './dashboard'; +import { APP_DASHBOARD_LAYOUT_VERSION } from './layout-store'; +import type { AppDashboardRowsPayload } from './widgets'; + +beforeAll(() => { + if (!('ResizeObserver' in globalThis)) { + globalThis.ResizeObserver = class ResizeObserverStub { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + this.callback([{ + target, + contentRect: { + bottom: 240, + height: 240, + left: 0, + right: 640, + top: 0, + width: 640, + x: 0, + y: 0, + toJSON: () => ({}) + } + } as ResizeObserverEntry], this as unknown as ResizeObserver); + } + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } +}); + +const layout = { + version: APP_DASHBOARD_LAYOUT_VERSION, + placements: [ + { widgetId: 'attendance', order: 0, size: 'half' as const }, + { widgetId: 'trend', order: 1, size: 'half' as const }, + { widgetId: 'status-bars', order: 2, size: 'half' as const }, + { widgetId: 'breakdown', order: 3, size: 'half' as const }, + { widgetId: 'empty-widget', order: 4, size: 'half' as const }, + { widgetId: 'private', order: 5, size: 'full' as const } + ] +}; + +function catalog() { + return createAppDashboardWidgetCatalog([ + { + id: 'attendance', + kind: 'kpi', + title: 'Attendance', + formatValue: (value) => `${value} people`, + state: { status: 'ready', value: { value: 420 } } + }, + { + id: 'trend', + kind: 'line', + title: 'Registration trend', + xKey: 'day', + series: [{ key: 'registrations', label: 'Registrations', color: 'chart-1' }], + state: { status: 'ready', value: { rows: [{ day: 'Mon', registrations: 12 }] } } + }, + { + id: 'status-bars', + kind: 'bar', + title: 'Sessions by status', + xKey: 'status', + series: [{ key: 'sessions', label: 'Sessions', color: 'chart-2' }], + state: { status: 'ready', value: { rows: [{ status: 'Draft', sessions: 3 }] } } + }, + { + id: 'breakdown', + kind: 'breakdown', + title: 'Venue breakdown', + columns: [ + { key: 'venue', label: 'Venue' }, + { key: 'sessions', label: 'Sessions', align: 'right', format: (value) => `${value} total` } + ], + state: { status: 'ready', value: { rows: [{ venue: 'Hall A', sessions: 4 }] } } + }, + { + id: 'empty-widget', + kind: 'bar', + title: 'Empty report', + xKey: 'day', + series: [{ key: 'sessions', label: 'Sessions', color: 'chart-3' }], + state: { status: 'empty', message: 'No sessions match this range.' } + }, + { + id: 'private', + kind: 'kpi', + title: 'Private metric', + state: { status: 'denied', message: 'Manager access required.' } + } + ]); +} + +describe('AppDashboard', () => { + it('validates approved catalog identifiers, chart series, and semantic tokens', () => { + expect(() => createAppDashboardWidgetCatalog([ + { id: 'same', kind: 'kpi', title: 'One', state: { status: 'loading' } }, + { id: 'same', kind: 'kpi', title: 'Two', state: { status: 'loading' } } + ])).toThrow('Duplicate dashboard widget id'); + expect(() => createAppDashboardWidgetCatalog([{ + id: 'Invalid widget', + kind: 'kpi', + title: 'Invalid', + state: { status: 'loading' } + }])).toThrow('is invalid'); + expect(() => createAppDashboardWidgetCatalog([{ + id: 'invalid-series', + kind: 'line', + title: 'Invalid series', + xKey: 'day', + series: [{ key: 'registrations.total', label: 'Registrations', color: 'chart-1' }], + state: { status: 'loading' } + }])).toThrow('safe CSS and row identifier'); + expect(() => createAppDashboardWidgetCatalog([{ + id: 'invalid-color', + kind: 'bar', + title: 'Invalid color', + xKey: 'day', + series: [{ key: 'sessions', label: 'Sessions', color: 'red' as 'chart-1' }], + state: { status: 'loading' } + }])).toThrow('unsupported chart token'); + }); + + it('renders all four catalog-owned families, explicit empty/denied states, and accessible chart descendants', () => { + render(); + expect(screen.getByText('420 people')).toHaveClass('tabular-nums'); + expect(screen.getByText('420 people').closest('[data-slot="card"]')).toHaveClass('md:col-span-6'); + expect(screen.getByRole('figure', { name: 'Registration trend' })).toBeInTheDocument(); + expect(screen.getByRole('figure', { name: 'Sessions by status' })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Registration trend' })).toHaveAttribute('aria-roledescription', 'chart'); + expect(screen.getByRole('img', { name: 'Sessions by status' })).toHaveAttribute('tabindex', '0'); + expect(screen.getByRole('list', { name: 'Registration trend legend' })).toHaveTextContent('Registrations'); + expect(screen.getByText('4 total')).toBeInTheDocument(); + expect(screen.getByText('No sessions match this range.')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Manager access required.'); + }); + + it('rejects unexpected loader row keys instead of flowing them to chart configuration', () => { + const injected = createAppDashboardWidgetCatalog([{ + id: 'injected-chart', + kind: 'line', + title: 'Injected chart', + xKey: 'day', + series: [{ key: 'registrations', label: 'Registrations', color: 'chart-1' }], + state: { + status: 'ready', + value: { + rows: [{ day: 'Mon', registrations: 12, formatter: () => 'unsafe' }] + } + } + }]); + render( + + ); + expect(screen.getByRole('alert')).toHaveTextContent('unexpected key: formatter'); + expect(screen.queryByRole('figure', { name: 'Injected chart' })).not.toBeInTheDocument(); + }); + + it('validates inert rows returned by a connected query', async () => { + const query = defineQuery>, AppDashboardRowsPayload>({ + id: 'analytics.injected', + execute: () => ({ + rows: [{ day: 'Mon', registrations: 12 }], + series: [{ key: 'attacker-owned' }] + } as unknown as AppDashboardRowsPayload) + }); + const scope: AppScope = { + endpointId: 'graphql', + databaseId: 'events', + sessionPartition: 'user-1', + schemaRevision: 'schema-1', + securityRevision: 'security-1' + }; + render( + + + + ); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('unexpected key: series')); + }); + + it('changes only approved layout placements through keyboard-accessible controls', async () => { + const user = userEvent.setup(); + const onLayoutChange = vi.fn(); + render( + + ); + await user.click(screen.getByRole('button', { name: 'Customize Attendance' })); + await user.click(await screen.findByRole('menuitem', { name: 'Move later' })); + expect(onLayoutChange).toHaveBeenCalledWith(expect.objectContaining({ + version: 1, + placements: expect.arrayContaining([ + expect.objectContaining({ widgetId: 'attendance', order: 1 }), + expect.objectContaining({ widgetId: 'trend', order: 0 }) + ]) + })); + }); +}); diff --git a/apps/blocks/src/blocks/app-kit/dashboard/dashboard.tsx b/apps/blocks/src/blocks/app-kit/dashboard/dashboard.tsx new file mode 100644 index 0000000..d87c5ea --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/dashboard.tsx @@ -0,0 +1,357 @@ +'use client'; + +import * as React from 'react'; +import { + EyeIcon, + MoreHorizontalIcon, + MoveLeftIcon, + MoveRightIcon, + PanelTopCloseIcon, + ScalingIcon +} from 'lucide-react'; +import { Button } from '@constructive-io/ui/button'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@constructive-io/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@constructive-io/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; +import { + APP_DASHBOARD_LAYOUT_VERSION, + type AppDashboardLayout, + type AppDashboardPlacement, + type AppDashboardWidgetSize, + reconcileAppDashboardLayout +} from './layout-store'; +import { + APP_DASHBOARD_CHART_TOKENS, + AppDashboardWidgetContent, + isAppDashboardDataKey, + type AppDashboardWidget, + type AppDashboardWidgetDefinition +} from './widgets'; + +export interface AppDashboardWidgetCatalog { + readonly widgets: readonly AppDashboardWidget[]; + get(widgetId: string): AppDashboardWidget | undefined; +} + +const SAFE_WIDGET_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; + +function assertUniqueKeys(keys: readonly string[], context: string) { + if (new Set(keys).size !== keys.length) { + throw new Error(`${context} keys must be unique.`); + } +} + +function assertDataKey(key: string, context: string) { + if (!isAppDashboardDataKey(key)) { + throw new Error(`${context} key "${key}" must be a safe CSS and row identifier.`); + } +} + +function validateWidgetDefinition(widget: AppDashboardWidgetDefinition) { + if (!SAFE_WIDGET_ID.test(widget.id)) { + throw new Error(`Dashboard widget id "${widget.id}" is invalid.`); + } + if (!widget.title.trim()) { + throw new Error(`Dashboard widget "${widget.id}" needs a title.`); + } + if (widget.kind === 'kpi') { + if (widget.formatValue !== undefined && typeof widget.formatValue !== 'function') { + throw new Error(`Dashboard widget "${widget.id}" has an invalid value formatter.`); + } + if (widget.formatChange !== undefined && typeof widget.formatChange !== 'function') { + throw new Error(`Dashboard widget "${widget.id}" has an invalid change formatter.`); + } + return; + } + if (widget.kind === 'breakdown') { + if (widget.columns.length === 0) { + throw new Error(`Dashboard widget "${widget.id}" needs at least one breakdown column.`); + } + assertUniqueKeys(widget.columns.map((column) => column.key), `Dashboard widget "${widget.id}" column`); + for (const column of widget.columns) { + assertDataKey(column.key, `Dashboard widget "${widget.id}" column`); + if (!column.label.trim()) { + throw new Error(`Dashboard widget "${widget.id}" column "${column.key}" needs a label.`); + } + if (column.format !== undefined && typeof column.format !== 'function') { + throw new Error(`Dashboard widget "${widget.id}" column "${column.key}" has an invalid formatter.`); + } + } + return; + } + + assertDataKey(widget.xKey, `Dashboard widget "${widget.id}" x-axis`); + if (widget.series.length === 0) { + throw new Error(`Dashboard widget "${widget.id}" needs at least one chart series.`); + } + assertUniqueKeys(widget.series.map((series) => series.key), `Dashboard widget "${widget.id}" series`); + for (const series of widget.series) { + assertDataKey(series.key, `Dashboard widget "${widget.id}" series`); + if (!series.label.trim()) { + throw new Error(`Dashboard widget "${widget.id}" series "${series.key}" needs a label.`); + } + if (series.key === widget.xKey) { + throw new Error(`Dashboard widget "${widget.id}" cannot reuse its x-axis key as a series key.`); + } + if (!APP_DASHBOARD_CHART_TOKENS.includes(series.color)) { + throw new Error(`Dashboard widget "${widget.id}" series "${series.key}" uses an unsupported chart token.`); + } + } +} + +export function createAppDashboardWidgetCatalog( + widgets: readonly AppDashboardWidget[] +): AppDashboardWidgetCatalog { + const byId = new Map(); + for (const widget of widgets) { + validateWidgetDefinition(widget); + if (byId.has(widget.id)) { + throw new Error(`Duplicate dashboard widget id: ${widget.id}`); + } + byId.set(widget.id, widget); + } + + return Object.freeze({ + widgets: Object.freeze([...widgets]), + get(widgetId: string) { + return byId.get(widgetId); + } + }); +} + +export interface AppDashboardProps { + catalog: AppDashboardWidgetCatalog; + layout: AppDashboardLayout; + onLayoutChange?: (layout: AppDashboardLayout) => void; + renderWidget?: (widget: AppDashboardWidget) => React.ReactNode; + density?: 'compact' | 'comfortable'; + surface?: 'page' | 'card' | 'embedded'; + className?: string; +} + +const SIZE_ORDER: readonly AppDashboardWidgetSize[] = [ + 'third', + 'half', + 'wide', + 'full' +]; + +const SIZE_CLASS: Record = { + third: 'lg:col-span-4', + half: 'md:col-span-6', + wide: 'lg:col-span-8', + full: 'col-span-12' +}; + +function replacePlacement( + layout: AppDashboardLayout, + widgetId: string, + update: (placement: AppDashboardPlacement) => AppDashboardPlacement +): AppDashboardLayout { + return { + version: APP_DASHBOARD_LAYOUT_VERSION, + placements: layout.placements.map((placement) => ( + placement.widgetId === widgetId ? update(placement) : placement + )) + }; +} + +function reorderPlacement( + layout: AppDashboardLayout, + widgetId: string, + direction: -1 | 1 +): AppDashboardLayout { + const placements = [...layout.placements].sort((left, right) => left.order - right.order); + const index = placements.findIndex((placement) => placement.widgetId === widgetId); + const target = index + direction; + if (index < 0 || target < 0 || target >= placements.length) return layout; + [placements[index], placements[target]] = [placements[target]!, placements[index]!]; + return { + version: APP_DASHBOARD_LAYOUT_VERSION, + placements: placements.map((placement, order) => ({ ...placement, order })) + }; +} + +function AppDashboardControls({ + widget, + placement, + layout, + onLayoutChange +}: { + widget: AppDashboardWidget; + placement: AppDashboardPlacement; + layout: AppDashboardLayout; + onLayoutChange: (layout: AppDashboardLayout) => void; +}) { + const ordered = [...layout.placements].sort((left, right) => left.order - right.order); + const index = ordered.findIndex((item) => item.widgetId === widget.id); + const sizeIndex = SIZE_ORDER.indexOf(placement.size); + const nextSize = SIZE_ORDER[(sizeIndex + 1) % SIZE_ORDER.length]!; + + return ( + + + + + + + Arrange widget + onLayoutChange(reorderPlacement(layout, widget.id, -1))} + > + + Move earlier + + = ordered.length - 1} + onClick={() => onLayoutChange(reorderPlacement(layout, widget.id, 1))} + > + + Move later + + onLayoutChange(replacePlacement( + layout, + widget.id, + (current) => ({ ...current, size: nextSize }) + ))} + > + + Resize to {nextSize} + + + + + onLayoutChange(replacePlacement( + layout, + widget.id, + (current) => ({ ...current, hidden: true }) + ))} + > + + Hide widget + + + + + ); +} + +/** A controlled dashboard that renders only widgets from its approved catalog. */ +export function AppDashboard({ + catalog, + layout: layoutProp, + onLayoutChange, + renderWidget, + density = 'comfortable', + surface = 'page', + className +}: AppDashboardProps) { + const layout = React.useMemo( + () => reconcileAppDashboardLayout( + layoutProp, + catalog.widgets.map((widget) => widget.id), + Object.fromEntries(catalog.widgets.map((widget) => [widget.id, widget.defaultSize])) + ), + [catalog, layoutProp] + ); + const placements = [...layout.placements].sort((left, right) => left.order - right.order); + const hidden = placements.filter((placement) => placement.hidden); + + return ( +
    + {onLayoutChange && hidden.length > 0 ? ( +
    + + + + + + + Restore widget + {hidden.map((placement) => ( + onLayoutChange(replacePlacement( + layout, + placement.widgetId, + (current) => ({ ...current, hidden: false }) + ))} + > + + {catalog.get(placement.widgetId)?.title ?? placement.widgetId} + + ))} + + + +
    + ) : null} + +
    + {placements.map((placement) => { + if (placement.hidden) return null; + const widget = catalog.get(placement.widgetId); + if (!widget) return null; + + return ( + + + {widget.title} + {widget.description ? ( + {widget.description} + ) : null} + {onLayoutChange ? ( + + + + ) : null} + + + {renderWidget?.(widget) ?? ( + + )} + + + ); + })} +
    +
    + ); +} diff --git a/apps/blocks/src/blocks/app-kit/dashboard/index.ts b/apps/blocks/src/blocks/app-kit/dashboard/index.ts new file mode 100644 index 0000000..de73871 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/index.ts @@ -0,0 +1,5 @@ +export * from './connected-dashboard'; +export * from './dashboard'; +export * from './layout-store'; +export * from './persisted-dashboard'; +export * from './widgets'; diff --git a/apps/blocks/src/blocks/app-kit/dashboard/layout-store.test.ts b/apps/blocks/src/blocks/app-kit/dashboard/layout-store.test.ts new file mode 100644 index 0000000..b0b569e --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/layout-store.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + APP_DASHBOARD_LAYOUT_VERSION, + createConstructiveAppDashboardLayoutStore, + createLocalStorageAppDashboardLayoutStore, + parseAppDashboardLayout, + reconcileAppDashboardLayout +} from './layout-store'; + +const layout = { + version: APP_DASHBOARD_LAYOUT_VERSION, + placements: [{ widgetId: 'attendance', order: 0, size: 'half' as const }] +}; + +describe('dashboard layout stores', () => { + it('rejects unknown versions and duplicate widget placements', () => { + expect(parseAppDashboardLayout({ ...layout, version: 2 })).toBeNull(); + expect(parseAppDashboardLayout({ + version: 1, + placements: [layout.placements[0], layout.placements[0]] + })).toBeNull(); + }); + + it('reconciles approved widgets without reviving removed catalog entries', () => { + expect(reconcileAppDashboardLayout(layout, ['attendance', 'sessions'])).toEqual({ + version: 1, + placements: [ + { widgetId: 'attendance', order: 0, size: 'half' }, + { widgetId: 'sessions', order: 1, size: 'half' } + ] + }); + expect(reconcileAppDashboardLayout(layout, ['sessions']).placements) + .toEqual([{ widgetId: 'sessions', order: 0, size: 'half' }]); + }); + + it('round-trips local storage and adapts a Constructive persistence boundary', async () => { + const values = new Map(); + const local = createLocalStorageAppDashboardLayoutStore({ + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value) + }); + await local.save('event-studio', layout); + expect(await local.load('event-studio')).toEqual(layout); + + const loadLayout = vi.fn().mockResolvedValue(layout); + const saveLayout = vi.fn().mockResolvedValue(undefined); + const constructive = createConstructiveAppDashboardLayoutStore({ loadLayout, saveLayout }); + expect(await constructive.load('event-studio')).toEqual(layout); + await constructive.save('event-studio', layout); + expect(saveLayout).toHaveBeenCalledWith('event-studio', layout); + }); +}); diff --git a/apps/blocks/src/blocks/app-kit/dashboard/layout-store.ts b/apps/blocks/src/blocks/app-kit/dashboard/layout-store.ts new file mode 100644 index 0000000..4c89b71 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/layout-store.ts @@ -0,0 +1,123 @@ +export const APP_DASHBOARD_LAYOUT_VERSION = 1 as const; + +export type AppDashboardWidgetSize = 'third' | 'half' | 'wide' | 'full'; + +export interface AppDashboardPlacement { + widgetId: string; + order: number; + size: AppDashboardWidgetSize; + hidden?: boolean; +} + +export interface AppDashboardLayout { + version: typeof APP_DASHBOARD_LAYOUT_VERSION; + placements: readonly AppDashboardPlacement[]; +} + +export interface AppDashboardLayoutStore { + load(key: string): AppDashboardLayout | null | Promise; + save(key: string, layout: AppDashboardLayout): void | Promise; +} + +export interface ConstructiveAppDashboardLayoutAdapter { + loadLayout(key: string): AppDashboardLayout | null | Promise; + saveLayout(key: string, layout: AppDashboardLayout): void | Promise; +} + +function isPlacement(value: unknown): value is AppDashboardPlacement { + if (!value || typeof value !== 'object') return false; + const placement = value as Partial; + return typeof placement.widgetId === 'string' + && Number.isInteger(placement.order) + && ['third', 'half', 'wide', 'full'].includes(placement.size ?? '') + && (placement.hidden === undefined || typeof placement.hidden === 'boolean'); +} + +export function parseAppDashboardLayout(value: unknown): AppDashboardLayout | null { + if (!value || typeof value !== 'object') return null; + const layout = value as Partial; + if (layout.version !== APP_DASHBOARD_LAYOUT_VERSION) return null; + if (!Array.isArray(layout.placements) || !layout.placements.every(isPlacement)) return null; + + const widgetIds = layout.placements.map((placement) => placement.widgetId); + if (new Set(widgetIds).size !== widgetIds.length) return null; + return { + version: APP_DASHBOARD_LAYOUT_VERSION, + placements: layout.placements + }; +} + +export function createDefaultAppDashboardLayout( + widgetIds: readonly string[], + defaultSizes: Readonly>> = {} +): AppDashboardLayout { + return { + version: APP_DASHBOARD_LAYOUT_VERSION, + placements: widgetIds.map((widgetId, order) => ({ + widgetId, + order, + size: defaultSizes[widgetId] ?? 'half' + })) + }; +} + +export function reconcileAppDashboardLayout( + layout: AppDashboardLayout | null, + widgetIds: readonly string[], + defaultSizes: Readonly>> = {} +): AppDashboardLayout { + const allowed = new Set(widgetIds); + const existing = (layout?.placements ?? []) + .filter((placement) => allowed.has(placement.widgetId)) + .sort((left, right) => left.order - right.order); + const existingIds = new Set(existing.map((placement) => placement.widgetId)); + const additions = widgetIds + .filter((widgetId) => !existingIds.has(widgetId)) + .map((widgetId, index) => ({ + widgetId, + order: existing.length + index, + size: defaultSizes[widgetId] ?? 'half' + })); + + return { + version: APP_DASHBOARD_LAYOUT_VERSION, + placements: [...existing, ...additions].map((placement, order) => ({ + ...placement, + order + })) + }; +} + +export function createLocalStorageAppDashboardLayoutStore( + storage: Pick | undefined = + typeof window === 'undefined' ? undefined : window.localStorage +): AppDashboardLayoutStore { + return { + load(key) { + if (!storage) return null; + const serialized = storage.getItem(key); + if (!serialized) return null; + try { + return parseAppDashboardLayout(JSON.parse(serialized)); + } catch { + return null; + } + }, + save(key, layout) { + storage?.setItem(key, JSON.stringify(layout)); + } + }; +} + +/** + * Adapts a generated Constructive SDK or server-action persistence boundary + * without coupling App Kit to a particular GraphQL operation or table. + */ +export function createConstructiveAppDashboardLayoutStore( + adapter: ConstructiveAppDashboardLayoutAdapter +): AppDashboardLayoutStore { + return { + load: (key) => adapter.loadLayout(key), + save: (key, layout) => adapter.saveLayout(key, layout) + }; +} diff --git a/apps/blocks/src/blocks/app-kit/dashboard/persisted-dashboard.test.tsx b/apps/blocks/src/blocks/app-kit/dashboard/persisted-dashboard.test.tsx new file mode 100644 index 0000000..18e6e52 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/persisted-dashboard.test.tsx @@ -0,0 +1,70 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { createAppDashboardWidgetCatalog } from './dashboard'; +import { + APP_DASHBOARD_LAYOUT_VERSION, + type AppDashboardLayout +} from './layout-store'; +import { PersistedAppDashboard } from './persisted-dashboard'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +describe('PersistedAppDashboard', () => { + it('serializes rapid saves so an older layout cannot finish last', async () => { + const user = userEvent.setup(); + const firstSave = deferred(); + const secondSave = deferred(); + const saveGates = [firstSave, secondSave]; + let saveIndex = 0; + const initialLayout: AppDashboardLayout = { + version: APP_DASHBOARD_LAYOUT_VERSION, + placements: [{ widgetId: 'attendance', order: 0, size: 'half' }] + }; + const save = vi.fn((_key: string, _layout: AppDashboardLayout) => + saveGates[saveIndex++]!.promise + ); + const catalog = createAppDashboardWidgetCatalog([{ + id: 'attendance', + kind: 'kpi', + title: 'Attendance', + state: { status: 'ready', value: { value: 42 } } + }]); + + render( + initialLayout, save }} + /> + ); + + await screen.findByRole('region', { name: 'Dashboard' }); + await user.click(screen.getByRole('button', { name: 'Customize Attendance' })); + await user.click(await screen.findByRole('menuitem', { name: 'Resize to wide' })); + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole('button', { name: 'Customize Attendance' })); + await user.click(await screen.findByRole('menuitem', { name: 'Resize to full' })); + await act(async () => Promise.resolve()); + expect(save).toHaveBeenCalledTimes(1); + + await act(async () => firstSave.resolve()); + await waitFor(() => expect(save).toHaveBeenCalledTimes(2)); + expect(save.mock.calls.map(([key, saved]) => ({ + key, + size: saved.placements[0]?.size + }))).toEqual([ + { key: 'event-studio', size: 'wide' }, + { key: 'event-studio', size: 'full' } + ]); + await act(async () => secondSave.resolve()); + }); +}); diff --git a/apps/blocks/src/blocks/app-kit/dashboard/persisted-dashboard.tsx b/apps/blocks/src/blocks/app-kit/dashboard/persisted-dashboard.tsx new file mode 100644 index 0000000..1c241ed --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/persisted-dashboard.tsx @@ -0,0 +1,99 @@ +'use client'; + +import * as React from 'react'; +import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; +import { Skeleton } from '@constructive-io/ui/skeleton'; +import { AppDashboard, type AppDashboardProps } from './dashboard'; +import { + createDefaultAppDashboardLayout, + reconcileAppDashboardLayout, + type AppDashboardLayout, + type AppDashboardLayoutStore +} from './layout-store'; + +export interface PersistedAppDashboardProps extends Omit< + AppDashboardProps, + 'layout' | 'onLayoutChange' +> { + layoutKey: string; + layoutStore: AppDashboardLayoutStore; + onLayoutError?: (error: Error) => void; +} + +export function PersistedAppDashboard({ + catalog, + layoutKey, + layoutStore, + onLayoutError, + ...props +}: PersistedAppDashboardProps) { + const [layout, setLayout] = React.useState(null); + const [layoutError, setLayoutError] = React.useState(null); + const saveQueueRef = React.useRef>(Promise.resolve()); + const widgetIds = React.useMemo( + () => catalog.widgets.map((widget) => widget.id), + [catalog] + ); + const defaultSizes = React.useMemo( + () => Object.fromEntries(catalog.widgets.map((widget) => [widget.id, widget.defaultSize])), + [catalog] + ); + + React.useEffect(() => { + let active = true; + Promise.resolve().then(() => layoutStore.load(layoutKey)).then((stored) => { + if (!active) return; + setLayout(reconcileAppDashboardLayout( + stored ?? createDefaultAppDashboardLayout(widgetIds, defaultSizes), + widgetIds, + defaultSizes + )); + }).catch((error: unknown) => { + if (!active) return; + const normalized = error instanceof Error ? error : new Error('The dashboard layout could not be loaded.'); + setLayoutError(normalized.message); + onLayoutError?.(normalized); + setLayout(createDefaultAppDashboardLayout(widgetIds, defaultSizes)); + }); + return () => { + active = false; + }; + }, [defaultSizes, layoutKey, layoutStore, onLayoutError, widgetIds]); + + const updateLayout = React.useCallback((next: AppDashboardLayout) => { + setLayout(next); + saveQueueRef.current = saveQueueRef.current + .then(() => layoutStore.save(layoutKey, next)) + .catch((error: unknown) => { + const normalized = error instanceof Error ? error : new Error('The dashboard layout could not be saved.'); + setLayoutError(normalized.message); + onLayoutError?.(normalized); + }); + }, [layoutKey, layoutStore, onLayoutError]); + + if (!layout) { + return ( +
    + + +
    + ); + } + + return ( +
    + {layoutError ? ( + + Layout persistence failed + {layoutError} + + ) : null} + +
    + ); +} diff --git a/apps/blocks/src/blocks/app-kit/dashboard/widgets.tsx b/apps/blocks/src/blocks/app-kit/dashboard/widgets.tsx new file mode 100644 index 0000000..b5d0a5e --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/dashboard/widgets.tsx @@ -0,0 +1,609 @@ +'use client'; + +import * as React from 'react'; +import { + barY, + defineChart, + group, + lineY +} from '@tanstack/charts'; +import { tooltip } from '@tanstack/charts/tooltip'; +import { scaleBand } from '@tanstack/charts-scales/band'; +import { scaleLinear } from '@tanstack/charts-scales/linear'; +import { Chart } from '@tanstack/react-charts'; +import { ChartNoAxesCombinedIcon } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; +import { Badge } from '@constructive-io/ui/badge'; +import { CONSTRUCTIVE_CHART_THEME } from '@constructive-io/ui/chart'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from '@constructive-io/ui/empty'; +import { Skeleton } from '@constructive-io/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@constructive-io/ui/table'; +import type { AppDashboardWidgetSize } from './layout-store'; + +export const APP_DASHBOARD_CHART_TOKENS = [ + 'chart-1', + 'chart-2', + 'chart-3', + 'chart-4', + 'chart-5' +] as const; + +export type AppDashboardChartToken = typeof APP_DASHBOARD_CHART_TOKENS[number]; +export type AppDashboardCellValue = string | number | boolean | null; +export type AppDashboardRow = Readonly>; + +/** Inert values returned by a KPI query. Formatting stays in the catalog. */ +export interface AppKpiWidgetPayload { + value: number | string; + change?: number | string; + changeLabel?: string; +} + +/** Inert rows returned by chart and breakdown queries. */ +export interface AppDashboardRowsPayload { + rows: readonly Readonly>[]; +} + +export type AppDashboardWidgetPayload = AppKpiWidgetPayload | AppDashboardRowsPayload; + +interface AppDashboardWidgetDefinitionBase { + id: string; + title: string; + description?: string; + defaultSize?: AppDashboardWidgetSize; + emptyMessage?: string; +} + +export interface AppKpiWidgetDefinition extends AppDashboardWidgetDefinitionBase { + kind: 'kpi'; + formatValue?: (value: number | string) => React.ReactNode; + formatChange?: (change: number | string) => React.ReactNode; +} + +export interface AppChartSeriesDefinition { + /** A safe CSS identifier and the exact loader-row key. */ + key: string; + label: string; + color: AppDashboardChartToken; +} + +interface AppChartWidgetDefinitionBase extends AppDashboardWidgetDefinitionBase { + xKey: string; + series: readonly AppChartSeriesDefinition[]; +} + +export interface AppBarWidgetDefinition extends AppChartWidgetDefinitionBase { + kind: 'bar'; +} + +export interface AppLineWidgetDefinition extends AppChartWidgetDefinitionBase { + kind: 'line'; +} + +export interface AppBreakdownColumnDefinition { + key: string; + label: string; + align?: 'left' | 'right'; + format?: (value: AppDashboardCellValue, row: AppDashboardRow) => React.ReactNode; +} + +export interface AppBreakdownWidgetDefinition extends AppDashboardWidgetDefinitionBase { + kind: 'breakdown'; + columns: readonly AppBreakdownColumnDefinition[]; + getRowId?: (row: AppDashboardRow, index: number) => string; +} + +export type AppRowsWidgetDefinition = + | AppBarWidgetDefinition + | AppLineWidgetDefinition + | AppBreakdownWidgetDefinition; + +export type AppDashboardWidgetDefinition = + | AppKpiWidgetDefinition + | AppRowsWidgetDefinition; + +export type AppDashboardWidgetState = + | { status: 'loading' } + | { status: 'empty'; message?: string } + | { status: 'denied'; message?: string } + | { status: 'error'; message: string } + | { status: 'ready'; value: TPayload }; + +export type AppDashboardWidget = + | (AppKpiWidgetDefinition & { state: AppDashboardWidgetState }) + | (AppRowsWidgetDefinition & { state: AppDashboardWidgetState }); + +export type AppDashboardPayloadValidation = + | Readonly<{ status: 'ready'; value: TValue }> + | Readonly<{ status: 'empty' }> + | Readonly<{ status: 'error'; message: string }>; + +const SAFE_DATA_KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/u; +const RESERVED_DATA_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +export function isAppDashboardDataKey(value: string) { + return SAFE_DATA_KEY.test(value) && !RESERVED_DATA_KEYS.has(value); +} + +function isRecord(value: unknown): value is Readonly> { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function ownKeysExactly( + value: Readonly>, + allowed: ReadonlySet, + context: string +): string | null { + const unexpected = Object.keys(value).filter((key) => !allowed.has(key)); + return unexpected.length > 0 + ? `${context} returned unexpected ${unexpected.length === 1 ? 'key' : 'keys'}: ${unexpected.join(', ')}.` + : null; +} + +function finiteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function validateKpiPayload( + payload: unknown +): AppDashboardPayloadValidation { + if (!isRecord(payload)) { + return { status: 'error', message: 'The KPI query must return an object with a value.' }; + } + const unexpected = ownKeysExactly( + payload, + new Set(['value', 'change', 'changeLabel']), + 'The KPI query' + ); + if (unexpected) return { status: 'error', message: unexpected }; + if (typeof payload.value !== 'string' && !finiteNumber(payload.value)) { + return { status: 'error', message: 'The KPI value must be a string or finite number.' }; + } + if ( + payload.change !== undefined + && typeof payload.change !== 'string' + && !finiteNumber(payload.change) + ) { + return { status: 'error', message: 'The KPI change must be a string or finite number.' }; + } + if (payload.changeLabel !== undefined && typeof payload.changeLabel !== 'string') { + return { status: 'error', message: 'The KPI change label must be a string.' }; + } + const change = payload.change; + const changeLabel = payload.changeLabel; + return { + status: 'ready', + value: { + value: payload.value, + ...(change !== undefined ? { change } : {}), + ...(changeLabel !== undefined ? { changeLabel } : {}) + } + }; +} + +type AppDashboardCellValidation = + | Readonly<{ ok: true; value: AppDashboardCellValue }> + | Readonly<{ ok: false; message: string }>; + +function validateRowValue( + definition: AppRowsWidgetDefinition, + key: string, + value: unknown, + rowIndex: number +): AppDashboardCellValidation { + if (definition.kind === 'breakdown') { + if ( + value === null + || typeof value === 'string' + || typeof value === 'boolean' + || finiteNumber(value) + ) return { ok: true, value }; + return { + ok: false, + message: `Row ${rowIndex + 1} key "${key}" must be a scalar value.` + }; + } + + if (key === definition.xKey) { + if (typeof value === 'string' || finiteNumber(value)) { + return { ok: true, value }; + } + return { + ok: false, + message: `Row ${rowIndex + 1} x-axis key "${key}" must be a string or finite number.` + }; + } + if (value === null || finiteNumber(value)) return { ok: true, value }; + return { + ok: false, + message: `Row ${rowIndex + 1} series key "${key}" must be a finite number or null.` + }; +} + +function validateRowsPayload( + definition: AppRowsWidgetDefinition, + payload: unknown +): AppDashboardPayloadValidation { + if (!isRecord(payload)) { + return { status: 'error', message: 'The widget query must return an object with rows.' }; + } + const unexpectedPayloadKey = ownKeysExactly(payload, new Set(['rows']), 'The widget query'); + if (unexpectedPayloadKey) return { status: 'error', message: unexpectedPayloadKey }; + if (!Array.isArray(payload.rows)) { + return { status: 'error', message: 'The widget query rows must be an array.' }; + } + if (payload.rows.length === 0) return { status: 'empty' }; + + const keys = definition.kind === 'breakdown' + ? definition.columns.map((column) => column.key) + : [definition.xKey, ...definition.series.map((series) => series.key)]; + const allowed = new Set(keys); + const sanitized: AppDashboardRow[] = []; + for (const [rowIndex, candidate] of payload.rows.entries()) { + if (!isRecord(candidate)) { + return { status: 'error', message: `Row ${rowIndex + 1} must be an object.` }; + } + const unexpected = ownKeysExactly(candidate, allowed, `Row ${rowIndex + 1}`); + if (unexpected) return { status: 'error', message: unexpected }; + const missing = keys.filter((key) => !Object.hasOwn(candidate, key)); + if (missing.length > 0) { + return { + status: 'error', + message: `Row ${rowIndex + 1} is missing ${missing.length === 1 ? 'key' : 'keys'}: ${missing.join(', ')}.` + }; + } + + const row: Record = {}; + for (const key of keys) { + const validation = validateRowValue(definition, key, candidate[key], rowIndex); + if (!validation.ok) { + return { status: 'error', message: validation.message }; + } + row[key] = validation.value; + } + sanitized.push(Object.freeze(row)); + } + return { status: 'ready', value: Object.freeze(sanitized) }; +} + +export function validateAppDashboardWidgetPayload( + definition: AppKpiWidgetDefinition, + payload: unknown +): AppDashboardPayloadValidation; +export function validateAppDashboardWidgetPayload( + definition: AppRowsWidgetDefinition, + payload: unknown +): AppDashboardPayloadValidation; +export function validateAppDashboardWidgetPayload( + definition: AppDashboardWidgetDefinition, + payload: unknown +): AppDashboardPayloadValidation { + return definition.kind === 'kpi' + ? validateKpiPayload(payload) + : validateRowsPayload(definition, payload); +} + +function WidgetLoading() { + return ( +
    + + +
    + ); +} + +function WidgetEmpty({ message }: { message: string }) { + return ( + + + + No data + {message} + + + ); +} + +function WidgetUnavailable({ title, message }: { title: string; message: string }) { + return ( + + {title} + {message} + + ); +} + +export function AppKpiWidget({ + definition, + data +}: { + definition: AppKpiWidgetDefinition; + data: AppKpiWidgetPayload; +}) { + const value = definition.formatValue?.(data.value) ?? String(data.value); + return ( +
    +

    {value}

    + {data.change !== undefined ? ( +
    + + {definition.formatChange?.(data.change) ?? String(data.change)} + + {data.changeLabel ? ( + + {data.changeLabel} + + ) : null} +
    + ) : null} +
    + ); +} + +interface AppChartDatum { + category: string; + key: string; + seriesKey: string; + value: number | null; +} + +function appChartData( + definition: AppBarWidgetDefinition | AppLineWidgetDefinition, + rows: readonly AppDashboardRow[] +): readonly AppChartDatum[] { + return rows.flatMap((row, rowIndex) => definition.series.map((series) => ({ + category: String(row[definition.xKey]), + key: `${rowIndex}:${series.key}`, + seriesKey: series.key, + value: row[series.key] as number | null + }))); +} + +function chartTooltip( + definition: AppBarWidgetDefinition | AppLineWidgetDefinition +) { + const labels = new Map(definition.series.map((series) => [series.key, series.label])); + return { + use: tooltip, + items: [ + { channel: 'x' as const, label: 'Category' }, + { + channel: 'group' as const, + label: 'Series', + text: (point: { group: string | number | null; groupLabel: string }) => ( + point.group === null ? point.groupLabel : (labels.get(String(point.group)) ?? point.groupLabel) + ) + }, + { channel: 'y' as const, label: 'Value' } + ], + sort: 'color-domain' as const + }; +} + +function AppChartLegend({ + definition +}: { + definition: AppBarWidgetDefinition | AppLineWidgetDefinition; +}) { + return ( +
      + {definition.series.map((series) => ( +
    • +
    • + ))} +
    + ); +} + +export function AppBarWidget({ + definition, + rows +}: { + definition: AppBarWidgetDefinition; + rows: readonly AppDashboardRow[]; +}) { + const labelId = React.useId(); + const chartRows = React.useMemo(() => appChartData(definition, rows), [definition, rows]); + const chartDefinition = React.useMemo(() => defineChart({ + marks: [ + barY(chartRows, { + x: 'category', + y: 'value', + color: 'seriesKey', + key: 'key', + layout: group({ + scale: scaleBand() + .domain(definition.series.map((series) => series.key)) + .paddingInner(0.12) + }), + inset: 1, + radius: 4 + }) + ], + x: { + scale: () => scaleBand().paddingInner(0.16).paddingOuter(0.08), + axis: { tickLabels: { thin: { minGap: 8, priority: 'ends' } } } + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { ticks: { count: 5 } } + }, + color: { + domain: definition.series.map((series) => series.key), + range: definition.series.map((series) => `var(--${series.color})`) + }, + theme: CONSTRUCTIVE_CHART_THEME, + focus: 'group-x', + tooltip: chartTooltip(definition) + }), [chartRows, definition]); + return ( +
    +
    {definition.title}
    + + +
    + ); +} + +export function AppLineWidget({ + definition, + rows +}: { + definition: AppLineWidgetDefinition; + rows: readonly AppDashboardRow[]; +}) { + const labelId = React.useId(); + const chartRows = React.useMemo(() => appChartData(definition, rows), [definition, rows]); + const chartDefinition = React.useMemo(() => defineChart({ + marks: [ + lineY(chartRows, { + x: 'category', + y: 'value', + z: 'seriesKey', + color: 'seriesKey', + key: 'key', + strokeWidth: 2 + }) + ], + x: { + scale: () => scaleBand().padding(0.08), + axis: { tickLabels: { thin: { minGap: 8, priority: 'ends' } } } + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { ticks: { count: 5 } } + }, + color: { + domain: definition.series.map((series) => series.key), + range: definition.series.map((series) => `var(--${series.color})`) + }, + theme: CONSTRUCTIVE_CHART_THEME, + focus: 'group-x', + tooltip: chartTooltip(definition) + }), [chartRows, definition]); + return ( +
    +
    {definition.title}
    + + +
    + ); +} + +export function AppBreakdownWidget({ + definition, + rows +}: { + definition: AppBreakdownWidgetDefinition; + rows: readonly AppDashboardRow[]; +}) { + return ( + + + + {definition.columns.map((column) => ( + + {column.label} + + ))} + + + + {rows.map((row, index) => ( + + {definition.columns.map((column) => { + const value = row[column.key] ?? null; + return ( + + {column.format?.(value, row) ?? String(value ?? '—')} + + ); + })} + + ))} + +
    + ); +} + +export function AppDashboardWidgetContent({ widget }: { widget: AppDashboardWidget }) { + if (widget.state.status === 'loading') return ; + if (widget.state.status === 'empty') { + return ; + } + if (widget.state.status === 'denied') { + return ( + + ); + } + if (widget.state.status === 'error') { + return ; + } + + if (widget.kind === 'kpi') { + const validation = validateAppDashboardWidgetPayload(widget, widget.state.value); + if (validation.status === 'error') { + return ; + } + if (validation.status === 'empty') { + return ; + } + return ; + } + const validation = validateAppDashboardWidgetPayload(widget, widget.state.value); + if (validation.status === 'error') { + return ; + } + if (validation.status === 'empty') { + return ; + } + if (widget.kind === 'bar') return ; + if (widget.kind === 'line') return ; + return ; +} diff --git a/apps/blocks/src/blocks/app-kit/data/action-bars.tsx b/apps/blocks/src/blocks/app-kit/data/action-bars.tsx new file mode 100644 index 0000000..b4a2214 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/data/action-bars.tsx @@ -0,0 +1,89 @@ +'use client'; + +import * as React from 'react'; +import { XIcon } from 'lucide-react'; + +import { Button } from '@constructive-io/ui/button'; +import { Separator } from '@constructive-io/ui/separator'; + +export type AppActionItem = Readonly<{ + id: string; + label: string; + disabledReason?: string; + destructive?: boolean; + pending?: boolean; + onExecute: () => void | Promise; +}>; + +export type AppActionBarProps = Readonly<{ + actions: readonly AppActionItem[]; + label?: string; + className?: string; +}>; + +export function AppActionBar({ + actions, + label = 'Record actions', + className +}: AppActionBarProps) { + return ( +
    + {actions.map((action) => ( + + ))} +
    + ); +} + +export type AppBulkActionBarProps = AppActionBarProps & + Readonly<{ + selectedCount: number; + onClearSelection: () => void; + }>; + +export function AppBulkActionBar({ + selectedCount, + onClearSelection, + actions, + className +}: AppBulkActionBarProps) { + if (selectedCount === 0) return null; + return ( +
    +

    + {selectedCount} selected +

    + + + +
    + ); +} diff --git a/apps/blocks/src/blocks/app-kit/data/collections.tsx b/apps/blocks/src/blocks/app-kit/data/collections.tsx new file mode 100644 index 0000000..7c8fd14 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/data/collections.tsx @@ -0,0 +1,474 @@ +'use client'; + +import * as React from 'react'; +import { ChevronRightIcon } from 'lucide-react'; + +import { Badge } from '@constructive-io/ui/badge'; +import { Button } from '@constructive-io/ui/button'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@constructive-io/ui/card'; +import { Checkbox } from '@constructive-io/ui/checkbox'; +import { Separator } from '@constructive-io/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@constructive-io/ui/table'; + +import { AppRuntimeError, useAppQuery } from '../core/runtime'; +import type { AppError } from '../core'; + +import { AppDataStateView } from './states'; +import { + columnsFromResource, + type AppCollectionPage, + type AppCollectionViewProps, + type AppColumn, + type AppDataState, + type ConnectedAppCollectionProps +} from './types'; + +export function formatAppValue(value: unknown): React.ReactNode { + if (value === null || value === undefined || value === '') { + return ; + } + if (typeof value === 'boolean') { + return {value ? 'Yes' : 'No'}; + } + if (Array.isArray(value)) { + return value.length === 0 ? ( + + ) : ( + {value.map(String).join(', ')} + ); + } + if (value instanceof Date) return value.toLocaleString(); + if (typeof value === 'object') { + return Structured value; + } + return String(value); +} + +function columnValue>( + column: AppColumn, + record: TRecord +) { + if (column.render) return column.render(record); + return formatAppValue(column.field ? record[column.field] : undefined); +} + +function queryError(error: unknown): AppError { + if (error instanceof AppRuntimeError) return error.appError; + return { + kind: 'unknown', + message: error instanceof Error ? error.message : 'Records could not be loaded.' + }; +} + +function toDataState(query: { + data?: AppCollectionPage; + error: unknown; + isError: boolean; + isLoading: boolean; + isFetching: boolean; +}): AppDataState> { + if (query.isLoading) return { status: 'loading' }; + if (query.isError) { + const error = queryError(query.error); + return error.kind === 'authorization' || error.kind === 'authentication' + ? { error, status: 'denied' } + : { error, status: 'error' }; + } + if (!query.data || query.data.items.length === 0) return { status: 'empty' }; + return { data: query.data, refreshing: query.isFetching, status: 'ready' }; +} + +function CollectionFrame({ + label, + description, + surface, + toolbar, + footer, + children, + className +}: Readonly<{ + label: string; + description: string; + surface: AppCollectionViewProps>['surface']; + toolbar?: React.ReactNode; + footer?: React.ReactNode; + children: React.ReactNode; + className?: string; +}>) { + if (surface === 'card') { + return ( + + + {label} + {description} + + {toolbar ? {toolbar} : null} + {children} + {footer ? {footer} : null} + + ); + } + + return ( +
    + {toolbar ?
    {toolbar}
    : null} + {children} + {footer ? ( + <> + + {footer} + + ) : null} +
    + ); +} + +function selectionFor( + selectedKeys: readonly string[] | undefined, + key: string, + checked: boolean +) { + const selected = new Set(selectedKeys ?? []); + if (checked) selected.add(key); + else selected.delete(key); + return [...selected]; +} + +export function AppDataTable>({ + resource, + state, + columns = columnsFromResource(resource), + getRowKey, + density = 'comfortable', + surface = 'page', + selectedKeys, + onSelectionChange, + onOpenRecord, + onRetry, + toolbar, + footer, + renderFooter, + className +}: AppCollectionViewProps) { + return ( + + + {(page) => ( +
    + + + + {onSelectionChange ? ( + + Select records + + ) : null} + {columns.map((column) => ( + + {column.label} + + ))} + {onOpenRecord ? ( + + Open record + + ) : null} + + + + {page.items.map((record) => { + const rowKey = getRowKey(record); + const checked = selectedKeys?.includes(rowKey) ?? false; + return ( + + {onSelectionChange ? ( + + + onSelectionChange( + selectionFor(selectedKeys, rowKey, nextChecked) + ) + } + /> + + ) : null} + {columns.map((column) => ( + + {columnValue(column, record)} + + ))} + {onOpenRecord ? ( + + + + ) : null} + + ); + })} + +
    +
    + )} +
    +
    + ); +} + +export function AppDataList>({ + resource, + state, + getRowKey, + density = 'comfortable', + surface = 'page', + selectedKeys, + onSelectionChange, + onOpenRecord, + onRetry, + toolbar, + footer, + renderFooter, + renderRecord, + className +}: AppCollectionViewProps) { + return ( + + + {(page) => ( +
      + {page.items.map((record) => { + const rowKey = getRowKey(record); + const title = String(record[resource.displayField] ?? resource.label); + return ( +
    • + {onSelectionChange ? ( + + onSelectionChange(selectionFor(selectedKeys, rowKey, checked)) + } + /> + ) : null} +
      + {renderRecord ? ( + renderRecord(record) + ) : ( + <> +

      {title}

      +

      + {resource.fields + .filter((field) => field.key !== resource.displayField) + .slice(0, 2) + .map((field) => String(record[field.key] ?? '')) + .filter(Boolean) + .join(' · ')} +

      + + )} +
      + {onOpenRecord ? ( + + ) : null} +
    • + ); + })} +
    + )} +
    +
    + ); +} + +export function AppDataCards>({ + resource, + state, + getRowKey, + density = 'comfortable', + surface = 'page', + selectedKeys, + onSelectionChange, + onOpenRecord, + onRetry, + toolbar, + footer, + renderFooter, + renderRecord, + className +}: AppCollectionViewProps) { + return ( + + + {(page) => ( +
    + {page.items.map((record) => { + const rowKey = getRowKey(record); + const title = String(record[resource.displayField] ?? resource.label); + return ( + + + {title} + {resource.label} + {onSelectionChange ? ( + + + onSelectionChange( + selectionFor(selectedKeys, rowKey, checked) + ) + } + /> + + ) : null} + + + {renderRecord + ? renderRecord(record) + : resource.fields + .filter((field) => field.key !== resource.displayField) + .slice(0, density === 'compact' ? 2 : 4) + .map((field) => ( +
    + {field.label} + + {formatAppValue(record[field.key])} + +
    + ))} +
    + {onOpenRecord ? ( + + + + ) : null} +
    + ); + })} +
    + )} +
    +
    + ); +} + +function useConnectedCollection>( + props: ConnectedAppCollectionProps +) { + const query = useAppQuery(props.query, props.queryInput, { + enabled: props.enabled + }); + return { + retry: () => void query.refetch(), + state: toDataState(query) + }; +} + +export function ConnectedAppDataTable< + TRecord extends Record +>(props: ConnectedAppCollectionProps) { + const { query: _query, queryInput: _queryInput, enabled: _enabled, ...viewProps } = props; + const connected = useConnectedCollection(props); + return ; +} + +export function ConnectedAppDataList< + TRecord extends Record +>(props: ConnectedAppCollectionProps) { + const { query: _query, queryInput: _queryInput, enabled: _enabled, ...viewProps } = props; + const connected = useConnectedCollection(props); + return ; +} + +export function ConnectedAppDataCards< + TRecord extends Record +>(props: ConnectedAppCollectionProps) { + const { query: _query, queryInput: _queryInput, enabled: _enabled, ...viewProps } = props; + const connected = useConnectedCollection(props); + return ; +} diff --git a/apps/blocks/src/blocks/app-kit/data/controls.tsx b/apps/blocks/src/blocks/app-kit/data/controls.tsx new file mode 100644 index 0000000..ea19450 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/data/controls.tsx @@ -0,0 +1,223 @@ +'use client'; + +import * as React from 'react'; +import { SearchIcon, XIcon } from 'lucide-react'; + +import { Button } from '@constructive-io/ui/button'; +import { Field, FieldLabel } from '@constructive-io/ui/field'; +import { Input } from '@constructive-io/ui/input'; +import { + Pagination, + PaginationContent, + PaginationItem +} from '@constructive-io/ui/pagination'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@constructive-io/ui/select'; + +import type { + AppCollectionState, + AppFilterDefinition, + AppPageInfo, + AppSortDefinition +} from './types'; + +const ALL_FILTER_VALUE = '__app-kit-all__'; + +export type AppCollectionToolbarProps = Readonly<{ + state: AppCollectionState; + onStateChange: (state: AppCollectionState) => void; + filters?: readonly AppFilterDefinition[]; + sorts?: readonly AppSortDefinition[]; + actions?: React.ReactNode; + searchLabel?: string; +}>; + +export function AppCollectionToolbar({ + state, + onStateChange, + filters = [], + sorts = [], + actions, + searchLabel = 'Search records' +}: AppCollectionToolbarProps) { + const searchId = React.useId(); + const controlsId = React.useId(); + const setSearch = (search: string) => + onStateChange({ ...state, page: 1, search }); + + return ( +
    + + + {searchLabel} + +
    +
    +
    + {filters.map((definition) => { + const triggerId = `${controlsId}-filter-${definition.id.replace(/[^A-Za-z0-9_-]/gu, '-')}`; + const current = + state.filters.find((filter) => filter.id === definition.id)?.value ?? + ALL_FILTER_VALUE; + const items = [ + { label: `All ${definition.label.toLocaleLowerCase()}`, value: ALL_FILTER_VALUE }, + ...definition.options + ]; + return ( + + {definition.label} + + + ); + })} + {sorts.length > 0 ? ( + + Sort + + + ) : null} + {state.search || state.filters.length || state.sort.length ? ( + + ) : null} + {actions ?
    {actions}
    : null} +
    + ); +} + +export type AppPaginationProps = Readonly<{ + pageInfo: AppPageInfo; + onPageChange: (page: number) => void; +}>; + +export function AppPagination({ pageInfo, onPageChange }: AppPaginationProps) { + const firstRecord = (pageInfo.page - 1) * pageInfo.pageSize + 1; + const lastRecord = pageInfo.totalCount + ? Math.min(pageInfo.page * pageInfo.pageSize, pageInfo.totalCount) + : pageInfo.page * pageInfo.pageSize; + return ( +
    +

    + {pageInfo.totalCount === undefined + ? `Page ${pageInfo.page}` + : `${firstRecord}–${lastRecord} of ${pageInfo.totalCount}`} +

    + + + + + + + + + + +
    + ); +} diff --git a/apps/blocks/src/blocks/app-kit/data/data.test.tsx b/apps/blocks/src/blocks/app-kit/data/data.test.tsx new file mode 100644 index 0000000..bfa919e --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/data/data.test.tsx @@ -0,0 +1,993 @@ +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { hydrateRoot, type Root } from 'react-dom/client'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@constructive-io/ui/combobox', () => ({ + Combobox: ({ + children, + items + }: Readonly<{ children: React.ReactNode; items: readonly { label: string }[] }>) => ( +
    item.label).join(',')} data-testid='relation-options'> + {children} +
    + ), + ComboboxEmpty: ({ children }: Readonly<{ children: React.ReactNode }>) =>
    {children}
    , + ComboboxInput: ({ + showClear: _showClear, + ...props + }: React.ComponentProps<'input'> & { showClear?: boolean }) => ( + + ), + ComboboxItem: ({ children }: Readonly<{ children: React.ReactNode }>) =>
    {children}
    , + ComboboxList: () => null, + ComboboxPopup: ({ children }: Readonly<{ children: React.ReactNode }>) =>
    {children}
    +})); + +import { AppKitProvider } from '../core/runtime'; +import { + defineAction, + defineQuery, + defineResource, + type AppScope +} from '../core'; + +import { + AppDataCards, + AppDataTable, + AppCollectionToolbar, + AppPagination, + AppRecordForm, + AppRelationPicker, + ConnectedAppRecordForm, + ConnectedAppRelationPicker, + toAppDateTimeLocalValue +} from './index'; + +type Session = Record & { + id: string; + title: string; + startsAt: string; + status: string; + settings: Record; +}; + +const list = defineQuery({ + id: 'sessions.list', + execute: () => [] +}); + +type Appointment = Record & { + id: string; + startsAt: string; +}; + +const appointmentResource = defineResource({ + id: 'appointments', + label: 'Appointment', + pluralLabel: 'Appointments', + source: { + graphQLTypeName: 'Appointment', + listFieldName: 'appointmentsConnection', + schemaName: 'events', + tableName: 'appointments', + updateMutationName: 'updateAppointment' + }, + fields: [ + { databaseName: 'id', graphQLName: 'id', key: 'id', kind: 'string', label: 'ID', readOnly: true }, + { databaseName: 'starts_at', graphQLName: 'startsAt', key: 'startsAt', kind: 'datetime', label: 'Starts at' } + ], + displayField: 'startsAt', + forms: { update: { fields: [{ field: 'startsAt', required: true }] } }, + identity: { + fields: ['id'], + read: (record) => record.id, + serialize: String + }, + queries: { + list: defineQuery({ + id: 'appointments.list', + execute: () => [] + }) + } +}); + +type ScalarArrayRecord = Record & { + dates: string[]; + flags: boolean[]; + id: string; + instants: string[]; + integers: number[]; + metric: unknown; + numbers: number[]; + optionalStrings: (string | null)[] | null; + settings: Record; + stages: string[]; + strings: (string | null)[]; +}; + +const scalarArrayResource = defineResource({ + id: 'scalar-arrays', + label: 'Scalar array record', + pluralLabel: 'Scalar array records', + source: { + graphQLTypeName: 'ScalarArrayRecord', + listFieldName: 'scalarArrayRecordsConnection', + schemaName: 'testing', + tableName: 'scalar_array_records', + updateMutationName: 'updateScalarArrayRecord' + }, + fields: [ + { databaseName: 'id', graphQLName: 'id', key: 'id', kind: 'string', label: 'ID', readOnly: true }, + { databaseName: 'flags', graphQLName: 'flags', key: 'flags', kind: 'boolean-array', label: 'Flags' }, + { databaseName: 'dates', graphQLName: 'dates', key: 'dates', kind: 'date-array', label: 'Dates' }, + { databaseName: 'instants', graphQLName: 'instants', key: 'instants', kind: 'datetime-array', label: 'Instants' }, + { databaseName: 'integers', graphQLName: 'integers', key: 'integers', kind: 'integer-array', label: 'Integers' }, + { databaseName: 'numbers', graphQLName: 'numbers', key: 'numbers', kind: 'float-array', label: 'Numbers' }, + { + arrayElementNullable: true, + databaseName: 'optional_strings', + graphQLName: 'optionalStrings', + key: 'optionalStrings', + kind: 'string-array', + label: 'Optional strings', + nullable: true + }, + { + arrayElementNullable: true, + databaseName: 'strings', + graphQLName: 'strings', + key: 'strings', + kind: 'string-array', + label: 'Strings' + }, + { + databaseName: 'stages', + graphQLName: 'stages', + key: 'stages', + kind: 'enum-array', + label: 'Stages', + options: [ + { label: 'Draft', value: 'DRAFT' }, + { label: 'Published', value: 'PUBLISHED' } + ] + }, + { databaseName: 'settings', graphQLName: 'settings', key: 'settings', kind: 'json', label: 'Settings' }, + { databaseName: 'metric', graphQLName: 'metric', key: 'metric', kind: 'custom', label: 'Metric' } + ], + displayField: 'id', + forms: { + update: { + fields: [ + { field: 'flags' }, + { field: 'dates' }, + { field: 'instants' }, + { field: 'integers' }, + { field: 'numbers' }, + { field: 'optionalStrings' }, + { field: 'strings' }, + { field: 'stages' }, + { field: 'settings' }, + { field: 'metric' } + ] + } + }, + identity: { + fields: ['id'], + read: (record) => record.id, + serialize: String + }, + queries: { + list: defineQuery({ + id: 'scalar-arrays.list', + execute: () => [] + }) + } +}); + +const scalarArrayValues: ScalarArrayRecord = { + dates: ['2026-08-03'], + flags: [true], + id: 'scalar-array-1', + instants: ['2026-08-03T03:15:30.000Z'], + integers: [1], + metric: { raw: '1.23' }, + numbers: [1.5], + optionalStrings: null, + settings: { room: 'A' }, + stages: ['DRAFT'], + strings: ['Smith, John', '', 'line one\nline two', null] +}; + +const sessionResource = defineResource({ + id: 'sessions', + label: 'Session', + pluralLabel: 'Sessions', + source: { + graphQLTypeName: 'Session', + listFieldName: 'sessionsConnection', + schemaName: 'events', + tableName: 'sessions' + }, + fields: [ + { databaseName: 'id', graphQLName: 'id', key: 'id', kind: 'string', label: 'ID', readOnly: true }, + { databaseName: 'title', graphQLName: 'title', key: 'title', kind: 'string', label: 'Title' }, + { + databaseName: 'status', + graphQLName: 'status', + key: 'status', + kind: 'string', + label: 'Status', + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' } + ] + }, + { databaseName: 'starts_at', graphQLName: 'startsAt', key: 'startsAt', kind: 'datetime', label: 'Starts at' }, + { + databaseName: 'settings', + description: 'Structured session settings.', + graphQLName: 'settings', + key: 'settings', + kind: 'json', + label: 'Settings' + } + ], + displayField: 'title', + forms: { + create: { + fields: [ + { field: 'title', required: true }, + { field: 'status', required: true }, + { field: 'startsAt' } + ] + }, + update: { + fields: [ + { field: 'title', required: true }, + { field: 'status', required: true }, + { field: 'startsAt' }, + { field: 'settings' } + ] + } + }, + identity: { + fields: ['id'], + read: (record) => record.id, + serialize: String + }, + queries: { list } +}); + +const page = { + items: [ + { + id: 'session-1', + settings: { room: 'A' }, + startsAt: '2026-08-03T03:15:30.000Z', + status: 'draft', + title: 'Opening keynote' + } + ], + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + page: 1, + pageSize: 25, + totalCount: 41 + } +} as const; + +describe('App Kit data views', () => { + it('renders a controlled collection, record opening, and page-aware footer', () => { + const open = vi.fn(); + const changePage = vi.fn(); + render( + record.id} + onOpenRecord={open} + renderFooter={(currentPage) => ( + + )} + resource={sessionResource} + state={{ data: page, status: 'ready' }} + /> + ); + + expect(screen.getByText('Opening keynote')).toBeInTheDocument(); + expect(screen.getByText('1–25 of 41')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open Opening keynote' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(open).toHaveBeenCalledWith(page.items[0]); + expect(changePage).toHaveBeenCalledWith(2); + }); + + it('keeps card collections selectable for mobile bulk workflows', () => { + const onSelectionChange = vi.fn(); + render( + record.id} + onSelectionChange={onSelectionChange} + resource={sessionResource} + selectedKeys={[]} + state={{ data: page, status: 'ready' }} + /> + ); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Opening keynote' })); + expect(onSelectionChange).toHaveBeenCalledWith(['session-1']); + }); + + it('uses distinct denied and empty states', () => { + const view = render( + record.id} + resource={sessionResource} + state={{ + error: { kind: 'authorization', message: 'Denied by policy.' }, + status: 'denied' + }} + /> + ); + expect(screen.getByText('Access denied')).toBeInTheDocument(); + expect(screen.getByText('Denied by policy.')).toBeInTheDocument(); + + view.rerender( + record.id} + resource={sessionResource} + state={{ status: 'empty' }} + /> + ); + expect(screen.getByText('No sessions')).toBeInTheDocument(); + }); + + it('keeps JSON read-only without a renderer and blocks all writes without identity', () => { + const readOnlyResource = { ...sessionResource, identity: undefined }; + render( + + ); + + expect(screen.getByText('Read-only resource')).toBeInTheDocument(); + expect(screen.getByLabelText('Settings')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + it('uses mode-specific fields and renders constrained strings as selects', () => { + const view = render( + + ); + + expect(screen.getByLabelText('Title')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Status' })).toBeInTheDocument(); + expect(screen.queryByLabelText('Settings')).not.toBeInTheDocument(); + + view.rerender( + + ); + expect(screen.getByLabelText('Settings')).toBeDisabled(); + }); + + it('converts datetime instants for datetime-local inputs and back to ISO', () => { + const onChange = vi.fn(); + render( + + ); + + const input = screen.getByLabelText('Starts at'); + expect(input).toHaveValue( + toAppDateTimeLocalValue(page.items[0].startsAt) + ); + fireEvent.change(input, { target: { value: '2026-08-03T11:30:00' } }); + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + startsAt: new Date('2026-08-03T11:30:00').toISOString() + }) + ); + }); + + it('parses boolean arrays and preserves date and datetime scalar strings', () => { + const onChange = vi.fn(); + render( + + ); + + const flags = screen.getByLabelText('Flags'); + fireEvent.change(flags, { + target: { value: '[true, "not-a-boolean"]' } + }); + expect(onChange).not.toHaveBeenCalled(); + expect( + screen.getByText( + 'Invalid boolean values: "not-a-boolean". Use only true or false.' + ) + ).toBeInTheDocument(); + + fireEvent.change(flags, { + target: { value: '[true, false]' } + }); + expect(onChange).toHaveBeenLastCalledWith({ + ...scalarArrayValues, + flags: [true, false] + }); + + const dates = ['2026-08-03', '2026-12-31']; + fireEvent.change(screen.getByLabelText('Dates'), { + target: { value: JSON.stringify(dates) } + }); + expect(onChange).toHaveBeenLastCalledWith({ + ...scalarArrayValues, + dates + }); + + const instants = [ + '2026-08-03T03:15:30.000Z', + '2026-08-04T10:45:00+07:00' + ]; + fireEvent.change(screen.getByLabelText('Instants'), { + target: { value: JSON.stringify(instants) } + }); + expect(onChange).toHaveBeenLastCalledWith({ + ...scalarArrayValues, + instants + }); + }); + + it('rejects invalid numeric, date, and datetime array values', () => { + const onChange = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByLabelText('Integers'), { + target: { value: '[1, 2.5]' } + }); + expect(screen.getByText('Invalid integer values: 2.5.')).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Integers'), { + target: { value: '[2147483648]' } + }); + expect( + screen.getByText('Invalid integer values: 2147483648.') + ).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Numbers'), { + target: { value: '[1.5, "not-a-number"]' } + }); + expect( + screen.getByText('Invalid number values: "not-a-number".') + ).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Dates'), { + target: { value: '["2026-02-29"]' } + }); + expect( + screen.getByText('Invalid date values: "2026-02-29". Use YYYY-MM-DD.') + ).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Instants'), { + target: { value: '["2026-02-30T00:00:00Z"]' } + }); + expect( + screen.getByText( + 'Invalid datetime values: "2026-02-30T00:00:00Z". Use an ISO date and time.' + ) + ).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('rejects undeclared enum-array tokens and keeps JSON and custom scalars read-only', () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + render( + + ); + + const stages = screen.getByLabelText('Stages'); + fireEvent.change(stages, { target: { value: '["DRAFT", "ARCHIVED"]' } }); + expect(onChange).not.toHaveBeenCalled(); + expect(stages).toHaveValue('["DRAFT", "ARCHIVED"]'); + expect(stages).toHaveAttribute('aria-invalid', 'true'); + expect(stages).toBeInvalid(); + expect( + screen.getByText('Unknown option values: "ARCHIVED". Use only declared options.') + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.change(stages, { target: { value: '["DRAFT", "PUBLISHED"]' } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...scalarArrayValues, + stages: ['DRAFT', 'PUBLISHED'] + }); + expect(stages).not.toHaveAttribute('aria-invalid', 'true'); + expect(stages).toBeValid(); + expect( + screen.queryByText('Unknown option values: "ARCHIVED". Use only declared options.') + ).not.toBeInTheDocument(); + expect(screen.getByLabelText('Settings')).toBeDisabled(); + expect(screen.getByLabelText('Metric')).toBeDisabled(); + }); + + it('round-trips lossless string arrays and distinguishes null from an empty array', () => { + const onChange = vi.fn(); + render( + + ); + + const strings = screen.getByLabelText('Strings'); + expect(JSON.parse((strings as HTMLTextAreaElement).value)).toEqual( + scalarArrayValues.strings + ); + const nextStrings = ['Doe, Jane', '', 'line one\nline two', null]; + fireEvent.change(strings, { target: { value: JSON.stringify(nextStrings) } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...scalarArrayValues, + strings: nextStrings + }); + + const optional = screen.getByLabelText('Optional strings'); + expect(optional).toHaveValue('null'); + fireEvent.change(optional, { target: { value: '[]' } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...scalarArrayValues, + optionalStrings: [] + }); + fireEvent.change(optional, { target: { value: 'null' } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...scalarArrayValues, + optionalStrings: null + }); + }); + + it('allows clearing a non-null array but enforces an explicitly required array', async () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + const requiredResource = defineResource({ + ...scalarArrayResource, + id: 'required-scalar-arrays', + forms: { update: { fields: [{ field: 'strings', required: true }] } } + }); + const { unmount } = render( + + ); + + const flags = screen.getByLabelText('Flags'); + expect(flags).not.toBeRequired(); + fireEvent.change(flags, { target: { value: '' } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...scalarArrayValues, + flags: [] + }); + unmount(); + + render( + + ); + const requiredStrings = screen.getByLabelText('Strings'); + expect(requiredStrings).toBeRequired(); + await waitFor(() => expect(requiredStrings).toBeInvalid()); + expect(screen.getByText('Add at least one array element.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('clears an invalid array draft when resetKey switches records', async () => { + const props = { + mode: 'update' as const, + onChange: vi.fn(), + onSubmit: vi.fn(), + resource: scalarArrayResource, + values: scalarArrayValues + }; + const { rerender } = render(); + const flags = screen.getByLabelText('Flags'); + fireEvent.change(flags, { target: { value: '[true, "bad"]' } }); + expect(flags).toBeInvalid(); + + const equivalentResource = defineResource({ + ...scalarArrayResource, + fields: scalarArrayResource.fields.map((field) => ({ ...field })) + }); + rerender( + + ); + expect(screen.getByLabelText('Flags')).toHaveValue('[true, "bad"]'); + expect(screen.getByLabelText('Flags')).toBeInvalid(); + + rerender( + + ); + const resetFlags = screen.getByLabelText('Flags'); + await waitFor(() => expect(resetFlags).toBeValid()); + expect(resetFlags).toHaveValue(JSON.stringify([true], null, 2)); + }); + + it('defers browser-local datetime values until after hydration', () => { + const markup = renderToStaticMarkup( + + ); + const container = document.createElement('div'); + container.innerHTML = markup; + + expect(container.querySelector('input[type="datetime-local"]')).toHaveValue(''); + expect(markup).not.toContain(toAppDateTimeLocalValue(page.items[0].startsAt)); + }); + + it('hydrates datetime-local values without mismatch in a non-UTC browser timezone', async () => { + const previousTimeZone = process.env.TZ; + const value = { id: 'appointment-1', startsAt: '2026-08-03T12:30:00.000Z' }; + const form = ( + + ); + let root: Root | undefined; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const container = document.createElement('div'); + document.body.append(container); + + try { + process.env.TZ = 'UTC'; + container.innerHTML = renderToStaticMarkup(form); + expect(container.querySelector('input[type="datetime-local"]')).toHaveValue(''); + + process.env.TZ = 'America/Los_Angeles'; + await act(async () => { + root = hydrateRoot(container, form); + }); + + await waitFor(() => expect( + container + .querySelector('input[type="datetime-local"]') + ?.getAttribute('value') + ).toBe(toAppDateTimeLocalValue(value.startsAt))); + expect(toAppDateTimeLocalValue(value.startsAt)).toBe('2026-08-03T05:30:00.000'); + expect( + consoleError.mock.calls.flat().map(String).join('\n').toLocaleLowerCase() + ).not.toContain('hydration'); + } finally { + await act(async () => root?.unmount()); + container.remove(); + consoleError.mockRestore(); + if (previousTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimeZone; + } + }); + + it('allows decimal values in generated float inputs', () => { + type Measurement = Record & { id: string; ratio: number }; + const resource = defineResource({ + id: 'measurements', + label: 'Measurement', + pluralLabel: 'Measurements', + source: { + graphQLTypeName: 'Measurement', + listFieldName: 'measurementsConnection', + schemaName: 'metrics', + tableName: 'measurements' + }, + fields: [ + { databaseName: 'id', graphQLName: 'id', key: 'id', kind: 'string', label: 'ID', readOnly: true }, + { databaseName: 'ratio', graphQLName: 'ratio', key: 'ratio', kind: 'float', label: 'Ratio' } + ], + displayField: 'id', + forms: { update: { fields: [{ field: 'ratio' }] } }, + identity: { + fields: ['id'], + read: (record) => record.id, + serialize: String + }, + queries: { + list: defineQuery({ + id: 'measurements.list', + execute: () => [] + }) + } + }); + const onChange = vi.fn(); + render( + + ); + + const input = screen.getByLabelText('Ratio'); + expect(input).toHaveAttribute('step', 'any'); + fireEvent.change(input, { target: { value: '1.25' } }); + expect(onChange).toHaveBeenLastCalledWith({ + id: 'measurement-1', + ratio: 1.25 + }); + }); + + it('passes generated accessibility IDs to custom input renderers', () => { + render( + ( + + ) + } + }} + mode='update' + onChange={vi.fn()} + onSubmit={vi.fn()} + resource={sessionResource} + values={page.items[0]} + /> + ); + + const input = screen.getByLabelText('Settings'); + const describedBy = input.getAttribute('aria-describedby')?.split(' ') ?? []; + expect(describedBy).toHaveLength(2); + expect(describedBy.every((id) => document.getElementById(id))).toBe(true); + expect(input.getAttribute('data-error-id')).toBe(describedBy[1]); + }); + + it('associates filter and sort labels with their select triggers', () => { + render( + + ); + + expect(screen.getByRole('combobox', { name: 'Status' })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Sort' })).toBeInTheDocument(); + }); + + it('marks responsive columns with mobile-hidden breakpoint classes', () => { + render( + record.id} + resource={sessionResource} + state={{ data: page, status: 'ready' }} + /> + ); + + const heading = screen.getByRole('columnheader', { name: 'Title' }); + expect(heading).toHaveAttribute('data-hide-below', 'md'); + expect(heading).toHaveClass('data-[hide-below=md]:hidden'); + expect(heading).toHaveClass('md:data-[hide-below=md]:table-cell'); + }); +}); + +describe('ConnectedAppRecordForm', () => { + it('preserves dirty fields when focus refetching supplies fresh initial values', async () => { + const execute = vi.fn(({ input }: { input: Partial }) => input); + const action = defineAction, Partial>({ + id: 'sessions.update', + execute + }); + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } } + }); + const currentScope: AppScope = { + databaseId: 'db', + endpointId: 'graphql', + organizationId: 'org', + schemaRevision: 'schema', + securityRevision: 'security', + sessionPartition: 'session' + }; + const form = (initialValues: Partial) => ( + + values} + /> + + ); + const view = render(form(page.items[0])); + + fireEvent.change(screen.getByLabelText('Title'), { + target: { value: 'Locally edited title' } + }); + view.rerender( + form({ + ...page.items[0], + status: 'published', + title: 'Server refetch title' + }) + ); + + expect(screen.getByLabelText('Title')).toHaveValue('Locally edited title'); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ title: 'Locally edited title' }) + }) + ) + ); + }); +}); + +describe('ConnectedAppRelationPicker scope safety', () => { + it('keeps an embedded picker named when no external input id is supplied', () => { + render( + + ); + + expect(screen.getByRole('combobox', { name: 'People' })).toBeInTheDocument(); + }); + + it('drops accumulated options synchronously when scope changes with the same search', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + const query = defineQuery< + { search: string; page: number; pageSize: number }, + { items: readonly { label: string; record: { id: string }; value: string }[]; hasMore: boolean } + >({ + id: 'people.search', + execute: ({ scope }) => ({ + hasMore: false, + items: [ + { + label: scope.organizationId === 'org-b' ? 'Beta Person' : 'Alpha Person', + record: { id: scope.organizationId ?? '' }, + value: scope.organizationId ?? '' + } + ] + }) + }); + const baseScope: AppScope = { + databaseId: 'db', + endpointId: 'graphql', + organizationId: 'org-a', + schemaRevision: 'schema', + securityRevision: 'security', + sessionPartition: 'session' + }; + const picker = ( + currentScope: AppScope + ) => ( + + + + ); + const view = render(picker(baseScope)); + await waitFor(() => + expect(screen.getByTestId('relation-options')).toHaveAttribute( + 'data-items', + 'Alpha Person' + ) + ); + + view.rerender(picker({ ...baseScope, organizationId: 'org-b' })); + expect(screen.getByTestId('relation-options')).not.toHaveAttribute( + 'data-items', + expect.stringContaining('Alpha Person') + ); + await waitFor(() => + expect(screen.getByTestId('relation-options')).toHaveAttribute( + 'data-items', + 'Beta Person' + ) + ); + }); +}); diff --git a/apps/blocks/src/blocks/app-kit/data/detail-form.tsx b/apps/blocks/src/blocks/app-kit/data/detail-form.tsx new file mode 100644 index 0000000..7d2a1f7 --- /dev/null +++ b/apps/blocks/src/blocks/app-kit/data/detail-form.tsx @@ -0,0 +1,853 @@ +'use client'; + +import * as React from 'react'; +import { SaveIcon } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; +import { Button } from '@constructive-io/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@constructive-io/ui/card'; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel +} from '@constructive-io/ui/field'; +import { Input } from '@constructive-io/ui/input'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@constructive-io/ui/select'; +import { Separator } from '@constructive-io/ui/separator'; +import { Switch } from '@constructive-io/ui/switch'; +import { Textarea } from '@constructive-io/ui/textarea'; + +import type { + AppActionDefinition, + AppError, + AppFieldDefinition, + AppQueryDefinition, + AppResourceDefinition +} from '../core'; +import { useAppAction, useAppQuery } from '../core/runtime'; + +import { formatAppValue } from './collections'; +import { AppDataStateView } from './states'; +import type { + AppDataState, + AppFieldInputRenderer, + AppFieldRenderer, + AppSurface +} from './types'; + +export type AppRecordDetailProps< + TRecord extends Record, + TIdentity = unknown +> = Readonly<{ + resource: Pick< + AppResourceDefinition, + '__types' | 'label' | 'fields' | 'displayField' | 'identity' + >; + state: AppDataState>; + renderField?: AppFieldRenderer; + actions?: React.ReactNode; + surface?: AppSurface; + onRetry?: () => void; + className?: string; +}>; + +export function AppRecordDetail< + TRecord extends Record, + TIdentity = unknown +>({ + resource, + state, + renderField, + actions, + surface = 'card', + onRetry, + className +}: AppRecordDetailProps) { + const content = ( + + {(record) => ( +
    + {resource.fields.map((field) => ( +
    +
    {field.label}
    +
    + {renderField + ? renderField(record[field.key], record, field) + : formatAppValue(record[field.key])} +
    +
    + ))} +
    + )} +
    + ); + + if (surface !== 'card') { + return ( +
    + {content} + {actions ? ( + <> + + {actions} + + ) : null} +
    + ); + } + + const readyRecord = state.status === 'ready' ? state.data : undefined; + return ( + + + + {readyRecord + ? String(readyRecord[resource.displayField] ?? resource.label) + : resource.label} + + + {resource.identity + ? `${resource.label} details` + : 'Read-only because this resource has no stable identity.'} + + + {content} + {actions ? {actions} : null} + + ); +} + +export type ConnectedAppRecordDetailProps< + TRecord extends Record, + TIdentity +> = Omit, 'state' | 'onRetry'> & + Readonly<{ + query: AppQueryDefinition; + identity: TIdentity; + enabled?: boolean; + }>; + +export function ConnectedAppRecordDetail< + TRecord extends Record, + TIdentity +>(props: ConnectedAppRecordDetailProps) { + const { query, identity, enabled, ...detailProps } = props; + const result = useAppQuery(query, identity, { enabled }); + let state: AppDataState; + if (result.isLoading) state = { status: 'loading' }; + else if (result.isError) { + const error = result.error.appError; + state = + error.kind === 'authorization' || error.kind === 'authentication' + ? { error, status: 'denied' } + : { error, status: 'error' }; + } else if (!result.data) state = { status: 'empty' }; + else state = { data: result.data, refreshing: result.isFetching, status: 'ready' }; + + return ( + void result.refetch()} + state={state} + /> + ); +} + +function arrayValue(value: unknown) { + return JSON.stringify(value === null ? null : Array.isArray(value) ? value : [], null, 2); +} + +type ParsedArray = + | Readonly<{ success: true; value: readonly unknown[] | null }> + | Readonly<{ success: false; error: string }>; + +function invalidArrayItems( + items: readonly unknown[], + accepts: (item: unknown) => boolean +) { + return items.filter((item) => !accepts(item)); +} + +function validDate(item: unknown) { + if (typeof item !== 'string') return false; + const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(item); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ); +} + +function validDateTime(item: unknown) { + if (typeof item !== 'string') return false; + const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|([+-])(\d{2}):(\d{2}))?$/u.exec(item); + if (!match || !validDate(match[1])) return false; + const hour = Number(match[2]); + const minute = Number(match[3]); + const second = match[4] === undefined ? 0 : Number(match[4]); + const offsetHour = match[6] === undefined ? 0 : Number(match[6]); + const offsetMinute = match[7] === undefined ? 0 : Number(match[7]); + return ( + hour <= 23 && + minute <= 59 && + second <= 59 && + offsetHour <= 23 && + offsetMinute <= 59 + ); +} + +function arrayItemLabel(item: unknown) { + const serialized = JSON.stringify(item); + return serialized === undefined ? String(item) : serialized; +} + +function invalidArrayError(label: string, items: readonly unknown[]) { + return `Invalid ${label} values: ${items.map(arrayItemLabel).join(', ')}.`; +} + +function parseArray>( + field: AppFieldDefinition, + value: string, + required: boolean +): ParsedArray { + let parsed: unknown; + try { + parsed = value.trim() ? JSON.parse(value) : []; + } catch { + return { success: false, error: 'Enter a valid JSON array.' }; + } + if (parsed === null) { + return field.nullable === true && !required + ? { success: true, value: null } + : { success: false, error: 'This array cannot be null.' }; + } + if (!Array.isArray(parsed)) { + return { success: false, error: 'Enter a JSON array.' }; + } + const items = parsed; + if (required && items.length === 0) { + return { success: false, error: 'Add at least one array element.' }; + } + const nullItems = items.filter((item) => item === null); + const nonNullItems = items.filter((item) => item !== null); + if (field.arrayElementNullable !== true && nullItems.length > 0) { + return { + success: false, + error: 'Null array elements are not allowed for this field.' + }; + } + if (field.kind === 'integer-array') { + const invalid = invalidArrayItems( + nonNullItems, + (item) => + typeof item === 'number' && + Number.isInteger(item) && + item >= -2_147_483_648 && + item <= 2_147_483_647 + ); + if (invalid.length > 0) { + return { + success: false, + error: invalidArrayError('integer', invalid) + }; + } + } + if (field.kind === 'float-array') { + const invalid = invalidArrayItems( + nonNullItems, + (item) => typeof item === 'number' && Number.isFinite(item) + ); + if (invalid.length > 0) { + return { + success: false, + error: invalidArrayError('number', invalid) + }; + } + } + if (field.kind === 'boolean-array') { + const invalid = invalidArrayItems( + nonNullItems, + (item) => typeof item === 'boolean' + ); + if (invalid.length > 0) { + return { + success: false, + error: `${invalidArrayError('boolean', invalid)} Use only true or false.` + }; + } + } + if (field.kind === 'enum-array') { + const allowed = new Set(field.options?.map((option) => option.value) ?? []); + const invalid = nonNullItems.filter( + (item) => typeof item !== 'string' || !allowed.has(item) + ); + if (invalid.length > 0) { + return { + success: false, + error: `Unknown option values: ${invalid.map(arrayItemLabel).join(', ')}. Use only declared options.` + }; + } + } + if (field.kind === 'string-array') { + const invalid = invalidArrayItems( + nonNullItems, + (item) => typeof item === 'string' + ); + if (invalid.length > 0) { + return { success: false, error: invalidArrayError('string', invalid) }; + } + } + if (field.kind === 'date-array') { + const invalid = invalidArrayItems(nonNullItems, validDate); + if (invalid.length > 0) { + return { + success: false, + error: `${invalidArrayError('date', invalid)} Use YYYY-MM-DD.` + }; + } + } + if (field.kind === 'datetime-array') { + const invalid = invalidArrayItems(nonNullItems, validDateTime); + if (invalid.length > 0) { + return { + success: false, + error: `${invalidArrayError('datetime', invalid)} Use an ISO date and time.` + }; + } + } + // Scalar arrays use JSON so nulls, empty strings, commas, and embedded + // newlines round-trip without inventing a lossy delimiter format. Date and + // datetime values stay as strings so offsets and date-only values survive. + return { success: true, value: items }; +} + +function structuredInputValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value !== 'object') return String(value); + try { + return JSON.stringify(value, null, 2) ?? ''; + } catch { + return 'Structured value'; + } +} + +function padDateTimePart(value: number): string { + return String(value).padStart(2, '0'); +} + +function padMilliseconds(value: number): string { + return String(value).padStart(3, '0'); +} + +function subscribeToClientSnapshot() { + return () => undefined; +} + +function getClientSnapshot() { + return true; +} + +function getServerSnapshot() { + return false; +} + +/** Converts an instant-bearing ISO value to the browser's datetime-local wall time. */ +export function toAppDateTimeLocalValue(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return [ + `${date.getFullYear()}-${padDateTimePart(date.getMonth() + 1)}-${padDateTimePart(date.getDate())}`, + `${padDateTimePart(date.getHours())}:${padDateTimePart(date.getMinutes())}:${padDateTimePart(date.getSeconds())}.${padMilliseconds(date.getMilliseconds())}` + ].join('T'); +} + +/** Converts datetime-local wall time to an offset-aware ISO instant (`Z`). */ +export function fromAppDateTimeLocalValue(value: string): string { + if (value.length === 0) return ''; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toISOString(); +} + +function GeneratedField>({ + field, + value, + error, + disabled, + required, + renderer, + onChange +}: Readonly<{ + field: AppFieldDefinition; + value: unknown; + error?: string; + disabled: boolean; + required: boolean; + renderer?: AppFieldInputRenderer; + onChange: (value: unknown) => void; +}>) { + const id = React.useId(); + const serializedArrayValue = field.kind.endsWith('-array') + ? arrayValue(value) + : ''; + const arrayInputRef = React.useRef(null); + const [arrayDraft, setArrayDraft] = React.useState(serializedArrayValue); + const [arrayValidationError, setArrayValidationError] = + React.useState(); + const arrayValidationSignature = [ + field.kind, + renderer ? 'custom-renderer' : 'generated-renderer', + field.nullable === true ? 'outer-null' : 'outer-required', + field.arrayElementNullable === true ? 'inner-null' : 'inner-required', + field.options?.map((option) => option.value).join('\u0000') ?? '' + ].join('\u0001'); + React.useEffect(() => { + setArrayDraft(serializedArrayValue); + if (renderer || !field.kind.endsWith('-array')) { + setArrayValidationError(undefined); + arrayInputRef.current?.setCustomValidity(''); + return; + } + const parsed = parseArray(field, serializedArrayValue, required); + const message = parsed.success ? '' : parsed.error; + setArrayValidationError(message || undefined); + arrayInputRef.current?.setCustomValidity(message); + }, [arrayValidationSignature, required, serializedArrayValue]); + const canRenderBrowserLocalTime = React.useSyncExternalStore( + subscribeToClientSnapshot, + getClientSnapshot, + getServerSnapshot + ); + const enumOptions = field.options?.map((option) => option.value) ?? []; + const enumOptionsUsable = + !field.kind.startsWith('enum') || + (enumOptions.length > 0 && new Set(enumOptions).size === enumOptions.length); + const descriptionId = field.description ? `${id}-description` : undefined; + const arrayDescriptionId = + !renderer && field.kind.endsWith('-array') + ? `${id}-array-description` + : undefined; + const enumDescriptionId = + !renderer && !enumOptionsUsable ? `${id}-enum-description` : undefined; + const resolvedError = error ?? arrayValidationError; + const errorId = resolvedError ? `${id}-error` : undefined; + const describedBy = [ + descriptionId, + arrayDescriptionId, + enumDescriptionId, + errorId + ].filter(Boolean).join(' ') || undefined; + const invalid = Boolean(resolvedError); + const fieldDisabled = disabled || field.readOnly === true || !enumOptionsUsable; + + let control: React.ReactNode; + if (renderer) { + control = renderer.render({ + 'aria-describedby': describedBy, + descriptionId, + disabled: fieldDisabled, + errorId, + field, + id, + invalid, + onChange, + required, + value + }); + } else if (field.kind === 'boolean') { + control = ( + + ); + } else if ( + field.kind === 'enum' || + (field.kind === 'string' && Boolean(field.options?.length)) + ) { + const items = [ + ...(field.nullable ? [{ label: 'None', value: '__app-kit-null__' }] : []), + ...(field.options ?? []) + ]; + control = ( + + ); + } else if (field.kind.endsWith('-array')) { + control = ( +