@manablox/db 0.2.0 → 0.4.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 (61) hide show
  1. package/dist/index-BLAkQMJT.d.ts +877 -0
  2. package/dist/index-DrNMGM9N.d.ts +5193 -0
  3. package/dist/index.d.ts +123 -0
  4. package/dist/index.js +60 -0
  5. package/dist/repositories-pz4NeWaF.js +1928 -0
  6. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  7. package/dist/schema-Dm3RcBst.js +648 -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/migrations/0009_audit-log.sql +40 -0
  13. package/migrations/0010_notifications-approvals.sql +45 -0
  14. package/migrations/meta/0009_snapshot.json +3192 -0
  15. package/migrations/meta/0010_snapshot.json +3574 -0
  16. package/migrations/meta/_journal.json +14 -0
  17. package/package.json +18 -10
  18. package/drizzle.config.ts +0 -11
  19. package/src/bootstrap.ts +0 -13
  20. package/src/cli/create-db.ts +0 -30
  21. package/src/cli/migrate.ts +0 -17
  22. package/src/client.ts +0 -44
  23. package/src/columns.ts +0 -39
  24. package/src/errors.ts +0 -50
  25. package/src/index.ts +0 -19
  26. package/src/migrate.ts +0 -21
  27. package/src/pagination.ts +0 -52
  28. package/src/query.ts +0 -213
  29. package/src/repositories/asset-usage.ts +0 -166
  30. package/src/repositories/asset.ts +0 -181
  31. package/src/repositories/content-type.ts +0 -116
  32. package/src/repositories/content.ts +0 -811
  33. package/src/repositories/index.ts +0 -40
  34. package/src/repositories/menu.ts +0 -235
  35. package/src/repositories/role.ts +0 -85
  36. package/src/repositories/space.ts +0 -83
  37. package/src/repositories/user.ts +0 -280
  38. package/src/repositories/webhook.ts +0 -46
  39. package/src/repositories/workflow.ts +0 -306
  40. package/src/schema/assets.ts +0 -108
  41. package/src/schema/auth.ts +0 -166
  42. package/src/schema/content-types.ts +0 -31
  43. package/src/schema/content.ts +0 -133
  44. package/src/schema/index.ts +0 -38
  45. package/src/schema/menus.ts +0 -61
  46. package/src/schema/relations.ts +0 -64
  47. package/src/schema/spaces.ts +0 -20
  48. package/src/schema/webhooks.ts +0 -46
  49. package/src/schema/workflows.ts +0 -92
  50. package/src/testing-fixtures.ts +0 -139
  51. package/src/testing.ts +0 -105
  52. package/test/asset-usage.test.ts +0 -101
  53. package/test/menu.test.ts +0 -126
  54. package/test/publish.test.ts +0 -130
  55. package/test/query.test.ts +0 -170
  56. package/test/role.test.ts +0 -81
  57. package/test/tree.test.ts +0 -188
  58. package/test/user.test.ts +0 -126
  59. package/test/webhook.test.ts +0 -48
  60. package/tsconfig.json +0 -4
  61. package/vitest.config.ts +0 -10
