feat(storage): confirm the reclaim count the admin was shown, name a running backup as a skip, bound a trip cover

The confirm request now carries the unreferenced count the admin saw, so the
server can refuse a count its last report no longer holds. A pass that found
objects due while a backup read the uploads reports backup_running for them.
A trip cover is bounded to 2048 characters on REST, RPC and the MCP tool, as a
place image already is, since a replaced cover is queued for release by every
upload path it spells out.
This commit is contained in:
Maurice
2026-11-01 18:11:25 +01:00
parent 59a6d92cea
commit 0a25de2c6d
5 changed files with 40 additions and 8 deletions
+1
View File
@@ -225,6 +225,7 @@ export class TripsMcp {
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
cover_image: z
.string()
.max(2048)
.nullable()
.optional()
.describe('Cover image path, e.g. /uploads/covers/abc.jpg; null removes the cover'),
+14 -3
View File
@@ -224,7 +224,7 @@ describe('the reclaim contract', () => {
expect(storageReclaimReportSchema.safeParse(old).success).toBe(false);
});
it('names every skip reason a pass reports, the brake that used to skip a category gone', () => {
it('names every skip reason a pass reports, a running backup included, the old brake gone', () => {
expect([...STORAGE_RECLAIM_SKIP_REASONS]).toEqual([
'not_reclaimed',
'sources_missing',
@@ -234,6 +234,7 @@ describe('the reclaim contract', () => {
'list_failed',
'source_failed',
'delete_failed',
'backup_running',
'interrupted',
]);
for (const skipped of STORAGE_RECLAIM_SKIP_REASONS) {
@@ -264,10 +265,20 @@ describe('the reclaim contract', () => {
it('the requests are closed objects, and a started check answers started: true', () => {
expect(storageReclaimSettingsRequestSchema.parse({ deleteEnabled: true })).toEqual({ deleteEnabled: true });
expect(storageReclaimSettingsRequestSchema.safeParse({ deleteEnabled: true, extra: 1 }).success).toBe(false);
expect(storageReclaimConfirmRequestSchema.parse({ category: 'covers' })).toEqual({ category: 'covers' });
expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'nowhere' }).success).toBe(false);
expect(storageReclaimConfirmRequestSchema.parse({ category: 'covers', unreferenced: 300 })).toEqual({
category: 'covers',
unreferenced: 300,
});
expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'nowhere', unreferenced: 3 }).success).toBe(false);
expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', count: 3 }).success).toBe(false);
expect(storageReclaimRunStartedSchema.parse({ started: true })).toEqual({ started: true });
expect(storageReclaimRunStartedSchema.safeParse({ started: false }).success).toBe(false);
});
it('a confirmation names the count the admin was shown, a whole number of objects', () => {
expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers' }).success).toBe(false);
expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', unreferenced: -1 }).success).toBe(false);
expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', unreferenced: 1.5 }).success).toBe(false);
expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', unreferenced: '3' }).success).toBe(false);
expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', unreferenced: 0 }).success).toBe(true);
});
});
+15 -4
View File
@@ -291,6 +291,8 @@ export const storageReclaimCategorySchema = z.enum(STORAGE_RECLAIM_CATEGORIES);
* - `owner_unknown`: this installation's owner marker could not be written there, so a shared place cannot be ruled out;
* - `list_failed` / `source_failed`: the listing or a reference lookup failed, so the pass stopped there;
* - `delete_failed`: deleting failed on the backend, so the pass stopped deleting in this category;
* - `backup_running`: a backup was reading the uploads when this category's objects were due, so the pass
* deleted nothing there (from then on, for the rest of the pass); the next pass tries again;
* - `interrupted`: the pass stopped (`stopped` says why) before it finished this category; its figures are partial.
* A skip keeps the marks it found: only a pass that judges the category moves them.
*/
@@ -303,6 +305,7 @@ export const STORAGE_RECLAIM_SKIP_REASONS = [
'list_failed',
'source_failed',
'delete_failed',
'backup_running',
'interrupted',
] as const;
@@ -317,8 +320,9 @@ export const storageReclaimCategoryReportSchema = z.object({
eligible: storageObjectCountSchema,
deleted: storageObjectCountSchema,
/**
* More than a fifth of the category is unreferenced, and more than an admin
* last confirmed: deletion waits for a confirmation (POST .../reclaim/confirm).
* More than a fifth of the category is unreferenced and no admin confirmation
* covers that count: deletion waits for one (POST .../reclaim/confirm). The
* admin state reads false once a confirmation covers it, before the next pass.
*/
confirmationRequired: z.boolean(),
skipped: z.enum(STORAGE_RECLAIM_SKIP_REASONS).nullable(),
@@ -359,9 +363,16 @@ export const storageReclaimRunStartedSchema = z.object({ started: z.literal(true
/**
* Lets the pass delete in a category whose unreferenced share is over a
* fifth, up to the count the last report showed for it.
* fifth. `unreferenced` is the count the admin was shown; the server refuses
* (409 `reclaim_confirm_stale`) when its last report's count for the category
* differs from it by more than a tenth or by more than 50 objects. The
* confirmation then covers up to one and a half times that count (at least 100
* more) for 14 days, and is used up once the share falls under a fifth again.
*/
export const storageReclaimConfirmRequestSchema = z.strictObject({ category: storageReclaimCategorySchema });
export const storageReclaimConfirmRequestSchema = z.strictObject({
category: storageReclaimCategorySchema,
unreferenced: z.number().int().nonnegative(),
});
export type StorageReclaimConfirmRequest = z.infer<typeof storageReclaimConfirmRequestSchema>;
export const storageAdminStateSchema = z.object({
+7
View File
@@ -48,6 +48,13 @@ describe('tripUpdateRequestSchema', () => {
expect(tripUpdateRequestSchema.safeParse({}).success).toBe(true);
expect(tripUpdateRequestSchema.safeParse({ is_archived: 1, cover_image: null }).success).toBe(true);
});
it('bounds cover_image like a place image url: a replaced cover is queued for release', () => {
expect(tripUpdateRequestSchema.safeParse({ cover_image: `/uploads/covers/${'a'.repeat(2000)}` }).success).toBe(
true,
);
expect(tripUpdateRequestSchema.safeParse({ cover_image: 'x'.repeat(2049) }).success).toBe(false);
});
});
describe('tripAddMemberRequestSchema', () => {
+3 -1
View File
@@ -118,7 +118,9 @@ export const tripUpdateRequestSchema = z.object({
reminder_days: z.number().optional(),
day_count: z.number().optional(),
is_archived: z.union([z.boolean(), z.number()]).optional(),
cover_image: z.string().nullable().optional(),
// Bounded like a place's image url: a replaced cover is queued for release,
// and every upload path the old value spells out is one queued object.
cover_image: z.string().max(2048).nullable().optional(),
});
export type TripUpdateRequest = z.infer<typeof tripUpdateRequestSchema>;