From 2ba72330ebb26b2ee66c7e9af4cca20171e53080 Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 21:25:57 +0000 Subject: [PATCH 01/12] feat: US-001 - Add Standard Schema spec package and create base types - Add @standard-schema/spec as dependency - Create src/types/standard-schema.ts with: - Re-export StandardSchemaV1 from spec package - IsStandardSchema type guard - InferSSInput for extracting input types - InferSSOutput for extracting output types - Export types from src/index.ts - Add comprehensive type tests (17 tests) - All 230 tests pass, typecheck and build pass --- packages/mizzle-orm/package.json | 1 + packages/mizzle-orm/src/index.ts | 3 + .../types/__tests__/standard-schema.test.ts | 247 ++++++++++++++++++ .../mizzle-orm/src/types/standard-schema.ts | 66 +++++ pnpm-lock.yaml | 11 +- 5 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 packages/mizzle-orm/src/types/__tests__/standard-schema.test.ts create mode 100644 packages/mizzle-orm/src/types/standard-schema.ts diff --git a/packages/mizzle-orm/package.json b/packages/mizzle-orm/package.json index e8ab0e9..61cdef7 100644 --- a/packages/mizzle-orm/package.json +++ b/packages/mizzle-orm/package.json @@ -55,6 +55,7 @@ }, "homepage": "https://orm.mizzle.dev", "dependencies": { + "@standard-schema/spec": "^1.1.0", "mongodb": "^7.0.0", "nanoid": "^5.0.0", "zod": "^4.1.12" diff --git a/packages/mizzle-orm/src/index.ts b/packages/mizzle-orm/src/index.ts index 1b5768a..21219ec 100644 --- a/packages/mizzle-orm/src/index.ts +++ b/packages/mizzle-orm/src/index.ts @@ -50,6 +50,9 @@ export type { OrmContext, Mizzle, MizzleConfig } from './types/orm'; export type { IncludeConfig, NestedIncludeConfig, WithIncluded } from './types/include'; +// Standard Schema integration +export type { StandardSchemaV1, IsStandardSchema, InferSSInput, InferSSOutput } from './types/standard-schema'; + // Validation export { generateDocumentSchema, diff --git a/packages/mizzle-orm/src/types/__tests__/standard-schema.test.ts b/packages/mizzle-orm/src/types/__tests__/standard-schema.test.ts new file mode 100644 index 0000000..7983d62 --- /dev/null +++ b/packages/mizzle-orm/src/types/__tests__/standard-schema.test.ts @@ -0,0 +1,247 @@ +/** + * Standard Schema type utility tests + */ + +import { describe, it, expect, expectTypeOf } from 'vitest'; +import { z } from 'zod'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { IsStandardSchema, InferSSInput, InferSSOutput } from '../standard-schema'; + +// Type assertion helper - compilation will fail if types don't match +type AssertEqual = [T] extends [U] ? ([U] extends [T] ? true : false) : false; +type Assert = T; + +describe('Standard Schema Type Utilities', () => { + describe('IsStandardSchema', () => { + it('should return true for Zod schemas', () => { + const zodString = z.string(); + const zodObject = z.object({ name: z.string() }); + + // Runtime check that Zod implements Standard Schema + expect((zodString as any)['~standard']).toBeDefined(); + expect((zodObject as any)['~standard']).toBeDefined(); + + // Type-level checks + type StringIsSSPasses = Assert, true>>; + type ObjectIsSSPasses = Assert, true>>; + + // Verify types compile (these are compile-time checks) + const _strCheck: StringIsSSPasses = true; + const _objCheck: ObjectIsSSPasses = true; + expect(_strCheck).toBe(true); + expect(_objCheck).toBe(true); + }); + + it('should return false for non-Standard Schema values', () => { + // Plain object + type PlainObj = { foo: string }; + type PlainObjNotSS = Assert, false>>; + const _plainCheck: PlainObjNotSS = true; + expect(_plainCheck).toBe(true); + + // Primitives + type StringPrimitive = string; + type StringNotSS = Assert, false>>; + const _stringCheck: StringNotSS = true; + expect(_stringCheck).toBe(true); + + // Function + type FuncType = () => void; + type FuncNotSS = Assert, false>>; + const _funcCheck: FuncNotSS = true; + expect(_funcCheck).toBe(true); + }); + }); + + describe('InferSSInput', () => { + it('should extract input type from Zod string schema', () => { + const schema = z.string(); + type Input = InferSSInput; + type InputIsString = Assert>; + const _check: InputIsString = true; + expect(_check).toBe(true); + }); + + it('should extract input type from Zod number schema', () => { + const schema = z.number(); + type Input = InferSSInput; + type InputIsNumber = Assert>; + const _check: InputIsNumber = true; + expect(_check).toBe(true); + }); + + it('should extract input type from Zod object schema', () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + active: z.boolean(), + }); + + type Input = InferSSInput; + + // Check specific fields + type NameIsString = Assert>; + type AgeIsNumber = Assert>; + type ActiveIsBool = Assert>; + + const _nameCheck: NameIsString = true; + const _ageCheck: AgeIsNumber = true; + const _activeCheck: ActiveIsBool = true; + + expect(_nameCheck).toBe(true); + expect(_ageCheck).toBe(true); + expect(_activeCheck).toBe(true); + }); + + it('should extract input type from Zod array schema', () => { + const schema = z.array(z.string()); + type Input = InferSSInput; + type InputIsStringArray = Assert>; + const _check: InputIsStringArray = true; + expect(_check).toBe(true); + }); + + it('should return never for non-Standard Schema types', () => { + type Input = InferSSInput<{ notASchema: true }>; + type InputIsNever = Assert>; + const _check: InputIsNever = true; + expect(_check).toBe(true); + }); + }); + + describe('InferSSOutput', () => { + it('should extract output type from simple Zod schema', () => { + const schema = z.string(); + type Output = InferSSOutput; + type OutputIsString = Assert>; + const _check: OutputIsString = true; + expect(_check).toBe(true); + }); + + it('should handle Zod transform (output differs from input)', () => { + const schema = z.string().transform((s) => s.length); + type Input = InferSSInput; + type Output = InferSSOutput; + + // Input is string + type InputIsString = Assert>; + const _inputCheck: InputIsString = true; + expect(_inputCheck).toBe(true); + + // Output is number (transformed) + type OutputIsNumber = Assert>; + const _outputCheck: OutputIsNumber = true; + expect(_outputCheck).toBe(true); + }); + + it('should extract output type from Zod object schema', () => { + const schema = z.object({ + email: z.string().email(), + count: z.number().int(), + }); + + type Output = InferSSOutput; + + type EmailIsString = Assert>; + type CountIsNumber = Assert>; + + const _emailCheck: EmailIsString = true; + const _countCheck: CountIsNumber = true; + + expect(_emailCheck).toBe(true); + expect(_countCheck).toBe(true); + }); + + it('should handle Zod optional fields', () => { + const schema = z.object({ + required: z.string(), + optional: z.string().optional(), + }); + + type Output = InferSSOutput; + + // Required field is just string + type RequiredIsString = Assert>; + const _reqCheck: RequiredIsString = true; + expect(_reqCheck).toBe(true); + + // Optional field is string | undefined + type OptionalIsStringOrUndef = Assert>; + const _optCheck: OptionalIsStringOrUndef = true; + expect(_optCheck).toBe(true); + }); + + it('should return never for non-Standard Schema types', () => { + type Output = InferSSOutput<{ notASchema: true }>; + type OutputIsNever = Assert>; + const _check: OutputIsNever = true; + expect(_check).toBe(true); + }); + }); + + describe('Standard Schema runtime compliance', () => { + it('should verify Zod implements ~standard interface', () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + }); + + // Check runtime Standard Schema interface + const ssInterface = (schema as any)['~standard']; + expect(ssInterface).toBeDefined(); + expect(ssInterface.version).toBe(1); + expect(typeof ssInterface.vendor).toBe('string'); + expect(typeof ssInterface.validate).toBe('function'); + }); + + it('should use Standard Schema validate for runtime validation', async () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + }); + + const ssInterface = (schema as any)['~standard'] as StandardSchemaV1['~standard']; + + // Valid input + const validResult = ssInterface.validate({ name: 'Alice', age: 30 }); + + // Handle both sync and async results + const resolvedValid = validResult instanceof Promise ? await validResult : validResult; + expect(resolvedValid.issues).toBeUndefined(); + expect(resolvedValid.value).toEqual({ name: 'Alice', age: 30 }); + + // Invalid input + const invalidResult = ssInterface.validate({ name: 123, age: 'thirty' }); + const resolvedInvalid = invalidResult instanceof Promise ? await invalidResult : invalidResult; + expect(resolvedInvalid.issues).toBeDefined(); + expect(resolvedInvalid.issues!.length).toBeGreaterThan(0); + }); + }); + + describe('vitest expectTypeOf assertions', () => { + it('should verify IsStandardSchema with expectTypeOf', () => { + const zodSchema = z.string(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it('should verify InferSSInput with expectTypeOf', () => { + const schema = z.object({ + id: z.string(), + count: z.number(), + }); + + expectTypeOf>().toEqualTypeOf<{ + id: string; + count: number; + }>(); + }); + + it('should verify InferSSOutput with expectTypeOf', () => { + const schema = z.string().transform((s) => parseInt(s, 10)); + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + }); +}); diff --git a/packages/mizzle-orm/src/types/standard-schema.ts b/packages/mizzle-orm/src/types/standard-schema.ts new file mode 100644 index 0000000..c36379c --- /dev/null +++ b/packages/mizzle-orm/src/types/standard-schema.ts @@ -0,0 +1,66 @@ +/** + * Standard Schema type utilities for Mizzle ORM + * Enables integration with any Standard Schema-compliant validation library (Zod, Valibot, ArkType) + * @see https://standardschema.dev/ + */ + +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +// Re-export the core Standard Schema interface +export type { StandardSchemaV1 }; + +/** + * Type guard to check if a value conforms to the Standard Schema interface + * A valid Standard Schema must have: + * - ~standard.version property (1 for StandardSchemaV1) + * - ~standard.vendor property (string identifying the library) + * - ~standard.validate method for validation + * + * @example + * ```typescript + * import { z } from 'zod'; + * + * const schema = z.string(); + * type Test = IsStandardSchema; // true + * + * const notSchema = { foo: 'bar' }; + * type Test2 = IsStandardSchema; // false + * ``` + */ +export type IsStandardSchema = T extends StandardSchemaV1 ? true : false; + +/** + * Extract the input type from a Standard Schema + * This is the type that the schema accepts for validation + * + * @example + * ```typescript + * import { z } from 'zod'; + * + * const userSchema = z.object({ + * name: z.string(), + * age: z.number(), + * }); + * + * type UserInput = InferSSInput; + * // { name: string; age: number } + * ``` + */ +export type InferSSInput = T extends StandardSchemaV1 ? TInput : never; + +/** + * Extract the output type from a Standard Schema + * This is the type returned after successful validation + * May differ from input type due to transformations or defaults + * + * @example + * ```typescript + * import { z } from 'zod'; + * + * const schema = z.string().transform(s => s.length); + * + * type Input = InferSSInput; // string + * type Output = InferSSOutput; // number + * ``` + */ +export type InferSSOutput = T extends StandardSchemaV1 ? TOutput : never; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d135d5..84ad390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: packages/mizzle-orm: dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 mongodb: specifier: ^7.0.0 version: 7.0.0 @@ -647,8 +650,8 @@ packages: cpu: [x64] os: [win32] - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2345,7 +2348,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@types/chai@5.2.3': dependencies: @@ -2469,7 +2472,7 @@ snapshots: '@vitest/expect@4.0.12': dependencies: - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 '@vitest/spy': 4.0.12 '@vitest/utils': 4.0.12 From 61c85521b4bd0e3294a62742cb796508b2c00e41 Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 21:29:47 +0000 Subject: [PATCH 02/12] feat: US-002 - Create type inference utilities for Standard Schema collections --- packages/mizzle-orm/src/index.ts | 1 + .../standard-schema-inference.test.ts | 375 ++++++++++++++++++ .../src/types/standard-schema-inference.ts | 101 +++++ 3 files changed, 477 insertions(+) create mode 100644 packages/mizzle-orm/src/types/__tests__/standard-schema-inference.test.ts create mode 100644 packages/mizzle-orm/src/types/standard-schema-inference.ts diff --git a/packages/mizzle-orm/src/index.ts b/packages/mizzle-orm/src/index.ts index 21219ec..299ab5b 100644 --- a/packages/mizzle-orm/src/index.ts +++ b/packages/mizzle-orm/src/index.ts @@ -52,6 +52,7 @@ export type { IncludeConfig, NestedIncludeConfig, WithIncluded } from './types/i // Standard Schema integration export type { StandardSchemaV1, IsStandardSchema, InferSSInput, InferSSOutput } from './types/standard-schema'; +export type { InferSSDocument, InferSSInsert, InferSSUpdate } from './types/standard-schema-inference'; // Validation export { diff --git a/packages/mizzle-orm/src/types/__tests__/standard-schema-inference.test.ts b/packages/mizzle-orm/src/types/__tests__/standard-schema-inference.test.ts new file mode 100644 index 0000000..79da468 --- /dev/null +++ b/packages/mizzle-orm/src/types/__tests__/standard-schema-inference.test.ts @@ -0,0 +1,375 @@ +/** + * Standard Schema inference type utility tests + */ + +import { describe, it, expect, expectTypeOf } from 'vitest'; +import { z } from 'zod'; +import { ObjectId } from 'mongodb'; +import type { InferSSDocument, InferSSInsert, InferSSUpdate } from '../standard-schema-inference'; + +// Type assertion helper - compilation will fail if types don't match +type AssertEqual = [T] extends [U] ? ([U] extends [T] ? true : false) : false; +type Assert = T; + +describe('Standard Schema Inference Type Utilities', () => { + describe('InferSSDocument', () => { + it('should add _id: ObjectId when schema has no _id', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + type UserDoc = InferSSDocument; + + // _id should be ObjectId + type IdIsObjectId = Assert>; + const _idCheck: IdIsObjectId = true; + expect(_idCheck).toBe(true); + + // Other fields should match schema output + type EmailIsString = Assert>; + type NameIsString = Assert>; + const _emailCheck: EmailIsString = true; + const _nameCheck: NameIsString = true; + expect(_emailCheck).toBe(true); + expect(_nameCheck).toBe(true); + }); + + it('should preserve existing _id type when defined in schema', () => { + const customIdSchema = z.object({ + _id: z.string().uuid(), + name: z.string(), + }); + + type CustomDoc = InferSSDocument; + + // _id should be string (from schema), not ObjectId + type IdIsString = Assert>; + const _idCheck: IdIsString = true; + expect(_idCheck).toBe(true); + }); + + it('should preserve existing ObjectId _id type when explicitly defined', () => { + // Schema explicitly defines _id as ObjectId + const explicitIdSchema = z.object({ + _id: z.instanceof(ObjectId), + email: z.string(), + }); + + type ExplicitDoc = InferSSDocument; + + // _id should be ObjectId as explicitly defined + type IdIsObjectId = Assert>; + const _check: IdIsObjectId = true; + expect(_check).toBe(true); + }); + + it('should handle complex nested objects', () => { + const profileSchema = z.object({ + user: z.object({ + name: z.string(), + settings: z.object({ + theme: z.enum(['light', 'dark']), + notifications: z.boolean(), + }), + }), + tags: z.array(z.string()), + }); + + type ProfileDoc = InferSSDocument; + + // _id should be added + type HasObjectId = Assert>; + const _idCheck: HasObjectId = true; + expect(_idCheck).toBe(true); + + // Nested structures should be preserved + type UserNameIsString = Assert>; + type ThemeIsEnum = Assert>; + type TagsIsArray = Assert>; + + const _nameCheck: UserNameIsString = true; + const _themeCheck: ThemeIsEnum = true; + const _tagsCheck: TagsIsArray = true; + + expect(_nameCheck).toBe(true); + expect(_themeCheck).toBe(true); + expect(_tagsCheck).toBe(true); + }); + + it('should handle schema with transforms', () => { + const transformSchema = z.object({ + createdAt: z.string().transform((s) => new Date(s)), + count: z.string().transform((s) => parseInt(s, 10)), + }); + + type TransformDoc = InferSSDocument; + + // Output types should be transformed + type CreatedAtIsDate = Assert>; + type CountIsNumber = Assert>; + + const _dateCheck: CreatedAtIsDate = true; + const _countCheck: CountIsNumber = true; + + expect(_dateCheck).toBe(true); + expect(_countCheck).toBe(true); + }); + }); + + describe('InferSSInsert', () => { + it('should return schema input type', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + age: z.number(), + }); + + type UserInsert = InferSSInsert; + + type InsertShape = Assert< + AssertEqual + >; + const _check: InsertShape = true; + expect(_check).toBe(true); + }); + + it('should handle optional fields', () => { + const userSchema = z.object({ + email: z.string().email(), + nickname: z.string().optional(), + }); + + type UserInsert = InferSSInsert; + + type EmailRequired = Assert>; + type NicknameOptional = Assert>; + + const _emailCheck: EmailRequired = true; + const _nickCheck: NicknameOptional = true; + + expect(_emailCheck).toBe(true); + expect(_nickCheck).toBe(true); + }); + + it('should handle default values (input before defaults applied)', () => { + const userSchema = z.object({ + email: z.string().email(), + role: z.enum(['user', 'admin']).default('user'), + }); + + type UserInsert = InferSSInsert; + + // Role should be optional in input since it has a default + // Note: Zod .default() makes the field optional in input + type RoleIsOptional = Assert>; + const _check: RoleIsOptional = true; + expect(_check).toBe(true); + }); + + it('should use input type before transforms', () => { + const schema = z.object({ + timestamp: z.string().transform((s) => new Date(s)), + }); + + type InsertType = InferSSInsert; + + // Input should be string (before transform) + type TimestampIsString = Assert>; + const _check: TimestampIsString = true; + expect(_check).toBe(true); + }); + }); + + describe('InferSSUpdate', () => { + it('should return partial document without _id', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + age: z.number(), + }); + + type UserUpdate = InferSSUpdate; + + // All fields should be optional + type EmailOptional = Assert>; + type NameOptional = Assert>; + type AgeOptional = Assert>; + + const _emailCheck: EmailOptional = true; + const _nameCheck: NameOptional = true; + const _ageCheck: AgeOptional = true; + + expect(_emailCheck).toBe(true); + expect(_nameCheck).toBe(true); + expect(_ageCheck).toBe(true); + }); + + it('should exclude _id from update type', () => { + const userSchema = z.object({ + email: z.string(), + name: z.string(), + }); + + type UserUpdate = InferSSUpdate; + + // _id should not be in the update type + type NoIdInUpdate = Assert>; + const _check: NoIdInUpdate = true; + expect(_check).toBe(true); + }); + + it('should exclude _id even when defined in schema', () => { + const customSchema = z.object({ + _id: z.string().uuid(), + name: z.string(), + }); + + type CustomUpdate = InferSSUpdate; + + // _id should be excluded + type NoIdInUpdate = Assert>; + const _check: NoIdInUpdate = true; + expect(_check).toBe(true); + + // name should be present and optional + type NameOptional = Assert>; + const _nameCheck: NameOptional = true; + expect(_nameCheck).toBe(true); + }); + + it('should use output type (after transforms) for updates', () => { + const schema = z.object({ + timestamp: z.string().transform((s) => new Date(s)), + count: z.number(), + }); + + type UpdateType = InferSSUpdate; + + // Update should use output type (Date, not string) + type TimestampIsDate = Assert>; + type CountIsNumber = Assert>; + + const _tsCheck: TimestampIsDate = true; + const _countCheck: CountIsNumber = true; + + expect(_tsCheck).toBe(true); + expect(_countCheck).toBe(true); + }); + }); + + describe('vitest expectTypeOf assertions', () => { + it('should verify InferSSDocument adds _id: ObjectId', () => { + const schema = z.object({ + name: z.string(), + email: z.string(), + }); + + expectTypeOf>().toMatchTypeOf<{ + _id: ObjectId; + name: string; + email: string; + }>(); + }); + + it('should verify InferSSDocument preserves custom _id', () => { + const schema = z.object({ + _id: z.string(), + name: z.string(), + }); + + expectTypeOf>().toMatchTypeOf<{ + _id: string; + name: string; + }>(); + }); + + it('should verify InferSSInsert matches schema input', () => { + const schema = z.object({ + email: z.string(), + count: z.number(), + }); + + expectTypeOf>().toEqualTypeOf<{ + email: string; + count: number; + }>(); + }); + + it('should verify InferSSUpdate is partial without _id', () => { + const schema = z.object({ + email: z.string(), + name: z.string(), + }); + + type Update = InferSSUpdate; + + // Should be partial + expectTypeOf().toMatchTypeOf>(); + + // Should not have _id + expectTypeOf().not.toHaveProperty('_id'); + }); + }); + + describe('integration scenarios', () => { + it('should work with realistic user schema', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), + role: z.enum(['user', 'admin', 'moderator']).default('user'), + createdAt: z.date().default(() => new Date()), + settings: z + .object({ + theme: z.enum(['light', 'dark']).default('light'), + language: z.string().default('en'), + }) + .optional(), + }); + + type UserDoc = InferSSDocument; + type UserInsert = InferSSInsert; + type UserUpdate = InferSSUpdate; + + // Document should have _id: ObjectId + expectTypeOf().toEqualTypeOf(); + + // Insert should match Zod input (with defaults as optional) + expectTypeOf().toEqualTypeOf(); + + // Update should be partial + expectTypeOf().toEqualTypeOf(); + }); + + it('should work with complex nested schema', () => { + const orderSchema = z.object({ + customerId: z.string(), + items: z.array( + z.object({ + productId: z.string(), + quantity: z.number().int().positive(), + price: z.number().positive(), + }) + ), + total: z.number(), + status: z.enum(['pending', 'shipped', 'delivered']), + }); + + type OrderDoc = InferSSDocument; + type OrderUpdate = InferSSUpdate; + + // Document has _id + expectTypeOf().toEqualTypeOf(); + + // Items array is preserved + expectTypeOf().toMatchTypeOf< + Array<{ productId: string; quantity: number; price: number }> + >(); + + // Update is partial + expectTypeOf().toEqualTypeOf< + 'pending' | 'shipped' | 'delivered' | undefined + >(); + }); + }); +}); diff --git a/packages/mizzle-orm/src/types/standard-schema-inference.ts b/packages/mizzle-orm/src/types/standard-schema-inference.ts new file mode 100644 index 0000000..e7c2ab7 --- /dev/null +++ b/packages/mizzle-orm/src/types/standard-schema-inference.ts @@ -0,0 +1,101 @@ +/** + * Type inference utilities for Standard Schema collections + * These utilities derive Document, Insert, and Update types from a Standard Schema + */ + +import type { ObjectId } from 'mongodb'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { InferSSInput, InferSSOutput } from './standard-schema'; + +/** + * Helper type to check if a type has _id property + */ +type HasIdProperty = T extends { _id: unknown } ? true : false; + +/** + * Infer the Document type from a Standard Schema + * This is what you get when reading from the database + * + * - If the schema output already includes _id, it uses that type + * - If _id is not defined, it adds _id: ObjectId automatically + * + * @example + * ```typescript + * import { z } from 'zod'; + * + * const userSchema = z.object({ + * email: z.string().email(), + * name: z.string(), + * }); + * + * type UserDoc = InferSSDocument; + * // { _id: ObjectId; email: string; name: string } + * ``` + * + * @example + * ```typescript + * // With custom _id type + * const customSchema = z.object({ + * _id: z.string(), + * name: z.string(), + * }); + * + * type CustomDoc = InferSSDocument; + * // { _id: string; name: string } + * ``` + */ +export type InferSSDocument> = + HasIdProperty> extends true + ? InferSSOutput + : InferSSOutput & { _id: ObjectId }; + +/** + * Infer the Insert type from a Standard Schema + * This is what you pass when creating a new document + * + * Uses the schema input type (before transforms) since that's + * what the validation library expects to receive. + * + * @example + * ```typescript + * import { z } from 'zod'; + * + * const userSchema = z.object({ + * email: z.string().email(), + * name: z.string(), + * role: z.enum(['user', 'admin']).default('user'), + * }); + * + * type UserInsert = InferSSInsert; + * // { email: string; name: string; role?: 'user' | 'admin' } + * ``` + */ +export type InferSSInsert> = InferSSInput; + +/** + * Infer the Update type from a Standard Schema + * This is what you can pass when updating a document + * + * - All fields are optional (partial updates) + * - _id is excluded (you can't update _id) + * + * Uses the schema output type since updates should match + * the stored document shape (after transforms). + * + * @example + * ```typescript + * import { z } from 'zod'; + * + * const userSchema = z.object({ + * email: z.string().email(), + * name: z.string(), + * role: z.enum(['user', 'admin']), + * }); + * + * type UserUpdate = InferSSUpdate; + * // { email?: string; name?: string; role?: 'user' | 'admin' } + * ``` + */ +export type InferSSUpdate> = Partial< + Omit, '_id'> +>; From 15f22113704d07568ff7a0ddd5d5ae5e4c90c4ec Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 21:34:23 +0000 Subject: [PATCH 03/12] feat: US-003 - Implement fromStandardSchema collection factory - Create src/collection/from-standard-schema.ts with: - fromStandardSchema(name, schema, options?) factory function - SSCollectionDefinition type with , , - SSCollectionOptions interface (publicId, softDelete, timestamps, middlewares) - SSCollectionMeta for runtime metadata access - isSSCollectionDefinition() type guard - ExtractSSDocument/Insert/Update helper types - Runtime Standard Schema compliance validation - Export fromStandardSchema and related types from index.ts - Add 35 comprehensive tests covering: - Basic factory functionality with any StandardSchemaV1 schema - Options handling (publicId, softDelete, timestamps) - Type inference for Document/Insert/Update types - Nested objects, transforms, optional/default fields - Extract type helpers - Type guard behavior - Realistic usage examples - Edge cases (empty schemas, deep nesting, unions, nullable) --- .../__tests__/from-standard-schema.test.ts | 554 ++++++++++++++++++ .../src/collection/from-standard-schema.ts | 266 +++++++++ packages/mizzle-orm/src/index.ts | 14 + progress.txt | 53 ++ 4 files changed, 887 insertions(+) create mode 100644 packages/mizzle-orm/src/collection/__tests__/from-standard-schema.test.ts create mode 100644 packages/mizzle-orm/src/collection/from-standard-schema.ts create mode 100644 progress.txt diff --git a/packages/mizzle-orm/src/collection/__tests__/from-standard-schema.test.ts b/packages/mizzle-orm/src/collection/__tests__/from-standard-schema.test.ts new file mode 100644 index 0000000..169602b --- /dev/null +++ b/packages/mizzle-orm/src/collection/__tests__/from-standard-schema.test.ts @@ -0,0 +1,554 @@ +/** + * Tests for fromStandardSchema collection factory + */ + +import { describe, it, expect, expectTypeOf } from 'vitest'; +import { z } from 'zod'; +import type { ObjectId } from 'mongodb'; +import { + fromStandardSchema, + isSSCollectionDefinition, + type SSCollectionDefinition, + type ExtractSSDocument, + type ExtractSSInsert, + type ExtractSSUpdate, +} from '../from-standard-schema'; + +describe('fromStandardSchema', () => { + describe('basic functionality', () => { + it('should create a collection definition from a Zod schema', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema); + + expect(users._brand).toBe('SSCollectionDefinition'); + expect(users._meta.name).toBe('users'); + expect(users._schema).toBe(userSchema); + }); + + it('should accept any StandardSchemaV1 compliant schema', () => { + // Zod schemas implement Standard Schema + const stringSchema = z.string(); + const numberSchema = z.number(); + const objectSchema = z.object({ foo: z.string() }); + const arraySchema = z.array(z.number()); + + // All should work + const col1 = fromStandardSchema('strings', stringSchema); + const col2 = fromStandardSchema('numbers', numberSchema); + const col3 = fromStandardSchema('objects', objectSchema); + const col4 = fromStandardSchema('arrays', arraySchema); + + expect(col1._brand).toBe('SSCollectionDefinition'); + expect(col2._brand).toBe('SSCollectionDefinition'); + expect(col3._brand).toBe('SSCollectionDefinition'); + expect(col4._brand).toBe('SSCollectionDefinition'); + }); + + it('should throw error for non-Standard Schema values', () => { + const notASchema = { foo: 'bar' }; + + expect(() => { + fromStandardSchema('invalid', notASchema as any); + }).toThrow('Standard Schema compliant'); + }); + + it('should store the schema in the definition for runtime access', () => { + const schema = z.object({ + title: z.string(), + published: z.boolean(), + }); + + const posts = fromStandardSchema('posts', schema); + + // Schema should be accessible at runtime + expect(posts._schema).toBe(schema); + + // Should be able to use it for validation + const ssInterface = (posts._schema as any)['~standard']; + expect(ssInterface).toBeDefined(); + expect(typeof ssInterface.validate).toBe('function'); + }); + }); + + describe('options handling', () => { + it('should accept options with publicId', () => { + const schema = z.object({ name: z.string() }); + + const users = fromStandardSchema('users', schema, { + publicId: 'user', + }); + + expect(users._meta.options.publicId).toBe('user'); + }); + + it('should accept options with softDelete', () => { + const schema = z.object({ name: z.string() }); + + const users = fromStandardSchema('users', schema, { + softDelete: true, + }); + + expect(users._meta.options.softDelete).toBe(true); + }); + + it('should accept options with timestamps', () => { + const schema = z.object({ name: z.string() }); + + const users = fromStandardSchema('users', schema, { + timestamps: true, + }); + + expect(users._meta.options.timestamps).toBe(true); + }); + + it('should accept all options together', () => { + const schema = z.object({ name: z.string() }); + + const users = fromStandardSchema('users', schema, { + publicId: 'user', + softDelete: true, + timestamps: true, + }); + + expect(users._meta.options.publicId).toBe('user'); + expect(users._meta.options.softDelete).toBe(true); + expect(users._meta.options.timestamps).toBe(true); + }); + + it('should work without options', () => { + const schema = z.object({ name: z.string() }); + + const users = fromStandardSchema('users', schema); + + expect(users._meta.options).toEqual({}); + }); + }); + + describe('type inference - $inferDocument', () => { + it('should infer Document type with _id: ObjectId added', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema); + + type UserDoc = typeof users.$inferDocument; + + // Verify type structure with expectTypeOf + expectTypeOf().toHaveProperty('_id'); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('name'); + expectTypeOf().toBeString(); + expectTypeOf().toBeString(); + }); + + it('should preserve custom _id type if defined in schema', () => { + const customIdSchema = z.object({ + _id: z.string(), + name: z.string(), + }); + + const items = fromStandardSchema('items', customIdSchema); + + type ItemDoc = typeof items.$inferDocument; + + // _id should be string, not ObjectId + expectTypeOf().toBeString(); + expectTypeOf().toBeString(); + }); + + it('should handle nested objects in Document type', () => { + const schema = z.object({ + profile: z.object({ + bio: z.string(), + avatar: z.string().url(), + }), + settings: z.object({ + theme: z.enum(['light', 'dark']), + notifications: z.boolean(), + }), + }); + + const users = fromStandardSchema('users', schema); + + type UserDoc = typeof users.$inferDocument; + + expectTypeOf().toHaveProperty('bio'); + expectTypeOf().toBeString(); + expectTypeOf().toEqualTypeOf<'light' | 'dark'>(); + }); + + it('should handle transforms in Document type', () => { + const schema = z.object({ + slug: z.string().transform((s) => s.toLowerCase()), + count: z.string().transform((s) => parseInt(s, 10)), + }); + + const posts = fromStandardSchema('posts', schema); + + type PostDoc = typeof posts.$inferDocument; + + // Output types after transform + expectTypeOf().toBeString(); + expectTypeOf().toBeNumber(); + }); + }); + + describe('type inference - $inferInsert', () => { + it('should infer Insert type as schema input type', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema); + + type UserInsert = typeof users.$inferInsert; + + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('name'); + expectTypeOf().toBeString(); + }); + + it('should handle optional fields in Insert type', () => { + const schema = z.object({ + required: z.string(), + optional: z.string().optional(), + }); + + const items = fromStandardSchema('items', schema); + + type ItemInsert = typeof items.$inferInsert; + + // Required field is required + expectTypeOf().toBeString(); + // Optional field allows undefined + expectTypeOf().toEqualTypeOf(); + }); + + it('should handle defaults making fields optional in Insert type', () => { + const schema = z.object({ + name: z.string(), + role: z.enum(['user', 'admin']).default('user'), + }); + + const users = fromStandardSchema('users', schema); + + type UserInsert = typeof users.$inferInsert; + + // name is required + expectTypeOf().toBeString(); + // role is optional due to default (Zod makes it optional in input type) + expectTypeOf().toHaveProperty('role'); + }); + + it('should use input type (before transforms)', () => { + const schema = z.object({ + slug: z.string().transform((s) => s.toLowerCase()), + count: z.string().transform((s) => parseInt(s, 10)), + }); + + const posts = fromStandardSchema('posts', schema); + + type PostInsert = typeof posts.$inferInsert; + + // Input types before transform + expectTypeOf().toBeString(); + expectTypeOf().toBeString(); // Still string on input + }); + }); + + describe('type inference - $inferUpdate', () => { + it('should infer Update type as Partial without _id', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema); + + type UserUpdate = typeof users.$inferUpdate; + + // All fields should be optional + expectTypeOf().toMatchTypeOf<{ + email?: string; + name?: string; + }>(); + }); + + it('should exclude _id from Update type', () => { + const schema = z.object({ + name: z.string(), + value: z.number(), + }); + + const items = fromStandardSchema('items', schema); + + type ItemUpdate = typeof items.$inferUpdate; + + // Should not have _id + expectTypeOf().not.toHaveProperty('_id'); + // Should have other fields as optional + expectTypeOf().toMatchTypeOf<{ + name?: string; + value?: number; + }>(); + }); + + it('should use output type (after transforms) for Update', () => { + const schema = z.object({ + count: z.string().transform((s) => parseInt(s, 10)), + }); + + const items = fromStandardSchema('items', schema); + + type ItemUpdate = typeof items.$inferUpdate; + + // Uses output type (number after transform) + expectTypeOf().toMatchTypeOf<{ + count?: number; + }>(); + }); + }); + + describe('Extract type helpers', () => { + it('should extract Document type with ExtractSSDocument', () => { + const schema = z.object({ + email: z.string(), + name: z.string(), + }); + + const users = fromStandardSchema('users', schema); + + type UserDoc = ExtractSSDocument; + + expectTypeOf().toHaveProperty('_id'); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('name'); + }); + + it('should extract Insert type with ExtractSSInsert', () => { + const schema = z.object({ + email: z.string(), + name: z.string(), + }); + + const users = fromStandardSchema('users', schema); + + type UserInsert = ExtractSSInsert; + + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('name'); + }); + + it('should extract Update type with ExtractSSUpdate', () => { + const schema = z.object({ + email: z.string(), + name: z.string(), + }); + + const users = fromStandardSchema('users', schema); + + type UserUpdate = ExtractSSUpdate; + + expectTypeOf().toMatchTypeOf<{ + email?: string; + name?: string; + }>(); + }); + }); + + describe('isSSCollectionDefinition type guard', () => { + it('should return true for SSCollectionDefinition', () => { + const schema = z.object({ name: z.string() }); + const users = fromStandardSchema('users', schema); + + expect(isSSCollectionDefinition(users)).toBe(true); + }); + + it('should return false for null/undefined', () => { + expect(isSSCollectionDefinition(null)).toBe(false); + expect(isSSCollectionDefinition(undefined)).toBe(false); + }); + + it('should return false for plain objects', () => { + expect(isSSCollectionDefinition({})).toBe(false); + expect(isSSCollectionDefinition({ _brand: 'wrong' })).toBe(false); + }); + + it('should return false for objects with wrong brand', () => { + expect(isSSCollectionDefinition({ _brand: 'CollectionDefinition' })).toBe(false); + }); + }); + + describe('realistic usage examples', () => { + it('should work with a typical user collection', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + role: z.enum(['user', 'admin', 'moderator']).default('user'), + profile: z + .object({ + bio: z.string().optional(), + website: z.string().url().optional(), + }) + .optional(), + lastLoginAt: z.date().optional(), + createdAt: z.date().default(() => new Date()), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: 'user', + softDelete: true, + timestamps: true, + }); + + expect(users._meta.name).toBe('users'); + expect(users._meta.options.publicId).toBe('user'); + + // Type checks + type UserDoc = typeof users.$inferDocument; + type UserInsert = typeof users.$inferInsert; + + expectTypeOf().toHaveProperty('_id'); + expectTypeOf().toEqualTypeOf<'user' | 'admin' | 'moderator'>(); + + expectTypeOf().toBeString(); + }); + + it('should work with a blog post collection with transforms', () => { + const postSchema = z.object({ + title: z.string().min(1).max(200), + slug: z.string().transform((s) => + s + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + ), + content: z.string(), + tags: z.array(z.string()).default([]), + published: z.boolean().default(false), + views: z.number().int().min(0).default(0), + authorId: z.string(), + }); + + const posts = fromStandardSchema('posts', postSchema, { + publicId: 'post', + }); + + expect(posts._meta.name).toBe('posts'); + + type PostDoc = typeof posts.$inferDocument; + type PostInsert = typeof posts.$inferInsert; + + // Document has transformed types + expectTypeOf().toBeString(); + expectTypeOf().toBeNumber(); + expectTypeOf().toEqualTypeOf(); + + // Insert accepts input types + expectTypeOf().toBeString(); + }); + + it('should allow runtime validation using stored schema', async () => { + const schema = z.object({ + email: z.string().email(), + age: z.number().min(0).max(150), + }); + + const users = fromStandardSchema('users', schema); + + // Access the Standard Schema interface for validation + const ssInterface = (users._schema as any)['~standard']; + + // Valid data + const validResult = ssInterface.validate({ email: 'test@example.com', age: 25 }); + const resolved = validResult instanceof Promise ? await validResult : validResult; + expect(resolved.issues).toBeUndefined(); + expect(resolved.value).toEqual({ email: 'test@example.com', age: 25 }); + + // Invalid data + const invalidResult = ssInterface.validate({ email: 'not-an-email', age: -5 }); + const resolvedInvalid = invalidResult instanceof Promise ? await invalidResult : invalidResult; + expect(resolvedInvalid.issues).toBeDefined(); + expect(resolvedInvalid.issues!.length).toBeGreaterThan(0); + }); + }); + + describe('edge cases', () => { + it('should handle empty object schema', () => { + const schema = z.object({}); + const empty = fromStandardSchema('empty', schema); + + expect(empty._meta.name).toBe('empty'); + + type EmptyDoc = typeof empty.$inferDocument; + expectTypeOf().toHaveProperty('_id'); + }); + + it('should handle deeply nested schemas', () => { + const schema = z.object({ + level1: z.object({ + level2: z.object({ + level3: z.object({ + value: z.string(), + }), + }), + }), + }); + + const deep = fromStandardSchema('deep', schema); + + type DeepDoc = typeof deep.$inferDocument; + + expectTypeOf().toBeString(); + }); + + it('should handle union types', () => { + const schema = z.object({ + status: z.union([ + z.literal('pending'), + z.literal('approved'), + z.literal('rejected'), + ]), + }); + + const items = fromStandardSchema('items', schema); + + type ItemDoc = typeof items.$inferDocument; + + expectTypeOf().toEqualTypeOf<'pending' | 'approved' | 'rejected'>(); + }); + + it('should handle nullable fields', () => { + const schema = z.object({ + name: z.string(), + description: z.string().nullable(), + }); + + const items = fromStandardSchema('items', schema); + + type ItemDoc = typeof items.$inferDocument; + + expectTypeOf().toBeString(); + expectTypeOf().toEqualTypeOf(); + }); + + it('should handle record types', () => { + const schema = z.object({ + metadata: z.record(z.string(), z.unknown()), + }); + + const items = fromStandardSchema('items', schema); + + type ItemDoc = typeof items.$inferDocument; + + expectTypeOf().toEqualTypeOf>(); + }); + }); +}); diff --git a/packages/mizzle-orm/src/collection/from-standard-schema.ts b/packages/mizzle-orm/src/collection/from-standard-schema.ts new file mode 100644 index 0000000..f6f6467 --- /dev/null +++ b/packages/mizzle-orm/src/collection/from-standard-schema.ts @@ -0,0 +1,266 @@ +/** + * Standard Schema collection factory + * Creates collection definitions from Standard Schema-compliant validation libraries (Zod, Valibot, ArkType) + */ + +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { Middleware } from '../types/middleware'; +import type { InferSSDocument, InferSSInsert, InferSSUpdate } from '../types/standard-schema-inference'; + +/** + * Options for Standard Schema collections + * Simplified options compared to field-builder collections since the schema handles validation + */ +export interface SSCollectionOptions { + /** + * Public ID prefix for human-readable IDs + * @example 'user' → 'user_abc123' + */ + publicId?: string; + + /** + * Enable soft delete (adds deletedAt field) + */ + softDelete?: boolean; + + /** + * Enable timestamps (adds createdAt, updatedAt fields) + */ + timestamps?: boolean; + + /** + * Custom middlewares for this collection + */ + middlewares?: Middleware[]; +} + +/** + * Metadata for Standard Schema collections + */ +export interface SSCollectionMeta> { + /** + * Collection name in MongoDB + */ + name: string; + + /** + * The Standard Schema used for validation + */ + schema: T; + + /** + * Collection options + */ + options: SSCollectionOptions; + + /** + * Middlewares applied to this collection + */ + middlewares: Middleware[]; +} + +/** + * Collection definition for Standard Schema-based collections + * + * This mirrors the CollectionDefinition interface but uses Standard Schema + * types for inference instead of field builders. + * + * @template T - The Standard Schema type (must be StandardSchemaV1 compliant) + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { fromStandardSchema } from '@mizzle-dev/orm'; + * + * const userSchema = z.object({ + * email: z.string().email(), + * name: z.string(), + * role: z.enum(['user', 'admin']).default('user'), + * }); + * + * const users = fromStandardSchema('users', userSchema, { + * publicId: 'user', + * softDelete: true, + * }); + * + * // Type inference works automatically + * type UserDoc = typeof users.$inferDocument; + * // { _id: ObjectId; email: string; name: string; role: 'user' | 'admin' } + * ``` + */ +export interface SSCollectionDefinition> { + /** + * The Standard Schema used for validation + * Stored for runtime access (validation on insert/update) + */ + readonly _schema: T; + + /** + * Collection metadata + */ + readonly _meta: SSCollectionMeta; + + /** + * Brand to distinguish from regular CollectionDefinition + */ + readonly _brand: 'SSCollectionDefinition'; + + /** + * Infer the Document type (what you get from the database) + * Includes _id: ObjectId unless the schema defines _id + */ + readonly $inferDocument: InferSSDocument; + + /** + * Infer the Insert type (what you pass to create a document) + * Uses schema input type (before transforms, respects optional/defaults) + */ + readonly $inferInsert: InferSSInsert; + + /** + * Infer the Update type (what you pass to update a document) + * Partial of Document without _id + */ + readonly $inferUpdate: InferSSUpdate; +} + +/** + * Create a collection definition from a Standard Schema-compliant schema + * + * This is the primary way to define collections using validation libraries + * like Zod, Valibot, or ArkType instead of Mizzle's field builders. + * + * @param name - The MongoDB collection name + * @param schema - Any Standard Schema-compliant schema (Zod, Valibot, ArkType, etc.) + * @param options - Optional collection configuration + * @returns A collection definition with full type inference + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { fromStandardSchema } from '@mizzle-dev/orm'; + * + * // Define schema with Zod + * const userSchema = z.object({ + * email: z.string().email(), + * name: z.string(), + * role: z.enum(['user', 'admin']).default('user'), + * }); + * + * // Create collection definition + * const users = fromStandardSchema('users', userSchema, { + * publicId: 'user', + * softDelete: true, + * timestamps: true, + * }); + * + * // Type helpers are available + * type UserDoc = typeof users.$inferDocument; + * type UserInsert = typeof users.$inferInsert; + * type UserUpdate = typeof users.$inferUpdate; + * ``` + * + * @example + * ```typescript + * // Works with transforms + * const postSchema = z.object({ + * title: z.string(), + * slug: z.string().transform(s => s.toLowerCase().replace(/\s+/g, '-')), + * views: z.number().default(0), + * }); + * + * const posts = fromStandardSchema('posts', postSchema); + * + * // Insert type has original string for slug + * type PostInsert = typeof posts.$inferInsert; + * // { title: string; slug: string; views?: number } + * + * // Document type has transformed slug + * type PostDoc = typeof posts.$inferDocument; + * // { _id: ObjectId; title: string; slug: string; views: number } + * ``` + */ +export function fromStandardSchema>( + name: string, + schema: T, + options: SSCollectionOptions = {}, +): SSCollectionDefinition { + // Validate that the schema is Standard Schema compliant + if (!isStandardSchema(schema)) { + throw new Error( + `Schema passed to fromStandardSchema must be Standard Schema compliant. ` + + `Expected an object with '~standard' property containing version, vendor, and validate.` + ); + } + + // Build metadata + const meta: SSCollectionMeta = { + name, + schema, + options, + middlewares: options.middlewares || [], + }; + + // Create collection definition + // The type helpers ($inferDocument, etc.) are phantom types - they exist only at compile time + const definition: SSCollectionDefinition = { + _schema: schema, + _meta: meta, + _brand: 'SSCollectionDefinition', + // These are phantom types for inference, never accessed at runtime + $inferDocument: null as any, + $inferInsert: null as any, + $inferUpdate: null as any, + }; + + return definition; +} + +/** + * Runtime check for Standard Schema compliance + * Verifies the schema has the required ~standard interface + */ +function isStandardSchema(value: unknown): value is StandardSchemaV1 { + if (typeof value !== 'object' || value === null) { + return false; + } + + const standard = (value as any)['~standard']; + if (typeof standard !== 'object' || standard === null) { + return false; + } + + return ( + typeof standard.version === 'number' && + typeof standard.vendor === 'string' && + typeof standard.validate === 'function' + ); +} + +/** + * Type guard to check if a collection definition is a Standard Schema collection + */ +export function isSSCollectionDefinition( + value: unknown, +): value is SSCollectionDefinition { + return ( + typeof value === 'object' && + value !== null && + (value as any)._brand === 'SSCollectionDefinition' + ); +} + +/** + * Extract the Document type from an SSCollectionDefinition + */ +export type ExtractSSDocument> = T['$inferDocument']; + +/** + * Extract the Insert type from an SSCollectionDefinition + */ +export type ExtractSSInsert> = T['$inferInsert']; + +/** + * Extract the Update type from an SSCollectionDefinition + */ +export type ExtractSSUpdate> = T['$inferUpdate']; diff --git a/packages/mizzle-orm/src/index.ts b/packages/mizzle-orm/src/index.ts index 299ab5b..9a0bb67 100644 --- a/packages/mizzle-orm/src/index.ts +++ b/packages/mizzle-orm/src/index.ts @@ -7,6 +7,20 @@ export { mizzle, defineSchema } from './orm/orm'; export { mongoCollection } from './collection/collection'; +// Standard Schema collection factory +export { + fromStandardSchema, + isSSCollectionDefinition, +} from './collection/from-standard-schema'; +export type { + SSCollectionDefinition, + SSCollectionOptions, + SSCollectionMeta, + ExtractSSDocument, + ExtractSSInsert, + ExtractSSUpdate, +} from './collection/from-standard-schema'; + // Relation factory functions export { lookup, reference, embed } from './collection/relations'; diff --git a/progress.txt b/progress.txt new file mode 100644 index 0000000..02d7a54 --- /dev/null +++ b/progress.txt @@ -0,0 +1,53 @@ +# Progress Log +Run: e3cd4a59-170f-4265-8334-d7048dd34df2 +Task: Add Standard Schema support to mizzle-orm +Started: 2026-02-11T21:23:00Z + +## Codebase Patterns +- Monorepo with turbo, main package at `packages/mizzle-orm` +- Build: `pnpm build` → tsup (generates CJS/ESM/DTS) +- Test: `pnpm test` → vitest (230+ tests) +- Typecheck: `pnpm typecheck` → tsc --noEmit +- Type files live in `src/types/`, tests in `src//__tests__/` +- vitest expectTypeOf available for type-level assertions +- Zod is already a dependency (zod ^4.1.12) +- Uses node:test style expect/describe/it from vitest + +--- + +## 2026-02-11 21:25 - US-001: Add Standard Schema spec package and create base types +- Added `@standard-schema/spec` package as dependency +- Created `src/types/standard-schema.ts` with: + - StandardSchemaV1 re-export + - IsStandardSchema type guard + - InferSSInput helper type + - InferSSOutput helper type +- Exported types from `src/index.ts` +- Files changed: package.json, src/index.ts, src/types/standard-schema.ts, pnpm-lock.yaml +- Tests: Created `src/types/__tests__/standard-schema.test.ts` with 17 tests covering: + - IsStandardSchema detection for Zod schemas vs plain objects + - InferSSInput extraction from various Zod schema types + - InferSSOutput extraction including transforms + - Runtime Standard Schema compliance verification + - vitest expectTypeOf assertions +- **Learnings:** Zod 4.x fully implements Standard Schema v1 spec with `~standard` interface +--- + +## 2026-02-11 21:29 - US-002: Create type inference utilities for Standard Schema collections +- Created `src/types/standard-schema-inference.ts` with: + - `InferSSDocument` - adds _id: ObjectId to schema output, preserves existing _id if defined + - `InferSSInsert` - returns schema input type (before transforms) + - `InferSSUpdate` - returns Partial of document without _id (using output type) +- Helper type `HasIdProperty` to check if schema already defines _id +- Exported types from `src/index.ts` +- Files changed: src/types/standard-schema-inference.ts, src/types/__tests__/standard-schema-inference.test.ts, src/index.ts +- Tests: Created 19 tests covering: + - InferSSDocument adding _id: ObjectId when not in schema + - InferSSDocument preserving custom _id types (string, explicit ObjectId) + - InferSSDocument with nested objects and transforms + - InferSSInsert returning input type (before transforms, with optionals for defaults) + - InferSSUpdate returning Partial without _id + - Integration tests with realistic schemas + - vitest expectTypeOf assertions +- **Learnings:** Use `HasIdProperty` conditional to check for existing _id before adding ObjectId; Update types use output (post-transform) while Insert uses input (pre-transform) +--- From 43afb1a513245b0e0bd1b577131c59436b5d35fa Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 21:34:45 +0000 Subject: [PATCH 04/12] docs: update progress.txt with US-003 completion --- progress.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/progress.txt b/progress.txt index 02d7a54..6cd553e 100644 --- a/progress.txt +++ b/progress.txt @@ -12,6 +12,8 @@ Started: 2026-02-11T21:23:00Z - vitest expectTypeOf available for type-level assertions - Zod is already a dependency (zod ^4.1.12) - Uses node:test style expect/describe/it from vitest +- Collection definitions use phantom types for `$inferDocument`, `$inferInsert`, `$inferUpdate` - assigned `null as any`, never accessed at runtime +- Use `_brand` literal type to distinguish collection types (e.g., `'CollectionDefinition'` vs `'SSCollectionDefinition'`) --- @@ -51,3 +53,29 @@ Started: 2026-02-11T21:23:00Z - vitest expectTypeOf assertions - **Learnings:** Use `HasIdProperty` conditional to check for existing _id before adding ObjectId; Update types use output (post-transform) while Insert uses input (pre-transform) --- + +## 2026-02-11 21:34 - US-003: Implement fromStandardSchema collection factory +- Created `src/collection/from-standard-schema.ts` with: + - `fromStandardSchema(name, schema, options?)` factory function + - `SSCollectionDefinition` type with `$inferDocument`, `$inferInsert`, `$inferUpdate` phantom types + - `SSCollectionOptions` interface (publicId, softDelete, timestamps, middlewares) + - `SSCollectionMeta` for runtime metadata access + - `isSSCollectionDefinition()` type guard for distinguishing from regular collections + - `ExtractSSDocument/Insert/Update` helper types + - Runtime validation that schema is Standard Schema compliant (checks for `~standard` interface) +- Exported all types and functions from `src/index.ts` +- Files changed: src/collection/from-standard-schema.ts, src/collection/__tests__/from-standard-schema.test.ts, src/index.ts +- Tests: Created 35 comprehensive tests covering: + - Basic factory with any StandardSchemaV1 schema (string, number, object, array) + - Error handling for non-compliant schemas + - Schema stored in definition for runtime validation access + - Options: publicId, softDelete, timestamps, combined, no options + - Type inference: $inferDocument adds _id, preserves custom _id, nested objects, transforms + - Type inference: $inferInsert uses input types, handles optional/default fields + - Type inference: $inferUpdate is Partial without _id, uses output types + - Extract helper types + - Type guard behavior + - Realistic user/post collection examples with runtime validation demo + - Edge cases: empty schema, deep nesting, unions, nullable, records +- **Learnings:** Collection definitions use phantom types (`null as any`) for type inference helpers - they're never accessed at runtime. The `_brand` literal type pattern helps distinguish collection types. +--- From 810b5fb85b2d4c1aa9db7758265947840742edf2 Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 21:43:46 +0000 Subject: [PATCH 05/12] feat: US-004 - Integrate Standard Schema validation into insert and update operations - Add SSValidationError class in src/errors/validation-error.ts - Contains issues array from Standard Schema validation - Includes getIssuesAtPath(), paths getter, and toJSON() helpers - Modify CollectionFacade to validate data using Standard Schema - create() validates data before inserting (throws SSValidationError on failure) - Updates skip full validation since they're partial (known limitation) - Backward compatible: validation skipped for non-SS collections - Add guards for SS collections in applyDefaults, applyPolicies, and hooks - Export SSValidationError and ValidationIssue from main index - Add comprehensive tests in standard-schema-validation.test.ts (20 tests) Note: Update validation is intentionally skipped because Standard Schema validates complete structures, but updates only contain changed fields. Future enhancement could validate individual field types. --- .../mizzle-orm/src/errors/validation-error.ts | 95 +++++ packages/mizzle-orm/src/index.ts | 4 + .../standard-schema-validation.test.ts | 345 ++++++++++++++++++ .../mizzle-orm/src/query/collection-facade.ts | 133 +++++-- 4 files changed, 547 insertions(+), 30 deletions(-) create mode 100644 packages/mizzle-orm/src/errors/validation-error.ts create mode 100644 packages/mizzle-orm/src/query/__tests__/standard-schema-validation.test.ts diff --git a/packages/mizzle-orm/src/errors/validation-error.ts b/packages/mizzle-orm/src/errors/validation-error.ts new file mode 100644 index 0000000..7be0213 --- /dev/null +++ b/packages/mizzle-orm/src/errors/validation-error.ts @@ -0,0 +1,95 @@ +/** + * Standard Schema validation error + * Thrown when data fails validation against a Standard Schema-based collection + */ + +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +/** + * Validation issue from Standard Schema + * Re-exported for convenience + */ +export type ValidationIssue = StandardSchemaV1.Issue; + +/** + * Error thrown when Standard Schema validation fails + * + * Contains the full issues array from the validation result for detailed error handling. + * + * @example + * ```typescript + * try { + * await users.create({ email: 'invalid', name: '' }); + * } catch (error) { + * if (error instanceof SSValidationError) { + * console.log(error.issues); + * // [ + * // { message: 'Invalid email', path: ['email'] }, + * // { message: 'String must be at least 1 character', path: ['name'] } + * // ] + * } + * } + * ``` + */ +export class SSValidationError extends Error { + /** + * The validation issues from Standard Schema + * Each issue contains a message and optional path to the invalid field + */ + public readonly issues: readonly ValidationIssue[]; + + constructor(issues: readonly ValidationIssue[], message?: string) { + // Build a descriptive message from issues if not provided + const firstIssue = issues[0]; + const defaultMessage = issues.length === 1 && firstIssue + ? `Validation failed: ${firstIssue.message}` + : `Validation failed with ${issues.length} issues: ${issues.map(i => i.message).join('; ')}`; + + super(message || defaultMessage); + this.name = 'SSValidationError'; + this.issues = issues; + + // Maintains proper stack trace for where error was thrown (V8 engines) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, SSValidationError); + } + } + + /** + * Get issues at a specific path + * @param path - The path to filter by (e.g., ['user', 'email']) + */ + getIssuesAtPath(path: (string | number | symbol)[]): readonly ValidationIssue[] { + return this.issues.filter(issue => { + if (!issue.path || issue.path.length !== path.length) return false; + return issue.path.every((segment, index) => segment === path[index]); + }); + } + + /** + * Get all unique paths that have issues + */ + get paths(): string[] { + const pathStrings = new Set(); + for (const issue of this.issues) { + if (issue.path && issue.path.length > 0) { + pathStrings.add(issue.path.join('.')); + } + } + return Array.from(pathStrings); + } + + /** + * Convert to plain object for serialization + */ + toJSON() { + return { + name: this.name, + message: this.message, + issues: this.issues.map(issue => ({ + message: issue.message, + path: issue.path, + })), + }; + } +} diff --git a/packages/mizzle-orm/src/index.ts b/packages/mizzle-orm/src/index.ts index 9a0bb67..4849b96 100644 --- a/packages/mizzle-orm/src/index.ts +++ b/packages/mizzle-orm/src/index.ts @@ -68,6 +68,10 @@ export type { IncludeConfig, NestedIncludeConfig, WithIncluded } from './types/i export type { StandardSchemaV1, IsStandardSchema, InferSSInput, InferSSOutput } from './types/standard-schema'; export type { InferSSDocument, InferSSInsert, InferSSUpdate } from './types/standard-schema-inference'; +// Standard Schema validation errors +export { SSValidationError } from './errors/validation-error'; +export type { ValidationIssue } from './errors/validation-error'; + // Validation export { generateDocumentSchema, diff --git a/packages/mizzle-orm/src/query/__tests__/standard-schema-validation.test.ts b/packages/mizzle-orm/src/query/__tests__/standard-schema-validation.test.ts new file mode 100644 index 0000000..706872a --- /dev/null +++ b/packages/mizzle-orm/src/query/__tests__/standard-schema-validation.test.ts @@ -0,0 +1,345 @@ +/** + * Standard Schema validation integration tests + * Tests that insert/update operations validate data using Standard Schema + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { ObjectId } from 'mongodb'; +import { teardownTestDb, clearTestDb, createTestOrm } from '../../test/setup'; +import { fromStandardSchema } from '../../collection/from-standard-schema'; +import { mongoCollection } from '../../collection/collection'; +import { string, number } from '../../schema/fields'; +import { SSValidationError } from '../../errors/validation-error'; +import type { Mizzle } from '../../types/orm'; + +describe('Standard Schema Validation', () => { + // Standard Schema collection using Zod + const userSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), + age: z.number().int().positive().optional(), + role: z.enum(['user', 'admin']).default('user'), + }); + + const users = fromStandardSchema('ss_users', userSchema); + + // Regular field-builder collection for comparison + const legacyUsers = mongoCollection('legacy_users', { + email: string().email(), + name: string(), + age: number().int().positive().optional(), + }); + + let db: Mizzle<{ users: typeof users; legacyUsers: typeof legacyUsers }>; + + beforeAll(async () => { + db = await createTestOrm({ users, legacyUsers }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + describe('create() validation', () => { + it('should accept valid data', async () => { + const user = await db().users.create({ + email: 'test@example.com', + name: 'Test User', + }); + + expect(user.email).toBe('test@example.com'); + expect(user.name).toBe('Test User'); + expect(user._id).toBeInstanceOf(ObjectId); + }); + + it('should reject invalid email', async () => { + await expect( + db().users.create({ + email: 'not-an-email', + name: 'Test User', + }), + ).rejects.toThrow(SSValidationError); + }); + + it('should reject empty name', async () => { + await expect( + db().users.create({ + email: 'test@example.com', + name: '', + }), + ).rejects.toThrow(SSValidationError); + }); + + it('should reject negative age', async () => { + await expect( + db().users.create({ + email: 'test@example.com', + name: 'Test User', + age: -5, + }), + ).rejects.toThrow(SSValidationError); + }); + + it('should include issues array in SSValidationError', async () => { + try { + await db().users.create({ + email: 'invalid', + name: '', + }); + expect.fail('Should have thrown SSValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(SSValidationError); + const validationError = error as SSValidationError; + expect(validationError.issues).toBeDefined(); + expect(validationError.issues.length).toBeGreaterThan(0); + // Should have issues for both email and name + expect(validationError.issues.length).toBeGreaterThanOrEqual(2); + } + }); + + it('should include descriptive error message', async () => { + try { + await db().users.create({ + email: 'invalid', + name: 'Test', + }); + expect.fail('Should have thrown SSValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(SSValidationError); + const validationError = error as SSValidationError; + expect(validationError.message).toContain('Validation failed'); + } + }); + + it('should include path information in issues', async () => { + try { + await db().users.create({ + email: 'invalid-email', + name: 'Test', + }); + expect.fail('Should have thrown SSValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(SSValidationError); + const validationError = error as SSValidationError; + const emailIssue = validationError.issues.find( + (issue) => issue.path && issue.path.includes('email'), + ); + expect(emailIssue).toBeDefined(); + } + }); + }); + + describe('updateOne() validation', () => { + let existingUserId: ObjectId; + + beforeEach(async () => { + // Create a valid user first + const user = await db().users.create({ + email: 'existing@example.com', + name: 'Existing User', + }); + existingUserId = user._id; + }); + + it('should accept valid update data', async () => { + const updated = await db().users.updateOne( + { _id: existingUserId }, + { name: 'Updated Name' }, + ); + + expect(updated?.name).toBe('Updated Name'); + }); + + // Note: Update validation is skipped because Standard Schema validates + // complete structures, but updates are partial by design. + // This is a known limitation - create() validates, updates don't. + it('should allow partial updates without full schema validation', async () => { + // Partial updates only contain the fields being changed + // Standard Schema would reject this because email is missing + // We intentionally skip validation for updates + const updated = await db().users.updateOne( + { _id: existingUserId }, + { name: 'New Name' }, + ); + + expect(updated?.name).toBe('New Name'); + expect(updated?.email).toBe('existing@example.com'); // Original value preserved + }); + }); + + describe('updateMany() validation', () => { + beforeEach(async () => { + // Create multiple users + await db().users.create({ email: 'user1@example.com', name: 'User 1' }); + await db().users.create({ email: 'user2@example.com', name: 'User 2' }); + }); + + it('should accept valid update data', async () => { + const count = await db().users.updateMany({}, { role: 'admin' as const }); + expect(count).toBe(2); + }); + + // Note: Like updateOne, updateMany also skips full schema validation + // because updates are partial by design + it('should allow partial bulk updates', async () => { + const count = await db().users.updateMany({}, { name: 'Bulk Updated' }); + expect(count).toBe(2); + }); + }); + + describe('backward compatibility with field-builder collections', () => { + it('should skip validation for non-SS collections on create', async () => { + // Legacy collections don't use Standard Schema validation + // They may have their own validation via middlewares or hooks + const user = await db().legacyUsers.create({ + email: 'test@example.com', + name: 'Test', + }); + + expect(user.name).toBe('Test'); + expect(user._id).toBeInstanceOf(ObjectId); + }); + + it('should skip validation for non-SS collections on update', async () => { + const user = await db().legacyUsers.create({ + email: 'test@example.com', + name: 'Test', + }); + + // Update should work without Standard Schema validation + const updated = await db().legacyUsers.updateOne( + { _id: user._id }, + { name: 'Updated' }, + ); + + expect(updated?.name).toBe('Updated'); + }); + + it('should not throw SSValidationError for field-builder collections', async () => { + // Even with potentially invalid data (if it were validated), + // field-builder collections don't use SSValidationError + const user = await db().legacyUsers.create({ + email: 'test@example.com', + name: 'Test', + }); + + // This should not throw SSValidationError + const updated = await db().legacyUsers.updateOne( + { _id: user._id }, + { name: 'New Name' }, + ); + + expect(updated?.name).toBe('New Name'); + }); + }); + + describe('SSValidationError helpers', () => { + it('should provide paths getter', async () => { + try { + await db().users.create({ + email: 'invalid', + name: '', + }); + expect.fail('Should have thrown SSValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(SSValidationError); + const validationError = error as SSValidationError; + expect(validationError.paths).toBeDefined(); + expect(Array.isArray(validationError.paths)).toBe(true); + } + }); + + it('should support toJSON for serialization', async () => { + try { + await db().users.create({ + email: 'bad', + name: 'Test', + }); + expect.fail('Should have thrown SSValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(SSValidationError); + const validationError = error as SSValidationError; + const json = validationError.toJSON(); + expect(json.name).toBe('SSValidationError'); + expect(json.message).toBeDefined(); + expect(json.issues).toBeDefined(); + expect(Array.isArray(json.issues)).toBe(true); + } + }); + + it('should find issues at specific path', async () => { + try { + await db().users.create({ + email: 'not-valid', + name: 'Test', + }); + expect.fail('Should have thrown SSValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(SSValidationError); + const validationError = error as SSValidationError; + const emailIssues = validationError.getIssuesAtPath(['email']); + expect(emailIssues.length).toBeGreaterThan(0); + } + }); + }); + + describe('validation with optional fields', () => { + it('should accept missing optional fields', async () => { + const user = await db().users.create({ + email: 'test@example.com', + name: 'Test User', + // age is optional, not provided + }); + + expect(user.age).toBeUndefined(); + }); + + it('should validate optional fields when provided', async () => { + // Providing an invalid value for optional field should fail + await expect( + db().users.create({ + email: 'test@example.com', + name: 'Test User', + age: -5, // invalid: must be positive + }), + ).rejects.toThrow(SSValidationError); + }); + + it('should accept valid optional fields', async () => { + const user = await db().users.create({ + email: 'test@example.com', + name: 'Test User', + age: 25, + }); + + expect(user.age).toBe(25); + }); + }); + + describe('validation with enums', () => { + it('should accept valid enum values', async () => { + const user = await db().users.create({ + email: 'admin@example.com', + name: 'Admin', + role: 'admin' as const, + }); + + expect(user.role).toBe('admin'); + }); + + it('should reject invalid enum values', async () => { + await expect( + db().users.create({ + email: 'test@example.com', + name: 'Test', + role: 'superuser' as any, // invalid enum value + }), + ).rejects.toThrow(SSValidationError); + }); + }); +}); diff --git a/packages/mizzle-orm/src/query/collection-facade.ts b/packages/mizzle-orm/src/query/collection-facade.ts index 4a227f3..579a57f 100644 --- a/packages/mizzle-orm/src/query/collection-facade.ts +++ b/packages/mizzle-orm/src/query/collection-facade.ts @@ -8,6 +8,9 @@ import type { OrmContext, QueryOptions } from '../types/orm'; import type { SchemaDefinition } from '../types/field'; import type { Filter } from '../types/inference'; import type { Middleware, MiddlewareContext, Operation } from '../types/middleware'; +import type { SSCollectionDefinition } from '../collection/from-standard-schema'; +import { isSSCollectionDefinition } from '../collection/from-standard-schema'; +import { SSValidationError } from '../errors/validation-error'; import { generatePublicId } from '../utils/public-id'; import { RelationHelper } from './relations'; import { RelationPipelineBuilder } from './relation-pipeline-builder'; @@ -75,6 +78,43 @@ export class CollectionFacade< this.collectionMiddlewares = options?.collectionMiddlewares || []; } + /** + * Validate data against Standard Schema if this collection uses one + * Does nothing for regular field-builder collections (backward compatible) + * + * @param data - The data to validate + * @param options - Validation options + * @param options.partial - If true, skip validation (for partial updates) + * @throws SSValidationError if validation fails + */ + private async validateWithStandardSchema( + data: unknown, + options: { partial?: boolean } = {}, + ): Promise { + // Skip validation if not a Standard Schema collection + if (!isSSCollectionDefinition(this.collectionDef)) { + return; + } + + // Skip validation for partial data (updates) since Standard Schema + // validates the entire structure but updates only contain changed fields + // Note: Future improvement could validate individual field types + if (options.partial) { + return; + } + + const ssCollectionDef = this.collectionDef as unknown as SSCollectionDefinition; + const schema = ssCollectionDef._schema; + + // Call Standard Schema's validate method + const result = await schema['~standard'].validate(data); + + // Check for validation issues + if (result.issues && result.issues.length > 0) { + throw new SSValidationError(result.issues); + } + } + /** * Execute an operation with middleware chain * @template TResult - The return type of the operation (preserved through the chain) @@ -292,28 +332,34 @@ export class CollectionFacade< return this.executeWithMiddlewares( 'create', async () => { + // Validate against Standard Schema if applicable (throws SSValidationError on failure) + await this.validateWithStandardSchema(data); + // Apply defaults and auto-generated fields const doc = await this.applyDefaults(data as any); - // Run before hooks + // Run before hooks (SS collections don't have hooks) let finalDoc = doc; - if (this.collectionDef._meta.hooks.beforeInsert) { - finalDoc = await this.collectionDef._meta.hooks.beforeInsert(this.ctx, finalDoc); + const hooks = (this.collectionDef._meta as any).hooks; + if (hooks?.beforeInsert) { + finalDoc = await hooks.beforeInsert(this.ctx, finalDoc); } - // Check policies - if (this.collectionDef._meta.policies.canInsert) { - const allowed = await this.collectionDef._meta.policies.canInsert(this.ctx, finalDoc); + // Check policies (SS collections don't have policies) + const policies = (this.collectionDef._meta as any).policies; + if (policies?.canInsert) { + const allowed = await policies.canInsert(this.ctx, finalDoc); if (!allowed) { throw new Error('Insert not allowed by policy'); } } - // Validate references - await this.relationHelper.validateReferences(finalDoc as any); - - // Process forward embeds (fetch and embed referenced data) - finalDoc = (await this.relationHelper.processForwardEmbeds(finalDoc as any)) as any; + // Validate references (skip for SS collections - they don't have relations yet) + if (!isSSCollectionDefinition(this.collectionDef)) { + await this.relationHelper.validateReferences(finalDoc as any); + // Process forward embeds (fetch and embed referenced data) + finalDoc = (await this.relationHelper.processForwardEmbeds(finalDoc as any)) as any; + } // Insert const result = await this.collection.insertOne(finalDoc as any, { @@ -326,8 +372,8 @@ export class CollectionFacade< } as unknown as TDoc; // Run after hooks - if (this.collectionDef._meta.hooks.afterInsert) { - await this.collectionDef._meta.hooks.afterInsert(this.ctx, inserted); + if (hooks?.afterInsert) { + await hooks.afterInsert(this.ctx, inserted); } return inserted; @@ -367,6 +413,10 @@ export class CollectionFacade< * Internal update logic (shared by updateOne and updateById) */ private async updateOneInternal(filter: Filter, data: TUpdate): Promise { + // Note: Updates are partial and won't match the full schema + // Skip Standard Schema validation for updates + await this.validateWithStandardSchema(data, { partial: true }); + const finalFilter = this.applyPolicies(filter); // Get old document for hooks and policies @@ -380,19 +430,21 @@ export class CollectionFacade< // Apply update timestamp const updateData = this.applyUpdateTimestamps(data as any); - // Run before hooks + // Run before hooks (SS collections don't have hooks) let finalUpdate = updateData; - if (this.collectionDef._meta.hooks.beforeUpdate) { - finalUpdate = await this.collectionDef._meta.hooks.beforeUpdate( + const hooks = (this.collectionDef._meta as any).hooks; + if (hooks?.beforeUpdate) { + finalUpdate = await hooks.beforeUpdate( this.ctx, oldDoc as any, updateData, ); } - // Check policies - if (this.collectionDef._meta.policies.canUpdate) { - const allowed = await this.collectionDef._meta.policies.canUpdate( + // Check policies (SS collections don't have policies) + const policies = (this.collectionDef._meta as any).policies; + if (policies?.canUpdate) { + const allowed = await policies.canUpdate( this.ctx, oldDoc as any, finalUpdate, @@ -402,11 +454,11 @@ export class CollectionFacade< } } - // Validate references - await this.relationHelper.validateReferences(finalUpdate as any); - - // Process forward embeds (fetch and embed referenced data) - finalUpdate = (await this.relationHelper.processForwardEmbeds(finalUpdate as any)) as any; + // Validate references and process embeds (skip for SS collections) + if (!isSSCollectionDefinition(this.collectionDef)) { + await this.relationHelper.validateReferences(finalUpdate as any); + finalUpdate = (await this.relationHelper.processForwardEmbeds(finalUpdate as any)) as any; + } // Update const result = await this.collection.findOneAndUpdate( @@ -423,12 +475,14 @@ export class CollectionFacade< } // Run after hooks - if (this.collectionDef._meta.hooks.afterUpdate) { - await this.collectionDef._meta.hooks.afterUpdate(this.ctx, oldDoc as any, result as any); + if (hooks?.afterUpdate) { + await hooks.afterUpdate(this.ctx, oldDoc as any, result as any); } - // Propagate reverse embeds if this collection is a source for any embeds - await this.propagateReverseEmbeds(result as TDoc, finalUpdate); + // Propagate reverse embeds if this collection is a source for any embeds (skip for SS collections) + if (!isSSCollectionDefinition(this.collectionDef)) { + await this.propagateReverseEmbeds(result as TDoc, finalUpdate); + } return result as TDoc; } @@ -440,6 +494,10 @@ export class CollectionFacade< return this.executeWithMiddlewares( 'updateMany', async () => { + // Note: Updates are partial and won't match the full schema + // Skip Standard Schema validation for updates + await this.validateWithStandardSchema(data, { partial: true }); + const finalFilter = this.applyPolicies(filter); const updateData = this.applyUpdateTimestamps(data as any); @@ -637,10 +695,11 @@ export class CollectionFacade< * Apply policy filters to a query filter */ private applyPolicies(filter: Filter): Filter { - const policies = this.collectionDef._meta.policies; + // SS collections don't have policies + const policies = (this.collectionDef._meta as any).policies; // Apply read filter - if (policies.readFilter) { + if (policies?.readFilter) { const policyFilter = policies.readFilter(this.ctx); return { $and: [filter, policyFilter], @@ -652,8 +711,15 @@ export class CollectionFacade< /** * Apply default values and generate auto-fields + * Note: For Standard Schema collections, defaults are handled by the schema itself */ private async applyDefaults(data: Record): Promise> { + // Standard Schema collections handle defaults through the schema (e.g., Zod .default()) + // Skip field-builder-specific default application + if (isSSCollectionDefinition(this.collectionDef)) { + return { ...data }; + } + const schema = this.collectionDef._schema; const result = { ...data }; @@ -691,8 +757,15 @@ export class CollectionFacade< /** * Apply update timestamps (onUpdateNow fields) + * Note: For Standard Schema collections, timestamps must be handled differently (not supported yet) */ private applyUpdateTimestamps(data: Record): Record { + // Standard Schema collections don't use field builders with onUpdateNow + // Timestamp support for SS collections would need a different mechanism + if (isSSCollectionDefinition(this.collectionDef)) { + return { ...data }; + } + const schema = this.collectionDef._schema; const result = { ...data }; From ccd96ed491193f751dfa3169f384ad8192ed0129 Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 21:49:42 +0000 Subject: [PATCH 06/12] feat: US-004 - Integrate Standard Schema validation into insert and update operations - Fixed update validation: now validates fields present in update data - For partial updates, filters issues to only report errors for fields being updated - Allows partial updates (missing required fields) while catching invalid values - updateOne/updateMany now properly reject invalid field values - Added tests for: - updateOne rejecting invalid name, age, enum values - updateMany rejecting invalid values in bulk updates - SSValidationError includes correct issues for invalid updates - All 312 tests pass, typecheck passes --- .../standard-schema-validation.test.ts | 73 ++++++++++++++++--- .../mizzle-orm/src/query/collection-facade.ts | 35 +++++++-- progress.txt | 35 ++++++++- 3 files changed, 125 insertions(+), 18 deletions(-) diff --git a/packages/mizzle-orm/src/query/__tests__/standard-schema-validation.test.ts b/packages/mizzle-orm/src/query/__tests__/standard-schema-validation.test.ts index 706872a..4120e7a 100644 --- a/packages/mizzle-orm/src/query/__tests__/standard-schema-validation.test.ts +++ b/packages/mizzle-orm/src/query/__tests__/standard-schema-validation.test.ts @@ -155,13 +155,10 @@ describe('Standard Schema Validation', () => { expect(updated?.name).toBe('Updated Name'); }); - // Note: Update validation is skipped because Standard Schema validates - // complete structures, but updates are partial by design. - // This is a known limitation - create() validates, updates don't. - it('should allow partial updates without full schema validation', async () => { + it('should allow partial updates without requiring all fields', async () => { // Partial updates only contain the fields being changed - // Standard Schema would reject this because email is missing - // We intentionally skip validation for updates + // Validation should NOT fail just because email is missing - + // we only validate the fields present in the update const updated = await db().users.updateOne( { _id: existingUserId }, { name: 'New Name' }, @@ -170,6 +167,54 @@ describe('Standard Schema Validation', () => { expect(updated?.name).toBe('New Name'); expect(updated?.email).toBe('existing@example.com'); // Original value preserved }); + + it('should reject invalid update values for name', async () => { + // Even in partial updates, the provided fields should be validated + await expect( + db().users.updateOne( + { _id: existingUserId }, + { name: '' }, // Invalid: min length is 1 + ), + ).rejects.toThrow(SSValidationError); + }); + + it('should reject invalid update values for age', async () => { + await expect( + db().users.updateOne( + { _id: existingUserId }, + { age: -10 }, // Invalid: must be positive + ), + ).rejects.toThrow(SSValidationError); + }); + + it('should reject invalid enum values in update', async () => { + await expect( + db().users.updateOne( + { _id: existingUserId }, + { role: 'superadmin' as any }, // Invalid: not in enum + ), + ).rejects.toThrow(SSValidationError); + }); + + it('should include issues array for invalid updates', async () => { + try { + await db().users.updateOne( + { _id: existingUserId }, + { name: '' }, + ); + expect.fail('Should have thrown SSValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(SSValidationError); + const validationError = error as SSValidationError; + expect(validationError.issues).toBeDefined(); + expect(validationError.issues.length).toBeGreaterThan(0); + // Issue should be for the 'name' field + const nameIssue = validationError.issues.find( + (issue) => issue.path && issue.path.includes('name'), + ); + expect(nameIssue).toBeDefined(); + } + }); }); describe('updateMany() validation', () => { @@ -184,12 +229,22 @@ describe('Standard Schema Validation', () => { expect(count).toBe(2); }); - // Note: Like updateOne, updateMany also skips full schema validation - // because updates are partial by design - it('should allow partial bulk updates', async () => { + it('should allow partial bulk updates without requiring all fields', async () => { const count = await db().users.updateMany({}, { name: 'Bulk Updated' }); expect(count).toBe(2); }); + + it('should reject invalid values in bulk update', async () => { + await expect( + db().users.updateMany({}, { name: '' }), // Invalid: empty string + ).rejects.toThrow(SSValidationError); + }); + + it('should reject invalid age in bulk update', async () => { + await expect( + db().users.updateMany({}, { age: -5 }), // Invalid: must be positive + ).rejects.toThrow(SSValidationError); + }); }); describe('backward compatibility with field-builder collections', () => { diff --git a/packages/mizzle-orm/src/query/collection-facade.ts b/packages/mizzle-orm/src/query/collection-facade.ts index 579a57f..1350e53 100644 --- a/packages/mizzle-orm/src/query/collection-facade.ts +++ b/packages/mizzle-orm/src/query/collection-facade.ts @@ -10,7 +10,7 @@ import type { Filter } from '../types/inference'; import type { Middleware, MiddlewareContext, Operation } from '../types/middleware'; import type { SSCollectionDefinition } from '../collection/from-standard-schema'; import { isSSCollectionDefinition } from '../collection/from-standard-schema'; -import { SSValidationError } from '../errors/validation-error'; +import { SSValidationError, type ValidationIssue } from '../errors/validation-error'; import { generatePublicId } from '../utils/public-id'; import { RelationHelper } from './relations'; import { RelationPipelineBuilder } from './relation-pipeline-builder'; @@ -96,13 +96,6 @@ export class CollectionFacade< return; } - // Skip validation for partial data (updates) since Standard Schema - // validates the entire structure but updates only contain changed fields - // Note: Future improvement could validate individual field types - if (options.partial) { - return; - } - const ssCollectionDef = this.collectionDef as unknown as SSCollectionDefinition; const schema = ssCollectionDef._schema; @@ -111,6 +104,32 @@ export class CollectionFacade< // Check for validation issues if (result.issues && result.issues.length > 0) { + if (options.partial) { + // For partial validation (updates), only report issues for fields present in the data. + // This allows updates like { name: 'New Name' } without failing due to missing email. + // But if name itself is invalid, we catch it. + const updateKeys = typeof data === 'object' && data !== null + ? new Set(Object.keys(data as object)) + : new Set(); + + const relevantIssues = result.issues.filter((issue: ValidationIssue) => { + // If no path, it's a top-level structural issue - skip for partial validation + if (!issue.path || issue.path.length === 0) { + return false; + } + + // Check if the first path segment is a key in our update data + const firstSegment = issue.path[0]; + return updateKeys.has(String(firstSegment)); + }); + + if (relevantIssues.length > 0) { + throw new SSValidationError(relevantIssues); + } + // No relevant issues for the fields we're updating - validation passed + return; + } + throw new SSValidationError(result.issues); } } diff --git a/progress.txt b/progress.txt index 6cd553e..39efa78 100644 --- a/progress.txt +++ b/progress.txt @@ -6,7 +6,7 @@ Started: 2026-02-11T21:23:00Z ## Codebase Patterns - Monorepo with turbo, main package at `packages/mizzle-orm` - Build: `pnpm build` → tsup (generates CJS/ESM/DTS) -- Test: `pnpm test` → vitest (230+ tests) +- Test: `pnpm test` → vitest (230+ tests, now 306) - Typecheck: `pnpm typecheck` → tsc --noEmit - Type files live in `src/types/`, tests in `src//__tests__/` - vitest expectTypeOf available for type-level assertions @@ -14,6 +14,10 @@ Started: 2026-02-11T21:23:00Z - Uses node:test style expect/describe/it from vitest - Collection definitions use phantom types for `$inferDocument`, `$inferInsert`, `$inferUpdate` - assigned `null as any`, never accessed at runtime - Use `_brand` literal type to distinguish collection types (e.g., `'CollectionDefinition'` vs `'SSCollectionDefinition'`) +- CollectionFacade methods need null-safe access for SS collections (no hooks/policies/relations) +- Error classes: use `src/errors/` directory for custom error types +- Standard Schema validation via `schema['~standard'].validate(data)` returns `{value}` or `{issues}` +- Partial validation pattern: run full validate(), filter issues to only those with paths in update keys --- @@ -79,3 +83,32 @@ Started: 2026-02-11T21:23:00Z - Edge cases: empty schema, deep nesting, unions, nullable, records - **Learnings:** Collection definitions use phantom types (`null as any`) for type inference helpers - they're never accessed at runtime. The `_brand` literal type pattern helps distinguish collection types. --- + +## 2026-02-11 21:43 - US-004: Integrate Standard Schema validation into insert and update operations +- Created `src/errors/validation-error.ts` with: + - `SSValidationError` class extending Error + - Contains `issues` array from Standard Schema validation result + - Helper methods: `getIssuesAtPath()`, `paths` getter, `toJSON()` + - Proper stack trace preservation with Error.captureStackTrace +- Modified `src/query/collection-facade.ts` to validate using Standard Schema: + - Added `validateWithStandardSchema()` private method + - `create()` validates full data before inserting (throws SSValidationError on failure) + - `updateOne()`/`updateMany()` validate partial data: + - Run full schema validation, then filter issues to only those for fields present in update + - Allows partial updates (missing required fields) while rejecting invalid field values + - Added null-safe access for SS collections: hooks, policies, applyDefaults, applyUpdateTimestamps + - Skip relation helpers for SS collections (they don't have relations yet) +- Exported `SSValidationError` and `ValidationIssue` from `src/index.ts` +- Files changed: src/errors/validation-error.ts, src/query/collection-facade.ts, src/index.ts, src/query/__tests__/standard-schema-validation.test.ts +- Tests: 28 tests covering: + - create() validates against Standard Schema + - create() rejects invalid email, empty name, negative age + - SSValidationError includes issues array, descriptive message, path info + - updateOne() allows partial updates (missing required fields OK) + - updateOne() rejects invalid values for name, age, enum + - updateMany() rejects invalid values in bulk updates + - Backward compatibility: non-SS collections skip validation + - SSValidationError helpers: paths getter, toJSON(), getIssuesAtPath() + - Optional fields, enum validation +- **Learnings:** For partial validation, run full validation then filter issues by paths present in update data. This allows partial updates while catching invalid field values. Import `ValidationIssue` type from validation-error.ts for type safety in filter callbacks. +--- From 4823c3aeb5b6549bc0a814badf4c098106fb36c8 Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 21:55:46 +0000 Subject: [PATCH 07/12] feat: US-005 - Add publicId support for Standard Schema collections --- .../standard-schema-public-id.test.ts | 400 ++++++++++++++++++ .../src/collection/from-standard-schema.ts | 45 +- packages/mizzle-orm/src/index.ts | 1 + .../mizzle-orm/src/query/collection-facade.ts | 47 +- 4 files changed, 477 insertions(+), 16 deletions(-) create mode 100644 packages/mizzle-orm/src/collection/__tests__/standard-schema-public-id.test.ts diff --git a/packages/mizzle-orm/src/collection/__tests__/standard-schema-public-id.test.ts b/packages/mizzle-orm/src/collection/__tests__/standard-schema-public-id.test.ts new file mode 100644 index 0000000..6fd8dee --- /dev/null +++ b/packages/mizzle-orm/src/collection/__tests__/standard-schema-public-id.test.ts @@ -0,0 +1,400 @@ +/** + * Tests for Standard Schema publicId support + * Story US-005: Add publicId support for Standard Schema collections + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { MongoClient, Db } from 'mongodb'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { fromStandardSchema, type SSCollectionDefinition } from '../from-standard-schema'; +import { CollectionFacade } from '../../query/collection-facade'; +import type { OrmContext } from '../../types/orm'; + +describe('Standard Schema publicId support', () => { + let mongod: MongoMemoryServer; + let client: MongoClient; + let db: Db; + let ctx: OrmContext; + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + const uri = mongod.getUri(); + client = await MongoClient.connect(uri); + db = client.db('test'); + }); + + afterAll(async () => { + await client.close(); + await mongod.stop(); + }); + + beforeEach(async () => { + // Clean up collections + const collections = await db.listCollections().toArray(); + for (const col of collections) { + await db.dropCollection(col.name); + } + ctx = { session: undefined }; + }); + + describe('fromStandardSchema publicId option', () => { + it('should accept publicId option with prefix string', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: 'user', + }); + + expect(users._meta.options.publicId).toBe('user'); + expect(users._meta.publicIdConfig).toEqual({ + prefix: 'user', + field: 'id', + }); + }); + + it('should accept publicId option with { prefix } object', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: { prefix: 'usr' }, + }); + + expect(users._meta.publicIdConfig).toEqual({ + prefix: 'usr', + field: 'id', // Default field + }); + }); + + it('should accept publicId option with { prefix, field } object', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: { prefix: 'usr', field: 'publicId' }, + }); + + expect(users._meta.publicIdConfig).toEqual({ + prefix: 'usr', + field: 'publicId', + }); + }); + + it('should default field to "id" if not specified', () => { + const schema = z.object({ name: z.string() }); + + const items1 = fromStandardSchema('items', schema, { publicId: 'item' }); + const items2 = fromStandardSchema('items', schema, { publicId: { prefix: 'item' } }); + + expect(items1._meta.publicIdConfig?.field).toBe('id'); + expect(items2._meta.publicIdConfig?.field).toBe('id'); + }); + + it('should have undefined publicIdConfig when no publicId option', () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema); + + expect(items._meta.publicIdConfig).toBeUndefined(); + }); + }); + + describe('insert with publicId auto-generation', () => { + it('should auto-generate a prefixed public ID on insert', async () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: 'user', + }); + + const facade = new CollectionFacade(db, users as any, ctx); + const doc = await facade.create({ + email: 'test@example.com', + name: 'Test User', + }); + + expect(doc.id).toBeDefined(); + expect(typeof doc.id).toBe('string'); + expect(doc.id.startsWith('user_')).toBe(true); + expect(doc.id.length).toBeGreaterThan(10); // user_ + random chars + }); + + it('should use custom field name for public ID', async () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: { prefix: 'usr', field: 'publicId' }, + }); + + const facade = new CollectionFacade(db, users as any, ctx); + const doc = await facade.create({ + email: 'test@example.com', + name: 'Test User', + }); + + expect(doc.publicId).toBeDefined(); + expect(doc.publicId.startsWith('usr_')).toBe(true); + expect(doc.id).toBeUndefined(); // Default field not used + }); + + it('should not overwrite provided public ID', async () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: 'user', + }); + + const facade = new CollectionFacade(db, users as any, ctx); + const doc = await facade.create({ + email: 'test@example.com', + name: 'Test User', + id: 'user_custom123', + } as any); + + expect(doc.id).toBe('user_custom123'); + }); + + it('should generate unique IDs for multiple inserts', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { publicId: 'item' }); + const facade = new CollectionFacade(db, items as any, ctx); + + const doc1 = await facade.create({ name: 'Item 1' }); + const doc2 = await facade.create({ name: 'Item 2' }); + const doc3 = await facade.create({ name: 'Item 3' }); + + expect(doc1.id).not.toBe(doc2.id); + expect(doc2.id).not.toBe(doc3.id); + expect(doc1.id).not.toBe(doc3.id); + + // All should have the same prefix + expect(doc1.id.startsWith('item_')).toBe(true); + expect(doc2.id.startsWith('item_')).toBe(true); + expect(doc3.id.startsWith('item_')).toBe(true); + }); + }); + + describe('findById with public ID', () => { + it('should find document by public ID', async () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: 'user', + }); + + const facade = new CollectionFacade(db, users as any, ctx); + const created = await facade.create({ + email: 'test@example.com', + name: 'Test User', + }); + + const found = await facade.findById(created.id); + + expect(found).not.toBeNull(); + expect(found._id).toEqual(created._id); + expect(found.email).toBe('test@example.com'); + expect(found.name).toBe('Test User'); + }); + + it('should find by public ID with custom field name', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: { prefix: 'itm', field: 'publicId' }, + }); + + const facade = new CollectionFacade(db, items as any, ctx); + const created = await facade.create({ name: 'Test Item' }); + + const found = await facade.findById(created.publicId); + + expect(found).not.toBeNull(); + expect(found._id).toEqual(created._id); + expect(found.name).toBe('Test Item'); + }); + + it('should still support findById with ObjectId', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: 'item', + }); + + const facade = new CollectionFacade(db, items as any, ctx); + const created = await facade.create({ name: 'Test Item' }); + + // Find by ObjectId + const found = await facade.findById(created._id); + + expect(found).not.toBeNull(); + expect(found.name).toBe('Test Item'); + }); + + it('should return null for non-existent public ID', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: 'item', + }); + + const facade = new CollectionFacade(db, items as any, ctx); + await facade.create({ name: 'Test Item' }); + + const found = await facade.findById('item_nonexistent123'); + + expect(found).toBeNull(); + }); + }); + + describe('updateById and deleteById with public ID', () => { + it('should update document by public ID', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: 'item', + }); + + const facade = new CollectionFacade(db, items as any, ctx); + const created = await facade.create({ name: 'Original' }); + + const updated = await facade.updateById(created.id, { name: 'Updated' }); + + expect(updated).not.toBeNull(); + expect(updated!.name).toBe('Updated'); + expect(updated!.id).toBe(created.id); + }); + + it('should delete document by public ID', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: 'item', + }); + + const facade = new CollectionFacade(db, items as any, ctx); + const created = await facade.create({ name: 'To Delete' }); + + const deleted = await facade.deleteById(created.id); + expect(deleted).toBe(true); + + const found = await facade.findById(created.id); + expect(found).toBeNull(); + }); + }); + + describe('integration with other SS collection features', () => { + it('should work with Standard Schema validation', async () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string().min(2), + }); + + const users = fromStandardSchema('users', userSchema, { + publicId: 'user', + }); + + const facade = new CollectionFacade(db, users as any, ctx); + + // Valid insert should work and have publicId + const doc = await facade.create({ + email: 'test@example.com', + name: 'Valid Name', + }); + expect(doc.id.startsWith('user_')).toBe(true); + + // Invalid insert should still fail validation + await expect( + facade.create({ + email: 'invalid-email', + name: 'Valid Name', + }) + ).rejects.toThrow(); + }); + + it('should work with combined options (softDelete, timestamps)', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: 'item', + softDelete: true, + timestamps: true, + }); + + expect(items._meta.publicIdConfig).toEqual({ + prefix: 'item', + field: 'id', + }); + expect(items._meta.options.softDelete).toBe(true); + expect(items._meta.options.timestamps).toBe(true); + }); + + it('should store public ID in the database correctly', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: 'item', + }); + + const facade = new CollectionFacade(db, items as any, ctx); + const created = await facade.create({ name: 'Test' }); + + // Verify in raw collection + const raw = await db.collection('items').findOne({ _id: created._id }); + expect(raw).not.toBeNull(); + expect(raw!.id).toBe(created.id); + expect(raw!.id.startsWith('item_')).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should treat empty string prefix as no publicId', () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: '', + }); + + // Empty string is falsy, so no publicIdConfig is created + expect(items._meta.publicIdConfig).toBeUndefined(); + }); + + it('should handle special characters in prefix', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: 'my-item', + }); + + const facade = new CollectionFacade(db, items as any, ctx); + const doc = await facade.create({ name: 'Test' }); + + expect(doc.id.startsWith('my-item_')).toBe(true); + }); + + it('should find by public ID that contains underscore in prefix', async () => { + const schema = z.object({ name: z.string() }); + const items = fromStandardSchema('items', schema, { + publicId: 'org_item', + }); + + const facade = new CollectionFacade(db, items as any, ctx); + const created = await facade.create({ name: 'Test' }); + + expect(created.id.startsWith('org_item_')).toBe(true); + + const found = await facade.findById(created.id); + expect(found).not.toBeNull(); + expect(found!.name).toBe('Test'); + }); + }); +}); diff --git a/packages/mizzle-orm/src/collection/from-standard-schema.ts b/packages/mizzle-orm/src/collection/from-standard-schema.ts index f6f6467..fe9c3e6 100644 --- a/packages/mizzle-orm/src/collection/from-standard-schema.ts +++ b/packages/mizzle-orm/src/collection/from-standard-schema.ts @@ -7,16 +7,33 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Middleware } from '../types/middleware'; import type { InferSSDocument, InferSSInsert, InferSSUpdate } from '../types/standard-schema-inference'; +/** + * Normalized public ID configuration + */ +export interface SSPublicIdConfig { + /** + * Prefix for the public ID (e.g., 'user' → 'user_abc123') + */ + prefix: string; + + /** + * Field name to store the public ID (default: 'id') + */ + field: string; +} + /** * Options for Standard Schema collections * Simplified options compared to field-builder collections since the schema handles validation */ export interface SSCollectionOptions { /** - * Public ID prefix for human-readable IDs - * @example 'user' → 'user_abc123' + * Public ID configuration + * Can be a prefix string (uses 'id' as field name) or full config object + * @example 'user' → prefix 'user', field 'id' → 'user_abc123' + * @example { prefix: 'user', field: 'publicId' } → stores in 'publicId' field */ - publicId?: string; + publicId?: string | { prefix: string; field?: string }; /** * Enable soft delete (adds deletedAt field) @@ -53,6 +70,11 @@ export interface SSCollectionMeta> { */ options: SSCollectionOptions; + /** + * Normalized public ID configuration (if publicId option was specified) + */ + publicIdConfig?: SSPublicIdConfig; + /** * Middlewares applied to this collection */ @@ -193,11 +215,28 @@ export function fromStandardSchema>( ); } + // Normalize publicId config + let publicIdConfig: SSPublicIdConfig | undefined; + if (options.publicId) { + if (typeof options.publicId === 'string') { + publicIdConfig = { + prefix: options.publicId, + field: 'id', // Default field name + }; + } else { + publicIdConfig = { + prefix: options.publicId.prefix, + field: options.publicId.field || 'id', // Default field name if not specified + }; + } + } + // Build metadata const meta: SSCollectionMeta = { name, schema, options, + publicIdConfig, middlewares: options.middlewares || [], }; diff --git a/packages/mizzle-orm/src/index.ts b/packages/mizzle-orm/src/index.ts index 4849b96..628db1d 100644 --- a/packages/mizzle-orm/src/index.ts +++ b/packages/mizzle-orm/src/index.ts @@ -16,6 +16,7 @@ export type { SSCollectionDefinition, SSCollectionOptions, SSCollectionMeta, + SSPublicIdConfig, ExtractSSDocument, ExtractSSInsert, ExtractSSUpdate, diff --git a/packages/mizzle-orm/src/query/collection-facade.ts b/packages/mizzle-orm/src/query/collection-facade.ts index 1350e53..d29061f 100644 --- a/packages/mizzle-orm/src/query/collection-facade.ts +++ b/packages/mizzle-orm/src/query/collection-facade.ts @@ -571,14 +571,16 @@ export class CollectionFacade< return false; } - // Run before hooks - if (this.collectionDef._meta.hooks.beforeDelete) { - await this.collectionDef._meta.hooks.beforeDelete(this.ctx, doc as any); + // Run before hooks (SS collections don't have hooks) + const hooks = (this.collectionDef._meta as any).hooks; + if (hooks?.beforeDelete) { + await hooks.beforeDelete(this.ctx, doc as any); } - // Check policies - if (this.collectionDef._meta.policies.canDelete) { - const allowed = await this.collectionDef._meta.policies.canDelete(this.ctx, doc as any); + // Check policies (SS collections don't have policies) + const policies = (this.collectionDef._meta as any).policies; + if (policies?.canDelete) { + const allowed = await policies.canDelete(this.ctx, doc as any); if (!allowed) { throw new Error('Delete not allowed by policy'); } @@ -589,13 +591,13 @@ export class CollectionFacade< session: this.ctx.session, }); - // Run after hooks - if (result.deletedCount > 0 && this.collectionDef._meta.hooks.afterDelete) { - await this.collectionDef._meta.hooks.afterDelete(this.ctx, doc as any); + // Run after hooks (SS collections don't have hooks) + if (result.deletedCount > 0 && hooks?.afterDelete) { + await hooks.afterDelete(this.ctx, doc as any); } - // Handle delete cascades - if (result.deletedCount > 0) { + // Handle delete cascades (skip for SS collections - they don't have relations yet) + if (result.deletedCount > 0 && !isSSCollectionDefinition(this.collectionDef)) { await this.handleDeleteCascades(doc as TDoc); } @@ -731,12 +733,24 @@ export class CollectionFacade< /** * Apply default values and generate auto-fields * Note: For Standard Schema collections, defaults are handled by the schema itself + * Exception: publicId is auto-generated by the ORM for SS collections */ private async applyDefaults(data: Record): Promise> { // Standard Schema collections handle defaults through the schema (e.g., Zod .default()) - // Skip field-builder-specific default application + // Exception: publicId is generated by the ORM since it requires the generatePublicId utility if (isSSCollectionDefinition(this.collectionDef)) { - return { ...data }; + const result = { ...data }; + const ssMeta = (this.collectionDef as unknown as SSCollectionDefinition)._meta; + + // Generate publicId if configured and not already provided + if (ssMeta.publicIdConfig) { + const { prefix, field } = ssMeta.publicIdConfig; + if (!(field in result) || result[field] === undefined) { + result[field] = generatePublicId(prefix); + } + } + + return result; } const schema = this.collectionDef._schema; @@ -803,6 +817,13 @@ export class CollectionFacade< * Get the public ID field name if configured */ private getPublicIdField(): string | null { + // Check if this is a Standard Schema collection with publicId config + if (isSSCollectionDefinition(this.collectionDef)) { + const ssMeta = (this.collectionDef as unknown as SSCollectionDefinition)._meta; + return ssMeta.publicIdConfig?.field ?? null; + } + + // For regular field-builder collections, check schema const schema = this.collectionDef._schema; for (const [fieldName, fieldBuilder] of Object.entries(schema)) { if (fieldBuilder._config.isPublicId) { From 1295a37051a8050cbdb924ab4346a4ccfcfb07de Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 22:01:33 +0000 Subject: [PATCH 08/12] feat: US-006 - Add softDelete support for Standard Schema collections - Extended SSCollectionOptions.softDelete to accept boolean | { field?: string } - Added SSSoftDeleteConfig interface for normalized config - Added softDeleteConfig to SSCollectionMeta (normalized at factory time) - Updated fromStandardSchema to normalize softDelete options (default field: 'deletedAt') - Updated getSoftDeleteField() in CollectionFacade to check SS collections - Added applySoftDeleteFilter() to exclude soft-deleted documents from queries - Updated findById, findOne, findMany, count to apply soft delete filter - softDelete(id) sets the deletedAt timestamp instead of deleting - restore(id) clears the deletedAt timestamp - Default queries exclude documents where soft delete field is set - Exported SSSoftDeleteConfig from index.ts Tests: 22 tests in src/collection/__tests__/standard-schema-soft-delete.test.ts covering: - fromStandardSchema options (softDelete: true, softDelete: { field }) - softDelete() and restore() operations - Query filtering (findMany, findOne, findById, count exclude soft-deleted) - Custom soft delete field name support - Integration with publicId - Hard delete vs soft delete behavior --- .../standard-schema-soft-delete.test.ts | 426 ++++++++++++++++++ .../src/collection/from-standard-schema.ts | 37 +- packages/mizzle-orm/src/index.ts | 1 + .../mizzle-orm/src/query/collection-facade.ts | 49 +- progress.txt | 30 +- 5 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 packages/mizzle-orm/src/collection/__tests__/standard-schema-soft-delete.test.ts diff --git a/packages/mizzle-orm/src/collection/__tests__/standard-schema-soft-delete.test.ts b/packages/mizzle-orm/src/collection/__tests__/standard-schema-soft-delete.test.ts new file mode 100644 index 0000000..0cec2a9 --- /dev/null +++ b/packages/mizzle-orm/src/collection/__tests__/standard-schema-soft-delete.test.ts @@ -0,0 +1,426 @@ +/** + * Tests for softDelete support in Standard Schema collections + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { teardownTestDb, clearTestDb, createTestOrm } from '../../test/setup'; +import { fromStandardSchema } from '../from-standard-schema'; + +describe('Standard Schema Soft Delete', () => { + describe('fromStandardSchema options', () => { + it('should accept softDelete: true option', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_soft_delete_users', userSchema, { + softDelete: true, + }); + + expect(users._meta.options.softDelete).toBe(true); + expect(users._meta.softDeleteConfig).toEqual({ + field: 'deletedAt', + }); + }); + + it('should accept softDelete: { field: "customField" } option', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_soft_delete_users2', userSchema, { + softDelete: { field: 'removedAt' }, + }); + + expect(users._meta.options.softDelete).toEqual({ field: 'removedAt' }); + expect(users._meta.softDeleteConfig).toEqual({ + field: 'removedAt', + }); + }); + + it('should use default field name "deletedAt" when field not specified in object form', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_soft_delete_users3', userSchema, { + softDelete: { field: undefined }, + }); + + expect(users._meta.softDeleteConfig).toEqual({ + field: 'deletedAt', + }); + }); + + it('should not have softDeleteConfig when softDelete not enabled', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_soft_delete_users4', userSchema); + + expect(users._meta.softDeleteConfig).toBeUndefined(); + }); + }); + + describe('softDelete() and restore() operations', () => { + let db: any; + + const itemSchema = z.object({ + name: z.string(), + category: z.string().optional(), + }); + + const items = fromStandardSchema('ss_soft_delete_items', itemSchema, { + softDelete: true, + }); + + beforeAll(async () => { + db = await createTestOrm({ items }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should soft delete a document by setting deletedAt timestamp', async () => { + const item = await db().items.create({ name: 'Test Item', category: 'test' }); + + const deleted = await db().items.softDelete(item._id); + + expect(deleted).not.toBeNull(); + expect(deleted.deletedAt).toBeInstanceOf(Date); + expect(deleted.name).toBe('Test Item'); + + // Document still exists in database + const raw = db().items.rawCollection(); + const doc = await raw.findOne({ _id: item._id }); + expect(doc).not.toBeNull(); + expect(doc.deletedAt).toBeInstanceOf(Date); + }); + + it('should restore a soft-deleted document by clearing deletedAt', async () => { + const item = await db().items.create({ name: 'Test Item' }); + await db().items.softDelete(item._id); + + const restored = await db().items.restore(item._id); + + expect(restored).not.toBeNull(); + expect(restored.deletedAt).toBeNull(); + expect(restored.name).toBe('Test Item'); + }); + + it('should throw error when softDelete not configured', async () => { + const noSoftDeleteSchema = z.object({ title: z.string() }); + const noSoftDelete = fromStandardSchema('ss_no_soft_delete', noSoftDeleteSchema); + + const testDb = await createTestOrm({ noSoftDelete }); + const doc = await testDb().noSoftDelete.create({ title: 'Test' }); + + await expect(testDb().noSoftDelete.softDelete(doc._id)).rejects.toThrow( + 'Soft delete not configured for this collection' + ); + }); + }); + + describe('Query filtering', () => { + let db: any; + + const productSchema = z.object({ + name: z.string(), + price: z.number(), + }); + + const products = fromStandardSchema('ss_soft_delete_products', productSchema, { + softDelete: true, + }); + + beforeAll(async () => { + db = await createTestOrm({ products }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should exclude soft-deleted documents from findMany by default', async () => { + const product1 = await db().products.create({ name: 'Product 1', price: 10 }); + const product2 = await db().products.create({ name: 'Product 2', price: 20 }); + const product3 = await db().products.create({ name: 'Product 3', price: 30 }); + + // Soft delete product2 + await db().products.softDelete(product2._id); + + const results = await db().products.findMany(); + + expect(results).toHaveLength(2); + expect(results.map((p: any) => p.name).sort()).toEqual(['Product 1', 'Product 3']); + }); + + it('should exclude soft-deleted documents from findOne by default', async () => { + const product = await db().products.create({ name: 'Test Product', price: 100 }); + await db().products.softDelete(product._id); + + const result = await db().products.findOne({ name: 'Test Product' }); + + expect(result).toBeNull(); + }); + + it('should exclude soft-deleted documents from findById by default', async () => { + const product = await db().products.create({ name: 'Test Product', price: 100 }); + await db().products.softDelete(product._id); + + const result = await db().products.findById(product._id); + + expect(result).toBeNull(); + }); + + it('should exclude soft-deleted documents from count by default', async () => { + await db().products.create({ name: 'Product 1', price: 10 }); + const product2 = await db().products.create({ name: 'Product 2', price: 20 }); + await db().products.create({ name: 'Product 3', price: 30 }); + + await db().products.softDelete(product2._id); + + const count = await db().products.count(); + + expect(count).toBe(2); + }); + + it('should allow finding soft-deleted documents via raw collection', async () => { + const product = await db().products.create({ name: 'Deleted Product', price: 999 }); + await db().products.softDelete(product._id); + + const raw = db().products.rawCollection(); + const found = await raw.findOne({ _id: product._id }); + + expect(found).not.toBeNull(); + expect(found.deletedAt).toBeInstanceOf(Date); + expect(found.name).toBe('Deleted Product'); + }); + + it('should include non-deleted documents with null deletedAt', async () => { + // Test that documents without deletedAt or with null deletedAt are included + const product1 = await db().products.create({ name: 'Product 1', price: 10 }); + + // Soft delete and restore to set deletedAt to null + await db().products.softDelete(product1._id); + await db().products.restore(product1._id); + + const results = await db().products.findMany(); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Product 1'); + }); + + it('should apply filter with soft delete exclusion', async () => { + await db().products.create({ name: 'Expensive', price: 100 }); + const cheap = await db().products.create({ name: 'Cheap', price: 10 }); + await db().products.create({ name: 'Medium', price: 50 }); + + await db().products.softDelete(cheap._id); + + const results = await db().products.findMany({ price: { $lt: 60 } }); + + // Only Medium should be returned (Cheap is soft-deleted) + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Medium'); + }); + }); + + describe('Custom soft delete field', () => { + let db: any; + + const taskSchema = z.object({ + title: z.string(), + priority: z.number().optional(), + }); + + const tasks = fromStandardSchema('ss_soft_delete_tasks', taskSchema, { + softDelete: { field: 'archivedAt' }, + }); + + beforeAll(async () => { + db = await createTestOrm({ tasks }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should use custom field name for soft delete', async () => { + const task = await db().tasks.create({ title: 'Test Task', priority: 1 }); + + const deleted = await db().tasks.softDelete(task._id); + + expect(deleted.archivedAt).toBeInstanceOf(Date); + expect(deleted.deletedAt).toBeUndefined(); + }); + + it('should exclude documents with custom soft delete field from queries', async () => { + const task1 = await db().tasks.create({ title: 'Task 1', priority: 1 }); + await db().tasks.create({ title: 'Task 2', priority: 2 }); + + await db().tasks.softDelete(task1._id); + + const results = await db().tasks.findMany(); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('Task 2'); + }); + + it('should restore using custom field', async () => { + const task = await db().tasks.create({ title: 'Test Task' }); + await db().tasks.softDelete(task._id); + + const restored = await db().tasks.restore(task._id); + + expect(restored.archivedAt).toBeNull(); + + // Should be findable again + const found = await db().tasks.findById(task._id); + expect(found).not.toBeNull(); + expect(found.title).toBe('Test Task'); + }); + }); + + describe('Integration with other features', () => { + let db: any; + + const entitySchema = z.object({ + name: z.string(), + type: z.enum(['a', 'b']).default('a'), + }); + + const entities = fromStandardSchema('ss_soft_delete_entities', entitySchema, { + publicId: 'entity', + softDelete: true, + }); + + beforeAll(async () => { + db = await createTestOrm({ entities }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should work with publicId', async () => { + const entity = await db().entities.create({ name: 'Test Entity' }); + + expect(entity.id).toMatch(/^entity_/); + + await db().entities.softDelete(entity.id); // Use public ID + + // Should not be findable via public ID + const notFound = await db().entities.findById(entity.id); + expect(notFound).toBeNull(); + + // Restore via public ID + await db().entities.restore(entity.id); + + const found = await db().entities.findById(entity.id); + expect(found).not.toBeNull(); + expect(found.name).toBe('Test Entity'); + }); + + it('should preserve validation on soft-deleted documents', async () => { + const entity = await db().entities.create({ name: 'Entity', type: 'a' }); + await db().entities.softDelete(entity._id); + + // Restore and update - validation should still work + await db().entities.restore(entity._id); + + // Invalid type should fail + await expect( + db().entities.updateById(entity._id, { type: 'invalid' }) + ).rejects.toThrow(); + }); + }); + + describe('Hard delete vs soft delete', () => { + let db: any; + + const recordSchema = z.object({ + data: z.string(), + }); + + const records = fromStandardSchema('ss_soft_delete_records', recordSchema, { + softDelete: true, + }); + + beforeAll(async () => { + db = await createTestOrm({ records }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should preserve document with softDelete', async () => { + const record = await db().records.create({ data: 'important' }); + + await db().records.softDelete(record._id); + + // Document still exists + const raw = db().records.rawCollection(); + const found = await raw.findOne({ _id: record._id }); + expect(found).not.toBeNull(); + }); + + it('should actually remove document with deleteById', async () => { + const record = await db().records.create({ data: 'deleteable' }); + + await db().records.deleteById(record._id); + + // Document is gone + const raw = db().records.rawCollection(); + const found = await raw.findOne({ _id: record._id }); + expect(found).toBeNull(); + }); + + it('should allow re-soft-deleting a restored document', async () => { + const record = await db().records.create({ data: 'test' }); + + // Soft delete + await db().records.softDelete(record._id); + let found = await db().records.findById(record._id); + expect(found).toBeNull(); + + // Restore + await db().records.restore(record._id); + found = await db().records.findById(record._id); + expect(found).not.toBeNull(); + + // Soft delete again + await db().records.softDelete(record._id); + found = await db().records.findById(record._id); + expect(found).toBeNull(); + }); + }); +}); diff --git a/packages/mizzle-orm/src/collection/from-standard-schema.ts b/packages/mizzle-orm/src/collection/from-standard-schema.ts index fe9c3e6..391d22e 100644 --- a/packages/mizzle-orm/src/collection/from-standard-schema.ts +++ b/packages/mizzle-orm/src/collection/from-standard-schema.ts @@ -22,6 +22,16 @@ export interface SSPublicIdConfig { field: string; } +/** + * Normalized soft delete configuration + */ +export interface SSSoftDeleteConfig { + /** + * Field name to store the deletion timestamp (default: 'deletedAt') + */ + field: string; +} + /** * Options for Standard Schema collections * Simplified options compared to field-builder collections since the schema handles validation @@ -36,9 +46,12 @@ export interface SSCollectionOptions { publicId?: string | { prefix: string; field?: string }; /** - * Enable soft delete (adds deletedAt field) + * Enable soft delete (marks documents as deleted instead of removing them) + * Can be a boolean (uses 'deletedAt' as field name) or config object + * @example true → uses 'deletedAt' field + * @example { field: 'removedAt' } → uses 'removedAt' field */ - softDelete?: boolean; + softDelete?: boolean | { field?: string }; /** * Enable timestamps (adds createdAt, updatedAt fields) @@ -75,6 +88,11 @@ export interface SSCollectionMeta> { */ publicIdConfig?: SSPublicIdConfig; + /** + * Normalized soft delete configuration (if softDelete option was specified) + */ + softDeleteConfig?: SSSoftDeleteConfig; + /** * Middlewares applied to this collection */ @@ -231,12 +249,27 @@ export function fromStandardSchema>( } } + // Normalize softDelete config + let softDeleteConfig: SSSoftDeleteConfig | undefined; + if (options.softDelete) { + if (typeof options.softDelete === 'boolean') { + softDeleteConfig = { + field: 'deletedAt', // Default field name + }; + } else { + softDeleteConfig = { + field: options.softDelete.field || 'deletedAt', // Default field name if not specified + }; + } + } + // Build metadata const meta: SSCollectionMeta = { name, schema, options, publicIdConfig, + softDeleteConfig, middlewares: options.middlewares || [], }; diff --git a/packages/mizzle-orm/src/index.ts b/packages/mizzle-orm/src/index.ts index 628db1d..7cda319 100644 --- a/packages/mizzle-orm/src/index.ts +++ b/packages/mizzle-orm/src/index.ts @@ -17,6 +17,7 @@ export type { SSCollectionOptions, SSCollectionMeta, SSPublicIdConfig, + SSSoftDeleteConfig, ExtractSSDocument, ExtractSSInsert, ExtractSSUpdate, diff --git a/packages/mizzle-orm/src/query/collection-facade.ts b/packages/mizzle-orm/src/query/collection-facade.ts index d29061f..3fb993c 100644 --- a/packages/mizzle-orm/src/query/collection-facade.ts +++ b/packages/mizzle-orm/src/query/collection-facade.ts @@ -177,7 +177,8 @@ export class CollectionFacade< async () => { const filter = this.buildIdFilter(id); // Call the inner logic of findOne directly to avoid double middleware execution - const finalFilter = this.applyPolicies(filter); + const policyFilter = this.applyPolicies(filter); + const finalFilter = this.applySoftDeleteFilter(policyFilter); // If include is specified, use aggregation pipeline if (options?.include) { @@ -220,7 +221,8 @@ export class CollectionFacade< return this.executeWithMiddlewares( 'findOne', async () => { - const finalFilter = this.applyPolicies(filter); + const policyFilter = this.applyPolicies(filter); + const finalFilter = this.applySoftDeleteFilter(policyFilter); // If include is specified, use aggregation pipeline if (options?.include) { @@ -266,7 +268,8 @@ export class CollectionFacade< return this.executeWithMiddlewares( 'findMany', async () => { - const finalFilter = this.applyPolicies(filter); + const policyFilter = this.applyPolicies(filter); + const finalFilter = this.applySoftDeleteFilter(policyFilter); // If include is specified, use aggregation pipeline if (options?.include) { @@ -335,7 +338,8 @@ export class CollectionFacade< return this.executeWithMiddlewares( 'count', async () => { - const finalFilter = this.applyPolicies(filter); + const policyFilter = this.applyPolicies(filter); + const finalFilter = this.applySoftDeleteFilter(policyFilter); return this.collection.countDocuments(finalFilter, { session: this.ctx.session, }); @@ -730,6 +734,36 @@ export class CollectionFacade< return filter; } + /** + * Apply soft delete filter to exclude soft-deleted documents + * For SS collections with softDelete enabled, excludes documents where deletedAt is set + */ + private applySoftDeleteFilter(filter: Filter): Filter { + // Only apply for SS collections with soft delete configured + if (!isSSCollectionDefinition(this.collectionDef)) { + return filter; + } + + const ssMeta = (this.collectionDef as unknown as SSCollectionDefinition)._meta; + if (!ssMeta.softDeleteConfig) { + return filter; + } + + const softDeleteField = ssMeta.softDeleteConfig.field; + + // Exclude documents where the soft delete field is set (not null/undefined) + const softDeleteFilter = { + $or: [ + { [softDeleteField]: null }, + { [softDeleteField]: { $exists: false } }, + ], + }; + + return { + $and: [filter, softDeleteFilter], + } as Filter; + } + /** * Apply default values and generate auto-fields * Note: For Standard Schema collections, defaults are handled by the schema itself @@ -837,6 +871,13 @@ export class CollectionFacade< * Get the soft delete field name if configured */ private getSoftDeleteField(): string | null { + // Check if this is a Standard Schema collection with softDelete config + if (isSSCollectionDefinition(this.collectionDef)) { + const ssMeta = (this.collectionDef as unknown as SSCollectionDefinition)._meta; + return ssMeta.softDeleteConfig?.field ?? null; + } + + // For regular field-builder collections, check schema const schema = this.collectionDef._schema; for (const [fieldName, fieldBuilder] of Object.entries(schema)) { if (fieldBuilder._config.isSoftDeleteFlag) { diff --git a/progress.txt b/progress.txt index 39efa78..edb9a8b 100644 --- a/progress.txt +++ b/progress.txt @@ -6,7 +6,7 @@ Started: 2026-02-11T21:23:00Z ## Codebase Patterns - Monorepo with turbo, main package at `packages/mizzle-orm` - Build: `pnpm build` → tsup (generates CJS/ESM/DTS) -- Test: `pnpm test` → vitest (230+ tests, now 306) +- Test: `pnpm test` → vitest (230+ tests, now 333) - Typecheck: `pnpm typecheck` → tsc --noEmit - Type files live in `src/types/`, tests in `src//__tests__/` - vitest expectTypeOf available for type-level assertions @@ -112,3 +112,31 @@ Started: 2026-02-11T21:23:00Z - Optional fields, enum validation - **Learnings:** For partial validation, run full validation then filter issues by paths present in update data. This allows partial updates while catching invalid field values. Import `ValidationIssue` type from validation-error.ts for type safety in filter callbacks. --- + +## 2026-02-11 21:55 - US-005: Add publicId support for Standard Schema collections +- Extended `SSCollectionOptions` to accept `publicId?: string | { prefix: string; field?: string }` +- Added `SSPublicIdConfig` interface for normalized config +- Added `publicIdConfig` to `SSCollectionMeta` (normalized at factory time) +- Updated `fromStandardSchema` to normalize publicId options into publicIdConfig +- Updated `CollectionFacade.applyDefaults` to generate publicId for SS collections using `generatePublicId` utility +- Updated `CollectionFacade.getPublicIdField` to return SS collection's publicId field +- Fixed `CollectionFacade.deleteOneInternal` for SS collections (null-safe hooks/policies access, skip cascades) +- Exported `SSPublicIdConfig` from index.ts +- Files changed: + - src/collection/from-standard-schema.ts (SSPublicIdConfig, extended options, normalized config) + - src/query/collection-facade.ts (applyDefaults, getPublicIdField, deleteOneInternal) + - src/index.ts (export SSPublicIdConfig) + - src/collection/__tests__/standard-schema-public-id.test.ts (21 new tests) +- Tests: 21 tests covering: + - fromStandardSchema publicId option (prefix string, object with prefix, object with prefix+field) + - Default field name 'id' + - Auto-generation of prefixed IDs on insert + - Custom field name support + - Unique ID generation + - findById with public ID (default field, custom field, ObjectId fallback) + - updateById and deleteById with public ID + - Integration with validation + - Combined options (softDelete, timestamps) + - Edge cases (empty prefix = no config, special chars, underscore in prefix) +- **Learnings:** SS collections require null-safe access throughout CollectionFacade for hooks, policies, and cascades. PublicId default field is 'id' (not 'publicId') to match common API patterns. +--- From 104ca31205dd053fab79a68c86471254c0e99481 Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 22:09:34 +0000 Subject: [PATCH 09/12] feat: US-007 - Add timestamp support for Standard Schema collections - Extended SSCollectionOptions.timestamps to accept boolean or custom config - Added SSTimestampsConfig interface with createdAt/updatedAt field names - Added timestampsConfig to SSCollectionMeta (normalized at factory time) - Default field names: 'createdAt' and 'updatedAt' - applyDefaults sets createdAt to current Date on insert - applyUpdateTimestamps sets updatedAt to current Date on update/updateMany - Exported SSTimestampsConfig from index.ts - 20 tests covering all acceptance criteria --- .../standard-schema-timestamps.test.ts | 391 ++++++++++++++++++ .../src/collection/from-standard-schema.ts | 44 +- packages/mizzle-orm/src/index.ts | 1 + .../mizzle-orm/src/query/collection-facade.ts | 26 +- progress.txt | 19 + 5 files changed, 474 insertions(+), 7 deletions(-) create mode 100644 packages/mizzle-orm/src/collection/__tests__/standard-schema-timestamps.test.ts diff --git a/packages/mizzle-orm/src/collection/__tests__/standard-schema-timestamps.test.ts b/packages/mizzle-orm/src/collection/__tests__/standard-schema-timestamps.test.ts new file mode 100644 index 0000000..8eafab8 --- /dev/null +++ b/packages/mizzle-orm/src/collection/__tests__/standard-schema-timestamps.test.ts @@ -0,0 +1,391 @@ +/** + * Tests for timestamps support in Standard Schema collections + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { teardownTestDb, clearTestDb, createTestOrm } from '../../test/setup'; +import { fromStandardSchema } from '../from-standard-schema'; + +describe('Standard Schema Timestamps', () => { + describe('fromStandardSchema options', () => { + it('should accept timestamps: true option', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_timestamps_users1', userSchema, { + timestamps: true, + }); + + expect(users._meta.options.timestamps).toBe(true); + expect(users._meta.timestampsConfig).toEqual({ + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }); + }); + + it('should accept timestamps with custom field names', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_timestamps_users2', userSchema, { + timestamps: { createdAt: 'created', updatedAt: 'modified' }, + }); + + expect(users._meta.options.timestamps).toEqual({ + createdAt: 'created', + updatedAt: 'modified', + }); + expect(users._meta.timestampsConfig).toEqual({ + createdAt: 'created', + updatedAt: 'modified', + }); + }); + + it('should use default field names when only one custom field is provided', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_timestamps_users3', userSchema, { + timestamps: { createdAt: 'created' }, + }); + + expect(users._meta.timestampsConfig).toEqual({ + createdAt: 'created', + updatedAt: 'updatedAt', // Default + }); + }); + + it('should use default createdAt when only updatedAt is specified', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_timestamps_users4', userSchema, { + timestamps: { updatedAt: 'modified' }, + }); + + expect(users._meta.timestampsConfig).toEqual({ + createdAt: 'createdAt', // Default + updatedAt: 'modified', + }); + }); + + it('should not have timestampsConfig when timestamps not enabled', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_timestamps_users5', userSchema); + + expect(users._meta.timestampsConfig).toBeUndefined(); + }); + + it('should work with other options combined', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromStandardSchema('ss_timestamps_users6', userSchema, { + timestamps: true, + publicId: 'user', + softDelete: true, + }); + + expect(users._meta.timestampsConfig).toEqual({ + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }); + expect(users._meta.publicIdConfig).toBeDefined(); + expect(users._meta.softDeleteConfig).toBeDefined(); + }); + }); + + describe('insert operations', () => { + let db: any; + + const itemSchema = z.object({ + name: z.string(), + category: z.string().optional(), + }); + + const items = fromStandardSchema('ss_timestamps_items', itemSchema, { + timestamps: true, + }); + + beforeAll(async () => { + db = await createTestOrm({ items }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should set createdAt to current timestamp on insert', async () => { + const beforeCreate = new Date(); + const item = await db().items.create({ name: 'Test Item' }); + const afterCreate = new Date(); + + expect(item.createdAt).toBeInstanceOf(Date); + expect(item.createdAt.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()); + expect(item.createdAt.getTime()).toBeLessThanOrEqual(afterCreate.getTime()); + }); + + it('should not set updatedAt on insert', async () => { + const item = await db().items.create({ name: 'Test Item' }); + + expect(item.updatedAt).toBeUndefined(); + }); + + it('should not override createdAt if explicitly provided', async () => { + const customDate = new Date('2020-01-01T00:00:00.000Z'); + const item = await db().items.create({ + name: 'Test Item', + createdAt: customDate, + } as any); + + expect(item.createdAt).toEqual(customDate); + }); + + it('should generate unique createdAt for different documents', async () => { + const item1 = await db().items.create({ name: 'Item 1' }); + // Small delay to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 5)); + const item2 = await db().items.create({ name: 'Item 2' }); + + expect(item1.createdAt).toBeInstanceOf(Date); + expect(item2.createdAt).toBeInstanceOf(Date); + // Both should be valid dates (doesn't have to be different due to timing) + expect(item1.createdAt.getTime()).toBeLessThanOrEqual(item2.createdAt.getTime()); + }); + }); + + describe('update operations', () => { + let db: any; + + const itemSchema = z.object({ + name: z.string(), + category: z.string().optional(), + }); + + const items = fromStandardSchema('ss_timestamps_update_items', itemSchema, { + timestamps: true, + }); + + beforeAll(async () => { + db = await createTestOrm({ items }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should set updatedAt to current timestamp on updateById', async () => { + const item = await db().items.create({ name: 'Test Item' }); + expect(item.updatedAt).toBeUndefined(); + + const beforeUpdate = new Date(); + const updated = await db().items.updateById(item._id, { name: 'Updated Item' }); + const afterUpdate = new Date(); + + expect(updated.updatedAt).toBeInstanceOf(Date); + expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(beforeUpdate.getTime()); + expect(updated.updatedAt.getTime()).toBeLessThanOrEqual(afterUpdate.getTime()); + }); + + it('should set updatedAt to current timestamp on updateOne', async () => { + const item = await db().items.create({ name: 'Test Item', category: 'original' }); + + const beforeUpdate = new Date(); + const updated = await db().items.updateOne( + { name: 'Test Item' }, + { category: 'updated' } + ); + const afterUpdate = new Date(); + + expect(updated.updatedAt).toBeInstanceOf(Date); + expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(beforeUpdate.getTime()); + expect(updated.updatedAt.getTime()).toBeLessThanOrEqual(afterUpdate.getTime()); + }); + + it('should not modify createdAt on update', async () => { + const item = await db().items.create({ name: 'Test Item' }); + const originalCreatedAt = item.createdAt; + + const updated = await db().items.updateById(item._id, { name: 'Updated Item' }); + + expect(updated.createdAt.getTime()).toEqual(originalCreatedAt.getTime()); + }); + + it('should update updatedAt on subsequent updates', async () => { + const item = await db().items.create({ name: 'Test Item' }); + + const update1 = await db().items.updateById(item._id, { name: 'Update 1' }); + const firstUpdatedAt = update1.updatedAt; + + // Small delay to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 5)); + + const update2 = await db().items.updateById(item._id, { name: 'Update 2' }); + const secondUpdatedAt = update2.updatedAt; + + expect(secondUpdatedAt.getTime()).toBeGreaterThan(firstUpdatedAt.getTime()); + }); + }); + + describe('custom field names', () => { + let db: any; + + const articleSchema = z.object({ + title: z.string(), + content: z.string().optional(), + }); + + const articles = fromStandardSchema('ss_timestamps_articles', articleSchema, { + timestamps: { createdAt: 'publishedAt', updatedAt: 'lastEditedAt' }, + }); + + beforeAll(async () => { + db = await createTestOrm({ articles }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should use custom createdAt field name on insert', async () => { + const article = await db().articles.create({ title: 'Test Article' }); + + expect(article.publishedAt).toBeInstanceOf(Date); + expect(article.createdAt).toBeUndefined(); // Default name should not be used + }); + + it('should use custom updatedAt field name on update', async () => { + const article = await db().articles.create({ title: 'Test Article' }); + expect(article.lastEditedAt).toBeUndefined(); + + const updated = await db().articles.updateById(article._id, { title: 'Updated Article' }); + + expect(updated.lastEditedAt).toBeInstanceOf(Date); + expect(updated.updatedAt).toBeUndefined(); // Default name should not be used + }); + + it('should preserve custom createdAt field on update', async () => { + const article = await db().articles.create({ title: 'Test Article' }); + const originalPublishedAt = article.publishedAt; + + const updated = await db().articles.updateById(article._id, { content: 'New content' }); + + expect(updated.publishedAt.getTime()).toEqual(originalPublishedAt.getTime()); + }); + }); + + describe('updateMany operations', () => { + let db: any; + + const itemSchema = z.object({ + name: z.string(), + category: z.string(), + }); + + const items = fromStandardSchema('ss_timestamps_many_items', itemSchema, { + timestamps: true, + }); + + beforeAll(async () => { + db = await createTestOrm({ items }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should set updatedAt on updateMany', async () => { + await db().items.create({ name: 'Item 1', category: 'electronics' }); + await db().items.create({ name: 'Item 2', category: 'electronics' }); + await db().items.create({ name: 'Item 3', category: 'books' }); + + const beforeUpdate = new Date(); + const count = await db().items.updateMany( + { category: 'electronics' }, + { category: 'tech' } + ); + const afterUpdate = new Date(); + + expect(count).toBe(2); + + // Verify updatedAt was set on affected documents + const updated = await db().items.findMany({ category: 'tech' }); + expect(updated).toHaveLength(2); + for (const item of updated) { + expect(item.updatedAt).toBeInstanceOf(Date); + expect(item.updatedAt.getTime()).toBeGreaterThanOrEqual(beforeUpdate.getTime()); + expect(item.updatedAt.getTime()).toBeLessThanOrEqual(afterUpdate.getTime()); + } + + // Verify unaffected document has no updatedAt + const unaffected = await db().items.findOne({ category: 'books' }); + expect(unaffected.updatedAt).toBeUndefined(); + }); + }); + + describe('without timestamps', () => { + let db: any; + + const itemSchema = z.object({ + name: z.string(), + }); + + const items = fromStandardSchema('ss_no_timestamps_items', itemSchema); + + beforeAll(async () => { + db = await createTestOrm({ items }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should not add createdAt on insert when timestamps not enabled', async () => { + const item = await db().items.create({ name: 'Test Item' }); + + expect(item.createdAt).toBeUndefined(); + }); + + it('should not add updatedAt on update when timestamps not enabled', async () => { + const item = await db().items.create({ name: 'Test Item' }); + const updated = await db().items.updateById(item._id, { name: 'Updated Item' }); + + expect(updated.updatedAt).toBeUndefined(); + }); + }); +}); diff --git a/packages/mizzle-orm/src/collection/from-standard-schema.ts b/packages/mizzle-orm/src/collection/from-standard-schema.ts index 391d22e..ad92469 100644 --- a/packages/mizzle-orm/src/collection/from-standard-schema.ts +++ b/packages/mizzle-orm/src/collection/from-standard-schema.ts @@ -32,6 +32,21 @@ export interface SSSoftDeleteConfig { field: string; } +/** + * Normalized timestamps configuration + */ +export interface SSTimestampsConfig { + /** + * Field name to store creation timestamp (default: 'createdAt') + */ + createdAt: string; + + /** + * Field name to store update timestamp (default: 'updatedAt') + */ + updatedAt: string; +} + /** * Options for Standard Schema collections * Simplified options compared to field-builder collections since the schema handles validation @@ -54,9 +69,12 @@ export interface SSCollectionOptions { softDelete?: boolean | { field?: string }; /** - * Enable timestamps (adds createdAt, updatedAt fields) + * Enable timestamps (auto-manages createdAt and updatedAt fields) + * Can be a boolean (uses default field names) or config object with custom names + * @example true → uses 'createdAt' and 'updatedAt' fields + * @example { createdAt: 'created', updatedAt: 'modified' } → custom field names */ - timestamps?: boolean; + timestamps?: boolean | { createdAt?: string; updatedAt?: string }; /** * Custom middlewares for this collection @@ -93,6 +111,11 @@ export interface SSCollectionMeta> { */ softDeleteConfig?: SSSoftDeleteConfig; + /** + * Normalized timestamps configuration (if timestamps option was specified) + */ + timestampsConfig?: SSTimestampsConfig; + /** * Middlewares applied to this collection */ @@ -263,6 +286,22 @@ export function fromStandardSchema>( } } + // Normalize timestamps config + let timestampsConfig: SSTimestampsConfig | undefined; + if (options.timestamps) { + if (typeof options.timestamps === 'boolean') { + timestampsConfig = { + createdAt: 'createdAt', // Default field name + updatedAt: 'updatedAt', // Default field name + }; + } else { + timestampsConfig = { + createdAt: options.timestamps.createdAt || 'createdAt', + updatedAt: options.timestamps.updatedAt || 'updatedAt', + }; + } + } + // Build metadata const meta: SSCollectionMeta = { name, @@ -270,6 +309,7 @@ export function fromStandardSchema>( options, publicIdConfig, softDeleteConfig, + timestampsConfig, middlewares: options.middlewares || [], }; diff --git a/packages/mizzle-orm/src/index.ts b/packages/mizzle-orm/src/index.ts index 7cda319..158dafe 100644 --- a/packages/mizzle-orm/src/index.ts +++ b/packages/mizzle-orm/src/index.ts @@ -18,6 +18,7 @@ export type { SSCollectionMeta, SSPublicIdConfig, SSSoftDeleteConfig, + SSTimestampsConfig, ExtractSSDocument, ExtractSSInsert, ExtractSSUpdate, diff --git a/packages/mizzle-orm/src/query/collection-facade.ts b/packages/mizzle-orm/src/query/collection-facade.ts index 3fb993c..6c9d563 100644 --- a/packages/mizzle-orm/src/query/collection-facade.ts +++ b/packages/mizzle-orm/src/query/collection-facade.ts @@ -767,11 +767,11 @@ export class CollectionFacade< /** * Apply default values and generate auto-fields * Note: For Standard Schema collections, defaults are handled by the schema itself - * Exception: publicId is auto-generated by the ORM for SS collections + * Exceptions: publicId and timestamps are auto-generated by the ORM for SS collections */ private async applyDefaults(data: Record): Promise> { // Standard Schema collections handle defaults through the schema (e.g., Zod .default()) - // Exception: publicId is generated by the ORM since it requires the generatePublicId utility + // Exceptions: publicId and createdAt are generated by the ORM if (isSSCollectionDefinition(this.collectionDef)) { const result = { ...data }; const ssMeta = (this.collectionDef as unknown as SSCollectionDefinition)._meta; @@ -783,6 +783,14 @@ export class CollectionFacade< result[field] = generatePublicId(prefix); } } + + // Set createdAt timestamp if timestamps are configured and not already provided + if (ssMeta.timestampsConfig) { + const { createdAt } = ssMeta.timestampsConfig; + if (!(createdAt in result) || result[createdAt] === undefined) { + result[createdAt] = new Date(); + } + } return result; } @@ -824,12 +832,20 @@ export class CollectionFacade< /** * Apply update timestamps (onUpdateNow fields) - * Note: For Standard Schema collections, timestamps must be handled differently (not supported yet) + * For Standard Schema collections with timestamps config, sets updatedAt field */ private applyUpdateTimestamps(data: Record): Record { - // Standard Schema collections don't use field builders with onUpdateNow - // Timestamp support for SS collections would need a different mechanism + // Standard Schema collections use timestampsConfig for update timestamps if (isSSCollectionDefinition(this.collectionDef)) { + const ssMeta = (this.collectionDef as unknown as SSCollectionDefinition)._meta; + + if (ssMeta.timestampsConfig) { + return { + ...data, + [ssMeta.timestampsConfig.updatedAt]: new Date(), + }; + } + return { ...data }; } diff --git a/progress.txt b/progress.txt index edb9a8b..96d8aa6 100644 --- a/progress.txt +++ b/progress.txt @@ -140,3 +140,22 @@ Started: 2026-02-11T21:23:00Z - Edge cases (empty prefix = no config, special chars, underscore in prefix) - **Learnings:** SS collections require null-safe access throughout CollectionFacade for hooks, policies, and cascades. PublicId default field is 'id' (not 'publicId') to match common API patterns. --- + +## 2026-02-11 22:01 - US-006: Add softDelete support for Standard Schema collections +- Extended `SSCollectionOptions.softDelete` to accept `boolean | { field?: string }` +- Added `SSSoftDeleteConfig` interface for normalized config (default field: 'deletedAt') +- Added `softDeleteConfig` to `SSCollectionMeta` (normalized at factory time) +- Updated `fromStandardSchema` to normalize softDelete options +- Updated `getSoftDeleteField()` in CollectionFacade to check SS collections +- Added `applySoftDeleteFilter()` helper method to exclude soft-deleted documents +- Updated `findById`, `findOne`, `findMany`, `count` to apply soft delete filter +- `softDelete(id)` sets the timestamp field; `restore(id)` clears it +- Default queries exclude documents where soft delete field is set +- Exported `SSSoftDeleteConfig` from index.ts +- Files changed: + - src/collection/from-standard-schema.ts (SSSoftDeleteConfig, extended options, normalized config) + - src/query/collection-facade.ts (getSoftDeleteField, applySoftDeleteFilter, query methods) + - src/index.ts (export SSSoftDeleteConfig) + - src/collection/__tests__/standard-schema-soft-delete.test.ts (22 new tests) +- **Learnings:** Soft delete filtering needs to apply to all read queries (findById, findOne, findMany, count). The filter uses `$or` to match both null and non-existent fields to include non-deleted documents. +--- From e1d04ea53d30844c8fee0b0c1dd30baf2ca50c53 Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 22:18:27 +0000 Subject: [PATCH 10/12] feat: US-008 - Create fromZod convenience wrapper with defaults extraction - Create src/collection/from-zod.ts with Zod-specific collection factory - extractZodDefaults() extracts .default() values from Zod schema - applyZodDefaults() applies extracted defaults on insert - ZodValidationError provides Zod-formatted error messages with format()/flatten() - validateWithZod() uses Zod's safeParse for better error formatting - Update CollectionFacade to use Zod-specific validation and defaults - Export fromZod, ZodValidationError, and related utilities from index.ts - Tests: 51 tests covering extraction, application, validation, and integration --- .../src/collection/__tests__/from-zod.test.ts | 763 ++++++++++++++++++ .../mizzle-orm/src/collection/from-zod.ts | 479 +++++++++++ packages/mizzle-orm/src/index.ts | 15 + .../mizzle-orm/src/query/collection-facade.ts | 25 +- progress.txt | 17 +- 5 files changed, 1296 insertions(+), 3 deletions(-) create mode 100644 packages/mizzle-orm/src/collection/__tests__/from-zod.test.ts create mode 100644 packages/mizzle-orm/src/collection/from-zod.ts diff --git a/packages/mizzle-orm/src/collection/__tests__/from-zod.test.ts b/packages/mizzle-orm/src/collection/__tests__/from-zod.test.ts new file mode 100644 index 0000000..2eb5878 --- /dev/null +++ b/packages/mizzle-orm/src/collection/__tests__/from-zod.test.ts @@ -0,0 +1,763 @@ +/** + * Tests for fromZod convenience wrapper + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { expectTypeOf } from 'vitest'; +import { ObjectId } from 'mongodb'; +import { teardownTestDb, clearTestDb, createTestOrm } from '../../test/setup'; +import { + fromZod, + isZodSchema, + isZodCollectionDefinition, + extractZodDefaults, + applyZodDefaults, + validateWithZod, + ZodValidationError, +} from '../from-zod'; +import { isSSCollectionDefinition } from '../from-standard-schema'; + +describe('fromZod', () => { + describe('isZodSchema', () => { + it('should return true for Zod schemas', () => { + const schema = z.object({ name: z.string() }); + expect(isZodSchema(schema)).toBe(true); + }); + + it('should return true for simple Zod types', () => { + expect(isZodSchema(z.string())).toBe(true); + expect(isZodSchema(z.number())).toBe(true); + expect(isZodSchema(z.boolean())).toBe(true); + expect(isZodSchema(z.array(z.string()))).toBe(true); + }); + + it('should return false for plain objects', () => { + expect(isZodSchema({ name: 'test' })).toBe(false); + }); + + it('should return false for null/undefined', () => { + expect(isZodSchema(null)).toBe(false); + expect(isZodSchema(undefined)).toBe(false); + }); + + it('should return false for non-Zod validation libraries', () => { + // Mock a non-Zod Standard Schema (no safeParse) + const mockSchema = { + '~standard': { + version: 1, + vendor: 'other', + validate: () => ({ value: {} }), + }, + }; + expect(isZodSchema(mockSchema)).toBe(false); + }); + }); + + describe('extractZodDefaults', () => { + it('should extract simple default values', () => { + const schema = z.object({ + role: z.string().default('user'), + count: z.number().default(0), + active: z.boolean().default(true), + }); + + const defaults = extractZodDefaults(schema); + + expect(defaults).toEqual({ + role: 'user', + count: 0, + active: true, + }); + }); + + it('should extract default values from optional fields with defaults', () => { + const schema = z.object({ + name: z.string(), + nickname: z.string().optional().default('Anonymous'), + }); + + const defaults = extractZodDefaults(schema); + + expect(defaults.nickname).toBe('Anonymous'); + expect(defaults.name).toBeUndefined(); + }); + + it('should extract object defaults', () => { + const schema = z.object({ + settings: z.object({ + theme: z.string(), + }).default({ theme: 'dark' }), + }); + + const defaults = extractZodDefaults(schema); + + expect(defaults.settings).toEqual({ theme: 'dark' }); + }); + + it('should extract array defaults', () => { + const schema = z.object({ + tags: z.array(z.string()).default(['general']), + }); + + const defaults = extractZodDefaults(schema); + + expect(defaults.tags).toEqual(['general']); + }); + + it('should return empty object for schemas without defaults', () => { + const schema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const defaults = extractZodDefaults(schema); + + expect(defaults).toEqual({}); + }); + + it('should return empty object for non-object schemas', () => { + const schema = z.string(); + + const defaults = extractZodDefaults(schema as any); + + expect(defaults).toEqual({}); + }); + + it('should extract enum defaults', () => { + const schema = z.object({ + status: z.enum(['active', 'inactive', 'pending']).default('pending'), + }); + + const defaults = extractZodDefaults(schema); + + expect(defaults.status).toBe('pending'); + }); + }); + + describe('applyZodDefaults', () => { + it('should apply defaults to empty object', () => { + const defaults = { role: 'user', count: 0 }; + const data = {}; + + const result = applyZodDefaults(data, defaults); + + expect(result).toEqual({ role: 'user', count: 0 }); + }); + + it('should not override existing values', () => { + const defaults = { role: 'user', count: 0 }; + const data = { role: 'admin', name: 'Test' }; + + const result = applyZodDefaults(data, defaults); + + expect(result).toEqual({ role: 'admin', name: 'Test', count: 0 }); + }); + + it('should apply defaults for undefined values', () => { + const defaults = { role: 'user' }; + const data = { role: undefined, name: 'Test' }; + + const result = applyZodDefaults(data as any, defaults); + + expect(result).toEqual({ role: 'user', name: 'Test' }); + }); + + it('should preserve null values (not apply defaults)', () => { + const defaults = { role: 'user' }; + const data = { role: null, name: 'Test' }; + + const result = applyZodDefaults(data as any, defaults); + + // null is a valid value, not undefined - should not apply default + expect(result.role).toBe(null); + }); + }); + + describe('validateWithZod', () => { + it('should return parsed data on success', () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + }); + + const result = validateWithZod(schema, { name: 'Test', age: 25 }); + + expect(result).toEqual({ name: 'Test', age: 25 }); + }); + + it('should throw ZodValidationError on failure', () => { + const schema = z.object({ + email: z.string().email(), + }); + + expect(() => validateWithZod(schema, { email: 'invalid' })) + .toThrow(ZodValidationError); + }); + + it('should apply transforms', () => { + const schema = z.object({ + slug: z.string().transform((s) => s.toLowerCase().replace(/\s+/g, '-')), + }); + + const result = validateWithZod(schema, { slug: 'Hello World' }); + + expect(result.slug).toBe('hello-world'); + }); + + it('should apply defaults during validation', () => { + const schema = z.object({ + role: z.string().default('user'), + name: z.string(), + }); + + const result = validateWithZod(schema, { name: 'Test' }); + + expect(result).toEqual({ role: 'user', name: 'Test' }); + }); + + describe('partial validation', () => { + it('should pass for partial updates with valid fields', () => { + const schema = z.object({ + email: z.string().email(), + name: z.string().min(1), + }); + + // Only updating name, email missing is OK + const result = validateWithZod(schema, { name: 'Updated' }, { partial: true }); + + expect(result).toEqual({ name: 'Updated' }); + }); + + it('should fail for partial updates with invalid fields', () => { + const schema = z.object({ + email: z.string().email(), + name: z.string().min(1), + }); + + expect(() => validateWithZod(schema, { name: '' }, { partial: true })) + .toThrow(ZodValidationError); + }); + }); + }); + + describe('ZodValidationError', () => { + it('should include formatted field errors in message', () => { + const schema = z.object({ + email: z.string().email(), + name: z.string().min(1), + }); + + try { + validateWithZod(schema, { email: 'invalid', name: '' }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ZodValidationError); + const zodError = error as ZodValidationError; + expect(zodError.message).toContain('email'); + expect(zodError.message).toContain('name'); + } + }); + + it('should provide format() method', () => { + const schema = z.object({ + email: z.string().email(), + }); + + try { + validateWithZod(schema, { email: 'invalid' }); + expect.fail('Should have thrown'); + } catch (error) { + const zodError = error as ZodValidationError; + const formatted = zodError.format(); + + expect(formatted).toBeDefined(); + expect(formatted._errors).toEqual([]); + expect((formatted.email as any)._errors).toBeDefined(); + } + }); + + it('should provide flatten() method', () => { + const schema = z.object({ + email: z.string().email(), + age: z.number().min(0), + }); + + try { + validateWithZod(schema, { email: 'invalid', age: -1 }); + expect.fail('Should have thrown'); + } catch (error) { + const zodError = error as ZodValidationError; + const flattened = zodError.flatten(); + + expect(flattened.formErrors).toEqual([]); + expect(flattened.fieldErrors.email).toBeDefined(); + expect(flattened.fieldErrors.age).toBeDefined(); + } + }); + + it('should provide getFieldErrors() method', () => { + const schema = z.object({ + email: z.string().email(), + }); + + try { + validateWithZod(schema, { email: 'invalid' }); + expect.fail('Should have thrown'); + } catch (error) { + const zodError = error as ZodValidationError; + const emailErrors = zodError.getFieldErrors('email'); + + expect(emailErrors.length).toBeGreaterThan(0); + expect(zodError.getFieldErrors('nonexistent')).toEqual([]); + } + }); + + it('should provide errorFields getter', () => { + const schema = z.object({ + email: z.string().email(), + name: z.string().min(5), + }); + + try { + validateWithZod(schema, { email: 'invalid', name: 'a' }); + expect.fail('Should have thrown'); + } catch (error) { + const zodError = error as ZodValidationError; + const fields = zodError.errorFields; + + expect(fields).toContain('email'); + expect(fields).toContain('name'); + } + }); + + it('should provide toJSON() method', () => { + const schema = z.object({ + email: z.string().email(), + }); + + try { + validateWithZod(schema, { email: 'invalid' }); + expect.fail('Should have thrown'); + } catch (error) { + const zodError = error as ZodValidationError; + const json = zodError.toJSON(); + + expect(json.name).toBe('ZodValidationError'); + expect(json.message).toBeDefined(); + expect(json.issues).toBeDefined(); + expect(json.formatted).toBeDefined(); + expect(json.flattened).toBeDefined(); + } + }); + + it('should include Standard Schema compatible issues', () => { + const schema = z.object({ + email: z.string().email(), + }); + + try { + validateWithZod(schema, { email: 'invalid' }); + expect.fail('Should have thrown'); + } catch (error) { + const zodError = error as ZodValidationError; + + expect(zodError.issues).toBeDefined(); + expect(zodError.issues.length).toBeGreaterThan(0); + expect(zodError.issues[0].message).toBeDefined(); + expect(zodError.issues[0].path).toEqual(['email']); + } + }); + }); + + describe('fromZod factory', () => { + it('should create a collection definition', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromZod('zod_users', userSchema); + + expect(users._meta.name).toBe('zod_users'); + expect(users._schema).toBe(userSchema); + expect(users._brand).toBe('SSCollectionDefinition'); + }); + + it('should be recognized as SS collection definition', () => { + const schema = z.object({ name: z.string() }); + const collection = fromZod('test', schema); + + expect(isSSCollectionDefinition(collection)).toBe(true); + }); + + it('should be recognized as Zod collection definition', () => { + const schema = z.object({ name: z.string() }); + const collection = fromZod('test', schema); + + expect(isZodCollectionDefinition(collection)).toBe(true); + }); + + it('should store the original Zod schema', () => { + const userSchema = z.object({ + email: z.string().email(), + }); + + const users = fromZod('zod_users', userSchema); + + expect(users._meta.zodSchema).toBe(userSchema); + expect(users._meta.isZod).toBe(true); + }); + + it('should extract and store defaults', () => { + const userSchema = z.object({ + email: z.string().email(), + role: z.enum(['user', 'admin']).default('user'), + active: z.boolean().default(true), + }); + + const users = fromZod('zod_users', userSchema); + + expect(users._meta.defaults).toEqual({ + role: 'user', + active: true, + }); + }); + + it('should throw for non-Zod schemas', () => { + const notZod = { + '~standard': { + version: 1, + vendor: 'other', + validate: () => ({ value: {} }), + }, + }; + + expect(() => fromZod('test', notZod as any)) + .toThrow('must be a Zod schema'); + }); + + it('should accept options like publicId', () => { + const schema = z.object({ name: z.string() }); + + const collection = fromZod('test', schema, { + publicId: 'test', + }); + + expect(collection._meta.publicIdConfig).toEqual({ + prefix: 'test', + field: 'id', + }); + }); + + it('should accept options like softDelete', () => { + const schema = z.object({ name: z.string() }); + + const collection = fromZod('test', schema, { + softDelete: true, + }); + + expect(collection._meta.softDeleteConfig).toEqual({ + field: 'deletedAt', + }); + }); + + it('should accept options like timestamps', () => { + const schema = z.object({ name: z.string() }); + + const collection = fromZod('test', schema, { + timestamps: true, + }); + + expect(collection._meta.timestampsConfig).toEqual({ + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }); + }); + + it('should accept combined options', () => { + const schema = z.object({ name: z.string() }); + + const collection = fromZod('test', schema, { + publicId: 'item', + softDelete: true, + timestamps: { createdAt: 'created', updatedAt: 'modified' }, + }); + + expect(collection._meta.publicIdConfig?.prefix).toBe('item'); + expect(collection._meta.softDeleteConfig?.field).toBe('deletedAt'); + expect(collection._meta.timestampsConfig?.createdAt).toBe('created'); + expect(collection._meta.timestampsConfig?.updatedAt).toBe('modified'); + }); + }); + + describe('type inference', () => { + it('should infer document type correctly', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + age: z.number().optional(), + }); + + const users = fromZod('zod_users', userSchema); + + type Doc = typeof users.$inferDocument; + + // Type assertions at compile time + expectTypeOf().toHaveProperty('_id'); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('name'); + expectTypeOf().toHaveProperty('age'); + }); + + it('should infer insert type correctly', () => { + const userSchema = z.object({ + email: z.string().email(), + role: z.enum(['user', 'admin']).default('user'), + }); + + const users = fromZod('zod_users', userSchema); + + type Insert = typeof users.$inferInsert; + + // Insert type should have optional role (due to default) + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('role'); + }); + + it('should infer update type correctly', () => { + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + }); + + const users = fromZod('zod_users', userSchema); + + type Update = typeof users.$inferUpdate; + + // Update should be partial and not include _id + expectTypeOf().toMatchTypeOf<{ email?: string; name?: string }>(); + }); + }); + + describe('integration: defaults on insert', () => { + let db: any; + + const userSchema = z.object({ + email: z.string().email(), + role: z.enum(['user', 'admin']).default('user'), + settings: z.object({ + theme: z.string(), + }).default({ theme: 'light' }), + }); + + const users = fromZod('zod_users_defaults', userSchema); + + beforeAll(async () => { + db = await createTestOrm({ users }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should apply Zod defaults on insert', async () => { + const created = await db().users.create({ + email: 'test@example.com', + }); + + expect(created.email).toBe('test@example.com'); + expect(created.role).toBe('user'); + expect(created.settings).toEqual({ theme: 'light' }); + }); + + it('should not override provided values with defaults', async () => { + const created = await db().users.create({ + email: 'admin@example.com', + role: 'admin', + }); + + expect(created.role).toBe('admin'); + }); + }); + + describe('integration: validation errors', () => { + let db: any; + + const userSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), + }); + + const users = fromZod('zod_users_validation', userSchema); + + beforeAll(async () => { + db = await createTestOrm({ users }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should throw ZodValidationError on invalid insert', async () => { + try { + await db().users.create({ + email: 'invalid-email', + name: '', + }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ZodValidationError); + const zodError = error as ZodValidationError; + expect(zodError.errorFields).toContain('email'); + expect(zodError.errorFields).toContain('name'); + } + }); + }); + + describe('integration: partial updates', () => { + let db: any; + + const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + age: z.number().min(0).optional(), + bio: z.string().optional(), + }); + + const users = fromZod('zod_users_partial', userSchema); + + beforeAll(async () => { + db = await createTestOrm({ users }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should throw ZodValidationError on invalid update', async () => { + const created = await db().users.create({ + email: 'test@example.com', + name: 'Test', + age: 25, + }); + + try { + await db().users.updateById(created._id, { age: -5 }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ZodValidationError); + const zodError = error as ZodValidationError; + expect(zodError.errorFields).toContain('age'); + } + }); + + it('should allow partial updates without required fields', async () => { + const created = await db().users.create({ + email: 'test@example.com', + name: 'Test User', + }); + + // Update only bio - should not fail due to missing email/name + const updated = await db().users.updateById(created._id, { bio: 'Hello!' }); + + expect(updated?.bio).toBe('Hello!'); + expect(updated?.email).toBe('test@example.com'); + }); + }); + + describe('integration: with options', () => { + let db: any; + + const userSchema = z.object({ + email: z.string().email(), + role: z.enum(['user', 'admin']).default('user'), + }); + + const users = fromZod('zod_users_options', userSchema, { + publicId: 'user', + timestamps: true, + softDelete: true, + }); + + beforeAll(async () => { + db = await createTestOrm({ users }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await clearTestDb(); + }); + + it('should work with publicId option', async () => { + const created = await db().users.create({ + email: 'test@example.com', + }); + + expect(created.id).toBeDefined(); + expect(created.id).toMatch(/^user_/); + }); + + it('should work with timestamps option', async () => { + const created = await db().users.create({ + email: 'test@example.com', + }); + + expect(created.createdAt).toBeInstanceOf(Date); + + // Wait a bit and update + await new Promise((r) => setTimeout(r, 10)); + const updated = await db().users.updateById(created._id, { + email: 'new@example.com', + }); + + expect(updated?.updatedAt).toBeInstanceOf(Date); + expect(updated!.updatedAt.getTime()).toBeGreaterThan(created.createdAt.getTime()); + }); + + it('should work with softDelete option', async () => { + const created = await db().users.create({ + email: 'test@example.com', + }); + + await db().users.softDelete(created._id); + + // Should not find soft-deleted document + const found = await db().users.findById(created._id); + expect(found).toBeNull(); + + // Restore and find again + await db().users.restore(created._id); + const restored = await db().users.findById(created._id); + expect(restored).not.toBeNull(); + }); + + it('should combine defaults with ORM-generated fields', async () => { + const created = await db().users.create({ + email: 'test@example.com', + }); + + // Zod default + expect(created.role).toBe('user'); + // ORM-generated publicId + expect(created.id).toMatch(/^user_/); + // ORM-generated timestamp + expect(created.createdAt).toBeInstanceOf(Date); + }); + }); +}); diff --git a/packages/mizzle-orm/src/collection/from-zod.ts b/packages/mizzle-orm/src/collection/from-zod.ts new file mode 100644 index 0000000..76bc1d0 --- /dev/null +++ b/packages/mizzle-orm/src/collection/from-zod.ts @@ -0,0 +1,479 @@ +/** + * Zod-specific convenience wrapper for Standard Schema collections + * Provides Zod-specific ergonomics like default extraction and better error formatting + */ + +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { + fromStandardSchema, + type SSCollectionDefinition, + type SSCollectionOptions, + type SSCollectionMeta, +} from './from-standard-schema'; + +/** + * Type for a Zod schema (minimal interface we need) + * This avoids a hard dependency on Zod types while ensuring we get a Zod schema + */ +interface ZodLike extends StandardSchemaV1 { + /** + * Zod schemas have a _def property with schema definition + * Zod 3.x uses `typeName`, Zod 4.x uses `type` + */ + _def: { + typeName?: string; + type?: string; + defaultValue?: unknown; + innerType?: unknown; + [key: string]: unknown; + }; + + /** + * Zod's safeParse method for validation with better errors + */ + safeParse: (data: unknown) => ZodSafeParseResult; + + /** + * Zod's parse method (throws ZodError on failure) + */ + parse: (data: unknown) => TOutput; + + /** + * Zod object schemas have a shape property + */ + shape?: Record; +} + +/** + * Zod safeParse result type + */ +interface ZodSafeParseResult { + success: boolean; + data?: T; + error?: ZodLikeError; +} + +/** + * Minimal ZodError interface + */ +interface ZodLikeError extends Error { + issues: ZodIssue[]; + format: () => ZodFormattedError; + flatten: () => ZodFlattenedError; +} + +/** + * Zod issue type + */ +interface ZodIssue { + code: string; + message: string; + path: (string | number)[]; + [key: string]: unknown; +} + +/** + * Zod formatted error (nested structure) + */ +interface ZodFormattedError { + _errors: string[]; + [key: string]: ZodFormattedError | string[]; +} + +/** + * Zod flattened error + */ +interface ZodFlattenedError { + formErrors: string[]; + fieldErrors: Record; +} + +/** + * Extended metadata for Zod collections + */ +export interface ZodCollectionMeta extends SSCollectionMeta { + /** + * The original Zod schema (for Zod-specific operations) + */ + zodSchema: T; + + /** + * Extracted default values from the Zod schema + * Keys are field names, values are the default values or functions + */ + defaults: Record; + + /** + * Whether this collection uses Zod (always true for fromZod collections) + */ + isZod: true; +} + +/** + * Zod collection definition (extends SSCollectionDefinition with Zod-specific meta) + */ +export interface ZodCollectionDefinition + extends Omit, '_meta'> { + readonly _meta: ZodCollectionMeta; +} + +/** + * Zod validation error with enhanced formatting + * Provides Zod-specific error formatting methods + */ +export class ZodValidationError extends Error { + /** + * The original Zod error + */ + public readonly zodError: ZodLikeError; + + /** + * Standard Schema issues (for compatibility) + */ + public readonly issues: readonly StandardSchemaV1.Issue[]; + + constructor(zodError: ZodLikeError) { + // Build a descriptive message using Zod's formatting + const formatted = zodError.flatten(); + const fieldMessages = Object.entries(formatted.fieldErrors) + .map(([field, errors]) => `${field}: ${errors.join(', ')}`) + .join('; '); + const message = fieldMessages + ? `Validation failed: ${fieldMessages}` + : `Validation failed: ${zodError.message}`; + + super(message); + this.name = 'ZodValidationError'; + this.zodError = zodError; + + // Convert Zod issues to Standard Schema issues for compatibility + this.issues = zodError.issues.map((issue) => ({ + message: issue.message, + path: issue.path, + })); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ZodValidationError); + } + } + + /** + * Get formatted errors (Zod's nested format) + */ + format(): ZodFormattedError { + return this.zodError.format(); + } + + /** + * Get flattened errors (Zod's flat format) + */ + flatten(): ZodFlattenedError { + return this.zodError.flatten(); + } + + /** + * Get errors for a specific field + */ + getFieldErrors(field: string): string[] { + const flattened = this.zodError.flatten(); + return flattened.fieldErrors[field] || []; + } + + /** + * Get all field names with errors + */ + get errorFields(): string[] { + const flattened = this.zodError.flatten(); + return Object.keys(flattened.fieldErrors); + } + + /** + * Convert to plain object for serialization + */ + toJSON() { + return { + name: this.name, + message: this.message, + issues: this.issues, + formatted: this.format(), + flattened: this.flatten(), + }; + } +} + +/** + * Extract default values from a Zod object schema + * + * Traverses the Zod schema definition to find fields with .default() applied + * and extracts their default values. + * + * @param schema - A Zod object schema + * @returns Record of field names to default values + */ +export function extractZodDefaults( + schema: T, +): Record { + const defaults: Record = {}; + + // Must be an object schema with shape + if (!schema.shape) { + return defaults; + } + + for (const [fieldName, fieldSchema] of Object.entries(schema.shape)) { + const defaultValue = getZodDefaultValue(fieldSchema); + if (defaultValue !== undefined) { + defaults[fieldName] = defaultValue; + } + } + + return defaults; +} + +/** + * Get the default value from a Zod schema field + * Handles both direct defaults and wrapped schemas (optional, nullable, etc.) + * + * Supports both Zod 3.x (_def.typeName) and Zod 4.x (_def.type) structures + */ +function getZodDefaultValue(schema: ZodLike): unknown { + const def = schema._def; + + // Zod 4.x: _def.type === 'default' with defaultValue + if (def.type === 'default' && 'defaultValue' in def) { + const defaultVal = def.defaultValue; + // In Zod 4.x, defaultValue can be the actual value or a function + return typeof defaultVal === 'function' ? defaultVal() : defaultVal; + } + + // Zod 3.x: _def.typeName === 'ZodDefault' with defaultValue function + if (def.typeName === 'ZodDefault' && 'defaultValue' in def) { + const defaultFn = def.defaultValue as () => unknown; + return typeof defaultFn === 'function' ? defaultFn() : defaultFn; + } + + // Check inner schema for wrapped types (ZodOptional, ZodNullable, etc.) + // Zod 4.x uses 'innerType' in def + if ('innerType' in def && def.innerType) { + return getZodDefaultValue(def.innerType as ZodLike); + } + + // Zod 3.x uses 'innerType' in _def for some wrappers + if (def.innerType) { + return getZodDefaultValue(def.innerType as ZodLike); + } + + return undefined; +} + +/** + * Apply Zod defaults to data before insertion + * + * @param data - The input data + * @param defaults - The extracted defaults from the schema + * @returns Data with defaults applied for missing/undefined fields + */ +export function applyZodDefaults>( + data: T, + defaults: Record, +): T { + const result: Record = { ...data }; + + for (const [field, defaultValue] of Object.entries(defaults)) { + if (!(field in result) || result[field] === undefined) { + result[field] = defaultValue; + } + } + + return result as T; +} + +/** + * Validate data using Zod schema and throw ZodValidationError on failure + * + * @param schema - The Zod schema + * @param data - Data to validate + * @param options - Validation options + * @throws ZodValidationError if validation fails + * @returns The parsed data (with defaults and transforms applied) + */ +export function validateWithZod( + schema: T, + data: unknown, + options: { partial?: boolean } = {}, +): StandardSchemaV1.InferOutput { + const result = schema.safeParse(data); + + if (!result.success) { + const error = result.error!; + + if (options.partial && typeof data === 'object' && data !== null) { + // For partial validation, filter issues to only fields in the update + const updateKeys = new Set(Object.keys(data as object)); + const relevantIssues = error.issues.filter((issue) => { + if (!issue.path || issue.path.length === 0) return false; + return updateKeys.has(String(issue.path[0])); + }); + + if (relevantIssues.length === 0) { + // No relevant issues for fields we're updating + return data as any; + } + + // Create a new error with only relevant issues + const filteredError = { + ...error, + issues: relevantIssues, + format: () => { + const formatted: ZodFormattedError = { _errors: [] }; + for (const issue of relevantIssues) { + if (issue.path.length > 0) { + const field = String(issue.path[0]); + if (!formatted[field]) { + formatted[field] = { _errors: [] }; + } + (formatted[field] as ZodFormattedError)._errors.push(issue.message); + } + } + return formatted; + }, + flatten: () => { + const fieldErrors: Record = {}; + for (const issue of relevantIssues) { + if (issue.path.length > 0) { + const field = String(issue.path[0]); + if (!fieldErrors[field]) { + fieldErrors[field] = []; + } + fieldErrors[field].push(issue.message); + } + } + return { formErrors: [], fieldErrors }; + }, + }; + + throw new ZodValidationError(filteredError as ZodLikeError); + } + + throw new ZodValidationError(error); + } + + return result.data as StandardSchemaV1.InferOutput; +} + +/** + * Create a collection definition from a Zod schema + * + * This is a convenience wrapper around `fromStandardSchema` that provides + * Zod-specific ergonomics: + * + * 1. **Default extraction**: Automatically extracts `.default()` values from + * the Zod schema and applies them on insert + * 2. **Better errors**: Uses Zod's error formatting for detailed validation errors + * 3. **Full Zod support**: Preserves access to the original Zod schema for + * advanced use cases + * + * @param name - The MongoDB collection name + * @param schema - A Zod object schema + * @param options - Collection options (publicId, softDelete, timestamps, etc.) + * @returns A collection definition with Zod-specific features + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { fromZod } from '@mizzle-dev/orm'; + * + * const userSchema = z.object({ + * email: z.string().email(), + * name: z.string().min(1), + * role: z.enum(['user', 'admin']).default('user'), + * settings: z.object({ + * theme: z.enum(['light', 'dark']).default('light'), + * notifications: z.boolean().default(true), + * }).default({}), + * }); + * + * const users = fromZod('users', userSchema, { + * publicId: 'user', + * softDelete: true, + * }); + * + * // Defaults are automatically applied on insert: + * // { email: 'test@example.com', name: 'Test' } + * // becomes: + * // { email: 'test@example.com', name: 'Test', role: 'user', settings: { theme: 'light', notifications: true } } + * + * // Validation errors include Zod formatting: + * try { + * await db.users.create({ email: 'invalid', name: '' }); + * } catch (error) { + * if (error instanceof ZodValidationError) { + * console.log(error.flatten()); + * // { formErrors: [], fieldErrors: { email: ['Invalid email'], name: ['String must contain at least 1 character(s)'] } } + * } + * } + * ``` + */ +export function fromZod( + name: string, + schema: T, + options: SSCollectionOptions = {}, +): ZodCollectionDefinition { + // Validate that it's a Zod schema + if (!isZodSchema(schema)) { + throw new Error( + `Schema passed to fromZod must be a Zod schema. ` + + `Expected an object with '_def' and 'safeParse'. ` + + `For other Standard Schema libraries, use fromStandardSchema instead.`, + ); + } + + // Extract defaults from the Zod schema + const defaults = extractZodDefaults(schema); + + // Create base collection using fromStandardSchema + const baseCollection = fromStandardSchema(name, schema, options); + + // Extend metadata with Zod-specific info + const zodMeta: ZodCollectionMeta = { + ...baseCollection._meta, + zodSchema: schema, + defaults, + isZod: true, + }; + + // Return extended collection definition + const zodCollection: ZodCollectionDefinition = { + ...baseCollection, + _meta: zodMeta, + }; + + return zodCollection; +} + +/** + * Type guard to check if a schema is a Zod schema + */ +export function isZodSchema(value: unknown): value is ZodLike { + return ( + typeof value === 'object' && + value !== null && + '_def' in value && + 'safeParse' in value && + typeof (value as any).safeParse === 'function' + ); +} + +/** + * Type guard to check if a collection definition is a Zod collection + */ +export function isZodCollectionDefinition( + value: unknown, +): value is ZodCollectionDefinition { + return ( + typeof value === 'object' && + value !== null && + (value as any)._brand === 'SSCollectionDefinition' && + (value as any)._meta?.isZod === true + ); +} diff --git a/packages/mizzle-orm/src/index.ts b/packages/mizzle-orm/src/index.ts index 158dafe..9d7d43f 100644 --- a/packages/mizzle-orm/src/index.ts +++ b/packages/mizzle-orm/src/index.ts @@ -24,6 +24,21 @@ export type { ExtractSSUpdate, } from './collection/from-standard-schema'; +// Zod-specific collection factory +export { + fromZod, + isZodSchema, + isZodCollectionDefinition, + extractZodDefaults, + applyZodDefaults, + validateWithZod, + ZodValidationError, +} from './collection/from-zod'; +export type { + ZodCollectionDefinition, + ZodCollectionMeta, +} from './collection/from-zod'; + // Relation factory functions export { lookup, reference, embed } from './collection/relations'; diff --git a/packages/mizzle-orm/src/query/collection-facade.ts b/packages/mizzle-orm/src/query/collection-facade.ts index 6c9d563..978b6ac 100644 --- a/packages/mizzle-orm/src/query/collection-facade.ts +++ b/packages/mizzle-orm/src/query/collection-facade.ts @@ -10,6 +10,8 @@ import type { Filter } from '../types/inference'; import type { Middleware, MiddlewareContext, Operation } from '../types/middleware'; import type { SSCollectionDefinition } from '../collection/from-standard-schema'; import { isSSCollectionDefinition } from '../collection/from-standard-schema'; +import { isZodCollectionDefinition, validateWithZod, applyZodDefaults } from '../collection/from-zod'; +import type { ZodCollectionDefinition } from '../collection/from-zod'; import { SSValidationError, type ValidationIssue } from '../errors/validation-error'; import { generatePublicId } from '../utils/public-id'; import { RelationHelper } from './relations'; @@ -82,10 +84,13 @@ export class CollectionFacade< * Validate data against Standard Schema if this collection uses one * Does nothing for regular field-builder collections (backward compatible) * + * For Zod collections (created with fromZod), uses Zod's native validation + * for better error messages. + * * @param data - The data to validate * @param options - Validation options * @param options.partial - If true, skip validation (for partial updates) - * @throws SSValidationError if validation fails + * @throws SSValidationError or ZodValidationError if validation fails */ private async validateWithStandardSchema( data: unknown, @@ -96,6 +101,15 @@ export class CollectionFacade< return; } + // Use Zod-specific validation for Zod collections (better error formatting) + if (isZodCollectionDefinition(this.collectionDef)) { + const zodCollectionDef = this.collectionDef as unknown as ZodCollectionDefinition; + const schema = zodCollectionDef._meta.zodSchema; + // validateWithZod throws ZodValidationError on failure + validateWithZod(schema, data, options); + return; + } + const ssCollectionDef = this.collectionDef as unknown as SSCollectionDefinition; const schema = ssCollectionDef._schema; @@ -773,9 +787,16 @@ export class CollectionFacade< // Standard Schema collections handle defaults through the schema (e.g., Zod .default()) // Exceptions: publicId and createdAt are generated by the ORM if (isSSCollectionDefinition(this.collectionDef)) { - const result = { ...data }; + let result = { ...data }; const ssMeta = (this.collectionDef as unknown as SSCollectionDefinition)._meta; + // For Zod collections, apply extracted defaults before validation + // This ensures defaults are applied even for deeply nested schemas + if (isZodCollectionDefinition(this.collectionDef)) { + const zodMeta = (this.collectionDef as unknown as ZodCollectionDefinition)._meta; + result = applyZodDefaults(result, zodMeta.defaults); + } + // Generate publicId if configured and not already provided if (ssMeta.publicIdConfig) { const { prefix, field } = ssMeta.publicIdConfig; diff --git a/progress.txt b/progress.txt index 96d8aa6..f579dfb 100644 --- a/progress.txt +++ b/progress.txt @@ -6,7 +6,7 @@ Started: 2026-02-11T21:23:00Z ## Codebase Patterns - Monorepo with turbo, main package at `packages/mizzle-orm` - Build: `pnpm build` → tsup (generates CJS/ESM/DTS) -- Test: `pnpm test` → vitest (230+ tests, now 333) +- Test: `pnpm test` → vitest (230+ tests, now 375) - Typecheck: `pnpm typecheck` → tsc --noEmit - Type files live in `src/types/`, tests in `src//__tests__/` - vitest expectTypeOf available for type-level assertions @@ -159,3 +159,18 @@ Started: 2026-02-11T21:23:00Z - src/collection/__tests__/standard-schema-soft-delete.test.ts (22 new tests) - **Learnings:** Soft delete filtering needs to apply to all read queries (findById, findOne, findMany, count). The filter uses `$or` to match both null and non-existent fields to include non-deleted documents. --- + +## 2026-02-11 22:09 - US-007: Add timestamp support for Standard Schema collections +- Extended `SSCollectionOptions.timestamps` to accept `boolean | { createdAt?: string; updatedAt?: string }` +- Added `SSTimestampsConfig` interface with normalized field names (defaults: 'createdAt', 'updatedAt') +- Added `timestampsConfig` to `SSCollectionMeta` (normalized at factory time in fromStandardSchema) +- Updated `CollectionFacade.applyDefaults` to set createdAt timestamp on insert for SS collections +- Updated `CollectionFacade.applyUpdateTimestamps` to set updatedAt timestamp on update for SS collections +- Exported `SSTimestampsConfig` from index.ts +- Files changed: + - src/collection/from-standard-schema.ts (SSTimestampsConfig, extended options, normalized config) + - src/query/collection-facade.ts (applyDefaults, applyUpdateTimestamps) + - src/index.ts (export SSTimestampsConfig) + - src/collection/__tests__/standard-schema-timestamps.test.ts (20 new tests) +- **Learnings:** Timestamps follow the same normalization pattern as publicId and softDelete configs. createdAt is set on insert only if not already provided (allows override). updatedAt is always set on update operations. +--- From f1abb2da4f9661ef394c7de00b17c5779bdefbcf Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 22:18:46 +0000 Subject: [PATCH 11/12] docs: update progress.txt with US-008 completion --- progress.txt | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/progress.txt b/progress.txt index f579dfb..7d6b715 100644 --- a/progress.txt +++ b/progress.txt @@ -174,3 +174,32 @@ Started: 2026-02-11T21:23:00Z - src/collection/__tests__/standard-schema-timestamps.test.ts (20 new tests) - **Learnings:** Timestamps follow the same normalization pattern as publicId and softDelete configs. createdAt is set on insert only if not already provided (allows override). updatedAt is always set on update operations. --- + +## 2026-02-11 22:19 - US-008: Create fromZod convenience wrapper with defaults extraction +- Created `src/collection/from-zod.ts` with: + - `fromZod(name, zodSchema, options)` factory function wrapping `fromStandardSchema` + - `ZodCollectionDefinition` and `ZodCollectionMeta` types with Zod-specific metadata + - `extractZodDefaults()` to extract `.default()` values from Zod schema + - `applyZodDefaults()` to apply extracted defaults on insert + - `ZodValidationError` class with `format()`, `flatten()`, `getFieldErrors()`, `errorFields` + - `validateWithZod()` for Zod-specific validation with better error messages + - `isZodSchema()` and `isZodCollectionDefinition()` type guards +- Updated `CollectionFacade.validateWithStandardSchema` to use Zod-specific validation for Zod collections +- Updated `CollectionFacade.applyDefaults` to apply extracted Zod defaults before validation +- Exported all Zod utilities from `src/index.ts` +- Files changed: + - src/collection/from-zod.ts (new file) + - src/query/collection-facade.ts (Zod-specific validation and defaults) + - src/index.ts (exports) + - src/collection/__tests__/from-zod.test.ts (51 new tests) +- Tests: 51 tests covering: + - isZodSchema detection + - extractZodDefaults for primitives, objects, arrays, enums + - applyZodDefaults behavior + - validateWithZod success/failure/partial validation + - ZodValidationError formatting methods + - fromZod factory with options + - Type inference + - Integration with CollectionFacade (defaults, validation, publicId, timestamps, softDelete) +- **Learnings:** Zod 4.x uses `_def.type === 'default'` with `defaultValue` directly (not a function), differs from Zod 3.x which uses `_def.typeName === 'ZodDefault'` with function. +--- From 99294a749112eaf8d1d3d4bef4024482e29f31f6 Mon Sep 17 00:00:00 2001 From: Captain Claw Date: Wed, 11 Feb 2026 22:24:22 +0000 Subject: [PATCH 12/12] feat: US-009 - Add documentation and examples for Standard Schema support - Add comprehensive 'Standard Schema Support' section to README.md - Document fromStandardSchema and fromZod functions with examples - Create examples/standard-schema-zod.ts with full working example - Document all options (publicId, softDelete, timestamps) - Include migration guide from field builders to Standard Schema - Add example to examples/README.md - All public APIs already have JSDoc comments from prior stories --- packages/mizzle-orm/README.md | 161 +++++++++ packages/mizzle-orm/examples/README.md | 27 ++ .../examples/standard-schema-zod.ts | 321 ++++++++++++++++++ 3 files changed, 509 insertions(+) create mode 100644 packages/mizzle-orm/examples/standard-schema-zod.ts diff --git a/packages/mizzle-orm/README.md b/packages/mizzle-orm/README.md index 1fc02c1..eff4b36 100644 --- a/packages/mizzle-orm/README.md +++ b/packages/mizzle-orm/README.md @@ -260,6 +260,166 @@ posts[0].author?.organization?.name // string | undefined posts[0].comments[0]?.user?.email // string | undefined ``` +## Standard Schema Support + +Mizzle supports [Standard Schema](https://standardschema.dev/), allowing you to define collections using validation libraries like **Zod**, **Valibot**, or **ArkType** instead of the built-in field builders. + +### Quick Start with Zod + +```typescript +import { z } from 'zod'; +import { mizzle, defineSchema, fromZod } from '@mizzle-dev/orm'; + +// Define schema with Zod - full validation power! +const userSchema = z.object({ + email: z.string().email('Must be a valid email'), + name: z.string().min(1, 'Name is required'), + role: z.enum(['user', 'admin']).default('user'), + age: z.number().int().min(0).optional(), +}); + +// Create collection from Zod schema +const users = fromZod('users', userSchema, { + publicId: 'user', // Generates 'user_abc123' style IDs + softDelete: true, // Soft delete support + timestamps: true, // Auto createdAt/updatedAt +}); + +// Type inference works automatically +type UserDoc = typeof users.$inferDocument; +// { _id: ObjectId; email: string; name: string; role: 'user' | 'admin'; age?: number; id: string; createdAt: Date; updatedAt: Date } + +type UserInsert = typeof users.$inferInsert; +// { email: string; name: string; role?: 'user' | 'admin'; age?: number } (defaults are optional) + +// Use in your schema +const schema = defineSchema({ users }); +const db = await mizzle({ uri: '...', dbName: 'myapp', schema }); + +// Defaults are applied automatically +const user = await db().users.create({ + email: 'alice@example.com', + name: 'Alice', + // role defaults to 'user' +}); +``` + +### fromStandardSchema vs fromZod + +| Function | Use Case | +|----------|----------| +| `fromZod` | Zod schemas - extracts `.default()` values, better error formatting | +| `fromStandardSchema` | Any Standard Schema library (Valibot, ArkType, etc.) | + +```typescript +import { fromStandardSchema } from '@mizzle-dev/orm'; +import * as v from 'valibot'; + +// Works with Valibot too +const postSchema = v.object({ + title: v.string(), + content: v.string(), +}); + +const posts = fromStandardSchema('posts', postSchema); +``` + +### Validation Errors + +Validation errors include detailed information: + +```typescript +import { ZodValidationError, SSValidationError } from '@mizzle-dev/orm'; + +try { + await db().users.create({ email: 'bad', name: '' }); +} catch (error) { + if (error instanceof ZodValidationError) { + // Zod-specific formatting + console.log(error.flatten()); + // { formErrors: [], fieldErrors: { email: ['Must be a valid email'], name: ['Name is required'] } } + + console.log(error.errorFields); // ['email', 'name'] + console.log(error.getFieldErrors('email')); // ['Must be a valid email'] + } + + if (error instanceof SSValidationError) { + // Standard Schema format (works with any library) + console.log(error.issues); + // [{ message: '...', path: ['email'] }, ...] + } +} +``` + +### Collection Options + +All options work with Standard Schema collections: + +```typescript +const users = fromZod('users', userSchema, { + // Public ID generation + publicId: 'user', // prefix string + publicId: { prefix: 'usr', field: 'publicId' }, // custom field + + // Soft delete + softDelete: true, // uses 'deletedAt' field + softDelete: { field: 'removedAt' }, // custom field + + // Timestamps + timestamps: true, // uses 'createdAt' and 'updatedAt' + timestamps: { createdAt: 'created', updatedAt: 'modified' }, // custom fields + + // Middlewares still work + middlewares: [loggingMiddleware()], +}); +``` + +### Migration from Field Builders + +Standard Schema collections work alongside field builder collections: + +```typescript +// Before (field builders) +import { mongoCollection, string, number } from '@mizzle-dev/orm'; + +const users = mongoCollection('users', { + email: string(), + name: string(), + age: number().optional(), +}); + +// After (Zod) +import { z } from 'zod'; +import { fromZod } from '@mizzle-dev/orm'; + +const userSchema = z.object({ + email: z.string().email(), + name: z.string(), + age: z.number().optional(), +}); + +const users = fromZod('users', userSchema); + +// Mix both in the same schema! +const schema = defineSchema({ + users, // Standard Schema collection + posts, // Field builder collection (if you have one) +}); +``` + +**Benefits of Standard Schema:** +- Powerful validation (refinements, transforms, custom error messages) +- Reuse schemas across your app (forms, APIs, etc.) +- Ecosystem compatibility (Zod is widely used) +- Runtime type safety with detailed errors + +**When to use field builders:** +- Simple schemas without complex validation +- Minimal dependencies preferred +- Legacy code already using field builders + +See [examples/standard-schema-zod.ts](./examples/standard-schema-zod.ts) for a complete working example. + ## When to Use Each Relation Type | Scenario | Best Choice | Why | @@ -275,6 +435,7 @@ posts[0].comments[0]?.user?.email // string | undefined Check out the [examples directory](./examples) for comprehensive demonstrations: - [quickstart.ts](./examples/quickstart.ts) - 5-minute tutorial +- [standard-schema-zod.ts](./examples/standard-schema-zod.ts) - Using Zod schemas for collections - [blog-with-embeds.ts](./examples/blog-with-embeds.ts) - Blog platform with auto-updating embeds - [ecommerce-orders.ts](./examples/ecommerce-orders.ts) - Order system with historical snapshots - [mizzle-api-example.ts](./examples/mizzle-api-example.ts) - Advanced usage patterns diff --git a/packages/mizzle-orm/examples/README.md b/packages/mizzle-orm/examples/README.md index 0563cd5..686b0cb 100644 --- a/packages/mizzle-orm/examples/README.md +++ b/packages/mizzle-orm/examples/README.md @@ -25,6 +25,33 @@ const posts = await db().posts.findMany({}, { - Using context for multi-tenancy - Transactions +### [standard-schema-zod.ts](./standard-schema-zod.ts) +**Standard Schema with Zod** - Use Zod schemas instead of field builders. + +```typescript +import { z } from 'zod'; +import { fromZod } from '@mizzle-dev/orm'; + +const userSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), + role: z.enum(['user', 'admin']).default('user'), +}); + +const users = fromZod('users', userSchema, { + publicId: 'user', + timestamps: true, +}); +``` + +**Covers:** +- Defining collections with Zod schemas +- Automatic default extraction +- Validation with detailed error messages +- Transforms support +- Public ID, soft delete, and timestamps +- Migration guide from field builders + ## Comprehensive Examples ### [mizzle-api-example.ts](./mizzle-api-example.ts) diff --git a/packages/mizzle-orm/examples/standard-schema-zod.ts b/packages/mizzle-orm/examples/standard-schema-zod.ts new file mode 100644 index 0000000..9bf6250 --- /dev/null +++ b/packages/mizzle-orm/examples/standard-schema-zod.ts @@ -0,0 +1,321 @@ +/** + * Standard Schema Support with Zod - Example + * + * This example demonstrates how to use Zod schemas to define collections + * instead of Mizzle's built-in field builders. This approach gives you: + * + * - Full Zod validation with transforms, refinements, and custom errors + * - Automatic default extraction and application + * - Compatibility with any Standard Schema library (Zod, Valibot, ArkType) + * - Perfect TypeScript inference from your schemas + */ + +import { z } from 'zod'; +import { mizzle, defineSchema, fromZod, ZodValidationError } from '../src'; + +// ============================================================ +// Step 1: Define your schemas with Zod +// ============================================================ + +// User schema with validation rules and defaults +const userSchema = z.object({ + email: z.string().email('Must be a valid email'), + name: z.string().min(1, 'Name is required').max(100), + role: z.enum(['user', 'admin', 'moderator']).default('user'), + age: z.number().int().min(0).max(150).optional(), + settings: z + .object({ + theme: z.enum(['light', 'dark']).default('light'), + notifications: z.boolean().default(true), + locale: z.string().default('en-US'), + }) + .default({}), + bio: z.string().max(500).optional(), +}); + +// Post schema with transforms +const postSchema = z.object({ + title: z.string().min(1).max(200), + slug: z + .string() + .transform((s) => s.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')), + content: z.string().min(1), + status: z.enum(['draft', 'published', 'archived']).default('draft'), + tags: z.array(z.string()).default([]), + views: z.number().int().min(0).default(0), + authorId: z.string(), // ObjectId as string for this example +}); + +// Comment schema - simple nested structure +const commentSchema = z.object({ + postId: z.string(), + authorId: z.string(), + content: z.string().min(1).max(1000), + likes: z.number().int().min(0).default(0), +}); + +// ============================================================ +// Step 2: Create collections using fromZod +// ============================================================ + +// fromZod provides Zod-specific features: +// - Automatic default extraction and application +// - Better error formatting with flatten() and format() +// - Access to the original Zod schema for advanced use +const users = fromZod('users', userSchema, { + publicId: 'user', // Generates 'user_abc123' style IDs + timestamps: true, // Adds createdAt/updatedAt +}); + +const posts = fromZod('posts', postSchema, { + publicId: 'post', + softDelete: true, // Soft delete with deletedAt field + timestamps: true, +}); + +const comments = fromZod('comments', commentSchema, { + timestamps: { createdAt: 'created', updatedAt: 'modified' }, // Custom field names +}); + +// ============================================================ +// Step 3: Type inference works automatically +// ============================================================ + +// These types are inferred from your Zod schemas - no manual type definitions! +type UserDocument = typeof users.$inferDocument; +// { _id: ObjectId; email: string; name: string; role: 'user' | 'admin' | 'moderator'; ... } + +type UserInsert = typeof users.$inferInsert; +// { email: string; name: string; role?: 'user' | 'admin' | 'moderator'; ... } (defaults optional) + +type PostDocument = typeof posts.$inferDocument; +// { _id: ObjectId; title: string; slug: string; content: string; ... } + +// Log types for documentation (these are compile-time only) +const _userExample: UserDocument = null as any; +const _insertExample: UserInsert = null as any; +const _postExample: PostDocument = null as any; + +// ============================================================ +// Step 4: Connect and use +// ============================================================ + +async function main() { + const schema = defineSchema({ users, posts, comments }); + + const db = await mizzle({ + uri: 'mongodb://localhost:27017', + dbName: 'standard-schema-example', + schema, + }); + + try { + // ============================================================ + // Creating documents - defaults are applied automatically + // ============================================================ + + console.log('Creating user with defaults...'); + + // Only required fields needed - defaults are applied + const user = await db().users.create({ + email: 'alice@example.com', + name: 'Alice', + }); + + console.log('Created user:', { + id: user.id, // Public ID: 'user_abc123' + email: user.email, + name: user.name, + role: user.role, // 'user' (default applied) + settings: user.settings, // { theme: 'light', notifications: true, locale: 'en-US' } + createdAt: user.createdAt, // Auto timestamp + }); + + // ============================================================ + // Validation in action + // ============================================================ + + console.log('\nTesting validation...'); + + try { + await db().users.create({ + email: 'invalid-email', // Bad email + name: '', // Empty name + }); + } catch (error) { + if (error instanceof ZodValidationError) { + console.log('Validation failed (expected):'); + console.log(' Flattened:', error.flatten()); + // { formErrors: [], fieldErrors: { email: ['Must be a valid email'], name: ['Name is required'] } } + + console.log(' Error fields:', error.errorFields); + // ['email', 'name'] + + console.log(' Email errors:', error.getFieldErrors('email')); + // ['Must be a valid email'] + } + } + + // ============================================================ + // Transforms work on insert + // ============================================================ + + console.log('\nCreating post with transforms...'); + + const post = await db().posts.create({ + title: 'My First Post!', + slug: 'My First Post!', // Will be transformed to 'my-first-post' + content: 'This is the content of my first post.', + authorId: user._id.toString(), + }); + + console.log('Created post:', { + id: post.id, // Public ID: 'post_xyz789' + title: post.title, + slug: post.slug, // 'my-first-post' (transformed) + status: post.status, // 'draft' (default) + views: post.views, // 0 (default) + tags: post.tags, // [] (default) + }); + + // ============================================================ + // Partial updates with validation + // ============================================================ + + console.log('\nUpdating post...'); + + // Partial updates only validate the fields you're changing + await db().posts.updateOne( + { _id: post._id }, + { + status: 'published', + views: 42, + } + ); + + const updatedPost = await db().posts.findOne({ _id: post._id }); + console.log('Updated post status:', updatedPost?.status); // 'published' + console.log('Updated views:', updatedPost?.views); // 42 + + // ============================================================ + // Soft delete in action + // ============================================================ + + console.log('\nTesting soft delete...'); + + await db().posts.softDelete(post._id); + + // findMany excludes soft-deleted documents by default + const visiblePosts = await db().posts.findMany({}); + console.log('Visible posts after soft delete:', visiblePosts.length); // 0 + + // Restore the post + await db().posts.restore(post._id); + + const restoredPosts = await db().posts.findMany({}); + console.log('Visible posts after restore:', restoredPosts.length); // 1 + + // ============================================================ + // Custom timestamp field names + // ============================================================ + + console.log('\nCreating comment with custom timestamp fields...'); + + const comment = await db().comments.create({ + postId: post._id.toString(), + authorId: user._id.toString(), + content: 'Great post!', + }); + + console.log('Comment timestamps:', { + created: (comment as any).created, // Custom createdAt field name + modified: (comment as any).modified, // Custom updatedAt field name + }); + + // ============================================================ + // Working with the Zod schema directly + // ============================================================ + + console.log('\nDirect Zod schema access...'); + + // You can still use the Zod schema for other purposes + const zodSchema = posts._meta.zodSchema; + const defaults = posts._meta.defaults; + + console.log('Extracted defaults:', defaults); + // { status: 'draft', tags: [], views: 0 } + + // Validate data outside of database operations + const parseResult = zodSchema.safeParse({ + title: 'Test', + slug: 'test', + content: 'Content', + authorId: '123', + }); + + if (parseResult.success) { + console.log('Direct validation passed'); + } + + // ============================================================ + // Summary + // ============================================================ + + console.log('\n✅ Standard Schema example complete!'); + console.log('\nKey features demonstrated:'); + console.log(' - Zod schema validation with custom messages'); + console.log(' - Automatic default extraction and application'); + console.log(' - Transform support (slug normalization)'); + console.log(' - Public ID generation'); + console.log(' - Soft delete support'); + console.log(' - Custom timestamp field names'); + console.log(' - ZodValidationError with flatten() and format()'); + console.log(' - Perfect TypeScript inference from schemas'); + } finally { + await db.close(); + } +} + +main().catch(console.error); + +/** + * Migration Guide: Field Builders → Standard Schema + * + * Before (field builders): + * ```typescript + * import { mongoCollection, string, number } from '@mizzle-dev/orm'; + * + * const users = mongoCollection('users', { + * email: string(), + * name: string(), + * age: number().optional(), + * }); + * ``` + * + * After (Zod): + * ```typescript + * import { z } from 'zod'; + * import { fromZod } from '@mizzle-dev/orm'; + * + * const userSchema = z.object({ + * email: z.string().email(), + * name: z.string(), + * age: z.number().optional(), + * }); + * + * const users = fromZod('users', userSchema); + * ``` + * + * Benefits of Standard Schema approach: + * - More powerful validation (refinements, transforms, custom errors) + * - Reuse schemas across your app (forms, API validation, etc.) + * - Ecosystem compatibility (Zod is widely adopted) + * - Runtime type checking with detailed errors + * + * When to stick with field builders: + * - Simple schemas without complex validation + * - When you want minimal dependencies + * - Legacy codebases already using field builders + * + * Both approaches work in the same schema - mix and match as needed! + */