From 5dfaabf610b27ce31038e3a351592daecd34657d Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:02:53 +0200 Subject: [PATCH 1/4] feat(core): derive select, insert, update and json Schemas from a table --- src/ch/table-schemas.test-d.ts | 65 ++++++++++++++++++++ src/ch/table-schemas.test.ts | 106 +++++++++++++++++++++++++++++++++ src/ch/table-schemas.ts | 76 +++++++++++++++++++++++ src/core.ts | 1 + 4 files changed, 248 insertions(+) create mode 100644 src/ch/table-schemas.test-d.ts create mode 100644 src/ch/table-schemas.test.ts create mode 100644 src/ch/table-schemas.ts diff --git a/src/ch/table-schemas.test-d.ts b/src/ch/table-schemas.test-d.ts new file mode 100644 index 0000000..14fef15 --- /dev/null +++ b/src/ch/table-schemas.test-d.ts @@ -0,0 +1,65 @@ +// Type-level tests: the row Schemas of a table + +import { type DateTime, Schema } from "effect" +import { expectTypeOf } from "expect-type" +import * as CH from "./index" +import * as CHD from "../clickhouse" + +const Spans = CHD.table("spans", { + columns: { + OrgId: CH.string, + Id: CHD.column(CH.uint64, { default: 0 }), + At: CH.dateTime, + Note: CH.nullable(CH.string), + Day: CHD.column(CH.string, { materialized: "toString(toDate(At))" }), + }, + engine: CHD.engine.mergeTree(), + orderBy: ["OrgId", "At"], +}) + +const schemas = CH.tableSchemas(Spans) + +// select is the whole decoded row, as SelectRowOf names it. +expectTypeOf().toEqualTypeOf>() + +// insert: defaulted and nullable columns optional, computed ones absent. +expectTypeOf().toEqualTypeOf<{ + readonly OrgId: string + readonly At: DateTime.Utc + readonly Id?: number | undefined + readonly Note?: string | null | undefined +}>() + +// A decoded insert row is a row `values` takes. +declare const created: typeof schemas.insert.Type +CH.insertInto(Spans).values(created) + +// update: every writable column optional. +expectTypeOf().toEqualTypeOf<{ + readonly OrgId?: string | undefined + readonly Id?: number | undefined + readonly At?: DateTime.Utc | undefined + readonly Note?: string | null | undefined +}>() +declare const patch: typeof schemas.update.Type & { readonly Id: number } +CH.update(Spans).set(patch).allRows() + +// The json variants keep the row types and encode to JSON. +expectTypeOf().toEqualTypeOf>() +expectTypeOf().toEqualTypeOf() +expectTypeOf().toEqualTypeOf() +expectTypeOf().toEqualTypeOf() + +// The select Schema's wire side is what the database sends. +expectTypeOf().toEqualTypeOf() + +// A table written with the bare `table` and no options: every non-null column required. +const Plain = CH.table("plain", { A: CH.string, B: CH.nullable(CH.uint32) }) +expectTypeOf["insert"]["Type"]>().toEqualTypeOf<{ + readonly A: string + readonly B?: number | null | undefined +}>() + +// A computed column is not part of an insert row. +// @ts-expect-error Day is computed +const withDay: typeof schemas.insert.Type = { OrgId: "o", At: {} as DateTime.Utc, Day: "2026-01-01" } diff --git a/src/ch/table-schemas.test.ts b/src/ch/table-schemas.test.ts new file mode 100644 index 0000000..9e174a9 --- /dev/null +++ b/src/ch/table-schemas.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "@effect/vitest" +import { DateTime, Schema } from "effect" +import * as CH from "./index" +import * as CHD from "../clickhouse" +import * as PG from "../postgres" + +const Spans = CHD.table("spans", { + columns: { + OrgId: CH.string, + Id: CHD.column(CH.uint64, { default: 0 }), + At: CH.dateTime, + Note: CH.nullable(CH.string), + Day: CHD.column(CH.string, { materialized: "toString(toDate(At))" }), + }, + engine: CHD.engine.mergeTree(), + orderBy: ["OrgId", "At"], +}) + +describe("tableSchemas", () => { + it("select decodes a whole row from the wire, as a SELECT of every column does", () => { + const { select } = CH.tableSchemas(Spans) + const row = Schema.decodeUnknownSync(select)({ + OrgId: "o", + Id: "7", + At: "2026-01-01 00:00:00", + Note: null, + Day: "2026-01-01", + }) + expect(row).toEqual({ + OrgId: "o", + Id: 7, + At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"), + Note: null, + Day: "2026-01-01", + }) + }) + + it("insert makes defaulted and nullable columns optional and leaves computed ones out", () => { + const { insert } = CH.tableSchemas(Spans) + expect(Object.keys(insert.fields)).toEqual(["OrgId", "Id", "At", "Note"]) + const decode = Schema.decodeUnknownSync(insert) + expect(decode({ OrgId: "o", At: "2026-01-01 00:00:00" })).toEqual({ + OrgId: "o", + At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"), + }) + expect(decode({ OrgId: "o", At: "2026-01-01 00:00:00", Id: undefined, Note: null })).toEqual({ + OrgId: "o", + At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"), + Id: undefined, + Note: null, + }) + expect(() => decode({ OrgId: "o" })).toThrow(/At/) + }) + + it("update makes every writable column optional and leaves computed ones out", () => { + const { update } = CH.tableSchemas(Spans) + expect(Object.keys(update.fields)).toEqual(["OrgId", "Id", "At", "Note"]) + const decode = Schema.decodeUnknownSync(update) + expect(decode({})).toEqual({}) + expect(decode({ Id: "8", Note: null })).toEqual({ Id: 8, Note: null }) + expect(() => decode({ OrgId: null })).toThrow(/OrgId/) + }) + + it("json writes the select row in plain JSON and reads it back", () => { + const { json } = CH.tableSchemas(Spans) + const row = { OrgId: "o", Id: 7, At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"), Note: null, Day: "2026-01-01" } + const encoded = Schema.encodeSync(json)(row) + expect(encoded).toEqual({ OrgId: "o", Id: 7, At: "2026-01-01T00:00:00.000Z", Note: null, Day: "2026-01-01" }) + expect(Schema.decodeUnknownSync(json)(JSON.parse(JSON.stringify(encoded)))).toEqual(row) + }) + + it("jsonCreate and jsonUpdate read request bodies with the insert and update keys", () => { + const { jsonCreate, jsonUpdate } = CH.tableSchemas(Spans) + expect(Schema.decodeUnknownSync(jsonCreate)({ OrgId: "o", At: "2026-01-01T00:00:00.000Z", Note: null })).toEqual({ + OrgId: "o", + At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"), + Note: null, + }) + expect(() => Schema.decodeUnknownSync(jsonCreate)({ OrgId: "o" })).toThrow(/At/) + expect(Schema.decodeUnknownSync(jsonUpdate)({ Note: "n" })).toEqual({ Note: "n" }) + expect(Schema.decodeUnknownSync(jsonUpdate)({ Day: "2026-01-01" })).toEqual({}) + }) + + it("reads a Postgres table: identity and default columns are optional on insert, keys are the column keys", () => { + const Requests = PG.table("requests", { + columns: { + id: PG.column(PG.int8, { identity: "always" }), + orgId: PG.column(PG.text, { name: "org_id" }), + createdAt: PG.column(PG.timestamptz, { defaultExpr: "now()" }), + body: PG.nullable(PG.jsonb<{ readonly path: string }>()), + }, + primaryKey: ["id"], + }) + const { insert, json } = PG.tableSchemas(Requests) + expect(Schema.decodeUnknownSync(insert)({ orgId: "o" })).toEqual({ orgId: "o" }) + expect(() => Schema.decodeUnknownSync(insert)({})).toThrow(/orgId/) + expect( + Schema.encodeSync(json)({ + id: 1, + orgId: "o", + createdAt: DateTime.makeUnsafe("2026-01-01T00:00:00Z"), + body: { path: "/" }, + }), + ).toEqual({ id: 1, orgId: "o", createdAt: "2026-01-01T00:00:00.000Z", body: { path: "/" } }) + }) +}) diff --git a/src/ch/table-schemas.ts b/src/ch/table-schemas.ts new file mode 100644 index 0000000..e5ccfea --- /dev/null +++ b/src/ch/table-schemas.ts @@ -0,0 +1,76 @@ +// Table Schemas +// +// The Effect Schemas of a table's rows, derived from its columns: what a +// `SELECT` of every column decodes, what an insert or an update takes, and the +// JSON forms of the same for an HTTP API. The names follow `Model.Class` in +// `effect/unstable/schema/Model`, so a `SqlModel` user finds the same variants. + +import { Schema } from "effect" +import type { Table } from "./table" +import type { ColumnDefs, InferTS } from "./types" + +/** A bare `string` means the table did not say, which is read as none. */ +type Known = string extends K ? never : K + +type Simplify = { [K in keyof A]: A[K] } & {} + +type NullableKeys = { + [K in keyof Cols]: null extends InferTS ? K : never +}[keyof Cols] & + string + +type Writable = Exclude> +type Optional = Extract< + Known | NullableKeys, + Writable +> + +type SelectFields = { readonly [K in keyof Cols]: Cols[K]["schema"] } + +type InsertFields = Simplify< + { + readonly [K in Exclude, Optional>]: Cols[K]["schema"] + } & { + readonly [K in Optional]: Schema.optional + } +> + +type UpdateFields = { + readonly [K in Writable]: Schema.optional +} + +/** The row Schemas of a table value: `TableSchemas`. */ +export type TableSchemas = + T extends Table + ? { + readonly select: Schema.Struct> + readonly insert: Schema.Struct> + readonly update: Schema.Struct> + readonly json: Schema.toCodecJson>>> + readonly jsonCreate: Schema.toCodecJson>>> + readonly jsonUpdate: Schema.toCodecJson>>> + } + : never + +type AnyTable = Table + +export const tableSchemas = (table: T): TableSchemas => { + const computed = new Set(table.computed ?? []) + const defaulted = new Set(table.defaults ?? []) + const columns = Object.entries(table.columns) + const writable = columns.filter(([key]) => !computed.has(key)) + const isOptional = (key: string, schema: Schema.Top) => defaulted.has(key) || Schema.is(schema)(null) + const select = Object.fromEntries(columns.map(([key, column]) => [key, column.schema])) + const insert = Object.fromEntries( + writable.map(([key, column]) => [key, isOptional(key, column.schema) ? Schema.optional(column.schema) : column.schema]), + ) + const update = Object.fromEntries(writable.map(([key, column]) => [key, Schema.optional(column.schema)])) + const rows = { select: Schema.Struct(select), insert: Schema.Struct(insert), update: Schema.Struct(update) } + const toJson = (schema: Schema.Struct) => Schema.toCodecJson(Schema.toType(schema)) + return { + ...rows, + json: toJson(rows.select), + jsonCreate: toJson(rows.insert), + jsonUpdate: toJson(rows.update), + } as unknown as TableSchemas +} diff --git a/src/core.ts b/src/core.ts index b32adcf..a68e477 100644 --- a/src/core.ts +++ b/src/core.ts @@ -17,6 +17,7 @@ export { // Tables, as the query builder sees them. Define one with the dialect's `table`. export { type SelectRowOf, type Table } from "./ch/table" +export { type TableSchemas, tableSchemas } from "./ch/table-schemas" // Core expression primitives export { From 79084aed5136e6ebd45a56c91b18b30702090f15 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:03:26 +0200 Subject: [PATCH 2/4] docs: row Schemas from tableSchemas, with a changeset --- .changeset/table-row-schemas.md | 5 +++++ docs/reference.md | 4 +++- docs/tables-and-types.md | 28 ++++++++++++++++++++++++++++ src/docs-examples.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .changeset/table-row-schemas.md diff --git a/.changeset/table-row-schemas.md b/.changeset/table-row-schemas.md new file mode 100644 index 0000000..7b10ddf --- /dev/null +++ b/.changeset/table-row-schemas.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": minor +--- + +`tableSchemas(table)` derives the Effect Schemas of a table's rows from its columns, on both `/clickhouse` and `/postgres`: `select` (every column, decoded from the wire), `insert` (computed columns left out, defaulted and nullable ones optional), `update` (every writable column optional), and `json`, `jsonCreate` and `jsonUpdate`, the same rows as plain JSON for an HTTP API. The names follow `Model.Class`. A decoded `insert` or `update` row is a row `insertInto(...).values` or `update(...).set` takes, so request bodies and repositories no longer need Schemas written by hand next to the table. diff --git a/docs/reference.md b/docs/reference.md index abc2d46..0a9a59b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -46,6 +46,7 @@ Everything in this section is exported, identically, from both `/clickhouse` and | `unionAll` | `(...queries) => CHUnionQuery` | | `update` | `(table) => CHUpdateStart`, then `CHUpdate`: `.set(record \| fn)`, `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)`. See [Updating and deleting](./updates-and-deletes.md) | | `deleteFrom` | `(table) => CHDelete`: `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)` | +| `tableSchemas` | `(table) => TableSchemas`: the `select`, `insert`, `update`, `json`, `jsonCreate` and `jsonUpdate` row Schemas. See [Row Schemas](./tables-and-types.md#row-schemas) | | `insertInto` | `(table) => CHInsertStart`, then `CHInsert`; `.values(row \| rows)` or `.select(query)` sets its rows, `.settings(record)` ClickHouse `SETTINGS`, `.returning(...)` the RETURNING list, `.onConflictDoNothing(options?)` / `.onConflictDoUpdate(options)` the ON CONFLICT clause (Postgres). See [Inserting rows](./inserts.md) | Tables come from the dialect's `table`: [`CH.table`](#tables-and-ddl) or @@ -179,7 +180,8 @@ decoded type of a column), `InferEncoded` (its wire type), `ColumnDefs`, `Nullab `OutputToColumnDefs`. **Inference** — `InferOutput`, `InferQueryOutput`, `InferUnionOutput`, `SelectRowOf`, -`InsertRow`, `InsertRowOf`, `UpdateSet`, `UpdateSetOf`. +`InsertRow`, `InsertRowOf`, `UpdateSet`, `UpdateSetOf`, `TableSchemas` (what `tableSchemas` +returns for a table). **What `compile` checks** — `NeedsSelect` (the query has a SELECT list) and `ParamsSatisfied` (the params object fills every required param). A helper generic diff --git a/docs/tables-and-types.md b/docs/tables-and-types.md index 23b0795..3f71780 100644 --- a/docs/tables-and-types.md +++ b/docs/tables-and-types.md @@ -251,6 +251,34 @@ Related utilities: `ColumnDefs` (the shape of a `columns` record), `OutputToColu (converts a query's output row back into column defs, used by `fromQuery`), and `NullableColumnDefs` (what `leftJoin` applies to the joined side). +## Row Schemas + +`tableSchemas(table)` derives the Effect Schemas of a table's rows from its columns, so an +`HttpApi` endpoint or a repository uses the same definition as the queries. The variant names are +the ones `Model.Class` uses: + +| Variant | Columns | Decodes from | +| ------------ | ---------------------------------------------------- | ------------------ | +| `select` | every column | the database wire | +| `insert` | no computed column; defaulted and nullable optional | the database wire | +| `update` | no computed column; every column optional | the database wire | +| `json` | as `select` | JSON | +| `jsonCreate` | as `insert` | JSON | +| `jsonUpdate` | as `update` | JSON | + +The JSON variants are `Schema.toCodecJson` of each row's type, so a `DateTime` is an ISO string +and a branded column keeps its brand. An optional key also takes `undefined`, as in an insert row. + +```ts +const { select, jsonCreate } = CH.tableSchemas(Requests) + +type Request = typeof select.Type // SelectRowOf +const body = Schema.decodeUnknownSync(jsonCreate)({ OrgId: "org_1", Route: "/" }) +CH.insertInto(Requests).values(body) // a decoded body is an insert row +``` + +_(Backed by `docs/tables-and-types.md > Row Schemas`.)_ + ## Map columns `Map` columns get a `.get(key)` accessor that compiles to ClickHouse's bracket syntax: diff --git a/src/docs-examples.test.ts b/src/docs-examples.test.ts index ef673be..d4f01f2 100644 --- a/src/docs-examples.test.ts +++ b/src/docs-examples.test.ts @@ -192,6 +192,28 @@ describe("docs/tables-and-types.md", () => { "SELECT counters.Hits AS hits FROM counters WHERE counters.OrgId = 'org_123' AND counters.Live = 1", ) }) + + it("Row Schemas", () => { + const Requests = CHE.table("requests", { + columns: { + OrgId: CHE.string, + Timestamp: CHE.column(CHE.dateTime, { defaultExpr: "now()" }), + Route: CHE.string, + Status: CHE.column(CHE.uint16, { default: 200 }), + Hour: CHE.column(CHE.dateTime, { materialized: "toStartOfHour(Timestamp)" }), + }, + engine: CHE.engine.mergeTree(), + orderBy: ["OrgId", "Route", "Timestamp"], + }) + const { select, jsonCreate } = CH.tableSchemas(Requests) + + const body = Schema.decodeUnknownSync(jsonCreate)({ OrgId: "org_1", Route: "/" }) + expect(body).toEqual({ OrgId: "org_1", Route: "/" }) + expect(oneLine(compileCHUnsafe(CH.insertInto(Requests).values(body)).sql)).toBe( + "INSERT INTO requests (OrgId, Route) VALUES ('org_1', '/')", + ) + expect(Object.keys(select.fields)).toEqual(["OrgId", "Timestamp", "Route", "Status", "Hour"]) + }) }) // queries.md From acf17a6e94589455139d5d4feb36ae1ac63e0e21 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:16:00 +0200 Subject: [PATCH 3/4] feat(database): makeRepository, tenant-pinned CRUD derived from a table --- src/database.ts | 8 ++ src/database/repository.test-d.ts | 70 +++++++++++ src/database/repository.test.ts | 146 ++++++++++++++++++++++ src/database/repository.ts | 180 ++++++++++++++++++++++++++++ tests/repository.clickhouse.test.ts | 95 +++++++++++++++ 5 files changed, 499 insertions(+) create mode 100644 src/database/repository.test-d.ts create mode 100644 src/database/repository.test.ts create mode 100644 src/database/repository.ts create mode 100644 tests/repository.clickhouse.test.ts diff --git a/src/database.ts b/src/database.ts index ba66164..f915542 100644 --- a/src/database.ts +++ b/src/database.ts @@ -38,6 +38,14 @@ export { TransactionUnsupported, type TransactionError, } from "./database/errors" +export { + makeRepository, + type Repository, + type RepositoryError, + type RepositoryOf, + type RepositoryOptions, + type TenantRepository, +} from "./database/repository" export { QuerySummary, withQuerySummary } from "./database/tracing" export { sql, type SqlIdentifier, type SqlRawText, type SqlTemplate } from "./database/sql" export type { DialectTransactions, IsolationLevel, TransactionSettings } from "./ch/dialect" diff --git a/src/database/repository.test-d.ts b/src/database/repository.test-d.ts new file mode 100644 index 0000000..829ac7e --- /dev/null +++ b/src/database/repository.test-d.ts @@ -0,0 +1,70 @@ +// Type-level tests: what a repository's methods take and return + +import type { Effect, Option } from "effect" +import { expectTypeOf } from "expect-type" +import * as PG from "../postgres" +import * as Db from "../database" + +const Tickets = PG.table("tickets", { + columns: { + id: PG.column(PG.int4, { identity: "always" }), + org: PG.text, + title: PG.text, + note: PG.nullable(PG.text), + }, + primaryKey: ["id"], + tenantColumn: "org", +}) +type Ticket = PG.SelectRowOf + +declare const tickets: Effect.Success> +const makeTickets = () => Db.makeRepository(Tickets, { idColumn: "id", tenantColumn: "org" }) + +// It needs the Database in context, and fails with nothing when it is built. +// Inserts take the insert row without the tenant column; patches the update row without the id +// and the tenant column. +expectTypeOf(makeTickets()).toEqualTypeOf< + Effect.Effect< + Db.TenantRepository< + Ticket, + string, + number, + { readonly id?: number | undefined; readonly title: string; readonly note?: string | null | undefined }, + { readonly title?: string | undefined; readonly note?: string | null | undefined } + >, + never, + Db.Database + > +>() + +// Every method takes the tenant first. +expectTypeOf(tickets.findById("acme", 1)).toEqualTypeOf, Db.RepositoryError>>() +expectTypeOf(tickets.insert("acme", { title: "t" })).toEqualTypeOf>() +// @ts-expect-error -- no tenant: a read that is not pinned to one does not compile +tickets.findById(1) +// @ts-expect-error -- no tenant +tickets.delete(1) +// @ts-expect-error -- the tenant comes from the argument, not the row +tickets.insert("acme", { title: "t", org: "other" }) +// @ts-expect-error -- an update cannot move a row to another tenant +tickets.update("acme", 1, { org: "other" }) +// @ts-expect-error -- nor change its id +tickets.updateVoid("acme", 1, { id: 2 }) +// @ts-expect-error -- a required column of the insert row is still required +tickets.insertVoid("acme", {}) +// @ts-expect-error -- the id has the id column's type +tickets.findById("acme", "1") + +// A table without a tenant column: no tenant argument. +const Tags = PG.table("tags", { columns: { name: PG.text, uses: PG.int4 }, primaryKey: ["name"] }) +declare const tags: Effect.Success> +const makeTags = () => Db.makeRepository(Tags, { idColumn: "name" }) +expectTypeOf(tags.findById("urgent")).toEqualTypeOf< + Effect.Effect, Db.RepositoryError> +>() +expectTypeOf(tags.update).parameters.toEqualTypeOf<[string, { readonly uses?: number | undefined }]>() + +// @ts-expect-error -- the id column must be a column +Db.makeRepository(Tags, { idColumn: "nope" }) +// @ts-expect-error -- the tenant column cannot also be the id +Db.makeRepository(Tickets, { idColumn: "org", tenantColumn: "org" }) diff --git a/src/database/repository.test.ts b/src/database/repository.test.ts new file mode 100644 index 0000000..60296f7 --- /dev/null +++ b/src/database/repository.test.ts @@ -0,0 +1,146 @@ +// `makeRepository` against a real Effect SqlClient: `@effect/sql-pglite` +// (Postgres 17 in WASM). The ClickHouse half is in +// `tests/repository.clickhouse.test.ts`. + +import { PgliteClient } from "@effect/sql-pglite" +import { expect, layer } from "@effect/vitest" +import { Cause, Effect, Exit, Layer, Option } from "effect" +import * as SqlClient from "effect/sql/SqlClient" +import * as PG from "../postgres" +import { postgresDialect } from "../pg/dialect" +import * as Db from "../database" +import { QueryBuilderDefect, QueryBuilderError } from "../ch/errors" + +// `requireTenantScope` refuses any statement that is not pinned to one tenant, +// so every test here also checks that the repository pins each one. +const Live = Layer.effect( + Db.Database, + Effect.gen(function* () { + return Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: postgresDialect, requireTenantScope: true }) + }), +).pipe(Layer.provideMerge(PgliteClient.layer({ postgresqlconf: "timezone = 'UTC'" }))) + +const Tickets = PG.table("tickets", { + columns: { + id: PG.column(PG.int4, { identity: "always" }), + org: PG.text, + title: PG.text, + seats: PG.column(PG.int4, { default: 1 }), + note: PG.nullable(PG.text), + }, + primaryKey: ["id"], + tenantColumn: "org", +}) + +const createTickets = Db.execute( + Db.sql`CREATE TABLE IF NOT EXISTS tickets ( + id int4 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + org text NOT NULL, + title text NOT NULL, + seats int4 NOT NULL DEFAULT 1, + note text + )`, +) + +const Tags = PG.table("tags", { + columns: { name: PG.text, uses: PG.column(PG.int4, { default: 0 }) }, + primaryKey: ["name"], +}) + +const createTags = Db.execute(Db.sql`CREATE TABLE IF NOT EXISTS tags (name text PRIMARY KEY, uses int4 NOT NULL DEFAULT 0)`) + +const tickets = Effect.andThen(createTickets, Db.makeRepository(Tickets, { idColumn: "id", tenantColumn: "org" })) + +const defectOf = (exit: Exit.Exit) => + Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + +layer(Live, { excludeTestServices: true })("makeRepository on PGlite", (it) => { + it.effect("insert writes the row into the tenant and returns it with its defaults", () => + Effect.gen(function* () { + yield* createTickets + const repo = yield* Db.makeRepository(Tickets, { idColumn: "id", tenantColumn: "org" }) + const row = yield* repo.insert("acme", { title: "Board" }) + expect(row).toEqual({ id: row.id, org: "acme", title: "Board", seats: 1, note: null }) + }), + ) + + it.effect("findById reads only the given tenant's row", () => + Effect.gen(function* () { + const repo = yield* tickets + const row = yield* repo.insert("find-a", { title: "Mine", note: "n" }) + expect(yield* repo.findById("find-a", row.id)).toEqual(Option.some(row)) + expect(yield* repo.findById("find-b", row.id)).toEqual(Option.none()) + expect(yield* repo.findById("find-a", -1)).toEqual(Option.none()) + }), + ) + + it.effect("update changes the tenant's row and returns it; another tenant's row is None and stays as it was", () => + Effect.gen(function* () { + const repo = yield* tickets + const row = yield* repo.insert("upd-a", { title: "Old", note: "keep" }) + expect(yield* repo.update("upd-a", row.id, { title: "New", seats: 3 })).toEqual( + Option.some({ ...row, title: "New", seats: 3 }), + ) + expect(yield* repo.update("upd-b", row.id, { title: "Stolen" })).toEqual(Option.none()) + yield* repo.updateVoid("upd-b", row.id, { title: "Stolen" }) + yield* repo.updateVoid("upd-a", row.id, { note: null }) + expect(yield* repo.findById("upd-a", row.id)).toEqual(Option.some({ ...row, title: "New", seats: 3, note: null })) + }), + ) + + it.effect("delete removes only the tenant's row", () => + Effect.gen(function* () { + const repo = yield* tickets + const row = yield* repo.insert("del-a", { title: "Gone" }) + yield* repo.delete("del-b", row.id) + expect(Option.isSome(yield* repo.findById("del-a", row.id))).toBe(true) + yield* repo.delete("del-a", row.id) + expect(yield* repo.findById("del-a", row.id)).toEqual(Option.none()) + }), + ) + + it.effect("a patch with no keys is a QueryBuilderError, and the row stays as it was", () => + Effect.gen(function* () { + const repo = yield* tickets + const row = yield* repo.insert("empty-a", { title: "Same" }) + expect(yield* Effect.flip(repo.update("empty-a", row.id, {}))).toBeInstanceOf(QueryBuilderError) + expect(yield* Effect.flip(repo.updateVoid("empty-a", row.id, { title: undefined }))).toBeInstanceOf(QueryBuilderError) + expect(yield* repo.findById("empty-a", row.id)).toEqual(Option.some(row)) + }), + ) + + it.effect("the tenant argument wins over a tenant column smuggled into a row or a patch", () => + Effect.gen(function* () { + const repo = yield* tickets + const smuggled = { title: "T", org: "other" } as { title: string } + yield* repo.insertVoid("own-a", smuggled) + const row = yield* repo.insert("own-a", smuggled) + expect(row.org).toBe("own-a") + const moved = yield* repo.update("own-a", row.id, { title: "U", org: "other" } as { title: string }) + expect(Option.map(moved, (r) => r.org)).toEqual(Option.some("own-a")) + expect(yield* repo.findById("other", row.id)).toEqual(Option.none()) + }), + ) + + it.effect("a table without a tenant column gets a repository without a tenant argument", () => + Effect.gen(function* () { + yield* createTags + const repo = yield* Db.makeRepository(Tags, { idColumn: "name" }) + expect(yield* repo.insert({ name: "urgent" })).toEqual({ name: "urgent", uses: 0 }) + expect(yield* repo.update("urgent", { uses: 2 })).toEqual(Option.some({ name: "urgent", uses: 2 })) + yield* repo.delete("urgent") + expect(yield* repo.findById("urgent")).toEqual(Option.none()) + }), + ) + + it.effect("a repository over a tenant table that is not pinned to its tenant column is a defect", () => + Effect.gen(function* () { + // @ts-expect-error -- not a column of the table + const wrong = yield* Effect.exit(Db.makeRepository(Tickets, { idColumn: "id", tenantColumn: "tenant" })) + const missing = yield* Effect.exit(Db.makeRepository(Tickets, { idColumn: "id" })) + const extra = yield* Effect.exit(Db.makeRepository(Tags, { idColumn: "name", tenantColumn: "uses" })) + for (const exit of [wrong, missing, extra] as Array>) expect(defectOf(exit)).toBeInstanceOf(QueryBuilderDefect) + expect(String(defectOf(missing))).toMatch(/tenant column is "org"/) + }), + ) +}) diff --git a/src/database/repository.ts b/src/database/repository.ts new file mode 100644 index 0000000..450989b --- /dev/null +++ b/src/database/repository.ts @@ -0,0 +1,180 @@ +// A repository derived from a table: insert, update, findById and delete, the +// plain CRUD `SqlModel.makeRepository` gives a `Model.Class`, built from an +// effect-orm table and its `tableSchemas` row types instead. +// +// A table with a tenant column gets a tenant repository: every method takes +// the tenant first and pins every statement to it, so a call cannot read or +// write another tenant's rows, and each statement compiles as +// `"single-tenant"` for `requireTenantScope`. See docs/database.md#repositories. + +import { Effect, Option } from "effect" +import type { CompiledQueryDecodeError } from "../ch/compile" +import { QueryBuilderDefect, type QueryBuilderError } from "../ch/errors" +import { insertInto } from "../ch/insert" +import { from } from "../ch/query" +import type { SelectRowOf, Table } from "../ch/table" +import type { TableSchemas } from "../ch/table-schemas" +import type { ColumnDefs } from "../ch/types" +import { deleteFrom, update as updateTable } from "../ch/update" +import { Database, type DatabaseApi } from "./database" +import type { DatabaseError } from "./errors" + +type AnyTable = Table + +type Simplify = { [K in keyof A]: A[K] } & {} + +/** What a repository method fails with: the errors of `Database.run`. */ +export type RepositoryError = DatabaseError | QueryBuilderError | CompiledQueryDecodeError + +/** CRUD over a table without a tenant column. */ +export interface Repository { + /** Insert a row and return it as stored, with its defaults. Needs RETURNING (Postgres). */ + readonly insert: (row: Insert) => Effect.Effect + /** Insert a row. */ + readonly insertVoid: (row: Insert) => Effect.Effect + /** Change the row with this id and return it, or `None` when there is no such row. Needs RETURNING (Postgres). */ + readonly update: (id: Id, patch: Update) => Effect.Effect, RepositoryError> + /** Change the row with this id, if there is one. */ + readonly updateVoid: (id: Id, patch: Update) => Effect.Effect + /** The row with this id, or `None`. */ + readonly findById: (id: Id) => Effect.Effect, RepositoryError> + /** Delete the row with this id, if there is one. */ + readonly delete: (id: Id) => Effect.Effect +} + +/** + * CRUD over a table with a tenant column. Every method takes the tenant first + * and reads or writes only that tenant's rows: an insert writes the tenant + * column itself, and every other statement filters on it. + */ +export interface TenantRepository { + readonly insert: (tenant: Tenant, row: Insert) => Effect.Effect + readonly insertVoid: (tenant: Tenant, row: Insert) => Effect.Effect + readonly update: (tenant: Tenant, id: Id, patch: Update) => Effect.Effect, RepositoryError> + readonly updateVoid: (tenant: Tenant, id: Id, patch: Update) => Effect.Effect + readonly findById: (tenant: Tenant, id: Id) => Effect.Effect, RepositoryError> + readonly delete: (tenant: Tenant, id: Id) => Effect.Effect +} + +type InsertOf = TableSchemas["insert"]["Type"] +type UpdateOf = TableSchemas["update"]["Type"] + +/** + * The repository `makeRepository` builds for a table: a `TenantRepository` + * when `TenantColumn` is given, a `Repository` otherwise. Inserts take the + * table's `tableSchemas(table).insert` row, without the tenant column; updates + * take its `update` row, without the id and the tenant column. + */ +export type RepositoryOf = [TenantColumn] extends [never] + ? Repository, SelectRowOf[Id & keyof SelectRowOf], InsertOf, Simplify, Id>>> + : TenantRepository< + SelectRowOf, + SelectRowOf[TenantColumn & keyof SelectRowOf], + SelectRowOf[Id & keyof SelectRowOf], + Simplify, TenantColumn>>, + Simplify, Id | TenantColumn>> + > + +export interface RepositoryOptions { + /** The column `findById`, `update` and `delete` look a row up by. */ + readonly idColumn: Id + /** + * The table's tenant column. Required when the table declares one, and + * refused when it does not: a repository over a tenant table that is not + * pinned to a tenant is a defect when it is built. + */ + readonly tenantColumn?: TenantColumn | undefined +} + +type Untyped = Repository, Record> + +const withoutKeys = (record: Record, keys: ReadonlyArray) => + Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key))) + +/** The untyped CRUD of a table, every statement pinned to `tenant` when `tenantColumn` is set. */ +const crud = ( + db: DatabaseApi, + table: AnyTable, + idColumn: string, + tenantColumn: string | undefined, + tenant: unknown, +): Untyped => { + // Both run `Database.run`, typed for queries written in the source; these are built at runtime. + const run = db.run as (query: unknown) => Effect.Effect, RepositoryError> + const write = db.run as (query: unknown) => Effect.Effect, RepositoryError> + const byId = + (id: unknown) => + ($: any): ReadonlyArray => + tenantColumn === undefined ? [$[idColumn].eq(id)] : [$[tenantColumn].eq(tenant), $[idColumn].eq(id)] + const row = (input: Record) => + tenantColumn === undefined ? input : { ...withoutKeys(input, [tenantColumn]), [tenantColumn]: tenant } + const patch = (input: Record) => withoutKeys(input, [idColumn, tenantColumn]) + const insert = (input: Record) => (insertInto(table) as any).values(row(input)) + // A ClickHouse UPDATE is a mutation and its DELETE a lightweight delete: both + // wait here, so a `findById` after them reads what they wrote. + const settled = db.dialect.clauses.writeSettings === true + const change = (id: unknown, input: Record) => { + const query = (updateTable(table) as any).set(patch(input)).where(byId(id)) + return settled && db.dialect.clauses.alterTableUpdate === true ? query.settings({ mutations_sync: 2 }) : query + } + const remove = (id: unknown) => { + const query = (deleteFrom(table) as any).where(byId(id)) + return settled ? query.settings({ lightweight_deletes_sync: 2 }) : query + } + + return { + // An INSERT without ON CONFLICT returns its row or fails, so no row is a defect. + insert: (input) => Effect.flatMap(run(insert(input).returning()), (rows) => Effect.orDie(Effect.fromNullishOr(rows[0]))), + insertVoid: (input) => Effect.asVoid(write(insert(input))), + update: (id, input) => Effect.map(run(change(id, input).returning()), (rows) => Option.fromNullishOr(rows[0])), + updateVoid: (id, input) => Effect.asVoid(write(change(id, input))), + findById: (id) => + Effect.map(run((from(table) as any).select().where(byId(id)).limit(1)), (rows) => Option.fromNullishOr(rows[0])), + delete: (id) => Effect.asVoid(write(remove(id))), + } +} + +/** + * Build the CRUD repository of a table on the `Database` in context, as + * `SqlModel.makeRepository` does for a `Model.Class`: + * + * ```ts + * const tickets = yield* Db.makeRepository(Tickets, { idColumn: "id", tenantColumn: "org" }) + * const ticket = yield* tickets.insert("acme", { title: "Board" }) + * yield* tickets.findById("acme", ticket.id) // Option.some(ticket) + * yield* tickets.findById("other", ticket.id) // Option.none() + * ``` + */ +export const makeRepository = < + T extends AnyTable, + const Id extends keyof T["columns"] & string, + const TenantColumn extends Exclude = never, +>( + table: T, + options: RepositoryOptions, +): Effect.Effect, never, Database> => + Effect.gen(function* () { + const db = yield* Database + const tenantColumn: string | undefined = options.tenantColumn + if (table.tenantColumn !== tenantColumn) { + return yield* Effect.die( + new QueryBuilderDefect({ + message: + table.tenantColumn === undefined + ? `makeRepository(${table.name}): the table has no tenant column, so tenantColumn ${JSON.stringify(tenantColumn)} pins nothing` + : `makeRepository(${table.name}): the table's tenant column is ${JSON.stringify(table.tenantColumn)}; pass tenantColumn: ${JSON.stringify(table.tenantColumn)} so every method is pinned to one tenant`, + }), + ) + } + const scoped = (tenant: unknown) => crud(db, table, options.idColumn, tenantColumn, tenant) + if (tenantColumn === undefined) return scoped(undefined) as RepositoryOf + const repository: TenantRepository, Record> = { + insert: (tenant, row) => scoped(tenant).insert(row), + insertVoid: (tenant, row) => scoped(tenant).insertVoid(row), + update: (tenant, id, patch) => scoped(tenant).update(id, patch), + updateVoid: (tenant, id, patch) => scoped(tenant).updateVoid(id, patch), + findById: (tenant, id) => scoped(tenant).findById(id), + delete: (tenant, id) => scoped(tenant).delete(id), + } + return repository as RepositoryOf + }) diff --git a/tests/repository.clickhouse.test.ts b/tests/repository.clickhouse.test.ts new file mode 100644 index 0000000..b15a0c9 --- /dev/null +++ b/tests/repository.clickhouse.test.ts @@ -0,0 +1,95 @@ +// `makeRepository` on a live ClickHouse. ClickHouse has no RETURNING, so +// `insert` and `update` are defects there; `insertVoid`, `updateVoid` (a +// mutation that waits), `findById` and `delete` (a lightweight delete that +// waits) work. Creates one table in a throwaway database. + +import { ClickhouseClient } from "@effect/sql-clickhouse" +import { Cause, Effect, Exit, Option } from "effect" +import { describe, expect, it } from "vitest" +import * as CH from "@maple-dev/effect-orm/clickhouse" +import * as Db from "@maple-dev/effect-orm/database" +import { endpoint } from "./clickhouse-support" + +const user = process.env.EFFECT_ORM_CLICKHOUSE_USER ?? "default" +const password = process.env.EFFECT_ORM_CLICKHOUSE_PASSWORD ?? "" + +const Tickets = CH.table("tickets", { + external: true, + tenantColumn: "OrgId", + columns: { OrgId: CH.string, Id: CH.uint64, Title: CH.string, Seats: CH.column(CH.uint32, { default: 1 }) }, +}) + +const withRepository = ( + body: (repo: Db.RepositoryOf, sent: Array) => Effect.Effect, +) => + Effect.gen(function* () { + const database = `eo_repository_${Date.now()}_${Math.floor(Math.random() * 1e6)}` + const admin = yield* ClickhouseClient.ClickhouseClient + yield* admin.asCommand(admin.unsafe(`CREATE DATABASE ${database}`)) + return yield* Effect.gen(function* () { + const client = yield* ClickhouseClient.ClickhouseClient + const sent: Array = [] + const db = Db.fromSqlClient(client, { + dialect: CH.clickhouseDialect, + command: client.asCommand, + requireTenantScope: true, + observe: (statement) => Effect.sync(() => void sent.push(statement.sql)), + }) + yield* db.execute( + Db.sql`CREATE TABLE tickets (OrgId String, Id UInt64, Title String, Seats UInt32 DEFAULT 1) ENGINE = MergeTree ORDER BY (OrgId, Id)`, + ) + return yield* Effect.gen(function* () { + const repo = yield* Db.makeRepository(Tickets, { idColumn: "Id", tenantColumn: "OrgId" }) + return yield* body(repo, sent) + }).pipe(Effect.provideService(Db.Database, db)) + }).pipe( + Effect.provide(ClickhouseClient.layer({ url: endpoint!, username: user, password, database })), + Effect.ensuring(Effect.orDie(admin.asCommand(admin.unsafe(`DROP DATABASE IF EXISTS ${database} SYNC`)))), + ) + }).pipe(Effect.provide(ClickhouseClient.layer({ url: endpoint!, username: user, password, database: "default" }))) + +describe.skipIf(!endpoint)("makeRepository on live ClickHouse", () => { + it("inserts, finds, updates and deletes only within the tenant", async () => { + const result = await Effect.runPromise( + withRepository((repo, sent) => + Effect.gen(function* () { + yield* repo.insertVoid("a", { Id: 1, Title: "Board" }) + const found = yield* repo.findById("a", 1) + const otherTenant = yield* repo.findById("b", 1) + yield* repo.updateVoid("b", 1, { Title: "Stolen" }) + yield* repo.updateVoid("a", 1, { Title: "Renamed", Seats: 4 }) + const updated = yield* repo.findById("a", 1) + yield* repo.delete("b", 1) + const afterOtherDelete = yield* repo.findById("a", 1) + yield* repo.delete("a", 1) + const deleted = yield* repo.findById("a", 1) + return { found, otherTenant, updated, afterOtherDelete, deleted, sent: [...sent] } + }), + ), + ) + expect(result.found).toEqual(Option.some({ OrgId: "a", Id: 1, Title: "Board", Seats: 1 })) + expect(result.otherTenant).toEqual(Option.none()) + expect(result.updated).toEqual(Option.some({ OrgId: "a", Id: 1, Title: "Renamed", Seats: 4 })) + expect(Option.isSome(result.afterOtherDelete)).toBe(true) + expect(result.deleted).toEqual(Option.none()) + expect(result.sent.find((sql) => sql.startsWith("ALTER TABLE"))).toMatch(/SETTINGS mutations_sync = 2$/) + expect(result.sent.find((sql) => sql.startsWith("DELETE"))).toMatch(/SETTINGS lightweight_deletes_sync = 2$/) + }) + + it("insert and update need RETURNING, which ClickHouse has not: a defect, and nothing is written", async () => { + const { inserted, updated, rows } = await Effect.runPromise( + withRepository((repo) => + Effect.gen(function* () { + const inserted = yield* Effect.exit(repo.insert("a", { Id: 2, Title: "x" })) + const updated = yield* Effect.exit(repo.update("a", 2, { Title: "y" })) + return { inserted, updated, rows: yield* repo.findById("a", 2) } + }), + ), + ) + for (const exit of [inserted, updated] as Array>) { + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) + expect(String(Exit.isFailure(exit) ? Cause.squash(exit.cause) : "")).toMatch(/no RETURNING clause/) + } + expect(rows).toEqual(Option.none()) + }) +}) From b4f5c51e199400e7e5425bf63afd03d81910e730 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:16:00 +0200 Subject: [PATCH 4/4] docs: repositories, with a changeset --- .changeset/make-repository.md | 5 +++ docs/README.md | 2 +- docs/database.md | 75 +++++++++++++++++++++++++++++++++++ docs/reference.md | 1 + 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .changeset/make-repository.md diff --git a/.changeset/make-repository.md b/.changeset/make-repository.md new file mode 100644 index 0000000..b46165b --- /dev/null +++ b/.changeset/make-repository.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": minor +--- + +`makeRepository(table, { idColumn, tenantColumn })` in `/database` builds the plain CRUD of a table on the `Database` in context, as `SqlModel.makeRepository` does for a `Model.Class`: `insert`, `insertVoid`, `update`, `updateVoid`, `findById` and `delete`. `findById` and `update` return an `Option`. Inserts take the `tableSchemas(table).insert` row and updates its `update` row. A table with a tenant column gets a `TenantRepository`: every method takes the tenant first, writes or filters the tenant column from it, and compiles as `"single-tenant"`, so a call cannot read or write another tenant's rows. A patch cannot change the id or the tenant column. On ClickHouse, which has no `RETURNING`, use `insertVoid`, `updateVoid` (a mutation that waits), `findById` and `delete` (a lightweight delete that waits). diff --git a/docs/README.md b/docs/README.md index 7a61d11..80a09be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,7 +64,7 @@ Roughly in reading order. | [Extending the DSL](./extending.md) | `defineFn`, raw escape hatches, handwritten SQL | | [Postgres](./postgres.md) | The Postgres dialect, its column types and functions | | [Schema and migrations](./migrations.md) | DDL from `CH.table` / `PG.table`, `materializedView`, `effect-orm generate`, applying migrations, adopting drizzle-kit | -| [Statements and transactions](./database.md) | `Database` over your `SqlClient`: `run`, `execute`, `transaction`, retry | +| [Statements and transactions](./database.md) | `Database` over your `SqlClient`: `run`, `execute`, `transaction`, retry, repositories | ## Reference diff --git a/docs/database.md b/docs/database.md index 2e0cccd..37bb463 100644 --- a/docs/database.md +++ b/docs/database.md @@ -284,6 +284,81 @@ Db.run(query, params).pipe(Db.withQuerySummary("dashboard.revenue_by_day")) The table is also on the compiled query, as `compiled.collection`. +## Repositories + +`makeRepository(table, options)` builds the plain CRUD of a table on the `Database` in +context, as `SqlModel.makeRepository` does for a `Model.Class`. The row types come from +[`tableSchemas(table)`](./tables-and-types.md#row-schemas): `insert` takes its `insert` row and +`update` its `update` row. + +```ts +import * as Db from "@maple-dev/effect-orm/database" +import * as PG from "@maple-dev/effect-orm/postgres" +import { Effect, Option } from "effect" + +const Tickets = PG.table("tickets", { + columns: { + id: PG.column(PG.int4, { identity: "always" }), + org: PG.text, + title: PG.text, + note: PG.nullable(PG.text), + }, + primaryKey: ["id"], + tenantColumn: "org", +}) + +const program = Effect.gen(function* () { + const tickets = yield* Db.makeRepository(Tickets, { idColumn: "id", tenantColumn: "org" }) + const ticket = yield* tickets.insert("acme", { title: "Board" }) // the stored row, with its id + yield* tickets.update("acme", ticket.id, { note: "moved" }) // Option of the changed row + yield* tickets.findById("other", ticket.id) // Option.none(): not this tenant's row + yield* tickets.delete("acme", ticket.id) +}) +``` + +| Method | Does | +| ---------------------------- | ------------------------------------------------------------ | +| `insert(row)` | INSERT ... RETURNING, and gives back the stored row | +| `insertVoid(row)` | INSERT | +| `update(id, patch)` | UPDATE ... RETURNING: `Option` of the changed row | +| `updateVoid(id, patch)` | UPDATE | +| `findById(id)` | SELECT ... LIMIT 1: `Option` of the row | +| `delete(id)` | DELETE | + +`idColumn` names the column a row is found by. A patch cannot set it. A key left out of a +patch (or `undefined`) keeps its value, as in [`set`](./updates-and-deletes.md#set). A patch +with no keys is a `QueryBuilderError`. + +### Tenant tables + +When the table declares a [tenant column](./tenant-scoping.md), the repository is a +`TenantRepository`, and every method takes the tenant first: + +- `insert` and `insertVoid` write the tenant column from that argument. The row type has no + tenant column. +- `update`, `updateVoid`, `findById` and `delete` filter on the tenant column and the id. A row + of another tenant is not found, not changed and not deleted. A patch cannot set the tenant + column, so it cannot move a row to another tenant. +- Each statement compiles as `"single-tenant"`, so a `Database` with `requireTenantScope` runs + it. + +A method called without the tenant does not type-check. The `Table` type does not carry its +tenant column, so `makeRepository` checks `tenantColumn` against the table when it is built: a +tenant table without it, or a `tenantColumn` on a table without one, is a `QueryBuilderDefect`. + +### ClickHouse + +ClickHouse has no `RETURNING`, so `insert` and `update` are defects there +(`QueryBuilderDefect`), and nothing is written. Use `insertVoid` and `updateVoid`, and +`findById` to read the row. `updateVoid` is a mutation with `mutations_sync = 2`, and `delete` +a lightweight delete with `lightweight_deletes_sync = 2`: both wait, so a `findById` after them +reads what they wrote. Mutations rewrite whole parts, so keep `updateVoid` for occasional +corrections, as [Updating and deleting rows](./updates-and-deletes.md#clickhouse) says. +`findById` reads without `FINAL`, so on a `ReplacingMergeTree` it can return an older version. + +_(Backed by `src/database/repository.test.ts`, `src/database/repository.test-d.ts` and +`tests/repository.clickhouse.test.ts`.)_ + ## Writes The builder compiles SELECTs, [INSERTs](./inserts.md) and diff --git a/docs/reference.md b/docs/reference.md index 0a9a59b..a15998c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -515,6 +515,7 @@ placeholders, double-quoted identifiers. | `run`, `status`, `verify`, `baseline`, `MigrationDriver` and the migrate errors | `/migrate` | | `Database`, `run`, `sql`, `query`, `execute`, `transaction`, `requireTransaction`, `retryContention`, `Transaction` | `/database` | | `DatabaseError`, `TransactionCommitFailed`, `TransactionRollbackFailed` and the other transaction errors | `/database` | +| `makeRepository`, `Repository`, `TenantRepository`, `RepositoryOf`, `RepositoryOptions`, `RepositoryError` — CRUD derived from a table, tenant-pinned | `/database` | | `withQuerySummary`, `QuerySummary` — the `db.query.summary` of the statements an effect runs, for spans | `/database` | | `ClickhouseHttpClient`, `layer`, `make`, `ClickhouseHttpClientConfig` — a ClickHouse `SqlClient` over `fetch`, for Workers | `/clickhouse-http` | | `defineConfig`, `generate`, `check`, `pull`, `loadSchema`, `readMigrations`, `analyze`, `KitError` | `/kit` |