feat: store null instead of empty string for album.description (#30123)

Addresses the first column of issue #28832: album.description now stores
and returns null instead of an empty string.

Co-authored-by: Giacomo Pinato <giacomo.pinato@gmail.com>
This commit is contained in:
Giacomo Pinato
2026-07-30 19:41:49 +00:00
committed by GitHub
co-authored by Giacomo Pinato
parent e7aace436d
commit 6b6058c463
9 changed files with 118 additions and 18 deletions
@@ -25,7 +25,9 @@ class DriftAlbumApiRepository extends ApiRepository {
_api.createAlbum(
CreateAlbumDto(
albumName: name,
description: description == null ? const Optional.absent() : Optional.present(description),
description: description == null
? const Optional.absent()
: Optional.present(description.isEmpty ? null : description),
assetIds: Optional.present(assetIds.toList()),
),
),
@@ -88,7 +90,9 @@ class DriftAlbumApiRepository extends ApiRepository {
albumId,
UpdateAlbumDto(
albumName: name == null ? const Optional.absent() : Optional.present(name),
description: description == null ? const Optional.absent() : Optional.present(description),
description: description == null
? const Optional.absent()
: Optional.present(description.isEmpty ? null : description),
albumThumbnailAssetId: thumbnailAssetId == null
? const Optional.absent()
: Optional.present(thumbnailAssetId),
+38 -3
View File
@@ -16585,7 +16585,18 @@
},
"description": {
"description": "Album description",
"type": "string"
"type": "string",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v3",
"state": "Updated",
"description": "An empty string is returned instead of null for backwards compatibility; null will be returned in v4."
}
]
},
"endDate": {
"description": "End date (latest asset)",
@@ -18443,7 +18454,19 @@
},
"description": {
"description": "Album description",
"type": "string"
"nullable": true,
"type": "string",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v3",
"state": "Updated",
"description": "Sending an empty string is deprecated; send null instead. Empty strings will no longer be coerced to null in v4."
}
]
}
},
"required": [
@@ -27115,7 +27138,19 @@
},
"description": {
"description": "Album description",
"type": "string"
"nullable": true,
"type": "string",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v3",
"state": "Updated",
"description": "Sending an empty string is deprecated; send null instead. Empty strings will no longer be coerced to null in v4."
}
]
},
"isActivityEnabled": {
"description": "Enable activity feed",
+2 -2
View File
@@ -540,7 +540,7 @@ export type CreateAlbumDto = {
/** Initial asset IDs */
assetIds?: string[];
/** Album description */
description?: string;
description?: string | null;
};
export type AlbumsAddAssetsDto = {
/** Album IDs */
@@ -567,7 +567,7 @@ export type UpdateAlbumDto = {
/** Album thumbnail asset ID */
albumThumbnailAssetId?: string;
/** Album description */
description?: string;
description?: string | null;
/** Enable activity feed */
isActivityEnabled?: boolean;
order?: AssetOrder;
+47 -5
View File
@@ -34,7 +34,22 @@ const AlbumUserCreateSchema = z
const CreateAlbumSchema = z
.object({
albumName: z.string().describe('Album name'),
description: z.string().optional().describe('Album description'),
// TODO: drop the empty-string-to-null transform in v4 (clients should send null)
description: z
.string()
.nullable()
.transform((value) => (value === '' ? null : value))
.optional()
.describe('Album description')
.meta({
...new HistoryBuilder()
.added('v1')
.updated(
'v3',
'Sending an empty string is deprecated; send null instead. Empty strings will no longer be coerced to null in v4.',
)
.getExtensions(),
}),
albumUsers: z.array(AlbumUserCreateSchema).optional().describe('Album users'),
assetIds: z.array(z.uuidv4()).optional().describe('Initial asset IDs'),
})
@@ -57,7 +72,22 @@ const AlbumsAddAssetsResponseSchema = z
const UpdateAlbumSchema = z
.object({
albumName: z.string().optional().describe('Album name'),
description: z.string().optional().describe('Album description'),
// TODO: drop the empty-string-to-null transform in v4 (clients should send null)
description: z
.string()
.nullable()
.transform((value) => (value === '' ? null : value))
.optional()
.describe('Album description')
.meta({
...new HistoryBuilder()
.added('v1')
.updated(
'v3',
'Sending an empty string is deprecated; send null instead. Empty strings will no longer be coerced to null in v4.',
)
.getExtensions(),
}),
albumThumbnailAssetId: z.uuidv4().optional().describe('Album thumbnail asset ID'),
isActivityEnabled: z.boolean().optional().describe('Enable activity feed'),
order: AssetOrderSchema.optional(),
@@ -110,7 +140,18 @@ export const AlbumResponseSchema = z
.object({
id: z.uuidv4().describe('Album ID'),
albumName: z.string().describe('Album name'),
description: z.string().describe('Album description'),
description: z
.string()
.describe('Album description')
.meta({
...new HistoryBuilder()
.added('v1')
.updated(
'v3',
'An empty string is returned instead of null for backwards compatibility; null will be returned in v4.',
)
.getExtensions(),
}),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -171,7 +212,7 @@ export type MapAlbumDto = {
assets?: ShallowDehydrateObject<MapAsset>[];
sharedLinks?: ShallowDehydrateObject<AuthSharedLink>[];
albumName: string;
description: string;
description: string | null;
albumThumbnailAssetId: string | null;
createdAt: Date;
updatedAt: Date;
@@ -207,7 +248,8 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
return {
albumName: entity.albumName,
description: entity.description,
// TODO: return null instead of '' in v4
description: entity.description ?? '',
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: asDateTimeString(entity.createdAt),
updatedAt: asDateTimeString(entity.updatedAt),
@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album" ALTER COLUMN "description" DROP NOT NULL;`.execute(db);
await sql`ALTER TABLE "album" ALTER COLUMN "description" SET DEFAULT NULL;`.execute(db);
await sql`UPDATE "album" SET "description" = NULL WHERE "description" = '';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`UPDATE "album" SET "description" = '' WHERE "description" IS NULL;`.execute(db);
await sql`ALTER TABLE "album" ALTER COLUMN "description" SET DEFAULT ''::text;`.execute(db);
await sql`ALTER TABLE "album" ALTER COLUMN "description" SET NOT NULL;`.execute(db);
}
+2 -2
View File
@@ -36,8 +36,8 @@ export class AlbumTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ type: 'text', default: '' })
description!: Generated<string>;
@Column({ type: 'text', nullable: true })
description!: string | null;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
+8 -2
View File
@@ -441,7 +441,12 @@ export class SyncService extends BaseService {
const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
const albumUsers = await this.syncRepository.album.getAlbumUsers(data.id);
send(response, { type: upsertType, ids: [updateId], data: syncAlbumV2ToV1(data, albumUsers) });
send(response, {
type: upsertType,
ids: [updateId],
// TODO: return null instead of '' in v4
data: syncAlbumV2ToV1({ ...data, description: data.description ?? '' }, albumUsers),
});
}
}
@@ -455,7 +460,8 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.AlbumV2;
const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
// TODO: return null instead of '' in v4
send(response, { type: upsertType, ids: [updateId], data: { ...data, description: data.description ?? '' } });
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
let description = $state(album.description);
const onSubmit = async () => {
const success = await handleUpdateAlbum(album, { albumName, description });
const success = await handleUpdateAlbum(album, { albumName, description: description || null });
if (success) {
onClose();
}
@@ -20,7 +20,7 @@
const response = await updateAlbumInfo({
id,
updateAlbumDto: {
description,
description: description || null,
},
});
eventManager.emit('AlbumUpdate', response);