@manablox/db 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/drizzle.config.ts +1 -1
- package/migrations/0005_menus.sql +44 -0
- package/migrations/0006_roles.sql +13 -0
- package/migrations/0007_apikey-permissions.sql +4 -0
- package/migrations/0008_workflows.sql +49 -0
- package/migrations/meta/0005_snapshot.json +2504 -0
- package/migrations/meta/0006_snapshot.json +2605 -0
- package/migrations/meta/0007_snapshot.json +2605 -0
- package/migrations/meta/0008_snapshot.json +2986 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +10 -5
- package/src/cli/create-db.ts +30 -0
- package/src/cli/migrate.ts +2 -9
- package/src/client.ts +8 -1
- package/src/errors.ts +50 -0
- package/src/index.ts +8 -2
- package/src/migrate.ts +21 -0
- package/src/pagination.ts +52 -0
- package/src/query.ts +1 -5
- package/src/repositories/asset-usage.ts +1 -1
- package/src/repositories/asset.ts +13 -21
- package/src/repositories/content-type.ts +1 -1
- package/src/repositories/content.ts +150 -97
- package/src/repositories/index.ts +12 -0
- package/src/repositories/menu.ts +235 -0
- package/src/repositories/role.ts +85 -0
- package/src/repositories/space.ts +7 -2
- package/src/repositories/user.ts +171 -25
- package/src/repositories/webhook.ts +46 -0
- package/src/repositories/workflow.ts +306 -0
- package/src/schema/assets.ts +108 -0
- package/src/schema/auth.ts +166 -0
- package/src/schema/content-types.ts +31 -0
- package/src/schema/content.ts +133 -0
- package/src/schema/index.ts +38 -0
- package/src/schema/menus.ts +61 -0
- package/src/schema/relations.ts +64 -0
- package/src/schema/spaces.ts +20 -0
- package/src/schema/webhooks.ts +46 -0
- package/src/schema/workflows.ts +92 -0
- package/{test/helpers.ts → src/testing-fixtures.ts} +21 -35
- package/src/testing.ts +105 -0
- package/test/asset-usage.test.ts +3 -3
- package/test/menu.test.ts +126 -0
- package/test/publish.test.ts +31 -3
- package/test/query.test.ts +33 -3
- package/test/role.test.ts +81 -0
- package/test/tree.test.ts +3 -3
- package/test/user.test.ts +126 -0
- package/test/webhook.test.ts +48 -0
- package/vitest.config.ts +0 -2
- package/src/schema.ts +0 -513
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ContentStatus, type ContentTypeRegistry, ManabloxError } from '@manablox/core';
|
|
2
2
|
import { and, eq, getTableColumns, inArray, type SQL, sql } from 'drizzle-orm';
|
|
3
|
-
import type {
|
|
3
|
+
import type { Executor } from '../client.js';
|
|
4
4
|
import { idToLabel } from '../columns.js';
|
|
5
|
+
import { type Paginated, paginate } from '../pagination.js';
|
|
5
6
|
import {
|
|
6
7
|
buildContentWhere,
|
|
7
8
|
buildOrderBy,
|
|
@@ -15,9 +16,9 @@ import {
|
|
|
15
16
|
contentVersions,
|
|
16
17
|
publishedContents,
|
|
17
18
|
spaces,
|
|
18
|
-
} from '../schema.js';
|
|
19
|
+
} from '../schema/index.js';
|
|
19
20
|
|
|
20
|
-
export type { ContentFilter, ContentSort, Pagination };
|
|
21
|
+
export type { ContentFilter, ContentSort, Paginated, Pagination };
|
|
21
22
|
|
|
22
23
|
export interface ContentWriteData {
|
|
23
24
|
id?: string | undefined;
|
|
@@ -30,7 +31,6 @@ export interface ContentWriteData {
|
|
|
30
31
|
slug: string;
|
|
31
32
|
fields: Record<string, unknown>;
|
|
32
33
|
searchText?: string | undefined;
|
|
33
|
-
visibleInMenu?: boolean | undefined;
|
|
34
34
|
position?: number | undefined;
|
|
35
35
|
status?: ContentStatus | undefined;
|
|
36
36
|
/** Whether the content type contributes its slug to descendants' permalinks. */
|
|
@@ -46,16 +46,40 @@ export interface TreeNode {
|
|
|
46
46
|
children: TreeNode[];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
]);
|
|
55
79
|
|
|
56
80
|
export class ContentRepository {
|
|
57
81
|
constructor(
|
|
58
|
-
private readonly db:
|
|
82
|
+
private readonly db: Executor,
|
|
59
83
|
private readonly registry: ContentTypeRegistry,
|
|
60
84
|
) {}
|
|
61
85
|
|
|
@@ -170,6 +194,26 @@ export class ContentRepository {
|
|
|
170
194
|
/**
|
|
171
195
|
* Every other row in a document's localization group — its translations.
|
|
172
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
|
+
|
|
173
217
|
async localizationSiblings(
|
|
174
218
|
spaceId: string,
|
|
175
219
|
localizationId: string,
|
|
@@ -208,23 +252,12 @@ export class ContentRepository {
|
|
|
208
252
|
): Promise<Paginated<ContentRow>> {
|
|
209
253
|
const table = published ? publishedContents : contents;
|
|
210
254
|
const where = buildContentWhere(table, filter, this.registry);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
};
|
|
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>;
|
|
228
261
|
}
|
|
229
262
|
|
|
230
263
|
/**
|
|
@@ -281,12 +314,9 @@ export class ContentRepository {
|
|
|
281
314
|
async create(data: ContentWriteData): Promise<ContentRow> {
|
|
282
315
|
return this.db.transaction(async (tx) => {
|
|
283
316
|
const id = data.id ?? crypto.randomUUID();
|
|
284
|
-
const parentPath = await this.parentPath(tx
|
|
317
|
+
const parentPath = await this.parentPath(tx, data.parentId ?? null);
|
|
285
318
|
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
|
-
);
|
|
319
|
+
const parentPrefix = await this.parentPermalinkPath(tx, data.parentId ?? null);
|
|
290
320
|
const segment = data.hasSlug ? data.slug : null;
|
|
291
321
|
const permalinkPath = joinSegment(parentPrefix, segment);
|
|
292
322
|
const permalink = segment === null ? null : permalinkPath;
|
|
@@ -307,7 +337,6 @@ export class ContentRepository {
|
|
|
307
337
|
permalinkPath,
|
|
308
338
|
permalinkSegment: segment,
|
|
309
339
|
status: data.status ?? 'draft',
|
|
310
|
-
visibleInMenu: data.visibleInMenu ?? false,
|
|
311
340
|
position: data.position ?? 0,
|
|
312
341
|
fields: data.fields,
|
|
313
342
|
searchText: data.searchText ?? '',
|
|
@@ -318,14 +347,14 @@ export class ContentRepository {
|
|
|
318
347
|
.returning();
|
|
319
348
|
|
|
320
349
|
if (!row) throw new ManabloxError('content.create.failed');
|
|
321
|
-
await this.snapshot(tx
|
|
350
|
+
await this.snapshot(tx, row, data.actorId ?? null);
|
|
322
351
|
return row as ContentRow;
|
|
323
352
|
});
|
|
324
353
|
}
|
|
325
354
|
|
|
326
355
|
async update(id: string, data: ContentWriteData): Promise<ContentRow> {
|
|
327
356
|
return this.db.transaction(async (tx) => {
|
|
328
|
-
const current = await this.lockRow(tx
|
|
357
|
+
const current = await this.lockRow(tx, id);
|
|
329
358
|
|
|
330
359
|
if (data.expectedVersion !== undefined && data.expectedVersion !== current.version) {
|
|
331
360
|
throw ManabloxError.conflict('content.version.conflict', {
|
|
@@ -339,10 +368,10 @@ export class ContentRepository {
|
|
|
339
368
|
const segmentChanged = segment !== current.permalinkSegment;
|
|
340
369
|
|
|
341
370
|
if (parentChanged) {
|
|
342
|
-
await this.assertNotOwnDescendant(tx
|
|
371
|
+
await this.assertNotOwnDescendant(tx, id, data.parentId ?? null);
|
|
343
372
|
}
|
|
344
373
|
|
|
345
|
-
const parentPath = await this.parentPath(tx
|
|
374
|
+
const parentPath = await this.parentPath(tx, data.parentId ?? null);
|
|
346
375
|
const newPath = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
|
|
347
376
|
|
|
348
377
|
const [row] = await tx
|
|
@@ -357,7 +386,6 @@ export class ContentRepository {
|
|
|
357
386
|
permalinkSegment: segment,
|
|
358
387
|
fields: data.fields,
|
|
359
388
|
searchText: data.searchText ?? '',
|
|
360
|
-
visibleInMenu: data.visibleInMenu ?? current.visibleInMenu,
|
|
361
389
|
position: data.position ?? current.position,
|
|
362
390
|
version: current.version + 1,
|
|
363
391
|
updatedAt: new Date(),
|
|
@@ -369,15 +397,15 @@ export class ContentRepository {
|
|
|
369
397
|
if (!row) throw ManabloxError.notFound('content.notFound', { id });
|
|
370
398
|
|
|
371
399
|
if (parentChanged) {
|
|
372
|
-
await this.moveSubtree(tx
|
|
400
|
+
await this.moveSubtree(tx, id, current.path, newPath);
|
|
373
401
|
}
|
|
374
402
|
|
|
375
403
|
if (parentChanged || segmentChanged) {
|
|
376
|
-
await this.recomputePermalinks(tx
|
|
404
|
+
await this.recomputePermalinks(tx, contents, id);
|
|
377
405
|
}
|
|
378
406
|
|
|
379
|
-
const fresh = (await this.findRow(tx
|
|
380
|
-
await this.snapshot(tx
|
|
407
|
+
const fresh = (await this.findRow(tx, id)) ?? (row as ContentRow);
|
|
408
|
+
await this.snapshot(tx, fresh, data.actorId ?? null);
|
|
381
409
|
return fresh;
|
|
382
410
|
});
|
|
383
411
|
}
|
|
@@ -400,7 +428,7 @@ export class ContentRepository {
|
|
|
400
428
|
*/
|
|
401
429
|
async move(id: string, parentId: string | null, position: number): Promise<ContentRow> {
|
|
402
430
|
return this.db.transaction(async (tx) => {
|
|
403
|
-
const db = tx
|
|
431
|
+
const db = tx;
|
|
404
432
|
const current = await this.findRow(db, id);
|
|
405
433
|
if (!current) throw ManabloxError.notFound('content.notFound', { id });
|
|
406
434
|
|
|
@@ -433,9 +461,7 @@ export class ContentRepository {
|
|
|
433
461
|
.set({ parentId, path: newPath, updatedAt: new Date() })
|
|
434
462
|
.where(eq(contents.id, id));
|
|
435
463
|
|
|
436
|
-
|
|
437
|
-
await tx.update(contents).set({ position: at }).where(eq(contents.id, sibling));
|
|
438
|
-
}
|
|
464
|
+
await this.renumber(db, order);
|
|
439
465
|
|
|
440
466
|
if (parentChanged) {
|
|
441
467
|
await this.moveSubtree(db, id, current.path, newPath);
|
|
@@ -455,9 +481,10 @@ export class ContentRepository {
|
|
|
455
481
|
),
|
|
456
482
|
)
|
|
457
483
|
.orderBy(sql`${contents.position} asc, ${contents.title} asc`)) as { id: string }[];
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
484
|
+
await this.renumber(
|
|
485
|
+
db,
|
|
486
|
+
former.map((row) => row.id),
|
|
487
|
+
);
|
|
461
488
|
}
|
|
462
489
|
|
|
463
490
|
const fresh = await this.findRow(db, id);
|
|
@@ -466,8 +493,32 @@ export class ContentRepository {
|
|
|
466
493
|
});
|
|
467
494
|
}
|
|
468
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
|
+
|
|
469
520
|
private async moveSubtree(
|
|
470
|
-
db:
|
|
521
|
+
db: Executor,
|
|
471
522
|
id: string,
|
|
472
523
|
oldPath: string,
|
|
473
524
|
newPath: string,
|
|
@@ -486,7 +537,7 @@ export class ContentRepository {
|
|
|
486
537
|
* `concat_ws` drops NULL segments so a type without a slug is transparent in the path.
|
|
487
538
|
*/
|
|
488
539
|
private async recomputePermalinks(
|
|
489
|
-
db:
|
|
540
|
+
db: Executor,
|
|
490
541
|
table: typeof contents | typeof publishedContents,
|
|
491
542
|
rootId: string,
|
|
492
543
|
): Promise<void> {
|
|
@@ -526,7 +577,7 @@ export class ContentRepository {
|
|
|
526
577
|
/** Deletes a node and its whole subtree, in both the draft and published tables. */
|
|
527
578
|
async delete(id: string): Promise<number> {
|
|
528
579
|
return this.db.transaction(async (tx) => {
|
|
529
|
-
const current = await this.findRow(tx
|
|
580
|
+
const current = await this.findRow(tx, id);
|
|
530
581
|
if (!current) throw ManabloxError.notFound('content.notFound', { id });
|
|
531
582
|
|
|
532
583
|
await tx.execute(sql`
|
|
@@ -553,50 +604,54 @@ export class ContentRepository {
|
|
|
553
604
|
*/
|
|
554
605
|
async publish(id: string, actorId: string | null = null): Promise<ContentRow> {
|
|
555
606
|
return this.db.transaction(async (tx) => {
|
|
556
|
-
const row = await this.findRow(tx
|
|
607
|
+
const row = await this.findRow(tx, id);
|
|
557
608
|
if (!row) throw ManabloxError.notFound('content.notFound', { id });
|
|
558
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
|
+
|
|
559
621
|
const publishedAt = new Date();
|
|
560
622
|
// `db.execute` binds parameters through postgres.js's unsafe path, which has no
|
|
561
623
|
// Date serialiser — pass an ISO string and let Postgres cast it.
|
|
562
624
|
const publishedAtIso = publishedAt.toISOString();
|
|
563
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
|
+
|
|
564
641
|
await tx.execute(sql`
|
|
565
|
-
insert into published_contents (
|
|
566
|
-
|
|
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
|
|
642
|
+
insert into published_contents (${sql.join(columns, sql`, `)})
|
|
643
|
+
select ${sql.join(values, sql`, `)}
|
|
575
644
|
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
|
|
645
|
+
on conflict (id) do update set ${sql.join(updates, sql`, `)}
|
|
596
646
|
`);
|
|
597
647
|
|
|
598
|
-
// Descendants already
|
|
599
|
-
|
|
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
|
+
}
|
|
600
655
|
|
|
601
656
|
const [updated] = await tx
|
|
602
657
|
.update(contents)
|
|
@@ -645,19 +700,17 @@ export class ContentRepository {
|
|
|
645
700
|
return rows;
|
|
646
701
|
}
|
|
647
702
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
version: number,
|
|
651
|
-
): Promise<Record<string, unknown> | null> {
|
|
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> {
|
|
652
705
|
const rows = await this.db
|
|
653
706
|
.select({ snapshot: contentVersions.snapshot })
|
|
654
707
|
.from(contentVersions)
|
|
655
708
|
.where(and(eq(contentVersions.contentId, contentId), eq(contentVersions.version, version)))
|
|
656
709
|
.limit(1);
|
|
657
|
-
return rows[0]?.snapshot ?? null;
|
|
710
|
+
return (rows[0]?.snapshot as ContentRow | undefined) ?? null;
|
|
658
711
|
}
|
|
659
712
|
|
|
660
|
-
private async snapshot(db:
|
|
713
|
+
private async snapshot(db: Executor, row: ContentRow, actorId: string | null): Promise<void> {
|
|
661
714
|
await db
|
|
662
715
|
.insert(contentVersions)
|
|
663
716
|
.values({
|
|
@@ -673,19 +726,19 @@ export class ContentRepository {
|
|
|
673
726
|
// Internals
|
|
674
727
|
// -------------------------------------------------------------------------
|
|
675
728
|
|
|
676
|
-
private async findRow(db:
|
|
729
|
+
private async findRow(db: Executor, id: string): Promise<ContentRow | null> {
|
|
677
730
|
const rows = await db.select().from(contents).where(eq(contents.id, id)).limit(1);
|
|
678
731
|
return (rows[0] as ContentRow | undefined) ?? null;
|
|
679
732
|
}
|
|
680
733
|
|
|
681
|
-
private async lockRow(db:
|
|
734
|
+
private async lockRow(db: Executor, id: string): Promise<ContentRow> {
|
|
682
735
|
const rows = await db.select().from(contents).where(eq(contents.id, id)).limit(1).for('update');
|
|
683
736
|
const row = rows[0] as ContentRow | undefined;
|
|
684
737
|
if (!row) throw ManabloxError.notFound('content.notFound', { id });
|
|
685
738
|
return row;
|
|
686
739
|
}
|
|
687
740
|
|
|
688
|
-
private async parentPath(db:
|
|
741
|
+
private async parentPath(db: Executor, parentId: string | null): Promise<string | null> {
|
|
689
742
|
if (!parentId) return null;
|
|
690
743
|
const rows = await db
|
|
691
744
|
.select({ path: contents.path })
|
|
@@ -697,7 +750,7 @@ export class ContentRepository {
|
|
|
697
750
|
return path;
|
|
698
751
|
}
|
|
699
752
|
|
|
700
|
-
private async parentPermalinkPath(db:
|
|
753
|
+
private async parentPermalinkPath(db: Executor, parentId: string | null): Promise<string> {
|
|
701
754
|
if (!parentId) return '';
|
|
702
755
|
const rows = await db
|
|
703
756
|
.select({ permalinkPath: contents.permalinkPath })
|
|
@@ -709,7 +762,7 @@ export class ContentRepository {
|
|
|
709
762
|
|
|
710
763
|
/** Guards against making a node its own ancestor, which would orphan the subtree. */
|
|
711
764
|
private async assertNotOwnDescendant(
|
|
712
|
-
db:
|
|
765
|
+
db: Executor,
|
|
713
766
|
id: string,
|
|
714
767
|
parentId: string | null,
|
|
715
768
|
): Promise<void> {
|
|
@@ -4,8 +4,12 @@ import { AssetRepository } from './asset.js';
|
|
|
4
4
|
import { AssetUsageRepository } from './asset-usage.js';
|
|
5
5
|
import { ContentRepository } from './content.js';
|
|
6
6
|
import { ContentTypeRepository } from './content-type.js';
|
|
7
|
+
import { MenuRepository } from './menu.js';
|
|
8
|
+
import { RoleRepository } from './role.js';
|
|
7
9
|
import { SpaceRepository } from './space.js';
|
|
8
10
|
import { UserRepository } from './user.js';
|
|
11
|
+
import { WebhookRepository } from './webhook.js';
|
|
12
|
+
import { WorkflowRepository } from './workflow.js';
|
|
9
13
|
|
|
10
14
|
export interface Repositories {
|
|
11
15
|
content: ContentRepository;
|
|
@@ -14,6 +18,10 @@ export interface Repositories {
|
|
|
14
18
|
assets: AssetRepository;
|
|
15
19
|
assetUsages: AssetUsageRepository;
|
|
16
20
|
users: UserRepository;
|
|
21
|
+
menus: MenuRepository;
|
|
22
|
+
roles: RoleRepository;
|
|
23
|
+
workflows: WorkflowRepository;
|
|
24
|
+
webhooks: WebhookRepository;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export function createRepositories(db: Database, registry: ContentTypeRegistry): Repositories {
|
|
@@ -24,5 +32,9 @@ export function createRepositories(db: Database, registry: ContentTypeRegistry):
|
|
|
24
32
|
assets: new AssetRepository(db),
|
|
25
33
|
assetUsages: new AssetUsageRepository(db),
|
|
26
34
|
users: new UserRepository(db),
|
|
35
|
+
menus: new MenuRepository(db),
|
|
36
|
+
roles: new RoleRepository(db),
|
|
37
|
+
workflows: new WorkflowRepository(db),
|
|
38
|
+
webhooks: new WebhookRepository(db),
|
|
27
39
|
};
|
|
28
40
|
}
|