@buildinternet/releases-api-types 0.18.0 → 0.20.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.18.0",
3
+ "version": "0.20.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",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@buildinternet/releases-core": "^0.21.0",
21
- "zod": "^4.3.6"
21
+ "zod": "^4.4.3"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public"
package/src/api-types.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  StatsSchema,
17
17
  ErrorResponseSchema,
18
18
  ReleaseTypeSchema,
19
+ ReleaseCompositionSchema,
19
20
  ReleaseItemSchema,
20
21
  ReleaseSummaryItemSchema,
21
22
  OverviewPageItemSchema,
@@ -148,6 +149,7 @@ import type {
148
149
  SearchSourceHitSchema,
149
150
  SearchReleaseHitSchema,
150
151
  SearchChunkHitSchema,
152
+ SearchCollectionHitSchema,
151
153
  LookupResultPayloadSchema,
152
154
  UnifiedSearchResponseSchema,
153
155
  } from "./schemas/search.js";
@@ -221,6 +223,7 @@ export {
221
223
  StatsSchema,
222
224
  ErrorResponseSchema,
223
225
  ReleaseTypeSchema,
226
+ ReleaseCompositionSchema,
224
227
  ReleaseItemSchema,
225
228
  ReleaseSummaryItemSchema,
226
229
  OverviewPageItemSchema,
@@ -367,6 +370,7 @@ export {
367
370
  SearchSourceHitSchema,
368
371
  SearchReleaseHitSchema,
369
372
  SearchChunkHitSchema,
373
+ SearchCollectionHitSchema,
370
374
  LookupResultPayloadSchema,
371
375
  UnifiedSearchResponseSchema,
372
376
  } from "./schemas/search.js";
@@ -569,6 +573,7 @@ export interface OrgsRollupResponse extends ListResponse<OrgsRollupRow> {
569
573
  export type ReleaseType = z.infer<typeof ReleaseTypeSchema>;
570
574
 
571
575
  export type ReleaseItem = z.infer<typeof ReleaseItemSchema>;
576
+ export type ReleaseComposition = z.infer<typeof ReleaseCompositionSchema>;
572
577
 
573
578
  export interface ReleaseDetail {
574
579
  id: string;
@@ -597,6 +602,8 @@ export interface ReleaseDetail {
597
602
  org: { slug: string; name: string } | null;
598
603
  /** Release type. See {@link ReleaseType}. */
599
604
  type?: ReleaseType;
605
+ /** Per-category item counts from the AI release-content pass. See {@link ReleaseComposition}. */
606
+ composition?: ReleaseComposition | null;
600
607
  }
601
608
 
602
609
  export type ReleaseCoverageRow = z.infer<typeof ReleaseCoverageRowSchema>;
@@ -676,6 +683,63 @@ export function foldSourcesIntoCatalog(
676
683
 
677
684
  export type SearchReleaseHit = z.infer<typeof SearchReleaseHitSchema>;
678
685
  export type SearchChunkHit = z.infer<typeof SearchChunkHitSchema>;
686
+ export type SearchCollectionHit = z.infer<typeof SearchCollectionHitSchema>;
687
+
688
+ /**
689
+ * Merge collection hits from three independent sources into one ordered,
690
+ * deduped array on the wire:
691
+ *
692
+ * 1. Direct lexical matches — name/slug/description LIKE.
693
+ * 2. Direct semantic matches — vector hit (hybrid/semantic only).
694
+ * 3. Member rollups — collections containing one of the result orgs.
695
+ *
696
+ * A direct row always wins for a given slug; member rollups attach their
697
+ * `matchedOrgSlugs` to a winning direct row so the UI keeps the "shown
698
+ * because" affordance. Within each `via` bucket, rows order by score desc
699
+ * (missing scores last) then by name.
700
+ *
701
+ * Pure: no DB, no Workers runtime. Used by both `/v1/search` and the MCP
702
+ * `search` tool so the merge stays in lockstep across surfaces.
703
+ */
704
+ export function mergeCollectionHits(
705
+ direct: SearchCollectionHit[],
706
+ semantic: SearchCollectionHit[],
707
+ member: SearchCollectionHit[],
708
+ limit: number,
709
+ ): SearchCollectionHit[] {
710
+ // Pure: never mutate caller-owned objects. Always replace map entries with
711
+ // shallow copies so two callers (e.g. API and MCP merging from the same
712
+ // upstream list) can't see each other's writes.
713
+ const bySlug = new Map<string, SearchCollectionHit>();
714
+ for (const c of direct) bySlug.set(c.slug, { ...c });
715
+ for (const c of semantic) {
716
+ const existing = bySlug.get(c.slug);
717
+ if (existing) {
718
+ if (c.score !== undefined && (existing.score === undefined || c.score > existing.score)) {
719
+ bySlug.set(c.slug, { ...existing, score: c.score });
720
+ }
721
+ } else {
722
+ bySlug.set(c.slug, { ...c });
723
+ }
724
+ }
725
+ for (const c of member) {
726
+ const existing = bySlug.get(c.slug);
727
+ if (existing) {
728
+ bySlug.set(c.slug, { ...existing, matchedOrgSlugs: c.matchedOrgSlugs });
729
+ } else {
730
+ bySlug.set(c.slug, { ...c });
731
+ }
732
+ }
733
+ return [...bySlug.values()]
734
+ .toSorted((a, b) => {
735
+ if (a.via !== b.via) return a.via === "direct" ? -1 : 1;
736
+ const sa = a.score ?? -Infinity;
737
+ const sb = b.score ?? -Infinity;
738
+ if (sa !== sb) return sb - sa;
739
+ return a.name.localeCompare(b.name);
740
+ })
741
+ .slice(0, limit);
742
+ }
679
743
 
680
744
  // ── Lookups ──
681
745
 
@@ -931,6 +995,15 @@ export interface LatestRelease {
931
995
  /** AI-generated smart-brevity headline (#852, renamed in #860). */
932
996
  titleShort?: string | null;
933
997
  media: MediaItem[];
998
+ /**
999
+ * Cached release-body size — `LENGTH(content)` and `countTokensSafe(content)`
1000
+ * snapshotted at ingest, so clients can advertise "this release is ~1.5K
1001
+ * tokens" without round-tripping the body. Both fields are optional and
1002
+ * nullable: pre-existing rows land null until the backfill script
1003
+ * populates them. See #958.
1004
+ */
1005
+ contentChars?: number | null;
1006
+ contentTokens?: number | null;
934
1007
  }
935
1008
 
936
1009
  // ── Stats ──
@@ -75,8 +75,11 @@ 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(),
81
+ /** Admin-only: pause/unpause all ingest for this org without touching sources (#1057). */
82
+ fetchPaused: z.boolean().optional(),
80
83
  });
81
84
 
82
85
  // Org detail's products query selects a strict subset of `ProductListItem` —
@@ -1,6 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { SOURCE_TYPES } from "@buildinternet/releases-core/source-enums";
3
- import { MediaItemSchema, ReleaseTypeSchema } from "./shared.js";
3
+ import { MediaItemSchema, ReleaseCompositionSchema, ReleaseTypeSchema } from "./shared.js";
4
+
5
+ // ReleaseCompositionSchema lives in ./shared.js (single source of truth) — both
6
+ // this file and ReleaseItemSchema reference it from there.
4
7
 
5
8
  /**
6
9
  * Per-row shape returned by `GET /v1/releases/latest`. Distinct from the
@@ -32,6 +35,11 @@ export const ReleaseLatestItemSchema = z.object({
32
35
  media: z.array(MediaItemSchema),
33
36
  source: ReleaseLatestSourceSchema,
34
37
  coverageCount: z.number().int().min(0).optional(),
38
+ // Cached release-body size hint — see {@link ReleaseItemSchema} for the
39
+ // same fields on the org / collection feeds. #958.
40
+ contentChars: z.number().int().min(0).nullable().optional(),
41
+ contentTokens: z.number().int().min(0).nullable().optional(),
42
+ composition: ReleaseCompositionSchema.nullable().optional(),
35
43
  });
36
44
 
37
45
  export const ReleaseLatestResponseSchema = z.object({
@@ -151,6 +159,7 @@ export const ReleaseDetailResponseSchema = z.object({
151
159
  sourceSlug: z.string().nullable(),
152
160
  sourceType: z.enum(SOURCE_TYPES).nullable(),
153
161
  org: ReleaseDetailOrgSchema.nullable(),
162
+ composition: ReleaseCompositionSchema.nullable(),
154
163
  });
155
164
 
156
165
  /**
@@ -173,6 +182,8 @@ export const UpdateReleaseBodySchema = z
173
182
  summary: z.string().nullable().optional(),
174
183
  titleGenerated: z.string().nullable().optional(),
175
184
  titleShort: z.string().nullable().optional(),
185
+ // `null` removes `$.composition` from metadata; an object replaces it.
186
+ composition: ReleaseCompositionSchema.nullable().optional(),
176
187
  })
177
188
  .strict();
178
189
 
@@ -80,6 +80,34 @@ export const SearchReleaseHitSchema = z.object({
80
80
  coverageCount: z.number().int().min(0).optional(),
81
81
  });
82
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(),
109
+ });
110
+
83
111
  /**
84
112
  * Heading-aware CHANGELOG.md slice returned by hybrid / semantic search.
85
113
  * Clients can deep-link to
@@ -149,6 +177,13 @@ export const UnifiedSearchResponseSchema = z.object({
149
177
  catalog: z.array(SearchCatalogHitSchema),
150
178
  sources: z.array(SearchSourceHitSchema),
151
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(),
152
187
  chunks: z.array(SearchChunkHitSchema).optional(),
153
188
  mode: z.enum(["lexical", "semantic", "hybrid"]).optional(),
154
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({
@@ -40,6 +46,19 @@ export const StatsSchema = z.object({
40
46
 
41
47
  export const ReleaseTypeSchema = z.enum(RELEASE_TYPES);
42
48
 
49
+ /**
50
+ * Per-category item counts produced by the AI release-content pass and
51
+ * persisted on `releases.metadata.composition`. Surfaces as a chip on the
52
+ * release detail UI ("12 fixes · 3 features"); `null` when the row hasn't
53
+ * been through the AI pass yet, the body was empty/boilerplate, or all
54
+ * three counts came back zero (we filter that case out on the parse side).
55
+ */
56
+ export const ReleaseCompositionSchema = z.object({
57
+ bugs: z.number().int().min(0),
58
+ features: z.number().int().min(0),
59
+ enhancements: z.number().int().min(0),
60
+ });
61
+
43
62
  export const ReleaseItemSchema = z.object({
44
63
  id: z.string().optional(),
45
64
  version: z.string().nullable(),
@@ -74,6 +93,19 @@ export const ReleaseItemSchema = z.object({
74
93
  // detail-page round trip. `.optional()` — older API responses and pinned
75
94
  // workers omit the field; treat undefined as 0.
76
95
  coverageCount: z.number().int().min(0).optional(),
96
+ // Cached `LENGTH(content)` and `countTokensSafe(content)` for the release
97
+ // body — lets feed surfaces ("releases get <org>", `/v1/releases/latest`,
98
+ // MCP `get_latest_releases`) advertise "this release is ~1.5K tokens" so
99
+ // agents can decide whether to pull the full body. `.optional()` and
100
+ // nullable because pre-existing rows landed without the columns; the
101
+ // backfill script populates them and renderers degrade to "size unknown"
102
+ // when null. See #958.
103
+ contentChars: z.number().int().min(0).nullable().optional(),
104
+ contentTokens: z.number().int().min(0).nullable().optional(),
105
+ // Per-category item counts produced by the AI release-content pass and
106
+ // stored on `releases.metadata.composition`. `null`/`undefined` when the
107
+ // row hasn't been through the AI pass yet or the body was boilerplate.
108
+ composition: ReleaseCompositionSchema.nullable().optional(),
77
109
  });
78
110
 
79
111
  export const ReleaseSummaryItemSchema = z.object({