Skip to content

Say what a read wants, instead of writing out its SQL each time - #1991

Merged
stefan-burke merged 5 commits into
mainfrom
claude/attendee-pattern-collections-pfvtod
Jul 29, 2026
Merged

stefan-burke merged 5 commits into
mainfrom
claude/attendee-pattern-collections-pfvtod

Conversation

@stefan-burke

@stefan-burke stefan-burke commented Jul 29, 2026

Copy link
Copy Markdown
Member

What changed

Two steps, in order.

1. A read is said, not written

Every read in the codebase is the same sentence: these columns, from here,
keeping these rows, in this order, at most this many.
Each reader used to
write that sentence out itself — gluing SELECT, FROM, the filter tail, the
ORDER BY and the LIMIT together by hand — and each one separately remembered
to skip the database when its filter could match no row.

One place now turns a said read into SQL, and one place runs it.

  • Two readers were the same factory written twice. The listing reader and
    the attendee reader each had their own copy of "build the columns, build the
    tables, look up the named order, collect the filters, run it". They now share
    one factory: a collection's reader is its list of orders plus one function
    saying what its read is.
  • The skip is inherited, not repeated. Asking for none of something is
    already answered, so it should cost no trip to the database.
  • Two filters joined the shared vocabulary — "rows another query names" and
    "rows another query does not name" each existed in three private versions.

2. A table read says which rows it wants, and nothing else

A table already declares its columns, their types, and how to open them. A read
of that table still had to say all of it again: the column list as a string, the
row shape as a type, and a separate call to decrypt.

Now it says it once:

wholePage.one({ slug_index: index })
apiKeys.pick(["id", "name"]).one({ id, user_id: userId })

The filter is the row — a value means "equals this", a list means "is one of
these" — so one item and many are the same filter, never two paths.

Two column lists that were transcriptions of a table's schema are gone. They
could drift from the table they copied and nothing would have noticed: the
site-pages one was a hand-written list of eight columns sitting beside a schema
assembled from three spread helpers, so you could not tell by reading whether it
was still right.

Two fixes the types found

Because a filter is now the row's own shape, a wrong column name or a wrong value
type is a build error rather than a runtime one. It found these immediately:

  • An API key's name was being decrypted on every API request. The auth path
    never shows the name — the comment above it said so — but the hand-written
    column list included it anyway. The read now names the columns it uses, so the
    one encrypted column is left alone.
  • A page lookup took a plain string where the column holds a one-way code.
    Its only caller already passed the right thing, so the parameter was simply too
    loose.

Two more from the first step, both cases of asking for nothing and being charged
anyway: an attendee read given an empty list of ids, and a transfer read,
each used to send a query that could not match a row.

What is deliberately left alone

  • Reads that join, or that select something SQL invents — questions with
    their answers, news cards with their image, images with their sort order. No
    table describes those shapes, so nothing can infer them. They say their read
    the ordinary way, which is the honest boundary: one table and its own columns
    infers, anything else does not.
  • Notes. system_notes has no table object at all, so its column list is not
    a copy of anything and there is nothing to unify.
  • A cast in the new reader. Tightening the table type to remove it turned up
    a genuine inconsistency elsewhere (built-sites exposes snake_case column
    names on a camelCase row). That is a real thing to fix, but not here.

About size

This does not make the codebase smaller, and that is worth saying plainly.
The first step alone measured +88 lines and +275 syntax nodes across src/; the
second adds a 95-line module and removes 62 lines from the files it touched.

The reason is structural rather than something more migrations would fix: a piece
of SQL written as text is one leaf of the syntax tree however long it is, while
the same read described as data is a small tree of its own. Two conversions were
tried purely for terseness, measured, and reverted for adding size without
removing anything.

What it does remove is knowledge written down twice — two column lists that
could silently drift from their tables, a reader factory written twice, three
private copies of the same two filters, and eighteen column names that nothing
was checking. That is the case for this change; brevity is not.

Tests

  • New tests for the shared assembly: the order of the parts, the order of the
    values that fill them (including a join carrying its own value), and that a
    read matching nothing still produces valid SQL for a caller embedding it in a
    batch.
  • New tests for the table reader against a real table: whole-row reads decrypt,
    a value filters by equals and a list by is-one-of, several columns must all
    match, a narrowed read can still filter on a column it does not select.
  • The attendee builder's tests now go through its public statement builder; the
    exact-SQL assertions are unchanged.
  • Methods left without callers were removed rather than kept for symmetry.

Full checks pass, including complete test coverage.


Generated by Claude Code

Every read is the same sentence: these columns, from here, keeping these
rows, in this order, at most this many. Each reader wrote that sentence out
itself and separately remembered to skip the database when its filter could
match no row.

