diff --git a/CLAUDE.md b/CLAUDE.md index 461cd593..5920dab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,45 @@ common → (no dependencies) - **Testing**: Vitest (unit/integration), Playwright (E2E), @vitest/browser (Storybook) - **Language**: TypeScript throughout +### Development Workflow with Conditional Exports + +This monorepo uses **conditional exports** for zero-build development workflow: + +**In Development:** +- Library packages (common, database, forms-core, auth, design) are consumed directly from TypeScript source files +- No build step required when editing library code - changes are immediately reflected in consuming apps +- Hot module replacement (HMR) works instantly across package boundaries +- Consumer apps (server, spotlight, etc.) are configured with `customConditions: ["development"]` in tsconfig.json +- Vite/Astro resolve the `development` export condition to use `./src/**/*.ts` files + +**In Production:** +- Library packages are built and published from `dist/` folders +- Production builds use optimized, transpiled artifacts +- The `development` export condition is not used + +**How it Works:** +Each library package.json has exports like: +```json +{ + "exports": { + ".": { + "development": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} +``` + +**Build Scripts:** +- **Important:** The `design` package requires CSS/SASS compilation: + - First time setup: Run `pnpm --filter @flexion/forms-design build:styles` (one-time) + - Or use `pnpm dev` which includes `dev:styles` (gulp watch) for the design package +- Production builds (`pnpm build`) are still required before publishing + ### Pattern System Patterns are the platform's primary building blocks. Each pattern has: @@ -137,5 +176,9 @@ Use `describeDatabase` helper for testing database routines against both SQLite - Node version is specified in `.nvmrc` - use `nvm install` to ensure correct version - Requires Docker or Podman for running tests (PostgreSQL container) - Playwright version must match exactly (1.51.1) across local and CI environments -- Build is required before running `pnpm dev` +- **Development**: + - No TypeScript build required - packages are consumed from source via conditional exports + - CSS/styles must be built once: `pnpm --filter @flexion/forms-design build:styles` + - Or run `pnpm dev` which includes style watching +- **Production/Publishing**: Run `pnpm build` to create optimized artifacts before publishing - Pre-commit hook runs `pnpm format` automatically diff --git a/apps/cli/src/cli-controller/forms.ts b/apps/cli/src/cli-controller/forms.ts index 6898ce12..c470727d 100644 --- a/apps/cli/src/cli-controller/forms.ts +++ b/apps/cli/src/cli-controller/forms.ts @@ -1,9 +1,9 @@ import { promises as fs } from 'fs'; import { Command } from 'commander'; -import { commands } from '@flexion/forms-infra-core'; import { type Context } from './types.js'; -import { createFormService, createFormsRepository, defaultFormConfig, parsePdf as parsePdfCore } from '@flexion/forms-core'; +import { createFormService, defaultFormConfig, parsePdf as parsePdfCore } from '@flexion/forms-core'; +import { createFormsRepository } from '@flexion/forms-core/repository'; import { createTestPdfParser } from '@flexion/forms-core/documents/pdf/context'; import { createFilesystemDatabaseContext } from '@flexion/forms-database/context'; diff --git a/apps/sandbox/tsconfig.json b/apps/sandbox/tsconfig.json index 11cf6557..09185aaf 100644 --- a/apps/sandbox/tsconfig.json +++ b/apps/sandbox/tsconfig.json @@ -4,7 +4,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", - "emitDeclarationOnly": true + "emitDeclarationOnly": true, + "customConditions": ["development"] }, "include": ["./src"], "references": [] diff --git a/apps/server-doj/tsconfig.json b/apps/server-doj/tsconfig.json index 11cf6557..09185aaf 100644 --- a/apps/server-doj/tsconfig.json +++ b/apps/server-doj/tsconfig.json @@ -4,7 +4,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", - "emitDeclarationOnly": true + "emitDeclarationOnly": true, + "customConditions": ["development"] }, "include": ["./src"], "references": [] diff --git a/apps/spotlight/astro.config.mjs b/apps/spotlight/astro.config.mjs index 01f796f2..50895063 100644 --- a/apps/spotlight/astro.config.mjs +++ b/apps/spotlight/astro.config.mjs @@ -21,6 +21,11 @@ export default defineConfig({ define: { 'import.meta.env.GITHUB': JSON.stringify(githubRepository), }, + resolve: { + conditions: process.env.NODE_ENV === 'production' + ? ['production', 'import', 'module', 'browser', 'default'] + : ['development', 'import', 'module', 'browser', 'default'], + }, }, }); diff --git a/apps/spotlight/tsconfig.json b/apps/spotlight/tsconfig.json index 7ea57e6d..70c020ab 100644 --- a/apps/spotlight/tsconfig.json +++ b/apps/spotlight/tsconfig.json @@ -6,7 +6,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "jsx": "react", - "resolveJsonModule": true + "resolveJsonModule": true, + "customConditions": ["development"] }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.astro"], "exclude": [".astro", "dist", "node_modules"] diff --git a/packages/auth/package.json b/packages/auth/package.json index d363d0f7..f8fa8e4d 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -6,6 +6,17 @@ "license": "CC0", "main": "dist/index.js", "types": "dist/index.d.js", + "exports": { + ".": { + "development": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "publishConfig": { "registry": "https://npm.pkg.github.com" }, diff --git a/packages/common/package.json b/packages/common/package.json index 48246d6f..b8c44fdd 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -6,6 +6,17 @@ "license": "CC0", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "development": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "publishConfig": { "registry": "https://npm.pkg.github.com" }, diff --git a/packages/database/migrations/20251007000000_create_form_jobs.mjs b/packages/database/migrations/20251007000000_create_form_jobs.mjs new file mode 100644 index 00000000..df23e6ae --- /dev/null +++ b/packages/database/migrations/20251007000000_create_form_jobs.mjs @@ -0,0 +1,58 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex) { + await knex.schema.createTable('form_jobs', table => { + table.uuid('id').primary(); + + // Foreign key to forms (CASCADE delete: jobs belong to forms) + table + .uuid('form_id') + .notNullable() + .references('id') + .inTable('forms') + .onDelete('CASCADE'); + + // Job type - extensible for future operations + table.text('job_type').notNullable(); + // Values: 'import-pdf', 'validate-schema', 'publish', 'export', etc. + + // Job status - represents operation state + table.text('status').notNullable(); + // Values: 'pending', 'processing', 'completed', 'failed' + + // Timing information + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + table.timestamp('started_at').nullable(); + table.timestamp('completed_at').nullable(); + + // Error tracking + table.text('error_message').nullable(); + table.text('error_stack').nullable(); + + // Job metadata (input parameters, varies by job_type) + // For 'import-pdf': { documentId, fileName, userId } + // For 'publish': { targetEnvironment, publisherId } + table.text('metadata').nullable(); + + // Job result (output data, varies by job_type) + // For 'import-pdf': { patternsAdded: 5, fieldsExtracted: 12 } + // For 'validate': { errorsFound: 2, warningsFound: 5 } + table.text('result').nullable(); + + // Indexes for common queries + table.index('form_id', 'idx_form_jobs_form_id'); + table.index('status', 'idx_form_jobs_status'); + table.index(['form_id', 'job_type'], 'idx_form_jobs_form_type'); + table.index('created_at', 'idx_form_jobs_created'); + }); +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex) { + await knex.schema.dropTableIfExists('form_jobs'); +} diff --git a/packages/database/package.json b/packages/database/package.json index 38ca8d0a..7379b76d 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -12,16 +12,28 @@ }, "exports": { ".": { + "development": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" }, "./context": { + "development": { + "types": "./src/context/index.ts", + "import": "./src/context/index.ts" + }, "types": "./dist/types/context/index.d.ts", "import": "./dist/esm/context.js", "require": "./dist/cjs/context.js" }, "./testing": { + "development": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + }, "types": "./dist/types/testing.d.ts", "import": "./dist/esm/testing.js", "require": "./dist/cjs/testing.js" diff --git a/packages/database/src/clients/kysely/types.ts b/packages/database/src/clients/kysely/types.ts index 7590feb7..49a80c7b 100644 --- a/packages/database/src/clients/kysely/types.ts +++ b/packages/database/src/clients/kysely/types.ts @@ -16,7 +16,8 @@ export interface Database { forms: FormsTable; form_sessions: FormSessionsTable; form_documents: FormDocumentsTable; - llm_request_cache: LlmRequestCacheTable; + form_jobs: FormJobsTable; + llm_request_cache: LlmRequestCacheTable; } export type DatabaseClient = Kysely; @@ -74,12 +75,29 @@ export type FormDocumentsTableSelectable = Selectable; export type FormDocumentsTableInsertable = Insertable; export type FormDocumentsTableUpdateable = Updateable; -interface LlmRequestCacheTable { +interface FormJobsTable { + id: string; + form_id: string; + job_type: string; + status: string; + created_at: DbDate; + started_at: DbDate | null; + completed_at: DbDate | null; + error_message: string | null; + error_stack: string | null; + metadata: string | null; + result: string | null; +} +export type FormJobsTableSelectable = Selectable; +export type FormJobsTableInsertable = Insertable; +export type FormJobsTableUpdateable = Updateable; + +interface LlmRequestCacheTable { id: Generated; cache_key: string; response_data: string; - created_at: Generated; - accessed_at: Date; + created_at: DbDate; + accessed_at: DbDate; access_count: number; } export type LlmRequestCacheTableSelectable = Selectable; diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts deleted file mode 100644 index 319fe17a..00000000 --- a/packages/database/src/schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type SessionSchema = { - id: string; - user_id: string; - session_token: string; - expires_at: number; - created_at: number; - updated_at: number; -}; diff --git a/packages/design/.eslintrc.cjs b/packages/design/.eslintrc.cjs index 0d5fa822..3ae371ac 100644 --- a/packages/design/.eslintrc.cjs +++ b/packages/design/.eslintrc.cjs @@ -28,4 +28,9 @@ module.exports = { rules: { 'react/prop-types': 'off', }, + settings: { + react: { + version: 'detect', + }, + }, }; diff --git a/packages/design/package.json b/packages/design/package.json index c25e1efa..223ddd2f 100644 --- a/packages/design/package.json +++ b/packages/design/package.json @@ -4,6 +4,18 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "type": "module", + "exports": { + ".": { + "development": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./*": "./*" + }, "publishConfig": { "registry": "https://npm.pkg.github.com" }, diff --git a/packages/design/src/AvailableFormList/FormStatusBadge.tsx b/packages/design/src/AvailableFormList/FormStatusBadge.tsx new file mode 100644 index 00000000..594aab16 --- /dev/null +++ b/packages/design/src/AvailableFormList/FormStatusBadge.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import type { FormListItem } from '@flexion/forms-core'; +import styles from './formStatusBadge.module.css'; + +type FormStatusBadgeProps = { + form: FormListItem; +}; + +export const FormStatusBadge: React.FC = ({ form }) => { + const { latestJob } = form; + + // No job or completed job - no badge needed + if (!latestJob || latestJob.status === 'completed') { + return null; + } + + const isProcessing = latestJob.status === 'processing'; + const isFailed = latestJob.status === 'failed'; + + if (isProcessing) { + return ( + + + Processing + + ); + } + + if (isFailed) { + return ( + + + Import failed + + ); + } + + return null; +}; diff --git a/packages/design/src/AvailableFormList/formStatusBadge.module.css b/packages/design/src/AvailableFormList/formStatusBadge.module.css new file mode 100644 index 00000000..8ff3dcf3 --- /dev/null +++ b/packages/design/src/AvailableFormList/formStatusBadge.module.css @@ -0,0 +1,68 @@ +/* Badge container */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; /* 14px */ + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +/* Processing badge */ +.badgeProcessing { + background-color: #e7f6f8; /* USWDS info-lighter */ + color: #00687d; /* USWDS info-darker */ +} + +/* Failed badge */ +.badgeFailed { + background-color: #f4e3db; /* USWDS error-lighter */ + color: #b50909; /* USWDS red-warm-60v */ +} + +/* Spinner animation */ +.spinner { + width: 1rem; + height: 1rem; + animation: rotate 1s linear infinite; + flex-shrink: 0; +} + +.spinnerCircle { + stroke: currentColor; + stroke-dasharray: 40, 60; + stroke-dashoffset: 0; + animation: dash 1.5s ease-in-out infinite; + stroke-linecap: round; +} + +@keyframes rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes dash { + 0% { + stroke-dasharray: 1, 100; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 40, 100; + stroke-dashoffset: -20; + } + 100% { + stroke-dasharray: 40, 100; + stroke-dashoffset: -60; + } +} + +/* Error icon */ +.errorIcon { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} diff --git a/packages/design/src/AvailableFormList/index.tsx b/packages/design/src/AvailableFormList/index.tsx index 5a438eea..18f7b0cf 100644 --- a/packages/design/src/AvailableFormList/index.tsx +++ b/packages/design/src/AvailableFormList/index.tsx @@ -1,15 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { type FormService } from '@flexion/forms-core'; +import { type FormService, type FormListItem } from '@flexion/forms-core'; import * as AppRoutes from '../FormManager/routes.js'; +import { FormStatusBadge } from './FormStatusBadge.js'; -type FormDetails = { - id: string; - title: string; - description: string; -}; export type UrlForForm = (id: string) => string | null; export type UrlForFormManager = UrlForForm; @@ -22,8 +18,9 @@ export default function AvailableFormList({ urlForForm: UrlForForm; urlForFormManager: UrlForFormManager; }) { - const [forms, setForms] = useState([]); + const [forms, setForms] = useState([]); const location = useLocation(); + const pollIntervalRef = useRef(null); const loadForms = React.useCallback(() => { formService.getFormList().then(result => { @@ -37,6 +34,32 @@ export default function AvailableFormList({ loadForms(); }, [location.pathname, location.hash, location.key, loadForms]); + // Poll if any forms are processing + useEffect(() => { + const hasProcessingForms = forms.some( + form => form.latestJob?.status === 'processing' + ); + + // Clear any existing interval + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + + // Start polling if needed + if (hasProcessingForms) { + pollIntervalRef.current = setInterval(() => { + loadForms(); + }, 3000); // Poll every 3 seconds + } + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [forms, loadForms]); + return ( <>
@@ -74,7 +97,7 @@ const FormList = ({ urlForForm, urlForFormManager, }: { - forms: FormDetails[]; + forms: FormListItem[]; urlForForm: UrlForForm; urlForFormManager: UrlForFormManager; }) => { @@ -104,19 +127,12 @@ const FormList = ({ ) : ( forms.map((form, index) => ( - - - {form.title} - - {form.description} - - - - + )) )} @@ -124,20 +140,87 @@ const FormList = ({ ); }; +const FormRow = ({ + form, + urlForForm, + urlForFormManager, +}: { + form: FormListItem; + urlForForm: UrlForForm; + urlForFormManager: UrlForFormManager; +}) => { + const [showError, setShowError] = React.useState(false); + + return ( + <> + + +
+ {form.title} + +
+ + {form.description} + + + + + {form.latestJob?.status === 'failed' && form.latestJob && ( + + +
+
+

+ Import error:{' '} + {form.latestJob.errorMessage || + 'An error occurred while processing this form.'}{' '} + +

+ {showError && form.latestJob.errorMessage && ( +
+ Technical details +
+                      {form.latestJob.errorMessage}
+                    
+
+ )} +
+
+ + + )} + + ); +}; + const FormActions = ({ form, urlForForm, urlForFormManager, }: { - form: FormDetails; + form: FormListItem; urlForForm: UrlForForm; urlForFormManager: UrlForFormManager; }) => { const formUrl = urlForForm(form.id); + const isProcessing = form.latestJob?.status === 'processing'; return ( ; } - const form = useBlueprint(formId, context.formService); - if (form === null) { + const { form, status, isLoading, isProcessing } = + useBlueprintWithStatus(formId, context.formService); + + if (isProcessing && status) { + return ( + + + + + + ); + } + + if (isLoading || form === null) { return
Loading...
; } + return ( formId is undefined; } - const form = useBlueprint(formId, context.formService); - if (form === null) { + const { form, status, isLoading, isProcessing } = + useBlueprintWithStatus(formId, context.formService); + + if (isProcessing && status) { + return ( + + + + + + ); + } + + if (isLoading || form === null) { return
Loading...
; } + return ( formId is undefined; } - const form = useBlueprint(formId, context.formService); - if (form === null) { + const { form, status, isLoading, isProcessing } = + useBlueprintWithStatus(formId, context.formService); + + if (isProcessing && status) { + return ( + + + + + + ); + } + + if (isLoading || form === null) { return
Loading...
; } + return ( formId is undefined; } - const form = useBlueprint(formId, context.formService); - if (form === null) { + const { form, status, isLoading, isProcessing } = + useBlueprintWithStatus(formId, context.formService); + + if (isProcessing && status) { + return ( + + + + + + ); + } + + if (isLoading || form === null) { return
Loading...
; } + const session = createFormSession(form); return ( ); } - -const useBlueprint = (formId: string | undefined, formService: FormService) => { - if (formId === undefined) { - console.error('formId is undefined'); - return null; - } - const [form, setForm] = useState(null); - useEffect(() => { - formService.getForm(formId).then(result => { - if (result.success) { - setForm(result.data); - } else { - console.error('Error loading form', result.error); - } - }); - }, []); - return form; -}; diff --git a/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.stories.tsx b/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.stories.tsx index 80c7ef0d..dbd93b80 100644 --- a/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.stories.tsx +++ b/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.stories.tsx @@ -43,7 +43,6 @@ export const Edit = { step: NavPage.edit, next: '#', back: '#', - preview: '#', }, } satisfies StoryObj; @@ -52,6 +51,5 @@ export const Publish = { step: NavPage.publish, next: '#', back: '#', - preview: '#', }, } satisfies StoryObj; diff --git a/packages/design/src/FormManager/FormProcessingIndicator/FormProcessingIndicator.stories.tsx b/packages/design/src/FormManager/FormProcessingIndicator/FormProcessingIndicator.stories.tsx new file mode 100644 index 00000000..f281991c --- /dev/null +++ b/packages/design/src/FormManager/FormProcessingIndicator/FormProcessingIndicator.stories.tsx @@ -0,0 +1,147 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { FormProcessingIndicator } from './FormProcessingIndicator.js'; + +const meta = { + title: 'FormManager/FormProcessingIndicator', + component: FormProcessingIndicator, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * The processing indicator shown immediately after PDF upload. + * Shows an animated spinner, progress steps, and elapsed time. + */ +export const Processing: Story = { + args: { + formId: 'test-form-123', + status: { + formId: 'test-form-123', + formStatus: 'draft', + latestJob: { + id: 'job-123', + jobType: 'import-pdf', + status: 'processing', + createdAt: new Date(Date.now() - 15000).toISOString(), // 15 seconds ago + }, + }, + }, +}; + +/** + * Processing state after 45 seconds (approaching the upper end of expected time). + */ +export const ProcessingLongRunning: Story = { + args: { + formId: 'test-form-456', + status: { + formId: 'test-form-456', + formStatus: 'draft', + latestJob: { + id: 'job-456', + jobType: 'import-pdf', + status: 'processing', + createdAt: new Date(Date.now() - 45000).toISOString(), // 45 seconds ago + }, + }, + }, +}; + +/** + * Processing state after more than a minute (exceeding expected time). + */ +export const ProcessingVeryLongRunning: Story = { + args: { + formId: 'test-form-789', + status: { + formId: 'test-form-789', + formStatus: 'draft', + latestJob: { + id: 'job-789', + jobType: 'import-pdf', + status: 'processing', + createdAt: new Date(Date.now() - 90000).toISOString(), // 90 seconds ago + }, + }, + }, +}; + +/** + * Failed processing state with generic error message. + */ +export const Failed: Story = { + args: { + formId: 'test-form-error', + status: { + formId: 'test-form-error', + formStatus: 'draft', + latestJob: { + id: 'job-error', + jobType: 'import-pdf', + status: 'failed', + createdAt: new Date(Date.now() - 30000).toISOString(), + completedAt: new Date(Date.now() - 5000).toISOString(), + }, + }, + }, +}; + +/** + * Failed processing state with specific error message. + */ +export const FailedWithMessage: Story = { + args: { + formId: 'test-form-error-msg', + status: { + formId: 'test-form-error-msg', + formStatus: 'draft', + latestJob: { + id: 'job-error-msg', + jobType: 'import-pdf', + status: 'failed', + createdAt: new Date(Date.now() - 30000).toISOString(), + completedAt: new Date(Date.now() - 5000).toISOString(), + errorMessage: + 'Unable to extract text from PDF. The document may be image-based or corrupted.', + }, + }, + }, +}; + +/** + * Completed state - component should not render anything. + */ +export const Completed: Story = { + args: { + formId: 'test-form-complete', + status: { + formId: 'test-form-complete', + formStatus: 'ready', + latestJob: { + id: 'job-complete', + jobType: 'import-pdf', + status: 'completed', + createdAt: new Date(Date.now() - 60000).toISOString(), + completedAt: new Date(Date.now() - 5000).toISOString(), + }, + }, + }, +}; + +/** + * No job state - component should not render anything. + */ +export const NoJob: Story = { + args: { + formId: 'test-form-no-job', + status: { + formId: 'test-form-no-job', + formStatus: 'draft', + }, + }, +}; diff --git a/packages/design/src/FormManager/FormProcessingIndicator/FormProcessingIndicator.tsx b/packages/design/src/FormManager/FormProcessingIndicator/FormProcessingIndicator.tsx new file mode 100644 index 00000000..76aee924 --- /dev/null +++ b/packages/design/src/FormManager/FormProcessingIndicator/FormProcessingIndicator.tsx @@ -0,0 +1,143 @@ +import React, { useState, useEffect } from 'react'; +import type { FormStatusResponse } from '@flexion/forms-core'; +import styles from './formProcessingIndicator.module.css'; + +type FormProcessingIndicatorProps = { + formId: string; + status: FormStatusResponse; +}; + +export const FormProcessingIndicator: React.FC< + FormProcessingIndicatorProps +> = ({ formId, status }) => { + const { latestJob } = status; + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + // Don't show anything if no job or job is completed + if (!latestJob || latestJob.status === 'completed') { + return null; + } + + const isProcessing = latestJob.status === 'processing'; + const isFailed = latestJob.status === 'failed'; + + // Calculate elapsed time for processing jobs + useEffect(() => { + if (!isProcessing || !latestJob.createdAt) { + return; + } + + const startTime = new Date(latestJob.createdAt).getTime(); + + const updateElapsed = () => { + const now = Date.now(); + const elapsed = Math.floor((now - startTime) / 1000); + setElapsedSeconds(elapsed); + }; + + // Update immediately + updateElapsed(); + + // Then update every second + const interval = setInterval(updateElapsed, 1000); + + return () => clearInterval(interval); + }, [isProcessing, latestJob.createdAt]); + + const formatElapsedTime = (seconds: number): string => { + if (seconds < 60) { + return `${seconds} second${seconds !== 1 ? 's' : ''}`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes} minute${minutes !== 1 ? 's' : ''}, ${remainingSeconds} second${remainingSeconds !== 1 ? 's' : ''}`; + }; + + return ( +
+ ); +}; diff --git a/packages/design/src/FormManager/FormProcessingIndicator/formProcessingIndicator.module.css b/packages/design/src/FormManager/FormProcessingIndicator/formProcessingIndicator.module.css new file mode 100644 index 00000000..5aadcd9f --- /dev/null +++ b/packages/design/src/FormManager/FormProcessingIndicator/formProcessingIndicator.module.css @@ -0,0 +1,120 @@ +/* Alert container styling */ +.processingAlert { + border-left-width: 0.5rem; + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1); +} + +/* Main content layout */ +.processingContent { + display: flex; + align-items: flex-start; + gap: 1.5rem; + padding: 0.5rem 0; +} + +/* Spinner container and animation */ +.spinnerContainer { + flex-shrink: 0; + width: 3.5rem; + height: 3.5rem; + position: relative; +} + +.spinner { + width: 100%; + height: 100%; + animation: rotate 2s linear infinite; +} + +.spinnerTrack { + stroke: #dfe1e2; /* USWDS gray-cool-10 */ + opacity: 0.3; +} + +.spinnerProgress { + stroke: #005ea2; /* USWDS primary blue */ + stroke-linecap: round; + stroke-dasharray: 80, 200; + stroke-dashoffset: 0; + animation: dash 1.5s ease-in-out infinite; + transform-origin: center; +} + +@keyframes rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes dash { + 0% { + stroke-dasharray: 1, 200; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 100, 200; + stroke-dashoffset: -15; + } + 100% { + stroke-dasharray: 100, 200; + stroke-dashoffset: -125; + } +} + +/* Text content */ +.processingText { + flex: 1; + min-width: 0; +} + +.heading { + margin-top: 0; + margin-bottom: 1rem; + color: #1b1b1b; /* USWDS ink */ +} + +/* Description text */ +.description { + margin: 0 0 1.5rem 0; + font-size: 0.9375rem; /* 15px - USWDS body size */ + line-height: 1.5; + color: #1b1b1b; /* USWDS ink */ +} + +/* Time information */ +.timeInfo { + padding: 1rem; + background-color: #f0f0f0; /* USWDS gray-cool-5 */ + border-radius: 0.25rem; + border-left: 4px solid #005ea2; +} + +.elapsedTime { + margin: 0 0 0.5rem 0; + font-size: 0.9375rem; + color: #1b1b1b; +} + +.elapsedTime strong { + font-weight: 700; +} + +.warningText { + margin: 0.5rem 0 0 0; + font-size: 0.875rem; /* 14px - USWDS small text */ + color: #b50909; /* USWDS red-warm-60v */ + font-weight: 600; +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .processingContent { + flex-direction: column; + gap: 1rem; + } + + .spinnerContainer { + width: 3rem; + height: 3rem; + } +} diff --git a/packages/design/src/FormManager/FormProcessingIndicator/index.ts b/packages/design/src/FormManager/FormProcessingIndicator/index.ts new file mode 100644 index 00000000..ad1e2f3b --- /dev/null +++ b/packages/design/src/FormManager/FormProcessingIndicator/index.ts @@ -0,0 +1 @@ +export { FormProcessingIndicator } from './FormProcessingIndicator.js'; diff --git a/packages/design/src/FormManager/hooks.ts b/packages/design/src/FormManager/hooks.ts index 34bae99b..f6b53096 100644 --- a/packages/design/src/FormManager/hooks.ts +++ b/packages/design/src/FormManager/hooks.ts @@ -18,3 +18,6 @@ export const useRouteParams = (): { pathname: location.pathname, }; }; + +export { useFormStatus } from './hooks/useFormStatus.js'; +export { useBlueprintWithStatus } from './hooks/useBlueprintWithStatus.js'; diff --git a/packages/design/src/FormManager/hooks/useBlueprintWithStatus.ts b/packages/design/src/FormManager/hooks/useBlueprintWithStatus.ts new file mode 100644 index 00000000..513c7ead --- /dev/null +++ b/packages/design/src/FormManager/hooks/useBlueprintWithStatus.ts @@ -0,0 +1,99 @@ +import { useState, useEffect, useRef } from 'react'; +import type { + Blueprint, + FormService, + FormStatusResponse, +} from '@flexion/forms-core'; +import { useFormStatus } from './useFormStatus.js'; + +type UseBlueprintWithStatusResult = { + form: Blueprint | null; + status: FormStatusResponse | null; + isLoading: boolean; + isProcessing: boolean; + error: string | null; +}; + +/** + * Combined hook that loads a form blueprint and monitors its processing status. + * Returns both the form data and status information, automatically polling + * when the form is being processed. + */ +export const useBlueprintWithStatus = ( + formId: string | undefined, + formService: FormService +): UseBlueprintWithStatusResult => { + const [form, setForm] = useState(null); + const [formError, setFormError] = useState(null); + const [formLoading, setFormLoading] = useState(true); + const previousStatusRef = useRef(null); + + const { + status, + isLoading: statusLoading, + error: statusError, + } = useFormStatus(formId, formService); + + const currentStatus = status?.latestJob?.status || null; + + // Load the form initially + useEffect(() => { + if (!formId) { + setFormLoading(false); + return; + } + + setFormLoading(true); + formService.getForm(formId).then(result => { + if (result.success) { + setForm(result.data); + setFormError(null); + } else { + console.error('Error loading form', result.error); + setFormError('Failed to load form'); + } + setFormLoading(false); + }); + }, [formId]); + + // Reload form when processing completes (transition from 'processing' to 'completed') + useEffect(() => { + const previousStatus = previousStatusRef.current; + const hasTransitionedToCompleted = + previousStatus === 'processing' && currentStatus === 'completed'; + + if (hasTransitionedToCompleted && formId) { + console.log('Processing completed, reloading form...'); + setFormLoading(true); + formService.getForm(formId).then(result => { + if (result.success) { + console.log( + 'Form reloaded successfully with patterns:', + Object.keys(result.data.patterns).length + ); + setForm(result.data); + setFormError(null); + } else { + console.error('Failed to reload form:', result.error); + setFormError('Failed to load form'); + } + setFormLoading(false); + }); + } + + // Update the previous status ref + previousStatusRef.current = currentStatus; + }, [currentStatus, formId]); + + const isProcessing = currentStatus === 'processing'; + const isLoading = formLoading || statusLoading; + const error = formError || statusError; + + return { + form, + status, + isLoading, + isProcessing, + error, + }; +}; diff --git a/packages/design/src/FormManager/hooks/useFormStatus.ts b/packages/design/src/FormManager/hooks/useFormStatus.ts new file mode 100644 index 00000000..6375d036 --- /dev/null +++ b/packages/design/src/FormManager/hooks/useFormStatus.ts @@ -0,0 +1,111 @@ +import { useState, useEffect, useRef } from 'react'; +import type { FormService, FormStatusResponse } from '@flexion/forms-core'; + +type UseFormStatusResult = { + status: FormStatusResponse | null; + isLoading: boolean; + error: string | null; + refetch: () => void; +}; + +/** + * Hook to poll form status. Automatically refreshes when form is processing. + * Stops polling when processing is complete or failed. + */ +export const useFormStatus = ( + formId: string | undefined, + formService: FormService, + options?: { + pollInterval?: number; // milliseconds, defaults to 2000 (2 seconds) + enabled?: boolean; // whether to poll, defaults to true + } +): UseFormStatusResult => { + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const pollIntervalRef = useRef(null); + const mountedRef = useRef(true); + + const pollInterval = options?.pollInterval ?? 2000; + const enabled = options?.enabled ?? true; + + const fetchStatus = async () => { + if (!formId || !enabled) { + return; + } + + try { + const result = await formService.getFormStatus(formId); + + if (!mountedRef.current) { + return; + } + + if (result.success) { + setStatus(result.data); + setError(null); + } else { + setError(result.error.message); + } + } catch (err) { + if (!mountedRef.current) { + return; + } + setError((err as Error).message); + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }; + + const shouldPoll = (currentStatus: FormStatusResponse | null): boolean => { + if (!currentStatus?.latestJob) { + return false; + } + return currentStatus.latestJob.status === 'processing'; + }; + + useEffect(() => { + mountedRef.current = true; + + // Initial fetch + fetchStatus(); + + return () => { + mountedRef.current = false; + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [formId, enabled]); + + // Set up polling based on status + useEffect(() => { + // Clear any existing interval + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + + // Only poll if we should + if (shouldPoll(status)) { + pollIntervalRef.current = setInterval(() => { + fetchStatus(); + }, pollInterval); + } + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [status, pollInterval, enabled]); + + return { + status, + isLoading, + error, + refetch: fetchStatus, + }; +}; diff --git a/packages/design/src/experiments/document-assembler.tsx b/packages/design/src/experiments/document-assembler.tsx deleted file mode 100644 index 55118680..00000000 --- a/packages/design/src/experiments/document-assembler.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { useState } from 'react'; - -import { generateDummyPDF } from '@flexion/forms-core'; - -export const downloadPdfBytes = (bytes: Uint8Array) => { - const base64 = btoa(String.fromCharCode(...bytes)); - const element = document.createElement('a'); - element.setAttribute( - 'href', - 'data:application/pdf;base64,' + encodeURIComponent(base64) - ); - element.setAttribute('download', 'sample-document.pdf'); - element.style.display = 'none'; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); -}; - -const generatePDF = async () => { - const timestamp = new Date().toISOString(); - const pdfBytes = await generateDummyPDF({ timestamp }); - downloadPdfBytes(pdfBytes); -}; - -const previewPDF = async (setPreviewPdfUrl: (url: string) => void) => { - const timestamp = new Date().toISOString(); - const pdfBytes = await generateDummyPDF({ timestamp }); - const pdfBlob = new Blob([pdfBytes], { type: 'application/pdf' }); - setPreviewPdfUrl(URL.createObjectURL(pdfBlob)); -}; - -export const DocumentAssembler = () => { - const [previewPdfUrl, setPreviewPdfUrl] = useState(); - return ( -
- - -
- {previewPdfUrl ? ( - - ) : null} -
-
- ); -}; diff --git a/apps/cli/__fixtures__/ai-cache/8f/8fd9b9039c736540e0f3becade55e2d36a3d5afe3f448c8efdb86b10525cdfd4.json b/packages/forms/fixtures/ai-cache/8f/8fd9b9039c736540e0f3becade55e2d36a3d5afe3f448c8efdb86b10525cdfd4.json similarity index 100% rename from apps/cli/__fixtures__/ai-cache/8f/8fd9b9039c736540e0f3becade55e2d36a3d5afe3f448c8efdb86b10525cdfd4.json rename to packages/forms/fixtures/ai-cache/8f/8fd9b9039c736540e0f3becade55e2d36a3d5afe3f448c8efdb86b10525cdfd4.json diff --git a/packages/forms/fixtures/ai-cache/a3/a353b2de50409d3a56d22762b1d1a46dde3065a938de55877960826eff5c2b01.json b/packages/forms/fixtures/ai-cache/a3/a353b2de50409d3a56d22762b1d1a46dde3065a938de55877960826eff5c2b01.json new file mode 100644 index 00000000..2c34744a --- /dev/null +++ b/packages/forms/fixtures/ai-cache/a3/a353b2de50409d3a56d22762b1d1a46dde3065a938de55877960826eff5c2b01.json @@ -0,0 +1,399 @@ +{ + "object": { + "form_summary": { + "title": "Application for Certificate of Pardon for Marijuana Offenses", + "description": "Apply for a certificate of pardon for federal offenses of simple possession, attempted possession, or use of marijuana under presidential proclamations." + }, + "pages": [ + { + "title": "Introduction", + "elements": [ + { + "component_type": "rich_text", + "text": "

Application for Certificate of Pardon for Marijuana Offenses

On October 6, 2022, and December 22, 2023, President Biden issued presidential proclamations pardoning federal and D.C. offenses for simple marijuana possession, attempted possession, and use.

How a Pardon Can Help You

A pardon is an expression of the President's forgiveness. It does not mean you are innocent or expunge your conviction, but it does remove civil disabilities such as restrictions on voting, holding office, or serving on a jury. It may also help with obtaining licenses, bonding, or employment.

You Qualify If:

  • On or before December 22, 2023, you were charged with or convicted of simple possession, attempted possession, or use of marijuana under federal code, D.C. code, or Code of Federal Regulations
  • You were a U.S. citizen or lawfully present in the United States at the time of the offense
  • You were a U.S. citizen or lawful permanent resident on December 22, 2023
" + }, + { + "component_type": "rich_text", + "text": "

What You'll Need

About You: Personal details including name, citizenship status, and contact information (mailing address and/or email).

About the Charge or Conviction: Whether it was a charge or conviction, court district, date, and if possible, case information (docket number, code section) and supporting documents.

Important: Submit a separate form for each conviction or charge. Without complete information, we cannot guarantee we can determine your eligibility.

" + }, + { + "component_type": "paragraph", + "text": "The estimated time to complete this application is 120 minutes. Information regarding gender, race, or ethnicity is optional and will not affect processing." + } + ] + }, + { + "title": "Personal Information", + "elements": [ + { + "component_type": "paragraph", + "text": "Provide your current legal name." + }, + { + "component_type": "fieldset", + "legend": "Current Name", + "fields": [ + { + "component_type": "text_input", + "id": "Fst Name 1", + "label": "First Name", + "required": true + }, + { + "component_type": "text_input", + "id": "Mid Name 1", + "label": "Middle Name", + "required": false + }, + { + "component_type": "text_input", + "id": "Lst Name 1", + "label": "Last Name", + "required": true + } + ] + }, + { + "component_type": "paragraph", + "text": "If your name was different at the time of conviction, provide that name below." + }, + { + "component_type": "fieldset", + "legend": "Name at Conviction (if different)", + "fields": [ + { + "component_type": "text_input", + "id": "Conv Fst Name", + "label": "First Name", + "required": false + }, + { + "component_type": "text_input", + "id": "Conv Mid Name", + "label": "Middle Name", + "required": false + }, + { + "component_type": "text_input", + "id": "Conv Lst Name", + "label": "Last Name", + "required": false + } + ] + }, + { + "component_type": "text_input", + "id": "Date of Birth", + "label": "Date of Birth (MM/DD/YYYY)", + "required": true + }, + { + "component_type": "text_input", + "id": "Gender", + "label": "Gender (optional)", + "required": false + } + ] + }, + { + "title": "Contact Information", + "elements": [ + { + "component_type": "paragraph", + "text": "Provide your mailing address and/or email address. We strongly recommend including an email address for faster communication." + }, + { + "component_type": "fieldset", + "legend": "Mailing Address", + "fields": [ + { + "component_type": "text_input", + "id": "Address", + "label": "Street Address (number, street, apartment/unit)", + "required": false + }, + { + "component_type": "text_input", + "id": "City", + "label": "City", + "required": false + }, + { + "component_type": "text_input", + "id": "State", + "label": "State", + "required": false + }, + { + "component_type": "text_input", + "id": "Zip Code", + "label": "ZIP Code", + "required": false + } + ] + }, + { + "component_type": "text_input", + "id": "Email Address", + "label": "Email Address", + "required": false + }, + { + "component_type": "text_input", + "id": "Phone Number", + "label": "Phone Number", + "required": false + } + ] + }, + { + "title": "Demographics", + "elements": [ + { + "component_type": "paragraph", + "text": "The following demographic information is optional and will not affect the processing of your application." + }, + { + "component_type": "radio_group", + "id": "Ethnicity.undefined", + "legend": "Are you Hispanic or Latino?", + "options": [ + { + "id": "Ethnicity.0", + "label": "Yes", + "name": "Ethnicity.undefined", + "default_checked": false + }, + { + "id": "Ethnicity.1", + "label": "No", + "name": "Ethnicity.undefined", + "default_checked": false + } + ] + }, + { + "component_type": "checkbox_group", + "legend": "Race (check all that apply)", + "options": [ + { + "id": "Nat Amer", + "label": "Alaska Native or American Indian", + "default_checked": false + }, + { + "id": "Asian", + "label": "Asian", + "default_checked": false + }, + { + "id": "Blck Amer", + "label": "Black or African American", + "default_checked": false + }, + { + "id": "Nat Haw Islander", + "label": "Native Hawaiian or Other Pacific Islander", + "default_checked": false + }, + { + "id": "White", + "label": "White", + "default_checked": false + }, + { + "id": "Other", + "label": "Other", + "default_checked": false + } + ] + } + ] + }, + { + "title": "Citizenship and Residency", + "elements": [ + { + "component_type": "paragraph", + "text": "Select your citizenship or residency status. If you are a naturalized citizen or lawful permanent resident, provide the relevant dates and documentation numbers." + }, + { + "component_type": "radio_group", + "id": "Citizenship.undefined", + "legend": "Citizenship or Residency Status", + "options": [ + { + "id": "Citizenship.0", + "label": "U.S. citizen by birth", + "name": "Citizenship.undefined", + "default_checked": false + }, + { + "id": "Citizenship.1", + "label": "U.S. naturalized citizen", + "name": "Citizenship.undefined", + "default_checked": false + }, + { + "id": "Citizenship.2", + "label": "Lawful Permanent Resident", + "name": "Citizenship.undefined", + "default_checked": false + } + ] + }, + { + "component_type": "text_input", + "id": "Naturalization Date_af_date", + "label": "Date Naturalization Granted (if applicable)", + "required": false + }, + { + "component_type": "text_input", + "id": "Residency Date_af_date", + "label": "Date Residency Granted (if applicable)", + "required": false + }, + { + "component_type": "text_input", + "id": "A-Number", + "label": "Alien Registration Number (A-Number), Certificate of Naturalization Number, or Citizenship Number (if applicable)", + "required": false + } + ] + }, + { + "title": "Conviction Information", + "elements": [ + { + "component_type": "paragraph", + "text": "Complete this section if you were CONVICTED of simple possession, attempted possession, or use of marijuana. Provide the conviction date, court information, docket number, and code section." + }, + { + "component_type": "text_input", + "id": "Convict-Date_af_date", + "label": "Date of Conviction (MM/DD/YYYY)", + "required": false + }, + { + "component_type": "text_input", + "id": "US District Court", + "label": "U.S. District Court (e.g., Northern, Eastern, Southern, Western)", + "required": false + }, + { + "component_type": "text_input", + "id": "Dist State", + "label": "District of (State)", + "required": false + }, + { + "component_type": "checkbox", + "id": "D.C. Superior Court 1", + "label": "D.C. Superior Court (check if applicable instead of U.S. District Court)", + "default_checked": false + }, + { + "component_type": "text_input", + "id": "Docket No", + "label": "Docket Number", + "required": false + }, + { + "component_type": "text_input", + "id": "Code Section", + "label": "Code Section", + "required": false + } + ] + }, + { + "title": "Charge Information", + "elements": [ + { + "component_type": "paragraph", + "text": "Complete this section if you were CHARGED (but not convicted) with simple possession, attempted possession, or use of marijuana. Provide the court information, code section, and docket number." + }, + { + "component_type": "text_input", + "id": "Code Section_2", + "label": "Code Section", + "required": false + }, + { + "component_type": "text_input", + "id": "US District Court_2", + "label": "U.S. District Court (e.g., Northern, Eastern, Southern, Western)", + "required": false + }, + { + "component_type": "text_input", + "id": "District 2", + "label": "District of (State)", + "required": false + }, + { + "component_type": "checkbox", + "id": "D.C. Superior Court 2", + "label": "D.C. Superior Court (check if applicable instead of U.S. District Court)", + "default_checked": false + }, + { + "component_type": "text_input", + "id": "Docket No 2", + "label": "Docket Number", + "required": false + } + ] + }, + { + "title": "Certification and Signature", + "elements": [ + { + "component_type": "rich_text", + "text": "

Certification

With knowledge of the penalties for false statements to Federal Agencies, as provided by 18 U.S.C. § 1001, and with knowledge that this statement is submitted to affect action by the U.S. Department of Justice, I certify that:

  1. The applicant was either a U.S. citizen or lawfully present in the United States at the time of the offense.
  2. The applicant was a U.S. citizen or lawful permanent resident on December 22, 2023.
  3. The above statements, and accompanying documents, are true and complete to the best of my knowledge, information, and belief.
  4. I acknowledge that any certificate issued in reliance on the above information will be voided if the information is subsequently determined to be false.
" + }, + { + "component_type": "text_input", + "id": "App Date", + "label": "Date (MM/DD/YYYY)", + "required": true + }, + { + "component_type": "paragraph", + "text": "After completing this form, sign and submit it with any supporting documents to USPardon.Attorney@usdoj.gov or mail to: U.S. Department of Justice, Office of the Pardon Attorney, 950 Pennsylvania Avenue NW, Washington, DC 20530." + } + ] + } + ] + }, + "finishReason": "tool-calls", + "usage": { + "inputTokens": 8233, + "outputTokens": 3532, + "totalTokens": 11765, + "cachedInputTokens": 0 + }, + "warnings": [], + "providerMetadata": { + "bedrock": { + "usage": { + "cacheWriteInputTokens": 0 + }, + "isJsonResponseFromTool": true + } + }, + "response": { + "id": "aiobj-lBGkNnDR0D5WSAaylwbnO2hf", + "timestamp": "2025-10-09T05:12:43.629Z", + "modelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "headers": { + "connection": "keep-alive", + "content-length": "9747", + "content-type": "application/json", + "date": "Thu, 09 Oct 2025 05:12:43 GMT", + "x-amzn-requestid": "abf19a6e-cba7-49cd-bf04-5434f3ac2a94" + } + }, + "request": {} +} \ No newline at end of file diff --git a/packages/forms/package.json b/packages/forms/package.json index 7c879d00..4347d429 100644 --- a/packages/forms/package.json +++ b/packages/forms/package.json @@ -12,19 +12,40 @@ }, "exports": { ".": { + "development": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" }, "./context": { + "development": { + "types": "./src/context/index.ts", + "import": "./src/context/index.ts" + }, "types": "./dist/types/context/index.d.ts", "import": "./dist/esm/context.js", "require": "./dist/cjs/context.js" }, "./documents/pdf/context": { + "development": { + "types": "./src/documents/pdf/context.ts", + "import": "./src/documents/pdf/context.ts" + }, "types": "./dist/types/documents/pdf/context.d.ts", "import": "./dist/esm/documents/pdf/context.js", "require": "./dist/cjs/documents/pdf/context.js" + }, + "./repository": { + "development": { + "types": "./src/repository/index.ts", + "import": "./src/repository/index.ts" + }, + "types": "./dist/types/repository/index.d.ts", + "import": "./dist/esm/repository.js", + "require": "./dist/cjs/repository.js" } }, "scripts": { diff --git a/packages/forms/rollup.config.js b/packages/forms/rollup.config.js index a6d02662..3125c2c5 100644 --- a/packages/forms/rollup.config.js +++ b/packages/forms/rollup.config.js @@ -15,6 +15,7 @@ export default { index: 'src/index.ts', context: 'src/context/index.ts', 'documents/pdf/context': 'src/documents/pdf/context.ts', + repository: 'src/repository/index.ts', }, output: [ { diff --git a/packages/forms/src/blueprint.ts b/packages/forms/src/blueprint.ts index b9c61887..5de67c96 100644 --- a/packages/forms/src/blueprint.ts +++ b/packages/forms/src/blueprint.ts @@ -710,14 +710,33 @@ export const addFormOutput = ( /** * Updates the summary of a given form with the provided summary details. + * Also updates the form-summary pattern to keep them in sync. */ export const updateFormSummary = ( form: Blueprint, summary: FormSummary ): Blueprint => { + // Find the form-summary pattern and update it + const formSummaryPatternEntry = Object.entries(form.patterns).find( + ([_, pattern]) => pattern.type === 'form-summary' + ); + + const updatedPatterns = { ...form.patterns }; + if (formSummaryPatternEntry) { + const [patternId, pattern] = formSummaryPatternEntry; + updatedPatterns[patternId] = { + ...pattern, + data: { + ...pattern.data, + ...summary, + }, + }; + } + return { ...form, summary, + patterns: updatedPatterns, }; }; diff --git a/packages/forms/src/builder/parse-form.test.ts b/packages/forms/src/builder/parse-form.test.ts index 6748256a..72a40704 100644 --- a/packages/forms/src/builder/parse-form.test.ts +++ b/packages/forms/src/builder/parse-form.test.ts @@ -139,11 +139,11 @@ describe('parseFormString', () => { '[\n' + ' {\n' + ' "code": "custom",\n' + - ' "message": "Invalid pattern",\n' + ' "path": [\n' + ' "patterns",\n' + ' "invalidPattern"\n' + - ' ]\n' + + ' ],\n' + + ' "message": "Invalid pattern"\n' + ' }\n' + ']', }); diff --git a/packages/forms/src/context/browser/form-repo.ts b/packages/forms/src/context/browser/form-repo.ts index 84fa8bef..c3bc138a 100644 --- a/packages/forms/src/context/browser/form-repo.ts +++ b/packages/forms/src/context/browser/form-repo.ts @@ -144,7 +144,7 @@ export class BrowserFormRepository implements FormRepository { addDocument(document: { fileName: string; data: Uint8Array; - extract: { parsedPdf: ParsedPdf; fields: DocumentFieldMap }; + extract?: { parsedPdf: ParsedPdf; fields: DocumentFieldMap }; }) { const documentId = crypto.randomUUID(); const data = uint8ArrayToBase64(document.data); @@ -155,7 +155,7 @@ export class BrowserFormRepository implements FormRepository { type: 'pdf', file_name: document.fileName, data, - extract: JSON.stringify(document.extract), + extract: document.extract ? JSON.stringify(document.extract) : '', }) ); return Promise.resolve( @@ -183,6 +183,33 @@ export class BrowserFormRepository implements FormRepository { data: base64ToUint8Array(json.data), }); } + + // Job methods are not supported in browser context + createFormJob(): Promise { + return Promise.resolve( + failure('Job management is not supported in browser context') + ); + } + + completeFormJob(): Promise { + return Promise.resolve( + failure('Job management is not supported in browser context') + ); + } + + failFormJob(): Promise { + return Promise.resolve( + failure('Job management is not supported in browser context') + ); + } + + getLatestFormJob(): Promise { + return Promise.resolve(success(null)); + } + + getFormJobs(): Promise { + return Promise.resolve(success([])); + } } /** diff --git a/packages/forms/src/context/index.ts b/packages/forms/src/context/index.ts index 92bb4ebf..a0e52b57 100644 --- a/packages/forms/src/context/index.ts +++ b/packages/forms/src/context/index.ts @@ -23,6 +23,7 @@ export type FormServiceContext = { repository: FormRepository; config: FormConfig; isUserLoggedIn: () => boolean; + getUserId?: () => string; parser: PdfParser; }; diff --git a/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts b/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts index 9dbdb00b..b8b3cd92 100644 --- a/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts +++ b/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts @@ -81,49 +81,42 @@ describe('DOJ Pardon Attorney Office - Marijuana pardon application form', () => }); }); - // Only run if AWS credentials available - const skipIfNoCredentials = process.env.AWS_ACCESS_KEY_ID ? test : test.skip; - - skipIfNoCredentials( - 'generates guided interview from PDF via Bedrock', - async () => { - const pdfBytes = await loadSamplePDF( - 'doj-pardon-marijuana/demo-application_for_certificate_of_pardon_for_simple_marijuana_possession.pdf' - ); + test('generates guided interview from PDF via Bedrock', async () => { + const pdfBytes = await loadSamplePDF( + 'doj-pardon-marijuana/demo-application_for_certificate_of_pardon_for_simple_marijuana_possession.pdf' + ); - const { createTestPdfParser } = await import('../pdf/context.js'); - const { defaultFormConfig } = await import('../../patterns/index.js'); - const parser = createTestPdfParser(); - const result = await parsePdf( - { parser, formConfig: defaultFormConfig }, - pdfBytes - ); - const { parsedPdf, fields } = result; + const { createTestPdfParser } = await import('../pdf/context.js'); + const { defaultFormConfig } = await import('../../patterns/index.js'); + const parser = createTestPdfParser(); + const result = await parsePdf( + { parser, formConfig: defaultFormConfig }, + pdfBytes + ); + const { parsedPdf, fields } = result; - // Should create valid pattern structure - expect(parsedPdf.root).toBe('root'); - expect(parsedPdf.patterns['root']).toBeDefined(); - expect(parsedPdf.errors.length).toBe(0); + // Should create valid pattern structure + expect(parsedPdf.root).toBe('root'); + expect(parsedPdf.patterns['root']).toBeDefined(); + expect(parsedPdf.errors.length).toBe(0); - // Should organize into pages - const rootPattern = parsedPdf.patterns['root'] as PageSetPattern; - expect(rootPattern.data.pages.length).toBeGreaterThan(0); + // Should organize into pages + const rootPattern = parsedPdf.patterns['root'] as PageSetPattern; + expect(rootPattern.data.pages.length).toBeGreaterThan(0); - // Should maintain field mappings - const fieldNames = Object.values(parsedPdf.outputs).map( - output => output.name - ); - expect(fieldNames.length).toBeGreaterThan(0); + // Should maintain field mappings + const fieldNames = Object.values(parsedPdf.outputs).map( + output => output.name + ); + expect(fieldNames.length).toBeGreaterThan(0); - // Should have a title and description - expect(parsedPdf.title).toBeTruthy(); - expect(parsedPdf.description).toBeTruthy(); + // Should have a title and description + expect(parsedPdf.title).toBeTruthy(); + expect(parsedPdf.description).toBeTruthy(); - // Should also extract raw field data - expect(Object.keys(fields).length).toBeGreaterThan(0); - }, - 30000 - ); // Longer timeout for LLM call + // Should also extract raw field data + expect(Object.keys(fields).length).toBeGreaterThan(0); + }, 300000); // 5 minute timeout for initial Bedrock call and caching }); const getFieldByName = (fields: DocumentFieldMap, name: string) => { diff --git a/packages/forms/src/documents/pdf/context.ts b/packages/forms/src/documents/pdf/context.ts index 07744a54..b1bad74f 100644 --- a/packages/forms/src/documents/pdf/context.ts +++ b/packages/forms/src/documents/pdf/context.ts @@ -29,19 +29,20 @@ export const createProductionPdfParser = (db: DatabaseContext): PdfParser => { * Creates a test PDF parser with filesystem-backed LLM caching (VCR pattern). * Enables "record once, replay forever" testing workflow. * - * @param cachePath - Directory path for storing cached LLM responses + * By default, uses a shared cache directory at the workspace root to ensure + * all tests and CLI tools can share cached Bedrock responses. + * + * @param cachePath - Directory path for storing cached LLM responses (defaults to workspace root) * @returns PdfParser configured for testing * * @example * ```typescript - * const parser = createTestPdfParser('__fixtures__/ai-cache'); - * // First run: records live Bedrock API response to disk - * // Subsequent runs: replays from disk, no API calls + * const parser = createTestPdfParser(); + * // First run: records live Bedrock API response to workspace root cache + * // Subsequent runs: replays from shared cache, no API calls * ``` */ -export const createTestPdfParser = ( - cachePath: string = '__fixtures__/ai-cache' -): PdfParser => { +export const createTestPdfParser = (cachePath?: string): PdfParser => { const llmContext = createTestLlmContext(cachePath); return createBedrockParser(llmContext); }; diff --git a/packages/forms/src/documents/pdf/scripts/parse-pdf-with-bedrock.ts b/packages/forms/src/documents/pdf/scripts/parse-pdf-with-bedrock.ts index 27c4b106..a4d3b69a 100644 --- a/packages/forms/src/documents/pdf/scripts/parse-pdf-with-bedrock.ts +++ b/packages/forms/src/documents/pdf/scripts/parse-pdf-with-bedrock.ts @@ -43,8 +43,8 @@ async function main() { console.log('Invoking Bedrock...'); const startTime = Date.now(); - // Use test LLM context with filesystem caching - const llmContext = createTestLlmContext('__fixtures__/ai-cache'); + // Use test LLM context with filesystem caching (shared workspace root) + const llmContext = createTestLlmContext(); const parser = createBedrockParser(llmContext); const result = await parser.parse( new Uint8Array(pdfBytes), diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index 9312eb9c..cf79f766 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -11,6 +11,11 @@ export * from './types.js'; export * from './util/base64.js'; export * from './patterns/address/jurisdictions.js'; export { type FormService, createFormService } from './services/index.js'; +export { + type FormStatusResponse, + type GetFormStatusError, +} from './services/get-form-status.js'; +export { type FormListItem } from './services/get-form-list.js'; export { defaultFormConfig, attachmentFileTypeOptions, @@ -23,10 +28,6 @@ import { type SequencePattern } from './patterns/sequence.js'; import { FieldsetPattern } from './patterns/index.js'; import { type FormSummaryPattern } from './patterns/form-summary/form-summary.js'; import { RepeaterPattern } from './patterns/index.js'; -export { - type FormRepository, - createFormsRepository, -} from './repository/index.js'; export { type FormRoute, type RouteData, diff --git a/packages/forms/src/llm/cache/backends/database.test.ts b/packages/forms/src/llm/cache/backends/database.test.ts new file mode 100644 index 00000000..f31a7cd0 --- /dev/null +++ b/packages/forms/src/llm/cache/backends/database.test.ts @@ -0,0 +1,69 @@ +import { expect, it } from 'vitest'; +import { + type DbTestContext, + describeDatabase, +} from '@flexion/forms-database/testing'; +import { DatabaseCache } from './database.js'; +import { testCacheSpecification } from '../cache-spec.js'; + +describeDatabase('DatabaseCache', () => { + it('passes shared cache specification', async ({ db }) => { + const cache = new DatabaseCache(db.ctx); + await testCacheSpecification(cache); + }); + + it('tracks access statistics on cache hits', async ({ + db, + }) => { + const cache = new DatabaseCache(db.ctx); + await cache.set('key', 'value'); + + const kysely = await db.ctx.getKysely(); + const initial = await kysely + .selectFrom('llm_request_cache') + .select('access_count') + .where('cache_key', '=', 'key') + .executeTakeFirst(); + + expect(initial?.access_count).toBe(1); + + // Trigger cache hit (stats update is async) + await cache.get('key'); + await new Promise(resolve => setTimeout(resolve, 100)); + + const updated = await kysely + .selectFrom('llm_request_cache') + .select('access_count') + .where('cache_key', '=', 'key') + .executeTakeFirst(); + + expect(updated?.access_count).toBe(2); + }, 10000); + + it('handles concurrent writes to same key', async ({ db }) => { + const cache = new DatabaseCache(db.ctx); + const key = 'concurrent'; + + // SQLite: sequential writes; Postgres: concurrent writes + if (db.engine === 'sqlite') { + for (let i = 0; i < 5; i++) { + await cache.set(key, `v${i}`); + } + } else { + await Promise.all( + Array.from({ length: 5 }, (_, i) => cache.set(key, `v${i}`)) + ); + } + + // Should have exactly one entry + const kysely = await db.ctx.getKysely(); + const count = await kysely + .selectFrom('llm_request_cache') + .select(kysely.fn.count('id').as('count')) + .where('cache_key', '=', key) + .executeTakeFirst(); + + expect(Number(count?.count)).toBe(1); + expect(await cache.get(key)).toMatch(/^v[0-4]$/); + }, 10000); +}); diff --git a/packages/forms/src/llm/cache/database.ts b/packages/forms/src/llm/cache/backends/database.ts similarity index 89% rename from packages/forms/src/llm/cache/database.ts rename to packages/forms/src/llm/cache/backends/database.ts index da1e55ef..96e03170 100644 --- a/packages/forms/src/llm/cache/database.ts +++ b/packages/forms/src/llm/cache/backends/database.ts @@ -1,7 +1,7 @@ import { sql } from 'kysely'; -import { type DatabaseContext } from '@flexion/forms-database'; -import type { AiRequestCache } from './types.js'; +import { type DatabaseContext, dateValue } from '@flexion/forms-database'; +import type { AiRequestCache } from '../types.js'; /** * Database-backed cache for production use. @@ -34,7 +34,7 @@ export class DatabaseCache implements AiRequestCache { async set(key: string, value: T): Promise { const kysely = await this.db.getKysely(); - const now = new Date(); + const now = dateValue(this.db.engine, new Date()); await kysely .insertInto('llm_request_cache') @@ -69,7 +69,7 @@ export class DatabaseCache implements AiRequestCache { await kysely .updateTable('llm_request_cache') .set({ - accessed_at: new Date(), + accessed_at: dateValue(this.db.engine, new Date()), access_count: sql`access_count + 1`, }) .where('cache_key', '=', key) diff --git a/packages/forms/src/llm/cache/backends/filesystem.test.ts b/packages/forms/src/llm/cache/backends/filesystem.test.ts new file mode 100644 index 00000000..682e8c22 --- /dev/null +++ b/packages/forms/src/llm/cache/backends/filesystem.test.ts @@ -0,0 +1,81 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FilesystemCache } from './filesystem.js'; +import { testCacheSpecification } from '../cache-spec.js'; + +describe('FilesystemCache', () => { + let tempDir: string; + let cache: FilesystemCache; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cache-test-')); + cache = new FilesystemCache(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('passes shared cache specification', async () => { + await testCacheSpecification(cache); + }); + + it('organizes cache files by prefix', async () => { + await cache.set('abcdef123', 'test-value'); + + const filePath = path.join(tempDir, 'ab', 'abcdef123.json'); + const exists = await fs + .access(filePath) + .then(() => true) + .catch(() => false); + + expect(exists).toBe(true); + }); + + it('stores JSON in minified format by default', async () => { + const key = 'minified'; + const value = { foo: 'bar', nested: { value: 123 } }; + + await cache.set(key, value); + + const filePath = path.join(tempDir, key.slice(0, 2), `${key}.json`); + const content = await fs.readFile(filePath, 'utf-8'); + + expect(content).not.toContain('\n '); + expect(JSON.parse(content)).toEqual(value); + }); + + it('stores JSON in pretty format when enabled', async () => { + const prettyCache = new FilesystemCache(tempDir, { pretty: true }); + const key = 'pretty'; + const value = { foo: 'bar' }; + + await prettyCache.set(key, value); + + const filePath = path.join(tempDir, key.slice(0, 2), `${key}.json`); + const content = await fs.readFile(filePath, 'utf-8'); + + expect(content).toContain('\n '); + expect(JSON.parse(content)).toEqual(value); + }); + + it('clear succeeds even if directory does not exist', async () => { + const nonExistentCache = new FilesystemCache( + path.join(tempDir, 'non-existent') + ); + + await expect(nonExistentCache.clear()).resolves.toBeUndefined(); + }); + + it('throws error for corrupt cache files', async () => { + const key = 'corrupt'; + const filePath = path.join(tempDir, key.slice(0, 2), `${key}.json`); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, 'invalid json', 'utf-8'); + + await expect(cache.get(key)).rejects.toThrow(SyntaxError); + }); +}); diff --git a/packages/forms/src/llm/cache/filesystem.ts b/packages/forms/src/llm/cache/backends/filesystem.ts similarity index 97% rename from packages/forms/src/llm/cache/filesystem.ts rename to packages/forms/src/llm/cache/backends/filesystem.ts index 8eb2bba7..d152999d 100644 --- a/packages/forms/src/llm/cache/filesystem.ts +++ b/packages/forms/src/llm/cache/backends/filesystem.ts @@ -1,6 +1,6 @@ import fs from 'fs/promises'; import path from 'path'; -import type { AiRequestCache } from './types.js'; +import type { AiRequestCache } from '../types.js'; /** * Options for configuring filesystem cache behavior. diff --git a/packages/forms/src/llm/cache/noop.ts b/packages/forms/src/llm/cache/backends/noop.ts similarity index 87% rename from packages/forms/src/llm/cache/noop.ts rename to packages/forms/src/llm/cache/backends/noop.ts index d255e592..5d5a0b6c 100644 --- a/packages/forms/src/llm/cache/noop.ts +++ b/packages/forms/src/llm/cache/backends/noop.ts @@ -1,4 +1,4 @@ -import type { AiRequestCache } from './types.js'; +import type { AiRequestCache } from '../types.js'; /** * No-operation cache that never stores or retrieves values. diff --git a/packages/forms/src/llm/cache/cache-spec.ts b/packages/forms/src/llm/cache/cache-spec.ts new file mode 100644 index 00000000..0bb6c497 --- /dev/null +++ b/packages/forms/src/llm/cache/cache-spec.ts @@ -0,0 +1,52 @@ +import { expect } from 'vitest'; +import type { AiRequestCache } from './types.js'; + +/** + * Shared specification for all cache implementations. + * Tests core functionality: get, set, clear, and basic edge cases. + */ +export async function testCacheSpecification(cache: AiRequestCache) { + // Non-existent key + expect(await cache.get('non-existent')).toBeNull(); + + // Basic string value + await cache.set('key1', 'value1'); + expect(await cache.get('key1')).toBe('value1'); + + // Complex object (tests JSON serialization) + const complexValue = { + text: 'Hello', + nested: { array: [1, 2, 3], nullValue: null, bool: true }, + }; + await cache.set('complex', complexValue); + expect(await cache.get('complex')).toEqual(complexValue); + + // Overwriting existing value + await cache.set('key1', 'updated'); + expect(await cache.get('key1')).toBe('updated'); + + // Multiple independent keys + await cache.set('a', 'value-a'); + await cache.set('b', 'value-b'); + expect(await cache.get('a')).toBe('value-a'); + expect(await cache.get('b')).toBe('value-b'); + + // Edge cases: empty string, null, zero, booleans + await cache.set('empty', ''); + await cache.set('null', null); + await cache.set('zero', 0); + await cache.set('true', true); + await cache.set('false', false); + + expect(await cache.get('empty')).toBe(''); + expect(await cache.get('null')).toBeNull(); + expect(await cache.get('zero')).toBe(0); + expect(await cache.get('true')).toBe(true); + expect(await cache.get('false')).toBe(false); + + // Clear removes all entries + await cache.clear(); + expect(await cache.get('key1')).toBeNull(); + expect(await cache.get('a')).toBeNull(); + expect(await cache.get('complex')).toBeNull(); +} diff --git a/packages/forms/src/llm/cache/hash.ts b/packages/forms/src/llm/cache/hash.ts index 42d3f295..92d12d6e 100644 --- a/packages/forms/src/llm/cache/hash.ts +++ b/packages/forms/src/llm/cache/hash.ts @@ -73,8 +73,16 @@ const sha256 = async (data: string): Promise => { /** * Computes SHA-256 hash of a buffer using Web Crypto API. */ -const sha256Buffer = async (data: Uint8Array): Promise => { - const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data); +const sha256Buffer = async ( + data: Uint8Array | ArrayBuffer | ArrayBufferLike +): Promise => { + // Create a new Uint8Array to ensure proper ArrayBuffer backing + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + const normalizedData = new Uint8Array(bytes); + const hashBuffer = await globalThis.crypto.subtle.digest( + 'SHA-256', + normalizedData + ); return Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join(''); diff --git a/packages/forms/src/llm/cache/index.ts b/packages/forms/src/llm/cache/index.ts index 039cb934..d356c18f 100644 --- a/packages/forms/src/llm/cache/index.ts +++ b/packages/forms/src/llm/cache/index.ts @@ -1,5 +1,5 @@ export type { AiRequestCache, CacheMetadata } from './types.js'; export { computeObjectCacheKey } from './hash.js'; -export { DatabaseCache } from './database.js'; -export { FilesystemCache } from './filesystem.js'; -export { NoOpCache } from './noop.js'; +export { DatabaseCache } from './backends/database.js'; +export { FilesystemCache } from './backends/filesystem.js'; +export { NoOpCache } from './backends/noop.js'; diff --git a/packages/forms/src/llm/cache/types.ts b/packages/forms/src/llm/cache/types.ts index 21b13a2c..b9f0f567 100644 --- a/packages/forms/src/llm/cache/types.ts +++ b/packages/forms/src/llm/cache/types.ts @@ -19,9 +19,9 @@ export interface AiRequestCache { /** * Clears all cached entries. - * Optional - primarily for testing. + * Primarily intended for testing. */ - clear?(): Promise; + clear(): Promise; } /** diff --git a/packages/forms/src/llm/services/context.ts b/packages/forms/src/llm/services/context.ts index 726c4b56..baab547d 100644 --- a/packages/forms/src/llm/services/context.ts +++ b/packages/forms/src/llm/services/context.ts @@ -1,8 +1,9 @@ import type { DatabaseContext } from '@flexion/forms-database'; import type { AiRequestCache } from '../cache/types.js'; -import { DatabaseCache } from '../cache/database.js'; -import { FilesystemCache } from '../cache/filesystem.js'; -import { NoOpCache } from '../cache/noop.js'; +import { DatabaseCache } from '../cache/backends/database.js'; +import { FilesystemCache } from '../cache/backends/filesystem.js'; +import { NoOpCache } from '../cache/backends/noop.js'; +import { getDefaultCachePath } from '../../util/workspace-root.js'; /** * Context for LLM operations. @@ -37,22 +38,25 @@ export const createProductionLlmContext = ( * Creates a test LLM context with filesystem-backed caching (VCR pattern). * Enables "record once, replay forever" testing workflow. * - * @param cachePath - Directory path for storing cached responses + * By default, uses a shared cache directory at the workspace root to ensure + * all tests and CLI tools can share cached responses. + * + * @param cachePath - Directory path for storing cached responses (defaults to workspace root) * @param pretty - Whether to pretty-print JSON files (default: true) * @returns LlmContext configured for testing * * @example * ```typescript - * const llmContext = createTestLlmContext('__fixtures__/ai-cache'); - * // First run: records live API response to disk - * // Subsequent runs: replays from disk, no API calls + * const llmContext = createTestLlmContext(); + * // First run: records live API response to workspace root cache + * // Subsequent runs: replays from shared cache, no API calls * ``` */ export const createTestLlmContext = ( - cachePath: string = '__fixtures__/ai-cache', + cachePath?: string, pretty: boolean = true ): LlmContext => ({ - cache: new FilesystemCache(cachePath, { pretty }), + cache: new FilesystemCache(cachePath ?? getDefaultCachePath(), { pretty }), }); /** diff --git a/packages/forms/src/patterns/phone-number/phone-number.ts b/packages/forms/src/patterns/phone-number/phone-number.ts index f01824ad..4e2f948e 100644 --- a/packages/forms/src/patterns/phone-number/phone-number.ts +++ b/packages/forms/src/patterns/phone-number/phone-number.ts @@ -22,21 +22,37 @@ export type PhoneNumberPatternOutput = z.infer< export const createPhoneSchema = (data: PhoneNumberPattern['data']) => { const phoneSchema = z .string() - .regex(/^(\d{3}-\d{3}-\d{4}|\d{10})$/, { - message: 'Invalid phone number format', + .superRefine((value, ctx) => { + // Allow empty string if not required + if (value === '' && !data.required) { + return; + } + + // Validate format + if (!/^(\d{3}-\d{3}-\d{4}|\d{10})$/.test(value)) { + ctx.addIssue({ + code: 'custom', + message: 'Invalid phone number format', + }); + return; + } + + // Validate length + const digits = value.replace(/[^\d]/g, ''); + if (digits.length !== 10) { + ctx.addIssue({ + code: 'custom', + message: 'Invalid phone number format', + }); + } }) .transform(value => { + if (value === '') { + return value; + } const digits = value.replace(/[^\d]/g, ''); return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`; - }) - .refine(value => { - const digits = value.replace(/[^\d]/g, ''); - return digits.length === 10; - }, 'Phone number must contain exactly 10 digits'); - - if (!data.required) { - return z.union([z.literal(''), phoneSchema]); - } + }); return phoneSchema; }; @@ -64,7 +80,7 @@ export const phoneNumberConfig: PatternConfig< return []; }, - createPrompt(_, session, pattern, options) { + createPrompt(_, session, pattern) { const sessionValue = getFormSessionValue(session, pattern.id); const sessionError = getFormSessionError(session, pattern.id); diff --git a/packages/forms/src/repository/add-document.ts b/packages/forms/src/repository/add-document.ts index 16ac29e1..ec1af5ec 100644 --- a/packages/forms/src/repository/add-document.ts +++ b/packages/forms/src/repository/add-document.ts @@ -9,7 +9,7 @@ export type AddDocument = ( document: { fileName: string; data: Uint8Array; - extract: { + extract?: { parsedPdf: ParsedPdf; fields: DocumentFieldMap; }; @@ -30,7 +30,7 @@ export const addDocument: AddDocument = async (ctx, document) => { type: 'pdf', file_name: document.fileName, data: Buffer.from(document.data), - extract: JSON.stringify(document.extract), + extract: document.extract ? JSON.stringify(document.extract) : '', }) .execute() .then(() => diff --git a/packages/forms/src/repository/form-jobs.test.ts b/packages/forms/src/repository/form-jobs.test.ts new file mode 100644 index 00000000..8a02f9d4 --- /dev/null +++ b/packages/forms/src/repository/form-jobs.test.ts @@ -0,0 +1,273 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { + type DbTestContext, + describeDatabase, +} from '@flexion/forms-database/testing'; +import { createFormJob } from './jobs/create-form-job.js'; +import { completeFormJob } from './jobs/complete-form-job.js'; +import { failFormJob } from './jobs/fail-form-job.js'; +import { getLatestFormJob } from './jobs/get-latest-form-job.js'; +import { getFormJobs } from './jobs/get-form-jobs.js'; +import { addForm } from './add-form.js'; +import { defaultFormConfig } from '../patterns/index.js'; + +describeDatabase('form jobs repository', getDb => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('creates a job in processing state', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + // Create a form first + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Create a job + const jobResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + metadata: { + documentId: 'doc-1', + fileName: 'test.pdf', + userId: 'user-1', + }, + }); + + if (!jobResult.success) { + console.error('Job creation failed:', jobResult.error); + expect.fail(`createFormJob failed: ${jobResult.error}`); + } + expect(jobResult.success).toBe(true); + + expect(jobResult.data.status).toBe('processing'); + expect(jobResult.data.jobType).toBe('import-pdf'); + expect(jobResult.data.formId).toBe(formResult.data.id); + expect(jobResult.data.metadata).toEqual({ + documentId: 'doc-1', + fileName: 'test.pdf', + userId: 'user-1', + }); + }); + + it('completes a job with result data', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + // Create a form + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Create a job + const jobResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + if (!jobResult.success) { + expect.fail('createFormJob failed'); + } + + // Complete the job + const completeResult = await completeFormJob(ctx, jobResult.data.id, { + patternsAdded: 5, + fieldsExtracted: 12, + documentId: 'doc-1', + }); + + expect(completeResult.success).toBe(true); + + // Verify the job was updated + const latestResult = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + if (!latestResult.success || !latestResult.data) { + expect.fail('getLatestFormJob failed'); + } + + expect(latestResult.data.status).toBe('completed'); + expect(latestResult.data.result).toEqual({ + patternsAdded: 5, + fieldsExtracted: 12, + documentId: 'doc-1', + }); + expect(latestResult.data.completedAt).toBeDefined(); + }); + + it('fails a job with error message', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + // Create a form + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Create a job + const jobResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + if (!jobResult.success) { + expect.fail('createFormJob failed'); + } + + // Fail the job + const failResult = await failFormJob(ctx, jobResult.data.id, { + message: 'PDF parsing failed', + stack: 'Error stack trace', + }); + + expect(failResult.success).toBe(true); + + // Verify the job was updated + const latestResult = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + if (!latestResult.success || !latestResult.data) { + expect.fail('getLatestFormJob failed'); + } + + expect(latestResult.data.status).toBe('failed'); + expect(latestResult.data.errorMessage).toBe('PDF parsing failed'); + expect(latestResult.data.errorStack).toBe('Error stack trace'); + expect(latestResult.data.completedAt).toBeDefined(); + }); + + it('returns null when no job exists', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + // Create a form + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Try to get a job that doesn't exist + const latestResult = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + + expect(latestResult.success).toBe(true); + if (!latestResult.success) { + expect.fail('getLatestFormJob failed'); + } + expect(latestResult.data).toBeNull(); + }); + + it('returns job history in chronological order', async ({ + db, + }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + // Create a form + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Create 3 jobs with different timestamps + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 0)); + const job1 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + expect(job1.success).toBe(true); + + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 1)); + const job2 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + expect(job2.success).toBe(true); + + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 2)); + const job3 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'validate-schema', + metadata: { + validatorVersion: '1.0', + userId: 'user-1', + }, + }); + expect(job3.success).toBe(true); + + // Get all jobs + const historyResult = await getFormJobs(ctx, formResult.data.id); + if (!historyResult.success) { + expect.fail('getFormJobs failed'); + } + + expect(historyResult.data.length).toBe(3); + + // Should be ordered newest first + expect(historyResult.data[0].createdAt.getTime()).toBeGreaterThan( + historyResult.data[1].createdAt.getTime() + ); + expect(historyResult.data[1].createdAt.getTime()).toBeGreaterThan( + historyResult.data[2].createdAt.getTime() + ); + + // Verify the last one is the validate-schema job + expect(historyResult.data[0].jobType).toBe('validate-schema'); + }); + + it('gets only the latest job of a specific type', async ({ + db, + }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + // Create a form + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Create 2 import-pdf jobs + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 0)); + await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 1)); + const job2 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + if (!job2.success) { + expect.fail('createFormJob failed'); + } + + // Get latest import-pdf job + const latestResult = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + if (!latestResult.success || !latestResult.data) { + expect.fail('getLatestFormJob failed'); + } + + // Should be the second job + expect(latestResult.data.id).toBe(job2.data.id); + }); +}); + +const testForm = { + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + outputs: [], +}; diff --git a/packages/forms/src/repository/form-jobs.ts b/packages/forms/src/repository/form-jobs.ts new file mode 100644 index 00000000..cd5d4d3d --- /dev/null +++ b/packages/forms/src/repository/form-jobs.ts @@ -0,0 +1,249 @@ +import { type Result, success, failure } from '@flexion/forms-common'; +import type { FormRepositoryContext } from './index.js'; +import { dateValue } from '@flexion/forms-database'; + +// Type-safe job metadata by job type +export type JobMetadata = { + 'import-pdf': { + documentId: string; + fileName: string; + userId: string; + }; + 'validate-schema': { + validatorVersion: string; + userId: string; + }; + publish: { + targetEnvironment: 'staging' | 'production'; + publisherId: string; + }; +}; + +// Type-safe job results by job type +export type JobResult = { + 'import-pdf': { + patternsAdded: number; + fieldsExtracted: number; + documentId: string; + }; + 'validate-schema': { + errorsFound: number; + warningsFound: number; + issues: Array<{ field: string; message: string }>; + }; + publish: { + publishedAt: string; + url: string; + }; +}; + +export type JobType = keyof JobMetadata; +export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export type FormJob = { + id: string; + formId: string; + jobType: T; + status: JobStatus; + createdAt: Date; + startedAt?: Date; + completedAt?: Date; + errorMessage?: string; + errorStack?: string; + metadata?: JobMetadata[T]; + result?: JobResult[T]; +}; + +/** + * Create a new job record in 'processing' state. + * Call this before starting async work. + */ +export type CreateFormJob = ( + ctx: FormRepositoryContext, + params: { + formId: string; + jobType: T; + metadata?: JobMetadata[T]; + } +) => Promise, string>>; + +export const createFormJob: CreateFormJob = async (ctx, params) => { + const uuid = crypto.randomUUID(); + const db = await ctx.db.getKysely(); + + try { + const now = new Date(); + await db + .insertInto('form_jobs') + .values({ + id: uuid, + form_id: params.formId, + job_type: params.jobType, + status: 'processing', + created_at: dateValue(ctx.db.engine, now), + started_at: dateValue(ctx.db.engine, now), + metadata: params.metadata ? JSON.stringify(params.metadata) : null, + }) + .execute(); + + return success({ + id: uuid, + formId: params.formId, + jobType: params.jobType, + status: 'processing' as JobStatus, + createdAt: new Date(), + startedAt: new Date(), + metadata: params.metadata, + }) as any; + } catch (err) { + return failure(`Failed to create job: ${(err as Error).message}`); + } +}; + +/** + * Mark a job as completed with result data. + */ +export type CompleteFormJob = ( + ctx: FormRepositoryContext, + jobId: string, + result?: JobResult[T] +) => Promise>; + +export const completeFormJob: CompleteFormJob = async (ctx, jobId, result) => { + const db = await ctx.db.getKysely(); + + try { + await db + .updateTable('form_jobs') + .set({ + status: 'completed', + completed_at: new Date().toISOString() as any, + result: result ? JSON.stringify(result) : null, + }) + .where('id', '=', jobId) + .execute(); + + return success(undefined); + } catch (err) { + return failure(`Failed to complete job: ${(err as Error).message}`); + } +}; + +/** + * Mark a job as failed with error information. + */ +export type FailFormJob = ( + ctx: FormRepositoryContext, + jobId: string, + error: { message: string; stack?: string } +) => Promise>; + +export const failFormJob: FailFormJob = async (ctx, jobId, error) => { + const db = await ctx.db.getKysely(); + + try { + await db + .updateTable('form_jobs') + .set({ + status: 'failed', + completed_at: new Date().toISOString() as any, + error_message: error.message, + error_stack: error.stack || null, + }) + .where('id', '=', jobId) + .execute(); + + return success(undefined); + } catch (err) { + return failure(`Failed to fail job: ${(err as Error).message}`); + } +}; + +/** + * Get the latest job for a form of a specific type. + * Useful for status polling: "What's the current import-pdf job status?" + */ +export type GetLatestFormJob = ( + ctx: FormRepositoryContext, + formId: string, + jobType: T +) => Promise | null, string>>; + +export const getLatestFormJob: GetLatestFormJob = async ( + ctx, + formId, + jobType +) => { + const db = await ctx.db.getKysely(); + + try { + const row = await db + .selectFrom('form_jobs') + .selectAll() + .where('form_id', '=', formId) + .where('job_type', '=', jobType) + .orderBy('created_at', 'desc') + .limit(1) + .executeTakeFirst(); + + if (!row) { + return success(null); + } + + return success({ + id: row.id, + formId: row.form_id, + jobType: row.job_type as JobType, + status: row.status as JobStatus, + createdAt: new Date(row.created_at), + startedAt: row.started_at ? new Date(row.started_at) : undefined, + completedAt: row.completed_at ? new Date(row.completed_at) : undefined, + errorMessage: row.error_message || undefined, + errorStack: row.error_stack || undefined, + metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + result: row.result ? JSON.parse(row.result) : undefined, + }) as any; + } catch (err) { + return failure(`Failed to get latest job: ${(err as Error).message}`); + } +}; + +/** + * Get all jobs for a form (full history). + * Useful for debugging and audit logs. + */ +export type GetFormJobs = ( + ctx: FormRepositoryContext, + formId: string +) => Promise>; + +export const getFormJobs: GetFormJobs = async (ctx, formId) => { + const db = await ctx.db.getKysely(); + + try { + const rows = await db + .selectFrom('form_jobs') + .selectAll() + .where('form_id', '=', formId) + .orderBy('created_at', 'desc') + .execute(); + + const jobs = rows.map(row => ({ + id: row.id, + formId: row.form_id, + jobType: row.job_type as JobType, + status: row.status as JobStatus, + createdAt: new Date(row.created_at), + startedAt: row.started_at ? new Date(row.started_at) : undefined, + completedAt: row.completed_at ? new Date(row.completed_at) : undefined, + errorMessage: row.error_message || undefined, + errorStack: row.error_stack || undefined, + metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + result: row.result ? JSON.parse(row.result) : undefined, + })); + + return success(jobs as FormJob[]); + } catch (err) { + return failure(`Failed to get jobs: ${(err as Error).message}`); + } +}; diff --git a/packages/forms/src/repository/get-document.ts b/packages/forms/src/repository/get-document.ts index dd13a3b9..63d199c8 100644 --- a/packages/forms/src/repository/get-document.ts +++ b/packages/forms/src/repository/get-document.ts @@ -29,8 +29,11 @@ export const getDocument: GetDocument = async (ctx, id) => { .where('id', '=', id) .executeTakeFirstOrThrow() .then(data => { + // Handle documents without extract (stored during async processing initialization) const extract: { parsedPdf: ParsedPdf; fields: DocumentFieldMap } = - JSON.parse(data.extract); + data.extract && data.extract !== '' + ? JSON.parse(data.extract) + : { parsedPdf: {} as ParsedPdf, fields: {} }; return success({ id: data.id, data: data.data, diff --git a/packages/forms/src/repository/get-form-list.ts b/packages/forms/src/repository/get-form-list.ts index 77822733..4f7d1aa5 100644 --- a/packages/forms/src/repository/get-form-list.ts +++ b/packages/forms/src/repository/get-form-list.ts @@ -1,27 +1,70 @@ import type { FormRepositoryContext } from '.'; +import type { JobStatus } from './jobs/types.js'; -export type GetFormList = (ctx: FormRepositoryContext) => Promise< - | { - id: string; - title: string; - description: string; - }[] - | null ->; +export type FormListItem = { + id: string; + title: string; + description: string; + latestJob?: { + id: string; + jobType: string; + status: JobStatus; + createdAt: Date; + completedAt?: Date; + errorMessage?: string; + }; +}; + +export type GetFormList = ( + ctx: FormRepositoryContext +) => Promise; /** - * Retrieves a list of forms from the database. + * Retrieves a list of forms from the database with their latest job status. */ export const getFormList: GetFormList = async ctx => { const db = await ctx.db.getKysely(); - const rows = await db.selectFrom('forms').select(['id', 'data']).execute(); - - return rows.map(row => { - const form = JSON.parse(row.data); - return { - id: row.id, - title: form.summary.title, - description: form.summary.description, - }; - }); + + // Get all forms + const forms = await db.selectFrom('forms').select(['id', 'data']).execute(); + + // For each form, get the latest import-pdf job + const formList = await Promise.all( + forms.map(async row => { + const form = JSON.parse(row.data); + + // Get latest import-pdf job for this form + const latestJob = await db + .selectFrom('form_jobs') + .selectAll() + .where('form_id', '=', row.id) + .where('job_type', '=', 'import-pdf') + .orderBy('created_at', 'desc') + .limit(1) + .executeTakeFirst(); + + const formListItem: FormListItem = { + id: row.id, + title: form.summary.title, + description: form.summary.description, + }; + + if (latestJob) { + formListItem.latestJob = { + id: latestJob.id, + jobType: latestJob.job_type, + status: latestJob.status as JobStatus, + createdAt: new Date(latestJob.created_at), + completedAt: latestJob.completed_at + ? new Date(latestJob.completed_at) + : undefined, + errorMessage: latestJob.error_message || undefined, + }; + } + + return formListItem; + }) + ); + + return formList; }; diff --git a/packages/forms/src/repository/index.ts b/packages/forms/src/repository/index.ts index b09c4511..e70bc403 100644 --- a/packages/forms/src/repository/index.ts +++ b/packages/forms/src/repository/index.ts @@ -16,6 +16,18 @@ import { type UpsertFormSession, upsertFormSession, } from './upsert-form-session.js'; +import { type CreateFormJob, createFormJob } from './jobs/create-form-job.js'; +import { + type CompleteFormJob, + completeFormJob, +} from './jobs/complete-form-job.js'; +import { type FailFormJob, failFormJob } from './jobs/fail-form-job.js'; +import { + type GetLatestFormJob, + getLatestFormJob, +} from './jobs/get-latest-form-job.js'; +import { type GetFormJobs, getFormJobs } from './jobs/get-form-jobs.js'; +import { type FormJob, type JobType, type JobStatus } from './jobs/types.js'; export type { FormRepository }; @@ -24,6 +36,8 @@ export type FormRepositoryContext = { formConfig: FormConfig; }; +export { type FormJob, type JobType, type JobStatus } from './jobs/types.js'; + export const createFormsRepository = ( ctx: FormRepositoryContext ): FormRepository => @@ -37,4 +51,9 @@ export const createFormsRepository = ( getForm, saveForm, upsertFormSession, + createFormJob, + completeFormJob, + failFormJob, + getLatestFormJob, + getFormJobs, }); diff --git a/packages/forms/src/repository/jobs/complete-form-job.test.ts b/packages/forms/src/repository/jobs/complete-form-job.test.ts new file mode 100644 index 00000000..02b4db80 --- /dev/null +++ b/packages/forms/src/repository/jobs/complete-form-job.test.ts @@ -0,0 +1,104 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { + type DbTestContext, + describeDatabase, +} from '@flexion/forms-database/testing'; +import { completeFormJob } from './complete-form-job.js'; +import { createFormJob } from './create-form-job.js'; +import { getLatestFormJob } from './get-latest-form-job.js'; +import { addForm } from '../add-form.js'; +import { defaultFormConfig } from '../../patterns/index.js'; + +describeDatabase('completeFormJob', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('completes a job with result data', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const jobResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + if (!jobResult.success) { + expect.fail('createFormJob failed'); + } + + const result = await completeFormJob(ctx, jobResult.data.id, { + patternsAdded: 5, + fieldsExtracted: 12, + documentId: 'doc-1', + }); + + expect(result.success).toBe(true); + + // Verify the job was updated + const latestResult = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + if (!latestResult.success || !latestResult.data) { + expect.fail('getLatestFormJob failed'); + } + + expect(latestResult.data.status).toBe('completed'); + expect(latestResult.data.completedAt).toBeDefined(); + expect(latestResult.data.result).toEqual({ + patternsAdded: 5, + fieldsExtracted: 12, + documentId: 'doc-1', + }); + }); + + it('completes a job without result data', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const jobResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + if (!jobResult.success) { + expect.fail('createFormJob failed'); + } + + const result = await completeFormJob(ctx, jobResult.data.id); + + expect(result.success).toBe(true); + + // Verify the job was updated + const latestResult = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + if (!latestResult.success || !latestResult.data) { + expect.fail('getLatestFormJob failed'); + } + + expect(latestResult.data.status).toBe('completed'); + expect(latestResult.data.completedAt).toBeDefined(); + expect(latestResult.data.result).toBeUndefined(); + }); +}); + +const testForm = { + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + outputs: [], +}; diff --git a/packages/forms/src/repository/jobs/complete-form-job.ts b/packages/forms/src/repository/jobs/complete-form-job.ts new file mode 100644 index 00000000..f90874c1 --- /dev/null +++ b/packages/forms/src/repository/jobs/complete-form-job.ts @@ -0,0 +1,32 @@ +import { type Result, success, failure } from '@flexion/forms-common'; +import type { FormRepositoryContext } from '../index.js'; +import { type JobResult, type JobType } from './types.js'; + +/** + * Mark a job as completed with result data. + */ +export type CompleteFormJob = ( + ctx: FormRepositoryContext, + jobId: string, + result?: JobResult[T] +) => Promise>; + +export const completeFormJob: CompleteFormJob = async (ctx, jobId, result) => { + const db = await ctx.db.getKysely(); + + try { + await db + .updateTable('form_jobs') + .set({ + status: 'completed', + completed_at: new Date().toISOString() as any, + result: result ? JSON.stringify(result) : null, + }) + .where('id', '=', jobId) + .execute(); + + return success(undefined); + } catch (err) { + return failure(`Failed to complete job: ${(err as Error).message}`); + } +}; diff --git a/packages/forms/src/repository/jobs/create-form-job.test.ts b/packages/forms/src/repository/jobs/create-form-job.test.ts new file mode 100644 index 00000000..494ff19b --- /dev/null +++ b/packages/forms/src/repository/jobs/create-form-job.test.ts @@ -0,0 +1,108 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { + type DbTestContext, + describeDatabase, +} from '@flexion/forms-database/testing'; +import { createFormJob } from './create-form-job.js'; +import { addForm } from '../add-form.js'; +import { defaultFormConfig } from '../../patterns/index.js'; + +describeDatabase('createFormJob', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('creates a job in processing state', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const result = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + metadata: { + documentId: 'doc-1', + fileName: 'test.pdf', + userId: 'user-1', + }, + }); + + if (!result.success) { + expect.fail(`createFormJob failed: ${result.error}`); + } + + expect(result.data.status).toBe('processing'); + expect(result.data.jobType).toBe('import-pdf'); + expect(result.data.formId).toBe(formResult.data.id); + expect(result.data.createdAt).toEqual(today); + expect(result.data.startedAt).toEqual(today); + expect(result.data.metadata).toEqual({ + documentId: 'doc-1', + fileName: 'test.pdf', + userId: 'user-1', + }); + expect(result.data.id).toBeDefined(); + }); + + it('creates a job without metadata', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const result = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + + if (!result.success) { + expect.fail(`createFormJob failed: ${result.error}`); + } + + expect(result.data.status).toBe('processing'); + expect(result.data.metadata).toBeUndefined(); + }); + + it('creates jobs with different types', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const validateResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'validate-schema', + metadata: { + validatorVersion: '1.0', + userId: 'user-1', + }, + }); + + if (!validateResult.success) { + expect.fail('createFormJob failed'); + } + + expect(validateResult.data.jobType).toBe('validate-schema'); + expect(validateResult.data.metadata).toEqual({ + validatorVersion: '1.0', + userId: 'user-1', + }); + }); +}); + +const testForm = { + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + outputs: [], +}; diff --git a/packages/forms/src/repository/jobs/create-form-job.ts b/packages/forms/src/repository/jobs/create-form-job.ts new file mode 100644 index 00000000..604d025c --- /dev/null +++ b/packages/forms/src/repository/jobs/create-form-job.ts @@ -0,0 +1,55 @@ +import { type Result, success, failure } from '@flexion/forms-common'; +import type { FormRepositoryContext } from '../index.js'; +import { + type FormJob, + type JobMetadata, + type JobStatus, + type JobType, +} from './types.js'; +import { dateValue } from '@flexion/forms-database'; + +/** + * Create a new job record in 'processing' state. + * Call this before starting async work. + */ +export type CreateFormJob = ( + ctx: FormRepositoryContext, + params: { + formId: string; + jobType: T; + metadata?: JobMetadata[T]; + } +) => Promise, string>>; + +export const createFormJob: CreateFormJob = async (ctx, params) => { + const uuid = crypto.randomUUID(); + const db = await ctx.db.getKysely(); + + try { + const now = new Date(); + await db + .insertInto('form_jobs') + .values({ + id: uuid, + form_id: params.formId, + job_type: params.jobType, + status: 'processing', + created_at: dateValue(ctx.db.engine, now), + started_at: dateValue(ctx.db.engine, now), + metadata: params.metadata ? JSON.stringify(params.metadata) : null, + }) + .execute(); + + return success({ + id: uuid, + formId: params.formId, + jobType: params.jobType, + status: 'processing' as JobStatus, + createdAt: now, + startedAt: now, + metadata: params.metadata, + }) as any; + } catch (err) { + return failure(`Failed to create job: ${(err as Error).message}`); + } +}; diff --git a/packages/forms/src/repository/jobs/fail-form-job.test.ts b/packages/forms/src/repository/jobs/fail-form-job.test.ts new file mode 100644 index 00000000..1faea1d3 --- /dev/null +++ b/packages/forms/src/repository/jobs/fail-form-job.test.ts @@ -0,0 +1,107 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { + type DbTestContext, + describeDatabase, +} from '@flexion/forms-database/testing'; +import { failFormJob } from './fail-form-job.js'; +import { createFormJob } from './create-form-job.js'; +import { getLatestFormJob } from './get-latest-form-job.js'; +import { addForm } from '../add-form.js'; +import { defaultFormConfig } from '../../patterns/index.js'; + +describeDatabase('failFormJob', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('fails a job with error message and stack', async ({ + db, + }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const jobResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + if (!jobResult.success) { + expect.fail('createFormJob failed'); + } + + const result = await failFormJob(ctx, jobResult.data.id, { + message: 'PDF parsing failed', + stack: 'Error: PDF parsing failed\n at parsePDF', + }); + + expect(result.success).toBe(true); + + // Verify the job was updated + const latestResult = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + if (!latestResult.success || !latestResult.data) { + expect.fail('getLatestFormJob failed'); + } + + expect(latestResult.data.status).toBe('failed'); + expect(latestResult.data.completedAt).toBeDefined(); + expect(latestResult.data.errorMessage).toBe('PDF parsing failed'); + expect(latestResult.data.errorStack).toBe( + 'Error: PDF parsing failed\n at parsePDF' + ); + }); + + it('fails a job without stack trace', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const jobResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'validate-schema', + }); + if (!jobResult.success) { + expect.fail('createFormJob failed'); + } + + const result = await failFormJob(ctx, jobResult.data.id, { + message: 'Validation failed', + }); + + expect(result.success).toBe(true); + + // Verify the job was updated + const latestResult = await getLatestFormJob( + ctx, + formResult.data.id, + 'validate-schema' + ); + if (!latestResult.success || !latestResult.data) { + expect.fail('getLatestFormJob failed'); + } + + expect(latestResult.data.status).toBe('failed'); + expect(latestResult.data.completedAt).toBeDefined(); + expect(latestResult.data.errorMessage).toBe('Validation failed'); + expect(latestResult.data.errorStack).toBeUndefined(); + }); +}); + +const testForm = { + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + outputs: [], +}; diff --git a/packages/forms/src/repository/jobs/fail-form-job.ts b/packages/forms/src/repository/jobs/fail-form-job.ts new file mode 100644 index 00000000..10577d1f --- /dev/null +++ b/packages/forms/src/repository/jobs/fail-form-job.ts @@ -0,0 +1,32 @@ +import { type Result, success, failure } from '@flexion/forms-common'; +import type { FormRepositoryContext } from '../index.js'; + +/** + * Mark a job as failed with error information. + */ +export type FailFormJob = ( + ctx: FormRepositoryContext, + jobId: string, + error: { message: string; stack?: string } +) => Promise>; + +export const failFormJob: FailFormJob = async (ctx, jobId, error) => { + const db = await ctx.db.getKysely(); + + try { + await db + .updateTable('form_jobs') + .set({ + status: 'failed', + completed_at: new Date().toISOString() as any, + error_message: error.message, + error_stack: error.stack || null, + }) + .where('id', '=', jobId) + .execute(); + + return success(undefined); + } catch (err) { + return failure(`Failed to fail job: ${(err as Error).message}`); + } +}; diff --git a/packages/forms/src/repository/jobs/get-form-jobs.test.ts b/packages/forms/src/repository/jobs/get-form-jobs.test.ts new file mode 100644 index 00000000..58db29d0 --- /dev/null +++ b/packages/forms/src/repository/jobs/get-form-jobs.test.ts @@ -0,0 +1,126 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { + type DbTestContext, + describeDatabase, +} from '@flexion/forms-database/testing'; +import { getFormJobs } from './get-form-jobs.js'; +import { createFormJob } from './create-form-job.js'; +import { addForm } from '../add-form.js'; +import { defaultFormConfig } from '../../patterns/index.js'; + +describeDatabase('getFormJobs', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('returns empty array when no jobs exist', async ({ + db, + }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const result = await getFormJobs(ctx, formResult.data.id); + + if (!result.success) { + expect.fail('getFormJobs failed'); + } + + expect(result.data).toEqual([]); + }); + + it('returns all jobs in descending order', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Create 3 jobs with different timestamps + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 0)); + const job1 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + expect(job1.success).toBe(true); + + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 1)); + const job2 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'validate-schema', + }); + expect(job2.success).toBe(true); + + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 2)); + const job3 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'publish', + }); + expect(job3.success).toBe(true); + + const result = await getFormJobs(ctx, formResult.data.id); + + if (!result.success) { + expect.fail('getFormJobs failed'); + } + + expect(result.data.length).toBe(3); + + // Should be ordered newest first + expect(result.data[0].createdAt.getTime()).toBeGreaterThan( + result.data[1].createdAt.getTime() + ); + expect(result.data[1].createdAt.getTime()).toBeGreaterThan( + result.data[2].createdAt.getTime() + ); + + // Verify order of job types + expect(result.data[0].jobType).toBe('publish'); + expect(result.data[1].jobType).toBe('validate-schema'); + expect(result.data[2].jobType).toBe('import-pdf'); + }); + + it('returns jobs for specific form only', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + // Create two forms + const form1Result = await addForm(ctx, testForm); + const form2Result = await addForm(ctx, testForm); + if (!form1Result.success || !form2Result.success) { + expect.fail('addForm failed'); + } + + // Create jobs for both forms + await createFormJob(ctx, { + formId: form1Result.data.id, + jobType: 'import-pdf', + }); + await createFormJob(ctx, { + formId: form2Result.data.id, + jobType: 'import-pdf', + }); + + const result = await getFormJobs(ctx, form1Result.data.id); + + if (!result.success) { + expect.fail('getFormJobs failed'); + } + + expect(result.data.length).toBe(1); + expect(result.data[0].formId).toBe(form1Result.data.id); + }); +}); + +const testForm = { + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + outputs: [], +}; diff --git a/packages/forms/src/repository/jobs/get-form-jobs.ts b/packages/forms/src/repository/jobs/get-form-jobs.ts new file mode 100644 index 00000000..6effe49f --- /dev/null +++ b/packages/forms/src/repository/jobs/get-form-jobs.ts @@ -0,0 +1,31 @@ +import { type Result, success, failure } from '@flexion/forms-common'; +import type { FormRepositoryContext } from '../index.js'; +import { type FormJob, rowToFormJob } from './types.js'; + +/** + * Get all jobs for a form (full history). + * Useful for debugging and audit logs. + */ +export type GetFormJobs = ( + ctx: FormRepositoryContext, + formId: string +) => Promise>; + +export const getFormJobs: GetFormJobs = async (ctx, formId) => { + const db = await ctx.db.getKysely(); + + try { + const rows = await db + .selectFrom('form_jobs') + .selectAll() + .where('form_id', '=', formId) + .orderBy('created_at', 'desc') + .execute(); + + const jobs = rows.map(rowToFormJob); + + return success(jobs as FormJob[]); + } catch (err) { + return failure(`Failed to get jobs: ${(err as Error).message}`); + } +}; diff --git a/packages/forms/src/repository/jobs/get-latest-form-job.test.ts b/packages/forms/src/repository/jobs/get-latest-form-job.test.ts new file mode 100644 index 00000000..8e535901 --- /dev/null +++ b/packages/forms/src/repository/jobs/get-latest-form-job.test.ts @@ -0,0 +1,162 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { + type DbTestContext, + describeDatabase, +} from '@flexion/forms-database/testing'; +import { getLatestFormJob } from './get-latest-form-job.js'; +import { createFormJob } from './create-form-job.js'; +import { addForm } from '../add-form.js'; +import { defaultFormConfig } from '../../patterns/index.js'; + +describeDatabase('getLatestFormJob', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('returns null when no job exists', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const result = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + + if (!result.success) { + expect.fail('getLatestFormJob failed'); + } + + expect(result.data).toBeNull(); + }); + + it('returns the latest job of specific type', async ({ + db, + }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Create two jobs of the same type + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 0)); + const job1 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + expect(job1.success).toBe(true); + + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 1)); + const job2 = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + if (!job2.success) { + expect.fail('createFormJob failed'); + } + + const result = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + + if (!result.success || !result.data) { + expect.fail('getLatestFormJob failed'); + } + + // Should return the second (newer) job + expect(result.data.id).toBe(job2.data.id); + }); + + it('returns job of specific type only', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + // Create jobs of different types + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 0)); + const importJob = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'import-pdf', + }); + if (!importJob.success) { + expect.fail('createFormJob failed'); + } + + vi.setSystemTime(new Date(2000, 1, 1, 0, 0, 1)); + await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'validate-schema', + }); + + // Should return import-pdf job, not the newer validate-schema + const result = await getLatestFormJob( + ctx, + formResult.data.id, + 'import-pdf' + ); + + if (!result.success || !result.data) { + expect.fail('getLatestFormJob failed'); + } + + expect(result.data.jobType).toBe('import-pdf'); + expect(result.data.id).toBe(importJob.data.id); + }); + + it('returns job with metadata and result', async ({ db }) => { + const ctx = { db: db.ctx, formConfig: defaultFormConfig }; + + const formResult = await addForm(ctx, testForm); + if (!formResult.success) { + expect.fail('addForm failed'); + } + + const jobResult = await createFormJob(ctx, { + formId: formResult.data.id, + jobType: 'validate-schema', + metadata: { + validatorVersion: '2.0', + userId: 'user-123', + }, + }); + if (!jobResult.success) { + expect.fail('createFormJob failed'); + } + + const result = await getLatestFormJob( + ctx, + formResult.data.id, + 'validate-schema' + ); + + if (!result.success || !result.data) { + expect.fail('getLatestFormJob failed'); + } + + expect(result.data.metadata).toEqual({ + validatorVersion: '2.0', + userId: 'user-123', + }); + }); +}); + +const testForm = { + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + outputs: [], +}; diff --git a/packages/forms/src/repository/jobs/get-latest-form-job.ts b/packages/forms/src/repository/jobs/get-latest-form-job.ts new file mode 100644 index 00000000..651a3a94 --- /dev/null +++ b/packages/forms/src/repository/jobs/get-latest-form-job.ts @@ -0,0 +1,40 @@ +import { type Result, success, failure } from '@flexion/forms-common'; +import type { FormRepositoryContext } from '../index.js'; +import { type FormJob, type JobType, rowToFormJob } from './types.js'; + +/** + * Get the latest job for a form of a specific type. + * Useful for status polling: "What's the current import-pdf job status?" + */ +export type GetLatestFormJob = ( + ctx: FormRepositoryContext, + formId: string, + jobType: T +) => Promise | null, string>>; + +export const getLatestFormJob: GetLatestFormJob = async ( + ctx, + formId, + jobType +) => { + const db = await ctx.db.getKysely(); + + try { + const row = await db + .selectFrom('form_jobs') + .selectAll() + .where('form_id', '=', formId) + .where('job_type', '=', jobType) + .orderBy('created_at', 'desc') + .limit(1) + .executeTakeFirst(); + + if (!row) { + return success(null); + } + + return success(rowToFormJob(row)) as any; + } catch (err) { + return failure(`Failed to get latest job: ${(err as Error).message}`); + } +}; diff --git a/packages/forms/src/repository/jobs/types.ts b/packages/forms/src/repository/jobs/types.ts new file mode 100644 index 00000000..9af83d49 --- /dev/null +++ b/packages/forms/src/repository/jobs/types.ts @@ -0,0 +1,69 @@ +// Type-safe job metadata by job type +export type JobMetadata = { + 'import-pdf': { + documentId: string; + fileName: string; + userId: string; + }; + 'validate-schema': { + validatorVersion: string; + userId: string; + }; + publish: { + targetEnvironment: 'staging' | 'production'; + publisherId: string; + }; +}; + +// Type-safe job results by job type +export type JobResult = { + 'import-pdf': { + patternsAdded: number; + fieldsExtracted: number; + documentId: string; + }; + 'validate-schema': { + errorsFound: number; + warningsFound: number; + issues: Array<{ field: string; message: string }>; + }; + publish: { + publishedAt: string; + url: string; + }; +}; + +export type JobType = keyof JobMetadata; +export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export type FormJob = { + id: string; + formId: string; + jobType: T; + status: JobStatus; + createdAt: Date; + startedAt?: Date; + completedAt?: Date; + errorMessage?: string; + errorStack?: string; + metadata?: JobMetadata[T]; + result?: JobResult[T]; +}; + +/** + * Helper to convert database row to FormJob object. + * Handles date conversions and JSON parsing. + */ +export const rowToFormJob = (row: any): FormJob => ({ + id: row.id, + formId: row.form_id, + jobType: row.job_type as JobType, + status: row.status as JobStatus, + createdAt: new Date(row.created_at), + startedAt: row.started_at ? new Date(row.started_at) : undefined, + completedAt: row.completed_at ? new Date(row.completed_at) : undefined, + errorMessage: row.error_message || undefined, + errorStack: row.error_stack || undefined, + metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + result: row.result ? JSON.parse(row.result) : undefined, +}); diff --git a/packages/forms/src/repository/types.ts b/packages/forms/src/repository/types.ts index 07142ed0..9291a6b8 100644 --- a/packages/forms/src/repository/types.ts +++ b/packages/forms/src/repository/types.ts @@ -9,6 +9,11 @@ import type { GetFormList } from './get-form-list.js'; import type { GetFormSession } from './get-form-session.js'; import type { SaveForm } from './save-form.js'; import type { UpsertFormSession } from './upsert-form-session.js'; +import type { CreateFormJob } from './jobs/create-form-job.js'; +import type { CompleteFormJob } from './jobs/complete-form-job.js'; +import type { FailFormJob } from './jobs/fail-form-job.js'; +import type { GetLatestFormJob } from './jobs/get-latest-form-job.js'; +import type { GetFormJobs } from './jobs/get-form-jobs.js'; /** * Interface for the forms repository. @@ -24,4 +29,9 @@ export interface FormRepository { getFormList: ServiceMethod; saveForm: ServiceMethod; upsertFormSession: ServiceMethod; + createFormJob: ServiceMethod; + completeFormJob: ServiceMethod; + failFormJob: ServiceMethod; + getLatestFormJob: ServiceMethod; + getFormJobs: ServiceMethod; } diff --git a/packages/forms/src/services/get-form-list.test.ts b/packages/forms/src/services/get-form-list.test.ts index 058704e7..26d0f71c 100644 --- a/packages/forms/src/services/get-form-list.test.ts +++ b/packages/forms/src/services/get-form-list.test.ts @@ -16,7 +16,7 @@ describe('getFormList', () => { success: false, error: { status: 401, - message: 'You must be logged in to delete a form', + message: 'You must be logged in to get form list', }, }); }); diff --git a/packages/forms/src/services/get-form-list.ts b/packages/forms/src/services/get-form-list.ts index eaaf1935..0626524d 100644 --- a/packages/forms/src/services/get-form-list.ts +++ b/packages/forms/src/services/get-form-list.ts @@ -1,12 +1,10 @@ import { type Result, failure, success } from '@flexion/forms-common'; import { type FormServiceContext } from '../context/index.js'; +import type { FormListItem as RepositoryFormListItem } from '../repository/get-form-list.js'; + +export type FormListItem = RepositoryFormListItem; -export type FormListItem = { - id: string; - title: string; - description: string; -}; type FormListError = { status: number; message: string; @@ -24,7 +22,7 @@ export const getFormList: GetFormList = async ctx => { if (!ctx.isUserLoggedIn()) { return failure({ status: 401, - message: 'You must be logged in to delete a form', + message: 'You must be logged in to get form list', }); } const forms = await ctx.repository.getFormList(); diff --git a/packages/forms/src/services/get-form-status.test.ts b/packages/forms/src/services/get-form-status.test.ts new file mode 100644 index 00000000..e1a91feb --- /dev/null +++ b/packages/forms/src/services/get-form-status.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm } from '../index.js'; +import { createTestFormServiceContext } from '../testing.js'; +import { getFormStatus } from './get-form-status.js'; + +const TEST_FORM = createForm({ title: 'Form Title', description: '' }); + +const TEST_FORM_WITH_PATTERNS = { + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { + root: { type: 'sequence', id: 'root', data: { patterns: ['page1'] } }, + page1: { + type: 'page', + id: 'page1', + data: { title: 'Page 1', patterns: [] }, + }, + }, + outputs: [], +}; + +describe('getFormStatus', () => { + it('returns 404 for non-existent form', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + + const result = await getFormStatus(ctx, 'non-existent-id'); + + expect(result).toEqual({ + success: false, + error: { + status: 404, + message: 'Form not found', + }, + }); + }); + + it('returns draft status for empty form with no job', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + + const addResult = await ctx.repository.addForm(TEST_FORM); + if (!addResult.success) { + expect.fail('Failed to add form'); + } + + const result = await getFormStatus(ctx, addResult.data.id); + + if (!result.success) { + expect.fail(`Failed to get form status: ${JSON.stringify(result.error)}`); + } + + expect(result.data.formStatus).toBe('draft'); + expect(result.data.latestJob).toBeUndefined(); + }); + + it('returns ready status for form with patterns and no job', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + + const addResult = await ctx.repository.addForm(TEST_FORM_WITH_PATTERNS); + if (!addResult.success) { + expect.fail('Failed to add form'); + } + + const result = await getFormStatus(ctx, addResult.data.id); + + if (!result.success) { + expect.fail(`Failed to get form status: ${JSON.stringify(result.error)}`); + } + + expect(result.data.formStatus).toBe('ready'); + expect(result.data.latestJob).toBeUndefined(); + }); + + it('returns draft status when job is processing (race condition fix)', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + + // Create form with patterns (simulating mid-processing state where patterns exist) + const addResult = await ctx.repository.addForm(TEST_FORM_WITH_PATTERNS); + if (!addResult.success) { + expect.fail('Failed to add form'); + } + + // Create a processing job + const jobResult = await ctx.repository.createFormJob({ + formId: addResult.data.id, + jobType: 'import-pdf', + metadata: { + documentId: 'doc-1', + fileName: 'test.pdf', + userId: 'user-1', + }, + }); + if (!jobResult.success) { + expect.fail('Failed to create job'); + } + + // Get form status - should be 'draft' even though patterns exist + // because the job is still processing + const result = await getFormStatus(ctx, addResult.data.id); + + if (!result.success) { + expect.fail(`Failed to get form status: ${JSON.stringify(result.error)}`); + } + + // Key assertion: form status should be 'draft' because job is still processing + expect(result.data.formStatus).toBe('draft'); + expect(result.data.latestJob).toBeDefined(); + expect(result.data.latestJob?.status).toBe('processing'); + }); + + it('returns ready status after job completes successfully', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + + // Create form + const addResult = await ctx.repository.addForm(TEST_FORM); + if (!addResult.success) { + expect.fail('Failed to add form'); + } + + // Create and complete a job + const jobResult = await ctx.repository.createFormJob({ + formId: addResult.data.id, + jobType: 'import-pdf', + metadata: { + documentId: 'doc-1', + fileName: 'test.pdf', + userId: 'user-1', + }, + }); + if (!jobResult.success) { + expect.fail('Failed to create job'); + } + + // Add patterns to form (simulating job completion) + await ctx.repository.saveForm( + addResult.data.id, + TEST_FORM_WITH_PATTERNS as any + ); + + // Complete the job + await ctx.repository.completeFormJob(jobResult.data.id, { + patternsAdded: 1, + fieldsExtracted: 5, + documentId: 'doc-1', + }); + + // Get form status - should be 'ready' now + const result = await getFormStatus(ctx, addResult.data.id); + + if (!result.success) { + expect.fail(`Failed to get form status: ${JSON.stringify(result.error)}`); + } + + expect(result.data.formStatus).toBe('ready'); + expect(result.data.latestJob).toBeDefined(); + expect(result.data.latestJob?.status).toBe('completed'); + }); + + it('returns draft status when job fails', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + + // Create form + const addResult = await ctx.repository.addForm(TEST_FORM); + if (!addResult.success) { + expect.fail('Failed to add form'); + } + + // Create and fail a job + const jobResult = await ctx.repository.createFormJob({ + formId: addResult.data.id, + jobType: 'import-pdf', + metadata: { + documentId: 'doc-1', + fileName: 'test.pdf', + userId: 'user-1', + }, + }); + if (!jobResult.success) { + expect.fail('Failed to create job'); + } + + await ctx.repository.failFormJob(jobResult.data.id, { + message: 'Processing failed', + }); + + // Get form status - should be 'draft' because no patterns and job failed + const result = await getFormStatus(ctx, addResult.data.id); + + if (!result.success) { + expect.fail(`Failed to get form status: ${JSON.stringify(result.error)}`); + } + + expect(result.data.formStatus).toBe('draft'); + expect(result.data.latestJob).toBeDefined(); + expect(result.data.latestJob?.status).toBe('failed'); + expect(result.data.latestJob?.errorMessage).toBe('Processing failed'); + }); +}); diff --git a/packages/forms/src/services/get-form-status.ts b/packages/forms/src/services/get-form-status.ts new file mode 100644 index 00000000..4d7cd241 --- /dev/null +++ b/packages/forms/src/services/get-form-status.ts @@ -0,0 +1,92 @@ +import { type Result, success, failure } from '@flexion/forms-common'; +import type { InternalFormServiceContext } from '../context/index.js'; +import type { JobStatus } from '../repository/jobs/types.js'; + +export type FormStatusResponse = { + formId: string; + formStatus: 'draft' | 'ready'; // Usability status + latestJob?: { + id: string; + jobType: string; + status: JobStatus; + createdAt: string; + completedAt?: string; + errorMessage?: string; + }; +}; + +export type GetFormStatusError = { + status: number; + message: string; +}; + +export type GetFormStatus = ( + ctx: InternalFormServiceContext, + formId: string +) => Promise>; + +/** + * Get form status and latest import-pdf job status. + * Used by frontend polling to check processing progress. + */ +export const getFormStatus: GetFormStatus = async (ctx, formId) => { + // Get form to check if it exists + const formResult = await ctx.repository.getForm(formId); + if (!formResult.success) { + return failure({ + status: 404, + message: 'Form not found', + }); + } + + const form = formResult.data; + if (!form) { + return failure({ + status: 404, + message: 'Form not found', + }); + } + + // Get latest import-pdf job (if any) + const latestJobResult = await ctx.repository.getLatestFormJob( + formId, + 'import-pdf' + ); + + if (!latestJobResult.success) { + return failure({ + status: 500, + message: 'Failed to get job status', + }); + } + + const job = latestJobResult.data; + + // Determine form status based on job state first to avoid race conditions + // If a job is actively processing, keep status as 'draft' even if patterns exist + let formStatus: 'draft' | 'ready'; + + if (job && (job.status === 'pending' || job.status === 'processing')) { + // Job is still active - form is not ready yet + formStatus = 'draft'; + } else { + // No active job - determine status based on content + const hasContent = Object.keys(form.patterns).length > 1; // >1 because root pattern always exists + formStatus = hasContent ? 'ready' : 'draft'; + } + + return success({ + formId, + formStatus, + latestJob: job + ? { + id: job.id, + jobType: job.jobType, + status: job.status, + createdAt: job.createdAt.toISOString(), + completedAt: job.completedAt?.toISOString(), + errorMessage: job.errorMessage, + } + : undefined, + }); +}; diff --git a/packages/forms/src/services/index.ts b/packages/forms/src/services/index.ts index 161b0e2d..7c35be75 100644 --- a/packages/forms/src/services/index.ts +++ b/packages/forms/src/services/index.ts @@ -11,6 +11,7 @@ import { type DeleteForm, deleteForm } from './delete-form.js'; import { type GetForm, getForm } from './get-form.js'; import { type GetFormList, getFormList } from './get-form-list.js'; import { type GetFormSession, getFormSession } from './get-form-session.js'; +import { type GetFormStatus, getFormStatus } from './get-form-status.js'; import { type InitializeForm, initializeForm } from './initialize-form.js'; import { type SaveForm, saveForm } from './save-form.js'; import { type SubmitForm, submitForm } from './submit-form.js'; @@ -34,6 +35,7 @@ export const createFormService = (ctx: FormServiceContext): FormService => { getForm, getFormList, getFormSession, + getFormStatus, initializeForm, saveForm, submitForm, @@ -46,6 +48,7 @@ export type FormService = { getForm: ServiceMethod; getFormList: ServiceMethod; getFormSession: ServiceMethod; + getFormStatus: ServiceMethod; initializeForm: ServiceMethod; saveForm: ServiceMethod; submitForm: ServiceMethod; diff --git a/packages/forms/src/services/initialize-form.test.ts b/packages/forms/src/services/initialize-form.test.ts index f9ee4695..efedf88a 100644 --- a/packages/forms/src/services/initialize-form.test.ts +++ b/packages/forms/src/services/initialize-form.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createTestFormServiceContext } from '../testing.js'; @@ -31,25 +31,248 @@ describe('initializeForm', () => { data: { timestamp: expect.any(String), id: expect.any(String), + status: 'ready', }, }); }); - it.skip('initializes successfully with document when user is logged in', async () => { - // TODO: Update this test to use a fake parser that returns expected structure + it('initializes and returns immediately with document (async processing)', async () => { + const mockParsedPdf = { + title: 'Parsed Form Title', + description: 'This form was parsed from a PDF', + root: 'page1', + patterns: { + page1: { + type: 'page', + id: 'page1', + data: { + title: 'Page 1', + patterns: ['paragraph1', 'input1'], + }, + }, + paragraph1: { + type: 'paragraph', + id: 'paragraph1', + data: { + text: 'Welcome to the parsed form', + }, + }, + input1: { + type: 'input', + id: 'input1', + data: { + label: 'First Name', + required: true, + }, + }, + }, + outputs: { + firstName: { type: 'text' as const, name: 'firstName', value: '' }, + }, + errors: [], + }; + + const mockFields = { + firstName: { + type: 'TextField' as const, + name: 'firstName', + label: 'First Name', + value: '', + required: true, + }, + }; + const ctx = await createTestFormServiceContext({ isUserLoggedIn: () => true, }); + + // Mock parsePdf to avoid needing a real PDF + ctx.parsePdf = async () => ({ + parsedPdf: mockParsedPdf as any, + fields: mockFields, + }); + + // Base64 encoded "This is test PDF data" + const testPdfData = 'VGhpcyBpcyB0ZXN0IFBERiBkYXRh'; + const result = await initializeForm(ctx, { - summary, - document: { fileName: 'test.pdf', data: 'VGhpcyBpcyBub3QgYSBQREYu' }, + document: { fileName: 'test.pdf', data: testPdfData }, }); + + // Should return immediately with processing status expect(result).toEqual({ success: true, data: { timestamp: expect.any(String), id: expect.any(String), + jobId: expect.any(String), + status: 'processing', }, }); + + if (!result.success) return; + + const { id: formId, jobId } = result.data; + + // Wait for async processing to complete + await vi.waitFor( + async () => { + const job = await ctx.repository.getLatestFormJob(formId, 'import-pdf'); + expect(job.success).toBe(true); + if (!job.success) throw new Error('Job not found'); + expect(job.data?.status).toBe('completed'); + }, + { timeout: 5000 } + ); + + // Verify job was completed successfully + const jobResult = await ctx.repository.getLatestFormJob( + formId, + 'import-pdf' + ); + expect(jobResult.success).toBe(true); + if (!jobResult.success) return; + + const job = jobResult.data!; + expect(job.status).toBe('completed'); + expect(job.completedAt).toBeDefined(); + expect(job.result).toEqual({ + patternsAdded: expect.any(Number), + fieldsExtracted: expect.any(Number), + documentId: expect.any(String), + }); + + // Verify form was updated with parsed content + const formResult = await ctx.repository.getForm(formId); + expect(formResult.success).toBe(true); + if (!formResult.success) return; + + const form = formResult.data!; + + // Form should have been updated with parsed title + expect(form.summary.title).toBe('Parsed Form Title'); + expect(form.summary.description).toBe('This form was parsed from a PDF'); + + // Form should have patterns added (more than just root) + expect(Object.keys(form.patterns).length).toBeGreaterThan(1); + }); + + it('handles async processing errors gracefully', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + + // Mock parsePdf to throw an error + ctx.parsePdf = async () => { + throw new Error('Parser failed to process PDF'); + }; + + const testPdfData = 'VGhpcyBpcyB0ZXN0IFBERiBkYXRh'; + + const result = await initializeForm(ctx, { + document: { fileName: 'failing-test.pdf', data: testPdfData }, + }); + + // Should still return successfully (processing happens async) + expect(result.success).toBe(true); + if (!result.success) return; + + const { id: formId } = result.data; + + // Wait for async processing to fail + await vi.waitFor( + async () => { + const job = await ctx.repository.getLatestFormJob(formId, 'import-pdf'); + expect(job.success).toBe(true); + if (!job.success) throw new Error('Job not found'); + expect(job.data?.status).toBe('failed'); + }, + { timeout: 5000 } + ); + + // Verify job failed with error message + const jobResult = await ctx.repository.getLatestFormJob( + formId, + 'import-pdf' + ); + expect(jobResult.success).toBe(true); + if (!jobResult.success) return; + + const job = jobResult.data!; + expect(job.status).toBe('failed'); + expect(job.errorMessage).toContain('Parser failed to process PDF'); + expect(job.completedAt).toBeDefined(); + expect(job.result).toBeUndefined(); + }); + + it('validates document data is valid base64', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + + const result = await initializeForm(ctx, { + document: { fileName: 'test.pdf', data: 'not-valid-base64!!!' }, + }); + + expect(result).toEqual({ + success: false, + error: { + status: 400, + message: 'Invalid options', + }, + }); + }); + + it('uses filename as title when no summary provided', async () => { + const mockParsedPdf = { + title: 'Override Title', + description: 'Description from PDF', + root: 'root', + patterns: { + root: { + type: 'page', + id: 'root', + data: { title: 'Page 1', patterns: [] }, + }, + }, + outputs: {}, + errors: [], + }; + + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + + // Mock parsePdf + ctx.parsePdf = async () => ({ + parsedPdf: mockParsedPdf as any, + fields: {}, + }); + + const result = await initializeForm(ctx, { + document: { fileName: 'my-form.pdf', data: 'VGVzdA==' }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + const { id: formId } = result.data; + + // Wait for processing + await vi.waitFor( + async () => { + const job = await ctx.repository.getLatestFormJob(formId, 'import-pdf'); + if (!job.success) throw new Error('Job not found'); + expect(job.data?.status).toBe('completed'); + }, + { timeout: 5000 } + ); + + // Check that the parsed title was used (not the filename) + const formResult = await ctx.repository.getForm(formId); + expect(formResult.success).toBe(true); + if (!formResult.success) return; + + expect(formResult.data!.summary.title).toBe('Override Title'); }); }); diff --git a/packages/forms/src/services/initialize-form.ts b/packages/forms/src/services/initialize-form.ts index 544f97f8..cd5d35e7 100644 --- a/packages/forms/src/services/initialize-form.ts +++ b/packages/forms/src/services/initialize-form.ts @@ -14,6 +14,8 @@ type InitializeFormError = { type InitializeFormResult = { timestamp: string; id: string; + jobId?: string; + status: 'ready' | 'processing'; }; export type InitializeForm = ( @@ -50,8 +52,9 @@ const optionSchema = z.object({ }); /** - * Asynchronously initializes a new form based on the provided context and options. Handles schema validation, - * document import (parses uploaded PDF), builds a Blueprint, and saves to the repository. + * Asynchronously initializes a new form based on the provided context and options. + * If a document is provided, creates the form immediately and processes the PDF asynchronously. + * Otherwise, creates a form with the provided summary. */ export const initializeForm: InitializeForm = async (ctx, opts) => { if (!ctx.isUserLoggedIn()) { @@ -71,56 +74,164 @@ export const initializeForm: InitializeForm = async (ctx, opts) => { } const { document, summary } = parseResult.data; + // Create empty blueprint const builder = new BlueprintBuilder(ctx.config); - if (document !== undefined) { - const parsePdfResult = await ctx - .parsePdf(document.data) - .then(result => success(result)) - .catch(err => - failure({ - status: 400, - message: `Failed to parse PDF: ${err.message}`, - }) - ); - if (!parsePdfResult.success) { - return parsePdfResult; - } - const { parsedPdf } = parsePdfResult.data; + if (summary) { + builder.setFormSummary(summary); + } else if (document) { builder.setFormSummary({ - title: parsedPdf.title || document.fileName, - description: parsedPdf.description, + title: document.fileName, + description: '', }); + } + // Step 1: Create form in database (empty, draft state) + const formResult = await ctx.repository.addForm(builder.form); + if (!formResult.success) { + console.error('Failed to add form:', formResult.error); + return failure({ + status: 500, + message: formResult.error, + }); + } + + const formId = formResult.data.id; + + // Step 2: If document provided, store it and initiate async processing + if (document !== undefined) { const fileName = document.fileName.split('/').pop() || 'my-form.pdf'; + + // Store document (without extract, will be filled by job processing) const addDocumentResult = await ctx.repository.addDocument({ fileName, data: document.data, - extract: parsePdfResult.data, + extract: undefined, }); + if (!addDocumentResult.success) { return failure({ status: 500, message: `Failed to add document: ${addDocumentResult.error}`, }); } + + const documentId = addDocumentResult.data.id; + + // Create job record (status: 'processing') + const jobResult = await ctx.repository.createFormJob({ + formId, + jobType: 'import-pdf', + metadata: { + documentId, + fileName, + userId: ctx.getUserId?.() || 'system', + }, + }); + + if (!jobResult.success) { + return failure({ + status: 500, + message: 'Failed to create processing job', + }); + } + + const job = jobResult.data; + + // Step 3: Fire async processing (don't await!) + processFormDocumentAsync(ctx, formId, job.id, documentId).catch(err => { + console.error('Async form processing failed:', err); + // Error already logged to database by processFormDocumentAsync + }); + + // Step 4: Return immediately + return success({ + id: formId, + timestamp: formResult.data.timestamp, + jobId: job.id, + status: 'processing', + }); + } + + // No document, form is ready immediately + return success({ + id: formId, + timestamp: formResult.data.timestamp, + status: 'ready', + }); +}; + +/** + * Async function that processes PDF and updates form + job. + * Runs in background, not awaited by HTTP request. + */ +async function processFormDocumentAsync( + ctx: InternalFormServiceContext, + formId: string, + jobId: string, + documentId: string +): Promise { + try { + // Get document data + const documentResult = await ctx.repository.getDocument(documentId); + if (!documentResult.success) { + await ctx.repository.failFormJob(jobId, { + message: `Document not found: ${documentResult.error}`, + }); + return; + } + + // Parse PDF via Bedrock + const parsePdfResult = await ctx.parsePdf(documentResult.data.data); + const { parsedPdf, fields } = parsePdfResult; + + // Get current form + const formResult = await ctx.repository.getForm(formId); + if (!formResult.success || !formResult.data) { + await ctx.repository.failFormJob(jobId, { + message: 'Form not found', + }); + return; + } + + // Build updated form with parsed patterns + const builder = new BlueprintBuilder(ctx.config, formResult.data); + + // Update summary from parsed PDF + builder.setFormSummary({ + title: parsedPdf.title || documentResult.data.path || 'Untitled', + description: parsedPdf.description || '', + }); + + // Add document reference await builder.addDocumentRef({ - id: addDocumentResult.data.id, + id: documentId, extract: parsedPdf, }); - } - if (summary) { - builder.setFormSummary(summary); - } + // Save updated form + const saveResult = await ctx.repository.saveForm(formId, builder.form); + if (!saveResult.success) { + await ctx.repository.failFormJob(jobId, { + message: `Failed to save form: ${saveResult.error}`, + }); + return; + } - const result = await ctx.repository.addForm(builder.form); - if (!result.success) { - console.error('Failed to add form:', result.error); - return failure({ - status: 500, - message: result.error, + // Mark job as completed + await ctx.repository.completeFormJob(jobId, { + patternsAdded: Object.keys(builder.form.patterns).length, + fieldsExtracted: Object.keys(fields).length, + documentId, + }); + + console.log(`Form ${formId} processed successfully`); + } catch (err) { + // Catch any unexpected errors + console.error('Unexpected error in processFormDocumentAsync:', err); + await ctx.repository.failFormJob(jobId, { + message: (err as Error).message, + stack: (err as Error).stack, }); } - return result; -}; +} diff --git a/packages/forms/src/util/workspace-root.ts b/packages/forms/src/util/workspace-root.ts new file mode 100644 index 00000000..c7afc92e --- /dev/null +++ b/packages/forms/src/util/workspace-root.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +/** + * Finds the workspace root by looking for pnpm-workspace.yaml. + * Walks up the directory tree from the current file location. + * + * @returns Absolute path to the workspace root + * @throws Error if workspace root cannot be found + */ +export const findWorkspaceRoot = (): string => { + // Start from the directory containing this file + let currentDir = dirname(fileURLToPath(import.meta.url)); + + // Walk up the directory tree looking for pnpm-workspace.yaml + while (currentDir !== dirname(currentDir)) { + const workspaceFile = join(currentDir, 'pnpm-workspace.yaml'); + if (existsSync(workspaceFile)) { + return currentDir; + } + currentDir = dirname(currentDir); + } + + throw new Error('Could not find workspace root (pnpm-workspace.yaml)'); +}; + +/** + * Gets the default shared cache directory path for the workspace. + * All tests and CLI tools should use this to ensure caches are shared. + * + * @returns Absolute path to __fixtures__/ai-cache at workspace root + */ +export const getDefaultCachePath = (): string => { + return join(findWorkspaceRoot(), 'packages', 'forms', 'fixtures', 'ai-cache'); +}; diff --git a/packages/forms/src/util/zod.ts b/packages/forms/src/util/zod.ts index 99e4c17b..c1885cef 100644 --- a/packages/forms/src/util/zod.ts +++ b/packages/forms/src/util/zod.ts @@ -62,9 +62,15 @@ export const convertZodErrorToFormErrors = ( zodError.issues.forEach((error: z.ZodIssue) => { const path = error.path.join('.'); if (error.code === 'too_small' && error.minimum === 1) { + // Replace default Zod message with consistent custom message + const message = + error.message === 'String must contain at least 1 character(s)' || + error.message.startsWith('Too small') + ? 'String must contain at least 1 character(s)' + : error.message; formErrors[path] = { type: 'required', - message: error.message, + message, }; } else { formErrors[path] = { diff --git a/packages/server/astro.config.mjs b/packages/server/astro.config.mjs index 85e2b0d6..1a53737c 100644 --- a/packages/server/astro.config.mjs +++ b/packages/server/astro.config.mjs @@ -29,6 +29,11 @@ export default defineConfig({ define: { 'import.meta.env.GITHUB': JSON.stringify(githubRepository), }, + resolve: { + conditions: process.env.NODE_ENV === 'production' + ? ['production', 'import', 'module', 'browser', 'default'] + : ['development', 'import', 'module', 'browser', 'default'], + }, }, }); diff --git a/packages/server/src/config/services.ts b/packages/server/src/config/services.ts index 840bd4ea..df43ed6a 100644 --- a/packages/server/src/config/services.ts +++ b/packages/server/src/config/services.ts @@ -1,9 +1,9 @@ import { type FormService, createFormService, - createFormsRepository, defaultFormConfig, } from '@flexion/forms-core'; +import { createFormsRepository } from '@flexion/forms-core/repository'; import { createProductionPdfParser } from '@flexion/forms-core/documents/pdf/context'; import { type ServerOptions } from './options.js'; diff --git a/packages/server/src/lib/api-client.ts b/packages/server/src/lib/api-client.ts index f31da251..fbdb9f26 100644 --- a/packages/server/src/lib/api-client.ts +++ b/packages/server/src/lib/api-client.ts @@ -6,6 +6,8 @@ import { type Blueprint, type FormService, type FormSummary, + type FormStatusResponse, + type GetFormStatusError, } from '@flexion/forms-core'; import { type FormServiceContext } from '@flexion/forms-core/context'; @@ -37,7 +39,12 @@ export class FormServiceClient implements FormService { } ): Promise< Result< - { timestamp: string; id: string }, + { + timestamp: string; + id: string; + jobId?: string; + status: 'ready' | 'processing'; + }, { status: number; message: string } > > { @@ -136,6 +143,15 @@ export class FormServiceClient implements FormService { throw new Error('Not implemented'); } + async getFormStatus( + formId: string + ): Promise> { + const response = await fetch( + `${this.ctx.baseUrl}api/forms/${formId}/status` + ); + return await response.json(); + } + getContext() { return {} as unknown as FormServiceContext; } diff --git a/packages/server/src/pages/api/forms/[id]/status.ts b/packages/server/src/pages/api/forms/[id]/status.ts new file mode 100644 index 00000000..a86e2645 --- /dev/null +++ b/packages/server/src/pages/api/forms/[id]/status.ts @@ -0,0 +1,20 @@ +import type { APIRoute } from 'astro'; +import { getServerContext } from '../../../../config/astro.js'; + +export const GET: APIRoute = async context => { + const ctx = await getServerContext(context); + const formId = context.params.id; + + if (!formId) { + return new Response('Form ID is required', { status: 400 }); + } + + const result = await ctx.formService.getFormStatus(formId); + + return new Response(JSON.stringify(result), { + headers: { + 'Content-Type': 'application/json', + }, + status: result.success ? 200 : result.error.status, + }); +}; diff --git a/packages/server/src/styles.css b/packages/server/src/styles.css index 26f6dc29..5809a82e 100644 --- a/packages/server/src/styles.css +++ b/packages/server/src/styles.css @@ -1 +1,2 @@ @import '@flexion/forms-design/static/uswds/styles/styles.css'; +@import '@flexion/forms-design/dist/assets/forms-design.css'; diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index d8f40787..6e11151a 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -7,7 +7,8 @@ "moduleResolution": "NodeNext", "jsx": "react", "resolveJsonModule": true, - "types": ["@testing-library/jest-dom"] + "types": ["@testing-library/jest-dom"], + "customConditions": ["development"] }, "include": [ "globals.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09125da5..e649ac55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,7 +153,7 @@ importers: version: link:../../packages/design astro: specifier: ^4.16.18 - version: 4.16.18(@types/node@22.14.0)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(typescript@6.0.0-dev.20251006) + version: 4.16.18(@types/node@22.14.0)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(typescript@6.0.0-dev.20251008) qs: specifier: ^6.13.0 version: 6.14.0 @@ -178,7 +178,7 @@ importers: devDependencies: '@astrojs/check': specifier: ^0.4.1 - version: 0.4.1(prettier@3.5.3)(typescript@6.0.0-dev.20251006) + version: 0.4.1(prettier@3.5.3)(typescript@6.0.0-dev.20251008) '@size-limit/preset-app': specifier: ^11.1.6 version: 11.2.0(size-limit@11.2.0) @@ -431,16 +431,16 @@ importers: version: 8.6.12(storybook@8.6.12(prettier@3.5.3)) '@storybook/react': specifier: ^8.4.7 - version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251006) + version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251008) '@storybook/react-vite': specifier: ^8.4.7 - version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.39.0)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251006)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)) + version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.39.0)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251008)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)) '@storybook/test': specifier: ^8.4.7 version: 8.6.12(storybook@8.6.12(prettier@3.5.3)) '@storybook/test-runner': specifier: ^0.21.0 - version: 0.21.3(@types/node@22.14.0)(storybook@8.6.12(prettier@3.5.3))(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + version: 0.21.3(@types/node@22.14.0)(storybook@8.6.12(prettier@3.5.3))(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) '@storybook/types': specifier: ^8.4.7 version: 8.6.12(storybook@8.6.12(prettier@3.5.3)) @@ -464,13 +464,13 @@ importers: version: 19.1.1(@types/react@18.3.20) '@typescript-eslint/eslint-plugin': specifier: ^7.18.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006))(eslint@8.57.1)(typescript@6.0.0-dev.20251006) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008))(eslint@8.57.1)(typescript@6.0.0-dev.20251008) '@typescript-eslint/parser': specifier: ^7.18.0 - version: 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006) + version: 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008) '@uswds/compile': specifier: ^1.2.2 - version: 1.2.2(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + version: 1.2.2(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)) @@ -503,7 +503,7 @@ importers: version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1) vite-plugin-dts: specifier: ^4.4.0 - version: 4.5.3(@types/node@22.14.0)(rollup@4.39.0)(typescript@6.0.0-dev.20251006)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)) + version: 4.5.3(@types/node@22.14.0)(rollup@4.39.0)(typescript@6.0.0-dev.20251008)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)) wait-on: specifier: ^7.2.0 version: 7.2.0 @@ -552,10 +552,10 @@ importers: dependencies: '@astrojs/check': specifier: ^0.9.4 - version: 0.9.4(prettier@3.5.3)(typescript@6.0.0-dev.20251006) + version: 0.9.4(prettier@3.5.3)(typescript@6.0.0-dev.20251008) '@astrojs/node': specifier: ^9.0.0 - version: 9.1.3(astro@5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251006)(yaml@2.7.1)) + version: 9.1.3(astro@5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251008)(yaml@2.7.1)) '@astrojs/react': specifier: ^4.1.2 version: 4.2.3(@types/node@22.14.0)(@types/react-dom@19.1.1(@types/react@18.3.20))(@types/react@18.3.20)(jiti@2.4.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1) @@ -576,7 +576,7 @@ importers: version: link:../design astro: specifier: ^5.1.3 - version: 5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251006)(yaml@2.7.1) + version: 5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251008)(yaml@2.7.1) express: specifier: ^4.21.0 version: 4.21.2 @@ -1645,6 +1645,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -4380,6 +4386,9 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -4956,6 +4965,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -8701,6 +8719,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -9509,8 +9532,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20251006: - resolution: {integrity: sha512-DHt+o3xZV+a3cambN7XN1l0RH9PP3bYhUn2pwWyHqSA7j5HSR4MjTEf2hFGpty3/IZj6zHztEBtq4B6bGRdJgg==} + typescript@6.0.0-dev.20251008: + resolution: {integrity: sha512-akqwl9XdobElV/XntkoCZ8w4NuY4zleLjYCO5lBCUaJ9suB7SotGJKuw5MoVZZ9drObDYXCyZL4z0RqQPtEm0A==} engines: {node: '>=14.17'} hasBin: true @@ -10432,24 +10455,24 @@ snapshots: '@csstools/css-tokenizer': 3.0.3 lru-cache: 10.4.3 - '@astrojs/check@0.4.1(prettier@3.5.3)(typescript@6.0.0-dev.20251006)': + '@astrojs/check@0.4.1(prettier@3.5.3)(typescript@6.0.0-dev.20251008)': dependencies: - '@astrojs/language-server': 2.15.4(prettier@3.5.3)(typescript@6.0.0-dev.20251006) + '@astrojs/language-server': 2.15.4(prettier@3.5.3)(typescript@6.0.0-dev.20251008) chokidar: 3.6.0 fast-glob: 3.3.3 kleur: 4.1.5 - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 yargs: 17.7.2 transitivePeerDependencies: - prettier - prettier-plugin-astro - '@astrojs/check@0.9.4(prettier@3.5.3)(typescript@6.0.0-dev.20251006)': + '@astrojs/check@0.9.4(prettier@3.5.3)(typescript@6.0.0-dev.20251008)': dependencies: - '@astrojs/language-server': 2.15.4(prettier@3.5.3)(typescript@6.0.0-dev.20251006) + '@astrojs/language-server': 2.15.4(prettier@3.5.3)(typescript@6.0.0-dev.20251008) chokidar: 4.0.3 kleur: 4.1.5 - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 yargs: 17.7.2 transitivePeerDependencies: - prettier @@ -10461,12 +10484,12 @@ snapshots: '@astrojs/internal-helpers@0.6.1': {} - '@astrojs/language-server@2.15.4(prettier@3.5.3)(typescript@6.0.0-dev.20251006)': + '@astrojs/language-server@2.15.4(prettier@3.5.3)(typescript@6.0.0-dev.20251008)': dependencies: '@astrojs/compiler': 2.11.0 '@astrojs/yaml2ts': 0.2.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@volar/kit': 2.4.12(typescript@6.0.0-dev.20251006) + '@volar/kit': 2.4.12(typescript@6.0.0-dev.20251008) '@volar/language-core': 2.4.12 '@volar/language-server': 2.4.12 '@volar/language-service': 2.4.12 @@ -10535,10 +10558,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/node@9.1.3(astro@5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251006)(yaml@2.7.1))': + '@astrojs/node@9.1.3(astro@5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251008)(yaml@2.7.1))': dependencies: '@astrojs/internal-helpers': 0.6.1 - astro: 5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251006)(yaml@2.7.1) + astro: 5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251008)(yaml@2.7.1) send: 1.2.0 server-destroy: 1.0.1 transitivePeerDependencies: @@ -11408,7 +11431,7 @@ snapshots: '@babel/traverse': 7.27.0 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.0 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11605,7 +11628,7 @@ snapshots: '@babel/parser': 7.27.0 '@babel/template': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.0 + debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -12148,6 +12171,11 @@ snapshots: eslint: 8.57.1 eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.1': {} '@eslint/eslintrc@2.1.4': @@ -12412,7 +12440,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006))': + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -12426,7 +12454,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + jest-config: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -12569,14 +12597,14 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@6.0.0-dev.20251006)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@6.0.0-dev.20251008)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1))': dependencies: glob: 10.4.5 magic-string: 0.27.0 - react-docgen-typescript: 2.2.2(typescript@6.0.0-dev.20251006) + react-docgen-typescript: 2.2.2(typescript@6.0.0-dev.20251008) vite: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1) optionalDependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 '@jridgewell/gen-mapping@0.3.8': dependencies: @@ -12913,11 +12941,11 @@ snapshots: '@puppeteer/browsers@2.9.0': dependencies: - debug: 4.4.0 + debug: 4.4.3 extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 - semver: 7.7.1 + semver: 7.7.3 tar-fs: 3.0.8 yargs: 17.7.2 transitivePeerDependencies: @@ -13181,7 +13209,7 @@ snapshots: '@sitespeed.io/tracium@0.3.3': dependencies: - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -13991,12 +14019,12 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.12(prettier@3.5.3) - '@storybook/react-vite@8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.39.0)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251006)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1))': + '@storybook/react-vite@8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.39.0)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251008)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@6.0.0-dev.20251006)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@6.0.0-dev.20251008)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)) '@rollup/pluginutils': 5.1.4(rollup@4.39.0) '@storybook/builder-vite': 8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)) - '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251006) + '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251008) find-up: 5.0.0 magic-string: 0.30.17 react: 18.3.1 @@ -14013,7 +14041,7 @@ snapshots: - supports-color - typescript - '@storybook/react@8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251006)': + '@storybook/react@8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.12(prettier@3.5.3))(typescript@6.0.0-dev.20251008)': dependencies: '@storybook/components': 8.6.12(storybook@8.6.12(prettier@3.5.3)) '@storybook/global': 5.0.0 @@ -14026,9 +14054,9 @@ snapshots: storybook: 8.6.12(prettier@3.5.3) optionalDependencies: '@storybook/test': 8.6.12(storybook@8.6.12(prettier@3.5.3)) - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 - '@storybook/test-runner@0.21.3(@types/node@22.14.0)(storybook@8.6.12(prettier@3.5.3))(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006))': + '@storybook/test-runner@0.21.3(@types/node@22.14.0)(storybook@8.6.12(prettier@3.5.3))(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008))': dependencies: '@babel/core': 7.26.10 '@babel/generator': 7.27.0 @@ -14039,14 +14067,14 @@ snapshots: '@swc/core': 1.11.16 '@swc/jest': 0.2.37(@swc/core@1.11.16) expect-playwright: 0.8.0 - jest: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + jest: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) jest-circus: 29.7.0 jest-environment-node: 29.7.0 jest-junit: 16.0.0 - jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006))) + jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008))) jest-runner: 29.7.0 jest-serializer-html: 7.1.0 - jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006))) + jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008))) nyc: 15.1.0 playwright: 1.51.1 storybook: 8.6.12(prettier@3.5.3) @@ -14625,34 +14653,34 @@ snapshots: '@types/yoga-layout@1.9.2': {} - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006))(eslint@8.57.1)(typescript@6.0.0-dev.20251006)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008))(eslint@8.57.1)(typescript@6.0.0-dev.20251008)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006) + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008) '@typescript-eslint/visitor-keys': 7.18.0 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.4.3(typescript@6.0.0-dev.20251006) + ts-api-utils: 1.4.3(typescript@6.0.0-dev.20251008) optionalDependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006)': + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.0-dev.20251006) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.0-dev.20251008) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.0 + debug: 4.4.3 eslint: 8.57.1 optionalDependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 transitivePeerDependencies: - supports-color @@ -14661,41 +14689,41 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006)': + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008)': dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.0-dev.20251006) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006) - debug: 4.4.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.0-dev.20251008) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008) + debug: 4.4.3 eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@6.0.0-dev.20251006) + ts-api-utils: 1.4.3(typescript@6.0.0-dev.20251008) optionalDependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 transitivePeerDependencies: - supports-color '@typescript-eslint/types@7.18.0': {} - '@typescript-eslint/typescript-estree@7.18.0(typescript@6.0.0-dev.20251006)': + '@typescript-eslint/typescript-estree@7.18.0(typescript@6.0.0-dev.20251008)': dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.0 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.1 - ts-api-utils: 1.4.3(typescript@6.0.0-dev.20251006) + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@6.0.0-dev.20251008) optionalDependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251006)': + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@6.0.0-dev.20251008)': dependencies: - '@eslint-community/eslint-utils': 4.5.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.0-dev.20251006) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.0-dev.20251008) eslint: 8.57.1 transitivePeerDependencies: - supports-color @@ -14708,11 +14736,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@uswds/compile@1.2.2(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006))': + '@uswds/compile@1.2.2(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008))': dependencies: autoprefixer: 10.4.20(postcss@8.5.2) gulp: 5.0.0 - gulp-postcss: 9.0.1(postcss@8.5.2)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + gulp-postcss: 9.0.1(postcss@8.5.2)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) gulp-rename: 2.0.0 gulp-replace: 1.1.4 gulp-sass: 5.1.0 @@ -14871,12 +14899,12 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 - '@volar/kit@2.4.12(typescript@6.0.0-dev.20251006)': + '@volar/kit@2.4.12(typescript@6.0.0-dev.20251008)': dependencies: '@volar/language-service': 2.4.12 '@volar/typescript': 2.4.12 typesafe-path: 0.2.2 - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.1.0 @@ -14939,7 +14967,7 @@ snapshots: de-indent: 1.0.2 he: 1.2.0 - '@vue/language-core@2.2.0(typescript@6.0.0-dev.20251006)': + '@vue/language-core@2.2.0(typescript@6.0.0-dev.20251008)': dependencies: '@volar/language-core': 2.4.12 '@vue/compiler-dom': 3.5.13 @@ -14950,7 +14978,7 @@ snapshots: muggle-string: 0.4.1 path-browserify: 1.0.1 optionalDependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 '@vue/shared@3.5.13': {} @@ -15059,7 +15087,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -15345,7 +15373,7 @@ snapshots: astral-regex@2.0.0: {} - astro@4.16.18(@types/node@22.14.0)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(typescript@6.0.0-dev.20251006): + astro@4.16.18(@types/node@22.14.0)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(typescript@6.0.0-dev.20251008): dependencies: '@astrojs/compiler': 2.11.0 '@astrojs/internal-helpers': 0.4.1 @@ -15398,7 +15426,7 @@ snapshots: semver: 7.7.1 shiki: 1.29.2 tinyexec: 0.3.2 - tsconfck: 3.1.5(typescript@6.0.0-dev.20251006) + tsconfck: 3.1.5(typescript@6.0.0-dev.20251008) unist-util-visit: 5.0.0 vfile: 6.0.3 vite: 5.4.17(@types/node@22.14.0)(sass-embedded@1.83.4)(terser@5.39.0) @@ -15408,7 +15436,7 @@ snapshots: yargs-parser: 21.1.1 zod: 3.24.2 zod-to-json-schema: 3.24.5(zod@3.24.2) - zod-to-ts: 1.2.0(typescript@6.0.0-dev.20251006)(zod@3.24.2) + zod-to-ts: 1.2.0(typescript@6.0.0-dev.20251008)(zod@3.24.2) optionalDependencies: sharp: 0.33.5 transitivePeerDependencies: @@ -15424,7 +15452,7 @@ snapshots: - terser - typescript - astro@5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251006)(yaml@2.7.1): + astro@5.6.0(@types/node@22.14.0)(aws4fetch@1.0.20)(jiti@2.4.2)(rollup@4.39.0)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(typescript@6.0.0-dev.20251008)(yaml@2.7.1): dependencies: '@astrojs/compiler': 2.11.0 '@astrojs/internal-helpers': 0.6.1 @@ -15470,7 +15498,7 @@ snapshots: shiki: 3.2.1 tinyexec: 0.3.2 tinyglobby: 0.2.12 - tsconfck: 3.1.5(typescript@6.0.0-dev.20251006) + tsconfck: 3.1.5(typescript@6.0.0-dev.20251008) ultrahtml: 1.5.3 unist-util-visit: 5.0.0 unstorage: 1.15.0(aws4fetch@1.0.20) @@ -15482,7 +15510,7 @@ snapshots: yocto-spinner: 0.2.1 zod: 3.24.2 zod-to-json-schema: 3.24.5(zod@3.24.2) - zod-to-ts: 1.2.0(typescript@6.0.0-dev.20251006)(zod@3.24.2) + zod-to-ts: 1.2.0(typescript@6.0.0-dev.20251008)(zod@3.24.2) optionalDependencies: sharp: 0.33.5 transitivePeerDependencies: @@ -15777,6 +15805,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -16234,13 +16266,13 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 - create-jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)): + create-jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + jest-config: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -16359,6 +16391,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} decamelize@5.0.1: {} @@ -16455,7 +16491,7 @@ snapshots: detect-port@1.6.1: dependencies: address: 1.2.2 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -16569,7 +16605,7 @@ snapshots: dependencies: semver: 7.7.1 shelljs: 0.8.5 - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 dset@3.1.4: {} @@ -16760,7 +16796,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.2): dependencies: - debug: 4.4.0 + debug: 4.4.3 esbuild: 0.25.2 transitivePeerDependencies: - supports-color @@ -17066,7 +17102,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.0 + debug: 4.4.3 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -17391,7 +17427,7 @@ snapshots: dependencies: basic-ftp: 5.0.5 data-uri-to-buffer: 6.0.2 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -17542,12 +17578,12 @@ snapshots: v8flags: 4.0.1 yargs: 16.2.0 - gulp-postcss@9.0.1(postcss@8.5.2)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)): + gulp-postcss@9.0.1(postcss@8.5.2)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)): dependencies: fancy-log: 1.3.3 plugin-error: 1.0.1 postcss: 8.5.2 - postcss-load-config: 3.1.4(postcss@8.5.2)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + postcss-load-config: 3.1.4(postcss@8.5.2)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) vinyl-sourcemaps-apply: 0.2.1 transitivePeerDependencies: - ts-node @@ -17770,7 +17806,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -17804,14 +17840,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -18211,7 +18247,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.0 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -18286,16 +18322,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)): + jest-cli@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + create-jest: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + jest-config: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -18305,7 +18341,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)): + jest-config@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)): dependencies: '@babel/core': 7.26.10 '@jest/test-sequencer': 29.7.0 @@ -18331,7 +18367,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.14.0 - ts-node: 10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006) + ts-node: 10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -18419,10 +18455,10 @@ snapshots: '@types/node': 22.14.0 jest-util: 29.7.0 - jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006))): + jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008))): dependencies: expect-playwright: 0.8.0 - jest: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + jest: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) jest-circus: 29.7.0 jest-environment-node: 29.7.0 jest-process-manager: 0.4.0 @@ -18554,7 +18590,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.7.1 + semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -18576,11 +18612,11 @@ snapshots: leven: 3.1.0 pretty-format: 29.7.0 - jest-watch-typeahead@2.2.2(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006))): + jest-watch-typeahead@2.2.2(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008))): dependencies: ansi-escapes: 6.2.1 chalk: 5.4.1 - jest: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + jest: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -18611,12 +18647,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)): + jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)) + jest-cli: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -18940,7 +18976,7 @@ snapshots: log4js@6.9.1: dependencies: date-format: 4.0.14 - debug: 4.4.0 + debug: 4.4.3 flatted: 3.3.3 rfdc: 1.4.1 streamroller: 3.1.5 @@ -19000,7 +19036,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.1 + semver: 7.7.3 make-error@1.3.6: {} @@ -19421,7 +19457,7 @@ snapshots: minimatch@9.0.5: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.0.2 minimist@1.2.8: {} @@ -19494,7 +19530,7 @@ snapshots: node-abi@3.74.0: dependencies: - semver: 7.7.1 + semver: 7.7.3 node-fetch-native@1.6.6: {} @@ -19793,7 +19829,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 get-uri: 6.0.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -20042,7 +20078,7 @@ snapshots: portfinder@1.0.35: dependencies: async: 3.2.6 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -20053,13 +20089,13 @@ snapshots: csso: 5.0.5 postcss: 8.5.2 - postcss-load-config@3.1.4(postcss@8.5.2)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006)): + postcss-load-config@3.1.4(postcss@8.5.2)(ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.5.2 - ts-node: 10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006) + ts-node: 10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008) postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.3)(tsx@4.20.6)(yaml@2.7.1): dependencies: @@ -20311,7 +20347,7 @@ snapshots: proxy-agent@6.4.0: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.3.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -20324,7 +20360,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -20402,9 +20438,9 @@ snapshots: - bufferutil - utf-8-validate - react-docgen-typescript@2.2.2(typescript@6.0.0-dev.20251006): + react-docgen-typescript@2.2.2(typescript@6.0.0-dev.20251008): dependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 react-docgen@7.1.1: dependencies: @@ -20997,6 +21033,8 @@ snapshots: semver@7.7.1: {} + semver@7.7.3: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -21017,7 +21055,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.0 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -21226,7 +21264,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 socks: 2.8.4 transitivePeerDependencies: - supports-color @@ -21374,7 +21412,7 @@ snapshots: streamroller@3.1.5: dependencies: date-format: 4.0.14 - debug: 4.4.0 + debug: 4.4.3 fs-extra: 8.1.0 transitivePeerDependencies: - supports-color @@ -21526,7 +21564,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.0 + debug: 4.4.3 fast-safe-stringify: 2.1.1 form-data: 4.0.2 formidable: 3.5.2 @@ -21763,9 +21801,9 @@ snapshots: trough@2.2.0: {} - ts-api-utils@1.4.3(typescript@6.0.0-dev.20251006): + ts-api-utils@1.4.3(typescript@6.0.0-dev.20251008): dependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 ts-dedent@2.2.0: {} @@ -21795,7 +21833,7 @@ snapshots: optionalDependencies: '@swc/core': 1.11.16 - ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251006): + ts-node@10.9.2(@swc/core@1.11.16)(@types/node@22.14.0)(typescript@6.0.0-dev.20251008): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -21809,7 +21847,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: @@ -21820,9 +21858,9 @@ snapshots: optionalDependencies: typescript: 5.8.2 - tsconfck@3.1.5(typescript@6.0.0-dev.20251006): + tsconfck@3.1.5(typescript@6.0.0-dev.20251008): optionalDependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 tsconfig-paths@4.2.0: dependencies: @@ -21967,13 +22005,13 @@ snapshots: typescript-auto-import-cache@0.3.5: dependencies: - semver: 7.7.1 + semver: 7.7.3 typescript@5.4.5: {} typescript@5.8.2: {} - typescript@6.0.0-dev.20251006: {} + typescript@6.0.0-dev.20251008: {} uc.micro@2.1.0: {} @@ -22245,18 +22283,18 @@ snapshots: - tsx - yaml - vite-plugin-dts@4.5.3(@types/node@22.14.0)(rollup@4.39.0)(typescript@6.0.0-dev.20251006)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)): + vite-plugin-dts@4.5.3(@types/node@22.14.0)(rollup@4.39.0)(typescript@6.0.0-dev.20251008)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1)): dependencies: '@microsoft/api-extractor': 7.52.2(@types/node@22.14.0) '@rollup/pluginutils': 5.1.4(rollup@4.39.0) '@volar/typescript': 2.4.12 - '@vue/language-core': 2.2.0(typescript@6.0.0-dev.20251006) + '@vue/language-core': 2.2.0(typescript@6.0.0-dev.20251008) compare-versions: 6.1.1 debug: 4.4.0 kolorist: 1.8.0 local-pkg: 1.1.1 magic-string: 0.30.17 - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 optionalDependencies: vite: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(sass-embedded@1.83.4)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.1) transitivePeerDependencies: @@ -22410,7 +22448,7 @@ snapshots: volar-service-typescript@0.0.62(@volar/language-service@2.4.12): dependencies: path-browserify: 1.0.1 - semver: 7.7.1 + semver: 7.7.3 typescript-auto-import-cache: 0.3.5 vscode-languageserver-textdocument: 1.0.12 vscode-nls: 5.2.0 @@ -22499,7 +22537,7 @@ snapshots: dependencies: chalk: 2.4.2 commander: 3.0.2 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -22825,9 +22863,9 @@ snapshots: dependencies: zod: 3.24.2 - zod-to-ts@1.2.0(typescript@6.0.0-dev.20251006)(zod@3.24.2): + zod-to-ts@1.2.0(typescript@6.0.0-dev.20251008)(zod@3.24.2): dependencies: - typescript: 6.0.0-dev.20251006 + typescript: 6.0.0-dev.20251008 zod: 3.24.2 zod@3.22.4: {} diff --git a/turbo.json b/turbo.json index 1e8d9761..78a466bb 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turbo.build/schema.json", + "ui": "tui", "tasks": { "build": { "dependsOn": ["^build"],