UNPKG

alinea

Version:

[![npm](https://img.shields.io/npm/v/alinea.svg)](https://npmjs.org/package/alinea) [![install size](https://packagephobia.com/badge?p=alinea)](https://packagephobia.com/result?p=alinea)

663 lines (661 loc) 21.3 kB
import { e } from "../chunks/chunk-YHFRTBNP.js"; import { alias, create, exists } from "../chunks/chunk-TY7XOXM3.js"; import { Expr } from "../chunks/chunk-4JLFL6LD.js"; import "../chunks/chunk-U5RRZUYZ.js"; // src/backend/Database.ts import { JsonLoader, Media } from "alinea/backend"; import { PageSeed, Root, Schema, Workspace, createId, unreachable } from "alinea/core"; import { entryInfo, entryUrl } from "alinea/core/EntryFilenames"; import { EntryRecord, META_KEY, createRecord } from "alinea/core/EntryRecord"; import { MutationType } from "alinea/core/Mutation"; import { createEntryRow, publishEntryRow } from "alinea/core/util/EntryRows"; import { Logger } from "alinea/core/util/Logger"; import { entries } from "alinea/core/util/Objects"; import * as paths from "alinea/core/util/Paths"; import { EntryPhase, EntryRow } from "../core/EntryRow.js"; import { ChangeSetCreator } from "./data/ChangeSet.js"; import { AlineaMeta } from "./db/AlineaMeta.js"; import { createEntrySearch } from "./db/CreateEntrySearch.js"; import { createFileHash, createRowHash } from "./util/ContentHash.js"; var Database = class _Database { constructor(config, store) { this.config = config; this.store = store; this.seed = this.seedData(); } seed; async syncRequired(contentHash) { const meta = await this.meta(); return meta.contentHash !== contentHash; } async sync(contentHashes) { return this.store.transaction(async (tx) => { const insert = await tx( EntryRow().where(EntryRow.rowHash.isNotIn(contentHashes)) ); const keep = new Set( await tx( EntryRow().where(EntryRow.rowHash.isIn(contentHashes)).select(EntryRow.rowHash) ) ); const remove = contentHashes.filter((hash) => !keep.has(hash)); return { insert, remove }; }); } async contentHashes() { return this.store(EntryRow().select(EntryRow.rowHash)); } // Syncs data with a remote database, returning the i18nIds of changed entries async syncWith(remote, force = false) { await this.init(); const meta = await this.meta(); const isRequired = force || await remote.syncRequired(meta.contentHash); if (!isRequired) return []; const { insert, remove } = await remote.sync(await this.contentHashes()); return this.store.transaction(async (tx) => { const removed = await tx( EntryRow().where(EntryRow.rowHash.isIn(remove)).select(EntryRow.i18nId) ); await tx(EntryRow().delete().where(EntryRow.rowHash.isIn(remove))); const changed = []; for (const entry of insert) { await tx(EntryRow().insertOne(entry)); changed.push(entry.i18nId); } await _Database.index(tx); await this.writeMeta(tx, meta.commitHash); return removed.concat(changed); }); } async applyMutations(mutations, commitHash) { const hash = commitHash ?? (await this.meta()).commitHash; return this.store.transaction(async (tx) => { const reHash = []; for (const mutation of mutations) { try { const updateRows = await this.applyMutation(tx, mutation); if (updateRows) reHash.push(updateRows); } catch (error) { console.error(error); console.warn( `> could not apply mutation ${JSON.stringify(mutation)}` ); } } await _Database.index(tx); const changed = (await Promise.all(reHash.map((updateRows) => updateRows()))).flat(); await this.writeMeta(tx, hash); return changed; }); } async applyPublish(tx, entry) { const next = publishEntryRow(this.config, entry); await tx( EntryRow({ entryId: entry.entryId, phase: entry.phase }).set({ phase: EntryPhase.Published, filePath: next.filePath, parentDir: next.parentDir, childrenDir: next.childrenDir, url: next.url }) ); return this.updateChildren(tx, entry, next); } async updateChildren(tx, previous, next) { const { childrenDir: dir } = previous; if (next.phase !== EntryPhase.Published || dir === next.childrenDir) return []; const children = await tx( EntryRow().where( EntryRow.parentDir.is(dir).or(EntryRow.childrenDir.like(dir + "/%")) ) ); for (const child of children) { const filePath = next.childrenDir + child.filePath.slice(dir.length); const childrenDir = next.childrenDir + child.childrenDir.slice(dir.length); const parentDir = next.childrenDir + child.parentDir.slice(dir.length); const parentPaths = parentDir.split("/").filter(Boolean); if (child.locale) parentPaths.shift(); const url = entryUrl(this.config.schema[child.type], { ...child, parentPaths }); await tx( EntryRow({ entryId: child.entryId, phase: child.phase }).set({ filePath, childrenDir, parentDir, url }) ); } return children; } async logEntries() { const entries2 = await this.store( EntryRow().orderBy(EntryRow.url.asc(), EntryRow.index.asc()) ); for (const entry of entries2) { console.log( entry.url.padEnd(35), entry.entryId.padEnd(12), entry.phase.padEnd(12), entry.title ); } } async applyMutation(tx, mutation) { switch (mutation.type) { case MutationType.Create: { const row = EntryRow({ entryId: mutation.entryId, phase: mutation.entry.phase }); const current = await tx(row.maybeFirst()); if (current) return; await tx(EntryRow().insert(mutation.entry)); return () => this.updateHash(tx, row); } case MutationType.Edit: { const { entryId, entry } = mutation; const row = EntryRow({ entryId, phase: entry.phase }); const current = await tx(row.maybeFirst()); await tx(row.delete(), EntryRow().insert(entry)); let children = []; if (entry.phase === EntryPhase.Published) { if (current) children = await this.updateChildren(tx, current, entry); } return () => { return this.updateHash(tx, row).then( (self) => this.updateHash( tx, EntryRow().where( EntryRow.entryId.isIn(children.map((e2) => e2.entryId)) ) ).then((children2) => self.concat(children2)) ); }; } case MutationType.Archive: { const archived = EntryRow({ entryId: mutation.entryId, phase: EntryPhase.Archived }); const row = EntryRow({ entryId: mutation.entryId, phase: EntryPhase.Published }); const published = await tx(row.maybeFirst()); if (!published) return; const filePath = published.filePath.slice(0, -5) + `.${EntryPhase.Archived}.json`; await tx( archived.delete(), row.set({ phase: EntryPhase.Archived, filePath }) ); return () => this.updateHash(tx, archived); } case MutationType.Publish: { const promoting = await tx( EntryRow({ entryId: mutation.entryId, phase: mutation.phase }).maybeFirst() ); if (!promoting) return; const row = EntryRow({ entryId: mutation.entryId, phase: EntryPhase.Published }); await tx(row.delete()); const children = await this.applyPublish(tx, promoting); return () => this.updateHash(tx, row).then((rows) => { return this.updateHash( tx, EntryRow().where( EntryRow.entryId.isIn(children.map((e2) => e2.entryId)) ) ).then((r) => rows.concat(r)); }); } case MutationType.FileRemove: if (mutation.replace) return; case MutationType.Remove: { const existing = await tx( EntryRow({ entryId: mutation.entryId }).maybeFirst() ); if (!existing) return; const phases = await tx(EntryRow({ entryId: mutation.entryId })); for (const phase of phases) { await tx( EntryRow().delete().where( EntryRow.parentDir.is(phase.childrenDir).or(EntryRow.childrenDir.like(phase.childrenDir + "/%")) ) ); } await tx(EntryRow({ entryId: mutation.entryId }).delete()); return async () => phases.map((e2) => e2.i18nId).concat(existing.i18nId); } case MutationType.Discard: { const existing = await tx( EntryRow({ entryId: mutation.entryId }).maybeFirst() ); if (!existing) return; await tx( EntryRow({ entryId: mutation.entryId, phase: EntryPhase.Draft }).delete() ); return async () => [existing.i18nId]; } case MutationType.Order: { const rows = EntryRow({ entryId: mutation.entryId }); await tx(rows.set({ index: mutation.index })); return () => this.updateHash(tx, rows); } case MutationType.Move: { const rows = EntryRow({ entryId: mutation.entryId }); await tx( rows.set({ index: mutation.index, parent: mutation.parent, workspace: mutation.workspace, root: mutation.root }) ); return () => this.updateHash(tx, rows); } case MutationType.Upload: { const row = EntryRow({ entryId: mutation.entryId, phase: EntryPhase.Published }); const existing = await tx(row.maybeFirst()); if (!existing) return; if (process.env.NODE_ENV !== "develoment") await tx( row.set({ data: { ...existing.data, location: mutation.url, [Media.ORIGINAL_LOCATION]: existing.data.location } }) ); return () => this.updateHash(tx, row); } default: throw unreachable(mutation); } } async updateHash(tx, selection) { const changed = []; const entries2 = await tx(selection); for (const entry of entries2) { const updated = await createEntryRow(this.config, entry); changed.push(updated.i18nId); await tx( EntryRow({ entryId: entry.entryId, phase: entry.phase }).set({ fileHash: updated.fileHash, rowHash: updated.rowHash }) ); } return changed; } async meta() { return await this.store(AlineaMeta().maybeFirst()) ?? { commitHash: "", contentHash: "", modifiedAt: 0 }; } static async index(tx) { const { Parent } = alias(EntryRow); const res = await tx( EntryRow().set({ parent: Parent({ childrenDir: EntryRow.parentDir }).select(Parent.entryId).maybeFirst(), active: EntryRealm.isActive, main: EntryRealm.isMain }) ); return res; } async writeMeta(tx, commitHash) { const { create32 } = await e(); let hash = create32(); const contentHashes = await tx( EntryRow().select(EntryRow.rowHash).orderBy(EntryRow.rowHash) ); for (const c of contentHashes) hash = hash.update(c); const contentHash = hash.digest().toString(16).padStart(8, "0"); const modifiedAt = await tx( EntryRow().select(EntryRow.modifiedAt).orderBy(EntryRow.modifiedAt.desc()).maybeFirst() ); await tx(AlineaMeta().delete()); await tx( AlineaMeta().insertOne({ commitHash, contentHash, modifiedAt: modifiedAt ?? 0 }) ); } inited = false; async init() { if (this.inited) return; this.inited = true; try { await this.store.transaction(async (tx) => { await tx(create(EntryRow, AlineaMeta)); await createEntrySearch(tx); }); await this.meta(); } catch (e2) { this.inited = false; throw e2; } } computeEntry(record, meta, seed) { const { [META_KEY]: alineaMeta, ...data } = record; const typeName = alineaMeta.type; const parentDir = paths.dirname(meta.filePath); const extension = paths.extname(meta.filePath); const fileName = paths.basename(meta.filePath, extension); const [entryPath, entryPhase] = entryInfo(fileName); const segments = parentDir.split("/").filter(Boolean); const root = Root.data(this.config.workspaces[meta.workspace][meta.root]); let locale = null; if (root.i18n) { locale = segments.shift(); if (!root.i18n.locales.includes(locale)) throw new Error(`invalid locale: "${locale}"`); } const type = this.config.schema[typeName]; if (!type) throw new Error(`invalid type: "${typeName}"`); if (seed && seed.type !== typeName) throw new Error( `Type mismatch between seed and file: "${seed.type}" !== "${typeName}"` ); const childrenDir = paths.join(parentDir, entryPath); if (!record[META_KEY].entryId) throw new Error(`missing id`); const urlMeta = { locale, parentPaths: segments, path: entryPath, phase: entryPhase }; const pathData = entryPath === "index" ? "" : entryPath; const seedData = seed ? PageSeed.data(seed.page).partial : {}; const entryData = { ...seedData, ...data, path: pathData }; const searchableText = ""; return { workspace: meta.workspace, root: meta.root, filePath: meta.filePath, seeded: Boolean(seed || alineaMeta.seeded || false), modifiedAt: Date.now(), // file.modifiedAt, active: false, main: false, entryId: alineaMeta.entryId, phase: entryPhase, type: alineaMeta.type, parentDir, childrenDir, parent: null, level: parentDir === "/" ? 0 : segments.length, index: alineaMeta.index, locale, i18nId: alineaMeta.i18nId ?? alineaMeta.entryId, path: entryPath, title: record.title ?? seedData?.title ?? "", url: entryUrl(type, urlMeta), data: entryData, searchableText }; } seedData() { const res = /* @__PURE__ */ new Map(); const typeNames = Schema.typeNames(this.config.schema); for (const [workspaceName, workspace] of entries(this.config.workspaces)) { for (const [rootName, root] of entries(workspace)) { const { i18n } = Root.data(root); const locales = i18n?.locales ?? [void 0]; for (const locale of locales) { const pages = entries(root); const target = locale ? `/${locale}` : "/"; while (pages.length > 0) { const [pagePath, page] = pages.shift(); if (!PageSeed.isPageSeed(page)) continue; const { type } = PageSeed.data(page); const filePath = paths.join(target, pagePath) + ".json"; const typeName = typeNames.get(type); if (!typeName) continue; res.set(filePath, { type: typeName, workspace: workspaceName, root: rootName, filePath, page }); const children = entries(page).map( ([childPath, child]) => [paths.join(pagePath, childPath), child] ); pages.push(...children); } } } } return res; } async fill(source, commitHash, target) { await this.init(); const typeNames = Schema.typeNames(this.config.schema); const publishSeed = []; await this.store.transaction(async (query) => { const seenVersions = []; const seenSeeds = /* @__PURE__ */ new Set(); const inserted = []; for await (const file of source.entries()) { const seed = this.seed.get(file.filePath); const fileHash = await createFileHash(file.contents); const exists2 = await query( EntryRow({ fileHash, filePath: file.filePath, workspace: file.workspace, root: file.root }).select(EntryRow.versionId).maybeFirst() ); if (seed) { seenSeeds.add(seed.filePath); } if (exists2) { seenVersions.push(exists2); continue; } try { const raw = JsonLoader.parse(this.config.schema, file.contents); const entry = this.computeEntry(EntryRecord(raw), file, seed); if (entry.seeded && entry.phase === EntryPhase.Published && !seed) throw new Error(`seed entry is missing from config`); await query( EntryRow({ entryId: entry.entryId, phase: entry.phase }).delete() ); const withHash = { ...entry, fileHash, rowHash: "" }; seenVersions.push( await query( EntryRow().insert(withHash).returning(EntryRow.versionId) ) ); inserted.push(`${entry.entryId}.${entry.phase}`); } catch (e2) { console.log(`> skipped ${file.filePath} \u2014 ${e2.message}`); } } const seedPaths = Array.from(this.seed.keys()); for (const seedPath of seedPaths) { if (seenSeeds.has(seedPath)) continue; const seed = this.seed.get(seedPath); const { type, partial } = PageSeed.data(seed.page); const typeName = typeNames.get(type); if (!typeName) continue; const entry = this.computeEntry( { title: partial.title ?? "", [META_KEY]: { entryId: createId(), type: typeName, index: "a0" } }, seed ); const record = createRecord(entry); const fileContents = JsonLoader.format(this.config.schema, record); const fileHash = await createFileHash(fileContents); const withHash = { ...entry, fileHash, rowHash: "" }; seenVersions.push( await query(EntryRow().insert(withHash).returning(EntryRow.versionId)) ); inserted.push(`${entry.entryId}.${entry.phase}`); publishSeed.push({ ...withHash, seeded: true, title: void 0, data: {} }); } if (seenVersions.length === 0) return; const { rowsAffected: removed } = await query( EntryRow().delete().where(EntryRow.versionId.isNotIn(seenVersions)) ); const noChanges = inserted.length === 0 && removed === 0; if (noChanges) return; await _Database.index(query); const entries2 = await query( EntryRow().where(EntryRow.versionId.isIn(inserted)) ); for (const entry of entries2) { const rowHash = await createRowHash(entry); await query( EntryRow({ entryId: entry.entryId, phase: entry.phase }).set({ rowHash }) ); } await this.writeMeta(query, commitHash); }); if (target && publishSeed.length > 0) { const changeSetCreator = new ChangeSetCreator(this.config); const mutations = publishSeed.map((seed) => { const workspace = this.config.workspaces[seed.workspace]; const file = paths.join( Workspace.data(workspace).source, seed.root, seed.filePath ); return { type: MutationType.Create, entryId: seed.entryId, file, entry: seed }; }); const changes = changeSetCreator.create(mutations); await target.mutate( { commitHash: "", mutations: changes }, { logger: new Logger("seed") } ); } } }; var EntryRealm; ((EntryRealm2) => { const { Alt } = alias(EntryRow); const isDraft = EntryRow.phase.is(EntryPhase.Draft); const isArchived = EntryRow.phase.is(EntryPhase.Archived); const isPublished = EntryRow.phase.is(EntryPhase.Published); const hasDraft = exists( Alt({ phase: EntryPhase.Draft, entryId: EntryRow.entryId }) ); const hasPublished = exists( Alt({ phase: EntryPhase.Published, entryId: EntryRow.entryId }) ); const hasArchived = exists( Alt({ phase: EntryPhase.Archived, entryId: EntryRow.entryId }) ); const isPublishedWithoutDraft = Expr.and(isPublished, hasDraft.not()); const isArchivedWithoutDraftOrPublished = Expr.and( isArchived, hasDraft.not(), hasPublished.not() ); EntryRealm2.isActive = Expr.or( isDraft, isPublishedWithoutDraft, isArchivedWithoutDraftOrPublished ); const isArchivedWithoutPublished = Expr.and(isArchived, hasPublished.not()); const isDraftWithoutPublishedOrArchived = Expr.and( isDraft, hasPublished.not(), hasArchived.not() ); EntryRealm2.isMain = Expr.or( isPublished, isArchivedWithoutPublished, isDraftWithoutPublishedOrArchived ); })(EntryRealm || (EntryRealm = {})); export { Database };