chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# ────────────────────────────────────────────────────────
# Electric Cloud Integration (e2e write-back test)
# ────────────────────────────────────────────────────────
# These are consumed by the Caddy reverse proxy at:
# plugins/plugin-sql/caddy/electric-proxy.Caddyfile
# Caddy rewrites path-based shape URLs to query-param format
# and injects these credentials, proxying to api.electric-sql.cloud.
#
# Get your source ID and secret from the Electric Cloud dashboard:
# https://dashboard.electric-sql.cloud
ELECTRIC_CLOUD_SOURCE_ID=svc-xxxxxxxxxxxx
ELECTRIC_CLOUD_SECRET=
# ────────────────────────────────────────────────────────
# Electric Sync (PGlite ↔ Electric)
# ────────────────────────────────────────────────────────
# ELIZA_ELECTRIC_SYNC_URL is the base URL of your Electric
# sync service. For local dev with Caddy + Electric Cloud:
# ELIZA_ELECTRIC_SYNC_URL=http://localhost:3001
# For direct Electric Cloud (no Caddy needed if your client
# library constructs query-param URLs natively):
# ELIZA_ELECTRIC_SYNC_URL=https://api.electric-sql.cloud
ELIZA_ELECTRIC_SYNC_URL=http://localhost:3001
# Agent ID for per-agent sync filtering (required when
# ELIZA_ELECTRIC_SYNC_URL is set). Each PGlite instance
# syncs only rows matching its agent_id WHERE clause.
AGENT_ID=
# ────────────────────────────────────────────────────────
# Write-Back Service (PGlite → Cloud API)
# ────────────────────────────────────────────────────────
# URL of the write-back cloud endpoint that receives local
# PGlite writes and forwards them to Postgres.
ELIZA_CLOUD_WRITE_BASE_URL=
# Service key for authenticating write-back requests.
ELIZA_CLOUD_SERVICE_KEY=
# ────────────────────────────────────────────────────────
# PGlite Storage
# ────────────────────────────────────────────────────────
# Filesystem directory for PGlite data. Defaults to the
# project-relative .eliza/.elizadb when unset.
PGLITE_DATA_DIR=
# Set to "1" to disable PGlite extensions (vector, live
# queries, fuzzystrmatch, Electric sync).
# ELIZA_PGLITE_DISABLE_EXTENSIONS=1
# ────────────────────────────────────────────────────────
# Postgres (server-mode / Neon)
# ────────────────────────────────────────────────────────
# When set, plugin-sql runs in server mode against a remote
# Postgres instead of using embedded PGlite.
POSTGRES_URL=
# ────────────────────────────────────────────────────────
# Data Isolation / RLS
# ────────────────────────────────────────────────────────
# Enable row-level security for multi-tenant deployments.
# ENABLE_DATA_ISOLATION=true
# Server ID used as the RLS tenant identifier. Must be a
# valid UUID when ENABLE_DATA_ISOLATION is true.
ELIZA_SERVER_ID=
# ────────────────────────────────────────────────────────
# Migrations
# ────────────────────────────────────────────────────────
# Set to "true" to allow destructive DDL migrations in
# production (e.g., DROP COLUMN, DROP TABLE). Off by
# default in NODE_ENV=production.
# ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS=true
+188
View File
@@ -0,0 +1,188 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.bun-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files (keep .env out of git;
# .env.example is the template — it IS tracked)
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.test
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
:memory:
debug-cache-db
./cursor
coverage/node_modules
.elizadb
.turbo
target/
__pycache__
*.pyc
.venv
*.egg-info
package-lock.json
node_modules
+36
View File
@@ -0,0 +1,36 @@
# Environment files - CRITICAL: Never publish secrets
**/.env
**/.env.*
!**/.env.example
# Database and runtime files
**/.eliza/
**/.elizadb/
# Dependencies
**/node_modules/
# Source files (dist is published) — do not exclude this package's `src/` tree;
# npm `files` lists `src/dist`; ignoring `**/src/` would omit published assets.
**/tsconfig.json
**/tsconfig.*.json
**/*.tsbuildinfo
# Tests
**/__tests__/
**/test/
**/tests/
**/*.test.ts
**/*.spec.ts
# Development
**/.turbo/
**/.cache/
**/coverage/
# IDE
**/.vscode/
**/.idea/
# OS files
**/.DS_Store
+176
View File
@@ -0,0 +1,176 @@
# @elizaos/plugin-sql
SQL database adapter plugin for elizaOS — provides persistent storage via PostgreSQL or embedded PGlite (WASM), with Drizzle ORM, automatic schema migrations, and optional Row Level Security.
## Purpose / role
This plugin registers a `DatabaseAdapter` with the elizaOS agent runtime so that all core runtime persistence (memories, entities, rooms, tasks, cache, logs, relationships, etc.) works against a real SQL backend. It is the default database plugin; elizaOS agents load it automatically if no other adapter is already registered. On Node/Bun it selects PostgreSQL when `POSTGRES_URL` is set, otherwise falls back to embedded PGlite. In the browser build it always uses PGlite (WASM).
## Plugin surface
The exported `plugin` object (`src/index.ts` / `src/index.node.ts` / `src/index.browser.ts`) registers:
| Kind | Name | Description |
|------|------|-------------|
| Service | `AdvancedMemoryStorageService` (`serviceType = "memoryStorage"`) | Implements `MemoryStorageProvider`; persists long-term memories and session summaries to dedicated SQL tables via the runtime memory API |
| Schema | `schema` (all tables) | Passed as `plugin.schema` so `DatabaseMigrationService` can auto-migrate at startup |
No actions, providers, evaluators, routes, or event handlers are registered by this plugin.
## Layout
```
plugins/plugin-sql/
package.json npm manifest; scripts, deps
README.md human-facing docs
src/
index.ts Default entry (same implementation as index.node.ts; uses ./utils)
index.node.ts Node/Bun entry: PostgreSQL + PGlite; createDatabaseAdapter()
index.browser.ts Browser entry: PGlite-only plugin
base.ts BaseDrizzleAdapter — shared IDatabaseAdapter implementation
types.ts DrizzleDatabase union type; getDb() helper
agent-mapping.ts Utilities for normalizing agent message examples from DB rows
utils.ts / utils.node.ts / utils.browser.ts Platform-specific helpers (resolvePgliteDir)
utils/
string-to-uuid.ts String-to-UUID conversion utility
connector-credential-store.ts ConnectorCredentialStore/Vault interfaces + factory
migration-service.ts DatabaseMigrationService — discovers plugin schemas, runs migrations, re-applies RLS
migrations.ts One-off migrations (e.g., entity RLS backfill)
rls.ts Row Level Security helpers (install/apply/uninstall)
pg/
adapter.ts PgDatabaseAdapter (wraps BaseDrizzleAdapter for Postgres)
manager.ts PostgresConnectionManager — pg Pool singleton, withEntityContext
sslmode.ts SSL mode resolver
pglite/
adapter.ts PgliteDatabaseAdapter (wraps BaseDrizzleAdapter for PGlite)
manager.ts PGliteClientManager — PGlite singleton, lifecycle
errors.ts PGlite-specific error types
neon/
adapter.ts NeonDatabaseAdapter — serverless adapter using @neondatabase/serverless
manager.ts NeonConnectionManager — WebSocket-based connection for Neon/Vercel/Cloudflare
schema/
index.ts Re-exports all table definitions
agent.ts / room.ts / memory.ts / entity.ts / ... One file per table
services/
advanced-memory-storage.ts AdvancedMemoryStorageService implementation
stores/
agent.store.ts / memory.store.ts / room.store.ts / ... Query logic split by domain
runtime-migrator/
index.ts RuntimeMigrator entry
runtime-migrator.ts Diff-based migration engine
schema-transformer.ts Drizzle schema → SQL diff
extension-manager.ts PGlite extension loading
write-back/
index.ts WriteBackService — forwards local PGlite writes to cloud API (Electric Pattern 1)
drizzle/ Drizzle ORM re-exports
```
## Commands
All scripts run from the plugin root via `bun run --cwd plugins/plugin-sql <script>`.
```bash
bun run --cwd plugins/plugin-sql build # Build (cd src && bun run build.ts)
bun run --cwd plugins/plugin-sql dev # Watch build (bun --hot build.ts)
bun run --cwd plugins/plugin-sql test # vitest run
bun run --cwd plugins/plugin-sql typecheck # tsc --noEmit
bun run --cwd plugins/plugin-sql lint # biome lint
bun run --cwd plugins/plugin-sql lint:check # biome lint (no write)
bun run --cwd plugins/plugin-sql format # biome format (write)
bun run --cwd plugins/plugin-sql format:check # biome format (check only)
bun run --cwd plugins/plugin-sql clean # rm -rf src/dist .turbo
bun run --cwd plugins/plugin-sql test:e2e # live smoke test (needs running stack)
```
## Config / env vars
| Variable | Required | Default | Effect |
|----------|----------|---------|--------|
| `POSTGRES_URL` | No | — | PostgreSQL connection string. When absent, PGlite is used. |
| `DATABASE_URL` | No | — | Alternative connection string used by the Neon serverless adapter. |
| `PGLITE_DATA_DIR` | No | `.eliza/.elizadb` | Directory (or `idb://` URL) for PGlite data storage. |
| `ENABLE_DATA_ISOLATION` | No | `false` | When `true`, enables PostgreSQL Row Level Security per-server isolation. |
| `ELIZA_SERVER_ID` | Conditional | — | Required when `ENABLE_DATA_ISOLATION=true`; becomes the RLS server UUID. |
| `ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS` | No | `false` | Allow column drops and other destructive schema changes at startup. |
| `ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS` | No | auto | Controls automatic install of the `message_search_document` generated column and message-search GIN indexes. Production Postgres adapters skip this DDL by default; set `true` after scheduling the generated-column/index migration. |
| `ELIZA_ELECTRIC_SYNC_URL` | No | — | URL for the Electric sync service; enables PGlite cloud sync read path. |
| `ELIZA_CLOUD_WRITE_BASE_URL` | No | — | Base URL of the cloud API for WriteBackService (e.g. `https://api.elizacloud.ai`). If unset, write-back is a no-op. |
| `ELIZA_CLOUD_SERVICE_KEY` | No | — | `X-Service-Key` header value sent by WriteBackService to the cloud API. |
| `ELIZA_PGLITE_DISABLE_EXTENSIONS` | No | `false` | Disables PGlite extension loading when set. |
| `ELIZA_IOS_LOCAL_BACKEND` | No | — | Overrides the local backend URL for iOS platform targets. |
| `ELIZA_ANDROID_LOCAL_BACKEND` | No | — | Overrides the local backend URL for Android platform targets. |
| `NODE_ENV` | No | `development` | `production` disables verbose migration logging and tightens safety checks. |
Settings are read via `runtime.getSetting(key)` inside `plugin.init`.
## How to extend
### Add a new schema table
1. Create `src/schema/<tableName>.ts` exporting a Drizzle `pgTable(...)`.
2. Add the export to `src/schema/index.ts`.
3. The plugin's `schema` export is picked up by `DatabaseMigrationService` at startup — no manual `drizzle-kit generate` step needed in normal development.
### Add a new store (domain queries)
1. Create `src/stores/<domain>.store.ts` implementing your query functions against `DrizzleDatabase`.
2. Export from `src/stores/index.ts`.
3. Call from `BaseDrizzleAdapter` in `src/base.ts` or from the relevant `PgDatabaseAdapter` / `PgliteDatabaseAdapter`.
### Add a new service
1. Implement `Service` from `@elizaos/core` in `src/services/<name>.ts`.
2. Add it to the `services` array in the `plugin` object in `src/index.ts` (and mirror in `src/index.node.ts` / `src/index.browser.ts` as appropriate).
## Conventions / gotchas
- **Global singleton managers.** Both `PostgresConnectionManager` and `PGliteClientManager` are stored under `Symbol.for("elizaos.plugin-sql.global-singletons")` on `globalThis`. This prevents multiple pools when the module is imported from multiple paths in the same process. Do not create manager instances directly — always go through `createDatabaseAdapter()`.
- **Skips init if adapter already registered.** If another plugin already called `registerDatabaseAdapter` before this plugin's `init` runs, the plugin does nothing. This is intentional; use it to swap in a custom adapter by loading it first.
- **Dual-runtime exports.** The `exports` field in `package.json` conditionally resolves `index.node.js` (Bun/Node) vs `index.browser.js` (browser). The node entry has PostgreSQL support; the browser entry is PGlite-only. Do not import the node adapter directly in browser-targeted code.
- **Schema subpath export.** Consumers that only need schema types (e.g., for drizzle queries outside the plugin) can import from `@elizaos/plugin-sql/schema` without pulling in adapters.
- **Drizzle subpath export.** Common Drizzle query helpers (`eq`, `sql`, `and`, etc.) are re-exported from `@elizaos/plugin-sql` and `@elizaos/plugin-sql/drizzle` to avoid direct drizzle-orm version coupling in consumer code.
- **Vector dimensions are active-width scoped.** `ensureEmbeddingDimension(n)` selects the current vector column, and runtime boot calls `clearEmbeddingsOutsideActiveDimension()` to delete vectors in other dimension columns and queue those memories for re-embedding at the active width. Memory rows survive; stale vectors do not.
- **RLS is PostgreSQL-only.** PGlite does not support Row Level Security. The `ENABLE_DATA_ISOLATION` path is silently skipped on PGlite.
- **Tests live under `src/__tests__/`** and run via vitest configured in `src/vitest.config.ts`.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — storage / memory:**
- The actual rows / embeddings / documents written **and read back**, with their shape inspected — not a mock asserting itself.
- Query correctness: precision/recall on real data, ordering, pagination, and migration up/down.
- GC/retention, concurrency, and large-payload paths.
- A trajectory showing memory/knowledge actually recalled into a turn, where relevant.
<!-- END: evidence-and-e2e-mandate -->
+176
View File
@@ -0,0 +1,176 @@
# @elizaos/plugin-sql
SQL database adapter plugin for elizaOS — provides persistent storage via PostgreSQL or embedded PGlite (WASM), with Drizzle ORM, automatic schema migrations, and optional Row Level Security.
## Purpose / role
This plugin registers a `DatabaseAdapter` with the elizaOS agent runtime so that all core runtime persistence (memories, entities, rooms, tasks, cache, logs, relationships, etc.) works against a real SQL backend. It is the default database plugin; elizaOS agents load it automatically if no other adapter is already registered. On Node/Bun it selects PostgreSQL when `POSTGRES_URL` is set, otherwise falls back to embedded PGlite. In the browser build it always uses PGlite (WASM).
## Plugin surface
The exported `plugin` object (`src/index.ts` / `src/index.node.ts` / `src/index.browser.ts`) registers:
| Kind | Name | Description |
|------|------|-------------|
| Service | `AdvancedMemoryStorageService` (`serviceType = "memoryStorage"`) | Implements `MemoryStorageProvider`; persists long-term memories and session summaries to dedicated SQL tables via the runtime memory API |
| Schema | `schema` (all tables) | Passed as `plugin.schema` so `DatabaseMigrationService` can auto-migrate at startup |
No actions, providers, evaluators, routes, or event handlers are registered by this plugin.
## Layout
```
plugins/plugin-sql/
package.json npm manifest; scripts, deps
README.md human-facing docs
src/
index.ts Default entry (same implementation as index.node.ts; uses ./utils)
index.node.ts Node/Bun entry: PostgreSQL + PGlite; createDatabaseAdapter()
index.browser.ts Browser entry: PGlite-only plugin
base.ts BaseDrizzleAdapter — shared IDatabaseAdapter implementation
types.ts DrizzleDatabase union type; getDb() helper
agent-mapping.ts Utilities for normalizing agent message examples from DB rows
utils.ts / utils.node.ts / utils.browser.ts Platform-specific helpers (resolvePgliteDir)
utils/
string-to-uuid.ts String-to-UUID conversion utility
connector-credential-store.ts ConnectorCredentialStore/Vault interfaces + factory
migration-service.ts DatabaseMigrationService — discovers plugin schemas, runs migrations, re-applies RLS
migrations.ts One-off migrations (e.g., entity RLS backfill)
rls.ts Row Level Security helpers (install/apply/uninstall)
pg/
adapter.ts PgDatabaseAdapter (wraps BaseDrizzleAdapter for Postgres)
manager.ts PostgresConnectionManager — pg Pool singleton, withEntityContext
sslmode.ts SSL mode resolver
pglite/
adapter.ts PgliteDatabaseAdapter (wraps BaseDrizzleAdapter for PGlite)
manager.ts PGliteClientManager — PGlite singleton, lifecycle
errors.ts PGlite-specific error types
neon/
adapter.ts NeonDatabaseAdapter — serverless adapter using @neondatabase/serverless
manager.ts NeonConnectionManager — WebSocket-based connection for Neon/Vercel/Cloudflare
schema/
index.ts Re-exports all table definitions
agent.ts / room.ts / memory.ts / entity.ts / ... One file per table
services/
advanced-memory-storage.ts AdvancedMemoryStorageService implementation
stores/
agent.store.ts / memory.store.ts / room.store.ts / ... Query logic split by domain
runtime-migrator/
index.ts RuntimeMigrator entry
runtime-migrator.ts Diff-based migration engine
schema-transformer.ts Drizzle schema → SQL diff
extension-manager.ts PGlite extension loading
write-back/
index.ts WriteBackService — forwards local PGlite writes to cloud API (Electric Pattern 1)
drizzle/ Drizzle ORM re-exports
```
## Commands
All scripts run from the plugin root via `bun run --cwd plugins/plugin-sql <script>`.
```bash
bun run --cwd plugins/plugin-sql build # Build (cd src && bun run build.ts)
bun run --cwd plugins/plugin-sql dev # Watch build (bun --hot build.ts)
bun run --cwd plugins/plugin-sql test # vitest run
bun run --cwd plugins/plugin-sql typecheck # tsc --noEmit
bun run --cwd plugins/plugin-sql lint # biome lint
bun run --cwd plugins/plugin-sql lint:check # biome lint (no write)
bun run --cwd plugins/plugin-sql format # biome format (write)
bun run --cwd plugins/plugin-sql format:check # biome format (check only)
bun run --cwd plugins/plugin-sql clean # rm -rf src/dist .turbo
bun run --cwd plugins/plugin-sql test:e2e # live smoke test (needs running stack)
```
## Config / env vars
| Variable | Required | Default | Effect |
|----------|----------|---------|--------|
| `POSTGRES_URL` | No | — | PostgreSQL connection string. When absent, PGlite is used. |
| `DATABASE_URL` | No | — | Alternative connection string used by the Neon serverless adapter. |
| `PGLITE_DATA_DIR` | No | `.eliza/.elizadb` | Directory (or `idb://` URL) for PGlite data storage. |
| `ENABLE_DATA_ISOLATION` | No | `false` | When `true`, enables PostgreSQL Row Level Security per-server isolation. |
| `ELIZA_SERVER_ID` | Conditional | — | Required when `ENABLE_DATA_ISOLATION=true`; becomes the RLS server UUID. |
| `ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS` | No | `false` | Allow column drops and other destructive schema changes at startup. |
| `ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS` | No | auto | Controls automatic install of the `message_search_document` generated column and message-search GIN indexes. Production Postgres adapters skip this DDL by default; set `true` after scheduling the generated-column/index migration. |
| `ELIZA_ELECTRIC_SYNC_URL` | No | — | URL for the Electric sync service; enables PGlite cloud sync read path. |
| `ELIZA_CLOUD_WRITE_BASE_URL` | No | — | Base URL of the cloud API for WriteBackService (e.g. `https://api.elizacloud.ai`). If unset, write-back is a no-op. |
| `ELIZA_CLOUD_SERVICE_KEY` | No | — | `X-Service-Key` header value sent by WriteBackService to the cloud API. |
| `ELIZA_PGLITE_DISABLE_EXTENSIONS` | No | `false` | Disables PGlite extension loading when set. |
| `ELIZA_IOS_LOCAL_BACKEND` | No | — | Overrides the local backend URL for iOS platform targets. |
| `ELIZA_ANDROID_LOCAL_BACKEND` | No | — | Overrides the local backend URL for Android platform targets. |
| `NODE_ENV` | No | `development` | `production` disables verbose migration logging and tightens safety checks. |
Settings are read via `runtime.getSetting(key)` inside `plugin.init`.
## How to extend
### Add a new schema table
1. Create `src/schema/<tableName>.ts` exporting a Drizzle `pgTable(...)`.
2. Add the export to `src/schema/index.ts`.
3. The plugin's `schema` export is picked up by `DatabaseMigrationService` at startup — no manual `drizzle-kit generate` step needed in normal development.
### Add a new store (domain queries)
1. Create `src/stores/<domain>.store.ts` implementing your query functions against `DrizzleDatabase`.
2. Export from `src/stores/index.ts`.
3. Call from `BaseDrizzleAdapter` in `src/base.ts` or from the relevant `PgDatabaseAdapter` / `PgliteDatabaseAdapter`.
### Add a new service
1. Implement `Service` from `@elizaos/core` in `src/services/<name>.ts`.
2. Add it to the `services` array in the `plugin` object in `src/index.ts` (and mirror in `src/index.node.ts` / `src/index.browser.ts` as appropriate).
## Conventions / gotchas
- **Global singleton managers.** Both `PostgresConnectionManager` and `PGliteClientManager` are stored under `Symbol.for("elizaos.plugin-sql.global-singletons")` on `globalThis`. This prevents multiple pools when the module is imported from multiple paths in the same process. Do not create manager instances directly — always go through `createDatabaseAdapter()`.
- **Skips init if adapter already registered.** If another plugin already called `registerDatabaseAdapter` before this plugin's `init` runs, the plugin does nothing. This is intentional; use it to swap in a custom adapter by loading it first.
- **Dual-runtime exports.** The `exports` field in `package.json` conditionally resolves `index.node.js` (Bun/Node) vs `index.browser.js` (browser). The node entry has PostgreSQL support; the browser entry is PGlite-only. Do not import the node adapter directly in browser-targeted code.
- **Schema subpath export.** Consumers that only need schema types (e.g., for drizzle queries outside the plugin) can import from `@elizaos/plugin-sql/schema` without pulling in adapters.
- **Drizzle subpath export.** Common Drizzle query helpers (`eq`, `sql`, `and`, etc.) are re-exported from `@elizaos/plugin-sql` and `@elizaos/plugin-sql/drizzle` to avoid direct drizzle-orm version coupling in consumer code.
- **Vector dimensions are active-width scoped.** `ensureEmbeddingDimension(n)` selects the current vector column, and runtime boot calls `clearEmbeddingsOutsideActiveDimension()` to delete vectors in other dimension columns and queue those memories for re-embedding at the active width. Memory rows survive; stale vectors do not.
- **RLS is PostgreSQL-only.** PGlite does not support Row Level Security. The `ENABLE_DATA_ISOLATION` path is silently skipped on PGlite.
- **Tests live under `src/__tests__/`** and run via vitest configured in `src/vitest.config.ts`.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — storage / memory:**
- The actual rows / embeddings / documents written **and read back**, with their shape inspected — not a mock asserting itself.
- Query correctness: precision/recall on real data, ordering, pagination, and migration up/down.
- GC/retention, concurrency, and large-payload paths.
- A trajectory showing memory/knowledge actually recalled into a turn, where relevant.
<!-- END: evidence-and-e2e-mandate -->
+145
View File
@@ -0,0 +1,145 @@
# @elizaos/plugin-sql
SQL database adapter plugin for elizaOS — provides persistent storage via PostgreSQL or embedded PGlite (WASM), with Drizzle ORM, automatic schema migrations, and optional Row Level Security.
## Installation
```bash
bun add @elizaos/plugin-sql
```
## Overview
This plugin registers a `DatabaseAdapter` with the elizaOS agent runtime so that all core runtime persistence (memories, entities, rooms, tasks, cache, logs, relationships, etc.) works against a real SQL backend. On Node/Bun it selects PostgreSQL when `POSTGRES_URL` is set, otherwise falls back to embedded PGlite. In the browser build it always uses PGlite (WASM).
## Database Schema
The plugin uses the following main tables:
- **Agent**: Agent information and configurations
- **Room / Channel**: Conversation rooms and messaging channels
- **Participant / ChannelParticipant**: Participants in rooms and channels
- **Memory**: Agent memories with vector embeddings for semantic search
- **Embedding**: Vector embeddings for entities
- **Entity**: Entities agents interact with
- **Relationship**: Relationships between entities
- **Component**: Agent components and configurations
- **Tasks**: Tasks and goals
- **Log**: System logs
- **Cache**: Frequently accessed data cache
- **World**: World settings and configurations
Table definitions live in `src/schema/`.
## Electric Sync (PGlite ↔ Electric Cloud)
When `ELIZA_ELECTRIC_SYNC_URL` and `AGENT_ID` are set, PGlite connects to an Electric sync service and streams real-time updates for all core tables. Each agent syncs only its own rows (filtered by `agent_id` / `id`), preserving per-agent isolation in shared-Neon deployments.
### Local dev with Electric Cloud
The e2e write-back test (`__tests__/integration/electric-write-back.test.ts`) uses a [Caddy](https://caddyserver.com) reverse proxy to forward shape requests to Electric Cloud with auth. Caddy is the proxy [recommended by Electric](https://electric.ax/docs/sync/guides/troubleshooting#missing-headers).
```bash
# 1. Set your Electric Cloud credentials (get these from dashboard.electric-sql.cloud)
export ELECTRIC_CLOUD_SOURCE_ID=svc-xxxxxxxxxxxx
export ELECTRIC_CLOUD_SECRET=eyJ...
# 2. Start the Caddy proxy
caddy run --config plugins/plugin-sql/caddy/electric-proxy.Caddyfile
# 3. Run the e2e test
bun run --cwd plugins/plugin-sql test -- \
__tests__/integration/electric-write-back.test.ts
```
The Caddyfile at `plugins/plugin-sql/caddy/electric-proxy.Caddyfile` forwards every incoming request to `api.electric-sql.cloud` with auth query params appended.
## Environment Variables
| Variable | Required | Default | Effect |
|----------|----------|---------|--------|
| `POSTGRES_URL` | No | — | PostgreSQL connection string. When absent, PGlite is used. |
| `PGLITE_DATA_DIR` | No | `.eliza/.elizadb` | Directory (or `idb://` URL) for PGlite data storage. |
| `ELIZA_ELECTRIC_SYNC_URL` | No | — | Base URL of Electric sync service (e.g. `http://localhost:3001` via Caddy). |
| `AGENT_ID` | Conditional | — | UUID of the agent. Required when `ELIZA_ELECTRIC_SYNC_URL` is set (per-agent WHERE filter). |
| `ELIZA_CLOUD_WRITE_BASE_URL` | No | — | Write-back cloud endpoint for forwarding local PGlite writes to Postgres. |
| `ELIZA_CLOUD_SERVICE_KEY` | No | — | Service key for authenticating write-back requests. |
| `ELECTRIC_CLOUD_SOURCE_ID` | Test-only | — | Electric Cloud source ID (consumed by Caddy, not the runtime). |
| `ELECTRIC_CLOUD_SECRET` | Test-only | — | Electric Cloud JWT secret (consumed by Caddy, not the runtime). |
| `ELIZA_PGLITE_DISABLE_EXTENSIONS` | No | `false` | Set to `1` to disable PGlite extensions (vector, live, fuzzystrmatch, Electric sync). |
| `ENABLE_DATA_ISOLATION` | No | `false` | When `true`, enables PostgreSQL Row Level Security per-server isolation. |
| `ELIZA_SERVER_ID` | Conditional | — | Required when `ENABLE_DATA_ISOLATION=true`; becomes the RLS server UUID. |
| `ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS` | No | `false` | Allow column drops and other destructive schema changes at startup. |
| `ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS` | No | auto | Controls automatic install of the `message_search_document` generated column and message-search GIN indexes. Production Postgres adapters skip this DDL by default; set `true` after scheduling the generated-column/index migration. |
| `NODE_ENV` | No | `development` | `production` disables verbose migration logging and tightens safety checks. |
Settings are read via `runtime.getSetting(key)` inside `plugin.init`.
## Vector Dimensions
```typescript
VECTOR_DIMS = {
SMALL: 384,
MEDIUM: 512,
LARGE: 768,
XL: 1024,
XXL: 1536,
XXXL: 3072,
};
```
When an agent switches embedding dimensions, runtime boot deletes vectors stored in other dimension columns and queues those memories for re-embedding at the active width. Memory rows survive; stale vectors do not.
## Runtime Migrations
Plugins export a `schema` object; `DatabaseMigrationService` diffs the schema against the live DB at startup and runs migrations automatically. No manual `drizzle-kit generate` / `drizzle-kit push` step is needed in normal development.
```typescript
// In your plugin
export const plugin = {
name: "@your-org/plugin-name",
schema: schema, // Drizzle schema object
// ...
};
```
Destructive changes (column drops, type changes) are blocked by default. Set `ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS=true` to allow them.
Message-search DDL is also guarded on production Postgres. The generated column and GIN indexes are still installed automatically for development/test and embedded PGlite. For production Postgres, schedule the table rewrite/index creation and run with `ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS=true` once the deployment window is approved.
## Connection Management
Both `PostgresConnectionManager` and `PGliteClientManager` are stored under `Symbol.for("elizaos.plugin-sql.global-singletons")` on `globalThis`. This prevents multiple pools when the module is imported from multiple paths in the same process. Do not construct manager instances directly — always go through `createDatabaseAdapter()`.
## Database Pool Configuration
Default Postgres pool configuration (`src/pg/manager.ts`):
```typescript
{
max: 20,
min: 2,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
keepAlive: true,
keepAliveInitialDelayMillis: 10000
}
```
## Retry Configuration
`BaseDrizzleAdapter` retries failed operations with exponential backoff and jitter (`src/base.ts`):
```typescript
{
maxRetries: 3,
baseDelay: 1000,
maxDelay: 10000,
jitterMax: 1000
}
```
## Requirements
- Node.js or Bun
- PostgreSQL with vector extension (for Postgres mode)
@@ -0,0 +1,19 @@
# Electric Cloud reverse proxy — rewrites every incoming request
# to the Electric Cloud shape API with auth query params injected.
#
# Per https://electric.ax/docs/sync/guides/troubleshooting#missing-headers
#
# Required env vars: ELECTRIC_CLOUD_SOURCE_ID, ELECTRIC_CLOUD_SECRET
# Start with: ELECTRIC_CLOUD_SOURCE_ID=... ELECTRIC_CLOUD_SECRET=... caddy run --config plugins/plugin-sql/caddy/electric-proxy.Caddyfile
http://localhost:3001 {
# Forward every request to Electric Cloud with auth params appended.
# Note: designed for query-param client URLs like /?table=agents&offset=-1.
# Path-based format (/v1/shape/{table}) is not handled — the table name
# is lost in the rewrite since {query} only captures the query string.
rewrite * /v1/shape?{query}&source_id={$ELECTRIC_CLOUD_SOURCE_ID}&secret={$ELECTRIC_CLOUD_SECRET}
reverse_proxy https://api.electric-sql.cloud {
header_up Host api.electric-sql.cloud
}
}
@@ -0,0 +1,52 @@
# End-to-end Electric Sync integration test stack.
# Start with: docker compose -f docker-compose.electric-test.yml up -d
# Stop with: docker compose -f docker-compose.electric-test.yml down -v
services:
postgres:
image: pgvector/pgvector:pg16
container_name: electric-test-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: electric_test
ports:
- "5433:5432"
command:
- postgres
- -c
- wal_level=logical
- -c
- max_replication_slots=5
- -c
- max_wal_senders=5
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d electric_test"]
interval: 3s
timeout: 5s
retries: 10
volumes:
- electric_test_pgdata:/var/lib/postgresql/data
electric:
image: electricsql/electric:secure
container_name: electric-test-electric
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/electric_test
ELECTRIC_HOST: 0.0.0.0
ELECTRIC_PORT: "3000"
ELECTRIC_WRITE_MODE: direct_writes
ELECTRIC_INSECURE: "true"
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/v1/health || exit 1"]
interval: 3s
timeout: 5s
retries: 15
volumes:
electric_test_pgdata:
@@ -0,0 +1,17 @@
services:
postgres:
image: pgvector/pgvector:pg16
container_name: plugin-sql-test-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: eliza_test
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- ./scripts/init-test-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
@@ -0,0 +1,488 @@
CREATE TABLE "agents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"server_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"username" text,
"system" text DEFAULT '',
"bio" jsonb DEFAULT '[]'::jsonb,
"message_examples" jsonb DEFAULT '[]'::jsonb NOT NULL,
"post_examples" jsonb DEFAULT '[]'::jsonb NOT NULL,
"topics" jsonb DEFAULT '[]'::jsonb NOT NULL,
"adjectives" jsonb DEFAULT '[]'::jsonb NOT NULL,
"knowledge" jsonb DEFAULT '[]'::jsonb NOT NULL,
"plugins" jsonb DEFAULT '[]'::jsonb NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"style" jsonb DEFAULT '{}'::jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "approval_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"state" text NOT NULL,
"requested_by" text NOT NULL,
"subject_user_id" text NOT NULL,
"action" text NOT NULL,
"payload" jsonb NOT NULL,
"channel" text NOT NULL,
"reason" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"resolved_at" timestamp with time zone,
"resolved_by" text,
"resolution_reason" text,
"agent_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth_audit_events" (
"id" text PRIMARY KEY NOT NULL,
"ts" bigint NOT NULL,
"actor_identity_id" text,
"ip" text,
"user_agent" text,
"action" text NOT NULL,
"outcome" text NOT NULL,
"metadata" jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth_bootstrap_jti_seen" (
"jti" text PRIMARY KEY NOT NULL,
"seen_at" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth_identities" (
"id" text PRIMARY KEY NOT NULL,
"kind" text NOT NULL,
"display_name" text NOT NULL,
"created_at" bigint NOT NULL,
"password_hash" text,
"cloud_user_id" text
);
--> statement-breakpoint
CREATE TABLE "auth_owner_bindings" (
"id" text PRIMARY KEY NOT NULL,
"identity_id" text NOT NULL,
"connector" text NOT NULL,
"external_id" text NOT NULL,
"display_handle" text NOT NULL,
"instance_id" text NOT NULL,
"verified_at" bigint NOT NULL,
"pending_code_hash" text,
"pending_expires_at" bigint
);
--> statement-breakpoint
CREATE TABLE "auth_sessions" (
"id" text PRIMARY KEY NOT NULL,
"identity_id" text NOT NULL,
"kind" text NOT NULL,
"created_at" bigint NOT NULL,
"last_seen_at" bigint NOT NULL,
"expires_at" bigint NOT NULL,
"remember_device" boolean DEFAULT false NOT NULL,
"csrf_secret" text NOT NULL,
"ip" text,
"user_agent" text,
"scopes" jsonb NOT NULL,
"revoked_at" bigint
);
--> statement-breakpoint
CREATE TABLE "cache" (
"key" text NOT NULL,
"agent_id" uuid NOT NULL,
"value" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone,
CONSTRAINT "cache_key_agent_id_pk" PRIMARY KEY("key","agent_id")
);
--> statement-breakpoint
CREATE TABLE "channel_participants" (
"channel_id" text NOT NULL,
"entity_id" text NOT NULL,
CONSTRAINT "channel_participants_channel_id_entity_id_pk" PRIMARY KEY("channel_id","entity_id")
);
--> statement-breakpoint
CREATE TABLE "channels" (
"id" text PRIMARY KEY NOT NULL,
"message_server_id" uuid NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"source_type" text,
"source_id" text,
"topic" text,
"metadata" jsonb,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "components" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"entity_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"room_id" uuid NOT NULL,
"world_id" uuid,
"source_entity_id" uuid,
"type" text NOT NULL,
"data" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "embeddings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"memory_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"dim_384" vector(384),
"dim_512" vector(512),
"dim_768" vector(768),
"dim_1024" vector(1024),
"dim_1536" vector(1536),
"dim_3072" vector(3072),
CONSTRAINT "embedding_source_check" CHECK ("memory_id" IS NOT NULL)
);
--> statement-breakpoint
CREATE TABLE "entity_identities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"entity_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"platform" text NOT NULL,
"handle" text NOT NULL,
"verified" boolean DEFAULT false NOT NULL,
"confidence" real DEFAULT 0 NOT NULL,
"source" text,
"first_seen" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen" timestamp with time zone DEFAULT now() NOT NULL,
"evidence_message_ids" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "unique_entity_identity" UNIQUE("entity_id","platform","handle","agent_id")
);
--> statement-breakpoint
CREATE TABLE "entity_merge_candidates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"agent_id" uuid NOT NULL,
"entity_a" uuid NOT NULL,
"entity_b" uuid NOT NULL,
"confidence" real DEFAULT 0 NOT NULL,
"evidence" jsonb,
"status" text DEFAULT 'pending' NOT NULL,
"proposed_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "entities" (
"id" uuid PRIMARY KEY NOT NULL,
"agent_id" uuid NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"names" text[] DEFAULT '{}'::text[] NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT "id_agent_id_unique" UNIQUE("id","agent_id")
);
--> statement-breakpoint
CREATE TABLE "fact_candidates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"agent_id" uuid NOT NULL,
"entity_id" uuid NOT NULL,
"kind" text NOT NULL,
"existing_fact_id" uuid,
"proposed_text" text NOT NULL,
"confidence" real DEFAULT 0 NOT NULL,
"evidence" jsonb,
"status" text DEFAULT 'pending' NOT NULL,
"proposed_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "logs" (
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"entity_id" uuid NOT NULL,
"body" jsonb NOT NULL,
"type" text NOT NULL,
"room_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "long_term_memories" (
"id" uuid PRIMARY KEY NOT NULL,
"agent_id" uuid NOT NULL,
"entity_id" uuid NOT NULL,
"category" text NOT NULL,
"content" text NOT NULL,
"metadata" jsonb,
"embedding" real[],
"confidence" real DEFAULT 1,
"source" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"last_accessed_at" timestamp,
"access_count" integer DEFAULT 0
);
--> statement-breakpoint
CREATE TABLE "memory_access_logs" (
"id" uuid PRIMARY KEY NOT NULL,
"memory_id" uuid NOT NULL,
"memory_type" text NOT NULL,
"agent_id" uuid NOT NULL,
"access_type" text NOT NULL,
"accessed_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memories" (
"id" uuid PRIMARY KEY NOT NULL,
"type" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"content" jsonb NOT NULL,
"entity_id" uuid,
"agent_id" uuid NOT NULL,
"room_id" uuid,
"world_id" uuid,
"unique" boolean DEFAULT true NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT "fragment_metadata_check" CHECK (
CASE
WHEN metadata->>'type' = 'fragment' THEN
metadata ? 'documentId' AND
metadata ? 'position'
ELSE true
END
),
CONSTRAINT "document_metadata_check" CHECK (
CASE
WHEN metadata->>'type' = 'document' THEN
metadata ? 'timestamp'
ELSE true
END
)
);
--> statement-breakpoint
CREATE TABLE "message_server_agents" (
"message_server_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
CONSTRAINT "message_server_agents_message_server_id_agent_id_pk" PRIMARY KEY("message_server_id","agent_id")
);
--> statement-breakpoint
CREATE TABLE "message_servers" (
"id" uuid PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"source_type" text NOT NULL,
"source_id" text,
"metadata" jsonb,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "central_messages" (
"id" text PRIMARY KEY NOT NULL,
"channel_id" text NOT NULL,
"author_id" text NOT NULL,
"content" text NOT NULL,
"raw_message" jsonb,
"in_reply_to_root_message_id" text,
"source_type" text,
"source_id" text,
"metadata" jsonb,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pairing_allowlist" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"channel" text NOT NULL,
"sender_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
"agent_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pairing_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"channel" text NOT NULL,
"sender_id" text NOT NULL,
"code" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
"agent_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "participants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"entity_id" uuid,
"room_id" uuid,
"agent_id" uuid,
"room_state" text
);
--> statement-breakpoint
CREATE TABLE "relationships" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"source_entity_id" uuid NOT NULL,
"target_entity_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"tags" text[],
"metadata" jsonb,
CONSTRAINT "unique_relationship" UNIQUE("source_entity_id","target_entity_id","agent_id")
);
--> statement-breakpoint
CREATE TABLE "rooms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"agent_id" uuid,
"source" text NOT NULL,
"type" text NOT NULL,
"message_server_id" uuid,
"world_id" uuid,
"name" text,
"metadata" jsonb,
"channel_id" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "servers" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session_summaries" (
"id" uuid PRIMARY KEY NOT NULL,
"agent_id" uuid NOT NULL,
"room_id" uuid NOT NULL,
"entity_id" uuid,
"summary" text NOT NULL,
"message_count" integer NOT NULL,
"last_message_offset" integer DEFAULT 0 NOT NULL,
"start_time" timestamp NOT NULL,
"end_time" timestamp NOT NULL,
"topics" jsonb,
"metadata" jsonb,
"embedding" real[],
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"room_id" uuid,
"world_id" uuid,
"entity_id" uuid,
"agent_id" uuid NOT NULL,
"tags" text[] DEFAULT '{}'::text[],
"metadata" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "worlds" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"agent_id" uuid NOT NULL,
"name" text NOT NULL,
"metadata" jsonb,
"message_server_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "approval_requests" ADD CONSTRAINT "approval_requests_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth_owner_bindings" ADD CONSTRAINT "auth_owner_bindings_identity_id_auth_identities_id_fk" FOREIGN KEY ("identity_id") REFERENCES "public"."auth_identities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth_owner_bindings" ADD CONSTRAINT "fk_auth_owner_bindings_identity" FOREIGN KEY ("identity_id") REFERENCES "public"."auth_identities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_identity_id_auth_identities_id_fk" FOREIGN KEY ("identity_id") REFERENCES "public"."auth_identities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth_sessions" ADD CONSTRAINT "fk_auth_sessions_identity" FOREIGN KEY ("identity_id") REFERENCES "public"."auth_identities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cache" ADD CONSTRAINT "cache_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "channel_participants" ADD CONSTRAINT "channel_participants_channel_id_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "channels" ADD CONSTRAINT "channels_message_server_id_message_servers_id_fk" FOREIGN KEY ("message_server_id") REFERENCES "public"."message_servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "components" ADD CONSTRAINT "components_entity_id_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "components" ADD CONSTRAINT "components_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "components" ADD CONSTRAINT "components_room_id_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "components" ADD CONSTRAINT "components_world_id_worlds_id_fk" FOREIGN KEY ("world_id") REFERENCES "public"."worlds"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "components" ADD CONSTRAINT "components_source_entity_id_entities_id_fk" FOREIGN KEY ("source_entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "embeddings" ADD CONSTRAINT "embeddings_memory_id_memories_id_fk" FOREIGN KEY ("memory_id") REFERENCES "public"."memories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "embeddings" ADD CONSTRAINT "fk_embedding_memory" FOREIGN KEY ("memory_id") REFERENCES "public"."memories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_identities" ADD CONSTRAINT "entity_identities_entity_id_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_identities" ADD CONSTRAINT "entity_identities_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_identities" ADD CONSTRAINT "fk_entity_identities_entity" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_identities" ADD CONSTRAINT "fk_entity_identities_agent" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_merge_candidates" ADD CONSTRAINT "entity_merge_candidates_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_merge_candidates" ADD CONSTRAINT "entity_merge_candidates_entity_a_entities_id_fk" FOREIGN KEY ("entity_a") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_merge_candidates" ADD CONSTRAINT "entity_merge_candidates_entity_b_entities_id_fk" FOREIGN KEY ("entity_b") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_merge_candidates" ADD CONSTRAINT "fk_entity_merge_candidates_a" FOREIGN KEY ("entity_a") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_merge_candidates" ADD CONSTRAINT "fk_entity_merge_candidates_b" FOREIGN KEY ("entity_b") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_merge_candidates" ADD CONSTRAINT "fk_entity_merge_candidates_agent" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entities" ADD CONSTRAINT "entities_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "fact_candidates" ADD CONSTRAINT "fact_candidates_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "fact_candidates" ADD CONSTRAINT "fact_candidates_entity_id_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "fact_candidates" ADD CONSTRAINT "fk_fact_candidates_entity" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "fact_candidates" ADD CONSTRAINT "fk_fact_candidates_agent" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "logs" ADD CONSTRAINT "logs_entity_id_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "logs" ADD CONSTRAINT "logs_room_id_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "logs" ADD CONSTRAINT "fk_room" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "logs" ADD CONSTRAINT "fk_user" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memories" ADD CONSTRAINT "memories_entity_id_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memories" ADD CONSTRAINT "memories_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memories" ADD CONSTRAINT "memories_room_id_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memories" ADD CONSTRAINT "fk_room" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memories" ADD CONSTRAINT "fk_user" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memories" ADD CONSTRAINT "fk_agent" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "message_server_agents" ADD CONSTRAINT "message_server_agents_message_server_id_message_servers_id_fk" FOREIGN KEY ("message_server_id") REFERENCES "public"."message_servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "message_server_agents" ADD CONSTRAINT "message_server_agents_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "central_messages" ADD CONSTRAINT "central_messages_channel_id_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "central_messages" ADD CONSTRAINT "central_messages_in_reply_to_root_message_id_central_messages_id_fk" FOREIGN KEY ("in_reply_to_root_message_id") REFERENCES "public"."central_messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pairing_allowlist" ADD CONSTRAINT "pairing_allowlist_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pairing_requests" ADD CONSTRAINT "pairing_requests_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "participants" ADD CONSTRAINT "participants_entity_id_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "participants" ADD CONSTRAINT "participants_room_id_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "participants" ADD CONSTRAINT "participants_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "participants" ADD CONSTRAINT "fk_room" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "participants" ADD CONSTRAINT "fk_user" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_source_entity_id_entities_id_fk" FOREIGN KEY ("source_entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_target_entity_id_entities_id_fk" FOREIGN KEY ("target_entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "relationships" ADD CONSTRAINT "fk_user_a" FOREIGN KEY ("source_entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "relationships" ADD CONSTRAINT "fk_user_b" FOREIGN KEY ("target_entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "rooms" ADD CONSTRAINT "rooms_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "worlds" ADD CONSTRAINT "worlds_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "approval_requests_subject_state_idx" ON "approval_requests" USING btree ("subject_user_id","state");--> statement-breakpoint
CREATE INDEX "approval_requests_agent_state_idx" ON "approval_requests" USING btree ("agent_id","state");--> statement-breakpoint
CREATE INDEX "approval_requests_state_expires_idx" ON "approval_requests" USING btree ("state","expires_at");--> statement-breakpoint
CREATE INDEX "auth_audit_events_action_idx" ON "auth_audit_events" USING btree ("action");--> statement-breakpoint
CREATE INDEX "auth_audit_events_ts_idx" ON "auth_audit_events" USING btree ("ts");--> statement-breakpoint
CREATE INDEX "auth_audit_events_actor_idx" ON "auth_audit_events" USING btree ("actor_identity_id");--> statement-breakpoint
CREATE INDEX "auth_bootstrap_jti_seen_at_idx" ON "auth_bootstrap_jti_seen" USING btree ("seen_at");--> statement-breakpoint
CREATE INDEX "auth_identities_kind_idx" ON "auth_identities" USING btree ("kind");--> statement-breakpoint
CREATE INDEX "auth_identities_cloud_user_idx" ON "auth_identities" USING btree ("cloud_user_id");--> statement-breakpoint
CREATE INDEX "auth_owner_bindings_identity_idx" ON "auth_owner_bindings" USING btree ("identity_id");--> statement-breakpoint
CREATE INDEX "auth_owner_bindings_connector_idx" ON "auth_owner_bindings" USING btree ("connector");--> statement-breakpoint
CREATE UNIQUE INDEX "auth_owner_bindings_connector_external_instance_uniq" ON "auth_owner_bindings" USING btree ("connector","external_id","instance_id");--> statement-breakpoint
CREATE INDEX "auth_sessions_identity_idx" ON "auth_sessions" USING btree ("identity_id");--> statement-breakpoint
CREATE INDEX "auth_sessions_expires_idx" ON "auth_sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "idx_embedding_memory" ON "embeddings" USING btree ("memory_id");--> statement-breakpoint
CREATE INDEX "idx_entity_identities_entity" ON "entity_identities" USING btree ("entity_id");--> statement-breakpoint
CREATE INDEX "idx_entity_identities_platform_handle" ON "entity_identities" USING btree ("platform","handle");--> statement-breakpoint
CREATE INDEX "idx_entity_merge_candidates_status" ON "entity_merge_candidates" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_entity_merge_candidates_pair" ON "entity_merge_candidates" USING btree ("entity_a","entity_b");--> statement-breakpoint
CREATE INDEX "idx_fact_candidates_status" ON "fact_candidates" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_fact_candidates_entity" ON "fact_candidates" USING btree ("entity_id");--> statement-breakpoint
CREATE INDEX "long_term_memories_agent_entity_idx" ON "long_term_memories" USING btree ("agent_id","entity_id");--> statement-breakpoint
CREATE INDEX "long_term_memories_category_idx" ON "long_term_memories" USING btree ("category");--> statement-breakpoint
CREATE INDEX "long_term_memories_confidence_idx" ON "long_term_memories" USING btree ("confidence");--> statement-breakpoint
CREATE INDEX "long_term_memories_created_at_idx" ON "long_term_memories" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "memory_access_logs_memory_id_idx" ON "memory_access_logs" USING btree ("memory_id");--> statement-breakpoint
CREATE INDEX "memory_access_logs_agent_id_idx" ON "memory_access_logs" USING btree ("agent_id");--> statement-breakpoint
CREATE INDEX "memory_access_logs_accessed_at_idx" ON "memory_access_logs" USING btree ("accessed_at");--> statement-breakpoint
CREATE INDEX "idx_memories_type_room" ON "memories" USING btree ("type","room_id");--> statement-breakpoint
CREATE INDEX "idx_memories_world_id" ON "memories" USING btree ("world_id");--> statement-breakpoint
CREATE INDEX "idx_memories_metadata_type" ON "memories" USING btree (((metadata->>'type')));--> statement-breakpoint
CREATE INDEX "idx_memories_document_id" ON "memories" USING btree (((metadata->>'documentId')));--> statement-breakpoint
CREATE INDEX "idx_fragments_order" ON "memories" USING btree (((metadata->>'documentId')),((metadata->>'position')));--> statement-breakpoint
CREATE INDEX "pairing_allowlist_channel_agent_idx" ON "pairing_allowlist" USING btree ("channel","agent_id");--> statement-breakpoint
CREATE UNIQUE INDEX "pairing_allowlist_sender_channel_agent_idx" ON "pairing_allowlist" USING btree ("sender_id","channel","agent_id");--> statement-breakpoint
CREATE INDEX "pairing_requests_channel_agent_idx" ON "pairing_requests" USING btree ("channel","agent_id");--> statement-breakpoint
CREATE UNIQUE INDEX "pairing_requests_code_channel_agent_idx" ON "pairing_requests" USING btree ("code","channel","agent_id");--> statement-breakpoint
CREATE UNIQUE INDEX "pairing_requests_sender_channel_agent_idx" ON "pairing_requests" USING btree ("sender_id","channel","agent_id");--> statement-breakpoint
CREATE INDEX "idx_participants_user" ON "participants" USING btree ("entity_id");--> statement-breakpoint
CREATE INDEX "idx_participants_room" ON "participants" USING btree ("room_id");--> statement-breakpoint
CREATE INDEX "idx_relationships_users" ON "relationships" USING btree ("source_entity_id","target_entity_id");--> statement-breakpoint
CREATE INDEX "session_summaries_agent_room_idx" ON "session_summaries" USING btree ("agent_id","room_id");--> statement-breakpoint
CREATE INDEX "session_summaries_entity_idx" ON "session_summaries" USING btree ("entity_id");--> statement-breakpoint
CREATE INDEX "session_summaries_start_time_idx" ON "session_summaries" USING btree ("start_time");
@@ -0,0 +1,92 @@
CREATE TABLE "connector_accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"agent_id" uuid NOT NULL,
"provider" text NOT NULL,
"account_key" text NOT NULL,
"external_id" text,
"display_name" text,
"username" text,
"email" text,
"owner_binding_id" text,
"owner_identity_id" text,
"role" text DEFAULT 'OWNER' NOT NULL,
"purpose" jsonb DEFAULT '["messaging"]'::jsonb NOT NULL,
"access_gate" text DEFAULT 'open' NOT NULL,
"status" text DEFAULT 'connected' NOT NULL,
"scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"capabilities" jsonb DEFAULT '[]'::jsonb NOT NULL,
"profile" jsonb DEFAULT '{}'::jsonb NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_sync_at" timestamp with time zone,
"deleted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "connector_account_credentials" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"account_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"provider" text NOT NULL,
"credential_type" text NOT NULL,
"vault_ref" text NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"expires_at" timestamp with time zone,
"last_verified_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "connector_account_audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"account_id" uuid,
"agent_id" uuid NOT NULL,
"provider" text NOT NULL,
"actor_id" text,
"action" text NOT NULL,
"outcome" text NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "oauth_flows" (
"state_hash" text NOT NULL,
"agent_id" uuid NOT NULL,
"provider" text NOT NULL,
"account_id" uuid,
"redirect_uri" text,
"code_verifier_ref" text,
"scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"consumed_at" timestamp with time zone,
"consumed_by" text,
CONSTRAINT "oauth_flows_agent_provider_state_pk" PRIMARY KEY("agent_id","provider","state_hash")
);
--> statement-breakpoint
ALTER TABLE "connector_accounts" ADD CONSTRAINT "connector_accounts_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "connector_account_credentials" ADD CONSTRAINT "connector_account_credentials_account_id_connector_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."connector_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "connector_account_credentials" ADD CONSTRAINT "connector_account_credentials_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "connector_account_audit_events" ADD CONSTRAINT "connector_account_audit_events_account_id_connector_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."connector_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "connector_account_audit_events" ADD CONSTRAINT "connector_account_audit_events_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oauth_flows" ADD CONSTRAINT "oauth_flows_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oauth_flows" ADD CONSTRAINT "oauth_flows_account_id_connector_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."connector_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "connector_accounts_agent_provider_account_key_uniq" ON "connector_accounts" USING btree ("agent_id","provider","account_key") WHERE "deleted_at" IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "connector_accounts_agent_provider_external_uniq" ON "connector_accounts" USING btree ("agent_id","provider","external_id") WHERE "deleted_at" IS NULL;--> statement-breakpoint
CREATE INDEX "connector_accounts_agent_provider_idx" ON "connector_accounts" USING btree ("agent_id","provider");--> statement-breakpoint
CREATE INDEX "connector_accounts_status_idx" ON "connector_accounts" USING btree ("status");--> statement-breakpoint
CREATE INDEX "connector_accounts_updated_idx" ON "connector_accounts" USING btree ("updated_at");--> statement-breakpoint
CREATE UNIQUE INDEX "connector_account_credentials_account_type_uniq" ON "connector_account_credentials" USING btree ("account_id","credential_type");--> statement-breakpoint
CREATE UNIQUE INDEX "connector_account_credentials_agent_provider_ref_uniq" ON "connector_account_credentials" USING btree ("agent_id","provider","vault_ref");--> statement-breakpoint
CREATE INDEX "connector_account_credentials_agent_provider_idx" ON "connector_account_credentials" USING btree ("agent_id","provider");--> statement-breakpoint
CREATE INDEX "connector_account_credentials_expires_idx" ON "connector_account_credentials" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "connector_account_audit_agent_provider_idx" ON "connector_account_audit_events" USING btree ("agent_id","provider");--> statement-breakpoint
CREATE INDEX "connector_account_audit_account_idx" ON "connector_account_audit_events" USING btree ("account_id");--> statement-breakpoint
CREATE INDEX "connector_account_audit_action_idx" ON "connector_account_audit_events" USING btree ("action");--> statement-breakpoint
CREATE INDEX "connector_account_audit_created_idx" ON "connector_account_audit_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "oauth_flows_agent_provider_idx" ON "oauth_flows" USING btree ("agent_id","provider");--> statement-breakpoint
CREATE INDEX "oauth_flows_account_idx" ON "oauth_flows" USING btree ("account_id");--> statement-breakpoint
CREATE INDEX "oauth_flows_expires_idx" ON "oauth_flows" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "oauth_flows_consumed_idx" ON "oauth_flows" USING btree ("consumed_at");
@@ -0,0 +1,5 @@
ALTER TABLE "connector_accounts" ADD COLUMN IF NOT EXISTS "owner_binding_id" text;
--> statement-breakpoint
ALTER TABLE "connector_accounts" ADD COLUMN IF NOT EXISTS "owner_identity_id" text;
--> statement-breakpoint
ALTER TABLE "oauth_flows" ADD COLUMN IF NOT EXISTS "code_verifier_ref" text;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1777101370172,
"tag": "0000_lively_otto_octavius",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777200000000,
"tag": "0001_connector_accounts",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1778237300000,
"tag": "0002_connector_account_policy_backfill",
"breakpoints": true
}
]
}
+127
View File
@@ -0,0 +1,127 @@
{
"name": "@elizaos/plugin-sql",
"version": "2.0.3-beta.7",
"description": "",
"type": "module",
"main": "src/dist/index.js",
"types": "src/dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./src/dist/index.node.d.ts",
"eliza-source": {
"types": "./src/index.node.ts",
"import": "./src/index.node.ts",
"default": "./src/index.node.ts"
},
"bun": {
"types": "./src/dist/index.node.d.ts",
"import": "./src/dist/node/index.node.js",
"default": "./src/dist/node/index.node.js"
},
"browser": {
"types": "./src/dist/index.d.ts",
"import": "./src/dist/browser/index.browser.js",
"default": "./src/dist/browser/index.browser.js"
},
"node": {
"types": "./src/dist/index.d.ts",
"import": "./src/dist/node/index.node.js",
"default": "./src/dist/node/index.node.js"
},
"import": "./src/dist/index.js",
"default": "./src/dist/index.js"
},
"./drizzle": {
"types": "./src/dist/drizzle/index.d.ts",
"bun": {
"types": "./src/dist/drizzle/index.d.ts",
"import": "./src/dist/drizzle/index.js",
"default": "./src/dist/drizzle/index.js"
},
"import": "./src/dist/drizzle/index.js",
"default": "./src/dist/drizzle/index.js"
},
"./schema": {
"types": "./src/dist/schema/index.d.ts",
"bun": {
"types": "./src/dist/schema/index.d.ts",
"import": "./src/dist/schema/index.js",
"default": "./src/dist/schema/index.js"
},
"import": "./src/dist/schema/index.js",
"default": "./src/dist/schema/index.js"
},
"./*.css": "./dist/*.css",
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"default": "./dist/*.js"
}
},
"files": [
"src/dist",
"README.md",
"dist"
],
"keywords": [],
"author": "elizaOS",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/elizaos-plugins/plugin-sql"
},
"scripts": {
"build": "cd src && bun run build.ts",
"migrate": "cd src && drizzle-kit migrate",
"migrate:generate": "cd src && drizzle-kit generate",
"dev": "cd src && bun --hot build.ts",
"test": "cd src && vitest run",
"typecheck": "tsgo --noEmit -p src/tsconfig.json",
"lint": "cd src && bun run lint",
"lint:check": "cd src && bun run lint:check",
"clean": "node ../../packages/scripts/rm-path-recursive.mjs src/dist .turbo",
"format": "cd src && bun run format",
"format:check": "cd src && bun run format:check",
"test:e2e": "node ../../packages/app-core/scripts/run-local-plugin-live-smoke.mjs",
"test:live": "bun run test:e2e"
},
"dependencies": {
"@electric-sql/pglite": "^0.4.0",
"@electric-sql/pglite-sync": "0.5.6",
"@neondatabase/serverless": "^1.1.0",
"drizzle-orm": "0.45.2",
"pg": "^8.16.3",
"uuid": "^14.0.0",
"ws": "^8.18.3"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/bun": "^1.3.5",
"@types/node": "^25.0.3",
"@types/pg": "^8.15.2",
"@types/ws": "^8.18.1",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"typescript": "^6.0.3",
"vitest": "^4.0.0"
},
"peerDependencies": {
"@elizaos/core": "workspace:*"
},
"gitHead": "05d4ca11d769db8c7f54a722ee24b2ce2b951543",
"publishConfig": {
"access": "public"
},
"elizaos": {
"scripts": {
"testSerial": true,
"publish": {
"registryFallbackTag": "beta"
},
"devStack": {
"harnessBuild": true
}
}
}
}
@@ -0,0 +1,4 @@
{
"version": "1.0.0",
"evaluators": []
}
@@ -0,0 +1,48 @@
-- Align local and Docker test credentials with what the RLS suites expect.
ALTER ROLE postgres WITH PASSWORD 'postgres';
-- Enable required extensions before the non-admin user is created.
CREATE EXTENSION IF NOT EXISTS "vector" WITH SCHEMA public;
CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA public;
CREATE EXTENSION IF NOT EXISTS "fuzzystrmatch" WITH SCHEMA public;
-- Clean up RLS functions that may exist from previous test runs
-- This is required because CREATE OR REPLACE FUNCTION requires ownership
-- Without this, tests fail with "must be owner of function current_server_id"
DROP FUNCTION IF EXISTS current_server_id() CASCADE;
DROP FUNCTION IF EXISTS current_entity_id() CASCADE;
DROP FUNCTION IF EXISTS add_server_isolation(text, text) CASCADE;
DROP FUNCTION IF EXISTS apply_rls_to_all_tables() CASCADE;
DROP FUNCTION IF EXISTS apply_entity_rls_to_all_tables() CASCADE;
-- Create or refresh the non-admin user for RLS testing.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'eliza_test') THEN
CREATE USER eliza_test WITH PASSWORD 'test123';
ELSE
ALTER USER eliza_test WITH PASSWORD 'test123';
END IF;
END $$;
-- Grant necessary permissions (but NOT superuser)
-- GRANT CREATE allows creating schemas (needed for migrations schema)
GRANT ALL ON DATABASE eliza_test TO eliza_test;
GRANT CREATE ON DATABASE eliza_test TO eliza_test;
-- PostgreSQL 15+ changed default permissions on public schema
-- These grants are required for non-superusers to create objects in public schema
GRANT ALL ON SCHEMA public TO eliza_test;
GRANT USAGE ON SCHEMA public TO eliza_test;
GRANT CREATE ON SCHEMA public TO eliza_test;
-- Set default search_path for the user (required for table creation)
ALTER USER eliza_test SET search_path TO public;
-- Allow creating tables/sequences (applies to objects created by postgres user)
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO eliza_test;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO eliza_test;
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO eliza_test;
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO eliza_test;
-- Note: eliza_test is NOT a superuser, so RLS policies will apply
+207
View File
@@ -0,0 +1,207 @@
#!/bin/bash
# Integration tests runner
# Usage:
# ./scripts/run-integration-tests.sh # PGLite only
# ./scripts/run-integration-tests.sh --postgres # Include real PostgreSQL tests (auto-start Docker)
# POSTGRES_URL=... ./scripts/run-integration-tests.sh # Use existing PostgreSQL (CI mode)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$PACKAGE_DIR/docker-compose.test.yml"
USE_POSTGRES=false
POSTGRES_STARTED=false
# Auto-enable PostgreSQL tests if POSTGRES_URL is already set (CI mode)
if [ -n "$POSTGRES_URL" ]; then
USE_POSTGRES=true
fi
# Parse arguments
for arg in "$@"; do
case $arg in
--postgres)
USE_POSTGRES=true
shift
;;
esac
done
cleanup() {
if [ "$POSTGRES_STARTED" = true ]; then
echo "🧹 Stopping PostgreSQL..."
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
fi
}
trap cleanup EXIT
start_postgres() {
# Skip if POSTGRES_URL already set (CI provides PostgreSQL)
if [ -n "$POSTGRES_URL" ]; then
echo "✅ Using existing PostgreSQL: $POSTGRES_URL"
return 0
fi
echo "🐘 Starting PostgreSQL via Docker..."
docker compose -f "$COMPOSE_FILE" up -d
echo "⏳ Waiting for PostgreSQL to be ready..."
for i in {1..30}; do
if docker exec plugin-sql-test-postgres pg_isready -U postgres > /dev/null 2>&1; then
echo "✅ PostgreSQL is ready"
POSTGRES_STARTED=true
export POSTGRES_URL="postgresql://eliza_test:test123@localhost:5432/eliza_test"
return 0
fi
echo -n "."
sleep 1
done
echo ""
echo "❌ PostgreSQL failed to start"
return 1
}
# Define test groups (PGLite)
BATCH1=(
"src/__tests__/integration/memory.test.ts"
"src/__tests__/integration/cache.test.ts"
"src/__tests__/integration/embedding.test.ts"
)
BATCH2=(
"src/__tests__/integration/agent.test.ts"
"src/__tests__/integration/entity.test.ts"
"src/__tests__/integration/entity-crud.test.ts"
)
BATCH3=(
"src/__tests__/integration/component.test.ts"
"src/__tests__/integration/relationship.test.ts"
"src/__tests__/integration/room.test.ts"
)
BATCH4=(
"src/__tests__/integration/world.test.ts"
"src/__tests__/integration/log.test.ts"
"src/__tests__/integration/messaging.test.ts"
)
BATCH5=(
"src/__tests__/integration/base-comprehensive.test.ts"
"src/__tests__/integration/base-adapter-methods.test.ts"
"src/__tests__/integration/cascade-delete.test.ts"
)
BATCH6=(
"src/__tests__/integration/entity-methods.test.ts"
"src/__tests__/integration/participant.test.ts"
"src/__tests__/integration/task.test.ts"
)
BATCH7=(
"src/__tests__/integration/utils.test.ts"
"src/__tests__/integration/schema-factory.test.ts"
)
# PostgreSQL-specific tests
BATCH_POSTGRES=(
"src/__tests__/integration/postgres/pglite-adapter.test.ts"
"src/__tests__/integration/postgres/postgres-init.test.ts"
"src/__tests__/integration/postgres/pg-adapter-integration.test.ts"
)
BATCH_RLS=(
"src/__tests__/integration/postgres/rls-entity.test.ts"
"src/__tests__/integration/postgres/rls-server.test.ts"
"src/__tests__/integration/postgres/rls-logs.test.ts"
"src/__tests__/integration/postgres/rls-message-server-agents.test.ts"
)
run_batch() {
local batch_name=$1
shift
local tests=("$@")
echo ""
echo "📦 Running: $batch_name"
if ! npx vitest run "${tests[@]}" --timeout=120000 --bail=1; then
echo "$batch_name failed"
return 1
fi
echo "$batch_name passed"
sleep 1
}
# Run tests sequentially (one file at a time) - needed for RLS tests
# where rls-entity.test.ts must complete before others run
run_sequential() {
local batch_name=$1
shift
local tests=("$@")
echo ""
echo "📦 Running: $batch_name (sequential)"
for test_file in "${tests[@]}"; do
echo "$(basename "$test_file")"
if ! npx vitest run "$test_file" --timeout=120000; then
echo "$batch_name failed at $test_file"
return 1
fi
done
echo "$batch_name passed"
sleep 1
}
echo "🧪 Running plugin-sql integration tests"
if [ "$USE_POSTGRES" = true ]; then
echo " Mode: PGLite + PostgreSQL"
else
echo " Mode: PGLite only"
fi
echo ""
OVERALL_SUCCESS=true
# Core tests (PGLite)
run_batch "Core Tests" "${BATCH1[@]}" || OVERALL_SUCCESS=false
run_batch "Entity Tests" "${BATCH2[@]}" || OVERALL_SUCCESS=false
run_batch "Component Tests" "${BATCH3[@]}" || OVERALL_SUCCESS=false
run_batch "Infrastructure Tests" "${BATCH4[@]}" || OVERALL_SUCCESS=false
run_batch "Integration Tests" "${BATCH5[@]}" || OVERALL_SUCCESS=false
run_batch "Method Tests" "${BATCH6[@]}" || OVERALL_SUCCESS=false
run_batch "Utility Tests" "${BATCH7[@]}" || OVERALL_SUCCESS=false
# PostgreSQL tests (if requested or POSTGRES_URL set)
if [ "$USE_POSTGRES" = true ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🐘 PostgreSQL Tests"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if start_postgres; then
run_batch "PostgreSQL Adapter Tests" "${BATCH_POSTGRES[@]}" || OVERALL_SUCCESS=false
# RLS tests must run sequentially - rls-entity.test.ts creates schema and installs RLS
# that other RLS tests depend on
run_sequential "RLS Tests" "${BATCH_RLS[@]}" || OVERALL_SUCCESS=false
else
OVERALL_SUCCESS=false
fi
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ "$OVERALL_SUCCESS" = true ]; then
echo "🎉 All tests passed!"
exit 0
else
echo "💥 Some tests failed"
exit 1
fi
+12
View File
@@ -0,0 +1,12 @@
*
!dist/**
!package.json
!README.md
!readme.md
!tsup.config.ts
!schema.sql
!seed.sql
!config.toml
!types/**
__tests__/**
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Shaw Walters and elizaOS Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,25 @@
/**
* Compile-time check (no runtime assertions beyond expectTypeOf) that
* plugin-sql's re-exported connector-account storage types stay identical to
* the `@elizaos/core` contract types, including the adapter method signature.
*/
import type {
ConnectorAccountRecord as CoreConnectorAccountRecord,
UpsertConnectorAccountParams as CoreUpsertConnectorAccountParams,
IDatabaseAdapter,
} from "@elizaos/core";
import { describe, expectTypeOf, it } from "vitest";
import type {
ConnectorAccountRecord as SqlConnectorAccountRecord,
UpsertConnectorAccountParams as SqlUpsertConnectorAccountParams,
} from "../index";
describe("connector account storage types", () => {
it("re-exports the core connector account storage contract types", () => {
expectTypeOf<SqlConnectorAccountRecord>().toEqualTypeOf<CoreConnectorAccountRecord>();
expectTypeOf<SqlUpsertConnectorAccountParams>().toEqualTypeOf<CoreUpsertConnectorAccountParams>();
expectTypeOf<
Parameters<IDatabaseAdapter["upsertConnectorAccount"]>[0]
>().toEqualTypeOf<CoreUpsertConnectorAccountParams>();
});
});
@@ -0,0 +1,287 @@
/**
* Real-PGlite integration test for the built-in advanced-memory plugin running
* on top of plugin-sql storage: boots a full AgentRuntime with a migrated
* PGlite adapter, then verifies long-term memories are stored/retrieved
* (including across confirmed entity-identity links) and session summaries
* are persisted and read back for real conversation rooms.
*/
import { PGlite } from "@electric-sql/pglite";
import {
AgentRuntime,
ChannelType,
type Character,
type Entity,
type IAgentRuntime,
type Plugin,
type Room,
Service,
type UUID,
type World,
} from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterEach, describe, expect, it } from "vitest";
import { plugin as sqlPlugin } from "../../index";
import { DatabaseMigrationService } from "../../migration-service";
import { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { PGliteClientManager } from "../../pglite/manager";
import * as schema from "../../schema";
import type { DrizzleDatabase } from "../../types";
type RuntimeMemoryService = {
storeLongTermMemory: (memory: {
agentId: UUID;
entityId: UUID;
category: "episodic" | "semantic" | "procedural";
content: string;
confidence?: number;
source?: string;
metadata?: Record<string, unknown>;
embedding?: number[];
}) => Promise<{
id: UUID;
entityId: UUID;
content: string;
confidence?: number;
}>;
getLongTermMemories: (
entityId: UUID,
category?: "episodic" | "semantic" | "procedural",
limit?: number
) => Promise<Array<{ id: UUID; entityId: UUID; content: string; confidence?: number }>>;
storeSessionSummary: (summary: {
agentId: UUID;
roomId: UUID;
entityId?: UUID;
summary: string;
messageCount: number;
lastMessageOffset: number;
startTime: Date;
endTime: Date;
topics?: string[];
metadata?: Record<string, unknown>;
embedding?: number[];
}) => Promise<{ id: UUID; summary: string; messageCount: number }>;
getCurrentSessionSummary: (roomId: UUID) => Promise<{
id: UUID;
summary: string;
messageCount: number;
topics?: string[];
} | null>;
};
class TestEntityResolutionService extends Service {
static serviceType = "entity_resolution" as const;
static links = new Map<UUID, UUID[]>();
capabilityDescription = "Entity-resolution test service for advanced-memory tests";
static async start(runtime: IAgentRuntime): Promise<Service> {
const service = new TestEntityResolutionService(runtime);
await service.initialize(runtime);
return service;
}
async initialize(runtime: IAgentRuntime): Promise<void> {
this.runtime = runtime;
}
async stop(): Promise<void> {}
async getConfirmedLinks(
entityId: UUID
): Promise<Array<{ entityA: UUID; entityB: UUID; status: "confirmed" }>> {
return (TestEntityResolutionService.links.get(entityId) ?? []).map((other) => ({
entityA: entityId,
entityB: other,
status: "confirmed" as const,
}));
}
}
async function createMigratedAdapter(agentId: UUID): Promise<PgliteDatabaseAdapter> {
const client = new PGlite();
const manager = new PGliteClientManager(client);
const adapter = new PgliteDatabaseAdapter(agentId, manager);
await adapter.init();
const migrationService = new DatabaseMigrationService();
const db = adapter.getDatabase() as DrizzleDatabase;
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
return adapter;
}
async function createConversationRoom(
runtime: AgentRuntime
): Promise<{ roomId: UUID; worldId: UUID }> {
const worldId = uuidv4() as UUID;
const roomId = uuidv4() as UUID;
const world: World = {
id: worldId,
agentId: runtime.agentId,
name: "Test World",
metadata: {},
createdAt: new Date(),
} as World & { createdAt: Date };
await runtime.createWorld(world);
const room: Room = {
id: roomId,
agentId: runtime.agentId,
worldId,
source: "test",
type: ChannelType.DM,
name: "Test Room",
metadata: {},
createdAt: new Date(),
} as Room & { createdAt: Date };
await runtime.createRooms([room]);
return { roomId, worldId };
}
async function createEntities(runtime: AgentRuntime, entityIds: UUID[]): Promise<void> {
const entities: Entity[] = entityIds.map((entityId, index) => ({
id: entityId,
agentId: runtime.agentId,
names: [`Entity ${index + 1}`],
metadata: {},
}));
await runtime.createEntities(entities);
}
function createRuntime(extraServices: NonNullable<Plugin["services"]> = []): AgentRuntime {
const character: Character = {
name: "Eliza",
bio: ["Test"],
templates: {},
messageExamples: [],
postExamples: [],
topics: [],
adjectives: [],
knowledge: [],
advancedMemory: true,
secrets: {},
};
const integrationPlugin: Plugin = {
name: "advanced-memory-integration-test",
description: "Advanced memory integration test plugin",
services: extraServices,
};
return new AgentRuntime({
character,
plugins: [sqlPlugin, integrationPlugin],
});
}
describe("plugin-sql advanced memory storage", () => {
const runtimes: AgentRuntime[] = [];
afterEach(async () => {
TestEntityResolutionService.links.clear();
await Promise.all(
runtimes.splice(0).map(async (runtime) => {
await runtime.stop();
})
);
});
it("boots the built-in advanced-memory plugin against plugin-sql storage", async () => {
const runtime = createRuntime();
runtimes.push(runtime);
const adapter = await createMigratedAdapter(runtime.agentId);
runtime.registerDatabaseAdapter(adapter);
await runtime.initialize({ skipMigrations: true });
const memoryStorage = await runtime.getServiceLoadPromise("memoryStorage");
const memory = await runtime.getServiceLoadPromise("memory");
expect(memoryStorage).toBeTruthy();
expect(memory).toBeTruthy();
expect(runtime.providers.some((provider) => provider.name === "LONG_TERM_MEMORY")).toBe(true);
expect(runtime.providers.some((provider) => provider.name === "SUMMARIZED_CONTEXT")).toBe(true);
expect(runtime.evaluators.some((evaluator) => evaluator.name === "summary")).toBe(true);
expect(runtime.evaluators.some((evaluator) => evaluator.name === "longTermMemory")).toBe(true);
});
it("stores long-term memories in SQL and retrieves them across confirmed identity links", async () => {
const runtime = createRuntime([TestEntityResolutionService]);
runtimes.push(runtime);
const adapter = await createMigratedAdapter(runtime.agentId);
runtime.registerDatabaseAdapter(adapter);
await runtime.initialize({ skipMigrations: true });
const entityA = uuidv4() as UUID;
const entityB = uuidv4() as UUID;
await createEntities(runtime, [entityA, entityB]);
TestEntityResolutionService.links.set(entityA, [entityB]);
TestEntityResolutionService.links.set(entityB, [entityA]);
const memoryService = (await runtime.getServiceLoadPromise(
"memory"
)) as unknown as RuntimeMemoryService;
const stored = await memoryService.storeLongTermMemory({
agentId: runtime.agentId,
entityId: entityA,
category: "semantic",
content: "Chris prefers short emails and fast follow-ups.",
confidence: 0.93,
source: "conversation",
metadata: { channel: "discord" },
});
expect(stored.entityId).toBe(entityA);
const viaLinkedIdentity = await memoryService.getLongTermMemories(entityB, undefined, 10);
expect(viaLinkedIdentity).toHaveLength(1);
expect(viaLinkedIdentity[0]?.content).toContain("short emails");
expect(viaLinkedIdentity[0]?.entityId).toBe(entityA);
});
it("stores session summaries in SQL for real conversation rooms", async () => {
const runtime = createRuntime();
runtimes.push(runtime);
const adapter = await createMigratedAdapter(runtime.agentId);
runtime.registerDatabaseAdapter(adapter);
await runtime.initialize({ skipMigrations: true });
const entityId = uuidv4() as UUID;
await createEntities(runtime, [entityId]);
const { roomId } = await createConversationRoom(runtime);
const memoryService = (await runtime.getServiceLoadPromise(
"memory"
)) as unknown as RuntimeMemoryService;
await memoryService.storeSessionSummary({
agentId: runtime.agentId,
roomId,
entityId,
summary: "We agreed to ship the billing audit first, then revisit experiments.",
messageCount: 12,
lastMessageOffset: 12,
startTime: new Date("2026-04-08T19:00:00.000Z"),
endTime: new Date("2026-04-08T19:20:00.000Z"),
topics: ["billing", "experiments"],
metadata: { source: "test" },
});
const current = await memoryService.getCurrentSessionSummary(roomId);
expect(current?.summary).toContain("billing audit first");
expect(current?.messageCount).toBe(12);
expect(current?.topics).toEqual(["billing", "experiments"]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,774 @@
/**
* Real-database integration tests for `BaseDrizzleAdapter` CRUD/query methods
* (memories, entities, components, rooms) against an isolated test database,
* covering room/count filters, updates, cascade behavior on delete, and
* idempotent/no-op handling of duplicate or missing records.
*/
import type { ChannelType, Component, Content, Entity, Memory, Room, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
import { expectCreatedEntityIds } from "./entity-create-assertions";
describe("Base Adapter Methods Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testEntityId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("base-adapter-methods");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
// Create a test entity for memories
testEntityId = uuidv4() as UUID;
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity for Memories"],
metadata: { type: "custom" },
},
]);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("CRUD Operations", () => {
it("should handle getMemories with various filters", async () => {
const agentId = testAgentId;
const roomId = uuidv4() as UUID;
const roomId2 = uuidv4() as UUID; // Different room for third memory
// Create rooms first
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
{
id: roomId2,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room 2",
},
]);
// Create test memories
const memories: Memory[] = [
{
id: uuidv4() as UUID,
agentId,
entityId: testEntityId,
roomId,
content: { text: "Test memory 1" } as Content,
createdAt: Date.now() - 1000,
metadata: { type: "custom" },
},
{
id: uuidv4() as UUID,
agentId,
entityId: testEntityId,
roomId,
content: { text: "Test memory 2" } as Content,
createdAt: Date.now() - 500,
metadata: { type: "custom" },
},
{
id: uuidv4() as UUID,
agentId,
entityId: testEntityId,
roomId: roomId2, // Different room
content: { text: "Test memory 3" } as Content,
createdAt: Date.now(),
metadata: { type: "custom" },
},
];
for (const memory of memories) {
await adapter.createMemory(memory, "memories");
}
// Test with room filter
const roomMemories = await adapter.getMemories({
roomId,
tableName: "memories",
count: 10,
});
expect(roomMemories.length).toBe(2);
// Test with count limit
const limitedMemories = await adapter.getMemories({
roomId,
tableName: "memories",
count: 1,
});
expect(limitedMemories.length).toBe(1);
});
it("should handle getMemoriesByRoomIds", async () => {
const agentId = testAgentId;
const roomId1 = uuidv4() as UUID;
const roomId2 = uuidv4() as UUID;
// Create rooms
await adapter.createRooms([
{
id: roomId1,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Room 1",
},
{
id: roomId2,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Room 2",
},
]);
// Create memories in different rooms
const memories: Memory[] = [
{
id: uuidv4() as UUID,
agentId,
entityId: testEntityId,
roomId: roomId1,
content: { text: "Room 1 memory" } as Content,
createdAt: Date.now(),
metadata: { type: "custom" },
},
{
id: uuidv4() as UUID,
agentId,
entityId: testEntityId,
roomId: roomId2,
content: { text: "Room 2 memory" } as Content,
createdAt: Date.now(),
metadata: { type: "custom" },
},
];
for (const memory of memories) {
await adapter.createMemory(memory, "memories");
}
// Test getting memories from multiple rooms
const retrievedMemories = await adapter.getMemoriesByRoomIds({
roomIds: [roomId1, roomId2],
tableName: "memories",
});
expect(retrievedMemories.length).toBe(2);
});
it("should handle updateEntity", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Test Entity"],
metadata: {
test: true,
version: 1,
},
};
// Create entity
await adapter.createEntities([entity]);
// Update entity
const updatedEntity: Entity = {
...entity,
names: ["Updated Entity"],
metadata: {
test: false,
version: 2,
},
};
await adapter.updateEntity(updatedEntity);
// Verify update
const retrieved = await adapter.getEntitiesByNames({
names: ["Updated Entity"],
agentId: testAgentId,
});
expect(retrieved.length).toBe(1);
if (!retrieved[0]) throw new Error("Entity should exist");
expect(retrieved[0].id).toBe(entity.id as UUID);
const metadata = retrieved[0].metadata as Record<string, unknown>;
if (!metadata) throw new Error("Metadata should exist");
expect(metadata.version).toBe(2);
});
it("should handle updateMemory", async () => {
const roomId = uuidv4() as UUID;
// Create room first
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
const memoryId = uuidv4() as UUID;
const memory: Memory = {
id: memoryId,
agentId: testAgentId,
entityId: testEntityId,
roomId: roomId,
content: { text: "Original content" } as Content,
createdAt: Date.now(),
metadata: { type: "custom" },
};
// Create memory
await adapter.createMemory(memory, "memories");
// Update memory
await adapter.updateMemory({
id: memoryId,
content: { text: "Updated content" } as Content,
metadata: { type: "custom" },
});
// Verify update
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).not.toBeNull();
if (!retrieved) throw new Error("Memory should exist");
const content = retrieved.content as Record<string, unknown>;
if (!content) throw new Error("Content should exist");
expect(content.text).toBe("Updated content");
const metadata = retrieved.metadata as Record<string, unknown>;
if (!metadata) throw new Error("Metadata should exist");
expect(metadata.type).toBe("custom");
});
it("should handle updateComponent", async () => {
const worldId = uuidv4() as UUID;
const sourceEntityId = uuidv4() as UUID;
const roomId = uuidv4() as UUID;
// Create world first
await adapter.createWorld({
id: worldId,
agentId: testAgentId,
messageServerId: uuidv4() as UUID,
name: "Test World",
});
// Create room
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
// Create source entity
await adapter.createEntities([
{
id: sourceEntityId,
agentId: testAgentId,
names: ["Source Entity"],
metadata: {},
},
]);
const component: Component = {
id: uuidv4() as UUID,
type: "relationship",
worldId,
entityId: testEntityId,
sourceEntityId,
agentId: testAgentId,
roomId,
data: {
relationship: "friend",
trust: 0.5,
},
createdAt: Date.now(),
};
// Create component
await adapter.createComponent(component);
// Update component
await adapter.updateComponent({
...component,
data: {
relationship: "best_friend",
trust: 0.9,
},
});
// Verify update
const retrieved = await adapter.getComponent(
testEntityId,
"relationship",
worldId,
sourceEntityId
);
expect(retrieved).not.toBeNull();
if (!retrieved) throw new Error("Component should exist");
const data = retrieved.data as Record<string, unknown>;
if (!data) throw new Error("Data should exist");
expect(data.relationship).toBe("best_friend");
expect(data.trust).toBe(0.9);
});
it("should handle deleteComponent", async () => {
const worldId = uuidv4() as UUID;
const sourceEntityId = uuidv4() as UUID;
const roomId = uuidv4() as UUID;
// Create world first
await adapter.createWorld({
id: worldId,
agentId: testAgentId,
messageServerId: uuidv4() as UUID,
name: "Test World",
});
// Create room
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
// Create source entity
await adapter.createEntities([
{
id: sourceEntityId,
agentId: testAgentId,
names: ["Source Entity"],
metadata: {},
},
]);
const component: Component = {
id: uuidv4() as UUID,
type: "test",
worldId,
entityId: testEntityId,
sourceEntityId,
agentId: testAgentId,
roomId,
data: {},
createdAt: Date.now(),
};
// Create component
await adapter.createComponent(component);
// Delete component
await adapter.deleteComponent(component.id);
// Verify removal
const retrieved = await adapter.getComponent(testEntityId, "test", worldId, sourceEntityId);
expect(retrieved).toBeNull();
});
it("should handle deleteEntity", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Test Entity"],
metadata: { type: "custom" },
};
// Create entity
await adapter.createEntities([entity]);
// Delete entity
if (entity.id) {
await adapter.deleteEntity(entity.id);
}
// Verify removal
const retrieved = await adapter.getEntitiesByNames({
names: entity.names,
agentId: testAgentId,
});
expect(retrieved.length).toBe(0);
});
it("should handle removeMemory using deleteMemory", async () => {
const roomId = uuidv4() as UUID;
// Create room first
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
const memoryId = uuidv4() as UUID;
const memory: Memory = {
id: memoryId,
agentId: testAgentId,
entityId: testEntityId,
roomId: roomId,
content: { text: "Test memory" } as Content,
createdAt: Date.now(),
metadata: { type: "custom" },
};
// Create memory
await adapter.createMemory(memory, "memories");
// Remove memory
await adapter.deleteMemory(memoryId);
// Verify removal
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeNull();
});
it("should handle deleteRoom", async () => {
const room: Room = {
id: uuidv4() as UUID,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
};
// Create room
await adapter.createRooms([room]);
// Delete room
await adapter.deleteRoom(room.id);
// Verify removal - use getRoomsByIds to check
const retrieved = await adapter.getRoomsByIds([room.id]);
if (!retrieved) throw new Error("Result should exist");
expect(retrieved.length).toBe(0);
});
it("should handle entity operations with metadata", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Test Entity"],
metadata: {
category: "person",
age: 25,
tags: ["developer", "engineer"],
nested: {
level: 1,
data: "test",
},
},
};
await adapter.createEntities([entity]);
const retrieved = await adapter.getEntitiesByNames({
names: entity.names,
agentId: testAgentId,
});
expect(retrieved.length).toBe(1);
if (!retrieved[0]) throw new Error("Entity should exist");
const metadata = retrieved[0].metadata as Record<string, unknown>;
if (!metadata) throw new Error("Metadata should exist");
expect(metadata.category).toBe("person");
expect(metadata.age).toBe(25);
expect(metadata.tags).toEqual(["developer", "engineer"]);
const nested = metadata.nested as Record<string, unknown>;
if (!nested) throw new Error("Nested metadata should exist");
expect(nested.level).toBe(1);
});
it("should handle component operations with complex params", async () => {
const worldId = uuidv4() as UUID;
const sourceEntityId = uuidv4() as UUID;
const roomId = uuidv4() as UUID;
// Create world first
await adapter.createWorld({
id: worldId,
agentId: testAgentId,
messageServerId: uuidv4() as UUID,
name: "Test World",
});
// Create room
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
// Create source entity
await adapter.createEntities([
{
id: sourceEntityId,
agentId: testAgentId,
names: ["Source Entity"],
metadata: {},
},
]);
const component: Component = {
id: uuidv4() as UUID,
type: "inventory",
worldId,
entityId: testEntityId,
sourceEntityId,
agentId: testAgentId,
roomId,
data: {
items: [
{ id: "sword", quantity: 1, damage: 10 },
{ id: "shield", quantity: 1, defense: 5 },
],
maxCapacity: 10,
currentWeight: 25.5,
},
createdAt: Date.now(),
};
await adapter.createComponent(component);
const retrieved = await adapter.getComponent(
testEntityId,
"inventory",
worldId,
sourceEntityId
);
expect(retrieved).toBeDefined();
if (!retrieved) throw new Error("Component should exist");
const data = retrieved.data as Record<string, unknown>;
if (!data) throw new Error("Data should exist");
const items = data.items as Array<unknown>;
if (!items) throw new Error("Items should exist");
expect(items).toHaveLength(2);
expect(data.maxCapacity).toBe(10);
expect(data.currentWeight).toBe(25.5);
});
});
describe("Search and Filtering", () => {
beforeEach(async () => {
// Clean up any existing entities from previous tests
const existingEntities = await adapter.searchEntitiesByName({
query: "",
agentId: testAgentId,
limit: 100,
});
for (const entity of existingEntities) {
if (entity.id) {
await adapter.deleteEntity(entity.id);
}
}
});
it("should search entities by name and limit results", async () => {
// Create multiple entities
const entities: Entity[] = [
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Alice Smith", "Alicia"],
metadata: { type: "person" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Bob Johnson"],
metadata: { type: "person" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Alice Cooper"],
metadata: { type: "person" },
},
];
for (const entity of entities) {
await adapter.createEntities([entity]);
}
// Search for entities with 'Alice' in name
const searchResults = await adapter.searchEntitiesByName({
query: "Alice",
agentId: testAgentId,
limit: 10,
});
expect(searchResults.length).toBe(2);
expect(
searchResults.every((e) => e.names.some((name) => name.toLowerCase().includes("alice")))
).toBe(true);
// Test with limit
const limitedResults = await adapter.searchEntitiesByName({
query: "Alice",
agentId: testAgentId,
limit: 1,
});
expect(limitedResults.length).toBe(1);
});
it("should handle complex memory searches", async () => {
const agentId = testAgentId;
const roomId = uuidv4() as UUID;
const entityId = uuidv4() as UUID;
// Create entity first
await adapter.createEntities([
{
id: entityId,
agentId: testAgentId,
names: ["Test Entity for Memory Search"],
metadata: { type: "custom" },
},
]);
// Create room
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
// Create memories with different metadata
const memories: Memory[] = [
{
id: uuidv4() as UUID,
agentId,
entityId: entityId,
roomId,
content: { text: "Meeting scheduled for tomorrow" } as Content,
createdAt: Date.now() - 3600_000, // 1 hour ago
metadata: { type: "custom", priority: "high" },
},
{
id: uuidv4() as UUID,
agentId,
entityId: entityId,
roomId,
content: { text: "Remember to buy groceries" } as Content,
createdAt: Date.now() - 1800_000, // 30 min ago
metadata: { type: "custom", priority: "low" },
},
{
id: uuidv4() as UUID,
agentId,
entityId: entityId,
roomId,
content: { text: "Important meeting notes" } as Content,
createdAt: Date.now() - 900_000, // 15 min ago
metadata: { type: "custom", priority: "high" },
},
];
for (const memory of memories) {
await adapter.createMemory(memory, "memories");
}
// Get recent memories
const recentMemories = await adapter.getMemories({
roomId,
tableName: "memories",
count: 2,
});
expect(recentMemories.length).toBe(2);
// Memories are ordered by createdAt DESC, so most recent should be first
if (!recentMemories[0]) throw new Error("Memory should exist");
const content = recentMemories[0].content as Record<string, unknown>;
if (!content) throw new Error("Content should exist");
expect(content.text).toBe("Important meeting notes");
});
});
describe("Error Handling", () => {
it("should handle duplicate entity creation gracefully", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Test Entity"],
metadata: { type: "custom" },
};
// Create entity
await adapter.createEntities([entity]);
// Duplicate creation is idempotent: the existing id is reported as
// success and no second row appears.
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const rows = await adapter.getEntitiesByIds([entity.id as UUID]);
expect(rows).toHaveLength(1);
});
it("should handle updating non-existent entity", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Non-existent Entity"],
metadata: { type: "custom" },
};
// Update should not throw but also shouldn't create
await adapter.updateEntity(entity);
// Verify entity doesn't exist
const retrieved = await adapter.getEntitiesByNames({
names: entity.names,
agentId: testAgentId,
});
expect(retrieved.length).toBe(0);
});
it("should handle removing non-existent items", async () => {
// These should not throw
await adapter.deleteEntity(uuidv4() as UUID);
await adapter.deleteMemory(uuidv4() as UUID);
await adapter.deleteComponent(uuidv4() as UUID);
await adapter.deleteRoom(uuidv4() as UUID);
});
});
});
@@ -0,0 +1,563 @@
/**
* Broader real-database coverage of `BaseDrizzleAdapter` beyond the basic CRUD
* suite: exact-match name search, cascade deletes, multi-room memory queries,
* component lifecycle, room/world relationships, embedding search, cached
* embeddings, and batch-create edge cases.
*/
import type {
AgentRuntime,
ChannelType,
Component,
Content,
Entity,
Memory,
Room,
UUID,
} from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
import { expectCreatedEntityIds } from "./entity-create-assertions";
describe("Base Adapter Comprehensive Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let _runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testEntityId: UUID;
let testRoomId: UUID;
let testWorldId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("base-comprehensive");
adapter = setup.adapter;
_runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
// Create base entities for foreign key constraints
testEntityId = uuidv4() as UUID;
testRoomId = uuidv4() as UUID;
testWorldId = uuidv4() as UUID;
// Create world first
await adapter.createWorld({
id: testWorldId,
agentId: testAgentId,
messageServerId: uuidv4() as UUID,
name: "Test World",
});
// Create room
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
// Create entity
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
metadata: { type: "custom" },
},
]);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Entity Methods", () => {
it("should handle getEntitiesByNames with exact match", async () => {
// Create multiple entities with different names
const entities = [
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Alice", "Alice Smith"],
metadata: { type: "person" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Bob", "Bob Jones"],
metadata: { type: "person" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Charlie"],
metadata: { type: "person" },
},
];
for (const entity of entities) {
await adapter.createEntities([entity]);
}
// Search for specific names
const results = await adapter.getEntitiesByNames({
names: ["Alice", "Bob"],
agentId: testAgentId,
});
expect(results).toHaveLength(2);
const foundNames = results.flatMap((e) => e.names);
expect(foundNames).toContain("Alice");
expect(foundNames).toContain("Bob");
expect(foundNames).not.toContain("Charlie");
});
it("should handle searchEntitiesByName with limit", async () => {
// Create many entities
const entities: Entity[] = [];
for (let i = 0; i < 10; i++) {
entities.push({
id: uuidv4() as UUID,
agentId: testAgentId,
names: [`Search Test ${i}`],
metadata: { index: i },
});
}
for (const entity of entities) {
await adapter.createEntities([entity]);
}
// Search with limit
const results = await adapter.searchEntitiesByName({
query: "Search Test",
agentId: testAgentId,
limit: 5,
});
expect(results).toHaveLength(5);
});
it("should handle deleteEntity with cascade", async () => {
const entityId = uuidv4() as UUID;
// Create entity
await adapter.createEntities([
{
id: entityId,
agentId: testAgentId,
names: ["Delete Test"],
metadata: {},
},
]);
// Create related memory
await adapter.createMemory(
{
id: uuidv4() as UUID,
agentId: testAgentId,
entityId: entityId,
roomId: testRoomId,
content: { text: "Related memory" } as Content,
createdAt: Date.now(),
metadata: { type: "custom" },
},
"memories"
);
// Delete entity (should cascade delete memory)
await adapter.deleteEntity(entityId);
// Verify entity is deleted
const entities = await adapter.getEntitiesByIds([entityId]);
expect(entities).toHaveLength(0);
// Verify related memory is also deleted
const memories = await adapter.getMemories({
agentId: testAgentId,
entityId: entityId,
tableName: "memories",
});
expect(memories).toHaveLength(0);
});
});
describe("Memory Operations", () => {
it("should handle memory operations with all fields", async () => {
const memoryId = uuidv4() as UUID;
const memory: Memory = {
id: memoryId,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: {
text: "Comprehensive test memory",
metadata: { important: true },
} as Content,
createdAt: Date.now(),
metadata: {
type: "custom",
category: "comprehensive",
priority: 1,
},
};
// Create memory
await adapter.createMemory(memory, "memories");
// Get memory with filters
const memories = await adapter.getMemories({
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
tableName: "memories",
count: 10,
});
expect(memories).toHaveLength(1);
expect(memories[0].id).toBe(memoryId);
const memories0Metadata = memories[0].metadata as Record<string, unknown>;
expect(memories0Metadata?.category).toBe("comprehensive");
// Update memory
await adapter.updateMemory({
id: memoryId,
content: { text: "Updated memory" } as Content,
metadata: { type: "custom", category: "comprehensive-updated" },
});
// Verify update
const updated = await adapter.getMemories({
agentId: testAgentId,
entityId: testEntityId,
tableName: "memories",
});
expect(updated[0].content.text).toBe("Updated memory");
const updated0Metadata = updated[0].metadata as Record<string, unknown>;
expect(updated0Metadata?.category).toBe("comprehensive-updated");
// Delete memory
await adapter.deleteMemory(memoryId);
// Verify deletion
const deleted = await adapter.getMemories({
agentId: testAgentId,
entityId: testEntityId,
tableName: "memories",
});
expect(deleted).toHaveLength(0);
});
it("should handle getMemoriesByRoomIds with multiple rooms", async () => {
const roomIds: UUID[] = [];
const memories: Memory[] = [];
// Create multiple rooms
for (let i = 0; i < 3; i++) {
const roomId = uuidv4() as UUID;
roomIds.push(roomId);
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: "GROUP" as ChannelType,
name: `Room ${i}`,
},
]);
// Create memory for each room
const memory: Memory = {
id: uuidv4() as UUID,
agentId: testAgentId,
entityId: testEntityId,
roomId: roomId,
content: { text: `Memory for room ${i}` } as Content,
createdAt: Date.now(),
metadata: { type: "custom", roomIndex: i },
};
memories.push(memory);
await adapter.createMemory(memory, "memories");
}
// Get memories for first two rooms
const results = await adapter.getMemoriesByRoomIds({
roomIds: roomIds.slice(0, 2),
tableName: "memories",
});
expect(results).toHaveLength(2);
expect(results.every((m) => roomIds.slice(0, 2).includes(m.roomId!))).toBe(true);
});
});
describe("Component Operations", () => {
it("should handle component CRUD with all fields", async () => {
const sourceEntityId = uuidv4() as UUID;
// Create source entity first
await adapter.createEntities([
{
id: sourceEntityId,
agentId: testAgentId,
names: ["Source Entity"],
metadata: {},
},
]);
const component: Component = {
id: uuidv4() as UUID,
type: "relationship",
worldId: testWorldId,
entityId: testEntityId,
sourceEntityId: sourceEntityId,
agentId: testAgentId,
roomId: testRoomId,
data: {
relationshipType: "friend",
strength: 0.8,
},
createdAt: Date.now(),
};
// Create component
const created = await adapter.createComponent(component);
expect(created).toBe(true);
// Get component
const retrieved = await adapter.getComponent(
testEntityId,
"relationship",
testWorldId,
sourceEntityId
);
expect(retrieved).toBeDefined();
expect(retrieved?.data.relationshipType).toBe("friend");
// Update component
await adapter.updateComponent({
...component,
data: {
relationshipType: "best_friend",
strength: 1.0,
},
});
// Verify update
const updated = await adapter.getComponent(
testEntityId,
"relationship",
testWorldId,
sourceEntityId
);
expect(updated).not.toBeNull();
expect(updated?.data.relationshipType).toBe("best_friend");
expect(updated?.data.strength).toBe(1.0);
// Delete component
await adapter.deleteComponent(component.id);
// Verify deletion
const deleted = await adapter.getComponent(
testEntityId,
"relationship",
testWorldId,
sourceEntityId
);
expect(deleted).toBeNull();
});
});
describe("Room Operations", () => {
it("should handle room operations with world relationships", async () => {
const roomId = uuidv4() as UUID;
const room: Room = {
id: roomId,
agentId: testAgentId,
source: "test",
type: "DM" as ChannelType,
name: "DM Room",
metadata: {
participants: ["user1", "user2"],
},
};
// Create room
await adapter.createRooms([room]);
// Get rooms by IDs
const retrieved = await adapter.getRoomsByIds([roomId]);
expect(retrieved).toHaveLength(1);
expect(retrieved).not.toBeNull();
const retrieved0 = retrieved[0];
const retrieved0Metadata = retrieved0?.metadata;
expect(retrieved0Metadata?.participants).toHaveLength(2);
// Create participant
const userId = uuidv4() as UUID;
await adapter.addParticipantsRoom([userId], roomId);
// Get rooms for participant
const participantRooms = await adapter.getRoomsForParticipant(userId);
expect(participantRooms).toBeDefined();
// Delete room
await adapter.deleteRoom(roomId);
// Verify deletion
const deleted = await adapter.getRoomsByIds([roomId]);
expect(deleted).toHaveLength(0);
});
});
describe("Search Operations", () => {
it("should handle searchMemoriesByEmbedding", async () => {
const embedding = new Float32Array(384).fill(0.5);
// Create memory with embedding
const memoryId = uuidv4() as UUID;
await adapter.createMemory(
{
id: memoryId,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: { text: "Memory with embedding" } as Content,
embedding: Array.from(embedding),
createdAt: Date.now(),
metadata: { type: "custom" },
},
"memories"
);
// Search by embedding
const results = await adapter.searchMemoriesByEmbedding(Array.from(embedding), {
tableName: "memories",
count: 5,
match_threshold: 0.5,
});
expect(results.length).toBeGreaterThan(0);
expect(results[0].id).toBe(memoryId);
});
it("should handle getCachedEmbeddings", async () => {
const content = "Test content for embedding";
const embedding = new Float32Array(384).fill(0.7);
// Store embedding in cache - log requires specific format
await adapter.log({
body: {
content: content,
embedding: Array.from(embedding),
},
entityId: testEntityId,
roomId: testRoomId,
type: "custom",
});
// Retrieve cached embedding
const cached = await adapter.getCachedEmbeddings({
query_table_name: "logs",
query_threshold: 5,
query_input: content,
query_field_name: "body",
query_field_sub_name: "content",
query_match_count: 10,
});
// May return empty if cache implementation doesn't match expected behavior
expect(cached).toBeDefined();
});
});
describe("Edge Cases", () => {
it("should handle operations with missing optional fields", async () => {
// Entity without metadata
const entityId = uuidv4() as UUID;
await adapter.createEntities([
{
id: entityId,
agentId: testAgentId,
names: ["No Metadata Entity"],
metadata: {},
},
]);
const entities = await adapter.getEntitiesByIds([entityId]);
expect(entities).not.toBeNull();
expect(entities[0]?.metadata).toEqual({});
// Memory with required entityId
const memoryId = uuidv4() as UUID;
await adapter.createMemory(
{
id: memoryId,
agentId: testAgentId,
entityId: testEntityId, // Required field
roomId: testRoomId,
content: { text: "Memory with entity" } as Content,
createdAt: Date.now(),
},
"memories"
);
const memories = await adapter.getMemories({
agentId: testAgentId,
roomId: testRoomId,
tableName: "memories",
});
const memoryWithEntity = memories.find((m) => m.id === memoryId);
expect(memoryWithEntity).toBeDefined();
expect(memoryWithEntity?.entityId).toBe(testEntityId);
});
it("should handle batch operations correctly", async () => {
const batchSize = 5;
const entities: Entity[] = [];
const rooms: Room[] = [];
// Create batch of entities
for (let i = 0; i < batchSize; i++) {
entities.push({
id: uuidv4() as UUID,
agentId: testAgentId,
names: [`Batch Entity ${i}`],
metadata: { batchIndex: i },
});
}
const entityResult = await adapter.createEntities(entities);
expectCreatedEntityIds(entityResult, entities);
// Create batch of rooms
for (let i = 0; i < batchSize; i++) {
rooms.push({
id: uuidv4() as UUID,
agentId: testAgentId,
source: "batch",
type: "GROUP" as ChannelType,
name: `Batch Room ${i}`,
});
}
await adapter.createRooms(rooms);
// Verify all were created
const entityIds = entities.map((e) => e.id!);
const retrievedEntities = await adapter.getEntitiesByIds(entityIds);
expect(retrievedEntities).toHaveLength(batchSize);
const roomIds = rooms.map((r) => r.id);
const retrievedRooms = await adapter.getRoomsByIds(roomIds);
expect(retrievedRooms).toHaveLength(batchSize);
});
});
});
@@ -0,0 +1,76 @@
/**
* Verifies BaseDrizzleAdapter's cache get/set/delete behavior (set, overwrite,
* delete, and miss-on-unknown-key) against a real isolated adapter instance.
*/
import type { AgentRuntime, UUID } from "@elizaos/core";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { cacheTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Cache Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let _runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let _testAgentId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("cache-tests");
adapter = setup.adapter;
_runtime = setup.runtime;
cleanup = setup.cleanup;
_testAgentId = setup.testAgentId;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Cache Tests", () => {
beforeEach(async () => {
// Clean up cache table before each test
await (adapter.getDatabase() as DrizzleDatabase).delete(cacheTable);
});
it("should set and get a simple string value", async () => {
const key = "simple_key";
const value = "hello world";
await adapter.setCache(key, value);
const retrievedValue = await adapter.getCache(key);
expect(retrievedValue).toBe(value);
});
it("should set and get a complex object value", async () => {
const key = "complex_key";
const value = { a: 1, b: { c: "nested" }, d: [1, 2, 3] };
await adapter.setCache(key, value);
const retrievedValue = await adapter.getCache(key);
expect(retrievedValue).toEqual(value);
});
it("should update an existing cache value", async () => {
const key = "update_key";
await adapter.setCache(key, "initial_value");
await adapter.setCache(key, "updated_value");
const retrievedValue = await adapter.getCache(key);
expect(retrievedValue).toBe("updated_value");
});
it("should delete a cache value", async () => {
const key = "delete_key";
await adapter.setCache(key, "some value");
await adapter.deleteCache(key);
const retrievedValue = await adapter.getCache(key);
expect(retrievedValue).toBeUndefined();
});
it("should return undefined for a non-existent key", async () => {
const retrievedValue = await adapter.getCache("non_existent_key");
expect(retrievedValue).toBeUndefined();
});
});
});
@@ -0,0 +1,151 @@
/**
* Verifies that deleting an agent cascades through world, room, entity,
* memory, task, and cache rows created for that agent, and that deleting an
* agent with no related data (or a non-existent agent) is handled cleanly.
*/
import { ChannelType, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Cascade Delete Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeAll(async () => {
// Create a fresh isolated database for cascade delete testing
const setup = await createIsolatedTestDatabase("cascade-delete-tests");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
it("should cascade delete all related data when deleting an agent", async () => {
// Use the testAgentId that the adapter was initialized with
const agentId = testAgentId;
// The agent was already created by the test helper, so we just need to create related data
// Create a world
const worldId = uuidv4() as UUID;
await adapter.createWorld({
id: worldId,
name: "Test World",
agentId: agentId,
messageServerId: uuidv4() as UUID,
});
// Create rooms
const roomId = uuidv4() as UUID;
await adapter.createRooms([
{
id: roomId,
name: "Test Room",
agentId: agentId,
serverId: uuidv4() as UUID,
worldId: worldId,
channelId: uuidv4() as UUID,
type: ChannelType.GROUP,
source: "test",
},
]);
// Create entities
const entityId = uuidv4() as UUID;
await adapter.createEntities([
{
id: entityId,
agentId: agentId,
names: ["Test Entity"],
metadata: { type: "custom" },
},
]);
// Create memory with embedding
const memoryId = await adapter.createMemory(
{
id: uuidv4() as UUID,
agentId: agentId,
entityId: entityId,
roomId: roomId,
content: { text: "Test memory" },
createdAt: Date.now(),
embedding: new Array(384).fill(0.1), // Test embedding
},
"test_memories"
);
// Create task
const taskId = await adapter.createTask({
id: uuidv4() as UUID,
name: "Test Task",
description: "A test task",
roomId: roomId,
worldId: worldId,
tags: ["test"],
metadata: { priority: "high" },
});
// Create cache entry
await adapter.setCache("test_cache_key", { value: "cached data" });
// Verify all data was created
expect(await adapter.getAgent(agentId)).not.toBeNull();
expect(await adapter.getWorld(worldId)).not.toBeNull();
expect((await adapter.getRoomsByIds([roomId]))?.length).toBe(1);
expect((await adapter.getEntitiesByIds([entityId]))?.length).toBe(1);
expect(await adapter.getMemoryById(memoryId)).not.toBeNull();
expect(await adapter.getTask(taskId)).not.toBeNull();
expect(await adapter.getCache("test_cache_key")).toBeDefined();
// Now delete the agent - this should cascade delete everything
const deleteResult = await adapter.deleteAgent(agentId);
expect(deleteResult).toBe(true);
// Verify the agent is deleted
expect(await adapter.getAgent(agentId)).toBeNull();
// Verify all related data is deleted via cascade
expect(await adapter.getWorld(worldId)).toBeNull();
expect(await adapter.getRoomsByIds([roomId])).toEqual([]);
expect(await adapter.getEntitiesByIds([entityId])).toEqual([]);
expect(await adapter.getMemoryById(memoryId)).toBeNull();
expect(await adapter.getTask(taskId)).toBeNull();
expect(await adapter.getCache("test_cache_key")).toBeUndefined();
});
it("should handle deletion of agent with no related data", async () => {
// Create a separate test instance for this test
const setup = await createIsolatedTestDatabase("cascade-delete-simple-agent");
const simpleAdapter = setup.adapter;
const simpleAgentId = setup.testAgentId;
try {
// The agent was already created by the test helper
// Just delete it without creating any related data
const result = await simpleAdapter.deleteAgent(simpleAgentId);
expect(result).toBe(true);
// Verify deletion
expect(await simpleAdapter.getAgent(simpleAgentId)).toBeNull();
} finally {
await setup.cleanup();
}
});
it("should return false when deleting non-existent agent", async () => {
const nonExistentId = uuidv4() as UUID;
const result = await adapter.deleteAgent(nonExistentId);
expect(result).toBe(false);
});
});
@@ -0,0 +1,184 @@
/**
* Verifies component CRUD against a real isolated adapter: create/read,
* update, JSON-patch operations (set/increment/push/remove on nested paths),
* and delete, scoped to a seeded entity/room/world.
*/
import {
type AgentRuntime,
ChannelType,
type Component,
type Entity,
type Room,
stringToUuid,
type UUID,
type World,
} from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Component Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let _runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testEntityId: UUID;
let testRoomId: UUID;
let testWorldId: UUID;
let testSourceEntityId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("component-tests");
adapter = setup.adapter;
_runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
// Generate random UUIDs for test data
testWorldId = uuidv4() as UUID;
testRoomId = uuidv4() as UUID;
testEntityId = uuidv4() as UUID;
testSourceEntityId = uuidv4() as UUID;
await adapter.createWorld({
id: testWorldId,
agentId: testAgentId,
name: "Test World",
serverId: "test-server",
} as World);
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
worldId: testWorldId,
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
{
id: testSourceEntityId,
agentId: testAgentId,
names: ["Source Entity"],
} as Entity,
]);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Component Tests", () => {
it("should create and retrieve a basic component", async () => {
const component: Component = {
id: stringToUuid("a0000000-0000-0000-0000-000000000001"),
entityId: testEntityId,
agentId: testAgentId,
roomId: testRoomId,
type: "test_component",
data: { value: "test" },
worldId: testWorldId,
sourceEntityId: testSourceEntityId,
createdAt: Date.now(),
};
await adapter.createComponent(component);
const retrieved = await adapter.getComponent(testEntityId, "test_component");
expect(retrieved).toBeDefined();
expect(retrieved?.id).toBe(component.id);
expect(retrieved?.data).toEqual({ value: "test" });
});
it("should update an existing component", async () => {
const originalComponent: Component = {
id: stringToUuid("a0000000-0000-0000-0000-000000000002"),
entityId: testEntityId,
agentId: testAgentId,
roomId: testRoomId,
type: "updatable_component",
data: { value: "original" },
worldId: testWorldId,
sourceEntityId: testSourceEntityId,
createdAt: Date.now(),
};
await adapter.createComponent(originalComponent);
const updatedComponent: Component = {
...originalComponent,
data: { value: "updated" },
};
await adapter.updateComponent(updatedComponent);
const retrieved = await adapter.getComponent(testEntityId, "updatable_component");
expect(retrieved).toBeDefined();
expect(retrieved?.data).toEqual({ value: "updated" });
});
it("should patch component data fields", async () => {
const component: Component = {
id: stringToUuid("a0000000-0000-0000-0000-0000000000a4"),
entityId: testEntityId,
agentId: testAgentId,
roomId: testRoomId,
type: "patchable_component",
data: {
wallet: { balance: 10 },
tags: ["initial"],
obsolete: true,
},
worldId: testWorldId,
sourceEntityId: testSourceEntityId,
createdAt: Date.now(),
};
await adapter.createComponent(component);
await adapter.patchComponents([
{
componentId: component.id,
ops: [
{ op: "set", path: "wallet.currency", value: "USD" },
{ op: "increment", path: "wallet.balance", value: 5 },
{ op: "push", path: "tags", value: "patched" },
{ op: "remove", path: "obsolete" },
],
},
]);
const retrieved = await adapter.getComponent(testEntityId, "patchable_component");
expect(retrieved?.data).toEqual({
wallet: { balance: 15, currency: "USD" },
tags: ["initial", "patched"],
});
});
it("should delete a component", async () => {
const component: Component = {
id: stringToUuid("a0000000-0000-0000-0000-000000000003"),
entityId: testEntityId,
agentId: testAgentId,
roomId: testRoomId,
type: "deletable_component",
data: { value: "original" },
worldId: testWorldId,
sourceEntityId: testSourceEntityId,
createdAt: Date.now(),
};
await adapter.createComponent(component);
let retrieved = await adapter.getComponent(testEntityId, "deletable_component");
expect(retrieved).toBeDefined();
await adapter.deleteComponent(component.id);
retrieved = await adapter.getComponent(testEntityId, "deletable_component");
expect(retrieved).toBeNull();
});
});
});
@@ -0,0 +1,188 @@
/**
* Verifies connector-account storage end to end against a real isolated
* database: plugin-sql migrations create the connector/OAuth tables
* idempotently, account upsert stores only credential refs (never plaintext
* secrets), audit metadata is redacted before insert, and OAuth flow state is
* single-use and expiry-aware.
*/
import type { UUID } from "@elizaos/core";
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../migration-service";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import * as schema from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Connector account storage", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let db: DrizzleDatabase;
beforeEach(async () => {
const setup = await createIsolatedTestDatabase("connector-account-storage");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
db = adapter.getDatabase() as DrizzleDatabase;
});
afterEach(async () => {
await cleanup?.();
});
it("migrates connector account tables idempotently", async () => {
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
await migrationService.runAllPluginMigrations();
const tables = await db.execute(sql`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN (
'connector_accounts',
'connector_account_credentials',
'connector_account_audit_events',
'oauth_flows',
'life_connector_grants'
)
ORDER BY tablename
`);
const tableNames = tables.rows.map((row) => String(row.tablename));
expect(tableNames).toContain("connector_accounts");
expect(tableNames).toContain("connector_account_credentials");
expect(tableNames).toContain("connector_account_audit_events");
expect(tableNames).toContain("oauth_flows");
expect(tableNames).not.toContain("life_connector_grants");
});
it("upserts account metadata and stores only credential refs", async () => {
const account = await adapter.upsertConnectorAccount({
provider: "google",
accountKey: "google-user-1",
externalId: "google-sub-1",
displayName: "Example User",
email: "user@example.com",
role: "OWNER",
purpose: ["messaging"],
accessGate: "open",
scopes: ["email", "calendar.readonly"],
capabilities: ["calendar"],
metadata: { source: "oauth" },
});
const updated = await adapter.upsertConnectorAccount({
provider: "google",
accountKey: "google-user-1",
displayName: "Updated User",
scopes: ["email"],
});
expect(updated.id).toBe(account.id);
expect(updated.displayName).toBe("Updated User");
expect(updated.role).toBe("OWNER");
expect(updated.purpose).toEqual(["messaging"]);
const listed = await adapter.listConnectorAccounts({ provider: "google" });
expect(listed).toHaveLength(1);
const credential = await adapter.setConnectorAccountCredentialRef({
accountId: account.id,
credentialType: "oauth.refresh_token",
vaultRef: `connector.${testAgentId}.google.${account.id}.refresh`,
metadata: { rotatedBy: "test" },
});
expect(credential.vaultRef).toContain(".refresh");
const retrievedCredential = await adapter.getConnectorAccountCredentialRef({
accountId: account.id,
credentialType: "oauth.refresh_token",
});
expect(retrievedCredential?.vaultRef).toBe(credential.vaultRef);
const columns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'connector_account_credentials'
ORDER BY column_name
`);
const columnNames = columns.rows.map((row) => String(row.column_name));
expect(columnNames).toContain("vault_ref");
expect(columnNames).not.toContain("plaintext");
expect(columnNames).not.toContain("ciphertext");
});
it("redacts audit metadata before insert", async () => {
const account = await adapter.upsertConnectorAccount({
provider: "slack",
accountKey: "team:T123:user:U123",
displayName: "Slack User",
});
const audit = await adapter.appendConnectorAccountAuditEvent({
accountId: account.id,
actorId: "owner:test",
action: "credential.set",
metadata: {
accessToken: "xoxb-secret",
nested: {
refresh_token: "refresh-secret",
safe: "visible",
},
attempts: 1,
},
});
expect(audit.metadata.accessToken).toBe("[REDACTED]");
expect((audit.metadata.nested as Record<string, unknown>).refresh_token).toBe("[REDACTED]");
expect((audit.metadata.nested as Record<string, unknown>).safe).toBe("visible");
expect(audit.metadata.attempts).toBe(1);
});
it("consumes OAuth flow state once and ignores expired state", async () => {
const state = "opaque-oauth-state";
const flow = await adapter.createOAuthFlowState({
state,
provider: "github",
ttlMs: 60_000,
codeVerifierRef: `connector.${testAgentId}.github.flow.pkce`,
scopes: ["repo"],
});
expect(flow.stateHash).not.toBe(state);
expect(flow.stateHash).toHaveLength(64);
expect(flow.consumedAt).toBeNull();
const firstConsume = await adapter.consumeOAuthFlowState({
state,
provider: "github",
consumedBy: "oauth-callback",
});
expect(firstConsume?.consumedBy).toBe("oauth-callback");
const secondConsume = await adapter.consumeOAuthFlowState({
state,
provider: "github",
consumedBy: "oauth-callback",
});
expect(secondConsume).toBeNull();
await adapter.createOAuthFlowState({
state: "expired-state",
provider: "github",
expiresAt: Date.now() - 1_000,
});
const expired = await adapter.consumeOAuthFlowState({
state: "expired-state",
provider: "github",
});
expect(expired).toBeNull();
});
});
@@ -0,0 +1,404 @@
/**
* Integration tests for connector-account persistence (accounts, credential
* refs, owner bindings, audit events, OAuth flow state) against a real
* isolated PGlite/Postgres adapter via `BaseDrizzleAdapter` delegation — no
* mocked adapter.
*/
import type { UUID } from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { authIdentityTable, authOwnerBindingTable } from "../../schema/index";
import type { DrizzleDatabase } from "../../types";
import { mockCharacter } from "../schema-data";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("ConnectorAccountStore (via BaseDrizzleAdapter delegation)", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeEach(async () => {
const setup = await createIsolatedTestDatabase("connector-account-store");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
});
afterEach(async () => {
await cleanup?.();
});
it("upserts a new account, looks it up by composite key, and re-upserts with conflict update", async () => {
const initial = await adapter.upsertConnectorAccount({
provider: "github",
accountKey: "user-1",
displayName: "Alice",
ownerBindingId: "binding-owner-1",
ownerIdentityId: "identity-owner-1",
scopes: ["repo"],
capabilities: ["pull-requests"],
role: "OWNER",
});
expect(initial.id).toBeDefined();
expect(initial.agentId).toBe(testAgentId);
const fetched = await adapter.getConnectorAccount({
provider: "github",
accountKey: "user-1",
});
expect(fetched?.id).toBe(initial.id);
expect(fetched?.scopes).toEqual(["repo"]);
expect(fetched?.ownerBindingId).toBe("binding-owner-1");
expect(fetched?.ownerIdentityId).toBe("identity-owner-1");
const updated = await adapter.upsertConnectorAccount({
id: initial.id,
provider: "github",
accountKey: "user-renamed",
displayName: "Alice Renamed",
ownerBindingId: "binding-owner-2",
scopes: ["repo", "workflow"],
});
expect(updated.id).toBe(initial.id);
expect(updated.accountKey).toBe("user-renamed");
expect(updated.displayName).toBe("Alice Renamed");
expect(updated.ownerBindingId).toBe("binding-owner-2");
expect(updated.ownerIdentityId).toBe("identity-owner-1");
expect(updated.scopes).toEqual(["repo", "workflow"]);
expect(updated.role).toBe("OWNER");
await expect(
adapter.getConnectorAccount({
provider: "github",
accountKey: "user-1",
})
).resolves.toBeNull();
await expect(
adapter.getConnectorAccount({
provider: "github",
accountKey: "user-renamed",
})
).resolves.toMatchObject({ id: initial.id });
});
it("soft-deletes by composite key and hides the deleted account from normal reads", async () => {
const account = await adapter.upsertConnectorAccount({
provider: "discord",
accountKey: "user:guild-1",
displayName: "Bot",
});
const removed = await adapter.deleteConnectorAccount({
provider: "discord",
accountKey: "user:guild-1",
});
expect(removed).toBe(true);
const after = await adapter.getConnectorAccount({ id: account.id });
expect(after).toBeNull();
const reconnected = await adapter.upsertConnectorAccount({
provider: "discord",
accountKey: "user:guild-1",
displayName: "Bot Reconnected",
});
expect(reconnected.displayName).toBe("Bot Reconnected");
expect(reconnected.deletedAt).toBeFalsy();
});
it("manages credential refs (set, get, list) keyed by (accountId, credentialType)", async () => {
const account = await adapter.upsertConnectorAccount({
provider: "stripe",
accountKey: "acct_123",
});
const set1 = await adapter.setConnectorAccountCredentialRef({
accountId: account.id,
credentialType: "api_key",
vaultRef: `connector.${testAgentId}.stripe.${account.id}.api_key`,
metadata: { kind: "secret" },
});
expect(set1.id).toBeDefined();
expect(set1.agentId).toBe(testAgentId);
expect(set1.provider).toBe("stripe");
const set2 = await adapter.setConnectorAccountCredentialRef({
accountId: account.id,
credentialType: "webhook_secret",
vaultRef: `connector.${testAgentId}.stripe.${account.id}.webhook`,
});
const update = await adapter.setConnectorAccountCredentialRef({
accountId: account.id,
credentialType: "api_key",
vaultRef: `connector.${testAgentId}.stripe.${account.id}.api_key.v2`,
});
expect(update.id).toBe(set1.id);
expect(update.vaultRef).toContain(".v2");
const got = await adapter.getConnectorAccountCredentialRef({
accountId: account.id,
credentialType: "api_key",
});
expect(got?.vaultRef).toContain(".v2");
const all = await adapter.listConnectorAccountCredentialRefs({
accountId: account.id,
});
expect(all).toHaveLength(2);
expect(new Set(all.map((c) => c.credentialType))).toEqual(
new Set(["api_key", "webhook_secret"])
);
expect(all.every((c) => c.id === set1.id || c.id === set2.id)).toBe(true);
await adapter.deleteConnectorAccount({
provider: "stripe",
accountKey: "acct_123",
});
await expect(
adapter.getConnectorAccountCredentialRef({
accountId: account.id,
credentialType: "api_key",
})
).resolves.toBeNull();
await expect(
adapter.listConnectorAccountCredentialRefs({
accountId: account.id,
})
).resolves.toEqual([]);
});
it("resolves verified owner bindings for connector account policy checks", async () => {
const identityId = "identity-connector-owner";
const now = Date.now();
const db = adapter.getDatabase() as DrizzleDatabase;
await db.insert(authIdentityTable).values({
id: identityId,
kind: "owner",
displayName: "Connector Owner",
createdAt: now,
});
await db.insert(authOwnerBindingTable).values({
id: "binding-connector-owner",
identityId,
connector: "discord",
externalId: "discord-user-1",
displayHandle: "owner#1234",
instanceId: "instance-1",
verifiedAt: now,
});
await expect(
adapter.findConnectorOwnerBinding?.({
connector: "discord",
externalId: "discord-user-1",
instanceId: "instance-1",
})
).resolves.toMatchObject({
id: "binding-connector-owner",
identityId,
connector: "discord",
externalId: "discord-user-1",
displayHandle: "owner#1234",
instanceId: "instance-1",
});
await expect(
adapter.findConnectorOwnerBinding?.({
connector: "discord",
externalId: "discord-user-1",
instanceId: "other-instance",
})
).resolves.toBeNull();
await expect(
adapter.findConnectorOwnerBinding?.({
connector: "discord",
externalId: "discord-user-1",
})
).resolves.toBeNull();
});
it("appends and lists audit events with redaction and filtering", async () => {
const account = await adapter.upsertConnectorAccount({
provider: "twitter",
accountKey: "user-twt",
});
await adapter.appendConnectorAccountAuditEvent({
accountId: account.id,
action: "credential.set",
outcome: "success",
actorId: "owner:t1",
metadata: { client_secret: "shh", note: "rotated" },
});
await adapter.appendConnectorAccountAuditEvent({
accountId: account.id,
action: "credential.set",
outcome: "failure",
metadata: { reason: "rejected" },
});
await adapter.appendConnectorAccountAuditEvent({
accountId: account.id,
action: "account.connected",
metadata: {},
});
const adapterWithAudit = adapter as typeof adapter & {
listConnectorAccountAuditEvents?: (p: {
agentId?: string;
provider?: string;
accountId?: string;
action?: string;
outcome?: string;
limit?: number;
}) => Promise<unknown[]>;
};
if (typeof adapterWithAudit.listConnectorAccountAuditEvents !== "function") {
throw new Error("listConnectorAccountAuditEvents not exposed by adapter");
}
const all = await adapterWithAudit.listConnectorAccountAuditEvents({
provider: "twitter",
});
expect(all).toHaveLength(3);
const onlyFailures = await adapterWithAudit.listConnectorAccountAuditEvents({
provider: "twitter",
outcome: "failure",
});
expect(onlyFailures).toHaveLength(1);
const onlyConnect = await adapterWithAudit.listConnectorAccountAuditEvents({
provider: "twitter",
action: "account.connected",
});
expect(onlyConnect).toHaveLength(1);
const redacted = (all as Array<{ metadata: Record<string, unknown> }>).find(
(entry) => "client_secret" in entry.metadata
);
expect(redacted?.metadata.client_secret).toBe("[REDACTED]");
});
it("hashes oauth state, treats consume as single-use, and ignores expired flows", async () => {
const flow = await adapter.createOAuthFlowState({
provider: "spotify",
state: "raw-state-1",
ttlMs: 60_000,
scopes: ["user-read"],
metadata: { flowId: "oauth_spotify_1" },
});
expect(flow.stateHash).toHaveLength(64);
expect(flow.stateHash).not.toBe("raw-state-1");
await expect(
adapter.getOAuthFlowState({
provider: "spotify",
flowId: "oauth_spotify_1",
includeExpired: true,
})
).resolves.toMatchObject({ stateHash: flow.stateHash });
await expect(
adapter.updateOAuthFlowState({
provider: "spotify",
flowId: "oauth_spotify_1",
metadata: { status: "started" },
})
).resolves.toMatchObject({
metadata: expect.objectContaining({
flowId: "oauth_spotify_1",
status: "started",
}),
});
await expect(
adapter.getOAuthFlowState({
agentId: "00000000-0000-0000-0000-000000000099" as UUID,
provider: "spotify",
flowId: "oauth_spotify_1",
includeExpired: true,
})
).resolves.toBeNull();
const consumed = await adapter.consumeOAuthFlowState({
provider: "spotify",
state: "raw-state-1",
consumedBy: "callback",
});
expect(consumed?.consumedBy).toBe("callback");
expect(consumed?.stateHash).toBe(flow.stateHash);
const consumedAgain = await adapter.consumeOAuthFlowState({
provider: "spotify",
state: "raw-state-1",
});
expect(consumedAgain).toBeNull();
await adapter.createOAuthFlowState({
provider: "spotify",
state: "raw-state-2",
expiresAt: Date.now() - 10_000,
});
const stale = await adapter.consumeOAuthFlowState({
provider: "spotify",
state: "raw-state-2",
});
expect(stale).toBeNull();
await expect(
adapter.deleteOAuthFlowState({
provider: "spotify",
flowId: "oauth_spotify_1",
})
).resolves.toBe(true);
});
it("keeps identical OAuth states isolated by agent and provider", async () => {
const sameState = "shared-raw-state";
const otherAgentId = "00000000-0000-4000-8000-000000000123" as UUID;
const createdOtherAgent = await adapter.createAgent({
...mockCharacter,
id: otherAgentId,
name: "Other OAuth Test Agent",
username: "other_oauth_test_agent",
createdAt: Date.now(),
updatedAt: Date.now(),
});
expect(createdOtherAgent).toBe(true);
const spotify = await adapter.createOAuthFlowState({
provider: "spotify",
state: sameState,
metadata: { flowId: "oauth_spotify_shared" },
});
const github = await adapter.createOAuthFlowState({
provider: "github",
state: sameState,
metadata: { flowId: "oauth_github_shared" },
});
const otherAgent = await adapter.createOAuthFlowState({
agentId: otherAgentId,
provider: "spotify",
state: sameState,
metadata: { flowId: "oauth_other_agent_shared" },
});
expect(spotify.stateHash).toBe(github.stateHash);
expect(otherAgent.stateHash).toBe(spotify.stateHash);
await expect(
adapter.consumeOAuthFlowState({
provider: "spotify",
state: sameState,
})
).resolves.toMatchObject({ provider: "spotify", agentId: testAgentId });
await expect(
adapter.getOAuthFlowState({
provider: "github",
state: sameState,
})
).resolves.toMatchObject({ provider: "github", agentId: testAgentId });
await expect(
adapter.getOAuthFlowState({
agentId: otherAgentId,
provider: "spotify",
state: sameState,
})
).resolves.toMatchObject({ provider: "spotify", agentId: otherAgentId });
});
});
@@ -0,0 +1,150 @@
/**
* Real error-path coverage for the fail-fast DB adapter sweep (#12269 / #12182):
* drives a genuinely broken PGlite (its underlying connection is closed out from
* under the adapter while the manager still reports "not shutting down", so the
* query itself faults) and asserts that read/count/write methods throw a typed
* `ElizaError` with the right `code` instead of fabricating a healthy-looking
* default (`0` / `[]` / `false` / `undefined`). A companion case confirms an API
* route mounted over the adapter surfaces a structured 5xx, not a fabricated 200.
*
* No mocks: the failure is a real closed PGlite client. `VITEST_EXCLUDE_REAL=1`
* (the PR lane) skips this; the post-merge lane runs it against real PGlite.
*/
import type { PGlite } from "@electric-sql/pglite";
import { type ElizaError, isElizaError, type UUID } from "@elizaos/core";
import { v4 } from "uuid";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
type Adapter = PgliteDatabaseAdapter | PgDatabaseAdapter;
// Close the live PGlite client without going through adapter.close() (which
// flips the manager's shuttingDown flag and short-circuits withDatabase with a
// plain "shutting down" error). Closing only the raw connection leaves the
// shutdown gate open, so the next real query faults and exercises the adapter's
// own catch → ElizaError path — the actual behavior under a broken DB.
async function breakConnection(adapter: Adapter): Promise<void> {
const raw = (adapter as PgliteDatabaseAdapter).getRawConnection?.() as PGlite | undefined;
if (!raw) throw new Error("test requires the PGlite adapter (getRawConnection)");
await raw.close();
}
async function expectElizaError(fn: () => Promise<unknown>, code: string): Promise<ElizaError> {
let thrown: unknown;
try {
await fn();
} catch (e) {
thrown = e;
}
expect(thrown, `expected a throw with code ${code}, got a normal return`).toBeDefined();
expect(isElizaError(thrown), `expected ElizaError, got ${String(thrown)}`).toBe(true);
expect((thrown as ElizaError).code).toBe(code);
return thrown as ElizaError;
}
describe("DB adapter fail-fast error paths (real broken PGlite)", () => {
let adapter: Adapter;
let cleanup: () => Promise<void>;
beforeEach(async () => {
const setup = await createIsolatedTestDatabase("db-failure-error-path");
adapter = setup.adapter;
cleanup = setup.cleanup;
}, 60_000);
afterEach(async () => {
// cleanup() calls adapter.close(); the raw connection may already be closed,
// which is tolerated by the J6 teardown handlers in the manager.
if (cleanup) await cleanup();
});
it("countAgents throws DB_COUNT_FAILED instead of returning 0", async () => {
await breakConnection(adapter);
await expectElizaError(() => adapter.countAgents(), "DB_COUNT_FAILED");
});
it("getCache throws DB_QUERY_FAILED instead of returning undefined (miss)", async () => {
await breakConnection(adapter);
const err = await expectElizaError(() => adapter.getCache("any-key"), "DB_QUERY_FAILED");
// The typed error carries structured context, not a swallowed message.
expect(err.context).toMatchObject({ table: "cache" });
expect(err.cause).toBeDefined();
});
it("setCache throws DB_UPSERT_FAILED instead of returning false", async () => {
await breakConnection(adapter);
await expectElizaError(() => adapter.setCache("k", "v"), "DB_UPSERT_FAILED");
});
it("deleteCache throws DB_DELETE_FAILED instead of returning false", async () => {
await breakConnection(adapter);
await expectElizaError(() => adapter.deleteCache("k"), "DB_DELETE_FAILED");
});
it("deleteAgents throws DB_DELETE_FAILED instead of returning false", async () => {
await breakConnection(adapter);
await expectElizaError(() => adapter.deleteAgents([v4() as UUID]), "DB_DELETE_FAILED");
});
it("createEntities throws DB_INSERT_FAILED instead of returning []", async () => {
const entity = { id: v4() as UUID, agentId: v4() as UUID, names: ["x"], metadata: {} };
await breakConnection(adapter);
await expectElizaError(() => adapter.createEntities([entity]), "DB_INSERT_FAILED");
});
it("updateMemory throws DB_UPDATE_FAILED instead of returning false", async () => {
await breakConnection(adapter);
await expectElizaError(
() => adapter.updateMemory({ id: v4() as UUID, content: { text: "x" } }),
"DB_UPDATE_FAILED"
);
});
it("addParticipant throws DB_INSERT_FAILED instead of returning false", async () => {
await breakConnection(adapter);
await expectElizaError(
() => adapter.addParticipant(v4() as UUID, v4() as UUID),
"DB_INSERT_FAILED"
);
});
it("createRelationship throws DB_INSERT_FAILED instead of returning false", async () => {
await breakConnection(adapter);
await expectElizaError(
() =>
adapter.createRelationship({
sourceEntityId: v4() as UUID,
targetEntityId: v4() as UUID,
}),
"DB_INSERT_FAILED"
);
});
it("an API-style handler over the adapter surfaces a structured 5xx, not a fabricated 200/0", async () => {
await breakConnection(adapter);
// Minimal J1 boundary: the shape a route handler uses — call the adapter,
// and on a thrown ElizaError produce a structured 500 body. A fabricated
// default (0) would instead sail through as a 200 and hide the outage.
const handler = async (): Promise<{ status: number; body: unknown }> => {
try {
const count = await adapter.countAgents();
return { status: 200, body: { count } };
} catch (error) {
if (isElizaError(error)) {
return {
status: 500,
body: { error: error.code, message: error.message },
};
}
return { status: 500, body: { error: "UNCLASSIFIED" } };
}
};
const res = await handler();
expect(res.status).toBe(500);
expect(res.body).toMatchObject({ error: "DB_COUNT_FAILED" });
});
});
@@ -0,0 +1,599 @@
/**
* Integration tests for the deferred Electric Sync startup path against a
* real `PGliteClientManager` + `PgliteDatabaseAdapter` (temp-dir PGlite, no
* mocks) and a bogus sync URL — verifies phase ordering, `ensureSync()`
* idempotency, and `forceResync()` behavior. See the phase-order note below.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { UUID } from "@elizaos/core";
import { sql } from "drizzle-orm";
import { v4 } from "uuid";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../migration-service";
import { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { PGliteClientManager } from "../../pglite/manager";
import * as schema from "../../schema";
import type { DrizzleDatabase } from "../../types";
const BOGUS_SYNC_URL = "https://example.invalid/electric";
/**
* Integration tests for the deferred Electric Sync startup architecture.
*
* Core invariant: syncShapesToTables must NOT run before the target tables
* exist. Tables are created by Drizzle migrations, which run AFTER
* `PGliteClientManager.initialize()`. So sync is deferred to
* `ensureSync()`, called lazily from the first `withDatabase()` operation.
*
* Phase order:
* 1. PGliteClientManager.initialize() → PGlite starts, migrations schema
* 2. Drizzle migrations → tables created (agents, memories, …)
* 3. First withDatabase() → ensureSync() → startSync()
*/
const CORE_TABLES = [
"agents",
"entities",
"worlds",
"rooms",
"participants",
"memories",
"relationships",
"tasks",
] as const;
function createTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
describe("Electric Sync deferred startup", () => {
const cleanups: Array<{ dir: string; manager?: PGliteClientManager }> = [];
// ── pglite-sync internal unhandled rejection filter ───────────────────
//
// The @electric-sql/pglite-sync extension (v0.6.1) launches an async
// subscription loop via MultiShapeStream. When the sync URL is unreachable
// (BOGUS_SYNC_URL), two things happen:
// 1. onError fires (we catch this in startSyncInternal).
// 2. Internal retry logic (exponential backoff) kicks in. When those
// retries fail during close() teardown, the rejection from the
// extension's internal promise chain bypasses onError and becomes
// an unhandled rejection.
//
// Known patterns from pglite-sync v0.6.1 (upstream bug — the async
// subscribe handler lacks a top-level try/catch):
// • "PGlite failed to initialize properly" — internal setup (subscriptions_metadata)
// • "Already syncing shape for table …" — concurrent cleanup/retry
//
// We filter these specifically so they don't fail CI, but log at debug
// level for traceability. Any OTHER unhandled rejection (from our code)
// still surfaces as an error, keeping the safety net intact.
//
// Upstream: https://github.com/electric-sql/pglite/issues/962
// https://github.com/electric-sql/pglite/issues/641
const KNOWN_PGLITE_SYNC_REJECTIONS = [
/PGlite failed to initialize/,
/Already syncing shape for table/,
/electric\.subscriptions_metadata does not exist/,
];
let unhandledSuppressor: ((reason: unknown, promise: Promise<unknown>) => void) | null = null;
beforeAll(() => {
unhandledSuppressor = (reason: unknown, _promise: Promise<unknown>) => {
const msg = reason instanceof Error ? reason.message : String(reason);
const isKnown = KNOWN_PGLITE_SYNC_REJECTIONS.some((pattern) => pattern.test(msg));
if (isKnown) {
console.debug("[test] Suppressed known pglite-sync rejection during teardown:", msg);
} else {
// Unknown rejection — log prominently so it's not silently swallowed.
console.error("[test] UNEXPECTED unhandled rejection (not from pglite-sync):", msg, reason);
}
};
process.on("unhandledRejection", unhandledSuppressor);
});
afterAll(() => {
if (unhandledSuppressor) {
process.off("unhandledRejection", unhandledSuppressor);
}
});
afterEach(async () => {
for (const c of cleanups.splice(0)) {
// Close the manager first so any sync subscriptions are torn down
// before we remove the data dir — prevents unhandled rejections
// from the Electric sync extension trying to read deleted files.
if (c.manager) {
try {
await c.manager.close();
} catch {}
}
// Yield the event loop so any final PGlite WASM cleanup callbacks
// (extension teardown, file handle release) complete before we
// remove the data directory. Without this delay, stray async
// operations may try to read files that no longer exist.
await new Promise((r) => setTimeout(r, 50));
try {
fs.rmSync(c.dir, { recursive: true, force: true });
} catch {}
}
});
// ------------------------------------------------------------------
// Helper: create manager + adapter + run migrations (no sync involved)
// ------------------------------------------------------------------
async function setupWithMigrations(opts?: { syncUrl?: string; agentId?: string }): Promise<{
manager: PGliteClientManager;
adapter: PgliteDatabaseAdapter;
agentId: string;
db: DrizzleDatabase;
}> {
const dir = createTempDir("eliza-sync-test-");
const agentId = opts?.agentId ?? v4();
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: opts?.syncUrl,
agentId,
});
await manager.initialize();
// Track for cleanup early — if migrations throw, we still need
// to close the manager and remove the temp dir.
cleanups.push({ dir, manager });
const adapter = new PgliteDatabaseAdapter(agentId as UUID, manager);
await adapter.init();
const db = adapter.getDatabase() as DrizzleDatabase;
// Run migrations so all target tables exist.
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
return { manager, adapter, agentId, db };
}
// ------------------------------------------------------------------
// 1. Sync is NOT started during initialize()
// ------------------------------------------------------------------
describe("phase ordering", () => {
it("does not start sync during PGliteClientManager.initialize()", async () => {
const dir = createTempDir("eliza-sync-noinit-");
const manager = new PGliteClientManager({ dataDir: dir });
cleanups.push({ dir, manager });
await manager.initialize();
// Before ensureSync() is called, status should be "disabled"
// (no syncUrl was configured).
const status = manager.getSyncStatus();
expect(status.status).toBe("disabled");
expect(status.error).toBeNull();
expect(status.synced).toEqual([]);
});
it("reports 'disabled' when no ELIZA_ELECTRIC_SYNC_URL is configured", async () => {
const { manager } = await setupWithMigrations();
// Trigger ensureSync via getSyncStatus-style check — with no sync URL
// configured, ensureSync is a no-op and status stays "disabled".
const status = manager.getSyncStatus();
expect(status.status).toBe("disabled");
expect(status.error).toBeNull();
});
it("reports 'error' when sync URL is set but agentId is unknown", async () => {
// Guard against CI environment leaking AGENT_ID into the test.
const savedAgentId = process.env.AGENT_ID;
delete process.env.AGENT_ID;
try {
const dir = createTempDir("eliza-sync-noagent-");
// Create manager directly (bypass setupWithMigrations) so we can
// pass agentId: undefined and have this.agentId stay null. The
// setupWithMigrations helper always generates a UUID when agentId
// is falsy, which would defeat this test's purpose.
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId: undefined,
});
cleanups.push({ dir, manager });
await manager.initialize();
// ensureSync() → startSync() checks:
// const agentId = this.agentId ?? process.env.AGENT_ID ?? null;
// Both are null/undefined → hits the !agentId error guard.
await manager.ensureSync();
const status = manager.getSyncStatus();
// With a sync URL but no agentId, startSync() sets status to "error".
expect(status.status).toBe("error");
expect(status.error).toContain("agentId");
} finally {
if (savedAgentId !== undefined) {
process.env.AGENT_ID = savedAgentId;
}
}
});
});
// ------------------------------------------------------------------
// 2. Tables exist before sync would start
// ------------------------------------------------------------------
describe("table readiness", () => {
it("all target tables exist after migrations (before sync)", async () => {
const { db } = await setupWithMigrations();
// Verify every table that sync would target exists and is queryable.
for (const table of CORE_TABLES) {
const result = await db.execute(sql.raw(`SELECT count(*) as cnt FROM ${table}`));
const rows = result.rows as Array<{ cnt: number }>;
expect(rows[0]?.cnt).toBe(0);
}
});
it("tables are queryable after migrations complete", async () => {
const { db, agentId } = await setupWithMigrations();
// Insert a row into the agents table to prove writes work
// and the schema matches expectations. Column names are
// snake_case as defined by the Drizzle schema.
// Use to_timestamp() since the column type is timestamp with time zone.
await db.execute(
sql.raw(
`INSERT INTO agents (id, name, created_at, updated_at) VALUES ('${agentId}', 'test', to_timestamp(${Date.now() / 1000.0}), to_timestamp(${Date.now() / 1000.0}))`
)
);
const result = await db.execute(
sql.raw(`SELECT id, name FROM agents WHERE id = '${agentId}'`)
);
const rows = result.rows as Array<{ id: string; name: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]?.name).toBe("test");
});
});
// ------------------------------------------------------------------
// 3. ensureSync() is idempotent
// ------------------------------------------------------------------
describe("ensureSync() idempotency", () => {
it("calling ensureSync() multiple times is safe (no double-start)", async () => {
const dir = createTempDir("eliza-sync-idem-");
cleanups.push({ dir });
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId: v4(),
});
cleanups.push({ dir, manager });
await manager.initialize();
// First call: should trigger startSync() (which will fail to connect
// to the bogus URL, setting status to "syncing" → "error").
await manager.ensureSync();
const afterFirst = manager.getSyncStatus();
// Second call: should be a no-op since sync was already started.
// The guard `if (this.syncUnsubscribe) return;` prevents re-entry.
await manager.ensureSync();
const afterSecond = manager.getSyncStatus();
// Status and error should be stable — no double-initialization
// skews the in-memory state.
expect(afterSecond.status).toBe(afterFirst.status);
expect(afterSecond.error).toBe(afterFirst.error);
});
it("ensureSync() before initialize() is a no-op", async () => {
const dir = createTempDir("eliza-sync-preinit-");
cleanups.push({ dir });
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId: v4(),
});
// Don't push manager to cleanups — it was never initialized, so
// calling close() would try to close a PGlite that hasn't been
// fully started, which can hang. Just clean up the temp dir.
// ensureSync() checks this.initialized → false, returns early.
await manager.ensureSync();
const status = manager.getSyncStatus();
expect(status.status).toBe("disabled");
});
});
// ------------------------------------------------------------------
// 4. withDatabase() triggers ensureSync() on first DB operation
// ------------------------------------------------------------------
describe("withDatabase() triggers ensureSync()", () => {
it("first adapter DB operation triggers sync after migrations", async () => {
const dir = createTempDir("eliza-sync-wdb-");
cleanups.push({ dir });
const agentId = v4();
// Create manager with a syncUrl so ensureSync() has something to
// attempt (it will fail to the bogus URL, but that proves the code
// path is exercised).
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
await manager.initialize();
// Before any DB operation, sync should be "disabled".
expect(manager.getSyncStatus().status).toBe("disabled");
const adapter = new PgliteDatabaseAdapter(agentId as UUID, manager);
await adapter.init();
const db = adapter.getDatabase() as DrizzleDatabase;
// Run migrations.
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
// The first withDatabase() call (via createAgent) triggers ensureSync().
// With a bogus sync URL, startSync() will encounter an error and set
// status to "error". The key assertion: status is NOT "disabled" — the
// sync code path was exercised.
await adapter.createAgent({
id: agentId as import("@elizaos/core").UUID,
name: "Test Agent",
createdAt: Date.now(),
updatedAt: Date.now(),
bio: ["Test bio"],
} as import("@elizaos/core").Agent);
const status = manager.getSyncStatus();
// The sync was attempted — status is no longer "disabled".
expect(status.status).not.toBe("disabled");
});
it("sync stays disabled when no syncUrl is configured (no-op path)", async () => {
const { adapter, agentId, manager } = await setupWithMigrations();
// First DB operation — ensureSync() is called but with no syncUrl,
// startSync() immediately returns with status "disabled".
await adapter.createAgent({
id: agentId as import("@elizaos/core").UUID,
name: "Test Agent",
createdAt: Date.now(),
updatedAt: Date.now(),
bio: ["Test bio"],
} as import("@elizaos/core").Agent);
const status = manager.getSyncStatus();
expect(status.status).toBe("disabled");
expect(status.error).toBeNull();
});
});
// ------------------------------------------------------------------
// 5. Force resync preserves local data, resets sync state
// ------------------------------------------------------------------
describe("forceResync()", () => {
it("returns null when no syncUrl is configured", async () => {
const { manager } = await setupWithMigrations();
const result = await manager.forceResync();
expect(result).toBeNull();
});
it("preserves local data after force resync with bogus URL", async () => {
const dir = createTempDir("eliza-sync-fr-");
cleanups.push({ dir });
const agentId = v4();
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
await manager.initialize();
const adapter = new PgliteDatabaseAdapter(agentId as UUID, manager);
await adapter.init();
const db = adapter.getDatabase() as DrizzleDatabase;
// Run migrations + create agent.
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
await adapter.createAgent({
id: agentId as import("@elizaos/core").UUID,
name: "Test Agent",
createdAt: Date.now(),
updatedAt: Date.now(),
bio: ["Test bio"],
} as import("@elizaos/core").Agent);
// Force resync to invalid URL — should attempt, DROP electric schema
// if it exists, reset state, and re-attempt sync.
const result = await manager.forceResync();
expect(result).not.toBeNull();
expect(result!.status).toMatch(/^(error|syncing|synced)$/);
// Verify local data survived.
const agent = await adapter.getAgent(agentId as import("@elizaos/core").UUID);
expect(agent).not.toBeNull();
expect(agent?.name).toBe("Test Agent");
});
it("DROP SCHEMA electric CASCADE executes without error and PGlite survives", async () => {
const dir = createTempDir("eliza-sync-drop-");
cleanups.push({ dir });
const agentId = v4();
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
await manager.initialize();
// Start sync so the electric schema may be created by the extension.
await manager.ensureSync();
// Trigger force resync — exercises DROP SCHEMA electric CASCADE,
// state reset, and re-attempted startSync.
const result = await manager.forceResync();
expect(result).not.toBeNull();
expect(result!.status).toMatch(/^(error|syncing|synced)$/);
// PGlite must still be functional after the DROP + re-sync.
const client = manager.getConnection();
const checkResult = await client.query("SELECT 1 AS alive");
expect((checkResult.rows as Array<{ alive: number }>)[0]?.alive).toBe(1);
});
it("re-sync restores per-table state from reset to populated", async () => {
const dir = createTempDir("eliza-sync-restore-");
cleanups.push({ dir });
const agentId = v4();
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
await manager.initialize();
// Start sync — populates per-table state (8+ table entries).
await manager.ensureSync();
const beforeStatus = manager.getSyncStatus();
const beforeTableKeys = Object.keys(beforeStatus.tables);
// startSync populates syncTableStates for all configured tables.
expect(beforeTableKeys.length).toBeGreaterThanOrEqual(8);
for (const key of beforeTableKeys) {
expect(beforeStatus.tables[key]?.state).toMatch(/^(pending|synced|error)$/);
}
// Force resync — clears tables and synced arrays, then calls
// startSync again which re-populates them.
const afterResync = await manager.forceResync();
expect(afterResync).not.toBeNull();
// After re-sync, tables should be re-populated (same or similar keys).
expect(Object.keys(afterResync!.tables).length).toBeGreaterThanOrEqual(8);
for (const key of Object.keys(afterResync!.tables)) {
expect(afterResync!.tables[key]?.state).toMatch(/^(pending|synced|error)$/);
}
// Status should be a valid state after re-sync attempt.
expect(afterResync!.status).toMatch(/^(error|syncing|synced)$/);
});
it("concurrent forceResync calls are safe and return consistent results", async () => {
const dir = createTempDir("eliza-sync-concur-");
cleanups.push({ dir });
const agentId = v4();
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
await manager.initialize();
// Start sync so forceResync has something to unwind.
await manager.ensureSync();
// Call forceResync twice concurrently — neither should throw,
// and both should agree on the final status.
const [r1, r2] = await Promise.all([manager.forceResync(), manager.forceResync()]);
expect(r1).not.toBeNull();
expect(r2).not.toBeNull();
expect(r1!.status).toMatch(/^(error|syncing|synced)$/);
expect(r2!.status).toMatch(/^(error|syncing|synced)$/);
// Both results should agree on status — no corruption.
expect(r1!.status).toBe(r2!.status);
});
});
// ------------------------------------------------------------------
// 6. Full lifecycle: init → migrate → first DB op → sync triggered
// ------------------------------------------------------------------
describe("full lifecycle", () => {
it("completes the init→migrate→write→sync dance in order", async () => {
const dir = createTempDir("eliza-sync-lifecycle-");
cleanups.push({ dir });
const agentId = v4();
// Phase 1: Create and initialize manager — sync NOT started.
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: BOGUS_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
await manager.initialize();
expect(manager.getSyncStatus().status).toBe("disabled");
// Phase 2: Create adapter, run migrations.
const adapter = new PgliteDatabaseAdapter(agentId as UUID, manager);
await adapter.init();
const db = adapter.getDatabase() as DrizzleDatabase;
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
// Phase 3: Verify all tables exist and are empty.
for (const table of CORE_TABLES) {
const result = await db.execute(sql.raw(`SELECT count(*) as cnt FROM ${table}`));
expect((result.rows as Array<{ cnt: number }>)[0]?.cnt).toBe(0);
}
// Phase 4: First write triggers ensureSync() → startSync().
// With bogus URL, sync will error, but the write succeeds.
await adapter.createAgent({
id: agentId as import("@elizaos/core").UUID,
name: "Lifecycle Test",
createdAt: Date.now(),
updatedAt: Date.now(),
bio: ["Test"],
} as import("@elizaos/core").Agent);
// Phase 5: Sync was attempted (no longer "disabled").
const status = manager.getSyncStatus();
expect(status.status).not.toBe("disabled");
// Phase 6: Data survives.
const agent = await adapter.getAgent(agentId as import("@elizaos/core").UUID);
expect(agent).not.toBeNull();
expect(agent?.name).toBe("Lifecycle Test");
});
});
}, 60_000);
@@ -0,0 +1,469 @@
/**
* End-to-end Electric Sync integration test against a real Postgres +
* Electric stack (`docker compose -f plugins/plugin-sql/docker-compose.electric-test.yml up -d`).
* Exercises the full data flow — Postgres (source) → Electric (shape server)
* → PGlite (`syncShapesToTables`) — including per-agent isolation and sync
* status transitions. All tests skip gracefully when the containers are not
* running.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { sql } from "drizzle-orm";
import { v4 } from "uuid";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../migration-service";
import { PGliteClientManager } from "../../pglite/manager";
import * as schema from "../../schema";
import type { DrizzleDatabase } from "../../types";
const ELECTRIC_HEALTH_URL = "http://localhost:3000/api/health";
const ELECTRIC_SYNC_URL = "http://localhost:3000";
const POSTGRES_URL = "postgresql://postgres:postgres@localhost:5433/electric_test";
let containersAvailable = false;
let pgModule: typeof import("pg") | null = null;
// ------------------------------------------------------------------
// Probe whether the Docker containers are reachable.
// ------------------------------------------------------------------
beforeAll(async () => {
try {
// Try to reach Electric's health endpoint.
const res = await fetch(ELECTRIC_HEALTH_URL, { signal: AbortSignal.timeout(3000) });
if (!res.ok) {
throw new Error(`Electric health check returned ${res.status}`);
}
// Dynamically import pg so tests that don't use it don't fail on missing dep.
try {
pgModule = await import("pg");
} catch {
console.warn(
"[e2e-sync] Electric containers are reachable but 'pg' module is not installed. " +
"Install it with: bun add pg"
);
return;
}
containersAvailable = true;
} catch {
console.warn(
"[e2e-sync] Electric containers not reachable at localhost:3000. " +
"Start them with: docker compose -f plugins/plugin-sql/docker-compose.electric-test.yml up -d"
);
}
}, 10_000);
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function createTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
/** Connect to Postgres and run raw SQL. */
async function pgExec(query: string): Promise<void> {
if (!pgModule) throw new Error("pg module not loaded");
const client = new pgModule.Client({ connectionString: POSTGRES_URL });
try {
await client.connect();
await client.query(query);
} finally {
await client.end();
}
}
/**
* Create the plugin-sql schema tables in Postgres so Electric can
* discover them and serve shapes for them.
*/
async function createPostgresSchema(): Promise<void> {
// Drop existing tables for a clean slate.
await pgExec(`
DO $$ DECLARE r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`);
// Create ALL tables that the sync config expects (manager.ts tables array).
// Electric's syncShapesToTables is all-or-nothing — if any table is missing
// from Postgres, that shape errors and onInitialSync never fires.
await pgExec(`
CREATE TABLE agents (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
bio JSONB DEFAULT '[]'::jsonb,
topics JSONB DEFAULT '[]'::jsonb,
adjectives JSONB DEFAULT '[]'::jsonb,
knowledge JSONB DEFAULT '[]'::jsonb,
plugins JSONB DEFAULT '[]'::jsonb,
settings JSONB DEFAULT '{}'::jsonb
);
CREATE TABLE entities (
id UUID PRIMARY KEY,
agent_id UUID NOT NULL,
name TEXT,
metadata JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE worlds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
name TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
name TEXT,
source TEXT NOT NULL,
type TEXT NOT NULL,
message_server_id UUID,
world_id UUID,
metadata JSONB,
channel_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
room_id UUID REFERENCES rooms(id) ON DELETE CASCADE,
user_id UUID,
role TEXT,
joined_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE memories (
id UUID PRIMARY KEY,
type TEXT NOT NULL,
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
room_id UUID REFERENCES rooms(id) ON DELETE CASCADE,
entity_id UUID,
world_id UUID,
content JSONB NOT NULL,
"unique" BOOLEAN DEFAULT true NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
source_entity_id UUID,
target_entity_id UUID,
metadata JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
room_id UUID,
world_id UUID,
entity_id UUID,
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
tags TEXT[] DEFAULT '{}'::text[],
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
organization_id UUID NOT NULL,
session_token TEXT NOT NULL UNIQUE,
credits_used NUMERIC(10,2) DEFAULT 0.00 NOT NULL,
requests_made INTEGER DEFAULT 0 NOT NULL,
tokens_consumed BIGINT DEFAULT 0 NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
ip_address TEXT,
user_agent TEXT,
device_info JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
}
// ------------------------------------------------------------------
// Test suite
// ------------------------------------------------------------------
describe("Electric Sync end-to-end", () => {
const cleanups: Array<{ dir: string; manager?: PGliteClientManager }> = [];
afterEach(async () => {
for (const c of cleanups.splice(0)) {
if (c.manager) {
try {
await c.manager.close();
} catch {}
}
try {
fs.rmSync(c.dir, { recursive: true, force: true });
} catch {}
}
});
// ------------------------------------------------------------------
// 1. Data flows: Postgres → Electric → PGlite
// ------------------------------------------------------------------
it("synced data from Postgres flows to PGlite via Electric", async () => {
if (!containersAvailable) return;
// 1. Create schema and insert test data in Postgres.
await createPostgresSchema();
const agentId = v4();
const roomId = v4();
const memoryId = v4();
const now = Date.now() / 1000.0;
await pgExec(`
INSERT INTO agents (id, name, created_at, updated_at)
VALUES ('${agentId}', 'e2e-agent', to_timestamp(${now}), to_timestamp(${now}));
INSERT INTO rooms (id, agent_id, name, source, type, created_at)
VALUES ('${roomId}', '${agentId}', 'e2e-room', 'test', 'GROUP', to_timestamp(${now}));
INSERT INTO memories (id, type, agent_id, room_id, content, created_at)
VALUES ('${memoryId}', 'test', '${agentId}', '${roomId}', '{"text":"hello from pg"}'::jsonb, to_timestamp(${now}));
`);
// 2. Create PGlite with Electric sync and run migrations locally.
const dir = createTempDir("eliza-e2e-sync-");
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: ELECTRIC_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
await manager.initialize();
const client = manager.getConnection();
const { drizzle } = await import("drizzle-orm/pglite");
const db = drizzle(client) as unknown as DrizzleDatabase;
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
// 3. Trigger sync.
await manager.ensureSync();
// 4. Poll for sync completion (max 15s).
const deadline = Date.now() + 15_000;
let synced = false;
while (Date.now() < deadline) {
const status = manager.getSyncStatus();
if (status.status === "synced" || status.synced.length >= 3) {
synced = true;
break;
}
if (status.status === "error") {
throw new Error(`Sync errored: ${status.error}`);
}
await new Promise((r) => setTimeout(r, 500));
}
expect(synced).toBe(true);
// 5. Verify synced data in PGlite matches Postgres.
const agentRows = await db.execute(
sql.raw(`SELECT id, name FROM agents WHERE id = '${agentId}'`)
);
const agents = agentRows.rows as Array<{ id: string; name: string }>;
expect(agents).toHaveLength(1);
expect(agents[0]?.name).toBe("e2e-agent");
const roomRows = await db.execute(
sql.raw(`SELECT id, name FROM rooms WHERE agent_id = '${agentId}'`)
);
const rooms = roomRows.rows as Array<{ id: string; name: string }>;
expect(rooms).toHaveLength(1);
expect(rooms[0]?.name).toBe("e2e-room");
const memoryRows = await db.execute(
sql.raw(`SELECT id, content FROM memories WHERE agent_id = '${agentId}'`)
);
const memories = memoryRows.rows as Array<{ id: string; content: unknown }>;
expect(memories).toHaveLength(1);
}, 30_000);
// ------------------------------------------------------------------
// 2. Per-agent isolation: agentA's PGlite only sees agentA's data
// ------------------------------------------------------------------
it("per-agent isolation: only the synced agent's rows appear in PGlite", async () => {
if (!containersAvailable) return;
await createPostgresSchema();
const agentA = v4();
const agentB = v4();
const roomA = v4();
const roomB = v4();
const memoryA = v4();
const memoryB = v4();
const now = Date.now() / 1000.0;
// Seed data for two agents in Postgres.
await pgExec(`
INSERT INTO agents (id, name, created_at, updated_at)
VALUES
('${agentA}', 'agent-a', to_timestamp(${now}), to_timestamp(${now})),
('${agentB}', 'agent-b', to_timestamp(${now}), to_timestamp(${now}));
INSERT INTO rooms (id, agent_id, name, source, type, created_at)
VALUES
('${roomA}', '${agentA}', 'room-a', 'test', 'GROUP', to_timestamp(${now})),
('${roomB}', '${agentB}', 'room-b', 'test', 'GROUP', to_timestamp(${now}));
INSERT INTO memories (id, type, agent_id, room_id, content, created_at)
VALUES
('${memoryA}', 'test', '${agentA}', '${roomA}', '{"text":"a"}'::jsonb, to_timestamp(${now})),
('${memoryB}', 'test', '${agentB}', '${roomB}', '{"text":"b"}'::jsonb, to_timestamp(${now}));
`);
// Sync PGlite as agentA — should only receive agentA's rows.
const dir = createTempDir("eliza-e2e-iso-");
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: ELECTRIC_SYNC_URL,
agentId: agentA,
});
cleanups.push({ dir, manager });
await manager.initialize();
const client = manager.getConnection();
const { drizzle } = await import("drizzle-orm/pglite");
const db = drizzle(client) as unknown as DrizzleDatabase;
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
await manager.ensureSync();
// Poll for sync completion.
const deadline = Date.now() + 15_000;
let synced = false;
while (Date.now() < deadline) {
const status = manager.getSyncStatus();
if (status.status === "synced") {
synced = true;
break;
}
if (status.status === "error") {
throw new Error(`Sync errored: ${status.error}`);
}
await new Promise((r) => setTimeout(r, 500));
}
expect(synced).toBe(true);
// agentA's data should be present.
const agentARows = await db.execute(
sql.raw(`SELECT id, name FROM agents WHERE id = '${agentA}'`)
);
expect((agentARows.rows as Array<{ name: string }>)[0]?.name).toBe("agent-a");
const roomARows = await db.execute(
sql.raw(`SELECT id, name FROM rooms WHERE agent_id = '${agentA}'`)
);
expect((roomARows.rows as Array<{ name: string }>)[0]?.name).toBe("room-a");
const memoryARows = await db.execute(
sql.raw(`SELECT id FROM memories WHERE agent_id = '${agentA}'`)
);
expect(memoryARows.rows).toHaveLength(1);
// agentB's data should NOT be present (filtered by agent_id = $1).
const agentBRows = await db.execute(sql.raw(`SELECT id FROM agents WHERE id = '${agentB}'`));
expect(agentBRows.rows).toHaveLength(0);
const roomBRows = await db.execute(
sql.raw(`SELECT id FROM rooms WHERE agent_id = '${agentB}'`)
);
expect(roomBRows.rows).toHaveLength(0);
const memoryBRows = await db.execute(
sql.raw(`SELECT id FROM memories WHERE agent_id = '${agentB}'`)
);
expect(memoryBRows.rows).toHaveLength(0);
}, 30_000);
// ------------------------------------------------------------------
// 3. Sync status transitions: disabled → syncing → synced
// ------------------------------------------------------------------
it("sync status transitions from disabled through syncing to synced", async () => {
if (!containersAvailable) return;
await createPostgresSchema();
const agentId = v4();
const now = Date.now() / 1000.0;
await pgExec(`
INSERT INTO agents (id, name, created_at, updated_at)
VALUES ('${agentId}', 'status-agent', to_timestamp(${now}), to_timestamp(${now}));
`);
const dir = createTempDir("eliza-e2e-status-");
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: ELECTRIC_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
// Before initialize, status is disabled.
expect(manager.getSyncStatus().status).toBe("disabled");
await manager.initialize();
const client = manager.getConnection();
const { drizzle } = await import("drizzle-orm/pglite");
const db = drizzle(client) as unknown as DrizzleDatabase;
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
// Trigger sync — status should transition. Poll until outcome.
await manager.ensureSync();
const deadline = Date.now() + 15_000;
let finalStatus = "";
while (Date.now() < deadline) {
const { status, error } = manager.getSyncStatus();
finalStatus = status;
if (status === "synced") break;
if (status === "error") {
throw new Error(`Sync errored: ${error}`);
}
await new Promise((r) => setTimeout(r, 500));
}
expect(finalStatus).toBe("synced");
}, 30_000);
}, 90_000);
@@ -0,0 +1,390 @@
/**
* Integration tests for the PGlite live query extension (`pg.live`), each
* against a real `PGliteClientManager` in a temp directory with the
* plugin-sql Drizzle migrations applied. Verifies `liveNs.query()`'s
* reactive callback firing on INSERT/UPDATE/DELETE and its unsubscribe
* cleanup.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { sql } from "drizzle-orm";
import { v4 } from "uuid";
import { afterEach, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../migration-service";
import { PGliteClientManager } from "../../pglite/manager";
import * as schema from "../../schema";
import type { DrizzleDatabase } from "../../types";
function createTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
describe("PGlite live query extension", () => {
const cleanups: Array<{ dir: string; manager?: PGliteClientManager }> = [];
afterEach(async () => {
for (const c of cleanups.splice(0)) {
if (c.manager) {
try {
await c.manager.close();
} catch {}
}
try {
fs.rmSync(c.dir, { recursive: true, force: true });
} catch {}
}
});
// ------------------------------------------------------------------
// Helper: create a fresh PGlite manager, run migrations, seed FK rows
// ------------------------------------------------------------------
async function setupPGlite(): Promise<{
manager: PGliteClientManager;
db: DrizzleDatabase;
agentId: string;
}> {
const dir = createTempDir("eliza-live-query-");
const agentId = v4();
const manager = new PGliteClientManager({
dataDir: dir,
agentId,
});
await manager.initialize();
cleanups.push({ dir, manager });
const client = manager.getConnection();
const { drizzle } = await import("drizzle-orm/pglite");
const db = drizzle(client) as unknown as DrizzleDatabase;
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
// Create an agent row (FK requirement for rooms and memories).
const now = Date.now();
const nowSec = now / 1000.0;
await db.execute(
sql.raw(
`INSERT INTO agents (id, name, created_at, updated_at) VALUES ('${agentId}', 'live-query-test', to_timestamp(${nowSec}), to_timestamp(${nowSec}))`
)
);
return { manager, db, agentId };
}
// ------------------------------------------------------------------
// 1. liveNs.query() returns reactive results
// ------------------------------------------------------------------
it("liveNs.query() returns reactive results after INSERT", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return; // Extensions disabled
const roomId = v4();
const now = Date.now() / 1000.0;
// Create a room row so we can insert memories against it.
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'reactive-room', 'test', 'GROUP', to_timestamp(${now}))`
)
);
// Use a Promise to await the post-INSERT callback deterministically.
const inserted = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Reactive callback did not fire within 5s")),
5000
);
let initialFired = false;
liveNs
.query<{ count: string }>(
"SELECT COUNT(*)::text AS count FROM memories WHERE agent_id = $1",
[agentId],
(result) => {
const count = parseInt(result.rows[0]?.count ?? "0", 10);
if (!initialFired) {
initialFired = true;
return;
}
clearTimeout(timeout);
resolve(count);
}
)
.catch(reject);
});
// Let initial callback settle.
await new Promise((r) => setTimeout(r, 100));
// Insert a memory — should trigger a callback with updated count.
const memoryId = v4();
await db.execute(
sql.raw(
`INSERT INTO memories (id, type, agent_id, room_id, content, created_at) VALUES ('${memoryId}', 'test', '${agentId}', '${roomId}', '{"text":"reactive"}'::jsonb, to_timestamp(${now}))`
)
);
const count = await inserted;
expect(count).toBeGreaterThanOrEqual(1);
}, 10_000);
// ------------------------------------------------------------------
// 2. Callback fires on INSERT
// ------------------------------------------------------------------
it("live.query() callback fires on INSERT", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return;
const roomId = v4();
const now = Date.now() / 1000.0;
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'insert-room', 'test', 'GROUP', to_timestamp(${now}))`
)
);
// Wait for the callback that fires AFTER the INSERT (skip initial).
const inserted = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("INSERT callback did not fire within 5s")),
5000
);
let initialFired = false;
liveNs
.query<{ count: string }>(
"SELECT COUNT(*)::text AS count FROM rooms WHERE agent_id = $1",
[agentId],
(result) => {
const count = parseInt(result.rows[0]?.count ?? "0", 10);
if (!initialFired) {
initialFired = true;
// Initial callback includes the room we already inserted.
expect(count).toBeGreaterThanOrEqual(1);
return;
}
clearTimeout(timeout);
resolve(count);
}
)
.catch(reject);
});
await new Promise((r) => setTimeout(r, 100));
// Insert a second room — triggers the post-initial callback.
const roomId2 = v4();
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId2}', '${agentId}', 'insert-room-2', 'test', 'GROUP', to_timestamp(${Date.now() / 1000.0}))`
)
);
const count = await inserted;
expect(count).toBeGreaterThanOrEqual(2);
}, 10_000);
// ------------------------------------------------------------------
// 3. Callback fires on UPDATE
// ------------------------------------------------------------------
it("live.query() callback fires on UPDATE", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return;
const roomId = v4();
const now = Date.now() / 1000.0;
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'before-update', 'test', 'GROUP', to_timestamp(${now}))`
)
);
// Wait for the callback that fires AFTER the UPDATE (skip initial).
const updated = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("UPDATE callback did not fire within 5s")),
5000
);
let initialFired = false;
liveNs
.query<{ name: string }>("SELECT name FROM rooms WHERE id = $1", [roomId], (result) => {
const name = result.rows[0]?.name ?? "";
if (!initialFired) {
initialFired = true;
expect(name).toBe("before-update");
return;
}
clearTimeout(timeout);
resolve(name);
})
.catch(reject);
});
await new Promise((r) => setTimeout(r, 100));
// Update the room name — triggers the post-initial callback.
await db.execute(sql.raw(`UPDATE rooms SET name = 'after-update' WHERE id = '${roomId}'`));
const name = await updated;
expect(name).toBe("after-update");
}, 10_000);
// ------------------------------------------------------------------
// 4. Callback fires on DELETE
// ------------------------------------------------------------------
it("live.query() callback fires on DELETE", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return;
const roomId = v4();
const now = Date.now() / 1000.0;
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'delete-me', 'test', 'GROUP', to_timestamp(${now}))`
)
);
// Wait for the callback AFTER delete (count should drop).
const deleted = new Promise<{ count: number; initialCount: number }>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("DELETE callback did not fire within 5s")),
5000
);
let initialFired = false;
let capturedInitialCount = 0;
liveNs
.query<{ count: string }>(
"SELECT COUNT(*)::text AS count FROM rooms WHERE agent_id = $1",
[agentId],
(result) => {
const count = parseInt(result.rows[0]?.count ?? "0", 10);
if (!initialFired) {
initialFired = true;
capturedInitialCount = count;
expect(count).toBeGreaterThanOrEqual(1);
return;
}
clearTimeout(timeout);
resolve({ count, initialCount: capturedInitialCount });
}
)
.catch(reject);
});
await new Promise((r) => setTimeout(r, 100));
// Delete the room — triggers the post-initial callback with lower count.
await db.execute(sql.raw(`DELETE FROM rooms WHERE id = '${roomId}'`));
const { count, initialCount } = await deleted;
// Count must be strictly lower after deletion.
expect(count).toBeLessThan(initialCount);
}, 10_000);
// ------------------------------------------------------------------
// 5. Unsubscribe stops callbacks
// ------------------------------------------------------------------
it("unsubscribe() stops live query callbacks", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return;
const roomId = v4();
const now = Date.now() / 1000.0;
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'unsub-room', 'test', 'GROUP', to_timestamp(${now}))`
)
);
let callbackFiredAfterUnsubscribe = false;
const liveReturn = await liveNs.query<{ count: string }>(
"SELECT COUNT(*)::text AS count FROM rooms WHERE agent_id = $1",
[agentId],
() => {
callbackFiredAfterUnsubscribe = true;
}
);
// Let initial callback fire.
await new Promise((r) => setTimeout(r, 100));
// Reset flag and unsubscribe.
callbackFiredAfterUnsubscribe = false;
await liveReturn.unsubscribe();
// Insert another room — should NOT trigger any more callbacks.
const roomId2 = v4();
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId2}', '${agentId}', 'unsub-room-2', 'test', 'GROUP', to_timestamp(${Date.now() / 1000.0}))`
)
);
// Give ample time for any delayed callback to fire.
await new Promise((r) => setTimeout(r, 300));
expect(callbackFiredAfterUnsubscribe).toBe(false);
}, 10_000);
// ------------------------------------------------------------------
// 6. liveNs.query() with parameterized query returns correct rows
// ------------------------------------------------------------------
it("liveNs.query() with parameters returns correct initial rows", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return;
const roomId = v4();
const now = Date.now() / 1000.0;
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'param-room', 'test', 'GROUP', to_timestamp(${now}))`
)
);
// Wait for initial callback to resolve the Promise.
const initialResult = await new Promise<number>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Initial callback did not fire within 3s")),
3000
);
liveNs
.query<{ count: string }>(
"SELECT COUNT(*)::text AS count FROM rooms WHERE agent_id = $1",
[agentId],
(result) => {
clearTimeout(timeout);
resolve(parseInt(result.rows[0]?.count ?? "0", 10));
}
)
.catch(reject);
});
expect(initialResult).toBeGreaterThanOrEqual(1);
}, 10_000);
}, 60_000);
@@ -0,0 +1,175 @@
/**
* End-to-end Electric Write-Back test against Electric Cloud (the managed
* sync service, not a local Docker Compose stack) — verifies PGlite connects
* and syncs existing tables via `syncShapesToTables`. Skips gracefully when
* the Cloud env vars are unset or the proxy is unreachable. Write-back
* enabled/disabled/enqueue behavior itself is covered by the unit tests in
* `__tests__/unit/write-back.test.ts`.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { v4 } from "uuid";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../migration-service";
import { PGliteClientManager } from "../../pglite/manager";
import * as schema from "../../schema";
import type { DrizzleDatabase } from "../../types";
// Caddy injects Electric Cloud auth from these env vars — the test just
// checks they're set so Caddy can do its job.
const ELECTRIC_CLOUD_SOURCE_ID = process.env.ELECTRIC_CLOUD_SOURCE_ID;
const ELECTRIC_CLOUD_SECRET = process.env.ELECTRIC_CLOUD_SECRET;
const ELECTRIC_SYNC_URL = "http://localhost:3001";
// Caddy forwards to Electric Cloud with auth; use query-param format.
const ELECTRIC_PROBE_URL = "http://localhost:3001/?table=agents&offset=-1";
const CLOUD_ENV_VARS_SET = !!(ELECTRIC_CLOUD_SOURCE_ID && ELECTRIC_CLOUD_SECRET);
// ── Electric Cloud rejection filter ──────────────────────────────────
//
// The Electric Cloud database may not have every table in SYNCED_TABLE_NAMES
// (e.g. user_sessions was added after the Cloud DB was provisioned). When
// the sync stream encounters a missing table, the postgres error bypasses
// pglite-sync's onError callback and surfaces as an unhandled rejection.
//
// We filter these specifically so they don't fail CI, but log them for
// traceability. Any OTHER unhandled rejection still surfaces.
const KNOWN_CLOUD_REJECTIONS = [/relation "public\.user_sessions" does not exist/];
let unhandledSuppressor: ((reason: unknown, promise: Promise<unknown>) => void) | null = null;
// Register the suppressor first so it's active before any async work
// (including the cloud probe fetch) begins.
beforeAll(() => {
unhandledSuppressor = (reason: unknown, _promise: Promise<unknown>) => {
const msg = reason instanceof Error ? reason.message : String(reason);
const isKnown = KNOWN_CLOUD_REJECTIONS.some((pattern) => pattern.test(msg));
if (isKnown) {
console.debug("[write-back] Suppressed known Cloud rejection:", msg);
} else {
console.error("[write-back] UNEXPECTED unhandled rejection:", msg, reason);
}
};
process.on("unhandledRejection", unhandledSuppressor);
});
afterAll(() => {
if (unhandledSuppressor) {
process.off("unhandledRejection", unhandledSuppressor);
}
});
let cloudAvailable = false;
beforeAll(async () => {
if (!CLOUD_ENV_VARS_SET) {
console.warn(
"[write-back] ELECTRIC_CLOUD_SOURCE_ID and ELECTRIC_CLOUD_SECRET env vars not set — skipping e2e test.\n" +
" Start Caddy with: ELECTRIC_CLOUD_SOURCE_ID=... ELECTRIC_CLOUD_SECRET=... caddy run --config plugins/plugin-sql/caddy/electric-proxy.Caddyfile"
);
return;
}
try {
const res = await fetch(ELECTRIC_PROBE_URL, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) throw new Error(`Electric probe returned ${res.status}`);
cloudAvailable = true;
console.log("[write-back] Electric Cloud reachable via Caddy proxy — running write-back tests");
} catch (err) {
console.warn(
`[write-back] Electric Cloud not reachable at localhost:3001: ${err instanceof Error ? err.message : String(err)}. Start Caddy with: ELECTRIC_CLOUD_SOURCE_ID=... ELECTRIC_CLOUD_SECRET=... caddy run --config plugins/plugin-sql/caddy/electric-proxy.Caddyfile`
);
}
}, 15_000);
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function createTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
// ------------------------------------------------------------------
// Test suite
// ------------------------------------------------------------------
describe("Electric Write-Back e2e", () => {
const cleanups: Array<{ dir: string; manager?: PGliteClientManager }> = [];
afterEach(async () => {
for (const c of cleanups.splice(0)) {
if (c.manager) {
try {
await c.manager.close();
} catch {}
}
// Yield the event loop so PGlite WASM cleanup callbacks complete
// before the data directory is removed.
await new Promise((r) => setTimeout(r, 50));
try {
fs.rmSync(c.dir, { recursive: true, force: true });
} catch {}
}
});
// ------------------------------------------------------------------
// 1. PGlite syncs data from Electric Cloud
// ------------------------------------------------------------------
it("write-back: PGlite connects to Electric Cloud and syncs existing data", async () => {
if (!cloudAvailable) return;
const agentId = v4();
// 1. Create PGlite with Electric Cloud sync configured.
const dir = createTempDir("eliza-wb-");
const manager = new PGliteClientManager({
dataDir: dir,
syncUrl: ELECTRIC_SYNC_URL,
agentId,
});
cleanups.push({ dir, manager });
await manager.initialize();
const client = manager.getConnection();
const { drizzle } = await import("drizzle-orm/pglite");
const db = drizzle(client) as unknown as DrizzleDatabase;
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
await manager.ensureSync();
// Wait for sync to connect and make progress. Some tables
// (e.g. user_sessions) may not exist in this Electric Cloud
// database — per-table 404s are expected and don't block the
// tables that DO exist from syncing.
const syncDeadline = Date.now() + 15_000;
while (Date.now() < syncDeadline) {
const status = manager.getSyncStatus();
if (status.status === "synced") {
break;
}
if (status.status === "error") throw new Error(`Sync errored: ${status.error}`);
await new Promise((r) => setTimeout(r, 500));
}
// Sync may not reach "synced" if some Cloud tables don't exist
// (e.g. user_sessions). Verify the stream connected successfully —
// the real validation is that syncShapesToTables accepted the URL
// without 400/404 (which would indicate auth or format issues).
const finalStatus = manager.getSyncStatus();
expect(finalStatus.status).not.toBe("error");
expect(finalStatus.status).not.toBe("disabled");
console.log(
"[write-back] Sync connected — status:",
finalStatus.status,
"tables:",
Object.keys(finalStatus.tables).length
);
expect(Object.keys(finalStatus.tables).length).toBeGreaterThan(0);
}, 90_000);
// Tests 2-4 (write-back enabled/disabled/enqueue) are covered by the
// 14 unit tests in __tests__/unit/write-back.test.ts.
});
@@ -0,0 +1,199 @@
/**
* Verifies memories can be created with embeddings and read back with the vector
* dimension preserved, including an adapter-scoped dimension change (384 to 768).
* Runs against a real Postgres or PGlite backend via `createIsolatedTestDatabase`.
*/
import {
type Agent,
ChannelType,
type Entity,
type Memory,
MemoryType,
type Room,
type UUID,
} from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { embeddingTable, memoryTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Embedding Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testEntityId: UUID;
let testRoomId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("embedding-tests");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
// Generate random UUIDs for test data
testEntityId = uuidv4() as UUID;
testRoomId = uuidv4() as UUID;
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
]);
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
name: "Test Room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Embedding Tests", () => {
beforeEach(async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
await db.delete(embeddingTable);
await db.delete(memoryTable);
});
it("should create a memory with an embedding and retrieve it", async () => {
await adapter.ensureEmbeddingDimension(384);
const memory: Memory = {
id: uuidv4() as UUID,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: { text: "This memory has an embedding." },
embedding: Array.from({ length: 384 }, () => Math.random()),
createdAt: Date.now(),
unique: false,
metadata: {
type: MemoryType.CUSTOM,
source: "test",
},
};
const memoryId = await adapter.createMemory(memory, "embedding_test");
expect(memoryId).toBe(memory.id as UUID);
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeDefined();
expect(retrieved?.embedding).toBeDefined();
expect(retrieved?.embedding?.length).toBe(384);
});
it("should handle different embedding dimensions", async () => {
// Test with 768 dimensions
await adapter.ensureEmbeddingDimension(768);
const memory768: Memory = {
id: uuidv4() as UUID,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: { text: "This memory has a 768-dimension embedding." },
embedding: Array.from({ length: 768 }, () => Math.random()),
createdAt: Date.now(),
unique: false,
metadata: {
type: MemoryType.CUSTOM,
source: "test",
},
};
const memoryId = await adapter.createMemory(memory768, "embedding_test_768");
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved?.embedding?.length).toBe(768);
});
it("clearEmbeddingsOutsideActiveDimension reclaims stale-dimension vectors and keeps active-dimension ones", async () => {
// An agent that used cloud 1536-dim embeddings, then switched to on-device
// gte-small (384-dim): the 1536 vector must be reclaimed (a 384-dim search
// can never match it) while the memory row itself survives.
await adapter.ensureEmbeddingDimension(1536);
const otherAgentId = uuidv4() as UUID;
await adapter.createAgent({
id: otherAgentId,
name: "Other embedding agent",
} as Agent);
const stale: Memory = {
id: uuidv4() as UUID,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: { text: "Embedded with the old cloud model." },
embedding: Array.from({ length: 1536 }, () => Math.random()),
createdAt: Date.now(),
unique: false,
metadata: { type: MemoryType.CUSTOM, source: "test" },
};
const staleId = await adapter.createMemory(stale, "embedding_test");
const otherStale: Memory = {
id: uuidv4() as UUID,
agentId: otherAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: { text: "Other agent still uses the old cloud model." },
embedding: Array.from({ length: 1536 }, () => Math.random()),
createdAt: Date.now(),
unique: false,
metadata: { type: MemoryType.CUSTOM, source: "test" },
};
const otherStaleId = await adapter.createMemory(otherStale, "embedding_test");
await adapter.ensureEmbeddingDimension(384);
const fresh: Memory = {
id: uuidv4() as UUID,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: { text: "Embedded with the new on-device model." },
embedding: Array.from({ length: 384 }, () => Math.random()),
createdAt: Date.now(),
unique: false,
metadata: { type: MemoryType.CUSTOM, source: "test" },
};
const freshId = await adapter.createMemory(fresh, "embedding_test");
const reclaimed = await adapter.clearEmbeddingsOutsideActiveDimension();
expect(reclaimed).toContain(staleId);
expect(reclaimed).not.toContain(freshId);
expect(reclaimed).not.toContain(otherStaleId);
// The stale vector is gone but the memory row (its text) survives, so it
// can be re-embedded at the active width.
const staleRetrieved = await adapter.getMemoryById(staleId);
expect(staleRetrieved).toBeDefined();
expect(staleRetrieved?.embedding ?? undefined).toBeUndefined();
// The active-dimension vector is untouched.
const freshRetrieved = await adapter.getMemoryById(freshId);
expect(freshRetrieved?.embedding?.length).toBe(384);
// The cleanup is scoped to this adapter's agent; another agent may still
// legitimately own old-width vectors until that agent boots and reclaims
// against its own active dimension.
await adapter.ensureEmbeddingDimension(1536);
const otherRetrieved = await adapter.getMemoryById(otherStaleId);
expect(otherRetrieved?.embedding?.length).toBe(1536);
await adapter.ensureEmbeddingDimension(384);
// Idempotent: nothing left to reclaim.
expect(await adapter.clearEmbeddingsOutsideActiveDimension()).toEqual([]);
});
});
});
@@ -0,0 +1,273 @@
/**
* Verifies entity `names` is always persisted and read back as a string array,
* regardless of input shape (single string, empty array, Set, or non-standard
* values), across create, update, and batch-create. Runs against a real
* Postgres or PGlite backend via `createTestDatabase`.
*/
import type { Entity, UUID } from "@elizaos/core";
import { stringToUuid } from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createTestDatabase } from "../test-helpers";
import { expectCreatedEntityIds } from "./entity-create-assertions";
// Helper type for testing edge cases with non-standard Entity inputs
type PartialEntity = Partial<Entity> & {
id: UUID;
agentId: UUID;
names?: string | string[] | Set<string> | number | boolean | object | null | undefined;
metadata?: Record<string, unknown>;
};
// Helper function to create test entity with type assertion for edge cases
function createTestEntity(entity: PartialEntity): Entity {
return entity as Entity;
}
describe("Entity Array Serialization Fix Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeEach(async () => {
testAgentId = stringToUuid(`test-agent-${Date.now()}`);
const testDB = await createTestDatabase(testAgentId);
adapter = testDB.adapter;
cleanup = testDB.cleanup;
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Entity Creation with Names Array", () => {
it("should create entity with single name in array", async () => {
const entityId = stringToUuid(`entity-single-${Date.now()}`);
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["user-test123"],
metadata: { web: { userName: "user-test123" } },
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved) throw new Error("Entities should exist");
expect(retrieved.length).toBe(1);
if (!retrieved[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual(["user-test123"]);
});
it("should create entity with multiple names in array", async () => {
const entityId = stringToUuid(`entity-multiple-${Date.now()}`);
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["user-primary", "user-alias1", "user-alias2"],
metadata: { web: { userName: "user-primary" } },
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual(["user-primary", "user-alias1", "user-alias2"]);
});
it("should handle entity with empty names array", async () => {
const entityId = stringToUuid(`entity-empty-${Date.now()}`);
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: [],
metadata: {},
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual([]);
});
it("should handle entity with Set-like names by converting to array", async () => {
const entityId = stringToUuid(`entity-set-${Date.now()}`);
// Simulate what might happen if names accidentally becomes a Set
const namesSet = new Set(["user-name1", "user-name2"]);
// Use type assertion to test edge case where names is a Set (not conforming to Entity type)
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: namesSet, // This should be normalized to an array
metadata: {},
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
// Set order is not guaranteed, so we just check the values are present
expect(retrieved[0].names.length).toBe(2);
expect(retrieved[0].names).toContain("user-name1");
expect(retrieved[0].names).toContain("user-name2");
});
});
describe("Entity Update with Names Array", () => {
it("should update entity names correctly", async () => {
const entityId = stringToUuid(`entity-update-${Date.now()}`);
// Create initial entity
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["original-name"],
metadata: {},
};
await adapter.createEntities([entity]);
// Update with new names
const updatedEntity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["original-name", "new-name", "another-name"],
metadata: { updated: true },
};
await adapter.updateEntity(updatedEntity);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual(["original-name", "new-name", "another-name"]);
});
it("should handle Set-like names in update by converting to array", async () => {
const entityId = stringToUuid(`entity-update-set-${Date.now()}`);
// Create initial entity
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["original-name"],
metadata: {},
};
await adapter.createEntities([entity]);
// Update with Set-like names
const namesSet = new Set(["updated-name1", "updated-name2"]);
// Use type assertion to test edge case where names is a Set (not conforming to Entity type)
const updatedEntity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: namesSet, // This should be normalized to an array
metadata: { updated: true },
});
await adapter.updateEntity(updatedEntity);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names.length).toBe(2);
expect(retrieved[0].names).toContain("updated-name1");
expect(retrieved[0].names).toContain("updated-name2");
});
});
describe("Batch Entity Creation", () => {
it("should create multiple entities with proper name arrays", async () => {
const timestamp = Date.now();
const entities: Entity[] = Array.from({ length: 5 }, (_, i) => ({
id: stringToUuid(`batch-entity-${timestamp}-${i}`),
agentId: testAgentId,
names: [`user-${i}`, `alias-${i}`],
metadata: { index: i },
}));
const result = await adapter.createEntities(entities);
expectCreatedEntityIds(result, entities);
const entityIds = entities.map((e) => e.id);
const retrieved = await adapter.getEntitiesByIds(entityIds);
expect(retrieved).not.toBeNull();
if (!retrieved) throw new Error("Entities should exist");
expect(retrieved.length).toBe(5);
// Verify each entity has proper array format without relying on order
retrieved.forEach((entity) => {
expect(Array.isArray(entity.names)).toBe(true);
expect(entity.names.length).toBe(2);
expect(entity.names[0]).toMatch(/^user-\d+$/);
expect(entity.names[1]).toMatch(/^alias-\d+$/);
});
});
});
describe("Edge Cases", () => {
it("should handle entity with special characters in names", async () => {
const entityId = stringToUuid(`entity-special-${Date.now()}`);
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["user@test.com", "user-with-dash", "user_with_underscore", "user{with}braces"],
metadata: {},
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual([
"user@test.com",
"user-with-dash",
"user_with_underscore",
"user{with}braces",
]);
});
it("should handle entity with unicode characters in names", async () => {
const entityId = stringToUuid(`entity-unicode-${Date.now()}`);
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["用户名", "ユーザー", "пользователь", "👤user"],
metadata: {},
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual(["用户名", "ユーザー", "пользователь", "👤user"]);
});
});
});
@@ -0,0 +1,22 @@
/**
* Shared assertion helper for entity-creation tests: checks that
* `createEntities` reports exactly the expected set of entity IDs as created,
* order-independent. Used across the entity/base-adapter test suites so each
* one doesn't hand-roll the same length + set-membership check.
*/
import type { UUID } from "@elizaos/core";
import { expect } from "vitest";
export function expectCreatedEntityIds(
result: UUID[],
entities: ReadonlyArray<{ id?: UUID }>
): UUID[] {
const expectedIds = entities
.map((entity) => entity.id)
.filter((id): id is UUID => id !== undefined);
expect(result).toHaveLength(expectedIds.length);
expect(result).toEqual(expect.arrayContaining(expectedIds));
return expectedIds;
}
@@ -0,0 +1,379 @@
/**
* Verifies core entity CRUD (create, update, delete, name search, metadata
* round-trip) plus create-is-not-upsert idempotency and batch-with-duplicate
* handling. Runs against a real Postgres or PGlite backend via
* `createIsolatedTestDatabase`.
*/
import type { Entity, Metadata, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
import { expectCreatedEntityIds } from "./entity-create-assertions";
describe("Entity CRUD Operations", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("entity-crud");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Basic CRUD Operations", () => {
beforeEach(async () => {
// Clean up any existing entities
const existingEntities = await adapter.searchEntitiesByName({
query: "",
agentId: testAgentId,
limit: 100,
});
for (const entity of existingEntities) {
if (entity.id) {
await adapter.deleteEntity(entity.id);
}
}
});
it("should create and retrieve an entity", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Test Entity"],
metadata: { type: "custom" },
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved).toHaveLength(1);
expect(retrieved[0].id).toBe(entity.id as UUID);
});
it("should update an entity", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Original Name"],
metadata: { version: 1 },
};
await adapter.createEntities([entity]);
const updatedEntity: Entity = {
...entity,
names: ["Updated Name"],
metadata: { version: 2 },
};
await adapter.updateEntity(updatedEntity);
const retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved[0].names).toContain("Updated Name");
expect(retrieved[0].metadata?.version).toBe(2);
});
it("should delete an entity", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["To Delete"],
metadata: {},
};
await adapter.createEntities([entity]);
// Verify it exists
let retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved).toHaveLength(1);
// Delete it
await adapter.deleteEntity(entity.id!);
// Verify it's gone
retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved).toHaveLength(0);
});
});
describe("Advanced Entity Operations", () => {
it("should get entities by multiple names", async () => {
const entities = [
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["John Doe", "JD"],
metadata: { type: "person" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Jane Doe", "JD2"],
metadata: { type: "person" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Bob Smith"],
metadata: { type: "person" },
},
];
for (const entity of entities) {
await adapter.createEntities([entity]);
}
// Search for Doe entities
const doeEntities = await adapter.getEntitiesByNames({
names: ["John Doe", "Jane Doe"],
agentId: testAgentId,
});
expect(doeEntities).toHaveLength(2);
const names = doeEntities.flatMap((e) => e.names);
expect(names).toContain("John Doe");
expect(names).toContain("Jane Doe");
});
it("should search entities with partial name matching", async () => {
const entities = [
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Alexander Hamilton"],
metadata: {},
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Alexandra Smith"],
metadata: {},
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Bob Johnson"],
metadata: {},
},
];
for (const entity of entities) {
await adapter.createEntities([entity]);
}
const results = await adapter.searchEntitiesByName({
query: "Alex",
agentId: testAgentId,
limit: 10,
});
expect(results).toHaveLength(2);
expect(
results.every((e) => e.names.some((name) => name.toLowerCase().includes("alex")))
).toBe(true);
});
it("should handle entity metadata operations", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Metadata Test"],
metadata: {
level: 1,
tags: ["test", "metadata"],
nested: {
property: "value",
array: [1, 2, 3],
},
},
};
await adapter.createEntities([entity]);
const retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved[0].metadata).toEqual(entity.metadata as Metadata);
expect(
retrieved[0].metadata?.nested &&
(retrieved[0].metadata.nested as Record<string, unknown>).array
).toEqual([1, 2, 3]);
});
it("should handle duplicate entity creation", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Duplicate Test"],
metadata: {},
};
// First creation should succeed
const firstResult = await adapter.createEntities([entity]);
expectCreatedEntityIds(firstResult, [entity]);
// Re-creating the same id is idempotent: the existing id is reported as
// success, and the stored row is NOT clobbered (create is not upsert —
// that's upsertEntities' job).
const duplicate: Entity = { ...entity, names: ["Duplicate Test Renamed"] };
const secondResult = await adapter.createEntities([duplicate]);
expectCreatedEntityIds(secondResult, [entity]);
const retrieved = await adapter.getEntitiesByIds([entity.id as UUID]);
expect(retrieved).toHaveLength(1);
expect(retrieved[0].names).toEqual(["Duplicate Test"]);
});
it("should still insert new entities in a batch containing a duplicate", async () => {
const existing: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Existing Batch Entity"],
metadata: {},
};
expectCreatedEntityIds(await adapter.createEntities([existing]), [existing]);
// A duplicate in the batch must not roll back the new rows.
const fresh: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Fresh Batch Entity"],
metadata: {},
};
const result = await adapter.createEntities([existing, fresh]);
expectCreatedEntityIds(result, [existing, fresh]);
const retrieved = await adapter.getEntitiesByIds([existing.id as UUID, fresh.id as UUID]);
expect(retrieved).toHaveLength(2);
});
it("should handle batch entity operations", async () => {
const entities: Entity[] = [];
for (let i = 0; i < 5; i++) {
entities.push({
id: uuidv4() as UUID,
agentId: testAgentId,
names: [`Batch Entity ${i}`],
metadata: { index: i },
});
}
// Create all entities
const result = await adapter.createEntities(entities);
expectCreatedEntityIds(result, entities);
// Verify all were created
const entityIds = entities.map((e) => e.id!);
const retrieved = await adapter.getEntitiesByIds(entityIds);
expect(retrieved).toHaveLength(5);
// Delete all entities
for (const entity of entities) {
await adapter.deleteEntity(entity.id!);
}
// Verify all were deleted
const afterDelete = await adapter.getEntitiesByIds(entityIds);
expect(afterDelete).toHaveLength(0);
});
});
describe("Edge Cases", () => {
it("should handle empty search query", async () => {
// Create a few entities
for (let i = 0; i < 3; i++) {
await adapter.createEntities([
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: [`Entity ${i}`],
metadata: {},
},
]);
}
const results = await adapter.searchEntitiesByName({
query: "",
agentId: testAgentId,
limit: 10,
});
expect(results.length).toBeGreaterThanOrEqual(3);
});
it("should handle update of non-existent entity", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Non-existent"],
metadata: {},
};
// Should not throw - updateEntity should handle non-existent entities gracefully
await adapter.updateEntity(entity);
// Entity should not exist
const retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved).toHaveLength(0);
});
it("should handle deletion of non-existent entity", async () => {
const nonExistentId = uuidv4() as UUID;
// Should not throw - deleteEntity should handle non-existent entities gracefully
await adapter.deleteEntity(nonExistentId);
});
it("should handle entities with multiple names in search", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Primary Name", "Alias One", "Alias Two"],
metadata: {},
};
await adapter.createEntities([entity]);
// Should find by any name
const result1 = await adapter.searchEntitiesByName({
query: "Primary",
agentId: testAgentId,
});
expect(result1).toHaveLength(1);
const result2 = await adapter.searchEntitiesByName({
query: "Alias",
agentId: testAgentId,
});
expect(result2).toHaveLength(1);
});
it("should handle case-insensitive name search", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["CaseSensitive Name"],
metadata: {},
};
await adapter.createEntities([entity]);
const results = await adapter.searchEntitiesByName({
query: "CASESENSITIVE",
agentId: testAgentId,
});
expect(results).toHaveLength(1);
expect(results[0].id).toBe(entity.id as UUID);
});
});
});
@@ -0,0 +1,819 @@
/**
* Verifies entity `names` normalization handles every input shape the field
* can arrive in (string, Set, Map, number, boolean, plain object, custom
* iterable, null/undefined, mixed-type arrays) without throwing or silently
* corrupting data, plus entity delete/search/name-lookup behavior. Runs
* against a real Postgres or PGlite backend via `createIsolatedTestDatabase`.
*/
import type { Entity, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
import { expectCreatedEntityIds } from "./entity-create-assertions";
// Helper type for testing edge cases with non-standard Entity inputs
type PartialEntity = Partial<Entity> & {
id: UUID;
agentId: UUID;
names?: string | string[] | Set<string> | number | boolean | object | null | undefined;
metadata?: Record<string, unknown>;
};
// Helper function to create test entity with type assertion for edge cases
function createTestEntity(entity: PartialEntity): Entity {
return entity as Entity;
}
// Helper function to create test entity array with type assertion for edge cases
function createTestEntityArray(entities: PartialEntity[]): Entity[] {
return entities as Entity[];
}
describe("Entity Methods Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("entity-methods");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
/**
* Entity Names Normalization Tests
*
* These tests verify that the entity names field is properly normalized to an array
* regardless of the input type. This is critical because:
*
* 1. **String Handling Bug**: Without proper normalization, passing a string like "username"
* would be treated as an iterable and split into individual characters ["u","s","e","r"...]
* which corrupts the data.
*
* 2. **Non-Iterable Values**: When non-array values (numbers, booleans, objects) are passed,
* calling Array.from() would throw a runtime error, crashing the application.
*
* 3. **Data Integrity**: Entity names must always be stored as a proper string array to
* maintain consistency across the database and prevent query failures.
*
* The normalizeEntityNames method handles:
* - Arrays: Pass through unchanged
* - Strings: Wrap in array (not split into characters)
* - Sets: Convert to array using Array.from()
* - Iterables: Check for Symbol.iterator before calling Array.from()
* - Non-iterables: Convert to string and wrap in array
* - null/undefined: Return empty array
*/
describe("Entity Names Normalization", () => {
/**
* Test: String values should NOT be split into individual characters
*
* WHY: A common bug was that passing a string like "username" would result in
* the string being treated as an iterable, producing ["u","s","e","r","n","a","m","e"]
* instead of ["username"].
*
* WHAT: Verify that a single string is wrapped in an array, not character-split.
*/
it("should NOT split a string name into individual characters", async () => {
const entityId = uuidv4() as UUID;
// Simulate a case where names might accidentally be a string
// Use type assertion to test edge case where names is a string (not conforming to Entity type)
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: "username123", // String instead of array
metadata: { web: { userName: "username123" } },
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
// Should be ["username123"], NOT ["u","s","e","r","n","a","m","e","1","2","3"]
expect(retrieved[0].names).toEqual(["username123"]);
expect(retrieved[0].names.length).toBe(1);
});
/**
* Test: String values in updates should also NOT be split into characters
*
* WHY: The bug could also occur during entity updates, not just creation.
*
* WHAT: Verify that updating an entity with a string name doesn't split it.
*/
it("should handle string name in update without splitting into characters", async () => {
const entityId = uuidv4() as UUID;
// Create initial entity with proper array
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["original-name"],
metadata: {},
};
await adapter.createEntities([entity]);
// Update with string instead of array (simulating the bug scenario)
// Use type assertion to test edge case where names is a string (not conforming to Entity type)
const updatedEntity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: "updated-username", // String instead of array
metadata: { updated: true },
});
await adapter.updateEntity(updatedEntity);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
// Should be ["updated-username"], NOT character array
expect(retrieved[0].names).toEqual(["updated-username"]);
expect(retrieved[0].names.length).toBe(1);
});
/**
* Test: Multiple input types should all normalize correctly
*
* WHY: Entities can come from various sources (APIs, user input, other systems)
* and we need to handle all possible input types gracefully.
*
* WHAT: Test Set, string, and array inputs all in one batch to ensure
* consistent behavior across different input types.
*/
it("should properly handle Set, string, and array types in batch", async () => {
// Use type assertion to test edge cases with non-standard inputs
const entities = createTestEntityArray([
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: new Set(["name1", "name2"]), // Set should convert to array
metadata: { type: "set" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: "singlename", // String should wrap in array
metadata: { type: "string" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["proper", "array"], // Array should stay as is
metadata: { type: "array" },
},
]);
const result = await adapter.createEntities(entities);
expectCreatedEntityIds(result, entities);
const entityIds = entities.map((e) => e.id);
const retrieved = await adapter.getEntitiesByIds(entityIds);
expect(retrieved).not.toBeNull();
if (!retrieved) throw new Error("Entities should exist");
expect(retrieved.length).toBe(3);
// Find each entity by metadata type
const setEntity = retrieved.find(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
"type" in e.metadata &&
e.metadata.type === "set"
);
const stringEntity = retrieved.find(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
"type" in e.metadata &&
e.metadata.type === "string"
);
const arrayEntity = retrieved.find(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
"type" in e.metadata &&
e.metadata.type === "array"
);
// Set should be converted to array
if (!setEntity) throw new Error("Set entity should exist");
expect(Array.isArray(setEntity.names)).toBe(true);
expect(setEntity.names.length).toBe(2);
expect(setEntity.names).toContain("name1");
expect(setEntity.names).toContain("name2");
// String should be wrapped in array, NOT split into characters
if (!stringEntity) throw new Error("String entity should exist");
expect(Array.isArray(stringEntity.names)).toBe(true);
expect(stringEntity.names).toEqual(["singlename"]);
expect(stringEntity.names.length).toBe(1);
// Array should remain unchanged
if (!arrayEntity) throw new Error("Array entity should exist");
expect(Array.isArray(arrayEntity.names)).toBe(true);
expect(arrayEntity.names).toEqual(["proper", "array"]);
});
/**
* Test: Numbers should be converted to string arrays
*
* WHY: Without proper handling, passing a number would cause Array.from(42)
* to throw "TypeError: 42 is not iterable", crashing the application.
*
* WHAT: Verify that numbers are safely converted to string format ["42"].
*/
it("should handle number as names by converting to string array", async () => {
const entityId = uuidv4() as UUID;
// Use type assertion to test edge case where names is a number
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: 42, // Number (non-iterable)
metadata: { type: "number" },
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual(["42"]);
});
/**
* Test: Booleans should be converted to string arrays
*
* WHY: Similar to numbers, booleans are non-iterable and would cause
* Array.from(true) to throw a runtime error.
*
* WHAT: Verify that booleans are safely converted to string format ["true"].
*/
it("should handle boolean as names by converting to string array", async () => {
const entityId = uuidv4() as UUID;
// Use type assertion to test edge case where names is a boolean
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: true, // Boolean (non-iterable)
metadata: { type: "boolean" },
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual(["true"]);
});
/**
* Test: Plain objects should be converted to string arrays
*
* WHY: Plain objects like {foo: 'bar'} are not iterable and would cause
* Array.from() to throw. They need to be stringified.
*
* WHAT: Verify that plain objects are safely converted to their string
* representation and wrapped in an array.
*/
it("should handle plain object as names by converting to string array", async () => {
const entityId = uuidv4() as UUID;
// Use type assertion to test edge case where names is an object
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: { foo: "bar" }, // Plain object (non-iterable)
metadata: { type: "object" },
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
// Should convert object to string representation
expect(retrieved[0].names.length).toBe(1);
expect(typeof retrieved[0].names[0]).toBe("string");
});
/**
* Test: Null and undefined should result in empty arrays
*
* WHY: Null and undefined are common in JavaScript and need special handling.
* They should not cause errors or produce invalid data.
*
* WHAT: Verify that both null and undefined values are safely converted
* to empty arrays [].
*/
it("should handle null and undefined names gracefully", async () => {
// Use type assertion to test edge cases with null/undefined
const entities = createTestEntityArray([
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: null,
metadata: { type: "null" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: undefined,
metadata: { type: "undefined" },
},
]);
const result = await adapter.createEntities(entities);
expectCreatedEntityIds(result, entities);
const entityIds = entities.map((e) => e.id);
const retrieved = await adapter.getEntitiesByIds(entityIds);
expect(retrieved).not.toBeNull();
if (!retrieved) throw new Error("Entities should exist");
expect(retrieved.length).toBe(2);
// Both should result in empty arrays
retrieved.forEach((entity) => {
expect(Array.isArray(entity.names)).toBe(true);
expect(entity.names).toEqual([]);
});
});
/**
* Test: Non-iterable values in updates should be handled gracefully
*
* WHY: The normalization bug could occur during updates just as it could
* during creation. Updates need the same safety guarantees.
*
* WHAT: Verify that updating an entity with a non-iterable value (number)
* doesn't crash and produces the expected string array.
*/
it("should handle non-iterable values in update", async () => {
const entityId = uuidv4() as UUID;
// Create initial entity
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["original-name"],
metadata: {},
};
await adapter.createEntities([entity]);
// Update with non-iterable value (number)
// Use type assertion to test edge case where names is a number
const updatedEntity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: 999, // Number
metadata: { updated: true },
});
await adapter.updateEntity(updatedEntity);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names).toEqual(["999"]);
});
/**
* Test: Maps should be converted to string arrays (not [key, value] tuples)
*
* WHY: Maps are iterables that yield [key, value] tuples. Without proper
* handling, Array.from(map) would create an array of tuples like
* [['key1', 'value1'], ['key2', 'value2']], which would then be stringified
* to ["key1,value1", "key2,value2"] or cause database errors.
*
* WHAT: Verify that Map inputs are converted to proper string arrays.
*/
it("should handle Map as names by converting entries to strings", async () => {
const entityId = uuidv4() as UUID;
// Create a Map (common in some APIs or data structures)
const namesMap = new Map([
["key1", "value1"],
["key2", "value2"],
]);
// Use type assertion to test edge case where names is a Map
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: namesMap, // Map (iterable that yields tuples)
metadata: { type: "map" },
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
// Each entry should be converted to a string
// Map entries become "key1,value1", "key2,value2" when stringified
expect(retrieved[0].names.length).toBe(2);
expect(retrieved[0].names).toContain("key1,value1");
expect(retrieved[0].names).toContain("key2,value2");
});
/**
* Test: Arrays with non-string elements should convert all to strings
*
* WHY: Arrays might contain mixed types (numbers, objects, booleans).
* Without converting all elements to strings, these would cause database
* errors when inserted into a string array column.
*
* WHAT: Verify that arrays with non-string elements are properly converted.
*/
it("should convert all array elements to strings, even if non-string", async () => {
const entityId = uuidv4() as UUID;
// Use type assertion to test edge case where names array has mixed types
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: ["string", 123, true, { foo: "bar" }, null], // Mixed types
metadata: { type: "mixed-array" },
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
// All elements should be strings now
expect(retrieved[0].names.length).toBe(5);
expect(retrieved[0].names[0]).toBe("string");
expect(retrieved[0].names[1]).toBe("123");
expect(retrieved[0].names[2]).toBe("true");
expect(typeof retrieved[0].names[3]).toBe("string"); // Object stringified
expect(retrieved[0].names[4]).toBe("null");
// Verify all are strings
retrieved[0].names.forEach((name) => {
expect(typeof name).toBe("string");
});
});
/**
* Test: Sets with non-string elements should convert all to strings
*
* WHY: Sets might contain non-string values (numbers, objects, etc).
* These need to be converted to strings to prevent database errors.
*
* WHAT: Verify that Sets with mixed types are properly converted.
*/
it("should convert Set elements to strings, even if non-string", async () => {
const entityId = uuidv4() as UUID;
const namesSet = new Set([123, "test", true, 456]);
// Use type assertion to test edge case where names is a Set with mixed types
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: namesSet, // Set with mixed types
metadata: { type: "mixed-set" },
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
// All elements should be strings now
expect(retrieved[0].names.length).toBe(4);
expect(retrieved[0].names).toContain("123");
expect(retrieved[0].names).toContain("test");
expect(retrieved[0].names).toContain("true");
expect(retrieved[0].names).toContain("456");
// Verify all are strings
retrieved[0].names.forEach((name) => {
expect(typeof name).toBe("string");
});
});
/**
* Test: Custom iterables should be handled correctly
*
* WHY: Some objects might implement Symbol.iterator and yield non-string
* values. These need to be properly converted.
*
* WHAT: Verify that custom iterables are converted to string arrays.
*/
it("should handle custom iterable with non-string values", async () => {
const entityId = uuidv4() as UUID;
// Create a custom iterable
const customIterable = {
*[Symbol.iterator]() {
yield 1;
yield 2;
yield 3;
},
};
// Use type assertion to test edge case where names is a custom iterable
const entity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: customIterable, // Custom iterable yielding numbers
metadata: { type: "custom-iterable" },
});
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
// All elements should be converted to strings
expect(retrieved[0].names).toEqual(["1", "2", "3"]);
retrieved[0].names.forEach((name) => {
expect(typeof name).toBe("string");
});
});
/**
* Test: Map in update should also be handled correctly
*
* WHY: The Map bug could occur during updates, not just creation.
*
* WHAT: Verify that updating an entity with a Map doesn't cause issues.
*/
it("should handle Map in update by converting entries to strings", async () => {
const entityId = uuidv4() as UUID;
// Create initial entity
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["original-name"],
metadata: {},
};
await adapter.createEntities([entity]);
// Update with Map
const namesMap = new Map([
["updated", "name1"],
["another", "name2"],
]);
// Use type assertion to test edge case where names is a Map
const updatedEntity = createTestEntity({
id: entityId,
agentId: testAgentId,
names: namesMap,
metadata: { updated: true },
});
await adapter.updateEntity(updatedEntity);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Entity should exist");
expect(Array.isArray(retrieved[0].names)).toBe(true);
expect(retrieved[0].names.length).toBe(2);
expect(retrieved[0].names).toContain("updated,name1");
expect(retrieved[0].names).toContain("another,name2");
});
});
describe("deleteEntity", () => {
it("should delete an entity by ID", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Entity to Delete"],
metadata: { type: "custom" },
};
// Create entity
await adapter.createEntities([entity]);
// Verify it exists
let retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved).toHaveLength(1);
// Delete entity
await adapter.deleteEntity(entity.id!);
// Verify it's deleted
retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved).toHaveLength(0);
});
it("should not throw when deleting non-existent entity", async () => {
const nonExistentId = uuidv4() as UUID;
// Should not throw - deleteEntity should handle non-existent entities gracefully
await adapter.deleteEntity(nonExistentId);
});
});
describe("getEntitiesByNames", () => {
it("should retrieve entities by names", async () => {
const entity1: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["John Doe", "Johnny"],
metadata: { type: "person" },
};
const entity2: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Jane Doe", "Janet"],
metadata: { type: "person" },
};
const entity3: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Bob Smith"],
metadata: { type: "person" },
};
// Create entities
await adapter.createEntities([entity1, entity2, entity3]);
// Search for entities with Doe names
const doeEntities = await adapter.getEntitiesByNames({
names: ["John Doe", "Jane Doe"],
agentId: testAgentId,
});
expect(doeEntities).toHaveLength(2);
expect(doeEntities.map((e) => e.id)).toContain(entity1.id);
expect(doeEntities.map((e) => e.id)).toContain(entity2.id);
});
it("should return empty array when no entities match", async () => {
const result = await adapter.getEntitiesByNames({
names: ["Non Existent Name"],
agentId: testAgentId,
});
expect(result).toEqual([]);
});
});
describe("searchEntitiesByName", () => {
it("should search entities by partial name match", async () => {
const entities: Entity[] = [
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Alice Smith", "Alicia"],
metadata: { type: "person" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Bob Johnson"],
metadata: { type: "person" },
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Alice Cooper", "Al Cooper"],
metadata: { type: "person" },
},
];
// Create entities
for (const entity of entities) {
await adapter.createEntities([entity]);
}
// Search for entities with 'Alice' in name
const searchResults = await adapter.searchEntitiesByName({
query: "Alice",
agentId: testAgentId,
limit: 10,
});
expect(searchResults).toHaveLength(2);
expect(
searchResults.every((e) => e.names.some((name) => name.toLowerCase().includes("alice")))
).toBe(true);
});
it("should respect the limit parameter", async () => {
const entities: Entity[] = [];
for (let i = 0; i < 5; i++) {
entities.push({
id: uuidv4() as UUID,
agentId: testAgentId,
names: [`Test Entity ${i}`],
metadata: { index: i },
});
}
// Create entities
await adapter.createEntities(entities);
// Search with limit
const results = await adapter.searchEntitiesByName({
query: "Test",
agentId: testAgentId,
limit: 2,
});
expect(results).toHaveLength(2);
});
it("should return all entities when query is empty", async () => {
const entities: Entity[] = [
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Entity 1"],
metadata: {},
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["Entity 2"],
metadata: {},
},
];
await adapter.createEntities(entities);
const results = await adapter.searchEntitiesByName({
query: "",
agentId: testAgentId,
limit: 10,
});
expect(results.length).toBeGreaterThanOrEqual(2);
});
it("should perform case-insensitive search", async () => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["UPPERCASE NAME", "MixedCase Name"],
metadata: {},
};
await adapter.createEntities([entity]);
// Search with lowercase
let results = await adapter.searchEntitiesByName({
query: "uppercase",
agentId: testAgentId,
});
expect(results).toHaveLength(1);
// Search with different case
results = await adapter.searchEntitiesByName({
query: "MIXEDCASE",
agentId: testAgentId,
});
expect(results).toHaveLength(1);
});
});
});
@@ -0,0 +1,152 @@
/**
* Integration tests for entity create/get/update against a real isolated
* PGlite/Postgres adapter (`createIsolatedTestDatabase`), covering
* multi-entity batches, multi-name aliasing, and default metadata.
*/
import type { AgentRuntime, Entity, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { entityTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
import { expectCreatedEntityIds } from "./entity-create-assertions";
describe("Entity Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let _runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("entity-tests");
adapter = setup.adapter;
_runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Entity Tests", () => {
beforeEach(async () => {
// Clear entities before each test to ensure a clean slate
const db = adapter.getDatabase() as DrizzleDatabase;
await db.delete(entityTable);
});
it("should create and retrieve a basic entity", async () => {
const entityId = uuidv4() as UUID;
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["Test Entity"],
metadata: { type: "custom" },
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
expect(retrieved?.[0]?.id).toBe(entityId);
});
it("should return empty array when retrieving non-existent entities", async () => {
const nonExistentId = uuidv4() as UUID;
const retrieved = await adapter.getEntitiesByIds([nonExistentId]);
expect(retrieved).toEqual([]);
});
it("should update an existing entity", async () => {
const entityId = uuidv4() as UUID;
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["Original Name"],
metadata: { original: "data" },
};
const createResult = await adapter.createEntities([entity]);
expectCreatedEntityIds(createResult, [entity]);
const updatedEntity = {
...entity,
names: ["Updated Name"],
metadata: { updated: "data" },
};
await adapter.updateEntity(updatedEntity);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
expect(retrieved?.[0]?.names).toEqual(["Updated Name"]);
expect(retrieved?.[0]?.metadata).toEqual({
updated: "data",
});
});
it("should handle multiple entities creation", async () => {
const entity1Id = uuidv4() as UUID;
const entity2Id = uuidv4() as UUID;
const entities: Entity[] = [
{
id: entity1Id,
agentId: testAgentId,
names: ["Entity One"],
metadata: { type: "type1" },
},
{
id: entity2Id,
agentId: testAgentId,
names: ["Entity Two"],
metadata: { type: "type2" },
},
];
const result = await adapter.createEntities(entities);
expectCreatedEntityIds(result, entities);
const retrieved = await adapter.getEntitiesByIds([entity1Id, entity2Id]);
expect(retrieved).not.toBeNull();
expect(retrieved?.length).toBe(2);
});
it("should handle entities with multiple names", async () => {
const entityId = uuidv4() as UUID;
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["Primary Name", "Alias 1", "Alias 2"],
metadata: { hasAliases: true },
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
expect(retrieved?.[0]?.names).toEqual(["Primary Name", "Alias 1", "Alias 2"]);
});
it("should handle entities with no metadata", async () => {
const entityId = uuidv4() as UUID;
const entity: Entity = {
id: entityId,
agentId: testAgentId,
names: ["Simple Entity"],
};
const result = await adapter.createEntities([entity]);
expectCreatedEntityIds(result, [entity]);
const retrieved = await adapter.getEntitiesByIds([entityId]);
expect(retrieved).not.toBeNull();
expect(retrieved?.[0]?.metadata).toEqual({}); // Assuming default is an empty object
});
});
});
@@ -0,0 +1,90 @@
/**
* Real-database error-policy tests for SQL adapter methods that previously
* hid adapter failures behind empty success values. Each case corrupts the
* underlying schema and asserts the public method throws a typed `ElizaError`.
*/
import { type Component, ElizaError, type Entity, type UUID } from "@elizaos/core";
import { sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { describe, expect, it } from "vitest";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
async function dropTable(db: DrizzleDatabase, tableName: string): Promise<void> {
await db.execute(sql.raw(`DROP TABLE IF EXISTS ${tableName} CASCADE`));
}
async function expectElizaError(promise: Promise<unknown>, code: string): Promise<void> {
let thrown: unknown;
try {
await promise;
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(ElizaError);
expect(thrown).toMatchObject({ code });
expect((thrown as ElizaError).cause).toBeDefined();
}
describe("SQL adapter error policy", () => {
it("throws a typed error when countAgents cannot read the agents table", async () => {
const setup = await createIsolatedTestDatabase("error-policy-count-agents");
try {
await dropTable(setup.adapter.getDatabase() as DrizzleDatabase, "agents");
await expectElizaError(setup.adapter.countAgents(), "DB_COUNT_FAILED");
} finally {
await setup.cleanup();
}
}, 60_000);
it("throws a typed error when deleteAgents cannot write the agents table", async () => {
const setup = await createIsolatedTestDatabase("error-policy-delete-agents");
try {
await dropTable(setup.adapter.getDatabase() as DrizzleDatabase, "agents");
await expectElizaError(setup.adapter.deleteAgents([uuidv4() as UUID]), "DB_DELETE_FAILED");
} finally {
await setup.cleanup();
}
}, 60_000);
it("throws a typed error when createEntities cannot write the entities table", async () => {
const setup = await createIsolatedTestDatabase("error-policy-create-entities");
try {
await dropTable(setup.adapter.getDatabase() as DrizzleDatabase, "entities");
const entity: Entity = {
id: uuidv4() as UUID,
agentId: setup.testAgentId,
names: ["Error Policy Entity"],
};
await expectElizaError(setup.adapter.createEntities([entity]), "DB_INSERT_FAILED");
} finally {
await setup.cleanup();
}
}, 60_000);
it("throws a typed error when updateComponent cannot write the components table", async () => {
const setup = await createIsolatedTestDatabase("error-policy-update-component");
try {
await dropTable(setup.adapter.getDatabase() as DrizzleDatabase, "components");
const component: Component = {
id: uuidv4() as UUID,
entityId: uuidv4() as UUID,
agentId: setup.testAgentId,
roomId: uuidv4() as UUID,
worldId: uuidv4() as UUID,
sourceEntityId: uuidv4() as UUID,
type: "error-policy-component",
data: { value: "boom" },
createdAt: Date.now(),
};
await expectElizaError(setup.adapter.updateComponent(component), "DB_UPDATE_FAILED");
} finally {
await setup.cleanup();
}
}, 60_000);
});
@@ -0,0 +1,270 @@
/**
* Integration tests for PGlite live query latency: measures end-to-end time
* from INSERT to callback fire on the same `live.query()` pipeline the
* `/api/database/status/events` SSE/WebSocket endpoints use to push reactive
* table counts. Uses `live.query()` rather than `incrementalQuery` because
* these single-table tests use plain `COUNT(*)` aggregates with no stable
* row identifier, unlike the production UNION ALL query.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { sql } from "drizzle-orm";
import { v4 } from "uuid";
import { afterEach, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../migration-service";
import { PGliteClientManager } from "../../pglite/manager";
import * as schema from "../../schema";
import type { DrizzleDatabase } from "../../types";
function createTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
describe("Live query latency", () => {
const cleanups: Array<{ dir: string; manager?: PGliteClientManager }> = [];
afterEach(async () => {
for (const c of cleanups.splice(0)) {
if (c.manager) {
try {
await c.manager.close();
} catch {}
}
try {
fs.rmSync(c.dir, { recursive: true, force: true });
} catch {}
}
});
// ------------------------------------------------------------------
// Helper: create manager, run migrations, return db handle
// ------------------------------------------------------------------
async function setupPGlite(): Promise<{
manager: PGliteClientManager;
db: DrizzleDatabase;
agentId: string;
}> {
const dir = createTempDir("eliza-live-latency-");
const agentId = v4();
const manager = new PGliteClientManager({
dataDir: dir,
agentId,
});
await manager.initialize();
cleanups.push({ dir, manager });
const client = manager.getConnection();
const { drizzle } = await import("drizzle-orm/pglite");
const db = drizzle(client) as unknown as DrizzleDatabase;
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
// Create agent + room rows (FK requirements for memories).
const now = Date.now();
await db.execute(
sql.raw(
`INSERT INTO agents (id, name, created_at, updated_at) VALUES ('${agentId}', 'latency-test', to_timestamp(${now / 1000.0}), to_timestamp(${now / 1000.0}))`
)
);
const roomId = v4();
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'test-room', 'test', 'GROUP', to_timestamp(${now / 1000.0}))`
)
);
return { manager, db, agentId };
}
// ------------------------------------------------------------------
// 1. live.query() fires within 100ms of INSERT
// ------------------------------------------------------------------
it("live.query() callback fires within 100ms of a table INSERT", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return; // Extensions disabled
const memoryId = v4();
const roomId = v4();
// Create a fresh room for this test's memories.
const now = Date.now();
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'test-room', 'test', 'GROUP', to_timestamp(${now / 1000.0}))`
)
);
// Set up a promise that resolves on the second callback (after INSERT).
const countUpdated = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Live query did not fire within 5s")),
5000
);
let initialFired = false;
let unsubscribe: (() => Promise<void>) | null = null;
liveNs
.query<{ count: string | number }>(
"SELECT COUNT(*)::text AS count FROM memories",
[],
(result) => {
const count = parseInt(String(result.rows[0]?.count ?? "0"), 10);
if (!initialFired) {
initialFired = true;
return;
}
clearTimeout(timeout);
if (unsubscribe) unsubscribe().catch(() => {});
resolve(count);
}
)
.then((ret) => {
unsubscribe = ret.unsubscribe;
})
.catch(reject);
});
// Wait for initial callback to settle.
await new Promise((r) => setTimeout(r, 100));
const startTime = Date.now();
await db.execute(
sql.raw(
`INSERT INTO memories (id, type, agent_id, room_id, content, created_at) VALUES ('${memoryId}', 'test', '${agentId}', '${roomId}', '{"text":"latency test"}'::jsonb, to_timestamp(${Date.now() / 1000.0}))`
)
);
const count = await countUpdated;
const elapsed = Date.now() - startTime;
expect(count).toBeGreaterThanOrEqual(1);
expect(elapsed).toBeLessThan(100);
}, 10_000);
// ------------------------------------------------------------------
// 2. Count accurately reflects INSERTs
// ------------------------------------------------------------------
it("live query count accurately reflects INSERTs", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return;
const roomId = v4();
const now = Date.now();
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'test-room', 'test', 'GROUP', to_timestamp(${now / 1000.0}))`
)
);
const finalCount = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Live query did not reach expected count within 5s")),
5000
);
let unsubscribe: (() => Promise<void>) | null = null;
liveNs
.query<{ count: string | number }>(
"SELECT COUNT(*)::text AS count FROM memories",
[],
(result) => {
const count = parseInt(String(result.rows[0]?.count ?? "0"), 10);
if (count >= 3) {
clearTimeout(timeout);
if (unsubscribe) unsubscribe().catch(() => {});
resolve(count);
}
}
)
.then((ret) => {
unsubscribe = ret.unsubscribe;
})
.catch(reject);
});
// Let initial callback settle.
await new Promise((r) => setTimeout(r, 50));
for (let i = 0; i < 3; i++) {
await db.execute(
sql.raw(
`INSERT INTO memories (id, type, agent_id, room_id, content, created_at) VALUES ('${v4()}', 'test', '${agentId}', '${roomId}', '{"text":"batch ${i}"}'::jsonb, to_timestamp(${now / 1000.0}))`
)
);
await new Promise((r) => setTimeout(r, 10));
}
const count = await finalCount;
expect(count).toBeGreaterThanOrEqual(3);
}, 10_000);
// ------------------------------------------------------------------
// 3. Rooms table also pushes within 100ms
// ------------------------------------------------------------------
it("live.query() fires within 100ms for rooms INSERT too", async () => {
const { manager, db, agentId } = await setupPGlite();
const liveNs = manager.liveQuery();
if (!liveNs) return;
const roomId = v4();
const countUpdated = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Live query did not fire within 5s")),
5000
);
let initialFired = false;
let unsubscribe: (() => Promise<void>) | null = null;
liveNs
.query<{ count: string | number }>(
"SELECT COUNT(*)::text AS count FROM rooms",
[],
(result) => {
const count = parseInt(String(result.rows[0]?.count ?? "0"), 10);
if (!initialFired) {
initialFired = true;
return;
}
clearTimeout(timeout);
if (unsubscribe) unsubscribe().catch(() => {});
resolve(count);
}
)
.then((ret) => {
unsubscribe = ret.unsubscribe;
})
.catch(reject);
});
await new Promise((r) => setTimeout(r, 100));
const startTime = Date.now();
await db.execute(
sql.raw(
`INSERT INTO rooms (id, agent_id, name, source, type, created_at) VALUES ('${roomId}', '${agentId}', 'test-room-2', 'test', 'GROUP', to_timestamp(${Date.now() / 1000.0}))`
)
);
const count = await countUpdated;
const elapsed = Date.now() - startTime;
expect(count).toBeGreaterThanOrEqual(1);
expect(elapsed).toBeLessThan(100);
}, 10_000);
}, 60_000);
@@ -0,0 +1,174 @@
/**
* Integration tests for log create/get/delete against a real isolated
* PGlite/Postgres adapter, covering the `limit`/legacy-`count` param
* contract, JSON-body escaping (backslashes, NUL stripping), and filtering
* by type.
*/
import { type AgentRuntime, ChannelType, type Entity, type Room, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { logTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Log Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let _runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testEntityId: UUID;
let testRoomId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("log-tests");
adapter = setup.adapter;
_runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
// Generate random UUIDs for test data
testEntityId = uuidv4() as UUID;
testRoomId = uuidv4() as UUID;
// Create necessary entities for foreign key constraints
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
]);
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
name: "Test Room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Log Tests", () => {
beforeEach(async () => {
await (adapter.getDatabase() as DrizzleDatabase).delete(logTable);
});
it("should create and retrieve a log entry", async () => {
const logData = {
body: { message: "hello world" },
entityId: testEntityId,
roomId: testRoomId,
type: "test_log",
};
await adapter.log(logData);
const logs = await adapter.getLogs({
entityId: testEntityId,
roomId: testRoomId,
});
expect(logs).toHaveLength(1);
expect(logs[0].body).toEqual({ message: "hello world" });
});
it("should not throw when deleting a non-existent log", async () => {
const nonExistentId = uuidv4() as UUID;
await expect(adapter.deleteLog(nonExistentId)).resolves.not.toThrow();
});
it("honors the `limit` param from the IDatabaseAdapter contract (not just legacy `count`)", async () => {
for (let i = 0; i < 15; i++) {
await adapter.log({
body: { seq: i },
entityId: testEntityId,
roomId: testRoomId,
type: "limit_test",
});
}
const all = await adapter.getLogs({ roomId: testRoomId, limit: 100 });
expect(all).toHaveLength(15);
const capped = await adapter.getLogs({ roomId: testRoomId, limit: 5 });
expect(capped).toHaveLength(5);
// Legacy `count` alias still works
const legacy = await adapter.getLogs({ roomId: testRoomId, count: 7 });
expect(legacy).toHaveLength(7);
// No limit provided keeps the historical default of 10
const defaulted = await adapter.getLogs({ roomId: testRoomId });
expect(defaulted).toHaveLength(10);
});
it("round-trips backslashes in log bodies without double-escaping", async () => {
const body = {
path: "C:\\Users\\dev\\project",
regex: "^\\d+\\q$",
unicodeish: "literal \\u12 sequence",
};
await adapter.log({
body,
entityId: testEntityId,
roomId: testRoomId,
type: "backslash_test",
});
const logs = await adapter.getLogs({
roomId: testRoomId,
type: "backslash_test",
});
expect(logs).toHaveLength(1);
// sanitizeJsonObject must not double a backslash that isn't followed
// by a valid JSON escape char (["\/bfnrtu]).
expect(logs[0].body).toEqual(body);
});
it("strips NUL characters so the jsonb insert does not fail", async () => {
const nul = String.fromCharCode(0);
await adapter.log({
body: { text: `a${nul}b` },
entityId: testEntityId,
roomId: testRoomId,
type: "nul_test",
});
const logs = await adapter.getLogs({
roomId: testRoomId,
type: "nul_test",
});
expect(logs).toHaveLength(1);
expect(logs[0].body).toEqual({ text: "ab" });
});
it("should filter logs by type", async () => {
await adapter.log({
body: { message: "message 1" },
entityId: testEntityId,
roomId: testRoomId,
type: "typeA",
});
await adapter.log({
body: { message: "message 2" },
entityId: testEntityId,
roomId: testRoomId,
type: "typeB",
});
const logs = await adapter.getLogs({
entityId: testEntityId,
roomId: testRoomId,
type: "typeA",
});
expect(logs).toHaveLength(1);
expect(logs[0].type).toBe("typeA");
});
});
});
@@ -0,0 +1,237 @@
/**
* Scale test for the keyword (`textContains`) search path on `getMemories`.
* Seeds a large fixture (>=2k rooms / >=200k message rows) and proves the
* keyword search is a single pushed-down SQL `ILIKE` predicate — not a
* "fetch every row, scan in JS" pattern — returns correct rows across
* conversations, reaches a hit far outside the recent window, and stays
* bounded in latency. Default mode is PGlite (WASM, in-process); set
* `POSTGRES_URL` to run against a real Postgres instead.
*/
import { ChannelType, type Entity, type Room, type UUID, type World } from "@elizaos/core";
import { v4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { memoryTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
const ROOMS = Number(process.env.KEYWORD_SEARCH_TEST_ROOMS ?? 2_000);
const MESSAGES_PER_ROOM = Number(process.env.KEYWORD_SEARCH_TEST_MESSAGES_PER_ROOM ?? 100);
const TOTAL_MESSAGES = ROOMS * MESSAGES_PER_ROOM;
const INSERT_CHUNK = 2_000;
// A unique needle that appears in exactly one seeded message, planted as the
// VERY FIRST message of the first room — i.e. the oldest row in the whole
// fixture, far outside any recent-N window. Reaching it proves the keyword
// predicate scans the full table in SQL, not a JS slice of recent rows.
const RARE_NEEDLE = "xyzzy-plugh-unique-marker";
// A common needle present in a known, bounded fraction of messages.
const COMMON_NEEDLE = "pineapple";
const COMMON_HIT_EVERY = 50; // one in fifty messages contains COMMON_NEEDLE
describe("getMemories keyword search at scale (real DB)", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let runtime: Awaited<ReturnType<typeof createIsolatedTestDatabase>>["runtime"];
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let firstRoomId: UUID;
let expectedCommonHits = 0;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("memory_keyword_search");
adapter = setup.adapter;
runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
const worldId = v4() as UUID;
await adapter.createWorld({
id: worldId,
agentId: testAgentId,
name: "Scale World",
serverId: "scale-server",
} as World);
const entityId = v4() as UUID;
await adapter.createEntities([
{ id: entityId, agentId: testAgentId, names: ["Scale Entity"] } as Entity,
]);
// Create rooms in batches via the adapter (FK target for messages).
const roomIds: UUID[] = [];
for (let i = 0; i < ROOMS; i++) roomIds.push(v4() as UUID);
firstRoomId = roomIds[0];
for (let start = 0; start < ROOMS; start += INSERT_CHUNK) {
const slice = roomIds.slice(start, start + INSERT_CHUNK);
await adapter.createRooms(
slice.map(
(id) =>
({
id,
agentId: testAgentId,
worldId,
name: "room",
source: "test",
type: ChannelType.DM,
}) as Room
)
);
}
for (const roomId of roomIds) {
await adapter.addParticipant(entityId, roomId);
}
// Batch-insert message rows directly. Seeding 200k+ rows through the
// per-row createMemory path would dominate the suite; a chunked
// db.insert is the legitimate fast path for fixture data.
const db = adapter.getDatabase() as DrizzleDatabase;
const baseTime = Date.now() - TOTAL_MESSAGES * 1_000;
let rows: Array<typeof memoryTable.$inferInsert> = [];
let globalIndex = 0;
const flush = async () => {
if (rows.length === 0) return;
await db.insert(memoryTable).values(rows);
rows = [];
};
for (let r = 0; r < ROOMS; r++) {
const roomId = roomIds[r];
for (let m = 0; m < MESSAGES_PER_ROOM; m++) {
// Plant the rare needle as the oldest row in the whole fixture. The
// rare row is NOT a common hit (its text omits COMMON_NEEDLE), so the
// common-hit count must exclude it even when its index aligns.
const isRare = r === 0 && m === 0;
const isCommon = !isRare && globalIndex % COMMON_HIT_EVERY === 0;
if (isCommon) expectedCommonHits += 1;
const text = isRare
? `oldest message containing ${RARE_NEEDLE} keyword`
: isCommon
? `message ${globalIndex} mentioning ${COMMON_NEEDLE} fruit`
: `ordinary message number ${globalIndex} with filler words`;
rows.push({
id: v4() as UUID,
type: "messages",
content: { text },
entityId,
agentId: testAgentId,
roomId,
worldId,
unique: false,
metadata: { type: "messages", source: "test" },
createdAt: new Date(baseTime + globalIndex * 1_000),
});
globalIndex += 1;
if (rows.length >= INSERT_CHUNK) await flush();
}
}
await flush();
}, 600_000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it("seeded the full-scale fixture", async () => {
expect(TOTAL_MESSAGES).toBeGreaterThanOrEqual(200_000);
expect(ROOMS).toBeGreaterThanOrEqual(2_000);
});
it("uses a pushed-down SQL ILIKE, not a full-table JS scan (EXPLAIN)", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
// The escaping mirrors base.ts; here the needle has no metacharacters.
const result = await db.execute(
`EXPLAIN SELECT id FROM memories WHERE type = 'messages' AND content->>'text' ILIKE '%${COMMON_NEEDLE}%'`
);
// Postgres normalizes ILIKE to the `~~*` operator and surfaces it as a
// Filter / Index Cond on `content ->> 'text'`. Its presence proves the
// keyword predicate is evaluated by the DATABASE on this query — a JS
// search would never appear in any DB plan.
const plan = JSON.stringify(result).toLowerCase();
expect(plan).toContain("~~*");
expect(plan).toContain("content ->> 'text'");
});
it("finds the rare needle — the OLDEST row, far outside the recent window", async () => {
const started = Date.now();
const results = await runtime.getMemories({
tableName: "messages",
textContains: RARE_NEEDLE,
includeEmbedding: false,
limit: 50,
});
const elapsed = Date.now() - started;
expect(results).toHaveLength(1);
const hit = results[0];
expect((hit.content as { text?: string }).text).toContain(RARE_NEEDLE);
expect(hit.roomId).toBe(firstRoomId);
// The hit is the single oldest message (index 0) of 200k+ — proving the
// keyword path reached past the most-recent 200 window into the full table.
expect(elapsed).toBeLessThan(5_000);
});
it("returns all common-needle hits across conversations, ranked", async () => {
const started = Date.now();
const results = await runtime.getMemories({
tableName: "messages",
textContains: COMMON_NEEDLE,
includeEmbedding: false,
// High cap; every returned row already matched in SQL.
limit: 100_000,
});
const elapsed = Date.now() - started;
expect(results.length).toBe(expectedCommonHits);
for (const hit of results) {
expect((hit.content as { text?: string }).text?.toLowerCase()).toContain(COMMON_NEEDLE);
}
// Hits span many distinct rooms (cross-conversation), not just one.
const distinctRooms = new Set(results.map((r) => r.roomId));
expect(distinctRooms.size).toBeGreaterThan(10);
// Bounded latency even though the fixture has 200k+ rows: the DB discarded
// every non-matching row before returning.
expect(elapsed).toBeLessThan(8_000);
});
it("is case-insensitive", async () => {
const lower = await runtime.getMemories({
tableName: "messages",
textContains: COMMON_NEEDLE.toLowerCase(),
includeEmbedding: false,
limit: 100_000,
});
const upper = await runtime.getMemories({
tableName: "messages",
textContains: COMMON_NEEDLE.toUpperCase(),
includeEmbedding: false,
limit: 100_000,
});
expect(upper.length).toBe(lower.length);
expect(upper.length).toBe(expectedCommonHits);
});
it("returns nothing for a needle that does not occur", async () => {
const results = await runtime.getMemories({
tableName: "messages",
textContains: "this-substring-was-never-seeded-anywhere",
includeEmbedding: false,
limit: 100,
});
expect(results).toHaveLength(0);
});
it("treats LIKE metacharacters as literals (no wildcard expansion)", async () => {
// '%' would match everything if not escaped; the seeded text never contains
// a literal '%', so a literal-substring search must return zero rows.
const results = await runtime.getMemories({
tableName: "messages",
textContains: "%",
includeEmbedding: false,
limit: 100,
});
expect(results).toHaveLength(0);
});
});
@@ -0,0 +1,214 @@
/**
* Focused correctness test for the keyword (`textContains`) predicate on the
* SQL `getMemories` path — the small, fast complement to the 200k-row
* `memory-keyword-search.real.test.ts` scale test.
*
* It pins the load-bearing details the scale test cannot exercise cheaply:
* literal `_` and backslash escaping (not just `%`), AND-composition with the
* existing `roomId` / `tableName` filters, and that the predicate targets
* `content->>'text'` ONLY (a token hidden in another `content` field or in
* `metadata` must NOT match). Runs on PGlite by default; set `POSTGRES_URL`
* to run against a real Postgres.
*/
import {
ChannelType,
type Content,
type Entity,
type Memory,
type MemoryMetadata,
MemoryType,
type Room,
type UUID,
type World,
} from "@elizaos/core";
import { v4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { embeddingTable, memoryTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("getMemories textContains keyword filter (real SQL adapter)", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let roomA: UUID;
let roomB: UUID;
let entityId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("memory_text_contains");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
const worldId = v4() as UUID;
await adapter.createWorld({
id: worldId,
agentId: testAgentId,
name: "TextContains World",
serverId: "text-contains-server",
} as World);
roomA = v4() as UUID;
roomB = v4() as UUID;
await adapter.createRooms([
{
id: roomA,
agentId: testAgentId,
worldId,
name: "room-a",
source: "test",
type: ChannelType.DM,
} as Room,
{
id: roomB,
agentId: testAgentId,
worldId,
name: "room-b",
source: "test",
type: ChannelType.DM,
} as Room,
]);
entityId = v4() as UUID;
await adapter.createEntities([
{ id: entityId, agentId: testAgentId, names: ["Text Entity"] } as Entity,
]);
await adapter.addParticipant(entityId, roomA);
await adapter.addParticipant(entityId, roomB);
});
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
await db.delete(embeddingTable);
await db.delete(memoryTable);
});
/** Seed one row (defaults to the `messages` table in `roomA`). */
const seed = async (
content: Content,
options: { roomId?: UUID; tableName?: string } = {}
): Promise<void> => {
const memory: Memory & { metadata: MemoryMetadata } = {
id: v4() as UUID,
agentId: testAgentId,
roomId: options.roomId ?? roomA,
entityId,
content,
createdAt: Date.now(),
unique: false,
metadata: { type: MemoryType.MESSAGE, source: "test" },
};
await adapter.createMemory(memory, options.tableName ?? "messages");
};
const textsFor = async (
textContains: string,
options: { roomId?: UUID; tableName?: string } = {}
): Promise<string[]> => {
const rows = await adapter.getMemories({
tableName: options.tableName ?? "messages",
roomId: options.roomId ?? roomA,
textContains,
includeEmbedding: false,
});
return rows
.map((row) => row.content.text)
.filter((text): text is string => typeof text === "string");
};
it("matches case-insensitively (ILIKE, not LIKE)", async () => {
await seed({ text: "Hello World" });
await seed({ text: "goodbye" });
for (const needle of ["hello", "HELLO", "HeLLo"]) {
const texts = await textsFor(needle);
expect(texts).toEqual(["Hello World"]);
}
});
it("matches an arbitrary substring, not just a prefix or whole word", async () => {
await seed({ text: "the quick brown fox" });
expect(await textsFor("quick")).toEqual(["the quick brown fox"]);
expect(await textsFor("own fo")).toEqual(["the quick brown fox"]);
expect(await textsFor("brown fox")).toEqual(["the quick brown fox"]);
});
it("returns nothing when no row contains the keyword", async () => {
await seed({ text: "alpha" });
await seed({ text: "beta" });
expect(await textsFor("gamma")).toEqual([]);
});
it("escapes a literal `%` so it is not a wildcard", async () => {
await seed({ text: "50% off sale" });
await seed({ text: "50 percent off" });
// Unescaped, `%50%%` would also match "50 percent off".
expect(await textsFor("50%")).toEqual(["50% off sale"]);
});
it("escapes a literal `_` so it is not a single-char wildcard", async () => {
await seed({ text: "user_name field" });
await seed({ text: "username field" });
await seed({ text: "userXname field" });
// Unescaped, `_` would match the X and the absent char too.
expect(await textsFor("user_name")).toEqual(["user_name field"]);
});
it("escapes a literal backslash so the ESCAPE char does not corrupt the pattern", async () => {
await seed({ text: "path\\to\\file" });
await seed({ text: "pathtofile" });
expect(await textsFor("path\\to")).toEqual(["path\\to\\file"]);
});
it("treats an empty / whitespace query as a no-op filter", async () => {
await seed({ text: "one" });
await seed({ text: "two" });
await seed({ text: "three" });
expect((await textsFor("")).sort()).toEqual(["one", "three", "two"]);
expect((await textsFor(" ")).sort()).toEqual(["one", "three", "two"]);
});
it("AND-combines with the roomId filter (scoped, not OR)", async () => {
await seed({ text: "find me here" }, { roomId: roomA });
await seed({ text: "find me there" }, { roomId: roomB });
expect(await textsFor("find", { roomId: roomA })).toEqual(["find me here"]);
expect(await textsFor("find", { roomId: roomB })).toEqual(["find me there"]);
});
it("AND-combines with the tableName (type) filter", async () => {
await seed({ text: "find me in messages" }, { tableName: "messages" });
await seed({ text: "find me in memories" }, { tableName: "memories" });
expect(await textsFor("find", { tableName: "messages" })).toEqual(["find me in messages"]);
expect(await textsFor("find", { tableName: "memories" })).toEqual(["find me in memories"]);
});
it("matches `content->>'text'` ONLY — not other content fields or metadata", async () => {
// The needle lives in `content.thought` / `content.source`, never in
// `content.text`. A whole-jsonb `content::text ILIKE` would wrongly match;
// the `->>'text'` extraction must not.
await seed({
text: "totally ordinary message body",
thought: "zzqhiddenxx internal reasoning",
source: "zzqhiddenxx",
});
// A control row whose text DOES carry the needle, so a positive match exists.
await seed({ text: "this line mentions zzqhiddenxx directly" });
expect(await textsFor("zzqhiddenxx")).toEqual(["this line mentions zzqhiddenxx directly"]);
});
});
@@ -0,0 +1,160 @@
/**
* Every memory READ path (`getMemories`, `getMemoriesByRoomIds`,
* `getMemoryById`, `getMemoriesByIds`) must map the stored `worldId` back
* onto the returned `Memory`, against a real isolated PGlite/Postgres
* adapter — consumers that round-trip a fetched memory (e.g. agent-export
* restore, which remaps `mem.worldId`) depend on every path agreeing.
*/
import {
ChannelType,
type Entity,
type Memory,
type Room,
type UUID,
type World,
} from "@elizaos/core";
import { v4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Memory worldId round-trip", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testRoomId: UUID;
let testEntityId: UUID;
let testWorldId: UUID;
let memoryId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("memory_worldid_tests");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
testRoomId = v4() as UUID;
testEntityId = v4() as UUID;
testWorldId = v4() as UUID;
await adapter.createWorld({
id: testWorldId,
agentId: testAgentId,
name: "WorldId Test World",
serverId: "test-server",
} as World);
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
worldId: testWorldId,
name: "WorldId Test Room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["WorldId Test Entity"],
} as Entity,
]);
await adapter.addParticipant(testEntityId, testRoomId);
memoryId = await adapter.createMemory(
{
id: v4() as UUID,
entityId: testEntityId,
agentId: testAgentId,
roomId: testRoomId,
worldId: testWorldId,
content: { text: "memory with a world" },
} as Memory,
"messages"
);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
it("getMemories returns worldId (embedding branch)", async () => {
const memories = await adapter.getMemories({
tableName: "messages",
roomId: testRoomId,
});
expect(memories).toHaveLength(1);
expect(memories[0].worldId).toBe(testWorldId);
});
it("getMemories returns worldId (includeEmbedding: false branch)", async () => {
const memories = await adapter.getMemories({
tableName: "messages",
roomId: testRoomId,
includeEmbedding: false,
});
expect(memories).toHaveLength(1);
expect(memories[0].worldId).toBe(testWorldId);
});
it("getMemories filtered by worldId returns rows that carry that worldId", async () => {
const memories = await adapter.getMemories({
tableName: "messages",
worldId: testWorldId,
});
expect(memories).toHaveLength(1);
expect(memories[0].worldId).toBe(testWorldId);
});
it("getMemoryById returns worldId", async () => {
const memory = await adapter.getMemoryById(memoryId);
expect(memory).not.toBeNull();
expect(memory?.worldId).toBe(testWorldId);
});
it("getMemoriesByIds returns worldId", async () => {
const memories = await adapter.getMemoriesByIds([memoryId], "messages");
expect(memories).toHaveLength(1);
expect(memories[0].worldId).toBe(testWorldId);
});
it("getMemoriesByRoomIds returns worldId", async () => {
const memories = await adapter.getMemoriesByRoomIds({
roomIds: [testRoomId],
tableName: "messages",
});
expect(memories).toHaveLength(1);
expect(memories[0].worldId).toBe(testWorldId);
});
it("keeps worldId undefined for memories stored without one", async () => {
const noWorldRoomId = v4() as UUID;
await adapter.createRooms([
{
id: noWorldRoomId,
agentId: testAgentId,
name: "No-World Room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
const noWorldMemoryId = await adapter.createMemory(
{
id: v4() as UUID,
entityId: testEntityId,
agentId: testAgentId,
roomId: noWorldRoomId,
content: { text: "memory without a world" },
} as Memory,
"messages"
);
const memory = await adapter.getMemoryById(noWorldMemoryId);
expect(memory).not.toBeNull();
expect(memory?.worldId).toBeUndefined();
});
});
@@ -0,0 +1,908 @@
/**
* Integration tests for the memory CRUD/retrieval/search surface against a
* real isolated PGlite/Postgres adapter: create/update/delete, partial and
* nested-partial metadata updates, room/id-list/pagination reads, embedding
* search, document+fragment cascade delete, and Memory<->MemoryModel field
* mapping.
*/
import {
ChannelType,
type Content,
type Entity,
type Memory,
type MemoryMetadata,
MemoryType,
type Room,
type UUID,
type World,
} from "@elizaos/core";
import { v4 } from "uuid";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { embeddingTable, memoryTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
import {
documentMemoryId,
memoryTestAgentId,
memoryTestDocument,
memoryTestFragments,
memoryTestMemories,
memoryTestMemoriesWithEmbedding,
} from "./seed";
const normalizeSignedZeroes = (embedding: number[] | null | undefined) =>
embedding?.map((value) => (Object.is(value, -0) ? 0 : value));
describe("Memory Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let runtime: Awaited<ReturnType<typeof createIsolatedTestDatabase>>["runtime"];
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testRoomId: UUID;
let testEntityId: UUID;
let testWorldId: UUID;
beforeAll(async () => {
try {
const setup = await createIsolatedTestDatabase("memory_tests");
adapter = setup.adapter;
runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
testRoomId = v4() as UUID;
testEntityId = v4() as UUID;
testWorldId = v4() as UUID;
await adapter.createWorld({
id: testWorldId,
agentId: testAgentId,
name: "Test World",
serverId: "test-server",
} as World);
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
worldId: testWorldId,
name: "Test Room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
]);
await adapter.addParticipant(testEntityId, testRoomId);
} catch (error) {
console.error("Failed to create test database for memory tests:", error);
throw error;
}
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
beforeEach(async () => {
// Clean up memories and embeddings before each test
const db = adapter.getDatabase() as DrizzleDatabase;
// Delete embeddings first due to foreign key constraints
await db.delete(embeddingTable);
await db.delete(memoryTable);
});
const createTestMemory = (
content: Content,
embedding?: number[]
): Memory & { metadata: MemoryMetadata } => ({
id: v4() as UUID,
agentId: testAgentId,
roomId: testRoomId,
entityId: testEntityId,
content,
embedding,
createdAt: Date.now(),
unique: false,
metadata: {
type: MemoryType.CUSTOM,
source: "test",
},
});
it("should create and retrieve a memory with an embedding", async () => {
const memory = createTestMemory(
{ text: "test" },
Array.from({ length: 384 }, () => Math.random())
);
const memoryId = await adapter.createMemory(memory, "test");
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeDefined();
if (!retrieved) throw new Error("Memory should exist");
if (!retrieved.embedding) throw new Error("Embedding should exist");
expect(retrieved.embedding.length).toEqual(384);
});
afterEach(async () => {
// Clean up memories after each test to ensure isolation
const db = adapter.getDatabase() as DrizzleDatabase;
// Delete in correct order to avoid foreign key constraint violations
await db.delete(embeddingTable);
await db.delete(memoryTable);
});
describe("Memory CRUD Operations", () => {
it("should create a simple memory without embedding", async () => {
const memory = createTestMemory({ text: "simple memory" });
const memoryId = await adapter.createMemory(memory, "memories");
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeDefined();
if (!retrieved) throw new Error("Memory should exist");
expect(retrieved.content).toEqual({ text: "simple memory" });
});
it("should count memories through the runtime object contract", async () => {
await adapter.createMemory(createTestMemory({ text: "message one" }), "messages");
await adapter.createMemory(createTestMemory({ text: "message two" }), "messages");
const count = await runtime.countMemories({
roomId: testRoomId,
unique: false,
tableName: "messages",
});
expect(count).toBe(2);
});
it("should default runtime countMemories to the messages table", async () => {
await adapter.createMemory(createTestMemory({ text: "message one" }), "messages");
await adapter.createMemory(createTestMemory({ text: "message two" }), "messages");
const count = await runtime.countMemories({
roomId: testRoomId,
unique: false,
} as never);
expect(count).toBe(2);
});
it("should update an existing memory", async () => {
const memory = createTestMemory({ text: "original" });
const memoryId = await adapter.createMemory(memory, "memories");
await adapter.updateMemory({
id: memoryId,
content: { text: "updated" },
});
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).not.toBeNull();
if (!retrieved) throw new Error("Memory should exist");
expect(retrieved.content).toEqual({ text: "updated" });
});
it("should delete a memory", async () => {
const memory = createTestMemory({ text: "to be deleted" });
const memoryId = await adapter.createMemory(memory, "memories");
let retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeDefined();
await adapter.deleteMemory(memoryId);
retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeNull();
});
it("should create a memory with embedding", async () => {
const memory: Memory = {
id: v4() as UUID,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: { text: "memory with embedding" },
createdAt: Date.now(),
embedding: Array.from({ length: 384 }, () => Math.random()),
};
const memoryId = await adapter.createMemory(memory, "memories");
const createdMemory = await adapter.getMemoryById(memoryId);
expect(createdMemory).not.toBeNull();
if (!createdMemory) throw new Error("Memory should exist");
expect(createdMemory.embedding).toBeDefined();
if (!createdMemory.embedding) throw new Error("Embedding should exist");
expect(createdMemory.embedding.length).toBe(384);
});
it("should perform partial updates without affecting other fields", async () => {
const memory = {
...memoryTestMemoriesWithEmbedding[0],
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
metadata: {
type: "test-original",
source: "integration-test",
tags: ["original", "test"],
timestamp: 1000,
},
};
const memoryId = await adapter.createMemory(memory, "memories");
const contentUpdate = {
id: memoryId,
content: {
text: "This is updated content only",
type: "text",
},
};
await adapter.updateMemory(contentUpdate);
const afterContentUpdate = await adapter.getMemoryById(memoryId);
expect(afterContentUpdate).not.toBeNull();
if (!afterContentUpdate) throw new Error("Memory should exist");
const content = afterContentUpdate.content as Record<string, unknown>;
expect(content.text).toBe("This is updated content only");
expect(normalizeSignedZeroes(afterContentUpdate.embedding)).toEqual(
normalizeSignedZeroes(memory.embedding as number[])
);
expect(afterContentUpdate.metadata).toEqual(memory.metadata);
const metadataUpdate = {
id: memoryId,
metadata: {
type: "test-original",
source: "updated-source", // Only updating the source field
tags: ["original", "test"],
timestamp: 1000,
},
};
await adapter.updateMemory(metadataUpdate);
const afterMetadataUpdate = await adapter.getMemoryById(memoryId);
expect(afterMetadataUpdate).not.toBeNull();
if (!afterMetadataUpdate) throw new Error("Memory should exist");
const contentAfter = afterMetadataUpdate.content as Record<string, unknown>;
expect(contentAfter.text).toBe("This is updated content only");
const metadataAfter = afterMetadataUpdate.metadata as Record<string, unknown>;
if (!metadataAfter) throw new Error("Metadata should exist");
expect(metadataAfter.type).toBe("test-original");
expect(metadataAfter.source).toBe("updated-source");
expect(metadataAfter.tags).toEqual(["original", "test"]);
expect(metadataAfter.timestamp).toBe(1000);
});
it("should perform nested partial updates without overriding existing fields", async () => {
const originalMemory = {
...memoryTestMemoriesWithEmbedding[0],
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
content: {
text: "Original content text",
type: "text",
additionalInfo: "This should be preserved",
},
metadata: {
type: "test-original",
source: "integration-test",
tags: ["original", "test"],
timestamp: 1000,
},
};
const memoryId = await adapter.createMemory(originalMemory, "memories");
// When updating content, we must include the full content object
// since partial updates fully replace the content object
const contentTextUpdate = {
id: memoryId,
content: {
text: "Updated text only",
type: "text",
additionalInfo: "This should be preserved",
},
};
await adapter.updateMemory(contentTextUpdate);
const afterContentTextUpdate = await adapter.getMemoryById(memoryId);
expect(afterContentTextUpdate).not.toBeNull();
if (!afterContentTextUpdate) throw new Error("Memory should exist");
const contentAfterText = afterContentTextUpdate.content as Record<string, unknown>;
expect(contentAfterText.text).toBe("Updated text only");
expect(contentAfterText.type).toBe("text");
expect(contentAfterText.additionalInfo).toBe("This should be preserved");
expect(afterContentTextUpdate.metadata).toEqual(originalMemory.metadata);
// Update only source field in metadata, but must include all metadata fields
// since partial updates fully replace the metadata object
const sourceUpdate = {
id: memoryId,
metadata: {
type: "test-original",
source: "updated-source",
tags: ["original", "test"],
timestamp: 1000,
},
};
await adapter.updateMemory(sourceUpdate);
const afterSourceUpdate = await adapter.getMemoryById(memoryId);
expect(afterSourceUpdate).not.toBeNull();
if (!afterSourceUpdate || !afterContentTextUpdate) throw new Error("Memory should exist");
expect(afterSourceUpdate.content).toEqual(afterContentTextUpdate.content as Content);
const metadataAfterSource = afterSourceUpdate.metadata as Record<string, unknown>;
if (!metadataAfterSource) throw new Error("Metadata should exist");
expect(metadataAfterSource.type).toBe("test-original");
expect(metadataAfterSource.source).toBe("updated-source");
expect(metadataAfterSource.tags).toEqual(["original", "test"]);
expect(metadataAfterSource.timestamp).toBe(1000);
});
});
describe("Memory Retrieval Operations", () => {
it("should retrieve memories by room ID", async () => {
await adapter.createMemory(createTestMemory({ text: "mem1" }), "messages");
await adapter.createMemory(createTestMemory({ text: "mem2" }), "messages");
const memories = await adapter.getMemories({
roomId: testRoomId,
tableName: "messages",
});
expect(memories.length).toBe(2);
});
it("should respect start/end filters when timestamp is 0", async () => {
await adapter.createMemory(
{
...createTestMemory({ text: "epoch-message" }),
createdAt: 0,
},
"messages"
);
await adapter.createMemory(
{
...createTestMemory({ text: "later-message" }),
createdAt: 10,
},
"messages"
);
const epochOnly = await adapter.getMemories({
roomId: testRoomId,
tableName: "messages",
start: 0,
end: 0,
});
expect(epochOnly).toHaveLength(1);
expect(epochOnly[0].content.text).toBe("epoch-message");
expect(epochOnly[0].createdAt).toBe(0);
});
it("should count memories in a room", async () => {
await adapter.createMemory(createTestMemory({ text: "mem1" }), "memories");
await adapter.createMemory(createTestMemory({ text: "mem2" }), "memories");
const count = await adapter.countMemories(testRoomId, false, "memories");
expect(count).toBe(2);
});
it("should require tableName on reads and default counts to the messages table", async () => {
await adapter.createMemory(createTestMemory({ text: "message one" }), "messages");
await adapter.createMemory(createTestMemory({ text: "message two" }), "messages");
// Seed a different table to prove reads/counts are table-scoped.
await adapter.createMemory(createTestMemory({ text: "fact one" }), "facts");
// getMemories has NO default table: tableName is required by the
// IDatabaseAdapter contract (packages/core/src/types/database.ts), and
// omitting it (only possible by bypassing the types) is a loud error,
// not a silent empty read.
await expect(adapter.getMemories({ roomId: testRoomId } as never)).rejects.toThrow(
/tableName/
);
const memories = await adapter.getMemories({
roomId: testRoomId,
tableName: "messages",
});
expect(memories).toHaveLength(2);
expect(memories.map((memory) => memory.content.text)).toEqual(
expect.arrayContaining(["message one", "message two"])
);
// countMemories keeps its documented legacy default: an omitted
// tableName counts the messages table only (the "facts" row is excluded).
const count = await adapter.countMemories(testRoomId, false);
expect(count).toBe(2);
});
it("should retrieve memories by ID list", async () => {
const memoryIds: UUID[] = [];
for (const memory of memoryTestMemories.slice(0, 2)) {
const testMemory = {
...memory,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
};
const memoryId = await adapter.createMemory(testMemory, "memories");
memoryIds.push(memoryId);
}
const memories = await adapter.getMemoriesByIds(memoryIds, "memories");
expect(memories).toHaveLength(2);
expect(memories.map((m) => m.id)).toEqual(expect.arrayContaining(memoryIds));
});
it("should retrieve memories with pagination", async () => {
for (const memory of memoryTestMemories) {
const testMemory = {
...memory,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
};
await adapter.createMemory(testMemory, "memories");
}
const firstPage = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: 2,
});
expect(firstPage).toHaveLength(2);
const secondPage = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
});
expect(secondPage.length).toBeGreaterThanOrEqual(memoryTestMemories.length);
});
it("should apply a LIMIT clause when only `limit` is passed (not `count`)", async () => {
for (const content of ["lim1", "lim2", "lim3", "lim4", "lim5"]) {
await adapter.createMemory(createTestMemory({ text: content }), "memories");
}
const limited = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
limit: 2,
});
expect(limited).toHaveLength(2);
// `limit` should compose with `offset` just like `count` does.
const limitedWithOffset = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
limit: 2,
offset: 2,
});
expect(limitedWithOffset).toHaveLength(2);
const firstIds = new Set(limited.map((m) => m.id));
for (const memory of limitedWithOffset) {
expect(firstIds.has(memory.id)).toBe(false);
}
});
it("should retrieve memories with offset for pagination", async () => {
const memoryContents = ["mem1", "mem2", "mem3", "mem4", "mem5"];
for (const content of memoryContents) {
await adapter.createMemory(createTestMemory({ text: content }), "memories");
}
const firstPage = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: 2,
offset: 0,
});
expect(firstPage).toHaveLength(2);
const secondPage = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: 2,
offset: 2,
});
expect(secondPage).toHaveLength(2);
const thirdPage = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: 2,
offset: 4,
});
expect(thirdPage).toHaveLength(1);
const allIds = [...firstPage, ...secondPage, ...thirdPage].map((m) => m.id);
const uniqueIds = new Set(allIds);
expect(allIds.length).toBe(uniqueIds.size);
});
it("should handle offset without count parameter", async () => {
for (let i = 0; i < 5; i++) {
await adapter.createMemory(createTestMemory({ text: `mem${i}` }), "memories");
}
const allMemories = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
});
expect(allMemories.length).toBe(5);
const withOffset = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
offset: 2,
});
expect(withOffset.length).toBe(3);
const lastThreeIds = allMemories.slice(2).map((m) => m.id);
const offsetIds = withOffset.map((m) => m.id);
expect(offsetIds).toEqual(lastThreeIds);
});
it("should handle edge cases for offset pagination", async () => {
for (let i = 0; i < 3; i++) {
await adapter.createMemory(createTestMemory({ text: `mem${i}` }), "memories");
}
const beyondOffset = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: 2,
offset: 10,
});
expect(beyondOffset.length).toBe(0);
// Offset of 0 should behave like no offset
const zeroOffset = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: 2,
offset: 0,
});
expect(zeroOffset.length).toBe(2);
// No offset should return all (up to count limit)
const noOffset = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: 2,
});
expect(noOffset.length).toBe(2);
expect(noOffset.map((m) => m.id)).toEqual(zeroOffset.map((m) => m.id));
});
it("should reject negative offset values", async () => {
await adapter.createMemory(createTestMemory({ text: "test" }), "memories");
await expect(
adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
offset: -1,
})
).rejects.toThrow("offset must be a non-negative number");
await expect(
adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: 5,
offset: -10,
})
).rejects.toThrow("offset must be a non-negative number");
});
it("should maintain consistent pagination results with countMemories", async () => {
const totalMemories = 10;
for (let i = 0; i < totalMemories; i++) {
await adapter.createMemory(createTestMemory({ text: `mem${i}` }), "memories");
}
const totalCount = await adapter.countMemories(testRoomId, false, "memories");
expect(totalCount).toBe(totalMemories);
const pageSize = 3;
const totalPages = Math.ceil(totalCount / pageSize);
const allPaginatedMemories: Memory[] = [];
for (let page = 0; page < totalPages; page++) {
const pageMemories = await adapter.getMemories({
roomId: testRoomId,
tableName: "memories",
count: pageSize,
offset: page * pageSize,
});
allPaginatedMemories.push(...pageMemories);
}
expect(allPaginatedMemories.length).toBe(totalMemories);
const ids = allPaginatedMemories.map((m) => m.id);
const uniqueIds = new Set(ids);
expect(ids.length).toBe(uniqueIds.size);
});
});
describe("Memory Search Operations", () => {
it("should search memories by embedding similarity", async () => {
const baseEmbedding = Array.from({ length: 384 }, () => Math.random());
const memory1: Partial<Memory> = {
id: v4() as UUID,
content: { text: "memory 1" },
createdAt: Date.now(),
embedding: baseEmbedding,
};
memory1.agentId = testAgentId;
memory1.roomId = testRoomId;
memory1.entityId = testEntityId;
await adapter.createMemory(memory1 as Memory, "search");
const results = await adapter.searchMemoriesByEmbedding(baseEmbedding, {
tableName: "search",
roomId: testRoomId,
count: 1,
});
expect(results.length).toBe(1);
expect(results[0].id).toBe(memory1.id as UUID);
expect(results[0].similarity).toBeGreaterThan(0.99);
});
});
describe("Document and Fragment Operations", () => {
it("should create a document with fragments", async () => {
const testDocument = {
...memoryTestDocument,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
};
await adapter.createMemory(testDocument, "documents");
for (const fragment of memoryTestFragments) {
const testFragment = {
...fragment,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
};
await adapter.createMemory(testFragment, "fragments");
}
const fragments = await adapter.getMemories({
tableName: "fragments",
roomId: testRoomId,
});
expect(fragments.length).toEqual(memoryTestFragments.length);
});
it("should delete a document and its fragments", async () => {
const testDocument = {
...memoryTestDocument,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
};
await adapter.createMemory(testDocument, "documents");
for (const fragment of memoryTestFragments) {
const testFragment = {
...fragment,
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
};
await adapter.createMemory(testFragment, "fragments");
}
// Deleting the document must cascade to its fragments.
await adapter.deleteMemory(documentMemoryId);
const document = await adapter.getMemoryById(documentMemoryId);
expect(document).toBeNull();
const fragments = await adapter.getMemories({
tableName: "fragments",
roomId: testRoomId,
});
expect(fragments.length).toBe(0);
});
});
describe("Memory Model Mapping", () => {
it("should correctly map between Memory and MemoryModel", async () => {
const testMemory = {
...memoryTestMemories[0],
agentId: testAgentId,
entityId: testEntityId,
roomId: testRoomId,
};
await adapter.createMemory(testMemory, "memories");
const retrievedMemory = await adapter.getMemoryById(testMemory.id as UUID);
expect(retrievedMemory).not.toBeNull();
if (!retrievedMemory) throw new Error("Memory should exist");
expect(retrievedMemory.id).toBe(testMemory.id as UUID);
expect(retrievedMemory.entityId).toBe(testMemory.entityId);
expect(retrievedMemory.roomId).toBe(testMemory.roomId);
expect(retrievedMemory.agentId).toBe(testMemory.agentId);
const content = retrievedMemory.content as Record<string, unknown>;
expect(content.text).toBe(testMemory.content.text as string);
const metadata = retrievedMemory.metadata as Record<string, unknown>;
if (testMemory.metadata && metadata) {
expect(metadata.type).toBe(testMemory.metadata.type as string);
}
});
it("should handle partial Memory objects in mapToMemoryModel", async () => {
const uniqueEntityId = v4() as UUID;
await adapter.createEntities([
{
id: uniqueEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
]);
const partialMemory: Partial<Memory> = {
id: memoryTestAgentId,
entityId: uniqueEntityId,
roomId: testRoomId,
agentId: testAgentId,
content: {
text: "Partial memory object",
type: "text",
},
};
await adapter.createMemory(partialMemory as Partial<Memory>, "memories");
const retrievedMemory = await adapter.getMemoryById(partialMemory.id as UUID);
expect(retrievedMemory).not.toBeNull();
if (!retrievedMemory) throw new Error("Memory should exist");
expect(retrievedMemory.id).toBe(partialMemory.id);
expect(retrievedMemory.entityId).toBe(partialMemory.entityId);
expect(retrievedMemory.roomId).toBe(partialMemory.roomId);
const content = retrievedMemory.content as Record<string, unknown>;
const partialContent = partialMemory.content as Record<string, unknown> | undefined;
expect(content.text).toBe(partialContent?.text);
expect(retrievedMemory.unique).toBe(true); // Default value
expect(retrievedMemory.metadata).toBeDefined(); // Default empty object
});
});
describe("Memory Batch Operations", () => {
it("should delete all memories in a room", async () => {
const uniqueEntityId = v4() as UUID;
await adapter.createEntities([
{
id: uniqueEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
]);
for (const memory of memoryTestMemories) {
const testMemory = {
...memory,
agentId: testAgentId,
entityId: uniqueEntityId,
roomId: testRoomId,
};
await adapter.createMemory(testMemory, "memories");
}
const countBefore = await adapter.countMemories(testRoomId, true, "memories");
expect(countBefore).toBeGreaterThan(0);
await adapter.deleteAllMemories(testRoomId, "memories");
const countAfter = await adapter.countMemories(testRoomId, true, "memories");
expect(countAfter).toBe(0);
});
it("should retrieve memories by multiple room IDs", async () => {
const secondRoomId = v4() as UUID;
await adapter.createRooms([
{
id: secondRoomId,
name: "Memory Test Room 2",
agentId: testAgentId,
source: "test",
type: ChannelType.GROUP,
worldId: testWorldId,
},
]);
await adapter.createMemory(createTestMemory({ text: "mem1-room1" }), "memories");
await adapter.createMemory(createTestMemory({ text: "mem2-room1" }), "memories");
await adapter.createMemory(
{ ...createTestMemory({ text: "mem3-room2" }), roomId: secondRoomId },
"memories"
);
const memories = await adapter.getMemoriesByRoomIds({
roomIds: [testRoomId, secondRoomId],
tableName: "memories",
});
expect(memories.length).toEqual(3);
});
});
it("should properly convert metadata objects to JSON when updating only metadata", async () => {
await adapter.ensureEmbeddingDimension(768);
const memory = {
entityId: testEntityId,
roomId: testRoomId,
worldId: testWorldId,
agentId: testAgentId,
content: { text: "Initial content" },
embedding: Array.from({ length: 768 }, (_, i) => i / 768),
metadata: {
type: "initial",
source: "test",
tags: ["test"],
nested: {
value: 123,
flag: true,
},
},
};
const memoryId = await adapter.createMemory(memory, "memory");
expect(memoryId).toBeDefined();
const complexMetadata = {
type: "updated",
source: "test-update",
tags: ["updated", "test"],
nested: {
value: 456,
flag: false,
deeper: {
array: [1, 2, 3],
string: "test",
},
},
timestamp: Date.now(),
};
// This should not throw a PostgreSQL jsonb cast error
const updateResult = await adapter.updateMemory({
id: memoryId,
metadata: complexMetadata,
});
expect(updateResult).toBe(true);
const updated = await adapter.getMemoryById(memoryId);
expect(updated).not.toBeNull();
if (!updated) throw new Error("Memory should exist");
expect(updated.metadata).toEqual(complexMetadata);
const content = updated.content as Record<string, unknown>;
expect(content.text).toBe("Initial content");
});
});
@@ -0,0 +1,349 @@
/**
* Real-store correctness suite for corpus-wide chat message search (#13534,
* follow-up #9955). Drives `IDatabaseAdapter.searchMessages` against a REAL
* PGlite adapter with the actual migration-installed FTS + `pg_trgm` objects
* (`eliza_message_search_document`, `eliza_search_fold`, the two GIN indexes) —
* no mock stands in for the store. Asserts the acceptance-criteria edge cases:
* multi-word non-adjacent (`websearch_to_tsquery`), case/accent/apostrophe
* folding, partial-word + typo (trigram), stemming, code metacharacters treated
* literally, URL/emoji/CJK substrings, quoted phrase vs bare-term semantics,
* near-duplicate deterministic ordering, attachment-filename indexing, deleted-
* row exclusion, room access-scoping, and corpus-wide recall of a hit far older
* than any recency window on a multi-thousand-row corpus. Default harness is
* PGlite (WASM, in-process); set `POSTGRES_URL` to run against real Postgres.
*/
import { ChannelType, type Entity, type Room, type UUID, type World } from "@elizaos/core";
import { sql } from "drizzle-orm";
import { v4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { memoryTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
const RECALL_CORPUS = Number(process.env.MESSAGE_SEARCH_RECALL_CORPUS ?? 3_000);
describe("searchMessages FTS + trigram (real DB)", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let agentId: UUID;
let entityId: UUID;
let worldId: UUID;
let trigramAvailable = false;
const roomA = v4() as UUID;
const roomB = v4() as UUID;
let seq = 0;
const seed = async (
roomId: UUID,
text: string,
author: "user" | "assistant" = "user",
attachments?: Array<{ title?: string; url?: string }>
): Promise<UUID> => {
const id = v4() as UUID;
const content: Record<string, unknown> = { text };
if (attachments) content.attachments = attachments;
await adapter.createMemory(
{
id,
entityId: author === "assistant" ? agentId : entityId,
agentId,
roomId,
worldId,
content,
metadata: { type: "messages" },
createdAt: Date.now() + seq++ * 1000,
} as never,
"messages"
);
return id;
};
const searchTexts = async (query: string, room: UUID = roomA) => {
const hits = await adapter.searchMessages({
roomIds: [room],
query,
tableName: "messages",
limit: 50,
});
return hits.map((h) => (h.memory.content as { text?: string }).text ?? "");
};
beforeAll(async () => {
const s = await createIsolatedTestDatabase("message_search_fts");
adapter = s.adapter;
cleanup = s.cleanup;
agentId = s.testAgentId;
try {
await (adapter.getDatabase() as DrizzleDatabase).execute(
sql`SELECT similarity('configuration', 'configuraton')`
);
trigramAvailable = true;
} catch {
// error-policy:J3 extension probing intentionally treats unsupported
// `pg_trgm` as absent in the PGlite harness.
trigramAvailable = false;
}
worldId = v4() as UUID;
await adapter.createWorld({
id: worldId,
agentId,
name: "W",
serverId: "s",
} as World);
entityId = v4() as UUID;
await adapter.createEntities([
{ id: entityId, agentId, names: ["E"] } as Entity,
// The agent's own entity — assistant messages carry entityId === agentId,
// which the memories FK requires to exist in `entities`.
{ id: agentId, agentId, names: ["Agent"] } as Entity,
]);
for (const rid of [roomA, roomB]) {
await adapter.createRooms([
{
id: rid,
agentId,
worldId,
name: "r",
source: "test",
type: ChannelType.DM,
} as Room,
]);
await adapter.addParticipant(entityId, rid);
}
await seed(roomA, "deploy your first agent to the cloud", "user");
await seed(roomA, "I opened a PR on GitHub this morning", "assistant");
await seed(roomA, "the café down the street has great coffee", "user");
await seed(roomA, "please don't merge that branch yet", "assistant");
await seed(roomA, "how do I edit the configuration file", "user");
await seed(roomA, "I am configuring the webhook right now", "assistant");
await seed(roomA, "run `SELECT * FROM users WHERE name LIKE '%bob_%'` now", "user");
await seed(roomA, "see https://example.com/docs/path/to/thing for details", "assistant");
await seed(roomA, "great work 🚀 shipping today", "user");
await seed(roomA, "北京 is the capital of China", "assistant");
await seed(roomA, "the exact phrase alpha beta lives here", "user");
await seed(roomA, "alpha appears alone and beta appears far away later", "assistant");
await seed(roomA, "here is the file you asked for", "user", [
{
title: "quarterly-budget-2026.xlsx",
url: "https://cdn.example.com/quarterly-budget-2026.xlsx",
},
]);
await seed(roomA, "duplicate marker zephyr", "user");
await seed(roomA, "duplicate marker zephyr", "user");
// roomB — for access scoping.
await seed(roomB, "roomB privately mentions zephyr too", "user");
}, 180_000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it("1. multi-word non-adjacent — 'deploy agent' matches 'deploy your first agent'", async () => {
expect(
(await searchTexts("deploy agent")).some((t) => t.includes("deploy your first agent"))
).toBe(true);
});
it("2. mixed case — 'GITHUB' matches 'GitHub'", async () => {
expect((await searchTexts("GITHUB")).some((t) => t.includes("GitHub"))).toBe(true);
});
it("3. accent + apostrophe fold — cafe~café, dont~don't", async () => {
expect((await searchTexts("cafe")).some((t) => t.includes("café"))).toBe(true);
expect((await searchTexts("dont")).some((t) => t.includes("don't"))).toBe(true);
});
it("4. partial word (trigram) — 'config' matches 'configuration'", async () => {
expect((await searchTexts("config")).some((t) => t.includes("configuration"))).toBe(true);
});
it("4b. typo word (trigram) — 'configuraton' matches 'configuration'", async () => {
if (!trigramAvailable) return;
expect((await searchTexts("configuraton")).some((t) => t.includes("configuration"))).toBe(true);
});
it("4c. typo fallback still requires every multi-term query token", async () => {
if (!trigramAvailable) return;
expect(
(await searchTexts("configuraton file")).some((t) => t.includes("configuration file"))
).toBe(true);
expect(await searchTexts("configuraton deployment")).toEqual([]);
});
it("5. stemming — 'configure' matches 'configuring'", async () => {
expect((await searchTexts("configure")).some((t) => t.includes("configuring"))).toBe(true);
});
it("6. code metacharacters are literal — '%bob_%' finds the code row, 'zzz%zzz' matches nothing", async () => {
expect((await searchTexts("%bob_%")).some((t) => t.includes("SELECT * FROM users"))).toBe(true);
expect(await searchTexts("zzz%zzz")).toHaveLength(0);
});
it("7. URL substring — 'example.com/docs' found in a pasted URL", async () => {
expect(
(await searchTexts("example.com/docs")).some((t) => t.includes("https://example.com/docs"))
).toBe(true);
});
it("8. emoji query is found", async () => {
expect((await searchTexts("🚀")).some((t) => t.includes("🚀"))).toBe(true);
});
it("9. CJK substring — '北京' is found", async () => {
expect((await searchTexts("北京")).some((t) => t.includes("北京"))).toBe(true);
});
it("10. quoted phrase vs OR", async () => {
const phrase = await searchTexts('"alpha beta"');
expect(phrase.some((t) => t.includes("exact phrase alpha beta"))).toBe(true);
expect(phrase.some((t) => t.includes("appears alone and beta appears far away"))).toBe(false);
const bare = await searchTexts("alpha beta");
expect(bare.some((t) => t.includes("exact phrase alpha beta"))).toBe(true);
});
it("11. near-duplicates all returned, deterministically ordered by recency then id", async () => {
const hits = await adapter.searchMessages({
roomIds: [roomA],
query: "zephyr",
tableName: "messages",
limit: 50,
});
const dups = hits.filter(
(h) => (h.memory.content as { text?: string }).text === "duplicate marker zephyr"
);
expect(dups.length).toBe(2);
const t0 = dups[0].memory.createdAt ?? 0;
const t1 = dups[1].memory.createdAt ?? 0;
expect(t0).toBeGreaterThanOrEqual(t1);
// Re-running the same query yields the identical order.
const hits2 = await adapter.searchMessages({
roomIds: [roomA],
query: "zephyr",
tableName: "messages",
limit: 50,
});
expect(hits2.map((h) => h.memory.id)).toEqual(hits.map((h) => h.memory.id));
});
it("12. attachment-only match — found by attachment filename", async () => {
expect(
(await searchTexts("quarterly-budget")).some((t) =>
t.includes("here is the file you asked for")
)
).toBe(true);
});
it("13. role attribution surfaces via entityId (assistant vs user)", async () => {
const hits = await adapter.searchMessages({
roomIds: [roomA],
query: "configuring",
tableName: "messages",
limit: 5,
});
const row = hits.find((h) =>
(h.memory.content as { text?: string }).text?.includes("configuring")
);
expect(row?.memory.entityId).toBe(agentId);
});
it("14. deleted message never appears", async () => {
const id = await seed(roomA, "ephemeral platypus message", "user");
expect((await searchTexts("platypus")).length).toBe(1);
await adapter.deleteMemory(id);
expect(await searchTexts("platypus")).toHaveLength(0);
});
it("15. access scoping — roomA search never leaks roomB's zephyr", async () => {
const inA = await searchTexts("zephyr", roomA);
expect(inA.some((t) => t.includes("roomB privately"))).toBe(false);
const inB = await searchTexts("zephyr", roomB);
expect(inB.some((t) => t.includes("roomB privately"))).toBe(true);
});
it("is index-backed — the FTS GIN index and STORED search-document column exist", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
// The migration hook installs the FTS GIN index and the materialized
// `message_search_document` column that keeps the query off the O(n)
// recompute path. (On this ~20-row fixture the planner rationally prefers a
// seq scan over any index, so the index's actual *selection* at scale — and
// the latency win — is measured in packages/benchmarks/searchbench on the
// 10k corpus, not asserted against this tiny table's query plan.)
const indexes = JSON.stringify(
await db.execute(sql`SELECT indexname FROM pg_indexes WHERE tablename = 'memories'`)
).toLowerCase();
expect(indexes).toContain("idx_memories_message_fts");
const columns = JSON.stringify(
await db.execute(sql`
SELECT column_name, is_generated FROM information_schema.columns
WHERE table_name = 'memories' AND column_name = 'message_search_document'
`)
).toLowerCase();
expect(columns).toContain("message_search_document");
expect(columns).toContain("always");
});
it("16. corpus-wide recall — the OLDEST hit is returned despite thousands of newer rows", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
const needle = "xyzzy-plugh-corpus-needle";
// The needle row is the oldest in the corpus; every newer row omits it.
const base = Date.now() - RECALL_CORPUS * 1000;
const needleId = v4() as UUID;
const rows: Array<Record<string, unknown>> = [
{
id: needleId,
type: "messages",
content: { text: `oldest row carrying ${needle} keyword` },
entityId,
agentId,
roomId: roomB,
worldId,
unique: false,
metadata: { type: "messages" },
createdAt: new Date(base),
},
];
for (let i = 1; i < RECALL_CORPUS; i++) {
rows.push({
id: v4() as UUID,
type: "messages",
content: { text: `filler message ${i} with ordinary words` },
entityId,
agentId,
roomId: roomB,
worldId,
unique: false,
metadata: { type: "messages" },
createdAt: new Date(base + i * 1000),
});
}
for (let start = 0; start < rows.length; start += 2000) {
await db.insert(memoryTable).values(rows.slice(start, start + 2000) as never);
}
const started = Date.now();
const hits = await adapter.searchMessages({
roomIds: [roomB],
query: needle,
tableName: "messages",
limit: 20,
});
const elapsed = Date.now() - started;
expect(hits.length).toBe(1);
expect(hits[0].memory.id).toBe(needleId);
expect(hits[0].ftsRank).toBeGreaterThan(0);
// Bounded latency on a multi-thousand-row corpus via the GIN index.
expect(elapsed).toBeLessThan(5_000);
}, 120_000);
it("18. empty room set short-circuits to no rows without a query", async () => {
const hits = await adapter.searchMessages({
roomIds: [],
query: "anything",
tableName: "messages",
});
expect(hits).toHaveLength(0);
});
});
@@ -0,0 +1,237 @@
/**
* Integration tests for message server/channel/participant persistence and
* server-scoped memory retrieval, against a real isolated PGlite/Postgres
* adapter.
*/
import { ChannelType, type Content, type Memory, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Messaging Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let messageServerId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("messaging-tests");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
// Create a test message server
const server = await adapter.createMessageServer({
name: "Test Message Server",
sourceType: "test",
});
messageServerId = server.id;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Message Server Tests", () => {
it("should create and retrieve a message channel", async () => {
const channelData = {
messageServerId: messageServerId,
name: "test-channel",
type: ChannelType.GROUP,
};
const channel = await adapter.createChannel(channelData, [testAgentId]);
expect(channel).toBeDefined();
expect(channel.name).toBe("test-channel");
const retrieved = await adapter.getChannelDetails(channel.id);
expect(retrieved).toBeDefined();
expect(retrieved?.name).toBe("test-channel");
});
it("should create and retrieve a message", async () => {
const channel = await adapter.createChannel(
{
messageServerId: messageServerId,
name: "message-channel",
type: ChannelType.GROUP,
},
[testAgentId]
);
const messageData = {
channelId: channel.id,
authorId: testAgentId,
content: "Hello, world!",
};
const message = await adapter.createMessage(messageData);
expect(message).toBeDefined();
const messages = await adapter.getMessagesForChannel(channel.id);
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe("Hello, world!");
});
it("should add and retrieve channel participants", async () => {
const channel = await adapter.createChannel(
{
messageServerId: messageServerId,
name: "participant-channel",
type: ChannelType.GROUP,
},
[]
);
const entityId1 = uuidv4() as UUID;
const entityId2 = uuidv4() as UUID;
await adapter.addChannelParticipants(channel.id, [entityId1, entityId2]);
const participants = await adapter.getChannelParticipants(channel.id);
expect(participants).toHaveLength(2);
expect(participants).toContain(entityId1);
expect(participants).toContain(entityId2);
});
it("should check if entity is channel participant", async () => {
const channel = await adapter.createChannel(
{
messageServerId: messageServerId,
name: "check-participant-channel",
type: ChannelType.GROUP,
},
[]
);
const entityId = uuidv4() as UUID;
// Initially not a participant
let isParticipant = await adapter.isChannelParticipant(channel.id, entityId);
expect(isParticipant).toBe(false);
// Add as participant
await adapter.addChannelParticipants(channel.id, [entityId]);
isParticipant = await adapter.isChannelParticipant(channel.id, entityId);
expect(isParticipant).toBe(true);
});
it("should return false for non-existent channel participant check", async () => {
const nonExistentChannelId = uuidv4() as UUID;
const nonExistentEntityId = uuidv4() as UUID;
const isParticipant = await adapter.isChannelParticipant(
nonExistentChannelId,
nonExistentEntityId
);
expect(isParticipant).toBe(false);
});
it("should add and retrieve agents for a server", async () => {
const agent1 = uuidv4() as UUID;
const agent2 = uuidv4() as UUID;
// Create the agents first before adding them to server
await adapter.createAgent({
id: agent1,
name: "Test Agent 1",
bio: "Test agent bio",
createdAt: Date.now(),
updatedAt: Date.now(),
});
await adapter.createAgent({
id: agent2,
name: "Test Agent 2",
bio: "Test agent bio",
createdAt: Date.now(),
updatedAt: Date.now(),
});
await adapter.addAgentToMessageServer(messageServerId, agent1);
await adapter.addAgentToMessageServer(messageServerId, agent2);
const agents = await adapter.getAgentsForMessageServer(messageServerId);
expect(agents).toHaveLength(2);
expect(agents).toContain(agent1);
expect(agents).toContain(agent2);
});
it("should retrieve memories by message server id", async () => {
const otherServer = await adapter.createMessageServer({
name: "Other Message Server",
sourceType: "test",
});
const entityId = uuidv4() as UUID;
const roomId = uuidv4() as UUID;
const otherRoomId = uuidv4() as UUID;
await adapter.createEntities([
{
id: entityId,
agentId: testAgentId,
names: ["Message Memory Entity"],
metadata: { type: "test" },
},
]);
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
source: "test",
type: ChannelType.GROUP,
name: "Server Memory Room",
messageServerId,
},
{
id: otherRoomId,
agentId: testAgentId,
source: "test",
type: ChannelType.GROUP,
name: "Other Server Memory Room",
messageServerId: otherServer.id,
},
]);
const memories: Memory[] = [
{
id: uuidv4() as UUID,
agentId: testAgentId,
entityId,
roomId,
content: { text: "server memory 1" } as Content,
metadata: { type: "messages" },
createdAt: Date.now() - 100,
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
entityId,
roomId,
content: { text: "server memory 2" } as Content,
metadata: { type: "messages" },
createdAt: Date.now(),
},
{
id: uuidv4() as UUID,
agentId: testAgentId,
entityId,
roomId: otherRoomId,
content: { text: "other server memory" } as Content,
metadata: { type: "messages" },
createdAt: Date.now(),
},
];
for (const memory of memories) {
await adapter.createMemory(memory, "messages");
}
const serverMemories = await adapter.getMemoriesByServerId({
serverId: messageServerId,
});
expect(serverMemories.map((memory) => (memory.content as Content).text)).toEqual([
"server memory 2",
"server memory 1",
]);
});
});
});
@@ -0,0 +1,110 @@
/**
* Integration tests for room participant add/remove/state against a real
* isolated PGlite/Postgres adapter.
*/
import { type AgentRuntime, ChannelType, type Entity, type Room, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { participantTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Participant Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let _runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testRoomId: UUID;
let testEntityId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("participant-tests");
adapter = setup.adapter;
_runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
testRoomId = uuidv4() as UUID;
testEntityId = uuidv4() as UUID;
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
name: "Test Room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
]);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Participant Tests", () => {
beforeEach(async () => {
await (adapter.getDatabase() as DrizzleDatabase).delete(participantTable);
});
it("should add and retrieve a participant", async () => {
const result = await adapter.addParticipant(testEntityId, testRoomId);
expect(result).toBe(true);
const rooms = await adapter.getRoomsForParticipant(testEntityId);
expect(rooms).toContain(testRoomId);
});
it("should remove a participant from a room", async () => {
await adapter.addParticipant(testEntityId, testRoomId);
let rooms = await adapter.getRoomsForParticipant(testEntityId);
expect(rooms).toContain(testRoomId);
const result = await adapter.removeParticipant(testEntityId, testRoomId);
expect(result).toBe(true);
rooms = await adapter.getRoomsForParticipant(testEntityId);
expect(rooms).not.toContain(testRoomId);
});
it("should manage participant state", async () => {
await adapter.addParticipant(testEntityId, testRoomId);
await adapter.setParticipantUserState(testRoomId, testEntityId, "FOLLOWED");
let state = await adapter.getParticipantUserState(testRoomId, testEntityId);
expect(state).toBe("FOLLOWED");
await adapter.setParticipantUserState(testRoomId, testEntityId, null);
state = await adapter.getParticipantUserState(testRoomId, testEntityId);
expect(state).toBeNull();
});
it("should check if entity is room participant", async () => {
let isParticipant = await adapter.isRoomParticipant(testRoomId, testEntityId);
expect(isParticipant).toBe(false);
await adapter.addParticipant(testEntityId, testRoomId);
isParticipant = await adapter.isRoomParticipant(testRoomId, testEntityId);
expect(isParticipant).toBe(true);
await adapter.removeParticipant(testEntityId, testRoomId);
isParticipant = await adapter.isRoomParticipant(testRoomId, testEntityId);
expect(isParticipant).toBe(false);
});
it("should return false for non-existent room participant check", async () => {
const nonExistentRoomId = uuidv4() as UUID;
const nonExistentEntityId = uuidv4() as UUID;
const isParticipant = await adapter.isRoomParticipant(nonExistentRoomId, nonExistentEntityId);
expect(isParticipant).toBe(false);
});
});
});
@@ -0,0 +1,603 @@
/**
* End-to-end tests for `PgliteDatabaseAdapter` across the full adapter
* surface (agents, entities, memories, components, concurrency, error
* handling) against a real in-process PGlite instance with migrations
* applied — no mocks, standing in for a real PostgreSQL backend.
*/
import { PGlite } from "@electric-sql/pglite";
import type { Agent, ChannelType, Component, Entity, Memory, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../migration-service";
import { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { PGliteClientManager } from "../../pglite/manager";
import * as schema from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { expectCreatedEntityIds } from "./entity-create-assertions";
describe("PostgreSQL E2E Tests", () => {
const createTestAdapter = async () => {
const client = new PGlite();
const manager = new PGliteClientManager(client);
const agentId = uuidv4() as UUID;
const adapter = new PgliteDatabaseAdapter(agentId, manager);
await adapter.init();
const migrationService = new DatabaseMigrationService();
const db = adapter.getDatabase() as DrizzleDatabase;
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
return { adapter, agentId };
};
describe("Connection Management", () => {
it("should test connection successfully", async () => {
const { adapter } = await createTestAdapter();
const isReady = await adapter.isReady();
expect(isReady).toBe(true);
await adapter.close();
}, 60_000);
it("should get connection", async () => {
const { adapter } = await createTestAdapter();
const connection = await adapter.getConnection();
expect(connection).toBeDefined();
await adapter.close();
}, 60_000);
});
describe("Agent Operations", () => {
it("should create and retrieve an agent", async () => {
const { adapter, agentId } = await createTestAdapter();
const agent: Partial<Agent> = {
id: agentId,
name: "Test Agent",
settings: {
apiKey: "test-key",
model: "gpt-5",
},
};
const created = await adapter.createAgent(agent as Agent);
expect(created).toBe(true);
const retrieved = await adapter.getAgent(agentId);
expect(retrieved).toBeDefined();
expect(retrieved?.name).toBe("Test Agent");
expect(retrieved?.settings).toEqual(agent.settings!);
await adapter.close();
});
it("should update an agent", async () => {
const { adapter, agentId } = await createTestAdapter();
const agent: Partial<Agent> = {
id: agentId,
name: "Original Name",
};
await adapter.createAgent(agent as Agent);
const updated = await adapter.updateAgent(agentId, {
name: "Updated Name",
settings: { newSetting: "value" },
});
expect(updated).toBe(true);
const retrieved = await adapter.getAgent(agentId);
expect(retrieved).not.toBeNull();
expect(retrieved?.name).toBe("Updated Name");
const retrievedSettings = retrieved?.settings;
expect(retrievedSettings?.newSetting).toBe("value");
await adapter.close();
});
it("should delete an agent", async () => {
const { adapter, agentId } = await createTestAdapter();
const agent: Partial<Agent> = {
id: agentId,
name: "To Delete",
};
await adapter.createAgent(agent as Agent);
const deleted = await adapter.deleteAgent(agentId);
expect(deleted).toBe(true);
const retrieved = await adapter.getAgent(agentId);
expect(retrieved).toBeNull();
await adapter.close();
});
});
describe("Entity Operations", () => {
it("should create and retrieve entities", async () => {
const { adapter, agentId } = await createTestAdapter();
await adapter.createAgent({
id: agentId,
name: "Test Agent",
} as Agent);
const entities: Entity[] = [
{
id: uuidv4() as UUID,
agentId,
names: ["Entity One"],
metadata: { custom: "data" },
},
{
id: uuidv4() as UUID,
agentId,
names: ["Entity Two"],
metadata: { custom: "data" },
},
];
const created = await adapter.createEntities(entities);
expectCreatedEntityIds(created, entities);
const entityIds = entities.map((e) => e.id).filter((id): id is UUID => id !== undefined);
const retrieved = await adapter.getEntitiesByIds(entityIds);
expect(retrieved).toHaveLength(2);
// Sort by name to ensure consistent order
expect(retrieved).not.toBeNull();
const sortedRetrieved = retrieved?.sort((a, b) => a.names[0].localeCompare(b.names[0]));
expect(sortedRetrieved[0].names).toContain("Entity One");
expect(sortedRetrieved[1].metadata).toEqual({ custom: "data" });
await adapter.close();
});
it("should update an entity", async () => {
const { adapter, agentId } = await createTestAdapter();
await adapter.createAgent({
id: agentId,
name: "Test Agent",
} as Agent);
const entity: Entity = {
id: uuidv4() as UUID,
agentId,
names: ["Original"],
metadata: { custom: "data" },
};
await adapter.createEntities([entity]);
await adapter.updateEntity({
...entity,
names: ["Updated"],
metadata: { updated: true },
});
const retrieved = await adapter.getEntitiesByIds([entity.id!]);
expect(retrieved).not.toBeNull();
expect(retrieved?.[0].names).toContain("Updated");
expect(retrieved?.[0].metadata).toEqual({ updated: true });
await adapter.close();
});
});
describe("Memory Operations", () => {
let adapter: PgliteDatabaseAdapter;
let agentId: UUID;
let roomId: UUID;
let entityId: UUID;
beforeEach(async () => {
const result = await createTestAdapter();
adapter = result.adapter;
agentId = result.agentId;
await adapter.createAgent({
id: agentId,
name: "Test Agent",
} as Agent);
roomId = uuidv4() as UUID;
entityId = uuidv4() as UUID;
await adapter.createRooms([
{
id: roomId,
agentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
await adapter.createEntities([
{
id: entityId,
agentId,
names: ["Test Entity"],
metadata: { custom: "data" },
},
]);
});
afterEach(async () => {
await adapter.close();
});
it("should create and retrieve memories", async () => {
const memory: Memory = {
id: uuidv4() as UUID,
agentId,
entityId,
roomId,
content: { text: "Test memory content" },
metadata: {
type: "custom",
},
createdAt: Date.now(),
};
const memoryId = await adapter.createMemory(memory, "memories");
expect(memoryId).toBeDefined();
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeDefined();
expect(retrieved?.content).toEqual({ text: "Test memory content" });
});
it("should keep getMemories working after unsupported embedding dimension", async () => {
const memory: Memory = {
id: uuidv4() as UUID,
agentId,
entityId,
roomId,
content: { text: "Dimension fallback memory" },
metadata: {
type: "custom",
},
createdAt: Date.now(),
};
await adapter.createMemory(memory, "messages");
await adapter.ensureEmbeddingDimension(123 as number);
const memories = await adapter.getMemories({
roomId,
tableName: "messages",
count: 20,
});
expect(memories.length).toBeGreaterThanOrEqual(1);
expect(memories[0]?.content).toEqual({ text: "Dimension fallback memory" });
});
it("should search memories by embedding", async () => {
const embedding = Array(384).fill(0.1);
const memory: Memory = {
id: uuidv4() as UUID,
agentId,
entityId,
roomId,
content: { text: "Searchable memory" },
metadata: {
type: "custom",
},
embedding,
createdAt: Date.now(),
};
await adapter.createMemory(memory, "memories");
const results = await adapter.searchMemories({
tableName: "memories",
embedding,
count: 10,
});
expect(results).toHaveLength(1);
expect(results[0].content).toEqual({ text: "Searchable memory" });
});
it("should update memory content", async () => {
const memory: Memory = {
id: uuidv4() as UUID,
agentId,
entityId,
roomId,
content: { text: "Original content" },
metadata: {
type: "custom",
},
createdAt: Date.now(),
};
const memoryId = await adapter.createMemory(memory, "memories");
const updated = await adapter.updateMemory({
id: memoryId,
content: { text: "Updated content" },
metadata: { type: "custom", edited: true },
});
expect(updated).toBe(true);
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).not.toBeNull();
expect(retrieved?.content).toEqual({ text: "Updated content" });
expect(retrieved?.metadata).toMatchObject({
type: "custom",
edited: true,
});
});
it("should delete memories", async () => {
const memory: Memory = {
id: uuidv4() as UUID,
agentId,
entityId,
roomId,
content: { text: "To be deleted" },
metadata: {
type: "custom",
},
createdAt: Date.now(),
};
const memoryId = await adapter.createMemory(memory, "memories");
await adapter.deleteMemory(memoryId);
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeNull();
});
});
describe("Component Operations", () => {
let adapter: PgliteDatabaseAdapter;
let agentId: UUID;
let entityId: UUID;
let roomId: UUID;
let worldId: UUID;
let sourceEntityId: UUID;
beforeEach(async () => {
const result = await createTestAdapter();
adapter = result.adapter;
agentId = result.agentId;
await adapter.createAgent({
id: agentId,
name: "Test Agent",
} as Agent);
entityId = uuidv4() as UUID;
roomId = uuidv4() as UUID;
worldId = uuidv4() as UUID;
sourceEntityId = uuidv4() as UUID;
await adapter.createWorld({
id: worldId,
agentId,
messageServerId: uuidv4() as UUID,
name: "Test World",
});
await adapter.createRooms([
{
id: roomId,
agentId,
source: "test",
type: "GROUP" as ChannelType,
name: "Test Room",
},
]);
await adapter.createEntities([
{
id: entityId,
agentId,
names: ["Component Test Entity"],
metadata: { custom: "data" },
},
{
id: sourceEntityId,
agentId,
names: ["Source Test Entity"],
metadata: { custom: "data" },
},
]);
});
afterEach(async () => {
await adapter.close();
});
it("should create and retrieve components", async () => {
const component: Component = {
id: uuidv4() as UUID,
entityId,
agentId,
roomId,
worldId,
sourceEntityId,
type: "test-component",
data: { value: "test data" },
createdAt: Date.now(),
};
const created = await adapter.createComponent(component);
expect(created).toBe(true);
const retrieved = await adapter.getComponent(
entityId,
"test-component",
worldId,
sourceEntityId
);
expect(retrieved).toBeDefined();
expect(retrieved?.data).toEqual({ value: "test data" });
});
it("should update a component", async () => {
const component: Component = {
id: uuidv4() as UUID,
entityId,
agentId,
roomId,
worldId,
sourceEntityId,
type: "update-test",
data: { original: true },
createdAt: Date.now(),
};
await adapter.createComponent(component);
await adapter.updateComponent({
...component,
data: { updated: true },
});
const retrieved = await adapter.getComponent(
entityId,
"update-test",
worldId,
sourceEntityId
);
expect(retrieved).not.toBeNull();
expect(retrieved?.data).toEqual({ updated: true });
});
it("should delete a component", async () => {
const component: Component = {
id: uuidv4() as UUID,
entityId,
agentId,
roomId,
worldId,
sourceEntityId,
type: "delete-test",
data: {},
createdAt: Date.now(),
};
await adapter.createComponent(component);
await adapter.deleteComponent(component.id);
const retrieved = await adapter.getComponent(
entityId,
"delete-test",
worldId,
sourceEntityId
);
expect(retrieved).toBeNull();
});
});
describe("Transaction and Concurrency", () => {
it("should handle concurrent operations", async () => {
const { adapter, agentId } = await createTestAdapter();
await adapter.createAgent({
id: agentId,
name: "Test Agent",
} as Agent);
const operations = Array(5)
.fill(null)
.map((_, i) => {
const entity: Entity = {
id: uuidv4() as UUID,
agentId,
names: [`Concurrent Entity ${i}`],
metadata: { custom: "data" },
};
return adapter.createEntities([entity]);
});
const results = await Promise.all(operations);
expect(results.every((result) => result.length === 1)).toBe(true);
expect(results.flat()).toHaveLength(operations.length);
await adapter.close();
});
it("should handle large batch operations", async () => {
const { adapter, agentId } = await createTestAdapter();
await adapter.createAgent({
id: agentId,
name: "Test Agent",
} as Agent);
const entities: Entity[] = Array(100)
.fill(null)
.map((_, i) => ({
id: uuidv4() as UUID,
agentId,
names: [`Batch Entity ${i}`],
metadata: { custom: "data" },
}));
const created = await adapter.createEntities(entities);
expectCreatedEntityIds(created, entities);
const entityIds = entities.map((e) => e.id).filter((id): id is UUID => id !== undefined);
const retrieved = await adapter.getEntitiesByIds(entityIds);
expect(retrieved).toHaveLength(100);
await adapter.close();
});
});
describe("Error Handling", () => {
it("should handle duplicate agent creation", async () => {
const { adapter, agentId } = await createTestAdapter();
const agent: Partial<Agent> = {
id: agentId,
name: "Duplicate Test",
};
await adapter.createAgent(agent as Agent);
const secondCreate = await adapter.createAgent(agent as Agent);
expect(secondCreate).toBe(false);
await adapter.close();
});
it("should handle non-existent entity retrieval", async () => {
const { adapter } = await createTestAdapter();
const nonExistentId = uuidv4() as UUID;
const result = await adapter.getEntitiesByIds([nonExistentId]);
expect(result).toHaveLength(0);
await adapter.close();
});
it("should handle invalid memory search", async () => {
const { adapter } = await createTestAdapter();
const results = await adapter.searchMemories({
tableName: "memories",
embedding: Array(384).fill(0),
count: 0, // Invalid count
});
expect(results).toHaveLength(0);
await adapter.close();
});
});
}, 120_000);
@@ -0,0 +1,285 @@
/**
* Direct integration tests for `PgliteDatabaseAdapter`/`PGliteClientManager`
* against a real in-process PGlite instance (Postgres-compatible surface):
* raw SQL, transactions, JSON/array/timestamp operations, error recovery,
* and adapter shutdown.
*/
import { PGlite } from "@electric-sql/pglite";
import type { UUID } from "@elizaos/core";
import { sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../../migration-service";
import { PgliteDatabaseAdapter } from "../../../pglite/adapter";
import { PGliteClientManager } from "../../../pglite/manager";
import * as schema from "../../../schema";
import type { DrizzleDatabase } from "../../../types";
describe("PostgreSQL Adapter Direct Integration Tests", () => {
describe("PostgreSQL Adapter Direct Tests", () => {
let adapter: PgliteDatabaseAdapter;
let manager: PGliteClientManager;
let testAgentId: UUID;
beforeAll(async () => {
testAgentId = uuidv4() as UUID;
const client = new PGlite();
manager = new PGliteClientManager(client);
adapter = new PgliteDatabaseAdapter(testAgentId, manager);
await adapter.init();
const migrationService = new DatabaseMigrationService();
const db = adapter.getDatabase() as DrizzleDatabase;
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
});
afterAll(async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
await db.execute(sql`DELETE FROM agents WHERE id = ${testAgentId}`);
await adapter.close();
});
describe("Initialization and Connection", () => {
it("should initialize adapter successfully", () => {
expect(adapter).toBeDefined();
expect(adapter.getDatabase()).toBeDefined();
});
it("should test connection through adapter", async () => {
const isReady = await adapter.isReady();
expect(isReady).toBe(true);
});
it("should get database instance", () => {
const db = adapter.getDatabase() as DrizzleDatabase;
expect(db).toBeDefined();
expect(db.execute).toBeDefined();
});
it("should check if adapter is ready", async () => {
const isReady = await adapter.isReady();
expect(isReady).toBe(true);
});
});
describe("Raw Database Operations", () => {
it("should execute raw SQL through adapter database", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
const result = await db.execute(sql`SELECT 1 as value`);
expect(result).toBeDefined();
expect(result.rows).toBeDefined();
expect(result.rows).toHaveLength(1);
expect(result.rows[0]).toEqual({ value: 1 });
});
it("should handle transactions through adapter", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
await db.execute(sql`
CREATE TABLE IF NOT EXISTS pg_adapter_test (
id SERIAL PRIMARY KEY,
value INTEGER
)
`);
try {
await db.transaction(async (tx) => {
await tx.execute(sql`INSERT INTO pg_adapter_test (value) VALUES (100)`);
await tx.execute(sql`INSERT INTO pg_adapter_test (value) VALUES (200)`);
});
const result = await db.execute(sql`
SELECT SUM(value) as total FROM pg_adapter_test
`);
expect(Number(result.rows[0].total)).toBe(300);
} finally {
await db.execute(sql`DROP TABLE IF EXISTS pg_adapter_test`);
}
});
});
describe("Agent Operations", () => {
it("should create an agent", async () => {
const created = await adapter.createAgent({
id: testAgentId,
name: "PG Direct Test Agent",
bio: ["Test agent for PostgreSQL direct tests"],
createdAt: Date.now(),
updatedAt: Date.now(),
});
expect(created).toBe(true);
});
it("should retrieve the created agent", async () => {
const agent = await adapter.getAgent(testAgentId);
expect(agent).toBeDefined();
expect(agent?.id).toBe(testAgentId);
expect(agent?.name).toBe("PG Direct Test Agent");
});
it("should update agent settings", async () => {
const updated = await adapter.updateAgent(testAgentId, {
settings: {
theme: "dark",
language: "en",
},
});
expect(updated).toBe(true);
const agent = await adapter.getAgent(testAgentId);
expect(agent?.settings).toEqual({
theme: "dark",
language: "en",
});
});
});
describe("Connection Manager Features", () => {
it("should get connection from manager", () => {
const connection = manager.getConnection();
expect(connection).toBeDefined();
expect(connection.query).toBeDefined();
});
it("should handle multiple operations", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
const results = await Promise.all([
db.execute(sql`SELECT 1 as id`),
db.execute(sql`SELECT 2 as id`),
db.execute(sql`SELECT 3 as id`),
]);
expect(results).toHaveLength(3);
results.forEach((result, index) => {
expect(result.rows[0].id).toBe(index + 1);
});
});
});
describe("Error Handling", () => {
it("should handle query errors gracefully", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
let errorThrown = false;
try {
await db.execute(sql`SELECT * FROM non_existent_table`);
} catch (error) {
errorThrown = true;
expect(error).toBeDefined();
}
expect(errorThrown).toBe(true);
});
it("should maintain connection after error", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
try {
await db.execute(sql`INVALID SQL`);
} catch (_e) {
// Expected — the query itself is invalid.
}
const result = await db.execute(sql`SELECT 1 as value`);
expect(result.rows[0].value).toBe(1);
});
});
describe("Advanced Features", () => {
it("should support JSON operations", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
await db.execute(sql`
CREATE TABLE IF NOT EXISTS json_test (
id SERIAL PRIMARY KEY,
data JSONB
)
`);
try {
const testData = { name: "test", values: [1, 2, 3] };
await db.execute(sql`
INSERT INTO json_test (data) VALUES (${JSON.stringify(testData)}::jsonb)
`);
const result = await db.execute(sql`
SELECT data FROM json_test WHERE data->>'name' = 'test'
`);
expect(result.rows[0].data).toEqual(testData);
} finally {
await db.execute(sql`DROP TABLE IF EXISTS json_test`);
}
});
it("should support array operations", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
await db.execute(sql`
CREATE TABLE IF NOT EXISTS array_test (
id SERIAL PRIMARY KEY,
tags TEXT[]
)
`);
try {
// PGLite requires array syntax
await db.execute(sql`
INSERT INTO array_test (tags) VALUES (ARRAY['tag1', 'tag2', 'tag3'])
`);
const result = await db.execute(sql`
SELECT tags FROM array_test WHERE 'tag2' = ANY(tags)
`);
expect(result.rows[0].tags).toEqual(["tag1", "tag2", "tag3"]);
} finally {
await db.execute(sql`DROP TABLE IF EXISTS array_test`);
}
});
it("should support timestamp operations", async () => {
const db = adapter.getDatabase() as DrizzleDatabase;
const result = await db.execute(sql`
SELECT
CURRENT_TIMESTAMP as current_time,
CURRENT_TIMESTAMP + INTERVAL '1 day' as tomorrow,
CURRENT_TIMESTAMP - INTERVAL '1 hour' as hour_ago
`);
const row = result.rows[0];
expect(row.current_time).toBeDefined();
expect(row.tomorrow).toBeDefined();
expect(row.hour_ago).toBeDefined();
});
});
describe("Adapter Shutdown", () => {
it("should handle close gracefully", async () => {
const tempClient = new PGlite();
const tempManager = new PGliteClientManager(tempClient);
const tempAdapter = new PgliteDatabaseAdapter(uuidv4() as UUID, tempManager);
await tempAdapter.init();
await tempAdapter.close();
const isReady = await tempAdapter.isReady();
expect(isReady).toBe(false);
});
});
});
});
@@ -0,0 +1,236 @@
/**
* Exercises PgliteDatabaseAdapter and PGliteClientManager against a real
* in-memory PGlite instance (WASM Postgres) with migrations run through
* DatabaseMigrationService — connection lifecycle, withDatabase error
* propagation, and agent CRUD.
*/
import { PGlite } from "@electric-sql/pglite";
import type { Agent, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DatabaseMigrationService } from "../../../migration-service";
import { PgliteDatabaseAdapter } from "../../../pglite/adapter";
import { PGliteClientManager } from "../../../pglite/manager";
import * as schema from "../../../schema";
import type { DrizzleDatabase } from "../../../types";
describe("PostgreSQL Adapter Integration Tests", () => {
let adapter: PgliteDatabaseAdapter;
let manager: PGliteClientManager;
let cleanup: () => Promise<void>;
let agentId: UUID;
beforeEach(async () => {
agentId = uuidv4() as UUID;
const client = new PGlite();
manager = new PGliteClientManager(client);
adapter = new PgliteDatabaseAdapter(agentId, manager);
await adapter.init();
// Run migrations
const migrationService = new DatabaseMigrationService();
const db = adapter.getDatabase() as DrizzleDatabase;
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([
{ name: "@elizaos/plugin-sql", description: "SQL plugin", schema },
]);
await migrationService.runAllPluginMigrations();
cleanup = async () => {
await adapter.close();
};
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Connection Management", () => {
it("should initialize successfully", async () => {
const isReady = await adapter.isReady();
expect(isReady).toBe(true);
});
it("should get database connection", async () => {
const connection = await adapter.getConnection();
expect(connection).toBeDefined();
});
it("should close connection gracefully", async () => {
await expect(adapter.close()).resolves.not.toThrow();
});
it("should handle isReady when adapter is closed", async () => {
await adapter.close();
const isReady = await adapter.isReady();
expect(isReady).toBe(false);
});
});
describe("Database Operations", () => {
// Test interface to access protected method
interface TestableAdapter extends PgliteDatabaseAdapter {
withDatabase<T>(operation: () => Promise<T>): Promise<T>;
}
it("should perform withDatabase operation", async () => {
const result = await (adapter as TestableAdapter).withDatabase(async () => {
// Simple operation to test
return "success";
});
expect(result).toBe("success");
});
it("should handle withDatabase errors", async () => {
let errorCaught = false;
try {
await (adapter as TestableAdapter).withDatabase(async () => {
throw new Error("Test error");
});
} catch (error) {
errorCaught = true;
expect((error as Error).message).toBe("Test error");
}
expect(errorCaught).toBe(true);
});
it("should handle database operations", async () => {
// Test a simple database operation
const result = await (adapter as TestableAdapter).withDatabase(async () => {
return { status: "ok" };
});
expect(result).toEqual({ status: "ok" });
});
it("should propagate errors from database operations", async () => {
let errorCaught = false;
try {
await (adapter as TestableAdapter).withDatabase(async () => {
throw new Error("Database operation failed");
});
} catch (error) {
errorCaught = true;
expect((error as Error).message).toBe("Database operation failed");
}
expect(errorCaught).toBe(true);
});
});
describe("Manager Operations", () => {
it("should get connection instance", () => {
const connection = manager.getConnection();
expect(connection).toBeDefined();
});
it("should check if shutting down", () => {
const isShuttingDown = manager.isShuttingDown();
expect(isShuttingDown).toBe(false);
});
it("should handle close operation", async () => {
await manager.close();
const isShuttingDown = manager.isShuttingDown();
expect(isShuttingDown).toBe(true);
});
it("should test connection through adapter", async () => {
const isReady = await adapter.isReady();
expect(isReady).toBe(true);
});
it("should handle connection errors", async () => {
// Close the adapter
await adapter.close();
// Now try to check if ready
const isReady = await adapter.isReady();
expect(isReady).toBe(false);
});
it("should handle query failures", async () => {
// PGLite adapter init doesn't actually run queries, so we test a different operation
const mockClient = new PGlite();
const mockManager = new PGliteClientManager(mockClient as PGlite);
const mockAdapter = new PgliteDatabaseAdapter(uuidv4() as UUID, mockManager);
// Close the manager to simulate a connection issue
await mockManager.close();
// Check that adapter reports not ready
const isReady = await mockAdapter.isReady();
expect(isReady).toBe(false);
});
});
describe("Agent Operations", () => {
it("should create an agent", async () => {
const result = await adapter.createAgent({
id: agentId,
name: "Test Agent",
createdAt: Date.now(),
updatedAt: Date.now(),
bio: "Test agent bio",
} as Partial<Agent>);
expect(result).toBe(true);
});
it("should retrieve an agent", async () => {
// Create agent first
await adapter.createAgent({
id: agentId,
name: "Test Agent",
createdAt: Date.now(),
updatedAt: Date.now(),
bio: "Test agent bio",
} as Partial<Agent>);
const agent = await adapter.getAgent(agentId);
expect(agent).toBeDefined();
expect(agent?.name).toBe("Test Agent");
});
it("should update an agent", async () => {
// Create agent first
await adapter.createAgent({
id: agentId,
name: "Test Agent",
createdAt: Date.now(),
updatedAt: Date.now(),
bio: "Test agent bio",
} as Partial<Agent>);
// Update agent
await adapter.updateAgent(agentId, {
name: "Updated Agent",
});
const agent = await adapter.getAgent(agentId);
expect(agent?.name).toBe("Updated Agent");
});
it("should delete an agent", async () => {
// Create agent first
await adapter.createAgent({
id: agentId,
name: "Test Agent",
createdAt: Date.now(),
updatedAt: Date.now(),
bio: "Test agent bio",
} as Partial<Agent>);
const deleted = await adapter.deleteAgent(agentId);
expect(deleted).toBe(true);
const agent = await adapter.getAgent(agentId);
expect(agent).toBeNull();
});
});
});
@@ -0,0 +1,203 @@
/**
* Tests for `PGliteClientManager`'s file-backed single-writer lock: rejects
* a second manager while the first is confirmed live, reclaims a lock left
* by a dead or reused PID, and reclaims unconditionally in mobile embedded
* mode where the liveness probe is unusable. Writes real lock files to a
* temp dir and exercises the real manager constructor — no mocked lock
* logic, only `readLinuxProcessStartedAtMs` is spied for the PID-reuse case.
*/
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PGLITE_ERROR_CODES } from "../../../pglite/errors";
import { PGliteClientManager } from "../../../pglite/manager";
const lockPathFor = (dataDir: string) => path.join(dataDir, "eliza-pglite.lock");
const readLinuxBootId = (): string | null => {
try {
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim();
return bootId.length > 0 ? bootId : null;
} catch {
return null;
}
};
const readLinuxProcStartTicks = (pid: number | "self"): string | null => {
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
const commEnd = stat.lastIndexOf(")");
if (commEnd === -1) {
return null;
}
const fieldsAfterComm = stat
.slice(commEnd + 2)
.trim()
.split(/\s+/);
const startTicks = fieldsAfterComm[19];
return startTicks && /^\d+$/.test(startTicks) ? startTicks : null;
} catch {
return null;
}
};
const currentProcessIdentity = (): { bootId?: string; processStartTicks?: string } => {
const bootId = readLinuxBootId();
const processStartTicks = readLinuxProcStartTicks("self");
return {
...(bootId ? { bootId } : {}),
...(processStartTicks ? { processStartTicks } : {}),
};
};
type PGliteManagerInternals = {
readLinuxProcessStartedAtMs(pid: number): number | null;
};
describe("PGliteClientManager file lock", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects a second manager for the same file-backed data dir", async () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "eliza-pglite-lock-"));
tempDirs.push(dataDir);
const first = new PGliteClientManager({ dataDir });
try {
let error: unknown;
try {
new PGliteClientManager({ dataDir });
} catch (err) {
error = err;
}
expect((error as { code?: string }).code).toBe(PGLITE_ERROR_CODES.ACTIVE_LOCK);
} finally {
await first.close();
}
const second = new PGliteClientManager({ dataDir });
await second.close();
});
it("honors a confirmed-live lock regardless of how old its createdAt is", async () => {
// A long-running agent (days/weeks of uptime) holds a lock recording its
// own live PID with an ancient createdAt. Single-writer safety must win:
// a confirmed-alive PID is honored unconditionally, so a second manager is
// rejected rather than reclaiming the lock and opening a dual-writer window.
// (The staleness window only applies to UNCONFIRMABLE liveness — EPERM /
// non-ESRCH — never to a PID we can positively confirm is running.)
const dataDir = mkdtempSync(path.join(tmpdir(), "eliza-pglite-lock-"));
tempDirs.push(dataDir);
const ancientCreatedAt = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
writeFileSync(
lockPathFor(dataDir),
`${JSON.stringify({
pid: process.pid,
createdAt: ancientCreatedAt,
dataDir,
...currentProcessIdentity(),
})}\n`
);
let error: unknown;
try {
new PGliteClientManager({ dataDir });
} catch (err) {
error = err;
}
expect((error as { code?: string }).code).toBe(PGLITE_ERROR_CODES.ACTIVE_LOCK);
// The live lock must be left intact, not reclaimed.
expect(existsSync(lockPathFor(dataDir))).toBe(true);
});
it("reclaims a legacy live-pid lock when proc start time proves PID reuse", async () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "eliza-pglite-lock-"));
tempDirs.push(dataDir);
const lockCreatedAtMs = Date.now() - 60_000;
const processStartedAfterLockMs = lockCreatedAtMs + 10_000;
const startTimeSpy = vi
.spyOn(
PGliteClientManager.prototype as unknown as PGliteManagerInternals,
"readLinuxProcessStartedAtMs"
)
.mockReturnValue(processStartedAfterLockMs);
try {
// The PID is alive, but the lock predates that PID's process generation.
// A process cannot create a lock before it starts, so the PID was reused.
// This is the pid-1-in-container failure mode from #11222 generalized to
// the current test runner PID.
writeFileSync(
lockPathFor(dataDir),
`${JSON.stringify({
pid: process.pid,
createdAt: new Date(lockCreatedAtMs).toISOString(),
dataDir,
})}\n`
);
const manager = new PGliteClientManager({ dataDir });
await manager.close();
expect(existsSync(lockPathFor(dataDir))).toBe(false);
} finally {
startTimeSpy.mockRestore();
}
});
it("reclaims a lock owned by a non-running PID", async () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "eliza-pglite-lock-"));
tempDirs.push(dataDir);
// PID that cannot exist on Linux/macOS (above the configured pid_max).
writeFileSync(
lockPathFor(dataDir),
`${JSON.stringify({
pid: 2_147_483_646,
createdAt: new Date().toISOString(),
dataDir,
})}\n`
);
const manager = new PGliteClientManager({ dataDir });
await manager.close();
expect(existsSync(lockPathFor(dataDir))).toBe(false);
});
it("mobile embedded mode reclaims ANY leftover lock — even one recording a confirmed-live PID (#11030)", async () => {
// iOS/Android local backend is single-tenant: Bun runs as a thread inside
// the one app process and ElizaBunRuntime serializes engine starts, so a
// leftover lock is stale by definition. The liveness probe is unusable
// there: a prior LAUNCH's PID probes EPERM in the iOS sandbox (honored
// for 7 days -> every relaunch bricked with "PGlite data dir is already
// in use"), and a prior Bun THREAD's recorded PID equals the CURRENT app
// PID (probes alive forever). This is the exact on-device #11030
// post-engine-fix failure shape: lock pid == process.pid, recent
// createdAt, and the app must still boot.
const dataDir = mkdtempSync(path.join(tmpdir(), "eliza-pglite-lock-"));
tempDirs.push(dataDir);
writeFileSync(
lockPathFor(dataDir),
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), dataDir })}\n`
);
process.env.ELIZA_IOS_LOCAL_BACKEND = "1";
try {
const manager = new PGliteClientManager({ dataDir });
await manager.close();
} finally {
delete process.env.ELIZA_IOS_LOCAL_BACKEND;
}
// Reclaimed, re-acquired, and released on close.
expect(existsSync(lockPathFor(dataDir))).toBe(false);
});
});
@@ -0,0 +1,288 @@
/**
* PostgreSQL RLS entity-isolation integration tests against a real Postgres
* with RLS enabled (`docker-compose up -d postgres`). Verifies entity-level
* isolation (user privacy), participant-based access control (room
* membership), Entity RLS composing with Server RLS (double isolation), and
* that `PostgresConnectionManager.withEntityContext()` sets the RLS entity
* context via the parameterized, transaction-scoped
* `set_config('app.entity_id', $1, true)` form — this exercises the real
* production code path, not a raw-interpolation stand-in.
*/
import { stringToUuid, type UUID } from "@elizaos/core";
import { sql } from "drizzle-orm";
import { Client } from "pg";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { PostgresConnectionManager } from "../../../pg/manager";
import { bootstrapPostgresRlsSchema, toPostgresSuperuserUrl } from "./rls-test-helpers";
// Skip these tests if POSTGRES_URL is not set (e.g., in CI without PostgreSQL)
describe.skipIf(!process.env.POSTGRES_URL)("PostgreSQL RLS Entity Integration", () => {
let setupClient: Client; // Setup client for migrations (eliza_test user)
let superuserClient: Client; // Superuser client for data setup (bypasses RLS)
let manager: PostgresConnectionManager; // Production code path for RLS tests
const POSTGRES_URL =
process.env.POSTGRES_URL || "postgresql://eliza_test:test123@localhost:5432/eliza_test";
// Use ELIZA_SERVER_ID if set (CI mode with ENABLE_DATA_ISOLATION=true)
// Otherwise generate a random UUID for local testing
const serverId = process.env.ELIZA_SERVER_ID
? stringToUuid(process.env.ELIZA_SERVER_ID)
: uuidv4();
const aliceId = uuidv4();
const bobId = uuidv4();
const charlieId = uuidv4();
const room1Id = uuidv4();
const room2Id = uuidv4();
const agentId = uuidv4();
beforeAll(async () => {
await bootstrapPostgresRlsSchema(POSTGRES_URL);
setupClient = new Client({
connectionString: POSTGRES_URL,
application_name: serverId,
});
await setupClient.connect();
superuserClient = new Client({
connectionString: toPostgresSuperuserUrl(POSTGRES_URL),
application_name: serverId,
});
await superuserClient.connect();
// Create PostgresConnectionManager for test assertions
// This tests the actual production code path (withEntityContext + sql.raw fix)
manager = new PostgresConnectionManager(POSTGRES_URL, serverId);
// Enable data isolation for these tests (required for withEntityContext to set entity context)
process.env.ENABLE_DATA_ISOLATION = "true";
// Setup test data using superuser (bypasses RLS for initial data creation)
// servers table has no RLS, so any connection can insert
await superuserClient.query(
`INSERT INTO servers (id, created_at, updated_at)
VALUES ($1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[serverId]
);
// Create agent (explicitly set server_id for RLS)
await superuserClient.query(
`INSERT INTO agents (id, name, username, server_id, created_at, updated_at)
VALUES ($1, 'Test Agent RLS', $2, $3, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`,
[agentId, `rls_test_agent_${serverId.substring(0, 8)}`, serverId]
);
// Create entities (server_id is added dynamically by RLS)
try {
const result = await superuserClient.query(
`INSERT INTO entities (id, agent_id, names, metadata, created_at)
VALUES
($1, $4, ARRAY['Alice'], '{}'::jsonb, NOW()),
($2, $4, ARRAY['Bob'], '{}'::jsonb, NOW()),
($3, $4, ARRAY['Charlie'], '{}'::jsonb, NOW())
ON CONFLICT (id) DO UPDATE SET names = EXCLUDED.names
RETURNING id`,
[aliceId, bobId, charlieId, agentId]
);
console.log("[RLS Test] Entities created:", result.rows.length);
} catch (err) {
console.error(
"[RLS Test] Failed to create entities:",
err instanceof Error ? err.message : String(err)
);
throw err;
}
// Create rooms (server_id is added dynamically by RLS)
await superuserClient.query(
`INSERT INTO rooms (id, agent_id, source, type, created_at)
VALUES
($1, $3, 'test', 'DM', NOW()),
($2, $3, 'test', 'GROUP', NOW())
ON CONFLICT (id) DO NOTHING`,
[room1Id, room2Id, agentId]
);
// Create participants (server_id is added dynamically by RLS)
// Room1: Alice + Bob
// Room2: Bob + Charlie
try {
const participantResult = await superuserClient.query(
`INSERT INTO participants (id, entity_id, room_id, agent_id, created_at)
VALUES
(gen_random_uuid(), $1, $2, $4, NOW()),
(gen_random_uuid(), $3, $2, $4, NOW()),
(gen_random_uuid(), $3, $5, $4, NOW()),
(gen_random_uuid(), $6, $5, $4, NOW())
ON CONFLICT DO NOTHING
RETURNING id, entity_id`,
[aliceId, room1Id, bobId, agentId, room2Id, charlieId]
);
console.log(
"[RLS Test] Participants created:",
participantResult.rows.length,
participantResult.rows.map((r: { entity_id?: string }) => ({
e: r.entity_id?.substring(0, 8),
}))
);
} catch (err) {
console.error(
"[RLS Test] Failed to create participants:",
err instanceof Error ? err.message : String(err)
);
console.log("UUIDs:", {
aliceId,
bobId,
charlieId,
room1Id,
room2Id,
agentId,
});
throw err;
}
// Create memories (server_id is added dynamically by RLS)
// Memory in room1 (accessible to Alice and Bob)
await superuserClient.query(
`INSERT INTO memories (id, agent_id, room_id, content, type, created_at)
VALUES (gen_random_uuid(), $1, $2, '{"text": "Message in room1"}', 'message', NOW())`,
[agentId, room1Id]
);
// Memory in room2 (accessible to Bob and Charlie)
await superuserClient.query(
`INSERT INTO memories (id, agent_id, room_id, content, type, created_at)
VALUES (gen_random_uuid(), $1, $2, '{"text": "Message in room2"}', 'message', NOW())`,
[agentId, room2Id]
);
console.log("[RLS Test] Test data setup complete");
});
afterAll(async () => {
// Cleanup using superuser (bypasses RLS)
try {
await superuserClient.query(`DELETE FROM memories WHERE room_id IN ($1, $2)`, [
room1Id,
room2Id,
]);
await superuserClient.query(`DELETE FROM participants WHERE room_id IN ($1, $2)`, [
room1Id,
room2Id,
]);
await superuserClient.query(`DELETE FROM rooms WHERE id IN ($1, $2)`, [room1Id, room2Id]);
await superuserClient.query(`DELETE FROM entities WHERE id IN ($1, $2, $3)`, [
aliceId,
bobId,
charlieId,
]);
await superuserClient.query(`DELETE FROM agents WHERE id = $1`, [agentId]);
await superuserClient.query(`DELETE FROM servers WHERE id = $1`, [serverId]);
} catch (err) {
console.warn("[RLS Test] Cleanup error:", err);
}
await setupClient.end();
await superuserClient.end();
await manager.close();
});
it("should block access without entity context", async () => {
// Without entity context, user should see 0 memories (STRICT mode)
// Use withEntityContext with null to test no entity context
const result = await manager.withEntityContext(null, async (tx) => {
return await tx.execute(sql`SELECT COUNT(*) as count FROM memories`);
});
expect(parseInt(String(result.rows[0].count), 10)).toBe(0);
});
it("should allow Alice to see room1 memories (tests withEntityContext + sql.raw fix)", async () => {
// Exercises the production path: withEntityContext() ->
// sql.raw(`SET LOCAL app.entity_id = '${entityId}'`).
const result = await manager.withEntityContext(aliceId as UUID, async (tx) => {
return await tx.execute(sql`SELECT id, room_id, content FROM memories`);
});
// Alice is in room1, so should see 1 memory
expect(result.rows).toHaveLength(1);
expect(result.rows[0].room_id).toBe(room1Id);
expect((result.rows[0].content as { text: string }).text).toContain("room1");
});
it("should allow Bob to see BOTH room1 and room2 memories", async () => {
const result = await manager.withEntityContext(bobId as UUID, async (tx) => {
return await tx.execute(sql`SELECT id, room_id, content FROM memories ORDER BY room_id`);
});
// Bob is in both rooms, so should see 2 memories
expect(result.rows).toHaveLength(2);
expect(result.rows.map((r: { room_id: string }) => r.room_id)).toContain(room1Id);
expect(result.rows.map((r: { room_id: string }) => r.room_id)).toContain(room2Id);
});
it("should allow Charlie to see ONLY room2 memories", async () => {
const result = await manager.withEntityContext(charlieId as UUID, async (tx) => {
return await tx.execute(sql`SELECT id, room_id, content FROM memories`);
});
// Charlie is only in room2
expect(result.rows).toHaveLength(1);
expect(result.rows[0].room_id).toBe(room2Id);
expect((result.rows[0].content as { text: string }).text).toContain("room2");
});
it("should block non-participant from seeing any memories", async () => {
const nonParticipantId = uuidv4();
const result = await manager.withEntityContext(nonParticipantId as UUID, async (tx) => {
return await tx.execute(sql`SELECT COUNT(*) as count FROM memories`);
});
// Non-participant should see 0
expect(parseInt(String(result.rows[0].count), 10)).toBe(0);
});
it("should have entity_isolation_policy on key tables", async () => {
// pg_policies is a system catalog, any user can query it
const result = await manager.withEntityContext(null, async (tx) => {
return await tx.execute(sql`
SELECT DISTINCT tablename
FROM pg_policies
WHERE policyname = 'entity_isolation_policy'
AND tablename IN ('memories', 'participants', 'components', 'logs', 'tasks')
`);
});
expect(result.rows.length).toBeGreaterThanOrEqual(3);
});
it("should use current_entity_id() function correctly via withEntityContext", async () => {
const result = await manager.withEntityContext(aliceId as UUID, async (tx) => {
return await tx.execute(sql`SELECT current_entity_id() as eid`);
});
expect(result.rows[0].eid).toBe(aliceId);
});
it("should combine Server RLS + Entity RLS (double isolation)", async () => {
// Create a manager with wrong server context
const wrongServerId = uuidv4();
const wrongServerManager = new PostgresConnectionManager(POSTGRES_URL, wrongServerId);
try {
// Even with correct entity_id, wrong server_id should see nothing
const result = await wrongServerManager.withEntityContext(aliceId as UUID, async (tx) => {
return await tx.execute(sql`SELECT COUNT(*) as count FROM memories`);
});
// Wrong server context blocks access
expect(parseInt(String(result.rows[0].count), 10)).toBe(0);
} finally {
await wrongServerManager.close();
}
});
});
@@ -0,0 +1,336 @@
/**
* PostgreSQL RLS integration tests for the `logs` table's STRICT entity
* isolation: logs hold sensitive activity data (model usage, embeddings)
* and must be visible only to entities participating in the log's room, with
* zero rows returned when no entity context is set. All connections use the
* `eliza_test` role (not superuser); `application_name` supplies the server
* context for RLS.
*/
import { Client } from "pg";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { bootstrapPostgresRlsSchema } from "./rls-test-helpers";
describe.skipIf(!process.env.POSTGRES_URL)("PostgreSQL RLS - Logs Isolation (STRICT)", () => {
let setupClient: Client; // Setup client with server context
let aliceClient: Client;
let bobClient: Client;
const POSTGRES_URL =
process.env.POSTGRES_URL || "postgresql://eliza_test:test123@localhost:5432/eliza_test";
const serverId = uuidv4();
const aliceId = uuidv4();
const bobId = uuidv4();
const agentId = uuidv4();
const sharedRoomId = uuidv4(); // Alice + Agent
const alicePrivateRoomId = uuidv4(); // Alice only
const bobPrivateRoomId = uuidv4(); // Bob only
beforeAll(async () => {
await bootstrapPostgresRlsSchema(POSTGRES_URL);
// Setup client with server context (for creating test data)
setupClient = new Client({
connectionString: POSTGRES_URL,
application_name: serverId,
});
await setupClient.connect();
// Alice client with Entity RLS context
aliceClient = new Client({
connectionString: POSTGRES_URL,
application_name: serverId,
});
await aliceClient.connect();
// Bob client with Entity RLS context
bobClient = new Client({
connectionString: POSTGRES_URL,
application_name: serverId,
});
await bobClient.connect();
// Setup test data (with server context via application_name)
// servers table has no RLS, so any connection can insert
await setupClient.query(
`INSERT INTO servers (id, created_at, updated_at)
VALUES ($1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[serverId]
);
await setupClient.query(
`INSERT INTO agents (id, name, username, server_id, created_at, updated_at)
VALUES ($1, 'Log Test Agent', $2, $3, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`,
[agentId, `log_test_agent_${serverId.substring(0, 8)}`, serverId]
);
await setupClient.query(
`INSERT INTO entities (id, agent_id, names, metadata, created_at)
VALUES
($1, $3, ARRAY['Alice'], '{}'::jsonb, NOW()),
($2, $3, ARRAY['Bob'], '{}'::jsonb, NOW())
ON CONFLICT (id) DO UPDATE SET names = EXCLUDED.names`,
[aliceId, bobId, agentId]
);
await setupClient.query(
`INSERT INTO rooms (id, agent_id, source, type, created_at)
VALUES
($1, $4, 'test', 'DM', NOW()),
($2, $4, 'test', 'DM', NOW()),
($3, $4, 'test', 'DM', NOW())
ON CONFLICT (id) DO NOTHING`,
[sharedRoomId, alicePrivateRoomId, bobPrivateRoomId, agentId]
);
// Create participants (using snake_case column names)
// Shared room: Alice only (agents are not participants)
await setupClient.query(
`INSERT INTO participants (entity_id, room_id, agent_id, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT DO NOTHING`,
[aliceId, sharedRoomId, agentId]
);
// Alice private room: Alice only
await setupClient.query(
`INSERT INTO participants (entity_id, room_id, agent_id, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT DO NOTHING`,
[aliceId, alicePrivateRoomId, agentId]
);
// Bob private room: Bob only
await setupClient.query(
`INSERT INTO participants (entity_id, room_id, agent_id, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT DO NOTHING`,
[bobId, bobPrivateRoomId, agentId]
);
// Create test logs (using snake_case column names)
// Need to use entity context for STRICT RLS tables
await setupClient.query("BEGIN");
await setupClient.query(`SET LOCAL app.entity_id = '${aliceId}'`);
// Log 1: Alice in shared room
await setupClient.query(
`INSERT INTO logs (id, entity_id, room_id, type, body, created_at)
VALUES ($1, $2, $3, 'useModel:TEXT_EMBEDDING', '{"model":"ada-002","tokens":100}'::jsonb, NOW())`,
[uuidv4(), aliceId, sharedRoomId]
);
// Log 2: Alice in private room
await setupClient.query(
`INSERT INTO logs (id, entity_id, room_id, type, body, created_at)
VALUES ($1, $2, $3, 'useModel:TEXT_LARGE', '{"model":"gpt-5","tokens":500}'::jsonb, NOW())`,
[uuidv4(), aliceId, alicePrivateRoomId]
);
await setupClient.query("COMMIT");
// Bob's log (with Bob's entity context)
await setupClient.query("BEGIN");
await setupClient.query(`SET LOCAL app.entity_id = '${bobId}'`);
await setupClient.query(
`INSERT INTO logs (id, entity_id, room_id, type, body, created_at)
VALUES ($1, $2, $3, 'useModel:TEXT_EMBEDDING', '{"model":"ada-002","tokens":50}'::jsonb, NOW())`,
[uuidv4(), bobId, bobPrivateRoomId]
);
await setupClient.query("COMMIT");
console.log("[RLS Logs Test] Test data created:", {
aliceId: aliceId.substring(0, 8),
bobId: bobId.substring(0, 8),
sharedRoom: sharedRoomId.substring(0, 8),
alicePrivateRoom: alicePrivateRoomId.substring(0, 8),
bobPrivateRoom: bobPrivateRoomId.substring(0, 8),
});
});
afterAll(async () => {
// Deletes need entity context because logs is a STRICT-RLS table.
try {
await setupClient.query("BEGIN");
await setupClient.query(`SET LOCAL app.entity_id = '${aliceId}'`);
await setupClient.query(`DELETE FROM logs WHERE room_id IN ($1, $2)`, [
sharedRoomId,
alicePrivateRoomId,
]);
await setupClient.query("COMMIT");
await setupClient.query("BEGIN");
await setupClient.query(`SET LOCAL app.entity_id = '${bobId}'`);
await setupClient.query(`DELETE FROM logs WHERE room_id = $1`, [bobPrivateRoomId]);
await setupClient.query("COMMIT");
await setupClient.query(`DELETE FROM participants WHERE room_id IN ($1, $2, $3)`, [
sharedRoomId,
alicePrivateRoomId,
bobPrivateRoomId,
]);
await setupClient.query(`DELETE FROM rooms WHERE id IN ($1, $2, $3)`, [
sharedRoomId,
alicePrivateRoomId,
bobPrivateRoomId,
]);
await setupClient.query(`DELETE FROM entities WHERE id IN ($1, $2)`, [aliceId, bobId]);
await setupClient.query(`DELETE FROM agents WHERE id = $1`, [agentId]);
await setupClient.query(`DELETE FROM servers WHERE id = $1`, [serverId]);
} catch (err) {
console.warn("[RLS Logs Test] Cleanup failed:", err);
}
if (setupClient) await setupClient.end();
if (aliceClient) await aliceClient.end();
if (bobClient) await bobClient.end();
});
it("should verify RLS is enabled on logs table", async () => {
const result = await setupClient.query(`
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'logs'
`);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].rowsecurity).toBe(true);
});
it("should verify STRICT entity_isolation_policy exists on logs", async () => {
const result = await setupClient.query(`
SELECT policyname, permissive, cmd
FROM pg_policies
WHERE schemaname = 'public'
AND tablename = 'logs'
AND policyname = 'entity_isolation_policy'
`);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].policyname).toBe("entity_isolation_policy");
expect(result.rows[0].cmd).toBe("ALL"); // Applies to SELECT, INSERT, UPDATE, DELETE
});
it("should isolate Alice logs from Bob (Alice sees 2, Bob sees 1)", async () => {
await aliceClient.query("BEGIN");
await aliceClient.query(`SET LOCAL app.entity_id = '${aliceId}'`);
const aliceResult = await aliceClient.query(
`
SELECT id, entity_id, room_id, type
FROM logs
WHERE entity_id = $1
ORDER BY created_at DESC
`,
[aliceId]
);
await aliceClient.query("COMMIT");
expect(aliceResult.rows).toHaveLength(2);
expect(aliceResult.rows.every((row) => row.entity_id === aliceId)).toBe(true);
await bobClient.query("BEGIN");
await bobClient.query(`SET LOCAL app.entity_id = '${bobId}'`);
const bobResult = await bobClient.query(
`
SELECT id, entity_id, room_id, type
FROM logs
WHERE entity_id = $1
ORDER BY created_at DESC
`,
[bobId]
);
await bobClient.query("COMMIT");
expect(bobResult.rows).toHaveLength(1);
expect(bobResult.rows[0].entity_id).toBe(bobId);
});
it("should allow Alice to see logs from shared room (Agent + Alice)", async () => {
await aliceClient.query("BEGIN");
await aliceClient.query(`SET LOCAL app.entity_id = '${aliceId}'`);
const result = await aliceClient.query(
`
SELECT id, entity_id, room_id, type
FROM logs
WHERE room_id = $1
`,
[sharedRoomId]
);
await aliceClient.query("COMMIT");
// Alice should see the log from shared room
expect(result.rows).toHaveLength(1);
expect(result.rows[0].room_id).toBe(sharedRoomId);
expect(result.rows[0].entity_id).toBe(aliceId);
});
it("should block Bob from seeing Alice private room logs", async () => {
await bobClient.query("BEGIN");
await bobClient.query(`SET LOCAL app.entity_id = '${bobId}'`);
const result = await bobClient.query(
`
SELECT id, entity_id, room_id, type
FROM logs
WHERE room_id = $1
`,
[alicePrivateRoomId]
);
await bobClient.query("COMMIT");
// Bob should NOT see Alice's private logs (RLS blocks)
expect(result.rows).toHaveLength(0);
});
it("should block Alice from seeing Bob private room logs", async () => {
await aliceClient.query("BEGIN");
await aliceClient.query(`SET LOCAL app.entity_id = '${aliceId}'`);
const result = await aliceClient.query(
`
SELECT id, entity_id, room_id, type
FROM logs
WHERE room_id = $1
`,
[bobPrivateRoomId]
);
await aliceClient.query("COMMIT");
// Alice should NOT see Bob's private logs (RLS blocks)
expect(result.rows).toHaveLength(0);
});
it("should block queries when entity_id is NOT set (STRICT mode)", async () => {
// Without SET LOCAL app.entity_id, should see 0 results
const result = await aliceClient.query(`
SELECT id, entity_id, room_id, type
FROM logs
ORDER BY created_at DESC
`);
// STRICT mode: NO rows visible without entity context
expect(result.rows).toHaveLength(0);
});
it("should verify logs table is in STRICT mode (memories, logs, components, tasks)", async () => {
const result = await setupClient.query(`
SELECT
c.relname as table_name,
p.polname as policy_name,
pg_get_expr(p.polqual, p.polrelid) as policy_qual
FROM pg_policy p
JOIN pg_class c ON p.polrelid = c.oid
WHERE c.relname = 'logs'
AND p.polname = 'entity_isolation_policy'
`);
expect(result.rows).toHaveLength(1);
const policyQual = result.rows[0].policy_qual;
// STRICT mode should have: (current_entity_id() IS NOT NULL) AND (room_id IN ...)
// PERMISSIVE mode would have: (current_entity_id() IS NULL) OR (room_id IN ...)
expect(policyQual).toContain("current_entity_id()");
expect(policyQual).toContain("IS NOT NULL"); // STRICT check
expect(policyQual).toContain("room_id"); // snake_case column name
});
});
@@ -0,0 +1,311 @@
/**
* PostgreSQL RLS integration tests for `message_server_agents`: verifies
* Server A cannot see, join across, modify, or delete Server B's associations
* — isolating Discord/Telegram server associations by `server_id`. All
* connections use the `eliza_test` role (not superuser); `application_name`
* supplies each server's RLS context, and each server's data is set up via
* its own connection.
*/
import { Client } from "pg";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { bootstrapPostgresRlsSchema } from "./rls-test-helpers";
describe.skipIf(!process.env.POSTGRES_URL)(
"PostgreSQL RLS - message_server_agents Isolation",
() => {
let setupClientA: Client; // Setup client for server A
let setupClientB: Client; // Setup client for server B
let serverAClient: Client;
let serverBClient: Client;
const POSTGRES_URL =
process.env.POSTGRES_URL || "postgresql://eliza_test:test123@localhost:5432/eliza_test";
const serverAId = uuidv4();
const serverBId = uuidv4();
const agentAId = uuidv4();
const agentBId = uuidv4();
const messageServerA1Id = uuidv4();
const messageServerA2Id = uuidv4();
const messageServerB1Id = uuidv4();
beforeAll(async () => {
await bootstrapPostgresRlsSchema(POSTGRES_URL);
// Setup clients - each with its own server context
setupClientA = new Client({
connectionString: POSTGRES_URL,
application_name: serverAId,
});
setupClientB = new Client({
connectionString: POSTGRES_URL,
application_name: serverBId,
});
await setupClientA.connect();
await setupClientB.connect();
// User clients (same as setup, just clearer naming)
serverAClient = new Client({
connectionString: POSTGRES_URL,
application_name: serverAId,
});
serverBClient = new Client({
connectionString: POSTGRES_URL,
application_name: serverBId,
});
await serverAClient.connect();
await serverBClient.connect();
// Create RLS servers (servers table has no RLS)
await setupClientA.query(
`INSERT INTO servers (id, created_at, updated_at)
VALUES ($1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[serverAId]
);
await setupClientB.query(
`INSERT INTO servers (id, created_at, updated_at)
VALUES ($1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[serverBId]
);
// Create agents for each server (each client creates its own server's agent)
await setupClientA.query(
`INSERT INTO agents (id, name, username, server_id, created_at, updated_at)
VALUES ($1, 'Agent A', 'rls_test_agent_a', $2, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[agentAId, serverAId]
);
await setupClientB.query(
`INSERT INTO agents (id, name, username, server_id, created_at, updated_at)
VALUES ($1, 'Agent B', 'rls_test_agent_b', $2, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[agentBId, serverBId]
);
// Create message servers (each client creates its own)
await setupClientA.query(
`INSERT INTO message_servers (id, source_type, source_id, name, server_id, created_at, updated_at)
VALUES
($1, 'discord', 'discord_a1', 'Discord Server A1', $3, NOW(), NOW()),
($2, 'discord', 'discord_a2', 'Discord Server A2', $3, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[messageServerA1Id, messageServerA2Id, serverAId]
);
await setupClientB.query(
`INSERT INTO message_servers (id, source_type, source_id, name, server_id, created_at, updated_at)
VALUES ($1, 'discord', 'discord_b1', 'Discord Server B1', $2, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[messageServerB1Id, serverBId]
);
});
afterAll(async () => {
// Cleanup - each client cleans its own server's data (RLS enforced)
try {
await setupClientA.query(`DELETE FROM message_server_agents WHERE agent_id = $1`, [
agentAId,
]);
await setupClientA.query(`DELETE FROM message_servers WHERE id IN ($1, $2)`, [
messageServerA1Id,
messageServerA2Id,
]);
await setupClientA.query(`DELETE FROM agents WHERE id = $1`, [agentAId]);
await setupClientA.query(`DELETE FROM servers WHERE id = $1`, [serverAId]);
} catch (err) {
console.warn("Cleanup error (server A):", err);
}
try {
await setupClientB.query(`DELETE FROM message_server_agents WHERE agent_id = $1`, [
agentBId,
]);
await setupClientB.query(`DELETE FROM message_servers WHERE id = $1`, [messageServerB1Id]);
await setupClientB.query(`DELETE FROM agents WHERE id = $1`, [agentBId]);
await setupClientB.query(`DELETE FROM servers WHERE id = $1`, [serverBId]);
} catch (err) {
console.warn("Cleanup error (server B):", err);
}
await setupClientA.end();
await setupClientB.end();
await serverAClient.end();
await serverBClient.end();
});
it("should isolate message_server_agents entries by server_id", async () => {
// Server A creates associations
await serverAClient.query(
`
INSERT INTO message_server_agents (message_server_id, agent_id)
VALUES ($1, $2), ($3, $2)
`,
[messageServerA1Id, agentAId, messageServerA2Id]
);
// Server B creates association
await serverBClient.query(
`
INSERT INTO message_server_agents (message_server_id, agent_id)
VALUES ($1, $2)
`,
[messageServerB1Id, agentBId]
);
// Server A should only see its own associations (2 entries)
const resultA = await serverAClient.query(`
SELECT message_server_id, agent_id, server_id
FROM message_server_agents
ORDER BY message_server_id
`);
expect(resultA.rows).toHaveLength(2);
expect(resultA.rows[0].agent_id).toBe(agentAId);
expect(resultA.rows[1].agent_id).toBe(agentAId);
expect(resultA.rows[0].server_id).toBe(serverAId);
expect(resultA.rows[1].server_id).toBe(serverAId);
// Server B should only see its own association (1 entry)
const resultB = await serverBClient.query(`
SELECT message_server_id, agent_id, server_id
FROM message_server_agents
ORDER BY message_server_id
`);
expect(resultB.rows).toHaveLength(1);
expect(resultB.rows[0].agent_id).toBe(agentBId);
expect(resultB.rows[0].server_id).toBe(serverBId);
// Both servers have their data (verified by individual queries above)
// RLS properly isolates them - no superuser needed to verify total count
});
it("should auto-populate server_id with current_server_id()", async () => {
// Insert without specifying server_id
await serverAClient.query(
`
INSERT INTO message_server_agents (message_server_id, agent_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`,
[messageServerA1Id, agentAId]
);
// Verify server_id was set automatically (query with same server context)
const result = await serverAClient.query(
`
SELECT server_id
FROM message_server_agents
WHERE message_server_id = $1 AND agent_id = $2
`,
[messageServerA1Id, agentAId]
);
expect(result.rows[0].server_id).toBe(serverAId);
});
it("should prevent Server A from seeing Server B message servers via JOIN", async () => {
// Server A tries to find all message_servers via JOIN
const result = await serverAClient.query(
`
SELECT ms.id, ms.name, msa.agent_id
FROM message_servers ms
LEFT JOIN message_server_agents msa ON ms.id = msa.message_server_id
WHERE ms.id IN ($1, $2, $3)
`,
[messageServerA1Id, messageServerA2Id, messageServerB1Id]
);
// Server A should only see its own message_servers (A1, A2)
// Server B's message_server (B1) should not be visible due to Server RLS on message_servers table
expect(result.rows.length).toBeLessThan(3);
// All visible rows should belong to Server A
result.rows.forEach((row) => {
expect([messageServerA1Id, messageServerA2Id]).toContain(row.id);
});
});
it("should have server_isolation_policy applied", async () => {
// pg_policies is a system catalog, any user can query it
const result = await serverAClient.query(`
SELECT policyname, cmd, qual
FROM pg_policies
WHERE tablename = 'message_server_agents'
AND policyname = 'server_isolation_policy'
`);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].qual).toContain("server_id = current_server_id()");
});
it("should have server_id column with DEFAULT current_server_id()", async () => {
// information_schema is accessible by any user
const result = await serverAClient.query(`
SELECT column_name, column_default
FROM information_schema.columns
WHERE table_name = 'message_server_agents'
AND column_name = 'server_id'
`);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].column_default).toContain("current_server_id()");
});
it("should prevent Server A from querying Server B associations even with known UUIDs", async () => {
// This simulates what getAgentsForMessageServer() does in production
// Server A tries to query agents for Server B's message server (messageServerB1Id)
// Even though Server A knows the UUID, RLS should prevent seeing the data
const result = await serverAClient.query(
`
SELECT agent_id
FROM message_server_agents
WHERE message_server_id = $1
`,
[messageServerB1Id]
);
// Server A should see NOTHING because RLS filters by server_id
expect(result.rows).toHaveLength(0);
// Verify with Server B that its association exists (same data, different context)
const serverBResult = await serverBClient.query(
`
SELECT agent_id, server_id
FROM message_server_agents
WHERE message_server_id = $1
`,
[messageServerB1Id]
);
expect(serverBResult.rows).toHaveLength(1);
expect(serverBResult.rows[0].agent_id).toBe(agentBId);
expect(serverBResult.rows[0].server_id).toBe(serverBId);
});
it("should prevent Server B from modifying Server A associations", async () => {
// Server B tries to delete Server A's association
await serverBClient.query(
`
DELETE FROM message_server_agents
WHERE message_server_id = $1 AND agent_id = $2
`,
[messageServerA1Id, agentAId]
);
// Verify with Server A that its association still exists (delete was blocked by RLS)
const result = await serverAClient.query(
`
SELECT *
FROM message_server_agents
WHERE message_server_id = $1 AND agent_id = $2
`,
[messageServerA1Id, agentAId]
);
expect(result.rows.length).toBeGreaterThan(0);
});
}
);
@@ -0,0 +1,187 @@
/**
* PostgreSQL RLS integration tests for server-level isolation between
* different elizaOS instances sharing one database: enforced for
* non-superuser accounts, using the `eliza_test` role for all connections
* (`application_name` supplies each server's RLS context). Verifies data is
* completely isolated between servers.
*/
import { Client } from "pg";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { bootstrapPostgresRlsSchema } from "./rls-test-helpers";
// Skip these tests if POSTGRES_URL is not set (e.g., in CI without PostgreSQL)
describe.skipIf(!process.env.POSTGRES_URL)("PostgreSQL RLS Server Integration", () => {
let setupClient1: Client; // Setup client for server 1 (with server1 context)
let setupClient2: Client; // Setup client for server 2 (with server2 context)
let userClient1: Client;
let userClient2: Client;
const POSTGRES_URL =
process.env.POSTGRES_URL || "postgresql://eliza_test:test123@localhost:5432/eliza_test";
const server1Id = uuidv4();
const server2Id = uuidv4();
beforeAll(async () => {
await bootstrapPostgresRlsSchema(POSTGRES_URL);
// Setup clients - each with its own server context (application_name)
// No superuser needed - eliza_test is subject to RLS, so each connection
// can only manage data for its own server_id
setupClient1 = new Client({
connectionString: POSTGRES_URL,
application_name: server1Id,
});
setupClient2 = new Client({
connectionString: POSTGRES_URL,
application_name: server2Id,
});
await setupClient1.connect();
await setupClient2.connect();
// User clients (same as setup, just clearer naming for test assertions)
userClient1 = new Client({
connectionString: POSTGRES_URL,
application_name: server1Id,
});
userClient2 = new Client({
connectionString: POSTGRES_URL,
application_name: server2Id,
});
await userClient1.connect();
await userClient2.connect();
// Create servers - each setup client creates its own server
// (servers table may not have RLS, but this pattern is consistent)
await setupClient1.query(
`INSERT INTO servers (id, created_at, updated_at)
VALUES ($1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[server1Id]
);
await setupClient2.query(
`INSERT INTO servers (id, created_at, updated_at)
VALUES ($1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[server2Id]
);
});
afterAll(async () => {
// Cleanup - each client cleans its own server's data (RLS enforced)
try {
await setupClient1.query(`DELETE FROM agents WHERE username = 'rls_test_server1'`);
await setupClient1.query(`DELETE FROM servers WHERE id = $1`, [server1Id]);
} catch (err) {
console.warn("Cleanup error (server1):", err);
}
try {
await setupClient2.query(`DELETE FROM agents WHERE username = 'rls_test_server2'`);
await setupClient2.query(`DELETE FROM servers WHERE id = $1`, [server2Id]);
} catch (err) {
console.warn("Cleanup error (server2):", err);
}
await setupClient1.end();
await setupClient2.end();
await userClient1.end();
await userClient2.end();
});
it("should isolate agents by server_id", async () => {
const agent1Id = uuidv4();
const agent2Id = uuidv4();
// Server 1 creates an agent
await userClient1.query(
`
INSERT INTO agents (id, name, username, server_id, created_at, updated_at)
VALUES ($1, 'Agent Server 1', 'rls_test_server1', $2, NOW(), NOW())
`,
[agent1Id, server1Id]
);
// Server 2 creates an agent
await userClient2.query(
`
INSERT INTO agents (id, name, username, server_id, created_at, updated_at)
VALUES ($1, 'Agent Server 2', 'rls_test_server2', $2, NOW(), NOW())
`,
[agent2Id, server2Id]
);
// Server 1 should only see its own agent
const result1 = await userClient1.query(`
SELECT id, name, username, server_id
FROM agents
WHERE username IN ('rls_test_server1', 'rls_test_server2')
`);
expect(result1.rows).toHaveLength(1);
expect(result1.rows[0].username).toBe("rls_test_server1");
expect(result1.rows[0].server_id).toBe(server1Id);
// Server 2 should only see its own agent
const result2 = await userClient2.query(`
SELECT id, name, username, server_id
FROM agents
WHERE username IN ('rls_test_server1', 'rls_test_server2')
`);
expect(result2.rows).toHaveLength(1);
expect(result2.rows[0].username).toBe("rls_test_server2");
expect(result2.rows[0].server_id).toBe(server2Id);
// Both agents exist (verified by each seeing their own)
// RLS properly isolates them - no superuser needed to verify total count
});
it("should enforce RLS on all tables with server_id", async () => {
// Check that RLS is enabled on key tables (pg_tables is a system catalog, no RLS)
const result = await userClient1.query(`
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN ('agents', 'rooms', 'memories', 'channels')
AND rowsecurity = true
`);
expect(result.rows.length).toBeGreaterThan(0);
result.rows.forEach((row: { rowsecurity: boolean }) => {
expect(row.rowsecurity).toBe(true);
});
});
it("should have server_isolation_policy on tables", async () => {
// pg_policies is a system catalog, any user can query it
const result = await userClient1.query(`
SELECT DISTINCT tablename
FROM pg_policies
WHERE policyname = 'server_isolation_policy'
AND tablename IN ('agents', 'rooms', 'memories')
`);
expect(result.rows.length).toBeGreaterThanOrEqual(3);
});
it("should block cross-server data access", async () => {
// Server 1 tries to access Server 2's data directly
const result = await userClient1.query(`
SELECT COUNT(*) as count
FROM agents
WHERE username = 'rls_test_server2'
`);
// Should see 0 (RLS blocks it)
expect(parseInt(result.rows[0].count, 10)).toBe(0);
});
it("should use current_server_id() function correctly", async () => {
const result1 = await userClient1.query(`SELECT current_server_id() as sid`);
const result2 = await userClient2.query(`SELECT current_server_id() as sid`);
expect(result1.rows[0].sid).toBe(server1Id);
expect(result2.rows[0].sid).toBe(server2Id);
});
});
@@ -0,0 +1,71 @@
/**
* Shared setup for the Postgres RLS integration tests in this directory:
* `bootstrapPostgresRlsSchema` wipes the target database (superuser),
* re-runs migrations, installs RLS functions/policies, and grants the
* non-superuser `eliza_test` role table/sequence access so the tests can
* exercise RLS as that role. `toPostgresSuperuserUrl` derives the
* superuser connection string used for the wipe/grant steps.
*/
import type { IDatabaseAdapter } from "@elizaos/core";
import { drizzle } from "drizzle-orm/node-postgres";
import { Client } from "pg";
import { plugin as sqlPlugin } from "../../../index";
import { DatabaseMigrationService } from "../../../migration-service";
import { applyEntityRLSToAllTables, applyRLSToNewTables, installRLSFunctions } from "../../../rls";
export function toPostgresSuperuserUrl(connectionString: string): string {
const superuserUrl = new URL(connectionString);
superuserUrl.username = "postgres";
superuserUrl.password = "postgres";
return superuserUrl.toString();
}
export async function bootstrapPostgresRlsSchema(connectionString: string): Promise<void> {
const setupClient = new Client({ connectionString });
const superuserClient = new Client({
connectionString: toPostgresSuperuserUrl(connectionString),
});
const previousIsolationSetting = process.env.ENABLE_DATA_ISOLATION;
await superuserClient.connect();
await setupClient.connect();
try {
await superuserClient.query(`DROP SCHEMA IF EXISTS migrations CASCADE`);
await superuserClient.query(`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`);
process.env.ENABLE_DATA_ISOLATION = "true";
const db = drizzle(setupClient);
const migrationService = new DatabaseMigrationService();
await migrationService.initializeWithDatabase(db);
migrationService.discoverAndRegisterPluginSchemas([sqlPlugin]);
await migrationService.runAllPluginMigrations();
const adapter = { db } as IDatabaseAdapter;
await installRLSFunctions(adapter);
await applyRLSToNewTables(adapter);
await applyEntityRLSToAllTables(adapter);
await setupClient.query(`GRANT ALL ON ALL TABLES IN SCHEMA public TO eliza_test`);
await setupClient.query(`GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO eliza_test`);
} finally {
if (previousIsolationSetting === undefined) {
delete process.env.ENABLE_DATA_ISOLATION;
} else {
process.env.ENABLE_DATA_ISOLATION = previousIsolationSetting;
}
await setupClient.end();
await superuserClient.end();
}
}
@@ -0,0 +1,166 @@
/**
* Integration tests for entity relationship create/update/retrieve against a
* real isolated PGlite/Postgres adapter, including duplicate-pair dedup and
* tag-scoped lookups.
*/
import type { AgentRuntime, Entity, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { relationshipTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Relationship Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let _runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testEntityId: UUID;
let testTargetEntityId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("relationship-tests");
adapter = setup.adapter;
_runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
testEntityId = uuidv4() as UUID;
testTargetEntityId = uuidv4() as UUID;
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
{
id: testTargetEntityId,
agentId: testAgentId,
names: ["Target Entity"],
} as Entity,
]);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Relationship Tests", () => {
beforeEach(async () => {
await (adapter.getDatabase() as DrizzleDatabase).delete(relationshipTable);
});
it("should create and retrieve a relationship", async () => {
const relationshipData = {
sourceEntityId: testEntityId,
targetEntityId: testTargetEntityId,
tags: ["friend"],
};
const result = await adapter.createRelationship(relationshipData);
expect(result).toBe(true);
const retrieved = await adapter.getRelationship({
sourceEntityId: testEntityId,
targetEntityId: testTargetEntityId,
});
expect(retrieved).toBeDefined();
expect(retrieved?.tags).toContain("friend");
});
it("should ignore duplicate relationship creation for the same pair", async () => {
const relationshipData = {
sourceEntityId: testEntityId,
targetEntityId: testTargetEntityId,
tags: ["friend"],
};
await expect(adapter.createRelationship(relationshipData)).resolves.toBe(true);
await expect(adapter.createRelationship(relationshipData)).resolves.toBe(false);
const relationships = await adapter.getRelationships({
entityIds: [testEntityId, testTargetEntityId],
});
expect(relationships).toHaveLength(1);
});
it("should update an existing relationship", async () => {
const relationshipData = {
sourceEntityId: testEntityId,
targetEntityId: testTargetEntityId,
tags: ["friend"],
};
await adapter.createRelationship(relationshipData);
const retrieved = await adapter.getRelationship({
sourceEntityId: testEntityId,
targetEntityId: testTargetEntityId,
});
expect(retrieved).toBeDefined();
const updatedRelationship = {
...retrieved!,
tags: ["best_friend"],
metadata: { since: "2023" },
};
await adapter.updateRelationship(updatedRelationship);
const updatedRetrieved = await adapter.getRelationship({
sourceEntityId: testEntityId,
targetEntityId: testTargetEntityId,
});
expect(updatedRetrieved?.tags).toContain("best_friend");
expect(updatedRetrieved?.metadata).toEqual({
since: "2023",
});
});
it("should retrieve relationships by entity ID and tags", async () => {
await adapter.createRelationship({
sourceEntityId: testEntityId,
targetEntityId: testTargetEntityId,
tags: ["friend", "colleague"],
});
const otherTargetId = uuidv4() as UUID;
await adapter.createEntities([
{
id: otherTargetId,
agentId: testAgentId,
names: ["Other Entity"],
} as Entity,
]);
await adapter.createRelationship({
sourceEntityId: testEntityId,
targetEntityId: otherTargetId,
tags: ["family"],
});
const results = await adapter.getRelationships({
entityId: testEntityId,
tags: ["friend"],
});
expect(results).toHaveLength(1);
expect(results[0].targetEntityId).toBe(testTargetEntityId);
});
it("should retrieve relationships by entityIds and tags", async () => {
await adapter.createRelationship({
sourceEntityId: testEntityId,
targetEntityId: testTargetEntityId,
tags: ["friend", "colleague"],
});
const results = await adapter.getRelationships({
entityIds: [testEntityId],
tags: ["friend"],
});
expect(results).toHaveLength(1);
expect(results[0].targetEntityId).toBe(testTargetEntityId);
});
});
});
@@ -0,0 +1,152 @@
/**
* Room-store CRUD tests against a real PGlite (or Postgres, if `POSTGRES_URL`
* is set) adapter via `createIsolatedTestDatabase` — no mocks.
*/
import { type AgentRuntime, ChannelType, type Room, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { roomTable } from "../../schema";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Room Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let _runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testWorldId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("room-tests");
adapter = setup.adapter;
_runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
testWorldId = uuidv4() as UUID;
await adapter.createWorld({
id: testWorldId,
agentId: testAgentId,
name: "Test World",
messageServerId: uuidv4() as UUID,
});
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Room Tests", () => {
beforeEach(async () => {
const db = adapter.getDatabase() as {
delete: (table: typeof roomTable) => Promise<void>;
};
await db.delete(roomTable);
});
it("should create and retrieve a room", async () => {
const roomId = uuidv4() as UUID;
const room: Room = {
id: roomId,
agentId: testAgentId,
worldId: testWorldId,
source: "test",
type: ChannelType.GROUP,
name: "Test Room",
};
await adapter.createRooms([room]);
const retrieved = await adapter.getRoomsByIds([roomId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Room should exist");
expect(retrieved[0].id).toBe(roomId);
});
it("should get all rooms for a world", async () => {
const room1: Room = {
id: uuidv4() as UUID,
agentId: testAgentId,
worldId: testWorldId,
source: "test",
type: ChannelType.GROUP,
name: "Room 1",
};
const room2: Room = {
id: uuidv4() as UUID,
agentId: testAgentId,
worldId: testWorldId,
source: "test",
type: ChannelType.GROUP,
name: "Room 2",
};
await adapter.createRooms([room1, room2]);
const rooms = await adapter.getRoomsByWorld(testWorldId);
expect(rooms).toHaveLength(2);
});
it("should update a room", async () => {
const roomId = uuidv4() as UUID;
const room = {
id: roomId,
agentId: testAgentId,
worldId: testWorldId,
source: "test",
type: ChannelType.GROUP,
name: "Original Room Name",
};
await adapter.createRooms([room as Room]);
const updatedRoom = { ...room, name: "Updated Room Name" };
await adapter.updateRoom(updatedRoom);
const retrieved = await adapter.getRoomsByIds([roomId]);
expect(retrieved).not.toBeNull();
if (!retrieved?.[0]) throw new Error("Room should exist");
expect(retrieved[0].name).toBe("Updated Room Name");
});
it("should delete a room", async () => {
const roomId = uuidv4() as UUID;
const room = {
id: roomId,
agentId: testAgentId,
worldId: testWorldId,
source: "test",
type: ChannelType.GROUP,
name: "To Be Deleted",
};
await adapter.createRooms([room as Room]);
let retrieved = await adapter.getRoomsByIds([room.id]);
expect(retrieved).toHaveLength(1);
await adapter.deleteRoom(room.id);
retrieved = await adapter.getRoomsByIds([room.id]);
expect(retrieved).toEqual([]);
});
it("should store and retrieve messageServerId", async () => {
const roomId = uuidv4() as UUID;
const messageServerId = uuidv4() as UUID;
const room: Room = {
id: roomId,
agentId: testAgentId,
worldId: testWorldId,
source: "discord",
type: ChannelType.GROUP,
name: "Discord Room",
messageServerId: messageServerId,
};
await adapter.createRooms([room]);
const retrieved = await adapter.getRoomsByIds([roomId]);
expect(retrieved).toHaveLength(1);
if (!retrieved?.[0]) throw new Error("Room should exist");
expect(retrieved[0].messageServerId).toBe(messageServerId);
});
});
});
@@ -0,0 +1,19 @@
/**
* Seed data for agent integration tests
*/
import type { Agent } from "@elizaos/core";
/**
* Test agent data used across agent integration tests
*/
export const testAgent: Agent = {
name: "Integration Test Agent",
username: "test_agent",
bio: "A test agent for integration tests",
enabled: true,
settings: {
testSetting: "test value",
},
createdAt: Date.now(),
updatedAt: Date.now(),
};
@@ -0,0 +1,83 @@
/**
* Seed data for cache integration tests
*/
import { type Agent, AgentStatus, type UUID } from "@elizaos/core";
import { v4 } from "uuid";
/**
* Default test agent settings for cache tests
*/
export const cacheTestAgentSettings = {
id: v4() as UUID,
name: "Cache Test Agent",
username: "cache_test_agent",
system: "Test agent system prompt",
bio: ["An agent for testing cache operations"],
templates: {},
enabled: true,
status: AgentStatus.ACTIVE,
createdAt: Date.now(),
updatedAt: Date.now(),
messageExamples: [],
postExamples: [],
topics: [],
adjectives: [],
knowledge: [],
plugins: [],
secrets: {},
settings: {
cacheTestSetting: "cache test value",
},
style: {
all: [],
chat: [],
post: [],
},
} as Agent;
/**
* Test cache values
*/
export const testCacheEntries = {
stringValue: {
key: "test_string",
value: "test value",
},
numberValue: {
key: "test_number",
value: 42,
},
objectValue: {
key: "test_object",
value: {
name: "Test Object",
properties: {
id: 1,
active: true,
},
tags: ["test", "cache", "integration"],
},
},
arrayValue: {
key: "test_array",
value: [1, 2, 3, "four", { five: 5 }],
},
};
/**
* Test cache with expiration settings
*/
export const testCacheWithExpiration = {
expired: {
key: "expired_cache",
value: "This value has expired",
// Set expiration to 1 hour in the past
expiresAt: Date.now() - 60 * 60 * 1000,
},
notExpired: {
key: "not_expired_cache",
value: "This value has not expired",
// Set expiration to 1 hour in the future
expiresAt: Date.now() + 60 * 60 * 1000,
},
};
@@ -0,0 +1,123 @@
/**
* Seed data for component integration tests
*/
import { type Agent, AgentStatus, ChannelType, type Component, type UUID } from "@elizaos/core";
import { v4 } from "uuid";
/**
* Default test agent settings for component tests
*/
export const componentTestAgentSettings = {
id: v4() as UUID,
name: "Component Test Agent",
username: "component_test_agent",
system: "Test agent system prompt",
bio: ["An agent for testing component operations"],
templates: {},
enabled: true,
status: AgentStatus.ACTIVE,
createdAt: Date.now(),
updatedAt: Date.now(),
messageExamples: [],
postExamples: [],
topics: [],
adjectives: [],
knowledge: [],
plugins: [],
secrets: {},
settings: {
componentTestSetting: "component test value",
},
style: {
all: [],
chat: [],
post: [],
},
} as Agent;
/**
* Test world for component tests
*/
export const componentTestWorld = {
id: v4() as UUID,
name: "Component Test World",
agentId: componentTestAgentSettings.id,
serverId: "component-test-server",
metadata: {
ownership: {
ownerId: "component-test-owner",
},
},
};
/**
* Test entity for component tests
*/
export const componentTestEntity = {
id: v4() as UUID,
names: ["Component Test Entity"],
agentId: componentTestAgentSettings.id,
metadata: {},
};
/**
* Secondary test entity for source entity references
*/
export const componentTestSourceEntity = {
id: v4() as UUID,
names: ["Component Test Source Entity"],
agentId: componentTestAgentSettings.id,
metadata: {},
};
/**
* Test room for component tests
*/
export const componentTestRoom = {
id: v4() as UUID,
name: "Component Test Room",
type: ChannelType.DM,
agentId: componentTestAgentSettings.id,
source: "component-test",
worldId: componentTestWorld.id,
metadata: {},
};
/**
* Test components
*/
export const componentTestComponents = {
basic: {
id: v4() as UUID,
entityId: componentTestEntity.id,
agentId: componentTestAgentSettings.id,
roomId: componentTestRoom.id,
type: "basic_component",
data: { value: "basic component data" },
worldId: undefined as UUID | undefined,
sourceEntityId: undefined as UUID | undefined,
createdAt: Date.now(),
} as Component,
withWorldId: {
id: v4() as UUID,
entityId: componentTestEntity.id,
agentId: componentTestAgentSettings.id,
roomId: componentTestRoom.id,
worldId: componentTestWorld.id,
type: "world_component",
data: { worldValue: 42 },
sourceEntityId: undefined as UUID | undefined,
createdAt: Date.now(),
} as Component,
withSourceEntity: {
id: v4() as UUID,
entityId: componentTestEntity.id,
agentId: componentTestAgentSettings.id,
roomId: componentTestRoom.id,
sourceEntityId: componentTestSourceEntity.id,
type: "source_component",
data: { sourceValue: "from source entity" },
worldId: undefined as UUID | undefined,
createdAt: Date.now(),
} as Component,
};
@@ -0,0 +1,174 @@
/**
* Fixture agent/room/entity/memory/embedding records for the embedding
* integration tests — deterministic (fixed UUIDs, seeded IDs) so query
* results are stable across runs, covering the 384/512/768-dim embedding
* cases.
*/
import {
type Agent,
AgentStatus,
ChannelType,
type Entity,
type Memory,
type Room,
type UUID,
} from "@elizaos/core";
// Fixed UUID so fixtures are stable across runs instead of type-widened strings.
const fixedUuid = (n: number): UUID =>
`${"0".repeat(8)}-${"0".repeat(4)}-${"0".repeat(4)}-${"0".repeat(4)}-${n.toString().padStart(12, "0")}`;
export const embeddingTestAgentId = fixedUuid(1);
export const embeddingTestRoomId = fixedUuid(2);
export const embeddingTestEntityId = fixedUuid(3);
export const embeddingTestWorldId = fixedUuid(4);
export const generateRandomVector = (size: number): number[] => {
return Array.from({ length: size }, () => (Math.random() * 2 - 1) * 0.1);
};
export const embeddingTestAgent = {
id: embeddingTestAgentId,
name: "Embedding Test Agent",
username: "embedding_test_agent",
system: "Test agent system prompt",
bio: ["An agent for testing embedding functionality"],
templates: {},
enabled: true,
status: AgentStatus.ACTIVE,
createdAt: Date.now(),
updatedAt: Date.now(),
messageExamples: [],
postExamples: [],
topics: [],
adjectives: [],
knowledge: [],
secrets: {},
plugins: [],
settings: {
dummySetting: "dummy value",
},
style: {
all: [],
chat: [],
post: [],
},
} as Agent;
export const embeddingTestEntity: Entity = {
id: embeddingTestEntityId,
names: ["Test Entity"],
agentId: embeddingTestAgentId,
metadata: {
description: "A test entity for embedding tests",
},
};
export const embeddingTestRoom: Room = {
id: embeddingTestRoomId,
name: "Embedding Test Room",
agentId: embeddingTestAgentId,
source: "test",
type: ChannelType.DM,
worldId: embeddingTestWorldId,
};
/** Memory shape used by these fixtures, with the DB-only `type` column made explicit. */
export interface TestMemory extends Memory {
type: string;
}
export const embeddingTestMemories: TestMemory[] = [
{
id: fixedUuid(10),
entityId: embeddingTestEntityId,
agentId: embeddingTestAgentId,
roomId: embeddingTestRoomId,
createdAt: Date.now(),
content: {
text: "This is test memory 1",
type: "text",
},
unique: true,
type: "custom",
metadata: { type: "custom" },
},
{
id: fixedUuid(11),
entityId: embeddingTestEntityId,
agentId: embeddingTestAgentId,
roomId: embeddingTestRoomId,
createdAt: Date.now(),
content: {
text: "This is test memory 2",
type: "text",
},
unique: true,
type: "custom",
metadata: { type: "custom" },
},
{
id: fixedUuid(12),
entityId: embeddingTestEntityId,
agentId: embeddingTestAgentId,
roomId: embeddingTestRoomId,
createdAt: Date.now(),
content: {
text: "This is test memory 3",
type: "text",
},
unique: true,
type: "custom",
metadata: { type: "custom" },
},
];
interface EmbeddingTestDataItem {
id: UUID;
memoryId: UUID;
createdAt: number;
dim384?: number[];
dim512?: number[];
dim768?: number[];
}
export const embeddingTestData: EmbeddingTestDataItem[] = [
{
id: fixedUuid(30),
memoryId: embeddingTestMemories[0].id as UUID,
createdAt: Date.now(),
dim384: generateRandomVector(384),
},
{
id: fixedUuid(31),
memoryId: embeddingTestMemories[1].id as UUID,
createdAt: Date.now(),
dim512: generateRandomVector(512),
},
{
id: fixedUuid(32),
memoryId: embeddingTestMemories[2].id as UUID,
createdAt: Date.now(),
dim768: generateRandomVector(768),
},
];
export const embeddingTestMemoriesWithEmbedding: (TestMemory & {
embedding: number[];
})[] = [
{
...embeddingTestMemories[0],
embedding: embeddingTestData[0].dim384!,
metadata: embeddingTestMemories[0].metadata,
},
{
...embeddingTestMemories[1],
embedding: embeddingTestData[1].dim512!,
metadata: embeddingTestMemories[1].metadata,
},
{
...embeddingTestMemories[2],
embedding: embeddingTestData[2].dim768!,
metadata: embeddingTestMemories[2].metadata,
},
];
@@ -0,0 +1,83 @@
/**
* Seed data for entity integration tests
*/
import { type Agent, AgentStatus, type UUID } from "@elizaos/core";
import { v4 } from "uuid";
/**
* Default test agent settings for entity tests
*/
export const entityTestAgentSettings = {
id: v4() as UUID,
name: "Entity Test Agent",
username: "entity_test_agent",
system: "Test agent system prompt",
bio: ["An agent for testing entity operations"],
templates: {},
enabled: true,
status: AgentStatus.ACTIVE,
createdAt: Date.now(),
updatedAt: Date.now(),
messageExamples: [],
postExamples: [],
topics: [],
adjectives: [],
knowledge: [],
plugins: [],
secrets: {},
settings: {
entityTestSetting: "entity test value",
},
style: {
all: [],
chat: [],
post: [],
},
} as Agent;
/**
* Test entity data
*/
export const testEntities = {
basicEntity: {
id: v4() as UUID,
names: ["Basic Entity"],
metadata: {
type: "basic",
description: "A basic entity for testing",
},
agentId: entityTestAgentSettings.id as UUID,
},
complexEntity: {
id: v4() as UUID,
names: ["Complex Entity", "Alternative Name"],
metadata: {
type: "complex",
description: "A complex entity for testing",
properties: {
strength: 10,
intelligence: 15,
isSpecial: true,
},
tags: ["test", "entity", "complex"],
},
agentId: entityTestAgentSettings.id as UUID,
},
entityToUpdate: {
id: v4() as UUID,
names: ["Entity to Update"],
metadata: {
type: "updatable",
version: 1,
},
agentId: entityTestAgentSettings.id as UUID,
},
entityWithComponent: {
id: v4() as UUID,
names: ["Entity with Component"],
metadata: {
type: "component-holder",
},
agentId: entityTestAgentSettings.id as UUID,
},
};
@@ -0,0 +1,16 @@
/**
* Exports all seed data for tests
*/
export * from "./agent-seed";
export * from "./cache-seed";
export * from "./component-seed";
export * from "./embedding-seed";
export * from "./entity-seed";
export * from "./log-seed";
export * from "./memory-seed";
export * from "./participant-seed";
export * from "./relationship-seed";
export * from "./room-seed";
export * from "./task-seed";
export * from "./world-seed";
@@ -0,0 +1,99 @@
/**
* Seed data for log integration tests
*/
import { type Agent, AgentStatus, ChannelType, type Log, type UUID } from "@elizaos/core";
import { v4 } from "uuid";
/**
* Default test agent settings for log tests
*/
export const logTestAgentSettings = {
id: v4() as UUID,
name: "Log Test Agent",
username: "log_test_agent",
system: "Test agent system prompt",
bio: ["An agent for testing log operations"],
templates: {},
enabled: true,
status: AgentStatus.ACTIVE,
createdAt: Date.now(),
updatedAt: Date.now(),
messageExamples: [],
postExamples: [],
topics: [],
adjectives: [],
knowledge: [],
plugins: [],
secrets: {},
settings: {
logTestSetting: "log test value",
},
style: {
all: [],
chat: [],
post: [],
},
} as Agent;
/**
* Test world for log tests
*/
export const logTestWorld = {
id: v4() as UUID,
agentId: logTestAgentSettings.id,
serverId: v4() as UUID,
name: "Log Test World",
description: "A world for log integration tests",
metadata: {},
};
/**
* Test entity for log tests
*/
export const logTestEntity = {
id: v4() as UUID,
names: ["Log Test Entity"],
agentId: logTestAgentSettings.id,
metadata: {},
};
/**
* Test room for log tests
*/
export const logTestRoom = {
id: v4() as UUID,
name: "Log Test Room",
type: ChannelType.DM,
agentId: logTestAgentSettings.id,
worldId: logTestWorld.id,
source: "log-test",
metadata: {},
};
/**
* Test logs
*/
export const logTestLogs = {
basic: {
id: v4() as UUID,
entityId: logTestEntity.id,
roomId: logTestRoom.id,
body: { message: "Test log message", data: { key: "value" } },
type: "test_log",
createdAt: new Date(),
} as Log,
withMetadata: {
id: v4() as UUID,
entityId: logTestEntity.id,
roomId: logTestRoom.id,
body: {
message: "Log with extra metadata",
metadata: {
priority: "high",
source: "test_suite",
},
},
type: "metadata_log",
createdAt: new Date(),
} as Log,
};
@@ -0,0 +1,222 @@
/**
* Fixture agent/entity/world/room/memory records for the memory integration
* tests, including embedding vectors and helpers (`generateEmbedding`,
* `createSimilarMemoryVector`) for exercising vector-similarity search and
* document/fragment relationships.
*/
import {
type Agent,
ChannelType,
type Entity,
type Memory,
type Room,
type UUID,
type World,
} from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
export const memoryTestAgentId = uuidv4() as UUID;
export const memoryTestEntityId = uuidv4() as UUID;
export const memoryTestRoomId = uuidv4() as UUID;
export const memoryTestWorldId = uuidv4() as UUID;
export const memoryTestAgent: Agent = {
id: memoryTestAgentId,
name: "Memory Test Agent",
bio: "Test agent for memory integration tests",
settings: {
profile: {
short_description: "Test agent for memory integration tests",
},
},
createdAt: Date.now(),
updatedAt: Date.now(),
};
export const memoryTestEntity: Entity = {
id: memoryTestEntityId,
names: ["Memory Test Entity"],
agentId: memoryTestAgentId,
components: [],
metadata: {
type: "user",
worldId: memoryTestWorldId,
},
};
export const memoryTestWorld: World = {
id: memoryTestWorldId,
agentId: memoryTestAgentId,
name: "Memory Test World",
serverId: "test-server",
metadata: {},
};
export const memoryTestRoom: Room = {
id: memoryTestRoomId,
name: "Memory Test Room",
agentId: memoryTestAgentId,
source: "test",
type: ChannelType.GROUP,
worldId: memoryTestWorldId,
metadata: {},
};
export const generateEmbedding = (dimension: number = 384): number[] => {
const vector = Array(dimension)
.fill(0)
.map(() => Math.random() * 2 - 1);
const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
return vector.map((val) => Number((val / magnitude).toFixed(6)));
};
export const memoryTestMemories: Memory[] = [
{
id: uuidv4() as UUID,
entityId: memoryTestEntityId,
roomId: memoryTestRoomId,
agentId: memoryTestAgentId,
createdAt: Date.now() - 3600000, // 1 hour ago
unique: true,
content: {
text: "This is a test memory for integration testing #1",
type: "text",
},
metadata: {
type: "chat",
source: "integration-test",
},
},
{
id: uuidv4() as UUID,
entityId: memoryTestEntityId,
roomId: memoryTestRoomId,
agentId: memoryTestAgentId,
createdAt: Date.now() - 7200000, // 2 hours ago
unique: true,
content: {
text: "This is a test memory for integration testing #2",
type: "text",
},
metadata: {
type: "chat",
source: "integration-test",
},
},
{
id: uuidv4() as UUID,
entityId: memoryTestEntityId,
roomId: memoryTestRoomId,
agentId: memoryTestAgentId,
createdAt: Date.now() - 10800000, // 3 hours ago
unique: true,
content: {
text: "This is a test memory for integration testing #3",
type: "text",
},
metadata: {
type: "chat",
source: "integration-test",
},
},
];
export const memoryTestMemoriesWithEmbedding: Memory[] = [
{
...memoryTestMemories[0],
id: uuidv4() as UUID,
embedding: generateEmbedding(384),
content: {
text: "This is a test memory with embedding dimension 384",
type: "text",
},
},
{
...memoryTestMemories[1],
id: uuidv4() as UUID,
embedding: generateEmbedding(384),
content: {
text: "This is a test memory with embedding dimension 384",
type: "text",
},
},
{
...memoryTestMemories[2],
id: uuidv4() as UUID,
embedding: generateEmbedding(384),
content: {
text: "This is a test memory with embedding dimension 384",
type: "text",
},
},
];
export const documentMemoryId = uuidv4() as UUID;
export const memoryTestDocument: Memory = {
id: documentMemoryId,
entityId: memoryTestEntityId,
roomId: memoryTestRoomId,
agentId: memoryTestAgentId,
createdAt: Date.now(),
unique: true,
content: {
text: "This is a test document memory",
type: "text",
},
metadata: {
type: "document",
documentId: documentMemoryId,
timestamp: Date.now(),
title: "Test Document",
source: "integration-test",
},
};
export const memoryTestFragments: Memory[] = Array(3)
.fill(0)
.map((_, index) => ({
id: uuidv4() as UUID,
entityId: memoryTestEntityId,
roomId: memoryTestRoomId,
agentId: memoryTestAgentId,
createdAt: Date.now(),
unique: true,
content: {
text: `This is fragment ${index + 1} of the test document`,
type: "text",
},
embedding: generateEmbedding(384),
metadata: {
type: "fragment",
documentId: documentMemoryId,
position: index,
source: "integration-test",
},
}));
/** Blends `baseMemory`'s embedding with noise so the result has the given cosine similarity to it, for vector-similarity-search tests. */
export const createSimilarMemoryVector = (baseMemory: Memory, similarity: number): Memory => {
if (!baseMemory.embedding || !Array.isArray(baseMemory.embedding)) {
throw new Error("Base memory must have an embedding");
}
const dimension = baseMemory.embedding.length;
const noise = generateEmbedding(dimension);
const blendedVector = baseMemory.embedding.map((value, idx) => {
return value * similarity + noise[idx] * (1 - similarity);
});
const magnitude = Math.sqrt(blendedVector.reduce((sum, val) => sum + val * val, 0));
const normalizedVector = blendedVector.map((val) => Number((val / magnitude).toFixed(6)));
return {
...baseMemory,
id: uuidv4() as UUID,
embedding: normalizedVector,
content: {
...baseMemory.content,
text: `Similar to: ${baseMemory.content.text}`,
},
};
};
@@ -0,0 +1,57 @@
/** Fixture agent/entity/world/room records for the room-participant integration tests. */
import {
type Agent,
ChannelType,
type Entity,
type Room,
type UUID,
type World,
} from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
export const participantTestAgentId = uuidv4() as UUID;
export const participantTestEntityId = uuidv4() as UUID;
export const participantTestRoomId = uuidv4() as UUID;
export const participantTestWorldId = uuidv4() as UUID;
export const participantTestAgent: Agent = {
id: participantTestAgentId,
name: "Participant Test Agent",
bio: "Test agent for participant integration tests",
settings: {
profile: {
short_description: "Test agent for participant integration tests",
},
},
createdAt: Date.now(),
updatedAt: Date.now(),
};
export const participantTestEntity: Entity = {
id: participantTestEntityId,
names: ["Participant Test Entity"],
agentId: participantTestAgentId,
components: [],
metadata: {
type: "user",
worldId: participantTestWorldId,
},
};
export const participantTestWorld: World = {
id: participantTestWorldId,
agentId: participantTestAgentId,
name: "Participant Test World",
serverId: "test-server",
metadata: {},
};
export const participantTestRoom: Room = {
id: participantTestRoomId,
name: "Participant Test Room",
agentId: participantTestAgentId,
source: "test",
type: ChannelType.GROUP,
worldId: participantTestWorldId,
metadata: {},
};
@@ -0,0 +1,84 @@
/** Fixture agent/entity/relationship records for the relationship-store integration tests. */
import type { Agent, Entity, Metadata, Relationship, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
export const relationshipTestAgentId = uuidv4() as UUID;
export const relationshipTestSourceEntityId = uuidv4() as UUID;
export const relationshipTestTargetEntityId = uuidv4() as UUID;
export const relationshipTestAgent: Agent = {
id: relationshipTestAgentId,
name: "Relationship Test Agent",
bio: "Test agent for relationship integration tests",
settings: {
profile: {
short_description: "Test agent for relationship integration tests",
},
},
createdAt: Date.now(),
updatedAt: Date.now(),
};
export const relationshipTestSourceEntity: Entity = {
id: relationshipTestSourceEntityId,
names: ["Source Entity"],
agentId: relationshipTestAgentId,
components: [],
metadata: {
type: "user",
},
};
export const relationshipTestTargetEntity: Entity = {
id: relationshipTestTargetEntityId,
names: ["Target Entity"],
agentId: relationshipTestAgentId,
components: [],
metadata: {
type: "user",
},
};
export const relationshipTestRelationships: Relationship[] = [
{
id: uuidv4() as UUID,
sourceEntityId: relationshipTestSourceEntityId,
targetEntityId: relationshipTestTargetEntityId,
agentId: relationshipTestAgentId,
tags: ["friend"],
metadata: {
type: "social",
strength: "high",
},
createdAt: Date.now().toString(),
},
{
id: uuidv4() as UUID,
sourceEntityId: relationshipTestTargetEntityId,
targetEntityId: relationshipTestSourceEntityId,
agentId: relationshipTestAgentId,
tags: ["colleague"],
metadata: {
type: "professional",
strength: "medium",
},
createdAt: Date.now().toString(),
},
];
export const createTestRelationship = (
sourceId: UUID,
target: UUID,
tags: string[] = [],
metadata: Metadata = {}
): Relationship => {
return {
id: uuidv4() as UUID,
sourceEntityId: sourceId,
targetEntityId: target,
agentId: relationshipTestAgentId,
tags,
metadata,
createdAt: Date.now().toString(),
};
};
@@ -0,0 +1,101 @@
/** Fixture agent/entity/world/room records for the room-store integration tests. */
import {
type Agent,
ChannelType,
type Entity,
type Room,
type UUID,
type World,
} from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
export const roomTestAgentId = uuidv4() as UUID;
export const roomTestEntityId = uuidv4() as UUID;
export const roomTestWorldId = uuidv4() as UUID;
export const roomTestRoomId = uuidv4() as UUID;
export const roomTestRoom2Id = uuidv4() as UUID;
export const roomTestAgent: Agent = {
id: roomTestAgentId,
name: "Room Test Agent",
bio: "Test agent for room integration tests",
settings: {
profile: {
short_description: "Test agent for room integration tests",
},
},
createdAt: Date.now(),
updatedAt: Date.now(),
};
export const roomTestEntity: Entity = {
id: roomTestEntityId,
names: ["Room Test Entity"],
agentId: roomTestAgentId,
components: [],
metadata: {
type: "user",
worldId: roomTestWorldId,
},
};
export const roomTestWorld: World = {
id: roomTestWorldId,
agentId: roomTestAgentId,
name: "Room Test World",
serverId: "test-server",
metadata: {},
};
export const roomTestRooms: Room[] = [
{
id: roomTestRoomId,
name: "Room Test Room 1",
agentId: roomTestAgentId,
source: "test",
type: ChannelType.GROUP,
worldId: roomTestWorldId,
metadata: {
description: "Test description for room 1",
tags: ["test", "integration"],
},
},
{
id: roomTestRoom2Id,
name: "Room Test Room 2",
agentId: roomTestAgentId,
source: "test",
type: ChannelType.DM,
worldId: roomTestWorldId,
channelId: "test-channel-id",
serverId: "test-server-id",
metadata: {
description: "Test description for room 2",
tags: ["integration"],
},
},
{
id: uuidv4() as UUID,
name: "Room Test Room 3",
agentId: roomTestAgentId,
source: "discord",
type: ChannelType.GROUP,
worldId: roomTestWorldId,
metadata: {
description: "Test description for room 3",
isPrivate: true,
},
},
];
export const createModifiedRoom = (room: Room): Room => {
return {
...room,
name: `${room.name} - Updated`,
metadata: {
...(room.metadata as Record<string, unknown>),
updated: true,
timestamp: Date.now(),
},
};
};
@@ -0,0 +1,93 @@
/** Fixture task records for the task-store integration tests, covering room/tag filtering and name search. */
import type { Task, UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
export const taskTestAgentId = uuidv4() as UUID;
export const taskTestRoomId = uuidv4() as UUID;
export const taskTestWorldId = uuidv4() as UUID;
export const taskTestEntityId = uuidv4() as UUID;
export const taskTestTasks: Task[] = [
{
id: uuidv4() as UUID,
name: "Test Task 1",
description: "This is a test task for integration testing #1",
roomId: taskTestRoomId,
worldId: taskTestWorldId,
tags: ["test", "integration", "task1"],
metadata: {
priority: "high",
dueDate: Date.now() + 86400000, // 1 day from now
status: "pending",
},
},
{
id: uuidv4() as UUID,
name: "Test Task 2",
description: "This is a test task for integration testing #2",
roomId: taskTestRoomId,
worldId: taskTestWorldId,
tags: ["test", "integration", "task2"],
metadata: {
priority: "medium",
dueDate: Date.now() + 172800000, // 2 days from now
status: "pending",
},
},
{
id: uuidv4() as UUID,
name: "Test Task 3",
description: "This is a test task for integration testing #3",
roomId: taskTestRoomId,
worldId: taskTestWorldId,
tags: ["test", "integration", "task3"],
metadata: {
priority: "low",
dueDate: Date.now() + 259200000, // 3 days from now
status: "pending",
},
},
{
id: uuidv4() as UUID,
name: "Special Test Task",
description: "This is a special task with unique name for name search testing",
roomId: taskTestRoomId,
worldId: taskTestWorldId,
tags: ["test", "integration", "special"],
metadata: {
priority: "high",
dueDate: Date.now() + 43200000, // 12 hours from now
status: "pending",
options: [
{ name: "Option 1", description: "First option" },
{ name: "Option 2", description: "Second option" },
],
},
},
];
export const taskTestTaskDifferentRoom: Task = {
id: uuidv4() as UUID,
name: "Task Different Room",
description: "This task belongs to a different room for filter testing",
roomId: uuidv4() as UUID,
worldId: taskTestWorldId,
tags: ["test", "different-room"],
metadata: {
priority: "medium",
status: "pending",
},
};
export const taskTestTaskWithSpecificTags: Task = {
id: uuidv4() as UUID,
name: "Task With Specific Tags",
description: "This task has specific tags for filter testing",
roomId: taskTestRoomId,
worldId: taskTestWorldId,
tags: ["test", "filter-test", "specific-tag"],
metadata: {
priority: "high",
status: "pending",
},
};
@@ -0,0 +1,67 @@
/** Fixture agent/entity/world records for the world-store integration tests, including owner/role metadata variants. */
import { type Agent, type Entity, Role, type UUID, type World } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
export const worldTestAgentId = uuidv4() as UUID;
export const worldTestEntityId = uuidv4() as UUID;
export const worldTestAgent: Agent = {
id: worldTestAgentId,
name: "World Test Agent",
bio: "Test agent for world integration tests",
settings: {
profile: {
short_description: "Test agent for world integration tests",
},
},
createdAt: Date.now(),
updatedAt: Date.now(),
};
export const worldTestEntity: Entity = {
id: worldTestEntityId,
names: ["World Test Entity"],
agentId: worldTestAgentId,
components: [],
metadata: {
type: "user",
},
};
export const worldTestWorlds: World[] = [
{
id: uuidv4() as UUID,
agentId: worldTestAgentId,
name: "Test World 1",
messageServerId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" as UUID,
metadata: {
ownership: {
ownerId: worldTestEntityId,
},
roles: {
[worldTestEntityId]: Role.OWNER,
},
},
},
{
id: uuidv4() as UUID,
agentId: worldTestAgentId,
name: "Test World 2",
messageServerId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" as UUID,
metadata: {
ownership: {
ownerId: worldTestEntityId,
},
},
},
{
id: uuidv4() as UUID,
agentId: worldTestAgentId,
name: "Test World 3",
messageServerId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc" as UUID,
metadata: {
custom: "value",
tags: ["test", "integration"],
},
},
];
@@ -0,0 +1,207 @@
/**
* Pins the adapter semantics the sub-agent entity lifecycle fix (#15102,
* plugin-agent-orchestrator SubAgentRouter) depends on, against a real
* isolated PGlite/Postgres adapter:
*
* 1. `deleteParticipants` removes ONLY participant rows — unlinked entities
* vanish from `getEntitiesForRooms` (a participants→entities join) while
* their rows and the transcript memories that FK them survive, and
* `getEntitiesByIds` still resolves their original display names (what
* recentMessages' author backfill reads).
* 2. `createEntities` is idempotent by id (ON CONFLICT DO NOTHING), so the
* router's per-event shared-entity create never grows the table: N
* completion cycles add exactly one entity row and one participant row.
* 3. The reason the sweep must NOT delete entity rows: `deleteEntity`
* cascades to the memories that FK it (schema/memory.ts onDelete cascade),
* destroying historical transcripts.
*/
import { ChannelType, type Entity, type Memory, type Room, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
// Mirrors the structural creation-time marker the router stamps on entities
// (metadata[MESSAGE_SOURCE_SUB_AGENT]); the sweep classifies on it.
const SUB_AGENT_KEY = "sub_agent";
describe("sub-agent entity unlink semantics (#15102)", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("sub-agent-entity-unlink");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
async function createRoom(): Promise<UUID> {
const roomId = uuidv4() as UUID;
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
name: "sub-agent room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
return roomId;
}
function legacyEntity(label: string): Entity {
return {
id: uuidv4() as UUID,
agentId: testAgentId,
names: [`sub-agent: ${label}`],
metadata: {
[SUB_AGENT_KEY]: {
subAgentSessionId: uuidv4(),
subAgentAgentType: "codex",
},
},
};
}
function transcriptMemory(entityId: UUID, roomId: UUID, text: string): Memory {
return {
id: uuidv4() as UUID,
entityId,
agentId: testAgentId,
roomId,
content: { text, source: SUB_AGENT_KEY },
};
}
it("deleteParticipants hides legacy entities from the room read while transcripts and entity rows survive with their names", async () => {
const roomId = await createRoom();
const legacy = [
legacyEntity("old task a"),
legacyEntity("old task b"),
legacyEntity("old task c"),
];
const humans: Entity[] = [
{ id: uuidv4() as UUID, agentId: testAgentId, names: ["nubs"] },
{ id: uuidv4() as UUID, agentId: testAgentId, names: ["shaw"] },
];
const shared: Entity = {
id: uuidv4() as UUID,
agentId: testAgentId,
names: ["sub-agents"],
metadata: { [SUB_AGENT_KEY]: { shared: true } },
};
await adapter.createEntities([...legacy, ...humans, shared]);
await adapter.addParticipantsRoom(
[...legacy, ...humans, shared].map((e) => e.id as UUID),
roomId
);
for (const entity of legacy) {
await adapter.createMemory(
transcriptMemory(entity.id as UUID, roomId, `transcript of ${entity.names[0]}`),
"memories"
);
}
const before = (await adapter.getEntitiesForRooms([roomId]))[0]?.entities ?? [];
expect(before).toHaveLength(6);
// The router's sweep: classify on the structural marker, unlink in one batch.
const stale = before.filter((entity) => {
const marker = entity.metadata?.[SUB_AGENT_KEY];
return (
typeof marker === "object" &&
marker !== null &&
!Array.isArray(marker) &&
typeof (marker as Record<string, unknown>).subAgentSessionId === "string"
);
});
expect(stale.map((e) => e.id).sort()).toEqual(legacy.map((e) => e.id).sort());
await adapter.deleteParticipants(stale.map((e) => ({ entityId: e.id as UUID, roomId })));
// Room read (participants join) now shows humans + shared only.
const after = (await adapter.getEntitiesForRooms([roomId]))[0]?.entities ?? [];
expect(after.map((e) => e.names[0]).sort()).toEqual(["nubs", "shaw", "sub-agents"]);
// Transcript memories are untouched and their entityId FKs still resolve
// to the original per-task display names (rendering-attribution pin).
const memories = await adapter.getMemories({ tableName: "memories", roomId });
expect(memories).toHaveLength(3);
const authors = await adapter.getEntitiesByIds(memories.map((m) => m.entityId));
expect(authors.map((a) => a.names[0]).sort()).toEqual([
"sub-agent: old task a",
"sub-agent: old task b",
"sub-agent: old task c",
]);
});
it("N completion cycles of the shared-entity create grow the table by exactly one entity and one participant", async () => {
const roomId = await createRoom();
const human: Entity = { id: uuidv4() as UUID, agentId: testAgentId, names: ["nubs"] };
await adapter.createEntities([human]);
await adapter.addParticipantsRoom([human.id as UUID], roomId);
const sharedId = uuidv4() as UUID;
for (let cycle = 0; cycle < 10; cycle++) {
// The router's per-event sequence: idempotent create, participation,
// then the routed memory carrying the per-session identity.
await adapter.createEntities([
{
id: sharedId,
agentId: testAgentId,
names: ["sub-agents"],
metadata: { [SUB_AGENT_KEY]: { shared: true } },
},
]);
await adapter.addParticipant(sharedId, roomId);
await adapter.createMemory(
{
id: uuidv4() as UUID,
entityId: sharedId,
agentId: testAgentId,
roomId,
content: {
text: `[sub-agent: task ${cycle} (codex) — task_complete]\ndone`,
source: SUB_AGENT_KEY,
metadata: { subAgentSessionId: uuidv4() },
},
},
"memories"
);
}
const entities = (await adapter.getEntitiesForRooms([roomId]))[0]?.entities ?? [];
expect(entities).toHaveLength(2);
expect(entities.map((e) => e.names[0]).sort()).toEqual(["nubs", "sub-agents"]);
const participants = await adapter.getParticipantsForRoom(roomId);
expect(participants.sort()).toEqual([human.id, sharedId].sort());
// Every cycle's memory landed under the one shared entity.
const memories = await adapter.getMemories({ tableName: "memories", roomId });
expect(memories).toHaveLength(10);
expect(new Set(memories.map((m) => m.entityId))).toEqual(new Set([sharedId]));
});
it("deleting a legacy entity row cascades its transcripts — the hazard the unlink avoids", async () => {
const roomId = await createRoom();
const doomed = legacyEntity("doomed task");
await adapter.createEntities([doomed]);
await adapter.addParticipantsRoom([doomed.id as UUID], roomId);
await adapter.createMemory(
transcriptMemory(doomed.id as UUID, roomId, "transcript that must not be GC'd"),
"memories"
);
await adapter.deleteEntity(doomed.id as UUID);
const memories = await adapter.getMemories({ tableName: "memories", roomId });
expect(memories).toHaveLength(0);
});
});
@@ -0,0 +1,226 @@
/**
* Task-store CRUD and query (tags/room/name) tests against a real PGlite (or
* Postgres, if `POSTGRES_URL` is set) adapter via `createIsolatedTestDatabase`
* — no mocks.
*/
import { ChannelType, type Entity, type Room, type Task, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { taskTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("Task Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
let testRoomId: UUID;
let testWorldId: UUID;
let testEntityId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("task-tests");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
testRoomId = uuidv4() as UUID;
testWorldId = uuidv4() as UUID;
testEntityId = uuidv4() as UUID;
await adapter.createWorld({
id: testWorldId,
agentId: testAgentId,
name: "Test World",
messageServerId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" as UUID,
});
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
worldId: testWorldId,
name: "Test Room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
]);
await adapter.addParticipant(testEntityId, testRoomId);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Task Tests", () => {
beforeEach(async () => {
await (adapter.getDatabase() as DrizzleDatabase).delete(taskTable);
});
it("should create and retrieve a task", async () => {
const taskId = uuidv4() as UUID;
const task: Task = {
id: taskId,
roomId: testRoomId,
worldId: testWorldId,
entityId: testEntityId,
name: "Test Task",
description: "A test task",
tags: ["a", "b"],
metadata: { status: "pending" },
};
const taskIdCreated = await adapter.createTask(task);
expect(taskIdCreated).toBe(taskId);
const retrieved = await adapter.getTask(taskId);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(taskId);
expect(retrieved?.agentId).toBe(testAgentId);
});
it("returns agentId from task lookup APIs", async () => {
const taskId = uuidv4() as UUID;
const task: Task = {
id: taskId,
roomId: testRoomId,
worldId: testWorldId,
entityId: testEntityId,
name: "Drain Task",
description: "A managed drain task",
tags: ["queue", "repeat"],
metadata: { affinityKey: "autonomy" },
};
await adapter.createTask(task);
const byId = await adapter.getTask(taskId);
const byName = await adapter.getTasksByName("Drain Task");
const byQuery = await adapter.getTasks({ tags: ["queue"] });
expect(byId?.agentId).toBe(testAgentId);
expect(byName).toHaveLength(1);
expect(byName[0]?.agentId).toBe(testAgentId);
expect(byQuery.find((item) => item.id === taskId)?.agentId).toBe(testAgentId);
});
it("should update a task", async () => {
const taskId = uuidv4() as UUID;
const originalTask: Task = {
id: taskId,
roomId: testRoomId,
worldId: testWorldId,
entityId: testEntityId,
name: "Original Task",
description: "Original description",
tags: ["a"],
metadata: { status: "pending" },
};
await adapter.createTask(originalTask);
await adapter.updateTask(taskId, {
description: "Updated Description",
metadata: { status: "completed" },
});
const retrieved = await adapter.getTask(taskId);
expect(retrieved?.description).toBe("Updated Description");
expect(retrieved?.metadata).toEqual({ status: "completed" });
});
it("should delete a task", async () => {
const taskId = uuidv4() as UUID;
const task: Task = {
id: taskId,
roomId: testRoomId,
worldId: testWorldId,
entityId: testEntityId,
name: "Deletable Task",
description: "This task will be deleted",
tags: [],
metadata: {},
};
await adapter.createTask(task);
let retrieved = await adapter.getTask(taskId);
expect(retrieved).not.toBeNull();
await adapter.deleteTask(taskId);
retrieved = await adapter.getTask(taskId);
expect(retrieved).toBeNull();
});
it("should filter tasks by tags and room", async () => {
const roomId1 = uuidv4() as UUID;
const roomId2 = uuidv4() as UUID;
await adapter.createRooms([
{
id: roomId1,
agentId: testAgentId,
worldId: testWorldId,
source: "test",
type: ChannelType.GROUP,
} as Room,
{
id: roomId2,
agentId: testAgentId,
worldId: testWorldId,
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
const task1: Task = {
id: uuidv4() as UUID,
roomId: roomId1,
worldId: testWorldId,
entityId: testEntityId,
name: "Task 1",
description: "Task 1",
tags: ["urgent", "a"],
metadata: {},
};
await adapter.createTask(task1);
const task2: Task = {
id: uuidv4() as UUID,
roomId: roomId1,
worldId: testWorldId,
entityId: testEntityId,
name: "Task 2",
description: "Task 2",
tags: ["a", "b"],
metadata: {},
};
await adapter.createTask(task2);
const task3: Task = {
id: uuidv4() as UUID,
roomId: roomId2,
worldId: testWorldId,
entityId: testEntityId,
name: "Task 3",
description: "Task 3",
tags: ["urgent", "c"],
metadata: {},
};
await adapter.createTask(task3);
const filteredTasks = await adapter.getTasks({
roomId: roomId1,
tags: ["urgent"],
});
expect(filteredTasks.length).toBe(1);
expect(filteredTasks[0].id).toBe(task1.id as UUID);
});
});
});
@@ -0,0 +1,142 @@
/**
* Tests for the path-resolution helpers (`expandTildePath`, `resolveEnvFile`,
* `resolvePgliteDir`) against a real temp directory and real `.env` files on
* disk — no filesystem mocking.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { expandTildePath, resolveEnvFile, resolvePgliteDir } from "../../utils";
describe("Utils Integration Tests", () => {
let originalEnv: NodeJS.ProcessEnv;
let originalCwd: () => string;
let tempDir: string;
beforeEach(() => {
originalEnv = { ...process.env };
originalCwd = process.cwd;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "utils-test-"));
process.cwd = () => tempDir;
});
afterEach(() => {
process.env = originalEnv;
process.cwd = originalCwd;
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe("expandTildePath", () => {
it("should expand ~ to current working directory", () => {
const result = expandTildePath("~/test/path");
expect(result).toBe(path.join(tempDir, "test/path"));
});
it("should not change paths without ~", () => {
const absolutePath = "/absolute/path";
expect(expandTildePath(absolutePath)).toBe(absolutePath);
const relativePath = "relative/path";
expect(expandTildePath(relativePath)).toBe(relativePath);
});
it("should handle just ~ alone", () => {
const result = expandTildePath("~");
expect(result).toBe(tempDir);
});
it("should handle empty string", () => {
expect(expandTildePath("")).toBe("");
});
});
describe("resolveEnvFile", () => {
it("should find .env in current directory", () => {
fs.writeFileSync(path.join(tempDir, ".env"), "TEST=true");
const result = resolveEnvFile(tempDir);
expect(result).toBe(path.join(tempDir, ".env"));
});
it("should traverse up directories to find .env", () => {
const subDir = path.join(tempDir, "sub", "nested");
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(path.join(tempDir, ".env"), "TEST=true");
const result = resolveEnvFile(subDir);
expect(result).toBe(path.join(tempDir, ".env"));
});
it("should return .env path in start directory if not found", () => {
const subDir = path.join(tempDir, "sub");
fs.mkdirSync(subDir, { recursive: true });
const result = resolveEnvFile(subDir);
expect(result).toBe(path.join(subDir, ".env"));
});
it("should use current working directory if no startDir provided", () => {
const result = resolveEnvFile();
expect(result).toBe(path.join(tempDir, ".env"));
});
});
describe("resolvePgliteDir", () => {
it("should use provided dir argument", () => {
const customDir = "/custom/dir";
const result = resolvePgliteDir(customDir);
expect(result).toBe(customDir);
});
it("should use PGLITE_DATA_DIR environment variable", () => {
const envDir = "/env/dir";
process.env.PGLITE_DATA_DIR = envDir;
const result = resolvePgliteDir();
expect(result).toBe(envDir);
});
it("should use fallback dir when no dir or env var", () => {
delete process.env.PGLITE_DATA_DIR;
const fallbackDir = "/fallback/dir";
const result = resolvePgliteDir(undefined, fallbackDir);
expect(result).toBe(fallbackDir);
});
it("should use default path when no arguments or env var", () => {
delete process.env.PGLITE_DATA_DIR;
const result = resolvePgliteDir();
expect(result).toBe(path.join(tempDir, ".eliza", ".elizadb"));
});
it("should load .env file if it exists", () => {
fs.writeFileSync(path.join(tempDir, ".env"), "PGLITE_DATA_DIR=/from/env/file");
delete process.env.PGLITE_DATA_DIR;
const result = resolvePgliteDir();
expect(process.env.PGLITE_DATA_DIR).toBe("/from/env/file");
expect(result).toBe("/from/env/file");
});
it("should expand tilde paths", () => {
const result = resolvePgliteDir("~/data/pglite");
expect(result).toBe(path.join(tempDir, "data/pglite"));
});
it("should use provided path directly", () => {
const customPath = path.join(tempDir, ".elizadb");
const result = resolvePgliteDir(customPath);
expect(result).toBe(customPath);
});
});
});
@@ -0,0 +1,190 @@
/**
* World-store CRUD tests against a real PGlite (or Postgres, if
* `POSTGRES_URL` is set) adapter via `createIsolatedTestDatabase` — no mocks.
* Covers UUID edge cases (nil UUID, non-RFC-version server ids) and
* duplicate-id rejection.
*/
import type { UUID, World } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { worldTable } from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabase } from "../test-helpers";
describe("World Integration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("world-tests");
adapter = setup.adapter;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("World Tests", () => {
beforeEach(async () => {
await (adapter.getDatabase() as DrizzleDatabase).delete(worldTable);
});
it("should create and retrieve a world", async () => {
const worldId = uuidv4() as UUID;
const world: World = {
id: worldId,
agentId: testAgentId,
name: "Test World",
metadata: { owner: "test-user" },
messageServerId: "a1111111-1111-4111-8111-111111111111" as UUID,
};
await adapter.createWorld(world);
const retrieved = await adapter.getWorld(worldId);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(worldId);
});
it("should preserve UUID-shaped server ids outside RFC version constraints", async () => {
const worldId = uuidv4() as UUID;
const messageServerId = "06df3cf3-6c0c-0449-9c80-87e36d1ea8f6" as UUID;
await adapter.createWorld({
id: worldId,
agentId: testAgentId,
name: "Canonical UUID World",
messageServerId,
});
const retrieved = await adapter.getWorld(worldId);
expect(retrieved?.messageServerId).toBe(messageServerId);
});
it("should preserve nil UUID server ids", async () => {
const worldId = uuidv4() as UUID;
const messageServerId = "00000000-0000-0000-0000-000000000000" as UUID;
await adapter.createWorld({
id: worldId,
agentId: testAgentId,
name: "Nil UUID World",
messageServerId,
});
const retrieved = await adapter.getWorld(worldId);
expect(retrieved?.messageServerId).toBe(messageServerId);
});
it("should not create a world with a duplicate id", async () => {
const worldId = uuidv4() as UUID;
const world1: World = {
id: worldId,
agentId: testAgentId,
name: "Test World 1",
messageServerId: "b1111111-1111-4111-8111-111111111111" as UUID,
};
const world2: World = {
id: worldId,
agentId: testAgentId,
name: "Test World 2",
messageServerId: "b2222222-2222-4222-8222-222222222222" as UUID,
};
await adapter.createWorld(world1);
await expect(adapter.createWorld(world2)).rejects.toThrow();
});
it("should update an existing world", async () => {
const worldId = uuidv4() as UUID;
const originalWorld: World = {
id: worldId,
agentId: testAgentId,
name: "Original World",
messageServerId: "c1111111-1111-4111-8111-111111111111" as UUID,
};
await adapter.createWorld(originalWorld);
const updatedWorld = { ...originalWorld, name: "Updated World Name" };
await adapter.updateWorld(updatedWorld);
const retrieved = await adapter.getWorld(worldId);
expect(retrieved?.name).toBe("Updated World Name");
});
it("should only update the specified world", async () => {
const world1: World = {
id: uuidv4() as UUID,
agentId: testAgentId,
name: "World One",
messageServerId: "d1111111-1111-4111-8111-111111111111" as UUID,
};
const world2: World = {
id: uuidv4() as UUID,
agentId: testAgentId,
name: "World Two",
messageServerId: "d2222222-2222-4222-8222-222222222222" as UUID,
};
await adapter.createWorld(world1);
await adapter.createWorld(world2);
const updatedWorld1 = { ...world1, name: "Updated World One" };
await adapter.updateWorld(updatedWorld1);
const retrieved1 = await adapter.getWorld(world1.id);
const retrieved2 = await adapter.getWorld(world2.id);
expect(retrieved1?.name).toBe("Updated World One");
expect(retrieved2?.name).toBe("World Two");
});
it("should delete a world", async () => {
const worldId = uuidv4() as UUID;
const world: World = {
id: worldId,
agentId: testAgentId,
name: "To Be Deleted",
messageServerId: "e1111111-1111-4111-8111-111111111111" as UUID,
};
await adapter.createWorld(world);
let retrieved = await adapter.getWorld(worldId);
expect(retrieved).not.toBeNull();
await adapter.removeWorld(worldId);
retrieved = await adapter.getWorld(worldId);
expect(retrieved).toBeNull();
});
it("should return null when retrieving a non-existent world", async () => {
const world = await adapter.getWorld(uuidv4() as UUID);
expect(world).toBeNull();
});
it("should retrieve all worlds for an agent", async () => {
const world1: World = {
id: uuidv4() as UUID,
agentId: testAgentId,
name: "World 0",
messageServerId: "f0000000-0000-4000-8000-000000000000" as UUID,
};
const world2: World = {
id: uuidv4() as UUID,
agentId: testAgentId,
name: "World 1",
messageServerId: "f1111111-1111-4111-8111-111111111111" as UUID,
};
await adapter.createWorld(world1);
await adapter.createWorld(world2);
const worlds = await adapter.getAllWorlds();
expect(worlds.length).toBe(2);
});
it("should return an empty array if no worlds exist", async () => {
const worlds = await adapter.getAllWorlds();
expect(worlds).toEqual([]);
});
});
});
@@ -0,0 +1,257 @@
/**
* End-to-end simulation of how `RuntimeMigrator` behaves across the real
* multi-plugin boot sequence — separate migrator instances per plugin (as
* each plugin's `init` would create), a simulated app restart, and a single
* migrator shared across plugins — asserting migrations stay idempotent and
* both plugin schemas (`@elizaos/plugin-sql`, `polymarket`) coexist. Runs
* against a real Postgres/PGlite database via `createIsolatedTestDatabaseForMigration`.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
interface MigrationRow {
plugin_name: string;
hash: string;
created_at: string | Date;
[key: string]: unknown;
}
interface SnapshotRow {
plugin_name: string;
count: string | number;
[key: string]: unknown;
}
interface SchemaRow {
schema_name: string;
[key: string]: unknown;
}
interface TablesPerSchemaRow {
schemaname: string;
table_count: string | number;
[key: string]: unknown;
}
interface TableRow {
tablename: string;
[key: string]: unknown;
}
interface SchemaExistsRow {
public_exists: boolean;
polymarket_exists: boolean;
[key: string]: unknown;
}
import { sql } from "drizzle-orm";
import { RuntimeMigrator } from "../../runtime-migrator";
import * as coreSchema from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { testPolymarketSchema } from "../schema-data/test-plugin-schema";
import { createIsolatedTestDatabaseForMigration } from "../test-helpers";
describe("Actual Runtime Scenario - Plugin Loading Simulation", () => {
let db: DrizzleDatabase;
let cleanup: () => Promise<void>;
beforeAll(async () => {
console.log("\n🚀 Simulating actual runtime plugin loading scenario...\n");
const testSetup = await createIsolatedTestDatabaseForMigration("actual_runtime");
cleanup = testSetup.cleanup;
db = testSetup.db;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
it("should handle plugin migrations as they would be loaded at runtime", async () => {
console.log("=".repeat(80));
console.log("SCENARIO: Application Startup");
console.log("=".repeat(80));
console.log("\n📦 Step 1: Loading plugin-sql (database adapter)...");
const sqlPluginMigrator = new RuntimeMigrator(db);
await sqlPluginMigrator.initialize();
await sqlPluginMigrator.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: false,
});
console.log("✅ plugin-sql loaded and migrated");
const afterSqlPlugin = await db.execute(sql`
SELECT plugin_name, hash, created_at
FROM migrations._migrations
ORDER BY created_at ASC
`);
console.log(`\n📊 Migrations after plugin-sql: ${afterSqlPlugin.rows.length}`);
for (const m of afterSqlPlugin.rows as MigrationRow[]) {
console.log(` - ${m.plugin_name}`);
}
console.log("\n📦 Step 2: Loading polymarket plugin...");
console.log("\n--- Testing Scenario A: Polymarket creates its own migrator ---");
const polymarketMigrator = new RuntimeMigrator(db);
// initialize() is idempotent: it detects the existing migration tables.
await polymarketMigrator.initialize();
await polymarketMigrator.migrate("polymarket", testPolymarketSchema, {
verbose: false,
});
console.log("✅ polymarket loaded and migrated (own migrator)");
const afterPolymarket = await db.execute(sql`
SELECT plugin_name, hash, created_at
FROM migrations._migrations
ORDER BY created_at ASC
`);
console.log(`\n📊 Migrations after polymarket: ${afterPolymarket.rows.length}`);
for (const m of afterPolymarket.rows as MigrationRow[]) {
console.log(` - ${m.plugin_name}`);
}
expect(afterPolymarket.rows.length).toBe(2);
console.log(`\n${"=".repeat(80)}`);
console.log("SCENARIO: Application Restart");
console.log("=".repeat(80));
console.log("\n🔄 Simulating application restart...");
// New migrator instances, as each plugin's init would create on restart.
const sqlPluginMigrator2 = new RuntimeMigrator(db);
await sqlPluginMigrator2.initialize();
await sqlPluginMigrator2.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: false,
});
const polymarketMigrator2 = new RuntimeMigrator(db);
await polymarketMigrator2.initialize();
await polymarketMigrator2.migrate("polymarket", testPolymarketSchema, {
verbose: false,
});
// Re-migrating on restart must stay idempotent: still exactly 2 rows.
const afterRestart = await db.execute(sql`
SELECT plugin_name, hash, created_at
FROM migrations._migrations
ORDER BY created_at ASC
`);
console.log(`\n📊 Migrations after restart: ${afterRestart.rows.length}`);
expect(afterRestart.rows.length).toBe(2);
console.log(`\n${"=".repeat(80)}`);
console.log("DIAGNOSTICS");
console.log("=".repeat(80));
console.log("\n🔍 Checking snapshots:");
const snapshots = await db.execute(sql`
SELECT plugin_name, COUNT(*) as count
FROM migrations._snapshots
GROUP BY plugin_name
`);
for (const s of snapshots.rows as SnapshotRow[]) {
console.log(` - ${s.plugin_name}: ${s.count} snapshots`);
}
console.log("\n🔍 Checking schemas:");
const schemas = await db.execute(sql`
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
ORDER BY schema_name
`);
console.log("Schemas:", schemas.rows.map((r: SchemaRow) => r.schema_name).join(", "));
console.log("\n🔍 Tables per schema:");
const tablesPerSchema = await db.execute(sql`
SELECT schemaname, COUNT(*) as table_count
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
GROUP BY schemaname
ORDER BY schemaname
`);
for (const t of tablesPerSchema.rows as TablesPerSchemaRow[]) {
console.log(` - ${t.schemaname}: ${t.table_count} tables`);
}
});
it("should test shared migrator scenario", async () => {
console.log(`\n${"=".repeat(80)}`);
console.log("SCENARIO: Shared Migrator Instance");
console.log("=".repeat(80));
console.log("\n🧹 Cleaning up database from previous test...");
await db.execute(sql`DROP SCHEMA IF EXISTS polymarket CASCADE`);
// Migration tables (`migrations` schema) are dropped separately below.
const tables = await db.execute(sql`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT LIKE 'spatial_ref_sys'
AND tablename NOT LIKE 'geography_columns'
AND tablename NOT LIKE 'geometry_columns'
AND tablename NOT LIKE 'raster_columns'
AND tablename NOT LIKE 'raster_overviews'
`);
for (const table of tables.rows as TableRow[]) {
await db.execute(sql.raw(`DROP TABLE IF EXISTS public."${table.tablename}" CASCADE`));
}
// Dropped entirely; `initialize()` below recreates it.
await db.execute(sql`DROP SCHEMA IF EXISTS migrations CASCADE`);
console.log("\n🔄 Testing with shared migrator instance...");
const sharedMigrator = new RuntimeMigrator(db);
await sharedMigrator.initialize();
console.log("\n📦 plugin-sql using shared migrator...");
await sharedMigrator.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: false,
});
console.log("📦 polymarket using shared migrator...");
await sharedMigrator.migrate("polymarket", testPolymarketSchema, {
verbose: false,
});
const finalMigrations = await db.execute(sql`
SELECT plugin_name, hash, created_at
FROM migrations._migrations
ORDER BY created_at ASC
`);
console.log(`\n📊 Final migrations with shared migrator: ${finalMigrations.rows.length}`);
for (const m of finalMigrations.rows as MigrationRow[]) {
console.log(` - ${m.plugin_name}`);
}
expect(finalMigrations.rows.length).toBe(2);
const schemasExist = await db.execute(sql`
SELECT
EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'public') as public_exists,
EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'polymarket') as polymarket_exists
`);
const result = schemasExist.rows[0] as SchemaExistsRow;
console.log("\n✅ Schema verification:");
console.log(` - public schema: ${result.public_exists ? "exists" : "missing"}`);
console.log(` - polymarket schema: ${result.polymarket_exists ? "exists" : "missing"}`);
expect(result.public_exists).toBe(true);
expect(result.polymarket_exists).toBe(true);
});
});
@@ -0,0 +1,425 @@
/**
* End-to-end `RuntimeMigrator` coverage against a real Postgres/PGlite
* database (via `createIsolatedTestDatabaseForMigration`): a purpose-built
* schema exercising every constraint kind the migrator must diff and apply —
* dependency-ordered table creation, `vector` columns, foreign keys, unique
* and check constraints, actual constraint enforcement on insert, idempotent
* re-migration, empty schemas, and non-table schema exports that must be
* ignored rather than migrated.
*/
import { sql } from "drizzle-orm";
import {
boolean,
check,
foreignKey,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
unique,
uuid,
vector,
} from "drizzle-orm/pg-core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
interface VectorColumnRow {
data_type: string;
column_name: string;
[key: string]: unknown;
}
interface ForeignKeyRow {
constraint_name: string;
table_name: string;
[key: string]: unknown;
}
interface UniqueConstraintRow {
constraint_name: string;
table_name: string;
[key: string]: unknown;
}
interface CheckConstraintRow {
constraint_name: string;
table_name: string;
[key: string]: unknown;
}
interface AgentRow {
name: string;
[key: string]: unknown;
}
import { RuntimeMigrator } from "../../runtime-migrator";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabaseForMigration } from "../test-helpers";
const testBaseTable = pgTable(
"base_entities",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
created_at: timestamp("created_at", { withTimezone: true }).default(sql`now()`).notNull(),
updated_at: timestamp("updated_at", { withTimezone: true }).default(sql`now()`).notNull(),
active: boolean("active").default(true).notNull(),
metadata: jsonb("metadata").default({}).notNull(),
},
(table) => [
unique("base_entities_name_unique").on(table.name),
index("idx_base_entities_active").on(table.active),
]
);
const testDependentTable = pgTable(
"dependent_entities",
{
id: uuid("id").primaryKey().defaultRandom(),
base_id: uuid("base_id")
.notNull()
.references(() => testBaseTable.id, { onDelete: "cascade" }),
type: text("type").notNull(),
value: integer("value").default(0),
settings: jsonb("settings").default({}).notNull(),
},
(table) => [
unique("dependent_entities_base_type_unique").on(table.base_id, table.type),
index("idx_dependent_entities_type").on(table.type),
check("value_positive", sql`value >= 0`),
]
);
const testVectorTable = pgTable(
"vector_embeddings",
{
id: uuid("id").primaryKey().defaultRandom(),
entity_id: uuid("entity_id")
.notNull()
.references(() => testBaseTable.id, { onDelete: "cascade" }),
dim_384: vector("dim_384", { dimensions: 384 }),
dim_512: vector("dim_512", { dimensions: 512 }),
dim_1024: vector("dim_1024", { dimensions: 1024 }),
created_at: timestamp("created_at", { withTimezone: true }).default(sql`now()`).notNull(),
},
(table) => [
index("idx_vector_embeddings_entity").on(table.entity_id),
foreignKey({
name: "fk_vector_embeddings_entity",
columns: [table.entity_id],
foreignColumns: [testBaseTable.id],
}).onDelete("cascade"),
]
);
const testComplexDependencyTable = pgTable(
"complex_relations",
{
id: uuid("id").primaryKey().defaultRandom(),
base_id: uuid("base_id")
.notNull()
.references(() => testBaseTable.id),
dependent_id: uuid("dependent_id")
.notNull()
.references(() => testDependentTable.id),
vector_id: uuid("vector_id").references(() => testVectorTable.id),
relation_type: text("relation_type").notNull(),
strength: integer("strength").default(1),
},
(table) => [
unique("complex_relations_base_dependent_unique").on(table.base_id, table.dependent_id),
check("strength_range", sql`strength >= 1 AND strength <= 10`),
index("idx_complex_relations_type").on(table.relation_type),
]
);
const testSchema = {
testBaseTable,
testDependentTable,
testVectorTable,
testComplexDependencyTable,
};
describe("Comprehensive Dynamic Migration Tests", () => {
let db: DrizzleDatabase;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
beforeAll(async () => {
const testSetup = await createIsolatedTestDatabaseForMigration("comprehensive_migration_tests");
db = testSetup.db;
cleanup = testSetup.cleanup;
// The vector extension is already installed by adapter.init().
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Migration Execution", () => {
beforeAll(async () => {
try {
await db.execute(sql`DROP SCHEMA IF EXISTS migrations CASCADE`);
await db.execute(sql`DROP SCHEMA IF EXISTS public CASCADE`);
await db.execute(sql`CREATE SCHEMA public`);
console.log("[TEST CLEANUP] Reset schemas for migration tests");
} catch (error) {
console.log("[TEST CLEANUP] Schema cleanup failed:", error);
}
migrator = new RuntimeMigrator(db);
await migrator.initialize();
});
it("should create all tables in correct dependency order", async () => {
await migrator.migrate("test-plugin", testSchema, { verbose: true });
const result = await db.execute(
sql`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name`
);
const tableNames = result.rows.map((row) => (row as { table_name: string }).table_name);
expect(tableNames).toContain("base_entities");
expect(tableNames).toContain("dependent_entities");
expect(tableNames).toContain("vector_embeddings");
expect(tableNames).toContain("complex_relations");
});
it("should create vector columns with correct types", async () => {
const result = await db.execute(
sql`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'vector_embeddings'
AND column_name LIKE 'dim_%'
ORDER BY column_name`
);
const vectorColumns = result.rows as VectorColumnRow[];
expect(vectorColumns).toHaveLength(3);
// The pgvector extension reports its column type as USER-DEFINED.
vectorColumns.forEach((col) => {
expect(col.data_type).toBe("USER-DEFINED");
});
});
it("should create foreign key constraints properly", async () => {
const result = await db.execute(
sql`SELECT tc.constraint_name, tc.table_name, kcu.column_name, ccu.table_name AS referenced_table
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
ORDER BY tc.table_name, tc.constraint_name`
);
const foreignKeys = result.rows as ForeignKeyRow[];
expect(foreignKeys.length).toBeGreaterThan(0);
const dependentFk = foreignKeys.find(
(fk) =>
fk.table_name === "dependent_entities" &&
(fk as { column_name: string }).column_name === "base_id"
);
expect(dependentFk).toBeDefined();
expect((dependentFk as { referenced_table: string }).referenced_table).toBe("base_entities");
});
it("should create unique constraints properly", async () => {
const result = await db.execute(
sql`SELECT tc.constraint_name, tc.table_name
FROM information_schema.table_constraints AS tc
WHERE tc.constraint_type = 'UNIQUE'
AND tc.table_schema = 'public'
ORDER BY tc.table_name, tc.constraint_name`
);
const uniqueConstraints = result.rows as UniqueConstraintRow[];
expect(uniqueConstraints.length).toBeGreaterThan(0);
const baseNameUnique = uniqueConstraints.find(
(uc) =>
uc.table_name === "base_entities" && uc.constraint_name === "base_entities_name_unique"
);
expect(baseNameUnique).toBeDefined();
});
it("should create check constraints properly", async () => {
const result = await db.execute(
sql`SELECT tc.constraint_name, tc.table_name
FROM information_schema.table_constraints AS tc
WHERE tc.constraint_type = 'CHECK'
AND tc.table_schema = 'public'
ORDER BY tc.table_name, tc.constraint_name`
);
const checkConstraints = result.rows as CheckConstraintRow[];
expect(checkConstraints.length).toBeGreaterThan(0);
const valuePositive = checkConstraints.find(
(cc) => cc.table_name === "dependent_entities" && cc.constraint_name === "value_positive"
);
expect(valuePositive).toBeDefined();
});
it("should support vector similarity operations", async () => {
await db.execute(
sql`INSERT INTO public.base_entities (id, name) VALUES
('550e8400-e29b-41d4-a716-446655440000', 'test-entity')`
);
const testVector = Array(384)
.fill(0)
.map(() => Math.random())
.join(",");
await db.execute(
sql`INSERT INTO public.vector_embeddings (entity_id, dim_384) VALUES
('550e8400-e29b-41d4-a716-446655440000', '[${sql.raw(testVector)}]')`
);
const result = await db.execute(
sql`SELECT entity_id, dim_384 <=> '[${sql.raw(testVector)}]' as distance
FROM public.vector_embeddings
WHERE dim_384 IS NOT NULL
ORDER BY distance
LIMIT 1`
);
expect(result.rows).toHaveLength(1);
const row = result.rows[0] as AgentRow;
expect(row.entity_id).toBe("550e8400-e29b-41d4-a716-446655440000");
expect(row.distance).toBe(0); // Same vector should have distance 0
});
});
describe("Data Integrity", () => {
it("should enforce foreign key constraints", async () => {
// Inserting a dependent row against a non-existent base_id must fail.
let errorThrown = false;
try {
await db.execute(
sql`INSERT INTO public.dependent_entities (base_id, type) VALUES
('99999999-9999-9999-9999-999999999999', 'test')`
);
} catch (_error) {
errorThrown = true;
}
expect(errorThrown).toBe(true);
});
it("should enforce unique constraints", async () => {
await db.execute(
sql`INSERT INTO public.base_entities (id, name) VALUES
('550e8400-e29b-41d4-a716-446655440001', 'unique-test')`
);
// A second row with the same name must violate the unique constraint.
let errorThrown = false;
try {
await db.execute(
sql`INSERT INTO public.base_entities (id, name) VALUES
('550e8400-e29b-41d4-a716-446655440002', 'unique-test')`
);
} catch (_error) {
errorThrown = true;
}
expect(errorThrown).toBe(true);
});
it("should enforce check constraints", async () => {
await db.execute(
sql`INSERT INTO public.base_entities (id, name) VALUES
('550e8400-e29b-41d4-a716-446655440003', 'check-test')`
);
// A negative value must violate the "value_positive" check constraint.
let errorThrown = false;
try {
await db.execute(
sql`INSERT INTO public.dependent_entities (base_id, type, value) VALUES
('550e8400-e29b-41d4-a716-446655440003', 'test', -1)`
);
} catch (_error) {
errorThrown = true;
}
expect(errorThrown).toBe(true);
});
});
describe("Edge Cases", () => {
it("should handle idempotent migrations (running same migration twice)", async () => {
let errorThrown = false;
let _errorDetails: unknown = null;
try {
await migrator.migrate("test-plugin", testSchema, { verbose: true });
} catch (error) {
errorThrown = true;
_errorDetails = error;
console.error("[TEST] Idempotent migration failed:", error);
}
expect(errorThrown).toBe(false);
const result = await db.execute(
sql`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
);
expect(result.rows.length).toBeGreaterThanOrEqual(4);
});
it("should handle empty schema gracefully", async () => {
let errorThrown = false;
let _errorDetails: unknown = null;
try {
await migrator.migrate("empty-plugin", {}, { verbose: true });
} catch (error) {
errorThrown = true;
_errorDetails = error;
console.error("[TEST] Empty schema migration failed:", error);
}
expect(errorThrown).toBe(false);
});
it("should handle schema with non-table exports", async () => {
// A dedicated table avoids name conflicts with tables from other tests.
const testMixedTable = pgTable("mixed_test_table", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
created_at: timestamp("created_at", { withTimezone: true }).default(sql`now()`).notNull(),
});
const mixedSchema = {
testMixedTable,
someConstant: "not-a-table",
someFunction: () => "also-not-a-table",
someObject: { notATable: true },
};
let errorThrown = false;
let _errorDetails: unknown = null;
try {
await migrator.migrate("mixed-plugin", mixedSchema, { verbose: true });
} catch (error) {
errorThrown = true;
_errorDetails = error;
console.error("[TEST] Mixed schema migration failed:", error);
}
expect(errorThrown).toBe(false);
// Only the real table should be created; non-table exports are ignored.
const result = await db.execute(
sql`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
);
const mixedTableExists = result.rows.some(
(row) => (row as { table_name: string }).table_name === "mixed_test_table"
);
expect(mixedTableExists).toBe(true);
});
});
});
@@ -0,0 +1,564 @@
/**
* Real-PGlite tests proving `RuntimeMigrator` never loses existing rows when
* a schema changes underneath it — column additions, safe type widenings,
* foreign-key tables, JSON/array columns, large (1000-row) batches, and a
* destructive rename that must be rejected while leaving prior data intact.
*/
import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite/vector";
import { sql } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
numeric,
pgTable,
serial,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/pglite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RuntimeMigrator } from "../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../runtime-migrator/types";
describe("Data Persistence Through Migrations", () => {
let pgClient: PGlite;
let db: DrizzleDB;
let migrator: RuntimeMigrator;
beforeEach(async () => {
pgClient = new PGlite({ extensions: { vector } });
db = drizzle(pgClient);
migrator = new RuntimeMigrator(db);
await migrator.initialize();
});
afterEach(async () => {
await pgClient.close();
});
describe("Critical Data Persistence Scenarios", () => {
it("should preserve ALL data through column additions", async () => {
await db.execute(sql`
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
const customerData: Array<{ id: number; name: string; email: string }> = [];
for (let i = 1; i <= 100; i++) {
customerData.push({
id: i,
name: `Customer ${i}`,
email: `customer${i}@example.com`,
});
}
for (const customer of customerData) {
await db.execute(sql`
INSERT INTO customers (name, email)
VALUES (${customer.name}, ${customer.email})
`);
}
const initialCount = await db.execute(sql`SELECT COUNT(*) as count FROM customers`);
expect(Number(initialCount.rows[0].count)).toBe(100);
const customersTable = pgTable("customers", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
created_at: timestamp("created_at").defaultNow(),
phone: varchar("phone", { length: 20 }),
address: text("address"),
is_active: boolean("is_active").default(true),
metadata: jsonb("metadata"),
});
const schema = { customers: customersTable };
await migrator.migrate("@elizaos/plugin-sql", schema, {
verbose: false,
force: true,
});
const afterMigration = await db.execute(sql`
SELECT * FROM customers
ORDER BY id
`);
expect(afterMigration.rows).toHaveLength(100);
for (let i = 0; i < 100; i++) {
const row = afterMigration.rows[i];
expect(row.id).toBe(i + 1);
expect(row.name).toBe(`Customer ${i + 1}`);
expect(row.email).toBe(`customer${i + 1}@example.com`);
expect(row.created_at).toBeDefined();
expect(row.is_active).toBe(true);
expect(row.phone).toBeNull();
expect(row.address).toBeNull();
expect(row.metadata).toBeNull();
}
const status = await migrator.getStatus("@elizaos/plugin-sql");
expect(status.hasRun).toBe(true);
expect(status.snapshots).toBeGreaterThan(0);
expect(status.journal).toBeDefined();
expect(status.journal?.entries).toHaveLength(2); // One for introspection, one for migration
});
it("should preserve data through column type changes that are safe", async () => {
await db.execute(sql`
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL,
stock INTEGER DEFAULT 0
)
`);
const products = [
{ name: "Product A", price: 1999, stock: 100 },
{ name: "Product B", price: 2999, stock: 50 },
{ name: "Product C", price: 3999, stock: 25 },
{ name: "Product D", price: 4999, stock: 10 },
{ name: "Product E", price: 5999, stock: 5 },
];
for (const product of products) {
await db.execute(sql`
INSERT INTO products (name, price, stock)
VALUES (${product.name}, ${product.price}, ${product.stock})
`);
}
const productsTable = pgTable("products", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
name: text("name").notNull(),
price: numeric("price", { precision: 10, scale: 2 }).notNull(), // Changed from INTEGER
stock: integer("stock").default(0),
description: text("description"), // New column
});
const schema = { products: productsTable };
await migrator.migrate("@elizaos/plugin-sql", schema, {
verbose: false,
force: true,
});
const result = await db.execute(sql`
SELECT * FROM products ORDER BY id
`);
expect(result.rows).toHaveLength(5);
expect(result.rows[0].name).toBe("Product A");
expect(result.rows[0].price).toBe("1999.00"); // Numeric type returns as string with precision
expect(result.rows[0].stock).toBe(100);
expect(result.rows[1].name).toBe("Product B");
expect(result.rows[1].price).toBe("2999.00");
});
it("should preserve data in related tables with foreign keys", async () => {
await db.execute(sql`
CREATE TABLE departments (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
budget NUMERIC(12,2)
)
`);
await db.execute(sql`
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
department_id INTEGER REFERENCES departments(id),
salary NUMERIC(10,2),
hired_at TIMESTAMP DEFAULT NOW()
)
`);
const deptIds: number[] = [];
const departments = ["Engineering", "Sales", "Marketing", "HR"];
for (const dept of departments) {
const result = await db.execute(sql`
INSERT INTO departments (name, budget)
VALUES (${dept}, ${Math.random() * 1000000})
RETURNING id
`);
deptIds.push(result.rows[0].id as number);
}
for (let i = 1; i <= 50; i++) {
const deptId = deptIds[Math.floor(Math.random() * deptIds.length)];
await db.execute(sql`
INSERT INTO employees (name, email, department_id, salary)
VALUES (
${`Employee ${i}`},
${`employee${i}@company.com`},
${deptId},
${50000 + Math.random() * 100000}
)
`);
}
const empCount = await db.execute(sql`SELECT COUNT(*) as count FROM employees`);
expect(Number(empCount.rows[0].count)).toBe(50);
const deptCount = await db.execute(sql`SELECT COUNT(*) as count FROM departments`);
expect(Number(deptCount.rows[0].count)).toBe(4);
const departmentsTable = pgTable("departments", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
name: text("name").notNull(),
budget: numeric("budget", { precision: 12, scale: 2 }),
created_at: timestamp("created_at").defaultNow(), // New column
is_active: boolean("is_active").default(true), // New column
});
const employeesTable = pgTable("employees", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
department_id: integer("department_id").references(() => departmentsTable.id),
salary: numeric("salary", { precision: 10, scale: 2 }),
hired_at: timestamp("hired_at").defaultNow(),
position: text("position"), // New column
is_active: boolean("is_active").default(true), // New column
});
const schema = {
departments: departmentsTable,
employees: employeesTable,
};
await migrator.migrate("@elizaos/plugin-sql", schema, {
verbose: false,
force: true,
});
const employeesAfter = await db.execute(sql`
SELECT e.*, d.name as dept_name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id
ORDER BY e.id
`);
expect(employeesAfter.rows).toHaveLength(50);
for (const emp of employeesAfter.rows) {
expect(emp.dept_name).toBeDefined();
expect(departments).toContain(emp.dept_name as string);
expect(emp.is_active).toBe(true); // New column should have default
}
const deptsAfter = await db.execute(sql`
SELECT * FROM departments ORDER BY id
`);
expect(deptsAfter.rows).toHaveLength(4);
for (const dept of deptsAfter.rows) {
expect(dept.is_active).toBe(true); // New column should have default
}
});
it("should handle migration rollback on failure without data loss", async () => {
await db.execute(sql`
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
amount NUMERIC(10,2) NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
const transactionIds: string[] = [];
for (let i = 1; i <= 10; i++) {
const result = await db.execute(sql`
INSERT INTO transactions (amount, status)
VALUES (${100 * i}, 'completed')
RETURNING id
`);
transactionIds.push(result.rows[0].id as string);
}
const initialData = await db.execute(sql`
SELECT * FROM transactions ORDER BY amount
`);
expect(initialData.rows).toHaveLength(10);
// `id: integer` conflicts with the existing UUID column, so this
// migration must be rejected as destructive rather than applied.
const badSchema = pgTable("transactions", {
id: integer("id").primaryKey(),
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
status: text("status").notNull(),
created_at: timestamp("created_at").defaultNow(),
});
try {
await migrator.migrate(
"@elizaos/plugin-sql",
{ transactions: badSchema },
{
verbose: false,
force: false,
}
);
expect(true).toBe(false); // Unreachable: migrate() above must throw.
} catch (error) {
expect((error as Error).message).toContain("Destructive migration blocked");
}
const afterFailure = await db.execute(sql`
SELECT * FROM transactions ORDER BY amount
`);
expect(afterFailure.rows).toHaveLength(10);
for (let i = 0; i < 10; i++) {
expect(afterFailure.rows[i].amount).toBe(`${String(100 * (i + 1))}.00`); // Numeric includes precision
expect(afterFailure.rows[i].status).toBe("completed");
expect(transactionIds).toContain(afterFailure.rows[i].id as string);
}
});
it("should correctly track migration history through multiple changes", async () => {
const ordersV1 = pgTable("orders", {
id: serial("id").primaryKey(),
total: integer("total").notNull(),
});
await migrator.migrate("@elizaos/plugin-sql", { orders: ordersV1 }, { verbose: false });
for (let i = 1; i <= 5; i++) {
await db.execute(sql`
INSERT INTO orders (total) VALUES (${i * 100})
`);
}
// V2: adds customer_name.
const ordersV2 = pgTable("orders", {
id: serial("id").primaryKey(),
total: integer("total").notNull(),
customer_name: text("customer_name"),
});
await migrator.migrate("@elizaos/plugin-sql", { orders: ordersV2 }, { verbose: false });
// V3: adds status and created_at.
const ordersV3 = pgTable("orders", {
id: serial("id").primaryKey(),
total: integer("total").notNull(),
customer_name: text("customer_name"),
status: varchar("status", { length: 20 }).default("pending"),
created_at: timestamp("created_at").defaultNow(),
});
await migrator.migrate("@elizaos/plugin-sql", { orders: ordersV3 }, { verbose: false });
const finalData = await db.execute(sql`
SELECT * FROM orders ORDER BY id
`);
expect(finalData.rows).toHaveLength(5);
for (let i = 0; i < 5; i++) {
expect(finalData.rows[i].id).toBe(i + 1);
expect(finalData.rows[i].total).toBe((i + 1) * 100);
expect(finalData.rows[i].status).toBe("pending"); // Default value
}
const status = await migrator.getStatus("@elizaos/plugin-sql");
expect(status.hasRun).toBe(true);
expect(status.journal?.entries).toHaveLength(3); // One per migrate() call above.
expect(status.snapshots).toBe(3);
const journalEntries = status.journal?.entries || [];
expect(journalEntries[0].idx).toBe(0);
expect(journalEntries[1].idx).toBe(1);
expect(journalEntries[2].idx).toBe(2);
});
it("should handle complex data with JSON and arrays correctly", async () => {
await db.execute(sql`
CREATE TABLE user_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL,
settings JSONB NOT NULL,
tags TEXT[] DEFAULT '{}',
metadata JSONB
)
`);
const profiles = [
{
username: "user1",
settings: { theme: "dark", notifications: true, language: "en" },
tags: ["admin", "developer"],
metadata: { lastLogin: "2024-01-01", loginCount: 42 },
},
{
username: "user2",
settings: { theme: "light", notifications: false, language: "es" },
tags: ["user", "beta-tester"],
metadata: { lastLogin: "2024-01-02", loginCount: 15 },
},
{
username: "user3",
settings: { theme: "auto", notifications: true, language: "fr" },
tags: ["moderator"],
metadata: null,
},
];
for (const profile of profiles) {
// Postgres array literals need the `{"a","b"}` text form, not JSON.
const tagsLiteral = profile.tags
? `{${profile.tags.map((t) => `"${t}"`).join(",")}}`
: "{}";
await db.execute(
sql.raw(`
INSERT INTO user_profiles (username, settings, tags, metadata)
VALUES (
'${profile.username}',
'${JSON.stringify(profile.settings)}'::jsonb,
'${tagsLiteral}'::text[],
${profile.metadata ? `'${JSON.stringify(profile.metadata)}'::jsonb` : "NULL"}
)
`)
);
}
const userProfilesTable = pgTable("user_profiles", {
id: uuid("id").primaryKey().defaultRandom(),
username: text("username").notNull(),
settings: jsonb("settings").notNull(),
tags: text("tags").array().default(sql`'{}'::text[]`),
metadata: jsonb("metadata"),
created_at: timestamp("created_at").defaultNow(), // New column
is_verified: boolean("is_verified").default(false), // New column
});
const schema = { user_profiles: userProfilesTable };
await migrator.migrate("@elizaos/plugin-sql", schema, {
verbose: false,
force: true,
});
const result = await db.execute(sql`
SELECT * FROM user_profiles ORDER BY username
`);
expect(result.rows).toHaveLength(3);
const user1 = result.rows[0];
expect(user1.username).toBe("user1");
expect(user1.settings).toEqual({
theme: "dark",
notifications: true,
language: "en",
});
expect(user1.tags).toEqual(["admin", "developer"]);
expect(user1.metadata).toEqual({
lastLogin: "2024-01-01",
loginCount: 42,
});
expect(user1.is_verified).toBe(false); // New column default
const user2 = result.rows[1];
expect(user2.username).toBe("user2");
expect(user2.settings).toEqual({
theme: "light",
notifications: false,
language: "es",
});
expect(user2.tags).toEqual(["user", "beta-tester"]);
const user3 = result.rows[2];
expect(user3.username).toBe("user3");
expect(user3.metadata).toBeNull();
expect(user3.tags).toEqual(["moderator"]);
});
it("should handle large-scale data migration efficiently", async () => {
await db.execute(sql`
CREATE TABLE events (
id SERIAL PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMP DEFAULT NOW()
)
`);
const eventTypes = ["click", "view", "purchase", "signup", "logout"];
const batchSize = 100;
for (let batch = 0; batch < 10; batch++) {
const values: string[] = [];
for (let i = 0; i < batchSize; i++) {
const eventNum = batch * batchSize + i;
const eventType = eventTypes[eventNum % eventTypes.length];
values.push(
`('${eventType}', '{"id": ${eventNum}, "timestamp": "${new Date().toISOString()}"}')`
);
}
await db.execute(
sql.raw(`
INSERT INTO events (event_type, payload)
VALUES ${values.join(", ")}
`)
);
}
const initialCount = await db.execute(sql`SELECT COUNT(*) as count FROM events`);
expect(Number(initialCount.rows[0].count)).toBe(1000);
const eventsTable = pgTable("events", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
event_type: text("event_type").notNull(),
payload: jsonb("payload"),
created_at: timestamp("created_at").defaultNow(),
user_id: uuid("user_id"), // New column
session_id: text("session_id"), // New column
processed: boolean("processed").default(false), // New column
});
const schema = { events: eventsTable };
await migrator.migrate("@elizaos/plugin-sql", schema, {
verbose: false,
force: true,
});
const finalCount = await db.execute(sql`SELECT COUNT(*) as count FROM events`);
expect(Number(finalCount.rows[0].count)).toBe(1000);
const sample = await db.execute(sql`
SELECT * FROM events
WHERE id IN (1, 100, 500, 999, 1000)
ORDER BY id
`);
expect(sample.rows).toHaveLength(5);
for (const row of sample.rows) {
expect(row.event_type).toBeDefined();
expect(row.payload).toBeDefined();
expect(row.created_at).toBeDefined();
expect(row.processed).toBe(false); // New column default
}
const status = await migrator.getStatus("@elizaos/plugin-sql");
expect(status.hasRun).toBe(true);
expect(status.lastMigration).toBeDefined();
});
});
});
@@ -0,0 +1,339 @@
/**
* Tests for `RuntimeMigrator`'s private `isRealPostgresDatabase` connection-
* string sniffer, which decides whether a URL points at real Postgres (vs.
* PGlite/SQLite/in-memory/other engines) across the connection-string shapes
* used by major managed-Postgres providers. A thin subclass re-exposes the
* private method so it can be exercised directly; no real database connects.
*/
import { describe, expect, it } from "vitest";
import { RuntimeMigrator } from "../../runtime-migrator";
import type { DrizzleDB } from "../../runtime-migrator/types";
describe("RuntimeMigrator - Database Detection", () => {
interface TestableRuntimeMigrator extends RuntimeMigrator {
isRealPostgresDatabase(url: string): boolean;
}
function getTestableMigrator(migrator: RuntimeMigrator): TestableRuntimeMigrator {
return migrator as TestableRuntimeMigrator;
}
class TestRuntimeMigrator extends RuntimeMigrator {
constructor() {
// The detection logic under test never touches the db handle.
const mockDb: Partial<DrizzleDB> = {};
super(mockDb as DrizzleDB);
}
public testIsRealPostgresDatabase(url: string): boolean {
return getTestableMigrator(this).isRealPostgresDatabase(url);
}
}
const migrator = new TestRuntimeMigrator();
describe("Valid PostgreSQL Connection Strings", () => {
it("should detect standard postgres:// URLs", () => {
const urls = [
"postgres://user:password@localhost:5432/mydb",
"postgres://user@localhost/mydb",
"postgres://localhost/mydb",
"POSTGRES://USER:PASSWORD@HOST:5432/DB", // Case insensitive
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should detect standard postgresql:// URLs", () => {
const urls = [
"postgresql://user:password@localhost:5432/mydb",
"postgresql://user@localhost/mydb",
"postgresql://localhost/mydb",
"POSTGRESQL://USER:PASSWORD@HOST:5432/DB", // Case insensitive
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should detect cloud provider PostgreSQL URLs", () => {
const urls = [
// AWS RDS
"postgresql://user:pass@myinstance.123456789012.us-east-1.rds.amazonaws.com:5432/mydb",
"postgres://user:pass@mydb.cluster-abc123.us-west-2.rds.amazonaws.com:5432/prod",
// Azure Database for PostgreSQL
"postgresql://user@myserver:password@myserver.postgres.database.azure.com:5432/mydb?sslmode=require",
// Google Cloud SQL
"postgresql://user:password@127.0.0.1:5432/mydb?host=/cloudsql/project:region:instance",
"postgres://user@35.201.123.45:5432/production?application_name=myapp",
// Supabase
"postgresql://postgres.abcdefghijklmnop:password@aws-0-us-west-1.pooler.supabase.com:5432/postgres",
"postgres://user:pass@db.abcdefghijklmnop.supabase.co:5432/postgres",
// Neon
"postgresql://user:pass@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require",
"postgres://user:pass@ep-name-123456.eu-central-1.aws.neon.tech/dbname",
// DigitalOcean Managed Database
"postgresql://doadmin:password@db-postgresql-nyc1-12345-do-user-123456-0.db.ondigitalocean.com:25060/defaultdb?sslmode=require",
// Aiven
"postgres://avnadmin:password@pg-123456-project.aivencloud.com:12345/defaultdb?sslmode=require",
"postgresql://user:pass@my-pg-project-name.aiven.io:21234/mydb",
// TimescaleDB Cloud
"postgresql://tsdbadmin:password@abcdef123.a.timescaledb.io:31234/tsdb?sslmode=require",
"postgres://user:pass@service.region.timescale.com:30000/tsdb",
// CockroachDB (PostgreSQL-compatible)
"postgresql://user:pass@free-tier.gcp-us-central1.cockroachlabs.cloud:26257/defaultdb?sslmode=require",
"postgres://user@app-name-1234.cockroachlabs.cloud:26257/mydb",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should detect PostgreSQL URLs with special parameters", () => {
const urls = [
"postgres://user:pass@localhost:5432/mydb?sslmode=require",
"postgresql://user:pass@localhost:5432/mydb?sslmode=require&connect_timeout=10",
"postgres://user:pass@localhost:5432/mydb?application_name=myapp",
"postgresql://user:pass@localhost:5432/mydb?sslmode=require&sslcert=client.crt&sslkey=client.key",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should detect host:port/database format without scheme", () => {
const urls = [
"localhost:5432/mydb",
"192.168.1.1:5432/testdb",
"db.example.com:5432/production",
"postgres-server.local:5433/app_db",
"10.0.0.50:5432/my_database",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should detect PostgreSQL connection string parameters", () => {
const urls = [
"host=localhost port=5432 dbname=mydb user=myuser password=mypass",
"host=db.example.com port=5432 dbname=production sslmode=require",
"host=192.168.1.1 dbname=testdb user=admin",
"dbname=mydb host=localhost",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should detect alternative PostgreSQL ports", () => {
const urls = [
"postgres://localhost:5433/mydb",
"postgresql://host:6543/database",
"postgres://server:15432/db",
"localhost:5433/mydb",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
});
describe("Invalid/Non-PostgreSQL Connection Strings", () => {
it("should reject PGLite connection strings", () => {
const urls = [
"pglite://localhost/mydb",
"/path/to/pglite/database",
"./pglite/data",
"/home/user/pglite/db",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(false);
}
});
it("should reject in-memory database strings", () => {
const urls = [
":memory:",
"sqlite::memory:",
"postgres://:memory:",
"postgresql://localhost:5432/:memory:",
"file::memory:?cache=shared",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(false);
}
});
it("should reject SQLite connection strings", () => {
const urls = [
"sqlite:///path/to/database.db",
"sqlite3:///home/user/data.db",
"/path/to/database.sqlite",
"./data/app.db",
"database.db",
"myapp.sqlite",
"data.sqlite3",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(false);
}
});
it("should reject file-based database paths", () => {
const urls = [
"/absolute/path/to/data.db",
"./relative/path/to/data.sqlite",
"../parent/database.db",
"database.db",
"local.sqlite3",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(false);
}
});
it("should reject empty or invalid strings", () => {
const urls = ["", " ", " ", "\n", "\t"];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(false);
}
});
it("should reject MySQL connection strings", () => {
const urls = [
"mysql://user:password@localhost:3306/mydb",
"mysql://localhost/database",
"mysqli://user@host/db",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(false);
}
});
it("should reject MongoDB connection strings", () => {
const urls = [
"mongodb://localhost:27017/mydb",
"mongodb+srv://user:pass@cluster.mongodb.net/db",
"mongodb://user:password@host1:27017,host2:27017/database",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(false);
}
});
});
describe("Edge Cases", () => {
it("should handle URLs with special characters in credentials", () => {
const urls = [
"postgres://user%40example:p%40ssw0rd@localhost:5432/mydb", // @ symbol encoded
"postgresql://user:p@$$w0rd!@localhost:5432/mydb",
"postgres://user\\with\\backslash:pass@localhost/db",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should handle URLs with unusual database names", () => {
const urls = [
"postgres://localhost:5432/my-db-name",
"postgresql://localhost:5432/my_db_name_123",
"postgres://localhost:5432/DB",
"localhost:5432/test-db_2024",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should handle URLs with IPv6 addresses", () => {
const urls = [
"postgres://[::1]:5432/mydb",
"postgresql://user:pass@[2001:db8::1]:5432/database",
"postgres://[fe80::1%lo0]:5432/localdb",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should be case-insensitive for detection", () => {
const urls = [
"POSTGRES://LOCALHOST:5432/MYDB",
"PostgreSQL://Host:5432/Database",
"postgres://SUPABASE.com/db",
"POSTGRESQL://NEON.TECH/database",
"HOST=localhost DBNAME=mydb",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
it("should handle whitespace appropriately", () => {
expect(migrator.testIsRealPostgresDatabase(" postgres://localhost:5432/mydb ")).toBe(true);
expect(migrator.testIsRealPostgresDatabase("\tpostgresql://localhost/db\n")).toBe(true);
expect(migrator.testIsRealPostgresDatabase(" localhost:5432/mydb ")).toBe(true);
// Whitespace-only strings are still empty for detection purposes.
expect(migrator.testIsRealPostgresDatabase(" ")).toBe(false);
});
});
describe("Security Considerations", () => {
it("should not be fooled by PostgreSQL keywords in non-PostgreSQL URLs", () => {
// These contain PostgreSQL keywords but are not real PostgreSQL databases
const urls = [
"sqlite:///path/with/postgres/in/it.db",
"mongodb://postgres.example.com:27017/db", // MongoDB on a host named postgres
"file:///data/postgresql/backup.sqlite",
":memory:?name=postgres",
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(false);
}
});
it("should correctly identify PostgreSQL even with confusing paths", () => {
// These are real PostgreSQL despite having potentially confusing elements
const urls = [
"postgres://user:pass@localhost:5432/sqlite_migration", // DB named after sqlite
"postgresql://localhost:5432/memory_cache", // DB with "memory" in name
"postgres://localhost:5432/file_storage", // DB with "file" in name
];
for (const url of urls) {
expect(migrator.testIsRealPostgresDatabase(url)).toBe(true);
}
});
});
});
@@ -0,0 +1,193 @@
/**
* End-to-end tests that register plugin-defined Drizzle schemas (a simple
* hello-world table pair and a table set with FK relationships) through
* `createIsolatedTestDatabase`, confirm the runtime migrates and creates them
* alongside the core schema, and exercise a full memory write/read against
* one of the dynamically created tables. Runs against a real PGlite/Postgres
* adapter, not a mock.
*/
import {
type AgentRuntime,
ChannelType,
type Entity,
type Memory,
MemoryType,
type Plugin,
type Room,
stringToUuid,
type UUID,
type World,
} from "@elizaos/core";
import { pgTable, serial, text, uuid } from "drizzle-orm/pg-core";
import { v4 as uuidv4 } from "uuid";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { PgDatabaseAdapter } from "../../pg/adapter";
import type { PgliteDatabaseAdapter } from "../../pglite/adapter";
import { createIsolatedTestDatabase } from "../test-helpers";
const helloWorldTable = pgTable("hello_world", {
id: serial("id").primaryKey(),
message: text("message"),
});
const greetingsTable = pgTable("greetings", {
id: serial("id").primaryKey(),
greeting: text("greeting"),
});
const helloWorldPlugin: Plugin = {
name: "test-hello-world",
description: "A test plugin for dynamic migrations.",
schema: {
helloWorldTable,
greetingsTable,
},
};
const usersTable = pgTable("users", {
id: uuid("id").primaryKey(),
name: text("name"),
});
const postsTable = pgTable("posts", {
id: uuid("id").primaryKey(),
userId: uuid("user_id").references(() => usersTable.id),
content: text("content"),
});
const commentsTable = pgTable("comments", {
id: uuid("id").primaryKey(),
postId: uuid("post_id").references(() => postsTable.id),
userId: uuid("user_id").references(() => usersTable.id),
text: text("text"),
});
const complexPlugin: Plugin = {
name: "test-complex-plugin",
description: "A test plugin with complex relationships.",
schema: {
usersTable,
postsTable,
commentsTable,
},
};
describe("Dynamic Migration Tests", () => {
let adapter: PgliteDatabaseAdapter | PgDatabaseAdapter;
let runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeAll(async () => {
const setup = await createIsolatedTestDatabase("dynamic-migration-tests", [
helloWorldPlugin,
complexPlugin,
]);
adapter = setup.adapter;
runtime = setup.runtime;
cleanup = setup.cleanup;
testAgentId = setup.testAgentId;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Migration Tests", () => {
it("should initialize runtime with SQL plugin and hello world plugin", () => {
expect(runtime).toBeDefined();
const dbAdapter = runtime.db;
expect(dbAdapter).toBeDefined();
});
it("should create tables for the core sql plugin in the public schema", async () => {
const db = adapter.getDatabase();
const tables = await db.execute(
`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
);
const tableNames = tables.rows.map((r) => (r as { table_name: string }).table_name);
expect(tableNames).toContain("agents");
expect(tableNames).toContain("memories");
});
it("should create tables for the hello-world plugin", async () => {
const db = adapter.getDatabase();
const tables = await db.execute(
`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
);
const tableNames = tables.rows.map((r) => (r as { table_name: string }).table_name);
expect(tableNames).toContain("hello_world");
expect(tableNames).toContain("greetings");
});
it("should store and retrieve memory using the dynamically created schema", async () => {
const testEntityId = stringToUuid("test-entity-for-dynamic-migration");
const testRoomId = stringToUuid("test-room-for-dynamic-migration");
const testWorldId = stringToUuid("test-world-for-dynamic-migration");
await adapter.createWorld({
id: testWorldId,
agentId: testAgentId,
name: "Test World",
serverId: "test-server",
} as World);
await adapter.createRooms([
{
id: testRoomId,
agentId: testAgentId,
worldId: testWorldId,
name: "Test Room",
source: "test",
type: ChannelType.GROUP,
} as Room,
]);
await adapter.createEntities([
{
id: testEntityId,
agentId: testAgentId,
names: ["Test Entity"],
} as Entity,
]);
const memory: Memory = {
id: uuidv4() as UUID,
agentId: testAgentId,
roomId: testRoomId,
entityId: testEntityId,
worldId: testWorldId,
content: { text: "Hello from dynamic schema!" },
metadata: { type: MemoryType.CUSTOM, source: "test" },
};
const memoryId = await adapter.createMemory(memory, "memories");
const retrieved = await adapter.getMemoryById(memoryId);
expect(retrieved).toBeDefined();
expect(retrieved?.content).toEqual({
text: "Hello from dynamic schema!",
});
});
it("should handle complex relationships in dynamic schemas", async () => {
const { adapter: complexAdapter, cleanup: complexCleanup } = await createIsolatedTestDatabase(
"complex-plugin-tests",
[complexPlugin]
);
const db = complexAdapter.getDatabase();
const tables = await db.execute(
`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
);
const tableNames = tables.rows.map((r) => (r as { table_name: string }).table_name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("posts");
expect(tableNames).toContain("comments");
await complexCleanup();
});
});
});
@@ -0,0 +1,21 @@
services:
postgres:
image: pgvector/pgvector:pg16
container_name: migration-test-postgres
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
- POSTGRES_DB=migration_test
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres-data:
@@ -0,0 +1,835 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_PROJECT_DIR="$SCRIPT_DIR/test-agent"
ELIZA_REPO_DIR="$(cd "$SCRIPT_DIR/../../../../../.." && pwd)"
CLEANUP_HELPER="$ELIZA_REPO_DIR/packages/scripts/rm-path-recursive.mjs"
POSTGRES_URL="postgresql://postgres:postgres@localhost:5433/migration_test"
STATE_BEFORE_FILE="$SCRIPT_DIR/.state_before.txt"
STATE_AFTER_FILE="$SCRIPT_DIR/.state_after.txt"
LOG_LEVEL=debug
# ============================================
# SOURCE VERSION - Change this to test different versions
# ============================================
# Examples: 1.6.2, 1.6.3, 1.6.4
SOURCE_VERSION="${SOURCE_VERSION:-1.6.3}"
# macOS compatible timeout function
run_with_timeout() {
local timeout=$1
shift
# Run command in background
"$@" &
local pid=$!
# Wait for timeout or completion
( sleep $timeout && kill $pid 2>/dev/null ) &
local killer=$!
wait $pid 2>/dev/null
kill $killer 2>/dev/null || true
}
# Function to capture complete database state
capture_db_state() {
local output_file=$1
local label=$2
echo "=== DATABASE STATE: $label ===" > "$output_file"
echo "" >> "$output_file"
# List all tables
echo "--- TABLES ---" >> "$output_file"
docker exec migration-test-postgres psql -U postgres -d migration_test -t -c "
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
" >> "$output_file" 2>/dev/null || echo "(no tables)" >> "$output_file"
echo "" >> "$output_file"
# For each key table, show columns
for table in rooms memories worlds agents entities participants components relationships cache embeddings logs tasks; do
echo "--- TABLE: $table ---" >> "$output_file"
docker exec migration-test-postgres psql -U postgres -d migration_test -t -c "
SELECT column_name || ' (' || data_type || ')' as col
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = '$table'
ORDER BY ordinal_position;
" 2>/dev/null | grep -v "^$" >> "$output_file" || echo "(table not exists)" >> "$output_file"
echo "" >> "$output_file"
done
# Data counts
echo "--- DATA COUNTS ---" >> "$output_file"
docker exec migration-test-postgres psql -U postgres -d migration_test -t -c "
SELECT 'agents: ' || COUNT(*) FROM agents
UNION ALL SELECT 'rooms: ' || COUNT(*) FROM rooms
UNION ALL SELECT 'memories: ' || COUNT(*) FROM memories
UNION ALL SELECT 'worlds: ' || COUNT(*) FROM worlds;
" 2>/dev/null | grep -v "^$" >> "$output_file" || echo "(no data)" >> "$output_file"
echo "" >> "$output_file"
# Sample room data
echo "--- SAMPLE ROOM DATA ---" >> "$output_file"
docker exec migration-test-postgres psql -U postgres -d migration_test -c "
SELECT * FROM rooms LIMIT 1;
" 2>/dev/null >> "$output_file" || echo "(no rooms)" >> "$output_file"
}
# Function to display state with colors
display_state() {
local file=$1
local color=$2
while IFS= read -r line; do
if [[ $line == "==="* ]]; then
echo -e "${color}${line}${NC}"
elif [[ $line == "---"* ]]; then
echo -e "${CYAN}${line}${NC}"
else
echo -e "${color}${line}${NC}"
fi
done < "$file"
}
echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Migration Upgrade Test v${SOURCE_VERSION} → local (Local Build) ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
echo -e "${CYAN} Source version: ${SOURCE_VERSION}${NC}"
echo -e "${CYAN} Usage: SOURCE_VERSION=1.6.4 ./run-upgrade-test.sh${NC}"
# Step 1: Start PostgreSQL
echo -e "\n${YELLOW}▶ Step 1: Starting PostgreSQL...${NC}"
cd "$SCRIPT_DIR"
docker compose down -v 2>/dev/null || true
docker compose up -d
echo -e "${GREEN}✓ PostgreSQL started on port 5433${NC}"
# Wait for PostgreSQL to be ready
echo -e "${YELLOW} Waiting for PostgreSQL to be ready...${NC}"
for i in {1..30}; do
if docker exec migration-test-postgres pg_isready -U postgres > /dev/null 2>&1; then
echo -e "${GREEN}✓ PostgreSQL is ready${NC}"
break
fi
echo -n "."
sleep 1
done
# Step 2: Create test project directory
echo -e "\n${YELLOW}▶ Step 2: Creating test project...${NC}"
node "$CLEANUP_HELPER" "$TEST_PROJECT_DIR"
mkdir -p "$TEST_PROJECT_DIR"
# Step 3: Setup source version environment
echo -e "\n${YELLOW}▶ Step 3: Setting up v${SOURCE_VERSION} environment...${NC}"
# Create package.json for source version
cat > "$TEST_PROJECT_DIR/package.json" << EOF
{
"name": "migration-test-agent",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "bun run start-agent.ts",
"seed": "bun run seed-data.ts"
},
"dependencies": {
"@elizaos/core": "${SOURCE_VERSION}",
"@elizaos/plugin-sql": "${SOURCE_VERSION}"
}
}
EOF
# Create simple character file
cat > "$TEST_PROJECT_DIR/character.json" << 'EOF'
{
"name": "MigrationTestAgent",
"username": "migration_test",
"system": "You are a test agent for migration testing.",
"bio": ["A simple test agent"],
"plugins": ["@elizaos/plugin-sql"]
}
EOF
# Create a minimal start script that initializes the agent runtime and database schema
cat > "$TEST_PROJECT_DIR/start-agent.ts" << 'STARTEOF'
import { AgentRuntime } from "@elizaos/core";
import { sqlPlugin } from "@elizaos/plugin-sql";
import { readFileSync } from "fs";
const character = JSON.parse(readFileSync("./character.json", "utf-8"));
const runtime = new AgentRuntime({
character,
plugins: [sqlPlugin],
});
await runtime.initialize();
// Give the database schema time to be created, then exit
setTimeout(() => process.exit(0), 5000);
STARTEOF
# Create .env file
cat > "$TEST_PROJECT_DIR/.env" << EOF
POSTGRES_URL=$POSTGRES_URL
OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-sk-or-v1-test}
OPENROUTER_SMALL_MODEL=openai/gpt-5.1-nano
OPENROUTER_LARGE_MODEL=openai/gpt-5.1-mini
OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
OPENROUTER_EMBEDDING_DIMENSIONS=1536
LOG_LEVEL=info
EOF
# Create FULLY DYNAMIC seed data script - works with ANY version
# Introspects the database schema and builds INSERT statements dynamically
cat > "$TEST_PROJECT_DIR/seed-data.ts" << 'EOF'
import postgres, { type Sql } from 'postgres';
const POSTGRES_URL = process.env.POSTGRES_URL || 'postgresql://postgres:postgres@localhost:5433/migration_test';
interface ColumnInfo {
name: string;
type: string;
}
interface TableSchema {
columns: ColumnInfo[];
exists: boolean;
}
/** Row type from information_schema.columns query */
interface ColumnSchemaRow {
column_name: string;
data_type: string;
udt_name: string;
}
/** Generic database value types supported by postgres driver */
type DbValue = string | number | boolean | null | object | string[];
/** Record with database-compatible values */
type DbRecord = Record<string, DbValue>;
// Get all columns and their types for a table
async function getTableSchema(client: Sql, tableName: string): Promise<TableSchema> {
const result = await client<ColumnSchemaRow[]>`
SELECT column_name, data_type, udt_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${tableName}
ORDER BY ordinal_position
`;
return {
columns: result.map((r: ColumnSchemaRow) => ({
name: r.column_name,
type: r.data_type === 'ARRAY' ? r.udt_name : r.data_type // e.g., '_text' for text[]
})),
exists: result.length > 0
};
}
// Find which column name exists (tries multiple variants) - returns column info
function findColumn(schema: TableSchema, ...variants: string[]): ColumnInfo | null {
for (const v of variants) {
const col = schema.columns.find(c => c.name === v);
if (col) return col;
}
return null;
}
// Check if column is an array type
function isArrayColumn(col: ColumnInfo): boolean {
return col.type.startsWith('_'); // PostgreSQL uses _text for text[], _int4 for int[], etc.
}
// Build INSERT statement dynamically based on available columns
function buildInsert(
tableName: string,
schema: TableSchema,
data: DbRecord
): { sql: string; values: DbValue[] } | null {
// Filter data to only include columns that exist in the schema
const availableCols: string[] = [];
const availableVals: DbValue[] = [];
for (const [key, value] of Object.entries(data)) {
// Try to find the column (could be camelCase or snake_case)
const col = findColumn(schema, key);
if (col && value !== undefined) {
availableCols.push(`"${col}"`);
availableVals.push(value);
}
}
if (availableCols.length === 0) return null;
const placeholders = availableVals.map((_, i) => `$${i + 1}`).join(', ');
return {
sql: `INSERT INTO "${tableName}" (${availableCols.join(', ')}) VALUES (${placeholders})`,
values: availableVals
};
}
async function seedData() {
console.log('🌱 DYNAMIC SEED - Works with ANY schema version');
console.log(' Introspecting database schema...\n');
const client = postgres(POSTGRES_URL);
const stats: Record<string, number> = {};
try {
// ============================================
// INTROSPECT ALL TABLES
// ============================================
console.log('🔍 Discovering schema...');
const tables = ['agents', 'worlds', 'rooms', 'entities', 'memories',
'participants', 'relationships', 'components', 'logs', 'tasks', 'cache'];
const schemas: Record<string, TableSchema> = {};
for (const table of tables) {
schemas[table] = await getTableSchema(client, table);
if (schemas[table].exists) {
const colNames = schemas[table].columns.map(c => c.name);
console.log(` ✓ ${table}: ${colNames.length} columns`);
} else {
console.log(` ✗ ${table}: not found`);
}
}
console.log('');
// ============================================
// TEST DATA IDs
// ============================================
const agentId = '11111111-1111-1111-1111-111111111111';
const agentId2 = '11111111-1111-1111-1111-111111111112';
const worldId = '44444444-4444-4444-4444-444444444444';
const worldId2 = '44444444-4444-4444-4444-444444444445';
const roomId = '22222222-2222-2222-2222-222222222222';
const roomId2 = '22222222-2222-2222-2222-222222222223';
const roomId3 = '22222222-2222-2222-2222-222222222224';
const entityId = '55555555-5555-5555-5555-555555555551';
const entityId2 = '55555555-5555-5555-5555-555555555552';
const entityId3 = '55555555-5555-5555-5555-555555555553';
// ============================================
// HELPER: Insert with schema detection
// ============================================
async function smartInsert(tableName: string, dataRows: DbRecord[]) {
if (!schemas[tableName]?.exists) {
console.log(` ⊘ ${tableName}: table not found, skipping`);
return 0;
}
let count = 0;
for (const data of dataRows) {
// Map common column name variants with type info
const mappedData: Record<string, { value: DbValue; colInfo: ColumnInfo }> = {};
for (const [key, value] of Object.entries(data)) {
// Try camelCase, snake_case, and exact
const snakeCase = key.replace(/([A-Z])/g, '_$1').toLowerCase();
const colInfo = findColumn(schemas[tableName], key, snakeCase);
if (colInfo && !mappedData[colInfo.name]) {
mappedData[colInfo.name] = { value, colInfo };
}
}
const cols = Object.keys(mappedData).filter(k => mappedData[k].value !== undefined);
if (cols.length === 0) continue;
const colNames = cols.map(c => `"${c}"`).join(', ');
const values = cols.map(c => {
const { value, colInfo } = mappedData[c];
// For array columns, convert JS array to PostgreSQL array literal
if (isArrayColumn(colInfo) && Array.isArray(value)) {
return '{' + value.map(v => `"${String(v).replace(/"/g, '\\"')}"`).join(',') + '}';
}
// For jsonb, stringify the object
if (colInfo.type === 'jsonb' && typeof value === 'object') {
return JSON.stringify(value);
}
return value;
});
const placeholders = cols.map((c, i) => {
const { colInfo } = mappedData[c];
if (isArrayColumn(colInfo)) return `$${i + 1}::text[]`;
if (colInfo.type === 'jsonb') return `$${i + 1}::jsonb`;
if (colInfo.type === 'uuid') return `$${i + 1}::uuid`;
return `$${i + 1}`;
}).join(', ');
try {
await client.unsafe(`INSERT INTO "${tableName}" (${colNames}) VALUES (${placeholders})`, values);
count++;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.log(` ⚠ ${tableName} insert failed: ${message.slice(0, 60)}`);
}
}
stats[tableName] = count;
return count;
}
// ============================================
// 1. AGENTS
// ============================================
await smartInsert('agents', [
{ id: agentId, name: 'Test Agent 1', username: 'test_agent_1', enabled: true },
{ id: agentId2, name: 'Test Agent 2', username: 'test_agent_2', enabled: true }
]);
console.log(`✓ agents: ${stats.agents || 0} records`);
// ============================================
// 2. WORLDS
// ============================================
await smartInsert('worlds', [
{ id: worldId, agentId: agentId, agent_id: agentId, name: 'Test World 1', serverId: 'world-server-789', server_id: 'world-server-789' },
{ id: worldId2, agentId: agentId2, agent_id: agentId2, name: 'Test World 2', serverId: 'another-server', server_id: 'another-server' }
]);
console.log(`✓ worlds: ${stats.worlds || 0} records`);
// ============================================
// 3. ROOMS
// ============================================
await smartInsert('rooms', [
{ id: roomId, agentId: agentId, agent_id: agentId, worldId: worldId, world_id: worldId, name: 'General Chat', source: 'discord', type: 'GROUP', serverId: 'server-123', server_id: 'server-123', channelId: 'channel-456', channel_id: 'channel-456' },
{ id: roomId2, agentId: agentId, agent_id: agentId, worldId: worldId, world_id: worldId, name: 'Private DM', source: 'telegram', type: 'DM', serverId: 'server-789', server_id: 'server-789', channelId: 'channel-abc', channel_id: 'channel-abc' },
{ id: roomId3, agentId: agentId2, agent_id: agentId2, worldId: worldId2, world_id: worldId2, name: 'Another Room', source: 'slack', type: 'GROUP', serverId: 'slack-srv', server_id: 'slack-srv', channelId: 'slack-ch', channel_id: 'slack-ch' }
]);
console.log(`✓ rooms: ${stats.rooms || 0} records`);
// ============================================
// 4. ENTITIES
// ============================================
await smartInsert('entities', [
{ id: entityId, agentId: agentId, agent_id: agentId, names: ['Alice', 'alice123'], metadata: { role: 'user' } },
{ id: entityId2, agentId: agentId, agent_id: agentId, names: ['Bob', 'bob456'], metadata: { role: 'admin' } },
{ id: entityId3, agentId: agentId2, agent_id: agentId2, names: ['Charlie'], metadata: { role: 'guest' } }
]);
console.log(`✓ entities: ${stats.entities || 0} records`);
// ============================================
// 5. MEMORIES
// ============================================
const memoryRows = [];
for (let i = 1; i <= 10; i++) {
memoryRows.push({
id: `33333333-3333-3333-3333-3333333333${i.toString().padStart(2, '0')}`,
agentId: agentId, agent_id: agentId,
roomId: roomId, room_id: roomId,
entityId: entityId, entity_id: entityId,
worldId: worldId, world_id: worldId,
content: { text: `Test memory message ${i}` },
type: 'message',
unique: i % 2 === 0,
metadata: { importance: i }
});
}
await smartInsert('memories', memoryRows);
console.log(`✓ memories: ${stats.memories || 0} records`);
// ============================================
// 6. PARTICIPANTS
// ============================================
await smartInsert('participants', [
{ id: '66666666-6666-6666-6666-666666666661', entityId: entityId, entity_id: entityId, roomId: roomId, room_id: roomId, agentId: agentId, agent_id: agentId, roomState: 'ACTIVE', room_state: 'ACTIVE' },
{ id: '66666666-6666-6666-6666-666666666662', entityId: entityId2, entity_id: entityId2, roomId: roomId, room_id: roomId, agentId: agentId, agent_id: agentId, roomState: 'MUTED', room_state: 'MUTED' },
{ id: '66666666-6666-6666-6666-666666666663', entityId: entityId, entity_id: entityId, roomId: roomId2, room_id: roomId2, agentId: agentId, agent_id: agentId, roomState: 'ACTIVE', room_state: 'ACTIVE' },
{ id: '66666666-6666-6666-6666-666666666664', entityId: entityId3, entity_id: entityId3, roomId: roomId3, room_id: roomId3, agentId: agentId2, agent_id: agentId2, roomState: 'ACTIVE', room_state: 'ACTIVE' }
]);
console.log(`✓ participants: ${stats.participants || 0} records`);
// ============================================
// 7. RELATIONSHIPS
// ============================================
await smartInsert('relationships', [
{ id: '77777777-7777-7777-7777-777777777771', sourceEntityId: entityId, source_entity_id: entityId, targetEntityId: entityId2, target_entity_id: entityId2, agentId: agentId, agent_id: agentId, tags: ['friend'], metadata: { trust: 0.8 } },
{ id: '77777777-7777-7777-7777-777777777772', sourceEntityId: entityId2, source_entity_id: entityId2, targetEntityId: entityId, target_entity_id: entityId, agentId: agentId, agent_id: agentId, tags: ['friend'], metadata: { trust: 0.9 } },
{ id: '77777777-7777-7777-7777-777777777773', sourceEntityId: entityId, source_entity_id: entityId, targetEntityId: entityId3, target_entity_id: entityId3, agentId: agentId, agent_id: agentId, tags: ['acquaintance'], metadata: { trust: 0.5 } }
]);
console.log(`✓ relationships: ${stats.relationships || 0} records`);
// ============================================
// 8. COMPONENTS
// ============================================
await smartInsert('components', [
{ id: '88888888-8888-8888-8888-888888888881', entityId: entityId, entity_id: entityId, agentId: agentId, agent_id: agentId, roomId: roomId, room_id: roomId, worldId: worldId, world_id: worldId, type: 'profile', data: { bio: 'Test user' } },
{ id: '88888888-8888-8888-8888-888888888882', entityId: entityId2, entity_id: entityId2, agentId: agentId, agent_id: agentId, roomId: roomId, room_id: roomId, worldId: worldId, world_id: worldId, type: 'settings', data: { theme: 'dark' } },
{ id: '88888888-8888-8888-8888-888888888883', entityId: entityId3, entity_id: entityId3, agentId: agentId2, agent_id: agentId2, roomId: roomId3, room_id: roomId3, worldId: worldId2, world_id: worldId2, type: 'badge', data: { level: 5 } }
]);
console.log(`✓ components: ${stats.components || 0} records`);
// ============================================
// 9. LOGS
// ============================================
await smartInsert('logs', [
{ id: '99999999-9999-9999-9999-999999999991', entityId: entityId, entity_id: entityId, roomId: roomId, room_id: roomId, body: { action: 'message_sent' }, type: 'ACTION' },
{ id: '99999999-9999-9999-9999-999999999992', entityId: entityId2, entity_id: entityId2, roomId: roomId, room_id: roomId, body: { action: 'joined_room' }, type: 'EVENT' },
{ id: '99999999-9999-9999-9999-999999999993', entityId: entityId, entity_id: entityId, roomId: roomId2, room_id: roomId2, body: { error: 'rate_limited' }, type: 'ERROR' }
]);
console.log(`✓ logs: ${stats.logs || 0} records`);
// ============================================
// 10. TASKS
// ============================================
await smartInsert('tasks', [
{ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', name: 'Process messages', description: 'Background task', roomId: roomId, room_id: roomId, worldId: worldId, world_id: worldId, entityId: entityId, entity_id: entityId, agentId: agentId, agent_id: agentId, tags: ['background'], metadata: { priority: 1 } },
{ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab', name: 'Sync data', description: 'Sync task', roomId: roomId2, room_id: roomId2, worldId: worldId, world_id: worldId, agentId: agentId, agent_id: agentId, tags: ['sync'], metadata: { priority: 2 } },
{ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaac', name: 'Cleanup', description: 'Cleanup task', agentId: agentId2, agent_id: agentId2, tags: ['maintenance'], metadata: { priority: 3 } }
]);
console.log(`✓ tasks: ${stats.tasks || 0} records`);
// ============================================
// 11. CACHE
// ============================================
await smartInsert('cache', [
{ id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', key: 'user_prefs_alice', agentId: agentId, agent_id: agentId, value: { theme: 'dark' } },
{ id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbc', key: 'session_data_bob', agentId: agentId, agent_id: agentId, value: { token: 'abc123' } },
{ id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbd', key: 'api_response_cache', agentId: agentId2, agent_id: agentId2, value: { data: [1, 2, 3] } }
]);
console.log(`✓ cache: ${stats.cache || 0} records`);
// ============================================
// SUMMARY
// ============================================
const total = Object.values(stats).reduce((a, b) => a + b, 0);
console.log('\n' + '='.repeat(60));
console.log('📊 SEED DATA SUMMARY:');
console.log('='.repeat(60));
for (const [table, count] of Object.entries(stats)) {
console.log(` ${table.padEnd(15)} ${count} records`);
}
console.log('='.repeat(60));
console.log(` TOTAL: ${total} records`);
console.log('='.repeat(60));
console.log('\n✅ Dynamic seed complete!');
} catch (error) {
console.error('❌ Error seeding data:', error);
throw error;
} finally {
await client.end();
}
}
seedData().catch(console.error);
EOF
echo -e "${GREEN}✓ Test project created${NC}"
# Step 4: Install source version dependencies
echo -e "\n${YELLOW}▶ Step 4: Installing v${SOURCE_VERSION} dependencies...${NC}"
cd "$TEST_PROJECT_DIR"
bun install
echo -e "${GREEN}✓ v${SOURCE_VERSION} dependencies installed${NC}"
# Step 5: Initialize database with source version schema
echo -e "\n${YELLOW}▶ Step 5: Initializing v${SOURCE_VERSION} database schema...${NC}"
run_with_timeout 30 bun run start 2>&1 || true
echo -e "${GREEN}✓ v${SOURCE_VERSION} schema initialized${NC}"
# Step 6: Seed test data
echo -e "\n${YELLOW}▶ Step 6: Seeding test data...${NC}"
bun add postgres
bun run seed
echo -e "${GREEN}✓ Test data seeded${NC}"
# Step 7: Capture BEFORE state
echo -e "\n${YELLOW}▶ Step 7: Capturing v${SOURCE_VERSION} database state...${NC}"
capture_db_state "$STATE_BEFORE_FILE" "v${SOURCE_VERSION} (BEFORE migration)"
echo -e "\n${MAGENTA}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${MAGENTA}║ STATE BEFORE MIGRATION ║${NC}"
echo -e "${MAGENTA}║ (v${SOURCE_VERSION}) ║${NC}"
echo -e "${MAGENTA}╚════════════════════════════════════════════════════════════════╝${NC}"
display_state "$STATE_BEFORE_FILE" "$MAGENTA"
# Step 8: Upgrade to local build
echo -e "\n${YELLOW}▶ Step 8: Upgrading to local workspace...${NC}"
echo -e "${GREEN} Using workspace-linked packages from: $ELIZA_REPO_DIR/packages/*${NC}"
# Update package.json to use workspaces for local packages
cat > "$TEST_PROJECT_DIR/package.json" << EOF
{
"name": "migration-test-agent",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "bun run start-agent.ts",
"verify": "bun run verify-data.ts"
},
"workspaces": [
"$ELIZA_REPO_DIR/packages/*"
],
"dependencies": {
"@elizaos/core": "workspace:*",
"@elizaos/plugin-sql": "workspace:*",
"postgres": "^3.4.5"
}
}
EOF
# Create comprehensive verification script
cat > "$TEST_PROJECT_DIR/verify-data.ts" << 'EOF'
import postgres from 'postgres';
const POSTGRES_URL = process.env.POSTGRES_URL || 'postgresql://postgres:postgres@localhost:5433/migration_test';
// Define expected column migrations per table
const EXPECTED_MIGRATIONS = {
rooms: {
renames: ['agentId→agent_id', 'worldId→world_id', 'channelId→channel_id', 'createdAt→created_at', 'serverId→message_server_id'],
expectedCount: 3
},
worlds: {
renames: ['agentId→agent_id', 'createdAt→created_at', 'serverId→message_server_id'],
expectedCount: 2
},
memories: {
renames: ['agentId→agent_id', 'roomId→room_id', 'entityId→entity_id', 'worldId→world_id', 'createdAt→created_at'],
expectedCount: 10
},
entities: {
renames: ['agentId→agent_id', 'createdAt→created_at'],
expectedCount: 3
},
participants: {
renames: ['entityId→entity_id', 'roomId→room_id', 'agentId→agent_id', 'roomState→room_state', 'createdAt→created_at'],
expectedCount: 4
},
relationships: {
renames: ['sourceEntityId→source_entity_id', 'targetEntityId→target_entity_id', 'agentId→agent_id', 'createdAt→created_at'],
expectedCount: 3
},
components: {
renames: ['entityId→entity_id', 'agentId→agent_id', 'roomId→room_id', 'worldId→world_id', 'sourceEntityId→source_entity_id', 'createdAt→created_at'],
expectedCount: 3
},
logs: {
renames: ['entityId→entity_id', 'roomId→room_id', 'createdAt→created_at'],
expectedCount: 3
},
tasks: {
renames: ['roomId→room_id', 'worldId→world_id', 'entityId→entity_id', 'createdAt→created_at', 'updatedAt→updated_at'],
expectedCount: 3
},
cache: {
renames: ['agentId→agent_id', 'createdAt→created_at', 'expiresAt→expires_at'],
expectedCount: 3
}
};
async function verifyData() {
console.log('🔍 Comprehensive verification after migration...\n');
console.log('='.repeat(70));
const client = postgres(POSTGRES_URL);
try {
const errors: string[] = [];
const successes: string[] = [];
let totalRecordsBefore = 0;
let totalRecordsAfter = 0;
for (const [tableName, config] of Object.entries(EXPECTED_MIGRATIONS)) {
console.log(`\n📋 Checking table: ${tableName}`);
console.log('-'.repeat(40));
// Get current columns
const columns = await client`
SELECT column_name
FROM information_schema.columns
WHERE table_name = ${tableName}
ORDER BY ordinal_position
`;
const columnNames = columns.map(c => c.column_name);
// Check each expected rename
for (const rename of config.renames) {
const [oldName, newName] = rename.split('→');
const hasOld = columnNames.includes(oldName);
const hasNew = columnNames.includes(newName);
if (hasNew && !hasOld) {
successes.push(`${tableName}: ${oldName} → ${newName}`);
console.log(` ✓ ${oldName} → ${newName}`);
} else if (hasOld && !hasNew) {
errors.push(`${tableName}: ${oldName} not renamed to ${newName}`);
console.log(` ✗ ${oldName} not renamed (still camelCase)`);
} else if (hasOld && hasNew) {
errors.push(`${tableName}: Both ${oldName} and ${newName} exist`);
console.log(` ⚠ Both ${oldName} and ${newName} exist`);
} else {
// Neither exists - might be OK if column was optional
console.log(` ○ ${newName} (column not present)`);
}
}
// Check data count
const countResult = await client.unsafe(`SELECT COUNT(*) as count FROM "${tableName}"`);
const count = Number(countResult[0].count);
totalRecordsAfter += count;
totalRecordsBefore += config.expectedCount;
if (count >= config.expectedCount) {
successes.push(`${tableName}: ${count}/${config.expectedCount} records preserved`);
console.log(` ✓ Data: ${count} records (expected ≥${config.expectedCount})`);
} else {
errors.push(`${tableName}: Only ${count}/${config.expectedCount} records`);
console.log(` ✗ Data: ${count} records (expected ≥${config.expectedCount}) - DATA LOST!`);
}
}
// Check special tables/renames
console.log(`\n📋 Checking special migrations`);
console.log('-'.repeat(40));
// Check owners table is dropped
const ownersExists = await client`
SELECT 1 FROM information_schema.tables
WHERE table_name = 'owners'
`;
if (ownersExists.length === 0) {
successes.push('owners table dropped (migrated to servers)');
console.log(' ✓ owners table dropped');
} else {
errors.push('owners table still exists');
console.log(' ✗ owners table still exists');
}
// Check servers table exists
const serversExists = await client`
SELECT 1 FROM information_schema.tables
WHERE table_name = 'servers'
`;
if (serversExists.length > 0) {
successes.push('servers table created');
console.log(' ✓ servers table exists');
} else {
errors.push('servers table missing');
console.log(' ✗ servers table missing');
}
// Check message_server_id is UUID type
const messageServerIdType = await client`
SELECT data_type FROM information_schema.columns
WHERE table_name = 'rooms' AND column_name = 'message_server_id'
`;
if (messageServerIdType.length > 0 && messageServerIdType[0].data_type === 'uuid') {
successes.push('rooms.message_server_id is UUID type');
console.log(' ✓ rooms.message_server_id is UUID');
} else {
const actualType = messageServerIdType[0]?.data_type || 'missing';
errors.push(`rooms.message_server_id is ${actualType}, expected uuid`);
console.log(` ✗ rooms.message_server_id is ${actualType} (expected uuid)`);
}
// Check worlds.message_server_id is UUID type
const worldsMessageServerIdType = await client`
SELECT data_type FROM information_schema.columns
WHERE table_name = 'worlds' AND column_name = 'message_server_id'
`;
if (worldsMessageServerIdType.length > 0 && worldsMessageServerIdType[0].data_type === 'uuid') {
successes.push('worlds.message_server_id is UUID type');
console.log(' ✓ worlds.message_server_id is UUID');
} else {
const actualType = worldsMessageServerIdType[0]?.data_type || 'missing';
errors.push(`worlds.message_server_id is ${actualType}, expected uuid`);
console.log(` ✗ worlds.message_server_id is ${actualType} (expected uuid)`);
}
// Summary
console.log('\n' + '='.repeat(70));
console.log('📊 VERIFICATION SUMMARY');
console.log('='.repeat(70));
console.log(` Tables checked: ${Object.keys(EXPECTED_MIGRATIONS).length}`);
console.log(` Total records: ${totalRecordsAfter} (expected ≥${totalRecordsBefore})`);
console.log(` Successful checks: ${successes.length}`);
console.log(` Errors: ${errors.length}`);
console.log('='.repeat(70));
if (errors.length > 0) {
console.log('\n❌ ERRORS DETECTED:');
errors.forEach(e => console.log(` ✗ ${e}`));
console.log('\n');
process.exit(1);
}
console.log('\n🎉 ALL MIGRATIONS SUCCESSFUL! Data integrity verified.\n');
} catch (error) {
console.error('❌ Error verifying data:', error);
throw error;
} finally {
await client.end();
}
}
verifyData().catch(e => {
console.error(e);
process.exit(1);
});
EOF
bun install
echo -e "${GREEN}✓ Dependencies installed${NC}"
# Step 9: Run agent with local build (triggers migration)
echo -e "\n${YELLOW}▶ Step 9: Running agent with local build (migration will run)...${NC}"
echo -e "${BLUE} Running: bun run start (using workspace-linked @elizaos/core and @elizaos/plugin-sql)${NC}"
run_with_timeout 60 bun run start 2>&1 || true
echo -e "${GREEN}✓ Local agent ran (migration executed)${NC}"
# Step 10: Capture AFTER state
echo -e "\n${YELLOW}▶ Step 10: Capturing database state after migration...${NC}"
capture_db_state "$STATE_AFTER_FILE" "local (AFTER migration)"
echo -e "\n${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ STATE AFTER MIGRATION ║${NC}"
echo -e "${GREEN}║ (local build) ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
display_state "$STATE_AFTER_FILE" "$GREEN"
# Step 11: Show DIFF
echo -e "\n${CYAN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ MIGRATION DIFF ║${NC}"
echo -e "${CYAN}║ (camelCase → snake_case changes) ║${NC}"
echo -e "${CYAN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo -e "\n${YELLOW}Columns renamed in 'rooms' table:${NC}"
echo -e " ${RED}- agentId (text)${NC}${GREEN}+ agent_id (uuid)${NC}"
echo -e " ${RED}- createdAt (timestamp)${NC}${GREEN}+ created_at (timestamp)${NC}"
echo -e " ${RED}- channelId (text)${NC}${GREEN}+ channel_id (text)${NC}"
echo -e " ${RED}- serverId (text)${NC}${GREEN}+ message_server_id (uuid)${NC}"
echo -e "\n${YELLOW}Full diff (BEFORE vs AFTER):${NC}"
diff --color=always -u "$STATE_BEFORE_FILE" "$STATE_AFTER_FILE" 2>/dev/null || true
# Step 12: Run verification
echo -e "\n${YELLOW}▶ Step 11: Running verification...${NC}"
cd "$TEST_PROJECT_DIR"
bun run verify
echo -e "\n${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Migration Test Complete! ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
# Cleanup temp files
rm -f "$STATE_BEFORE_FILE" "$STATE_AFTER_FILE"
echo -e "\n${YELLOW}To cleanup:${NC}"
echo " cd $SCRIPT_DIR && docker compose down -v"
echo " node \"$CLEANUP_HELPER\" \"$TEST_PROJECT_DIR\""
@@ -0,0 +1,613 @@
/**
* End-to-end `RuntimeMigrator` tests against a real isolated database proving
* core (`@elizaos/plugin-sql`) and plugin (polymarket test fixture) schemas
* migrate into separate Postgres schemas, stay isolated (no table
* cross-contamination, correct per-schema table/index/FK counts), are
* idempotent on re-migration, and support real read/write/upsert/transaction
* operations against the plugin's schema-qualified tables.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
interface MigrationRow {
plugin_name: string;
hash: string;
[key: string]: unknown;
}
import type { UUID } from "@elizaos/core";
import { sql } from "drizzle-orm";
import { RuntimeMigrator } from "../../runtime-migrator";
import * as coreSchema from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { testPolymarketSchema } from "../schema-data/test-plugin-schema";
import { createIsolatedTestDatabaseForMigration } from "../test-helpers";
describe("Runtime Migrator - Core + Plugin Schema Tests", () => {
let db: DrizzleDatabase;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
let testAgentId: UUID;
beforeAll(async () => {
console.log("\n🚀 Testing Runtime Migrator with Core + Plugin Schemas...\n");
const testSetup = await createIsolatedTestDatabaseForMigration("plugin_schema_tests");
cleanup = testSetup.cleanup;
testAgentId = testSetup.testAgentId;
db = testSetup.db;
migrator = new RuntimeMigrator(db);
await migrator.initialize();
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Core Schema Migration", () => {
it("should migrate core schema to public schema", async () => {
console.log("\n📦 Migrating core schema (@elizaos/plugin-sql)...\n");
await migrator.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: true,
});
const tablesResult = await db.execute(
sql.raw(`SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename`)
);
const createdTables = tablesResult.rows.map((r) => (r as { tablename: string }).tablename);
console.log("Core tables created in public schema:", createdTables);
const expectedCoreTables = [
"agents",
"cache",
"memories",
"participants",
"relationships",
"rooms",
"tasks",
];
for (const table of expectedCoreTables) {
expect(createdTables).toContain(table);
}
});
it("should track core migration in _migrations table", async () => {
const result = await db.execute(
sql.raw(`SELECT * FROM migrations._migrations
WHERE plugin_name = '@elizaos/plugin-sql'
ORDER BY created_at DESC
LIMIT 1`)
);
expect(result.rows.length).toBeGreaterThan(0);
const migration = result.rows[0] as MigrationRow;
expect(migration.plugin_name).toBe("@elizaos/plugin-sql");
expect(migration.hash).toBeDefined();
});
});
describe("Plugin Schema Migration", () => {
it("should migrate plugin schema to polymarket schema namespace", async () => {
console.log("\n🔌 Migrating polymarket plugin schema...\n");
await migrator.migrate("polymarket", testPolymarketSchema, {
verbose: true,
});
const schemaResult = await db.execute(
sql.raw(`SELECT EXISTS (
SELECT 1 FROM information_schema.schemata
WHERE schema_name = 'polymarket'
)`)
);
const schemaResultRows0 = schemaResult.rows[0];
expect(schemaResultRows0?.exists).toBe(true);
console.log("✅ Polymarket schema created");
});
it("should create plugin tables in polymarket schema", async () => {
const tablesResult = await db.execute(
sql.raw(`SELECT tablename FROM pg_tables
WHERE schemaname = 'polymarket'
ORDER BY tablename`)
);
const createdTables = tablesResult.rows.map((r) => (r as { tablename: string }).tablename);
console.log("Plugin tables created in polymarket schema:", createdTables);
const expectedPluginTables = ["markets", "tokens", "rewards", "prices", "sync_status"];
for (const table of expectedPluginTables) {
expect(createdTables).toContain(table);
}
});
it("should verify no plugin tables in public schema", async () => {
const publicTablesResult = await db.execute(
sql.raw(`SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename LIKE 'polymarket_%'`)
);
expect(publicTablesResult.rows.length).toBe(0);
console.log("✅ No polymarket tables in public schema");
});
it("should track plugin migration in _migrations table", async () => {
const result = await db.execute(
sql.raw(`SELECT * FROM migrations._migrations
WHERE plugin_name = 'polymarket'
ORDER BY created_at DESC
LIMIT 1`)
);
expect(result.rows.length).toBeGreaterThan(0);
const migration = result.rows[0] as MigrationRow;
expect(migration.plugin_name).toBe("polymarket");
expect(migration.hash).toBeDefined();
});
it("should verify foreign key constraints work across tables in polymarket schema", async () => {
const fkResult = await db.execute(
sql.raw(`
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'polymarket'
ORDER BY tc.table_name, tc.constraint_name
`)
);
const foreignKeys = fkResult.rows;
console.log(`Found ${foreignKeys.length} foreign keys in polymarket schema`);
interface ForeignKeyResult {
table_name: string;
foreign_table_name: string;
}
const tokensFk = foreignKeys.find(
(fk) =>
(fk as ForeignKeyResult).table_name === "tokens" &&
(fk as ForeignKeyResult).foreign_table_name === "markets"
);
expect(tokensFk).toBeDefined();
const pricesFk = foreignKeys.find(
(fk) =>
(fk as ForeignKeyResult).table_name === "prices" &&
(fk as ForeignKeyResult).foreign_table_name === "markets"
);
expect(pricesFk).toBeDefined();
});
it("should create indexes in polymarket schema", async () => {
const indexResult = await db.execute(
sql.raw(`
SELECT
schemaname,
tablename,
indexname
FROM pg_indexes
WHERE schemaname = 'polymarket'
ORDER BY tablename, indexname
`)
);
const indexes = indexResult.rows;
console.log(`Found ${indexes.length} indexes in polymarket schema`);
interface IndexResult {
tablename: string;
indexname: string;
}
const marketIndexes = indexes.filter((idx) => (idx as IndexResult).tablename === "markets");
expect(marketIndexes.length).toBeGreaterThan(0);
const conditionIdIdx = indexes.find(
(idx) => (idx as IndexResult).indexname === "markets_condition_id_idx"
);
expect(conditionIdIdx).toBeDefined();
});
});
describe("Migration Idempotency", () => {
it("should skip re-migration when schema has not changed", async () => {
console.log("\n🔄 Testing migration idempotency...\n");
const _coreResult = await migrator.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: true,
});
const coreMigrations = await db.execute(
sql.raw(`SELECT COUNT(*) as count FROM migrations._migrations
WHERE plugin_name = '@elizaos/plugin-sql'`)
);
const coreMigrationsRows0 = coreMigrations.rows[0];
expect(Number(coreMigrationsRows0?.count ?? 0)).toBe(1);
const _pluginResult = await migrator.migrate("polymarket", testPolymarketSchema, {
verbose: true,
});
const pluginMigrations = await db.execute(
sql.raw(`SELECT COUNT(*) as count FROM migrations._migrations
WHERE plugin_name = 'polymarket'`)
);
const pluginMigrationsRows0 = pluginMigrations.rows[0];
expect(Number(pluginMigrationsRows0?.count ?? 0)).toBe(1);
});
});
describe("Schema Isolation", () => {
it("should verify core and plugin schemas are properly isolated", async () => {
// Get all schemas
const schemasResult = await db.execute(
sql.raw(`SELECT schema_name FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
ORDER BY schema_name`)
);
const schemas = schemasResult.rows.map((r) => (r as { schema_name: string }).schema_name);
console.log("Available schemas:", schemas);
expect(schemas).toContain("public");
expect(schemas).toContain("polymarket");
expect(schemas).toContain("migrations");
});
it("should verify table counts per schema", async () => {
const tableCountsResult = await db.execute(
sql.raw(`
SELECT
schemaname,
COUNT(*) as table_count
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
GROUP BY schemaname
ORDER BY schemaname
`)
);
interface TableCountRow {
schemaname: string;
table_count: string;
}
const counts = tableCountsResult.rows.reduce<Record<string, number>>((acc, row) => {
const typedRow = row as TableCountRow;
acc[typedRow.schemaname] = parseInt(typedRow.table_count, 10);
return acc;
}, {});
console.log("Table counts per schema:", counts);
// Core tables in public schema
expect(counts.public).toBeGreaterThan(10); // Core has many tables
// Plugin tables in polymarket schema
expect(counts.polymarket).toBe(5); // markets, tokens, rewards, prices, sync_status
// Migration tables
expect(counts.migrations).toBe(3); // _migrations, _journal, _snapshots
});
});
describe("Migration Status", () => {
it("should provide accurate status for both core and plugin", async () => {
const coreStatus = await migrator.getStatus("@elizaos/plugin-sql");
const pluginStatus = await migrator.getStatus("polymarket");
console.log("\nMigration Status:");
console.log("Core:", {
hasRun: coreStatus.hasRun,
snapshots: coreStatus.snapshots,
});
console.log("Plugin:", {
hasRun: pluginStatus.hasRun,
snapshots: pluginStatus.snapshots,
});
expect(coreStatus.hasRun).toBe(true);
expect(coreStatus.snapshots).toBeGreaterThan(0);
expect(pluginStatus.hasRun).toBe(true);
expect(pluginStatus.snapshots).toBeGreaterThan(0);
});
});
describe("Polymarket Schema Write/Read Operations", () => {
it("should successfully insert and read data from polymarket.markets table", async () => {
console.log("\n🔍 Testing write/read operations on polymarket schema...\n");
const testConditionId = `test_${testAgentId.slice(0, 8)}`;
const testMarketId = testAgentId; // Use the test UUID
// Direct insert using the polymarket schema tables
try {
// Import the actual schema tables
const { polymarketMarketsTable, polymarketTokensTable, polymarketRewardsTable } =
await import("../schema-data/test-plugin-schema");
// Test 1: Insert a market
console.log("Inserting test market...");
await db.insert(polymarketMarketsTable).values({
id: testMarketId,
conditionId: testConditionId,
questionId: `test_question_${Date.now()}`,
marketSlug: "test-market-slug",
question: "Test market question?",
category: "Test",
active: true,
closed: false,
secondsDelay: 0,
createdAt: new Date(),
updatedAt: new Date(),
lastSyncedAt: new Date(),
});
console.log("✅ Market inserted successfully");
// Verify the insert with raw SQL to confirm schema
const marketCheck = await db.execute(
sql.raw(`SELECT * FROM polymarket.markets WHERE condition_id = '${testConditionId}'`)
);
expect(marketCheck.rows.length).toBe(1);
expect(marketCheck.rows[0].condition_id).toBe(testConditionId);
console.log("✅ Market verified via raw SQL");
// Test 2: Insert tokens for the market
const tokenId1 = `token_yes_${Date.now()}`;
const tokenId2 = `token_no_${Date.now()}`;
console.log("Inserting test tokens...");
await db.insert(polymarketTokensTable).values([
{
id: sql`gen_random_uuid()`,
tokenId: tokenId1,
conditionId: testConditionId,
outcome: "YES",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: sql`gen_random_uuid()`,
tokenId: tokenId2,
conditionId: testConditionId,
outcome: "NO",
createdAt: new Date(),
updatedAt: new Date(),
},
]);
console.log("✅ Tokens inserted successfully");
// Verify tokens
const tokenCheck = await db.execute(
sql.raw(`SELECT * FROM polymarket.tokens WHERE condition_id = '${testConditionId}'`)
);
expect(tokenCheck.rows.length).toBe(2);
console.log("✅ Tokens verified via raw SQL");
// Test 3: Insert reward config
console.log("Inserting test reward config...");
await db.insert(polymarketRewardsTable).values({
id: sql`gen_random_uuid()`,
conditionId: testConditionId,
minSize: "100",
maxSpread: "0.05",
rewardEpoch: 1,
createdAt: new Date(),
updatedAt: new Date(),
});
console.log("✅ Reward config inserted successfully");
// Test 4: Test ON CONFLICT DO UPDATE (upsert)
console.log("Testing upsert functionality...");
await db
.insert(polymarketMarketsTable)
.values({
id: sql`gen_random_uuid()`,
conditionId: testConditionId,
questionId: `updated_question_${Date.now()}`,
marketSlug: "updated-market-slug",
question: "Updated test market question?",
category: "Updated",
active: false,
closed: true,
secondsDelay: 5,
createdAt: new Date(),
updatedAt: new Date(),
lastSyncedAt: new Date(),
})
.onConflictDoUpdate({
target: polymarketMarketsTable.conditionId,
set: {
question: "Updated test market question?",
active: false,
closed: true,
updatedAt: new Date(),
},
});
console.log("✅ Upsert operation completed");
// Verify the update
const updatedMarket = await db.execute(
sql.raw(`SELECT * FROM polymarket.markets WHERE condition_id = '${testConditionId}'`)
);
expect(updatedMarket.rows[0].question).toBe("Updated test market question?");
expect(updatedMarket.rows[0].active).toBe(false);
expect(updatedMarket.rows[0].closed).toBe(true);
console.log("✅ Upsert verified");
// Clean up test data
console.log("Cleaning up test data...");
await db.execute(
sql.raw(`DELETE FROM polymarket.rewards WHERE condition_id = '${testConditionId}'`)
);
await db.execute(
sql.raw(`DELETE FROM polymarket.tokens WHERE condition_id = '${testConditionId}'`)
);
await db.execute(
sql.raw(`DELETE FROM polymarket.markets WHERE condition_id = '${testConditionId}'`)
);
console.log("✅ Test data cleaned up");
} catch (error) {
console.error("❌ Write/Read test failed:", error);
if (error instanceof Error) {
console.error("Error message:", error.message);
console.error("Stack trace:", error.stack);
}
throw error;
}
});
it("should handle foreign key constraints correctly", async () => {
console.log("\n🔍 Testing foreign key constraints...\n");
const { polymarketMarketsTable, polymarketTokensTable } = await import(
"../schema-data/test-plugin-schema"
);
// Try to insert a token with non-existent conditionId (should fail)
const invalidConditionId = `non_existent_${Date.now()}`;
const tokenId = `test_token_${Date.now()}`;
try {
await db.insert(polymarketTokensTable).values({
id: sql`gen_random_uuid()`,
tokenId: tokenId,
conditionId: invalidConditionId,
outcome: "YES",
createdAt: new Date(),
updatedAt: new Date(),
});
// If we get here, the foreign key constraint didn't work
throw new Error("Foreign key constraint should have prevented this insert");
} catch (error) {
// This is expected - foreign key violation
console.log("✅ Foreign key constraint working correctly");
// In PGLite, the actual error is in the cause property
const errorObj = error as Error & { cause?: { message?: string } };
const errorMessage = errorObj.cause?.message || errorObj.message;
// The error should be a foreign key violation
const isValidForeignKeyError =
errorMessage.includes("violates foreign key constraint") ||
(errorObj.message.includes("Failed query") &&
errorObj.message.includes('insert into "polymarket"."tokens"') &&
!errorObj.message.includes("Foreign key constraint should have prevented this insert"));
expect(isValidForeignKeyError).toBe(true);
}
});
it("should handle transactions correctly across schema boundaries", async () => {
console.log("\n🔍 Testing transactions with polymarket schema...\n");
const { polymarketMarketsTable, polymarketTokensTable } = await import(
"../schema-data/test-plugin-schema"
);
const testConditionId = `tx_test_${Date.now()}`;
let transactionSucceeded = false;
try {
await db.transaction(async (tx) => {
// Insert market
await tx.insert(polymarketMarketsTable).values({
id: sql`gen_random_uuid()`,
conditionId: testConditionId,
questionId: "tx_question",
marketSlug: "tx-test",
question: "Transaction test?",
active: true,
closed: false,
createdAt: new Date(),
updatedAt: new Date(),
lastSyncedAt: new Date(),
});
// Insert token
await tx.insert(polymarketTokensTable).values({
id: sql`gen_random_uuid()`,
tokenId: `tx_token_${Date.now()}`,
conditionId: testConditionId,
outcome: "YES",
createdAt: new Date(),
updatedAt: new Date(),
});
transactionSucceeded = true;
console.log("✅ Transaction operations completed");
});
// Verify both inserts succeeded
const marketResult = await db.execute(
sql.raw(`SELECT * FROM polymarket.markets WHERE condition_id = '${testConditionId}'`)
);
const tokenResult = await db.execute(
sql.raw(`SELECT * FROM polymarket.tokens WHERE condition_id = '${testConditionId}'`)
);
expect(marketResult.rows.length).toBe(1);
expect(tokenResult.rows.length).toBe(1);
expect(transactionSucceeded).toBe(true);
console.log("✅ Transaction committed successfully");
// Clean up
await db.execute(
sql.raw(`DELETE FROM polymarket.tokens WHERE condition_id = '${testConditionId}'`)
);
await db.execute(
sql.raw(`DELETE FROM polymarket.markets WHERE condition_id = '${testConditionId}'`)
);
} catch (error) {
console.error("❌ Transaction test failed:", error);
throw error;
}
});
it("should correctly use schema-qualified table names in queries", async () => {
console.log("\n🔍 Testing schema qualification...\n");
// Test that Drizzle generates correct SQL with schema prefix
const { polymarketMarketsTable } = await import("../schema-data/test-plugin-schema");
// Use Drizzle's query builder to verify it generates correct SQL
const query = db
.select()
.from(polymarketMarketsTable)
.where(sql`active = true`)
.limit(1)
.toSQL();
console.log("Generated SQL:", query.sql);
// Verify the SQL includes the schema qualification
expect(query.sql).toContain("polymarket");
expect(query.sql).toMatch(/"polymarket"\."markets"/);
console.log("✅ Schema qualification verified in generated SQL");
});
});
});
@@ -0,0 +1,249 @@
/**
* End-to-end `RuntimeMigrator` tests against a real isolated database:
* verifies `initialize()` creates the `migrations` schema and its
* `_migrations` / `_journal` / `_snapshots` tables with the expected
* columns, that a first `migrate()` of the core schema creates every core
* table and records a migration/journal/snapshot row, and that
* `getStatus()` reports correctly for both a migrated and a never-seen
* plugin name.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
interface MigrationRow {
plugin_name: string;
hash: string;
created_at: string | Date;
[key: string]: unknown;
}
interface JournalRow {
entries: unknown[];
[key: string]: unknown;
}
interface SnapshotRow {
snapshot: {
tables: Record<string, unknown>;
[key: string]: unknown;
};
[key: string]: unknown;
}
interface ColumnInfoRow {
column_name: string;
data_type?: string;
is_nullable?: string;
[key: string]: unknown;
}
interface TableInfoRow {
tablename: string;
[key: string]: unknown;
}
import type { UUID } from "@elizaos/core";
import { sql } from "drizzle-orm";
import { RuntimeMigrator } from "../../runtime-migrator";
import * as originalSchema from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { createIsolatedTestDatabaseForMigration } from "../test-helpers";
describe("Runtime Migrator - Initialization Tests", () => {
let db: DrizzleDatabase;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
let _testAgentId: UUID;
beforeAll(async () => {
console.log("\n🚀 Testing Runtime Migrator Initialization...\n");
const testSetup = await createIsolatedTestDatabaseForMigration("initialization_tests");
cleanup = testSetup.cleanup;
_testAgentId = testSetup.testAgentId;
db = testSetup.db;
// Left uninitialized here; the "Migration Infrastructure Setup" tests call initialize().
migrator = new RuntimeMigrator(db);
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
describe("Migration Infrastructure Setup", () => {
it("should initialize migration schema and tables", async () => {
await migrator.initialize();
const schemaResult = await db.execute(
sql.raw(`SELECT EXISTS (
SELECT 1 FROM information_schema.schemata
WHERE schema_name = 'migrations'
)`)
);
const schemaResultRows0 = schemaResult.rows[0];
expect(schemaResultRows0?.exists).toBe(true);
});
it("should create all required migration tables", async () => {
const expectedTables = ["_migrations", "_journal", "_snapshots"];
for (const tableName of expectedTables) {
const result = await db.execute(
sql.raw(`SELECT EXISTS (
SELECT 1 FROM pg_tables
WHERE schemaname = 'migrations'
AND tablename = '${tableName}'
)`)
);
const resultRows0 = result.rows[0];
expect(resultRows0?.exists).toBe(true);
}
});
it("should create migration tables with correct structure", async () => {
const migrationsColumns = await db.execute(
sql.raw(`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'migrations'
AND table_name = '_migrations'
ORDER BY ordinal_position`)
);
const columnNames = migrationsColumns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("plugin_name");
expect(columnNames).toContain("hash");
expect(columnNames).toContain("created_at");
const journalColumns = await db.execute(
sql.raw(`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'migrations'
AND table_name = '_journal'`)
);
const journalColumnNames = journalColumns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(journalColumnNames).toContain("plugin_name");
expect(journalColumnNames).toContain("entries");
const snapshotColumns = await db.execute(
sql.raw(`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'migrations'
AND table_name = '_snapshots'`)
);
const snapshotColumnNames = snapshotColumns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(snapshotColumnNames).toContain("plugin_name");
expect(snapshotColumnNames).toContain("snapshot");
expect(snapshotColumnNames).toContain("idx");
});
});
describe("Basic Migration Execution", () => {
it("should run initial schema migration successfully", async () => {
await migrator.migrate("@elizaos/plugin-sql", originalSchema, {
verbose: true,
});
const tablesResult = await db.execute(
sql.raw(`SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename`)
);
const createdTables = tablesResult.rows.map((r: TableInfoRow) => r.tablename);
const expectedTables = [
"agents",
"cache",
"channel_participants",
"channels",
"components",
"embeddings",
"entities",
"logs",
"memories",
"message_servers",
"message_server_agents",
"central_messages",
"participants",
"relationships",
"rooms",
"tasks",
"worlds",
];
for (const table of expectedTables) {
expect(createdTables).toContain(table);
}
});
it("should track migration in _migrations table", async () => {
const result = await db.execute(
sql.raw(`SELECT * FROM migrations._migrations
WHERE plugin_name = '@elizaos/plugin-sql'
ORDER BY created_at DESC
LIMIT 1`)
);
expect(result.rows.length).toBeGreaterThan(0);
const migration = result.rows[0] as MigrationRow;
expect(migration.plugin_name).toBe("@elizaos/plugin-sql");
expect(migration.hash).toBeDefined();
expect(migration.created_at).toBeDefined();
});
it("should save journal entry with migration details", async () => {
const result = await db.execute(
sql.raw(`SELECT * FROM migrations._journal
WHERE plugin_name = '@elizaos/plugin-sql'`)
);
expect(result.rows.length).toBe(1);
const journal = result.rows[0] as JournalRow;
expect(journal.entries).toBeDefined();
expect(Array.isArray(journal.entries)).toBe(true);
expect(journal.entries.length).toBeGreaterThan(0);
});
it("should save schema snapshot", async () => {
const result = await db.execute(
sql.raw(`SELECT * FROM migrations._snapshots
WHERE plugin_name = '@elizaos/plugin-sql'
ORDER BY idx DESC`)
);
expect(result.rows.length).toBeGreaterThan(0);
const snapshot = result.rows[0] as SnapshotRow;
expect(snapshot.snapshot).toBeDefined();
expect(snapshot.snapshot.tables).toBeDefined();
expect(Object.keys(snapshot.snapshot.tables).length).toBeGreaterThan(0);
});
});
describe("Migration Status and Tracking", () => {
it("should provide accurate migration status", async () => {
const status = await migrator.getStatus("@elizaos/plugin-sql");
expect(status.hasRun).toBe(true);
expect(status.snapshots).toBeGreaterThan(0);
expect(status.lastMigration).toBeDefined();
});
it("should handle status check for non-existent plugin", async () => {
const status = await migrator.getStatus("non-existent-plugin");
expect(status.hasRun).toBe(false);
expect(status.snapshots).toBe(0);
expect(status.lastMigration).toBeNull();
});
});
});
@@ -0,0 +1,416 @@
/**
* Real-PGlite tests for `DatabaseIntrospector`: reading table/column/FK/
* unique/check-constraint/index metadata back from tables created with raw
* SQL (not Drizzle), across the public schema, custom schemas, and plugin
* schemas; plus `RuntimeMigrator` behavior when tables pre-exist without a
* stored snapshot (introspect-then-diff) versus when a snapshot already
* exists (diff against the snapshot, skip re-introspection).
*/
import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite/vector";
import { sql } from "drizzle-orm";
import { integer, pgSchema, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/pglite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DatabaseIntrospector } from "../../runtime-migrator/drizzle-adapters/database-introspector";
import { RuntimeMigrator } from "../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../runtime-migrator/types";
describe("Database Introspection Tests", () => {
let pgClient: PGlite;
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let introspector: DatabaseIntrospector;
beforeEach(async () => {
pgClient = new PGlite({ extensions: { vector } });
db = drizzle(pgClient);
migrator = new RuntimeMigrator(db);
introspector = new DatabaseIntrospector(db);
await migrator.initialize();
});
afterEach(async () => {
await pgClient.close();
});
describe("Basic Introspection", () => {
it("should introspect existing tables without snapshots", async () => {
// Tables created via raw SQL simulate pre-existing production tables
// that were never registered with the migrator.
await db.execute(sql`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
published BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE INDEX idx_posts_user_id ON posts(user_id)
`);
await db.execute(sql`
CREATE INDEX idx_posts_published ON posts(published) WHERE published = true
`);
const snapshot = await introspector.introspectSchema("public");
expect(Object.keys(snapshot.tables)).toHaveLength(2);
expect(snapshot.tables["public.users"]).toBeDefined();
expect(snapshot.tables["public.posts"]).toBeDefined();
const usersTable = snapshot.tables["public.users"];
expect(usersTable.name).toBe("users");
expect(usersTable.schema).toBe("public");
expect(Object.keys(usersTable.columns)).toHaveLength(5);
expect(usersTable.columns.id).toMatchObject({
name: "id",
type: "serial",
primaryKey: true,
notNull: true,
});
expect(usersTable.columns.email.notNull).toBe(true);
expect(usersTable.uniqueConstraints).toBeDefined();
const postsTable = snapshot.tables["public.posts"];
expect(postsTable.name).toBe("posts");
expect(Object.keys(postsTable.columns)).toHaveLength(6);
expect(postsTable.columns.user_id.notNull).toBe(true);
const foreignKeys = Object.values(postsTable.foreignKeys);
expect(foreignKeys).toHaveLength(1);
expect(foreignKeys[0]).toMatchObject({
tableFrom: "posts",
tableTo: "users",
columnsFrom: ["user_id"],
columnsTo: ["id"],
onDelete: "cascade",
});
expect(Object.keys(postsTable.indexes)).toHaveLength(2);
});
it("should handle introspection with custom schemas", async () => {
await db.execute(sql`CREATE SCHEMA plugin_test`);
await db.execute(sql`
CREATE TABLE plugin_test.settings (
id SERIAL PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE plugin_test.logs (
id SERIAL PRIMARY KEY,
level TEXT NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
const snapshot = await introspector.introspectSchema("plugin_test");
expect(Object.keys(snapshot.tables)).toHaveLength(2);
expect(snapshot.tables["plugin_test.settings"]).toBeDefined();
expect(snapshot.tables["plugin_test.logs"]).toBeDefined();
const settingsTable = snapshot.tables["plugin_test.settings"];
expect(settingsTable.schema).toBe("plugin_test");
expect(settingsTable.name).toBe("settings");
});
it("should handle check constraints during introspection", async () => {
await db.execute(sql`
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price DECIMAL(10,2) NOT NULL,
quantity INTEGER NOT NULL,
CONSTRAINT positive_price CHECK (price > 0),
CONSTRAINT positive_quantity CHECK (quantity >= 0)
)
`);
const snapshot = await introspector.introspectSchema("public");
const productsTable = snapshot.tables["public.products"];
expect(Object.keys(productsTable.checkConstraints)).toHaveLength(2);
expect(productsTable.checkConstraints.positive_price).toBeDefined();
expect(productsTable.checkConstraints.positive_quantity).toBeDefined();
});
it("should detect existing tables correctly", async () => {
let hasExisting = await introspector.hasExistingTables("@elizaos/plugin-sql");
expect(hasExisting).toBe(false);
await db.execute(sql`
CREATE TABLE test_table (
id SERIAL PRIMARY KEY,
name TEXT
)
`);
hasExisting = await introspector.hasExistingTables("@elizaos/plugin-sql");
expect(hasExisting).toBe(true);
});
});
describe("Migration with Pre-existing Tables", () => {
it("should handle migration when tables already exist without snapshots", async () => {
await db.execute(sql`
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
const accountsTable = pgTable("accounts", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
username: text("username").notNull().unique(),
email: text("email").notNull().unique(),
created_at: timestamp("created_at").defaultNow(),
updated_at: timestamp("updated_at").defaultNow(), // Not present in the DB table above.
});
const schema = { accounts: accountsTable };
// migrate() must introspect the pre-existing table first, since there's no snapshot.
await migrator.migrate("@elizaos/plugin-sql", schema, {
verbose: false,
force: true,
});
const result = await db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'accounts'
ORDER BY column_name
`);
const columnNames = result.rows.map((row) => row.column_name);
expect(columnNames).toContain("updated_at");
expect(columnNames).toHaveLength(5); // id, username, email, created_at, updated_at
});
it("should preserve data when introspecting and migrating", async () => {
await db.execute(sql`
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL
)
`);
await db.execute(sql`
INSERT INTO products (name, price) VALUES
('Product A', 100),
('Product B', 200),
('Product C', 300)
`);
const productsTable = pgTable("products", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
name: text("name").notNull(),
price: integer("price").notNull(),
description: text("description"), // New column
});
const schema = { products: productsTable };
await migrator.migrate("@elizaos/plugin-sql", schema, {
verbose: false,
force: true,
});
const result = await db.execute(sql`SELECT * FROM products ORDER BY id`);
expect(result.rows).toHaveLength(3);
expect(result.rows[0]).toMatchObject({ name: "Product A", price: 100 });
expect(result.rows[1]).toMatchObject({ name: "Product B", price: 200 });
expect(result.rows[2]).toMatchObject({ name: "Product C", price: 300 });
const columns = await db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'products'
`);
const columnNames = columns.rows.map((row) => row.column_name);
expect(columnNames).toContain("description");
});
it("should handle complex schema with foreign keys during introspection", async () => {
await db.execute(sql`
CREATE TABLE organizations (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
username TEXT NOT NULL,
UNIQUE(org_id, username)
)
`);
await db.execute(sql`
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL
)
`);
const snapshot = await introspector.introspectSchema("public");
expect(Object.keys(snapshot.tables)).toHaveLength(3);
const usersTable = snapshot.tables["public.users"];
const usersFKs = Object.values(usersTable.foreignKeys);
expect(usersFKs).toHaveLength(1);
expect(usersFKs[0]).toMatchObject({
tableTo: "organizations",
columnsFrom: ["org_id"],
columnsTo: ["id"],
onDelete: "cascade",
});
const projectsTable = snapshot.tables["public.projects"];
const projectsFKs = Object.values(projectsTable.foreignKeys);
expect(projectsFKs).toHaveLength(2);
interface UniqueConstraint {
columns: string[];
}
const uniqueConstraints = Object.values(usersTable.uniqueConstraints);
expect(uniqueConstraints).toHaveLength(1);
const firstConstraint = uniqueConstraints[0] as UniqueConstraint;
expect(firstConstraint.columns).toContain("org_id");
expect(firstConstraint.columns).toContain("username");
});
it("should handle plugin schemas correctly during introspection", async () => {
await db.execute(sql`CREATE SCHEMA test_plugin`);
await db.execute(sql`
CREATE TABLE test_plugin.config (
id SERIAL PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
value TEXT
)
`);
const hasExisting = await introspector.hasExistingTables("@elizaos/test-plugin");
expect(hasExisting).toBe(true);
const testSchema = pgSchema("test_plugin");
const configTable = testSchema.table("config", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
key: text("key").notNull().unique(),
value: text("value"),
created_at: timestamp("created_at").defaultNow(), // New column
});
const schema = { config: configTable };
await migrator.migrate("@elizaos/test-plugin", schema, {
verbose: false,
});
const result = await db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'test_plugin'
AND table_name = 'config'
ORDER BY column_name
`);
const columnNames = result.rows.map((row) => row.column_name);
// Migration adds the new created_at column on top of id, key, value.
expect(columnNames).toHaveLength(4);
expect(columnNames).toContain("created_at");
});
});
describe("Edge Cases", () => {
it("should handle empty database gracefully", async () => {
const snapshot = await introspector.introspectSchema("public");
expect(Object.keys(snapshot.tables)).toHaveLength(0);
expect(snapshot.version).toBe("7");
expect(snapshot.dialect).toBe("postgresql");
});
it("should handle non-existent schema gracefully", async () => {
const snapshot = await introspector.introspectSchema("non_existent_schema");
expect(Object.keys(snapshot.tables)).toHaveLength(0);
});
it("should not introspect when snapshot already exists", async () => {
const testTable = pgTable("test", {
id: integer("id").primaryKey(),
name: text("name"),
});
const schema = { test: testTable };
// This first migrate() call creates the snapshot the second call diffs against.
await migrator.migrate("@elizaos/plugin-sql", schema, { verbose: false });
// Table created outside the migrator, simulating an out-of-band manual DB change.
await db.execute(sql`
CREATE TABLE manual_table (
id SERIAL PRIMARY KEY,
data TEXT
)
`);
const updatedTestTable = pgTable("test", {
id: integer("id").primaryKey(),
name: text("name"),
description: text("description"), // New column
});
const updatedSchema = { test: updatedTestTable };
// Diffs against the stored snapshot rather than introspecting live tables.
await migrator.migrate("@elizaos/plugin-sql", updatedSchema, {
verbose: false,
});
// manual_table must survive untouched, since it was never part of any snapshot.
const result = await db.execute(sql`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name
`);
const tableNames = result.rows.map((row) => row.table_name);
expect(tableNames).toContain("manual_table");
expect(tableNames).toContain("test");
});
});
});
@@ -0,0 +1,199 @@
/**
* Real-PGlite migration tests for the production guard around message-search
* DDL. The guard is environment-driven because the migration service only sees
* a Drizzle handle, not the adapter/manager that selected Postgres vs PGlite.
*/
import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite/vector";
import {
type Agent,
ChannelType,
type Entity,
type Memory,
type Room,
type UUID,
type World,
} from "@elizaos/core";
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/pglite";
import { v4 } from "uuid";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { plugin as sqlPlugin } from "../../index";
import { DatabaseMigrationService } from "../../migration-service";
import type { DrizzleDatabase } from "../../types";
import { mockCharacter } from "../schema-data";
import { createIsolatedTestDatabaseForMigration } from "../test-helpers";
describe("message-search production DDL guard", () => {
let pgClient: PGlite;
let db: DrizzleDatabase;
let originalNodeEnv: string | undefined;
let originalApplyMessageSearchObjects: string | undefined;
beforeEach(async () => {
originalNodeEnv = process.env.NODE_ENV;
originalApplyMessageSearchObjects = process.env.ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS;
pgClient = new PGlite({ extensions: { vector } });
db = drizzle(pgClient);
});
afterEach(async () => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
if (originalApplyMessageSearchObjects === undefined) {
delete process.env.ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS;
} else {
process.env.ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS = originalApplyMessageSearchObjects;
}
await pgClient.close();
});
const runSqlPluginMigration = async (databaseBackend: "postgres" | "pglite", targetDb = db) => {
const migrationService = new DatabaseMigrationService({ databaseBackend });
await migrationService.initializeWithDatabase(targetDb);
migrationService.discoverAndRegisterPluginSchemas([sqlPlugin]);
await migrationService.runAllPluginMigrations();
};
const messageSearchColumnExists = async (targetDb = db): Promise<boolean> => {
const result = await targetDb.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'memories'
AND column_name = 'message_search_document'
`);
return result.rows.length > 0;
};
it("skips generated-column/index DDL by default for production Postgres startup", async () => {
process.env.NODE_ENV = "production";
delete process.env.ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS;
await runSqlPluginMigration("postgres");
expect(await messageSearchColumnExists()).toBe(false);
});
it("applies generated-column/index DDL in production Postgres only when explicitly enabled", async () => {
process.env.NODE_ENV = "production";
process.env.ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS = "true";
await runSqlPluginMigration("postgres");
expect(await messageSearchColumnExists()).toBe(true);
});
it("keeps automatic install enabled for embedded PGlite production builds", async () => {
process.env.NODE_ENV = "production";
delete process.env.ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS;
await runSqlPluginMigration("pglite");
expect(await messageSearchColumnExists()).toBe(true);
});
it("falls back at runtime when production Postgres startup skipped message-search DDL", async () => {
process.env.NODE_ENV = "production";
delete process.env.ELIZA_APPLY_MESSAGE_SEARCH_OBJECTS;
const setup = await createIsolatedTestDatabaseForMigration("message_search_runtime_fallback");
try {
await runSqlPluginMigration("postgres", setup.db);
expect(await messageSearchColumnExists(setup.db)).toBe(false);
const { adapter, testAgentId } = setup;
const now = Date.now();
expect(
await adapter.createAgent({
...mockCharacter,
id: testAgentId,
createdAt: now,
updatedAt: now,
} as Agent)
).toBe(true);
const worldId = v4() as UUID;
const entityId = v4() as UUID;
const roomId = v4() as UUID;
await adapter.createWorld({
id: worldId,
agentId: testAgentId,
name: "Runtime fallback world",
serverId: v4(),
} as World);
await adapter.createEntities([
{ id: entityId, agentId: testAgentId, names: ["Runtime User"] } as Entity,
]);
await adapter.createRooms([
{
id: roomId,
agentId: testAgentId,
worldId,
name: "Runtime fallback room",
source: "test",
type: ChannelType.DM,
} as Room,
]);
await adapter.addParticipant(entityId, roomId);
const seedMessage = async (
text: string,
createdAt: number,
attachments?: Array<{ title: string; url: string }>
) => {
await adapter.createMemory(
{
id: v4() as UUID,
entityId,
agentId: testAgentId,
roomId,
worldId,
content: attachments ? { text, attachments } : { text },
metadata: { type: "messages" },
createdAt,
} as Memory,
"messages"
);
};
await seedMessage("runtime fallback recalls alpha-sentinel text", now);
await seedMessage("attachment carrier for production fallback", now + 1_000, [
{
title: "incident-runbook-14230.pdf",
url: "https://cdn.example.test/incident-runbook-14230.pdf",
},
]);
const textHits = await adapter.searchMessages({
roomIds: [roomId],
query: "alpha-sentinel",
tableName: "messages",
limit: 10,
});
expect(textHits.map((hit) => (hit.memory.content as { text?: string }).text)).toContain(
"runtime fallback recalls alpha-sentinel text"
);
expect(textHits.every((hit) => hit.ftsRank === 0 && hit.trigramSimilarity === 0)).toBe(true);
const attachmentHits = await adapter.searchMessages({
roomIds: [roomId],
query: "incident-runbook-14230",
tableName: "messages",
limit: 10,
});
expect(attachmentHits.map((hit) => (hit.memory.content as { text?: string }).text)).toContain(
"attachment carrier for production fallback"
);
expect(attachmentHits.every((hit) => hit.ftsRank === 0 && hit.trigramSimilarity === 0)).toBe(
true
);
} finally {
await setup.cleanup();
}
});
});
@@ -0,0 +1,853 @@
/**
* Tests for migrations.ts - migrateToEntityRLS function
*
* These tests verify the migration from pre-1.6.5 (camelCase) to 1.6.5+ (snake_case)
* including RLS cleanup and column renames.
*
* Works with both PGLite (default) and PostgreSQL (when POSTGRES_URL is set).
*/
import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite/vector";
import type { IDatabaseAdapter } from "@elizaos/core";
import { sql } from "drizzle-orm";
import type { PgliteDatabase } from "drizzle-orm/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { migrateToEntityRLS } from "../../migrations";
// Helper types for database query results
interface ColumnInfoRow {
column_name: string;
data_type?: string;
[key: string]: unknown;
}
interface TableInfoRow {
table_name: string;
[key: string]: unknown;
}
// Mock adapter interface for migration testing
interface MockDatabaseAdapter extends Partial<IDatabaseAdapter> {
db: PgliteDatabase;
getDatabase: () => PgliteDatabase;
}
describe("migrateToEntityRLS (pre-1.6.5 migration)", () => {
let pgClient: PGlite;
let db: PgliteDatabase;
let mockAdapter: MockDatabaseAdapter;
beforeEach(async () => {
pgClient = new PGlite({ extensions: { vector } });
db = drizzle(pgClient);
// Create a mock adapter with the db property
mockAdapter = {
db,
getDatabase: () => db,
};
});
afterEach(async () => {
await pgClient.close();
});
describe("Flow 1: Fresh install (no tables)", () => {
it("should return early when rooms table does not exist", async () => {
// No tables created - should return early without error
await migrateToEntityRLS(mockAdapter);
// Verify no tables were created (migration returned early)
const tablesResult = await db.execute(sql`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
`);
// Only system tables should exist (if any)
const tableNames = tablesResult.rows.map((r: TableInfoRow) => r.table_name);
expect(tableNames).not.toContain("rooms");
expect(tableNames).not.toContain("memories");
});
});
describe("Flow 2: Migration from pre-1.6.5 (camelCase columns)", () => {
beforeEach(async () => {
// Create tables with camelCase columns (simulating pre-1.6.5)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"worldId" UUID,
"channelId" TEXT,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID NOT NULL,
"roomId" UUID,
"entityId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW(),
"worldId" UUID,
content JSONB NOT NULL,
type TEXT NOT NULL DEFAULT 'message'
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS worlds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"serverId" TEXT,
"createdAt" TIMESTAMP DEFAULT NOW()
)
`);
});
it("should rename camelCase columns to snake_case", async () => {
// Run migration
await migrateToEntityRLS(mockAdapter);
// Verify rooms columns are now snake_case
const roomsColumns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'rooms'
ORDER BY column_name
`);
const roomColumnNames = roomsColumns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(roomColumnNames).toContain("agent_id");
expect(roomColumnNames).toContain("world_id");
expect(roomColumnNames).toContain("channel_id");
expect(roomColumnNames).toContain("created_at");
expect(roomColumnNames).not.toContain("agentId");
expect(roomColumnNames).not.toContain("worldId");
expect(roomColumnNames).not.toContain("channelId");
expect(roomColumnNames).not.toContain("createdAt");
});
it("should rename memories camelCase columns to snake_case", async () => {
await migrateToEntityRLS(mockAdapter);
const memoriesColumns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'memories'
ORDER BY column_name
`);
const memoryColumnNames = memoriesColumns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(memoryColumnNames).toContain("agent_id");
expect(memoryColumnNames).toContain("room_id");
expect(memoryColumnNames).toContain("entity_id");
expect(memoryColumnNames).toContain("created_at");
expect(memoryColumnNames).toContain("world_id");
expect(memoryColumnNames).not.toContain("agentId");
expect(memoryColumnNames).not.toContain("roomId");
expect(memoryColumnNames).not.toContain("entityId");
});
it("should rename worlds.serverId to message_server_id", async () => {
await migrateToEntityRLS(mockAdapter);
const worldsColumns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'worlds'
ORDER BY column_name
`);
const worldColumnNames = worldsColumns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(worldColumnNames).toContain("message_server_id");
expect(worldColumnNames).not.toContain("serverId");
expect(worldColumnNames).not.toContain("server_id");
});
it("should preserve data during column renames", async () => {
// Insert test data before migration
const agentId = "123e4567-e89b-12d3-a456-426614174000";
const roomId = "223e4567-e89b-12d3-a456-426614174000";
await db.execute(sql`
INSERT INTO agents (id, name) VALUES (${agentId}::uuid, 'Test Agent')
`);
await db.execute(sql`
INSERT INTO rooms (id, "agentId", name, source, type)
VALUES (${roomId}::uuid, ${agentId}::uuid, 'Test Room', 'test', 'general')
`);
await db.execute(sql`
INSERT INTO memories ("agentId", "roomId", content, type)
VALUES (${agentId}::uuid, ${roomId}::uuid, '{"text": "Hello"}'::jsonb, 'message')
`);
// Run migration
await migrateToEntityRLS(mockAdapter);
// Verify data is preserved
const agents = await db.execute(sql`SELECT * FROM agents WHERE id = ${agentId}::uuid`);
expect(agents.rows[0].name).toBe("Test Agent");
const rooms = await db.execute(sql`SELECT * FROM rooms WHERE id = ${roomId}::uuid`);
expect(rooms.rows[0].name).toBe("Test Room");
expect(rooms.rows[0].agent_id).toBe(agentId);
const memories = await db.execute(
sql`SELECT * FROM memories WHERE agent_id = ${agentId}::uuid`
);
expect(memories.rows).toHaveLength(1);
expect(memories.rows[0].room_id).toBe(roomId);
});
});
describe("Flow 3: Already migrated (snake_case columns exist)", () => {
beforeEach(async () => {
// Create tables with snake_case columns (already migrated)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID,
world_id UUID,
channel_id TEXT,
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
// Insert test data
await db.execute(sql`
INSERT INTO agents (id, name) VALUES ('123e4567-e89b-12d3-a456-426614174000'::uuid, 'Test Agent')
`);
await db.execute(sql`
INSERT INTO rooms (id, agent_id, name, source, type)
VALUES ('223e4567-e89b-12d3-a456-426614174000'::uuid, '123e4567-e89b-12d3-a456-426614174000'::uuid, 'Test Room', 'test', 'general')
`);
});
it("should skip schema migration but still run RLS cleanup", async () => {
// This test verifies RLS cleanup when data isolation is DISABLED
// Save and unset ENABLE_DATA_ISOLATION to test cleanup behavior
const savedEnableDataIsolation = process.env.ENABLE_DATA_ISOLATION;
delete process.env.ENABLE_DATA_ISOLATION;
try {
// Enable RLS on rooms table to test cleanup
await db.execute(sql`ALTER TABLE rooms ENABLE ROW LEVEL SECURITY`);
// Verify RLS is enabled
const beforeRls = await db.execute(sql`
SELECT relrowsecurity FROM pg_class WHERE relname = 'rooms'
`);
expect(beforeRls.rows[0].relrowsecurity).toBe(true);
// Run migration (should skip schema changes but do RLS cleanup)
await migrateToEntityRLS(mockAdapter);
// Verify RLS is disabled (cleanup happened)
const afterRls = await db.execute(sql`
SELECT relrowsecurity FROM pg_class WHERE relname = 'rooms'
`);
expect(afterRls.rows[0].relrowsecurity).toBe(false);
// Verify data is still intact
const rooms = await db.execute(sql`SELECT * FROM rooms`);
expect(rooms.rows).toHaveLength(1);
expect(rooms.rows[0].name).toBe("Test Room");
} finally {
// Restore environment
if (savedEnableDataIsolation !== undefined) {
process.env.ENABLE_DATA_ISOLATION = savedEnableDataIsolation;
}
}
});
it("should not modify existing snake_case columns", async () => {
await migrateToEntityRLS(mockAdapter);
const roomsColumns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'rooms'
ORDER BY column_name
`);
const roomColumnNames = roomsColumns.rows.map((r: ColumnInfoRow) => r.column_name);
// Should still have snake_case columns
expect(roomColumnNames).toContain("agent_id");
expect(roomColumnNames).toContain("world_id");
expect(roomColumnNames).toContain("channel_id");
expect(roomColumnNames).toContain("created_at");
});
it("should NOT disable RLS when ENABLE_DATA_ISOLATION=true (avoid wasteful cycle)", async () => {
// Enable RLS on rooms table
await db.execute(sql`ALTER TABLE rooms ENABLE ROW LEVEL SECURITY`);
// Verify RLS is enabled
const beforeRls = await db.execute(sql`
SELECT relrowsecurity FROM pg_class WHERE relname = 'rooms'
`);
expect(beforeRls.rows[0].relrowsecurity).toBe(true);
// Set ENABLE_DATA_ISOLATION=true
const originalEnv = process.env.ENABLE_DATA_ISOLATION;
process.env.ENABLE_DATA_ISOLATION = "true";
try {
// Run migration (should NOT disable RLS because ENABLE_DATA_ISOLATION=true)
await migrateToEntityRLS(mockAdapter);
// Verify RLS is STILL enabled (no wasteful disable/re-enable cycle)
const afterRls = await db.execute(sql`
SELECT relrowsecurity FROM pg_class WHERE relname = 'rooms'
`);
expect(afterRls.rows[0].relrowsecurity).toBe(true);
// Verify data is still intact
const rooms = await db.execute(sql`SELECT * FROM rooms`);
expect(rooms.rows).toHaveLength(1);
expect(rooms.rows[0].name).toBe("Test Room");
} finally {
// Restore original env
if (originalEnv === undefined) {
delete process.env.ENABLE_DATA_ISOLATION;
} else {
process.env.ENABLE_DATA_ISOLATION = originalEnv;
}
}
});
});
describe("Flow 4: serverId to message_server_id migration", () => {
it("should convert TEXT serverId to UUID message_server_id with valid UUID", async () => {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
"serverId" TEXT,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
// Insert with valid UUID as text
const serverId = "323e4567-e89b-12d3-a456-426614174000";
await db.execute(sql`
INSERT INTO rooms (id, "serverId", name, source, type)
VALUES ('223e4567-e89b-12d3-a456-426614174000'::uuid, ${serverId}, 'Test Room', 'test', 'general')
`);
await migrateToEntityRLS(mockAdapter);
// Verify column was renamed and converted to UUID
const roomsColumns = await db.execute(sql`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'rooms' AND column_name = 'message_server_id'
`);
expect(roomsColumns.rows).toHaveLength(1);
expect(roomsColumns.rows[0].data_type).toBe("uuid");
// Verify data was preserved
const rooms = await db.execute(sql`SELECT message_server_id FROM rooms`);
expect(rooms.rows[0].message_server_id).toBe(serverId);
});
it("should convert non-UUID TEXT serverId to md5 UUID", async () => {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
"serverId" TEXT,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
// Insert with non-UUID text
await db.execute(sql`
INSERT INTO rooms (id, "serverId", name, source, type)
VALUES ('223e4567-e89b-12d3-a456-426614174000'::uuid, 'my-server-name', 'Test Room', 'test', 'general')
`);
await migrateToEntityRLS(mockAdapter);
// Verify data was converted (md5 hash of 'my-server-name')
const rooms = await db.execute(sql`SELECT message_server_id FROM rooms`);
expect(rooms.rows[0].message_server_id).toBeDefined();
// The value should be a valid UUID (md5 hash)
expect(rooms.rows[0].message_server_id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
});
it("should handle NULL serverId values", async () => {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
"serverId" TEXT,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
// Insert with NULL serverId
await db.execute(sql`
INSERT INTO rooms (id, "serverId", name, source, type)
VALUES ('223e4567-e89b-12d3-a456-426614174000'::uuid, NULL, 'Test Room', 'test', 'general')
`);
await migrateToEntityRLS(mockAdapter);
// Verify NULL was preserved
const rooms = await db.execute(sql`SELECT message_server_id FROM rooms`);
expect(rooms.rows[0].message_server_id).toBeNull();
});
});
describe("Flow 5: owner_id to server_id migration (agents table)", () => {
it("should rename agents.owner_id to server_id", async () => {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
owner_id UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
// Also need rooms table for migration check
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
const ownerId = "423e4567-e89b-12d3-a456-426614174000";
await db.execute(sql`
INSERT INTO agents (id, name, owner_id)
VALUES ('123e4567-e89b-12d3-a456-426614174000'::uuid, 'Test Agent', ${ownerId}::uuid)
`);
await migrateToEntityRLS(mockAdapter);
// Verify column was renamed
const agentsColumns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'agents'
ORDER BY column_name
`);
const agentColumnNames = agentsColumns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(agentColumnNames).toContain("server_id");
expect(agentColumnNames).not.toContain("owner_id");
// Verify data was preserved
const agents = await db.execute(sql`SELECT server_id FROM agents`);
expect(agents.rows[0].server_id).toBe(ownerId);
});
});
describe("Flow 6: Other plugin tables in public schema", () => {
beforeEach(async () => {
// Create plugin-sql required tables with camelCase columns (pre-1.6.5)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
// Create a table from another plugin (e.g., plugin-x)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS x_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id TEXT NOT NULL,
author_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
// Create another plugin table with server_id column (potential conflict!)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS custom_plugin_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID,
data JSONB,
created_at TIMESTAMP DEFAULT NOW()
)
`);
});
it("should DROP server_id from unknown plugin tables (current behavior - potential issue)", async () => {
// Insert test data in custom_plugin_data
const serverId = "523e4567-e89b-12d3-a456-426614174000";
await db.execute(sql`
INSERT INTO custom_plugin_data (id, server_id, data)
VALUES ('623e4567-e89b-12d3-a456-426614174000'::uuid, ${serverId}::uuid, '{"key": "value"}'::jsonb)
`);
// Run migration
await migrateToEntityRLS(mockAdapter);
// Check if server_id column was preserved or deleted
const columns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'custom_plugin_data'
ORDER BY column_name
`);
const columnNames = columns.rows.map((r: ColumnInfoRow) => r.column_name);
// CURRENT BEHAVIOR: migrations.ts DROPS server_id from tables not in exclusion list
// This is DOCUMENTED BEHAVIOR - other plugins should NOT use server_id column name
expect(columnNames).not.toContain("server_id"); // server_id is DROPPED
});
it("should preserve other plugin tables with their data intact", async () => {
// Insert test data
await db.execute(sql`
INSERT INTO x_posts (id, post_id, author_id, content)
VALUES ('723e4567-e89b-12d3-a456-426614174000'::uuid, '12345', 'user123', 'Hello X!')
`);
// Run migration
await migrateToEntityRLS(mockAdapter);
// Verify x_posts table and data are intact
const posts = await db.execute(sql`SELECT * FROM x_posts`);
expect(posts.rows).toHaveLength(1);
expect(posts.rows[0].content).toBe("Hello X!");
expect(posts.rows[0].post_id).toBe("12345");
});
it("should disable RLS on other plugin tables during migration", async () => {
// Enable RLS on x_posts
await db.execute(sql`ALTER TABLE x_posts ENABLE ROW LEVEL SECURITY`);
// Verify RLS is enabled
const beforeRls = await db.execute(sql`
SELECT relrowsecurity FROM pg_class WHERE relname = 'x_posts'
`);
expect(beforeRls.rows[0].relrowsecurity).toBe(true);
// Run migration
await migrateToEntityRLS(mockAdapter);
// RLS should be disabled on ALL tables including other plugins
const afterRls = await db.execute(sql`
SELECT relrowsecurity FROM pg_class WHERE relname = 'x_posts'
`);
expect(afterRls.rows[0].relrowsecurity).toBe(false);
});
it("should NOT rename columns in other plugin tables", async () => {
// Insert test data
await db.execute(sql`
INSERT INTO x_posts (id, post_id, author_id, content)
VALUES ('723e4567-e89b-12d3-a456-426614174000'::uuid, '12345', 'user123', 'Hello!')
`);
// Run migration
await migrateToEntityRLS(mockAdapter);
// Verify columns are unchanged
const columns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'x_posts'
ORDER BY column_name
`);
const columnNames = columns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(columnNames).toContain("post_id");
expect(columnNames).toContain("author_id");
expect(columnNames).toContain("created_at");
});
});
describe("Flow 7: Tables in non-public schemas", () => {
beforeEach(async () => {
// Create plugin-sql required tables
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
// Create a separate schema
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS other_schema`);
// Create table in other_schema with server_id and camelCase columns
await db.execute(sql`
CREATE TABLE IF NOT EXISTS other_schema.custom_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID,
"agentId" UUID,
data JSONB,
created_at TIMESTAMP DEFAULT NOW()
)
`);
});
it("should NOT touch tables in non-public schemas", async () => {
// Insert test data
const serverId = "823e4567-e89b-12d3-a456-426614174000";
const agentId = "923e4567-e89b-12d3-a456-426614174000";
await db.execute(sql`
INSERT INTO other_schema.custom_table (id, server_id, "agentId", data)
VALUES ('a23e4567-e89b-12d3-a456-426614174000'::uuid, ${serverId}::uuid, ${agentId}::uuid, '{"key": "value"}'::jsonb)
`);
// Run migration
await migrateToEntityRLS(mockAdapter);
// Verify table in other_schema is completely untouched
const columns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'other_schema' AND table_name = 'custom_table'
ORDER BY column_name
`);
const columnNames = columns.rows.map((r: ColumnInfoRow) => r.column_name);
// server_id should still exist (not dropped)
expect(columnNames).toContain("server_id");
// agentId should NOT be renamed to agent_id
expect(columnNames).toContain("agentId");
expect(columnNames).not.toContain("agent_id");
// Verify data is intact
const data = await db.execute(sql`SELECT * FROM other_schema.custom_table`);
expect(data.rows).toHaveLength(1);
expect(data.rows[0].server_id).toBe(serverId);
});
it("should NOT disable RLS on tables in non-public schemas", async () => {
// Enable RLS on other_schema.custom_table
await db.execute(sql`ALTER TABLE other_schema.custom_table ENABLE ROW LEVEL SECURITY`);
// Verify RLS is enabled
const beforeRls = await db.execute(sql`
SELECT relrowsecurity FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'custom_table' AND n.nspname = 'other_schema'
`);
expect(beforeRls.rows[0].relrowsecurity).toBe(true);
// Run migration
await migrateToEntityRLS(mockAdapter);
// RLS should still be enabled (migration doesn't touch other schemas)
const afterRls = await db.execute(sql`
SELECT relrowsecurity FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'custom_table' AND n.nspname = 'other_schema'
`);
expect(afterRls.rows[0].relrowsecurity).toBe(true);
});
});
describe("Flow 8: Indexes from other plugins", () => {
beforeEach(async () => {
// Create plugin-sql required tables
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
// Create another plugin table with custom indexes
await db.execute(sql`
CREATE TABLE IF NOT EXISTS analytics_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
user_id UUID NOT NULL,
timestamp TIMESTAMP DEFAULT NOW()
)
`);
// Create custom indexes on the analytics table
await db.execute(sql`CREATE INDEX idx_analytics_event_type ON analytics_events(event_type)`);
await db.execute(sql`CREATE INDEX idx_analytics_user_id ON analytics_events(user_id)`);
});
it("should drop ALL indexes in public schema including other plugins (current behavior)", async () => {
// Insert test data
await db.execute(sql`
INSERT INTO analytics_events (id, event_type, user_id)
VALUES ('b23e4567-e89b-12d3-a456-426614174000'::uuid, 'page_view', 'c23e4567-e89b-12d3-a456-426614174000'::uuid)
`);
// Verify indexes exist
const beforeIndexes = await db.execute(sql`
SELECT indexname FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'analytics_events'
AND indexname NOT LIKE '%_pkey'
`);
expect(beforeIndexes.rows.length).toBeGreaterThanOrEqual(2);
// Run migration
await migrateToEntityRLS(mockAdapter);
// CURRENT BEHAVIOR: ALL regular indexes are dropped (including from other plugins)
// This is DOCUMENTED BEHAVIOR - indexes are recreated by RuntimeMigrator
const afterIndexes = await db.execute(sql`
SELECT indexname FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'analytics_events'
AND indexname NOT LIKE '%_pkey'
`);
expect(afterIndexes.rows.length).toBe(0); // All indexes dropped
// Verify data is still intact though
const events = await db.execute(sql`SELECT * FROM analytics_events`);
expect(events.rows).toHaveLength(1);
});
});
describe("Idempotency", () => {
it("should be safe to run multiple times", async () => {
// Create tables with camelCase
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general'
)
`);
await db.execute(sql`
INSERT INTO rooms (id, name, source, type)
VALUES ('223e4567-e89b-12d3-a456-426614174000'::uuid, 'Test Room', 'test', 'general')
`);
// Run migration multiple times
await migrateToEntityRLS(mockAdapter);
await migrateToEntityRLS(mockAdapter);
await migrateToEntityRLS(mockAdapter);
// Verify data is still intact
const rooms = await db.execute(sql`SELECT * FROM rooms`);
expect(rooms.rows).toHaveLength(1);
expect(rooms.rows[0].name).toBe("Test Room");
// Verify columns are correct
const roomsColumns = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'rooms'
ORDER BY column_name
`);
const roomColumnNames = roomsColumns.rows.map((r: ColumnInfoRow) => r.column_name);
expect(roomColumnNames).toContain("agent_id");
expect(roomColumnNames).toContain("created_at");
});
});
});
@@ -0,0 +1,562 @@
/**
* Real-PGlite tests simulating production migration scenarios: a database
* that already has tables from earlier deployments (including the
* camelCase-vs-snake_case rename case the runtime migrator must block as
* destructive rather than silently drop/re-add), multiple plugins with
* pre-existing schemas, recovery from a partially-completed migration,
* idempotent repeated migration runs, and introspection at 20-table scale.
*/
import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite/vector";
import { sql } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
numeric,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/pglite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DatabaseIntrospector } from "../../runtime-migrator/drizzle-adapters/database-introspector";
import { RuntimeMigrator } from "../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../runtime-migrator/types";
import * as coreSchema from "../../schema";
describe("Production Migration Scenarios", () => {
let pgClient: PGlite;
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let introspector: DatabaseIntrospector;
beforeEach(async () => {
pgClient = new PGlite({ extensions: { vector } });
db = drizzle(pgClient);
migrator = new RuntimeMigrator(db);
introspector = new DatabaseIntrospector(db);
await migrator.initialize();
});
afterEach(async () => {
await pgClient.close();
});
describe("Scenario 1: Existing elizaOS Core Tables", () => {
it("should block destructive migrations when camelCase columns need renaming to snake_case", async () => {
// Exactly one of the two outcome branches below runs, and each contributes one
// assertion. Combined with the two data-preservation checks at the end, every
// correct run makes exactly 3 assertions; expect.assertions(3) makes a silently
// skipped branch (e.g. the migration unexpectedly renaming columns) fail loudly.
expect.assertions(3);
// Simulates a production DB with the old camelCase columns. In production,
// migrations.ts performs RENAME operations before RuntimeMigrator runs;
// RuntimeMigrator itself can't detect a rename — it sees old-name-gone +
// new-name-present as DROP + ADD, i.e. destructive — so it must block.
await db.execute(sql`
CREATE TABLE IF NOT EXISTS memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID NOT NULL,
"roomId" UUID,
"entityId" UUID,
content JSONB NOT NULL,
"createdAt" TIMESTAMP DEFAULT NOW(),
type TEXT NOT NULL DEFAULT 'message',
"unique" BOOLEAN DEFAULT TRUE,
metadata JSONB DEFAULT '{}',
"worldId" UUID
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
enabled BOOLEAN DEFAULT TRUE,
username TEXT,
system TEXT DEFAULT '',
bio JSONB DEFAULT '[]'::jsonb,
message_examples JSONB DEFAULT '[]'::jsonb,
post_examples JSONB DEFAULT '[]'::jsonb,
topics JSONB DEFAULT '[]'::jsonb,
adjectives JSONB DEFAULT '[]'::jsonb,
knowledge JSONB DEFAULT '[]'::jsonb,
plugins JSONB DEFAULT '[]'::jsonb,
style JSONB DEFAULT '{}'::jsonb
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"agentId" UUID,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general',
"createdAt" TIMESTAMP DEFAULT NOW() NOT NULL,
"worldId" UUID,
message_server_id UUID,
metadata JSONB,
"channelId" TEXT
)
`);
const agentId = "123e4567-e89b-12d3-a456-426614174000";
const roomId = "223e4567-e89b-12d3-a456-426614174000";
await db.execute(sql`
INSERT INTO agents (id, name, settings)
VALUES (${agentId}::uuid, 'Production Agent', '{"model": "gpt-5"}'::jsonb)
`);
await db.execute(sql`
INSERT INTO rooms (id, "agentId", name, source, type)
VALUES (${roomId}::uuid, ${agentId}::uuid, 'Main Room', 'test', 'general')
`);
await db.execute(sql`
INSERT INTO memories ("agentId", "roomId", "entityId", content, type)
VALUES
(${agentId}::uuid, ${roomId}::uuid, null, '{"text": "System initialized"}'::jsonb, 'message')
`);
try {
await migrator.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: false,
force: false,
});
const memoryColumns = await db.execute(sql`
SELECT column_name FROM information_schema.columns WHERE table_name = 'memories'
`);
const columnNames = memoryColumns.rows.map((row) => row.column_name);
// The only correct non-throw outcome is that the destructive rename was
// skipped, so the original camelCase "agentId" column must still be present.
expect(columnNames).toContain("agentId");
} catch (error) {
expect((error as Error).message).toContain("Destructive migration blocked");
}
const agents = await db.execute(sql`SELECT * FROM agents WHERE id = ${agentId}::uuid`);
expect(agents.rows[0]).toBeDefined();
expect(agents.rows[0].name).toBe("Production Agent");
});
it("should work with tables that already have snake_case columns (post-migration)", async () => {
// Simulates a DB where migrations.ts already ran and renamed columns to
// snake_case, so RuntimeMigrator should see the schema already matching.
await db.execute(sql`
CREATE TABLE IF NOT EXISTS memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
room_id UUID,
entity_id UUID,
content JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
type TEXT NOT NULL DEFAULT 'message',
"unique" BOOLEAN DEFAULT TRUE,
metadata JSONB DEFAULT '{}',
world_id UUID
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
enabled BOOLEAN DEFAULT TRUE,
username TEXT,
system TEXT DEFAULT '',
bio JSONB DEFAULT '[]'::jsonb,
message_examples JSONB DEFAULT '[]'::jsonb,
post_examples JSONB DEFAULT '[]'::jsonb,
topics JSONB DEFAULT '[]'::jsonb,
adjectives JSONB DEFAULT '[]'::jsonb,
knowledge JSONB DEFAULT '[]'::jsonb,
plugins JSONB DEFAULT '[]'::jsonb,
style JSONB DEFAULT '{}'::jsonb
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID,
name TEXT,
source TEXT NOT NULL DEFAULT 'unknown',
type TEXT NOT NULL DEFAULT 'general',
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
world_id UUID,
message_server_id UUID,
metadata JSONB,
channel_id TEXT
)
`);
const agentId = "123e4567-e89b-12d3-a456-426614174000";
const roomId = "223e4567-e89b-12d3-a456-426614174000";
await db.execute(sql`
INSERT INTO agents (id, name, settings)
VALUES (${agentId}::uuid, 'Production Agent', '{"model": "gpt-5"}'::jsonb)
`);
await db.execute(sql`
INSERT INTO rooms (id, agent_id, name, source, type)
VALUES (${roomId}::uuid, ${agentId}::uuid, 'Main Room', 'test', 'general')
`);
await db.execute(sql`
INSERT INTO memories (agent_id, room_id, entity_id, content, type)
VALUES
(${agentId}::uuid, ${roomId}::uuid, null, '{"text": "System initialized"}'::jsonb, 'message'),
(${agentId}::uuid, ${roomId}::uuid, null, '{"text": "User preferences loaded"}'::jsonb, 'message'),
(${agentId}::uuid, ${roomId}::uuid, null, '{"text": "Context established"}'::jsonb, 'message')
`);
await migrator.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: false,
});
const agents = await db.execute(sql`SELECT * FROM agents WHERE id = ${agentId}::uuid`);
expect(agents.rows[0]).toBeDefined();
expect(agents.rows[0].name).toBe("Production Agent");
const memories = await db.execute(sql`SELECT COUNT(*) as count FROM memories`);
expect(Number(memories.rows[0].count)).toBe(3);
const memoryColumns = await db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'memories'
ORDER BY column_name
`);
const columnNames = memoryColumns.rows.map((row) => row.column_name);
expect(columnNames).toContain("agent_id");
expect(columnNames).toContain("room_id");
expect(columnNames).toContain("content");
expect(columnNames).toContain("type");
});
it("should handle version mismatch between DB and code schema", async () => {
await db.execute(sql`
CREATE TABLE participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
room_id UUID NOT NULL,
user_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
const agentId = "323e4567-e89b-12d3-a456-426614174000";
const roomId = "423e4567-e89b-12d3-a456-426614174000";
await db.execute(sql`
INSERT INTO participants (agent_id, room_id, user_name)
VALUES
(${agentId}::uuid, ${roomId}::uuid, 'Alice'),
(${agentId}::uuid, ${roomId}::uuid, 'Bob')
`);
const participantTable = pgTable("participants", {
id: uuid("id").primaryKey().defaultRandom(),
agent_id: uuid("agent_id").notNull(),
room_id: uuid("room_id").notNull(),
user_name: text("user_name").notNull(),
created_at: timestamp("created_at").defaultNow(),
// New fields not in the old schema
updated_at: timestamp("updated_at").defaultNow(),
is_active: boolean("is_active").default(true),
metadata: jsonb("metadata"),
});
const schema = { participants: participantTable };
await migrator.migrate("@elizaos/plugin-sql", schema, { verbose: false });
const result = await db.execute(sql`SELECT * FROM participants ORDER BY user_name`);
expect(result.rows).toHaveLength(2);
expect(result.rows[0].user_name).toBe("Alice");
expect(result.rows[1].user_name).toBe("Bob");
const columns = await db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'participants'
ORDER BY column_name
`);
const columnNames = columns.rows.map((row) => row.column_name);
expect(columnNames).toContain("updated_at");
expect(columnNames).toContain("is_active");
expect(columnNames).toContain("metadata");
});
});
describe("Scenario 2: Multiple Plugin Tables in Production", () => {
it("should handle multiple plugins with existing tables", async () => {
// Analytics plugin with its own schema, already deployed.
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS analytics`);
await db.execute(sql`
CREATE TABLE analytics.events (
id SERIAL PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
agent_id UUID NOT NULL,
payload JSONB,
created_at TIMESTAMP DEFAULT NOW()
)
`);
await db.execute(sql`
CREATE TABLE analytics.metrics (
id SERIAL PRIMARY KEY,
metric_name VARCHAR(100) NOT NULL,
value NUMERIC NOT NULL,
tags JSONB,
created_at TIMESTAMP DEFAULT NOW()
)
`);
await db.execute(sql`
INSERT INTO analytics.events (event_type, agent_id, payload)
VALUES
('agent_started', '523e4567-e89b-12d3-a456-426614174000'::uuid, '{"version": "1.0.0"}'::jsonb),
('message_processed', '523e4567-e89b-12d3-a456-426614174000'::uuid, '{"tokens": 150}'::jsonb)
`);
await db.execute(sql`
INSERT INTO analytics.metrics (metric_name, value, tags)
VALUES
('response_time_ms', 250.5, '{"endpoint": "chat"}'::jsonb),
('memory_usage_mb', 512.0, '{"process": "agent"}'::jsonb)
`);
const hasExisting = await introspector.hasExistingTables("@elizaos/analytics");
expect(hasExisting).toBe(true);
const snapshot = await introspector.introspectSchema("analytics");
expect(Object.keys(snapshot.tables)).toHaveLength(2);
expect(snapshot.tables["analytics.events"]).toBeDefined();
expect(snapshot.tables["analytics.metrics"]).toBeDefined();
const events = await db.execute(sql`
SELECT COUNT(*) as count FROM analytics.events
`);
expect(Number(events.rows[0].count)).toBe(2);
const metrics = await db.execute(sql`
SELECT COUNT(*) as count FROM analytics.metrics
`);
expect(Number(metrics.rows[0].count)).toBe(2);
});
it("should handle schema conflicts gracefully", async () => {
await db.execute(sql`
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
description TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE
)
`);
// The plugin expects a different structure
const tasksTable = pgTable("tasks", {
id: uuid("id").primaryKey().defaultRandom(), // Different type!
description: text("description").notNull(),
completed: boolean("completed").default(false),
agent_id: uuid("agent_id").notNull(), // New required field
created_at: timestamp("created_at").defaultNow(),
});
const schema = { tasks: tasksTable };
try {
await migrator.migrate("@elizaos/plugin-sql", schema, {
verbose: false,
force: false, // Don't allow destructive changes
});
const columns = await db.execute(sql`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'tasks'
ORDER BY column_name
`);
expect(columns.rows).toBeDefined();
} catch (error) {
expect((error as Error).message).toContain("Destructive migration blocked");
}
});
});
describe("Scenario 3: Recovery from Failed Migrations", () => {
it("should handle partial migration state gracefully", async () => {
// Only `users` exists here; `profiles` and `sessions` below simulate
// tables a prior, incomplete migration run never got to create.
await db.execute(sql`
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
`);
const usersTable = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
created_at: timestamp("created_at").defaultNow(), // New field
});
const profilesTable = pgTable("profiles", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: uuid("user_id")
.notNull()
.references(() => usersTable.id),
bio: text("bio"),
avatar_url: text("avatar_url"),
});
const sessionsTable = pgTable("sessions", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: uuid("user_id")
.notNull()
.references(() => usersTable.id),
token: text("token").notNull().unique(),
expires_at: timestamp("expires_at").notNull(),
});
const schema = {
users: usersTable,
profiles: profilesTable,
sessions: sessionsTable,
};
await migrator.migrate("@elizaos/plugin-sql", schema, { verbose: false });
const tables = await db.execute(sql`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`);
const tableNames = tables.rows.map((row) => row.table_name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("profiles");
expect(tableNames).toContain("sessions");
const userColumns = await db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'users'
ORDER BY column_name
`);
const columnNames = userColumns.rows.map((row) => row.column_name);
expect(columnNames).toContain("created_at");
});
it("should be idempotent when run multiple times", async () => {
await db.execute(sql`
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL
)
`);
await db.execute(sql`
INSERT INTO products (name, price) VALUES
('Product A', 99.99),
('Product B', 149.99)
`);
const productsTable = pgTable("products", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
name: text("name").notNull(),
price: numeric("price", { precision: 10, scale: 2 }).notNull(),
category: text("category").default("general"), // New field
});
const schema = { products: productsTable };
await migrator.migrate("@elizaos/plugin-sql", schema, { verbose: false });
await migrator.migrate("@elizaos/plugin-sql", schema, { verbose: false });
await migrator.migrate("@elizaos/plugin-sql", schema, { verbose: false });
const products = await db.execute(sql`
SELECT * FROM products ORDER BY id
`);
expect(products.rows).toHaveLength(2);
expect(products.rows[0].name).toBe("Product A");
expect(products.rows[1].name).toBe("Product B");
const columns = await db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'products'
ORDER BY column_name
`);
const columnNames = columns.rows.map((row) => row.column_name);
expect(columnNames).toHaveLength(4); // id, name, price, category
expect(columnNames).toContain("category");
});
});
describe("Scenario 4: Large Production Database", () => {
it("should handle introspection of many tables efficiently", async () => {
const tableCount = 20;
for (let i = 0; i < tableCount; i++) {
await db.execute(
sql.raw(`
CREATE TABLE table_${i} (
id SERIAL PRIMARY KEY,
data TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`)
);
await db.execute(
sql.raw(`
INSERT INTO table_${i} (data) VALUES ('Data for table ${i}')
`)
);
}
for (let i = 0; i < 5; i++) {
await db.execute(
sql.raw(`
CREATE INDEX idx_table_${i}_created_at ON table_${i}(created_at)
`)
);
}
const snapshot = await introspector.introspectSchema("public");
expect(Object.keys(snapshot.tables)).toHaveLength(tableCount);
for (let i = 0; i < tableCount; i++) {
const result = await db.execute(sql.raw(`SELECT COUNT(*) as count FROM table_${i}`));
expect(Number(result.rows[0].count)).toBe(1);
}
});
});
});
@@ -0,0 +1,669 @@
/**
* End-to-end `RuntimeMigrator` tests against a real isolated Postgres
* database, covering: migration-infrastructure init, running the core
* plugin-sql schema and tracking it in `_migrations`/`_journal`/`_snapshots`,
* column type/FK/unique/check-constraint/index creation, idempotent re-runs,
* dry-run and reset, error handling for an invalid schema, and — critically —
* that migrating plugin-sql never drops or alters tables/columns belonging
* to other plugins that happen to share the public schema. Accumulates a
* pass/fail summary logged in `afterAll` alongside the vitest assertions.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
interface ExistsRow {
exists: boolean;
}
interface CountRow {
count: string | number;
}
interface MigrationRow {
plugin_name: string;
idx: number;
hash: string;
[key: string]: unknown;
}
interface JournalRow {
plugin_name: string;
entries: unknown;
[key: string]: unknown;
}
interface SnapshotRow {
plugin_name: string;
idx: number;
snapshot: unknown;
[key: string]: unknown;
}
interface ColumnRow {
data_type: string;
[key: string]: unknown;
}
interface TableInfoRow {
tablename: string;
[key: string]: unknown;
}
interface ConstraintRow {
table_name: string;
constraint_name: string;
[key: string]: unknown;
}
import { sql } from "drizzle-orm";
import { RuntimeMigrator } from "../../runtime-migrator";
import type { DrizzleDB } from "../../runtime-migrator/types";
import * as schema from "../../schema";
import { createIsolatedTestDatabaseForMigration } from "../test-helpers";
describe("Runtime Migrator - PostgreSQL Integration Tests", () => {
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
const testResults: { passed: string[]; failed: string[] } = {
passed: [],
failed: [],
};
beforeAll(async () => {
console.log("\n🚀 Starting Runtime Migrator Tests...\n");
const testSetup = await createIsolatedTestDatabaseForMigration("runtime_migrator_tests");
db = testSetup.db;
cleanup = testSetup.cleanup;
migrator = new RuntimeMigrator(db);
console.log("🗑️ Test environment ready...");
try {
// Guards against a leftover `migrations` schema from a previous failed run.
await db.execute(sql`DROP SCHEMA IF EXISTS migrations CASCADE`);
console.log("✅ Test environment cleaned\n");
} catch (error) {
console.log("⚠️ Cleanup warning:", error);
}
});
afterAll(async () => {
console.log(`\n${"=".repeat(80)}`);
console.log("📊 RUNTIME MIGRATOR TEST SUMMARY");
console.log(`${"=".repeat(80)}\n`);
console.log(`✅ PASSED (${testResults.passed.length} tests):`);
testResults.passed.forEach((test, i) => {
console.log(` ${i + 1}. ${test}`);
});
if (testResults.failed.length > 0) {
console.log(`\n❌ FAILED (${testResults.failed.length} tests):`);
testResults.failed.forEach((test, i) => {
console.log(` ${i + 1}. ${test}`);
});
}
console.log(`\n${"=".repeat(80)}\n`);
if (cleanup) {
await cleanup();
}
});
describe("Migration System Initialization", () => {
it("should initialize migration tables", async () => {
await migrator.initialize();
const schemaResult = await db.execute(
sql`SELECT EXISTS (
SELECT 1 FROM information_schema.schemata
WHERE schema_name = 'migrations'
) as exists`
);
const schemaExists = (schemaResult.rows[0] as ExistsRow).exists;
expect(schemaExists).toBe(true);
if (schemaExists) {
testResults.passed.push("Migration schema created");
} else {
testResults.failed.push("Migration schema not created");
}
const tables = ["_migrations", "_journal", "_snapshots"];
for (const tableName of tables) {
const result = await db.execute(
sql`SELECT EXISTS (
SELECT 1 FROM pg_tables
WHERE schemaname = 'migrations'
AND tablename = ${tableName}
) as exists`
);
const exists = (result.rows[0] as ExistsRow).exists;
expect(exists).toBe(true);
if (exists) {
testResults.passed.push(`Migration table created: migrations.${tableName}`);
} else {
testResults.failed.push(`Migration table missing: migrations.${tableName}`);
}
}
});
});
describe("Schema Migration Execution", () => {
it("should run initial migration for plugin-sql schema", async () => {
await migrator.migrate("plugin-sql", schema, { verbose: true });
const tablesResult = await db.execute(
sql`SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename`
);
const createdTables = tablesResult.rows.map((r: TableInfoRow) => r.tablename);
console.log(`\n📋 Tables created: ${createdTables.length}`);
const expectedTables = [
"agents",
"cache",
"channel_participants",
"channels",
"components",
"embeddings",
"entities",
"logs",
"memories",
"message_servers",
"message_server_agents",
"central_messages",
"participants",
"relationships",
"rooms",
"tasks",
"worlds",
];
for (const table of expectedTables) {
if (createdTables.includes(table)) {
testResults.passed.push(`Table created: ${table}`);
} else {
testResults.failed.push(`Table missing: ${table}`);
}
expect(createdTables).toContain(table);
}
});
it("should track migration in _migrations table", async () => {
const result = await db.execute(
sql`SELECT * FROM migrations._migrations
WHERE plugin_name = 'plugin-sql'
ORDER BY created_at DESC
LIMIT 1`
);
expect(result.rows.length).toBeGreaterThan(0);
if (result.rows.length > 0) {
const migration = result.rows[0] as MigrationRow;
testResults.passed.push(
`Migration tracked: ${migration.plugin_name} - ${migration.hash.substring(0, 8)}...`
);
} else {
testResults.failed.push("Migration not tracked in _migrations table");
}
});
it("should save journal entry", async () => {
const result = await db.execute(
sql`SELECT * FROM migrations._journal
WHERE plugin_name = 'plugin-sql'`
);
expect(result.rows.length).toBe(1);
if (result.rows.length > 0) {
const journal = result.rows[0] as JournalRow;
const entries = journal.entries;
testResults.passed.push(`Journal saved with ${entries.length} entries`);
} else {
testResults.failed.push("Journal not saved");
}
});
it("should save snapshot", async () => {
const result = await db.execute(
sql`SELECT * FROM migrations._snapshots
WHERE plugin_name = 'plugin-sql'
ORDER BY idx DESC`
);
expect(result.rows.length).toBeGreaterThan(0);
if (result.rows.length > 0) {
const snapshot = result.rows[0] as SnapshotRow;
const tables = Object.keys(snapshot.snapshot.tables || {});
testResults.passed.push(`Snapshot saved with ${tables.length} tables`);
} else {
testResults.failed.push("Snapshot not saved");
}
});
});
describe("Column Types and Constraints", () => {
it("should create columns with correct types", async () => {
const criticalColumns = [
{ table: "agents", column: "id", type: "uuid" },
{ table: "agents", column: "name", type: "text" },
{ table: "agents", column: "enabled", type: "boolean" },
{ table: "agents", column: "bio", type: "jsonb" },
{ table: "memories", column: "content", type: "jsonb" },
{ table: "embeddings", column: "dim_384", type: "USER-DEFINED" }, // vector
{ table: "entities", column: "names", type: "ARRAY" },
];
for (const col of criticalColumns) {
const result = await db.execute(
sql`SELECT data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ${col.table}
AND column_name = ${col.column}`
);
if (result.rows.length > 0) {
const actualType = (result.rows[0] as ColumnRow).data_type;
const typeMatches =
actualType === col.type ||
(col.type === "USER-DEFINED" && actualType === "USER-DEFINED");
if (typeMatches) {
testResults.passed.push(
`Column type correct: ${col.table}.${col.column} (${actualType})`
);
} else {
testResults.failed.push(
`Column type wrong: ${col.table}.${col.column} - expected ${col.type}, got ${actualType}`
);
}
} else {
testResults.failed.push(`Column missing: ${col.table}.${col.column}`);
}
}
});
it("should create foreign key constraints", async () => {
const result = await db.execute(
sql`SELECT COUNT(*) as count
FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND constraint_type = 'FOREIGN KEY'`
);
const fkCount = parseInt(String((result.rows[0] as unknown as CountRow).count), 10);
expect(fkCount).toBeGreaterThan(0);
if (fkCount > 0) {
testResults.passed.push(`Foreign keys created: ${fkCount}`);
} else {
testResults.failed.push("No foreign keys created");
}
});
it("should create unique constraints", async () => {
const result = await db.execute(
sql`SELECT constraint_name, table_name
FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND constraint_type = 'UNIQUE'`
);
const uniqueCount = result.rows.length;
expect(uniqueCount).toBeGreaterThan(0);
if (uniqueCount > 0) {
testResults.passed.push(`Unique constraints created: ${uniqueCount}`);
// Check specific unique constraint on agents.name
const hasAgentNameUnique = result.rows.some(
(r: ConstraintRow) => r.table_name === "agents" && r.constraint_name === "name_unique"
);
if (hasAgentNameUnique) {
testResults.passed.push("agents.name unique constraint created");
} else {
testResults.failed.push("agents.name unique constraint missing");
}
} else {
testResults.failed.push("No unique constraints created");
}
});
});
describe("Idempotency", () => {
it("should handle running the same migration twice", async () => {
await migrator.migrate("plugin-sql", schema);
const result = await db.execute(
sql`SELECT COUNT(*) as count
FROM migrations._migrations
WHERE plugin_name = 'plugin-sql'`
);
const count = parseInt(String((result.rows[0] as unknown as CountRow).count), 10);
expect(count).toBe(1);
if (count === 1) {
testResults.passed.push("Idempotency: Migration not duplicated");
} else {
testResults.failed.push(`Idempotency: Found ${count} migrations instead of 1`);
}
});
it("should detect when no changes are needed", async () => {
const status = await migrator.getStatus("plugin-sql");
expect(status.hasRun).toBe(true);
if (status.hasRun) {
testResults.passed.push("Migration status correctly tracked");
} else {
testResults.failed.push("Migration status not tracked");
}
});
});
describe("Schema Evolution Support", () => {
it("should support ALTER operations (when schema changes)", async () => {
// Check that the migration journal stored a schema snapshot, which is the
// comparison input used when a later plugin schema changes.
const status = await migrator.getStatus("plugin-sql");
expect(status.snapshots).toBeGreaterThan(0);
if (status.snapshots > 0) {
testResults.passed.push("Schema evolution: Snapshots stored for comparison");
} else {
testResults.failed.push("Schema evolution: No snapshots stored");
}
});
it("should track migration history properly", async () => {
const journal = await db.execute(
sql`SELECT entries FROM migrations._journal
WHERE plugin_name = 'plugin-sql'`
);
if (journal.rows.length > 0) {
const entries = (journal.rows[0] as JournalRow).entries;
expect(entries.length).toBeGreaterThan(0);
if (entries.length > 0) {
testResults.passed.push(`Migration history: ${entries.length} entries tracked`);
} else {
testResults.failed.push("Migration history: No entries in journal");
}
} else {
testResults.failed.push("Migration history: Journal not found");
}
});
});
describe("Index Creation", () => {
it("should create indexes on tables", async () => {
const allIndexes = await db.execute(
sql`SELECT schemaname, tablename, indexname
FROM pg_indexes
WHERE schemaname = 'public'`
);
console.log("All indexes in public schema:", allIndexes.rows);
const result = await db.execute(
sql`SELECT COUNT(*) as count
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname LIKE 'idx_%'`
);
const indexCount = parseInt(String((result.rows[0] as unknown as CountRow).count), 10);
console.log("Count of idx_ indexes:", indexCount);
if (indexCount > 0) {
testResults.passed.push(`Indexes created: ${indexCount}`);
} else {
testResults.failed.push("🔴 CRITICAL GAP: No indexes created");
}
// The memories table's idx_-prefixed indexes are the ones asserted here.
expect(indexCount).toBeGreaterThan(0);
});
});
describe("Check Constraints", () => {
it("should create check constraints", async () => {
const result = await db.execute(
sql`SELECT COUNT(*) as count
FROM pg_constraint
WHERE connamespace = 'public'::regnamespace
AND contype = 'c'`
);
const checkCount = parseInt(String((result.rows[0] as unknown as CountRow).count), 10);
if (checkCount > 0) {
testResults.passed.push(`Check constraints created: ${checkCount}`);
} else {
testResults.failed.push("🟡 GAP: No check constraints created");
}
// The memories table's check constraints are the ones asserted here.
expect(checkCount).toBeGreaterThan(0);
});
});
describe("Production Readiness", () => {
it("should use transactions for atomicity", async () => {
// The initial plugin-sql migration earlier in this suite ran inside a single
// BEGIN/COMMIT transaction. If that transaction committed atomically, the
// migration record, journal, and snapshot must all be present together —
// a non-atomic implementation could leave any one of them missing.
const status = await migrator.getStatus("plugin-sql");
expect(status.hasRun).toBe(true);
expect(status.lastMigration).not.toBeNull();
expect(status.lastMigration?.hash).toBeTruthy();
expect(status.snapshots).toBeGreaterThan(0);
testResults.passed.push("Transactions: migration recorded with snapshot atomically");
});
it("should handle errors gracefully", async () => {
let _errorCaught = false;
try {
await migrator.migrate("invalid-plugin", {
invalidTable: "not-a-table",
});
} catch (_error) {
_errorCaught = true;
}
// A schema with no real tables is still recorded and considered "hasRun",
// but must not create any table matching the bogus entry.
const _status = await migrator.getStatus("invalid-plugin");
const tablesResult = await db.execute(
sql`SELECT COUNT(*) as count
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'invalidTable'`
);
const invalidTableExists =
parseInt(String((tablesResult.rows[0] as unknown as CountRow).count), 10) > 0;
expect(invalidTableExists).toBe(false);
if (!invalidTableExists) {
testResults.passed.push("Error handling: Invalid schema does not create tables");
} else {
testResults.failed.push("Error handling: Invalid table was created");
}
});
});
describe("Table Filtering - Ignoring Other Plugin Tables", () => {
it("should ignore tables in public schema that are not in plugin-sql schema", async () => {
// Simulates another plugin's table living alongside plugin-sql's own tables.
await db.execute(
sql`CREATE TABLE IF NOT EXISTS public.custom_analytics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
user_id UUID NOT NULL,
data JSONB,
created_at TIMESTAMP DEFAULT NOW()
)`
);
await db.execute(
sql`CREATE INDEX IF NOT EXISTS idx_custom_analytics_event_type
ON public.custom_analytics(event_type)`
);
await db.execute(
sql`INSERT INTO public.custom_analytics (event_type, user_id, data)
VALUES ('page_view', gen_random_uuid(), '{"page": "/home"}'::jsonb)`
);
// Must not drop custom_analytics — it isn't part of the plugin-sql schema.
await migrator.migrate("plugin-sql", schema, { verbose: true });
const tableExists = await db.execute(
sql`SELECT EXISTS (
SELECT 1 FROM pg_tables
WHERE schemaname = 'public'
AND tablename = 'custom_analytics'
) as exists`
);
const exists = (tableExists.rows[0] as ExistsRow).exists;
expect(exists).toBe(true);
if (exists) {
testResults.passed.push("Table filtering: custom_analytics table preserved");
} else {
testResults.failed.push("Table filtering: custom_analytics table was deleted!");
}
const dataResult = await db.execute(
sql`SELECT COUNT(*) as count FROM public.custom_analytics`
);
interface QueryRow {
count: string;
}
const count = parseInt((dataResult.rows[0] as unknown as QueryRow).count, 10);
expect(count).toBeGreaterThan(0);
if (count > 0) {
testResults.passed.push("Table filtering: custom_analytics data preserved");
} else {
testResults.failed.push("Table filtering: custom_analytics data was deleted!");
}
await db.execute(sql`DROP TABLE IF EXISTS public.custom_analytics CASCADE`);
});
it("should not schedule DROP for tables from other plugins in public schema", async () => {
// Simulates another plugin's data table living in the shared public schema.
await db.execute(
sql`CREATE TABLE IF NOT EXISTS public.other_plugin_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID,
plugin_name TEXT NOT NULL DEFAULT 'other-plugin',
data JSONB,
created_at TIMESTAMP DEFAULT NOW()
)`
);
await migrator.migrate("plugin-sql", schema, { verbose: true });
const tableExists = await db.execute(
sql`SELECT EXISTS (
SELECT 1 FROM pg_tables
WHERE schemaname = 'public'
AND tablename = 'other_plugin_data'
) as exists`
);
const exists = (tableExists.rows[0] as ExistsRow).exists;
expect(exists).toBe(true);
// migrations.ts (which runs before RuntimeMigrator) may drop server_id,
// but RuntimeMigrator itself must never touch this foreign table.
const columnExists = await db.execute(
sql`SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'other_plugin_data'
AND column_name = 'server_id'
) as exists`
);
const _serverIdExists = (columnExists.rows[0] as ExistsRow).exists;
if (exists) {
testResults.passed.push(
"Table filtering: other_plugin_data table preserved by RuntimeMigrator"
);
} else {
testResults.failed.push(
"Table filtering: other_plugin_data table was deleted by RuntimeMigrator!"
);
}
await db.execute(sql`DROP TABLE IF EXISTS public.other_plugin_data CASCADE`);
});
});
describe("Development Features", () => {
it("should support dry-run mode", async () => {
await migrator.migrate("dry-run-test", schema, {
dryRun: true,
});
const result = await db.execute(
sql`SELECT COUNT(*) as count
FROM migrations._migrations
WHERE plugin_name = 'dry-run-test'`
);
const count = parseInt(String((result.rows[0] as unknown as CountRow).count), 10);
expect(count).toBe(0);
if (count === 0) {
testResults.passed.push("Dry-run mode: No changes applied");
} else {
testResults.failed.push("Dry-run mode: Changes were applied!");
}
});
it("should support reset for development", async () => {
await migrator.migrate("reset-test", { testTable: {} });
await migrator.reset("reset-test");
const status = await migrator.getStatus("reset-test");
expect(status.hasRun).toBe(false);
if (!status.hasRun) {
testResults.passed.push("Reset functionality: Works correctly");
} else {
testResults.failed.push("Reset functionality: Failed to clear state");
}
});
});
});
@@ -0,0 +1,344 @@
/**
* End-to-end `RuntimeMigrator` test against a real isolated database that
* walks the full runtime flow a booting agent goes through: initialize
* migration infrastructure, migrate the core `@elizaos/plugin-sql` schema,
* migrate a plugin schema (polymarket) into its own Postgres schema, inspect
* the resulting migration/snapshot/journal rows, check `getStatus()` for
* both, then simulate an agent restart with a fresh `RuntimeMigrator`
* instance to confirm re-running both migrations is a no-op. A second test
* confirms migration records preserve registration order.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
interface MigrationRow {
plugin_name: string;
hash: string;
created_at: string | Date;
[key: string]: unknown;
}
interface JournalRow {
entries: unknown[];
[key: string]: unknown;
}
interface SnapshotRow {
snapshot: {
tables: Record<string, unknown>;
[key: string]: unknown;
};
[key: string]: unknown;
}
interface MigrationOrderRow {
migration_order: number;
plugin_name: string;
created_at: string | Date;
[key: string]: unknown;
}
interface TableInfoRow {
tablename: string;
[key: string]: unknown;
}
import { sql } from "drizzle-orm";
import { RuntimeMigrator } from "../../runtime-migrator";
import * as coreSchema from "../../schema";
import type { DrizzleDatabase } from "../../types";
import { testPolymarketSchema } from "../schema-data/test-plugin-schema";
import { createIsolatedTestDatabaseForMigration } from "../test-helpers";
describe("Runtime Simulation - Full Migration Flow", () => {
let db: DrizzleDatabase;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
beforeAll(async () => {
console.log("\n🚀 Simulating full runtime migration flow...\n");
const testSetup = await createIsolatedTestDatabaseForMigration("runtime_simulation");
cleanup = testSetup.cleanup;
db = testSetup.db;
});
afterAll(async () => {
if (cleanup) {
await cleanup();
}
});
it("should perform complete migration flow as in runtime", async () => {
console.log("=".repeat(80));
console.log("STEP 1: Initialize Migration System");
console.log("=".repeat(80));
// Fresh instance simulates a runtime process starting up cold.
migrator = new RuntimeMigrator(db);
await migrator.initialize();
const schemasResult = await db.execute(sql`
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name = 'migrations'
`);
expect(schemasResult.rows.length).toBe(1);
console.log("✅ Migration schema created");
const tablesResult = await db.execute(sql`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'migrations'
ORDER BY tablename
`);
const migrationTables = tablesResult.rows.map((r: TableInfoRow) => r.tablename);
console.log("Migration tables:", migrationTables);
expect(migrationTables).toContain("_migrations");
expect(migrationTables).toContain("_snapshots");
expect(migrationTables).toContain("_journal");
console.log(`\n${"=".repeat(80)}`);
console.log("STEP 2: Migrate Core Schema (@elizaos/plugin-sql)");
console.log("=".repeat(80));
// Mirrors what plugin-sql's own init does.
await migrator.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: true,
});
const coreMigrationCheck = await db.execute(sql`
SELECT * FROM migrations._migrations
WHERE plugin_name = '@elizaos/plugin-sql'
ORDER BY created_at DESC
`);
console.log(`\n📋 Core migration records: ${coreMigrationCheck.rows.length}`);
expect(coreMigrationCheck.rows.length).toBe(1);
const coreMigration = coreMigrationCheck.rows[0] as MigrationRow;
console.log("Core migration details:");
console.log(" - Plugin:", coreMigration.plugin_name);
console.log(" - Hash:", coreMigration.hash);
console.log(" - Created:", coreMigration.created_at);
const coreSnapshots = await db.execute(sql`
SELECT * FROM migrations._snapshots
WHERE plugin_name = '@elizaos/plugin-sql'
ORDER BY id DESC
`);
console.log(`\n📸 Core snapshots: ${coreSnapshots.rows.length}`);
const publicTables = await db.execute(sql`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename
`);
const coreTableNames = publicTables.rows.map((r: TableInfoRow) => r.tablename);
console.log(`\n📦 Core tables in public schema: ${coreTableNames.length}`);
console.log("Tables:", coreTableNames.join(", "));
console.log(`\n${"=".repeat(80)}`);
console.log("STEP 3: Migrate Plugin Schema (polymarket)");
console.log("=".repeat(80));
// Mirrors a plugin's own init calling migrate() with its schema.
await migrator.migrate("polymarket", testPolymarketSchema, {
verbose: true,
});
const polymarketMigrationCheck = await db.execute(sql`
SELECT * FROM migrations._migrations
WHERE plugin_name = 'polymarket'
ORDER BY created_at DESC
`);
console.log(`\n📋 Polymarket migration records: ${polymarketMigrationCheck.rows.length}`);
expect(polymarketMigrationCheck.rows.length).toBe(1);
interface MigrationRow {
plugin_name: string;
hash: string;
created_at: Date | string;
}
const polymarketMigration = polymarketMigrationCheck.rows[0] as MigrationRow;
console.log("Polymarket migration details:");
console.log(" - Plugin:", polymarketMigration.plugin_name);
console.log(" - Hash:", polymarketMigration.hash);
console.log(" - Created:", polymarketMigration.created_at);
const polymarketSnapshots = await db.execute(sql`
SELECT * FROM migrations._snapshots
WHERE plugin_name = 'polymarket'
ORDER BY id DESC
`);
console.log(`\n📸 Polymarket snapshots: ${polymarketSnapshots.rows.length}`);
const polymarketSchemaExists = await db.execute(sql`
SELECT EXISTS (
SELECT 1 FROM information_schema.schemata
WHERE schema_name = 'polymarket'
) as exists
`);
const firstRow = polymarketSchemaExists.rows?.[0];
expect(firstRow?.exists).toBe(true);
console.log("\n✅ Polymarket schema created");
const polymarketTables = await db.execute(sql`
SELECT tablename FROM pg_tables
WHERE schemaname = 'polymarket'
ORDER BY tablename
`);
const polymarketTableNames = polymarketTables.rows.map((r: TableInfoRow) => r.tablename);
console.log(`📦 Polymarket tables: ${polymarketTableNames.length}`);
console.log("Tables:", polymarketTableNames.join(", "));
console.log(`\n${"=".repeat(80)}`);
console.log("STEP 4: Verify Complete Migration State");
console.log("=".repeat(80));
// Expects exactly 2: core + polymarket.
const allMigrations = await db.execute(sql`
SELECT
plugin_name,
hash,
created_at
FROM migrations._migrations
ORDER BY created_at ASC
`);
console.log(`\n📊 Total migration records: ${allMigrations.rows.length}`);
expect(allMigrations.rows.length).toBe(2);
console.log("\nAll migrations:");
for (const migration of allMigrations.rows as MigrationRow[]) {
console.log(` - ${migration.plugin_name}: ${migration.hash} (${migration.created_at})`);
}
try {
const journalEntries = await db.execute(sql`
SELECT * FROM migrations._journal
ORDER BY id ASC
`);
console.log(`\n📓 Journal entries: ${journalEntries.rows.length}`);
for (const entry of journalEntries.rows as JournalRow[]) {
console.log(` - Journal entry:`, entry);
}
} catch (_err) {
console.log("\n📓 Journal table not available or empty");
}
const latestPolymarketSnapshot = await db.execute(sql`
SELECT * FROM migrations._snapshots
WHERE plugin_name = 'polymarket'
ORDER BY id DESC
LIMIT 1
`);
if (latestPolymarketSnapshot.rows.length > 0) {
const snapshot = latestPolymarketSnapshot.rows[0] as SnapshotRow;
try {
const snapshotData =
typeof snapshot.snapshot === "string" ? JSON.parse(snapshot.snapshot) : snapshot.snapshot;
console.log("\n🔍 Polymarket Snapshot Analysis:");
console.log(" - Version:", snapshotData.version);
console.log(" - Dialect:", snapshotData.dialect);
console.log(" - Tables:", Object.keys(snapshotData.tables || {}).length);
console.log(" - Table names:", Object.keys(snapshotData.tables || {}).join(", "));
for (const tableName of Object.keys(snapshotData.tables || {})) {
expect(tableName).toMatch(/^polymarket\./);
console.log(`${tableName} is correctly namespaced`);
}
} catch (_e) {
console.log("\n🔍 Polymarket Snapshot (raw):", snapshot.snapshot);
}
}
console.log(`\n${"=".repeat(80)}`);
console.log("STEP 5: Test Migration Status Methods");
console.log("=".repeat(80));
const coreStatus = await migrator.getStatus("@elizaos/plugin-sql");
const polymarketStatus = await migrator.getStatus("polymarket");
console.log("\n📈 Migration Status:");
console.log("Core (@elizaos/plugin-sql):");
console.log(" - Has Run:", coreStatus.hasRun);
console.log(" - Snapshots:", coreStatus.snapshots);
console.log(" - Last Migration:", coreStatus.lastMigration);
console.log("\nPolymarket:");
console.log(" - Has Run:", polymarketStatus.hasRun);
console.log(" - Snapshots:", polymarketStatus.snapshots);
console.log(" - Last Migration:", polymarketStatus.lastMigration);
expect(coreStatus.hasRun).toBe(true);
expect(polymarketStatus.hasRun).toBe(true);
console.log(`\n${"=".repeat(80)}`);
console.log("STEP 6: Simulate Re-initialization (Idempotency Check)");
console.log("=".repeat(80));
// Fresh instance simulates an agent process restart.
const migrator2 = new RuntimeMigrator(db);
await migrator2.initialize();
console.log("\n🔄 Re-running migrations (should skip)...");
await migrator2.migrate("@elizaos/plugin-sql", coreSchema, {
verbose: false,
});
await migrator2.migrate("polymarket", testPolymarketSchema, {
verbose: false,
});
const finalMigrationCount = await db.execute(sql`
SELECT COUNT(*) as count FROM migrations._migrations
`);
console.log(`\n📊 Final migration count: ${finalMigrationCount.rows?.[0]?.count}`);
const finalFirstRow = finalMigrationCount.rows?.[0];
expect(Number(finalFirstRow?.count)).toBe(2);
console.log(`\n${"=".repeat(80)}`);
console.log("✅ MIGRATION SIMULATION COMPLETE");
console.log("=".repeat(80));
console.log("\nSummary:");
console.log(" - Both migrations recorded: ✓");
console.log(" - Core tables in public schema: ✓");
console.log(" - Polymarket tables in polymarket schema: ✓");
console.log(" - Snapshots created: ✓");
console.log(" - Journal entries logged: ✓");
console.log(" - Idempotency verified: ✓");
});
it("should handle plugin registration order correctly", async () => {
console.log(`\n${"=".repeat(80)}`);
console.log("Testing Plugin Registration Order");
console.log("=".repeat(80));
const migrationOrder = await db.execute(sql`
SELECT
plugin_name,
created_at,
ROW_NUMBER() OVER (ORDER BY created_at ASC) as migration_order
FROM migrations._migrations
ORDER BY created_at ASC
`);
console.log("\nMigration Order:");
for (const record of migrationOrder.rows as MigrationOrderRow[]) {
console.log(` ${record.migration_order}. ${record.plugin_name} at ${record.created_at}`);
}
const firstMigration = migrationOrder.rows[0] as MigrationOrderRow;
expect(firstMigration.plugin_name).toBe("@elizaos/plugin-sql");
const secondMigration = migrationOrder.rows[1] as MigrationOrderRow;
expect(secondMigration.plugin_name).toBe("polymarket");
});
});
@@ -0,0 +1,302 @@
/**
* Schema-evolution test against the real elizaOS production schemas: proves
* `RuntimeMigrator` detects a column drop (removing `agents.username`) as
* data-losing, blocks it in both development and production without
* `ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS`, allows it via that env var or the
* `force`/`allowDataLoss` migrate options, and that unrelated tables/rows
* survive the drop.
*/
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RuntimeMigrator } from "../../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../../runtime-migrator/types";
// Import the ACTUAL production schemas
import { agentTable } from "../../../schema/agent";
import { cacheTable } from "../../../schema/cache";
import { channelTable } from "../../../schema/channel";
import { channelParticipantsTable } from "../../../schema/channelParticipant";
import { componentTable } from "../../../schema/component";
import { embeddingTable } from "../../../schema/embedding";
import { entityTable } from "../../../schema/entity";
import { logTable } from "../../../schema/log";
import { memoryTable } from "../../../schema/memory";
import { messageTable } from "../../../schema/message";
import { messageServerTable } from "../../../schema/messageServer";
import { messageServerAgentsTable } from "../../../schema/messageServerAgent";
import { participantTable } from "../../../schema/participant";
import { relationshipTable } from "../../../schema/relationship";
import { roomTable } from "../../../schema/room";
import { taskTable } from "../../../schema/tasks";
import { worldTable } from "../../../schema/world";
import { createIsolatedTestDatabaseForSchemaEvolutionTests } from "../../test-helpers";
type CountRow = { count: number };
describe("Schema Evolution Test: Drop Column with Production Schema", () => {
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
// Full production schema as it exists today
const getFullSchemaV1 = () => ({
agents: agentTable,
memories: memoryTable,
entities: entityTable,
relationships: relationshipTable,
rooms: roomTable,
worlds: worldTable,
participants: participantTable,
messages: messageTable,
messageServers: messageServerTable,
channels: channelTable,
channelParticipants: channelParticipantsTable,
components: componentTable,
embeddings: embeddingTable,
logs: logTable,
cache: cacheTable,
tasks: taskTable,
messageServerAgents: messageServerAgentsTable,
});
beforeEach(async () => {
const testSetup = await createIsolatedTestDatabaseForSchemaEvolutionTests(
"schema_evolution_drop_column_test"
);
db = testSetup.db;
cleanup = testSetup.cleanup;
migrator = new RuntimeMigrator(db);
await migrator.initialize();
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
it("should handle dropping username column from agents table with production data", async () => {
const schemaV1 = getFullSchemaV1();
console.log("🚀 Migrating full production schema V1...");
await migrator.migrate("@elizaos/production-schema-v1", schemaV1);
console.log("\n📝 Inserting production data...");
await db.insert(agentTable).values([
{
id: "550e8400-e29b-41d4-a716-446655440001",
name: "Agent Alpha",
username: "alpha_bot", // This field will be dropped
bio: ["Expert in natural language processing", "Specialized in customer support"],
enabled: true,
system: "You are a helpful assistant",
messageExamples: [],
postExamples: [],
topics: ["technology", "support"],
adjectives: ["helpful", "knowledgeable"],
knowledge: [],
plugins: ["bootstrap", "sql"],
settings: { secrets: {} },
style: { all: ["professional", "friendly"] },
},
{
id: "550e8400-e29b-41d4-a716-446655440002",
name: "Agent Beta",
username: "beta_bot", // This field will be dropped
bio: ["Data analysis specialist"],
enabled: true,
system: "You are a data analyst",
messageExamples: [],
postExamples: [],
topics: ["data", "analytics"],
adjectives: ["analytical", "precise"],
knowledge: [],
plugins: ["bootstrap"],
settings: {},
style: {},
},
]);
await db.insert(entityTable).values([
{
id: "660e8400-e29b-41d4-a716-446655440001",
agentId: "550e8400-e29b-41d4-a716-446655440001",
names: ["User One"],
metadata: { role: "admin", type: "user" },
},
{
id: "660e8400-e29b-41d4-a716-446655440002",
agentId: "550e8400-e29b-41d4-a716-446655440002",
names: ["User Two"],
metadata: { role: "member", type: "user" },
},
]);
await db.insert(memoryTable).values([
{
id: "770e8400-e29b-41d4-a716-446655440001",
agentId: "550e8400-e29b-41d4-a716-446655440001",
entityId: "660e8400-e29b-41d4-a716-446655440001",
type: "conversation",
content: {
text: "Important customer interaction",
sentiment: "positive",
},
metadata: { type: "fragment", documentId: "doc1", position: 1 },
unique: true,
},
{
id: "770e8400-e29b-41d4-a716-446655440002",
agentId: "550e8400-e29b-41d4-a716-446655440002",
entityId: "660e8400-e29b-41d4-a716-446655440002",
type: "fact",
content: { data: "Analysis result", confidence: 0.95 },
metadata: { type: "document", timestamp: new Date().toISOString() },
unique: true,
},
]);
const agentsBeforeCount = await db.execute(sql`SELECT COUNT(*) as count FROM agents`);
const memoriesBeforeCount = await db.execute(sql`SELECT COUNT(*) as count FROM memories`);
console.log("\n📊 Production data statistics:");
console.log(` - Agents: ${(agentsBeforeCount.rows[0] as unknown as CountRow).count}`);
console.log(` - Memories: ${(memoriesBeforeCount.rows[0] as unknown as CountRow).count}`);
const usernameCheck = await db.execute(
sql`SELECT name, username FROM agents WHERE username IS NOT NULL`
);
console.log(` - Agents with usernames: ${usernameCheck.rows.length}`);
usernameCheck.rows.forEach((row) => {
const typedRow = row as { name: string; username: string };
console.log(`${typedRow.name}: @${typedRow.username}`);
});
// V2 redefines the agents table without `username` — a destructive change.
const { pgTable, text, uuid, boolean, timestamp, jsonb, unique } = await import(
"drizzle-orm/pg-core"
);
const agentTableV2 = pgTable(
"agents",
{
id: uuid("id").primaryKey().defaultRandom(),
enabled: boolean("enabled").default(true).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).default(sql`now()`).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).default(sql`now()`).notNull(),
name: text("name").notNull(),
// No `username` field — its absence relative to V1 is the destructive change under test.
system: text("system").default(""),
bio: jsonb("bio").$type<string | string[]>().default(sql`'[]'::jsonb`),
messageExamples: jsonb("message_examples").default(sql`'[]'::jsonb`).notNull(),
postExamples: jsonb("post_examples").default(sql`'[]'::jsonb`).notNull(),
topics: jsonb("topics").default(sql`'[]'::jsonb`).notNull(),
adjectives: jsonb("adjectives").default(sql`'[]'::jsonb`).notNull(),
knowledge: jsonb("knowledge").default(sql`'[]'::jsonb`).notNull(),
plugins: jsonb("plugins").default(sql`'[]'::jsonb`).notNull(),
settings: jsonb("settings").default(sql`'{}'::jsonb`).notNull(),
style: jsonb("style").default(sql`'{}'::jsonb`).notNull(),
},
(table) => ({
nameUnique: unique("name_unique").on(table.name),
})
);
const schemaV2 = {
...schemaV1,
agents: agentTableV2, // Replace with version without username
};
console.log("\n🔍 Checking migration for data loss...");
const dataLossCheck = await migrator.checkMigration("@elizaos/production-schema-v1", schemaV2);
if (dataLossCheck) {
expect(dataLossCheck.hasDataLoss).toBe(true);
expect(dataLossCheck.requiresConfirmation).toBe(true);
expect(dataLossCheck.warnings).toContain(
'Column "username" in table "public.agents" will be dropped'
);
console.log("\n⚠️ Data Loss Detection:");
dataLossCheck.warnings.forEach((warning) => {
console.log(`${warning}`);
});
}
// Must be blocked even in development, absent the override env var.
process.env.NODE_ENV = "development";
delete process.env.ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS;
console.log("\n🛡️ Testing protection without environment variable...");
let blockedError: Error | null = null;
try {
await migrator.migrate("@elizaos/production-schema-v1", schemaV2);
} catch (error) {
blockedError = error as Error;
}
expect(blockedError).not.toBeNull();
expect(blockedError?.message).toContain("Destructive migration blocked");
expect(blockedError?.message).toContain("ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS");
console.log(` ✅ Migration blocked: ${blockedError?.message}`);
// Production mode blocks too, with a stricter/longer error message.
process.env.NODE_ENV = "production";
console.log("\n🛡️ Testing production protection...");
let productionError: Error | null = null;
try {
await migrator.migrate("@elizaos/production-schema-v1", schemaV2);
} catch (error) {
productionError = error as Error;
}
expect(productionError).not.toBeNull();
expect(productionError?.message).toContain("production");
expect(productionError?.message).toContain("ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS");
console.log(` ✅ Production blocked: ${productionError?.message?.substring(0, 80) || ""}...`);
process.env.NODE_ENV = "development";
process.env.ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS = "true";
console.log("\n⚠️ Testing with ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS=true...");
await migrator.migrate("@elizaos/production-schema-v1", schemaV2);
console.log(" ✅ Migration allowed with environment variable");
// Reset for next test
await migrator.migrate("@elizaos/production-schema-v1", schemaV1);
// The force/allowDataLoss migrate() options are an alternative to the env var above.
delete process.env.ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS;
console.log("\n⚠️ Testing with force option...");
await migrator.migrate("@elizaos/production-schema-v1", schemaV2, {
force: true,
allowDataLoss: true,
});
console.log(" ✅ Migration allowed with force option");
const columnsAfter = await db.execute(
sql`SELECT column_name FROM information_schema.columns
WHERE table_name = 'agents' AND table_schema = 'public'`
);
const columnNames = columnsAfter.rows.map((r) => (r as { column_name: string }).column_name);
expect(columnNames).not.toContain("username");
console.log("\n📊 Schema after migration:");
console.log(` ✅ Agent table columns: ${columnNames.length}`);
console.log(" ❌ Username column has been dropped");
const agentsAfterCount = await db.execute(sql`SELECT COUNT(*) as count FROM agents`);
const memoriesAfterCount = await db.execute(sql`SELECT COUNT(*) as count FROM memories`);
console.log("\n✅ Data integrity check:");
console.log(` - Agents preserved: ${(agentsAfterCount.rows[0] as unknown as CountRow).count}`);
console.log(
` - Memories preserved: ${(memoriesAfterCount.rows[0] as unknown as CountRow).count}`
);
console.log(" - Username data: PERMANENTLY LOST");
});
});
@@ -0,0 +1,410 @@
/**
* Schema-evolution test against the real elizaOS production schemas (with
* their actual foreign-key graph): proves `RuntimeMigrator` detects dropping
* the `memories` table — and dropping several interconnected tables at once
* (entities/memories/relationships/embeddings) — as data-losing, blocks it in
* development and production without `ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS`,
* and surfaces what happens (including FK-constraint failures from
* `embeddings`) when the override is set.
*/
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RuntimeMigrator } from "../../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../../runtime-migrator/types";
import { createIsolatedTestDatabaseForSchemaEvolutionTests } from "../../test-helpers";
interface ExistsRow {
exists: boolean;
}
function asExistsRow(row: unknown): ExistsRow {
return row as ExistsRow;
}
interface StatsRow {
agents: string | number;
entities: string | number;
rooms: string | number;
memories: string | number;
relationships: string | number;
[key: string]: unknown;
}
import { agentTable } from "../../../schema/agent";
import { cacheTable } from "../../../schema/cache";
import { channelTable } from "../../../schema/channel";
import { channelParticipantsTable } from "../../../schema/channelParticipant";
import { componentTable } from "../../../schema/component";
import { embeddingTable } from "../../../schema/embedding";
import { entityTable } from "../../../schema/entity";
import { logTable } from "../../../schema/log";
import { memoryTable } from "../../../schema/memory";
import { messageTable } from "../../../schema/message";
import { messageServerTable } from "../../../schema/messageServer";
import { messageServerAgentsTable } from "../../../schema/messageServerAgent";
import { participantTable } from "../../../schema/participant";
import { relationshipTable } from "../../../schema/relationship";
import { roomTable } from "../../../schema/room";
import { taskTable } from "../../../schema/tasks";
import { worldTable } from "../../../schema/world";
describe("Schema Evolution Test: Drop Table with Production Relationships", () => {
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
const getFullSchemaV1 = () => ({
agents: agentTable,
memories: memoryTable,
entities: entityTable,
relationships: relationshipTable,
rooms: roomTable,
worlds: worldTable,
participants: participantTable,
messages: messageTable,
messageServers: messageServerTable,
channels: channelTable,
channelParticipants: channelParticipantsTable,
components: componentTable,
embeddings: embeddingTable,
logs: logTable,
cache: cacheTable,
tasks: taskTable,
messageServerAgents: messageServerAgentsTable,
});
beforeEach(async () => {
const testSetup = await createIsolatedTestDatabaseForSchemaEvolutionTests(
"schema_evolution_drop_table_test"
);
db = testSetup.db;
cleanup = testSetup.cleanup;
migrator = new RuntimeMigrator(db);
await migrator.initialize();
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
it("should handle dropping memories table with cascade effects on production schema", async () => {
const schemaV1 = getFullSchemaV1();
console.log("🚀 Migrating full production schema...");
await migrator.migrate("@elizaos/production-schema-v1", schemaV1);
console.log("\n📝 Creating interconnected production data...");
const agent1Id = "550e8400-e29b-41d4-a716-446655440001";
const agent2Id = "550e8400-e29b-41d4-a716-446655440002";
await db.insert(agentTable).values([
{
id: agent1Id,
name: "Production Agent One",
bio: ["Customer support specialist"],
enabled: true,
system: "Support system prompt",
messageExamples: [],
postExamples: [],
topics: ["support"],
adjectives: ["helpful"],
knowledge: [],
plugins: ["bootstrap", "sql"],
settings: {},
style: {},
},
{
id: agent2Id,
name: "Production Agent Two",
bio: ["Analytics agent"],
enabled: true,
system: "Analytics system prompt",
messageExamples: [],
postExamples: [],
topics: ["analytics"],
adjectives: ["analytical"],
knowledge: [],
plugins: ["bootstrap"],
settings: {},
style: {},
},
]);
const entity1Id = "660e8400-e29b-41d4-a716-446655440001";
const entity2Id = "660e8400-e29b-41d4-a716-446655440002";
await db.insert(entityTable).values([
{
id: entity1Id,
agentId: agent1Id,
names: ["John Doe"],
metadata: { type: "user", verified: true },
},
{
id: entity2Id,
agentId: agent2Id,
names: ["Jane Smith"],
metadata: { type: "user", verified: false },
},
]);
const room1Id = "770e8400-e29b-41d4-a716-446655440001";
const room2Id = "770e8400-e29b-41d4-a716-446655440002";
const channelId1 = "990e8400-e29b-41d4-a716-446655440001";
const channelId2 = "990e8400-e29b-41d4-a716-446655440002";
const messageServerId = "aa1e8400-e29b-41d4-a716-446655440001";
await db.insert(roomTable).values([
{
id: room1Id,
name: "Support Channel",
agentId: agent1Id,
source: "discord",
type: "text",
channelId: channelId1,
messageServerId: messageServerId,
},
{
id: room2Id,
name: "Analytics Room",
agentId: agent2Id,
source: "discord",
type: "voice",
channelId: channelId2,
messageServerId: messageServerId,
},
]);
// Memories carry FKs to agents, entities, and rooms.
await db.insert(memoryTable).values([
{
id: "880e8400-e29b-41d4-a716-446655440001",
agentId: agent1Id,
entityId: entity1Id,
roomId: room1Id,
type: "conversation",
content: { text: "Customer support interaction #1", priority: "high" },
metadata: { type: "fragment", documentId: "doc1", position: 1 },
unique: true,
},
{
id: "880e8400-e29b-41d4-a716-446655440002",
agentId: agent1Id,
entityId: entity1Id,
roomId: room1Id,
type: "fact",
content: { text: "Customer preference noted", category: "preference" },
metadata: { type: "fragment", documentId: "doc1", position: 2 },
unique: true,
},
{
id: "880e8400-e29b-41d4-a716-446655440003",
agentId: agent2Id,
entityId: entity2Id,
roomId: room2Id,
type: "analysis",
content: { data: "Analytics result", confidence: 0.92 },
metadata: { type: "document", timestamp: new Date().toISOString() },
unique: false,
},
]);
await db.insert(relationshipTable).values([
{
sourceEntityId: entity1Id,
targetEntityId: entity2Id,
agentId: agent1Id,
tags: ["colleague", "team"],
metadata: { strength: 0.8 },
},
]);
// Embeddings are skipped here (vector setup is complex); the case under
// test is whether dropping the memories table itself is detected.
console.log("\n📊 Production data created:");
const counts = await db.execute(sql`
SELECT
(SELECT COUNT(*) FROM agents) as agents,
(SELECT COUNT(*) FROM entities) as entities,
(SELECT COUNT(*) FROM rooms) as rooms,
(SELECT COUNT(*) FROM memories) as memories,
(SELECT COUNT(*) FROM relationships) as relationships
`);
const stats = counts.rows[0] as StatsRow;
console.log(` - Agents: ${stats.agents}`);
console.log(` - Entities: ${stats.entities}`);
console.log(` - Rooms: ${stats.rooms}`);
console.log(` - Memories: ${stats.memories} (with FKs to agents, entities, rooms)`);
console.log(` - Relationships: ${stats.relationships}`);
// V2 omits `memories` — a destructive drop — while `embeddings` still
// holds foreign keys pointing at it, which is the cascade case under test.
const schemaV2 = {
agents: agentTable,
entities: entityTable,
relationships: relationshipTable,
rooms: roomTable,
worlds: worldTable,
participants: participantTable,
messages: messageTable,
messageServers: messageServerTable,
channels: channelTable,
channelParticipants: channelParticipantsTable,
components: componentTable,
embeddings: embeddingTable,
logs: logTable,
cache: cacheTable,
tasks: taskTable,
messageServerAgents: messageServerAgentsTable,
};
console.log("\n🔍 Checking migration for cascade effects...");
const dataLossCheck = await migrator.checkMigration("@elizaos/production-schema-v1", schemaV2);
if (dataLossCheck) {
expect(dataLossCheck.hasDataLoss).toBe(true);
expect(dataLossCheck.requiresConfirmation).toBe(true);
expect(
dataLossCheck.warnings.some((w) => w.includes("memories") && w.includes("dropped"))
).toBe(true);
console.log("\n⚠️ Table Drop Detection:");
dataLossCheck.warnings.forEach((warning) => {
console.log(`${warning}`);
});
}
process.env.NODE_ENV = "development";
delete process.env.ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS;
console.log("\n🛡️ Testing protection without environment variable...");
let blockedError: Error | null = null;
try {
await migrator.migrate("@elizaos/production-schema-v1", schemaV2);
} catch (error) {
blockedError = error as Error;
}
expect(blockedError).not.toBeNull();
expect(blockedError?.message).toContain("Destructive migration blocked");
console.log(` ✅ Table drop blocked without env var`);
// Production mode blocks the same drop.
process.env.NODE_ENV = "production";
console.log("\n🛡️ Testing production protection...");
let productionError: Error | null = null;
try {
await migrator.migrate("@elizaos/production-schema-v1", schemaV2);
} catch (error) {
productionError = error as Error;
}
expect(productionError).not.toBeNull();
expect(productionError?.message).toContain("production");
expect(productionError?.message).toContain("ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS");
console.log(` ✅ Table drop blocked in production`);
const tableExists = await db.execute(
sql`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'memories' AND table_schema = 'public'
) as exists`
);
expect(asExistsRow(tableExists.rows[0]).exists).toBe(true);
process.env.NODE_ENV = "development";
process.env.ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS = "true";
console.log("\n⚠️ Attempting migration with ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS=true...");
// May fail here due to the embeddings table's FK constraint on memories.
try {
await migrator.migrate("@elizaos/production-schema-v1", schemaV2);
const tableExistsAfter = await db.execute(
sql`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'memories'
) as exists`
);
expect(asExistsRow(tableExistsAfter.rows[0]).exists).toBe(false);
console.log("\n📊 After forced table drop:");
console.log(" ❌ Memories table dropped");
console.log(" ❌ All memory data lost permanently");
console.log(" ⚠️ Embeddings may have orphaned references");
} catch (error) {
console.log("\n❌ Migration failed (expected due to FK constraints):");
console.log(` Error: ${(error as Error).message}`);
console.log(" 💡 Would need to handle dependent tables first");
}
});
it("should detect cascade effects when dropping multiple related tables", async () => {
const schemaV1 = getFullSchemaV1();
await migrator.migrate("@elizaos/production-cascade-test", schemaV1);
const agentId = "aa0e8400-e29b-41d4-a716-446655440001";
await db.insert(agentTable).values({
id: agentId,
name: "Test Agent",
bio: ["Test bio"],
enabled: true,
system: "Test system",
messageExamples: [],
postExamples: [],
topics: [],
adjectives: [],
knowledge: [],
plugins: [],
settings: {},
style: {},
});
// V2 omits entities, memories, relationships, and embeddings — all
// interconnected by foreign keys.
const schemaV2 = {
agents: agentTable,
rooms: roomTable,
worlds: worldTable,
participants: participantTable,
messages: messageTable,
messageServers: messageServerTable,
channels: channelTable,
channelParticipants: channelParticipantsTable,
components: componentTable,
logs: logTable,
cache: cacheTable,
tasks: taskTable,
messageServerAgents: messageServerAgentsTable,
};
const check = await migrator.checkMigration("@elizaos/production-cascade-test", schemaV2);
if (check) {
expect(check.hasDataLoss).toBe(true);
expect(check.warnings.length).toBeGreaterThanOrEqual(4); // At least 4 tables dropped
console.log("\n🔗 Cascade Drop Analysis:");
console.log(` Total warnings: ${check.warnings.length}`);
check.warnings.forEach((warning) => {
console.log(`${warning}`);
});
// Should detect all dropped tables
expect(check.warnings.some((w) => w.includes("entities"))).toBe(true);
expect(check.warnings.some((w) => w.includes("memories"))).toBe(true);
expect(check.warnings.some((w) => w.includes("relationships"))).toBe(true);
expect(check.warnings.some((w) => w.includes("embeddings"))).toBe(true);
}
});
});
@@ -0,0 +1,371 @@
/**
* Schema-evolution tests covering `RuntimeMigrator` column type-change
* handling with real data present: JSONB→text (lossy), text→integer
* (fails on non-numeric values), boolean→text, and two regression cases —
* varchar→uuid and boolean→integer — that require the generated `ALTER
* COLUMN ... USING ...` clause to use the correct cast, since Postgres
* rejects direct casts for these type pairs.
*/
import { boolean, integer, jsonb, pgTable, text, uuid, varchar } from "drizzle-orm/pg-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RuntimeMigrator } from "../../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../../runtime-migrator/types";
import { createIsolatedTestDatabaseForSchemaEvolutionTests } from "../../test-helpers";
type ContentObject = Record<string, unknown>;
describe("Schema Evolution Test: Type Changes with Data", () => {
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
beforeEach(async () => {
const testSetup = await createIsolatedTestDatabaseForSchemaEvolutionTests(
"schema_evolution_type_changes_test"
);
db = testSetup.db;
cleanup = testSetup.cleanup;
migrator = new RuntimeMigrator(db);
await migrator.initialize();
// Every test here exercises a destructive type change.
process.env.ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS = "true";
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
it("should handle JSONB to text conversion with complex data", async () => {
// V1: JSONB columns storing complex nested data
const memoryTableV1 = pgTable("memories", {
id: uuid("id").primaryKey().notNull(),
content: jsonb("content").notNull(),
metadata: jsonb("metadata").default({}).notNull(),
settings: jsonb("settings"),
});
const schemaV1 = { memories: memoryTableV1 };
await migrator.migrate("@elizaos/schema-evolution-test-types-v1", schemaV1);
await db.insert(memoryTableV1).values([
{
id: "110e8400-e29b-41d4-a716-446655440001",
content: {
text: "Complex memory",
nested: {
level1: {
level2: {
data: ["array", "of", "values"],
number: 42,
},
},
},
tags: ["important", "conversation", "technical"],
},
metadata: {
timestamp: new Date().toISOString(),
version: 2,
flags: {
processed: true,
archived: false,
},
},
settings: {
visibility: "private",
retention: 30,
notifications: {
email: true,
push: false,
},
},
},
{
id: "110e8400-e29b-41d4-a716-446655440002",
content: {
simple: "Another memory",
score: 0.95,
},
metadata: {
source: "chat",
confidence: 0.8,
},
settings: null,
},
]);
const beforeData = await db.select().from(memoryTableV1);
console.log("Data before type change:");
console.log(` - ${beforeData.length} records with complex JSON structures`);
console.log(` - Sample content type: ${typeof beforeData[0].content}`);
console.log(
` - Content keys: ${Object.keys(beforeData[0].content as ContentObject).join(", ")}`
);
// V2: Change JSONB to text (lossy conversion!)
const memoryTableV2 = pgTable("memories", {
id: uuid("id").primaryKey().notNull(),
content: text("content").notNull(), // JSONB → text
metadata: text("metadata").notNull(), // JSONB → text
settings: text("settings"), // JSONB → text (nullable)
});
const schemaV2 = { memories: memoryTableV2 };
const check = await migrator.checkMigration(
"@elizaos/schema-evolution-test-types-v1",
schemaV2
);
expect(check).toBeDefined();
expect(check?.warnings.length).toBeGreaterThan(0);
console.log("\n⚠️ Type Conversion Warnings:");
check?.warnings.forEach((warning) => {
console.log(`${warning}`);
});
await migrator.migrate("@elizaos/schema-evolution-test-types-v1", schemaV2);
const afterData = await db.select().from(memoryTableV2);
console.log("\n📊 After type conversion:");
console.log(` - Content is now: ${typeof afterData[0].content}`);
console.log(` - First 100 chars: ${(afterData[0].content as string).substring(0, 100)}...`);
expect(typeof afterData[0].content).toBe("string");
expect(afterData[0].content).toContain("{"); // Should be JSON string
let _parsed: unknown;
try {
_parsed = JSON.parse(afterData[0].content as string);
console.log(" ✅ Content is valid JSON string, can be parsed back");
} catch (_e) {
console.log(" ❌ Content is not valid JSON string after conversion");
}
});
it("should handle text to integer conversion with invalid data", async () => {
// V1: Text column that might contain non-numeric data
const userTableV1 = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
age: text("age"), // Stored as text (bad practice but happens)
score: text("score"),
});
const schemaV1 = { users: userTableV1 };
await migrator.migrate("@elizaos/schema-evolution-test-text-to-int-v1", schemaV1);
await db.insert(userTableV1).values([
{
name: "User 1",
age: "25", // Valid integer
score: "100", // Valid integer
},
{
name: "User 2",
age: "30.5", // Decimal - will lose precision
score: "95.75", // Decimal - will lose precision
},
{
name: "User 3",
age: "unknown", // Invalid for integer!
score: "N/A", // Invalid for integer!
},
{
name: "User 4",
age: null, // NULL is ok
score: "", // Empty string - problem!
},
]);
console.log("Test data with mixed text values:");
const data = await db.select().from(userTableV1);
data.forEach((row) => {
console.log(` - ${row.name}: age="${row.age}", score="${row.score}"`);
});
// V2: Convert text to integer (will fail for invalid data!)
const userTableV2 = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
age: integer("age"), // text → integer
score: integer("score"), // text → integer
});
const schemaV2 = { users: userTableV2 };
const check = await migrator.checkMigration(
"@elizaos/schema-evolution-test-text-to-int-v1",
schemaV2
);
expect(check).toBeDefined();
expect(check?.warnings.length).toBeGreaterThan(0);
expect(
check?.warnings.some(
(w) => w.includes("Type change") || w.includes("type") || w.includes("column")
)
).toBe(true);
console.log("\n⚠️ Conversion Risk Detection:");
check?.warnings.forEach((warning) => {
console.log(`${warning}`);
});
let migrationError: Error | null = null;
try {
await migrator.migrate("@elizaos/schema-evolution-test-text-to-int-v1", schemaV2);
} catch (error) {
migrationError = error as Error;
}
// Expected to fail: "unknown" and "N/A" can't cast to integer, and even
// "30.5" is rejected as not a valid integer literal.
if (migrationError) {
console.log("\n❌ Migration failed as expected:");
console.log(` Error: ${migrationError.message}`);
expect(migrationError.message.toLowerCase()).toMatch(/failed query|invalid|error/);
} else {
console.log("\n⚠️ Migration succeeded with USING clause for conversion");
const afterData = await db.select().from(userTableV2);
console.log(" Converted data:");
afterData.forEach((row) => {
console.log(` - ${row.name}: age=${row.age}, score=${row.score}`);
});
}
});
it("should handle boolean to text and back conversions", async () => {
// V1: Boolean columns
const settingsTableV1 = pgTable("settings", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
enabled: boolean("enabled").notNull().default(true),
verified: boolean("verified").default(false),
active: boolean("active"),
});
const schemaV1 = { settings: settingsTableV1 };
await migrator.migrate("@elizaos/schema-evolution-test-bool-v1", schemaV1);
await db.insert(settingsTableV1).values([
{ name: "Setting 1", enabled: true, verified: true, active: true },
{ name: "Setting 2", enabled: false, verified: false, active: false },
{ name: "Setting 3", enabled: true, verified: false, active: null },
]);
console.log("Boolean data before conversion:");
const beforeData = await db.select().from(settingsTableV1);
beforeData.forEach((row) => {
console.log(
` - ${row.name}: enabled=${row.enabled}, verified=${row.verified}, active=${row.active}`
);
});
// V2: Convert boolean to text
const settingsTableV2 = pgTable("settings", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
enabled: text("enabled").notNull().default("true"), // boolean → text
verified: text("verified").default("false"), // boolean → text
active: text("active"), // boolean → text
});
const schemaV2 = { settings: settingsTableV2 };
await migrator.migrate("@elizaos/schema-evolution-test-bool-v1", schemaV2);
const afterTextData = await db.select().from(settingsTableV2);
console.log("\n📊 After boolean → text conversion:");
afterTextData.forEach((row) => {
console.log(
` - ${row.name}: enabled="${row.enabled}", verified="${row.verified}", active="${row.active}"`
);
});
expect(afterTextData[0].enabled).toBe("true");
expect(afterTextData[1].enabled).toBe("false");
expect(afterTextData[2].active).toBeNull();
});
it("should handle varchar to uuid conversion with a USING clause", async () => {
// Regression: Postgres introspects `varchar` as "character varying".
// The USING-clause check missed that spelling, so an
// `ALTER COLUMN ref_id TYPE uuid` was emitted without a USING clause and
// Postgres rejected it ("cannot be cast automatically"). This is the
// exact failure that blocked agent boots whose memory tables predated
// the uuid id migration.
const tableV1 = pgTable("ref_rows", {
id: uuid("id").primaryKey().defaultRandom(),
ref_id: varchar("ref_id", { length: 36 }).notNull(),
});
await migrator.migrate("@elizaos/schema-evolution-test-varchar-uuid-v1", {
ref_rows: tableV1,
});
await db
.insert(tableV1)
.values([
{ ref_id: "110e8400-e29b-41d4-a716-446655440001" },
{ ref_id: "110e8400-e29b-41d4-a716-446655440002" },
]);
// V2: ref_id becomes uuid.
const tableV2 = pgTable("ref_rows", {
id: uuid("id").primaryKey().defaultRandom(),
ref_id: uuid("ref_id").notNull(),
});
// Must not throw — the generated ALTER carries `USING ref_id::text::uuid`.
await migrator.migrate("@elizaos/schema-evolution-test-varchar-uuid-v1", {
ref_rows: tableV2,
});
const after = await db.select().from(tableV2);
const refs = after.map((r) => r.ref_id).sort();
expect(refs).toEqual([
"110e8400-e29b-41d4-a716-446655440001",
"110e8400-e29b-41d4-a716-446655440002",
]);
});
it("should handle boolean to integer conversion with a native cast", async () => {
// Regression: the generic `::text::integer` bridge breaks here because
// Postgres rejects boolean text ('true'/'false') as integer input. The
// boolean→integer conversion must use the native cast (true→1, false→0).
const tableV1 = pgTable("flags", {
id: uuid("id").primaryKey().defaultRandom(),
active: boolean("active").notNull(),
});
await migrator.migrate("@elizaos/schema-evolution-test-bool-int-v1", {
flags: tableV1,
});
await db.insert(tableV1).values([{ active: true }, { active: false }]);
// V2: active becomes integer.
const tableV2 = pgTable("flags", {
id: uuid("id").primaryKey().defaultRandom(),
active: integer("active").notNull(),
});
// Must not throw — the generated ALTER carries `USING active::integer`.
await migrator.migrate("@elizaos/schema-evolution-test-bool-int-v1", {
flags: tableV2,
});
const after = await db.select().from(tableV2);
const values = after.map((r) => r.active).sort();
expect(values).toEqual([0, 1]);
});
});
@@ -0,0 +1,285 @@
/**
* Schema-evolution tests proving `RuntimeMigrator` treats additive column
* changes as safe (no data-loss warnings, no destructive-migration block):
* nullable columns, `NOT NULL` columns backed by a default, columns whose
* default is a non-column-referencing SQL expression, and adding a dozen
* columns in a single migration. Runs against a real isolated database.
*/
import { sql } from "drizzle-orm";
import { boolean, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RuntimeMigrator } from "../../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../../runtime-migrator/types";
import { createIsolatedTestDatabaseForSchemaEvolutionTests } from "../../test-helpers";
type AgentRow = Record<string, unknown>;
type EntityRow = Record<string, unknown>;
type ColumnRow = Record<string, unknown>;
describe("Schema Evolution Test: Safe Column Additions", () => {
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
beforeEach(async () => {
const testSetup = await createIsolatedTestDatabaseForSchemaEvolutionTests(
"schema_evolution_safe_column_additions_test"
);
db = testSetup.db;
cleanup = testSetup.cleanup;
migrator = new RuntimeMigrator(db);
await migrator.initialize();
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
it("should add nullable columns without warnings", async () => {
// V1: Basic agent table
const agentTableV1 = pgTable("agents", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
enabled: boolean("enabled").default(true).notNull(),
});
const schemaV1 = { agents: agentTableV1 };
console.log("📦 Creating initial schema...");
await migrator.migrate("@elizaos/safe-additions-v1", schemaV1);
await db.insert(agentTableV1).values([
{ name: "Agent Alpha", enabled: true },
{ name: "Agent Beta", enabled: false },
]);
const beforeData = await db.select().from(agentTableV1);
console.log(` ✅ Created ${beforeData.length} agents`);
// V2: Add nullable columns
const agentTableV2 = pgTable("agents", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
enabled: boolean("enabled").default(true).notNull(),
// New nullable columns - safe additions
description: text("description"),
avatar: text("avatar"),
tags: text("tags").array(),
metadata: jsonb("metadata"),
});
const schemaV2 = { agents: agentTableV2 };
console.log("\n🔍 Checking for data loss warnings...");
const check = await migrator.checkMigration("@elizaos/safe-additions-v1", schemaV2);
if (check) {
expect(check.hasDataLoss).toBe(false);
expect(check.warnings.length).toBe(0);
console.log(" ✅ No data loss warnings (as expected for nullable columns)");
} else {
console.log(" ✅ No changes detected that would cause data loss");
}
// No ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS needed — purely additive.
console.log("\n📦 Applying safe migration...");
await migrator.migrate("@elizaos/safe-additions-v1", schemaV2);
console.log(" ✅ Migration completed successfully");
const afterData = await db.execute(sql`SELECT * FROM agents ORDER BY name`);
expect(afterData.rows.length).toBe(2);
const firstAgent = afterData.rows[0] as AgentRow;
expect(firstAgent.name).toBe("Agent Alpha");
expect(firstAgent.description).toBeNull();
expect(firstAgent.avatar).toBeNull();
expect(firstAgent.tags).toBeNull();
expect(firstAgent.metadata).toBeNull();
console.log("\n✅ Nullable columns added successfully:");
console.log(" - All existing data preserved");
console.log(" - New columns are NULL for existing rows");
});
it("should add NOT NULL columns with defaults without warnings", async () => {
// V1: Basic entity table
const entityTableV1 = pgTable("entities", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
});
const schemaV1 = { entities: entityTableV1 };
console.log("📦 Creating initial schema...");
await migrator.migrate("@elizaos/defaults-test-v1", schemaV1);
await db
.insert(entityTableV1)
.values([{ name: "Entity One" }, { name: "Entity Two" }, { name: "Entity Three" }]);
console.log(" ✅ Created 3 entities");
// V2: Add NOT NULL columns with defaults
const entityTableV2 = pgTable("entities", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
// New NOT NULL columns with defaults - safe because of defaults
enabled: boolean("enabled").default(true).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).default(sql`now()`).notNull(),
priority: integer("priority").default(0).notNull(),
status: text("status").default("active").notNull(),
settings: jsonb("settings").default({}).notNull(),
});
const schemaV2 = { entities: entityTableV2 };
console.log("\n🔍 Checking for data loss warnings...");
const check = await migrator.checkMigration("@elizaos/defaults-test-v1", schemaV2);
if (check) {
expect(check.hasDataLoss).toBe(false);
console.log(" ✅ No data loss (defaults protect existing rows)");
}
console.log("\n📦 Applying migration with NOT NULL + defaults...");
await migrator.migrate("@elizaos/defaults-test-v1", schemaV2);
console.log(" ✅ Migration completed successfully");
const afterData = await db.execute(sql`SELECT * FROM entities ORDER BY name`);
expect(afterData.rows.length).toBe(3);
for (const row of afterData.rows) {
const entity = row as EntityRow;
expect(entity.enabled).toBe(true); // Got default value
expect(entity.priority).toBe(0); // Got default value
expect(entity.status).toBe("active"); // Got default value
expect(entity.settings).toEqual({}); // Got default value
expect(entity.created_at).toBeDefined(); // Got NOW() default
console.log(`${entity.name}: defaults applied correctly`);
}
console.log("\n✅ NOT NULL columns with defaults added successfully");
});
it("should handle adding columns with valid DEFAULT expressions", async () => {
// V1: Basic table
const testTableV1 = pgTable("test_complex", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
});
const schemaV1 = { test_complex: testTableV1 };
console.log("📦 Creating initial schema...");
await migrator.migrate("@elizaos/complex-columns-v1", schemaV1);
await db.insert(testTableV1).values([{ name: "Test Record 1" }, { name: "Test Record 2" }]);
// Adds columns backed by DEFAULT expressions — these must not reference
// other columns; GENERATED ALWAYS AS expressions (e.g. to_tsvector, which
// can reference columns) aren't fully supported and are out of scope here.
const testTableV2 = pgTable("test_complex", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
createdAt: timestamp("created_at").default(sql`now()`).notNull(),
status: text("status").default("active").notNull(),
randomValue: integer("random_value").default(sql`floor(random() * 100)`),
timestampWithOffset:
timestamp("timestamp_with_offset").default(sql`now() + interval '1 hour'`),
});
const schemaV2 = { test_complex: testTableV2 };
console.log("\n🔍 Testing complex column defaults...");
const check = await migrator.checkMigration("@elizaos/complex-columns-v1", schemaV2);
if (check) {
expect(check.hasDataLoss).toBe(false);
console.log(" ✅ No data loss with complex defaults");
}
await migrator.migrate("@elizaos/complex-columns-v1", schemaV2);
const afterData = await db.execute(sql`SELECT * FROM test_complex ORDER BY name`);
for (const row of afterData.rows) {
const record = row as AgentRow;
expect(record.created_at).toBeDefined();
expect(record.status).toBe("active");
expect(record.timestamp_with_offset).toBeDefined();
// random_value can be null or a number
if (record.random_value !== null) {
expect(typeof record.random_value).toBe("number");
}
console.log(`${record.name}: default expressions applied correctly`);
}
console.log("\n✅ Valid DEFAULT expressions handled correctly");
});
it("should add multiple columns in single migration", async () => {
// V1: Minimal table
const tableV1 = pgTable("multi_test", {
id: uuid("id").primaryKey().defaultRandom(),
});
const schemaV1 = { multi_test: tableV1 };
console.log("📦 Creating minimal initial schema...");
await migrator.migrate("@elizaos/multi-column-v1", schemaV1);
// Adds 12 columns in a single migration.
const tableV2 = pgTable("multi_test", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").default("unnamed").notNull(),
description: text("description"),
enabled: boolean("enabled").default(true).notNull(),
priority: integer("priority").default(0).notNull(),
tags: text("tags").array(),
metadata: jsonb("metadata").default({}),
createdAt: timestamp("created_at").default(sql`now()`).notNull(),
updatedAt: timestamp("updated_at"),
deletedAt: timestamp("deleted_at"),
version: integer("version").default(1).notNull(),
status: text("status").default("active"),
config: jsonb("config"),
});
const schemaV2 = { multi_test: tableV2 };
console.log("\n📦 Adding 12 columns in single migration...");
const check = await migrator.checkMigration("@elizaos/multi-column-v1", schemaV2);
if (check) {
expect(check.hasDataLoss).toBe(false);
console.log(" ✅ No data loss adding multiple columns");
}
await migrator.migrate("@elizaos/multi-column-v1", schemaV2);
const columns = await db.execute(
sql`SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'multi_test'
ORDER BY ordinal_position`
);
expect(columns.rows.length).toBe(13); // id + 12 new columns
console.log(` ✅ All ${columns.rows.length} columns created successfully`);
for (const col of columns.rows) {
const column = col as ColumnRow;
const nullable = column.is_nullable === "YES" ? "NULL" : "NOT NULL";
const defaultVal = column.column_default
? `DEFAULT ${column.column_default.substring(0, 20)}...`
: "";
console.log(` - ${column.column_name}: ${column.data_type} ${nullable} ${defaultVal}`);
}
console.log("\n✅ Bulk column addition completed successfully");
});
});
@@ -0,0 +1,361 @@
/**
* Schema-evolution tests covering `RuntimeMigrator` handling of adding
* `NOT NULL`, `UNIQUE`, and `CHECK` constraints to a table that already has
* violating rows: each case confirms the migration fails against dirty data,
* succeeds once the data is fixed, and that the constraint is actually
* enforced afterward by attempting a violating insert. A final test adds
* several constraint kinds in one migration.
*/
import { sql } from "drizzle-orm";
import { boolean, check, integer, pgTable, text, unique, uuid } from "drizzle-orm/pg-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RuntimeMigrator } from "../../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../../runtime-migrator/types";
import { createIsolatedTestDatabaseForSchemaEvolutionTests } from "../../test-helpers";
describe("Schema Evolution Test: Constraint Modifications", () => {
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
beforeEach(async () => {
const testSetup = await createIsolatedTestDatabaseForSchemaEvolutionTests(
"schema_evolution_constraint_modifications_test"
);
db = testSetup.db;
cleanup = testSetup.cleanup;
migrator = new RuntimeMigrator(db);
await migrator.initialize();
// Every test here retries a constraint addition after fixing violating data.
process.env.ELIZA_ALLOW_DESTRUCTIVE_MIGRATIONS = "true";
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
it("should handle adding NOT NULL to columns with NULL values", async () => {
// V1: Table with nullable email
const userTableV1 = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email"), // Nullable
});
const schemaV1 = { users: userTableV1 };
console.log("📦 Creating schema with nullable email...");
await migrator.migrate("@elizaos/not-null-test-v1", schemaV1);
await db.insert(userTableV1).values([
{ name: "User 1", email: "user1@example.com" },
{ name: "User 2", email: null }, // NULL email
{ name: "User 3", email: "user3@example.com" },
{ name: "User 4", email: null }, // NULL email
]);
console.log(" ✅ Created 4 users (2 with NULL emails)");
// V2: Make email NOT NULL
const userTableV2 = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull(), // Now required!
});
const schemaV2 = { users: userTableV2 };
console.log("\n🔍 Checking NOT NULL constraint addition...");
const check = await migrator.checkMigration("@elizaos/not-null-test-v1", schemaV2);
if (check) {
expect(check.warnings.length).toBeGreaterThan(0);
console.log(" ⚠️ Warnings detected:");
check.warnings.forEach((w) => console.log(` - ${w}`));
}
console.log("\n❌ Attempting migration with NULL values...");
let migrationError: Error | null = null;
try {
await migrator.migrate("@elizaos/not-null-test-v1", schemaV2);
} catch (error) {
migrationError = error as Error;
}
if (migrationError) {
console.log(" ✅ Migration failed as expected (NULL values violate NOT NULL)");
console.log(` Error: ${migrationError.message.substring(0, 100)}...`);
}
console.log("\n🔧 Fixing NULL values...");
await db.execute(sql`UPDATE users SET email = 'default@example.com' WHERE email IS NULL`);
console.log(" ✅ Updated NULL emails with default values");
console.log("\n📦 Retrying migration after fixing data...");
await migrator.migrate("@elizaos/not-null-test-v1", schemaV2);
console.log(" ✅ Migration succeeded after fixing NULL values");
let insertError: Error | null = null;
try {
await db.execute(sql`INSERT INTO users (name, email) VALUES ('Test User', NULL)`);
} catch (error) {
insertError = error as Error;
}
expect(insertError).not.toBeNull();
console.log(" ✅ NOT NULL constraint is now enforced");
});
it("should handle adding UNIQUE constraints with duplicate values", async () => {
// V1: Table without unique constraint
const agentTableV1 = pgTable("agents", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
username: text("username"),
});
const schemaV1 = { agents: agentTableV1 };
console.log("📦 Creating schema without unique constraint...");
await migrator.migrate("@elizaos/unique-test-v1", schemaV1);
await db.insert(agentTableV1).values([
{ name: "Agent 1", username: "alpha" },
{ name: "Agent 2", username: "beta" },
{ name: "Agent 3", username: "alpha" }, // Duplicate!
{ name: "Agent 4", username: "gamma" },
]);
console.log(" ✅ Created 4 agents (with duplicate usernames)");
// V2: Add unique constraint
const agentTableV2 = pgTable(
"agents",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
username: text("username").unique(), // Now unique!
},
(table) => [unique("username_unique").on(table.username)]
);
const schemaV2 = { agents: agentTableV2 };
console.log("\n🔍 Checking UNIQUE constraint addition...");
const check = await migrator.checkMigration("@elizaos/unique-test-v1", schemaV2);
if (check) {
console.log(" ⚠️ Migration check results:");
if (check.warnings.length > 0) {
check.warnings.forEach((w) => console.log(` - ${w}`));
}
}
console.log("\n❌ Attempting migration with duplicate values...");
let migrationError: Error | null = null;
try {
await migrator.migrate("@elizaos/unique-test-v1", schemaV2);
} catch (error) {
migrationError = error as Error;
}
if (migrationError) {
console.log(" ✅ Migration failed as expected (duplicates violate UNIQUE)");
console.log(` Error: ${migrationError.message.substring(0, 100)}...`);
}
console.log("\n🔧 Fixing duplicate values...");
await db.execute(
sql`UPDATE agents SET username = username || '-' || id WHERE username IN (
SELECT username FROM agents GROUP BY username HAVING COUNT(*) > 1
)`
);
console.log(" ✅ Made usernames unique by appending IDs");
console.log("\n📦 Retrying migration after fixing duplicates...");
await migrator.migrate("@elizaos/unique-test-v1", schemaV2);
console.log(" ✅ Migration succeeded after fixing duplicates");
let insertError: Error | null = null;
try {
await db.execute(sql`INSERT INTO agents (name, username) VALUES ('Test', 'beta')`);
} catch (error) {
insertError = error as Error;
}
expect(insertError).not.toBeNull();
console.log(" ✅ UNIQUE constraint is now enforced");
});
it("should handle adding CHECK constraints with violating data", async () => {
// V1: Table without check constraints
const productTableV1 = pgTable("products", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
price: integer("price"),
quantity: integer("quantity"),
});
const schemaV1 = { products: productTableV1 };
console.log("📦 Creating schema without check constraints...");
await migrator.migrate("@elizaos/check-test-v1", schemaV1);
await db.insert(productTableV1).values([
{ name: "Product 1", price: 100, quantity: 10 },
{ name: "Product 2", price: -50, quantity: 5 }, // Negative price!
{ name: "Product 3", price: 200, quantity: -2 }, // Negative quantity!
{ name: "Product 4", price: 0, quantity: 0 }, // Zero values
]);
console.log(" ✅ Created 4 products (some with invalid values)");
// V2: Add check constraints
const productTableV2 = pgTable(
"products",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
price: integer("price"),
quantity: integer("quantity"),
},
() => [
check("positive_price", sql`price > 0`),
check("non_negative_quantity", sql`quantity >= 0`),
]
);
const schemaV2 = { products: productTableV2 };
console.log("\n🔍 Checking CHECK constraint addition...");
const check2 = await migrator.checkMigration("@elizaos/check-test-v1", schemaV2);
if (check2) {
console.log(" ⚠️ Migration analysis:");
if (check2.warnings.length > 0) {
check2.warnings.forEach((w) => console.log(` - ${w}`));
}
}
console.log("\n❌ Attempting migration with constraint violations...");
let migrationError: Error | null = null;
try {
await migrator.migrate("@elizaos/check-test-v1", schemaV2);
} catch (error) {
migrationError = error as Error;
}
if (migrationError) {
console.log(" ✅ Migration failed as expected (data violates CHECK)");
console.log(` Error: ${migrationError.message.substring(0, 100)}...`);
}
console.log("\n🔧 Fixing constraint violations...");
await db.execute(sql`UPDATE products SET price = 1 WHERE price <= 0`);
await db.execute(sql`UPDATE products SET quantity = 0 WHERE quantity < 0`);
console.log(" ✅ Fixed negative and zero prices/quantities");
console.log("\n📦 Retrying migration after fixing data...");
await migrator.migrate("@elizaos/check-test-v1", schemaV2);
console.log(" ✅ Migration succeeded after fixing violations");
let insertError: Error | null = null;
try {
await db.execute(
sql`INSERT INTO products (name, price, quantity) VALUES ('Bad Product', -10, 5)`
);
} catch (error) {
insertError = error as Error;
}
expect(insertError).not.toBeNull();
console.log(" ✅ CHECK constraints are now enforced");
});
it("should handle multiple constraint additions in single migration", async () => {
// V1: Simple table
const tableV1 = pgTable("test_multi_constraints", {
id: uuid("id").primaryKey().defaultRandom(),
code: text("code"),
value: integer("value"),
active: boolean("active"),
});
const schemaV1 = { test_multi_constraints: tableV1 };
console.log("📦 Creating initial schema...");
await migrator.migrate("@elizaos/multi-constraints-v1", schemaV1);
await db.insert(tableV1).values([
{ code: "ABC", value: 100, active: true },
{ code: "DEF", value: 200, active: false },
{ code: null, value: null, active: null },
]);
// V2: Add multiple constraints at once
const tableV2 = pgTable(
"test_multi_constraints",
{
id: uuid("id").primaryKey().defaultRandom(),
code: text("code").notNull().unique(), // Add NOT NULL + UNIQUE
value: integer("value").notNull(), // Add NOT NULL
active: boolean("active").default(true).notNull(), // Add NOT NULL with default
},
(table) => [
check("value_positive", sql`value > 0`), // Add CHECK constraint
unique("code_unique_constraint").on(table.code), // Additional unique constraint
]
);
const schemaV2 = { test_multi_constraints: tableV2 };
console.log("\n🔍 Checking multiple constraint additions...");
const check2 = await migrator.checkMigration("@elizaos/multi-constraints-v1", schemaV2);
if (check2) {
console.log(" ⚠️ Multiple constraints being added:");
console.log(" - NOT NULL on code");
console.log(" - UNIQUE on code");
console.log(" - NOT NULL on value");
console.log(" - NOT NULL with default on active");
console.log(" - CHECK constraint on value");
}
console.log("\n🔧 Preparing data for constraints...");
await db.execute(sql`DELETE FROM test_multi_constraints WHERE code IS NULL`);
await db.execute(sql`UPDATE test_multi_constraints SET value = 50 WHERE value IS NULL`);
await db.execute(sql`UPDATE test_multi_constraints SET active = true WHERE active IS NULL`);
console.log("\n📦 Applying multiple constraints...");
await migrator.migrate("@elizaos/multi-constraints-v1", schemaV2);
console.log(" ✅ All constraints added successfully");
const violations: string[] = [];
try {
await db.execute(sql`INSERT INTO test_multi_constraints (code, value) VALUES (NULL, 100)`);
} catch (_e) {
violations.push("NOT NULL on code");
}
try {
await db.execute(sql`INSERT INTO test_multi_constraints (code, value) VALUES ('ABC', 100)`);
} catch (_e) {
violations.push("UNIQUE on code");
}
try {
await db.execute(sql`INSERT INTO test_multi_constraints (code, value) VALUES ('XYZ', -10)`);
} catch (_e) {
violations.push("CHECK on value");
}
console.log("\n✅ All constraints enforced:");
violations.forEach((v) => console.log(` - ${v}`));
expect(violations.length).toBeGreaterThanOrEqual(3);
});
});
@@ -0,0 +1,420 @@
/**
* Schema-evolution tests proving `RuntimeMigrator` handles index changes
* (single-column, composite, expression/JSONB, partial, GIN, descending, text
* search) as safe, data-preserving operations: adding a first batch of
* indexes, dropping/renaming/recreating indexes on an existing table, a
* larger real-world-shaped indexing strategy verified via `EXPLAIN`, and
* redefining an index that keeps its name but targets a different column.
*/
import { sql } from "drizzle-orm";
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RuntimeMigrator } from "../../../runtime-migrator/runtime-migrator";
import type { DrizzleDB } from "../../../runtime-migrator/types";
import { createIsolatedTestDatabaseForSchemaEvolutionTests } from "../../test-helpers";
type IndexRow = Record<string, unknown>;
type IndexDefRow = Record<string, unknown>;
type CountRow = { count: number };
describe("Schema Evolution Test: Index Evolution", () => {
let db: DrizzleDB;
let migrator: RuntimeMigrator;
let cleanup: () => Promise<void>;
beforeEach(async () => {
const testSetup = await createIsolatedTestDatabaseForSchemaEvolutionTests(
"schema_evolution_index_evolution_test"
);
db = testSetup.db;
cleanup = testSetup.cleanup;
migrator = new RuntimeMigrator(db);
await migrator.initialize();
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
it("should add various types of indexes without warnings", async () => {
// V1: Table without indexes
const memoryTableV1 = pgTable("memories", {
id: uuid("id").primaryKey().defaultRandom(),
type: text("type").notNull(),
roomId: uuid("room_id"),
agentId: uuid("agent_id"),
entityId: uuid("entity_id"),
content: jsonb("content").notNull(),
metadata: jsonb("metadata").default({}).notNull(),
createdAt: timestamp("created_at").default(sql`now()`).notNull(),
});
const schemaV1 = { memories: memoryTableV1 };
console.log("📦 Creating table without indexes...");
await migrator.migrate("@elizaos/index-test-v1", schemaV1);
await db.insert(memoryTableV1).values([
{
type: "conversation",
roomId: "550e8400-e29b-41d4-a716-446655440001",
agentId: "660e8400-e29b-41d4-a716-446655440001",
content: { text: "Hello world" },
metadata: { type: "fragment", documentId: "doc1", position: 1 },
},
{
type: "fact",
roomId: "550e8400-e29b-41d4-a716-446655440001",
agentId: "660e8400-e29b-41d4-a716-446655440001",
content: { text: "Important fact" },
metadata: { type: "document", documentId: "doc2", position: 2 },
},
{
type: "analysis",
roomId: "550e8400-e29b-41d4-a716-446655440002",
agentId: "660e8400-e29b-41d4-a716-446655440002",
content: { text: "Data analysis result" },
metadata: { type: "result", confidence: 0.95 },
},
]);
console.log(" ✅ Created test data");
// V2: Add multiple index types
const memoryTableV2 = pgTable(
"memories",
{
id: uuid("id").primaryKey().defaultRandom(),
type: text("type").notNull(),
roomId: uuid("room_id"),
agentId: uuid("agent_id"),
entityId: uuid("entity_id"),
content: jsonb("content").notNull(),
metadata: jsonb("metadata").default({}).notNull(),
createdAt: timestamp("created_at").default(sql`now()`).notNull(),
},
(table) => [
// Simple single column index
index("idx_memories_type").on(table.type),
// Composite index (multiple columns)
index("idx_memories_type_room").on(table.type, table.roomId),
// Index on nullable column
index("idx_memories_entity").on(table.entityId),
// Index on timestamp
index("idx_memories_created").on(table.createdAt),
// Expression index on JSONB field
index("idx_memories_metadata_type").on(sql`(metadata->>'type')`),
// Multi-column expression index for fragments
index("idx_fragments_order").on(
sql`(metadata->>'documentId')`,
sql`((metadata->>'position')::int)`
),
// Partial index with WHERE clause
index("idx_conversation_memories")
.on(table.type, table.agentId)
.where(sql`type = 'conversation'`),
]
);
const schemaV2 = { memories: memoryTableV2 };
console.log("\n🔍 Checking index additions...");
const check = await migrator.checkMigration("@elizaos/index-test-v1", schemaV2);
if (check) {
expect(check.hasDataLoss).toBe(false);
console.log(" ✅ No data loss warnings for index creation");
}
console.log("\n📦 Adding indexes...");
await migrator.migrate("@elizaos/index-test-v1", schemaV2);
console.log(" ✅ All indexes created successfully");
const indexes = await db.execute(
sql`SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'memories'
AND indexname != 'memories_pkey'
ORDER BY indexname`
);
console.log("\n📊 Created indexes:");
for (const idx of indexes.rows) {
const index = idx as IndexRow;
console.log(` - ${index.indexname}`);
console.log(` Definition: ${index.indexdef.substring(0, 80)}...`);
}
expect(indexes.rows.length).toBeGreaterThanOrEqual(7);
console.log(`\n✅ Created ${indexes.rows.length} indexes successfully`);
});
it("should drop and recreate indexes without data loss", async () => {
// V1: Table with initial indexes
const testTableV1 = pgTable(
"test_index_changes",
{
id: uuid("id").primaryKey().defaultRandom(),
col1: text("col1"),
col2: text("col2"),
col3: text("col3"),
metadata: jsonb("metadata"),
},
(table) => [
index("idx_col1").on(table.col1),
index("idx_col2").on(table.col2),
index("idx_composite").on(table.col1, table.col2),
]
);
const schemaV1 = { test_index_changes: testTableV1 };
console.log("📦 Creating table with initial indexes...");
await migrator.migrate("@elizaos/index-change-v1", schemaV1);
await db.insert(testTableV1).values([
{ col1: "a", col2: "b", col3: "c", metadata: { key: "value1" } },
{ col1: "d", col2: "e", col3: "f", metadata: { key: "value2" } },
{ col1: "g", col2: "h", col3: "i", metadata: { key: "value3" } },
]);
console.log(" ✅ Created test data and initial indexes");
// V2: Change index configuration
const testTableV2 = pgTable(
"test_index_changes",
{
id: uuid("id").primaryKey().defaultRandom(),
col1: text("col1"),
col2: text("col2"),
col3: text("col3"),
metadata: jsonb("metadata"),
},
(table) => [
// idx_col1 removed (will be dropped)
// idx_col2 kept the same
index("idx_col2").on(table.col2),
// idx_composite changed to different columns
index("idx_composite").on(table.col2, table.col3), // Different columns!
// New indexes added
index("idx_col3").on(table.col3),
index("idx_metadata_key").on(sql`(metadata->>'key')`),
]
);
const schemaV2 = { test_index_changes: testTableV2 };
console.log("\n🔍 Checking index modifications...");
const check = await migrator.checkMigration("@elizaos/index-change-v1", schemaV2);
if (check) {
expect(check.hasDataLoss).toBe(false);
console.log(" ✅ No data loss for index modifications");
if (check.warnings.length > 0) {
console.log(" ️ Index changes detected:");
check.warnings.forEach((w) => console.log(` - ${w}`));
}
}
console.log("\n📦 Modifying indexes...");
await migrator.migrate("@elizaos/index-change-v1", schemaV2);
console.log(" ✅ Index modifications completed");
const indexes = await db.execute(
sql`SELECT indexname
FROM pg_indexes
WHERE tablename = 'test_index_changes'
AND indexname != 'test_index_changes_pkey'
ORDER BY indexname`
);
const indexNames = indexes.rows.map((r) => (r as { indexname: string }).indexname);
console.log("\n📊 Final indexes:");
indexNames.forEach((name) => console.log(` - ${name}`));
expect(indexNames).not.toContain("idx_col1"); // Should be dropped
expect(indexNames).toContain("idx_col2"); // Should remain
expect(indexNames).toContain("idx_col3"); // Should be added
expect(indexNames).toContain("idx_metadata_key"); // Should be added
const dataCount = await db.execute(sql`SELECT COUNT(*) as count FROM test_index_changes`);
expect(Number((dataCount.rows[0] as unknown as CountRow).count)).toBe(3);
console.log("\n✅ All data preserved during index changes");
});
it("should handle complex index scenarios", async () => {
// V1: Simple table
const tableV1 = pgTable("complex_indexes", {
id: uuid("id").primaryKey().defaultRandom(),
status: text("status").notNull(),
category: text("category"),
priority: text("priority"),
data: jsonb("data").default({}).notNull(),
searchVector: text("search_vector"),
createdAt: timestamp("created_at").default(sql`now()`).notNull(),
});
const schemaV1 = { complex_indexes: tableV1 };
console.log("📦 Creating initial table...");
await migrator.migrate("@elizaos/complex-index-v1", schemaV1);
const statuses = ["pending", "active", "completed", "archived"];
const categories = ["A", "B", "C", null];
const priorities = ["high", "medium", "low", "urgent"];
for (let i = 0; i < 20; i++) {
await db.insert(tableV1).values({
status: statuses[i % statuses.length],
category: categories[i % categories.length],
priority: priorities[i % priorities.length],
data: { index: i, value: `test-${i}` },
searchVector: `keyword${i % 5} term${i % 3}`,
});
}
console.log(" ✅ Created 20 test records");
// V2: Add complex indexing strategy
const tableV2 = pgTable(
"complex_indexes",
{
id: uuid("id").primaryKey().defaultRandom(),
status: text("status").notNull(),
category: text("category"),
priority: text("priority"),
data: jsonb("data").default({}).notNull(),
searchVector: text("search_vector"),
createdAt: timestamp("created_at").default(sql`now()`).notNull(),
},
(table) => [
// Covering index for common queries
index("idx_status_priority_created").on(table.status, table.priority, table.createdAt),
// Partial indexes for specific status values
index("idx_pending").on(table.createdAt).where(sql`status = 'pending'`),
index("idx_active_high_priority")
.on(table.createdAt)
.where(sql`status = 'active' AND priority = 'high'`),
// Expression index for case-insensitive search
index("idx_lower_category").on(sql`lower(category)`),
// GIN index for JSONB (if available)
index("idx_data_gin").using("gin", table.data),
// Text search index
index("idx_search_vector").on(sql`to_tsvector('english', search_vector)`),
// Descending index for reverse chronological queries
index("idx_created_desc").on(sql`created_at DESC`),
]
);
const schemaV2 = { complex_indexes: tableV2 };
console.log("\n📦 Creating complex indexing strategy...");
const check = await migrator.checkMigration("@elizaos/complex-index-v1", schemaV2);
if (check) {
expect(check.hasDataLoss).toBe(false);
console.log(" ✅ No data loss for complex indexes");
}
await migrator.migrate("@elizaos/complex-index-v1", schemaV2);
const indexes = await db.execute(
sql`SELECT
indexname,
indexdef,
CASE
WHEN indexdef LIKE '%WHERE%' THEN 'PARTIAL'
WHEN indexdef LIKE '%gin%' THEN 'GIN'
WHEN indexdef LIKE '%DESC%' THEN 'DESCENDING'
WHEN indexdef LIKE '%to_tsvector%' THEN 'TEXT_SEARCH'
WHEN indexdef LIKE '%lower%' THEN 'EXPRESSION'
ELSE 'STANDARD'
END as index_type
FROM pg_indexes
WHERE tablename = 'complex_indexes'
AND indexname != 'complex_indexes_pkey'
ORDER BY indexname`
);
console.log("\n📊 Complex indexes created:");
for (const idx of indexes.rows) {
const index = idx as IndexRow;
console.log(` - ${index.indexname} (${index.index_type})`);
}
console.log("\n🔍 Testing index usage:");
const _explainPending = await db.execute(
sql`EXPLAIN SELECT * FROM complex_indexes WHERE status = 'pending' ORDER BY created_at`
);
console.log(" ✅ Partial index for pending status can be used");
const _explainCovering = await db.execute(
sql`EXPLAIN SELECT status, priority, created_at FROM complex_indexes WHERE status = 'active'`
);
console.log(" ✅ Covering index can satisfy query without table access");
console.log("\n✅ Complex indexing strategy successfully implemented");
});
it("should handle index name conflicts gracefully", async () => {
// V1: Table with index
const tableV1 = pgTable(
"test_conflicts",
{
id: uuid("id").primaryKey().defaultRandom(),
field1: text("field1"),
field2: text("field2"),
},
(table) => [index("my_index").on(table.field1)]
);
const schemaV1 = { test_conflicts: tableV1 };
console.log("📦 Creating table with index...");
await migrator.migrate("@elizaos/conflict-test-v1", schemaV1);
// V2: Try to change what my_index points to
const tableV2 = pgTable(
"test_conflicts",
{
id: uuid("id").primaryKey().defaultRandom(),
field1: text("field1"),
field2: text("field2"),
},
(table) => [
index("my_index").on(table.field2), // Same name, different column!
]
);
const schemaV2 = { test_conflicts: tableV2 };
console.log("\n📦 Changing index definition with same name...");
await migrator.migrate("@elizaos/conflict-test-v1", schemaV2);
const indexDef = await db.execute(
sql`SELECT indexdef FROM pg_indexes
WHERE tablename = 'test_conflicts'
AND indexname = 'my_index'`
);
const definition = (indexDef.rows[0] as IndexDefRow).indexdef;
expect(definition).toContain("field2");
console.log(" ✅ Index redefined to use different column");
console.log(` ${definition}`);
});
});

Some files were not shown because too many files have changed in this diff Show More