diff --git a/sdk/packages/model-instance-client/package.json b/sdk/packages/model-instance-client/package.json index 5d8ce8bf..e2195780 100644 --- a/sdk/packages/model-instance-client/package.json +++ b/sdk/packages/model-instance-client/package.json @@ -34,6 +34,7 @@ "@ceramic-sdk/events": "workspace:^", "@ceramic-sdk/identifiers": "workspace:^", "@ceramic-sdk/model-instance-protocol": "workspace:^", + "@ceramic-sdk/model-protocol": "workspace:^", "@ceramic-sdk/stream-client": "workspace:^", "@didtools/codecs": "^3.0.0", "fast-json-patch": "^3.1.1", diff --git a/sdk/packages/model-instance-client/src/client.ts b/sdk/packages/model-instance-client/src/client.ts index d54900f6..e2ce480d 100644 --- a/sdk/packages/model-instance-client/src/client.ts +++ b/sdk/packages/model-instance-client/src/client.ts @@ -9,6 +9,7 @@ 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' @@ -38,7 +39,12 @@ export type CreateSingletonParams = { * Parameters for creating an instance of a model. */ export type CreateInstanceParams = - Omit, 'controller'> & { + Omit, 'controller' | 'content'> & { + /** The model definition containing account relation info */ + modelDefinition?: ModelDefinition + /** The document content */ + content?: T + /** The controller DID */ controller?: DID } @@ -103,18 +109,82 @@ export class ModelInstanceClient extends StreamClient { } /** - * Creates an instance of a model. The model must have an account relation of list or set. + * 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( params: CreateInstanceParams, ): Promise { - const { controller, ...rest } = params - const event = await createInitEvent({ - ...rest, - controller: this.getDID(controller), - }) - const cid = await this.ceramic.postEventType(SignedEvent, event) - return CommitID.fromStream(getStreamID(cid)) + 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)[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}`) + } } /** diff --git a/sdk/packages/model-instance-client/test/set-relations.test.ts b/sdk/packages/model-instance-client/test/set-relations.test.ts new file mode 100644 index 00000000..7fe35aa7 --- /dev/null +++ b/sdk/packages/model-instance-client/test/set-relations.test.ts @@ -0,0 +1,516 @@ +import { InitEventPayload, SignedEvent } from '@ceramic-sdk/events' +import type { CeramicClient } from '@ceramic-sdk/http-client' +import { StreamID } from '@ceramic-sdk/identifiers' +import type { ModelDefinition } from '@ceramic-sdk/model-protocol' +import type { StreamState } from '@ceramicnetwork/common' +import { getAuthenticatedDID } from '@didtools/key-did' +import { jest } from '@jest/globals' +import type { DID } from 'dids' +import { bases } from 'multiformats/basics' +import { ModelInstanceClient } from '../src' + +describe('ModelInstanceClient SET Relations', () => { + let client: ModelInstanceClient + let mockCeramic: CeramicClient + let authenticatedDID: DID + + // Helper to create multibase encoded data + const encodeMultibase = (data: unknown): string => { + const json = typeof data === 'string' ? data : JSON.stringify(data) + const bytes = new TextEncoder().encode(json) + return bases.base64url.encode(bytes) + } + + // Helper to encode StreamID for dimensions + const encodeStreamID = (streamId: StreamID): string => { + return bases.base64url.encode(streamId.bytes) + } + + beforeEach(async () => { + authenticatedDID = await getAuthenticatedDID(new Uint8Array(32)) + + mockCeramic = { + getStreamState: jest.fn(), + postEventType: jest.fn(), + getVersion: jest.fn().mockResolvedValue({ version: '0.55.0' }), + api: { + GET: jest.fn().mockResolvedValue({ + data: { + id: 'kjzl6kcym7w8y8xqxtdnzh6x6ehb2dkcqc37hbmm5fhc6xzpv9m1muon9y5avyn', + data: encodeMultibase({ content: null }), + event_cid: + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a', + dimensions: { + model: encodeStreamID( + StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ), + ), + }, + controller: authenticatedDID.id, + }, + error: null, + }), + }, + } as CeramicClient + + client = new ModelInstanceClient({ + ceramic: mockCeramic, + did: authenticatedDID, + }) + }) + + describe('createSingleton method with unique values', () => { + test('should create deterministic instance with unique values', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + mockCeramic.postEventType.mockResolvedValueOnce( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + // Create deterministic instance with explicit unique values + const uniqueValue = new TextEncoder().encode('production|v1.2.3') + await client.createSingleton({ + model: modelId, + controller: authenticatedDID, + uniqueValue, + }) + + // Verify the unique value was passed correctly + expect(mockCeramic.postEventType).toHaveBeenCalled() + const [eventType, payload] = mockCeramic.postEventType.mock.calls[0] + + // Should use InitEventPayload (deterministic) + expect(eventType).toBe(InitEventPayload) + + // Check the unique value + const uniqueBytes = (payload as InitEventPayload).header.unique + const uniqueString = new TextDecoder().decode(uniqueBytes) + expect(uniqueString).toBe('production|v1.2.3') + }) + + test('should handle empty unique values', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + const uniqueValue = new TextEncoder().encode('|tag') + await client.createSingleton({ + model: modelId, + controller: authenticatedDID, + uniqueValue, + }) + + const [, payload] = mockCeramic.postEventType.mock.calls[0] + const uniqueString = new TextDecoder().decode(payload.header.unique) + expect(uniqueString).toBe('|tag') + }) + }) + + describe('createInstance method', () => { + test('should default to LIST relation when modelDefinition is not provided', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + // When no modelDefinition is provided, it should default to LIST relation + await client.createInstance({ + model: modelId, + content: { test: 'data' }, + }) + + // Should use SignedEvent for LIST relations + expect(mockCeramic.postEventType).toHaveBeenCalledWith( + SignedEvent, + expect.any(Object), + ) + }) + + test('should handle SET relation with automatic unique extraction', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + const modelDef: ModelDefinition = { + version: '2.0', + name: 'TestModel', + accountRelation: { + type: 'set', + fields: ['environment', 'version'], + }, + interface: false, + implements: [], + schema: { + type: 'object', + properties: { + environment: { type: 'string' }, + version: { type: 'string' }, + data: { type: 'string' }, + }, + }, + } + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + const content = { + environment: 'production', + version: 'v1.2.3', + data: 'some data', + } + + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + content, + shouldIndex: true, + }) + + const [eventType, payload] = mockCeramic.postEventType.mock.calls[0] + expect(eventType).toBe(InitEventPayload) + + const uniqueString = new TextDecoder().decode(payload.header.unique) + expect(uniqueString).toBe('production|v1.2.3') + }) + + test('should handle missing field values in SET relation', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + const modelDef: ModelDefinition = { + version: '2.0', + name: 'TestModel', + accountRelation: { + type: 'set', + fields: ['field1', 'field2', 'field3'], + }, + interface: false, + implements: [], + schema: {}, + } + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + const content = { + field1: 'value1', + field3: 'value3', + // field2 is missing + } + + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + content, + }) + + const [, payload] = mockCeramic.postEventType.mock.calls[0] + const uniqueString = new TextDecoder().decode(payload.header.unique) + expect(uniqueString).toBe('value1||value3') + }) + + test('should convert non-string values correctly', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + const modelDef: ModelDefinition = { + version: '2.0', + name: 'TestModel', + accountRelation: { + type: 'set', + fields: ['boolField', 'numField', 'strField'], + }, + interface: false, + implements: [], + schema: {}, + } + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + const content = { + boolField: true, + numField: 42, + strField: 'hello', + } + + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + content, + }) + + const [, payload] = mockCeramic.postEventType.mock.calls[0] + const uniqueString = new TextDecoder().decode(payload.header.unique) + expect(uniqueString).toBe('true|42|hello') + }) + + test('should handle LIST relation', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + const modelDef: ModelDefinition = { + version: '2.0', + name: 'ListModel', + accountRelation: { type: 'list' }, + interface: false, + implements: [], + schema: {}, + } + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + content: { data: 'test' }, + }) + + // Should use SignedEvent for LIST relations + expect(mockCeramic.postEventType).toHaveBeenCalledWith( + SignedEvent, + expect.any(Object), + ) + }) + + test('should handle SINGLE relation', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + const modelDef: ModelDefinition = { + version: '2.0', + name: 'SingleModel', + accountRelation: { type: 'single' }, + interface: false, + implements: [], + schema: {}, + } + + mockCeramic.postEventType.mockResolvedValueOnce( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + }) + + // Should create singleton without unique value + const [, payload] = mockCeramic.postEventType.mock.calls[0] + expect(payload.header.unique).toBeUndefined() + }) + }) + + describe('Core logic validation', () => { + test('createInstance correctly calculates unique values for SET relations', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + const modelDef: ModelDefinition = { + version: '2.0', + name: 'TestModel', + accountRelation: { + type: 'set', + fields: ['field1', 'field2', 'field3'], + }, + interface: false, + implements: [], + schema: {}, + } + + const content = { + field1: 'field1Value', + field2: 'field2Value', + field3: 'field3Value', + extra: 'data', + } + + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + content, + }) + + // Verify InitEventPayload was posted with correct unique value + expect(mockCeramic.postEventType).toHaveBeenCalledWith( + InitEventPayload, + expect.objectContaining({ + header: expect.objectContaining({ + unique: new TextEncoder().encode( + 'field1Value|field2Value|field3Value', + ), + }), + }), + ) + }) + + test('createInstance extracts unique values from content for SET relations', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + const modelDef: ModelDefinition = { + version: '2.0', + name: 'TestModel', + accountRelation: { + type: 'set', + fields: ['color', 'size', 'category'], + }, + interface: false, + implements: [], + schema: {}, + } + + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + content: { + color: 'red', + size: 42, + category: null, + description: 'This field is not in the unique fields', + }, + }) + + // Verify unique value was correctly calculated + const [, payload] = mockCeramic.postEventType.mock.calls[0] + const uniqueString = new TextDecoder().decode(payload.header.unique) + expect(uniqueString).toBe('red|42|') // color as-is, size converted to string, category as empty string + }) + }) + + describe('ComposeDB parity tests', () => { + test('should create deterministic stream for SET relation like ComposeDB', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + const modelDef: ModelDefinition = { + version: '2.0', + name: 'TestModel', + accountRelation: { + type: 'set', + fields: ['field1', 'field2'], + }, + interface: false, + implements: [], + schema: {}, + } + + // Mock responses + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + + // Test case from ComposeDB: ['foo', 'bar'] unique values + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + content: { field1: 'foo', field2: 'bar' }, + }) + + // Create again with same unique values + await client.createInstance({ + model: modelId, + modelDefinition: modelDef, + content: { field1: 'foo', field2: 'bar', extra: 'data' }, + }) + + // Both should create deterministic init events with same unique value + const [, payload1] = mockCeramic.postEventType.mock.calls[0] + const [, payload2] = mockCeramic.postEventType.mock.calls[1] + + const unique1 = new TextDecoder().decode(payload1.header.unique) + const unique2 = new TextDecoder().decode(payload2.header.unique) + + expect(unique1).toBe('foo|bar') + expect(unique2).toBe('foo|bar') + }) + + test('should handle SET relation fields like Favorite model from ComposeDB', async () => { + const modelId = StreamID.fromString( + 'kjzl6hvfrbw6c5ynhkxyaqzyllij9mpv9lrnce35d1jlhfdd6mhkp09xcywvt9k', + ) + + // Favorite model from ComposeDB has accountRelationFields: ["docID", "tag"] + const favoriteModel: ModelDefinition = { + version: '2.0', + name: 'Favorite', + description: 'A set of favorite documents', + accountRelation: { + type: 'set', + fields: ['docID', 'tag'], + }, + interface: false, + implements: [], + schema: { + type: 'object', + properties: { + docID: { type: 'string' }, + tag: { type: 'string', minLength: 2, maxLength: 20 }, + note: { type: 'string', maxLength: 500 }, + }, + required: ['docID', 'tag'], + }, + } + + mockCeramic.postEventType.mockResolvedValue( + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a' as string, + ) + mockCeramic.getStreamState.mockResolvedValue({ + id: 'kjzl6kcym7w8y8xqxtdnzh6x6ehb2dkcqc37hbmm5fhc6xzpv9m1muon9y5avyn', + data: encodeMultibase({ content: null }), + event_cid: + 'bagcqcerakszw2vsovxznyp5gfnpdj4cqm2xiv76yd24wkjewhhykovorwo6a', + dimensions: { model: encodeStreamID(modelId) }, + controller: authenticatedDID.id, + } as StreamState) + + // Create favorite instance + await client.createInstance({ + model: modelId, + modelDefinition: favoriteModel, + content: { + docID: + 'kjzl6kcym7w8y9zdwz9q2r5bx5srfhdtjvs7ouq2kimk9od0f96v8kp29e37efu', + tag: 'important', + note: 'This is a very important document', + }, + }) + + const [, payload] = mockCeramic.postEventType.mock.calls[0] + const uniqueString = new TextDecoder().decode(payload.header.unique) + expect(uniqueString).toBe( + 'kjzl6kcym7w8y9zdwz9q2r5bx5srfhdtjvs7ouq2kimk9od0f96v8kp29e37efu|important', + ) + }) + }) +}) diff --git a/sdk/pnpm-lock.yaml b/sdk/pnpm-lock.yaml index 785f4af3..ac16b31a 100644 --- a/sdk/pnpm-lock.yaml +++ b/sdk/pnpm-lock.yaml @@ -301,6 +301,9 @@ importers: '@ceramic-sdk/model-instance-protocol': specifier: workspace:^ version: link:../model-instance-protocol + '@ceramic-sdk/model-protocol': + specifier: workspace:^ + version: link:../model-protocol '@ceramic-sdk/stream-client': specifier: workspace:^ version: link:../stream-client diff --git a/tests/suite/src/__tests__/correctness/fast/model-mid-setType.test.ts b/tests/suite/src/__tests__/correctness/fast/model-mid-setType.test.ts index fbc2edac..c02e97bc 100644 --- a/tests/suite/src/__tests__/correctness/fast/model-mid-setType.test.ts +++ b/tests/suite/src/__tests__/correctness/fast/model-mid-setType.test.ts @@ -23,17 +23,27 @@ const testModel: ModelDefinition = { description: 'Set Test model', accountRelation: { type: 'set', - fields: ['test'], + fields: ['environment', 'version'], }, schema: { type: 'object', $schema: 'https://json-schema.org/draft/2020-12/schema', properties: { - test: { + environment: { type: 'string', }, + version: { + type: 'string', + }, + deployedAt: { + type: 'string', + format: 'date-time', + }, + metadata: { + type: 'object', + }, }, - required: ['test'], + required: ['environment', 'version'], additionalProperties: false, }, interface: false, @@ -41,7 +51,7 @@ const testModel: ModelDefinition = { } const FLIGHT_OPTIONS: ClientOptions = { - headers: new Array(), + headers: [], username: undefined, password: undefined, token: undefined, @@ -84,34 +94,294 @@ describe('model integration test for set model and MID', () => { const definition = await modelClient.getModelDefinition(modelStream) expect(definition).toEqual(testModel) }) - test('creates instance and obtains correct state', async () => { - const documentStream = await modelInstanceClient.createInstance({ + + test('creates SET instance with deterministic stream ID', async () => { + // For SET relations, create with unique field values + const documentStream1 = await modelInstanceClient.createInstance({ model: modelStream, - content: null, + modelDefinition: testModel, + content: { + environment: 'production', + version: 'v1.0.0' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, documentStream1.commit) + + // Now update with the actual content + await modelInstanceClient.updateDocument({ + streamID: documentStream1.baseID.toString(), + newContent: { + environment: 'production', + version: 'v1.0.0', + deployedAt: new Date().toISOString() + }, + shouldIndex: true, + }) + + // Create another instance with same unique values - should return same stream + const documentStream2 = await modelInstanceClient.createInstance({ + model: modelStream, + modelDefinition: testModel, + content: { + environment: 'production', + version: 'v1.0.0' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, documentStream2.commit) + + // Update with different metadata + const updatedState = await modelInstanceClient.updateDocument({ + streamID: documentStream2.baseID.toString(), + newContent: { + environment: 'production', + version: 'v1.0.0', + deployedAt: new Date().toISOString(), + metadata: { buildNumber: 123 } + }, + shouldIndex: true, + }) + + // For SET relations with same unique field values, stream IDs should be identical + expect(documentStream1.baseID.toString()).toEqual(documentStream2.baseID.toString()) + + // Verify the content was updated with the second call + expect(updatedState.content).toHaveProperty('metadata') + expect(updatedState.content?.metadata).toEqual({ buildNumber: 123 }) + }) + + test('creates different streams for different unique values', async () => { + // Create instance for production v1.0.0 + const prodStream = await modelInstanceClient.createInstance({ + model: modelStream, + modelDefinition: testModel, + content: { + environment: 'production', + version: 'v1.0.0' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, prodStream.commit) + + // Add additional content + const prodUpdate = await modelInstanceClient.updateDocument({ + streamID: prodStream.baseID.toString(), + newContent: { + environment: 'production', + version: 'v1.0.0', + deployedAt: new Date().toISOString() + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, prodUpdate.commitID.commit) + + // Create instance for staging v1.0.0 + const stagingStream = await modelInstanceClient.createInstance({ + model: modelStream, + modelDefinition: testModel, + content: { + environment: 'staging', + version: 'v1.0.0' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, stagingStream.commit) + + // Add additional content + const stagingUpdate = await modelInstanceClient.updateDocument({ + streamID: stagingStream.baseID.toString(), + newContent: { + environment: 'staging', + version: 'v1.0.0', + deployedAt: new Date().toISOString() + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, stagingUpdate.commitID.commit) + + // Different unique values should create different streams + expect(prodStream.baseID.toString()).not.toEqual(stagingStream.baseID.toString()) + + // Verify both streams have their correct content after updates + const prodState = await modelInstanceClient.getDocumentState(prodStream.baseID) + const stagingState = await modelInstanceClient.getDocumentState(stagingStream.baseID) + + // Content should be present after the updates + expect(prodState.content?.environment).toBe('production') + expect(stagingState.content?.environment).toBe('staging') + }) + + test('handles empty and null values in SET fields', async () => { + // Create with empty string in environment field + const emptyEnvStream = await modelInstanceClient.createInstance({ + model: modelStream, + modelDefinition: testModel, + content: { + environment: '', + version: 'v2.0.0' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, emptyEnvStream.commit) + + // Add additional content + await modelInstanceClient.updateDocument({ + streamID: emptyEnvStream.baseID.toString(), + newContent: { + environment: '', + version: 'v2.0.0', + deployedAt: new Date().toISOString() + }, + shouldIndex: true, + }) + + // Try to create with same empty environment - should be same stream + const sameStream = await modelInstanceClient.createInstance({ + model: modelStream, + modelDefinition: testModel, + content: { + environment: '', + version: 'v2.0.0' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, sameStream.commit) + + // Update with metadata + await modelInstanceClient.updateDocument({ + streamID: sameStream.baseID.toString(), + newContent: { + environment: '', + version: 'v2.0.0', + metadata: { note: 'Same empty environment' } + }, shouldIndex: true, }) - // Use the flightsql stream behavior to ensure the events states have been process before querying their states. - await waitForEventState(flightClient, documentStream.commit) - const currentState = await modelInstanceClient.getDocumentState( - documentStream.baseID, - ) - expect(currentState.content).toBeNull() + expect(emptyEnvStream.baseID.toString()).toEqual(sameStream.baseID.toString()) }) - test('updates document and obtains correct state', async () => { + + test('respects immutability of SET fields after first update', async () => { + // Create initial instance const documentStream = await modelInstanceClient.createInstance({ model: modelStream, - content: null, + modelDefinition: testModel, + content: { + environment: 'test', + version: 'v3.0.0' + }, shouldIndex: true, }) - // Use the flightsql stream behavior to ensure the events states have been process before querying their states. await waitForEventState(flightClient, documentStream.commit) - // update the document + + // Add initial content + await modelInstanceClient.updateDocument({ + streamID: documentStream.baseID.toString(), + newContent: { + environment: 'test', + version: 'v3.0.0', + deployedAt: new Date().toISOString() + }, + shouldIndex: true, + }) + + // Update the document (only non-SET fields should be updatable) const updatedState = await modelInstanceClient.updateDocument({ streamID: documentStream.baseID.toString(), - newContent: { test: 'world' }, + newContent: { + environment: 'test', // Must remain the same + version: 'v3.0.0', // Must remain the same + deployedAt: new Date().toISOString(), // Can be updated + metadata: { updated: true } // Can be added + }, + shouldIndex: true, + }) + + expect(updatedState.content).toHaveProperty('metadata') + expect(updatedState.content?.metadata).toEqual({ updated: true }) + }) + + test('ComposeDB parity: deterministic streams with simple unique values', async () => { + // Create a simple model like ComposeDB's Favorite with two fields + const favoriteModel: ModelDefinition = { + version: '2.0', + name: 'FavoriteTest', + description: 'ComposeDB parity test', + accountRelation: { + type: 'set', + fields: ['docID', 'tag'], + }, + schema: { + type: 'object', + properties: { + docID: { type: 'string' }, + tag: { type: 'string' }, + note: { type: 'string' }, + }, + required: ['docID', 'tag'], + additionalProperties: false, + }, + interface: false, + implements: [], + } + + const favoriteModelStream = await modelClient.createDefinition(favoriteModel) + await waitForEventState(flightClient, favoriteModelStream.cid) + + // Test case from ComposeDB: create with ['foo', 'bar'] pattern + const instance1 = await modelInstanceClient.createInstance({ + model: favoriteModelStream, + modelDefinition: favoriteModel, + content: { + docID: 'foo', + tag: 'bar' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, instance1.commit) + + // Add note + const firstUpdate = await modelInstanceClient.updateDocument({ + streamID: instance1.baseID.toString(), + newContent: { + docID: 'foo', + tag: 'bar', + note: 'First instance' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, firstUpdate.commitID.commit) + + // Create again with same unique values + const instance2 = await modelInstanceClient.createInstance({ + model: favoriteModelStream, + modelDefinition: favoriteModel, + content: { + docID: 'foo', + tag: 'bar' + }, shouldIndex: true, }) - expect(updatedState.content).toEqual({ test: 'world' }) + await waitForEventState(flightClient, instance2.commit) + + // Update with different note + const finalUpdate = await modelInstanceClient.updateDocument({ + streamID: instance2.baseID.toString(), + newContent: { + docID: 'foo', + tag: 'bar', + note: 'Second instance - should update the first' + }, + shouldIndex: true, + }) + await waitForEventState(flightClient, finalUpdate.commitID.commit) + + // Should be the same stream ID (deterministic) + expect(instance1.baseID.toString()).toEqual(instance2.baseID.toString()) + + // Verify content was updated + const state = await modelInstanceClient.getDocumentState(instance2.baseID) + expect(state.content?.note).toBe('Second instance - should update the first') }) })