* fix(announcements): keep creator posts in the Announcements tab, unbreak the domain chips, clamp the window
Three reports from creators, plus two follow-ups.
Announcements were arriving as an "X made an announcement" row in the Updates
tab. That fan-out duplicated the Announcements tab, which already resolves
followers at read time via getFollowedAnnouncements, and buried the surface
that owns the feature. Drop prepareQuery so nothing new is generated; keep
prepareMessage, because getNotificationMessage resolves at render time and
removing the entry would blank out every notification already delivered.
The Creator Studio dashboard rendered one chip per letter of "{green,blue}".
pg has no parser for arrays of a user-defined enum, so a DomainColor[] column
arrives as that raw Postgres literal, and [...new Set(value)] over a string
iterates its characters. The main app registers the parsers in
instrumentation.node.ts; the spoke never did. Register them in a SvelteKit
init hook (awaited before the first request) and normalise at the read site
too, since that registration is fail-open by design.
Start and end dates were unbounded. Clamp on arrival rather than refusing: a
wall-clock picker can submit a start that went stale while the creator wrote
the message. The start slides up to now, the end out to start + 1h. A start
the creator did not touch is left alone - re-stamping it would republish a
running announcement to the top of every follower's feed on a typo fix.
Also: label domains by host (civitai.com / civitai.red) rather than by colour,
since the colour names do not match the sites; and give the composer chips a
check mark, which creators reported was the only way to tell selected from
unselected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xboS6BWLNnDcxz67WsFT4
* style: prettier formatting for toDomainArray
* refactor: name the clamped window `schedule`, not `window`
* docs+comments: correct a false comment, enforce the mirrored constant, unstale two READMEs
Fallout from running comment-review and docs-drift over the change.
The comment on domain-array.test.ts claimed `$lib` imports fail collection in
this project's vitest. That is false -- apps/creator-studio/vitest.config.ts
aliases `$lib` for exactly this case, and a `$lib` import collects and passes.
The comment would have taught the next author to avoid `$lib` in every future
creator-studio test, and could have got the alias deleted as dead config.
Removed, and the import switched to `$lib`.
MIN_ANNOUNCEMENT_DURATION_MS is declared in two apps that cannot import across
the boundary. The test named the invariant but only pinned the main-app copy,
so drifting the picker's number stayed green. It now reads the creator-studio
source and asserts the two agree; drifting it to 30 minutes fails.
Two READMEs went stale with the pool change: civitai-db documented the
createKyselyClients return shape, and db-queries named the main app as the only
registerEnumArrayTypeParsers call site. Both corrected.
The rest is comment trimming against CLAUDE.md's keep test -- reviewer
justification, what-narration, and one rationale that had been written three
times. Also corrected an adjacent comment on getFollowedAnnouncements that
still implied a per-announcement notification path exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xboS6BWLNnDcxz67WsFT4
* fix(announcements): distinct chip hover, and a 500-character content limit
Two pieces of review feedback.
The toggle chips looked identical hovered and selected. That is stock
shadcn: the `outline` variant sets `hover:bg-accent`, and the base sets
`data-[state=on]:bg-accent` -- the same token. Fixed in the shared variant
rather than at the call site, since all four ToggleGroups that use `outline`
(all in creator-studio) have the same confusion. Three weights of one token,
which stays distinct in both themes -- `--muted` and `--accent` are the same
colour in light mode, so reverting the hover to `bg-muted` would not have
fixed it.
Content drops from 5000 to 500. The card that renders it has no line clamp,
so whatever is stored is drawn in full, in a 710px drawer or a 540px
container -- 500 is roughly 7-11 lines there, and an announcement is meant to
point at something rather than be the thing.
500 alone would have trapped people. The pending profile-banner backfill
inserts 25,655 rows from UserProfile.message, and 8,757 of those are longer
than 500 (measured on prod: p50 152, p99 1200, max 1232) -- so a creator
opening their own migrated banner to fix a typo could not have saved it. The
schema keeps a higher hard ceiling and the service enforces 500 on anything
new or lengthened, so an over-long legacy row can be edited and shortened,
never grown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xboS6BWLNnDcxz67WsFT4
* fix(announcements): three defects an adversarial review found in the previous commits
All three reproduced before fixing. Two were real bugs; the third was my
testing being unable to see them.
The `untouched` guard was defeated by the picker it exists to serve.
`datetime-local` cannot express seconds, so the composer round-trips a stored
start through YYYY-MM-DDTHH:mm and hands back a value truncated to the minute.
An exact timestamp comparison therefore reported "changed" for every row whose
start carried seconds -- which is exactly the rows the guard protects, since a
row crossing into notifying is stamped with `new Date()`. Result: a typo fix
republished a running announcement to the top of every follower's feed, the
outcome the comment above it claims to prevent. Now compared at minute
granularity, which is all the picker can express, so a real reschedule always
differs by a whole minute and cannot be masked.
The content grandfather clause was unreachable from the only creator UI.
creator-studio's own form schema capped at CONTENT_MAX, so a migrated 900-char
banner was refused in the spoke before it ever reached the main app that
decides whether an over-long row may be saved. The form now bounds at a
ceiling and lets the main app judge.
Neither of those could be caught by the tests I wrote, because none of them
drove `upsertCreatorAnnouncement`: deleting BOTH new guards from the write
path left 347 files and 4814 tests green. The unit tests exercised the
functions and nothing asserted anything called them. The window test also
passed the same Date object on both sides of the comparison, so it could not
see a lossy round-trip by construction. Wiring tests added to the boundary
suite, which was already the only place driving the real write path -- and its
ownership mock lacked the two fields the service now selects, leaving both
branches permanently undefined there.
Verified by re-running the exact mutations: unwiring both guards now fails 3,
reverting the minute comparison fails the named regression, and capping the
spoke schema again fails the legacy-content test.
Also: the PR described the shared toggle change as touching 4 ToggleGroups. It
is 13 across 8 files -- my grep required the variant on the same line. Nothing
in moderator, which was the half that mattered and which does hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xboS6BWLNnDcxz67WsFT4
* fix(announcements): re-review round -- reattach the load-bearing comment, defuse a dated test, correct a false premise
A second adversarial pass over the fix commit. It found that the fix round
reintroduced the defect it had just deleted, planted a time bomb, and wrote
down a reason that is not true.
The 🔴 rationale explaining why an untouched past start is NOT re-stamped had
become detached from clampAnnouncementWindow: my sameMinute helper landed
between the doc block and the function, so both stacked blocks documented
MINUTE_MS, a numeric constant. That comment is the most load-bearing one here
and it no longer surfaced on the function it describes. Helpers moved above it.
The stated premise was false. "The picker cannot produce a sub-minute edit"
is not true of every writer -- the REST endpoint and the tRPC router both take
arbitrary instants. The fix is still correct, for a stronger reason now
written down instead: `YYYY-MM-DDTHH:mm` IS a floor-to-the-minute, so a value
derived by that round-trip always lands in the same bucket as the value it
came from, whatever produced the original. That is a property of the
transform, not of the widget. The residual case -- two API-supplied starts
under a minute apart reading as unchanged -- leaves the start alone, the safe
direction, and is noted.
Wiring the clamp made a hardcoded `2026-12-01` in a pre-existing test live:
it passes today and fails from that date onward, in a file whose failure would
read as a regression in upsertCreatorAnnouncement. Now relative.
Also: the content ceiling was raised for everyone, replacing a keystroke-level
stop at 500 with a form round-trip that fails. The input now bounds at the
seeded row's own length, so only a row that was already over the limit gets
the higher bound. Two tests tightened to pin both sides of a boundary rather
than one, one renamed to match what it asserts, and four narrow mock overrides
widened to match what the service selects.
Both mutations still fail as intended after the test rework: unwiring the
guards fails 3, reverting the minute comparison fails the named regression.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xboS6BWLNnDcxz67WsFT4
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@civitai/db-queries
The monorepo's shared data-access layer: typed Kysely query functions over each app's own Postgres pools,
consumed by the main app and the SvelteKit spokes. Prisma stays the schema/migration source of truth and
generates the DB type (@civitai/db-schema/kysely); this package is queries only.
See docs/db-queries-kysely-plan.md for the migration plan.
Structure
-
src/infra/helpers.ts— shared query helpers (jsonArrayFrom/jsonObjectFromreads,toJsonwrites). -
src/<domain>.db.ts— one query module per domain, exported at the subpath@civitai/db-queries/<domain>. Domain modules are (re)ported on demand, per cutover, from currentmain(see the migration plan) rather than kept ahead of their consumers where they'd drift. Today:tag.db.ts(the original exemplar, plus the votable-tag vote lookups) andmodel.db.ts.The first ported hot read paths are the per-user vote lookups behind the votable-tag endpoint (
listImageTagVotes/listImageTagVotesMany/listModelTagVotes) and the per-visible-set engagement membership read (listModelEngagements). These went to Kysely specifically to keep the highest-volume read statements off the Prisma query engine, whose in-process CPU cost scales with call volume. Their behaviour parity with the Prisma originals is pinned against a real database by<civitai>/src/server/db/__tests__/kysely-prisma-parity.test.ts— see Testing below.
Client access — executor injection
Every query function takes a Kysely client as its first argument (db: Kysely<DB>); the caller decides
which client — read, write, a replica, or an open transaction. The package owns no client vars and does
no boot-time wiring (no connect(), no globalThis, no proxies). Each app builds its clients over its
existing pools and passes them in.
// a query module (illustrative)
import type { Kysely } from 'kysely';
import type { DB } from '@civitai/db-schema/kysely';
export function setReportStatus(db: Kysely<DB>, input: { id; status; userId }) {
return db.updateTable('Report').set(/* … */).where('id', '=', input.id).executeTakeFirst();
}
// app — the app owns the clients (main app: src/server/db/kyselyDb.ts) and passes one per call
import { kyselyRead, kyselyWrite } from '~/server/db/kyselyDb';
await setReportStatus(kyselyWrite, input);
await getReports(kyselyRead, input);
Why first-arg injection rather than an ambient singleton: it makes transactions compose (Transaction<DB>
satisfies Kysely<DB>, so pass a trx to run several statements atomically), keeps functions
tree-shakeable and trivially testable, and lets routing (read/write/replica/lag-aware) be a per-call
decision instead of frozen into the query. The main-app-only tiers (kyselyReadLong, kyselyDatapacket) are
just clients the app can choose to pass; a spoke that never builds them simply never passes them.
Binding once per scope (optional sugar): if passing db at every call site is noisy in a hot handler, an
app can wrap a domain — const reports = createReportsRepo(db) returning db-free methods — so db appears
once per request/transaction. Keep this as an app-local convenience; the package exports plain
functions (a factory object closing over every builder would defeat tree-shaking).
Authoring conventions
- Imports: named imports only — no
import * as. - Function shape — every exported query function follows the same signature so call sites read uniformly:
- First parameter is always
db: Kysely<DB>— the executor the caller supplies (see Client access). A compose function passes itsdb(or atrxit opens) through to each callee. - Inputs after
db: a singleinputobject for anything with two or more fields or any optional field —setReportStatus(db, { id, status, userId }). This is what makesinsertUserCosmeticGrant(db, { userId, cosmeticIds })right and(db, userId, cosmeticIds)wrong. Never take two or more positional data args. A lone required id/array may be positional (getImage(db, imageId),getImages(db, ids)) — don't wrap a single value just for ceremony. - Always execute — end the builder in
.execute()/.executeTakeFirst()(orsql\…`.execute(db)) and return the result/Promise`. Do not return an un-executed query builder for the caller to run; it breaks the uniform call shape and hides the execution point.
- First parameter is always
- Prefer a generic
update<Entity>over narrow single-column setters. Each entity has oneupdate<Entity>(db, input: Updateable<DB['<Entity>']> & { id: number })({ id, ...data }), which stampsupdatedAtautomatically when the table has an@updatedAtcolumn, and aupdate<Entity>Many(db, { ids } & Updateable<…>)bulk variant where needed (guarding the empty-id case). A trivialSET <one column> WHERE idbelongs at the call site asupdateX(db, { id, <col>: value }), not a bespokeset<Entity><Column>function. Keep a named function only when it (a) sets two or more columns as a specific semantic transition (e.g.setImageAppealRestored), (b) needs a jsonb/CASE/expression or stored-proc write (the generic sets columns raw — a jsonb column must go throughtoJson(), so it can't collapse), (c) toggles / negates a column, or (d) keys on something other thanid. - Method names:
<verb><Entity>[Qualifier]— entity-prefixed so each name is self-identifying at the call site.- Verbs:
get/list/countfor reads;set/upsert/insert/deletefor writes. - Singular entity +
Manyfor bulk — a bulk variant keeps the entity singular and appendsMany:setReportStatus→setReportStatusMany(notsetReportsStatusMany). - Examples:
getReportReporters,setReportStatus,setReportStatusMany.
- Verbs:
- Name collisions: a db method may share a name with a higher-level service function (e.g. the spoke's
setReportStatusservice wraps the dbsetReportStatus). Resolve with a local import alias in that file —import { setReportStatus as setReportStatusDb }— the package export stays canonical. - Transactions: native Kysely. A compose function takes
dband opens the transaction on it —db.transaction().execute((trx) => …)— passingtrxas the first arg to each statement function (Transaction<DB>satisfiesKysely<DB>). Prefer a single atomic statement (e.g. bulkUPDATE … WHERE id IN (…) RETURNING) where it suffices. Kysely and Prisma transactions do not compose — migrate whole transactional units together. - Read-your-writes: the caller picks the client. The main app's
getKyselyWithoutLag(type, id)returns the lag-correct client (read or write) via the shared lag tracker; pass its result as the query'sdb.
Reads — nested relations
Replace a Prisma include/nested select with jsonArrayFrom/jsonObjectFrom (re-exported from this
package). They build a correlated jsonb subquery and the result type is inferred — no hand-authored
selector/GetPayload type. Postgres parses the jsonb itself (no result-parsing plugin needed).
import { jsonArrayFrom } from '@civitai/db-queries';
db.selectFrom('Model').select((eb) => [
'Model.id',
'Model.name',
jsonArrayFrom(
eb
.selectFrom('ModelVersion')
.select(['id', 'name'])
.whereRef('ModelVersion.modelId', '=', 'Model.id')
).as('versions'),
]); // inferred: { id; name; versions: { id; name }[] }[]
Gotcha: dates/bigints nested inside the json come back as strings (JSON serialization) even though the
inferred type says Date/number. Parse those fields where a real Date/bigint is needed.
Writes — Prisma-parity gotchas
- jsonb columns: wrap the value in
toJson(). node-postgres serializes a plain object fine but turns a JS array into a Postgres array literal ({1,2}) — wrong for jsonb.toJson()is unambiguous for both:.set({ meta: toJson(metaObject) }). @updatedAtis handled by a plugin — don't set it by hand. Prisma bumped@updatedAtclient-side; Kysely doesn't. The app installsupdatedAtPlugin(exported here) on its write client, which auto-stampsupdatedAton everyUPDATEto an@updatedAttable (the table set is generated from the schema). A ported write just sets its own columns. Consumers must install the plugin (main app:src/server/db/kyselyDb.ts) — a client without it silently stops bumpingupdatedAt. To deliberately not bump (the source used raw$executeRawto dodge it — e.g. a scan recompute that must not reorder a "recently updated" feed), opt out withkeepUpdatedAt:.set({ ..., updatedAt: keepUpdatedAt }). The plugin only rewrites plainUPDATEs — INSERTs andON CONFLICT DO UPDATEstill setupdatedAtexplicitly.- Postgres enum-ARRAY columns need a parser. pg has no parser for an array of a user-defined enum (dynamic
oid), so a
SomeEnum[]column reads back as the raw{a,b}literal string. Consumers callregisterEnumArrayTypeParsers(pool)(from@civitai/db/kysely) once at startup — main app:src/instrumentation.node.ts; SvelteKit spokes: aServerInitinitexport inhooks.server.ts(SvelteKit awaitsinitbefore the first request), using thepoolreturned bycreateKyselyClients. Both register fail-open, so a read site that can receive an enum-array column must still tolerate the raw literal —{green,blue}is a string that every array operation accepts and iterates by character (seetoDomainArrayinapps/creator-studio/src/lib/announcements.ts). Scalar enums are fine without it. - Nested writes (Prisma
connect/connectOrCreate/nestedcreate): decompose into explicit statement functions, and have a compose function opendb.transaction().execute((trx) => …)and passtrxas each statement'sdb. See Tag links below for the canonical example.
Tag links (connectOrCreate / sync)
Porting a Prisma tagsOnModels-style nested connectOrCreate/deleteMany: decompose into a
sync<Entity>Tags(db, { entityId, existingTagIds, newTagNames }) run inside the upsert transaction. It (1)
unlinks tags not in the kept set (empty kept-set ⇒ unlink all), (2) creates brand-new tags by name via the
shared upsertTagsByName(db, names, target) — one batched ON CONFLICT DO NOTHING insert + one id lookup,
not a per-tag loop — (3) links kept + new ids in one idempotent batch. See syncModelTags.
When a second simple tag join lands (Post/Article/Collection/Bounty — not the Image tag system, which has
required source/attributes columns), promote to a generic syncEntityTags(db, { joinTable, entityColumn, … }). It needs db.dynamic.ref + a contained cast — Kysely can't statically type a dynamic table/column pair
(same limitation as an abstract getById) — behind typed per-entity wrappers.
Correctness rules
- Guard empty arrays before
where('col', 'in', arr). Kysely compilesin ([])toIN (), a Postgres syntax error (Prisma silently no-op'd). Any bulk query taking an id/array input must short-circuit:if (!input.ids.length) return [];before executing. (SeesetReportStatusMany.)
Testing
Every query gets a test. There are two tiers — the first is required, the second is required for hot paths:
Run the in-package tests with pnpm --filter @civitai/db-queries test (Vitest; test:watch to iterate).
They are wired into CI the same way the other packages' vitest run scripts are.
1. Compiled-SQL tests (required, no DB)
Assert the exact SQL + parameters a query function compiles to. This is the cheap, deterministic guard
that runs in CI with no database: it catches a refactor that silently drops a where filter, reorders a
set clause, or would emit IN () for an empty array. Use the offline harness in
src/test/harness.ts — compileHarness() returns a db (a Kysely DummyDriver
client) you pass to the query; .execute() compiles the SQL (captured via the log hook) and resolves to an
empty result without a pool.
import { compileHarness } from './test/harness';
import { setReportStatusMany } from './reports.db';
const h = compileHarness();
it('bulk-updates the given ids in one statement', async () => {
await setReportStatusMany(h.db, { ids: [1, 2, 3], status: 'Actioned', userId: 99 });
const { sql, parameters } = h.lastQuery();
expect(sql).toContain('where "id" in ($4, $5, $6)');
expect(sql).not.toContain('in ()');
expect(parameters).toEqual(['Actioned', expect.any(Date), 99, 1, 2, 3, 'Actioned']);
});
it('short-circuits an empty id list without touching the DB', async () => {
const result = await setReportStatusMany(h.db, { ids: [], status: 'Actioned', userId: 99 });
expect(result).toEqual([]);
expect(h.queries).toHaveLength(0); // the empty-array guard the correctness rules require
});
See src/tag.db.test.ts for a full example (insert/update/delete and the batched-upsert
clauses, the single-row setReportStatus, and the empty-array guard).
2. Behavior + execution-plan checks (required for hot paths, needs a live DB)
The compiled-SQL test proves what SQL runs, not that it's valid against the real schema. explainHarness()
gives you a db (still the DummyDriver, so passing it to a query COMPILES without executing — safe for
writes) plus explainLast()/explainAll(), which EXPLAIN (no ANALYZE) the compiled SQL against a live
Postgres: it parses + plans the statement without running it, so a query whose columns/joins/types/proc
signatures don't resolve fails here even though the compile test passed. Env-gated (TEST_DATABASE_URL, or
the root .env DATABASE_URL locally); the suite describe.skipIf(!h.hasDb)-skips when no DB is reachable.
Wrap it and destroy the client in afterAll. See src/tag.db.explain.test.ts.
Stricter plan-regression assertions (no seq-scan on a hot path) need a prod-like dataset — dev-DB planner
choices vary with table size — so keep those against real data, not this suite.
3. Prisma-vs-Kysely behaviour parity (for a query ported off Prisma)
Tiers 1 and 2 prove what SQL runs and that it plans. Neither proves the caller sees the same thing it saw
before, which is the whole risk of porting a live read path. For that, run BOTH implementations against the
SAME rows in the SAME database and deep-compare the results, rather than asserting the Kysely result against
a hand-written expectation (which only re-states what the new code does). This lives in the consuming app,
since the package itself must stay Prisma-free: <civitai>/src/server/db/__tests__/kysely-prisma-parity.test.ts,
with its schema fixture alongside. Cover the empty result, an empty input array (the IN () guard), and
cross-user/cross-entity isolation — a dropped predicate shows up as extra rows, not as an empty diff.
It is opt-in via KYSELY_PARITY_DATABASE_URL and deliberately does NOT fall back to DATABASE_URL: it
writes fixtures, so it needs a throwaway database. The test header has the one-line docker command.
Example
// example query module (illustrative)
import type { Kysely } from 'kysely';
import type { DB } from '@civitai/db-schema/kysely';
// Single: transition one report, RETURNing the reporters only if it actually changed.
export function setReportStatus(
db: Kysely<DB>,
input: { id: number; status: ReportStatusValue; userId: number }
) {
return db
.updateTable('Report')
.set(/* … */)
.where('id', '=', input.id)
.where('status', '!=', input.status)
.returning(['userId', 'alsoReportedBy'])
.executeTakeFirst();
}
// Bulk: same transition across many reports in one atomic statement (no explicit transaction).
export function setReportStatusMany(
db: Kysely<DB>,
input: { ids: number[]; status: ReportStatusValue; userId: number }
) {
if (!input.ids.length) return []; // guard: Kysely compiles `in ([])` to `IN ()` (a syntax error)
return db
.updateTable('Report')
.set(/* … */)
.where('id', 'in', input.ids)
.where('status', '!=', input.status)
.returning(['id', 'userId', 'alsoReportedBy'])
.execute();
}