@read-frog/api-contract 0.24.0 → 0.26.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.26.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.14.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",
@@ -2,6 +2,8 @@ import { defineAgentProcedure } from "./shared";
2
2
  import { oc } from "@orpc/contract";
3
3
  import { pickPublicErrorMap } from "#/public-errors";
4
4
  import {
5
+ CardDeleteInputSchema,
6
+ CardDeleteOutputSchema,
5
7
  CardGenerateInputSchema,
6
8
  CardGenerateOutputSchema,
7
9
  CardGetInputSchema,
@@ -11,6 +13,20 @@ import {
11
13
  } from "#/schemas/card";
12
14
 
13
15
  export const cardContract = {
16
+ delete: oc
17
+ .errors(
18
+ pickPublicErrorMap(
19
+ "CARD_NOT_FOUND",
20
+ "NOTEBASE_NOT_FOUND",
21
+ "CARD_DELETE_UNSUPPORTED",
22
+ "RESOURCE_UPDATE_CONFLICT",
23
+ "CELL_VALIDATION_FAILED",
24
+ ),
25
+ )
26
+ .route({ method: "DELETE", path: "/cards/{id}", summary: "Delete card", tags: ["Cards"] })
27
+ .input(CardDeleteInputSchema)
28
+ .output(CardDeleteOutputSchema),
29
+
14
30
  list: defineAgentProcedure({
15
31
  method: "GET",
16
32
  path: "/cards",
@@ -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
+ };
@@ -92,7 +92,7 @@ export const notebaseRowContract = {
92
92
  path: "/notebase-rows/{notebaseRowId}",
93
93
  summary: "Update note",
94
94
  description:
95
- "Merge supplied fields into an owned note, preserving omitted fields. Use null to clear a field. expectedUpdatedAt guards against editing stale data; reread on a modification conflict.",
95
+ "Merge supplied fields into an owned note, preserving omitted fields. Use null to clear a field. expectedUpdatedAt guards against editing stale data; reread on a modification conflict. In a cloze field, a deletion's number ({{c1::…}}) is its card's identity: keep the numbers of the deletions you keep, and leave any gaps for the save to close. Renumbering them yourself hands a card's review history to other text, and can delete it.",
96
96
  tags: ["Notebase Rows"],
97
97
  })
98
98
  .errors(pickPublicErrorMap("NOTEBASE_ROW_NOT_FOUND", "CELL_VALIDATION_FAILED"))
@@ -105,7 +105,7 @@ export const notebaseRowContract = {
105
105
  path: "/notebase-rows/batch",
106
106
  summary: "Update notes",
107
107
  description:
108
- "Update 1–50 notes atomically in one owned notebase. Each row supplies its ID and fields to merge; omitted fields are preserved and null clears a field. Supply each note's expectedUpdatedAt to guard against stale edits. A missing row, invalid field or modification conflict rolls back the whole batch. Items are returned in input order.",
108
+ "Update 1–50 notes atomically in one owned notebase. Each row supplies its ID and fields to merge; omitted fields are preserved and null clears a field. Supply each note's expectedUpdatedAt to guard against stale edits. A missing row, invalid field or modification conflict rolls back the whole batch. Items are returned in input order. In a cloze field, a deletion's number ({{c1::…}}) is its card's identity: keep the numbers of the deletions you keep, and leave any gaps for the save to close. Renumbering them yourself hands a card's review history to other text, and can delete it.",
109
109
  tags: ["Notebase Rows"],
110
110
  })
111
111
  .errors(
@@ -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";
@@ -21,6 +21,24 @@ export const CellValidationFailureReasonSchema = z.enum([
21
21
  "type_mismatch",
22
22
  "invalid_select_option",
23
23
  "invalid_date_format",
24
+ // A cloze deletion with a blank answer; receivedValue is the deletion as
25
+ // written (`{{c1::}}`).
26
+ "empty_cloze_answer",
27
+ // A cloze opening that never closes (unpaired braces, or no `}}`);
28
+ // receivedValue is the opening as written (`{{c1::`).
29
+ "unclosed_cloze",
30
+ // A cloze deletion whose ordinal list names no group (`{{c0::a}}`);
31
+ // receivedValue is the deletion as written.
32
+ "invalid_cloze_ordinal",
33
+ // A cloze deletion that cuts across a formula's delimiters (`\(a{{c1::b\) c}}`);
34
+ // receivedValue is the deletion as written.
35
+ "cloze_crosses_math",
36
+ // A cloze deletion in a formula that holds half of a `\left`/`\right` or
37
+ // `\begin`/`\end` pair; receivedValue is the deletion as written.
38
+ "cloze_splits_math_pair",
39
+ // More cloze groups in one note than CLOZE_MAX_GROUPS; receivedValue is
40
+ // the note's group count.
41
+ "too_many_cloze_groups",
24
42
  ]);
25
43
  export type CellValidationFailureReason = z.infer<typeof CellValidationFailureReasonSchema>;
26
44
 
@@ -42,6 +60,8 @@ export const CellValidationFailedDataSchema = z
42
60
  reason: CellValidationFailureReasonSchema,
43
61
  /** Index of the failing row within a batch input (createMany / updateMany / initialRows). */
44
62
  rowIndex: z.number().int().min(0).optional(),
63
+ /** The failing note, when the check ran over stored notes (a column type change). */
64
+ notebaseRowId: z.string().optional(),
45
65
  })
