@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.
@@ -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
- name: string;
14
- type: ColumnType;
15
- nullable: boolean;
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 { pattern: RegExp; type: ColumnType }[] = [
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 ParsedColumn = DomainColumn;
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, ParsedColumn>;
55
- columns: DomainColumn[];
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: ParsedColumn, file: string) {
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(table: WorkingTable): TableSchema {
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 { name: table.name, columns: table.columns };
260
+ return {
261
+ name: table.name,
262
+ columns: table.columns.map((column) => finishColumn(column, enums)),
263
+ };
180
264
  }
181
265
 
182
- function readColumn(definition: string, file: string, table: string) {
183
- const ident = readIdent(definition.trim());
184
- if (!ident) {
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
- `${file} has a column in ${table} Keja cannot read`,
277
+ `${column.name} references missing type ${column.enumName}`,
188
278
  );
189
279
  }
190
- const mapped = typePatterns.find((item) => item.pattern.test(ident.rest));
191
- if (!mapped) {
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}.${ident.name} has no TypeScript type`,
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
- return { name: ident.name, type: mapped.type, nullable };
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
  }
@@ -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,
@@ -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
  }
@@ -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 }),