@homie-rocks/studio 0.54.0 → 0.55.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/lib/background-queue.mjs +31 -0
  3. package/lib/build.mjs +4 -1
  4. package/lib/cloudflare.mjs +16 -1
  5. package/lib/read-replication.mjs +26 -0
  6. package/lib/scaffold.mjs +28 -10
  7. package/lib/shop.mjs +7 -0
  8. package/lib/static-assets.mjs +27 -0
  9. package/lib/template-history.json +42 -0
  10. package/lib/worker-build.mjs +13 -4
  11. package/lib/worker-config.mjs +3 -1
  12. package/package.json +1 -1
  13. package/worker/analytics.mjs +84 -0
  14. package/worker/app-frame.mjs +1 -1
  15. package/worker/app-graphics.mjs +1 -1
  16. package/worker/app-streams.mjs +3 -3
  17. package/worker/asset-paths.mjs +2 -0
  18. package/worker/background-queue.mjs +30 -0
  19. package/worker/build-id.mjs +1 -1
  20. package/worker/built-metadata.mjs +2 -0
  21. package/worker/catalogue.mjs +18 -0
  22. package/worker/chat-page.mjs +1 -1
  23. package/worker/embed.mjs +1 -1
  24. package/worker/event-resources.mjs +23 -11
  25. package/worker/function-registry.mjs +4 -0
  26. package/worker/functions.mjs +7 -5
  27. package/worker/gate-routing.mjs +61 -0
  28. package/worker/gate.mjs +12 -68
  29. package/worker/hosted-registry.mjs +11 -0
  30. package/worker/hosted.mjs +3 -17
  31. package/worker/index.mjs +54 -2785
  32. package/worker/lazy-objects.mjs +40 -0
  33. package/worker/lobby-limits.mjs +2 -0
  34. package/worker/lobby.mjs +251 -0
  35. package/worker/lounge.mjs +3 -3
  36. package/worker/mcp-event-streams.mjs +3 -3
  37. package/worker/mcp-events.mjs +4 -4
  38. package/worker/mcp-store.mjs +12 -1
  39. package/worker/mcp.mjs +5 -1
  40. package/worker/office.mjs +16 -20
  41. package/worker/operational-schema.mjs +14 -0
  42. package/worker/page-policy.mjs +29 -0
  43. package/worker/pages.mjs +8 -28
  44. package/worker/parts.mjs +2 -1
  45. package/worker/payment-recovery.mjs +33 -19
  46. package/worker/player-registry.mjs +9 -0
  47. package/worker/players.mjs +8 -7
  48. package/worker/purchase-machine.mjs +5 -3
  49. package/worker/reconcile-guard.mjs +22 -0
  50. package/worker/request-db.mjs +35 -10
  51. package/worker/responses.mjs +18 -0
  52. package/worker/room-chat.mjs +216 -0
  53. package/worker/room-controls.mjs +296 -0
  54. package/worker/room-lifecycle.mjs +10 -0
  55. package/worker/room-protocol.mjs +197 -0
  56. package/worker/room.mjs +38 -731
  57. package/worker/rooms-cache.mjs +19 -0
  58. package/worker/routes.mjs +667 -0
  59. package/worker/runtime-support.mjs +445 -0
  60. package/worker/seats.mjs +1 -1
  61. package/worker/selling/index.mjs +1 -0
  62. package/worker/selling/runtime.mjs +7 -5
  63. package/worker/service-run.mjs +1 -1
  64. package/worker/shop-links.mjs +1 -1
  65. package/worker/shop-page.mjs +1 -0
  66. package/worker/shop.mjs +27 -20
  67. package/worker/stats.mjs +7 -6
  68. package/worker/stream-objects.mjs +18 -0
  69. package/worker/stripe-intake.mjs +79 -0
  70. package/worker/stripe.mjs +3 -0
  71. package/worker/table.mjs +1212 -0
  72. package/worker/tool-registry.mjs +10 -0
  73. package/worker/version.mjs +1 -1
package/worker/shop.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ const officeShopPage = async (...args) => (await import('./shop-page.mjs')).officeShopPage(...args);
1
2
  import {claimHoldStatement,ownedHold} from './booking-holds.mjs';
2
3
  import { stripeEventOwner } from './stripe-ownership.mjs';
3
4
  import { localSession } from './local-test.mjs';
@@ -47,7 +48,7 @@ import {
47
48
  setBand, shopDataOf, spentThisMonth,
48
49
  } from './shop-store.mjs';
49
50
  import { apiBase, STRIPE_API, redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
50
- import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
51
+
51
52
  import { notFoundPage } from './site.mjs';
52
53
 
53
54
  export { forgetPlayerShop, officeShopPage, shopDataOf };
@@ -217,7 +218,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
217
218
  const requestAt = Date.now();
218
219
  const path = url.pathname;
219
220
  const read = request.method === 'GET' || request.method === 'HEAD';
220
- if (path === '/_homie/shop.js') return new Response(SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
221
+ if (path === '/_homie/shop.js') return new Response((await import('./shop-page.mjs')).SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
221
222
  const shopPath = path === '/shop' || path === '/shop/' || path.startsWith('/shop/') || path === '/api/shop' || path.startsWith('/api/shop/') || path === '/api/player/owns';
222
223
  if (selling.enabled && (path.startsWith('/api/purchases/') || path.startsWith('/purchases/'))) return selling.purchaseRoutes(request, env, url, await catalogueOf());
223
224
  if (!shopPath) return null;
@@ -230,7 +231,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
230
231
  if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
231
232
  if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
232
233
  // No shop.json: /shop/ is the studio's own to use (a page in site/pages, say). The policy can close a kids studio's shop.
233
- if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
234
+ if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return (await import('./shop-page.mjs')).shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
234
235
  return shop ? notFoundPage('This studio sells nothing.', cat) : null;
235
236
  }
236
237
  const ready = await readiness(env, shop);
@@ -271,9 +272,9 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
271
272
  if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
272
273
  if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env, shop);
273
274
  if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
274
- if (path === '/shop/' && read) return shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
275
- if (path === '/shop/thanks' && read) return thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
276
- if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return refundsPage(cat, shop);
275
+ if (path === '/shop/' && read) return (await import('./shop-page.mjs')).shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
276
+ if (path === '/shop/thanks' && read) return (await import('./shop-page.mjs')).thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
277
+ if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return (await import('./shop-page.mjs')).refundsPage(cat, shop);
277
278
  const parent = /^\/shop\/parent\/([A-Za-z0-9_-]{43})$/.exec(path);
278
279
  if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1], ctx);
279
280
  } catch (error) {
@@ -738,35 +739,35 @@ async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
738
739
  const cart = row ? row.cart ? JSON.parse(row.cart) : [{ item: row.item, quantity: 1 }] : [];
739
740
  const lines = cart.map((entry) => ({ item: shop.items.find((i) => i.id === entry.item), quantity: entry.quantity }));
740
741
  const item = lines[0]?.item;
741
- if (lines.some((line) => !line.item)) return parentPage(cat, shop, { gone: true });
742
- if (!row || !item || Number(row.expires_at) < Date.now()) return parentPage(cat, shop, { gone: true });
742
+ if (lines.some((line) => !line.item)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
743
+ if (!row || !item || Number(row.expires_at) < Date.now()) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
743
744
  const kid = await players.get(env, row.player);
744
- if (!kid) return parentPage(cat, shop, { gone: true });
745
+ if (!kid) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
745
746
  const done = row.order_id ? await orderById(env, row.order_id) : null;
746
- if (done?.status === 'paid') return parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
747
- if (request.method === 'GET' || request.method === 'HEAD') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
747
+ if (done?.status === 'paid') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
748
+ if (request.method === 'GET' || request.method === 'HEAD') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
748
749
  if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
749
750
  let form = null;
750
751
  try { form = await request.formData(); } catch { form = null; }
751
- if (String(form?.get('grownup') ?? '') !== 'yes') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready, said: 'Tick the box to say you are their parent or guardian and an adult.' });
752
- if (!ready.ready) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
753
- if (purchaseLimited(request, shop, row.player, 'parentpay')) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
752
+ if (String(form?.get('grownup') ?? '') !== 'yes') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready, said: 'Tick the box to say you are their parent or guardian and an adult.' });
753
+ if (!ready.ready) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
754
+ if (purchaseLimited(request, shop, row.player, 'parentpay')) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
754
755
  const band = await bandOfPlayer(env, row.player);
