@composedb/devtools
Version:
Development tools for ComposeDB projects.
255 lines (254 loc) • 11.3 kB
JavaScript
import { Model, loadAllModelInterfaces } from '@ceramicnetwork/stream-model';
import { isRelationViewDefinition, promiseMap } from '../utils.js';
import { assertAuthenticatedDID, assertSupportedWriteModelController, assertValidModelInterfaceType, isSignedCommitContainer } from './validation.js';
async function loadCommits(ceramic, id) {
const commits = await ceramic.loadStreamCommits(id);
return commits.map((c)=>c.value).filter(isSignedCommitContainer);
}
function executeCreateFactory(ceramic, modelName, definition) {
return async function executeCreate(executing) {
assertAuthenticatedDID(ceramic);
// Resolve a named dependency to its stream ID
async function getDependencyID(name) {
const existing = executing[name];
if (existing == null) {
throw new Error(`Missing ${name} dependency to create model ${modelName}`);
}
const resolved = await existing;
return resolved.id;
}
const sourceDefinition = definition.model;
const isV1 = sourceDefinition.version === '1.0';
// Flatten the implemented interfaces tree to provide them all in the definition
const implementsPromise = isV1 ? [] : Promise.all(sourceDefinition.implements.map(getDependencyID)).then((ids)=>{
return loadAllModelInterfaces(ceramic, ids);
});
// Resolve named dependencies in relations to their stream ID
const relationsPromise = promiseMap(sourceDefinition.relations ?? {}, async (relation)=>{
return relation.type === 'document' && relation.model !== null ? {
...relation,
model: await getDependencyID(relation.model)
} : relation;
});
// Resolve named dependencies in views to their stream ID when possible, or move the view to the composite
const compositeViews = {};
const viewsPromises = {};
for (const [name, view] of Object.entries(sourceDefinition.views ?? {})){
if (isRelationViewDefinition(view) && view.model !== null) {
const existing = executing[view.model];
if (existing == null) {
compositeViews[name] = view;
} else {
viewsPromises[name] = getDependencyID(view.model).then((model)=>{
return {
...view,
model
};
});
}
} else {
viewsPromises[name] = Promise.resolve(view);
}
}
const [implementIDs, relations, views] = await Promise.all([
implementsPromise,
relationsPromise,
promiseMap(viewsPromises, (viewPromise)=>viewPromise)
]);
// Always convert to a v2 definition
const newDefinition = {
version: '2.0',
name: sourceDefinition.name,
description: sourceDefinition.description,
accountRelation: sourceDefinition.accountRelation,
interface: isV1 ? false : sourceDefinition.interface,
implements: implementIDs,
schema: sourceDefinition.schema,
immutableFields: isV1 ? [] : sourceDefinition.immutableFields,
relations,
views
};
const model = await Model.create(ceramic, newDefinition);
assertSupportedWriteModelController(model, ceramic);
const id = model.id.toString();
return {
id,
name: modelName,
model,
commitsPromise: loadCommits(ceramic, id),
indices: definition.indices ?? [],
views: compositeViews
};
};
}
function executeLoadFactory(ceramic, modelName, definition) {
return async function executeLoad() {
const id = definition.id;
const model = await Model.load(ceramic, id);
assertSupportedWriteModelController(model, ceramic);
assertValidModelInterfaceType(model.content, definition.interface);
return {
id,
name: modelName,
model,
commitsPromise: loadCommits(ceramic, id),
indices: definition.indices ?? [],
views: definition.views
};
};
}
function assertNoCircularDependency(models, targetModel, currentModel = targetModel, visited = []) {
const dependencies = models[currentModel]?.requiredDependencies ?? [];
if (dependencies.includes(targetModel)) {
if (targetModel === currentModel) {
throw new Error(`Unsupported self-referenced dependency on model ${targetModel}`);
} else {
throw new Error(`Circular dependency of model ${targetModel} in model ${currentModel}`);
}
}
if (visited.includes(currentModel)) {
throw new Error(`Circular dependency of model ${currentModel} in visited models: ${visited.join(', ')}`);
}
for (const model of dependencies){
assertNoCircularDependency(models, targetModel, model, [
...visited,
currentModel
]);
}
}
function assertValidSetRelationReference(modelID, refModelID, refModel, property) {
if (refModel.version === '1.0' || refModel.accountRelation.type !== 'set') {
throw new Error(`Invalid view referencing model ${refModelID} on model ${modelID}: expected "set" account relation`);
}
if (!refModel.accountRelation.fields.includes(property)) {
throw new Error(`Invalid property ${property} set for view on model ${modelID}: ${property} is not defined as a "set" account relation field`);
}
const relation = refModel.relations?.[property];
if (relation == null || relation.type !== 'document' || relation.model !== null && relation.model !== refModelID) {
throw new Error(`Invalid property ${property} set for view on model ${modelID}: ${property} must define a relation to model ${refModelID}`);
}
}
export async function createIntermediaryCompositeDefinition(ceramic, models) {
const toResolve = {};
for (const [modelName, definition] of Object.entries(models)){
if (definition.action === 'load') {
toResolve[modelName] = {
requiredDependencies: [],
viewDependencies: [],
name: modelName,
execute: executeLoadFactory(ceramic, modelName, definition)
};
} else if (definition.action === 'create') {
const isInterface = definition.model.version !== '1.0' && definition.model.interface;
const requiredDependencies = new Set();
const viewDependencies = new Set();
if (definition.model.version !== '1.0') {
for (const dependency of definition.model.implements){
requiredDependencies.add(dependency);
}
}
for (const relation of Object.values(definition.model.relations ?? {})){
if (relation.type === 'document' && relation.model !== null) {
if (relation.model === modelName) {
throw new Error(`Unsupported self-reference relation on model ${modelName}`);
}
requiredDependencies.add(relation.model);
}
}
for (const view of Object.values(definition.model.views ?? {})){
if (isRelationViewDefinition(view) && view.model !== null) {
if (isInterface) {
// Views must be present in the model definition of interfaces
requiredDependencies.add(view.model);
} else {
viewDependencies.add(view.model);
}
}
}
toResolve[modelName] = {
requiredDependencies: Array.from(requiredDependencies),
viewDependencies: Array.from(viewDependencies),
name: modelName,
execute: executeCreateFactory(ceramic, modelName, definition)
};
} else {
// @ts-ignore unknown action
throw new Error(`Unsupported action: ${definition.action}`);
}
}
const steps = [
[]
];
const remainingModels = new Set();
// In the first pass, check for circular dependencies and add models with no dependencies to the first execution step
for (const [name, resolve] of Object.entries(toResolve)){
assertNoCircularDependency(toResolve, name);
if (resolve.requiredDependencies.length === 0) {
steps[0].push(resolve);
} else {
remainingModels.add(name);
}
}
// Add steps while there are remaining models to resolve
while(remainingModels.size !== 0){
steps.push([]);
for (const [name, resolve] of Object.entries(toResolve)){
if (!remainingModels.has(name)) {
continue;
}
// Models that have dependencies executed in a previous step can now be executed
const remainingDependencies = resolve.requiredDependencies.filter((model)=>{
return remainingModels.has(model);
});
if (remainingDependencies.length === 0) {
steps[steps.length - 1].push(resolve);
remainingModels.delete(name);
}
}
}
// Run all the execution steps with injected dependencies
const executing = {};
for (const step of steps){
for (const model of step){
executing[model.name] = model.execute(executing);
}
}
const definition = {
commits: {},
models: {},
aliases: {},
indices: {},
views: {}
};
const modelIDs = {};
// Fill the composite definition with the models execution results
await Promise.all(Object.values(executing).map(async (executedPromise)=>{
const res = await executedPromise;
definition.models[res.id] = res.model.content;
definition.aliases[res.id] = res.name;
definition.commits[res.id] = await res.commitsPromise;
definition.indices[res.id] = res.indices;
definition.views[res.id] = res.views;
modelIDs[res.name] = res.id;
}));
// Replace referenced models in composite views by their ID after all models are resolved
for (const [modelID, modelViews] of Object.entries(definition.views)){
for (const view of Object.values(modelViews)){
if (isRelationViewDefinition(view) && view.model !== null) {
const id = modelIDs[view.model];
if (id == null) {
throw new Error(`ID not found for referenced model ${view.model}`);
}
view.model = id;
if (view.type === 'relationSetFrom') {
const refModel = definition.models[id];
if (refModel == null) {
throw new Error(`Model not found for ID ${id}`);
}
assertValidSetRelationReference(modelID, id, refModel, view.property);
}
}
}
}
return definition;
}