Ctrl K
ComunidadGuías…

harvest-feed.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'use strict';
// Outbound harvest feed — push a summary of what has been harvested and what is
// coming to a URL you configure. This module opens connections; it never accepts
// any. No inbound endpoint, no open port, no certificate on this side.
//
// Why a lab might want this: the harvest numbers already live here, and the
// places that need them (your own website, a CSA/box scheme, a co-op listing, a
// chat bot answering "what do you have today?") are somewhere else. Copying them
// by hand goes stale within a day. Pointing those systems at this server instead
// would mean exposing the lab machine to the internet — the wrong trade for a
// handful of numbers.
//
//     lab machine  ──POST(signed)──▶  whatever you run  ──▶  customers
//                  ◀──── reply ─────
//
// If this machine is off, the receiver keeps the last payload it got; its
// consumers see older numbers instead of an outage.
//
// ── What comes back ──────────────────────────────────────────────────────────
//
// The reply to that POST, and nothing else. It may carry `pickups` — collection
// slots the receiver has taken bookings for — which are stored here and shown
// on the Pickups page. Note what this does and does not change:
//
//   still true   nothing can reach this machine unbidden. There is no listener,
//                no port and no route in; the reply exists only because this
//                side asked a question and is still holding the socket open.
//   now false    "nothing comes back". Data from the far end does reach the
//                database, which makes the reply a trust boundary — see "The
//                reply" further down for what that costs and what it buys.
//
// A push is finished the moment the receiver answers 2xx. Whatever is in the
// body is a second, separate question, and a bad answer to it never turns a
// delivered payload into a failed one.
//
// ── What leaves the building ─────────────────────────────────────────────────
//
// Species, optionally strain, gram totals, and dates. No batch ids, no bag ids,
// no customers, no scan history, no notes. The payload is small on purpose:
// anything that leaves is something you have to reason about, and a summary is
// far easier to reason about than a dump.
//
// ── What this module deliberately does NOT do ────────────────────────────────
//
// It does not estimate yields. Planned entries carry a species and a date, never
// an amount: how much a block will actually give varies too much between flushes
// to put a number on it, and a number that arrives at a customer becomes a
// promise. Recorded harvests are measured, so those carry grams.
//
// It does not subtract reservations. A lab that has promised half its Thursday
// harvest to a restaurant should publish the remainder, not the total — but what
// counts as promised is a business rule that differs per lab and is not tracked
// here. Do that subtraction in the receiving system, where the commitments live.
//
// ── Configuration ────────────────────────────────────────────────────────────
//
// Normally: Settings → Harvest feed, in the browser. That writes one row and
// restarts the timer; nothing needs a shell or a restart of the server.
//
// The environment variables below still work and are the fallback whenever the
// stored config is off. They exist for installs where the configuration is baked
// into an image or handled by whatever starts the process — there, a value that
// can be changed through the web UI is the wrong shape.
//
// One place wins at a time, and which one is visible in Settings. Splitting a
// single setting across two sources is how you end up with a feed pointing
// somewhere nobody can find.
//
//   HARVEST_WEBHOOK_URL           where to POST. Required — unset means off.
//   HARVEST_WEBHOOK_SECRET        HMAC key. Required; see "Signature" below.
//   HARVEST_WEBHOOK_INTERVAL_MIN  how often, in minutes (default 15, min 1).
//   HARVEST_WEBHOOK_FRESH_DAYS    how far back a harvest still counts as
//                                 available (default 3). Fresh produce; set it
//                                 to what your product actually keeps.
//   HARVEST_WEBHOOK_PLANNED_DAYS  how far ahead to report upcoming batches
//                                 (default 28). 0 turns the planned block off.
//   HARVEST_WEBHOOK_LEAD_DAYS     days between a batch's due date (end of
//                                 incubation) and the first expected flush
//                                 (default 0). Species-dependent; yours is
//                                 whatever your records show.
//   HARVEST_WEBHOOK_STRAIN        1 (default) includes strain names, 0 sends
//                                 only the species.
//   HARVEST_WEBHOOK_SITE          free-form label, passed through untouched.
//                                 Useful when several sites post to one
//                                 receiver.
//   HARVEST_WEBHOOK_PACK_SIZES    the portions you hand a release out in, in
//                                 grams: "250,500,1000". One ladder for every
//                                 species — see `packSizes` below. Unset means
//                                 no instruction, and the receiver decides.
//   HARVEST_WEBHOOK_TIMEOUT_MS    per attempt (default 15000).
//
// ── Signature ────────────────────────────────────────────────────────────────
//
// Every request carries:
//
//   X-Meistertracker-Timestamp: <unix seconds>
//   X-Meistertracker-Signature: sha256=<hex>
//
// where the hex is HMAC-SHA256 over `${timestamp}.${body}` using the shared
// secret. Signing the timestamp *with* the body is what makes a captured request
// useless later: the receiver rejects anything older than its own tolerance, and
// the timestamp cannot be edited without breaking the signature. Same
// construction as this server's own GitHub webhook check, in the other
// direction.
//
// The secret is not optional. An unsigned feed is one anyone can forge, and a
// forged "we have 40 kg" is worse than no feed at all.

const crypto = require('crypto');

// Guard against a hung POST stacking up behind a short interval. One in flight
// at a time; a skipped tick is harmless because the next one carries the same
// (or fresher) numbers — this is a snapshot, not an event log.
let inFlight = false;
let timer = null;

// Fassung 1 carried `harvested`, and a receiver was free to publish it. Fassung 2
// says a `released` list is present and that it, not `harvested`, is what may be
// offered for sale. Ignoring that difference means publishing produce the grower
// deliberately kept back, so it is a version bump and not a new optional field.
//
// ⚠️ **Fassung 1 is no longer sent.** It used to be the default, with release
// mode as a switch, and that switch was the bug: a harvest total is a number
// that stops being true the moment something is sold at the stall, and no
// setting makes it true again. Worse, it was the *quiet* option — a lab that
// never found the checkbox published raw stock and looked fine doing it. What
// may be sold is now always a decision somebody made by hand, and an empty
// `released` list means exactly that: nothing is for sale yet.
//
// `VERSION` stays as the number Fassung 1 carried, so a receiver reading old
// records still knows what it is looking at.
const VERSION = 1;
const VERSION_RELEASE = 2;
const ATTEMPTS = 3;