@@ -0,0 +1,1928 @@
1
+ import { A as assetUsages, D as users, E as sessions, F as publishedContents, I as spaces, M as assets, N as contentVersions, P as contents, R as idToLabel, S as accounts, T as roles, _ as contentApprovals, a as webhookDeliveries, b as menus, i as workflows, j as assetVariants, k as auditEntries, n as pushSubscriptions, o as webhooks, r as workflowRuns, t as schema_exports, v as notifications, w as memberships, x as contentTypes, y as menuItems } from "./schema-Dm3RcBst.js";
2
+ import { drizzle } from "drizzle-orm/postgres-js";
3
+ import postgres from "postgres";
4
+ import { and, asc, desc, eq, getTableColumns, gt, gte, ilike, inArray, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm";
5
+ import { ManabloxError, currentActor, defineContentType, hashAuditEntry } from "@manablox/core";
6
+ import { randomUUID } from "node:crypto";
7
+ //#region src/bootstrap.ts
8
+ /**
9
+ * Extensions the schema depends on. Run before the generated migrations, which
10
+ * reference `ltree` columns and `pg_trgm` operator classes.
11
+ */
12
+ async function applyBootstrapSql(sql) {
13
+ await sql.unsafe(`
14
+ create extension if not exists "ltree";
15
+ create extension if not exists "pg_trgm";
16
+ create extension if not exists "btree_gin";
17
+ `);
18
+ }
19
+ //#endregion
20
+ //#region src/client.ts
21
+ function createDatabase(config, options = {}) {
22
+ const sql = postgres(config.url, {
23
+ max: config.max ?? 10,
24
+ ...options.onQuery ? { debug: (_c, query) => options.onQuery?.(query) } : {},
25
+ ...config.ssl ? { ssl: "require" } : {},
26
+ onnotice: () => {}
27
+ });
28
+ return {
29
+ db: drizzle(sql, {
30
+ schema: schema_exports,
31
+ casing: "snake_case"
32
+ }),
33
+ sql,
34
+ close: () => sql.end({ timeout: 5 })
35
+ };
36
+ }
37
+ //#endregion
38
+ //#region src/pagination.ts
39
+ /**
40
+ * One page of a table plus the total, in one round trip: a window `count(*) over ()`
41
+ * rides along with the rows. A page past the end comes back empty and so carries no
42
+ * count; only then is the total asked for separately, so a caller paging by `total`
43
+ * still learns the true size.
44
+ */
45
+ async function paginate(db, table, options) {
46
+ const orderBy = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy];
47
+ const rows = await db.select({
48
+ ...getTableColumns(table),
49
+ total: sql`count(*) over ()::int`
50
+ }).from(table).where(options.where).orderBy(...orderBy).limit(options.pagination.limit).offset(options.pagination.offset);
51
+ let total = rows[0]?.total ?? 0;
52
+ if (rows.length === 0 && options.pagination.offset > 0) total = (await db.select({ count: sql`count(*)::int` }).from(table).where(options.where))[0]?.count ?? 0;
53
+ return {
54
+ items: rows.map(({ total: _total, ...row }) => row),
55
+ total,
56
+ limit: options.pagination.limit,
57
+ offset: options.pagination.offset
58
+ };
59
+ }
60
+ //#endregion
61
+ //#region src/query.ts
62
+ const SORT_COLUMNS$1 = {
63
+ position: "position",
64
+ title: "title",
65
+ createdAt: "created_at",
66
+ updatedAt: "updated_at",
67
+ publishedAt: "published_at",
68
+ slug: "slug"
69
+ };
70
+ /**
71
+ * Translates a filter into SQL. Every field predicate is checked against the field
72
+ * type's declared `filters` list first: an unsupported operator is a 400, and every
73
+ * supported one has an index behind it.
74
+ */
75
+ function buildContentWhere(table, filter, registry) {
76
+ const conditions = [];
77
+ if (filter.spaceId) conditions.push(eq(table.spaceId, filter.spaceId));
78
+ if (filter.locale) conditions.push(eq(table.locale, filter.locale));
79
+ if (filter.status) conditions.push(eq(table.status, filter.status));
80
+ if (filter.localizationId) conditions.push(eq(table.localizationId, filter.localizationId));
81
+ if (filter.slug) conditions.push(eq(table.slug, filter.slug));
82
+ if (filter.permalink !== void 0) conditions.push(eq(table.permalink, filter.permalink));
83
+ if (filter.typeIds?.length) conditions.push(inArray(table.typeId, filter.typeIds));
84
+ if (filter.ids?.length) conditions.push(inArray(table.id, filter.ids));
85
+ if (filter.parentId !== void 0) conditions.push(filter.parentId === null ? isNull(table.parentId) : eq(table.parentId, filter.parentId));
86
+ if (filter.under) conditions.push(sql`${table.path} <@ (select path from ${table} where id = ${filter.under}::uuid)`);
87
+ if (filter.search) conditions.push(sql`${table.search} @@ websearch_to_tsquery('simple', ${filter.search})`);
88
+ for (const fieldFilter of filter.fields ?? []) conditions.push(buildFieldCondition(table, fieldFilter, filter.typeIds ?? [], registry));
89
+ return conditions.length > 0 ? and(...conditions) : void 0;
90
+ }
91
+ function buildFieldCondition(table, filter, typeIds, registry) {
92
+ assertOperatorAllowed(filter, typeIds, registry);
93
+ const path = sql`${table.fields} -> ${filter.name}`;
94
+ const text = sql`${table.fields} ->> ${filter.name}`;
95
+ switch (filter.op) {
96
+ case "eq": return sql`${table.fields} @> jsonb_build_object(${filter.name}::text, ${JSON.stringify(filter.value ?? null)}::jsonb)`;
97
+ case "neq": return sql`not (${table.fields} @> jsonb_build_object(${filter.name}::text, ${JSON.stringify(filter.value ?? null)}::jsonb))`;
98
+ case "in": return sql`${text} = any(${sql.param(asStringArray(filter.value))}::text[])`;
99
+ case "notIn": return sql`${text} <> all(${sql.param(asStringArray(filter.value))}::text[])`;
100
+ case "lt": return sql`(${text})::numeric < ${asNumber(filter.value)}`;
101
+ case "lte": return sql`(${text})::numeric <= ${asNumber(filter.value)}`;
102
+ case "gt": return sql`(${text})::numeric > ${asNumber(filter.value)}`;
103
+ case "gte": return sql`(${text})::numeric >= ${asNumber(filter.value)}`;
104
+ case "contains": return sql`${text} ilike ${`%${escapeLike$1(String(filter.value ?? ""))}%`}`;
105
+ case "startsWith": return sql`${text} ilike ${`${escapeLike$1(String(filter.value ?? ""))}%`}`;
106
+ case "endsWith": return sql`${text} ilike ${`%${escapeLike$1(String(filter.value ?? ""))}`}`;
107
+ case "isNull": return sql`(${path} is null or ${path} = 'null'::jsonb)`;
108
+ case "isNotNull": return sql`(${path} is not null and ${path} <> 'null'::jsonb)`;
109
+ default: {
110
+ const exhaustive = filter.op;
111
+ throw ManabloxError.badRequest("query.operator.unsupported", { op: exhaustive });
112
+ }
113
+ }
114
+ }
115
+ /**
116
+ * A field predicate is only accepted when *every* candidate content type declares a
117
+ * field of that name whose field type supports the operator.
118
+ */
119
+ function assertOperatorAllowed(filter, typeIds, registry) {
120
+ const matching = (typeIds.length > 0 ? typeIds.map((id) => registry.get(id)) : registry.contentTypes).map((type) => type.fields.find((field) => field.name === filter.name)).filter((field) => field !== void 0);
121
+ if (matching.length === 0) throw ManabloxError.badRequest("query.field.unknown", { field: filter.name });
122
+ for (const field of matching) if (!registry.fieldTypes.tryGet(field.type)?.filters.includes(filter.op)) throw ManabloxError.badRequest("query.operator.unsupported", {
123
+ field: filter.name,
124
+ fieldType: field.type,
125
+ op: filter.op
126
+ });
127
+ }
128
+ function buildOrderBy(sorts) {
129
+ if (sorts.length === 0) return sql`${sql.identifier("position")} asc, ${sql.identifier("created_at")} asc`;
130
+ return sorts.map((sort) => {
131
+ const column = SORT_COLUMNS$1[sort.by];
132
+ if (!column) throw ManabloxError.badRequest("query.sort.unsupported", { by: sort.by });
133
+ return sql`${sql.identifier(column)} ${sql.raw(sort.direction === "desc" ? "desc" : "asc")}`;
134
+ }).reduce((acc, part) => sql`${acc}, ${part}`);
135
+ }
136
+ const escapeLike$1 = (value) => value.replace(/[%_\\]/g, (c) => `\\${c}`);
137
+ function asStringArray(value) {
138
+ if (!Array.isArray(value)) throw ManabloxError.badRequest("query.value.expectedArray");
139
+ return value.map((entry) => String(entry));
140
+ }
141
+ function asNumber(value) {
142
+ const parsed = Number(value);
143
+ if (Number.isNaN(parsed)) throw ManabloxError.badRequest("query.value.expectedNumber");
144
+ return parsed;
145
+ }
146
+ //#endregion
147
+ //#region src/repositories/asset.ts
148
+ var AssetRepository = class {
149
+ db;
150
+ constructor(db) {
151
+ this.db = db;
152
+ }
153
+ async findById(id) {
154
+ return (await this.db.select().from(assets).where(eq(assets.id, id)).limit(1))[0] ?? null;
155
+ }
156
+ /**
157
+ * `spaceId` is not an optimisation. On the public instance an asset id is the only
158
+ * thing a caller supplies, and without this predicate any id resolves — including one
159
+ * belonging to another tenant sharing the process.
160
+ */
161
+ async findManyByIds(ids, spaceId) {
162
+ if (ids.length === 0) return [];
163
+ const where = spaceId ? and(inArray(assets.id, ids), eq(assets.spaceId, spaceId)) : inArray(assets.id, ids);
164
+ return this.db.select().from(assets).where(where);
165
+ }
166
+ async findByChecksum(spaceId, checksum) {
167
+ return (await this.db.select().from(assets).where(and(eq(assets.spaceId, spaceId), eq(assets.checksum, checksum))).limit(1))[0] ?? null;
168
+ }
169
+ async list(filter, pagination) {
170
+ const conditions = [eq(assets.spaceId, filter.spaceId)];
171
+ if (filter.mimeType) conditions.push(sql`${assets.mimeType} like ${`${filter.mimeType}%`}`);
172
+ if (filter.search) conditions.push(sql`(${assets.name} ilike ${`%${filter.search}%`} or ${assets.filename} ilike ${`%${filter.search}%`})`);
173
+ return paginate(this.db, assets, {
174
+ where: and(...conditions),
175
+ orderBy: desc(assets.createdAt),
176
+ pagination
177
+ });
178
+ }
179
+ async create(data) {
180
+ const [row] = await this.db.insert(assets).values({
181
+ ...data.id ? { id: data.id } : {},
182
+ spaceId: data.spaceId,
183
+ driver: data.driver,
184
+ key: data.key,
185
+ filename: data.filename,
186
+ name: data.name,
187
+ mimeType: data.mimeType,
188
+ size: data.size,
189
+ width: data.width ?? null,
190
+ height: data.height ?? null,
191
+ duration: data.duration ?? null,
192
+ checksum: data.checksum ?? null,
193
+ alt: data.alt ?? null,
194
+ title: data.title ?? null,
195
+ meta: data.meta ?? {},
196
+ createdBy: data.actorId ?? null
197
+ }).returning();
198
+ if (!row) throw new ManabloxError("asset.create.failed");
199
+ return row;
200
+ }
201
+ async update(id, data) {
202
+ const [row] = await this.db.update(assets).set({
203
+ ...data,
204
+ updatedAt: /* @__PURE__ */ new Date()
205
+ }).where(eq(assets.id, id)).returning();
206
+ if (!row) throw ManabloxError.notFound("asset.notFound", { id });
207
+ return row;
208
+ }
209
+ async delete(id) {
210
+ const [row] = await this.db.delete(assets).where(eq(assets.id, id)).returning();
211
+ return row ?? null;
212
+ }
213
+ /** Timestamps an import carries over; `create()` can only stamp the moment of import. */
214
+ async restoreTimestamps(id, createdAt, updatedAt) {
215
+ await this.db.update(assets).set({
216
+ createdAt,
217
+ updatedAt
218
+ }).where(eq(assets.id, id));
219
+ }
220
+ async variants(assetIds) {
221
+ if (assetIds.length === 0) return [];
222
+ return this.db.select().from(assetVariants).where(inArray(assetVariants.assetId, assetIds));
223
+ }
224
+ async findVariant(assetId, preset, format) {
225
+ return (await this.db.select().from(assetVariants).where(and(eq(assetVariants.assetId, assetId), eq(assetVariants.preset, preset), eq(assetVariants.format, format))).limit(1))[0] ?? null;
226
+ }
227
+ /** Drops every variant row; the caller removes the files. */
228
+ async deleteVariants(assetId) {
229
+ await this.db.delete(assetVariants).where(eq(assetVariants.assetId, assetId));
230
+ }
231
+ async addVariant(data) {
232
+ const [row] = await this.db.insert(assetVariants).values({
233
+ assetId: data.assetId,
234
+ preset: data.preset,
235
+ format: data.format,
236
+ key: data.key,
237
+ width: data.width ?? null,
238
+ height: data.height ?? null,
239
+ size: data.size
240
+ }).onConflictDoUpdate({
241
+ target: [
242
+ assetVariants.assetId,
243
+ assetVariants.preset,
244
+ assetVariants.format
245
+ ],
246
+ set: {
247
+ key: data.key,
248
+ size: data.size
249
+ }
250
+ }).returning();
251
+ if (!row) throw new ManabloxError("assetVariant.create.failed");
252
+ return row;
253
+ }
254
+ };
255
+ //#endregion
256
+ //#region src/repositories/asset-usage.ts
257
+ /**
258
+ * The asset → document reachability index.
259
+ *
260
+ * `published` tracks the *published projection*, not the draft: a draft that adds an
261
+ * image does not make that image public, and a draft that removes one does not make it
262
+ * private until the change is published. Every method below preserves that distinction,
263
+ * which is why the column exists rather than the table simply holding published rows.
264
+ */
265
+ var AssetUsageRepository = class {
266
+ db;
267
+ constructor(db) {
268
+ this.db = db;
269
+ }
270
+ /** The subset of `assetIds` reachable from at least one published document. */
271
+ async filterPublished(assetIds) {
272
+ if (assetIds.length === 0) return /* @__PURE__ */ new Set();
273
+ const rows = await this.db.selectDistinct({ assetId: assetUsages.assetId }).from(assetUsages).where(and(inArray(assetUsages.assetId, assetIds), eq(assetUsages.published, true)));
274
+ return new Set(rows.map((row) => row.assetId));
275
+ }
276
+ async forContent(contentId) {
277
+ return this.db.select({
278
+ assetId: assetUsages.assetId,
279
+ published: assetUsages.published
280
+ }).from(assetUsages).where(eq(assetUsages.contentId, contentId));
281
+ }
282
+ /**
283
+ * Records what a *draft* references.
284
+ *
285
+ * Rows the draft dropped are removed only if they are not currently published —
286
+ * otherwise editing a draft would silently revoke access to an image the live page is
287
+ * still showing.
288
+ */
289
+ async recordDraft(contentId, spaceId, assetIds) {
290
+ const unique = [...new Set(assetIds)];
291
+ await this.db.transaction(async (tx) => {
292
+ await tx.delete(assetUsages).where(and(eq(assetUsages.contentId, contentId), eq(assetUsages.published, false), ...unique.length > 0 ? [notInArray(assetUsages.assetId, unique)] : []));
293
+ if (unique.length === 0) return;
294
+ await tx.insert(assetUsages).values(unique.map((assetId) => ({
295
+ assetId,
296
+ contentId,
297
+ spaceId,
298
+ published: false,
299
+ updatedAt: /* @__PURE__ */ new Date()
300
+ }))).onConflictDoNothing();
301
+ });
302
+ }
303
+ /**
304
+ * Records what the published projection references.
305
+ *
306
+ * Assets the new revision no longer uses lose their published flag but keep their row
307
+ * when the draft still references them, so the admin's "where is this used" view stays
308
+ * complete.
309
+ */
310
+ async recordPublished(contentId, spaceId, assetIds) {
311
+ const unique = [...new Set(assetIds)];
312
+ await this.db.transaction(async (tx) => {
313
+ await tx.update(assetUsages).set({
314
+ published: false,
315
+ updatedAt: /* @__PURE__ */ new Date()
316
+ }).where(and(eq(assetUsages.contentId, contentId), ...unique.length > 0 ? [notInArray(assetUsages.assetId, unique)] : []));
317
+ if (unique.length === 0) return;
318
+ await tx.insert(assetUsages).values(unique.map((assetId) => ({
319
+ assetId,
320
+ contentId,
321
+ spaceId,
322
+ published: true,
323
+ updatedAt: /* @__PURE__ */ new Date()
324
+ }))).onConflictDoUpdate({
325
+ target: [assetUsages.assetId, assetUsages.contentId],
326
+ set: {
327
+ published: true,
328
+ updatedAt: /* @__PURE__ */ new Date()
329
+ }
330
+ });
331
+ });
332
+ }
333
+ /** Unpublishing revokes every asset this document was keeping public. */
334
+ async clearPublished(contentId) {
335
+ await this.db.update(assetUsages).set({
336
+ published: false,
337
+ updatedAt: /* @__PURE__ */ new Date()
338
+ }).where(eq(assetUsages.contentId, contentId));
339
+ }
340
+ async deleteForContent(contentId) {
341
+ await this.db.delete(assetUsages).where(eq(assetUsages.contentId, contentId));
342
+ }
343
+ async count() {
344
+ return (await this.db.select({ count: sql`count(*)::int` }).from(assetUsages))[0]?.count ?? 0;
345
+ }
346
+ /**
347
+ * Every document with its draft and published field values, for the backfill.
348
+ *
349
+ * References are derived from field-type definitions rather than stored, so the
350
+ * backfill cannot be a SQL migration — it has to run inside the application.
351
+ */
352
+ async backfillSource() {
353
+ return (await this.db.select({
354
+ id: contents.id,
355
+ spaceId: contents.spaceId,
356
+ typeId: contents.typeId,
357
+ draftFields: contents.fields,
358
+ publishedFields: publishedContents.fields
359
+ }).from(contents).leftJoin(publishedContents, eq(publishedContents.id, contents.id))).map((row) => ({
360
+ id: row.id,
361
+ spaceId: row.spaceId,
362
+ typeId: row.typeId,
363
+ draftFields: row.draftFields,
364
+ publishedFields: row.publishedFields ?? null
365
+ }));
366
+ }
367
+ };
368
+ //#endregion
369
+ //#region src/repositories/audit.ts
370
+ const SORT_COLUMNS = {
371
+ at: auditEntries.at,
372
+ action: auditEntries.action,
373
+ actorLabel: auditEntries.actorLabel,
374
+ targetKind: auditEntries.targetKind,
375
+ targetLabel: auditEntries.targetLabel
376
+ };
377
+ const escapeLike = (value) => value.replace(/[%_\\]/g, (c) => `\\${c}`);
378
+ /**
379
+ * Appends to and reads the audit log. There is no update and no delete: the table
380
+ * refuses both, so this class offers neither.
381
+ */
382
+ var AuditRepository = class {
383
+ db;
384
+ options;
385
+ constructor(db, options = {}) {
386
+ this.db = db;
387
+ this.options = options;
388
+ }
389
+ /**
390
+ * Writes one entry, chained to the last. Writers are serialised on a transaction-level
391
+ * advisory lock, so two entries can never both claim the same predecessor; the lock
392
+ * is held for one read and one insert.
393
+ */
394
+ async append(input) {
395
+ const row = await this.write(input);
396
+ if (this.options.onAppended) try {
397
+ this.options.onAppended(row);
398
+ } catch (error) {
399
+ this.options.onError?.(error, input);
400
+ }
401
+ return row;
402
+ }
403
+ async write(input) {
404
+ const actor = input.actor ?? currentActor();
405
+ const at = input.at ?? /* @__PURE__ */ new Date();
406
+ const changes = input.changes ?? [];
407
+ const meta = input.meta ?? null;
408
+ const actorDetail = actor.detail ?? null;
409
+ return this.db.transaction(async (tx) => {
410
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('audit_entries'))`);
411
+ const [last] = await tx.select({ hash: auditEntries.hash }).from(auditEntries).orderBy(desc(auditEntries.seq)).limit(1);
412
+ const prevHash = last?.hash ?? null;
413
+ const content = {
414
+ at,
415
+ spaceId: input.spaceId ?? null,
416
+ actorKind: actor.kind,
417
+ actorId: actor.id,
418
+ actorLabel: actor.label,
419
+ actorDetail,
420
+ action: input.action,
421
+ targetKind: input.targetKind,
422
+ targetId: input.targetId ?? null,
423
+ targetLabel: input.targetLabel ?? null,
424
+ changes,
425
+ meta,
426
+ prevHash
427
+ };
428
+ const [row] = await tx.insert(auditEntries).values({
429
+ ...content,
430
+ hash: hashAuditEntry(content)
431
+ }).returning();
432
+ if (!row) throw new ManabloxError("audit.append.failed");
433
+ return row;
434
+ });
435
+ }
436
+ /**
437
+ * `append` that never throws. The write it describes has already happened; failing
438
+ * the request now would report an error for something that succeeded, so the failure
439
+ * is reported to `onError` (the host logs it) and the caller carries on.
440
+ */
441
+ async record(input) {
442
+ try {
443
+ return await this.append(input);
444
+ } catch (error) {
445
+ this.options.onError?.(error, input);
446
+ return null;
447
+ }
448
+ }
449
+ async findById(id) {
450
+ return (await this.db.select().from(auditEntries).where(eq(auditEntries.id, id)).limit(1))[0] ?? null;
451
+ }
452
+ list(filter, sort = {
453
+ by: "at",
454
+ direction: "desc"
455
+ }, pagination = {
456
+ limit: 50,
457
+ offset: 0
458
+ }) {
459
+ const column = SORT_COLUMNS[sort.by];
460
+ const primary = sort.direction === "asc" ? asc(column) : desc(column);
461
+ const tiebreak = sort.direction === "asc" ? asc(auditEntries.seq) : desc(auditEntries.seq);
462
+ return paginate(this.db, auditEntries, {
463
+ where: buildWhere(filter),
464
+ orderBy: [primary, tiebreak],
465
+ pagination
466
+ });
467
+ }
468
+ /** The entries about one thing, oldest first: a document's history. */
469
+ forTarget(targetKind, targetId, limit = 100) {
470
+ return this.db.select().from(auditEntries).where(and(eq(auditEntries.targetKind, targetKind), eq(auditEntries.targetId, targetId))).orderBy(desc(auditEntries.seq)).limit(limit);
471
+ }
472
+ /**
473
+ * Recomputes every hash from the first entry on and checks each link to its
474
+ * predecessor. The chain is one across the instance, so this walks all of it, in
475
+ * batches; on a large log it takes a while and that is the point.
476
+ */
477
+ async verify(batchSize = 500) {
478
+ let prevHash = null;
479
+ let afterSeq = 0;
480
+ let checked = 0;
481
+ for (;;) {
482
+ const rows = await this.db.select().from(auditEntries).where(gt(auditEntries.seq, afterSeq)).orderBy(asc(auditEntries.seq)).limit(batchSize);
483
+ if (rows.length === 0) break;
484
+ for (const row of rows) {
485
+ checked++;
486
+ if (row.prevHash !== prevHash) return {
487
+ ok: false,
488
+ checked,
489
+ brokenAt: {
490
+ seq: row.seq,
491
+ id: row.id,
492
+ reason: "link"
493
+ }
494
+ };
495
+ if (hashAuditEntry({
496
+ at: row.at,
497
+ spaceId: row.spaceId,
498
+ actorKind: row.actorKind,
499
+ actorId: row.actorId,
500
+ actorLabel: row.actorLabel,
501
+ actorDetail: row.actorDetail,
502
+ action: row.action,
503
+ targetKind: row.targetKind,
504
+ targetId: row.targetId,
505
+ targetLabel: row.targetLabel,
506
+ changes: row.changes,
507
+ meta: row.meta,
508
+ prevHash: row.prevHash
509
+ }) !== row.hash) return {
510
+ ok: false,
511
+ checked,
512
+ brokenAt: {
513
+ seq: row.seq,
514
+ id: row.id,
515
+ reason: "hash"
516
+ }
517
+ };
518
+ prevHash = row.hash;
519
+ afterSeq = row.seq;
520
+ }
521
+ }
522
+ return {
523
+ ok: true,
524
+ checked,
525
+ brokenAt: null
526
+ };
527
+ }
528
+ async count(filter = {}) {
529
+ const [row] = await this.db.select({ count: sql`count(*)::int` }).from(auditEntries).where(buildWhere(filter));
530
+ return row?.count ?? 0;
531
+ }
532
+ };
533
+ function buildWhere(filter) {
534
+ const conditions = [];
535
+ if (filter.spaceId === null) conditions.push(isNull(auditEntries.spaceId));
536
+ else if (filter.spaceId) conditions.push(eq(auditEntries.spaceId, filter.spaceId));
537
+ if (filter.actorKind) conditions.push(eq(auditEntries.actorKind, filter.actorKind));
538
+ if (filter.actorId) conditions.push(eq(auditEntries.actorId, filter.actorId));
539
+ if (filter.actions?.length) conditions.push(inArray(auditEntries.action, filter.actions));
540
+ if (filter.targetKind) conditions.push(eq(auditEntries.targetKind, filter.targetKind));
541
+ if (filter.targetId) conditions.push(eq(auditEntries.targetId, filter.targetId));
542
+ if (filter.from) conditions.push(gte(auditEntries.at, filter.from));
543
+ if (filter.to) conditions.push(lte(auditEntries.at, filter.to));
544
+ if (filter.search?.trim()) {
545
+ const term = `%${escapeLike(filter.search.trim())}%`;
546
+ const match = or(ilike(auditEntries.actorLabel, term), ilike(auditEntries.targetLabel, term), ilike(auditEntries.action, term));
547
+ if (match) conditions.push(match);
548
+ }
549
+ return conditions.length ? and(...conditions) : void 0;
550
+ }
551
+ //#endregion
552
+ //#region src/repositories/content.ts
553
+ /**
554
+ * The columns `publish()` copies from the draft into the projection: every column the
555
+ * two tables share, in the projection's order, derived from the schema so a new column
556
+ * cannot be forgotten on one side. `source_version` exists only on the projection and
557
+ * is filled from `version`.
558
+ */
559
+ const PROJECTION_COLUMNS = (() => {
560
+ const columnNames = (table) => Object.values(getTableColumns(table)).filter((column) => column.generated === void 0).map((column) => column.name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`));
561
+ const draft = new Set(columnNames(contents));
562
+ return columnNames(publishedContents).filter((name) => draft.has(name) || name === "source_version");
563
+ })();
564
+ /** Columns that identify the row and are never rewritten on a republish. */
565
+ const PROJECTION_IDENTITY = /* @__PURE__ */ new Set([
566
+ "id",
567
+ "space_id",
568
+ "localization_id",
569
+ "created_at",
570
+ "created_by"
571
+ ]);
572
+ var ContentRepository = class {
573
+ db;
574
+ registry;
575
+ constructor(db, registry) {
576
+ this.db = db;
577
+ this.registry = registry;
578
+ }
579
+ async findById(id, published = false) {
580
+ const table = published ? publishedContents : contents;
581
+ return (await this.db.select().from(table).where(eq(table.id, id)).limit(1))[0] ?? null;
582
+ }
583
+ /**
584
+ * `spaceId` bounds a lookup by id to one tenant.
585
+ *
586
+ * The delivery API takes ids straight from the caller, so without it a public instance
587
+ * pinned to one space still answers for any other space's published documents.
588
+ */
589
+ async findManyByIds(ids, published = false, spaceId) {
590
+ if (ids.length === 0) return [];
591
+ const table = published ? publishedContents : contents;
592
+ const where = spaceId ? and(inArray(table.id, ids), eq(table.spaceId, spaceId)) : inArray(table.id, ids);
593
+ return this.db.select().from(table).where(where);
594
+ }
595
+ /** Children of many parents in one query, for the tree loader. */
596
+ async findChildrenOf(parentIds, published = false, spaceId) {
597
+ if (parentIds.length === 0) return [];
598
+ const table = published ? publishedContents : contents;
599
+ const where = spaceId ? and(inArray(table.parentId, parentIds), eq(table.spaceId, spaceId)) : inArray(table.parentId, parentIds);
600
+ return this.db.select().from(table).where(where).orderBy(sql`${table.position} asc, ${table.title} asc`);
601
+ }
602
+ async findByPermalink(spaceId, locale, permalink, published = true) {
603
+ if (permalink === "") return this.findHome(spaceId, locale, published);
604
+ const table = published ? publishedContents : contents;
605
+ return (await this.db.select().from(table).where(and(eq(table.spaceId, spaceId), eq(table.locale, locale), eq(table.permalink, permalink))).limit(1))[0] ?? null;
606
+ }
607
+ /**
608
+ * The document a space nominates as its home, in one locale.
609
+ *
610
+ * `settings.homeContentId` names a single row, which belongs to one locale. Every
611
+ * translation of that document shares its `localizationId`, so the requested locale is
612
+ * resolved through that rather than by pinning one row per language.
613
+ */
614
+ async findHome(spaceId, locale, published = true) {
615
+ const [space] = await this.db.select({ settings: spaces.settings }).from(spaces).where(eq(spaces.id, spaceId)).limit(1);
616
+ const homeId = space?.settings?.homeContentId;
617
+ if (typeof homeId !== "string") return null;
618
+ const [nominated] = await this.db.select({ localizationId: contents.localizationId }).from(contents).where(and(eq(contents.id, homeId), eq(contents.spaceId, spaceId))).limit(1);
619
+ if (!nominated) return null;
620
+ const table = published ? publishedContents : contents;
621
+ return (await this.db.select().from(table).where(and(eq(table.spaceId, spaceId), eq(table.locale, locale), eq(table.localizationId, nominated.localizationId))).limit(1))[0] ?? null;
622
+ }
623
+ /**
624
+ * Every other row in a document's localization group — its translations.
625
+ */
626
+ /**
627
+ * One document per localization group, for checking that several groups exist in a
628
+ * space at once — a menu's entries, say. The locale returned is whichever sorts first;
629
+ * a caller that needs a particular one asks `localizationSiblings` for that group.
630
+ */
631
+ async findByLocalizationIds(spaceId, localizationIds) {
632
+ if (localizationIds.length === 0) return [];
633
+ const rows = await this.db.select().from(contents).where(and(eq(contents.spaceId, spaceId), inArray(contents.localizationId, localizationIds))).orderBy(contents.localizationId, contents.locale);
634
+ const seen = /* @__PURE__ */ new Set();
635
+ return rows.filter((row) => {
636
+ if (seen.has(row.localizationId)) return false;
637
+ seen.add(row.localizationId);
638
+ return true;
639
+ });
640
+ }
641
+ async localizationSiblings(spaceId, localizationId, excludeId) {
642
+ const all = await this.db.select().from(contents).where(and(eq(contents.spaceId, spaceId), eq(contents.localizationId, localizationId)));
643
+ return excludeId ? all.filter((row) => row.id !== excludeId) : all;
644
+ }
645
+ /**
646
+ * Merges a few field values into rows without touching the rest of the document.
647
+ *
648
+ * Used to carry a non-localized field across a document's translations: a jsonb `||`
649
+ * so concurrent edits to *other* fields on those rows are not clobbered.
650
+ */
651
+ async patchFields(ids, patch) {
652
+ if (ids.length === 0 || Object.keys(patch).length === 0) return;
653
+ await this.db.update(contents).set({
654
+ fields: sql`${contents.fields} || ${JSON.stringify(patch)}::jsonb`,
655
+ updatedAt: /* @__PURE__ */ new Date()
656
+ }).where(inArray(contents.id, ids));
657
+ }
658
+ async list(filter, pagination, sorts = [], published = false) {
659
+ const table = published ? publishedContents : contents;
660
+ const where = buildContentWhere(table, filter, this.registry);
661
+ return await paginate(this.db, table, {
662
+ where,
663
+ orderBy: buildOrderBy(sorts),
664
+ pagination
665
+ });
666
+ }
667
+ /**
668
+ * The whole tree below `rootId` in **one** query: a GiST-indexed `path <@ root`
669
+ * returns every descendant, and `nlevel()` gives the depth to rebuild the hierarchy.
670
+ */
671
+ async tree(spaceId, locale, rootId = null, maxDepth = 32, published = false) {
672
+ const table = published ? publishedContents : contents;
673
+ const scope = rootId ? sql`and ${table.path} <@ (select path from ${table} where id = ${rootId}::uuid)
674
+ and ${table.id} <> ${rootId}::uuid` : sql``;
675
+ const depthLimit = rootId ? sql`and nlevel(${table.path}) <= (select nlevel(path) from ${table} where id = ${rootId}::uuid) + ${maxDepth}` : sql`and nlevel(${table.path}) <= ${maxDepth}`;
676
+ return buildTree$1(await this.db.select({
677
+ ...getTableColumns(table),
678
+ depth: sql`nlevel(${table.path})::int`
679
+ }).from(table).where(sql`${table.spaceId} = ${spaceId}::uuid and ${table.locale} = ${locale} ${scope} ${depthLimit}`).orderBy(sql`nlevel(${table.path}) asc, ${table.position} asc, ${table.title} asc`), rootId);
680
+ }
681
+ /** Ancestors of a node, root first — read straight off the materialised path. */
682
+ async ancestors(id, published = false) {
683
+ const table = published ? publishedContents : contents;
684
+ return await this.db.select(getTableColumns(table)).from(table).where(sql`${table.path} @> (select path from ${table} where id = ${id}::uuid)
685
+ and ${table.id} <> ${id}::uuid`).orderBy(sql`nlevel(${table.path}) asc`);
686
+ }
687
+ async create(data) {
688
+ return this.db.transaction(async (tx) => {
689
+ const id = data.id ?? crypto.randomUUID();
690
+ const parentPath = await this.parentPath(tx, data.parentId ?? null);
691
+ const path = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
692
+ const parentPrefix = await this.parentPermalinkPath(tx, data.parentId ?? null);
693
+ const segment = data.hasSlug ? data.slug : null;
694
+ const permalinkPath = joinSegment(parentPrefix, segment);
695
+ const permalink = segment === null ? null : permalinkPath;
696
+ const [row] = await tx.insert(contents).values({
697
+ id,
698
+ spaceId: data.spaceId,
699
+ typeId: data.typeId,
700
+ locale: data.locale,
701
+ localizationId: data.localizationId ?? crypto.randomUUID(),
702
+ parentId: data.parentId ?? null,
703
+ title: data.title,
704
+ slug: data.slug,
705
+ path,
706
+ permalink,
707
+ permalinkPath,
708
+ permalinkSegment: segment,
709
+ status: data.status ?? "draft",
710
+ position: data.position ?? 0,
711
+ fields: data.fields,
712
+ searchText: data.searchText ?? "",
713
+ version: 1,
714
+ createdBy: data.actorId ?? null,
715
+ updatedBy: data.actorId ?? null
716
+ }).returning();
717
+ if (!row) throw new ManabloxError("content.create.failed");
718
+ await this.snapshot(tx, row, data.actorId ?? null);
719
+ return row;
720
+ });
721
+ }
722
+ async update(id, data) {
723
+ return this.db.transaction(async (tx) => {
724
+ const current = await this.lockRow(tx, id);
725
+ if (data.expectedVersion !== void 0 && data.expectedVersion !== current.version) throw ManabloxError.conflict("content.version.conflict", {
726
+ expected: data.expectedVersion,
727
+ actual: current.version
728
+ });
729
+ const parentChanged = (data.parentId ?? null) !== current.parentId;
730
+ const segment = data.hasSlug ? data.slug : null;
731
+ const segmentChanged = segment !== current.permalinkSegment;
732
+ if (parentChanged) await this.assertNotOwnDescendant(tx, id, data.parentId ?? null);
733
+ const parentPath = await this.parentPath(tx, data.parentId ?? null);
734
+ const newPath = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
735
+ const [row] = await tx.update(contents).set({
736
+ typeId: data.typeId,
737
+ locale: data.locale,
738
+ parentId: data.parentId ?? null,
739
+ title: data.title,
740
+ slug: data.slug,
741
+ path: newPath,
742
+ permalinkSegment: segment,
743
+ fields: data.fields,
744
+ searchText: data.searchText ?? "",
745
+ position: data.position ?? current.position,
746
+ version: current.version + 1,
747
+ updatedAt: /* @__PURE__ */ new Date(),
748
+ updatedBy: data.actorId ?? null
749
+ }).where(eq(contents.id, id)).returning();
750
+ if (!row) throw ManabloxError.notFound("content.notFound", { id });
751
+ if (parentChanged) await this.moveSubtree(tx, id, current.path, newPath);
752
+ if (parentChanged || segmentChanged) await this.recomputePermalinks(tx, contents, id);
753
+ const fresh = await this.findRow(tx, id) ?? row;
754
+ await this.snapshot(tx, fresh, data.actorId ?? null);
755
+ return fresh;
756
+ });
757
+ }
758
+ /**
759
+ * Reparents a subtree with one statement. `subpath(path, nlevel(:oldPath))` is the part
760
+ * of each descendant's path *below* the moved node; prefixing it with the node's new
761
+ * path rebases the whole subtree.
762
+ */
763
+ /**
764
+ * Reparents and reorders a node in one transaction.
765
+ *
766
+ * Separate from `update` because a drag is a structural change, not an edit: it writes
767
+ * no field values, takes no version bump and records no snapshot, so an editor open on
768
+ * the document does not hit a version conflict because someone reordered the tree.
769
+ *
770
+ * `position` is the index among the destination's children, clamped to the ends.
771
+ * Siblings on both sides are renumbered densely afterwards, so positions never drift
772
+ * into ties that the tree's `position asc, title asc` ordering would resolve by name.
773
+ */
774
+ async move(id, parentId, position) {
775
+ return this.db.transaction(async (tx) => {
776
+ const db = tx;
777
+ const current = await this.findRow(db, id);
778
+ if (!current) throw ManabloxError.notFound("content.notFound", { id });
779
+ await this.assertNotOwnDescendant(db, id, parentId);
780
+ const parentPath = await this.parentPath(db, parentId);
781
+ const newPath = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
782
+ const parentChanged = parentId !== current.parentId;
783
+ const order = (await db.select({ id: contents.id }).from(contents).where(and(eq(contents.spaceId, current.spaceId), eq(contents.locale, current.locale), parentId === null ? sql`${contents.parentId} is null` : eq(contents.parentId, parentId))).orderBy(sql`${contents.position} asc, ${contents.title} asc`)).map((row) => row.id).filter((sibling) => sibling !== id);
784
+ const index = Math.max(0, Math.min(position, order.length));
785
+ order.splice(index, 0, id);
786
+ await tx.update(contents).set({
787
+ parentId,
788
+ path: newPath,
789
+ updatedAt: /* @__PURE__ */ new Date()
790
+ }).where(eq(contents.id, id));
791
+ await this.renumber(db, order);
792
+ if (parentChanged) {
793
+ await this.moveSubtree(db, id, current.path, newPath);
794
+ await this.recomputePermalinks(db, contents, id);
795
+ const former = await db.select({ id: contents.id }).from(contents).where(and(eq(contents.spaceId, current.spaceId), eq(contents.locale, current.locale), current.parentId === null ? sql`${contents.parentId} is null` : eq(contents.parentId, current.parentId))).orderBy(sql`${contents.position} asc, ${contents.title} asc`);
796
+ await this.renumber(db, former.map((row) => row.id));
797
+ }
798
+ const fresh = await this.findRow(db, id);
799
+ if (!fresh) throw ManabloxError.notFound("content.notFound", { id });
800
+ return fresh;
801
+ });
802
+ }
803
+ /**
804
+ * Writes `position = index` for a whole sibling list in one statement: the ids and
805
+ * their new positions travel as two arrays and are joined by `unnest`, so a drag in a
806
+ * forty-child section costs one round trip rather than forty. Rows already in place
807
+ * are left untouched, so their `updated_at` and version do not move either.
808
+ */
809
+ async renumber(db, ids) {
810
+ if (ids.length === 0) return;
811
+ const idList = sql.join(ids.map((id) => sql`${id}::uuid`), sql`, `);
812
+ const positions = sql.join(ids.map((_, index) => sql`${index}::int`), sql`, `);
813
+ await db.execute(sql`
814
+ update contents
815
+ set position = v.pos
816
+ from (select unnest(array[${idList}]) as id, unnest(array[${positions}]) as pos) v
817
+ where contents.id = v.id and contents.position is distinct from v.pos
818
+ `);
819
+ }
820
+ async moveSubtree(db, id, oldPath, newPath) {
821
+ await db.execute(sql`
822
+ update contents
823
+ set path = ${newPath}::ltree || subpath(path, nlevel(${oldPath}::ltree)),
824
+ updated_at = now()
825
+ where path <@ ${oldPath}::ltree and id <> ${id}::uuid
826
+ `);
827
+ }
828
+ /**
829
+ * Recomputes permalinks for a node and everything beneath it in one recursive CTE.
830
+ * Each level derives from *its own* parent's freshly computed value (`t.pl`), and
831
+ * `concat_ws` drops NULL segments so a type without a slug is transparent in the path.
832
+ */
833
+ async recomputePermalinks(db, table, rootId) {
834
+ const tableName = table === contents ? sql`contents` : sql`published_contents`;
835
+ await db.execute(sql`
836
+ with recursive t as (
837
+ select c.id,
838
+ c.permalink_segment,
839
+ concat_ws('/',
840
+ nullif(coalesce(
841
+ (select p.permalink_path from ${tableName} p where p.id = c.parent_id), ''), ''),
842
+ c.permalink_segment
843
+ ) as prefix
844
+ from ${tableName} c
845
+ where c.id = ${rootId}::uuid
846
+
847
+ union all
848
+
849
+ select ch.id,
850
+ ch.permalink_segment,
851
+ concat_ws('/', nullif(t.prefix, ''), ch.permalink_segment) as prefix
852
+ from ${tableName} ch
853
+ join t on ch.parent_id = t.id
854
+ )
855
+ update ${tableName} target
856
+ set permalink_path = t.prefix,
857
+ permalink = case when t.permalink_segment is null then null else nullif(t.prefix, '') end,
858
+ updated_at = now()
859
+ from t
860
+ where target.id = t.id
861
+ and (target.permalink_path is distinct from t.prefix
862
+ or target.permalink is distinct from
863
+ (case when t.permalink_segment is null then null else nullif(t.prefix, '') end))
864
+ `);
865
+ }
866
+ /** Deletes a node and its whole subtree, in both the draft and published tables. */
867
+ async delete(id) {
868
+ return this.db.transaction(async (tx) => {
869
+ const current = await this.findRow(tx, id);
870
+ if (!current) throw ManabloxError.notFound("content.notFound", { id });
871
+ await tx.execute(sql`
872
+ delete from published_contents
873
+ where path <@ (select path from contents where id = ${id}::uuid)
874
+ `);
875
+ return (await tx.delete(contents).where(sql`${contents.path} <@ ${current.path}::ltree`).returning({ id: contents.id })).length;
876
+ });
877
+ }
878
+ /**
879
+ * Copies a draft into the delivery projection inside one transaction, so a reader
880
+ * never observes a partially published tree.
881
+ */
882
+ async publish(id, actorId = null) {
883
+ return this.db.transaction(async (tx) => {
884
+ const row = await this.findRow(tx, id);
885
+ if (!row) throw ManabloxError.notFound("content.notFound", { id });
886
+ const previous = (await tx.select({ permalinkPath: publishedContents.permalinkPath }).from(publishedContents).where(eq(publishedContents.id, id)).limit(1))[0];
887
+ const publishedAt = /* @__PURE__ */ new Date();
888
+ const publishedAtIso = publishedAt.toISOString();
889
+ const overrides = {
890
+ status: sql`'published'`,
891
+ source_version: sql`version`,
892
+ updated_at: sql`now()`,
893
+ updated_by: sql`${actorId}::uuid`,
894
+ published_at: sql`${publishedAtIso}::timestamptz`
895
+ };
896
+ const columns = PROJECTION_COLUMNS.map((name) => sql.identifier(name));
897
+ const values = PROJECTION_COLUMNS.map((name) => overrides[name] ?? sql.identifier(name));
898
+ const updates = PROJECTION_COLUMNS.filter((name) => !PROJECTION_IDENTITY.has(name)).map((name) => sql`${sql.identifier(name)} = excluded.${sql.identifier(name)}`);
899
+ await tx.execute(sql`
900
+ insert into published_contents (${sql.join(columns, sql`, `)})
901
+ select ${sql.join(values, sql`, `)}
902
+ from contents where id = ${id}::uuid
903
+ on conflict (id) do update set ${sql.join(updates, sql`, `)}
904
+ `);
905
+ if (previous?.permalinkPath !== row.permalinkPath) await this.recomputePermalinks(tx, publishedContents, id);
906
+ const [updated] = await tx.update(contents).set({
907
+ status: "published",
908
+ publishedAt,
909
+ updatedBy: actorId
910
+ }).where(eq(contents.id, id)).returning();
911
+ return updated;
912
+ });
913
+ }
914
+ async unpublish(id) {
915
+ await this.db.transaction(async (tx) => {
916
+ await tx.execute(sql`
917
+ delete from published_contents
918
+ where path <@ (select path from contents where id = ${id}::uuid)
919
+ `);
920
+ await tx.update(contents).set({
921
+ status: "draft",
922
+ publishedAt: null
923
+ }).where(eq(contents.id, id));
924
+ });
925
+ }
926
+ async versions(contentId, limit = 50) {
927
+ return await this.db.select({
928
+ version: contentVersions.version,
929
+ createdAt: contentVersions.createdAt,
930
+ createdBy: contentVersions.createdBy,
931
+ label: contentVersions.label
932
+ }).from(contentVersions).where(eq(contentVersions.contentId, contentId)).orderBy(sql`version desc`).limit(limit);
933
+ }
934
+ /** The row exactly as it was at that version — `snapshot()` stores the whole row. */
935
+ async versionSnapshot(contentId, version) {
936
+ return (await this.db.select({ snapshot: contentVersions.snapshot }).from(contentVersions).where(and(eq(contentVersions.contentId, contentId), eq(contentVersions.version, version))).limit(1))[0]?.snapshot ?? null;
937
+ }
938
+ /** Every stored version of these documents, oldest first, for a space export. */
939
+ async history(contentIds) {
940
+ if (contentIds.length === 0) return [];
941
+ return this.db.select().from(contentVersions).where(inArray(contentVersions.contentId, contentIds)).orderBy(contentVersions.contentId, contentVersions.version);
942
+ }
943
+ /**
944
+ * Puts an imported document back the way its source had it: the version counter and
945
+ * timestamps `create()` had to invent, and the version history in place of the single
946
+ * snapshot `create()` wrote. The row's own columns are left alone otherwise.
947
+ */
948
+ async restoreHistory(id, data) {
949
+ await this.db.transaction(async (tx) => {
950
+ await tx.update(contents).set({
951
+ version: data.version,
952
+ createdAt: data.createdAt,
953
+ updatedAt: data.updatedAt
954
+ }).where(eq(contents.id, id));
955
+ if (data.versions.length === 0) return;
956
+ await tx.delete(contentVersions).where(eq(contentVersions.contentId, id));
957
+ await tx.insert(contentVersions).values(data.versions.map((version) => ({
958
+ ...version,
959
+ contentId: id
960
+ })));
961
+ });
962
+ }
963
+ async snapshot(db, row, actorId) {
964
+ await db.insert(contentVersions).values({
965
+ contentId: row.id,
966
+ version: row.version,
967
+ snapshot: row,
968
+ createdBy: actorId
969
+ }).onConflictDoNothing();
970
+ }
971
+ async findRow(db, id) {
972
+ return (await db.select().from(contents).where(eq(contents.id, id)).limit(1))[0] ?? null;
973
+ }
974
+ async lockRow(db, id) {
975
+ const row = (await db.select().from(contents).where(eq(contents.id, id)).limit(1).for("update"))[0];
976
+ if (!row) throw ManabloxError.notFound("content.notFound", { id });
977
+ return row;
978
+ }
979
+ async parentPath(db, parentId) {
980
+ if (!parentId) return null;
981
+ const path = (await db.select({ path: contents.path }).from(contents).where(eq(contents.id, parentId)).limit(1))[0]?.path;
982
+ if (!path) throw ManabloxError.notFound("content.parent.notFound", { id: parentId });
983
+ return path;
984
+ }
985
+ async parentPermalinkPath(db, parentId) {
986
+ if (!parentId) return "";
987
+ return (await db.select({ permalinkPath: contents.permalinkPath }).from(contents).where(eq(contents.id, parentId)).limit(1))[0]?.permalinkPath ?? "";
988
+ }
989
+ /** Guards against making a node its own ancestor, which would orphan the subtree. */
990
+ async assertNotOwnDescendant(db, id, parentId) {
991
+ if (!parentId) return;
992
+ if (parentId === id) throw ManabloxError.badRequest("content.parent.self");
993
+ if ((await db.select({ cycle: sql`exists (
994
+ select 1 from contents p
995
+ where p.id = ${parentId}::uuid
996
+ and p.path <@ (select path from contents where id = ${id}::uuid)
997
+ )` }).from(sql`(select 1) as _`))[0]?.cycle) throw ManabloxError.badRequest("content.parent.cycle", {
998
+ id,
999
+ parentId
1000
+ });
1001
+ }
1002
+ };
1003
+ function buildTree$1(rows, rootId) {
1004
+ const nodes = /* @__PURE__ */ new Map();
1005
+ for (const row of rows) {
1006
+ const { depth, ...content } = row;
1007
+ nodes.set(row.id, {
1008
+ content,
1009
+ depth,
1010
+ children: []
1011
+ });
1012
+ }
1013
+ const roots = [];
1014
+ for (const node of nodes.values()) {
1015
+ const parentId = node.content.parentId;
1016
+ const parent = parentId ? nodes.get(parentId) : void 0;
1017
+ if (parent && parentId !== rootId) parent.children.push(node);
1018
+ else if (parentId === rootId || !parent) roots.push(node);
1019
+ }
1020
+ return roots;
1021
+ }
1022
+ function joinSegment(prefix, segment) {
1023
+ if (segment === null) return prefix;
1024
+ return prefix ? `${prefix}/${segment}` : segment;
1025
+ }
1026
+ //#endregion
1027
+ //#region src/repositories/content-approval.ts
1028
+ /**
1029
+ * The approval history of documents. Rows are appended when someone asks and closed
1030
+ * when someone decides; the "one pending per document" rule is the service's, this
1031
+ * only offers the reads it needs to keep it.
1032
+ */
1033
+ var ContentApprovalRepository = class {
1034
+ db;
1035
+ constructor(db) {
1036
+ this.db = db;
1037
+ }
1038
+ async findById(id) {
1039
+ return (await this.db.select().from(contentApprovals).where(eq(contentApprovals.id, id)).limit(1))[0] ?? null;
1040
+ }
1041
+ /** The open request on a document, if there is one. */
1042
+ async pendingFor(contentId) {
1043
+ return (await this.db.select().from(contentApprovals).where(and(eq(contentApprovals.contentId, contentId), eq(contentApprovals.status, "pending"))).orderBy(desc(contentApprovals.requestedAt)).limit(1))[0] ?? null;
1044
+ }
1045
+ /** The most recent request on a document, whatever became of it. */
1046
+ async latestFor(contentId) {
1047
+ return (await this.db.select().from(contentApprovals).where(eq(contentApprovals.contentId, contentId)).orderBy(desc(contentApprovals.requestedAt)).limit(1))[0] ?? null;
1048
+ }
1049
+ historyFor(contentId, limit = 20) {
1050
+ return this.db.select().from(contentApprovals).where(eq(contentApprovals.contentId, contentId)).orderBy(desc(contentApprovals.requestedAt)).limit(limit);
1051
+ }
1052
+ /**
1053
+ * Every open request in a space, oldest first, with the document beside it. Narrowed
1054
+ * to `typeIds` when the reader may only publish some types; `null` means every type.
1055
+ */
1056
+ async pendingInSpace(spaceId, typeIds = null) {
1057
+ if (typeIds && typeIds.length === 0) return [];
1058
+ const conditions = [eq(contentApprovals.spaceId, spaceId), eq(contentApprovals.status, "pending")];
1059
+ if (typeIds) conditions.push(inArray(contentApprovals.typeId, typeIds));
1060
+ return (await this.db.select({
1061
+ approval: contentApprovals,
1062
+ content: {
1063
+ id: contents.id,
1064
+ title: contents.title,
1065
+ locale: contents.locale,
1066
+ typeId: contents.typeId,
1067
+ updatedAt: contents.updatedAt
1068
+ }
1069
+ }).from(contentApprovals).innerJoin(contents, eq(contents.id, contentApprovals.contentId)).where(and(...conditions)).orderBy(contentApprovals.requestedAt)).map((row) => ({
1070
+ ...row.approval,
1071
+ content: row.content
1072
+ }));
1073
+ }
1074
+ async request(data) {
1075
+ const [row] = await this.db.insert(contentApprovals).values({
1076
+ spaceId: data.spaceId,
1077
+ contentId: data.contentId,
1078
+ typeId: data.typeId,
1079
+ requestedBy: data.requestedBy,
1080
+ requestedByLabel: data.requestedByLabel,
1081
+ requestNote: data.requestNote ?? null,
1082
+ contentVersion: data.contentVersion ?? null
1083
+ }).returning();
1084
+ if (!row) throw new ManabloxError("content.approval.create.failed");
1085
+ return row;
1086
+ }
1087
+ async decide(id, data) {
1088
+ const [row] = await this.db.update(contentApprovals).set({
1089
+ status: data.status,
1090
+ decidedBy: data.decidedBy,
1091
+ decidedByLabel: data.decidedByLabel,
1092
+ decisionNote: data.decisionNote ?? null,
1093
+ decidedAt: /* @__PURE__ */ new Date()
1094
+ }).where(eq(contentApprovals.id, id)).returning();
1095
+ if (!row) throw ManabloxError.notFound("content.approval.notPending", { id });
1096
+ return row;
1097
+ }
1098
+ };
1099
+ //#endregion
1100
+ //#region src/repositories/content-type.ts
1101
+ /**
1102
+ * Persistence for *runtime-defined* content types only. Code-defined types come from
1103
+ * `manablox.config.ts` and are never written here — the registry merges both into one
1104
+ * shape, and `source` tells the admin which are read-only.
1105
+ */
1106
+ var ContentTypeRepository = class {
1107
+ db;
1108
+ constructor(db) {
1109
+ this.db = db;
1110
+ }
1111
+ async all() {
1112
+ return (await this.db.select().from(contentTypes)).map(toDefinition);
1113
+ }
1114
+ async findById(id) {
1115
+ const rows = await this.db.select().from(contentTypes).where(eq(contentTypes.id, id)).limit(1);
1116
+ return rows[0] ? toDefinition(rows[0]) : null;
1117
+ }
1118
+ async create(input) {
1119
+ const definition = defineContentType(input);
1120
+ const [row] = await this.db.insert(contentTypes).values({
1121
+ id: definition.id,
1122
+ spaceId: definition.spaceId,
1123
+ name: definition.name,
1124
+ label: definition.label,
1125
+ description: definition.description ?? null,
1126
+ icon: definition.icon ?? null,
1127
+ kind: definition.kind,
1128
+ hasSlug: definition.hasSlug,
1129
+ isPublishable: definition.isPublishable,
1130
+ isVisibleInTree: definition.isVisibleInTree,
1131
+ canBeVisibleInMenu: definition.canBeVisibleInMenu,
1132
+ requiresApproval: definition.requiresApproval,
1133
+ fields: definition.fields
1134
+ }).returning();
1135
+ if (!row) throw new ManabloxError("contentType.create.failed");
1136
+ return toDefinition(row);
1137
+ }
1138
+ async update(id, input) {
1139
+ const existing = await this.findById(id);
1140
+ if (!existing) throw ManabloxError.notFound("contentType.notFound", { id });
1141
+ const definition = defineContentType({
1142
+ ...input,
1143
+ id
1144
+ });
1145
+ for (const field of definition.fields) {
1146
+ const previous = existing.fields.find((candidate) => candidate.id === field.id);
1147
+ if (previous && previous.name !== field.name) throw ManabloxError.badRequest("contentType.field.name.immutable", {
1148
+ from: previous.name,
1149
+ to: field.name
1150
+ });
1151
+ }
1152
+ const [row] = await this.db.update(contentTypes).set({
1153
+ name: definition.name,
1154
+ label: definition.label,
1155
+ description: definition.description ?? null,
1156
+ icon: definition.icon ?? null,
1157
+ hasSlug: definition.hasSlug,
1158
+ isPublishable: definition.isPublishable,
1159
+ isVisibleInTree: definition.isVisibleInTree,
1160
+ canBeVisibleInMenu: definition.canBeVisibleInMenu,
1161
+ requiresApproval: definition.requiresApproval,
1162
+ fields: definition.fields,
1163
+ updatedAt: /* @__PURE__ */ new Date()
1164
+ }).where(eq(contentTypes.id, id)).returning();
1165
+ if (!row) throw ManabloxError.notFound("contentType.notFound", { id });
1166
+ return toDefinition(row);
1167
+ }
1168
+ async delete(id) {
1169
+ await this.db.delete(contentTypes).where(eq(contentTypes.id, id));
1170
+ }
1171
+ };
1172
+ function toDefinition(row) {
1173
+ return {
1174
+ id: row.id,
1175
+ name: row.name,
1176
+ label: row.label,
1177
+ ...row.description !== null ? { description: row.description } : {},
1178
+ ...row.icon !== null ? { icon: row.icon } : {},
1179
+ kind: row.kind,
1180
+ spaceId: row.spaceId,
1181
+ hasSlug: row.hasSlug,
1182
+ isPublishable: row.isPublishable,
1183
+ isVisibleInTree: row.isVisibleInTree,
1184
+ canBeVisibleInMenu: row.canBeVisibleInMenu,
1185
+ requiresApproval: row.requiresApproval,
1186
+ fields: row.fields,
1187
+ source: "runtime"
1188
+ };
1189
+ }
1190
+ //#endregion
1191
+ //#region src/repositories/menu.ts
1192
+ var MenuRepository = class {
1193
+ db;
1194
+ constructor(db) {
1195
+ this.db = db;
1196
+ }
1197
+ async listBySpace(spaceId) {
1198
+ return this.db.select().from(menus).where(eq(menus.spaceId, spaceId)).orderBy(menus.name);
1199
+ }
1200
+ async findById(id) {
1201
+ return (await this.db.select().from(menus).where(eq(menus.id, id)).limit(1))[0] ?? null;
1202
+ }
1203
+ async findByMachineName(spaceId, machineName) {
1204
+ return (await this.db.select().from(menus).where(and(eq(menus.spaceId, spaceId), eq(menus.machineName, machineName))).limit(1))[0] ?? null;
1205
+ }
1206
+ async create(data) {
1207
+ const [row] = await this.db.insert(menus).values({
1208
+ ...data.id ? { id: data.id } : {},
1209
+ spaceId: data.spaceId,
1210
+ name: data.name,
1211
+ machineName: data.machineName,
1212
+ description: data.description ?? null
1213
+ }).returning();
1214
+ if (!row) throw new ManabloxError("menu.create.failed");
1215
+ return row;
1216
+ }
1217
+ async update(id, data) {
1218
+ const [row] = await this.db.update(menus).set({
1219
+ ...data.name !== void 0 ? { name: data.name } : {},
1220
+ ...data.machineName !== void 0 ? { machineName: data.machineName } : {},
1221
+ ...data.description !== void 0 ? { description: data.description } : {},
1222
+ updatedAt: /* @__PURE__ */ new Date()
1223
+ }).where(eq(menus.id, id)).returning();
1224
+ if (!row) throw ManabloxError.notFound("menu.notFound", { id });
1225
+ return row;
1226
+ }
1227
+ async delete(id) {
1228
+ await this.db.delete(menus).where(eq(menus.id, id));
1229
+ }
1230
+ /** Every entry of a menu, flat, in tree order within each level. */
1231
+ async items(menuId) {
1232
+ return this.db.select().from(menuItems).where(eq(menuItems.menuId, menuId)).orderBy(asc(menuItems.position), asc(menuItems.id));
1233
+ }
1234
+ async tree(menuId) {
1235
+ return buildTree(await this.items(menuId));
1236
+ }
1237
+ /**
1238
+ * Replaces the whole entry tree in one transaction.
1239
+ *
1240
+ * A menu is edited as one document and saved as one, so this is simpler and safer than
1241
+ * a per-entry API whose partial failures would leave a half-reordered menu behind.
1242
+ */
1243
+ async setItems(menuId, tree) {
1244
+ const rows = [];
1245
+ const flatten = (nodes, parentId) => {
1246
+ nodes.forEach((node, position) => {
1247
+ const id = node.id ?? randomUUID();
1248
+ rows.push({
1249
+ id,
1250
+ menuId,
1251
+ parentId,
1252
+ position,
1253
+ localizationId: node.localizationId ?? null,
1254
+ label: node.label ?? null,
1255
+ url: node.url ?? null
1256
+ });
1257
+ flatten(node.children ?? [], id);
1258
+ });
1259
+ };
1260
+ flatten(tree, null);
1261
+ return this.db.transaction(async (tx) => {
1262
+ await tx.delete(menuItems).where(eq(menuItems.menuId, menuId));
1263
+ if (rows.length) await tx.insert(menuItems).values(rows);
1264
+ await tx.update(menus).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(menus.id, menuId));
1265
+ return buildTree(rows.map((row) => ({
1266
+ ...row,
1267
+ position: row.position ?? 0
1268
+ })));
1269
+ });
1270
+ }
1271
+ /** Menus that carry the document, for the editor's "used in" hint. */
1272
+ async menusReferencing(spaceId, localizationId) {
1273
+ return this.db.selectDistinct({
1274
+ id: menus.id,
1275
+ spaceId: menus.spaceId,
1276
+ name: menus.name,
1277
+ machineName: menus.machineName,
1278
+ description: menus.description,
1279
+ createdAt: menus.createdAt,
1280
+ updatedAt: menus.updatedAt
1281
+ }).from(menuItems).innerJoin(menus, eq(menuItems.menuId, menus.id)).where(and(eq(menus.spaceId, spaceId), eq(menuItems.localizationId, localizationId))).orderBy(menus.name);
1282
+ }
1283
+ /** Drops every entry pointing at a document, in every menu; sub-entries cascade. */
1284
+ async removeContent(localizationId) {
1285
+ return (await this.db.delete(menuItems).where(eq(menuItems.localizationId, localizationId)).returning({ id: menuItems.id })).length;
1286
+ }
1287
+ /**
1288
+ * The tree with each content entry's document for one locale. A content entry whose
1289
+ * document has no row in that locale — or, on the published table, no published one —
1290
+ * comes back with `content: null`; the caller decides whether to show or drop it.
1291
+ */
1292
+ async resolve(menu, locale, published = false) {
1293
+ const items = await this.items(menu.id);
1294
+ const localizationIds = [...new Set(items.flatMap((item) => item.localizationId ? [item.localizationId] : []))];
1295
+ const table = published ? publishedContents : contents;
1296
+ const rows = localizationIds.length ? await this.db.select().from(table).where(and(eq(table.spaceId, menu.spaceId), eq(table.locale, locale), inArray(table.localizationId, localizationIds))) : [];
1297
+ const byLocalization = new Map(rows.map((row) => [row.localizationId, row]));
1298
+ const toResolved = (node) => ({
1299
+ id: node.item.id,
1300
+ label: node.item.label,
1301
+ url: node.item.url,
1302
+ localizationId: node.item.localizationId,
1303
+ content: node.item.localizationId ? byLocalization.get(node.item.localizationId) ?? null : null,
1304
+ children: node.children.map(toResolved)
1305
+ });
1306
+ return buildTree(items).map(toResolved);
1307
+ }
1308
+ };
1309
+ function buildTree(rows) {
1310
+ const nodes = /* @__PURE__ */ new Map();
1311
+ for (const row of rows) nodes.set(row.id, {
1312
+ item: row,
1313
+ children: []
1314
+ });
1315
+ const roots = [];
1316
+ for (const node of nodes.values()) {
1317
+ const parent = node.item.parentId ? nodes.get(node.item.parentId) : void 0;
1318
+ (parent ? parent.children : roots).push(node);
1319
+ }
1320
+ const byPosition = (a, b) => a.item.position - b.item.position;
1321
+ const sort = (list) => {
1322
+ list.sort(byPosition);
1323
+ for (const node of list) sort(node.children);
1324
+ };
1325
+ sort(roots);
1326
+ return roots;
1327
+ }
1328
+ //#endregion
1329
+ //#region src/repositories/notification.ts
1330
+ /**
1331
+ * One person's inbox. Every method is scoped to a `userId`, so a row id from another
1332
+ * account is inert: there is no way to read or change what is not yours.
1333
+ */
1334
+ var NotificationRepository = class {
1335
+ db;
1336
+ constructor(db) {
1337
+ this.db = db;
1338
+ }
1339
+ /** Inserts one row per recipient in one statement; an empty list writes nothing. */
1340
+ async insertMany(rows) {
1341
+ if (rows.length === 0) return [];
1342
+ return this.db.insert(notifications).values(rows.map((row) => ({
1343
+ userId: row.userId,
1344
+ spaceId: row.spaceId ?? null,
1345
+ kind: row.kind,
1346
+ title: row.title,
1347
+ body: row.body ?? "",
1348
+ url: row.url ?? null,
1349
+ targetKind: row.targetKind ?? null,
1350
+ targetId: row.targetId ?? null,
1351
+ actorId: row.actorId ?? null,
1352
+ actorLabel: row.actorLabel ?? null,
1353
+ meta: row.meta ?? null
1354
+ }))).returning();
1355
+ }
1356
+ list(userId, filter = {}, pagination = {
1357
+ limit: 25,
1358
+ offset: 0
1359
+ }) {
1360
+ const conditions = [eq(notifications.userId, userId)];
1361
+ if (filter.unreadOnly) conditions.push(isNull(notifications.readAt));
1362
+ if (filter.kinds?.length) conditions.push(inArray(notifications.kind, filter.kinds));
1363
+ if (filter.spaceId === null) conditions.push(isNull(notifications.spaceId));
1364
+ else if (filter.spaceId) conditions.push(eq(notifications.spaceId, filter.spaceId));
1365
+ return paginate(this.db, notifications, {
1366
+ where: and(...conditions),
1367
+ orderBy: [desc(notifications.createdAt), desc(notifications.id)],
1368
+ pagination
1369
+ });
1370
+ }
1371
+ async findById(userId, id) {
1372
+ return (await this.db.select().from(notifications).where(and(eq(notifications.userId, userId), eq(notifications.id, id))).limit(1))[0] ?? null;
1373
+ }
1374
+ async countUnread(userId) {
1375
+ return (await this.db.select({ count: sql`count(*)::int` }).from(notifications).where(and(eq(notifications.userId, userId), isNull(notifications.readAt))))[0]?.count ?? 0;
1376
+ }
1377
+ /** Marks the named rows read; rows already read keep their original time. Returns how many changed. */
1378
+ async markRead(userId, ids) {
1379
+ if (ids.length === 0) return 0;
1380
+ return (await this.db.update(notifications).set({ readAt: /* @__PURE__ */ new Date() }).where(and(eq(notifications.userId, userId), inArray(notifications.id, ids), isNull(notifications.readAt))).returning({ id: notifications.id })).length;
1381
+ }
1382
+ async markUnread(userId, ids) {
1383
+ if (ids.length === 0) return 0;
1384
+ return (await this.db.update(notifications).set({ readAt: null }).where(and(eq(notifications.userId, userId), inArray(notifications.id, ids))).returning({ id: notifications.id })).length;
1385
+ }
1386
+ async markAllRead(userId) {
1387
+ return (await this.db.update(notifications).set({ readAt: /* @__PURE__ */ new Date() }).where(and(eq(notifications.userId, userId), isNull(notifications.readAt))).returning({ id: notifications.id })).length;
1388
+ }
1389
+ async delete(userId, ids) {
1390
+ if (ids.length === 0) return 0;
1391
+ return (await this.db.delete(notifications).where(and(eq(notifications.userId, userId), inArray(notifications.id, ids))).returning({ id: notifications.id })).length;
1392
+ }
1393
+ async deleteRead(userId) {
1394
+ return (await this.db.delete(notifications).where(and(eq(notifications.userId, userId), sql`${notifications.readAt} is not null`)).returning({ id: notifications.id })).length;
1395
+ }
1396
+ };
1397
+ //#endregion
1398
+ //#region src/repositories/role.ts
1399
+ var RoleRepository = class {
1400
+ db;
1401
+ constructor(db) {
1402
+ this.db = db;
1403
+ }
1404
+ async listBySpace(spaceId) {
1405
+ return this.db.select().from(roles).where(eq(roles.spaceId, spaceId)).orderBy(roles.name);
1406
+ }
1407
+ async findById(id) {
1408
+ return (await this.db.select().from(roles).where(eq(roles.id, id)).limit(1))[0] ?? null;
1409
+ }
1410
+ async findByMachineName(spaceId, machineName) {
1411
+ return (await this.db.select().from(roles).where(and(eq(roles.spaceId, spaceId), eq(roles.machineName, machineName))).limit(1))[0] ?? null;
1412
+ }
1413
+ async create(spaceId, data) {
1414
+ const [row] = await this.db.insert(roles).values({
1415
+ spaceId,
1416
+ ...data
1417
+ }).returning();
1418
+ if (!row) throw new ManabloxError("role.create.failed");
1419
+ return row;
1420
+ }
1421
+ async update(id, data) {
1422
+ const [row] = await this.db.update(roles).set({
1423
+ ...data,
1424
+ updatedAt: /* @__PURE__ */ new Date()
1425
+ }).where(eq(roles.id, id)).returning();
1426
+ if (!row) throw ManabloxError.notFound("role.notFound", { id });
1427
+ return row;
1428
+ }
1429
+ async delete(id) {
1430
+ await this.db.delete(roles).where(eq(roles.id, id));
1431
+ }
1432
+ /** How many members of the role's space hold it, by name. */
1433
+ async countMembers(spaceId, machineName) {
1434
+ return (await this.db.select({ count: sql`count(*)::int` }).from(memberships).where(and(eq(memberships.spaceId, spaceId), eq(memberships.role, machineName))))[0]?.count ?? 0;
1435
+ }
1436
+ /**
1437
+ * Drops every grant narrowed to a content type from every role, once the type is
1438
+ * gone. Grants are a JSON array, so this is one statement across the roles that carry
1439
+ * such a grant rather than a read-modify-write per role.
1440
+ */
1441
+ async pruneContentType(typeId) {
1442
+ const suffix = `:${typeId}`;
1443
+ await this.db.update(roles).set({
1444
+ permissions: sql`(
1445
+ select coalesce(jsonb_agg(value), '[]'::jsonb)
1446
+ from jsonb_array_elements_text(${roles.permissions}) as value
1447
+ where value not like ${`%${suffix}`}
1448
+ )`,
1449
+ updatedAt: /* @__PURE__ */ new Date()
1450
+ }).where(sql`${roles.permissions}::text like ${`%${suffix}%`}`);
1451
+ }
1452
+ };
1453
+ //#endregion
1454
+ //#region src/repositories/space.ts
1455
+ var SpaceRepository = class {
1456
+ db;
1457
+ constructor(db) {
1458
+ this.db = db;
1459
+ }
1460
+ async all() {
1461
+ return this.db.select().from(spaces).orderBy(spaces.name);
1462
+ }
1463
+ async findManyByIds(ids) {
1464
+ if (ids.length === 0) return [];
1465
+ return this.db.select().from(spaces).where(inArray(spaces.id, ids)).orderBy(spaces.name);
1466
+ }
1467
+ async findById(id) {
1468
+ return (await this.db.select().from(spaces).where(eq(spaces.id, id)).limit(1))[0] ?? null;
1469
+ }
1470
+ async findByMachineName(machineName) {
1471
+ return (await this.db.select().from(spaces).where(eq(spaces.machineName, machineName)).limit(1))[0] ?? null;
1472
+ }
1473
+ async create(data) {
1474
+ const [row] = await this.db.insert(spaces).values({
1475
+ ...data.id ? { id: data.id } : {},
1476
+ name: data.name,
1477
+ machineName: data.machineName,
1478
+ description: data.description ?? null,
1479
+ url: data.url,
1480
+ defaultLocale: data.defaultLocale ?? "en",
1481
+ locales: data.locales ?? [data.defaultLocale ?? "en"],
1482
+ settings: data.settings ?? {}
1483
+ }).returning();
1484
+ if (!row) throw new ManabloxError("space.create.failed");
1485
+ return row;
1486
+ }
1487
+ async update(id, data) {
1488
+ const [row] = await this.db.update(spaces).set({
1489
+ ...data.name !== void 0 ? { name: data.name } : {},
1490
+ ...data.machineName !== void 0 ? { machineName: data.machineName } : {},
1491
+ ...data.description !== void 0 ? { description: data.description } : {},
1492
+ ...data.url !== void 0 ? { url: data.url } : {},
1493
+ ...data.defaultLocale !== void 0 ? { defaultLocale: data.defaultLocale } : {},
1494
+ ...data.locales !== void 0 ? { locales: data.locales } : {},
1495
+ ...data.settings !== void 0 ? { settings: data.settings } : {},
1496
+ updatedAt: /* @__PURE__ */ new Date()
1497
+ }).where(eq(spaces.id, id)).returning();
1498
+ if (!row) throw ManabloxError.notFound("space.notFound", { id });
1499
+ return row;
1500
+ }
1501
+ async delete(id) {
1502
+ await this.db.delete(spaces).where(eq(spaces.id, id));
1503
+ }
1504
+ };
1505
+ //#endregion
1506
+ //#region src/repositories/user.ts
1507
+ /**
1508
+ * How better-auth 1.7 identifies an email + password credential: sign-in looks for an
1509
+ * account with this provider *and* this issuer, so a row missing either is invisible to
1510
+ * it and the account can never sign in.
1511
+ */
1512
+ const CREDENTIAL_PROVIDER = "credential";
1513
+ const CREDENTIAL_ISSUER = "local:credential";
1514
+ var UserRepository = class {
1515
+ db;
1516
+ constructor(db) {
1517
+ this.db = db;
1518
+ }
1519
+ async findById(id) {
1520
+ return (await this.db.select().from(users).where(eq(users.id, id)).limit(1))[0] ?? null;
1521
+ }
1522
+ async findManyByIds(ids) {
1523
+ if (ids.length === 0) return [];
1524
+ return this.db.select().from(users).where(inArray(users.id, ids));
1525
+ }
1526
+ /** Every account holding an instance role, for a fan-out to the superadmins. */
1527
+ async findByRole(role) {
1528
+ return this.db.select().from(users).where(eq(users.role, role));
1529
+ }
1530
+ async findByEmail(email) {
1531
+ return (await this.db.select().from(users).where(eq(users.email, email)).limit(1))[0] ?? null;
1532
+ }
1533
+ async list(pagination, search) {
1534
+ const where = search ? sql`${users.email} ilike ${`%${search}%`} or ${users.name} ilike ${`%${search}%`}` : void 0;
1535
+ return paginate(this.db, users, {
1536
+ where,
1537
+ orderBy: desc(users.createdAt),
1538
+ pagination
1539
+ });
1540
+ }
1541
+ /**
1542
+ * Users who are not members of a space, matching a search, newest first: the
1543
+ * add-member picker's candidates, decided in SQL rather than by loading a page of
1544
+ * users and filtering it here.
1545
+ */
1546
+ async candidates(spaceId, search, limit) {
1547
+ const notMember = sql`not exists (select 1 from ${memberships} where ${memberships.userId} = ${users.id} and ${memberships.spaceId} = ${spaceId})`;
1548
+ const where = search ? and(notMember, sql`(${users.email} ilike ${`%${search}%`} or ${users.name} ilike ${`%${search}%`})`) : notMember;
1549
+ return this.db.select().from(users).where(where).orderBy(desc(users.createdAt)).limit(limit);
1550
+ }
1551
+ async count() {
1552
+ return (await this.db.select({ count: sql`count(*)::int` }).from(users))[0]?.count ?? 0;
1553
+ }
1554
+ /**
1555
+ * Inserts the user and its password credential together, so a failure on the second
1556
+ * row cannot leave an account nobody can sign in to. The account row is shaped the way
1557
+ * better-auth writes it on sign-up, so a sign-in later finds it as its own.
1558
+ */
1559
+ async create(data) {
1560
+ return this.db.transaction(async (tx) => {
1561
+ const [user] = await tx.insert(users).values({
1562
+ name: data.name,
1563
+ email: data.email,
1564
+ role: data.role
1565
+ }).returning();
1566
+ if (!user) throw new ManabloxError("user.create.failed");
1567
+ await tx.insert(accounts).values({
1568
+ userId: user.id,
1569
+ accountId: user.id,
1570
+ providerId: CREDENTIAL_PROVIDER,
1571
+ issuer: CREDENTIAL_ISSUER,
1572
+ password: data.passwordHash
1573
+ });
1574
+ return user;
1575
+ });
1576
+ }
1577
+ async update(id, data) {
1578
+ const [row] = await this.db.update(users).set({
1579
+ ...data,
1580
+ updatedAt: /* @__PURE__ */ new Date()
1581
+ }).where(eq(users.id, id)).returning();
1582
+ if (!row) throw ManabloxError.notFound("user.notFound", { id });
1583
+ return row;
1584
+ }
1585
+ async delete(id) {
1586
+ await this.db.delete(users).where(eq(users.id, id));
1587
+ }
1588
+ async setBanned(id, banned, reason) {
1589
+ const [row] = await this.db.update(users).set({
1590
+ banned,
1591
+ banReason: banned ? reason : null,
1592
+ updatedAt: /* @__PURE__ */ new Date()
1593
+ }).where(eq(users.id, id)).returning();
1594
+ if (!row) throw ManabloxError.notFound("user.notFound", { id });
1595
+ return row;
1596
+ }
1597
+ /**
1598
+ * Replaces the password credential, creating it for an account that only ever signed
1599
+ * in through another provider.
1600
+ */
1601
+ async setPasswordHash(userId, passwordHash) {
1602
+ if ((await this.db.update(accounts).set({
1603
+ password: passwordHash,
1604
+ updatedAt: /* @__PURE__ */ new Date()
1605
+ }).where(and(eq(accounts.userId, userId), eq(accounts.providerId, CREDENTIAL_PROVIDER), eq(accounts.issuer, CREDENTIAL_ISSUER))).returning({ id: accounts.id })).length) return;
1606
+ await this.db.insert(accounts).values({
1607
+ userId,
1608
+ accountId: userId,
1609
+ providerId: CREDENTIAL_PROVIDER,
1610
+ issuer: CREDENTIAL_ISSUER,
1611
+ password: passwordHash
1612
+ });
1613
+ }
1614
+ /** Signs the user out everywhere. */
1615
+ async revokeSessions(userId) {
1616
+ await this.db.delete(sessions).where(eq(sessions.userId, userId));
1617
+ }
1618
+ async countByRole(role) {
1619
+ return (await this.db.select({ count: sql`count(*)::int` }).from(users).where(eq(users.role, role)))[0]?.count ?? 0;
1620
+ }
1621
+ async setNotificationPreferences(id, preferences) {
1622
+ const [row] = await this.db.update(users).set({
1623
+ notificationPreferences: preferences,
1624
+ updatedAt: /* @__PURE__ */ new Date()
1625
+ }).where(eq(users.id, id)).returning();
1626
+ if (!row) throw ManabloxError.notFound("user.notFound", { id });
1627
+ return row;
1628
+ }
1629
+ /** The stored hash of the password credential, or `null` for an account without one. */
1630
+ async passwordHash(userId) {
1631
+ return (await this.db.select({ password: accounts.password }).from(accounts).where(and(eq(accounts.userId, userId), eq(accounts.providerId, CREDENTIAL_PROVIDER), eq(accounts.issuer, CREDENTIAL_ISSUER))).limit(1))[0]?.password ?? null;
1632
+ }
1633
+ async setRole(id, role) {
1634
+ const [row] = await this.db.update(users).set({
1635
+ role,
1636
+ updatedAt: /* @__PURE__ */ new Date()
1637
+ }).where(eq(users.id, id)).returning();
1638
+ if (!row) throw ManabloxError.notFound("user.notFound", { id });
1639
+ return row;
1640
+ }
1641
+ /**
1642
+ * Authoritative role plus space memberships in one query.
1643
+ *
1644
+ * Read on every authenticated request rather than trusting the role embedded in the
1645
+ * session: better-auth caches the session payload (five minutes by default), so a
1646
+ * promotion or demotion would otherwise not take effect until that cache expired.
1647
+ *
1648
+ * A membership naming a custom role joins that role's grants; one naming a built-in
1649
+ * role has none here, and the auth package answers those from its own table.
1650
+ */
1651
+ async principal(userId) {
1652
+ const rows = await this.db.select({
1653
+ role: users.role,
1654
+ banned: users.banned,
1655
+ spaceId: memberships.spaceId,
1656
+ spaceRole: memberships.role,
1657
+ grants: roles.permissions
1658
+ }).from(users).leftJoin(memberships, eq(memberships.userId, users.id)).leftJoin(roles, and(eq(roles.spaceId, memberships.spaceId), eq(roles.machineName, memberships.role))).where(eq(users.id, userId));
1659
+ const first = rows[0];
1660
+ if (!first) return null;
1661
+ const spaces = {};
1662
+ const permissions = {};
1663
+ for (const row of rows) {
1664
+ if (!row.spaceId || !row.spaceRole) continue;
1665
+ spaces[row.spaceId] = row.spaceRole;
1666
+ if (row.grants) permissions[row.spaceId] = row.grants;
1667
+ }
1668
+ return {
1669
+ role: first.role,
1670
+ banned: first.banned,
1671
+ spaces,
1672
+ permissions
1673
+ };
1674
+ }
1675
+ async memberships(userId) {
1676
+ return this.db.select().from(memberships).where(eq(memberships.userId, userId));
1677
+ }
1678
+ /** The user's memberships with the space each one is in, for a per-user view. */
1679
+ async membershipsWithSpaces(userId) {
1680
+ return (await this.db.select({
1681
+ membership: memberships,
1682
+ space: spaces
1683
+ }).from(memberships).innerJoin(spaces, eq(spaces.id, memberships.spaceId)).where(eq(memberships.userId, userId)).orderBy(spaces.name)).map((row) => ({
1684
+ ...row.membership,
1685
+ space: row.space
1686
+ }));
1687
+ }
1688
+ async membersOf(spaceId) {
1689
+ return (await this.db.select({
1690
+ membership: memberships,
1691
+ user: users
1692
+ }).from(memberships).innerJoin(users, eq(users.id, memberships.userId)).where(eq(memberships.spaceId, spaceId))).map((row) => ({
1693
+ ...row.membership,
1694
+ user: row.user
1695
+ }));
1696
+ }
1697
+ async roleIn(userId, spaceId) {
1698
+ return (await this.db.select({ role: memberships.role }).from(memberships).where(and(eq(memberships.userId, userId), eq(memberships.spaceId, spaceId))).limit(1))[0]?.role ?? null;
1699
+ }
1700
+ async grant(userId, spaceId, role) {
1701
+ await this.db.insert(memberships).values({
1702
+ userId,
1703
+ spaceId,
1704
+ role
1705
+ }).onConflictDoUpdate({
1706
+ target: [memberships.userId, memberships.spaceId],
1707
+ set: { role }
1708
+ });
1709
+ }
1710
+ async revoke(userId, spaceId) {
1711
+ await this.db.delete(memberships).where(and(eq(memberships.userId, userId), eq(memberships.spaceId, spaceId)));
1712
+ }
1713
+ };
1714
+ //#endregion
1715
+ //#region src/repositories/webhook.ts
1716
+ /** The webhooks of a space and the log of what was sent to them. */
1717
+ var WebhookRepository = class {
1718
+ db;
1719
+ constructor(db) {
1720
+ this.db = db;
1721
+ }
1722
+ async findById(id) {
1723
+ return (await this.db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1))[0] ?? null;
1724
+ }
1725
+ async listBySpace(spaceId) {
1726
+ return this.db.select().from(webhooks).where(eq(webhooks.spaceId, spaceId)).orderBy(webhooks.name);
1727
+ }
1728
+ async create(data) {
1729
+ const [row] = await this.db.insert(webhooks).values({
1730
+ ...data.id ? { id: data.id } : {},
1731
+ spaceId: data.spaceId,
1732
+ name: data.name,
1733
+ url: data.url,
1734
+ secret: data.secret ?? null,
1735
+ events: data.events,
1736
+ enabled: data.enabled ?? true
1737
+ }).returning();
1738
+ if (!row) throw new ManabloxError("webhook.create.failed");
1739
+ return row;
1740
+ }
1741
+ /** The switched-on webhooks of a space, for fanning an event out. */
1742
+ async findEnabled(spaceId) {
1743
+ return this.db.select().from(webhooks).where(and(eq(webhooks.spaceId, spaceId), eq(webhooks.enabled, true)));
1744
+ }
1745
+ async recordDelivery(data) {
1746
+ const [row] = await this.db.insert(webhookDeliveries).values(data).returning();
1747
+ return row;
1748
+ }
1749
+ async deliveries(webhookId, limit = 50) {
1750
+ return this.db.select().from(webhookDeliveries).where(eq(webhookDeliveries.webhookId, webhookId)).orderBy(webhookDeliveries.createdAt).limit(limit);
1751
+ }
1752
+ };
1753
+ //#endregion
1754
+ //#region src/repositories/workflow.ts
1755
+ /** How many runs a workflow keeps; older ones are pruned as new ones are written. */
1756
+ const RUNS_KEPT_PER_WORKFLOW = 200;
1757
+ var WorkflowRepository = class {
1758
+ db;
1759
+ constructor(db) {
1760
+ this.db = db;
1761
+ }
1762
+ async listBySpace(spaceId) {
1763
+ return this.db.select().from(workflows).where(eq(workflows.spaceId, spaceId)).orderBy(workflows.name);
1764
+ }
1765
+ /** Every enabled workflow of a space, for the dispatcher; every enabled one at all for the scheduler. */
1766
+ async listEnabled(spaceId) {
1767
+ return this.db.select().from(workflows).where(spaceId ? and(eq(workflows.enabled, true), eq(workflows.spaceId, spaceId)) : eq(workflows.enabled, true));
1768
+ }
1769
+ async findById(id) {
1770
+ return (await this.db.select().from(workflows).where(eq(workflows.id, id)).limit(1))[0] ?? null;
1771
+ }
1772
+ async create(data) {
1773
+ const [row] = await this.db.insert(workflows).values({
1774
+ ...data.id ? { id: data.id } : {},
1775
+ spaceId: data.spaceId,
1776
+ name: data.name,
1777
+ description: data.description ?? null,
1778
+ enabled: data.enabled ?? false,
1779
+ trigger: data.trigger,
1780
+ steps: data.steps
1781
+ }).returning();
1782
+ if (!row) throw new ManabloxError("workflow.create.failed");
1783
+ return row;
1784
+ }
1785
+ async update(id, data) {
1786
+ const [row] = await this.db.update(workflows).set({
1787
+ ...data.name !== void 0 ? { name: data.name } : {},
1788
+ ...data.description !== void 0 ? { description: data.description } : {},
1789
+ ...data.enabled !== void 0 ? { enabled: data.enabled } : {},
1790
+ ...data.trigger !== void 0 ? { trigger: data.trigger } : {},
1791
+ ...data.steps !== void 0 ? { steps: data.steps } : {},
1792
+ updatedAt: /* @__PURE__ */ new Date()
1793
+ }).where(eq(workflows.id, id)).returning();
1794
+ if (!row) throw ManabloxError.notFound("workflow.notFound", { id });
1795
+ return row;
1796
+ }
1797
+ async delete(id) {
1798
+ await this.db.delete(workflows).where(eq(workflows.id, id));
1799
+ }
1800
+ /**
1801
+ * Claims a scheduled workflow for one minute. Returns false when another process got
1802
+ * there first — the update matches nothing once `lastScheduledAt` is already `minute`.
1803
+ */
1804
+ async claimSchedule(id, minute) {
1805
+ return (await this.db.update(workflows).set({ lastScheduledAt: minute }).where(and(eq(workflows.id, id), or(isNull(workflows.lastScheduledAt), lt(workflows.lastScheduledAt, minute)))).returning({ id: workflows.id })).length > 0;
1806
+ }
1807
+ async touchRun(id, at) {
1808
+ await this.db.update(workflows).set({ lastRunAt: at }).where(eq(workflows.id, id));
1809
+ }
1810
+ async createRun(data) {
1811
+ const [row] = await this.db.insert(workflowRuns).values({
1812
+ workflowId: data.workflowId,
1813
+ spaceId: data.spaceId,
1814
+ trigger: data.trigger,
1815
+ context: data.context,
1816
+ status: "queued"
1817
+ }).returning();
1818
+ if (!row) throw new ManabloxError("workflow.create.failed");
1819
+ await this.pruneRuns(data.workflowId);
1820
+ return row;
1821
+ }
1822
+ async findRun(id) {
1823
+ return (await this.db.select().from(workflowRuns).where(eq(workflowRuns.id, id)).limit(1))[0] ?? null;
1824
+ }
1825
+ async listRuns(workflowId, limit = 50) {
1826
+ return this.db.select().from(workflowRuns).where(eq(workflowRuns.workflowId, workflowId)).orderBy(desc(workflowRuns.createdAt)).limit(limit);
1827
+ }
1828
+ /**
1829
+ * Moves a run from `queued` or `waiting` to `running`, or reports that it is not
1830
+ * there to be moved. The status check in the predicate is what keeps two workers off
1831
+ * the same run.
1832
+ */
1833
+ async claimRun(id) {
1834
+ return (await this.db.update(workflowRuns).set({
1835
+ status: "running",
1836
+ startedAt: sql`coalesce(${workflowRuns.startedAt}, now())`
1837
+ }).where(and(eq(workflowRuns.id, id), inArray(workflowRuns.status, ["queued", "waiting"]))).returning())[0] ?? null;
1838
+ }
1839
+ /** Runs paused by a delay step whose time has come. */
1840
+ async dueRuns(now, limit = 100) {
1841
+ return this.db.select().from(workflowRuns).where(and(eq(workflowRuns.status, "waiting"), lte(workflowRuns.resumeAt, now))).orderBy(workflowRuns.resumeAt).limit(limit);
1842
+ }
1843
+ async saveRunProgress(id, data) {
1844
+ await this.db.update(workflowRuns).set({
1845
+ status: data.status,
1846
+ cursor: data.cursor,
1847
+ log: data.log,
1848
+ error: data.error ?? null,
1849
+ resumeAt: data.resumeAt ?? null,
1850
+ ...data.finished ? { finishedAt: /* @__PURE__ */ new Date() } : {}
1851
+ }).where(eq(workflowRuns.id, id));
1852
+ }
1853
+ async pruneRuns(workflowId) {
1854
+ await this.db.execute(sql`
1855
+ delete from ${workflowRuns}
1856
+ where ${workflowRuns.workflowId} = ${workflowId}
1857
+ and ${workflowRuns.id} in (
1858
+ select id from ${workflowRuns}
1859
+ where ${workflowRuns.workflowId} = ${workflowId}
1860
+ order by ${workflowRuns.createdAt} desc
1861
+ offset ${200}
1862
+ )
1863
+ `);
1864
+ }
1865
+ /** The documents a scheduled workflow's selection names, newest change first. */
1866
+ async selectDocuments(spaceId, selection, limit = 500) {
1867
+ const predicates = [eq(contents.spaceId, spaceId)];
1868
+ if (selection.typeIds.length) predicates.push(inArray(contents.typeId, selection.typeIds));
1869
+ if (selection.status !== "any") predicates.push(eq(contents.status, selection.status));
1870
+ if (selection.locale) predicates.push(eq(contents.locale, selection.locale));
1871
+ if (selection.changedWithinHours) {
1872
+ const since = /* @__PURE__ */ new Date(Date.now() - selection.changedWithinHours * 36e5);
1873
+ predicates.push(gte(contents.updatedAt, since));
1874
+ }
1875
+ return this.db.select().from(contents).where(and(...predicates)).orderBy(desc(contents.updatedAt)).limit(limit);
1876
+ }
1877
+ async subscriptionsFor(userIds) {
1878
+ if (userIds.length === 0) return [];
1879
+ return this.db.select().from(pushSubscriptions).where(inArray(pushSubscriptions.userId, userIds));
1880
+ }
1881
+ async subscriptionsOf(userId) {
1882
+ return this.db.select().from(pushSubscriptions).where(eq(pushSubscriptions.userId, userId)).orderBy(desc(pushSubscriptions.createdAt));
1883
+ }
1884
+ /** Upserts on the endpoint: a browser re-subscribing keeps one row, not two. */
1885
+ async subscribe(data) {
1886
+ const [row] = await this.db.insert(pushSubscriptions).values(data).onConflictDoUpdate({
1887
+ target: pushSubscriptions.endpoint,
1888
+ set: {
1889
+ userId: data.userId,
1890
+ keys: data.keys,
1891
+ userAgent: data.userAgent
1892
+ }
1893
+ }).returning();
1894
+ if (!row) throw new ManabloxError("workflow.create.failed");
1895
+ return row;
1896
+ }
1897
+ async unsubscribe(userId, endpoint) {
1898
+ await this.db.delete(pushSubscriptions).where(and(eq(pushSubscriptions.userId, userId), eq(pushSubscriptions.endpoint, endpoint)));
1899
+ }
1900
+ /** A push service answered 404/410: the browser is gone, and so is the row. */
1901
+ async dropSubscription(id) {
1902
+ await this.db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, id));
1903
+ }
1904
+ async markSubscriptionUsed(id) {
1905
+ await this.db.update(pushSubscriptions).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq(pushSubscriptions.id, id));
1906
+ }
1907
+ };
1908
+ //#endregion
1909
+ //#region src/repositories/index.ts
1910
+ function createRepositories(db, registry, options = {}) {
1911
+ return {
1912
+ content: new ContentRepository(db, registry),
1913
+ contentTypes: new ContentTypeRepository(db),
1914
+ spaces: new SpaceRepository(db),
1915
+ assets: new AssetRepository(db),
1916
+ assetUsages: new AssetUsageRepository(db),
1917
+ users: new UserRepository(db),
1918
+ menus: new MenuRepository(db),
1919
+ roles: new RoleRepository(db),
1920
+ workflows: new WorkflowRepository(db),
1921
+ webhooks: new WebhookRepository(db),
1922
+ audit: new AuditRepository(db, options.audit),
1923
+ notifications: new NotificationRepository(db),
1924
+ approvals: new ContentApprovalRepository(db)
1925
+ };
1926
+ }
1927
+ //#endregion
1928
+ export { buildOrderBy as _, UserRepository as a, applyBootstrapSql as b, MenuRepository as c, ContentRepository as d, buildTree$1 as f, buildContentWhere as g, AssetRepository as h, WebhookRepository as i, ContentTypeRepository as l, AssetUsageRepository as m, RUNS_KEPT_PER_WORKFLOW as n, SpaceRepository as o, AuditRepository as p, WorkflowRepository as r, NotificationRepository as s, createRepositories as t, ContentApprovalRepository as u, paginate as v, createDatabase as y };