@manablox/db 0.2.0 → 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.
- package/dist/index-Cyf_N5K3.d.ts +658 -0
- package/dist/index-rZ24t-Ln.d.ts +4338 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +60 -0
- package/dist/repositories-DYjzuuF6.js +1533 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-Bb4p16Yz.js +539 -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/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
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 };
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm';
|
|
2
|
-
import type { Database } from '../client.js';
|
|
3
|
-
import { assetUsages, contents, publishedContents } from '../schema/index.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
|
-
}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import { type Loose, ManabloxError } from '@manablox/core';
|
|
2
|
-
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
-
import type { Database } from '../client.js';
|
|
4
|
-
import { type Paginated, paginate } from '../pagination.js';
|
|
5
|
-
import type { Pagination } from '../query.js';
|
|
6
|
-
import { type AssetRow, type AssetVariantRow, assets, assetVariants } from '../schema/index.js';
|
|
7
|
-
|
|
8
|
-
export interface AssetWriteData {
|
|
9
|
-
id?: string | undefined;
|
|
10
|
-
spaceId: string;
|
|
11
|
-
driver: string;
|
|
12
|
-
key: string;
|
|
13
|
-
filename: string;
|
|
14
|
-
name: string;
|
|
15
|
-
mimeType: string;
|
|
16
|
-
size: number;
|
|
17
|
-
width?: number | null | undefined;
|
|
18
|
-
height?: number | null | undefined;
|
|
19
|
-
duration?: number | null | undefined;
|
|
20
|
-
checksum?: string | null | undefined;
|
|
21
|
-
alt?: string | null | undefined;
|
|
22
|
-
title?: string | null | undefined;
|
|
23
|
-
meta?: Record<string, unknown> | undefined;
|
|
24
|
-
actorId?: string | null | undefined;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface AssetFilter {
|
|
28
|
-
spaceId: string;
|
|
29
|
-
/** Prefix match on the mime type, e.g. `image/`. */
|
|
30
|
-
mimeType?: string | undefined;
|
|
31
|
-
search?: string | undefined;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class AssetRepository {
|
|
35
|
-
constructor(private readonly db: Database) {}
|
|
36
|
-
|
|
37
|
-
async findById(id: string): Promise<AssetRow | null> {
|
|
38
|
-
const rows = await this.db.select().from(assets).where(eq(assets.id, id)).limit(1);
|
|
39
|
-
return rows[0] ?? null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* `spaceId` is not an optimisation. On the public instance an asset id is the only
|
|
44
|
-
* thing a caller supplies, and without this predicate any id resolves — including one
|
|
45
|
-
* belonging to another tenant sharing the process.
|
|
46
|
-
*/
|
|
47
|
-
async findManyByIds(ids: string[], spaceId?: string | null): Promise<AssetRow[]> {
|
|
48
|
-
if (ids.length === 0) return [];
|
|
49
|
-
const where = spaceId
|
|
50
|
-
? and(inArray(assets.id, ids), eq(assets.spaceId, spaceId))
|
|
51
|
-
: inArray(assets.id, ids);
|
|
52
|
-
return this.db.select().from(assets).where(where);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async findByChecksum(spaceId: string, checksum: string): Promise<AssetRow | null> {
|
|
56
|
-
const rows = await this.db
|
|
57
|
-
.select()
|
|
58
|
-
.from(assets)
|
|
59
|
-
.where(and(eq(assets.spaceId, spaceId), eq(assets.checksum, checksum)))
|
|
60
|
-
.limit(1);
|
|
61
|
-
return rows[0] ?? null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async list(filter: AssetFilter, pagination: Pagination): Promise<Paginated<AssetRow>> {
|
|
65
|
-
const conditions = [eq(assets.spaceId, filter.spaceId)];
|
|
66
|
-
if (filter.mimeType) conditions.push(sql`${assets.mimeType} like ${`${filter.mimeType}%`}`);
|
|
67
|
-
if (filter.search) {
|
|
68
|
-
conditions.push(
|
|
69
|
-
sql`(${assets.name} ilike ${`%${filter.search}%`} or ${assets.filename} ilike ${`%${filter.search}%`})`,
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return paginate(this.db, assets, {
|
|
74
|
-
where: and(...conditions),
|
|
75
|
-
orderBy: desc(assets.createdAt),
|
|
76
|
-
pagination,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async create(data: AssetWriteData): Promise<AssetRow> {
|
|
81
|
-
const [row] = await this.db
|
|
82
|
-
.insert(assets)
|
|
83
|
-
.values({
|
|
84
|
-
...(data.id ? { id: data.id } : {}),
|
|
85
|
-
spaceId: data.spaceId,
|
|
86
|
-
driver: data.driver,
|
|
87
|
-
key: data.key,
|
|
88
|
-
filename: data.filename,
|
|
89
|
-
name: data.name,
|
|
90
|
-
mimeType: data.mimeType,
|
|
91
|
-
size: data.size,
|
|
92
|
-
width: data.width ?? null,
|
|
93
|
-
height: data.height ?? null,
|
|
94
|
-
duration: data.duration ?? null,
|
|
95
|
-
checksum: data.checksum ?? null,
|
|
96
|
-
alt: data.alt ?? null,
|
|
97
|
-
title: data.title ?? null,
|
|
98
|
-
meta: data.meta ?? {},
|
|
99
|
-
createdBy: data.actorId ?? null,
|
|
100
|
-
})
|
|
101
|
-
.returning();
|
|
102
|
-
if (!row) throw new ManabloxError('asset.create.failed');
|
|
103
|
-
return row;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async update(
|
|
107
|
-
id: string,
|
|
108
|
-
data: Loose<Pick<AssetWriteData, 'name' | 'alt' | 'title' | 'meta'>>,
|
|
109
|
-
): Promise<AssetRow> {
|
|
110
|
-
const [row] = await this.db
|
|
111
|
-
.update(assets)
|
|
112
|
-
.set({ ...data, updatedAt: new Date() })
|
|
113
|
-
.where(eq(assets.id, id))
|
|
114
|
-
.returning();
|
|
115
|
-
if (!row) throw ManabloxError.notFound('asset.notFound', { id });
|
|
116
|
-
return row;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async delete(id: string): Promise<AssetRow | null> {
|
|
120
|
-
const [row] = await this.db.delete(assets).where(eq(assets.id, id)).returning();
|
|
121
|
-
return row ?? null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async variants(assetIds: string[]): Promise<AssetVariantRow[]> {
|
|
125
|
-
if (assetIds.length === 0) return [];
|
|
126
|
-
return this.db.select().from(assetVariants).where(inArray(assetVariants.assetId, assetIds));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async findVariant(
|
|
130
|
-
assetId: string,
|
|
131
|
-
preset: string,
|
|
132
|
-
format: string,
|
|
133
|
-
): Promise<AssetVariantRow | null> {
|
|
134
|
-
const rows = await this.db
|
|
135
|
-
.select()
|
|
136
|
-
.from(assetVariants)
|
|
137
|
-
.where(
|
|
138
|
-
and(
|
|
139
|
-
eq(assetVariants.assetId, assetId),
|
|
140
|
-
eq(assetVariants.preset, preset),
|
|
141
|
-
eq(assetVariants.format, format),
|
|
142
|
-
),
|
|
143
|
-
)
|
|
144
|
-
.limit(1);
|
|
145
|
-
return rows[0] ?? null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Drops every variant row; the caller removes the files. */
|
|
149
|
-
async deleteVariants(assetId: string): Promise<void> {
|
|
150
|
-
await this.db.delete(assetVariants).where(eq(assetVariants.assetId, assetId));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async addVariant(data: {
|
|
154
|
-
assetId: string;
|
|
155
|
-
preset: string;
|
|
156
|
-
format: string;
|
|
157
|
-
key: string;
|
|
158
|
-
width?: number | null;
|
|
159
|
-
height?: number | null;
|
|
160
|
-
size: number;
|
|
161
|
-
}): Promise<AssetVariantRow> {
|
|
162
|
-
const [row] = await this.db
|
|
163
|
-
.insert(assetVariants)
|
|
164
|
-
.values({
|
|
165
|
-
assetId: data.assetId,
|
|
166
|
-
preset: data.preset,
|
|
167
|
-
format: data.format,
|
|
168
|
-
key: data.key,
|
|
169
|
-
width: data.width ?? null,
|
|
170
|
-
height: data.height ?? null,
|
|
171
|
-
size: data.size,
|
|
172
|
-
})
|
|
173
|
-
.onConflictDoUpdate({
|
|
174
|
-
target: [assetVariants.assetId, assetVariants.preset, assetVariants.format],
|
|
175
|
-
set: { key: data.key, size: data.size },
|
|
176
|
-
})
|
|
177
|
-
.returning();
|
|
178
|
-
if (!row) throw new ManabloxError('assetVariant.create.failed');
|
|
179
|
-
return row;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ContentTypeDefinition,
|
|
3
|
-
type ContentTypeInput,
|
|
4
|
-
defineContentType,
|
|
5
|
-
ManabloxError,
|
|
6
|
-
} from '@manablox/core';
|
|
7
|
-
import { eq } from 'drizzle-orm';
|
|
8
|
-
import type { Database } from '../client.js';
|
|
9
|
-
import { type ContentTypeRow, contentTypes } from '../schema/index.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Persistence for *runtime-defined* content types only. Code-defined types come from
|
|
13
|
-
* `manablox.config.ts` and are never written here — the registry merges both into one
|
|
14
|
-
* shape, and `source` tells the admin which are read-only.
|
|
15
|
-
*/
|
|
16
|
-
export class ContentTypeRepository {
|
|
17
|
-
constructor(private readonly db: Database) {}
|
|
18
|
-
|
|
19
|
-
async all(): Promise<ContentTypeDefinition[]> {
|
|
20
|
-
const rows = await this.db.select().from(contentTypes);
|
|
21
|
-
return rows.map(toDefinition);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async findById(id: string): Promise<ContentTypeDefinition | null> {
|
|
25
|
-
const rows = await this.db.select().from(contentTypes).where(eq(contentTypes.id, id)).limit(1);
|
|
26
|
-
return rows[0] ? toDefinition(rows[0]) : null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async create(input: ContentTypeInput): Promise<ContentTypeDefinition> {
|
|
30
|
-
// Normalise through the same helper the config path uses, so a runtime type and a
|
|
31
|
-
// code type are byte-for-byte the same shape.
|
|
32
|
-
const definition = defineContentType(input);
|
|
33
|
-
|
|
34
|
-
const [row] = await this.db
|
|
35
|
-
.insert(contentTypes)
|
|
36
|
-
.values({
|
|
37
|
-
id: definition.id,
|
|
38
|
-
spaceId: definition.spaceId,
|
|
39
|
-
name: definition.name,
|
|
40
|
-
label: definition.label,
|
|
41
|
-
description: definition.description ?? null,
|
|
42
|
-
icon: definition.icon ?? null,
|
|
43
|
-
kind: definition.kind,
|
|
44
|
-
hasSlug: definition.hasSlug,
|
|
45
|
-
isPublishable: definition.isPublishable,
|
|
46
|
-
isVisibleInTree: definition.isVisibleInTree,
|
|
47
|
-
canBeVisibleInMenu: definition.canBeVisibleInMenu,
|
|
48
|
-
fields: definition.fields,
|
|
49
|
-
})
|
|
50
|
-
.returning();
|
|
51
|
-
|
|
52
|
-
if (!row) throw new ManabloxError('contentType.create.failed');
|
|
53
|
-
return toDefinition(row);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async update(id: string, input: ContentTypeInput): Promise<ContentTypeDefinition> {
|
|
57
|
-
const existing = await this.findById(id);
|
|
58
|
-
if (!existing) throw ManabloxError.notFound('contentType.notFound', { id });
|
|
59
|
-
|
|
60
|
-
const definition = defineContentType({ ...input, id });
|
|
61
|
-
|
|
62
|
-
// Field machine names are part of the public GraphQL schema. Renaming one would
|
|
63
|
-
// silently break every consumer query, so a rename is expressed as remove + add.
|
|
64
|
-
for (const field of definition.fields) {
|
|
65
|
-
const previous = existing.fields.find((candidate) => candidate.id === field.id);
|
|
66
|
-
if (previous && previous.name !== field.name) {
|
|
67
|
-
throw ManabloxError.badRequest('contentType.field.name.immutable', {
|
|
68
|
-
from: previous.name,
|
|
69
|
-
to: field.name,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const [row] = await this.db
|
|
75
|
-
.update(contentTypes)
|
|
76
|
-
.set({
|
|
77
|
-
name: definition.name,
|
|
78
|
-
label: definition.label,
|
|
79
|
-
description: definition.description ?? null,
|
|
80
|
-
icon: definition.icon ?? null,
|
|
81
|
-
hasSlug: definition.hasSlug,
|
|
82
|
-
isPublishable: definition.isPublishable,
|
|
83
|
-
isVisibleInTree: definition.isVisibleInTree,
|
|
84
|
-
canBeVisibleInMenu: definition.canBeVisibleInMenu,
|
|
85
|
-
fields: definition.fields,
|
|
86
|
-
updatedAt: new Date(),
|
|
87
|
-
})
|
|
88
|
-
.where(eq(contentTypes.id, id))
|
|
89
|
-
.returning();
|
|
90
|
-
|
|
91
|
-
if (!row) throw ManabloxError.notFound('contentType.notFound', { id });
|
|
92
|
-
return toDefinition(row);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async delete(id: string): Promise<void> {
|
|
96
|
-
await this.db.delete(contentTypes).where(eq(contentTypes.id, id));
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function toDefinition(row: ContentTypeRow): ContentTypeDefinition {
|
|
101
|
-
return {
|
|
102
|
-
id: row.id,
|
|
103
|
-
name: row.name,
|
|
104
|
-
label: row.label,
|
|
105
|
-
...(row.description !== null ? { description: row.description } : {}),
|
|
106
|
-
...(row.icon !== null ? { icon: row.icon } : {}),
|
|
107
|
-
kind: row.kind,
|
|
108
|
-
spaceId: row.spaceId,
|
|
109
|
-
hasSlug: row.hasSlug,
|
|
110
|
-
isPublishable: row.isPublishable,
|
|
111
|
-
isVisibleInTree: row.isVisibleInTree,
|
|
112
|
-
canBeVisibleInMenu: row.canBeVisibleInMenu,
|
|
113
|
-
fields: row.fields,
|
|
114
|
-
source: 'runtime',
|
|
115
|
-
};
|
|
116
|
-
}
|