Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## General Behaviour

- **Ask questions if unsure, do not assume anything.** When requirements are ambiguous, ask for clarification before writing code.
- **Keep files under 150 lines** (soft limit). Files above 200 lines must be refactored into smaller modules (hard limit).
- **Keep files under 150-200 lines** (soft limit). Files above 250 lines must be refactored into smaller modules (hard limit).
- **No Python.** For helper scripts, use Node.js (plain `.mjs` files). Never reach for Python, shell scripts beyond simple one-liners, or other runtimes.
- **Do not edit auto-generated files.** Files like `routeTree.gen.ts` (TanStack Router), `worker-configuration.d.ts`, or any file with a `// This file is auto-generated` header must never be manually edited — they are overwritten by tooling.
- **Do not edit shadcn/ui files.** Files under `src/components/ui/` are installed and managed by the shadcn CLI. Never modify them — override styles at the call site instead.
Expand Down
3 changes: 2 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"@logtape/logtape": "^2.0.5",
"drizzle-orm": "^0.45.2",
"jose": "^6.2.3",
"neverthrow": "^8.2.0"
"neverthrow": "^8.2.0",
"sqlite3-parser": "^0.7.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
Expand Down
11 changes: 11 additions & 0 deletions packages/server/src/test-utils/createTestDb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { DurableObjectStorage } from "@cloudflare/workers-types";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/durable-sqlite";
import { createMockDOStorage } from "./mockDOStorage";

export function createTestDb<TSchema extends Record<string, unknown>>(schema: TSchema) {
const sqlite = new Database(":memory:");
const storage = createMockDOStorage(sqlite);
const db = drizzle(storage as unknown as DurableObjectStorage, { schema });
return { db, sqlite, storage };
}
110 changes: 110 additions & 0 deletions packages/server/src/test-utils/mockDOStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import DatabaseCtor from "better-sqlite3";

type Database = InstanceType<typeof DatabaseCtor>;

export type SqlStorageCursor<T = unknown> = {
toArray(): T[];
next(): IteratorResult<T>;
raw(): SqlStorageCursor<unknown[]>;
[Symbol.iterator](): IterableIterator<T>;
};

export type MockDOStorage = {
sql: {
exec<T = unknown>(sql: string, ...bindings: unknown[]): SqlStorageCursor<T>;
databaseSize: number;
};
transactionSync<T>(callback: () => T): T;
};

function makeEmptyCursor<T>(): SqlStorageCursor<T> {
const doneResult: IteratorResult<T> = { done: true, value: undefined };
const emptyIter: IterableIterator<T> = {
next: () => doneResult,
[Symbol.iterator]: () => emptyIter,
};
return {
toArray: () => [],
next: () => doneResult,
raw: () => makeEmptyCursor<unknown[]>(),
[Symbol.iterator]: () => emptyIter,
};
}

function makeCursor<T>(
sqlite: Database,
sql: string,
params: unknown[],
raw = false,
): SqlStorageCursor<T> {
const stmt = sqlite.prepare(sql);
if (raw) stmt.raw(true);

let rows: T[];
try {
rows = (params.length > 0 ? stmt.all(...params) : stmt.all()) as T[];
} catch {
// Non-SELECT statement (INSERT, UPDATE, DELETE, CREATE, etc.)
if (params.length > 0) {
stmt.run(...params);
} else {
stmt.run();
}
return makeEmptyCursor<T>();
}

let index = 0;
const iter: IterableIterator<T> = {
next() {
if (index < rows.length) {
return { done: false, value: rows[index++] } as IteratorResult<T>;
}
return { done: true, value: undefined } as IteratorResult<T>;
},
[Symbol.iterator]() {
return iter;
},
};

return {
toArray() {
return rows;
},
next() {
return iter.next();
},
raw() {
return makeCursor(sqlite, sql, params, true);
},
[Symbol.iterator]() {
return iter;
},
};
}

export function createMockDOStorage(sqlite: Database): MockDOStorage {
return {
sql: {
exec<T>(sql: string, ...params: unknown[]) {
return makeCursor<T>(sqlite, sql, params);
},
get databaseSize() {
try {
const pageCount = sqlite.prepare("PRAGMA page_count").get() as {
page_count: number;
};
const pageSize = sqlite.prepare("PRAGMA page_size").get() as {
page_size: number;
};
return pageCount.page_count * pageSize.page_size;
} catch {
return 0;
}
},
},
transactionSync<T>(callback: () => T): T {
const tx = sqlite.transaction(callback);
return tx();
},
};
}
7 changes: 0 additions & 7 deletions packages/server/src/tools/createMutationProxy.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { recordMutationWithCascades } from "./recordMutation";
import { createQueryProxy, type ProxyConfig } from "./createQueryProxy";

export function createMutationProxy(
builder: Record<string, unknown>,
warnings: string[],
mutationType: "update" | "delete",
tableName?: string,
tablesWritten?: Set<string>,
cascadeGraph?: Map<string, Set<string>>,
initialState = { whereSet: false, withoutWhereSet: false },
): unknown {
const config: ProxyConfig = {
Expand All @@ -29,9 +25,6 @@ export function createMutationProxy(
`[EdgePod] ${mutationType.toUpperCase()} without WHERE is blocked. If intentional, chain .withoutWhere().`,
);
}
if (tableName && tableName !== "unknown" && tablesWritten) {
recordMutationWithCascades(tableName, tablesWritten, cascadeGraph ?? new Map());
}
return target[prop](...args);
},
};
Expand Down
108 changes: 12 additions & 96 deletions packages/server/src/tools/createSelectProxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createSelectProxy } from "./createSelectProxy";
import { hashTableName } from "./hashTableName";
import type { EdgePodSessionMap } from "../types";

