@marktiderman/genesis-forms 0.6.0 → 0.8.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.
- package/README.md +157 -10
- package/dist/destinations.cjs +658 -18
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +394 -6
- package/dist/destinations.d.ts +394 -6
- package/dist/destinations.js +629 -19
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-DC4YTPdr.d.cts → engine-Bj23YJlB.d.cts} +2 -2
- package/dist/{engine-CpfOz8FN.d.ts → engine-CzinTJph.d.ts} +2 -2
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -66
- package/dist/index.d.ts +9 -66
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +228 -0
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +199 -6
- package/dist/server.d.ts +199 -6
- package/dist/server.js +221 -1
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -4
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js.map +1 -1
- package/dist/{types-oAckGR_Z.d.cts → types-jqDKcmP4.d.cts} +1 -1
- package/dist/{types-oAckGR_Z.d.ts → types-jqDKcmP4.d.ts} +1 -1
- package/dist/upload-protocol-BOe018kL.d.cts +122 -0
- package/dist/upload-protocol-BUO74WkS.d.ts +122 -0
- package/dist/{validate-GpTXPwyl.d.ts → validate-DvfQHYnu.d.ts} +3 -3
- package/dist/{validate-B5WKHbst.d.cts → validate-irIPubxc.d.cts} +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ const version = eventFeedback.hash(); // content fingerprint for publish
|
|
|
83
83
|
| `isFormEvent(value)` | Guard for one well-formed event-log entry. |
|
|
84
84
|
| `isSensitive(block)` | `sensitive: true` on a field, or the `props.sensitive` alias. Hidden inputs take `sensitive: true` too (`f.hidden.string({ source: "server", sensitive: true })`); devtools masks both. |
|
|
85
85
|
| `/server` → `validateSubmission`, `publicDefinition` | Re-check a submission against the published definition: drops unknown and logic-hidden answers, ignores client values for server inputs, checks drafts by format only. Never throws on a malformed body. No IO. |
|
|
86
|
-
| `/destinations` → `emailDestination` + `resendProvider`, `sheetsDestination`, `webhookDestination`, `responseRow` | Send a checked submission to an inbox, a Google Sheet or a signed webhook. fetch and WebCrypto only. See [Destinations](#destinations). |
|
|
86
|
+
| `/destinations` → `emailDestination` + `resendProvider`, `sheetsDestination`, `webhookDestination`, `slackDestination`, `responseRow`, `createDeliveryRunner` | Send a checked submission to an inbox, a Google Sheet, Slack or a signed webhook, and run an app's outbox of deliveries. fetch and WebCrypto only. See [Destinations](#destinations). |
|
|
87
87
|
| `explain(engine, blockId)` | "Why is this hidden?": the path, the jump that skipped a block, each show/hide rule with its result in plain English, and the field's validation. |
|
|
88
88
|
| `/testing` → `simulate`, `coverage`, `runScenario`, `recordScenario`, `compactEvents` | Walk a form by key, enumerate its paths, and run JSON scenarios. See [Testing](#testing). |
|
|
89
89
|
|
|
@@ -143,7 +143,7 @@ A `photo` field asks for up to a few photos, such as a picture of broken equipme
|
|
|
143
143
|
returned, never the bytes: `PhotoRef[]`, each `{ id, name, type, size }`. The renderer uploads
|
|
144
144
|
through the host (`onUpload` on genesis-forms-web's `Form`), so the engine and `validateSubmission`
|
|
145
145
|
stay storage-agnostic; the host's server should check that every reference is a file it stored
|
|
146
|
-
for this respondent and form before saving.
|
|
146
|
+
for this respondent and form before saving (`checkUploads`, see [Uploads](#uploads)).
|
|
147
147
|
|
|
148
148
|
| Prop | Default | Rule |
|
|
149
149
|
| --- | --- | --- |
|
|
@@ -268,9 +268,9 @@ An option can carry more than its value and label, from the question's own `opti
|
|
|
268
268
|
app's list:
|
|
269
269
|
|
|
270
270
|
```ts
|
|
271
|
-
{ value: "
|
|
271
|
+
{ value: "riverside", label: "Riverside Gym",
|
|
272
272
|
description: "12 Main St", // a muted line under the label
|
|
273
|
-
image: "https://cdn.example.com/
|
|
273
|
+
image: "https://cdn.example.com/riverside.png", // a small picture; https only
|
|
274
274
|
attributes: { region: "north", coaches: ["ada", "sam"] } } // for filters and recall
|
|
275
275
|
```
|
|
276
276
|
|
|
@@ -288,7 +288,7 @@ f.singleChoice("coach", {
|
|
|
288
288
|
label: "Who coached you?",
|
|
289
289
|
optionsFrom: { source: "host", key: "coaches", filter: { attribute: "location", field: "location" } },
|
|
290
290
|
});
|
|
291
|
-
// a coach: { value: "ada", label: "Ada", attributes: { location: ["
|
|
291
|
+
// a coach: { value: "ada", label: "Ada", attributes: { location: ["riverside", "hilltop"] } }
|
|
292
292
|
```
|
|
293
293
|
|
|
294
294
|
`field` is the earlier question's key (`parseForm` checks it exists and comes first). An option
|
|
@@ -307,7 +307,7 @@ options: `props.searchable` when it is true or false, else more than `SEARCHABLE
|
|
|
307
307
|
query in the label, value or description, ignoring case and accents.
|
|
308
308
|
|
|
309
309
|
**Preselect from a link.** `props.prefill: { param }` on a choice question takes a link's query
|
|
310
|
-
parameter, such as `?location=
|
|
310
|
+
parameter, such as `?location=hilltop`:
|
|
311
311
|
|
|
312
312
|
```ts
|
|
313
313
|
f.dropdown("location", { label: "Where?", optionsFrom: { source: "host", key: "locations" },
|
|
@@ -335,7 +335,7 @@ import { answerSnapshots, answersFromSnapshots, withHostOptions } from "@marktid
|
|
|
335
335
|
|
|
336
336
|
const shown = withHostOptions(definition, lists);
|
|
337
337
|
const snapshots = answerSnapshots(shown, result.submission.answers);
|
|
338
|
-
// { location: { value: "
|
|
338
|
+
// { location: { value: "riverside", label: "Riverside Gym" }, helpers: [{ value: "ada", label: "Ada" }] }
|
|
339
339
|
|
|
340
340
|
// An edit link: find each pick again in today's lists.
|
|
341
341
|
const { answers, missing } = answersFromSnapshots(withHostOptions(definition, listsNow), stored);
|
|
@@ -430,6 +430,64 @@ const key = await rateLimitKey({ kind: "submit", scope: practiceId, ip: clientIp
|
|
|
430
430
|
if (!(await allow(store, { key, ...RATE_LIMIT_DEFAULTS.submit }, { log }))) return tooMany();
|
|
431
431
|
```
|
|
432
432
|
|
|
433
|
+
### Uploads
|
|
434
|
+
|
|
435
|
+
A `photo` answer holds references, never bytes, so the server must check each one is a file it
|
|
436
|
+
stored for this respondent. Two pieces do it, with the app's database and storage behind an
|
|
437
|
+
`UploadStore` (`beginUpload`, `signUpload`, `uploadRefs`):
|
|
438
|
+
|
|
439
|
+
- `createUploadHandler({ resolveForm, store, edit?, rateLimit?, ... })` is the endpoint the browser
|
|
440
|
+
asks for one signed upload per file (`(request, params) => Response`). It checks the form is open,
|
|
441
|
+
the field takes files and the file's type and size, the visit's attempt id or the edit link
|
|
442
|
+
(`edit.response` loads the response, the handler compares its nonce), and the rate limit
|
|
443
|
+
(`kind: "upload"`, 120 per 10 minutes by default), then records the upload and signs it.
|
|
444
|
+
- `checkUploads(definition, answers, { formId, owner, store })`, on submit after
|
|
445
|
+
`validateSubmission`: every reference must be an upload this attempt (`{ attemptHash }`) or this
|
|
446
|
+
edit (`{ responseId }`: the response's own files, or ones uploaded for the edit) made, for that
|
|
447
|
+
field, finished, of a type and size the field takes, and no more than it takes. The stored name,
|
|
448
|
+
type and size replace what the browser sent; a refusal is keyed by field id.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
import { attemptHash, checkUploads, createUploadHandler } from "@marktiderman/genesis-forms/server";
|
|
452
|
+
|
|
453
|
+
export const POST = createUploadHandler<{ slug: string }>({
|
|
454
|
+
resolveForm: async (request, { slug }) => {
|
|
455
|
+
const found = await loadForm(request, slug); // null: 404
|
|
456
|
+
return found && { id: found.id, definition: found.definition, open: found.open, scope: found.practiceId, allowsEdits: found.allowEdit };
|
|
457
|
+
},
|
|
458
|
+
store: uploadStore, // beginUpload, signUpload, uploadRefs
|
|
459
|
+
edit: { secret: editSecret, response: (id, form) => loadSentResponse(id, form) }, // { id, editNonce } or null
|
|
460
|
+
rateLimit: { store: counter, secret: rateLimitSecret },
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// On submit:
|
|
464
|
+
const checked = await checkUploads(definition, result.submission.answers, {
|
|
465
|
+
formId,
|
|
466
|
+
owner: editing ? { responseId } : { attemptHash: await attemptHash(body.attemptId) },
|
|
467
|
+
store: uploadStore,
|
|
468
|
+
}); // throws when the store can't read: answer 503
|
|
469
|
+
if (!checked.ok) return Response.json({ errors: checked.errors }, { status: 422 });
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The JSON (`UploadRequest`, `UploadGrant`, `UploadRefusal` in the main entry, for the browser; every
|
|
473
|
+
response is `cache-control: no-store`):
|
|
474
|
+
|
|
475
|
+
| Status | Body | When |
|
|
476
|
+
| --- | --- | --- |
|
|
477
|
+
| 200 | `{ ref: { id, name, type, size }, upload: { path, token, bucket?, url? } }` | Recorded and signed; `upload` is what `signUpload` returned. |
|
|
478
|
+
| 400 | `{ error }` | Not a JSON object; no `attemptId` (a UUID) without `edit`; the field isn't an upload field. |
|
|
479
|
+
| 403 | `{ error, code: "bad_link" }` | An edit link that doesn't verify, isn't the response's newest, or a form without edits. |
|
|
480
|
+
| 404 | `{ error }` | `resolveForm` found no form. |
|
|
481
|
+
| 409 | `{ error }`, or `code: "closed"` for an edit | The form isn't open. |
|
|
482
|
+
| 413 | `{ error }` | The request is over 4,000 bytes, or the file is empty, not whole bytes or over the field's size. |
|
|
483
|
+
| 415 | `{ error }` | Not `application/json`, or a file type the field doesn't take. |
|
|
484
|
+
| 429 | `{ error }`, or `code: "too_many_photos"` | The rate limit, or the store's `{ tooMany: true }`. |
|
|
485
|
+
| 500 / 503 | `{ error }` | Recording or signing failed / the form or the edit's response couldn't be loaded; the cause is logged, never sent. |
|
|
486
|
+
|
|
487
|
+
The request is `{ fieldId, name, type, size }` plus `attemptId` or `edit`. `attemptHash(attemptId)`
|
|
488
|
+
is the hex SHA-256 the store keeps for the attempt. `messages` overrides any of the words
|
|
489
|
+
(`UPLOAD_MESSAGES`). genesis-forms-web's `signedUploadHost` is the browser half.
|
|
490
|
+
|
|
433
491
|
## Destinations
|
|
434
492
|
|
|
435
493
|
`@marktiderman/genesis-forms/destinations` sends a checked submission somewhere. No dependencies:
|
|
@@ -484,7 +542,10 @@ store and log: never a secret, an answer, contact details or the webhook URL. Co
|
|
|
484
542
|
has a timeout (`timeoutMs`, default 10 s) and honours `options.signal`. `deliverBatch(items,
|
|
485
543
|
config)` (the sheet) sends many in one request; all succeed or fail together. Write your own by
|
|
486
544
|
implementing `Destination` and throwing `DeliveryError` inside; `deliveryFailure(error)` turns any
|
|
487
|
-
error into a safe result.
|
|
545
|
+
error into a safe result. A destination that deliberately sends nothing (no address, a cap reached)
|
|
546
|
+
resolves `{ ok: true, notSent: reason }`. `options.update` marks the delivery of an edit, and
|
|
547
|
+
`options.destinationId` is handed to option functions (a secret, an account, a URL, a token) as
|
|
548
|
+
their second argument, `{ destinationId }`, so they can look up what is stored for it.
|
|
488
549
|
|
|
489
550
|
**Rows.** `responseRow(submission, definition)` flattens a submission: `id`, `submitted_at`, the
|
|
490
551
|
app's `meta` columns, then one column per field key in form order. Numbers stay numbers, choices
|
|
@@ -495,8 +556,94 @@ submission. On an anonymous form identifying fields get no column. `sensitive` f
|
|
|
495
556
|
| Destination | Config | Notes |
|
|
496
557
|
| --- | --- | --- |
|
|
497
558
|
| `emailDestination({ provider, from, replyTo?, render?, sensitive? })` | `{ to: string \| string[], subject? }` | HTML and plain text from the definition (`renderEmail`), every value HTML-escaped. Subject, From, To and Reply-To are stripped of line breaks or refused, so no answer can add a header. At most 50 recipients. Sensitive fields masked by default. The idempotency key is the delivery id plus a hash of the recipients. `resendProvider({ apiKey })` is built in; any `EmailProvider` works. `render` and `emailBody(lines)` let an app write its own content and keep the escaping. |
|
|
498
|
-
| `sheetsDestination({ account, escapeFormulas?, sensitive? })` | `{ spreadsheetId, sheetName? }` | Service-account JWT signed with WebCrypto (RS256), token cached per process. Creates the tab, writes and freezes the header, adds a column for a new key without moving others, appends with `valueInputOption=RAW`, so Sheets stores text exactly as typed and never runs it as a formula (`@team thanks` stays `@team thanks`). No `'` is added by default, since RAW would store it literally; `escapeFormulas: true` adds one (headers included) for a sheet that is exported and opened elsewhere. `escapeFormula(cell)` is the same opt-in guard for CSV you write yourself: text starting with `=`, `@`, tab or CR, or with `+`/`-` followed by anything but number punctuation, gets a leading `'`. Keeps the tab, header and last row in `state`, so a normal delivery is one request. On `redelivery`, skips ids already in the last 500 rows. A 403/404 says to share the sheet with the service account. |
|
|
499
|
-
| `webhookDestination({ secret, allowInsecureUrls?, allowPrivateHosts?, sensitive? })` | `{ url }` | [Standard Webhooks](https://www.standardwebhooks.com): `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<HMAC-SHA256>`. The secret is base64 (at least 16 bytes decoded), usually `whsec_`-prefixed; as in every Standard Webhooks library the prefix is optional and the rest is always base64-decoded, so receivers verify with the same string. A secret that is not padded standard base64 (a raw passphrase, say) fails permanently rather than signing deliveries no receiver could verify. Generate one with `
|
|
559
|
+
| `sheetsDestination({ account \| accessToken, escapeFormulas?, sensitive? })` | `{ spreadsheetId, sheetName? }` | Signs in with a service account (`account`) or with an access token the app supplies (`accessToken(config, { destinationId })`, for a sheet a person connected with Google sign-in: see `googleOAuthAccessToken`). Service-account JWT signed with WebCrypto (RS256), token cached per process. Creates the tab, writes and freezes the header, adds a column for a new key without moving others, appends with `valueInputOption=RAW`, so Sheets stores text exactly as typed and never runs it as a formula (`@team thanks` stays `@team thanks`). No `'` is added by default, since RAW would store it literally; `escapeFormulas: true` adds one (headers included) for a sheet that is exported and opened elsewhere. `escapeFormula(cell)` is the same opt-in guard for CSV you write yourself: text starting with `=`, `@`, tab or CR, or with `+`/`-` followed by anything but number punctuation, gets a leading `'`. Keeps the tab, header and last row in `state`, so a normal delivery is one request. On `redelivery`, skips ids already in the last 500 rows. On `update` (an edit), reads the id column and overwrites the response's row where it is, appending only when the row is gone. A 403/404 says to share the sheet with the service account (or, with `accessToken`, that it is no longer connected). |
|
|
560
|
+
| `webhookDestination({ secret, url?, resolveHost?, allowInsecureUrls?, allowPrivateHosts?, sensitive? })` | `{ url }` | [Standard Webhooks](https://www.standardwebhooks.com): `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<HMAC-SHA256>`. The secret is base64 (at least 16 bytes decoded), usually `whsec_`-prefixed; as in every Standard Webhooks library the prefix is optional and the rest is always base64-decoded, so receivers verify with the same string. A secret that is not padded standard base64 (a raw passphrase, say) fails permanently rather than signing deliveries no receiver could verify. Generate one with `newWebhookSecret()`. Body `{ type: "form.submitted", timestamp, data: { form, submission, row } }` (`form.updated` on `update`). `url(config, { destinationId })` looks the address up when it is kept out of config. https only, no URL credentials, no localhost or private, loopback or link-local address literals, redirects not followed. Pass `resolveHost` to also refuse a hostname that resolves to a non-public address (checked before every request; see below). Without it, such a hostname is not caught; either way, if untrusted users pick URLs, send from a network that cannot reach internal services. Receivers check with `verifyWebhook({ secret, headers, body })` (constant-time, five-minute tolerance). |
|
|
561
|
+
| `slackDestination({ url?, resolveHost?, message? })` | `{ url }` | One message per submission to a Slack incoming webhook (`https://hooks.slack.com/services/…` only; anything else fails permanently). The heading (`New response: <title>`, `Updated response` on `update`), each answer as `label: value` in form order, then a link back or the response id. Every value is escaped (`escapeSlack`), so typed text can't mention `@channel` or make a link; answers over 1,500 characters are cut. Sensitive fields masked by default. `message(input)` sets the heading, extra lines, a link and fields to `omit` per delivery; `slackMessage(submission, definition, options)` builds the same text on its own. The URL is the credential, so `url(config, { destinationId })` can look it up. |
|
|
562
|
+
|
|
563
|
+
### Sending by answer
|
|
564
|
+
|
|
565
|
+
A destination's `config.when` is a form `Condition` (the same shape logic rules use). The runner
|
|
566
|
+
sends a submission only when it matches, reading answers by field id and hidden inputs by name
|
|
567
|
+
(trusted `context` wins over a hidden input of the same name). A submission that doesn't match is
|
|
568
|
+
recorded as `filtered`. A `when` that is not a valid condition fails the delivery permanently
|
|
569
|
+
rather than guessing. `passesCondition(submission, definition, config)`, `conditionOf(config)` and
|
|
570
|
+
`isCondition(value)` are exported for apps that check it themselves.
|
|
571
|
+
|
|
572
|
+
### Hook URLs and SSRF
|
|
573
|
+
|
|
574
|
+
`parseHookUrl(raw, "webhook" | "slack")` checks an address when someone adds it: https on port 443,
|
|
575
|
+
no credentials, at most 2,048 characters, and a public host (no localhost, single-label or
|
|
576
|
+
`.local`/`.internal`/`.lan`-style names, no private or reserved IP literal); a Slack address must
|
|
577
|
+
be a Slack incoming webhook. It throws a `HookUrlError` with a message safe to show. Before every
|
|
578
|
+
request, `checkHookHost(url, resolveHost)` (what `resolveHost` turns on in the webhook and Slack
|
|
579
|
+
destinations) resolves the name and refuses it unless every address is public by
|
|
580
|
+
`isPublicAddress`, which knows the private, loopback, link-local, CGNAT, documentation,
|
|
581
|
+
benchmarking, multicast, IPv4-mapped, NAT64, 6to4 and Teredo ranges. A lookup that fails is
|
|
582
|
+
retryable; a private address is not. In Node, `resolveHost` can be
|
|
583
|
+
`(host) => dns.promises.lookup(host, { all: true }).then((a) => a.map((x) => x.address))`. The
|
|
584
|
+
name is checked and then fetched, so a name re-pointed in between (DNS rebinding) is not caught:
|
|
585
|
+
where untrusted people choose URLs, also send from a network that cannot reach internal services.
|
|
586
|
+
|
|
587
|
+
### Google sign-in for sheets
|
|
588
|
+
|
|
589
|
+
`googleOAuthAccessToken({ clientId, clientSecret }, refreshToken)` trades a refresh token from the
|
|
590
|
+
app's own consent flow (`DRIVE_FILE_SCOPE` reaches only files the person picked or created through
|
|
591
|
+
the app) for an access token, cached per grant until a minute before it expires.
|
|
592
|
+
`refreshGoogleAccessToken` is the uncached call. `invalid_grant` fails permanently with "Connect
|
|
593
|
+
the sheet again"; 429 and 5xx are retryable. The app keeps the refresh token; this only trades it.
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
sheetsDestination({
|
|
597
|
+
accessToken: async (config, { destinationId }) =>
|
|
598
|
+
googleOAuthAccessToken(oauthClient, await refreshTokenFor(destinationId)),
|
|
599
|
+
});
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Secrets at rest
|
|
603
|
+
|
|
604
|
+
Webhook and Slack URLs, signing secrets and refresh tokens should not sit in config. The helpers
|
|
605
|
+
seal them for whatever store the app uses: `deriveSecretKey(appKey, purpose)` derives an AES-256-GCM
|
|
606
|
+
key from a 32-byte app secret (base64 or base64url, as `openssl rand -base64 32` prints) with
|
|
607
|
+
HKDF-SHA-256, one key per purpose; `sealSecret(key, plaintext, aad)` gives `v1.<iv>.<ciphertext>`
|
|
608
|
+
(base64url), bound to `aad` (the row it belongs to), and `openSecret(key, sealed, aad)` opens it,
|
|
609
|
+
failing permanently for another row, another purpose or a rotated key.
|
|
610
|
+
|
|
611
|
+
### The outbox runner
|
|
612
|
+
|
|
613
|
+
`createDeliveryRunner` claims a batch of due deliveries from the app's store, sends each to its
|
|
614
|
+
destination, and records the outcome. The store maps the three calls to its own tables:
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
import { createDeliveryRunner } from "@marktiderman/genesis-forms/destinations";
|
|
618
|
+
|
|
619
|
+
const runner = createDeliveryRunner<{ responseId?: string }>({
|
|
620
|
+
// Lease up to `limit` due rows (a claim function in SQL, say) with their submission and definition.
|
|
621
|
+
claim: ({ limit, filter }) => store.claimDeliveries(limit, filter),
|
|
622
|
+
// Write the outcome; resolve false if the lease had run out.
|
|
623
|
+
record: (delivery, outcome) => store.finishDelivery(delivery.id, outcome),
|
|
624
|
+
saveState: (destinationId, state) => store.saveDestinationState(destinationId, state),
|
|
625
|
+
destinations: { email, sheet, slack, webhook },
|
|
626
|
+
log: (line) => console.warn(line), // ids, kinds and safe reasons only
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
await runner.run({ filter: { responseId } }); // right after a save, or from a job
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
Each `ClaimedDelivery` carries its `id` (stable across retries), `destinationId`, `kind`, `config`,
|
|
633
|
+
`state`, `submission`, `definition`, its `attempt` (from 1), how many times it was `deferrals`
|
|
634
|
+
(deferred), and `redelivery` and `update` flags. A response sent and edited in one sheet batch is
|
|
635
|
+
written once, as its latest version.
|
|
636
|
+
Deliveries to one destination go in one `deliverBatch` call where the destination has one (a sheet
|
|
637
|
+
appends them in one request), else one by one with the state passed along; new state is saved
|
|
638
|
+
once. Outcomes: `sent` (`alreadyThere` on a redelivery that found its row), `filtered` (didn't
|
|
639
|
+
match `config.when`), `skipped` (the destination returned `notSent`, a deliberate no-send such as a
|
|
640
|
+
cap; never retried), `retry` with `retryAt`, and `failed`. A retryable failure with the service's
|
|
641
|
+
Retry-After is deferred without counting an attempt (`counted: false`), up to `maxDeferrals`
|
|
642
|
+
(default 24, counted by the store in `ClaimedDelivery.deferrals`); any other retryable
|
|
643
|
+
failure backs off by attempt (`defaultBackoff`: 1 minute doubling to 6 hours) until `maxAttempts`
|
|
644
|
+
(default 8), then fails. Permanent failures and unknown kinds fail at once. A destination that
|
|
645
|
+
throws is reduced to a safe message. `run` returns a `DeliveryReport` of counts, including `stale`
|
|
646
|
+
(record resolved false) and `unrecorded` (record threw; the lease runs out and the row comes back).
|
|
500
647
|
|
|
501
648
|
## Testing
|
|
502
649
|
|