From d7e7d513f30915212582dc2b5999541e9d8c0e68 Mon Sep 17 00:00:00 2001 From: Ari Dyckovsky Date: Sat, 8 Nov 2025 17:25:04 -0500 Subject: [PATCH 01/12] feat(core): add Time and ProcessInfo services - Add Time service with Clock capture pattern for testable timestamps - Add ProcessInfo service for Effect-first env/cwd access - Time.Default captures Clock once at construction to prevent leaking - Export service helpers (nowMillis, now, nowUtc, checkpointId, formatCheckpointId) - Schema.decodeUnknownSync pattern for DateTime.Utc creation without casting Amp-Thread-ID: https://ampcode.com/threads/T-a15b90b3-d708-46f1-a20d-188db13bc420 Co-authored-by: Amp --- packages/core/src/index.ts | 46 +++++ packages/core/src/services/ProcessInfo.ts | 52 ++++++ packages/core/src/services/Time.ts | 216 ++++++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 packages/core/src/services/ProcessInfo.ts create mode 100644 packages/core/src/services/Time.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bdeca51..4e8d395 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -389,6 +389,52 @@ export { RuleRunner, type RuleRunnerService } from "./services/RuleRunner.js" */ export { RuleRunnerLayer, RuleRunnerLive } from "./services/RuleRunner.js" +/** + * Time service for centralized time operations. + * + * Wraps Clock.Clock to provide consistent timestamps and TestClock compatibility. + * + * @example + * ```ts + * import { Time, TimeLive } from "@effect-migrate/core" + * + * const program = Effect.gen(function*() { + * const timestamp = yield* Time.now + * const checkpointId = yield* Time.checkpointId + * return { timestamp, checkpointId } + * }).pipe(Effect.provide(TimeLive)) + * ``` + */ +export { + checkpointId, + formatCheckpointId, + layerLive as TimeLive, + now, + nowMillis, + nowUtc, + Time +} from "./services/Time.js" + +/** + * ProcessInfo service for Effect-first access to process information. + * + * Provides safe, testable access to Node.js process globals (cwd, env, etc.) + * following Effect-first patterns. + * + * @example + * ```ts + * import { ProcessInfo, ProcessInfoLive } from "@effect-migrate/core" + * + * const program = Effect.gen(function*() { + * const processInfo = yield* ProcessInfo + * const cwd = yield* processInfo.cwd + * const ampThreadId = yield* processInfo.getEnv("AMP_CURRENT_THREAD_ID") + * return { cwd, ampThreadId } + * }).pipe(Effect.provide(ProcessInfoLive)) + * ``` + */ +export { ProcessInfo, ProcessInfoLive, type ProcessInfoService } from "./services/ProcessInfo.js" + // ============================================================================ // Amp Context Generation // ============================================================================ diff --git a/packages/core/src/services/ProcessInfo.ts b/packages/core/src/services/ProcessInfo.ts new file mode 100644 index 0000000..5d27703 --- /dev/null +++ b/packages/core/src/services/ProcessInfo.ts @@ -0,0 +1,52 @@ +/** + * ProcessInfo Service - Effect-first access to process information + * + * Provides safe, testable access to Node.js process globals (cwd, env, etc.) + * following Effect-first patterns used throughout the codebase. + * + * @module @effect-migrate/core/services/ProcessInfo + * @since 0.4.0 + */ + +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" + +/** + * ProcessInfo service interface + * + * Provides access to process information in an Effect-first way. + */ +export interface ProcessInfoService { + /** + * Get current working directory + */ + readonly cwd: Effect.Effect + + /** + * Get environment variable + */ + readonly getEnv: (key: string) => Effect.Effect + + /** + * Get all environment variables + */ + readonly getAllEnv: Effect.Effect> +} + +/** + * ProcessInfo service tag + */ +export class ProcessInfo extends Context.Tag("ProcessInfo")< + ProcessInfo, + ProcessInfoService +>() {} + +/** + * Live implementation using Node.js process globals + */ +export const ProcessInfoLive = Layer.succeed(ProcessInfo, { + cwd: Effect.sync(() => process.cwd()), + getEnv: (key: string) => Effect.sync(() => process.env[key]), + getAllEnv: Effect.sync(() => process.env as Record) +}) diff --git a/packages/core/src/services/Time.ts b/packages/core/src/services/Time.ts new file mode 100644 index 0000000..6120917 --- /dev/null +++ b/packages/core/src/services/Time.ts @@ -0,0 +1,216 @@ +/** + * Time Service - Centralized time abstraction for testability + * + * Wraps Clock service to provide: + * - Consistent timestamp generation (DateTime) + * - UTC zoned datetime for checkpoint IDs + * - Formatted checkpoint IDs (ISO with colons replaced) + * - TestClock compatibility for deterministic testing + * + * ## Design Pattern + * + * This service captures Clock ONCE during layer construction (like FileDiscovery + * captures FileSystem), then returns methods that close over the captured Clock. + * This prevents Clock from leaking into consumer type requirements. + * + * ## Usage + * + * ```typescript + * import { Time } from "@effect-migrate/core" + * + * const program = Effect.gen(function* () { + * const timestamp = yield* Time.now + * const checkpointId = yield* Time.checkpointId + * // ... + * }) + * ``` + * + * ## Testing + * + * ```typescript + * import { Time } from "@effect-migrate/core" + * import * as TestClock from "effect/TestClock" + * + * it.effect("should use controlled time", () => + * Effect.gen(function* () { + * yield* TestClock.adjust("1 seconds") + * const ts = yield* Time.now + * // ... + * }).pipe(Effect.provide(Time.Default)) + * ) + * ``` + * + * @module @effect-migrate/core/services/Time + * @since 0.5.0 + */ + +import * as Clock from "effect/Clock" +import * as DateTime from "effect/DateTime" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" + +/** + * Time service interface. + * + * Provides time-related operations that can be tested with TestClock. + * Methods return Effects with no additional requirements beyond Time. + * + * @category Service + * @since 0.3.0 + */ +export interface TimeService { + /** + * Get current time in milliseconds since epoch. + */ + readonly nowMillis: Effect.Effect + + /** + * Get current time as DateTime (generic, not timezone-aware). + */ + readonly now: Effect.Effect + + /** + * Get current time as UTC DateTime. + * + * Returns DateTimeUtc compatible with Schema.DateTimeUtc. + */ + readonly nowUtc: Effect.Effect + + /** + * Generate checkpoint ID from current UTC time. + * + * Format: ISO string with colons replaced by hyphens. + * Example: "2025-11-08T15-30-45.123Z" + */ + readonly checkpointId: Effect.Effect + + /** + * Format DateTime as checkpoint ID. + * + * Pure function for reuse in testing or manual formatting. + */ + readonly formatCheckpointId: (dt: DateTime.DateTime) => string +} + +/** + * Format DateTime as checkpoint ID. + * + * Converts ISO string to checkpoint-safe format by replacing colons. + * + * @param dt - DateTime to format + * @returns Checkpoint ID string (ISO with colons replaced) + * + * @category Pure Function + * @since 0.3.0 + * + * @example + * ```typescript + * const dt = DateTime.unsafeMake(Date.now()) + * const id = formatCheckpointId(dt) + * // => "2025-11-08T15-30-45.123Z" + * ``` + */ +export const formatCheckpointId = (dt: DateTime.DateTime): string => + DateTime.formatIso(dt).replace(/:/g, "-") + +/** + * Time service tag. + * + * Uses Effect.Service pattern for clean dependency injection. + * Clock dependency is captured ONCE during layer construction and + * does NOT leak to consumers. + * + * @category Service + * @since 0.3.0 + */ +export class Time extends Effect.Service