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
9 changes: 7 additions & 2 deletions src/domain/data/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
DataMetadataSchema,
type GarbageCollectionPolicy,
type Lifetime,
normalizeLifetime,
type OwnerDefinition,
} from "./data_metadata.ts";

Expand Down Expand Up @@ -85,13 +86,14 @@ export class Data {
const id = props.id ?? generateDataId();
const version = props.version ?? 1;
const createdAt = props.createdAt ?? new Date();
const lifetime = normalizeLifetime(props.lifetime);

const validated = DataMetadataSchema.parse({
id,
name: props.name,
version,
contentType: props.contentType,
lifetime: props.lifetime,
lifetime,
garbageCollection: props.garbageCollection,
streaming: props.streaming ?? false,
tags: props.tags,
Expand Down Expand Up @@ -125,7 +127,10 @@ export class Data {
* @throws ZodError if validation fails
*/
static fromData(data: DataMetadata): Data {
const validated = DataMetadataSchema.parse(data);
const validated = DataMetadataSchema.parse({
...data,
lifetime: normalizeLifetime(data.lifetime),
});
return new Data(
createDataId(validated.id),
validated.name,
Expand Down
88 changes: 88 additions & 0 deletions src/domain/data/data_lifecycle_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,94 @@ function createMockData(overrides: {
});
}

// --- Zero-duration lifetime normalization via Data.create ---

Deno.test("calculateExpiration - Data.create with '0h' produces workflow lifetime (null expiration)", () => {
const service = new DefaultDataLifecycleService(
new MockDataRepository() as never,
new MockWorkflowRunRepository() as never,
);

// Data.create normalizes "0h" to "workflow"
const data = createMockData({ name: "zero-hours", lifetime: "0h" });
assertEquals(data.lifetime, "workflow");

const createdAt = new Date("2025-01-01T00:00:00Z");
const expiration = service.calculateExpiration(data.lifetime, createdAt);
assertEquals(expiration, null);
});

Deno.test("calculateExpiration - Data.create with '0d' produces workflow lifetime (null expiration)", () => {
const service = new DefaultDataLifecycleService(
new MockDataRepository() as never,
new MockWorkflowRunRepository() as never,
);

const data = createMockData({ name: "zero-days", lifetime: "0d" });
assertEquals(data.lifetime, "workflow");

const createdAt = new Date("2025-01-01T00:00:00Z");
const expiration = service.calculateExpiration(data.lifetime, createdAt);
assertEquals(expiration, null);
});

Deno.test("calculateExpiration - Data.create with '0mo' produces workflow lifetime (null expiration)", () => {
const service = new DefaultDataLifecycleService(
new MockDataRepository() as never,
new MockWorkflowRunRepository() as never,
);

const data = createMockData({ name: "zero-months", lifetime: "0mo" });
assertEquals(data.lifetime, "workflow");

const createdAt = new Date("2025-01-01T00:00:00Z");
const expiration = service.calculateExpiration(data.lifetime, createdAt);
assertEquals(expiration, null);
});

Deno.test("calculateExpiration - Data.create with '0y' produces workflow lifetime (null expiration)", () => {
const service = new DefaultDataLifecycleService(
new MockDataRepository() as never,
new MockWorkflowRunRepository() as never,
);

const data = createMockData({ name: "zero-years", lifetime: "0y" });
assertEquals(data.lifetime, "workflow");

const createdAt = new Date("2025-01-01T00:00:00Z");
const expiration = service.calculateExpiration(data.lifetime, createdAt);
assertEquals(expiration, null);
});

Deno.test("calculateExpiration - Data.create with '00w' produces workflow lifetime (null expiration)", () => {
const service = new DefaultDataLifecycleService(
new MockDataRepository() as never,
new MockWorkflowRunRepository() as never,
);

const data = createMockData({ name: "zero-weeks", lifetime: "00w" });
assertEquals(data.lifetime, "workflow");

const createdAt = new Date("2025-01-01T00:00:00Z");
const expiration = service.calculateExpiration(data.lifetime, createdAt);
assertEquals(expiration, null);
});

Deno.test("calculateExpiration - non-zero durations still produce correct expiration", () => {
const service = new DefaultDataLifecycleService(
new MockDataRepository() as never,
new MockWorkflowRunRepository() as never,
);

// Verify non-zero is unaffected
const data = createMockData({ name: "valid-hours", lifetime: "2h" });
assertEquals(data.lifetime, "2h");

const createdAt = new Date("2025-01-01T00:00:00Z");
const expiration = service.calculateExpiration(data.lifetime, createdAt);
assertEquals(expiration, new Date("2025-01-01T02:00:00Z"));
});

Deno.test("findExpiredData - finds expired data for nested model types", async () => {
const mockRepo = new MockDataRepository();
const expiredData = createMockData({ name: "vpc-data" });
Expand Down
26 changes: 26 additions & 0 deletions src/domain/data/data_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,37 @@ export const GarbageCollectionSchema = z.union([
z.string().regex(/^\d+(mo|y|h|m|d|w)$/, {
message:
"Duration must match pattern like '1h', '5m', '10d', '2w', '1mo', '10y'",
}).refine((val) => {
const match = val.match(/^(\d+)/);
return match !== null && parseInt(match[1], 10) > 0;
}, {
message: "Garbage collection duration must be greater than zero",
}),
]);

export type GarbageCollectionPolicy = z.infer<typeof GarbageCollectionSchema>;

/**
* Normalizes zero-duration lifetime strings to "workflow".
*
* Zero-duration strings like "0h", "0d", "00w" produce 0ms when parsed,
* which would cause data to expire immediately on creation. Instead,
* we treat them as "workflow" lifetime — the data lives for the
* duration of the workflow run.
*
* @param lifetime - The lifetime value to normalize
* @returns The normalized lifetime (zero durations become "workflow")
*/
export function normalizeLifetime(lifetime: Lifetime): Lifetime {
if (typeof lifetime === "string") {
const match = lifetime.match(/^(\d+)(mo|y|h|m|d|w)$/);
if (match && parseInt(match[1], 10) === 0) {
return "workflow";
}
}
return lifetime;
}

/**
* Owner types that can create data.
*/
Expand Down
229 changes: 229 additions & 0 deletions src/domain/data/data_metadata_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Swamp, an Automation Framework
// Copyright (C) 2026 System Initiative, Inc.
//
// This file is part of Swamp.
//
// Swamp is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation, with the Swamp
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
// file).
//
// Swamp is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { assertEquals } from "@std/assert";
import {
GarbageCollectionSchema,
type Lifetime,
normalizeLifetime,
} from "./data_metadata.ts";

// --- normalizeLifetime: zero-duration strings become "workflow" ---

Deno.test("normalizeLifetime - converts '0h' to 'workflow'", () => {
assertEquals(normalizeLifetime("0h"), "workflow");
});

Deno.test("normalizeLifetime - converts '0m' to 'workflow'", () => {
assertEquals(normalizeLifetime("0m"), "workflow");
});

Deno.test("normalizeLifetime - converts '0d' to 'workflow'", () => {
assertEquals(normalizeLifetime("0d"), "workflow");
});

Deno.test("normalizeLifetime - converts '0w' to 'workflow'", () => {
assertEquals(normalizeLifetime("0w"), "workflow");
});

Deno.test("normalizeLifetime - converts '0mo' to 'workflow'", () => {
assertEquals(normalizeLifetime("0mo"), "workflow");
});

Deno.test("normalizeLifetime - converts '0y' to 'workflow'", () => {
assertEquals(normalizeLifetime("0y"), "workflow");
});

// --- normalizeLifetime: leading zeros that still equal zero ---

Deno.test("normalizeLifetime - converts '00d' to 'workflow'", () => {
assertEquals(normalizeLifetime("00d"), "workflow");
});

Deno.test("normalizeLifetime - converts '000h' to 'workflow'", () => {
assertEquals(normalizeLifetime("000h"), "workflow");
});

Deno.test("normalizeLifetime - converts '00mo' to 'workflow'", () => {
assertEquals(normalizeLifetime("00mo"), "workflow");
});

Deno.test("normalizeLifetime - converts '0000w' to 'workflow'", () => {
assertEquals(normalizeLifetime("0000w"), "workflow");
});

// --- normalizeLifetime: non-zero durations pass through unchanged ---

Deno.test("normalizeLifetime - passes through '1h' unchanged", () => {
assertEquals(normalizeLifetime("1h"), "1h");
});

Deno.test("normalizeLifetime - passes through '5m' unchanged", () => {
assertEquals(normalizeLifetime("5m"), "5m");
});

Deno.test("normalizeLifetime - passes through '10d' unchanged", () => {
assertEquals(normalizeLifetime("10d"), "10d");
});

Deno.test("normalizeLifetime - passes through '2w' unchanged", () => {
assertEquals(normalizeLifetime("2w"), "2w");
});

Deno.test("normalizeLifetime - passes through '1mo' unchanged", () => {
assertEquals(normalizeLifetime("1mo"), "1mo");
});

Deno.test("normalizeLifetime - passes through '10y' unchanged", () => {
assertEquals(normalizeLifetime("10y"), "10y");
});

Deno.test("normalizeLifetime - passes through '24h' unchanged", () => {
assertEquals(normalizeLifetime("24h"), "24h");
});

Deno.test("normalizeLifetime - passes through '100d' unchanged", () => {
assertEquals(normalizeLifetime("100d"), "100d");
});

// --- normalizeLifetime: leading zeros on non-zero values pass through ---

Deno.test("normalizeLifetime - passes through '01d' unchanged (value is 1)", () => {
assertEquals(normalizeLifetime("01d" as Lifetime), "01d");
});

Deno.test("normalizeLifetime - passes through '007h' unchanged (value is 7)", () => {
assertEquals(normalizeLifetime("007h" as Lifetime), "007h");
});

// --- normalizeLifetime: special lifetime values pass through unchanged ---

Deno.test("normalizeLifetime - passes through 'ephemeral' unchanged", () => {
assertEquals(normalizeLifetime("ephemeral"), "ephemeral");
});

Deno.test("normalizeLifetime - passes through 'infinite' unchanged", () => {
assertEquals(normalizeLifetime("infinite"), "infinite");
});

Deno.test("normalizeLifetime - passes through 'job' unchanged", () => {
assertEquals(normalizeLifetime("job"), "job");
});

Deno.test("normalizeLifetime - passes through 'workflow' unchanged", () => {
assertEquals(normalizeLifetime("workflow"), "workflow");
});

// --- GarbageCollectionSchema: rejects zero-duration strings ---

Deno.test("GarbageCollectionSchema - rejects '0h'", () => {
const result = GarbageCollectionSchema.safeParse("0h");
assertEquals(result.success, false);
});

Deno.test("GarbageCollectionSchema - rejects '0d'", () => {
const result = GarbageCollectionSchema.safeParse("0d");
assertEquals(result.success, false);
});

Deno.test("GarbageCollectionSchema - rejects '0w'", () => {
const result = GarbageCollectionSchema.safeParse("0w");
assertEquals(result.success, false);
});

Deno.test("GarbageCollectionSchema - rejects '0m'", () => {
const result = GarbageCollectionSchema.safeParse("0m");
assertEquals(result.success, false);
});

Deno.test("GarbageCollectionSchema - rejects '0mo'", () => {
const result = GarbageCollectionSchema.safeParse("0mo");
assertEquals(result.success, false);
});

Deno.test("GarbageCollectionSchema - rejects '0y'", () => {
const result = GarbageCollectionSchema.safeParse("0y");
assertEquals(result.success, false);
});

Deno.test("GarbageCollectionSchema - rejects '00d'", () => {
const result = GarbageCollectionSchema.safeParse("00d");
assertEquals(result.success, false);
});

Deno.test("GarbageCollectionSchema - rejects '000w'", () => {
const result = GarbageCollectionSchema.safeParse("000w");
assertEquals(result.success, false);
});

// --- GarbageCollectionSchema: rejects zero as a number ---

Deno.test("GarbageCollectionSchema - rejects 0 (number)", () => {
const result = GarbageCollectionSchema.safeParse(0);
assertEquals(result.success, false);
});

Deno.test("GarbageCollectionSchema - rejects negative numbers", () => {
const result = GarbageCollectionSchema.safeParse(-1);
assertEquals(result.success, false);
});

// --- GarbageCollectionSchema: accepts valid values ---

Deno.test("GarbageCollectionSchema - accepts positive integer", () => {
const result = GarbageCollectionSchema.safeParse(5);
assertEquals(result.success, true);
});

Deno.test("GarbageCollectionSchema - accepts '1h'", () => {
const result = GarbageCollectionSchema.safeParse("1h");
assertEquals(result.success, true);
});

Deno.test("GarbageCollectionSchema - accepts '30d'", () => {
const result = GarbageCollectionSchema.safeParse("30d");
assertEquals(result.success, true);
});

Deno.test("GarbageCollectionSchema - accepts '2w'", () => {
const result = GarbageCollectionSchema.safeParse("2w");
assertEquals(result.success, true);
});

Deno.test("GarbageCollectionSchema - accepts '1mo'", () => {
const result = GarbageCollectionSchema.safeParse("1mo");
assertEquals(result.success, true);
});

Deno.test("GarbageCollectionSchema - accepts '10y'", () => {
const result = GarbageCollectionSchema.safeParse("10y");
assertEquals(result.success, true);
});

// --- GarbageCollectionSchema: accepts leading-zero non-zero values ---

Deno.test("GarbageCollectionSchema - accepts '01d' (leading zero, value is 1)", () => {
const result = GarbageCollectionSchema.safeParse("01d");
assertEquals(result.success, true);
});

Deno.test("GarbageCollectionSchema - accepts '007h' (leading zeros, value is 7)", () => {
const result = GarbageCollectionSchema.safeParse("007h");
assertEquals(result.success, true);
});
Loading