@mastra/core
Version:
1 lines • 101 kB
Source Map (JSON)
{"version":3,"file":"filesystem-versioned-8Ag_Np8l.cjs","names":["MastraBase","MastraBase","#runInit","StorageDomain"],"sources":["../src/storage/domains/thread-state/base.ts","../src/storage/domains/thread-state/inmemory.ts","../src/storage/base.ts","../src/storage/domains/versioned.ts","../src/storage/git-history.ts","../src/storage/source-control.ts","../src/storage/filesystem-versioned.ts"],"sourcesContent":["import { MastraBase } from '../../../base';\nimport type { PruneOptions, PruneResult, RetentionTablesDescriptor, TableRetentionPolicy } from '../../retention';\n\n/**\n * A single task in an agent's structured task list.\n *\n * Mirrors the task shape used by the built-in task tools. Kept as a plain,\n * self-contained type so the storage domain does not depend on the tools\n * package.\n */\nexport interface TaskRecord {\n id: string;\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n activeForm: string;\n}\n\n/**\n * A durable goal objective for an agent thread.\n *\n * Stored in the thread-state domain under `type: 'goal'`. The objective drives\n * the in-loop goal scorer (the agent keeps working until the goal is judged\n * complete or the run budget is exhausted). Goal settings are optional: when\n * absent here they fall back to the Agent's `goal` config at read time, so an\n * objective only persists the settings a caller explicitly provided.\n * `activeDurationMs` is persisted accounting data rather than a goal setting;\n * when absent, consumers treat it as zero. `judgeModelId` is required at runtime\n * for the goal to do anything — when neither this record nor the Agent's `goal.judge`\n * resolves a judge model, the goal step is a no-op.\n */\nexport interface GoalObjectiveRecord {\n /** Stable objective id, used for per-goal judge memory and UI correlation. */\n id?: string;\n /** The prose objective the agent is working toward. */\n objective: string;\n status: 'active' | 'paused' | 'done';\n /** Number of goal evaluations consumed so far. */\n runsUsed: number;\n /** Accumulated active-pursuit time in milliseconds. Missing values represent zero. */\n activeDurationMs?: number;\n /** Max evaluations before the goal stops. Falls back to agent `goal.maxRuns` (default 50). */\n maxRuns?: number;\n /** Judge model id. Falls back to agent `goal.judge`; if neither resolves the goal is a no-op. */\n judgeModelId?: string;\n /** Extra judge guidance. Falls back to agent `goal.prompt` (default = built-in goal judge prompt). */\n prompt?: string;\n /**\n * Why the objective is parked (`status === 'paused'`). Set for judge failure\n * or budget exhaustion. Unset for `active`/`done`.\n */\n pausedReason?: string;\n startedAt: number;\n updatedAt: number;\n}\n\n/**\n * Abstract base class for the thread-state storage domain.\n *\n * The thread-state domain holds arbitrary, durable, per-thread state keyed by a\n * `type` namespace. Each `(threadId, type)` pair owns one value. Today the only\n * types are `'task'` (the structured task list managed by the built-in task\n * tools) and `'goal'` (the durable {@link GoalObjectiveRecord} that drives the\n * in-loop goal scorer). The domain is intentionally generic so other\n * agent-scoped state can be tracked the same way without a new domain.\n *\n * The built-in task tools read/write the `'task'` slot synchronously within a\n * run (so a `task_update` sees the tasks a prior `task_write` produced), and the\n * task state processor reads it to project the list onto the agent state-signal\n * lane.\n */\nexport abstract class ThreadStateStorage extends MastraBase {\n /**\n * Declares which of this domain's tables are eligible for age-based retention.\n * Adapters that support retention override this; the default is empty.\n */\n static readonly retentionTables: RetentionTablesDescriptor = {};\n\n constructor() {\n super({\n component: 'STORAGE',\n name: 'THREAD_STATE',\n });\n }\n\n /**\n * Delete rows older than each policy's `maxAge`, batched, bounded, and\n * cancellable. Default implementation is a no-op (retention not supported).\n */\n async prune(_policies: Record<string, TableRetentionPolicy>, _options?: PruneOptions): Promise<PruneResult[]> {\n return [];\n }\n\n /**\n * Initialize the thread-state store (create tables, indexes, etc).\n */\n abstract init(): Promise<void>;\n\n /**\n * Get the state value for a `(threadId, type)` pair. Returns `undefined` when\n * no value has been set.\n */\n abstract getState<T = unknown>(args: { threadId: string; type: string }): Promise<T | undefined>;\n\n /**\n * Set the state value for a `(threadId, type)` pair. Full-replacement\n * semantics: the stored value becomes exactly `value`.\n */\n abstract setState<T = unknown>(args: { threadId: string; type: string; value: T }): Promise<void>;\n\n /**\n * Delete the state value for a `(threadId, type)` pair.\n */\n abstract deleteState(args: { threadId: string; type: string }): Promise<void>;\n\n /**\n * Delete all thread state. Used for testing.\n */\n abstract dangerouslyClearAll(): Promise<void>;\n}\n","import { ThreadStateStorage } from './base';\n\nfunction clone<T>(value: T): T {\n return value === undefined ? value : (structuredClone(value) as T);\n}\n\n/**\n * In-memory implementation of {@link ThreadStateStorage}.\n *\n * Holds each thread's state in a `Map<threadId, Map<type, value>>`. Stored\n * values are cloned on read and write so callers cannot mutate the backing\n * value.\n *\n * This is the default thread-state store wired by the composite store: task\n * tracking works out of the box without a configured backend. It is **not**\n * durable across process restarts — configure a durable backend (e.g.\n * `@mastra/libsql`) for state that must survive a restart.\n */\nexport class InMemoryThreadStateStorage extends ThreadStateStorage {\n private readonly stateByThread = new Map<string, Map<string, unknown>>();\n\n async init(): Promise<void> {\n // No-op for in-memory store.\n }\n\n async getState<T = unknown>({ threadId, type }: { threadId: string; type: string }): Promise<T | undefined> {\n const value = this.stateByThread.get(threadId)?.get(type);\n return value === undefined ? undefined : clone(value as T);\n }\n\n async setState<T = unknown>({ threadId, type, value }: { threadId: string; type: string; value: T }): Promise<void> {\n let byType = this.stateByThread.get(threadId);\n if (!byType) {\n byType = new Map<string, unknown>();\n this.stateByThread.set(threadId, byType);\n }\n byType.set(type, clone(value));\n }\n\n async deleteState({ threadId, type }: { threadId: string; type: string }): Promise<void> {\n const byType = this.stateByThread.get(threadId);\n if (!byType) return;\n byType.delete(type);\n if (byType.size === 0) this.stateByThread.delete(threadId);\n }\n\n async dangerouslyClearAll(): Promise<void> {\n this.stateByThread.clear();\n }\n}\n","import { MastraBase } from '../base';\n\nimport type {\n AgentsStorage,\n PromptBlocksStorage,\n ScorerDefinitionsStorage,\n MCPClientsStorage,\n MCPServersStorage,\n WorkspacesStorage,\n SkillsStorage,\n FavoritesStorage,\n ScoresStorage,\n WorkflowsStorage,\n MemoryStorage,\n ObservabilityStorage,\n BlobStore,\n DatasetsStorage,\n ExperimentsStorage,\n BackgroundTasksStorage,\n SchedulesStorage,\n ChannelsStorage,\n HarnessStorage,\n ToolProviderConnectionsStorage,\n NotificationsStorage,\n ThreadStateStorage,\n WorkflowDefinitionsStorage,\n} from './domains';\nimport { InMemoryThreadStateStorage } from './domains/thread-state/inmemory';\nimport type { PruneOptions, PruneResult, RetentionConfig, TableRetentionPolicy } from './retention';\n\n/** Map of all storage domain interfaces available in a composite store. */\nexport type StorageDomains = {\n workflows?: WorkflowsStorage;\n workflowDefinitions?: WorkflowDefinitionsStorage;\n scores?: ScoresStorage;\n memory?: MemoryStorage;\n channels?: ChannelsStorage;\n notifications?: NotificationsStorage;\n observability?: ObservabilityStorage;\n agents?: AgentsStorage;\n datasets?: DatasetsStorage;\n experiments?: ExperimentsStorage;\n promptBlocks?: PromptBlocksStorage;\n scorerDefinitions?: ScorerDefinitionsStorage;\n mcpClients?: MCPClientsStorage;\n mcpServers?: MCPServersStorage;\n workspaces?: WorkspacesStorage;\n skills?: SkillsStorage;\n favorites?: FavoritesStorage;\n blobs?: BlobStore;\n backgroundTasks?: BackgroundTasksStorage;\n schedules?: SchedulesStorage;\n harness?: HarnessStorage;\n toolProviderConnections?: ToolProviderConnectionsStorage;\n threadState?: ThreadStateStorage;\n};\n\n/**\n * Domain keys used by the Mastra Editor.\n * Used by the `editor` shorthand on MastraCompositeStoreConfig to route\n * all editor-related domains to a single store.\n */\nexport const EDITOR_DOMAINS = [\n 'agents',\n 'promptBlocks',\n 'scorerDefinitions',\n 'mcpClients',\n 'mcpServers',\n 'workspaces',\n 'skills',\n 'favorites',\n 'toolProviderConnections',\n] as const satisfies ReadonlyArray<keyof StorageDomains>;\n\n/**\n * Normalizes perPage input for pagination queries.\n *\n * @param perPageInput - The raw perPage value from the user\n * @param defaultValue - The default perPage value to use when undefined (typically 40 for messages, 100 for threads)\n * @returns A numeric perPage value suitable for queries (false becomes MAX_SAFE_INTEGER)\n * @throws Error if perPage is a negative number\n */\nexport function normalizePerPage(perPageInput: number | false | undefined, defaultValue: number): number {\n if (perPageInput === false) {\n return Number.MAX_SAFE_INTEGER; // Get all results\n } else if (perPageInput === 0) {\n return 0; // Return zero results\n } else if (typeof perPageInput === 'number' && perPageInput > 0) {\n return perPageInput; // Valid positive number\n } else if (typeof perPageInput === 'number' && perPageInput < 0) {\n throw new Error('perPage must be >= 0');\n }\n // For undefined, use default\n return defaultValue;\n}\n\n/**\n * Calculates pagination offset and prepares perPage value for response.\n * When perPage is false (fetch all), offset is always 0 regardless of page.\n *\n * @param page - The page number (0-indexed)\n * @param perPageInput - The original perPage input (number, false for all, or undefined)\n * @param normalizedPerPage - The normalized perPage value (from normalizePerPage)\n * @returns Object with offset for query and perPage for response\n */\nexport function calculatePagination(\n page: number,\n perPageInput: number | false | undefined,\n normalizedPerPage: number,\n): { offset: number; perPage: number | false } {\n return {\n offset: perPageInput === false ? 0 : page * normalizedPerPage,\n perPage: perPageInput === false ? false : normalizedPerPage,\n };\n}\n\n/**\n * Configuration for individual domain overrides.\n * Each domain can be sourced from a different storage adapter.\n *\n * Set a domain to `false` to disable it entirely: the domain resolves to\n * `undefined` instead of falling back to the `editor`/`default` stores, so\n * nothing can read from or write to it through this composite.\n */\nexport type MastraStorageDomains = {\n [K in keyof StorageDomains]?: StorageDomains[K] | false;\n};\n\n/**\n * Configuration options for MastraCompositeStore.\n *\n * Can be used in two ways:\n * 1. By store implementations: `{ id, name, disableInit? }` - stores set `this.stores` directly\n * 2. For composition: `{ id, default?, domains?, disableInit? }` - compose domains from multiple stores\n */\nexport interface MastraCompositeStoreConfig {\n /**\n * Unique identifier for this storage instance.\n */\n id: string;\n\n /**\n * Name of the storage adapter (used for logging).\n * Required for store implementations extending MastraCompositeStore.\n */\n name?: string;\n\n /**\n * Default storage adapter to use for domains not explicitly specified.\n * If provided, domains from this storage will be used as fallbacks.\n */\n default?: MastraCompositeStore;\n\n /**\n * Storage adapter for editor-related domains (agents, promptBlocks, scorerDefinitions,\n * mcpClients, mcpServers, workspaces, skills).\n *\n * This is a shorthand that routes all editor domains to a single store instead of\n * specifying each individually in `domains`. Useful for filesystem-based storage\n * where editor configs are stored as JSON files in the repository.\n *\n * Priority: domains > editor > default\n *\n * @example\n * ```typescript\n * new MastraCompositeStore({\n * id: 'my-store',\n * default: postgresStore,\n * editor: filesystemStore,\n * })\n * ```\n */\n editor?: MastraCompositeStore;\n\n /**\n * Individual domain overrides. Each domain can come from a different storage adapter.\n * These take precedence over both `editor` and `default` storage.\n *\n * @example\n * ```typescript\n * domains: {\n * memory: pgStore.stores?.memory,\n * workflows: libsqlStore.stores?.workflows,\n * }\n * ```\n */\n domains?: MastraStorageDomains;\n\n /**\n * When true, automatic initialization (table creation/migrations) is disabled.\n * This is useful for CI/CD pipelines where you want to:\n * 1. Run migrations explicitly during deployment (not at runtime)\n * 2. Use different credentials for schema changes vs runtime operations\n *\n * When disableInit is true:\n * - The storage will not automatically create/alter tables on first use\n * - You must call `storage.init()` explicitly in your CI/CD scripts\n *\n * @example\n * // In CI/CD script:\n * const storage = new PostgresStore({ ...config, disableInit: false });\n * await storage.init(); // Explicitly run migrations\n *\n * // In runtime application:\n * const storage = new PostgresStore({ ...config, disableInit: true });\n * // No auto-init, tables must already exist\n */\n disableInit?: boolean;\n\n /**\n * Opt-in, table-granular, age-based retention policies.\n *\n * Declare per-domain, per-table `maxAge` policies; call `storage.prune()`\n * to delete rows older than their configured age. Anything left unset is\n * kept forever (no behavior change by default).\n *\n * @example\n * ```typescript\n * retention: {\n * memory: {\n * messages: { maxAge: '30d' },\n * threads: { maxAge: '90d' },\n * },\n * observability: {\n * spans: { maxAge: '7d' },\n * },\n * }\n * ```\n */\n retention?: RetentionConfig;\n}\n\n/**\n * Base class for all Mastra storage adapters.\n *\n * Can be used in two ways:\n *\n * 1. **Extended by store implementations** (PostgresStore, LibSQLStore, etc.):\n * Store implementations extend this class and set `this.stores` with their domain implementations.\n *\n * 2. **Directly instantiated for composition**:\n * Compose domains from multiple storage backends using `default` and `domains` options.\n *\n * All domain-specific operations should be accessed through `getStore()`:\n *\n * @example\n * ```typescript\n * // Composition: mix domains from different stores\n * const storage = new MastraCompositeStore({\n * id: 'composite',\n * default: pgStore,\n * domains: {\n * memory: libsqlStore.stores?.memory,\n * },\n * });\n *\n * // Use `editor` shorthand to route all editor domains to a filesystem store\n * const storage2 = new MastraCompositeStore({\n * id: 'with-fs-editor',\n * default: pgStore,\n * editor: filesystemStore,\n * });\n *\n * // Access domains\n * const memory = await storage.getStore('memory');\n * await memory?.saveThread({ thread });\n * ```\n */\n/**\n * Minimal interface a storage adapter sees from the Mastra instance.\n * Kept narrow on purpose to avoid pulling the full Mastra type into the\n * storage layer (which would create a circular import).\n */\nexport interface StorageMastraRef {\n getAgentById?: (id: string) => { source?: string; __getEditorConfig?: () => unknown } | undefined;\n listAgents?: () => Record<string, { id: string; source?: string; __getEditorConfig?: () => unknown }> | undefined;\n getEditor?: () => { getSource?: () => 'code' | 'db' | undefined } | undefined;\n}\n\n/** A domain that implements the age-based retention `prune()` contract. */\ninterface PruneCapable {\n prune(policies: Record<string, TableRetentionPolicy>, options?: PruneOptions): Promise<PruneResult[]>;\n}\n\nfunction isPruneCapable(value: unknown): value is PruneCapable {\n return typeof value === 'object' && value !== null && typeof (value as PruneCapable).prune === 'function';\n}\n\nexport class MastraCompositeStore extends MastraBase {\n protected hasInitialized: null | Promise<boolean> = null;\n protected shouldCacheInit = true;\n\n id: string;\n stores?: StorageDomains;\n protected mastra?: StorageMastraRef;\n\n /**\n * When true, automatic initialization (table creation/migrations) is disabled.\n */\n disableInit: boolean = false;\n\n /**\n * Opt-in, table-granular, age-based retention policies. Consumed by\n * `prune()`. Undefined means nothing is pruned (keep forever).\n */\n protected retention?: RetentionConfig;\n\n /**\n * Retained references to the parent stores supplied via composition. `init()`\n * delegates to these so the parent's own `init()` logic (pragmas, ordered\n * DDL, init coalescing, etc.) runs instead of being bypassed by the\n * composite iterating the inner domains in parallel — which was the cause\n * of the SQLITE_BUSY / \"no such table\" races reported in issue #16782.\n */\n protected parentDefault?: MastraCompositeStore;\n protected parentEditor?: MastraCompositeStore;\n\n constructor(config: MastraCompositeStoreConfig) {\n const name = config.name ?? 'MastraCompositeStore';\n\n if (!config.id || typeof config.id !== 'string' || config.id.trim() === '') {\n throw new Error(`${name}: id must be provided and cannot be empty.`);\n }\n\n super({\n component: 'STORAGE',\n name,\n });\n\n this.id = config.id;\n this.disableInit = config.disableInit ?? false;\n this.retention = config.retention;\n\n // If composition config is provided (default, editor, or domains), compose the stores\n if (config.default || config.editor || config.domains) {\n const defaultStores = config.default?.stores;\n const editorStores = config.editor?.stores;\n const domainOverrides = config.domains ?? {};\n\n // Retain the parent store refs so init() can delegate to their own\n // init() — see field doc above and init() below.\n this.parentDefault = config.default;\n this.parentEditor = config.editor;\n\n // Validate that at least one storage source is provided (a `false`\n // override disables a domain, so it doesn't count as a source)\n const hasDefaultDomains = defaultStores && Object.values(defaultStores).some(v => v !== undefined);\n const hasEditorDomains = editorStores && Object.values(editorStores).some(v => v !== undefined);\n const hasOverrideDomains = Object.values(domainOverrides).some(v => v !== undefined && v !== false);\n\n if (!hasDefaultDomains && !hasEditorDomains && !hasOverrideDomains) {\n throw new Error(\n 'MastraCompositeStore requires at least one storage source. Provide a default storage, an editor storage, or domain overrides.',\n );\n }\n\n const editorDomainSet = new Set<string>(EDITOR_DOMAINS);\n\n // Helper: resolve a domain with priority: domains > editor (for editor domains) > default.\n // A `false` override disables the domain — it resolves to undefined\n // instead of falling through to the editor/default stores.\n const resolve = <K extends keyof StorageDomains>(key: K): StorageDomains[K] | undefined => {\n const override: StorageDomains[K] | false | undefined = domainOverrides[key];\n if (override === false) return undefined;\n if (override !== undefined) return override;\n if (editorDomainSet.has(key) && editorStores?.[key] !== undefined) return editorStores[key];\n return defaultStores?.[key];\n };\n\n // Build the composed stores object\n this.stores = {\n memory: resolve('memory'),\n workflows: resolve('workflows'),\n workflowDefinitions: resolve('workflowDefinitions'),\n scores: resolve('scores'),\n observability: resolve('observability'),\n agents: resolve('agents'),\n datasets: resolve('datasets'),\n experiments: resolve('experiments'),\n promptBlocks: resolve('promptBlocks'),\n scorerDefinitions: resolve('scorerDefinitions'),\n mcpClients: resolve('mcpClients'),\n mcpServers: resolve('mcpServers'),\n workspaces: resolve('workspaces'),\n skills: resolve('skills'),\n favorites: resolve('favorites'),\n blobs: resolve('blobs'),\n backgroundTasks: resolve('backgroundTasks'),\n schedules: resolve('schedules'),\n channels: resolve('channels'),\n harness: resolve('harness'),\n toolProviderConnections: resolve('toolProviderConnections'),\n notifications: resolve('notifications'),\n // The thread-state domain always has an in-memory store wired by default\n // so the built-in task tools work out of the box without a configured\n // backend. Configure a durable backend for state that must survive a\n // process restart. An explicit `false` override still disables the\n // domain entirely — the in-memory fallback only applies when the\n // domain is left unset.\n threadState:\n domainOverrides.threadState === false\n ? undefined\n : (resolve('threadState') ?? new InMemoryThreadStateStorage()),\n } as StorageDomains;\n }\n // Otherwise, subclasses set stores themselves\n }\n\n /**\n * Register the Mastra instance with this storage adapter and cascade the\n * reference to all owned domain stores and parent composites. Storage\n * adapters that need to look up agents, editor config, etc. can read\n * `this.mastra` after this is called.\n * @internal\n */\n __registerMastra(mastra: StorageMastraRef, seen: Set<unknown> = new Set<unknown>()): void {\n if (seen.has(this)) return;\n seen.add(this);\n this.mastra = mastra;\n const cascade = (target: unknown) => {\n if (!target || typeof target !== 'object' || seen.has(target)) return;\n const fn = (target as { __registerMastra?: (m: StorageMastraRef, s?: Set<unknown>) => void }).__registerMastra;\n if (typeof fn === 'function') {\n fn.call(target, mastra, seen);\n } else {\n seen.add(target);\n }\n };\n if (this.parentDefault) cascade(this.parentDefault);\n if (this.parentEditor) cascade(this.parentEditor);\n if (this.stores) {\n for (const domain of Object.values(this.stores)) cascade(domain);\n }\n }\n\n /**\n * Get a domain-specific storage interface.\n *\n * @param storeName - The name of the domain to access ('memory', 'workflows', 'scores', 'observability', 'agents')\n * @returns The domain storage interface, or undefined if not available\n *\n * @example\n * ```typescript\n * const memory = await storage.getStore('memory');\n * if (memory) {\n * await memory.saveThread({ thread });\n * }\n * ```\n */\n async getStore<K extends keyof StorageDomains>(storeName: K): Promise<StorageDomains[K] | undefined> {\n return this.stores?.[storeName];\n }\n\n /**\n * Delete rows older than their configured `maxAge` across all domains that\n * have a policy declared in `retention`.\n *\n * Prune is safe at scale: each domain deletes in bounded, batched, resumable,\n * cancellable chunks (see {@link PruneOptions}). It only deletes rows. On\n * SQLite/LibSQL freed pages are reused by future writes so the file stops\n * growing; handing disk back to the OS is left to the underlying database and\n * the operator to manage.\n *\n * Returns one {@link PruneResult} per table touched. A result with\n * `done: false` means eligible rows remain — call `prune()` again (e.g. on\n * the next cron tick) to continue.\n *\n * Prune is meant to run unattended (a cron tick), so a failure in one\n * domain is logged and skipped rather than rejecting the whole call — the\n * results already gathered for other domains are still returned, and the\n * failed domain is retried naturally on the next tick.\n *\n * With no `retention` configured this is a no-op returning `[]`.\n *\n * Pass `options.retention` to replace the configured retention policies for\n * this call only — e.g. to skip a domain (keep chat history) or prune more\n * aggressively than the standing config without reconstructing the store.\n */\n async prune(options?: PruneOptions): Promise<PruneResult[]> {\n const retention = options?.retention ?? this.retention;\n if (!retention) return [];\n\n const results: PruneResult[] = [];\n for (const [domainKey, tablePolicies] of Object.entries(retention) as [\n keyof StorageDomains,\n Record<string, TableRetentionPolicy> | undefined,\n ][]) {\n if (options?.signal?.aborted) break;\n if (!tablePolicies || Object.keys(tablePolicies).length === 0) continue;\n\n const domain = this.stores?.[domainKey];\n if (!isPruneCapable(domain)) continue; // domain not configured / doesn't support retention\n\n try {\n const domainResults = await domain.prune(tablePolicies, options);\n results.push(...domainResults);\n } catch (error) {\n this.logger?.error(`prune() failed for domain \"${domainKey}\"`, { error });\n }\n }\n return results;\n }\n\n /**\n * Initialize all domain stores.\n *\n * When a parent store was supplied via `default` or `editor`, delegate to\n * its own `init()` first. Each adapter owns its `init()` contract — it may\n * apply connection-level setup, run migrations, enforce DDL ordering, or\n * coalesce concurrent callers. Calling each domain's `init()` directly\n * against the parent's shared client would bypass all of that and can\n * corrupt or partially create schema (see issue #16782 for the SQLite\n * symptom).\n *\n * Any remaining domains that did NOT come from a parent (e.g. supplied via\n * the explicit `domains` override pointing at a different store) are then\n * initialized individually — but only the ones the parents didn't already\n * cover, so we never double-init the same domain instance.\n */\n async init(): Promise<void> {\n if (!this.shouldCacheInit) {\n await this.#runInit();\n return;\n }\n\n if (this.hasInitialized) {\n await this.hasInitialized;\n return;\n }\n\n const initPromise = this.#runInit().catch(error => {\n if (this.hasInitialized === initPromise) {\n this.hasInitialized = null;\n }\n throw error;\n });\n this.hasInitialized = initPromise;\n await initPromise;\n }\n\n async #runInit(): Promise<boolean> {\n // 1. Delegate to parent stores. Each parent owns its own init contract\n // (setup, migrations, sequencing, coalescing). Dedupe by identity so\n // a store passed as both `default` and `editor` only gets init()'d once.\n const uniqueParents = new Set<MastraCompositeStore>();\n if (this.parentDefault) uniqueParents.add(this.parentDefault);\n if (this.parentEditor) uniqueParents.add(this.parentEditor);\n await Promise.all([...uniqueParents].map(parent => parent.init()));\n\n // 2. Build a set of domain instances the parents already initialized so\n // we don't init them a second time below.\n const alreadyInitialized = new Set<unknown>();\n const addParentDomains = (parent?: MastraCompositeStore) => {\n if (!parent?.stores) return;\n for (const domain of Object.values(parent.stores)) {\n if (domain) alreadyInitialized.add(domain);\n }\n };\n addParentDomains(this.parentDefault);\n addParentDomains(this.parentEditor);\n\n // 3. Init any remaining domains (typically those provided via the\n // explicit `domains` override pointing at a different store, or those\n // set directly by a subclass).\n const initTasks: Promise<void>[] = [];\n const maybeInit = (domain: { init(): Promise<void> } | undefined) => {\n if (!domain || alreadyInitialized.has(domain)) return;\n initTasks.push(domain.init());\n alreadyInitialized.add(domain);\n };\n\n if (this.stores) {\n maybeInit(this.stores.memory);\n maybeInit(this.stores.workflows);\n maybeInit(this.stores.workflowDefinitions);\n maybeInit(this.stores.scores);\n maybeInit(this.stores.observability);\n maybeInit(this.stores.agents);\n maybeInit(this.stores.datasets);\n maybeInit(this.stores.experiments);\n maybeInit(this.stores.promptBlocks);\n maybeInit(this.stores.scorerDefinitions);\n maybeInit(this.stores.mcpClients);\n maybeInit(this.stores.mcpServers);\n maybeInit(this.stores.workspaces);\n maybeInit(this.stores.skills);\n maybeInit(this.stores.favorites);\n maybeInit(this.stores.blobs);\n maybeInit(this.stores.backgroundTasks);\n maybeInit(this.stores.schedules);\n maybeInit(this.stores.channels);\n maybeInit(this.stores.harness);\n maybeInit(this.stores.toolProviderConnections);\n maybeInit(this.stores.notifications);\n maybeInit(this.stores.threadState);\n }\n\n await Promise.all(initTasks);\n return true;\n }\n /**\n * Optional lifecycle hook: release underlying client/connection handles.\n * Implementations (e.g. LibSQLStore) override this to checkpoint WAL files\n * and close the database client so OS handles are freed synchronously.\n * Called automatically by Mastra.shutdown().\n */\n close?(): Promise<void>;\n}\n\n/**\n * @deprecated Use MastraCompositeStoreConfig instead. This alias will be removed in a future version.\n */\nexport interface MastraStorageConfig extends MastraCompositeStoreConfig {}\n\n/**\n * @deprecated Use MastraCompositeStore instead. This alias will be removed in a future version.\n */\nexport class MastraStorage extends MastraCompositeStore {}\n","import type { StorageOrderBy, ThreadOrderBy, ThreadSortDirection } from '../types';\nimport { StorageDomain } from './base';\n\n// ============================================================================\n// Version Resolution Options\n// ============================================================================\n\n/**\n * Options for resolving which version of an entity to use.\n * Either pick by status (draft/published/archived) or by a specific version ID — not both.\n */\nexport type VersionResolutionOptions =\n | { status?: 'draft' | 'published' | 'archived'; versionId?: never }\n | { versionId: string; status?: never };\n\n// ============================================================================\n// Generic Version Types\n// ============================================================================\n\n/**\n * Base interface for version metadata fields that exist on every version row.\n * The `TFkField` parameter controls the name of the foreign key field.\n */\nexport interface VersionBase {\n /** UUID identifier for this version */\n id: string;\n /** Sequential version number (1, 2, 3, ...) */\n versionNumber: number;\n /** Array of field names that changed from the previous version */\n changedFields?: string[];\n /** Optional message describing the changes */\n changeMessage?: string;\n /** When this version was created */\n createdAt: Date;\n}\n\n/**\n * Base interface for version creation input.\n * Same as VersionBase but without the server-assigned `createdAt` timestamp.\n */\nexport interface CreateVersionInputBase extends Omit<VersionBase, 'createdAt'> {}\n\n/**\n * Sort direction for version listings.\n */\nexport type VersionSortDirectionGeneric = ThreadSortDirection;\n\n/**\n * Fields that can be used for ordering version listings.\n */\nexport type VersionOrderByGeneric = 'versionNumber' | 'createdAt';\n\n/**\n * Input for listing versions with pagination and sorting.\n */\nexport interface ListVersionsInputBase {\n /** Page number (0-indexed) */\n page?: number;\n /**\n * Number of items per page, or `false` to fetch all records without pagination limit.\n * Defaults to 20 if not specified.\n */\n perPage?: number | false;\n /** Sorting options */\n orderBy?: {\n field?: VersionOrderByGeneric;\n direction?: VersionSortDirectionGeneric;\n };\n}\n\n/**\n * Output for listing versions with pagination info.\n */\nexport interface ListVersionsOutputBase<TVersion> {\n /** Array of versions for the current page */\n versions: TVersion[];\n /** Total number of versions */\n total: number;\n /** Current page number */\n page: number;\n /** Items per page */\n perPage: number | false;\n /** Whether there are more pages */\n hasMore: boolean;\n}\n\n// ============================================================================\n// Entity base — the \"thin record\" must have these fields\n// ============================================================================\n\nexport interface VersionedEntityBase {\n id: string;\n activeVersionId?: string;\n}\n\n// ============================================================================\n// Constants for validation (shared across all versioned domains)\n// ============================================================================\n\nconst ENTITY_ORDER_BY_SET: Record<ThreadOrderBy, true> = {\n createdAt: true,\n updatedAt: true,\n};\n\nconst SORT_DIRECTION_SET: Record<ThreadSortDirection, true> = {\n ASC: true,\n DESC: true,\n};\n\nconst VERSION_ORDER_BY_SET: Record<VersionOrderByGeneric, true> = {\n versionNumber: true,\n createdAt: true,\n};\n\n// ============================================================================\n// VersionedStorageDomain — generic base class\n// ============================================================================\n\n/**\n * Generic base class for versioned storage domains (agents, prompt blocks, scorer definitions).\n *\n * Type parameters:\n * - `TEntity` — Thin record type (e.g. StorageAgentType)\n * - `TSnapshot` — Snapshot config type (e.g. StorageAgentSnapshotType)\n * - `TResolved` — Entity + snapshot merged (e.g. StorageResolvedAgentType)\n * - `TVersion` — Version row (e.g. AgentVersion)\n * - `TCreateVersion` — Input for creating a version\n * - `TListVersionsInput` — Input for listing versions\n * - `TListVersionsOutput` — Output for listing versions\n * - `TCreateInput` — Input for creating an entity\n * - `TUpdateInput` — Input for updating an entity\n * - `TListInput` — Input for listing entities\n * - `TListOutput` — Output for listing entities (paginated thin records)\n * - `TListResolvedOutput` — Output for listing resolved entities\n */\nexport abstract class VersionedStorageDomain<\n TEntity extends VersionedEntityBase,\n TSnapshot,\n TResolved extends TEntity,\n TVersion extends VersionBase,\n TCreateVersion extends CreateVersionInputBase,\n TListVersionsInput extends ListVersionsInputBase,\n TListVersionsOutput extends ListVersionsOutputBase<TVersion>,\n TCreateInput,\n TUpdateInput,\n TListInput,\n TListOutput,\n TListResolvedOutput,\n> extends StorageDomain {\n /**\n * The key name used in list outputs (e.g. 'agents', 'promptBlocks', 'scorerDefinitions').\n * Subclasses must provide this so the generic resolution logic can build the correct output shape.\n */\n protected abstract readonly listKey: string;\n\n /**\n * The set of version metadata field names (including the FK field) to strip\n * when extracting snapshot config from a version row.\n * e.g. ['id', 'agentId', 'versionNumber', 'changedFields', 'changeMessage', 'createdAt']\n */\n protected abstract readonly versionMetadataFields: string[];\n\n // ==========================================================================\n // Entity CRUD (abstract — implemented by concrete store classes)\n // ==========================================================================\n\n abstract getById(id: string): Promise<TEntity | null>;\n abstract create(input: TCreateInput): Promise<TEntity>;\n abstract update(input: TUpdateInput): Promise<TEntity>;\n abstract delete(id: string): Promise<void>;\n abstract list(args?: TListInput): Promise<TListOutput>;\n\n // ==========================================================================\n // Version methods (abstract — implemented by concrete store classes)\n // ==========================================================================\n\n abstract createVersion(input: TCreateVersion): Promise<TVersion>;\n abstract getVersion(id: string): Promise<TVersion | null>;\n abstract getVersionByNumber(entityId: string, versionNumber: number): Promise<TVersion | null>;\n abstract getLatestVersion(entityId: string): Promise<TVersion | null>;\n abstract listVersions(input: TListVersionsInput): Promise<TListVersionsOutput>;\n abstract deleteVersion(id: string): Promise<void>;\n abstract deleteVersionsByParentId(entityId: string): Promise<void>;\n abstract countVersions(entityId: string): Promise<number>;\n\n // ==========================================================================\n // Concrete resolution methods\n // ==========================================================================\n\n /**\n * Strips version metadata fields from a version row, leaving only snapshot config fields.\n */\n protected extractSnapshotConfig(version: TVersion): Partial<TSnapshot> {\n const result: Record<string, unknown> = {};\n const metadataSet = new Set(this.versionMetadataFields);\n\n for (const [key, value] of Object.entries(version)) {\n if (!metadataSet.has(key)) {\n result[key] = value;\n }\n }\n\n return result as Partial<TSnapshot>;\n }\n\n /**\n * Resolves an entity by merging its thin record with the active or latest version config.\n * - `{ status: 'draft' }` — resolve with the latest version.\n * - `{ status: 'published' }` (default) — resolve with the active version, falling back to latest.\n * - `{ versionId: '...' }` — resolve with a specific version by ID.\n */\n async getByIdResolved(id: string, options?: VersionResolutionOptions): Promise<TResolved | null> {\n const entity = await this.getById(id);\n\n if (!entity) {\n return null;\n }\n\n return this.resolveEntity(entity, options);\n }\n\n /**\n * Lists entities with version resolution.\n * When `status` is `'draft'`, each entity is resolved with its latest version.\n * When `status` is `'published'` (default), each entity is resolved with its active version.\n */\n async listResolved(args?: TListInput): Promise<TListResolvedOutput> {\n const result = await this.list(args);\n\n const status = (args as Record<string, unknown> | undefined)?.status as string | undefined;\n const entities = (result as Record<string, unknown>)[this.listKey] as TEntity[];\n const resolved = await Promise.all(\n entities.map(entity => this.resolveEntity(entity, { status: status as 'draft' | 'published' | 'archived' })),\n );\n\n return {\n ...result,\n [this.listKey]: resolved,\n } as TListResolvedOutput;\n }\n\n /**\n * Resolves a single entity by merging it with its active or latest version.\n * - `{ versionId: '...' }` — resolve with a specific version by ID.\n * - `{ status: 'published' }` (default) — use activeVersionId, fall back to latest.\n * - `{ status: 'draft' }` — always use the latest version.\n */\n protected async resolveEntity(entity: TEntity, options?: VersionResolutionOptions): Promise<TResolved> {\n const status = options?.status || 'published';\n let version: TVersion | null = null;\n\n if (options?.versionId) {\n // Specific version resolution: fetch by exact version ID\n version = await this.getVersion(options.versionId);\n } else if (status === 'draft') {\n // Draft resolution: always use the latest version (which may be ahead of activeVersionId)\n version = await this.getLatestVersion(entity.id);\n } else {\n // Published/archived resolution: use activeVersionId, fall back to latest\n if (entity.activeVersionId) {\n version = await this.getVersion(entity.activeVersionId);\n\n if (!version) {\n this.logger?.warn?.(\n `Entity ${entity.id} has activeVersionId ${entity.activeVersionId} but version not found. Falling back to latest version.`,\n );\n }\n }\n\n if (!version) {\n version = await this.getLatestVersion(entity.id);\n }\n }\n\n if (version) {\n const snapshotConfig = this.extractSnapshotConfig(version);\n return {\n ...entity,\n ...snapshotConfig,\n resolvedVersionId: version.id,\n } as unknown as TResolved;\n }\n\n return entity as unknown as TResolved;\n }\n\n // ==========================================================================\n // Protected Helper Methods\n // ==========================================================================\n\n protected parseOrderBy(\n orderBy?: StorageOrderBy,\n defaultDirection: ThreadSortDirection = 'DESC',\n ): { field: ThreadOrderBy; direction: ThreadSortDirection } {\n return {\n field: orderBy?.field && orderBy.field in ENTITY_ORDER_BY_SET ? orderBy.field : 'createdAt',\n direction: orderBy?.direction && orderBy.direction in SORT_DIRECTION_SET ? orderBy.direction : defaultDirection,\n };\n }\n\n protected parseVersionOrderBy(\n orderBy?: TListVersionsInput['orderBy'],\n defaultDirection: VersionSortDirectionGeneric = 'DESC',\n ): { field: VersionOrderByGeneric; direction: VersionSortDirectionGeneric } {\n return {\n field: orderBy?.field && orderBy.field in VERSION_ORDER_BY_SET ? orderBy.field : 'versionNumber',\n direction: orderBy?.direction && orderBy.direction in SORT_DIRECTION_SET ? orderBy.direction : defaultDirection,\n };\n }\n}\n","import { execFile } from 'node:child_process';\nimport { realpathSync } from 'node:fs';\nimport { relative } from 'node:path';\n\n/**\n * A single Git commit entry parsed from `git log` output.\n */\nexport interface GitCommit {\n /** Full commit SHA */\n hash: string;\n /** Commit author date as a Date object */\n date: Date;\n /** Author name */\n author: string;\n /** Commit subject line */\n message: string;\n}\n\n/**\n * Read-only utility for reading Git history of filesystem-stored JSON files.\n *\n * All operations are performed by shelling out to the `git` CLI via\n * `child_process.execFile` (no third-party dependencies). This class never\n * writes to Git — the user manages their own commits.\n *\n * Designed as a singleton shared across all domain helpers via a static field\n * on `FilesystemVersionedHelpers`.\n */\nexport class GitHistory {\n /** Cache: dir → repo root (string) or `false` if not a repo. */\n private repoRootCache = new Map<string, string | false>();\n\n /** Cache: `dir:filename:limit` → ordered commits (newest first). */\n private commitCache = new Map<string, GitCommit[]>();\n\n /** Cache: `dir:commitHash:filename` → parsed JSON. Stored as unknown because\n * shared files are `{ [entityId]: snapshot }` while per-entity files are the\n * snapshot itself. */\n private snapshotCache = new Map<string, unknown>();\n\n // ===========================================================================\n // Public API\n // ===========================================================================\n\n /**\n * Returns `true` if `dir` is inside a Git repository.\n * Result is cached after the first call per directory.\n */\n async isGitRepo(dir: string): Promise<boolean> {\n const cached = this.repoRootCache.get(dir);\n if (cached === false) return false;\n if (typeof cached === 'string') return true;\n\n try {\n const root = (await this.exec(dir, ['rev-parse', '--show-toplevel'])).trim();\n this.repoRootCache.set(dir, root);\n return true;\n } catch {\n this.repoRootCache.set(dir, false);\n return false;\n }\n }\n\n /**\n * Get the list of commits that touched a specific file, newest first.\n * Returns an empty array if Git is unavailable or the file has no history.\n *\n * @param dir Absolute path to the storage directory\n * @param filename The JSON filename relative to `dir` (e.g., 'agents.json')\n * @param limit Maximum number of commits to retrieve\n */\n async getFileHistory(dir: string, filename: string, limit: number = 50): Promise<GitCommit[]> {\n const cacheKey = `${dir}:${filename}:${limit}`;\n if (this.commitCache.has(cacheKey)) {\n return this.commitCache.get(cacheKey)!;\n }\n\n if (!(await this.isGitRepo(dir))) {\n this.commitCache.set(cacheKey, []);\n return [];\n }\n\n try {\n // `filename` is already relative to `dir`, and `exec` runs with `cwd: dir`,\n // so `git log -- <filename>` resolves correctly.\n const raw = await this.exec(dir, [\n 'log',\n `--max-count=${limit}`,\n '--format=%H|%aI|%aN|%s',\n '--follow',\n '--',\n filename,\n ]);\n\n const commits: GitCommit[] = [];\n for (const line of raw.split('\\n')) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n\n const pipeIdx1 = trimmed.indexOf('|');\n const pipeIdx2 = trimmed.indexOf('|', pipeIdx1 + 1);\n const pipeIdx3 = trimmed.indexOf('|', pipeIdx2 + 1);\n\n if (pipeIdx1 === -1 || pipeIdx2 === -1 || pipeIdx3 === -1) continue;\n\n commits.push({\n hash: trimmed.slice(0, pipeIdx1),\n date: new Date(trimmed.slice(pipeIdx1 + 1, pipeIdx2)),\n author: trimmed.slice(pipeIdx2 + 1, pipeIdx3),\n message: trimmed.slice(pipeIdx3 + 1),\n });\n }\n\n this.commitCache.set(cacheKey, commits);\n return commits;\n } catch {\n this.commitCache.set(cacheKey, []);\n return [];\n }\n }\n\n /**\n * Read and parse a JSON file at a specific Git commit.\n * Returns the parsed entity map, or `null` if the file didn't exist at that commit.\n *\n * @param dir Absolute path to the storage directory\n * @param commitHash Full or abbreviated commit SHA\n * @param filename The JSON filename relative to `dir` (e.g., 'agents.json')\n */\n async getFileAtCommit<T = Record<string, Record<string, unknown>>>(\n dir: string,\n commitHash: string,\n filename: string,\n ): Promise<T | null> {\n const cacheKey = `${dir}:${commitHash}:${filename}`;\n if (this.snapshotCache.has(cacheKey)) {\n return this.snapshotCache.get(cacheKey)! as T;\n }\n\n if (!(await this.isGitRepo(dir))) return null;\n\n try {\n const relPath = this.relativeToRepo(dir, filename);\n const raw = await this.exec(dir, ['show', `${commitHash}:${relPath}`]);\n const parsed = JSON.parse(raw);\n this.snapshotCache.set(cacheKey, parsed);\n return parsed as T;\n } catch {\n return null;\n }\n }\n\n /**\n * Invalidate all caches. Call after external operations that change Git state\n * (e.g., the user commits or pulls).\n */\n invalidateCache(): void {\n this.repoRootCache.clear();\n this.commitCache.clear();\n this.snapshotCache.clear();\n }\n\n // ===========================================================================\n // Internals\n // ===========================================================================\n\n /**\n * Get the relative path from the Git repo root to a file in the storage directory.\n */\n private relativeToRepo(dir: string, filename: string): string {\n const root = this.repoRootCache.get(dir);\n if (!root) {\n throw new Error(`Not a git repository: ${dir}`);\n }\n // Resolve symlinks so that macOS /var → /private/var differences don't break relative()\n const realRoot = realpathSync(root);\n const realDir = realpathSync(dir);\n const relDir = relative(realRoot, realDir);\n return relDir ? `${relDir}/${filename}` : filename;\n }\n\n /**\n * Execute a git command and return stdout.\n */\n private exec(cwd: string, args: string[]): Promise<string> {\n return new Promise((resolve, reject) => {\n execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (error, stdout) => {\n if (error) reject(error);\n else resolve(stdout);\n });\n });\n }\n}\n","export type SourceControlCapabilityReason =\n | 'provider-not-configured'\n | 'provider-unavailable'\n | 'missing-permissions'\n | 'project-not-linked'\n | 'unsupported';\n\nexport type SourceControlCapabilities = {\n canRead: boolean;\n canWrite: boolean;\n canListHistory: boolean;\n canOpenChangeRequest: boolean;\n reason?: SourceControlCapabilityReason | string;\n};\n\nexport type SourceProviderInfo = {\n id: string;\n displayName: string;\n};\n\nexport type SourceFileRef = {\n path: string;\n ref?: string;\n};\n\nexport type SourceFile = SourceFileRef & {\n content: string;\n sha?: string;\n};\n\nexport type SourceWriteFileInput = SourceFileRef & {\n content: string;\n message?: string;\n expectedSha?: string;\n};\n\nexport type SourceWriteResult = {\n path: string;\n ref?: string;\n sha?: string;\n commitSha?: string;\n url?: string;\n};\n\nexport type SourceFileHistoryInput = {\n path: string;\n ref?: string;\n limit?: number;\n};\n\nexport type SourceFi