@buildinternet/releases-api-types 0.16.0 → 0.17.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.
@@ -0,0 +1,138 @@
1
+ import { z } from "zod";
2
+ import { OverviewPageItemSchema, OverviewCitationSchema } from "./shared.js";
3
+
4
+ /**
5
+ * Response shape returned by `GET /v1/orgs/:slug/overview` — the org-scoped
6
+ * knowledge page with inline citations, or `null` when no overview has been
7
+ * generated yet.
8
+ */
9
+ export const OrgOverviewResponseSchema = OverviewPageItemSchema.extend({
10
+ citations: z.array(OverviewCitationSchema).optional(),
11
+ }).nullable();
12
+
13
+ /**
14
+ * Incoming citation row attached to `POST /v1/orgs/:slug/overview`. Slimmer
15
+ * than the read-side `OverviewCitationSchema` because the wire shape doesn't
16
+ * carry `releaseId` — that's resolved server-side from `sourceUrl`. Spans
17
+ * must be in-range (`endIndex > startIndex`); the cross-field
18
+ * `endIndex <= content.length` check stays in the handler.
19
+ */
20
+ export const IncomingOverviewCitationSchema = z
21
+ .object({
22
+ startIndex: z.number().int().min(0),
23
+ endIndex: z.number().int().min(1),
24
+ sourceUrl: z.string().min(1),
25
+ title: z.string().nullable().optional(),
26
+ citedText: z.string().min(1),
27
+ })
28
+ .refine((c) => c.endIndex > c.startIndex, {
29
+ message: "endIndex must be > startIndex",
30
+ });
31
+
32
+ /**
33
+ * Body accepted by `POST /v1/orgs/:slug/overview` — the agent-authored
34
+ * markdown content, the release count it was derived from, the timestamp of
35
+ * the most-recent contributing release, and optional inline citations.
36
+ *
37
+ * `content` is non-empty. `releaseCount` is a finite non-negative integer.
38
+ * `citations` is an array of `IncomingOverviewCitation`; the handler still
39
+ * cross-checks `endIndex <= content.length` since that bound is content-aware.
40
+ */
41
+ export const RegenerateOverviewBodySchema = z.object({
42
+ content: z.string().min(1),
43
+ releaseCount: z.number().int().min(0),
44
+ lastContributingReleaseAt: z.string().nullable().optional(),
45
+ citations: z.array(IncomingOverviewCitationSchema).optional(),
46
+ });
47
+
48
+ /** Response returned by `POST /v1/orgs/:slug/overview` on success. */
49
+ export const RegenerateOverviewResponseSchema = z.object({
50
+ ok: z.literal(true),
51
+ citations: z.number().int().min(0),
52
+ });
53
+
54
+ /**
55
+ * Response shape returned by `GET /v1/products/:slug/overview` — the
56
+ * product-scoped knowledge page, or `null` when no overview exists.
57
+ * Does not include inline citations (products overview does not store them).
58
+ */
59
+ export const ProductOverviewResponseSchema = OverviewPageItemSchema.nullable();
60
+
61
+ /**
62
+ * Pre-flight payload returned by `GET /v1/orgs/:slug/overview/inputs?check=true`.
63
+ * Lets an orchestrator decide whether to dispatch a per-org sub-agent without
64
+ * paying for the full release-content hydration.
65
+ */
66
+ export const OverviewInputsCheckResponseSchema = z.object({
67
+ orgSlug: z.string(),
68
+ selected: z.number().int().min(0),
69
+ totalAvailable: z.number().int().min(0),
70
+ hasExistingContent: z.boolean(),
71
+ wouldRegenerate: z.boolean(),
72
+ windowDays: z.number().int().min(1),
73
+ });
74
+
75
+ /**
76
+ * Full inputs payload returned by `GET /v1/orgs/:slug/overview/inputs` (without
77
+ * `?check=true`). Contains org metadata, active sources, the existing overview
78
+ * content if any, and the selected hydrated releases ready for the AI model.
79
+ *
80
+ * Typed loosely because the release shape includes hydrated media URLs that
81
+ * differ slightly from `ReleaseItem` — this avoids tight coupling to internal
82
+ * fields while still surfacing the discriminating top-level structure.
83
+ */
84
+ export const OverviewInputsFullResponseSchema = z.object({
85
+ org: z.object({
86
+ id: z.string(),
87
+ slug: z.string(),
88
+ name: z.string(),
89
+ description: z.string().nullable(),
90
+ discovery: z.string(),
91
+ }),
92
+ sources: z.array(
93
+ z.object({
94
+ id: z.string(),
95
+ slug: z.string(),
96
+ name: z.string(),
97
+ type: z.string(),
98
+ }),
99
+ ),
100
+ existingContent: z.string().nullable(),
101
+ selected: z.array(z.unknown()),
102
+ totalAvailable: z.number().int().min(0),
103
+ windowDays: z.number().int().min(1),
104
+ });
105
+
106
+ /**
107
+ * Union of the two shapes `GET /v1/orgs/:slug/overview/inputs` can return.
108
+ * The pre-flight (`?check=true`) variant skips content hydration; the full
109
+ * variant carries org metadata + sources + selected releases. Single response
110
+ * entry — Zod union so OpenAPI emits one schema with the variants inlined.
111
+ */
112
+ export const OverviewInputsResponseSchema = z.union([
113
+ OverviewInputsCheckResponseSchema,
114
+ OverviewInputsFullResponseSchema,
115
+ ]);
116
+
117
+ /**
118
+ * Response returned by `GET /v1/orgs/:slug/playbook` — the playbook knowledge
119
+ * page (scope `"playbook"`), or `null` when no playbook exists for the org.
120
+ *
121
+ * The playbook `content` field holds a deterministic markdown header generated
122
+ * by `generatePlaybookHeader()` from `@releases/ai-internal/playbook`.
123
+ */
124
+ export const PlaybookResponseSchema = OverviewPageItemSchema.extend({
125
+ scope: z.literal("playbook"),
126
+ notes: z.string().nullable().optional(),
127
+ }).nullable();
128
+
129
+ /** Body accepted by `PATCH /v1/orgs/:slug/playbook/notes`. */
130
+ export const UpdatePlaybookNotesBodySchema = z.object({
131
+ notes: z.string(),
132
+ });
133
+
134
+ /** Response returned by `PATCH /v1/orgs/:slug/playbook/notes` on success. */
135
+ export const UpdatePlaybookNotesResponseSchema = z.object({
136
+ ok: z.literal(true),
137
+ notes: z.string().nullable(),
138
+ });
@@ -66,7 +66,14 @@ export const ProductDetailSchema = ProductRowSchema.extend({
66
66
  aliases: z.array(z.string()),
67
67
  });
