@dossierhq/core
Version:
The core Dossier library used by clients and server alike, used to interact with schema and entities directly, as well as remotely through a client.
472 lines • 21 kB
JavaScript
/// <reference types="./schemaUpdate.d.ts" />
import { notOk, ok } from '../ErrorResult.js';
import { assertExhaustive } from '../utils/Asserts.js';
import { isFieldValueEqual } from '../utils/isFieldValueEqual.js';
import { FieldType, } from './SchemaSpecification.js';
export function schemaUpdate(currentSchemaSpec, update) {
const schemaSpec = JSON.parse(JSON.stringify(currentSchemaSpec));
const mergeMigrationsResult = mergeMigrations(update, schemaSpec);
if (mergeMigrationsResult.isError())
return mergeMigrationsResult;
const currentMigration = mergeMigrationsResult.value;
const applyMigrationsResult = applyMigrationsToSchema(currentMigration, schemaSpec);
if (applyMigrationsResult.isError())
return applyMigrationsResult;
const applyTransientMigrationsResult = applyTransientMigrationsToSchema(update.version, update.transientMigrations, schemaSpec);
if (applyTransientMigrationsResult.isError())
return applyTransientMigrationsResult;
// Merge entity types
if (update.entityTypes) {
for (const entitySpecUpdate of update.entityTypes) {
const existingIndex = schemaSpec.entityTypes.findIndex((it) => it.name === entitySpecUpdate.name);
const existingEntitySpec = existingIndex >= 0 ? schemaSpec.entityTypes[existingIndex] : null;
const publishable = valueOrExistingOrDefault(entitySpecUpdate.publishable, existingEntitySpec?.publishable, true);
const authKeyPattern = valueOrExistingOrDefault(entitySpecUpdate.authKeyPattern, existingEntitySpec?.authKeyPattern, null);
const nameField = valueOrExistingOrDefault(entitySpecUpdate.nameField, existingEntitySpec?.nameField, null);
const collectFieldsResult = collectFieldSpecsFromUpdates(entitySpecUpdate.fields, existingEntitySpec);
if (collectFieldsResult.isError())
return collectFieldsResult;
const entitySpec = {
name: entitySpecUpdate.name,
publishable,
authKeyPattern,
nameField,
fields: collectFieldsResult.value,
};
if (existingIndex >= 0) {
schemaSpec.entityTypes[existingIndex] = entitySpec;
}
else {
schemaSpec.entityTypes.push(entitySpec);
}
// Version 0.2.3: moved isName from field to nameField on entity types, isName is deprecated
const fieldWithIsName = entitySpecUpdate.fields.find((it) => 'isName' in it);
if (fieldWithIsName) {
return notOk.BadRequest(`${entitySpec.name}.${fieldWithIsName.name}: isName is specified, use nameField on the type instead`);
}
}
}
// Merge component types
if (update.componentTypes) {
for (const componentSpecUpdate of update.componentTypes) {
const existingIndex = schemaSpec.componentTypes.findIndex((it) => it.name === componentSpecUpdate.name);
const existingComponentSpec = existingIndex >= 0 ? schemaSpec.componentTypes[existingIndex] : null;
const adminOnly = valueOrExistingOrDefault(componentSpecUpdate.adminOnly, existingComponentSpec?.adminOnly, false);
const collectFieldsResult = collectFieldSpecsFromUpdates(componentSpecUpdate.fields, existingComponentSpec);
if (collectFieldsResult.isError())
return collectFieldsResult;
const componentSpec = {
name: componentSpecUpdate.name,
adminOnly,
fields: collectFieldsResult.value,
};
if (existingIndex >= 0) {
schemaSpec.componentTypes[existingIndex] = componentSpec;
}
else {
schemaSpec.componentTypes.push(componentSpec);
}
}
}
// Check which patterns and indexes are used
const usedPatterns = new Set(schemaSpec.entityTypes.map((it) => it.authKeyPattern).filter((it) => !!it));
const usedIndexes = new Set();
for (const typeSpec of [...schemaSpec.entityTypes, ...schemaSpec.componentTypes]) {
for (const fieldSpec of typeSpec.fields) {
if (fieldSpec.type !== FieldType.String)
continue;
if (fieldSpec.matchPattern) {
usedPatterns.add(fieldSpec.matchPattern);
}
if (fieldSpec.index) {
usedIndexes.add(fieldSpec.index);
}
}
}
// Merge patterns
for (const pattern of update.patterns ?? []) {
const existingPatternIndex = schemaSpec.patterns.findIndex((it) => it.name === pattern.name);
if (existingPatternIndex >= 0) {
schemaSpec.patterns[existingPatternIndex] = pattern;
}
else {
schemaSpec.patterns.push(pattern);
}
}
// Delete unused patterns and check that all used patterns are defined
const unspecifiedPatternNames = new Set(usedPatterns);
schemaSpec.patterns = schemaSpec.patterns.filter((it) => {
unspecifiedPatternNames.delete(it.name);
return usedPatterns.has(it.name);
});
if (unspecifiedPatternNames.size > 0) {
return notOk.BadRequest(`Pattern ${[...unspecifiedPatternNames].join(', ')} is used, but not defined`);
}
// Merge indexes
for (const index of update.indexes ?? []) {
const existingIndexIndex = schemaSpec.indexes.findIndex((it) => it.name === index.name);
if (existingIndexIndex >= 0) {
schemaSpec.indexes[existingIndexIndex] = index;
}
else {
schemaSpec.indexes.push(index);
}
}
// Delete unused indexes and check that all used indexes are defined
const unspecifiedIndexNames = new Set(usedIndexes);
schemaSpec.indexes = schemaSpec.indexes.filter((it) => {
unspecifiedIndexNames.delete(it.name);
return usedIndexes.has(it.name);
});
if (unspecifiedIndexNames.size > 0) {
return notOk.BadRequest(`Index ${[...unspecifiedIndexNames].join(', ')} is used, but not defined`);
}
// Sort everything
schemaSpec.entityTypes.sort((a, b) => a.name.localeCompare(b.name));
schemaSpec.componentTypes.sort((a, b) => a.name.localeCompare(b.name));
schemaSpec.patterns.sort((a, b) => a.name.localeCompare(b.name));
schemaSpec.indexes.sort((a, b) => a.name.localeCompare(b.name));
schemaSpec.migrations.sort((a, b) => b.version - a.version);
// Detect if changed and bump version
if (isFieldValueEqual(currentSchemaSpec, schemaSpec)) {
return ok(currentSchemaSpec); // no change
}
schemaSpec.version += 1;
return ok(schemaSpec);
}
function mergeMigrations(update, updatedSpec) {
let currentMigration = null;
for (const newMigration of update.migrations ?? []) {
const existingMigration = updatedSpec.migrations.find((it) => it.version === newMigration.version);
if (existingMigration) {
if (!isFieldValueEqual(existingMigration, newMigration)) {
return notOk.BadRequest(`Migration ${newMigration.version} is already defined`);
}
}
else {
if (newMigration.version !== updatedSpec.version + 1) {
return notOk.BadRequest(`New migration ${newMigration.version} must be the same as the schema new version ${updatedSpec.version + 1}`);
}
else {
if (currentMigration) {
return notOk.BadRequest(`Duplicate migrations for version ${newMigration.version}`);
}
currentMigration = newMigration;
}
}
}
if (currentMigration?.actions.length === 0) {
currentMigration = null;
}
if (currentMigration) {
updatedSpec.migrations.unshift(currentMigration);
}
return ok(currentMigration);
}
function applyMigrationsToSchema(migration, schemaSpec) {
if (!migration) {
return ok(undefined);
}
for (const actionSpec of migration.actions) {
const { action } = actionSpec;
switch (action) {
case 'deleteField': {
const result = applyFieldMigration(schemaSpec, actionSpec, (typeSpec, _fieldSpec, fieldIndex) => {
// remove field
typeSpec.fields.splice(fieldIndex, 1);
// Reset nameField if it was deleted
if ('nameField' in typeSpec && typeSpec.nameField === actionSpec.field) {
typeSpec.nameField = null;
}
});
if (result.isError())
return result;
break;
}
case 'deleteType': {
const result = applyTypeMigration(schemaSpec, actionSpec, (typeSpecs, _typeSpec, typeIndex) => {
typeSpecs.splice(typeIndex, 1);
});
if (result.isError())
return result;
applyTypeMigrationToTypeReferences(schemaSpec, actionSpec, (references, typeIndex) => {
references.splice(typeIndex, 1);
});
break;
}
case 'renameField': {
const result = applyFieldMigration(schemaSpec, actionSpec, (typeSpec, fieldSpec, _fieldIndex) => {
fieldSpec.name = actionSpec.newName;
// Change nameField if it was renamed
if ('nameField' in typeSpec && typeSpec.nameField === actionSpec.field) {
typeSpec.nameField = actionSpec.newName;
}
});
if (result.isError())
return result;
break;
}
case 'renameType': {
const result = applyTypeMigration(schemaSpec, actionSpec, (_typeSpecs, typeSpec, _typeIndex) => {
typeSpec.name = actionSpec.newName;
});
if (result.isError())
return result;
applyTypeMigrationToTypeReferences(schemaSpec, actionSpec, (references, typeIndex) => {
references[typeIndex] = actionSpec.newName;
});
break;
}
default:
assertExhaustive(action);
}
}
return ok(undefined);
}
function applyFieldMigration(schemaSpec, actionSpec, apply) {
let typeSpecs;
let typeName;
if ('entityType' in actionSpec) {
typeSpecs = schemaSpec.entityTypes;
typeName = actionSpec.entityType;
}
else {
typeSpecs = schemaSpec.componentTypes;
typeName = actionSpec.componentType;
}
const typeSpec = typeSpecs.find((it) => it.name === typeName);
if (!typeSpec) {
return notOk.BadRequest(`Type for migration ${actionSpec.action} ${typeName}.${actionSpec.field} does not exist`);
}
const fieldIndex = typeSpec.fields.findIndex((it) => it.name === actionSpec.field);
if (fieldIndex < 0) {
return notOk.BadRequest(`Field for migration ${actionSpec.action} ${typeName}.${actionSpec.field} does not exist`);
}
apply(typeSpec, typeSpec.fields[fieldIndex], fieldIndex);
return ok(undefined);
}
function applyTypeMigration(schemaSpec, actionSpec, apply) {
let typeSpecs;
let typeName;
if ('entityType' in actionSpec) {
typeSpecs = schemaSpec.entityTypes;
typeName = actionSpec.entityType;
}
else {
typeSpecs = schemaSpec.componentTypes;
typeName = actionSpec.componentType;
}
const typeIndex = typeSpecs.findIndex((it) => it.name === typeName);
if (typeIndex < 0) {
return notOk.BadRequest(`Type for migration ${actionSpec.action} ${typeName} does not exist`);
}
const typeSpec = typeSpecs[typeIndex];
apply(typeSpecs, typeSpec, typeIndex);
return ok(undefined);
}
function applyTypeMigrationToTypeReferences(schemaSpec, actionSpec, apply) {
const typeName = 'entityType' in actionSpec ? actionSpec.entityType : actionSpec.componentType;
for (const typeSpec of [...schemaSpec.entityTypes, ...schemaSpec.componentTypes]) {
for (const fieldSpec of typeSpec.fields) {
for (const property of 'entityType' in actionSpec
? ['entityTypes', 'linkEntityTypes']
: ['componentTypes']) {
if (property in fieldSpec) {
const references = fieldSpec[property];
const index = references.indexOf(typeName);
if (index >= 0) {
apply(references, index);
}
}
}
}
}
}
function applyTransientMigrationsToSchema(version, transientMigrations, schemaSpec) {
if (!transientMigrations || transientMigrations.length === 0)
return ok(undefined);
if (typeof version !== 'number') {
return notOk.BadRequest('Schema version is required when specifying transient migrations');
}
for (const actionSpec of transientMigrations ?? []) {
const { action } = actionSpec;
switch (action) {
case 'deleteIndex': {
const indexIndex = schemaSpec.indexes.findIndex((it) => it.name === actionSpec.index);
if (indexIndex < 0) {
return notOk.BadRequest(`Index for migration ${actionSpec.action} ${actionSpec.index} does not exist`);
}
schemaSpec.indexes.splice(indexIndex, 1);
applyIndexMigrationToIndexReferences(schemaSpec, actionSpec, () => null);
break;
}
case 'renameIndex': {
const index = schemaSpec.indexes.find((it) => it.name === actionSpec.index);
if (!index) {
return notOk.BadRequest(`Index for migration ${actionSpec.action} ${actionSpec.index} does not exist`);
}
index.name = actionSpec.newName;
applyIndexMigrationToIndexReferences(schemaSpec, actionSpec, () => actionSpec.newName);
break;
}
default:
assertExhaustive(action);
}
}
return ok(undefined);
}
function applyIndexMigrationToIndexReferences(schemaSpec, actionSpec, apply) {
for (const typeSpec of [...schemaSpec.entityTypes, ...schemaSpec.componentTypes]) {
for (const fieldSpec of typeSpec.fields) {
if (fieldSpec.type === FieldType.String) {
const reference = fieldSpec.index;
if (reference === actionSpec.index) {
const newReference = apply();
fieldSpec.index = newReference;
}
}
}
}
}
function collectFieldSpecsFromUpdates(fieldUpdates, existingTypeSpec) {
const fields = [];
const usedFieldNames = new Set();
for (const fieldUpdate of fieldUpdates) {
const fieldResult = mergeAndNormalizeUpdatedFieldSpec(fieldUpdate, existingTypeSpec);
if (fieldResult.isError())
return fieldResult;
fields.push(fieldResult.value);
usedFieldNames.add(fieldUpdate.name);
}
// Add existing fields that are not updated
if (existingTypeSpec) {
for (const fieldSpec of existingTypeSpec.fields) {
if (!usedFieldNames.has(fieldSpec.name)) {
fields.push(fieldSpec);
}
}
}
return ok(fields);
}
function mergeAndNormalizeUpdatedFieldSpec(fieldSpecUpdate, existingTypeSpec) {
const existingFieldSpec = existingTypeSpec?.fields.find((it) => it.name === fieldSpecUpdate.name);
const { name, type } = fieldSpecUpdate;
const list = valueOrExistingOrDefault(fieldSpecUpdate.list, existingFieldSpec?.list, false);
const required = valueOrExistingOrDefault(fieldSpecUpdate.required, existingFieldSpec?.required, false);
const adminOnly = valueOrExistingOrDefault(fieldSpecUpdate.adminOnly, existingFieldSpec?.adminOnly, false);
if (existingTypeSpec && existingFieldSpec) {
const typeName = existingTypeSpec.name;
if (existingFieldSpec.type !== type) {
return notOk.BadRequest(`${typeName}.${name}: Can’t change type of field. Requested ${type} but is ${existingFieldSpec.type}`);
}
if (existingFieldSpec.list !== list) {
return notOk.BadRequest(`${typeName}.${name}: Can’t change the value of list. Requested ${list} but is ${existingFieldSpec.list}`);
}
}
switch (type) {
case FieldType.Boolean:
return ok({ name, type, list, required, adminOnly });
case FieldType.Reference: {
const existingEntityFieldSpec = existingFieldSpec;
const entityTypes = sortAndRemoveDuplicates(valueOrExistingOrDefault(fieldSpecUpdate.entityTypes, existingEntityFieldSpec?.entityTypes, []));
return ok({
name,
type,
list,
required,
adminOnly,
entityTypes,
});
}
case FieldType.Location:
return ok({ name, type, list, required, adminOnly });
case FieldType.Number: {
const existingNumberFieldSpec = existingFieldSpec;
const integer = valueOrExistingOrDefault(fieldSpecUpdate.integer, existingNumberFieldSpec?.integer, false);
return ok({
name,
type,
list,
required,
adminOnly,
integer,
});
}
case FieldType.RichText: {
const existingRichTextFieldSpec = existingFieldSpec;
const richTextNodes = sortAndRemoveDuplicates(valueOrExistingOrDefault(fieldSpecUpdate.richTextNodes, existingRichTextFieldSpec?.richTextNodes, []));
const entityTypes = sortAndRemoveDuplicates(valueOrExistingOrDefault(fieldSpecUpdate.entityTypes, existingRichTextFieldSpec?.entityTypes, []));
const linkEntityTypes = sortAndRemoveDuplicates(valueOrExistingOrDefault(fieldSpecUpdate.linkEntityTypes, existingRichTextFieldSpec?.linkEntityTypes, []));
const componentTypes = sortAndRemoveDuplicates(valueOrExistingOrDefault(fieldSpecUpdate.componentTypes, existingRichTextFieldSpec?.componentTypes, []));
return ok({
name,
type,
list,
required,
adminOnly,
richTextNodes,
entityTypes,
linkEntityTypes,
componentTypes,
});
}
case FieldType.String: {
const existingStringFieldSpec = existingFieldSpec;
const multiline = valueOrExistingOrDefault(fieldSpecUpdate.multiline, existingStringFieldSpec?.multiline, false);
const matchPattern = valueOrExistingOrDefault(fieldSpecUpdate.matchPattern, existingStringFieldSpec?.matchPattern, null);
const values = [
...valueOrExistingOrDefault(fieldSpecUpdate.values, existingStringFieldSpec?.values, []),
].sort((a, b) => a.value.localeCompare(b.value));
removeDuplicatesFromSorted(values, (it) => it.value);
const index = valueOrExistingOrDefault(fieldSpecUpdate.index, existingStringFieldSpec?.index, null);
return ok({
name,
type,
list,
required,
adminOnly,
multiline,
matchPattern,
values,
index,
});
}
case FieldType.Component: {
const existingComponentFieldSpec = existingFieldSpec;
const componentTypes = sortAndRemoveDuplicates(valueOrExistingOrDefault(fieldSpecUpdate.componentTypes, existingComponentFieldSpec?.componentTypes, []));
return ok({
name,
type,
list,
required,
adminOnly,
componentTypes,
});
}
default:
assertExhaustive(type);
}
}
function valueOrExistingOrDefault(update, existing, defaultValue) {
if (update !== undefined)
return update;
if (existing !== undefined)
return existing;
return defaultValue;
}
function sortAndRemoveDuplicates(values) {
if (values.length <= 1) {
return values;
}
const copy = [...values].sort();
removeDuplicatesFromSorted(copy);
return copy;
}
function removeDuplicatesFromSorted(values, predicate = (it) => it) {
for (let i = values.length - 1; i > 0; i--) {
if (predicate(values[i]) === predicate(values[i - 1])) {
values.splice(i, 1);
}
}
}
//# sourceMappingURL=schemaUpdate.js.map