// ── Pack sizes ───────────────────────────────────────────────────────────────
//
// `released` says how much may be sold. `packSizes` says in what portions it is
// handed over: [250, 500, 1000] — grams, ascending, one ladder for every
// species.
//
// It is a separate statement because it answers a different question, and the
// far end was answering it alone. A shop has to offer *some* amount, so without
// this it invents a ladder — 250 g and multiples of it, say. That is a
// fair guess and still a guess: a farm packing 400 g trays has no way to say so.
//
// **One list, not one per species.** Portioning follows the packing bench —
// which trays are on the shelf, what the scale steps in — not the mushroom in
// the tray. Per species it would be a field to fill in for every new Sorte, for
// an answer that is the same every time, and the first one forgotten would be a
// species a shop quietly offers in the wrong sizes.
//
// **Absent means absent.** No sizes set, no field in the payload — and a
// receiver has nothing to sell produce in. That is the intended direction, and
// it is deliberately not a quiet one: a receiver is meant to stop taking
// orders entirely rather than fall back on a ladder of its own. A fallback
// is the problem — multiples of 250 g that nobody decided on, sitting in a
// shop window looking exactly as authoritative as a
// figure somebody chose. Same lesson as the release switch above.
//
// Not a version bump: a receiver that ignores the field is correct, only less
// specific. Fassung 2 stays Fassung 2.
const PACK_MIN_G = 25;
const PACK_MAX_G = 25_000;
const PACK_MAX_COUNT = 8;

/**
 * The canonical form of a pack-size list: ascending, deduplicated, in range.
 *
 * Takes what a form, an environment variable or an API body might hold — an
 * array, "250,500,1000", a single number — and returns integers. The one place
 * that decides what a size may be; everything else stores or sends the result.
 *
 * Silent about what it drops, on purpose. This runs on the way in from a UI
 * that shows the result back, and on every read of a stored row where throwing
 * would take the settings page down over a stray comma.
 */
function packSizes(raw) {
  const parts = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
  const out = [];
  for (const part of parts) {
    // Number and not parseInt. parseInt('500g') is 500 and parseInt('2 kg') is
    // 2 — it reads until it stops understanding and keeps what it had, which
    // turns a unit somebody typed out of habit into a portion size two grams
    // large. Number says NaN to both, and NaN is dropped.
    const n = Number(String(part).trim());
    if (!Number.isInteger(n) || n < PACK_MIN_G || n > PACK_MAX_G) continue;
    if (!out.includes(n)) out.push(n);
  }
  return out.sort((a, b) => a - b).slice(0, PACK_MAX_COUNT);
}

/**
 * The URL check, shared by both config sources.
 *
 * Plain HTTP would put the payload and the signature on the wire in clear.
 * Loopback stays allowed so the thing can be tried out against a local receiver
 * before a certificate exists.
 */
function checkUrl(url, label) {
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error(label + ' is not a URL: ' + url);
  }
  // Credentials in the URL would be copied into the config row and, from there,
  // into every "Harvest feed started" log line. The secret is deliberately kept
  // out of the logs; a password smuggled in via the URL would walk straight
  // back in. The receiver authenticates the HMAC, so it never needs these.
  if (parsed.username || parsed.password)
    throw new Error(label + ' must not carry credentials in the URL — the feed authenticates with the shared secret');
  // URL.hostname keeps the brackets on an IPv6 literal ('[::1]', not '::1'), so
  // comparing against the bare form never matched and http://[::1]:PORT/ was
  // rejected as "not https" — the one loopback address this is meant to allow.
  const host = parsed.hostname;
  const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
  if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback))
    throw new Error(label + ' must be https (http is allowed for localhost only): ' + url);
}

/** Read config from an env-like object. Returns null when the feed is off. */
function readConfig(env) {
  const url = String(env.HARVEST_WEBHOOK_URL || '').trim();
  if (!url) return null;

  const secret = String(env.HARVEST_WEBHOOK_SECRET || '').trim();
  if (!secret)
    throw new Error('HARVEST_WEBHOOK_URL is set but HARVEST_WEBHOOK_SECRET is not — refusing to send unsigned');

  checkUrl(url, 'HARVEST_WEBHOOK_URL');

  return {
    url,
    secret,
    intervalMs: Math.max(1, num(env.HARVEST_WEBHOOK_INTERVAL_MIN, 15)) * 60 * 1000,
    freshDays: Math.max(0, num(env.HARVEST_WEBHOOK_FRESH_DAYS, 3)),
    plannedDays: Math.max(0, num(env.HARVEST_WEBHOOK_PLANNED_DAYS, 28)),
    leadDays: Math.max(0, num(env.HARVEST_WEBHOOK_LEAD_DAYS, 0)),
    // Strain names are a competitive detail for some labs and the whole point of
    // the listing for others ("Blue oyster" is not "oyster"). Default on,
    // because a receiver can always drop a field it does not want, while it
    // cannot invent one it never got.
    strain: String(env.HARVEST_WEBHOOK_STRAIN ?? '1') !== '0',
    site: String(env.HARVEST_WEBHOOK_SITE || '').trim(),
    packSizes: packSizes(env.HARVEST_WEBHOOK_PACK_SIZES),
    timeoutMs: Math.max(1000, num(env.HARVEST_WEBHOOK_TIMEOUT_MS, 15000)),
    source: 'env'
  };
}

/** Turn a stored row (db.getHarvestFeedCfg) into the same shape. */
function storedConfig(row) {
  if (!row || !row.enabled) return null;
  const url = String(row.url || '').trim();
  if (!url) throw new Error('Harvest feed is enabled but no URL is set');
  const secret = String(row.secret || '').trim();
  if (!secret) throw new Error('Harvest feed is enabled but no secret is set — refusing to send unsigned');
  checkUrl(url, 'Harvest feed URL');

  return {
    url,
    secret,
    intervalMs: Math.max(1, Number(row.intervalMin) || 15) * 60 * 1000,
    freshDays: Math.max(0, Number(row.freshDays) || 0),
    plannedDays: Math.max(0, Number(row.plannedDays) || 0),
    leadDays: Math.max(0, Number(row.leadDays) || 0),
    strain: row.strain !== false,
    site: String(row.site || '').trim(),
    // Re-checked, not trusted. The stored text is canonical when it was written
    // through the settings page, and this file must still be right for a row
    // somebody edited with sqlite3.
    packSizes: packSizes(row.packSizes),
    timeoutMs: 15000,
    source: 'db'
  };
}

/**
 * Which config actually applies.
 *
 * Stored config wins when it is on, environment otherwise. Not merged: a URL
 * from one place and a secret from the other is a configuration nobody can read
 * off a single screen, and the failure mode is a feed posting to a stale
 * receiver with nothing in the UI to explain it.
 */
function resolveConfig({ database, env, dbApi }) {
  if (database && dbApi && typeof dbApi.getHarvestFeedCfg === 'function') {
    const fromDb = storedConfig(dbApi.getHarvestFeedCfg(database));
    if (fromDb) return fromDb;
  }
  return readConfig(env || process.env);
}

function num(raw, fallback) {
  const n = parseInt(raw, 10);
  return Number.isFinite(n) ? n : fallback;
}

/** YYYY-MM-DD, `days` away from `from`. Dates only — no clock, no timezone. */
function shiftDate(from, days) {
  const d = new Date(from.getTime());
  d.setUTCDate(d.getUTCDate() + days);
  return d.toISOString().slice(0, 10);
}

