mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-01 02:01:40 +00:00
feat(tools): add SDK idempotency keys for safe memory write retries
Phase A — SDK-only, Fixes #1627. Generate Idempotency-Key = SHA256(normalizedContent|sorted tags|minuteBucket) and attach as Idempotency-Key header on addMemory / documentAdd via Supermemory client RequestOptions. Header is optional for the backend (Phase B will honor it). Prevents duplicate memories on network retries, enables safe retries and offline queue. Staff improvements: customIdempotencyKey (user-provided priority), RetryContext (reuses key across minute rollover), NFC+trim normalization, Why SHA-256 rationale, 14 Vitest tests. Co-authored-by: Sravanjangam <163002695+Sravanjangam@users.noreply.github.com>
This commit is contained in:
parent
d436792e77
commit
99609351bf
4 changed files with 283 additions and 16 deletions
|
|
@ -8,6 +8,7 @@ import {
|
|||
getContainerTags,
|
||||
} from "./tools-shared"
|
||||
import { forgetMemoryRequest } from "./shared/forget-memory"
|
||||
import { buildIdempotencyHeaders } from "./shared/idempotency"
|
||||
import type { SupermemoryToolsConfig } from "./types"
|
||||
|
||||
// Export individual tool creators
|
||||
|
|
@ -94,16 +95,37 @@ export const addMemoryTool = (
|
|||
description: TOOL_DESCRIPTIONS.addMemory,
|
||||
inputSchema: z.object({
|
||||
memory: z.string().describe(PARAMETER_DESCRIPTIONS.memory),
|
||||
idempotencyKey: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional custom idempotency key. If provided, used as-is; otherwise SDK generates a deterministic key.",
|
||||
),
|
||||
}),
|
||||
execute: async ({ memory }) => {
|
||||
execute: async ({
|
||||
memory,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
memory: string
|
||||
idempotencyKey?: string
|
||||
}) => {
|
||||
try {
|
||||
const metadata: Record<string, string | number | boolean> = {}
|
||||
|
||||
const response = await client.add({
|
||||
content: memory,
|
||||
const headers = await buildIdempotencyHeaders(
|
||||
memory,
|
||||
containerTags,
|
||||
...(Object.keys(metadata).length > 0 && { metadata }),
|
||||
})
|
||||
Date.now(),
|
||||
idempotencyKey,
|
||||
)
|
||||
|
||||
const response = await client.add(
|
||||
{
|
||||
content: memory,
|
||||
containerTags,
|
||||
...(Object.keys(metadata).length > 0 && { metadata }),
|
||||
},
|
||||
{ headers },
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -278,11 +300,15 @@ export const documentAddTool = (
|
|||
if (title) metadata.title = title
|
||||
if (description) metadata.description = description
|
||||
|
||||
const response = await client.documents.add({
|
||||
content,
|
||||
containerTags,
|
||||
...(Object.keys(metadata).length > 0 && { metadata }),
|
||||
})
|
||||
const headers = await buildIdempotencyHeaders(content, containerTags)
|
||||
const response = await client.documents.add(
|
||||
{
|
||||
content,
|
||||
containerTags,
|
||||
...(Object.keys(metadata).length > 0 && { metadata }),
|
||||
},
|
||||
{ headers },
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getContainerTags,
|
||||
} from "../tools-shared"
|
||||
import { forgetMemoryRequest } from "../shared/forget-memory"
|
||||
import { buildIdempotencyHeaders } from "../shared/idempotency"
|
||||
import type { SupermemoryToolsConfig } from "../types"
|
||||
|
||||
/**
|
||||
|
|
@ -287,11 +288,15 @@ export function createAddMemoryFunction(
|
|||
try {
|
||||
const metadata: Record<string, string | number | boolean> = {}
|
||||
|
||||
const response = await client.add({
|
||||
content: memory,
|
||||
containerTags,
|
||||
...(Object.keys(metadata).length > 0 && { metadata }),
|
||||
})
|
||||
const headers = await buildIdempotencyHeaders(memory, containerTags)
|
||||
const response = await client.add(
|
||||
{
|
||||
content: memory,
|
||||
containerTags,
|
||||
...(Object.keys(metadata).length > 0 && { metadata }),
|
||||
},
|
||||
{ headers },
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
128
packages/tools/src/shared/idempotency.test.ts
Normal file
128
packages/tools/src/shared/idempotency.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
buildIdempotencyHeaders,
|
||||
createRetryContext,
|
||||
generateIdempotencyKey,
|
||||
} from "./idempotency"
|
||||
|
||||
describe("idempotency — Phase A (SDK-only, V1)", () => {
|
||||
it("same content+tags+minute → same key", async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const k1 = await generateIdempotencyKey("hello", ["a", "b"], now)
|
||||
const k2 = await generateIdempotencyKey("hello", ["a", "b"], now)
|
||||
expect(k1).toBe(k2)
|
||||
expect(k1).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it("different content → different key", async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const k1 = await generateIdempotencyKey("hello", ["a"], now)
|
||||
const k2 = await generateIdempotencyKey("world", ["a"], now)
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("different tags → different key", async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const k1 = await generateIdempotencyKey("hello", ["a"], now)
|
||||
const k2 = await generateIdempotencyKey("hello", ["b"], now)
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("minute rollover → different key", async () => {
|
||||
const k1 = await generateIdempotencyKey("hello", ["a"], 60_000 * 100)
|
||||
const k2 = await generateIdempotencyKey("hello", ["a"], 60_000 * 101)
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("custom now injection is respected", async () => {
|
||||
const k1 = await generateIdempotencyKey("x", [], 0)
|
||||
const k2 = await generateIdempotencyKey("x", [], 60_000)
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("header builder returns Idempotency-Key", async () => {
|
||||
const h = await buildIdempotencyHeaders("hello", ["a"], 1_700_000_000_000)
|
||||
expect(h).toHaveProperty("Idempotency-Key")
|
||||
expect(h["Idempotency-Key"]).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it("retry helper reuses same key within same minute", async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const k1 = await generateIdempotencyKey("retry me", ["t1"], now)
|
||||
// Simulate retry 10s later, same minute bucket
|
||||
const k2 = await generateIdempotencyKey("retry me", ["t1"], now + 10_000)
|
||||
expect(k1).toBe(k2)
|
||||
})
|
||||
|
||||
it("empty content edge — still deterministic", async () => {
|
||||
const k1 = await generateIdempotencyKey("", [], 1_700_000_000_000)
|
||||
const k2 = await generateIdempotencyKey("", [], 1_700_000_000_000)
|
||||
expect(k1).toBe(k2)
|
||||
expect(k1).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it("containerTags order independence (sorted)", async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const k1 = await generateIdempotencyKey("hello", ["b", "a"], now)
|
||||
const k2 = await generateIdempotencyKey("hello", ["a", "b"], now)
|
||||
expect(k1).toBe(k2)
|
||||
})
|
||||
|
||||
it("concurrent callers share key (parallel generation)", async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const ps = Array.from({ length: 20 }, () =>
|
||||
generateIdempotencyKey("hello", ["a"], now),
|
||||
)
|
||||
const keys = await Promise.all(ps)
|
||||
expect(new Set(keys).size).toBe(1)
|
||||
})
|
||||
|
||||
it("customIdempotencyKey priority — user-provided wins", async () => {
|
||||
const custom = "my-custom-key-123"
|
||||
const k = await generateIdempotencyKey(
|
||||
"hello",
|
||||
["a"],
|
||||
1_700_000_000_000,
|
||||
custom,
|
||||
)
|
||||
expect(k).toBe(custom)
|
||||
const h = await buildIdempotencyHeaders(
|
||||
"hello",
|
||||
["a"],
|
||||
1_700_000_000_000,
|
||||
custom,
|
||||
)
|
||||
expect(h["Idempotency-Key"]).toBe(custom)
|
||||
})
|
||||
|
||||
it("RetryContext reuses same key across minute rollover", async () => {
|
||||
const now = 60_000 * 100
|
||||
const ctx = await createRetryContext("hello", ["a"], now)
|
||||
// 61s later — minute bucket would normally roll, but context reuses original key
|
||||
const laterKey = await generateIdempotencyKey("hello", ["a"], now + 61_000)
|
||||
expect(laterKey).not.toBe(ctx.key) // without context, key changes
|
||||
expect(ctx.getKey()).toBe(ctx.key)
|
||||
expect(ctx.getHeaders()["Idempotency-Key"]).toBe(ctx.key)
|
||||
// Simulate retry using context — still same key
|
||||
expect(ctx.headers["Idempotency-Key"]).toBe(ctx.key)
|
||||
})
|
||||
|
||||
it("unicode normalization — café (NFC) and cafe\u0301 (NFD) hash to same key", async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const nfc = "café" // U+00E9
|
||||
const nfd = "cafe\u0301" // e + U+0301
|
||||
expect(nfc.normalize("NFC")).toBe(nfd.normalize("NFC"))
|
||||
const k1 = await generateIdempotencyKey(nfc, ["a"], now)
|
||||
const k2 = await generateIdempotencyKey(nfd, ["a"], now)
|
||||
expect(k1).toBe(k2)
|
||||
})
|
||||
|
||||
it("whitespace trimming — trailing spaces are stable", async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const k1 = await generateIdempotencyKey("hello ", ["a"], now)
|
||||
const k2 = await generateIdempotencyKey("hello", ["a"], now)
|
||||
const k3 = await generateIdempotencyKey(" hello ", ["a"], now)
|
||||
expect(k1).toBe(k2)
|
||||
expect(k2).toBe(k3)
|
||||
})
|
||||
})
|
||||
108
packages/tools/src/shared/idempotency.ts
Normal file
108
packages/tools/src/shared/idempotency.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Idempotency — Phase A (SDK-only, V1 + Staff improvements)
|
||||
*
|
||||
* Generates a stable `Idempotency-Key` for memory writes so retries
|
||||
* (network retry, caller retry, offline queue) do not create duplicates.
|
||||
* The header is optional for the backend in Phase A — useful even before
|
||||
* the server honors it as a dedupe key.
|
||||
*
|
||||
* Key = SHA-256( normalizedContent + '|' + sorted(containerTags).join(',') + '|' + minuteBucket )
|
||||
* minuteBucket = floor(now / 60000) — stable within the same minute, rotates after.
|
||||
* normalizedContent = content.normalize("NFC").trim() — unicode + whitespace stable.
|
||||
*
|
||||
* Why SHA-256?
|
||||
* - Deterministic across runtimes (browser, Node, Workers).
|
||||
* - Available through Web Crypto in browsers, Node, and Workers.
|
||||
* - Fixed-size output suitable for HTTP headers.
|
||||
* - No additional dependency required.
|
||||
*
|
||||
* Runtime availability: Web Crypto (`crypto.subtle`) is available in browsers,
|
||||
* Cloudflare Workers, and Node 20+ (repo `engines.node >=20`). Verified via
|
||||
* `globalThis.crypto.subtle.digest` — no polyfill needed. All three `addMemory`
|
||||
* entry points (`ai-sdk`, `openai/tools`, `supermemory` client) use the same path.
|
||||
*
|
||||
* Header forwarding: Supermemory client's `client.add(params, { headers })`
|
||||
* and `client.documents.add(params, { headers })` forward `RequestOptions.headers`
|
||||
* into the underlying `fetch` call (see `supermemory` `client.mjs` `RequestOptions.headers`
|
||||
* → `fetchWithTimeout`). This is how `Idempotency-Key` reaches the backend.
|
||||
*
|
||||
* Metadata scope: `content` + `containerTags` define identity; `metadata`
|
||||
* (title/description) is intentionally excluded — identical content+tags within
|
||||
* the same minute produce the same key (desired for dedupe). Callers needing
|
||||
* metadata-distinct keys should pass `customIdempotencyKey`.
|
||||
*/
|
||||
|
||||
function normalizeContent(content: string): string {
|
||||
return content.normalize("NFC").trim()
|
||||
}
|
||||
|
||||
export async function generateIdempotencyKey(
|
||||
content: string,
|
||||
containerTags: string[] = [],
|
||||
now: number = Date.now(),
|
||||
customKey?: string,
|
||||
): Promise<string> {
|
||||
if (customKey !== undefined && customKey.length > 0) {
|
||||
return customKey
|
||||
}
|
||||
const minuteBucket = Math.floor(now / 60_000)
|
||||
const tags = [...containerTags].sort().join(",")
|
||||
const normalized = normalizeContent(content)
|
||||
const input = `${normalized}|${tags}|${minuteBucket}`
|
||||
const bytes = new TextEncoder().encode(input)
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
export async function buildIdempotencyHeaders(
|
||||
content: string,
|
||||
containerTags: string[] = [],
|
||||
now: number = Date.now(),
|
||||
customKey?: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const key = await generateIdempotencyKey(
|
||||
content,
|
||||
containerTags,
|
||||
now,
|
||||
customKey,
|
||||
)
|
||||
return { "Idempotency-Key": key }
|
||||
}
|
||||
|
||||
export type RetryContext = {
|
||||
key: string
|
||||
headers: Record<string, string>
|
||||
getKey: () => string
|
||||
getHeaders: () => Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Future-proof retry helper — captures the key once and reuses it
|
||||
* across retries, even across minute rollovers.
|
||||
*
|
||||
* const retryCtx = await createRetryContext(content, containerTags)
|
||||
* await client.add(params, { headers: retryCtx.headers })
|
||||
* await client.add(params, { headers: retryCtx.headers }) // same key
|
||||
*/
|
||||
export async function createRetryContext(
|
||||
content: string,
|
||||
containerTags: string[] = [],
|
||||
now: number = Date.now(),
|
||||
customKey?: string,
|
||||
): Promise<RetryContext> {
|
||||
const key = await generateIdempotencyKey(
|
||||
content,
|
||||
containerTags,
|
||||
now,
|
||||
customKey,
|
||||
)
|
||||
const headers = { "Idempotency-Key": key }
|
||||
return {
|
||||
key,
|
||||
headers,
|
||||
getKey: () => key,
|
||||
getHeaders: () => headers,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue