diff --git a/.changeset/callable-compiled-queries.md b/.changeset/callable-compiled-queries.md new file mode 100644 index 00000000..16c32fd0 --- /dev/null +++ b/.changeset/callable-compiled-queries.md @@ -0,0 +1,8 @@ +--- +'@cleverbrush/knex-schema': minor +'@cleverbrush/orm': minor +--- + +Add `parameter('name')` for callable PostgreSQL SELECT templates with schema-inferred positional arguments. Compile SQL, binding slots and decoding once on first invocation or `.toSQL(...)`; reuse them with independent values on later calls. `.query(...)` returns an ordinary composable bound reader. + +Support repeated names, grouped filters, fixed membership/range slots, alias joins, relation and STI/CTI customizers, and caller-owned transactions. Preserve storage types, null comparison semantics, result schemas, property navigation, and ORM identity tracking. Reject unsupported placeholder positions and unbound terminals or writes before execution. diff --git a/.changeset/circular-object-hashing.md b/.changeset/circular-object-hashing.md new file mode 100644 index 00000000..cef64d69 --- /dev/null +++ b/.changeset/circular-object-hashing.md @@ -0,0 +1,5 @@ +--- +'@cleverbrush/deep': patch +--- + +Prevent circular object references from overflowing the stack during internal object hashing while preserving acyclic hash results. diff --git a/.changeset/close-rejected-subscriptions.md b/.changeset/close-rejected-subscriptions.md new file mode 100644 index 00000000..5d639d76 --- /dev/null +++ b/.changeset/close-rejected-subscriptions.md @@ -0,0 +1,6 @@ +--- +'@cleverbrush/server': patch +--- + +Close WebSocket subscriptions when middleware rejects the request without +calling the next handler, preventing idle unauthorized connections. diff --git a/.changeset/composable-typed-queries.md b/.changeset/composable-typed-queries.md new file mode 100644 index 00000000..1b396dd7 --- /dev/null +++ b/.changeset/composable-typed-queries.md @@ -0,0 +1,11 @@ +--- +'@cleverbrush/knex-schema': minor +'@cleverbrush/orm': minor +--- + +Add typed flat joined projections, aggregate expressions and scalar helpers +with optional output schemas, and composite keyset pagination. Preserve parent +ordering during eager loading and infer related-query customization types. +Existing aggregate and single-column cursor APIs remain available unchanged. +Export reusable SQL identifier validation, preserve schema inference throughout +bound query factories, and document the public query/ORM APIs for IDE tooltips. diff --git a/.changeset/config.json b/.changeset/config.json index f6700320..00062adf 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,6 +9,7 @@ "@cleverbrush/deep", "@cleverbrush/async", "@cleverbrush/scheduler", + "@cleverbrush/scheduler-postgres", "@cleverbrush/mapper", "@cleverbrush/knex-clickhouse", "@cleverbrush/react-form", @@ -22,7 +23,9 @@ "@cleverbrush/server", "@cleverbrush/server-openapi", "@cleverbrush/orm", - "@cleverbrush/orm-cli" + "@cleverbrush/orm-cli", + "@cleverbrush/storage", + "@cleverbrush/storage-s3" ] ], "linked": [], diff --git a/.changeset/connection-independent-queries.md b/.changeset/connection-independent-queries.md new file mode 100644 index 00000000..9a25aba1 --- /dev/null +++ b/.changeset/connection-independent-queries.md @@ -0,0 +1,14 @@ +--- +'@cleverbrush/knex-schema': minor +'@cleverbrush/orm': minor +--- + +Add immutable connection-independent query definitions with `query(Schema)`. +Define typed reads once and supply a Knex connection or transaction through +`definition(knex, ...values)`, `.query(knex, ...values)` or `.toSQL(knex, ...values)`. + +Preserve inferred parameters, projections, relation/variant schemas and existing +connection-first APIs. Capture selectors/scopes/customizers once, compile SELECTs +lazily per definition and actual Knex instance, and bind ordinary readers before +native SQL composition, pagination or supported writes. Include runtime, +declaration and PostgreSQL integration coverage and multi-file usage guidance. diff --git a/.changeset/cors-preflight.md b/.changeset/cors-preflight.md new file mode 100644 index 00000000..02aaf745 --- /dev/null +++ b/.changeset/cors-preflight.md @@ -0,0 +1,10 @@ +--- +"@cleverbrush/server": minor +--- + +Add opt-in server-wide CORS through `ServerBuilder.useCors()` and +`ServerCorsOptions`. Handle route-aware preflights before authentication while +preserving the normal pipeline for actual requests. Support exact origins, +origin predicates, explicit request/response header policies, credentials and +preflight cache duration. Reject disallowed origins before handlers and finalize +CORS headers per physical response, including errors and cache/idempotency replays. diff --git a/.changeset/durable-job-execution.md b/.changeset/durable-job-execution.md new file mode 100644 index 00000000..8fac4c55 --- /dev/null +++ b/.changeset/durable-job-execution.md @@ -0,0 +1,28 @@ +--- +"@cleverbrush/scheduler": major +"@cleverbrush/scheduler-postgres": major +--- + +Redesign the scheduler for versioned immediate, delayed and recurring jobs, +typed separate handlers, durable ordered progress and fenced worker leases. +Add an independently installed PostgreSQL adapter using Framework ORM and +knex-schema, explicit migrations, transactional enqueue and restart recovery. + +Retries are opt-in and rerun whole handlers. Calendar triggers use explicit UTC +or IANA zones with persisted cursors and missed/overlap policies. See the +scheduler v4.x-to-v5 migration guide for the breaking API and rollout steps. +The adapter joins the fixed Framework release group. + +Retain schema-driven minute/day/week/month/year definitions and their +discriminated Schedule type, exposing individual schemas and the Schemas facade. +Normalize recurrence defaults, dates and weekday order before fingerprinting; +unchanged registrations retain their cursor and start anchor. Preserve the +one-based calculator index and accept the deprecated maxOccurences spelling +while rejecting ambiguous dual spelling. Name the explicit persistence option +storageRepository. Derive PostgreSQL row and entity types from schema definitions +without parallel hand-written row types. + +Use native Date/Intl calendar calculations without an additional date-time +runtime dependency. Preserve DST-gap skipping and earlier-fold selection, +including non-hour transitions and skipped calendar dates, independently of +the host time zone. Bound minute schedules to the representable Date range. diff --git a/.changeset/immutable-query-major.md b/.changeset/immutable-query-major.md new file mode 100644 index 00000000..631bb9fe --- /dev/null +++ b/.changeset/immutable-query-major.md @@ -0,0 +1,31 @@ +--- +"@cleverbrush/async": major +"@cleverbrush/auth": major +"@cleverbrush/client": major +"@cleverbrush/deep": major +"@cleverbrush/di": major +"@cleverbrush/env": major +"@cleverbrush/knex-clickhouse": major +"@cleverbrush/knex-schema": major +"@cleverbrush/log": major +"@cleverbrush/mapper": major +"@cleverbrush/orm-cli": major +"@cleverbrush/orm": major +"@cleverbrush/otel": major +"@cleverbrush/react-form": major +"@cleverbrush/scheduler": major +"@cleverbrush/schema-json": major +"@cleverbrush/schema": major +"@cleverbrush/server-openapi": major +"@cleverbrush/server": major +--- + +Make Framework query builders immutable and infer row schemas automatically. + +Retain returned query builders, return synchronous builders from scopes and grouped predicates, and supply an explicit Framework object output schema for opaque raw SELECTs. Ordinary, aliased, polymorphic and ORM queries expose their row schemas directly. Projections replace scalar selections, and projected/aggregate/raw queries cannot perform entity writes. Reads and write-returning rows consistently preserve exact decimal/bigint strings, Date objects and SQL nulls. + +All published Framework packages advance together to the next major version. Tracked entity objects remain mutable. + +### Migrating from v4.x to v5 + +Remove `withRowSchema()` calls and retain each configured query instead of relying on mutation. Replace raw base-query overloads with explicit output contracts. See `libs/knex-schema/MIGRATION-v5.md` for the complete migration guide. diff --git a/.changeset/modular-implementations-and-error-policies.md b/.changeset/modular-implementations-and-error-policies.md new file mode 100644 index 00000000..2f8176aa --- /dev/null +++ b/.changeset/modular-implementations-and-error-policies.md @@ -0,0 +1,9 @@ +--- +"@cleverbrush/server": minor +--- + +Add immutable, contract-bound implementation scopes and feature-module composition +with complete operation coverage, cross-file handler inference, shared/per-operation +DI, and unchanged HTTP/subscription registration. Add reusable, endpoint-checked +error policies and a standalone handler wrapper. Existing registration APIs and +the browser-safe contract entry point remain supported. diff --git a/.changeset/object-storage-s3.md b/.changeset/object-storage-s3.md new file mode 100644 index 00000000..a3aa19cc --- /dev/null +++ b/.changeset/object-storage-s3.md @@ -0,0 +1,13 @@ +--- +"@cleverbrush/storage": minor +"@cleverbrush/storage-s3": minor +--- + +Add provider-neutral object storage contracts, portable errors and safe public URL +mapping. Add an S3-compatible adapter with explicit custom endpoints, credentials, +addressing style, key prefixes and independently configured public asset URLs. +Support streamed reads, bounded multipart writes, metadata-preserving copies, +idempotent deletion, cancellation and asynchronous disposal through `await using` +on the shared storage contract. Include shared contract +coverage, real Garage integration tests, and configuration examples for hosted +and self-hosted services. diff --git a/.changeset/polymorphic-write-lifecycle.md b/.changeset/polymorphic-write-lifecycle.md new file mode 100644 index 00000000..f9765a37 --- /dev/null +++ b/.changeset/polymorphic-write-lifecycle.md @@ -0,0 +1,19 @@ +--- +"@cleverbrush/orm": major +"@cleverbrush/knex-schema": patch +--- + +Honor hooks, timestamps and base-schema soft deletion in explicit variant writes +and tracked polymorphic saves, for single-table and class-table inheritance. +Add `ofVariant(key).restore()` and `hardDelete()`; CTI soft deletion retains the +child row, while permanent deletion removes both rows atomically. + +**Migration:** Variant `delete()` now respects the base schema's `.softDelete()`. +Use `hardDelete()` for physical removal, and `withDeleted()` to include hidden +rows. Review lifecycle hooks that now run, and remove identity/discriminator/join +keys from update patches. This change is part of the coordinated v5 major release. + +Capture mutation targets inside the write transaction, preserve query restrictions +and transaction bindings, and use savepoints for caller-owned transactions. Fix +base/variant column mapping and visibility of extension-managed deletion columns; +retain exact numeric keys and tracked optimistic-concurrency/rollback semantics. diff --git a/.changeset/preserve-column-default-changes.md b/.changeset/preserve-column-default-changes.md new file mode 100644 index 00000000..af45c197 --- /dev/null +++ b/.changeset/preserve-column-default-changes.md @@ -0,0 +1,9 @@ +--- +'@cleverbrush/knex-schema': patch +--- + +Apply and generate column default changes without dropping, retyping or changing +the nullability of the existing column. Removing a default executes DROP DEFAULT; +setting one executes SET DEFAULT with safely quoted values (including question +marks and backslashes) and support for explicit SQL expressions. Generated down +migrations restore the original default instead of assuming a timestamp column. diff --git a/.changeset/projection-aware-synchronous-mapping.md b/.changeset/projection-aware-synchronous-mapping.md new file mode 100644 index 00000000..647a4c89 --- /dev/null +++ b/.changeset/projection-aware-synchronous-mapping.md @@ -0,0 +1,23 @@ +--- +"@cleverbrush/mapper": minor +"@cleverbrush/knex-schema": minor +"@cleverbrush/orm": minor +"@cleverbrush/schema": minor +"@cleverbrush/di": minor +--- + +Add opt-in immutable, detached PostgreSQL reads with projection-aware runtime +schemas, precise decimal/bigint decoding, nested relation graphs and explicit +STI/CTI branch schemas. Reuse result schemas directly in separately defined +application mappings without duplicating projection schemas. + +Add `getSyncMapper()` with synchronous eligibility inferred through the existing +`configure()` API, completeness checks, nested mapping propagation and runtime +thenable guards. Existing queries and asynchronous mapping behavior are unchanged. + +Add application-agnostic typed metadata extension methods that retain metadata +through immutable chains. Use these in database extensions without modifying +global schema prototypes. + +Preserve typed function-schema compatibility in dependency injection when a +function declares its return schema. diff --git a/.changeset/property-definition-navigation.md b/.changeset/property-definition-navigation.md new file mode 100644 index 00000000..f1f16c32 --- /dev/null +++ b/.changeset/property-definition-navigation.md @@ -0,0 +1,14 @@ +--- +'@cleverbrush/knex-schema': patch +'@cleverbrush/orm': patch +'@cleverbrush/mapper': patch +'@cleverbrush/schema-json': patch +'@cleverbrush/server': patch +'@cleverbrush/deep': patch +--- + +Preserve original property declarations and JSDoc through derived types so +editors can navigate to definitions and show property documentation. This covers +query selectors, rows, projections and write payloads; declared relation includes; +mapper targets; JSON Schema inferred values; composed API groups and injected +services; and merged object properties. Runtime behavior is unchanged. diff --git a/.changeset/schema-aware-read-predicates.md b/.changeset/schema-aware-read-predicates.md new file mode 100644 index 00000000..c105dbec --- /dev/null +++ b/.changeset/schema-aware-read-predicates.md @@ -0,0 +1,10 @@ +--- +"@cleverbrush/knex-schema": minor +--- + +Add shape-preserving grouped AND/OR predicates, captured IN/EXISTS subqueries, +bound raw predicates and ordering, and typed SQL column references to ordinary +and aliased schema-aware readers. These capabilities also work in ordinary ORM +reads and nested relation customizers while retaining immutable query plans and +stable row-schema identity. Group callbacks are synchronous and predicate-only; +unrestricted raw query mutation remains unavailable. diff --git a/.changeset/schema-boundaries.md b/.changeset/schema-boundaries.md new file mode 100644 index 00000000..199781d1 --- /dev/null +++ b/.changeset/schema-boundaries.md @@ -0,0 +1,13 @@ +--- +"@cleverbrush/schema": minor +"@cleverbrush/schema-json": minor +"@cleverbrush/server-openapi": minor +--- + +Add optional-aware fallbacks and preprocessing, and automatic named schema +references through ordinary immutable use-site modifiers, without a wrapper API. +Shape, validation-rule, default, fallback and extension changes clear inherited +names; apply schemaName after those edits to establish a new named definition. +Preserve one canonical definition in JSON +Schema, OpenAPI and AsyncAPI with strict name collision checks. Keep existing +type inference and optional null acceptance unchanged. diff --git a/.changeset/secure-v5-release-readiness.md b/.changeset/secure-v5-release-readiness.md new file mode 100644 index 00000000..ad8bff3f --- /dev/null +++ b/.changeset/secure-v5-release-readiness.md @@ -0,0 +1,32 @@ +--- +'@cleverbrush/async': major +'@cleverbrush/auth': major +'@cleverbrush/client': major +'@cleverbrush/deep': major +'@cleverbrush/di': major +'@cleverbrush/env': major +'@cleverbrush/knex-clickhouse': major +'@cleverbrush/knex-schema': major +'@cleverbrush/log': major +'@cleverbrush/mapper': major +'@cleverbrush/orm': major +'@cleverbrush/orm-cli': major +'@cleverbrush/otel': major +'@cleverbrush/react-form': major +'@cleverbrush/scheduler': major +'@cleverbrush/scheduler-postgres': major +'@cleverbrush/schema': major +'@cleverbrush/schema-json': major +'@cleverbrush/server': major +'@cleverbrush/server-openapi': major +'@cleverbrush/storage': major +'@cleverbrush/storage-s3': major +--- + +Require Node.js 24+ consistently across all published packages. Correct the root and all published-package license files to BSD-3-Clause, matching package metadata and documentation, and verify license consistency in source and npm tarballs. See docs/MIGRATION-v5.md for migration guidance. + +Harden JWT key/algorithm and claim validation, cookie parsing/serialization, and request-body lifecycle handling. Require explicit authorization scope for bounded server idempotency; coalesce concurrent retries and capture full response bodies. Bound response caching and bypass private, no-store and cookie-setting responses. + +Preserve DI scope validation through factories and propagate registered optional-service failures. Fix batch response status/header capture, timeout abort-listener cleanup, concurrent deduplication response cloning, CLI database cleanup on validation/production-guard failures, and the missing client idempotency JavaScript export. Update security-sensitive dependencies and ensure OpenTelemetry disable flags override SDK defaults. + +Add regression tests, package-consumer smoke checks, package-level unit coverage floors, migration/security documentation and release validation gates. diff --git a/.changeset/server-form-validation.md b/.changeset/server-form-validation.md new file mode 100644 index 00000000..55850f3a --- /dev/null +++ b/.changeset/server-form-validation.md @@ -0,0 +1,10 @@ +--- +'@cleverbrush/schema': minor +'@cleverbrush/server': minor +'@cleverbrush/client': minor +'@cleverbrush/react-form': minor +--- + +Preserve structured server validation issues through typed clients and form +submissions. Add transport-independent external form issues, typed indexed array +fields, and precise indexed validation paths without changing existing form APIs. diff --git a/.changeset/subscription-empty-history.md b/.changeset/subscription-empty-history.md new file mode 100644 index 00000000..47e68729 --- /dev/null +++ b/.changeset/subscription-empty-history.md @@ -0,0 +1,5 @@ +--- +'@cleverbrush/client': patch +--- + +Respect maxEvents: 0 in useSubscription by retaining only lastEvent and leaving the event history empty. diff --git a/.changeset/typed-idempotent-operations.md b/.changeset/typed-idempotent-operations.md new file mode 100644 index 00000000..9c55df32 --- /dev/null +++ b/.changeset/typed-idempotent-operations.md @@ -0,0 +1,11 @@ +--- +"@cleverbrush/server": minor +"@cleverbrush/client": minor +"@cleverbrush/server-openapi": minor +--- + +Add contract-declared mutation replay with typed async request preparation, +explicit authorization scopes, and consistent endpoint error policies. Generate +client keys and coordinate HTTP retries/batching from the contract without +changing ordinary client calls. Document replay headers and framework errors in +OpenAPI while preserving domain response schemas. diff --git a/.changeset/typed-multipart-json-documents.md b/.changeset/typed-multipart-json-documents.md new file mode 100644 index 00000000..712a0a28 --- /dev/null +++ b/.changeset/typed-multipart-json-documents.md @@ -0,0 +1,20 @@ +--- +"@cleverbrush/server": minor +"@cleverbrush/client": minor +"@cleverbrush/server-openapi": minor +"@cleverbrush/knex-schema": minor +"@cleverbrush/orm": minor +--- + +Add schema-based single and multiple file upload contracts, typed multipart client +serialization, and matching OpenAPI schemas. Enforce multipart body, file, field, +and part limits, reject truncated or duplicate singleton uploads, and support +file-only endpoints. Existing options-only uploads retain their single-file +shape and explicit MIME rejection reporting. + +Add lossless JSONB object reads and writes using native object schemas with +`.acceptUnknownProps().jsonb()`. Preserve nested extension data through returning +rows and projections, validate JSON extensions in the database layer, align +nullable object column DDL with reads, and track nested edits independently in +the ORM. Fix the PostgreSQL +upsert returning path exercised by document round trips. diff --git a/.github/pr-evidence/contract-implementations.png b/.github/pr-evidence/contract-implementations.png new file mode 100644 index 00000000..ab584dfc Binary files /dev/null and b/.github/pr-evidence/contract-implementations.png differ diff --git a/.github/pr-evidence/durable-scheduler.png b/.github/pr-evidence/durable-scheduler.png new file mode 100644 index 00000000..85197c4e Binary files /dev/null and b/.github/pr-evidence/durable-scheduler.png differ diff --git a/.github/pr-evidence/server-form-validation.png b/.github/pr-evidence/server-form-validation.png new file mode 100644 index 00000000..275945d0 Binary files /dev/null and b/.github/pr-evidence/server-form-validation.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c9af5d0..22a9bb44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: - push: - branches: [master] pull_request: + workflow_call: + +permissions: + contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: framework-validation-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -43,3 +45,103 @@ jobs: - name: Test & Typecheck run: npm run test + + - name: Coverage and package floors + run: npm run test:coverage + + - name: Dependency security + run: npm audit --audit-level=high + + - name: Packed package smoke tests + run: npm run test:packages + + - name: Build documentation websites + run: npm run build:schema-site && npm run build:docs-site + + - name: Generate every package API reference + run: npx typedoc --out "$RUNNER_TEMP/framework-api-docs" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-node-${{ matrix.node }} + path: coverage/ + + query-integration: + name: PostgreSQL Query Integration + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: framework_queries + POSTGRES_USER: framework_test + POSTGRES_PASSWORD: framework_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U framework_test -d framework_queries" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + QUERY_TEST_DATABASE_URL: postgres://framework_test:framework_test@127.0.0.1:5432/framework_queries + SCHEDULER_TEST_DATABASE_URL: postgres://framework_test:framework_test@127.0.0.1:5432/framework_queries + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:queries:integration + - run: npm run test:scheduler:integration + - run: npm run test:queries:integration && npm run test:scheduler:integration + env: + TZ: America/Los_Angeles + - run: node demos/durable-jobs/demo.ts + - run: node demos/durable-jobs/periodic.ts --fast + + storage-integration: + name: S3 Storage Integration (Garage) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:storage:integration + + demo-e2e: + name: Demo API, browser and telemetry E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + CI: 'true' + KEEP_STACK: '0' + COMPOSE_PROJECT_NAME: framework-e2e + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run playwright:install --workspace @cleverbrush/demo-e2e -- --with-deps + - run: npm run test:e2e + - name: Collect isolated stack logs + if: failure() + run: docker compose -f demos/docker-compose.yml logs --no-color > e2e-stack.log + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: demo-e2e-logs + path: e2e-stack.log + - name: Remove disposable CI stack + if: always() + run: docker compose -f demos/docker-compose.yml down --volumes --remove-orphans diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml index a213f01f..6c8fda99 100644 --- a/.github/workflows/publish-beta.yml +++ b/.github/workflows/publish-beta.yml @@ -12,7 +12,11 @@ permissions: contents: read jobs: + validate: + uses: ./.github/workflows/ci.yml + publish-beta: + needs: validate name: Publish Beta to npm runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d9a9633..d2e727b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,13 @@ permissions: pull-requests: write jobs: + validate: + permissions: + contents: read + uses: ./.github/workflows/ci.yml + release: + needs: validate name: Version & Publish runs-on: ubuntu-latest diff --git a/.nvmrc b/.nvmrc index 2bd5a0a9..a45fd52c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +24 diff --git a/AGENTS.md b/AGENTS.md index 4b8d890f..8f5cb8b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,12 +30,12 @@ npm run lint:fix | Property | Value | |---|---| -| Package manager | npm (v24+) | +| Package manager | npm 11 (see `packageManager`) | | Build system | Turborepo (`turbo run build`) | | Language | TypeScript — target ES2022, `moduleResolution: bundler` | | Lint / Format | [Biome](https://biomejs.dev) (not ESLint or Prettier) | | Test runner | [Vitest](https://vitest.dev) (with built-in typecheck) | -| Node.js | 20+ (22 recommended — see `.nvmrc`) | +| Node.js | 24+ (see `.nvmrc`) | | Module system | ES Modules (`"type": "module"` in root `package.json`) | ### Workspace layout @@ -58,7 +58,8 @@ scripts/ ← build/release helper scripts | `@cleverbrush/async` | Async utilities: Collector, debounce, throttle, retry | | `@cleverbrush/mapper` | Schema-driven object mapper | | `@cleverbrush/react-form` | React form library powered by schema PropertyDescriptors | -| `@cleverbrush/scheduler` | Cron-like job scheduler with schema-validated config | +| `@cleverbrush/scheduler` | Typed durable jobs, recurring triggers and progress | +| `@cleverbrush/scheduler-postgres` | PostgreSQL job repository and explicit migrations | | `@cleverbrush/server` | Schema-first HTTP server: DI, auto-validation, RFC 9457 errors | | `@cleverbrush/server-openapi` | OpenAPI 3.x generation from server endpoints | | `@cleverbrush/client` | Type-safe HTTP client for `@cleverbrush/server` endpoints | @@ -72,6 +73,8 @@ scripts/ ← build/release helper scripts | `@cleverbrush/otel` | OpenTelemetry instrumentation | | `@cleverbrush/env` | Environment-variable parsing with schema validation | | `@cleverbrush/schema-json` | JSON Schema generation from schema builders | +| `@cleverbrush/storage` | Provider-neutral object storage contracts | +| `@cleverbrush/storage-s3` | Streaming S3-compatible storage | --- @@ -135,13 +138,24 @@ The `demos/` directory is linted separately (see `demos/todo-backend/biome.json` - Target `ES2022`; use modern syntax freely - Type assertions with `as` are acceptable (the linter won't block them) +## Documentation and Project Boundaries + +- Framework is an independent, application-agnostic project. Use generic domain + examples in source, documentation, changesets and PR descriptions. Consumer + application references belong only in website showcase links. +- Describe the current supported API in READMEs, guides and JSDoc. Keep historical + API comparisons and upgrade instructions in explicitly labeled v4.x-to-v5 + migration documentation, and link to it from current guides where useful. +- Preserve accurate API contracts and deprecation annotations; do not change + runtime behavior just to simplify documentation. + --- ## Testing Conventions - Tests are **co-located** with source files: `src/foo.ts` → `src/foo.test.ts` -- Vitest globals are available (`describe`, `it`, `expect`, etc.) — no explicit - import needed (configured via `"types": ["vitest/globals"]` in `tsconfig.json`) +- Import runtime helpers (`describe`, `it`, `expect`, etc.) from `vitest`. + Ambient TypeScript declarations do not enable runtime globals. - Run with `npm run test` which also performs TypeScript typechecking - Benchmarks live in `libs/benchmarks/` and run with `npm run bench` - Server integration tests live in `libs/server-integration-tests/` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6abba706..61c71456 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,8 @@ Thanks for your interest in contributing! This guide will help you get started. ## Prerequisites -- **Node.js** 20 or later (22 recommended — see `.nvmrc`) -- **npm** (ships with Node) +- **Node.js** 24 or later (see `.nvmrc`) +- **npm** 11 (see the root `packageManager` field) ## Getting Started @@ -24,6 +24,50 @@ npm run build npm run test ``` +The root build also refreshes workspace CLI links. On a fresh checkout npm cannot +link `cb-orm` until its generated `dist/bin.js` exists; the postbuild step makes +demo migration commands work without a second dependency installation. + +## Release verification + +In addition to lint, build and unit/type tests, CI runs: + +```bash +npm run test:coverage +npm run test:packages +npm audit --audit-level=high +npm run typecheck:schema-site +npm run typecheck:docs-site +npm run build:schema-site +npm run build:docs-site +``` + +`coverage-thresholds.json` sets per-package statement, branch, function and line +floors for **unit** coverage. New published packages need explicit floors; do not +lower existing floors to hide regressions. Refresh README badges explicitly with +`npm run coverage:badges` after a successful coverage run. Badges do not include +the dedicated database or S3 integration suites. Query, ORM and PostgreSQL +scheduler unit tests use the real Knex compiler with a simulated driver boundary +to check SQL, bindings, row decoding and failure paths. These do not prove database +locking, concurrent claims, lease recovery or transactional behavior: the real +PostgreSQL suites remain mandatory even when unit coverage reaches 100%. + +The package smoke test packs every published workspace, installs the tarballs +and peer dependencies in a disposable consumer, checks every export and TypeScript +declaration, and bundles browser entry points without Node polyfills. It requires +registry access and deletes only its own temporary directory on completion. + +CI runs the query and durable scheduler integration suites against disposable +PostgreSQL in UTC and America/Los_Angeles, and the storage suite against Garage. +Locally, provide isolated `QUERY_TEST_DATABASE_URL` and +`SCHEDULER_TEST_DATABASE_URL` values, then run `npm run test:queries:integration` +and `npm run test:scheduler:integration`. `npm run test:storage:integration` +creates and cleans up its own Docker service. Never point tests at production. +The full demo API/browser/telemetry E2E stack and API-reference generation also +run in CI. Both beta and stable publication wait for this reusable validation +workflow. TypeDoc's non-exported internal-type warnings remain visible; they are +not suppressed by the documentation build. + ## Monorepo Structure This project uses **npm workspaces** with **Turborepo** for orchestration. All packages live under `libs/`: @@ -35,9 +79,12 @@ This project uses **npm workspaces** with **Turborepo** for orchestration. All p | `@cleverbrush/async` | Async utilities (Collector, debounce, throttle, retry) | | `@cleverbrush/mapper` | Schema-driven object mapping | | `@cleverbrush/react-form` | React form library powered by schema PropertyDescriptors | -| `@cleverbrush/scheduler` | Cron-like job scheduler with schema-validated config | +| `@cleverbrush/scheduler` | Typed durable jobs, recurring schedules and progress | | `@cleverbrush/knex-clickhouse` | Knex dialect for ClickHouse | +This table highlights foundational packages. The [root package inventory](README.md#packages) +lists all published packages, including HTTP, persistence, storage and telemetry. + ## Development Workflow ### Code Style @@ -79,11 +126,12 @@ npm run clean The extension system is the primary way to add new validators. See `libs/schema/src/extensions/` for examples. 1. Create your extension file (e.g. `libs/schema/src/extensions/myExtension.ts`) -2. Export extension functions that call the builder's `.extend()` method +2. Define methods with `defineExtension()` and return the new immutable builder 3. Add tests in a co-located `*.test.ts` file 4. Re-export from `libs/schema/src/extensions/index.ts` -Look at `libs/schema/src/extensions/string.ts` for a complete example of how extensions add validators like `email()`, `url()`, `uuid()`, etc. +See the schema README's extension-system examples for `defineExtension()` and +`withExtensions()`. Do not discard the builder returned by an extension method. ## Adding a New Builder @@ -97,7 +145,7 @@ Builders live in `libs/schema/src/builders/`. Each builder extends the base `Sch ## Pull Request Process -1. **Fork** the repo and create a feature branch from `master` +1. Create a feature branch from `development` and target `development` in the PR 2. Make your changes with tests 3. **Add a changeset** — every PR that changes package behavior needs one: ```bash diff --git a/LICENSE b/LICENSE index 0b842d4c..053547c1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,52 +1,28 @@ -Cleverbrush Framework is dual-licensed under both the "Unlicense" and the -"Zero-Clause BSD" (0BSD) licenses. The intent of this dual-licensing -structure is to make Cleverbrush Framework as consumable as possible in as many -environments / countries / companies as possible without encumbering -users. - -This license applies to all of the Cleverbrush Framework source code, build code, -and tests. - -The text of the two licenses follows below: - -============================== UNLICENSE ============================== - -This is free and unencumbered software released into the public domain. - -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. - -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - -For more information, please refer to - -================================ 0BSD ================================= - -Copyright (C) 2024 by Andrew Zolotuhkin - -Permission to use, copy, modify, and/or distribute this software for -any purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file +BSD 3-Clause License + +Copyright (C) 2024 by Andrew Zolotuhkin + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index c8f8a1b7..9dd534f9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](./LICENSE) [![Standard Schema v1](https://img.shields.io/badge/Standard%20Schema-v1-blue)](https://standardschema.dev/) -![Coverage](https://img.shields.io/badge/coverage-86.6%25-green) +![Unit coverage](https://img.shields.io/badge/unit_coverage-92.2%25-brightgreen) Cleverbrush is a schema-first TypeScript framework monorepo. It provides the @@ -39,16 +39,19 @@ JSON Schema, API contracts, and Standard Schema integrations. | [`@cleverbrush/otel`](./libs/otel) | OpenTelemetry setup and instrumentation helpers for apps and clients. | | [`@cleverbrush/async`](./libs/async) | Async utilities including collector, debounce, throttle, and retry. | | [`@cleverbrush/deep`](./libs/deep) | Deep equality, deep extension, flattening, and object utilities. | -| [`@cleverbrush/scheduler`](./libs/scheduler) | Cron-like job scheduler with schema-validated job configuration. | +| [`@cleverbrush/scheduler`](./libs/scheduler) | Typed durable jobs, recurring triggers and ordered progress. | +| [`@cleverbrush/scheduler-postgres`](./libs/scheduler-postgres) | PostgreSQL job persistence, transactional enqueue and fenced leases. | +| [`@cleverbrush/storage`](./libs/storage) | Provider-neutral object storage contracts and public URL mapping. | +| [`@cleverbrush/storage-s3`](./libs/storage-s3) | Streaming S3-compatible storage for self-hosted and hosted providers. | ## How The Pieces Fit ```ts import { object, string, number, type InferType } from '@cleverbrush/schema'; -import { endpoint } from '@cleverbrush/server/contract'; +import { endpoint, route } from '@cleverbrush/server/contract'; const UserSchema = object({ - id: number().int().min(1), + id: number().isInteger().min(1), email: string().email(), displayName: string().minLength(2) }); @@ -56,8 +59,7 @@ const UserSchema = object({ type User = InferType; const GetUserEndpoint = endpoint - .get('/api/users/:id') - .params(object({ id: number().int().min(1) })) + .get('/api/users', route({ id: number().coerce().isInteger().min(1) })`/${p => p.id}`) .responses({ 200: UserSchema }); ``` @@ -87,7 +89,7 @@ ES modules. ## Development -Use Node.js 20 or newer. Node.js 22 is recommended. +Use Node.js 24 or newer and npm 11. Install the Node version in `.nvmrc`. ```bash npm ci @@ -117,6 +119,9 @@ demo workflow. ## Documentation +- [Migrating from v4.x to v5](./docs/MIGRATION-v5.md) +- [Security guidance](./SECURITY.md) + - Framework docs: https://docs.cleverbrush.com - Schema docs and playground: https://schema.cleverbrush.com - Standard Schema: https://standardschema.dev @@ -154,4 +159,6 @@ npm run publish:beta ## License -BSD-3-Clause. See [LICENSE](./LICENSE). +All Framework libraries are licensed under BSD-3-Clause. See [LICENSE](./LICENSE). +Each published package includes the same license text; `npm run test:packages` +verifies license text and metadata in both source packages and installed tarballs. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..12ac3966 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security + +Report suspected vulnerabilities privately to `andrew_zol@cleverbrush.com`. +Include affected versions, impact and a minimal reproduction using synthetic data. +Do not publish credentials or real user data in issues, logs or pull requests. + +## Consumer responsibilities + +- Use supported Node.js 24+ runtimes and keep dependency lockfiles current. +- Authenticate and authorize before response-cache or idempotency middleware. + Include every tenant, identity and representation boundary in cache keys and + derive idempotency scopes only from verified identity. Keep client cache/dedupe + instances request/session scoped when used on a server. +- Configure JWT issuer, audience and an explicit permitted algorithm family; + store signing keys outside source control. OAuth/OIDC adapters describe schemes + and delegate validation to the configured callback; they do not implement a + complete login flow or validate tokens automatically. +- Set upload/body limits, authenticate upload endpoints and validate file contents + independently of client-supplied MIME types and filenames. +- Use query parameters for untrusted values. Raw SQL and migration/config modules + are trusted-code boundaries, not sandboxes. Review destructive migrations. +- Restrict CORS origins and configure cookie `Secure`, `HttpOnly`, `SameSite` and + CSRF protection to suit the application. Cookie authentication alone does not + provide CSRF protection. +- Do not include secrets in URLs. Review telemetry configuration before exporting + full URLs, SQL statements, headers or custom attributes to third parties. +- Use transactional deduplication for durable exactly-once business effects; + in-memory idempotency and job retries cannot guarantee them by themselves. + +The demo stack uses development credentials. Do not expose it publicly or reuse +those credentials in deployed applications. Dependency advisories must be reviewed +for reachability; passing an automated scan does not establish absence of defects. diff --git a/coverage-thresholds.json b/coverage-thresholds.json new file mode 100644 index 00000000..81eef547 --- /dev/null +++ b/coverage-thresholds.json @@ -0,0 +1,134 @@ +{ + "async": { + "statements": 96, + "branches": 91, + "functions": 100, + "lines": 98 + }, + "auth": { + "statements": 92, + "branches": 89, + "functions": 100, + "lines": 92 + }, + "client": { + "statements": 93, + "branches": 88, + "functions": 96, + "lines": 95 + }, + "deep": { + "statements": 98, + "branches": 95, + "functions": 100, + "lines": 98 + }, + "di": { + "statements": 94, + "branches": 80, + "functions": 100, + "lines": 94 + }, + "env": { + "statements": 97, + "branches": 82, + "functions": 100, + "lines": 98 + }, + "knex-clickhouse": { + "statements": 100, + "branches": 95, + "functions": 100, + "lines": 100 + }, + "knex-schema": { + "statements": 80, + "branches": 75, + "functions": 80, + "lines": 83 + }, + "log": { + "statements": 98, + "branches": 92, + "functions": 94, + "lines": 98 + }, + "mapper": { + "statements": 97, + "branches": 90, + "functions": 100, + "lines": 98 + }, + "orm": { + "statements": 90, + "branches": 80, + "functions": 97, + "lines": 94 + }, + "orm-cli": { + "statements": 90, + "branches": 85, + "functions": 100, + "lines": 98 + }, + "otel": { + "statements": 88, + "branches": 75, + "functions": 94, + "lines": 90 + }, + "react-form": { + "statements": 99, + "branches": 95, + "functions": 100, + "lines": 99 + }, + "scheduler": { + "statements": 95, + "branches": 90, + "functions": 97, + "lines": 97 + }, + "scheduler-postgres": { + "statements": 90, + "branches": 85, + "functions": 100, + "lines": 100 + }, + "schema": { + "statements": 97, + "branches": 93, + "functions": 97, + "lines": 97 + }, + "schema-json": { + "statements": 96, + "branches": 93, + "functions": 100, + "lines": 97 + }, + "server": { + "statements": 90, + "branches": 85, + "functions": 94, + "lines": 92 + }, + "server-openapi": { + "statements": 96, + "branches": 86, + "functions": 100, + "lines": 97 + }, + "storage": { + "statements": 100, + "branches": 95, + "functions": 100, + "lines": 100 + }, + "storage-s3": { + "statements": 90, + "branches": 88, + "functions": 92, + "lines": 92 + } +} diff --git a/demos/durable-jobs/README.md b/demos/durable-jobs/README.md new file mode 100644 index 00000000..ee217e39 --- /dev/null +++ b/demos/durable-jobs/README.md @@ -0,0 +1,22 @@ +# Durable job contracts and separated handlers + +After npm ci and npm run build, run `node demos/durable-jobs/demo.ts` +with Node 24. It prints queued, running, progress and succeeded events, then +the typed result. This small example deliberately uses process-local memory. + +The core scheduler README shows PostgreSQL setup for real durability. +The sibling crash-worker.mjs and thread-handler.mjs are process-level fixtures +exercised by the scheduler integration and packaged-thread tests, respectively. + +## Periodic execution + +Run `node demos/durable-jobs/periodic.ts` to execute two reports one minute +apart. The example validates a minute schedule, registers it with +`upsertSchedule`, starts both the dispatcher and worker, waits for both +completed runs, and stops dispatch before draining the worker. + +For a fast smoke test, run `node demos/durable-jobs/periodic.ts --fast`. +Only this example's in-memory repository clock advances by one minute after +the first completion; the periodic dispatcher and worker still execute normally. +CI runs this mode. This is a test/demo clock, not a production scheduling option. +Choose PostgreSQL via `storageRepository` for restart-safe recurring jobs. diff --git a/demos/durable-jobs/contracts.ts b/demos/durable-jobs/contracts.ts new file mode 100644 index 00000000..05f993ee --- /dev/null +++ b/demos/durable-jobs/contracts.ts @@ -0,0 +1,9 @@ +import { object, string, number } from '@cleverbrush/schema'; +import { defineJob } from '@cleverbrush/scheduler'; + +export const Report = defineJob({ + name: 'report', version: 1, + input: object({ reportId: string() }), + progress: object({ percent: number() }), + output: object({ downloadUrl: string() }) +}); diff --git a/demos/durable-jobs/crash-worker.mjs b/demos/durable-jobs/crash-worker.mjs new file mode 100644 index 00000000..40044482 --- /dev/null +++ b/demos/durable-jobs/crash-worker.mjs @@ -0,0 +1,14 @@ +// Integration fixture: deliberately never finishes after committing progress. +import knex from 'knex'; +import { object, string, number } from '@cleverbrush/schema'; +import { defineJob, JobScheduler } from '@cleverbrush/scheduler'; +import { PostgresJobRepository } from '@cleverbrush/scheduler-postgres'; +const db = knex({ client: 'pg', connection: process.env.SCHEDULER_TEST_DATABASE_URL }); +const job = defineJob({ name: 'report', version: 1, input: object({ id: string() }), progress: object({ percent: number() }), output: object({ url: string() }), retry: { maxAttempts: 2, initialDelayMs: 1 } }); +const scheduler = new JobScheduler({ storageRepository: new PostgresJobRepository(db, { tablePrefix: process.env.JOB_TABLE_PREFIX }), namespace: process.env.JOB_NAMESPACE }); +const worker = scheduler.createWorker({ jobs: [job.handle(async (_, context) => { + await context.report({ percent: 50 }); + process.send?.({ ready: true }); + await new Promise(() => {}); +})], pollIntervalMs: 10, leaseMs: 500, heartbeatMs: 100 }); +await worker.start(); diff --git a/demos/durable-jobs/demo.ts b/demos/durable-jobs/demo.ts new file mode 100644 index 00000000..5622f915 --- /dev/null +++ b/demos/durable-jobs/demo.ts @@ -0,0 +1,16 @@ +import { JobScheduler, InMemoryJobRepository } from '@cleverbrush/scheduler'; +import { Report } from './contracts.ts'; +import { handleReport } from './handler.ts'; + +const jobs = new JobScheduler({ storageRepository: new InMemoryJobRepository(), pollIntervalMs: 10 }); +const worker = jobs.createWorker({ jobs: [Report.handle(handleReport)], pollIntervalMs: 10 }); +const run = await jobs.enqueue(Report, { reportId: 'quarterly' }); +await worker.start(); +try { + for await (const event of jobs.events(Report, run.id)) { + console.log(event.sequence, event.type, event.data); + } + const snapshot = await jobs.getRun(Report, run.id); + console.log(snapshot?.status, snapshot?.output); + if (snapshot?.status !== 'succeeded') process.exitCode = 1; +} finally { await worker.stop(); } diff --git a/demos/durable-jobs/handler.ts b/demos/durable-jobs/handler.ts new file mode 100644 index 00000000..042b4a99 --- /dev/null +++ b/demos/durable-jobs/handler.ts @@ -0,0 +1,9 @@ +import type { JobHandler } from '@cleverbrush/scheduler'; +import type { Report } from './contracts.ts'; + +/** Handler in a separate module retains all contract-inferred types. */ +export const handleReport: JobHandler = async (input, context) => { + context.signal.throwIfAborted(); + await context.report({ percent: 50 }); + return { downloadUrl: '/reports/' + input.reportId }; +}; diff --git a/demos/durable-jobs/periodic.ts b/demos/durable-jobs/periodic.ts new file mode 100644 index 00000000..7c08460e --- /dev/null +++ b/demos/durable-jobs/periodic.ts @@ -0,0 +1,42 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { InMemoryJobRepository, JobScheduler, ScheduleSchema } from '@cleverbrush/scheduler'; +import { Report } from './contracts.ts'; +import { handleReport } from './handler.ts'; + +// --fast advances only this demo's in-memory clock after the first completion. +const fast = process.argv.includes('--fast'); +let clock = Date.now(); +const jobs = new JobScheduler({ + storageRepository: new InMemoryJobRepository({ now: () => fast ? clock : Date.now() }), + pollIntervalMs: 10 +}); +const worker = jobs.createWorker({ jobs: [Report.handle(handleReport)], pollIntervalMs: 10 }); +await jobs.upsertSchedule('minute-report', Report, { reportId: 'periodic' }, { + schedule: ScheduleSchema.parse({ every: 'minute', interval: 1, maxOccurrences: 2 }), + missed: 'coalesce', overlap: 'skip' +}); + +try { + await worker.start(); // Executes jobs accepted by the dispatcher. + await jobs.start(); // Materializes due occurrences, not handler execution. + const deadline = Date.now() + (fast ? 5000 : 65000); + let advanced = false; + for (;;) { + const completed = (await jobs.health()).counts.succeeded ?? 0; + if (completed === 2) { + console.log('Completed both periodic reports.'); + break; + } + if (jobs.lastError) throw jobs.lastError; + if (worker.lastError) throw worker.lastError; + if (Date.now() > deadline) throw new Error('Periodic demo timed out'); + if (fast && completed === 1 && !advanced) { + clock += 60000; + advanced = true; + } + await delay(10); + } +} finally { + await jobs.stop(); // Stop producing before draining the worker. + await worker.stop(); +} diff --git a/demos/durable-jobs/thread-handler.mjs b/demos/durable-jobs/thread-handler.mjs new file mode 100644 index 00000000..21537f05 --- /dev/null +++ b/demos/durable-jobs/thread-handler.mjs @@ -0,0 +1,12 @@ +// Trusted default-export handler for a worker-thread execution. +export default async function handler(input, context) { + if (input.mode === 'exit') process.exit(7); + if (input.mode === 'hang') await new Promise(() => {}); + if (input.mode === 'invalid') { await context.report({ percent: 'wrong' }); } + if (input.mode === 'accessor') { + await context.report({ get percent() { throw new Error('must not execute'); } }); + } + if (input.mode === 'unawaited-invalid') void context.report({ percent: 'wrong' }); + await context.report({ percent: 50 }); + return { url: '/reports/' + input.id }; +} diff --git a/demos/e2e/README.md b/demos/e2e/README.md index 94ed7f84..aab01e1f 100644 --- a/demos/e2e/README.md +++ b/demos/e2e/README.md @@ -27,7 +27,7 @@ npx vitest --run src/api/todos.api.test.ts # single file ### One-time browser install -The UI project requires Chromium. After `npm install`, run: +The UI project requires Chromium. After `npm ci`, run: ```bash cd demos/e2e && npx playwright install chromium @@ -41,6 +41,7 @@ cd demos/e2e && npx playwright install chromium | `RESET` | `0` | If `1`, run `docker compose down -v` before bringing the stack up (wipes Postgres). | | `CI` | unset | Setting `CI=true` flips `KEEP_STACK` default to `0` and forces full teardown. | | `HEADED` | `0` | If `1`, launch Chromium headed so you can watch UI tests run. | +| `E2E_BROWSER_EXECUTABLE_PATH` | unset | Optional path to an existing Chromium executable; otherwise uses Playwright's managed browser. | | `SLOWMO` | `0` | Slow-motion delay (ms) for Playwright actions — useful with `HEADED=1`. | | `E2E_API_URL` | `http://localhost:3000` | Backend HTTP base URL. | | `E2E_WS_URL` | `ws://localhost:3000` | Backend WebSocket base URL. | @@ -115,7 +116,7 @@ src/ - **Todos** — full CRUD, list pagination, `getWithAuthor`, polymorphic events (`assigned` / `commented` / `completed`), optimistic concurrency on `complete` (200 / 409 with `If-Match`), cross-user 403, attachment - download, `legacyReplace` redirect. + download, PUT redirect. - **Import / Export** — 207 small batch, 202 large batch, idempotency header (contract-level), CSV export with quoting + content headers. - **Users (admin)** — list (admin only), delete user, self-delete blocked, diff --git a/demos/e2e/package.json b/demos/e2e/package.json index c99f4ed5..9c98144b 100644 --- a/demos/e2e/package.json +++ b/demos/e2e/package.json @@ -15,13 +15,13 @@ "dependencies": { "pg": "^8.13.3", "playwright": "^1.49.1", - "ws": "^8.18.0" + "ws": "^8.22.0" }, "devDependencies": { "@types/node": "^22.0.0", "@types/pg": "^8.11.10", "@types/ws": "^8.5.13", "typescript": "^6.0.2", - "vitest": "^4.1.2" + "vitest": "^4.1.11" } } diff --git a/demos/e2e/src/api/telemetry.smoke.test.ts b/demos/e2e/src/api/telemetry.smoke.test.ts index 0fb18998..55c78690 100644 --- a/demos/e2e/src/api/telemetry.smoke.test.ts +++ b/demos/e2e/src/api/telemetry.smoke.test.ts @@ -41,8 +41,6 @@ describe('Telemetry smoke — ClickHouse logs & traces correlation', () => { TraceId: string; }>( `SELECT body AS Body, trace_id AS TraceId FROM signoz_logs.distributed_logs_v2 WHERE trace_id = '${traceId}' FORMAT JSON`, -ace_id = '${traceId}' FORMAT JSON`, - 45_000, 1_000 ); @@ -55,10 +53,8 @@ ace_id = '${traceId}' FORMAT JSON`, const spans = await waitForRows<{ SpanName: string; ServiceName: string; - }>(name AS SpanName, resources_string['service.name'] AS ServiceName FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = '${traceId}' FORMAT JSON`, - + }>( `SELECT name AS SpanName, resources_string['service.name'] AS ServiceName FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = '${traceId}' FORMAT JSON`, - 45_000, 1_000 ); @@ -67,7 +63,7 @@ ace_id = '${traceId}' FORMAT JSON`, expect(services.has('todo-backend')).toBe(true); }); - it('ClickHouse is reachable and reports recent losignoz_logs.distributed_logs_v2 WHERE toDateTime(intDiv(timestamp, 1000000000)) + it('ClickHouse is reachable and reports recent logs', async () => { const { rows } = await clickhouseQuery<{ recent: string }>( `SELECT toString(count()) AS recent FROM signoz_logs.distributed_logs_v2 WHERE toDateTime(intDiv(timestamp, 1000000000)) >= now() - INTERVAL 1 HOUR FORMAT JSON` ); diff --git a/demos/e2e/src/api/todos.api.test.ts b/demos/e2e/src/api/todos.api.test.ts index fdf28824..edb420cb 100644 --- a/demos/e2e/src/api/todos.api.test.ts +++ b/demos/e2e/src/api/todos.api.test.ts @@ -228,10 +228,17 @@ describe('Todos — attachment & legacyReplace', () => { body: { title: uniqueTitle('attach') } }) ); + const boundary = 'framework-attachment-fixture'; + const uploaded = await r('POST', `/api/todos/${created.id}/attachment`, { + raw: true, + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + body: `--${boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="example.txt"\r\nContent-Type: text/plain\r\n\r\nImmutable query demo\r\n--${boundary}--\r\n` + }); + expect(uploaded.status).toBe(201); const res = await r('GET', `/api/todos/${created.id}/attachment`); expect(res.status).toBe(200); expect(res.headers['content-type']).toMatch(/text\/plain/); - expect(res.body.length).toBeGreaterThan(0); + expect(res.body).toBe('Immutable query demo'); }); it('legacyReplace (PUT) returns a redirect', async () => { diff --git a/demos/e2e/src/setup/global-setup.ts b/demos/e2e/src/setup/global-setup.ts index 92d9d692..82d4987f 100644 --- a/demos/e2e/src/setup/global-setup.ts +++ b/demos/e2e/src/setup/global-setup.ts @@ -86,10 +86,8 @@ async function isReachable(url: string, timeoutMs = 1_500): Promise { /** Run pending DB migrations against the dockerized Postgres. */ async function runMigrations(): Promise { - // cb-orm currently keeps the knex pool open after migrations complete, - // so the process lingers ~30s. We implement a portable Node-based timeout - // and send SIGTERM after 60s; the migrations themselves are fully applied - // by then. + // The CLI closes its database pool. A timeout or signal must fail setup, + // never be mistaken for successfully applied migrations. await new Promise((resolve, reject) => { const child = spawn('npm', ['run', 'db:run'], { stdio: 'inherit', @@ -112,9 +110,8 @@ async function runMigrations(): Promise { }); child.on('exit', (code, signal) => { clearTimeout(timer); - // 0 = clean exit; SIGTERM = killed by our timer (still success) - if (code === 0 || signal === 'SIGTERM') resolve(); - else reject(new Error(`Migrations failed with exit code ${code}`)); + if (code === 0) resolve(); + else reject(new Error(`Migrations failed (${signal ?? code})`)); }); }); } diff --git a/demos/e2e/src/support/playwright.ts b/demos/e2e/src/support/playwright.ts index c01df835..bf5239d5 100644 --- a/demos/e2e/src/support/playwright.ts +++ b/demos/e2e/src/support/playwright.ts @@ -11,6 +11,7 @@ let browser: Browser | null = null; async function getBrowser(): Promise { if (!browser) { browser = await chromium.launch({ + executablePath: process.env.E2E_BROWSER_EXECUTABLE_PATH || undefined, headless: !config.headed, slowMo: config.slowMo }); diff --git a/demos/e2e/src/ui/todo-crud.ui.test.ts b/demos/e2e/src/ui/todo-crud.ui.test.ts index 1abe3411..8c252195 100644 --- a/demos/e2e/src/ui/todo-crud.ui.test.ts +++ b/demos/e2e/src/ui/todo-crud.ui.test.ts @@ -22,6 +22,30 @@ async function registerAndLogin( } describe('UI — todo CRUD', () => { + it('shows a real server rejection beside the field, preserves edits and retries', async () => { + await withPage(async page => { + await registerAndLogin(page, uniqueEmail('ui-field-errors'), 'TestPass123!'); + await page.goto('/todos/new'); + const title = page.locator('input').first(); + await title.fill(uniqueTitle('rejected')); + await page.locator('textarea').fill('Keep this description'); + // Simulate a stale client's request shape while keeping the browser + // input locally valid. The actual backend produces the 400 response. + await page.route(/\/api\/todos\/?$/, async route => { + if (route.request().method() !== 'POST') return route.continue(); + await route.continue({postData: JSON.stringify({...route.request().postDataJSON(), title: ''})}); + }); + await page.getByRole('button', {name: 'Create Todo', exact: true}).click(); + await page.getByText('Check the highlighted fields.').waitFor(); + expect(await title.getAttribute('aria-invalid')).toBe('true'); + expect(await page.locator('textarea').inputValue()).toBe('Keep this description'); + await page.unroute(/\/api\/todos\/?$/); + await title.fill(uniqueTitle('corrected')); + expect(await title.getAttribute('aria-invalid')).toBe('false'); + await page.getByRole('button', {name: 'Create Todo', exact: true}).click(); + await page.waitForURL(/\/todos\/\d+$/); + }); + }); it('create → appears in list → delete → disappears; DB row removed', async () => { const email = uniqueEmail('ui-crud'); const title = uniqueTitle('crud'); @@ -38,6 +62,12 @@ describe('UI — todo CRUD', () => { // Detail page after creation await page.waitForURL(/\/todos\/\d+$/, { timeout: 10_000 }); + // Stay on the detail page until its requests settle: an admin-only + // picker lookup must not sign out an ordinary user after creation. + await page.getByRole('button', { name: 'Save Changes', exact: true }).waitFor(); + await page.waitForLoadState('networkidle'); + expect(page.url()).toMatch(/\/todos\/\d+$/); + expect(await page.locator('input').first().inputValue()).toBe(title); const url = page.url(); const todoId = Number(url.match(/\/todos\/(\d+)$/)![1]); diff --git a/demos/e2e/vitest.config.ts b/demos/e2e/vitest.config.ts index 2701d17b..6ac3aa10 100644 --- a/demos/e2e/vitest.config.ts +++ b/demos/e2e/vitest.config.ts @@ -35,7 +35,8 @@ export default defineConfig({ testTimeout: 90_000, hookTimeout: 180_000, pool: 'forks', - forks: { singleFork: true } + fileParallelism: false, + maxWorkers: 1 } } ] diff --git a/demos/todo-backend/Dockerfile b/demos/todo-backend/Dockerfile index 3f11b1bd..fccd77ce 100644 --- a/demos/todo-backend/Dockerfile +++ b/demos/todo-backend/Dockerfile @@ -1,15 +1,17 @@ # ── Stage 1: builder ────────────────────────────────────────────────────────── # Build context must be the monorepo root -FROM node:22-alpine AS builder +FROM node:24-alpine AS builder WORKDIR /app # Copy root workspace manifest for npm workspaces resolution -COPY package.json package-lock.json* turbo.json tsconfig.json tsconfig.build.json ./ +COPY package.json package-lock.json turbo.json tsconfig.json tsconfig.build.json ./ -# Copy all workspace package.json files for dependency resolution +# Copy every workspace manifest so npm ci validates the complete lockfile. COPY libs/async/package.json ./libs/async/ COPY libs/auth/package.json ./libs/auth/ +COPY libs/benchmarks/package.json ./libs/benchmarks/ +COPY libs/client/package.json ./libs/client/ COPY libs/deep/package.json ./libs/deep/ COPY libs/di/package.json ./libs/di/ COPY libs/env/package.json ./libs/env/ @@ -17,21 +19,28 @@ COPY libs/knex-clickhouse/package.json ./libs/knex-clickhouse/ COPY libs/knex-schema/package.json ./libs/knex-schema/ COPY libs/log/package.json ./libs/log/ COPY libs/mapper/package.json ./libs/mapper/ +COPY libs/orm/package.json ./libs/orm/ +COPY libs/orm-cli/package.json ./libs/orm-cli/ +COPY libs/otel/package.json ./libs/otel/ COPY libs/react-form/package.json ./libs/react-form/ +COPY libs/scheduler/package.json ./libs/scheduler/ +COPY libs/scheduler-postgres/package.json ./libs/scheduler-postgres/ COPY libs/schema/package.json ./libs/schema/ COPY libs/schema-json/package.json ./libs/schema-json/ -COPY libs/scheduler/package.json ./libs/scheduler/ COPY libs/server/package.json ./libs/server/ +COPY libs/server-integration-tests/package.json ./libs/server-integration-tests/ COPY libs/server-openapi/package.json ./libs/server-openapi/ -COPY libs/otel/package.json ./libs/otel/ -COPY libs/client/package.json ./libs/client/ -COPY libs/benchmarks/package.json ./libs/benchmarks/ -COPY libs/orm/package.json ./libs/orm/ -COPY libs/orm-cli/package.json ./libs/orm-cli/ +COPY libs/storage/package.json ./libs/storage/ +COPY libs/storage-s3/package.json ./libs/storage-s3/ +COPY demos/e2e/package.json ./demos/e2e/ COPY demos/todo-backend/package.json ./demos/todo-backend/ +COPY demos/todo-frontend/package.json ./demos/todo-frontend/ +COPY websites/docs/package.json ./websites/docs/ +COPY websites/schema/package.json ./websites/schema/ +COPY websites/shared/package.json ./websites/shared/ # Install all workspace dependencies -RUN npm install --ignore-scripts +RUN npm ci --ignore-scripts # Copy all lib sources (needed for workspace build) COPY libs/ ./libs/ @@ -50,7 +59,7 @@ RUN npx turbo run build --filter=@cleverbrush/todo-backend RUN cp -rL /app/node_modules /app/runtime_modules && \ rm -rf /app/runtime_modules/@cleverbrush # ── Stage 2: runtime ────────────────────────────────────────────────────────── -FROM node:22-alpine AS runtime +FROM node:24-alpine AS runtime LABEL org.opencontainers.image.title="ToDo Management API" LABEL org.opencontainers.image.description="Production-ready ToDo REST API built with @cleverbrush/* framework" diff --git a/demos/todo-backend/package.json b/demos/todo-backend/package.json index 13900022..de9b4977 100644 --- a/demos/todo-backend/package.json +++ b/demos/todo-backend/package.json @@ -19,7 +19,7 @@ "@cleverbrush/schema-json": "*", "@cleverbrush/server": "*", "@cleverbrush/server-openapi": "*", - "@opentelemetry/instrumentation-http": "^0.215.0", + "@opentelemetry/instrumentation-http": "^0.222.0", "@opentelemetry/instrumentation-runtime-node": "^0.28.0", "@opentelemetry/instrumentation-undici": "^0.25.0", "google-auth-library": "^9.15.0", @@ -30,7 +30,7 @@ "files": [ "dist" ], - "license": "BSD 3-Clause", + "license": "BSD-3-Clause", "main": "./dist/index.js", "exports": { ".": { diff --git a/demos/todo-backend/src/api/contract.ts b/demos/todo-backend/src/api/contract.ts index df3d5b93..0f546852 100644 --- a/demos/todo-backend/src/api/contract.ts +++ b/demos/todo-backend/src/api/contract.ts @@ -4,8 +4,9 @@ * This is the **single source of truth** for both the backend and the * frontend. It defines the shape of every API endpoint (HTTP method, * path, body / query / header schemas, and response schemas) without any - * server-specific concerns like authorization, dependency injection, or - * OpenAPI metadata. + * server-only dependencies or OpenAPI metadata. Authentication requirements + * belong in shared contracts so clients know when to attach their tokens; + * the backend remains responsible for enforcing permissions. * * - The **backend** imports this contract and extends each endpoint with * `.authorize()`, `.inject()`, and OpenAPI metadata. @@ -29,6 +30,7 @@ import { ImportTodosBodySchema, LoginBodySchema, PaginationQuerySchema, + PrincipalSchema, RegisterBodySchema, TodoEventSchema, TodoListQuerySchema, @@ -47,7 +49,7 @@ const ById = route({ id: number().coerce() })`/${t => t.id}`; // ── Resource factories ──────────────────────────────────────────────────────── -const todosResource = endpoint.resource('/api/todos'); +const todosResource = endpoint.resource('/api/todos').authorize(PrincipalSchema); const usersResource = endpoint.resource('/api/users'); const activityResource = endpoint.resource('/api/activity'); @@ -222,6 +224,7 @@ export const api = defineApi({ }), me: usersResource.get(route({})`/me`) + .authorize(PrincipalSchema) .cacheTag('user-profile') .returns(UserResponseSchema) }, diff --git a/demos/todo-backend/src/api/handlers/todos.ts b/demos/todo-backend/src/api/handlers/todos.ts index 0c60aea1..04bc0eef 100644 --- a/demos/todo-backend/src/api/handlers/todos.ts +++ b/demos/todo-backend/src/api/handlers/todos.ts @@ -452,13 +452,13 @@ export const uploadAttachmentHandler: Handler< return ActionResult.created({ id: updated.id, title: updated.title, - description: updated.description, + description: updated.description ?? undefined, completed: updated.completed, userId: updated.userId, createdAt: updated.createdAt, updatedAt: updated.updatedAt, - attachmentName: updated.attachmentName, - attachmentMimeType: updated.attachmentMimeType, + attachmentName: updated.attachmentName ?? undefined, + attachmentMimeType: updated.attachmentMimeType ?? undefined, attachmentSize: file.size }); }; diff --git a/demos/todo-backend/src/api/mappers.ts b/demos/todo-backend/src/api/mappers.ts index eaba2470..74a74b47 100644 --- a/demos/todo-backend/src/api/mappers.ts +++ b/demos/todo-backend/src/api/mappers.ts @@ -7,7 +7,6 @@ import type { TodoActivityResponse } from './schemas.js'; const UserRowSchema = object({ id: number(), email: string(), - passwordHash: string().optional(), role: string(), authProvider: string(), createdAt: date() @@ -16,13 +15,13 @@ const UserRowSchema = object({ const TodoRowSchema = object({ id: number(), title: string(), - description: string().optional(), + description: string().nullable(), completed: boolean(), userId: number(), createdAt: date(), updatedAt: date(), - attachmentName: string().optional(), - attachmentMimeType: string().optional() + attachmentName: string().nullable(), + attachmentMimeType: string().nullable() }); export const mappingRegistry = mapper() @@ -33,6 +32,10 @@ export const mappingRegistry = mapper() m .for(t => t.description) .compute(f => f.description ?? undefined) + .for(t => t.attachmentName) + .compute(f => f.attachmentName ?? undefined) + .for(t => t.attachmentMimeType) + .compute(f => f.attachmentMimeType ?? undefined) .for(t => t.attachmentSize) .ignore() ); @@ -40,7 +43,7 @@ export const mappingRegistry = mapper() const _mapUserFn = mappingRegistry.getMapper(UserRowSchema, UserResponseSchema); const _mapTodoFn = mappingRegistry.getMapper(TodoRowSchema, TodoResponseSchema); -export const mapUser = (row: UserDb) => _mapUserFn(row); +export const mapUser = (row: Omit) => _mapUserFn(row); export const mapTodo = (row: TodoDb) => _mapTodoFn(row); export function mapTodoActivity(row: ActivityDb & Record): TodoActivityResponse { diff --git a/demos/todo-backend/src/db/schemas.ts b/demos/todo-backend/src/db/schemas.ts index 277907e2..6333b074 100644 --- a/demos/todo-backend/src/db/schemas.ts +++ b/demos/todo-backend/src/db/schemas.ts @@ -1,4 +1,5 @@ import { + type EntityResult, array, boolean, date, @@ -144,12 +145,7 @@ const TodoSchema = object({ 'attachmentMimeType' ) .projection('ownership', 'id', 'userId') - .scope( - 'recentFirst', - (q: { - orderBy: (column: string, direction: 'asc' | 'desc') => unknown; - }) => q.orderBy('created_at', 'desc') - ); + .scope('recentFirst', q => q.orderBy('createdAt', 'desc')); export const TodoEntity = defineEntity(TodoSchema) .belongsTo( @@ -187,32 +183,6 @@ export const entityMap: AppEntityMap = { // ── Plain row types (used by mappers) ─────────────────────────────────────── -export type ActivityDb = { - id: number; - todoId: number; - type: string; - actorUserId?: number; - completedAt?: Date | null; - createdAt: Date; -}; - -export type UserDb = { - id: number; - email: string; - passwordHash?: string; - role: string; - authProvider: string; - createdAt: Date; -}; - -export type TodoDb = { - id: number; - title: string; - description?: string; - completed: boolean; - userId: number; - createdAt: Date; - updatedAt: Date; - attachmentName?: string; - attachmentMimeType?: string; -}; +export type ActivityDb = EntityResult; +export type UserDb = EntityResult; +export type TodoDb = EntityResult; diff --git a/demos/todo-frontend/Dockerfile b/demos/todo-frontend/Dockerfile index 9fd23c3c..0e67dc2f 100644 --- a/demos/todo-frontend/Dockerfile +++ b/demos/todo-frontend/Dockerfile @@ -1,38 +1,46 @@ # ── Stage 1: builder ────────────────────────────────────────────────────────── # Build context must be the monorepo root -FROM node:22-alpine AS builder +FROM node:24-alpine AS builder WORKDIR /app # Copy root workspace manifest for dependency resolution -COPY package.json package-lock.json* turbo.json tsconfig.build.json ./ +COPY package.json package-lock.json turbo.json tsconfig.json tsconfig.build.json ./ -# Copy all workspace package.json files for dependency resolution +# Copy every workspace manifest so npm ci validates the complete lockfile. COPY libs/async/package.json ./libs/async/ COPY libs/auth/package.json ./libs/auth/ +COPY libs/benchmarks/package.json ./libs/benchmarks/ +COPY libs/client/package.json ./libs/client/ COPY libs/deep/package.json ./libs/deep/ COPY libs/di/package.json ./libs/di/ COPY libs/env/package.json ./libs/env/ COPY libs/knex-clickhouse/package.json ./libs/knex-clickhouse/ COPY libs/knex-schema/package.json ./libs/knex-schema/ +COPY libs/log/package.json ./libs/log/ COPY libs/mapper/package.json ./libs/mapper/ +COPY libs/orm/package.json ./libs/orm/ +COPY libs/orm-cli/package.json ./libs/orm-cli/ +COPY libs/otel/package.json ./libs/otel/ COPY libs/react-form/package.json ./libs/react-form/ +COPY libs/scheduler/package.json ./libs/scheduler/ +COPY libs/scheduler-postgres/package.json ./libs/scheduler-postgres/ COPY libs/schema/package.json ./libs/schema/ COPY libs/schema-json/package.json ./libs/schema-json/ -COPY libs/scheduler/package.json ./libs/scheduler/ COPY libs/server/package.json ./libs/server/ +COPY libs/server-integration-tests/package.json ./libs/server-integration-tests/ COPY libs/server-openapi/package.json ./libs/server-openapi/ -COPY libs/client/package.json ./libs/client/ -COPY libs/benchmarks/package.json ./libs/benchmarks/ -COPY libs/log/package.json ./libs/log/ -COPY libs/otel/package.json ./libs/otel/ -COPY libs/orm/package.json ./libs/orm/ -COPY libs/orm-cli/package.json ./libs/orm-cli/ +COPY libs/storage/package.json ./libs/storage/ +COPY libs/storage-s3/package.json ./libs/storage-s3/ +COPY demos/e2e/package.json ./demos/e2e/ COPY demos/todo-backend/package.json ./demos/todo-backend/ COPY demos/todo-frontend/package.json ./demos/todo-frontend/ +COPY websites/docs/package.json ./websites/docs/ +COPY websites/schema/package.json ./websites/schema/ +COPY websites/shared/package.json ./websites/shared/ # Install workspace dependencies -RUN npm install --ignore-scripts +RUN npm ci --ignore-scripts # Copy all lib sources (needed for workspace build) COPY libs/ ./libs/ diff --git a/demos/todo-frontend/package.json b/demos/todo-frontend/package.json index 88daf7e4..28e74a9e 100644 --- a/demos/todo-frontend/package.json +++ b/demos/todo-frontend/package.json @@ -21,13 +21,13 @@ "@react-oauth/google": "^0.12.1", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router": "^7.5.3" + "react-router": "^7.18.2" }, "devDependencies": { "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "typescript": "^6.0.2", - "vite": "^6.3.3" + "vite": "^6.4.3" } } diff --git a/demos/todo-frontend/src/features/todos/CreateTodoPage.tsx b/demos/todo-frontend/src/features/todos/CreateTodoPage.tsx index 73aea85b..3a8be8d8 100644 --- a/demos/todo-frontend/src/features/todos/CreateTodoPage.tsx +++ b/demos/todo-frontend/src/features/todos/CreateTodoPage.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { useNavigate } from 'react-router'; import { Box, @@ -9,31 +8,26 @@ import { } from '@radix-ui/themes'; import { Field, useSchemaForm } from '@cleverbrush/react-form'; import { CreateTodoBodySchema } from '@cleverbrush/todo-backend/contract'; -import { ApiError } from '@cleverbrush/client'; +import { ApiError, decodeValidationIssues } from '@cleverbrush/client'; import { client } from '../../api/client'; export function CreateTodoPage() { const navigate = useNavigate(); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const form = useSchemaForm(CreateTodoBodySchema); - const handleSubmit = async () => { - const result = await form.submit(); - if (!result.valid || !result.object) return; - - setLoading(true); - setError(null); + const handleSubmit = form.handleSubmit(async values => { try { - const todo = await client.todos.create({ body: result.object }); - navigate(`/todos/${todo.id}`); + const todo = await client.todos.create({ body: values }); + return { ok: true, data: todo }; } catch (e) { - setError(e instanceof ApiError ? e.message : 'Failed to create todo.'); - } finally { - setLoading(false); + const issues = decodeValidationIssues(e, { source: 'body' }); + return { + ok: false, + error: issues ? 'Check the highlighted fields.' : e instanceof ApiError ? e.message : 'Failed to create todo.', + issues + }; } - }; + }, { onSuccess: todo => { navigate(`/todos/${todo!.id}`); } }); return ( @@ -42,9 +36,9 @@ export function CreateTodoPage() { New Todo - {error && ( + {form.error && ( - {error} + {form.error} )} @@ -52,7 +46,7 @@ export function CreateTodoPage() { t.description} form={form} label="Description (optional)" variant="textarea" /> -