Skip to content

Commit

Permalink
Add handlers for document events
Browse files Browse the repository at this point in the history
  • Loading branch information
PaulLeCam committed Jun 19, 2024
1 parent fddbf71 commit b8f0eb6
Show file tree
Hide file tree
Showing 15 changed files with 442 additions and 209 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"@biomejs/biome": "1.8.1",
"@jest/globals": "^29.7.0",
"@swc/cli": "^0.3.12",
"@swc/core": "^1.6.1",
"@swc/core": "^1.6.3",
"@swc/jest": "^0.2.36",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.5",
Expand Down
2 changes: 1 addition & 1 deletion packages/document-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ export {
getDeterministicInitEventPayload,
} from './events.js'
export type { UnknownContent } from './types.js'
export { getPatchOperations } from './utils.js'
export { createInitHeader, getPatchOperations } from './utils.js'
6 changes: 5 additions & 1 deletion packages/document-handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@
"@ceramic-sdk/identifiers": "workspace:^",
"ajv": "^8.16.0",
"ajv-formats": "^3.0.1",
"codeco": "^1.2.3",
"fast-json-patch": "^3.1.1",
"uint8arrays": "^5.1.0"
},
"devDependencies": {
"@ceramic-sdk/document-client": "workspace:^",
"@ceramic-sdk/key-did": "workspace:^",
"@ceramic-sdk/model-protocol": "workspace:^",
"dids": "^5.0.2"
"dids": "^5.0.2",
"multiformats": "^13.1.1",
"ts-essentials": "^10.0.1"
},
"jest": {
"extensionsToTreatAsEsm": [".ts"],
Expand Down
56 changes: 49 additions & 7 deletions packages/document-handler/src/assertions.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import type {
DocumentInitEventHeader,
DocumentMetadata,
JSONPatchOperation,
import {
type DocumentDataEventPayload,
type DocumentInitEventHeader,
type DocumentMetadata,
type JSONPatchOperation,
getStreamID,
} from '@ceramic-sdk/document-protocol'
import type { TimeEvent } from '@ceramic-sdk/events'
import type { JSONSchema, ModelDefinition } from '@ceramic-sdk/model-protocol'
import addFormats from 'ajv-formats'
import Ajv from 'ajv/dist/2020.js'
import { toString as bytesToString } from 'uint8arrays'
import { equals } from 'uint8arrays'

import type { UnknowContent } from './types.js'
import type { DocumentState, UnknowContent } from './types.js'
import { getUniqueFieldsValue } from './utils.js'

const validator = new Ajv({
Expand Down Expand Up @@ -127,9 +130,48 @@ export function assertValidUniqueValue(
definition.accountRelation.fields,
content,
)
if (unique !== bytesToString(metadata.unique)) {
if (!equals(unique, metadata.unique)) {
throw new Error(
'Unique content fields value does not match metadata. If you are trying to change the value of these fields, this is causing this error: these fields values are not mutable.',
)
}
}

/**
* Asserts that the 'id' and 'prev' properties of the given event properly link to the tip of
* the given document state.
*
* By the time the code gets into a StreamtypeHandler's applyCommit function the link to the state
* should already have been established by the stream loading and conflict resolution code, so
* if this check were to fail as part of a StreamtypeHandler's applyCommit function, that would
* indicate a programming error.
*/
export function assertEventLinksToState(
payload: DocumentDataEventPayload | TimeEvent,
state: DocumentState,
) {
// Older versions of the CAS created time events without an 'id' field, so only check
// the event payload 'id' field if it is present.
if (payload.id != null && !payload.id.equals(state.cid)) {
throw new Error(
`Invalid genesis CID in event payload for document ${getStreamID(state.cid)}. Found: ${
payload.id
}, expected ${state.cid}`,
)
}

const [init, ...changes] = state.log
if (init == null) {
throw new Error(
`Invalid state for document ${getStreamID(state.cid)}: log is empty`,
)
}

const expectedPrev =
changes.length === 0 ? state.cid : changes[changes.length - 1].id
if (!payload.prev.equals(expectedPrev)) {
throw new Error(
`Commit doesn't properly point to previous event payload in log for document ${getStreamID(state.cid)}. Expected ${expectedPrev}, found 'prev' ${payload.prev}`,
)
}
}
29 changes: 29 additions & 0 deletions packages/document-handler/src/codecs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
DeterministicInitEventPayload,
DocumentDataEventPayload,
DocumentInitEventPayload,
} from '@ceramic-sdk/document-protocol'
import { SignedEvent, TimeEvent } from '@ceramic-sdk/events'
import { type TypeOf, union } from 'codeco'
import 'ts-essentials' // Import needed for TS reference

export const DocumentEvent = union(
[
DeterministicInitEventPayload,
SignedEvent, // non-deterministic init or data payload
TimeEvent,
],
'DocumentEvent',
)
export type DocumentEvent = TypeOf<typeof DocumentEvent>

export const DocumentEventPayload = union(
[
DeterministicInitEventPayload,
DocumentInitEventPayload,
DocumentDataEventPayload,
TimeEvent,
],
'DocumentEventPayload',
)
export type DocumentEventPayload = TypeOf<typeof DocumentEventPayload>
176 changes: 136 additions & 40 deletions packages/document-handler/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,163 @@
import {
type DeterministicInitEventPayload,
DocumentDataEventPayload,
DocumentInitEventPayload,
DocumentMetadata,
type EncodedDocumentMetadata,
assertValidContentLength,
getDeterministicStreamID,
getStreamID,
} from '@ceramic-sdk/document-protocol'
import { eventToContainer } from '@ceramic-sdk/events'
import type { StreamID } from '@ceramic-sdk/identifiers'
import { TimeEvent, eventToContainer } from '@ceramic-sdk/events'
import jsonpatch from 'fast-json-patch'
import type { CID } from 'multiformats/cid'

import { assertValidContent, assertValidInitHeader } from './assertions.js'
import {
assertEventLinksToState,
assertNoImmutableFieldChange,
assertValidContent,
assertValidInitHeader,
assertValidUniqueValue,
} from './assertions.js'
import { type DocumentEvent, DocumentEventPayload } from './codecs.js'
import type { Context, DocumentState } from './types.js'
import { getImmutableFieldsToCheck } from './utils.js'
import { validateRelationsContent } from './validation.js'

export async function handleInitEvent(
event: unknown,
export async function handleDeterministicInitPayload(
cid: CID,
payload: DeterministicInitEventPayload,
context: Context,
): Promise<DocumentState> {
const container = await eventToContainer(
context.verifier,
DocumentInitEventPayload,
event,
)
const { data, header } = container.payload
const { data, header } = payload
if (data !== null) {
throw new Error(
'Deterministic init commits for ModelInstanceDocuments must not have content',
)
}

const modelID = header.model.toString()
const definition = await context.loadModelDefinition(modelID)
const definition = await context.getModelDefinition(modelID)
assertValidInitHeader(definition, header)

let streamID: StreamID
let metadata: EncodedDocumentMetadata
if (container.signed) {
// Handle non-deterministic event - should have content
assertValidContentLength(data)
assertValidContent(modelID, definition.schema, data)
await validateRelationsContent(context, definition, data)
streamID = getStreamID(container.cid)
metadata = DocumentMetadata.encode({
return {
cid,
content: null,
metadata: {
controller: header.controllers[0],
model: header.model,
unique: header.unique,
},
log: [payload],
}
}

export async function handleInitPayload(
cid: CID,
payload: DocumentInitEventPayload,
context: Context,
): Promise<DocumentState> {
const { data, header } = payload
assertValidContentLength(data)

const modelID = header.model.toString()
const definition = await context.getModelDefinition(modelID)
assertValidInitHeader(definition, header)

assertValidContent(modelID, definition.schema, data)
await validateRelationsContent(context, definition, data)

return {
cid,
content: data,
metadata: {
controller: header.controllers[0],
model: header.model,
unique: header.unique,
context: header.context,
shouldIndex: header.shouldIndex,
})
} else {
// Handle deterministic event - no content
if (data !== null) {
},
log: [payload],
}
}

export async function handleDataPayload(
payload: DocumentDataEventPayload,
context: Context,
): Promise<DocumentState> {
const state = await context.getDocumentState(payload.id)
assertEventLinksToState(payload, state)

// Check the header is valid when provided
if (payload.header != null) {
const { shouldIndex, ...others } = payload.header
const otherKeys = Object.keys(others)
if (otherKeys.length) {
throw new Error(
'Deterministic init commits for ModelInstanceDocuments must not have content',
`Updating metadata for ModelInstanceDocument Streams is not allowed. Tried to change metadata for Stream ${getStreamID(payload.id)} from ${JSON.stringify(
state.metadata,
)} to ${JSON.stringify(payload.header)}`,
)
}
streamID = getDeterministicStreamID(header)
metadata = DocumentMetadata.encode({
controller: header.controllers[0],
model: header.model,
unique: header.unique,
})
// Update metadata if needed
if (shouldIndex != null) {
state.metadata.shouldIndex = shouldIndex
}
}

return {
id: streamID.toString(),
content: data,
metadata,
log: [event],
// Get updated content by applying the patch to the current content
const content = jsonpatch.applyPatch(
state.content ?? {},
payload.data,
).newDocument
assertValidContentLength(content)

// Load the model definition for the document and validate the content
const modelID = state.metadata.model.toString()
const definition = await context.getModelDefinition(modelID)
assertValidContent(modelID, definition.schema, content)

// Check the content satifies the SET account relations and immutable fields constraints
assertValidUniqueValue(definition, state.metadata, content)
const immutableFields = getImmutableFieldsToCheck(definition, state)
if (immutableFields != null) {
assertNoImmutableFieldChange(payload.data, immutableFields)
}

// Validate relations
await validateRelationsContent(context, definition, content)

return { ...state, log: [...state.log, payload] }
}

export async function handleTimeEvent(
event: TimeEvent,
context: Context,
): Promise<DocumentState> {
const state = await context.getDocumentState(event.id)
assertEventLinksToState(event, state)
return { ...state, log: [...state.log, event] }
}

export async function handleEvent(
cid: CID,
event: DocumentEvent,
context: Context,
): Promise<DocumentState> {
const container = await eventToContainer(
context.verifier,
DocumentEventPayload,
event,
)
if (container.signed) {
// Signed event is either non-deterministic init or data
if (DocumentDataEventPayload.is(container.payload)) {
return await handleDataPayload(container.payload, context)
}
if (DocumentInitEventPayload.is(container.payload)) {
return await handleInitPayload(cid, container.payload, context)
}
}
// Unsigned event is either deterministic init or time
if (TimeEvent.is(container.payload)) {
return await handleTimeEvent(container.payload as TimeEvent, context)
}
return await handleDeterministicInitPayload(cid, container.payload, context)
}
25 changes: 19 additions & 6 deletions packages/document-handler/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
import type { EncodedDocumentMetadata } from '@ceramic-sdk/document-protocol'
import type {
DeterministicInitEventPayload,
DocumentDataEventPayload,
DocumentInitEventPayload,
DocumentMetadata,
} from '@ceramic-sdk/document-protocol'
import type { TimeEvent } from '@ceramic-sdk/events'
import type { ModelDefinition } from '@ceramic-sdk/model-protocol'
import type { DID } from 'dids'
import type { CID } from 'multiformats/cid'

export type InitEventPayload =
| DeterministicInitEventPayload
| DocumentInitEventPayload
export type ChangeEventPayload = DocumentDataEventPayload | TimeEvent

export type UnknowContent = Record<string, unknown>

export type DocumentSnapshot = {
content: UnknowContent | null
metadata: EncodedDocumentMetadata
metadata: DocumentMetadata
}

export type DocumentState = DocumentSnapshot & {
id: string
log: Array<unknown> // TODO: events
cid: CID
log: [InitEventPayload, ...Array<ChangeEventPayload>]
}

export type Context = {
loadDocument: (id: string) => Promise<DocumentSnapshot>
loadModelDefinition: (id: string) => Promise<ModelDefinition>
getDocumentSnapshot: (id: string) => Promise<DocumentSnapshot>
getDocumentState: (cid: CID) => Promise<DocumentState>
getModelDefinition: (id: string) => Promise<ModelDefinition>
verifier: DID
}
Loading

0 comments on commit b8f0eb6

Please sign in to comment.