A read is now said, not written. `readStatement` turns it into SQL and
`readRows` runs it, so the skip is inherited rather than repeated, and
`defineReader` builds a collection's reader from its orders plus one function
saying what its read is — the listing and attendee readers were the same
factory written twice.

Two filters join the shared vocabulary: `inSubquery` and `notInSubquery`,
which the attendee reader, the group picker and the catalog each had their
own version of.

Two behaviour changes fall out. An attendee read asking for none of something
no longer touches the database, and neither does a transfer read; both used
to send a query that could match nothing.

`ChosenColumns.queryAll` had no callers left and is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UASQuuj6grCBBrHHQeqMCF
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1f3740e3-e050-48fe-bb1f-9d861dab43aa

📥 Commits

Reviewing files that changed from the base of the PR and between bb81226 and 9a93559.

📒 Files selected for processing (17)
  • src/shared/db/activityLog.ts
  • src/shared/db/api-keys.ts
  • src/shared/db/attendees/select.ts
  • src/shared/db/chosen-columns.ts
  • src/shared/db/groups.ts
  • src/shared/db/listings/attendees.ts
  • src/shared/db/listings/records.ts
  • src/shared/db/listings/select.ts
  • src/shared/db/news-posts.ts
  • src/shared/db/read.ts
  • src/shared/db/site-pages.ts
  • src/shared/db/table-reader.ts
  • src/shared/db/where-clauses.ts
  • test/shared/db/listings/delete.test.ts
  • test/shared/db/listings/select.test.ts
  • test/shared/db/read.test.ts
  • test/shared/db/table-reader.test.ts
📝 Walkthrough

Walkthrough

The pull request introduces shared typed SQL read and WHERE-clause builders, then migrates accounting, attendee, listing, chosen-column, and database read paths from manually assembled SQL to structured query objects while preserving filtering, ordering, projection, and null-result behavior.

Changes

Typed database read migration

