-
Notifications
You must be signed in to change notification settings - Fork 19
Expand file tree
/
Copy pathclient.ts
More file actions
301 lines (278 loc) · 9.42 KB
/
client.ts
File metadata and controls
301 lines (278 loc) · 9.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
import {
InitEventPayload,
SignedEvent,
decodeMultibaseToJSON,
decodeMultibaseToStreamID,
} from '@ceramic-sdk/events'
import { CommitID, StreamID } from '@ceramic-sdk/identifiers'
import {
DocumentEvent,
getStreamID,
} from '@ceramic-sdk/model-instance-protocol'
import type { ModelDefinition } from '@ceramic-sdk/model-protocol'
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,
} from './events.js'
import type { DocumentState, UnknownContent } from './types.js'
/**
* Parameters for creating a singleton instance of a model.
*/
export type CreateSingletonParams = {
/** The model's stream ID */
model: StreamID
/** The controller of the stream */
controller: DID
/** A unique value to ensure determinism of the event */
uniqueValue?: Uint8Array
}
/**
* Parameters for creating an instance of a model.
*/
export type CreateInstanceParams<T extends UnknownContent = UnknownContent> =
Omit<CreateInitEventParams<T>, 'controller' | 'content'> & {
/** The model definition containing account relation info */
modelDefinition?: ModelDefinition
/** The document content */
content?: T
/** The controller DID */
controller?: DID
}
/**
* Parameters for posting a data event.
*/
export type PostDataParams<T extends UnknownContent = UnknownContent> = Omit<
CreateDataEventParams<T>,
'controller'
> & {
controller?: DID
}
/**
* Parameters for updating a document with new content.
*/
export type UpdateDataParams<T extends UnknownContent = UnknownContent> = Omit<
PostDataEventParams<T>,
'controller'
> & {
controller?: DID
}
/**
* Extends the StreamClient to add functionality for interacting with Ceramic model instance documents.
*
* The `ModelInstanceClient` class provides methods to:
* - Retrieve events and document states
* - Create instances and singleton of models
* - Update existing documents with new content
*/
export class ModelInstanceClient extends StreamClient {
/**
* Retrieves a `DocumentEvent` based on its commit ID.
*
* @param commitID - The commit ID of the event, either as a `CommitID` object or string.
* @returns A promise that resolves to the `DocumentEvent` for the specified commit ID.
*
* @throws Will throw an error if the commit ID is invalid or the request fails.
*/
async getEvent(commitID: CommitID | string): Promise<DocumentEvent> {
const id =
typeof commitID === 'string' ? CommitID.fromString(commitID) : commitID
return (await this.ceramic.getEventType(
DocumentEvent,
id.commit.toString(),
)) as DocumentEvent
}
/**
* Creates an instance of a model with account relation single.
* By definition this instance will always be a singleton.
*/
async createSingleton(params: CreateSingletonParams): Promise<CommitID> {
const event = getDeterministicInitEventPayload(
params.model,
params.controller,
params.uniqueValue,
)
const cid = await this.ceramic.postEventType(InitEventPayload, event)
return CommitID.fromStream(getStreamID(cid))
}
/**
* Creates an instance based on the model definition's account relation type.
* - LIST: Creates a new instance with random unique value
* - SET: Creates a deterministic instance based on specified field values
* - SINGLE: Creates a singleton instance
*
* @param params - Parameters for creating the instance
* @returns The commit ID of the created instance
*/
async createInstance<T extends UnknownContent = UnknownContent>(
params: CreateInstanceParams<T>,
): Promise<CommitID> {
const {
model,
modelDefinition,
content,
controller,
shouldIndex,
modelVersion,
} = params
// Default to 'list' if no modelDefinition provided (backward compatibility)
const relationType = modelDefinition?.accountRelation?.type || 'list'
switch (relationType) {
case 'list': {
const event = await createInitEvent({
model,
content: content ?? null,
controller: this.getDID(controller),
shouldIndex,
modelVersion,
})
const cid = await this.ceramic.postEventType(SignedEvent, event)
return CommitID.fromStream(getStreamID(cid))
}
case 'single': {
return this.createSingleton({
model,
controller: this.getDID(controller),
})
}
case 'set': {
if (!modelDefinition || !modelDefinition.accountRelation) {
throw new Error('Model definition is required for SET relations')
}
// We know it's a SET relation, so fields must exist
const fields = (
modelDefinition.accountRelation as { type: 'set'; fields: string[] }
).fields
if (!fields || fields.length === 0) {
throw new Error('SET relation must specify fields')
}
// Extract "unique" components from content
const unique = fields.map((field: string) => {
const value =
content && typeof content === 'object'
? (content as Record<string, unknown>)[field]
: undefined
return value == null ? '' : String(value)
})
const uniqueValue = new TextEncoder().encode(unique.join('|'))
return this.createSingleton({
model,
controller: this.getDID(controller),
uniqueValue,
})
}
default:
throw new Error(`Unknown account relation type: ${relationType}`)
}
}
/**
* Posts a data event and returns its commit ID.
*
* @param params - Parameters for posting the data event.
* @returns A promise that resolves to the `CommitID` of the posted event.
*
* @remarks
* The data event updates the content of a stream and is associated with the
* current state of the stream.
*/
async postData<T extends UnknownContent = UnknownContent>(
params: PostDataParams<T>,
): Promise<CommitID> {
const { controller, ...rest } = params
const event = await createDataEvent({
...rest,
controller: this.getDID(controller),
})
const cid = await this.ceramic.postEventType(SignedEvent, event)
return CommitID.fromStream(params.currentID.baseID, cid)
}
/**
* Transforms a `StreamState` into a `DocumentState`.
*
* @param streamState - The stream state to transform.
* @returns The `DocumentState` derived from the stream state.
*/
streamStateToDocumentState(streamState: StreamState): DocumentState {
const streamID = StreamID.fromString(streamState.id)
const decodedData = decodeMultibaseToJSON(streamState.data)
const controller = streamState.controller
const modelID = decodeMultibaseToStreamID(streamState.dimensions.model)
return {
commitID: CommitID.fromStream(streamID, streamState.event_cid),
content: decodedData.content as UnknownContent | null,
metadata: {
model: modelID,
controller: controller as DIDString,
...(typeof decodedData.metadata === 'object'
? decodedData.metadata
: {}),
},
}
}
/**
* Retrieves the document state for a given stream ID.
*
* @param streamID - The stream ID, either as a `StreamID` object or string.
* @returns A promise that resolves to the `DocumentState`.
*
* @throws Will throw an error if the stream ID is invalid or the request fails.
*
* @remarks This method fetches the stream state using the extended StreamClient's `getStreamState` method.
*/
async getDocumentState(streamID: StreamID | string): Promise<DocumentState> {
const id =
typeof streamID === 'string' ? StreamID.fromString(streamID) : streamID
const streamState = await this.getStreamState(id)
return this.streamStateToDocumentState(streamState)
}
/**
* Updates a document with new content and returns the updated document state.
*
* @param params - Parameters for updating the document.
* @returns A promise that resolves to the updated `DocumentState`.
*
* @remarks
* This method posts the new content as a data event, updating the document.
* It can optionally take the current document state to avoid re-fetching it.
*/
async updateDocument<T extends UnknownContent = UnknownContent>(
params: UpdateDataParams<T>,
): Promise<DocumentState> {
let currentState: DocumentState
if (!params.currentState) {
const streamState = await this.getStreamState(
StreamID.fromString(params.streamID),
)
currentState = this.streamStateToDocumentState(streamState)
} else {
currentState = this.streamStateToDocumentState(params.currentState)
}
const { content } = currentState
const { controller, newContent, shouldIndex, modelVersion } = params
const newCommit = await this.postData({
controller: this.getDID(controller),
currentContent: content ?? undefined,
newContent,
currentID: currentState.commitID,
shouldIndex,
modelVersion,
})
return {
commitID: newCommit,
content: newContent,
metadata: {
model: currentState.metadata.model,
controller: currentState.metadata.controller,
...(typeof currentState.metadata === 'object'
? currentState.metadata
: {}),
},
}
}
}