@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,175 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const namespace = "keja";
6
+ const generationKey = "keja";
7
+
8
+ let projectRoot: string | null = null;
9
+ let generation: string | null = null;
10
+ let pluginInstalled = false;
11
+ const outsideProject = new Set<string>();
12
+
13
+ export function freshImportUrl(file: string, token: string) {
14
+ return `${namespace}:${path.resolve(file)}?${generationKey}=${token}`;
15
+ }
16
+
17
+ export function resolveProjectImport(input: {
18
+ importer: string;
19
+ specifier: string;
20
+ projectRoot: string;
21
+ generation: string;
22
+ }) {
23
+ const specifier = input.specifier.split("?")[0] ?? input.specifier;
24
+ if (!isRelativeSpecifier(specifier)) {
25
+ return null;
26
+ }
27
+ const importerFile = filePath(input.importer);
28
+ if (!importerFile || isNodeModules(importerFile)) {
29
+ return null;
30
+ }
31
+ const inProject = isInside(importerFile, input.projectRoot);
32
+ const inGraph =
33
+ input.importer.startsWith(`${namespace}:`) ||
34
+ input.importer.includes(`${generationKey}=`);
35
+ if (!inProject && !inGraph) {
36
+ return null;
37
+ }
38
+ const resolved = path.resolve(path.dirname(importerFile), specifier);
39
+ if (isNodeModules(resolved)) {
40
+ return null;
41
+ }
42
+ return {
43
+ path: `${resolved}?${generationKey}=${input.generation}`,
44
+ namespace,
45
+ file: resolved,
46
+ outside: !isInside(resolved, input.projectRoot),
47
+ };
48
+ }
49
+
50
+ export function beginProjectReload(root: string, token: string) {
51
+ projectRoot = path.resolve(root);
52
+ generation = token;
53
+ outsideProject.clear();
54
+ installPlugin();
55
+ }
56
+
57
+ export function projectDependenciesOutsideRoot() {
58
+ return [...outsideProject];
59
+ }
60
+
61
+ function installPlugin() {
62
+ if (pluginInstalled) {
63
+ return;
64
+ }
65
+ const runtime = globalThis as {
66
+ Bun?: { plugin?: (plugin: BunPlugin) => void };
67
+ };
68
+ if (!runtime.Bun?.plugin) {
69
+ return;
70
+ }
71
+ pluginInstalled = true;
72
+ runtime.Bun.plugin({
73
+ name: "keja-dev-reload",
74
+ setup(build) {
75
+ build.onResolve({ filter: /^\.{1,2}(?:[\\/]|$)/ }, (args) => {
76
+ if (!projectRoot || !generation) {
77
+ return null;
78
+ }
79
+ if (!args.importer.startsWith(`${namespace}:`)) {
80
+ return null;
81
+ }
82
+ const resolved = resolveProjectImport({
83
+ importer: args.importer,
84
+ specifier: args.path,
85
+ projectRoot,
86
+ generation,
87
+ });
88
+ if (!resolved) {
89
+ return null;
90
+ }
91
+ if (resolved.outside) {
92
+ outsideProject.add(resolved.file);
93
+ }
94
+ return { path: resolved.path, namespace: resolved.namespace };
95
+ });
96
+ build.onLoad({ filter: /.*/, namespace }, (args) => {
97
+ const file = filePath(args.path);
98
+ if (!file) {
99
+ return null;
100
+ }
101
+ return {
102
+ contents: readFileSync(file, "utf8"),
103
+ loader: loaderFor(file),
104
+ };
105
+ });
106
+ },
107
+ });
108
+ }
109
+
110
+ type BunPlugin = {
111
+ name: string;
112
+ setup: (build: {
113
+ onResolve: (
114
+ constraints: { filter: RegExp },
115
+ callback: (args: { importer: string; path: string }) => {
116
+ path: string;
117
+ namespace: string;
118
+ } | null,
119
+ ) => void;
120
+ onLoad: (
121
+ constraints: { filter: RegExp; namespace: string },
122
+ callback: (args: { path: string }) => {
123
+ contents: string;
124
+ loader: "js" | "jsx" | "ts" | "tsx" | "json";
125
+ } | null,
126
+ ) => void;
127
+ }) => void;
128
+ };
129
+
130
+ function loaderFor(file: string) {
131
+ const extension = path.extname(file).toLowerCase();
132
+ if (extension === ".tsx") {
133
+ return "tsx" as const;
134
+ }
135
+ if (extension === ".jsx") {
136
+ return "jsx" as const;
137
+ }
138
+ if (extension === ".js" || extension === ".mjs" || extension === ".cjs") {
139
+ return "js" as const;
140
+ }
141
+ if (extension === ".json") {
142
+ return "json" as const;
143
+ }
144
+ return "ts" as const;
145
+ }
146
+
147
+ function isRelativeSpecifier(specifier: string) {
148
+ return specifier.startsWith("./") || specifier.startsWith("../");
149
+ }
150
+
151
+ function filePath(importer: string) {
152
+ const withoutQuery = importer.split("?")[0] ?? importer;
153
+ const clean = withoutQuery.startsWith(`${namespace}:`)
154
+ ? withoutQuery.slice(namespace.length + 1)
155
+ : withoutQuery;
156
+ if (clean.startsWith("file:")) {
157
+ return fileURLToPath(clean);
158
+ }
159
+ if (path.isAbsolute(clean)) {
160
+ return clean;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function isNodeModules(file: string) {
166
+ return file.split(/[\\/]/).includes("node_modules");
167
+ }
168
+
169
+ function isInside(file: string, root: string) {
170
+ const relative = path.relative(root, file);
171
+ return (
172
+ relative === "" ||
173
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
174
+ );
175
+ }
@@ -0,0 +1,123 @@
1
+ import { projectError } from "../errors/framework-errors.ts";
2
+ import { processBrand } from "../process/slot.ts";
3
+
4
+ const repositoryBrand = processBrand("repositories");
5
+ const tableName = /^[a-z_][a-z0-9_]*$/;
6
+
7
+ export type ListConstraints = {
8
+ filters?: readonly string[];
9
+ limit?: number;
10
+ };
11
+
12
+ export type RepositoryFlags = {
13
+ list?: boolean | ListConstraints;
14
+ get?: boolean;
15
+ };
16
+
17
+ export type ResolvedList = boolean | ListConstraints;
18
+
19
+ export function defineRepositories<
20
+ const Tables extends Record<string, RepositoryFlags>,
21
+ >(tables: Tables) {
22
+ for (const [name, flags] of Object.entries(tables)) {
23
+ if (!tableName.test(name)) {
24
+ throw projectError(
25
+ "invalidRepositoryFile",
26
+ `${name} is not a table name`,
27
+ );
28
+ }
29
+ for (const [key, value] of Object.entries(flags)) {
30
+ if (key === "get") {
31
+ if (typeof value !== "boolean") {
32
+ throw projectError(
33
+ "invalidRepositoryFile",
34
+ `${name}.get is not a repository flag`,
35
+ );
36
+ }
37
+ continue;
38
+ }
39
+ if (key !== "list" || !isListFlag(value)) {
40
+ throw projectError(
41
+ "invalidRepositoryFile",
42
+ `${name}.${key} is not a repository flag`,
43
+ );
44
+ }
45
+ readList(name, value);
46
+ }
47
+ }
48
+ return Object.assign({}, tables, { [repositoryBrand]: true as const });
49
+ }
50
+
51
+ export function isRepositories(
52
+ value: unknown,
53
+ ): value is Record<string, RepositoryFlags> {
54
+ return (
55
+ typeof value === "object" &&
56
+ value !== null &&
57
+ repositoryBrand in value &&
58
+ (value as Record<symbol, unknown>)[repositoryBrand] === true
59
+ );
60
+ }
61
+
62
+ export function repositoryFlags(value: Record<string, RepositoryFlags>) {
63
+ const flags = new Map<string, { list: ResolvedList; get: boolean }>();
64
+ for (const [name, entry] of Object.entries(value)) {
65
+ if (name === "") {
66
+ continue;
67
+ }
68
+ flags.set(name, {
69
+ list: entry.list === undefined ? false : readList(name, entry.list),
70
+ get: entry.get === true,
71
+ });
72
+ }
73
+ return flags;
74
+ }
75
+
76
+ function isListFlag(value: unknown): value is boolean | ListConstraints {
77
+ return (
78
+ typeof value === "boolean" ||
79
+ (typeof value === "object" && value !== null && !Array.isArray(value))
80
+ );
81
+ }
82
+
83
+ function readList(
84
+ name: string,
85
+ value: boolean | ListConstraints,
86
+ ): ResolvedList {
87
+ if (typeof value === "boolean") {
88
+ return value;
89
+ }
90
+ for (const key of Object.keys(value)) {
91
+ if (key !== "filters" && key !== "limit") {
92
+ throw projectError(
93
+ "invalidRepositoryFile",
94
+ `${name}.list.${key} is not a list constraint`,
95
+ );
96
+ }
97
+ }
98
+ if (
99
+ value.filters !== undefined &&
100
+ (!Array.isArray(value.filters) ||
101
+ value.filters.some(
102
+ (filter) => typeof filter !== "string" || !tableName.test(filter),
103
+ ))
104
+ ) {
105
+ throw projectError(
106
+ "invalidRepositoryFile",
107
+ `${name}.list.filters must name columns`,
108
+ );
109
+ }
110
+ if (
111
+ value.limit !== undefined &&
112
+ (!Number.isInteger(value.limit) || value.limit < 1 || value.limit > 100)
113
+ ) {
114
+ throw projectError(
115
+ "invalidRepositoryFile",
116
+ `${name}.list.limit must be from 1 to 100`,
117
+ );
118
+ }
119
+ return {
120
+ ...(value.filters ? { filters: [...value.filters] } : {}),
121
+ ...(value.limit !== undefined ? { limit: value.limit } : {}),
122
+ };
123
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderRepositories } from "./generate.ts";
3
+ import { readTables } from "./sql.ts";
4
+
5
+ const owned = `
6
+ _id text primary key,
7
+ _object text not null,
8
+ _tenant text not null,
9
+ _archived boolean not null,
10
+ "_createdAt" timestamptz not null,
11
+ "_updatedAt" timestamptz not null
12
+ `;
13
+
14
+ describe("renderRepositories", () => {
15
+ it("writes a repository and the processors that are enabled", () => {
16
+ const tables = readTables([
17
+ {
18
+ file: "0001_example.sql",
19
+ sql: `create table users (${owned}, name text not null); create table jobs (${owned}, name text not null);`,
20
+ },
21
+ ]);
22
+ const source = renderRepositories(
23
+ tables,
24
+ new Map([
25
+ ["users", { list: true, get: true }],
26
+ ["jobs", { list: false, get: false }],
27
+ ]),
28
+ );
29
+ expect(source).toContain("export const jobs = repository({");
30
+ expect(source).toContain("list: true,");
31
+ expect(source).toContain("get: true,");
32
+ expect(source.indexOf("export const jobs")).toBeLessThan(
33
+ source.indexOf("export const users"),
34
+ );
35
+ expect(
36
+ source.slice(
37
+ source.indexOf("export const jobs"),
38
+ source.indexOf("export const users"),
39
+ ),
40
+ ).not.toContain("list: true");
41
+ });
42
+
43
+ it("writes list filter and limit constraints", () => {
44
+ const tables = readTables([
45
+ {
46
+ file: "0001_example.sql",
47
+ sql: `create table users (${owned}, name text not null, email text not null);`,
48
+ },
49
+ ]);
50
+ const source = renderRepositories(
51
+ tables,
52
+ new Map([
53
+ ["users", { list: { filters: ["name"], limit: 20 }, get: false }],
54
+ ]),
55
+ );
56
+ expect(source).toContain('list: { filters: ["name"], limit: 20 },');
57
+ });
58
+ });
@@ -0,0 +1,117 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { loadMigrations } from "../db/migrate.ts";
6
+ import { projectError } from "../errors/framework-errors.ts";
7
+ import { loadConfig } from "../project/config.ts";
8
+ import {
9
+ isRepositories,
10
+ type ResolvedList,
11
+ repositoryFlags,
12
+ } from "./define.ts";
13
+ import { readTables, type TableSchema } from "./sql.ts";
14
+
15
+ export const repositoryModule = path.join("src", "db", "repositories.ts");
16
+
17
+ export function renderRepositories(
18
+ tables: readonly TableSchema[],
19
+ flags: ReadonlyMap<string, { list: ResolvedList; get: boolean }>,
20
+ ) {
21
+ const blocks = [...tables]
22
+ .sort((left, right) => left.name.localeCompare(right.name))
23
+ .map((table) => renderRepository(table, flags.get(table.name)));
24
+ return `import { repository } from "@veducio/keja";\n\n${blocks.join("\n\n")}\n`;
25
+ }
26
+
27
+ export async function generateRepositories(root: string) {
28
+ const project = await loadConfig(root);
29
+ const directory = project.database.enabled
30
+ ? project.database.migrations
31
+ : path.resolve(root, "migrations");
32
+ const tables = readTables(
33
+ (await loadMigrations(directory)).map((file) => ({
34
+ file: file.file,
35
+ sql: file.sql,
36
+ })),
37
+ );
38
+ const flags = await loadRepositoryFlags(root);
39
+ for (const [name, flag] of flags) {
40
+ const table = tables.find((item) => item.name === name);
41
+ if (!table) {
42
+ throw projectError("invalidRepositoryFile", `${name} is not a table`);
43
+ }
44
+ if (typeof flag.list === "object") {
45
+ for (const filter of flag.list.filters ?? []) {
46
+ const column = table.columns.find((item) => item.name === filter);
47
+ if (!column || column.type === "jsonb") {
48
+ throw projectError(
49
+ "invalidRepositoryFile",
50
+ `${name}.list.filters ${filter} is not a filter`,
51
+ );
52
+ }
53
+ }
54
+ }
55
+ }
56
+ const source = renderRepositories(tables, flags);
57
+ const output = path.join(root, repositoryModule);
58
+ await mkdir(path.dirname(output), { recursive: true });
59
+ await writeFile(output, source);
60
+ return { output, tables: tables.map((table) => table.name) };
61
+ }
62
+
63
+ async function loadRepositoryFlags(root: string) {
64
+ const file = path.join(root, "keja.repositories.ts");
65
+ if (!existsSync(file)) {
66
+ return new Map<string, { list: boolean; get: boolean }>();
67
+ }
68
+ const loaded = (await import(pathToFileURL(file).href)) as {
69
+ default?: unknown;
70
+ };
71
+ if (!isRepositories(loaded.default)) {
72
+ throw projectError(
73
+ "invalidRepositoryFile",
74
+ `${file} must default-export defineRepositories(...)`,
75
+ );
76
+ }
77
+ return repositoryFlags(loaded.default);
78
+ }
79
+
80
+ function renderRepository(
81
+ table: TableSchema,
82
+ flags: { list: ResolvedList; get: boolean } | undefined,
83
+ ) {
84
+ const columns = table.columns
85
+ .map(
86
+ (column) =>
87
+ ` ${column.name}: { type: "${column.type}", nullable: ${column.nullable} },`,
88
+ )
89
+ .join("\n");
90
+ const options = [
91
+ renderList(flags?.list),
92
+ flags?.get ? " get: true," : "",
93
+ ].filter((line) => line !== "");
94
+ const optionBlock = options.length > 0 ? `\n${options.join("\n")}` : "";
95
+ return `export const ${table.name} = repository({
96
+ table: "${table.name}",
97
+ columns: {
98
+ ${columns}
99
+ } as const,${optionBlock}
100
+ });`;
101
+ }
102
+
103
+ function renderList(list: ResolvedList | undefined) {
104
+ if (!list) {
105
+ return "";
106
+ }
107
+ if (list === true) {
108
+ return " list: true,";
109
+ }
110
+ const parts = [
111
+ list.filters
112
+ ? `filters: [${list.filters.map((name) => `"${name}"`).join(", ")}]`
113
+ : "",
114
+ list.limit !== undefined ? `limit: ${list.limit}` : "",
115
+ ].filter((part) => part !== "");
116
+ return ` list: { ${parts.join(", ")} },`;
117
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildListSql, encodeCursor } from "./query.ts";
3
+ import type { DomainColumn } from "./sql.ts";
4
+
5
+ const columns: DomainColumn[] = [
6
+ { name: "name", type: "text", nullable: false },
7
+ { name: "email", type: "text", nullable: false },
8
+ { name: "active", type: "boolean", nullable: false },
9
+ ];
10
+
11
+ describe("buildListSql", () => {
12
+ it("filters live rows for a tenant and searches text columns", () => {
13
+ const built = buildListSql({
14
+ table: "users",
15
+ columns,
16
+ tenant: "tenant_1",
17
+ query: { q: "ada_%", name: "Ada", limit: 2 },
18
+ });
19
+ expect(built.limit).toBe(2);
20
+ expect(built.text).toContain(`"name" = $3`);
21
+ expect(built.text).toContain(`ilike $4 escape '\\'`);
22
+ expect(built.text).toContain(`order by "_id" asc`);
23
+ expect(built.text).toContain("limit $6");
24
+ expect(built.values.slice(0, 5)).toEqual([
25
+ "tenant_1",
26
+ false,
27
+ "Ada",
28
+ "%ada\\_\\%%",
29
+ "%ada\\_\\%%",
30
+ ]);
31
+ });
32
+
33
+ it("pages with a cursor for a descending domain sort", () => {
34
+ const cursor = encodeCursor({
35
+ column: "name",
36
+ direction: "desc",
37
+ value: "Ada",
38
+ id: "users_a",
39
+ });
40
+ const built = buildListSql({
41
+ table: "users",
42
+ columns,
43
+ tenant: "tenant_1",
44
+ query: { sort: "-name", cursor, archived: "true" },
45
+ });
46
+ expect(built.values[1]).toBe(true);
47
+ expect(built.text).toContain(`"name" < $3`);
48
+ expect(built.text).toContain(`"_id" < $4`);
49
+ expect(built.text).toContain(`order by "name" desc, "_id" desc`);
50
+ });
51
+
52
+ it("applies a default limit, an allowlist, and extra where checks", () => {
53
+ const built = buildListSql({
54
+ table: "users",
55
+ columns,
56
+ tenant: "tenant_1",
57
+ query: { name: "Ada", email: "skip@example.com" },
58
+ filters: ["name"],
59
+ limitDefault: 20,
60
+ where: { email: "ada@example.com", active: null },
61
+ });
62
+ expect(built.limit).toBe(20);
63
+ expect(built.text).toContain(`"email" = $3`);
64
+ expect(built.text).toContain(`"active" is null`);
65
+ expect(built.text).toContain(`"name" = $4`);
66
+ expect(built.text).not.toContain(`"email" = $4`);
67
+ expect(built.values.slice(2, 4)).toEqual(["ada@example.com", "Ada"]);
68
+ });
69
+
70
+ it("includes archived rows when asked", () => {
71
+ const built = buildListSql({
72
+ table: "users",
73
+ columns,
74
+ tenant: "tenant_1",
75
+ query: { archived: "true" },
76
+ includeArchived: true,
77
+ });
78
+ expect(built.text).not.toContain(`"_archived" =`);
79
+ expect(built.values[0]).toBe("tenant_1");
80
+ expect(built.values).not.toContain(true);
81
+ });
82
+
83
+ it("rejects a cursor for a different sort", () => {
84
+ const cursor = encodeCursor({
85
+ column: "name",
86
+ direction: "asc",
87
+ value: "Ada",
88
+ id: "users_a",
89
+ });
90
+ expect(() =>
91
+ buildListSql({
92
+ table: "users",
93
+ columns,
94
+ tenant: "tenant_1",
95
+ query: { cursor },
96
+ }),
97
+ ).toThrow("invalid cursor");
98
+ });
99
+ });