@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.
@@ -100,12 +100,46 @@ describe("defineConfig", () => {
100
100
  token: "s.token",
101
101
  namespace: "apps",
102
102
  mount: "kv",
103
- transit_key: null,
104
- transit_mount: "transit",
105
- grant_max_exp: 900,
106
103
  });
107
104
  expect(defineConfig().vault).toEqual({ enabled: false });
108
105
  });
106
+
107
+ it("reads grants and batch settings from config", () => {
108
+ expect(
109
+ defineConfig({
110
+ grants: {
111
+ enabled: true,
112
+ mode: "transit",
113
+ transit_key: "keja-grants",
114
+ },
115
+ }).grants,
116
+ ).toEqual({
117
+ enabled: true,
118
+ mode: "transit",
119
+ max_exp: 900,
120
+ transit_key: "keja-grants",
121
+ transit_mount: "transit",
122
+ });
123
+ expect(
124
+ defineConfig({
125
+ grants: {
126
+ enabled: true,
127
+ mode: "secret",
128
+ secret: "test-secret",
129
+ },
130
+ }).grants,
131
+ ).toEqual({
132
+ enabled: true,
133
+ mode: "secret",
134
+ max_exp: 900,
135
+ secret: "test-secret",
136
+ secret_vault: null,
137
+ secret_field: "secret",
138
+ });
139
+ expect(defineConfig({ batch: false }).batch).toEqual({ enabled: false });
140
+ expect(defineConfig().grants).toEqual({ enabled: false });
141
+ expect(defineConfig().batch).toEqual({ enabled: true });
142
+ });
109
143
  });
110
144
 
