key-value storage for applications (#23089)
## What
Key-value storage for applications, as proposed in
twentyhq/core-team-issues#2391 — built on the existing `keyValuePair`
entity:
- Nullable `applicationId` relation on `keyValuePair` + a new
`APPLICATION_VARIABLE` type (fast instance command included)
- GraphQL CRUD on the metadata schema (`appKeyValue`, `setAppKeyValue`,
`deleteAppKeyValue`), requiring an `APPLICATION_ACCESS` token —
`applicationId` always comes from the token, never from arguments, so
apps can't touch each other's entries
- `kv.get` / `kv.set` / `kv.delete` helpers in
`twenty-sdk/logic-function`
## Scopes
- **`INSTALL`** (default): entries are private to one workspace install;
arbitrary JSON values
- **`GLOBAL`**: entries are shared across every install of the app, with
claim semantics — the value is always the claiming `workspaceId` and
only that workspace can overwrite or delete the key (guarded writes,
race-safe via insert-if-absent)
Since `applicationId` identifies an install (one row per workspace),
GLOBAL entries are stored under the registration owner workspace's
install so all installs of the same app share one namespace.
The GLOBAL scope is what enables cross-workspace webhook routing: e.g.
the Slack app's `serverRoute` resolver (running in the owner workspace)
can resolve `kv.get('slack:team:' + team_id, { scope: 'GLOBAL' })` to
find the workspace that connected that Slack team — without a workspace
being able to hijack another's mapping.
## Follow-ups
- Wire the Slack assistant PR (#22984) to write the claim at connect
time and read it in the events resolver
- `kv.*` access from front components
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23089?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
+1
@@ -44,6 +44,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
|
||||
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
|
||||
case ApplicationExceptionCode.UPGRADE_FAILED:
|
||||
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
|
||||
case ApplicationExceptionCode.KEY_VALUE_PERSISTENCE_FAILED:
|
||||
throw new InternalServerError(exception);
|
||||
case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED: {
|
||||
const installationError = new BaseGraphQLError(
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationKeyValueResolver } from 'src/engine/core-modules/application/application-key-value/application-key-value.resolver';
|
||||
import { ApplicationKeyValueService } from 'src/engine/core-modules/application/application-key-value/services/application-key-value.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
KeyValuePairEntity,
|
||||
ApplicationEntity,
|
||||
ApplicationRegistrationEntity,
|
||||
]),
|
||||
],
|
||||
providers: [ApplicationKeyValueService, ApplicationKeyValueResolver],
|
||||
exports: [ApplicationKeyValueService],
|
||||
})
|
||||
export class ApplicationKeyValueModule {}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { type AppKeyValue } from 'twenty-shared/application';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import { AppKeyValueDto } from 'src/engine/core-modules/application/application-key-value/dtos/app-key-value.dto';
|
||||
import { SetAppKeyValueInput } from 'src/engine/core-modules/application/application-key-value/dtos/set-app-key-value.input';
|
||||
import { AppKeyValueScope } from 'src/engine/core-modules/application/application-key-value/enums/app-key-value-scope.enum';
|
||||
import { ApplicationKeyValueService } from 'src/engine/core-modules/application/application-key-value/services/application-key-value.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { AuthApplication } from 'src/engine/decorators/auth/auth-application.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@UseFilters(ApplicationExceptionFilter)
|
||||
@MetadataResolver()
|
||||
export class ApplicationKeyValueResolver {
|
||||
constructor(
|
||||
private readonly applicationKeyValueService: ApplicationKeyValueService,
|
||||
) {}
|
||||
|
||||
@Query(() => AppKeyValueDto, { nullable: true })
|
||||
async appKeyValue(
|
||||
@AuthApplication() application: FlatApplication,
|
||||
@AuthWorkspace() workspace: FlatWorkspace,
|
||||
@Args('key') key: string,
|
||||
@Args('scope', {
|
||||
type: () => AppKeyValueScope,
|
||||
nullable: true,
|
||||
defaultValue: AppKeyValueScope.WORKSPACE,
|
||||
})
|
||||
scope: AppKeyValueScope,
|
||||
): Promise<AppKeyValue | null> {
|
||||
return this.applicationKeyValueService.get({
|
||||
application,
|
||||
workspaceId: workspace.id,
|
||||
key,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => AppKeyValueDto)
|
||||
async setAppKeyValue(
|
||||
@AuthApplication() application: FlatApplication,
|
||||
@AuthWorkspace() workspace: FlatWorkspace,
|
||||
@Args('input') input: SetAppKeyValueInput,
|
||||
): Promise<AppKeyValue> {
|
||||
return this.applicationKeyValueService.set({
|
||||
application,
|
||||
workspaceId: workspace.id,
|
||||
key: input.key,
|
||||
value: input.value,
|
||||
scope: input.scope ?? AppKeyValueScope.WORKSPACE,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteAppKeyValue(
|
||||
@AuthApplication() application: FlatApplication,
|
||||
@AuthWorkspace() workspace: FlatWorkspace,
|
||||
@Args('key') key: string,
|
||||
@Args('scope', {
|
||||
type: () => AppKeyValueScope,
|
||||
nullable: true,
|
||||
defaultValue: AppKeyValueScope.WORKSPACE,
|
||||
})
|
||||
scope: AppKeyValueScope,
|
||||
): Promise<boolean> {
|
||||
return this.applicationKeyValueService.delete({
|
||||
application,
|
||||
workspaceId: workspace.id,
|
||||
key,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { type AppKeyValue } from 'twenty-shared/application';
|
||||
|
||||
import { AppKeyValueScope } from 'src/engine/core-modules/application/application-key-value/enums/app-key-value-scope.enum';
|
||||
|
||||
@ObjectType('AppKeyValue')
|
||||
export class AppKeyValueDto implements AppKeyValue {
|
||||
@Field()
|
||||
key: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
value: unknown;
|
||||
|
||||
@Field(() => AppKeyValueScope)
|
||||
scope: AppKeyValueScope;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { AppKeyValueScope } from 'src/engine/core-modules/application/application-key-value/enums/app-key-value-scope.enum';
|
||||
|
||||
@InputType('SetAppKeyValueInput')
|
||||
export class SetAppKeyValueInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
key: string;
|
||||
|
||||
@IsOptional()
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
value?: unknown;
|
||||
|
||||
@IsEnum(AppKeyValueScope)
|
||||
@IsOptional()
|
||||
@Field(() => AppKeyValueScope, {
|
||||
nullable: true,
|
||||
defaultValue: AppKeyValueScope.WORKSPACE,
|
||||
})
|
||||
scope?: AppKeyValueScope;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum AppKeyValueScope {
|
||||
WORKSPACE = 'WORKSPACE',
|
||||
SERVER = 'SERVER',
|
||||
}
|
||||
|
||||
registerEnumType(AppKeyValueScope, {
|
||||
name: 'AppKeyValueScope',
|
||||
description:
|
||||
'WORKSPACE entries are private to one workspace install of the application. SERVER entries are shared across every install: the value is always the claiming workspaceId and only that workspace can overwrite or delete the key.',
|
||||
});
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { type AppKeyValue } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AppKeyValueScope } from 'src/engine/core-modules/application/application-key-value/enums/app-key-value-scope.enum';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import {
|
||||
KeyValuePairEntity,
|
||||
KeyValuePairType,
|
||||
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationKeyValueService {
|
||||
constructor(
|
||||
@InjectRepository(KeyValuePairEntity)
|
||||
private readonly keyValuePairRepository: Repository<KeyValuePairEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
) {}
|
||||
|
||||
async get({
|
||||
application,
|
||||
workspaceId,
|
||||
key,
|
||||
scope,
|
||||
}: {
|
||||
application: FlatApplication;
|
||||
workspaceId: string;
|
||||
key: string;
|
||||
scope: AppKeyValueScope;
|
||||
}): Promise<AppKeyValue | null> {
|
||||
const scopedWhere =
|
||||
scope === AppKeyValueScope.SERVER
|
||||
? {
|
||||
applicationId: await this.resolveServerApplicationId(application),
|
||||
workspaceId: IsNull(),
|
||||
}
|
||||
: {
|
||||
applicationId: application.id,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
const entry = await this.keyValuePairRepository.findOne({
|
||||
where: {
|
||||
key,
|
||||
type: KeyValuePairType.APPLICATION_VARIABLE,
|
||||
...scopedWhere,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { key, value: entry.value, scope };
|
||||
}
|
||||
|
||||
async set({
|
||||
application,
|
||||
workspaceId,
|
||||
key,
|
||||
value,
|
||||
scope,
|
||||
}: {
|
||||
application: FlatApplication;
|
||||
workspaceId: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
scope: AppKeyValueScope;
|
||||
}): Promise<AppKeyValue> {
|
||||
if (scope === AppKeyValueScope.SERVER) {
|
||||
return this.claimServerKey({ application, workspaceId, key });
|
||||
}
|
||||
|
||||
await this.keyValuePairRepository.upsert(
|
||||
{
|
||||
key,
|
||||
value: value as KeyValuePairEntity['value'],
|
||||
applicationId: application.id,
|
||||
workspaceId,
|
||||
userId: null,
|
||||
type: KeyValuePairType.APPLICATION_VARIABLE,
|
||||
},
|
||||
{
|
||||
conflictPaths: ['key', 'applicationId'],
|
||||
indexPredicate:
|
||||
'"applicationId" IS NOT NULL AND "workspaceId" IS NOT NULL',
|
||||
},
|
||||
);
|
||||
|
||||
return { key, value, scope };
|
||||
}
|
||||
|
||||
async delete({
|
||||
application,
|
||||
workspaceId,
|
||||
key,
|
||||
scope,
|
||||
}: {
|
||||
application: FlatApplication;
|
||||
workspaceId: string;
|
||||
key: string;
|
||||
scope: AppKeyValueScope;
|
||||
}): Promise<boolean> {
|
||||
if (scope === AppKeyValueScope.SERVER) {
|
||||
const result = await this.keyValuePairRepository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('"key" = :key', { key })
|
||||
.andWhere('"applicationId" = :applicationId', {
|
||||
applicationId: await this.resolveServerApplicationId(application),
|
||||
})
|
||||
.andWhere('"workspaceId" IS NULL')
|
||||
.andWhere('"type" = :type', {
|
||||
type: KeyValuePairType.APPLICATION_VARIABLE,
|
||||
})
|
||||
.andWhere('"value" = :value::jsonb', {
|
||||
value: JSON.stringify(workspaceId),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
const result = await this.keyValuePairRepository.delete({
|
||||
key,
|
||||
applicationId: application.id,
|
||||
workspaceId,
|
||||
type: KeyValuePairType.APPLICATION_VARIABLE,
|
||||
});
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
// SERVER keys are a claim registry: the stored value is always the caller's
|
||||
// token-derived workspaceId, so any value passed by the caller is ignored.
|
||||
private async claimServerKey({
|
||||
application,
|
||||
workspaceId,
|
||||
key,
|
||||
}: {
|
||||
application: FlatApplication;
|
||||
workspaceId: string;
|
||||
key: string;
|
||||
}): Promise<AppKeyValue> {
|
||||
const serverApplicationId =
|
||||
await this.resolveServerApplicationId(application);
|
||||
const claimValue: unknown = workspaceId;
|
||||
|
||||
await this.keyValuePairRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(KeyValuePairEntity)
|
||||
.values({
|
||||
key,
|
||||
value: claimValue as KeyValuePairEntity['value'],
|
||||
applicationId: serverApplicationId,
|
||||
workspaceId: null,
|
||||
userId: null,
|
||||
type: KeyValuePairType.APPLICATION_VARIABLE,
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
const entry = await this.keyValuePairRepository.findOne({
|
||||
where: {
|
||||
key,
|
||||
applicationId: serverApplicationId,
|
||||
workspaceId: IsNull(),
|
||||
type: KeyValuePairType.APPLICATION_VARIABLE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(entry)) {
|
||||
throw new ApplicationException(
|
||||
`Could not persist server key "${key}"`,
|
||||
ApplicationExceptionCode.KEY_VALUE_PERSISTENCE_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const claimedWorkspaceId: unknown = entry.value;
|
||||
|
||||
if (claimedWorkspaceId !== workspaceId) {
|
||||
throw new ApplicationException(
|
||||
`Server key "${key}" is already claimed by another workspace`,
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
{
|
||||
userFriendlyMessage: msg`This server key is already claimed by another workspace.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { key, value: entry.value, scope: AppKeyValueScope.SERVER };
|
||||
}
|
||||
|
||||
// SERVER entries of a registered application all live under the registration owner workspace's install
|
||||
private async resolveServerApplicationId(
|
||||
application: FlatApplication,
|
||||
): Promise<string> {
|
||||
if (!isDefined(application.applicationRegistrationId)) {
|
||||
return application.id;
|
||||
}
|
||||
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id: application.applicationRegistrationId },
|
||||
});
|
||||
|
||||
if (!isDefined(registration) || !isDefined(registration.ownerWorkspaceId)) {
|
||||
return application.id;
|
||||
}
|
||||
|
||||
if (registration.ownerWorkspaceId === application.workspaceId) {
|
||||
return application.id;
|
||||
}
|
||||
|
||||
const ownerInstall = await this.applicationRepository.findOne({
|
||||
where: {
|
||||
applicationRegistrationId: registration.id,
|
||||
workspaceId: registration.ownerWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(ownerInstall)) {
|
||||
throw new ApplicationException(
|
||||
`Server keys are unavailable for application ${application.id}: the registration owner workspace has no install`,
|
||||
ApplicationExceptionCode.APP_NOT_INSTALLED,
|
||||
{
|
||||
userFriendlyMessage: msg`Server keys require the application publisher workspace to have the application installed.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ownerInstall.id;
|
||||
}
|
||||
}
|
||||
+1
@@ -45,6 +45,7 @@ const applicationExceptionCodeToHttpStatus = (
|
||||
case ApplicationExceptionCode.UPGRADE_FAILED:
|
||||
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
|
||||
case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED:
|
||||
case ApplicationExceptionCode.KEY_VALUE_PERSISTENCE_FAILED:
|
||||
return 500;
|
||||
default:
|
||||
return assertUnreachable(code);
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum ApplicationExceptionCode {
|
||||
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
|
||||
INVALID_WORKSPACE_VERSION = 'INVALID_WORKSPACE_VERSION',
|
||||
APPLICATION_INSTALLATION_FAILED = 'APPLICATION_INSTALLATION_FAILED',
|
||||
KEY_VALUE_PERSISTENCE_FAILED = 'KEY_VALUE_PERSISTENCE_FAILED',
|
||||
}
|
||||
|
||||
const getApplicationExceptionUserFriendlyMessage = (
|
||||
@@ -81,6 +82,8 @@ const getApplicationExceptionUserFriendlyMessage = (
|
||||
return msg`This workspace's upgrade state could not be determined. Please try again once the workspace has finished upgrading.`;
|
||||
case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED:
|
||||
return msg`We couldn't install this application because some of its metadata could not be applied to your workspace.`;
|
||||
case ApplicationExceptionCode.KEY_VALUE_PERSISTENCE_FAILED:
|
||||
return msg`The application key-value entry could not be saved. Please try again.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+37
-2
@@ -14,6 +14,8 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@@ -21,6 +23,7 @@ export enum KeyValuePairType {
|
||||
USER_VARIABLE = 'USER_VARIABLE',
|
||||
FEATURE_FLAG = 'FEATURE_FLAG',
|
||||
CONFIG_VARIABLE = 'CONFIG_VARIABLE',
|
||||
APPLICATION_VARIABLE = 'APPLICATION_VARIABLE',
|
||||
}
|
||||
|
||||
@Entity({ name: 'keyValuePair', schema: 'core' })
|
||||
@@ -35,7 +38,7 @@ export enum KeyValuePairType {
|
||||
['key', 'workspaceId'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"userId" is NULL',
|
||||
where: '"userId" is NULL AND "applicationId" is NULL',
|
||||
},
|
||||
)
|
||||
@Index(
|
||||
@@ -51,9 +54,27 @@ export enum KeyValuePairType {
|
||||
['key'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"userId" is NULL AND "workspaceId" is NULL',
|
||||
where:
|
||||
'"userId" is NULL AND "workspaceId" is NULL AND "applicationId" is NULL',
|
||||
},
|
||||
)
|
||||
@Index(
|
||||
'IDX_KEY_VALUE_PAIR_KEY_APPLICATION_ID_WORKSPACE_UNIQUE',
|
||||
['key', 'applicationId'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"applicationId" is NOT NULL AND "workspaceId" is NOT NULL',
|
||||
},
|
||||
)
|
||||
@Index(
|
||||
'IDX_KEY_VALUE_PAIR_KEY_APPLICATION_ID_GLOBAL_UNIQUE',
|
||||
['key', 'applicationId'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"applicationId" is NOT NULL AND "workspaceId" is NULL',
|
||||
},
|
||||
)
|
||||
@Index('IDX_KEY_VALUE_PAIR_APPLICATION_ID', ['applicationId'])
|
||||
export class KeyValuePairEntity {
|
||||
@Field(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -77,6 +98,20 @@ export class KeyValuePairEntity {
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
workspaceId: string | null;
|
||||
|
||||
@ManyToOne(() => ApplicationEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: Relation<ApplicationEntity> | null;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
'2.23.0_AddApplicationIdToKeyValuePairFastInstanceCommand_1784659343818',
|
||||
})
|
||||
applicationId: string | null;
|
||||
|
||||
@Field(() => String)
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
key: string;
|
||||
|
||||
+55
-2
@@ -35,13 +35,15 @@ describe('KeyValuePairService', () => {
|
||||
{
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
applicationId: null,
|
||||
key: 'MAINTENANCE_MODE',
|
||||
value: { startAt: '2026-04-02T10:00:00.000Z' },
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
},
|
||||
{
|
||||
conflictPaths: ['key'],
|
||||
indexPredicate: '"userId" IS NULL AND "workspaceId" IS NULL',
|
||||
indexPredicate:
|
||||
'"userId" IS NULL AND "workspaceId" IS NULL AND "applicationId" IS NULL',
|
||||
},
|
||||
);
|
||||
expect(keyValuePairRepository.findOne).not.toHaveBeenCalled();
|
||||
@@ -61,6 +63,7 @@ describe('KeyValuePairService', () => {
|
||||
{
|
||||
userId: 'user-id',
|
||||
workspaceId: null,
|
||||
applicationId: null,
|
||||
key: 'USER_SETTING',
|
||||
value: true,
|
||||
type: KeyValuePairType.USER_VARIABLE,
|
||||
@@ -85,13 +88,62 @@ describe('KeyValuePairService', () => {
|
||||
{
|
||||
userId: null,
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: null,
|
||||
key: 'WORKSPACE_SETTING',
|
||||
value: 'test',
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
},
|
||||
{
|
||||
conflictPaths: ['key', 'workspaceId'],
|
||||
indexPredicate: '"userId" IS NULL',
|
||||
indexPredicate: '"userId" IS NULL AND "applicationId" IS NULL',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should upsert a workspace-scoped application key on the (key, applicationId) index', async () => {
|
||||
await service.set({
|
||||
userId: null,
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: 'application-id',
|
||||
key: 'APP_SETTING',
|
||||
value: 'test',
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
});
|
||||
|
||||
expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: null,
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: 'application-id',
|
||||
key: 'APP_SETTING',
|
||||
}),
|
||||
{
|
||||
conflictPaths: ['key', 'applicationId'],
|
||||
indexPredicate:
|
||||
'"applicationId" IS NOT NULL AND "workspaceId" IS NOT NULL',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should upsert a server-scoped application key on the global (key, applicationId) index', async () => {
|
||||
await service.set({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
applicationId: 'application-id',
|
||||
key: 'APP_CLAIM',
|
||||
value: 'workspace-id',
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
});
|
||||
|
||||
expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: null,
|
||||
applicationId: 'application-id',
|
||||
key: 'APP_CLAIM',
|
||||
}),
|
||||
{
|
||||
conflictPaths: ['key', 'applicationId'],
|
||||
indexPredicate: '"applicationId" IS NOT NULL AND "workspaceId" IS NULL',
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -109,6 +161,7 @@ describe('KeyValuePairService', () => {
|
||||
{
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: null,
|
||||
key: 'USER_WORKSPACE_SETTING',
|
||||
value: 42,
|
||||
type: KeyValuePairType.USER_VARIABLE,
|
||||
|
||||
+30
-5
@@ -19,11 +19,13 @@ export class KeyValuePairService<
|
||||
async get<K extends keyof KeyValueTypesMap>({
|
||||
userId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
type,
|
||||
key,
|
||||
}: {
|
||||
userId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
applicationId?: string | null;
|
||||
type: KeyValuePairType;
|
||||
key?: Extract<K, string>;
|
||||
}): Promise<Array<KeyValueTypesMap[K]>> {
|
||||
@@ -40,6 +42,9 @@ export class KeyValuePairService<
|
||||
? { workspaceId: IsNull() }
|
||||
: { workspaceId }),
|
||||
...(key === undefined ? {} : { key }),
|
||||
...(applicationId == null
|
||||
? { applicationId: IsNull() }
|
||||
: { applicationId }),
|
||||
type,
|
||||
},
|
||||
})) as Array<KeyValueTypesMap[K]>;
|
||||
@@ -54,12 +59,14 @@ export class KeyValuePairService<
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
}: {
|
||||
userId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
applicationId?: string | null;
|
||||
key: Extract<K, string>;
|
||||
value: KeyValueTypesMap[K];
|
||||
type: KeyValuePairType;
|
||||
@@ -68,8 +75,11 @@ export class KeyValuePairService<
|
||||
) {
|
||||
const normalizedUserId = userId ?? null;
|
||||
const normalizedWorkspaceId = workspaceId ?? null;
|
||||
const hasNullUserAndWorkspace =
|
||||
normalizedUserId === null && normalizedWorkspaceId === null;
|
||||
const normalizedApplicationId = applicationId ?? null;
|
||||
const hasNullUserAndWorkspaceAndApplication =
|
||||
normalizedUserId === null &&
|
||||
normalizedWorkspaceId === null &&
|
||||
normalizedApplicationId === null;
|
||||
const keyValuePairRepository = queryRunner
|
||||
? queryRunner.manager.getRepository(KeyValuePairEntity)
|
||||
: this.keyValuePairRepository;
|
||||
@@ -77,6 +87,7 @@ export class KeyValuePairService<
|
||||
const upsertData = {
|
||||
userId: normalizedUserId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
applicationId: normalizedApplicationId,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
@@ -85,11 +96,18 @@ export class KeyValuePairService<
|
||||
const conflictPaths: string[] = ['key'];
|
||||
let indexPredicate: string | undefined;
|
||||
|
||||
if (hasNullUserAndWorkspace) {
|
||||
indexPredicate = '"userId" IS NULL AND "workspaceId" IS NULL';
|
||||
if (normalizedApplicationId !== null) {
|
||||
conflictPaths.push('applicationId');
|
||||
indexPredicate =
|
||||
normalizedWorkspaceId === null
|
||||
? '"applicationId" IS NOT NULL AND "workspaceId" IS NULL'
|
||||
: '"applicationId" IS NOT NULL AND "workspaceId" IS NOT NULL';
|
||||
} else if (hasNullUserAndWorkspaceAndApplication) {
|
||||
indexPredicate =
|
||||
'"userId" IS NULL AND "workspaceId" IS NULL AND "applicationId" IS NULL';
|
||||
} else if (normalizedUserId === null) {
|
||||
conflictPaths.push('workspaceId');
|
||||
indexPredicate = '"userId" IS NULL';
|
||||
indexPredicate = '"userId" IS NULL AND "applicationId" IS NULL';
|
||||
} else if (normalizedWorkspaceId === null) {
|
||||
conflictPaths.push('userId');
|
||||
indexPredicate = '"workspaceId" IS NULL';
|
||||
@@ -107,11 +125,13 @@ export class KeyValuePairService<
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
type,
|
||||
key,
|
||||
}: {
|
||||
userId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
applicationId?: string | null;
|
||||
type: KeyValuePairType;
|
||||
key: Extract<keyof KeyValueTypesMap, string>;
|
||||
},
|
||||
@@ -128,6 +148,11 @@ export class KeyValuePairService<
|
||||
: workspaceId === null
|
||||
? { workspaceId: IsNull() }
|
||||
: { workspaceId }),
|
||||
// Application rows are isolated from core key-value pairs: without an
|
||||
// explicit applicationId we only match rows where it is NULL.
|
||||
...(applicationId == null
|
||||
? { applicationId: IsNull() }
|
||||
: { applicationId }),
|
||||
type,
|
||||
key,
|
||||
};
|
||||
|
||||
+2
@@ -43,8 +43,10 @@ describe('ConfigStorageService', () => {
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
applicationId: null,
|
||||
user: null as unknown as UserEntity,
|
||||
workspace: null as unknown as WorkspaceEntity,
|
||||
application: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
textValueDeprecated: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.mod
|
||||
import { AiGenerateTextModule } from 'src/engine/metadata-modules/ai/ai-generate-text/ai-generate-text.module';
|
||||
import { AiWorkspaceStatsModule } from 'src/engine/metadata-modules/ai/ai-workspace-stats/ai-workspace-stats.module';
|
||||
import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.module';
|
||||
import { ApplicationKeyValueModule } from 'src/engine/core-modules/application/application-key-value/application-key-value.module';
|
||||
import { CalendarChannelMetadataModule } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.module';
|
||||
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module';
|
||||
@@ -48,6 +49,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
|
||||
AiGenerateTextModule,
|
||||
AiWorkspaceStatsModule,
|
||||
ApplicationConnectionsModule,
|
||||
ApplicationKeyValueModule,
|
||||
MinimalMetadataModule,
|
||||
ViewModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
|
||||
Reference in New Issue
Block a user