68
68
 
69
- /** Body accepted by `POST /v1/products`. */
69
+ /**
70
+ * Body accepted by `POST /v1/products`. `category` is a non-empty string at
71
+ * the wire boundary so callers can pass either a canonical slug or one of
72
+ * its configured aliases (resolved server-side via `resolveCategoryInput`).
73
+ * Responses always carry the canonical slug. Empty strings are rejected so
74
+ * the truthy-guard around `resolveCategoryInput` in the handler can't be
75
+ * bypassed into persisting an invalid `""` value.
76
+ */
70
77
  export const CreateProductBodySchema = z.object({
71
78
  name: z.string().min(1),
72
79
  orgId: z.string().optional(),
@@ -74,16 +81,20 @@ export const CreateProductBodySchema = z.object({
74
81
  slug: z.string().optional(),
75
82
  url: z.string().optional(),
76
83
  description: z.string().optional(),
77
- category: CategorySchema.optional(),
84
+ category: z.string().min(1).optional(),
78
85
  tags: z.array(z.string()).optional(),
79
86
  });
80
87
 
81
- /** Body accepted by `PATCH /v1/products/:slug`. */
88
+ /**
89
+ * Body accepted by `PATCH /v1/products/:slug`. Same alias-allowed string rule
90
+ * as create, plus `null` to explicitly clear the overlay. Empty strings stay
91
+ * rejected so callers must pick between a slug/alias or `null`.
92
+ */
82
93
  export const UpdateProductBodySchema = z.object({
83
94
  name: z.string().optional(),
84
95
  url: z.string().nullable().optional(),
85
96
  description: z.string().nullable().optional(),
86
- category: CategorySchema.nullable().optional(),
97
+ category: z.string().min(1).nullable().optional(),
87
98
  tags: z.array(z.string()).optional(),
88
99
  aliases: z.array(z.string()).optional(),
89
100
  });
@@ -144,3 +155,20 @@ export const ProductDeleteResponseSchema = z.object({
144
155
  hard: z.literal(true).optional(),
145
156
  deletedAt: z.string().optional(),
146
157
  });
158
+
159
+ /**
160
+ * Tag list returned by `GET /v1/products/:identifier/tags`. Tag names are
161
+ * sorted alphabetically; the empty array is returned when the product has
162
+ * no tags rather than 404.
163
+ */
164
+ export const ProductTagsListResponseSchema = z.array(z.string());
165
+
166
+ /** Body accepted by `PUT` and `DELETE` `/v1/products/:identifier/tags`. */
167
+ export const ProductTagsBodySchema = z.object({
168
+ tags: z.array(z.string()),
169
+ });
170
+
171
+ /** Response shape returned by `PUT` and `DELETE` `/v1/products/:identifier/tags`. */
172
+ export const ProductTagsMutationResponseSchema = z.object({
173
+ ok: z.literal(true),
174
+ });
@@ -0,0 +1,107 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Anchor scope for related-* lookups. `org` filters the Vectorize query by
5
+ * the anchor's org id; `global` (default) runs unscoped.
6
+ */
7
+ export const RelatedScopeSchema = z.enum(["org", "global"]);
8
+
9
+ /** Thumbnail picked from the release's first image-like media entry. */
10
+ export const RelatedReleaseThumbnailSchema = z.object({
11
+ url: z.string(),
12
+ alt: z.string().optional(),
13
+ });
14
+
15
+ /** Owning source rollup attached to each related-release row. */
16
+ export const RelatedReleaseSourceSchema = z.object({
17
+ id: z.string(),
18
+ slug: z.string(),
19
+ name: z.string(),
20
+ orgSlug: z.string().nullable(),
21
+ orgName: z.string().nullable(),
22
+ });
23
+
24
+ /**
25
+ * One neighbor returned by `GET /v1/related/releases`. `score` is the
26
+ * Vectorize cosine similarity from the underlying query.
27
+ */
28
+ export const RelatedReleaseItemSchema = z.object({
29
+ id: z.string(),
30
+ title: z.string(),
31
+ version: z.string().nullable(),
32
+ url: z.string().nullable(),
33
+ publishedAt: z.string().nullable(),
34
+ summary: z.string(),
35
+ titleGenerated: z.string().nullable(),
36
+ titleShort: z.string().nullable(),
37
+ thumbnail: RelatedReleaseThumbnailSchema.nullable(),
38
+ score: z.number(),
39
+ source: RelatedReleaseSourceSchema,
40
+ });
41
+
42
+ /**
43
+ * One neighbor returned by `GET /v1/related/sources`. Carries enough
44
+ * source + org + recent-release stats for the rail card to render without
45
+ * a follow-up request.
46
+ */
47
+ export const RelatedSourceItemSchema = z.object({
48
+ id: z.string(),
49
+ slug: z.string(),
50
+ name: z.string(),
51
+ type: z.string(),
52
+ url: z.string().nullable(),
53
+ score: z.number(),
54
+ orgSlug: z.string().nullable(),
55
+ orgName: z.string().nullable(),
56
+ orgAvatarUrl: z.string().nullable(),
57
+ releaseCount: z.number().int().min(0),
58
+ latestDate: z.string().nullable(),
59
+ latestTitle: z.string().nullable(),
60
+ latestVersion: z.string().nullable(),
61
+ /** Total releases published in the last 30 days (includes the latest). */
62
+ recentCount: z.number().int().min(0),
63
+ });
64
+
65
+ /**
66
+ * Successful response from `GET /v1/related/releases`. The handler returns
67
+ * `{scope, items}` when Vectorize is reachable and the anchor has a vector.
68
+ */
69
+ export const RelatedReleasesOkResponseSchema = z.object({
70
+ scope: RelatedScopeSchema,
71
+ items: z.array(RelatedReleaseItemSchema),
72
+ });
73
+
74
+ /**
75
+ * Degraded response from `GET /v1/related/releases`. Returned (HTTP 200) when
76
+ * Vectorize bindings are missing, the anchor hasn't been embedded yet, or a
77
+ * downstream call errored. Callers render nothing in this case rather than
78
+ * surfacing a hard failure. `degradedReason` is human-readable, not stable.
79
+ */
80
+ export const RelatedReleasesDegradedResponseSchema = z.object({
81
+ degraded: z.literal(true),
82
+ degradedReason: z.string(),
83
+ items: z.array(RelatedReleaseItemSchema),
84
+ });
85
+
86
+ export const RelatedReleasesResponseSchema = z.union([
87
+ RelatedReleasesOkResponseSchema,
88
+ RelatedReleasesDegradedResponseSchema,
89
+ ]);
90
+
91
+ /** Successful response from `GET /v1/related/sources`. */
92
+ export const RelatedSourcesOkResponseSchema = z.object({
93
+ scope: RelatedScopeSchema,
94
+ items: z.array(RelatedSourceItemSchema),
95
+ });
96
+
97
+ /** Degraded response from `GET /v1/related/sources`. Same semantics as the releases variant. */
98
+ export const RelatedSourcesDegradedResponseSchema = z.object({
99
+ degraded: z.literal(true),
100
+ degradedReason: z.string(),
101
+ items: z.array(RelatedSourceItemSchema),
102
+ });
103
+
104
+ export const RelatedSourcesResponseSchema = z.union([
105
+ RelatedSourcesOkResponseSchema,
106
+ RelatedSourcesDegradedResponseSchema,
107
+ ]);
@@ -0,0 +1,249 @@
1
+ import { z } from "zod";
2
+ import { SOURCE_TYPES } from "@buildinternet/releases-core/source-enums";
3
+ import { MediaItemSchema, ReleaseTypeSchema } from "./shared.js";
4
+
5
+ /**
6
+ * Per-row shape returned by `GET /v1/releases/latest`. Distinct from the
7
+ * shared `ReleaseItemSchema` because the latest-feed handler joins the
8
+ * source row in and exposes it as a nested `source` object instead of
9
+ * leaving the caller to do a second lookup.
10
+ *
11
+ * `type` is required here — the latest handler reads it directly from
12
+ * `releases.type` (NOT NULL with DB-default `'feature'`), so mid-deploy
13
+ * degrade-gracefully nullability doesn't apply on this path.
14
+ */
15
+ export const ReleaseLatestSourceSchema = z.object({
16
+ slug: z.string(),
17
+ name: z.string(),
18
+ type: z.enum(SOURCE_TYPES),
19
+ orgSlug: z.string().nullable(),
20
+ });
21
+
22
+ export const ReleaseLatestItemSchema = z.object({
23
+ id: z.string(),
24
+ version: z.string().nullable(),
25
+ type: ReleaseTypeSchema,
26
+ title: z.string(),
27
+ summary: z.string().nullable(),
28
+ titleGenerated: z.string().nullable(),
29
+ titleShort: z.string().nullable(),
30
+ publishedAt: z.string().nullable(),
31
+ url: z.string().nullable(),
32
+ media: z.array(MediaItemSchema),
33
+ source: ReleaseLatestSourceSchema,
34
+ });
35
+
36
+ export const ReleaseLatestResponseSchema = z.object({
37
+ releases: z.array(ReleaseLatestItemSchema),
38
+ });
39
+
40
+ /**
41
+ * Single row of the `release_coverage` join table. Drives the
42
+ * GET /releases/:id/coverage response.
43
+ */
44
+ export const ReleaseCoverageRowSchema = z.object({
45
+ coverageId: z.string(),
46
+ canonicalId: z.string(),
47
+ reason: z.string().nullable(),
48
+ decidedBy: z.string(),
49
+ decidedAt: z.string(),
50
+ });
51
+
52
+ /**
53
+ * Discriminated by `role`:
54
+ * - `standalone` — release is neither canonical nor coverage of anything.
55
+ * - `coverage` — release is coverage rolling up to `canonical`.
56
+ * - `canonical` — release is the canonical row; `covers` enumerates rollups.
57
+ */
58
+ export const ReleaseCoverageResponseSchema = z.discriminatedUnion("role", [
59
+ z.object({
60
+ role: z.literal("standalone"),
61
+ canonical: z.null(),
62
+ covers: z.array(z.never()).length(0),
63
+ }),
64
+ z.object({
65
+ role: z.literal("coverage"),
66
+ canonical: ReleaseCoverageRowSchema,
67
+ covers: z.array(z.never()).length(0),
68
+ }),
69
+ z.object({
70
+ role: z.literal("canonical"),
71
+ canonical: z.null(),
72
+ covers: z.array(ReleaseCoverageRowSchema),
73
+ }),
74
+ ]);
75
+
76
+ /**
77
+ * Body accepted by `POST /v1/releases/:id/coverage`. `decidedBy` MUST be
78
+ * prefixed with `human:` or `agent:` so the audit trail records who linked
79
+ * the rows; the handler enforces this with a regex.
80
+ */
81
+ export const LinkReleaseCoverageBodySchema = z.object({
82
+ coverageIds: z.array(z.string()).min(1),
83
+ reason: z.string().nullable().optional(),
84
+ decidedBy: z.string().regex(/^(human:|agent:)/),
85
+ });
86
+
87
+ export const LinkReleaseCoverageResponseSchema = z.object({
88
+ canonicalId: z.string(),
89
+ coverageIds: z.array(z.string()),
90
+ linked: z.number().int().min(0),
91
+ });
92
+
93
+ /**
94
+ * Response for `DELETE /v1/releases/:id/coverage`. Idempotent — the handler
95
+ * returns `{ unlinked: false }` when the release isn't in a cluster, so the
96
+ * remote client can skip a brittle error-message sniff.
97
+ */
98
+ export const UnlinkReleaseCoverageResponseSchema = z.object({
99
+ unlinked: z.boolean(),
100
+ });
101
+
102
+ /**
103
+ * Row returned by `GET /v1/releases?hasMedia=true`. Internal/admin escape
104
+ * hatch consumed by the `media backfill` CLI command.
105
+ */
106
+ export const ReleaseWithMediaRowSchema = z.object({
107
+ id: z.string(),
108
+ sourceId: z.string(),
109
+ media: z.string().nullable(),
110
+ });
111
+
112
+ export const ReleasesWithMediaResponseSchema = z.array(ReleaseWithMediaRowSchema);
113
+
114
+ /**
115
+ * Nested org context joined into the `GET /v1/releases/:id` response.
116
+ * Both fields are nullable — releases whose source has no org attached
117
+ * return `null` for this object entirely.
118
+ */
119
+ export const ReleaseDetailOrgSchema = z.object({
120
+ slug: z.string(),
121
+ name: z.string(),
122
+ });
123
+
124
+ /**
125
+ * Full release detail returned by `GET /v1/releases/:id`. Joins source
126
+ * name/slug/type and a nullable org object; media is parsed from the raw
127
+ * JSON stored in D1 and typed URLs are rewritten to the CDN origin. The
128
+ * route only surfaces non-suppressed, non-coverage rows (filtered via the
129
+ * `releases_visible` view).
130
+ */
131
+ export const ReleaseDetailResponseSchema = z.object({
132
+ id: z.string(),
133
+ sourceId: z.string(),
134
+ version: z.string().nullable(),
135
+ type: ReleaseTypeSchema,
136
+ title: z.string(),
137
+ content: z.string(),
138
+ summary: z.string().nullable(),
139
+ titleGenerated: z.string().nullable(),
140
+ titleShort: z.string().nullable(),
141
+ url: z.string().nullable(),
142
+ contentHash: z.string().nullable(),
143
+ media: z.array(MediaItemSchema),
144
+ publishedAt: z.string().nullable(),
145
+ suppressed: z.boolean().nullable(),
146
+ suppressedReason: z.string().nullable(),
147
+ prerelease: z.boolean().nullable(),
148
+ fetchedAt: z.string().nullable(),
149
+ sourceName: z.string().nullable(),
150
+ sourceSlug: z.string().nullable(),
151
+ sourceType: z.enum(SOURCE_TYPES).nullable(),
152
+ org: ReleaseDetailOrgSchema.nullable(),
153
+ });
154
+
155
+ /**
156
+ * Body accepted by `PATCH /v1/releases/:id`. All fields are optional;
157
+ * omitted fields are not updated. The nullable string columns (`version`,
158
+ * `url`, `publishedAt`, `contentHash`) accept a string or `null`. AI-generated
159
+ * fields (`summary`, `titleGenerated`, `titleShort`) accept `null` to
160
+ * explicitly clear the stored value. Non-whitelisted fields are rejected
161
+ * via `.strict()` — the handler also re-runs the field-set check after
162
+ * sanitization so an empty update payload still 400s.
163
+ */
164
+ export const UpdateReleaseBodySchema = z
165
+ .object({
166
+ title: z.string().optional(),
167
+ content: z.string().optional(),
168
+ version: z.string().nullable().optional(),
169
+ url: z.string().nullable().optional(),
170
+ publishedAt: z.string().nullable().optional(),
171
+ contentHash: z.string().nullable().optional(),
172
+ summary: z.string().nullable().optional(),
173
+ titleGenerated: z.string().nullable().optional(),
174
+ titleShort: z.string().nullable().optional(),
175
+ })
176
+ .strict();
177
+
178
+ /**
179
+ * Response returned by `PATCH /v1/releases/:id`. The handler returns the raw
180
+ * Drizzle row from the `releases` table — distinct from the augmented
181
+ * `ReleaseDetailResponseSchema` shape served by `GET /v1/releases/:id`, which
182
+ * adds joined source/org metadata and parses `media` from raw JSON. Clients
183
+ * that need the augmented shape after an edit should re-fetch via GET.
184
+ */
185
+ export const ReleasePatchResponseSchema = z.object({
186
+ id: z.string(),
187
+ sourceId: z.string(),
188
+ version: z.string().nullable(),
189
+ type: ReleaseTypeSchema,
190
+ title: z.string(),
191
+ content: z.string(),
192
+ summary: z.string().nullable(),
193
+ titleGenerated: z.string().nullable(),
194
+ titleShort: z.string().nullable(),
195
+ url: z.string().nullable(),
196
+ contentHash: z.string().nullable(),
197
+ metadata: z.string().nullable(),
198
+ // Raw JSON string of the media array — not parsed at this endpoint.
199
+ media: z.string().nullable(),
200
+ publishedAt: z.string().nullable(),
201
+ prerelease: z.boolean().nullable(),
202
+ suppressed: z.boolean().nullable(),
203
+ suppressedReason: z.string().nullable(),
204
+ fetchedAt: z.string().nullable(),
205
+ embeddedAt: z.string().nullable(),
206
+ });
207
+
208
+ /** Response returned by `DELETE /v1/releases/:id`. */
209
+ export const ReleaseDeleteResponseSchema = z.object({
210
+ deleted: z.literal(true),
211
+ });
212
+
213
+ /** Response returned by `POST /v1/releases/:id/suppress`. */
214
+ export const ReleaseSuppressResponseSchema = z.object({
215
+ suppressed: z.literal(true),
216
+ });
217
+
218
+ /** Response returned by `POST /v1/releases/:id/unsuppress`. */
219
+ export const ReleaseUnsuppressResponseSchema = z.object({
220
+ unsuppressed: z.literal(true),
221
+ });
222
+
223
+ /**
224
+ * Body accepted by `POST /v1/releases/:id/suppress`. The `reason` field
225
+ * is optional; when omitted the stored `suppressed_reason` is set to null.
226
+ */
227
+ export const ReleaseSuppressBodySchema = z.object({
228
+ reason: z.string().optional(),
229
+ });
230
+
231
+ /**
232
+ * WebSocket message schema for the `GET /v1/releases/stream` endpoint.
233
+ * Each message is a JSON object with one of the following shapes:
234
+ * - `{ type: "ready", seq: number }` — sent on connect to give the caller
235
+ * the current head sequence number for later resume.
236
+ * - `{ type: "release.created", seq: number, release: object }` — a new
237
+ * release was indexed; `release` carries the full release row.
238
+ * - `{ type: "snapshot_gap" }` — the requested `since` sequence fell
239
+ * behind the oldest buffered event; the client must REST-backfill.
240
+ */
241
+ export const ReleaseStreamMessageSchema = z.discriminatedUnion("type", [
242
+ z.object({ type: z.literal("ready"), seq: z.number().int() }),
243
+ z.object({
244
+ type: z.literal("release.created"),
245
+ seq: z.number().int(),
246
+ release: z.record(z.string(), z.unknown()),
247
+ }),
248
+ z.object({ type: z.literal("snapshot_gap") }),
249
+ ]);