nodejs_webpack_safe.js
// TODO I don't know why, but webpack was failing with:
// Error: Cannot find module 'ourbigbook/package.json'
// at:
// const PACKAGE_PATH = path.dirname(require.resolve(path.join(PACKAGE_NAME, 'package.json')));
// from nodejs.js. Just splitting this out here until I find the patience to
// minimize and resolve that bs.
//
// Edit: this is a not a webpack issue. Doing:
// path.dirname(require.resolve(path.join('ourbigbook', 'package.json'))
// from web/app.js also blows up.
// We cannot require sequelize here, because otherwise the web/ version blows up due to missing postgres,
// which is a peer dependency of sequelize that we don't need for the CLI converter, as we use SQLite there.
const fs = require('fs');
const path = require('path');
const { DataTypes } = require('sequelize')
const ourbigbook = require('./index');
const ourbigbook_nodejs_front = require('./nodejs_front');
const web_api = require('./web_api');
const models = require('./models');
const ENCODING = 'utf8'
// DB options that have to be given to both ourbigbook CLI and dynamic website.
// These must be used for both for consistency, e.g. freezeTableName would lead
// to different able names in the database, which could break manually written queries.
// Yes, we could work around that by using model properties like models.Id.tableName,
// but having different tables in both cases would be too insane.
const db_options = {
define: {
freezeTableName: true,
},
}
async function get_noscopes_base_fetch_rows(sequelize, ids, ignore_paths_set) {
let rows
if (ids.length) {
const where = {
idid: ids,
}
if (ignore_paths_set !== undefined) {
const ignore_paths = Array.from(ignore_paths_set).filter(x => x !== undefined)
where.path = { [sequelize.Sequelize.Op.not]: ignore_paths }
}
// Fetch in one go:
// - starting point IDs
// - from those starting point IDs:
// - parent
// - main synonym
// - title-title dependencies
// - from those, also fetch the main synonym
rows = await sequelize.models.Id.findAll({
where,
include: [
{
model: sequelize.models.Ref,
as: 'to',
where: { type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT] },
required: false,
},
{
model: sequelize.models.Ref,
as: 'from',
where: {
type: { [sequelize.Sequelize.Op.or]: [
sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_X_TITLE_TITLE],
sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_SYNONYM],
]}
},
required: false,
include: [
{
model: sequelize.models.Id,
as: 'to',
required: false,
// This is to only get IDs here for REFS_TABLE_X_TITLE_TITLE,
// and not for REFS_TABLE_PARENT.
// Can't do it with a second include easily it seems:
// https://stackoverflow.com/questions/51480266/joining-same-table-multiple-times-with-sequelize
// so we are just hacking this custom ON here.
on: {
// This is the default ON condition. Don't know how to add a new condition to the default,
// so just duplicating it here.
'$from.to_id$': {[sequelize.Sequelize.Op.col]: 'from->to.idid' },
// This gets only the TITLE TITLE and SYNONYM.
'$from.type$': [
sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_X_TITLE_TITLE],
// For every \x to a synonym, we need to know the synonym target.
// This was originally added to decide if the synonym target is the
// toplevel ID or not, because if it is, we don't add a fragment.
// https://docs.ourbigbook.com/todo/links-to-synonym-header-have-fragment
sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_SYNONYM],
],
},
// Also get the synonyms of title-title.
// Also tries to get synonyms of the other synonym and parent, but those never have them.
include: [
{
model: sequelize.models.Ref,
as: 'from',
where: {
type: { [sequelize.Sequelize.Op.or]: [
sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_SYNONYM],
]},
},
required: false,
include: [
{
model: sequelize.models.Id,
as: 'to',
required: false,
},
],
},
],
},
],
},
],
})
} else {
rows = []
}
return rows
}
/**
* @param {string[]} starting_ids
* @return {Object[]} Id-like objects sorted in breadth first order representing the
* entire subtree of IDs under starting_ids, considering only
* ourbigbook.REFS_TABLE_PARENT type refs.
*
* The IDs for the starting_ids are not present, only its children.
* These children start at depth 0.
*/
async function fetch_header_tree_ids(sequelize, starting_ids, opts={}) {
if (starting_ids.length > 0) {
const to_id_index_order = opts.to_id_index_order || 'ASC'
let { idAttrs, definedAtFileId, transaction } = opts
if (idAttrs === undefined) {
idAttrs = '*'
}
let definedAtString
if (definedAtFileId) {
definedAtString = ' AND "defined_at" = :definedAtFileId'
} else {
definedAtString = ''
}
// Fetch all data recursively.
//
// Going for WITH RECURSIVE:
// https://stackoverflow.com/questions/192220/what-is-the-most-efficient-elegant-way-to-parse-a-flat-table-into-a-tree/192462#192462
//
// Sequelize doesn't support this of course.
// - https://stackoverflow.com/questions/34135555/recursive-include-sequelize
// - https://stackoverflow.com/questions/55091052/recursive-postgresql-query
// - https://github.com/sequelize/sequelize/issues/4890
// We could use one of the other constructs proposed besides WITH RECURSIVE,
// but it would likely be less efficient and harder to implement. So just going
// with this for now.
;const [rows, meta] = await sequelize.query(`
SELECT ${idAttrs} FROM "${sequelize.models.Id.tableName}"
INNER JOIN (
WITH RECURSIVE tree_search (to_id, level, from_id, to_id_index) AS (
SELECT
to_id,
0,
from_id,
to_id_index
FROM "${sequelize.models.Ref.tableName}"
WHERE from_id IN (:starting_ids) AND type = :type${definedAtString}
UNION ALL
SELECT
t.to_id,
ts.level + 1,
ts.to_id,
t.to_id_index
FROM "${sequelize.models.Ref.tableName}" t, tree_search ts
WHERE t.from_id = ts.to_id AND type = :type${definedAtString}
)
SELECT * FROM tree_search
) AS "RecRefs"
ON "${sequelize.models.Id.tableName}".idid = "RecRefs"."to_id"
AND "${sequelize.models.Id.tableName}".macro_name = '${ourbigbook.Macro.HEADER_MACRO_NAME}'
ORDER BY "RecRefs".level ASC, "RecRefs".from_id ASC, "RecRefs".to_id_index ${to_id_index_order}
`,
{
replacements: {
starting_ids,
type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
definedAtFileId,
},
transaction,
}
)
return rows
} else {
return []
}
}
/** DbProvider that fetches data from SQL queries directly.
* Should work across different RDMSs (SQLite / PostgreSQL) due
* to the use of an ORM (Sequelize) or portable queries/ifs.
*/
class SqlDbProvider extends web_api.DbProviderBase {
constructor(sequelize) {
super();
this.sequelize = sequelize
}
async clear(input_paths, transaction) {
const sequelize = this.sequelize
return Promise.all([
// TODO get rid of this when we start deleting files on CLI.
// https://docs.ourbigbook.com/bigb-id-ref-and-file-foreign-normalization
sequelize.models.Id.findAll({
where: {},
include: [
{
model: sequelize.models.File,
as: 'idDefinedAt',
required: true,
where: { path: input_paths },
},
],
transaction
}).then(ids => sequelize.models.Id.destroy({ where: { id: ids.map(id => id.id ) }, transaction })),
sequelize.models.Ref.findAll({
attributes: ['id'],
include: [
{
model: sequelize.models.File,
as: 'definedAt',
where: { path: input_paths },
attributes: [],
},
],
transaction,
}).then(ids => sequelize.models.Ref.destroy({ where: { id: ids.map(id => id.id ) }, transaction })),
])
}
async clear_prefix(prefix) {
let prefix_literal;
if (prefix) {
prefix_literal = prefix + ourbigbook.Macro.HEADER_SCOPE_SEPARATOR + '%'
} else {
// Toplevel dir, delete all IDs.
prefix_literal = '%'
}
return Promise.all([
this.sequelize.models.Id.destroy({
where: {},
include: [
{
model: sequelize.models.File,
as: 'idDefinedAt',
required: true,
where: { path: { [this.sequelize.Sequelize.Op.like]: prefix_literal } }
},
],
}),
this.sequelize.models.Ref.findAll({
attributes: ['id'],
include: [
{
model: this.sequelize.models.File,
as: 'definedAt',
where: { path: { [this.sequelize.Sequelize.Op.like]: prefix_literal } },
attributes: [],
},
],
transaction,
}).then(ids => this.sequelize.models.Ref.destroy({ where: { id: ids.map(id => id.id ) }, transaction })),
])
}
// Get all ASTs for the selected IDs.
// @return Ast[]
async get_noscopes_base_fetch(ids, ignore_paths_set, context) {
const rows = await get_noscopes_base_fetch_rows(this.sequelize, ids, ignore_paths_set)
return this.rows_to_asts(rows, context)
}
async get_refs_to_fetch(types, to_ids, { reversed, ignore_paths_set, context }) {
if (reversed === undefined) {
reversed = false
}
if (to_ids.length) {
let to_id_key, other_key;
if (reversed) {
to_id_key = 'from_id'
other_key = 'to_id'
} else {
to_id_key = 'to_id'
other_key = 'from_id'
}
const include_key = other_key.split('_')[0]
const where = {
[to_id_key]: to_ids,
type: types.map(type => this.sequelize.models.Ref.Types[type]),
}
const include = [
{
model: this.sequelize.models.Id,
as: include_key,
// https://github.com/ourbigbook/ourbigbook/issues/240
include: [
{
model: this.sequelize.models.File,
as: 'idDefinedAt',
include: [{
model: this.sequelize.models.Id,
as: 'toplevelId',
}]
},
],
}
]
if (ignore_paths_set !== undefined) {
const ignore_paths = Array.from(ignore_paths_set).filter(x => x !== undefined)
include.push({
model: this.sequelize.models.File,
as: 'definedAt',
where: {
path: {
[this.sequelize.Sequelize.Op.or]: [
{ [this.sequelize.Sequelize.Op.not]: ignore_paths },
null
]
}
},
})
}
const rows = await this.sequelize.models.Ref.findAll({
where,
attributes: [
[other_key, 'id'],
'defined_at',
to_id_key,
'type',
],
include,
})
// Fetch files. In theory should be easily done on above query as JOIN,
// but for some reason it is not working as mentioned on the TODO...
for (const row of rows) {
if (row[include_key]) {
this.add_file_row_to_cache(row[include_key].idDefinedAt, context)
}
}
//const file_paths = []
//for (const row of rows) {
// if (row[include_key]) {
// file_paths.push(row[include_key].idDefinedAt.path)
// }
//}
//const file_rows = await this.sequelize.models.File.findAll({
// where: { path: file_paths },
// include: [
// {
// model: this.sequelize.models.Id,
// as: 'toplevelId',
// }
// ],
//})
//for (const file_row of file_rows) {
// this.add_file_row_to_cache(file_row, context)
//}
for (const row of rows) {
this.add_ref_row_to_cache(row, to_id_key, include_key, context)
}
return rows
}
}
get_refs_to(type, to_id, reversed=false) {
let to_id_key, other_key;
if (reversed) {
to_id_key = 'from_id'
other_key = 'to_id'
} else {
to_id_key = 'to_id'
other_key = 'from_id'
}
// We don't even query the DB here to ensure that the warm is getting everything,
// as part of our effort to centralize all queries at a single point.
const ref_cache_to_id = this.ref_cache[to_id_key][to_id]
if (ref_cache_to_id === undefined) {
return []
}
const ret = ref_cache_to_id[this.sequelize.models.Ref.Types[type]]
if (ret === undefined) {
return []
}
return ret
}
// We have a separate function from fetch_header_tree_ids to defer after that call,
// because we want to first fetch everything
// and populate the ID cache with the include entry points that have proper header_tree_node.
// Only then are we ready for linking up the rest of the tree.
build_header_tree(fetch_header_tree_ids_rows, { context }) {
const asts = []
for (const row of fetch_header_tree_ids_rows) {
const ast = this.row_to_ast(row, context)
if (ast.synonym === undefined) {
const parent_id = row.from_id
const parent_ast = this.id_cache[parent_id]
const parent_ast_header_tree_node = parent_ast.header_tree_node
ast.header_tree_node = new ourbigbook.HeaderTreeNode(ast, parent_ast_header_tree_node);
// I love it when you get potential features like this for free.
// Only noticed when Figures showed up on ToC.
if (
ast.macro_name === ourbigbook.Macro.HEADER_MACRO_NAME &&
// Can happen on error condition of pointing options.parent_id to self.
// Blew up on web test "Circular parent loops to self fail gracefully."
parent_ast_header_tree_node !== undefined
) {
parent_ast_header_tree_node.add_child(ast.header_tree_node);
}
ourbigbook.propagateNumbered(ast, context)
this.id_cache[ast.id] = ast
asts.push(ast)
}
}
return asts
}
async fetch_header_tree_ids(starting_ids, opts={}) {
return fetch_header_tree_ids(this.sequelize, starting_ids, opts)
}
// Recursively fetch all ancestors of a given ID from the database.
async fetch_ancestors(toplevel_id) {
if (toplevel_id) {
;const [rows, meta] = await this.sequelize.query(`
SELECT * FROM "${this.sequelize.models.Id.tableName}"
INNER JOIN (
WITH RECURSIVE
tree_search (to_id, level, from_id) AS (
SELECT
to_id,
0,
from_id
FROM "${this.sequelize.models.Ref.tableName}"
WHERE to_id = :toplevel_id AND type = :type
UNION ALL
SELECT
ts.from_id,
ts.level + 1,
t.from_id
FROM "${this.sequelize.models.Ref.tableName}" t, tree_search ts
WHERE t.to_id = ts.from_id AND type = :type
)
SELECT * FROM tree_search
) AS "RecRefs"
ON "${this.sequelize.models.Id.tableName}".idid = "RecRefs"."from_id"
ORDER BY "RecRefs".level DESC
`,
{ replacements: {
toplevel_id,
type: this.sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
} }
)
return rows
} else {
return []
}
}
fetch_ancestors_build_tree(rows, context) {
const asts = []
let parent_ast
for (const row of rows) {
let ast = this.id_cache[row.idid]
if (!ast) {
ast = this.add_row_to_id_cache(row, context)
}
if (ast.synonym === undefined) {
let parent_ast_header_tree_node
if (parent_ast) {
parent_ast_header_tree_node = parent_ast.header_tree_node
}
ast.header_tree_node = new ourbigbook.HeaderTreeNode(ast, parent_ast_header_tree_node);
if (parent_ast) {
parent_ast_header_tree_node.add_child(ast.header_tree_node);
}
ourbigbook.propagateNumbered(ast, context)
parent_ast = ast
}
}
return asts
}
// Update the databases based on the output of the Ourbigbook conversion.
// This updates the tables:
// * Id
// * Ref
async update(ourbigbook_extra_returns, sequelize, transaction, opts={}) {
const { newFile, synonymHeaderPaths } = opts
const context = ourbigbook_extra_returns.context
// Remove all IDs from the converted files to ensure that removed IDs won't be
// left over hanging in the database.
await this.clear(Array.from(context.options.include_path_set).concat(synonymHeaderPaths), transaction);
// Calculate create_ids
const ids = ourbigbook_extra_returns.ids;
const create_ids = []
for (const id in ids) {
const ast = ids[id];
create_ids.push({
idid: id,
defined_at: newFile.id,
ast_json: JSON.stringify(ast),
macro_name: ast.macro_name,
toplevel_id: ast.toplevel_id,
})
}
// calculate refs
const refs = []
// We only need to inspect the false because the information is redundant with the true,
// it is only a primitive indexing mechanism.
for (const to_id in context.refs_to[false]) {
const types = context.refs_to[false][to_id];
for (const type in types) {
const from_ids = types[type];
for (const from_id in from_ids) {
if (
// TODO happens on OurBigBookExample, likely also include,
// need to fix and remove this if.
from_id !== undefined
) {
const ref_props = from_ids[from_id];
const defined_ats = ref_props.defined_at
for (const defined_at in defined_ats) {
for (const { line: defined_at_line, column: defined_at_col, inflected } of defined_ats[defined_at]) {
refs.push({
from_id,
defined_at: newFile.id,
defined_at_line,
defined_at_col,
to_id_index: ref_props.child_index,
to_id,
type: sequelize.models.Ref.Types[type],
inflected,
})
}
}
}
}
}
}
return Promise.all([
sequelize.models.Id.bulkCreate(create_ids, { transaction }),
sequelize.models.Ref.bulkCreate(refs, { transaction }),
])
}
async fetch_files(path, context) {
const rows = await this.sequelize.models.File.findAll({
where: { path },
// We need to fetch these for toplevel scope removal.
include: [{
model: this.sequelize.models.Id,
as: 'toplevelId',
}],
})
for (const row of rows) {
this.add_file_row_to_cache(row, context)
}
}
}
async function create_sequelize(db_options_arg, Sequelize, sync_opts={}) {
db_options_arg = Object.assign({ timestamps: false }, db_options_arg, db_options)
const storage = db_options_arg.storage
delete db_options_arg.storage
let sequelize
if (ourbigbook_nodejs_front.postgres) {
Object.assign(
db_options_arg,
ourbigbook_nodejs_front.sequelize_postgres_opts,
)
sequelize = new Sequelize('postgres://ourbigbook_user:a@localhost:5432/ourbigbook_cli', db_options_arg)
} else {
Object.assign(db_options_arg,
{
dialect: 'sqlite',
storage,
},
db_options_arg,
)
sequelize = new Sequelize(db_options_arg)
}
models.addModels(sequelize, { cli: true })
if (
db_options_arg.dialect !== 'sqlite' ||
storage === ourbigbook.SQLITE_MAGIC_MEMORY_NAME ||
(storage && !fs.existsSync(storage))
) {
await sequelize.sync(sync_opts)
}
return sequelize
}
async function destroy_sequelize(sequelize) {
return sequelize.close()
}
// Update the database after converting each separate file.
// This updates the tables:
// * Id
// * Ref
// * File
// * Render
async function update_database_after_convert({
authorId,
bodySource,
extra_returns,
db_provider,
had_error,
is_render_after_extract,
non_ourbigbook_options,
renderType,
path,
render, // boolean
sequelize,
synonymHeaderPaths,
hash,
transaction,
titleSource,
}) {
const context = extra_returns.context;
if (non_ourbigbook_options === undefined) {
non_ourbigbook_options = {}
}
if (non_ourbigbook_options.commander === undefined) {
non_ourbigbook_options.commander = {}
}
if (renderType === undefined) {
renderType = ourbigbook.OUTPUT_FORMAT_HTML
}
ourbigbook.perfPrint(context, 'convert_path_pre_sqlite_transaction')
let toplevel_id;
if (context.toplevel_ast !== undefined) {
toplevel_id = context.toplevel_ast.id;
}
const file_bulk_create_opts = {}
let file_bulk_create_last_parse
if (extra_returns.errors.length > 0) {
file_bulk_create_last_parse = null
file_bulk_create_opts.ignoreDuplicates = true
} else {
file_bulk_create_opts.updateOnDuplicate = [
'titleSource',
'bodySource',
'last_parse',
// https://github.com/ourbigbook/ourbigbook/issues/241
'toplevel_id',
'hash',
]
file_bulk_create_last_parse = Date.now()
}
// This was the 80% bottleneck at Ourbigbook f8fc9eacfa794b95c1d9982a04b62603e6d0bb83
// before being converted to a single transaction!
// Likely would not have been a bottleneck if we new more about databases/had more patience
// and instead of doing INSERT one by one we would do a single insert with a bunch of data.
// The move to Sequelize made that easier with bulkCreate. But keeping the transaction just in case
let newFile
await sequelize.transaction({ transaction }, async (transaction) => {
file_bulk_create_opts.transaction = transaction
await sequelize.models.File.bulkCreate(
[
{
authorId,
bodySource,
last_parse: file_bulk_create_last_parse,
path,
hash,
titleSource,
toplevel_id,
},
],
file_bulk_create_opts,
)
newFile = await sequelize.models.File.findOne({ where: { path }, transaction})
const promises = []
if (
// This is not just an optimization, but actually required, because otherwise the second database
// update would override \x magic plural/singular check_db removal.
!is_render_after_extract
) {
promises.push(db_provider.update(
extra_returns,
sequelize,
transaction,
{
newFile,
synonymHeaderPaths,
}
))
}
await Promise.all(promises)
// Re-find here until SQLite RETURNING gets used by sequelize.
const file = await sequelize.models.File.findOne({ where: { path }, transaction })
if (!render) {
// Mark all existing renderings as outdated.
await sequelize.models.Render.update(
{
outdated: true,
},
{
where: {
fileId: file.id,
},
transaction,
}
)
}
// Create a rendering for the current type if one does not exist.
await sequelize.models.Render.upsert(
{
outdated: !render || !!had_error,
type: sequelize.models.Render.Types[renderType],
fileId: file.id,
},
{
transaction,
}
)
});
ourbigbook.perfPrint(context, 'convert_path_post_sqlite_transaction')
return { file: newFile }
}
// Do various post conversion checks to verify database integrity after the database is updated by the ID extraction step:
//
// - refs to IDs that don't exist
// - duplicate IDs
// - https://docs.ourbigbook.com/x-within-title-restrictions
//
// Previously these were done inside ourbigbook.convert. But then we started skipping render by timestamp,
// so if you e.g. move an ID from one file to another, a common operation, then it would still see
// the ID in the previous file depending on conversion order. So we are moving it here instead at the end.
// Having this single query at the end also be slightly more efficient than doing each query separately per file converion.
async function check_db(sequelize, paths_converted, opts={}) {
// * delete unused xrefs in different files to correctly have tags and incoming links in such cases
// https://github.com/ourbigbook/ourbigbook/issues/229
// These can happen due to:
// * directory based scopes
// * \x magic pluralization variants
// * ensure that all \x targets exist
const { perf, transaction } = opts
let t0
if (perf) {
t0 = performance.now();
console.error('perf: check_db.start');
}
const [new_refs, duplicate_rows, invalid_title_title_rows] = await Promise.all([
sequelize.models.Ref.findAll({
order: [
['defined_at', 'ASC'],
['defined_at_line', 'ASC'],
['defined_at_col', 'ASC'],
['type', 'ASC'],
['inflected', 'ASC'],
// Longest matching scope first, we then ignore all others.
[sequelize.fn('length', sequelize.col('to_id')), 'DESC'],
],
include: [
{
model: sequelize.models.Id,
as: 'to',
attributes: ['id'],
},
{
model: sequelize.models.Id,
as: 'from',
attributes: ['id'],
},
{
model: sequelize.models.File,
as: 'definedAt',
where: { path: paths_converted },
},
],
transaction,
}),
sequelize.models.Id.findDuplicates(
paths_converted, transaction),
sequelize.models.Id.findInvalidTitleTitle(
paths_converted, transaction),
])
if (perf) {
console.error(`perf: check_db.after_finds: ${performance.now() - t0} ms`);
}
const error_messages = []
// Check that each link has at least one hit for the available magic inflections if any.
// If there are multiple matches pick the one that is either:
// - on the longest scope
// - if there's a draw on scope length, prefer the non inflected one
// TODO maybe it is possible to do this in a single query. But I'm not smart enough.
// So just doing some Js code and an extra deletion query afterwards
let i = 0
const delete_unused_inflection_ids = []
//console.dir(new_refs.map((r, i) => { return {
// i,
// defined_at: r.defined_at,
// defined_at_line: r.defined_at_line,
// defined_at_col: r.defined_at_col,
// from_id: r.from_id,
// to_id: r.to_id,
// type: r.type,
// inflected: r.inflected,
//} }), { maxArrayLength: null } );
while (i < new_refs.length) {
let new_ref = new_refs[i]
let new_ref_next = new_ref
let not_inflected_match_local_idx, inflected_match_local_idx, not_inflected_match_global_idx, inflected_match_global_idx
do {
let do_delete = true
let not_inflected_idx = 0
let inflected_idx = 0
if (new_ref_next.inflected) {
if (
inflected_match_global_idx === undefined &&
new_ref_next.to &&
new_ref_next.from
) {
inflected_match_global_idx = i
inflected_match_local_idx = inflected_idx
do_delete = false
}
inflected_idx++
} else if (inflected_match_global_idx === undefined) {
shortest_not_inflected_ref = new_ref_next
if (
not_inflected_match_global_idx === undefined &&
new_ref_next.to &&
new_ref_next.from
) {
not_inflected_match_global_idx = i
not_inflected_match_local_idx = not_inflected_idx
do_delete = false
}
not_inflected_idx++
}
if (do_delete) {
//console.error(`do_delete ${i} ${new_refs[i].from_id} -> ${new_refs[i].to_id}`);
delete_unused_inflection_ids.push(new_ref_next.id)
}
i++
new_ref_next = new_refs[i]
} while (
new_ref_next &&
new_ref.definedAt.path === new_ref_next.definedAt.path &&
new_ref.defined_at_line === new_ref_next.defined_at_line &&
new_ref.defined_at_col === new_ref_next.defined_at_col &&
new_ref.type === new_ref_next.type
)
// Select between inflected and non-inflected since both match.
if (
not_inflected_match_global_idx !== undefined &&
inflected_match_global_idx !== undefined
) {
let delete_idx
if (inflected_match_local_idx < not_inflected_match_local_idx) {
delete_idx = not_inflected_match_global_idx
} else {
delete_idx = inflected_match_global_idx
}
delete_unused_inflection_ids.push(new_refs[delete_idx].id)
}
// No matches, so error.
if (
not_inflected_match_global_idx === undefined &&
inflected_match_global_idx === undefined
) {
let to
if (
// Happens on undefined tags.
// https://docs.ourbigbook.com/todo/undefined-tag-error-message-for-directory-conversion-says-header-id-is-not-defined-instead-of-tag-id
shortest_not_inflected_ref.type === sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_X_CHILD]
) {
to = shortest_not_inflected_ref.from_id
} else {
to = shortest_not_inflected_ref.to_id
}
error_messages.push(
`${new_ref.definedAt.path}:${new_ref.defined_at_line}:${new_ref.defined_at_col}: cross reference to unknown id: "${to}"`
)
}
}
if (delete_unused_inflection_ids.length) {
await sequelize.models.Ref.destroy({ where: { id: delete_unused_inflection_ids }, transaction })
}
if (duplicate_rows.length > 0) {
for (const duplicate_row of duplicate_rows) {
const ast = ourbigbook.AstNode.fromJSON(duplicate_row.ast_json)
const source_location = ast.source_location
const other_ast = ourbigbook.AstNode.fromJSON(duplicate_row.duplicate[0].ast_json)
const other_source_location = other_ast.source_location
error_messages.push(
`${source_location.path}:${source_location.line}:${source_location.column}: duplicated ID: "${duplicate_row.idid}". Previous definition at: ${other_source_location.path}:${other_source_location.line}:${other_source_location.column}`
)
}
}
if (invalid_title_title_rows.length > 0) {
for (const invalid_title_title_row of invalid_title_title_rows) {
const ast = ourbigbook.AstNode.fromJSON(invalid_title_title_row.ast_json)
const source_location = ast.source_location
error_messages.push(
`${source_location.path}:${source_location.line}:${source_location.column}: cannot \\x link from a title to a non-header element: https://docs.ourbigbook.com/x-within-title-restrictions`
)
}
}
if (perf) {
console.error(`perf: check_db.finish: ${performance.now() - t0} ms`);
}
return error_messages
}
function preload_katex_from_file(tex_path, katex_macros) {
if (katex_macros === undefined) {
katex_macros = {}
}
katex_macros = ourbigbook_nodejs_front.preload_katex(
fs.readFileSync(tex_path, ENCODING),
katex_macros,
)
return katex_macros
}
// https://stackoverflow.com/questions/9355403/deleting-duplicates-on-sorted-array/61974900#61974900
function remove_duplicates_sorted_array(arr) {
return arr.filter((e, i, a) => e !== a[i - 1]);
}
// on: 'insert', 'delete', 'update'
// action: SQL statement string with what must be done
// after: 'BEFORE' or 'AFTER'
// when: SQL statement string that goes in WHEN ( <when> )
async function sequelizeCreateTrigger(sequelize, model, on, action, { after, when, nameExtra } = {}) {
if (after === undefined) {
after = 'AFTER'
}
if (nameExtra) {
nameExtra = `_${nameExtra})`
} else {
nameExtra = ''
}
const oldnew = on === 'delete' ? 'OLD' : 'NEW'
const triggerName = `${model.tableName}_${on}${nameExtra}`
if (when) {
when = `\n WHEN (${when})`
} else {
when = ''
}
if (sequelize.options.dialect === 'postgres') {
const functionName = `${triggerName}_fn`
await sequelize.query(`CREATE OR REPLACE FUNCTION "${functionName}"()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS
$$
BEGIN
${action};
RETURN ${oldnew};
END;
$$
`)
// CREATE OR REPLACE TRIGGER was only added on postgresql 14 so let's be a bit more portable for now:
// https://stackoverflow.com/questions/35927365/create-or-replace-trigger-postgres
await sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName} ON "${model.tableName}"`)
await sequelize.query(`CREATE TRIGGER ${triggerName}
${after} ${on.toUpperCase()}
ON "${model.tableName}"
FOR EACH ROW${when}
EXECUTE PROCEDURE "${functionName}"();
`)
} else if (sequelize.options.dialect === 'sqlite') {
await sequelize.query(`
CREATE TRIGGER IF NOT EXISTS ${triggerName}
${after} ${on.toUpperCase()}
ON "${model.tableName}"
FOR EACH ROW${when}
BEGIN
${action};
END;
`)
}
}
/** Create triggers to keep counts such as user likes article counts on article table in sync. */
async function sequeliezeCreateTriggerUpdateCount(sequelize, articleTable, likeTable, articleTableCountField, likeTableArticleIdField) {
const articleTableName = articleTable.tableName
await sequelizeCreateTrigger(sequelize, likeTable, 'insert',
`UPDATE "${articleTableName}" SET "${articleTableCountField}" = "${articleTableCountField}" + 1 WHERE NEW."${likeTableArticleIdField}" = "${articleTableName}"."id"`
),
await sequelizeCreateTrigger(sequelize, likeTable, 'delete',
`UPDATE "${articleTableName}" SET "${articleTableCountField}" = "${articleTableCountField}" - 1 WHERE OLD."${likeTableArticleIdField}" = "${articleTableName}"."id"`
),
await sequelizeCreateTrigger(
// I don't think this will ever happen, only insert/deletion. But still let's define it just in case.
sequelize,
likeTable,
'update',
`UPDATE "${articleTableName}" SET "${articleTableCountField}" = "${articleTableCountField}" + 1 WHERE NEW."${likeTableArticleIdField}" = "${articleTableName}"."id";\n` +
`UPDATE "${articleTableName}" SET "${articleTableCountField}" = "${articleTableCountField}" - 1 WHERE OLD."${likeTableArticleIdField}" = "${articleTableName}"."id"`
,
{
when: `OLD."${likeTableArticleIdField}" <> NEW."${likeTableArticleIdField}"`,
}
)
}
module.exports = {
SqlDbProvider,
check_db,
create_sequelize,
db_options,
destroy_sequelize,
fetch_header_tree_ids,
get_noscopes_base_fetch_rows,
preload_katex_from_file,
remove_duplicates_sorted_array,
sequelizeCreateTrigger,
sequeliezeCreateTriggerUpdateCount,
update_database_after_convert,
ENCODING,
TMP_DIRNAME: 'out',
}