diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..a45b5bb2e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: release + +on: + workflow_dispatch: + inputs: + semver: + description: 'The semver to use' + required: true + default: 'prerelease' + type: choice + options: + # - auto + # - patch + # - minor + # - major + - prerelease + # - prepatch + # - preminor + # - premajor + pull_request: + types: [closed] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Use Node.js 18 + uses: actions/setup-node@v3 + with: + node-version: 18 + - uses: nearform-actions/optic-release-automation-action@v4 + with: + commit-message: 'Release {version}' + sync-semver-tags: true + access: 'public' + # This prefix is added before the prerelease number, e.g. `v3.0.0-alpha.0` + prerelease-prefix: 'alpha' + semver: ${{ github.event.inputs.semver }} + # Prereleases are published under the `alpha` npm dist-tag + npm-tag: ${{ startsWith(github.event.inputs.semver, 'pre') && 'alpha' || 'latest' }} + # Don't notify linked issues + notify-linked-issues: false + # optional: set this secret in your repo config for publishing to NPM + npm-token: ${{ secrets.NPM_TOKEN }} + build-command: | + npm install + npm run build diff --git a/drizzle/project/0000_brown_living_mummy.sql b/drizzle/project/0000_faithful_mister_fear.sql similarity index 87% rename from drizzle/project/0000_brown_living_mummy.sql rename to drizzle/project/0000_faithful_mister_fear.sql index c83464f44..6390bc076 100644 --- a/drizzle/project/0000_brown_living_mummy.sql +++ b/drizzle/project/0000_faithful_mister_fear.sql @@ -61,6 +61,24 @@ CREATE TABLE `field` ( `forks` text NOT NULL ); --> statement-breakpoint +CREATE TABLE `icon_backlink` ( + `versionId` text PRIMARY KEY NOT NULL +); +--> statement-breakpoint +CREATE TABLE `icon` ( + `docId` text PRIMARY KEY NOT NULL, + `versionId` text NOT NULL, + `schemaName` text NOT NULL, + `createdAt` text NOT NULL, + `createdBy` text NOT NULL, + `updatedAt` text NOT NULL, + `links` text NOT NULL, + `deleted` integer NOT NULL, + `name` text NOT NULL, + `variants` text NOT NULL, + `forks` text NOT NULL +); +--> statement-breakpoint CREATE TABLE `observation_backlink` ( `versionId` text PRIMARY KEY NOT NULL ); diff --git a/drizzle/project/meta/0000_snapshot.json b/drizzle/project/meta/0000_snapshot.json index 23c6d8b6c..c4e29e32d 100644 --- a/drizzle/project/meta/0000_snapshot.json +++ b/drizzle/project/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "sqlite", - "id": "ee8a89aa-fd1d-47d8-88db-86f467de1c18", + "id": "b8724f08-6f32-43e1-8fca-f8395a30f19d", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "coreOwnership_backlink": { @@ -373,6 +373,108 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, + "icon_backlink": { + "name": "icon_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variants": { + "name": "variants", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, "observation_backlink": { "name": "observation_backlink", "columns": { @@ -756,4 +858,4 @@ "tables": {}, "columns": {} } -} \ No newline at end of file +} diff --git a/drizzle/project/meta/_journal.json b/drizzle/project/meta/_journal.json index d74dae09b..3816c3c94 100644 --- a/drizzle/project/meta/_journal.json +++ b/drizzle/project/meta/_journal.json @@ -5,9 +5,9 @@ { "idx": 0, "version": "5", - "when": 1695927172554, - "tag": "0000_brown_living_mummy", + "when": 1696355460625, + "tag": "0000_faithful_mister_fear", "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index a61f50699..ea2f52ca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -840,9 +840,9 @@ } }, "node_modules/@mapeo/schema": { - "version": "3.0.0-next.10", - "resolved": "https://registry.npmjs.org/@mapeo/schema/-/schema-3.0.0-next.10.tgz", - "integrity": "sha512-5r1G9TBRCVS1RouXL+ekXgdBZA/XpzbLyHnhf/jU7b5dCtTV05AmAuTl6s3SKHYuhycgDypE/9CO6U05NEI4qg==", + "version": "3.0.0-next.11", + "resolved": "https://registry.npmjs.org/@mapeo/schema/-/schema-3.0.0-next.11.tgz", + "integrity": "sha512-tZnIzNmXpKNSkqEZQP9rCb91toKga/jrSF9RIUsYeIMamsePHtLuTxF2BEVdT81/P+NLgHt57uXIXEIefU3Usw==", "dependencies": { "@json-schema-tools/dereferencer": "^1.6.1", "ajv": "^8.12.0", diff --git a/package.json b/package.json index 31f71f3f1..5b28a0242 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@fastify/type-provider-typebox": "^3.3.0", "@hyperswarm/secret-stream": "^6.1.2", "@mapeo/crypto": "^1.0.0-alpha.8", - "@mapeo/schema": "^3.0.0-next.10", + "@mapeo/schema": "^3.0.0-next.11", "@mapeo/sqlite-indexer": "^1.0.0-alpha.6", "@sinclair/typebox": "^0.29.6", "b4a": "^1.6.3", @@ -131,7 +131,6 @@ "protobufjs": "^7.2.3", "protomux": "^3.4.1", "quickbit-universal": "^2.2.0", - "rpc-reflector": "^1.3.11", "sodium-universal": "^4.0.0", "start-stop-state-machine": "^1.2.0", "sub-encoder": "^2.1.1", diff --git a/src/datastore/index.js b/src/datastore/index.js index 6ddbbc93b..44b597c3e 100644 --- a/src/datastore/index.js +++ b/src/datastore/index.js @@ -26,7 +26,7 @@ import { discoveryKey } from 'hypercore-crypto' const NAMESPACE_SCHEMAS = /** @type {const} */ ({ data: ['observation'], - config: ['preset', 'field', 'projectSettings', 'deviceInfo'], + config: ['preset', 'field', 'projectSettings', 'deviceInfo', 'icon'], auth: ['coreOwnership', 'role'], }) diff --git a/src/datatype/index.js b/src/datatype/index.js index 81c7b9f1e..698c9f58b 100644 --- a/src/datatype/index.js +++ b/src/datatype/index.js @@ -137,7 +137,8 @@ export class DataType { */ async getByDocId(docId) { const result = this.#sql.getByDocId.get({ docId }) - return result ? deNullify(result) : result + if (!result) throw new Error('Not found') + return deNullify(result) } /** @param {string} versionId */ diff --git a/src/ipc-wrapper/client.js b/src/ipc-wrapper/client.js deleted file mode 100644 index ce4668a3b..000000000 --- a/src/ipc-wrapper/client.js +++ /dev/null @@ -1,96 +0,0 @@ -// @ts-check -import { createClient } from 'rpc-reflector' -import pDefer from 'p-defer' -import { MANAGER_CHANNEL_ID, MAPEO_RPC_ID, SubChannel } from './sub-channel.js' - -const CLOSE = Symbol('close') - -/** - * @param {import('./sub-channel.js').MessagePortLike} messagePort - * @returns {import('rpc-reflector/client.js').ClientApi} - */ -export function createMapeoClient(messagePort) { - /** @type {Map>>} */ - const projectClientPromises = new Map() - - const managerChannel = new SubChannel(messagePort, MANAGER_CHANNEL_ID) - const mapeoRpcChannel = new SubChannel(messagePort, MAPEO_RPC_ID) - - /** @type {import('rpc-reflector').ClientApi} */ - const managerClient = createClient(managerChannel) - /** @type {import('rpc-reflector').ClientApi} */ - const mapeoRpcClient = createClient(mapeoRpcChannel) - - mapeoRpcChannel.start() - managerChannel.start() - - const client = new Proxy(managerClient, { - get(target, prop, receiver) { - if (prop === CLOSE) { - return async () => { - managerChannel.close() - createClient.close(managerClient) - - const projectClientResults = await Promise.allSettled( - projectClientPromises.values() - ) - - for (const result of projectClientResults) { - if (result.status === 'fulfilled') { - createClient.close(result.value) - } - } - } - } - - if (prop === 'getProject') { - return createProjectClient - } - - return Reflect.get(target, prop, receiver) - }, - }) - - return client - - /** - * @param {string} projectPublicId - * @returns {Promise>} - */ - async function createProjectClient(projectPublicId) { - const existingClientPromise = projectClientPromises.get(projectPublicId) - - if (existingClientPromise) return existingClientPromise - - /** @type {import('p-defer').DeferredPromise>}*/ - const deferred = pDefer() - - projectClientPromises.set(projectPublicId, deferred.promise) - - try { - await mapeoRpcClient.assertProjectExists(projectPublicId) - } catch (err) { - deferred.reject(err) - throw err - } - - const projectChannel = new SubChannel(messagePort, projectPublicId) - - /** @type {import('rpc-reflector').ClientApi} */ - const projectClient = createClient(projectChannel) - projectChannel.start() - - deferred.resolve(projectClient) - - return projectClient - } -} - -/** - * @param {import('rpc-reflector').ClientApi} client client created with `createMapeoClient` - * @returns {Promise} - */ -export async function closeMapeoClient(client) { - // @ts-expect-error - return client[CLOSE]() -} diff --git a/src/ipc-wrapper/server.js b/src/ipc-wrapper/server.js deleted file mode 100644 index 56b874325..000000000 --- a/src/ipc-wrapper/server.js +++ /dev/null @@ -1,110 +0,0 @@ -// @ts-check -import { createServer } from 'rpc-reflector' -import { MANAGER_CHANNEL_ID, MAPEO_RPC_ID, SubChannel } from './sub-channel.js' -import { extractMessageEventData } from './utils.js' - -/** - * @param {import('../mapeo-manager.js').MapeoManager} manager - * @param {import('./sub-channel.js').MessagePortLike} messagePort - */ -export function createMapeoServer(manager, messagePort) { - /** @type {Map void }>} */ - const existingProjectServers = new Map() - - /** @type {Map} */ - const existingProjectChannels = new Map() - - const mapeoRpcApi = new MapeoRpcApi(manager) - - const managerChannel = new SubChannel(messagePort, MANAGER_CHANNEL_ID) - const mapeoRpcChannel = new SubChannel(messagePort, MAPEO_RPC_ID) - - const managerServer = createServer(manager, managerChannel) - const mapeoRpcServer = createServer(mapeoRpcApi, mapeoRpcChannel) - - managerChannel.start() - mapeoRpcChannel.start() - - messagePort.addEventListener('message', handleMessage) - - return { - close() { - messagePort.removeEventListener('message', handleMessage) - - for (const [id, server] of existingProjectServers.entries()) { - server.close() - - const channel = existingProjectChannels.get(id) - - if (channel) { - channel.close() - existingProjectChannels.delete(id) - } - - existingProjectServers.delete(id) - } - - managerServer.close() - managerChannel.close() - mapeoRpcServer.close() - mapeoRpcChannel.close() - }, - } - - /** - * @param {unknown} payload - */ - async function handleMessage(payload) { - const data = extractMessageEventData(payload) - - if (!data || typeof data !== 'object' || !('message' in data)) return - - const id = 'id' in data && typeof data.id === 'string' ? data.id : null - - if (!id || id === MANAGER_CHANNEL_ID || id === MAPEO_RPC_ID) return - - if (existingProjectChannels.has(id)) return - - const projectChannel = new SubChannel(messagePort, id) - existingProjectChannels.set(id, projectChannel) - - let project - try { - project = await manager.getProject(id) - } catch (err) { - // TODO: how to respond to client so that method errors? - projectChannel.close() - existingProjectChannels.delete(id) - existingProjectServers.delete(id) - return - } - - const { close } = createServer(project, projectChannel) - - existingProjectServers.set(id, { close }) - - projectChannel.emit('message', data.message) - - projectChannel.start() - } -} - -export class MapeoRpcApi { - #manager - - /** - * @param {import('../mapeo-manager.js').MapeoManager} manager - */ - constructor(manager) { - this.#manager = manager - } - - /** - * @param {string} projectId - * @returns {Promise} - */ - async assertProjectExists(projectId) { - const project = await this.#manager.getProject(projectId) - return !!project - } -} diff --git a/src/ipc-wrapper/sub-channel.js b/src/ipc-wrapper/sub-channel.js deleted file mode 100644 index fc1845b56..000000000 --- a/src/ipc-wrapper/sub-channel.js +++ /dev/null @@ -1,117 +0,0 @@ -// @ts-check -import { EventEmitter } from 'eventemitter3' -import { extractMessageEventData } from './utils.js' - -// Ideally unique ID used for identifying "global" Mapeo IPC messages -export const MAPEO_RPC_ID = '@@mapeo-rpc' -export const MANAGER_CHANNEL_ID = '@@manager' - -/** - * @typedef {Object} Events - * @property {(message: any) => void} message - */ - -/** - * Node's built-in types for MessagePort are misleading so we opt for this limited type definition - * that fits our usage and works in both Node and browser contexts - * @typedef {EventTarget & { postMessage: (message: any) => void }} MessagePortLike - */ - -export class SubChannel extends EventEmitter { - #id - #messagePort - /** @type {'idle' | 'active' | 'closed'} */ - #state - /** @type {Array<{id: string, message: any}>} */ - #queued - #handleMessageEvent - - /** - * @param {MessagePortLike} messagePort Parent channel to add namespace to - * @param {string} id ID for the subchannel - */ - constructor(messagePort, id) { - super() - - this.#id = id - this.#messagePort = messagePort - this.#state = 'idle' - this.#queued = [] - - /** - * @param {unknown} event - */ - this.#handleMessageEvent = (event) => { - const value = extractMessageEventData(event) - - if (!isRelevantEvent(value)) return - - const { id, message } = value - - if (this.#id !== id) return - - switch (this.#state) { - case 'idle': { - this.#queued.push(value) - break - } - case 'active': { - this.emit('message', message) - break - } - case 'closed': { - // no-op if closed (the event listener should be removed anyway) - break - } - } - } - - this.#messagePort.addEventListener('message', this.#handleMessageEvent) - } - - get id() { - return this.#id - } - - /** - * Send messages with the subchannel's ID - * @param {any} message - */ - postMessage(message) { - this.#messagePort.postMessage({ id: this.#id, message }) - } - - start() { - if (this.#state !== 'idle') return - - this.#state = 'active' - - /** @type {{id: string, message: any} | undefined} */ - let event - while ((event = this.#queued.shift())) { - this.#handleMessageEvent(event) - } - } - - close() { - if (this.#state === 'closed') return - - this.#state = 'closed' - this.#queued = [] - - // Node types are incorrect (as of v14, Node's MessagePort should also extend [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget)) - this.#messagePort.removeEventListener('message', this.#handleMessageEvent) - } -} - -/** - * @param {unknown} event - * @returns {event is { id: string, message: any }} - */ -function isRelevantEvent(event) { - if (!event || typeof event !== 'object') return false - if (!('id' in event && 'message' in event)) return false - if (typeof event.id !== 'string') return false - - return true -} diff --git a/src/ipc-wrapper/utils.js b/src/ipc-wrapper/utils.js deleted file mode 100644 index 576bbb2f3..000000000 --- a/src/ipc-wrapper/utils.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @template T - * @param {T} event - * @returns {T extends { data: infer D } ? D : T} - */ -export function extractMessageEventData(event) { - // In browser-like contexts, the actual payload will live in the `event.data` field - // https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/message_event#event_properties - if (event && typeof event === 'object' && 'data' in event) { - return /** @type {any} */ (event.data) - } - - // In Node the event is the actual data that was sent - return /** @type {any} */ (event) -} diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 9f8abd176..19f570c06 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -22,6 +22,7 @@ import { observationTable, presetTable, roleTable, + iconTable, } from './schema/project.js' import { CoreOwnership, @@ -187,6 +188,11 @@ export class MapeoProject { table: deviceInfoTable, db, }), + icon: new DataType({ + dataStore: this.#dataStores.config, + table: iconTable, + db, + }), } this.#blobStore = new BlobStore({ diff --git a/src/schema/project.js b/src/schema/project.js index 872c02f92..cf3c214db 100644 --- a/src/schema/project.js +++ b/src/schema/project.js @@ -20,6 +20,7 @@ export const deviceInfoTable = sqliteTable( 'deviceInfo', toColumns(schemas.deviceInfo) ) +export const iconTable = sqliteTable('icon', toColumns(schemas.icon)) export const observationBacklinkTable = backlinkTable(observationTable) export const presetBacklinkTable = backlinkTable(presetTable) @@ -27,3 +28,4 @@ export const fieldBacklinkTable = backlinkTable(fieldTable) export const coreOwnershipBacklinkTable = backlinkTable(coreOwnershipTable) export const roleBacklinkTable = backlinkTable(roleTable) export const deviceInfoBacklinkTable = backlinkTable(deviceInfoTable) +export const iconBacklinkTable = backlinkTable(iconTable) diff --git a/test-e2e/ipc-wrapper.js b/test-e2e/ipc-wrapper.js deleted file mode 100644 index ccf9786fb..000000000 --- a/test-e2e/ipc-wrapper.js +++ /dev/null @@ -1,167 +0,0 @@ -import { test } from 'brittle' -import { MessageChannel } from 'node:worker_threads' -import RAM from 'random-access-memory' -import { KeyManager } from '@mapeo/crypto' -import { createMapeoServer } from '../src/ipc-wrapper/server.js' -import { MapeoManager } from '../src/mapeo-manager.js' -import { - createMapeoClient, - closeMapeoClient, -} from '../src/ipc-wrapper/client.js' - -test('IPC wrappers work', async (t) => { - const { client, cleanup } = setup() - - const projectId = await client.createProject({ name: 'mapeo' }) - - t.ok(projectId) - - const project = await client.getProject(projectId) - - t.ok(project) - - const projectSettings = await project.$getProjectSettings() - - t.alike(projectSettings, { name: 'mapeo', defaultPresets: undefined }) - - return cleanup() -}) - -test('Multiple projects and several calls in same tick', async (t) => { - const { client, cleanup } = setup() - - const sample = Array(10) - .fill(null) - .map((_, index) => { - return { - name: `Mapeo ${index}`, - defaultPresets: undefined, - } - }) - - const projectIds = await Promise.all( - sample.map(async (s) => client.createProject(s)) - ) - - const projects = await Promise.all( - projectIds.map((id) => client.getProject(id)) - ) - - const settings = await Promise.all( - projects.map((project) => project.$getProjectSettings()) - ) - - const listedProjects = await client.listProjects() - - t.is(projectIds.length, sample.length) - t.is(projects.length, sample.length) - t.is(settings.length, sample.length) - t.is(listedProjects.length, sample.length) - - settings.forEach((s, index) => { - const expectedSettings = sample[index] - t.alike(s, expectedSettings) - }) - - return cleanup() -}) - -test('Attempting to get non-existent project fails', async (t) => { - const { client, cleanup } = setup() - - await t.exception(async () => { - await client.getProject('mapeo') - }) - - const results = await Promise.allSettled([ - client.getProject('mapeo'), - client.getProject('mapeo'), - ]) - - t.alike( - results.map(({ status }) => status), - ['rejected', 'rejected'] - ) - - return cleanup() -}) - -test('Concurrent calls that succeed', async (t) => { - const { client, cleanup } = setup() - - const projectId = await client.createProject() - - const [project1, project2] = await Promise.all([ - client.getProject(projectId), - client.getProject(projectId), - ]) - - t.is(project1, project2) - - return cleanup() -}) - -test('Client calls fail after server closes', async (t) => { - const { client, server, cleanup } = setup() - - const projectId = await client.createProject({ name: 'mapeo' }) - const projectBefore = await client.getProject(projectId) - - await projectBefore.$getProjectSettings() - - server.close() - await closeMapeoClient(client) - - const projectAfter = await client.getProject(projectId) - - // Even after server closes we're still able to get the project ipc instance, which is okay - // because any field access should fail on that, rendering it unusable - // Adding this assertion to track changes in this behavior - t.ok(projectAfter) - - // Doing it this way to speed up the test because each should wait for a timeout - // Attempting to access any fields on the ipc instances should fail (aside from client.getProject, which is tested above) - const results = await Promise.allSettled([ - client.listProjects(), - projectBefore.$getProjectSettings(), - ]) - - for (const result of results) { - // @ts-ignore - t.is(result.status, 'rejected', result.reason) - } - - return cleanup() -}) - -function setup() { - const { port1, port2 } = new MessageChannel() - - const manager = new MapeoManager({ - rootKey: KeyManager.generateRootKey(), - dbFolder: ':memory:', - coreStorage: () => new RAM(), - }) - - // Since v14.7.0, Node's MessagePort extends EventTarget (https://nodejs.org/api/worker_threads.html#class-messageport) - // @ts-expect-error - const server = createMapeoServer(manager, port1) - // @ts-expect-error - const client = createMapeoClient(port2) - - port1.start() - port2.start() - - return { - port1, - port2, - server, - client, - cleanup: async () => { - server.close() - await closeMapeoClient(client) - port1.close() - port2.close() - }, - } -} diff --git a/test-types/data-types.ts b/test-types/data-types.ts index 0362fdeb3..f93833ad4 100644 --- a/test-types/data-types.ts +++ b/test-types/data-types.ts @@ -55,6 +55,9 @@ Expect> const manyObservations = await mapeoProject.observation.getMany() Expect> +const observationByDocId = await mapeoProject.observation.getByDocId('abc') +Expect> + const observationByVersionId = await mapeoProject.observation.getByVersionId( 'abc' ) @@ -71,6 +74,9 @@ Expect> const manyPresets = await mapeoProject.preset.getMany() Expect> +const presetByDocId = await mapeoProject.preset.getByDocId('abc') +Expect> + const presetByVersionId = await mapeoProject.preset.getByVersionId('abc') Expect> @@ -85,5 +91,8 @@ Expect> const manyFields = await mapeoProject.field.getMany() Expect> +const fieldByDocId = await mapeoProject.field.getByDocId('abc') +Expect> + const fieldByVersionId = await mapeoProject.field.getByVersionId('abc') Expect>