@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.
@@ -0,0 +1,189 @@
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 AssetRow, type AssetVariantRow, assets, assetVariants } from '../schema.js';
5
+ import type { Paginated, Pagination } from './content.js';
6
+
7
+ export interface AssetWriteData {
8
+ id?: string | undefined;
9
+ spaceId: string;
10
+ driver: string;
11
+ key: string;
12
+ filename: string;
13
+ name: string;
14
+ mimeType: string;
15
+ size: number;
16
+ width?: number | null | undefined;
17
+ height?: number | null | undefined;
18
+ duration?: number | null | undefined;
19
+ checksum?: string | null | undefined;
20
+ alt?: string | null | undefined;
21
+ title?: string | null | undefined;
22
+ meta?: Record<string, unknown> | undefined;
23
+ actorId?: string | null | undefined;
24
+ }
25
+
26
+ export interface AssetFilter {
27
+ spaceId: string;
28
+ /** Prefix match on the mime type, e.g. `image/`. */
29
+ mimeType?: string | undefined;
30
+ search?: string | undefined;
31
+ }
32
+
33
+ export class AssetRepository {
34
+ constructor(private readonly db: Database) {}
35
+
36
+ async findById(id: string): Promise<AssetRow | null> {
37
+ const rows = await this.db.select().from(assets).where(eq(assets.id, id)).limit(1);
38
+ return rows[0] ?? null;
39
+ }
40
+
41
+ /**
42
+ * `spaceId` is not an optimisation. On the public instance an asset id is the only
43
+ * thing a caller supplies, and without this predicate any id resolves — including one
44
+ * belonging to another tenant sharing the process.
45
+ */
46
+ async findManyByIds(ids: string[], spaceId?: string | null): Promise<AssetRow[]> {
47
+ if (ids.length === 0) return [];
48
+ const where = spaceId
49
+ ? and(inArray(assets.id, ids), eq(assets.spaceId, spaceId))
50
+ : inArray(assets.id, ids);
51
+ return this.db.select().from(assets).where(where);
52
+ }
53
+
54
+ async findByChecksum(spaceId: string, checksum: string): Promise<AssetRow | null> {
55
+ const rows = await this.db
56
+ .select()
57
+ .from(assets)
58
+ .where(and(eq(assets.spaceId, spaceId), eq(assets.checksum, checksum)))
59
+ .limit(1);
60
+ return rows[0] ?? null;
61
+ }
62
+
63
+ async list(filter: AssetFilter, pagination: Pagination): Promise<Paginated<AssetRow>> {
64
+ const conditions = [eq(assets.spaceId, filter.spaceId)];
65
+ if (filter.mimeType) conditions.push(sql`${assets.mimeType} like ${`${filter.mimeType}%`}`);
66
+ if (filter.search) {
67
+ conditions.push(
68
+ sql`(${assets.name} ilike ${`%${filter.search}%`} or ${assets.filename} ilike ${`%${filter.search}%`})`,
69
+ );
70
+ }
71
+
72
+ const where = and(...conditions);
73
+
74
+ const [items, counted] = await Promise.all([
75
+ this.db
76
+ .select()
77
+ .from(assets)
78
+ .where(where)
79
+ .orderBy(desc(assets.createdAt))
80
+ .limit(pagination.limit)
81
+ .offset(pagination.offset),
82
+ this.db.select({ count: sql<number>`count(*)::int` }).from(assets).where(where),
83
+ ]);
84
+
85
+ return {
86
+ items,
87
+ total: counted[0]?.count ?? 0,
88
+ limit: pagination.limit,
89
+ offset: pagination.offset,
90
+ };
91
+ }
92
+
93
+ async create(data: AssetWriteData): Promise<AssetRow> {
94
+ const [row] = await this.db
95
+ .insert(assets)
96
+ .values({
97
+ ...(data.id ? { id: data.id } : {}),
98
+ spaceId: data.spaceId,
99
+ driver: data.driver,
100
+ key: data.key,
101
+ filename: data.filename,
102
+ name: data.name,
103
+ mimeType: data.mimeType,
104
+ size: data.size,
105
+ width: data.width ?? null,
106
+ height: data.height ?? null,
107
+ duration: data.duration ?? null,
108
+ checksum: data.checksum ?? null,
109
+ alt: data.alt ?? null,
110
+ title: data.title ?? null,
111
+ meta: data.meta ?? {},
112
+ createdBy: data.actorId ?? null,
113
+ })
114
+ .returning();
115
+ if (!row) throw new ManabloxError('asset.create.failed');
116
+ return row;
117
+ }
118
+
119
+ async update(
120
+ id: string,
121
+ data: Loose<Pick<AssetWriteData, 'name' | 'alt' | 'title' | 'meta'>>,
122
+ ): Promise<AssetRow> {
123
+ const [row] = await this.db
124
+ .update(assets)
125
+ .set({ ...data, updatedAt: new Date() })
126
+ .where(eq(assets.id, id))
127
+ .returning();
128
+ if (!row) throw ManabloxError.notFound('asset.notFound', { id });
129
+ return row;
130
+ }
131
+
132
+ async delete(id: string): Promise<AssetRow | null> {
133
+ const [row] = await this.db.delete(assets).where(eq(assets.id, id)).returning();
134
+ return row ?? null;
135
+ }
136
+
137
+ async variants(assetIds: string[]): Promise<AssetVariantRow[]> {
138
+ if (assetIds.length === 0) return [];
139
+ return this.db.select().from(assetVariants).where(inArray(assetVariants.assetId, assetIds));
140
+ }
141
+
142
+ async findVariant(
143
+ assetId: string,
144
+ preset: string,
145
+ format: string,
146
+ ): Promise<AssetVariantRow | null> {
147
+ const rows = await this.db
148
+ .select()
149
+ .from(assetVariants)
150
+ .where(
151
+ and(
152
+ eq(assetVariants.assetId, assetId),
153
+ eq(assetVariants.preset, preset),
154
+ eq(assetVariants.format, format),
155
+ ),
156
+ )
157
+ .limit(1);
158
+ return rows[0] ?? null;
159
+ }
160
+
161
+ async addVariant(data: {
162
+ assetId: string;
163
+ preset: string;
164
+ format: string;
165
+ key: string;
166
+ width?: number | null;
167
+ height?: number | null;
168
+ size: number;
169
+ }): Promise<AssetVariantRow> {
170
+ const [row] = await this.db
171
+ .insert(assetVariants)
172
+ .values({
173
+ assetId: data.assetId,
174
+ preset: data.preset,
175
+ format: data.format,
176
+ key: data.key,
177
+ width: data.width ?? null,
178
+ height: data.height ?? null,
179
+ size: data.size,
180
+ })
181
+ .onConflictDoUpdate({
182
+ target: [assetVariants.assetId, assetVariants.preset, assetVariants.format],
183
+ set: { key: data.key, size: data.size },
184
+ })
185
+ .returning();
186
+ if (!row) throw new ManabloxError('assetVariant.create.failed');
187
+ return row;
188
+ }
189
+ }
@@ -0,0 +1,116 @@
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.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
+ }