convex
Version:
Client for the Convex Cloud
639 lines (610 loc) • 16.9 kB
text/typescript
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* Utilities for defining the schema of your Convex project.
*
* ## Usage
*
* Schemas should be placed in a `schema.ts` file in your `convex/` directory.
*
* Schema definitions should be built using {@link defineSchema},
* {@link defineTable}, and {@link s}. Make sure to export the schema as the
* default export.
*
* ```ts
* import { defineSchema, defineTable, s } from "convex/schema";
*
* export default defineSchema({
* messages: defineTable({
* body: s.string(),
* user: s.id("users"),
* }),
* users: defineTable({
* name: s.string(),
* }),
* });
* ```
*
* To learn more about schemas, see [Defining a Schema](https://docs.convex.dev/using/schemas).
* @module
*/
import { GenericId } from "../values/index.js";
import {
AnyDataModel,
DocumentByInfo,
GenericDataModel,
GenericDocument,
GenericTableIndexes,
GenericTableInfo,
GenericTableSearchIndexes,
Indexes,
SearchIndexes,
} from "../server/data_model.js";
import {
IdField,
IndexTiebreakerField,
SystemFields,
SystemIndexes,
} from "../server/system_fields.js";
import { Expand } from "../type_utils.js";
/**
* A Convex type defined in a schema.
*
* These should be constructed using {@link s}.
*
* This class encapsulates:
* - The TypeScript type of this Convex type.
* - Whether this field should be optional if it's included in an object.
* - The TypeScript type for the set of index field paths that can be used to
* build indexes on this type.
* - The table names referenced in `s.id` usages in this type.
* @public
*/
export class SchemaType<
TypeScriptType,
IsOptional extends boolean = false,
FieldPaths extends string = never
> {
readonly type!: TypeScriptType;
readonly isOptional!: IsOptional;
readonly fieldPaths!: FieldPaths;
// Property for a bit of nominal type safety.
private _isSchemaType: undefined;
readonly referencedTableNames: Set<string>;
constructor(referencedTableNames: Set<string> = new Set()) {
this.referencedTableNames = referencedTableNames;
}
}
/**
* The schema builder.
*
* This builder allows you to define the types of documents stored in Convex.
* @public
*/
export const s = {
id<TableName extends string>(
tableName: TableName
): SchemaType<GenericId<TableName>> {
return new SchemaType(new Set([tableName]));
},
null(): SchemaType<null> {
return new SchemaType();
},
number(): SchemaType<number> {
return new SchemaType();
},
bigint(): SchemaType<bigint> {
return new SchemaType();
},
boolean(): SchemaType<boolean> {
return new SchemaType();
},
string(): SchemaType<string> {
return new SchemaType();
},
bytes(): SchemaType<ArrayBuffer> {
return new SchemaType();
},
literal<T extends string | number | bigint | boolean>(
literal: T
): SchemaType<T> {
return new SchemaType();
},
array<T>(values: SchemaType<T, false, any>): SchemaType<T[]> {
return new SchemaType(values.referencedTableNames);
},
set<T>(values: SchemaType<T, false, any>): SchemaType<Set<T>> {
return new SchemaType(values.referencedTableNames);
},
map<K, V>(
keys: SchemaType<K, false, any>,
values: SchemaType<V, false, any>
): SchemaType<Map<K, V>> {
return new SchemaType(
new Set([...keys.referencedTableNames, ...values.referencedTableNames])
);
},
object<T extends Record<string, SchemaType<any, any, any>>>(
schema: T
): ObjectSchemaType<T> {
const referencedTableNames = new Set<string>();
for (const schemaType of Object.values(schema)) {
for (const tableName of schemaType.referencedTableNames) {
referencedTableNames.add(tableName);
}
}
return new SchemaType(referencedTableNames);
},
union<
T extends [
SchemaType<any, false, any>,
SchemaType<any, false, any>,
...SchemaType<any, false, any>[]
]
>(
...schemaTypes: T
): SchemaType<T[number]["type"], false, T[number]["fieldPaths"]> {
const referencedTableNames = new Set<string>();
for (const schemaType of schemaTypes) {
for (const tableName of schemaType.referencedTableNames) {
referencedTableNames.add(tableName);
}
}
return new SchemaType(referencedTableNames);
},
any(): SchemaType<any, false, string> {
return new SchemaType();
},
optional<T extends SchemaType<any, false, any>>(
inner: T
): SchemaType<T["type"], true, T["fieldPaths"]> {
return new SchemaType(inner.referencedTableNames) as SchemaType<
T["type"],
true,
T["fieldPaths"]
>;
},
};
/**
* Calculate the {@link SchemaType} for an object.
*
* This is used within the SchemaBuilder {@link s}.
* @public
*/
type ObjectSchemaType<
SchemaValueType extends Record<string, SchemaType<any, any, any>>
> = SchemaType<
// Compute the TypeScript type for this object.
// Map each key to the corresponding type making the optional ones optional.
Expand<
{
[Property in OptionalKeys<SchemaValueType>]?: SchemaValueType[Property]["type"];
} & {
[Property in RequiredKeys<SchemaValueType>]: SchemaValueType[Property]["type"];
}
>,
false,
// Compute the field paths for this object. For every property in the object
// Add on a field path for that property and extend all the field paths in the
// value.
{
[Property in keyof SchemaValueType]:
| JoinFieldPaths<
Property & string,
SchemaValueType[Property]["fieldPaths"]
>
| Property;
}[keyof SchemaValueType] &
string
>;
type OptionalKeys<
SchemaValueType extends Record<string, SchemaType<any, any, any>>
> = {
[Property in keyof SchemaValueType]: SchemaValueType[Property]["isOptional"] extends true
? Property
: never;
}[keyof SchemaValueType];
type RequiredKeys<
SchemaValueType extends Record<string, SchemaType<any, any, any>>
> = Exclude<keyof SchemaValueType, OptionalKeys<SchemaValueType>>;
/**
* Join together two index field paths.
*
* This is used within the SchemaBuilder {@link s}.
* @public
*/
type JoinFieldPaths<
Start extends string,
End extends string
> = `${Start}.${End}`;
/**
* Extract all of the index field paths within a {@link SchemaType}.
*
* This is used within {@link defineTable}.
* @public
*/
type ExtractFieldPaths<T extends SchemaType<any, any, any>> =
// Add in the system fields available in index definitions.
// This should be everything except for `_id` because thats added to indexes
// automatically.
T["fieldPaths"] | keyof SystemFields;
/**
* Extract the {@link GenericDocument} within a {@link SchemaType} and
* add on the system fields.
*
* This is used within {@link defineTable}.
* @public
*/
type ExtractDocument<T extends SchemaType<any, any, any>> =
// Add the system fields to `Value` (except `_id` because it depends on
//the table name) and trick TypeScript into expanding them.
Expand<SystemFields & T["type"]>;
/**
* The configuration for a search index.
*
* @internal
*/
export interface SearchIndexConfig<
SearchField extends string,
FilterFields extends string
> {
/**
* The field to index for full text search.
*
* This must be a field of type `string`.
*/
searchField: SearchField;
/**
* Additional fields to index for fast filtering when running search queries.
*/
filterFields?: FilterFields[];
}
/**
* The definition of a table within a schema.
*
* This should be produced by using {@link defineTable}.
* @public
*/
export class TableDefinition<
Document extends GenericDocument = GenericDocument,
FieldPaths extends string = string,
// eslint-disable-next-line @typescript-eslint/ban-types
Indexes extends GenericTableIndexes = {},
// eslint-disable-next-line @typescript-eslint/ban-types
SearchIndexes extends GenericTableSearchIndexes = {}
> {
private indexes: { indexDescriptor: string; fields: string[] }[];
private searchIndexes: {
indexDescriptor: string;
searchField: string;
filterFields: string[];
}[];
// The type of documents stored in this table.
private documentType: SchemaType<any, any, any>;
/**
* @internal
*/
constructor(documentType: SchemaType<any, any, any>) {
this.indexes = [];
this.searchIndexes = [];
this.documentType = documentType;
}
/**
* Define an index on this table.
*
* To learn about indexes, see [Defining Indexes](https://docs.convex.dev/using/indexes).
*
* @param name - The name of the index.
* @param fields - The fields to index, in order. Must specify at least one
* field.
* @returns A {@link TableDefinition} with this index included.
*/
index<
IndexName extends string,
FirstFieldPath extends FieldPaths,
RestFieldPaths extends FieldPaths[]
>(
name: IndexName,
fields: [FirstFieldPath, ...RestFieldPaths]
): TableDefinition<
Document,
FieldPaths,
// Update `Indexes` to include the new index and use `Expand` to make the
// types look pretty in editors.
Expand<
Indexes &
Record<
IndexName,
[FirstFieldPath, ...RestFieldPaths, IndexTiebreakerField]
>
>,
SearchIndexes
> {
this.indexes.push({ indexDescriptor: name, fields });
return this;
}
/**
* Define a search index on this table.
*
* @param name - The name of the index.
* @param indexConfig - The search index configuration object.
* @returns A {@link TableDefinition} with this search index included.
* @internal
*/
searchIndex<
IndexName extends string,
SearchField extends FieldPaths,
FilterFields extends FieldPaths = never
>(
name: IndexName,
indexConfig: Expand<SearchIndexConfig<SearchField, FilterFields>>
): TableDefinition<
Document,
FieldPaths,
Indexes,
// Update `SearchIndexes` to include the new index and use `Expand` to make
// the types look pretty in editors.
Expand<
SearchIndexes &
Record<
IndexName,
{
searchField: SearchField;
filterFields: FilterFields;
}
>
>
> {
this.searchIndexes.push({
indexDescriptor: name,
searchField: indexConfig.searchField,
filterFields: indexConfig.filterFields || [],
});
return this;
}
/**
* Export the contents of this definition.
*
* This is called internally by the Convex framework.
* @internal
*/
export() {
return {
indexes: this.indexes,
searchIndexes: this.searchIndexes,
referencedTableNames: this.documentType.referencedTableNames,
};
}
}
/**
* Define a table in a schema.
*
* You can either specify the schema of your documents as an object like
* ```ts
* defineTable({
* field: s.string()
* });
* ```
*
* or as a schema type like
* ```ts
* defineTable(
* s.union(
* s.object({...}),
* s.object({...})
* )
* );
* ```
*
* @param documentSchema - The type of documents stored in this table.
* @returns A {@link TableDefinition} for the table.
*
* @public
*/
export function defineTable<
DocumentSchema extends SchemaType<Record<string, any>, false, any>
>(
documentSchema: DocumentSchema
): TableDefinition<
ExtractDocument<DocumentSchema>,
ExtractFieldPaths<DocumentSchema>
>;
/**
* Define a table in a schema.
*
* You can either specify the schema of your documents as an object like
* ```ts
* defineTable({
* field: s.string()
* });
* ```
*
* or as a schema type like
* ```ts
* defineTable(
* s.union(
* s.object({...}),
* s.object({...})
* )
* );
* ```
*
* @param documentSchema - The type of documents stored in this table.
* @returns A {@link TableDefinition} for the table.
*
* @public
*/
export function defineTable<
DocumentSchema extends Record<string, SchemaType<any, any, any>>
>(
documentSchema: DocumentSchema
): TableDefinition<
ExtractDocument<ObjectSchemaType<DocumentSchema>>,
ExtractFieldPaths<ObjectSchemaType<DocumentSchema>>
>;
export function defineTable<
DocumentSchema extends
| SchemaType<Record<string, any>, false, any>
| Record<string, SchemaType<any, any, any>>
>(documentSchema: DocumentSchema): TableDefinition<any, any> {
if (documentSchema instanceof SchemaType) {
return new TableDefinition(documentSchema);
} else {
return new TableDefinition(s.object(documentSchema));
}
}
/**
* A type describing the schema of a Convex project.
*
* This should be constructed using {@link defineSchema}, {@link defineTable},
* and {@link s}.
* @public
*/
export type GenericSchema = Record<string, TableDefinition>;
/**
*
* The definition of a Convex project schema.
*
* This should be produced by using {@link defineSchema}.
* @public
*/
export class SchemaDefinition<
Schema extends GenericSchema,
IsStrict extends boolean
> {
public tables: Schema;
public isStrict: IsStrict;
/**
* @internal
*/
constructor(tables: Schema, options?: DefineSchemaOptions<IsStrict>) {
this.tables = tables;
this.isStrict = !!options?.strict as IsStrict;
}
/**
* Export the contents of this definition.
*
* This is called internally by the Convex framework.
* @internal
*/
export(): string {
const tableNames = new Set(Object.keys(this.tables));
return JSON.stringify({
tables: Object.entries(this.tables).map(([tableName, definition]) => {
const { indexes, searchIndexes, referencedTableNames } =
definition.export();
// Make sure all the referenced table names are actually defined.
for (const referencedTableName of referencedTableNames) {
if (!tableNames.has(referencedTableName)) {
throw new Error(
`SchemaValidationError: Table ${tableName} has a \`s.id\` ` +
`expression that references table ${referencedTableName} which isn't defined in the schema.`
);
}
}
return {
tableName,
indexes,
searchIndexes,
};
}),
});
}
}
/**
* Options for {@link defineSchema}.
*
* @public
*/
export interface DefineSchemaOptions<IsStrict extends boolean> {
/**
* Whether to strictly enforce this schema.
*
* If this schema is not strictly enforced, its type will permit:
* 1. Using additional tables not explicitly listed in the schema.
* 2. Using additional properties not explicitly listed in the tables.
*
* Loose schemas are useful for rapid prototyping.
*
* By default the schema is considered strict.
*/
strict?: IsStrict;
}
/**
* Define the schema of this Convex project.
*
* This should be exported from a `schema.ts` file in your `convex/` directory
* like:
*
* ```ts
* export default defineSchema({
* ...
* });
* ```
*
* @param schema - A map from table name to {@link TableDefinition} for all of
* the tables in this project.
* @param options - Optional configuration. See {@link DefineSchemaOptions} for
* a full description.
* @returns The schema.
*
* @public
*/
export function defineSchema<
Schema extends GenericSchema,
IsStrict extends boolean = true
>(
schema: Schema,
options?: DefineSchemaOptions<IsStrict>
): SchemaDefinition<Schema, IsStrict> {
return new SchemaDefinition(schema, options);
}
/**
* Internal type used in Convex code generation!
*
* Convert a {@link SchemaDefinition} into a {@link server.GenericDataModel}.
*
* @public
*/
export type DataModelFromSchemaDefinition<
SchemaDef extends SchemaDefinition<any, boolean>
> = MaybeMakeLooseDataModel<
{
[TableName in keyof SchemaDef["tables"] &
string]: SchemaDef["tables"][TableName] extends TableDefinition<
infer Document,
infer FieldPaths,
infer Indexes,
infer SearchIndexes
>
? MaybeMakeLooseTableInfo<
{
// We've already added all of the system fields except for `_id`.
// Add that here.
document: Expand<IdField<TableName> & Document>;
fieldPaths: keyof IdField<TableName> | FieldPaths;
indexes: Expand<Indexes & SystemIndexes>;
searchIndexes: SearchIndexes;
},
SchemaDef["isStrict"]
>
: never;
},
SchemaDef["isStrict"]
>;
type MaybeMakeLooseDataModel<
DataModel extends GenericDataModel,
IsStrict extends boolean
> = IsStrict extends true ? DataModel : Expand<DataModel & AnyDataModel>;
type MaybeMakeLooseTableInfo<
TableInfo extends GenericTableInfo,
IsStrict extends boolean
> = IsStrict extends true
? TableInfo
: {
document: Expand<
DocumentByInfo<TableInfo> & { [propertyName: string]: any }
>;
fieldPaths: string;
indexes: Indexes<TableInfo>;
searchIndexes: SearchIndexes<TableInfo>;
};