/**
 * Today, in the machine's own timezone. Twin of localDay() in db.js.
 *
 * Deliberately not the UTC day the window arithmetic above uses. A window that
 * ends two hours early costs nothing; a release does not expire until the end of
 * the day someone typed, and east of Greenwich the UTC day ends first — "valid
 * until Saturday" would keep selling into Sunday morning. The direction of the
 * error is what decides this, not tidiness.
 */
function localDay(at) {
  const p = (n) => String(n).padStart(2, '0');
  return `${at.getFullYear()}-${p(at.getMonth() + 1)}-${p(at.getDate())}`;
}

// How far ahead pickup windows are published. Not configurable, unlike the
// harvest horizons: those answer "how much of our production do we show", which
// is a business decision per lab. This one answers "how far ahead can somebody
// book", and four weeks is long enough for any market rhythm while keeping a
// recurring block from expanding into thousands of rows.
const PICKUP_WINDOW_DAYS = 28;
// A market lab has a handful of windows a week; four weeks of six-a-day is 168.
// Anything past this is a runaway recurrence rather than a real schedule.
const MAX_PICKUP_WINDOWS = 500;
// The zone the calendar's timed events are written in — see customEventToVEVENT,
// which hardcodes the same one on every VEVENT. Sent explicitly so the receiver
// never has to guess: "09:00" without a zone is not a time, and a window that
// silently moves by an hour in October is a missed handover.
const PICKUP_TZ = 'Europe/Berlin';

/** Comma-separated exception dates, as stored. Twin of db.parseExceptionDates. */
function exceptionSet(raw) {
  if (!raw) return new Set();
  return new Set(
    String(raw)
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean)
  );
}

/** UTC-only date arithmetic: YYYY-MM-DD in, YYYY-MM-DD out. */
function addDaysStr(dateStr, n) {
  const d = new Date(dateStr + 'T00:00:00Z');
  d.setUTCDate(d.getUTCDate() + n);
  return d.toISOString().slice(0, 10);
}

/**
 * n months after `base`, clamping to the target month's last day.
 *
 * ⚠️ Computed from the base every time, never cumulatively from the previous
 * occurrence: adding a month to 31 January overflows to 3 March, which both
 * skips February and shifts every later occurrence onto the 3rd. Twin of
 * addMonthsClamped() in app.js, which carries the same warning.
 */
function addMonthsStr(baseStr, n) {
  const base = new Date(baseStr + 'T00:00:00Z');
  const m = base.getUTCMonth() + n;
  const y = base.getUTCFullYear() + Math.floor(m / 12);
  const tm = ((m % 12) + 12) % 12;
  const lastDay = new Date(Date.UTC(y, tm + 1, 0)).getUTCDate();
  const day = Math.min(base.getUTCDate(), lastDay);
  return new Date(Date.UTC(y, tm, day)).toISOString().slice(0, 10);
}

/**
 * The dates a calendar event actually falls on, inside [from, until].
 *
 * A recurring block is one row with many occurrences, and `exception_dates`
 * means "this one does not exist" — a holiday, or a market that was cancelled.
 */
function occurrenceDates(ev, from, until) {
  const skip = exceptionSet(ev.exception_dates);
  const out = [];
  if (!ev.recurrence) {
    if (ev.start_date >= from && ev.start_date <= until && !skip.has(ev.start_date)) out.push(ev.start_date);
    return out;
  }
  const hardEnd = ev.recurrence_until || null;
  let cur = ev.start_date;
  let monthIdx = 0;
  let guard = 0;
  while (guard++ < 1000) {
    if (cur > until) break;
    if (hardEnd && cur > hardEnd) break;
    if (cur >= from && !skip.has(cur)) out.push(cur);
    if (ev.recurrence === 'daily') cur = addDaysStr(cur, 1);
    else if (ev.recurrence === 'weekly') cur = addDaysStr(cur, 7);
    else if (ev.recurrence === 'monthly') cur = addMonthsStr(ev.start_date, ++monthIdx);
    else break;
  }
  return out;
}

/**
 * The id one window is known by, on both sides, for good.
 *
 * ⚠️ Event id **plus occurrence date**. A recurring block is a single row, so
 * keying on the row alone makes every Friday the same window — one booking then
 * occupies all of them, and the mistake only shows up at a handover point.
 *
 * Kept inside `[A-Za-z0-9_-]{1,64}`, which is the narrowest charset any receiver
 * is likely to impose (the one this feed is built against does). Ids from
 * imported CalDAV events can carry `@` and `.`, so they are folded — and folding
 * two different ids onto the same string would silently merge two windows, hence
 * the digest tail whenever anything had to change.
 */
function windowId(eventId, date) {
  const raw = String(eventId);
  const safe = raw.replace(/[^A-Za-z0-9_-]/g, '-');
  if (safe === raw && safe.length <= 43) return safe + '_' + date;
  const digest = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
  return safe.slice(0, 34) + '-' + digest + '_' + date;
}

/**
 * When goods can be collected — the lab's opening times, stated positively.
 *
 * ⚠️ **Title and description are not read here, and must not be.** They are free
 * text in an internal tool and will end up holding staff notes ("Erika covers,
 * Max is off"). What leaves is the shape of the window: when, where, how many.
 * A receiver composes its own label from that.
 *
 * The list is complete for its horizon and replaces whatever the receiver holds
 * — it is not a set of changes. Empty therefore means "nothing bookable", which
 * is a real answer and must survive the trip; the field is always present.
 */
function buildPickupWindows(database, at) {
  const rows = collectPickupWindows(database, at);
  if (rows === null) return null;
  // Named one by one rather than spread with the internal fields removed. This
  // is the boundary the whole feature is careful about, and an allow-list keeps
  // it careful: a field added upstream has to be added here on purpose before it
  // can leave the building. `event` is the one dropped today — the lab's own
  // screens key on it (see pickupWindowIndex), and it is nobody else's business.
  return rows.map((w) =>
    trim({
      id: w.id,
      date: w.date,
      from: w.from,
      to: w.to,
      tz: w.tz,
      place: w.place ?? null,
      capacity: w.capacity ?? null
    })
  );
}

/**
 * The same windows, with the row each one came from.
 *
 * For this end only. The warning before a booked window is moved has to find
 * the bookings, and a booking names the window by the id below — so the mapping
 * from "calendar event on this date" to "that id" must exist exactly once, and
 * this is it.
 */
function pickupWindowIndex(database, at) {
  return collectPickupWindows(database, at) || [];
}

