From d47c5272257b7fa531852dba5352111c958a8e24 Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Fri, 11 Mar 2022 09:16:20 +0100 Subject: [PATCH] Add support for external storages on lock, unlock, update, get, and delete (#654) * exclude misspell check on dep manager files * storage backend: make onUpdate and onDelete aware of ownerID --- .github/workflows/pr-build.yaml | 5 + hub-js/graphql/local/schema.graphql | 72 ++++- hub-js/package-lock.json | 49 +++- hub-js/package.json | 5 +- hub-js/proto/storage_backend.proto | 2 + hub-js/src/generated/grpc/storage_backend.ts | 23 +- hub-js/src/index.ts | 11 +- hub-js/src/local/index.ts | 16 +- hub-js/src/local/mutation/context.ts | 14 - .../local/mutation/update-type-instances.ts | 62 ----- .../local/resolver/field/spec-value-field.ts | 112 ++++++++ hub-js/src/local/resolver/mutation/context.ts | 20 ++ .../mutation/create-type-instance.ts | 5 +- .../mutation/create-type-instances.ts | 84 +++++- .../{ => resolver}/mutation/cypher-errors.ts | 1 + .../mutation/delete-type-instance.ts | 88 ++++-- .../mutation/lock-type-instances.ts | 71 ++++- .../mutation/register-built-in-storage.ts | 4 +- .../mutation/unlock-type-instances.ts | 22 +- .../mutation/update-type-instances.ts | 138 ++++++++++ hub-js/src/local/storage/service.ts | 251 ++++++++++++++++-- .../local/storage/update-args-container.ts | 118 ++++++++ hub-js/src/local/types/type-instance.ts | 9 +- internal/secret-storage-backend/server.go | 27 +- .../secret-storage-backend/server_test.go | 2 +- pkg/hub/api/graphql/local/models_gen.go | 4 + pkg/hub/api/graphql/local/schema_gen.go | 112 +++++++- .../local/update_type_instances_input.go | 7 +- .../storage_backend/storage_backend.pb.go | 210 ++++++++------- test/e2e/action_test.go | 10 + test/e2e/hub_test.go | 17 +- 31 files changed, 1266 insertions(+), 305 deletions(-) delete mode 100644 hub-js/src/local/mutation/context.ts delete mode 100644 hub-js/src/local/mutation/update-type-instances.ts create mode 100644 hub-js/src/local/resolver/field/spec-value-field.ts create mode 100644 hub-js/src/local/resolver/mutation/context.ts rename hub-js/src/local/{ => resolver}/mutation/create-type-instance.ts (81%) rename hub-js/src/local/{ => resolver}/mutation/create-type-instances.ts (80%) rename hub-js/src/local/{ => resolver}/mutation/cypher-errors.ts (98%) rename hub-js/src/local/{ => resolver}/mutation/delete-type-instance.ts (52%) rename hub-js/src/local/{ => resolver}/mutation/lock-type-instances.ts (67%) rename hub-js/src/local/{ => resolver}/mutation/register-built-in-storage.ts (93%) rename hub-js/src/local/{ => resolver}/mutation/unlock-type-instances.ts (65%) create mode 100644 hub-js/src/local/resolver/mutation/update-type-instances.ts create mode 100644 hub-js/src/local/storage/update-args-container.ts diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml index 7727fbd29..ecc530b40 100644 --- a/.github/workflows/pr-build.yaml +++ b/.github/workflows/pr-build.yaml @@ -133,6 +133,11 @@ jobs: reporter: github-pr-review level: info locale: "US" + exclude: | + ./hub-js/package-lock.json + ./hub-js/package.json + ./go.mod + ./go.sum - name: Check links in *.md files if: always() # validate also *.md even if errors found in mdx files. uses: gaurav-nelson/github-action-markdown-link-check@v1 diff --git a/hub-js/graphql/local/schema.graphql b/hub-js/graphql/local/schema.graphql index 7ed630fa2..838764257 100644 --- a/hub-js/graphql/local/schema.graphql +++ b/hub-js/graphql/local/schema.graphql @@ -105,7 +105,33 @@ type TypeInstanceResourceVersionSpec { value: Any! @cypher( statement: """ - RETURN apoc.convert.fromJsonMap(this.value) + MATCH (this)<-[:SPECIFIED_BY]-(rev:TypeInstanceResourceVersion)<-[:CONTAINS]-(ti:TypeInstance) + MATCH (this)-[:WITH_BACKEND]->(backendCtx) + MATCH (ti)-[:STORED_IN]->(backendRef) + WITH * + CALL apoc.when( + backendRef.abstract, + ' + WITH { + abstract: backendRef.abstract, + builtinValue: apoc.convert.fromJsonMap(spec.value) + } AS value + RETURN value + ', + ' + WITH { + abstract: backendRef.abstract, + fetchInput: { + typeInstance: { resourceVersion: rev.resourceVersion, id: ti.id }, + backend: { context: backendCtx.context, id: backendRef.id} + } + } AS value + RETURN value + ', + {spec: this, rev: rev, ti: ti, backendRef: backendRef, backendCtx: backendCtx} + ) YIELD value as out + + RETURN out.value """ ) @@ -293,6 +319,15 @@ input UpdateTypeInstanceInput { The value property is optional. If not provided, previous value is used. """ value: Any + + """ + The backend property is optional. If not provided, previous value is used. + """ + backend: UpdateTypeInstanceBackendInput +} + +input UpdateTypeInstanceBackendInput { + context: Any } input UpdateTypeInstancesInput { @@ -440,26 +475,45 @@ type Mutation { CREATE (tir)-[:SPECIFIED_BY]->(spec) WITH ti, tir, spec, latestRevision, item - CALL apoc.do.when( - item.typeInstance.value IS NOT NULL, + MATCH (ti)-[:STORED_IN]->(storageRef:TypeInstanceBackendReference) + + WITH ti, tir, spec, latestRevision, item, storageRef + CALL apoc.do.case([ + storageRef.abstract AND item.typeInstance.value IS NOT NULL, // built-in: store new value ' SET spec.value = apoc.convert.toJson(item.typeInstance.value) RETURN spec ', + storageRef.abstract AND item.typeInstance.value IS NULL, // built-in: no value, so use old one ' MATCH (latestRevision)-[:SPECIFIED_BY]->(latestSpec: TypeInstanceResourceVersionSpec) SET spec.value = latestSpec.value RETURN spec + ' + ], + ' + RETURN spec // external storage, do nothing ', - {spec:spec, latestRevision: latestRevision, item: item}) YIELD value + {spec:spec, latestRevision: latestRevision, item: item}) YIELD value + + // Handle the `backend.context` + WITH ti, tir, spec, latestRevision, item + CALL apoc.do.when( + item.typeInstance.backend IS NOT NULL, + ' + CREATE (specBackend: TypeInstanceResourceVersionSpecBackend {context: apoc.convert.toJson(item.typeInstance.backend.context)}) + RETURN specBackend + ', + ' + CREATE (specBackend: TypeInstanceResourceVersionSpecBackend {context: apoc.convert.toJson(item.typeInstance.backend.context)}) + RETURN specBackend + ', + {spec:spec, latestRevision: latestRevision, item: item}) YIELD value as backendRef + WITH ti, tir, spec, latestRevision, item, backendRef.specBackend as specBackend + CREATE (spec)-[:WITH_BACKEND]->(specBackend) // Handle the `metadata.attributes` property CREATE (metadata: TypeInstanceResourceVersionMetadata) CREATE (tir)-[:DESCRIBED_BY]->(metadata) - // TODO: Temporary don't allow backend update, will be fixed in follow-up PR - WITH * - MATCH (latestRevision)-[:SPECIFIED_BY]->(latestSpec: TypeInstanceResourceVersionSpec)-[:WITH_BACKEND]->(specBackend: TypeInstanceResourceVersionSpecBackend) - CREATE (spec)-[:WITH_BACKEND]->(specBackend) - WITH ti, tir, latestRevision, metadata, item CALL apoc.do.when( item.typeInstance.attributes IS NOT NULL, diff --git a/hub-js/package-lock.json b/hub-js/package-lock.json index 657f8ee5e..13a7e6d61 100644 --- a/hub-js/package-lock.json +++ b/hub-js/package-lock.json @@ -9,19 +9,22 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@godaddy/terminus": "^4.8.0", - "@grpc/grpc-js": "^1.3.1", - "apollo-server-express": "^3.6.2", - "express": "^4.17.0", - "graphql": "^15.4.0", - "graphql-tools": "^8.1.0", + "prettier": "^2.5.0", + "@types/lodash": "^4.14.179", + "unique-names-generator": "^4.7.1", + "protobufjs": "^6.11.2", + "lodash": "^4.17.21", "long": "^5.2.0", + "graphql-tools": "^8.1.0", "neo4j-driver": "^4.3.0", - "neo4j-graphql-js": "~2.19.4", + "apollo-server-express": "^3.6.2", + "graphql": "^15.4.0", + "@godaddy/terminus": "^4.8.0", "nice-grpc": "^1.0.6", - "prettier": "^2.5.0", - "protobufjs": "^6.11.2", - "unique-names-generator": "^4.7.1", + "async-mutex": "^0.3.2", + "express": "^4.17.0", + "@grpc/grpc-js": "^1.3.1", + "neo4j-graphql-js": "~2.19.4", "winston": "^3.3.3" }, "devDependencies": { @@ -6091,6 +6094,11 @@ "react-is": "^16.7.0" } }, + "node_modules/@types/lodash": { + "version": "4.14.179", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.179.tgz", + "integrity": "sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w==" + }, "node_modules/@babel/helper-validator-option": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", @@ -7340,6 +7348,14 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "node_modules/async-mutex": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz", + "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==", + "dependencies": { + "tslib": "^2.3.1" + } + }, "node_modules/neo4j-driver/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -9680,6 +9696,11 @@ "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, + "@types/lodash": { + "version": "4.14.179", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.179.tgz", + "integrity": "sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w==" + }, "@types/long": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", @@ -10184,6 +10205,14 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" }, + "async-mutex": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz", + "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==", + "requires": { + "tslib": "^2.3.1" + } + }, "async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", diff --git a/hub-js/package.json b/hub-js/package.json index fd2001bbb..86b9a6a06 100644 --- a/hub-js/package.json +++ b/hub-js/package.json @@ -21,10 +21,13 @@ "dependencies": { "@godaddy/terminus": "^4.8.0", "@grpc/grpc-js": "^1.3.1", + "@types/lodash": "^4.14.179", "apollo-server-express": "^3.6.2", + "async-mutex": "^0.3.2", "express": "^4.17.0", "graphql": "^15.4.0", "graphql-tools": "^8.1.0", + "lodash": "^4.17.21", "long": "^5.2.0", "neo4j-driver": "^4.3.0", "neo4j-graphql-js": "~2.19.4", @@ -44,9 +47,9 @@ "@types/express-serve-static-core": "^4.17.19", "@types/node": "^16.4.13", "@types/ws": "^7.4.7", - "eslint": "^8.10.0", "@typescript-eslint/eslint-plugin": "^5.13.0", "@typescript-eslint/parser": "^5.13.0", + "eslint": "^8.10.0", "husky": "^4.0.0", "lint-staged": "^10.5.4", "npm-force-resolutions": "^0.0.10", diff --git a/hub-js/proto/storage_backend.proto b/hub-js/proto/storage_backend.proto index 2dd6dab4a..2069e972a 100644 --- a/hub-js/proto/storage_backend.proto +++ b/hub-js/proto/storage_backend.proto @@ -22,6 +22,7 @@ message OnUpdateRequest { uint32 new_resource_version = 2; bytes new_value = 3; optional bytes context = 4; + optional string owner_id = 5; } message OnUpdateResponse { @@ -31,6 +32,7 @@ message OnUpdateResponse { message OnDeleteRequest { string type_instance_id = 1; optional bytes context = 2; + optional string owner_id = 3; } message OnDeleteResponse {} diff --git a/hub-js/src/generated/grpc/storage_backend.ts b/hub-js/src/generated/grpc/storage_backend.ts index bf03eb0e8..64305d8d8 100644 --- a/hub-js/src/generated/grpc/storage_backend.ts +++ b/hub-js/src/generated/grpc/storage_backend.ts @@ -24,6 +24,7 @@ export interface OnUpdateRequest { newResourceVersion: number; newValue: Uint8Array; context?: Uint8Array | undefined; + ownerId?: string | undefined; } export interface OnUpdateResponse { @@ -33,6 +34,7 @@ export interface OnUpdateResponse { export interface OnDeleteRequest { typeInstanceId: string; context?: Uint8Array | undefined; + ownerId?: string | undefined; } export interface OnDeleteResponse {} @@ -293,6 +295,7 @@ function createBaseOnUpdateRequest(): OnUpdateRequest { newResourceVersion: 0, newValue: new Uint8Array(), context: undefined, + ownerId: undefined, }; } @@ -313,6 +316,9 @@ export const OnUpdateRequest = { if (message.context !== undefined) { writer.uint32(34).bytes(message.context); } + if (message.ownerId !== undefined) { + writer.uint32(42).string(message.ownerId); + } return writer; }, @@ -335,6 +341,9 @@ export const OnUpdateRequest = { case 4: message.context = reader.bytes(); break; + case 5: + message.ownerId = reader.string(); + break; default: reader.skipType(tag & 7); break; @@ -357,6 +366,7 @@ export const OnUpdateRequest = { context: isSet(object.context) ? bytesFromBase64(object.context) : undefined, + ownerId: isSet(object.ownerId) ? String(object.ownerId) : undefined, }; }, @@ -375,6 +385,7 @@ export const OnUpdateRequest = { message.context !== undefined ? base64FromBytes(message.context) : undefined); + message.ownerId !== undefined && (obj.ownerId = message.ownerId); return obj; }, @@ -384,6 +395,7 @@ export const OnUpdateRequest = { message.newResourceVersion = object.newResourceVersion ?? 0; message.newValue = object.newValue ?? new Uint8Array(); message.context = object.context ?? undefined; + message.ownerId = object.ownerId ?? undefined; return message; }, }; @@ -447,7 +459,7 @@ export const OnUpdateResponse = { }; function createBaseOnDeleteRequest(): OnDeleteRequest { - return { typeInstanceId: "", context: undefined }; + return { typeInstanceId: "", context: undefined, ownerId: undefined }; } export const OnDeleteRequest = { @@ -461,6 +473,9 @@ export const OnDeleteRequest = { if (message.context !== undefined) { writer.uint32(18).bytes(message.context); } + if (message.ownerId !== undefined) { + writer.uint32(26).string(message.ownerId); + } return writer; }, @@ -477,6 +492,9 @@ export const OnDeleteRequest = { case 2: message.context = reader.bytes(); break; + case 3: + message.ownerId = reader.string(); + break; default: reader.skipType(tag & 7); break; @@ -493,6 +511,7 @@ export const OnDeleteRequest = { context: isSet(object.context) ? bytesFromBase64(object.context) : undefined, + ownerId: isSet(object.ownerId) ? String(object.ownerId) : undefined, }; }, @@ -505,6 +524,7 @@ export const OnDeleteRequest = { message.context !== undefined ? base64FromBytes(message.context) : undefined); + message.ownerId !== undefined && (obj.ownerId = message.ownerId); return obj; }, @@ -512,6 +532,7 @@ export const OnDeleteRequest = { const message = createBaseOnDeleteRequest(); message.typeInstanceId = object.typeInstanceId ?? ""; message.context = object.context ?? undefined; + message.ownerId = object.ownerId ?? undefined; return message; }, }; diff --git a/hub-js/src/index.ts b/hub-js/src/index.ts index 27c945867..d05fe6c2b 100644 --- a/hub-js/src/index.ts +++ b/hub-js/src/index.ts @@ -12,8 +12,9 @@ import { GraphQLSchema } from "graphql"; import { assertSchemaOnDatabase, getSchemaForMode, HubMode } from "./schema"; import { config } from "./config"; import { logger } from "./logger"; -import { ensureCoreStorageTypeInstance } from "./local/mutation/register-built-in-storage"; +import { ensureCoreStorageTypeInstance } from "./local/resolver/mutation/register-built-in-storage"; import DelegatedStorageService from "./local/storage/service"; +import UpdateArgsContainer from "./local/storage/update-args-container"; async function main() { logger.info("Using Neo4j database", { endpoint: config.neo4j.endpoint }); @@ -68,7 +69,13 @@ async function setupHttpServer( const delegatedStorage = new DelegatedStorageService(driver); const apolloServer = new ApolloServer({ schema, - context: { driver, delegatedStorage }, + context: () => { + return { + driver, + delegatedStorage, + updateArgs: new UpdateArgsContainer(), + }; + }, }); await apolloServer.start(); apolloServer.applyMiddleware({ app }); diff --git a/hub-js/src/local/index.ts b/hub-js/src/local/index.ts index 962f294be..90b29543f 100644 --- a/hub-js/src/local/index.ts +++ b/hub-js/src/local/index.ts @@ -1,11 +1,12 @@ import { readFileSync } from "fs"; import { makeAugmentedSchema } from "neo4j-graphql-js"; -import { createTypeInstances } from "./mutation/create-type-instances"; -import { updateTypeInstances } from "./mutation/update-type-instances"; -import { deleteTypeInstance } from "./mutation/delete-type-instance"; -import { createTypeInstance } from "./mutation/create-type-instance"; -import { lockTypeInstances } from "./mutation/lock-type-instances"; -import { unlockTypeInstances } from "./mutation/unlock-type-instances"; +import { createTypeInstances } from "./resolver/mutation/create-type-instances"; +import { updateTypeInstances } from "./resolver/mutation/update-type-instances"; +import { deleteTypeInstance } from "./resolver/mutation/delete-type-instance"; +import { createTypeInstance } from "./resolver/mutation/create-type-instance"; +import { lockTypeInstances } from "./resolver/mutation/lock-type-instances"; +import { unlockTypeInstances } from "./resolver/mutation/unlock-type-instances"; +import { typeInstanceResourceVersionSpecValueField } from "./resolver/field/spec-value-field"; const typeDefs = readFileSync("./graphql/local/schema.graphql", "utf-8"); @@ -20,6 +21,9 @@ export const schema = makeAugmentedSchema({ lockTypeInstances, unlockTypeInstances, }, + TypeInstanceResourceVersionSpec: { + value: typeInstanceResourceVersionSpecValueField, + }, }, config: { query: false, diff --git a/hub-js/src/local/mutation/context.ts b/hub-js/src/local/mutation/context.ts deleted file mode 100644 index 1f6a561d0..000000000 --- a/hub-js/src/local/mutation/context.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Driver } from "neo4j-driver"; -import DelegatedStorageService from "../storage/service"; - -export interface ContextWithDriver { - driver: Driver; -} - -export interface ContextWithDelegatedStorage { - delegatedStorage: DelegatedStorageService; -} - -export interface Context - extends ContextWithDriver, - ContextWithDelegatedStorage {} diff --git a/hub-js/src/local/mutation/update-type-instances.ts b/hub-js/src/local/mutation/update-type-instances.ts deleted file mode 100644 index ce56ff485..000000000 --- a/hub-js/src/local/mutation/update-type-instances.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Neo4jContext, neo4jgraphql } from "neo4j-graphql-js"; -import { GraphQLResolveInfo } from "graphql"; -import { - CustomCypherErrorCode, - CustomCypherErrorOutput, - tryToExtractCustomCypherError, -} from "./cypher-errors"; -import { logger } from "../../logger"; - -export async function updateTypeInstances( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - obj: any, - args: { in: [{ id: string }] }, - context: Neo4jContext, - resolveInfo: GraphQLResolveInfo -) { - try { - logger.debug("Executing query to update TypeInstance(s)", args); - return await neo4jgraphql(obj, args, context, resolveInfo); - } catch (e) { - let err = e as Error; - - const customErr = tryToExtractCustomCypherError(err); - if (customErr) { - switch (customErr.code) { - case CustomCypherErrorCode.Conflict: - err = generateConflictError(customErr); - break; - case CustomCypherErrorCode.NotFound: { - err = generateNotFoundError(args.in, customErr); - break; - } - default: - err = Error(`Unexpected error code ${customErr.code}`); - break; - } - } - throw new Error(`failed to update TypeInstances: ${err.message}`); - } -} - -function generateNotFoundError( - input: [{ id: string }], - customErr: CustomCypherErrorOutput -) { - const ids = input.map(({ id }) => id); - const notFoundIDs = ids - .filter((x) => !customErr.ids.includes(x)) - .join(`", "`); - return Error(`TypeInstances with IDs "${notFoundIDs}" were not found`); -} - -function generateConflictError(customErr: CustomCypherErrorOutput) { - if (!Object.prototype.hasOwnProperty.call(customErr, "ids")) { - // it shouldn't happen - return Error(`TypeInstances are locked by different owner`); - } - const conflictIDs = customErr.ids.join(`", "`); - return Error( - `TypeInstances with IDs "${conflictIDs}" are locked by different owner` - ); -} diff --git a/hub-js/src/local/resolver/field/spec-value-field.ts b/hub-js/src/local/resolver/field/spec-value-field.ts new file mode 100644 index 000000000..8cb4a7145 --- /dev/null +++ b/hub-js/src/local/resolver/field/spec-value-field.ts @@ -0,0 +1,112 @@ +import { logger } from "../../../logger"; +import { GetInput } from "../../storage/service"; +import { Context } from "../mutation/context"; +import { Operation } from "../../storage/update-args-container"; +import _ from "lodash"; +import { Mutex } from "async-mutex"; + +const mutex = new Mutex(); + +// Represents contract defined on `TypeInstanceResourceVersionSpec.Value` field cypher query. +interface InputObject { + value: { + // specifies whether data is stored in built-in or external storage + abstract: boolean; + // holds the TypeInstance's value stored in built-in storage + builtinValue: undefined; + // holds information needed to fetch the TypeInstance's value from external storage + fetchInput: GetInput; + }; +} + +export async function typeInstanceResourceVersionSpecValueField( + { value: obj }: InputObject, + _: undefined, + context: Context +) { + // This is a field resolver, it can be called multiple times within the same `query/mutation`. + // We also perform, external calls to change the state if needed, due to that fact, we use + // mutex to ensure that we won't call backend multiple times as backends may be not thread safe. + return await mutex.runExclusive(async () => { + logger.debug("Executing custom field resolver for 'value' field", obj); + if (obj.abstract) { + logger.debug("Return data stored in built-in storage"); + return obj.builtinValue; + } + + switch (context.updateArgs.GetOperation()) { + case Operation.UpdateTypeInstancesMutation: + return await resolveMutationReturnValue(context, obj.fetchInput); + default: { + logger.debug("Return data stored in external storage"); + const resp = await context.delegatedStorage.Get(obj.fetchInput); + return resp[obj.fetchInput.typeInstance.id]; + } + } + }); +} + +async function resolveMutationReturnValue( + context: Context, + fetchInput: GetInput +) { + const tiId = fetchInput.typeInstance.id; + const revToResolve = fetchInput.typeInstance.resourceVersion; + + let newValue = context.updateArgs.GetValue(tiId); + const lastKnownRev = context.updateArgs.GetLastKnownRev(tiId); + + // During the mutation someone asked to return also: + // - `firstResourceVersion` + // - and/or `previousResourceVersion` + // - and/or `resourceVersion` with already known revision + // - and/or `resourceVersions` which holds also previous already stored revisions + if (revToResolve <= lastKnownRev) { + logger.debug( + "Fetch data from external storage for already known revision", + fetchInput + ); + const resp = await context.delegatedStorage.Get(fetchInput); + return resp[tiId]; + } + + // If the revision is higher that the last known revision version, it means that we need to store that into delegated + // storage. + + // 1. Based on our contract, if user didn't provide value, we need to fetch the old one and put it + // to the new revision. + if (!newValue) { + const previousValue: GetInput = _.cloneDeep(fetchInput); + previousValue.typeInstance.resourceVersion -= 1; + + logger.debug( + "Fetching previous value from external storage", + previousValue + ); + const resp = await context.delegatedStorage.Get(previousValue); + newValue = resp[tiId]; + } + + // 2. Update TypeInstance's value + const update = { + backend: fetchInput.backend, + typeInstance: { + id: fetchInput.typeInstance.id, + newResourceVersion: fetchInput.typeInstance.resourceVersion, + newValue, + ownerID: context.updateArgs.GetOwnerID(fetchInput.typeInstance.id), + }, + }; + + logger.debug("Storing new value into external storage", update); + await context.delegatedStorage.Update(update); + + // 3. Update last known revision, so if `value` resolver is called next time we won't update it once again + // and run into `ALREADY_EXISTS` error. + context.updateArgs.SetLastKnownRev( + update.typeInstance.id, + update.typeInstance.newResourceVersion + ); + + return newValue; +} diff --git a/hub-js/src/local/resolver/mutation/context.ts b/hub-js/src/local/resolver/mutation/context.ts new file mode 100644 index 000000000..d9ff1895c --- /dev/null +++ b/hub-js/src/local/resolver/mutation/context.ts @@ -0,0 +1,20 @@ +import { Driver } from "neo4j-driver"; +import DelegatedStorageService from "../../storage/service"; +import UpdateArgsContainer from "../../storage/update-args-container"; + +export interface ContextWithDriver { + driver: Driver; +} + +export interface ContextWithDelegatedStorage { + delegatedStorage: DelegatedStorageService; +} + +export interface ContextWithUpdateArgs { + updateArgs: UpdateArgsContainer; +} + +export interface Context + extends ContextWithDriver, + ContextWithDelegatedStorage, + ContextWithUpdateArgs {} diff --git a/hub-js/src/local/mutation/create-type-instance.ts b/hub-js/src/local/resolver/mutation/create-type-instance.ts similarity index 81% rename from hub-js/src/local/mutation/create-type-instance.ts rename to hub-js/src/local/resolver/mutation/create-type-instance.ts index 8e4d8c243..6648cd384 100644 --- a/hub-js/src/local/mutation/create-type-instance.ts +++ b/hub-js/src/local/resolver/mutation/create-type-instance.ts @@ -1,5 +1,5 @@ import { Context } from "./context"; -import { CreateTypeInstanceInput } from "../types/type-instance"; +import { CreateTypeInstanceInput } from "../../types/type-instance"; import { createTypeInstances, CreateTypeInstancesArgs, @@ -10,8 +10,7 @@ interface CreateTypeInstanceArgs { } export async function createTypeInstance( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _: any, + _: unknown, args: CreateTypeInstanceArgs, context: Context ) { diff --git a/hub-js/src/local/mutation/create-type-instances.ts b/hub-js/src/local/resolver/mutation/create-type-instances.ts similarity index 80% rename from hub-js/src/local/mutation/create-type-instances.ts rename to hub-js/src/local/resolver/mutation/create-type-instances.ts index 4ea766944..520e68a5f 100644 --- a/hub-js/src/local/mutation/create-type-instances.ts +++ b/hub-js/src/local/resolver/mutation/create-type-instances.ts @@ -1,5 +1,5 @@ import { QueryResult, Transaction } from "neo4j-driver"; -import { BUILTIN_STORAGE_BACKEND_ID } from "../../config"; +import { BUILTIN_STORAGE_BACKEND_ID } from "../../../config"; import { Context } from "./context"; import { uniqueNamesGenerator, @@ -8,21 +8,26 @@ import { colors, animals, } from "unique-names-generator"; -import { DeleteInput, StoreInput, UpdatedContexts } from "../storage/service"; +import DelegatedStorageService, { + DeleteInput, + StoreInput, + UpdatedContexts, +} from "../../storage/service"; import { CreateTypeInstanceInput, CreateTypeInstancesInput, TypeInstanceBackendDetails, TypeInstanceBackendInput, TypeInstanceUsesRelationInput, -} from "../types/type-instance"; +} from "../../types/type-instance"; import { CustomCypherErrorCode, CustomCypherErrorOutput, tryToExtractCustomCypherError, } from "./cypher-errors"; -import { logger } from "../../logger"; +import { logger } from "../../../logger"; import { builtinStorageBackendDetails } from "./register-built-in-storage"; +import * as grpc from "@grpc/grpc-js"; const genAdjsColorsAndAnimals: Config = { dictionaries: [adjectives, colors, animals], @@ -43,8 +48,7 @@ export type TypeInstanceInput = Omit & { }; export async function createTypeInstances( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _: any, + _: unknown, args: CreateTypeInstancesArgs, context: Context ) { @@ -90,16 +94,61 @@ export async function createTypeInstances( })); }); } catch (e) { - // Ensure that data is deleted in case of not committed transaction - await context.delegatedStorage.Delete(...externallyStored); + const rollbackErr = await rollbackExternalStoreAction( + context.delegatedStorage, + externallyStored + ); const err = e as Error; - throw new Error(`failed to create the TypeInstances: ${err.message}`); + const outErr = aggregateError(err, rollbackErr); + throw new Error(`failed to create the TypeInstances: ${outErr.message}`); } finally { await neo4jSession.close(); } } +// Ensure that data is deleted in case of not committed transaction +async function rollbackExternalStoreAction( + delegatedStorage: DelegatedStorageService, + externallyStored: DeleteInput[] +): Promise { + try { + await delegatedStorage.Delete(...externallyStored); + } catch (e) { + const err = e as grpc.ServiceError; + if (err.code != grpc.status.NOT_FOUND) { + return new Error(`rollback externally stored values: ${err.message}`); + } + } + return; +} + +// Allows printing multiple errors in readable way. For example: +// +// All attempts fail: +// #1: graphql: failed to create the TypeInstances: 2 error occurred: +// * ClientError: /storage_backend.StorageBackend/OnCreate INVALID_ARGUMENT: Delegated storage doesn't accept value +// * ClientError: /storage_backend.StorageBackend/OnDelete NOT_FOUND: path "/tmp/capact/85375699-6e4f-4089-b99c-9bece4c17190" in provider "dotenv" not found +export function aggregateError( + ...input: (string | Error | undefined)[] +): Error { + const errors = input.filter((x) => !!x); + + let header = ""; + switch (errors.length) { + case 0: + break; + case 1: + header = "1 error occurred:\n\t* "; + break; + default: + header = `${errors.length} error occurred:\n\t* `; + } + const items: string = input.filter((x) => !!x).join("\n\t* "); + + return new Error(`${header}${items}`); +} + function validate(input: TypeInstanceInput[]) { const aliases = input .filter((x) => x.alias !== undefined) @@ -182,7 +231,20 @@ async function createTypeInstancesInDB( CREATE (ti)-[:CONTAINS]->(tir) CREATE (tir)-[:DESCRIBED_BY]->(metadata: TypeInstanceResourceVersionMetadata) - CREATE (tir)-[:SPECIFIED_BY]->(spec: TypeInstanceResourceVersionSpec {value: apoc.convert.toJson(typeInstance.value)}) + CREATE (tir)-[:SPECIFIED_BY]->(spec: TypeInstanceResourceVersionSpec) + WITH * + CALL apoc.do.when( + typeInstance.backend.abstract, + ' + SET spec.value = apoc.convert.toJson(typeInstance.value) + RETURN true as executed + ', + ' + RETURN false as executed + ', + {typeInstance: typeInstance, spec: spec} + ) YIELD value + CREATE (specBackend: TypeInstanceResourceVersionSpecBackend {context: apoc.convert.toJson(typeInstance.backend.context)}) CREATE (spec)-[:WITH_BACKEND]->(specBackend) @@ -237,7 +299,7 @@ async function updateTypeInstancesContextInDB( updatedContexts: UpdatedContexts ) { if (Object.keys(updatedContexts).length) { - logger.debug("Executing query to update backend contexts"); + logger.debug("Executing query to update backend contexts", updatedContexts); } await tx.run( diff --git a/hub-js/src/local/mutation/cypher-errors.ts b/hub-js/src/local/resolver/mutation/cypher-errors.ts similarity index 98% rename from hub-js/src/local/mutation/cypher-errors.ts rename to hub-js/src/local/resolver/mutation/cypher-errors.ts index 26f861e18..fc4703c91 100644 --- a/hub-js/src/local/mutation/cypher-errors.ts +++ b/hub-js/src/local/resolver/mutation/cypher-errors.ts @@ -1,4 +1,5 @@ export enum CustomCypherErrorCode { + BadRequest = 400, Conflict = 409, NotFound = 404, } diff --git a/hub-js/src/local/mutation/delete-type-instance.ts b/hub-js/src/local/resolver/mutation/delete-type-instance.ts similarity index 52% rename from hub-js/src/local/mutation/delete-type-instance.ts rename to hub-js/src/local/resolver/mutation/delete-type-instance.ts index 6b8aff355..7cec7e530 100644 --- a/hub-js/src/local/mutation/delete-type-instance.ts +++ b/hub-js/src/local/resolver/mutation/delete-type-instance.ts @@ -1,21 +1,17 @@ import { Transaction } from "neo4j-driver"; -import { ContextWithDriver } from "./context"; +import { Context } from "./context"; import { CustomCypherErrorCode, + CustomCypherErrorOutput, tryToExtractCustomCypherError, } from "./cypher-errors"; -import { logger } from "../../logger"; - -export interface UpdateTypeInstanceError { - code: CustomCypherErrorCode; - ids: string[]; -} +import { logger } from "../../../logger"; +import { TypeInstanceBackendInput } from "../../types/type-instance"; export async function deleteTypeInstance( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _: any, + _: unknown, args: { id: string; ownerID: string }, - context: ContextWithDriver + context: Context ) { const neo4jSession = context.driver.session(); try { @@ -24,7 +20,7 @@ export async function deleteTypeInstance( "Executing query to delete TypeInstance from database", args ); - await tx.run( + const result = await tx.run( ` OPTIONAL MATCH (ti:TypeInstance {id: $id}) @@ -45,40 +41,75 @@ export async function deleteTypeInstance( CALL { WITH ti WITH ti - MATCH (ti)-[:USES]->(others:TypeInstance) - WITH count(others) as othersLen - RETURN othersLen > 1 as isUsed + MATCH (others:TypeInstance)-[:USES]->(ti) + RETURN collect(others.id) as usedIds } - CALL apoc.util.validate(isUsed, apoc.convert.toJson({code: 400}), null) + CALL apoc.util.validate(size(usedIds) > 0, apoc.convert.toJson({ids: usedIds, code: 400}), null) WITH ti MATCH (ti)-[:CONTAINS]->(tirs: TypeInstanceResourceVersion) MATCH (ti)-[:OF_TYPE]->(typeRef: TypeInstanceTypeReference) + MATCH (ti)-[:STORED_IN]->(backendRef: TypeInstanceBackendReference) MATCH (metadata:TypeInstanceResourceVersionMetadata)<-[:DESCRIBED_BY]-(tirs) MATCH (tirs)-[:SPECIFIED_BY]->(spec: TypeInstanceResourceVersionSpec) + MATCH (spec)-[:WITH_BACKEND]->(specBackend: TypeInstanceResourceVersionSpecBackend) + OPTIONAL MATCH (metadata)-[:CHARACTERIZED_BY]->(attrRef: AttributeReference) - DETACH DELETE ti, metadata, spec, tirs + // NOTE: Need to be preserved with 'WITH' statement, otherwise we won't be able + // to access node's properties after 'DETACH DELETE' statement. + WITH *, {id: ti.id, backend: { id: backendRef.id, context: specBackend.context, abstract: backendRef.abstract}} as out + DETACH DELETE ti, metadata, spec, tirs, specBackend - WITH typeRef + WITH * CALL { MATCH (typeRef) WHERE NOT (typeRef)--() DELETE (typeRef) - RETURN 'remove typeRef' + RETURN count([]) as _tmp0 } WITH * CALL { - MATCH (attrRef) + MATCH (backendRef) + WHERE NOT (backendRef)--() + DELETE (backendRef) + RETURN count([]) as _tmp1 + } + + WITH * + CALL { + OPTIONAL MATCH (attrRef) WHERE attrRef IS NOT NULL AND NOT (attrRef)--() DELETE (attrRef) - RETURN 'remove attr' + RETURN count([]) as _tmp2 } - RETURN $id`, + RETURN out`, { id: args.id, ownerID: args.ownerID || null } ); + + // NOTE: Use map to ensure that external storage is not called multiple time for the same ID + const deleteExternally = new Map(); + result.records.forEach((record) => { + const out = record.get("out"); + + if (out.backend.abstract) { + return; + } + deleteExternally.set(out.id, out.backend); + }); + + for (const [id, backend] of deleteExternally) { + await context.delegatedStorage.Delete({ + typeInstance: { + id, + ownerID: args.ownerID, + }, + backend: backend as TypeInstanceBackendInput, + }); + } + return args.id; }); } catch (e) { @@ -92,6 +123,9 @@ export async function deleteTypeInstance( case CustomCypherErrorCode.NotFound: err = Error(`TypeInstance was not found`); break; + case CustomCypherErrorCode.BadRequest: + err = generateBadRequestError(customErr); + break; default: err = Error(`Unexpected error code ${customErr.code}`); break; @@ -105,3 +139,15 @@ export async function deleteTypeInstance( await neo4jSession.close(); } } + +function generateBadRequestError(customErr: CustomCypherErrorOutput) { + if (!Object.prototype.hasOwnProperty.call(customErr, "ids")) { + // it shouldn't happen + return Error(`TypeInstance is used by other TypeInstances`); + } + return Error( + `TypeInstance is used by other TypeInstances, you must first remove ${customErr.ids.join( + '", "' + )}` + ); +} diff --git a/hub-js/src/local/mutation/lock-type-instances.ts b/hub-js/src/local/resolver/mutation/lock-type-instances.ts similarity index 67% rename from hub-js/src/local/mutation/lock-type-instances.ts rename to hub-js/src/local/resolver/mutation/lock-type-instances.ts index 8cdaa30d5..fbddc8b6c 100644 --- a/hub-js/src/local/mutation/lock-type-instances.ts +++ b/hub-js/src/local/resolver/mutation/lock-type-instances.ts @@ -1,6 +1,8 @@ import { Transaction } from "neo4j-driver"; -import { ContextWithDriver } from "./context"; -import { logger } from "../../logger"; +import { Context } from "./context"; +import { logger } from "../../../logger"; +import { TypeInstanceBackendDetails } from "../../types/type-instance"; +import { LockInput } from "../../storage/service"; export interface LockingTypeInstanceInput { in: { @@ -21,11 +23,15 @@ interface LockingResult { }; } +interface ExternallyStoredOutput { + backend: TypeInstanceBackendDetails; + typeInstanceId: string; +} + export async function lockTypeInstances( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _: any, + _: unknown, args: LockingTypeInstanceInput, - context: ContextWithDriver + context: Context ) { const neo4jSession = context.driver.session(); try { @@ -40,6 +46,13 @@ export async function lockTypeInstances( SET ti.lockedBy = $in.ownerID RETURN true as executed` ); + const lockExternals = await getTypeInstanceStoredExternally( + tx, + args.in.ids, + args.in.ownerID + ); + await context.delegatedStorage.Lock(...lockExternals); + return args.in.ids; }); } catch (e) { @@ -141,3 +154,51 @@ function validateLockingProcess(result: LockingResult, expIDs: [string]) { } } } + +export async function getTypeInstanceStoredExternally( + tx: Transaction, + ids: string[], + lockedBy: string +): Promise { + const result = await tx.run( + ` + UNWIND $ids as id + MATCH (ti:TypeInstance {id: id}) + + WITH * + // Get Latest Revision + CALL { + WITH ti + WITH ti + MATCH (ti)-[:CONTAINS]->(tir:TypeInstanceResourceVersion) + RETURN tir ORDER BY tir.resourceVersion DESC LIMIT 1 + } + + MATCH (tir)-[:SPECIFIED_BY]->(spec:TypeInstanceResourceVersionSpec) + MATCH (spec)-[:WITH_BACKEND]->(backendCtx) + MATCH (ti)-[:STORED_IN]->(backendRef) + + WITH { + typeInstanceId: ti.id, + backend: { context: backendCtx.context, id: backendRef.id, abstract: backendRef.abstract} + } AS value + RETURN value + `, + { ids: ids } + ); + + const output = result.records.map( + (record) => record.get("value") as ExternallyStoredOutput + ); + return output + .filter((x) => !x.backend.abstract) + .map((x) => { + return { + backend: x.backend, + typeInstance: { + id: x.typeInstanceId, + lockedBy: lockedBy, + }, + }; + }); +} diff --git a/hub-js/src/local/mutation/register-built-in-storage.ts b/hub-js/src/local/resolver/mutation/register-built-in-storage.ts similarity index 93% rename from hub-js/src/local/mutation/register-built-in-storage.ts rename to hub-js/src/local/resolver/mutation/register-built-in-storage.ts index a3e2ce353..5a37dd4b6 100644 --- a/hub-js/src/local/mutation/register-built-in-storage.ts +++ b/hub-js/src/local/resolver/mutation/register-built-in-storage.ts @@ -1,7 +1,7 @@ import { Transaction } from "neo4j-driver"; -import { BUILTIN_STORAGE_BACKEND_ID } from "../../config"; +import { BUILTIN_STORAGE_BACKEND_ID } from "../../../config"; import { ContextWithDriver } from "./context"; -import { TypeInstanceBackendDetails } from "../types/type-instance"; +import { TypeInstanceBackendDetails } from "../../types/type-instance"; export async function ensureCoreStorageTypeInstance( context: ContextWithDriver diff --git a/hub-js/src/local/mutation/unlock-type-instances.ts b/hub-js/src/local/resolver/mutation/unlock-type-instances.ts similarity index 65% rename from hub-js/src/local/mutation/unlock-type-instances.ts rename to hub-js/src/local/resolver/mutation/unlock-type-instances.ts index 1a976531a..16e78128c 100644 --- a/hub-js/src/local/mutation/unlock-type-instances.ts +++ b/hub-js/src/local/resolver/mutation/unlock-type-instances.ts @@ -1,15 +1,18 @@ import { Transaction } from "neo4j-driver"; -import { ContextWithDriver } from "./context"; -import { LockingTypeInstanceInput, switchLocking } from "./lock-type-instances"; -import { logger } from "../../logger"; +import { Context } from "./context"; +import { + getTypeInstanceStoredExternally, + LockingTypeInstanceInput, + switchLocking, +} from "./lock-type-instances"; +import { logger } from "../../../logger"; interface UnLockTypeInstanceInput extends LockingTypeInstanceInput {} export async function unlockTypeInstances( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _: any, + _: unknown, args: UnLockTypeInstanceInput, - context: ContextWithDriver + context: Context ) { const neo4jSession = context.driver.session(); try { @@ -24,6 +27,13 @@ export async function unlockTypeInstances( SET ti.lockedBy = null RETURN true as executed` ); + const unlockExternals = await getTypeInstanceStoredExternally( + tx, + args.in.ids, + args.in.ownerID + ); + await context.delegatedStorage.Unlock(...unlockExternals); + return args.in.ids; }); } catch (e) { diff --git a/hub-js/src/local/resolver/mutation/update-type-instances.ts b/hub-js/src/local/resolver/mutation/update-type-instances.ts new file mode 100644 index 000000000..c153b32cb --- /dev/null +++ b/hub-js/src/local/resolver/mutation/update-type-instances.ts @@ -0,0 +1,138 @@ +import { cypherMutation } from "neo4j-graphql-js"; +import { GraphQLResolveInfo } from "graphql"; +import _ from "lodash"; +import neo4j, { QueryResult, Transaction } from "neo4j-driver"; +import { + CustomCypherErrorCode, + CustomCypherErrorOutput, + tryToExtractCustomCypherError, +} from "./cypher-errors"; +import { logger } from "../../../logger"; +import { Context } from "./context"; +import { Operation } from "../../storage/update-args-container"; + +interface UpdateTypeInstancesInput { + in: [ + { + id: string; + ownerID?: string; + typeInstance: { + value?: unknown; + }; + } + ]; +} + +export async function updateTypeInstances( + _: unknown, + args: UpdateTypeInstancesInput, + context: Context, + resolveInfo: GraphQLResolveInfo +) { + logger.debug("Executing query to update TypeInstance(s)", args); + + context.updateArgs.SetOperation(Operation.UpdateTypeInstancesMutation); + args.in.forEach((x) => { + context.updateArgs.SetValue(x.id, x.typeInstance.value); + context.updateArgs.SetOwnerID(x.id, x.ownerID); + }); + + const neo4jSession = context.driver.session(); + + try { + return await neo4jSession.writeTransaction(async (tx: Transaction) => { + // NOTE: we need to record for each input TypeInstance's id, current latest + // revision in order to know for which revision the value property is already known and + // stored. + const instancesResult = await getLatestRevisionVersions(tx, args); + instancesResult.records.forEach((record) => { + context.updateArgs.SetLastKnownRev(record.get("id"), record.get("ver")); + }); + + const [query, queryParams] = cypherMutation(args, context, resolveInfo); + const outputResult = await tx.run(query, queryParams); + + return extractUpdateMutationResult(outputResult); + }); + } catch (e) { + let err = e as Error; + + const customErr = tryToExtractCustomCypherError(err); + if (customErr) { + switch (customErr.code) { + case CustomCypherErrorCode.Conflict: + err = generateConflictError(customErr); + break; + case CustomCypherErrorCode.NotFound: { + err = generateNotFoundError(args.in, customErr); + break; + } + default: + err = Error(`Unexpected error code ${customErr.code}`); + break; + } + } + throw new Error(`failed to update TypeInstances: ${err.message}`); + } +} + +function generateNotFoundError( + input: [{ id: string }], + customErr: CustomCypherErrorOutput +) { + const ids = input.map(({ id }) => id); + const notFoundIDs = ids + .filter((x) => !customErr.ids.includes(x)) + .join(`", "`); + return Error(`TypeInstances with IDs "${notFoundIDs}" were not found`); +} + +function generateConflictError(customErr: CustomCypherErrorOutput) { + if (!Object.prototype.hasOwnProperty.call(customErr, "ids")) { + // it shouldn't happen + return Error(`TypeInstances are locked by different owner`); + } + const conflictIDs = customErr.ids.join(`", "`); + return Error( + `TypeInstances with IDs "${conflictIDs}" are locked by different owner` + ); +} + +// Simplified version of: https://github.com/neo4j-graphql/neo4j-graphql-js/blob/381ef0302bbd11ecd0f94f978045cdbc61c39b8e/src/utils.js#L57 +// We know the variable name as the mutation is written by us, and this function is not meant to be generic. +function extractUpdateMutationResult(result: QueryResult) { + const data = result.records.map((record) => record.get("typeInstance")); + // handle Integer fields + return _.cloneDeepWith(data, (field) => { + if (neo4j.isInt(field)) { + // See: https://neo4j.com/docs/api/javascript-driver/current/class/src/v1/integer.js~Integer.html + return field.inSafeRange() ? field.toNumber() : field.toString(); + } + return; + }); +} + +async function getLatestRevisionVersions( + tx: Transaction, + args: UpdateTypeInstancesInput +) { + const typeInstanceIds = args.in.map((x) => x.id); + return tx.run( + ` + UNWIND $ids as id + MATCH (ti:TypeInstance {id: id}) + + WITH * + // Get Latest Revision + CALL { + WITH ti + WITH ti + MATCH (ti)-[:CONTAINS]->(tir:TypeInstanceResourceVersion) + RETURN tir ORDER BY tir.resourceVersion DESC LIMIT 1 + } + + RETURN id, tir.resourceVersion as ver + `, + { ids: typeInstanceIds } + ); +} diff --git a/hub-js/src/local/storage/service.ts b/hub-js/src/local/storage/service.ts index 2360ef731..139e086a5 100644 --- a/hub-js/src/local/storage/service.ts +++ b/hub-js/src/local/storage/service.ts @@ -1,6 +1,10 @@ import { + GetValueRequest, OnCreateRequest, OnDeleteRequest, + OnLockRequest, + OnUnlockRequest, + OnUpdateRequest, StorageBackendDefinition, } from "../../generated/grpc/storage_backend"; import { createChannel, createClient, Client } from "nice-grpc"; @@ -8,7 +12,7 @@ import { Driver } from "neo4j-driver"; import { TypeInstanceBackendInput } from "../types/type-instance"; import { logger } from "../../logger"; -// TODO(https://github.com/capactio/capact/issues/604): +// TODO(https://github.com/capactio/capact/issues/634): // Represents the fake storage backend URL that should be ignored // as the backend server is not deployed. // It should be removed after a real backend is used in `test/e2e/action_test.go` scenarios. @@ -24,12 +28,45 @@ export interface StoreInput { backend: TypeInstanceBackendInput; typeInstance: { id: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any; + value: unknown; + }; +} + +export interface UpdateInput { + backend: TypeInstanceBackendInput; + typeInstance: { + id: string; + newResourceVersion: number; + newValue: unknown; + ownerID?: string; + }; +} + +export interface GetInput { + backend: TypeInstanceBackendInput; + typeInstance: { + id: string; + resourceVersion: number; }; } export interface DeleteInput { + backend: TypeInstanceBackendInput; + typeInstance: { + id: string; + ownerID?: string; + }; +} + +export interface LockInput { + backend: TypeInstanceBackendInput; + typeInstance: { + id: string; + lockedBy: string; + }; +} + +export interface UnlockInput { backend: TypeInstanceBackendInput; typeInstance: { id: string; @@ -37,8 +74,7 @@ export interface DeleteInput { } export interface UpdatedContexts { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; + [key: string]: unknown; } export default class DelegatedStorageService { @@ -57,14 +93,17 @@ export default class DelegatedStorageService { * @param inputs - Describes what should be stored. * @returns The update backend's context. If there was no update, it's undefined. * + * TODO(https://github.com/capactio/capact/issues/656): validate if `input.value` is allowed by backend (`backend.acceptValue`) + * TODO(https://github.com/capactio/capact/issues/656): validate `input.backend.context` against `backend.contextSchema`. */ async Store(...inputs: StoreInput[]): Promise { let mapping: UpdatedContexts = {}; for (const input of inputs) { - logger.debug( - `Storing TypeInstance ${input.typeInstance.id} in external backend ${input.backend.id}` - ); + logger.debug("Storing TypeInstance in external backend", { + typeInstanceId: input.typeInstance.id, + backendId: input.backend.id, + }); const cli = await this.getClient(input.backend.id); if (!cli) { // TODO: remove after using a real backend in e2e tests. @@ -73,12 +112,8 @@ export default class DelegatedStorageService { const req: OnCreateRequest = { typeInstanceId: input.typeInstance.id, - value: new TextEncoder().encode( - JSON.stringify(input.typeInstance.value) - ), - context: new TextEncoder().encode( - JSON.stringify(input.backend.context) - ), + value: this.encode(input.typeInstance.value), + context: this.encode(input.backend.context), }; const res = await cli.onCreate(req); @@ -97,32 +132,169 @@ export default class DelegatedStorageService { } /** - * Delete a given TypeInstance + * Updates the TypeInstance's value in a given backend. + * + * + * @param inputs - Describes what should be updated. + * + * TODO(https://github.com/capactio/capact/issues/656): validate if `input.value` is allowed by backend (`backend.acceptValue`) + * TODO(https://github.com/capactio/capact/issues/656): validate `input.backend.context` against `backend.contextSchema`. + */ + async Update(...inputs: UpdateInput[]) { + for (const input of inputs) { + logger.debug("Updating TypeInstance in external backend", { + typeInstanceId: input.typeInstance.id, + backendId: input.backend.id, + }); + const cli = await this.getClient(input.backend.id); + if (!cli) { + // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. + continue; + } + + const req: OnUpdateRequest = { + typeInstanceId: input.typeInstance.id, + newResourceVersion: input.typeInstance.newResourceVersion, + newValue: this.encode(input.typeInstance.newValue), + context: this.encode(input.backend.context), + ownerId: input.typeInstance.ownerID, + }; + + await cli.onUpdate(req); + } + } + + /** + * Gets the TypeInstance's value from a given backend. + * + * + * @param inputs - Describes what should be stored. + * @returns The update backend's context. If there was no update, it's undefined. + * + */ + async Get(...inputs: GetInput[]): Promise { + let result: UpdatedContexts = {}; + + for (const input of inputs) { + logger.debug("Fetching TypeInstance from external backend", { + typeInstanceId: input.typeInstance.id, + backendId: input.backend.id, + }); + const cli = await this.getClient(input.backend.id); + if (!cli) { + // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. + result = { + ...result, + [input.typeInstance.id]: { + key: input.backend.id, + }, + }; + continue; + } + + const req: GetValueRequest = { + typeInstanceId: input.typeInstance.id, + resourceVersion: input.typeInstance.resourceVersion, + context: this.encode(input.backend.context), + }; + const res = await cli.getValue(req); + + if (!res.value) { + throw Error( + `Got empty response for TypeInstance ${input.typeInstance.id} from external backend ${input.backend.id}` + ); + } + + const decodeRes = JSON.parse(res.value.toString()); + result = { + ...result, + [input.typeInstance.id]: decodeRes, + }; + } + + return result; + } + + /** + * Deletes a given TypeInstance * * @param inputs - Describes what should be deleted. * */ async Delete(...inputs: DeleteInput[]) { for (const input of inputs) { - logger.debug( - `Deleting TypeInstance ${input.typeInstance.id} from external backend ${input.backend.id}` - ); + logger.debug("Deleting TypeInstance from external backend", { + typeInstanceId: input.typeInstance.id, + backendId: input.backend.id, + }); const cli = await this.getClient(input.backend.id); if (!cli) { - // TODO: remove after using a real backend in e2e tests. + // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. continue; } const req: OnDeleteRequest = { typeInstanceId: input.typeInstance.id, - context: new TextEncoder().encode( - JSON.stringify(input.backend.context) - ), + context: this.encode(input.backend.context), + ownerId: input.typeInstance.ownerID, }; await cli.onDelete(req); } } + /** + * Locks a given TypeInstance + * + * @param inputs - Describes what should be locked. Owner ID is needed. + * + */ + async Lock(...inputs: LockInput[]) { + for (const input of inputs) { + logger.debug("Locking TypeInstance in external backend", { + typeInstanceId: input.typeInstance.id, + backendId: input.backend.id, + }); + const cli = await this.getClient(input.backend.id); + if (!cli) { + // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. + continue; + } + + const req: OnLockRequest = { + typeInstanceId: input.typeInstance.id, + lockedBy: input.typeInstance.lockedBy, + context: this.encode(input.backend.context), + }; + await cli.onLock(req); + } + } + + /** + * Unlocks a given TypeInstance + * + * @param inputs - Describes what should be unlocked. Owner ID is not needed. + * + */ + async Unlock(...inputs: UnlockInput[]) { + for (const input of inputs) { + logger.debug(`Unlocking TypeInstance in external backend`, { + typeInstanceId: input.typeInstance.id, + backendId: input.backend.id, + }); + const cli = await this.getClient(input.backend.id); + if (!cli) { + // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. + continue; + } + + const req: OnUnlockRequest = { + typeInstanceId: input.typeInstance.id, + context: this.encode(input.backend.context), + }; + await cli.onUnlock(req); + } + } + private async storageInstanceDetailsFetcher( id: string ): Promise { @@ -142,14 +314,19 @@ export default class DelegatedStorageService { `, { id: id } ); - if (fetchRevisionResult.records.length !== 1) { - throw new Error( - `Internal Server Error, unexpected response row length, want 1, got ${fetchRevisionResult.records.length}` - ); + switch (fetchRevisionResult.records.length) { + case 0: + throw new Error(`TypeInstance not found`); + case 1: + break; + default: + throw new Error( + `Found ${fetchRevisionResult.records.length} TypeInstances with the same id` + ); } const record = fetchRevisionResult.records[0]; - return record.get("value"); // TODO: validate against Storage JSON Schema. + return record.get("value"); // TODO(https://github.com/capactio/capact/issues/656): validate against Storage JSON Schema. } catch (e) { const err = e as Error; throw new Error( @@ -167,11 +344,14 @@ export default class DelegatedStorageService { logger.debug( "Skipping a real call as backend was classified as a fake one" ); - // TODO: remove after using a real backend in e2e tests. + // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. return undefined; } - logger.debug(`Initialize gRPC client for Backend ${id} with URL ${url}`); + logger.debug("Initialize gRPC client", { + backend: id, + url, + }); const channel = createChannel(url); const client: StorageClient = createClient( StorageBackendDefinition, @@ -182,4 +362,17 @@ export default class DelegatedStorageService { return this.registeredClients.get(id); } + + private static convertToJSONIfObject(val: unknown): string | undefined { + if (val instanceof Array || typeof val === "object") { + return JSON.stringify(val); + } + return val as string; + } + + private encode(val: unknown) { + return new TextEncoder().encode( + DelegatedStorageService.convertToJSONIfObject(val) + ); + } } diff --git a/hub-js/src/local/storage/update-args-container.ts b/hub-js/src/local/storage/update-args-container.ts new file mode 100644 index 000000000..d8b094791 --- /dev/null +++ b/hub-js/src/local/storage/update-args-container.ts @@ -0,0 +1,118 @@ +export enum Operation { + None, + UpdateTypeInstancesMutation, +} + +export default class UpdateArgsContainer { + private valuesPerTypeInstance: Map; + private latestKnownRevPerTypeInstance: Map; + private currentOperation: Operation; + private currentOperationOwnerID: Map; + + constructor() { + this.valuesPerTypeInstance = new Map(); + this.latestKnownRevPerTypeInstance = new Map(); + this.currentOperationOwnerID = new Map(); + this.currentOperation = Operation.None; + } + + /** + * Sets which operation started the GraphQL request. + * It is used to correlate proper flow between different resolvers. + * + * + * @param op - GraphQL request operation. + * + */ + SetOperation(op: Operation) { + if (this.currentOperation == op) { + return; + } + + if (this.currentOperation != Operation.None) { + throw Error(`Operation in progress, cannot change it`); + } + + this.currentOperation = op; + } + + /** + * Describes which operation is currently in progress. + * + * + * @return - GraphQL request operation. + * + */ + GetOperation(): Operation { + return this.currentOperation; + } + + /** + * Gives an option to transmit the TypeInstance's input value between resolvers. + * + * + * @param id - TypeInstance's ID. + * @param value - User specified TypeInstance's value. + * + */ + SetValue(id: string, value: unknown) { + return this.valuesPerTypeInstance.set(id, value); + } + + /** + * Gives an option to fetch the input set by other resolver. + * + * + * @return - TypeInstance's value if set by other resolver. + * + */ + GetValue(id: string): unknown { + return this.valuesPerTypeInstance.get(id); + } + + /** + * Informs which TypeInstance's version was already processed a stored. + * + * + * @param id - TypeInstance's ID. + * @param rev - TypeInstance's last revision version. + * + */ + SetLastKnownRev(id: string, rev: number) { + this.latestKnownRevPerTypeInstance.set(id, rev); + } + + /** + * Returns latest TypeInstance's revision version. + * Used to optimize number of request. If already stored, we don't need to trigger the update logic. + * + * @param id - TypeInstance's ID. + * @return - - TypeInstance's last revision version. If not set, returns 0. + * It's a safe assumption as Local Hub starts counting revision with 1. + * + */ + GetLastKnownRev(id: string) { + return this.latestKnownRevPerTypeInstance.get(id) || 0; + } + + /** + * Returns the owner id of the current operation (sent in GQL request). + * + * @return - current owner id. + * + */ + GetOwnerID(id: string): string | undefined { + return this.currentOperationOwnerID.get(id); + } + + /** + * Set the owner id of the current operation (sent in GQL request). May be undefined. + * + * @param id - related TypeInstance id. + * @param ownerId - current owner id. + * + */ + SetOwnerID(id: string, ownerId?: string) { + this.currentOperationOwnerID.set(id, ownerId); + } +} diff --git a/hub-js/src/local/types/type-instance.ts b/hub-js/src/local/types/type-instance.ts index 7b032c8d2..498c6b9ad 100644 --- a/hub-js/src/local/types/type-instance.ts +++ b/hub-js/src/local/types/type-instance.ts @@ -1,21 +1,18 @@ export interface TypeInstanceBackendInput { id: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context?: any; + context?: unknown; } export interface CreateTypeInstanceInput { alias?: string; backend?: TypeInstanceBackendInput; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value?: any; + value?: unknown; } export interface TypeInstanceBackendDetails { abstract: boolean; id: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context?: any; + context?: unknown; } export interface CreateTypeInstancesInput { diff --git a/internal/secret-storage-backend/server.go b/internal/secret-storage-backend/server.go index 871c8da19..322ad09c8 100644 --- a/internal/secret-storage-backend/server.go +++ b/internal/secret-storage-backend/server.go @@ -173,7 +173,7 @@ func (h *Handler) OnUpdate(_ context.Context, request *pb.OnUpdateRequest) (*pb. key := h.storageKeyForTypeInstanceValue(provider, request.TypeInstanceId, request.NewResourceVersion) - if err := h.ensureSecretCanBeUpdated(provider, key); err != nil { + if err := h.ensureSecretCanBeUpdated(provider, key, request.OwnerId); err != nil { return nil, err } @@ -255,7 +255,7 @@ func (h *Handler) OnDelete(_ context.Context, request *pb.OnDeleteRequest) (*pb. key := tellercore.KeyPath{ Path: h.storagePathForTypeInstance(provider, request.TypeInstanceId), } - err = h.ensureSecretCanBeDeleted(provider, key) + err = h.ensureSecretCanBeDeleted(provider, key, request.OwnerId) if err != nil { return nil, err } @@ -270,19 +270,26 @@ func (h *Handler) OnDelete(_ context.Context, request *pb.OnDeleteRequest) (*pb. func (h *Handler) getProviderFromContext(contextBytes []byte) (tellercore.Provider, error) { if len(contextBytes) == 0 { - // try to get the default provider provider, err := h.providers.GetDefault() if err != nil { return nil, h.failedPreconditionError(errors.Wrap(err, "while getting default provider based on empty context")) } - return provider, nil } var context Context err := json.Unmarshal(contextBytes, &context) if err != nil { - return nil, h.internalError(errors.Wrap(err, "while unmarshaling additional parameters")) + return nil, h.internalError(errors.Wrap(err, "while unmarshaling context")) + } + + if context.Provider == "" { + provider, err := h.providers.GetDefault() + if err != nil { + return nil, h.failedPreconditionError(errors.Wrap(err, "while getting default provider as not specified in context")) + } + + return provider, nil } provider, ok := h.providers[context.Provider] @@ -375,7 +382,7 @@ func (h *Handler) ensureSecretCanBeCreated(provider tellercore.Provider, key tel return nil } -func (h *Handler) ensureSecretCanBeUpdated(provider tellercore.Provider, key tellercore.KeyPath) error { +func (h *Handler) ensureSecretCanBeUpdated(provider tellercore.Provider, key tellercore.KeyPath, ownerID *string) error { entries, err := h.getEntriesForPath(provider, key) if err != nil { return h.internalError(err) @@ -387,6 +394,9 @@ func (h *Handler) ensureSecretCanBeUpdated(provider tellercore.Provider, key tel for _, entry := range entries { if entry.Key == lockedByField { + if ownerID != nil && entry.Value == *ownerID { + continue + } return h.typeInstanceLockedError(key.Path, entry.Value) } if entry.Key == key.Field { @@ -410,7 +420,7 @@ func (h *Handler) ensureSecretIsNotLocked(provider tellercore.Provider, typeInst return nil } -func (h *Handler) ensureSecretCanBeDeleted(provider tellercore.Provider, key tellercore.KeyPath) error { +func (h *Handler) ensureSecretCanBeDeleted(provider tellercore.Provider, key tellercore.KeyPath, ownerID *string) error { entries, err := h.getEntriesForPath(provider, key) if err != nil { return h.internalError(err) @@ -424,6 +434,9 @@ func (h *Handler) ensureSecretCanBeDeleted(provider tellercore.Provider, key tel if entry.Key != lockedByField { continue } + if ownerID != nil && entry.Value == *ownerID { + continue + } return h.typeInstanceLockedError(key.Path, entry.Value) } diff --git a/internal/secret-storage-backend/server_test.go b/internal/secret-storage-backend/server_test.go index a767009e5..1deadf7ee 100644 --- a/internal/secret-storage-backend/server_test.go +++ b/internal/secret-storage-backend/server_test.go @@ -679,7 +679,7 @@ func TestHandler_GetProviderFromContext(t *testing.T) { "one": &fakeProvider{name: "one"}, }, InputContextBytes: []byte(`{foo}`), - ExpectedErrorMessage: ptr.String("rpc error: code = Internal desc = while unmarshaling additional parameters: invalid character 'f' looking for beginning of object key string"), + ExpectedErrorMessage: ptr.String("rpc error: code = Internal desc = while unmarshaling context: invalid character 'f' looking for beginning of object key string"), }, { // this case shouldn't happen as the server validates list of input providers during start diff --git a/pkg/hub/api/graphql/local/models_gen.go b/pkg/hub/api/graphql/local/models_gen.go index d1faed55a..d01bf0eb7 100644 --- a/pkg/hub/api/graphql/local/models_gen.go +++ b/pkg/hub/api/graphql/local/models_gen.go @@ -162,6 +162,10 @@ type UnlockTypeInstancesInput struct { OwnerID string `json:"ownerID"` } +type UpdateTypeInstanceBackendInput struct { + Context interface{} `json:"context"` +} + type FilterRule string const ( diff --git a/pkg/hub/api/graphql/local/schema_gen.go b/pkg/hub/api/graphql/local/schema_gen.go index ecf74cd06..15e09761b 100644 --- a/pkg/hub/api/graphql/local/schema_gen.go +++ b/pkg/hub/api/graphql/local/schema_gen.go @@ -705,7 +705,33 @@ type TypeInstanceResourceVersionSpec { value: Any! @cypher( statement: """ - RETURN apoc.convert.fromJsonMap(this.value) + MATCH (this)<-[:SPECIFIED_BY]-(rev:TypeInstanceResourceVersion)<-[:CONTAINS]-(ti:TypeInstance) + MATCH (this)-[:WITH_BACKEND]->(backendCtx) + MATCH (ti)-[:STORED_IN]->(backendRef) + WITH * + CALL apoc.when( + backendRef.abstract, + ' + WITH { + abstract: backendRef.abstract, + builtinValue: apoc.convert.fromJsonMap(spec.value) + } AS value + RETURN value + ', + ' + WITH { + abstract: backendRef.abstract, + fetchInput: { + typeInstance: { resourceVersion: rev.resourceVersion, id: ti.id }, + backend: { context: backendCtx.context, id: backendRef.id} + } + } AS value + RETURN value + ', + {spec: this, rev: rev, ti: ti, backendRef: backendRef, backendCtx: backendCtx} + ) YIELD value as out + + RETURN out.value """ ) @@ -893,6 +919,15 @@ input UpdateTypeInstanceInput { The value property is optional. If not provided, previous value is used. """ value: Any + + """ + The backend property is optional. If not provided, previous value is used. + """ + backend: UpdateTypeInstanceBackendInput +} + +input UpdateTypeInstanceBackendInput { + context: Any } input UpdateTypeInstancesInput { @@ -1040,26 +1075,45 @@ type Mutation { CREATE (tir)-[:SPECIFIED_BY]->(spec) WITH ti, tir, spec, latestRevision, item - CALL apoc.do.when( - item.typeInstance.value IS NOT NULL, + MATCH (ti)-[:STORED_IN]->(storageRef:TypeInstanceBackendReference) + + WITH ti, tir, spec, latestRevision, item, storageRef + CALL apoc.do.case([ + storageRef.abstract AND item.typeInstance.value IS NOT NULL, // built-in: store new value ' SET spec.value = apoc.convert.toJson(item.typeInstance.value) RETURN spec ', + storageRef.abstract AND item.typeInstance.value IS NULL, // built-in: no value, so use old one ' MATCH (latestRevision)-[:SPECIFIED_BY]->(latestSpec: TypeInstanceResourceVersionSpec) SET spec.value = latestSpec.value RETURN spec + ' + ], + ' + RETURN spec // external storage, do nothing ', - {spec:spec, latestRevision: latestRevision, item: item}) YIELD value + {spec:spec, latestRevision: latestRevision, item: item}) YIELD value + + // Handle the ` + "`" + `backend.context` + "`" + ` + WITH ti, tir, spec, latestRevision, item + CALL apoc.do.when( + item.typeInstance.backend IS NOT NULL, + ' + CREATE (specBackend: TypeInstanceResourceVersionSpecBackend {context: apoc.convert.toJson(item.typeInstance.backend.context)}) + RETURN specBackend + ', + ' + CREATE (specBackend: TypeInstanceResourceVersionSpecBackend {context: apoc.convert.toJson(item.typeInstance.backend.context)}) + RETURN specBackend + ', + {spec:spec, latestRevision: latestRevision, item: item}) YIELD value as backendRef + WITH ti, tir, spec, latestRevision, item, backendRef.specBackend as specBackend + CREATE (spec)-[:WITH_BACKEND]->(specBackend) // Handle the ` + "`" + `metadata.attributes` + "`" + ` property CREATE (metadata: TypeInstanceResourceVersionMetadata) CREATE (tir)-[:DESCRIBED_BY]->(metadata) - // TODO: Temporary don't allow backend update, will be fixed in follow-up PR - WITH * - MATCH (latestRevision)-[:SPECIFIED_BY]->(latestSpec: TypeInstanceResourceVersionSpec)-[:WITH_BACKEND]->(specBackend: TypeInstanceResourceVersionSpecBackend) - CREATE (spec)-[:WITH_BACKEND]->(specBackend) - WITH ti, tir, latestRevision, metadata, item CALL apoc.do.when( item.typeInstance.attributes IS NOT NULL, @@ -1619,7 +1673,7 @@ func (ec *executionContext) _Mutation_updateTypeInstances(ctx context.Context, f return ec.resolvers.Mutation().UpdateTypeInstances(rctx, args["in"].([]*UpdateTypeInstancesInput)) } directive1 := func(ctx context.Context) (interface{}, error) { - statement, err := ec.unmarshalOString2ᚖstring(ctx, "CALL {\n UNWIND $in AS item\n RETURN collect(item.id) as allInputIDs\n}\n\n// Check if all TypeInstances were found\nWITH *\nCALL {\n WITH allInputIDs\n MATCH (ti:TypeInstance)\n WHERE ti.id IN allInputIDs\n WITH collect(ti.id) as foundIDs\n RETURN foundIDs\n}\nCALL apoc.util.validate(size(foundIDs) < size(allInputIDs), apoc.convert.toJson({code: 404, ids: foundIDs}), null)\n\n// Check if given TypeInstances are not already locked by others\nWITH *\nCALL {\n WITH *\n UNWIND $in AS item\n MATCH (tic:TypeInstance {id: item.id})\n WHERE tic.lockedBy IS NOT NULL AND (item.ownerID IS NULL OR tic.lockedBy <> item.ownerID)\n WITH collect(tic.id) as lockedIDs\n RETURN lockedIDs\n}\nCALL apoc.util.validate(size(lockedIDs) > 0, apoc.convert.toJson({code: 409, ids: lockedIDs}), null)\n\nUNWIND $in as item\nMATCH (ti: TypeInstance {id: item.id})\nCALL {\n WITH ti\n MATCH (ti)-[:CONTAINS]->(latestRevision:TypeInstanceResourceVersion)\n RETURN latestRevision\n ORDER BY latestRevision.resourceVersion DESC LIMIT 1\n}\n\nCREATE (tir: TypeInstanceResourceVersion {resourceVersion: latestRevision.resourceVersion + 1, createdBy: item.createdBy})\nCREATE (ti)-[:CONTAINS]->(tir)\n\n// Handle the `spec.value` property\nCREATE (spec: TypeInstanceResourceVersionSpec)\nCREATE (tir)-[:SPECIFIED_BY]->(spec)\n\nWITH ti, tir, spec, latestRevision, item\nCALL apoc.do.when(\n item.typeInstance.value IS NOT NULL,\n '\n SET spec.value = apoc.convert.toJson(item.typeInstance.value) RETURN spec\n ',\n '\n MATCH (latestRevision)-[:SPECIFIED_BY]->(latestSpec: TypeInstanceResourceVersionSpec)\n SET spec.value = latestSpec.value RETURN spec\n ',\n {spec:spec, latestRevision: latestRevision, item: item}) YIELD value\n\n// Handle the `metadata.attributes` property\nCREATE (metadata: TypeInstanceResourceVersionMetadata)\nCREATE (tir)-[:DESCRIBED_BY]->(metadata)\n\n// TODO: Temporary don't allow backend update, will be fixed in follow-up PR\nWITH *\nMATCH (latestRevision)-[:SPECIFIED_BY]->(latestSpec: TypeInstanceResourceVersionSpec)-[:WITH_BACKEND]->(specBackend: TypeInstanceResourceVersionSpecBackend)\nCREATE (spec)-[:WITH_BACKEND]->(specBackend)\n\nWITH ti, tir, latestRevision, metadata, item\nCALL apoc.do.when(\n item.typeInstance.attributes IS NOT NULL,\n '\n FOREACH (attr in item.typeInstance.attributes |\n MERGE (attrRef: AttributeReference {path: attr.path, revision: attr.revision})\n CREATE (metadata)-[:CHARACTERIZED_BY]->(attrRef)\n )\n\n RETURN metadata\n ',\n '\n OPTIONAL MATCH (latestRevision)-[:DESCRIBED_BY]->(TypeInstanceResourceVersionMetadata)-[:CHARACTERIZED_BY]->(latestAttrRef: AttributeReference)\n WHERE latestAttrRef IS NOT NULL\n WITH *, COLLECT(latestAttrRef) AS latestAttrRefs\n FOREACH (attr in latestAttrRefs |\n CREATE (metadata)-[:CHARACTERIZED_BY]->(attr)\n )\n\n RETURN metadata\n ',\n {metadata: metadata, latestRevision: latestRevision, item: item}\n) YIELD value\n\nRETURN ti") + statement, err := ec.unmarshalOString2ᚖstring(ctx, "CALL {\n UNWIND $in AS item\n RETURN collect(item.id) as allInputIDs\n}\n\n// Check if all TypeInstances were found\nWITH *\nCALL {\n WITH allInputIDs\n MATCH (ti:TypeInstance)\n WHERE ti.id IN allInputIDs\n WITH collect(ti.id) as foundIDs\n RETURN foundIDs\n}\nCALL apoc.util.validate(size(foundIDs) < size(allInputIDs), apoc.convert.toJson({code: 404, ids: foundIDs}), null)\n\n// Check if given TypeInstances are not already locked by others\nWITH *\nCALL {\n WITH *\n UNWIND $in AS item\n MATCH (tic:TypeInstance {id: item.id})\n WHERE tic.lockedBy IS NOT NULL AND (item.ownerID IS NULL OR tic.lockedBy <> item.ownerID)\n WITH collect(tic.id) as lockedIDs\n RETURN lockedIDs\n}\nCALL apoc.util.validate(size(lockedIDs) > 0, apoc.convert.toJson({code: 409, ids: lockedIDs}), null)\n\nUNWIND $in as item\nMATCH (ti: TypeInstance {id: item.id})\nCALL {\n WITH ti\n MATCH (ti)-[:CONTAINS]->(latestRevision:TypeInstanceResourceVersion)\n RETURN latestRevision\n ORDER BY latestRevision.resourceVersion DESC LIMIT 1\n}\n\nCREATE (tir: TypeInstanceResourceVersion {resourceVersion: latestRevision.resourceVersion + 1, createdBy: item.createdBy})\nCREATE (ti)-[:CONTAINS]->(tir)\n\n// Handle the `spec.value` property\nCREATE (spec: TypeInstanceResourceVersionSpec)\nCREATE (tir)-[:SPECIFIED_BY]->(spec)\n\nWITH ti, tir, spec, latestRevision, item\nMATCH (ti)-[:STORED_IN]->(storageRef:TypeInstanceBackendReference)\n\nWITH ti, tir, spec, latestRevision, item, storageRef\nCALL apoc.do.case([\n storageRef.abstract AND item.typeInstance.value IS NOT NULL, // built-in: store new value\n '\n SET spec.value = apoc.convert.toJson(item.typeInstance.value) RETURN spec\n ',\n storageRef.abstract AND item.typeInstance.value IS NULL, // built-in: no value, so use old one\n '\n MATCH (latestRevision)-[:SPECIFIED_BY]->(latestSpec: TypeInstanceResourceVersionSpec)\n SET spec.value = latestSpec.value RETURN spec\n '\n ],\n '\n RETURN spec // external storage, do nothing\n ',\n{spec:spec, latestRevision: latestRevision, item: item}) YIELD value\n\n// Handle the `backend.context`\nWITH ti, tir, spec, latestRevision, item\nCALL apoc.do.when(\n item.typeInstance.backend IS NOT NULL,\n '\n CREATE (specBackend: TypeInstanceResourceVersionSpecBackend {context: apoc.convert.toJson(item.typeInstance.backend.context)})\n RETURN specBackend\n ',\n '\n CREATE (specBackend: TypeInstanceResourceVersionSpecBackend {context: apoc.convert.toJson(item.typeInstance.backend.context)})\n RETURN specBackend\n ',\n{spec:spec, latestRevision: latestRevision, item: item}) YIELD value as backendRef\nWITH ti, tir, spec, latestRevision, item, backendRef.specBackend as specBackend\nCREATE (spec)-[:WITH_BACKEND]->(specBackend)\n\n// Handle the `metadata.attributes` property\nCREATE (metadata: TypeInstanceResourceVersionMetadata)\nCREATE (tir)-[:DESCRIBED_BY]->(metadata)\n\nWITH ti, tir, latestRevision, metadata, item\nCALL apoc.do.when(\n item.typeInstance.attributes IS NOT NULL,\n '\n FOREACH (attr in item.typeInstance.attributes |\n MERGE (attrRef: AttributeReference {path: attr.path, revision: attr.revision})\n CREATE (metadata)-[:CHARACTERIZED_BY]->(attrRef)\n )\n\n RETURN metadata\n ',\n '\n OPTIONAL MATCH (latestRevision)-[:DESCRIBED_BY]->(TypeInstanceResourceVersionMetadata)-[:CHARACTERIZED_BY]->(latestAttrRef: AttributeReference)\n WHERE latestAttrRef IS NOT NULL\n WITH *, COLLECT(latestAttrRef) AS latestAttrRefs\n FOREACH (attr in latestAttrRefs |\n CREATE (metadata)-[:CHARACTERIZED_BY]->(attr)\n )\n\n RETURN metadata\n ',\n {metadata: metadata, latestRevision: latestRevision, item: item}\n) YIELD value\n\nRETURN ti") if err != nil { return nil, err } @@ -3382,7 +3436,7 @@ func (ec *executionContext) _TypeInstanceResourceVersionSpec_value(ctx context.C return obj.Value, nil } directive1 := func(ctx context.Context) (interface{}, error) { - statement, err := ec.unmarshalOString2ᚖstring(ctx, "RETURN apoc.convert.fromJsonMap(this.value)") + statement, err := ec.unmarshalOString2ᚖstring(ctx, "MATCH (this)<-[:SPECIFIED_BY]-(rev:TypeInstanceResourceVersion)<-[:CONTAINS]-(ti:TypeInstance)\nMATCH (this)-[:WITH_BACKEND]->(backendCtx)\nMATCH (ti)-[:STORED_IN]->(backendRef)\nWITH *\nCALL apoc.when(\n backendRef.abstract,\n '\n WITH {\n abstract: backendRef.abstract,\n builtinValue: apoc.convert.fromJsonMap(spec.value)\n } AS value\n RETURN value\n ',\n '\n WITH {\n abstract: backendRef.abstract,\n fetchInput: {\n typeInstance: { resourceVersion: rev.resourceVersion, id: ti.id },\n backend: { context: backendCtx.context, id: backendRef.id}\n }\n } AS value\n RETURN value\n ',\n {spec: this, rev: rev, ti: ti, backendRef: backendRef, backendCtx: backendCtx}\n) YIELD value as out\n\nRETURN out.value") if err != nil { return nil, err } @@ -5115,6 +5169,26 @@ func (ec *executionContext) unmarshalInputUnlockTypeInstancesInput(ctx context.C return it, nil } +func (ec *executionContext) unmarshalInputUpdateTypeInstanceBackendInput(ctx context.Context, obj interface{}) (UpdateTypeInstanceBackendInput, error) { + var it UpdateTypeInstanceBackendInput + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "context": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("context")) + it.Context, err = ec.unmarshalOAny2interface(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputUpdateTypeInstanceInput(ctx context.Context, obj interface{}) (UpdateTypeInstanceInput, error) { var it UpdateTypeInstanceInput var asMap = obj.(map[string]interface{}) @@ -5137,6 +5211,14 @@ func (ec *executionContext) unmarshalInputUpdateTypeInstanceInput(ctx context.Co if err != nil { return it, err } + case "backend": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("backend")) + it.Backend, err = ec.unmarshalOUpdateTypeInstanceBackendInput2ᚖcapactᚗioᚋcapactᚋpkgᚋhubᚋapiᚋgraphqlᚋlocalᚐUpdateTypeInstanceBackendInput(ctx, v) + if err != nil { + return it, err + } } } @@ -7013,6 +7095,14 @@ func (ec *executionContext) unmarshalOTypeRefFilterInput2ᚖcapactᚗioᚋcapact return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOUpdateTypeInstanceBackendInput2ᚖcapactᚗioᚋcapactᚋpkgᚋhubᚋapiᚋgraphqlᚋlocalᚐUpdateTypeInstanceBackendInput(ctx context.Context, v interface{}) (*UpdateTypeInstanceBackendInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputUpdateTypeInstanceBackendInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOUpdateTypeInstancesInput2ᚖcapactᚗioᚋcapactᚋpkgᚋhubᚋapiᚋgraphqlᚋlocalᚐUpdateTypeInstancesInput(ctx context.Context, v interface{}) (*UpdateTypeInstancesInput, error) { if v == nil { return nil, nil diff --git a/pkg/hub/api/graphql/local/update_type_instances_input.go b/pkg/hub/api/graphql/local/update_type_instances_input.go index 44f0c31e4..d65c937e8 100644 --- a/pkg/hub/api/graphql/local/update_type_instances_input.go +++ b/pkg/hub/api/graphql/local/update_type_instances_input.go @@ -21,6 +21,8 @@ type UpdateTypeInstanceInput struct { Attributes []*AttributeReferenceInput `json:"attributes"` // The value property is optional. If not provided, previous value is used. Value interface{} `json:"value,omitempty"` + // The backend property is optional. If not provided, previous value is used. + Backend *UpdateTypeInstanceBackendInput `json:"backend,omitempty"` } // NativeUpdateTypeInstanceInput declared to shadow custom MarshalJSON declared on UpdateTypeInstanceInput. @@ -38,8 +40,11 @@ func (u *UpdateTypeInstanceInput) MarshalJSON() ([]byte, error) { a := struct { // The value property is optional. If not provided, previous value is used. Value interface{} `json:"value,omitempty"` + // The backend property is optional. If not provided, previous value is used. + Backend *UpdateTypeInstanceBackendInput `json:"backend,omitempty"` }{ - Value: u.Value, + Value: u.Value, + Backend: u.Backend, } return json.Marshal(a) } diff --git a/pkg/hub/api/grpc/storage_backend/storage_backend.pb.go b/pkg/hub/api/grpc/storage_backend/storage_backend.pb.go index fca5494a1..08680d296 100644 --- a/pkg/hub/api/grpc/storage_backend/storage_backend.pb.go +++ b/pkg/hub/api/grpc/storage_backend/storage_backend.pb.go @@ -190,10 +190,11 @@ type OnUpdateRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - TypeInstanceId string `protobuf:"bytes,1,opt,name=type_instance_id,json=typeInstanceId,proto3" json:"type_instance_id,omitempty"` - NewResourceVersion uint32 `protobuf:"varint,2,opt,name=new_resource_version,json=newResourceVersion,proto3" json:"new_resource_version,omitempty"` - NewValue []byte `protobuf:"bytes,3,opt,name=new_value,json=newValue,proto3" json:"new_value,omitempty"` - Context []byte `protobuf:"bytes,4,opt,name=context,proto3,oneof" json:"context,omitempty"` + TypeInstanceId string `protobuf:"bytes,1,opt,name=type_instance_id,json=typeInstanceId,proto3" json:"type_instance_id,omitempty"` + NewResourceVersion uint32 `protobuf:"varint,2,opt,name=new_resource_version,json=newResourceVersion,proto3" json:"new_resource_version,omitempty"` + NewValue []byte `protobuf:"bytes,3,opt,name=new_value,json=newValue,proto3" json:"new_value,omitempty"` + Context []byte `protobuf:"bytes,4,opt,name=context,proto3,oneof" json:"context,omitempty"` + OwnerId *string `protobuf:"bytes,5,opt,name=owner_id,json=ownerId,proto3,oneof" json:"owner_id,omitempty"` } func (x *OnUpdateRequest) Reset() { @@ -256,6 +257,13 @@ func (x *OnUpdateRequest) GetContext() []byte { return nil } +func (x *OnUpdateRequest) GetOwnerId() string { + if x != nil && x.OwnerId != nil { + return *x.OwnerId + } + return "" +} + type OnUpdateResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -308,8 +316,9 @@ type OnDeleteRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - TypeInstanceId string `protobuf:"bytes,1,opt,name=type_instance_id,json=typeInstanceId,proto3" json:"type_instance_id,omitempty"` - Context []byte `protobuf:"bytes,2,opt,name=context,proto3,oneof" json:"context,omitempty"` + TypeInstanceId string `protobuf:"bytes,1,opt,name=type_instance_id,json=typeInstanceId,proto3" json:"type_instance_id,omitempty"` + Context []byte `protobuf:"bytes,2,opt,name=context,proto3,oneof" json:"context,omitempty"` + OwnerId *string `protobuf:"bytes,3,opt,name=owner_id,json=ownerId,proto3,oneof" json:"owner_id,omitempty"` } func (x *OnDeleteRequest) Reset() { @@ -358,6 +367,13 @@ func (x *OnDeleteRequest) GetContext() []byte { return nil } +func (x *OnDeleteRequest) GetOwnerId() string { + if x != nil && x.OwnerId != nil { + return *x.OwnerId + } + return "" +} + type OnDeleteResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -825,7 +841,7 @@ var file_storage_backend_proto_rawDesc = []byte{ 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xb5, 0x01, 0x0a, 0x0f, 0x4f, 0x6e, 0x55, 0x70, 0x64, 0x61, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xe2, 0x01, 0x0a, 0x0f, 0x4f, 0x6e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, @@ -836,94 +852,100 @@ var file_storage_backend_proto_rawDesc = []byte{ 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x88, 0x01, - 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x3d, 0x0a, - 0x10, 0x4f, 0x6e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0c, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x88, 0x01, 0x01, - 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x66, 0x0a, 0x0f, - 0x4f, 0x6e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x28, 0x0a, 0x10, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, - 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x6e, - 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, - 0x6e, 0x74, 0x65, 0x78, 0x74, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x6e, - 0x74, 0x65, 0x78, 0x74, 0x22, 0x12, 0x0a, 0x10, 0x4f, 0x6e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x80, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, - 0x74, 0x79, 0x70, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, 0x74, - 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, - 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x37, 0x0a, 0x10, 0x47, - 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x19, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x22, 0x58, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, - 0x64, 0x42, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x79, - 0x70, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, - 0x63, 0x65, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x45, - 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x20, 0x0a, 0x09, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, - 0x62, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x6b, - 0x65, 0x64, 0x42, 0x79, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, - 0x65, 0x64, 0x5f, 0x62, 0x79, 0x22, 0x70, 0x0a, 0x0d, 0x4f, 0x6e, 0x4c, 0x6f, 0x63, 0x6b, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x69, - 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, - 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6c, 0x6f, - 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, - 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x22, 0x10, 0x0a, 0x0e, 0x4f, 0x6e, 0x4c, 0x6f, 0x63, - 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x55, 0x0a, 0x0f, 0x4f, 0x6e, 0x55, - 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, - 0x74, 0x79, 0x70, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, 0x74, - 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, - 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, - 0x22, 0x12, 0x0a, 0x10, 0x4f, 0x6e, 0x55, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xca, 0x04, 0x0a, 0x0e, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, - 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x12, 0x4f, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x12, 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, - 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, - 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x4f, 0x6e, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, - 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, - 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x4f, 0x6e, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, - 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, - 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x4f, 0x6e, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, - 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, - 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x47, - 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x12, 0x23, 0x2e, 0x73, 0x74, 0x6f, - 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x47, 0x65, 0x74, - 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x24, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, - 0x64, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x06, 0x4f, 0x6e, 0x4c, 0x6f, 0x63, 0x6b, 0x12, - 0x1e, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, - 0x64, 0x2e, 0x4f, 0x6e, 0x4c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1f, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, - 0x64, 0x2e, 0x4f, 0x6e, 0x4c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x4f, 0x0a, 0x08, 0x4f, 0x6e, 0x55, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x12, 0x20, 0x2e, 0x73, - 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, - 0x6e, 0x55, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x01, 0x12, 0x1e, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x88, 0x01, + 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x42, 0x0b, 0x0a, + 0x09, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x22, 0x3d, 0x0a, 0x10, 0x4f, 0x6e, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, + 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x48, + 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, + 0x08, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x93, 0x01, 0x0a, 0x0f, 0x4f, 0x6e, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, + 0x10, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, + 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, + 0x65, 0x78, 0x74, 0x88, 0x01, 0x01, 0x12, 0x1e, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, + 0x72, 0x49, 0x64, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x22, + 0x12, 0x0a, 0x10, 0x4f, 0x6e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x80, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x79, 0x70, 0x65, 0x5f, + 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, + 0x64, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0f, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x37, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, + 0x58, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x69, 0x6e, + 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, + 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x45, 0x0a, 0x13, 0x47, 0x65, 0x74, + 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x20, 0x0a, 0x09, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x88, + 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x62, 0x79, + 0x22, 0x70, 0x0a, 0x0d, 0x4f, 0x6e, 0x4c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, + 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, + 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, + 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, + 0x42, 0x79, 0x22, 0x10, 0x0a, 0x0e, 0x4f, 0x6e, 0x4c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x55, 0x0a, 0x0f, 0x4f, 0x6e, 0x55, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x79, 0x70, 0x65, 0x5f, + 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x74, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, + 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x12, 0x0a, 0x10, 0x4f, + 0x6e, 0x55, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0xca, 0x04, 0x0a, 0x0e, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x42, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x12, 0x4f, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, - 0x2e, 0x4f, 0x6e, 0x55, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x42, 0x13, 0x5a, 0x11, 0x2e, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, - 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x2e, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x4f, 0x6e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, + 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, + 0x64, 0x2e, 0x4f, 0x6e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, + 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x4f, 0x6e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x12, 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x4f, 0x6e, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x12, 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, + 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, + 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, + 0x6b, 0x65, 0x64, 0x42, 0x79, 0x12, 0x23, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, + 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, + 0x64, 0x42, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x73, 0x74, 0x6f, + 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x47, 0x65, 0x74, + 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x49, 0x0a, 0x06, 0x4f, 0x6e, 0x4c, 0x6f, 0x63, 0x6b, 0x12, 0x1e, 0x2e, 0x73, 0x74, 0x6f, + 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x4c, + 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x73, 0x74, 0x6f, + 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x4c, + 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x4f, + 0x6e, 0x55, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x12, 0x20, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, + 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x55, 0x6e, 0x6c, 0x6f, + 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x73, 0x74, 0x6f, 0x72, + 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x4f, 0x6e, 0x55, 0x6e, + 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x13, 0x5a, 0x11, + 0x2e, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, + 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/test/e2e/action_test.go b/test/e2e/action_test.go index ad8ebaf66..1ca81eb03 100644 --- a/test/e2e/action_test.go +++ b/test/e2e/action_test.go @@ -132,6 +132,11 @@ var _ = Describe("Action", func() { By("8.1 Check uploaded TypeInstances") expUploadTIBackend = &hublocalgraphql.TypeInstanceBackendReference{ID: helmStorageTI.ID, Abstract: false} + + // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend. + // for now, the Local Hub returns backend id under `key` property. + implIndicatorValue = expUploadTIBackend.ID + uploadedTI, cleanupUploaded = getUploadedTypeInstanceByValue(ctx, hubClient, implIndicatorValue) defer cleanupUploaded() // We need to clean it up as it's not deleted when Action is deleted. Expect(uploadedTI.Backend).Should(Equal(expUploadTIBackend)) @@ -194,6 +199,11 @@ var _ = Describe("Action", func() { By("4.1 Check uploaded TypeInstances") expUploadTIBackend := &hublocalgraphql.TypeInstanceBackendReference{ID: helmStorageTI.ID, Abstract: false} + + // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend. + // for now, the Local Hub returns backend id under `key` property. + implIndicatorValue = expUploadTIBackend.ID + uploadedTI, cleanupUploaded := getUploadedTypeInstanceByValue(ctx, hubClient, implIndicatorValue) defer cleanupUploaded() // We need to clean it up as it's not deleted when Action is deleted. Expect(uploadedTI.Backend).Should(Equal(expUploadTIBackend)) diff --git a/test/e2e/hub_test.go b/test/e2e/hub_test.go index 889ff4264..7c011dd61 100644 --- a/test/e2e/hub_test.go +++ b/test/e2e/hub_test.go @@ -322,9 +322,20 @@ var _ = Describe("GraphQL API", func() { createdTypeInstanceIDs, err := cli.CreateTypeInstances(ctx, createTypeInstancesInput()) Expect(err).NotTo(HaveOccurred()) - for _, ti := range createdTypeInstanceIDs { - defer deleteTypeInstance(ctx, cli, ti.ID) - } + defer func() { + var child string + for _, ti := range createdTypeInstanceIDs { + if ti.Alias == "child" { + child = ti.ID + continue + } + // Delete parent first, child TypeInstance is protected as it is used by parent. + deleteTypeInstance(ctx, cli, ti.ID) + } + + // Delete child TypeInstance as there are no "users". + deleteTypeInstance(ctx, cli, child) + }() parentTiID := findCreatedTypeInstanceID("parent", createdTypeInstanceIDs) Expect(parentTiID).ToNot(BeNil())