alinea
Version:
Headless git-based CMS
572 lines (570 loc) • 20.5 kB
JavaScript
import "../../chunks/chunk-NZLE2WMY.js";
// src/core/db/EntryTransaction.ts
import { Config } from "alinea/core/Config";
import { createRecord } from "alinea/core/EntryRecord";
import { createId } from "alinea/core/Id";
import { getRoot, getWorkspace } from "alinea/core/Internal";
import { Type } from "alinea/core/Type";
import { pathSuffix } from "alinea/core/util/EntryFilenames";
import {
generateKeyBetween,
generateNKeysBetween
} from "alinea/core/util/FractionalIndexing";
import { entries, fromEntries, keys } from "alinea/core/util/Objects";
import * as paths from "alinea/core/util/Paths";
import { slugify } from "alinea/core/util/Slugs";
import { unreachable } from "alinea/core/util/Types";
import { ShaMismatchError } from "../source/ShaMismatchError.js";
import { SourceTransaction } from "../source/Source.js";
import { assert } from "../util/Assert.js";
import { commitChanges } from "./CommitRequest.js";
var EntryTransaction = class {
#checks = [];
#messages = [];
#config;
#index;
#tx;
#fileChanges = [];
constructor(config, index, source, from) {
if (index.sha !== from.sha) throw new ShaMismatchError(index.sha, from.sha);
this.#config = config;
this.#index = index;
this.#tx = new SourceTransaction(source, from);
}
get empty() {
return this.#messages.length === 0;
}
create({
locale,
type,
data,
root,
workspace,
fromSeed,
parentId = null,
id = createId(),
insertOrder = "last",
status = "published",
overwrite = false
}) {
const config = this.#config;
const index = this.#index;
const existing = index.byId(id);
if (existing) {
parentId = existing.parentId;
if (!workspace) workspace = existing.workspace;
if (!root) root = existing.root;
assert(
existing.workspace === workspace,
`Cannot create entry with id ${id} in workspace ${workspace}, already exists in ${existing.workspace}`
);
assert(
existing.root === root,
`Cannot create entry with id ${id} in root ${root}, already exists in ${existing.root}`
);
}
const workspaces = keys(config.workspaces);
if (!workspace) workspace = workspaces[0];
assert(
workspace in config.workspaces,
`Workspace "${workspace}" not found in config`
);
const roots = keys(config.workspaces[workspace]);
if (!root) root = roots[0];
assert(
root in config.workspaces[workspace],
`Root "${root}" not found in workspace "${workspace}"`
);
const rootConfig = this.#config.workspaces[workspace][root];
assert(rootConfig, "Invalid root");
const i18n = getRoot(rootConfig).i18n;
if (i18n) assert(i18n.locales.includes(locale), "Invalid locale");
else assert(locale === null, "Invalid locale");
let parent;
if (parentId) {
parent = index.findFirst((entry) => {
return entry.id === parentId && entry.locale === locale && entry.main;
});
assert(parent, `Parent not found: ${parentId}`);
this.#checks.push([parent.filePath, parent.fileHash]);
}
const siblings = Array.from(
index.findMany((entry) => {
return entry.root === root && entry.workspace === workspace && entry.parentId === parentId;
})
);
assert(typeof data === "object", "Invalid data");
const title = data.title ?? data.path;
assert(typeof title === "string", "Missing title");
let path = slugify(typeof data.path === "string" ? data.path : title);
assert(path.length > 0, "Invalid path");
const existingPath = existing?.get(locale)?.path;
const hasSamePath = existingPath === path;
if (!hasSamePath)
path = this.#getAvailablePath({
id,
path,
parentId,
root,
workspace,
locale
});
if (status !== "published" && existingPath) path = existingPath;
if (existingPath && !hasSamePath && status === "published") {
this.#rename(existing.id, locale, path);
}
if (overwrite && existing?.type === "MediaFile") {
const prev = existing.get(null)?.main;
assert(prev, "Previous entry not found");
const prevLocation = prev.data.location;
if (prevLocation !== data.location)
this.removeFile({
location: paths.join(
getWorkspace(this.#config.workspaces[prev.workspace]).mediaDir,
prev.data.location
)
});
}
const parentDir = parent ? parent.childrenDir : Config.filePath(this.#config, workspace, root, locale);
const filePath = paths.join(
parentDir,
`${path}${status === "published" ? "" : `.${status}`}.json`
);
const hasSameVersion = existing?.get(locale)?.has(status);
const warnDuplicate = !overwrite && hasSameVersion;
assert(!warnDuplicate, `Cannot create duplicate entry with id ${id}`);
let newIndex;
if (existing) {
newIndex = existing.index;
if (status === "published") {
const versions = index.byId(id)?.get(locale);
if (versions)
for (const [status2, version] of versions) {
this.#tx.remove(version.filePath);
}
}
} else {
const previous = insertOrder === "first" ? null : siblings.at(-1) ?? null;
const next = insertOrder === "last" ? null : siblings.at(0) ?? null;
newIndex = generateKeyBetween(
previous?.index ?? null,
next?.index ?? null
);
}
if (locale !== null && status === "published") {
const from = index.findFirst((entry) => {
return entry.id === id && entry.locale !== locale && entry.status === "published";
});
if (from) {
const typeInstance = this.#config.schema[type];
assert(typeInstance, `Type not found: ${type}`);
const shared = Type.sharedData(typeInstance, from.data);
data = { ...shared, ...data };
}
this.#persistSharedFields(id, locale, type, data);
}
const seeds = existing?.get(locale);
const seeded = fromSeed ?? seeds?.seeded ?? null;
const record = createRecord(
{ id, type, index: newIndex, path, seeded, data, title },
status
);
const contents = new TextEncoder().encode(JSON.stringify(record, null, 2));
this.#tx.add(filePath, contents);
this.#messages.push(this.#reportOp("create", title));
return this;
}
#rename(entryId, locale, path) {
const index = this.#index;
const versions = index.findMany((entry) => {
return entry.id === entryId && entry.locale === locale;
});
for (const version of versions) {
const name = version.status === "published" ? path : `${path}.${version.status}`;
const filePath = paths.join(version.parentDir, `${name}.json`);
this.#tx.rename(version.filePath, filePath);
const childrenDir = paths.join(version.parentDir, path);
this.#tx.rename(version.childrenDir, childrenDir);
}
}
update({ id, locale, status, set }) {
const index = this.#index;
const entry = index.findFirst((entry2) => {
return entry2.id === id && entry2.locale === locale && entry2.status === status;
});
assert(entry, `Entry not found: ${id}`);
const fieldUpdates = fromEntries(
entries(set).map(([key, value]) => {
return [key, value ?? null];
})
);
const data = { ...entry.data, ...fieldUpdates };
if (locale !== null && status === "published") {
this.#persistSharedFields(id, locale, entry.type, data);
}
const record = createRecord(
{
id,
type: entry.type,
index: entry.index,
path: entry.path,
seeded: entry.seeded,
data
},
status
);
const desiredPath = slugify(
data.path ?? entry.data.path ?? entry.path
);
const lockPath = entry.status !== "published" && !entry.main;
const path = lockPath ? entry.path : this.#getAvailablePath({
id,
path: desiredPath,
parentId: entry.parentId,
root: entry.root,
workspace: entry.workspace,
locale
});
this.#checks.push([entry.filePath, entry.fileHash]);
const childrenDir = paths.join(entry.parentDir, path);
const filePath = `${childrenDir}${entry.status === "published" ? "" : `.${entry.status}`}.json`;
if (entry.status === "published") {
if (filePath !== entry.filePath) this.#rename(id, locale, path);
} else {
if (path !== entry.path) record.path = path;
}
const contents = new TextEncoder().encode(JSON.stringify(record, null, 2));
this.#tx.add(filePath, contents);
this.#messages.push(this.#reportOp("update", entry.title));
return this;
}
#getAvailablePath(target) {
const conflictingPaths = Array.from(
this.#index.findMany((entry) => {
return entry.id !== target.id && entry.parentId === target.parentId && entry.workspace === target.workspace && entry.root === target.root && entry.locale === target.locale && (entry.path === target.path || entry.path.startsWith(`${target.path}-`));
})
).map((entry) => entry.path);
const suffix = pathSuffix(target.path, conflictingPaths);
if (suffix !== void 0) return `${target.path}-${suffix}`;
return target.path;
}
#persistSharedFields(id, locale, type, data) {
const index = this.#index;
const typeInstance = this.#config.schema[type];
assert(type, `Type not found: ${type}`);
const shared = Type.sharedData(typeInstance, data);
if (shared) {
const translations = index.findMany((entry) => {
return entry.id === id && entry.locale !== locale;
});
for (const translation of translations) {
this.#checks.push([translation.filePath, translation.fileHash]);
const record = createRecord(
{
id,
type: translation.type,
index: translation.index,
path: translation.path,
seeded: translation.seeded,
data: {
...translation.data,
...shared
}
},
translation.status
);
const contents = new TextEncoder().encode(
JSON.stringify(record, null, 2)
);
this.#tx.add(translation.filePath, contents);
}
}
}
publish({ id, locale, status }) {
const index = this.#index;
const entry = index.findFirst((entry2) => {
return entry2.id === id && entry2.locale === locale && entry2.status === status;
});
assert(entry, `Entry not found: ${id}`);
const pathChange = entry.data.path && entry.data.path !== entry.path;
let path = slugify(entry.data.path ?? entry.path);
path = this.#getAvailablePath({
id,
path,
parentId: entry.parentId,
root: entry.root,
workspace: entry.workspace,
locale
});
const childrenDir = paths.join(entry.parentDir, path);
if (entry.locale !== null)
this.#persistSharedFields(id, entry.locale, entry.type, entry.data);
const versions = index.byId(id)?.get(locale);
if (versions)
for (const [_, version] of versions) {
this.#tx.remove(version.filePath);
}
this.#checks.push([entry.filePath, entry.fileHash]);
this.#tx.remove(entry.filePath);
const record = createRecord({ ...entry, path }, "published");
const contents = new TextEncoder().encode(JSON.stringify(record, null, 2));
if (pathChange) {
this.#tx.remove(`${entry.parentDir}/${entry.path}.json`);
this.#tx.rename(entry.childrenDir, childrenDir);
}
this.#tx.add(`${childrenDir}.json`, contents);
this.#messages.push(this.#reportOp("publish", entry.title));
return this;
}
unpublish({ id, locale }) {
const index = this.#index;
const versions = index.byId(id)?.get(locale);
const mainEntry = versions?.main;
assert(mainEntry, `Entry not found: ${id}`);
for (const [_, version] of versions) {
if (version === mainEntry) continue;
this.#tx.remove(version.filePath);
}
this.#checks.push([mainEntry.filePath, mainEntry.fileHash]);
this.#tx.rename(mainEntry.filePath, `${mainEntry.childrenDir}.draft.json`);
this.#messages.push(this.#reportOp("unpublish", mainEntry.title));
return this;
}
archive({ id, locale }) {
const index = this.#index;
const versions = index.byId(id)?.get(locale);
const mainEntry = versions?.main;
assert(mainEntry, `Entry not found: ${id}`);
for (const [_, version] of versions) {
if (version === mainEntry) continue;
this.#tx.remove(version.filePath);
}
this.#checks.push([mainEntry.filePath, mainEntry.fileHash]);
this.#tx.rename(
mainEntry.filePath,
`${mainEntry.childrenDir}.archived.json`
);
this.#messages.push(this.#reportOp("archive", mainEntry.title));
return this;
}
move({ id, after, toParent, toRoot }) {
const index = this.#index;
const entries2 = Array.from(index.findMany((entry) => entry.id === id));
assert(entries2.length > 0, `Entry not found: ${id}`);
const parentId = toRoot ? null : toParent ?? entries2[0].parentId;
const root = toRoot ?? entries2[0].root;
const workspace = entries2[0].workspace;
const siblings = new Map(
Array.from(
index.findMany((entry) => {
return entry.workspace === workspace && entry.root === root && entry.parentId === parentId && entry.id !== id;
})
).map((entry) => [entry.id, entry])
);
if (after) assert(siblings.has(after), `Sibling not found: ${after}`);
const siblingList = Array.from(siblings.values());
const previousIndex = after ? siblingList.findIndex((entry) => entry.id === after) : -1;
const nextIndex = previousIndex + 1;
const previous = siblingList[previousIndex] ?? null;
const next = siblingList[nextIndex] ?? null;
const seen = /* @__PURE__ */ new Set();
const hasDuplicates = siblingList.some((entry) => {
const wasSeen = seen.has(entry.index);
if (wasSeen) return true;
seen.add(entry.index);
return false;
});
let newIndex;
if (hasDuplicates) {
const self = index.findFirst((entry) => entry.id === id);
assert(self, `Entry not found: ${id}`);
siblingList.splice(previousIndex + 1, 0, self);
const newKeys = generateNKeysBetween(null, null, siblingList.length);
for (const [i, key] of newKeys.entries()) {
const id2 = siblingList[i].id;
const node = index.byId(id2);
assert(node);
for (const locale of node.keys()) {
for (const [_, version] of node.get(locale)) {
const record = createRecord(
{
id: id2,
type: version.type,
index: key,
path: version.path,
seeded: version.seeded,
data: version.data
},
version.status
);
const contents = new TextEncoder().encode(
JSON.stringify(record, null, 2)
);
this.#tx.add(version.filePath, contents);
}
}
}
newIndex = newKeys[previousIndex + 1];
} else {
newIndex = generateKeyBetween(
previous?.index ?? null,
next?.index ?? null
);
}
let info;
for (const entry of entries2) {
info = entry;
const parent = parentId ? index.findFirst((e) => {
return e.id === parentId && e.locale === entry.locale;
}) : void 0;
if (toParent) {
assert(!entry.seeded, `Cannot move seeded entry ${entry.filePath}`);
assert(parent, `Parent not found: ${parentId}`);
assert(
!parent?.childrenDir.startsWith(entry.childrenDir),
"Cannot move entry into its own children"
);
const parentType = this.#config.schema[parent.type];
const childType = this.#config.schema[entry.type];
const allowed = Config.typeContains(this.#config, parentType, childType);
assert(
allowed,
`Parent of type ${parent.type} does not allow children of type ${entry.type}`
);
}
const parentDir = parent ? parent.childrenDir : Config.filePath(this.#config, workspace, root, entry.locale);
const childrenDir = paths.join(parentDir, entry.path);
const filePath = `${childrenDir}${entry.status === "published" ? "" : `.${entry.status}`}.json`;
const record = createRecord(
{
id,
type: entry.type,
index: newIndex,
path: entry.path,
seeded: entry.seeded,
data: entry.data
},
entry.status
);
const contents = new TextEncoder().encode(JSON.stringify(record, null, 2));
if (toParent || toRoot) {
this.#tx.remove(entry.filePath);
this.#tx.rename(entry.childrenDir, childrenDir);
}
this.#tx.add(filePath, contents);
}
this.#messages.push(this.#reportOp("move", info.title));
return this;
}
remove({ id, locale, status }) {
const index = this.#index;
const entries2 = index.findMany((entry) => {
const matchesStatus = status === void 0 || entry.status === status;
const matchesLocale = locale === void 0 || entry.locale === locale;
return entry.id === id && matchesLocale && matchesStatus;
});
let info;
for (const entry of entries2) {
if (entry.status === "published")
assert(!entry.seeded, `Cannot remove seeded entry ${entry.filePath}`);
info = entry;
this.#checks.push([entry.filePath, entry.fileHash]);
this.#tx.remove(entry.filePath);
if (entry.status !== "draft") {
this.#tx.remove(entry.childrenDir);
}
if (entry.type === "MediaLibrary") {
const workspace = this.#config.workspaces[entry.workspace];
const mediaDir = getWorkspace(workspace).mediaDir;
const files = index.findMany((f) => {
return f.workspace === entry.workspace && f.root === entry.root && f.filePath.startsWith(entry.childrenDir) && f.type === "MediaFile";
});
for (const file of files) {
this.removeFile({
location: paths.join(mediaDir, file.data.location)
});
}
}
if (entry.type === "MediaFile") {
const workspace = this.#config.workspaces[entry.workspace];
const mediaDir = getWorkspace(workspace).mediaDir;
this.removeFile({
location: paths.join(
mediaDir,
entry.data.location
)
});
}
}
if (info) this.#messages.unshift(this.#reportOp("remove", info.title));
return this;
}
removeFile(mutation) {
assert(mutation.location, "Missing location");
this.#messages.push(this.#reportOp("remove", mutation.location));
this.#fileChanges.push({ op: "removeFile", ...mutation });
return this;
}
uploadFile(mutation) {
this.#fileChanges.push({ op: "uploadFile", ...mutation });
return this;
}
description() {
return this.#messages.map((message, index, all) => {
if (index > 0) return message;
const suffix = all.length > 1 ? ` (and ${all.length - 1} other edits)` : "";
return `${message + suffix}`;
}).join("\n");
}
#reportOp(op, title) {
return `(${op}) ${title}`;
}
apply(mutations) {
for (const mutation of mutations) {
switch (mutation.op) {
case "create":
this.create(mutation);
break;
case "update":
this.update(mutation);
break;
case "publish":
this.publish(mutation);
break;
case "unpublish":
this.unpublish(mutation);
break;
case "archive":
this.archive(mutation);
break;
case "move":
this.move(mutation);
break;
case "remove":
this.remove(mutation);
break;
case "removeFile":
this.removeFile(mutation);
break;
case "uploadFile":
this.uploadFile(mutation);
break;
default:
unreachable(mutation);
}
}
}
async toRequest() {
const { from, into, changes } = await this.#tx.compile();
return {
fromSha: from.sha,
intoSha: into.sha,
description: this.description(),
checks: this.#checks,
changes: this.#fileChanges.concat(commitChanges(changes))
};
}
};
export {
EntryTransaction
};