diff --git a/src/calendar/README.md b/src/calendar/README.md index abff616c3..39a09569b 100644 --- a/src/calendar/README.md +++ b/src/calendar/README.md @@ -327,11 +327,11 @@ the user's own key-value store. It never writes an event: every decision about the calendar is made in the app, where the contacts, the settings and the recurrence engine are. -The subscription targets the worker only. A persistent subscription prefers a -connected browser tab, but only if that tab registered the handler in the call -that created the subscription — which a later page load has not — so adding the -socket target would stall each delivery behind two lease periods whenever the -calendar happened to be open. +The subscription is a queue (`queue: true`), so each message is handled once +and retried if the run never finishes or fails for a temporary reason; the +handler throws with `retry: true` for those, and swallows anything else. The +page never registers the handler in-page, so an open calendar tab declines each +delivery and the events worker runs it right away. **Only people you have already dealt with.** An invitation is added automatically when the sender is *authenticated* — delivered inside Puter, or diff --git a/src/calendar/src/js/mailbox.js b/src/calendar/src/js/mailbox.js index 49204a05e..e9e61e318 100644 --- a/src/calendar/src/js/mailbox.js +++ b/src/calendar/src/js/mailbox.js @@ -3,7 +3,7 @@ * * - the capability probe + batched permission request, * - subscribing (or adopting an existing subscription) to - * `fs:~/.mail/objects/**:add` with `targets:['worker']`, + * `fs:~/.mail/objects/**:add` as a `queue` subscription, * - `cal:mail:cfg` (mode/subId/watermark/…), * - the watermark rescan (via `puter.email.list`), which runs the SAME * handler source in-page (via `new Function`) so a foreground session or @@ -177,8 +177,7 @@ export function createMailbox({ if (existing) return { subId: existing.id || existing.subId || '' }; const sub = await p.events.onPersistent({ subject: MAIL_SUBJECT, - delivery: 'single', - targets: ['worker'], + queue: true, handlerName: HANDLER_NAME, context: { v: 1, self: selfEmail(), user: username() }, }); @@ -250,8 +249,6 @@ export function createMailbox({ } catch { return enterForeground('Background permission was declined.'); } - } else if (code === 'invalid_targets') { - return enterForeground('No app identity is available here.'); } else { lastError = errorMessage(err) || 'Could not request permission.'; state = 'off'; @@ -260,6 +257,10 @@ export function createMailbox({ } } + // Without an app there is no events worker to run the handler, and the + // server accepts the subscription anyway, so check here. + if (!getPuter().appID) return enterForeground('No app identity is available here.'); + try { return await subscribeAndScan(); } catch (err) { @@ -279,7 +280,6 @@ export function createMailbox({ return status(); } } - if (code === 'invalid_targets') return enterForeground('No app identity is available here.'); // The consent check happens at subscribe, so this surfaces here rather // than out of perms.request. if (code === 'events_background_consent_required') { diff --git a/src/calendar/src/workers/mail-handler.js b/src/calendar/src/workers/mail-handler.js index 448df6b84..dc2b6ed2c 100644 --- a/src/calendar/src/workers/mail-handler.js +++ b/src/calendar/src/workers/mail-handler.js @@ -564,7 +564,10 @@ async function calendarMailHandler({ event, ctx, user, fetch, ack }) { if (n % 20 === 0) await trimRecords(); } catch (err) { if (recordWritten) return; // the record landed — ack, don't retry - if (isTransientError(err)) throw err; // let the lease retry + // Only an error marked temporary is retried; any other throw drops the event. + if (isTransientError(err)) { + throw Object.assign(new Error((err && err.message) || String(err)), { retry: true, cause: err }); + } // Never terminal: anything else the handler can't use is ignored. try { await markSeen(); diff --git a/src/calendar/tests/mail-handler.test.js b/src/calendar/tests/mail-handler.test.js index 30f60b5ba..43257a847 100644 --- a/src/calendar/tests/mail-handler.test.js +++ b/src/calendar/tests/mail-handler.test.js @@ -307,11 +307,11 @@ describe('mail-handler: non-calendar mail and daily/scan gaps', () => { expect([...kv.map.keys()].some((k) => k.startsWith('cal:mail:seen:'))).toBe(true); }); - it('a transient `get` failure (5xx) is rethrown so the lease retries, and nothing is marked seen', async () => { + it('a transient `get` failure (5xx) is rethrown with retry: true, and nothing is marked seen', async () => { const kv = makeKv(); const id = makeId(); const user = makeUser({ kv, getErr: Object.assign(new Error('server exploded'), { code: 500 }) }); - await expect(run(loadHandler(), user, addEvent(id))).rejects.toThrow(); + await expect(run(loadHandler(), user, addEvent(id))).rejects.toMatchObject({ retry: true, message: 'server exploded' }); expect([...kv.map.keys()].some((k) => k.startsWith('cal:mail:seen:'))).toBe(false); }); diff --git a/src/calendar/tests/mailbox.test.js b/src/calendar/tests/mailbox.test.js index c6290d592..cf98fb1c8 100644 --- a/src/calendar/tests/mailbox.test.js +++ b/src/calendar/tests/mailbox.test.js @@ -95,7 +95,7 @@ describe('mailbox: available()', () => { }); describe('mailbox: connect() happy path', () => { - it('sends one batched perms request, then onPersistent with targets:[worker]', async () => { + it('sends one batched perms request, then a queue onPersistent', async () => { const { mailbox, kv, puter } = makeMailbox(); const status = await mailbox.connect(); @@ -107,10 +107,11 @@ describe('mailbox: connect() happy path', () => { expect(puter.events.onPersistent).toHaveBeenCalledTimes(1); expect(puter.events.onPersistent.mock.calls[0][0]).toMatchObject({ subject: MAIL_SUBJECT, - delivery: 'single', - targets: ['worker'], + queue: true, handlerName: HANDLER_NAME, }); + expect(puter.events.onPersistent.mock.calls[0][0]).not.toHaveProperty('targets'); + expect(puter.events.onPersistent.mock.calls[0][0]).not.toHaveProperty('delivery'); expect(status.mode).toBe('background'); expect(status.state).toBe('ok'); expect(status.subId).toBe('sub-1'); @@ -158,13 +159,14 @@ describe('mailbox: connect() error mapping', () => { expect(status.lastError).toMatch(/full Puter account/i); }); - it('invalid_targets (no app identity) -> foreground mode', async () => { - const err = Object.assign(new Error('no'), { code: 'invalid_targets' }); - const puter = makePuter({ events: { onPersistent: vi.fn(async () => { throw err; }) } }); + it('no app identity -> foreground mode, without subscribing', async () => { + const puter = makePuter(); + delete puter.appID; const { mailbox } = makeMailbox({ puter }); const status = await mailbox.connect(); expect(status.mode).toBe('foreground'); expect(status.state).toBe('foreground'); + expect(puter.events.onPersistent).not.toHaveBeenCalled(); }); // puter.perms.request RESOLVES falsy on denial — it does not throw. Modelling