From 32d429a701d25837b574cb3124af2ac477687c95 Mon Sep 17 00:00:00 2001 From: Mark Krasner <70119343+mzkrasner@users.noreply.github.com> Date: Wed, 11 Dec 2024 15:26:29 -0500 Subject: [PATCH] updateDocument() method for model-instance-client (#39) --- packages/model-client/src/index.ts | 12 +++ packages/model-instance-client/src/client.ts | 65 +++++++++++-- packages/model-instance-client/src/events.ts | 12 +++ .../model-instance-client/test/lib.test.ts | 80 ++++++++++++++++ tests/c1-integration/src/index.ts | 1 - tests/c1-integration/test/flight.test.ts | 69 ++++++++++++- .../test/model-mid-listType.test.ts | 89 +++++++++++++++++ .../test/model-mid-setType.test.ts | 96 +++++++++++++++++++ .../test/model-mid-singleType.test.ts | 88 +++++++++++++++++ 9 files changed, 500 insertions(+), 12 deletions(-) create mode 100644 tests/c1-integration/test/model-mid-listType.test.ts create mode 100644 tests/c1-integration/test/model-mid-setType.test.ts create mode 100644 tests/c1-integration/test/model-mid-singleType.test.ts diff --git a/packages/model-client/src/index.ts b/packages/model-client/src/index.ts index e958549..4d2883a 100644 --- a/packages/model-client/src/index.ts +++ b/packages/model-client/src/index.ts @@ -2,6 +2,7 @@ import { type PartialInitEventHeader, SignedEvent, createSignedInitEvent, + decodeMultibaseToJSON, eventToContainer, } from '@ceramic-sdk/events' import { StreamID } from '@ceramic-sdk/identifiers' @@ -67,4 +68,15 @@ export class ModelClient extends StreamClient { const cid = await this.ceramic.postEventType(SignedEvent, event) return getModelStreamID(cid) } + + /** Retrieve a model's JSON definition */ + async getModelDefinition( + streamID: StreamID | string, + ): Promise { + const id = typeof streamID === 'string' ? streamID : streamID.toString() // Convert StreamID to string + const streamState = await this.getStreamState(id) + const decodedData = decodeMultibaseToJSON(streamState.data) + .content as ModelDefinition + return decodedData + } } diff --git a/packages/model-instance-client/src/client.ts b/packages/model-instance-client/src/client.ts index bbb6d47..7ac2931 100644 --- a/packages/model-instance-client/src/client.ts +++ b/packages/model-instance-client/src/client.ts @@ -9,12 +9,13 @@ import { DocumentEvent, getStreamID, } from '@ceramic-sdk/model-instance-protocol' -import { StreamClient } from '@ceramic-sdk/stream-client' +import { StreamClient, type StreamState } from '@ceramic-sdk/stream-client' import type { DIDString } from '@didtools/codecs' import type { DID } from 'dids' import { type CreateDataEventParams, type CreateInitEventParams, + type PostDataEventParams, createDataEvent, createInitEvent, getDeterministicInitEventPayload, @@ -39,6 +40,13 @@ export type PostDataParams = Omit< controller?: DID } +export type UpdateDataParams = Omit< + PostDataEventParams, + 'controller' +> & { + controller?: DID +} + export class ModelInstanceClient extends StreamClient { /** Get a DocumentEvent based on its commit ID */ async getEvent(commitID: CommitID | string): Promise { @@ -89,12 +97,14 @@ export class ModelInstanceClient extends StreamClient { 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 + /** Gets currentID */ + getCurrentID(streamID: string): CommitID { + return new CommitID(3, streamID) + } - const decodedData = decodeMultibaseToJSON(encodedData) + /** Transform StreamState into DocumentState */ + streamStateToDocumentState(streamState: StreamState): DocumentState { + const decodedData = decodeMultibaseToJSON(streamState.data) const controller = streamState.controller const modelID = decodeMultibaseToStreamID(streamState.dimensions.model) return { @@ -108,4 +118,47 @@ export class ModelInstanceClient extends StreamClient { }, } } + + /** Retrieve and return document state */ + async getDocumentState(streamID: string): Promise { + const streamState = await this.getStreamState(streamID) + return this.streamStateToDocumentState(streamState) + } + + /** Post an update to a document that optionally obtains docstate first */ + async updateDocument( + params: UpdateDataParams, + ): Promise { + let currentState: DocumentState + let currentId: CommitID + // If currentState is not provided, fetch the current state + if (!params.currentState) { + const streamState = await this.getStreamState(params.streamID) + currentState = this.streamStateToDocumentState(streamState) + currentId = this.getCurrentID(streamState.event_cid) + } else { + currentState = this.streamStateToDocumentState(params.currentState) + currentId = this.getCurrentID(params.currentState.event_cid) + } + const { content } = currentState + const { controller, newContent, shouldIndex } = params + // Use existing postData utility to access the ceramic api + await this.postData({ + controller: this.getDID(controller), + currentContent: content ?? undefined, + newContent: newContent, + currentID: currentId, + shouldIndex: shouldIndex, + }) + return { + content: params.newContent, + metadata: { + model: currentState.metadata.model, + controller: currentState.metadata.controller, + ...(typeof currentState.metadata === 'object' + ? currentState.metadata + : {}), + }, + } + } } diff --git a/packages/model-instance-client/src/events.ts b/packages/model-instance-client/src/events.ts index 3f2e7b8..e9b6b2b 100644 --- a/packages/model-instance-client/src/events.ts +++ b/packages/model-instance-client/src/events.ts @@ -17,6 +17,7 @@ import { import type { DIDString } from '@didtools/codecs' import type { DID } from 'dids' +import type { StreamState } from '@ceramic-sdk/stream-client' import type { UnknownContent } from './types.js' import { createInitHeader, getPatchOperations } from './utils.js' @@ -122,6 +123,17 @@ export type CreateDataEventParams = { shouldIndex?: boolean } +export type PostDataEventParams = { + /** String representation of the StreamID to update */ + streamID: string + /** New JSON object content for the ModelInstanceDocument stream, used with `currentContent` to create the JSON patch */ + newContent: T + /** Current JSON object containing the stream's current state */ + currentState?: StreamState + /** Flag notifying indexers if they should index the ModelInstanceDocument stream or not */ + shouldIndex?: boolean +} + /** * Create a signed data event for a ModelInstanceDocument stream */ diff --git a/packages/model-instance-client/test/lib.test.ts b/packages/model-instance-client/test/lib.test.ts index 4daa45e..a080da3 100644 --- a/packages/model-instance-client/test/lib.test.ts +++ b/packages/model-instance-client/test/lib.test.ts @@ -257,4 +257,84 @@ describe('ModelInstanceClient', () => { ) }) }) + describe('updateDocument() method', () => { + const mockStreamState = { + id: 'k2t6wyfsu4pfy7r1jdd6jex9oxbqyp4gr2a5kxs8ioxwtisg8nzj3anbckji8g', + event_cid: 'bafyreib5j4def5a4w4j6sg4upm6nb4cfn752wdjwqtwdzejfladyyymxca', + controller: 'did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp', + dimensions: { + context: 'u', + controller: + 'uZGlkOmtleTp6Nk1raVRCejF5bXVlcEFRNEhFSFlTRjFIOHF1RzVHTFZWUVIzZGpkWDNtRG9vV3A', + model: 'uzgEAAXESIA8og02Dnbwed_besT8M0YOnaZ-hrmMZaa7mnpdUL8jE', + }, + data: 'ueyJtZXRhZGF0YSI6eyJzaG91bGRJbmRleCI6dHJ1ZX0sImNvbnRlbnQiOnsiYm9keSI6IlRoaXMgaXMgYSBzaW1wbGUgbWVzc2FnZSJ9fQ', + } + test('updates a document with new content when current is not provided', async () => { + const newContent = { body: 'This is a new message' } + const streamId = + 'k2t6wyfsu4pfy7r1jdd6jex9oxbqyp4gr2a5kxs8ioxwtisg8nzj3anbckji8g' + // Mock CeramicClient and its API + const postEventType = jest.fn(() => randomCID()) + const mockGet = jest.fn(() => + Promise.resolve({ + data: mockStreamState, + error: null, + }), + ) + const ceramic = { + api: { GET: mockGet }, + postEventType, + } as unknown as CeramicClient + const client = new ModelInstanceClient({ ceramic, did: authenticatedDID }) + jest.spyOn(client, 'getDocumentState') + jest.spyOn(client, 'postData') + const newState = await client.updateDocument({ + streamID: streamId, + newContent, + shouldIndex: true, + }) + expect(client.postData).toHaveBeenCalledWith({ + controller: authenticatedDID, + currentID: new CommitID(3, mockStreamState.event_cid), + currentContent: { body: 'This is a simple message' }, + newContent, + shouldIndex: true, + }) + expect(newState.content).toEqual(newContent) + expect(postEventType).toHaveBeenCalled() + expect(mockGet).toHaveBeenCalledWith('/streams/{stream_id}', { + params: { path: { stream_id: streamId } }, + }) + }) + test('updates a document with new content when current is provided', async () => { + const newContent = { body: 'This is a new message' } + const streamId = + 'k2t6wyfsu4pfy7r1jdd6jex9oxbqyp4gr2a5kxs8ioxwtisg8nzj3anbckji8g' + // Mock CeramicClient and its API + const postEventType = jest.fn(() => randomCID()) + const mockGet = jest.fn(() => + Promise.resolve({ + data: mockStreamState, + error: null, + }), + ) + + const ceramic = { + api: { GET: mockGet }, + postEventType, + } as unknown as CeramicClient + const client = new ModelInstanceClient({ ceramic, did: authenticatedDID }) + jest.spyOn(client, 'streamStateToDocumentState') + const newState = await client.updateDocument({ + streamID: streamId, + newContent, + currentState: mockStreamState, + }) + expect(client.streamStateToDocumentState).toHaveBeenCalled() + expect(newState.content).toEqual(newContent) + expect(postEventType).toHaveBeenCalled() + expect(mockGet).not.toHaveBeenCalled() + }) + }) }) diff --git a/tests/c1-integration/src/index.ts b/tests/c1-integration/src/index.ts index dc70b44..009202d 100644 --- a/tests/c1-integration/src/index.ts +++ b/tests/c1-integration/src/index.ts @@ -10,7 +10,6 @@ const DEFAULT_ENVIRONMENT = { CERAMIC_ONE_S3_BUCKET: 'sdkintegrationtests.new', CERAMIC_ONE_LOG_FORMAT: 'single-line', CERAMIC_ONE_NETWORK: 'in-memory', - CERAMIC_ONE_STORE_DIR: '/', CERAMIC_ONE_AGGREGATOR: 'true', CERAMIC_ONE_OBJECT_STORE_URL: 'file://./generated', } diff --git a/tests/c1-integration/test/flight.test.ts b/tests/c1-integration/test/flight.test.ts index 860b176..7bf5942 100644 --- a/tests/c1-integration/test/flight.test.ts +++ b/tests/c1-integration/test/flight.test.ts @@ -1,8 +1,15 @@ +import { InitEventPayload, SignedEvent, signEvent } from '@ceramic-sdk/events' import { type ClientOptions, type FlightSqlClient, createFlightSqlClient, } from '@ceramic-sdk/flight-sql-client' +import { CeramicClient } from '@ceramic-sdk/http-client' +import { StreamID } from '@ceramic-sdk/identifiers' +import { ModelClient } from '@ceramic-sdk/model-client' +import type { ModelDefinition } from '@ceramic-sdk/model-protocol' +import { asDIDString } from '@didtools/codecs' +import { getAuthenticatedDID } from '@didtools/key-did' import { tableFromIPC } from 'apache-arrow' import CeramicOneContainer from '../src' import type { EnvironmentOptions } from '../src' @@ -24,44 +31,92 @@ const OPTIONS: ClientOptions = { port: CONTAINER_OPTS.flightSqlPort, } +const testModel: ModelDefinition = { + version: '2.0', + name: 'ListTestModel', + description: 'List Test model', + accountRelation: { type: 'list' }, + interface: false, + implements: [], + schema: { + type: 'object', + properties: { + test: { type: 'string', maxLength: 10 }, + }, + additionalProperties: false, + }, +} + async function getClient(): Promise { return createFlightSqlClient(OPTIONS) } describe('flight sql', () => { let c1Container: CeramicOneContainer + const ceramicClient = new CeramicClient({ + url: `http://127.0.0.1:${CONTAINER_OPTS.apiPort}`, + }) beforeAll(async () => { c1Container = await CeramicOneContainer.startContainer(CONTAINER_OPTS) + const authenticatedDID = await getAuthenticatedDID(new Uint8Array(32)) + c1Container = await CeramicOneContainer.startContainer(CONTAINER_OPTS) + + // create a new event + const model = StreamID.fromString( + 'kjzl6hvfrbw6c5he7fxl3oakeckm2kchkqboqug08inkh1tmfqpd8v3oceriml2', + ) + const eventPayload: InitEventPayload = { + data: { + body: 'This is a simple message', + }, + header: { + controllers: [asDIDString(authenticatedDID.id)], + model, + sep: 'test', + }, + } + const encodedPayload = InitEventPayload.encode(eventPayload) + const signedEvent = await signEvent(authenticatedDID, encodedPayload) + await ceramicClient.postEventType(SignedEvent, signedEvent) + + // create a model streamType + const modelClient = new ModelClient({ + ceramic: ceramicClient, + did: authenticatedDID, + }) + await modelClient.postDefinition(testModel) }, 10000) test('makes query', async () => { const client = await getClient() const buffer = await client.query('SELECT * FROM conclusion_events') const data = tableFromIPC(buffer) - console.log(JSON.stringify(data)) + const row = data.get(0) + expect(row).toBeDefined() + expect(data.numRows).toBe(2) }) test('catalogs', async () => { const client = await getClient() const buffer = await client.getCatalogs() const data = tableFromIPC(buffer) - console.log(JSON.stringify(data)) + const row = data.get(0) + expect(row).toBeDefined() }) test('schemas', async () => { const client = await getClient() const buffer = await client.getDbSchemas({}) const data = tableFromIPC(buffer) - console.log(JSON.stringify(data)) + const row = data.get(0) + expect(row).toBeDefined() }) test('tables', async () => { const client = await getClient() const withSchema = await client.getTables({ includeSchema: true }) const noSchema = await client.getTables({ includeSchema: false }) - console.log(JSON.stringify(tableFromIPC(withSchema))) - console.log(JSON.stringify(tableFromIPC(noSchema))) expect(withSchema).not.toBe(noSchema) }) @@ -72,7 +127,11 @@ describe('flight sql', () => { new Array(['$1', '3']), ) const data = tableFromIPC(buffer) + const row = data.get(0) + const streamType = row?.stream_type + expect(streamType).toBe(3) expect(data).toBeDefined() + expect(data.numRows).toBe(1) }) afterAll(async () => { diff --git a/tests/c1-integration/test/model-mid-listType.test.ts b/tests/c1-integration/test/model-mid-listType.test.ts new file mode 100644 index 0000000..d75c6eb --- /dev/null +++ b/tests/c1-integration/test/model-mid-listType.test.ts @@ -0,0 +1,89 @@ +import { CeramicClient } from '@ceramic-sdk/http-client' +import type { CommitID, StreamID } from '@ceramic-sdk/identifiers' +import { ModelClient } from '@ceramic-sdk/model-client' +import { ModelInstanceClient } from '@ceramic-sdk/model-instance-client' +import type { ModelDefinition } from '@ceramic-sdk/model-protocol' +import { getAuthenticatedDID } from '@didtools/key-did' +import CeramicOneContainer, { type EnvironmentOptions } from '../src' + +const authenticatedDID = await getAuthenticatedDID(new Uint8Array(32)) + +const testModel: ModelDefinition = { + version: '2.0', + name: 'ListTestModel', + description: 'List Test model', + accountRelation: { type: 'list' }, + interface: false, + implements: [], + schema: { + type: 'object', + properties: { + test: { type: 'string', maxLength: 10 }, + }, + additionalProperties: false, + }, +} + +const CONTAINER_OPTS: EnvironmentOptions = { + containerName: 'ceramic-test-model-MID-list', + apiPort: 5222, + flightSqlPort: 5223, + testPort: 5223, +} + +const client = new CeramicClient({ + url: `http://127.0.0.1:${CONTAINER_OPTS.apiPort}`, +}) + +const modelInstanceClient = new ModelInstanceClient({ + ceramic: client, + did: authenticatedDID, +}) + +const modelClient = new ModelClient({ + ceramic: client, + did: authenticatedDID, +}) + +describe('model integration test for list model and MID', () => { + let c1Container: CeramicOneContainer + let modelStream: StreamID + let documentStream: CommitID + + beforeAll(async () => { + c1Container = await CeramicOneContainer.startContainer(CONTAINER_OPTS) + modelStream = await modelClient.postDefinition(testModel) + }, 10000) + + test('gets correct model definition', async () => { + // wait one second + await new Promise((resolve) => setTimeout(resolve, 1000)) + const definition = await modelClient.getModelDefinition(modelStream) + expect(definition).toEqual(testModel) + }) + test('posts signed init event and obtains correct state', async () => { + documentStream = await modelInstanceClient.postSignedInit({ + model: modelStream, + content: { test: 'hello' }, + shouldIndex: true, + }) + // wait 1 seconds + await new Promise((resolve) => setTimeout(resolve, 1000)) + const currentState = await modelInstanceClient.getDocumentState( + documentStream.toString(), + ) + expect(currentState.content).toEqual({ test: 'hello' }) + }) + test('updates document and obtains correct state', async () => { + // update the document + const updatedState = await modelInstanceClient.updateDocument({ + streamID: documentStream.toString(), + newContent: { test: 'world' }, + shouldIndex: true, + }) + expect(updatedState.content).toEqual({ test: 'world' }) + }) + afterAll(async () => { + await c1Container.teardown() + }) +}) diff --git a/tests/c1-integration/test/model-mid-setType.test.ts b/tests/c1-integration/test/model-mid-setType.test.ts new file mode 100644 index 0000000..7f642ed --- /dev/null +++ b/tests/c1-integration/test/model-mid-setType.test.ts @@ -0,0 +1,96 @@ +import { CeramicClient } from '@ceramic-sdk/http-client' +import type { CommitID, StreamID } from '@ceramic-sdk/identifiers' +import { ModelClient } from '@ceramic-sdk/model-client' +import { ModelInstanceClient } from '@ceramic-sdk/model-instance-client' +import type { ModelDefinition } from '@ceramic-sdk/model-protocol' +import { getAuthenticatedDID } from '@didtools/key-did' +import CeramicOneContainer, { type EnvironmentOptions } from '../src' + +const authenticatedDID = await getAuthenticatedDID(new Uint8Array(32)) + +const testModel: ModelDefinition = { + version: '2.0', + name: 'SetTestModel', + description: 'Set Test model', + accountRelation: { + type: 'set', + fields: ['test'], + }, + schema: { + type: 'object', + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: { + test: { + type: 'string', + }, + }, + required: ['test'], + additionalProperties: false, + }, + interface: false, + implements: [], +} + +const CONTAINER_OPTS: EnvironmentOptions = { + containerName: 'ceramic-test-model-MID-list', + apiPort: 5222, + flightSqlPort: 5223, + testPort: 5223, +} + +const client = new CeramicClient({ + url: `http://127.0.0.1:${CONTAINER_OPTS.apiPort}`, +}) + +const modelInstanceClient = new ModelInstanceClient({ + ceramic: client, + did: authenticatedDID, +}) + +const modelClient = new ModelClient({ + ceramic: client, + did: authenticatedDID, +}) + +describe('model integration test for list model and MID', () => { + let c1Container: CeramicOneContainer + let modelStream: StreamID + let documentStream: CommitID + + beforeAll(async () => { + c1Container = await CeramicOneContainer.startContainer(CONTAINER_OPTS) + modelStream = await modelClient.postDefinition(testModel) + }, 10000) + + test('gets correct model definition', async () => { + // wait one second + await new Promise((resolve) => setTimeout(resolve, 1000)) + const definition = await modelClient.getModelDefinition(modelStream) + expect(definition).toEqual(testModel) + }) + test('posts signed init event and obtains correct state', async () => { + documentStream = await modelInstanceClient.postSignedInit({ + model: modelStream, + content: { test: 'hello' }, + shouldIndex: true, + }) + // wait 1 seconds + await new Promise((resolve) => setTimeout(resolve, 1000)) + const currentState = await modelInstanceClient.getDocumentState( + documentStream.toString(), + ) + expect(currentState.content).toEqual({ test: 'hello' }) + }) + test('updates document and obtains correct state', async () => { + // update the document + const updatedState = await modelInstanceClient.updateDocument({ + streamID: documentStream.toString(), + newContent: { test: 'world' }, + shouldIndex: true, + }) + expect(updatedState.content).toEqual({ test: 'world' }) + }) + afterAll(async () => { + await c1Container.teardown() + }) +}) diff --git a/tests/c1-integration/test/model-mid-singleType.test.ts b/tests/c1-integration/test/model-mid-singleType.test.ts new file mode 100644 index 0000000..3fb4beb --- /dev/null +++ b/tests/c1-integration/test/model-mid-singleType.test.ts @@ -0,0 +1,88 @@ +import { CeramicClient } from '@ceramic-sdk/http-client' +import type { CommitID, StreamID } from '@ceramic-sdk/identifiers' +import { ModelClient } from '@ceramic-sdk/model-client' +import { ModelInstanceClient } from '@ceramic-sdk/model-instance-client' +import type { ModelDefinition } from '@ceramic-sdk/model-protocol' +import { getAuthenticatedDID } from '@didtools/key-did' +import CeramicOneContainer, { type EnvironmentOptions } from '../src' + +const authenticatedDID = await getAuthenticatedDID(new Uint8Array(32)) + +const testModel: ModelDefinition = { + version: '2.0', + name: 'SingleTestModel', + description: 'Single Test model', + accountRelation: { type: 'single' }, + interface: false, + implements: [], + schema: { + type: 'object', + properties: { + test: { type: 'string', maxLength: 10 }, + }, + additionalProperties: false, + }, +} + +const CONTAINER_OPTS: EnvironmentOptions = { + containerName: 'ceramic-test-model-MID-list', + apiPort: 5222, + flightSqlPort: 5223, + testPort: 5223, +} + +const client = new CeramicClient({ + url: `http://127.0.0.1:${CONTAINER_OPTS.apiPort}`, +}) + +const modelInstanceClient = new ModelInstanceClient({ + ceramic: client, + did: authenticatedDID, +}) + +const modelClient = new ModelClient({ + ceramic: client, + did: authenticatedDID, +}) + +describe('model integration test for list model and MID', () => { + let c1Container: CeramicOneContainer + let modelStream: StreamID + let documentStream: CommitID + + beforeAll(async () => { + c1Container = await CeramicOneContainer.startContainer(CONTAINER_OPTS) + modelStream = await modelClient.postDefinition(testModel) + }, 10000) + + test('gets correct model definition', async () => { + // wait one second + await new Promise((resolve) => setTimeout(resolve, 1000)) + const definition = await modelClient.getModelDefinition(modelStream) + expect(definition).toEqual(testModel) + }) + test('posts signed init event and obtains correct state', async () => { + documentStream = await modelInstanceClient.postDeterministicInit({ + model: modelStream, + controller: authenticatedDID.id, + }) + // wait 1 seconds + await new Promise((resolve) => setTimeout(resolve, 1000)) + const currentState = await modelInstanceClient.getDocumentState( + documentStream.toString(), + ) + expect(currentState.content).toEqual(null) + }) + test('updates document and obtains correct state', async () => { + // update the document + const updatedState = await modelInstanceClient.updateDocument({ + streamID: documentStream.toString(), + newContent: { test: 'hello' }, + shouldIndex: true, + }) + expect(updatedState.content).toEqual({ test: 'hello' }) + }) + afterAll(async () => { + await c1Container.teardown() + }) +})