[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

type fuckery

Florian (Mar 17, 2026, 6:42 PM +0100) 5d3de98f f9a8ac36

+1347 -186
+7 -13
README.md
··· 57 57 When you run `pnpm generate`, queryable fields are derived from each collection's lexicon: 58 58 59 59 - **String fields** → equality filter (`?status=going`) 60 - - **Datetime/integer fields** → range filter (`?min=startsAt:2026-03-16`) 60 + - **Datetime/integer fields** → range filters (`?startsAtMin=2026-03-16&startsAtMax=2026-04-01`) 61 61 - **StrongRef fields** → `.uri` equality filter (`?subjectUri=at://...`) 62 62 63 63 You can override any auto-detected field by specifying `queryable` manually in config. ··· 101 101 | `actor` | `?actor=did:plc:...` or `?actor=alice.bsky.social` | Filter by DID or handle (triggers on-demand backfill) | 102 102 | `profiles` | `?profiles=true` | Include profile + identity info keyed by DID | 103 103 | `{field}` | `?status=going` | Equality filter on queryable string field | 104 - | `min` | `?min=startsAt:2026-03-16` | Range minimum (datetime/integer fields) or count minimum | 105 - | `max` | `?max=endsAt:2026-04-01` | Range maximum (datetime/integer fields) | 104 + | `{field}Min` | `?startsAtMin=2026-03-16` | Range minimum (datetime/integer fields) | 105 + | `{field}Max` | `?endsAtMax=2026-04-01` | Range maximum (datetime/integer fields) | 106 + | `{rel}CountMin` | `?rsvpsCountMin=10` | Minimum total relation count | 107 + | `{rel}{Group}CountMin` | `?rsvpsGoingCountMin=10` | Minimum relation count for a specific groupBy value | 106 108 | `hydrate` | `?hydrate=rsvps:10` | Embed latest N related records per record | 107 109 | `limit` | `?limit=25` | Page size (1-100, default 50) | 108 110 | `cursor` | `?cursor=...` | Pagination cursor | 109 - 110 - **Count filters** use the same `min`/`max` syntax with the count type as key: 111 - 112 - ``` 113 - # events with 10+ "going" RSVPs, 114 - # take care to parse the `#` character correctly in your HTTP client 115 - ?min=community.lexicon.calendar.rsvp#going:10 116 - ``` 117 111 118 112 **Hydration** returns related records grouped by `groupBy` value: 119 113 ··· 125 119 ### Examples (events) 126 120 127 121 ``` 128 - # Upcoming events with 10+ going, with RSVP records and profiles 129 - /xrpc/community.lexicon.calendar.event.getRecords?min=startsAt:2026-03-16&min=community.lexicon.calendar.rsvp#going:10&hydrate=rsvps:5&profiles=true 122 + # Upcoming events with 10+ going RSVPs, with RSVP records and profiles 123 + /xrpc/community.lexicon.calendar.event.getRecords?startsAtMin=2026-03-16&rsvpsGoingCountMin=10&hydrate=rsvps:5&profiles=true 130 124 131 125 # Events for a specific user (by handle) 132 126 /xrpc/community.lexicon.calendar.event.getRecords?actor=alice.bsky.social&profiles=true
+8
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "Bash(pnpm generate:*)", 5 + "Bash(pnpm typecheck:*)" 6 + ] 7 + } 8 + }
+1 -1
lexicons-pulled/README.md
··· 2 2 3 3 this directory contains lexicon documents pulled from the following sources: 4 4 5 - - atproto (nsids: app.blento.card, community.lexicon.calendar.event, community.lexicon.calendar.rsvp, community.lexicon.location.address, community.lexicon.location.fsq, community.lexicon.location.geo, community.lexicon.location.hthree) 5 + - atproto (nsids: app.blento.card, app.bsky.actor.profile, community.lexicon.calendar.event, community.lexicon.calendar.rsvp, community.lexicon.location.address, community.lexicon.location.fsq, community.lexicon.location.geo, community.lexicon.location.hthree)
+210 -40
scripts/generate-lexicons.ts
··· 95 95 return result; 96 96 } 97 97 98 + // Extract knownValues for a field from a collection's lexicon 99 + function getKnownValues(collection: string, fieldName: string): string[] { 100 + const filePath = findCollectionLexicon(collection); 101 + if (!filePath) return []; 102 + try { 103 + const doc = JSON.parse(readFileSync(filePath, "utf-8")); 104 + const props = doc.defs?.main?.record?.properties; 105 + if (!props) return []; 106 + const field = props[fieldName]; 107 + if (!field) return []; 108 + if (Array.isArray(field.knownValues)) return field.knownValues; 109 + return []; 110 + } catch { 111 + return []; 112 + } 113 + } 114 + 115 + // Default mapping: "community.lexicon.calendar.rsvp#going" → "going" 116 + function tokenShortName(token: string): string { 117 + const hash = token.indexOf("#"); 118 + return hash !== -1 ? token.slice(hash + 1) : token; 119 + } 120 + 98 121 // Clean generated dir (user-provided lexicons/ is untouched) 99 122 rmSync(GENERATED_DIR, { recursive: true, force: true }); 100 123 ··· 125 148 } 126 149 127 150 // Build record output shape, optionally typing the record field 128 - function buildRecordDef(collectionRef: string | null) { 151 + // countFields: e.g. [{ name: "rsvpsTotal", description: "..." }, { name: "rsvpsGoing", ... }] 152 + interface CountField { 153 + name: string; 154 + description: string; 155 + } 156 + 157 + function buildRecordDef(collectionRef: string | null, countFields?: CountField[], hasRelations?: boolean) { 158 + const properties: Record<string, any> = { 159 + uri: { type: "string", format: "at-uri" }, 160 + did: { type: "string", format: "did" }, 161 + collection: { type: "string", format: "nsid" }, 162 + rkey: { type: "string" }, 163 + cid: { type: "string" }, 164 + record: collectionRef 165 + ? { type: "ref", ref: collectionRef } 166 + : { type: "unknown" }, 167 + time_us: { type: "integer" }, 168 + }; 169 + 170 + if (countFields) { 171 + for (const cf of countFields) { 172 + properties[cf.name] = { type: "integer", description: cf.description }; 173 + } 174 + } 175 + 176 + if (hasRelations) { 177 + properties.hydrates = { 178 + type: "unknown", 179 + description: "Hydrated related records, grouped by relation name and groupBy value", 180 + }; 181 + } 182 + 129 183 return { 130 184 type: "object", 131 185 required: ["uri", "did", "collection", "rkey", "time_us"], 132 - properties: { 133 - uri: { type: "string", format: "at-uri" }, 134 - did: { type: "string", format: "did" }, 135 - collection: { type: "string", format: "nsid" }, 136 - rkey: { type: "string" }, 137 - cid: { type: "string" }, 138 - record: collectionRef 139 - ? { type: "ref", ref: collectionRef } 140 - : { type: "unknown" }, 141 - time_us: { type: "integer" }, 186 + properties, 187 + }; 188 + } 189 + 190 + // Read the inner record object schema from a collection's lexicon 191 + function getRecordObjectSchema(collection: string): any | null { 192 + const filePath = findCollectionLexicon(collection); 193 + if (!filePath) return null; 194 + try { 195 + const doc = JSON.parse(readFileSync(filePath, "utf-8")); 196 + const main = doc.defs?.main; 197 + if (main?.type === "record" && main.record) return main.record; 198 + return null; 199 + } catch { 200 + return null; 201 + } 202 + } 203 + 204 + function profileDefs() { 205 + const profiles = config.profiles ?? ["app.bsky.actor.profile"]; 206 + const extraDefs: Record<string, any> = {}; 207 + const objectRefs: string[] = []; 208 + 209 + for (const col of profiles) { 210 + const schema = getRecordObjectSchema(col); 211 + if (!schema) continue; 212 + // Create a local def name from the NSID, e.g. "app.bsky.actor.profile" → "appBskyActorProfile" 213 + const defName = col.split(".").map((p, i) => i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)).join(""); 214 + extraDefs[defName] = schema; 215 + objectRefs.push(`#${defName}`); 216 + } 217 + 218 + let recordField: any; 219 + if (objectRefs.length === 1) { 220 + recordField = { type: "ref", ref: objectRefs[0] }; 221 + } else if (objectRefs.length > 1) { 222 + recordField = { type: "union", refs: objectRefs }; 223 + } else { 224 + recordField = { type: "unknown" }; 225 + } 226 + 227 + return { 228 + profileEntry: { 229 + type: "object", 230 + required: ["did"], 231 + properties: { 232 + did: { type: "string", format: "did" }, 233 + handle: { type: "string" }, 234 + uri: { type: "string", format: "at-uri" }, 235 + collection: { type: "string", format: "nsid" }, 236 + rkey: { type: "string" }, 237 + cid: { type: "string" }, 238 + record: recordField, 239 + }, 142 240 }, 241 + ...extraDefs, 143 242 }; 144 243 } 145 244 ··· 238 337 }, 239 338 }); 240 339 340 + writeLexicon("contrail.admin.reset", { 341 + lexicon: 1, 342 + id: "contrail.admin.reset", 343 + defs: { 344 + main: { 345 + type: "query", 346 + description: "Delete all data from all tables", 347 + output: { 348 + encoding: "application/json", 349 + schema: { 350 + type: "object", 351 + required: ["ok"], 352 + properties: { 353 + ok: { type: "boolean" }, 354 + }, 355 + }, 356 + }, 357 + }, 358 + }, 359 + }); 360 + 241 361 // --- Per-collection endpoints --- 242 362 243 363 console.log("Generating collection endpoints..."); 244 364 245 365 // Collect resolved queryable fields for all collections 246 366 const resolvedQueryable: Record<string, Record<string, { type?: "range" }>> = {}; 367 + // Collect resolved relation mappings (short name → full token) for runtime 368 + const resolvedRelations: Record<string, Record<string, { collection: string; groupBy: string; groups: Record<string, string> }>> = {}; 247 369 248 370 for (const [collection, colConfig] of Object.entries(config.collections)) { 249 371 const collectionRef = getCollectionLexiconRef(collection); ··· 268 390 const getRecordsParamProps: Record<string, any> = { 269 391 limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }, 270 392 cursor: { type: "string" }, 271 - did: { type: "string", format: "did" }, 272 - include: { 273 - type: "string", 274 - description: "Comma-separated include names", 275 - }, 393 + actor: { type: "string", format: "at-identifier", description: "Filter by DID or handle (triggers on-demand backfill)" }, 394 + profiles: { type: "boolean", description: "Include profile + identity info keyed by DID" }, 395 + hydrate: { type: "string", description: "Embed related records, as relName:limit (e.g. rsvps:5). Repeatable." }, 276 396 }; 277 397 278 398 for (const [field, fieldConfig] of Object.entries(merged)) { 279 - if (fieldConfig.type !== "range") { 280 - getRecordsParamProps[fieldToParam(field)] = { 399 + const param = fieldToParam(field); 400 + if (fieldConfig.type === "range") { 401 + getRecordsParamProps[`${param}Min`] = { 402 + type: "string", 403 + description: `Minimum value for ${field}`, 404 + }; 405 + getRecordsParamProps[`${param}Max`] = { 406 + type: "string", 407 + description: `Maximum value for ${field}`, 408 + }; 409 + } else { 410 + getRecordsParamProps[param] = { 281 411 type: "string", 282 412 description: `Filter by ${field}`, 283 413 }; 284 414 } 285 415 } 286 416 287 - getRecordsParamProps["min"] = { 288 - type: "string", 289 - description: "Min filter as field:value (range fields or count types). Repeatable.", 290 - }; 291 - getRecordsParamProps["max"] = { 292 - type: "string", 293 - description: "Max filter as field:value (range fields). Repeatable.", 294 - }; 417 + // Build count fields and params from relations + knownValues 418 + const countFields: CountField[] = []; 419 + for (const [relName, rel] of Object.entries(colConfig.relations ?? {})) { 420 + // Total count 421 + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); 422 + countFields.push({ name: `${relName}Count`, description: `Total ${relName} count` }); 423 + getRecordsParamProps[`${relName}CountMin`] = { 424 + type: "integer", 425 + description: `Minimum total ${relName} count`, 426 + }; 295 427 296 - for (const relName of Object.keys(colConfig.relations ?? {})) { 297 428 getRecordsParamProps[`${relName}Preview`] = { 298 429 type: "integer", 299 430 minimum: 1, 300 431 maximum: 50, 301 432 description: `Number of ${relName} previews per record`, 302 433 }; 434 + 435 + // Per-group counts from knownValues 436 + if (rel.groupBy) { 437 + const knownValues = getKnownValues(rel.collection, rel.groupBy); 438 + const groupMapping: Record<string, string> = {}; 439 + for (const token of knownValues) { 440 + const shortName = tokenShortName(token); 441 + groupMapping[shortName] = token; 442 + countFields.push({ 443 + name: `${relName}${capitalize(shortName)}Count`, 444 + description: `${relName} count where ${rel.groupBy} = ${shortName}`, 445 + }); 446 + getRecordsParamProps[`${relName}${capitalize(shortName)}CountMin`] = { 447 + type: "integer", 448 + description: `Minimum ${relName} count where ${rel.groupBy} = ${shortName}`, 449 + }; 450 + } 451 + // Store mapping for runtime use 452 + if (!resolvedRelations[collection]) resolvedRelations[collection] = {}; 453 + resolvedRelations[collection][relName] = { 454 + collection: rel.collection, 455 + groupBy: rel.groupBy, 456 + groups: groupMapping, 457 + }; 458 + } 303 459 } 460 + 461 + const hasRelations = Object.keys(colConfig.relations ?? {}).length > 0; 304 462 305 463 writeLexicon(`${collection}.getRecords`, { 306 464 lexicon: 1, ··· 321 479 properties: { 322 480 records: { type: "array", items: { type: "ref", ref: "#record" } }, 323 481 cursor: { type: "string" }, 482 + profiles: { type: "array", items: { type: "ref", ref: "#profileEntry" } }, 324 483 }, 325 484 }, 326 485 }, 327 486 }, 328 - record: buildRecordDef(collectionRef), 487 + record: buildRecordDef(collectionRef, countFields, hasRelations), 488 + ...profileDefs(), 329 489 }, 330 490 }); 331 491 332 492 // --- getRecord --- 333 493 const getRecordParamProps: Record<string, any> = { 334 494 uri: { type: "string", format: "at-uri", description: "AT URI of the record" }, 495 + profiles: { type: "boolean", description: "Include profile + identity info keyed by DID" }, 496 + hydrate: { type: "string", description: "Embed related records, as relName:limit (e.g. rsvps:5). Repeatable." }, 335 497 }; 336 - for (const relName of Object.keys(colConfig.relations ?? {})) { 337 - getRecordParamProps[`${relName}Preview`] = { 338 - type: "integer", 339 - minimum: 1, 340 - maximum: 50, 341 - description: `Number of ${relName} previews per group`, 342 - }; 343 - } 344 498 345 499 writeLexicon(`${collection}.getRecord`, { 346 500 lexicon: 1, ··· 356 510 }, 357 511 output: { 358 512 encoding: "application/json", 359 - schema: { type: "ref", ref: "#record" }, 513 + schema: { 514 + type: "object", 515 + required: ["uri", "did", "collection", "rkey", "time_us"], 516 + properties: { 517 + ...buildRecordDef(collectionRef, countFields, hasRelations).properties, 518 + profiles: { type: "array", items: { type: "ref", ref: "#profileEntry" } }, 519 + }, 520 + }, 360 521 }, 361 522 }, 362 - record: buildRecordDef(collectionRef), 523 + ...profileDefs(), 363 524 }, 364 525 }); 365 526 ··· 506 667 } 507 668 } 508 669 509 - // Merge: collection NSIDs + transitive deps (excluding com.atproto.* which comes from imports) 510 - const pullNsids = new Set(collectionNsids); 670 + // Merge: collection NSIDs + profile NSIDs + transitive deps (excluding com.atproto.* which comes from imports) 671 + const profileNsids = config.profiles ?? ["app.bsky.actor.profile"]; 672 + const pullNsids = new Set([...collectionNsids, ...profileNsids]); 511 673 for (const ref of allRefs) { 512 674 if (!ref.startsWith("com.atproto.")) { 513 675 pullNsids.add(ref); ··· 543 705 import type { QueryableField } from "./types"; 544 706 545 707 export const resolvedQueryable: Record<string, Record<string, QueryableField>> = ${JSON.stringify(resolvedQueryable, null, 2)}; 708 + 709 + export interface ResolvedRelation { 710 + collection: string; 711 + groupBy: string; 712 + groups: Record<string, string>; // shortName → full token value 713 + } 714 + 715 + export const resolvedRelationsMap: Record<string, Record<string, ResolvedRelation>> = ${JSON.stringify(resolvedRelations, null, 2)}; 546 716 `; 547 717 548 718 writeFileSync(join(ROOT_DIR, "src", "core", "queryable.generated.ts"), queryableContent);
+1
src/config.ts
··· 13 13 "community.lexicon.calendar.rsvp": {}, 14 14 "app.blento.card": {} 15 15 }, 16 + profiles: ["app.bsky.actor.profile", "app.blento.profile"], 16 17 };
+24
lexicons-generated/contrail/admin/reset.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "contrail.admin.reset", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Delete all data from all tables", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "ok" 14 + ], 15 + "properties": { 16 + "ok": { 17 + "type": "boolean" 18 + } 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+29
lexicons/app/blento/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.blento.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "displayName": { 12 + "type": "string", 13 + "maxLength": 640 14 + }, 15 + "description": { 16 + "type": "string", 17 + "maxLength": 2560 18 + }, 19 + "avatar": { 20 + "type": "blob", 21 + "accept": ["image/png", "image/jpeg"], 22 + "maxSize": 1000000 23 + } 24 + } 25 + }, 26 + "description": "A Blento profile record." 27 + } 28 + } 29 + }
+1 -1
src/core/db/records.ts
··· 45 45 const targetUri = getNestedValue(record, getRelationField(rel)); 46 46 if (!targetUri) continue; 47 47 48 - const types = ["_total"]; 48 + const types = [rel.collection]; 49 49 if (rel.groupBy) { 50 50 const groupValue = getNestedValue(record, rel.groupBy); 51 51 if (groupValue != null) types.push(String(groupValue));
+10
src/core/router/admin.ts
··· 16 16 const auth = c.req.header("Authorization"); 17 17 if (auth !== `Bearer ${adminSecret}`) 18 18 return c.json({ error: "Unauthorized" }, 401); 19 + } else { 20 + const url = new URL(c.req.url); 21 + if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") 22 + return c.json({ error: "ADMIN_SECRET not configured" }, 403); 19 23 } 20 24 await next(); 21 25 }; ··· 118 122 remaining: remaining?.count ?? 0, 119 123 done: discoveryDone && (remaining?.count ?? 0) === 0, 120 124 }); 125 + }); 126 + 127 + app.get("/xrpc/contrail.admin.reset", requireAdmin, async (c) => { 128 + const tables = ["records", "counts", "backfills", "discovery", "cursor", "identities"]; 129 + await db.batch(tables.map((t) => db.prepare(`DELETE FROM ${t}`))); 130 + return c.json({ ok: true }); 121 131 }); 122 132 }
+64 -44
src/core/router/collection.ts
··· 1 1 import type { Hono } from "hono"; 2 2 import type { ContrailConfig, Database, RecordRow, QueryableField } from "../types"; 3 3 import { getCollectionNames } from "../types"; 4 - import { resolvedQueryable } from "../queryable.generated"; 4 + import { resolvedQueryable, resolvedRelationsMap } from "../queryable.generated"; 5 5 import { queryRecords, getUsersByCollection } from "../db"; 6 6 import { backfillUser } from "../backfill"; 7 7 import { resolveHydrates } from "./hydrate"; ··· 37 37 } 38 38 39 39 const filters: Record<string, string> = {}; 40 + const rangeFilters: Record<string, { min?: string; max?: string }> = {}; 40 41 for (const [field, fieldConfig] of Object.entries(queryableFields)) { 41 - if (fieldConfig.type === "range") continue; 42 - const value = params.get(fieldToParam(field)); 43 - if (value) filters[field] = value; 42 + const param = fieldToParam(field); 43 + if (fieldConfig.type === "range") { 44 + const min = params.get(`${param}Min`); 45 + const max = params.get(`${param}Max`); 46 + if (min || max) { 47 + rangeFilters[field] = {}; 48 + if (min) rangeFilters[field].min = min; 49 + if (max) rangeFilters[field].max = max; 50 + } 51 + } else { 52 + const value = params.get(param); 53 + if (value) filters[field] = value; 54 + } 44 55 } 45 56 46 - const rangeFilters: Record<string, { min?: string; max?: string }> = {}; 47 57 const countFilters: Record<string, number> = {}; 48 - const rangeFields = new Set( 49 - Object.entries(queryableFields) 50 - .filter(([, c]) => c.type === "range") 51 - .map(([f]) => f) 52 - ); 53 - parseMinMaxParams( 54 - params.getAll("min"), 55 - params.getAll("max"), 56 - rangeFields, 57 - rangeFilters, 58 - countFilters 59 - ); 58 + const relMap = resolvedRelationsMap[collection] ?? {}; 59 + for (const [relName, rel] of Object.entries(relations)) { 60 + const totalMin = parseIntParam(params.get(`${relName}CountMin`)); 61 + if (totalMin != null) countFilters[rel.collection] = totalMin; 62 + const mapping = relMap[relName]; 63 + if (mapping) { 64 + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); 65 + for (const [shortName, fullToken] of Object.entries(mapping.groups)) { 66 + const val = parseIntParam(params.get(`${relName}${capitalize(shortName)}CountMin`)); 67 + if (val != null) countFilters[fullToken] = val; 68 + } 69 + } 70 + } 60 71 61 72 const result = await queryRecords(db, config, { 62 73 collection, ··· 78 89 79 90 const formattedRecords: FormattedRecord[] = rows.map((row) => { 80 91 const formatted = formatRecord(row); 81 - if (row.counts) formatted.counts = row.counts; 92 + flattenCounts(formatted, row.counts, collection, relations); 82 93 const h = hydrates[row.uri]; 83 94 if (h) formatted.hydrates = h; 84 95 return formatted; 85 96 }); 86 97 87 98 const allDids = collectDids(rows, hydrates); 88 - const profiles = wantProfiles 99 + const profileMap = wantProfiles 89 100 ? await resolveProfiles(db, config, allDids) 90 101 : undefined; 91 102 92 103 return c.json({ 93 104 records: formattedRecords, 94 105 cursor: result.cursor, 95 - ...(profiles ? { profiles } : {}), 106 + ...(profileMap ? { profiles: Object.values(profileMap) } : {}), 96 107 }); 97 108 }); 98 109 ··· 118 129 if (countRows.results?.length) { 119 130 const counts: Record<string, number> = {}; 120 131 for (const cr of countRows.results) counts[cr.type] = cr.count; 121 - formatted.counts = counts; 132 + flattenCounts(formatted, counts, collection, relations); 122 133 } 123 134 124 135 const params = new URL(c.req.url).searchParams; ··· 134 145 if (h) formatted.hydrates = h; 135 146 136 147 const allDids = collectDids([row], hydrates); 137 - const profilesSingle = wantProfilesSingle 148 + const profileMap = wantProfilesSingle 138 149 ? await resolveProfiles(db, config, allDids) 139 150 : undefined; 140 151 141 152 return c.json({ 142 153 ...formatted, 143 - ...(profilesSingle ? { profiles: profilesSingle } : {}), 154 + ...(profileMap ? { profiles: Object.values(profileMap) } : {}), 144 155 }); 145 156 }); 146 157 ··· 181 192 } 182 193 } 183 194 184 - function parseMinMaxParams( 185 - minValues: string[], 186 - maxValues: string[], 187 - rangeFields: Set<string>, 188 - rangeFilters: Record<string, { min?: string; max?: string }>, 189 - countFilters: Record<string, number> 195 + function flattenCounts( 196 + formatted: FormattedRecord, 197 + counts: Record<string, number> | undefined, 198 + collection: string, 199 + relations: Record<string, any> 190 200 ): void { 191 - for (const [side, values] of [ 192 - ["min", minValues], 193 - ["max", maxValues], 194 - ] as const) { 195 - for (const raw of values) { 196 - const sep = raw.indexOf(":"); 197 - if (sep === -1) continue; 198 - const key = raw.slice(0, sep); 199 - const val = raw.slice(sep + 1); 200 - if (rangeFields.has(key)) { 201 - (rangeFilters[key] ??= {})[side] = val; 202 - } else if (side === "min") { 203 - const num = parseInt(val, 10); 204 - if (!isNaN(num)) countFilters[key] = num; 205 - } 201 + if (!counts) return; 202 + const relMap = resolvedRelationsMap[collection] ?? {}; 203 + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); 204 + 205 + // Build reverse lookups: collection NSID → relName (for totals), full token → field name (for groups) 206 + const collectionToRelName: Record<string, string> = {}; 207 + const tokenToField: Record<string, string> = {}; 208 + for (const [relName, mapping] of Object.entries(relMap)) { 209 + collectionToRelName[mapping.collection] = relName; 210 + for (const [shortName, fullToken] of Object.entries(mapping.groups)) { 211 + tokenToField[fullToken] = `${relName}${capitalize(shortName)}Count`; 212 + } 213 + } 214 + // Also map relations without groupBy (no entry in relMap) 215 + for (const [relName, rel] of Object.entries(relations)) { 216 + if (!collectionToRelName[rel.collection]) { 217 + collectionToRelName[rel.collection] = relName; 218 + } 219 + } 220 + 221 + for (const [type, count] of Object.entries(counts)) { 222 + if (collectionToRelName[type]) { 223 + formatted[`${collectionToRelName[type]}Count`] = count; 224 + } else if (tokenToField[type]) { 225 + formatted[tokenToField[type]] = count; 206 226 } 207 227 } 208 228 }
-1
src/core/router/helpers.ts
··· 8 8 cid: string | null; 9 9 record: any; 10 10 time_us: number; 11 - counts?: Record<string, number>; 12 11 hydrates?: Record<string, Record<string, any[]>>; 13 12 [key: string]: any; 14 13 }
+146 -15
lexicons-generated/app/blento/card/getRecord.json
··· 15 15 "type": "string", 16 16 "format": "at-uri", 17 17 "description": "AT URI of the record" 18 + }, 19 + "profiles": { 20 + "type": "boolean", 21 + "description": "Include profile + identity info keyed by DID" 22 + }, 23 + "hydrate": { 24 + "type": "string", 25 + "description": "Embed related records, as relName:limit (e.g. rsvps:5). Repeatable." 18 26 } 19 27 } 20 28 }, 21 29 "output": { 22 30 "encoding": "application/json", 23 31 "schema": { 24 - "type": "ref", 25 - "ref": "#record" 32 + "type": "object", 33 + "required": [ 34 + "uri", 35 + "did", 36 + "collection", 37 + "rkey", 38 + "time_us" 39 + ], 40 + "properties": { 41 + "uri": { 42 + "type": "string", 43 + "format": "at-uri" 44 + }, 45 + "did": { 46 + "type": "string", 47 + "format": "did" 48 + }, 49 + "collection": { 50 + "type": "string", 51 + "format": "nsid" 52 + }, 53 + "rkey": { 54 + "type": "string" 55 + }, 56 + "cid": { 57 + "type": "string" 58 + }, 59 + "record": { 60 + "type": "unknown" 61 + }, 62 + "time_us": { 63 + "type": "integer" 64 + }, 65 + "profiles": { 66 + "type": "array", 67 + "items": { 68 + "type": "ref", 69 + "ref": "#profileEntry" 70 + } 71 + } 72 + } 26 73 } 27 74 } 28 75 }, 29 - "record": { 76 + "profileEntry": { 30 77 "type": "object", 31 78 "required": [ 32 - "uri", 33 - "did", 34 - "collection", 35 - "rkey", 36 - "time_us" 79 + "did" 37 80 ], 38 81 "properties": { 39 - "uri": { 40 - "type": "string", 41 - "format": "at-uri" 42 - }, 43 82 "did": { 44 83 "type": "string", 45 84 "format": "did" 85 + }, 86 + "handle": { 87 + "type": "string" 88 + }, 89 + "uri": { 90 + "type": "string", 91 + "format": "at-uri" 46 92 }, 47 93 "collection": { 48 94 "type": "string", ··· 55 101 "type": "string" 56 102 }, 57 103 "record": { 58 - "type": "unknown" 104 + "type": "union", 105 + "refs": [ 106 + "#appBskyActorProfile", 107 + "#appBlentoProfile" 108 + ] 109 + } 110 + } 111 + }, 112 + "appBskyActorProfile": { 113 + "type": "object", 114 + "properties": { 115 + "avatar": { 116 + "type": "blob", 117 + "accept": [ 118 + "image/png", 119 + "image/jpeg" 120 + ], 121 + "maxSize": 1000000, 122 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 59 123 }, 60 - "time_us": { 61 - "type": "integer" 124 + "banner": { 125 + "type": "blob", 126 + "accept": [ 127 + "image/png", 128 + "image/jpeg" 129 + ], 130 + "maxSize": 1000000, 131 + "description": "Larger horizontal image to display behind profile view." 132 + }, 133 + "labels": { 134 + "refs": [ 135 + "com.atproto.label.defs#selfLabels" 136 + ], 137 + "type": "union", 138 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 139 + }, 140 + "website": { 141 + "type": "string", 142 + "format": "uri" 143 + }, 144 + "pronouns": { 145 + "type": "string", 146 + "maxLength": 200, 147 + "description": "Free-form pronouns text.", 148 + "maxGraphemes": 20 149 + }, 150 + "createdAt": { 151 + "type": "string", 152 + "format": "datetime" 153 + }, 154 + "pinnedPost": { 155 + "ref": "com.atproto.repo.strongRef", 156 + "type": "ref" 157 + }, 158 + "description": { 159 + "type": "string", 160 + "maxLength": 2560, 161 + "description": "Free-form profile description text.", 162 + "maxGraphemes": 256 163 + }, 164 + "displayName": { 165 + "type": "string", 166 + "maxLength": 640, 167 + "maxGraphemes": 64 168 + }, 169 + "joinedViaStarterPack": { 170 + "ref": "com.atproto.repo.strongRef", 171 + "type": "ref" 172 + } 173 + } 174 + }, 175 + "appBlentoProfile": { 176 + "type": "object", 177 + "properties": { 178 + "displayName": { 179 + "type": "string", 180 + "maxLength": 640 181 + }, 182 + "description": { 183 + "type": "string", 184 + "maxLength": 2560 185 + }, 186 + "avatar": { 187 + "type": "blob", 188 + "accept": [ 189 + "image/png", 190 + "image/jpeg" 191 + ], 192 + "maxSize": 1000000 62 193 } 63 194 } 64 195 }
+135 -11
lexicons-generated/app/blento/card/getRecords.json
··· 17 17 "cursor": { 18 18 "type": "string" 19 19 }, 20 - "did": { 20 + "actor": { 21 21 "type": "string", 22 - "format": "did" 22 + "format": "at-identifier", 23 + "description": "Filter by DID or handle (triggers on-demand backfill)" 23 24 }, 24 - "include": { 25 - "type": "string", 26 - "description": "Comma-separated include names" 25 + "profiles": { 26 + "type": "boolean", 27 + "description": "Include profile + identity info keyed by DID" 27 28 }, 28 - "min": { 29 + "hydrate": { 29 30 "type": "string", 30 - "description": "Min filter as field:value (range fields or count types). Repeatable." 31 - }, 32 - "max": { 33 - "type": "string", 34 - "description": "Max filter as field:value (range fields). Repeatable." 31 + "description": "Embed related records, as relName:limit (e.g. rsvps:5). Repeatable." 35 32 } 36 33 } 37 34 }, ··· 52 49 }, 53 50 "cursor": { 54 51 "type": "string" 52 + }, 53 + "profiles": { 54 + "type": "array", 55 + "items": { 56 + "type": "ref", 57 + "ref": "#profileEntry" 58 + } 55 59 } 56 60 } 57 61 } ··· 90 94 }, 91 95 "time_us": { 92 96 "type": "integer" 97 + } 98 + } 99 + }, 100 + "profileEntry": { 101 + "type": "object", 102 + "required": [ 103 + "did" 104 + ], 105 + "properties": { 106 + "did": { 107 + "type": "string", 108 + "format": "did" 109 + }, 110 + "handle": { 111 + "type": "string" 112 + }, 113 + "uri": { 114 + "type": "string", 115 + "format": "at-uri" 116 + }, 117 + "collection": { 118 + "type": "string", 119 + "format": "nsid" 120 + }, 121 + "rkey": { 122 + "type": "string" 123 + }, 124 + "cid": { 125 + "type": "string" 126 + }, 127 + "record": { 128 + "type": "union", 129 + "refs": [ 130 + "#appBskyActorProfile", 131 + "#appBlentoProfile" 132 + ] 133 + } 134 + } 135 + }, 136 + "appBskyActorProfile": { 137 + "type": "object", 138 + "properties": { 139 + "avatar": { 140 + "type": "blob", 141 + "accept": [ 142 + "image/png", 143 + "image/jpeg" 144 + ], 145 + "maxSize": 1000000, 146 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 147 + }, 148 + "banner": { 149 + "type": "blob", 150 + "accept": [ 151 + "image/png", 152 + "image/jpeg" 153 + ], 154 + "maxSize": 1000000, 155 + "description": "Larger horizontal image to display behind profile view." 156 + }, 157 + "labels": { 158 + "refs": [ 159 + "com.atproto.label.defs#selfLabels" 160 + ], 161 + "type": "union", 162 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 163 + }, 164 + "website": { 165 + "type": "string", 166 + "format": "uri" 167 + }, 168 + "pronouns": { 169 + "type": "string", 170 + "maxLength": 200, 171 + "description": "Free-form pronouns text.", 172 + "maxGraphemes": 20 173 + }, 174 + "createdAt": { 175 + "type": "string", 176 + "format": "datetime" 177 + }, 178 + "pinnedPost": { 179 + "ref": "com.atproto.repo.strongRef", 180 + "type": "ref" 181 + }, 182 + "description": { 183 + "type": "string", 184 + "maxLength": 2560, 185 + "description": "Free-form profile description text.", 186 + "maxGraphemes": 256 187 + }, 188 + "displayName": { 189 + "type": "string", 190 + "maxLength": 640, 191 + "maxGraphemes": 64 192 + }, 193 + "joinedViaStarterPack": { 194 + "ref": "com.atproto.repo.strongRef", 195 + "type": "ref" 196 + } 197 + } 198 + }, 199 + "appBlentoProfile": { 200 + "type": "object", 201 + "properties": { 202 + "displayName": { 203 + "type": "string", 204 + "maxLength": 640 205 + }, 206 + "description": { 207 + "type": "string", 208 + "maxLength": 2560 209 + }, 210 + "avatar": { 211 + "type": "blob", 212 + "accept": [ 213 + "image/png", 214 + "image/jpeg" 215 + ], 216 + "maxSize": 1000000 93 217 } 94 218 } 95 219 }
+67
lexicons-pulled/app/bsky/actor/profile.json
··· 1 + { 2 + "id": "app.bsky.actor.profile", 3 + "defs": { 4 + "main": { 5 + "key": "literal:self", 6 + "type": "record", 7 + "record": { 8 + "type": "object", 9 + "properties": { 10 + "avatar": { 11 + "type": "blob", 12 + "accept": ["image/png", "image/jpeg"], 13 + "maxSize": 1000000, 14 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 15 + }, 16 + "banner": { 17 + "type": "blob", 18 + "accept": ["image/png", "image/jpeg"], 19 + "maxSize": 1000000, 20 + "description": "Larger horizontal image to display behind profile view." 21 + }, 22 + "labels": { 23 + "refs": ["com.atproto.label.defs#selfLabels"], 24 + "type": "union", 25 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 26 + }, 27 + "website": { 28 + "type": "string", 29 + "format": "uri" 30 + }, 31 + "pronouns": { 32 + "type": "string", 33 + "maxLength": 200, 34 + "description": "Free-form pronouns text.", 35 + "maxGraphemes": 20 36 + }, 37 + "createdAt": { 38 + "type": "string", 39 + "format": "datetime" 40 + }, 41 + "pinnedPost": { 42 + "ref": "com.atproto.repo.strongRef", 43 + "type": "ref" 44 + }, 45 + "description": { 46 + "type": "string", 47 + "maxLength": 2560, 48 + "description": "Free-form profile description text.", 49 + "maxGraphemes": 256 50 + }, 51 + "displayName": { 52 + "type": "string", 53 + "maxLength": 640, 54 + "maxGraphemes": 64 55 + }, 56 + "joinedViaStarterPack": { 57 + "ref": "com.atproto.repo.strongRef", 58 + "type": "ref" 59 + } 60 + } 61 + }, 62 + "description": "A declaration of a Bluesky account profile." 63 + } 64 + }, 65 + "$type": "com.atproto.lexicon.schema", 66 + "lexicon": 1 67 + }
+166 -21
lexicons-generated/community/lexicon/calendar/event/getRecord.json
··· 16 16 "format": "at-uri", 17 17 "description": "AT URI of the record" 18 18 }, 19 - "rsvpsPreview": { 20 - "type": "integer", 21 - "minimum": 1, 22 - "maximum": 50, 23 - "description": "Number of rsvps previews per group" 19 + "profiles": { 20 + "type": "boolean", 21 + "description": "Include profile + identity info keyed by DID" 22 + }, 23 + "hydrate": { 24 + "type": "string", 25 + "description": "Embed related records, as relName:limit (e.g. rsvps:5). Repeatable." 24 26 } 25 27 } 26 28 }, 27 29 "output": { 28 30 "encoding": "application/json", 29 31 "schema": { 30 - "type": "ref", 31 - "ref": "#record" 32 + "type": "object", 33 + "required": [ 34 + "uri", 35 + "did", 36 + "collection", 37 + "rkey", 38 + "time_us" 39 + ], 40 + "properties": { 41 + "uri": { 42 + "type": "string", 43 + "format": "at-uri" 44 + }, 45 + "did": { 46 + "type": "string", 47 + "format": "did" 48 + }, 49 + "collection": { 50 + "type": "string", 51 + "format": "nsid" 52 + }, 53 + "rkey": { 54 + "type": "string" 55 + }, 56 + "cid": { 57 + "type": "string" 58 + }, 59 + "record": { 60 + "type": "ref", 61 + "ref": "community.lexicon.calendar.event#main" 62 + }, 63 + "time_us": { 64 + "type": "integer" 65 + }, 66 + "rsvpsCount": { 67 + "type": "integer", 68 + "description": "Total rsvps count" 69 + }, 70 + "rsvpsInterestedCount": { 71 + "type": "integer", 72 + "description": "rsvps count where status = interested" 73 + }, 74 + "rsvpsGoingCount": { 75 + "type": "integer", 76 + "description": "rsvps count where status = going" 77 + }, 78 + "rsvpsNotgoingCount": { 79 + "type": "integer", 80 + "description": "rsvps count where status = notgoing" 81 + }, 82 + "hydrates": { 83 + "type": "unknown", 84 + "description": "Hydrated related records, grouped by relation name and groupBy value" 85 + }, 86 + "profiles": { 87 + "type": "array", 88 + "items": { 89 + "type": "ref", 90 + "ref": "#profileEntry" 91 + } 92 + } 93 + } 32 94 } 33 95 } 34 96 }, 35 - "record": { 97 + "profileEntry": { 36 98 "type": "object", 37 99 "required": [ 38 - "uri", 39 - "did", 40 - "collection", 41 - "rkey", 42 - "time_us" 100 + "did" 43 101 ], 44 102 "properties": { 45 - "uri": { 46 - "type": "string", 47 - "format": "at-uri" 48 - }, 49 103 "did": { 50 104 "type": "string", 51 105 "format": "did" 106 + }, 107 + "handle": { 108 + "type": "string" 109 + }, 110 + "uri": { 111 + "type": "string", 112 + "format": "at-uri" 52 113 }, 53 114 "collection": { 54 115 "type": "string", ··· 61 122 "type": "string" 62 123 }, 63 124 "record": { 64 - "type": "ref", 65 - "ref": "community.lexicon.calendar.event#main" 125 + "type": "union", 126 + "refs": [ 127 + "#appBskyActorProfile", 128 + "#appBlentoProfile" 129 + ] 130 + } 131 + } 132 + }, 133 + "appBskyActorProfile": { 134 + "type": "object", 135 + "properties": { 136 + "avatar": { 137 + "type": "blob", 138 + "accept": [ 139 + "image/png", 140 + "image/jpeg" 141 + ], 142 + "maxSize": 1000000, 143 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 66 144 }, 67 - "time_us": { 68 - "type": "integer" 145 + "banner": { 146 + "type": "blob", 147 + "accept": [ 148 + "image/png", 149 + "image/jpeg" 150 + ], 151 + "maxSize": 1000000, 152 + "description": "Larger horizontal image to display behind profile view." 153 + }, 154 + "labels": { 155 + "refs": [ 156 + "com.atproto.label.defs#selfLabels" 157 + ], 158 + "type": "union", 159 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 160 + }, 161 + "website": { 162 + "type": "string", 163 + "format": "uri" 164 + }, 165 + "pronouns": { 166 + "type": "string", 167 + "maxLength": 200, 168 + "description": "Free-form pronouns text.", 169 + "maxGraphemes": 20 170 + }, 171 + "createdAt": { 172 + "type": "string", 173 + "format": "datetime" 174 + }, 175 + "pinnedPost": { 176 + "ref": "com.atproto.repo.strongRef", 177 + "type": "ref" 178 + }, 179 + "description": { 180 + "type": "string", 181 + "maxLength": 2560, 182 + "description": "Free-form profile description text.", 183 + "maxGraphemes": 256 184 + }, 185 + "displayName": { 186 + "type": "string", 187 + "maxLength": 640, 188 + "maxGraphemes": 64 189 + }, 190 + "joinedViaStarterPack": { 191 + "ref": "com.atproto.repo.strongRef", 192 + "type": "ref" 193 + } 194 + } 195 + }, 196 + "appBlentoProfile": { 197 + "type": "object", 198 + "properties": { 199 + "displayName": { 200 + "type": "string", 201 + "maxLength": 640 202 + }, 203 + "description": { 204 + "type": "string", 205 + "maxLength": 2560 206 + }, 207 + "avatar": { 208 + "type": "blob", 209 + "accept": [ 210 + "image/png", 211 + "image/jpeg" 212 + ], 213 + "maxSize": 1000000 69 214 } 70 215 } 71 216 }
+195 -11
lexicons-generated/community/lexicon/calendar/event/getRecords.json
··· 17 17 "cursor": { 18 18 "type": "string" 19 19 }, 20 - "did": { 20 + "actor": { 21 21 "type": "string", 22 - "format": "did" 22 + "format": "at-identifier", 23 + "description": "Filter by DID or handle (triggers on-demand backfill)" 23 24 }, 24 - "include": { 25 + "profiles": { 26 + "type": "boolean", 27 + "description": "Include profile + identity info keyed by DID" 28 + }, 29 + "hydrate": { 25 30 "type": "string", 26 - "description": "Comma-separated include names" 31 + "description": "Embed related records, as relName:limit (e.g. rsvps:5). Repeatable." 27 32 }, 28 33 "mode": { 29 34 "type": "string", ··· 33 38 "type": "string", 34 39 "description": "Filter by name" 35 40 }, 41 + "endsAtMin": { 42 + "type": "string", 43 + "description": "Minimum value for endsAt" 44 + }, 45 + "endsAtMax": { 46 + "type": "string", 47 + "description": "Maximum value for endsAt" 48 + }, 36 49 "status": { 37 50 "type": "string", 38 51 "description": "Filter by status" 52 + }, 53 + "startsAtMin": { 54 + "type": "string", 55 + "description": "Minimum value for startsAt" 56 + }, 57 + "startsAtMax": { 58 + "type": "string", 59 + "description": "Maximum value for startsAt" 60 + }, 61 + "createdAtMin": { 62 + "type": "string", 63 + "description": "Minimum value for createdAt" 64 + }, 65 + "createdAtMax": { 66 + "type": "string", 67 + "description": "Maximum value for createdAt" 39 68 }, 40 69 "description": { 41 70 "type": "string", 42 71 "description": "Filter by description" 43 72 }, 44 - "min": { 45 - "type": "string", 46 - "description": "Min filter as field:value (range fields or count types). Repeatable." 47 - }, 48 - "max": { 49 - "type": "string", 50 - "description": "Max filter as field:value (range fields). Repeatable." 73 + "rsvpsCountMin": { 74 + "type": "integer", 75 + "description": "Minimum total rsvps count" 51 76 }, 52 77 "rsvpsPreview": { 53 78 "type": "integer", 54 79 "minimum": 1, 55 80 "maximum": 50, 56 81 "description": "Number of rsvps previews per record" 82 + }, 83 + "rsvpsInterestedCountMin": { 84 + "type": "integer", 85 + "description": "Minimum rsvps count where status = interested" 86 + }, 87 + "rsvpsGoingCountMin": { 88 + "type": "integer", 89 + "description": "Minimum rsvps count where status = going" 90 + }, 91 + "rsvpsNotgoingCountMin": { 92 + "type": "integer", 93 + "description": "Minimum rsvps count where status = notgoing" 57 94 } 58 95 } 59 96 }, ··· 74 111 }, 75 112 "cursor": { 76 113 "type": "string" 114 + }, 115 + "profiles": { 116 + "type": "array", 117 + "items": { 118 + "type": "ref", 119 + "ref": "#profileEntry" 120 + } 77 121 } 78 122 } 79 123 } ··· 113 157 }, 114 158 "time_us": { 115 159 "type": "integer" 160 + }, 161 + "rsvpsCount": { 162 + "type": "integer", 163 + "description": "Total rsvps count" 164 + }, 165 + "rsvpsInterestedCount": { 166 + "type": "integer", 167 + "description": "rsvps count where status = interested" 168 + }, 169 + "rsvpsGoingCount": { 170 + "type": "integer", 171 + "description": "rsvps count where status = going" 172 + }, 173 + "rsvpsNotgoingCount": { 174 + "type": "integer", 175 + "description": "rsvps count where status = notgoing" 176 + }, 177 + "hydrates": { 178 + "type": "unknown", 179 + "description": "Hydrated related records, grouped by relation name and groupBy value" 180 + } 181 + } 182 + }, 183 + "profileEntry": { 184 + "type": "object", 185 + "required": [ 186 + "did" 187 + ], 188 + "properties": { 189 + "did": { 190 + "type": "string", 191 + "format": "did" 192 + }, 193 + "handle": { 194 + "type": "string" 195 + }, 196 + "uri": { 197 + "type": "string", 198 + "format": "at-uri" 199 + }, 200 + "collection": { 201 + "type": "string", 202 + "format": "nsid" 203 + }, 204 + "rkey": { 205 + "type": "string" 206 + }, 207 + "cid": { 208 + "type": "string" 209 + }, 210 + "record": { 211 + "type": "union", 212 + "refs": [ 213 + "#appBskyActorProfile", 214 + "#appBlentoProfile" 215 + ] 216 + } 217 + } 218 + }, 219 + "appBskyActorProfile": { 220 + "type": "object", 221 + "properties": { 222 + "avatar": { 223 + "type": "blob", 224 + "accept": [ 225 + "image/png", 226 + "image/jpeg" 227 + ], 228 + "maxSize": 1000000, 229 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 230 + }, 231 + "banner": { 232 + "type": "blob", 233 + "accept": [ 234 + "image/png", 235 + "image/jpeg" 236 + ], 237 + "maxSize": 1000000, 238 + "description": "Larger horizontal image to display behind profile view." 239 + }, 240 + "labels": { 241 + "refs": [ 242 + "com.atproto.label.defs#selfLabels" 243 + ], 244 + "type": "union", 245 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 246 + }, 247 + "website": { 248 + "type": "string", 249 + "format": "uri" 250 + }, 251 + "pronouns": { 252 + "type": "string", 253 + "maxLength": 200, 254 + "description": "Free-form pronouns text.", 255 + "maxGraphemes": 20 256 + }, 257 + "createdAt": { 258 + "type": "string", 259 + "format": "datetime" 260 + }, 261 + "pinnedPost": { 262 + "ref": "com.atproto.repo.strongRef", 263 + "type": "ref" 264 + }, 265 + "description": { 266 + "type": "string", 267 + "maxLength": 2560, 268 + "description": "Free-form profile description text.", 269 + "maxGraphemes": 256 270 + }, 271 + "displayName": { 272 + "type": "string", 273 + "maxLength": 640, 274 + "maxGraphemes": 64 275 + }, 276 + "joinedViaStarterPack": { 277 + "ref": "com.atproto.repo.strongRef", 278 + "type": "ref" 279 + } 280 + } 281 + }, 282 + "appBlentoProfile": { 283 + "type": "object", 284 + "properties": { 285 + "displayName": { 286 + "type": "string", 287 + "maxLength": 640 288 + }, 289 + "description": { 290 + "type": "string", 291 + "maxLength": 2560 292 + }, 293 + "avatar": { 294 + "type": "blob", 295 + "accept": [ 296 + "image/png", 297 + "image/jpeg" 298 + ], 299 + "maxSize": 1000000 116 300 } 117 301 } 118 302 }
+147 -16
lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json
··· 15 15 "type": "string", 16 16 "format": "at-uri", 17 17 "description": "AT URI of the record" 18 + }, 19 + "profiles": { 20 + "type": "boolean", 21 + "description": "Include profile + identity info keyed by DID" 22 + }, 23 + "hydrate": { 24 + "type": "string", 25 + "description": "Embed related records, as relName:limit (e.g. rsvps:5). Repeatable." 18 26 } 19 27 } 20 28 }, 21 29 "output": { 22 30 "encoding": "application/json", 23 31 "schema": { 24 - "type": "ref", 25 - "ref": "#record" 32 + "type": "object", 33 + "required": [ 34 + "uri", 35 + "did", 36 + "collection", 37 + "rkey", 38 + "time_us" 39 + ], 40 + "properties": { 41 + "uri": { 42 + "type": "string", 43 + "format": "at-uri" 44 + }, 45 + "did": { 46 + "type": "string", 47 + "format": "did" 48 + }, 49 + "collection": { 50 + "type": "string", 51 + "format": "nsid" 52 + }, 53 + "rkey": { 54 + "type": "string" 55 + }, 56 + "cid": { 57 + "type": "string" 58 + }, 59 + "record": { 60 + "type": "ref", 61 + "ref": "community.lexicon.calendar.rsvp#main" 62 + }, 63 + "time_us": { 64 + "type": "integer" 65 + }, 66 + "profiles": { 67 + "type": "array", 68 + "items": { 69 + "type": "ref", 70 + "ref": "#profileEntry" 71 + } 72 + } 73 + } 26 74 } 27 75 } 28 76 }, 29 - "record": { 77 + "profileEntry": { 30 78 "type": "object", 31 79 "required": [ 32 - "uri", 33 - "did", 34 - "collection", 35 - "rkey", 36 - "time_us" 80 + "did" 37 81 ], 38 82 "properties": { 39 - "uri": { 40 - "type": "string", 41 - "format": "at-uri" 42 - }, 43 83 "did": { 44 84 "type": "string", 45 85 "format": "did" 86 + }, 87 + "handle": { 88 + "type": "string" 89 + }, 90 + "uri": { 91 + "type": "string", 92 + "format": "at-uri" 46 93 }, 47 94 "collection": { 48 95 "type": "string", ··· 55 102 "type": "string" 56 103 }, 57 104 "record": { 58 - "type": "ref", 59 - "ref": "community.lexicon.calendar.rsvp#main" 105 + "type": "union", 106 + "refs": [ 107 + "#appBskyActorProfile", 108 + "#appBlentoProfile" 109 + ] 110 + } 111 + } 112 + }, 113 + "appBskyActorProfile": { 114 + "type": "object", 115 + "properties": { 116 + "avatar": { 117 + "type": "blob", 118 + "accept": [ 119 + "image/png", 120 + "image/jpeg" 121 + ], 122 + "maxSize": 1000000, 123 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 60 124 }, 61 - "time_us": { 62 - "type": "integer" 125 + "banner": { 126 + "type": "blob", 127 + "accept": [ 128 + "image/png", 129 + "image/jpeg" 130 + ], 131 + "maxSize": 1000000, 132 + "description": "Larger horizontal image to display behind profile view." 133 + }, 134 + "labels": { 135 + "refs": [ 136 + "com.atproto.label.defs#selfLabels" 137 + ], 138 + "type": "union", 139 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 140 + }, 141 + "website": { 142 + "type": "string", 143 + "format": "uri" 144 + }, 145 + "pronouns": { 146 + "type": "string", 147 + "maxLength": 200, 148 + "description": "Free-form pronouns text.", 149 + "maxGraphemes": 20 150 + }, 151 + "createdAt": { 152 + "type": "string", 153 + "format": "datetime" 154 + }, 155 + "pinnedPost": { 156 + "ref": "com.atproto.repo.strongRef", 157 + "type": "ref" 158 + }, 159 + "description": { 160 + "type": "string", 161 + "maxLength": 2560, 162 + "description": "Free-form profile description text.", 163 + "maxGraphemes": 256 164 + }, 165 + "displayName": { 166 + "type": "string", 167 + "maxLength": 640, 168 + "maxGraphemes": 64 169 + }, 170 + "joinedViaStarterPack": { 171 + "ref": "com.atproto.repo.strongRef", 172 + "type": "ref" 173 + } 174 + } 175 + }, 176 + "appBlentoProfile": { 177 + "type": "object", 178 + "properties": { 179 + "displayName": { 180 + "type": "string", 181 + "maxLength": 640 182 + }, 183 + "description": { 184 + "type": "string", 185 + "maxLength": 2560 186 + }, 187 + "avatar": { 188 + "type": "blob", 189 + "accept": [ 190 + "image/png", 191 + "image/jpeg" 192 + ], 193 + "maxSize": 1000000 63 194 } 64 195 } 65 196 }
+136 -12
lexicons-generated/community/lexicon/calendar/rsvp/getRecords.json
··· 17 17 "cursor": { 18 18 "type": "string" 19 19 }, 20 - "did": { 20 + "actor": { 21 21 "type": "string", 22 - "format": "did" 22 + "format": "at-identifier", 23 + "description": "Filter by DID or handle (triggers on-demand backfill)" 23 24 }, 24 - "include": { 25 + "profiles": { 26 + "type": "boolean", 27 + "description": "Include profile + identity info keyed by DID" 28 + }, 29 + "hydrate": { 25 30 "type": "string", 26 - "description": "Comma-separated include names" 31 + "description": "Embed related records, as relName:limit (e.g. rsvps:5). Repeatable." 27 32 }, 28 33 "status": { 29 34 "type": "string", ··· 32 37 "subjectUri": { 33 38 "type": "string", 34 39 "description": "Filter by subject.uri" 35 - }, 36 - "min": { 37 - "type": "string", 38 - "description": "Min filter as field:value (range fields or count types). Repeatable." 39 - }, 40 - "max": { 41 - "type": "string", 42 - "description": "Max filter as field:value (range fields). Repeatable." 43 40 } 44 41 } 45 42 }, ··· 60 57 }, 61 58 "cursor": { 62 59 "type": "string" 60 + }, 61 + "profiles": { 62 + "type": "array", 63 + "items": { 64 + "type": "ref", 65 + "ref": "#profileEntry" 66 + } 63 67 } 64 68 } 65 69 } ··· 99 103 }, 100 104 "time_us": { 101 105 "type": "integer" 106 + } 107 + } 108 + }, 109 + "profileEntry": { 110 + "type": "object", 111 + "required": [ 112 + "did" 113 + ], 114 + "properties": { 115 + "did": { 116 + "type": "string", 117 + "format": "did" 118 + }, 119 + "handle": { 120 + "type": "string" 121 + }, 122 + "uri": { 123 + "type": "string", 124 + "format": "at-uri" 125 + }, 126 + "collection": { 127 + "type": "string", 128 + "format": "nsid" 129 + }, 130 + "rkey": { 131 + "type": "string" 132 + }, 133 + "cid": { 134 + "type": "string" 135 + }, 136 + "record": { 137 + "type": "union", 138 + "refs": [ 139 + "#appBskyActorProfile", 140 + "#appBlentoProfile" 141 + ] 142 + } 143 + } 144 + }, 145 + "appBskyActorProfile": { 146 + "type": "object", 147 + "properties": { 148 + "avatar": { 149 + "type": "blob", 150 + "accept": [ 151 + "image/png", 152 + "image/jpeg" 153 + ], 154 + "maxSize": 1000000, 155 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 156 + }, 157 + "banner": { 158 + "type": "blob", 159 + "accept": [ 160 + "image/png", 161 + "image/jpeg" 162 + ], 163 + "maxSize": 1000000, 164 + "description": "Larger horizontal image to display behind profile view." 165 + }, 166 + "labels": { 167 + "refs": [ 168 + "com.atproto.label.defs#selfLabels" 169 + ], 170 + "type": "union", 171 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 172 + }, 173 + "website": { 174 + "type": "string", 175 + "format": "uri" 176 + }, 177 + "pronouns": { 178 + "type": "string", 179 + "maxLength": 200, 180 + "description": "Free-form pronouns text.", 181 + "maxGraphemes": 20 182 + }, 183 + "createdAt": { 184 + "type": "string", 185 + "format": "datetime" 186 + }, 187 + "pinnedPost": { 188 + "ref": "com.atproto.repo.strongRef", 189 + "type": "ref" 190 + }, 191 + "description": { 192 + "type": "string", 193 + "maxLength": 2560, 194 + "description": "Free-form profile description text.", 195 + "maxGraphemes": 256 196 + }, 197 + "displayName": { 198 + "type": "string", 199 + "maxLength": 640, 200 + "maxGraphemes": 64 201 + }, 202 + "joinedViaStarterPack": { 203 + "ref": "com.atproto.repo.strongRef", 204 + "type": "ref" 205 + } 206 + } 207 + }, 208 + "appBlentoProfile": { 209 + "type": "object", 210 + "properties": { 211 + "displayName": { 212 + "type": "string", 213 + "maxLength": 640 214 + }, 215 + "description": { 216 + "type": "string", 217 + "maxLength": 2560 218 + }, 219 + "avatar": { 220 + "type": "blob", 221 + "accept": [ 222 + "image/png", 223 + "image/jpeg" 224 + ], 225 + "maxSize": 1000000 102 226 } 103 227 } 104 228 }