@buildinternet/releases-api-types 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/releases-api-types",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Wire protocol — request/response types for the Releases registry API. Shared by web, workers, and clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api-types.ts CHANGED
@@ -148,6 +148,7 @@ import type {
148
148
  SearchSourceHitSchema,
149
149
  SearchReleaseHitSchema,
150
150
  SearchChunkHitSchema,
151
+ SearchCollectionHitSchema,
151
152
  LookupResultPayloadSchema,
152
153
  UnifiedSearchResponseSchema,
153
154
  } from "./schemas/search.js";
@@ -367,6 +368,7 @@ export {
367
368
  SearchSourceHitSchema,
368
369
  SearchReleaseHitSchema,
369
370
  SearchChunkHitSchema,
371
+ SearchCollectionHitSchema,
370
372
  LookupResultPayloadSchema,
371
373
  UnifiedSearchResponseSchema,
372
374
  } from "./schemas/search.js";
@@ -676,6 +678,63 @@ export function foldSourcesIntoCatalog(
676
678
 
677
679
  export type SearchReleaseHit = z.infer<typeof SearchReleaseHitSchema>;
678
680
  export type SearchChunkHit = z.infer<typeof SearchChunkHitSchema>;
681
+ export type SearchCollectionHit = z.infer<typeof SearchCollectionHitSchema>;
682
+
683
+ /**
684
+ * Merge collection hits from three independent sources into one ordered,
685
+ * deduped array on the wire:
686
+ *
687
+ * 1. Direct lexical matches — name/slug/description LIKE.
688
+ * 2. Direct semantic matches — vector hit (hybrid/semantic only).
689
+ * 3. Member rollups — collections containing one of the result orgs.
690
+ *
691
+ * A direct row always wins for a given slug; member rollups attach their
692
+ * `matchedOrgSlugs` to a winning direct row so the UI keeps the "shown
693
+ * because" affordance. Within each `via` bucket, rows order by score desc
694
+ * (missing scores last) then by name.
695
+ *
696
+ * Pure: no DB, no Workers runtime. Used by both `/v1/search` and the MCP
697
+ * `search` tool so the merge stays in lockstep across surfaces.
698
+ */
699
+ export function mergeCollectionHits(
700
+ direct: SearchCollectionHit[],
701
+ semantic: SearchCollectionHit[],
702
+ member: SearchCollectionHit[],
703
+ limit: number,
704
+ ): SearchCollectionHit[] {
705
+ // Pure: never mutate caller-owned objects. Always replace map entries with
706
+ // shallow copies so two callers (e.g. API and MCP merging from the same
707
+ // upstream list) can't see each other's writes.
708
+ const bySlug = new Map<string, SearchCollectionHit>();
709
+ for (const c of direct) bySlug.set(c.slug, { ...c });
710
+ for (const c of semantic) {
711
+ const existing = bySlug.get(c.slug);
712
+ if (existing) {
713
+ if (c.score !== undefined && (existing.score === undefined || c.score > existing.score)) {
714
+ bySlug.set(c.slug, { ...existing, score: c.score });
715
+ }
716
+ } else {
717
+ bySlug.set(c.slug, { ...c });
718
+ }
719
+ }
720
+ for (const c of member) {
721
+ const existing = bySlug.get(c.slug);
722
+ if (existing) {
723
+ bySlug.set(c.slug, { ...existing, matchedOrgSlugs: c.matchedOrgSlugs });
724
+ } else {
725
+ bySlug.set(c.slug, { ...c });
726
+ }
727
+ }
728
+ return [...bySlug.values()]
729
+ .toSorted((a, b) => {
730
+ if (a.via !== b.via) return a.via === "direct" ? -1 : 1;
731
+ const sa = a.score ?? -Infinity;
732
+ const sb = b.score ?? -Infinity;
733
+ if (sa !== sb) return sb - sa;
734
+ return a.name.localeCompare(b.name);
735
+ })
736
+ .slice(0, limit);
737
+ }
679
738
 
680
739
  // ── Lookups ──
681
740
 
@@ -931,6 +990,15 @@ export interface LatestRelease {
931
990
  /** AI-generated smart-brevity headline (#852, renamed in #860). */
932
991
  titleShort?: string | null;
933
992
  media: MediaItem[];
993
+ /**
994
+ * Cached release-body size — `LENGTH(content)` and `countTokensSafe(content)`
995
+ * snapshotted at ingest, so clients can advertise "this release is ~1.5K
996
+ * tokens" without round-tripping the body. Both fields are optional and
997
+ * nullable: pre-existing rows land null until the backfill script
998
+ * populates them. See #958.
999
+ */
1000
+ contentChars?: number | null;
1001
+ contentTokens?: number | null;
934
1002
  }
935
1003
 
936
1004
  // ── Stats ──
@@ -75,6 +75,7 @@ export const UpdateOrgBodySchema = z.object({
75
75
  domain: z.string().nullable().optional(),
76
76
  description: z.string().nullable().optional(),
77
77
  category: z.string().min(1).nullable().optional(),
78
+ avatarUrl: z.string().nullable().optional(),
78
79
  tags: z.array(z.string()).optional(),
79
80
  aliases: z.array(z.string()).optional(),
80
81
  });
@@ -31,6 +31,11 @@ export const ReleaseLatestItemSchema = z.object({
31
31
  url: z.string().nullable(),
32
32
  media: z.array(MediaItemSchema),
33
33
  source: ReleaseLatestSourceSchema,
34
+ coverageCount: z.number().int().min(0).optional(),
35
+ // Cached release-body size hint — see {@link ReleaseItemSchema} for the
36
+ // same fields on the org / collection feeds. #958.
37
+ contentChars: z.number().int().min(0).nullable().optional(),
38
+ contentTokens: z.number().int().min(0).nullable().optional(),
34
39
  });
35
40
 
36
41
  export const ReleaseLatestResponseSchema = z.object({
@@ -77,6 +77,35 @@ export const SearchReleaseHitSchema = z.object({
77
77
  // Hybrid fusion score; absent on pure-lexical results.
78
78
  score: z.number().optional(),
79
79
  type: ReleaseTypeSchema.optional(),
80
+ coverageCount: z.number().int().min(0).optional(),
81
+ });
82
+
83
+ /**
84
+ * Collection hit on the unified `/v1/search` response. Two origins are folded
85
+ * into the same wire shape via the `via` discriminator:
86
+ *
87
+ * - `direct` — the collection itself matched (name/description via LIKE in
88
+ * every mode, plus vector match in hybrid/semantic mode).
89
+ * - `member` — the collection was surfaced because one of the orgs that
90
+ * *did* match the query is a member of it; `matchedOrgSlugs`
91
+ * carries the subset of result-set org slugs that triggered
92
+ * the rollup so the UI can render an "includes X, Y" hint.
93
+ *
94
+ * `direct` rows always sort ahead of `member` rows; ties break on
95
+ * `score` then `memberCount`. Slugs are unique across the array — a
96
+ * direct hit that's also a member rollup keeps only the `direct` row
97
+ * with `matchedOrgSlugs` carried over.
98
+ */
99
+ export const SearchCollectionHitSchema = z.object({
100
+ slug: z.string(),
101
+ name: z.string(),
102
+ description: z.string().nullable(),
103
+ memberCount: z.number().int().min(0),
104
+ via: z.enum(["direct", "member"]),
105
+ /** Hybrid/semantic fusion score; absent on pure-lexical hits. */
106
+ score: z.number().optional(),
107
+ /** Result-set org slugs that triggered this rollup. Always present on `via=member`. */
108
+ matchedOrgSlugs: z.array(z.string()).optional(),
80
109
  });
81
110
 
82
111
  /**
@@ -148,6 +177,13 @@ export const UnifiedSearchResponseSchema = z.object({
148
177
  catalog: z.array(SearchCatalogHitSchema),
149
178
  sources: z.array(SearchSourceHitSchema),
150
179
  releases: z.array(SearchReleaseHitSchema),
180
+ /**
181
+ * Curated collections matching the query, either directly (name/description
182
+ * match or vector hit) or via member rollup (one of the result orgs is in
183
+ * the collection). Optional on the wire so older workers mid-rollout don't
184
+ * trip the schema check; clients should treat missing and `[]` identically.
185
+ */
186
+ collections: z.array(SearchCollectionHitSchema).optional(),
151
187
  chunks: z.array(SearchChunkHitSchema).optional(),
152
188
  mode: z.enum(["lexical", "semantic", "hybrid"]).optional(),
153
189
  degraded: z.boolean().optional(),
@@ -29,6 +29,12 @@ export const ListResponseSchema = <T extends z.ZodTypeAny>(item: T) =>
29
29
  export const ErrorResponseSchema = z.object({
30
30
  error: z.string(),
31
31
  message: z.string(),
32
+ // Stable D1 error classification from `classifyDbError()` in
33
+ // `@releases/lib/db-errors`. Set on 5xx responses that originate from a
34
+ // classified D1 failure so callers can branch on a stable code instead of
35
+ // substring-matching the upstream message. Absent on non-D1 errors and on
36
+ // routes that have not yet been wired. See DbErrorCode in packages/lib.
37
+ errorCode: z.string().optional(),
32
38
  });
33
39
 
34
40
  export const StatsSchema = z.object({
@@ -69,6 +75,20 @@ export const ReleaseItemSchema = z.object({
69
75
  // `.optional()` for the same mid-deploy / pinned-worker reason as `type`.
70
76
  titleGenerated: z.string().nullable().optional(),
71
77
  titleShort: z.string().nullable().optional(),
78
+ // Count of demoted siblings rolling up into this row via `release_coverage`
79
+ // (0 when standalone). Lets list views show a cluster indicator without a
80
+ // detail-page round trip. `.optional()` — older API responses and pinned
81
+ // workers omit the field; treat undefined as 0.
82
+ coverageCount: z.number().int().min(0).optional(),
83
+ // Cached `LENGTH(content)` and `countTokensSafe(content)` for the release
84
+ // body — lets feed surfaces ("releases get <org>", `/v1/releases/latest`,
85
+ // MCP `get_latest_releases`) advertise "this release is ~1.5K tokens" so
86
+ // agents can decide whether to pull the full body. `.optional()` and
87
+ // nullable because pre-existing rows landed without the columns; the
88
+ // backfill script populates them and renderers degrade to "size unknown"
89
+ // when null. See #958.
90
+ contentChars: z.number().int().min(0).nullable().optional(),
91
+ contentTokens: z.number().int().min(0).nullable().optional(),
72
92
  });
73
93
 
74
94
  export const ReleaseSummaryItemSchema = z.object({