fix: prevent spellbook duplication when saving

Problem:
- Saving active spellbooks was creating duplicates each time
- No deduplication logic for spellbooks with same slug + pubkey

Solution:
- Add findExistingSpellbook() to match by slug and pubkey
- Update saveSpellbook() to detect and update existing spellbooks:
  * For local-only: match by slug (no pubkey)
  * For published: match by slug AND pubkey
  * For updates: use provided ID
- SaveSpellbookDialog now passes existing ID in update mode

Testing:
- Added comprehensive spellbook-storage.test.ts
- Tests verify deduplication logic (requires jsdom environment)
- Existing tests still pass (14/14 for publish-spellbook)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2025-12-21 14:18:04 +01:00
parent 0f7f154b80
commit f1ba39a65e
4 changed files with 386 additions and 96 deletions

View File

@@ -3,10 +3,8 @@ import { markSpellbookPublished } from "@/services/spellbook-storage";
import { SpellbookEvent } from "@/types/spell";
import { GrimoireState } from "@/types/app";
import { SpellbookContent } from "@/types/spell";
import { mergeRelaySets } from "applesauce-core/helpers";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import accountManager from "@/services/accounts";
import type { ActionHub } from "applesauce-actions";
import type { ActionContext } from "applesauce-actions";
import type { NostrEvent } from "nostr-tools/core";
export interface PublishSpellbookOptions {
@@ -28,9 +26,8 @@ export interface PublishSpellbookOptions {
* 4. Yields the signed event (ActionHub handles publishing)
* 5. Marks local spellbook as published if localId provided
*
* @param hub - The action hub instance
* @param options - Spellbook publishing options
* @yields Signed spellbook event ready for publishing
* @returns Action generator for ActionHub
*
* @throws Error if title is empty, no active account, or no signer available
*
@@ -45,99 +42,73 @@ export interface PublishSpellbookOptions {
* });
* ```
*/
export async function* PublishSpellbook(
hub: ActionHub,
options: PublishSpellbookOptions
): AsyncGenerator<NostrEvent> {
export function PublishSpellbook(options: PublishSpellbookOptions) {
const { state, title, description, workspaceIds, localId, content } = options;
// 1. Validate inputs
if (!title || !title.trim()) {
throw new Error("Title is required");
}
const account = accountManager.active;
if (!account) {
throw new Error("No active account. Please log in first.");
}
const signer = account.signer;
if (!signer) {
throw new Error("No signer available. Please connect a signer.");
}
// 2. Create event props from state or use provided content
let eventProps;
if (content) {
// Use provided content directly
eventProps = {
kind: 30777,
content: JSON.stringify(content),
tags: [
["d", slugify(title)],
["title", title],
["client", "grimoire"],
] as [string, string, ...string[]][],
};
if (description) {
eventProps.tags.push(["description", description]);
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
} else {
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
return async function* ({
factory,
}: ActionContext): AsyncGenerator<NostrEvent> {
// 1. Validate inputs
if (!title || !title.trim()) {
throw new Error("Title is required");
}
} else {
// Create from state
const encoded = createSpellbook({
state,
title,
description,
workspaceIds,
const account = accountManager.active;
if (!account) {
throw new Error("No active account. Please log in first.");
}
const signer = account.signer;
if (!signer) {
throw new Error("No signer available. Please connect a signer.");
}
// 2. Create event props from state or use provided content
let eventProps;
if (content) {
// Use provided content directly
eventProps = {
kind: 30777,
content: JSON.stringify(content),
tags: [
["d", slugify(title)],
["title", title],
["client", "grimoire"],
] as [string, string, ...string[]][],
};
if (description) {
eventProps.tags.push(["description", description]);
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
} else {
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
}
} else {
// Create from state
const encoded = createSpellbook({
state,
title,
description,
workspaceIds,
});
eventProps = encoded.eventProps;
}
// 3. Build draft using factory from context
const draft = await factory.build({
kind: eventProps.kind,
content: eventProps.content,
tags: eventProps.tags,
});
eventProps = encoded.eventProps;
}
// 3. Build draft using hub's factory
const draft = await hub.factory.build({
kind: eventProps.kind,
content: eventProps.content,
tags: eventProps.tags,
signer,
});
// 4. Sign the event
const event = (await factory.sign(draft)) as SpellbookEvent;
// 4. Sign the event
const event = (await hub.factory.sign(draft, signer)) as SpellbookEvent;
// 5. Mark as published in local DB (before yielding for better UX)
if (localId) {
await markSpellbookPublished(localId, event);
}
// 5. Mark as published in local DB (before yielding for better UX)
if (localId) {
await markSpellbookPublished(localId, event);
}
// 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing
yield event;
}
/**
* Publishes a spellbook to Nostr with explicit relay selection
* Use this when you need more control over which relays to publish to
*
* @param hub - The action hub instance
* @param options - Spellbook publishing options
* @param additionalRelays - Additional relays to publish to (merged with author's outbox)
* @yields Signed spellbook event with relay hints
*/
export async function* PublishSpellbookWithRelays(
hub: ActionHub,
options: PublishSpellbookOptions,
additionalRelays: string[] = AGGREGATOR_RELAYS
): AsyncGenerator<NostrEvent> {
// Use the main action to create and sign the event
for await (const event of PublishSpellbook(hub, options)) {
// Add relay hints to the event for broader reach
// Note: The event is already signed, but we can enhance it by publishing to more relays
// via manual pool.publish call if needed
// For now, just yield - the ActionHub will handle publishing
// TODO: Consider adding relay hints to event tags before signing if needed
// 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing
yield event;
}
};
}

View File

@@ -93,8 +93,9 @@ export function SaveSpellbookDialog({
? existingSpellbook.slug
: title.toLowerCase().trim().replace(/\s+/g, "-");
// 3. Save locally
// 3. Save locally (pass existing ID in update mode to prevent duplicates)
const localSpellbook = await saveSpellbook({
id: isUpdateMode ? existingSpellbook.localId : undefined,
slug,
title,
description,

View File

@@ -0,0 +1,281 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
saveSpellbook,
getSpellbook,
deleteSpellbook,
markSpellbookPublished,
getAllSpellbooks,
} from "./spellbook-storage";
import db from "./db";
import type { SpellbookContent } from "@/types/spell";
describe("Spellbook Storage", () => {
// Clean up database before each test
beforeEach(async () => {
await db.spellbooks.clear();
});
afterEach(async () => {
await db.spellbooks.clear();
});
const mockContent: SpellbookContent = {
version: 1,
workspaces: {
"ws-1": {
id: "ws-1",
number: 1,
label: "Main",
layout: "win-1",
windowIds: ["win-1"],
},
},
windows: {
"win-1": {
id: "win-1",
appId: "req",
props: { filter: { kinds: [1] } },
commandString: "req -k 1",
},
},
};
describe("saveSpellbook", () => {
it("should save a new spellbook with generated ID", async () => {
const saved = await saveSpellbook({
slug: "test-spellbook",
title: "Test Spellbook",
description: "Test description",
content: mockContent,
isPublished: false,
});
expect(saved.id).toBeDefined();
expect(saved.slug).toBe("test-spellbook");
expect(saved.title).toBe("Test Spellbook");
expect(saved.createdAt).toBeDefined();
expect(saved.isPublished).toBe(false);
});
it("should update existing spellbook when saving with same slug (local-only)", async () => {
// Save initial version
const first = await saveSpellbook({
slug: "my-layout",
title: "My Layout",
content: mockContent,
isPublished: false,
});
// Save again with same slug (should update, not create new)
const second = await saveSpellbook({
slug: "my-layout",
title: "My Updated Layout",
content: mockContent,
isPublished: false,
});
// Should have same ID
expect(second.id).toBe(first.id);
expect(second.title).toBe("My Updated Layout");
// Should only have one spellbook in DB
const all = await getAllSpellbooks();
expect(all.length).toBe(1);
expect(all[0].title).toBe("My Updated Layout");
});
it("should update existing spellbook when saving with same slug and pubkey", async () => {
const mockEvent = {
id: "event-1",
pubkey: "test-pubkey",
created_at: Math.floor(Date.now() / 1000),
kind: 30777,
tags: [["d", "shared-layout"]],
content: JSON.stringify(mockContent),
sig: "test-sig",
} as any;
// Save initial version
const first = await saveSpellbook({
slug: "shared-layout",
title: "Shared Layout",
content: mockContent,
isPublished: true,
eventId: "event-1",
event: mockEvent,
});
// Save again with same slug and pubkey
const second = await saveSpellbook({
slug: "shared-layout",
title: "Updated Shared Layout",
content: mockContent,
isPublished: true,
eventId: "event-1",
event: mockEvent,
});
// Should have same ID (deduplicated)
expect(second.id).toBe(first.id);
expect(second.title).toBe("Updated Shared Layout");
// Should only have one spellbook in DB
const all = await getAllSpellbooks();
expect(all.length).toBe(1);
});
it("should create separate spellbooks for different pubkeys with same slug", async () => {
const event1 = {
id: "event-1",
pubkey: "pubkey-1",
created_at: Math.floor(Date.now() / 1000),
kind: 30777,
tags: [["d", "layout"]],
content: JSON.stringify(mockContent),
sig: "sig-1",
} as any;
const event2 = {
id: "event-2",
pubkey: "pubkey-2",
created_at: Math.floor(Date.now() / 1000),
kind: 30777,
tags: [["d", "layout"]],
content: JSON.stringify(mockContent),
sig: "sig-2",
} as any;
const first = await saveSpellbook({
slug: "layout",
title: "Layout by User 1",
content: mockContent,
isPublished: true,
eventId: "event-1",
event: event1,
});
const second = await saveSpellbook({
slug: "layout",
title: "Layout by User 2",
content: mockContent,
isPublished: true,
eventId: "event-2",
event: event2,
});
// Should have different IDs
expect(first.id).not.toBe(second.id);
// Should have two spellbooks
const all = await getAllSpellbooks();
expect(all.length).toBe(2);
});
it("should use provided ID when specified", async () => {
const customId = "custom-id-123";
const saved = await saveSpellbook({
id: customId,
slug: "custom",
title: "Custom ID",
content: mockContent,
isPublished: false,
});
expect(saved.id).toBe(customId);
});
});
describe("getSpellbook", () => {
it("should retrieve spellbook by ID", async () => {
const saved = await saveSpellbook({
slug: "test",
title: "Test",
content: mockContent,
isPublished: false,
});
const retrieved = await getSpellbook(saved.id);
expect(retrieved).toBeDefined();
expect(retrieved?.id).toBe(saved.id);
expect(retrieved?.title).toBe("Test");
});
it("should return undefined for non-existent ID", async () => {
const retrieved = await getSpellbook("non-existent");
expect(retrieved).toBeUndefined();
});
});
describe("deleteSpellbook", () => {
it("should soft-delete a spellbook", async () => {
const saved = await saveSpellbook({
slug: "to-delete",
title: "To Delete",
content: mockContent,
isPublished: false,
});
await deleteSpellbook(saved.id);
const retrieved = await getSpellbook(saved.id);
expect(retrieved?.deletedAt).toBeDefined();
});
});
describe("markSpellbookPublished", () => {
it("should mark spellbook as published with event", async () => {
const saved = await saveSpellbook({
slug: "to-publish",
title: "To Publish",
content: mockContent,
isPublished: false,
});
const mockEvent = {
id: "published-event-id",
pubkey: "test-pubkey",
created_at: Math.floor(Date.now() / 1000),
kind: 30777,
tags: [["d", "to-publish"]],
content: JSON.stringify(mockContent),
sig: "test-sig",
} as any;
await markSpellbookPublished(saved.id, mockEvent);
const updated = await getSpellbook(saved.id);
expect(updated?.isPublished).toBe(true);
expect(updated?.eventId).toBe("published-event-id");
expect(updated?.event).toEqual(mockEvent);
});
});
describe("getAllSpellbooks", () => {
it("should return all spellbooks sorted by createdAt", async () => {
// Create spellbooks with different timestamps
const first = await saveSpellbook({
slug: "first",
title: "First",
content: mockContent,
isPublished: false,
});
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const second = await saveSpellbook({
slug: "second",
title: "Second",
content: mockContent,
isPublished: false,
});
const all = await getAllSpellbooks();
expect(all.length).toBe(2);
// Should be in reverse chronological order (newest first)
expect(all[0].id).toBe(second.id);
expect(all[1].id).toBe(first.id);
});
});
});

View File

@@ -1,14 +1,51 @@
import db, { LocalSpellbook } from "./db";
import { SpellbookEvent } from "@/types/spell";
/**
* Find existing spellbook by slug and pubkey
*/
async function findExistingSpellbook(
slug: string,
pubkey?: string,
): Promise<LocalSpellbook | undefined> {
if (!pubkey) {
// For local-only spellbooks, match by slug
const spellbooks = await db.spellbooks.where("slug").equals(slug).toArray();
return spellbooks.find((s) => !s.event?.pubkey);
}
// For published spellbooks, match by slug AND pubkey
const spellbooks = await db.spellbooks.where("slug").equals(slug).toArray();
return spellbooks.find((s) => s.event?.pubkey === pubkey);
}
/**
* Save a spellbook to local storage
* If a spellbook with the same slug and pubkey exists, it will be updated
*/
export async function saveSpellbook(
spellbook: Omit<LocalSpellbook, "id" | "createdAt">,
spellbook: Omit<LocalSpellbook, "id" | "createdAt"> & { id?: string },
): Promise<LocalSpellbook> {
const id = spellbook.eventId || crypto.randomUUID();
const createdAt = Date.now();
// Check for existing spellbook
const pubkey = spellbook.event?.pubkey;
const existing = await findExistingSpellbook(spellbook.slug, pubkey);
let id: string;
let createdAt: number;
if (existing) {
// Update existing spellbook
id = existing.id;
createdAt = existing.createdAt;
} else if (spellbook.id) {
// Use provided ID (for updates via dialog)
id = spellbook.id;
createdAt = Date.now();
} else {
// Create new spellbook
id = spellbook.eventId || crypto.randomUUID();
createdAt = Date.now();
}
const localSpellbook: LocalSpellbook = {
id,