function collectPickupWindows(database, at) {
  const from = localDay(at);
  const until = addDaysStr(from, PICKUP_WINDOW_DAYS);
  let rows;
  try {
    rows = database
      .prepare(
        `SELECT e.id, e.start_date, e.start_time, e.end_time, e.all_day,
                e.recurrence, e.recurrence_until, e.exception_dates, e.pickup_capacity,
                l.name AS place
           FROM calendar_events e
           LEFT JOIN pickup_locations l ON l.id = e.location_id
          WHERE e.category = 'pickup'
          ORDER BY e.start_date, e.start_time`
      )
      .all();
  } catch {
    // A database that predates the pickup columns. The harvest numbers are the
    // point of this payload and must still go out.
    return null;
  }

  const windows = [];
  for (const ev of rows) {
    // A window without a clock is not a window: "Saturday" cannot be booked
    // into, and inventing 00:00–23:59 would publish an opening time nobody
    // stated. The editor keeps a pickup entry from being all-day; this is the
    // guard for rows that predate it or arrived over CalDAV.
    if (ev.all_day === 1 || !ev.start_time || !ev.end_time) continue;
    for (const date of occurrenceDates(ev, from, until)) {
      windows.push(
        trim({
          id: windowId(ev.id, date),
          event: ev.id,
          date,
          from: ev.start_time,
          to: ev.end_time,
          tz: PICKUP_TZ,
          // The name alone. The address stays in the building — it is on the
          // location row for the lab's own calendar clients, not for this.
          place: ev.place || null,
          // Absent means uncapped. 0 is a different answer — the window exists
          // and takes nobody — and survives trim() because only null does not.
          capacity: ev.pickup_capacity === null || ev.pickup_capacity === undefined ? null : ev.pickup_capacity
        })
      );
      if (windows.length >= MAX_PICKUP_WINDOWS) break;
    }
    if (windows.length >= MAX_PICKUP_WINDOWS) break;
  }
  windows.sort((a, b) => (a.date + a.from).localeCompare(b.date + b.from) || a.id.localeCompare(b.id));
  return windows;
}

/**
 * The payload, built straight from the database.
 *
 * Split in two on purpose, and the two halves mean different things:
 *
 *   harvested — weighed and recorded. A number you can sell against.
 *   planned   — a batch whose incubation ends soon. A hint, with no amount.
 *
 * Anything that blurs the two ends up promising produce that does not exist yet.
 */
function buildPayload(database, cfg, now) {
  const at = now || new Date();
  const since = shiftDate(at, -cfg.freshDays) + 'T00:00:00';

  // Aggregate in SQL rather than pulling every harvest row into JS: on a server
  // with years of records that is the difference between a few rows and all of
  // them, every interval.
  const harvested = database
    .prepare(
      `SELECT species, strain, SUM(grams) AS grams, MAX(time) AS last
         FROM harvests
        WHERE time >= ? AND species IS NOT NULL AND species <> ''
        GROUP BY species, strain
        HAVING SUM(grams) > 0
        ORDER BY species, strain`
    )
    .all(since)
    .map((r) =>
      trim({
        species: r.species,
        strain: cfg.strain ? r.strain || null : null,
        grams: Math.round(r.grams),
        lastHarvest: r.last || null
      })
    );

  const planned = [];
  if (cfg.plannedDays > 0) {
    const today = at.toISOString().slice(0, 10);
    // `due` is the end of incubation, not the harvest — hence leadDays. Compare
    // against the un-shifted window and shift the reported date, so a lead time
    // never silently widens how far ahead we look.
    const until = shiftDate(at, cfg.plannedDays);
    // ⚠️ `date(b.due)`, not `b.due`. The column holds a full timestamp — it is
    // written with toISOString() — so `b.due + 'T00:00:00Z'` produced
    // "2026-08-05T14:23:11.123ZT00:00:00Z", an invalid Date, and the
    // toISOString() below threw "Invalid time value". That took the *whole*
    // payload down: one planned batch and the harvested half never left either.
    //
    // Normalising in SQL fixes two things at once. Rows whose date cannot be
    // read yield NULL and drop out instead of throwing, and `BETWEEN` on a
    // plain date stops cutting the last day short — a timestamp on the final
    // day sorts after the bare date and was silently excluded.
    // GROUP BY, because one entry per batch is a different statement than one
    // entry per offer. Four blocks of the same species due the same day used to
    // produce four identical entries, so anyone reading the feed could count
    // batches — and this feed deliberately reports no amounts at all. It is a
    // cadence signal rather than a volume one (batch sizes differ severalfold),
    // but it is still a number nobody meant to publish, and on the receiving
    // end it lists the same offer four times.
    //
    // `harvested` already draws this line: one entry per species, on the
    // grounds that splitting by batch would "split one offer in two".
    const rows = database
      .prepare(
        `SELECT b.species, b.strain, date(b.due) AS due_date
           FROM batches b
          WHERE b.batch_type = 'block'
            AND date(b.due) BETWEEN ? AND ?
            AND b.species IS NOT NULL AND b.species <> ''
            AND EXISTS (SELECT 1 FROM bags g WHERE g.batch_id = b.batch_id)
            AND NOT EXISTS (SELECT 1 FROM harvests h WHERE h.batch = b.batch_id)
          GROUP BY b.species, b.strain, date(b.due)
          ORDER BY date(b.due), b.species`
      )
      .all(today, until);
    const seen = new Set();
    for (const r of rows) {
      // Belt and braces: date() already filtered the unreadable ones, and a
      // single bad row must never cost the whole feed.
      const tag = new Date(r.due_date + 'T00:00:00Z');
      if (Number.isNaN(tag.getTime())) continue;
      const entry = trim({
        species: r.species,
        strain: cfg.strain ? r.strain || null : null,
        expectedFrom: shiftDate(tag, cfg.leadDays)
      });
      // The SQL grouping is not enough on its own: with strain names switched
      // off, two strains of one species collapse into the same entry only after
      // the name is dropped here. Dedupe on what actually goes out.
      const key = JSON.stringify(entry);
      if (seen.has(key)) continue;
      seen.add(key);
      planned.push(entry);
    }
  }

  const payload = {
    version: VERSION_RELEASE,
    generatedAt: at.toISOString(),
    freshDays: cfg.freshDays,
    harvested,
    planned,
    // A separate list, not a field on `harvested`. The two answer different
    // questions — what came off the racks, and what may be sold — and only the
    // first is production data. Keeping them apart also lets a release outlive
    // its harvest window: set two kilos aside on Monday for a Saturday market
    // and by Thursday the harvest has aged out of `freshDays`, while the crate
    // is still standing there. The human who put it there is the better source.
    //
    // Always present, empty list included: "nobody has released anything" is an
    // answer, and a receiver has to be able to tell it apart from "this lab does
    // not do releases". The second reading is what let raw harvest totals reach
    // a shop window.
    released: database
      .prepare(
        `SELECT species, grams, valid_until AS validUntil
           FROM harvest_release
          WHERE grams > 0 AND (valid_until IS NULL OR valid_until >= ?)
          ORDER BY species`
      )
      .all(localDay(at))
      .map((r) => trim({ species: r.species, grams: Math.round(r.grams), validUntil: r.validUntil || null }))
  };

  // Alongside `released`, never inside it: the same ladder for every species is
  // the whole point, and a copy per entry invites a receiver to read the two as
  // independent. Omitted when nothing is set — see "Pack sizes" up top.
  const sizes = packSizes(cfg.packSizes);
  if (sizes.length) payload.packSizes = sizes;

  // When goods can be collected. Always present once this build can compute it,
  // empty list included: that is what tells a receiver the difference between
  // "this lab publishes no windows" and "this lab's software cannot state any",
  // and only the second one justifies falling back to a hand-kept list.
  //
  // No version bump for it. Fassung 2 changed what an existing field *meant*, so
  // ignoring it was unsafe; a new field is not, and bumping would have made the
  // receiver's deployment a flag day — it rejects versions it does not know.
  const windows = buildPickupWindows(database, at);
  if (windows) payload.pickupWindows = windows;

  // The ids we hold and have not confirmed yet, so the receiver can stop
  // repeating them. Omitted entirely when there is nothing to confirm: a
  // receiver that knows nothing about pickups should never see the field, and
  // an empty array is a claim ("I have none") where absence is silence.
  //
  // Bookings and withdrawals go in the same list. From the receiver's side
  // there is one question — did the other end take this in? — and it has to
  // stop repeating either kind on the same answer.
  //
  // Sending is not confirming. These are marked as confirmed only after a push
  // carrying them actually succeeded — see receive().
  //
  // ⚠️ Twin of db.unackedPickupIds(). A test asserts the two agree; changing
  // what counts as unconfirmed means changing it in both.
  try {
    const done = database
      .prepare(
        `SELECT id FROM (
             SELECT id, received AS ord FROM pickups WHERE acked_at IS NULL
             UNION ALL
             SELECT id, at AS ord FROM pickup_cancellations WHERE acked_at IS NULL
           ) ORDER BY ord LIMIT ?`
      )
      .all(MAX_ACK_PER_PUSH)
      .map((r) => r.id);
    if (done.length) payload.pickupsDone = [...new Set(done)];
  } catch {
    // No pickups tables yet — a database mid-migration, or one this feature has
    // never run against. The harvest numbers are the point of this payload and
    // they must still go out.
  }

  if (cfg.site) payload.site = cfg.site;
  return payload;
}

