@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.
Files changed (56) hide show
  1. package/dist/index-Cyf_N5K3.d.ts +658 -0
  2. package/dist/index-rZ24t-Ln.d.ts +4338 -0
  3. package/dist/index.d.ts +123 -0
  4. package/dist/index.js +60 -0
  5. package/dist/repositories-DYjzuuF6.js +1533 -0
  6. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  7. package/dist/schema-Bb4p16Yz.js +539 -0
  8. package/dist/schema.d.ts +2 -0
  9. package/dist/schema.js +2 -0
  10. package/dist/testing.d.ts +77 -0
  11. package/dist/testing.js +217 -0
  12. package/package.json +18 -10
  13. package/drizzle.config.ts +0 -11
  14. package/src/bootstrap.ts +0 -13
  15. package/src/cli/create-db.ts +0 -30
  16. package/src/cli/migrate.ts +0 -17
  17. package/src/client.ts +0 -44
  18. package/src/columns.ts +0 -39
  19. package/src/errors.ts +0 -50
  20. package/src/index.ts +0 -19
  21. package/src/migrate.ts +0 -21
  22. package/src/pagination.ts +0 -52
  23. package/src/query.ts +0 -213
  24. package/src/repositories/asset-usage.ts +0 -166
  25. package/src/repositories/asset.ts +0 -181
  26. package/src/repositories/content-type.ts +0 -116
  27. package/src/repositories/content.ts +0 -811
  28. package/src/repositories/index.ts +0 -40
  29. package/src/repositories/menu.ts +0 -235
  30. package/src/repositories/role.ts +0 -85
  31. package/src/repositories/space.ts +0 -83
  32. package/src/repositories/user.ts +0 -280
  33. package/src/repositories/webhook.ts +0 -46
  34. package/src/repositories/workflow.ts +0 -306
  35. package/src/schema/assets.ts +0 -108
  36. package/src/schema/auth.ts +0 -166
  37. package/src/schema/content-types.ts +0 -31
  38. package/src/schema/content.ts +0 -133
  39. package/src/schema/index.ts +0 -38
  40. package/src/schema/menus.ts +0 -61
  41. package/src/schema/relations.ts +0 -64
  42. package/src/schema/spaces.ts +0 -20
  43. package/src/schema/webhooks.ts +0 -46
  44. package/src/schema/workflows.ts +0 -92
  45. package/src/testing-fixtures.ts +0 -139
  46. package/src/testing.ts +0 -105
  47. package/test/asset-usage.test.ts +0 -101
  48. package/test/menu.test.ts +0 -126
  49. package/test/publish.test.ts +0 -130
  50. package/test/query.test.ts +0 -170
  51. package/test/role.test.ts +0 -81
  52. package/test/tree.test.ts +0 -188
  53. package/test/user.test.ts +0 -126
  54. package/test/webhook.test.ts +0 -48
  55. package/tsconfig.json +0 -4
  56. 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
- }