foldkit-cms-drizzle 0.1.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/LICENSE +21 -0
- package/README.md +245 -0
- package/dist/index.d.mts +1722 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +714 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +64 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { and, eq, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm";
|
|
2
|
+
import { Effect, Exit, Schema, Semaphore } from "effect";
|
|
3
|
+
import { Cms } from "foldkit-cms";
|
|
4
|
+
import { DrizzleDatabase, bind, query, returning, source } from "foldkit-remote-drizzle";
|
|
5
|
+
import { RemoteServer, RemoteServerError } from "foldkit-remote-server";
|
|
6
|
+
import { integer, jsonb, pgTable, text } from "drizzle-orm/pg-core";
|
|
7
|
+
import { integer as integer$1, sqliteTable, text as text$1 } from "drizzle-orm/sqlite-core";
|
|
8
|
+
//#region src/tables.ts
|
|
9
|
+
/**
|
|
10
|
+
* The three tables the CMS keeps beside the application's own. Drizzle tables
|
|
11
|
+
* are made per dialect, so there is one definition for each; they have the same
|
|
12
|
+
* columns. An application adds them to its schema and its migrations as it does
|
|
13
|
+
* any table, and may define its own instead, so long as the columns are these.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* - `cms_entries`: a piece of content from first keystroke to archive. It exists
|
|
17
|
+
* before its content row does.
|
|
18
|
+
* - `cms_drafts`: an entry's one working copy; its id is the entry's.
|
|
19
|
+
* - `cms_revisions`: published values, append-only.
|
|
20
|
+
*/
|
|
21
|
+
const sqliteTables = () => ({
|
|
22
|
+
entries: sqliteTable("cms_entries", {
|
|
23
|
+
id: text$1("id").primaryKey(),
|
|
24
|
+
type: text$1("type").notNull(),
|
|
25
|
+
targetId: text$1("target_id"),
|
|
26
|
+
label: text$1("label").notNull(),
|
|
27
|
+
createdBy: text$1("created_by"),
|
|
28
|
+
createdAt: text$1("created_at").notNull(),
|
|
29
|
+
archivedAt: text$1("archived_at"),
|
|
30
|
+
revision: integer$1("revision")
|
|
31
|
+
}),
|
|
32
|
+
drafts: sqliteTable("cms_drafts", {
|
|
33
|
+
id: text$1("id").primaryKey(),
|
|
34
|
+
values: text$1("values", { mode: "json" }),
|
|
35
|
+
model: text$1("model", { mode: "json" }),
|
|
36
|
+
form: text$1("form").notNull(),
|
|
37
|
+
updatedAt: text$1("updated_at").notNull(),
|
|
38
|
+
updatedBy: text$1("updated_by"),
|
|
39
|
+
baseRevision: integer$1("base_revision"),
|
|
40
|
+
scheduledFor: text$1("scheduled_for"),
|
|
41
|
+
scheduledBy: text$1("scheduled_by"),
|
|
42
|
+
scheduleError: text$1("schedule_error")
|
|
43
|
+
}),
|
|
44
|
+
revisions: sqliteTable("cms_revisions", {
|
|
45
|
+
id: text$1("id").primaryKey(),
|
|
46
|
+
entryId: text$1("entry_id").notNull(),
|
|
47
|
+
n: integer$1("n").notNull(),
|
|
48
|
+
values: text$1("values", { mode: "json" }),
|
|
49
|
+
publishedAt: text$1("published_at").notNull(),
|
|
50
|
+
publishedBy: text$1("published_by")
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
const pgTables = () => ({
|
|
54
|
+
entries: pgTable("cms_entries", {
|
|
55
|
+
id: text("id").primaryKey(),
|
|
56
|
+
type: text("type").notNull(),
|
|
57
|
+
targetId: text("target_id"),
|
|
58
|
+
label: text("label").notNull(),
|
|
59
|
+
createdBy: text("created_by"),
|
|
60
|
+
createdAt: text("created_at").notNull(),
|
|
61
|
+
archivedAt: text("archived_at"),
|
|
62
|
+
revision: integer("revision")
|
|
63
|
+
}),
|
|
64
|
+
drafts: pgTable("cms_drafts", {
|
|
65
|
+
id: text("id").primaryKey(),
|
|
66
|
+
values: jsonb("values"),
|
|
67
|
+
model: jsonb("model"),
|
|
68
|
+
form: text("form").notNull(),
|
|
69
|
+
updatedAt: text("updated_at").notNull(),
|
|
70
|
+
updatedBy: text("updated_by"),
|
|
71
|
+
baseRevision: integer("base_revision"),
|
|
72
|
+
scheduledFor: text("scheduled_for"),
|
|
73
|
+
scheduledBy: text("scheduled_by"),
|
|
74
|
+
scheduleError: text("schedule_error")
|
|
75
|
+
}),
|
|
76
|
+
revisions: pgTable("cms_revisions", {
|
|
77
|
+
id: text("id").primaryKey(),
|
|
78
|
+
entryId: text("entry_id").notNull(),
|
|
79
|
+
n: integer("n").notNull(),
|
|
80
|
+
values: jsonb("values"),
|
|
81
|
+
publishedAt: text("published_at").notNull(),
|
|
82
|
+
publishedBy: text("published_by")
|
|
83
|
+
})
|
|
84
|
+
});
|
|
85
|
+
/** The `create table` statements for SQLite, for a test or a first migration. */
|
|
86
|
+
const sqliteSchema = `
|
|
87
|
+
create table if not exists cms_entries (
|
|
88
|
+
id text primary key, type text not null, target_id text, label text not null,
|
|
89
|
+
created_by text, created_at text not null, archived_at text, revision integer
|
|
90
|
+
);
|
|
91
|
+
create table if not exists cms_drafts (
|
|
92
|
+
id text primary key, "values" text, model text, form text not null,
|
|
93
|
+
updated_at text not null, updated_by text, base_revision integer,
|
|
94
|
+
scheduled_for text, scheduled_by text, schedule_error text
|
|
95
|
+
);
|
|
96
|
+
create table if not exists cms_revisions (
|
|
97
|
+
id text primary key, entry_id text not null, n integer not null, "values" text,
|
|
98
|
+
published_at text not null, published_by text
|
|
99
|
+
);
|
|
100
|
+
`;
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/index.ts
|
|
103
|
+
/**
|
|
104
|
+
* `foldkit-cms-drizzle` — the server half of `foldkit-cms`.
|
|
105
|
+
*
|
|
106
|
+
* It keeps an author's unpublished work in three tables beside the
|
|
107
|
+
* application's own, and it is where the audience boundary is enforced: a
|
|
108
|
+
* principal that is not an author is refused entries, drafts and revisions
|
|
109
|
+
* outright, and sees of the content only the rows its `published` role shows.
|
|
110
|
+
* Both are a binding's `visible`, so they hold on every path a table is read by.
|
|
111
|
+
*
|
|
112
|
+
* Publishing runs the application's own mutation with the draft's value, inside
|
|
113
|
+
* a transaction that also appends the revision and removes the draft, so a
|
|
114
|
+
* publish happened entirely or did not happen.
|
|
115
|
+
*/
|
|
116
|
+
const statement = (database, text) => Effect.promise(() => {
|
|
117
|
+
const runs = database;
|
|
118
|
+
return Promise.resolve(runs.run !== void 0 ? runs.run(sql.raw(text)) : runs.execute(sql.raw(text)));
|
|
119
|
+
});
|
|
120
|
+
const turns = /* @__PURE__ */ new WeakMap();
|
|
121
|
+
const turnOf = (database) => {
|
|
122
|
+
const held = turns.get(database);
|
|
123
|
+
if (held !== void 0) return held;
|
|
124
|
+
const made = Semaphore.makeUnsafe(1);
|
|
125
|
+
turns.set(database, made);
|
|
126
|
+
return made;
|
|
127
|
+
};
|
|
128
|
+
const Transaction = {
|
|
129
|
+
/**
|
|
130
|
+
* `begin`, the work, then `commit` or `rollback`, as statements. For a database
|
|
131
|
+
* that is one connection, such as a SQLite file, which it takes turns at: the
|
|
132
|
+
* operations of this package wait for one another. A write of your own on the
|
|
133
|
+
* same connection does not, so make it inside `Transaction.statements` too.
|
|
134
|
+
* Over a pool each statement may take a different connection: use `drizzle` there.
|
|
135
|
+
*/
|
|
136
|
+
statements: ((work) => Effect.gen(function* () {
|
|
137
|
+
const database = yield* DrizzleDatabase;
|
|
138
|
+
return yield* turnOf(database).withPermit(statement(database, "begin").pipe(Effect.andThen(work), Effect.onExit((exit) => statement(database, Exit.isSuccess(exit) ? "commit" : "rollback"))));
|
|
139
|
+
})),
|
|
140
|
+
/**
|
|
141
|
+
* Drizzle's `database.transaction`, with the work given the transaction as its
|
|
142
|
+
* database. For a driver whose transactions are asynchronous: Postgres, libSQL.
|
|
143
|
+
*/
|
|
144
|
+
drizzle: ((work) => Effect.gen(function* () {
|
|
145
|
+
const database = yield* DrizzleDatabase;
|
|
146
|
+
const failed = Symbol("failed");
|
|
147
|
+
let exit;
|
|
148
|
+
yield* Effect.promise(() => database.transaction(async (inner) => {
|
|
149
|
+
exit = await Effect.runPromiseExit(work.pipe(Effect.provideService(DrizzleDatabase, inner)));
|
|
150
|
+
if (!Exit.isSuccess(exit)) throw failed;
|
|
151
|
+
}).catch((error) => {
|
|
152
|
+
if (error !== failed) throw error;
|
|
153
|
+
}));
|
|
154
|
+
return yield* exit;
|
|
155
|
+
}))
|
|
156
|
+
};
|
|
157
|
+
const refuse = (message) => new RemoteServerError({ message });
|
|
158
|
+
/**
|
|
159
|
+
* Whether a unique index on this column refused a write. Drivers say so in their
|
|
160
|
+
* own words, and Drizzle wraps what they say, so every message down the chain of
|
|
161
|
+
* causes is read. SQLite names the column; Postgres names the constraint, which
|
|
162
|
+
* by default is made of the column's name.
|
|
163
|
+
*/
|
|
164
|
+
const refusedAsDuplicate = (defect, column) => {
|
|
165
|
+
const said = [];
|
|
166
|
+
for (let at = defect, depth = 0; at != null && depth < 5; depth++) {
|
|
167
|
+
const { message, constraint, cause } = at;
|
|
168
|
+
if (!String(message).startsWith("Failed query")) said.push(String(message ?? at), String(constraint ?? ""));
|
|
169
|
+
at = cause;
|
|
170
|
+
}
|
|
171
|
+
const text = said.join(" ");
|
|
172
|
+
const named = new RegExp(`(^|[^a-zA-Z0-9])${column.replace(/[^\w]/g, ".")}([^a-zA-Z0-9]|$)`);
|
|
173
|
+
return /unique|duplicate/i.test(text) && named.test(text);
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* For a content table's binding: a visitor sees the rows whose `published`
|
|
177
|
+
* column is set, and an author sees every row. Give it as the storage's
|
|
178
|
+
* `visible` when the table is bound; `CmsServer.make` refuses a content type
|
|
179
|
+
* that can be unpublished and whose binding shows every row to everyone.
|
|
180
|
+
*/
|
|
181
|
+
const published = (column, isAuthor) => (principal) => isAuthor(principal) ? void 0 : isNotNull(column);
|
|
182
|
+
const CmsServer = { make: (config) => {
|
|
183
|
+
const { tables, isAuthor } = config;
|
|
184
|
+
const now = config.now ?? (() => /* @__PURE__ */ new Date());
|
|
185
|
+
const nameOf = config.nameOf ?? (() => null);
|
|
186
|
+
const maxDraftSize = config.maxDraftSize ?? 1e6;
|
|
187
|
+
const byType = new Map(config.content.map((served) => [served.type.name, served]));
|
|
188
|
+
for (const { type, binding, create, update } of config.content) {
|
|
189
|
+
for (const [handler, declared] of [[create, type.publish.create], [update, type.publish.update]]) if (handler.mutation !== declared.name) throw new Error(`foldkit-cms-drizzle: content "${type.name}" publishes through "${declared.name}", but was given the handler of "${handler.mutation}"`);
|
|
190
|
+
if (type.roles.published === void 0) continue;
|
|
191
|
+
if (binding.visible === void 0) throw new Error(`foldkit-cms-drizzle: content "${type.name}" can be unpublished, but its binding shows every row to everyone; bind its table with \`visible: published(column, isAuthor)\``);
|
|
192
|
+
}
|
|
193
|
+
const authorsOnly = (principal) => isAuthor(principal) ? void 0 : sql`0 = 1`;
|
|
194
|
+
const Db = bind(Cms.Entities, {
|
|
195
|
+
Entry: {
|
|
196
|
+
table: tables.entries,
|
|
197
|
+
visible: authorsOnly,
|
|
198
|
+
relations: {
|
|
199
|
+
draft: { foreignKey: tables.drafts.id },
|
|
200
|
+
revisions: {
|
|
201
|
+
foreignKey: tables.revisions.entryId,
|
|
202
|
+
orderBy: [{
|
|
203
|
+
column: tables.revisions.n,
|
|
204
|
+
direction: "desc"
|
|
205
|
+
}]
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
derived: { state: { supplied: true } }
|
|
209
|
+
},
|
|
210
|
+
Draft: {
|
|
211
|
+
table: tables.drafts,
|
|
212
|
+
visible: authorsOnly
|
|
213
|
+
},
|
|
214
|
+
Revision: {
|
|
215
|
+
table: tables.revisions,
|
|
216
|
+
visible: authorsOnly
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
/** What is known of some entries, for their state: their drafts, and whether a visitor sees their rows. */
|
|
220
|
+
const factsOf = (entries) => Effect.gen(function* () {
|
|
221
|
+
const database = yield* DrizzleDatabase;
|
|
222
|
+
const ids = entries.map((entry) => entry.id);
|
|
223
|
+
const drafts = ids.length === 0 ? [] : yield* Effect.promise(() => Promise.resolve(database.select({
|
|
224
|
+
id: tables.drafts.id,
|
|
225
|
+
scheduledFor: tables.drafts.scheduledFor,
|
|
226
|
+
scheduleError: tables.drafts.scheduleError
|
|
227
|
+
}).from(tables.drafts).where(inArray(tables.drafts.id, ids))));
|
|
228
|
+
const draftOf = new Map(drafts.map((draft) => [String(draft.id), draft]));
|
|
229
|
+
const rows = /* @__PURE__ */ new Map();
|
|
230
|
+
for (const [name, { type, binding }] of byType) {
|
|
231
|
+
const targets = entries.flatMap((entry) => entry.type === name && entry.targetId !== null ? [entry.targetId] : []);
|
|
232
|
+
if (targets.length === 0) continue;
|
|
233
|
+
const id = binding.columns.id;
|
|
234
|
+
const publishedColumn = type.roles.published === void 0 ? void 0 : binding.columns[type.roles.published.key];
|
|
235
|
+
const found = yield* Effect.promise(() => Promise.resolve(database.select({
|
|
236
|
+
id,
|
|
237
|
+
...publishedColumn === void 0 ? {} : { shown: publishedColumn }
|
|
238
|
+
}).from(binding.table).where(inArray(id, targets))));
|
|
239
|
+
for (const row of found) rows.set(`${name}:${String(row.id)}`, publishedColumn === void 0 || row.shown !== null ? "visible" : "hidden");
|
|
240
|
+
}
|
|
241
|
+
return new Map(entries.map((entry) => {
|
|
242
|
+
const draft = draftOf.get(entry.id);
|
|
243
|
+
return [entry.id, {
|
|
244
|
+
archivedAt: entry.archivedAt,
|
|
245
|
+
row: entry.targetId === null ? "none" : rows.get(`${entry.type}:${entry.targetId}`) ?? "none",
|
|
246
|
+
draft: draft === void 0 ? null : {
|
|
247
|
+
scheduledFor: draft.scheduledFor ?? null,
|
|
248
|
+
scheduleError: draft.scheduleError ?? null
|
|
249
|
+
}
|
|
250
|
+
}];
|
|
251
|
+
}));
|
|
252
|
+
});
|
|
253
|
+
const entryColumns = {
|
|
254
|
+
id: tables.entries.id,
|
|
255
|
+
type: tables.entries.type,
|
|
256
|
+
targetId: tables.entries.targetId,
|
|
257
|
+
label: tables.entries.label,
|
|
258
|
+
archivedAt: tables.entries.archivedAt,
|
|
259
|
+
revision: tables.entries.revision
|
|
260
|
+
};
|
|
261
|
+
const findEntry = (id) => Effect.gen(function* () {
|
|
262
|
+
const database = yield* DrizzleDatabase;
|
|
263
|
+
const [row] = yield* Effect.promise(() => Promise.resolve(database.select(entryColumns).from(tables.entries).where(eq(tables.entries.id, id)).limit(1)));
|
|
264
|
+
return row;
|
|
265
|
+
});
|
|
266
|
+
const generated = source(Db.Entry);
|
|
267
|
+
const entries = {
|
|
268
|
+
...generated,
|
|
269
|
+
read: (context) => Effect.gen(function* () {
|
|
270
|
+
const wantsState = context.fields.includes("state");
|
|
271
|
+
const fields = context.fields.filter((field) => field !== "state");
|
|
272
|
+
const records = yield* generated.read({
|
|
273
|
+
...context,
|
|
274
|
+
fields: wantsState ? [.../* @__PURE__ */ new Set([
|
|
275
|
+
...fields,
|
|
276
|
+
"type",
|
|
277
|
+
"targetId",
|
|
278
|
+
"archivedAt"
|
|
279
|
+
])] : fields
|
|
280
|
+
});
|
|
281
|
+
if (!wantsState) return records;
|
|
282
|
+
const facts = yield* factsOf(records.map((record) => ({
|
|
283
|
+
id: record.id,
|
|
284
|
+
type: String(record.values.type),
|
|
285
|
+
targetId: record.values.targetId ?? null,
|
|
286
|
+
label: "",
|
|
287
|
+
archivedAt: record.values.archivedAt ?? null,
|
|
288
|
+
revision: null
|
|
289
|
+
})));
|
|
290
|
+
const at = now();
|
|
291
|
+
return records.map((record) => ({
|
|
292
|
+
id: record.id,
|
|
293
|
+
values: {
|
|
294
|
+
...Object.fromEntries(Object.entries(record.values).filter(([field]) => field === "id" || context.fields.includes(field))),
|
|
295
|
+
state: Cms.state(facts.get(record.id), at)
|
|
296
|
+
}
|
|
297
|
+
}));
|
|
298
|
+
})
|
|
299
|
+
};
|
|
300
|
+
const worklist = query(Cms.Entries, { entity: Db.Entry });
|
|
301
|
+
/** A mutation of this server: its principal and its database are fixed, its input is the descriptor's. */
|
|
302
|
+
const operation = (descriptor, run) => RemoteServer.mutation(descriptor, (context) => isAuthor(context.principal) ? config.transaction(run(context)) : Effect.fail(refuse("Only an author may change unpublished work")));
|
|
303
|
+
/**
|
|
304
|
+
* A scheduled draft is published, when its time comes, as whoever scheduled it.
|
|
305
|
+
* So changing it, replacing it or removing it is for someone who could have
|
|
306
|
+
* scheduled it: otherwise an author who may not publish would publish through
|
|
307
|
+
* someone who may.
|
|
308
|
+
*/
|
|
309
|
+
const promised = (principal, entry) => Effect.gen(function* () {
|
|
310
|
+
const [draft] = yield* readRows(Db.Draft, { scheduledFor: tables.drafts.scheduledFor }, entry.id);
|
|
311
|
+
if (draft?.scheduledFor == null) return;
|
|
312
|
+
if (config.allow?.(principal, "schedule", entry) === false) return yield* refuse("This draft is scheduled, and only someone who may schedule it may change it");
|
|
313
|
+
});
|
|
314
|
+
/** Whether the application lets this author do this, once the entry is known. */
|
|
315
|
+
const asking = (principal, transition, entry) => entry !== void 0 && config.allow?.(principal, transition, entry) === false ? Effect.fail(refuse(`This author may not ${transition} this entry`)) : Effect.void;
|
|
316
|
+
const draftPatch = returning(Db.Draft, [
|
|
317
|
+
"values",
|
|
318
|
+
"model",
|
|
319
|
+
"form",
|
|
320
|
+
"updatedAt",
|
|
321
|
+
"updatedBy",
|
|
322
|
+
"baseRevision"
|
|
323
|
+
]);
|
|
324
|
+
const entryPatch = returning(Db.Entry, [
|
|
325
|
+
"type",
|
|
326
|
+
"targetId",
|
|
327
|
+
"label",
|
|
328
|
+
"createdAt",
|
|
329
|
+
"archivedAt",
|
|
330
|
+
"revision"
|
|
331
|
+
]);
|
|
332
|
+
/**
|
|
333
|
+
* The entry as the client should now hold it, its state with it: an operation
|
|
334
|
+
* changes the state, and a patch without it would leave the old one on screen.
|
|
335
|
+
*/
|
|
336
|
+
const entryPatches = (id) => Effect.gen(function* () {
|
|
337
|
+
const patches = entryPatch.patches(yield* readRows(Db.Entry, entryPatch.columns, id));
|
|
338
|
+
const entry = yield* findEntry(id);
|
|
339
|
+
if (entry === void 0) return patches;
|
|
340
|
+
const facts = (yield* factsOf([entry])).get(id);
|
|
341
|
+
return patches.map((patch) => ({
|
|
342
|
+
...patch,
|
|
343
|
+
values: {
|
|
344
|
+
...patch.values,
|
|
345
|
+
state: Cms.state(facts, now())
|
|
346
|
+
}
|
|
347
|
+
}));
|
|
348
|
+
});
|
|
349
|
+
const readRows = (binding, columns, id) => Effect.gen(function* () {
|
|
350
|
+
const database = yield* DrizzleDatabase;
|
|
351
|
+
return yield* Effect.promise(() => Promise.resolve(database.select(columns).from(binding.table).where(eq(binding.columns.id, id))));
|
|
352
|
+
});
|
|
353
|
+
const SaveDraft = operation(Cms.Operations.SaveDraft, ({ input, principal }) => Effect.gen(function* () {
|
|
354
|
+
if (!byType.has(input.type)) return yield* refuse(`"${input.type}" is not a type of content this server knows`);
|
|
355
|
+
const size = JSON.stringify([input.values, input.model]).length;
|
|
356
|
+
if (size > maxDraftSize) return yield* refuse(`This draft is too large to save: ${size} characters, and ${maxDraftSize} is the most`);
|
|
357
|
+
const existing = yield* findEntry(input.entry);
|
|
358
|
+
if (existing !== void 0 && existing.type !== input.type) return yield* refuse(`This entry is of "${existing.type}", not "${input.type}"`);
|
|
359
|
+
if (existing?.archivedAt != null) return yield* refuse("An archived entry takes no draft");
|
|
360
|
+
yield* asking(principal, "save", existing);
|
|
361
|
+
if (existing !== void 0) yield* promised(principal, existing);
|
|
362
|
+
const database = yield* DrizzleDatabase;
|
|
363
|
+
const who = nameOf(principal);
|
|
364
|
+
const id = existing?.id ?? input.entry;
|
|
365
|
+
const [held] = yield* readRows(Db.Draft, { updatedAt: tables.drafts.updatedAt }, id);
|
|
366
|
+
const previous = held === void 0 ? null : String(held.updatedAt);
|
|
367
|
+
const at = now().toISOString();
|
|
368
|
+
const updatedAt = previous !== null && at <= previous ? new Date(new Date(previous).getTime() + 1).toISOString() : at;
|
|
369
|
+
const written = {
|
|
370
|
+
values: input.values,
|
|
371
|
+
model: input.model,
|
|
372
|
+
form: input.form,
|
|
373
|
+
updatedAt,
|
|
374
|
+
updatedBy: who,
|
|
375
|
+
scheduleError: null
|
|
376
|
+
};
|
|
377
|
+
if (previous === null && input.basedOn !== null) return yield* refuse("CmsConflict: this draft was discarded");
|
|
378
|
+
if (existing === void 0) yield* Effect.promise(() => Promise.resolve(database.insert(tables.entries).values({
|
|
379
|
+
id,
|
|
380
|
+
type: input.type,
|
|
381
|
+
targetId: null,
|
|
382
|
+
label: input.label,
|
|
383
|
+
createdBy: who,
|
|
384
|
+
createdAt: at,
|
|
385
|
+
archivedAt: null,
|
|
386
|
+
revision: null
|
|
387
|
+
})));
|
|
388
|
+
if (previous === null) yield* Effect.promise(() => Promise.resolve(database.insert(tables.drafts).values({
|
|
389
|
+
id,
|
|
390
|
+
...written,
|
|
391
|
+
baseRevision: null
|
|
392
|
+
})));
|
|
393
|
+
else if ((yield* Effect.promise(() => Promise.resolve(database.update(tables.drafts).set(written).where(and(eq(tables.drafts.id, id), eq(tables.drafts.updatedAt, input.basedOn ?? ""))).returning({ id: tables.drafts.id })))).length === 0) return yield* refuse("CmsConflict: this draft was saved by someone else since");
|
|
394
|
+
if (existing !== void 0 && existing.label !== input.label) yield* Effect.promise(() => Promise.resolve(database.update(tables.entries).set({ label: input.label }).where(eq(tables.entries.id, id)).returning({ id: tables.entries.id })));
|
|
395
|
+
const entities = [...yield* entryPatches(id), ...draftPatch.patches(yield* readRows(Db.Draft, draftPatch.columns, id))];
|
|
396
|
+
return {
|
|
397
|
+
output: {
|
|
398
|
+
entry: id,
|
|
399
|
+
updatedAt
|
|
400
|
+
},
|
|
401
|
+
entities
|
|
402
|
+
};
|
|
403
|
+
}));
|
|
404
|
+
const DiscardDraft = operation(Cms.Operations.DiscardDraft, ({ input, principal }) => Effect.gen(function* () {
|
|
405
|
+
const entry = yield* findEntry(input.entry);
|
|
406
|
+
if (entry === void 0) return yield* refuse("There is no such entry");
|
|
407
|
+
yield* asking(principal, "discard", entry);
|
|
408
|
+
yield* promised(principal, entry);
|
|
409
|
+
const database = yield* DrizzleDatabase;
|
|
410
|
+
yield* Effect.promise(() => Promise.resolve(database.delete(tables.drafts).where(eq(tables.drafts.id, entry.id))));
|
|
411
|
+
const gone = [{
|
|
412
|
+
entity: "CmsDraft",
|
|
413
|
+
id: entry.id
|
|
414
|
+
}];
|
|
415
|
+
if (entry.targetId !== null) return {
|
|
416
|
+
output: {},
|
|
417
|
+
deleted: gone,
|
|
418
|
+
entities: yield* entryPatches(entry.id)
|
|
419
|
+
};
|
|
420
|
+
yield* Effect.promise(() => Promise.resolve(database.delete(tables.entries).where(eq(tables.entries.id, entry.id))));
|
|
421
|
+
return {
|
|
422
|
+
output: {},
|
|
423
|
+
deleted: [...gone, {
|
|
424
|
+
entity: "CmsEntry",
|
|
425
|
+
id: entry.id
|
|
426
|
+
}]
|
|
427
|
+
};
|
|
428
|
+
}));
|
|
429
|
+
const done = {
|
|
430
|
+
publish: "published",
|
|
431
|
+
unpublish: "unpublished",
|
|
432
|
+
schedule: "scheduled",
|
|
433
|
+
unschedule: "unscheduled",
|
|
434
|
+
archive: "archived",
|
|
435
|
+
unarchive: "unarchived",
|
|
436
|
+
restore: "restored"
|
|
437
|
+
};
|
|
438
|
+
/** The entry, if its state offers this transition now. */
|
|
439
|
+
const offering = (id, transition) => Effect.gen(function* () {
|
|
440
|
+
const entry = yield* findEntry(id);
|
|
441
|
+
if (entry === void 0) return yield* refuse("There is no such entry");
|
|
442
|
+
const served = byType.get(entry.type);
|
|
443
|
+
if (served === void 0) return yield* refuse(`"${entry.type}" is not a type of content this server knows`);
|
|
444
|
+
const facts = (yield* factsOf([entry])).get(entry.id);
|
|
445
|
+
if (!Cms.offers(facts, now(), served.type).includes(transition)) return yield* refuse(`This entry cannot be ${done[transition]} as it stands`);
|
|
446
|
+
return {
|
|
447
|
+
entry,
|
|
448
|
+
served,
|
|
449
|
+
facts
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
/** What the `published` role's column holds for "now". */
|
|
453
|
+
const stamp = (column, at) => {
|
|
454
|
+
const kind = column.dataType.split(" ")[0];
|
|
455
|
+
return kind === "string" ? at.toISOString() : kind === "number" ? at.getTime() : at;
|
|
456
|
+
};
|
|
457
|
+
const scheduledPatch = returning(Db.Draft, ["scheduledFor", "scheduleError"]);
|
|
458
|
+
const revisionPatch = returning(Db.Revision, [
|
|
459
|
+
"n",
|
|
460
|
+
"values",
|
|
461
|
+
"publishedAt",
|
|
462
|
+
"publishedBy"
|
|
463
|
+
]);
|
|
464
|
+
/** The draft as the publish mutation would take it, or why it would not. */
|
|
465
|
+
const readied = ({ entry, served, facts }) => Effect.gen(function* () {
|
|
466
|
+
const [draft] = yield* readRows(Db.Draft, { values: tables.drafts.values }, entry.id);
|
|
467
|
+
const creating = facts.row === "none";
|
|
468
|
+
const handler = creating ? served.create : served.update;
|
|
469
|
+
const value = creating || typeof draft?.values !== "object" || draft.values === null ? draft?.values : {
|
|
470
|
+
...draft.values,
|
|
471
|
+
id: entry.targetId
|
|
472
|
+
};
|
|
473
|
+
return {
|
|
474
|
+
draft,
|
|
475
|
+
creating,
|
|
476
|
+
handler,
|
|
477
|
+
decoded: yield* Schema.decodeUnknownEffect(handler.Input)(value).pipe(Effect.mapError((error) => refuse(`This draft is not ready to publish: ${String(error)}`)))
|
|
478
|
+
};
|
|
479
|
+
});
|
|
480
|
+
/** A publish, for the operation that asks for one and for the schedule that comes due. */
|
|
481
|
+
const publishing = (found, principal, basedOn) => Effect.gen(function* () {
|
|
482
|
+
const { entry, served } = found;
|
|
483
|
+
const database = yield* DrizzleDatabase;
|
|
484
|
+
const held = entry.revision;
|
|
485
|
+
const conflict = refuse("CmsConflict: this entry was published by someone else since");
|
|
486
|
+
if (held !== basedOn) return yield* conflict;
|
|
487
|
+
const { draft, creating, handler, decoded } = yield* readied(found);
|
|
488
|
+
const address = served.type.roles.slug;
|
|
489
|
+
const slug = address === void 0 ? void 0 : decoded?.[address.key];
|
|
490
|
+
const taken = (key) => refuse(Cms.slugTaken.message(key, String(slug)));
|
|
491
|
+
if (address !== void 0 && typeof slug === "string") {
|
|
492
|
+
const column = served.binding.columns[address.key];
|
|
493
|
+
const id = served.binding.columns.id;
|
|
494
|
+
if ((yield* Effect.promise(() => Promise.resolve(database.select({ id }).from(served.binding.table).where(and(eq(column, slug), creating ? void 0 : ne(id, entry.targetId))).limit(1)))).length > 0) return yield* taken(address.key);
|
|
495
|
+
}
|
|
496
|
+
return yield* Effect.gen(function* () {
|
|
497
|
+
const writes = yield* DrizzleDatabase;
|
|
498
|
+
const n = (held ?? 0) + 1;
|
|
499
|
+
if ((yield* Effect.promise(() => Promise.resolve(writes.update(tables.entries).set({ revision: n }).where(and(eq(tables.entries.id, entry.id), held === null ? isNull(tables.entries.revision) : eq(tables.entries.revision, held))).returning({ id: tables.entries.id })))).length === 0) return yield* conflict;
|
|
500
|
+
const ran = yield* handler.run({
|
|
501
|
+
input: decoded,
|
|
502
|
+
principal
|
|
503
|
+
});
|
|
504
|
+
const targetId = creating ? String(ran.output.id) : entry.targetId;
|
|
505
|
+
const at = now();
|
|
506
|
+
const shown = served.type.roles.published;
|
|
507
|
+
if (shown !== void 0) {
|
|
508
|
+
const column = served.binding.columns[shown.key];
|
|
509
|
+
yield* Effect.promise(() => Promise.resolve(writes.update(served.binding.table).set({ [shown.key]: stamp(column, at) }).where(and(eq(served.binding.columns.id, targetId), isNull(column))).returning({ id: served.binding.columns.id })));
|
|
510
|
+
}
|
|
511
|
+
const revisionId = `${entry.id}:${n}`;
|
|
512
|
+
yield* Effect.promise(() => Promise.resolve(writes.insert(tables.revisions).values({
|
|
513
|
+
id: revisionId,
|
|
514
|
+
entryId: entry.id,
|
|
515
|
+
n,
|
|
516
|
+
values: draft?.values,
|
|
517
|
+
publishedAt: at.toISOString(),
|
|
518
|
+
publishedBy: nameOf(principal)
|
|
519
|
+
})));
|
|
520
|
+
yield* Effect.promise(() => Promise.resolve(writes.delete(tables.drafts).where(eq(tables.drafts.id, entry.id))));
|
|
521
|
+
yield* Effect.promise(() => Promise.resolve(writes.update(tables.entries).set({ targetId }).where(eq(tables.entries.id, entry.id)).returning({ id: tables.entries.id })));
|
|
522
|
+
const content = returning(served.binding, shown === void 0 ? [] : [shown.key]);
|
|
523
|
+
return {
|
|
524
|
+
output: {
|
|
525
|
+
entry: entry.id,
|
|
526
|
+
targetId,
|
|
527
|
+
revision: n
|
|
528
|
+
},
|
|
529
|
+
entities: [
|
|
530
|
+
...ran.entities,
|
|
531
|
+
...content.patches(yield* readRows(served.binding, content.columns, targetId)),
|
|
532
|
+
...yield* entryPatches(entry.id),
|
|
533
|
+
...revisionPatch.patches(yield* readRows(Db.Revision, revisionPatch.columns, revisionId))
|
|
534
|
+
],
|
|
535
|
+
connections: ran.connections,
|
|
536
|
+
deleted: [...ran.deleted, {
|
|
537
|
+
entity: "CmsDraft",
|
|
538
|
+
id: entry.id
|
|
539
|
+
}]
|
|
540
|
+
};
|
|
541
|
+
}).pipe(Effect.catchDefect((defect) => address !== void 0 && refusedAsDuplicate(defect, served.binding.columns[address.key].name) ? Effect.fail(taken(address.key)) : Effect.die(defect)));
|
|
542
|
+
});
|
|
543
|
+
const Publish = operation(Cms.Operations.Publish, ({ input, principal }) => Effect.gen(function* () {
|
|
544
|
+
const found = yield* offering(input.entry, "publish");
|
|
545
|
+
yield* asking(principal, "publish", found.entry);
|
|
546
|
+
return yield* publishing(found, principal, input.basedOn);
|
|
547
|
+
}));
|
|
548
|
+
/** Writes to an entry's draft, and answers with both as the client should now hold them. */
|
|
549
|
+
const drafted = (id, values) => Effect.gen(function* () {
|
|
550
|
+
const writes = yield* DrizzleDatabase;
|
|
551
|
+
yield* Effect.promise(() => Promise.resolve(writes.update(tables.drafts).set(values).where(eq(tables.drafts.id, id)).returning({ id: tables.drafts.id })));
|
|
552
|
+
return {
|
|
553
|
+
output: {},
|
|
554
|
+
entities: [...yield* entryPatches(id), ...scheduledPatch.patches(yield* readRows(Db.Draft, scheduledPatch.columns, id))]
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
const Schedule = operation(Cms.Operations.Schedule, ({ input, principal }) => Effect.gen(function* () {
|
|
558
|
+
const found = yield* offering(input.entry, "schedule");
|
|
559
|
+
yield* asking(principal, "schedule", found.entry);
|
|
560
|
+
if (Number.isNaN(new Date(input.at).getTime())) return yield* refuse(`"${input.at}" is not a time`);
|
|
561
|
+
yield* readied(found);
|
|
562
|
+
return yield* drafted(found.entry.id, {
|
|
563
|
+
scheduledFor: new Date(input.at).toISOString(),
|
|
564
|
+
scheduledBy: nameOf(principal),
|
|
565
|
+
scheduleError: null
|
|
566
|
+
});
|
|
567
|
+
}));
|
|
568
|
+
const Unschedule = operation(Cms.Operations.Unschedule, ({ input, principal }) => Effect.gen(function* () {
|
|
569
|
+
const found = yield* offering(input.entry, "unschedule");
|
|
570
|
+
yield* asking(principal, "unschedule", found.entry);
|
|
571
|
+
return yield* drafted(found.entry.id, {
|
|
572
|
+
scheduledFor: null,
|
|
573
|
+
scheduledBy: null,
|
|
574
|
+
scheduleError: null
|
|
575
|
+
});
|
|
576
|
+
}));
|
|
577
|
+
/** Archiving and its undoing: a fact about the entry, and for what can be hidden, the row. */
|
|
578
|
+
const archiving = (transition) => operation(transition === "archive" ? Cms.Operations.Archive : Cms.Operations.Unarchive, ({ input, principal }) => Effect.gen(function* () {
|
|
579
|
+
const { entry, served } = yield* offering(input.entry, transition);
|
|
580
|
+
yield* asking(principal, transition, entry);
|
|
581
|
+
const writes = yield* DrizzleDatabase;
|
|
582
|
+
yield* Effect.promise(() => Promise.resolve(writes.update(tables.entries).set({ archivedAt: transition === "archive" ? now().toISOString() : null }).where(eq(tables.entries.id, entry.id)).returning({ id: tables.entries.id })));
|
|
583
|
+
const shown = served.type.roles.published;
|
|
584
|
+
if (transition === "unarchive" || shown === void 0 || entry.targetId === null) return {
|
|
585
|
+
output: {},
|
|
586
|
+
entities: yield* entryPatches(entry.id)
|
|
587
|
+
};
|
|
588
|
+
yield* Effect.promise(() => Promise.resolve(writes.update(served.binding.table).set({ [shown.key]: null }).where(eq(served.binding.columns.id, entry.targetId)).returning({ id: served.binding.columns.id })));
|
|
589
|
+
const content = returning(served.binding, [shown.key]);
|
|
590
|
+
return {
|
|
591
|
+
output: {},
|
|
592
|
+
entities: [...content.patches(yield* readRows(served.binding, content.columns, entry.targetId)), ...yield* entryPatches(entry.id)]
|
|
593
|
+
};
|
|
594
|
+
}));
|
|
595
|
+
/**
|
|
596
|
+
* A revision's value becomes the working copy, and nothing is published. It
|
|
597
|
+
* replaces what the draft held, and takes back a promise made of that: what
|
|
598
|
+
* was scheduled is not what is there now. The saved Model goes too, so an
|
|
599
|
+
* editor fills its form from the values, key by key.
|
|
600
|
+
*/
|
|
601
|
+
const Restore = operation(Cms.Operations.Restore, ({ input, principal }) => Effect.gen(function* () {
|
|
602
|
+
const { entry } = yield* offering(input.entry, "restore");
|
|
603
|
+
yield* asking(principal, "restore", entry);
|
|
604
|
+
yield* promised(principal, entry);
|
|
605
|
+
const database = yield* DrizzleDatabase;
|
|
606
|
+
const [revision] = yield* Effect.promise(() => Promise.resolve(database.select({ values: tables.revisions.values }).from(tables.revisions).where(and(eq(tables.revisions.entryId, entry.id), eq(tables.revisions.n, input.revision))).limit(1)));
|
|
607
|
+
if (revision === void 0) return yield* refuse(`This entry has no revision ${input.revision}`);
|
|
608
|
+
const writes = database;
|
|
609
|
+
const [held] = yield* readRows(Db.Draft, { updatedAt: tables.drafts.updatedAt }, entry.id);
|
|
610
|
+
const at = now().toISOString();
|
|
611
|
+
const updatedAt = held !== void 0 && at <= String(held.updatedAt) ? new Date(new Date(String(held.updatedAt)).getTime() + 1).toISOString() : at;
|
|
612
|
+
const restored = {
|
|
613
|
+
values: revision.values,
|
|
614
|
+
model: null,
|
|
615
|
+
form: `restored@${input.revision}`,
|
|
616
|
+
updatedAt,
|
|
617
|
+
updatedBy: nameOf(principal),
|
|
618
|
+
baseRevision: entry.revision,
|
|
619
|
+
scheduledFor: null,
|
|
620
|
+
scheduledBy: null,
|
|
621
|
+
scheduleError: null
|
|
622
|
+
};
|
|
623
|
+
yield* Effect.promise(() => Promise.resolve(held === void 0 ? writes.insert(tables.drafts).values({
|
|
624
|
+
id: entry.id,
|
|
625
|
+
...restored
|
|
626
|
+
}) : writes.update(tables.drafts).set(restored).where(eq(tables.drafts.id, entry.id)).returning({ id: tables.drafts.id })));
|
|
627
|
+
return {
|
|
628
|
+
output: { updatedAt },
|
|
629
|
+
entities: [
|
|
630
|
+
...yield* entryPatches(entry.id),
|
|
631
|
+
...draftPatch.patches(yield* readRows(Db.Draft, draftPatch.columns, entry.id)),
|
|
632
|
+
...scheduledPatch.patches(yield* readRows(Db.Draft, scheduledPatch.columns, entry.id))
|
|
633
|
+
]
|
|
634
|
+
};
|
|
635
|
+
}));
|
|
636
|
+
/**
|
|
637
|
+
* Publishes every draft whose time has come, each in its own transaction, as
|
|
638
|
+
* whoever scheduled it: `as` says who a stored name is. One that fails stays
|
|
639
|
+
* scheduled with the reason, and is left alone until its draft changes, so a
|
|
640
|
+
* fault the author must fix is not tried again every minute. The package owns
|
|
641
|
+
* no timer: a cron trigger, an interval or a queue calls this.
|
|
642
|
+
*/
|
|
643
|
+
const due = (at, options) => Effect.gen(function* () {
|
|
644
|
+
const database = yield* DrizzleDatabase;
|
|
645
|
+
const waiting = yield* Effect.promise(() => Promise.resolve(database.select({
|
|
646
|
+
id: tables.drafts.id,
|
|
647
|
+
scheduledBy: tables.drafts.scheduledBy
|
|
648
|
+
}).from(tables.drafts).where(and(isNotNull(tables.drafts.scheduledFor), lte(tables.drafts.scheduledFor, at.toISOString()), isNull(tables.drafts.scheduleError), sql`${tables.drafts.id} in (select ${tables.entries.id} from ${tables.entries} where ${tables.entries.archivedAt} is null)`))));
|
|
649
|
+
const outcomes = [];
|
|
650
|
+
for (const draft of waiting) {
|
|
651
|
+
const id = String(draft.id);
|
|
652
|
+
const principal = options.as(draft.scheduledBy ?? null);
|
|
653
|
+
const error = yield* config.transaction(Effect.gen(function* () {
|
|
654
|
+
if (!isAuthor(principal)) return yield* refuse("Whoever scheduled this is no longer an author");
|
|
655
|
+
const found = yield* offering(id, "publish");
|
|
656
|
+
yield* asking(principal, "publish", found.entry);
|
|
657
|
+
yield* publishing(found, principal, found.entry.revision);
|
|
658
|
+
return null;
|
|
659
|
+
})).pipe(Effect.catch((failure) => Effect.succeed(failure.message)), Effect.catchDefect((defect) => Effect.succeed(String(defect))));
|
|
660
|
+
if (error !== null) yield* config.transaction(drafted(id, { scheduleError: error }));
|
|
661
|
+
outcomes.push({
|
|
662
|
+
entry: id,
|
|
663
|
+
error
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return outcomes;
|
|
667
|
+
});
|
|
668
|
+
const Unpublish = operation(Cms.Operations.Unpublish, ({ input, principal }) => Effect.gen(function* () {
|
|
669
|
+
const { entry, served } = yield* offering(input.entry, "unpublish");
|
|
670
|
+
yield* asking(principal, "unpublish", entry);
|
|
671
|
+
const shown = served.type.roles.published;
|
|
672
|
+
const writes = yield* DrizzleDatabase;
|
|
673
|
+
yield* Effect.promise(() => Promise.resolve(writes.update(served.binding.table).set({ [shown.key]: null }).where(eq(served.binding.columns.id, entry.targetId)).returning({ id: served.binding.columns.id })));
|
|
674
|
+
const content = returning(served.binding, [shown.key]);
|
|
675
|
+
return {
|
|
676
|
+
output: {},
|
|
677
|
+
entities: [...content.patches(yield* readRows(served.binding, content.columns, entry.targetId)), ...yield* entryPatches(entry.id)]
|
|
678
|
+
};
|
|
679
|
+
}));
|
|
680
|
+
const sources = [
|
|
681
|
+
entries,
|
|
682
|
+
source(Db.Draft),
|
|
683
|
+
source(Db.Revision),
|
|
684
|
+
...config.content.map((served) => source(served.binding))
|
|
685
|
+
];
|
|
686
|
+
const mutations = [
|
|
687
|
+
SaveDraft,
|
|
688
|
+
DiscardDraft,
|
|
689
|
+
Publish,
|
|
690
|
+
Unpublish,
|
|
691
|
+
Schedule,
|
|
692
|
+
Unschedule,
|
|
693
|
+
archiving("archive"),
|
|
694
|
+
archiving("unarchive"),
|
|
695
|
+
Restore
|
|
696
|
+
];
|
|
697
|
+
return {
|
|
698
|
+
/**
|
|
699
|
+
* The sources of the content types and of the CMS's own Entities, each behind
|
|
700
|
+
* the audience boundary. Register these and not `source(binding)` of a content
|
|
701
|
+
* table: that is how the boundary cannot be forgotten.
|
|
702
|
+
*/
|
|
703
|
+
sources,
|
|
704
|
+
queries: [worklist, ...config.content.flatMap(({ type, binding }) => type.roles.slug === void 0 ? [] : [query(Cms.bySlug(type), { entity: binding })])],
|
|
705
|
+
mutations,
|
|
706
|
+
due,
|
|
707
|
+
/** The bindings of `Entry`, `Draft` and `Revision`, for a handler that returns patches of them. */
|
|
708
|
+
bindings: Db
|
|
709
|
+
};
|
|
710
|
+
} };
|
|
711
|
+
//#endregion
|
|
712
|
+
export { CmsServer, Transaction, pgTables, published, sqliteSchema, sqliteTables };
|
|
713
|
+
|
|
714
|
+
//# sourceMappingURL=index.mjs.map
|