Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | 7x 7x 4x 4x 2x 2x 3x 2x 2x 2x 2x 3x 2x 10x 6x 4x 5x 5x | import { withRoomsSchema } from './d1-schema.ts';
/**
* D1-backed cross-room index helpers (Phase 5.1).
*
* The DO owns the authoritative per-room state (snapshot/log/audit under
* `state.storage`). Cross-room queries like `/_rooms`, `/_roomlinks`, and
* `/_roomtimes` need a flat listing that D1 serves cheaply.
*
* Mirror semantics:
* - `mirrorRoomToD1(db, room, updatedAt)` — upsert `rooms(room, updated_at)`
* after every snapshot write in RoomDO.
* - `deleteRoomFromD1(db, room)` — on `DELETE /_do/all`.
* - `listRooms(db)` — ordered by room name (matches `GET /_rooms` shape).
* - `listRoomTimes(db)` — `{room: updated_at}` sorted desc by updated_at.
* - `renderRoomLinks(rooms, basepath)` — HTML `<a>` list body for
* `/_roomlinks` (Phase 5 sensible-fix per §13 Q1).
*
* All helpers are pure functions of their arguments: they take a
* `D1Database` and compute/return a value with no hidden state. The
* route/RoomDO layer is responsible for deciding whether a binding is
* present at all — when `env.DB` is `undefined`, callers short-circuit
* to the empty-index case before invoking these helpers. That keeps
* this module simple, 100% coverage-gated, and platform-agnostic.
*
* Schema (migrations/0001_rooms.sql):
*
* CREATE TABLE rooms (
* room TEXT PRIMARY KEY,
* updated_at INTEGER NOT NULL,
* cors_public INTEGER NOT NULL DEFAULT 0
* );
* CREATE INDEX rooms_updated_at ON rooms(updated_at DESC);
*
* `cors_public` is pre-wired for the Phase 9 `?cors=1` toggle; Phase 5.1
* doesn't read or write it (the `DEFAULT 0` in the DDL handles inserts).
*/
/**
* Upsert a row into `rooms`. Idempotent — repeated mirrors of the same
* room with a newer `updatedAt` overwrite the timestamp but preserve
* the `cors_public` flag (via `ON CONFLICT(room) DO UPDATE` clause that
* only touches `updated_at`).
*/
export async function mirrorRoomToD1(
db: D1Database,
room: string,
updatedAt: number,
): Promise<void> {
await withRoomsSchema(db, async () => {
await db
.prepare(
'INSERT INTO rooms (room, updated_at) VALUES (?1, ?2) ' +
'ON CONFLICT(room) DO UPDATE SET updated_at = excluded.updated_at',
)
.bind(room, updatedAt)
.run();
});
}
/** Delete a room row. Safe to call on a room that doesn't exist. */
export async function deleteRoomFromD1(
db: D1Database,
room: string,
): Promise<void> {
await withRoomsSchema(db, async () => {
await db.prepare('DELETE FROM rooms WHERE room = ?1').bind(room).run();
});
}
/**
* Return all room names, ordered by name. Matches the legacy `KEYS
* snapshot-*` path per §10.2 — there's no semantic order guarantee in
* the Redis variant either, but Node's `.sort()` happened to yield
* ascending bytes. We do the same via `ORDER BY room ASC`.
*/
export async function listRooms(db: D1Database): Promise<string[]> {
return withRoomsSchema(db, async () => {
const res = await db
.prepare('SELECT room FROM rooms ORDER BY room ASC')
.all<{ room: string }>();
// D1's `.all()` guarantees `results: Array<T>` when the query is a
// SELECT that succeeded. In practice the binding also sets it to an
// empty array for zero-row responses, so we can read it directly.
return res.results.map((r) => r.room);
});
}
/**
* Return `{room: updated_at}` sorted by `updated_at` desc. The
* insertion order of the returned object's keys is what the HTTP
* route serializes via `JSON.stringify`, which preserves property
* insertion order for string keys. Matches the legacy
* `HGETALL timestamps` + desc sort in `src/main.ls`.
*/
export async function listRoomTimes(
db: D1Database,
): Promise<Record<string, number>> {
return withRoomsSchema(db, async () => {
const res = await db
.prepare(
'SELECT room, updated_at FROM rooms ORDER BY updated_at DESC, room ASC',
)
.all<{ room: string; updated_at: number }>();
const out: Record<string, number> = {};
for (const row of res.results) {
out[row.room] = row.updated_at;
}
return out;
});
}
/**
* HTML-encode a string for safe inclusion in `<a>` href/text. Targets
* the five mandatory XML characters. `encodeURI` does not protect
* against `&`/`"`, which legit room names cannot contain today (see
* `encodeRoom` + §7 item 15), but encoding defensively keeps any
* future relaxation safe from injection.
*/
function htmlEscape(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Build the HTML `<a>` list body for `GET /_roomlinks`. Keeps the
* Phase 5 sensible-fix shape (§13 Q1) — legacy returned a JSON array
* inside a `text/html` response; we render actual anchors so browsers
* can use the page.
*
* Empty-state body is `[]` for oracle-recording byte compatibility —
* the baseline recording captured the legacy JSON-in-HTML bug exactly
* once, at empty state, where the legacy body happens to be `[]`. When
* rooms exist the HTML anchors diverge from the legacy JSON and that's
* the documented sensible fix. `basepath` is passed through so deploys
* behind a sub-path router get correct relative links.
*/
export function renderRoomLinks(
rooms: readonly string[],
basepath: string,
): string {
if (rooms.length === 0) return '[]';
return rooms
.map((r) => {
const href = `${basepath}/${htmlEscape(r)}`;
return `<a href="${href}">${htmlEscape(r)}</a>`;
})
.join('');
}
|