diff --git a/packages/zero-client/src/client/custom.test.ts b/packages/zero-client/src/client/custom.test.ts index e9037afb8b..c132ecc5cd 100644 --- a/packages/zero-client/src/client/custom.test.ts +++ b/packages/zero-client/src/client/custom.test.ts @@ -55,6 +55,15 @@ test('argument types are preserved on the generated mutator interface', () => { }>(); }); +test('cannot support non-namespace custom mutators', () => { + ({ + // @ts-expect-error - all mutators must be in a namespace + setTitle: (_tx, _a: {id: string; title: string}) => { + throw new Error('not implemented'); + }, + }) satisfies CustomMutatorDefs; +}); + test('custom mutators write to the local store', async () => { const z = zeroForTest({ logLevel: 'debug', diff --git a/packages/zero-client/src/client/custom.ts b/packages/zero-client/src/client/custom.ts index 68cc7f677d..83be2e2b14 100644 --- a/packages/zero-client/src/client/custom.ts +++ b/packages/zero-client/src/client/custom.ts @@ -68,9 +68,9 @@ export type MakeCustomMutatorInterface< export type TransactionReason = 'optimistic' | 'rebase'; /** - * WriteTransactions are used with *mutators* which are registered using - * {@link ReplicacheOptions.mutators} and allows read and write operations on the - * database. + * An instance of this is passed to custom mutator implementations and + * allows reading and writing to the database and IVM at the head + * at which the mutator is being applied. */ export interface Transaction { readonly clientID: ClientID; diff --git a/repro/package.json b/repro/package.json new file mode 100644 index 0000000000..6472f05649 --- /dev/null +++ b/repro/package.json @@ -0,0 +1,29 @@ +{ + "name": "repro", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "check-types": "tsc && tsc -p tsconfig.node.json", + "check-types:watch": "tsc --watch", + "format": "prettier --write .", + "check-format": "prettier --check .", + "lint": "eslint --ext .ts,.tsx,.js,.jsx src/", + "preview": "vite preview", + "zero": "npm run zero-build-schema && tsx ../../packages/zero-cache/src/server/multi/main.ts", + "zero-brk": "npm run zero-build-schema && tsx --inspect-brk ../../packages/zero-cache/src/server/debug/single.ts", + "transform-query": "npm run zero-build-schema && tsx ../../packages/zero-cache/src/scripts/transform-query.ts", + "run-query": "npm run zero-build-schema && tsx ../../packages/zero-cache/src/scripts/run-query.ts", + "run-query-brk": "npm run zero-build-schema && tsx --inspect-brk ../../packages/zero-cache/src/scripts/run-query.ts", + "zero-build-schema": "tsx ../../packages/zero-schema/src/build-schema.ts", + "test": "vitest run", + "test:watch": "vitest", + "analyze": "analyze -c vite.config.ts" + }, + "eslintConfig": { + "extends": "../../eslint-config.json" + }, + "prettier": "@rocicorp/prettier-config" +} diff --git a/repro/schema.ts b/repro/schema.ts new file mode 100644 index 0000000000..6f6985e547 --- /dev/null +++ b/repro/schema.ts @@ -0,0 +1,440 @@ +import { + createSchema, + table, + relationships, + definePermissions, + type ExpressionBuilder, + type Row, + NOBODY_CAN, + json, + string, + number, + boolean, +} from "@rocicorp/zero"; + + +const user = table("user") + .columns({ + id: string(), + email: string() + }) + .primaryKey("id") + +const artist = table("artist") + .columns({ + id: string(), + name: string() + }) + .primaryKey("id") + +const recordingSetArtist = table("recording_set_artist") + .columns({ + id: string(), + recording_set_id: string(), + artist_id: string() + }) + .primaryKey("id") + +const recordingSet = table("recording_set") + .columns({ + id: string(), + started_at: number(), + ended_at: number(), + location_id: string(), + short_id: string(), + duration: number() + }) + .primaryKey("id") + +const location = table("location") + .columns({ + id: string(), + name: string(), + code: string(), + short_id: string(), + workspace_id: string(), + }) + .primaryKey("id") + + +const workspace = table("workspace") + .columns({ + id: string(), + name: string(), + type: string() + }) + .primaryKey("id") + +const userWorkspace = table("user_workspace") + .columns({ + id: string(), + user_id: string(), + workspace_id: string(), + }) + .primaryKey("id") + +const recorderSoundcard = table("recorder_soundcard") + .columns({ + id: string(), + recorder_id: string(), + model: string(), + serial_number: string(), + is_connected: boolean() + }) + .primaryKey("id") + +const recorderConfig = table("recorder_config") + .columns({ + id: string(), + location_id: string(), + recording_channels: number(), + recording_sample_rate: number(), + recording_bit_depth: number(), + recording_schedule: json< + { + rec_start: string; + rec_end: string; + dayofweek?: string; + date?: string; // overrides default schedule for certain dates + display_name?: string; // "default" marks the usual schedule; Special dates will have different display_name e.g. "New Year" + no_gig?: boolean; + }[] + >() + }) + .primaryKey("id") + +const recorderStatus = table("recorder_status") + .columns({ + id: string(), + recorder_id: string(), + last_connected_at: number(), + is_online: boolean(), + next_recording_at: number(), + is_in_recording_window: boolean(), + thing_group: string(), + software_version: string(), + wifi_name: string(), + is_wifi_connected: boolean(), + is_ethernet_connected: boolean() + }) + .primaryKey("id") + +const recordingSetPurchase = table("recording_set_purchase") + .columns({ + id: string(), + artist_id: string(), + is_completed: boolean(), + recording_set_id: string(), + location_id: string(), + price: number(), + currency: string(), + transfer_fee: string(), + vat_tax: string(), + post_tax_amount: string(), + venue_royalty: string(), + created_at: number() + }) + .primaryKey("id") + + + +const recorderSoundcardRelationships = relationships(recorderSoundcard, ({ many }) => ({ + workspaceUsers: many( + { + sourceField: ['recorder_id'], + destField: ['id'], + destSchema: location, + }, + { + sourceField: ['workspace_id'], + destField: ['workspace_id'], + destSchema: userWorkspace, + } + ), +})); + +const userWorkspaceRelationships = relationships(userWorkspace, ({ one }) => ({ + user: one({ + sourceField: ['user_id'], + destField: ['id'], + destSchema: user, + }), + workspace: one({ + sourceField: ['workspace_id'], + destField: ['id'], + destSchema: workspace, + }), +})); + +const recordingSetRelationships = relationships(recordingSet, ({ many, one }) => ({ + artists: many( + { + sourceField: ['id'], + destField: ['recording_set_id'], + destSchema: recordingSetArtist + }, + { + sourceField: ['artist_id'], + destField: ['id'], + destSchema: artist + } + ), + location: one({ + sourceField: ['location_id'], + destField: ['id'], + destSchema: location + }), + workspaceUsers: many( + { + sourceField: ['location_id'], + destField: ['id'], + destSchema: location, + }, + { + sourceField: ['workspace_id'], + destField: ['workspace_id'], + destSchema: userWorkspace, + } + ), + soundcards: many({ + sourceField: ["id"], + destField: ["recorder_id"], + destSchema: recorderSoundcard + }) +})); + +const workspaceRelationships = relationships(workspace, ({ many }) => ({ + workspaceUsers: many( + { + sourceField: ['id'], + destField: ['workspace_id'], + destSchema: userWorkspace, + }, + ), +})); + +const locationRelationships = relationships(location, ({ many }) => ({ + workspaceUsers: many( + { + sourceField: ['workspace_id'], + destField: ['workspace_id'], + destSchema: userWorkspace, + }, + ), + soundcards: many({ + sourceField: ["id"], + destField: ["recorder_id"], + destSchema: recorderSoundcard + }) +})); + +const artistRelationships = relationships(artist, ({ many }) => ({ + recordingSets: many( + { + sourceField: ['id'], + destField: ['artist_id'], + destSchema: recordingSetArtist, + }, + { + sourceField: ['recording_set_id'], + destField: ['id'], + destSchema: recordingSet, + }, + ), +})); + +const recordingSetArtistRelationships = relationships(recordingSetArtist, ({ one }) => ({ + artist: one({ + sourceField: ['artist_id'], + destField: ['id'], + destSchema: artist, + }), + recordingSet: one({ + sourceField: ['recording_set_id'], + destField: ['id'], + destSchema: recordingSet, + }), +})); +const recorderConfigRelationships = relationships(recorderConfig, ({ one }) => ({ + recorder: one({ + sourceField: ['location_id'], + destField: ['id'], + destSchema: location + }) +})) +const recorderStatusRelationships = relationships(recorderStatus, ({ one }) => ({ + recorder: one({ + sourceField: ['recorder_id'], + destField: ['id'], + destSchema: location + }) +})) +const recordingSetPurchaseRelationships = relationships(recordingSetPurchase, ({ one, many }) => ({ + artist: one({ + sourceField: ['artist_id'], + destField: ['id'], + destSchema: artist + }), + recordingSet: one({ + sourceField: ['recording_set_id'], + destField: ['id'], + destSchema: recordingSet + }), + recorder: one({ + sourceField: ['location_id'], + destField: ['id'], + destSchema: location + }), + workspaceUsers: many( + { + sourceField: ['location_id'], + destField: ['id'], + destSchema: location + }, + { + sourceField: ['workspace_id'], + destField: ['workspace_id'], + destSchema: userWorkspace + } + ) +})) + + +export const schema = createSchema(1, { + tables: [ + user, + artist, + location, + workspace, + recordingSet, + recordingSetArtist, + userWorkspace, + recordingSetPurchase, + recorderConfig, + recorderStatus, + recorderSoundcard + ], + relationships: [ + recorderStatusRelationships, + recorderConfigRelationships, + artistRelationships, + recordingSetPurchaseRelationships, + recordingSetArtistRelationships, + locationRelationships, + workspaceRelationships, + recordingSetRelationships, + userWorkspaceRelationships, + recorderSoundcardRelationships + ] +} +) + +export type Schema = typeof schema; +type TableName = keyof Schema['tables']; + +export type RecordingSet = Row; +export type Artist = Row; +export type User = Row; +export type Workspace = Row; + +type AuthData = { + properties: { + id: string, + email: string + }; + sub: string +}; + +export const permissions = definePermissions(schema, () => { + const userIsLoggedIn = ( + authData: AuthData, + { cmpLit }: ExpressionBuilder, + ) => cmpLit(authData.sub, 'IS NOT', null); + + const userMemberOfWorkspace = ( + authData: AuthData, + eb: ExpressionBuilder, + ) => + eb.exists('workspaceUsers', iq => + iq.where((eb: ExpressionBuilder) => eb.cmp('user_id', '=', '018d36bd-4f47-731f-b132-34c5e331b3b0'))) + + + const allowIfMemberOfWorkspace = ( + authData: AuthData, + eb: ExpressionBuilder, + ) => { + return userMemberOfWorkspace(authData, eb) + } + + return { + recording_set: { + row: { + select: [allowIfMemberOfWorkspace], + insert: NOBODY_CAN, + update: { + preMutation: NOBODY_CAN + }, + delete: NOBODY_CAN + }, + }, + user: { + row: { + insert: NOBODY_CAN, + update: { + preMutation: NOBODY_CAN + }, + delete: NOBODY_CAN + } + }, + workspace: { + row: { + select: [allowIfMemberOfWorkspace], + insert: NOBODY_CAN, + update: { + preMutation: NOBODY_CAN + }, + delete: NOBODY_CAN + } + }, + location: { + row: { + insert: NOBODY_CAN, + update: { + preMutation: NOBODY_CAN + }, + delete: NOBODY_CAN + } + }, + user_workspace: { + row: { + insert: NOBODY_CAN, + update: { + preMutation: NOBODY_CAN + }, + delete: NOBODY_CAN + } + }, + artist: { + row: { + insert: NOBODY_CAN, + update: { + preMutation: NOBODY_CAN + }, + delete: NOBODY_CAN + } + }, + recording_set_artist: { + row: { + insert: NOBODY_CAN, + update: { + preMutation: NOBODY_CAN + }, + delete: NOBODY_CAN + } + } + }; +}); + + + + diff --git a/repro/zero-schema.json b/repro/zero-schema.json new file mode 100644 index 0000000000..a0020f698b --- /dev/null +++ b/repro/zero-schema.json @@ -0,0 +1,859 @@ +{ + "permissions": { + "recording_set": { + "row": { + "select": [ + [ + "allow", + { + "type": "correlatedSubquery", + "related": { + "system": "permissions", + "correlation": { + "parentField": [ + "location_id" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "location", + "alias": "zsubq_workspaceUsers", + "orderBy": [ + [ + "id", + "asc" + ] + ], + "where": { + "type": "correlatedSubquery", + "related": { + "system": "permissions", + "correlation": { + "parentField": [ + "workspace_id" + ], + "childField": [ + "workspace_id" + ] + }, + "subquery": { + "table": "user_workspace", + "alias": "zsubq_workspaceUsers", + "where": { + "type": "simple", + "left": { + "type": "column", + "name": "user_id" + }, + "right": { + "type": "literal", + "value": "018d36bd-4f47-731f-b132-34c5e331b3b0" + }, + "op": "=" + }, + "orderBy": [ + [ + "id", + "asc" + ] + ] + } + }, + "op": "EXISTS" + } + } + }, + "op": "EXISTS" + } + ] + ], + "insert": [], + "update": { + "preMutation": [] + }, + "delete": [] + } + }, + "user": { + "row": { + "insert": [], + "update": { + "preMutation": [] + }, + "delete": [] + } + }, + "workspace": { + "row": { + "select": [ + [ + "allow", + { + "type": "correlatedSubquery", + "related": { + "system": "permissions", + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "workspace_id" + ] + }, + "subquery": { + "table": "user_workspace", + "alias": "zsubq_workspaceUsers", + "where": { + "type": "simple", + "left": { + "type": "column", + "name": "user_id" + }, + "right": { + "type": "literal", + "value": "018d36bd-4f47-731f-b132-34c5e331b3b0" + }, + "op": "=" + }, + "orderBy": [ + [ + "id", + "asc" + ] + ] + } + }, + "op": "EXISTS" + } + ] + ], + "insert": [], + "update": { + "preMutation": [] + }, + "delete": [] + } + }, + "location": { + "row": { + "insert": [], + "update": { + "preMutation": [] + }, + "delete": [] + } + }, + "user_workspace": { + "row": { + "insert": [], + "update": { + "preMutation": [] + }, + "delete": [] + } + }, + "artist": { + "row": { + "insert": [], + "update": { + "preMutation": [] + }, + "delete": [] + } + }, + "recording_set_artist": { + "row": { + "insert": [], + "update": { + "preMutation": [] + }, + "delete": [] + } + } + }, + "schema": { + "version": 1, + "tables": { + "user": { + "name": "user", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "email": { + "type": "string", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "artist": { + "name": "artist", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "name": { + "type": "string", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "location": { + "name": "location", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "name": { + "type": "string", + "optional": false, + "customType": null + }, + "code": { + "type": "string", + "optional": false, + "customType": null + }, + "short_id": { + "type": "string", + "optional": false, + "customType": null + }, + "workspace_id": { + "type": "string", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "name": { + "type": "string", + "optional": false, + "customType": null + }, + "type": { + "type": "string", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "recording_set": { + "name": "recording_set", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "started_at": { + "type": "number", + "optional": false, + "customType": null + }, + "ended_at": { + "type": "number", + "optional": false, + "customType": null + }, + "location_id": { + "type": "string", + "optional": false, + "customType": null + }, + "short_id": { + "type": "string", + "optional": false, + "customType": null + }, + "duration": { + "type": "number", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "recording_set_artist": { + "name": "recording_set_artist", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "recording_set_id": { + "type": "string", + "optional": false, + "customType": null + }, + "artist_id": { + "type": "string", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "user_workspace": { + "name": "user_workspace", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "user_id": { + "type": "string", + "optional": false, + "customType": null + }, + "workspace_id": { + "type": "string", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "recording_set_purchase": { + "name": "recording_set_purchase", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "artist_id": { + "type": "string", + "optional": false, + "customType": null + }, + "is_completed": { + "type": "boolean", + "optional": false, + "customType": null + }, + "recording_set_id": { + "type": "string", + "optional": false, + "customType": null + }, + "location_id": { + "type": "string", + "optional": false, + "customType": null + }, + "price": { + "type": "number", + "optional": false, + "customType": null + }, + "currency": { + "type": "string", + "optional": false, + "customType": null + }, + "transfer_fee": { + "type": "string", + "optional": false, + "customType": null + }, + "vat_tax": { + "type": "string", + "optional": false, + "customType": null + }, + "post_tax_amount": { + "type": "string", + "optional": false, + "customType": null + }, + "venue_royalty": { + "type": "string", + "optional": false, + "customType": null + }, + "created_at": { + "type": "number", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "recorder_config": { + "name": "recorder_config", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "location_id": { + "type": "string", + "optional": false, + "customType": null + }, + "recording_channels": { + "type": "number", + "optional": false, + "customType": null + }, + "recording_sample_rate": { + "type": "number", + "optional": false, + "customType": null + }, + "recording_bit_depth": { + "type": "number", + "optional": false, + "customType": null + }, + "recording_schedule": { + "type": "json", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "recorder_status": { + "name": "recorder_status", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "recorder_id": { + "type": "string", + "optional": false, + "customType": null + }, + "last_connected_at": { + "type": "number", + "optional": false, + "customType": null + }, + "is_online": { + "type": "boolean", + "optional": false, + "customType": null + }, + "next_recording_at": { + "type": "number", + "optional": false, + "customType": null + }, + "is_in_recording_window": { + "type": "boolean", + "optional": false, + "customType": null + }, + "thing_group": { + "type": "string", + "optional": false, + "customType": null + }, + "software_version": { + "type": "string", + "optional": false, + "customType": null + }, + "wifi_name": { + "type": "string", + "optional": false, + "customType": null + }, + "is_wifi_connected": { + "type": "boolean", + "optional": false, + "customType": null + }, + "is_ethernet_connected": { + "type": "boolean", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + }, + "recorder_soundcard": { + "name": "recorder_soundcard", + "columns": { + "id": { + "type": "string", + "optional": false, + "customType": null + }, + "recorder_id": { + "type": "string", + "optional": false, + "customType": null + }, + "model": { + "type": "string", + "optional": false, + "customType": null + }, + "serial_number": { + "type": "string", + "optional": false, + "customType": null + }, + "is_connected": { + "type": "boolean", + "optional": false, + "customType": null + } + }, + "primaryKey": [ + "id" + ] + } + }, + "relationships": { + "recorder_status": { + "recorder": [ + { + "sourceField": [ + "recorder_id" + ], + "destField": [ + "id" + ], + "destSchema": "location", + "cardinality": "one" + } + ] + }, + "recorder_config": { + "recorder": [ + { + "sourceField": [ + "location_id" + ], + "destField": [ + "id" + ], + "destSchema": "location", + "cardinality": "one" + } + ] + }, + "artist": { + "recordingSets": [ + { + "sourceField": [ + "id" + ], + "destField": [ + "artist_id" + ], + "destSchema": "recording_set_artist", + "cardinality": "many" + }, + { + "sourceField": [ + "recording_set_id" + ], + "destField": [ + "id" + ], + "destSchema": "recording_set", + "cardinality": "many" + } + ] + }, + "recording_set_purchase": { + "artist": [ + { + "sourceField": [ + "artist_id" + ], + "destField": [ + "id" + ], + "destSchema": "artist", + "cardinality": "one" + } + ], + "recordingSet": [ + { + "sourceField": [ + "recording_set_id" + ], + "destField": [ + "id" + ], + "destSchema": "recording_set", + "cardinality": "one" + } + ], + "recorder": [ + { + "sourceField": [ + "location_id" + ], + "destField": [ + "id" + ], + "destSchema": "location", + "cardinality": "one" + } + ], + "workspaceUsers": [ + { + "sourceField": [ + "location_id" + ], + "destField": [ + "id" + ], + "destSchema": "location", + "cardinality": "many" + }, + { + "sourceField": [ + "workspace_id" + ], + "destField": [ + "workspace_id" + ], + "destSchema": "user_workspace", + "cardinality": "many" + } + ] + }, + "recording_set_artist": { + "artist": [ + { + "sourceField": [ + "artist_id" + ], + "destField": [ + "id" + ], + "destSchema": "artist", + "cardinality": "one" + } + ], + "recordingSet": [ + { + "sourceField": [ + "recording_set_id" + ], + "destField": [ + "id" + ], + "destSchema": "recording_set", + "cardinality": "one" + } + ] + }, + "location": { + "workspaceUsers": [ + { + "sourceField": [ + "workspace_id" + ], + "destField": [ + "workspace_id" + ], + "destSchema": "user_workspace", + "cardinality": "many" + } + ], + "soundcards": [ + { + "sourceField": [ + "id" + ], + "destField": [ + "recorder_id" + ], + "destSchema": "recorder_soundcard", + "cardinality": "many" + } + ] + }, + "workspace": { + "workspaceUsers": [ + { + "sourceField": [ + "id" + ], + "destField": [ + "workspace_id" + ], + "destSchema": "user_workspace", + "cardinality": "many" + } + ] + }, + "recording_set": { + "artists": [ + { + "sourceField": [ + "id" + ], + "destField": [ + "recording_set_id" + ], + "destSchema": "recording_set_artist", + "cardinality": "many" + }, + { + "sourceField": [ + "artist_id" + ], + "destField": [ + "id" + ], + "destSchema": "artist", + "cardinality": "many" + } + ], + "location": [ + { + "sourceField": [ + "location_id" + ], + "destField": [ + "id" + ], + "destSchema": "location", + "cardinality": "one" + } + ], + "workspaceUsers": [ + { + "sourceField": [ + "location_id" + ], + "destField": [ + "id" + ], + "destSchema": "location", + "cardinality": "many" + }, + { + "sourceField": [ + "workspace_id" + ], + "destField": [ + "workspace_id" + ], + "destSchema": "user_workspace", + "cardinality": "many" + } + ], + "soundcards": [ + { + "sourceField": [ + "id" + ], + "destField": [ + "recorder_id" + ], + "destSchema": "recorder_soundcard", + "cardinality": "many" + } + ] + }, + "user_workspace": { + "user": [ + { + "sourceField": [ + "user_id" + ], + "destField": [ + "id" + ], + "destSchema": "user", + "cardinality": "one" + } + ], + "workspace": [ + { + "sourceField": [ + "workspace_id" + ], + "destField": [ + "id" + ], + "destSchema": "workspace", + "cardinality": "one" + } + ] + }, + "recorder_soundcard": { + "workspaceUsers": [ + { + "sourceField": [ + "recorder_id" + ], + "destField": [ + "id" + ], + "destSchema": "location", + "cardinality": "many" + }, + { + "sourceField": [ + "workspace_id" + ], + "destField": [ + "workspace_id" + ], + "destSchema": "user_workspace", + "cardinality": "many" + } + ] + } + } + } +} \ No newline at end of file