@veducio/keja 0.0.0-stage → 0.2.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/LICENSE +7 -0
- package/README.md +39 -2
- package/examples/starter/.env.example +6 -0
- package/examples/starter/keja.config.ts +7 -0
- package/examples/starter/keja.versioning.ts +5 -0
- package/examples/starter/migrations/0001_init.sql +1 -0
- package/examples/starter/package.json +14 -0
- package/examples/starter/tsconfig.json +9 -0
- package/package.json +62 -4
- package/src/access/cloudflare.test.ts +131 -0
- package/src/access/cloudflare.ts +193 -0
- package/src/argus/call.test.ts +98 -0
- package/src/argus/call.ts +153 -0
- package/src/argus/heartbeat.ts +65 -0
- package/src/argus/register.test.ts +120 -0
- package/src/argus/register.ts +145 -0
- package/src/cli/banner.test.ts +98 -0
- package/src/cli/banner.ts +209 -0
- package/src/cli/commands/build.ts +12 -0
- package/src/cli/commands/dev.ts +12 -0
- package/src/cli/commands/generate.ts +12 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/migrate.ts +12 -0
- package/src/cli/commands/seed.ts +12 -0
- package/src/cli/commands/start.ts +12 -0
- package/src/cli/dev-reload.test.ts +87 -0
- package/src/cli/dev-reload.ts +182 -0
- package/src/cli/generate-project.ts +39 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/init-project.test.ts +312 -0
- package/src/cli/init-project.ts +416 -0
- package/src/cli/migrate-project.ts +31 -0
- package/src/cli/run-project.test.ts +50 -0
- package/src/cli/run-project.ts +93 -0
- package/src/cli/seed-project.ts +46 -0
- package/src/db/client.test.ts +49 -0
- package/src/db/client.ts +61 -0
- package/src/db/migrate.test.ts +141 -0
- package/src/db/migrate.ts +146 -0
- package/src/db/owned-sql.ts +202 -0
- package/src/db/postgres.ts +90 -0
- package/src/env/required-env.test.ts +64 -0
- package/src/env/required-env.ts +120 -0
- package/src/errors/framework-errors.ts +295 -0
- package/src/http/create-app.test.ts +340 -0
- package/src/http/create-app.ts +196 -0
- package/src/http/openapi.ts +111 -0
- package/src/process/slot.ts +18 -0
- package/src/project/config.test.ts +140 -0
- package/src/project/config.ts +413 -0
- package/src/project/load.test.ts +60 -0
- package/src/project/load.ts +158 -0
- package/src/project/reload-graph.test.ts +52 -0
- package/src/project/reload-graph.ts +175 -0
- package/src/repository/define.ts +123 -0
- package/src/repository/generate.test.ts +58 -0
- package/src/repository/generate.ts +117 -0
- package/src/repository/query.test.ts +99 -0
- package/src/repository/query.ts +287 -0
- package/src/repository/runtime.test.ts +154 -0
- package/src/repository/runtime.ts +434 -0
- package/src/repository/sql.test.ts +78 -0
- package/src/repository/sql.ts +282 -0
- package/src/route/define.test.ts +65 -0
- package/src/route/define.ts +307 -0
- package/src/route/middleware.test.ts +63 -0
- package/src/route/middleware.ts +144 -0
- package/src/seed/apply.test.ts +65 -0
- package/src/seed/apply.ts +11 -0
- package/src/seed/define.test.ts +51 -0
- package/src/seed/define.ts +55 -0
- package/src/seed/load.test.ts +54 -0
- package/src/seed/load.ts +67 -0
- package/src/seed/report.test.ts +16 -0
- package/src/seed/report.ts +15 -0
- package/src/self-test/checks.ts +88 -0
- package/src/self-test/run.test.ts +128 -0
- package/src/self-test/run.ts +105 -0
- package/src/server/identity.test.ts +57 -0
- package/src/server/identity.ts +116 -0
- package/src/server/index.test.ts +15 -0
- package/src/server/index.ts +169 -0
- package/src/server/swap.test.ts +89 -0
- package/src/server/swap.ts +29 -0
- package/src/typeid/id.test.ts +46 -0
- package/src/typeid/id.ts +136 -0
- package/src/typeid/owned.test.ts +73 -0
- package/src/typeid/owned.ts +193 -0
- package/src/vault/client.test.ts +122 -0
- package/src/vault/client.ts +191 -0
- package/src/versioning/define.test.ts +79 -0
- package/src/versioning/define.ts +173 -0
- package/src/versioning/select.test.ts +79 -0
- package/src/versioning/select.ts +62 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DBCall } from "../db/client.ts";
|
|
3
|
+
import { assertOwnedObject } from "../typeid/owned.ts";
|
|
4
|
+
import {
|
|
5
|
+
buildListSql,
|
|
6
|
+
cursorFromRow,
|
|
7
|
+
filterColumns,
|
|
8
|
+
quote,
|
|
9
|
+
sortableColumns,
|
|
10
|
+
} from "./query.ts";
|
|
11
|
+
import type { ColumnType, DomainColumn } from "./sql.ts";
|
|
12
|
+
|
|
13
|
+
export type ColumnSpec = {
|
|
14
|
+
readonly type: ColumnType;
|
|
15
|
+
readonly nullable: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type TsValue<Type extends ColumnType> = Type extends "text"
|
|
19
|
+
? string
|
|
20
|
+
: Type extends "boolean"
|
|
21
|
+
? boolean
|
|
22
|
+
: Type extends "timestamptz"
|
|
23
|
+
? Date
|
|
24
|
+
: Type extends "integer"
|
|
25
|
+
? number
|
|
26
|
+
: Type extends "bigint"
|
|
27
|
+
? string
|
|
28
|
+
: Type extends "numeric"
|
|
29
|
+
? string
|
|
30
|
+
: Type extends "jsonb"
|
|
31
|
+
? unknown
|
|
32
|
+
: never;
|
|
33
|
+
|
|
34
|
+
export type DomainRow<Columns extends Record<string, ColumnSpec>> = {
|
|
35
|
+
[Key in keyof Columns]: Columns[Key]["nullable"] extends true
|
|
36
|
+
? TsValue<Columns[Key]["type"]> | null
|
|
37
|
+
: TsValue<Columns[Key]["type"]>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type RepositoryRow<Columns extends Record<string, ColumnSpec>> = {
|
|
41
|
+
_id: string;
|
|
42
|
+
_object: string;
|
|
43
|
+
_tenant: string;
|
|
44
|
+
_archived: boolean;
|
|
45
|
+
_createdAt: Date;
|
|
46
|
+
_updatedAt: Date;
|
|
47
|
+
} & DomainRow<Columns>;
|
|
48
|
+
|
|
49
|
+
export type ListConstraints<Columns extends Record<string, ColumnSpec>> = {
|
|
50
|
+
filters?: readonly (keyof Columns & string)[];
|
|
51
|
+
limit?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type AllowedFilters<
|
|
55
|
+
Columns extends Record<string, ColumnSpec>,
|
|
56
|
+
List,
|
|
57
|
+
> = List extends { filters: readonly (infer Name)[] }
|
|
58
|
+
? Extract<Name, keyof Columns>
|
|
59
|
+
: keyof Columns;
|
|
60
|
+
|
|
61
|
+
type ListQuery<Columns extends Record<string, ColumnSpec>, List> = {
|
|
62
|
+
q?: string;
|
|
63
|
+
sort?: string;
|
|
64
|
+
cursor?: string;
|
|
65
|
+
limit?: number;
|
|
66
|
+
archived?: "true" | "false";
|
|
67
|
+
} & {
|
|
68
|
+
[Key in AllowedFilters<Columns, List>]?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type ListCall<Columns extends Record<string, ColumnSpec>, List> = (
|
|
72
|
+
query: ListQuery<Columns, List>,
|
|
73
|
+
options: { tenant: string; where?: Partial<DomainRow<Columns>> },
|
|
74
|
+
) => Promise<{
|
|
75
|
+
items: RepositoryRow<Columns>[];
|
|
76
|
+
nextCursor: string | null;
|
|
77
|
+
}>;
|
|
78
|
+
|
|
79
|
+
type ListProcessor<Columns extends Record<string, ColumnSpec>, List> = ListCall<
|
|
80
|
+
Columns,
|
|
81
|
+
List
|
|
82
|
+
> & {
|
|
83
|
+
schema: z.ZodType<ListQuery<Columns, List>>;
|
|
84
|
+
includeArchived: ListCall<Columns, List>;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type GetCall<Columns extends Record<string, ColumnSpec>> = (
|
|
88
|
+
params: { id: string },
|
|
89
|
+
query: { archived?: "true" | "false" },
|
|
90
|
+
options: { tenant: string },
|
|
91
|
+
) => Promise<RepositoryRow<Columns> | null>;
|
|
92
|
+
|
|
93
|
+
type GetProcessor<Columns extends Record<string, ColumnSpec>> =
|
|
94
|
+
GetCall<Columns> & {
|
|
95
|
+
params: z.ZodObject<{ id: z.ZodString }>;
|
|
96
|
+
query: z.ZodObject<{
|
|
97
|
+
archived: z.ZodOptional<z.ZodEnum<{ true: "true"; false: "false" }>>;
|
|
98
|
+
}>;
|
|
99
|
+
includeArchived: GetCall<Columns>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type Repository<Columns extends Record<string, ColumnSpec>> = {
|
|
103
|
+
table: string;
|
|
104
|
+
schema: z.ZodType<DomainRow<Columns>>;
|
|
105
|
+
meta: z.ZodType<RepositoryRow<Columns>>;
|
|
106
|
+
insert(row: RepositoryRow<Columns>): Promise<RepositoryRow<Columns>>;
|
|
107
|
+
upsert(row: RepositoryRow<Columns>): Promise<RepositoryRow<Columns> | null>;
|
|
108
|
+
update(
|
|
109
|
+
id: string,
|
|
110
|
+
tenant: string,
|
|
111
|
+
patch: Partial<DomainRow<Columns>> & { _archived?: boolean },
|
|
112
|
+
): Promise<RepositoryRow<Columns> | null>;
|
|
113
|
+
archive(id: string, tenant: string): Promise<RepositoryRow<Columns> | null>;
|
|
114
|
+
hardDelete(
|
|
115
|
+
id: string,
|
|
116
|
+
tenant: string,
|
|
117
|
+
): Promise<RepositoryRow<Columns> | null>;
|
|
118
|
+
find(
|
|
119
|
+
id: string,
|
|
120
|
+
options: { tenant: string; archived?: boolean },
|
|
121
|
+
): Promise<RepositoryRow<Columns> | null>;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function repository<
|
|
125
|
+
const Columns extends Record<string, ColumnSpec>,
|
|
126
|
+
const List extends boolean | ListConstraints<Columns> = false,
|
|
127
|
+
const Get extends boolean = false,
|
|
128
|
+
>(input: {
|
|
129
|
+
table: string;
|
|
130
|
+
columns: Columns;
|
|
131
|
+
list?: List;
|
|
132
|
+
get?: Get;
|
|
133
|
+
}): Repository<Columns> &
|
|
134
|
+
(List extends false ? object : { list: ListProcessor<Columns, List> }) &
|
|
135
|
+
(Get extends true ? { get: GetProcessor<Columns> } : object) {
|
|
136
|
+
const columns = Object.entries(input.columns).map(([name, column]) => ({
|
|
137
|
+
name,
|
|
138
|
+
type: column.type,
|
|
139
|
+
nullable: column.nullable,
|
|
140
|
+
}));
|
|
141
|
+
const shape = rowSchemas(columns);
|
|
142
|
+
const base: Repository<Columns> = {
|
|
143
|
+
table: input.table,
|
|
144
|
+
schema: shape.schema as z.ZodType<DomainRow<Columns>>,
|
|
145
|
+
meta: shape.meta as z.ZodType<RepositoryRow<Columns>>,
|
|
146
|
+
insert: (row) => insertRow(input.table, row),
|
|
147
|
+
upsert: (row) => upsertRow<Columns>(input.table, columns, row),
|
|
148
|
+
update: (id, tenant, patch) =>
|
|
149
|
+
updateRow<Columns>(input.table, columns, id, tenant, patch),
|
|
150
|
+
archive: (id, tenant) =>
|
|
151
|
+
updateRow<Columns>(input.table, columns, id, tenant, archivedPatch()),
|
|
152
|
+
hardDelete: (id, tenant) => hardDeleteRow<Columns>(input.table, id, tenant),
|
|
153
|
+
find: (id, options) => findRow<Columns>(input.table, columns, id, options),
|
|
154
|
+
};
|
|
155
|
+
const withList =
|
|
156
|
+
input.list === true || typeof input.list === "object"
|
|
157
|
+
? { ...base, list: createList(input.table, columns, input.list) }
|
|
158
|
+
: base;
|
|
159
|
+
const withGet = input.get
|
|
160
|
+
? { ...withList, get: createGet(input.table, columns) }
|
|
161
|
+
: withList;
|
|
162
|
+
return withGet as Repository<Columns> &
|
|
163
|
+
(List extends false ? object : { list: ListProcessor<Columns, List> }) &
|
|
164
|
+
(Get extends true ? { get: GetProcessor<Columns> } : object);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createList<Columns extends Record<string, ColumnSpec>>(
|
|
168
|
+
table: string,
|
|
169
|
+
columns: DomainColumn[],
|
|
170
|
+
list: true | ListConstraints<Columns>,
|
|
171
|
+
): ListProcessor<Columns, typeof list> {
|
|
172
|
+
const constraints = list === true ? {} : list;
|
|
173
|
+
const allowed = listFilters(table, columns, constraints.filters);
|
|
174
|
+
const sorts = sortableColumns(columns).flatMap((column) => [
|
|
175
|
+
column,
|
|
176
|
+
`-${column}`,
|
|
177
|
+
]);
|
|
178
|
+
const shape: Record<string, z.ZodType> = {
|
|
179
|
+
q: z.string().optional(),
|
|
180
|
+
sort: z.enum(sorts as [string, ...string[]]).optional(),
|
|
181
|
+
cursor: z.string().optional(),
|
|
182
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
183
|
+
archived: z.enum(["true", "false"]).optional(),
|
|
184
|
+
};
|
|
185
|
+
for (const column of allowed) {
|
|
186
|
+
shape[column.name] = z.string().optional();
|
|
187
|
+
}
|
|
188
|
+
const schema = z.object(shape) as z.ZodType<ListQuery<Columns, typeof list>>;
|
|
189
|
+
const run =
|
|
190
|
+
(includeArchived: boolean) =>
|
|
191
|
+
async (
|
|
192
|
+
query: ListQuery<Columns, typeof list>,
|
|
193
|
+
options: { tenant: string; where?: Partial<DomainRow<Columns>> },
|
|
194
|
+
) => {
|
|
195
|
+
const built = buildListSql({
|
|
196
|
+
table,
|
|
197
|
+
columns,
|
|
198
|
+
query,
|
|
199
|
+
tenant: options.tenant,
|
|
200
|
+
filters: constraints.filters,
|
|
201
|
+
limitDefault: constraints.limit,
|
|
202
|
+
where: options.where,
|
|
203
|
+
includeArchived,
|
|
204
|
+
});
|
|
205
|
+
const rows = (await DBCall.unsafe(
|
|
206
|
+
built.text,
|
|
207
|
+
built.values,
|
|
208
|
+
)) as RepositoryRow<Columns>[];
|
|
209
|
+
const items = rows.slice(0, built.limit);
|
|
210
|
+
const last = items.at(-1);
|
|
211
|
+
const nextCursor =
|
|
212
|
+
rows.length > built.limit && last
|
|
213
|
+
? cursorFromRow(last, {
|
|
214
|
+
column: builtQuerySort(query.sort),
|
|
215
|
+
direction: query.sort?.startsWith("-") ? "desc" : "asc",
|
|
216
|
+
})
|
|
217
|
+
: null;
|
|
218
|
+
return { items, nextCursor };
|
|
219
|
+
};
|
|
220
|
+
return Object.assign(run(false), { schema, includeArchived: run(true) });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function listFilters(
|
|
224
|
+
table: string,
|
|
225
|
+
columns: readonly DomainColumn[],
|
|
226
|
+
filters: readonly string[] | undefined,
|
|
227
|
+
) {
|
|
228
|
+
if (!filters) {
|
|
229
|
+
return filterColumns(columns);
|
|
230
|
+
}
|
|
231
|
+
return filters.map((name) => {
|
|
232
|
+
const column = columns.find((item) => item.name === name);
|
|
233
|
+
if (!column || column.type === "jsonb") {
|
|
234
|
+
throw new Error(`${table} list cannot filter ${name}`);
|
|
235
|
+
}
|
|
236
|
+
return column;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function rowSchemas(columns: readonly DomainColumn[]) {
|
|
241
|
+
const domain: Record<string, z.ZodType> = {};
|
|
242
|
+
for (const column of columns) {
|
|
243
|
+
domain[column.name] = columnSchema(column.type, column.nullable);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
schema: z.object(domain),
|
|
247
|
+
meta: z.object({
|
|
248
|
+
_id: z.string(),
|
|
249
|
+
_object: z.string(),
|
|
250
|
+
_tenant: z.string(),
|
|
251
|
+
_archived: z.boolean(),
|
|
252
|
+
_createdAt: z.date(),
|
|
253
|
+
_updatedAt: z.date(),
|
|
254
|
+
...domain,
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function columnSchema(type: ColumnType, nullable: boolean) {
|
|
260
|
+
const schema =
|
|
261
|
+
type === "boolean"
|
|
262
|
+
? z.boolean()
|
|
263
|
+
: type === "timestamptz"
|
|
264
|
+
? z.date()
|
|
265
|
+
: type === "integer"
|
|
266
|
+
? z.number().int()
|
|
267
|
+
: type === "bigint" || type === "numeric"
|
|
268
|
+
? z.string()
|
|
269
|
+
: type === "jsonb"
|
|
270
|
+
? z.unknown()
|
|
271
|
+
: z.string();
|
|
272
|
+
return nullable ? schema.nullable() : schema;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function builtQuerySort(sort: string | undefined) {
|
|
276
|
+
const raw = sort && sort !== "" ? sort : "_id";
|
|
277
|
+
return raw.startsWith("-") ? raw.slice(1) : raw;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function createGet<Columns extends Record<string, ColumnSpec>>(
|
|
281
|
+
table: string,
|
|
282
|
+
columns: DomainColumn[],
|
|
283
|
+
): GetProcessor<Columns> {
|
|
284
|
+
const params = z.object({ id: z.string().min(1) });
|
|
285
|
+
const query = z.object({
|
|
286
|
+
archived: z.enum(["true", "false"]).optional(),
|
|
287
|
+
});
|
|
288
|
+
const run =
|
|
289
|
+
(includeArchived: boolean) =>
|
|
290
|
+
async (
|
|
291
|
+
input: { id: string },
|
|
292
|
+
parsed: { archived?: "true" | "false" },
|
|
293
|
+
options: { tenant: string },
|
|
294
|
+
) =>
|
|
295
|
+
findRow<Columns>(table, columns, input.id, {
|
|
296
|
+
tenant: options.tenant,
|
|
297
|
+
archived: parsed.archived === "true",
|
|
298
|
+
includeArchived,
|
|
299
|
+
});
|
|
300
|
+
return Object.assign(run(false), {
|
|
301
|
+
params,
|
|
302
|
+
query,
|
|
303
|
+
includeArchived: run(true),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function insertRow<Columns extends Record<string, ColumnSpec>>(
|
|
308
|
+
table: string,
|
|
309
|
+
row: RepositoryRow<Columns>,
|
|
310
|
+
) {
|
|
311
|
+
assertOwnedObject(row);
|
|
312
|
+
const inserted = await DBCall<RepositoryRow<Columns>[]>`
|
|
313
|
+
insert into ${DBCall(table)} ${DBCall(row)}
|
|
314
|
+
returning *
|
|
315
|
+
`;
|
|
316
|
+
const saved = inserted[0];
|
|
317
|
+
if (!saved) {
|
|
318
|
+
throw new Error(`insert into ${table} returned no row`);
|
|
319
|
+
}
|
|
320
|
+
return saved;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function archivedPatch<Columns extends Record<string, ColumnSpec>>() {
|
|
324
|
+
const patch: Partial<DomainRow<Columns>> & { _archived?: boolean } = {};
|
|
325
|
+
patch._archived = true;
|
|
326
|
+
return patch;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function upsertRow<Columns extends Record<string, ColumnSpec>>(
|
|
330
|
+
table: string,
|
|
331
|
+
columns: readonly DomainColumn[],
|
|
332
|
+
row: RepositoryRow<Columns>,
|
|
333
|
+
) {
|
|
334
|
+
assertOwnedObject(row);
|
|
335
|
+
const names = [
|
|
336
|
+
"_id",
|
|
337
|
+
"_object",
|
|
338
|
+
"_tenant",
|
|
339
|
+
"_archived",
|
|
340
|
+
"_createdAt",
|
|
341
|
+
"_updatedAt",
|
|
342
|
+
...columns.map((column) => column.name),
|
|
343
|
+
];
|
|
344
|
+
const updates = names.filter(
|
|
345
|
+
(name) =>
|
|
346
|
+
name !== "_id" &&
|
|
347
|
+
name !== "_object" &&
|
|
348
|
+
name !== "_tenant" &&
|
|
349
|
+
name !== "_createdAt",
|
|
350
|
+
);
|
|
351
|
+
const values = names.map((name) => row[name as keyof RepositoryRow<Columns>]);
|
|
352
|
+
const placeholders = names.map((_, index) => `$${index + 1}`).join(", ");
|
|
353
|
+
const assignments = updates
|
|
354
|
+
.map((name) => `${quote(name)} = excluded.${quote(name)}`)
|
|
355
|
+
.join(", ");
|
|
356
|
+
const rows = (await DBCall.unsafe(
|
|
357
|
+
`insert into ${quote(table)} (${names.map((name) => quote(name)).join(", ")}) values (${placeholders}) on conflict (${quote("_id")}) do update set ${assignments} where ${quote(table)}.${quote("_tenant")} = excluded.${quote("_tenant")} returning *`,
|
|
358
|
+
values,
|
|
359
|
+
)) as RepositoryRow<Columns>[];
|
|
360
|
+
return rows[0] ?? null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function hardDeleteRow<Columns extends Record<string, ColumnSpec>>(
|
|
364
|
+
table: string,
|
|
365
|
+
id: string,
|
|
366
|
+
tenant: string,
|
|
367
|
+
) {
|
|
368
|
+
const rows = (await DBCall.unsafe(
|
|
369
|
+
`delete from ${quote(table)} where ${quote("_id")} = $1 and ${quote("_tenant")} = $2 returning *`,
|
|
370
|
+
[id, tenant],
|
|
371
|
+
)) as RepositoryRow<Columns>[];
|
|
372
|
+
return rows[0] ?? null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function updateRow<Columns extends Record<string, ColumnSpec>>(
|
|
376
|
+
table: string,
|
|
377
|
+
columns: readonly DomainColumn[],
|
|
378
|
+
id: string,
|
|
379
|
+
tenant: string,
|
|
380
|
+
patch: Partial<DomainRow<Columns>> & { _archived?: boolean },
|
|
381
|
+
) {
|
|
382
|
+
const entries = Object.entries(patch).filter(
|
|
383
|
+
([, value]) => value !== undefined,
|
|
384
|
+
);
|
|
385
|
+
if (entries.length === 0) {
|
|
386
|
+
throw new Error(`${table} update has no columns`);
|
|
387
|
+
}
|
|
388
|
+
const values: unknown[] = [];
|
|
389
|
+
const sets = entries.map(([key, value]) => {
|
|
390
|
+
if (key !== "_archived" && !columns.some((column) => column.name === key)) {
|
|
391
|
+
throw new Error(`${table} update cannot set ${key}`);
|
|
392
|
+
}
|
|
393
|
+
values.push(value);
|
|
394
|
+
return `${quote(key)} = $${values.length}`;
|
|
395
|
+
});
|
|
396
|
+
values.push(new Date());
|
|
397
|
+
const updatedAt = values.length;
|
|
398
|
+
values.push(id, tenant);
|
|
399
|
+
const rows = (await DBCall.unsafe(
|
|
400
|
+
`update ${quote(table)} set ${sets.join(", ")}, ${quote("_updatedAt")} = $${updatedAt} where ${quote("_id")} = $${updatedAt + 1} and ${quote("_tenant")} = $${updatedAt + 2} returning *`,
|
|
401
|
+
values,
|
|
402
|
+
)) as RepositoryRow<Columns>[];
|
|
403
|
+
return rows[0] ?? null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function findRow<Columns extends Record<string, ColumnSpec>>(
|
|
407
|
+
table: string,
|
|
408
|
+
columns: readonly DomainColumn[],
|
|
409
|
+
id: string,
|
|
410
|
+
options: { tenant: string; archived?: boolean; includeArchived?: boolean },
|
|
411
|
+
) {
|
|
412
|
+
const selected = [
|
|
413
|
+
"_id",
|
|
414
|
+
"_object",
|
|
415
|
+
"_tenant",
|
|
416
|
+
"_archived",
|
|
417
|
+
"_createdAt",
|
|
418
|
+
"_updatedAt",
|
|
419
|
+
...columns.map((column) => column.name),
|
|
420
|
+
]
|
|
421
|
+
.map((name) => quote(name))
|
|
422
|
+
.join(", ");
|
|
423
|
+
const archived = options.includeArchived
|
|
424
|
+
? ""
|
|
425
|
+
: ` and ${quote("_archived")} = $3`;
|
|
426
|
+
const values = options.includeArchived
|
|
427
|
+
? [id, options.tenant]
|
|
428
|
+
: [id, options.tenant, options.archived === true];
|
|
429
|
+
const rows = (await DBCall.unsafe(
|
|
430
|
+
`select ${selected} from ${quote(table)} where ${quote("_id")} = $1 and ${quote("_tenant")} = $2${archived}`,
|
|
431
|
+
values,
|
|
432
|
+
)) as RepositoryRow<Columns>[];
|
|
433
|
+
return rows[0] ?? null;
|
|
434
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { readTables } from "./sql.ts";
|
|
6
|
+
|
|
7
|
+
const owned = `
|
|
8
|
+
_id text primary key check (keja_is_typeid(_id)),
|
|
9
|
+
_object text not null check (keja_is_object_type(_object)),
|
|
10
|
+
_tenant text not null check (keja_is_tenant(_tenant)),
|
|
11
|
+
_archived boolean not null default false,
|
|
12
|
+
"_createdAt" timestamptz not null,
|
|
13
|
+
"_updatedAt" timestamptz not null,
|
|
14
|
+
check (keja_timestamps("_createdAt", "_updatedAt"))
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
describe("readTables", () => {
|
|
18
|
+
it("reads domain columns from create table and add column", () => {
|
|
19
|
+
const tables = readTables([
|
|
20
|
+
{
|
|
21
|
+
file: "0001_example.sql",
|
|
22
|
+
sql: `create table users (${owned}, name text not null, email text);`,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
file: "0002_nickname.sql",
|
|
26
|
+
sql: "alter table users add column nickname text;",
|
|
27
|
+
},
|
|
28
|
+
]);
|
|
29
|
+
expect(tables).toEqual([
|
|
30
|
+
{
|
|
31
|
+
name: "users",
|
|
32
|
+
columns: [
|
|
33
|
+
{ name: "name", type: "text", nullable: false },
|
|
34
|
+
{ name: "email", type: "text", nullable: true },
|
|
35
|
+
{ name: "nickname", type: "text", nullable: true },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("reads the example app migrations", () => {
|
|
42
|
+
const file = path.join(
|
|
43
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
44
|
+
"../../examples/example-app/migrations/0001_example.sql",
|
|
45
|
+
);
|
|
46
|
+
const tables = readTables([
|
|
47
|
+
{ file: "0001_example.sql", sql: readFileSync(file, "utf8") },
|
|
48
|
+
]);
|
|
49
|
+
expect(tables.map((table) => table.name)).toEqual([
|
|
50
|
+
"jobs",
|
|
51
|
+
"posts",
|
|
52
|
+
"users",
|
|
53
|
+
]);
|
|
54
|
+
expect(tables.find((table) => table.name === "posts")?.columns).toEqual([
|
|
55
|
+
{ name: "title", type: "text", nullable: false },
|
|
56
|
+
{ name: "author_id", type: "text", nullable: false },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects a table that misses an owned column or a mapped type", () => {
|
|
61
|
+
expect(() =>
|
|
62
|
+
readTables([
|
|
63
|
+
{
|
|
64
|
+
file: "0001_bad.sql",
|
|
65
|
+
sql: "create table users (_id text not null);",
|
|
66
|
+
},
|
|
67
|
+
]),
|
|
68
|
+
).toThrow("users._object must be text not null");
|
|
69
|
+
expect(() =>
|
|
70
|
+
readTables([
|
|
71
|
+
{
|
|
72
|
+
file: "0001_bad.sql",
|
|
73
|
+
sql: `create table users (${owned}, photo bytea);`,
|
|
74
|
+
},
|
|
75
|
+
]),
|
|
76
|
+
).toThrow("users.photo has no TypeScript type");
|
|
77
|
+
});
|
|
78
|
+
});
|