Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sweet-ghosts-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mastra/editor': patch
---

Fixed prompt block SDK updates to persist editable fields.
72 changes: 72 additions & 0 deletions packages/editor/src/namespaces/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest';
import { InMemoryStore } from '@mastra/core/storage';

import { EditorPromptNamespace } from './prompt';

function createPromptNamespace(storage: InMemoryStore) {
return new EditorPromptNamespace({
__mastra: {
getStorage: () => storage,
removePromptBlock: vi.fn(),
},
__logger: {
debug: vi.fn(),
},
} as any);
}

describe('EditorPromptNamespace', () => {
it('updates prompt block snapshot fields through the SDK', async () => {
const storage = new InMemoryStore();
const prompt = createPromptNamespace(storage);

await prompt.create({
id: 'sdk-updatable-block',
name: 'SDK Updatable Block',
content: 'Initial content',
});

const updated = await prompt.update({
id: 'sdk-updatable-block',
name: 'SDK Updated Block',
content: 'Updated content',
rules: {
operator: 'AND',
conditions: [{ field: 'role', operator: 'equals', value: 'admin' }],
},
requestContextSchema: {
type: 'object',
properties: {
role: { type: 'string' },
},
required: ['role'],
},
});

expect(updated.name).toBe('SDK Updated Block');
expect(updated.content).toBe('Updated content');
expect(updated.rules).toEqual({
operator: 'AND',
conditions: [{ field: 'role', operator: 'equals', value: 'admin' }],
});
expect(updated.requestContextSchema).toEqual({
type: 'object',
properties: {
role: { type: 'string' },
},
required: ['role'],
});

const persisted = await prompt.getById('sdk-updatable-block');
expect(persisted!.name).toBe('SDK Updated Block');
expect(persisted!.content).toBe('Updated content');
expect(persisted!.rules).toEqual(updated.rules);
expect(persisted!.requestContextSchema).toEqual(updated.requestContextSchema);

const promptStore = await storage.getStore('promptBlocks');
const versions = await promptStore!.listVersions({ blockId: 'sdk-updatable-block' });
expect(versions.versions).toHaveLength(2);
expect(versions.versions[0]!.changedFields).toEqual(['name', 'content', 'rules', 'requestContextSchema']);
expect(updated.activeVersionId).toBeUndefined();
});
});
106 changes: 106 additions & 0 deletions packages/editor/src/namespaces/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,75 @@ import type {
StorageListPromptBlocksOutput,
StorageResolvedPromptBlockType,
StorageListPromptBlocksResolvedOutput,
StoragePromptBlockSnapshotType,
PromptBlockVersion,
PromptBlocksStorage,
} from '@mastra/core/storage';

import { resolveInstructionBlocks } from '../instruction-builder';
import { CrudEditorNamespace } from './base';
import type { StorageAdapter } from './base';

const PROMPT_BLOCK_SNAPSHOT_CONFIG_FIELDS = [
'name',
'description',
'content',
'rules',
'requestContextSchema',
] as const satisfies (keyof StoragePromptBlockSnapshotType)[];

function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;

if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
}

if (typeof a === 'object' && typeof b === 'object') {
const aObj = a as Record<string, unknown>;
const bObj = b as Record<string, unknown>;
const aKeys = Object.keys(aObj);
const bKeys = Object.keys(bObj);

return aKeys.length === bKeys.length && aKeys.every(key => deepEqual(aObj[key], bObj[key]));
}

return false;
}

function extractConfigFromVersion(version: PromptBlockVersion): StoragePromptBlockSnapshotType {
return {
name: version.name,
description: version.description,
content: version.content,
rules: version.rules,
requestContextSchema: version.requestContextSchema,
};
}

function getProvidedConfigFields(input: StorageUpdatePromptBlockInput): Partial<StoragePromptBlockSnapshotType> {
const config: Partial<StoragePromptBlockSnapshotType> = {};

for (const field of PROMPT_BLOCK_SNAPSHOT_CONFIG_FIELDS) {
if (input[field] !== undefined) {
config[field] = input[field] as never;
}
}

return config;
}

function getChangedFields(
previousConfig: Partial<StoragePromptBlockSnapshotType>,
providedConfig: Partial<StoragePromptBlockSnapshotType>,
): (keyof StoragePromptBlockSnapshotType)[] {
return PROMPT_BLOCK_SNAPSHOT_CONFIG_FIELDS.filter(
field => field in providedConfig && !deepEqual(previousConfig[field], providedConfig[field]),
);
}

export class EditorPromptNamespace extends CrudEditorNamespace<
StorageCreatePromptBlockInput,
StorageUpdatePromptBlockInput,
Expand Down Expand Up @@ -49,6 +112,49 @@ export class EditorPromptNamespace extends CrudEditorNamespace<
};
}

override async update(input: StorageUpdatePromptBlockInput): Promise<StorageResolvedPromptBlockType> {
this.ensureRegistered();
const storage = this.mastra?.getStorage();
if (!storage) throw new Error('Storage is not configured');
const store = (await storage.getStore('promptBlocks')) as PromptBlocksStorage | undefined;
if (!store) throw new Error('Prompt blocks storage domain is not available');

const existing = await store.getById(input.id);
if (!existing) {
throw new Error(`Prompt block with id ${input.id} not found`);
}

await store.update(input);

const providedConfig = getProvidedConfigFields(input);
const latestVersion = Object.keys(providedConfig).length > 0 ? await store.getLatestVersion(input.id) : null;
const previousConfig = latestVersion ? extractConfigFromVersion(latestVersion) : null;
const changedFields = previousConfig ? getChangedFields(previousConfig, providedConfig) : [];

if (latestVersion && previousConfig && changedFields.length > 0) {
await store.createVersion({
...previousConfig,
...providedConfig,
id: crypto.randomUUID(),
blockId: input.id,
versionNumber: latestVersion.versionNumber + 1,
changedFields,
changeMessage: 'Auto-saved after edit',
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the snapshot write atomic with the block update.

store.update() persists the thin record first, and createVersion() happens afterward. If version creation fails, this method throws after a partial update and the versioned fields are still stale. This should be one storage-domain operation or a transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/editor/src/namespaces/prompt.ts` around lines 127 - 143, The update
currently calls store.update(...) then separately calls
store.createVersion(...), risking partial persistence if createVersion fails;
wrap both operations in a single storage transaction (e.g. store.transaction or
a new store.updateWithVersion API) so the thin record update and the optional
version insert happen atomically: inside the transaction compute providedConfig
via getProvidedConfigFields(input), fetch latestVersion via
store.getLatestVersion (or tx.getLatestVersion), compute previousConfig and
changedFields, then perform tx.update(input) and, only if changedFields.length >
0, tx.createVersion(...) so either both succeed or both are rolled back.

}

this._cache.delete(input.id);
this.onCacheEvict(input.id);

const resolved = await store.getByIdResolved(input.id, { status: 'draft' });
if (!resolved) {
throw new Error(`Failed to resolve entity ${input.id} after update`);
}

this._cache.set(input.id, resolved);
return resolved;
}

async preview(blocks: AgentInstructionBlock[], context: Record<string, unknown>): Promise<string> {
this.ensureRegistered();
const storage = this.mastra?.getStorage();
Expand Down
Loading