@manablox/db 0.1.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/drizzle.config.ts +11 -0
- package/migrations/0000_init.sql +268 -0
- package/migrations/0001_permalink-path.sql +2 -0
- package/migrations/0002_account-issuer.sql +1 -0
- package/migrations/0003_asset-usages.sql +14 -0
- package/migrations/0004_apikey-spaces.sql +1 -0
- package/migrations/meta/0000_snapshot.json +2094 -0
- package/migrations/meta/0001_snapshot.json +2108 -0
- package/migrations/meta/0002_snapshot.json +2115 -0
- package/migrations/meta/0003_snapshot.json +2304 -0
- package/migrations/meta/0004_snapshot.json +2310 -0
- package/migrations/meta/_journal.json +41 -0
- package/package.json +37 -0
- package/src/bootstrap.ts +13 -0
- package/src/cli/migrate.ts +24 -0
- package/src/client.ts +37 -0
- package/src/columns.ts +39 -0
- package/src/index.ts +13 -0
- package/src/query.ts +217 -0
- package/src/repositories/asset-usage.ts +166 -0
- package/src/repositories/asset.ts +189 -0
- package/src/repositories/content-type.ts +116 -0
- package/src/repositories/content.ts +758 -0
- package/src/repositories/index.ts +28 -0
- package/src/repositories/space.ts +78 -0
- package/src/repositories/user.ts +134 -0
- package/src/schema.ts +513 -0
- package/test/asset-usage.test.ts +101 -0
- package/test/helpers.ts +153 -0
- package/test/publish.test.ts +102 -0
- package/test/query.test.ts +140 -0
- package/test/tree.test.ts +188 -0
- package/tsconfig.json +4 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "7",
|
|
3
|
+
"dialect": "postgresql",
|
|
4
|
+
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"idx": 0,
|
|
7
|
+
"version": "7",
|
|
8
|
+
"when": 1788506703488,
|
|
9
|
+
"tag": "0000_init",
|
|
10
|
+
"breakpoints": true
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"idx": 1,
|
|
14
|
+
"version": "7",
|
|
15
|
+
"when": 1788506841571,
|
|
16
|
+
"tag": "0001_permalink-path",
|
|
17
|
+
"breakpoints": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"idx": 2,
|
|
21
|
+
"version": "7",
|
|
22
|
+
"when": 1788508162118,
|
|
23
|
+
"tag": "0002_account-issuer",
|
|
24
|
+
"breakpoints": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"idx": 3,
|
|
28
|
+
"version": "7",
|
|
29
|
+
"when": 1788514181873,
|
|
30
|
+
"tag": "0003_asset-usages",
|
|
31
|
+
"breakpoints": true
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"idx": 4,
|
|
35
|
+
"version": "7",
|
|
36
|
+
"when": 1788544039067,
|
|
37
|
+
"tag": "0004_apikey-spaces",
|
|
38
|
+
"breakpoints": true
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manablox/db",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"default": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"./schema": {
|
|
11
|
+
"types": "./src/schema.ts",
|
|
12
|
+
"default": "./src/schema.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@manablox/core": "0.1.0",
|
|
19
|
+
"drizzle-orm": "^0.45.2",
|
|
20
|
+
"postgres": "^3.4.9"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@manablox/config-typescript": "0.0.0",
|
|
24
|
+
"@types/node": "^26.4.1",
|
|
25
|
+
"drizzle-kit": "^0.31.10",
|
|
26
|
+
"testcontainers": "^12.1.0",
|
|
27
|
+
"tsx": "^4.20.7",
|
|
28
|
+
"typescript": "^7.0.2",
|
|
29
|
+
"vitest": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"generate": "drizzle-kit generate",
|
|
33
|
+
"migrate": "tsx src/cli/migrate.ts",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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 { createDatabase } from '../client.js';
|
|
6
|
+
|
|
7
|
+
const migrationsFolder = resolve(dirname(fileURLToPath(import.meta.url)), '../../migrations');
|
|
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 handle = createDatabase({ url, max: 1 });
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
// Extensions must exist before the generated migrations reference ltree columns.
|
|
19
|
+
await applyBootstrapSql(handle.sql);
|
|
20
|
+
await migrate(handle.db, { migrationsFolder });
|
|
21
|
+
console.info('migrations applied');
|
|
22
|
+
} finally {
|
|
23
|
+
await handle.close();
|
|
24
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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.js';
|
|
5
|
+
|
|
6
|
+
export type Database = PostgresJsDatabase<typeof schema>;
|
|
7
|
+
export type Sql = ReturnType<typeof postgres>;
|
|
8
|
+
|
|
9
|
+
export interface DatabaseHandle {
|
|
10
|
+
db: Database;
|
|
11
|
+
sql: Sql;
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DatabaseOptions {
|
|
16
|
+
/** Called once per statement sent to Postgres. Used by tests to assert query counts. */
|
|
17
|
+
onQuery?: (query: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createDatabase(
|
|
21
|
+
config: DatabaseConfig,
|
|
22
|
+
options: DatabaseOptions = {},
|
|
23
|
+
): DatabaseHandle {
|
|
24
|
+
const sql = postgres(config.url, {
|
|
25
|
+
max: config.max ?? 10,
|
|
26
|
+
...(options.onQuery ? { debug: (_c: number, query: string) => options.onQuery?.(query) } : {}),
|
|
27
|
+
...(config.ssl ? { ssl: 'require' as const } : {}),
|
|
28
|
+
// `ltree` and `tsvector` have no client-side parser, so postgres.js returns them as
|
|
29
|
+
// strings — which is exactly what the schema declares. Do not pass `types: {}` here:
|
|
30
|
+
// it replaces the built-in serialisers wholesale and Date parameters stop working.
|
|
31
|
+
onnotice: () => {},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const db = drizzle(sql, { schema, casing: 'snake_case' });
|
|
35
|
+
|
|
36
|
+
return { db, sql, close: () => sql.end({ timeout: 5 }) };
|
|
37
|
+
}
|
package/src/columns.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './bootstrap.js';
|
|
2
|
+
export * from './client.js';
|
|
3
|
+
export * from './columns.js';
|
|
4
|
+
export * from './query.js';
|
|
5
|
+
export * from './repositories/asset.js';
|
|
6
|
+
export * from './repositories/asset-usage.js';
|
|
7
|
+
export * from './repositories/content.js';
|
|
8
|
+
export * from './repositories/content-type.js';
|
|
9
|
+
export * from './repositories/index.js';
|
|
10
|
+
export * from './repositories/space.js';
|
|
11
|
+
export * from './repositories/user.js';
|
|
12
|
+
export * as schema from './schema.js';
|
|
13
|
+
export * from './schema.js';
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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.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
|
+
visibleInMenu?: boolean | undefined;
|
|
42
|
+
/** Full-text query against `title` + field contributions. */
|
|
43
|
+
search?: string | undefined;
|
|
44
|
+
fields?: FieldFilter[] | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ContentSort {
|
|
48
|
+
by: 'position' | 'title' | 'createdAt' | 'updatedAt' | 'publishedAt' | 'slug';
|
|
49
|
+
direction: 'asc' | 'desc';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Pagination {
|
|
53
|
+
limit: number;
|
|
54
|
+
offset: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SORT_COLUMNS: Record<ContentSort['by'], string> = {
|
|
58
|
+
position: 'position',
|
|
59
|
+
title: 'title',
|
|
60
|
+
createdAt: 'created_at',
|
|
61
|
+
updatedAt: 'updated_at',
|
|
62
|
+
publishedAt: 'published_at',
|
|
63
|
+
slug: 'slug',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Translates a filter into SQL. Every field predicate is checked against the field
|
|
68
|
+
* type's declared `filters` list first: an unsupported operator is a 400, and every
|
|
69
|
+
* supported one has an index behind it.
|
|
70
|
+
*/
|
|
71
|
+
export function buildContentWhere(
|
|
72
|
+
table: ContentTable,
|
|
73
|
+
filter: ContentFilter,
|
|
74
|
+
registry: ContentTypeRegistry,
|
|
75
|
+
): SQL | undefined {
|
|
76
|
+
const conditions: SQL[] = [];
|
|
77
|
+
|
|
78
|
+
if (filter.spaceId) conditions.push(eq(table.spaceId, filter.spaceId));
|
|
79
|
+
if (filter.locale) conditions.push(eq(table.locale, filter.locale));
|
|
80
|
+
if (filter.status) conditions.push(eq(table.status, filter.status));
|
|
81
|
+
if (filter.localizationId) conditions.push(eq(table.localizationId, filter.localizationId));
|
|
82
|
+
if (filter.slug) conditions.push(eq(table.slug, filter.slug));
|
|
83
|
+
if (filter.permalink !== undefined) conditions.push(eq(table.permalink, filter.permalink));
|
|
84
|
+
if (filter.visibleInMenu !== undefined) {
|
|
85
|
+
conditions.push(eq(table.visibleInMenu, filter.visibleInMenu));
|
|
86
|
+
}
|
|
87
|
+
if (filter.typeIds?.length) conditions.push(inArray(table.typeId, filter.typeIds));
|
|
88
|
+
if (filter.ids?.length) conditions.push(inArray(table.id, filter.ids));
|
|
89
|
+
|
|
90
|
+
if (filter.parentId !== undefined) {
|
|
91
|
+
conditions.push(
|
|
92
|
+
filter.parentId === null ? isNull(table.parentId) : eq(table.parentId, filter.parentId),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (filter.under) {
|
|
97
|
+
// GiST-indexed ancestor test — the whole subtree without a recursive query.
|
|
98
|
+
conditions.push(
|
|
99
|
+
sql`${table.path} <@ (select path from ${table} where id = ${filter.under}::uuid)`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (filter.search) {
|
|
104
|
+
conditions.push(sql`${table.search} @@ websearch_to_tsquery('simple', ${filter.search})`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const fieldFilter of filter.fields ?? []) {
|
|
108
|
+
conditions.push(buildFieldCondition(table, fieldFilter, filter.typeIds ?? [], registry));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildFieldCondition(
|
|
115
|
+
table: ContentTable,
|
|
116
|
+
filter: FieldFilter,
|
|
117
|
+
typeIds: string[],
|
|
118
|
+
registry: ContentTypeRegistry,
|
|
119
|
+
): SQL {
|
|
120
|
+
assertOperatorAllowed(filter, typeIds, registry);
|
|
121
|
+
|
|
122
|
+
const path = sql`${table.fields} -> ${filter.name}`;
|
|
123
|
+
const text = sql`${table.fields} ->> ${filter.name}`;
|
|
124
|
+
|
|
125
|
+
switch (filter.op) {
|
|
126
|
+
case 'eq':
|
|
127
|
+
// jsonb containment, so the `jsonb_path_ops` GIN index can serve it.
|
|
128
|
+
return sql`${table.fields} @> jsonb_build_object(${filter.name}::text, ${JSON.stringify(filter.value ?? null)}::jsonb)`;
|
|
129
|
+
case 'neq':
|
|
130
|
+
return sql`not (${table.fields} @> jsonb_build_object(${filter.name}::text, ${JSON.stringify(filter.value ?? null)}::jsonb))`;
|
|
131
|
+
case 'in':
|
|
132
|
+
return sql`${text} = any(${sql.param(asStringArray(filter.value))}::text[])`;
|
|
133
|
+
case 'notIn':
|
|
134
|
+
return sql`${text} <> all(${sql.param(asStringArray(filter.value))}::text[])`;
|
|
135
|
+
case 'lt':
|
|
136
|
+
return sql`(${text})::numeric < ${asNumber(filter.value)}`;
|
|
137
|
+
case 'lte':
|
|
138
|
+
return sql`(${text})::numeric <= ${asNumber(filter.value)}`;
|
|
139
|
+
case 'gt':
|
|
140
|
+
return sql`(${text})::numeric > ${asNumber(filter.value)}`;
|
|
141
|
+
case 'gte':
|
|
142
|
+
return sql`(${text})::numeric >= ${asNumber(filter.value)}`;
|
|
143
|
+
case 'contains':
|
|
144
|
+
return sql`${text} ilike ${`%${escapeLike(String(filter.value ?? ''))}%`}`;
|
|
145
|
+
case 'startsWith':
|
|
146
|
+
return sql`${text} ilike ${`${escapeLike(String(filter.value ?? ''))}%`}`;
|
|
147
|
+
case 'endsWith':
|
|
148
|
+
return sql`${text} ilike ${`%${escapeLike(String(filter.value ?? ''))}`}`;
|
|
149
|
+
case 'isNull':
|
|
150
|
+
return sql`(${path} is null or ${path} = 'null'::jsonb)`;
|
|
151
|
+
case 'isNotNull':
|
|
152
|
+
return sql`(${path} is not null and ${path} <> 'null'::jsonb)`;
|
|
153
|
+
default: {
|
|
154
|
+
const exhaustive: never = filter.op;
|
|
155
|
+
throw ManabloxError.badRequest('query.operator.unsupported', { op: exhaustive });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A field predicate is only accepted when *every* candidate content type declares a
|
|
162
|
+
* field of that name whose field type supports the operator.
|
|
163
|
+
*/
|
|
164
|
+
function assertOperatorAllowed(
|
|
165
|
+
filter: FieldFilter,
|
|
166
|
+
typeIds: string[],
|
|
167
|
+
registry: ContentTypeRegistry,
|
|
168
|
+
): void {
|
|
169
|
+
const candidates =
|
|
170
|
+
typeIds.length > 0 ? typeIds.map((id) => registry.get(id)) : registry.contentTypes;
|
|
171
|
+
|
|
172
|
+
const matching = candidates
|
|
173
|
+
.map((type) => type.fields.find((field) => field.name === filter.name))
|
|
174
|
+
.filter((field): field is NonNullable<typeof field> => field !== undefined);
|
|
175
|
+
|
|
176
|
+
if (matching.length === 0) {
|
|
177
|
+
throw ManabloxError.badRequest('query.field.unknown', { field: filter.name });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const field of matching) {
|
|
181
|
+
const fieldType = registry.fieldTypes.tryGet(field.type);
|
|
182
|
+
if (!fieldType?.filters.includes(filter.op)) {
|
|
183
|
+
throw ManabloxError.badRequest('query.operator.unsupported', {
|
|
184
|
+
field: filter.name,
|
|
185
|
+
fieldType: field.type,
|
|
186
|
+
op: filter.op,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function buildOrderBy(sorts: ContentSort[]): SQL {
|
|
193
|
+
if (sorts.length === 0) {
|
|
194
|
+
return sql`${sql.identifier('position')} asc, ${sql.identifier('created_at')} asc`;
|
|
195
|
+
}
|
|
196
|
+
const parts = sorts.map((sort) => {
|
|
197
|
+
const column = SORT_COLUMNS[sort.by];
|
|
198
|
+
if (!column) throw ManabloxError.badRequest('query.sort.unsupported', { by: sort.by });
|
|
199
|
+
return sql`${sql.identifier(column)} ${sql.raw(sort.direction === 'desc' ? 'desc' : 'asc')}`;
|
|
200
|
+
});
|
|
201
|
+
return parts.reduce((acc, part) => sql`${acc}, ${part}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (c) => `\\${c}`);
|
|
205
|
+
|
|
206
|
+
function asStringArray(value: unknown): string[] {
|
|
207
|
+
if (!Array.isArray(value)) throw ManabloxError.badRequest('query.value.expectedArray');
|
|
208
|
+
return value.map((entry) => String(entry));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function asNumber(value: unknown): number {
|
|
212
|
+
const parsed = Number(value);
|
|
213
|
+
if (Number.isNaN(parsed)) throw ManabloxError.badRequest('query.value.expectedNumber');
|
|
214
|
+
return parsed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type { PgColumn };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm';
|
|
2
|
+
import type { Database } from '../client.js';
|
|
3
|
+
import { assetUsages, contents, publishedContents } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The asset → document reachability index.
|
|
7
|
+
*
|
|
8
|
+
* `published` tracks the *published projection*, not the draft: a draft that adds an
|
|
9
|
+
* image does not make that image public, and a draft that removes one does not make it
|
|
10
|
+
* private until the change is published. Every method below preserves that distinction,
|
|
11
|
+
* which is why the column exists rather than the table simply holding published rows.
|
|
12
|
+
*/
|
|
13
|
+
export class AssetUsageRepository {
|
|
14
|
+
constructor(private readonly db: Database) {}
|
|
15
|
+
|
|
16
|
+
/** The subset of `assetIds` reachable from at least one published document. */
|
|
17
|
+
async filterPublished(assetIds: string[]): Promise<Set<string>> {
|
|
18
|
+
if (assetIds.length === 0) return new Set();
|
|
19
|
+
const rows = await this.db
|
|
20
|
+
.selectDistinct({ assetId: assetUsages.assetId })
|
|
21
|
+
.from(assetUsages)
|
|
22
|
+
.where(and(inArray(assetUsages.assetId, assetIds), eq(assetUsages.published, true)));
|
|
23
|
+
return new Set(rows.map((row) => row.assetId));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async forContent(contentId: string): Promise<Array<{ assetId: string; published: boolean }>> {
|
|
27
|
+
return this.db
|
|
28
|
+
.select({ assetId: assetUsages.assetId, published: assetUsages.published })
|
|
29
|
+
.from(assetUsages)
|
|
30
|
+
.where(eq(assetUsages.contentId, contentId));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Records what a *draft* references.
|
|
35
|
+
*
|
|
36
|
+
* Rows the draft dropped are removed only if they are not currently published —
|
|
37
|
+
* otherwise editing a draft would silently revoke access to an image the live page is
|
|
38
|
+
* still showing.
|
|
39
|
+
*/
|
|
40
|
+
async recordDraft(contentId: string, spaceId: string, assetIds: string[]): Promise<void> {
|
|
41
|
+
const unique = [...new Set(assetIds)];
|
|
42
|
+
|
|
43
|
+
await this.db.transaction(async (tx) => {
|
|
44
|
+
await tx
|
|
45
|
+
.delete(assetUsages)
|
|
46
|
+
.where(
|
|
47
|
+
and(
|
|
48
|
+
eq(assetUsages.contentId, contentId),
|
|
49
|
+
eq(assetUsages.published, false),
|
|
50
|
+
...(unique.length > 0 ? [notInArray(assetUsages.assetId, unique)] : []),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (unique.length === 0) return;
|
|
55
|
+
|
|
56
|
+
await tx
|
|
57
|
+
.insert(assetUsages)
|
|
58
|
+
.values(
|
|
59
|
+
unique.map((assetId) => ({
|
|
60
|
+
assetId,
|
|
61
|
+
contentId,
|
|
62
|
+
spaceId,
|
|
63
|
+
published: false,
|
|
64
|
+
updatedAt: new Date(),
|
|
65
|
+
})),
|
|
66
|
+
)
|
|
67
|
+
// A row that is already published stays published: this write describes the
|
|
68
|
+
// draft, and `onConflictDoNothing` is the difference between recording a draft
|
|
69
|
+
// and revoking a live asset.
|
|
70
|
+
.onConflictDoNothing();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Records what the published projection references.
|
|
76
|
+
*
|
|
77
|
+
* Assets the new revision no longer uses lose their published flag but keep their row
|
|
78
|
+
* when the draft still references them, so the admin's "where is this used" view stays
|
|
79
|
+
* complete.
|
|
80
|
+
*/
|
|
81
|
+
async recordPublished(contentId: string, spaceId: string, assetIds: string[]): Promise<void> {
|
|
82
|
+
const unique = [...new Set(assetIds)];
|
|
83
|
+
|
|
84
|
+
await this.db.transaction(async (tx) => {
|
|
85
|
+
await tx
|
|
86
|
+
.update(assetUsages)
|
|
87
|
+
.set({ published: false, updatedAt: new Date() })
|
|
88
|
+
.where(
|
|
89
|
+
and(
|
|
90
|
+
eq(assetUsages.contentId, contentId),
|
|
91
|
+
...(unique.length > 0 ? [notInArray(assetUsages.assetId, unique)] : []),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (unique.length === 0) return;
|
|
96
|
+
|
|
97
|
+
await tx
|
|
98
|
+
.insert(assetUsages)
|
|
99
|
+
.values(
|
|
100
|
+
unique.map((assetId) => ({
|
|
101
|
+
assetId,
|
|
102
|
+
contentId,
|
|
103
|
+
spaceId,
|
|
104
|
+
published: true,
|
|
105
|
+
updatedAt: new Date(),
|
|
106
|
+
})),
|
|
107
|
+
)
|
|
108
|
+
.onConflictDoUpdate({
|
|
109
|
+
target: [assetUsages.assetId, assetUsages.contentId],
|
|
110
|
+
set: { published: true, updatedAt: new Date() },
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Unpublishing revokes every asset this document was keeping public. */
|
|
116
|
+
async clearPublished(contentId: string): Promise<void> {
|
|
117
|
+
await this.db
|
|
118
|
+
.update(assetUsages)
|
|
119
|
+
.set({ published: false, updatedAt: new Date() })
|
|
120
|
+
.where(eq(assetUsages.contentId, contentId));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async deleteForContent(contentId: string): Promise<void> {
|
|
124
|
+
await this.db.delete(assetUsages).where(eq(assetUsages.contentId, contentId));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async count(): Promise<number> {
|
|
128
|
+
const rows = await this.db.select({ count: sql<number>`count(*)::int` }).from(assetUsages);
|
|
129
|
+
return rows[0]?.count ?? 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Every document with its draft and published field values, for the backfill.
|
|
134
|
+
*
|
|
135
|
+
* References are derived from field-type definitions rather than stored, so the
|
|
136
|
+
* backfill cannot be a SQL migration — it has to run inside the application.
|
|
137
|
+
*/
|
|
138
|
+
async backfillSource(): Promise<
|
|
139
|
+
Array<{
|
|
140
|
+
id: string;
|
|
141
|
+
spaceId: string;
|
|
142
|
+
typeId: string;
|
|
143
|
+
draftFields: Record<string, unknown>;
|
|
144
|
+
publishedFields: Record<string, unknown> | null;
|
|
145
|
+
}>
|
|
146
|
+
> {
|
|
147
|
+
const rows = await this.db
|
|
148
|
+
.select({
|
|
149
|
+
id: contents.id,
|
|
150
|
+
spaceId: contents.spaceId,
|
|
151
|
+
typeId: contents.typeId,
|
|
152
|
+
draftFields: contents.fields,
|
|
153
|
+
publishedFields: publishedContents.fields,
|
|
154
|
+
})
|
|
155
|
+
.from(contents)
|
|
156
|
+
.leftJoin(publishedContents, eq(publishedContents.id, contents.id));
|
|
157
|
+
|
|
158
|
+
return rows.map((row) => ({
|
|
159
|
+
id: row.id,
|
|
160
|
+
spaceId: row.spaceId,
|
|
161
|
+
typeId: row.typeId,
|
|
162
|
+
draftFields: row.draftFields as Record<string, unknown>,
|
|
163
|
+
publishedFields: (row.publishedFields as Record<string, unknown> | null) ?? null,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
}
|