111
145
  describe("loadConfig", () => {
@@ -117,6 +151,8 @@ describe("loadConfig", () => {
117
151
  argus: { enabled: false },
118
152
  database: { enabled: false },
119
153
  vault: { enabled: false },
154
+ grants: { enabled: false },
155
+ batch: { enabled: true },
120
156
  versioning: path.resolve(root, "keja.versioning.ts"),
121
157
  public: path.resolve(root, "src/public"),
122
158
  service: path.resolve(root, "src/service"),
@@ -49,9 +49,6 @@ export type VaultSettings = {
49
49
  token: string;
50
50
  namespace: string | null;
51
51
  mount: string;
52
- transit_key: string | null;
53
- transit_mount: string;
54
- grant_max_exp: number;
55
52
  };
56
53
 
57
54
  export type VaultConfigInput = {
@@ -60,15 +57,45 @@ export type VaultConfigInput = {
60
57
  token?: string;
61
58
  namespace?: string;
62
59
  mount?: string;
63
- transit_key?: string;
64
- transit_mount?: string;
65
- grant_max_exp?: number;
66
60
  };
67
61
 
68
62
  export type VaultConfig =
69
63
  | { enabled: false }
70
64
  | ({ enabled: true } & VaultSettings);
71
65
 
66
+ export type GrantsConfigInput = {
67
+ enabled: boolean;
68
+ mode?: "transit" | "secret";
69
+ max_exp?: number;
70
+ transit_key?: string;
71
+ transit_mount?: string;
72
+ secret?: string;
73
+ secret_vault?: string;
74
+ secret_field?: string;
75
+ };
76
+
77
+ export type GrantsConfig =
78
+ | { enabled: false }
79
+ | {
80
+ enabled: true;
81
+ mode: "transit";
82
+ max_exp: number;
83
+ transit_key: string;
84
+ transit_mount: string;
85
+ }
86
+ | {
87
+ enabled: true;
88
+ mode: "secret";
89
+ max_exp: number;
90
+ secret: string | null;
91
+ secret_vault: string | null;
92
+ secret_field: string;
93
+ };
94
+
95
+ export type BatchConfigInput = boolean | { enabled: boolean };
96
+
97
+ export type BatchConfig = { enabled: boolean };
98
+
72
99
  export type KejaConfigInput = {
73
100
  name?: string;
74
101
  versioning?: string;
@@ -77,6 +104,8 @@ export type KejaConfigInput = {
77
104
  argus?: ArgusConfigInput;
78
105
  db?: DatabaseConfigInput;
79
106
  vault?: VaultConfigInput;
107
+ grants?: GrantsConfigInput;
108
+ batch?: BatchConfigInput;
80
109
  };
81
110
 
82
111
  export type KejaConfig = {
@@ -87,6 +116,8 @@ export type KejaConfig = {
87
116
  argus: ArgusConfig;
88
117
  database: DatabaseConfig;
89
118
  vault: VaultConfig;
119
+ grants: GrantsConfig;
120
+ batch: BatchConfig;
90
121
  };
91
122
 
92
123
  export function defineConfig(input: KejaConfigInput = {}) {
@@ -110,6 +141,8 @@ export function defineConfig(input: KejaConfigInput = {}) {
110
141
  argus: configArgus(input.argus),
111
142
  database: configDatabase(input.db),
112
143
  vault: configVault(input.vault),
144
+ grants: configGrants(input.grants),
145
+ batch: configBatch(input.batch),
113
146
  };
114
147
  return Object.assign(config, { [configBrand]: true as const });
115
148
  }
@@ -126,7 +159,9 @@ export function isConfig(value: unknown): value is KejaConfig {
126
159
  hasName(value) &&
127
160
  hasArgus(value) &&
128
161
  hasDatabase(value) &&
129
- hasVault(value)
162
+ hasVault(value) &&
163
+ hasGrants(value) &&
164
+ hasBatch(value)
130
165
  );
131
166
  }
132
167
 
@@ -142,6 +177,8 @@ export type LoadedConfig = ProjectPaths & {
142
177
  argus: ArgusConfig;
143
178
  database: DatabaseConfig;
144
179
  vault: VaultConfig;
180
+ grants: GrantsConfig;
181
+ batch: BatchConfig;
145
182
  };
146
183
 
147
184
  export function resolveProjectPaths(root: string, config: KejaConfig) {
@@ -198,6 +235,8 @@ function loadedConfig(
198
235
  argus: config.argus,
199
236
  database: resolvedDatabase(root, config.database),
200
237
  vault: config.vault,
238
+ grants: config.grants,
239
+ batch: config.batch,
201
240
  };
202
241
  }
203
242
 
@@ -295,18 +334,90 @@ function configVault(value: VaultConfigInput | undefined): VaultConfig {
295
334
  ),
296
335
  namespace: optionalText(namespace, "vault.namespace", "namespace"),
297
336
  mount: vaultMount(value.mount ?? configEnv("VAULT_KV_MOUNT") ?? "secret"),
298
- transit_key:
299
- value.transit_key === undefined
300
- ? null
301
- : vaultName(value.transit_key, "vault.transit_key"),
302
- transit_mount: vaultName(
303
- value.transit_mount ?? "transit",
304
- "vault.transit_mount",
337
+ };
338
+ }
339
+
340
+ function configGrants(value: GrantsConfigInput | undefined): GrantsConfig {
341
+ if (value === undefined) {
342
+ return { enabled: false };
343
+ }
344
+ if (!isEnabledObject(value)) {
345
+ throw projectError(
346
+ "invalidConfigFile",
347
+ "keja.config.ts grants must be an object with enabled",
348
+ );
349
+ }
350
+ if (!value.enabled) {
351
+ return { enabled: false };
352
+ }
353
+ const mode = value.mode ?? "transit";
354
+ if (mode !== "transit" && mode !== "secret") {
355
+ throw projectError(
356
+ "invalidConfigFile",
357
+ "keja.config.ts grants.mode must be transit or secret",
358
+ );
359
+ }
360
+ const max_exp = grantMaxExp(value.max_exp);
361
+ if (mode === "transit") {
362
+ return {
363
+ enabled: true,
364
+ mode: "transit",
365
+ max_exp,
366
+ transit_key: vaultName(
367
+ requiredText(value.transit_key, "grants.transit_key", "key name"),
368
+ "grants.transit_key",
369
+ ),
370
+ transit_mount: vaultName(
371
+ value.transit_mount ?? "transit",
372
+ "grants.transit_mount",
373
+ ),
374
+ };
375
+ }
376
+ const secret =
377
+ value.secret === undefined
378
+ ? (configEnv("GRANT_SECRET") ?? null)
379
+ : requiredText(value.secret, "grants.secret", "secret");
380
+ const secret_vault =
381
+ value.secret_vault === undefined
382
+ ? null
383
+ : secretVaultPath(
384
+ requiredText(value.secret_vault, "grants.secret_vault", "path"),
385
+ );
386
+ if (secret === null && secret_vault === null) {
387
+ throw projectError(
388
+ "invalidConfigFile",
389
+ "keja.config.ts grants.secret, GRANT_SECRET, or grants.secret_vault is required",
390
+ );
391
+ }
392
+ return {
393
+ enabled: true,
394
+ mode: "secret",
395
+ max_exp,
396
+ secret,
397
+ secret_vault,
398
+ secret_field: vaultName(
399
+ value.secret_field ?? "secret",
400
+ "grants.secret_field",
305
401
  ),
306
- grant_max_exp: grantMaxExp(value.grant_max_exp),
307
402
  };
308
403
  }
309
404
 
405
+ function configBatch(value: BatchConfigInput | undefined): BatchConfig {
406
+ if (value === undefined) {
407
+ return { enabled: true };
408
+ }
409
+ if (typeof value === "boolean") {
410
+ return { enabled: value };
411
+ }
412
+ if (!isEnabledObject(value)) {
413
+ throw projectError(
414
+ "invalidConfigFile",
415
+ "keja.config.ts batch must be a boolean or an object with enabled",
416
+ );
417
+ }
418
+ return { enabled: value.enabled };
419
+ }
420
+
310
421
  function isEnabledObject(value: unknown): value is { enabled: boolean } {
311
422
  return (
312
423
  typeof value === "object" &&
@@ -354,6 +465,21 @@ function vaultName(value: string, field: string) {
354
465
  return name;
355
466
  }
356
467
 
468
+ function secretVaultPath(value: string) {
469
+ const cleaned = value.trim().replace(/^\/+|\/+$/g, "");
470
+ const parts = cleaned.split("/");
471
+ if (
472
+ !cleaned ||
473
+ parts.some((part) => part.length === 0 || part === "." || part === "..")
474
+ ) {
475
+ throw projectError(
476
+ "invalidConfigFile",
477
+ "keja.config.ts grants.secret_vault must be a Vault secret path",
478
+ );
479
+ }
480
+ return cleaned;
481
+ }
482
+
357
483
  function grantMaxExp(value: number | undefined) {
358
484
  if (value === undefined) {
359
485
  return 900;
@@ -366,7 +492,7 @@ function grantMaxExp(value: number | undefined) {
366
492
  ) {
367
493
  throw projectError(
368
494
  "invalidConfigFile",
369
- "keja.config.ts vault.grant_max_exp must be a whole number of seconds from 1 to 86400",
495
+ "keja.config.ts grants.max_exp must be a whole number of seconds from 1 to 86400",
370
496
  );
371
497
  }
372
498
  return value;
@@ -435,6 +561,27 @@ function hasVault(value: object) {
435
561
  );
436
562
  }
437
563
 
564
+ function hasGrants(value: object) {
565
+ const grants = (value as KejaConfig).grants;
566
+ return (
567
+ typeof grants === "object" &&
568
+ grants !== null &&
569
+ (grants.enabled === false ||
570
+ (grants.enabled === true &&
571
+ (grants.mode === "transit" || grants.mode === "secret") &&
572
+ typeof grants.max_exp === "number"))
573
+ );
574
+ }
575
+
576
+ function hasBatch(value: object) {
577
+ const batch = (value as KejaConfig).batch;
578
+ return (
579
+ typeof batch === "object" &&
580
+ batch !== null &&
581
+ typeof batch.enabled === "boolean"
582
+ );
583
+ }
584
+
438
585
  function hasDatabase(value: object) {
439
586
  const database = (value as KejaConfig).database;
440
587
  return (
@@ -11,7 +11,9 @@ import {
11
11
  import { isVersions, type VersionDefinition } from "../versioning/define.ts";
12
12
  import {
13
13
  type ArgusConfig,
14
+ type BatchConfig,
14
15
  type DatabaseConfig,
16
+ type GrantsConfig,
15
17
  loadConfig,
16
18
  type ProjectPaths,
17
19
  type VaultConfig,
@@ -23,6 +25,8 @@ export type LoadedProject = {
23
25
  name: string | null;
24
26
  argus: ArgusConfig;
25
27
  vault: VaultConfig;
28
+ grants: GrantsConfig;
29
+ batch: BatchConfig;
26
30
  database: DatabaseConfig;
27
31
  versions: VersionDefinition[];
28
32
  publicRoutes: RouteDefinition[];
@@ -58,6 +62,8 @@ export async function loadProject(
58
62
  name: loaded.name,
59
63
  argus: loaded.argus,
60
64
  vault: loaded.vault,
65
+ grants: loaded.grants,
66
+ batch: loaded.batch,
61
67
  database: loaded.database,
62
68
  versions: versioning.default.versions,
63
69
  publicRoutes: await loadRoutes(publicDirectory, generation),
@@ -55,4 +55,20 @@ describe("renderRepositories", () => {
55
55
  );
56
56
  expect(source).toContain('list: { filters: ["name"], limit: 20 },');
57
57
  });
58
+
59
+ it("writes enum columns with their values", () => {
60
+ const tables = readTables([
61
+ {
62
+ file: "0001_example.sql",
63
+ sql: `
64
+ create type post_status as enum ('draft', 'published');
65
+ create table posts (${owned}, status post_status not null);
66
+ `,
67
+ },
68
+ ]);
69
+ const source = renderRepositories(tables, new Map());
70
+ expect(source).toContain(
71
+ 'status: { type: "enum", values: ["draft", "published"], nullable: false },',
72
+ );
73
+ });
58
74
  });
@@ -82,10 +82,7 @@ function renderRepository(
82
82
  flags: { list: ResolvedList; get: boolean } | undefined,
83
83
  ) {
84
84
  const columns = table.columns
85
- .map(
86
- (column) =>
87
- ` ${column.name}: { type: "${column.type}", nullable: ${column.nullable} },`,
88
- )
85
+ .map((column) => renderColumn(column))
89
86
  .join("\n");
90
87
  const options = [
91
88
  renderList(flags?.list),
@@ -115,3 +112,13 @@ function renderList(list: ResolvedList | undefined) {
115
112
  ].filter((part) => part !== "");
116
113
  return ` list: { ${parts.join(", ")} },`;
117
114
  }
115
+
116
+ function renderColumn(column: TableSchema["columns"][number]) {
117
+ if (column.type === "enum") {
118
+ const values = column.values
119
+ .map((value) => JSON.stringify(value))
120
+ .join(", ");
121
+ return ` ${column.name}: { type: "enum", values: [${values}], nullable: ${column.nullable} },`;
122
+ }
123
+ return ` ${column.name}: { type: "${column.type}", nullable: ${column.nullable} },`;
124
+ }
@@ -245,6 +245,11 @@ function parseValue(column: DomainColumn, value: string) {
245
245
  }
246
246
  return value;
247
247
  }
248
+ if (column.type === "enum" && !column.values.includes(value)) {
249
+ throw new Error(
250
+ `${column.name} filter must be ${column.values.join(", ")}`,
251
+ );
252
+ }
248
253
  return value;
249
254
  }
250
255
 
@@ -29,6 +29,23 @@ describe("repository", () => {
29
29
  }
30
30
  });
31
31
 
32
+ it("validates enum columns against declared values", () => {
33
+ const posts = repository({
34
+ table: "posts",
35
+ columns: {
36
+ status: {
37
+ type: "enum",
38
+ values: ["draft", "published"],
39
+ nullable: false,
40
+ },
41
+ },
42
+ });
43
+ expect(posts.schema.parse({ status: "draft" })).toEqual({
44
+ status: "draft",
45
+ });
46
+ expect(() => posts.schema.parse({ status: "archived" })).toThrow();
47
+ });
48
+
32
49
  it("exposes a row schema with and without meta", () => {
33
50
  const users = repository({ table: "users", columns });
34
51
  const at = new Date("2026-01-15T00:00:00.000Z");
@@ -38,6 +55,7 @@ describe("repository", () => {
38
55
  name: "Ada",
39
56
  email: "ada@example.com",
40
57
  });
58
+
41
59
  expect(
42
60
  users.meta.parse({
43
61
  ...row("users_a", "Ada"),
@@ -10,31 +10,42 @@ import {
10
10
  } from "./query.ts";
11
11
  import type { ColumnType, DomainColumn } from "./sql.ts";
12
12
 
13
- export type ColumnSpec = {
14
- readonly type: ColumnType;
15
- readonly nullable: boolean;
16
- };
13
+ export type ColumnSpec =
14
+ | {
15
+ readonly type: Exclude<ColumnType, "enum">;
16
+ readonly nullable: boolean;
17
+ }
18
+ | {
19
+ readonly type: "enum";
20
+ readonly values: readonly [string, ...string[]];
21
+ readonly nullable: boolean;
22
+ };
17
23
 
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"
24
+ type SpecValue<Spec extends ColumnSpec> = Spec extends {
25
+ type: "enum";
26
+ values: readonly (infer Value)[];
27
+ }
28
+ ? Value
29
+ : Spec["type"] extends "text"
30
+ ? string
31
+ : Spec["type"] extends "boolean"
32
+ ? boolean
33
+ : Spec["type"] extends "timestamptz"
34
+ ? Date
35
+ : Spec["type"] extends "integer"
36
+ ? number
37
+ : Spec["type"] extends "bigint"
29
38
  ? string
30
- : Type extends "jsonb"
31
- ? unknown
32
- : never;
39
+ : Spec["type"] extends "numeric"
40
+ ? string
41
+ : Spec["type"] extends "jsonb"
42
+ ? unknown
43
+ : never;
33
44
 
34
45
  export type DomainRow<Columns extends Record<string, ColumnSpec>> = {
35
46
  [Key in keyof Columns]: Columns[Key]["nullable"] extends true
36
- ? TsValue<Columns[Key]["type"]> | null
37
- : TsValue<Columns[Key]["type"]>;
47
+ ? SpecValue<Columns[Key]> | null
48
+ : SpecValue<Columns[Key]>;
38
49
  };
39
50
 
40
51
  export type RepositoryRow<Columns extends Record<string, ColumnSpec>> = {
@@ -133,11 +144,21 @@ export function repository<
133
144
  }): Repository<Columns> &
134
145
  (List extends false ? object : { list: ListProcessor<Columns, List> }) &
135
146
  (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
- }));
147
+ const columns = Object.entries(input.columns).map(
148
+ ([name, column]): DomainColumn =>
149
+ column.type === "enum"
150
+ ? {
151
+ name,
152
+ type: "enum",
153
+ nullable: column.nullable,
154
+ values: column.values,
155
+ }
156
+ : {
157
+ name,
158
+ type: column.type,
159
+ nullable: column.nullable,
160
+ },
161
+ );
141
162
  const shape = rowSchemas(columns);
142
163
  const base: Repository<Columns> = {
143
164
  table: input.table,
@@ -240,7 +261,7 @@ function listFilters(
240
261
  function rowSchemas(columns: readonly DomainColumn[]) {
241
262
  const domain: Record<string, z.ZodType> = {};
242
263
  for (const column of columns) {
243
- domain[column.name] = columnSchema(column.type, column.nullable);
264
+ domain[column.name] = columnSchema(column);
244
265
  }
245
266
  return {
246
267
  schema: z.object(domain),
@@ -256,20 +277,22 @@ function rowSchemas(columns: readonly DomainColumn[]) {
256
277
  };
257
278
  }
258
279
 
259
- function columnSchema(type: ColumnType, nullable: boolean) {
280
+ function columnSchema(column: DomainColumn) {
260
281
  const schema =
261
- type === "boolean"
282
+ column.type === "boolean"
262
283
  ? z.boolean()
263
- : type === "timestamptz"
284
+ : column.type === "timestamptz"
264
285
  ? z.date()
265
- : type === "integer"
286
+ : column.type === "integer"
266
287
  ? z.number().int()
267
- : type === "bigint" || type === "numeric"
288
+ : column.type === "bigint" || column.type === "numeric"
268
289
  ? z.string()
269
- : type === "jsonb"
290
+ : column.type === "jsonb"
270
291
  ? z.unknown()
271
- : z.string();
272
- return nullable ? schema.nullable() : schema;
292
+ : column.type === "enum"
293
+ ? z.enum(column.values)
294
+ : z.string();
295
+ return column.nullable ? schema.nullable() : schema;
273
296
  }
274
297
 
275
298
  function builtQuerySort(sort: string | undefined) {
@@ -75,4 +75,37 @@ describe("readTables", () => {
75
75
  ]),
76
76
  ).toThrow("users.photo has no TypeScript type");
77
77
  });
78
+
79
+ it("reads create type enums and columns that use them", () => {
80
+ const tables = readTables([
81
+ {
82
+ file: "0001_status.sql",
83
+ sql: `
84
+ create type post_status as enum ('draft', 'published');
85
+ create table posts (${owned}, status post_status not null);
86
+ alter type post_status add value 'archived';
87
+ alter table posts add column visibility post_status;
88
+ `,
89
+ },
90
+ ]);
91
+ expect(tables).toEqual([
92
+ {
93
+ name: "posts",
94
+ columns: [
95
+ {
96
+ name: "status",
97
+ type: "enum",
98
+ nullable: false,
99
+ values: ["draft", "published", "archived"],
100
+ },
101
+ {
102
+ name: "visibility",
103
+ type: "enum",
104
+ nullable: true,
105
+ values: ["draft", "published", "archived"],
106
+ },
107
+ ],
108
+ },
109
+ ]);
110
+ });
78
111
  });