Layer / File(s) Summary
Read and subquery abstractions
src/shared/db/read.ts, src/shared/db/where-clauses.ts, test/shared/db/*
Adds Read, readStatement, readRows, readOneRow, defineReader, named ordering, subquery predicates, and SQL-generation tests.
Reader-backed selectors
src/shared/db/attendees/select.ts, src/shared/db/listings/select.ts, src/shared/db/chosen-columns.ts, test/shared/db/attendees/*
Moves attendee, listing, and chosen-column selection to shared readers and structured statements.
Accounting transfer reads
src/shared/accounting/*.ts, test/shared/accounting/rows.test.ts
Updates transfer reads to accept structured options, shared ordering, equality predicates, and inList filters.
Application database reads
src/shared/db/activityLog.ts, src/shared/db/api-keys.ts, src/shared/db/images.ts, src/shared/db/news-posts.ts, src/shared/db/notes/*, src/shared/db/questions/*, src/shared/db/site-pages.ts
Migrates database reads from raw SQL helpers to readRows, readOneRow, and structured predicates.
Structured listing exclusions
src/shared/db/groups/candidates.ts, src/shared/db/listings/catalog.ts
Replaces inline NOT IN SQL with notInSubquery while retaining listing ordering and visibility conditions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main refactor: expressing reads through shared abstractions instead of hand-written SQL.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/attendee-pattern-collections-pfvtod
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/attendee-pattern-collections-pfvtod

Comment @coderabbitai help to get the list of available commands.

@stefan-burke stefan-burke changed the title Say a read once, in one shape, everywhere Say what a read wants, instead of writing out its SQL each time Jul 29, 2026
A table already declares its columns, their types, and how to open them. A
read of that table had to say all of it again: the column list as a string,
the row shape as a type, and a separate call to decrypt.

`readerFor(table)` says it once. The filter is the row itself — a value means
"equals", a list means "is one of" — so `one({ slug_index: index })` replaces a
column constant, a row annotation, a filter call and a decrypt call.

Two column lists that were transcriptions of a table's schema are gone. They
could drift from the table they copied and nothing would have noticed: the
site-pages one was a hand-written list of eight columns beside a schema built
from three spread helpers.

Because a filter is now the row's own shape, a wrong column name or a wrong
value type is a compile error rather than a runtime one. It found two things
straight away:

- `getSitePageBySlugIndex` took a plain string where the column is a branded
  blind index. Its only caller already passed the right thing.
- The API-key auth read fetched and decrypted the key's name on every API
  request, though the auth path never shows it. It now selects the columns it
  reads, so the one encrypted column is left alone.

Notes keep their hand-written column list: `system_notes` has no table object
to infer from, so there is nothing there to say twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UASQuuj6grCBBrHHQeqMCF

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ab1424d82

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +100 to +103
const whole = pick(table.columns as unknown as ColumnNames<Row>) as Rows<
Row,
Row
>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not cast physical columns to the whole row

When readerFor receives a table with projected fields, this cast makes one and many promise a complete Row even though defineTable deliberately excludes projected fields from table.columns. For example, the rawListingsTable used by the new test omits day_prices and all three image fields, so accessing those required Listing properties can fail despite the returned type claiming they exist. Preserve the physical-row type or require callers to supply the projections instead of asserting this is the whole row.

AGENTS.md reference: AGENTS.md:L132-L137

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 340325c. This was the real one — thank you.

You're right about the mechanism: defineTable sets columns to physicalColumns, filtering out anything projected, so casting that list to the whole row makes one/many promise fields the read never selected. rawListingsTable has four (day_prices, image_alt_text, image_thumb_url, image_url), and my own test used exactly that table — it passed only because it asserted on name.

Rather than preserve a physical-row type (which can't be computed — Table.columns is readonly string[], with no type-level record of which columns are projected), I made the invariant real instead of asserted: readerFor now refuses a table that works any value out from elsewhere, and says to choose the columns with chooseColumns instead. Where the reader exists, its stored columns are the whole row, so the cast is sound.

That's not a loss in practice — every narrow listing read (listingOptionColumns, candidateColumns, catalogListingColumns) already goes through chooseColumns.

Regression test is "refuses a table whose row is more than its stored columns" in test/shared/db/table-reader.test.ts, written before the fix and confirmed failing against the old code:

fail [########################] 11/11 refuses a table whose row is more than its stored columns
FAILED 10 passed, 1 failed

The DB-backed tests moved to attributesTable, which stores its whole row.


Generated by Claude Code

Comment thread src/shared/db/attendees/select.ts Outdated
* rather than the booking.
*/
export const attendeeFromWhere = (
export const attendeeFrom = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the attendee table helper private

A repo-wide search finds no importer of attendeeFrom; it is used only by attendees in this same module. Exporting it creates a second public surface for assembling attendee reads without any production caller, so remove export and keep the shared defineReader interface as the supported mechanism.

AGENTS.md reference: AGENTS.md:L81-L81

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — fixed in 340325c, the export is gone.

Confirmed your search: attendeeFrom is referenced only by attendees in the same module. I created it while splitting the old attendeeFromWhere and carried the export across without checking whether anything still needed it. Nothing did, and a second way to assemble an attendee read is exactly what this change was meant to remove.


Generated by Claude Code

Comment thread src/shared/db/read.ts Outdated
Comment on lines +6 to +10
* keeping these rows, in this order, at most this many. Each reader used to
* write that sentence out itself, gluing `SELECT`, `FROM`, the WHERE tail and
* the ORDER BY together by hand, and each one separately remembered to skip the
* database when its filter could match no row. That is the handwriting this
* module removes: a reader describes the sentence and this assembles it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Describe the current read contract instead

This new module comment is largely a comparison with the previous implementation (used to write, handwriting this module removes) rather than information needed to understand the current API. Remove the historical paragraph and retain only the concise current invariant about how a Read is assembled.

AGENTS.md reference: AGENTS.md:L71-L71

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — fixed in 340325c.

You're right that it broke the rule I was supposed to be following: "used to write" and "the handwriting this module removes" describe what the code replaced, which git already records. The header now states only the current contract — what a read is, and that assembling it and skipping the database on an impossible filter happen here.


Generated by Claude Code

Comment thread src/shared/db/table-reader.ts Outdated
Comment on lines +48 to +51
Object.entries(filter).flatMap(([column, value]) =>
Array.isArray(value)
? inList(column, value as readonly InValue[])
: equals(column, value as Exclude<InValue, null>),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Convert filter values to their stored form

When a readerFor filter names a transformed column, these clauses bind the application value directly rather than applying the table column's write transform. For example, filtering apiKeys or newsPostsTable by an encrypted name is accepted by RowFilter but compares plaintext with ciphertext and silently returns no row. Either convert filters through the column definition or reject non-searchable transformed columns loudly instead of exposing a typed operation that produces a false miss.

AGENTS.md reference: AGENTS.md:L105-L110

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 340325c. A genuine hole, and the worst kind — a silent wrong answer rather than an error.

RowFilter accepted { name: "Sealed" } on a table whose name carries a write transform, bound the plaintext, and the database compared it against ciphertext. No match, no complaint, reads as "no such record".

Of the two options you offered I took the second — refuse it. Converting through the column definition would be worse here: an encrypted column's write transform is non-deterministic (a fresh nonce per write), so the converted value would never equal the stored one either. It would look like it worked and still find nothing. A column meant to be searched already carries a separate one-way index column (slug_index, key_index, username_index), which is what a filter should name.

So filterClauses now throws as the call is made, naming the table and column and pointing at the index column. Worth noting it throws synchronously, before any query is built — my first test asserted a rejected promise and failed for that reason, which is how I noticed.

Regression test: "refuses to filter on a column stored in another form" in test/shared/db/table-reader.test.ts, using attributesTable, whose name is encrypted.

No caller was affected — every existing filter names a plain column (id, user_id, key_index, slug_index, sort_order).


Generated by Claude Code

claude added 2 commits July 29, 2026 15:17
Two ways the reader could give a wrong answer quietly, both now refused.

A table whose row holds values it does not store — listings works its images
and day prices out from other tables — cannot be read whole, because the
columns it stores are not the whole row. The reader promised one anyway. It
now refuses such a table and says to choose the columns instead.

A filter naming a column stored in another form — an encrypted name — would
have compared the plain word against ciphertext, matched nothing, and read as
"no such record". It is now refused as the call is made.

Also: the module comment for a read described what the code replaced rather
than what it does, and `attendeeFrom` was exported with no importer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UASQuuj6grCBBrHHQeqMCF

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c339559d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/db/where-clauses.ts Outdated
Comment on lines +70 to +73
export const inSubquery = bySubquery("IN");

/** Keep rows the subquery does NOT name. */
export const notInSubquery = bySubquery("NOT IN");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Declare explicit types for the subquery helpers

Both new exports are function values whose public signatures are inferred from bySubquery, rather than checked against a declared contract. Give inSubquery and notInSubquery explicit function annotations or define them as exported wrapper functions with explicit return types, so changes to the shared builder fail at the definition instead of silently changing callers.

AGENTS.md reference: AGENTS.md:L97-L97

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — fixed in 9a93559.

Both were curried out of bySubquery, so their public signatures were whatever the builder happened to infer. They now declare a shared SubqueryFilter contract, so a change to the builder fails where it is defined rather than quietly changing what callers may pass.

Naming the type also made the pair read as what they are: two specialisations of one filter, differing only by keyword.


Generated by Claude Code

Comment thread src/shared/db/listings/select.ts Outdated
Comment on lines +134 to +135
export const listingStatement: (query: GetListingsQuery) => SqlStatement =
listings.statement;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expose the listing reader instead of an alias

This new export is only another name for listings.statement, so internal callers now see two names for the same mechanism. Export the reader itself and migrate callers to listings.statement rather than retaining this alias.

AGENTS.md reference: AGENTS.md:L79-L79

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — fixed in 9a93559. It was a pure alias: listingStatement was assigned straight from the reader's own statement, adding nothing.

The reader is now exported as listingReader, and all seven callers say listingReader.statement(...)groups.ts, activityLog.ts, listings/attendees.ts, listings/records.ts, and three test files. The alias is gone rather than kept alongside.

I named the export listingReader rather than listings deliberately: listings is the domain word used everywhere for an array of listing records, so an import called listings that is actually a query mechanism would read as the wrong thing at every call site.

getListingRows stays, since it is not an alias — it names the row type the raw read returns (listingReader.rows<ListingRecordRow>), which is the "thin wrapper that adds something" case.


Generated by Claude Code

Comment thread src/shared/db/read.ts Outdated
Comment on lines +81 to +84
export const namedOrder = <Order extends string>(
orders: Record<Order, string>,
order: Order | undefined,
): string | undefined => (order === undefined ? undefined : orders[order]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep namedOrder private

A repo-wide search finds that namedOrder has no production importer: its only production use is inside defineReader in this module, while the only external import is its direct unit test. Remove the export and test the behavior through defineReader instead of adding a public surface solely for tests.

AGENTS.md reference: AGENTS.md:L81-L81

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — fixed in 9a93559. namedOrder is private again.

Confirmed your search: its only production use was inside defineReader in the same module, and its only import was its own unit test. It had been exported for the two readers to call before defineReader existed, and I left the export behind when they stopped needing it.

The order behaviour is still covered, now through the mechanism production actually uses: defineReader is built with two named orders in test/shared/db/read.test.ts, and the tests assert the generated SQL carries the right ORDER BY for each, and none at all when no order is asked for. That is a stronger test than the old one — it pins the clause as it reaches the query rather than the lookup in isolation.


Generated by Claude Code

`listingStatement` was only another name for the reader's own `statement`,
so callers saw two names for one mechanism. The reader itself is now
exported and its seven callers say `listingReader.statement(...)`.

`namedOrder` had no caller outside its own module — only its test — so it is
private again, and the order behaviour is tested through `defineReader`,
which is what production uses.

The two subquery filters carried inferred signatures; they now declare one,
so a change to the shared builder fails where it is defined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UASQuuj6grCBBrHHQeqMCF
@stefan-burke
stefan-burke added this pull request to the merge queue Jul 29, 2026
Merged via the queue into main with commit 487194c Jul 29, 2026
3 checks passed
@stefan-burke
stefan-burke deleted the claude/attendee-pattern-collections-pfvtod branch July 29, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants