@prisma-next/mongo-core 0.0.1
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.
- package/README.md +21 -0
- package/dist/index.d.mts +291 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +419 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
- package/src/adapter-types.ts +13 -0
- package/src/codec-registry.ts +40 -0
- package/src/codecs.ts +22 -0
- package/src/commands.ts +93 -0
- package/src/contract-schema.ts +119 -0
- package/src/contract-types.ts +120 -0
- package/src/driver-types.ts +6 -0
- package/src/exports/index.ts +64 -0
- package/src/param-ref.ts +16 -0
- package/src/plan.ts +16 -0
- package/src/results.ts +12 -0
- package/src/validate-domain.ts +174 -0
- package/src/validate-mongo-contract.ts +50 -0
- package/src/validate-storage.ts +64 -0
- package/src/values.ts +12 -0
- package/src/wire-commands.ts +95 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { MongoExpr, MongoUpdateDocument, MongoValue, RawPipeline } from './values';
|
|
2
|
+
|
|
3
|
+
abstract class MongoCommand {
|
|
4
|
+
abstract readonly kind: string;
|
|
5
|
+
readonly collection: string;
|
|
6
|
+
|
|
7
|
+
protected constructor(collection: string) {
|
|
8
|
+
this.collection = collection;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
protected freeze(): void {
|
|
12
|
+
Object.freeze(this);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FindOptions {
|
|
17
|
+
readonly projection?: Record<string, 1 | 0>;
|
|
18
|
+
readonly sort?: Record<string, 1 | -1>;
|
|
19
|
+
readonly limit?: number;
|
|
20
|
+
readonly skip?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class FindCommand extends MongoCommand {
|
|
24
|
+
readonly kind = 'find' as const;
|
|
25
|
+
readonly filter: MongoExpr | undefined;
|
|
26
|
+
readonly projection: Record<string, 1 | 0> | undefined;
|
|
27
|
+
readonly sort: Record<string, 1 | -1> | undefined;
|
|
28
|
+
readonly limit: number | undefined;
|
|
29
|
+
readonly skip: number | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(collection: string, filter?: MongoExpr, options?: FindOptions) {
|
|
32
|
+
super(collection);
|
|
33
|
+
this.filter = filter;
|
|
34
|
+
this.projection = options?.projection;
|
|
35
|
+
this.sort = options?.sort;
|
|
36
|
+
this.limit = options?.limit;
|
|
37
|
+
this.skip = options?.skip;
|
|
38
|
+
this.freeze();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class InsertOneCommand extends MongoCommand {
|
|
43
|
+
readonly kind = 'insertOne' as const;
|
|
44
|
+
readonly document: Record<string, MongoValue>;
|
|
45
|
+
|
|
46
|
+
constructor(collection: string, document: Record<string, MongoValue>) {
|
|
47
|
+
super(collection);
|
|
48
|
+
this.document = document;
|
|
49
|
+
this.freeze();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class UpdateOneCommand extends MongoCommand {
|
|
54
|
+
readonly kind = 'updateOne' as const;
|
|
55
|
+
readonly filter: MongoExpr;
|
|
56
|
+
readonly update: MongoUpdateDocument;
|
|
57
|
+
|
|
58
|
+
constructor(collection: string, filter: MongoExpr, update: MongoUpdateDocument) {
|
|
59
|
+
super(collection);
|
|
60
|
+
this.filter = filter;
|
|
61
|
+
this.update = update;
|
|
62
|
+
this.freeze();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class DeleteOneCommand extends MongoCommand {
|
|
67
|
+
readonly kind = 'deleteOne' as const;
|
|
68
|
+
readonly filter: MongoExpr;
|
|
69
|
+
|
|
70
|
+
constructor(collection: string, filter: MongoExpr) {
|
|
71
|
+
super(collection);
|
|
72
|
+
this.filter = filter;
|
|
73
|
+
this.freeze();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class AggregateCommand extends MongoCommand {
|
|
78
|
+
readonly kind = 'aggregate' as const;
|
|
79
|
+
readonly pipeline: RawPipeline;
|
|
80
|
+
|
|
81
|
+
constructor(collection: string, pipeline: RawPipeline) {
|
|
82
|
+
super(collection);
|
|
83
|
+
this.pipeline = pipeline;
|
|
84
|
+
this.freeze();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type AnyMongoCommand =
|
|
89
|
+
| FindCommand
|
|
90
|
+
| InsertOneCommand
|
|
91
|
+
| UpdateOneCommand
|
|
92
|
+
| DeleteOneCommand
|
|
93
|
+
| AggregateCommand;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type } from 'arktype';
|
|
2
|
+
|
|
3
|
+
const MongoModelFieldSchema = type({
|
|
4
|
+
'+': 'reject',
|
|
5
|
+
codecId: 'string',
|
|
6
|
+
nullable: 'boolean',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const MongoModelStorageSchema = type({
|
|
10
|
+
'+': 'reject',
|
|
11
|
+
'collection?': 'string',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const MongoDiscriminatorSchema = type({
|
|
15
|
+
'+': 'reject',
|
|
16
|
+
field: 'string',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const MongoVariantEntrySchema = type({
|
|
20
|
+
'+': 'reject',
|
|
21
|
+
value: 'string',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const MongoReferenceRelationOnSchema = type({
|
|
25
|
+
'+': 'reject',
|
|
26
|
+
localFields: 'string[]',
|
|
27
|
+
targetFields: 'string[]',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const MongoReferenceRelationSchema = type({
|
|
31
|
+
'+': 'reject',
|
|
32
|
+
to: 'string',
|
|
33
|
+
cardinality: "'1:1' | '1:N' | 'N:1'",
|
|
34
|
+
strategy: "'reference'",
|
|
35
|
+
on: MongoReferenceRelationOnSchema,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const MongoEmbedRelationSchema = type({
|
|
39
|
+
'+': 'reject',
|
|
40
|
+
to: 'string',
|
|
41
|
+
cardinality: "'1:1' | '1:N'",
|
|
42
|
+
strategy: "'embed'",
|
|
43
|
+
field: 'string',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const MongoRelationSchema = MongoReferenceRelationSchema.or(MongoEmbedRelationSchema);
|
|
47
|
+
|
|
48
|
+
const MongoModelDefinitionSchema = type({
|
|
49
|
+
'+': 'reject',
|
|
50
|
+
fields: type('Record<string, unknown>').pipe((fields) => {
|
|
51
|
+
const result: Record<string, unknown> = {};
|
|
52
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
53
|
+
const parsed = MongoModelFieldSchema(value);
|
|
54
|
+
if (parsed instanceof type.errors) {
|
|
55
|
+
throw new Error(`Invalid field "${key}": ${parsed.summary}`);
|
|
56
|
+
}
|
|
57
|
+
result[key] = parsed;
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}),
|
|
61
|
+
storage: MongoModelStorageSchema,
|
|
62
|
+
relations: type('Record<string, unknown>').pipe((relations) => {
|
|
63
|
+
const result: Record<string, unknown> = {};
|
|
64
|
+
for (const [key, value] of Object.entries(relations)) {
|
|
65
|
+
const parsed = MongoRelationSchema(value);
|
|
66
|
+
if (parsed instanceof type.errors) {
|
|
67
|
+
throw new Error(`Invalid relation "${key}": ${parsed.summary}`);
|
|
68
|
+
}
|
|
69
|
+
result[key] = parsed;
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}),
|
|
73
|
+
'discriminator?': MongoDiscriminatorSchema,
|
|
74
|
+
'variants?': type('Record<string, unknown>').pipe((variants) => {
|
|
75
|
+
const result: Record<string, unknown> = {};
|
|
76
|
+
for (const [key, value] of Object.entries(variants)) {
|
|
77
|
+
const parsed = MongoVariantEntrySchema(value);
|
|
78
|
+
if (parsed instanceof type.errors) {
|
|
79
|
+
throw new Error(`Invalid variant "${key}": ${parsed.summary}`);
|
|
80
|
+
}
|
|
81
|
+
result[key] = parsed;
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}),
|
|
85
|
+
'base?': 'string',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const MongoStorageCollectionSchema = type({ '+': 'reject' });
|
|
89
|
+
|
|
90
|
+
export const MongoContractSchema = type({
|
|
91
|
+
'+': 'reject',
|
|
92
|
+
targetFamily: "'mongo'",
|
|
93
|
+
roots: 'Record<string, string>',
|
|
94
|
+
storage: type({
|
|
95
|
+
'+': 'reject',
|
|
96
|
+
collections: type('Record<string, unknown>').pipe((collections) => {
|
|
97
|
+
const result: Record<string, unknown> = {};
|
|
98
|
+
for (const [key, value] of Object.entries(collections)) {
|
|
99
|
+
const parsed = MongoStorageCollectionSchema(value);
|
|
100
|
+
if (parsed instanceof type.errors) {
|
|
101
|
+
throw new Error(`Invalid collection "${key}": ${parsed.summary}`);
|
|
102
|
+
}
|
|
103
|
+
result[key] = parsed;
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
models: type('Record<string, unknown>').pipe((models) => {
|
|
109
|
+
const result: Record<string, unknown> = {};
|
|
110
|
+
for (const [key, value] of Object.entries(models)) {
|
|
111
|
+
const parsed = MongoModelDefinitionSchema(value);
|
|
112
|
+
if (parsed instanceof type.errors) {
|
|
113
|
+
throw new Error(`Invalid model "${key}": ${parsed.summary}`);
|
|
114
|
+
}
|
|
115
|
+
result[key] = parsed;
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// --- Storage layer: collection-level metadata ---
|
|
2
|
+
|
|
3
|
+
export type MongoStorageCollection = Record<string, never>;
|
|
4
|
+
|
|
5
|
+
export type MongoStorage = {
|
|
6
|
+
readonly collections: Record<string, MongoStorageCollection>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// --- Model field (domain level) ---
|
|
10
|
+
|
|
11
|
+
export type MongoModelField = {
|
|
12
|
+
readonly codecId: string;
|
|
13
|
+
readonly nullable: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// --- Model storage (family-specific bridge) ---
|
|
17
|
+
|
|
18
|
+
export type MongoModelStorage = {
|
|
19
|
+
readonly collection?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// --- Polymorphism ---
|
|
23
|
+
|
|
24
|
+
export type MongoDiscriminator = {
|
|
25
|
+
readonly field: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type MongoVariantEntry = {
|
|
29
|
+
readonly value: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// --- Relations ---
|
|
33
|
+
|
|
34
|
+
export type MongoReferenceRelationOn = {
|
|
35
|
+
readonly localFields: readonly string[];
|
|
36
|
+
readonly targetFields: readonly string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type MongoReferenceRelation = {
|
|
40
|
+
readonly to: string;
|
|
41
|
+
readonly cardinality: '1:1' | '1:N' | 'N:1';
|
|
42
|
+
readonly strategy: 'reference';
|
|
43
|
+
readonly on: MongoReferenceRelationOn;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type MongoEmbedRelation = {
|
|
47
|
+
readonly to: string;
|
|
48
|
+
readonly cardinality: '1:1' | '1:N';
|
|
49
|
+
readonly strategy: 'embed';
|
|
50
|
+
readonly field: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type MongoRelation = MongoReferenceRelation | MongoEmbedRelation;
|
|
54
|
+
|
|
55
|
+
// --- Model definition ---
|
|
56
|
+
|
|
57
|
+
export type MongoModelDefinition = {
|
|
58
|
+
readonly fields: Record<string, MongoModelField>;
|
|
59
|
+
readonly storage: MongoModelStorage;
|
|
60
|
+
readonly relations: Record<string, MongoRelation>;
|
|
61
|
+
readonly discriminator?: MongoDiscriminator;
|
|
62
|
+
readonly variants?: Record<string, MongoVariantEntry>;
|
|
63
|
+
readonly base?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// --- Contract ---
|
|
67
|
+
|
|
68
|
+
export type MongoContract<
|
|
69
|
+
Roots extends Record<string, string> = Record<string, string>,
|
|
70
|
+
S extends MongoStorage = MongoStorage,
|
|
71
|
+
M extends Record<string, MongoModelDefinition> = Record<string, MongoModelDefinition>,
|
|
72
|
+
> = {
|
|
73
|
+
readonly targetFamily: string;
|
|
74
|
+
readonly roots: Roots;
|
|
75
|
+
readonly storage: S;
|
|
76
|
+
readonly models: M;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// --- TypeMaps: phantom type attachment ---
|
|
80
|
+
|
|
81
|
+
export type MongoTypeMaps<
|
|
82
|
+
TCodecTypes extends Record<string, { output: unknown }> = Record<string, { output: unknown }>,
|
|
83
|
+
> = {
|
|
84
|
+
readonly codecTypes: TCodecTypes;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type MongoTypeMapsPhantomKey = '__@prisma-next/mongo-core/typeMaps@__';
|
|
88
|
+
|
|
89
|
+
export type MongoContractWithTypeMaps<TContract, TTypeMaps> = TContract & {
|
|
90
|
+
readonly [K in MongoTypeMapsPhantomKey]?: TTypeMaps;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// --- Type extraction helpers ---
|
|
94
|
+
|
|
95
|
+
export type ExtractMongoTypeMaps<T> = MongoTypeMapsPhantomKey extends keyof T
|
|
96
|
+
? NonNullable<T[MongoTypeMapsPhantomKey & keyof T]>
|
|
97
|
+
: never;
|
|
98
|
+
|
|
99
|
+
export type ExtractMongoCodecTypes<T> =
|
|
100
|
+
ExtractMongoTypeMaps<T> extends { codecTypes: infer C }
|
|
101
|
+
? C extends Record<string, { output: unknown }>
|
|
102
|
+
? C
|
|
103
|
+
: Record<string, never>
|
|
104
|
+
: Record<string, never>;
|
|
105
|
+
|
|
106
|
+
// --- Row inference ---
|
|
107
|
+
|
|
108
|
+
export type InferModelRow<
|
|
109
|
+
TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>,
|
|
110
|
+
ModelName extends string & keyof TContract['models'],
|
|
111
|
+
TFields extends Record<
|
|
112
|
+
string,
|
|
113
|
+
{ codecId: string; nullable: boolean }
|
|
114
|
+
> = TContract['models'][ModelName]['fields'],
|
|
115
|
+
TCodecTypes extends Record<string, { output: unknown }> = ExtractMongoCodecTypes<TContract>,
|
|
116
|
+
> = {
|
|
117
|
+
-readonly [FieldName in keyof TFields]: TFields[FieldName]['nullable'] extends true
|
|
118
|
+
? TCodecTypes[TFields[FieldName]['codecId']]['output'] | null
|
|
119
|
+
: TCodecTypes[TFields[FieldName]['codecId']]['output'];
|
|
120
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type { MongoAdapter, MongoLoweringContext } from '../adapter-types';
|
|
2
|
+
export type { MongoCodecRegistry } from '../codec-registry';
|
|
3
|
+
export { createMongoCodecRegistry } from '../codec-registry';
|
|
4
|
+
export type { MongoCodec, MongoCodecJsType } from '../codecs';
|
|
5
|
+
export { mongoCodec } from '../codecs';
|
|
6
|
+
export type { AnyMongoCommand, FindOptions } from '../commands';
|
|
7
|
+
export {
|
|
8
|
+
AggregateCommand,
|
|
9
|
+
DeleteOneCommand,
|
|
10
|
+
FindCommand,
|
|
11
|
+
InsertOneCommand,
|
|
12
|
+
UpdateOneCommand,
|
|
13
|
+
} from '../commands';
|
|
14
|
+
export type {
|
|
15
|
+
ExtractMongoCodecTypes,
|
|
16
|
+
ExtractMongoTypeMaps,
|
|
17
|
+
InferModelRow,
|
|
18
|
+
MongoContract,
|
|
19
|
+
MongoContractWithTypeMaps,
|
|
20
|
+
MongoDiscriminator,
|
|
21
|
+
MongoEmbedRelation,
|
|
22
|
+
MongoModelDefinition,
|
|
23
|
+
MongoModelField,
|
|
24
|
+
MongoModelStorage,
|
|
25
|
+
MongoReferenceRelation,
|
|
26
|
+
MongoReferenceRelationOn,
|
|
27
|
+
MongoRelation,
|
|
28
|
+
MongoStorage,
|
|
29
|
+
MongoStorageCollection,
|
|
30
|
+
MongoTypeMaps,
|
|
31
|
+
MongoTypeMapsPhantomKey,
|
|
32
|
+
MongoVariantEntry,
|
|
33
|
+
} from '../contract-types';
|
|
34
|
+
export type { MongoDriver } from '../driver-types';
|
|
35
|
+
export { MongoParamRef } from '../param-ref';
|
|
36
|
+
export type { MongoExecutionPlan, MongoQueryPlan } from '../plan';
|
|
37
|
+
export type { DeleteOneResult, InsertOneResult, UpdateOneResult } from '../results';
|
|
38
|
+
export type {
|
|
39
|
+
DomainContractShape,
|
|
40
|
+
DomainModelShape,
|
|
41
|
+
DomainValidationResult,
|
|
42
|
+
} from '../validate-domain';
|
|
43
|
+
export { validateContractDomain } from '../validate-domain';
|
|
44
|
+
export type { MongoContractIndices, ValidatedMongoContract } from '../validate-mongo-contract';
|
|
45
|
+
export { validateMongoContract } from '../validate-mongo-contract';
|
|
46
|
+
export { validateMongoStorage } from '../validate-storage';
|
|
47
|
+
export type {
|
|
48
|
+
Document,
|
|
49
|
+
LiteralValue,
|
|
50
|
+
MongoArray,
|
|
51
|
+
MongoDocument,
|
|
52
|
+
MongoExpr,
|
|
53
|
+
MongoUpdateDocument,
|
|
54
|
+
MongoValue,
|
|
55
|
+
RawPipeline,
|
|
56
|
+
} from '../values';
|
|
57
|
+
export type { AnyMongoWireCommand } from '../wire-commands';
|
|
58
|
+
export {
|
|
59
|
+
AggregateWireCommand,
|
|
60
|
+
DeleteOneWireCommand,
|
|
61
|
+
FindWireCommand,
|
|
62
|
+
InsertOneWireCommand,
|
|
63
|
+
UpdateOneWireCommand,
|
|
64
|
+
} from '../wire-commands';
|
package/src/param-ref.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class MongoParamRef {
|
|
2
|
+
readonly value: unknown;
|
|
3
|
+
readonly name: string | undefined;
|
|
4
|
+
readonly codecId: string | undefined;
|
|
5
|
+
|
|
6
|
+
constructor(value: unknown, options?: { name?: string; codecId?: string }) {
|
|
7
|
+
this.value = value;
|
|
8
|
+
this.name = options?.name;
|
|
9
|
+
this.codecId = options?.codecId;
|
|
10
|
+
Object.freeze(this);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static of(value: unknown, options?: { name?: string; codecId?: string }): MongoParamRef {
|
|
14
|
+
return new MongoParamRef(value, options);
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PlanMeta } from '@prisma-next/contract/types';
|
|
2
|
+
import type { AnyMongoCommand } from './commands';
|
|
3
|
+
import type { AnyMongoWireCommand } from './wire-commands';
|
|
4
|
+
|
|
5
|
+
export interface MongoQueryPlan<Row = unknown> {
|
|
6
|
+
readonly command: AnyMongoCommand;
|
|
7
|
+
readonly meta: PlanMeta;
|
|
8
|
+
readonly _row?: Row;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MongoExecutionPlan<Row = unknown> {
|
|
12
|
+
readonly wireCommand: AnyMongoWireCommand;
|
|
13
|
+
readonly command: AnyMongoCommand;
|
|
14
|
+
readonly meta: PlanMeta;
|
|
15
|
+
readonly _row?: Row;
|
|
16
|
+
}
|
package/src/results.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface InsertOneResult {
|
|
2
|
+
readonly insertedId: unknown;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface UpdateOneResult {
|
|
6
|
+
readonly matchedCount: number;
|
|
7
|
+
readonly modifiedCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DeleteOneResult {
|
|
11
|
+
readonly deletedCount: number;
|
|
12
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
export interface DomainModelShape {
|
|
2
|
+
readonly fields: Record<string, unknown>;
|
|
3
|
+
readonly relations: Record<string, { readonly to: string }>;
|
|
4
|
+
readonly discriminator?: { readonly field: string };
|
|
5
|
+
readonly variants?: Record<string, unknown>;
|
|
6
|
+
readonly base?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DomainContractShape {
|
|
10
|
+
readonly roots: Record<string, string>;
|
|
11
|
+
readonly models: Record<string, DomainModelShape>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DomainValidationResult {
|
|
15
|
+
readonly warnings: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateContractDomain(contract: DomainContractShape): DomainValidationResult {
|
|
19
|
+
const errors: string[] = [];
|
|
20
|
+
const warnings: string[] = [];
|
|
21
|
+
const modelNames = new Set(Object.keys(contract.models));
|
|
22
|
+
|
|
23
|
+
validateRoots(contract, modelNames, errors);
|
|
24
|
+
validateVariantsAndBases(contract, modelNames, errors);
|
|
25
|
+
validateRelationTargets(contract, modelNames, errors);
|
|
26
|
+
validateDiscriminators(contract, errors);
|
|
27
|
+
detectOrphanedModels(contract, modelNames, warnings);
|
|
28
|
+
|
|
29
|
+
if (errors.length > 0) {
|
|
30
|
+
throw new Error(`Contract domain validation failed:\n- ${errors.join('\n- ')}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { warnings };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateRoots(
|
|
37
|
+
contract: DomainContractShape,
|
|
38
|
+
modelNames: Set<string>,
|
|
39
|
+
errors: string[],
|
|
40
|
+
): void {
|
|
41
|
+
const seenValues = new Set<string>();
|
|
42
|
+
for (const [rootKey, modelName] of Object.entries(contract.roots)) {
|
|
43
|
+
if (seenValues.has(modelName)) {
|
|
44
|
+
errors.push(`Duplicate root value: "${modelName}" is mapped by multiple root keys`);
|
|
45
|
+
}
|
|
46
|
+
seenValues.add(modelName);
|
|
47
|
+
|
|
48
|
+
if (!modelNames.has(modelName)) {
|
|
49
|
+
errors.push(
|
|
50
|
+
`Root "${rootKey}" references model "${modelName}" which does not exist in models`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateVariantsAndBases(
|
|
57
|
+
contract: DomainContractShape,
|
|
58
|
+
modelNames: Set<string>,
|
|
59
|
+
errors: string[],
|
|
60
|
+
): void {
|
|
61
|
+
for (const [modelName, model] of Object.entries(contract.models)) {
|
|
62
|
+
if (model.variants) {
|
|
63
|
+
for (const variantName of Object.keys(model.variants)) {
|
|
64
|
+
if (!modelNames.has(variantName)) {
|
|
65
|
+
errors.push(
|
|
66
|
+
`Model "${modelName}" lists variant "${variantName}" which does not exist in models`,
|
|
67
|
+
);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const variantModel = contract.models[variantName];
|
|
71
|
+
if (!variantModel) continue;
|
|
72
|
+
if (variantModel.base !== modelName) {
|
|
73
|
+
errors.push(
|
|
74
|
+
`Variant "${variantName}" has base "${variantModel.base ?? '(none)'}" but expected "${modelName}"`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (model.base) {
|
|
81
|
+
if (!modelNames.has(model.base)) {
|
|
82
|
+
errors.push(`Model "${modelName}" has base "${model.base}" which does not exist in models`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const baseModel = contract.models[model.base];
|
|
86
|
+
if (!baseModel) continue;
|
|
87
|
+
if (!baseModel.variants || !(modelName in baseModel.variants)) {
|
|
88
|
+
errors.push(
|
|
89
|
+
`Model "${modelName}" has base "${model.base}" which does not list it as a variant`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateRelationTargets(
|
|
97
|
+
contract: DomainContractShape,
|
|
98
|
+
modelNames: Set<string>,
|
|
99
|
+
errors: string[],
|
|
100
|
+
): void {
|
|
101
|
+
for (const [modelName, model] of Object.entries(contract.models)) {
|
|
102
|
+
for (const [relName, relation] of Object.entries(model.relations)) {
|
|
103
|
+
if (!modelNames.has(relation.to)) {
|
|
104
|
+
errors.push(
|
|
105
|
+
`Relation "${relName}" on model "${modelName}" targets "${relation.to}" which does not exist in models`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateDiscriminators(contract: DomainContractShape, errors: string[]): void {
|
|
113
|
+
for (const [modelName, model] of Object.entries(contract.models)) {
|
|
114
|
+
if (model.discriminator) {
|
|
115
|
+
if (!model.variants || Object.keys(model.variants).length === 0) {
|
|
116
|
+
errors.push(`Model "${modelName}" has discriminator but no variants`);
|
|
117
|
+
}
|
|
118
|
+
if (!(model.discriminator.field in model.fields)) {
|
|
119
|
+
errors.push(
|
|
120
|
+
`Discriminator field "${model.discriminator.field}" is not a field on model "${modelName}"`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (model.variants && Object.keys(model.variants).length > 0 && !model.discriminator) {
|
|
126
|
+
errors.push(`Model "${modelName}" has variants but no discriminator`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Single-level polymorphism only: a variant (model with `base`) cannot itself
|
|
130
|
+
// declare discriminator/variants. Multi-level polymorphism is out of scope per ADR 2.
|
|
131
|
+
if (model.base) {
|
|
132
|
+
if (model.discriminator) {
|
|
133
|
+
errors.push(`Model "${modelName}" has base and must not have discriminator`);
|
|
134
|
+
}
|
|
135
|
+
if (model.variants && Object.keys(model.variants).length > 0) {
|
|
136
|
+
errors.push(`Model "${modelName}" has base and must not have variants`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function detectOrphanedModels(
|
|
143
|
+
contract: DomainContractShape,
|
|
144
|
+
modelNames: Set<string>,
|
|
145
|
+
warnings: string[],
|
|
146
|
+
): void {
|
|
147
|
+
const referenced = new Set<string>();
|
|
148
|
+
|
|
149
|
+
for (const modelName of Object.values(contract.roots)) {
|
|
150
|
+
referenced.add(modelName);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const model of Object.values(contract.models)) {
|
|
154
|
+
for (const relation of Object.values(model.relations)) {
|
|
155
|
+
referenced.add(relation.to);
|
|
156
|
+
}
|
|
157
|
+
if (model.variants) {
|
|
158
|
+
for (const variantName of Object.keys(model.variants)) {
|
|
159
|
+
referenced.add(variantName);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (model.base) {
|
|
163
|
+
referenced.add(model.base);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const modelName of modelNames) {
|
|
168
|
+
if (!referenced.has(modelName)) {
|
|
169
|
+
warnings.push(
|
|
170
|
+
`Orphaned model: "${modelName}" is not referenced by any root, relation, or variant`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type as arktypeType } from 'arktype';
|
|
2
|
+
import { MongoContractSchema } from './contract-schema';
|
|
3
|
+
import type { MongoContract } from './contract-types';
|
|
4
|
+
import { validateContractDomain } from './validate-domain';
|
|
5
|
+
import { validateMongoStorage } from './validate-storage';
|
|
6
|
+
|
|
7
|
+
export interface MongoContractIndices {
|
|
8
|
+
readonly variantToBase: Record<string, string>;
|
|
9
|
+
readonly modelToVariants: Record<string, string[]>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ValidatedMongoContract<TContract extends MongoContract> {
|
|
13
|
+
readonly contract: TContract;
|
|
14
|
+
readonly indices: MongoContractIndices;
|
|
15
|
+
readonly warnings: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateMongoContract<TContract extends MongoContract>(
|
|
19
|
+
value: unknown,
|
|
20
|
+
): ValidatedMongoContract<TContract> {
|
|
21
|
+
const parsed = MongoContractSchema(value);
|
|
22
|
+
if (parsed instanceof arktypeType.errors) {
|
|
23
|
+
throw new Error(`Contract structural validation failed: ${parsed.summary}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const contract = parsed as unknown as TContract;
|
|
27
|
+
|
|
28
|
+
const { warnings } = validateContractDomain(contract);
|
|
29
|
+
validateMongoStorage(contract);
|
|
30
|
+
|
|
31
|
+
const indices = buildIndices(contract);
|
|
32
|
+
|
|
33
|
+
return { contract, indices, warnings };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildIndices(contract: MongoContract): MongoContractIndices {
|
|
37
|
+
const variantToBase: Record<string, string> = {};
|
|
38
|
+
const modelToVariants: Record<string, string[]> = {};
|
|
39
|
+
|
|
40
|
+
for (const [modelName, model] of Object.entries(contract.models)) {
|
|
41
|
+
if (model.base) {
|
|
42
|
+
variantToBase[modelName] = model.base;
|
|
43
|
+
}
|
|
44
|
+
if (model.variants) {
|
|
45
|
+
modelToVariants[modelName] = Object.keys(model.variants);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { variantToBase, modelToVariants };
|
|
50
|
+
}
|