Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ jobs:
needs: changes
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5
env:
REDIS_TEST_URL: redis://127.0.0.1:6379/0
defaults:
run:
working-directory: apps/server
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The aggregate script assumes app dependencies are already installed and intentio

## Current limitations

- Server tests compile TypeScript but do not provide behavioral coverage.
- Server behavioral coverage is currently limited to Redis namespacing and scoped reset behavior.
- Collection Android and web have tracked pre-existing issues.
- Dashboard analysis has one baseline informational failure and little test coverage.
- Website check and formatting commands fail at the migration baseline; production build needs private environment configuration.
6 changes: 4 additions & 2 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ BASE_URL=http://localhost:3000
DATABASE_URL="postgresql://username:password@localhost:5432/postgres"


# Connection string for Redis database
REDIS_URL="redis://username:password@localhost:6379/0"
# Connection string for the local Lovat Redis logical database
REDIS_URL="redis://username:password@localhost:6379/1"
# Namespace for every Lovat-owned Redis key
REDIS_KEY_PREFIX="lovat:local:"

# Domain identifying the Auth0 tenant of the project. Leave unchanged for production set of accounts.
AUTH0_DOMAIN=lovat.us.auth0.com
Expand Down
2 changes: 1 addition & 1 deletion apps/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- Use Node.js 22.20.0 from `.nvmrc` and install with `npm ci`.
- Run `npm run build`, `npm test`, and `npm run lint` for code changes.
- `npm test` currently compiles TypeScript; do not describe it as behavioral coverage.
- `npm test` compiles TypeScript and runs the server's behavioral tests; describe coverage only for the behaviors those tests exercise.
- Prisma schema and migrations live under `prisma/`. Use reviewed migrations for schema changes and verify database URLs before destructive commands.
- PostgreSQL and Redis are required for normal local startup. Do not weaken authentication or source-team visibility to simplify testing.
- Treat report events, authentication headers, deep links, and analysis responses as cross-app contracts.
Expand Down
10 changes: 9 additions & 1 deletion apps/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ npm run dev

Fill the local `.env` without committing it. PostgreSQL and Redis are required to start the service; external integrations are optional only when the exercised code path permits.

The example configuration uses Redis logical database 1 and prefixes every key with `lovat:local:`. Keep both settings isolated from other applications. To remove only Lovat-owned cache entries, run:

```bash
npm run cache:reset
```

The reset scans the Lovat namespace twice around metadata deletion. This prevents a successful reset from leaving an untracked cache entry when a request writes during the operation. Stop the server first when practical; a failed reset should be retried after correcting the reported error.

## Checks

```bash
Expand All @@ -29,7 +37,7 @@ npm test
npm run lint
```

`npm test` currently verifies TypeScript compilation rather than behavioral coverage.
`npm test` compiles the server and runs its behavioral tests.

## Optional database restore

Expand Down
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "NODE_ENV=development tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/src/server.js --omit=dev",
"test": "tsc",
"cache:reset": "tsx src/resetCache.ts",
"test": "npm run build && node --test dist/test/*.test.js",
"lint": "eslint .",
"format": "prettier --write ."
},
Expand Down
14 changes: 9 additions & 5 deletions apps/server/src/lib/clearCache.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import prismaClient from "../prismaClient.js";
import { kv } from "../redisClient.js";
import { resetCacheState } from "./resetCacheState.js";

export const clearCache = async () => {
await prismaClient.cachedAnalysis.deleteMany();

await kv.flush();
export const resetCache = async (): Promise<void> => {
const deletedRedisKeys = await resetCacheState({
resetRedis: () => kv.reset(),
deleteMetadata: async () => {
await prismaClient.cachedAnalysis.deleteMany();
},
});

console.log("Cache cleared");
console.log(`Lovat cache reset (${deletedRedisKeys} Redis keys deleted)`);
};

export const invalidateCache = async (
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/lib/loadEnvironmentFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { config } from "dotenv";

export const loadEnvironmentFile = (path?: string): void => {
const result = config({ path, quiet: true });

if (
result.error &&
(result.error as NodeJS.ErrnoException).code !== "ENOENT"
) {
throw result.error;
}
};
92 changes: 92 additions & 0 deletions apps/server/src/lib/namespacedKv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
const RESET_BATCH_SIZE = 100;

type RedisInteger = `${number}` | number;
type RedisKey = Buffer | string;

export type RedisKeyValueClient = {
set(key: string, data: string, options?: { EX: number }): Promise<unknown>;
get(key: string): Promise<Buffer | string | null>;
del(keys: RedisKey[] | RedisKey): Promise<RedisInteger>;
incr(key: string): Promise<RedisInteger>;
expire(key: string, seconds: number): Promise<unknown>;
scanIterator(options: {
MATCH: string;
COUNT: number;
}): AsyncIterable<RedisKey[]>;
};

export type NamespacedKv = {
set(key: string, data: string): Promise<unknown>;
get(key: string): Promise<Buffer | string | null>;
del(keys: string[] | string): Promise<number>;
incr(key: string): Promise<number>;
exp(key: string, seconds: number): Promise<unknown>;
setEx(key: string, data: string, seconds: number): Promise<unknown>;
reset(): Promise<number>;
};

export const createNamespacedKv = (
redis: RedisKeyValueClient,
keyPrefix: string,
): NamespacedKv => {
if (["*", "?", "[", "]", "\\"].some((char) => keyPrefix.includes(char))) {
throw new Error("Redis key prefix cannot contain Redis glob characters");
}

const namespacedKey = (key: string): string => `${keyPrefix}${key}`;

const set = async (key: string, data: string): Promise<unknown> => {
return await redis.set(namespacedKey(key), data);
};

const get = async (key: string): Promise<Buffer | string | null> => {
return await redis.get(namespacedKey(key));
};

const del = async (keys: string[] | string): Promise<number> => {
const namespacedKeys = Array.isArray(keys)
? keys.map(namespacedKey)
: namespacedKey(keys);

return Number(await redis.del(namespacedKeys));
};

const incr = async (key: string): Promise<number> => {
return Number(await redis.incr(namespacedKey(key)));
};

const exp = async (key: string, seconds: number): Promise<unknown> => {
return await redis.expire(namespacedKey(key), seconds);
};

const setEx = async (
key: string,
data: string,
seconds: number,
): Promise<unknown> => {
return await redis.set(namespacedKey(key), data, { EX: seconds });
};

const reset = async (): Promise<number> => {
let deleted = 0;

for await (const keys of redis.scanIterator({
MATCH: `${keyPrefix}*`,
COUNT: RESET_BATCH_SIZE,
})) {
if (keys.length > 0) deleted += Number(await redis.del(keys));
}

return deleted;
};

return {
set,
get,
del,
incr,
exp,
setEx,
reset,
};
};
15 changes: 15 additions & 0 deletions apps/server/src/lib/resetCacheState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type CacheResetDependencies = {
resetRedis(): Promise<number>;
deleteMetadata(): Promise<void>;
};

export const resetCacheState = async ({
resetRedis,
deleteMetadata,
}: CacheResetDependencies): Promise<number> => {
let deletedRedisKeys = await resetRedis();
await deleteMetadata();
deletedRedisKeys += await resetRedis();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent cache writes after the final reset scan

When cache:reset runs while requests are still in flight, this final scan does not cover writes that occur after its SCAN cursor reaches zero. For example, a request already executing the schema-mismatch recovery path in analysisFunction.ts:113-121 can finish its calculation after both scans and rewrite the Redis value without recreating the CachedAnalysis row that deleteMetadata removed; subsequent data changes then cannot discover and invalidate that stale key. The reset can therefore report success while leaving an untracked cache entry unless writes are blocked or the server is guaranteed to be stopped.

Useful? React with 👍 / 👎.


return deletedRedisKeys;
};
15 changes: 15 additions & 0 deletions apps/server/src/lib/startServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type ServerStartupDependencies = {
initializeCache(): Promise<void>;
scheduleJobs(): Promise<void>;
listen(): void;
};

export const startServer = async ({
initializeCache,
scheduleJobs,
listen,
}: ServerStartupDependencies): Promise<void> => {
await initializeCache();
await scheduleJobs();
listen();
};
58 changes: 13 additions & 45 deletions apps/server/src/redisClient.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,23 @@
import { createClient } from "redis";
import { createNamespacedKv } from "./lib/namespacedKv.js";

const redis = createClient({ url: process.env.REDIS_URL })
.on("error", (err) => console.log("Redis Client Error", err))
.connect();
const DEFAULT_KEY_PREFIX = "lovat:";

const set = async (
key: string,
data: string,
): ReturnType<Awaited<typeof redis>["set"]> => {
return await (await redis).set(key, data);
const getKeyPrefix = (): string => {
return process.env.REDIS_KEY_PREFIX?.trim() || DEFAULT_KEY_PREFIX;
};

const get = async (key: string): ReturnType<Awaited<typeof redis>["get"]> => {
return await (await redis).get(key);
};

const del = async (
key: string[] | string,
): ReturnType<Awaited<typeof redis>["del"]> => {
return await (await redis).del(key);
};

const flush = async (): ReturnType<Awaited<typeof redis>["flushDb"]> => {
return await (await redis).flushDb();
};

const incr = async (key: string): ReturnType<Awaited<typeof redis>["incr"]> => {
return await (await redis).incr(key);
};
const redisClient = createClient({ url: process.env.REDIS_URL }).on(
"error",
(err) => console.log("Redis Client Error", err),
);

const exp = async (
key: string,
exp: number,
): ReturnType<Awaited<typeof redis>["expire"]> => {
return await (await redis).expire(key, exp);
};
export const kv = createNamespacedKv(redisClient, getKeyPrefix());

const setEx = async (
key: string,
data: string,
seconds: number,
): ReturnType<Awaited<typeof redis>["set"]> => {
return await (await redis).set(key, data, { EX: seconds });
export const connectRedis = async (): Promise<void> => {
if (!redisClient.isOpen) await redisClient.connect();
};

export const kv = {
set,
get,
del,
flush,
incr,
exp,
setEx,
export const closeRedis = async (): Promise<void> => {
if (redisClient.isOpen) await redisClient.close();
};
24 changes: 24 additions & 0 deletions apps/server/src/resetCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { loadEnvironmentFile } from "./lib/loadEnvironmentFile.js";

loadEnvironmentFile();

const [
{ resetCache },
{ default: prismaClient },
{ closeRedis, connectRedis },
] = await Promise.all([
import("./lib/clearCache.js"),
import("./prismaClient.js"),
import("./redisClient.js"),
]);

try {
await connectRedis();
await resetCache();
} finally {
try {
await prismaClient.$disconnect();
} finally {
await closeRedis();
}
}
16 changes: 10 additions & 6 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { app } from "./app.js";
import { startServer } from "./lib/startServer.js";
import scheduleJobs from "./lib/scheduleJobs.js";
import { clearCache } from "./lib/clearCache.js";
import { connectRedis } from "./redisClient.js";

const port = process.env.PORT || 3000;

await scheduleJobs();
await clearCache();

app.listen(port, () => {
console.log(`Server running on :${port}`);
await startServer({
initializeCache: connectRedis,
scheduleJobs,
listen: () => {
app.listen(port, () => {
console.log(`Server running on :${port}`);
});
},
});
Loading
Loading