/** Drop null fields so the receiver never has to distinguish absent from null. */
function trim(obj) {
  const out = {};
  for (const [k, v] of Object.entries(obj)) if (v !== null && v !== undefined) out[k] = v;
  return out;
}

/** `sha256=<hex>` over `${timestamp}.${body}` — see "Signature" up top. */
function sign(secret, timestamp, body) {
  return 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
}

// ── The reply ────────────────────────────────────────────────────────────────
//
// Everything above this line goes out. What follows comes back, and it is the
// only place in this program where data from the far end travels towards the
// lab machine — so it is treated as hostile until every field has proven
// otherwise. Four rules, in order of how much they save you:
//
//   1. A push that got a 2xx has succeeded. What is in the body is a second and
//      separate question, and no answer to it may turn a delivered payload into
//      a failed one. The numbers are out; that was the job.
//   2. The body is capped *before* it is read, not measured after. A receiver
//      that answers with a gigabyte should cost a few kilobytes and a log line.
//   3. Anything not named below is dropped — not stored-and-ignored, dropped.
//   4. Every length and every count is bounded, because "the receiver would not
//      do that" is not a property of a network.

const REPLY_MAX_BYTES = 64 * 1024;
const MAX_PICKUPS = 200;
// Withdrawals are bare ids, so a reply can hold as many as it has bookings —
// the whole open list could be cancelled at once.
const MAX_CANCELLED = 200;
const MAX_ITEMS_PER_PICKUP = 50;
// How many confirmations ride along on one push. A backlog larger than this
// drains over the following ticks rather than inflating a single request.
const MAX_ACK_PER_PUSH = 500;
// The receiver assigns these. Narrow on purpose: an id is a key, not a label,
// and this covers what a key needs while leaving nothing to escape into.
const ID_RE = /^[A-Za-z0-9_.:@#/+-]{1,128}$/;
// Local wall-clock — no offset, no Z. The zone travels in its own field, and a
// value that looks like an instant would invite exactly the conversion that
// must not happen.
const LOCAL_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/;
// "Europe/Berlin", "UTC".
const ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+){0,2}$/;

/** A bounded string, or null. Control characters never make it through. */
function str(v, max) {
  if (typeof v !== 'string') return null;
  const s = v.replace(/[\x00-\x1f\x7f]/g, ' ').trim();
  return s ? s.slice(0, max) : null;
}

/**
 * One pickup, rebuilt field by field from an untrusted object.
 *
 * Rebuilt, not filtered: the result is a fresh object holding only what was
 * recognised, so a field nobody thought about cannot ride along to whatever
 * reads this next. Returns null when the entry is unusable, which for anything
 * without a valid id it is — the id is what makes storing it twice safe.
 */
function onePickup(raw) {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
  const id = typeof raw.id === 'string' ? raw.id.trim() : '';
  if (!ID_RE.test(id)) return null;

  const out = { id, overbooked: raw.overbooked === true };
  const order = str(raw.order, 64);
  if (order) out.order = order;
  const slot = str(raw.slot, 64);
  if (slot) out.slot = slot;
  const slotText = str(raw.slotText, 120);
  if (slotText) out.slotText = slotText;
  const place = str(raw.place, 120);
  if (place) out.place = place;

  const from = str(raw.from, 32);
  if (from && LOCAL_TIME_RE.test(from)) out.from = from;
  const to = str(raw.to, 32);
  if (to && LOCAL_TIME_RE.test(to)) out.to = to;
  const zone = str(raw.zone, 64);
  if (zone && ZONE_RE.test(zone)) out.zone = zone;

  const items = [];
  if (Array.isArray(raw.items)) {
    for (const it of raw.items.slice(0, MAX_ITEMS_PER_PICKUP)) {
      if (!it || typeof it !== 'object') continue;
      const kind = str(it.kind, 80);
      // A line with no name is not a line, and a weight that is negative or
      // measured in tonnes is a bug at the far end. Either would end up on a
      // picking list, so neither is stored.
      if (!kind) continue;
      const grams = Number(it.grams);
      if (!Number.isFinite(grams) || grams < 0 || grams > 10_000_000) continue;
      // `kind` is what the customer was shown ("Igelstachelbart"); `species` is
      // the string this database keys a release on ("Igelstachelbart (IGS)").
      // They differ on purpose — a shop does not print our shorthand — and the
      // mapping between them lives in the receiver, not here.
      //
      // Without it the two halves of the Pickups page cannot be added up: the
      // release table is keyed one way and the bookings the other, so nobody
      // can see how much of a release is already spoken for. Optional, because
      // a receiver that predates the field is not wrong, only less useful.
      const species = str(it.species, 120);
      items.push(species ? { kind, species, grams: Math.round(grams) } : { kind, grams: Math.round(grams) });
    }
  }
  if (items.length) out.items = items;
  return out;
}

