Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 54 additions & 10 deletions packages/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand All @@ -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

Expand All @@ -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.
Expand Down
54 changes: 30 additions & 24 deletions packages/storage/src/lib/bucket/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export type BucketInfoResponse = {
defaultTier: StorageClass;
lifecycleRules?: BucketLifecycleRule[];
dataMigration?: Omit<BucketMigration, 'enabled'>;
/**
* @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;
Expand Down Expand Up @@ -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<StorageClass, 'STANDARD'>
| 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,
};
Comment thread
cursor[bot] marked this conversation as resolved.
}) ?? [];

const data: BucketInfoResponse = {
isSnapshotEnabled: response.data.type === 1,
Expand Down Expand Up @@ -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,
Expand Down
130 changes: 87 additions & 43 deletions packages/storage/src/lib/bucket/set/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -76,20 +47,93 @@ 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 }
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
);

if (lifecycleError) {
return { error: lifecycleError };
}

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 ||
Comment thread
designcode marked this conversation as resolved.
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;
}
26 changes: 24 additions & 2 deletions packages/storage/src/lib/bucket/set/ttl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
Expand Down
Loading
Loading