Skip to content

Commit a5bef7c

Browse files
ethanjclaude
andauthored
fix: make docker smoke CI-runnable and side-by-side safe (#6)
* feat: port remaining atomicmemory-research PR #3 changes to core Five functional gaps and one test file identified by PR #3 audit: 1. docker-compose.yml: add restart: unless-stopped to postgres service (matches app restart policy; prevents DB death from crashing the stack) 2. src/db/repository-read.ts: add sourceSite + episodeId optional filters to listMemories() — dynamic parameterized WHERE clauses 3. src/db/memory-repository.ts: thread sourceSite + episodeId through repository wrapper 4. src/services/memory-crud.ts + memory-service.ts: thread the same params through the crud helper and service facade list() method 5. src/services/memory-ingest.ts: new performStoreVerbatim() — stores content as a single memory without fact extraction, for user-created text/file uploads 6. src/services/memory-service.ts: add storeVerbatim() facade method 7. src/routes/memories.ts: - Add UUID_REGEX constant - Add requireUuidParam() and optionalUuidQuery() helpers - UUID-validate /:id param on GET, DELETE, and /:id/audit routes (was previously letting "not-a-uuid" reach the DB as 500) - Add source_site and episode_id filters to GET /list - Add skip_extraction branch to POST /ingest/quick routing to storeVerbatim instead of quickIngest 8. src/__tests__/route-validation.test.ts: new 129-line test file covering UUID validation, filter behavior, and skip_extraction path. Mocks embedText to avoid hitting the real embedding provider with the CI placeholder OPENAI_API_KEY. Validation: - npx tsc --noEmit: clean - pnpm test: 876 passing, 0 failed (was 869; +7 new tests) - npx fallow --no-cache: 0 above threshold, maintainability 91.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: port atomicmemory-research PR #2 changes missed by extraction PR #2 merged into research main on 2026-04-14 ~09 UTC, but the core extraction (7a9b2d5) branched from research commit 3ac0471 which predates PR #2's merge commit 791c68e. Verified via `git merge-base --is-ancestor 791c68e 3ac0471` → not ancestor. Two functional/quality gaps ported: 1. src/services/memory-ingest.ts — fast-AUDN dedup now returns the existing memory ID on skip instead of null. Integration sync callers rely on this to link to the canonical memory when the ingested fact is a near-duplicate. 2. src/routes/route-errors.ts — server-side logging improvements: - String(err ?? 'Internal server error') coercion for non-Error throwables - [status] prefix in the log line for quick severity scanning - Full stack trace logged when available Deliberately NOT ported: PR #2's change to expose err.message to clients for 500s. Core's consolidated handler returns a generic 'Internal server error' which is the safer default and doesn't leak internals. Only the server-side log line gets richer. Validation: - npx tsc --noEmit: clean - pnpm test: 876/876 passing - npx fallow --no-cache: 0 above threshold, maintainability 91.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(smoke): make docker smoke CI-runnable and side-by-side safe --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cca9596 commit a5bef7c

11 files changed

Lines changed: 314 additions & 42 deletions

docker-compose.smoke.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
# Usage: docker compose -f docker-compose.yml -f docker-compose.smoke.yml up --build
55
services:
66
app:
7-
ports:
8-
- "${APP_PORT:-3060}:3050"
97
environment:
108
- EMBEDDING_PROVIDER=transformers
119
- EMBEDDING_MODEL=Xenova/all-MiniLM-L6-v2

docker-compose.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
services:
22
postgres:
33
image: pgvector/pgvector:pg17
4+
restart: unless-stopped
45
environment:
56
POSTGRES_USER: supermem
67
POSTGRES_PASSWORD: supermem
78
POSTGRES_DB: supermem
89
ports:
9-
- "5433:5432"
10+
- "${POSTGRES_PORT:-5433}:5432"
1011
volumes:
1112
- pgdata:/var/lib/postgresql/data
1213
healthcheck:
@@ -19,7 +20,7 @@ services:
1920
build: .
2021
restart: unless-stopped
2122
ports:
22-
- "3050:3050"
23+
- "${APP_PORT:-3050}:3050"
2324
environment:
2425
DATABASE_URL: postgresql://supermem:supermem@postgres:5432/supermem
2526
PORT: "3050"

scripts/docker-smoke-test.sh

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ set -euo pipefail
2323

2424
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
2525
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
26-
COMPOSE_PROJECT="supermem-smoke-test"
27-
APP_PORT=3060 # Use non-default port to avoid conflicts with dev server
26+
COMPOSE_PROJECT="${COMPOSE_PROJECT:-}"
27+
APP_PORT="${APP_PORT:-}"
28+
POSTGRES_PORT="${POSTGRES_PORT:-}"
2829
HEALTH_TIMEOUT=90
2930
HEALTH_INTERVAL=2
3031

@@ -42,6 +43,33 @@ log() { echo -e "${GREEN}[smoke]${NC} $*"; }
4243
warn() { echo -e "${YELLOW}[smoke]${NC} $*"; }
4344
fail() { echo -e "${RED}[FAIL]${NC} $*"; }
4445

46+
port_in_use() {
47+
local port="$1"
48+
lsof -n -P -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
49+
}
50+
51+
find_free_port() {
52+
local port="$1"
53+
while port_in_use "$port"; do
54+
port=$((port + 1))
55+
done
56+
echo "$port"
57+
}
58+
59+
resolve_port() {
60+
local requested="$1"
61+
local fallback="$2"
62+
if [[ -n "$requested" ]]; then
63+
if port_in_use "$requested"; then
64+
fail "Requested port is already in use: $requested"
65+
exit 1
66+
fi
67+
echo "$requested"
68+
return
69+
fi
70+
find_free_port "$fallback"
71+
}
72+
4573
assert_ok() {
4674
local name="$1"
4775
total=$((total + 1))
@@ -62,7 +90,7 @@ cleanup() {
6290
trap cleanup EXIT
6391

6492
# --- Pre-flight checks ---
65-
for cmd in docker curl jq; do
93+
for cmd in docker curl jq lsof; do
6694
if ! command -v "$cmd" &>/dev/null; then
6795
fail "Required command not found: $cmd"
6896
exit 1
@@ -76,11 +104,17 @@ fi
76104

77105
cd "$PROJECT_DIR"
78106

107+
APP_PORT="$(resolve_port "$APP_PORT" 3060)"
108+
POSTGRES_PORT="$(resolve_port "$POSTGRES_PORT" 5444)"
109+
if [[ -z "$COMPOSE_PROJECT" ]]; then
110+
COMPOSE_PROJECT="supermem-smoke-test-${APP_PORT}-${POSTGRES_PORT}"
111+
fi
112+
79113
# --- Build + Start ---
80-
log "Starting compose stack (project=$COMPOSE_PROJECT, port=$APP_PORT)..."
114+
log "Starting compose stack (project=$COMPOSE_PROJECT, app_port=$APP_PORT, postgres_port=$POSTGRES_PORT)..."
81115

82-
# Override the app port to avoid conflicts
83-
export APP_PORT
116+
# Override published ports to avoid conflicts with local dev stacks
117+
export APP_PORT POSTGRES_PORT
84118

85119
if [[ "${SKIP_BUILD:-}" == "1" ]]; then
86120
docker compose -p "$COMPOSE_PROJECT" \
@@ -154,10 +188,9 @@ fi
154188
# --- Test 4: Database connectivity (via stats endpoint) ---
155189
log "Test: database connectivity"
156190
stats_status=$(curl -sf -o /dev/null -w '%{http_code}' \
157-
-X POST "$BASE/memories/stats" \
158-
-H "Content-Type: application/json" \
159-
-d '{"user_id":"smoke-test-user"}')
160-
assert_ok "POST /memories/stats returns 200 (DB connected)" \
191+
-G "$BASE/memories/stats" \
192+
--data-urlencode "user_id=smoke-test-user")
193+
assert_ok "GET /memories/stats returns 200 (DB connected)" \
161194
'[ "$stats_status" = "200" ]'
162195

163196
# --- Test 5: Quick ingest endpoint (no LLM required — embedding-only dedup) ---
@@ -171,7 +204,7 @@ ingest_response=$(curl -sf -w '\n%{http_code}' \
171204
"source_site": "docker-smoke-test"
172205
}')
173206
ingest_status=$(echo "$ingest_response" | tail -1)
174-
ingest_body=$(echo "$ingest_response" | head -n -1)
207+
ingest_body=$(echo "$ingest_response" | sed '$d')
175208
assert_ok "POST /memories/ingest/quick returns 200" \
176209
'[ "$ingest_status" = "200" ]'
177210
assert_ok "Ingest stored at least 1 memory" \
@@ -188,7 +221,7 @@ search_response=$(curl -sf -w '\n%{http_code}' \
188221
"source_site": "docker-smoke-test"
189222
}')
190223
search_status=$(echo "$search_response" | tail -1)
191-
search_body=$(echo "$search_response" | head -n -1)
224+
search_body=$(echo "$search_response" | sed '$d')
192225
assert_ok "POST /memories/search returns 200" \
193226
'[ "$search_status" = "200" ]'
194227
assert_ok "Search returns at least 1 result" \
@@ -205,10 +238,10 @@ assert_ok "POST /memories/reset-source returns 200" \
205238

206239
# --- Test 8: Input validation ---
207240
log "Test: input validation"
208-
bad_ingest_status=$(curl -sf -o /dev/null -w '%{http_code}' \
241+
bad_ingest_status=$(curl -s -o /dev/null -w '%{http_code}' \
209242
-X POST "$BASE/memories/ingest" \
210243
-H "Content-Type: application/json" \
211-
-d '{"user_id":"x"}' 2>/dev/null || echo "400")
244+
-d '{"user_id":"x"}')
212245
assert_ok "Missing required fields returns 400" \
213246
'[ "$bad_ingest_status" = "400" ]'
214247

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Route-level validation tests for memory API endpoints.
3+
* Tests UUID validation on param/query inputs and filter behavior
4+
* on the list endpoint. Requires DATABASE_URL in .env.test.
5+
*/
6+
7+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
8+
9+
// Mock embedText to avoid hitting the real embedding provider in CI where
10+
// OPENAI_API_KEY is a placeholder. Returns a deterministic zero vector
11+
// matching the configured embedding dimensions.
12+
vi.mock('../services/embedding.js', async (importOriginal) => {
13+
const actual = await importOriginal<typeof import('../services/embedding.js')>();
14+
return {
15+
...actual,
16+
embedText: vi.fn(async () => {
17+
const { config: cfg } = await import('../config.js');
18+
return new Array(cfg.embeddingDimensions).fill(0);
19+
}),
20+
};
21+
});
22+
23+
import { pool } from '../db/pool.js';
24+
import { config } from '../config.js';
25+
import { MemoryRepository } from '../db/memory-repository.js';
26+
import { ClaimRepository } from '../db/claim-repository.js';
27+
import { MemoryService } from '../services/memory-service.js';
28+
import { createMemoryRouter } from '../routes/memories.js';
29+
import express from 'express';
30+
import { readFileSync } from 'node:fs';
31+
import { resolve, dirname } from 'node:path';
32+
import { fileURLToPath } from 'node:url';
33+
34+
const __dirname = dirname(fileURLToPath(import.meta.url));
35+
const TEST_USER = 'route-validation-test-user';
36+
const VALID_UUID = '00000000-0000-0000-0000-000000000001';
37+
const INVALID_UUID = 'not-a-uuid';
38+
39+
let server: ReturnType<typeof app.listen>;
40+
let baseUrl: string;
41+
const app = express();
42+
app.use(express.json());
43+
44+
beforeAll(async () => {
45+
const raw = readFileSync(resolve(__dirname, '../db/schema.sql'), 'utf-8');
46+
const sql = raw.replace(/\{\{EMBEDDING_DIMENSIONS\}\}/g, String(config.embeddingDimensions));
47+
await pool.query(sql);
48+
49+
const repo = new MemoryRepository(pool);
50+
const claimRepo = new ClaimRepository(pool);
51+
const service = new MemoryService(repo, claimRepo);
52+
app.use('/memories', createMemoryRouter(service));
53+
54+
await new Promise<void>((resolve) => {
55+
server = app.listen(0, () => {
56+
const addr = server.address();
57+
const port = typeof addr === 'object' && addr ? addr.port : 0;
58+
baseUrl = `http://localhost:${port}`;
59+
resolve();
60+
});
61+
});
62+
});
63+
64+
afterAll(async () => {
65+
await new Promise<void>((resolve) => server.close(() => resolve()));
66+
await pool.end();
67+
});
68+
69+
describe('GET /memories/:id — UUID validation', () => {
70+
it('returns 400 for an invalid UUID', async () => {
71+
const res = await fetch(`${baseUrl}/memories/${INVALID_UUID}?user_id=${TEST_USER}`);
72+
expect(res.status).toBe(400);
73+
const body = await res.json();
74+
expect(body.error).toMatch(/valid UUID/);
75+
});
76+
77+
it('returns 404 for a valid but non-existent UUID', async () => {
78+
const res = await fetch(`${baseUrl}/memories/${VALID_UUID}?user_id=${TEST_USER}`);
79+
expect(res.status).toBe(404);
80+
});
81+
});
82+
83+
describe('DELETE /memories/:id — UUID validation', () => {
84+
it('returns 400 for an invalid UUID', async () => {
85+
const res = await fetch(`${baseUrl}/memories/${INVALID_UUID}?user_id=${TEST_USER}`, {
86+
method: 'DELETE',
87+
});
88+
expect(res.status).toBe(400);
89+
const body = await res.json();
90+
expect(body.error).toMatch(/valid UUID/);
91+
});
92+
});
93+
94+
describe('POST /memories/ingest/quick — skip_extraction (storeVerbatim)', () => {
95+
it('stores a single memory without extraction when skip_extraction is true', async () => {
96+
const res = await fetch(`${baseUrl}/memories/ingest/quick`, {
97+
method: 'POST',
98+
headers: { 'Content-Type': 'application/json' },
99+
body: JSON.stringify({
100+
user_id: TEST_USER,
101+
conversation: 'Verbatim content that should not be extracted into facts.',
102+
source_site: 'verbatim-test',
103+
source_url: 'https://example.com/verbatim',
104+
skip_extraction: true,
105+
}),
106+
});
107+
expect(res.status).toBe(200);
108+
const body = await res.json();
109+
expect(body.memoriesStored).toBe(1);
110+
expect(body.memoryIds).toHaveLength(1);
111+
});
112+
});
113+
114+
describe('GET /memories/list — source_site filter', () => {
115+
it('returns memories filtered by source_site', async () => {
116+
const res = await fetch(
117+
`${baseUrl}/memories/list?user_id=${TEST_USER}&source_site=test-site`,
118+
);
119+
expect(res.status).toBe(200);
120+
const body = await res.json();
121+
expect(body).toHaveProperty('memories');
122+
expect(body).toHaveProperty('count');
123+
});
124+
});
125+
126+
describe('GET /memories/list — episode_id filter', () => {
127+
it('returns 400 for an invalid episode_id', async () => {
128+
const res = await fetch(
129+
`${baseUrl}/memories/list?user_id=${TEST_USER}&episode_id=${INVALID_UUID}`,
130+
);
131+
expect(res.status).toBe(400);
132+
const body = await res.json();
133+
expect(body.error).toMatch(/valid UUID/);
134+
});
135+
136+
it('accepts a valid episode_id UUID', async () => {
137+
const res = await fetch(
138+
`${baseUrl}/memories/list?user_id=${TEST_USER}&episode_id=${VALID_UUID}`,
139+
);
140+
expect(res.status).toBe(200);
141+
const body = await res.json();
142+
expect(body).toHaveProperty('memories');
143+
});
144+
});

src/db/memory-repository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@ export class MemoryRepository {
161161
return getMemoryWithClient(client, id, userId, true);
162162
}
163163

164-
async listMemories(userId: string, limit: number = 20, offset: number = 0) {
165-
return listMemories(this.pool, userId, limit, offset);
164+
async listMemories(userId: string, limit: number = 20, offset: number = 0, sourceSite?: string, episodeId?: string) {
165+
return listMemories(this.pool, userId, limit, offset, sourceSite, episodeId);
166166
}
167167

168168
async listMemoriesInWorkspace(workspaceId: string, limit: number = 20, offset: number = 0) {

src/db/repository-read.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,23 @@ export async function getMemoryWithClient(
5555
return result.rows[0] ? normalizeMemoryRow(result.rows[0]) : null;
5656
}
5757

58-
export async function listMemories(pool: pg.Pool, userId: string, limit: number, offset: number): Promise<MemoryRow[]> {
58+
export async function listMemories(pool: pg.Pool, userId: string, limit: number, offset: number, sourceSite?: string, episodeId?: string): Promise<MemoryRow[]> {
59+
const params: unknown[] = [userId, limit, offset];
60+
let extraClauses = '';
61+
if (sourceSite) {
62+
params.push(sourceSite);
63+
extraClauses += ` AND source_site = $${params.length}`;
64+
}
65+
if (episodeId) {
66+
params.push(episodeId);
67+
extraClauses += ` AND episode_id = $${params.length}`;
68+
}
5969
const result = await pool.query(
6070
`SELECT * FROM memories
6171
WHERE user_id = $1 AND deleted_at IS NULL AND expired_at IS NULL AND status = 'active'
62-
AND workspace_id IS NULL
72+
AND workspace_id IS NULL${extraClauses}
6373
ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
64-
[userId, limit, offset],
74+
params,
6575
);
6676
return result.rows.map(normalizeMemoryRow);
6777
}

0 commit comments

Comments
 (0)