Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
144a4d8
fix(api): clear the 30 no-unnecessary-condition lint errors.
m-murphy Oct 6, 2026
6720d9d
chore(api): lint against async database.transaction callbacks.
m-murphy Oct 6, 2026
c0262db
fix(web): clear the 64 no-unnecessary-condition lint errors.
m-murphy Oct 6, 2026
4c75809
chore: run lint and tests in CI and pre-commit, and point agents at p…
m-murphy Oct 6, 2026
d84fafe
test(db): fail when initial_schema.sql falls behind schema.ts.
m-murphy Oct 6, 2026
a307f1a
docs(agents): trim the domain doc and add the steps agents kept redis…
m-murphy Oct 6, 2026
ac6e111
fix(web): enforce the React hooks lint rules.
m-murphy Oct 6, 2026
ba434b3
chore: ignore local database copies, audit notes, and worktrees.
m-murphy Oct 6, 2026
34ba32f
test(db): close the snapshot test's database and note why it skips th…
m-murphy Oct 6, 2026
1e7285b
test(db): compare the snapshot's indexes, foreign keys, NOT NULL and …
m-murphy Oct 6, 2026
14eb9fd
chore(api): lint any transaction callback that returns a Promise, in …
m-murphy Oct 6, 2026
c524782
refactor(api): take an insert's returned row through one insertedRow …
m-murphy Oct 6, 2026
c7d696e
refactor(api): branch on every collection type explicitly when deleti…
m-murphy Oct 6, 2026
97b0327
refactor(web): list the command dependencies once and rebuild command…
m-murphy Oct 6, 2026
6c761b7
refactor(web): fall back to one shared empty list instead of memoizin…
m-murphy Oct 6, 2026
34f1167
refactor(web): share the box and bag sheet list and expand state in u…
m-murphy Oct 7, 2026
83fd885
fix(web): rerun the batch-definition and allowed-container-type effec…
m-murphy Oct 7, 2026
c1d105f
fix(web): set up box and bag sheet expansion on load or collection ch…
m-murphy Oct 7, 2026
3a511d6
fix(web): read chart colors for the theme being rendered and drop the…
m-murphy Oct 7, 2026
b8e5a0a
refactor(web): let the ContainerTypeUnitsManager effect call its load…
m-murphy Oct 7, 2026
c7b6e98
test(web): stub scrollIntoView in the web test setup instead of guard…
m-murphy Oct 7, 2026
c8938b0
style(web): drop trailing whitespace the lint fixes left behind.
m-murphy Oct 7, 2026
a14d39b
fix(web): say "Control definition not found" when the batch wizard's …
m-murphy Oct 7, 2026
f6f92fa
Merge branch 'fix/audit-medium' into chore/agent-guardrails
m-murphy Oct 7, 2026
ced8760
fix(ci): cancel superseded Checks runs only on pull requests, not on …
m-murphy Oct 30, 2026
2aa411d
fix(ci): refuse partially staged files in pre-commit instead of linti…
m-murphy Oct 30, 2026
81bf8ec
test(db): say why the snapshot test opens its database without openOp…
m-murphy Oct 30, 2026
a5ce4bf
refactor(web): drop the scrollIntoView guard in CollectionSelectOrCre…
m-murphy Oct 30, 2026
be6cad4
refactor(web): pick the default scanner configuration through one def…
m-murphy Oct 30, 2026
b32b4ac
refactor(web): gate CollectionMove and ControlBatchWizard behind one …
m-murphy Oct 30, 2026
e78d6b7
Merge branch 'fix/audit-medium' into chore/agent-guardrails
m-murphy Oct 30, 2026
cacf56f
test(lint): type-check each rule test case as its own code when CI=true.
m-murphy Oct 30, 2026
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
34 changes: 32 additions & 2 deletions .githooks/pre-commit-checks.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Pre-commit checks mirroring CI compile steps. Runs typecheck on packages
# touched by staged files so Docker `bun run build` (tsc) failures are caught locally.
# Pre-commit checks mirroring CI. Typechecks packages touched by staged files
# and lints the staged API/web source files (refusing ones with unstaged edits).
set -euo pipefail

ROOT="$(git rev-parse --show-toplevel)"
Expand All @@ -10,6 +10,8 @@ needs_contract=false
needs_api=false
needs_web=false
has_staged=false
api_lint_files=()
web_lint_files=()

