@manablox/db 0.2.0 → 0.4.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.
- package/dist/index-BLAkQMJT.d.ts +877 -0
- package/dist/index-DrNMGM9N.d.ts +5193 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +60 -0
- package/dist/repositories-pz4NeWaF.js +1928 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-Dm3RcBst.js +648 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -0
- package/dist/testing.d.ts +77 -0
- package/dist/testing.js +217 -0
- package/migrations/0009_audit-log.sql +40 -0
- package/migrations/0010_notifications-approvals.sql +45 -0
- package/migrations/meta/0009_snapshot.json +3192 -0
- package/migrations/meta/0010_snapshot.json +3574 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +18 -10
- package/drizzle.config.ts +0 -11
- package/src/bootstrap.ts +0 -13
- package/src/cli/create-db.ts +0 -30
- package/src/cli/migrate.ts +0 -17
- package/src/client.ts +0 -44
- package/src/columns.ts +0 -39
- package/src/errors.ts +0 -50
- package/src/index.ts +0 -19
- package/src/migrate.ts +0 -21
- package/src/pagination.ts +0 -52
- package/src/query.ts +0 -213
- package/src/repositories/asset-usage.ts +0 -166
- package/src/repositories/asset.ts +0 -181
- package/src/repositories/content-type.ts +0 -116
- package/src/repositories/content.ts +0 -811
- package/src/repositories/index.ts +0 -40
- package/src/repositories/menu.ts +0 -235
- package/src/repositories/role.ts +0 -85
- package/src/repositories/space.ts +0 -83
- package/src/repositories/user.ts +0 -280
- package/src/repositories/webhook.ts +0 -46
- package/src/repositories/workflow.ts +0 -306
- package/src/schema/assets.ts +0 -108
- package/src/schema/auth.ts +0 -166
- package/src/schema/content-types.ts +0 -31
- package/src/schema/content.ts +0 -133
- package/src/schema/index.ts +0 -38
- package/src/schema/menus.ts +0 -61
- package/src/schema/relations.ts +0 -64
- package/src/schema/spaces.ts +0 -20
- package/src/schema/webhooks.ts +0 -46
- package/src/schema/workflows.ts +0 -92
- package/src/testing-fixtures.ts +0 -139
- package/src/testing.ts +0 -105
- package/test/asset-usage.test.ts +0 -101
- package/test/menu.test.ts +0 -126
- package/test/publish.test.ts +0 -130
- package/test/query.test.ts +0 -170
- package/test/role.test.ts +0 -81
- package/test/tree.test.ts +0 -188
- package/test/user.test.ts +0 -126
- package/test/webhook.test.ts +0 -48
- package/tsconfig.json +0 -4
- package/vitest.config.ts +0 -10
|
@@ -64,6 +64,20 @@
|
|
|
64
64
|
"when": 1788650538612,
|
|
65
65
|
"tag": "0008_workflows",
|
|
66
66
|
"breakpoints": true
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"idx": 9,
|
|
70
|
+
"version": "7",
|
|
71
|
+
"when": 1788692313004,
|
|
72
|
+
"tag": "0009_audit-log",
|
|
73
|
+
"breakpoints": true
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"idx": 10,
|
|
77
|
+
"version": "7",
|
|
78
|
+
"when": 1788722093611,
|
|
79
|
+
"tag": "0010_notifications-approvals",
|
|
80
|
+
"breakpoints": true
|
|
67
81
|
}
|
|
68
82
|
]
|
|
69
83
|
}
|
package/package.json
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manablox/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
|
-
"types": "./
|
|
8
|
-
"default": "./
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"./schema": {
|
|
11
|
-
"types": "./
|
|
12
|
-
"default": "./
|
|
11
|
+
"types": "./dist/schema.d.ts",
|
|
12
|
+
"default": "./dist/schema.js"
|
|
13
13
|
},
|
|
14
14
|
"./testing": {
|
|
15
|
-
"types": "./
|
|
16
|
-
"default": "./
|
|
15
|
+
"types": "./dist/testing.d.ts",
|
|
16
|
+
"default": "./dist/testing.js"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
|
-
"main": "./
|
|
20
|
-
"types": "./
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@manablox/core": "0.
|
|
22
|
+
"@manablox/core": "0.4.0",
|
|
23
23
|
"drizzle-orm": "^0.45.2",
|
|
24
24
|
"postgres": "^3.4.9"
|
|
25
25
|
},
|
|
@@ -28,11 +28,19 @@
|
|
|
28
28
|
"@types/node": "^26.4.1",
|
|
29
29
|
"drizzle-kit": "^0.31.10",
|
|
30
30
|
"testcontainers": "^12.1.0",
|
|
31
|
+
"tsdown": "^0.23.0",
|
|
31
32
|
"tsx": "^4.20.7",
|
|
32
33
|
"typescript": "^7.0.2",
|
|
33
34
|
"vitest": "^5.0.0"
|
|
34
35
|
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"!dist/**/*.map",
|
|
39
|
+
"migrations",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
35
42
|
"scripts": {
|
|
43
|
+
"build": "tsdown",
|
|
36
44
|
"generate": "drizzle-kit generate",
|
|
37
45
|
"migrate": "tsx src/cli/migrate.ts",
|
|
38
46
|
"typecheck": "tsc --noEmit",
|
package/drizzle.config.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'drizzle-kit';
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
schema: './src/schema/index.ts',
|
|
5
|
-
out: './migrations',
|
|
6
|
-
dialect: 'postgresql',
|
|
7
|
-
dbCredentials: {
|
|
8
|
-
url: process.env.DATABASE_URL ?? 'postgres://manablox:manablox@localhost:5432/manablox',
|
|
9
|
-
},
|
|
10
|
-
casing: 'snake_case',
|
|
11
|
-
});
|
package/src/bootstrap.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { Sql } from './client.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Extensions the schema depends on. Run before the generated migrations, which
|
|
5
|
-
* reference `ltree` columns and `pg_trgm` operator classes.
|
|
6
|
-
*/
|
|
7
|
-
export async function applyBootstrapSql(sql: Sql): Promise<void> {
|
|
8
|
-
await sql.unsafe(`
|
|
9
|
-
create extension if not exists "ltree";
|
|
10
|
-
create extension if not exists "pg_trgm";
|
|
11
|
-
create extension if not exists "btree_gin";
|
|
12
|
-
`);
|
|
13
|
-
}
|
package/src/cli/create-db.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import postgres from 'postgres';
|
|
2
|
-
import { withDatabase } from '../testing.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Creates the database `DATABASE_URL` names, on the server it names, if it does not
|
|
6
|
-
* exist — `--fresh` drops it first. For throwaway databases (the end-to-end run) where
|
|
7
|
-
* nothing else provisions them.
|
|
8
|
-
*/
|
|
9
|
-
const url = process.env.DATABASE_URL;
|
|
10
|
-
if (!url) {
|
|
11
|
-
console.error('DATABASE_URL is required');
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const name = new URL(url).pathname.replace(/^\//, '');
|
|
16
|
-
const fresh = process.argv.includes('--fresh');
|
|
17
|
-
const admin = postgres(withDatabase(url, 'postgres'), { max: 1 });
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
if (fresh) await admin.unsafe(`drop database if exists "${name}" with (force)`);
|
|
21
|
-
const [row] = await admin.unsafe(`select 1 as found from pg_database where datname = '${name}'`);
|
|
22
|
-
if (!row) {
|
|
23
|
-
await admin.unsafe(`create database "${name}"`);
|
|
24
|
-
console.info(`created database ${name}`);
|
|
25
|
-
} else {
|
|
26
|
-
console.info(`database ${name} exists`);
|
|
27
|
-
}
|
|
28
|
-
} finally {
|
|
29
|
-
await admin.end();
|
|
30
|
-
}
|
package/src/cli/migrate.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { createDatabase } from '../client.js';
|
|
2
|
-
import { runMigrations } from '../migrate.js';
|
|
3
|
-
|
|
4
|
-
const url = process.env.DATABASE_URL;
|
|
5
|
-
if (!url) {
|
|
6
|
-
console.error('DATABASE_URL is required');
|
|
7
|
-
process.exit(1);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const handle = createDatabase({ url, max: 1 });
|
|
11
|
-
|
|
12
|
-
try {
|
|
13
|
-
await runMigrations(handle);
|
|
14
|
-
console.info('migrations applied');
|
|
15
|
-
} finally {
|
|
16
|
-
await handle.close();
|
|
17
|
-
}
|
package/src/client.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { DatabaseConfig } from '@manablox/core';
|
|
2
|
-
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
3
|
-
import postgres from 'postgres';
|
|
4
|
-
import * as schema from './schema/index.js';
|
|
5
|
-
|
|
6
|
-
export type Database = PostgresJsDatabase<typeof schema>;
|
|
7
|
-
/** What `db.transaction((tx) => …)` hands its callback. */
|
|
8
|
-
export type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
|
|
9
|
-
/**
|
|
10
|
-
* Anything a query can run on. Repository internals take this so the same helper serves
|
|
11
|
-
* a call inside a transaction and one outside it, without casting `tx` to `Database`.
|
|
12
|
-
*/
|
|
13
|
-
export type Executor = Database | Transaction;
|
|
14
|
-
export type Sql = ReturnType<typeof postgres>;
|
|
15
|
-
|
|
16
|
-
export interface DatabaseHandle {
|
|
17
|
-
db: Database;
|
|
18
|
-
sql: Sql;
|
|
19
|
-
close: () => Promise<void>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface DatabaseOptions {
|
|
23
|
-
/** Called once per statement sent to Postgres. Used by tests to assert query counts. */
|
|
24
|
-
onQuery?: (query: string) => void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function createDatabase(
|
|
28
|
-
config: DatabaseConfig,
|
|
29
|
-
options: DatabaseOptions = {},
|
|
30
|
-
): DatabaseHandle {
|
|
31
|
-
const sql = postgres(config.url, {
|
|
32
|
-
max: config.max ?? 10,
|
|
33
|
-
...(options.onQuery ? { debug: (_c: number, query: string) => options.onQuery?.(query) } : {}),
|
|
34
|
-
...(config.ssl ? { ssl: 'require' as const } : {}),
|
|
35
|
-
// `ltree` and `tsvector` have no client-side parser, so postgres.js returns them as
|
|
36
|
-
// strings — which is exactly what the schema declares. Do not pass `types: {}` here:
|
|
37
|
-
// it replaces the built-in serialisers wholesale and Date parameters stop working.
|
|
38
|
-
onnotice: () => {},
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const db = drizzle(sql, { schema, casing: 'snake_case' });
|
|
42
|
-
|
|
43
|
-
return { db, sql, close: () => sql.end({ timeout: 5 }) };
|
|
44
|
-
}
|
package/src/columns.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { customType } from 'drizzle-orm/pg-core';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `ltree` — the materialised ancestor path of a content node. A subtree move is one
|
|
5
|
-
* `UPDATE ... SET path = :newParent || subpath(path, nlevel(:oldParent))`.
|
|
6
|
-
*/
|
|
7
|
-
export const ltree = customType<{ data: string; driverData: string }>({
|
|
8
|
-
dataType: () => 'ltree',
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
/** `tsvector` — generated from `title` + `search_text`, never written directly. */
|
|
12
|
-
export const tsvector = customType<{ data: string; driverData: string }>({
|
|
13
|
-
dataType: () => 'tsvector',
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* ltree labels accept only `[A-Za-z0-9_]`, so UUID hyphens are swapped for underscores.
|
|
18
|
-
* The transform is total and reversible.
|
|
19
|
-
*/
|
|
20
|
-
export const idToLabel = (id: string): string => id.replaceAll('-', '_');
|
|
21
|
-
export const labelToId = (label: string): string => {
|
|
22
|
-
const hex = label.replaceAll('_', '');
|
|
23
|
-
return [
|
|
24
|
-
hex.slice(0, 8),
|
|
25
|
-
hex.slice(8, 12),
|
|
26
|
-
hex.slice(12, 16),
|
|
27
|
-
hex.slice(16, 20),
|
|
28
|
-
hex.slice(20, 32),
|
|
29
|
-
].join('-');
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** Builds the path of a node from its ancestors' ids (root first) plus its own. */
|
|
33
|
-
export const buildPath = (ancestorIds: string[], selfId: string): string =>
|
|
34
|
-
[...ancestorIds, selfId].map(idToLabel).join('.');
|
|
35
|
-
|
|
36
|
-
/** Splits a stored path back into ids, root first, self last. */
|
|
37
|
-
export const parsePath = (path: string): string[] => path.split('.').filter(Boolean).map(labelToId);
|
|
38
|
-
|
|
39
|
-
export const pathDepth = (path: string): number => (path ? path.split('.').length : 0);
|
package/src/errors.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { type ErrorDetail, type ErrorKey, ManabloxError } from '@manablox/core';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Postgres reports a unique-constraint violation as SQLSTATE `23505` with the constraint
|
|
5
|
-
* name in the message. Drizzle wraps the driver error, so the code and the message sit on
|
|
6
|
-
* `cause`, while the outer message is only ever "Failed query: insert into …".
|
|
7
|
-
*/
|
|
8
|
-
export function isUniqueViolation(error: unknown, constraint: string): boolean {
|
|
9
|
-
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
|
|
10
|
-
const code = (error as { code?: string })?.code ?? cause?.code;
|
|
11
|
-
const detail = `${cause?.message ?? ''} ${(error as { message?: string })?.message ?? ''}`;
|
|
12
|
-
return code === '23505' && detail.includes(constraint);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface UniqueViolationMapping {
|
|
16
|
-
/** The constraint name, or a distinctive part of it. */
|
|
17
|
-
constraint: string;
|
|
18
|
-
/** The detail reported for the field, e.g. `content.slug.duplicate`. */
|
|
19
|
-
key: ErrorKey;
|
|
20
|
-
path?: (string | number)[];
|
|
21
|
-
params?: Record<string, unknown>;
|
|
22
|
-
/** The error's own key. */
|
|
23
|
-
errorKey: ErrorKey;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Turns a unique violation on one constraint into the validation error the client would
|
|
28
|
-
* have received had the check run before the write, and rethrows anything else untouched.
|
|
29
|
-
* Use as `.catch((error) => rethrowUniqueViolation(error, { … }))`.
|
|
30
|
-
*/
|
|
31
|
-
export function rethrowUniqueViolation(error: unknown, mapping: UniqueViolationMapping): never {
|
|
32
|
-
if (!isUniqueViolation(error, mapping.constraint)) throw error;
|
|
33
|
-
|
|
34
|
-
const detail: ErrorDetail = {
|
|
35
|
-
key: mapping.key,
|
|
36
|
-
...(mapping.path ? { path: mapping.path } : {}),
|
|
37
|
-
...(mapping.params ? { params: mapping.params } : {}),
|
|
38
|
-
};
|
|
39
|
-
throw ManabloxError.validation([detail], mapping.errorKey);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A preset for one constraint: `const taken = uniqueViolation({ … })` once beside the
|
|
44
|
-
* service, then `.catch(taken({ machineName }))` on every write that can trip it. The
|
|
45
|
-
* params are the only part that differs per call, so they are the only argument left.
|
|
46
|
-
*/
|
|
47
|
-
export function uniqueViolation(preset: Omit<UniqueViolationMapping, 'params'>) {
|
|
48
|
-
return (params: Record<string, unknown>) => (error: unknown) =>
|
|
49
|
-
rethrowUniqueViolation(error, { ...preset, params });
|
|
50
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export * from './bootstrap.js';
|
|
2
|
-
export * from './client.js';
|
|
3
|
-
export * from './columns.js';
|
|
4
|
-
export * from './errors.js';
|
|
5
|
-
export * from './migrate.js';
|
|
6
|
-
export * from './pagination.js';
|
|
7
|
-
export * from './query.js';
|
|
8
|
-
export * from './repositories/asset.js';
|
|
9
|
-
export * from './repositories/asset-usage.js';
|
|
10
|
-
export * from './repositories/content.js';
|
|
11
|
-
export * from './repositories/content-type.js';
|
|
12
|
-
export * from './repositories/index.js';
|
|
13
|
-
export * from './repositories/menu.js';
|
|
14
|
-
export * from './repositories/space.js';
|
|
15
|
-
export * from './repositories/user.js';
|
|
16
|
-
export * from './repositories/webhook.js';
|
|
17
|
-
export * from './repositories/workflow.js';
|
|
18
|
-
export * as schema from './schema/index.js';
|
|
19
|
-
export * from './schema/index.js';
|
package/src/migrate.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { dirname, resolve } from 'node:path';
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
|
4
|
-
import { applyBootstrapSql } from './bootstrap.js';
|
|
5
|
-
import type { DatabaseHandle } from './client.js';
|
|
6
|
-
|
|
7
|
-
/** The SQL files shipped with this package, next to `src/`. */
|
|
8
|
-
export const MIGRATIONS_FOLDER = resolve(dirname(fileURLToPath(import.meta.url)), '../migrations');
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Brings a database up to the schema this version of the package expects.
|
|
12
|
-
*
|
|
13
|
-
* The same call the compose stack's `migrate` service and the `manablox migrate` CLI
|
|
14
|
-
* make, so an instance run from a dependency and one run from the repository apply the
|
|
15
|
-
* same files in the same order. Idempotent: drizzle records what it has applied.
|
|
16
|
-
*/
|
|
17
|
-
export async function runMigrations(handle: DatabaseHandle): Promise<void> {
|
|
18
|
-
// Extensions must exist before the generated migrations reference ltree columns.
|
|
19
|
-
await applyBootstrapSql(handle.sql);
|
|
20
|
-
await migrate(handle.db, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
21
|
-
}
|
package/src/pagination.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { getTableColumns, type SQL, sql } from 'drizzle-orm';
|
|
2
|
-
import type { PgColumn, PgTable } from 'drizzle-orm/pg-core';
|
|
3
|
-
import type { Executor } from './client.js';
|
|
4
|
-
import type { Pagination } from './query.js';
|
|
5
|
-
|
|
6
|
-
export interface Paginated<T> {
|
|
7
|
-
items: T[];
|
|
8
|
-
total: number;
|
|
9
|
-
limit: number;
|
|
10
|
-
offset: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* One page of a table plus the total, in one round trip: a window `count(*) over ()`
|
|
15
|
-
* rides along with the rows. A page past the end comes back empty and so carries no
|
|
16
|
-
* count; only then is the total asked for separately, so a caller paging by `total`
|
|
17
|
-
* still learns the true size.
|
|
18
|
-
*/
|
|
19
|
-
export async function paginate<TTable extends PgTable>(
|
|
20
|
-
db: Executor,
|
|
21
|
-
table: TTable,
|
|
22
|
-
options: {
|
|
23
|
-
where?: SQL | undefined;
|
|
24
|
-
orderBy: SQL | PgColumn | (SQL | PgColumn)[];
|
|
25
|
-
pagination: Pagination;
|
|
26
|
-
},
|
|
27
|
-
): Promise<Paginated<TTable['$inferSelect']>> {
|
|
28
|
-
const orderBy = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy];
|
|
29
|
-
const rows = (await db
|
|
30
|
-
.select({ ...getTableColumns(table), total: sql<number>`count(*) over ()::int` })
|
|
31
|
-
.from(table as PgTable)
|
|
32
|
-
.where(options.where)
|
|
33
|
-
.orderBy(...orderBy)
|
|
34
|
-
.limit(options.pagination.limit)
|
|
35
|
-
.offset(options.pagination.offset)) as Array<TTable['$inferSelect'] & { total: number }>;
|
|
36
|
-
|
|
37
|
-
let total = rows[0]?.total ?? 0;
|
|
38
|
-
if (rows.length === 0 && options.pagination.offset > 0) {
|
|
39
|
-
const counted = await db
|
|
40
|
-
.select({ count: sql<number>`count(*)::int` })
|
|
41
|
-
.from(table as PgTable)
|
|
42
|
-
.where(options.where);
|
|
43
|
-
total = counted[0]?.count ?? 0;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
items: rows.map(({ total: _total, ...row }) => row as TTable['$inferSelect']),
|
|
48
|
-
total,
|
|
49
|
-
limit: options.pagination.limit,
|
|
50
|
-
offset: options.pagination.offset,
|
|
51
|
-
};
|
|
52
|
-
}
|
package/src/query.ts
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { type ContentTypeRegistry, type FilterOperator, ManabloxError } from '@manablox/core';
|
|
2
|
-
import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm';
|
|
3
|
-
import type { PgColumn } from 'drizzle-orm/pg-core';
|
|
4
|
-
import type { contents, publishedContents } from './schema/index.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Both content tables share a column set, so one filter builder serves the draft and
|
|
8
|
-
* the published projection. Typing the parameter as the union (rather than `PgTable`)
|
|
9
|
-
* keeps real column references, which is what lets Drizzle bind arrays and UUIDs
|
|
10
|
-
* correctly instead of stringifying them into the SQL text.
|
|
11
|
-
*/
|
|
12
|
-
export type ContentTable = typeof contents | typeof publishedContents;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Note on `?: T | undefined` throughout the input types in this file: the workspace runs
|
|
16
|
-
* with `exactOptionalPropertyTypes`, under which a bare `?:` accepts an absent property
|
|
17
|
-
* but rejects an explicit `undefined`. Validators (Zod, and any Standard Schema) produce
|
|
18
|
-
* exactly that explicit `undefined` for optional fields, so input DTOs widen while the
|
|
19
|
-
* domain types they feed stay strict.
|
|
20
|
-
*/
|
|
21
|
-
export interface FieldFilter {
|
|
22
|
-
/** Field machine name on the content type. */
|
|
23
|
-
name: string;
|
|
24
|
-
op: FilterOperator;
|
|
25
|
-
value?: unknown | undefined;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface ContentFilter {
|
|
29
|
-
spaceId?: string | undefined;
|
|
30
|
-
/** Content type ids. */
|
|
31
|
-
typeIds?: string[] | undefined;
|
|
32
|
-
locale?: string | undefined;
|
|
33
|
-
status?: 'draft' | 'published' | 'archived' | undefined;
|
|
34
|
-
ids?: string[] | undefined;
|
|
35
|
-
parentId?: string | null | undefined;
|
|
36
|
-
/** Restrict to the subtree below this content id (inclusive of its descendants). */
|
|
37
|
-
under?: string | undefined;
|
|
38
|
-
slug?: string | undefined;
|
|
39
|
-
permalink?: string | undefined;
|
|
40
|
-
localizationId?: string | undefined;
|
|
41
|
-
/** Full-text query against `title` + field contributions. */
|
|
42
|
-
search?: string | undefined;
|
|
43
|
-
fields?: FieldFilter[] | undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface ContentSort {
|
|
47
|
-
by: 'position' | 'title' | 'createdAt' | 'updatedAt' | 'publishedAt' | 'slug';
|
|
48
|
-
direction: 'asc' | 'desc';
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface Pagination {
|
|
52
|
-
limit: number;
|
|
53
|
-
offset: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const SORT_COLUMNS: Record<ContentSort['by'], string> = {
|
|
57
|
-
position: 'position',
|
|
58
|
-
title: 'title',
|
|
59
|
-
createdAt: 'created_at',
|
|
60
|
-
updatedAt: 'updated_at',
|
|
61
|
-
publishedAt: 'published_at',
|
|
62
|
-
slug: 'slug',
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Translates a filter into SQL. Every field predicate is checked against the field
|
|
67
|
-
* type's declared `filters` list first: an unsupported operator is a 400, and every
|
|
68
|
-
* supported one has an index behind it.
|
|
69
|
-
*/
|
|
70
|
-
export function buildContentWhere(
|
|
71
|
-
table: ContentTable,
|
|
72
|
-
filter: ContentFilter,
|
|
73
|
-
registry: ContentTypeRegistry,
|
|
74
|
-
): SQL | undefined {
|
|
75
|
-
const conditions: SQL[] = [];
|
|
76
|
-
|
|
77
|
-
if (filter.spaceId) conditions.push(eq(table.spaceId, filter.spaceId));
|
|
78
|
-
if (filter.locale) conditions.push(eq(table.locale, filter.locale));
|
|
79
|
-
if (filter.status) conditions.push(eq(table.status, filter.status));
|
|
80
|
-
if (filter.localizationId) conditions.push(eq(table.localizationId, filter.localizationId));
|
|
81
|
-
if (filter.slug) conditions.push(eq(table.slug, filter.slug));
|
|
82
|
-
if (filter.permalink !== undefined) conditions.push(eq(table.permalink, filter.permalink));
|
|
83
|
-
if (filter.typeIds?.length) conditions.push(inArray(table.typeId, filter.typeIds));
|
|
84
|
-
if (filter.ids?.length) conditions.push(inArray(table.id, filter.ids));
|
|
85
|
-
|
|
86
|
-
if (filter.parentId !== undefined) {
|
|
87
|
-
conditions.push(
|
|
88
|
-
filter.parentId === null ? isNull(table.parentId) : eq(table.parentId, filter.parentId),
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (filter.under) {
|
|
93
|
-
// GiST-indexed ancestor test — the whole subtree without a recursive query.
|
|
94
|
-
conditions.push(
|
|
95
|
-
sql`${table.path} <@ (select path from ${table} where id = ${filter.under}::uuid)`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (filter.search) {
|
|
100
|
-
conditions.push(sql`${table.search} @@ websearch_to_tsquery('simple', ${filter.search})`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
for (const fieldFilter of filter.fields ?? []) {
|
|
104
|
-
conditions.push(buildFieldCondition(table, fieldFilter, filter.typeIds ?? [], registry));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function buildFieldCondition(
|
|
111
|
-
table: ContentTable,
|
|
112
|
-
filter: FieldFilter,
|
|
113
|
-
typeIds: string[],
|
|
114
|
-
registry: ContentTypeRegistry,
|
|
115
|
-
): SQL {
|
|
116
|
-
assertOperatorAllowed(filter, typeIds, registry);
|
|
117
|
-
|
|
118
|
-
const path = sql`${table.fields} -> ${filter.name}`;
|
|
119
|
-
const text = sql`${table.fields} ->> ${filter.name}`;
|
|
120
|
-
|
|
121
|
-
switch (filter.op) {
|
|
122
|
-
case 'eq':
|
|
123
|
-
// jsonb containment, so the `jsonb_path_ops` GIN index can serve it.
|
|
124
|
-
return sql`${table.fields} @> jsonb_build_object(${filter.name}::text, ${JSON.stringify(filter.value ?? null)}::jsonb)`;
|
|
125
|
-
case 'neq':
|
|
126
|
-
return sql`not (${table.fields} @> jsonb_build_object(${filter.name}::text, ${JSON.stringify(filter.value ?? null)}::jsonb))`;
|
|
127
|
-
case 'in':
|
|
128
|
-
return sql`${text} = any(${sql.param(asStringArray(filter.value))}::text[])`;
|
|
129
|
-
case 'notIn':
|
|
130
|
-
return sql`${text} <> all(${sql.param(asStringArray(filter.value))}::text[])`;
|
|
131
|
-
case 'lt':
|
|
132
|
-
return sql`(${text})::numeric < ${asNumber(filter.value)}`;
|
|
133
|
-
case 'lte':
|
|
134
|
-
return sql`(${text})::numeric <= ${asNumber(filter.value)}`;
|
|
135
|
-
case 'gt':
|
|
136
|
-
return sql`(${text})::numeric > ${asNumber(filter.value)}`;
|
|
137
|
-
case 'gte':
|
|
138
|
-
return sql`(${text})::numeric >= ${asNumber(filter.value)}`;
|
|
139
|
-
case 'contains':
|
|
140
|
-
return sql`${text} ilike ${`%${escapeLike(String(filter.value ?? ''))}%`}`;
|
|
141
|
-
case 'startsWith':
|
|
142
|
-
return sql`${text} ilike ${`${escapeLike(String(filter.value ?? ''))}%`}`;
|
|
143
|
-
case 'endsWith':
|
|
144
|
-
return sql`${text} ilike ${`%${escapeLike(String(filter.value ?? ''))}`}`;
|
|
145
|
-
case 'isNull':
|
|
146
|
-
return sql`(${path} is null or ${path} = 'null'::jsonb)`;
|
|
147
|
-
case 'isNotNull':
|
|
148
|
-
return sql`(${path} is not null and ${path} <> 'null'::jsonb)`;
|
|
149
|
-
default: {
|
|
150
|
-
const exhaustive: never = filter.op;
|
|
151
|
-
throw ManabloxError.badRequest('query.operator.unsupported', { op: exhaustive });
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* A field predicate is only accepted when *every* candidate content type declares a
|
|
158
|
-
* field of that name whose field type supports the operator.
|
|
159
|
-
*/
|
|
160
|
-
function assertOperatorAllowed(
|
|
161
|
-
filter: FieldFilter,
|
|
162
|
-
typeIds: string[],
|
|
163
|
-
registry: ContentTypeRegistry,
|
|
164
|
-
): void {
|
|
165
|
-
const candidates =
|
|
166
|
-
typeIds.length > 0 ? typeIds.map((id) => registry.get(id)) : registry.contentTypes;
|
|
167
|
-
|
|
168
|
-
const matching = candidates
|
|
169
|
-
.map((type) => type.fields.find((field) => field.name === filter.name))
|
|
170
|
-
.filter((field): field is NonNullable<typeof field> => field !== undefined);
|
|
171
|
-
|
|
172
|
-
if (matching.length === 0) {
|
|
173
|
-
throw ManabloxError.badRequest('query.field.unknown', { field: filter.name });
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
for (const field of matching) {
|
|
177
|
-
const fieldType = registry.fieldTypes.tryGet(field.type);
|
|
178
|
-
if (!fieldType?.filters.includes(filter.op)) {
|
|
179
|
-
throw ManabloxError.badRequest('query.operator.unsupported', {
|
|
180
|
-
field: filter.name,
|
|
181
|
-
fieldType: field.type,
|
|
182
|
-
op: filter.op,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function buildOrderBy(sorts: ContentSort[]): SQL {
|
|
189
|
-
if (sorts.length === 0) {
|
|
190
|
-
return sql`${sql.identifier('position')} asc, ${sql.identifier('created_at')} asc`;
|
|
191
|
-
}
|
|
192
|
-
const parts = sorts.map((sort) => {
|
|
193
|
-
const column = SORT_COLUMNS[sort.by];
|
|
194
|
-
if (!column) throw ManabloxError.badRequest('query.sort.unsupported', { by: sort.by });
|
|
195
|
-
return sql`${sql.identifier(column)} ${sql.raw(sort.direction === 'desc' ? 'desc' : 'asc')}`;
|
|
196
|
-
});
|
|
197
|
-
return parts.reduce((acc, part) => sql`${acc}, ${part}`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (c) => `\\${c}`);
|
|
201
|
-
|
|
202
|
-
function asStringArray(value: unknown): string[] {
|
|
203
|
-
if (!Array.isArray(value)) throw ManabloxError.badRequest('query.value.expectedArray');
|
|
204
|
-
return value.map((entry) => String(entry));
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function asNumber(value: unknown): number {
|
|
208
|
-
const parsed = Number(value);
|
|
209
|
-
if (Number.isNaN(parsed)) throw ManabloxError.badRequest('query.value.expectedNumber');
|
|
210
|
-
return parsed;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export type { PgColumn };
|