From b5ab691ee47c6852271238a06c5997d97c3c42ae Mon Sep 17 00:00:00 2001 From: Mark Krasner <70119343+mzkrasner@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:03:28 -0500 Subject: [PATCH] Get Document State (#38) --- packages/events/src/index.ts | 1 + packages/events/src/utils.ts | 23 +++++++ packages/events/test/utils.test.ts | 69 ++++++++++++++++++- packages/model-instance-client/src/client.ts | 30 +++++++- packages/model-instance-client/src/types.ts | 7 ++ .../model-instance-client/test/lib.test.ts | 47 +++++++++++++ .../model-instance-handler/src/assertions.ts | 6 +- packages/model-instance-handler/src/index.ts | 1 + packages/model-instance-handler/src/types.ts | 4 +- packages/model-instance-handler/src/utils.ts | 4 +- .../model-instance-handler/src/validation.ts | 4 +- packages/stream-client/src/index.ts | 2 +- typedoc.json | 1 + 13 files changed, 184 insertions(+), 15 deletions(-) diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index 79cf74e..5b29718 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -33,3 +33,4 @@ export { getSignedEventPayload, signEvent, } from './signing.js' +export { decodeMultibaseToJSON, decodeMultibaseToStreamID } from './utils.js' diff --git a/packages/events/src/utils.ts b/packages/events/src/utils.ts index 62445b9..8dbd38b 100644 --- a/packages/events/src/utils.ts +++ b/packages/events/src/utils.ts @@ -1,3 +1,5 @@ +import { StreamID } from '@ceramic-sdk/identifiers' +import { bases } from 'multiformats/basics' import type { CID } from 'multiformats/cid' import { toString as bytesToString, fromString } from 'uint8arrays' @@ -9,6 +11,27 @@ export function base64urlToJSON>(value: string): T { return JSON.parse(bytesToString(fromString(value, 'base64url'))) } +export function decodeMultibase(multibaseString: string): Uint8Array { + const prefix = multibaseString[0] + const baseEntry = Object.values(bases).find((base) => base.prefix === prefix) + if (!baseEntry) { + throw new Error(`Unsupported multibase prefix: ${prefix}`) + } + + return baseEntry.decode(multibaseString) // Remove prefix and decode +} + +export function decodeMultibaseToJSON>( + value: string, +): T { + const data = decodeMultibase(value) + return JSON.parse(new TextDecoder().decode(data)) +} + +export function decodeMultibaseToStreamID(value: string): StreamID { + return StreamID.fromBytes(decodeMultibase(value)) +} + /** * Restricts block size to MAX_BLOCK_SIZE. * diff --git a/packages/events/test/utils.test.ts b/packages/events/test/utils.test.ts index 50393dc..3d5e64a 100644 --- a/packages/events/test/utils.test.ts +++ b/packages/events/test/utils.test.ts @@ -1,6 +1,12 @@ +import { bases } from 'multiformats/basics' import { CID } from 'multiformats/cid' - -import { MAX_BLOCK_SIZE, restrictBlockSize } from '../src/utils.js' +import { + MAX_BLOCK_SIZE, + decodeMultibase, + decodeMultibaseToJSON, + decodeMultibaseToStreamID, + restrictBlockSize, +} from '../src/utils.js' describe('utils', () => { const TEST_CID = CID.parse( @@ -17,4 +23,63 @@ describe('utils', () => { restrictBlockSize(new Uint8Array(MAX_BLOCK_SIZE), TEST_CID) }).not.toThrow() }) + + describe('decodeMultibase', () => { + it('should decode a valid Base64url-encoded string', () => { + const input = + 'ueyJtZXRhZGF0YSI6eyJzaG91bGRJbmRleCI6dHJ1ZX0sImNvbnRlbnQiOnsiYm9keSI6IlRoaXMgaXMgYSBzaW1wbGUgbWVzc2FnZSJ9fQ' + const payload = { + metadata: { shouldIndex: true }, + content: { body: 'This is a simple message' }, + } + const array = new TextEncoder().encode(JSON.stringify(payload)) + const result = decodeMultibase(input) + + expect(result).toEqual(array) + }) + it('should decode a valid Base32-encoded string', () => { + const payload = { + metadata: { shouldIndex: true }, + content: { body: 'This is a simple message' }, + } + const array = new TextEncoder().encode(JSON.stringify(payload)) + const encoded = bases.base32.encode(array) + const result = decodeMultibase(encoded) + + expect(result).toEqual(array) + }) + }) + describe('decodeMultibaseToJSON', () => { + it('should decode a valid multibase-encoded string to JSON', () => { + const input = + 'ueyJtZXRhZGF0YSI6eyJzaG91bGRJbmRleCI6dHJ1ZX0sImNvbnRlbnQiOnsiYm9keSI6IlRoaXMgaXMgYSBzaW1wbGUgbWVzc2FnZSJ9fQ' + const payload = { + metadata: { shouldIndex: true }, + content: { body: 'This is a simple message' }, + } + const result = decodeMultibaseToJSON(input) + + expect(result).toEqual(payload) + }) + it('should decode a valid multibase-encoded string to JSON', () => { + const payload = { + metadata: { shouldIndex: true }, + content: { body: 'This is a simple message' }, + } + const array = new TextEncoder().encode(JSON.stringify(payload)) + const encoded = bases.base32.encode(array) + const result = decodeMultibaseToJSON(encoded) + + expect(result).toEqual(payload) + }) + }) + describe('decodeMultibaseToStreamID', () => { + it('should decode a valid multibase-encoded string to StreamID', () => { + const input = 'uzgEAAXESIA8og02Dnbwed_besT8M0YOnaZ-hrmMZaa7mnpdUL8jE' + const stream = + 'k2t6wyfsu4pfx2cbha7xh9fsjvqr8b7g3w7365w627bup0l5qo020e2id4txvo' + const result = decodeMultibaseToStreamID(input) + expect(result.toString()).toEqual(stream) + }) + }) }) diff --git a/packages/model-instance-client/src/client.ts b/packages/model-instance-client/src/client.ts index 91d417d..bbb6d47 100644 --- a/packages/model-instance-client/src/client.ts +++ b/packages/model-instance-client/src/client.ts @@ -1,4 +1,9 @@ -import { InitEventPayload, SignedEvent } from '@ceramic-sdk/events' +import { + InitEventPayload, + SignedEvent, + decodeMultibaseToJSON, + decodeMultibaseToStreamID, +} from '@ceramic-sdk/events' import { CommitID, type StreamID } from '@ceramic-sdk/identifiers' import { DocumentEvent, @@ -7,7 +12,6 @@ import { import { StreamClient } from '@ceramic-sdk/stream-client' import type { DIDString } from '@didtools/codecs' import type { DID } from 'dids' - import { type CreateDataEventParams, type CreateInitEventParams, @@ -15,7 +19,7 @@ import { createInitEvent, getDeterministicInitEventPayload, } from './events.js' -import type { UnknownContent } from './types.js' +import type { DocumentState, UnknownContent } from './types.js' export type PostDeterministicInitParams = { model: StreamID @@ -84,4 +88,24 @@ export class ModelInstanceClient extends StreamClient { const cid = await this.ceramic.postEventType(SignedEvent, event) return CommitID.fromStream(params.currentID.baseID, cid) } + + /** Retrieve and return document state */ + async getDocumentState(streamID: string): Promise { + const streamState = await this.getStreamState(streamID) + const encodedData = streamState.data + + const decodedData = decodeMultibaseToJSON(encodedData) + const controller = streamState.controller + const modelID = decodeMultibaseToStreamID(streamState.dimensions.model) + return { + content: decodedData.content as UnknownContent | null, + metadata: { + model: modelID, + controller: controller as DIDString, + ...(typeof decodedData.metadata === 'object' + ? decodedData.metadata + : {}), + }, + } + } } diff --git a/packages/model-instance-client/src/types.ts b/packages/model-instance-client/src/types.ts index a2aa6b2..43145ec 100644 --- a/packages/model-instance-client/src/types.ts +++ b/packages/model-instance-client/src/types.ts @@ -1 +1,8 @@ +import type { DocumentMetadata } from '@ceramic-sdk/model-instance-protocol' + export type UnknownContent = Record + +export type DocumentState = { + content: UnknownContent | null + metadata: DocumentMetadata +} diff --git a/packages/model-instance-client/test/lib.test.ts b/packages/model-instance-client/test/lib.test.ts index 3ae5abc..4daa45e 100644 --- a/packages/model-instance-client/test/lib.test.ts +++ b/packages/model-instance-client/test/lib.test.ts @@ -210,4 +210,51 @@ describe('ModelInstanceClient', () => { expect(dataCommitID.baseID.equals(initCommitID.baseID)).toBe(true) }) }) + + describe('getDocumentState() method', () => { + test('gets the document state by stream ID', async () => { + const mockStreamState = { + id: 'k2t6wyfsu4pfy7r1jdd6jex9oxbqyp4gr2a5kxs8ioxwtisg8nzj3anbckji8g', + event_cid: + 'bafyreib5j4def5a4w4j6sg4upm6nb4cfn752wdjwqtwdzejfladyyymxca', + controller: 'did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp', + dimensions: { + context: 'u', + controller: + 'uZGlkOmtleTp6Nk1raVRCejF5bXVlcEFRNEhFSFlTRjFIOHF1RzVHTFZWUVIzZGpkWDNtRG9vV3A', + model: 'uzgEAAXESIA8og02Dnbwed_besT8M0YOnaZ-hrmMZaa7mnpdUL8jE', + }, + data: 'ueyJtZXRhZGF0YSI6eyJzaG91bGRJbmRleCI6dHJ1ZX0sImNvbnRlbnQiOnsiYm9keSI6IlRoaXMgaXMgYSBzaW1wbGUgbWVzc2FnZSJ9fQ', + } + const docState = { + content: { body: 'This is a simple message' }, + metadata: { + shouldIndex: true, + controller: + 'did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp', + model: + 'k2t6wyfsu4pfx2cbha7xh9fsjvqr8b7g3w7365w627bup0l5qo020e2id4txvo', + }, + } + const streamId = + 'k2t6wyfsu4pfy7r1jdd6jex9oxbqyp4gr2a5kxs8ioxwtisg8nzj3anbckji8g' + // Mock CeramicClient and its API + const mockGet = jest.fn(() => + Promise.resolve({ + data: mockStreamState, + error: null, + }), + ) + const ceramic = { + api: { GET: mockGet }, + } as unknown as CeramicClient + const client = new ModelInstanceClient({ ceramic, did: authenticatedDID }) + + const documentState = await client.getDocumentState(streamId) + expect(documentState.content).toEqual(docState.content) + expect(documentState.metadata.model.toString()).toEqual( + docState.metadata.model, + ) + }) + }) }) diff --git a/packages/model-instance-handler/src/assertions.ts b/packages/model-instance-handler/src/assertions.ts index 4e5b49e..58822dc 100644 --- a/packages/model-instance-handler/src/assertions.ts +++ b/packages/model-instance-handler/src/assertions.ts @@ -10,7 +10,7 @@ import addFormats from 'ajv-formats' import Ajv from 'ajv/dist/2020.js' import { equals } from 'uint8arrays' -import type { DocumentState, UnknowContent } from './types.js' +import type { DocumentState, UnknownContent } from './types.js' import { getUniqueFieldsValue } from './utils.js' const validator = new Ajv({ @@ -41,7 +41,7 @@ export function assertNoImmutableFieldChange( } } -export function assertValidContent( +export function assertValidContent( modelID: string, modelSchema: JSONSchema.Object, content: unknown, @@ -112,7 +112,7 @@ export function assertValidInitHeader( export function assertValidUniqueValue( definition: ModelDefinition, metadata: DocumentMetadata, - content: UnknowContent | null, + content: UnknownContent | null, ): void { // Unique field validation only applies to the SET account relation if (definition.accountRelation.type !== 'set') { diff --git a/packages/model-instance-handler/src/index.ts b/packages/model-instance-handler/src/index.ts index de3543b..90f6673 100644 --- a/packages/model-instance-handler/src/index.ts +++ b/packages/model-instance-handler/src/index.ts @@ -1,4 +1,5 @@ export * from './assertions.js' export * from './handlers.js' +export * from './codecs.js' export * from './types.js' export * from './validation.js' diff --git a/packages/model-instance-handler/src/types.ts b/packages/model-instance-handler/src/types.ts index 84b366e..a498dfc 100644 --- a/packages/model-instance-handler/src/types.ts +++ b/packages/model-instance-handler/src/types.ts @@ -2,10 +2,10 @@ import type { DocumentMetadata } from '@ceramic-sdk/model-instance-protocol' import type { ModelDefinition } from '@ceramic-sdk/model-protocol' import type { DID } from 'dids' -export type UnknowContent = Record +export type UnknownContent = Record export type DocumentState = { - content: UnknowContent | null + content: UnknownContent | null metadata: DocumentMetadata log: [string, ...Array] } diff --git a/packages/model-instance-handler/src/utils.ts b/packages/model-instance-handler/src/utils.ts index 6caac6a..3405baa 100644 --- a/packages/model-instance-handler/src/utils.ts +++ b/packages/model-instance-handler/src/utils.ts @@ -2,7 +2,7 @@ import { DocumentDataEventPayload } from '@ceramic-sdk/model-instance-protocol' import type { ModelDefinition } from '@ceramic-sdk/model-protocol' import { fromString as bytesFromString } from 'uint8arrays' -import type { DocumentState, UnknowContent } from './types.js' +import type { DocumentState, UnknownContent } from './types.js' export function getImmutableFieldsToCheck( definition: ModelDefinition, @@ -37,7 +37,7 @@ export function encodeUniqueFieldsValue(values: Array): Uint8Array { export function getUniqueFieldsValue( fields: Array, - content: UnknowContent, + content: UnknownContent, ): Uint8Array { const values = fields.map((field) => { const value = content[field] diff --git a/packages/model-instance-handler/src/validation.ts b/packages/model-instance-handler/src/validation.ts index 8023fd4..e71515b 100644 --- a/packages/model-instance-handler/src/validation.ts +++ b/packages/model-instance-handler/src/validation.ts @@ -1,7 +1,7 @@ import { StreamID } from '@ceramic-sdk/identifiers' import type { ModelDefinition } from '@ceramic-sdk/model-protocol' -import type { Context, UnknowContent } from './types.js' +import type { Context, UnknownContent } from './types.js' export async function validateRelation( context: Context, @@ -32,7 +32,7 @@ export async function validateRelation( export async function validateRelationsContent( context: Context, definition: ModelDefinition, - content: UnknowContent, + content: UnknownContent, ): Promise { if (!definition.relations) { return diff --git a/packages/stream-client/src/index.ts b/packages/stream-client/src/index.ts index 50a99d5..4b564ef 100644 --- a/packages/stream-client/src/index.ts +++ b/packages/stream-client/src/index.ts @@ -9,7 +9,7 @@ export type StreamState = { /** Controller of the stream */ controller: string /** Dimensions of the stream, each value is multibase encoded */ - dimensions: Record + dimensions: Record /** Multibase encoding of the data of the stream. Content is stream type specific */ data: string } diff --git a/typedoc.json b/typedoc.json index c18367c..4a99b69 100644 --- a/typedoc.json +++ b/typedoc.json @@ -8,6 +8,7 @@ "packages/model-client", "packages/model-instance-client", "packages/model-instance-protocol", + "packages/model-instance-handler", "packages/model-protocol", "packages/stream-client" ],