@mastra/core
Version:
1,163 lines (1,155 loc) • 43.9 kB
JavaScript
import { MastraBase } from './chunk-77VL4DNS.js';
import { execFile } from 'child_process';
import { realpathSync } from 'fs';
import { relative } from 'path';
// src/storage/domains/thread-state/base.ts
var ThreadStateStorage = class extends 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 [];
}
};
// src/storage/domains/thread-state/inmemory.ts
function clone(value) {
return value === void 0 ? value : structuredClone(value);
}
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();
}
};
// src/storage/base.ts
var EDITOR_DOMAINS = [
"agents",
"promptBlocks",
"scorerDefinitions",
"mcpClients",
"mcpServers",
"workspaces",
"skills",
"favorites",
"toolProviderConnections"
];
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;
}
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 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);
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) => {
if (domainOverrides[key] !== void 0) return domainOverrides[key];
if (editorDomainSet.has(key) && editorStores?.[key] !== void 0) return editorStores[key];
return defaultStores?.[key];
};
this.stores = {
memory: resolve("memory"),
workflows: resolve("workflows"),
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"),
// The thread-state domain always has an in-memory store wired by default
// so the built-in task tools work out of the box without a configured
// backend. Configure a durable backend for state that must survive a
// process restart.
threadState: 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 `[]`.
*/
async prune(options) {
const 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.hasInitialized) {
return;
}
this.hasInitialized = this.#runInit();
await this.hasInitialized;
}
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.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;
}
};
var MastraStorage = class extends MastraCompositeStore {
};
// src/storage/domains/base.ts
var StorageDomain = class extends MastraBase {
/**
* Declares which of this domain's tables are eligible for age-based
* retention, mapping a stable table key → physical table name, timestamp
* anchor column, and whether that column is indexed.
*
* This is the single source of truth used by `prune()` implementations to
* resolve policies to physical deletes. Domains that support retention
* override this; the default is empty (nothing prunable).
*/
static retentionTables = {};
/**
* Initialize the storage domain.
* This should create any necessary tables/collections.
* Default implementation is a no-op - override in adapters that need initialization.
*/
async init() {
}
/**
* Delete rows older than each policy's `maxAge`, batched, bounded, and
* cancellable so it is safe to run against very large tables.
*
* - `policies` maps this domain's stable table keys to their retention policy.
* - `options` bound the work (`maxBatches`/`maxRows`), pace it (`pauseMs`),
* and allow cooperative cancellation (`signal`).
*
* Returns one {@link PruneResult} per table touched. A result with
* `done: false` means eligible rows remain — call `prune()` again to continue.
*
* `prune()` 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.
*
* Default implementation is a no-op (retention not supported).
*/
async prune(_policies, _options) {
return [];
}
};
// src/storage/domains/versioned.ts
var ENTITY_ORDER_BY_SET = {
createdAt: true,
updatedAt: true
};
var SORT_DIRECTION_SET = {
ASC: true,
DESC: true
};
var VERSION_ORDER_BY_SET = {
versionNumber: true,
createdAt: true
};
var VersionedStorageDomain = class extends StorageDomain {
// ==========================================================================
// Concrete resolution methods
// ==========================================================================
/**
* 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;
}
// ==========================================================================
// Protected Helper Methods
// ==========================================================================
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
};
}
};
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();
// ===========================================================================
// Public API
// ===========================================================================
/**
* 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();
}
// ===========================================================================
// Internals
// ===========================================================================
/**
* 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 realRoot = realpathSync(root);
const realDir = realpathSync(dir);
const relDir = relative(realRoot, realDir);
return relDir ? `${relDir}/${filename}` : filename;
}
/**
* Execute a git command and return stdout.
*/
exec(cwd, args) {
return new Promise((resolve, reject) => {
execFile("git", args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (error, stdout) => {
if (error) reject(error);
else resolve(stdout);
});
});
}
};
// src/storage/source-control.ts
var SOURCE_CONTROL_AGENTS_DIR = "agents";
function getSourceControlEntityFilePath(directory, entityId) {
return `${directory}/${encodeURIComponent(entityId)}.json`;
}
function getSourceAgentFilePath(agentId) {
return getSourceControlEntityFilePath(SOURCE_CONTROL_AGENTS_DIR, agentId);
}
// src/storage/filesystem-versioned.ts
var GIT_VERSION_PREFIX = "git-";
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;
}
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;
const isRepo = await git.isGitRepo(dir);
if (!isRepo) return;
const commits = await git.getFileHistory(dir, this.entitiesFile, this.gitHistoryLimit);
const orderedCommits = [...commits].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;
}
// ==========================================================================
// Disk persistence — only published snapshot configs
// ==========================================================================
/**
* 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;
}
// ==========================================================================
// Entity CRUD
// ==========================================================================
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";
const isPublished = updatedEntity.status === "published" && updatedEntity.activeVersionId;
if (isPublished || 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
};
}
// ==========================================================================
// Version Methods (in-memory + git history)
// ==========================================================================
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;
const hasVersions = Array.from(this.versions.values()).some(
(v) => v[this.parentIdField] === entityId
);
if (hasVersions) 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) {
const gitCount = this.gitVersionCounts.get(entityId) ?? 0;
let maxVersion = gitCount;
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);
}
}
}
};
export { EDITOR_DOMAINS, FilesystemVersionedHelpers, GitHistory, InMemoryThreadStateStorage, MastraCompositeStore, MastraStorage, SOURCE_CONTROL_AGENTS_DIR, StorageDomain, ThreadStateStorage, VersionedStorageDomain, calculatePagination, getSourceAgentFilePath, getSourceControlEntityFilePath, normalizePerPage };
//# sourceMappingURL=chunk-7R6GYIAF.js.map
//# sourceMappingURL=chunk-7R6GYIAF.js.map