Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .plans/refactor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Blocking: docs-sweep blocks llm-skills
| [rate-limiter-lazy](queued/rate-limiter-lazy.md) | P1b+ | Lazy-import RateLimiter. Small. |
| [cache-drivers](queued/cache-drivers.md) | P1c | Cache driver abstraction. Small. |
| [test-helpers](queued/test-helpers.md) | P1i | Framework test helpers usable from `node:test` + vitest. ~½ d. |
| [yup-optional](queued/yup-optional.md) | P1k | Un-bundle yup: built-in schemas → `defineSchema` (zero-dep Standard Schema). yup leaves `dependencies`. Codegen unchanged. ~½ d. |
| [codegen-incremental](queued/codegen-incremental.md) | P2a | File-based codegen cache + OpenAPI surface. TBD. |

### later/
Expand Down Expand Up @@ -71,7 +72,8 @@ Blocking: docs-sweep blocks llm-skills
## v5.1 extras (no phase doc — tracked as bullets)

- `bodyParsing: 'parsed' | 'raw' | 'none'` modes + parser registry (`app.parsers`)
- `multipartScalar` helper, `File` type export
- `File` type export — ✅ shipped (beta.51)
- **Route-level multipart single-element extraction** — let a route declare which multipart fields are scalar; the router unwraps their single-element arrays **before** validation, so the schema stays the clean logical shape (`avatar: z.instanceof(File)`) and codegen reads that output type directly. Chosen over a schema-side `multipartScalar` helper (that wrapper was prototyped and dropped — it pushed a parser concern into the schema/types). Interim: validator-native `.array().length(1).transform(...)`. Revisits the parser-side `getFieldShape` idea from `decisions.md` → "Multipart parser is always-array" as an opt-in route convenience. Not scheduled.
- Project-side boot hook (`bootHttp(app)` for ad-hoc routes / globals)
- `npm run cli routes` (registry walker for "what's mounted in my app")

Expand Down
94 changes: 94 additions & 0 deletions .plans/refactor/queued/yup-optional.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# P1k — Yup optional (un-bundle the validator)

**Status**: ⏸ deferred to v5.1
**Depends on**: P1a-runtime (Standard Schema dispatch + `standardSchemaDriver` shipped)
**Time**: ~½ day
**Parallelizable with**: everything (isolated to the validate layer + 3 built-in files)
**Origin**: surfaced 2026-05-31 — yup is in `dependencies` only because ~6 trivial built-in schemas (Auth + Pagination + `YupFile`) use it. Goal: framework ships **no** bundled validator, while keeping typed route generation. P1a left this as out-of-scope ("future migration to inline Standard Schema, ~250 lines").

## Goal

Move yup out of `dependencies`. Rewrite the handful of built-in schemas as hand-authored Standard Schema objects via a tiny `defineSchema` helper. Codegen keeps producing typed handler requests unchanged — it already reads `StandardSchemaV1.InferOutput`, which is validator-agnostic.

## Why

- **Abstract, no useless deps.** The runtime is already Standard-Schema-based (P1a). Yup is the last bundled validator, dragged in only by built-in defaults. Modern users mostly bring zod.
- **Type generation already supports this.** `emit.ts` emits `StandardSchemaV1.InferOutput<...['request']>` — it reads the schema's phantom `~standard.types.output`, so a hand-declared `defineSchema<T>` output type flows through with **zero codegen changes**.
- **Yup support is retained, just un-bundled.** The `yupDriver` has no top-level yup import (duck-typed); it keeps working for users who add yup as an optional peer.

## Files touched

- `src/services/validate/defineSchema.ts` (new) — the helper (see API). ~10 lines.
- `src/controllers/Auth.ts` — replace the 5 `object().shape({...})` schemas with `defineSchema<T>`. Drop `import { object, string } from 'yup'`.
- `src/services/http/middleware/Pagination.ts` — replace yup query schema with `defineSchema<T>`.
- `src/helpers/yup.ts` — **unchanged**. `YupFile` stays as the yup-specific file validator. It is only loaded when a user `import`s it, and those users have yup (now an optional peer) installed. **No built-in controller validates files**, so file validation is entirely outside P1k's path. The vendor-neutral file story (`File` export + `multipartScalar`) is a separate track — see Out of scope and `decisions.md` → "Standard-Schema-only file validation".
- `src/locales/*` — ensure i18n keys for hand-rolled messages (`validation.email`, `validation.required`, pagination keys) exist; reuse existing yup message keys where possible so wire/i18n output is unchanged.
- `package.json` — yup `^1.7.0`: `dependencies` → `devDependencies` (keeps fixtures + `yupDriver` tests running). Add `yup` to `peerDependenciesMeta` as optional (alongside `zod`).
- **Unchanged on purpose**: `src/tests/fixtures/controllers/SomeController.ts` + `src/services/validate/ValidateService.test.ts` keep using yup — they are the living test of the `yupDriver` vendor path (yup is now a devDependency).

