@afora/drizzle 0.0.0-stage → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.js +331 -0
- package/dist/provider.d.ts +27 -0
- package/package.json +59 -5
package/README.md
CHANGED
|
@@ -1,3 +1,140 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @afora/drizzle
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A repository provider for [`@afora/core`](../../README.md) on [Drizzle ORM](https://orm.drizzle.team). It implements the provider v2 contract (queries with `where` equality and `in`, `orderBy`, `limit` / `offset`, `withDeleted`, `count`, unique keys, transactions) and passes the `providerContract` kit of `@afora/core/testing` on SQLite (`bun:sqlite`) and Postgres (PGlite). The contract and the kit are described in [docs/repositories.md](../../docs/repositories.md) and [Testing a provider](../../docs/repositories.md#testing-a-provider).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun add @afora/core typebox drizzle-orm @afora/drizzle
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`@afora/core` and `drizzle-orm` (`^0.45.4`) are peer dependencies. SQLite and Postgres databases are supported, with any Drizzle driver. MySQL is not (the provider relies on `RETURNING` and `NULLS FIRST/LAST`).
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// schema.ts
|
|
15
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
16
|
+
|
|
17
|
+
export const users = sqliteTable("users", {
|
|
18
|
+
id: text("id").primaryKey(),
|
|
19
|
+
createdAt: text("createdAt").notNull(),
|
|
20
|
+
updatedAt: text("updatedAt"),
|
|
21
|
+
deletedAt: text("deletedAt"),
|
|
22
|
+
name: text("name").notNull(),
|
|
23
|
+
email: text("email").notNull().unique(),
|
|
24
|
+
age: integer("age"),
|
|
25
|
+
address: text("address", { mode: "json" }),
|
|
26
|
+
});
|
|
27
|
+
export const tables = { User: users };
|
|
28
|
+
|
|
29
|
+
// db.ts
|
|
30
|
+
import { Database } from "bun:sqlite";
|
|
31
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
32
|
+
|
|
33
|
+
export const db = drizzle(new Database("app.db"));
|
|
34
|
+
|
|
35
|
+
// app.ts
|
|
36
|
+
import {
|
|
37
|
+
applicationOf,
|
|
38
|
+
blueprintOf,
|
|
39
|
+
entityOf,
|
|
40
|
+
repositoryOf,
|
|
41
|
+
} from "@afora/core";
|
|
42
|
+
import { drizzleProvider } from "@afora/drizzle";
|
|
43
|
+
import { db } from "./db";
|
|
44
|
+
import { tables } from "./schema";
|
|
45
|
+
|
|
46
|
+
class User extends entityOf(
|
|
47
|
+
blueprintOf((ctx) => ({
|
|
48
|
+
name: ctx.string,
|
|
49
|
+
email: ctx.email,
|
|
50
|
+
age: ctx.optionalOf(ctx.integer),
|
|
51
|
+
address: ctx.optionalOf(blueprintOf((c) => ({ city: c.string }))),
|
|
52
|
+
})),
|
|
53
|
+
{ unique: ["email"] },
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
const provider = drizzleProvider({ db, tables });
|
|
57
|
+
export const Users = repositoryOf({
|
|
58
|
+
entity: User,
|
|
59
|
+
provider,
|
|
60
|
+
deletion: "soft",
|
|
61
|
+
actions: [
|
|
62
|
+
"create",
|
|
63
|
+
"findById",
|
|
64
|
+
"findByEmail",
|
|
65
|
+
"findMany",
|
|
66
|
+
"findPage",
|
|
67
|
+
"update",
|
|
68
|
+
"delete",
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const App = applicationOf({
|
|
73
|
+
name: "app",
|
|
74
|
+
provider,
|
|
75
|
+
repositories: [Users],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await Users.create(
|
|
79
|
+
new User({ name: "Ana", email: "ana@example.com", age: 30 }),
|
|
80
|
+
);
|
|
81
|
+
await Users.findPage({
|
|
82
|
+
where: { age: { in: [30, 31] } },
|
|
83
|
+
orderBy: [{ key: "name" }],
|
|
84
|
+
limit: 20,
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`tables` maps a repository to its Drizzle table: by the name of the repository (`UserRepository`, or `<name>Repository` for `repositoryOf({ name })`) and, failing that, by the name of the entity class (`User`). Create the schema with `drizzle-kit` or your own migrations; the provider never changes it.
|
|
89
|
+
|
|
90
|
+
## Mapping rules
|
|
91
|
+
|
|
92
|
+
- Column property names must equal the entity keys (`createdAt`, not `created_at`; the SQL name is free). The table needs an `id` column; `createdAt`, `updatedAt` and `deletedAt` are columns like the others, and `deletedAt` makes `withDeleted` and soft deletion work. A stored key with no column throws `InvalidRepositoryDeclaration`.
|
|
93
|
+
- Missing values (`undefined`) are `NULL`, so make optional keys nullable. An update replaces the whole row.
|
|
94
|
+
- Nested records and lists are stored in JSON columns (`text({ mode: "json" })` on SQLite, `jsonb` on Postgres; not `json`, which has no equality). Nested entities are stored by id and loaded through `relations`. Filtering a structure compares the serialized JSON, so key order matters on SQLite.
|
|
95
|
+
- Dates and timestamps: a stored date is an ISO 8601 string. Use a `text` column, or a timestamp with `{ mode: "date" }` (converted both ways; on Postgres use `withTimezone: true`). A Postgres `timestamp` in `mode: "string"` returns a non-ISO string that the entity rejects.
|
|
96
|
+
- Booleans and numbers use the driver's column types (`integer({ mode: "boolean" })` on SQLite).
|
|
97
|
+
|
|
98
|
+
## Queries
|
|
99
|
+
|
|
100
|
+
- `where`: equality (`eq`; `undefined`/`null` is `IS NULL`) and `{ in: [...] }`, ANDed across keys. An empty `in` matches nothing.
|
|
101
|
+
- `orderBy`: ascending puts missing values first and descending last (`NULLS FIRST` / `NULLS LAST`). Strings compare by code point: SQLite's default `BINARY` collation does, and on Postgres the provider adds `COLLATE "C"` to `text`, `varchar` and `char` columns. Ties keep insertion order: by `rowid` on SQLite (the table must not be `WITHOUT ROWID`) and, on Postgres, which has no stable physical order, by `id`, which is creation order for UUID v7 ids (the default `Id`).
|
|
102
|
+
- `limit` / `offset`, `withDeleted` (rows with `deletedAt IS NULL` unless true) and `count` (ignores order and pagination) as in the contract.
|
|
103
|
+
|
|
104
|
+
## Unique keys
|
|
105
|
+
|
|
106
|
+
`context.unique` (`id` and the entity's `unique` keys) is checked before every `insert` and `update` with a query, so a violation is reported as `RepositoryUniqueViolation` with `details.key` even without a unique index, and soft-deleted rows keep occupying their values. The check does not close the race between concurrent writers: also declare unique indexes. The provider translates the database's unique errors (SQLite `UNIQUE constraint failed`, Postgres `23505`, including a driver error's `cause`) into the same exception, naming the key from the column. `update` and `remove` of an unknown id throw `RepositoryEntityNotFound`.
|
|
107
|
+
|
|
108
|
+
## Transactions
|
|
109
|
+
|
|
110
|
+
`capabilities.transactions` is true. `provider.transaction(run)` uses `db.transaction`, and a nested one a savepoint. Synchronous SQLite drivers (`bun:sqlite`, `better-sqlite3`) cannot run an asynchronous `db.transaction` callback, so there the provider issues `BEGIN` / `SAVEPOINT` itself and makes the other operations wait for the transaction, since there is one connection. The scoped provider throws once its callback ends. Use it with `commandOf({ transaction: true })`.
|
|
111
|
+
|
|
112
|
+
## Codegen and extraction
|
|
113
|
+
|
|
114
|
+
The package exports the catalog entry for `@afora/codegen`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
export const providers = [
|
|
118
|
+
{ name: "drizzle", module: "@afora/drizzle", export: "drizzleProvider" },
|
|
119
|
+
];
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
so `afora-codegen generate --providers @afora/drizzle` accepts
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"provider": {
|
|
127
|
+
"use": "drizzle",
|
|
128
|
+
"options": {
|
|
129
|
+
"db": { "import": "./db.ts#db" },
|
|
130
|
+
"tables": { "import": "./schema.ts#tables" }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
and generates `drizzleProvider({ db, tables })`. A Drizzle provider is an opaque provider: `db` and `tables` are not JSON, so `drizzleProvider` does not register a descriptor, and `extractSpec` needs an import mapping for it (`--import provider=./db.ts#provider`) instead of guessing options.
|
|
137
|
+
|
|
138
|
+
## Tests
|
|
139
|
+
|
|
140
|
+
`bun run test:drizzle` runs `providerContract` against in-memory `bun:sqlite` and PGlite, plus repository-level tests (relations, soft deletion, `findPage`, a command with a transaction).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { DrizzleDatabase, DrizzleProviderOptions, DrizzleTable, } from "./provider.js";
|
|
2
|
+
export { drizzleProvider } from "./provider.js";
|
|
3
|
+
/** The provider catalog entries for `afora-codegen --providers`. */
|
|
4
|
+
export declare const providers: readonly [{
|
|
5
|
+
readonly name: "drizzle";
|
|
6
|
+
readonly module: "@afora/drizzle";
|
|
7
|
+
readonly export: "drizzleProvider";
|
|
8
|
+
}];
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// src/provider.ts
|
|
2
|
+
import {
|
|
3
|
+
InvalidRepositoryDeclaration,
|
|
4
|
+
InvalidRepositoryQuery,
|
|
5
|
+
RepositoryEntityNotFound,
|
|
6
|
+
RepositoryUniqueViolation
|
|
7
|
+
} from "@afora/core";
|
|
8
|
+
import {
|
|
9
|
+
and,
|
|
10
|
+
eq,
|
|
11
|
+
getTableColumns,
|
|
12
|
+
inArray,
|
|
13
|
+
isNull,
|
|
14
|
+
ne,
|
|
15
|
+
or,
|
|
16
|
+
sql
|
|
17
|
+
} from "drizzle-orm";
|
|
18
|
+
var SOURCE = "drizzle";
|
|
19
|
+
var NO_LIMIT = Number.MAX_SAFE_INTEGER;
|
|
20
|
+
function invalid(reason) {
|
|
21
|
+
throw new InvalidRepositoryDeclaration({
|
|
22
|
+
source: SOURCE,
|
|
23
|
+
details: { reason }
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function badQuery(reason) {
|
|
27
|
+
throw new InvalidRepositoryQuery({ source: SOURCE, details: { reason } });
|
|
28
|
+
}
|
|
29
|
+
var mappings = new WeakMap;
|
|
30
|
+
function mappingOf(tables, context) {
|
|
31
|
+
const table = tables[context.name] ?? tables[context.entity.name];
|
|
32
|
+
if (!table) {
|
|
33
|
+
return invalid(`No Drizzle table for "${context.name}" (or the entity "${context.entity.name}").`);
|
|
34
|
+
}
|
|
35
|
+
let mapping = mappings.get(table);
|
|
36
|
+
if (!mapping) {
|
|
37
|
+
const columns = getTableColumns(table);
|
|
38
|
+
if (!columns.id)
|
|
39
|
+
invalid(`The table of "${context.name}" has no "id" column.`);
|
|
40
|
+
mapping = {
|
|
41
|
+
table,
|
|
42
|
+
columns,
|
|
43
|
+
postgres: Object.values(columns).some((column) => column.columnType.startsWith("Pg"))
|
|
44
|
+
};
|
|
45
|
+
mappings.set(table, mapping);
|
|
46
|
+
}
|
|
47
|
+
return mapping;
|
|
48
|
+
}
|
|
49
|
+
function columnOf(mapping, key) {
|
|
50
|
+
const column = mapping.columns[key];
|
|
51
|
+
if (!column)
|
|
52
|
+
badQuery(`"${key}" is not a column of the table.`);
|
|
53
|
+
return column;
|
|
54
|
+
}
|
|
55
|
+
var isMissing = (value) => value === undefined || value === null;
|
|
56
|
+
function toColumn(column, value) {
|
|
57
|
+
if (isMissing(value))
|
|
58
|
+
return null;
|
|
59
|
+
if (column.dataType === "date" && typeof value === "string")
|
|
60
|
+
return new Date(value);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function fromColumn(value) {
|
|
64
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
65
|
+
}
|
|
66
|
+
function dataOf(mapping, data) {
|
|
67
|
+
for (const key of Object.keys(data)) {
|
|
68
|
+
if (data[key] !== undefined && !(key in mapping.columns))
|
|
69
|
+
invalid(`"${key}" has no column in the table.`);
|
|
70
|
+
}
|
|
71
|
+
const row = {};
|
|
72
|
+
for (const [key, column] of Object.entries(mapping.columns))
|
|
73
|
+
row[key] = toColumn(column, data[key]);
|
|
74
|
+
return row;
|
|
75
|
+
}
|
|
76
|
+
function dataFromRow(row) {
|
|
77
|
+
const data = {};
|
|
78
|
+
for (const [key, value] of Object.entries(row)) {
|
|
79
|
+
if (!isMissing(value))
|
|
80
|
+
data[key] = fromColumn(value);
|
|
81
|
+
}
|
|
82
|
+
return data;
|
|
83
|
+
}
|
|
84
|
+
var isIn = (condition) => typeof condition === "object" && condition !== null && !Array.isArray(condition) && Object.keys(condition).length === 1 && Array.isArray(condition.in);
|
|
85
|
+
function equality(column, value) {
|
|
86
|
+
return isMissing(value) ? isNull(column) : eq(column, toColumn(column, value));
|
|
87
|
+
}
|
|
88
|
+
function conditionOf(column, condition) {
|
|
89
|
+
if (!isIn(condition))
|
|
90
|
+
return equality(column, condition);
|
|
91
|
+
const present = condition.in.filter((value) => !isMissing(value)).map((value) => toColumn(column, value));
|
|
92
|
+
const parts = [];
|
|
93
|
+
if (present.length > 0)
|
|
94
|
+
parts.push(inArray(column, present));
|
|
95
|
+
if (present.length < condition.in.length)
|
|
96
|
+
parts.push(isNull(column));
|
|
97
|
+
return or(...parts) ?? sql`1 = 0`;
|
|
98
|
+
}
|
|
99
|
+
function whereOf(mapping, query = {}) {
|
|
100
|
+
const parts = [];
|
|
101
|
+
for (const [key, condition] of Object.entries(query.where ?? {}))
|
|
102
|
+
parts.push(conditionOf(columnOf(mapping, key), condition));
|
|
103
|
+
const deleted = mapping.columns.deletedAt;
|
|
104
|
+
if (deleted && query.withDeleted !== true)
|
|
105
|
+
parts.push(isNull(deleted));
|
|
106
|
+
return and(...parts);
|
|
107
|
+
}
|
|
108
|
+
function collate(mapping, column) {
|
|
109
|
+
return mapping.postgres && /^Pg(Text|Varchar|Char)/.test(column.columnType) ? sql`${column} COLLATE "C"` : sql`${column}`;
|
|
110
|
+
}
|
|
111
|
+
function orderOf(mapping, query) {
|
|
112
|
+
const order = [];
|
|
113
|
+
for (const { key, direction = "asc" } of query.orderBy ?? []) {
|
|
114
|
+
const column = columnOf(mapping, key);
|
|
115
|
+
const collated = collate(mapping, column);
|
|
116
|
+
order.push(direction === "desc" ? sql`${collated} DESC NULLS LAST` : sql`${collated} ASC NULLS FIRST`);
|
|
117
|
+
}
|
|
118
|
+
order.push(mapping.postgres ? collate(mapping, columnOf(mapping, "id")) : sql`rowid`);
|
|
119
|
+
return order;
|
|
120
|
+
}
|
|
121
|
+
function causesOf(error) {
|
|
122
|
+
const causes = [];
|
|
123
|
+
let current = error;
|
|
124
|
+
for (let depth = 0;depth < 6 && current instanceof Error; depth++) {
|
|
125
|
+
const fields = current;
|
|
126
|
+
causes.push({
|
|
127
|
+
message: fields.message,
|
|
128
|
+
detail: typeof fields.detail === "string" ? fields.detail : "",
|
|
129
|
+
code: typeof fields.code === "string" ? fields.code : ""
|
|
130
|
+
});
|
|
131
|
+
current = current.cause;
|
|
132
|
+
}
|
|
133
|
+
return causes;
|
|
134
|
+
}
|
|
135
|
+
function violatedKey(error, mapping) {
|
|
136
|
+
const names = (column) => Object.entries(mapping.columns).find(([, c]) => c.name === column)?.[0] ?? column;
|
|
137
|
+
for (const { message, detail, code } of causesOf(error)) {
|
|
138
|
+
const sqlite = /UNIQUE constraint failed: ([^\n]+)/.exec(message);
|
|
139
|
+
if (sqlite?.[1]) {
|
|
140
|
+
const [first = ""] = sqlite[1].split(",");
|
|
141
|
+
return names(first.trim().split(".").pop() ?? first);
|
|
142
|
+
}
|
|
143
|
+
if (code === "23505" || /duplicate key value/.test(message)) {
|
|
144
|
+
const postgres = /Key \(([^)]+?)\)=/.exec(detail);
|
|
145
|
+
if (postgres?.[1])
|
|
146
|
+
return names(postgres[1].split(",")[0]?.trim() ?? "");
|
|
147
|
+
return "id";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
function providerOver(tables, exec, hooks) {
|
|
153
|
+
const select = async (context, query, one) => {
|
|
154
|
+
const mapping = mappingOf(tables, context);
|
|
155
|
+
const limit = one ? Math.min(query.limit ?? 1, 1) : query.limit;
|
|
156
|
+
if (limit === 0)
|
|
157
|
+
return [];
|
|
158
|
+
let builder = exec.select().from(mapping.table).where(whereOf(mapping, query)).orderBy(...orderOf(mapping, query));
|
|
159
|
+
if (limit !== undefined || query.offset)
|
|
160
|
+
builder = builder.limit(limit ?? NO_LIMIT);
|
|
161
|
+
if (query.offset)
|
|
162
|
+
builder = builder.offset(query.offset);
|
|
163
|
+
return (await builder).map(dataFromRow);
|
|
164
|
+
};
|
|
165
|
+
const assertUnique = async (context, mapping, data, own) => {
|
|
166
|
+
const id = columnOf(mapping, "id");
|
|
167
|
+
for (const key of context.unique) {
|
|
168
|
+
const value = data[key];
|
|
169
|
+
if (isMissing(value) || !(key in mapping.columns))
|
|
170
|
+
continue;
|
|
171
|
+
if (own && key === "id")
|
|
172
|
+
continue;
|
|
173
|
+
const column = columnOf(mapping, key);
|
|
174
|
+
const others = own ? ne(id, data.id) : undefined;
|
|
175
|
+
const [found] = await exec.select({ id }).from(mapping.table).where(and(eq(column, toColumn(column, value)), others)).limit(1);
|
|
176
|
+
if (found) {
|
|
177
|
+
throw new RepositoryUniqueViolation({
|
|
178
|
+
source: context.name,
|
|
179
|
+
details: { key }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const write = async (context, mapping, work) => {
|
|
185
|
+
try {
|
|
186
|
+
return await work();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const key = violatedKey(error, mapping);
|
|
189
|
+
if (key === undefined)
|
|
190
|
+
throw error;
|
|
191
|
+
throw new RepositoryUniqueViolation({
|
|
192
|
+
source: context.name,
|
|
193
|
+
details: { key }
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const notFound = (context, id) => new RepositoryEntityNotFound({ source: context.name, details: { id } });
|
|
198
|
+
return {
|
|
199
|
+
capabilities: Object.freeze({ transactions: true }),
|
|
200
|
+
findOne: (context, query = {}) => hooks.guard(async () => (await select(context, query, true))[0]),
|
|
201
|
+
findMany: (context, query = {}) => hooks.guard(() => select(context, query, false)),
|
|
202
|
+
count: (context, query = {}) => hooks.guard(async () => {
|
|
203
|
+
const mapping = mappingOf(tables, context);
|
|
204
|
+
const [found] = await exec.select({ total: sql`count(*)` }).from(mapping.table).where(whereOf(mapping, { ...query, limit: undefined }));
|
|
205
|
+
return Number(found?.total ?? 0);
|
|
206
|
+
}),
|
|
207
|
+
insert: (context, data) => hooks.guard(async () => {
|
|
208
|
+
const mapping = mappingOf(tables, context);
|
|
209
|
+
const row = dataOf(mapping, data);
|
|
210
|
+
await assertUnique(context, mapping, data, false);
|
|
211
|
+
await write(context, mapping, () => exec.insert(mapping.table).values(row));
|
|
212
|
+
}),
|
|
213
|
+
update: (context, data) => hooks.guard(async () => {
|
|
214
|
+
const mapping = mappingOf(tables, context);
|
|
215
|
+
const row = dataOf(mapping, data);
|
|
216
|
+
const id = columnOf(mapping, "id");
|
|
217
|
+
const [existing] = await exec.select({ id }).from(mapping.table).where(eq(id, data.id)).limit(1);
|
|
218
|
+
if (!existing)
|
|
219
|
+
throw notFound(context, data.id);
|
|
220
|
+
await assertUnique(context, mapping, data, true);
|
|
221
|
+
await write(context, mapping, () => exec.update(mapping.table).set(row).where(eq(id, data.id)));
|
|
222
|
+
}),
|
|
223
|
+
remove: (context, id) => hooks.guard(async () => {
|
|
224
|
+
const mapping = mappingOf(tables, context);
|
|
225
|
+
const column = columnOf(mapping, "id");
|
|
226
|
+
const removed = await exec.delete(mapping.table).where(eq(column, id)).returning({ id: column });
|
|
227
|
+
if (removed.length === 0)
|
|
228
|
+
throw notFound(context, id);
|
|
229
|
+
}),
|
|
230
|
+
transaction: hooks.transaction
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function whileAlive(alive) {
|
|
234
|
+
return async (work) => {
|
|
235
|
+
if (!alive())
|
|
236
|
+
invalid("The transaction has ended; its provider cannot be used.");
|
|
237
|
+
return work();
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
var savepoints = 0;
|
|
241
|
+
function nestedTransactions(tables, exec) {
|
|
242
|
+
return (run) => exec.transaction(async (tx) => {
|
|
243
|
+
let alive = true;
|
|
244
|
+
try {
|
|
245
|
+
return await run(scoped(tables, tx, () => alive));
|
|
246
|
+
} finally {
|
|
247
|
+
alive = false;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function scoped(tables, tx, alive) {
|
|
252
|
+
return providerOver(tables, tx, {
|
|
253
|
+
guard: whileAlive(alive),
|
|
254
|
+
transaction: nestedTransactions(tables, tx)
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function manualScope(tables, db, root, alive) {
|
|
258
|
+
const state = root ?? { held: undefined };
|
|
259
|
+
const guard = async (work) => {
|
|
260
|
+
if (!alive())
|
|
261
|
+
invalid("The transaction has ended; its provider cannot be used.");
|
|
262
|
+
if (root === undefined)
|
|
263
|
+
while (state.held)
|
|
264
|
+
await state.held;
|
|
265
|
+
return work();
|
|
266
|
+
};
|
|
267
|
+
return providerOver(tables, db, {
|
|
268
|
+
guard,
|
|
269
|
+
async transaction(run) {
|
|
270
|
+
if (root === undefined)
|
|
271
|
+
while (state.held)
|
|
272
|
+
await state.held;
|
|
273
|
+
if (!alive())
|
|
274
|
+
invalid("The transaction has ended; its provider cannot be used.");
|
|
275
|
+
let release = () => {};
|
|
276
|
+
if (root === undefined) {
|
|
277
|
+
state.held = new Promise((resolve) => {
|
|
278
|
+
release = resolve;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const name = `afora_sp_${++savepoints}`;
|
|
282
|
+
await db.run(root === undefined ? sql`BEGIN` : sql.raw(`SAVEPOINT ${name}`));
|
|
283
|
+
let open = true;
|
|
284
|
+
try {
|
|
285
|
+
const result = await run(manualScope(tables, db, state, () => open && alive()));
|
|
286
|
+
open = false;
|
|
287
|
+
await db.run(root === undefined ? sql`COMMIT` : sql.raw(`RELEASE ${name}`));
|
|
288
|
+
return result;
|
|
289
|
+
} catch (error) {
|
|
290
|
+
open = false;
|
|
291
|
+
if (root === undefined)
|
|
292
|
+
await db.run(sql`ROLLBACK`);
|
|
293
|
+
else {
|
|
294
|
+
await db.run(sql.raw(`ROLLBACK TO ${name}`));
|
|
295
|
+
await db.run(sql.raw(`RELEASE ${name}`));
|
|
296
|
+
}
|
|
297
|
+
throw error;
|
|
298
|
+
} finally {
|
|
299
|
+
open = false;
|
|
300
|
+
if (root === undefined) {
|
|
301
|
+
state.held = undefined;
|
|
302
|
+
release();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function isSyncSqlite(db) {
|
|
309
|
+
return db.resultKind === "sync";
|
|
310
|
+
}
|
|
311
|
+
function drizzleProvider(options) {
|
|
312
|
+
if (!options || typeof options !== "object" || !options.db || typeof options.tables !== "object" || options.tables === null)
|
|
313
|
+
invalid("Expected { db, tables } with a Drizzle database and its tables.");
|
|
314
|
+
const { db, tables } = options;
|
|
315
|
+
const alwaysAlive = () => true;
|
|
316
|
+
if (isSyncSqlite(db))
|
|
317
|
+
return manualScope(tables, db, undefined, alwaysAlive);
|
|
318
|
+
return providerOver(tables, db, {
|
|
319
|
+
guard: (work) => work(),
|
|
320
|
+
transaction: nestedTransactions(tables, db)
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/index.ts
|
|
325
|
+
var providers = [
|
|
326
|
+
{ name: "drizzle", module: "@afora/drizzle", export: "drizzleProvider" }
|
|
327
|
+
];
|
|
328
|
+
export {
|
|
329
|
+
drizzleProvider,
|
|
330
|
+
providers
|
|
331
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type IRepositoryProvider } from "@afora/core";
|
|
2
|
+
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
3
|
+
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
|
|
4
|
+
/** The Drizzle databases the provider supports: SQLite (any driver) and Postgres (any driver). */
|
|
5
|
+
export type DrizzleDatabase = BaseSQLiteDatabase<any, any, any, any> | PgDatabase<any, any, any>;
|
|
6
|
+
/** A Drizzle table of either dialect. */
|
|
7
|
+
export type DrizzleTable = any;
|
|
8
|
+
export interface DrizzleProviderOptions {
|
|
9
|
+
/** The Drizzle database, or a transaction of it. */
|
|
10
|
+
readonly db: DrizzleDatabase;
|
|
11
|
+
/**
|
|
12
|
+
* The table of each repository: keyed by the name of the repository
|
|
13
|
+
* (`repositoryOf({ name })`, which defaults to the entity class name) or,
|
|
14
|
+
* failing that, by the name of the entity class.
|
|
15
|
+
*/
|
|
16
|
+
readonly tables: Readonly<Record<string, DrizzleTable>>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A repository provider over a Drizzle database (SQLite or Postgres) and the
|
|
20
|
+
* table of each repository. See the package README for the mapping rules.
|
|
21
|
+
*
|
|
22
|
+
* The provider is not made with `providerOf`: its `db` and `tables` are not
|
|
23
|
+
* JSON, so it is an opaque provider, and extracting an application that uses
|
|
24
|
+
* it needs an import mapping (`provider` / `repositories.<Name>.provider`) that
|
|
25
|
+
* generates `drizzleProvider({ db, tables })`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function drizzleProvider(options: DrizzleProviderOptions): IRepositoryProvider;
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@afora/drizzle",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "A repository provider for @afora/core on Drizzle ORM (SQLite and Postgres).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"afora",
|
|
7
|
+
"drizzle",
|
|
8
|
+
"drizzle-orm",
|
|
9
|
+
"repository",
|
|
10
|
+
"provider",
|
|
11
|
+
"sqlite",
|
|
12
|
+
"postgres"
|
|
13
|
+
],
|
|
14
|
+
"author": "Alan Reis <alanreisanjo@gmail.com> (https://github.com/Hoyasumii)",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://github.com/afora-framework/core/tree/main/packages/drizzle#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/afora-framework/core.git",
|
|
20
|
+
"directory": "packages/drizzle"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"module": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"bun": ">=1.4.4",
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"provenance": true
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"typecheck": "bun check",
|
|
47
|
+
"build": "rm -rf dist && bun build ./src/index.ts --outdir ./dist --target node --format esm --packages external --tsconfig-override tsconfig.build.json && tsc -p tsconfig.build.json && bun ../../scripts/add-dts-extensions.ts dist",
|
|
48
|
+
"test": "bun test"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@electric-sql/pglite": "0.4.6",
|
|
52
|
+
"@types/bun": "^1.4.4",
|
|
53
|
+
"drizzle-orm": "0.45.4",
|
|
54
|
+
"typebox": "^1.4.3"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@afora/core": ">=0.0.1",
|
|
58
|
+
"drizzle-orm": "^0.45.4"
|
|
59
|
+
}
|
|
60
|
+
}
|