while IFS= read -r file; do
[[ -z "$file" ]] && continue
Expand All @@ -22,9 +24,11 @@ while IFS= read -r file; do
;;
packages/api/*)
needs_api=true
[[ "$file" == packages/api/src/*.ts ]] && api_lint_files+=("${file#packages/api/}")
;;
packages/web/*)
needs_web=true
[[ "$file" == packages/web/src/*.ts || "$file" == packages/web/src/*.tsx ]] && web_lint_files+=("${file#packages/web/}")
;;
Dockerfile|.dockerignore|package.json|bun.lock|packages/*/package.json|packages/*/tsconfig*.json)
needs_contract=true
Expand All @@ -38,6 +42,23 @@ if ! $has_staged || { ! $needs_contract && ! $needs_api && ! $needs_web; }; then
exit 0
fi

# eslint reads files from disk, and the typed rules can't lint a staged blob over stdin (they take
# type information from the file on disk), so a file with unstaged edits would be linted as it is
# in the working tree rather than as it will be committed. Refuse those instead.
partially_staged=()
for file in ${api_lint_files[@]+"${api_lint_files[@]}"}; do
git diff --quiet -- "packages/api/$file" || partially_staged+=("packages/api/$file")
done
for file in ${web_lint_files[@]+"${web_lint_files[@]}"}; do
git diff --quiet -- "packages/web/$file" || partially_staged+=("packages/web/$file")
done
if (( ${#partially_staged[@]} > 0 )); then
echo "pre-commit: these files have unstaged changes, so the lint would check the working tree, not the commit:" >&2
printf ' %s\n' "${partially_staged[@]}" >&2
echo "Stage them fully, or set the unstaged edits aside (git stash push --keep-index) and commit again." >&2
exit 1
fi

echo "pre-commit: running typecheck for staged TypeScript changes..."

if $needs_contract; then
Expand All @@ -51,3 +72,12 @@ if $needs_web; then
fi

echo "pre-commit: typecheck passed."

if (( ${#api_lint_files[@]} > 0 )); then
echo "pre-commit: linting staged API files..."
(cd packages/api && bunx eslint --no-warn-ignored "${api_lint_files[@]}")
fi
if (( ${#web_lint_files[@]} > 0 )); then
echo "pre-commit: linting staged web files..."
(cd packages/web && bunx eslint --no-warn-ignored "${web_lint_files[@]}")
fi
37 changes: 37 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Checks

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

# A newer push to a PR supersedes its older run; runs on main each check a different merge, so
# they all finish.
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
# Matches the oven/bun:1 images in the Dockerfile.
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile

# Typecheck, lint, build, unit tests, and the docs stale-claims check.
- name: Verify
run: bun run ci:verify
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ build/
*.sqlite
*.sqlite-shm
*.sqlite-wal
# Renamed copies, e.g. sampledb.sqlite.old, sampledb.sqlite-shm.old, sampledb.sqlite.old-wal
*.sqlite*.old
*.sqlite.old-*
backup_dbs/
litestream-replicas/

# Python
Expand Down Expand Up @@ -77,3 +81,7 @@ scripts/
!packages/docs/scripts/

test-csvs/

# Local scratch: audit notes and agent worktrees
.audit/
.worktrees/
19 changes: 13 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ Canonical triage roles mapped to GitHub labels; see `docs/agents/triage-labels.m

### Domain docs

Single-context layout — `CONTEXT.md` and `docs/adr/` at repo root when present. See `docs/agents/domain.md`.
`CONTEXT.md` (glossary) and `docs/adr/` (decisions) at repo root. See `docs/agents/domain.md`.

### Package docs

- Writing an API test (test database, factories, authenticated routes): `packages/api/src/__tests__/README.md`
- Writing a web test (render helper, API mocks, React Query hooks): `packages/web/src/__tests__/README.md`
- Changing the schema, a migration, or a transaction: `packages/api/src/db/README.md`
- Finding the code behind a UI label: `packages/web/src/components/Sidebar.tsx` (label → path) → `packages/web/src/App.tsx` (path → page) → the page's hooks and `lib/` → `packages/api/src/routes` → `packages/api/src/lib`

### Tests

Expand All @@ -20,26 +27,26 @@ Single-context layout — `CONTEXT.md` and `docs/adr/` at repo root when present
| `@sampledb/api` | `cd packages/api && bun test src/...` | **Bun** (needs `bun:sqlite`) |
| repo root | `bun run test` | both, via package scripts |

**Web tests must use `bun run test` (Vitest), not `bun test`.** Bun's runner hangs on React component tests (no `vi.importActual`, no jsdom). Piping a hung process to `tail` blocks until EOF — avoid `| tail` on web tests unless you know the runner exits.
**Run web tests with `bun run test` (Vitest).** It runs `vitest run`, which exits on its own, so piping to `tail` or `grep` is safe. Bun's own runner (`bun test`) hangs on React component tests.

Example (completes in ~1s):

```bash
cd packages/web && bun run test -- src/components/__tests__/BulkImportFlow.test.tsx
```

### Pre-commit hooks
### Pre-commit hooks and CI

CI builds the Docker image with `bun run build`, which runs `tsc` on API and web. Type errors that tests miss will fail the nightly build.
The `Checks` workflow runs `bun run ci:verify` on every PR: typecheck, lint, build, unit tests, and the docs stale-claims check. Lint is clean, so any lint error is yours.

Install repo hooks once per clone:

```bash
bun run setup:hooks
```

The pre-commit hook runs `typecheck` on `@sampledb/contract`, `@sampledb/api`, and/or `@sampledb/web` when staged files touch those packages (or Docker/build config). To mirror CI fully before pushing:
The pre-commit hook runs `typecheck` on `@sampledb/contract`, `@sampledb/api`, and/or `@sampledb/web` when staged files touch those packages (or Docker/build config), and lints the staged API/web source files. It refuses a source file with unstaged edits, since eslint would lint the working-tree copy. To mirror CI fully before pushing:

```bash
bun run ci:verify # typecheck + build all packages
bun run ci:verify
```
48 changes: 7 additions & 41 deletions docs/agents/domain.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,17 @@
# Domain Docs

How the engineering skills should consume this repo's domain documentation when exploring the codebase.
Read these before naming or changing domain concepts:

## Before exploring, read these

- **`CONTEXT.md`** at the repo root — the canonical domain glossary (entity names, hierarchies, scope). Read this before naming concepts in issues, tests, or refactors.
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.

`CONTEXT.md` exists in this repo. Start there for vocabulary; use the guides under `packages/docs/` for user-facing workflow detail.

## File structure

Single-context repo (most repos):

```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```

Multi-context repo (presence of `CONTEXT-MAP.md` at the root):

```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
- **`CONTEXT.md`** is the domain glossary: entity names, hierarchies, and scope.
- **`docs/adr/`** records design decisions. Read the ADRs that touch the area you're changing.
- **`packages/docs/`** holds the user guide, for user-facing workflow detail.

## Use the glossary's vocabulary

When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.

If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
When your output names a domain concept (an issue title, a test name, a refactor proposal), use the term as `CONTEXT.md` defines it, not a synonym it lists under _Avoid_. If the concept isn't in the glossary, say so rather than inventing a term.

## Flag ADR conflicts

If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
If your change contradicts an ADR, say so explicitly instead of silently overriding it:

> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
> _Contradicts ADR-0006 (unified container inbound write), but worth reopening because…_
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"start": "bun --env-file=.env --env-file=.env.local --filter @sampledb/api start",
"lint": "bun --filter '*' lint",
"typecheck": "bun --filter '*' typecheck",
"ci:verify": "bun run typecheck && bun run build && bash packages/docs/scripts/check-stale-claims.sh",
"ci:verify": "bun run typecheck && bun run lint && bun run build && bun run test && bash packages/docs/scripts/check-stale-claims.sh",
"setup:hooks": "bash .githooks/install.sh",
"test": "bun --env-file=.env --env-file=.env.local run --filter @sampledb/contract test && bun --env-file=.env --env-file=.env.local run --filter @sampledb/api test && bun --env-file=.env --env-file=.env.local run --filter @sampledb/web test",
"test:coverage": "bun --env-file=.env --env-file=.env.local run --filter @sampledb/api test:coverage && bun --env-file=.env --env-file=.env.local run --filter @sampledb/web test:coverage",
Expand Down
1 change: 1 addition & 0 deletions packages/api/eslint-rules/__tests__/fixtures/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Placeholder so the fixture tsconfig has a file; RuleTester supplies the code.
4 changes: 4 additions & 0 deletions packages/api/eslint-rules/__tests__/fixtures/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"compilerOptions": { "strict": true, "target": "ES2022", "module": "ESNext", "lib": ["ES2022"], "noEmit": true },
"include": ["*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it } from 'vitest'
import { join } from 'path'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { RuleTester } from 'eslint'
import parser from '@typescript-eslint/parser'
import rule from '../no-promise-transaction-callback.mjs'

RuleTester.describe = describe
RuleTester.it = it

const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')

const ruleTester = new RuleTester({
languageOptions: {
parser,
// With CI=true, typescript-eslint builds one program from the files on disk, so every case
// would be type-checked as the placeholder fixtures/file.ts instead of its own code.
parserOptions: { project: './tsconfig.json', tsconfigRootDir: fixtures, disallowAutomaticSingleRunInference: true },
},
})

/** Drizzle's bun-sqlite shape: the callback runs synchronously and its result is returned. */
const database = `
type Tx = { insert(value: number): void }
declare const db: { transaction<T>(fn: (tx: Tx) => T): T }
declare function helper(tx: Tx): Promise<void>
declare function syncHelper(tx: Tx): number
`
const filename = join(fixtures, 'file.ts')
const invalid = (code: string) => ({ code: database + code, filename, errors: [{ messageId: 'promiseCallback' }] })

