diff --git a/packages/storage/README.md b/packages/storage/README.md index 124a549..ed216df 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -619,8 +619,8 @@ In case of successful `getBucketInfo`, the `data` property will be set and conta - `defaultTier`: Default storage class (`STANDARD`, `STANDARD_IA`, `GLACIER`, `GLACIER_IR`) - `corsRules`: Array of CORS rules - `notifications`: Notification configuration - - `ttlConfig`: TTL/expiration configuration - - `lifecycleRules`: Array of lifecycle transition rules + - `lifecycleRules`: Complete array of lifecycle rules on the bucket. Each rule may carry a transition (`storageClass` + `days`/`date`), an `expiration`, and/or a `filter.prefix`. The bucket-wide TTL (if set) is the rule with only `expiration` and no transition or filter. + - `ttlConfig`: _Deprecated._ No longer populated — read the bucket-wide TTL from `lifecycleRules` instead. - `dataMigration`: Data migration (shadow bucket) configuration - `customDomain`: Custom domain name - `deleteProtection`: Whether delete protection is enabled @@ -962,7 +962,7 @@ const result = await setBucketCors('my-bucket', { rules: [] }); ## Setting bucket lifecycle -`setBucketLifecycle` function can be used to configure lifecycle transition rules on a bucket. Only one lifecycle transition rule is allowed per bucket. +`setBucketLifecycle` function can be used to configure lifecycle rules on a bucket. A rule can carry at most one transition and/or one expiration, optionally scoped to a key prefix via `filter.prefix`. Multiple rules per bucket are supported (e.g. one rule per prefix). ### `setBucketLifecycle` @@ -979,17 +979,24 @@ setBucketLifecycle(bucketName: string, options?: SetBucketLifecycleOptions): Pro | **Parameter** | **Required** | **Values** | | -------------- | ------------ | -------------------------------------------------------------------------------- | -| lifecycleRules | Yes | An array with a single lifecycle rule. See below. | +| lifecycleRules | Yes | A non-empty array of lifecycle rules. See below. | | config | No | A configuration object to override the [default configuration](#authentication). | Each lifecycle rule has the following properties: -| **Property** | **Required** | **Values** | -| ------------ | ------------ | --------------------------------------------------------------------------------------- | -| storageClass | No | Target storage class: `STANDARD_IA`, `GLACIER`, or `GLACIER_IR`. | -| days | No | Number of days after object creation to transition. Cannot be combined with `date`. | -| date | No | A specific date to transition objects. Cannot be combined with `days`. | -| enabled | No | Whether the rule is enabled. | +| **Property** | **Required** | **Values** | +| ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| storageClass | No | Transition target storage class: `STANDARD_IA`, `GLACIER`, or `GLACIER_IR`. | +| days | No | Transition: days after object creation. Cannot be combined with `date`. | +| date | No | Transition: specific date to transition. Cannot be combined with `days`. | +| expiration | No | `{ days?: number; date?: string }` — when objects are deleted. `days` and `date` are mutually exclusive; one of them must be set. | +| filter | No | `{ prefix: string }` — scope the rule to objects whose key starts with `prefix`. | +| enabled | No | Whether the rule is enabled. Defaults to `true`. | +| id | No | Stable identifier. When updating, supply the existing rule's `id` to target it; otherwise the rule is created with a generated id. | + +A rule must have at least one of a transition (`storageClass` + `days`/`date`) or an `expiration`. `filter` alone is not enough. + +When `setBucketLifecycle` runs, each input rule is matched to an existing rule on the bucket by `id`. As a back-compat convenience, if the bucket has exactly one existing transition rule and the update has exactly one rule with no `id`, they auto-match. Existing rules whose `id` is not referenced in the update are preserved unchanged. ### Examples @@ -1007,6 +1014,43 @@ const result = await setBucketLifecycle('my-bucket', { }); ``` +#### Transition + expiration on the same rule, scoped to a prefix + +```ts +const result = await setBucketLifecycle('my-bucket', { + lifecycleRules: [ + { + storageClass: 'GLACIER', + days: 30, + expiration: { days: 365 }, + filter: { prefix: 'logs/' }, + enabled: true, + }, + ], +}); +``` + +#### Multiple rules, one per prefix + +```ts +const result = await setBucketLifecycle('my-bucket', { + lifecycleRules: [ + { + filter: { prefix: 'logs/' }, + storageClass: 'GLACIER', + days: 30, + expiration: { days: 30 }, + }, + { + filter: { prefix: 'logs-2/' }, + storageClass: 'GLACIER_IR', + days: 30, + expiration: { days: 60 }, + }, + ], +}); +``` + ## Setting bucket migration `setBucketMigration` function can be used to configure data migration from an external S3-compatible storage provider. diff --git a/packages/storage/src/lib/bucket/info.ts b/packages/storage/src/lib/bucket/info.ts index 50ff72a..b7792c2 100644 --- a/packages/storage/src/lib/bucket/info.ts +++ b/packages/storage/src/lib/bucket/info.ts @@ -32,6 +32,11 @@ export type BucketInfoResponse = { defaultTier: StorageClass; lifecycleRules?: BucketLifecycleRule[]; dataMigration?: Omit; + /** + * @deprecated Use `lifecycleRules` instead. This field is no longer + * populated — read the rule with only `expiration` (no transition, + * no filter) from `lifecycleRules` if you need the bucket-wide TTL. + */ ttlConfig?: BucketTtl; customDomain?: string; deleteProtection: boolean; @@ -95,9 +100,30 @@ export async function getBucketInfo( return { error: response.error }; } - const ttlConfig = response.data.lifecycle_rules?.find( - (rule) => rule.expiration !== undefined - ); + const lifecycleRules: BucketLifecycleRule[] = + response.data.lifecycle_rules?.map((rule) => { + const firstTransition = rule.transitions?.[0]; + return { + id: rule.id, + enabled: rule.status === 1, + storageClass: firstTransition?.storage_class as + | Exclude + | undefined, + days: firstTransition?.days, + date: firstTransition?.date, + expiration: + rule.expiration !== undefined + ? { + days: rule.expiration.days, + date: rule.expiration.date, + } + : undefined, + filter: + rule.filter?.prefix !== undefined + ? { prefix: rule.filter.prefix } + : undefined, + }; + }) ?? []; const data: BucketInfoResponse = { isSnapshotEnabled: response.data.type === 1, @@ -129,27 +155,7 @@ export async function getBucketInfo( writeThrough: response.data.shadow_bucket.write_through, } : undefined, - ttlConfig: ttlConfig - ? { - enabled: ttlConfig.status === 1, - days: ttlConfig.expiration?.days, - date: ttlConfig.expiration?.date, - id: ttlConfig.id, - } - : undefined, - lifecycleRules: - response.data.lifecycle_rules - ?.filter((rule) => rule.expiration === undefined) - .map((rule) => ({ - storageClass: rule.transitions?.[0]?.storage_class as Exclude< - StorageClass, - 'STANDARD' - >, - days: rule.transitions?.[0]?.days, - date: rule.transitions?.[0]?.date, - enabled: rule.status === 1, - id: rule.id, - })) ?? undefined, + lifecycleRules: lifecycleRules.length > 0 ? lifecycleRules : undefined, corsRules: response.data.cors?.rules.map((rule) => ({ allowedOrigins: rule.allowedOrigin, diff --git a/packages/storage/src/lib/bucket/set/lifecycle.ts b/packages/storage/src/lib/bucket/set/lifecycle.ts index fb3231b..499e481 100644 --- a/packages/storage/src/lib/bucket/set/lifecycle.ts +++ b/packages/storage/src/lib/bucket/set/lifecycle.ts @@ -26,46 +26,17 @@ export async function setBucketLifecycle( }; } - if (options.lifecycleRules.length > 1) { - return { - error: new Error('Only one lifecycle transition rule is allowed'), - }; - } + // Shallow-clone every rule (and its expiration) so date normalization + // doesn't mutate the caller's options. + const rules: BucketLifecycleRule[] = options.lifecycleRules.map((input) => ({ + ...input, + expiration: + input.expiration !== undefined ? { ...input.expiration } : undefined, + })); - const rule = options.lifecycleRules[0]; - - if (rule.date !== undefined && rule.days !== undefined) { - return { - error: new Error( - 'Cannot specify both date and days for a lifecycle rule' - ), - }; - } - - if ( - rule.date === undefined && - rule.days === undefined && - rule.enabled === undefined && - rule.storageClass === undefined - ) { - return { - error: new Error('No lifecycle rule configuration provided'), - }; - } - - if (rule.days !== undefined) { - const daysError = validateDays(rule.days); - if (daysError) { - return { error: new Error(`Lifecycle rule ${daysError}`) }; - } - } - - if (rule.date !== undefined) { - const dateResult = validateAndFormatDate(rule.date); - if ('error' in dateResult) { - return { error: new Error(`Lifecycle rule ${dateResult.error}`) }; - } - rule.date = dateResult.value; + for (const rule of rules) { + const validationError = validateRule(rule); + if (validationError) return { error: validationError }; } const { data, error } = await getBucketInfo(bucketName, { @@ -76,12 +47,11 @@ export async function setBucketLifecycle( return { error }; } - const { rules, error: lifecycleError } = buildLifecycleRules( + const { rules: builtRules, error: lifecycleError } = buildLifecycleRules( { - ttlConfig: data?.settings.ttlConfig, lifecycleRules: data?.settings.lifecycleRules, }, - { lifecycleRules: options.lifecycleRules } + { lifecycleRules: rules } ); if (lifecycleError) { @@ -89,7 +59,81 @@ export async function setBucketLifecycle( } return setBucketSettings(bucketName, { - body: { lifecycle_rules: rules }, + body: { lifecycle_rules: builtRules }, config: options.config, }); } + +function validateRule(rule: BucketLifecycleRule): Error | undefined { + const hasTransition = + rule.storageClass !== undefined || + rule.days !== undefined || + rule.date !== undefined; + const hasExpiration = rule.expiration !== undefined; + + if ( + !hasTransition && + !hasExpiration && + rule.enabled === undefined && + rule.filter === undefined + ) { + return new Error('No lifecycle rule configuration provided'); + } + + // A rule with no transition and no expiration must target an existing + // rule by `id` — filter-only or toggle-only inputs without an id have + // nothing to act on. + if (!hasTransition && !hasExpiration && rule.id === undefined) { + return new Error( + 'Lifecycle rule requires a transition or expiration when no `id` is provided' + ); + } + + if (hasTransition) { + if (rule.date !== undefined && rule.days !== undefined) { + return new Error( + 'Cannot specify both date and days for a lifecycle transition' + ); + } + if (rule.days !== undefined) { + const daysError = validateDays(rule.days); + if (daysError) return new Error(`Lifecycle transition ${daysError}`); + } + if (rule.date !== undefined) { + const dateResult = validateAndFormatDate(rule.date); + if ('error' in dateResult) { + return new Error(`Lifecycle transition ${dateResult.error}`); + } + rule.date = dateResult.value; + } + } + + if (hasExpiration) { + const expiration = rule.expiration!; + if (expiration.date !== undefined && expiration.days !== undefined) { + return new Error( + 'Cannot specify both date and days for a lifecycle expiration' + ); + } + if (expiration.days === undefined && expiration.date === undefined) { + return new Error('Lifecycle expiration requires either days or date'); + } + if (expiration.days !== undefined) { + const daysError = validateDays(expiration.days); + if (daysError) return new Error(`Lifecycle expiration ${daysError}`); + } + if (expiration.date !== undefined) { + const dateResult = validateAndFormatDate(expiration.date); + if ('error' in dateResult) { + return new Error(`Lifecycle expiration ${dateResult.error}`); + } + expiration.date = dateResult.value; + } + } + + if (rule.filter !== undefined && typeof rule.filter.prefix !== 'string') { + return new Error('Lifecycle rule filter.prefix must be a string'); + } + + return undefined; +} diff --git a/packages/storage/src/lib/bucket/set/ttl.ts b/packages/storage/src/lib/bucket/set/ttl.ts index cd419c4..1e6cdfa 100644 --- a/packages/storage/src/lib/bucket/set/ttl.ts +++ b/packages/storage/src/lib/bucket/set/ttl.ts @@ -64,10 +64,32 @@ export async function setBucketTtl( return { error }; } + const allExistingRules = data?.settings.lifecycleRules ?? []; + const existingTtlRule = allExistingRules.find( + (r) => + r.expiration !== undefined && + r.storageClass === undefined && + r.filter === undefined + ); + const existingTtl: BucketTtl | undefined = existingTtlRule + ? { + id: existingTtlRule.id, + enabled: existingTtlRule.enabled, + days: existingTtlRule.expiration?.days, + date: existingTtlRule.expiration?.date, + } + : undefined; + // Drop the TTL-shaped rule from the lifecycle list we hand off — it's + // emitted via the dedicated `ttlConfig` path and we don't want it + // duplicated. Match by reference so id-less rules dedupe correctly. + const otherExistingRules = existingTtlRule + ? allExistingRules.filter((r) => r !== existingTtlRule) + : allExistingRules; + const { rules, error: lifecycleError } = buildLifecycleRules( { - ttlConfig: data?.settings.ttlConfig, - lifecycleRules: data?.settings.lifecycleRules, + ttlConfig: existingTtl, + lifecycleRules: otherExistingRules, }, { ttlConfig: options.ttlConfig } ); diff --git a/packages/storage/src/lib/bucket/types.ts b/packages/storage/src/lib/bucket/types.ts index 18f1fa0..1a35bdb 100644 --- a/packages/storage/src/lib/bucket/types.ts +++ b/packages/storage/src/lib/bucket/types.ts @@ -55,6 +55,13 @@ export type BucketMigration = { writeThrough?: boolean; }; +/** + * Bucket-wide TTL configuration. Kept for back-compat with `setBucketTtl`, + * which manages a lifecycle rule with only `expiration` (no transition, + * no filter). New code should configure expirations via + * `BucketLifecycleRule.expiration` on `setBucketLifecycle` instead. This + * shape is expected to be removed in the next major version. + */ export type BucketTtl = { id?: string; enabled?: boolean; @@ -62,12 +69,29 @@ export type BucketTtl = { date?: string; }; +export type BucketLifecycleFilter = { + prefix: string; +}; + +export type BucketLifecycleExpiration = { + days?: number; + date?: string; +}; + +/** + * A bucket lifecycle rule. A rule can have at most one transition + * (top-level `storageClass` + `days`/`date`) and/or one `expiration`, + * optionally scoped to a key prefix via `filter.prefix`. At least one + * of transition or expiration must be present. + */ export type BucketLifecycleRule = { id?: string; enabled?: boolean; storageClass?: Exclude; days?: number; date?: string; + expiration?: BucketLifecycleExpiration; + filter?: BucketLifecycleFilter; }; export type BucketCorsRule = { diff --git a/packages/storage/src/lib/bucket/utils/api.ts b/packages/storage/src/lib/bucket/utils/api.ts index 0b8c114..ec0131d 100644 --- a/packages/storage/src/lib/bucket/utils/api.ts +++ b/packages/storage/src/lib/bucket/utils/api.ts @@ -59,6 +59,9 @@ type BucketApiSettings = { date?: string; days?: number; }[]; + filter?: { + prefix?: string; + }; status: 1 | 2; // 1: active, 2: disabled }[]; cors?: { diff --git a/packages/storage/src/lib/bucket/utils/lifecycle.test.ts b/packages/storage/src/lib/bucket/utils/lifecycle.test.ts index c1e2fe6..127dc08 100644 --- a/packages/storage/src/lib/bucket/utils/lifecycle.test.ts +++ b/packages/storage/src/lib/bucket/utils/lifecycle.test.ts @@ -436,7 +436,7 @@ describe('buildLifecycleRules', () => { }); describe('combined TTL + transition', () => { - it('returns max 2 rules when both TTL and transition exist', () => { + it('returns both rules when TTL and a transition rule exist', () => { const { rules } = buildLifecycleRules( { ttlConfig: ttlConfig(), lifecycleRules: [lifecycleRule()] }, {} @@ -529,40 +529,685 @@ describe('buildLifecycleRules', () => { }); }); - describe('only first lifecycle rule is used', () => { - it('ignores additional lifecycle rules beyond the first', () => { + describe('empty lifecycle rules array', () => { + it('does not create a transition rule from empty array', () => { + const { rules } = buildLifecycleRules( + {}, + { + lifecycleRules: [], + } + ); + expect(rules).toBeUndefined(); + }); + + it('preserves existing TTL when lifecycle rules array is empty', () => { + const { rules } = buildLifecycleRules( + { ttlConfig: ttlConfig() }, + { lifecycleRules: [] } + ); + expect(rules).toHaveLength(1); + expect(rules![0].expiration).toBeDefined(); + }); + }); + + describe('expiration on a lifecycle rule', () => { + it('emits transition + expiration on the same rule', () => { const { rules } = buildLifecycleRules( {}, { lifecycleRules: [ - { storageClass: 'GLACIER', days: 90 }, - { storageClass: 'STANDARD_IA', days: 30 }, + { + storageClass: 'GLACIER', + days: 30, + expiration: { days: 365 }, + }, ], } ); - expect(rules).toHaveLength(1); - expect(rules![0].transitions![0].storage_class).toBe('GLACIER'); + expect(rules![0]).toEqual({ + id: 'test-uuid-0000', + transitions: [{ storage_class: 'GLACIER', days: 30 }], + expiration: { days: 365, enabled: true }, + status: 1, + }); + }); + + it('emits transition + expiration + filter on the same rule', () => { + const { rules } = buildLifecycleRules( + {}, + { + lifecycleRules: [ + { + storageClass: 'GLACIER', + days: 30, + expiration: { days: 365 }, + filter: { prefix: 'logs/' }, + }, + ], + } + ); + expect(rules![0]).toEqual({ + id: 'test-uuid-0000', + transitions: [{ storage_class: 'GLACIER', days: 30 }], + expiration: { days: 365, enabled: true }, + filter: { prefix: 'logs/' }, + status: 1, + }); + }); + + it('emits expiration-only rule (no transition)', () => { + const { rules } = buildLifecycleRules( + {}, + { + lifecycleRules: [ + { + expiration: { days: 90 }, + filter: { prefix: 'tmp/' }, + }, + ], + } + ); + expect(rules![0]).toEqual({ + id: 'test-uuid-0000', + expiration: { days: 90, enabled: true }, + filter: { prefix: 'tmp/' }, + status: 1, + }); + expect(rules![0]).not.toHaveProperty('transitions'); + }); + + it('emits expiration with date instead of days', () => { + const { rules } = buildLifecycleRules( + {}, + { + lifecycleRules: [ + { + storageClass: 'GLACIER', + days: 30, + expiration: { date: '2026-12-31' }, + }, + ], + } + ); + expect(rules![0].expiration).toEqual({ + date: '2026-12-31', + enabled: true, + }); + }); + + it('preserves existing expiration on toggle-only update', () => { + const existing: BucketLifecycleRule = { + id: 'rule-1', + enabled: true, + storageClass: 'GLACIER', + days: 30, + expiration: { days: 365 }, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existing] }, + { lifecycleRules: [{ enabled: false }] } + ); + expect(rules![0]).toEqual({ + id: 'rule-1', + transitions: [{ storage_class: 'GLACIER', days: 30 }], + expiration: { days: 365, enabled: false }, + status: 2, + }); + }); + + it('replaces expiration when update provides one', () => { + const existing: BucketLifecycleRule = { + id: 'rule-1', + enabled: true, + storageClass: 'GLACIER', + days: 30, + expiration: { days: 365 }, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existing] }, + { lifecycleRules: [{ expiration: { days: 30 } }] } + ); + expect(rules![0].expiration).toEqual({ days: 30, enabled: true }); + }); + + it('switches expiration from days to date', () => { + const existing: BucketLifecycleRule = { + id: 'rule-1', + enabled: true, + storageClass: 'GLACIER', + days: 30, + expiration: { days: 365 }, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existing] }, + { lifecycleRules: [{ expiration: { date: '2026-12-31' } }] } + ); + expect(rules![0].expiration).toEqual({ + date: '2026-12-31', + enabled: true, + }); + expect(rules![0].expiration).not.toHaveProperty('days'); + }); + + it('omits expiration when neither update nor existing has one', () => { + const { rules } = buildLifecycleRules( + {}, + { lifecycleRules: [{ storageClass: 'GLACIER', days: 90 }] } + ); + expect(rules![0]).not.toHaveProperty('expiration'); }); }); - describe('empty lifecycle rules array', () => { - it('does not create a transition rule from empty array', () => { + describe('filter (prefix)', () => { + it('emits filter.prefix on a new rule', () => { const { rules } = buildLifecycleRules( {}, { - lifecycleRules: [], + lifecycleRules: [ + { + storageClass: 'GLACIER', + days: 90, + filter: { prefix: 'logs/' }, + }, + ], } ); + expect(rules![0]).toEqual({ + id: 'test-uuid-0000', + transitions: [{ storage_class: 'GLACIER', days: 90 }], + filter: { prefix: 'logs/' }, + status: 1, + }); + }); + + it('preserves existing filter on toggle-only update', () => { + const existingFiltered: BucketLifecycleRule = { + id: 'filtered-existing', + enabled: true, + storageClass: 'GLACIER', + days: 90, + filter: { prefix: 'logs/' }, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingFiltered] }, + { lifecycleRules: [{ enabled: false }] } + ); + expect(rules![0]).toEqual({ + id: 'filtered-existing', + transitions: [{ storage_class: 'GLACIER', days: 90 }], + filter: { prefix: 'logs/' }, + status: 2, + }); + }); + + it('updates filter while preserving existing transition', () => { + const existingFiltered: BucketLifecycleRule = { + id: 'filtered-existing', + enabled: true, + storageClass: 'GLACIER', + days: 90, + filter: { prefix: 'old/' }, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingFiltered] }, + { lifecycleRules: [{ filter: { prefix: 'new/' } }] } + ); + expect(rules![0].id).toBe('filtered-existing'); + expect(rules![0].filter).toEqual({ prefix: 'new/' }); + expect(rules![0].transitions).toEqual([ + { storage_class: 'GLACIER', days: 90 }, + ]); + }); + + it('omits filter when neither update nor existing has one', () => { + const { rules } = buildLifecycleRules( + {}, + { lifecycleRules: [{ storageClass: 'GLACIER', days: 90 }] } + ); + expect(rules![0]).not.toHaveProperty('filter'); + }); + + it('returns error for filter-only update when no existing rule', () => { + const { rules, error } = buildLifecycleRules( + {}, + { lifecycleRules: [{ filter: { prefix: 'logs/' } }] } + ); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toBe('No existing lifecycle rule found to update'); expect(rules).toBeUndefined(); }); + }); - it('preserves existing TTL when lifecycle rules array is empty', () => { + describe('multiple lifecycle rules', () => { + it('emits all update rules when no existing rules', () => { const { rules } = buildLifecycleRules( - { ttlConfig: ttlConfig() }, - { lifecycleRules: [] } + {}, + { + lifecycleRules: [ + { + storageClass: 'GLACIER', + days: 30, + expiration: { days: 365 }, + filter: { prefix: 'logs/' }, + }, + { + storageClass: 'GLACIER_IR', + days: 7, + filter: { prefix: 'tmp/' }, + }, + ], + } + ); + expect(rules).toHaveLength(2); + expect(rules![0].filter).toEqual({ prefix: 'logs/' }); + expect(rules![0].expiration).toEqual({ days: 365, enabled: true }); + expect(rules![1].filter).toEqual({ prefix: 'tmp/' }); + expect(rules![1]).not.toHaveProperty('expiration'); + }); + + it('matches update rules to existing rules by id', () => { + const existingA: BucketLifecycleRule = { + id: 'rule-a', + enabled: true, + storageClass: 'GLACIER', + days: 90, + filter: { prefix: 'logs/' }, + }; + const existingB: BucketLifecycleRule = { + id: 'rule-b', + enabled: true, + storageClass: 'STANDARD_IA', + days: 30, + filter: { prefix: 'images/' }, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingA, existingB] }, + { + lifecycleRules: [ + { id: 'rule-b', enabled: false }, + { id: 'rule-a', filter: { prefix: 'archive/' } }, + ], + } + ); + expect(rules).toHaveLength(2); + + const a = rules!.find((r) => r.id === 'rule-a'); + expect(a!.filter).toEqual({ prefix: 'archive/' }); + expect(a!.transitions).toEqual([{ storage_class: 'GLACIER', days: 90 }]); + expect(a!.status).toBe(1); + + const b = rules!.find((r) => r.id === 'rule-b'); + expect(b!.filter).toEqual({ prefix: 'images/' }); + expect(b!.transitions).toEqual([ + { storage_class: 'STANDARD_IA', days: 30 }, + ]); + expect(b!.status).toBe(2); + }); + + it('preserves existing rules whose id is not referenced in update', () => { + const existingA: BucketLifecycleRule = { + id: 'rule-a', + enabled: true, + storageClass: 'GLACIER', + days: 90, + }; + const existingB: BucketLifecycleRule = { + id: 'rule-b', + enabled: true, + storageClass: 'STANDARD_IA', + days: 30, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingA, existingB] }, + { lifecycleRules: [{ id: 'rule-a', enabled: false }] } + ); + expect(rules).toHaveLength(2); + expect(rules!.find((r) => r.id === 'rule-a')!.status).toBe(2); + expect(rules!.find((r) => r.id === 'rule-b')!.status).toBe(1); + }); + + it('treats a no-id update rule as new when multiple existing rules', () => { + const existingA: BucketLifecycleRule = { + id: 'rule-a', + enabled: true, + storageClass: 'GLACIER', + days: 90, + }; + const existingB: BucketLifecycleRule = { + id: 'rule-b', + enabled: true, + storageClass: 'STANDARD_IA', + days: 30, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingA, existingB] }, + { + lifecycleRules: [ + { + storageClass: 'GLACIER_IR', + days: 7, + filter: { prefix: 'tmp/' }, + }, + ], + } + ); + expect(rules).toHaveLength(3); + const newRule = rules!.find( + (r) => r.transitions?.[0].storage_class === 'GLACIER_IR' + ); + expect(newRule!.id).toBe('test-uuid-0000'); + expect(newRule!.filter).toEqual({ prefix: 'tmp/' }); + }); + + it('errors when a no-id update rule has no content and multiple existing rules', () => { + const existingA: BucketLifecycleRule = { + id: 'rule-a', + enabled: true, + storageClass: 'GLACIER', + days: 90, + }; + const existingB: BucketLifecycleRule = { + id: 'rule-b', + enabled: true, + storageClass: 'STANDARD_IA', + days: 30, + }; + const { rules, error } = buildLifecycleRules( + { lifecycleRules: [existingA, existingB] }, + { lifecycleRules: [{ enabled: false }] } + ); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toBe('No existing lifecycle rule found to update'); + expect(rules).toBeUndefined(); + }); + + it('preserves all existing rules when no update provided', () => { + const existingA: BucketLifecycleRule = { + id: 'rule-a', + enabled: true, + storageClass: 'GLACIER', + days: 90, + }; + const existingB: BucketLifecycleRule = { + id: 'rule-b', + enabled: false, + storageClass: 'STANDARD_IA', + days: 30, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingA, existingB] }, + {} + ); + expect(rules).toHaveLength(2); + expect(rules!.find((r) => r.id === 'rule-a')!.status).toBe(1); + expect(rules!.find((r) => r.id === 'rule-b')!.status).toBe(2); + }); + + it('preserves TTL alongside multiple lifecycle rules', () => { + const existingA: BucketLifecycleRule = { + id: 'rule-a', + enabled: true, + storageClass: 'GLACIER', + days: 90, + filter: { prefix: 'logs/' }, + }; + const existingB: BucketLifecycleRule = { + id: 'rule-b', + enabled: true, + storageClass: 'STANDARD_IA', + days: 30, + filter: { prefix: 'images/' }, + }; + const { rules } = buildLifecycleRules( + { + ttlConfig: ttlConfig(), + lifecycleRules: [existingA, existingB], + }, + { lifecycleRules: [{ id: 'rule-a', enabled: false }] } + ); + expect(rules).toHaveLength(3); + expect(rules!.find((r) => r.expiration && !r.transitions)).toBeDefined(); + }); + }); + + describe('auto-match shape compatibility', () => { + it('does not auto-match a no-id transition update into a TTL-only existing rule', () => { + // Bucket has a single TTL-only rule. A no-id transition update + // must NOT silently merge into it; it should be emitted as a new rule. + const existingTtlOnly: BucketLifecycleRule = { + id: 'ttl-existing', + enabled: true, + expiration: { days: 30 }, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingTtlOnly] }, + { lifecycleRules: [{ storageClass: 'GLACIER', days: 90 }] } + ); + expect(rules).toHaveLength(2); + + const ttl = rules!.find((r) => r.id === 'ttl-existing'); + expect(ttl).toEqual({ + id: 'ttl-existing', + expiration: { days: 30, enabled: true }, + status: 1, + }); + expect(ttl).not.toHaveProperty('transitions'); + + const transitionRule = rules!.find((r) => r.id !== 'ttl-existing'); + expect(transitionRule!.transitions).toEqual([ + { storage_class: 'GLACIER', days: 90 }, + ]); + expect(transitionRule).not.toHaveProperty('expiration'); + }); + + it('does not auto-match a no-id expiration update into a transition-only existing rule', () => { + const existingTransitionOnly: BucketLifecycleRule = { + id: 'transition-existing', + enabled: true, + storageClass: 'GLACIER', + days: 90, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingTransitionOnly] }, + { lifecycleRules: [{ expiration: { days: 365 } }] } + ); + expect(rules).toHaveLength(2); + const original = rules!.find((r) => r.id === 'transition-existing'); + expect(original).not.toHaveProperty('expiration'); + const newRule = rules!.find((r) => r.id !== 'transition-existing'); + expect(newRule!.expiration).toEqual({ days: 365, enabled: true }); + }); + + it('still auto-matches a transition update when existing has a transition', () => { + // Back-compat: single transition update + single transition existing. + const { rules } = buildLifecycleRules( + { lifecycleRules: [lifecycleRule()] }, + { lifecycleRules: [{ storageClass: 'STANDARD_IA', days: 60 }] } ); expect(rules).toHaveLength(1); - expect(rules![0].expiration).toBeDefined(); + expect(rules![0].id).toBe('transition-existing'); + expect(rules![0].transitions).toEqual([ + { storage_class: 'STANDARD_IA', days: 60 }, + ]); + }); + + it('still auto-matches a toggle-only update against any single existing rule', () => { + const existingTtlOnly: BucketLifecycleRule = { + id: 'ttl-existing', + enabled: true, + expiration: { days: 30 }, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [existingTtlOnly] }, + { lifecycleRules: [{ enabled: false }] } + ); + expect(rules).toHaveLength(1); + expect(rules![0]).toEqual({ + id: 'ttl-existing', + expiration: { days: 30, enabled: false }, + status: 2, + }); + }); + + it('auto-matches a no-id transition update past a TTL-only sibling', () => { + // Bucket has [TTL-only, transition]. A no-id transition update + // should auto-match the only shape-compatible existing rule. + const ttl: BucketLifecycleRule = { + id: 'ttl-existing', + enabled: true, + expiration: { days: 30 }, + }; + const transition: BucketLifecycleRule = { + id: 'transition-existing', + enabled: true, + storageClass: 'GLACIER', + days: 90, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [ttl, transition] }, + { lifecycleRules: [{ storageClass: 'STANDARD_IA', days: 60 }] } + ); + expect(rules).toHaveLength(2); + const merged = rules!.find((r) => r.id === 'transition-existing'); + expect(merged!.transitions).toEqual([ + { storage_class: 'STANDARD_IA', days: 60 }, + ]); + expect(rules!.find((r) => r.id === 'ttl-existing')).toBeDefined(); + }); + + it('skips auto-match when multiple shape-compatible existing rules exist', () => { + const a: BucketLifecycleRule = { + id: 'a', + enabled: true, + storageClass: 'GLACIER', + days: 90, + }; + const b: BucketLifecycleRule = { + id: 'b', + enabled: true, + storageClass: 'STANDARD_IA', + days: 30, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [a, b] }, + { lifecycleRules: [{ storageClass: 'GLACIER_IR', days: 7 }] } + ); + expect(rules).toHaveLength(3); + expect(rules!.find((r) => r.id === 'a')!.transitions).toEqual([ + { storage_class: 'GLACIER', days: 90 }, + ]); + expect(rules!.find((r) => r.id === 'b')!.transitions).toEqual([ + { storage_class: 'STANDARD_IA', days: 30 }, + ]); + }); + }); + + describe('idless rule preservation', () => { + it('preserves an idless existing rule when an unrelated rule is updated', () => { + const idless: BucketLifecycleRule = { + enabled: true, + storageClass: 'GLACIER', + days: 90, + filter: { prefix: 'logs/' }, + }; + const withId: BucketLifecycleRule = { + id: 'rule-b', + enabled: true, + storageClass: 'STANDARD_IA', + days: 30, + }; + const { rules } = buildLifecycleRules( + { lifecycleRules: [idless, withId] }, + { lifecycleRules: [{ id: 'rule-b', enabled: false }] } + ); + expect(rules).toHaveLength(2); + const preserved = rules!.find( + (r) => r.transitions?.[0].storage_class === 'GLACIER' + ); + expect(preserved).toBeDefined(); + expect(preserved!.filter).toEqual({ prefix: 'logs/' }); + }); + }); + + describe('built-rule completeness', () => { + it('errors when a transition update lacks a time field and no auto-match available', () => { + const { rules, error } = buildLifecycleRules( + {}, + { lifecycleRules: [{ storageClass: 'GLACIER' }] } + ); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toBe( + 'Lifecycle transition requires either `days` or `date`' + ); + expect(rules).toBeUndefined(); + }); + + it('errors when a no-id, no-storageClass update has no auto-match', () => { + // Two existing rules → no auto-match. Update has only `days`, + // no storageClass → resolveTransition returns undefined. + const a: BucketLifecycleRule = { + id: 'a', + enabled: true, + storageClass: 'GLACIER', + days: 90, + }; + const b: BucketLifecycleRule = { + id: 'b', + enabled: true, + storageClass: 'STANDARD_IA', + days: 30, + }; + const { rules, error } = buildLifecycleRules( + { lifecycleRules: [a, b] }, + { lifecycleRules: [{ days: 7 }] } + ); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toBe( + 'Lifecycle transition requires `storageClass`' + ); + expect(rules).toBeUndefined(); + }); + + it('errors when transition fields are set with expiration but no storageClass and no merge available', () => { + // Without this guard, `resolveTransition` would silently return + // undefined, the rule would pass `validateBuiltRule` on the strength + // of its expiration, and the caller's `days: 30` would be dropped. + const { rules, error } = buildLifecycleRules( + {}, + { + lifecycleRules: [ + { + days: 30, + expiration: { days: 365 }, + }, + ], + } + ); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toBe( + 'Lifecycle transition requires `storageClass`' + ); + expect(rules).toBeUndefined(); + }); + + it('errors when expiration update has no days or date', () => { + const { rules, error } = buildLifecycleRules( + {}, + { + lifecycleRules: [ + { + storageClass: 'GLACIER', + days: 30, + expiration: {}, + }, + ], + } + ); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toBe( + 'Lifecycle expiration requires either `days` or `date`' + ); + expect(rules).toBeUndefined(); }); }); }); diff --git a/packages/storage/src/lib/bucket/utils/lifecycle.ts b/packages/storage/src/lib/bucket/utils/lifecycle.ts index 6e571bb..c1b9c09 100644 --- a/packages/storage/src/lib/bucket/utils/lifecycle.ts +++ b/packages/storage/src/lib/bucket/utils/lifecycle.ts @@ -5,16 +5,24 @@ type LifecycleRuleBody = NonNullable< UpdateBucketBody['lifecycle_rules'] >[number]; +type TransitionBody = NonNullable[number]; +type ExpirationBody = NonNullable; + /** * Builds the `lifecycle_rules` array for the API request body. - * Merges new TTL/transition config with existing rules, preserving - * rules that aren't being updated. * - * Constraints: - * - Max 2 rules: 1 TTL (expiration) + 1 transition rule - * - A transition rule can only have a single transition - * - Updating TTL replaces the existing TTL rule (preserving its ID) - * - Updating lifecycle replaces the existing transition rule (preserving its ID) + * Model: + * - A rule may carry at most one transition (top-level `storageClass` + + * `days`/`date`), optionally an `expiration`, and optionally a + * `filter.prefix`. At least one of transition or expiration must be + * present. + * - `existing.ttlConfig` and `existing.lifecycleRules` must be disjoint: + * if a TTL-only rule is also present in `lifecycleRules`, callers must + * filter it out before passing in (`setBucketTtl` does this). + * - Update rules are merged into existing rules by `id`. As a back-compat + * convenience, a single no-id update rule auto-matches the only + * shape-compatible existing rule (when exactly one exists). + * - Existing rules not matched by any update rule are preserved unchanged. */ export function buildLifecycleRules( existing: { @@ -30,12 +38,11 @@ export function buildLifecycleRules( error?: Error; } { const existingTtl = existing.ttlConfig; - const existingTransition = existing.lifecycleRules?.[0]; + const existingLifecycleRules = existing.lifecycleRules ?? []; + const updateLifecycleRules = update.lifecycleRules; const rules: LifecycleRuleBody[] = []; - // TTL rule: use new config if provided, otherwise preserve existing - // If only `enabled` is provided (no days/date), toggle the existing rule if (update.ttlConfig !== undefined) { const isToggleOnly = update.ttlConfig.days === undefined && @@ -52,24 +59,75 @@ export function buildLifecycleRules( rules.push(formatTtlRule({}, existingTtl)); } - // Transition rule: use new config if provided, otherwise preserve existing - // Only 1 rule with 1 transition allowed - if (update.lifecycleRules !== undefined && update.lifecycleRules.length > 0) { - const updateRule = update.lifecycleRules[0]; - const isToggleOnly = - updateRule.storageClass === undefined && - updateRule.days === undefined && - updateRule.date === undefined; + if (updateLifecycleRules !== undefined && updateLifecycleRules.length > 0) { + const matchedExisting = new Set(); - if (isToggleOnly && !existingTransition) { - return { - error: new Error('No existing lifecycle rule found to update'), - }; + for (const updateRule of updateLifecycleRules) { + let existingMatch: BucketLifecycleRule | undefined; + if (updateRule.id !== undefined) { + existingMatch = existingLifecycleRules.find( + (e) => e.id === updateRule.id + ); + } else if (updateLifecycleRules.length === 1) { + // Auto-match a single no-id update to the only shape-compatible + // existing rule, when there is exactly one such rule. This + // preserves the back-compat path for "single transition rule on + // bucket" without picking up TTL-only or other-shaped rules. + const compatibleExisting = existingLifecycleRules.filter((e) => + rulesAreShapeCompatible(updateRule, e) + ); + if (compatibleExisting.length === 1) { + existingMatch = compatibleExisting[0]; + } + } + + const hasContent = ruleHasContent(updateRule); + if (!hasContent && !existingMatch) { + return { + error: new Error('No existing lifecycle rule found to update'), + }; + } + + if (existingMatch !== undefined) { + matchedExisting.add(existingMatch); + } + + const built = formatLifecycleRule(updateRule, existingMatch); + // If the user supplied transition fields but the merge couldn't + // resolve a `storage_class`, surface that — otherwise the + // `days`/`date` they provided would be silently dropped (the rule + // would still pass `validateBuiltRule` if it carries an expiration). + const userWantsTransition = + updateRule.storageClass !== undefined || + updateRule.days !== undefined || + updateRule.date !== undefined; + if ( + userWantsTransition && + (built.transitions === undefined || built.transitions.length === 0) + ) { + return { + error: new Error('Lifecycle transition requires `storageClass`'), + }; + } + + rules.push(built); } - rules.push(formatTransitionRule(updateRule, existingTransition)); - } else if (existingTransition) { - rules.push(formatTransitionRule({}, existingTransition)); + // Preserve every unmatched existing rule, even those without an id. + for (const existingRule of existingLifecycleRules) { + if (!matchedExisting.has(existingRule)) { + rules.push(formatLifecycleRule({}, existingRule)); + } + } + } else { + for (const existingRule of existingLifecycleRules) { + rules.push(formatLifecycleRule({}, existingRule)); + } + } + + for (const rule of rules) { + const error = validateBuiltRule(rule); + if (error) return { error }; } return { rules: rules.length > 0 ? rules : undefined }; @@ -79,6 +137,73 @@ function toStatus(enabled: boolean): 1 | 2 { return enabled ? 1 : 2; } +/** + * Final-shape validation for a built rule. A rule must carry either a + * complete transition (with `storage_class` and either `days` or `date`) + * or a complete `expiration` (with either `days` or `date`). + */ +function validateBuiltRule(rule: LifecycleRuleBody): Error | undefined { + const transitions = rule.transitions ?? []; + const expiration = rule.expiration; + + if (transitions.length === 0 && expiration === undefined) { + return new Error('Lifecycle rule must have a transition or expiration'); + } + + for (const t of transitions) { + if (t.days === undefined && t.date === undefined) { + return new Error('Lifecycle transition requires either `days` or `date`'); + } + } + + if ( + expiration !== undefined && + expiration.days === undefined && + expiration.date === undefined + ) { + return new Error('Lifecycle expiration requires either `days` or `date`'); + } + + return undefined; +} + +function ruleHasContent(rule: BucketLifecycleRule): boolean { + // Filter alone is not enough — need a transition or expiration. + return ( + rule.storageClass !== undefined || + rule.days !== undefined || + rule.date !== undefined || + rule.expiration !== undefined + ); +} + +/** + * Auto-matching a no-id update rule to a single existing rule is only safe + * when the update doesn't introduce a content kind (transition / expiration) + * that the existing rule lacks — otherwise we'd silently merge, e.g., a + * transition update into a TTL-only rule. Toggle/filter-only updates remain + * compatible with any existing rule. + */ +function rulesAreShapeCompatible( + update: BucketLifecycleRule, + existing: BucketLifecycleRule +): boolean { + const updateHasTransition = + update.storageClass !== undefined || + update.days !== undefined || + update.date !== undefined; + const existingHasTransition = + existing.storageClass !== undefined || + existing.days !== undefined || + existing.date !== undefined; + + if (updateHasTransition && !existingHasTransition) return false; + if (update.expiration !== undefined && existing.expiration === undefined) { + return false; + } + return true; +} + function formatTtlRule( ttl: BucketTtl, existing?: BucketTtl @@ -107,36 +232,80 @@ function formatTtlRule( }; } -function formatTransitionRule( +function formatLifecycleRule( rule: BucketLifecycleRule, existing?: BucketLifecycleRule ): LifecycleRuleBody { const enabled = rule.enabled ?? existing?.enabled ?? true; - const storageClass = rule.storageClass ?? existing?.storageClass; + const transition = resolveTransition(rule, existing); + const expiration = resolveExpiration(rule, existing, enabled); + const filter = rule.filter ?? existing?.filter; + return { id: existing?.id ?? rule.id ?? crypto.randomUUID(), - transitions: - storageClass !== undefined - ? [ - { - storage_class: storageClass, - ...(rule.days !== undefined - ? { days: rule.days } - : rule.date !== undefined - ? {} - : existing?.days !== undefined - ? { days: existing.days } - : undefined), - ...(rule.date !== undefined - ? { date: rule.date } - : rule.days !== undefined - ? {} - : existing?.date !== undefined - ? { date: existing.date } - : undefined), - }, - ] - : undefined, + ...(transition !== undefined ? { transitions: [transition] } : undefined), + ...(expiration !== undefined ? { expiration } : undefined), + ...(filter !== undefined + ? { filter: { prefix: filter.prefix } } + : undefined), status: toStatus(enabled), }; } + +function resolveTransition( + rule: BucketLifecycleRule, + existing?: BucketLifecycleRule +): TransitionBody | undefined { + const hasUpdate = + rule.storageClass !== undefined || + rule.days !== undefined || + rule.date !== undefined; + + if (hasUpdate) { + const storageClass = rule.storageClass ?? existing?.storageClass; + if (storageClass === undefined) { + return undefined; + } + return { + storage_class: storageClass, + ...(rule.days !== undefined + ? { days: rule.days } + : rule.date !== undefined + ? {} + : existing?.days !== undefined + ? { days: existing.days } + : undefined), + ...(rule.date !== undefined + ? { date: rule.date } + : rule.days !== undefined + ? {} + : existing?.date !== undefined + ? { date: existing.date } + : undefined), + }; + } + + // Toggle / filter-only / expiration-only update — preserve existing transition. + if (existing?.storageClass !== undefined) { + return { + storage_class: existing.storageClass, + ...(existing.days !== undefined ? { days: existing.days } : {}), + ...(existing.date !== undefined ? { date: existing.date } : {}), + }; + } + return undefined; +} + +function resolveExpiration( + rule: BucketLifecycleRule, + existing: BucketLifecycleRule | undefined, + enabled: boolean +): ExpirationBody | undefined { + const source = rule.expiration ?? existing?.expiration; + if (source === undefined) return undefined; + return { + ...(source.days !== undefined ? { days: source.days } : {}), + ...(source.date !== undefined ? { date: source.date } : {}), + enabled, + }; +} diff --git a/packages/storage/src/server.ts b/packages/storage/src/server.ts index 89d841f..63e6e93 100644 --- a/packages/storage/src/server.ts +++ b/packages/storage/src/server.ts @@ -45,6 +45,8 @@ export { } from './lib/bucket/snapshot'; export type { BucketCorsRule, + BucketLifecycleExpiration, + BucketLifecycleFilter, BucketLifecycleRule, BucketLocations, BucketMigration,