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
66 changes: 66 additions & 0 deletions .claude/skills/update-custom-mapping/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
name: update-custom-mapping
description: Find generated models in src/gen/models whose `custom` field (or `Filters<{ custom }>` entry) is still `Record<string, any>` although a matching `Custom*Data` interface exists in src/custom_types.ts, add them to the mapping tables in scripts/apply-custom-data-types.mts, and re-apply the script to src/gen. Use after `yarn generate-client`, or when the user asks to update, check or audit the custom data type mapping.
---

# Update the custom data mapping

`scripts/apply-custom-data-types.mts` runs after every `yarn generate-client` and rewrites `custom: Record<string, any>` in generated models to the module-augmentation interfaces in `src/custom_types.ts` (`CustomChannelData`, `CustomMessageData`, …), so integrators' augmentations reach the types. It only rewrites what its two tables name:

- `CUSTOM_DATA_MAPPING` — interface name → `Custom*Data`, for a `custom` field in the interface body.
- `FILTER_CUSTOM_MAPPING` — `` `${interface}.${filterField}` `` → `Custom*Data`, for the `custom: { type: Record<string, any> }` entry of a `Filters<{ … }>` block.

(`channel_custom` is always rewritten to `CustomChannelData` and needs no entry.)

A regen that adds a model leaves its `custom` untyped until someone adds it here. This skill finds those and fixes the tables.

All paths are relative to the repo root.

## 1. Collect the candidates

```bash
node .claude/skills/update-custom-mapping/find-unmapped-custom.mts
```

It is read-only and prints the `Custom*Data` interfaces available, every `custom` field / filter `custom` entry in `src/gen/models/index.ts` still typed `Record<string, any>` (with its doc comment and the fields that reference its interface), and any stale table entries.

## 2. Decide each candidate

Map a candidate only when its `custom` **is the custom data of the domain object a `Custom*Data` interface describes** — the same bag an integrator writes through that object's API. Judge from the interface name, the doc comment, what references it, and, when unsure, its other fields (read the interface in `src/gen/models/index.ts`):

- A variant of an already mapped object is a match: e.g. `ChannelPartialUpdateResponse` (the channel in `notification.channel_partial_updated`, with `id`/`type`/`cid` and "custom data for this object") is a channel → `CustomChannelData`. Look at the existing entries for the same object (`ChannelResponse`, `ChannelInput`, …) as a reference.
- Every new `*Event` interface with a `custom` field → `CustomEventData`, as all existing events are.
- Filters: map `Request.filter_field` to the type of the objects being queried (the filter on `QueryChannelsRequest` filters channels → `CustomChannelData`).
- **Not** a match, leave unmapped: objects that merely sit next to a mapped one but have their own custom bag — flags (`FlagRequest`, `MessageFlagResponse`, `ModerationFlagResponse`), moderation payloads / actions / action configs / review queue (including its filter), action logs, calls, and Feeds models (`Feeds*`). A flag on a message is not message custom data. There is no `Custom*Data` for these; the unmapped warning for them is expected.
- `CustomMessageComposerData`, `CustomCommandData` and `CustomEventTypes` are not wire-model custom bags; don't map generated models to them unless a model plainly is a command.

If a candidate is genuinely ambiguous, ask the user (AskUserQuestion) rather than guess — a wrong mapping types data that does not have the integrator's shape.

## 3. Edit the tables

In `scripts/apply-custom-data-types.mts`:

- Add each accepted interface to `CUSTOM_DATA_MAPPING` under the comment group of its type, keeping the group sorted alphabetically.
- Add each accepted filter to `FILTER_CUSTOM_MAPPING`, sorted.
- Remove entries the script reported as stale (the interface or filter field no longer exists in the models).

If an accepted `custom` field has a shape the script's regexes don't match (e.g. not `Record<string, any>`, or an inline object), say so and stop — don't extend the parser silently.

## 4. Re-apply to the generated models

The script is idempotent (it only touches lines still typed `Record<string, any>`, and merges the `custom_types` import), so run it on the checked-in models instead of regenerating:

```bash
node ./scripts/apply-custom-data-types.mts -i src/gen/models/index.ts
yarn prettier --write src/gen/models/index.ts
```

Re-run step 1: the fields you mapped must be gone from the list, and what remains should be only what you decided to leave.

## 5. Verify

