@veducio/keja 0.0.0-stage → 0.3.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.
Files changed (96) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +39 -2
  3. package/examples/starter/.env.example +6 -0
  4. package/examples/starter/keja.config.ts +7 -0
  5. package/examples/starter/keja.versioning.ts +5 -0
  6. package/examples/starter/migrations/0001_init.sql +1 -0
  7. package/examples/starter/package.json +14 -0
  8. package/examples/starter/tsconfig.json +9 -0
  9. package/package.json +62 -4
  10. package/src/access/cloudflare.test.ts +131 -0
  11. package/src/access/cloudflare.ts +193 -0
  12. package/src/argus/call.test.ts +98 -0
  13. package/src/argus/call.ts +153 -0
  14. package/src/argus/heartbeat.ts +65 -0
  15. package/src/argus/register.test.ts +120 -0
  16. package/src/argus/register.ts +145 -0
  17. package/src/cli/banner.test.ts +98 -0
  18. package/src/cli/banner.ts +209 -0
  19. package/src/cli/commands/build.ts +12 -0
  20. package/src/cli/commands/dev.ts +12 -0
  21. package/src/cli/commands/generate.ts +12 -0
  22. package/src/cli/commands/init.ts +57 -0
  23. package/src/cli/commands/migrate.ts +12 -0
  24. package/src/cli/commands/seed.ts +12 -0
  25. package/src/cli/commands/start.ts +12 -0
  26. package/src/cli/dev-reload.test.ts +87 -0
  27. package/src/cli/dev-reload.ts +182 -0
  28. package/src/cli/generate-project.ts +39 -0
  29. package/src/cli/index.ts +29 -0
  30. package/src/cli/init-project.test.ts +312 -0
  31. package/src/cli/init-project.ts +416 -0
  32. package/src/cli/migrate-project.ts +31 -0
  33. package/src/cli/run-project.test.ts +50 -0
  34. package/src/cli/run-project.ts +93 -0
  35. package/src/cli/seed-project.ts +46 -0
  36. package/src/db/client.test.ts +49 -0
  37. package/src/db/client.ts +61 -0
  38. package/src/db/migrate.test.ts +141 -0
  39. package/src/db/migrate.ts +146 -0
  40. package/src/db/owned-sql.ts +202 -0
  41. package/src/db/postgres.ts +90 -0
  42. package/src/env/required-env.test.ts +64 -0
  43. package/src/env/required-env.ts +120 -0
  44. package/src/errors/framework-errors.ts +295 -0
  45. package/src/grant/grant.test.ts +130 -0
  46. package/src/grant/grant.ts +268 -0
  47. package/src/http/create-app.test.ts +340 -0
  48. package/src/http/create-app.ts +196 -0
  49. package/src/http/openapi.ts +111 -0
  50. package/src/process/slot.ts +18 -0
  51. package/src/project/config.test.ts +143 -0
  52. package/src/project/config.ts +450 -0
  53. package/src/project/load.test.ts +60 -0
  54. package/src/project/load.ts +158 -0
  55. package/src/project/reload-graph.test.ts +52 -0
  56. package/src/project/reload-graph.ts +175 -0
  57. package/src/repository/define.ts +123 -0
  58. package/src/repository/generate.test.ts +58 -0
  59. package/src/repository/generate.ts +117 -0
  60. package/src/repository/query.test.ts +99 -0
  61. package/src/repository/query.ts +287 -0
  62. package/src/repository/runtime.test.ts +154 -0
  63. package/src/repository/runtime.ts +434 -0
  64. package/src/repository/sql.test.ts +78 -0
  65. package/src/repository/sql.ts +282 -0
  66. package/src/route/define.test.ts +65 -0
  67. package/src/route/define.ts +307 -0
  68. package/src/route/middleware.test.ts +63 -0
  69. package/src/route/middleware.ts +144 -0
  70. package/src/seed/apply.test.ts +65 -0
  71. package/src/seed/apply.ts +11 -0
  72. package/src/seed/define.test.ts +51 -0
  73. package/src/seed/define.ts +55 -0
  74. package/src/seed/load.test.ts +54 -0
  75. package/src/seed/load.ts +67 -0
  76. package/src/seed/report.test.ts +16 -0
  77. package/src/seed/report.ts +15 -0
  78. package/src/self-test/checks.ts +88 -0
  79. package/src/self-test/run.test.ts +128 -0
  80. package/src/self-test/run.ts +105 -0
  81. package/src/server/identity.test.ts +57 -0
  82. package/src/server/identity.ts +116 -0
  83. package/src/server/index.test.ts +15 -0
  84. package/src/server/index.ts +171 -0
  85. package/src/server/swap.test.ts +89 -0
  86. package/src/server/swap.ts +29 -0
  87. package/src/typeid/id.test.ts +46 -0
  88. package/src/typeid/id.ts +136 -0
  89. package/src/typeid/owned.test.ts +73 -0
  90. package/src/typeid/owned.ts +193 -0
  91. package/src/vault/client.test.ts +128 -0
  92. package/src/vault/client.ts +282 -0
  93. package/src/versioning/define.test.ts +79 -0
  94. package/src/versioning/define.ts +173 -0
  95. package/src/versioning/select.test.ts +79 -0
  96. package/src/versioning/select.ts +62 -0
