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