@manablox/db 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/index-Cyf_N5K3.d.ts +658 -0
  2. package/dist/index-rZ24t-Ln.d.ts +4338 -0
  3. package/dist/index.d.ts +123 -0
  4. package/dist/index.js +60 -0
  5. package/dist/repositories-DYjzuuF6.js +1533 -0
  6. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  7. package/dist/schema-Bb4p16Yz.js +539 -0
  8. package/dist/schema.d.ts +2 -0
  9. package/dist/schema.js +2 -0
  10. package/dist/testing.d.ts +77 -0
  11. package/dist/testing.js +217 -0
  12. package/package.json +18 -10
  13. package/drizzle.config.ts +0 -11
  14. package/src/bootstrap.ts +0 -13
  15. package/src/cli/create-db.ts +0 -30
  16. package/src/cli/migrate.ts +0 -17
  17. package/src/client.ts +0 -44
  18. package/src/columns.ts +0 -39
  19. package/src/errors.ts +0 -50
  20. package/src/index.ts +0 -19
  21. package/src/migrate.ts +0 -21
  22. package/src/pagination.ts +0 -52
  23. package/src/query.ts +0 -213
  24. package/src/repositories/asset-usage.ts +0 -166
  25. package/src/repositories/asset.ts +0 -181
  26. package/src/repositories/content-type.ts +0 -116
  27. package/src/repositories/content.ts +0 -811
  28. package/src/repositories/index.ts +0 -40
  29. package/src/repositories/menu.ts +0 -235
  30. package/src/repositories/role.ts +0 -85
  31. package/src/repositories/space.ts +0 -83
  32. package/src/repositories/user.ts +0 -280
  33. package/src/repositories/webhook.ts +0 -46
  34. package/src/repositories/workflow.ts +0 -306
  35. package/src/schema/assets.ts +0 -108
  36. package/src/schema/auth.ts +0 -166
  37. package/src/schema/content-types.ts +0 -31
  38. package/src/schema/content.ts +0 -133
  39. package/src/schema/index.ts +0 -38
  40. package/src/schema/menus.ts +0 -61
  41. package/src/schema/relations.ts +0 -64
  42. package/src/schema/spaces.ts +0 -20
  43. package/src/schema/webhooks.ts +0 -46
  44. package/src/schema/workflows.ts +0 -92
  45. package/src/testing-fixtures.ts +0 -139
  46. package/src/testing.ts +0 -105
  47. package/test/asset-usage.test.ts +0 -101
  48. package/test/menu.test.ts +0 -126
  49. package/test/publish.test.ts +0 -130
  50. package/test/query.test.ts +0 -170
  51. package/test/role.test.ts +0 -81
  52. package/test/tree.test.ts +0 -188
  53. package/test/user.test.ts +0 -126
  54. package/test/webhook.test.ts +0 -48
  55. package/tsconfig.json +0 -4
  56. package/vitest.config.ts +0 -10
