From 373e79a1f040f5ba85ff6f7ff35265a71fc39001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=A5=E5=9B=BD=E5=AE=87?= <841185308@qq.com> Date: Thu, 5 Jan 2023 14:24:33 +0800 Subject: [PATCH] feat: Implement store model (#51) * feat: Implement store model 1. JSON storage 2. Pattern define 3. Store implement --- .eslintrc.js | 2 +- package-lock.json | 16 +- .../models/__tests__/store/json-storage.ts | 169 +++++++ packages/models/__tests__/store/store.ts | 415 ++++++++++++++++++ packages/models/package.json | 2 + packages/models/src/decorator/constants.ts | 1 + packages/models/src/decorator/pattern.ts | 23 + packages/models/src/exceptions/index.ts | 1 + packages/models/src/exceptions/store.ts | 29 ++ packages/models/src/index.ts | 1 + packages/models/src/store/chain-storage.ts | 10 + packages/models/src/store/index.ts | 4 + packages/models/src/store/interface.ts | 101 +++++ packages/models/src/store/json-storage.ts | 78 ++++ packages/models/src/store/store.ts | 249 +++++++++++ 15 files changed, 1094 insertions(+), 7 deletions(-) create mode 100644 packages/models/__tests__/store/json-storage.ts create mode 100644 packages/models/__tests__/store/store.ts create mode 100644 packages/models/src/decorator/constants.ts create mode 100644 packages/models/src/decorator/pattern.ts create mode 100644 packages/models/src/exceptions/index.ts create mode 100644 packages/models/src/exceptions/store.ts create mode 100644 packages/models/src/store/chain-storage.ts create mode 100644 packages/models/src/store/interface.ts create mode 100644 packages/models/src/store/json-storage.ts create mode 100644 packages/models/src/store/store.ts diff --git a/.eslintrc.js b/.eslintrc.js index b8303526..beda2776 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,6 +12,6 @@ module.exports = { }, ], '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/explicit-module-boundary-types': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'off', }, } diff --git a/package-lock.json b/package-lock.json index 2787d003..6787173e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3954,9 +3954,9 @@ "dev": true }, "node_modules/bignumber.js": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", - "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz", + "integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==", "engines": { "node": "*" } @@ -12955,6 +12955,8 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@ckb-lumos/codec": "0.19.0", + "bignumber.js": "9.1.1", "inversify": "6.0.1", "ioredis": "5.2.4", "reflect-metadata": "0.1.13", @@ -13984,7 +13986,9 @@ "@kuai/models": { "version": "file:packages/models", "requires": { + "@ckb-lumos/codec": "0.19.0", "@jest/globals": "29.3.1", + "bignumber.js": "9.1.1", "inversify": "6.0.1", "ioredis": "5.2.4", "reflect-metadata": "0.1.13", @@ -16167,9 +16171,9 @@ "dev": true }, "bignumber.js": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", - "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==" + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz", + "integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==" }, "bin-links": { "version": "3.0.3", diff --git a/packages/models/__tests__/store/json-storage.ts b/packages/models/__tests__/store/json-storage.ts new file mode 100644 index 00000000..5e0b87f5 --- /dev/null +++ b/packages/models/__tests__/store/json-storage.ts @@ -0,0 +1,169 @@ +import BigNumber from 'bignumber.js' +import { describe, it, expect } from '@jest/globals' +import { addMarkForStorage, JSONStorage } from '../../src' +import { UnexpectedParamsException, UnexpectedTypeException } from '../../src/exceptions' + +describe('test json storage', () => { + describe('only data exist', () => { + it('simple object', () => { + const storage = new JSONStorage() + const res = storage.serialize({ data: {} }) + const original = storage.deserialize(res) + expect(original).toStrictEqual({ data: {} }) + }) + + it('only one layer', () => { + const storage = new JSONStorage<{ data: { a: BigNumber; b: string; c: boolean; d: boolean; e: BigNumber } }>() + const data = { a: BigNumber(1), b: 'b', c: true, d: false, e: BigNumber('100') } + const res = storage.serialize({ data }) + const original = storage.deserialize(res) + Object.keys(data).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((original.data as any)[key]).toStrictEqual((data as any)[key]) + }) + }) + + it('more than one layer', () => { + const storage = new JSONStorage<{ data: { lay: { b: string; c: boolean; d: boolean; e: BigNumber } } }>() + const data = { b: 'b', c: true, d: false, e: BigNumber('100') } + const res = storage.serialize({ data: { lay: data } }) + const original = storage.deserialize(res) + Object.keys(data).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((original.data.lay as any)[key]).toEqual((data as any)[key]) + }) + }) + }) + + describe('only witness exist', () => { + it('simple object', () => { + const storage = new JSONStorage() + const res = storage.serialize({ witness: {} }) + const original = storage.deserialize(res) + expect(original).toStrictEqual({ witness: {} }) + }) + it('only one layer', () => { + const storage = new JSONStorage<{ witness: { b: string; c: boolean; d: boolean; e: BigNumber } }>() + const witness = { b: 'b', c: true, d: false, e: BigNumber('100') } + const res = storage.serialize({ witness }) + const original = storage.deserialize(res) + Object.keys(witness).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((original.witness as any)[key]).toEqual((witness as any)[key]) + }) + }) + + it('more than one layer', () => { + const storage = new JSONStorage<{ + witness: { lay: { b: string; c: boolean; d: boolean; e: BigNumber } } + }>() + const witness = { b: 'b', c: true, d: false, e: BigNumber('100') } + const res = storage.serialize({ witness: { lay: witness } }) + const original = storage.deserialize(res) + Object.keys(witness).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((original.witness.lay as any)[key]).toEqual((witness as any)[key]) + }) + }) + }) + + describe('data and witness exist', () => { + it('simple object', () => { + const storage = new JSONStorage() + const res = storage.serialize({ witness: {}, data: {} }) + const original = storage.deserialize(res) + expect(original).toStrictEqual({ witness: {}, data: {} }) + }) + + it('more than one layer', () => { + const storage = new JSONStorage<{ + data: BigNumber + witness: { lay: { b: string; c: boolean; d: boolean; e: BigNumber } } + }>() + const witness = { b: 'b', c: true, d: false, e: BigNumber('100') } + const res = storage.serialize({ data: BigNumber(10), witness: { lay: witness } }) + const original = storage.deserialize(res) + Object.keys(witness).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((original.witness.lay as any)[key]).toEqual((witness as any)[key]) + }) + expect(original.data).toStrictEqual(BigNumber(10)) + }) + }) + + describe('some exception withnot type', () => { + const storage = new JSONStorage() + it('serialize with empty', () => { + const res = storage.serialize({}) + expect(res).toStrictEqual(Buffer.from(JSON.stringify({}))) + }) + it('serialize with null in data', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => storage.serialize({ a: null } as any)).toThrow(new UnexpectedTypeException('null')) + }) + it('serialize with null', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (storage.serialize as any)(null)).toThrow(new UnexpectedTypeException('null')) + }) + it('serialize with undefined', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (storage.serialize as any)()).toThrow(new UnexpectedTypeException('undefined')) + }) + it('deserialize with null in data', () => { + expect(() => storage.deserialize(Buffer.from(JSON.stringify({ a: null })))).toThrow( + new UnexpectedTypeException('null'), + ) + }) + it('deserialize with null', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (storage.deserialize as any)(null)).toThrow(new UnexpectedParamsException('null')) + }) + it('deserialize with undefined', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (storage.deserialize as any)()).toThrow(new UnexpectedParamsException('undefined')) + }) + }) + + describe('test addMarkForStorage', () => { + it('is null', () => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addMarkForStorage(null as any) + } catch (error) { + expect(error).toBeInstanceOf(UnexpectedTypeException) + } + }) + it('is undefined', () => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addMarkForStorage(null as any) + } catch (error) { + expect(error).toBeInstanceOf(UnexpectedTypeException) + } + }) + it('is BigNumber Array', () => { + const res = addMarkForStorage([BigNumber(10), BigNumber(20)]) + expect(res).toStrictEqual(['210', '220']) + }) + it('is simple obj', () => { + const res = addMarkForStorage({ a: 's', b: true, c: BigNumber(10) }) + expect(res).toStrictEqual({ + a: '0s', + b: '1true', + c: '210', + }) + }) + it('is complex obj', () => { + const res = addMarkForStorage({ + a: [BigNumber(10), BigNumber(20)], + b: { c: { e: BigNumber(20) }, d: [BigNumber(20)] }, + e: BigNumber(10), + }) + expect(res).toStrictEqual({ + a: ['210', '220'], + b: { c: { e: '220' }, d: ['220'] }, + e: '210', + }) + }) + }) +}) diff --git a/packages/models/__tests__/store/store.ts b/packages/models/__tests__/store/store.ts new file mode 100644 index 00000000..7745508e --- /dev/null +++ b/packages/models/__tests__/store/store.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals' +import { NonExistentException, NonStorageInstanceException } from '../../src/exceptions' +import { ChainStorage, JSONStore, Store, Behavior, ProviderKey, StorageLocation, StorageSchema } from '../../src' +import BigNumber from 'bignumber.js' + +const ref = { + name: '', + protocol: '', + path: '', + uri: 'json', +} + +type CustomType = string + +const serializeMock = jest.fn() +const deserializeMock = jest.fn() + +class StorageCustom extends ChainStorage { + serialize(data: T): Uint8Array { + serializeMock() + return Buffer.from(data) + } + + deserialize(data: Uint8Array): T { + deserializeMock() + return data.toString() as T + } +} + +class CustomStore> extends Store, R> { + getStorage(_storeKey: StorageLocation): StorageCustom { + return new StorageCustom() + } +} + +class NoInstanceCustomStore> extends Store, R> {} + +Reflect.defineMetadata(ProviderKey.Actor, { ref }, Store) +Reflect.defineMetadata(ProviderKey.Actor, { ref }, JSONStore) +Reflect.defineMetadata(ProviderKey.Actor, { ref }, CustomStore) +Reflect.defineMetadata(ProviderKey.Actor, { ref }, NoInstanceCustomStore) + +describe('test store', () => { + describe('use json storage', () => { + describe('test handleCall with add', () => { + const store = new JSONStore<{ data: { a: BigNumber } }>() + it('add success', () => { + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: { a: BigNumber(1) } }, + }, + }, + }, + }) + expect(store.get('0x1234')).toStrictEqual({ data: { a: BigNumber(1) } }) + }) + it('add with no add params', () => { + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + }, + }, + }) + }) + it('add with duplicate add params', () => { + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: { a: BigNumber(2) } }, + }, + }, + }, + }) + expect(store.get('0x1234')).toStrictEqual({ data: { a: BigNumber(2) } }) + }) + it('add lock without offset', () => { + const lockStore = new JSONStore<{ lock: { args: BigNumber } }>() + lockStore.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { lock: { args: BigNumber(1) } }, + }, + }, + }, + }) + expect(lockStore.get('0x1234')).toStrictEqual({ lock: { args: BigNumber(1) } }) + }) + it('add lock with offset', () => { + const lockStore = new JSONStore<{ lock: { args: { offset: 10; schema: BigNumber } } }>({ + schemaOption: { lock: { args: { offset: 10 } } }, + }) + lockStore.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { lock: { args: BigNumber(1) } }, + }, + }, + }, + }) + expect(lockStore.get('0x1234')).toStrictEqual({ lock: { args: BigNumber(1) } }) + }) + it('add lock codehash/hashtype with offset', () => { + const lockStore = new JSONStore<{ lock: { codeHash: { offset: 10; schema: BigNumber }; hashType: BigNumber } }>( + { + schemaOption: { lock: { codeHash: { offset: 10 } } }, + }, + ) + lockStore.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { lock: { codeHash: BigNumber(1), hashType: BigNumber(1) } }, + }, + }, + }, + }) + expect(lockStore.get('0x1234')).toStrictEqual({ lock: { codeHash: BigNumber(1), hashType: BigNumber(1) } }) + }) + }) + + describe('test handleCall with sub', () => { + const store = new JSONStore<{ data: { a: BigNumber } }>() + beforeEach(() => { + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: { a: BigNumber(1) } }, + }, + }, + }, + }) + }) + it('remove success', () => { + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'remove_state', + remove: ['0x1234'], + }, + }, + }) + expect(store.get('0x1234')).toBeUndefined() + }) + it('remove failed', () => { + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'remove_state', + remove: ['0x123411'], + }, + }, + }) + expect(store.get('0x1234')).toBeDefined() + }) + }) + + describe('test clone', () => { + it('normal', () => { + const store = new JSONStore<{ data: { a: BigNumber }; witness: { b: string } }>() + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: { a: BigNumber(1) }, witness: { b: '111' } }, + }, + }, + }, + }) + const cloneRes = store.clone() + expect(cloneRes.get('0x1234') === store.get('0x1234')).toBeFalsy() + expect(cloneRes.get('0x1234')).toStrictEqual(store.get('0x1234')) + expect(cloneRes instanceof JSONStore).toBe(true) + }) + it('clone with lock and type', () => { + const store = new JSONStore<{ type: { args: BigNumber }; lock: { codeHash: string } }>() + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { type: { args: BigNumber(1) }, lock: { codeHash: '111' } }, + }, + }, + }, + }) + const cloneRes = store.clone() + expect(cloneRes.get('0x1234') === store.get('0x1234')).toBeFalsy() + expect(cloneRes.get('0x1234')).toStrictEqual(store.get('0x1234')) + expect(cloneRes.get('0x1234', ['lock', 'codeHash'])).toEqual('111') + expect(cloneRes.get('0x1234', ['type', 'args'])).toStrictEqual(BigNumber(1)) + expect(cloneRes instanceof JSONStore).toBe(true) + }) + }) + + describe('test get', () => { + const store = new JSONStore<{ data: { a: BigNumber } }>() + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: { a: BigNumber(1) } }, + }, + }, + }, + }) + it('get success without path', () => { + expect(store.get('0x1234')).toStrictEqual({ data: { a: BigNumber(1) } }) + }) + it('get success with first path', () => { + expect(store.get('0x1234', ['data'])).toStrictEqual({ a: BigNumber(1) }) + }) + it('get success with path', () => { + expect(store.get('0x1234', ['data', 'a'])).toEqual(BigNumber(1)) + }) + it('get with non existent exception no outpoint', () => { + try { + store.get('0x1234111', ['data', 'a111']) + } catch (error) { + expect(error).toBeInstanceOf(NonExistentException) + } + }) + it('get with non existent exception', () => { + try { + store.get('0x1234', ['data', 'a111']) + } catch (error) { + expect(error).toBeInstanceOf(NonExistentException) + } + }) + it('get lock args', () => { + const lockStore = new JSONStore<{ lock: { args: BigNumber } }>() + lockStore.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { lock: { args: BigNumber(1) } }, + }, + }, + }, + }) + expect(lockStore.get('0x1234', ['lock', 'args'])).toStrictEqual(BigNumber(1)) + }) + }) + + describe('test set', () => { + const store = new JSONStore<{ data: { a: BigNumber; b: { c: BigNumber } } }>() + beforeEach(() => { + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: { a: BigNumber(1), b: { c: BigNumber(1) } } }, + }, + }, + }, + }) + }) + it('set success without path', () => { + store.set('0x1234', { data: { a: BigNumber(2), b: { c: BigNumber(1) } } }) + expect(store.get('0x1234')).toStrictEqual({ data: { a: BigNumber(2), b: { c: BigNumber(1) } } }) + }) + it('set success with data path', () => { + store.set('0x1234', { a: BigNumber(2), b: { c: BigNumber(1) } }, ['data']) + expect(store.get('0x1234')).toStrictEqual({ data: { a: BigNumber(2), b: { c: BigNumber(1) } } }) + }) + it('set success with data inner path', () => { + store.set('0x1234', { c: BigNumber(10) }, ['data', 'b']) + expect(store.get('0x1234')).toStrictEqual({ data: { a: BigNumber(1), b: { c: BigNumber(10) } } }) + }) + it('set lock args', () => { + const lockStore = new JSONStore<{ lock: { args: BigNumber } }>() + lockStore.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { lock: { args: BigNumber(1) } }, + }, + }, + }, + }) + lockStore.set('0x1234', BigNumber(10), ['lock', 'args']) + expect(lockStore.get('0x1234', ['lock', 'args'])).toStrictEqual(BigNumber(10)) + }) + }) + + describe('test remove', () => { + const store = new JSONStore<{ data: { a: BigNumber } }>() + beforeEach(() => { + store.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: { a: BigNumber(1) } }, + }, + }, + }, + }) + }) + it('remove success without path', () => { + store.remove('0x1234') + expect(store.get('0x1234')).toBeUndefined() + }) + it('remove with non exist path', () => { + store.remove('0x1234111') + expect(store.get('0x1234')).toStrictEqual({ data: { a: BigNumber(1) } }) + }) + }) + }) + + describe('extend store', () => { + it('success', () => { + const custom = new CustomStore<{ data: string }>() + custom.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: '' }, + }, + }, + }, + }) + custom.clone() + expect(serializeMock).toBeCalled() + expect(deserializeMock).toBeCalled() + }) + + it('exception', () => { + const custom = new NoInstanceCustomStore<{ data: string }>() + custom.handleCall({ + from: ref, + behavior: Behavior.Call, + payload: { + pattern: 'normal', + value: { + type: 'add_state', + add: { + '0x1234': { data: '' }, + }, + }, + }, + }) + expect(() => custom.clone()).toThrow(new NonStorageInstanceException()) + }) + }) +}) diff --git a/packages/models/package.json b/packages/models/package.json index 04e64d9d..b87115c6 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -29,6 +29,8 @@ "url": "https://github.com/ckb-js/kuai/issues" }, "dependencies": { + "@ckb-lumos/codec": "0.19.0", + "bignumber.js": "9.1.1", "inversify": "6.0.1", "ioredis": "5.2.4", "reflect-metadata": "0.1.13", diff --git a/packages/models/src/decorator/constants.ts b/packages/models/src/decorator/constants.ts new file mode 100644 index 00000000..bb106002 --- /dev/null +++ b/packages/models/src/decorator/constants.ts @@ -0,0 +1 @@ +export const PATTERN_META_NAME = '__pattern__' diff --git a/packages/models/src/decorator/pattern.ts b/packages/models/src/decorator/pattern.ts new file mode 100644 index 00000000..3bebf436 --- /dev/null +++ b/packages/models/src/decorator/pattern.ts @@ -0,0 +1,23 @@ +import { PATTERN_META_NAME } from "./constants" + +export interface PatternItem { + field: string + match: { + op: 'eq' | 'lt' | 'gt' | 'lte' | 'gte' | 'in' + value: any + } | RegExp +} + +export type PatternType = { + aggregate: 'OR' | 'AND' + patterns: PatternItem[] +}[] | PatternItem + +export function Pattern( + option?: PatternType, +): ClassDecorator { + return function (target) { + Reflect.defineMetadata(PATTERN_META_NAME, option, target) + // TODO refister target to APP + } +} diff --git a/packages/models/src/exceptions/index.ts b/packages/models/src/exceptions/index.ts new file mode 100644 index 00000000..16c86332 --- /dev/null +++ b/packages/models/src/exceptions/index.ts @@ -0,0 +1 @@ +export * from './store' diff --git a/packages/models/src/exceptions/store.ts b/packages/models/src/exceptions/store.ts new file mode 100644 index 00000000..34190468 --- /dev/null +++ b/packages/models/src/exceptions/store.ts @@ -0,0 +1,29 @@ +export class NonExistentException extends Error { + constructor(path: string) { + super(`The ${path} does not exist in state`) + } +} + +export class NonStorageInstanceException extends Error { + constructor() { + super(`Storage instance should be initialized`) + } +} + +export class UnexpectedTypeException extends Error { + constructor(type: string) { + super(`Unexpected type: ${type} in storage`) + } +} + +export class UnexpectedMarkException extends Error { + constructor(mark: string) { + super(`Unexpected mark: ${mark} in storage`) + } +} + +export class UnexpectedParamsException extends Error { + constructor(params: string) { + super(`Unexpected params with ${params} when calling deserialize`) + } +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 47285ec5..0bf30b7a 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -1,3 +1,4 @@ import 'reflect-metadata' export * from './actor' export * from './utils' +export * from './store' diff --git a/packages/models/src/store/chain-storage.ts b/packages/models/src/store/chain-storage.ts new file mode 100644 index 00000000..963e4458 --- /dev/null +++ b/packages/models/src/store/chain-storage.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export abstract class ChainStorage { + abstract serialize(data: T): Uint8Array + abstract deserialize(data: Uint8Array): T + clone(data: T): T { + return this.deserialize(this.serialize(data)) + } +} + +export type GetState = T extends ChainStorage ? State : never diff --git a/packages/models/src/store/index.ts b/packages/models/src/store/index.ts index 38387efc..ebd0a8f8 100644 --- a/packages/models/src/store/index.ts +++ b/packages/models/src/store/index.ts @@ -1 +1,5 @@ // TODO: https://github.com/ckb-js/kuai/issues/2 +export * from './json-storage' +export * from './chain-storage' +export * from './store' +export * from './interface' diff --git a/packages/models/src/store/interface.ts b/packages/models/src/store/interface.ts new file mode 100644 index 00000000..0da06463 --- /dev/null +++ b/packages/models/src/store/interface.ts @@ -0,0 +1,101 @@ +export type OutPointString = string + +type FieldSchema = + | T + | { + offset?: number + length?: number + schema: T + } + +export interface ScriptSchema { + codeHash?: FieldSchema + hashType?: FieldSchema + args?: FieldSchema +} +export interface StorageSchema { + data?: FieldSchema + witness?: FieldSchema + lock?: ScriptSchema + type?: ScriptSchema +} + +type GetFieldStruct = T extends { schema: unknown } ? T['schema'] : T + +type IsKeysExist = T & { _add: never } extends { + [P in K]: T[P] +} + ? true + : false + +type IsEmptyScriptSchemaNever = IsKeysExist extends true + ? T + : IsKeysExist extends true + ? T + : IsKeysExist extends true + ? T + : never + +type GetScriptStruct = IsEmptyScriptSchemaNever<{ + [P in keyof T as T[P] extends FieldSchema ? P : never]: GetFieldStruct +}> + +export type OmitByValue = { + [P in keyof T as T[P] extends R ? never : P]: T[P] +} + +type IfEmptyStorageSchemaNever = IsKeysExist extends true + ? T + : IsKeysExist extends true + ? T + : IsKeysExist extends true + ? T + : IsKeysExist extends true + ? T + : never + +export type GetFullStorageStruct = { + data: IsKeysExist extends true ? GetFieldStruct : never + witness: IsKeysExist extends true ? GetFieldStruct : never + lock: T extends { lock: infer Lock extends ScriptSchema } ? GetScriptStruct : never + type: T extends { type: infer Type extends ScriptSchema } ? GetScriptStruct : never +} + +export type GetStorageStruct = IfEmptyStorageSchemaNever extends true + ? never + : OmitByValue> + +type PickExist = OmitByValue<{ + [P in K as P extends keyof T ? P : never]: T[P] +}> + +type GetScriptOption = IsEmptyScriptSchemaNever< + OmitByValue<{ + [P in keyof T]: T[P] extends { offset?: number; length?: number; schema: unknown } + ? PickExist + : never + }> +> + +export type GetStorageOption = IfEmptyStorageSchemaNever< + OmitByValue<{ + data: T extends { data: infer Option extends { offset?: number; length?: number; schema: unknown } } + ? PickExist + : never + witness: T extends { witness: infer Option extends { offset?: number; length?: number; schema: unknown } } + ? PickExist + : never + lock: T extends { lock: infer Lock extends ScriptSchema } ? GetScriptOption : never + type: T extends { type: infer Type extends ScriptSchema } ? GetScriptOption : never + }> +> + +export type StorageLocation = 'data' | 'witness' | ['lock' | 'type', keyof ScriptSchema] + +export type StorePath = K extends string[] ? [...K, ...string[]] : [K, ...string[]] + +export interface StoreMessage { + type: 'add_state' | 'remove_state' + add?: Record + remove?: OutPointString[] +} diff --git a/packages/models/src/store/json-storage.ts b/packages/models/src/store/json-storage.ts new file mode 100644 index 00000000..3a2b4821 --- /dev/null +++ b/packages/models/src/store/json-storage.ts @@ -0,0 +1,78 @@ +import BigNumber from 'bignumber.js' +import { UnexpectedMarkException, UnexpectedParamsException, UnexpectedTypeException } from '../exceptions' +import { ChainStorage } from './chain-storage' + +export type JSONStorageType = string | boolean | BigNumber +export type JSONStorageOffChain = + | JSONStorageType + | JSONStorageOffChain[] + | { + [x: string]: JSONStorageOffChain + } + +const TYPE_MARK_LEN = 1 +const BIG_NUMBER_TYPE = 'bignumber' +const TYPE_MARK_MAP = { + string: '0', + boolean: '1', + [BIG_NUMBER_TYPE]: '2', +} + +function reviver(key: string, value: JSONStorageOffChain | JSONStorageType) { + if (value === null) throw new UnexpectedTypeException('null') + if (typeof value === 'object') { + return value + } + const mark = value.toString().slice(0, TYPE_MARK_LEN) + if (mark.length !== TYPE_MARK_LEN) { + throw new UnexpectedMarkException(mark) + } + const acturalValue = value.toString().slice(TYPE_MARK_LEN) + switch (mark) { + case TYPE_MARK_MAP['string']: + return acturalValue + case TYPE_MARK_MAP['boolean']: + return acturalValue === 'true' + case TYPE_MARK_MAP['bignumber']: + return BigNumber(acturalValue) + default: + throw new UnexpectedMarkException(mark) + } +} + +function serializeSimpleType(v: string | boolean) { + const valueType = typeof v + if (v === null) throw new UnexpectedTypeException('null') + const mark = TYPE_MARK_MAP[valueType as keyof typeof TYPE_MARK_MAP] + if (mark) { + return `${mark}${v.toString()}` + } + throw new UnexpectedTypeException(valueType) +} + +type AddMarkStorage = string | AddMarkStorage[] | { [key: string]: AddMarkStorage } + +export function addMarkForStorage(data?: JSONStorageOffChain): AddMarkStorage { + if (data === null || data === undefined) throw new UnexpectedTypeException(`${data}`) + if (data instanceof BigNumber) return `${TYPE_MARK_MAP[BIG_NUMBER_TYPE]}${data.toFixed()}` + if (typeof data !== 'object') return serializeSimpleType(data) + if (Array.isArray(data)) { + return data.map((v) => addMarkForStorage(v)) + } + const newData: Record = {} + Object.keys(data).forEach((key: string) => { + newData[key] = addMarkForStorage(data[key]) + }) + return newData +} + +export class JSONStorage extends ChainStorage { + serialize(data: T): Uint8Array { + return Buffer.from(JSON.stringify(addMarkForStorage(data))) + } + + deserialize(data: Uint8Array): T { + if (data === null || data === undefined) throw new UnexpectedParamsException(`${data}`) + return JSON.parse(data.toString(), reviver) + } +} diff --git a/packages/models/src/store/store.ts b/packages/models/src/store/store.ts new file mode 100644 index 00000000..61a80980 --- /dev/null +++ b/packages/models/src/store/store.ts @@ -0,0 +1,249 @@ +import type { ActorMessage, MessagePayload } from '../actor' +import type { + OutPointString, + StoreMessage, + StorePath, + StorageSchema, + StorageLocation, + GetStorageStruct, + GetFullStorageStruct, + ScriptSchema, + OmitByValue, + GetStorageOption, +} from './interface' +import type { JSONStorageOffChain } from './json-storage' +import type { GetState } from './chain-storage' +import { ChainStorage } from './chain-storage' +import { Actor } from '../actor' +import { JSONStorage } from './json-storage' +import { NonExistentException, NonStorageInstanceException } from '../exceptions' + +type GetKeyType = T & { _add: never } extends { [P in K]: T[P] } ? T[K] : never + +export class Store< + StorageT extends ChainStorage, + StructSchema extends StorageSchema>, + Option = never, +> extends Actor, MessagePayload>>> { + protected states: Record> + + protected options?: Option + + protected schemaOption?: GetStorageOption + + constructor( + params?: GetStorageOption extends never + ? { + states?: Record> + options?: Option + } + : { + states?: Record> + options?: Option + schemaOption: GetStorageOption + }, + ) { + super() + this.states = params?.states || {} + this.options = params?.options + if (params && 'schemaOption' in params) { + this.schemaOption = params?.schemaOption + } + } + + private addState(addStates: Record>) { + this.states = { + ...this.states, + ...addStates, + } + } + + private removeState(keys: OutPointString[]) { + keys.forEach((key) => { + delete this.states[key] + }) + } + + private getAndValidTargetKey(key: OutPointString, paths: StorePath, ignoreLast?: boolean) { + if (ignoreLast && paths.length === 1) return this.states[key] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let result: any = (this.states[key] as any)?.[paths[0]] + for (let i = 1; i < (ignoreLast ? paths.length - 1 : paths.length); i++) { + result = result?.[paths[i]] + } + if (result === undefined || result === null) { + throw new NonExistentException(paths.join('.')) + } + return result + } + + handleCall = (_msg: ActorMessage>>>): void => { + switch (_msg.payload?.value?.type) { + case 'add_state': + if (_msg.payload?.value?.add) { + this.addState(_msg.payload.value.add) + } + break + case 'remove_state': + if (_msg.payload?.value?.remove) { + this.removeState(_msg.payload.value.remove) + } + break + default: + break + } + } + + getStorage(_storeKey: StorageLocation): StorageT | undefined { + return undefined + } + + assetStorage(storage: StorageT | undefined) { + if (!storage) throw new NonStorageInstanceException() + } + + cloneScript(scriptValue: unknown, type: T): GetFullStorageStruct[T] { + if (typeof scriptValue !== 'object' || scriptValue === null) throw new Error() + const res = {} as GetFullStorageStruct[T] + if ('args' in scriptValue) { + this.assetStorage(this.getStorage([type, 'args'])) + res['args'] = this.getStorage([type, 'args'])?.clone(scriptValue.args) + } + if ('codeHash' in scriptValue) { + this.assetStorage(this.getStorage([type, 'codeHash'])) + res['codeHash'] = this.getStorage([type, 'codeHash'])?.clone(scriptValue.codeHash) + } + if ('hashType' in scriptValue) { + this.assetStorage(this.getStorage([type, 'hashType'])) + res['hashType'] = this.getStorage([type, 'hashType'])?.clone(scriptValue.hashType) + } + return res + } + + clone(): Store { + const states: Record> = {} + Object.keys(this.states).forEach((key) => { + const currentStateInKey = this.states[key] + states[key] = (states[key] || {}) as GetFullStorageStruct + if ('data' in currentStateInKey && currentStateInKey.data !== undefined) { + this.assetStorage(this.getStorage('data')) + states[key].data = this.getStorage('data')?.clone(currentStateInKey.data) + } + if ('witness' in currentStateInKey && currentStateInKey.witness !== undefined) { + this.assetStorage(this.getStorage('witness')) + states[key].witness = this.getStorage('witness')?.clone(currentStateInKey.witness) + } + if ('lock' in currentStateInKey && (currentStateInKey.lock ?? false) !== false) { + states[key].lock = this.cloneScript<'lock'>(currentStateInKey.lock, 'lock') + } + if ('type' in currentStateInKey && (currentStateInKey.type ?? false) !== false) { + states[key].type = this.cloneScript<'type'>(currentStateInKey.type, 'type') + } + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new (this.constructor)({ states, options: this.options, schemaOption: this.schemaOption }) + } + + get(key: OutPointString): GetStorageStruct + get(key: OutPointString, paths: ['data']): GetFullStorageStruct['data'] + get(key: OutPointString, paths: ['witness']): GetFullStorageStruct['witness'] + get(key: OutPointString, paths: ['lock', 'args']): GetKeyType['lock'], 'args'> + get( + key: OutPointString, + paths: ['lock', 'codeHash'], + ): GetKeyType['lock'], 'codeHash'> + get( + key: OutPointString, + paths: ['lock', 'hashType'], + ): GetKeyType['lock'], 'hashType'> + get(key: OutPointString, paths: ['type', 'args']): GetKeyType['type'], 'args'> + get( + key: OutPointString, + paths: ['type', 'codeHash'], + ): GetKeyType['type'], 'codeHash'> + get( + key: OutPointString, + paths: ['type', 'hashType'], + ): GetKeyType['type'], 'hashType'> + get(key: OutPointString, paths: ['data' | 'witness', ...string[]]): unknown + get(key: OutPointString, paths: ['type' | 'lock', keyof ScriptSchema, ...string[]]): unknown + get(key: OutPointString, paths?: StorePath) { + try { + if (paths) { + return this.getAndValidTargetKey(key, paths) + } + return this.states[key] + } catch (error) { + return + } + } + + set(key: OutPointString, value: GetStorageStruct): void + set(key: OutPointString, value: GetFullStorageStruct['data'], paths: ['data']): void + set(key: OutPointString, value: GetFullStorageStruct['witness'], paths: ['witness']): void + set( + key: OutPointString, + value: GetKeyType['lock'], 'args'>, + paths: ['lock', 'args'], + ): void + set( + key: OutPointString, + value: GetKeyType['lock'], 'codeHash'>, + paths: ['lock', 'codeHash'], + ): void + set( + key: OutPointString, + value: GetKeyType['lock'], 'hashType'>, + paths: ['lock', 'hashType'], + ): void + set( + key: OutPointString, + value: GetKeyType['type'], 'args'>, + paths: ['type', 'args'], + ): void + set( + key: OutPointString, + value: GetKeyType['type'], 'codeHash'>, + paths: ['type', 'codeHash'], + ): void + set( + key: OutPointString, + value: GetKeyType['type'], 'hashType'>, + paths: ['type', 'hashType'], + ): void + set(key: OutPointString, value: GetState, paths: ['data' | 'witness', ...string[]]): void + set(key: OutPointString, value: GetState, paths: ['type' | 'lock', keyof ScriptSchema, ...string[]]): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set(key: OutPointString, value: any, paths?: StorePath) { + if (paths) { + const target = this.getAndValidTargetKey(key, paths, true) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const lastKey = paths.at(-1)! + target[lastKey] = value + return + } + this.states[key] = value + } + + remove(key: OutPointString) { + delete this.states[key] + } +} + +type IsUnknownOrNever = T extends never ? true : unknown extends T ? (0 extends 1 & T ? false : true) : false + +type GetStorageStructByTemplate = OmitByValue<{ + data: IsUnknownOrNever extends true ? never : T['data'] + witness: IsUnknownOrNever extends true ? never : T['witness'] + lock: IsUnknownOrNever extends true ? never : T['lock'] + type: IsUnknownOrNever extends true ? never : T['type'] +}> + +export class JSONStore> extends Store< + JSONStorage, + GetStorageStructByTemplate +> { + getStorage(_storeKey: StorageLocation): JSONStorage { + return new JSONStorage() + } +}