@prisma-next/contract 0.3.0-dev.14 → 0.3.0-dev.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +42 -6
  3. package/dist/hashing-CVS9sXxd.mjs +215 -0
  4. package/dist/hashing-CVS9sXxd.mjs.map +1 -0
  5. package/dist/hashing.d.mts +38 -0
  6. package/dist/hashing.d.mts.map +1 -0
  7. package/dist/hashing.mjs +3 -0
  8. package/dist/testing.d.mts +28 -0
  9. package/dist/testing.d.mts.map +1 -0
  10. package/dist/testing.mjs +56 -0
  11. package/dist/testing.mjs.map +1 -0
  12. package/dist/types-D-iOS0Ks.d.mts +511 -0
  13. package/dist/types-D-iOS0Ks.d.mts.map +1 -0
  14. package/dist/types-DokLaU9G.mjs +30 -0
  15. package/dist/types-DokLaU9G.mjs.map +1 -0
  16. package/dist/types.d.mts +2 -0
  17. package/dist/types.mjs +3 -0
  18. package/dist/validate-contract.d.mts +35 -0
  19. package/dist/validate-contract.d.mts.map +1 -0
  20. package/dist/validate-contract.mjs +61 -0
  21. package/dist/validate-contract.mjs.map +1 -0
  22. package/dist/validate-domain-CTQiBiei.mjs +84 -0
  23. package/dist/validate-domain-CTQiBiei.mjs.map +1 -0
  24. package/dist/validate-domain.d.mts +24 -0
  25. package/dist/validate-domain.d.mts.map +1 -0
  26. package/dist/validate-domain.mjs +3 -0
  27. package/package.json +25 -25
  28. package/schemas/data-contract-document-v1.json +5 -5
  29. package/src/canonicalization.ts +286 -0
  30. package/src/contract-types.ts +54 -0
  31. package/src/domain-types.ts +85 -0
  32. package/src/exports/hashing.ts +6 -0
  33. package/src/exports/testing.ts +1 -0
  34. package/src/exports/types.ts +54 -7
  35. package/src/exports/validate-contract.ts +5 -0
  36. package/src/exports/validate-domain.ts +6 -0
  37. package/src/hashing.ts +69 -0
  38. package/src/testing-factories.ts +93 -0
  39. package/src/types.ts +292 -35
  40. package/src/validate-contract.ts +93 -0
  41. package/src/validate-domain.ts +205 -0
  42. package/dist/exports/framework-components.d.ts +0 -3
  43. package/dist/exports/framework-components.d.ts.map +0 -1
  44. package/dist/exports/framework-components.js +0 -24
  45. package/dist/exports/framework-components.js.map +0 -1
  46. package/dist/exports/ir.d.ts +0 -2
  47. package/dist/exports/ir.d.ts.map +0 -1
  48. package/dist/exports/ir.js +0 -35
  49. package/dist/exports/ir.js.map +0 -1
  50. package/dist/exports/pack-manifest-types.d.ts +0 -2
  51. package/dist/exports/pack-manifest-types.d.ts.map +0 -1
  52. package/dist/exports/pack-manifest-types.js +0 -1
  53. package/dist/exports/pack-manifest-types.js.map +0 -1
  54. package/dist/exports/types.d.ts +0 -3
  55. package/dist/exports/types.d.ts.map +0 -1
  56. package/dist/exports/types.js +0 -8
  57. package/dist/exports/types.js.map +0 -1
  58. package/dist/framework-components.d.ts +0 -408
  59. package/dist/framework-components.d.ts.map +0 -1
  60. package/dist/ir.d.ts +0 -76
  61. package/dist/ir.d.ts.map +0 -1
  62. package/dist/types.d.ts +0 -222
  63. package/dist/types.d.ts.map +0 -1
  64. package/src/exports/framework-components.ts +0 -26
  65. package/src/exports/ir.ts +0 -1
  66. package/src/exports/pack-manifest-types.ts +0 -6
  67. package/src/framework-components.ts +0 -525
  68. package/src/ir.ts +0 -113
package/src/types.ts CHANGED
@@ -1,16 +1,88 @@
1
1
  import type { OperationRegistry } from '@prisma-next/operations';
2
- import type { ContractIR } from './ir';
2
+ import type { Contract } from './contract-types';
3
+ import type { DomainModel } from './domain-types';
3
4
 