/**
 * Validate a reply body. Never throws; returns whatever survived.
 *
 * `dropped` is not decoration for a diagnostics screen. A receiver whose
 * pickups all fail validation looks exactly like a receiver that has none, and
 * those two need very different fixing.
 */
function parseReply(text) {
  const empty = { pickups: [], cancelled: [], dropped: 0 };
  if (!text) return empty;
  let raw;
  try {
    raw = JSON.parse(text);
  } catch {
    // Not repeated into the log: a body that is not JSON is frequently an HTML
    // error page, and the interesting part is that it was not JSON.
    return { ...empty, error: 'reply is not JSON' };
  }
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...empty, error: 'reply is not an object' };

  // The two lists are read independently and neither can take the other down
  // with it. They say different things — this is open, this is withdrawn — and
  // a receiver that garbles one has still told the truth with the other.
  const problems = [];
  let dropped = 0;

  const pickups = [];
  // No pickups field at all is the ordinary case: every receiver written before
  // this existed answers that way, and it is not an error.
  if (raw.pickups !== undefined && raw.pickups !== null) {
    if (!Array.isArray(raw.pickups)) problems.push('pickups is not a list');
    else {
      // Entries past the cap are never examined, only counted.
      const over = Math.max(0, raw.pickups.length - MAX_PICKUPS);
      if (over) problems.push(`reply carried more than ${MAX_PICKUPS} pickups`);
      dropped += over;
      const seen = new Set();
      for (const entry of raw.pickups.slice(0, MAX_PICKUPS)) {
        const p = onePickup(entry);
        if (!p) {
          dropped++;
          continue;
        }
        // The same id twice in one reply: first wins, so which one that is does
        // not depend on the order rows happen to be written in.
        if (seen.has(p.id)) {
          dropped++;
          continue;
        }
        seen.add(p.id);
        pickups.push(p);
      }
    }
  }

  // Withdrawals: a bare list of ids, nothing else. There is nothing to describe
  // about a pickup that is no longer happening.
  const cancelled = [];
  if (raw.pickupsCancelled !== undefined && raw.pickupsCancelled !== null) {
    if (!Array.isArray(raw.pickupsCancelled)) problems.push('pickupsCancelled is not a list');
    else {
      const over = Math.max(0, raw.pickupsCancelled.length - MAX_CANCELLED);
      if (over) problems.push(`reply carried more than ${MAX_CANCELLED} cancellations`);
      dropped += over;
      const seen = new Set();
      for (const entry of raw.pickupsCancelled.slice(0, MAX_CANCELLED)) {
        // Numbers, objects and nulls are not ids. Same shape as everywhere else
        // — an id that would be refused as a booking cannot be honoured as a
        // withdrawal either.
        const id = typeof entry === 'string' ? entry.trim() : '';
        if (!ID_RE.test(id) || seen.has(id)) {
          dropped++;
          continue;
        }
        seen.add(id);
        cancelled.push(id);
      }
    }
  }

  const out = { pickups, cancelled, dropped };
  if (problems.length) out.error = problems.join('; ');
  return out;
}

/**
 * Read at most REPLY_MAX_BYTES, and stop pulling the moment that is passed.
 *
 * Deliberately not res.text(): that reads whatever arrives and only then lets
 * you measure it, which is the wrong order when the point of the measurement is
 * to not have the thing in memory.
 */
async function readCapped(res) {
  const stream = res.body;
  if (stream && typeof stream.getReader === 'function') {
    const reader = stream.getReader();
    const chunks = [];
    let total = 0;
    try {
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        total += value.length;
        if (total > REPLY_MAX_BYTES) {
          // Hang up rather than drain politely. Whatever is still coming is
          // going to be discarded anyway.
          await reader.cancel().catch(() => {});
          return { tooBig: true };
        }
        chunks.push(value);
      }
    } finally {
      try {
        reader.releaseLock();
      } catch {
        // cancel() already released it.
      }
    }
    return { text: Buffer.concat(chunks).toString('utf8') };
  }
  // No stream to meter: a response with no body, or a stand-in in a test.
  if (typeof res.text === 'function') {
    const text = await res.text();
    return text.length > REPLY_MAX_BYTES ? { tooBig: true } : { text };
  }
  return { text: null };
}

/**
 * The whole reply path: content type, size, JSON, fields.
 *
 * Content-Type has to say JSON before a single byte of body is pulled. A
 * receiver answering an API call with an HTML error page is the everyday shape
 * of a misconfigured proxy, and handing that to a parser wastes time at best.
 * A reply with no content type at all is silence, not an error — that is what
 * every receiver written before this feature existed sends back.
 */
async function readReply(res) {
  const headers = res && res.headers;
  const get = headers && typeof headers.get === 'function' ? (k) => headers.get(k) : () => null;
  const ctype = get('content-type');
  if (!ctype) return { pickups: [], dropped: 0 };
  if (!/^application\/(json|[\w.+-]+\+json)\s*(;|$)/i.test(ctype.trim()))
    return { pickups: [], dropped: 0, error: 'reply is ' + str(ctype.split(';')[0], 60) + ', not JSON' };

  const declared = Number(get('content-length'));
  // The cheapest guard there is: the receiver said how big it is, and it is too
  // big, so nothing needs reading.
  if (Number.isFinite(declared) && declared > REPLY_MAX_BYTES)
    return { pickups: [], dropped: 0, error: `reply declared ${declared} bytes, over the ${REPLY_MAX_BYTES} limit` };

  const body = await readCapped(res);
  if (body.tooBig) return { pickups: [], dropped: 0, error: `reply exceeded ${REPLY_MAX_BYTES} bytes` };
  return parseReply(body.text);
}

/**
 * POST once, with retries. Resolves to {ok, status, attempts}; never throws —
 * a feed that crashes the timer it runs on takes the whole feature down until
 * the next restart.
 */
