UNPKG

@mastra/core

Version:
1,175 lines (1,174 loc) 44.6 kB
const require_base = require("./base-B6soWsYg.cjs"); const require_base$1 = require("./base-CfojwyC3.cjs"); let fs = require("fs"); let path = require("path"); let child_process = require("child_process"); //#region src/storage/domains/thread-state/base.ts /** * Abstract base class for the thread-state storage domain. * * The thread-state domain holds arbitrary, durable, per-thread state keyed by a * `type` namespace. Each `(threadId, type)` pair owns one value. Today the only * types are `'task'` (the structured task list managed by the built-in task * tools) and `'goal'` (the durable {@link GoalObjectiveRecord} that drives the * in-loop goal scorer). The domain is intentionally generic so other * agent-scoped state can be tracked the same way without a new domain. * * The built-in task tools read/write the `'task'` slot synchronously within a * run (so a `task_update` sees the tasks a prior `task_write` produced), and the * task state processor reads it to project the list onto the agent state-signal * lane. */ var ThreadStateStorage = class extends require_base.MastraBase { /** * Declares which of this domain's tables are eligible for age-based retention. * Adapters that support retention override this; the default is empty. */ static retentionTables = {}; constructor() { super({ component: "STORAGE", name: "THREAD_STATE" }); } /** * Delete rows older than each policy's `maxAge`, batched, bounded, and * cancellable. Default implementation is a no-op (retention not supported). */ async prune(_policies, _options) { return []; } }; //#endregion //#region src/storage/domains/thread-state/inmemory.ts function clone(value) { return value === void 0 ? value : structuredClone(value); } /** * In-memory implementation of {@link ThreadStateStorage}. * * Holds each thread's state in a `Map<threadId, Map<type, value>>`. Stored * values are cloned on read and write so callers cannot mutate the backing * value. * * This is the default thread-state store wired by the composite store: task * tracking works out of the box without a configured backend. It is **not** * durable across process restarts — configure a durable backend (e.g. * `@mastra/libsql`) for state that must survive a restart. */ var InMemoryThreadStateStorage = class extends ThreadStateStorage { stateByThread = /* @__PURE__ */ new Map(); async init() {} async getState({ threadId, type }) { const value = this.stateByThread.get(threadId)?.get(type); return value === void 0 ? void 0 : clone(value); } async setState({ threadId, type, value }) { let byType = this.stateByThread.get(threadId); if (!byType) { byType = /* @__PURE__ */ new Map(); this.stateByThread.set(threadId, byType); } byType.set(type, clone(value)); } async deleteState({ threadId, type }) { const byType = this.stateByThread.get(threadId); if (!byType) return; byType.delete(type); if (byType.size === 0) this.stateByThread.delete(threadId); } async dangerouslyClearAll() { this.stateByThread.clear(); } }; //#endregion //#region src/storage/base.ts /** * Domain keys used by the Mastra Editor. * Used by the `editor` shorthand on MastraCompositeStoreConfig to route * all editor-related domains to a single store. */ const EDITOR_DOMAINS = [ "agents", "promptBlocks", "scorerDefinitions", "mcpClients", "mcpServers", "workspaces", "skills", "favorites", "toolProviderConnections" ]; /** * Normalizes perPage input for pagination queries. * * @param perPageInput - The raw perPage value from the user * @param defaultValue - The default perPage value to use when undefined (typically 40 for messages, 100 for threads) * @returns A numeric perPage value suitable for queries (false becomes MAX_SAFE_INTEGER) * @throws Error if perPage is a negative number */ function normalizePerPage(perPageInput, defaultValue) { if (perPageInput === false) return Number.MAX_SAFE_INTEGER; else if (perPageInput === 0) return 0; else if (typeof perPageInput === "number" && perPageInput > 0) return perPageInput; else if (typeof perPageInput === "number" && perPageInput < 0) throw new Error("perPage must be >= 0"); return defaultValue; } /** * Calculates pagination offset and prepares perPage value for response. * When perPage is false (fetch all), offset is always 0 regardless of page. * * @param page - The page number (0-indexed) * @param perPageInput - The original perPage input (number, false for all, or undefined) * @param normalizedPerPage - The normalized perPage value (from normalizePerPage) * @returns Object with offset for query and perPage for response */ function calculatePagination(page, perPageInput, normalizedPerPage) { return { offset: perPageInput === false ? 0 : page * normalizedPerPage, perPage: perPageInput === false ? false : normalizedPerPage }; } function isPruneCapable(value) { return typeof value === "object" && value !== null && typeof value.prune === "function"; } var MastraCompositeStore = class extends require_base.MastraBase { hasInitialized = null; shouldCacheInit = true; id; stores; mastra; /** * When true, automatic initialization (table creation/migrations) is disabled. */ disableInit = false; /** * Opt-in, table-granular, age-based retention policies. Consumed by * `prune()`. Undefined means nothing is pruned (keep forever). */ retention; /** * Retained references to the parent stores supplied via composition. `init()` * delegates to these so the parent's own `init()` logic (pragmas, ordered * DDL, init coalescing, etc.) runs instead of being bypassed by the * composite iterating the inner domains in parallel — which was the cause * of the SQLITE_BUSY / "no such table" races reported in issue #16782. */ parentDefault; parentEditor; constructor(config) { const name = config.name ?? "MastraCompositeStore"; if (!config.id || typeof config.id !== "string" || config.id.trim() === "") throw new Error(`${name}: id must be provided and cannot be empty.`); super({ component: "STORAGE", name }); this.id = config.id; this.disableInit = config.disableInit ?? false; this.retention = config.retention; if (config.default || config.editor || config.domains) { const defaultStores = config.default?.stores; const editorStores = config.editor?.stores; const domainOverrides = config.domains ?? {}; this.parentDefault = config.default; this.parentEditor = config.editor; const hasDefaultDomains = defaultStores && Object.values(defaultStores).some((v) => v !== void 0); const hasEditorDomains = editorStores && Object.values(editorStores).some((v) => v !== void 0); const hasOverrideDomains = Object.values(domainOverrides).some((v) => v !== void 0 && v !== false); if (!hasDefaultDomains && !hasEditorDomains && !hasOverrideDomains) throw new Error("MastraCompositeStore requires at least one storage source. Provide a default storage, an editor storage, or domain overrides."); const editorDomainSet = new Set(EDITOR_DOMAINS); const resolve = (key) => { const override = domainOverrides[key]; if (override === false) return void 0; if (override !== void 0) return override; if (editorDomainSet.has(key) && editorStores?.[key] !== void 0) return editorStores[key]; return defaultStores?.[key]; }; this.stores = { memory: resolve("memory"), workflows: resolve("workflows"), workflowDefinitions: resolve("workflowDefinitions"), scores: resolve("scores"), observability: resolve("observability"), agents: resolve("agents"), datasets: resolve("datasets"), experiments: resolve("experiments"), promptBlocks: resolve("promptBlocks"), scorerDefinitions: resolve("scorerDefinitions"), mcpClients: resolve("mcpClients"), mcpServers: resolve("mcpServers"), workspaces: resolve("workspaces"), skills: resolve("skills"), favorites: resolve("favorites"), blobs: resolve("blobs"), backgroundTasks: resolve("backgroundTasks"), schedules: resolve("schedules"), channels: resolve("channels"), harness: resolve("harness"), toolProviderConnections: resolve("toolProviderConnections"), notifications: resolve("notifications"), threadState: domainOverrides.threadState === false ? void 0 : resolve("threadState") ?? new InMemoryThreadStateStorage() }; } } /** * Register the Mastra instance with this storage adapter and cascade the * reference to all owned domain stores and parent composites. Storage * adapters that need to look up agents, editor config, etc. can read * `this.mastra` after this is called. * @internal */ __registerMastra(mastra, seen = /* @__PURE__ */ new Set()) { if (seen.has(this)) return; seen.add(this); this.mastra = mastra; const cascade = (target) => { if (!target || typeof target !== "object" || seen.has(target)) return; const fn = target.__registerMastra; if (typeof fn === "function") fn.call(target, mastra, seen); else seen.add(target); }; if (this.parentDefault) cascade(this.parentDefault); if (this.parentEditor) cascade(this.parentEditor); if (this.stores) for (const domain of Object.values(this.stores)) cascade(domain); } /** * Get a domain-specific storage interface. * * @param storeName - The name of the domain to access ('memory', 'workflows', 'scores', 'observability', 'agents') * @returns The domain storage interface, or undefined if not available * * @example * ```typescript * const memory = await storage.getStore('memory'); * if (memory) { * await memory.saveThread({ thread }); * } * ``` */ async getStore(storeName) { return this.stores?.[storeName]; } /** * Delete rows older than their configured `maxAge` across all domains that * have a policy declared in `retention`. * * Prune is safe at scale: each domain deletes in bounded, batched, resumable, * cancellable chunks (see {@link PruneOptions}). It only deletes rows. On * SQLite/LibSQL freed pages are reused by future writes so the file stops * growing; handing disk back to the OS is left to the underlying database and * the operator to manage. * * Returns one {@link PruneResult} per table touched. A result with * `done: false` means eligible rows remain — call `prune()` again (e.g. on * the next cron tick) to continue. * * Prune is meant to run unattended (a cron tick), so a failure in one * domain is logged and skipped rather than rejecting the whole call — the * results already gathered for other domains are still returned, and the * failed domain is retried naturally on the next tick. * * With no `retention` configured this is a no-op returning `[]`. * * Pass `options.retention` to replace the configured retention policies for * this call only — e.g. to skip a domain (keep chat history) or prune more * aggressively than the standing config without reconstructing the store. */ async prune(options) { const retention = options?.retention ?? this.retention; if (!retention) return []; const results = []; for (const [domainKey, tablePolicies] of Object.entries(retention)) { if (options?.signal?.aborted) break; if (!tablePolicies || Object.keys(tablePolicies).length === 0) continue; const domain = this.stores?.[domainKey]; if (!isPruneCapable(domain)) continue; try { const domainResults = await domain.prune(tablePolicies, options); results.push(...domainResults); } catch (error) { this.logger?.error(`prune() failed for domain "${domainKey}"`, { error }); } } return results; } /** * Initialize all domain stores. * * When a parent store was supplied via `default` or `editor`, delegate to * its own `init()` first. Each adapter owns its `init()` contract — it may * apply connection-level setup, run migrations, enforce DDL ordering, or * coalesce concurrent callers. Calling each domain's `init()` directly * against the parent's shared client would bypass all of that and can * corrupt or partially create schema (see issue #16782 for the SQLite * symptom). * * Any remaining domains that did NOT come from a parent (e.g. supplied via * the explicit `domains` override pointing at a different store) are then * initialized individually — but only the ones the parents didn't already * cover, so we never double-init the same domain instance. */ async init() { if (!this.shouldCacheInit) { await this.#runInit(); return; } if (this.hasInitialized) { await this.hasInitialized; return; } const initPromise = this.#runInit().catch((error) => { if (this.hasInitialized === initPromise) this.hasInitialized = null; throw error; }); this.hasInitialized = initPromise; await initPromise; } async #runInit() { const uniqueParents = /* @__PURE__ */ new Set(); if (this.parentDefault) uniqueParents.add(this.parentDefault); if (this.parentEditor) uniqueParents.add(this.parentEditor); await Promise.all([...uniqueParents].map((parent) => parent.init())); const alreadyInitialized = /* @__PURE__ */ new Set(); const addParentDomains = (parent) => { if (!parent?.stores) return; for (const domain of Object.values(parent.stores)) if (domain) alreadyInitialized.add(domain); }; addParentDomains(this.parentDefault); addParentDomains(this.parentEditor); const initTasks = []; const maybeInit = (domain) => { if (!domain || alreadyInitialized.has(domain)) return; initTasks.push(domain.init()); alreadyInitialized.add(domain); }; if (this.stores) { maybeInit(this.stores.memory); maybeInit(this.stores.workflows); maybeInit(this.stores.workflowDefinitions); maybeInit(this.stores.scores); maybeInit(this.stores.observability); maybeInit(this.stores.agents); maybeInit(this.stores.datasets); maybeInit(this.stores.experiments); maybeInit(this.stores.promptBlocks); maybeInit(this.stores.scorerDefinitions); maybeInit(this.stores.mcpClients); maybeInit(this.stores.mcpServers); maybeInit(this.stores.workspaces); maybeInit(this.stores.skills); maybeInit(this.stores.favorites); maybeInit(this.stores.blobs); maybeInit(this.stores.backgroundTasks); maybeInit(this.stores.schedules); maybeInit(this.stores.channels); maybeInit(this.stores.harness); maybeInit(this.stores.toolProviderConnections); maybeInit(this.stores.notifications); maybeInit(this.stores.threadState); } await Promise.all(initTasks); return true; } }; /** * @deprecated Use MastraCompositeStore instead. This alias will be removed in a future version. */ var MastraStorage = class extends MastraCompositeStore {}; //#endregion //#region src/storage/domains/versioned.ts const ENTITY_ORDER_BY_SET = { createdAt: true, updatedAt: true }; const SORT_DIRECTION_SET = { ASC: true, DESC: true }; const VERSION_ORDER_BY_SET = { versionNumber: true, createdAt: true }; /** * Generic base class for versioned storage domains (agents, prompt blocks, scorer definitions). * * Type parameters: * - `TEntity` — Thin record type (e.g. StorageAgentType) * - `TSnapshot` — Snapshot config type (e.g. StorageAgentSnapshotType) * - `TResolved` — Entity + snapshot merged (e.g. StorageResolvedAgentType) * - `TVersion` — Version row (e.g. AgentVersion) * - `TCreateVersion` — Input for creating a version * - `TListVersionsInput` — Input for listing versions * - `TListVersionsOutput` — Output for listing versions * - `TCreateInput` — Input for creating an entity * - `TUpdateInput` — Input for updating an entity * - `TListInput` — Input for listing entities * - `TListOutput` — Output for listing entities (paginated thin records) * - `TListResolvedOutput` — Output for listing resolved entities */ var VersionedStorageDomain = class extends require_base$1.StorageDomain { /** * Strips version metadata fields from a version row, leaving only snapshot config fields. */ extractSnapshotConfig(version) { const result = {}; const metadataSet = new Set(this.versionMetadataFields); for (const [key, value] of Object.entries(version)) if (!metadataSet.has(key)) result[key] = value; return result; } /** * Resolves an entity by merging its thin record with the active or latest version config. * - `{ status: 'draft' }` — resolve with the latest version. * - `{ status: 'published' }` (default) — resolve with the active version, falling back to latest. * - `{ versionId: '...' }` — resolve with a specific version by ID. */ async getByIdResolved(id, options) { const entity = await this.getById(id); if (!entity) return null; return this.resolveEntity(entity, options); } /** * Lists entities with version resolution. * When `status` is `'draft'`, each entity is resolved with its latest version. * When `status` is `'published'` (default), each entity is resolved with its active version. */ async listResolved(args) { const result = await this.list(args); const status = args?.status; const entities = result[this.listKey]; const resolved = await Promise.all(entities.map((entity) => this.resolveEntity(entity, { status }))); return { ...result, [this.listKey]: resolved }; } /** * Resolves a single entity by merging it with its active or latest version. * - `{ versionId: '...' }` — resolve with a specific version by ID. * - `{ status: 'published' }` (default) — use activeVersionId, fall back to latest. * - `{ status: 'draft' }` — always use the latest version. */ async resolveEntity(entity, options) { const status = options?.status || "published"; let version = null; if (options?.versionId) version = await this.getVersion(options.versionId); else if (status === "draft") version = await this.getLatestVersion(entity.id); else { if (entity.activeVersionId) { version = await this.getVersion(entity.activeVersionId); if (!version) this.logger?.warn?.(`Entity ${entity.id} has activeVersionId ${entity.activeVersionId} but version not found. Falling back to latest version.`); } if (!version) version = await this.getLatestVersion(entity.id); } if (version) { const snapshotConfig = this.extractSnapshotConfig(version); return { ...entity, ...snapshotConfig, resolvedVersionId: version.id }; } return entity; } parseOrderBy(orderBy, defaultDirection = "DESC") { return { field: orderBy?.field && orderBy.field in ENTITY_ORDER_BY_SET ? orderBy.field : "createdAt", direction: orderBy?.direction && orderBy.direction in SORT_DIRECTION_SET ? orderBy.direction : defaultDirection }; } parseVersionOrderBy(orderBy, defaultDirection = "DESC") { return { field: orderBy?.field && orderBy.field in VERSION_ORDER_BY_SET ? orderBy.field : "versionNumber", direction: orderBy?.direction && orderBy.direction in SORT_DIRECTION_SET ? orderBy.direction : defaultDirection }; } }; //#endregion //#region src/storage/git-history.ts /** * Read-only utility for reading Git history of filesystem-stored JSON files. * * All operations are performed by shelling out to the `git` CLI via * `child_process.execFile` (no third-party dependencies). This class never * writes to Git — the user manages their own commits. * * Designed as a singleton shared across all domain helpers via a static field * on `FilesystemVersionedHelpers`. */ var GitHistory = class { /** Cache: dir → repo root (string) or `false` if not a repo. */ repoRootCache = /* @__PURE__ */ new Map(); /** Cache: `dir:filename:limit` → ordered commits (newest first). */ commitCache = /* @__PURE__ */ new Map(); /** Cache: `dir:commitHash:filename` → parsed JSON. Stored as unknown because * shared files are `{ [entityId]: snapshot }` while per-entity files are the * snapshot itself. */ snapshotCache = /* @__PURE__ */ new Map(); /** * Returns `true` if `dir` is inside a Git repository. * Result is cached after the first call per directory. */ async isGitRepo(dir) { const cached = this.repoRootCache.get(dir); if (cached === false) return false; if (typeof cached === "string") return true; try { const root = (await this.exec(dir, ["rev-parse", "--show-toplevel"])).trim(); this.repoRootCache.set(dir, root); return true; } catch { this.repoRootCache.set(dir, false); return false; } } /** * Get the list of commits that touched a specific file, newest first. * Returns an empty array if Git is unavailable or the file has no history. * * @param dir Absolute path to the storage directory * @param filename The JSON filename relative to `dir` (e.g., 'agents.json') * @param limit Maximum number of commits to retrieve */ async getFileHistory(dir, filename, limit = 50) { const cacheKey = `${dir}:${filename}:${limit}`; if (this.commitCache.has(cacheKey)) return this.commitCache.get(cacheKey); if (!await this.isGitRepo(dir)) { this.commitCache.set(cacheKey, []); return []; } try { const raw = await this.exec(dir, [ "log", `--max-count=${limit}`, "--format=%H|%aI|%aN|%s", "--follow", "--", filename ]); const commits = []; for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const pipeIdx1 = trimmed.indexOf("|"); const pipeIdx2 = trimmed.indexOf("|", pipeIdx1 + 1); const pipeIdx3 = trimmed.indexOf("|", pipeIdx2 + 1); if (pipeIdx1 === -1 || pipeIdx2 === -1 || pipeIdx3 === -1) continue; commits.push({ hash: trimmed.slice(0, pipeIdx1), date: new Date(trimmed.slice(pipeIdx1 + 1, pipeIdx2)), author: trimmed.slice(pipeIdx2 + 1, pipeIdx3), message: trimmed.slice(pipeIdx3 + 1) }); } this.commitCache.set(cacheKey, commits); return commits; } catch { this.commitCache.set(cacheKey, []); return []; } } /** * Read and parse a JSON file at a specific Git commit. * Returns the parsed entity map, or `null` if the file didn't exist at that commit. * * @param dir Absolute path to the storage directory * @param commitHash Full or abbreviated commit SHA * @param filename The JSON filename relative to `dir` (e.g., 'agents.json') */ async getFileAtCommit(dir, commitHash, filename) { const cacheKey = `${dir}:${commitHash}:${filename}`; if (this.snapshotCache.has(cacheKey)) return this.snapshotCache.get(cacheKey); if (!await this.isGitRepo(dir)) return null; try { const relPath = this.relativeToRepo(dir, filename); const raw = await this.exec(dir, ["show", `${commitHash}:${relPath}`]); const parsed = JSON.parse(raw); this.snapshotCache.set(cacheKey, parsed); return parsed; } catch { return null; } } /** * Invalidate all caches. Call after external operations that change Git state * (e.g., the user commits or pulls). */ invalidateCache() { this.repoRootCache.clear(); this.commitCache.clear(); this.snapshotCache.clear(); } /** * Get the relative path from the Git repo root to a file in the storage directory. */ relativeToRepo(dir, filename) { const root = this.repoRootCache.get(dir); if (!root) throw new Error(`Not a git repository: ${dir}`); const relDir = (0, path.relative)((0, fs.realpathSync)(root), (0, fs.realpathSync)(dir)); return relDir ? `${relDir}/${filename}` : filename; } /** * Execute a git command and return stdout. */ exec(cwd, args) { return new Promise((resolve, reject) => { (0, child_process.execFile)("git", args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (error, stdout) => { if (error) reject(error); else resolve(stdout); }); }); } }; //#endregion //#region src/storage/source-control.ts const SOURCE_CONTROL_AGENTS_DIR = "agents"; function getSourceControlEntityFilePath(directory, entityId) { return `${directory}/${encodeURIComponent(entityId)}.json`; } function getSourceAgentFilePath(agentId) { return getSourceControlEntityFilePath(SOURCE_CONTROL_AGENTS_DIR, agentId); } //#endregion //#region src/storage/filesystem-versioned.ts /** * Prefix for version IDs that come from git history. * These versions are read-only and cannot be deleted. */ const GIT_VERSION_PREFIX = "git-"; /** * Recursively sort object keys alphabetically so the on-disk JSON is stable * across saves. Arrays preserve order; object entries are emitted in a * deterministic order so git diffs only reflect real content changes. */ function stableSortKeys(value) { if (Array.isArray(value)) return value.map(stableSortKeys); if (value && typeof value === "object" && !(value instanceof Date)) return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stableSortKeys(entry)])); return value; } /** * Generic helpers for filesystem-backed versioned storage domains. * * Versions are kept entirely in memory. Only the published snapshot config * (the clean primitive configuration) is persisted to the on-disk JSON file. * This means the JSON files are human-readable, Git-friendly, and contain * no version metadata like `changedFields` or `changeMessage`. * * When the storage directory is inside a git repository, committed versions * of the JSON file are automatically loaded as read-only version history. * Each git commit that touched the file becomes a version record, giving * users a full published history in the version panel — powered by git. * * On-disk format for `agents.json`: * ```json * { * "my-agent-id": { * "name": "My Agent", * "instructions": "Be helpful", * "model": { "provider": "openai", "name": "gpt-4" } * } * } * ``` */ var FilesystemVersionedHelpers = class FilesystemVersionedHelpers { db; entitiesFile; parentIdField; name; versionMetadataFields; gitHistoryLimit; perEntityFilesDir; shouldPersistToPerEntityFile; perEntitySnapshotFilter; /** * In-memory entity records (thin metadata), keyed by entity ID. */ entities = /* @__PURE__ */ new Map(); /** * In-memory version records, keyed by version ID. * Includes both in-memory/hydrated versions and git-based versions (metadata only). */ versions = /* @__PURE__ */ new Map(); /** * Whether we've loaded from disk yet. */ hydrated = false; /** * Git history utility instance (shared across all helpers). */ static gitHistory = new GitHistory(); /** * Promise that resolves when git history has been loaded. * null means git history loading hasn't been triggered yet. */ gitHistoryPromise = null; /** * The highest version number from git history, per entity ID. * Used to assign version numbers to new in-memory versions that continue * after the git history. */ gitVersionCounts = /* @__PURE__ */ new Map(); constructor(config) { this.db = config.db; this.entitiesFile = config.entitiesFile; this.parentIdField = config.parentIdField; this.name = config.name; this.versionMetadataFields = config.versionMetadataFields; this.gitHistoryLimit = config.gitHistoryLimit ?? 50; this.perEntityFilesDir = config.perEntityFilesDir; this.shouldPersistToPerEntityFile = config.shouldPersistToPerEntityFile; this.perEntitySnapshotFilter = config.perEntitySnapshotFilter; } perEntityFilename(entityId) { if (!this.perEntityFilesDir) throw new Error(`${this.name}: per-entity files directory is not configured`); return getSourceControlEntityFilePath(this.perEntityFilesDir, entityId); } entityIdFromPerEntityFilename(filename) { const basename = filename.split("/").pop() ?? filename; return decodeURIComponent(basename.replace(/\.json$/, "")); } /** * Check if a version ID represents a git-based version. */ static isGitVersion(id) { return id.startsWith(GIT_VERSION_PREFIX); } /** * Hydrate in-memory state from the on-disk JSON file. * For each entry on disk, creates an in-memory entity (status: 'published') * and a synthetic version with the snapshot config. * * Also kicks off async git history loading in the background. * Version numbers for hydrated entities are assigned as 1 initially, * but will be reassigned after git history loads. */ hydrate() { if (this.hydrated) return; this.hydrated = true; const hydrateSnapshot = (entityId, snapshotConfig) => { const versionId = `hydrated-${entityId}-v1`; const now = /* @__PURE__ */ new Date(); const entity = { id: entityId, status: "published", activeVersionId: versionId, createdAt: now, updatedAt: now }; this.entities.set(entityId, entity); const version = { id: versionId, [this.parentIdField]: entityId, versionNumber: 1, ...snapshotConfig, createdAt: now }; this.versions.set(versionId, version); }; const diskData = this.db.readDomain(this.entitiesFile); for (const [entityId, snapshotConfig] of Object.entries(diskData)) { if (!snapshotConfig || typeof snapshotConfig !== "object") continue; hydrateSnapshot(entityId, snapshotConfig); } if (this.perEntityFilesDir) for (const filename of this.db.listDomainFiles(this.perEntityFilesDir)) { const entityId = this.entityIdFromPerEntityFilename(filename); const snapshotConfig = this.db.readDomain(filename); if (!snapshotConfig || typeof snapshotConfig !== "object") continue; hydrateSnapshot(entityId, snapshotConfig); } this.gitHistoryPromise = this.loadGitHistory(); } /** * Ensure git history has been loaded before proceeding. * Call this in version-related methods to ensure git versions are available. */ async ensureGitHistory() { this.hydrate(); if (this.gitHistoryPromise) await this.gitHistoryPromise; } /** * Load git commit history for the domain's JSON file. * Creates read-only version records (metadata + snapshot config) for each * commit where an entity existed. Reassigns version numbers for * hydrated (current disk) versions to sit on top of git history. */ async loadGitHistory() { const git = FilesystemVersionedHelpers.gitHistory; const dir = this.db.dir; if (!await git.isGitRepo(dir)) return; const orderedCommits = [...await git.getFileHistory(dir, this.entitiesFile, this.gitHistoryLimit)].reverse(); const entityVersionCount = /* @__PURE__ */ new Map(); const previousSnapshots = /* @__PURE__ */ new Map(); for (let i = 0; i < orderedCommits.length; i++) { const commit = orderedCommits[i]; const fileContent = await git.getFileAtCommit(dir, commit.hash, this.entitiesFile); if (!fileContent) continue; for (const [entityId, snapshotConfig] of Object.entries(fileContent)) { if (!snapshotConfig || typeof snapshotConfig !== "object") continue; const serialized = JSON.stringify(snapshotConfig); if (previousSnapshots.get(entityId) === serialized) continue; previousSnapshots.set(entityId, serialized); const count = (entityVersionCount.get(entityId) ?? 0) + 1; entityVersionCount.set(entityId, count); const versionId = `${GIT_VERSION_PREFIX}${commit.hash}-${entityId}`; if (this.versions.has(versionId)) continue; const version = { id: versionId, [this.parentIdField]: entityId, versionNumber: count, changeMessage: commit.message, ...snapshotConfig, createdAt: commit.date }; this.versions.set(versionId, version); } } if (this.perEntityFilesDir) { const perEntityIds = /* @__PURE__ */ new Set(); for (const filename of this.db.listDomainFiles(this.perEntityFilesDir)) perEntityIds.add(this.entityIdFromPerEntityFilename(filename)); for (const entityId of this.entities.keys()) perEntityIds.add(entityId); for (const entityId of perEntityIds) { const count = await this.loadPerEntityGitHistory(entityId, entityVersionCount.get(entityId) ?? 0); entityVersionCount.set(entityId, count); } } this.gitVersionCounts = entityVersionCount; for (const [entityId, gitCount] of entityVersionCount) { const hydratedVersionId = `hydrated-${entityId}-v1`; const version = this.versions.get(hydratedVersionId); if (version) version.versionNumber = gitCount + 1; } } /** * Load git-backed versions for a single per-entity file. Each commit that * changes the file becomes one version. Returns the running version count * for the entity (starting from `startCount`). Used both by the bulk * git-history pass and by `listVersions` to lazily discover entities that * were deleted on disk but still exist in git history. */ async loadPerEntityGitHistory(entityId, startCount) { const git = FilesystemVersionedHelpers.gitHistory; const dir = this.db.dir; const filename = this.perEntityFilename(entityId); const perEntityCommits = await git.getFileHistory(dir, filename, this.gitHistoryLimit); if (perEntityCommits.length === 0) return startCount; const orderedPerEntity = [...perEntityCommits].reverse(); let previousSnapshotForEntity; let count = startCount; for (const commit of orderedPerEntity) { const snapshotConfig = await git.getFileAtCommit(dir, commit.hash, filename); if (!snapshotConfig || typeof snapshotConfig !== "object") { previousSnapshotForEntity = void 0; continue; } const serialized = JSON.stringify(snapshotConfig); if (previousSnapshotForEntity === serialized) continue; previousSnapshotForEntity = serialized; count += 1; const versionId = `${GIT_VERSION_PREFIX}${commit.hash}-${entityId}`; if (this.versions.has(versionId)) continue; const version = { id: versionId, [this.parentIdField]: entityId, versionNumber: count, changeMessage: commit.message, ...snapshotConfig, createdAt: commit.date }; this.versions.set(versionId, version); } return count; } /** * Write the published snapshot config for an entity to disk. * Strips all entity metadata and version metadata fields, leaving only * the clean primitive configuration. */ persistToDisk() { const diskData = {}; const perEntityData = /* @__PURE__ */ new Map(); for (const [entityId, entity] of this.entities) { if (entity.status !== "published" || !entity.activeVersionId) continue; const version = this.versions.get(entity.activeVersionId); if (!version) continue; const snapshotConfig = this.extractSnapshotConfig(version); if (this.perEntityFilesDir && this.shouldPersistToPerEntityFile?.(entity)) { const filtered = this.perEntitySnapshotFilter ? this.perEntitySnapshotFilter(snapshotConfig, entity) : snapshotConfig; perEntityData.set(entityId, stableSortKeys(filtered)); } else diskData[entityId] = stableSortKeys(snapshotConfig); } const hasSharedEntries = Object.keys(diskData).length > 0; const sharedFileExists = this.db.domainFileExists(this.entitiesFile); if (hasSharedEntries || !this.perEntityFilesDir || sharedFileExists) this.db.writeDomain(this.entitiesFile, diskData); if (this.perEntityFilesDir) { for (const filename of this.db.listDomainFiles(this.perEntityFilesDir)) { const entityId = this.entityIdFromPerEntityFilename(filename); if (!perEntityData.has(entityId)) this.db.removeDomainFile(filename); } for (const [entityId, snapshotConfig] of perEntityData) this.db.writeDomain(this.perEntityFilename(entityId), snapshotConfig); } } /** * Extract the snapshot config from a version, stripping version metadata fields. */ extractSnapshotConfig(version) { const metadataSet = new Set(this.versionMetadataFields); const result = {}; for (const [key, value] of Object.entries(version)) if (!metadataSet.has(key)) result[key] = value; return result; } async getById(id) { this.hydrate(); return this.entities.has(id) ? structuredClone(this.entities.get(id)) : null; } async createEntity(id, entity) { this.hydrate(); if (this.entities.has(id)) throw new Error(`${this.name}: entity with id ${id} already exists`); this.entities.set(id, structuredClone(entity)); return structuredClone(entity); } async updateEntity(id, updates) { this.hydrate(); const existing = this.entities.get(id); if (!existing) throw new Error(`${this.name}: entity with id ${id} not found`); const updated = { ...existing }; for (const [key, value] of Object.entries(updates)) { if (key === "id") continue; if (value === void 0) continue; if (key === "metadata" && typeof value === "object" && value !== null) updated["metadata"] = { ...updated["metadata"] ?? {}, ...value }; else updated[key] = value; } updated["updatedAt"] = /* @__PURE__ */ new Date(); const updatedEntity = updated; this.entities.set(id, structuredClone(updatedEntity)); const wasPublished = existing.status === "published"; if (updatedEntity.status === "published" && updatedEntity.activeVersionId || wasPublished && updates["status"] !== void 0) this.persistToDisk(); return structuredClone(updatedEntity); } async deleteEntity(id) { this.hydrate(); this.entities.delete(id); await this.deleteVersionsByParentId(id); this.persistToDisk(); } async listEntities(args) { this.hydrate(); const { page = 0, perPage: perPageInput, orderBy, filters, listKey } = args; const perPage = normalizePerPage(perPageInput, 100); if (page < 0) throw new Error("page must be >= 0"); let entities = Array.from(this.entities.values()); if (filters) for (const [key, value] of Object.entries(filters)) { if (value === void 0) continue; if (key === "metadata" && typeof value === "object" && value !== null) entities = entities.filter((e) => { const meta = e["metadata"]; if (!meta) return false; return Object.entries(value).every(([k, v]) => JSON.stringify(meta[k]) === JSON.stringify(v)); }); else entities = entities.filter((e) => e[key] === value); } const field = orderBy?.field ?? "createdAt"; const direction = orderBy?.direction ?? "DESC"; entities.sort((a, b) => { const aVal = new Date(a[field]).getTime(); const bVal = new Date(b[field]).getTime(); return direction === "ASC" ? aVal - bVal : bVal - aVal; }); const { offset, perPage: perPageForResponse } = calculatePagination(page, perPageInput, perPage); return { [listKey]: entities.slice(offset, offset + perPage), total: entities.length, page, perPage: perPageForResponse, hasMore: offset + perPage < entities.length }; } async createVersion(input) { await this.ensureGitHistory(); if (this.versions.has(input.id)) throw new Error(`${this.name}: version with id ${input.id} already exists`); const parentId = input[this.parentIdField]; for (const v of this.versions.values()) if (v[this.parentIdField] === parentId && v.versionNumber === input.versionNumber) throw new Error(`${this.name}: version number ${input.versionNumber} already exists for entity ${parentId}`); const version = { ...input, createdAt: /* @__PURE__ */ new Date() }; this.versions.set(input.id, structuredClone(version)); return structuredClone(version); } async getVersion(id) { await this.ensureGitHistory(); return this.versions.has(id) ? structuredClone(this.versions.get(id)) : null; } async getVersionByNumber(entityId, versionNumber) { await this.ensureGitHistory(); for (const v of this.versions.values()) if (v[this.parentIdField] === entityId && v.versionNumber === versionNumber) return structuredClone(v); return null; } async getLatestVersion(entityId) { await this.ensureGitHistory(); let latest = null; for (const v of this.versions.values()) if (v[this.parentIdField] === entityId) { if (!latest || v.versionNumber > latest.versionNumber) latest = v; } return latest ? structuredClone(latest) : null; } async listVersions(input, parentIdField) { await this.ensureGitHistory(); const { page = 0, perPage: perPageInput, orderBy } = input; const entityId = input[parentIdField]; const perPage = normalizePerPage(perPageInput, 20); if (page < 0) throw new Error("page must be >= 0"); await this.ensurePerEntityGitHistory(entityId); const versions = Array.from(this.versions.values()).filter((v) => v[this.parentIdField] === entityId); const field = orderBy?.field ?? "versionNumber"; const direction = orderBy?.direction ?? "DESC"; versions.sort((a, b) => { const aVal = field === "createdAt" ? new Date(a.createdAt).getTime() : a.versionNumber; const bVal = field === "createdAt" ? new Date(b.createdAt).getTime() : b.versionNumber; return direction === "ASC" ? aVal - bVal : bVal - aVal; }); const { offset, perPage: perPageForResponse } = calculatePagination(page, perPageInput, perPage); return { versions: versions.slice(offset, offset + perPage), total: versions.length, page, perPage: perPageForResponse, hasMore: offset + perPage < versions.length }; } async deleteVersion(id) { await this.ensureGitHistory(); if (FilesystemVersionedHelpers.isGitVersion(id)) return; this.versions.delete(id); } async deleteVersionsByParentId(entityId) { await this.ensureGitHistory(); for (const [versionId, version] of this.versions) if (version[this.parentIdField] === entityId) { if (FilesystemVersionedHelpers.isGitVersion(versionId)) continue; this.versions.delete(versionId); } } async countVersions(entityId) { await this.ensureGitHistory(); let count = 0; for (const v of this.versions.values()) if (v[this.parentIdField] === entityId) count++; return count; } /** * Lazily discover per-entity git history for an entity that was deleted on * disk but still exists in git commits. The bulk git-history pass only walks * entities currently on disk or in memory, so without this an entity that has * no in-memory versions would surface no git versions (and `gitVersionCounts` * would stay 0, letting a recreated entity collide with git version numbers). */ async ensurePerEntityGitHistory(entityId) { if (!this.perEntityFilesDir || !entityId) return; if (Array.from(this.versions.values()).some((v) => v[this.parentIdField] === entityId)) return; const startCount = this.gitVersionCounts.get(entityId) ?? 0; const newCount = await this.loadPerEntityGitHistory(entityId, startCount); if (newCount > startCount) this.gitVersionCounts.set(entityId, newCount); } async getNextVersionNumber(entityId) { await this.ensureGitHistory(); await this.ensurePerEntityGitHistory(entityId); return this._getNextVersionNumber(entityId); } _getNextVersionNumber(entityId) { let maxVersion = this.gitVersionCounts.get(entityId) ?? 0; for (const v of this.versions.values()) if (v[this.parentIdField] === entityId) maxVersion = Math.max(maxVersion, v.versionNumber); return maxVersion + 1; } async dangerouslyClearAll() { this.entities.clear(); this.versions.clear(); this.gitVersionCounts.clear(); this.gitHistoryPromise = null; this.hydrated = false; this.db.clearDomain(this.entitiesFile); if (this.perEntityFilesDir) for (const filename of this.db.listDomainFiles(this.perEntityFilesDir)) this.db.removeDomainFile(filename); } }; //#endregion Object.defineProperty(exports, "EDITOR_DOMAINS", { enumerable: true, get: function() { return EDITOR_DOMAINS; } }); Object.defineProperty(exports, "FilesystemVersionedHelpers", { enumerable: true, get: function() { return FilesystemVersionedHelpers; } }); Object.defineProperty(exports, "GitHistory", { enumerable: true, get: function() { return GitHistory; } }); Object.defineProperty(exports, "InMemoryThreadStateStorage", { enumerable: true, get: function() { return InMemoryThreadStateStorage; } }); Object.defineProperty(exports, "MastraCompositeStore", { enumerable: true, get: function() { return MastraCompositeStore; } }); Object.defineProperty(exports, "MastraStorage", { enumerable: true, get: function() { return MastraStorage; } }); Object.defineProperty(exports, "SOURCE_CONTROL_AGENTS_DIR", { enumerable: true, get: function() { return SOURCE_CONTROL_AGENTS_DIR; } }); Object.defineProperty(exports, "ThreadStateStorage", { enumerable: true, get: function() { return ThreadStateStorage; } }); Object.defineProperty(exports, "VersionedStorageDomain", { enumerable: true, get: function() { return VersionedStorageDomain; } }); Object.defineProperty(exports, "calculatePagination", { enumerable: true, get: function() { return calculatePagination; } }); Object.defineProperty(exports, "getSourceAgentFilePath", { enumerable: true, get: function() { return getSourceAgentFilePath; } }); Object.defineProperty(exports, "getSourceControlEntityFilePath", { enumerable: true, get: function() { return getSourceControlEntityFilePath; } }); Object.defineProperty(exports, "normalizePerPage", { enumerable: true, get: function() { return normalizePerPage; } }); //# sourceMappingURL=filesystem-versioned-8Ag_Np8l.cjs.map