@veducio/keja 0.3.0 → 0.5.0
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/package.json +1 -1
- package/src/grant/grant.test.ts +116 -57
- package/src/grant/grant.ts +47 -26
- package/src/grant/settings.ts +67 -0
- package/src/http/batch.ts +307 -0
- package/src/http/create-app.test.ts +141 -0
- package/src/http/create-app.ts +25 -0
- package/src/http/openapi.ts +94 -0
- package/src/project/config.test.ts +39 -3
- package/src/project/config.ts +163 -16
- package/src/project/load.ts +6 -0
- package/src/repository/generate.test.ts +16 -0
- package/src/repository/generate.ts +11 -4
- package/src/repository/query.ts +5 -0
- package/src/repository/runtime.test.ts +18 -0
- package/src/repository/runtime.ts +57 -34
- package/src/repository/sql.test.ts +33 -0
- package/src/repository/sql.ts +199 -27
- package/src/route/define.ts +14 -0
- package/src/server/index.ts +19 -1
- package/src/vault/client.test.ts +0 -6
package/src/repository/sql.ts
CHANGED
|
@@ -7,13 +7,21 @@ export type ColumnType =
|
|
|
7
7
|
| "integer"
|
|
8
8
|
| "bigint"
|
|
9
9
|
| "numeric"
|
|
10
|
-
| "jsonb"
|
|
10
|
+
| "jsonb"
|
|
11
|
+
| "enum";
|
|
11
12
|
|
|
12
|
-
export type DomainColumn =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
export type DomainColumn =
|
|
14
|
+
| {
|
|
15
|
+
name: string;
|
|
16
|
+
type: Exclude<ColumnType, "enum">;
|
|
17
|
+
nullable: boolean;
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
name: string;
|
|
21
|
+
type: "enum";
|
|
22
|
+
nullable: boolean;
|
|
23
|
+
values: readonly [string, ...string[]];
|
|
24
|
+
};
|
|
17
25
|
|
|
18
26
|
export type TableSchema = {
|
|
19
27
|
name: string;
|
|
@@ -29,7 +37,10 @@ const reservedTypes = {
|
|
|
29
37
|
_updatedAt: "timestamptz",
|
|
30
38
|
} as const;
|
|
31
39
|
|
|
32
|
-
const typePatterns: readonly {
|
|
40
|
+
const typePatterns: readonly {
|
|
41
|
+
pattern: RegExp;
|
|
42
|
+
type: Exclude<ColumnType, "enum">;
|
|
43
|
+
}[] = [
|
|
33
44
|
{ pattern: /^timestamptz\b/i, type: "timestamptz" },
|
|
34
45
|
{ pattern: /^timestamp\s+with\s+time\s+zone\b/i, type: "timestamptz" },
|
|
35
46
|
{ pattern: /^boolean\b/i, type: "boolean" },
|
|
@@ -47,48 +58,114 @@ const typePatterns: readonly { pattern: RegExp; type: ColumnType }[] = [
|
|
|
47
58
|
{ pattern: /^character\s+varying\b/i, type: "text" },
|
|
48
59
|
];
|
|
49
60
|
|
|
50
|
-
type
|
|
61
|
+
type WorkingColumn =
|
|
62
|
+
| {
|
|
63
|
+
name: string;
|
|
64
|
+
type: Exclude<ColumnType, "enum">;
|
|
65
|
+
nullable: boolean;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
name: string;
|
|
69
|
+
type: "enum";
|
|
70
|
+
nullable: boolean;
|
|
71
|
+
enumName: string;
|
|
72
|
+
};
|
|
51
73
|
|
|
52
74
|
type WorkingTable = {
|
|
53
75
|
name: string;
|
|
54
|
-
reserved: Map<string,
|
|
55
|
-
columns:
|
|
76
|
+
reserved: Map<string, WorkingColumn>;
|
|
77
|
+
columns: WorkingColumn[];
|
|
56
78
|
};
|
|
57
79
|
|
|
58
80
|
export function readTables(files: readonly { file: string; sql: string }[]) {
|
|
59
81
|
const tables = new Map<string, WorkingTable>();
|
|
82
|
+
const enums = new Map<string, [string, ...string[]]>();
|
|
60
83
|
for (const file of files) {
|
|
61
84
|
for (const statement of statements(stripComments(file.sql))) {
|
|
62
|
-
applyStatement(tables, file.file, statement);
|
|
85
|
+
applyStatement(tables, enums, file.file, statement);
|
|
63
86
|
}
|
|
64
87
|
}
|
|
65
88
|
return [...tables.values()]
|
|
66
|
-
.map((table) => finishTable(table))
|
|
89
|
+
.map((table) => finishTable(table, enums))
|
|
67
90
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
function applyStatement(
|
|
71
94
|
tables: Map<string, WorkingTable>,
|
|
95
|
+
enums: Map<string, [string, ...string[]]>,
|
|
72
96
|
file: string,
|
|
73
97
|
statement: string,
|
|
74
98
|
) {
|
|
99
|
+
const createType = statement.match(
|
|
100
|
+
/^create\s+type\s+(?:if\s+not\s+exists\s+)?(.+?)\s+as\s+enum\s*\(([\s\S]*)\)\s*$/i,
|
|
101
|
+
);
|
|
102
|
+
if (createType?.[1] && createType[2] !== undefined) {
|
|
103
|
+
createEnum(enums, file, createType[1].trim(), createType[2]);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const alterType = statement.match(
|
|
107
|
+
/^alter\s+type\s+(.+?)\s+add\s+value\s+(?:if\s+not\s+exists\s+)?([\s\S]+)$/i,
|
|
108
|
+
);
|
|
109
|
+
if (alterType?.[1] && alterType[2]) {
|
|
110
|
+
addEnumValue(enums, file, alterType[1].trim(), alterType[2].trim());
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
75
113
|
const create = statement.match(
|
|
76
114
|
/^create\s+table\s+(?:if\s+not\s+exists\s+)?([\s\S]+)$/i,
|
|
77
115
|
);
|
|
78
116
|
if (create?.[1]) {
|
|
79
|
-
createTable(tables, file, create[1]);
|
|
117
|
+
createTable(tables, enums, file, create[1]);
|
|
80
118
|
return;
|
|
81
119
|
}
|
|
82
120
|
const alter = statement.match(
|
|
83
121
|
/^alter\s+table\s+(.+?)\s+add\s+(?:column\s+)?(?:if\s+not\s+exists\s+)?([\s\S]+)$/i,
|
|
84
122
|
);
|
|
85
123
|
if (alter?.[1] && alter[2]) {
|
|
86
|
-
addColumn(tables, file, alter[1].trim(), alter[2].trim());
|
|
124
|
+
addColumn(tables, enums, file, alter[1].trim(), alter[2].trim());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createEnum(
|
|
129
|
+
enums: Map<string, [string, ...string[]]>,
|
|
130
|
+
file: string,
|
|
131
|
+
token: string,
|
|
132
|
+
body: string,
|
|
133
|
+
) {
|
|
134
|
+
const name = typeName(token, file);
|
|
135
|
+
if (enums.has(name)) {
|
|
136
|
+
throw projectError(
|
|
137
|
+
"invalidRepositorySql",
|
|
138
|
+
`${file} creates type ${name} more than once`,
|
|
139
|
+
);
|
|
87
140
|
}
|
|
141
|
+
const values = readEnumValues(body, file, name);
|
|
142
|
+
enums.set(name, values);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function addEnumValue(
|
|
146
|
+
enums: Map<string, [string, ...string[]]>,
|
|
147
|
+
file: string,
|
|
148
|
+
token: string,
|
|
149
|
+
rest: string,
|
|
150
|
+
) {
|
|
151
|
+
const name = typeName(token, file);
|
|
152
|
+
const current = enums.get(name);
|
|
153
|
+
if (!current) {
|
|
154
|
+
throw projectError(
|
|
155
|
+
"invalidRepositorySql",
|
|
156
|
+
`${file} adds a value to missing type ${name}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const value = readEnumLiteral(rest, file, name);
|
|
160
|
+
if (current.includes(value)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
enums.set(name, [...current, value]);
|
|
88
164
|
}
|
|
89
165
|
|
|
90
166
|
function createTable(
|
|
91
167
|
tables: Map<string, WorkingTable>,
|
|
168
|
+
enums: Map<string, [string, ...string[]]>,
|
|
92
169
|
file: string,
|
|
93
170
|
rest: string,
|
|
94
171
|
) {
|
|
@@ -116,7 +193,7 @@ function createTable(
|
|
|
116
193
|
if (isConstraint(part)) {
|
|
117
194
|
continue;
|
|
118
195
|
}
|
|
119
|
-
const column = readColumn(part, file, name);
|
|
196
|
+
const column = readColumn(part, file, name, enums);
|
|
120
197
|
storeColumn(table, column, file);
|
|
121
198
|
}
|
|
122
199
|
tables.set(name, table);
|
|
@@ -124,6 +201,7 @@ function createTable(
|
|
|
124
201
|
|
|
125
202
|
function addColumn(
|
|
126
203
|
tables: Map<string, WorkingTable>,
|
|
204
|
+
enums: Map<string, [string, ...string[]]>,
|
|
127
205
|
file: string,
|
|
128
206
|
tableToken: string,
|
|
129
207
|
definition: string,
|
|
@@ -136,11 +214,11 @@ function addColumn(
|
|
|
136
214
|
`${file} adds a column to missing table ${name}`,
|
|
137
215
|
);
|
|
138
216
|
}
|
|
139
|
-
const column = readColumn(definition, file, name);
|
|
217
|
+
const column = readColumn(definition, file, name, enums);
|
|
140
218
|
storeColumn(table, column, file);
|
|
141
219
|
}
|
|
142
220
|
|
|
143
|
-
function storeColumn(table: WorkingTable, column:
|
|
221
|
+
function storeColumn(table: WorkingTable, column: WorkingColumn, file: string) {
|
|
144
222
|
if (column.name in reservedTypes) {
|
|
145
223
|
if (table.reserved.has(column.name)) {
|
|
146
224
|
throw projectError(
|
|
@@ -166,7 +244,10 @@ function storeColumn(table: WorkingTable, column: ParsedColumn, file: string) {
|
|
|
166
244
|
table.columns.push(column);
|
|
167
245
|
}
|
|
168
246
|
|
|
169
|
-
function finishTable(
|
|
247
|
+
function finishTable(
|
|
248
|
+
table: WorkingTable,
|
|
249
|
+
enums: Map<string, [string, ...string[]]>,
|
|
250
|
+
): TableSchema {
|
|
170
251
|
for (const [name, type] of Object.entries(reservedTypes)) {
|
|
171
252
|
const column = table.reserved.get(name);
|
|
172
253
|
if (!column || column.nullable || column.type !== type) {
|
|
@@ -176,26 +257,78 @@ function finishTable(table: WorkingTable): TableSchema {
|
|
|
176
257
|
);
|
|
177
258
|
}
|
|
178
259
|
}
|
|
179
|
-
return {
|
|
260
|
+
return {
|
|
261
|
+
name: table.name,
|
|
262
|
+
columns: table.columns.map((column) => finishColumn(column, enums)),
|
|
263
|
+
};
|
|
180
264
|
}
|
|
181
265
|
|
|
182
|
-
function
|
|
183
|
-
|
|
184
|
-
|
|
266
|
+
function finishColumn(
|
|
267
|
+
column: WorkingColumn,
|
|
268
|
+
enums: Map<string, [string, ...string[]]>,
|
|
269
|
+
): DomainColumn {
|
|
270
|
+
if (column.type !== "enum") {
|
|
271
|
+
return column;
|
|
272
|
+
}
|
|
273
|
+
const values = enums.get(column.enumName);
|
|
274
|
+
if (!values) {
|
|
185
275
|
throw projectError(
|
|
186
276
|
"invalidRepositorySql",
|
|
187
|
-
`${
|
|
277
|
+
`${column.name} references missing type ${column.enumName}`,
|
|
188
278
|
);
|
|
189
279
|
}
|
|
190
|
-
|
|
191
|
-
|
|
280
|
+
return {
|
|
281
|
+
name: column.name,
|
|
282
|
+
type: "enum",
|
|
283
|
+
nullable: column.nullable,
|
|
284
|
+
values,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function readColumn(
|
|
289
|
+
definition: string,
|
|
290
|
+
file: string,
|
|
291
|
+
table: string,
|
|
292
|
+
enums: Map<string, [string, ...string[]]>,
|
|
293
|
+
): WorkingColumn {
|
|
294
|
+
const ident = readIdent(definition.trim());
|
|
295
|
+
if (!ident) {
|
|
192
296
|
throw projectError(
|
|
193
297
|
"invalidRepositorySql",
|
|
194
|
-
`${file} column ${table}
|
|
298
|
+
`${file} has a column in ${table} Keja cannot read`,
|
|
195
299
|
);
|
|
196
300
|
}
|
|
301
|
+
const mapped = typePatterns.find((item) => item.pattern.test(ident.rest));
|
|
197
302
|
const nullable = !/\b(?:not\s+null|primary\s+key)\b/i.test(ident.rest);
|
|
198
|
-
|
|
303
|
+
if (mapped) {
|
|
304
|
+
return { name: ident.name, type: mapped.type, nullable };
|
|
305
|
+
}
|
|
306
|
+
const enumName = readTypeReference(ident.rest);
|
|
307
|
+
if (enumName && enums.has(enumName)) {
|
|
308
|
+
return { name: ident.name, type: "enum", nullable, enumName };
|
|
309
|
+
}
|
|
310
|
+
throw projectError(
|
|
311
|
+
"invalidRepositorySql",
|
|
312
|
+
`${file} column ${table}.${ident.name} has no TypeScript type`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function readTypeReference(rest: string) {
|
|
317
|
+
const parts: string[] = [];
|
|
318
|
+
let remaining = rest.trim();
|
|
319
|
+
while (remaining !== "") {
|
|
320
|
+
const ident = readIdent(remaining);
|
|
321
|
+
if (!ident) {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
parts.push(ident.name);
|
|
325
|
+
remaining = ident.rest;
|
|
326
|
+
if (!remaining.startsWith(".")) {
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
remaining = remaining.slice(1).trim();
|
|
330
|
+
}
|
|
331
|
+
return parts.at(-1);
|
|
199
332
|
}
|
|
200
333
|
|
|
201
334
|
function tableName(token: string, file: string) {
|
|
@@ -214,6 +347,45 @@ function tableName(token: string, file: string) {
|
|
|
214
347
|
return name;
|
|
215
348
|
}
|
|
216
349
|
|
|
350
|
+
function typeName(token: string, file: string) {
|
|
351
|
+
const name = readTypeReference(token);
|
|
352
|
+
if (!name || !/^[a-z_][a-z0-9_]*$/.test(name)) {
|
|
353
|
+
throw projectError(
|
|
354
|
+
"invalidRepositorySql",
|
|
355
|
+
`${file} has a type name Keja cannot read`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return name;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function readEnumValues(body: string, file: string, type: string) {
|
|
362
|
+
const values: string[] = [];
|
|
363
|
+
for (const part of splitTopLevel(body)) {
|
|
364
|
+
if (part.trim() === "") {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
values.push(readEnumLiteral(part.trim(), file, type));
|
|
368
|
+
}
|
|
369
|
+
if (values.length === 0) {
|
|
370
|
+
throw projectError(
|
|
371
|
+
"invalidRepositorySql",
|
|
372
|
+
`${file} type ${type} has no enum values`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return values as [string, ...string[]];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function readEnumLiteral(value: string, file: string, type: string) {
|
|
379
|
+
const match = value.match(/^'((?:[^']|'')*)'(?:\s+.*)?$/s);
|
|
380
|
+
if (!match?.[1]) {
|
|
381
|
+
throw projectError(
|
|
382
|
+
"invalidRepositorySql",
|
|
383
|
+
`${file} type ${type} has an enum value Keja cannot read`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return match[1].replaceAll("''", "'");
|
|
387
|
+
}
|
|
388
|
+
|
|
217
389
|
function isConstraint(part: string) {
|
|
218
390
|
return /^(?:check|constraint|primary|unique|foreign)\b/i.test(part.trim());
|
|
219
391
|
}
|
package/src/route/define.ts
CHANGED
|
@@ -78,6 +78,7 @@ export type RouteDefinition = {
|
|
|
78
78
|
method: HttpMethod;
|
|
79
79
|
path: string;
|
|
80
80
|
version: string;
|
|
81
|
+
name: string | null;
|
|
81
82
|
request: RequestSchemas;
|
|
82
83
|
responses: RouteResponse[];
|
|
83
84
|
middleware: readonly MiddlewareDefinition<object>[];
|
|
@@ -124,6 +125,7 @@ export function defineRoute<
|
|
|
124
125
|
>(definition: {
|
|
125
126
|
method: HttpMethod;
|
|
126
127
|
path: string;
|
|
128
|
+
name?: string;
|
|
127
129
|
version: VersionId<Version> extends never
|
|
128
130
|
? "Version must use the form v1, v2, v3"
|
|
129
131
|
: Version;
|
|
@@ -150,6 +152,17 @@ export function defineRoute<
|
|
|
150
152
|
`Path ${String(definition.path)} must start with /`,
|
|
151
153
|
);
|
|
152
154
|
}
|
|
155
|
+
const name =
|
|
156
|
+
definition.name === undefined
|
|
157
|
+
? null
|
|
158
|
+
: typeof definition.name === "string" && definition.name.trim() !== ""
|
|
159
|
+
? definition.name.trim()
|
|
160
|
+
: (() => {
|
|
161
|
+
throw projectError(
|
|
162
|
+
"invalidRoute",
|
|
163
|
+
`${definition.method.toUpperCase()} ${definition.path} name must be a non-empty string`,
|
|
164
|
+
);
|
|
165
|
+
})();
|
|
153
166
|
const request = definition.request as RequestSchemas | undefined;
|
|
154
167
|
if (request) {
|
|
155
168
|
for (const key of ["params", "query", "body"] as const) {
|
|
@@ -229,6 +242,7 @@ export function defineRoute<
|
|
|
229
242
|
method: definition.method,
|
|
230
243
|
path: definition.path,
|
|
231
244
|
version: definition.version,
|
|
245
|
+
name,
|
|
232
246
|
request: definition.request ?? {},
|
|
233
247
|
responses,
|
|
234
248
|
middleware,
|
package/src/server/index.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { startArgusHeartbeat } from "../argus/heartbeat.ts";
|
|
|
4
4
|
import { argusRegistrations, publishArgus } from "../argus/register.ts";
|
|
5
5
|
import { connectDatabase } from "../db/postgres.ts";
|
|
6
6
|
import { readOpenApiServers, readRequiredEnv } from "../env/required-env.ts";
|
|
7
|
-
import { projectError } from "../errors/framework-errors.ts";
|
|
7
|
+
import { DependencyNotMet, projectError } from "../errors/framework-errors.ts";
|
|
8
|
+
import { connectGrants, setGrants } from "../grant/settings.ts";
|
|
8
9
|
import { createHttpApp } from "../http/create-app.ts";
|
|
9
10
|
import { type LoadedProject, loadProject } from "../project/load.ts";
|
|
10
11
|
import { formatSelfTest, runSelfTest } from "../self-test/run.ts";
|
|
@@ -75,6 +76,21 @@ export async function listen(
|
|
|
75
76
|
} else {
|
|
76
77
|
setVault(null);
|
|
77
78
|
}
|
|
79
|
+
if (project.grants.enabled) {
|
|
80
|
+
if (project.grants.mode === "transit" && !project.vault.enabled) {
|
|
81
|
+
throw new DependencyNotMet("grant transit", "vault");
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
project.grants.mode === "secret" &&
|
|
85
|
+
project.grants.secret_vault &&
|
|
86
|
+
!project.vault.enabled
|
|
87
|
+
) {
|
|
88
|
+
throw new DependencyNotMet("grant secret_vault", "vault");
|
|
89
|
+
}
|
|
90
|
+
await connectGrants(project.grants);
|
|
91
|
+
} else {
|
|
92
|
+
setGrants(null);
|
|
93
|
+
}
|
|
78
94
|
const mounted = mountProject(root, project, env, openApiServers);
|
|
79
95
|
const identity = mounted.identity;
|
|
80
96
|
if (project.argus.enabled) {
|
|
@@ -157,6 +173,7 @@ function mountProject(
|
|
|
157
173
|
middleware: project.publicMiddleware,
|
|
158
174
|
server: openApiServers.openapiServer,
|
|
159
175
|
identity,
|
|
176
|
+
batch: project.batch.enabled,
|
|
160
177
|
}),
|
|
161
178
|
serviceApp: createHttpApp({
|
|
162
179
|
title: "Service",
|
|
@@ -166,6 +183,7 @@ function mountProject(
|
|
|
166
183
|
server: openApiServers.openapiServiceServer,
|
|
167
184
|
identity,
|
|
168
185
|
postOnly: true,
|
|
186
|
+
batch: project.batch.enabled,
|
|
169
187
|
}),
|
|
170
188
|
};
|
|
171
189
|
}
|
package/src/vault/client.test.ts
CHANGED
|
@@ -85,9 +85,6 @@ describe("createVault", () => {
|
|
|
85
85
|
token: "s.token",
|
|
86
86
|
namespace: null,
|
|
87
87
|
mount: "secret",
|
|
88
|
-
transit_key: null,
|
|
89
|
-
transit_mount: "transit",
|
|
90
|
-
grant_max_exp: 900,
|
|
91
88
|
},
|
|
92
89
|
async (url) => {
|
|
93
90
|
urls.push(url);
|
|
@@ -111,9 +108,6 @@ describe("createVault", () => {
|
|
|
111
108
|
token: "s.token",
|
|
112
109
|
namespace: null,
|
|
113
110
|
mount: "secret",
|
|
114
|
-
transit_key: null,
|
|
115
|
-
transit_mount: "transit",
|
|
116
|
-
grant_max_exp: 900,
|
|
117
111
|
},
|
|
118
112
|
async () =>
|
|
119
113
|
Response.json({ errors: ["permission denied"] }, { status: 403 }),
|