@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,758 @@
1
+ import { type ContentStatus, type ContentTypeRegistry, ManabloxError } from '@manablox/core';
2
+ import { and, eq, getTableColumns, inArray, type SQL, sql } from 'drizzle-orm';
3
+ import type { Database } from '../client.js';
4
+ import { idToLabel } from '../columns.js';
5
+ import {
6
+ buildContentWhere,
7
+ buildOrderBy,
8
+ type ContentFilter,
9
+ type ContentSort,
10
+ type Pagination,
11
+ } from '../query.js';
12
+ import {
13
+ type ContentRow,
14
+ contents,
15
+ contentVersions,
16
+ publishedContents,
17
+ spaces,
18
+ } from '../schema.js';
19
+
20
+ export type { ContentFilter, ContentSort, Pagination };
21
+
22
+ export interface ContentWriteData {
23
+ id?: string | undefined;
24
+ spaceId: string;
25
+ typeId: string;
26
+ locale: string;
27
+ localizationId?: string | undefined;
28
+ parentId?: string | null | undefined;
29
+ title: string;
30
+ slug: string;
31
+ fields: Record<string, unknown>;
32
+ searchText?: string | undefined;
33
+ visibleInMenu?: boolean | undefined;
34
+ position?: number | undefined;
35
+ status?: ContentStatus | undefined;
36
+ /** Whether the content type contributes its slug to descendants' permalinks. */
37
+ hasSlug: boolean;
38
+ actorId?: string | null | undefined;
39
+ /** Expected current version; a mismatch raises `content.version.conflict`. */
40
+ expectedVersion?: number | undefined;
41
+ }
42
+
43
+ export interface TreeNode {
44
+ content: ContentRow;
45
+ depth: number;
46
+ children: TreeNode[];
47
+ }
48
+
49
+ export interface Paginated<T> {
50
+ items: T[];
51
+ total: number;
52
+ limit: number;
53
+ offset: number;
54
+ }
55
+
56
+ export class ContentRepository {
57
+ constructor(
58
+ private readonly db: Database,
59
+ private readonly registry: ContentTypeRegistry,
60
+ ) {}
61
+
62
+ // -------------------------------------------------------------------------
63
+ // Reads
64
+ // -------------------------------------------------------------------------
65
+
66
+ async findById(id: string, published = false): Promise<ContentRow | null> {
67
+ const table = published ? publishedContents : contents;
68
+ const rows = await this.db.select().from(table).where(eq(table.id, id)).limit(1);
69
+ return (rows[0] as ContentRow | undefined) ?? null;
70
+ }
71
+
72
+ /**
73
+ * `spaceId` bounds a lookup by id to one tenant.
74
+ *
75
+ * The delivery API takes ids straight from the caller, so without it a public instance
76
+ * pinned to one space still answers for any other space's published documents.
77
+ */
78
+ async findManyByIds(
79
+ ids: string[],
80
+ published = false,
81
+ spaceId?: string | null,
82
+ ): Promise<ContentRow[]> {
83
+ if (ids.length === 0) return [];
84
+ const table = published ? publishedContents : contents;
85
+ const where = spaceId
86
+ ? and(inArray(table.id, ids), eq(table.spaceId, spaceId))
87
+ : inArray(table.id, ids);
88
+ return this.db.select().from(table).where(where) as unknown as Promise<ContentRow[]>;
89
+ }
90
+
91
+ /** Children of many parents in one query, for the tree loader. */
92
+ async findChildrenOf(
93
+ parentIds: string[],
94
+ published = false,
95
+ spaceId?: string | null,
96
+ ): Promise<ContentRow[]> {
97
+ if (parentIds.length === 0) return [];
98
+ const table = published ? publishedContents : contents;
99
+ const where = spaceId
100
+ ? and(inArray(table.parentId, parentIds), eq(table.spaceId, spaceId))
101
+ : inArray(table.parentId, parentIds);
102
+ return this.db
103
+ .select()
104
+ .from(table)
105
+ .where(where)
106
+ .orderBy(sql`${table.position} asc, ${table.title} asc`) as unknown as Promise<ContentRow[]>;
107
+ }
108
+
109
+ async findByPermalink(
110
+ spaceId: string,
111
+ locale: string,
112
+ permalink: string,
113
+ published = true,
114
+ ): Promise<ContentRow | null> {
115
+ // The root path names no document of its own; the space says which one it is.
116
+ if (permalink === '') return this.findHome(spaceId, locale, published);
117
+
118
+ const table = published ? publishedContents : contents;
119
+ const rows = await this.db
120
+ .select()
121
+ .from(table)
122
+ .where(
123
+ and(eq(table.spaceId, spaceId), eq(table.locale, locale), eq(table.permalink, permalink)),
124
+ )
125
+ .limit(1);
126
+ return (rows[0] as ContentRow | undefined) ?? null;
127
+ }
128
+
129
+ /**
130
+ * The document a space nominates as its home, in one locale.
131
+ *
132
+ * `settings.homeContentId` names a single row, which belongs to one locale. Every
133
+ * translation of that document shares its `localizationId`, so the requested locale is
134
+ * resolved through that rather than by pinning one row per language.
135
+ */
136
+ async findHome(spaceId: string, locale: string, published = true): Promise<ContentRow | null> {
137
+ const [space] = await this.db
138
+ .select({ settings: spaces.settings })
139
+ .from(spaces)
140
+ .where(eq(spaces.id, spaceId))
141
+ .limit(1);
142
+
143
+ const homeId = space?.settings?.homeContentId;
144
+ if (typeof homeId !== 'string') return null;
145
+
146
+ // Always from `contents`: the nominated row may itself be unpublished, and its
147
+ // localization group is what the lookup below actually needs.
148
+ const [nominated] = await this.db
149
+ .select({ localizationId: contents.localizationId })
150
+ .from(contents)
151
+ .where(and(eq(contents.id, homeId), eq(contents.spaceId, spaceId)))
152
+ .limit(1);
153
+ if (!nominated) return null;
154
+
155
+ const table = published ? publishedContents : contents;
156
+ const rows = await this.db
157
+ .select()
158
+ .from(table)
159
+ .where(
160
+ and(
161
+ eq(table.spaceId, spaceId),
162
+ eq(table.locale, locale),
163
+ eq(table.localizationId, nominated.localizationId),
164
+ ),
165
+ )
166
+ .limit(1);
167
+ return (rows[0] as ContentRow | undefined) ?? null;
168
+ }
169
+
170
+ /**
171
+ * Every other row in a document's localization group — its translations.
172
+ */
173
+ async localizationSiblings(
174
+ spaceId: string,
175
+ localizationId: string,
176
+ excludeId?: string,
177
+ ): Promise<ContentRow[]> {
178
+ const rows = await this.db
179
+ .select()
180
+ .from(contents)
181
+ .where(and(eq(contents.spaceId, spaceId), eq(contents.localizationId, localizationId)));
182
+ const all = rows as unknown as ContentRow[];
183
+ return excludeId ? all.filter((row) => row.id !== excludeId) : all;
184
+ }
185
+
186
+ /**
187
+ * Merges a few field values into rows without touching the rest of the document.
188
+ *
189
+ * Used to carry a non-localized field across a document's translations: a jsonb `||`
190
+ * so concurrent edits to *other* fields on those rows are not clobbered.
191
+ */
192
+ async patchFields(ids: string[], patch: Record<string, unknown>): Promise<void> {
193
+ if (ids.length === 0 || Object.keys(patch).length === 0) return;
194
+ await this.db
195
+ .update(contents)
196
+ .set({
197
+ fields: sql`${contents.fields} || ${JSON.stringify(patch)}::jsonb`,
198
+ updatedAt: new Date(),
199
+ })
200
+ .where(inArray(contents.id, ids));
201
+ }
202
+
203
+ async list(
204
+ filter: ContentFilter,
205
+ pagination: Pagination,
206
+ sorts: ContentSort[] = [],
207
+ published = false,
208
+ ): Promise<Paginated<ContentRow>> {
209
+ const table = published ? publishedContents : contents;
210
+ const where = buildContentWhere(table, filter, this.registry);
211
+
212
+ // A window count rides along with the page, so pagination is one round trip.
213
+ const rows = await this.db
214
+ .select({ ...getTableColumns(table), total: sql<number>`count(*) over ()::int` })
215
+ .from(table)
216
+ .where(where)
217
+ .orderBy(buildOrderBy(sorts))
218
+ .limit(pagination.limit)
219
+ .offset(pagination.offset);
220
+
221
+ const items = rows as Array<ContentRow & { total: number }>;
222
+ return {
223
+ items: items.map(({ total: _total, ...row }) => row as ContentRow),
224
+ total: items[0]?.total ?? 0,
225
+ limit: pagination.limit,
226
+ offset: pagination.offset,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * The whole tree below `rootId` in **one** query: a GiST-indexed `path <@ root`
232
+ * returns every descendant, and `nlevel()` gives the depth to rebuild the hierarchy.
233
+ */
234
+ async tree(
235
+ spaceId: string,
236
+ locale: string,
237
+ rootId: string | null = null,
238
+ maxDepth = 32,
239
+ published = false,
240
+ ): Promise<TreeNode[]> {
241
+ const table = published ? publishedContents : contents;
242
+
243
+ const scope = rootId
244
+ ? sql`and ${table.path} <@ (select path from ${table} where id = ${rootId}::uuid)
245
+ and ${table.id} <> ${rootId}::uuid`
246
+ : sql``;
247
+
248
+ const depthLimit = rootId
249
+ ? sql`and nlevel(${table.path}) <= (select nlevel(path) from ${table} where id = ${rootId}::uuid) + ${maxDepth}`
250
+ : sql`and nlevel(${table.path}) <= ${maxDepth}`;
251
+
252
+ const rows = await this.db
253
+ .select({ ...getTableColumns(table), depth: sql<number>`nlevel(${table.path})::int` })
254
+ .from(table)
255
+ .where(
256
+ sql`${table.spaceId} = ${spaceId}::uuid and ${table.locale} = ${locale} ${scope} ${depthLimit}`,
257
+ )
258
+ .orderBy(sql`nlevel(${table.path}) asc, ${table.position} asc, ${table.title} asc`);
259
+
260
+ return buildTree(rows as Array<ContentRow & { depth: number }>, rootId);
261
+ }
262
+
263
+ /** Ancestors of a node, root first — read straight off the materialised path. */
264
+ async ancestors(id: string, published = false): Promise<ContentRow[]> {
265
+ const table = published ? publishedContents : contents;
266
+ const rows = await this.db
267
+ .select(getTableColumns(table))
268
+ .from(table)
269
+ .where(
270
+ sql`${table.path} @> (select path from ${table} where id = ${id}::uuid)
271
+ and ${table.id} <> ${id}::uuid`,
272
+ )
273
+ .orderBy(sql`nlevel(${table.path}) asc`);
274
+ return rows as ContentRow[];
275
+ }
276
+
277
+ // -------------------------------------------------------------------------
278
+ // Writes
279
+ // -------------------------------------------------------------------------
280
+
281
+ async create(data: ContentWriteData): Promise<ContentRow> {
282
+ return this.db.transaction(async (tx) => {
283
+ const id = data.id ?? crypto.randomUUID();
284
+ const parentPath = await this.parentPath(tx as unknown as Database, data.parentId ?? null);
285
+ const path = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
286
+ const parentPrefix = await this.parentPermalinkPath(
287
+ tx as unknown as Database,
288
+ data.parentId ?? null,
289
+ );
290
+ const segment = data.hasSlug ? data.slug : null;
291
+ const permalinkPath = joinSegment(parentPrefix, segment);
292
+ const permalink = segment === null ? null : permalinkPath;
293
+
294
+ const [row] = await tx
295
+ .insert(contents)
296
+ .values({
297
+ id,
298
+ spaceId: data.spaceId,
299
+ typeId: data.typeId,
300
+ locale: data.locale,
301
+ localizationId: data.localizationId ?? crypto.randomUUID(),
302
+ parentId: data.parentId ?? null,
303
+ title: data.title,
304
+ slug: data.slug,
305
+ path,
306
+ permalink,
307
+ permalinkPath,
308
+ permalinkSegment: segment,
309
+ status: data.status ?? 'draft',
310
+ visibleInMenu: data.visibleInMenu ?? false,
311
+ position: data.position ?? 0,
312
+ fields: data.fields,
313
+ searchText: data.searchText ?? '',
314
+ version: 1,
315
+ createdBy: data.actorId ?? null,
316
+ updatedBy: data.actorId ?? null,
317
+ })
318
+ .returning();
319
+
320
+ if (!row) throw new ManabloxError('content.create.failed');
321
+ await this.snapshot(tx as unknown as Database, row, data.actorId ?? null);
322
+ return row as ContentRow;
323
+ });
324
+ }
325
+
326
+ async update(id: string, data: ContentWriteData): Promise<ContentRow> {
327
+ return this.db.transaction(async (tx) => {
328
+ const current = await this.lockRow(tx as unknown as Database, id);
329
+
330
+ if (data.expectedVersion !== undefined && data.expectedVersion !== current.version) {
331
+ throw ManabloxError.conflict('content.version.conflict', {
332
+ expected: data.expectedVersion,
333
+ actual: current.version,
334
+ });
335
+ }
336
+
337
+ const parentChanged = (data.parentId ?? null) !== current.parentId;
338
+ const segment = data.hasSlug ? data.slug : null;
339
+ const segmentChanged = segment !== current.permalinkSegment;
340
+
341
+ if (parentChanged) {
342
+ await this.assertNotOwnDescendant(tx as unknown as Database, id, data.parentId ?? null);
343
+ }
344
+
345
+ const parentPath = await this.parentPath(tx as unknown as Database, data.parentId ?? null);
346
+ const newPath = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
347
+
348
+ const [row] = await tx
349
+ .update(contents)
350
+ .set({
351
+ typeId: data.typeId,
352
+ locale: data.locale,
353
+ parentId: data.parentId ?? null,
354
+ title: data.title,
355
+ slug: data.slug,
356
+ path: newPath,
357
+ permalinkSegment: segment,
358
+ fields: data.fields,
359
+ searchText: data.searchText ?? '',
360
+ visibleInMenu: data.visibleInMenu ?? current.visibleInMenu,
361
+ position: data.position ?? current.position,
362
+ version: current.version + 1,
363
+ updatedAt: new Date(),
364
+ updatedBy: data.actorId ?? null,
365
+ })
366
+ .where(eq(contents.id, id))
367
+ .returning();
368
+
369
+ if (!row) throw ManabloxError.notFound('content.notFound', { id });
370
+
371
+ if (parentChanged) {
372
+ await this.moveSubtree(tx as unknown as Database, id, current.path, newPath);
373
+ }
374
+
375
+ if (parentChanged || segmentChanged) {
376
+ await this.recomputePermalinks(tx as unknown as Database, contents, id);
377
+ }
378
+
379
+ const fresh = (await this.findRow(tx as unknown as Database, id)) ?? (row as ContentRow);
380
+ await this.snapshot(tx as unknown as Database, fresh, data.actorId ?? null);
381
+ return fresh;
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Reparents a subtree with one statement. `subpath(path, nlevel(:oldPath))` is the part
387
+ * of each descendant's path *below* the moved node; prefixing it with the node's new
388
+ * path rebases the whole subtree.
389
+ */
390
+ /**
391
+ * Reparents and reorders a node in one transaction.
392
+ *
393
+ * Separate from `update` because a drag is a structural change, not an edit: it writes
394
+ * no field values, takes no version bump and records no snapshot, so an editor open on
395
+ * the document does not hit a version conflict because someone reordered the tree.
396
+ *
397
+ * `position` is the index among the destination's children, clamped to the ends.
398
+ * Siblings on both sides are renumbered densely afterwards, so positions never drift
399
+ * into ties that the tree's `position asc, title asc` ordering would resolve by name.
400
+ */
401
+ async move(id: string, parentId: string | null, position: number): Promise<ContentRow> {
402
+ return this.db.transaction(async (tx) => {
403
+ const db = tx as unknown as Database;
404
+ const current = await this.findRow(db, id);
405
+ if (!current) throw ManabloxError.notFound('content.notFound', { id });
406
+
407
+ await this.assertNotOwnDescendant(db, id, parentId);
408
+
409
+ const parentPath = await this.parentPath(db, parentId);
410
+ const newPath = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
411
+ const parentChanged = parentId !== current.parentId;
412
+
413
+ // Ordered as the tree renders them, minus the node being moved, so the requested
414
+ // index refers to the list the user actually saw.
415
+ const siblings = (await db
416
+ .select({ id: contents.id })
417
+ .from(contents)
418
+ .where(
419
+ and(
420
+ eq(contents.spaceId, current.spaceId),
421
+ eq(contents.locale, current.locale),
422
+ parentId === null ? sql`${contents.parentId} is null` : eq(contents.parentId, parentId),
423
+ ),
424
+ )
425
+ .orderBy(sql`${contents.position} asc, ${contents.title} asc`)) as { id: string }[];
426
+
427
+ const order = siblings.map((row) => row.id).filter((sibling) => sibling !== id);
428
+ const index = Math.max(0, Math.min(position, order.length));
429
+ order.splice(index, 0, id);
430
+
431
+ await tx
432
+ .update(contents)
433
+ .set({ parentId, path: newPath, updatedAt: new Date() })
434
+ .where(eq(contents.id, id));
435
+
436
+ for (const [at, sibling] of order.entries()) {
437
+ await tx.update(contents).set({ position: at }).where(eq(contents.id, sibling));
438
+ }
439
+
440
+ if (parentChanged) {
441
+ await this.moveSubtree(db, id, current.path, newPath);
442
+ await this.recomputePermalinks(db, contents, id);
443
+
444
+ // The node left a list; close the gap it left behind.
445
+ const former = (await db
446
+ .select({ id: contents.id })
447
+ .from(contents)
448
+ .where(
449
+ and(
450
+ eq(contents.spaceId, current.spaceId),
451
+ eq(contents.locale, current.locale),
452
+ current.parentId === null
453
+ ? sql`${contents.parentId} is null`
454
+ : eq(contents.parentId, current.parentId),
455
+ ),
456
+ )
457
+ .orderBy(sql`${contents.position} asc, ${contents.title} asc`)) as { id: string }[];
458
+ for (const [at, sibling] of former.entries()) {
459
+ await tx.update(contents).set({ position: at }).where(eq(contents.id, sibling.id));
460
+ }
461
+ }
462
+
463
+ const fresh = await this.findRow(db, id);
464
+ if (!fresh) throw ManabloxError.notFound('content.notFound', { id });
465
+ return fresh;
466
+ });
467
+ }
468
+
469
+ private async moveSubtree(
470
+ db: Database,
471
+ id: string,
472
+ oldPath: string,
473
+ newPath: string,
474
+ ): Promise<void> {
475
+ await db.execute(sql`
476
+ update contents
477
+ set path = ${newPath}::ltree || subpath(path, nlevel(${oldPath}::ltree)),
478
+ updated_at = now()
479
+ where path <@ ${oldPath}::ltree and id <> ${id}::uuid
480
+ `);
481
+ }
482
+
483
+ /**
484
+ * Recomputes permalinks for a node and everything beneath it in one recursive CTE.
485
+ * Each level derives from *its own* parent's freshly computed value (`t.pl`), and
486
+ * `concat_ws` drops NULL segments so a type without a slug is transparent in the path.
487
+ */
488
+ private async recomputePermalinks(
489
+ db: Database,
490
+ table: typeof contents | typeof publishedContents,
491
+ rootId: string,
492
+ ): Promise<void> {
493
+ const tableName = table === contents ? sql`contents` : sql`published_contents`;
494
+ await db.execute(sql`
495
+ with recursive t as (
496
+ select c.id,
497
+ c.permalink_segment,
498
+ concat_ws('/',
499
+ nullif(coalesce(
500
+ (select p.permalink_path from ${tableName} p where p.id = c.parent_id), ''), ''),
501
+ c.permalink_segment
502
+ ) as prefix
503
+ from ${tableName} c
504
+ where c.id = ${rootId}::uuid
505
+
506
+ union all
507
+
508
+ select ch.id,
509
+ ch.permalink_segment,
510
+ concat_ws('/', nullif(t.prefix, ''), ch.permalink_segment) as prefix
511
+ from ${tableName} ch
512
+ join t on ch.parent_id = t.id
513
+ )
514
+ update ${tableName} target
515
+ set permalink_path = t.prefix,
516
+ permalink = case when t.permalink_segment is null then null else nullif(t.prefix, '') end,
517
+ updated_at = now()
518
+ from t
519
+ where target.id = t.id
520
+ and (target.permalink_path is distinct from t.prefix
521
+ or target.permalink is distinct from
522
+ (case when t.permalink_segment is null then null else nullif(t.prefix, '') end))
523
+ `);
524
+ }
525
+
526
+ /** Deletes a node and its whole subtree, in both the draft and published tables. */
527
+ async delete(id: string): Promise<number> {
528
+ return this.db.transaction(async (tx) => {
529
+ const current = await this.findRow(tx as unknown as Database, id);
530
+ if (!current) throw ManabloxError.notFound('content.notFound', { id });
531
+
532
+ await tx.execute(sql`
533
+ delete from published_contents
534
+ where path <@ (select path from contents where id = ${id}::uuid)
535
+ `);
536
+
537
+ const deleted = await tx
538
+ .delete(contents)
539
+ .where(sql`${contents.path} <@ ${current.path}::ltree`)
540
+ .returning({ id: contents.id });
541
+
542
+ return deleted.length;
543
+ });
544
+ }
545
+
546
+ // -------------------------------------------------------------------------
547
+ // Publishing
548
+ // -------------------------------------------------------------------------
549
+
550
+ /**
551
+ * Copies a draft into the delivery projection inside one transaction, so a reader
552
+ * never observes a partially published tree.
553
+ */
554
+ async publish(id: string, actorId: string | null = null): Promise<ContentRow> {
555
+ return this.db.transaction(async (tx) => {
556
+ const row = await this.findRow(tx as unknown as Database, id);
557
+ if (!row) throw ManabloxError.notFound('content.notFound', { id });
558
+
559
+ const publishedAt = new Date();
560
+ // `db.execute` binds parameters through postgres.js's unsafe path, which has no
561
+ // Date serialiser — pass an ISO string and let Postgres cast it.
562
+ const publishedAtIso = publishedAt.toISOString();
563
+
564
+ await tx.execute(sql`
565
+ insert into published_contents (
566
+ id, space_id, type_id, locale, localization_id, parent_id, title, slug, path,
567
+ permalink, permalink_path, permalink_segment, status, visible_in_menu, position, fields,
568
+ search_text, version, source_version, created_at, updated_at, created_by,
569
+ updated_by, published_at
570
+ )
571
+ select id, space_id, type_id, locale, localization_id, parent_id, title, slug, path,
572
+ permalink, permalink_path, permalink_segment, 'published', visible_in_menu, position, fields,
573
+ search_text, version, version, created_at, now(), created_by,
574
+ ${actorId}::uuid, ${publishedAtIso}::timestamptz
575
+ from contents where id = ${id}::uuid
576
+ on conflict (id) do update set
577
+ type_id = excluded.type_id,
578
+ locale = excluded.locale,
579
+ parent_id = excluded.parent_id,
580
+ title = excluded.title,
581
+ slug = excluded.slug,
582
+ path = excluded.path,
583
+ permalink = excluded.permalink,
584
+ permalink_path = excluded.permalink_path,
585
+ permalink_segment = excluded.permalink_segment,
586
+ status = 'published',
587
+ visible_in_menu = excluded.visible_in_menu,
588
+ position = excluded.position,
589
+ fields = excluded.fields,
590
+ search_text = excluded.search_text,
591
+ version = excluded.version,
592
+ source_version = excluded.version,
593
+ updated_at = now(),
594
+ updated_by = excluded.updated_by,
595
+ published_at = excluded.published_at
596
+ `);
597
+
598
+ // Descendants already live in the projection may sit under a stale permalink.
599
+ await this.recomputePermalinks(tx as unknown as Database, publishedContents, id);
600
+
601
+ const [updated] = await tx
602
+ .update(contents)
603
+ .set({ status: 'published', publishedAt, updatedBy: actorId })
604
+ .where(eq(contents.id, id))
605
+ .returning();
606
+
607
+ return updated as ContentRow;
608
+ });
609
+ }
610
+
611
+ async unpublish(id: string): Promise<void> {
612
+ await this.db.transaction(async (tx) => {
613
+ await tx.execute(sql`
614
+ delete from published_contents
615
+ where path <@ (select path from contents where id = ${id}::uuid)
616
+ `);
617
+ await tx
618
+ .update(contents)
619
+ .set({ status: 'draft', publishedAt: null })
620
+ .where(eq(contents.id, id));
621
+ });
622
+ }
623
+
624
+ // -------------------------------------------------------------------------
625
+ // Versions
626
+ // -------------------------------------------------------------------------
627
+
628
+ async versions(
629
+ contentId: string,
630
+ limit = 50,
631
+ ): Promise<
632
+ Array<{ version: number; createdAt: Date; createdBy: string | null; label: string | null }>
633
+ > {
634
+ const rows = await this.db
635
+ .select({
636
+ version: contentVersions.version,
637
+ createdAt: contentVersions.createdAt,
638
+ createdBy: contentVersions.createdBy,
639
+ label: contentVersions.label,
640
+ })
641
+ .from(contentVersions)
642
+ .where(eq(contentVersions.contentId, contentId))
643
+ .orderBy(sql`version desc`)
644
+ .limit(limit);
645
+ return rows;
646
+ }
647
+
648
+ async versionSnapshot(
649
+ contentId: string,
650
+ version: number,
651
+ ): Promise<Record<string, unknown> | null> {
652
+ const rows = await this.db
653
+ .select({ snapshot: contentVersions.snapshot })
654
+ .from(contentVersions)
655
+ .where(and(eq(contentVersions.contentId, contentId), eq(contentVersions.version, version)))
656
+ .limit(1);
657
+ return rows[0]?.snapshot ?? null;
658
+ }
659
+
660
+ private async snapshot(db: Database, row: ContentRow, actorId: string | null): Promise<void> {
661
+ await db
662
+ .insert(contentVersions)
663
+ .values({
664
+ contentId: row.id,
665
+ version: row.version,
666
+ snapshot: row as unknown as Record<string, unknown>,
667
+ createdBy: actorId,
668
+ })
669
+ .onConflictDoNothing();
670
+ }
671
+
672
+ // -------------------------------------------------------------------------
673
+ // Internals
674
+ // -------------------------------------------------------------------------
675
+
676
+ private async findRow(db: Database, id: string): Promise<ContentRow | null> {
677
+ const rows = await db.select().from(contents).where(eq(contents.id, id)).limit(1);
678
+ return (rows[0] as ContentRow | undefined) ?? null;
679
+ }
680
+
681
+ private async lockRow(db: Database, id: string): Promise<ContentRow> {
682
+ const rows = await db.select().from(contents).where(eq(contents.id, id)).limit(1).for('update');
683
+ const row = rows[0] as ContentRow | undefined;
684
+ if (!row) throw ManabloxError.notFound('content.notFound', { id });
685
+ return row;
686
+ }
687
+
688
+ private async parentPath(db: Database, parentId: string | null): Promise<string | null> {
689
+ if (!parentId) return null;
690
+ const rows = await db
691
+ .select({ path: contents.path })
692
+ .from(contents)
693
+ .where(eq(contents.id, parentId))
694
+ .limit(1);
695
+ const path = rows[0]?.path;
696
+ if (!path) throw ManabloxError.notFound('content.parent.notFound', { id: parentId });
697
+ return path;
698
+ }
699
+
700
+ private async parentPermalinkPath(db: Database, parentId: string | null): Promise<string> {
701
+ if (!parentId) return '';
702
+ const rows = await db
703
+ .select({ permalinkPath: contents.permalinkPath })
704
+ .from(contents)
705
+ .where(eq(contents.id, parentId))
706
+ .limit(1);
707
+ return rows[0]?.permalinkPath ?? '';
708
+ }
709
+
710
+ /** Guards against making a node its own ancestor, which would orphan the subtree. */
711
+ private async assertNotOwnDescendant(
712
+ db: Database,
713
+ id: string,
714
+ parentId: string | null,
715
+ ): Promise<void> {
716
+ if (!parentId) return;
717
+ if (parentId === id) throw ManabloxError.badRequest('content.parent.self');
718
+
719
+ const rows = await db
720
+ .select({
721
+ cycle: sql<boolean>`exists (
722
+ select 1 from contents p
723
+ where p.id = ${parentId}::uuid
724
+ and p.path <@ (select path from contents where id = ${id}::uuid)
725
+ )`,
726
+ })
727
+ .from(sql`(select 1) as _`);
728
+
729
+ if (rows[0]?.cycle) throw ManabloxError.badRequest('content.parent.cycle', { id, parentId });
730
+ }
731
+ }
732
+
733
+ export function buildTree(
734
+ rows: Array<ContentRow & { depth: number }>,
735
+ rootId: string | null,
736
+ ): TreeNode[] {
737
+ const nodes = new Map<string, TreeNode>();
738
+ for (const row of rows) {
739
+ const { depth, ...content } = row;
740
+ nodes.set(row.id, { content: content as ContentRow, depth, children: [] });
741
+ }
742
+
743
+ const roots: TreeNode[] = [];
744
+ for (const node of nodes.values()) {
745
+ const parentId = node.content.parentId;
746
+ const parent = parentId ? nodes.get(parentId) : undefined;
747
+ if (parent && parentId !== rootId) parent.children.push(node);
748
+ else if (parentId === rootId || !parent) roots.push(node);
749
+ }
750
+ return roots;
751
+ }
752
+
753
+ function joinSegment(prefix: string, segment: string | null): string {
754
+ if (segment === null) return prefix;
755
+ return prefix ? `${prefix}/${segment}` : segment;
756
+ }
757
+
758
+ export type { SQL };