async function post(cfg, payload, deps) {
  const doFetch = (deps && deps.fetch) || globalThis.fetch;
  const sleep = (deps && deps.sleep) || ((ms) => new Promise((r) => setTimeout(r, ms)));
  const body = JSON.stringify(payload);
  let last = null;

  for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
    // A fresh timestamp per attempt: a retry after a 30 s backoff must not
    // arrive already outside the receiver's replay window.
    const timestamp = Math.floor(Date.now() / 1000);
    const controller = new AbortController();
    const abort = setTimeout(() => controller.abort(), cfg.timeoutMs);
    try {
      const res = await doFetch(cfg.url, {
        method: 'POST',
        // The receiver does not get to choose where the report actually lands.
        // Without this, fetch defaults to `follow`: a 308 from the receiver
        // re-sends the POST — body and X-Meistertracker-Signature intact, since
        // only Authorization/Cookie are stripped on an origin change — to
        // whatever host Location names, plain-HTTP hosts on the LAN included.
        // checkUrl only ever runs when the config is saved, so a redirect
        // target is a target nobody vetted. "Push-only, and to exactly the
        // configured address" is the promise; following a hop breaks it.
        redirect: 'manual',
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': 'meistertracker-harvest-feed/' + VERSION,
          'X-Meistertracker-Timestamp': String(timestamp),
          'X-Meistertracker-Signature': sign(cfg.secret, timestamp, body)
        },
        body,
        signal: controller.signal
      });
      if (res.ok) {
        const out = { ok: true, status: res.status, attempts: attempt };
        // Rule 1 up in "The reply", made concrete: the payload is delivered the
        // moment the receiver says 2xx, so reading what it sent back gets its
        // own try/catch and there is no path from in here to ok:false.
        try {
          out.reply = await readReply(res);
        } catch (e) {
          out.reply = { pickups: [], dropped: 0, error: e.message };
        }
        return out;
      }
      // With redirect:'manual' a 3xx arrives here instead of being chased.
      // Retrying cannot help — the receiver is pointing somewhere else and will
      // keep doing so — and each retry is another copy of the payload aimed at
      // an unvetted URL. Stop, and say what to fix.
      if (res.status >= 300 && res.status < 400) {
        return {
          ok: false,
          error: 'HTTP ' + res.status + ' redirect refused — set the feed URL to the receiver’s final address',
          attempts: attempt
        };
      }
      last = 'HTTP ' + res.status;
      // 4xx means the receiver understood us and said no — a bad secret, a
      // wrong path, a payload it rejects. Retrying hammers it without ever
      // changing the outcome. 408 and 429 are the two that do pass with time.
      if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
        return { ok: false, error: last, attempts: attempt };
    } catch (e) {
      last = e.name === 'AbortError' ? `timeout after ${cfg.timeoutMs} ms` : e.message;
    } finally {
      clearTimeout(abort);
    }
    if (attempt < ATTEMPTS) await sleep(attempt * 5000);
  }
  return { ok: false, error: last, attempts: ATTEMPTS };
}

/** Build and send once. Returns the same shape as post(), plus the payload. */
async function sendOnce(database, cfg, deps) {
  const payload = buildPayload(database, cfg, deps && deps.now);
  const result = await post(cfg, payload, deps);
  return { ...result, payload };
}

/**
 * Write the outcome where Settings can show it.
 *
 * A log line only helps someone who already suspects a problem; "last delivery:
 * 3 days ago" on the screen is what makes a feed that quietly stopped visible
 * at all. Recording it must never take the caller down with it.
 */
function note(database, dbApi, ok, error) {
  if (!database || !dbApi || typeof dbApi.updateHarvestFeedStatus !== 'function') return;
  try {
    dbApi.updateHarvestFeedStatus(database, { at: new Date().toISOString(), ok, error });
  } catch {
    // Bookkeeping around a push that already happened.
  }
}

/**
 * Take delivery of whatever came back, and confirm what went out.
 *
 * Order matters and only one order is right. Confirming comes first, and covers
 * exactly the ids a *successful* push carried: those the receiver has now
 * definitely heard about. Anything arriving after that genuinely is a repeat,
 * and re-arms itself in storePickup().
 *
 * Confirming what a failed push carried would be the bug this ordering exists
 * to prevent — the receiver never heard, drops the pickup from its next reply
 * anyway, and the two sides quietly disagree with nothing left to reconcile
 * them.
 *
 * Best-effort throughout, like note(): the push has happened, and none of this
 * may throw its way out to the timer.
 */
function receive(database, dbApi, log, r) {
  if (!database || !dbApi || typeof dbApi.storePickup !== 'function') return;
  try {
    if (r.ok && Array.isArray(r.payload.pickupsDone) && r.payload.pickupsDone.length)
      dbApi.ackPickups(database, r.payload.pickupsDone);

    const reply = r.reply;
    if (!reply) return;
    if (reply.error) log('warn', 'Harvest feed reply not usable', { error: reply.error, dropped: reply.dropped });

    let fresh = 0;
    for (const p of reply.pickups) {
      // One unusable row must not cost the other nineteen. A full table throws
      // here, which is the one case worth a line of its own.
      try {
        if (dbApi.storePickup(database, p) !== 'unchanged') fresh++;
      } catch (e) {
        log('warn', 'Pickup rejected', { id: p.id, error: e.message });
      }
    }

    // Withdrawals last, and that ordering is a decision rather than an
    // accident. Should one reply somehow name the same id in both lists, the
    // withdrawal is the statement that has to survive: packing a crate for a
    // cancelled order costs produce, and not packing one for an order that is
    // still live costs a phone call.
    //
    // Applied to the database before it can appear in `pickupsDone` — same
    // ordering as a booking, same reason. Confirming a withdrawal this end has
    // not carried out means the receiver stops repeating it while the pickup is
    // still sitting in the list.
    let withdrawn = { removed: 0, recorded: 0, skipped: 0 };
    if (reply.cancelled && reply.cancelled.length && typeof dbApi.cancelPickups === 'function') {
      try {
        withdrawn = dbApi.cancelPickups(database, reply.cancelled);
      } catch (e) {
        log('warn', 'Pickup withdrawals not applied', { error: e.message });
      }
    }

    // Long-finished collections go, so the table cannot grow until storePickup
    // starts refusing new ones. Here rather than on a timer of its own: this
    // runs on every push, it is already best-effort, and a pruning pass that
    // needs its own schedule is one that stops running when the feature is off.
    if (typeof dbApi.prunePickups === 'function') {
      try {
        const weg = dbApi.prunePickups(database);
        if (weg) log('info', 'Old pickups pruned', { removed: weg });
      } catch (e) {
        log('warn', 'Pickup pruning skipped', { error: e.message });
      }
    }

    // Silence when nothing moved: this runs every quarter of an hour and a
    // receiver repeats its open pickups every single time. A withdrawal that
    // removed nothing is the same non-event — the id was never here, which the
    // receiver cannot know and reports anyway.
    if (fresh || withdrawn.removed)
      log('info', 'Pickups received', {
        stored: fresh,
        of: reply.pickups.length,
        withdrawn: withdrawn.removed
      });
  } catch (e) {
    log('warn', 'Harvest feed reply not stored', { error: e.message });
  }
}

/**
 * One complete exchange: build, send, record the outcome, take delivery.
 *
 * The timer calls this and so do the tests, so the confirmation ordering that
 * receive() describes is exercised by the same code path that runs in
 * production rather than by a reimplementation of it.
 */
async function deliver({ database, cfg, dbApi, log, deps }) {
  const r = await sendOnce(database, cfg, deps);
  if (r.ok)
    log('info', 'Harvest feed sent', {
      harvested: r.payload.harvested.length,
      planned: r.payload.planned.length,
      attempts: r.attempts
    });
  else log('warn', 'Harvest feed failed', { error: r.error, attempts: r.attempts });
  note(database, dbApi, r.ok, r.error);
  receive(database, dbApi, log, r);
  return r;
}

