@homie-rocks/studio 0.50.6 → 0.51.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 (56) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/app-starters/giving/README.md +25 -2
  3. package/app-starters/giving/app.json +16 -49
  4. package/app-starters/giving/index.html +1 -1
  5. package/app-starters/giving/src/rules.ts +1 -1
  6. package/app-starters/giving/src/view.ts +57 -59
  7. package/apps/APPS.md +70 -2
  8. package/apps/apps.ts +1 -1
  9. package/bin/homie-studio.mjs +8 -4
  10. package/functions/FUNCTIONS.md +39 -2
  11. package/guides/app/SKILL.md +61 -5
  12. package/guides/functions/SKILL.md +30 -0
  13. package/guides/office/SKILL.md +20 -0
  14. package/guides/servers/SKILL.md +18 -0
  15. package/guides/shop/SKILL.md +41 -20
  16. package/lib/app-needs.mjs +31 -11
  17. package/lib/app-ready.mjs +49 -0
  18. package/lib/check.mjs +51 -35
  19. package/lib/dev-stripe-fixture.mjs +37 -0
  20. package/lib/dev-stripe-sandbox.mjs +80 -0
  21. package/lib/dev.mjs +19 -17
  22. package/lib/events-cli.mjs +5 -1
  23. package/lib/local-test.mjs +18 -0
  24. package/lib/mcp-tools.mjs +15 -1
  25. package/lib/office.mjs +1 -1
  26. package/lib/scaffold.mjs +22 -1
  27. package/lib/shop-links.mjs +79 -7
  28. package/lib/shop.mjs +4 -2
  29. package/lib/stripe-connect.mjs +1 -0
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +3 -0
  32. package/package.json +1 -1
  33. package/shop/SELLING.md +2 -2
  34. package/shop/SHOP.md +120 -5
  35. package/shop/shop.ts +29 -0
  36. package/tools/tools.ts +8 -1
  37. package/worker/app-format.mjs +8 -1
  38. package/worker/app-frame.mjs +9 -0
  39. package/worker/app-records.mjs +39 -10
  40. package/worker/booking-store.mjs +79 -0
  41. package/worker/build-id.mjs +1 -1
  42. package/worker/event-resources.mjs +30 -3
  43. package/worker/functions.mjs +5 -2
  44. package/worker/index.mjs +17 -3
  45. package/worker/local-test.mjs +19 -0
  46. package/worker/mcp-tools.mjs +19 -8
  47. package/worker/mcp.mjs +23 -11
  48. package/worker/records-page.mjs +13 -0
  49. package/worker/shop-links.mjs +3 -2
  50. package/worker/shop-page.mjs +27 -5
  51. package/worker/shop-rules.mjs +2 -1
  52. package/worker/shop-store.mjs +1 -1
  53. package/worker/shop-subscriptions.mjs +198 -0
  54. package/worker/shop.mjs +195 -29
  55. package/worker/tool-database.mjs +36 -0
  56. package/worker/version.mjs +1 -1
package/worker/shop.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { localSession } from './local-test.mjs';
2
+ import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-subscriptions.mjs';
3
+ import { bookingStatement } from './booking-store.mjs';
1
4
  import { paymentRefunds } from './shop-refunds.mjs';
2
5
  import { selling } from './extensions.mjs';
3
6
  /**
@@ -41,7 +44,7 @@ import {
41
44
  orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
42
45
  setBand, shopDataOf, spentThisMonth,
43
46
  } from './shop-store.mjs';
44
- import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
47
+ import { apiBase, STRIPE_API, redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
45
48
  import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
46
49
  import { notFoundPage } from './site.mjs';
47
50
 
@@ -138,11 +141,11 @@ export async function readiness(env, shop) {
138
141
  else if (shop.broken) missing.push('shop.json-refused');
139
142
  else if (shop.till === 'off') missing.push('till-off');
140
143
  else if (!shop.items.length) missing.push('items');
141
- const paidItems = shop?.items.some((i) => i.price !== 0);
144
+ const paidItems = shop?.items.some((i) => i.price !== 0 || i.interval);
142
145
  const links = linkConfig(env);
143
146
  if (paidItems && !hasStripeKey(env)) {
144
147
  if (fullerReason(shop)) missing.push('fuller-checkout');
145
- else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => i.price !== 0 && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
148
+ else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => (i.price !== 0 || i.interval) && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
146
149
  }
147
150
  // Reliable fulfillment requires the webhook as well as the redirect.
148
151
  // https://docs.stripe.com/checkout/fulfillment
@@ -199,8 +202,8 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
199
202
  const page = item ? selected : selected.slice(cursor, cursor + 100);
200
203
  const items = page.map((it) => {
201
204
  const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
202
- const way = wayFor(it, { open: open || it.price === 0 && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
203
- return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.price === 'choose' ? { min: it.min, max: it.max } : {}), shown: it.price === 'choose' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
205
+ const way = wayFor(it, { open: open || it.price === 0 && !it.interval && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
206
+ return { id: it.id, kind: it.kind, ...(it.interval ? { interval: it.interval } : {}), name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.price === 'choose' ? { min: it.min, max: it.max } : {}), shown: it.price === 'choose' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
204
207
  }).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
205
208
  return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
206
209
  }
@@ -225,6 +228,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
225
228
  if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
226
229
  if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
227
230
  // 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.
231
+ if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
228
232
  return shop ? notFoundPage('This studio sells nothing.', cat) : null;
229
233
  }
230
234
  const ready = await readiness(env, shop);
@@ -245,6 +249,21 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
245
249
  if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
246
250
  if (path === '/api/shop/mine' && read) return await mineRoute(request, env, shop);
247
251
  if (path === '/api/shop/order' && read) return await orderRoute(request, env, url, shop);
252
+ if (path === '/api/shop/subscriptions' && read) {
253
+ const player = await accountOf(request, env);
254
+ if (!player) return json({ ok: true, subscriptions: [] });
255
+ const subscriptions = (await env.DB.prepare(`SELECT s.id,s.state,o.item,o.currency,o.amount FROM shop_subscriptions s
256
+ JOIN shop_orders o ON o.id=s.order_id WHERE o.player=?1`).bind(player.id).all()).results ?? [];
257
+ return json({ ok: true, subscriptions });
258
+ }
259
+ if (path === '/api/shop/portal' && request.method === 'POST') {
260
+ const player = await accountOf(request, env);
261
+ if (!player) return fail(401, 'account', 'Use the account that made this gift.');
262
+ const b = await bodyOf(request); if (b.error) return b.error;
263
+ if (typeof b.body.subscription !== 'string') return fail(400, 'subscription', 'Name your subscription.');
264
+ const portal = await subscriptionPortal(env,player.id,b.body.subscription,url.origin);
265
+ return portal ? json({ ok: true, url: portal }) : fail(404,'subscription','No subscription for this account.');
266
+ }
248
267
  if (path === '/api/shop/age' && request.method === 'POST') return await ageRoute(request, env, shop);
249
268
  if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready, ctx, requestAt);
250
269
  if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
@@ -275,6 +294,8 @@ async function listRoute(request, env, url, cat, shop, ready) {
275
294
  currency: shop.currency, currencyScale: currencyScale(shop.currency), amountStep: amountStep(shop.currency), policy: shop.policy, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
276
295
  player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
277
296
  owns: v.owns.map((o) => o.key), items: v.items, nextCursor: v.nextCursor,
297
+ portalLogin: !hasStripeKey(env) && /^https:\/\/billing\.stripe\.com\/p\/login\//.test(linkConfig(env)?.portal??'') ? linkConfig(env).portal : null,
298
+ menus: !hasStripeKey(env) && ready.ready ? (linkConfig(env)?.menus??[]).filter(m=>m.items.every(entry=>v.items.some(i=>i.id===entry.item&&i.way==='checkout'))).map(m=>({items:m.items.map(i=>i.item)})) : [],
278
299
  });
279
300
  }
280
301
 
@@ -323,7 +344,8 @@ async function orderRoute(request, env, url, shop) {
323
344
  }
324
345
  } catch (error) { logOrderError(env, o.id, error); }
325
346
  }
326
- return json({ ok: true, status: playerOrderStatus(o), item: o.item, game: o.game ?? null });
347
+ const reference = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-reference:${o.id}`).first();
348
+ return json({ ok: true, order: o.id, simulated: apiBase(env) !== STRIPE_API, status: playerOrderStatus(o), item: o.item, game: o.game ?? null, reference: reference?.value ?? null });
327
349
  }
328
350
 
329
351
  /** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
@@ -355,12 +377,13 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
355
377
  const cart = lines ?? [{ item, amount, quantity: 1, product }];
356
378
  const back = game ? `&game=${encodeURIComponent(game)}` : '';
357
379
  return {
358
- mode: 'payment',
380
+ mode: cart.some(line => line.item.interval) ? 'subscription' : 'payment',
359
381
  // Stripe payment Checkout allows up to 100 lines, each with a quantity:
360
382
  // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items
361
383
  line_items: cart.map((line) => ({
362
384
  quantity: line.quantity,
363
385
  price_data: { currency: shop.currency, unit_amount: line.amount, tax_behavior: 'exclusive',
386
+ ...(line.item.interval ? { recurring: { interval: line.item.interval } } : {}),
364
387
  ...(line.product ? { product: line.product } : { product_data: {
365
388
  name: line.item.name, ...(line.item.blurb ? { description: line.item.blurb } : {}),
366
389
  ...(line.item.taxCode || managed ? { tax_code: line.item.taxCode ?? defaultTaxCode(line.item) } : {}),
@@ -372,7 +395,7 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
372
395
  // Stripe metadata values allow 500 characters; the order holds the complete item id.
373
396
  // https://docs.stripe.com/metadata (fetched 2026-10-08).
374
397
  metadata: { homie: 'shop-v1', origin, studio: String(studio ?? '').length <= 500 ? String(studio ?? '') : undefined, order: order.id, item: item.id.length <= 500 ? item.id : undefined, ...(game ? { game } : {}), ...(parent ? { parent: '1' } : {}) },
375
- payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } },
398
+ ...(cart.some(line => line.item.interval) ? { subscription_data: { metadata: { homie: 'shop-v1', origin, order: order.id } } } : { payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } } }),
376
399
  success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
377
400
  cancel_url: `${origin}/shop/?cancelled=1&order=${order.id}${back}`,
378
401
  // Keep Stripe’s default 24-hour Checkout lifetime: https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at
@@ -414,8 +437,38 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
414
437
  }
415
438
  await Promise.all(rows.map(async (row) => {
416
439
  if (!row.session) {
417
- if (row.status === 'paid' || (row.expires_at ?? row.created_at + 1860000) + 60000 < now) await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1 AND session IS NULL').bind(row.id).run();
418
- return;
440
+ const saved = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`booking-checkout:${row.id}`).first();
441
+ if (saved) {
442
+ try {
443
+ let recovered;
444
+ if (now - row.created_at < 23 * 60 * 60 * 1000) recovered = await createCheckoutSession(env, JSON.parse(saved.value), { idempotencyKey: `checkout-${row.id}` });
445
+ else {
446
+ // After idempotency retention, discover the original session instead of
447
+ // creating another. A complete provider list can establish no creation.
448
+ let cursor;
449
+ do {
450
+ const page = await stripeCall(env, 'GET', '/v1/checkout/sessions', { limit: 100, created: { gte: Math.floor(row.created_at / 1000) - 60 }, ...(cursor ? { starting_after: cursor } : {}) });
451
+ if (!Array.isArray(page.data)) throw new Error('Stripe session list unavailable');
452
+ recovered = page.data.find(session => session.metadata?.order === row.id);
453
+ if (recovered) break;
454
+ const next = page.has_more ? page.data.at(-1)?.id : null;
455
+ if (page.has_more && (!next || next === cursor)) throw new Error('Stripe session list incomplete');
456
+ cursor = next;
457
+ } while (cursor);
458
+ if (!recovered) {
459
+ await env.DB.prepare("UPDATE shop_orders SET status='failed',updated_at=?2 WHERE id=?1 AND session IS NULL AND paid_at IS NULL").bind(row.id,now).run();
460
+ return;
461
+ }
462
+ }
463
+ if (recovered?.id) {
464
+ await env.DB.prepare('UPDATE shop_orders SET session=?2,expires_at=?3 WHERE id=?1 AND session IS NULL').bind(row.id,recovered.id,recovered.expires_at*1000).run();
465
+ row.session = recovered.id;
466
+ }
467
+ } catch (error) { logOrderError(env,row.id,error); return; }
468
+ if (!row.session) return;
469
+ }
470
+ if (!row.session && (row.status === 'paid' || (row.expires_at ?? row.created_at + 1860000) + 60000 < now)) await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1 AND session IS NULL').bind(row.id).run();
471
+ if (!row.session) return;
419
472
  }
420
473
  if (expireOpen && row.status !== 'started') return;
421
474
  if (row.status === 'paid' && !row.player) { await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(row.id).run(); return; }
@@ -458,15 +511,31 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
458
511
  }
459
512
 
460
513
  /** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
461
- async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, requestAt = Date.now() }) {
514
+ async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, booking = null, requestAt = Date.now() }) {
462
515
  lines ??= [{ item, amount, quantity: 1 }];
463
- const keyless = amount > 0 && !hasStripeKey(env);
464
- if (keyless && (lines.length !== 1 || fullerReason(shop))) return { error: fail(409, 'one-item', fullerReason(shop) ?? 'Buy one item at a time. A combined cart needs the fuller checkout connection.') };
465
- const link = keyless ? linkConfig(env)?.items.find((l) => l.item === item.id) : null;
466
- if (keyless && (!link || !paymentLinkURL(link.url))) return { error: fail(503, 'payment-links', MISSING_WORDS['payment-links']) };
467
- if (keyless && item.price === 'choose' && lines[0].quantity !== 1) return { error: fail(400, 'quantity', 'A tip is one chosen amount; choose it on Stripe.') };
468
- if (keyless) lines = lines.map((l) => ({ ...l, item: { ...l.item, paymentLink: link } }));
516
+ const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
517
+ const config=linkConfig(env);
518
+ if(booking && amount===0 && !hasStripeKey(env) && config?.slots?.some(l=>l.app===game&&l.slots===booking.slots&&l.slot===booking.slot))return {error:fail(409,'booking','Publish a separate local slot for a free booking; it cannot share a paid Stripe capacity budget.')};
519
+ const menu=keyless && !booking && lines.length>1 ? config?.menus?.find(m=>lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
520
+ const link=keyless ? booking ? config?.slots?.find(l=>l.app===game&&l.collection===booking.collection&&l.slots===booking.slots&&l.slot===booking.slot&&l.item===item.id) : menu ?? config?.items.find(l=>l.item===item.id) : null;
521
+ if(keyless && lines.length>1 && !menu)return {error:fail(409,'menu','These items need separate Stripe menus. Custom amounts and monthly gifts use their own links.')};
522
+ if(keyless && (!link || !paymentLinkURL(link.url)))return {error:fail(503,'payment-links','Ask the owner’s AI to sync the shop and published booking slots.')};
523
+ if(keyless && booking) {
524
+ if(booking.quantity!==1)return {error:fail(400,'quantity','Book one seat per checkout; Stripe counts completed sessions.')};
525
+ const slot=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(booking.app,booking.slots,booking.slot).first();
526
+ if(!slot || JSON.parse(slot.value).capacity!==link.capacity)return {error:fail(409,'slot-sync','This slot changed; ask the owner’s AI to sync its Stripe link.')};
527
+ const confirmed=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state='confirmed'").bind(booking.app,booking.slots,booking.slot).first();
528
+ const update=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
529
+ const observed=update?JSON.parse(update.value):null;
530
+ const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
531
+ if(!status.active || status.count>=(link.limit??link.capacity) || confirmed.n>=link.capacity)return {error:fail(409,'capacity','That slot is full. Choose another slot.')};
532
+ }
533
+ if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
534
+ if(menu && menu.items.some(e=>{const i=shop.items.find(i=>i.id===e.item);return !i || i.game && i.game!==game;}))return {error:fail(409,'menu','This menu includes items from another app; sync separate menus for each app.')};
535
+ if(menu)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
536
+ if(keyless)lines=lines.map(l=>({...l,item:{...l.item,paymentLink:link,stripePrice:menu?.items.find(i=>i.item===l.item.id)?.price??link.price}}));
469
537
  const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
538
+ if (booking && reference === null) reference = order.id;
470
539
  const now = Date.now();
471
540
  order.createdAt = now;
472
541
  order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
@@ -476,7 +545,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
476
545
  lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
477
546
  const reserve = async () => {
478
547
  const results = await env.DB.batch([reservation(),
548
+ ...(booking ? [bookingStatement(env, { ...booking, pending: keyless, id: order.id, app: booking.app ?? game, order: order.id })] : []),
479
549
  ...(reference === null ? [] : [env.DB.prepare("INSERT INTO meta(key,value) SELECT ?1,?2 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id=?3)").bind(`shop-reference:${order.id}`, reference, order.id)]),
550
+ ...(menu ? [env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`shop-menu:${order.id}`,JSON.stringify(lines))] : []),
480
551
  ...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
481
552
  .bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
482
553
  env.DB.prepare('UPDATE shop_orders SET checkout_player = player WHERE id = ?1').bind(order.id),
@@ -484,14 +555,18 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
484
555
  return results[0];
485
556
  };
486
557
  await reconcileOrders(env, shop, player.id, { expireOpen: true, before: Math.min(requestAt, now - 60000) });
487
- let reserved = await reserve();
558
+ let reserved;
559
+ try { reserved = await reserve(); } catch (error) {
560
+ if (/booking capacity unavailable/.test(error.message)) return { error: fail(409, 'capacity', 'That slot is full. Choose another slot.') };
561
+ throw error;
562
+ }
488
563
  const reconciled = !reserved.meta?.changes;
489
564
  if (!reserved.meta?.changes) {
490
565
  await reconcileOrders(env, shop, player.id);
491
566
  reserved = await reserve();
492
567
  }
493
568
  if (!reserved.meta?.changes) return { error: fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' }) };
494
- if (amount === 0) {
569
+ if (amount === 0 && !lines.some(l => l.item.interval)) {
495
570
  const id = `cs_free_${order.id}`;
496
571
  await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1').bind(order.id, id).run();
497
572
  await paid(env, shop, { id, metadata: { order: order.id }, client_reference_id: player.id, currency: shop.currency, amount_subtotal: 0, amount_total: 0, payment_status: 'no_payment_required' });
@@ -504,7 +579,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
504
579
  let session = null;
505
580
  const mode = shopMode(env);
506
581
  try {
507
- const useCatalog = Array.isArray(shop.catalog) && shop.catalog.includes(mode);
582
+ const useCatalog = !booking && Array.isArray(shop.catalog) && shop.catalog.includes(mode);
508
583
  for (const line of lines) line.product = useCatalog ? await productIdOf(cat.studio?.slug, line.item.id) : null;
509
584
  await env.DB.batch(lines.map((line) => env.DB.prepare('UPDATE shop_order_lines SET snapshot = ?2 WHERE id = ?1').bind(line.id, JSON.stringify({ ...line.item, stripeProduct: line.product }))));
510
585
  // Start the provider lifetime at session creation, after reservation/expiry I/O. At Stripe's
@@ -514,7 +589,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
514
589
  return sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, lines });
515
590
  };
516
591
  try {
517
- session = await createCheckoutSession(env, params(), { idempotencyKey: `checkout-${order.id}` });
592
+ const checkoutParams = params();
593
+ if (booking) await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`booking-checkout:${order.id}`, JSON.stringify(checkoutParams)).run();
594
+ session = await createCheckoutSession(env, checkoutParams, { idempotencyKey: `checkout-${order.id}` });
518
595
  } catch (error) {
519
596
  if (!useCatalog || !isMissingProduct(error)) throw error;
520
597
  for (const line of lines) line.product = null;
@@ -526,7 +603,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
526
603
  }
527
604
  await env.DB.prepare('UPDATE shop_orders SET session = ?2, updated_at = ?3, expires_at = ?4 WHERE id = ?1').bind(order.id, session.id, Date.now(), (session.expires_at ?? order.expiresAt) * 1000).run();
528
605
  } catch (error) {
529
- await env.DB.prepare("UPDATE shop_orders SET status = 'failed', note = ?2, updated_at = ?3 WHERE id = ?1").bind(order.id, redactStripe(error?.code ?? 'stripe', env).slice(0, 60), Date.now()).run();
606
+ await env.DB.prepare("UPDATE shop_orders SET status = ?4, note = ?2, updated_at = ?3 WHERE id = ?1 AND paid_at IS NULL").bind(order.id, redactStripe(error?.code ?? 'stripe', env).slice(0, 60), Date.now(), booking ? 'started' : 'failed').run();
530
607
  const why = error instanceof StripeError ? redactStripe(error.message, env) : 'Stripe did not answer';
531
608
  return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
532
609
  }
@@ -552,7 +629,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
552
629
  if (b.error) return b.error;
553
630
  const reference = b.body.reference ?? null;
554
631
  if (reference !== null && (typeof reference !== 'string' || !reference.trim() || /[\u0000-\u001f\u007f]/.test(reference))) return fail(400, 'reference', 'reference must be a nonempty string without control characters');
555
- const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0));
632
+ const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0 && !i.interval));
556
633
  if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
557
634
  const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
558
635
  // Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
@@ -583,6 +660,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
583
660
  lines.push({ item, amount: unit, quantity });
584
661
  }
585
662
  const item = lines[0].item;
663
+ if (lines.some(l => l.item.interval) && (lines.filter(l => l.item.interval).length > 20 || lines.filter(l => !l.item.interval).length > 20)) return fail(400, 'stripe-lines', 'Stripe subscription Checkout allows 20 recurring and 20 one-time lines.');
586
664
  let cookies = [];
587
665
  if (!player) {
588
666
  const guest = await players.shopGuest(request, env, url, shop.guestBuyersPerAddressPerHour);
@@ -590,8 +668,18 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
590
668
  player = guest.session.player;
591
669
  cookies = guest.session.cookies;
592
670
  }
593
- const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, requestAt });
594
- const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url });
671
+ let booking = null;
672
+ if (b.body.booking !== undefined) {
673
+ const requestedBooking = b.body.booking;
674
+ const app = cat.games?.find(g => g.id === game && g.kind === 'app');
675
+ const collection = requestedBooking?.collection;
676
+ const spec = app?.records?.collections?.[collection]?.booking;
677
+ if (!spec || !/^[A-Za-z0-9_-]{1,64}$/.test(requestedBooking?.slot ?? '') || lines.length !== 1 || item.id !== spec.item || item.interval) return fail(400, 'booking', 'Choose a declared booking slot and its service item.');
678
+ const local = await localSession(request, env);
679
+ booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
680
+ }
681
+ const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt });
682
+ const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: r.order.id } : {}) });
595
683
  if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
596
684
  for (const cookie of cookies) response.headers.append('set-cookie', cookie);
597
685
  return response;
@@ -744,12 +832,41 @@ async function hook(request, env, cat, shop, ready) {
744
832
  if (payment) await syncRefunds(env, payment);
745
833
  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(); return json({ ok: true, did: purchase }); }
746
834
  }
835
+ needed.push('payment_link.updated','invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted');
747
836
  if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
748
837
  if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
749
838
  try {
839
+ if(ev.type==='payment_link.updated') {
840
+ if(linkConfig(env)?.slots?.some(l=>l.id===obj.id))await env.DB.prepare(`INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value
841
+ WHERE json_extract(excluded.value,'$.count') >= json_extract(meta.value,'$.count') AND
842
+ (json_extract(excluded.value,'$.at') > json_extract(meta.value,'$.at') OR
843
+ (json_extract(excluded.value,'$.at') = json_extract(meta.value,'$.at') AND json_extract(excluded.value,'$.active')=0))`)
844
+ .bind(`shop-slot-link:${obj.id}`,JSON.stringify({active:obj.active,count:obj.restrictions?.completed_sessions?.count??0,capacity:obj.restrictions?.completed_sessions?.limit,at:ev.created})).run();
845
+ return json({ok:true,did:'slot-updated'});
846
+ }
847
+ if(ev.type==='invoice.paid' && obj.status==='paid' && (obj.metadata?.homie==='shop-links-v1' || obj.parent?.subscription_details?.metadata?.homie==='shop-links-v1' || obj.subscription_details?.metadata?.homie==='shop-links-v1')) {
848
+ const invoice={id:obj.id,status:obj.status,livemode:ev.livemode,currency:obj.currency,total:obj.total,lines:obj.lines ? {has_more:obj.lines.has_more,data:obj.lines.data.map(l=>({quantity:l.quantity,amount:l.amount,price:l.price?{id:l.price.id}:undefined,pricing:l.pricing,period:l.period,taxes:l.taxes,tax_amounts:l.tax_amounts}))} : null};
849
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(`shop-invoice:${obj.id}`,JSON.stringify(invoice)).run();
850
+ const pending=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-link-session:${obj.id}`).first();
851
+ if(pending) {
852
+ const session=JSON.parse(pending.value);
853
+ if(await bindLinkSession(env,session,ev.livemode?'live':'test'))await paid(env,shop,session);
854
+ }
855
+ }
856
+ if(ev.type.startsWith('checkout.session.') && obj.payment_link && obj.invoice && ['paid','no_payment_required'].includes(obj.payment_status)) {
857
+ const session=Object.fromEntries(['id','client_reference_id','payment_link','metadata','currency','amount_subtotal','amount_total','total_details','payment_status','payment_intent','subscription','customer','invoice'].map(k=>[k,obj[k]]));
858
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(`shop-link-session:${obj.invoice}`,JSON.stringify(session)).run();
859
+ }
860
+ const subscriptionResult = await subscriptionEvent(env, ev, shop);
861
+ if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
750
862
  const sessionEvent = ev.type.startsWith('checkout.session.');
751
863
  if (!sessionEvent) await rememberMoneyEvent(env, ev);
752
864
  if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ ok: true, did: 'mismatch' });
865
+ if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
866
+ await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
867
+ AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
868
+ .bind(obj.metadata.order, obj.id, ev.livemode ? 'live' : 'test').run();
869
+ }
753
870
  let known = sessionEvent ? await orderBySession(env, obj.id) : await orderByPayment(env, String(obj.payment_intent ?? ''));
754
871
  if (!known) {
755
872
  let metadata = obj.metadata;
@@ -835,7 +952,12 @@ async function bindLinkSession(env, session, mode) {
835
952
  const source = await orderById(env, session.client_reference_id);
836
953
  if (!source || source.mode !== mode) return false;
837
954
  const lines = await orderLines(env, source.id);
838
- if (lines.length !== 1 || !lines[0].snapshot) return false;
955
+ if (!lines.length || !lines[0].snapshot) return false;
956
+ if(JSON.parse(lines[0].snapshot).paymentLink?.items) {
957
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-menu:${source.id}`).first();
958
+ const original=saved?JSON.parse(saved.value).map(l=>({item:l.item.id,snapshot:JSON.stringify(l.item),unit_amount:l.amount})):lines;
959
+ return bindMenuSession(env,session,source,original);
960
+ }
839
961
  const item = JSON.parse(lines[0].snapshot), link = item.paymentLink;
840
962
  if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
841
963
  const amount = session.amount_subtotal;
@@ -848,12 +970,15 @@ async function bindLinkSession(env, session, mode) {
848
970
  let order = await orderBySession(env, session.id);
849
971
  if (!order) {
850
972
  const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
973
+ const booking=link.slot ? await env.DB.prepare('SELECT * FROM shop_bookings WHERE order_id=?1').bind(source.id).first() : null;
851
974
  await env.DB.batch([
852
975
  env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, created_at, updated_at, session) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, ?5, ?5, ?3 FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
853
- env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2").bind(`shop-reference:${id}`, `shop-reference:${source.id}`),
976
+ env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) SELECT ?1,CASE WHEN value=?3 THEN ?4 ELSE value END FROM meta WHERE key=?2").bind(`shop-reference:${id}`, `shop-reference:${source.id}`,source.id,id),
854
977
  env.DB.prepare('INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) VALUES (?1, ?2, 0, ?3, ?4, ?5, ?6, ?7) ON CONFLICT DO NOTHING').bind(`${id}_0`, id, item.id, quantity, amount / quantity, amount, lines[0].snapshot),
978
+ ...(booking ? [bookingStatement(env,{...booking,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
855
979
  ]);
856
980
  order = await orderBySession(env, session.id);
981
+
857
982
  }
858
983
  if (!order) return false;
859
984
  if (order.paid_at === null) await env.DB.batch([
@@ -863,6 +988,40 @@ async function bindLinkSession(env, session, mode) {
863
988
  return true;
864
989
  }
865
990
 
991
+ /** Menu totals are never decoded into quantities. Only signed invoice lines are authoritative. */
992
+ async function bindMenuSession(env,session,source,lines) {
993
+ const first=JSON.parse(lines[0].snapshot),link=first.paymentLink;
994
+ if((session.payment_link?.id??session.payment_link)!==link.id || session.metadata?.revision!==link.revision || session.currency!==source.currency)return false;
995
+ if(!['paid','no_payment_required'].includes(session.payment_status))return false;
996
+ const invoiceId=typeof session.invoice==='string'?session.invoice:session.invoice?.id;
997
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-invoice:${invoiceId}`).first();
998
+ if(!saved)throw new Error('Waiting for signed paid invoice');
999
+ const invoice=JSON.parse(saved.value);
1000
+ if(invoice.livemode!==(source.mode==='live') || invoice.currency!==source.currency || invoice.total!==session.amount_total || invoice.lines?.has_more)throw new Error('Incomplete or mismatched signed invoice');
1001
+ const paidLines=[];
1002
+ for(const l of invoice.lines?.data??[]) {
1003
+ const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
1004
+ if(!line || !Number.isSafeInteger(l.quantity) || l.quantity<1 || l.amount!==JSON.parse(line.snapshot).price*l.quantity)throw new Error('Unknown invoice line');
1005
+ paidLines.push({...line,quantity:l.quantity,amount:l.amount});
1006
+ }
1007
+ if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
1008
+ await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
1009
+ let order=await orderBySession(env,session.id);
1010
+ if(!order) {
1011
+ const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
1012
+ await env.DB.batch([
1013
+ env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,player,checkout_player,item,game,amount,currency,till,mode,status,parent,created_at,updated_at,session) SELECT ?2,player,checkout_player,item,game,?4,currency,till,mode,'started',parent,?5,?5,?3 FROM shop_orders WHERE id=?1").bind(source.id,id,session.id,session.amount_subtotal,Date.now()),
1014
+ env.DB.prepare('INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2').bind(`shop-reference:${id}`,`shop-reference:${source.id}`),
1015
+ ]);order=await orderBySession(env,session.id);
1016
+ }
1017
+ if(order.paid_at===null)await env.DB.batch([
1018
+ env.DB.prepare('DELETE FROM shop_order_lines WHERE order_id=?1 AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?1 AND paid_at IS NULL)').bind(order.id),
1019
+ ...paidLines.map((l,i)=>env.DB.prepare('INSERT INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,snapshot) SELECT ?1,?2,?3,?4,?5,?6,?7,?8 WHERE EXISTS(SELECT 1 FROM shop_orders WHERE id=?2 AND paid_at IS NULL) ON CONFLICT DO NOTHING').bind(`${order.id}_${i}`,order.id,i,l.item,l.quantity,l.unit_amount,l.amount,l.snapshot)),
1020
+ env.DB.prepare('UPDATE shop_orders SET amount=?2 WHERE id=?1 AND paid_at IS NULL').bind(order.id,session.amount_subtotal),
1021
+ ]);
1022
+ return true;
1023
+ }
1024
+
866
1025
  /** A paid checkout: the order is paid, its entitlements granted, its payment recorded. Idempotent. */
867
1026
  export async function paid(env, shop, session, paidAt = Date.now()) {
868
1027
  const o = await orderBySession(env, session.id);
@@ -892,12 +1051,18 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
892
1051
  if (!Number.isSafeInteger(total) || !Number.isSafeInteger(tax) || total < 0 || tax < 0 || total !== Number(o.amount) + tax) return 'mismatch';
893
1052
  let stripeLines = null;
894
1053
  if (lines.length > 1 && tax) {
895
- if (!hasStripeKey(env)) return 'needs-keyed-recovery';
1054
+ if (!hasStripeKey(env)) {
1055
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-invoice:${typeof session.invoice==='string'?session.invoice:session.invoice?.id}`).first();
1056
+ const invoice=saved&&JSON.parse(saved.value);
1057
+ if(!invoice || invoice.lines?.has_more)throw new Error('Waiting for complete signed invoice');
1058
+ stripeLines=invoice.lines.data.map(l=>({quantity:l.quantity,amount_subtotal:l.amount,amount_total:l.amount+(l.taxes??l.tax_amounts??[]).reduce((n,t)=>n+t.amount,0),price:{id:l.price?.id??l.pricing?.price_details?.price,product:JSON.parse(lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price))?.snapshot??'{}').stripeProduct}}));
1059
+ } else {
896
1060
  // Use Stripe's actual tax per line, never spread tax across products with different tax codes.
897
1061
  // https://docs.stripe.com/api/checkout/sessions/line_items
898
1062
  const response = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(session.id)}/line_items`, { limit: 100, expand: ['data.price.product'] });
899
1063
  if (response.has_more || response.data?.length !== lines.length) throw new Error('Stripe line totals are incomplete');
900
1064
  stripeLines = [...response.data];
1065
+ }
901
1066
  }
902
1067
  const writes = [];
903
1068
  let lineTotal = 0;
@@ -912,7 +1077,7 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
912
1077
  let gross = lines.length === 1 ? total : Number(line.amount);
913
1078
  if (stripeLines) {
914
1079
  const index = stripeLines.findIndex((l) => l.quantity === line.quantity && l.amount_subtotal === line.amount &&
915
- (l.price?.product?.metadata?.line === line.id || item.stripeProduct && (l.price?.product?.id ?? l.price?.product) === item.stripeProduct || l.price?.product?.metadata?.homie_item === line.item || l.price?.product?.metadata?.item === line.item));
1080
+ (l.price?.id && l.price.id === item.stripePrice || l.price?.product?.metadata?.line === line.id || item.stripeProduct && (l.price?.product?.id ?? l.price?.product) === item.stripeProduct || l.price?.product?.metadata?.homie_item === line.item || l.price?.product?.metadata?.item === line.item));
916
1081
  if (index < 0) throw new Error('Stripe line identity mismatch');
917
1082
  gross = stripeLines.splice(index, 1)[0].amount_total;
918
1083
  }
@@ -939,6 +1104,7 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
939
1104
  if (['lost', 'won', 'warning_closed'].includes(d.status)) await disputeClosed(env, paymentId, d.status);
940
1105
  }
941
1106
  }
1107
+ if (lines.some(l => l.snapshot && JSON.parse(l.snapshot).interval)) await bindSubscription(env, await orderById(env,o.id), session, shop);
942
1108
  await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
943
1109
  return result.at(-1)?.meta?.changes ? 'paid' : 'already';
944
1110
  }
@@ -0,0 +1,36 @@
1
+ export const TOOL_DATABASE_MIGRATION_FILE='0024_tool_data_versions.sql';
2
+ export const TOOL_DATABASE_MIGRATION='ALTER TABLE tool_data ADD COLUMN version INTEGER NOT NULL DEFAULT 1;\n';
3
+ /** Each mutation is one SQLite statement; callers never hold a JS lock across Workers. */
4
+ export function toolDatabase(DB,namespace) {
5
+ const key=k=>{if(typeof k!=='string'||!k.length||k.length>128)throw new Error('Key must contain 1–128 characters');return k;};
6
+ const decode=r=>r?{value:JSON.parse(r.value),version:r.version}:null;
7
+ const getVersioned=async k=>decode(await DB.prepare('SELECT value,version FROM tool_data WHERE namespace=?1 AND key=?2').bind(namespace,key(k)).first());
8
+ return Object.freeze({
9
+ async get(k){return (await getVersioned(k))?.value??null;},
10
+ getVersioned,
11
+ async put(k,value,{version}={}) {
12
+ const text=JSON.stringify(value);if(text===undefined)throw new Error('Value must be JSON');
13
+ if(version!==undefined&&(!Number.isSafeInteger(version)||version<0))throw new Error('Version must be a nonnegative integer (0 creates)');
14
+ const stmt=version===undefined
15
+ ? DB.prepare('INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value,version=tool_data.version+1 RETURNING version').bind(namespace,key(k),text)
16
+ : version===0 ? DB.prepare('INSERT OR IGNORE INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) RETURNING version').bind(namespace,key(k),text)
17
+ : DB.prepare('UPDATE tool_data SET value=?3,version=version+1 WHERE namespace=?1 AND key=?2 AND version=?4 RETURNING version').bind(namespace,key(k),text,version);
18
+ const row=await stmt.first();return row?{ok:true,version:row.version}:{ok:false,conflict:true};
19
+ },
20
+ async increment(k,amount=1) {
21
+ if(!Number.isFinite(amount))throw new Error('Increment must be finite');
22
+ const row=await DB.prepare(`INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,json(?3)) ON CONFLICT(namespace,key) DO UPDATE SET value=json(json_extract(tool_data.value,'$')+?3),version=tool_data.version+1 WHERE json_type(tool_data.value) IN ('integer','real') RETURNING value,version`).bind(namespace,key(k),amount).first();
23
+ if(!row)throw new Error('Increment requires a numeric value');return decode(row);
24
+ },
25
+ async append(k,value) {
26
+ const text=JSON.stringify(value);if(text===undefined)throw new Error('Append requires JSON');
27
+ const row=await DB.prepare(`INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,json_array(json(?3))) ON CONFLICT(namespace,key) DO UPDATE SET value=json_insert(tool_data.value,'$[#]',json(?3)),version=tool_data.version+1 WHERE json_type(tool_data.value)='array' RETURNING value,version`).bind(namespace,key(k),text).first();
28
+ if(!row)throw new Error('Append requires an array value');return decode(row);
29
+ },
30
+ async list({prefix='',after='',limit=100}={}) {
31
+ if(typeof prefix!=='string'||typeof after!=='string'||!Number.isSafeInteger(limit)||limit<1)throw new Error('List requires a string prefix/cursor and positive page size');
32
+ const rows=(await DB.prepare('SELECT key,value,version FROM tool_data WHERE namespace=?1 AND substr(key,1,?2)=?3 AND key>?4 ORDER BY key LIMIT ?5').bind(namespace,prefix.length,prefix,after,limit+1).all()).results;
33
+ return {items:rows.slice(0,limit).map(r=>({key:r.key,...decode(r)})),next:rows.length>limit?rows[limit-1].key:null};
34
+ },
35
+ });
36
+ }
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.50.6';
2
+ export const STUDIO_VERSION_TAG = '0.51.0';