Skip to content

Commit

Permalink
Merge pull request #292 from evoluhq/improve-schema-dx
Browse files Browse the repository at this point in the history
Improve table and database schema DX.
  • Loading branch information
steida authored Dec 13, 2023
2 parents 94b1f41 + d289ac7 commit 631e033
Show file tree
Hide file tree
Showing 12 changed files with 341 additions and 102 deletions.
37 changes: 37 additions & 0 deletions .changeset/green-cars-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@evolu/common": major
---

Improve table and database schema DX.

In the previous Evolu version, table and database schemas were created with `S.struct` and validated with createEvolu. Because of how the TypeScript compiler works, type errors were incomprehensible.

We added two new helper functions to improve a DX: `table` and `database`.

Previous schema definition:

```ts
const TodoTable = S.struct({
id: TodoId,
title: NonEmptyString1000,
});
const Database = S.struct({
todo: TodoTable,
});
```

New schema definition:

```ts
const TodoTable = table({
id: TodoId,
title: NonEmptyString1000,
});
const Database = database({
todo: TodoTable,
});
```

Those two helpers also detect missing ID columns and the usage of reserved columns.

This update is a breaking change because reserved columns (createdAt, updatedAt, isDeleted) are created with `table` function now.
8 changes: 5 additions & 3 deletions apps/web/components/NextJsExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
canUseDom,
cast,
createEvolu,
database,
id,
jsonArrayFrom,
parseMnemonic,
table,
useEvolu,
useEvoluError,
useOwner,
Expand Down Expand Up @@ -40,7 +42,7 @@ const NonEmptyString50 = String.pipe(
);
type NonEmptyString50 = S.Schema.To<typeof NonEmptyString50>;