@@ -0,0 +1,287 @@
1
+ import type { DomainColumn } from "./sql.ts";
2
+
3
+ const pageDefault = 50;
4
+ const pageMax = 100;
5
+
6
+ export type ListInput = {
7
+ q?: string;
8
+ sort?: string;
9
+ cursor?: string;
10
+ limit?: number;
11
+ archived?: "true" | "false";
12
+ [column: string]: string | number | undefined;
13
+ };
14
+
15
+ export type Sort = {
16
+ column: string;
17
+ direction: "asc" | "desc";
18
+ };
19
+
20
+ type Cursor = {
21
+ column: string;
22
+ direction: "asc" | "desc";
23
+ value: string | null;
24
+ id: string;
25
+ };
26
+
27
+ export function sortableColumns(columns: readonly DomainColumn[]) {
28
+ return [
29
+ "_id",
30
+ ...columns
31
+ .filter((column) => column.type !== "jsonb" && !column.nullable)
32
+ .map((column) => column.name),
33
+ ];
34
+ }
35
+
36
+ export function filterColumns(columns: readonly DomainColumn[]) {
37
+ return columns.filter((column) => column.type !== "jsonb");
38
+ }
39
+
40
+ export function searchColumns(columns: readonly DomainColumn[]) {
41
+ return columns.filter((column) => column.type === "text");
42
+ }
43
+
44
+ export function buildListSql(input: {
45
+ table: string;
46
+ columns: readonly DomainColumn[];
47
+ query: ListInput;
48
+ tenant: string;
49
+ filters?: readonly string[];
50
+ limitDefault?: number;
51
+ where?: Record<string, unknown>;
52
+ includeArchived?: boolean;
53
+ }) {
54
+ const sort = parseSort(input.query.sort, sortableColumns(input.columns));
55
+ const limit = pageLimit(input.query.limit, input.limitDefault);
56
+ const values: unknown[] = [input.tenant];
57
+ const where = [`${quote("_tenant")} = $1`];
58
+ if (!input.includeArchived) {
59
+ values.push(input.query.archived === "true");
60
+ where.push(`${quote("_archived")} = $${values.length}`);
61
+ }
62
+ for (const [name, value] of Object.entries(input.where ?? {})) {
63
+ if (value === undefined) {
64
+ continue;
65
+ }
66
+ const column = input.columns.find((item) => item.name === name);
67
+ if (!column || column.type === "jsonb") {
68
+ throw new Error(`invalid where ${name}`);
69
+ }
70
+ if (value === null) {
71
+ where.push(`${quote(name)} is null`);
72
+ continue;
73
+ }
74
+ values.push(value);
75
+ where.push(`${quote(name)} = $${values.length}`);
76
+ }
77
+ const filters = input.filters
78
+ ? filterColumns(input.columns).filter((column) =>
79
+ input.filters?.includes(column.name),
80
+ )
81
+ : filterColumns(input.columns);
82
+ for (const column of filters) {
83
+ const raw = (input.query as Record<string, unknown>)[column.name];
84
+ if (typeof raw !== "string" || raw === "") {
85
+ continue;
86
+ }
87
+ values.push(parseValue(column, raw));
88
+ where.push(`${quote(column.name)} = $${values.length}`);
89
+ }
90
+ const pattern = likePattern(input.query.q);
91
+ const searchable = input.filters
92
+ ? searchColumns(input.columns).filter((column) =>
93
+ input.filters?.includes(column.name),
94
+ )
95
+ : searchColumns(input.columns);
96
+ if (pattern) {
97
+ const matches = searchable.map((column) => {
98
+ values.push(pattern);
99
+ return `${quote(column.name)} ilike $${values.length} escape '\\'`;
100
+ });
101
+ if (matches.length > 0) {
102
+ where.push(`(${matches.join(" or ")})`);
103
+ }
104
+ }
105
+ const cursor = input.query.cursor
106
+ ? decodeCursor(input.query.cursor, sort)
107
+ : null;
108
+ if (cursor) {
109
+ where.push(cursorPredicate(input.columns, cursor, values));
110
+ }
111
+ values.push(limit + 1);
112
+ const order =
113
+ sort.column === "_id"
114
+ ? `${quote("_id")} ${sort.direction}`
115
+ : `${quote(sort.column)} ${sort.direction}, ${quote("_id")} ${sort.direction}`;
116
+ return {
117
+ text: `select ${selectList(input.columns)} from ${quote(input.table)} where ${where.join(" and ")} order by ${order} limit $${values.length}`,
118
+ values,
119
+ limit,
120
+ };
121
+ }
122
+
123
+ export function encodeCursor(cursor: Cursor) {
124
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
125
+ }
126
+
127
+ export function cursorFromRow(row: Record<string, unknown>, sort: Sort) {
128
+ const value = sort.column === "_id" ? row._id : row[sort.column];
129
+ return encodeCursor({
130
+ column: sort.column,
131
+ direction: sort.direction,
132
+ value: serialize(value),
133
+ id: String(row._id),
134
+ });
135
+ }
136
+
137
+ export function quote(name: string) {
138
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
139
+ throw new Error(`invalid column ${name}`);
140
+ }
141
+ return `"${name}"`;
142
+ }
143
+
144
+ function parseSort(sort: string | undefined, allowed: readonly string[]): Sort {
145
+ const raw = sort && sort !== "" ? sort : "_id";
146
+ const direction = raw.startsWith("-") ? "desc" : "asc";
147
+ const column = raw.startsWith("-") ? raw.slice(1) : raw;
148
+ if (!allowed.includes(column)) {
149
+ throw new Error(`invalid sort ${raw}`);
150
+ }
151
+ return { column, direction };
152
+ }
153
+
154
+ function pageLimit(limit: number | undefined, fallback = pageDefault) {
155
+ const chosen = limit === undefined ? fallback : limit;
156
+ if (!Number.isInteger(chosen) || chosen < 1 || chosen > pageMax) {
157
+ throw new Error("limit must be from 1 to 100");
158
+ }
159
+ return chosen;
160
+ }
161
+
162
+ function cursorPredicate(
163
+ columns: readonly DomainColumn[],
164
+ cursor: Cursor,
165
+ values: unknown[],
166
+ ) {
167
+ if (cursor.column === "_id") {
168
+ values.push(cursor.id);
169
+ const operator = cursor.direction === "asc" ? ">" : "<";
170
+ return `${quote("_id")} ${operator} $${values.length}`;
171
+ }
172
+ const column = columns.find((item) => item.name === cursor.column);
173
+ if (!column) {
174
+ throw new Error("invalid cursor");
175
+ }
176
+ const operator = cursor.direction === "asc" ? ">" : "<";
177
+ if (cursor.value === null) {
178
+ values.push(cursor.id);
179
+ const id = values.length;
180
+ return cursor.direction === "asc"
181
+ ? `(${quote(cursor.column)} is null and ${quote("_id")} > $${id})`
182
+ : `(${quote(cursor.column)} is not null or (${quote(cursor.column)} is null and ${quote("_id")} < $${id}))`;
183
+ }
184
+ values.push(parseValue(column, cursor.value));
185
+ const compared = values.length;
186
+ values.push(cursor.id);
187
+ const id = values.length;
188
+ const tied = `(${quote(cursor.column)} = $${compared} and ${quote("_id")} ${operator} $${id})`;
189
+ const moved = `${quote(cursor.column)} ${operator} $${compared}`;
190
+ if (cursor.direction === "asc") {
191
+ return `(${moved} or ${tied} or ${quote(cursor.column)} is null)`;
192
+ }
193
+ return `(${moved} or ${tied})`;
194
+ }
195
+
196
+ function decodeCursor(value: string, sort: Sort): Cursor {
197
+ try {
198
+ const parsed = JSON.parse(
199
+ Buffer.from(value, "base64url").toString("utf8"),
200
+ ) as Partial<Cursor>;
201
+ if (
202
+ parsed.column !== sort.column ||
203
+ parsed.direction !== sort.direction ||
204
+ typeof parsed.id !== "string" ||
205
+ (parsed.value !== null && typeof parsed.value !== "string")
206
+ ) {
207
+ throw new Error("invalid cursor");
208
+ }
209
+ return {
210
+ column: parsed.column,
211
+ direction: parsed.direction,
212
+ value: parsed.value,
213
+ id: parsed.id,
214
+ };
215
+ } catch (error) {
216
+ if (error instanceof Error && error.message === "invalid cursor") {
217
+ throw error;
218
+ }
219
+ throw new Error("invalid cursor");
220
+ }
221
+ }
222
+
223
+ function parseValue(column: DomainColumn, value: string) {
224
+ if (column.type === "boolean") {
225
+ if (value !== "true" && value !== "false") {
226
+ throw new Error(`${column.name} filter must be true or false`);
227
+ }
228
+ return value === "true";
229
+ }
230
+ if (column.type === "integer") {
231
+ if (!/^-?\d+$/.test(value)) {
232
+ throw new Error(`${column.name} filter must be an integer`);
233
+ }
234
+ return Number(value);
235
+ }
236
+ if (column.type === "bigint") {
237
+ if (!/^-?\d+$/.test(value)) {
238
+ throw new Error(`${column.name} filter must be an integer`);
239
+ }
240
+ return value;
241
+ }
242
+ if (column.type === "numeric") {
243
+ if (!/^-?\d+(?:\.\d+)?$/.test(value)) {
244
+ throw new Error(`${column.name} filter must be a number`);
245
+ }
246
+ return value;
247
+ }
248
+ return value;
249
+ }
250
+
251
+ function likePattern(value: string | undefined) {
252
+ if (!value) {
253
+ return null;
254
+ }
255
+ const escaped = value
256
+ .replaceAll("\\", "\\\\")
257
+ .replaceAll("%", "\\%")
258
+ .replaceAll("_", "\\_");
259
+ return `%${escaped}%`;
260
+ }
261
+
262
+ function serialize(value: unknown) {
263
+ if (value === null || value === undefined) {
264
+ return null;
265
+ }
266
+ if (value instanceof Date) {
267
+ return value.toISOString();
268
+ }
269
+ if (typeof value === "boolean") {
270
+ return value ? "true" : "false";
271
+ }
272
+ return String(value);
273
+ }
274
+
275
+ function selectList(columns: readonly DomainColumn[]) {
276
+ return [
277
+ "_id",
278
+ "_object",
279
+ "_tenant",
280
+ "_archived",
281
+ "_createdAt",
282
+ "_updatedAt",
283
+ ...columns.map((column) => column.name),
284
+ ]
285
+ .map((name) => quote(name))
286
+ .join(", ");
287
+ }
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { setDatabase } from "../db/client.ts";
3
+ import { createTypeId } from "../typeid/id.ts";
4
+ import { repository } from "./runtime.ts";
5
+
6
+ const columns = {
7
+ name: { type: "text", nullable: false },
8
+ email: { type: "text", nullable: false },
9
+ } as const;
10
+
11
+ describe("repository", () => {
12
+ it("returns the next cursor when another row remains", async () => {
13
+ const calls: { statement: string; values: unknown[] }[] = [];
14
+ setDatabase({
15
+ unsafe(statement: string, values: unknown[]) {
16
+ calls.push({ statement, values });
17
+ return [row("users_a", "Ada"), row("users_b", "Alan")];
18
+ },
19
+ } as never);
20
+ try {
21
+ const users = repository({ table: "users", columns, list: true });
22
+ const page = await users.list({ limit: 1 }, { tenant: "tenant_1" });
23
+ expect(page.items.map((item) => item.name)).toEqual(["Ada"]);
24
+ expect(page.nextCursor).toEqual(expect.any(String));
25
+ expect(calls[0]?.statement).toContain(`"_archived" = $2`);
26
+ expect(calls[0]?.values[1]).toBe(false);
27
+ } finally {
28
+ setDatabase(null);
29
+ }
30
+ });
31
+
32
+ it("exposes a row schema with and without meta", () => {
33
+ const users = repository({ table: "users", columns });
34
+ const at = new Date("2026-01-15T00:00:00.000Z");
35
+ expect(
36
+ users.schema.parse({ name: "Ada", email: "ada@example.com" }),
37
+ ).toEqual({
38
+ name: "Ada",
39
+ email: "ada@example.com",
40
+ });
41
+ expect(
42
+ users.meta.parse({
43
+ ...row("users_a", "Ada"),
44
+ _createdAt: at,
45
+ _updatedAt: at,
46
+ })._tenant,
47
+ ).toBe("tenant_1");
48
+ });
49
+
50
+ it("keeps query filters to the allowlist and adds where checks", async () => {
51
+ const calls: { statement: string; values: unknown[] }[] = [];
52
+ setDatabase({
53
+ unsafe(statement: string, values: unknown[]) {
54
+ calls.push({ statement, values });
55
+ return [row("users_a", "Ada")];
56
+ },
57
+ } as never);
58
+ try {
59
+ const users = repository({
60
+ table: "users",
61
+ columns,
62
+ list: { filters: ["name"], limit: 10 },
63
+ });
64
+ const parsed = users.list.schema.parse({
65
+ name: "Ada",
66
+ email: "skip@example.com",
67
+ });
68
+ await users.list(parsed, {
69
+ tenant: "tenant_1",
70
+ where: { email: "ada@example.com" },
71
+ });
72
+ expect(calls[0]?.statement).toContain(`"email" = $3`);
73
+ expect(calls[0]?.statement).toContain(`"name" = $4`);
74
+ expect(calls[0]?.values[4]).toBe(11);
75
+ } finally {
76
+ setDatabase(null);
77
+ }
78
+ });
79
+
80
+ it("includes archived rows from list and get", async () => {
81
+ const calls: { statement: string; values: unknown[] }[] = [];
82
+ setDatabase({
83
+ unsafe(statement: string, values: unknown[]) {
84
+ calls.push({ statement, values });
85
+ return [row(createTypeId("users"), "Ada")];
86
+ },
87
+ } as never);
88
+ try {
89
+ const users = repository({
90
+ table: "users",
91
+ columns,
92
+ list: true,
93
+ get: true,
94
+ });
95
+ await users.list.includeArchived({}, { tenant: "tenant_1" });
96
+ await users.get.includeArchived(
97
+ { id: "users_a" },
98
+ { archived: "true" },
99
+ { tenant: "tenant_1" },
100
+ );
101
+ expect(calls[0]?.statement).not.toContain(`"_archived" =`);
102
+ expect(calls[1]?.statement).not.toContain(`"_archived" =`);
103
+ expect(calls[1]?.values).toEqual(["users_a", "tenant_1"]);
104
+ } finally {
105
+ setDatabase(null);
106
+ }
107
+ });
108
+
109
+ it("archives, deletes, and upserts a row for one tenant", async () => {
110
+ const calls: { statement: string; values: unknown[] }[] = [];
111
+ setDatabase({
112
+ unsafe(statement: string, values: unknown[]) {
113
+ calls.push({ statement, values });
114
+ return statement.startsWith("delete") ? [] : [stored];
115
+ },
116
+ } as never);
117
+ const users = repository({ table: "users", columns });
118
+ const stored = row(createTypeId("users"), "Ada");
119
+ try {
120
+ const archived = await users.archive(stored._id, stored._tenant);
121
+ const removed = await users.hardDelete(stored._id, stored._tenant);
122
+ const saved = await users.upsert(stored);
123
+ expect(archived?._id).toBe(stored._id);
124
+ expect(removed).toBeNull();
125
+ expect(saved?._id).toBe(stored._id);
126
+ expect(calls[0]?.statement).toContain(`"_archived" = $1`);
127
+ expect(calls[0]?.values[0]).toBe(true);
128
+ expect(calls[1]?.statement).toContain(
129
+ `delete from "users" where "_id" = $1 and "_tenant" = $2`,
130
+ );
131
+ expect(calls[2]?.statement).toContain(`"name" = excluded."name"`);
132
+ expect(calls[2]?.statement).toContain(
133
+ `where "users"."_tenant" = excluded."_tenant"`,
134
+ );
135
+ expect(calls[2]?.values[0]).toBe(stored._id);
136
+ } finally {
137
+ setDatabase(null);
138
+ }
139
+ });
140
+ });
141
+
142
+ function row(id: string, name: string) {
143
+ const at = new Date("2026-01-15T00:00:00.000Z");
144
+ return {
145
+ _id: id || createTypeId("users"),
146
+ _object: "v1.users",
147
+ _tenant: "tenant_1",
148
+ _archived: false,
149
+ _createdAt: at,
150
+ _updatedAt: at,
151
+ name,
152
+ email: `${name.toLowerCase()}@example.com`,
153
+ };
154
+ }