ruleTester.run('no-promise-transaction-callback', rule, {
valid: [
{ code: `${database} db.transaction((tx) => { tx.insert(1) })`, filename },
{ code: `${database} db.transaction(syncHelper)`, filename },
{ code: `${database} function doWrites(tx: Tx) { tx.insert(1); return 1 } db.transaction(doWrites)`, filename },
{ code: `${database} declare const other: { run(fn: () => Promise<void>): void }; other.run(async () => {})`, filename },
],
invalid: [
invalid(`db.transaction(async (tx) => { tx.insert(1) })`),
invalid(`db.transaction(async function (tx) { tx.insert(1) })`),
invalid(`async function doWrites(tx: Tx) { tx.insert(1) } db.transaction(doWrites)`),
invalid(`db.transaction((tx) => helper(tx))`),
invalid(`db.transaction(helper)`),
invalid(`declare const maybe: (tx: Tx) => Promise<void> | void; db.transaction(maybe)`),
],
})
67 changes: 67 additions & 0 deletions packages/api/eslint-rules/no-promise-transaction-callback.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Flags a callback passed to `.transaction(...)` whose return type is Promise-like.
*
* Drizzle's bun-sqlite `transaction` (and bun:sqlite's own) runs the callback synchronously and
* commits when it returns, so a callback that returns a Promise commits at its first `await`.
* Checking the callback's return type catches inline `async` functions, named async functions
* (`db.transaction(doWrites)`), and sync functions that return a Promise (`tx => helper(tx)`).
*/