const TodoTable = S.struct({
const TodoTable = table({
id: TodoId,
title: NonEmptyString1000,
isCompleted: S.nullable(SqliteBoolean),
Expand All @@ -51,14 +53,14 @@ type TodoTable = S.Schema.To<typeof TodoTable>;
const SomeJson = S.struct({ foo: S.string, bar: S.boolean });
type SomeJson = S.Schema.To<typeof SomeJson>;

const TodoCategoryTable = S.struct({
const TodoCategoryTable = table({
id: TodoCategoryId,
name: NonEmptyString50,
json: S.nullable(SomeJson),
});
type TodoCategoryTable = S.Schema.To<typeof TodoCategoryTable>;

const Database = S.struct({
const Database = database({
todo: TodoTable,
todoCategory: TodoCategoryTable,
});
Expand Down
6 changes: 4 additions & 2 deletions apps/web/pages/docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,21 @@ import {
SqliteBoolean,
createEvolu,
id,
table,
database,
} from "@evolu/react";

const TodoId = id("Todo");
type TodoId = S.Schema.To<typeof TodoId>;

const TodoTable = S.struct({
const TodoTable = table({
id: TodoId,
title: NonEmptyString1000,
isCompleted: SqliteBoolean,
});
type TodoTable = S.Schema.To<typeof TodoTable>;

const Database = S.struct({
const Database = database({
todo: TodoTable,
});
type Database = S.Schema.To<typeof Database>;
Expand Down
16 changes: 6 additions & 10 deletions apps/web/pages/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,36 +32,32 @@ import {
SqliteBoolean,
cast,
createEvolu,
database,
id,
table,
useEvolu,
useQuery,
} from "@evolu/react";

// Create TodoId schema.
const TodoId = id("Todo");

// It's branded string: string & Brand<"Id"> & Brand<"Todo">
// TodoId type ensures no other ID can be used where TodoId is expected.
type TodoId = S.Schema.To<typeof TodoId>;

// Create TodoTable schema.
const TodoTable = S.struct({
const TodoTable = table({
id: TodoId,
// Note we can enforce NonEmptyString1000.
title: NonEmptyString1000,
// SQLite doesn't support the boolean type, so Evolu uses
// SqliteBoolean (a branded number) instead.
// SQLite doesn't support the boolean type, so Evolu uses SqliteBoolean instead.
isCompleted: S.nullable(SqliteBoolean),
});
type TodoTable = S.Schema.To<typeof TodoTable>;

// Create database schema.
const Database = S.struct({
const Database = database({
todo: TodoTable,
});
type Database = S.Schema.To<typeof Database>;

// Create Evolu.
const evolu = createEvolu(Database);

// Create a typed SQL query. Yes, with autocomplete and type-checking.
Expand All @@ -78,7 +74,7 @@ const allTodos = evolu.createQuery((db) =>
const allTodosPromise = evolu.loadQuery(allTodos);

// Use the query in React reactively (it's updated on a mutation).
const { rows, row } = useQuery(allTodos);
const { rows } = useQuery(allTodos);

// Create a todo.
const { create } = useEvolu<Database>();
Expand Down
4 changes: 2 additions & 2 deletions packages/eslint-config-evolu/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"clean": "rm -rf node_modules"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"eslint-config-next": "14.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.11.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/evolu-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
"protobuf": "pnpm protoc --ts_out ./src --proto_path protobuf protobuf/Protobuf.proto --ts_opt eslint_disable --ts_opt optimize_code_size && pnpm format"
},
"dependencies": {
"@noble/ciphers": "^0.4.0",
"@noble/hashes": "^1.3.2",
"@noble/ciphers": "^0.4.1",
"@noble/hashes": "^1.3.3",
"@protobuf-ts/runtime": "^2.9.3",
"@scure/bip39": "^1.2.1",
"kysely": "^0.26.3",
Expand Down
103 changes: 96 additions & 7 deletions packages/evolu-common/src/Db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ReadonlyArray,
ReadonlyRecord,
String,
Types,
pipe,
} from "effect";
import * as Kysely from "kysely";
Expand All @@ -23,7 +24,7 @@ import {
timestampToString,
} from "./Crdt.js";
import { Bip39, Mnemonic, NanoId } from "./Crypto.js";
import { Id } from "./Model.js";
import { Id, SqliteBoolean, SqliteDate } from "./Model.js";
import { Owner, makeOwner } from "./Owner.js";
import {
createMessageTable,
Expand All @@ -39,13 +40,105 @@ import {
isJsonObjectOrArray,
} from "./Sqlite.js";
import { Store, makeStore } from "./Store.js";
import { EvoluTypeError } from "./ErrorStore.js";

export type DatabaseSchema = ReadonlyRecord.ReadonlyRecord<TableSchema>;

export type TableSchema = ReadonlyRecord.ReadonlyRecord<Value> & {
type TableSchema = ReadonlyRecord.ReadonlyRecord<Value> & {
readonly id: Id;
};

/**
* Create table schema.
*
* Supported types are null, string, number, Uint8Array, JSON Object, and JSON
* Array. Use SqliteDate for dates and SqliteBoolean for booleans.
*
* Reserved columns are createdAt, updatedAt, isDeleted. Those columns are added
* by default.
*
* @example
* const TodoId = id("Todo");
* type TodoId = S.Schema.To<typeof TodoId>;
*
* const TodoTable = table({
* id: TodoId,
* title: NonEmptyString1000,
* isCompleted: S.nullable(SqliteBoolean),
* });
* type TodoTable = S.Schema.To<typeof TodoTable>;
*/
export const table = <Fields extends TableFields>(
fields: Fields,
// Because Schema is invariant, we have to do validation like this.
): ValidateFieldsTypes<Fields> extends true
? ValidateFieldsNames<Fields> extends true
? ValidateFieldsHasId<Fields> extends true
? S.Schema<
Types.Simplify<
S.FromStruct<Fields> & S.Schema.From<typeof ReservedColumns>
>,
Types.Simplify<
S.ToStruct<Fields> & S.Schema.To<typeof ReservedColumns>
>
>
: EvoluTypeError<"table() called without id column.">
: EvoluTypeError<"table() called with a reserved column. Reserved columns are createdAt, updatedAt, isDeleted. Those columns are added by default.">
: EvoluTypeError<"table() called with unsupported type. Supported types are null, string, number, Uint8Array, JSON Object, and JSON Array. Use SqliteDate for dates and SqliteBoolean for booleans."> =>
S.struct(fields).pipe(S.extend(ReservedColumns)) as never;

const ReservedColumns = S.struct({
createdAt: SqliteDate,
updatedAt: SqliteDate,
isDeleted: SqliteBoolean,
});

type TableFields = Record<string, S.Schema<any, any>>;

type ValidateFieldsTypes<Fields extends TableFields> =
keyof Fields extends infer K
? K extends keyof Fields
? Fields[K] extends TableFields
? ValidateFieldsTypes<Fields[K]>
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
Fields[K] extends S.Schema<infer _I, infer A>
? A extends Value
? true
: false
: never
: never
: never;

type ValidateFieldsNames<Fields extends TableFields> =
keyof Fields extends infer K
? K extends keyof Fields
? K extends "createdAt" | "updatedAt" | "isDeleted"
? false
: true
: never
: never;

type ValidateFieldsHasId<Fields extends TableFields> = "id" extends keyof Fields
? true
: false;

/**
* Create database schema.
*
* Tables with a name prefixed with _ are local-only, which means they are not
* synced. Local-only tables are useful for device-specific or temporal data.
*
* @example
* const Database = database({
* // A local-only table.
* _todo: TodoTable,
* todo: TodoTable,
* todoCategory: TodoCategoryTable,
* });
* type Database = S.Schema.To<typeof Database>;
*/
export const database = S.struct;

// https://blog.beraliv.dev/2021-05-07-opaque-type-in-typescript
declare const __queryBrand: unique symbol;

Expand Down Expand Up @@ -168,18 +261,14 @@ const getPropertySignatures = <I extends { [K in keyof A]: any }, A>(
return out as any;
};

const commonColumns = ["createdAt", "updatedAt", "isDeleted"] as const;

export const schemaToTables = (schema: S.Schema<any, any>): Tables =>
pipe(
getPropertySignatures(schema),
ReadonlyRecord.toEntries,
ReadonlyArray.map(
([name, schema]): Table => ({
name,
columns: Object.keys(getPropertySignatures(schema)).concat(
commonColumns,
),
columns: Object.keys(getPropertySignatures(schema)),
}),
),
);
Expand Down
4 changes: 4 additions & 0 deletions packages/evolu-common/src/ErrorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ interface TransferableError {
readonly message: string;
readonly stack: string | undefined;
}

export interface EvoluTypeError<E extends string> {
readonly __evoluTypeError__: E;
}
24 changes: 9 additions & 15 deletions packages/evolu-common/src/Evolu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,23 +298,15 @@ type QueryCallback<S extends DatabaseSchema, R extends Row> = (
) => Kysely.SelectQueryBuilder<any, any, R>;

type QuerySchema<S extends DatabaseSchema> = {
readonly [Table in keyof S]: NullableExceptId<
{
readonly [Column in keyof S[Table]]: S[Table][Column];
} & CommonColumns
>;
readonly [Table in keyof S]: NullableExceptId<{
readonly [Column in keyof S[Table]]: S[Table][Column];
}>;
};

type NullableExceptId<T> = {
readonly [K in keyof T]: K extends "id" ? T[K] : T[K] | null;
};

export interface CommonColumns {
readonly createdAt: SqliteDate;
readonly updatedAt: SqliteDate;
readonly isDeleted: SqliteBoolean;
}

const kysely = new Kysely.Kysely<QuerySchema<DatabaseSchema>>({
dialect: {
createAdapter: (): Kysely.DialectAdapter => new Kysely.SqliteAdapter(),
Expand Down Expand Up @@ -568,10 +560,12 @@ export type Mutate<
table: K,
values: Kysely.Simplify<
Mode extends "create"
? PartialForNullable<Castable<Omit<S[K], "id">>>
: Partial<
Castable<Omit<S[K], "id"> & Pick<CommonColumns, "isDeleted">>
> & { readonly id: S[K]["id"] }
? PartialForNullable<
Castable<Omit<S[K], "id" | "createdAt" | "updatedAt" | "isDeleted">>
>
: Partial<Castable<Omit<S[K], "id" | "createdAt" | "updatedAt">>> & {
readonly id: S[K]["id"];
}
>,
onComplete?: () => void,
) => {
Expand Down
1 change: 1 addition & 0 deletions packages/evolu-common/src/Public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type { Timestamp, TimestampError } from "./Crdt.js";
export type { Mnemonic, InvalidMnemonicError } from "./Crypto.js";
export type { EvoluError, UnexpectedError } from "./ErrorStore.js";
export * from "./Model.js";
export { table, database } from "./Db.js";
export type { Owner, OwnerId } from "./Owner.js";
export { canUseDom } from "./Platform.js";
export type { SyncState } from "./SyncWorker.js";
2 changes: 2 additions & 0 deletions packages/evolu-react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export {
canUseDom,
cast,
id,
table,
database,
} from "@evolu/common";
export type {
EvoluError,
Expand Down
Loading

1 comment on commit 631e033

@vercel
Copy link

@vercel vercel bot commented on 631e033 Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

evolu – ./

evolu-git-main-evolu.vercel.app
evolu.vercel.app
www.evolu.dev
evolu-evolu.vercel.app
evolu.dev

Please sign in to comment.