vi.mock("drizzle-orm", () => ({
getTableName: vi.fn((t: { name?: string } | null) => t?.name ?? "unknown"),
}));

function createMockBuilder(
options: { resultData?: Record<string, unknown>[]; limit?: number } = {},
Expand All @@ -28,15 +22,11 @@ function createMockBuilder(
from: vi.fn(function () {
return builder;
}),
leftJoin: vi.fn(function (_table: unknown) {
const opts: { resultData: Record<string, unknown>[]; limit?: number } = { resultData };
if (currentLimit !== undefined) opts.limit = currentLimit;
return createMockBuilder(opts);
leftJoin: vi.fn(function () {
return builder;
}),
innerJoin: vi.fn(function (_table: unknown) {
const opts: { resultData: Record<string, unknown>[]; limit?: number } = { resultData };
if (currentLimit !== undefined) opts.limit = currentLimit;
return createMockBuilder(opts);
innerJoin: vi.fn(function () {
return builder;
}),
rightJoin: vi.fn(function () {
return builder;
Expand All @@ -54,29 +44,16 @@ function createMockBuilder(
return builder;
}

function createMockJoinTable() {
return { name: "joined_table" };
}

describe("createSelectProxy", () => {
let tablesRead: Set<string>;
let warnings: string[];
let activeSessions: EdgePodSessionMap;
const sessionId = "test-session";

beforeEach(() => {
tablesRead = new Set();
warnings = [];
activeSessions = new Map();
activeSessions.set(sessionId, {
socket: {} as WebSocket,
listeningToTables: new Set(),
});
});

it("auto-applies max limit when none set", async () => {
const builder = createMockBuilder({ resultData: Array(2000).fill({ id: 1 }) });
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);
const proxy = createSelectProxy(builder, warnings, 1000);

const result = await proxy;

Expand All @@ -85,7 +62,7 @@ describe("createSelectProxy", () => {

it("respects user-set limit under max", async () => {
const builder = createMockBuilder({ resultData: Array(100).fill({ id: 1 }) });
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);
const proxy = createSelectProxy(builder, warnings, 1000);

const withLimit = proxy.limit(50);
const result = await withLimit;
Expand All @@ -95,7 +72,7 @@ describe("createSelectProxy", () => {

it("caps limit at max", async () => {
const builder = createMockBuilder({ resultData: Array(5000).fill({ id: 1 }) });
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);
const proxy = createSelectProxy(builder, warnings, 1000);

const withLimit = proxy.limit(5000);
const result = await withLimit;
Expand All @@ -105,7 +82,7 @@ describe("createSelectProxy", () => {

it("adds warning when user limit exceeds max", async () => {
const builder = createMockBuilder({ resultData: [] });
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);
const proxy = createSelectProxy(builder, warnings, 1000);

await proxy.limit(5000);

Expand All @@ -114,29 +91,9 @@ describe("createSelectProxy", () => {
expect(warnings[0]).toContain("1000");
});

it("tracks table reads on join methods", () => {
const builder = createMockBuilder();
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);

const joinTable = createMockJoinTable();
proxy.leftJoin(joinTable, {});

expect(tablesRead.has("joined_table")).toBe(true);
});

it("tracks table reads on innerJoin", () => {
const builder = createMockBuilder();
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);

const joinTable = createMockJoinTable();
proxy.innerJoin(joinTable, {});

expect(tablesRead.has("joined_table")).toBe(true);
});

it("adds warning when result hits max limit", async () => {
const builder = createMockBuilder({ resultData: Array(1000).fill({ id: 1 }) });
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);
const proxy = createSelectProxy(builder, warnings, 1000);

await proxy;

Expand All @@ -147,57 +104,16 @@ describe("createSelectProxy", () => {

it("does not add warning when result is under limit", async () => {
const builder = createMockBuilder({ resultData: Array(100).fill({ id: 1 }) });
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);
const proxy = createSelectProxy(builder, warnings, 1000);

await proxy;

expect(warnings).toHaveLength(0);
});

it("tracks table reads on rightJoin", () => {
const builder = createMockBuilder();
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);

const joinTable = createMockJoinTable();
proxy.rightJoin(joinTable, {});

expect(tablesRead.has("joined_table")).toBe(true);
});

it("tracks table reads on fullJoin", () => {
const builder = createMockBuilder();
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);

const joinTable = createMockJoinTable();
proxy.fullJoin(joinTable, {});

expect(tablesRead.has("joined_table")).toBe(true);
});

it("tracks table reads on from", () => {
const builder = createMockBuilder();
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);

const joinTable = createMockJoinTable();
proxy.from(joinTable, {});

expect(tablesRead.has("joined_table")).toBe(true);
});

it("registers listening tables on session for joins", () => {
const builder = createMockBuilder();
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);

const joinTable = createMockJoinTable();
proxy.leftJoin(joinTable, {});

const session = activeSessions.get(sessionId);
expect(session?.listeningToTables.has(hashTableName("joined_table"))).toBe(true);
});

it("preserves proxy through chained method calls", () => {
const builder = createMockBuilder();
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);
const proxy = createSelectProxy(builder, warnings, 1000);

const withWhere = proxy.where({ id: 1 });
expect(withWhere).toBeDefined();
Expand All @@ -206,7 +122,7 @@ describe("createSelectProxy", () => {

it("original proxy still applies max limit after .limit() on a branch", async () => {
const builder = createMockBuilder({ resultData: Array(2000).fill({ id: 1 }) });
const proxy = createSelectProxy(builder, sessionId, activeSessions, tablesRead, warnings, 1000);
const proxy = createSelectProxy(builder, warnings, 1000);

proxy.limit(50);
const result = await proxy;
Expand Down
Loading