Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

updateDocument() method for model-instance-client #39

Merged
merged 12 commits into from
Dec 11, 2024
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
Loading