UNPKG

alinea

Version:
572 lines (570 loc) 20.5 kB
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 };