From cc1e2363651eaff63848baad2c7932923ca66554 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 14 Oct 2026 09:56:49 -0400 Subject: [PATCH] feat: server feature level (#32178) * feat: server feature level * fix: correctly compute feature level --- mobile/lib/utils/openapi_patching.dart | 1 + .../services/sync_stream_service_test.dart | 8 +++---- open-api/immich-openapi-specs.json | 23 +++++++++++++++++++ packages/sdk/src/fetch-client.ts | 5 ++++ server/src/decorators.ts | 4 ++++ server/src/dtos/server.dto.ts | 12 ++++++++++ server/src/enum.ts | 13 +++++++++++ server/src/services/version.service.spec.ts | 8 ++++++- server/src/utils/helpers.ts | 10 ++++++++ web/src/lib/utils.spec.ts | 4 ++-- 10 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 server/src/utils/helpers.ts diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index cca48c8b2d..410f3f8d5f 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -44,6 +44,7 @@ final Map> openApiPatches = { 'MemoriesResponse': {'duration': 5, 'sidebarWeb': false}, 'PeopleResponse': {'updateStrategy': 'everyone'}, 'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []}, + 'ServerVersionResponseDto': {'featureLevel': 0}, 'WorkflowResponseDto': {'logging': false}, }; diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index aa10e73598..891b802fe2 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -113,7 +113,7 @@ void main() { when(() => mockApi.serverInfoApi).thenReturn(mockServerApi); when( () => mockServerApi.getServerVersion(), - ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0, prerelease: null)); + ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0, prerelease: null, featureLevel: 1)); when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler); @@ -567,7 +567,7 @@ void main() { await Store.put(StoreKey.syncMigrationStatus, "[]"); when( () => mockServerApi.getServerVersion(), - ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null)); + ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null, featureLevel: 1)); await sut.sync(); @@ -595,7 +595,7 @@ void main() { await Store.put(StoreKey.syncMigrationStatus, "[]"); when( () => mockServerApi.getServerVersion(), - ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null)); + ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null, featureLevel: 1)); await sut.sync(); verifyInOrder([ @@ -625,7 +625,7 @@ void main() { when( () => mockServerApi.getServerVersion(), - ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null)); + ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null, featureLevel: 1)); await sut.sync(); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c6ecca7c0d..ff7afe6e32 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -27790,6 +27790,16 @@ ], "type": "object" }, + "ServerFeatureLevel": { + "description": "Server feature level", + "enum": [ + 1 + ], + "type": "integer", + "x-enum-varnames": [ + "FeatureLevel" + ] + }, "ServerFeaturesDto": { "properties": { "configFile": { @@ -28048,6 +28058,18 @@ }, "ServerVersionResponseDto": { "properties": { + "featureLevel": { + "description": "Highest feature level supported by the server, which increments independently of the release version", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + "x-immich-history": [ + { + "version": "v4.0.0", + "state": "Added" + } + ] + }, "major": { "description": "Major version number", "maximum": 9007199254740991, @@ -28081,6 +28103,7 @@ } }, "required": [ + "featureLevel", "major", "minor", "patch", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 6500628d94..b44df9ba51 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2907,6 +2907,8 @@ export type ServerStorageResponseDto = { diskUseRaw: number; }; export type ServerVersionResponseDto = { + /** Highest feature level supported by the server, which increments independently of the release version */ + featureLevel: number; /** Major version number */ major: number; /** Minor version number */ @@ -8632,6 +8634,9 @@ export enum ReleaseType { Prepatch = "prepatch", Prerelease = "prerelease" } +export enum ServerFeatureLevel { + FeatureLevel = 1 +} export enum UserMetadataKey { Preferences = "preferences", License = "license", diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 3361575654..4a44ad91f1 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -208,6 +208,10 @@ export class HistoryBuilder { return new HistoryBuilder().added('v3.0.0'); } + static v4() { + return new HistoryBuilder().added('v4.0.0'); + } + added(version: string, description?: string) { return this.push({ version, state: 'Added', description }); } diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 286d096c23..2e4e9f99ee 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -2,6 +2,7 @@ import { createZodDto } from 'nestjs-zod'; import z from 'zod'; import type { SemVer } from 'semver'; import { ExtraModel, HistoryBuilder } from 'src/decorators.js'; +import { ServerFeatureLevel, ServerFeatureLevelSchema } from 'src/enum.js'; import { isoDatetimeToDate } from 'src/validation.js'; const ServerPingResponseSchema = z @@ -68,6 +69,11 @@ const ServerVersionResponseSchema = z .nullable() .meta(HistoryBuilder.v3().getExtensions()) .describe('Pre-release version number'), + featureLevel: z + .int() + .min(0) + .meta(HistoryBuilder.v4().getExtensions()) + .describe('Highest feature level supported by the server, which increments independently of the release version'), }) .meta({ id: 'ServerVersionResponseDto' }); @@ -175,6 +181,8 @@ export class ServerAboutResponseDto extends createZodDto(ServerAboutResponseSche export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {} export class ServerStorageResponseDto extends createZodDto(ServerStorageResponseSchema) {} +const serverFeatureLevel = Math.max(...Object.values(ServerFeatureLevel).filter((value) => typeof value === 'number')); + export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) { static fromSemVer(value: SemVer): z.infer { return { @@ -182,6 +190,7 @@ export class ServerVersionResponseDto extends createZodDto(ServerVersionResponse minor: value.minor, patch: value.patch, prerelease: (value.prerelease[1] as number) ?? null, + featureLevel: serverFeatureLevel, }; } } @@ -195,3 +204,6 @@ export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {} @ExtraModel() export class ReleaseEventV1 extends createZodDto(ReleaseEventV1Schema) {} + +@ExtraModel() +export class ServerFeatureLevelDto extends createZodDto(ServerFeatureLevelSchema as unknown as z.ZodObject) {} diff --git a/server/src/enum.ts b/server/src/enum.ts index 70efdc100d..cd978257b2 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1,5 +1,6 @@ import { WorkflowTrigger } from '@immich/plugin-sdk'; import z from 'zod'; +import { asIntegerEnum } from 'src/utils/helpers.js'; export enum AuthType { Password = 'password', @@ -1325,3 +1326,15 @@ export enum SearchOrderField { } export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' }); + +export enum ServerFeatureLevel { + FeatureLevel = 1, +} + +export const ServerFeatureLevelSchema = z + .enum(ServerFeatureLevel) + .describe('Server feature level') + .meta({ + id: 'ServerFeatureLevel', + ...asIntegerEnum(ServerFeatureLevel), + }); diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 00aadf2e28..1ee8d7b8d4 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -65,12 +65,15 @@ describe(VersionService.name, () => { describe('getVersion', () => { it('should respond the server version', () => { - expect(sut.getVersion()).toEqual({ + const version = sut.getVersion(); + expect(version).toEqual({ major: 3, minor: 0, patch: 0, prerelease: null, + featureLevel: expect.any(Number), }); + expect(Number.isSafeInteger(version.featureLevel)).toBe(true); }); }); @@ -175,6 +178,7 @@ describe(VersionService.name, () => { minor: 0, patch: 0, prerelease: null, + featureLevel: expect.any(Number), }); expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1); }); @@ -187,6 +191,7 @@ describe(VersionService.name, () => { minor: 0, patch: 0, prerelease: null, + featureLevel: expect.any(Number), }); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); @@ -199,6 +204,7 @@ describe(VersionService.name, () => { minor: 0, patch: 0, prerelease: null, + featureLevel: expect.any(Number), }); expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); diff --git a/server/src/utils/helpers.ts b/server/src/utils/helpers.ts new file mode 100644 index 0000000000..bb94b16ac4 --- /dev/null +++ b/server/src/utils/helpers.ts @@ -0,0 +1,10 @@ +type NumericEnum = Record; + +export const asIntegerEnum = (type: NumericEnum) => { + return { + type: 'integer', + 'x-enum-varnames': asIntegerEnumVarNames(type), + }; +}; + +const asIntegerEnumVarNames = (type: NumericEnum) => Object.keys(type).filter((key) => Number.isNaN(Number(key))); diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index adeaba4ee8..8c5867ca74 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -166,11 +166,11 @@ describe('utils', () => { }); describe('semverToName', () => { it('should not append release candidate tag if prelease is not set', () => { - expect(semverToName({ major: 3, minor: 0, patch: 0, prerelease: null })).toEqual('v3.0.0'); + expect(semverToName({ major: 3, minor: 0, patch: 0, prerelease: null, featureLevel: 1 })).toEqual('v3.0.0'); }); it('should append release candidate if set', () => { - expect(semverToName({ major: 3, minor: 0, patch: 0, prerelease: 0 })).toEqual('v3.0.0-rc.0'); + expect(semverToName({ major: 3, minor: 0, patch: 0, prerelease: 0, featureLevel: 1 })).toEqual('v3.0.0-rc.0'); }); });