Skip to content

Commit 9b47305

Browse files
authored
feat: api core sql (#5278)
1 parent fcf86c7 commit 9b47305

25 files changed

Lines changed: 1537 additions & 16 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ packages/*/README.md
5757
packages/tasks/tpl/*
5858
./nextjs
5959

60+
db/
61+
6062
/.local
6163
CLAUDE.local.md
6264
.claude/*.local.*
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { resolve } from "path";
2+
3+
export default [
4+
{
5+
setupFiles: [resolve(import.meta.dirname, "setupFile.js")],
6+
setupFilesAfterEnv: [resolve(import.meta.dirname, "setupAfterEnv.js")]
7+
}
8+
];
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { beforeEach, afterAll } from "vitest";
2+
3+
const getKnex = () => {
4+
return global.__testKnex;
5+
};
6+
7+
beforeEach(async () => {
8+
const knex = getKnex();
9+
if (!knex) {
10+
return;
11+
}
12+
13+
const tables = await knex.raw(
14+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
15+
);
16+
for (const { name } of tables) {
17+
await knex.schema.dropTableIfExists(name);
18+
}
19+
20+
const managers = globalThis.__sqlTableManagers || [];
21+
for (const manager of managers) {
22+
manager.reset();
23+
}
24+
});
25+
26+
afterAll(async () => {
27+
const knex = getKnex();
28+
if (knex) {
29+
await knex.destroy();
30+
}
31+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { setStorageOps } from "@webiny/project-utils/testing/environment/index.js";
2+
import { createApiCoreSql } from "~/createApiCoreSql.js";
3+
import knexLib from "knex";
4+
5+
const knex = knexLib({
6+
client: "better-sqlite3",
7+
connection: {
8+
filename: ":memory:"
9+
},
10+
useNullAsDefault: true
11+
});
12+
13+
global.__testKnex = knex;
14+
15+
setStorageOps("apiCore", () => {
16+
return {
17+
storageOperations: createApiCoreSql({ knex }),
18+
plugins: []
19+
};
20+
});

packages/api-core-sql/package.json

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "@webiny/api-core-sql",
3+
"version": "0.0.0",
4+
"type": "module",
5+
"exports": {
6+
".": "./index.js",
7+
"./*": "./*"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "https://github.com/webiny/webiny-js.git"
12+
},
13+
"keywords": [
14+
"@webiny/api-core",
15+
"storage-operations",
16+
"sql",
17+
"knex",
18+
"sau:sql"
19+
],
20+
"description": "SQL-specific dependencies for @webiny/api-core.",
21+
"author": "Webiny Ltd.",
22+
"license": "MIT",
23+
"dependencies": {
24+
"@webiny/api-core": "0.0.0",
25+
"@webiny/error": "0.0.0",
26+
"knex": "^3.2.10"
27+
},
28+
"devDependencies": {
29+
"@webiny/build-tools": "0.0.0",
30+
"@webiny/project-utils": "0.0.0",
31+
"better-sqlite3": "^12.10.0",
32+
"typescript": "6.0.3",
33+
"vitest": "^4.1.8"
34+
},
35+
"publishConfig": {
36+
"access": "public"
37+
},
38+
"webiny": {
39+
"publishFrom": "dist"
40+
}
41+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Knex } from "knex";
2+
3+
interface ITableManager {
4+
reset(): void;
5+
ensure(tableName: string, creator: (table: Knex.CreateTableBuilder) => void): Promise<void>;
6+
}
7+
8+
export class TableManager implements ITableManager {
9+
private readonly knex: Knex;
10+
private readonly verified = new Set<string>();
11+
12+
constructor(knex: Knex) {
13+
this.knex = knex;
14+
15+
const g = globalThis as Record<string, unknown>;
16+
const managers = (g.__sqlTableManagers ??= []) as ITableManager[];
17+
managers.push(this);
18+
}
19+
20+
public reset(): void {
21+
this.verified.clear();
22+
}
23+
24+
public async ensure(
25+
tableName: string,
26+
creator: (table: Knex.CreateTableBuilder) => void
27+
): Promise<void> {
28+
if (this.verified.has(tableName)) {
29+
return;
30+
}
31+
32+
const exists = await this.knex.schema.hasTable(tableName);
33+
34+
if (!exists) {
35+
await this.knex.schema.createTable(tableName, creator);
36+
}
37+
38+
this.verified.add(tableName);
39+
}
40+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import type { Knex } from "knex";
2+
import type {
3+
AdminUser,
4+
AdminUsersStorageOperations,
5+
StorageOperationsGetUserParams,
6+
StorageOperationsListUsersParams
7+
} from "@webiny/api-core/types/users.js";
8+
import WebinyError from "@webiny/error";
9+
import type { TableManager } from "~/TableManager.js";
10+
import { sortItems } from "~/sortItems.js";
11+
12+
const TABLE_NAME = "webiny_core_admin_users";
13+
14+
interface IAdminUserRow {
15+
id: string;
16+
tenant: string;
17+
email: string;
18+
data: string;
19+
}
20+
21+
const userToRow = (user: AdminUser): IAdminUserRow => {
22+
return {
23+
id: user.id,
24+
tenant: user.tenant,
25+
email: user.email,
26+
data: JSON.stringify(user)
27+
};
28+
};
29+
30+
const rowToUser = <TUser extends AdminUser = AdminUser>(row: IAdminUserRow): TUser => {
31+
return JSON.parse(row.data) as TUser;
32+
};
33+
34+
interface CreateStorageOperationsParams {
35+
knex: Knex;
36+
tableManager: TableManager;
37+
}
38+
39+
export const createStorageOperations = (
40+
params: CreateStorageOperationsParams
41+
): AdminUsersStorageOperations => {
42+
const { knex, tableManager } = params;
43+
44+
const ensureTable = () => {
45+
return tableManager.ensure(TABLE_NAME, table => {
46+
table.text("id").notNullable();
47+
table.text("tenant").notNullable();
48+
table.text("email").notNullable();
49+
table.text("data").notNullable();
50+
51+
table.primary(["tenant", "id"]);
52+
table.unique(["tenant", "email"]);
53+
});
54+
};
55+
56+
const query = () => {
57+
return knex<IAdminUserRow>(TABLE_NAME);
58+
};
59+
60+
return {
61+
async getUser<TUser extends AdminUser = AdminUser>(
62+
params: StorageOperationsGetUserParams
63+
): Promise<TUser | null> {
64+
await ensureTable();
65+
66+
const {
67+
where: { tenant, id, email }
68+
} = params;
69+
70+
try {
71+
if (id) {
72+
const row = await query().where("tenant", tenant).andWhere("id", id).first();
73+
74+
return row ? rowToUser<TUser>(row) : null;
75+
}
76+
77+
const row = await query()
78+
.where("tenant", tenant)
79+
.andWhere("email", email as string)
80+
.first();
81+
82+
return row ? rowToUser<TUser>(row) : null;
83+
} catch (err) {
84+
throw WebinyError.from(err, {
85+
message: "Could not load user.",
86+
code: "GET_ADMIN_USERS_ERROR",
87+
data: { id, email }
88+
});
89+
}
90+
},
91+
92+
async listUsers<TUser extends AdminUser = AdminUser>(
93+
params: StorageOperationsListUsersParams
94+
): Promise<TUser[]> {
95+
await ensureTable();
96+
97+
const { where, sort } = params;
98+
99+
try {
100+
const rows = await query().where("tenant", where.tenant);
101+
102+
let items = rows.map(row => rowToUser<TUser>(row));
103+
104+
items = sortItems(items, sort);
105+
106+
const { id_in } = where;
107+
108+
if (Array.isArray(id_in)) {
109+
return items.filter(item => id_in.includes(item.id));
110+
}
111+
112+
return items;
113+
} catch (err) {
114+
throw WebinyError.from(err, {
115+
message: "Could not list users.",
116+
code: "LIST_ADMIN_USERS_ERROR"
117+
});
118+
}
119+
},
120+
121+
async createUser<TUser extends AdminUser = AdminUser>({
122+
user
123+
}: {
124+
user: TUser;
125+
}): Promise<TUser> {
126+
await ensureTable();
127+
128+
try {
129+
const row = userToRow(user);
130+
await query().insert(row);
131+
132+
return user;
133+
} catch (err) {
134+
throw WebinyError.from(err, {
135+
message: "Could not create admin user.",
136+
code: "CREATE_ADMIN_USER_ERROR",
137+
data: { user }
138+
});
139+
}
140+
},
141+
142+
async updateUser<TUser extends AdminUser = AdminUser>({
143+
user
144+
}: {
145+
user: TUser;
146+
}): Promise<TUser> {
147+
await ensureTable();
148+
149+
try {
150+
const row = userToRow(user);
151+
await query().where("tenant", user.tenant).andWhere("id", user.id).update(row);
152+
153+
return user;
154+
} catch (err) {
155+
throw WebinyError.from(err, {
156+
message: "Could not update user.",
157+
code: "UPDATE_ADMIN_USER_ERROR",
158+
data: { user }
159+
});
160+
}
161+
},
162+
163+
async deleteUser<TUser extends AdminUser = AdminUser>({
164+
user
165+
}: {
166+
user: TUser;
167+
}): Promise<void> {
168+
await ensureTable();
169+
170+
try {
171+
await query().where("tenant", user.tenant).andWhere("id", user.id).delete();
172+
} catch (err) {
173+
throw WebinyError.from(err, {
174+
message: "Could not delete user.",
175+
code: "DELETE_ADMIN_USER_ERROR",
176+
data: { user }
177+
});
178+
}
179+
}
180+
};
181+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Knex } from "knex";
2+
import type { ApiCoreStorageOperations } from "@webiny/api-core/types/core.js";
3+
import { createStorageOperations as createUsersStorageOperations } from "./adminUsers/index.js";
4+
import { createStorageOperations as createTenancyStorageOperations } from "./tenancy/index.js";
5+
import { createStorageOperations as createSecurityStorageOperations } from "./security/index.js";
6+
import { createStorageOperations as createKeyValueStorageOperations } from "./keyValueStore/index.js";
7+
import { TableManager } from "./TableManager.js";
8+
9+
interface CreateApiCoreSqlParams {
10+
knex: Knex;
11+
}
12+
13+
export const createApiCoreSql = ({ knex }: CreateApiCoreSqlParams): ApiCoreStorageOperations => {
14+
const tableManager = new TableManager(knex);
15+
16+
return {
17+
usersStorageOperations: createUsersStorageOperations({
18+
knex,
19+
tableManager
20+
}),
21+
tenancyStorageOperations: createTenancyStorageOperations({
22+
knex,
23+
tableManager
24+
}),
25+
securityStorageOperations: createSecurityStorageOperations({
26+
knex,
27+
tableManager
28+
}),
29+
keyValueStorageOperations: createKeyValueStorageOperations({
30+
knex,
31+
tableManager
32+
})
33+
};
34+
};

packages/api-core-sql/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { createApiCoreSql } from "./createApiCoreSql.js";

0 commit comments

Comments
 (0)