diff --git a/packages/core/src/Canvas.ts b/packages/core/src/Canvas.ts index 42bdad50f..4d03f09f8 100644 --- a/packages/core/src/Canvas.ts +++ b/packages/core/src/Canvas.ts @@ -107,6 +107,12 @@ export class Canvas< }, ) + for (const model of Object.values(messageLog.db.models)) { + if (!model.name.startsWith("$") && model.primaryKey.length !== 1) { + throw new Error("contract models cannot use composite primary keys") + } + } + const db = messageLog.db runtime.db = db diff --git a/packages/core/src/runtime/AbstractRuntime.ts b/packages/core/src/runtime/AbstractRuntime.ts index b4b8ad872..f651d5f15 100644 --- a/packages/core/src/runtime/AbstractRuntime.ts +++ b/packages/core/src/runtime/AbstractRuntime.ts @@ -144,9 +144,6 @@ export abstract class AbstractRuntime { const handleSnapshot = this.handleSnapshot.bind(this) return async function (this: AbstractGossipLog, signedMessage) { - const { id, signature, message, branch } = signedMessage - assert(branch !== undefined, "internal error - expected branch !== undefined") - if (isSession(signedMessage)) { return await handleSession(signedMessage) } else if (isAction(signedMessage)) { diff --git a/packages/core/src/runtime/ContractRuntime.ts b/packages/core/src/runtime/ContractRuntime.ts index d044dff39..b09ff0849 100644 --- a/packages/core/src/runtime/ContractRuntime.ts +++ b/packages/core/src/runtime/ContractRuntime.ts @@ -49,6 +49,10 @@ export class ContractRuntime extends AbstractRuntime { const mergeHandles: Record = {} const modelSchema: ModelSchema = mapValues(modelsUnwrap, (handle) => handle.consume(vm.context.dump)) + assert( + Object.keys(modelSchema).every((key) => !key.startsWith("$")), + "contract model names cannot start with '$'", + ) const cleanupSetupHandles = () => { for (const handle of Object.values(mergeHandles)) { @@ -82,84 +86,92 @@ export class ContractRuntime extends AbstractRuntime { return this.getModelValue(this.#context, model, key) }), set: vm.context.newFunction("set", (modelHandle, valueHandle) => { - // assert(this.#context !== null, "expected this.#modelEntries !== null") - // const model = vm.context.getString(modelHandle) - // assert(this.db.models[model] !== undefined, "model not found") - // const value = this.vm.unwrapValue(valueHandle) as ModelValue - // validateModelValue(this.db.models[model], value) - // const { primaryKey } = this.db.models[model] - // const key = value[primaryKey] as string - // assert(typeof key === "string", "expected value[primaryKey] to be a string") - // this.#context.modelEntries[model][key] = value + assert(this.#context !== null, "expected this.#modelEntries !== null") + const model = vm.context.getString(modelHandle) + assert(this.db.models[model] !== undefined, "model not found") + const value = this.vm.unwrapValue(valueHandle) as ModelValue + validateModelValue(this.db.models[model], value) + const { + primaryKey: [primaryKey], + } = this.db.models[model] + const key = value[primaryKey] as string + assert(typeof key === "string", "expected value[primaryKey] to be a string") + this.#context.modelEntries[model][key] = value }), create: vm.context.newFunction("create", (modelHandle, valueHandle) => { - // assert(this.#context !== null, "expected this.#modelEntries !== null") - // const model = vm.context.getString(modelHandle) - // assert(this.db.models[model] !== undefined, "model not found") - // const value = this.vm.unwrapValue(valueHandle) as ModelValue - // validateModelValue(this.db.models[model], value) - // const { primaryKey } = this.db.models[model] - // const key = value[primaryKey] as string - // assert(typeof key === "string", "expected value[primaryKey] to be a string") - // this.#context.modelEntries[model][key] = value + assert(this.#context !== null, "expected this.#modelEntries !== null") + const model = vm.context.getString(modelHandle) + assert(this.db.models[model] !== undefined, "model not found") + const value = this.vm.unwrapValue(valueHandle) as ModelValue + validateModelValue(this.db.models[model], value) + const { + primaryKey: [primaryKey], + } = this.db.models[model] + const key = value[primaryKey] as string + assert(typeof key === "string", "expected value[primaryKey] to be a string") + this.#context.modelEntries[model][key] = value }), update: vm.context.newFunction("update", (modelHandle, valueHandle) => { - // assert(this.#context !== null, "expected this.#modelEntries !== null") - // const model = vm.context.getString(modelHandle) - // assert(this.db.models[model] !== undefined, "model not found") - // const { primaryKey } = this.db.models[model] - // const value = this.vm.unwrapValue(valueHandle) as ModelValue - // const key = value[primaryKey] as string - // assert(typeof key === "string", "expected value[primaryKey] to be a string") - // const promise = vm.context.newPromise() - // // TODO: Ensure concurrent merges into the same value don't create a race condition - // // if the user doesn't call db.update() with await. - // this.getModelValue(this.#context, model, key) - // .then((previousValue) => { - // const mergedValue = updateModelValues(value, previousValue ?? {}) - // validateModelValue(this.db.models[model], mergedValue) - // assert(this.#context !== null) - // this.#context.modelEntries[model][key] = mergedValue - // promise.resolve() - // }) - // .catch((err) => { - // promise.reject() - // }) - // promise.settled.then(vm.runtime.executePendingJobs) - // return promise.handle + assert(this.#context !== null, "expected this.#modelEntries !== null") + const model = vm.context.getString(modelHandle) + assert(this.db.models[model] !== undefined, "model not found") + const { + primaryKey: [primaryKey], + } = this.db.models[model] + const value = this.vm.unwrapValue(valueHandle) as ModelValue + const key = value[primaryKey] as string + assert(typeof key === "string", "expected value[primaryKey] to be a string") + const promise = vm.context.newPromise() + // TODO: Ensure concurrent merges into the same value don't create a race condition + // if the user doesn't call db.update() with await. + this.getModelValue(this.#context, model, key) + .then((previousValue) => { + const mergedValue = updateModelValues(value, previousValue ?? {}) + validateModelValue(this.db.models[model], mergedValue) + assert(this.#context !== null) + this.#context.modelEntries[model][key] = mergedValue + promise.resolve() + }) + .catch((err) => { + promise.reject() + }) + promise.settled.then(vm.runtime.executePendingJobs) + return promise.handle }), merge: vm.context.newFunction("merge", (modelHandle, valueHandle) => { - // assert(this.#context !== null, "expected this.#modelEntries !== null") - // const model = vm.context.getString(modelHandle) - // assert(this.db.models[model] !== undefined, "model not found") - // const { primaryKey } = this.db.models[model] - // const value = this.vm.unwrapValue(valueHandle) as ModelValue - // const key = value[primaryKey] as string - // assert(typeof key === "string", "expected value[primaryKey] to be a string") - // const promise = vm.context.newPromise() - // // TODO: Ensure concurrent merges into the same value don't create a race condition - // // if the user doesn't call db.merge() with await. - // this.getModelValue(this.#context, model, key) - // .then((previousValue) => { - // const mergedValue = mergeModelValues(value, previousValue ?? {}) - // validateModelValue(this.db.models[model], mergedValue) - // assert(this.#context !== null) - // this.#context.modelEntries[model][key] = mergedValue - // promise.resolve() - // }) - // .catch((err) => { - // promise.reject() - // }) - // promise.settled.then(vm.runtime.executePendingJobs) - // return promise.handle + assert(this.#context !== null, "expected this.#modelEntries !== null") + const model = vm.context.getString(modelHandle) + assert(this.db.models[model] !== undefined, "model not found") + const { + primaryKey: [primaryKey], + } = this.db.models[model] + const value = this.vm.unwrapValue(valueHandle) as ModelValue + const key = value[primaryKey] as string + assert(typeof key === "string", "expected value[primaryKey] to be a string") + const promise = vm.context.newPromise() + // TODO: Ensure concurrent merges into the same value don't create a race condition + // if the user doesn't call db.merge() with await. + this.getModelValue(this.#context, model, key) + .then((previousValue) => { + const mergedValue = mergeModelValues(value, previousValue ?? {}) + validateModelValue(this.db.models[model], mergedValue) + assert(this.#context !== null) + this.#context.modelEntries[model][key] = mergedValue + promise.resolve() + }) + .catch((err) => { + promise.reject() + }) + promise.settled.then(vm.runtime.executePendingJobs) + return promise.handle }), delete: vm.context.newFunction("delete", (modelHandle, keyHandle) => { - // assert(this.#context !== null, "expected this.#modelEntries !== null") - // const model = vm.context.getString(modelHandle) - // assert(this.db.models[model] !== undefined, "model not found") - // const key = vm.context.getString(keyHandle) - // this.#context.modelEntries[model][key] = null + assert(this.#context !== null, "expected this.#modelEntries !== null") + const model = vm.context.getString(modelHandle) + assert(this.db.models[model] !== undefined, "model not found") + const key = vm.context.getString(keyHandle) + this.#context.modelEntries[model][key] = null }), }) .consume(vm.cache) diff --git a/packages/core/src/runtime/FunctionRuntime.ts b/packages/core/src/runtime/FunctionRuntime.ts index 5e7ba51d4..cb7b382e8 100644 --- a/packages/core/src/runtime/FunctionRuntime.ts +++ b/packages/core/src/runtime/FunctionRuntime.ts @@ -23,6 +23,10 @@ export class FunctionRuntime extends AbstractRuntim ): Promise> { assert(contract.actions !== undefined, "contract initialized without actions") assert(contract.models !== undefined, "contract initialized without models") + assert( + Object.keys(contract.models).every((key) => !key.startsWith("$")), + "contract model names cannot start with '$'", + ) const schema = AbstractRuntime.getModelSchema(contract.models) return new FunctionRuntime(topic, signers, schema, contract.actions) @@ -69,54 +73,54 @@ export class FunctionRuntime extends AbstractRuntim // Create a backlink from the model `linkModel` where pk=`linkPrimaryKey` // to point at the model we just created or updated. promise.link = async (linkModel: string, linkPrimaryKey: string, params?: { through: string }) => { - // await this.acquireLock() - // try { - // assert(this.#context !== null, "expected this.#context !== null") - // const { primaryKey } = this.db.models[model] - // const target = isSelect ? (value as string) : ((value as ModelValue)[primaryKey] as string) - // const modelValue = await this.getModelValue(this.#context, linkModel, linkPrimaryKey) - // assert(modelValue !== null, `db.link(): link from a missing model ${linkModel}.get(${linkPrimaryKey})`) - // const backlinkKey = params?.through ?? model - // const backlinkProp = this.db.models[linkModel].properties.find((prop) => prop.name === backlinkKey) - // assert(backlinkProp !== undefined, `db.link(): link from ${linkModel} used missing property ${backlinkKey}`) - // if (backlinkProp.kind === "relation") { - // const current = (modelValue[backlinkKey] ?? []) as RelationValue - // modelValue[backlinkKey] = current.includes(target) ? current : [...current, target] - // } else { - // throw new Error(`db.link(): link from ${linkModel} ${backlinkKey} must be a relation`) - // } - // validateModelValue(this.db.models[linkModel], modelValue) - // this.#context.modelEntries[linkModel][linkPrimaryKey] = modelValue - // } finally { - // this.releaseLock() - // } + await this.acquireLock() + try { + assert(this.#context !== null, "expected this.#context !== null") + const { primaryKey } = this.db.models[model] + const target = isSelect ? (value as string) : ((value as ModelValue)[primaryKey] as string) + const modelValue = await this.getModelValue(this.#context, linkModel, linkPrimaryKey) + assert(modelValue !== null, `db.link(): link from a missing model ${linkModel}.get(${linkPrimaryKey})`) + const backlinkKey = params?.through ?? model + const backlinkProp = this.db.models[linkModel].properties.find((prop) => prop.name === backlinkKey) + assert(backlinkProp !== undefined, `db.link(): link from ${linkModel} used missing property ${backlinkKey}`) + if (backlinkProp.kind === "relation") { + const current = (modelValue[backlinkKey] ?? []) as RelationValue + modelValue[backlinkKey] = current.includes(target) ? current : [...current, target] + } else { + throw new Error(`db.link(): link from ${linkModel} ${backlinkKey} must be a relation`) + } + validateModelValue(this.db.models[linkModel], modelValue) + this.#context.modelEntries[linkModel][linkPrimaryKey] = modelValue + } finally { + this.releaseLock() + } } promise.unlink = async (linkModel: string, linkPrimaryKey: string, params?: { through: string }) => { - // await this.acquireLock() - // try { - // assert(this.#context !== null, "expected this.#context !== null") - // const { primaryKey } = this.db.models[model] - // const target = isSelect ? (value as string) : ((value as ModelValue)[primaryKey] as string) - // const modelValue = await this.getModelValue(this.#context, linkModel, linkPrimaryKey) - // assert(modelValue !== null, `db.unlink(): called on a missing model ${linkModel}.get(${linkPrimaryKey})`) - // const backlinkKey = params?.through ?? model - // const backlinkProp = this.db.models[linkModel].properties.find((prop) => prop.name === backlinkKey) - // assert( - // backlinkProp !== undefined, - // `db.unlink(): called on ${linkModel} used missing property ${backlinkKey}`, - // ) - // if (backlinkProp.kind === "relation") { - // const current = (modelValue[backlinkKey] ?? []) as RelationValue - // modelValue[backlinkKey] = current.filter((item) => item !== target) - // } else { - // throw new Error(`db.unlink(): link from ${linkModel} ${backlinkKey} must be a relation`) - // } - // validateModelValue(this.db.models[linkModel], modelValue) - // this.#context.modelEntries[linkModel][linkPrimaryKey] = modelValue - // } finally { - // this.releaseLock() - // } + await this.acquireLock() + try { + assert(this.#context !== null, "expected this.#context !== null") + const { primaryKey } = this.db.models[model] + const target = isSelect ? (value as string) : ((value as ModelValue)[primaryKey] as string) + const modelValue = await this.getModelValue(this.#context, linkModel, linkPrimaryKey) + assert(modelValue !== null, `db.unlink(): called on a missing model ${linkModel}.get(${linkPrimaryKey})`) + const backlinkKey = params?.through ?? model + const backlinkProp = this.db.models[linkModel].properties.find((prop) => prop.name === backlinkKey) + assert( + backlinkProp !== undefined, + `db.unlink(): called on ${linkModel} used missing property ${backlinkKey}`, + ) + if (backlinkProp.kind === "relation") { + const current = (modelValue[backlinkKey] ?? []) as RelationValue + modelValue[backlinkKey] = current.filter((item) => item !== target) + } else { + throw new Error(`db.unlink(): link from ${linkModel} ${backlinkKey} must be a relation`) + } + validateModelValue(this.db.models[linkModel], modelValue) + this.#context.modelEntries[linkModel][linkPrimaryKey] = modelValue + } finally { + this.releaseLock() + } } return promise @@ -135,81 +139,81 @@ export class FunctionRuntime extends AbstractRuntim }, select: getChainableMethod(async (model, key: string) => {}, true), set: getChainableMethod(async (model, value) => { - // await this.acquireLock() - // try { - // assert(this.#context !== null, "expected this.#context !== null") - // validateModelValue(this.db.models[model], value) - // const { primaryKey } = this.db.models[model] - // assert(primaryKey in value, `db.set(${model}): missing primary key ${primaryKey}`) - // assert(primaryKey !== null && primaryKey !== undefined, `db.set(${model}): ${primaryKey} primary key`) - // const key = (value as ModelValue)[primaryKey] as string - // this.#context.modelEntries[model][key] = value - // } finally { - // this.releaseLock() - // } + await this.acquireLock() + try { + assert(this.#context !== null, "expected this.#context !== null") + validateModelValue(this.db.models[model], value) + const { primaryKey } = this.db.models[model] + assert(primaryKey in value, `db.set(${model}): missing primary key ${primaryKey}`) + assert(primaryKey !== null && primaryKey !== undefined, `db.set(${model}): ${primaryKey} primary key`) + const key = (value as ModelValue)[primaryKey] as string + this.#context.modelEntries[model][key] = value + } finally { + this.releaseLock() + } }), create: getChainableMethod(async (model, value) => { - // await this.acquireLock() - // try { - // assert(this.#context !== null, "expected this.#context !== null") - // validateModelValue(this.db.models[model], value) - // const { primaryKey } = this.db.models[model] - // assert(primaryKey in value, `db.create(${model}): missing primary key ${primaryKey}`) - // assert(primaryKey !== null && primaryKey !== undefined, `db.create(${model}): ${primaryKey} primary key`) - // const key = (value as ModelValue)[primaryKey] as string - // this.#context.modelEntries[model][key] = value - // } finally { - // this.releaseLock() - // } + await this.acquireLock() + try { + assert(this.#context !== null, "expected this.#context !== null") + validateModelValue(this.db.models[model], value) + const { primaryKey } = this.db.models[model] + assert(primaryKey in value, `db.create(${model}): missing primary key ${primaryKey}`) + assert(primaryKey !== null && primaryKey !== undefined, `db.create(${model}): ${primaryKey} primary key`) + const key = (value as ModelValue)[primaryKey] as string + this.#context.modelEntries[model][key] = value + } finally { + this.releaseLock() + } }), update: getChainableMethod(async (model, value) => { - // await this.acquireLock() - // try { - // assert(this.#context !== null, "expected this.#context !== null") - // const { primaryKey } = this.db.models[model] - // assert(primaryKey in value, `db.update(${model}): missing primary key ${primaryKey}`) - // assert(primaryKey !== null && primaryKey !== undefined, `db.update(${model}): ${primaryKey} primary key`) - // const key = (value as ModelValue)[primaryKey] as string - // const modelValue = await this.getModelValue(this.#context, model, key) - // if (modelValue === null) { - // console.log(`db.update(${model}, ${key}): attempted to update a nonexistent value`) - // return - // } - // const mergedValue = updateModelValues(value as ModelValue, modelValue ?? {}) - // validateModelValue(this.db.models[model], mergedValue) - // this.#context.modelEntries[model][key] = mergedValue - // } finally { - // this.releaseLock() - // } + await this.acquireLock() + try { + assert(this.#context !== null, "expected this.#context !== null") + const { primaryKey } = this.db.models[model] + assert(primaryKey in value, `db.update(${model}): missing primary key ${primaryKey}`) + assert(primaryKey !== null && primaryKey !== undefined, `db.update(${model}): ${primaryKey} primary key`) + const key = (value as ModelValue)[primaryKey] as string + const modelValue = await this.getModelValue(this.#context, model, key) + if (modelValue === null) { + console.log(`db.update(${model}, ${key}): attempted to update a nonexistent value`) + return + } + const mergedValue = updateModelValues(value as ModelValue, modelValue ?? {}) + validateModelValue(this.db.models[model], mergedValue) + this.#context.modelEntries[model][key] = mergedValue + } finally { + this.releaseLock() + } }), merge: getChainableMethod(async (model, value) => { - // await this.acquireLock() - // try { - // assert(this.#context !== null, "expected this.#context !== null") - // const { primaryKey } = this.db.models[model] - // assert(primaryKey in value, `db.merge(${model}): missing primary key ${primaryKey}`) - // assert(primaryKey !== null && primaryKey !== undefined, `db.merge(${model}): ${primaryKey} primary key`) - // const key = (value as ModelValue)[primaryKey] as string - // const modelValue = await this.getModelValue(this.#context, model, key) - // if (modelValue === null) { - // console.log(`db.merge(${model}, ${key}): attempted to merge into a nonexistent value`) - // return - // } - // const mergedValue = mergeModelValues(value as ModelValue, modelValue ?? {}) - // validateModelValue(this.db.models[model], mergedValue) - // this.#context.modelEntries[model][key] = mergedValue - // } finally { - // this.releaseLock() - // } + await this.acquireLock() + try { + assert(this.#context !== null, "expected this.#context !== null") + const { primaryKey } = this.db.models[model] + assert(primaryKey in value, `db.merge(${model}): missing primary key ${primaryKey}`) + assert(primaryKey !== null && primaryKey !== undefined, `db.merge(${model}): ${primaryKey} primary key`) + const key = (value as ModelValue)[primaryKey] as string + const modelValue = await this.getModelValue(this.#context, model, key) + if (modelValue === null) { + console.log(`db.merge(${model}, ${key}): attempted to merge into a nonexistent value`) + return + } + const mergedValue = mergeModelValues(value as ModelValue, modelValue ?? {}) + validateModelValue(this.db.models[model], mergedValue) + this.#context.modelEntries[model][key] = mergedValue + } finally { + this.releaseLock() + } }), delete: async (model: string, key: string) => { - // await this.acquireLock() - // try { - // assert(this.#context !== null, "expected this.#context !== null") - // this.#context.modelEntries[model][key] = null - // } finally { - // this.releaseLock() - // } + await this.acquireLock() + try { + assert(this.#context !== null, "expected this.#context !== null") + this.#context.modelEntries[model][key] = null + } finally { + this.releaseLock() + } }, } } diff --git a/packages/core/test/canvas.test.ts b/packages/core/test/canvas.test.ts index ae53cad87..38fe135f8 100644 --- a/packages/core/test/canvas.test.ts +++ b/packages/core/test/canvas.test.ts @@ -188,10 +188,10 @@ test("accept a manually encoded session/action with a legacy-style object arg", contract: { actions: { createMessage(db, arg) { - t.deepEqual(arg, { objectArg: '1' }) - } + t.deepEqual(arg, { objectArg: "1" }) + }, }, - models: {} + models: {}, }, topic: "com.example.app", reset: true, @@ -219,8 +219,8 @@ test("accept a manually encoded session/action with a legacy-style object arg", type: "action", did: sessionMessage.payload.did, name: "createMessage", - args: { objectArg: '1' }, - context: { timestamp: 0 } + args: { objectArg: "1" }, + context: { timestamp: 0 }, }, } const actionSignature = await session.signer.sign(actionMessage)