@suluk/drizzle 0.1.1 → 0.1.2
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/README.md +35 -0
- package/package.json +19 -8
- package/src/crud.ts +25 -2
- package/src/index.ts +7 -0
- package/src/meta.ts +9 -1
- package/src/mutations.ts +41 -0
- package/src/query.ts +95 -0
- package/test/depth.test.ts +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/MahmoodKhalil57/suluk">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">@suluk/drizzle</h1>
|
|
8
|
+
|
|
9
|
+
<p align="center"><b>Drizzle ORM schema -> v4 'Suluk' contract: table -> Zod (drizzle-zod) -> v4 Schema Objects, DB metadata, and generated CRUD RouteContracts.</b></p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
18
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
19
|
+
> to ratify anything on the SIG's behalf.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bun add @suluk/drizzle
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The Suluk cycle
|
|
28
|
+
|
|
29
|
+
`@suluk/drizzle` is one station on the Suluk walk — author one v4 source, then **validate · audit ·
|
|
30
|
+
preview · generate · deploy** the whole stack from it. Explore the full toolchain in the
|
|
31
|
+
[main repository](https://github.com/MahmoodKhalil57/suluk) or drive it from the [VS Code cockpit](https://marketplace.visualstudio.com/items?itemName=MahmoodKhalil.suluk-vscode).
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/drizzle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Drizzle ORM schema -> v4 'Suluk' contract: table -> Zod (drizzle-zod) -> v4 Schema Objects, DB metadata, and generated CRUD RouteContracts. CANDIDATE tooling.",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/drizzle"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
9
20
|
},
|
|
10
21
|
"dependencies": {
|
|
11
|
-
"@suluk/core": "0.1.
|
|
12
|
-
"@suluk/zod": "0.1.
|
|
13
|
-
"@suluk/hono": "0.1.
|
|
22
|
+
"@suluk/core": "^0.1.7",
|
|
23
|
+
"@suluk/zod": "^0.1.2",
|
|
24
|
+
"@suluk/hono": "^0.1.2"
|
|
14
25
|
},
|
|
15
26
|
"peerDependencies": {
|
|
16
27
|
"drizzle-orm": "^0.4.0 || ^0.30.0 || ^0.40.0 || ^0.45.0",
|
package/src/crud.ts
CHANGED
|
@@ -9,12 +9,25 @@ import { getTableName } from "drizzle-orm";
|
|
|
9
9
|
import type { RouteContract, RouteResponse } from "@suluk/hono";
|
|
10
10
|
import { tableSchemas } from "./schemas";
|
|
11
11
|
import { pascalCase, type AnyTable } from "./meta";
|
|
12
|
+
import { listQuerySchema, type ListQueryOptions } from "./query";
|
|
12
13
|
|
|
13
14
|
export interface CrudOptions {
|
|
14
15
|
/** Base path for the collection. Default "/" + tableName, e.g. "/users". */
|
|
15
16
|
basePath?: string;
|
|
16
17
|
/** Path-param name for the item id. Default "id" ⇒ ".../:id". */
|
|
17
18
|
idParam?: string;
|
|
19
|
+
/** Declare list query params (page/perPage/sort/order/q) on the list route. Default true; pass options to scope. */
|
|
20
|
+
listQuery?: boolean | ListQueryOptions;
|
|
21
|
+
/**
|
|
22
|
+
* SOFT delete: DELETE marks the row (sets a deletedAt column) instead of removing it, so the projected DELETE
|
|
23
|
+
* returns the affected row (200), not 204. The patch is built at runtime by `softDeleteValues`.
|
|
24
|
+
*/
|
|
25
|
+
softDelete?: boolean | { column?: string };
|
|
26
|
+
/**
|
|
27
|
+
* ANONYMIZE on delete (GDPR keep-record): DELETE redacts these columns instead of removing the row. Like
|
|
28
|
+
* softDelete, the projected DELETE returns the affected row (200). The patch comes from `anonymizeValues`.
|
|
29
|
+
*/
|
|
30
|
+
anonymizeDelete?: { columns: string[] };
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
/**
|
|
@@ -41,6 +54,15 @@ export function crudRoutes(table: AnyTable, opts: CrudOptions = {}): RouteContra
|
|
|
41
54
|
const ok = (status: number, schema: z.ZodType, description: string): RouteResponse => ({ status, schema, description });
|
|
42
55
|
const bare = (status: number, description: string): RouteResponse => ({ status, description });
|
|
43
56
|
|
|
57
|
+
// a soft-delete / anonymize-delete keeps the row, so DELETE returns it (200) rather than 204.
|
|
58
|
+
const softening = opts.softDelete || opts.anonymizeDelete;
|
|
59
|
+
const deleteResponses: RouteResponse[] = softening
|
|
60
|
+
? [ok(200, select, `The ${tableName} row, after a soft delete / anonymize.`), bare(404, "Not found.")]
|
|
61
|
+
: [bare(204, "Deleted.")];
|
|
62
|
+
|
|
63
|
+
const listQueryOpts: ListQueryOptions | undefined =
|
|
64
|
+
opts.listQuery === false ? undefined : typeof opts.listQuery === "object" ? opts.listQuery : {};
|
|
65
|
+
|
|
44
66
|
return [
|
|
45
67
|
{
|
|
46
68
|
method: "get",
|
|
@@ -48,6 +70,7 @@ export function crudRoutes(table: AnyTable, opts: CrudOptions = {}): RouteContra
|
|
|
48
70
|
name: `list${Pascal}`,
|
|
49
71
|
summary: `List ${tableName}`,
|
|
50
72
|
tags: [tableName],
|
|
73
|
+
...(listQueryOpts ? { request: { query: listQuerySchema(table, listQueryOpts) } } : {}),
|
|
51
74
|
responses: [ok(200, z.array(select), `A page of ${tableName}.`)],
|
|
52
75
|
},
|
|
53
76
|
{
|
|
@@ -81,10 +104,10 @@ export function crudRoutes(table: AnyTable, opts: CrudOptions = {}): RouteContra
|
|
|
81
104
|
method: "delete",
|
|
82
105
|
path: itemPath,
|
|
83
106
|
name: `delete${Pascal}`,
|
|
84
|
-
summary:
|
|
107
|
+
summary: `${softening ? "Soft-delete" : "Delete"} a ${tableName} row by ${idParam}`,
|
|
85
108
|
tags: [tableName],
|
|
86
109
|
request: { params: idParams },
|
|
87
|
-
responses:
|
|
110
|
+
responses: deleteResponses,
|
|
88
111
|
},
|
|
89
112
|
];
|
|
90
113
|
}
|
package/src/index.ts
CHANGED
|
@@ -32,3 +32,10 @@ export {
|
|
|
32
32
|
} from "./schemas";
|
|
33
33
|
|
|
34
34
|
export { crudRoutes, type CrudOptions } from "./crud";
|
|
35
|
+
// list query-param synthesis (Phase 1): declare page/perPage/sort/order/q + the pure parser the handler uses.
|
|
36
|
+
export { listQuerySchema, parseListQuery, type ListQuery, type ListQueryOptions } from "./query";
|
|
37
|
+
// CrudOptions runtime helpers (Phase 1): soft-delete / anonymize-on-delete (GDPR keep-record) / timestamps patches.
|
|
38
|
+
export {
|
|
39
|
+
softDeleteValues, anonymizeValues, touchTimestamps, notSoftDeleted,
|
|
40
|
+
type SoftDeleteOptions, type TimestampOptions,
|
|
41
|
+
} from "./mutations";
|
package/src/meta.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface ColumnMeta {
|
|
|
23
23
|
hasDefault: boolean;
|
|
24
24
|
/** Part of the (single-column) primary key. */
|
|
25
25
|
primaryKey: boolean;
|
|
26
|
+
/** Carries a column-level UNIQUE constraint (drizzle's `.unique()` / `isUnique`). */
|
|
27
|
+
unique: boolean;
|
|
26
28
|
/** SQL CHECK/enum allowed values when the column was declared with `{ enum: [...] }`. */
|
|
27
29
|
enumValues?: string[];
|
|
28
30
|
}
|
|
@@ -31,6 +33,8 @@ export interface TableMeta {
|
|
|
31
33
|
name: string;
|
|
32
34
|
/** Column names flagged `primary` (ordered as drizzle reports the columns). */
|
|
33
35
|
primaryKey: string[];
|
|
36
|
+
/** Column names carrying a UNIQUE constraint (the natural keys for upsert / by-field lookup). */
|
|
37
|
+
unique: string[];
|
|
34
38
|
columns: ColumnMeta[];
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -43,6 +47,7 @@ export function tableMetadata(table: AnyTable): TableMeta {
|
|
|
43
47
|
const cols = getTableColumns(table);
|
|
44
48
|
const columns: ColumnMeta[] = [];
|
|
45
49
|
const primaryKey: string[] = [];
|
|
50
|
+
const unique: string[] = [];
|
|
46
51
|
|
|
47
52
|
for (const [name, col] of Object.entries(cols)) {
|
|
48
53
|
// drizzle's descriptor surface — read defensively (any dialect, any version in our peer range).
|
|
@@ -52,6 +57,7 @@ export function tableMetadata(table: AnyTable): TableMeta {
|
|
|
52
57
|
notNull: boolean;
|
|
53
58
|
hasDefault: boolean;
|
|
54
59
|
primary: boolean;
|
|
60
|
+
isUnique?: boolean;
|
|
55
61
|
enumValues?: string[];
|
|
56
62
|
};
|
|
57
63
|
const meta: ColumnMeta = {
|
|
@@ -61,14 +67,16 @@ export function tableMetadata(table: AnyTable): TableMeta {
|
|
|
61
67
|
notNull: !!c.notNull,
|
|
62
68
|
hasDefault: !!c.hasDefault,
|
|
63
69
|
primaryKey: !!c.primary,
|
|
70
|
+
unique: !!c.isUnique,
|
|
64
71
|
// enumValues is often an empty array on non-enum columns — only surface a non-empty one.
|
|
65
72
|
...(Array.isArray(c.enumValues) && c.enumValues.length ? { enumValues: c.enumValues } : {}),
|
|
66
73
|
};
|
|
67
74
|
columns.push(meta);
|
|
68
75
|
if (meta.primaryKey) primaryKey.push(name);
|
|
76
|
+
if (meta.unique) unique.push(name);
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
return { name: getTableName(table), primaryKey, columns };
|
|
79
|
+
return { name: getTableName(table), primaryKey, unique, columns };
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
/** "user_accounts" / "users" → "UserAccounts" / "Users". The v4 component key (C009 by-name). */
|
package/src/mutations.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CrudOptions runtime helpers (saastarter-parity Phase 1): pure value-builders for soft-delete, anonymize-on-delete,
|
|
3
|
+
* and server-managed timestamps. The package projects CONTRACTS (it runs no SQL), so these produce the PATCH an
|
|
4
|
+
* app's Drizzle handler applies — keeping the policy (which column is `deletedAt`, which columns to redact) in one
|
|
5
|
+
* place. anonymizeValues is the row-level counterpart of @suluk/better-auth's GDPR erasure cascade (the keep-record,
|
|
6
|
+
* FK-safe posture).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SoftDeleteOptions {
|
|
10
|
+
/** the timestamp column set on delete (default "deletedAt"). */
|
|
11
|
+
column?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TimestampOptions {
|
|
15
|
+
createdAt?: string;
|
|
16
|
+
updatedAt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The patch a soft delete applies — sets the deletedAt column to `now` (default current time). */
|
|
20
|
+
export function softDeleteValues(opts: SoftDeleteOptions = {}, now: Date = new Date()): Record<string, string> {
|
|
21
|
+
return { [opts.column ?? "deletedAt"]: now.toISOString() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The patch an anonymize-on-delete applies — redacts each named column to `value` (null by default). Pair with a
|
|
25
|
+
* soft-delete to keep the row (FK-safe right-to-be-forgotten). */
|
|
26
|
+
export function anonymizeValues(columns: string[], value: string | null = null): Record<string, string | null> {
|
|
27
|
+
return Object.fromEntries(columns.map((c) => [c, value]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The patch server-managed timestamps apply on write — `updatedAt` always, `createdAt` only when `creating`. */
|
|
31
|
+
export function touchTimestamps(opts: TimestampOptions = {}, creating = false, now: Date = new Date()): Record<string, string> {
|
|
32
|
+
const iso = now.toISOString();
|
|
33
|
+
const out: Record<string, string> = { [opts.updatedAt ?? "updatedAt"]: iso };
|
|
34
|
+
if (creating) out[opts.createdAt ?? "createdAt"] = iso;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The implicit list filter for a soft-deleting table — exclude rows whose deletedAt is set (unless asked to include). */
|
|
39
|
+
export function notSoftDeleted(column = "deletedAt"): { column: string; isNull: true } {
|
|
40
|
+
return { column, isNull: true };
|
|
41
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List query-param synthesis (saastarter-parity Phase 1). The list route today returns the whole collection; real
|
|
3
|
+
* lists are paginated, sortable, filterable, searchable. This DECLARES those query params (so they appear in the v4
|
|
4
|
+
* doc + the SDK + the conformance tests) AND ships a pure `parseListQuery` that normalizes a raw query string into
|
|
5
|
+
* `{ limit, offset, orderBy, filters, q }` the app's Drizzle handler builds its query from — one synthesis, both ends.
|
|
6
|
+
*/
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { tableMetadata, type AnyTable } from "./meta";
|
|
9
|
+
|
|
10
|
+
export interface ListQueryOptions {
|
|
11
|
+
/** sortable + filterable columns (default: all of the table's columns). */
|
|
12
|
+
columns?: string[];
|
|
13
|
+
/** default page size (default 20). */
|
|
14
|
+
defaultPerPage?: number;
|
|
15
|
+
/** max page size — `perPage` is clamped to it (default 100). */
|
|
16
|
+
maxPerPage?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reserved query keys (everything else that matches a column becomes an equality filter). */
|
|
20
|
+
const RESERVED = new Set(["page", "perPage", "sort", "order", "q"]);
|
|
21
|
+
|
|
22
|
+
/** The Zod query schema for a list route: page/perPage/sort/order/q (coerced from strings). Extra column filters
|
|
23
|
+
* are read by {@link parseListQuery} at runtime (OpenAPI query params are flat, so they aren't enumerated here).
|
|
24
|
+
* `table` is OPTIONAL: with a table (or `opts.columns`) `sort` is a column enum; without either it is a free string —
|
|
25
|
+
* so the contract-projection layer (@suluk/builder), which holds a Zod entity rather than a Drizzle table, can call
|
|
26
|
+
* `listQuerySchema()` and still emit the same five params into the v4 doc + SDK. */
|
|
27
|
+
export function listQuerySchema(table?: AnyTable, opts: ListQueryOptions = {}): z.ZodType {
|
|
28
|
+
const cols = opts.columns ?? (table ? tableMetadata(table).columns.map((c) => c.name) : []);
|
|
29
|
+
const sort = cols.length ? z.enum(cols as [string, ...string[]]).optional() : z.string().optional();
|
|
30
|
+
return z.object({
|
|
31
|
+
page: z.coerce.number().int().min(1).optional(),
|
|
32
|
+
perPage: z.coerce.number().int().min(1).optional(),
|
|
33
|
+
sort,
|
|
34
|
+
order: z.enum(["asc", "desc"]).optional(),
|
|
35
|
+
q: z.string().optional(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ListQuery {
|
|
40
|
+
/** rows to return (= perPage). */
|
|
41
|
+
limit: number;
|
|
42
|
+
/** rows to skip (= (page-1)*perPage). */
|
|
43
|
+
offset: number;
|
|
44
|
+
orderBy?: { column: string; dir: "asc" | "desc" };
|
|
45
|
+
/** free-text search term. */
|
|
46
|
+
q?: string;
|
|
47
|
+
/** column → equality value. */
|
|
48
|
+
filters: Record<string, string>;
|
|
49
|
+
page: number;
|
|
50
|
+
perPage: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type RawQuery = Record<string, string | string[] | undefined>;
|
|
54
|
+
const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
|
55
|
+
const intOr = (v: string | undefined, fallback: number) => {
|
|
56
|
+
const n = v == null ? NaN : parseInt(v, 10);
|
|
57
|
+
return Number.isFinite(n) ? n : fallback;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalize a raw query object into a {@link ListQuery} — pure, validating against the table's real columns:
|
|
62
|
+
* page/perPage are clamped (≥1, ≤maxPerPage); `sort` is honored only for a real column; any other key matching a
|
|
63
|
+
* column becomes an equality filter (unknown keys are ignored — no injection of arbitrary columns).
|
|
64
|
+
*/
|
|
65
|
+
export function parseListQuery(raw: RawQuery, table: AnyTable, opts: ListQueryOptions = {}): ListQuery {
|
|
66
|
+
const colSet = new Set(opts.columns ?? tableMetadata(table).columns.map((c) => c.name));
|
|
67
|
+
const defaultPer = opts.defaultPerPage ?? 20;
|
|
68
|
+
const maxPer = opts.maxPerPage ?? 100;
|
|
69
|
+
|
|
70
|
+
const page = Math.max(1, intOr(first(raw.page), 1));
|
|
71
|
+
const perPage = Math.min(maxPer, Math.max(1, intOr(first(raw.perPage), defaultPer)));
|
|
72
|
+
|
|
73
|
+
const sortCol = first(raw.sort);
|
|
74
|
+
const orderBy = sortCol && colSet.has(sortCol)
|
|
75
|
+
? { column: sortCol, dir: (first(raw.order) === "desc" ? "desc" : "asc") as "asc" | "desc" }
|
|
76
|
+
: undefined;
|
|
77
|
+
|
|
78
|
+
const filters: Record<string, string> = {};
|
|
79
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
80
|
+
if (RESERVED.has(k)) continue;
|
|
81
|
+
const val = first(v);
|
|
82
|
+
if (colSet.has(k) && val != null) filters[k] = val;
|
|
83
|
+
}
|
|
84
|
+
const q = first(raw.q) || undefined;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
limit: perPage,
|
|
88
|
+
offset: (page - 1) * perPage,
|
|
89
|
+
...(orderBy ? { orderBy } : {}),
|
|
90
|
+
...(q ? { q } : {}),
|
|
91
|
+
filters,
|
|
92
|
+
page,
|
|
93
|
+
perPage,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
3
|
+
import { emitV4 } from "@suluk/hono";
|
|
4
|
+
import {
|
|
5
|
+
tableMetadata, crudRoutes,
|
|
6
|
+
listQuerySchema, parseListQuery,
|
|
7
|
+
softDeleteValues, anonymizeValues, touchTimestamps, notSoftDeleted,
|
|
8
|
+
} from "../src/index";
|
|
9
|
+
|
|
10
|
+
const users = sqliteTable("users", {
|
|
11
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
12
|
+
email: text("email").notNull().unique(),
|
|
13
|
+
name: text("name"),
|
|
14
|
+
deletedAt: text("deleted_at"),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("unique-index metadata", () => {
|
|
18
|
+
test("tableMetadata surfaces UNIQUE columns at the column + table level", () => {
|
|
19
|
+
const meta = tableMetadata(users);
|
|
20
|
+
expect(meta.unique).toEqual(["email"]);
|
|
21
|
+
expect(meta.columns.find((c) => c.name === "email")?.unique).toBe(true);
|
|
22
|
+
expect(meta.columns.find((c) => c.name === "name")?.unique).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("list query-param synthesis", () => {
|
|
27
|
+
test("listQuerySchema parses + coerces reserved params and validates sort against columns", () => {
|
|
28
|
+
const schema = listQuerySchema(users);
|
|
29
|
+
expect((schema as { parse: (v: unknown) => unknown }).parse({ page: "2", perPage: "10", sort: "email", order: "desc", q: "ab" }))
|
|
30
|
+
.toEqual({ page: 2, perPage: 10, sort: "email", order: "desc", q: "ab" });
|
|
31
|
+
// an unknown sort column is rejected
|
|
32
|
+
expect(() => (schema as { parse: (v: unknown) => unknown }).parse({ sort: "nope" })).toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("parseListQuery normalizes to limit/offset/orderBy/filters (clamped, column-validated)", () => {
|
|
36
|
+
const q = parseListQuery({ page: "3", perPage: "10", sort: "name", order: "desc", q: "lina", email: "a@b.co", bogus: "x" }, users);
|
|
37
|
+
expect(q).toMatchObject({ limit: 10, offset: 20, page: 3, perPage: 10, orderBy: { column: "name", dir: "desc" }, q: "lina" });
|
|
38
|
+
expect(q.filters).toEqual({ email: "a@b.co" }); // only real columns become filters; `bogus` ignored
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("parseListQuery clamps perPage to maxPerPage and defaults page/perPage", () => {
|
|
42
|
+
expect(parseListQuery({ perPage: "9999" }, users, { maxPerPage: 50 }).limit).toBe(50);
|
|
43
|
+
expect(parseListQuery({}, users)).toMatchObject({ page: 1, perPage: 20, offset: 0 });
|
|
44
|
+
// a sort on a non-existent column is dropped (no injection)
|
|
45
|
+
expect(parseListQuery({ sort: "evil" }, users).orderBy).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("soft-delete / anonymize / timestamp CrudOptions", () => {
|
|
50
|
+
test("a soft-deleting table's DELETE returns the row (200), not 204; summary says soft-delete", () => {
|
|
51
|
+
const routes = crudRoutes(users, { softDelete: true });
|
|
52
|
+
const del = routes.find((r) => r.name === "deleteUsers")!;
|
|
53
|
+
const statuses = (Array.isArray(del.responses) ? del.responses : []).map((r) => r.status);
|
|
54
|
+
expect(statuses).toContain(200);
|
|
55
|
+
expect(statuses).not.toContain(204);
|
|
56
|
+
expect(del.summary).toContain("Soft-delete");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the list route declares query params when listQuery is on (default)", () => {
|
|
60
|
+
const list = crudRoutes(users).find((r) => r.name === "listUsers")!;
|
|
61
|
+
expect(list.request?.query).toBeDefined();
|
|
62
|
+
expect(crudRoutes(users, { listQuery: false }).find((r) => r.name === "listUsers")!.request?.query).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the extended CRUD still projects to a valid v4 document", () => {
|
|
66
|
+
const { document } = emitV4(crudRoutes(users, { softDelete: true }));
|
|
67
|
+
expect(document.paths["users"].requests.listUsers).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("runtime helpers build the right patches (pure, time-injected)", () => {
|
|
71
|
+
const now = new Date("2026-06-11T00:00:00.000Z");
|
|
72
|
+
expect(softDeleteValues({}, now)).toEqual({ deletedAt: "2026-06-11T00:00:00.000Z" });
|
|
73
|
+
expect(softDeleteValues({ column: "removed_at" }, now)).toEqual({ removed_at: "2026-06-11T00:00:00.000Z" });
|
|
74
|
+
expect(anonymizeValues(["email", "name"])).toEqual({ email: null, name: null });
|
|
75
|
+
expect(anonymizeValues(["email"], "[redacted]")).toEqual({ email: "[redacted]" });
|
|
76
|
+
expect(touchTimestamps({}, true, now)).toEqual({ updatedAt: "2026-06-11T00:00:00.000Z", createdAt: "2026-06-11T00:00:00.000Z" });
|
|
77
|
+
expect(touchTimestamps({}, false, now)).toEqual({ updatedAt: "2026-06-11T00:00:00.000Z" });
|
|
78
|
+
expect(notSoftDeleted()).toEqual({ column: "deletedAt", isNull: true });
|
|
79
|
+
});
|
|
80
|
+
});
|