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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,8 @@ graphify-out/cost.json
# 3. Set "Required status checks" or use "Branch restrictions" to limit who can push
# Or use pre-receive hook on GitHub with: git config commit.gpgsign true
# Then enforce via GitHub branch protection rules

# Turbo/tsc build caches (declared as task outputs in turbo.json).
# `*.tsbuildinfo` above does not match these, so they were tracked and every
# build dirtied the working tree.
.cache/
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,92 @@
# query

The central monorepo for club operations and digital infrastructure.

## Workspace layout

| Path | Contents |
| --- | --- |
| `sites/mainweb` | Public club site |
| `sites/hacklytics2027` | Hacklytics 2027 event site (static export) |
| `packages/db` | Drizzle schema, client, seed script |
| `packages/api` | tRPC routers |
| `packages/auth` | NextAuth configuration |
| `packages/ui`, `packages/consts` | Shared components and constants |
| `tooling/*` | Shared eslint / tailwind / tsconfig |

## Database

Postgres, accessed through [Drizzle ORM](https://orm.drizzle.team). Production
runs on **Neon** (serverless Postgres, `us-west-2`, pooled endpoint); the
connection is made with `pg.Pool` in `packages/db/src/client.ts`, with SSL
required in production and a max pool size of 10.

Configuration is a single environment variable:

```
DATABASE_URL=postgresql://<user>:<password>@<host>/<database>?sslmode=require
```

`packages/db/src/client.ts` logs a warning and leaves `db` as `null` when the
variable is absent rather than throwing, so builds that never touch the database
still succeed.

### Schema

Schemas live in `packages/db/src/schemas/` and are re-exported from
`schemas/index.ts`. Drizzle picks them up via `schema: "./src/schemas/**/*.ts"`
in `drizzle.config.ts`.

| File | Tables |
| --- | --- |
| `auth.ts` | `user`, `account`, `session`, `verificationToken` |
| `members.ts` | `user_profile`, `member`, `membership_history` |
| `admins.ts` | `admin` |
| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_event`, `hackathon_event_attendee` |
| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_map` |
| `events.ts` | `event`, `event_check_in` |
| `stripe.ts` | `stripe_payment`, `user_account_link` |
| `security.ts` | `audit_logs` (+ `security_severity` enum) |
| `settings.ts` | `system_settings` |

26 tables in total. Two entities anchor the graph:

- **`user`** — every identity-bearing table cascades from it: `account`,
`session`, `admin`, `user_profile`, `member`, `judge`, `event`,
`event_check_in`, `hackathon_team`, `hackathon_participant`,
`user_account_link`, and `stripe_payment.linked_user_id`.
- **`hackathon`** — every event-scoped table cascades from it: teams,
participants, projects, hackathon events, judges, judge assignments, judging
projects, judge queue, and maps. `member` is also scoped to a hackathon.

Nearly all foreign keys are `onDelete: "cascade"`, so deleting a user or a
hackathon removes its dependent rows rather than orphaning them.

### Working with the schema

```bash
pnpm --filter @query/db migrate:push # push schema changes to DATABASE_URL
pnpm --filter @query/db migrate:generate # emit SQL into packages/db/drizzle
pnpm --filter @query/db studio # Drizzle Studio
pnpm --filter @query/db db:seed # scripts/seed.ts
```

The project is **push-based**: `packages/db/drizzle/meta/_journal.json` has no
entries and there are no generated `.sql` files, so schema changes are applied
directly with `migrate:push` rather than through a migration history. If you
want reviewable migrations, switch to `migrate:generate` and commit the output.

### Local database

`docker-compose.yml` brings up a local Postgres with the same database name as
Neon, so only `DATABASE_URL` changes between the two:

```bash
docker compose up -d
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb \
pnpm --filter @query/db migrate:push
```

It publishes on host port **5433** to avoid colliding with a system Postgres,
and has a `pg_isready` healthcheck so `migrate:push` is not run against a
container that is still starting.
22 changes: 21 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
# Local Postgres for development.
#
# Production runs on Neon (serverless Postgres, pooled endpoint) — see README.
# The database name here matches the Neon database so switching between them is
# only a DATABASE_URL change, not a schema-qualified rewrite.
#
# docker compose up -d
# DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb pnpm --filter @query/db migrate:push
#
# Port 5433 on the host so this does not collide with a system Postgres on 5432.

services:
db:
image: postgres:15
container_name: monorepo-postgres
restart: unless-stopped
ports:
- "5433:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app_db
POSTGRES_DB: neondb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
# drizzle-kit push against a container that is still booting fails with a
# confusing connection error; wait for Postgres to actually accept queries.
test: ["CMD-SHELL", "pg_isready -U postgres -d neondb"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s

volumes:
pgdata:
13 changes: 13 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@
"value": "nosniff"
}
]
},
{
"source": "/sw.js",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
},
{
"key": "Service-Worker-Allowed",
"value": "/"
}
]
}
]
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"test": "vitest run packages/api"
},
"dependencies": {
"next": "16.2.11",
"next": "16.3.0",
"typescript": "^6.0.2"
},
"devDependencies": {
Expand Down
1 change: 0 additions & 1 deletion packages/api/.cache/tsbuildinfo.json

This file was deleted.

13 changes: 7 additions & 6 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,22 @@
"./middleware/cache": "./src/middleware/cache.ts",
"./middleware/http-security": "./src/middleware/http-security.ts",
"./middleware/security": "./src/middleware/security.ts",
"./trpc": "./src/trpc.ts"
"./trpc": "./src/trpc.ts",
"./pricing": "./src/services/pricing.ts"
},
"scripts": {
"lint": "eslint .",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc --noEmit",
"test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts"
},
"dependencies": {
"@query/auth": "workspace:*",
"@query/db": "workspace:*",
"@tanstack/react-query": "5.90.12",
"@trpc/client": "^11.17.0",
"@trpc/next": "^11.15.1",
"@trpc/react-query": "^11.15.1",
"@trpc/server": "^11.15.1",
"@trpc/client": "11.18.0",
"@trpc/next": "11.18.0",
"@trpc/react-query": "11.18.0",
"@trpc/server": "11.18.0",
"drizzle-orm": "0.45.2",
"image-size": "2.0.2",
"sanitize-html": "2.17.4",
Expand Down
173 changes: 173 additions & 0 deletions packages/api/src/.internal-tests/_db-tx-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { vi } from "vitest";

/**
* Transaction semantics for the file-level `@query/db` mocks.
*
* Not a test file — no `.test.` in the name, so vitest does not collect it.
*
* A plain `transaction: (cb) => cb(db)` runs two racing callers with no
* ordering and no rollback, which is an interleaving no database produces: both
* write before either reads back. Correct claim-then-verify and
* compare-and-set code then looks broken here, and the tempting "fix" is an
* in-process JS lock — which does nothing across the instances apphosting
* actually runs. This module models the two things that make those patterns
* work, so a test that passes here is testing the guarantee production relies
* on.
*
* Lives in one place because both halves are subtle and were about to be
* copied into a second test file.
*/

/**
* Rollback, modelled the only way a mock can: a stand-in write says how to undo
* itself, and a transaction that throws replays those undos in reverse.
*/
let undoStack: Array<() => void> | null = null;

/** Registers an undo for the write currently executing. No-op outside a tx. */
export const __onRollback = (undo: () => void) => undoStack?.push(undo);

/**
* Row locking, modelled per table.
*
* An UPDATE — or a SELECT ... FOR UPDATE — inside a transaction takes the row's
* exclusive lock and holds it until that transaction ends; a second transaction
* touching the same row blocks there and only then re-reads. Keyed by table
* rather than by row, so this over-serializes unrelated rows; that direction is
* safe (it can only make a test stricter than production), while the reverse
* would let real races pass.
*
* Module-scoped so locks outlive a single transaction, which is the point.
*/
const tableLocks = new Map<unknown, Promise<void>>();

type Mock = (...args: any[]) => any;

export interface TxMockHooks {
/**
* The db stand-in whose non-transactional builders the tx inherits.
* A getter, not the object: the mock factory is hoisted above the import it
* would read, so this can only be resolved once a transaction actually runs.
*/
base: () => unknown;
insert?: Mock;
update?: Mock;
delete?: Mock;
/**
* Backs `tx.select(...).from(...).where(...)`. Only wired when supplied, so
* files that do not exercise a locking read keep their existing select stub.
*/
select?: Mock;
}

export const createTransactionMock = ({
base,
insert,
update,
delete: del,
select,
}: TxMockHooks) =>
vi.fn().mockImplementation(async (callback: (tx: any) => unknown) => {
const heldTables = new Set<unknown>();
const held: Array<() => void> = [];
const undos: Array<() => void> = [];

const acquire = (table: unknown) => {
// A transaction already holding a lock keeps it: touching the same table
// twice must not queue behind itself.
if (heldTables.has(table)) return Promise.resolve();
heldTables.add(table);
const prior = tableLocks.get(table) ?? Promise.resolve();
let release!: () => void;
tableLocks.set(
table,
new Promise<void>((resolve) => (release = resolve)),
);
held.push(release);
return prior;
};

// Scoped tightly around each synchronous stand-in so interleaved
// transactions cannot collect each other's undos.
const withUndos = <T>(run: () => T): T => {
const outer = undoStack;
undoStack = undos;
try {
return run();
} finally {
undoStack = outer;
}
};

// The db stand-in is typed nullable at the import site; spreading null is
// an empty object, which is the right answer for a mock that never ran.
const tx: any = { ...(base() as object) };

if (update) {
tx.update = (...updateArgs: any[]) => ({
set: (...setArgs: any[]) => ({
where: (...wArgs: any[]) => {
const ran = acquire(updateArgs[0]).then(() =>
withUndos(() => update("update", updateArgs, setArgs, wArgs)),
);
return Object.assign(ran, { returning: () => ran });
},
}),
});
}

if (insert) {
// No lock: concurrent INSERTs do not block each other in Postgres, they
// collide on a unique index if at all. The undo is what matters — a row
// inserted by a transaction that later throws never existed.
tx.insert = (...insertArgs: any[]) => ({
values: (...valArgs: any[]) => {
const val = withUndos(() => insert("insert", insertArgs, valArgs));
const ran = Promise.resolve(val);
return Object.assign(ran, {
returning: () => ran,
onConflictDoUpdate: () => ({ returning: () => ran }),
});
},
});
}

if (del) {
tx.delete = (...deleteArgs: any[]) => ({
where: (...wArgs: any[]) => {
const ran = acquire(deleteArgs[0]).then(() =>
withUndos(() => del("delete", deleteArgs, wArgs)),
);
return Object.assign(ran, { returning: () => ran });
},
});
}

if (select) {
tx.select = (...selectArgs: any[]) => ({
from: (...fromArgs: any[]) => ({
// Lazy: `.for("update")` must be the thing that takes the lock, and
// eagerly building the unlocked promise would run the query twice.
where: (...wArgs: any[]) => {
const exec = (locking: boolean) =>
(locking ? acquire(fromArgs[0]) : Promise.resolve()).then(() =>
select("select", selectArgs, fromArgs, wArgs),
);
return {
then: (ok: any, err: any) => exec(false).then(ok, err),
for: () => exec(true),
};
},
}),
});
}

try {
return await callback(tx);
} catch (error) {
for (const undo of undos.reverse()) undo();
throw error;
} finally {
for (const release of held) release();
}
});
Loading
Loading