···2222 field?: string;
2323 match?: "uri" | "did";
2424 groupBy?: string;
2525+ /** Enable materialized count columns on the parent. Defaults to true. */
2626+ count?: boolean;
2527}
26282729/** A forward reference: this collection's records point at another collection. */
+85-35
src/core/db/records.ts
···13131414// --- Counts ---
15151616+interface InboundRelation {
1717+ parentCollection: string;
1818+ relationName: string;
1919+ rel: RelationConfig;
2020+}
2121+1622function getInboundRelations(
1723 config: ContrailConfig,
1824 foreignCollection: string
1919-): RelationConfig[] {
2020- const results: RelationConfig[] = [];
2121- for (const [, colConfig] of Object.entries(config.collections)) {
2222- for (const [, rel] of Object.entries(colConfig.relations ?? {})) {
2525+): InboundRelation[] {
2626+ const results: InboundRelation[] = [];
2727+ for (const [colName, colConfig] of Object.entries(config.collections)) {
2828+ for (const [relName, rel] of Object.entries(colConfig.relations ?? {})) {
2329 if (rel.collection === foreignCollection) {
2424- results.push(rel);
3030+ results.push({ parentCollection: colName, relationName: relName, rel });
2531 }
2632 }
2733 }
2834 return results;
2935}
30363737+/**
3838+ * Build statements that fully recount child records for affected parents.
3939+ * Instead of +1/-1, we SELECT COUNT(*) so the count is always accurate.
4040+ * This runs for create, update, and delete operations.
4141+ */
3142function buildCountStatements(
3243 db: Database,
3344 event: IngestEvent,
3434- config: ContrailConfig
4545+ config: ContrailConfig,
4646+ existingRecordJson: string | null,
3547): Statement[] {
3636- if (event.operation !== "create" && event.operation !== "delete") return [];
3737-3848 const inbound = getInboundRelations(config, event.collection);
3949 if (inbound.length === 0) return [];
40504151 const record = event.record ? JSON.parse(event.record) : null;
4242- if (!record && event.operation === "create") return [];
5252+ const existingRecord = existingRecordJson ? JSON.parse(existingRecordJson) : null;
43534444- const isCreate = event.operation === "create";
4554 const statements: Statement[] = [];
5555+ const recountedTargets = new Set<string>();
46564747- for (const rel of inbound) {
4848- const targetUri = getNestedValue(record, getRelationField(rel));
4949- if (!targetUri) continue;
5757+ for (const { parentCollection, relationName, rel } of inbound) {
5858+ if (rel.count === false) continue;
50595151- const columns = [countColumnName(rel.collection)];
5252- if (rel.groupBy) {
5353- const groupValue = getNestedValue(record, rel.groupBy);
5454- if (groupValue != null) columns.push(countColumnName(String(groupValue)));
6060+ const field = getRelationField(rel);
6161+ const matchColumn = rel.match === "did" ? "did" : "uri";
6262+ const childCollection = rel.collection;
6363+6464+ // Collect target URIs/DIDs that need recounting (current + old if changed)
6565+ const targets: string[] = [];
6666+ if (record) {
6767+ const t = getNestedValue(record, field);
6868+ if (t) targets.push(t);
6969+ }
7070+ if (existingRecord) {
7171+ const t = getNestedValue(existingRecord, field);
7272+ if (t && !targets.includes(t)) targets.push(t);
5573 }
56745757- for (const col of columns) {
5858- statements.push(
5959- db
6060- .prepare(
6161- isCreate
6262- ? `UPDATE records SET ${col} = ${col} + 1 WHERE uri = ?`
6363- : `UPDATE records SET ${col} = MAX(${col} - 1, 0) WHERE uri = ?`
6464- )
6565- .bind(targetUri)
7575+ for (const targetValue of targets) {
7676+ const key = `${parentCollection}:${relationName}:${targetValue}`;
7777+ if (recountedTargets.has(key)) continue;
7878+ recountedTargets.add(key);
7979+8080+ const setClauses: string[] = [];
8181+ const setBindings: (string | number)[] = [];
8282+8383+ // Total count
8484+ const totalCol = countColumnName(rel.collection);
8585+ setClauses.push(
8686+ `${totalCol} = (SELECT COUNT(*) FROM records WHERE collection = ? AND json_extract(record, '$.${field}') = ?)`
6687 );
8888+ setBindings.push(childCollection, targetValue);
8989+9090+ // Grouped counts
9191+ if (rel.groupBy) {
9292+ const mapping = (resolvedRelationsMap as Record<string, any>)[parentCollection]?.[relationName];
9393+ if (mapping?.groups) {
9494+ for (const [groupValue, fullToken] of Object.entries(mapping.groups as Record<string, string>)) {
9595+ const groupCol = countColumnName(fullToken);
9696+ setClauses.push(
9797+ `${groupCol} = (SELECT COUNT(*) FROM records WHERE collection = ? AND json_extract(record, '$.${field}') = ? AND json_extract(record, '$.${rel.groupBy}') = ?)`
9898+ );
9999+ setBindings.push(childCollection, targetValue, groupValue);
100100+ }
101101+ }
102102+ }
103103+104104+ if (setClauses.length > 0) {
105105+ statements.push(
106106+ db
107107+ .prepare(
108108+ `UPDATE records SET ${setClauses.join(", ")} WHERE ${matchColumn} = ?`
109109+ )
110110+ .bind(...setBindings, targetValue)
111111+ );
112112+ }
67113 }
68114 }
69115···248294 const existingCids = new Map<string, string | null>();
249295 const existingRecords = new Map<string, string | null>();
250296 const followCollections = config ? getFeedFollowCollections(config) : [];
251251- const needRecordContent = followCollections.length > 0;
297297+ const hasCountingRelations = config ? Object.values(config.collections).some(c =>
298298+ Object.values(c.relations ?? {}).some(r => r.count !== false)
299299+ ) : false;
300300+ const needRecordContent = followCollections.length > 0 || hasCountingRelations;
252301253302 if (config && !options?.skipReplayDetection) {
254303 const uris = events.map((e) => e.uri);
···295344 }
296345297346 if (config) {
298298- // Skip count updates for replayed events:
299299- // - create/update where the record already exists with the same CID
300300- // - delete where the record doesn't exist
347347+ // Recount is idempotent — always run it for create/update/delete.
348348+ // Pass existing record so deletes and updates that change the target can recount the old parent.
349349+ const existingRecordJson = existingRecords.get(e.uri) ?? null;
350350+ batch.push(...buildCountStatements(db, e, config, existingRecordJson));
351351+352352+ // Feed fanout still needs replay detection
301353 const existing = existingCids.get(e.uri);
302354 const isReplay =
303355 e.operation === "delete"
304356 ? existing === undefined
305357 : existing === e.cid;
306358307307- if (!isReplay) {
308308- batch.push(...buildCountStatements(db, e, config));
309309- if (!options?.skipFeedFanout) {
310310- batch.push(...buildFeedStatements(db, e, config, existingRecords));
311311- }
359359+ if (!isReplay && !options?.skipFeedFanout) {
360360+ batch.push(...buildFeedStatements(db, e, config, existingRecords));
312361 }
313362 batch.push(...buildFtsStatements(db, e, config));
314363 }
···326375 const relMap = resolvedRelationsMap[collection] ?? {};
327376328377 for (const [relName, rel] of Object.entries(colConfig.relations)) {
378378+ if (rel.count === false) continue;
329379 columns.push({ type: rel.collection, column: countColumnName(rel.collection) });
330380 const mapping = relMap[relName];
331381 if (mapping) {
+1
src/core/db/schema.ts
···7878 for (const [collection, colConfig] of Object.entries(config.collections)) {
7979 const relMap = resolvedRelationsMap[collection] ?? {};
8080 for (const [relName, rel] of Object.entries(colConfig.relations ?? {})) {
8181+ if (rel.count === false) continue;
8182 // Total count column
8283 const totalCol = countColumnName(rel.collection);
8384 if (!addedColumns.has(totalCol)) {
+1
src/core/router/collection.ts
···285285 const counts: Record<string, number> = {};
286286287287 for (const [relName, rel] of Object.entries(relations)) {
288288+ if (rel.count === false) continue;
288289 const totalCol = countColumnName(rel.collection);
289290 const val = row[totalCol];
290291 if (val != null && val !== 0) counts[rel.collection] = val;
+1-2
src/core/router/notify.ts
···105105 did: parsed.did,
106106 collection: parsed.collection,
107107 rkey: parsed.rkey,
108108- // "update" skips count statements, "create" increments them.
109109- // Only use "create" if the record is genuinely new.
108108+ // Both "update" and "create" trigger a full recount of related records.
110109 operation: existing ? "update" : "create",
111110 cid: result.cid,
112111 record: JSON.stringify(result.value),