## API

```ts
// src/services/validate/defineSchema.ts
import type { StandardSchemaV1 } from './types.ts';

/**
* Wrap a validate function into a Standard Schema object — zero deps.
* `Output` is what codegen reads for handler types (InferOutput); you declare
* it, the runtime checks live in `validate`. Unknown keys are stripped by
* construction (return only known fields in the success `value`).
*/
export function defineSchema<Output>(
validate: (value: unknown) => StandardSchemaV1.Result<Output>,
): StandardSchemaV1<unknown, Output> {
return { '~standard': { version: 1, vendor: 'framework', validate } };
}
```

```ts
// migrated Auth login schema
type LoginRequest = { email: string; password: string };
const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

const loginSchema = defineSchema<LoginRequest>((value) => {
const v = (value ?? {}) as Record<string, unknown>;
const issues: StandardSchemaV1.Issue[] = [];
if (typeof v.email !== 'string' || !EMAIL.test(v.email))
issues.push({ message: 'validation.email', path: ['email'] });
if (typeof v.password !== 'string' || !v.password)
issues.push({ message: 'validation.required', path: ['password'] });
if (issues.length) return { issues };
return { value: { email: v.email as string, password: v.password as string } };
});
```

No new driver: `standardSchemaDriver` already handles any non-yup `~standard` object — calls `validate`, builds the framework `ValidationError` from `issues`, i18n-translates by `message` key.

## Test plan

- ☐ `defineSchema.test.ts` — success returns `{value}` with unknowns stripped; failure returns `{issues}`; type-level assert `InferOutput<typeof loginSchema>` equals `LoginRequest`.
- ☐ Auth integration tests stay green — login/register reject invalid email + missing password with the **same wire shape** as before.
- ☐ `ValidationError` byte-identical response test (existing cross-phase fixture) stays green.
- ☐ i18n: invalid-field messages still translate (keys reused).
- ☐ `npm run gen && tsc --noEmit` — generated handler `request` types still resolve via `InferOutput` on `defineSchema`.
- ☐ `grep -rn "from 'yup'" src/` returns only `helpers/yup.ts` (YupFile — user opt-in), `tests/fixtures/.../SomeController.ts`, `ValidateService.test.ts`. The **runtime + built-in controllers (Auth, Pagination)** are yup-free.
- ☐ Fresh install without yup in production deps boots and serves `/auth/login` validation.

## Out of scope

- **No schema-builder combinators** (`string()`, `object()`, `min()`, `email()`). That is rebuilding zod and owning its edge-case bugs — the explicit anti-goal. If `defineSchema`'s `if`-checks ever feel too verbose across *many* schemas, the answer is "install zod," not "grow this helper."
- **Not removing yup support.** `yupDriver` stays shipped; yup stays an optional peer.
- **File validation.** No built-in validates files, so P1k does not touch it. `YupFile` stays as the yup-specific helper. The vendor-neutral file path (`File` type export, `multipartScalar`, optional `fileSchema()`) is its own track per `decisions.md` → "Standard-Schema-only file validation" / "Multipart parser is always-array" — out of scope here.
- **Not migrating user apps** or user-facing schemas.
- **Full docs rewrite** — `05-models.md`/validation docs adding `defineSchema` is folded into P1g docs-sweep; this phase only ships the code + a CHANGELOG note.

## Done when

- `yup` is **not** in `package.json#dependencies`; `defineSchema` + `fileSchema` ship; `Auth.ts` + `Pagination.ts` import no yup; all existing tests green; `npm run gen && tsc --noEmit` clean.

## Notes

- **The one cost**: `defineSchema`'s declared `Output` type and its `validate` body are two hand-synced declarations — no `InferType` deriving one from the other, and codegen trusts the declared type without cross-checking `validate`. Acceptable for ~6 fixed, rarely-touched built-in schemas; **not** a pattern to push on large user apps (there, zod).
- `defineSchema` is a useful **public** export: a zero-dep escape hatch for simple validators. Reinforces the "framework bundles no validator; bring zod/yup for rich needs" story.
- Stripping is automatic — success `value` only carries the keys you copy, so the yup `stripUnknown` security property (8 spread-into-`Model.create` sites audited in P1a) is preserved by construction.
- **Migration note (CHANGELOG):** users who use `YupFile` — or any yup schema in their own controllers — must add `yup` to *their own* `dependencies` after P1k. It is no longer provided transitively by the framework. This is correct semantics (depend on the validator you use), but it is a user-visible breaking change to call out.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ Main feature of that release is full TypeScript support insluding mongoose model
## [5.0.0-beta.51]

- **[NEW]** `KeyValue` model: a minimal persistent key/value store backed by MongoDB for lightweight caching, runtime config, and feature flags.
- **[NEW]** `defineSchema<Output>(validate)` helper (`@adaptivestone/framework/services/validate/defineSchema.js`) — wrap a plain validate function into a zero-dependency Standard Schema. Codegen reads its `Output` generic for handler request types via `StandardSchemaV1.InferOutput`.
- **[NEW]** `File` type exported from `@adaptivestone/framework/types.js` — vendor-neutral uploaded-file type (aliases formidable's `PersistentFile` today; re-points at the web-standard `File` after the P3 parser swap). Validate uploads with your validator's idiom, e.g. `z.instanceof(File)`.
- **[NEW]** Content-type-keyed request schemas: a route's `request` can be a map (`{ 'application/json': schemaA, 'multipart/form-data': schemaB }`, mirrors OpenAPI `requestBody.content`). The framework validates with the schema matching the request's `Content-Type` (415 on no match) and `req.appInfo.request` becomes a `contentType`-discriminated union; codegen emits the union automatically. Media-type matching is case-insensitive and ignores parameters (`; charset=...`); `contentType` is a reserved field on the validated request object.
- **[CHANGE]** Built-in `Auth` controller and `Pagination` middleware now validate with `defineSchema` instead of yup. The framework runtime and built-ins are yup-free.
- **[BREAKING]** `yup` moved from `dependencies` to an optional `peerDependency`. It is no longer bundled. Apps that use yup schemas (including `YupFile`) must add `yup` to their own `dependencies`. Zod/Valibot/ArkType users are unaffected.
- **[DEPRECATED]** `YupFile` (`@adaptivestone/framework/helpers/yup.js`) — removed in v6. Validate files via the new `File` export + your validator's `instanceof` idiom instead.

---
## [5.0.0-beta.50]
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@
"mongoose": "^9.0.0",
"rate-limiter-flexible": "^11.0.0",
"winston": "^3.3.3",
"winston-transport": "^4.9.0",
"yup": "^1.7.0"
"winston-transport": "^4.9.0"
},
"devDependencies": {
"@biomejs/biome": "^2.0.6",
Expand All @@ -67,12 +66,14 @@
"mongodb-memory-server": "^11.0.0",
"typescript": "^6.0.0",
"vitest": "^4.0.0",
"yup": "^1.7.0",
"zod": "^4.0.0"
},
"peerDependencies": {
"@adaptivestone/framework-module-email": "^1.0.0",
"@sentry/core": "^10.34.0",
"@sentry/node": "^10.34.0",
"yup": "^1.7.0",
"zod": "^3.24.0 || ^4.0.0"
},
"peerDependenciesMeta": {
Expand All @@ -85,6 +86,9 @@
"@sentry/node": {
"optional": true
},
"yup": {
"optional": true
},
"zod": {
"optional": true
}
Expand Down
10 changes: 10 additions & 0 deletions src/codegen/collectMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import type AbstractController from '../modules/AbstractController.ts';
import { isContentTypeRequestMap } from '../services/validate/contentType.ts';

/** One middleware reference, with its parameters if it was declared as a tuple. */
export interface MiddlewareRef {
Expand All @@ -25,6 +26,12 @@ export interface RouteMeta {
handlerName: string | null;
/** True when the route entry is `{ handler, request }` and `request` is set. */
hasSchema: boolean;
/**
* Media-type keys when `request` is a content-type map (`{ 'application/json':
* schema, ... }`). Drives the discriminated-union request type. Absent for a
* single-schema `request`.
*/
requestContentTypes?: string[];
/** True when the route entry is `{ handler, query }` and `query` is set. */
hasQuerySchema: boolean;
}
Expand Down Expand Up @@ -87,6 +94,9 @@ function extractRouteMeta(
handlerName:
typeof obj.handler === 'function' ? nameOf(obj.handler) : null,
hasSchema: obj.request != null,
requestContentTypes: isContentTypeRequestMap(obj.request)
? Object.keys(obj.request)
: undefined,
hasQuerySchema: obj.query != null,
};
}
Expand Down
43 changes: 43 additions & 0 deletions src/codegen/emit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,46 @@ export default Standalone;
expect(output).toContain('UnionAppInfoProvides<readonly []>');
});
});

describe('emit content-type request map', () => {
it('emits a contentType-discriminated union for a content-type map request', async () => {
const srcPath = await writeFile(
'Upload.ts',
`import AbstractController from '@adaptivestone/framework/modules/AbstractController.js';

class Upload extends AbstractController {
get routes() {
return { post: { '/upload': { handler: this.upload, request: {} } } };
}
async upload() { return; }
}
export default Upload;
`,
);

const controller: ControllerMeta = {
className: 'Upload',
prefix: '',
urlPrefix: '/upload',
routes: [
{
method: 'post',
path: '/upload',
handlerName: 'upload',
hasSchema: true,
requestContentTypes: ['application/json', 'multipart/form-data'],
hasQuerySchema: false,
} satisfies RouteMeta,
],
};

const output = await emitGenFile({ controller, srcPath, chains: [[]] });

expect(output).toContain("{ contentType: 'application/json' }");
expect(output).toContain("['request']['application/json']");
expect(output).toContain("{ contentType: 'multipart/form-data' }");
expect(output).toContain("['request']['multipart/form-data']");
// The two media-type branches are joined as a union.
expect(output).toMatch(/\) \| \(/);
});
});
22 changes: 19 additions & 3 deletions src/codegen/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,25 @@ function renderShape(
// on the route entry.
const appInfoOverrides: string[] = [];
if (route.hasSchema) {
appInfoOverrides.push(
`request: StandardSchemaV1.InferOutput<${routesAlias}['${route.method}']['${route.path}']['request']>`,
);
if (route.requestContentTypes?.length) {
// Content-type map → discriminated union keyed by `contentType`. Each
// branch reads InferOutput of that media type's schema.
const base = `${routesAlias}['${route.method}']['${route.path}']['request']`;
// Discriminant literal is lower-cased to match the runtime-injected
// value (the parser normalizes `Content-Type` to lower case); the type
// navigation keeps the author's original key so the schema resolves.
const union = route.requestContentTypes
.map(
(ct) =>
`({ contentType: '${ct.toLowerCase()}' } & StandardSchemaV1.InferOutput<${base}['${ct}']>)`,
)
.join(' | ');
appInfoOverrides.push(`request: ${union}`);
} else {
appInfoOverrides.push(
`request: StandardSchemaV1.InferOutput<${routesAlias}['${route.method}']['${route.path}']['request']>`,
);
}
}
if (route.hasQuerySchema) {
appInfoOverrides.push(
Expand Down
12 changes: 12 additions & 0 deletions src/controllers/Auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,18 @@ describe('auth', () => {
expect(status).toBe(400);
});

it('rejects a non-string password with 400 (not 500)', async () => {
expect.assertions(1);

const { status } = await fetch(getTestServerURL('/auth/login'), {
method: 'POST',
headers: { 'Content-type': 'application/json' },
body: JSON.stringify({ email: 'a@b.com', password: ['x'] }),
}).catch(() => ({ status: 500 }));

expect(status).toBe(400);
});

it('can login with normal creds and verified email', async () => {
expect.assertions(3);
const UserModel = appInstance.getModel('User') as unknown as TUser;
Expand Down
Loading
Loading