46
66
  .strict();
47
67
  export type CellValidationFailedData = z.infer<typeof CellValidationFailedDataSchema>;
@@ -103,6 +123,10 @@ export const SpeechDailyLimitReachedDataSchema = z.strictObject({
103
123
  });
104
124
 
105
125
  export const PUBLIC_APP_ERROR_DEFS = {
126
+ CARD_DELETE_UNSUPPORTED: {
127
+ message: "This card cannot be deleted independently of its note.",
128
+ status: 400,
129
+ },
106
130
  RESOURCE_UPDATE_CONFLICT: {
107
131
  message: "The resource changed. Read it again before updating.",
108
132
  status: 409,
@@ -289,10 +313,6 @@ export const PUBLIC_APP_ERROR_DEFS = {
289
313
  message: "Concurrent position conflict, please retry",
290
314
  status: 409,
291
315
  },
292
- EBOOK_BETA_RESTRICTED: {
293
- message: "Ebooks are currently in beta for selected accounts",
294
- status: 403,
295
- },
296
316
  EBOOK_NOT_FOUND: {
297
317
  message: "Ebook not found",
298
318
  status: 404,
@@ -309,6 +329,26 @@ export const PUBLIC_APP_ERROR_DEFS = {
309
329
  message: "Ebook upload has not been completed",
310
330
  status: 409,
311
331
  },
332
+ MEDIA_BETA_RESTRICTED: {
333
+ message: "Media uploads are currently in beta for selected accounts",
334
+ status: 403,
335
+ },
336
+ MEDIA_NOT_FOUND: {
337
+ message: "Media file not found",
338
+ status: 404,
339
+ },
340
+ MEDIA_FILE_TOO_LARGE: {
341
+ message: "Media file exceeds the maximum allowed size",
342
+ status: 413,
343
+ },
344
+ MEDIA_QUOTA_EXCEEDED: {
345
+ message: "Not enough media storage space left on your plan",
346
+ status: 403,
347
+ },
348
+ MEDIA_UPLOAD_NOT_COMPLETED: {
349
+ message: "Media upload has not been completed",
350
+ status: 409,
351
+ },
312
352
  // An email's unsubscribe or preferences link that does not verify, or whose
313
353
  // account no longer exists. The page asks the reader to sign in instead.
314
354
  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>;
@@ -114,6 +114,16 @@ export type CardGetInput = z.infer<typeof CardGetInputSchema>;
114
114
  export const CardGetOutputSchema = renderedCardSchema;
115
115
  export type CardGetOutput = z.infer<typeof CardGetOutputSchema>;
116
116
 
117
+ export const CardDeleteInputSchema = z.strictObject({
118
+ id: z.uuid(),
119
+ expectedVariantKey: z.string().min(1),
120
+ expectedRowUpdatedAt: expectedUpdatedAtSchema,
121
+ });
122
+ export type CardDeleteInput = z.infer<typeof CardDeleteInputSchema>;
123
+
124
+ export const CardDeleteOutputSchema = z.object({ txid: z.number() });
125
+ export type CardDeleteOutput = z.infer<typeof CardDeleteOutputSchema>;
126
+
117
127
  export const CardGenerateInputSchema = z.object({
118
128
  notebaseId: z.uuid(),
119
129
  templateId: z.uuid().optional(),
@@ -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>;
@@ -1,5 +1,10 @@
1
1
  import { paginationShape, pageSchema } from "./pagination";
2
2
  import { deletionResultSchema, expectedUpdatedAtSchema } from "./resource-mutation";
3
+ import {
4
+ noteCreatedViaSchema,
5
+ noteOriginInputSchema,
6
+ noteOriginSchema,
7
+ } from "@read-frog/definitions";
3
8
  import { z } from "zod";
4
9
 
5
10
  export const notebaseRowCellsSchema = z.record(z.string(), z.unknown());
@@ -9,15 +14,38 @@ export const notebaseRowSchema = z.object({
9
14
  notebaseId: z.string(),
10
15
  cells: notebaseRowCellsSchema,
11
16
  position: z.number(),
17
+ origin: noteOriginSchema
18
+ .nullish()
19
+ .describe(
20
+ "Where the note's content came from, set when it was created: a web page (url, title), a book on the user's shelf (ebookId, its title then, and cfi, the EPUB location of the selection), or a video (url, title, seconds into it). Null when it has none, or when it was created before this was recorded.",
21
+ ),
22
+ createdVia: noteCreatedViaSchema
23
+ .nullish()
24
+ .describe(
25
+ 'Which client created the note: "web", "extension", "mobile", "mcp" (with the MCP client\'s id and the name it registered under) or "unknown". Null for notes created before this was recorded.',
26
+ ),
12
27
  createdAt: z.coerce.date(),
13
28
  updatedAt: z.coerce.date(),
14
29
  });
15
30
  export type NotebaseRow = z.infer<typeof notebaseRowSchema>;
16
31
 
32
+ /**
33
+ * Where a new note's content came from; see `noteOriginInputSchema`. Optional
34
+ * metadata never costs the note: an origin that does not parse is dropped and
35
+ * the note is saved without one.
36
+ */
37
+ export const notebaseRowOriginInputSchema = noteOriginInputSchema
38
+ .optional()
39
+ .catch(undefined)
40
+ .describe(
41
+ "Where the note's content came from: a web page or video by its http(s) URL, or a book on the user's shelf by its id. Stored once and never edited. Optional: an origin that cannot be used is left out and the note is still saved.",
42
+ );
43
+
17
44
  export const notebaseRowCreateDataSchema = z
18
45
  .object({
19
46
  id: z.uuid().optional(),
20
47
  cells: notebaseRowCellsSchema.optional(),
48
+ origin: notebaseRowOriginInputSchema,
21
49
  })
22
50
  .strict();
23
51
  export type NotebaseRowCreateData = z.infer<typeof notebaseRowCreateDataSchema>;
@@ -88,6 +116,8 @@ export const NotebaseRowUpdateOutputSchema = z.object({
88
116
  notebaseId: z.uuid(),
89
117
  cells: notebaseRowCellsSchema,
90
118
  position: z.number().int(),
119
+ origin: notebaseRowSchema.shape.origin,
120
+ createdVia: notebaseRowSchema.shape.createdVia,
91
121
  createdAt: z.date(),
92
122
  updatedAt: z.date(),
93
123
  txid: z.number(),
@@ -8,7 +8,11 @@ import {
8
8
  } from "@read-frog/definitions";
9
9
  import { z } from "zod";
10
10
  import { notebaseColumnSchema } from "./notebase-column";
11
- import { NOTEBASE_ROW_CREATE_MANY_MAX_ROWS, notebaseRowSchema } from "./notebase-row";
11
+ import {
12
+ NOTEBASE_ROW_CREATE_MANY_MAX_ROWS,
13
+ notebaseRowOriginInputSchema,
14
+ notebaseRowSchema,
15
+ } from "./notebase-row";
12
16
  import { notebaseViewSchema } from "./notebase-view";
13
17
 
14
18
  export const NotebaseListInputSchema = z.object({});
@@ -55,6 +59,7 @@ const notebaseInitialRowSchema = z
55
59
  .object({
56
60
  id: z.uuid().optional(),
57
61
  cells: z.record(z.string(), z.unknown()),
62
+ origin: notebaseRowOriginInputSchema,
58
63
  })
59
64
  .strict();
60
65
 
@@ -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
  });