@devosurf/tesser-connectors 0.1.0-alpha.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,454 @@
1
+ // Google Calendar connector — second connector behind the shared `google` Provider.
2
+
3
+ import { z } from "zod";
4
+ import { RetryableError, TerminalError } from "@devosurf/tesser-sdk";
5
+ import {
6
+ action,
7
+ defineConnector,
8
+ oauth2,
9
+ trigger,
10
+ type ActionCtx,
11
+ type ClassifyError,
12
+ } from "@devosurf/tesser-sdk/connector";
13
+ import { googleProvider } from "../providers/google.js";
14
+
15
+ // Google overloads 403: rate-limit 403s are retryable, permission 403s are terminal.
16
+ // The default "4xx terminal" mapping double-penalizes rate limits, so promote the two
17
+ // usageLimits reasons (and 429) to retry; everything else keeps the default. Family rule
18
+ // shared across the Google connectors (see the google-platform deep-dive).
19
+ const googleClassifyError: ClassifyError = (err) => {
20
+ if (err.status === 429) return "retry";
21
+ if (err.status === 403 && /rateLimitExceeded|userRateLimitExceeded/.test(err.bodySnippet)) {
22
+ return "retry";
23
+ }
24
+ return undefined;
25
+ };
26
+
27
+ const eventShape = z.object({
28
+ id: z.string(),
29
+ summary: z.string(),
30
+ start: z.string(),
31
+ end: z.string(),
32
+ htmlLink: z.string().optional(),
33
+ });
34
+
35
+ const calendarShape = z.object({
36
+ id: z.string(),
37
+ summary: z.string(),
38
+ primary: z.boolean(),
39
+ });
40
+
41
+ const busySlot = z.object({ start: z.string(), end: z.string() });
42
+ const freeBusyShape = z.object({
43
+ calendars: z.record(z.string(), z.object({ busy: z.array(busySlot) })),
44
+ });
45
+
46
+ type RawEvent = {
47
+ id: string;
48
+ summary?: string;
49
+ htmlLink?: string;
50
+ start?: { dateTime?: string; date?: string };
51
+ end?: { dateTime?: string; date?: string };
52
+ updated?: string;
53
+ status?: string;
54
+ };
55
+
56
+ type RawCalendarListEntry = {
57
+ id: string;
58
+ summary?: string;
59
+ primary?: boolean;
60
+ };
61
+
62
+ function mapEvent(raw: RawEvent): z.infer<typeof eventShape> {
63
+ return {
64
+ id: raw.id,
65
+ summary: raw.summary ?? "",
66
+ start: raw.start?.dateTime ?? raw.start?.date ?? "",
67
+ end: raw.end?.dateTime ?? raw.end?.date ?? "",
68
+ ...(raw.htmlLink !== undefined ? { htmlLink: raw.htmlLink } : {}),
69
+ };
70
+ }
71
+
72
+ function mapCalendar(raw: RawCalendarListEntry): z.infer<typeof calendarShape> {
73
+ return {
74
+ id: raw.id,
75
+ summary: raw.summary ?? "",
76
+ primary: raw.primary ?? false,
77
+ };
78
+ }
79
+
80
+ const eventsPath = (calendarId: string) =>
81
+ `/calendars/${encodeURIComponent(calendarId)}/events`;
82
+ const eventPath = (calendarId: string, eventId: string) =>
83
+ `${eventsPath(calendarId)}/${encodeURIComponent(eventId)}`;
84
+
85
+ // Build the Events resource body shared by create/update.
86
+ function eventBody(i: {
87
+ summary: string;
88
+ startIso: string;
89
+ endIso: string;
90
+ description?: string | undefined;
91
+ attendees?: string[] | undefined;
92
+ }): Record<string, unknown> {
93
+ return {
94
+ summary: i.summary,
95
+ start: { dateTime: i.startIso },
96
+ end: { dateTime: i.endIso },
97
+ ...(i.description !== undefined ? { description: i.description } : {}),
98
+ ...(i.attendees !== undefined ? { attendees: i.attendees.map((email) => ({ email })) } : {}),
99
+ };
100
+ }
101
+
102
+ type EventListPage = {
103
+ items?: RawEvent[];
104
+ nextPageToken?: string;
105
+ nextSyncToken?: string;
106
+ };
107
+
108
+ type EventListResult =
109
+ | { gone: false; items: RawEvent[]; nextSyncToken?: string }
110
+ | { gone: true; items: []; nextSyncToken?: undefined };
111
+
112
+ async function eventListPage(
113
+ ctx: ActionCtx,
114
+ calendarId: string,
115
+ query: Record<string, string | number | boolean | undefined>,
116
+ ): Promise<EventListPage | "gone"> {
117
+ const res = (await ctx.http.get(eventsPath(calendarId), { query, raw: true })) as Response;
118
+ const text = await res.text().catch(() => "");
119
+ if (res.status >= 200 && res.status < 300) {
120
+ return (text.length > 0 ? JSON.parse(text) : {}) as EventListPage;
121
+ }
122
+ if (res.status === 410) return "gone";
123
+
124
+ const bodySnippet = text.slice(0, 600);
125
+ const msg = `provider responded ${res.status} for GET ${eventsPath(calendarId)}${
126
+ bodySnippet ? ` — ${bodySnippet}` : ""
127
+ }`;
128
+ const classified = googleClassifyError({ status: res.status, bodySnippet, url: eventsPath(calendarId) });
129
+ if (classified === "retry" || res.status === 408 || res.status === 425 || res.status >= 500) {
130
+ throw new RetryableError(msg);
131
+ }
132
+ throw new TerminalError(msg);
133
+ }
134
+
135
+ async function listEventPages(
136
+ ctx: ActionCtx,
137
+ calendarId: string,
138
+ baseQuery: Record<string, string | number | boolean | undefined>,
139
+ ): Promise<EventListResult> {
140
+ const items: RawEvent[] = [];
141
+ let pageToken: string | undefined;
142
+ let nextSyncToken: string | undefined;
143
+ for (let page = 0; page < 100; page++) {
144
+ const res = await eventListPage(ctx, calendarId, {
145
+ ...baseQuery,
146
+ ...(pageToken !== undefined ? { pageToken } : {}),
147
+ });
148
+ if (res === "gone") return { gone: true, items: [] };
149
+ items.push(...(res.items ?? []));
150
+ if (res.nextSyncToken !== undefined) nextSyncToken = res.nextSyncToken;
151
+ if (res.nextPageToken === undefined) {
152
+ return {
153
+ gone: false,
154
+ items,
155
+ ...(nextSyncToken !== undefined ? { nextSyncToken } : {}),
156
+ };
157
+ }
158
+ pageToken = res.nextPageToken;
159
+ }
160
+ throw new RetryableError(`calendar events pagination exceeded safety cap for ${eventsPath(calendarId)}`);
161
+ }
162
+
163
+ function eventStartKey(raw: RawEvent): string {
164
+ return raw.start?.dateTime ?? raw.start?.date ?? "";
165
+ }
166
+
167
+ export default defineConnector({
168
+ id: "google-calendar",
169
+ describe: "Google Calendar events",
170
+ provider: googleProvider,
171
+ baseUrl: "https://www.googleapis.com/calendar/v3",
172
+ auth: oauth2({
173
+ provider: "google",
174
+ scopes: [
175
+ "https://www.googleapis.com/auth/calendar.events",
176
+ "https://www.googleapis.com/auth/calendar.readonly",
177
+ ],
178
+ }),
179
+ samples: {
180
+ "events.list": [
181
+ {
182
+ id: "evt1",
183
+ summary: "Standup",
184
+ start: "2026-01-01T09:00:00Z",
185
+ end: "2026-01-01T09:15:00Z",
186
+ },
187
+ ],
188
+ "events.get": {
189
+ id: "evt1",
190
+ summary: "Standup",
191
+ start: "2026-01-01T09:00:00Z",
192
+ end: "2026-01-01T09:15:00Z",
193
+ htmlLink: "https://calendar.google.com/event?eid=evt1",
194
+ },
195
+ "events.create": {
196
+ id: "evt2",
197
+ summary: "Created",
198
+ start: "2026-01-02T09:00:00Z",
199
+ end: "2026-01-02T10:00:00Z",
200
+ htmlLink: "https://calendar.google.com/event?eid=evt2",
201
+ },
202
+ "events.update": {
203
+ id: "evt2",
204
+ summary: "Rescheduled",
205
+ start: "2026-01-02T11:00:00Z",
206
+ end: "2026-01-02T12:00:00Z",
207
+ htmlLink: "https://calendar.google.com/event?eid=evt2",
208
+ },
209
+ "events.delete": { id: "evt2", deleted: true },
210
+ "calendars.list": [
211
+ { id: "primary", summary: "Ada", primary: true },
212
+ { id: "team@example.com", summary: "Team", primary: false },
213
+ ],
214
+ "freebusy.query": {
215
+ calendars: {
216
+ primary: { busy: [{ start: "2026-01-01T09:00:00Z", end: "2026-01-01T09:30:00Z" }] },
217
+ },
218
+ },
219
+ "trigger:eventCreated": {
220
+ id: "evt3",
221
+ summary: "New meeting",
222
+ start: "2026-01-03T09:00:00Z",
223
+ end: "2026-01-03T10:00:00Z",
224
+ htmlLink: "https://calendar.google.com/event?eid=evt3",
225
+ },
226
+ "trigger:eventStarting": {
227
+ id: "evt4",
228
+ summary: "Starting soon",
229
+ start: "2026-01-04T09:00:00Z",
230
+ end: "2026-01-04T10:00:00Z",
231
+ htmlLink: "https://calendar.google.com/event?eid=evt4",
232
+ },
233
+ },
234
+ actions: {
235
+ events: {
236
+ list: action({
237
+ describe: "List upcoming events",
238
+ input: z.object({
239
+ calendarId: z.string().default("primary"),
240
+ timeMin: z.string().optional(),
241
+ maxResults: z.number().int().min(1).max(250).default(10),
242
+ }),
243
+ output: z.array(eventShape),
244
+ safety: "read",
245
+ classifyError: googleClassifyError,
246
+ run: async (ctx, i) => {
247
+ const res = (await ctx.http.get(eventsPath(i.calendarId), {
248
+ query: {
249
+ maxResults: i.maxResults,
250
+ singleEvents: "true",
251
+ orderBy: "startTime",
252
+ ...(i.timeMin !== undefined ? { timeMin: i.timeMin } : {}),
253
+ },
254
+ })) as { items?: RawEvent[] };
255
+ return (res.items ?? []).map(mapEvent);
256
+ },
257
+ }),
258
+ get: action({
259
+ describe: "Fetch one event by id",
260
+ input: z.object({
261
+ calendarId: z.string().default("primary"),
262
+ eventId: z.string().min(1),
263
+ }),
264
+ output: eventShape,
265
+ safety: "read",
266
+ classifyError: googleClassifyError,
267
+ run: async (ctx, i) => {
268
+ const res = (await ctx.http.get(eventPath(i.calendarId, i.eventId))) as RawEvent;
269
+ return mapEvent(res);
270
+ },
271
+ }),
272
+ create: action({
273
+ describe: "Create an event",
274
+ input: z.object({
275
+ calendarId: z.string().default("primary"),
276
+ summary: z.string().min(1),
277
+ startIso: z.string(),
278
+ endIso: z.string(),
279
+ description: z.string().optional(),
280
+ attendees: z.array(z.string()).optional(),
281
+ sendUpdates: z.enum(["all", "externalOnly", "none"]).optional(),
282
+ }),
283
+ output: eventShape,
284
+ classifyError: googleClassifyError,
285
+ run: async (ctx, i) => {
286
+ const res = (await ctx.http.post(eventsPath(i.calendarId), eventBody(i), {
287
+ ...(i.sendUpdates !== undefined ? { query: { sendUpdates: i.sendUpdates } } : {}),
288
+ })) as RawEvent;
289
+ return mapEvent(res);
290
+ },
291
+ }),
292
+ update: action({
293
+ describe: "Replace an event (full PUT)",
294
+ input: z.object({
295
+ calendarId: z.string().default("primary"),
296
+ eventId: z.string().min(1),
297
+ summary: z.string().min(1),
298
+ startIso: z.string(),
299
+ endIso: z.string(),
300
+ description: z.string().optional(),
301
+ attendees: z.array(z.string()).optional(),
302
+ sendUpdates: z.enum(["all", "externalOnly", "none"]).optional(),
303
+ }),
304
+ output: eventShape,
305
+ classifyError: googleClassifyError,
306
+ run: async (ctx, i) => {
307
+ const res = (await ctx.http.put(eventPath(i.calendarId, i.eventId), eventBody(i), {
308
+ ...(i.sendUpdates !== undefined ? { query: { sendUpdates: i.sendUpdates } } : {}),
309
+ })) as RawEvent;
310
+ return mapEvent(res);
311
+ },
312
+ }),
313
+ delete: action({
314
+ describe: "Delete an event; deleting an already-gone event is a no-op success",
315
+ input: z.object({
316
+ calendarId: z.string().default("primary"),
317
+ eventId: z.string().min(1),
318
+ sendUpdates: z.enum(["all", "externalOnly", "none"]).optional(),
319
+ }),
320
+ output: z.object({ id: z.string(), deleted: z.boolean() }),
321
+ // A delete carries no idempotency key, so it stays a write with no auto-retry. We
322
+ // read the status in raw mode instead of letting ctx.http classify, because 404/410
323
+ // mean "already gone" and must resolve to idempotent success, not failure. OBSERVED
324
+ // behavior: the delete reference documents only an empty 2xx body, not a 410 on a
325
+ // repeat delete; 410 is the documented stale-sync signal elsewhere, but on a single
326
+ // event DELETE it means the event is gone. Treat both as a no-op success.
327
+ run: async (ctx, i) => {
328
+ const res = (await ctx.http.delete(eventPath(i.calendarId, i.eventId), {
329
+ raw: true,
330
+ ...(i.sendUpdates !== undefined ? { query: { sendUpdates: i.sendUpdates } } : {}),
331
+ })) as Response;
332
+ const s = res.status;
333
+ if (s === 404 || s === 410 || (s >= 200 && s < 300)) {
334
+ return { id: i.eventId, deleted: true };
335
+ }
336
+ const msg = `provider responded ${s} for DELETE ${eventPath(i.calendarId, i.eventId)}`;
337
+ if (s === 429 || s >= 500) throw new RetryableError(msg);
338
+ throw new TerminalError(msg);
339
+ },
340
+ }),
341
+ },
342
+ calendars: {
343
+ list: action({
344
+ describe: "List calendars on the connected account",
345
+ input: z.object({}),
346
+ output: z.array(calendarShape),
347
+ safety: "read",
348
+ classifyError: googleClassifyError,
349
+ run: async (ctx) => {
350
+ const out: z.infer<typeof calendarShape>[] = [];
351
+ let pageToken: string | undefined;
352
+ // Loop pages inside run() with a sane cap (calendarList max 250/page).
353
+ for (let page = 0; page < 20; page++) {
354
+ const res = (await ctx.http.get("/users/me/calendarList", {
355
+ query: { maxResults: 250, ...(pageToken !== undefined ? { pageToken } : {}) },
356
+ })) as { items?: RawCalendarListEntry[]; nextPageToken?: string };
357
+ for (const c of res.items ?? []) out.push(mapCalendar(c));
358
+ if (!res.nextPageToken) break;
359
+ pageToken = res.nextPageToken;
360
+ }
361
+ return out;
362
+ },
363
+ }),
364
+ },
365
+ freebusy: {
366
+ query: action({
367
+ describe: "Query busy windows for one or more calendars",
368
+ input: z.object({
369
+ timeMin: z.string(),
370
+ timeMax: z.string(),
371
+ calendarIds: z.array(z.string()).min(1).default(["primary"]),
372
+ }),
373
+ output: freeBusyShape,
374
+ safety: "read",
375
+ classifyError: googleClassifyError,
376
+ run: async (ctx, i) => {
377
+ const res = (await ctx.http.post("/freeBusy", {
378
+ timeMin: i.timeMin,
379
+ timeMax: i.timeMax,
380
+ items: i.calendarIds.map((id) => ({ id })),
381
+ })) as {
382
+ calendars?: Record<string, { busy?: Array<{ start: string; end: string }> }>;
383
+ };
384
+ const calendars: Record<string, { busy: Array<{ start: string; end: string }> }> = {};
385
+ for (const [id, cal] of Object.entries(res.calendars ?? {})) {
386
+ calendars[id] = { busy: (cal.busy ?? []).map((b) => ({ start: b.start, end: b.end })) };
387
+ }
388
+ return { calendars };
389
+ },
390
+ }),
391
+ },
392
+ },
393
+ triggers: {
394
+ eventCreated: trigger.poll({
395
+ describe: "Fires for each newly created or changed event (syncToken poll)",
396
+ input: z.object({ calendarId: z.string().default("primary") }),
397
+ output: eventShape,
398
+ interval: { default: "2m", floor: "1m" },
399
+ order: "oldest-first",
400
+ poll: async (ctx, params, cursor) => {
401
+ const syncToken = typeof cursor === "string" && cursor.length > 0 ? cursor : undefined;
402
+ const listed = await listEventPages(ctx, params.calendarId, {
403
+ maxResults: 2500,
404
+ singleEvents: "true",
405
+ ...(syncToken !== undefined ? { syncToken } : {}),
406
+ });
407
+ if (listed.gone) return { items: [], nextCursor: null };
408
+ // First activation establishes the syncToken cursor without backfilling the calendar.
409
+ if (syncToken === undefined) {
410
+ return { items: [], ...(listed.nextSyncToken !== undefined ? { nextCursor: listed.nextSyncToken } : {}) };
411
+ }
412
+ return {
413
+ items: listed.items.filter((event) => event.status !== "cancelled"),
414
+ ...(listed.nextSyncToken !== undefined ? { nextCursor: listed.nextSyncToken } : {}),
415
+ };
416
+ },
417
+ dedupeKey: (item) => {
418
+ const event = item as RawEvent;
419
+ return `${event.id}:${event.updated ?? eventStartKey(event)}`;
420
+ },
421
+ map: (item) => mapEvent(item as RawEvent),
422
+ }),
423
+ eventStarting: trigger.poll({
424
+ describe: "Fires for each event entering the upcoming window (poll)",
425
+ input: z.object({
426
+ calendarId: z.string().default("primary"),
427
+ leadMinutes: z.number().int().min(1).max(1440).default(10),
428
+ }),
429
+ output: eventShape,
430
+ interval: { default: "1m", floor: "1m" },
431
+ order: "oldest-first",
432
+ poll: async (ctx, params) => {
433
+ // This is a window trigger, not a generic upcoming-events list: bound the poll to
434
+ // [now, now+lead] and dedupe by occurrence start so an event fires as it enters.
435
+ const now = Date.now();
436
+ const res = (await ctx.http.get(eventsPath(params.calendarId), {
437
+ query: {
438
+ timeMin: new Date(now).toISOString(),
439
+ timeMax: new Date(now + params.leadMinutes * 60_000).toISOString(),
440
+ maxResults: 50,
441
+ singleEvents: "true",
442
+ orderBy: "startTime",
443
+ },
444
+ })) as { items?: RawEvent[] };
445
+ return res.items ?? [];
446
+ },
447
+ dedupeKey: (item) => {
448
+ const event = item as RawEvent;
449
+ return `${event.id}:${eventStartKey(event)}`;
450
+ },
451
+ map: (item) => mapEvent(item as RawEvent),
452
+ }),
453
+ },
454
+ });
@@ -0,0 +1,162 @@
1
+ // Google Docs connector — sits behind the shared `google` Provider (one OAuth app backs
2
+ // gmail + google-calendar + google-docs). The Docs v1 API is effectively documents.get +
3
+ // documents.create + documents.batchUpdate, where batchUpdate drives a fiddly 0-based
4
+ // UTF-16 index model. We HIDE that model behind high-level actions (appendText via
5
+ // endOfSegmentLocation, replacePlaceholders via replaceAllText) and never expose raw
6
+ // batchUpdate.
7
+ //
8
+ // No triggers: the Docs API has no webhooks, no push, no change feed, and no list
9
+ // endpoint. "When a doc changes / a doc is created in folder X" is a Drive capability and
10
+ // belongs in the Google Drive connector (files.watch / files.list).
11
+
12
+ import { z } from "zod";
13
+ import { action, defineConnector, oauth2, type ActionCtx } from "@devosurf/tesser-sdk/connector";
14
+ import { googleProvider } from "../providers/google.js";
15
+
16
+ // --- Plain-text flattening of a document body ---------------------------------
17
+
18
+ type RawTextRun = { content?: string };
19
+ type RawParagraphElement = { textRun?: RawTextRun };
20
+ type RawParagraph = { elements?: RawParagraphElement[] };
21
+ type RawStructuralElement = { paragraph?: RawParagraph };
22
+ type RawDocument = {
23
+ documentId?: string;
24
+ title?: string;
25
+ body?: { content?: RawStructuralElement[] };
26
+ };
27
+
28
+ // Walk body.content[].paragraph.elements[].textRun.content and concatenate. textRun
29
+ // content already carries its own trailing newline per paragraph, so a straight join
30
+ // reproduces the document's line breaks without extra index math.
31
+ function flattenBody(doc: RawDocument): string {
32
+ let text = "";
33
+ for (const el of doc.body?.content ?? []) {
34
+ for (const pe of el.paragraph?.elements ?? []) {
35
+ text += pe.textRun?.content ?? "";
36
+ }
37
+ }
38
+ return text;
39
+ }
40
+
41
+ function docPath(documentId: string): string {
42
+ return `/documents/${encodeURIComponent(documentId)}`;
43
+ }
44
+
45
+ // --- Output shapes (our stable mapped shapes, never the raw provider blob) -----
46
+
47
+ const documentRead = z.object({
48
+ documentId: z.string(),
49
+ title: z.string(),
50
+ text: z.string(),
51
+ });
52
+
53
+ const documentRef = z.object({ documentId: z.string() });
54
+
55
+ const replaceResult = z.object({
56
+ documentId: z.string(),
57
+ replacedCount: z.number().int(),
58
+ });
59
+
60
+ type BatchUpdateReply = { replaceAllText?: { occurrencesChanged?: number } };
61
+ type BatchUpdateResponse = { documentId?: string; replies?: BatchUpdateReply[] };
62
+
63
+ async function batchUpdate(
64
+ ctx: ActionCtx,
65
+ documentId: string,
66
+ requests: unknown[],
67
+ ): Promise<BatchUpdateResponse> {
68
+ return (await ctx.http.post(`${docPath(documentId)}:batchUpdate`, { requests })) as BatchUpdateResponse;
69
+ }
70
+
71
+ export default defineConnector({
72
+ id: "google-docs",
73
+ describe: "Google Docs — read, create, and template-fill documents",
74
+ provider: googleProvider,
75
+ baseUrl: "https://docs.googleapis.com/v1",
76
+ auth: oauth2({
77
+ provider: "google",
78
+ scopes: [
79
+ // documents = RW on Docs content (get + create + batchUpdate).
80
+ "https://www.googleapis.com/auth/documents",
81
+ // drive.file = open/create app docs and place them in a folder without the
82
+ // restricted drive scopes.
83
+ "https://www.googleapis.com/auth/drive.file",
84
+ ],
85
+ }),
86
+ // No idempotencyHeader: the Docs API has no idempotency key, so writes stay
87
+ // non-retrying (a retried create makes a 2nd doc; a retried append duplicates text).
88
+ samples: {
89
+ "documents.get": {
90
+ documentId: "1aBcD",
91
+ title: "Quarterly report",
92
+ text: "Heading\nFirst line.\nSecond line.\n",
93
+ },
94
+ "documents.create": { documentId: "1aBcD", title: "New doc" },
95
+ "documents.appendText": { documentId: "1aBcD" },
96
+ "documents.replacePlaceholders": { documentId: "1aBcD", replacedCount: 3 },
97
+ },
98
+ actions: {
99
+ documents: {
100
+ get: action({
101
+ describe: "Read a document's title and full plain text",
102
+ input: z.object({ documentId: z.string().min(1) }),
103
+ output: documentRead,
104
+ safety: "read",
105
+ run: async (ctx, i) => {
106
+ const doc = (await ctx.http.get(docPath(i.documentId))) as RawDocument;
107
+ return {
108
+ documentId: doc.documentId ?? i.documentId,
109
+ title: doc.title ?? "",
110
+ text: flattenBody(doc),
111
+ };
112
+ },
113
+ }),
114
+ create: action({
115
+ describe: "Create a new blank document with the given title",
116
+ input: z.object({ title: z.string().min(1) }),
117
+ output: documentRef.extend({ title: z.string() }),
118
+ // Non-idempotent write: no provider idempotency key, so no retrySafe override.
119
+ run: async (ctx, i) => {
120
+ const doc = (await ctx.http.post("/documents", { title: i.title })) as RawDocument;
121
+ return { documentId: doc.documentId ?? "", title: doc.title ?? i.title };
122
+ },
123
+ }),
124
+ appendText: action({
125
+ describe: "Append text to the end of a document's body (no index math)",
126
+ input: z.object({ documentId: z.string().min(1), text: z.string().min(1) }),
127
+ output: documentRef,
128
+ // endOfSegmentLocation appends at the end of the body, so we never compute a
129
+ // 0-based UTF-16 index. Non-idempotent: a retry duplicates the appended text.
130
+ run: async (ctx, i) => {
131
+ const res = await batchUpdate(ctx, i.documentId, [
132
+ { insertText: { endOfSegmentLocation: {}, text: i.text } },
133
+ ]);
134
+ return { documentId: res.documentId ?? i.documentId };
135
+ },
136
+ }),
137
+ replacePlaceholders: action({
138
+ describe: "Replace placeholder tokens throughout a document (one atomic batch)",
139
+ input: z.object({
140
+ documentId: z.string().min(1),
141
+ // e.g. { "{{name}}": "Ada", "{{date}}": "2026-06-13" }
142
+ replacements: z.record(z.string(), z.string()),
143
+ }),
144
+ output: replaceResult,
145
+ // replaceAllText is index-free and re-runnable (an already-replaced token is a
146
+ // harmless no-op), making this the most retry-friendly write here. Still a
147
+ // "write" with no retrySafe override: we do not auto-retry writes.
148
+ run: async (ctx, i) => {
149
+ const requests = Object.entries(i.replacements).map(([key, value]) => ({
150
+ replaceAllText: { containsText: { text: key, matchCase: true }, replaceText: value },
151
+ }));
152
+ const res = await batchUpdate(ctx, i.documentId, requests);
153
+ const replacedCount = (res.replies ?? []).reduce(
154
+ (sum, r) => sum + (r.replaceAllText?.occurrencesChanged ?? 0),
155
+ 0,
156
+ );
157
+ return { documentId: res.documentId ?? i.documentId, replacedCount };
158
+ },
159
+ }),
160
+ },
161
+ },
162
+ });