- `yarn types` — a newly typed `custom` can break hand-written code that read it as `Record<string, any>`; fix those call sites (they now get the integrator's type, as the other models do).
- `yarn lint`
- `yarn test`

Report to the user: which interfaces/filters were added (and to which type), which were left unmapped and why (grouped, e.g. "moderation / Feeds — no corresponding type"), stale entries removed, and the verification results.
116 changes: 116 additions & 0 deletions .claude/skills/update-custom-mapping/find-unmapped-custom.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Report state for the update-custom-mapping skill. Read-only.
*
* Usage: node .claude/skills/update-custom-mapping/find-unmapped-custom.mts
*
* Prints, for `src/gen/models/index.ts`:
* - the `Custom*Data` interfaces declared in `src/custom_types.ts`
* - every `custom` field (and every `Filters<{ custom: { type } }>` entry) still typed
* `Record<string, any>`, with its doc comment and the fields that reference the
* enclosing interface, so the caller can judge which domain object it belongs to
* - entries in the mapping tables of `scripts/apply-custom-data-types.mts` that name an
* interface or filter field the models no longer contain
*/
import { readFileSync } from 'node:fs';

const MODELS_PATH = 'src/gen/models/index.ts';
const CUSTOM_TYPES_PATH = 'src/custom_types.ts';
const SCRIPT_PATH = 'scripts/apply-custom-data-types.mts';

const models = readFileSync(MODELS_PATH, 'utf8').split('\n');
const customTypes = [
...readFileSync(CUSTOM_TYPES_PATH, 'utf8').matchAll(/^export interface (Custom\w+Data)\b/gm),
].map((m) => m[1]);
const script = readFileSync(SCRIPT_PATH, 'utf8');

const INTERFACE_OPEN_RE = /^export interface (\w+)\b/;
const CUSTOM_FIELD_RE = /^\s*(\w*custom\w*)\??:\s*Record<string,\s*any>;/;
const FILTER_OPEN_RE = /^\s*(\w+)\??:\s*Filters<\{\s*$/;
const FILTER_CUSTOM_TYPE_RE = /^\s*type:\s*Record<string,\s*any>;/;

type Finding = { line: number; iface: string; field: string; doc: string };

const interfaces = new Set<string>();
const filterKeys = new Set<string>();
const findings: Finding[] = [];
let iface = '';
let filterField: string | null = null;
let previousLine = '';

const docAbove = (index: number) => {
// Collect the `/** ... */` block that ends right above `index`, if any.
if (!models[index - 1]?.trim().endsWith('*/')) return '';
const doc: string[] = [];
for (let i = index - 1; i >= 0; i -= 1) {
const text = models[i].trim().replace(/^\/?\*+\/?\s?/, '').replace(/\*\/$/, '');
if (text) doc.unshift(text.trim());
if (models[i].trim().startsWith('/**')) break;
}
return doc.join(' ');
};

models.forEach((line, index) => {
const open = line.match(INTERFACE_OPEN_RE);
if (open) {
iface = open[1];
interfaces.add(iface);
filterField = null;
}
const filterOpen = line.match(FILTER_OPEN_RE);
if (filterOpen) {
filterField = filterOpen[1];
filterKeys.add(`${iface}.${filterField}`);
}
if (line.startsWith('}')) filterField = null;

const custom = line.match(CUSTOM_FIELD_RE);
if (custom && !filterField) {
findings.push({ line: index + 1, iface, field: custom[1], doc: docAbove(index) });
}
if (filterField && FILTER_CUSTOM_TYPE_RE.test(line) && /^\s*custom:\s*\{/.test(previousLine)) {
findings.push({
line: index + 1,
iface,
field: `${filterField} (filter custom)`,
doc: docAbove(index - 1),
});
}
previousLine = line;
});

const referencesTo = (name: string) =>
models.flatMap((line, index) => {
if (!new RegExp(`:\\s*(Array<)?${name}\\b`).test(line)) return [];
let owner = '';
for (let i = index; i >= 0; i -= 1) {
const open = models[i].match(INTERFACE_OPEN_RE);
if (open) {
owner = open[1];
break;
}
}
return [`${owner}.${line.trim().split(/\??:/)[0]}`];
});

console.log(`Custom*Data interfaces in ${CUSTOM_TYPES_PATH}:`);
console.log(` ${customTypes.join(', ')}\n`);

console.log(`Unmapped custom fields in ${MODELS_PATH} (${findings.length}):`);
for (const f of findings) {
console.log(`\n- ${f.iface}.${f.field} (${MODELS_PATH}:${f.line})`);
if (f.doc) console.log(` doc: ${f.doc}`);
const refs = referencesTo(f.iface);
console.log(` referenced by: ${refs.length ? refs.join(', ') : '(nothing)'}`);
}

const mappingBlock = (name: string) =>
script.split(`const ${name}`)[1]?.split('\n};')[0] ?? '';
const staleInterfaces = [...mappingBlock('CUSTOM_DATA_MAPPING').matchAll(/^\s*(\w+):\s*'/gm)]
.map((m) => m[1])
.filter((name) => !interfaces.has(name));
const staleFilters = [...mappingBlock('FILTER_CUSTOM_MAPPING').matchAll(/^\s*'([\w.]+)':/gm)]
.map((m) => m[1])
.filter((key) => !filterKeys.has(key));

console.log(`\nStale CUSTOM_DATA_MAPPING entries: ${staleInterfaces.join(', ') || '(none)'}`);
console.log(`Stale FILTER_CUSTOM_MAPPING entries: ${staleFilters.join(', ') || '(none)'}`);
2 changes: 2 additions & 0 deletions scripts/apply-custom-data-types.mts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const CUSTOM_DATA_MAPPING: Record<string, string> = {
ChannelInput: 'CustomChannelData',
ChannelInputRequest: 'CustomChannelData',
ChannelMetadata: 'CustomChannelData',
ChannelPartialUpdateResponse: 'CustomChannelData',
ChannelResponse: 'CustomChannelData',

// CustomMemberData
Expand Down Expand Up @@ -151,6 +152,7 @@ const CUSTOM_DATA_MAPPING: Record<string, string> = {
TypingStartEvent: 'CustomEventData',
TypingStopEvent: 'CustomEventData',
UserBannedEvent: 'CustomEventData',
UserClaimedEvent: 'CustomEventData',
UserDeactivatedEvent: 'CustomEventData',
UserDeletedEvent: 'CustomEventData',
UserGroupCreatedEvent: 'CustomEventData',
Expand Down
6 changes: 6 additions & 0 deletions scripts/generate-client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,10 @@ yarn exec prettier --write $OUTPUT_DIR/models/index.ts

node ./scripts/apply-custom-data-types.mts -i $OUTPUT_DIR/models/index.ts

# The API classes import every model, and unused-imports removes only every other
# unused name per fix pass (neighbouring fixes share a comma), so the cleanup needs
# more than ESLint's 10 passes per run. This first run lets the one in lint-fix finish;
# `|| true` because it exits non-zero while warnings remain.
yarn exec eslint --fix $OUTPUT_DIR > /dev/null || true

yarn lint-fix
Loading