@@ -0,0 +1,1533 @@
1
+ import { A as contentVersions, C as roles, D as assetUsages, F as idToLabel, M as publishedContents, N as spaces, O as assetVariants, S as memberships, T as users, _ as menuItems, a as webhookDeliveries, b as accounts, i as workflows, j as contents, k as assets, n as pushSubscriptions, o as webhooks, r as workflowRuns, t as schema_exports, v as menus, w as sessions, y as contentTypes } from "./schema-Bb4p16Yz.js";
2
+ import { drizzle } from "drizzle-orm/postgres-js";
3
+ import postgres from "postgres";
4
+ import { and, asc, desc, eq, getTableColumns, gte, inArray, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm";
5
+ import { ManabloxError, defineContentType } 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 = {
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(String(filter.value ?? ""))}%`}`;
105
+ case "startsWith": return sql`${text} ilike ${`${escapeLike(String(filter.value ?? ""))}%`}`;
106
+ case "endsWith": return sql`${text} ilike ${`%${escapeLike(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[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 = (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
+ async variants(assetIds) {
214
+ if (assetIds.length === 0) return [];
215
+ return this.db.select().from(assetVariants).where(inArray(assetVariants.assetId, assetIds));
216
+ }
217
+ async findVariant(assetId, preset, format) {
218
+ 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;
219
+ }
220
+ /** Drops every variant row; the caller removes the files. */
221
+ async deleteVariants(assetId) {
222
+ await this.db.delete(assetVariants).where(eq(assetVariants.assetId, assetId));
223
+ }
224
+ async addVariant(data) {
225
+ const [row] = await this.db.insert(assetVariants).values({
226
+ assetId: data.assetId,
227
+ preset: data.preset,
228
+ format: data.format,
229
+ key: data.key,
230
+ width: data.width ?? null,
231
+ height: data.height ?? null,
232
+ size: data.size
233
+ }).onConflictDoUpdate({
234
+ target: [
235
+ assetVariants.assetId,
236
+ assetVariants.preset,
237
+ assetVariants.format
238
+ ],
239
+ set: {
240
+ key: data.key,
241
+ size: data.size
242
+ }
243
+ }).returning();
244
+ if (!row) throw new ManabloxError("assetVariant.create.failed");
245
+ return row;
246
+ }
247
+ };
248
+ //#endregion
249
+ //#region src/repositories/asset-usage.ts
250
+ /**
251
+ * The asset → document reachability index.
252
+ *
253
+ * `published` tracks the *published projection*, not the draft: a draft that adds an
254
+ * image does not make that image public, and a draft that removes one does not make it
255
+ * private until the change is published. Every method below preserves that distinction,
256
+ * which is why the column exists rather than the table simply holding published rows.
257
+ */
258
+ var AssetUsageRepository = class {
259
+ db;
260
+ constructor(db) {
261
+ this.db = db;
262
+ }
263
+ /** The subset of `assetIds` reachable from at least one published document. */
264
+ async filterPublished(assetIds) {
265
+ if (assetIds.length === 0) return /* @__PURE__ */ new Set();
266
+ const rows = await this.db.selectDistinct({ assetId: assetUsages.assetId }).from(assetUsages).where(and(inArray(assetUsages.assetId, assetIds), eq(assetUsages.published, true)));
267
+ return new Set(rows.map((row) => row.assetId));
268
+ }
269
+ async forContent(contentId) {
270
+ return this.db.select({
271
+ assetId: assetUsages.assetId,
272
+ published: assetUsages.published
273
+ }).from(assetUsages).where(eq(assetUsages.contentId, contentId));
274
+ }
275
+ /**
276
+ * Records what a *draft* references.
277
+ *
278
+ * Rows the draft dropped are removed only if they are not currently published —
279
+ * otherwise editing a draft would silently revoke access to an image the live page is
280
+ * still showing.
281
+ */
282
+ async recordDraft(contentId, spaceId, assetIds) {
283
+ const unique = [...new Set(assetIds)];
284
+ await this.db.transaction(async (tx) => {
285
+ await tx.delete(assetUsages).where(and(eq(assetUsages.contentId, contentId), eq(assetUsages.published, false), ...unique.length > 0 ? [notInArray(assetUsages.assetId, unique)] : []));
286
+ if (unique.length === 0) return;
287
+ await tx.insert(assetUsages).values(unique.map((assetId) => ({
288
+ assetId,
289
+ contentId,
290
+ spaceId,
291
+ published: false,
292
+ updatedAt: /* @__PURE__ */ new Date()
293
+ }))).onConflictDoNothing();
294
+ });
295
+ }
296
+ /**
297
+ * Records what the published projection references.
298
+ *
299
+ * Assets the new revision no longer uses lose their published flag but keep their row
300
+ * when the draft still references them, so the admin's "where is this used" view stays
301
+ * complete.
302
+ */
303
+ async recordPublished(contentId, spaceId, assetIds) {
304
+ const unique = [...new Set(assetIds)];
305
+ await this.db.transaction(async (tx) => {
306
+ await tx.update(assetUsages).set({
307
+ published: false,
308
+ updatedAt: /* @__PURE__ */ new Date()
309
+ }).where(and(eq(assetUsages.contentId, contentId), ...unique.length > 0 ? [notInArray(assetUsages.assetId, unique)] : []));
310
+ if (unique.length === 0) return;
311
+ await tx.insert(assetUsages).values(unique.map((assetId) => ({
312
+ assetId,
313
+ contentId,
314
+ spaceId,
315
+ published: true,
316
+ updatedAt: /* @__PURE__ */ new Date()
317
+ }))).onConflictDoUpdate({
318
+ target: [assetUsages.assetId, assetUsages.contentId],
319
+ set: {
320
+ published: true,
321
+ updatedAt: /* @__PURE__ */ new Date()
322
+ }
323
+ });
324
+ });
325
+ }
326
+ /** Unpublishing revokes every asset this document was keeping public. */
327
+ async clearPublished(contentId) {
328
+ await this.db.update(assetUsages).set({
329
+ published: false,
330
+ updatedAt: /* @__PURE__ */ new Date()
331
+ }).where(eq(assetUsages.contentId, contentId));
332
+ }
333
+ async deleteForContent(contentId) {
334
+ await this.db.delete(assetUsages).where(eq(assetUsages.contentId, contentId));
335
+ }
336
+ async count() {
337
+ return (await this.db.select({ count: sql`count(*)::int` }).from(assetUsages))[0]?.count ?? 0;
338
+ }
339
+ /**
340
+ * Every document with its draft and published field values, for the backfill.
341
+ *
342
+ * References are derived from field-type definitions rather than stored, so the
343
+ * backfill cannot be a SQL migration — it has to run inside the application.
344
+ */
345
+ async backfillSource() {
346
+ return (await this.db.select({
347
+ id: contents.id,
348
+ spaceId: contents.spaceId,
349
+ typeId: contents.typeId,
350
+ draftFields: contents.fields,
351
+ publishedFields: publishedContents.fields
352
+ }).from(contents).leftJoin(publishedContents, eq(publishedContents.id, contents.id))).map((row) => ({
353
+ id: row.id,
354
+ spaceId: row.spaceId,
355
+ typeId: row.typeId,
356
+ draftFields: row.draftFields,
357
+ publishedFields: row.publishedFields ?? null
358
+ }));
359
+ }
360
+ };
361
+ //#endregion
362
+ //#region src/repositories/content.ts
363
+ /**
364
+ * The columns `publish()` copies from the draft into the projection: every column the
365
+ * two tables share, in the projection's order, derived from the schema so a new column
366
+ * cannot be forgotten on one side. `source_version` exists only on the projection and
367
+ * is filled from `version`.
368
+ */
369
+ const PROJECTION_COLUMNS = (() => {
370
+ const columnNames = (table) => Object.values(getTableColumns(table)).filter((column) => column.generated === void 0).map((column) => column.name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`));
371
+ const draft = new Set(columnNames(contents));
372
+ return columnNames(publishedContents).filter((name) => draft.has(name) || name === "source_version");
373
+ })();
374
+ /** Columns that identify the row and are never rewritten on a republish. */
375
+ const PROJECTION_IDENTITY = /* @__PURE__ */ new Set([
376
+ "id",
377
+ "space_id",
378
+ "localization_id",
379
+ "created_at",
380
+ "created_by"
381
+ ]);
382
+ var ContentRepository = class {
383
+ db;
384
+ registry;
385
+ constructor(db, registry) {
386
+ this.db = db;
387
+ this.registry = registry;
388
+ }
389
+ async findById(id, published = false) {
390
+ const table = published ? publishedContents : contents;
391
+ return (await this.db.select().from(table).where(eq(table.id, id)).limit(1))[0] ?? null;
392
+ }
393
+ /**
394
+ * `spaceId` bounds a lookup by id to one tenant.
395
+ *
396
+ * The delivery API takes ids straight from the caller, so without it a public instance
397
+ * pinned to one space still answers for any other space's published documents.
398
+ */
399
+ async findManyByIds(ids, published = false, spaceId) {
400
+ if (ids.length === 0) return [];
401
+ const table = published ? publishedContents : contents;
402
+ const where = spaceId ? and(inArray(table.id, ids), eq(table.spaceId, spaceId)) : inArray(table.id, ids);
403
+ return this.db.select().from(table).where(where);
404
+ }
405
+ /** Children of many parents in one query, for the tree loader. */
406
+ async findChildrenOf(parentIds, published = false, spaceId) {
407
+ if (parentIds.length === 0) return [];
408
+ const table = published ? publishedContents : contents;
409
+ const where = spaceId ? and(inArray(table.parentId, parentIds), eq(table.spaceId, spaceId)) : inArray(table.parentId, parentIds);
410
+ return this.db.select().from(table).where(where).orderBy(sql`${table.position} asc, ${table.title} asc`);
411
+ }
412
+ async findByPermalink(spaceId, locale, permalink, published = true) {
413
+ if (permalink === "") return this.findHome(spaceId, locale, published);
414
+ const table = published ? publishedContents : contents;
415
+ 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;
416
+ }
417
+ /**
418
+ * The document a space nominates as its home, in one locale.
419
+ *
420
+ * `settings.homeContentId` names a single row, which belongs to one locale. Every
421
+ * translation of that document shares its `localizationId`, so the requested locale is
422
+ * resolved through that rather than by pinning one row per language.
423
+ */
424
+ async findHome(spaceId, locale, published = true) {
425
+ const [space] = await this.db.select({ settings: spaces.settings }).from(spaces).where(eq(spaces.id, spaceId)).limit(1);
426
+ const homeId = space?.settings?.homeContentId;
427
+ if (typeof homeId !== "string") return null;
428
+ const [nominated] = await this.db.select({ localizationId: contents.localizationId }).from(contents).where(and(eq(contents.id, homeId), eq(contents.spaceId, spaceId))).limit(1);
429
+ if (!nominated) return null;
430
+ const table = published ? publishedContents : contents;
431
+ 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;
432
+ }
433
+ /**
434
+ * Every other row in a document's localization group — its translations.
435
+ */
436
+ /**
437
+ * One document per localization group, for checking that several groups exist in a
438
+ * space at once — a menu's entries, say. The locale returned is whichever sorts first;
439
+ * a caller that needs a particular one asks `localizationSiblings` for that group.
440
+ */
441
+ async findByLocalizationIds(spaceId, localizationIds) {
442
+ if (localizationIds.length === 0) return [];
443
+ const rows = await this.db.select().from(contents).where(and(eq(contents.spaceId, spaceId), inArray(contents.localizationId, localizationIds))).orderBy(contents.localizationId, contents.locale);
444
+ const seen = /* @__PURE__ */ new Set();
445
+ return rows.filter((row) => {
446
+ if (seen.has(row.localizationId)) return false;
447
+ seen.add(row.localizationId);
448
+ return true;
449
+ });
450
+ }
451
+ async localizationSiblings(spaceId, localizationId, excludeId) {
452
+ const all = await this.db.select().from(contents).where(and(eq(contents.spaceId, spaceId), eq(contents.localizationId, localizationId)));
453
+ return excludeId ? all.filter((row) => row.id !== excludeId) : all;
454
+ }
455
+ /**
456
+ * Merges a few field values into rows without touching the rest of the document.
457
+ *
458
+ * Used to carry a non-localized field across a document's translations: a jsonb `||`
459
+ * so concurrent edits to *other* fields on those rows are not clobbered.
460
+ */
461
+ async patchFields(ids, patch) {
462
+ if (ids.length === 0 || Object.keys(patch).length === 0) return;
463
+ await this.db.update(contents).set({
464
+ fields: sql`${contents.fields} || ${JSON.stringify(patch)}::jsonb`,
465
+ updatedAt: /* @__PURE__ */ new Date()
466
+ }).where(inArray(contents.id, ids));
467
+ }
468
+ async list(filter, pagination, sorts = [], published = false) {
469
+ const table = published ? publishedContents : contents;
470
+ const where = buildContentWhere(table, filter, this.registry);
471
+ return await paginate(this.db, table, {
472
+ where,
473
+ orderBy: buildOrderBy(sorts),
474
+ pagination
475
+ });
476
+ }
477
+ /**
478
+ * The whole tree below `rootId` in **one** query: a GiST-indexed `path <@ root`
479
+ * returns every descendant, and `nlevel()` gives the depth to rebuild the hierarchy.
480
+ */
481
+ async tree(spaceId, locale, rootId = null, maxDepth = 32, published = false) {
482
+ const table = published ? publishedContents : contents;
483
+ const scope = rootId ? sql`and ${table.path} <@ (select path from ${table} where id = ${rootId}::uuid)
484
+ and ${table.id} <> ${rootId}::uuid` : sql``;
485
+ const depthLimit = rootId ? sql`and nlevel(${table.path}) <= (select nlevel(path) from ${table} where id = ${rootId}::uuid) + ${maxDepth}` : sql`and nlevel(${table.path}) <= ${maxDepth}`;
486
+ return buildTree$1(await this.db.select({
487
+ ...getTableColumns(table),
488
+ depth: sql`nlevel(${table.path})::int`
489
+ }).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);
490
+ }
491
+ /** Ancestors of a node, root first — read straight off the materialised path. */
492
+ async ancestors(id, published = false) {
493
+ const table = published ? publishedContents : contents;
494
+ return await this.db.select(getTableColumns(table)).from(table).where(sql`${table.path} @> (select path from ${table} where id = ${id}::uuid)
495
+ and ${table.id} <> ${id}::uuid`).orderBy(sql`nlevel(${table.path}) asc`);
496
+ }
497
+ async create(data) {
498
+ return this.db.transaction(async (tx) => {
499
+ const id = data.id ?? crypto.randomUUID();
500
+ const parentPath = await this.parentPath(tx, data.parentId ?? null);
501
+ const path = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
502
+ const parentPrefix = await this.parentPermalinkPath(tx, data.parentId ?? null);
503
+ const segment = data.hasSlug ? data.slug : null;
504
+ const permalinkPath = joinSegment(parentPrefix, segment);
505
+ const permalink = segment === null ? null : permalinkPath;
506
+ const [row] = await tx.insert(contents).values({
507
+ id,
508
+ spaceId: data.spaceId,
509
+ typeId: data.typeId,
510
+ locale: data.locale,
511
+ localizationId: data.localizationId ?? crypto.randomUUID(),
512
+ parentId: data.parentId ?? null,
513
+ title: data.title,
514
+ slug: data.slug,
515
+ path,
516
+ permalink,
517
+ permalinkPath,
518
+ permalinkSegment: segment,
519
+ status: data.status ?? "draft",
520
+ position: data.position ?? 0,
521
+ fields: data.fields,
522
+ searchText: data.searchText ?? "",
523
+ version: 1,
524
+ createdBy: data.actorId ?? null,
525
+ updatedBy: data.actorId ?? null
526
+ }).returning();
527
+ if (!row) throw new ManabloxError("content.create.failed");
528
+ await this.snapshot(tx, row, data.actorId ?? null);
529
+ return row;
530
+ });
531
+ }
532
+ async update(id, data) {
533
+ return this.db.transaction(async (tx) => {
534
+ const current = await this.lockRow(tx, id);
535
+ if (data.expectedVersion !== void 0 && data.expectedVersion !== current.version) throw ManabloxError.conflict("content.version.conflict", {
536
+ expected: data.expectedVersion,
537
+ actual: current.version
538
+ });
539
+ const parentChanged = (data.parentId ?? null) !== current.parentId;
540
+ const segment = data.hasSlug ? data.slug : null;
541
+ const segmentChanged = segment !== current.permalinkSegment;
542
+ if (parentChanged) await this.assertNotOwnDescendant(tx, id, data.parentId ?? null);
543
+ const parentPath = await this.parentPath(tx, data.parentId ?? null);
544
+ const newPath = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
545
+ const [row] = await tx.update(contents).set({
546
+ typeId: data.typeId,
547
+ locale: data.locale,
548
+ parentId: data.parentId ?? null,
549
+ title: data.title,
550
+ slug: data.slug,
551
+ path: newPath,
552
+ permalinkSegment: segment,
553
+ fields: data.fields,
554
+ searchText: data.searchText ?? "",
555
+ position: data.position ?? current.position,
556
+ version: current.version + 1,
557
+ updatedAt: /* @__PURE__ */ new Date(),
558
+ updatedBy: data.actorId ?? null
559
+ }).where(eq(contents.id, id)).returning();
560
+ if (!row) throw ManabloxError.notFound("content.notFound", { id });
561
+ if (parentChanged) await this.moveSubtree(tx, id, current.path, newPath);
562
+ if (parentChanged || segmentChanged) await this.recomputePermalinks(tx, contents, id);
563
+ const fresh = await this.findRow(tx, id) ?? row;
564
+ await this.snapshot(tx, fresh, data.actorId ?? null);
565
+ return fresh;
566
+ });
567
+ }
568
+ /**
569
+ * Reparents a subtree with one statement. `subpath(path, nlevel(:oldPath))` is the part
570
+ * of each descendant's path *below* the moved node; prefixing it with the node's new
571
+ * path rebases the whole subtree.
572
+ */
573
+ /**
574
+ * Reparents and reorders a node in one transaction.
575
+ *
576
+ * Separate from `update` because a drag is a structural change, not an edit: it writes
577
+ * no field values, takes no version bump and records no snapshot, so an editor open on
578
+ * the document does not hit a version conflict because someone reordered the tree.
579
+ *
580
+ * `position` is the index among the destination's children, clamped to the ends.
581
+ * Siblings on both sides are renumbered densely afterwards, so positions never drift
582
+ * into ties that the tree's `position asc, title asc` ordering would resolve by name.
583
+ */
584
+ async move(id, parentId, position) {
585
+ return this.db.transaction(async (tx) => {
586
+ const db = tx;
587
+ const current = await this.findRow(db, id);
588
+ if (!current) throw ManabloxError.notFound("content.notFound", { id });
589
+ await this.assertNotOwnDescendant(db, id, parentId);
590
+ const parentPath = await this.parentPath(db, parentId);
591
+ const newPath = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
592
+ const parentChanged = parentId !== current.parentId;
593
+ 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);
594
+ const index = Math.max(0, Math.min(position, order.length));
595
+ order.splice(index, 0, id);
596
+ await tx.update(contents).set({
597
+ parentId,
598
+ path: newPath,
599
+ updatedAt: /* @__PURE__ */ new Date()
600
+ }).where(eq(contents.id, id));
601
+ await this.renumber(db, order);
602
+ if (parentChanged) {
603
+ await this.moveSubtree(db, id, current.path, newPath);
604
+ await this.recomputePermalinks(db, contents, id);
605
+ 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`);
606
+ await this.renumber(db, former.map((row) => row.id));
607
+ }
608
+ const fresh = await this.findRow(db, id);
609
+ if (!fresh) throw ManabloxError.notFound("content.notFound", { id });
610
+ return fresh;
611
+ });
612
+ }
613
+ /**
614
+ * Writes `position = index` for a whole sibling list in one statement: the ids and
615
+ * their new positions travel as two arrays and are joined by `unnest`, so a drag in a
616
+ * forty-child section costs one round trip rather than forty. Rows already in place
617
+ * are left untouched, so their `updated_at` and version do not move either.
618
+ */
619
+ async renumber(db, ids) {
620
+ if (ids.length === 0) return;
621
+ const idList = sql.join(ids.map((id) => sql`${id}::uuid`), sql`, `);
622
+ const positions = sql.join(ids.map((_, index) => sql`${index}::int`), sql`, `);
623
+ await db.execute(sql`
624
+ update contents
625
+ set position = v.pos
626
+ from (select unnest(array[${idList}]) as id, unnest(array[${positions}]) as pos) v
627
+ where contents.id = v.id and contents.position is distinct from v.pos
628
+ `);
629
+ }
630
+ async moveSubtree(db, id, oldPath, newPath) {
631
+ await db.execute(sql`
632
+ update contents
633
+ set path = ${newPath}::ltree || subpath(path, nlevel(${oldPath}::ltree)),
634
+ updated_at = now()
635
+ where path <@ ${oldPath}::ltree and id <> ${id}::uuid
636
+ `);
637
+ }
638
+ /**
639
+ * Recomputes permalinks for a node and everything beneath it in one recursive CTE.
640
+ * Each level derives from *its own* parent's freshly computed value (`t.pl`), and
641
+ * `concat_ws` drops NULL segments so a type without a slug is transparent in the path.
642
+ */
643
+ async recomputePermalinks(db, table, rootId) {
644
+ const tableName = table === contents ? sql`contents` : sql`published_contents`;
645
+ await db.execute(sql`
646
+ with recursive t as (
647
+ select c.id,
648
+ c.permalink_segment,
649
+ concat_ws('/',
650
+ nullif(coalesce(
651
+ (select p.permalink_path from ${tableName} p where p.id = c.parent_id), ''), ''),
652
+ c.permalink_segment
653
+ ) as prefix
654
+ from ${tableName} c
655
+ where c.id = ${rootId}::uuid
656
+
657
+ union all
658
+
659
+ select ch.id,
660
+ ch.permalink_segment,
661
+ concat_ws('/', nullif(t.prefix, ''), ch.permalink_segment) as prefix
662
+ from ${tableName} ch
663
+ join t on ch.parent_id = t.id
664
+ )
665
+ update ${tableName} target
666
+ set permalink_path = t.prefix,
667
+ permalink = case when t.permalink_segment is null then null else nullif(t.prefix, '') end,
668
+ updated_at = now()
669
+ from t
670
+ where target.id = t.id
671
+ and (target.permalink_path is distinct from t.prefix
672
+ or target.permalink is distinct from
673
+ (case when t.permalink_segment is null then null else nullif(t.prefix, '') end))
674
+ `);
675
+ }
676
+ /** Deletes a node and its whole subtree, in both the draft and published tables. */
677
+ async delete(id) {
678
+ return this.db.transaction(async (tx) => {
679
+ const current = await this.findRow(tx, id);
680
+ if (!current) throw ManabloxError.notFound("content.notFound", { id });
681
+ await tx.execute(sql`
682
+ delete from published_contents
683
+ where path <@ (select path from contents where id = ${id}::uuid)
684
+ `);
685
+ return (await tx.delete(contents).where(sql`${contents.path} <@ ${current.path}::ltree`).returning({ id: contents.id })).length;
686
+ });
687
+ }
688
+ /**
689
+ * Copies a draft into the delivery projection inside one transaction, so a reader
690
+ * never observes a partially published tree.
691
+ */
692
+ async publish(id, actorId = null) {
693
+ return this.db.transaction(async (tx) => {
694
+ const row = await this.findRow(tx, id);
695
+ if (!row) throw ManabloxError.notFound("content.notFound", { id });
696
+ const previous = (await tx.select({ permalinkPath: publishedContents.permalinkPath }).from(publishedContents).where(eq(publishedContents.id, id)).limit(1))[0];
697
+ const publishedAt = /* @__PURE__ */ new Date();
698
+ const publishedAtIso = publishedAt.toISOString();
699
+ const overrides = {
700
+ status: sql`'published'`,
701
+ source_version: sql`version`,
702
+ updated_at: sql`now()`,
703
+ updated_by: sql`${actorId}::uuid`,
704
+ published_at: sql`${publishedAtIso}::timestamptz`
705
+ };
706
+ const columns = PROJECTION_COLUMNS.map((name) => sql.identifier(name));
707
+ const values = PROJECTION_COLUMNS.map((name) => overrides[name] ?? sql.identifier(name));
708
+ const updates = PROJECTION_COLUMNS.filter((name) => !PROJECTION_IDENTITY.has(name)).map((name) => sql`${sql.identifier(name)} = excluded.${sql.identifier(name)}`);
709
+ await tx.execute(sql`
710
+ insert into published_contents (${sql.join(columns, sql`, `)})
711
+ select ${sql.join(values, sql`, `)}
712
+ from contents where id = ${id}::uuid
713
+ on conflict (id) do update set ${sql.join(updates, sql`, `)}
714
+ `);
715
+ if (previous?.permalinkPath !== row.permalinkPath) await this.recomputePermalinks(tx, publishedContents, id);
716
+ const [updated] = await tx.update(contents).set({
717
+ status: "published",
718
+ publishedAt,
719
+ updatedBy: actorId
720
+ }).where(eq(contents.id, id)).returning();
721
+ return updated;
722
+ });
723
+ }
724
+ async unpublish(id) {
725
+ await this.db.transaction(async (tx) => {
726
+ await tx.execute(sql`
727
+ delete from published_contents
728
+ where path <@ (select path from contents where id = ${id}::uuid)
729
+ `);
730
+ await tx.update(contents).set({
731
+ status: "draft",
732
+ publishedAt: null
733
+ }).where(eq(contents.id, id));
734
+ });
735
+ }
736
+ async versions(contentId, limit = 50) {
737
+ return await this.db.select({
738
+ version: contentVersions.version,
739
+ createdAt: contentVersions.createdAt,
740
+ createdBy: contentVersions.createdBy,
741
+ label: contentVersions.label
742
+ }).from(contentVersions).where(eq(contentVersions.contentId, contentId)).orderBy(sql`version desc`).limit(limit);
743
+ }
744
+ /** The row exactly as it was at that version — `snapshot()` stores the whole row. */
745
+ async versionSnapshot(contentId, version) {
746
+ 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;
747
+ }
748
+ async snapshot(db, row, actorId) {
749
+ await db.insert(contentVersions).values({
750
+ contentId: row.id,
751
+ version: row.version,
752
+ snapshot: row,
753
+ createdBy: actorId
754
+ }).onConflictDoNothing();
755
+ }
756
+ async findRow(db, id) {
757
+ return (await db.select().from(contents).where(eq(contents.id, id)).limit(1))[0] ?? null;
758
+ }
759
+ async lockRow(db, id) {
760
+ const row = (await db.select().from(contents).where(eq(contents.id, id)).limit(1).for("update"))[0];
761
+ if (!row) throw ManabloxError.notFound("content.notFound", { id });
762
+ return row;
763
+ }
764
+ async parentPath(db, parentId) {
765
+ if (!parentId) return null;
766
+ const path = (await db.select({ path: contents.path }).from(contents).where(eq(contents.id, parentId)).limit(1))[0]?.path;
767
+ if (!path) throw ManabloxError.notFound("content.parent.notFound", { id: parentId });
768
+ return path;
769
+ }
770
+ async parentPermalinkPath(db, parentId) {
771
+ if (!parentId) return "";
772
+ return (await db.select({ permalinkPath: contents.permalinkPath }).from(contents).where(eq(contents.id, parentId)).limit(1))[0]?.permalinkPath ?? "";
773
+ }
774
+ /** Guards against making a node its own ancestor, which would orphan the subtree. */
775
+ async assertNotOwnDescendant(db, id, parentId) {
776
+ if (!parentId) return;
777
+ if (parentId === id) throw ManabloxError.badRequest("content.parent.self");
778
+ if ((await db.select({ cycle: sql`exists (
779
+ select 1 from contents p
780
+ where p.id = ${parentId}::uuid
781
+ and p.path <@ (select path from contents where id = ${id}::uuid)
782
+ )` }).from(sql`(select 1) as _`))[0]?.cycle) throw ManabloxError.badRequest("content.parent.cycle", {
783
+ id,
784
+ parentId
785
+ });
786
+ }
787
+ };
788
+ function buildTree$1(rows, rootId) {
789
+ const nodes = /* @__PURE__ */ new Map();
790
+ for (const row of rows) {
791
+ const { depth, ...content } = row;
792
+ nodes.set(row.id, {
793
+ content,
794
+ depth,
795
+ children: []
796
+ });
797
+ }
798
+ const roots = [];
799
+ for (const node of nodes.values()) {
800
+ const parentId = node.content.parentId;
801
+ const parent = parentId ? nodes.get(parentId) : void 0;
802
+ if (parent && parentId !== rootId) parent.children.push(node);
803
+ else if (parentId === rootId || !parent) roots.push(node);
804
+ }
805
+ return roots;
806
+ }
807
+ function joinSegment(prefix, segment) {
808
+ if (segment === null) return prefix;
809
+ return prefix ? `${prefix}/${segment}` : segment;
810
+ }
811
+ //#endregion
812
+ //#region src/repositories/content-type.ts
813
+ /**
814
+ * Persistence for *runtime-defined* content types only. Code-defined types come from
815
+ * `manablox.config.ts` and are never written here — the registry merges both into one
816
+ * shape, and `source` tells the admin which are read-only.
817
+ */
818
+ var ContentTypeRepository = class {
819
+ db;
820
+ constructor(db) {
821
+ this.db = db;
822
+ }
823
+ async all() {
824
+ return (await this.db.select().from(contentTypes)).map(toDefinition);
825
+ }
826
+ async findById(id) {
827
+ const rows = await this.db.select().from(contentTypes).where(eq(contentTypes.id, id)).limit(1);
828
+ return rows[0] ? toDefinition(rows[0]) : null;
829
+ }
830
+ async create(input) {
831
+ const definition = defineContentType(input);
832
+ const [row] = await this.db.insert(contentTypes).values({
833
+ id: definition.id,
834
+ spaceId: definition.spaceId,
835
+ name: definition.name,
836
+ label: definition.label,
837
+ description: definition.description ?? null,
838
+ icon: definition.icon ?? null,
839
+ kind: definition.kind,
840
+ hasSlug: definition.hasSlug,
841
+ isPublishable: definition.isPublishable,
842
+ isVisibleInTree: definition.isVisibleInTree,
843
+ canBeVisibleInMenu: definition.canBeVisibleInMenu,
844
+ fields: definition.fields
845
+ }).returning();
846
+ if (!row) throw new ManabloxError("contentType.create.failed");
847
+ return toDefinition(row);
848
+ }
849
+ async update(id, input) {
850
+ const existing = await this.findById(id);
851
+ if (!existing) throw ManabloxError.notFound("contentType.notFound", { id });
852
+ const definition = defineContentType({
853
+ ...input,
854
+ id
855
+ });
856
+ for (const field of definition.fields) {
857
+ const previous = existing.fields.find((candidate) => candidate.id === field.id);
858
+ if (previous && previous.name !== field.name) throw ManabloxError.badRequest("contentType.field.name.immutable", {
859
+ from: previous.name,
860
+ to: field.name
861
+ });
862
+ }
863
+ const [row] = await this.db.update(contentTypes).set({
864
+ name: definition.name,
865
+ label: definition.label,
866
+ description: definition.description ?? null,
867
+ icon: definition.icon ?? null,
868
+ hasSlug: definition.hasSlug,
869
+ isPublishable: definition.isPublishable,
870
+ isVisibleInTree: definition.isVisibleInTree,
871
+ canBeVisibleInMenu: definition.canBeVisibleInMenu,
872
+ fields: definition.fields,
873
+ updatedAt: /* @__PURE__ */ new Date()
874
+ }).where(eq(contentTypes.id, id)).returning();
875
+ if (!row) throw ManabloxError.notFound("contentType.notFound", { id });
876
+ return toDefinition(row);
877
+ }
878
+ async delete(id) {
879
+ await this.db.delete(contentTypes).where(eq(contentTypes.id, id));
880
+ }
881
+ };
882
+ function toDefinition(row) {
883
+ return {
884
+ id: row.id,
885
+ name: row.name,
886
+ label: row.label,
887
+ ...row.description !== null ? { description: row.description } : {},
888
+ ...row.icon !== null ? { icon: row.icon } : {},
889
+ kind: row.kind,
890
+ spaceId: row.spaceId,
891
+ hasSlug: row.hasSlug,
892
+ isPublishable: row.isPublishable,
893
+ isVisibleInTree: row.isVisibleInTree,
894
+ canBeVisibleInMenu: row.canBeVisibleInMenu,
895
+ fields: row.fields,
896
+ source: "runtime"
897
+ };
898
+ }
899
+ //#endregion
900
+ //#region src/repositories/menu.ts
901
+ var MenuRepository = class {
902
+ db;
903
+ constructor(db) {
904
+ this.db = db;
905
+ }
906
+ async listBySpace(spaceId) {
907
+ return this.db.select().from(menus).where(eq(menus.spaceId, spaceId)).orderBy(menus.name);
908
+ }
909
+ async findById(id) {
910
+ return (await this.db.select().from(menus).where(eq(menus.id, id)).limit(1))[0] ?? null;
911
+ }
912
+ async findByMachineName(spaceId, machineName) {
913
+ return (await this.db.select().from(menus).where(and(eq(menus.spaceId, spaceId), eq(menus.machineName, machineName))).limit(1))[0] ?? null;
914
+ }
915
+ async create(data) {
916
+ const [row] = await this.db.insert(menus).values({
917
+ ...data.id ? { id: data.id } : {},
918
+ spaceId: data.spaceId,
919
+ name: data.name,
920
+ machineName: data.machineName,
921
+ description: data.description ?? null
922
+ }).returning();
923
+ if (!row) throw new ManabloxError("menu.create.failed");
924
+ return row;
925
+ }
926
+ async update(id, data) {
927
+ const [row] = await this.db.update(menus).set({
928
+ ...data.name !== void 0 ? { name: data.name } : {},
929
+ ...data.machineName !== void 0 ? { machineName: data.machineName } : {},
930
+ ...data.description !== void 0 ? { description: data.description } : {},
931
+ updatedAt: /* @__PURE__ */ new Date()
932
+ }).where(eq(menus.id, id)).returning();
933
+ if (!row) throw ManabloxError.notFound("menu.notFound", { id });
934
+ return row;
935
+ }
936
+ async delete(id) {
937
+ await this.db.delete(menus).where(eq(menus.id, id));
938
+ }
939
+ /** Every entry of a menu, flat, in tree order within each level. */
940
+ async items(menuId) {
941
+ return this.db.select().from(menuItems).where(eq(menuItems.menuId, menuId)).orderBy(asc(menuItems.position), asc(menuItems.id));
942
+ }
943
+ async tree(menuId) {
944
+ return buildTree(await this.items(menuId));
945
+ }
946
+ /**
947
+ * Replaces the whole entry tree in one transaction.
948
+ *
949
+ * A menu is edited as one document and saved as one, so this is simpler and safer than
950
+ * a per-entry API whose partial failures would leave a half-reordered menu behind.
951
+ */
952
+ async setItems(menuId, tree) {
953
+ const rows = [];
954
+ const flatten = (nodes, parentId) => {
955
+ nodes.forEach((node, position) => {
956
+ const id = node.id ?? randomUUID();
957
+ rows.push({
958
+ id,
959
+ menuId,
960
+ parentId,
961
+ position,
962
+ localizationId: node.localizationId ?? null,
963
+ label: node.label ?? null,
964
+ url: node.url ?? null
965
+ });
966
+ flatten(node.children ?? [], id);
967
+ });
968
+ };
969
+ flatten(tree, null);
970
+ return this.db.transaction(async (tx) => {
971
+ await tx.delete(menuItems).where(eq(menuItems.menuId, menuId));
972
+ if (rows.length) await tx.insert(menuItems).values(rows);
973
+ await tx.update(menus).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(menus.id, menuId));
974
+ return buildTree(rows.map((row) => ({
975
+ ...row,
976
+ position: row.position ?? 0
977
+ })));
978
+ });
979
+ }
980
+ /** Menus that carry the document, for the editor's "used in" hint. */
981
+ async menusReferencing(spaceId, localizationId) {
982
+ return this.db.selectDistinct({
983
+ id: menus.id,
984
+ spaceId: menus.spaceId,
985
+ name: menus.name,
986
+ machineName: menus.machineName,
987
+ description: menus.description,
988
+ createdAt: menus.createdAt,
989
+ updatedAt: menus.updatedAt
990
+ }).from(menuItems).innerJoin(menus, eq(menuItems.menuId, menus.id)).where(and(eq(menus.spaceId, spaceId), eq(menuItems.localizationId, localizationId))).orderBy(menus.name);
991
+ }
992
+ /** Drops every entry pointing at a document, in every menu; sub-entries cascade. */
993
+ async removeContent(localizationId) {
994
+ return (await this.db.delete(menuItems).where(eq(menuItems.localizationId, localizationId)).returning({ id: menuItems.id })).length;
995
+ }
996
+ /**
997
+ * The tree with each content entry's document for one locale. A content entry whose
998
+ * document has no row in that locale — or, on the published table, no published one —
999
+ * comes back with `content: null`; the caller decides whether to show or drop it.
1000
+ */
1001
+ async resolve(menu, locale, published = false) {
1002
+ const items = await this.items(menu.id);
1003
+ const localizationIds = [...new Set(items.flatMap((item) => item.localizationId ? [item.localizationId] : []))];
1004
+ const table = published ? publishedContents : contents;
1005
+ 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))) : [];
1006
+ const byLocalization = new Map(rows.map((row) => [row.localizationId, row]));
1007
+ const toResolved = (node) => ({
1008
+ id: node.item.id,
1009
+ label: node.item.label,
1010
+ url: node.item.url,
1011
+ localizationId: node.item.localizationId,
1012
+ content: node.item.localizationId ? byLocalization.get(node.item.localizationId) ?? null : null,
1013
+ children: node.children.map(toResolved)
1014
+ });
1015
+ return buildTree(items).map(toResolved);
1016
+ }
1017
+ };
1018
+ function buildTree(rows) {
1019
+ const nodes = /* @__PURE__ */ new Map();
1020
+ for (const row of rows) nodes.set(row.id, {
1021
+ item: row,
1022
+ children: []
1023
+ });
1024
+ const roots = [];
1025
+ for (const node of nodes.values()) {
1026
+ const parent = node.item.parentId ? nodes.get(node.item.parentId) : void 0;
1027
+ (parent ? parent.children : roots).push(node);
1028
+ }
1029
+ const byPosition = (a, b) => a.item.position - b.item.position;
1030
+ const sort = (list) => {
1031
+ list.sort(byPosition);
1032
+ for (const node of list) sort(node.children);
1033
+ };
1034
+ sort(roots);
1035
+ return roots;
1036
+ }
1037
+ //#endregion
1038
+ //#region src/repositories/role.ts
1039
+ var RoleRepository = class {
1040
+ db;
1041
+ constructor(db) {
1042
+ this.db = db;
1043
+ }
1044
+ async listBySpace(spaceId) {
1045
+ return this.db.select().from(roles).where(eq(roles.spaceId, spaceId)).orderBy(roles.name);
1046
+ }
1047
+ async findById(id) {
1048
+ return (await this.db.select().from(roles).where(eq(roles.id, id)).limit(1))[0] ?? null;
1049
+ }
1050
+ async findByMachineName(spaceId, machineName) {
1051
+ return (await this.db.select().from(roles).where(and(eq(roles.spaceId, spaceId), eq(roles.machineName, machineName))).limit(1))[0] ?? null;
1052
+ }
1053
+ async create(spaceId, data) {
1054
+ const [row] = await this.db.insert(roles).values({
1055
+ spaceId,
1056
+ ...data
1057
+ }).returning();
1058
+ if (!row) throw new ManabloxError("role.create.failed");
1059
+ return row;
1060
+ }
1061
+ async update(id, data) {
1062
+ const [row] = await this.db.update(roles).set({
1063
+ ...data,
1064
+ updatedAt: /* @__PURE__ */ new Date()
1065
+ }).where(eq(roles.id, id)).returning();
1066
+ if (!row) throw ManabloxError.notFound("role.notFound", { id });
1067
+ return row;
1068
+ }
1069
+ async delete(id) {
1070
+ await this.db.delete(roles).where(eq(roles.id, id));
1071
+ }
1072
+ /** How many members of the role's space hold it, by name. */
1073
+ async countMembers(spaceId, machineName) {
1074
+ return (await this.db.select({ count: sql`count(*)::int` }).from(memberships).where(and(eq(memberships.spaceId, spaceId), eq(memberships.role, machineName))))[0]?.count ?? 0;
1075
+ }
1076
+ /**
1077
+ * Drops every grant narrowed to a content type from every role, once the type is
1078
+ * gone. Grants are a JSON array, so this is one statement across the roles that carry
1079
+ * such a grant rather than a read-modify-write per role.
1080
+ */
1081
+ async pruneContentType(typeId) {
1082
+ const suffix = `:${typeId}`;
1083
+ await this.db.update(roles).set({
1084
+ permissions: sql`(
1085
+ select coalesce(jsonb_agg(value), '[]'::jsonb)
1086
+ from jsonb_array_elements_text(${roles.permissions}) as value
1087
+ where value not like ${`%${suffix}`}
1088
+ )`,
1089
+ updatedAt: /* @__PURE__ */ new Date()
1090
+ }).where(sql`${roles.permissions}::text like ${`%${suffix}%`}`);
1091
+ }
1092
+ };
1093
+ //#endregion
1094
+ //#region src/repositories/space.ts
1095
+ var SpaceRepository = class {
1096
+ db;
1097
+ constructor(db) {
1098
+ this.db = db;
1099
+ }
1100
+ async all() {
1101
+ return this.db.select().from(spaces).orderBy(spaces.name);
1102
+ }
1103
+ async findManyByIds(ids) {
1104
+ if (ids.length === 0) return [];
1105
+ return this.db.select().from(spaces).where(inArray(spaces.id, ids)).orderBy(spaces.name);
1106
+ }
1107
+ async findById(id) {
1108
+ return (await this.db.select().from(spaces).where(eq(spaces.id, id)).limit(1))[0] ?? null;
1109
+ }
1110
+ async findByMachineName(machineName) {
1111
+ return (await this.db.select().from(spaces).where(eq(spaces.machineName, machineName)).limit(1))[0] ?? null;
1112
+ }
1113
+ async create(data) {
1114
+ const [row] = await this.db.insert(spaces).values({
1115
+ ...data.id ? { id: data.id } : {},
1116
+ name: data.name,
1117
+ machineName: data.machineName,
1118
+ description: data.description ?? null,
1119
+ url: data.url,
1120
+ defaultLocale: data.defaultLocale ?? "en",
1121
+ locales: data.locales ?? [data.defaultLocale ?? "en"],
1122
+ settings: data.settings ?? {}
1123
+ }).returning();
1124
+ if (!row) throw new ManabloxError("space.create.failed");
1125
+ return row;
1126
+ }
1127
+ async update(id, data) {
1128
+ const [row] = await this.db.update(spaces).set({
1129
+ ...data.name !== void 0 ? { name: data.name } : {},
1130
+ ...data.machineName !== void 0 ? { machineName: data.machineName } : {},
1131
+ ...data.description !== void 0 ? { description: data.description } : {},
1132
+ ...data.url !== void 0 ? { url: data.url } : {},
1133
+ ...data.defaultLocale !== void 0 ? { defaultLocale: data.defaultLocale } : {},
1134
+ ...data.locales !== void 0 ? { locales: data.locales } : {},
1135
+ ...data.settings !== void 0 ? { settings: data.settings } : {},
1136
+ updatedAt: /* @__PURE__ */ new Date()
1137
+ }).where(eq(spaces.id, id)).returning();
1138
+ if (!row) throw ManabloxError.notFound("space.notFound", { id });
1139
+ return row;
1140
+ }
1141
+ async delete(id) {
1142
+ await this.db.delete(spaces).where(eq(spaces.id, id));
1143
+ }
1144
+ };
1145
+ //#endregion
1146
+ //#region src/repositories/user.ts
1147
+ /**
1148
+ * How better-auth 1.7 identifies an email + password credential: sign-in looks for an
1149
+ * account with this provider *and* this issuer, so a row missing either is invisible to
1150
+ * it and the account can never sign in.
1151
+ */
1152
+ const CREDENTIAL_PROVIDER = "credential";
1153
+ const CREDENTIAL_ISSUER = "local:credential";
1154
+ var UserRepository = class {
1155
+ db;
1156
+ constructor(db) {
1157
+ this.db = db;
1158
+ }
1159
+ async findById(id) {
1160
+ return (await this.db.select().from(users).where(eq(users.id, id)).limit(1))[0] ?? null;
1161
+ }
1162
+ async findManyByIds(ids) {
1163
+ if (ids.length === 0) return [];
1164
+ return this.db.select().from(users).where(inArray(users.id, ids));
1165
+ }
1166
+ async findByEmail(email) {
1167
+ return (await this.db.select().from(users).where(eq(users.email, email)).limit(1))[0] ?? null;
1168
+ }
1169
+ async list(pagination, search) {
1170
+ const where = search ? sql`${users.email} ilike ${`%${search}%`} or ${users.name} ilike ${`%${search}%`}` : void 0;
1171
+ return paginate(this.db, users, {
1172
+ where,
1173
+ orderBy: desc(users.createdAt),
1174
+ pagination
1175
+ });
1176
+ }
1177
+ /**
1178
+ * Users who are not members of a space, matching a search, newest first: the
1179
+ * add-member picker's candidates, decided in SQL rather than by loading a page of
1180
+ * users and filtering it here.
1181
+ */
1182
+ async candidates(spaceId, search, limit) {
1183
+ const notMember = sql`not exists (select 1 from ${memberships} where ${memberships.userId} = ${users.id} and ${memberships.spaceId} = ${spaceId})`;
1184
+ const where = search ? and(notMember, sql`(${users.email} ilike ${`%${search}%`} or ${users.name} ilike ${`%${search}%`})`) : notMember;
1185
+ return this.db.select().from(users).where(where).orderBy(desc(users.createdAt)).limit(limit);
1186
+ }
1187
+ async count() {
1188
+ return (await this.db.select({ count: sql`count(*)::int` }).from(users))[0]?.count ?? 0;
1189
+ }
1190
+ /**
1191
+ * Inserts the user and its password credential together, so a failure on the second
1192
+ * row cannot leave an account nobody can sign in to. The account row is shaped the way
1193
+ * better-auth writes it on sign-up, so a sign-in later finds it as its own.
1194
+ */
1195
+ async create(data) {
1196
+ return this.db.transaction(async (tx) => {
1197
+ const [user] = await tx.insert(users).values({
1198
+ name: data.name,
1199
+ email: data.email,
1200
+ role: data.role
1201
+ }).returning();
1202
+ if (!user) throw new ManabloxError("user.create.failed");
1203
+ await tx.insert(accounts).values({
1204
+ userId: user.id,
1205
+ accountId: user.id,
1206
+ providerId: CREDENTIAL_PROVIDER,
1207
+ issuer: CREDENTIAL_ISSUER,
1208
+ password: data.passwordHash
1209
+ });
1210
+ return user;
1211
+ });
1212
+ }
1213
+ async update(id, data) {
1214
+ const [row] = await this.db.update(users).set({
1215
+ ...data,
1216
+ updatedAt: /* @__PURE__ */ new Date()
1217
+ }).where(eq(users.id, id)).returning();
1218
+ if (!row) throw ManabloxError.notFound("user.notFound", { id });
1219
+ return row;
1220
+ }
1221
+ async delete(id) {
1222
+ await this.db.delete(users).where(eq(users.id, id));
1223
+ }
1224
+ async setBanned(id, banned, reason) {
1225
+ const [row] = await this.db.update(users).set({
1226
+ banned,
1227
+ banReason: banned ? reason : null,
1228
+ updatedAt: /* @__PURE__ */ new Date()
1229
+ }).where(eq(users.id, id)).returning();
1230
+ if (!row) throw ManabloxError.notFound("user.notFound", { id });
1231
+ return row;
1232
+ }
1233
+ /**
1234
+ * Replaces the password credential, creating it for an account that only ever signed
1235
+ * in through another provider.
1236
+ */
1237
+ async setPasswordHash(userId, passwordHash) {
1238
+ if ((await this.db.update(accounts).set({
1239
+ password: passwordHash,
1240
+ updatedAt: /* @__PURE__ */ new Date()
1241
+ }).where(and(eq(accounts.userId, userId), eq(accounts.providerId, CREDENTIAL_PROVIDER), eq(accounts.issuer, CREDENTIAL_ISSUER))).returning({ id: accounts.id })).length) return;
1242
+ await this.db.insert(accounts).values({
1243
+ userId,
1244
+ accountId: userId,
1245
+ providerId: CREDENTIAL_PROVIDER,
1246
+ issuer: CREDENTIAL_ISSUER,
1247
+ password: passwordHash
1248
+ });
1249
+ }
1250
+ /** Signs the user out everywhere. */
1251
+ async revokeSessions(userId) {
1252
+ await this.db.delete(sessions).where(eq(sessions.userId, userId));
1253
+ }
1254
+ async countByRole(role) {
1255
+ return (await this.db.select({ count: sql`count(*)::int` }).from(users).where(eq(users.role, role)))[0]?.count ?? 0;
1256
+ }
1257
+ async setRole(id, role) {
1258
+ const [row] = await this.db.update(users).set({
1259
+ role,
1260
+ updatedAt: /* @__PURE__ */ new Date()
1261
+ }).where(eq(users.id, id)).returning();
1262
+ if (!row) throw ManabloxError.notFound("user.notFound", { id });
1263
+ return row;
1264
+ }
1265
+ /**
1266
+ * Authoritative role plus space memberships in one query.
1267
+ *
1268
+ * Read on every authenticated request rather than trusting the role embedded in the
1269
+ * session: better-auth caches the session payload (five minutes by default), so a
1270
+ * promotion or demotion would otherwise not take effect until that cache expired.
1271
+ *
1272
+ * A membership naming a custom role joins that role's grants; one naming a built-in
1273
+ * role has none here, and the auth package answers those from its own table.
1274
+ */
1275
+ async principal(userId) {
1276
+ const rows = await this.db.select({
1277
+ role: users.role,
1278
+ banned: users.banned,
1279
+ spaceId: memberships.spaceId,
1280
+ spaceRole: memberships.role,
1281
+ grants: roles.permissions
1282
+ }).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));
1283
+ const first = rows[0];
1284
+ if (!first) return null;
1285
+ const spaces = {};
1286
+ const permissions = {};
1287
+ for (const row of rows) {
1288
+ if (!row.spaceId || !row.spaceRole) continue;
1289
+ spaces[row.spaceId] = row.spaceRole;
1290
+ if (row.grants) permissions[row.spaceId] = row.grants;
1291
+ }
1292
+ return {
1293
+ role: first.role,
1294
+ banned: first.banned,
1295
+ spaces,
1296
+ permissions
1297
+ };
1298
+ }
1299
+ async memberships(userId) {
1300
+ return this.db.select().from(memberships).where(eq(memberships.userId, userId));
1301
+ }
1302
+ /** The user's memberships with the space each one is in, for a per-user view. */
1303
+ async membershipsWithSpaces(userId) {
1304
+ return (await this.db.select({
1305
+ membership: memberships,
1306
+ space: spaces
1307
+ }).from(memberships).innerJoin(spaces, eq(spaces.id, memberships.spaceId)).where(eq(memberships.userId, userId)).orderBy(spaces.name)).map((row) => ({
1308
+ ...row.membership,
1309
+ space: row.space
1310
+ }));
1311
+ }
1312
+ async membersOf(spaceId) {
1313
+ return (await this.db.select({
1314
+ membership: memberships,
1315
+ user: users
1316
+ }).from(memberships).innerJoin(users, eq(users.id, memberships.userId)).where(eq(memberships.spaceId, spaceId))).map((row) => ({
1317
+ ...row.membership,
1318
+ user: row.user
1319
+ }));
1320
+ }
1321
+ async roleIn(userId, spaceId) {
1322
+ 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;
1323
+ }
1324
+ async grant(userId, spaceId, role) {
1325
+ await this.db.insert(memberships).values({
1326
+ userId,
1327
+ spaceId,
1328
+ role
1329
+ }).onConflictDoUpdate({
1330
+ target: [memberships.userId, memberships.spaceId],
1331
+ set: { role }
1332
+ });
1333
+ }
1334
+ async revoke(userId, spaceId) {
1335
+ await this.db.delete(memberships).where(and(eq(memberships.userId, userId), eq(memberships.spaceId, spaceId)));
1336
+ }
1337
+ };
1338
+ //#endregion
1339
+ //#region src/repositories/webhook.ts
1340
+ /** The webhooks of a space and the log of what was sent to them. */
1341
+ var WebhookRepository = class {
1342
+ db;
1343
+ constructor(db) {
1344
+ this.db = db;
1345
+ }
1346
+ async findById(id) {
1347
+ return (await this.db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1))[0] ?? null;
1348
+ }
1349
+ /** The switched-on webhooks of a space, for fanning an event out. */
1350
+ async findEnabled(spaceId) {
1351
+ return this.db.select().from(webhooks).where(and(eq(webhooks.spaceId, spaceId), eq(webhooks.enabled, true)));
1352
+ }
1353
+ async recordDelivery(data) {
1354
+ const [row] = await this.db.insert(webhookDeliveries).values(data).returning();
1355
+ return row;
1356
+ }
1357
+ async deliveries(webhookId, limit = 50) {
1358
+ return this.db.select().from(webhookDeliveries).where(eq(webhookDeliveries.webhookId, webhookId)).orderBy(webhookDeliveries.createdAt).limit(limit);
1359
+ }
1360
+ };
1361
+ //#endregion
1362
+ //#region src/repositories/workflow.ts
1363
+ /** How many runs a workflow keeps; older ones are pruned as new ones are written. */
1364
+ const RUNS_KEPT_PER_WORKFLOW = 200;
1365
+ var WorkflowRepository = class {
1366
+ db;
1367
+ constructor(db) {
1368
+ this.db = db;
1369
+ }
1370
+ async listBySpace(spaceId) {
1371
+ return this.db.select().from(workflows).where(eq(workflows.spaceId, spaceId)).orderBy(workflows.name);
1372
+ }
1373
+ /** Every enabled workflow of a space, for the dispatcher; every enabled one at all for the scheduler. */
1374
+ async listEnabled(spaceId) {
1375
+ return this.db.select().from(workflows).where(spaceId ? and(eq(workflows.enabled, true), eq(workflows.spaceId, spaceId)) : eq(workflows.enabled, true));
1376
+ }
1377
+ async findById(id) {
1378
+ return (await this.db.select().from(workflows).where(eq(workflows.id, id)).limit(1))[0] ?? null;
1379
+ }
1380
+ async create(data) {
1381
+ const [row] = await this.db.insert(workflows).values({
1382
+ ...data.id ? { id: data.id } : {},
1383
+ spaceId: data.spaceId,
1384
+ name: data.name,
1385
+ description: data.description ?? null,
1386
+ enabled: data.enabled ?? false,
1387
+ trigger: data.trigger,
1388
+ steps: data.steps
1389
+ }).returning();
1390
+ if (!row) throw new ManabloxError("workflow.create.failed");
1391
+ return row;
1392
+ }
1393
+ async update(id, data) {
1394
+ const [row] = await this.db.update(workflows).set({
1395
+ ...data.name !== void 0 ? { name: data.name } : {},
1396
+ ...data.description !== void 0 ? { description: data.description } : {},
1397
+ ...data.enabled !== void 0 ? { enabled: data.enabled } : {},
1398
+ ...data.trigger !== void 0 ? { trigger: data.trigger } : {},
1399
+ ...data.steps !== void 0 ? { steps: data.steps } : {},
1400
+ updatedAt: /* @__PURE__ */ new Date()
1401
+ }).where(eq(workflows.id, id)).returning();
1402
+ if (!row) throw ManabloxError.notFound("workflow.notFound", { id });
1403
+ return row;
1404
+ }
1405
+ async delete(id) {
1406
+ await this.db.delete(workflows).where(eq(workflows.id, id));
1407
+ }
1408
+ /**
1409
+ * Claims a scheduled workflow for one minute. Returns false when another process got
1410
+ * there first — the update matches nothing once `lastScheduledAt` is already `minute`.
1411
+ */
1412
+ async claimSchedule(id, minute) {
1413
+ 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;
1414
+ }
1415
+ async touchRun(id, at) {
1416
+ await this.db.update(workflows).set({ lastRunAt: at }).where(eq(workflows.id, id));
1417
+ }
1418
+ async createRun(data) {
1419
+ const [row] = await this.db.insert(workflowRuns).values({
1420
+ workflowId: data.workflowId,
1421
+ spaceId: data.spaceId,
1422
+ trigger: data.trigger,
1423
+ context: data.context,
1424
+ status: "queued"
1425
+ }).returning();
1426
+ if (!row) throw new ManabloxError("workflow.create.failed");
1427
+ await this.pruneRuns(data.workflowId);
1428
+ return row;
1429
+ }
1430
+ async findRun(id) {
1431
+ return (await this.db.select().from(workflowRuns).where(eq(workflowRuns.id, id)).limit(1))[0] ?? null;
1432
+ }
1433
+ async listRuns(workflowId, limit = 50) {
1434
+ return this.db.select().from(workflowRuns).where(eq(workflowRuns.workflowId, workflowId)).orderBy(desc(workflowRuns.createdAt)).limit(limit);
1435
+ }
1436
+ /**
1437
+ * Moves a run from `queued` or `waiting` to `running`, or reports that it is not
1438
+ * there to be moved. The status check in the predicate is what keeps two workers off
1439
+ * the same run.
1440
+ */
1441
+ async claimRun(id) {
1442
+ return (await this.db.update(workflowRuns).set({
1443
+ status: "running",
1444
+ startedAt: sql`coalesce(${workflowRuns.startedAt}, now())`
1445
+ }).where(and(eq(workflowRuns.id, id), inArray(workflowRuns.status, ["queued", "waiting"]))).returning())[0] ?? null;
1446
+ }
1447
+ /** Runs paused by a delay step whose time has come. */
1448
+ async dueRuns(now, limit = 100) {
1449
+ return this.db.select().from(workflowRuns).where(and(eq(workflowRuns.status, "waiting"), lte(workflowRuns.resumeAt, now))).orderBy(workflowRuns.resumeAt).limit(limit);
1450
+ }
1451
+ async saveRunProgress(id, data) {
1452
+ await this.db.update(workflowRuns).set({
1453
+ status: data.status,
1454
+ cursor: data.cursor,
1455
+ log: data.log,
1456
+ error: data.error ?? null,
1457
+ resumeAt: data.resumeAt ?? null,
1458
+ ...data.finished ? { finishedAt: /* @__PURE__ */ new Date() } : {}
1459
+ }).where(eq(workflowRuns.id, id));
1460
+ }
1461
+ async pruneRuns(workflowId) {
1462
+ await this.db.execute(sql`
1463
+ delete from ${workflowRuns}
1464
+ where ${workflowRuns.workflowId} = ${workflowId}
1465
+ and ${workflowRuns.id} in (
1466
+ select id from ${workflowRuns}
1467
+ where ${workflowRuns.workflowId} = ${workflowId}
1468
+ order by ${workflowRuns.createdAt} desc
1469
+ offset ${200}
1470
+ )
1471
+ `);
1472
+ }
1473
+ /** The documents a scheduled workflow's selection names, newest change first. */
1474
+ async selectDocuments(spaceId, selection, limit = 500) {
1475
+ const predicates = [eq(contents.spaceId, spaceId)];
1476
+ if (selection.typeIds.length) predicates.push(inArray(contents.typeId, selection.typeIds));
1477
+ if (selection.status !== "any") predicates.push(eq(contents.status, selection.status));
1478
+ if (selection.locale) predicates.push(eq(contents.locale, selection.locale));
1479
+ if (selection.changedWithinHours) {
1480
+ const since = /* @__PURE__ */ new Date(Date.now() - selection.changedWithinHours * 36e5);
1481
+ predicates.push(gte(contents.updatedAt, since));
1482
+ }
1483
+ return this.db.select().from(contents).where(and(...predicates)).orderBy(desc(contents.updatedAt)).limit(limit);
1484
+ }
1485
+ async subscriptionsFor(userIds) {
1486
+ if (userIds.length === 0) return [];
1487
+ return this.db.select().from(pushSubscriptions).where(inArray(pushSubscriptions.userId, userIds));
1488
+ }
1489
+ async subscriptionsOf(userId) {
1490
+ return this.db.select().from(pushSubscriptions).where(eq(pushSubscriptions.userId, userId)).orderBy(desc(pushSubscriptions.createdAt));
1491
+ }
1492
+ /** Upserts on the endpoint: a browser re-subscribing keeps one row, not two. */
1493
+ async subscribe(data) {
1494
+ const [row] = await this.db.insert(pushSubscriptions).values(data).onConflictDoUpdate({
1495
+ target: pushSubscriptions.endpoint,
1496
+ set: {
1497
+ userId: data.userId,
1498
+ keys: data.keys,
1499
+ userAgent: data.userAgent
1500
+ }
1501
+ }).returning();
1502
+ if (!row) throw new ManabloxError("workflow.create.failed");
1503
+ return row;
1504
+ }
1505
+ async unsubscribe(userId, endpoint) {
1506
+ await this.db.delete(pushSubscriptions).where(and(eq(pushSubscriptions.userId, userId), eq(pushSubscriptions.endpoint, endpoint)));
1507
+ }
1508
+ /** A push service answered 404/410: the browser is gone, and so is the row. */
1509
+ async dropSubscription(id) {
1510
+ await this.db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, id));
1511
+ }
1512
+ async markSubscriptionUsed(id) {
1513
+ await this.db.update(pushSubscriptions).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq(pushSubscriptions.id, id));
1514
+ }
1515
+ };
1516
+ //#endregion
1517
+ //#region src/repositories/index.ts
1518
+ function createRepositories(db, registry) {
1519
+ return {
1520
+ content: new ContentRepository(db, registry),
1521
+ contentTypes: new ContentTypeRepository(db),
1522
+ spaces: new SpaceRepository(db),
1523
+ assets: new AssetRepository(db),
1524
+ assetUsages: new AssetUsageRepository(db),
1525
+ users: new UserRepository(db),
1526
+ menus: new MenuRepository(db),
1527
+ roles: new RoleRepository(db),
1528
+ workflows: new WorkflowRepository(db),
1529
+ webhooks: new WebhookRepository(db)
1530
+ };
1531
+ }
1532
+ //#endregion
1533
+ export { applyBootstrapSql as _, UserRepository as a, ContentTypeRepository as c, AssetUsageRepository as d, AssetRepository as f, createDatabase as g, paginate as h, WebhookRepository as i, ContentRepository as l, buildOrderBy as m, RUNS_KEPT_PER_WORKFLOW as n, SpaceRepository as o, buildContentWhere as p, WorkflowRepository as r, MenuRepository as s, createRepositories as t, buildTree$1 as u };