Skip to content

Commit 2ea34e3

Browse files
committed
Add search indexes and improve init options
1 parent 351cc07 commit 2ea34e3

7 files changed

Lines changed: 189 additions & 34 deletions

File tree

.changeset/twelve-keys-hunt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"monarch-orm": minor
3+
---
4+
5+
Add searchIndexes support. Use `schema.searchIndexes()` for defining Atlas Search Indexes

src/database.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Collection } from "./collection/collection";
44
import { applyIndexes } from "./schema/indexes";
55
import type { AnySchema, Schemas } from "./schema/schema";
66
import { Schema } from "./schema/schema";
7+
import { applySearchIndexes } from "./schema/search-indexes";
78
import { getValidator, type SchemaValidation, type Validator } from "./schema/validation";
89
import type { DbCollections } from "./type-helpers";
910
import { createAsyncLimiter, createAsyncResolver, type AsyncResolver } from "./utils/misc";
@@ -92,16 +93,22 @@ export class Database<TSchemas extends Schemas<any, any>> {
9293
/**
9394
* Creates collections with indexes and document validation if provided.
9495
*
95-
* @param options - Init options
96+
* @param options - Init options. Pass `true` to run all steps, or an object to selectively enable steps.
97+
* @param collections - Select collections. Omit to initialize all collections, or an object to selectively enable collections.
9698
*/
97-
public async initialize(options?: InitOptions<keyof TSchemas["schemas"] & string>): Promise<void> {
99+
public async initialize(
100+
options?: InitOptions | true,
101+
collections?: InitCollections<keyof TSchemas["schemas"] & string>,
102+
): Promise<void> {
98103
const promises: Promise<void>[] = [];
99-
const collections = Object.values(this.collections).map((c: Collection<any, any>): CollectionInit => {
100-
const resolver = createAsyncResolver();
101-
promises.push(resolver.promise);
102-
return { schema: c.schema, defaultValidation: this.options?.validation, resolver };
103-
});
104-
initializeCollections(this.db, collections, options);
104+
const collectionInits = (Object.values(this.collections) as Collection<any, any>[])
105+
.filter((c) => !collections || collections[c.schema.name] === true)
106+
.map((c): CollectionInit => {
107+
const resolver = createAsyncResolver();
108+
promises.push(resolver.promise);
109+
return { schema: c.schema, defaultValidation: this.options?.validation, resolver };
110+
});
111+
initializeCollections(this.db, collectionInits, options);
105112
return Promise.all(promises).then<void>(() => undefined);
106113
}
107114

@@ -145,33 +152,40 @@ export function createDatabase<T extends Schemas<any, any>>(
145152
return new Database(db, schemas, options);
146153
}
147154

148-
type InitOptions<T extends string> = {
149-
indexes?: boolean;
150-
validation?: boolean;
151-
collections?: Partial<Record<T, true>>;
155+
/**
156+
* Initialization options. When provided, only fields explicitly set to `true` will run.
157+
* When omitted, all initialization steps run.
158+
*/
159+
export type InitOptions = {
160+
/** Create or update schema indexes. */
161+
indexes?: true;
162+
/** Create or update search indexes. */
163+
searchIndexes?: true;
164+
/** Apply document validation rules. */
165+
validation?: true;
152166
};
153167

168+
/**
169+
* Limit initialization to specific collections.
170+
* When provided, only collections with a `true` value will be initialized.
171+
*/
172+
type InitCollections<T extends string> = { [K in T]?: true };
173+
154174
type CollectionInit = {
155175
schema: AnySchema;
156176
defaultValidation?: SchemaValidation;
157177
resolver: AsyncResolver;
158178
};
159179

160-
function initializeCollections(db: Db, collections: CollectionInit[], options?: InitOptions<any>) {
180+
function initializeCollections(db: Db, collections: CollectionInit[], options?: InitOptions | true) {
181+
const opts = options === true ? undefined : options;
161182
const run = createAsyncLimiter(10);
162183
const existingPromise = db
163184
.listCollections({}, { nameOnly: true })
164185
.toArray()
165186
.then((colls) => new Set(colls.map((c) => c.name)));
166187

167188
for (const c of collections) {
168-
// Skip disabled collections
169-
const enabled = options?.collections ? options.collections[c.schema.name] === true : true;
170-
if (!enabled) {
171-
c.resolver.resolve();
172-
continue;
173-
}
174-
175189
run(async () => {
176190
const existing = await existingPromise;
177191
const exists = existing.has(c.schema.name);
@@ -180,7 +194,7 @@ function initializeCollections(db: Db, collections: CollectionInit[], options?:
180194
// Get schema validation
181195
let validation: (SchemaValidation & { validator: Validator }) | undefined;
182196
const validationOptions = schemaOptions.validation ?? c.defaultValidation;
183-
if ((options?.validation ?? true) && validationOptions) {
197+
if ((opts === undefined || opts.validation) && validationOptions) {
184198
validation = { ...validationOptions, validator: getValidator(c.schema) };
185199
}
186200

@@ -194,9 +208,14 @@ function initializeCollections(db: Db, collections: CollectionInit[], options?:
194208
}
195209

196210
// Create schema indexes
197-
if ((options?.indexes ?? true) && schemaOptions.indexes) {
211+
if ((opts === undefined || opts.indexes) && schemaOptions.indexes) {
198212
await applyIndexes(coll, schemaOptions.indexes);
199213
}
214+
215+
// Create schema search indexes
216+
if ((opts === undefined || opts.searchIndexes) && schemaOptions.searchIndexes) {
217+
await applySearchIndexes(coll, schemaOptions.searchIndexes);
218+
}
200219
})
201220
.then(c.resolver.resolve)
202221
.catch(c.resolver.reject);

src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { uuid } from "./types/uuid";
2121

2222
export { ObjectId } from "mongodb";
2323
export { Collection } from "./collection/collection";
24-
export { createClient, createDatabase, Database, type DatabaseOptions } from "./database";
24+
export { createClient, createDatabase, Database, type DatabaseOptions, type InitOptions } from "./database";
2525
export { MonarchError, MonarchParseError } from "./errors";
2626
export {
2727
mergeRelations,
@@ -30,7 +30,14 @@ export {
3030
type RelationsFn,
3131
type SchemasRelations,
3232
} from "./relations/relations";
33+
export type { CreateIndexesOptions, SchemaIndex } from "./schema/indexes";
3334
export { createSchema, defineSchemas, mergeSchemas, Schema, Schemas } from "./schema/schema";
35+
export type {
36+
SchemaSearchIndex,
37+
SchemaSearchIndexDefinition,
38+
SearchIndexDefinition,
39+
VectorSearchIndexDefinition,
40+
} from "./schema/search-indexes";
3441
export type {
3542
Condition,
3643
CreateIndexKey,

src/schema/indexes.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { MonarchError } from "../errors";
88
import type { AnyMonarchType } from "../types/type";
99
import type { CreateIndexKey } from "./type-helpers";
1010

11+
export type { CreateIndexesOptions };
12+
1113
export type CreateIndex<T extends Record<string, AnyMonarchType>> = (
1214
key: CreateIndexKey<T>,
1315
options?: CreateIndexesOptions,
@@ -28,15 +30,15 @@ export type SchemaIndexes<T extends Record<string, AnyMonarchType>> = (options:
2830
[k: string]: SchemaIndex<T>;
2931
};
3032

31-
export function makeIndexes<T extends Record<string, AnyMonarchType>>(indexesFn: SchemaIndexes<T>) {
32-
return indexesFn({
33+
export function makeIndexes<T extends Record<string, AnyMonarchType>>(fn: SchemaIndexes<T>) {
34+
return fn({
3335
createIndex: (key, options) => ({ key, options }),
3436
unique: (key) => ({ key: { [key]: 1 } as CreateIndexKey<T>, options: { unique: true } }),
3537
});
3638
}
3739

38-
export async function applyIndexes(coll: MongoCollection, indexesFn: SchemaIndexes<any>) {
39-
const indexes = Object.entries(makeIndexes(indexesFn));
40+
export async function applyIndexes(coll: MongoCollection, fn: SchemaIndexes<any>) {
41+
const indexes = Object.entries(makeIndexes(fn));
4042
const desiredIndexes = new Map(indexes.map(([_, idx]) => [JSON.stringify(idx.key), idx.options ?? {}]));
4143
const existingIndexes = await coll.indexes();
4244
const indexesToDrop: string[] = [];

src/schema/schema.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { MonarchObjectId, objectId } from "../types/objectId";
77
import { MonarchNullable, MonarchOptional, MonarchType, type AnyMonarchType } from "../types/type";
88
import type { MergeAll, MergeN1All, Pretty, RequiredObject } from "../utils/type-helpers";
99
import type { SchemaIndexes } from "./indexes";
10+
import type { SchemaSearchIndexes } from "./search-indexes";
1011
import type {
1112
InferSchemaData,
1213
InferSchemaInput,
@@ -52,6 +53,7 @@ export class Schema<
5253
private options: {
5354
omit?: SchemaOmit<TTypes>;
5455
indexes?: SchemaIndexes<TTypes>;
56+
searchIndexes?: SchemaSearchIndexes<TTypes>;
5557
validation?: SchemaValidation;
5658
virtuals?: SchemaVirtuals<TTypes, TVirtuals>;
5759
renames?: TRenames;
@@ -104,7 +106,6 @@ export class Schema<
104106
* This method allows you to specify indexes that should be created for the schema.
105107
*
106108
* @param indexes - A function that defines the indexes to be created.
107-
*
108109
* @returns The current schema instance for method chaining.
109110
*
110111
* @example
@@ -121,6 +122,33 @@ export class Schema<
121122
return this;
122123
}
123124

125+
/**
126+
* Defines the search indexes for the schema.
127+
*
128+
* Search indexes are only supported on MongoDB Atlas clusters and are applied during initialization.
129+
*
130+
* @param searchIndexes - A function that defines the search indexes to be created.
131+
* @returns The current schema instance for method chaining.
132+
*
133+
* @example
134+
* const articleSchema = createSchema("articles", {
135+
* title: string(),
136+
* body: string(),
137+
* embedding: array(number()),
138+
* }).searchIndexes(({ searchIndex, vectorSearchIndex }) => ({
139+
* fullText: searchIndex("articles_search", {
140+
* mappings: { dynamic: false, fields: { title: { type: "string" }, body: { type: "string" } } },
141+
* }),
142+
* semantic: vectorSearchIndex("articles_vector", {
143+
* fields: [{ type: "vector", path: "embedding", numDimensions: 1536, similarity: "cosine" }],
144+
* }),
145+
* }));
146+
*/
147+
public searchIndexes(searchIndexes: SchemaSearchIndexes<TTypes>) {
148+
this.options.searchIndexes = searchIndexes;
149+
return this;
150+
}
151+
124152
/**
125153
* Sets MongoDB document validation for this schema.
126154
*

src/schema/search-indexes.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { Document, Collection as MongoCollection } from "mongodb";
2+
import { MonarchError } from "../errors";
3+
import type { AnyMonarchType } from "../types/type";
4+
import type { CreateIndexKey } from "./type-helpers";
5+
6+
export type SearchIndexDefinition<T extends Record<string, AnyMonarchType>> = {
7+
mappings?: {
8+
dynamic?: boolean;
9+
fields?: { [K in keyof CreateIndexKey<T>]?: Document };
10+
};
11+
};
12+
export type VectorSearchIndexDefinition<T extends Record<string, AnyMonarchType>> = {
13+
fields?: Array<{
14+
type: "vector" | "filter";
15+
path: keyof CreateIndexKey<T>;
16+
numDimensions?: number;
17+
similarity?: "euclidean" | "cosine" | "dotProduct";
18+
[key: string]: unknown;
19+
}>;
20+
};
21+
export type SchemaSearchIndexDefinition<T extends Record<string, AnyMonarchType>> =
22+
| SearchIndexDefinition<T>
23+
| VectorSearchIndexDefinition<T>;
24+
25+
export type SchemaSearchIndex<T extends Record<string, AnyMonarchType>> = {
26+
name: string;
27+
type?: "search" | "vectorSearch";
28+
definition: SchemaSearchIndexDefinition<T>;
29+
};
30+
31+
export type SchemaSearchIndexes<T extends Record<string, AnyMonarchType>> = (options: {
32+
searchIndex: (name: string, definition: SearchIndexDefinition<T>) => SchemaSearchIndex<T>;
33+
vectorSearchIndex: (name: string, definition: VectorSearchIndexDefinition<T>) => SchemaSearchIndex<T>;
34+
}) => Record<string, SchemaSearchIndex<T>>;
35+
36+
export function makeSearchIndexes<T extends Record<string, AnyMonarchType>>(fn: SchemaSearchIndexes<T>) {
37+
return fn({
38+
searchIndex: (name, definition) => ({ name, type: "search", definition }),
39+
vectorSearchIndex: (name, definition) => ({ name, type: "vectorSearch", definition }),
40+
});
41+
}
42+
43+
type ExistingSearchIndex = {
44+
id: string;
45+
name: string;
46+
status: string;
47+
queryable: boolean;
48+
latestDefinition: Document;
49+
};
50+
51+
export async function applySearchIndexes(coll: MongoCollection, fn: SchemaSearchIndexes<any>) {
52+
const desired = Object.values(makeSearchIndexes(fn));
53+
const desiredByName = new Map(desired.map((idx) => [idx.name, idx]));
54+
55+
let existing: ExistingSearchIndex[];
56+
try {
57+
existing = (await coll.listSearchIndexes().toArray()) as ExistingSearchIndex[];
58+
} catch {
59+
return;
60+
}
61+
62+
const existingByName = new Map(existing.map((idx) => [idx.name, idx]));
63+
64+
// Drop stale indexes
65+
await Promise.all(
66+
Array.from(existingByName.keys())
67+
.filter((name) => !desiredByName.has(name))
68+
.map((name) =>
69+
coll.dropSearchIndex(name).catch((error) => {
70+
throw new MonarchError(`failed to drop search index '${name}': ${error}`, error);
71+
}),
72+
),
73+
);
74+
75+
// Create or update indexes
76+
await Promise.all(
77+
desired.map(async (idx) => {
78+
const existing = existingByName.get(idx.name);
79+
if (!existing) {
80+
await coll.createSearchIndex({ name: idx.name, type: idx.type, definition: idx.definition }).catch((error) => {
81+
throw new MonarchError(`failed to create search index '${idx.name}': ${error}`, error);
82+
});
83+
return;
84+
}
85+
86+
const defChanged = JSON.stringify(existing.latestDefinition) !== JSON.stringify(idx.definition);
87+
if (defChanged) {
88+
await coll.updateSearchIndex(idx.name, idx.definition).catch((error) => {
89+
throw new MonarchError(`failed to update search index '${idx.name}': ${error}`, error);
90+
});
91+
}
92+
}),
93+
);
94+
}

tests/database.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe("Database options", async () => {
7373
).rejects.toThrow("Document failed validation");
7474
});
7575

76-
it("initialize validation false skips applying validators", async () => {
76+
it("initialize without validation option skips applying validators", async () => {
7777
const schema = createSchema("users", {
7878
name: string(),
7979
nickname: string(),
@@ -86,21 +86,21 @@ describe("Database options", async () => {
8686
validationAction: "error",
8787
},
8888
});
89-
await db.initialize({ validation: false });
89+
await db.initialize({});
9090

9191
const rawCollection = client.db().collection("users");
9292
await expect(rawCollection.insertOne({})).resolves.toMatchObject({ acknowledged: true });
9393
});
9494

95-
it("initialize indexes false skips schema index creation", async () => {
95+
it("initialize without indexes option skips schema index creation", async () => {
9696
const schema = createSchema("users", {
9797
username: string(),
9898
}).indexes(({ unique }) => ({
9999
username: unique("username"),
100100
}));
101101

102102
const db = createDatabase(client.db(), defineSchemas({ users: schema }), { initialize: false });
103-
await db.initialize({ indexes: false });
103+
await db.initialize({});
104104

105105
const rawCollection = client.db().collection("users");
106106
await rawCollection.insertOne({ username: "same-user" });
@@ -123,7 +123,7 @@ describe("Database options", async () => {
123123
validationAction: "error",
124124
},
125125
});
126-
await db.initialize({ collections: { users: true } });
126+
await db.initialize(true, { users: true });
127127

128128
const existing = await client.db().listCollections({}, { nameOnly: true }).toArray();
129129
const existingNames = new Set(existing.map((collection) => collection.name));
@@ -164,7 +164,7 @@ describe("Database options", async () => {
164164
});
165165

166166
const firstDb = createDatabase(client.db(), defineSchemas({ users: schema }), { initialize: false });
167-
await firstDb.initialize({ validation: false });
167+
await firstDb.initialize({});
168168

169169
const rawCollection = client.db().collection("users");
170170
await expect(rawCollection.insertOne({})).resolves.toMatchObject({ acknowledged: true });

0 commit comments

Comments
 (0)