From 755e2fd534ebb1599c02500d0d91835f6ad10691 Mon Sep 17 00:00:00 2001 From: Serhii Volovyk Date: Tue, 7 Mar 2023 10:38:51 +0200 Subject: [PATCH] Migration example (#352) state migration --- .../__tests__/test-state-migration.ava.js | 38 ++++++++++ examples/package.json | 4 ++ examples/src/state-migration-new.ts | 71 +++++++++++++++++++ examples/src/state-migration-original.ts | 30 ++++++++ .../cli/build-tools/near-bindgen-exporter.js | 13 ++-- packages/near-sdk-js/lib/near-bindgen.d.ts | 6 ++ packages/near-sdk-js/lib/near-bindgen.js | 11 +++ .../cli/build-tools/near-bindgen-exporter.ts | 17 +++-- packages/near-sdk-js/src/near-bindgen.ts | 15 ++++ tests/__tests__/decorators/migrate.ava.js | 39 ++++++++++ tests/package.json | 2 + tests/src/migrate.ts | 23 ++++++ 12 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 examples/__tests__/test-state-migration.ava.js create mode 100644 examples/src/state-migration-new.ts create mode 100644 examples/src/state-migration-original.ts create mode 100644 tests/__tests__/decorators/migrate.ava.js create mode 100644 tests/src/migrate.ts diff --git a/examples/__tests__/test-state-migration.ava.js b/examples/__tests__/test-state-migration.ava.js new file mode 100644 index 000000000..a7d79d899 --- /dev/null +++ b/examples/__tests__/test-state-migration.ava.js @@ -0,0 +1,38 @@ +import { Worker } from "near-workspaces"; +import test from "ava"; + +test.beforeEach(async (t) => { + const worker = await Worker.init(); + const root = worker.rootAccount; + const contract = await root.devDeploy("./build/state-migration-original.wasm"); + + const ali = await root.createSubAccount("ali"); + + t.context.worker = worker; + t.context.accounts = { root, contract, ali }; +}); + +test.afterEach.always(async (t) => { + await t.context.worker.tearDown().catch((error) => { + console.log("Failed to tear down the worker:", error); + }); +}); + +test("migration works", async (t) => { + const { contract, ali } = t.context.accounts; + + await ali.call(contract, "addMessage", { message: { sender: "ali", header: "h1", text: "hello" } }); + await ali.call(contract, "addMessage", { message: { sender: "ali", header: "h2", text: "world" } }); + await ali.call(contract, "addMessage", { message: { sender: "ali", header: "h3", text: "This message is too log for new standard" } }); + await ali.call(contract, "addMessage", { message: { sender: "ali", header: "h4", text: "!" } }); + + const res1 = await contract.view("countMessages", {}); + t.is(res1, 4); + + contract.deploy("./build/state-migration-new.wasm"); + + await ali.call(contract, "migrateState", {}); + + const res2 = await contract.view("countMessages", {}); + t.is(res2, 3); +}); diff --git a/examples/package.json b/examples/package.json index fa83e7ded..e9f60fe77 100644 --- a/examples/package.json +++ b/examples/package.json @@ -25,6 +25,9 @@ "build:nft-receiver": "near-sdk-js build src/standard-nft/test-token-receiver.ts build/nft-receiver.wasm", "build:nft-approval-receiver": "near-sdk-js build src/standard-nft/test-approval-receiver.ts build/nft-approval-receiver.wasm", "build:ft": "near-sdk-js build src/standard-ft/my-ft.ts build/my-ft.wasm", + "build:state-migration": "run-s build:state-migration:*", + "build:state-migration:original": "near-sdk-js build src/state-migration-original.ts build/state-migration-original.wasm", + "build:state-migration:new": "near-sdk-js build src/state-migration-new.ts build/state-migration-new.wasm", "test": "ava && pnpm test:counter-lowlevel && pnpm test:counter-ts", "test:nft": "ava __tests__/standard-nft/*", "test:ft": "ava __tests__/standard-ft/*", @@ -41,6 +44,7 @@ "test:status-message-collections": "ava __tests__/test-status-message-collections.ava.js", "test:parking-lot": "ava __tests__/test-parking-lot.ava.js", "test:programmatic-update": "ava __tests__/test-programmatic-update.ava.js", + "test:state-migration": "ava __tests__/test-state-migration.ava.js", "test:nested-collections": "ava __tests__/test-nested-collections.ava.js" }, "author": "Near Inc ", diff --git a/examples/src/state-migration-new.ts b/examples/src/state-migration-new.ts new file mode 100644 index 000000000..6b0f91052 --- /dev/null +++ b/examples/src/state-migration-new.ts @@ -0,0 +1,71 @@ +import { NearBindgen, view, near, migrate, call, Vector, assert } from 'near-sdk-js' +import { AccountId } from 'near-sdk-js/lib/types' + +type OldMessageFormat = { + sender: AccountId, + header: string, + text: string, +} + +// This is the new version of the Message type, it has an additional field +type NewMessageFormat = { + sender: AccountId, + recipient?: AccountId, + header: string, + text: string, +} + +@NearBindgen({}) +export class MigrationDemo { + messages: Vector; + + constructor() { + this.messages = new Vector('messages'); + } + + @call({ payableFunction: true }) + addMessage({ message }: { + message: NewMessageFormat + }): void { + this.messages.push(message); + near.log(`${near.signerAccountId()} added message ${JSON.stringify(message)}`); + } + + @view({}) + countMessages(): number { + return this.messages.toArray().length; + } + + + @migrate({}) + migrateState(): Vector { + assert(this.messages.toArray().length == 0, "Contract state should not be deserialized in @migrate"); + // retrieve the current state from the contract + let raw_vector = JSON.parse(near.storageRead("STATE")).messages; + let old_messages: Vector = new Vector(raw_vector.prefix, raw_vector.length); + near.log("old_messages: " + JSON.stringify(old_messages)); + + // iterate through the state migrating it to the new version + let new_messages: Vector = new Vector('messages'); + + for (let old_message of old_messages) { + near.log(`migrating ${JSON.stringify(old_message)}`); + const new_message: NewMessageFormat = { + sender: old_message.sender, + recipient: "Unknown", + header: old_message.header, + text: old_message.text, + }; + if (new_message.text.length < 10) { + near.log(`adding ${new_message} to new_messages`); + new_messages.push(new_message); + } else { + near.log(`${new_message} is too long, skipping`); + } + } + + this.messages = new_messages; + + return this.messages; + } +} \ No newline at end of file diff --git a/examples/src/state-migration-original.ts b/examples/src/state-migration-original.ts new file mode 100644 index 000000000..6ea99335a --- /dev/null +++ b/examples/src/state-migration-original.ts @@ -0,0 +1,30 @@ +import { NearBindgen, view, near, migrate, call, Vector, assert } from 'near-sdk-js' +import { AccountId } from 'near-sdk-js/lib/types' + +type Message = { + sender: AccountId, + header: string, + text: string, +} + +@NearBindgen({}) +export class MigrationDemo { + messages: Vector; + + constructor() { + this.messages = new Vector('messages'); + } + + @call({ payableFunction: true }) + addMessage({ message }: { + message: Message + }): void { + this.messages.push(message); + near.log(`${near.signerAccountId()} added message ${JSON.stringify(message)}`); + } + + @view({}) + countMessages(): number { + return this.messages.toArray().length; + } +} \ No newline at end of file diff --git a/packages/near-sdk-js/lib/cli/build-tools/near-bindgen-exporter.js b/packages/near-sdk-js/lib/cli/build-tools/near-bindgen-exporter.js index 3e30d919e..6cd1d9975 100644 --- a/packages/near-sdk-js/lib/cli/build-tools/near-bindgen-exporter.js +++ b/packages/near-sdk-js/lib/cli/build-tools/near-bindgen-exporter.js @@ -4,7 +4,7 @@ const { Signale } = signal; /** * A list of supported method types/decorators. */ -const methodTypes = ["call", "view", "initialize"]; +const methodTypes = ["call", "view", "initialize", "migrate"]; /** * A helper function that inserts a new throw Error statement with * the passed message. @@ -26,7 +26,12 @@ function throwError(message) { * * @param classId - The class ID of the class which we are extending. */ -function readState(classId) { +function readState(classId, methodType) { + if (methodType === "migrate") { + return t.variableDeclaration("const", [ + t.variableDeclarator(t.identifier("_state"), t.nullLiteral()), + ]); + } return t.variableDeclaration("const", [ t.variableDeclarator(t.identifier("_state"), t.callExpression(t.memberExpression(classId, t.identifier("_getState")), [])), ]); @@ -145,7 +150,7 @@ function callContractMethod(methodName) { * @param methodType - The type of the method being called. */ function saveToStorage(classId, methodType) { - if (!["initialize", "call"].includes(methodType)) { + if (!["initialize", "call", "migrate"].includes(methodType)) { return t.emptyStatement(); } return t.expressionStatement(t.callExpression(t.memberExpression(classId, t.identifier("_saveToStorage")), [t.identifier("_contract")])); @@ -183,7 +188,7 @@ function createDeclaration(classId, methodName, methodType) { return t.exportNamedDeclaration(t.functionDeclaration(t.identifier(methodName), [], t.blockStatement([ // Read the state of the contract from storage. // const _state = Contract._getState(); - readState(classId), + readState(classId, methodType), // Throw if initialized on any subsequent init function calls. // if (_state) { throw new Error('Contract already initialized'); } preventDoubleInit(methodType), diff --git a/packages/near-sdk-js/lib/near-bindgen.d.ts b/packages/near-sdk-js/lib/near-bindgen.d.ts index bf60f5292..8bede8265 100644 --- a/packages/near-sdk-js/lib/near-bindgen.d.ts +++ b/packages/near-sdk-js/lib/near-bindgen.d.ts @@ -1,5 +1,11 @@ declare type EmptyParameterObject = Record; declare type DecoratorFunction = any>(target: object, key: string | symbol, descriptor: TypedPropertyDescriptor) => void; +/** + * Tells the SDK to use this function as the migration function of the contract. + * The migration function will ignore te existing state. + * @param _empty - An empty object. + */ +export declare function migrate(_empty: EmptyParameterObject): DecoratorFunction; /** * Tells the SDK to use this function as the initialization function of the contract. * diff --git a/packages/near-sdk-js/lib/near-bindgen.js b/packages/near-sdk-js/lib/near-bindgen.js index d5d8eb3af..a7b4792f8 100644 --- a/packages/near-sdk-js/lib/near-bindgen.js +++ b/packages/near-sdk-js/lib/near-bindgen.js @@ -1,5 +1,16 @@ import * as near from "./api"; import { deserialize, serialize, bytes, encode } from "./utils"; +/** + * Tells the SDK to use this function as the migration function of the contract. + * The migration function will ignore te existing state. + * @param _empty - An empty object. + */ +export function migrate(_empty) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function (_target, _key, _descriptor + // eslint-disable-next-line @typescript-eslint/no-empty-function + ) { }; +} /** * Tells the SDK to use this function as the initialization function of the contract. * diff --git a/packages/near-sdk-js/src/cli/build-tools/near-bindgen-exporter.ts b/packages/near-sdk-js/src/cli/build-tools/near-bindgen-exporter.ts index 8e8e733e7..922266d40 100644 --- a/packages/near-sdk-js/src/cli/build-tools/near-bindgen-exporter.ts +++ b/packages/near-sdk-js/src/cli/build-tools/near-bindgen-exporter.ts @@ -8,7 +8,7 @@ const { Signale } = signal; /** * A list of supported method types/decorators. */ -const methodTypes = ["call", "view", "initialize"]; +const methodTypes = ["call", "view", "initialize", "migrate"]; /** * A helper function that inserts a new throw Error statement with @@ -34,7 +34,16 @@ function throwError(message: string): t.BlockStatement { * * @param classId - The class ID of the class which we are extending. */ -function readState(classId: t.Identifier): t.VariableDeclaration { +function readState(classId: t.Identifier, methodType: string): t.VariableDeclaration { + if (methodType === "migrate") { + return t.variableDeclaration("const", [ + t.variableDeclarator( + t.identifier("_state"), + t.nullLiteral() + ), + ]); + } + return t.variableDeclaration("const", [ t.variableDeclarator( t.identifier("_state"), @@ -216,7 +225,7 @@ function saveToStorage( classId: t.Identifier, methodType: string ): t.EmptyStatement | t.ExpressionStatement { - if (!["initialize", "call"].includes(methodType)) { + if (!["initialize", "call", "migrate"].includes(methodType)) { return t.emptyStatement(); } @@ -315,7 +324,7 @@ function createDeclaration( t.blockStatement([ // Read the state of the contract from storage. // const _state = Contract._getState(); - readState(classId), + readState(classId, methodType), // Throw if initialized on any subsequent init function calls. // if (_state) { throw new Error('Contract already initialized'); } preventDoubleInit(methodType), diff --git a/packages/near-sdk-js/src/near-bindgen.ts b/packages/near-sdk-js/src/near-bindgen.ts index 27ea841fd..44211950a 100644 --- a/packages/near-sdk-js/src/near-bindgen.ts +++ b/packages/near-sdk-js/src/near-bindgen.ts @@ -10,6 +10,21 @@ type DecoratorFunction = any>( descriptor: TypedPropertyDescriptor ) => void; +/** + * Tells the SDK to use this function as the migration function of the contract. + * The migration function will ignore te existing state. + * @param _empty - An empty object. + */ +export function migrate(_empty: EmptyParameterObject): DecoratorFunction { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function any>( + _target: object, + _key: string | symbol, + _descriptor: TypedPropertyDescriptor + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): void {}; +} + /** * Tells the SDK to use this function as the initialization function of the contract. * diff --git a/tests/__tests__/decorators/migrate.ava.js b/tests/__tests__/decorators/migrate.ava.js new file mode 100644 index 000000000..eac04d392 --- /dev/null +++ b/tests/__tests__/decorators/migrate.ava.js @@ -0,0 +1,39 @@ +import { Worker } from "near-workspaces"; +import test from "ava"; + +test.beforeEach(async (t) => { + const worker = await Worker.init(); + const root = worker.rootAccount; + const counter = await root.devDeploy("./build/migrate.wasm"); + + const ali = await root.createSubAccount("ali"); + + t.context.worker = worker; + t.context.accounts = { root, counter, ali }; +}); + +// If the environment is reused, use test.after to replace test.afterEach +test.afterEach.always(async (t) => { + await t.context.worker.tearDown().catch((error) => { + console.log("Failed to tear down the worker:", error); + }); +}); + +test("migration works", async (t) => { + const { counter, ali } = t.context.accounts; + + const res1 = await counter.view("getCount", {}); + t.is(res1, 0); + + await ali.call(counter, "increase", {}); + + const res2 = await counter.view("getCount", {}); + t.is(res2, 1); + + const migrationRes = await ali.callRaw(counter, "migrFuncValueTo18", {}); + + t.is(JSON.stringify(migrationRes).includes("Count: 0"), true); + + const res3 = await counter.view("getCount", {}); + t.is(res3, 18); +}); diff --git a/tests/package.json b/tests/package.json index 06d6c99f7..2e1b7ac28 100644 --- a/tests/package.json +++ b/tests/package.json @@ -28,6 +28,7 @@ "build:bigint-serialization": "near-sdk-js build src/bigint-serialization.ts build/bigint-serialization.wasm", "build:date-serialization": "near-sdk-js build src/date-serialization.ts build/date-serialization.wasm", "build:middlewares": "near-sdk-js build src/middlewares.ts build/middlewares.wasm", + "build:migrate": "near-sdk-js build src/migrate.ts build/migrate.wasm", "test": "ava", "test:context-api": "ava __tests__/test_context_api.ava.js", "test:math-api": "ava __tests__/test_math_api.ava.js", @@ -51,6 +52,7 @@ "test:date-serialization": "ava __tests__/test-date-serialization.ava.js", "test:serialization": "ava __tests__/test-serialization.ava.js", "test:constructor-validation": "ava __tests__/constructor_validation.ava.js", + "test:migrate": "ava __tests__/decorators/migrate.ava.js", "test:middlewares": "ava __tests__/test-middlewares.ava.js" }, "author": "Near Inc ", diff --git a/tests/src/migrate.ts b/tests/src/migrate.ts new file mode 100644 index 000000000..c91ab5fd5 --- /dev/null +++ b/tests/src/migrate.ts @@ -0,0 +1,23 @@ +import { NearBindgen, near, call, view, migrate } from "near-sdk-js"; + +@NearBindgen({}) +export class Counter { + count = 0; + + @call({}) + increase({ n = 1 }: { n: number }) { + this.count += n; + near.log(`Counter increased to ${this.count}`); + } + + @view({}) + getCount(): number { + return this.count; + } + + @migrate({}) + migrFuncValueTo18(): void { + near.log("Count: " + this.count); // expected to be 0 + this.count = 18; + } +}