4
- export interface ContractBase {
5
+ /**
6
+ * Unique symbol used as the key for branding types.
7
+ */
8
+ export const $: unique symbol = Symbol('__prisma_next_brand__');
9
+
10
+ /**
11
+ * A helper type to brand a given type with a unique identifier.
12
+ *
13
+ * @template TKey Text used as the brand key.
14
+ * @template TValue Optional value associated with the brand key. Defaults to `true`.
15
+ */
16
+ export type Brand<TKey extends string | number | symbol, TValue = true> = {
17
+ [$]: {
18
+ [K in TKey]: TValue;
19
+ };
20
+ };
21
+
22
+ /**
23
+ * Context passed to type renderers during contract.d.ts generation.
24
+ */
25
+ export interface RenderTypeContext {
26
+ /** The name of the CodecTypes type alias (typically 'CodecTypes') */
27
+ readonly codecTypesName: string;
28
+ }
29
+
30
+ /**
31
+ * Base type for storage contract hashes.
32
+ * Emitted contract.d.ts files use this with the hash value as a type parameter:
33
+ * `type StorageHash = StorageHashBase<'sha256:abc123...'>`
34
+ */
35
+ export type StorageHashBase<THash extends string> = THash & Brand<'StorageHash'>;
36
+
37
+ /**
38
+ * Base type for execution contract hashes.
39
+ * Emitted contract.d.ts files use this with the hash value as a type parameter:
40
+ * `type ExecutionHash = ExecutionHashBase<'sha256:def456...'>`
41
+ */
42
+ export type ExecutionHashBase<THash extends string> = THash & Brand<'ExecutionHash'>;
43
+
44
+ export function coreHash<const T extends string>(value: T): StorageHashBase<T> {
45
+ return value as StorageHashBase<T>;
46
+ }
47
+
48
+ /**
49
+ * Base type for profile contract hashes.
50
+ * Emitted contract.d.ts files use this with the hash value as a type parameter:
51
+ * `type ProfileHash = ProfileHashBase<'sha256:def456...'>`
52
+ */
53
+ export type ProfileHashBase<THash extends string> = THash & Brand<'ProfileHash'>;
54
+
55
+ export function profileHash<const T extends string>(value: T): ProfileHashBase<T> {
56
+ return value as ProfileHashBase<T>;
57
+ }
58
+
59
+ /**
60
+ * Base type for family-specific storage blocks.
61
+ * Family storage types (SqlStorage, MongoStorage, etc.) extend this to carry the
62
+ * storage hash alongside family-specific data (tables, collections, etc.).
63
+ */
64
+ export interface StorageBase<THash extends string = string> {
65
+ readonly storageHash: StorageHashBase<THash>;
66
+ }
67
+
68
+ export interface ContractBase<
69
+ TStorageHash extends StorageHashBase<string> = StorageHashBase<string>,
70
+ TExecutionHash extends ExecutionHashBase<string> = ExecutionHashBase<string>,
71
+ TProfileHash extends ProfileHashBase<string> = ProfileHashBase<string>,
72
+ > {
5
73
  readonly schemaVersion: string;
6
74
  readonly target: string;
7
75
  readonly targetFamily: string;
8
- readonly coreHash: string;
9
- readonly profileHash?: string;
76
+ readonly storageHash: TStorageHash;
77
+ readonly executionHash?: TExecutionHash | undefined;
78
+ readonly profileHash?: TProfileHash | undefined;
10
79
  readonly capabilities: Record<string, Record<string, boolean>>;
11
80
  readonly extensionPacks: Record<string, unknown>;
12
81
  readonly meta: Record<string, unknown>;
13
82
  readonly sources: Record<string, Source>;
83
+ readonly execution?: ExecutionSection;
84
+ readonly roots: Record<string, string>;
85
+ readonly models: Record<string, DomainModel>;
14
86
  }
15
87
 
16
88
  export interface FieldType {
@@ -20,6 +92,78 @@ export interface FieldType {
20
92
  readonly properties?: Record<string, FieldType>;
21
93
  }
22
94
 
95
+ export type GeneratedValueSpec = {
96
+ readonly id: string;
97
+ readonly params?: Record<string, unknown>;
98
+ };
99
+
100
+ export type JsonPrimitive = string | number | boolean | null;
101
+
102
+ export type JsonValue =
103
+ | JsonPrimitive
104
+ | { readonly [key: string]: JsonValue }
105
+ | readonly JsonValue[];
106
+
107
+ export type TaggedBigInt = { readonly $type: 'bigint'; readonly value: string };
108
+
109
+ export function isTaggedBigInt(value: unknown): value is TaggedBigInt {
110
+ return (
111
+ typeof value === 'object' &&
112
+ value !== null &&
113
+ (value as { $type?: unknown }).$type === 'bigint' &&
114
+ typeof (value as { value?: unknown }).value === 'string'
115
+ );
116
+ }
117
+
118
+ export function bigintJsonReplacer(_key: string, value: unknown): unknown {
119
+ if (typeof value === 'bigint') {
120
+ return { $type: 'bigint', value: value.toString() } satisfies TaggedBigInt;
121
+ }
122
+ return value;
123
+ }
124
+
125
+ export type TaggedRaw = { readonly $type: 'raw'; readonly value: JsonValue };
126
+
127
+ export function isTaggedRaw(value: unknown): value is TaggedRaw {
128
+ return (
129
+ typeof value === 'object' &&
130
+ value !== null &&
131
+ (value as { $type?: unknown }).$type === 'raw' &&
132
+ 'value' in (value as object)
133
+ );
134
+ }
135
+
136
+ export type TaggedLiteralValue = TaggedBigInt | TaggedRaw;
137
+
138
+ export type ColumnDefaultLiteralValue = JsonValue | TaggedLiteralValue;
139
+
140
+ export type ColumnDefaultLiteralInputValue = ColumnDefaultLiteralValue | bigint | Date;
141
+
142
+ export type ColumnDefault =
143
+ | {
144
+ readonly kind: 'literal';
145
+ readonly value: ColumnDefaultLiteralInputValue;
146
+ }
147
+ | { readonly kind: 'function'; readonly expression: string };
148
+
149
+ export type ExecutionMutationDefaultValue = {
150
+ readonly kind: 'generator';
151
+ readonly id: GeneratedValueSpec['id'];
152
+ readonly params?: Record<string, unknown>;
153
+ };
154
+
155
+ export type ExecutionMutationDefault = {
156
+ readonly ref: { readonly table: string; readonly column: string };
157
+ readonly onCreate?: ExecutionMutationDefaultValue;
158
+ readonly onUpdate?: ExecutionMutationDefaultValue;
159
+ };
160
+
161
+ export type ExecutionSection = {
162
+ readonly mutations: {
163
+ readonly defaults: ReadonlyArray<ExecutionMutationDefault>;
164
+ };
165
+ };
166
+
23
167
  export interface Source {
24
168
  readonly readOnly: boolean;
25
169
  readonly projection: Record<string, FieldType>;
@@ -42,7 +186,7 @@ export type Expr =
42
186
  export interface DocCollection {
43
187
  readonly name: string;
44
188
  readonly id?: {
45
- readonly strategy: 'auto' | 'client' | 'uuid' | 'cuid' | 'objectId';
189
+ readonly strategy: 'auto' | 'client' | 'uuid' | 'objectId';
46
190
  };
47
191
  readonly fields: Record<string, FieldType>;
48
192
  readonly indexes?: ReadonlyArray<DocIndex>;
@@ -55,7 +199,11 @@ export interface DocumentStorage {
55
199
  };
56
200
  }
57
201
 
58
- export interface DocumentContract extends ContractBase {
202
+ export interface DocumentContract<
203
+ TStorageHash extends StorageHashBase<string> = StorageHashBase<string>,
204
+ TExecutionHash extends ExecutionHashBase<string> = ExecutionHashBase<string>,
205
+ TProfileHash extends ProfileHashBase<string> = ProfileHashBase<string>,
206
+ > extends ContractBase<TStorageHash, TExecutionHash, TProfileHash> {
59
207
  // Accept string to work with JSON imports; runtime validation ensures 'document'
60
208
  readonly targetFamily: string;
61
209
  readonly storage: DocumentStorage;
@@ -68,7 +216,7 @@ export interface ParamDescriptor {
68
216
  readonly codecId?: string;
69
217
  readonly nativeType?: string;
70
218
  readonly nullable?: boolean;
71
- readonly source: 'dsl' | 'raw';
219
+ readonly source: 'dsl' | 'raw' | 'lane';
72
220
  readonly refs?: { table: string; column: string };
73
221
  }
74
222
 
@@ -85,7 +233,7 @@ export interface PlanRefs {
85
233
  export interface PlanMeta {
86
234
  readonly target: string;
87
235
  readonly targetFamily?: string;
88
- readonly coreHash: string;
236
+ readonly storageHash: string;
89
237
  readonly profileHash?: string;
90
238
  readonly lane: string;
91
239
  readonly annotations?: {
@@ -151,7 +299,7 @@ export function isDocumentContract(contract: unknown): contract is DocumentContr
151
299
  * Represents the current contract identity for a database.
152
300
  */
153
301
  export interface ContractMarkerRecord {
154
- readonly coreHash: string;
302
+ readonly storageHash: string;
155
303
  readonly profileHash: string;
156
304
  readonly contractJson: unknown | null;
157
305
  readonly canonicalVersion: number | null;
@@ -180,6 +328,50 @@ export interface ValidationContext {
180
328
  readonly codecTypeImports?: ReadonlyArray<TypesImportSpec>;
181
329
  readonly operationTypeImports?: ReadonlyArray<TypesImportSpec>;
182
330
  readonly extensionIds?: ReadonlyArray<string>;
331
+ /**
332
+ * Parameterized codec descriptors collected from adapters and extensions.
333
+ * Map of codecId → descriptor for quick lookup during type generation.
334
+ */
335
+ readonly parameterizedCodecs?: Map<string, ParameterizedCodecDescriptor>;
336
+ }
337
+
338
+ /**
339
+ * Context for rendering parameterized types during contract.d.ts generation.
340
+ * Passed to type renderers so they can reference CodecTypes by name.
341
+ */
342
+ export interface TypeRenderContext {
343
+ readonly codecTypesName: string;
344
+ }
345
+
346
+ /**
347
+ * A normalized type renderer for parameterized codecs.
348
+ * This is the interface expected by TargetFamilyHook.generateContractTypes.
349
+ */
350
+ export interface TypeRenderEntry {
351
+ readonly codecId: string;
352
+ readonly render: (params: Record<string, unknown>, ctx: TypeRenderContext) => string;
353
+ }
354
+
355
+ /**
356
+ * Additional options for generateContractTypes.
357
+ */
358
+ export interface GenerateContractTypesOptions {
359
+ /**
360
+ * Normalized parameterized type renderers, keyed by codecId.
361
+ * When a column has typeParams and a renderer exists for its codecId,
362
+ * the renderer is called to produce the TypeScript type expression.
363
+ */
364
+ readonly parameterizedRenderers?: Map<string, TypeRenderEntry>;
365
+ /**
366
+ * Type imports for parameterized codecs.
367
+ * These are merged with codec and operation type imports in contract.d.ts.
368
+ */
369
+ readonly parameterizedTypeImports?: ReadonlyArray<TypesImportSpec>;
370
+ /**
371
+ * Query operation type imports for the query builder.
372
+ * Flat operation signatures keyed by operation name, emitted as standalone QueryOperationTypes.
373
+ */
374
+ readonly queryOperationTypeImports?: ReadonlyArray<TypesImportSpec>;
183
375
  }
184
376
 
185
377
  /**
@@ -191,52 +383,117 @@ export interface TargetFamilyHook {
191
383
 
192
384
  /**
193
385
  * Validates that all type IDs in the contract come from referenced extension packs.
194
- * @param ir - Contract IR to validate
386
+ * @param contract - Contract to validate
195
387
  * @param ctx - Validation context with operation registry and extension IDs
196
388
  */
197
- validateTypes(ir: ContractIR, ctx: ValidationContext): void;
389
+ validateTypes(contract: Contract, ctx: ValidationContext): void;
198
390
 
199
391
  /**
200
392
  * Validates family-specific contract structure.
201
- * @param ir - Contract IR to validate
393
+ * @param contract - Contract to validate
202
394
  */
203
- validateStructure(ir: ContractIR): void;
395
+ validateStructure(contract: Contract): void;
204
396
 
205
397
  /**
206
398
  * Generates contract.d.ts file content.
207
- * @param ir - Contract IR
399
+ * @param contract - Contract
208
400
  * @param codecTypeImports - Array of codec type import specs
209
401
  * @param operationTypeImports - Array of operation type import specs
402
+ * @param hashes - Contract hash values (storageHash, executionHash, profileHash)
403
+ * @param options - Additional options including parameterized type renderers
210
404
  * @returns Generated TypeScript type definitions as string
211
405
  */
212
406
  generateContractTypes(
213
- ir: ContractIR,
407
+ contract: Contract,
214
408
  codecTypeImports: ReadonlyArray<TypesImportSpec>,
215
409
  operationTypeImports: ReadonlyArray<TypesImportSpec>,
410
+ hashes: {
411
+ readonly storageHash: string;
412
+ readonly executionHash?: string;
413
+ readonly profileHash: string;
414
+ },
415
+ options?: GenerateContractTypesOptions,
216
416
  ): string;
217
417
  }
218
418
 
219
- // Extension pack manifest types - moved from @prisma-next/core-control-plane to shared location
220
- export type ArgSpecManifest =
221
- | { readonly kind: 'typeId'; readonly type: string }
222
- | { readonly kind: 'param' }
223
- | { readonly kind: 'literal' };
419
+ // ============================================================================
420
+ // Parameterized Codec Descriptor Types
421
+ // ============================================================================
422
+ //
423
+ // Types for codecs that support type parameters (e.g., Vector<1536>, Decimal<2>).
424
+ // These enable precise TypeScript types for parameterized columns without
425
+ // coupling the SQL family emitter to specific adapter codec IDs.
426
+ //
427
+ // ============================================================================
224
428
 
225
- export type ReturnSpecManifest =
226
- | { readonly kind: 'typeId'; readonly type: string }
227
- | { readonly kind: 'builtin'; readonly type: 'number' | 'boolean' | 'string' };
429
+ /**
430
+ * Declarative type renderer that produces a TypeScript type expression.
431
+ *
432
+ * Renderers can be:
433
+ * - A template string with `{{paramName}}` placeholders (e.g., `Vector<{{length}}>`)
434
+ * - A function that receives typeParams and context and returns a type expression
435
+ *
436
+ * **Prefer template strings** for most cases:
437
+ * - Templates are JSON-serializable (safe for pack-ref metadata)
438
+ * - Templates can be statically analyzed by tooling
439
+ *
440
+ * Function renderers are allowed but have tradeoffs:
441
+ * - Require runtime execution during emission (the emitter runs code)
442
+ * - Not JSON-serializable (can't be stored in contract.json)
443
+ * - The emitted artifacts (contract.json, contract.d.ts) still contain no
444
+ * executable code - this constraint applies to outputs, not the emission process
445
+ */
446
+ export type TypeRenderer =
447
+ | string
448
+ | ((params: Record<string, unknown>, ctx: RenderTypeContext) => string);
228
449
 
229
- export interface LoweringSpecManifest {
230
- readonly targetFamily: 'sql';
231
- readonly strategy: 'infix' | 'function';
232
- readonly template: string;
233
- }
450
+ /**
451
+ * Descriptor for a codec that supports type parameters.
452
+ *
453
+ * Parameterized codecs allow columns to carry additional metadata (typeParams)
454
+ * that affects the generated TypeScript types. For example:
455
+ * - A vector codec can use `{ length: 1536 }` to generate `Vector<1536>`
456
+ * - A decimal codec can use `{ precision: 10, scale: 2 }` to generate `Decimal<10, 2>`
457
+ *
458
+ * The SQL family emitter uses these descriptors to generate precise types
459
+ * without hard-coding knowledge of specific codec IDs.
460
+ *
461
+ * @example
462
+ * ```typescript
463
+ * const vectorCodecDescriptor: ParameterizedCodecDescriptor = {
464
+ * codecId: 'pg/vector@1',
465
+ * outputTypeRenderer: 'Vector<{{length}}>',
466
+ * // Optional: paramsSchema for runtime validation
467
+ * };
468
+ * ```
469
+ */
470
+ export interface ParameterizedCodecDescriptor {
471
+ /** The codec ID this descriptor applies to (e.g., 'pg/vector@1') */
472
+ readonly codecId: string;
473
+
474
+ /**
475
+ * Renderer for the output (read) type.
476
+ * Can be a template string or function.
477
+ *
478
+ * This is the primary renderer used by SQL emission to generate
479
+ * model field types in contract.d.ts.
480
+ */
481
+ readonly outputTypeRenderer: TypeRenderer;
234
482
 
235
- export interface OperationManifest {
236
- readonly for: string;
237
- readonly method: string;
238
- readonly args: ReadonlyArray<ArgSpecManifest>;
239
- readonly returns: ReturnSpecManifest;
240
- readonly lowering: LoweringSpecManifest;
241
- readonly capabilities?: ReadonlyArray<string>;
483
+ /**
484
+ * Optional renderer for the input (write) type.
485
+ * If not provided, outputTypeRenderer is used for both.
486
+ *
487
+ * **Reserved for future use**: Currently, SQL emission only uses
488
+ * outputTypeRenderer. This field is defined for future support of
489
+ * asymmetric codecs where input and output types differ (e.g., a
490
+ * codec that accepts `string | number` but always returns `number`).
491
+ */
492
+ readonly inputTypeRenderer?: TypeRenderer;
493
+
494
+ /**
495
+ * Optional import spec for types used by this codec's renderers.
496
+ * The emitter will add this import to contract.d.ts.
497
+ */
498
+ readonly typesImport?: TypesImportSpec;
242
499
  }
@@ -0,0 +1,93 @@
1
+ import { type } from 'arktype';
2
+ import type { Contract } from './contract-types';
3
+ import type { DomainContractShape, DomainValidationResult } from './validate-domain';
4
+ import { validateContractDomain } from './validate-domain';
5
+
6
+ /**
7
+ * Family-provided storage validator.
8
+ * SQL validates tables/columns/FKs; Mongo validates collections/embedding.
9
+ */
10
+ export type StorageValidator = (contract: Contract) => void;
11
+
12
+ export interface ValidateContractResult {
13
+ readonly warnings: string[];
14
+ }
15
+
16
+ const ContractSchema = type({
17
+ target: 'string',
18
+ targetFamily: 'string',
19
+ roots: 'Record<string, string>',
20
+ models: 'Record<string, unknown>',
21
+ storage: 'Record<string, unknown>',
22
+ capabilities: 'Record<string, Record<string, boolean>>',
23
+ extensionPacks: 'Record<string, unknown>',
24
+ meta: 'Record<string, unknown>',
25
+ 'execution?': {
26
+ 'executionHash?': 'string',
27
+ mutations: {
28
+ defaults: 'unknown[]',
29
+ },
30
+ },
31
+ 'profileHash?': 'string',
32
+ });
33
+
34
+ function stripPersistenceFields(raw: Record<string, unknown>): Record<string, unknown> {
35
+ const { schemaVersion: _, sources: _s, ...rest } = raw;
36
+ return rest;
37
+ }
38
+
39
+ function extractDomainShape(contract: Contract): DomainContractShape {
40
+ return {
41
+ roots: contract.roots,
42
+ models: contract.models,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Framework-level contract validation (ADR 182).
48
+ *
49
+ * Three-pass validation:
50
+ * 1. **Structural validation** (arktype): verifies required fields exist with
51
+ * correct base types.
52
+ * 2. **Domain validation** (framework-owned): roots, relation targets,
53
+ * variant/base consistency, discriminators, ownership, orphans.
54
+ * 3. **Storage validation** (family-provided): SQL validates tables/columns/FKs;
55
+ * Mongo validates collections/embedding.
56
+ *
57
+ * JSON persistence fields (`schemaVersion`, `sources`) are stripped before
58
+ * validation — they are not part of the in-memory contract representation.
59
+ *
60
+ * @template TContract The fully-typed contract type (preserves literal types).
61
+ * @param value Raw contract value (e.g. parsed from JSON).
62
+ * @param storageValidator Family-specific storage validation function.
63
+ * @returns The validated contract with full literal types.
64
+ */
65
+ export function validateContract<TContract extends Contract>(
66
+ value: unknown,
67
+ storageValidator: StorageValidator,
68
+ ): TContract & ValidateContractResult {
69
+ if (typeof value !== 'object' || value === null) {
70
+ throw new Error('Contract must be a non-null object');
71
+ }
72
+
73
+ const stripped = stripPersistenceFields(value as Record<string, unknown>);
74
+
75
+ const parsed = ContractSchema(stripped);
76
+ if (parsed instanceof type.errors) {
77
+ throw new Error(`Invalid contract structure: ${parsed.summary}`);
78
+ }
79
+
80
+ // Arktype verified the structural shape; Contract adds branded hash types and
81
+ // ContractModel generics that can't be expressed in the schema.
82
+ const contract = parsed as unknown as Contract;
83
+
84
+ const domainResult: DomainValidationResult = validateContractDomain(extractDomainShape(contract));
85
+
86
+ storageValidator(contract);
87
+
88
+ // TContract narrows Contract with literal types from the caller's contract.d.ts;
89
+ // the runtime object is the same — the cast preserves the caller's type parameter.
90
+ return Object.assign(contract as unknown as TContract, {
91
+ warnings: domainResult.warnings,
92
+ });
93
+ }