Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,595 changes: 1,595 additions & 0 deletions apps/convex/__tests__/googleSyncLoop.test.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type * as functions_lib_nameClaims from "../functions/lib/nameClaims.js";
import type * as functions_lib_names from "../functions/lib/names.js";
import type * as functions_lib_noteCount from "../functions/lib/noteCount.js";
import type * as functions_lib_noteLinks from "../functions/lib/noteLinks.js";
import type * as functions_lib_googleSchedule from "../functions/lib/googleSchedule.js";
import type * as functions_lib_privacy from "../functions/lib/privacy.js";
import type * as functions_lib_rateLimit from "../functions/lib/rateLimit.js";
import type * as functions_lib_scaffold from "../functions/lib/scaffold.js";
Expand All @@ -68,6 +69,7 @@ import type * as functions_lib_verification from "../functions/lib/verification.
import type * as functions_lib_workspaceAuth from "../functions/lib/workspaceAuth.js";
import type * as functions_calendarConnect from "../functions/calendarConnect.js";
import type * as functions_googleConnect from "../functions/googleConnect.js";
import type * as functions_googleSync from "../functions/googleSync.js";
import type * as functions_meetings_transcribe from "../functions/meetings/transcribe.js";
import type * as functions_names from "../functions/names.js";
import type * as functions_provisioning from "../functions/provisioning.js";
Expand Down Expand Up @@ -136,6 +138,7 @@ declare const fullApi: ApiFromModules<{
"functions/lib/names": typeof functions_lib_names;
"functions/lib/noteCount": typeof functions_lib_noteCount;
"functions/lib/noteLinks": typeof functions_lib_noteLinks;
"functions/lib/googleSchedule": typeof functions_lib_googleSchedule;
"functions/lib/privacy": typeof functions_lib_privacy;
"functions/lib/rateLimit": typeof functions_lib_rateLimit;
"functions/lib/scaffold": typeof functions_lib_scaffold;
Expand All @@ -145,6 +148,7 @@ declare const fullApi: ApiFromModules<{
"functions/lib/workspaceAuth": typeof functions_lib_workspaceAuth;
"functions/calendarConnect": typeof functions_calendarConnect;
"functions/googleConnect": typeof functions_googleConnect;
"functions/googleSync": typeof functions_googleSync;
"functions/meetings/transcribe": typeof functions_meetings_transcribe;
"functions/names": typeof functions_names;
"functions/provisioning": typeof functions_provisioning;
Expand Down
65 changes: 61 additions & 4 deletions apps/convex/crons.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
/**
* Scheduled maintenance.
* Scheduled maintenance, and — since 2026-09-10 — one scheduled engine.
*
* Nothing here may hold a decision. A cron is the wrong place for anything a
* person would want to see refused in the moment, so this file is limited to
* jobs whose only effect is that the database stops accumulating things nobody
* reads.
* person would want to see refused in the moment, and that rule is unchanged.
*
* What has changed is the second half of the original sentence. This file was
* written for jobs whose only effect is that the database stops accumulating
* things nobody reads, and it later gained a job that *restarts* work over a
* disposable derivative (`restart stalled search backfills`) — still a repair,
* still nothing a customer's bucket would notice. `sync due Google accounts`
* is a third kind and it should be named rather than filed quietly beside the
* sweeps: it calls a third party on a customer's quota and writes canonical
* Markdown into their bucket, on a clock, with nobody present.
*
* That is admitted here rather than argued away, because the honest limit on
* this file is not "only deletions" — it is that **every job here must hold no
* decision, and a job that acts outside this database must additionally
* re-ask, at the moment it acts, everything that could have changed since it
* was scheduled**. The sync job's own comment below makes that case in full,
* and `googleForwardSyncJob` is where the re-asking lives. A fourth job of
* this kind owes the same two paragraphs.
*/

import { cronJobs } from "convex/server";
Expand Down Expand Up @@ -146,4 +161,46 @@ crons.interval(
{},
);

/**
* Poll every connected Google account that is due.
*
* **The second job here that starts work rather than deleting it**, and it owes
* the same argument the search sweep above makes. It holds no decision:
* whether a connection may sync at all — is it still connected, is its context
* personal, does it enable a product this engine can advance, does this
* deployment allow reading a restricted scope — is the connection's own state,
* re-asked by `googleForwardSyncJob` inside the pass, before a credential is
* opened. What this decides is only *when to look*.
*
* One thing is genuinely this job's alone and is not re-asked inside the pass:
* **due-ness**. The sweep decides a connection is due and the pass then syncs
* without asking whether it should have waited. That is deliberate — the pass
* runs minutes later and re-deciding due-ness against a clock that has moved
* would make a claimed pass refuse itself — but it is worth stating plainly
* rather than letting "every gate is re-asked" imply more than is true.
*
* It exists because nothing else ever looked. Connecting a mailbox recorded a
* grant and a `historyId` and then nothing advanced it: no cron, no webhook —
* Google's push path is deliberately not built (`docs/decisions/communications.md`)
* — and the gateway's `scheduled()` handler has no trigger configured. A
* person connected Gmail and their mail never arrived.
*
* **Five minutes because that is the floor**, not because every account is
* polled that often. `syncIntervalMinutes` is per connection and defaults to
* fifteen; the sweep starts a pass only where `now >= lastSyncAt + interval`,
* which is how one fixed tick serves many different frequencies. Below five
* the tick would be finer than the shortest interval anybody may choose, and
* every extra tick is a transaction that reads an index to find nothing.
*
* And, as above: each run only touches connections nothing has written to in
* fifteen minutes, so a pass that is still running is never overtaken by a
* second one.
*/
crons.interval(
"sync due Google accounts",
{ minutes: 5 },
internal.functions.googleSync.sweepDueGoogleSyncs,
{},
);

export default crons;
Loading
Loading