@read-frog/api-contract 0.24.0 → 0.25.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": "@read-frog/api-contract",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,15 +24,15 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@orpc/contract": "^1.15.2",
28
- "@read-frog/definitions": "0.12.0",
29
- "zod": "^4.6.5"
27
+ "@orpc/contract": "^1.16.1",
28
+ "@read-frog/definitions": "0.13.0",
29
+ "zod": "^4.6.7"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@repo/tsdown-config": "0.0.0",
33
33
  "@repo/typescript-config": "0.0.0",
34
- "@types/node": "^26.6.2",
35
- "tsdown": "^0.23.0"
34
+ "@types/node": "^26.6.6",
35
+ "tsdown": "^0.23.4"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsdown",
@@ -23,7 +23,6 @@ const ebookProcedure = oc;
23
23
 
24
24
  export const ebookContract = {
25
25
  list: ebookProcedure
26
- .errors(pickPublicErrorMap("EBOOK_BETA_RESTRICTED"))
27
26
  .route({
28
27
  method: "GET",
29
28
  path: "/ebooks",
@@ -34,7 +33,7 @@ export const ebookContract = {
34
33
  .output(ebookListOutputSchema),
35
34
 
36
35
  get: ebookProcedure
37
- .errors(pickPublicErrorMap("EBOOK_BETA_RESTRICTED", "EBOOK_NOT_FOUND"))
36
+ .errors(pickPublicErrorMap("EBOOK_NOT_FOUND"))
38
37
  .route({
39
38
  method: "GET",
40
39
  path: "/ebooks/{id}",
@@ -45,9 +44,7 @@ export const ebookContract = {
45
44
  .output(ebookGetOutputSchema),
46
45
 
47
46
  requestUpload: ebookProcedure
48
- .errors(
49
- pickPublicErrorMap("EBOOK_BETA_RESTRICTED", "EBOOK_LIMIT_EXCEEDED", "EBOOK_FILE_TOO_LARGE"),
50
- )
47
+ .errors(pickPublicErrorMap("EBOOK_LIMIT_EXCEEDED", "EBOOK_FILE_TOO_LARGE"))
51
48
  .route({
52
49
  method: "POST",
53
50
  path: "/ebooks/uploads",
@@ -59,12 +56,7 @@ export const ebookContract = {
59
56
 
60
57
  finalizeUpload: ebookProcedure
61
58
  .errors(
62
- pickPublicErrorMap(
63
- "EBOOK_BETA_RESTRICTED",
64
- "EBOOK_NOT_FOUND",
65
- "EBOOK_UPLOAD_NOT_COMPLETED",
66
- "EBOOK_FILE_TOO_LARGE",
67
- ),
59
+ pickPublicErrorMap("EBOOK_NOT_FOUND", "EBOOK_UPLOAD_NOT_COMPLETED", "EBOOK_FILE_TOO_LARGE"),
68
60
  )
69
61
  .route({
70
62
  method: "POST",
@@ -76,7 +68,7 @@ export const ebookContract = {
76
68
  .output(ebookFinalizeUploadOutputSchema),
77
69
 
78
70
  getDownloadUrl: ebookProcedure
79
- .errors(pickPublicErrorMap("EBOOK_BETA_RESTRICTED", "EBOOK_NOT_FOUND"))
71
+ .errors(pickPublicErrorMap("EBOOK_NOT_FOUND"))
80
72
  .route({
81
73
  method: "GET",
82
74
  path: "/ebooks/{id}/download-url",
@@ -87,7 +79,7 @@ export const ebookContract = {
87
79
  .output(ebookGetDownloadUrlOutputSchema),
88
80
 
89
81
  updateProgress: ebookProcedure
90
- .errors(pickPublicErrorMap("EBOOK_BETA_RESTRICTED", "EBOOK_NOT_FOUND"))
82
+ .errors(pickPublicErrorMap("EBOOK_NOT_FOUND"))
91
83
  .route({
92
84
  method: "PATCH",
93
85
  path: "/ebooks/{id}/progress",
@@ -98,7 +90,7 @@ export const ebookContract = {
98
90
  .output(ebookUpdateProgressOutputSchema),
99
91
 
100
92
  rename: ebookProcedure
101
- .errors(pickPublicErrorMap("EBOOK_BETA_RESTRICTED", "EBOOK_NOT_FOUND"))
93
+ .errors(pickPublicErrorMap("EBOOK_NOT_FOUND"))
102
94
  .route({
103
95
  method: "PATCH",
104
96
  path: "/ebooks/{id}",
@@ -109,7 +101,7 @@ export const ebookContract = {
109
101
  .output(ebookRenameOutputSchema),
110
102
 
111
103
  delete: ebookProcedure
112
- .errors(pickPublicErrorMap("EBOOK_BETA_RESTRICTED", "EBOOK_NOT_FOUND"))
104
+ .errors(pickPublicErrorMap("EBOOK_NOT_FOUND"))
113
105
  .route({
114
106
  method: "DELETE",
115
107
  path: "/ebooks/{id}",
@@ -1,5 +1,7 @@
1
1
  import { oc } from "@orpc/contract";
2
+ import { z } from "zod";
2
3
  import {
4
+ mcpCallUsageSchema,
3
5
  mcpConnectionListOutputSchema,
4
6
  mcpConnectionRevokeInputSchema,
5
7
  mcpConnectionRevokeOutputSchema,
@@ -23,4 +25,13 @@ export const mcpConnectionContract = {
23
25
  })
24
26
  .input(mcpConnectionRevokeInputSchema)
25
27
  .output(mcpConnectionRevokeOutputSchema),
28
+ usage: oc
29
+ .route({
30
+ method: "GET",
31
+ path: "/mcp-connections/usage",
32
+ summary: "Get the MCP tool calls the caller has used of their plan's daily limit",
33
+ tags: ["MCP Connections"],
34
+ })
35
+ .input(z.object({}).strict())
36
+ .output(mcpCallUsageSchema),
26
37
  };
@@ -0,0 +1,60 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { z } from "zod";
3
+ import { pickPublicErrorMap } from "#/public-errors";
4
+ import {
5
+ mediaAssetIdInputSchema,
6
+ mediaAssetOutputSchema,
7
+ mediaRequestUploadInputSchema,
8
+ mediaRequestUploadOutputSchema,
9
+ mediaUsageOutputSchema,
10
+ } from "#/schemas/media";
11
+
12
+ export const mediaContract = {
13
+ requestUpload: oc
14
+ .errors(
15
+ pickPublicErrorMap("MEDIA_BETA_RESTRICTED", "MEDIA_FILE_TOO_LARGE", "MEDIA_QUOTA_EXCEEDED"),
16
+ )
17
+ .route({
18
+ method: "POST",
19
+ path: "/media/uploads",
20
+ summary: "Reserve space and get a signed upload URL for a media file",
21
+ tags: ["Media"],
22
+ })
23
+ .input(mediaRequestUploadInputSchema)
24
+ .output(mediaRequestUploadOutputSchema),
25
+
26
+ completeUpload: oc
27
+ .errors(
28
+ pickPublicErrorMap("MEDIA_BETA_RESTRICTED", "MEDIA_NOT_FOUND", "MEDIA_UPLOAD_NOT_COMPLETED"),
29
+ )
30
+ .route({
31
+ method: "POST",
32
+ path: "/media/{id}/complete",
33
+ summary: "Queue an uploaded media file for validation",
34
+ tags: ["Media"],
35
+ })
36
+ .input(mediaAssetIdInputSchema)
37
+ .output(mediaAssetOutputSchema),
38
+
39
+ get: oc
40
+ .errors(pickPublicErrorMap("MEDIA_BETA_RESTRICTED", "MEDIA_NOT_FOUND"))
41
+ .route({
42
+ method: "GET",
43
+ path: "/media/{id}",
44
+ summary: "Get a media asset and its upload status",
45
+ tags: ["Media"],
46
+ })
47
+ .input(mediaAssetIdInputSchema)
48
+ .output(mediaAssetOutputSchema),
49
+
50
+ usage: oc
51
+ .errors(pickPublicErrorMap("MEDIA_BETA_RESTRICTED"))
52
+ .route({
53
+ method: "GET",
54
+ path: "/media/usage",
55
+ summary: "Get the media space used, reserved and allowed",
56
+ tags: ["Media"],
57
+ })
58
+ .input(z.object({}).strict())
59
+ .output(mediaUsageOutputSchema),
60
+ };
@@ -0,0 +1,17 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { z } from "zod";
3
+ import { reportWebSyncInputSchema } from "#/schemas/web-sync";
4
+
5
+ export const telemetryContract = {
6
+ // Public: a page whose session ended still reports the streams that failed
7
+ // because of it.
8
+ reportWebSync: oc
9
+ .route({
10
+ method: "POST",
11
+ path: "/telemetry/web-sync",
12
+ summary: "Report problems the web app had keeping its synced data live",
13
+ tags: ["Telemetry"],
14
+ })
15
+ .input(reportWebSyncInputSchema)
16
+ .output(z.void()),
17
+ };
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { featureAccessContract } from "./contracts/feature-access";
8
8
  import { hostedAiContract } from "./contracts/hosted-ai";
9
9
  import { masteryGoalContract } from "./contracts/mastery-goal";
10
10
  import { mcpConnectionContract } from "./contracts/mcp-connection";
11
+ import { mediaContract } from "./contracts/media";
11
12
  import { notebaseContract } from "./contracts/notebase";
12
13
  import { notebaseColumnContract } from "./contracts/notebase-column";
13
14
  import { notebaseRowContract } from "./contracts/notebase-row";
@@ -15,6 +16,7 @@ import { pdfTranslationContract } from "./contracts/pdf-translation";
15
16
  import { speechContract } from "./contracts/speech";
16
17
  import { srsContract } from "./contracts/srs";
17
18
  import { statsContract } from "./contracts/stats";
19
+ import { telemetryContract } from "./contracts/telemetry";
18
20
  import { userContract } from "./contracts/user";
19
21
  import { videoTranscriptContract } from "./contracts/video-transcript";
20
22
 
@@ -28,6 +30,7 @@ export const contract = {
28
30
  hostedAi: hostedAiContract,
29
31
  masteryGoal: masteryGoalContract,
30
32
  mcpConnection: mcpConnectionContract,
33
+ media: mediaContract,
31
34
  notebase: notebaseContract,
32
35
  notebaseColumn: notebaseColumnContract,
33
36
  notebaseRow: notebaseRowContract,
@@ -35,6 +38,7 @@ export const contract = {
35
38
  speech: speechContract,
36
39
  srs: srsContract,
37
40
  stats: statsContract,
41
+ telemetry: telemetryContract,
38
42
  user: userContract,
39
43
  videoTranscript: videoTranscriptContract,
40
44
  };
@@ -50,6 +54,7 @@ export * from "./schemas/ebook";
50
54
  export * from "./schemas/email-preferences";
51
55
  export * from "./schemas/feature-access";
52
56
  export * from "./schemas/hosted-ai";
57
+ export * from "./schemas/media";
53
58
  export * from "./schemas/mastery-goal";
54
59
  export * from "./schemas/mcp-connection";
55
60
  export * from "./schemas/notebase";
@@ -65,6 +70,7 @@ export * from "./schemas/stats";
65
70
  export * from "./schemas/timezone";
66
71
  export * from "./schemas/user";
67
72
  export * from "./schemas/video-transcript";
73
+ export * from "./schemas/web-sync";
68
74
 
69
75
  export * from "./schemas/pagination";
70
76
  export * from "./schemas/resource-mutation";
@@ -289,10 +289,6 @@ export const PUBLIC_APP_ERROR_DEFS = {
289
289
  message: "Concurrent position conflict, please retry",
290
290
  status: 409,
291
291
  },
292
- EBOOK_BETA_RESTRICTED: {
293
- message: "Ebooks are currently in beta for selected accounts",
294
- status: 403,
295
- },
296
292
  EBOOK_NOT_FOUND: {
297
293
  message: "Ebook not found",
298
294
  status: 404,
@@ -309,6 +305,26 @@ export const PUBLIC_APP_ERROR_DEFS = {
309
305
  message: "Ebook upload has not been completed",
310
306
  status: 409,
311
307
  },
308
+ MEDIA_BETA_RESTRICTED: {
309
+ message: "Media uploads are currently in beta for selected accounts",
310
+ status: 403,
311
+ },
312
+ MEDIA_NOT_FOUND: {
313
+ message: "Media file not found",
314
+ status: 404,
315
+ },
316
+ MEDIA_FILE_TOO_LARGE: {
317
+ message: "Media file exceeds the maximum allowed size",
318
+ status: 413,
319
+ },
320
+ MEDIA_QUOTA_EXCEEDED: {
321
+ message: "Not enough media storage space left on your plan",
322
+ status: 403,
323
+ },
324
+ MEDIA_UPLOAD_NOT_COMPLETED: {
325
+ message: "Media upload has not been completed",
326
+ status: 409,
327
+ },
312
328
  // An email's unsubscribe or preferences link that does not verify, or whose
313
329
  // account no longer exists. The page asks the reader to sign in instead.
314
330
  EMAIL_LINK_INVALID: {
@@ -37,6 +37,22 @@ export const MCP_DAILY_CALL_LIMITS = {
37
37
  ultra: null,
38
38
  } as const satisfies Record<BillingPlan, number | null>;
39
39
 
40
+ /**
41
+ * How many ebooks each plan may keep at once. Free keeps one, so it reads one
42
+ * book at a time; deleting a book frees its place for the next.
43
+ *
44
+ * Read by the API's upload gate AND by the website's copy (the pricing page),
45
+ * so the pages cannot promise books the server refuses.
46
+ *
47
+ * Companion edit when this changes: `account-and-plans` under
48
+ * `apps/www/content/docs/`, which states the numbers.
49
+ */
50
+ export const EBOOK_LIMIT_BY_PLAN = {
51
+ free: 1,
52
+ pro: 10,
53
+ ultra: 80,
54
+ } as const satisfies Record<BillingPlan, number>;
55
+
40
56
  // Plans a user can actively check out into (the free tier has no price).
41
57
  export const billingCheckoutPlanSchema = z.enum(["pro", "ultra"]);
42
58
  export type BillingCheckoutPlan = z.infer<typeof billingCheckoutPlanSchema>;
@@ -77,6 +77,13 @@ export const ebookRequestUploadInputSchema = z
77
77
  })
78
78
  .strict()
79
79
  .optional(),
80
+ /**
81
+ * The client's key for this upload. Asking again with the same key, after
82
+ * an answer that never arrived or for a retry, returns the same pending
83
+ * book with fresh targets instead of reserving another place in the
84
+ * library's allowance.
85
+ */
86
+ uploadKey: z.uuid().optional(),
80
87
  })
81
88
  .strict();
82
89
  export type EbookRequestUploadInput = z.infer<typeof ebookRequestUploadInputSchema>;
@@ -26,3 +26,20 @@ export const mcpConnectionRevokeInputSchema = z.object({ clientId: z.string().mi
26
26
  export type McpConnectionRevokeInput = z.infer<typeof mcpConnectionRevokeInputSchema>;
27
27
 
28
28
  export const mcpConnectionRevokeOutputSchema = z.object({ clientId: z.string() }).strict();
29
+
30
+ /**
31
+ * How many MCP tool calls the caller has used of their plan's
32
+ * `MCP_DAILY_CALL_LIMITS`, read without counting one. The window is 24 hours
33
+ * from the first call in it, not a calendar day.
34
+ */
35
+ export const mcpCallUsageSchema = z
36
+ .object({
37
+ /** Calls used in the current window, at most `limit`. */
38
+ usedCalls: z.number().int().min(0),
39
+ /** null: the plan has no daily limit. */
40
+ limit: z.number().int().positive().nullable(),
41
+ /** When the current 24-hour window ends; null when no window is open or there is no limit. */
42
+ resetAt: z.iso.datetime().nullable(),
43
+ })
44
+ .strict();
45
+ export type McpCallUsage = z.infer<typeof mcpCallUsageSchema>;
@@ -0,0 +1,77 @@
1
+ import { MEDIA_ASSET_STATUS_VALUES, MEDIA_CONTENT_TYPES } from "@read-frog/definitions";
2
+ import { z } from "zod";
3
+
4
+ export const mediaContentTypeSchema = z.enum(MEDIA_CONTENT_TYPES);
5
+ export type MediaContentType = z.infer<typeof mediaContentTypeSchema>;
6
+
7
+ export const mediaAssetStatusSchema = z.enum(MEDIA_ASSET_STATUS_VALUES);
8
+ export type MediaAssetStatus = z.infer<typeof mediaAssetStatusSchema>;
9
+
10
+ /** Why the worker rejected an upload. */
11
+ export const mediaFailureCodeSchema = z.enum([
12
+ "unsupported_format",
13
+ "invalid_file",
14
+ "file_too_large",
15
+ "image_too_large",
16
+ "audio_too_long",
17
+ "quota_exceeded",
18
+ "processing_failed",
19
+ ]);
20
+ export type MediaFailureCode = z.infer<typeof mediaFailureCodeSchema>;
21
+
22
+ export const mediaAssetMetadataSchema = z.discriminatedUnion("kind", [
23
+ z
24
+ .object({ kind: z.literal("image"), width: z.number().int(), height: z.number().int() })
25
+ .strict(),
26
+ z.object({ kind: z.literal("audio"), durationMs: z.number().int() }).strict(),
27
+ ]);
28
+
29
+ export const mediaAssetSchema = z
30
+ .object({
31
+ id: z.uuid(),
32
+ /** What a cell stores to reference the asset; null until it is ready. */
33
+ uri: z.string().nullable(),
34
+ status: mediaAssetStatusSchema,
35
+ contentType: z.string().nullable(),
36
+ fileByteSize: z.number().int(),
37
+ metadata: mediaAssetMetadataSchema.nullable(),
38
+ failureCode: mediaFailureCodeSchema.nullable(),
39
+ createdAt: z.coerce.date(),
40
+ })
41
+ .strict();
42
+ export type MediaAsset = z.infer<typeof mediaAssetSchema>;
43
+
44
+ export const mediaRequestUploadInputSchema = z
45
+ .object({
46
+ contentType: mediaContentTypeSchema,
47
+ // The exact size: the upload is signed for it, and storage refuses any other file.
48
+ byteSize: z.number().int().min(1),
49
+ })
50
+ .strict();
51
+ export type MediaRequestUploadInput = z.infer<typeof mediaRequestUploadInputSchema>;
52
+
53
+ export const mediaRequestUploadOutputSchema = z
54
+ .object({
55
+ assetId: z.uuid(),
56
+ /** A signed PUT: send the file to `url` with exactly `headers`. */
57
+ upload: z.object({ url: z.url(), headers: z.record(z.string(), z.string()) }).strict(),
58
+ expiresAt: z.coerce.date(),
59
+ })
60
+ .strict();
61
+ export type MediaRequestUploadOutput = z.infer<typeof mediaRequestUploadOutputSchema>;
62
+
63
+ export const mediaAssetIdInputSchema = z.object({ id: z.uuid() }).strict();
64
+ export type MediaAssetIdInput = z.infer<typeof mediaAssetIdInputSchema>;
65
+
66
+ export const mediaAssetOutputSchema = z.object({ asset: mediaAssetSchema }).strict();
67
+ export type MediaAssetOutput = z.infer<typeof mediaAssetOutputSchema>;
68
+
69
+ export const mediaUsageOutputSchema = z
70
+ .object({
71
+ usedBytes: z.number().int().min(0),
72
+ /** Held by uploads that are not done yet. */
73
+ reservedBytes: z.number().int().min(0),
74
+ limitBytes: z.number().int().min(0),
75
+ })
76
+ .strict();
77
+ export type MediaUsageOutput = z.infer<typeof mediaUsageOutputSchema>;
@@ -154,7 +154,9 @@ export type SrsListCardRevlogsOutput = z.infer<typeof srsListCardRevlogsOutputSc
154
154
  export const srsReviewOutputSchema = z.object({
155
155
  card: cardSchema,
156
156
  revlog: srsRevlogSchema,
157
- txid: z.number(),
157
+ // Null for an idempotent replay: it writes nothing, so no shape ever carries
158
+ // its transaction. Wait for the returned card and revlog instead.
159
+ txid: z.number().nullable(),
158
160
  // False when an idempotent replay returned an existing revlog instead of creating one.
159
161
  created: z.boolean(),
160
162
  });
@@ -0,0 +1,121 @@
1
+ import { z } from "zod";
2
+
3
+ // What the web app reports about keeping its Electric data live. Every field is
4
+ // an enum, so each label the API counts them under has a fixed set of values.
5
+
6
+ /** The shapes the web app syncs, by their names in `@repo/electric-contract`. */
7
+ export const webSyncShapeSchema = z.enum([
8
+ "notebases",
9
+ "notebaseColumns",
10
+ "notebaseRows",
11
+ "cardTemplates",
12
+ "cards",
13
+ "revlogs",
14
+ "mediaAssets",
15
+ ]);
16
+ export type WebSyncShape = z.infer<typeof webSyncShapeSchema>;
17
+
18
+ /** The write whose change the page waited to see come back over Electric. */
19
+ export const webSyncOperationSchema = z.enum([
20
+ // The review session's own writes.
21
+ "review",
22
+ // A review the server had already recorded: it waits for that review's card
23
+ // and revlog, as its own transaction wrote nothing.
24
+ "reviewReplay",
25
+ "undo",
26
+ "bury",
27
+ "suspend",
28
+ "deleteNote",
29
+ // A synced collection's own mutation handlers.
30
+ "insert",
31
+ "update",
32
+ "delete",
33
+ // Other writes that wait for their change.
34
+ "setSleep",
35
+ "srsSettings",
36
+ "cardStatus",
37
+ "createTemplate",
38
+ "updateTemplate",
39
+ ]);
40
+ export type WebSyncOperation = z.infer<typeof webSyncOperationSchema>;
41
+
42
+ /** How the shape that lagged was receiving live changes. */
43
+ export const webSyncTransportSchema = z.enum(["sse", "longPoll"]);
44
+ export type WebSyncTransport = z.infer<typeof webSyncTransportSchema>;
45
+
46
+ /**
47
+ * A write wait is only reported once its first wait ran out: `recovered` when
48
+ * reconnecting the lagging shapes brought the change, `failed` when the page
49
+ * gave up on it.
50
+ */
51
+ export const webSyncWriteOutcomeSchema = z.enum(["recovered", "failed"]);
52
+ export type WebSyncWriteOutcome = z.infer<typeof webSyncWriteOutcomeSchema>;
53
+
54
+ export const webSyncStreamErrorTypeSchema = z.enum([
55
+ // 401: the session ended.
56
+ "unauthorized",
57
+ // 403: the notebase was deleted, or is someone else's.
58
+ "forbidden",
59
+ // 400 and any other 4xx.
60
+ "badRequest",
61
+ "clientError",
62
+ // A response without Electric's headers: a proxy or CORS in the way.
63
+ "missingHeaders",
64
+ // Anything that is not an HTTP status: an unreadable body, a parse error.
65
+ "unexpected",
66
+ ]);
67
+ export type WebSyncStreamErrorType = z.infer<typeof webSyncStreamErrorTypeSchema>;
68
+
69
+ /** `initial` before the shape first loaded, when the page shows the error at once. */
70
+ export const webSyncStreamPhaseSchema = z.enum(["initial", "live"]);
71
+ export type WebSyncStreamPhase = z.infer<typeof webSyncStreamPhaseSchema>;
72
+
73
+ export const webSyncPageSchema = z.enum(["reviewCombined", "reviewNotebase"]);
74
+ export type WebSyncPage = z.infer<typeof webSyncPageSchema>;
75
+
76
+ export const webSyncUnavailableReasonSchema = z.enum([
77
+ // A write's change never came back, even after reconnecting.
78
+ "syncTimeout",
79
+ // A shape stream stopped.
80
+ "streamFailed",
81
+ // The notebase is gone: its shapes answer 403.
82
+ "notebaseGone",
83
+ ]);
84
+ export type WebSyncUnavailableReason = z.infer<typeof webSyncUnavailableReasonSchema>;
85
+
86
+ export const webSyncEventSchema = z.discriminatedUnion("type", [
87
+ z
88
+ .object({
89
+ type: z.literal("writeWait"),
90
+ operation: webSyncOperationSchema,
91
+ outcome: webSyncWriteOutcomeSchema,
92
+ transport: webSyncTransportSchema,
93
+ })
94
+ .strict(),
95
+ z
96
+ .object({
97
+ type: z.literal("streamError"),
98
+ shape: webSyncShapeSchema,
99
+ errorType: webSyncStreamErrorTypeSchema,
100
+ phase: webSyncStreamPhaseSchema,
101
+ })
102
+ .strict(),
103
+ z
104
+ .object({
105
+ type: z.literal("unavailable"),
106
+ page: webSyncPageSchema,
107
+ reason: webSyncUnavailableReasonSchema,
108
+ })
109
+ .strict(),
110
+ ]);
111
+ export type WebSyncEvent = z.infer<typeof webSyncEventSchema>;
112
+
113
+ /** A page sends what happened within a second or so as one batch. */
114
+ export const WEB_SYNC_REPORT_MAX_EVENTS = 20;
115
+
116
+ export const reportWebSyncInputSchema = z
117
+ .object({
118
+ events: z.array(webSyncEventSchema).min(1).max(WEB_SYNC_REPORT_MAX_EVENTS),
119
+ })
120
+ .strict();
121
+ export type ReportWebSyncInput = z.infer<typeof reportWebSyncInputSchema>;