refactor: turn on noUncheckedIndexedAccess in both workspaces (#101) #167

Merged
bermudalamb merged 1 commits from feature/101-unchecked-indexed-access into main 2026-08-24 15:30:29 -05:00
21 changed files with 144 additions and 63 deletions
Showing only changes of commit f32913ef51 - Show all commits
+25
View File
@@ -7,3 +7,28 @@ export const pool = new Pool({
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE
});
/**
* The single row a query is guaranteed to have returned.
*
* For `INSERT ... RETURNING` and `UPDATE ... WHERE id = $1 RETURNING` after the
* row's existence has already been established: Postgres returns exactly one
* row, so there is nothing to branch on, but `noUncheckedIndexedAccess` is right
* that `rows[0]` is `T | undefined` and the compiler cannot know better.
*
* A thrown error rather than a non-null assertion. If the assumption is ever
* wrong the assertion would hand `undefined` to the next line and fail somewhere
* unrelated, whereas this fails here and says which query. `asyncRoute` turns it
* into a 500, which is the right answer for "the database did not do what the
* statement says it does".
*
* Reads that legitimately might find nothing do not use this — they destructure
* and branch, so the check and the use are the same thing.
*/
export function requireRow<T>(rows: T[], what: string): T {
const row = rows[0];
if (!row) {
throw new Error(`expected ${what} to return a row, got none`);
}
return row;
}
+12 -4
View File
@@ -179,15 +179,23 @@ const PLACEHOLDER = /\{\{\s*(\w+)\s*\}\}/g;
export function missingPlaceholders(key: TemplateKey, body: string): string[] {
const present = new Set<string>();
for (const match of body.matchAll(PLACEHOLDER)) {
present.add(match[1]);
// PLACEHOLDER has exactly one capture group, so a match always has [1] —
// but a RegExpMatchArray cannot say so, hence the guard rather than an
// assertion. A match without it would be a change to the pattern.
const name = match[1];
if (name) present.add(name);
}
return TEMPLATES[key].required.filter((name) => !present.has(name));
}
function substitute(text: string, values: Record<string, string>): string {
return text.replace(PLACEHOLDER, (whole, name: string) =>
Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole
);
return text.replace(PLACEHOLDER, (whole, name: string) => {
// hasOwnProperty does not narrow an index signature, so the lookup is done
// once and tested. Checking the value also treats an explicitly-undefined
// entry the same as a missing one, which is what the caller means.
const value = values[name];
return value === undefined ? whole : value;
});
}
/**
+3 -1
View File
@@ -31,7 +31,9 @@ function parseAddress(address: string): ParsedAddress | null {
}
const localWithSuffix = trimmed.slice(0, at);
return {
local: localWithSuffix.split('+')[0],
// split always yields at least one element, so this cannot actually be
// undefined — but String.split's type cannot express that.
local: localWithSuffix.split('+')[0] ?? localWithSuffix,
domain: trimmed.slice(at + 1)
};
}
+2 -1
View File
@@ -28,7 +28,8 @@ export async function attachCustomer(req: Request, _res: Response, next: NextFun
WHERE s.token = $1 AND s.expires_at > now() AND c.disabled_at IS NULL`,
[token]
);
if (rows.length) req.customerId = rows[0].customer_id;
const [session] = rows;
if (session) req.customerId = session.customer_id;
next();
}
+7 -6
View File
@@ -3,7 +3,7 @@ import multer from 'multer';
import { promises as fs } from 'fs';
import { randomUUID } from 'crypto';
import { PoolClient } from 'pg';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { ADMIN_ITEM_SELECT, AdminItemRow, ItemRecord } from '../itemSelect';
import { ItemStatus } from '../types';
import { asyncRoute } from '../asyncRoute';
@@ -270,11 +270,11 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons
`INSERT INTO items (name, description, price_cents, category_id) VALUES ($1, $2, $3, $4) RETURNING *`,
[name, description, Math.round(parseFloat(price) * 100), categoryId ?? null]
);
const item = rows[0];
const item = requireRow(rows, 'the item INSERT');
for (let i = 0; i < files.length; i++) {
await client.query(
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
[item.id, `/uploads/${files[i].filename}`, i]
[item.id, `/uploads/${files[i]?.filename ?? ''}`, i]
);
}
if (tagNames) {
@@ -282,7 +282,7 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons
}
await client.query('COMMIT');
const { rows: full } = await pool.query<AdminItemRow>(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
res.json(full[0]);
res.json(requireRow(full, 'the item just inserted'));
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
@@ -325,7 +325,8 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
[req.params.id]
);
let nextSort = existing[0].max_sort + 1;
// COALESCE'd MAX, so the aggregate always returns exactly one row.
let nextSort = requireRow(existing, 'the MAX(sort_order) aggregate').max_sort + 1;
for (const file of files) {
await client.query(
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
@@ -388,7 +389,7 @@ router.post('/items/:id/unpublish', asyncRoute(async (req: Request, res: Respons
return res.status(404).json({ error: 'not found' });
}
const status = rows[0].status;
const status = requireRow(rows, 'the item status lookup').status;
if (status === 'pending') {
return res.status(400).json({ error: 'this item is already pending' });
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
interface CategoryRow {
@@ -205,7 +205,7 @@ router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
// being deleted along with their category.
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
res.json({ deleted_categories: ids.length, uncategorized_items: requireRow(affected, 'the affected-items COUNT').n });
}));
export default router;
+15 -7
View File
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
import { getSettings } from '../adminSettings';
@@ -57,13 +57,17 @@ const CART_ITEM_SELECT = `
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows: cartRows } = await pool.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
if (!cartRows.length) return res.json({ items: [] });
const { rows: items } = await pool.query<CartRow>(CART_ITEM_SELECT, [cartRows[0].id]);
const [cart] = cartRows;
if (!cart) return res.json({ items: [] });
const { rows: items } = await pool.query<CartRow>(CART_ITEM_SELECT, [cart.id]);
res.json({ items });
}));
router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
// Express types route params as an index signature, so this is
// `string | undefined` even though the route cannot match without it.
const itemId = req.params.itemId;
if (!itemId) return res.status(400).json({ error: 'itemId is required' });
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -75,17 +79,18 @@ router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, r
return res.status(409).json({ error: 'item is no longer available' });
}
let { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
const { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
const [existingCart] = cartRows;
let cartId: number;
if (cartRows.length) {
cartId = cartRows[0].id;
if (existingCart) {
cartId = existingCart.id;
await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]);
} else {
const { rows: newCart } = await client.query<IdRow>(
`INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`,
[req.customerId]
);
cartId = newCart[0].id;
cartId = requireRow(newCart, 'the cart INSERT').id;
}
const { cartExpiryHours } = await getSettings();
@@ -107,7 +112,10 @@ router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, r
}));
router.delete('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
// Express types route params as an index signature, so this is
// `string | undefined` even though the route cannot match without it.
const itemId = req.params.itemId;
if (!itemId) return res.status(400).json({ error: 'itemId is required' });
const client = await pool.connect();
try {
await client.query('BEGIN');
+8 -6
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import type { PoolClient } from 'pg';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
import { notifyFavoritersOfSale } from '../favoriteAlerts';
@@ -70,8 +70,9 @@ interface LockedCart {
// and returns { cartId, items: [{id, name, price_cents}], totalCents }.
async function loadLockedCart(client: PoolClient, customerId: number): Promise<LockedCart | null> {
const { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]);
if (!cartRows.length) return null;
const cartId = cartRows[0].id;
const [cart] = cartRows;
if (!cart) return null;
const cartId = cart.id;
const { rows: items } = await client.query<CartItem>(
`SELECT i.id, i.name, i.price_cents
FROM cart_items ci
@@ -114,7 +115,7 @@ async function openCheckout(
VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`,
[customerId, shippingAddressId, processor, processorOrderId, cart.totalCents]
);
const checkoutId = checkoutRows[0].id;
const checkoutId = requireRow(checkoutRows, 'the checkout INSERT').id;
for (const it of cart.items) {
await client.query(
`INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`,
@@ -221,7 +222,7 @@ router.post('/paypal/capture', requireCustomer, asyncRoute(async (req: Request,
if (!rows.length) return res.status(404).json({ error: 'checkout not found' });
await client.query('BEGIN');
const sold = await completeCheckout(client, rows[0].id, 'paypal', orderID, capture);
const sold = await completeCheckout(client, requireRow(rows, 'the checkout lookup').id, 'paypal', orderID, capture);
await client.query('COMMIT');
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
res.json({ status: 'completed' });
@@ -282,7 +283,8 @@ webhookRouter.post('/', asyncRoute(async (req: Request, res: Response) => {
const checkoutId = event.resource?.custom_id;
if (checkoutId) {
const { rows } = await pool.query<CheckoutStatusRow>(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]);
if (rows.length && rows[0].status !== 'completed') {
const [checkout] = rows;
if (checkout && checkout.status !== 'completed') {
const client = await pool.connect();
try {
await client.query('BEGIN');
+20 -17
View File
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { requireCustomer } from '../middleware/customerAuth';
import { sendMail } from '../mailer';
import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
@@ -220,7 +220,7 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
unsubscribeToken
]
);
const customer = rows[0];
const customer = requireRow(rows, 'the registration INSERT');
await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_name);
@@ -235,8 +235,9 @@ router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => {
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
[token]
);
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [rows[0].customer_id]);
const [verifyToken] = rows;
if (!verifyToken) return res.status(400).json({ error: 'invalid or expired token' });
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [verifyToken.customer_id]);
await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]);
res.json({ status: 'verified' });
}));
@@ -250,7 +251,8 @@ router.post(
verificationResendLimiter,
asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const customer = rows[0];
// requireCustomer has already matched this id against a live session.
const customer = requireRow(rows, 'the signed-in customer');
// Refused rather than quietly sending. A pointless email is worse than an
// answer, and the account page has no reason to offer the button here.
@@ -323,8 +325,9 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
[token]
);
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
const customerId = rows[0].customer_id;
const [resetToken] = rows;
if (!resetToken) return res.status(400).json({ error: 'invalid or expired token' });
const customerId = resetToken.customer_id;
// A token issued before the account was disabled would otherwise still mint a
// fresh session.
@@ -361,7 +364,7 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
const { rows: fresh } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [customerId]);
const sessionToken = await createSession(customerId);
setSessionCookie(res, sessionToken);
res.json(publicCustomer(fresh[0]));
res.json(publicCustomer(requireRow(fresh, 'the customer whose password was just reset')));
}));
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
@@ -430,13 +433,13 @@ router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Reques
WHERE id = $4 RETURNING *`,
[enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId]
);
res.json(publicCustomer(rows[0]));
res.json(publicCustomer(requireRow(rows, 'the favorite-alerts UPDATE')));
}));
router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(publicCustomer(rows[0]));
const [customer] = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]).then(r => r.rows);
if (!customer) return res.status(404).json({ error: 'not found' });
res.json(publicCustomer(customer));
}));
// Kept in step with registration for consistency. Note nothing in the frontend
@@ -459,7 +462,7 @@ router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
[first, last, req.customerId]
);
res.json(publicCustomer(rows[0]));
res.json(publicCustomer(requireRow(rows, 'the name UPDATE')));
}));
router.post('/change-password', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
@@ -468,7 +471,7 @@ router.post('/change-password', requireCustomer, asyncRoute(async (req: Request,
return res.status(400).json({ error: 'new password must be at least 8 characters' });
}
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const customer = rows[0];
const customer = requireRow(rows, 'the signed-in customer');
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
return res.status(401).json({ error: 'current password is incorrect' });
}
@@ -500,7 +503,7 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
}
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const customer = rows[0];
const customer = requireRow(rows, 'the signed-in customer');
if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) {
return res.status(401).json({ error: 'current password is incorrect' });
@@ -542,7 +545,7 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
.catch(err => console.error('email change notice send failed', err));
const { rows: updated } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
res.json(publicCustomer(updated[0]));
res.json(publicCustomer(requireRow(updated, 'the customer after the email change')));
}));
router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
@@ -569,7 +572,7 @@ router.get('/me/export', requireCustomer, asyncRoute(async (req: Request, res: R
const { rows: orderRows } = await pool.query<OrderRecord>(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
res.json({
customer: publicCustomer(customerRows[0]),
customer: publicCustomer(requireRow(customerRows, 'the signed-in customer')),
orders: orderRows,
exported_at: new Date().toISOString()
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
interface IdRow {
@@ -18,7 +18,7 @@ router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => {
await pool.query(
`UPDATE customers SET marketing_consent = false, marketing_consent_at = now(),
marketing_consent_text = 'Unsubscribed via email link' WHERE id = $1`,
[rows[0].id]
[requireRow(rows, 'the unsubscribe-token lookup').id]
);
res.send('<html><body><h2>You\'ve been unsubscribed.</h2><p>You will no longer receive marketing emails from Redefined Designs.</p></body></html>');
}));
+9 -2
View File
@@ -43,7 +43,7 @@ export function isValidEmail(email: string): boolean {
// antd's preset Tag colours. Kept as the single source of truth for tag
// colours so the admin palette picker and the auto-assignment below can never
// drift apart — the frontend renders whatever string lands in tags.color.
export const TAG_COLORS = [
export const TAG_COLORS: [string, ...string[]] = [
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
'green', 'cyan', 'blue', 'geekblue', 'purple'
];
@@ -61,7 +61,14 @@ export function tagColorFor(name: string): string {
for (let i = 0; i < normalized.length; i++) {
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
}
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
// The modulo keeps this in range, but an index signature cannot say so. The
// fallback is the first colour rather than a throw: a tag with an unexpected
// colour is not worth failing a request over.
// TAG_COLORS is typed as a non-empty tuple, so index 0 is known to exist —
// the annotation, rather than `as const`, because the elements must stay
// `string` for the callers that assign them. The modulo keeps the computed
// index in range; the fallback only exists because indexing cannot say so.
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
}
export const MARKETING_CONSENT_TEXT =
@@ -32,7 +32,7 @@ describe('POST /api/client-errors', () => {
expect(res.status).toBe(204);
expect(errorSpy).toHaveBeenCalledTimes(1);
const logged = errorSpy.mock.calls[0][0] as string;
const logged = errorSpy.mock.calls[0]?.[0] as string;
expect(logged).toContain('[client-error]');
expect(logged).toContain('context=catalogue');
expect(logged).toContain('Cannot read properties of undefined');
@@ -64,7 +64,7 @@ describe('POST /api/client-errors', () => {
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const logged = errorSpy.mock.calls[0]?.[0] as string;
expect(logged).toContain('[truncated]');
expect(logged.length).toBeLessThan(2000);
});
@@ -76,7 +76,7 @@ describe('POST /api/client-errors', () => {
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const logged = errorSpy.mock.calls[0]?.[0] as string;
expect(logged).toContain(`message: ${message}\n`);
expect(logged).not.toContain('[truncated]');
});
@@ -87,7 +87,7 @@ describe('POST /api/client-errors', () => {
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const logged = errorSpy.mock.calls[0]?.[0] as string;
expect(logged).toContain(`message: ${'a'.repeat(500)}… [truncated]`);
});
@@ -99,7 +99,7 @@ describe('POST /api/client-errors', () => {
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const logged = errorSpy.mock.calls[0]?.[0] as string;
const stackLine = logged.split('\n').find((line) => line.trim().startsWith('stack:'));
expect(stackLine).toContain('[truncated]');
});
@@ -112,7 +112,7 @@ describe('POST /api/client-errors', () => {
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const logged = errorSpy.mock.calls[0]?.[0] as string;
const componentStackLine = logged
.split('\n')
.find((line) => line.trim().startsWith('componentStack:'));
@@ -127,7 +127,7 @@ describe('POST /api/client-errors', () => {
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const logged = errorSpy.mock.calls[0]?.[0] as string;
const pathLine = logged.split('\n')[0];
expect(pathLine).toContain('[truncated]');
});
@@ -142,7 +142,7 @@ describe('POST /api/client-errors', () => {
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const logged = errorSpy.mock.calls[0]?.[0] as string;
// The template itself joins four fixed lines with three newlines; that
// count must not grow no matter what the caller sends.
@@ -67,7 +67,7 @@ describe('resending your own verification email', () => {
expect(res.status).toBe(204);
expect(sentMail).toHaveBeenCalledTimes(1);
expect(String(sentMail.mock.calls[0][0])).toBe(email);
expect(String(sentMail.mock.calls[0]?.[0])).toBe(email);
});
// The point of the whole thing. An un-superseded link means a message still
+1 -1
View File
@@ -131,7 +131,7 @@ describe('requireAdminGate', () => {
requireAdminGate(h.req, h.res, h.next);
expect(warn).toHaveBeenCalledTimes(1);
const logged = warn.mock.calls[0][0] as string;
const logged = warn.mock.calls[0]?.[0] as string;
expect(logged).toContain('[admin-gate]');
expect(logged).toContain('/api/admin/items');
expect(logged).not.toContain('some-guessed-value');
@@ -75,8 +75,11 @@ function environmentEntries(source: string): Map<string, string> {
// this guard was found to be broken.
for (const line of source.split(/\r?\n/)) {
const match = /^\s+- ([A-Z_0-9]+)=(.*)$/.exec(line);
if (match) {
entries.set(match[1], match[2].trim());
// Both groups are non-optional in the pattern, so a match always has them —
// but RegExpExecArray cannot say so, and #101 made the compiler insist.
const [, name, value] = match ?? [];
if (name !== undefined && value !== undefined) {
entries.set(name, value.trim());
}
}
return entries;
+5
View File
@@ -6,6 +6,11 @@
"outDir": "dist",
"rootDir": "src",
"strict": true,
// #101. Indexing an array gives `T | undefined`, which is what it always
// was this makes the compiler say so. Only worth having once query rows
// carry real types (#159): before that `rows[0]` was `any`, and `any`
// indexes to `any`, so there was nothing for this to check.
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
+1 -1
View File
@@ -15,7 +15,7 @@ const { Title } = Typography;
// Mirrors TAG_COLORS in the backend's utils.ts — the server rejects anything
// outside this set, so the two lists have to stay aligned.
const TAG_COLORS = [
const TAG_COLORS: [string, ...string[]] = [
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
'green', 'cyan', 'blue', 'geekblue', 'purple'
];
+9 -1
View File
@@ -166,7 +166,15 @@ export default function FilterDrawer({
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
onChange={([min, max]) =>
onChange({ ...filters, minPriceCents: min, maxPriceCents: max })
// antd types the slider's value as number[], so destructuring gives
// `number | undefined`. A range slider always emits both ends; the
// fallbacks are the bounds it was given rather than nulls, which
// would read as "no filter" and widen the results.
onChange({
...filters,
minPriceCents: min ?? bounds.min_cents,
maxPriceCents: max ?? sliderMax
})
}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
+3 -1
View File
@@ -3,7 +3,9 @@ import { test, expect, AdminPage, createCategory, createTag, uniqueSuffix } from
// Relative luminance per WCAG, used to tell "light" from "dark" without
// asserting exact hex values, which would break on any palette tweak.
function luminance(rgb: string): number {
const [r, g, b] = (rgb.match(/\d+(\.\d+)?/g) ?? ['0', '0', '0']).slice(0, 3).map(Number);
// Defaulted per channel rather than on the match: a colour string with
// fewer than three numbers would otherwise leave a channel undefined.
const [r = 0, g = 0, b = 0] = (rgb.match(/\d+(\.\d+)?/g) ?? []).slice(0, 3).map(Number);
const channel = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
+3
View File
@@ -7,6 +7,9 @@
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
// #101. Indexing gives `T | undefined`, which it always did this makes
// the compiler say so.
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noEmit": true
+3
View File
@@ -8,6 +8,9 @@
"moduleResolution": "node",
"jsx": "react-jsx",
"strict": true,
// #101. Indexing gives `T | undefined`, which it always did this makes
// the compiler say so.
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noEmit": true