755
756
  const owns = await ownsOf(env, row.player);
756
757
  let amount = 0;
757
758
  for (const [index, line] of lines.entries()) {
758
759
  const it = line.item;
759
760
  const owned = it.gives.length > 0 && it.gives.every((key) => owns.some((o) => o.key === key && o.item === it.id));
760
- if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return parentPage(cat, shop, { gone: true });
761
+ if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
761
762
  line.amount = it.price === 'choose' ? parseAmount(form?.get(lines.length === 1 ? 'amount' : `amount_${index}`), currencyScale(shop.currency)) : it.price;
762
763
  const invalid = amountError(line.amount, shop.currency);
763
- if (invalid || it.price === 'choose' && (line.amount < it.min || it.max !== null && line.amount > it.max)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: invalid ?? 'This amount is outside the studio range.' });
764
+ if (invalid || it.price === 'choose' && (line.amount < it.min || it.max !== null && line.amount > it.max)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, said: invalid ?? 'This amount is outside the studio range.' });
764
765
  const total = BigInt(amount) + BigInt(line.amount) * BigInt(line.quantity);
765
- if (total > BigInt(Number.MAX_SAFE_INTEGER)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
766
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
766
767
  amount = Number(total);
767
768
  }
768
769
  const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, amount, parent: true, lines });
769
- if (r.error) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
770
+ if (r.error) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
770
771
  await env.DB.prepare('UPDATE shop_parent_links SET order_id = ?2 WHERE hash = ?1').bind(await sha256Hex(token), r.order.id).run();
