Skip to content

Commit

Permalink
updateDocument() method for model-instance-client (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
mzkrasner authored Dec 11, 2024
1 parent b5ab691 commit 32d429a
Show file tree
Hide file tree
Showing 9 changed files with 500 additions and 12 deletions.
12 changes: 12 additions & 0 deletions packages/model-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type PartialInitEventHeader,
SignedEvent,
createSignedInitEvent,
decodeMultibaseToJSON,
eventToContainer,
} from '@ceramic-sdk/events'
import { StreamID } from '@ceramic-sdk/identifiers'
Expand Down Expand Up @@ -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<ModelDefinition> {
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
}
}
65 changes: 59 additions & 6 deletions packages/model-instance-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,6 +40,13 @@ export type PostDataParams<T extends UnknownContent = UnknownContent> = Omit<
controller?: DID
}

export type UpdateDataParams<T extends UnknownContent = UnknownContent> = Omit<
PostDataEventParams<T>,
'controller'
> & {
controller?: DID
}

export class ModelInstanceClient extends StreamClient {
/** Get a DocumentEvent based on its commit ID */
async getEvent(commitID: CommitID | string): Promise<DocumentEvent> {
Expand Down Expand Up @@ -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<DocumentState> {
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 {
Expand All @@ -108,4 +118,47 @@ export class ModelInstanceClient extends StreamClient {
},
}
}

/** Retrieve and return document state */
async getDocumentState(streamID: string): Promise<DocumentState> {
const streamState = await this.getStreamState(streamID)
return this.streamStateToDocumentState(streamState)
}

/** Post an update to a document that optionally obtains docstate first */
async updateDocument<T extends UnknownContent = UnknownContent>(
params: UpdateDataParams<T>,
): Promise<DocumentState> {
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
: {}),
},
}
}
}
12 changes: 12 additions & 0 deletions packages/model-instance-client/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -122,6 +123,17 @@ export type CreateDataEventParams<T extends UnknownContent = UnknownContent> = {
shouldIndex?: boolean
}

export type PostDataEventParams<T extends UnknownContent = UnknownContent> = {
/** 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
*/
Expand Down
80 changes: 80 additions & 0 deletions packages/model-instance-client/test/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
1 change: 0 additions & 1 deletion tests/c1-integration/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand Down
69 changes: 64 additions & 5 deletions tests/c1-integration/test/flight.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<FlightSqlClient> {
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)
})

Expand All @@ -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 () => {
Expand Down
Loading

0 comments on commit 32d429a

Please sign in to comment.