feat: server feature level (#32178)

* feat: server feature level

* fix: correctly compute feature level
This commit is contained in:
Jason Rasmussen
2026-10-14 09:56:49 -04:00
committed by GitHub
parent 9d8435973e
commit cc1e236365
10 changed files with 81 additions and 7 deletions
+1
View File
@@ -44,6 +44,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
'PeopleResponse': {'updateStrategy': 'everyone'},
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
'ServerVersionResponseDto': {'featureLevel': 0},
'WorkflowResponseDto': {'logging': false},
};
@@ -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();
+23
View File
@@ -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",
+5
View File
@@ -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",
+4
View File
@@ -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 });
}
+12
View File
@@ -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<typeof ServerVersionResponseSchema> {
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) {}
+13
View File
@@ -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),
});
+7 -1
View File
@@ -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));
});
+10
View File
@@ -0,0 +1,10 @@
type NumericEnum = Record<string, string | number>;
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)));
+2 -2
View File
@@ -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');
});
});