From 494d2fd02f357b51eb62ca00e4e5ac26d490b8a3 Mon Sep 17 00:00:00 2001 From: Clement Boiteux Date: Sun, 9 Aug 2026 13:45:57 -0700 Subject: [PATCH] schema improvements + dynamodb test --- .gitignore | 3 +- docs/dynamodb-local-testing.md | 34 + frontend/.env.example | 9 + .../app/api/cards/[cardId]/reset/route.ts | 22 + frontend/app/api/cards/route.ts | 37 + frontend/app/page.tsx | 27 +- frontend/lib/api.ts | 61 +- frontend/lib/server/dynamodb.ts | 65 ++ frontend/lib/server/user-cards.ts | 95 ++ frontend/lib/test-data/example-base-card.ts | 28 + frontend/package-lock.json | 869 ++++++++++++++++++ frontend/package.json | 9 +- frontend/scripts/dynamodb-smoke-test.ts | 71 ++ frontend/scripts/dynamodb_boto3_smoke.py | 135 +++ frontend/types/vocabulary.ts | 118 ++- 15 files changed, 1529 insertions(+), 54 deletions(-) create mode 100644 docs/dynamodb-local-testing.md create mode 100644 frontend/app/api/cards/[cardId]/reset/route.ts create mode 100644 frontend/app/api/cards/route.ts create mode 100644 frontend/lib/server/dynamodb.ts create mode 100644 frontend/lib/server/user-cards.ts create mode 100644 frontend/lib/test-data/example-base-card.ts create mode 100644 frontend/scripts/dynamodb-smoke-test.ts create mode 100644 frontend/scripts/dynamodb_boto3_smoke.py diff --git a/.gitignore b/.gitignore index 764d786..fd114a5 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ pnpm-debug.log* Thumbs.db .vscode/ .idea/ -/frontend/.next \ No newline at end of file +/frontend/.next +/frontend/tsconfig.tsbuildinfo diff --git a/docs/dynamodb-local-testing.md b/docs/dynamodb-local-testing.md new file mode 100644 index 0000000..4ebd2d5 --- /dev/null +++ b/docs/dynamodb-local-testing.md @@ -0,0 +1,34 @@ +# DynamoDB local smoke test + +This test requires neither the word lookup Lambda nor AWS credentials. It creates two local tables, writes a typed `BaseCard` as the lookup Lambda would, saves a `UserCard`, prints DynamoDB's wrapped attributes, and resets the card. + +Start DynamoDB Local with Docker: + +```powershell +docker run --rm -p 8000:8000 amazon/dynamodb-local +``` + +In a second terminal: + +```powershell +cd frontend +$env:DYNAMODB_ENDPOINT = 'http://localhost:8000' +npm run dynamodb:smoke +``` + +The sample Lambda-shaped response is in `frontend/lib/test-data/example-base-card.ts`. A real lookup Lambda must return the same `BaseCard` contract, wrapped as `{ "card": baseCard }`; the frontend and save endpoint use that type directly. + +The smoke-test output shows `S`, `N`, `M`, and `L` wrappers. For example, `content` is an `M`, `definitions` is an `L`, and each definition's `id` and `text` are `S` values. + +## Boto3 direct insertion + +With DynamoDB Local still running, install Boto3 once and run the independent Python test: + +```powershell +cd frontend +py -3.12 -m pip install boto3 +$env:DYNAMODB_ENDPOINT = 'http://127.0.0.1:8000' +py -3.12 scripts/dynamodb_boto3_smoke.py +``` + +This uses Boto3's low-level `put_item` API and explicitly produces DynamoDB `AttributeValue` wrappers instead of relying on Boto3's higher-level resource serializer. diff --git a/frontend/.env.example b/frontend/.env.example index 090c10c..699a0d0 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -8,3 +8,12 @@ POST_LOGIN_REDIRECT=/ POST_LOGOUT_REDIRECT=/ NEXT_PUBLIC_API_URL=http://localhost:3000/api + +# Server-only DynamoDB configuration. Credentials come from your AWS profile +# locally or the deployed runtime's IAM role; do not expose them with NEXT_PUBLIC_. +AWS_REGION=us-east-1 +USER_CARDS_TABLE_NAME=UserCards +BASE_CARDS_TABLE_NAME=BaseCards + +# Local-only, for `npm run dynamodb:smoke` with DynamoDB Local. +# DYNAMODB_ENDPOINT=http://localhost:8000 diff --git a/frontend/app/api/cards/[cardId]/reset/route.ts b/frontend/app/api/cards/[cardId]/reset/route.ts new file mode 100644 index 0000000..e77a3aa --- /dev/null +++ b/frontend/app/api/cards/[cardId]/reset/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from '@/lib/auth-session' +import { resetUserCard } from '@/lib/server/user-cards' + +export const runtime = 'nodejs' + +// POST /api/cards/:cardId/reset +export async function POST(_req: NextRequest, { params }: { params: { cardId: string } }) { + const session = await getServerSession() + if (!session.authenticated || !session.user.openId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + try { + const card = await resetUserCard(session.user.openId, params.cardId) + if (!card) return NextResponse.json({ error: 'Card not found' }, { status: 404 }) + return NextResponse.json({ card }) + } catch (error) { + console.error('Unable to reset user card', error) + return NextResponse.json({ error: 'Unable to reset card' }, { status: 500 }) + } +} diff --git a/frontend/app/api/cards/route.ts b/frontend/app/api/cards/route.ts new file mode 100644 index 0000000..9ca1918 --- /dev/null +++ b/frontend/app/api/cards/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from '@/lib/auth-session' +import { saveUserCard } from '@/lib/server/user-cards' +import type { BaseCard } from '@/types/vocabulary' + +export const runtime = 'nodejs' + +function isBaseCard(value: unknown): value is BaseCard { + if (!value || typeof value !== 'object') return false + const card = value as Partial + return Boolean( + card.baseCardId && card.language && card.lemma && card.normalizedLemma && + Array.isArray(card.definitions) && Array.isArray(card.examples) && card.metadata + ) +} + +// POST /api/cards — save a lookup result without calling a Lambda. +export async function POST(req: NextRequest) { + const session = await getServerSession() + if (!session.authenticated || !session.user.openId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body: unknown = await req.json().catch(() => null) + const request = body as { baseCard?: unknown; notes?: unknown } | null + if (!request || !isBaseCard(request.baseCard) || (request.notes !== undefined && typeof request.notes !== 'string')) { + return NextResponse.json({ error: 'Expected { baseCard, notes? }' }, { status: 400 }) + } + + try { + const card = await saveUserCard(session.user.openId, request.baseCard, request.notes) + return NextResponse.json({ card }, { status: 201 }) + } catch (error) { + console.error('Unable to save user card', error) + return NextResponse.json({ error: 'Unable to save card' }, { status: 500 }) + } +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e01ac2a..fc23ed0 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -6,11 +6,12 @@ import { motion, AnimatePresence } from 'framer-motion' import Navigation from '@/components/Navigation' import VocabularyCard from '@/components/VocabularyCard' import ChatAssistant from '@/components/ChatAssistant' +import type { BaseCard } from '@/types/vocabulary' export default function LookupPage() { const [searchQuery, setSearchQuery] = useState('') const [selectedLanguage, setSelectedLanguage] = useState('') - const [searchResult, setSearchResult] = useState(null) + const [searchResult, setSearchResult] = useState(null) const [isSearching, setIsSearching] = useState(false) const [showChat, setShowChat] = useState(false) @@ -24,15 +25,7 @@ const languageCodes: Record = { setIsSearching(true) try { const data = await apiClient.lookup({ lang: languageCodes[selectedLanguage], lemma: searchQuery.trim() }) - const card = data.card - setSearchResult({ - word: card.lemma || searchQuery, - language: selectedLanguage, - partOfSpeech: card.partOfSpeech, - definitions: [card.shortDefinition], - examples: card.examples.map((e: { src: string; tgt: string }) => ({ src: e.src, tgt: e.tgt })), - relatedWords: card.relatedWords || [] - }) + setSearchResult(data.card) } catch { alert('Word not found. Try another word.') } finally { @@ -177,12 +170,12 @@ const languageCodes: Record = { transition={{ duration: 0.5 }} > definition.text)} + examples={searchResult.examples.map((example) => ({ src: example.source, tgt: example.translation }))} + relatedWords={(searchResult.relatedWords || []).map((relatedWord) => relatedWord.lemma)} + partOfSpeech={searchResult.definitions[0]?.partOfSpeech} onSave={handleSave} /> @@ -244,7 +237,7 @@ const languageCodes: Record = { {showChat && searchResult && ( setShowChat(false)} /> )} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 3103876..5feb3ba 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,5 +1,6 @@ // lib/api.ts -// API utilities for connecting to the backend Lambda functions +// API utilities for connecting to the backend Lambda functions +import type { BaseCard, CardLookupResponse, UserCard } from '@/types/vocabulary' const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api' @@ -8,15 +9,15 @@ export interface LookupParams { lemma: string } -export interface CreateCardParams extends LookupParams { - card: any - notes?: string -} +export interface CreateCardParams { + baseCard: BaseCard + notes?: string +} -export interface ChatParams { - word: string - baseCard: any - userEdits?: any +export interface ChatParams { + word: string + baseCard: BaseCard + userEdits?: Partial notes?: string question: string } @@ -33,7 +34,7 @@ class ApiClient { this.token = token } - private async request(endpoint: string, options: RequestInit = {}) { + private async request(endpoint: string, options: RequestInit = {}): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json', ...options.headers, @@ -52,36 +53,54 @@ class ApiClient { throw new Error(`API error: ${response.status} ${response.statusText}`) } - return response.json() + return response.json() as Promise } // GET /cardLookup?lang=&lemma= - async lookup({ lang, lemma }: LookupParams) { - return this.request(`/cardLookup?lang=${lang}&lemma=${encodeURIComponent(lemma)}`) + async lookup({ lang, lemma }: LookupParams): Promise { + return this.request(`/cardLookup?lang=${encodeURIComponent(lang)}&lemma=${encodeURIComponent(lemma)}`) } // GET /allCards (list user cards) - async getCards(userId: string) { - return this.request('/allCards', { + async getCards(userId: string): Promise<{ cards: UserCard[] }> { + return this.request<{ cards: UserCard[] }>('/allCards', { headers: { 'x-user-id': userId } }) } // POST /chatNuance (nuance Q&A for a word/card) - async chat(params: ChatParams) { - return this.request('/chatNuance', { + async chat(params: ChatParams): Promise<{ response: string }> { + return this.request<{ response: string }>('/chatNuance', { method: 'POST', body: JSON.stringify(params), }) } // DELETE /cards/:cardId - async deleteCard(cardId: string) { - return this.request(`/cards/${cardId}`, { + async deleteCard(cardId: string): Promise { + return this.request(`/cards/${encodeURIComponent(cardId)}`, { method: 'DELETE', }) - } -} + } + + // POST /cards — create a user-owned card from a BaseCard lookup result. + async saveCard(params: CreateCardParams): Promise<{ card: UserCard }> { + return this.request<{ card: UserCard }>('/cards', { + method: 'POST', + body: JSON.stringify(params), + }) + } + + /** + * Restores user-editable learning content from the referenced BaseCard. + * The backend preserves cardId, userId, createdAt, and baseRef. + */ + async resetCard(cardId: string): Promise<{ card: UserCard }> { + return this.request<{ card: UserCard }>(`/cards/${encodeURIComponent(cardId)}/reset`, { + method: 'POST', + }) + } +} export const apiClient = new ApiClient(API_BASE_URL) diff --git a/frontend/lib/server/dynamodb.ts b/frontend/lib/server/dynamodb.ts new file mode 100644 index 0000000..ffde19d --- /dev/null +++ b/frontend/lib/server/dynamodb.ts @@ -0,0 +1,65 @@ +import { + DynamoDBClient, + type AttributeValue, +} from '@aws-sdk/client-dynamodb' + +/** + * Converts application JSON to DynamoDB's low-level AttributeValue format. + * Objects become M (map), arrays become L (list), strings become S, numbers + * become N, and booleans become BOOL. Undefined object properties are omitted. + */ +export function toAttributeValue(value: unknown): AttributeValue { + if (value === null) return { NULL: true } + if (typeof value === 'string') return { S: value } + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('DynamoDB numbers must be finite') + return { N: String(value) } + } + if (typeof value === 'boolean') return { BOOL: value } + if (Array.isArray(value)) return { L: value.map(toAttributeValue) } + if (typeof value === 'object') { + const map: Record = {} + for (const [key, entry] of Object.entries(value)) { + if (entry !== undefined) map[key] = toAttributeValue(entry) + } + return { M: map } + } + throw new TypeError(`Unsupported DynamoDB value: ${typeof value}`) +} + +export function toItem(value: object): Record { + const item: Record = {} + for (const [key, entry] of Object.entries(value)) { + if (entry !== undefined) item[key] = toAttributeValue(entry) + } + return item +} + +export function fromAttributeValue(value: AttributeValue): unknown { + if (value.S !== undefined) return value.S + if (value.N !== undefined) return Number(value.N) + if (value.BOOL !== undefined) return value.BOOL + if (value.NULL) return null + if (value.L) return value.L.map(fromAttributeValue) + if (value.M) return fromItem(value.M) + if (value.SS) return value.SS + if (value.NS) return value.NS.map(Number) + throw new TypeError('Unsupported DynamoDB AttributeValue') +} + +export function fromItem(item: Record): T { + return Object.fromEntries(Object.entries(item).map(([key, value]) => [key, fromAttributeValue(value)])) as T +} + +let client: DynamoDBClient | undefined + +export function getDynamoClient() { + // The AWS SDK uses the standard AWS credential provider chain. This works + // with local AWS profiles as well as deployed IAM roles without app changes. + client ??= new DynamoDBClient({ + region: process.env.AWS_REGION || 'us-east-1', + // DynamoDB Local is opt-in, so production continues to use AWS's endpoint. + endpoint: process.env.DYNAMODB_ENDPOINT?.trim() || undefined, + }) + return client +} diff --git a/frontend/lib/server/user-cards.ts b/frontend/lib/server/user-cards.ts new file mode 100644 index 0000000..73acf9c --- /dev/null +++ b/frontend/lib/server/user-cards.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'crypto' +import { GetItemCommand, PutItemCommand } from '@aws-sdk/client-dynamodb' +import type { BaseCard, UserCard, UserCardContent } from '@/types/vocabulary' +import { fromItem, getDynamoClient, toItem } from './dynamodb' + +function requiredEnv(name: 'USER_CARDS_TABLE_NAME' | 'BASE_CARDS_TABLE_NAME') { + const value = process.env[name]?.trim() + if (!value) throw new Error(`Missing required environment variable: ${name}`) + return value +} + +export function contentFromBaseCard(card: BaseCard): UserCardContent { + return { + definitions: card.definitions, + examples: card.examples, + relatedWords: card.relatedWords, + collocations: card.collocations, + usageNotes: card.usageNotes, + } +} + +export function makeUserCard(userId: string, baseCard: BaseCard, generalNote?: string): UserCard { + const now = new Date().toISOString() + return { + userId, + cardId: randomUUID(), + baseRef: { + baseCardId: baseCard.baseCardId, + schemaVersion: baseCard.metadata.schemaVersion, + source: baseCard.metadata.source, + sourceVersion: baseCard.metadata.sourceVersion, + }, + language: baseCard.language, + lemma: baseCard.lemma, + normalizedLemma: baseCard.normalizedLemma, + forms: baseCard.forms, + romanization: baseCard.romanization, + content: contentFromBaseCard(baseCard), + notes: generalNote ? { general: generalNote } : undefined, + revision: 1, + createdAt: now, + updatedAt: now, + } +} + +export async function saveUserCard(userId: string, baseCard: BaseCard, generalNote?: string) { + const card = makeUserCard(userId, baseCard, generalNote) + await getDynamoClient().send( + new PutItemCommand({ + TableName: requiredEnv('USER_CARDS_TABLE_NAME'), + Item: toItem(card), + // Saving a card is create-only, so retries cannot overwrite another card. + ConditionExpression: 'attribute_not_exists(userId) AND attribute_not_exists(cardId)', + }) + ) + return card +} + +export async function resetUserCard(userId: string, cardId: string) { + const client = getDynamoClient() + const userTable = requiredEnv('USER_CARDS_TABLE_NAME') + const existingResponse = await client.send( + new GetItemCommand({ TableName: userTable, Key: toItem({ userId, cardId }), ConsistentRead: true }) + ) + if (!existingResponse.Item) return null + + const existing = fromItem(existingResponse.Item) + const baseResponse = await client.send( + new GetItemCommand({ + TableName: requiredEnv('BASE_CARDS_TABLE_NAME'), + Key: toItem({ PK: `BASECARD#${existing.baseRef.baseCardId}` }), + ConsistentRead: true, + }) + ) + if (!baseResponse.Item) throw new Error(`BaseCard not found: ${existing.baseRef.baseCardId}`) + + const baseCard = fromItem(baseResponse.Item) + const resetCard: UserCard = { + ...existing, + // Preserve cardId, userId, createdAt, and baseRef. Only editable learning + // content changes; warnings and notes remain user-owned additions. + content: contentFromBaseCard(baseCard), + revision: existing.revision + 1, + updatedAt: new Date().toISOString(), + } + await client.send( + new PutItemCommand({ + TableName: userTable, + Item: toItem(resetCard), + ConditionExpression: 'revision = :revision', + ExpressionAttributeValues: toItem({ ':revision': existing.revision }), + }) + ) + return resetCard +} diff --git a/frontend/lib/test-data/example-base-card.ts b/frontend/lib/test-data/example-base-card.ts new file mode 100644 index 0000000..205f031 --- /dev/null +++ b/frontend/lib/test-data/example-base-card.ts @@ -0,0 +1,28 @@ +import type { BaseCard } from '@/types/vocabulary' + +/** A realistic word-lookup Lambda response, used only for local smoke tests. */ +export const exampleBaseCard: BaseCard = { + baseCardId: 'zh#电脑#dian4-nao3', + language: 'zh', + lemma: '电脑', + normalizedLemma: '电脑', + forms: { simplified: '电脑', traditional: '電腦', variants: [] }, + romanization: { system: 'pinyin', value: 'diànnǎo' }, + definitions: [ + { id: 'def_1', text: 'computer', partOfSpeech: 'noun' }, + { id: 'def_2', text: 'electronic brain; computer', register: 'informal' }, + ], + examples: [ + { + id: 'ex_1', + source: '我买了一台新电脑。', + romanization: 'Wǒ mǎi le yì tái xīn diànnǎo.', + translation: 'I bought a new computer.', + definitionId: 'def_1', + }, + ], + relatedWords: [{ id: 'rel_1', lemma: '笔记本电脑', romanization: 'bǐjìběn diànnǎo', relation: 'related' }], + collocations: [{ id: 'col_1', text: '用电脑工作', romanization: 'yòng diànnǎo gōngzuò', translation: 'work using a computer' }], + usageNotes: ['电脑 is the usual general term for a computer.'], + metadata: { schemaVersion: 1, source: 'cc-cedict', sourceVersion: '2026-01-01' }, +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 04db480..25ebbba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "lingolm-frontend", "version": "0.1.0", "dependencies": { + "@aws-sdk/client-dynamodb": "^3.1106.0", "framer-motion": "^11.11.17", "jose": "^6.1.2", "next": "14.2.18", @@ -21,6 +22,7 @@ "autoprefixer": "^10.4.20", "postcss": "^8", "tailwindcss": "^3.4.1", + "tsx": "^4.23.11", "typescript": "^5" } }, @@ -37,6 +39,721 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@aws-sdk/client-dynamodb": { + "version": "3.1106.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1106.0.tgz", + "integrity": "sha512-VhYpGatp+5ke7HBc/8CqbukInNmsFT67W4d/QceP8/YIxpKfP0SRCSHlgRU85WTC5JcS88FWX3OxGYR63rEUdA==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/dynamodb-codec": "^3.973.41", + "@aws-sdk/middleware-endpoint-discovery": "^3.972.27", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/dynamodb-codec": { + "version": "3.973.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.41.tgz", + "integrity": "sha512-N4go/LzYFd6KJ+r7qqAjzmsw85PFN99RnDYZvw22FzU3VZgL5+umvncfu0M7hlzeIUKHSLL65HTJIXiLY7RJFg==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/endpoint-cache": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.9.tgz", + "integrity": "sha512-LFvdgq8SriaskUcjpBMDE7J2c9RmuT5v3gU36/znV71EU5DKUis4FmGFjCMelKCCViFeVrQADBAlIiOYRhEx6Q==", + "dependencies": { + "mnemonist": "0.38.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-endpoint-discovery": { + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.27.tgz", + "integrity": "sha512-5AJlxrsg27IGGiQauWOdVyqK55EN0EMIwXndkVHhBiPT4CtdSaz89/sAENSw+GP4KF+BOWcgycZFNjkOLmfo1A==", + "dependencies": { + "@aws-sdk/endpoint-cache": "^3.972.9", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -264,6 +981,81 @@ "node": ">= 8" } }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -406,6 +1198,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -589,6 +1386,47 @@ "dev": true, "license": "ISC" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -892,6 +1730,14 @@ "node": ">=8.6" } }, + "node_modules/mnemonist": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", + "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", + "dependencies": { + "obliterator": "^1.6.1" + } + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -1053,6 +1899,11 @@ "node": ">= 6" } }, + "node_modules/obliterator": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", + "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1607,6 +2458,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index df8671e..20a7e2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,14 +6,16 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "dynamodb:smoke": "tsx scripts/dynamodb-smoke-test.ts" }, "dependencies": { + "@aws-sdk/client-dynamodb": "^3.1106.0", + "framer-motion": "^11.11.17", "jose": "^6.1.2", "next": "14.2.18", "react": "^18.3.1", - "react-dom": "^18.3.1", - "framer-motion": "^11.11.17" + "react-dom": "^18.3.1" }, "devDependencies": { "@types/node": "^20", @@ -22,6 +24,7 @@ "autoprefixer": "^10.4.20", "postcss": "^8", "tailwindcss": "^3.4.1", + "tsx": "^4.23.11", "typescript": "^5" } } diff --git a/frontend/scripts/dynamodb-smoke-test.ts b/frontend/scripts/dynamodb-smoke-test.ts new file mode 100644 index 0000000..a35b2fd --- /dev/null +++ b/frontend/scripts/dynamodb-smoke-test.ts @@ -0,0 +1,71 @@ +import { + CreateTableCommand, + GetItemCommand, + PutItemCommand, + waitUntilTableExists, +} from '@aws-sdk/client-dynamodb' +import { exampleBaseCard } from '@/lib/test-data/example-base-card' +import { getDynamoClient, toItem } from '@/lib/server/dynamodb' +import { resetUserCard, saveUserCard } from '@/lib/server/user-cards' + +const endpoint = process.env.DYNAMODB_ENDPOINT || '' +if (!/^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(endpoint)) { + throw new Error('For safety, set DYNAMODB_ENDPOINT to a local DynamoDB endpoint, e.g. http://localhost:8000.') +} + +process.env.AWS_REGION ||= 'us-east-1' +process.env.USER_CARDS_TABLE_NAME ||= 'LingoLMUserCardsSmoke' +process.env.BASE_CARDS_TABLE_NAME ||= 'LingoLMBaseCardsSmoke' + +const client = getDynamoClient() + +async function ensureTable(name: string, keySchema: { AttributeName: string; KeyType: 'HASH' | 'RANGE' }[]) { + try { + await client.send(new CreateTableCommand({ + TableName: name, + BillingMode: 'PAY_PER_REQUEST', + AttributeDefinitions: keySchema.map(({ AttributeName }) => ({ AttributeName, AttributeType: 'S' })), + KeySchema: keySchema, + })) + await waitUntilTableExists( + { client, maxWaitTime: 10, minDelay: 1, maxDelay: 2 }, + { TableName: name } + ) + } catch (error) { + if ((error as { name?: string }).name !== 'ResourceInUseException') throw error + } +} + +async function main() { + const userTable = process.env.USER_CARDS_TABLE_NAME! + const baseTable = process.env.BASE_CARDS_TABLE_NAME! + await ensureTable(baseTable, [{ AttributeName: 'PK', KeyType: 'HASH' }]) + await ensureTable(userTable, [ + { AttributeName: 'userId', KeyType: 'HASH' }, + { AttributeName: 'cardId', KeyType: 'RANGE' }, + ]) + + // This is what the lookup Lambda would have returned and persisted in BaseCards. + await client.send(new PutItemCommand({ + TableName: baseTable, + Item: toItem({ PK: `BASECARD#${exampleBaseCard.baseCardId}`, ...exampleBaseCard }), + })) + + const saved = await saveUserCard('local-test-user', exampleBaseCard, 'Remember the measure word 台.') + const raw = await client.send(new GetItemCommand({ + TableName: userTable, + Key: toItem({ userId: saved.userId, cardId: saved.cardId }), + })) + console.log('Saved UserCards item uses wrappers:', JSON.stringify(raw.Item, null, 2)) + + // Simulate user drift, then prove reset replaces only editable content. + saved.content.definitions[0].text = 'incorrect meaning' + await client.send(new PutItemCommand({ TableName: userTable, Item: toItem(saved) })) + const reset = await resetUserCard(saved.userId, saved.cardId) + if (!reset || reset.content.definitions[0].text !== 'computer' || reset.notes?.general !== saved.notes?.general) { + throw new Error('Reset verification failed') + } + console.log(`PASS: saved ${saved.cardId} and reset it to BaseCard content (revision ${reset.revision}).`) +} + +void main() diff --git a/frontend/scripts/dynamodb_boto3_smoke.py b/frontend/scripts/dynamodb_boto3_smoke.py new file mode 100644 index 0000000..6ad3ea3 --- /dev/null +++ b/frontend/scripts/dynamodb_boto3_smoke.py @@ -0,0 +1,135 @@ +"""Seed BaseCards and UserCards with Boto3's low-level DynamoDB client. + +Run only against DynamoDB Local: + $env:DYNAMODB_ENDPOINT = 'http://127.0.0.1:8000' + python scripts/dynamodb_boto3_smoke.py +""" + +import copy +import os +import sys +import uuid +from datetime import datetime, timezone + +import boto3 +from botocore.exceptions import ClientError + +ENDPOINT = os.environ.get("DYNAMODB_ENDPOINT", "") +if not ENDPOINT.startswith(("http://localhost", "http://127.0.0.1")): + raise RuntimeError("Set DYNAMODB_ENDPOINT to DynamoDB Local (for example http://127.0.0.1:8000).") + +BASE_TABLE = os.environ.get("BASE_CARDS_TABLE_NAME", "LingoLMBaseCardsBoto3Smoke") +USER_TABLE = os.environ.get("USER_CARDS_TABLE_NAME", "LingoLMUserCardsBoto3Smoke") +client = boto3.client("dynamodb", endpoint_url=ENDPOINT, region_name=os.environ.get("AWS_REGION", "us-east-1"), aws_access_key_id="local", aws_secret_access_key="local") + +# This is equivalent to a successful word-lookup Lambda response's `card` field. +BASE_CARD = { + "baseCardId": "zh#电脑#dian4-nao3", + "language": "zh", + "lemma": "电脑", + "normalizedLemma": "电脑", + "forms": {"simplified": "电脑", "traditional": "電腦", "variants": []}, + "romanization": {"system": "pinyin", "value": "diànnǎo"}, + "definitions": [ + {"id": "def_1", "text": "computer", "partOfSpeech": "noun"}, + {"id": "def_2", "text": "electronic brain; computer", "register": "informal"}, + ], + "examples": [{"id": "ex_1", "source": "我买了一台新电脑。", "romanization": "Wǒ mǎi le yì tái xīn diànnǎo.", "translation": "I bought a new computer.", "definitionId": "def_1"}], + "relatedWords": [{"id": "rel_1", "lemma": "笔记本电脑", "romanization": "bǐjìběn diànnǎo", "relation": "related"}], + "collocations": [{"id": "col_1", "text": "用电脑工作", "romanization": "yòng diànnǎo gōngzuò", "translation": "work using a computer"}], + "usageNotes": ["电脑 is the usual general term for a computer."], + "metadata": {"schemaVersion": 1, "source": "cc-cedict", "sourceVersion": "2026-01-01"}, +} + + +def av(value): + """Explicitly turn JSON values into DynamoDB S/N/BOOL/M/L/NULL wrappers.""" + if value is None: + return {"NULL": True} + if isinstance(value, bool): + return {"BOOL": value} + if isinstance(value, str): + return {"S": value} + if isinstance(value, (int, float)): + return {"N": str(value)} + if isinstance(value, list): + return {"L": [av(item) for item in value]} + if isinstance(value, dict): + return {"M": {key: av(item) for key, item in value.items() if item is not None}} + raise TypeError(f"Unsupported DynamoDB value: {type(value).__name__}") + + +def item(payload): + return {key: av(value) for key, value in payload.items() if value is not None} + + +def user_card_from_base(user_id, base_card, general_note=None): + """The exact BaseCard -> UserCard mapping used by the save endpoint.""" + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + return { + "userId": user_id, + "cardId": str(uuid.uuid4()), + "baseRef": { + "baseCardId": base_card["baseCardId"], + "schemaVersion": base_card["metadata"]["schemaVersion"], + "source": base_card["metadata"]["source"], + "sourceVersion": base_card["metadata"].get("sourceVersion"), + }, + "language": base_card["language"], + "lemma": base_card["lemma"], + "normalizedLemma": base_card["normalizedLemma"], + "forms": copy.deepcopy(base_card.get("forms")), + "romanization": copy.deepcopy(base_card.get("romanization")), + "content": { + "definitions": copy.deepcopy(base_card["definitions"]), + "examples": copy.deepcopy(base_card["examples"]), + "relatedWords": copy.deepcopy(base_card.get("relatedWords")), + "collocations": copy.deepcopy(base_card.get("collocations")), + "usageNotes": copy.deepcopy(base_card.get("usageNotes")), + }, + "notes": {"general": general_note} if general_note else None, + "revision": 1, + "createdAt": now, + "updatedAt": now, + } + + +def ensure_table(name, key_schema): + try: + client.create_table( + TableName=name, + BillingMode="PAY_PER_REQUEST", + AttributeDefinitions=[{"AttributeName": key["AttributeName"], "AttributeType": "S"} for key in key_schema], + KeySchema=key_schema, + ) + client.get_waiter("table_exists").wait(TableName=name, WaiterConfig={"Delay": 1, "MaxAttempts": 10}) + except client.exceptions.ResourceInUseException: + pass + + +def main(): + ensure_table(BASE_TABLE, [{"AttributeName": "PK", "KeyType": "HASH"}]) + ensure_table(USER_TABLE, [{"AttributeName": "userId", "KeyType": "HASH"}, {"AttributeName": "cardId", "KeyType": "RANGE"}]) + + client.put_item(TableName=BASE_TABLE, Item=item({"PK": f"BASECARD#{BASE_CARD['baseCardId']}", **BASE_CARD})) + user_card = user_card_from_base("boto3-local-user", BASE_CARD, "Remember the measure word 台.") + client.put_item(TableName=USER_TABLE, Item=item(user_card)) + + raw = client.get_item( + TableName=USER_TABLE, + Key=item({"userId": user_card["userId"], "cardId": user_card["cardId"]}), + ConsistentRead=True, + )["Item"] + assert raw["content"]["M"]["definitions"]["L"][0]["M"]["text"] == {"S": "computer"} + assert raw["revision"] == {"N": "1"} + print("PASS: inserted BaseCard and converted UserCard using Boto3 low-level AttributeValues.") + print(f"UserCard key: {user_card['userId']} / {user_card['cardId']}") + print("content is an M; content.definitions is an L; definition.text is an S.") + + +if __name__ == "__main__": + try: + main() + except ClientError as error: + print(error, file=sys.stderr) + raise diff --git a/frontend/types/vocabulary.ts b/frontend/types/vocabulary.ts index 26bfba8..f2f4490 100644 --- a/frontend/types/vocabulary.ts +++ b/frontend/types/vocabulary.ts @@ -1,19 +1,113 @@ -// types/vocabulary.ts +// Shared card contracts. Canonical BaseCard fields are read-only; UserCard +// separates that identity from content a learner is allowed to change. -export interface VocabularyCard { +export interface Romanization { + system: string + value: string +} + +export interface Definition { id: string - word: string + text: string + partOfSpeech?: string + register?: string + domain?: string + romanization?: string +} + +export interface Example { + id: string + source: string + romanization?: string + translation: string + definitionId?: string +} + +export interface RelatedWord { + id: string + lemma: string + romanization?: string + relation?: 'synonym' | 'antonym' | 'related' +} + +export interface Collocation { + id: string + text: string + romanization?: string + translation?: string +} + +export interface CardForms { + simplified?: string + traditional?: string + variants?: string[] +} + +export interface BaseCard { + baseCardId: string + language: string + lemma: string + normalizedLemma: string + forms?: CardForms + romanization?: Romanization + definitions: Definition[] + examples: Example[] + relatedWords?: RelatedWord[] + collocations?: Collocation[] + usageNotes?: string[] + metadata: { + schemaVersion: number + source: string + sourceVersion?: string + generatedAt?: string + } +} + +/** The required payload from the word-lookup Lambda endpoint. */ +export interface CardLookupResponse { + card: BaseCard +} + +export interface CardAnnotation { + annotationId: string + section: 'definition' | 'example' | 'relatedWord' | 'collocation' | 'usageNote' + targetId?: string + text: string + createdAt: string + updatedAt: string +} + +export interface UserCardContent { + definitions: Definition[] + examples: Example[] + relatedWords?: RelatedWord[] + collocations?: Collocation[] + usageNotes?: string[] +} + +export interface UserCard { + userId: string + cardId: string + baseRef: { + baseCardId: string + schemaVersion: number + source: string + sourceVersion?: string + } language: string - lemma?: string - partOfSpeech: string - definitions: string[] - examples: string[] - relatedWords: string[] - patterns?: string[] - notes?: string - tags?: string[] + lemma: string + normalizedLemma: string + forms?: CardForms + romanization?: Romanization + content: UserCardContent + notes?: { + general?: string + annotations?: CardAnnotation[] + } + warnings?: string[] + revision: number createdAt: string - updatedAt?: string + updatedAt: string } export interface ChatMessage {