/**
 * Start the timer. Safe to call when the feature is off (no URL) — it says so
 * once and does nothing. Returns true when a timer is running.
 *
 * `skip` exists for the same reason the DuckDNS updater has one: a worktree or
 * staging copy usually inherits the production .env, and two servers posting
 * contradictory snapshots to one receiver is worse than one posting none.
 */
function start({ database, env, log, skip, dbApi, deps }) {
  stop();
  let cfg;
  try {
    cfg = resolveConfig({ database, env, dbApi });
  } catch (e) {
    log('error', 'Harvest feed misconfigured — not started', { error: e.message });
    return false;
  }
  if (!cfg) return false;
  if (skip) {
    log('info', 'Harvest feed skipped', { reason: 'worktree mode' });
    return false;
  }

  const tick = async () => {
    if (inFlight) {
      log('warn', 'Harvest feed still sending — tick skipped');
      return;
    }
    inFlight = true;
    try {
      await deliver({ database, cfg, dbApi, log, deps });
    } catch (e) {
      // buildPayload can throw if the schema is mid-migration. Log, keep the
      // timer.
      log('error', 'Harvest feed error', { error: e.message });
      note(database, dbApi, false, e.message);
    } finally {
      inFlight = false;
    }
  };

  timer = setInterval(tick, cfg.intervalMs);
  if (timer.unref) timer.unref();
  // The URL is logged, the secret never is.
  log('info', 'Harvest feed started', { url: cfg.url, everyMin: cfg.intervalMs / 60000 });
  tick();
  return true;
}

function stop() {
  if (timer) clearInterval(timer);
  timer = null;
}

/**
 * Push once, right now, without waiting for the timer.
 *
 * For the moment a release changes: the shop should not go on offering an
 * amount somebody has just corrected. Everything else about it is the ordinary
 * push.
 *
 * ⚠️ **Through the same `inFlight` guard as the timer, and that is the whole
 * reason this function exists** rather than a `deliver()` call at the call
 * site. It was one at first, and it bypassed the guard: two pushes could then
 * be in the air together, and the receiver's replay protection rejects the
 * second of two requests that share a unix-second timestamp. That 409 is
 * recorded by note() as a failed push — so a *successful* delivery would leave
 * "the shop has not heard this" on the screen, which is the one thing that
 * display exists to get right.
 *
 * Skipping is the correct outcome, not a loss: a push already in flight is
 * carrying the same row, and the payload is a snapshot rather than an event
 * log. Returns what happened so a caller can log it; never throws.
 */
async function pushNow({ database, env, log, dbApi, deps, skip }) {
  if (skip) return 'skipped';
  let cfg;
  try {
    cfg = resolveConfig({ database, env, dbApi });
  } catch {
    // Misconfigured. start() has already said so once; saying it again on every
    // save would bury the line that matters.
    return 'off';
  }
  if (!cfg) return 'off';
  if (inFlight) return 'busy';
  inFlight = true;
  try {
    await deliver({ database, cfg, dbApi, log, deps });
    return 'sent';
  } catch (e) {
    log('warn', 'Immediate harvest feed push failed', { error: e.message });
    note(database, dbApi, false, e.message);
    return 'failed';
  } finally {
    inFlight = false;
  }
}

/**
 * May this harvest carry the release it claims? `null` if it may, a reason if not.
 *
 * Pure, and that is the point: the two guards here are the ones the field exists
 * to protect — a slipped comma, and a release with no species to key it to — and
 * this repo has no HTTP-level harness that could pin them inside a route handler.
 * Inverting either comparison would otherwise stay green.
 *
 * `fatal` says which side of the harvest the answer falls on, because the two are
 * not the same kind of wrong. More released than weighed is a bad request and the
 * harvest never happened. A missing species is reported *next to* a stored
 * harvest: a weighed harvest is a fact either way, and losing it to a quibble
 * about the release would be the worse trade.
 */
function releaseProblem({ release, grams, species }) {
  if (!(Number(release) > 0)) return null;
  if (Number(release) > Number(grams)) return { reason: 'release must be <= grams', fatal: true };
  if (!species) return { reason: 'species required to release', fatal: false };
  return null;
}

module.exports = {
  readConfig,
  storedConfig,
  resolveConfig,
  buildPayload,
  buildPickupWindows,
  pickupWindowIndex,
  windowId,
  releaseProblem,
  packSizes,
  PACK_MIN_G,
  PACK_MAX_G,
  PACK_MAX_COUNT,
  sign,
  post,
  sendOnce,
  deliver,
  parseReply,
  readReply,
  start,
  stop,
  pushNow,
  VERSION,
  VERSION_RELEASE,
  REPLY_MAX_BYTES,
  MAX_PICKUPS,
  MAX_CANCELLED
};

// ── CLI ──────────────────────────────────────────────────────────────────────
// `node harvest-feed.js --dry-run` prints exactly what would be posted, without
// posting it. That is the answer to "is this thing going to leak something?" —
// look at it before you turn it on.
//
//     node harvest-feed.js --dry-run     # print the payload, send nothing
//     node harvest-feed.js --once        # build, sign, POST, print the result
//     node harvest-feed.js --dry-run --db /path/to/other.db
if (require.main === module) {
  const path = require('path');
  const fs = require('fs');
  const db = require('./db.js');

  const envPath = path.join(__dirname, '.env');
  if (fs.existsSync(envPath)) {
    for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
      const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
      if (!m) continue;
      let v = m[2];
      if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
      if (!process.env[m[1]]) process.env[m[1]] = v;
    }
  }

  const dry = process.argv.includes('--dry-run');
  if (!dry && !process.argv.includes('--once')) {
    console.error('usage: node harvest-feed.js [--dry-run | --once] [--db <path>]');
    process.exit(2);
  }
  // `--db` so a dry run can be aimed at a copy — reviewing what would leave the
  // building is exactly the moment you do not want to touch the live file.
  const dbFlag = process.argv.indexOf('--db');
  const dbPath =
    dbFlag !== -1 && process.argv[dbFlag + 1] ? process.argv[dbFlag + 1] : path.join(__dirname, 'meistertracker.db');

  let cfg;
  try {
    cfg = readConfig(process.env);
  } catch (e) {
    console.error(e.message);
    process.exit(2);
  }
  if (!cfg) {
    // A dry run should work before anything is configured — that is when you
    // most want to see the payload.
    if (!dry) {
      console.error('HARVEST_WEBHOOK_URL is not set — nothing to send.');
      process.exit(2);
    }
    cfg = { freshDays: 3, plannedDays: 28, leadDays: 0, strain: true, site: '' };
  }

  const database = db.openDb(dbPath);
  if (dry) {
    console.log(JSON.stringify(buildPayload(database, cfg), null, 2));
    process.exit(0);
  }
  sendOnce(database, cfg).then((r) => {
    console.log(JSON.stringify(r.payload, null, 2));
    console.log(r.ok ? `\nOK (HTTP ${r.status}, ${r.attempts} attempt(s))` : `\nFAILED: ${r.error}`);
    process.exit(r.ok ? 0 : 1);
  });
}