# Development

Running the board, changing it, and proving the change works.

---

## Where it runs

The dev stack serves `app/` as the document root:

```
http://127.0.0.1:8888/Forum/index.cfm            the Forum
http://127.0.0.1:8888/Forum/index.cfm?b=he       the Help Center
http://127.0.0.1:8888/hef.cfm?f=hef&ch=100       the legacy board (still live)
```

Both boards run against the same rows. That is deliberate — see
[`ARCHITECTURE.md`](ARCHITECTURE.md).

```bash
docker compose -f dev/docker-compose.yml --env-file dev/.env up -d
docker restart gcc-local-app      # you WILL need this — see below
```

### Lucee will not see a new CFC method until you restart

Editing an existing method *body* recompiles fine. Adding a **new method
signature** does not, and you get:

```
Component [components.People] has no function with name [myOpenTicketCount]
```

…while the method is plainly on disk. `applicationStop()` does **not** clear it.
`docker restart gcc-local-app` does. Check this before hunting a phantom syntax
error.

---

## Applying the schema

```bash
mysql -u WolfrenInd -p < app/Admin/sql/forum_schema.sql
```

**No database argument needed** — the file selects its own with `USE gcc;`, and
so must anything you add. See
[`CONVENTIONS.md`](CONVENTIONS.md#every-migration-names-its-database).

| File | Target | Purpose |
|---|---|---|
| `forum_schema.sql` | `gcc` | Every `forum_*` table, all indexes, the section seed. Idempotent. |
| `forum_glyph_widen.sql` | `gcc` | Standalone `forum_section.glyph` → `varchar(16)`, for installs created before the widen. |
| `forum_lastpost_indexes.sql` | `gcc` | `hef (lastpost)` and `he (lastpost)` for the Latest Activity rail. `IF NOT EXISTS`, re-runnable; also in `forum_schema.sql`. |
| `forum_archive_rename.sql` | `gcc` | Standalone rename of the `hef`/`1` section to "The Archive", no date range. Plain `UPDATE`, re-runnable. |
| `forum_avatars.sql` | `gcc` | Avatar columns on `forum_profile`. All `ADD COLUMN IF NOT EXISTS`, so a partial staging apply can be re-run. |
| `forum_acl.sql` | `gcc` | `forum_acl`, the configurable-clearance overrides behind the Access Control screen. `IF NOT EXISTS`; also in `forum_schema.sql`. Ships empty on purpose — every gate keeps its built-in default until somebody moves it. |
| `forum_section_events.sql` | `gcc` | The Events section — `he_type` 106 and its `forum_section` row, The Galaxy, below Federation. Also in `forum_schema.sql`. **Restart the app after applying**: `he_type` is cached in application scope. |
| `forum_thread_staff_only.sql` | `gcc` | `forum_thread_meta.staff_only_at` / `_by` — the per-thread "no player replies" flag. `ADD COLUMN IF NOT EXISTS`; also in `forum_schema.sql`. Harmless to apply before the code. |

Confirm with `information_schema` rather than trusting that the statement
returned success:

```sql
SELECT COLUMN_TYPE FROM information_schema.columns
WHERE table_schema='gcc' AND table_name='forum_section' AND column_name='glyph';
```

**The FULLTEXT indexes lock their table while building** — ~5s on `hef2`, ~1s on
`hef`. One-off, but do it in a quiet window rather than mid-day.

---

## Testing

There is no committed harness. The pattern:

1. Drop a scratch `.cfm` in `app/Forum/`.
2. Drive it with `curl`.
3. **Delete it.** `git status --short app/Forum` before every commit.

```bash
curl -s "http://127.0.0.1:8888/Forum/_t.cfm"
```

### Simulating a viewer

```cfml
function asUser(uid, lvl, active) {
    session.userid = uid; session.username = "PROBE";
    session.adminflag = lvl; session.activeflag = active; session.confirmflag = 1;
    // MANDATORY: request-scoped caches hold the previous viewer's section map
    // and me(). Without this the next level passes on the last one's answers.
    for (k in listToArray(structKeyList(request)))
        if (left(k,5) EQ "forum") structDelete(request, k);
    return new components.People();
}
```

### Write tests run against the real tables

Record every id you create and delete it afterwards. Do not use a `finally`
block — see [`CONVENTIONS.md`](CONVENTIONS.md) — put cleanup after the
`try/catch` so it runs either way.

```cfml
made = { threads: [] };
try { /* ... */ } catch (any e) { say("EXCEPTION", false, e.message); }

for (t in made.threads) {
    queryExecute("delete from hef  where id=:i",       {i:t}, {datasource:"gcc"});
    queryExecute("delete from hef2 where belongto=:i", {i:t}, {datasource:"gcc"});
    // ... and every forum_* table keyed on the thread
}
```

Then confirm the legacy tables are byte-identical:

```sql
SELECT 'hef', COUNT(*) FROM hef UNION ALL SELECT 'hef2', COUNT(*) FROM hef2;
```

### What a full pass looks like

The build was signed off on:

- **28 pages** render clean across both boards, including the legacy `?hi=` /
  `?ch=` / `?f=he_detail` URLs and every not-found case
- **31 write-path assertions** — create, CSRF, flood, reply, edit, pin, lock,
  move, mark-answer, report, archive, restore, subscribe, notify, unread —
  with clean teardown
- **24 access assertions** across guest, deactivated owner, active player,
  Guide, Admin and level-5
- **legacy row counts unchanged** after the run

---

## Before you commit

- [ ] Both boards render: `?p=index` and `?b=he&p=index`
- [ ] Access asserted in **both** directions — the right people in, the wrong
      people refused. A "staff can see it" test alone passed the section-105 leak.
- [ ] Timings taken **signed in** (`myStanding()` only runs for a session)
- [ ] Legacy table row counts unchanged
- [ ] `git status --short app/Forum` — no scratch files
- [ ] Any new `.sql` starts with `USE`

---

## Performance baseline

Signed in, warm, on the dev stack. If a change moves any of these materially,
find out why before committing.

| Page | Time |
|---|---|
| Index | ~35ms |
| Section (7,419 threads) | ~67ms |
| Thread | ~33ms |
| Members | ~86ms |
| Search | ~29ms |
| My Tickets | ~30ms |

The known outlier is **Most-read sort at ~118ms** — it orders on a LEFT JOIN
column, the same pattern fixed for `pinned`. Left alone deliberately: rarely
used, bounded cost, and fixing it properly means denormalising view counts.

Profiling harness shape:

```cfml
function t(label, fn) {
    var a = getTickCount(); arguments.fn();
    arrayAppend(rows, { l: arguments.label, ms: getTickCount() - a });
}
```

For SQL, `set profiling=1; <query>; show profiles;` in the MySQL client, and
`EXPLAIN` when a query is slower than it looks like it should be —
`Using temporary; Using filesort` over thousands of rows is the signature of the
`ORDER BY` trap.

---

## Related

- [`../Skills/`](../Skills/) — task playbooks. Start there for a specific job.
- [`ACCESS-CONTROL.md`](ACCESS-CONTROL.md) — before touching permissions.
- [`../CLAUDE.md`](../CLAUDE.md) — the standing rules, in brief.