771
772
  if (!r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
772
773
  return Response.redirect(r.url, 303);
@@ -836,7 +837,13 @@ async function hook(request, env, cat, shop, ready) {
836
837
  if (payload === null) return fail(413, 'too-large', 'The studio webhook byte limit was reached.');
837
838
  const v = await verifyWebhook(payload, request.headers.get('stripe-signature'), env.STRIPE_WEBHOOK_SECRET);
838
839
  if (!v.ok) return fail(v.why === 'no-secret' ? 503 : 400, v.why, 'not a webhook from this studio\'s Stripe');
839
- const ev = v.event;
840
+ if(env.BACKGROUND)return (await import('./stripe-intake.mjs')).acceptStripeEvent(env,v.event,new URL(request.url).origin);
841
+ return reconcileShopEvent(request,env,shop,v.event);
842
+ }
843
+
844
+ /** Internal verified-event seam shared by direct legacy intake and the queue consumer. */
845
+ export async function reconcileShopEvent(request,env,shop,ev) {
846
+ shop??=checkShop({}).shop;
840
847
  const obj = ev.data?.object ?? {};
841
848
  const needed = ['checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired', 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed'];
842
849
  if (selling.enabled) {
@@ -955,7 +962,6 @@ async function hook(request, env, cat, shop, ready) {
955
962
  if (did === 'needs-keyed-recovery') return fail(503, did, 'This earlier multi-item checkout needs its original connection to recover tax per line. Reconnect it, then resend this event.');
956
963
  if (did === 'missing-item' || did === 'unknown-order') return fail(503, did, did === 'missing-item' ? 'Restore this order item in shop.json to grant it; the office can refund its payment.' : 'Payment order is not recorded yet; retry this event.');
957
964
  await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run();
958
- if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
959
965
  return json({ ok: true, did });
960
966
  } catch (error) {
961
967
  if(/(?:booking|resource) capacity unavailable/.test(error.message)&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test','The paid group exceeds remaining capacity. Staff will arrange a replacement or refund.'))return json({ok:true,did:'review'});
@@ -1443,6 +1449,7 @@ export async function shopOffice(env, cat, origin, itemCursor = 0, orderCursor =
1443
1449
  },
1444
1450
  keyless: !hasStripeKey(env),
1445
1451
  hook: `${origin}/api/shop/hook`,
1452
+ ...(env.BACKGROUND ? {delivery:await (await import('./stripe-intake.mjs')).stripeIntakeStatus(env)} : {}),
1446
1453
  totals: null, orders: [],
1447
1454
  };
1448
1455
  if (!(await migrated(env, { complete: false }))) return out;
package/worker/stats.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {writePageMetric,analyticsStatus} from './analytics.mjs';
1
2
  /**
2
3
  * STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
3
4
  *
@@ -5,7 +6,7 @@
5
6
  * party, no cookie is set on a visitor, and no visitor is identified: a count is a number on a day, never a row
6
7
  * about a person. Referrers are kept as a host name only (never a path, a query or an address).
7
8
  *
8
- * WHAT IS COUNTED, one D1 row write each (an UPSERT on a daily counter; never per frame):
9
+ * WHAT IS COUNTED (page metrics go to Analytics Engine; daily rollups preserve D1 history):
9
10
  * visit a page opened (home, a game's page, /music/, a song's page, /videos/, a video's page)
10
11
  * play a game's play page opened: somebody pressed Play (or followed a Play link or QR code)
11
12
  * screen a big screen opened (/<game>/tv)
@@ -36,9 +37,8 @@
36
37
  * (HttpOnly, SameSite=Lax; from 0.13.0 at / so the owner is recognised in their own games, never readable by a
37
38
  * page or a game). There is no other way in.
38
39
  *
39
- * FREE PLAN: D1 allows 100,000 row writes and 5 million row reads a day. A visit, a play, a room and a
40
- * song or video start are one write each; a finished round is three (its row in `rounds`, the round and the
41
- * humans counters). Past the day's writes, counting stops until 00:00 UTC and the site keeps working.
40
+ * Page totals refresh hourly and can be sampled at high traffic. Older wrappers without the analytics
41
+ * binding retain D1 counting. Game, payment and budget facts remain in durable storage.
42
42
  */
43
43
 
44
44
  export const STATS_MIGRATION_FILE = '0002_studio_stats.sql';
@@ -97,9 +97,10 @@ export function counter(env, { metric, subject = '', source = '', n = 1, day = t
97
97
 
98
98
  /** Run a counter write after the response (ctx.waitUntil), or before it when the caller gave no ctx. Never throws. */
99
99
  export async function count(env, ctx, row) {
100
+ if (writePageMetric(env, row)) return;
100
101
  const stmt = counter(env, row);
101
102
  if (!stmt) return;
102
- const p = stmt.run().catch(() => {}); // before migration 0002, or past the free plan's daily writes: not counted
103
+ const p = stmt.run().catch(error => console.warn("Studio counter dropped:",error.message)); // before migration 0002, or past the free plan's daily writes: not counted
103
104
  if (ctx?.waitUntil) ctx.waitUntil(p); else await p;
104
105
  }
105
106
 
@@ -339,7 +340,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
339
340
  // The studio's own pages: its home, and the /music/ and /videos/ lists (a catalogue's road).
340
341
  pages: Object.fromEntries(['home', 'games', 'rooms', 'posts', 'music', 'videos'].map((p) => [p, sum((r) => r.metric === 'visit' && r.subject === p)])),
341
342
  games: perGame, songs: media(songs, 'song', 'music'), videos: media(videos, 'video', 'videos'), referrers, days,
342
- note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.',
343
+ note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.' + (env.STUDIO_ANALYTICS && env.STUDIO_ANALYTICS_DATASET ? ' Page visits, plays, screens and watches show the last hourly rollup; recent activity appears after ingestion and the next rollup. These counters may be sampled at high traffic.' + await analyticsStatus(env).then(s=>s.delayed ? ' Counts are delayed: '+(s.error??'awaiting a successful rollup')+'.' : '') : ''),
343
344
  };
344
345
  }
345
346
 
@@ -0,0 +1,18 @@
1
+ /** Streams use independent namespaces so subscribers never warm matchmaking state. */
2
+ export class AppRecordHub {
3
+ constructor(ctx, env) { this.ctx=ctx; this.env=env; }
4
+ async fetch(request) {
5
+ const {AppRecordStreams}=await import('./app-streams.mjs');
6
+ this.streams??=new AppRecordStreams(this.env,this.ctx);
7
+ const input=await request.json();
8
+ return input.op==='open'?this.streams.open(input):this.streams.wake(input.meta);
9
+ }
10
+ }
11
+ export class EventHub {
12
+ constructor(ctx, env) { this.ctx=ctx; this.env=env; }
13
+ async fetch(request) {
14
+ const {EventStreams}=await import('./event-resources.mjs');
15
+ this.streams??=new EventStreams(this.env,this.ctx);
16
+ return this.streams.fetch(request);
17
+ }
18
+ }
@@ -0,0 +1,79 @@
1
+ import {RECONCILE_GUARD,guardedDatabase} from './reconcile-guard.mjs';
2
+ /** Called only after raw-body signature verification. D1 owns the work; queues
3
+ * are wakeups and may be unavailable or exhaust their daily allowance. */
4
+ export async function acceptStripeEvent(env,event,origin) {
5
+ await env.DB.prepare('INSERT INTO stripe_intake(id,payload,origin,received) VALUES(?1,?2,?3,?4) ON CONFLICT(id) DO NOTHING')
6
+ .bind(event.id,JSON.stringify(event),origin,Date.now()).run();
7
+ try { await env.BACKGROUND.send({v:1,type:'stripe.event',id:event.id}); }
8
+ catch(error) { console.warn('Stripe delivery deferred to the scheduled D1 sweep:',error.message); }
9
+ return Response.json({ok:true,queued:true});
10
+ }
11
+ async function reconcile(env,row,event,cat) {
12
+ const {reconcileShopEvent,shopOf}=await import('./shop.mjs');
13
+ return reconcileShopEvent(new Request(new URL('/api/shop/hook',row.origin)),env,shopOf(cat),event);
14
+ }
15
+ export async function reconcileStripeEvent(env,id,cat,{leaseMs=300000,reconcile:deliver=reconcile}={}) {
16
+ const now=Date.now();let lease=now+leaseMs;
17
+ const row=await env.DB.prepare('UPDATE stripe_intake SET lease=?2 WHERE id=?1 AND done IS NULL AND lease<?3 RETURNING *').bind(id,lease,now).first();
18
+ if(!row) {
19
+ const pending=await env.DB.prepare('SELECT done,lease FROM stripe_intake WHERE id=?1').bind(id).first();
20
+ if(pending?.done)return;
21
+ const error=new Error('Stripe event is missing or already being reconciled');
22
+ if(pending?.lease>=now)error.retryAfterSeconds=Math.max(1,Math.min(301,Math.ceil((pending.lease-now)/1000)+1));
23
+ throw error;
24
+ }
25
+ let renewal=Promise.resolve(),lost=null;
26
+ const timer=setInterval(()=>{
27
+ renewal=renewal.then(async()=>{
28
+ if(lost)return;
29
+ const next=Date.now()+leaseMs;
30
+ const renewed=await env.DB.prepare('UPDATE stripe_intake SET lease=?3 WHERE id=?1 AND lease=?2 AND done IS NULL RETURNING id').bind(id,lease,next).first();
31
+ if(!renewed)throw new Error('Stripe reconciliation lease was lost');
32
+ lease=next;
33
+ }).catch(error=>{lost=error;});
34
+ },Math.max(1,Math.floor(leaseMs/3)));
35
+ const check=async()=>{
36
+ await renewal;
37
+ if(lost)throw lost;
38
+ if(Date.now()>=lease)throw new Error('Stripe reconciliation lease expired');
39
+ };
40
+ const guarded={...env,DB:guardedDatabase(env.DB,check),[RECONCILE_GUARD]:check};
41
+ try {
42
+ await check();
43
+ const event=JSON.parse(row.payload);
44
+ if(event.id!==id)throw new Error('Stored Stripe event id differs');
45
+ const response=await deliver(guarded,row,event,cat);
46
+ if(!response.ok)throw new Error('Stripe reconciliation needs retry ('+response.status+')');
47
+ clearInterval(timer);await renewal;
48
+ if(lost)throw lost;
49
+ const done=await env.DB.prepare('UPDATE stripe_intake SET done=?2,lease=0 WHERE id=?1 AND lease=?3 AND lease>?2 RETURNING id').bind(id,Date.now(),lease).first();
50
+ if(!done)throw new Error('Stripe reconciliation lease lost before completion');
51
+ } catch(error) {
52
+ clearInterval(timer);await renewal;
53
+ await env.DB.prepare('UPDATE stripe_intake SET lease=?3 WHERE id=?1 AND lease=?2').bind(id,lease,Date.now()+60000).run();
54
+ throw error;
55
+ } finally {clearInterval(timer);}
56
+ }
57
+ export async function stripeIntakeStatus(env) {
58
+ if(!env.DB)return {pending:0,stuck:0};
59
+ const row=await env.DB.prepare('SELECT COUNT(*) AS pending,COALESCE(SUM(received<?1),0) AS stuck,MIN(received) AS oldest FROM stripe_intake WHERE done IS NULL').bind(Date.now()-3600000).first();
60
+ return {...row,message:row?.stuck?'Stripe deliveries are delayed. Check Worker logs and Stripe key permissions; scheduled recovery retries unfinished events.':null};
61
+ }
62
+ export async function sweepStripeEvents(env,cat) {
63
+ if(!env.DB)return;
64
+ const rows=(await env.DB.prepare('SELECT id FROM stripe_intake WHERE done IS NULL AND lease<?1 ORDER BY lease,received,id LIMIT 100').bind(Date.now()).all()).results??[];
65
+ for(const row of rows) {
66
+ try {await reconcileStripeEvent(env,row.id,cat);}
67
+ catch(error) {console.error('Stripe intake recovery failed',row.id,error.message);}
68
+ }
69
+ const status=await stripeIntakeStatus(env);
70
+ if(status.stuck)console.error('Stripe intake needs attention',status);
71
+ return status;
72
+ }
73
+ export async function pruneStripeEvents(env) {
74
+ const cutoff=Date.now()-60*86400000;
75
+ await env.DB.batch([
76
+ ...(env.BACKGROUND?[env.DB.prepare('DELETE FROM stripe_intake WHERE id IN (SELECT id FROM stripe_intake WHERE done<?1 ORDER BY done LIMIT 1000)').bind(cutoff)]:[]),
77
+ env.DB.prepare('DELETE FROM shop_events WHERE id IN (SELECT id FROM shop_events WHERE at<?1 ORDER BY at LIMIT 1000)').bind(cutoff),
78
+ ]);
79
+ }
package/worker/stripe.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {RECONCILE_GUARD} from './reconcile-guard.mjs';
1
2
  /**
2
3
  * THE STUDIO'S OWN STRIPE, from its Worker (@homie-rocks/studio 0.24.0). No SDK: a few form-encoded calls with the
3
4
  * studio's key (a Worker secret), and Stripe's documented webhook signature check with WebCrypto.
@@ -95,6 +96,7 @@ export async function productIdOf(slug, item) {
95
96
 
96
97
  /** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
97
98
  export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch, version = STRIPE_VERSION, timeout = 15_000 } = {}) {
99
+ await env?.[RECONCILE_GUARD]?.();
98
100
  const key = String(env?.STRIPE_KEY ?? '');
99
101
  if (!KEY_SHAPE.test(key)) throw new StripeError(0, { error: { message: 'no Stripe key on this Worker', code: 'no-key' } });
100
102
  const body = params && method !== 'GET' ? formEncode(params).toString() : null;
@@ -119,6 +121,7 @@ export async function stripeCall(env, method, path, params = null, { idempotency
119
121
  error.idempotentReplayed = res.headers.get('idempotent-replayed') === 'true';
120
122
  throw error;
121
123
  }
124
+ await env?.[RECONCILE_GUARD]?.();
122
125
  return json;
123
126
  }
124
127