@paroicms/server
Version:
The ParoiCMS server
662 lines • 27 kB
JavaScript
import { generateDatabaseId, getMetadataValue, setMetadataDbSchemaVersion, setMetadataValue, } from "@paroicms/internal-server-lib";
import { type } from "arktype";
import { mainDbSchemaName, mainDbSchemaVersion } from "./db-constants.js";
export async function migrateMainDb(cn, { fromVersion, logger }, migrationValues) {
const toVersion = mainDbSchemaVersion;
let currentVersion = fromVersion;
if (currentVersion === 6) {
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 7 });
currentVersion = 7;
}
if (currentVersion === 7) {
await cn.raw(`create table PaOrderedLeaf (
leafId integer not null primary key references PaLeaf (id) on delete cascade,
orderNum integer not null
)`);
await cn.raw(`create table PaPartLeaf (
leafId integer not null primary key references PaLeaf (id) on delete cascade,
listName varchar(100) not null,
documentLeafId integer not null references PaLeaf (id)
)`);
await cn.raw(`insert into PaOrderedLeaf (leafId, orderNum)
select leafId, itemNum from PaLeafItem`);
await cn.raw(`insert into PaPartLeaf (leafId, listName, documentLeafId)
select i.leafId, i.listName, l.parentId
from PaLeafItem i
inner join PaLeaf l on l.id = i.leafId`);
await cn.raw("drop table PaLeafItem");
await cn.raw("alter table PaTermFlag rename column flaggedId to labeledId");
await cn.raw("alter table PaTermFlag rename to PaLabeling");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 8 });
currentVersion = 8;
}
if (currentVersion === 8) {
await cn.raw("alter table PaLeaf rename column leafType to typeName");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 9 });
currentVersion = 9;
}
if (currentVersion === 9) {
await cn.raw("alter table PaLeaf rename to PaNode");
await cn.raw("drop index if exists PaLeaf_typeName_idx");
await cn.raw("drop index if exists PaLeaf_leafType_idx");
await cn.raw("create index PaNode_typeName_idx on PaNode (typeName)");
await cn.raw("alter table PaOrderedLeaf rename to PaOrderedNode");
await cn.raw("alter table PaOrderedNode rename column leafId to nodeId");
await cn.raw("alter table PaPartLeaf rename to PaPartNode");
await cn.raw("alter table PaPartNode rename column leafId to nodeId");
await cn.raw("alter table PaPartNode rename column documentLeafId to documentNodeId");
await cn.raw("drop index if exists PaPartLeaf_listName_idx");
await cn.raw("create index PaPartNode_listName_idx on PaPartNode (listName)");
await cn.raw("alter table PaSection rename to PaNodel");
await cn.raw("alter table PaNodel rename column leafId to nodeId");
await cn.raw("alter table PaNodel rename column lang to language");
await cn.raw("alter table PaDocument rename column leafId to nodeId");
await cn.raw("alter table PaDocument rename column lang to language");
await cn.raw("alter table PaFieldVarchar rename column leafId to nodeId");
await cn.raw("alter table PaFieldVarchar rename column lang to language");
await cn.raw("alter table PaFieldText rename column leafId to nodeId");
await cn.raw("alter table PaFieldText rename column lang to language");
await cn.raw("update PaPartNode set listName = '_subParts' where listName = 'subParts'");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 10 });
currentVersion = 10;
}
if (currentVersion === 10) {
await cn.raw(`create table PaFieldLabeling (
field varchar(100) not null,
nodeId integer not null references PaNode (id) on delete cascade,
termId integer not null references PaNode (id),
orderNum integer,
primary key (field, nodeId, termId)
)`);
await cn.raw(`insert into PaFieldLabeling (field, nodeId, termId)
select tax.typeName, PaLabeling.labeledId, PaLabeling.termId
from PaLabeling
join PaNode term on term.id = PaLabeling.termId
join PaNode tax on tax.id = term.parentId`);
await cn.raw("drop table PaLabeling");
await cn.raw(`update PaFieldText set dataType = 'json' where dataType = 'quillDelta'`);
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 11 });
currentVersion = 11;
}
if (currentVersion === 11) {
await migrateAllQuillDeltaFieldsFrom11to12(cn, logger);
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 12 });
currentVersion = 12;
}
if (currentVersion === 12) {
await cn.raw("alter table PaNodel rename to PaLNode");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 13 });
currentVersion = 13;
}
if (currentVersion === 13) {
await cn.raw("update PaFieldVarchar set field = 'buttonLabel' where field = 'shortTitle'");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 14 });
currentVersion = 14;
}
if (currentVersion === 14) {
await cn.raw("ALTER TABLE PaNode DROP COLUMN depth");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 15 });
currentVersion = 15;
}
if (currentVersion === 15) {
for (const table of ["PaFieldVarchar", "PaFieldText", "PaFieldLabeling"]) {
await cn.raw(`ALTER TABLE ${table} ADD COLUMN plugin varchar(100)`);
}
const indexes = [
"create index if not exists PaFieldVarchar_plugin_idx on PaFieldVarchar (plugin)",
"create index if not exists PaFieldText_plugin_idx on PaFieldText (plugin)",
"create index if not exists PaFieldLabeling_plugin_idx on PaFieldLabeling (plugin)",
];
for (const indexSql of indexes) {
await cn.raw(indexSql);
}
const PLUGIN_FIELDS = {
leadParagraph: "@paroicms/quill-editor-plugin",
htmlContent: "@paroicms/quill-editor-plugin",
introduction: "@paroicms/quill-editor-plugin",
footerMention: "@paroicms/quill-editor-plugin",
video: "@paroicms/platform-video-plugin",
oneLanguageVideo: "@paroicms/platform-video-plugin",
phones: "@paroicms/list-field-plugin",
featuredDocument: "@paroicms/internal-link-plugin",
};
for (const [fieldName, pluginName] of Object.entries(PLUGIN_FIELDS)) {
for (const table of ["PaFieldVarchar", "PaFieldText", "PaFieldLabeling"]) {
await cn(table)
.update({ plugin: pluginName })
.where({ field: fieldName })
.whereNull("plugin");
}
}
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 16 });
currentVersion = 16;
}
if (currentVersion === 16) {
const existingDatabaseId = await getMetadataValue(cn, {
dbSchemaName: mainDbSchemaName,
key: "databaseId",
});
if (existingDatabaseId !== undefined) {
throw new Error(`[${mainDbSchemaName}] databaseId metadata already exists`);
}
const databaseId = generateDatabaseId();
await setMetadataValue(cn, {
dbSchemaName: mainDbSchemaName,
key: "databaseId",
value: databaseId,
});
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 17 });
currentVersion = 17;
}
if (currentVersion === 17) {
const PLUGIN_FIELDS = {
leadParagraph: "@paroicms/quill-editor-plugin",
htmlContent: "@paroicms/quill-editor-plugin",
introduction: "@paroicms/quill-editor-plugin",
footerMention: "@paroicms/quill-editor-plugin",
video: "@paroicms/platform-video-plugin",
oneLanguageVideo: "@paroicms/platform-video-plugin",
phones: "@paroicms/list-field-plugin",
featuredDocument: "@paroicms/internal-link-plugin",
};
for (const [fieldName, pluginName] of Object.entries(PLUGIN_FIELDS)) {
for (const table of ["PaFieldVarchar", "PaFieldText", "PaFieldLabeling"]) {
await cn(table)
.update({ plugin: pluginName })
.where({ field: fieldName })
.whereNull("plugin");
}
}
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 18 });
currentVersion = 18;
}
if (currentVersion === 18) {
await migrateAllQuillDeltaFieldsFrom18to19(cn, logger);
await migrateVideoFieldsFrom18to19(cn, logger);
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 19 });
currentVersion = 19;
}
if (currentVersion === 19) {
await migrateAllTiptapLinksFrom19to20(cn, logger, migrationValues);
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 20 });
currentVersion = 20;
}
if (currentVersion === 20) {
await cn.raw(`create table PaAccountRole (
accountId integer not null references PaAccount (id) on delete cascade,
role varchar(100) not null,
primary key (accountId, role)
)`);
await cn.raw(`create table PaAuthorAccount (
authorNodeId integer not null references PaNode (id) on delete cascade,
accountId integer not null references PaAccount (id) on delete cascade,
primary key (authorNodeId, accountId)
)`);
await cn.raw(`create table PaEventLog (
id integer not null primary key autoincrement,
eventType varchar(100) not null,
actorId integer references PaAccount (id),
targetType varchar(50) not null,
targetId varchar(100) not null,
eventData text,
createdAt timestamp not null default current_timestamp
)`);
await cn.raw("create index PaEventLog_eventType_idx on PaEventLog (eventType)");
await cn.raw("create index PaEventLog_actorId_idx on PaEventLog (actorId)");
await cn.raw("create index PaEventLog_targetType_targetId_idx on PaEventLog (targetType, targetId)");
await cn.raw(`insert into PaAccountRole (accountId, role)
select id, 'admin' from PaAccount`);
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 21 });
currentVersion = 21;
}
if (currentVersion === 21) {
await cn.raw("alter table PaAccount add column loginMethod varchar(50)");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 22 });
currentVersion = 22;
}
if (currentVersion === 22) {
await cn.raw("alter table PaAccount add column active tinyint(1) not null default 1");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 23 });
currentVersion = 23;
}
if (currentVersion === 23) {
await cn.raw("create index PaEventLog_createdAt_idx on PaEventLog (createdAt)");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 24 });
currentVersion = 24;
}
if (currentVersion === 24) {
await cn.raw("update PaAccount set email = trim(lower(email))");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 25 });
currentVersion = 25;
}
if (currentVersion === 25) {
await cn.raw("create index PaNode_parentId_idx on PaNode (parentId)");
await cn.raw("create index PaNode_publishDate_idx on PaNode (publishDate)");
await cn.raw("create index PaPartNode_documentNodeId_idx on PaPartNode (documentNodeId)");
await cn.raw("create index PaFieldLabeling_nodeId_idx on PaFieldLabeling (nodeId)");
await cn.raw("create index PaFieldLabeling_termId_idx on PaFieldLabeling (termId)");
await cn.raw("create index PaFieldVarchar_nodeId_idx on PaFieldVarchar (nodeId)");
await cn.raw("create index PaFieldText_nodeId_idx on PaFieldText (nodeId)");
await cn.raw("create index PaAuthorAccount_accountId_idx on PaAuthorAccount (accountId)");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 26 });
currentVersion = 26;
}
if (currentVersion === 26) {
await cn.raw(`create table PaPersonalAccessToken (
id integer not null primary key autoincrement,
accountId integer not null references PaAccount (id) on delete cascade,
tokenHash varchar(64) not null unique,
tokenPreview varchar(20) not null,
description varchar(200),
createdAt timestamp not null default current_timestamp,
lastUsedAt timestamp,
expiresAt timestamp,
active tinyint(1) not null default 1
)`);
await cn.raw("create index PaPersonalAccessToken_accountId_idx on PaPersonalAccessToken (accountId)");
await cn.raw("create index PaPersonalAccessToken_tokenHash_idx on PaPersonalAccessToken (tokenHash)");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 27 });
currentVersion = 27;
}
if (currentVersion === 27) {
await cn.raw("alter table PaLNode add column updatedAt timestamp");
await cn.raw(`
update PaLNode
set updatedAt = coalesce(
(select n.publishDate from PaNode n where n.id = PaLNode.nodeId),
current_timestamp
)
`);
await cn.raw(`create table PaHistoryEntry (
id varchar(36) not null primary key,
nodeId integer not null references PaNode (id) on delete cascade,
language varchar(6) not null,
fieldValues text not null,
documentValues text,
changeSummary text,
updatedAt timestamp not null,
expireAt timestamp,
timeTier varchar(10) not null
)`);
await cn.raw("create index PaHistoryEntry_nodeId_language_timeTier_idx on PaHistoryEntry (nodeId, language, timeTier, id desc)");
await cn.raw("create index PaHistoryEntry_expireAt_idx on PaHistoryEntry (expireAt)");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 28 });
currentVersion = 28;
}
if (currentVersion === 28) {
await cn.raw("alter table PaDocument add column overrideLanguage varchar(6)");
await setMetadataDbSchemaVersion(cn, { dbSchemaName: mainDbSchemaName, value: 29 });
currentVersion = 29;
}
if (currentVersion !== toVersion) {
throw new Error(`version of ${mainDbSchemaName} database should be '${toVersion}', but is '${currentVersion}'`);
}
logger.info(`${mainDbSchemaName} database was migrated from ${fromVersion} to ${currentVersion}`);
}
async function migrateAllQuillDeltaFieldsFrom11to12(cn, logger) {
let count = 0;
const rows = await cn("PaFieldText")
.select("field", "nodeId", "language", "val")
.where("dataType", "json")
.whereNotNull("val");
const RowAT = type({
field: "string",
nodeId: "number",
language: "string",
val: "string",
"+": "reject",
}).pipe((r) => ({
...r,
nodeId: String(r.nodeId),
}));
for (const row of rows) {
let fieldValue;
try {
const validatedRow = RowAT.assert(row);
fieldValue = {
...validatedRow,
val: JSON.parse(validatedRow.val),
};
}
catch (error) {
logger.error(`Error parsing JSON for field "${row.field}" on nodeId ${row.nodeId} in language "${row.language}"`, error);
continue;
}
const newVal = migrateQuillDeltaFrom11to12(fieldValue.val, logger);
if (newVal) {
await cn("PaFieldText")
.update({ val: JSON.stringify(newVal) })
.where({
field: fieldValue.field,
nodeId: fieldValue.nodeId,
language: fieldValue.language,
});
++count;
}
}
if (count > 0) {
logger.info(`Migrated ${count} quill delta field(s) in ${mainDbSchemaName} database`);
}
}
function migrateQuillDeltaFrom11to12(delta, logger) {
if (!delta?.ops || !Array.isArray(delta.ops))
return;
const { ops } = delta;
let modified = false;
const newOps = ops
.map((op) => {
if (!op.insert || typeof op.insert !== "object")
return op;
if (Object.keys(op.insert).length !== 1)
return op;
if (!op.insert.img || typeof op.insert.img !== "object")
return op;
const { uid, align, variant, zoom, href } = op.insert.img;
if (!uid || !align || !variant) {
logger.warn(`Quill Delta migration, remove incomplete image: ${JSON.stringify(op.insert)}`);
modified = true;
return undefined;
}
const { insert, ...opRest } = op;
modified = true;
return {
insert: {
media: {
mediaId: uid,
resizeRule: variant,
align,
zoomable: zoom === "none" ? undefined : true,
href: href,
},
},
...opRest,
};
})
.filter(Boolean);
if (modified) {
return { ops: newOps };
}
}
async function migrateAllQuillDeltaFieldsFrom18to19(cn, logger) {
const rows = await cn("PaFieldText")
.select("field", "nodeId", "language", "val")
.where("plugin", "@paroicms/quill-editor-plugin");
if (rows.length === 0) {
return;
}
logger.info(`Found ${rows.length} quill delta field(s) in ${mainDbSchemaName} database`);
let count = 0;
const RowAT = type({
field: "string",
nodeId: "number",
language: "string",
val: "string",
"+": "reject",
}).pipe((r) => ({
...r,
nodeId: String(r.nodeId),
}));
for (const row of rows) {
let fieldValue;
try {
const validatedRow = RowAT.assert(row);
fieldValue = {
...validatedRow,
val: JSON.parse(validatedRow.val),
};
}
catch (error) {
logger.error(`Error parsing JSON for field "${row.field}" on nodeId ${row.nodeId} in language "${row.language}"`, error);
continue;
}
const newVal = migrateQuillDeltaFrom18to19(fieldValue.val, logger);
if (newVal) {
await cn("PaFieldText")
.update({ val: JSON.stringify(newVal) })
.where({
field: fieldValue.field,
nodeId: fieldValue.nodeId,
language: fieldValue.language,
});
++count;
}
}
if (count > 0) {
logger.info(`Migrated ${count} quill delta field(s) in ${mainDbSchemaName} database`);
}
}
function migrateQuillDeltaFrom18to19(delta, logger) {
if (!delta?.ops || !Array.isArray(delta.ops))
return;
const { ops } = delta;
let modified = false;
const newOps = ops.map((op) => {
let newOp = { ...op };
if (op.insert && typeof op.insert === "object") {
if (op.insert["video-plugin"]) {
const videoId = op.insert["video-plugin"];
if (typeof videoId !== "string") {
logger.warn(`Quill Delta migration, invalid video-plugin value: ${JSON.stringify(op.insert)}`);
return op;
}
const { "video-plugin": _, ...restInsert } = op.insert;
modified = true;
newOp = {
...op,
insert: {
"platform-video": {
videoId: videoId,
platform: "youtube",
},
...restInsert,
},
};
}
}
if (op.attributes?.["internal-link-plugin"]) {
const { "internal-link-plugin": linkValue, ...restAttributes } = op.attributes;
modified = true;
newOp = {
...newOp,
attributes: {
"internal-link": linkValue,
...restAttributes,
},
};
}
return newOp;
});
if (modified) {
return { ops: newOps };
}
}
async function migrateVideoFieldsFrom18to19(cn, logger) {
await cn("PaFieldVarchar")
.where("plugin", "@paroicms/video-plugin")
.update({ plugin: "@paroicms/platform-video-plugin" });
const rows = await cn("PaFieldVarchar")
.select("field", "nodeId", "language", "val")
.where("plugin", "@paroicms/platform-video-plugin");
if (rows.length === 0) {
return;
}
logger.info(`Found ${rows.length} video field(s) in ${mainDbSchemaName} database`);
let count = 0;
const RowAT = type({
field: "string",
nodeId: "number",
language: "string",
val: "string|null",
"+": "reject",
}).pipe((r) => ({
...r,
nodeId: String(r.nodeId),
}));
for (const row of rows) {
let fieldValue;
try {
const validatedRow = RowAT.assert(row);
fieldValue = {
...validatedRow,
val: validatedRow.val ?? undefined,
};
}
catch (error) {
logger.error(`Error validating video field "${row.field}" on nodeId ${row.nodeId} in language "${row.language}"`, error);
continue;
}
if (!fieldValue.val) {
await cn("PaFieldVarchar").update({ dataType: "json" }).where({
field: fieldValue.field,
nodeId: fieldValue.nodeId,
language: fieldValue.language,
});
++count;
continue;
}
const newVal = JSON.stringify({
videoId: fieldValue.val,
platform: "youtube",
});
await cn("PaFieldVarchar")
.update({
val: newVal,
dataType: "json",
})
.where({
field: fieldValue.field,
nodeId: fieldValue.nodeId,
language: fieldValue.language,
});
++count;
}
if (count > 0) {
logger.info(`Migrated ${count} video field(s) in ${mainDbSchemaName} database`);
}
}
async function migrateAllTiptapLinksFrom19to20(cn, logger, migrationValues) {
const rows = await cn("PaFieldText")
.select("field", "nodeId", "language", "val")
.where("plugin", "@paroicms/tiptap-editor-plugin")
.whereNotNull("val");
if (rows.length === 0) {
return;
}
let count = 0;
const RowAT = type({
field: "string",
nodeId: "number",
language: "string",
val: "string",
"+": "reject",
}).pipe((r) => ({
...r,
nodeId: String(r.nodeId),
}));
for (const row of rows) {
let fieldValue;
try {
const validatedRow = RowAT.assert(row);
fieldValue = {
...validatedRow,
val: JSON.parse(validatedRow.val),
};
}
catch (error) {
logger.error(`Error parsing JSON for field "${row.field}" on nodeId ${row.nodeId} in language "${row.language}"`, error);
continue;
}
const newVal = migrateTiptapLinksFrom19to20(fieldValue.val, migrationValues.fqdn);
if (newVal) {
await cn("PaFieldText")
.update({ val: JSON.stringify(newVal) })
.where({
field: fieldValue.field,
nodeId: fieldValue.nodeId,
language: fieldValue.language,
});
++count;
}
}
logger.info(`Migrated ${count} tiptap field(s) in ${mainDbSchemaName} database`);
}
function migrateTiptapLinksFrom19to20(json, fqdn) {
if (!json?.content || !Array.isArray(json.content))
return;
let modified = false;
function processNodes(nodes) {
return nodes.map((node) => {
let processedNode = node;
if (node.marks && Array.isArray(node.marks)) {
const newMarks = node.marks.map((mark) => {
if (mark.type === "link" && mark.attrs?.href && !mark.attrs.target) {
const { rel, class: className, target, ...attrs } = mark.attrs;
if (shouldOpenInBlankTabForMigration(fqdn, attrs.href)) {
modified = true;
return {
...mark,
attrs: {
...attrs,
target: "_blank",
},
};
}
if (rel !== undefined || className !== undefined || target !== undefined) {
modified = true;
return {
...mark,
attrs,
};
}
}
return mark;
});
if (modified) {
processedNode = {
...processedNode,
marks: newMarks,
};
}
}
if (node.content && Array.isArray(node.content)) {
const newContent = processNodes(node.content);
if (modified) {
processedNode = {
...processedNode,
content: newContent,
};
}
}
return processedNode;
});
}
const newContent = processNodes(json.content);
if (modified) {
return {
...json,
content: newContent,
};
}
}
function shouldOpenInBlankTabForMigration(fqdn, url) {
const forceBlankTabExtensions = ["pdf", "txt"];
const extensionMatch = url.match(/\.([^./?#]+)(?:[?#]|$)/);
if (extensionMatch) {
const extension = extensionMatch[1].toLowerCase();
if (forceBlankTabExtensions.includes(extension)) {
return true;
}
}
if (url.startsWith("/"))
return false;
const regex = /^https?:\/\/([^/]+)/;
const match = url.match(regex);
if (!match)
return false;
const [, domainAndPort] = match;
const domain = domainAndPort.split(":")[0];
if (domain !== fqdn)
return true;
return false;
}
//# sourceMappingURL=ddl-migration.js.map