/** @param {import('typescript').Type} type */
const constituents = (type) => (type.isUnion() ? type.types : [type])

/**
* @param {import('typescript').TypeChecker} checker
* @param {import('typescript').Type} type
* @param {import('typescript').Node} location
*/
function isThenable(checker, type, location) {
return constituents(type).some((part) => {
const then = part.getProperty('then')
return then != null && checker.getTypeOfSymbolAtLocation(then, location).getCallSignatures().length > 0
})
}

/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: { description: 'Disallow callbacks that return a Promise in database.transaction(...)' },
messages: {
promiseCallback:
"Drizzle's bun-sqlite transaction commits when the callback returns, so a callback that returns a Promise commits at its first await. Use withWriteTransaction from src/db/write-transaction.ts, or a synchronous callback.",
},
schema: [],
},
create(context) {
const services = context.sourceCode.parserServices
if (!services?.program || !services.esTreeNodeToTSNodeMap) {
throw new Error('no-promise-transaction-callback needs type information (parserOptions.project).')
}
const checker = services.program.getTypeChecker()

return {
CallExpression(node) {
const { callee } = node
if (
callee.type !== 'MemberExpression' ||
callee.computed ||
callee.property.type !== 'Identifier' ||
callee.property.name !== 'transaction'
) {
return
}
const callback = node.arguments[0]
if (!callback || callback.type === 'SpreadElement') return

const tsNode = services.esTreeNodeToTSNodeMap.get(callback)
const returnsPromise = constituents(checker.getTypeAtLocation(tsNode)).some((part) =>
part
.getCallSignatures()
.some((signature) => isThenable(checker, checker.getReturnTypeOfSignature(signature), tsNode)),
)
if (returnsPromise) context.report({ node: callback, messageId: 'promiseCallback' })
},
}
},
}
Loading
Loading