@manablox/db 0.1.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.
- package/README.md +21 -0
- package/dist/index-Cyf_N5K3.d.ts +658 -0
- package/dist/index-rZ24t-Ln.d.ts +4338 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +60 -0
- package/dist/repositories-DYjzuuF6.js +1533 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-Bb4p16Yz.js +539 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -0
- package/dist/testing.d.ts +77 -0
- package/dist/testing.js +217 -0
- package/migrations/0005_menus.sql +44 -0
- package/migrations/0006_roles.sql +13 -0
- package/migrations/0007_apikey-permissions.sql +4 -0
- package/migrations/0008_workflows.sql +49 -0
- package/migrations/meta/0005_snapshot.json +2504 -0
- package/migrations/meta/0006_snapshot.json +2605 -0
- package/migrations/meta/0007_snapshot.json +2605 -0
- package/migrations/meta/0008_snapshot.json +2986 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +22 -9
- package/drizzle.config.ts +0 -11
- package/src/bootstrap.ts +0 -13
- package/src/cli/migrate.ts +0 -24
- package/src/client.ts +0 -37
- package/src/columns.ts +0 -39
- package/src/index.ts +0 -13
- package/src/query.ts +0 -217
- package/src/repositories/asset-usage.ts +0 -166
- package/src/repositories/asset.ts +0 -189
- package/src/repositories/content-type.ts +0 -116
- package/src/repositories/content.ts +0 -758
- package/src/repositories/index.ts +0 -28
- package/src/repositories/space.ts +0 -78
- package/src/repositories/user.ts +0 -134
- package/src/schema.ts +0 -513
- package/test/asset-usage.test.ts +0 -101
- package/test/helpers.ts +0 -153
- package/test/publish.test.ts +0 -102
- package/test/query.test.ts +0 -140
- package/test/tree.test.ts +0 -188
- package/tsconfig.json +0 -4
- package/vitest.config.ts +0 -12
|
@@ -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 };
|