@taprootio/site-authoring 0.16.0 → 0.19.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 +67 -13
- package/package.json +1 -1
- package/src/api.js +129 -11
- package/src/cli.js +41 -1
- package/src/constants.js +16 -0
- package/src/field-schema.json +5 -0
- package/src/field-validation.js +5 -1
- package/src/forms-api.js +8 -1
- package/src/forms-contract.js +29 -6
- package/src/forms-workspace.js +23 -14
- package/src/reference-help.js +10 -4
- package/src/transport.js +73 -12
- package/src/typed-page-reference.js +3 -1
- package/src/verbs/deploy.js +24 -4
- package/src/verbs/forms-pull.js +1 -3
- package/src/verbs/forms-push.js +54 -36
- package/src/verbs/index.js +9 -1
- package/src/verbs/media-upload.js +0 -1
- package/src/verbs/places.js +72 -2
- package/src/verbs/status.js +1 -4
package/src/forms-workspace.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { SiteAuthoringError } from "./errors.js";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
FORM_DEFAULT_NOTIFY_OWNER,
|
|
4
4
|
FORM_FILE_EXTENSION,
|
|
5
|
-
FORM_RETENTION_DAYS,
|
|
6
5
|
FORM_LIMITS,
|
|
7
6
|
FORMS_DIRECTORY,
|
|
8
7
|
formFileName,
|
|
9
8
|
isFormKey,
|
|
9
|
+
projectFormSettings,
|
|
10
10
|
validateFormDocument,
|
|
11
11
|
} from "./forms-contract.js";
|
|
12
12
|
import { readWorkspaceJson, walkWorkspaceFiles } from "./workspace.js";
|
|
@@ -18,8 +18,9 @@ import { readWorkspaceJson, walkWorkspaceFiles } from "./workspace.js";
|
|
|
18
18
|
* typo never reads as a successful empty push. With none, every file under
|
|
19
19
|
* `forms/` is read. A file whose name is not a form key is refused rather than
|
|
20
20
|
* skipped: skipping it would report success with one form left behind.
|
|
21
|
+
* Newsletter admission is strict by default; `forms push` defers it for baseline comparison.
|
|
21
22
|
*/
|
|
22
|
-
export async function readWorkspaceForms(workspaceDir, keys) {
|
|
23
|
+
export async function readWorkspaceForms(workspaceDir, keys, validationOptions = {}) {
|
|
23
24
|
const files = await walkWorkspaceFiles(workspaceDir, FORMS_DIRECTORY, [FORM_FILE_EXTENSION]);
|
|
24
25
|
const known = new Map();
|
|
25
26
|
for (const file of files) {
|
|
@@ -46,7 +47,7 @@ export async function readWorkspaceForms(workspaceDir, keys) {
|
|
|
46
47
|
);
|
|
47
48
|
}
|
|
48
49
|
const document_ = await readWorkspaceJson(workspaceDir, file, FORM_LIMITS.fileBytes);
|
|
49
|
-
forms.push({ key, file, document: validateFormDocument(document_, key) });
|
|
50
|
+
forms.push({ key, file, document: validateFormDocument(document_, key, validationOptions) });
|
|
50
51
|
}
|
|
51
52
|
return forms;
|
|
52
53
|
}
|
|
@@ -54,10 +55,9 @@ export async function readWorkspaceForms(workspaceDir, keys) {
|
|
|
54
55
|
/**
|
|
55
56
|
* The forms baseline in the pull manifest (TR01061).
|
|
56
57
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* the push refuse toward pull instead of overwriting it.
|
|
58
|
+
* A browser edit since the last pull or push makes the next push refuse rather
|
|
59
|
+
* than overwrite it. Each mutation also sends this baseline so the site can
|
|
60
|
+
* refuse an edit made after the CLI compared them.
|
|
61
61
|
*/
|
|
62
62
|
export const FORMS_MANIFEST_KEY = "forms";
|
|
63
63
|
|
|
@@ -67,15 +67,16 @@ function isObject(value) {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* The settings half of a baseline, from the form exactly as the site returned
|
|
70
|
-
* it.
|
|
70
|
+
* it. Operational settings change without appending a version, so the
|
|
71
71
|
* version alone cannot say they moved.
|
|
72
72
|
*/
|
|
73
73
|
export function siteSettingsBaseline(siteForm) {
|
|
74
|
-
return {
|
|
74
|
+
return projectFormSettings({
|
|
75
75
|
name: siteForm.name,
|
|
76
|
-
sink: siteForm.sink
|
|
77
|
-
retention_days: siteForm.retentionDays
|
|
78
|
-
|
|
76
|
+
sink: siteForm.sink,
|
|
77
|
+
retention_days: siteForm.retentionDays,
|
|
78
|
+
notify_owner: siteForm.notifyOwner,
|
|
79
|
+
});
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/** The recorded baseline for one key, or undefined when this workspace never saw that form. */
|
|
@@ -85,7 +86,15 @@ export function recordedForm(manifest, key) {
|
|
|
85
86
|
const entry = items[key];
|
|
86
87
|
return isObject(entry) && typeof entry.id === "string" && Number.isSafeInteger(entry.version)
|
|
87
88
|
&& isObject(entry.settings)
|
|
88
|
-
? {
|
|
89
|
+
? {
|
|
90
|
+
id: entry.id,
|
|
91
|
+
version: entry.version,
|
|
92
|
+
// Old baselines can prove only the default opt-out, never a remote opt-in.
|
|
93
|
+
settings: {
|
|
94
|
+
...entry.settings,
|
|
95
|
+
notify_owner: Object.hasOwn(entry.settings, "notify_owner") ? entry.settings.notify_owner : FORM_DEFAULT_NOTIFY_OWNER,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
89
98
|
: undefined;
|
|
90
99
|
}
|
|
91
100
|
|
package/src/reference-help.js
CHANGED
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
|
|
87
87
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
88
88
|
|
|
89
|
-
export const REFERENCE_VERSION =
|
|
89
|
+
export const REFERENCE_VERSION = 36;
|
|
90
90
|
|
|
91
91
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
92
92
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
|
@@ -773,14 +773,19 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
773
773
|
details: Object.freeze([
|
|
774
774
|
`Run 'pull' once first: 'forms pull' and 'forms push' need the pulled workspace's manifest, which records the `
|
|
775
775
|
+ "version of each form this workspace last saw. 'forms validate' needs only taproot-site.json.",
|
|
776
|
-
`The file is { key, name, sink, retention_days, definition }. key must equal the file's name `
|
|
776
|
+
`The file is { key, name, sink, retention_days, notify_owner, definition }. key must equal the file's name `
|
|
777
777
|
+ `and is lowercase letters, digits, and single hyphens, at most ${FORM_KEY_MAXIMUM_LENGTH} characters. name is `
|
|
778
778
|
+ `1 to ${FORM_LIMITS.nameCharacters} characters. sink is one of ${FORM_SINKS.join(", ")} and defaults to `
|
|
779
|
-
+ "'none' (store submissions only); 'newsletter'
|
|
779
|
+
+ "'none' (store submissions only); 'newsletter' requests double-opt-in confirmation for the contact address. "
|
|
780
|
+
+ "A newsletter sink requires a required email contact field and at least one consent checkbox; every consent checkbox must be checked. "
|
|
780
781
|
+ `retention_days is ${FORM_RETENTION_DAYS.minimum} to ${FORM_RETENTION_DAYS.maximum} (a site's plan may cap it lower): `
|
|
781
782
|
+ "submissions are deleted that many days after they arrive. Leave it out and the site chooses on creation (its "
|
|
782
783
|
+ "plan's default, usually 365) and keeps the current value on an update. Shortening it deletes the submissions that fall outside it, "
|
|
783
784
|
+ "so a key cannot do it; the site refuses it, and an owner or admin must make that change.",
|
|
785
|
+
"notify_owner is an optional top-level boolean, independent of the sink. New forms default to false; omitting it "
|
|
786
|
+
+ "on an existing form preserves the site's preference. It opts into hourly counts-only email to the current site "
|
|
787
|
+
+ "account owner, never visitor answers or addresses. This setting takes effect without a new version or publishing. "
|
|
788
|
+
+ "Delivery also requires Taproot's rollout and email provider readiness; opt-in enables neither, and earlier submissions are not backfilled.",
|
|
784
789
|
`definition is { fields, contact_field?, submit_label?, after_submit? } (contact_field only with several email fields). Each field is { id, type, label, hint?, ...constraints }; the closed `
|
|
785
790
|
+ `field types are ${FIELD_TYPES.join(", ")}. hint is optional helper text of up to ${MAX_HINT_LENGTH} `
|
|
786
791
|
+ "characters, drawn under the field on the published form; it is the only author-defined text printed there, so a required "
|
|
@@ -835,7 +840,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
835
840
|
+ "'unchanged' and leaves the bytes as they were. Write your own files in that order and a push rewrites "
|
|
836
841
|
+ "nothing you did not change.",
|
|
837
842
|
"'forms push' validates every file and compares every form with the site before the first write. It refuses "
|
|
838
|
-
+ "forms.concurrent_modification when the site's version moved since this workspace last read it, "
|
|
843
|
+
+ "forms.concurrent_modification when the site's version or operational settings moved since this workspace last read it, "
|
|
839
844
|
+ "forms.pull_required when the site has a form with that key this workspace never pulled, and forms.archived "
|
|
840
845
|
+ "when the form is archived on the site. Reconcile with 'forms pull' and retry; nothing was written.",
|
|
841
846
|
"Submissions are never pulled or pushed. They are visitors' personal data: read, mark, export and delete "
|
|
@@ -854,6 +859,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
854
859
|
name: "Contact",
|
|
855
860
|
sink: "none",
|
|
856
861
|
retention_days: 365,
|
|
862
|
+
notify_owner: false,
|
|
857
863
|
definition: Object.freeze({
|
|
858
864
|
fields: Object.freeze([
|
|
859
865
|
Object.freeze({ id: "name", type: "text", label: "Your name", required: true, max_length: 120 }),
|
package/src/transport.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
+
BANDWIDTH_ALLOWANCE_REFUSAL_REASON,
|
|
2
3
|
CAPABILITY_REFUSAL_FIELD,
|
|
3
4
|
CAPABILITY_REFUSAL_REASON,
|
|
4
5
|
CLI_NAME,
|
|
5
6
|
CLI_UPGRADE_REFUSAL_FIELD,
|
|
6
7
|
CLI_VERSION,
|
|
7
8
|
CREDENTIAL_REFUSAL_FIELD,
|
|
9
|
+
DEPLOYMENT_ALLOWANCE_REFUSAL_REASON,
|
|
8
10
|
GRPC_RESOURCE_EXHAUSTED,
|
|
9
11
|
GRPC_UNAUTHENTICATED,
|
|
10
12
|
HTTP_TOO_MANY_REQUESTS,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
REFUSAL_THROTTLED,
|
|
22
24
|
REFUSAL_UNCLASSIFIED,
|
|
23
25
|
ROLLOUT_REFUSAL_FIELD,
|
|
26
|
+
SITE_DEPLOYMENT_REFUSAL_DOMAIN,
|
|
24
27
|
} from "./constants.js";
|
|
25
28
|
import { Readable } from "node:stream";
|
|
26
29
|
|
|
@@ -409,15 +412,10 @@ function capabilityNames(value) {
|
|
|
409
412
|
* keys on it rather than on `@type`, which a future packing could reshape.
|
|
410
413
|
*/
|
|
411
414
|
export function capabilityRefusal(value) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
while (queue.length > 0 && work < 1_000) {
|
|
415
|
-
const current = queue.shift();
|
|
416
|
-
work += 1;
|
|
417
|
-
if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
|
|
418
|
-
const metadata = current.value.metadata;
|
|
415
|
+
return findErrorInfo(value, (detail) => {
|
|
416
|
+
const metadata = detail.metadata;
|
|
419
417
|
if (
|
|
420
|
-
|
|
418
|
+
detail.reason === CAPABILITY_REFUSAL_REASON
|
|
421
419
|
&& metadata !== null
|
|
422
420
|
&& typeof metadata === "object"
|
|
423
421
|
&& !Array.isArray(metadata)
|
|
@@ -434,6 +432,61 @@ export function capabilityRefusal(value) {
|
|
|
434
432
|
});
|
|
435
433
|
}
|
|
436
434
|
}
|
|
435
|
+
return undefined;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The pages-generated allowance or bandwidth-pause refusal the deploy API attaches
|
|
441
|
+
* as a `google.rpc.ErrorInfo` detail (TR00445, TR01201), or undefined. Keyed on the
|
|
442
|
+
* namespaced domain and reason, never the message sentence. `resetsAt` is the
|
|
443
|
+
* server's `resets_at` re-serialized as an ISO-8601 UTC instant, or undefined
|
|
444
|
+
* when it is absent (a licence that grants no deployments has no period) or
|
|
445
|
+
* does not parse. `requestedPages` and `remainingPages` are present on the
|
|
446
|
+
* allowance refusal only.
|
|
447
|
+
*/
|
|
448
|
+
export function planLimitRefusal(value) {
|
|
449
|
+
return findErrorInfo(value, (detail) => {
|
|
450
|
+
if (
|
|
451
|
+
detail.domain !== SITE_DEPLOYMENT_REFUSAL_DOMAIN
|
|
452
|
+
|| (detail.reason !== DEPLOYMENT_ALLOWANCE_REFUSAL_REASON && detail.reason !== BANDWIDTH_ALLOWANCE_REFUSAL_REASON)
|
|
453
|
+
) {
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
const metadata = detail.metadata !== null && typeof detail.metadata === "object" ? detail.metadata : {};
|
|
457
|
+
const resetsAt = typeof metadata.resets_at === "string" ? new Date(metadata.resets_at) : undefined;
|
|
458
|
+
return Object.freeze({
|
|
459
|
+
reason: detail.reason,
|
|
460
|
+
resetsAt: resetsAt !== undefined && !Number.isNaN(resetsAt.getTime()) ? resetsAt.toISOString() : undefined,
|
|
461
|
+
// The pages the refused request needed and the period had left (TR01201).
|
|
462
|
+
requestedPages: pageCount(metadata.requested_pages),
|
|
463
|
+
remainingPages: pageCount(metadata.remaining_pages),
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** A page count the server reported as a decimal string, or undefined. */
|
|
469
|
+
function pageCount(value) {
|
|
470
|
+
const count = typeof value === "string" && /^[0-9]{1,9}$/u.test(value) ? Number(value) : undefined;
|
|
471
|
+
return Number.isSafeInteger(count) ? count : undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The first object in `value`, breadth first, for which `match` returns a
|
|
476
|
+
* result. Transcoded `details[]` entries are `Any`-packed and nested, so one
|
|
477
|
+
* pinned path through them would silently empty every extractor on a contract
|
|
478
|
+
* change; the walk is bounded in depth and in work so a hostile body cannot
|
|
479
|
+
* stall it.
|
|
480
|
+
*/
|
|
481
|
+
function findErrorInfo(value, match) {
|
|
482
|
+
const queue = [{ value, depth: 0 }];
|
|
483
|
+
let work = 0;
|
|
484
|
+
while (queue.length > 0 && work < 1_000) {
|
|
485
|
+
const current = queue.shift();
|
|
486
|
+
work += 1;
|
|
487
|
+
if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
|
|
488
|
+
const found = match(current.value);
|
|
489
|
+
if (found !== undefined) return found;
|
|
437
490
|
for (const child of Object.values(current.value)) {
|
|
438
491
|
if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
|
|
439
492
|
}
|
|
@@ -459,6 +512,7 @@ export class ApiError extends SiteAuthoringError {
|
|
|
459
512
|
const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
|
|
460
513
|
const fields = fieldViolations(body);
|
|
461
514
|
const capability = capabilityRefusal(body);
|
|
515
|
+
const planLimit = planLimitRefusal(body);
|
|
462
516
|
// Only the known upgrade refusal may supply a human message. Other server
|
|
463
517
|
// errors can contain internal diagnostics and retain their generic output.
|
|
464
518
|
const upgradeMessage = fields.includes(CLI_UPGRADE_REFUSAL_FIELD)
|
|
@@ -472,6 +526,10 @@ export class ApiError extends SiteAuthoringError {
|
|
|
472
526
|
"api.request_rejected",
|
|
473
527
|
upgradeMessage || (fields.length > 0
|
|
474
528
|
? `Taproot rejected the request field '${fields[0]}'.`
|
|
529
|
+
: planLimit !== undefined
|
|
530
|
+
? planLimit.reason === BANDWIDTH_ALLOWANCE_REFUSAL_REASON
|
|
531
|
+
? "Taproot paused deployments: the site used more than its monthly bandwidth for two months in a row."
|
|
532
|
+
: "Taproot refused the request: it generates more pages than the site's allowance for this billing period has left."
|
|
475
533
|
: capability !== undefined
|
|
476
534
|
? `Taproot refused the request: this credential does not carry a capability granting '${
|
|
477
535
|
capability.permission
|
|
@@ -491,6 +549,7 @@ export class ApiError extends SiteAuthoringError {
|
|
|
491
549
|
this.fields = fields;
|
|
492
550
|
this.violationDescriptions = fieldDescriptions(body);
|
|
493
551
|
this.capability = capability;
|
|
552
|
+
this.planLimit = planLimit;
|
|
494
553
|
// The `retry-after` trailer (TR00839's deployment throttle, or any other
|
|
495
554
|
// ResourceExhausted refusal that sets it) transcodes to an ordinary
|
|
496
555
|
// response header. Undefined when absent or unparseable — callers must
|
|
@@ -545,10 +604,12 @@ export class ApiError extends SiteAuthoringError {
|
|
|
545
604
|
* left it unclassified: no `refusal` in the JSON result, and nothing said
|
|
546
605
|
* on the human channel. The field arrives only from the in-transaction
|
|
547
606
|
* re-validation, which does attach `ExternalApiKey`.
|
|
548
|
-
* - `plan_limit` (field `UpgradePrompt
|
|
607
|
+
* - `plan_limit` (field `UpgradePrompt`, or `ErrorInfo` reason
|
|
608
|
+
* `DEPLOYMENT_PERIOD_ALLOWANCE_EXCEEDED` / `BANDWIDTH_ALLOWANCE_EXCEEDED`
|
|
609
|
+
* with the server's `resets_at`) — a commercial ceiling, surfaced at
|
|
549
610
|
* deploy time because no pre-flight read of the published-page quota
|
|
550
|
-
* exists on the contract. **Upgrade** (or publish fewer pages
|
|
551
|
-
* never invents the numeric limit.
|
|
611
|
+
* exists on the contract. **Upgrade** (or publish fewer pages, or wait for
|
|
612
|
+
* the named reset); the CLI never invents the numeric limit.
|
|
552
613
|
* - `throttled` (gRPC `ResourceExhausted` / HTTP 429) — the per-key
|
|
553
614
|
* throttle. **Back off** and retry with delay.
|
|
554
615
|
* - `capability_missing` (`ErrorInfo` reason `SITE_AUTHORING_CAPABILITY_MISSING`)
|
|
@@ -568,7 +629,7 @@ export class ApiError extends SiteAuthoringError {
|
|
|
568
629
|
if (this.hasField(CLI_UPGRADE_REFUSAL_FIELD)) return REFUSAL_CLI_OUTDATED;
|
|
569
630
|
if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
|
|
570
631
|
if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
|
|
571
|
-
if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
|
|
632
|
+
if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD) || this.planLimit !== undefined) return REFUSAL_PLAN_LIMIT;
|
|
572
633
|
// The named detail ranks with the fields and above the status mappings, for
|
|
573
634
|
// the same reason: it is the specific statement about this refusal.
|
|
574
635
|
if (this.capability !== undefined) return REFUSAL_CAPABILITY_MISSING;
|
|
@@ -76,7 +76,9 @@ const FIELD_HELP = Object.freeze({
|
|
|
76
76
|
required: true,
|
|
77
77
|
description:
|
|
78
78
|
"The Taproot place being reviewed. The site fills in its name, address and coordinates from this record. Find "
|
|
79
|
-
+ "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'."
|
|
79
|
+
+ "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'. The site publishes the "
|
|
80
|
+
+ "place's category in the byline and files the review under /places/<category>; change it for this site with "
|
|
81
|
+
+ "'places category set <placeId> <category>'.",
|
|
80
82
|
},
|
|
81
83
|
place: {
|
|
82
84
|
type: "UUID",
|
package/src/verbs/deploy.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
|
|
5
5
|
DEPLOYMENT_STATUS_COMPLETED,
|
|
6
6
|
deploySite,
|
|
7
|
+
describePageEstimate,
|
|
7
8
|
getDeploySelection,
|
|
8
9
|
getPublishingReadiness,
|
|
9
10
|
listDeployments,
|
|
@@ -67,9 +68,6 @@ const REDIRECT_ACTIVATION_NOTE =
|
|
|
67
68
|
*/
|
|
68
69
|
|
|
69
70
|
const MAXIMUM_REPORTED_BLOCKERS = 50;
|
|
70
|
-
// Staged page ids ride in the readiness query string, so a very large candidate
|
|
71
|
-
// is checked at site level instead of pushing an unbounded URL at the API.
|
|
72
|
-
const MAXIMUM_READINESS_PAGE_IDS = 100;
|
|
73
71
|
|
|
74
72
|
function usageError(code, message, field) {
|
|
75
73
|
return new SiteAuthoringError(code, message, { field, exitCode: 2 });
|
|
@@ -157,6 +155,8 @@ function reportReadiness(readiness) {
|
|
|
157
155
|
redirectsChanged: readiness.redirectsChanged,
|
|
158
156
|
formsChanged: readiness.formsChanged,
|
|
159
157
|
videosChanged: readiness.videosChanged,
|
|
158
|
+
// The pages this deployment generates and the allowance left: what staging is charged (TR01201).
|
|
159
|
+
...(readiness.pageEstimate === null ? {} : { pageEstimate: readiness.pageEstimate }),
|
|
160
160
|
blockers: blockers.items,
|
|
161
161
|
...(blockers.truncated ? { blockersTruncated: true } : {}),
|
|
162
162
|
};
|
|
@@ -341,8 +341,10 @@ export async function deploy(invocation) {
|
|
|
341
341
|
);
|
|
342
342
|
const readiness = await getPublishingReadiness(client, siteId, {
|
|
343
343
|
...candidate,
|
|
344
|
-
stagedPageIds: stagedPageIds.length > MAXIMUM_READINESS_PAGE_IDS ? undefined : stagedPageIds,
|
|
345
344
|
useExactPageSelection: stagedPageIds.length === 0,
|
|
345
|
+
includePageEstimate: true,
|
|
346
|
+
// The estimate must recognize the request it is about to be followed by.
|
|
347
|
+
...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
|
|
346
348
|
});
|
|
347
349
|
if (readiness.blockers.length > 0) {
|
|
348
350
|
throw new SiteAuthoringError(
|
|
@@ -368,11 +370,29 @@ export async function deploy(invocation) {
|
|
|
368
370
|
if (!readiness.hasCandidateChanges) {
|
|
369
371
|
onProgress("Taproot reports this candidate contains no changes; deploying it anyway would be a no-op.");
|
|
370
372
|
}
|
|
373
|
+
// The plan is the charge: the same page count admission uses, shown before anything is sent.
|
|
374
|
+
if (readiness.pageEstimate !== null) {
|
|
375
|
+
onProgress(describePageEstimate(readiness.pageEstimate));
|
|
376
|
+
if (readiness.pageEstimate.exceedsAllowance) {
|
|
377
|
+
const estimate = readiness.pageEstimate;
|
|
378
|
+
throw new SiteAuthoringError(
|
|
379
|
+
"deploy.page_allowance_exceeded",
|
|
380
|
+
`This candidate generates ${estimate.plannedPageCount.toLocaleString("en-US")} ${
|
|
381
|
+
estimate.plannedPageCount === 1 ? "page" : "pages"
|
|
382
|
+
} but the site has ${(estimate.pagesGeneratedRemaining ?? 0).toLocaleString("en-US")} left for this billing `
|
|
383
|
+
+ `period${estimate.periodEnd === null ? "" : ` (it resets ${estimate.periodEnd})`}. Stage a smaller change, `
|
|
384
|
+
+ "or upgrade the site's plan for a larger allowance. Nothing was sent.",
|
|
385
|
+
{ field: "plannedPageCount" },
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
371
389
|
|
|
372
390
|
const created = await deploySite(client, siteId, {
|
|
373
391
|
siteId,
|
|
374
392
|
environment: DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
375
393
|
...candidate,
|
|
394
|
+
// Confirms the number just shown: the server refuses if the plan has grown since.
|
|
395
|
+
...(readiness.pageEstimate === null ? {} : { expectedPageCount: readiness.pageEstimate.plannedPageCount }),
|
|
376
396
|
...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
|
|
377
397
|
});
|
|
378
398
|
onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
|
package/src/verbs/forms-pull.js
CHANGED
|
@@ -41,9 +41,7 @@ export async function formsPull(invocation) {
|
|
|
41
41
|
}
|
|
42
42
|
const document_ = projectFormForWorkspace({
|
|
43
43
|
key: form.key,
|
|
44
|
-
|
|
45
|
-
sink: form.sink,
|
|
46
|
-
retention_days: form.retentionDays,
|
|
44
|
+
...siteSettingsBaseline(form),
|
|
47
45
|
definition: version.definition,
|
|
48
46
|
}, pages?.pathById);
|
|
49
47
|
await writeWorkspaceJson(config.workspaceDir, formFileName(form.key), document_);
|
package/src/verbs/forms-push.js
CHANGED
|
@@ -8,19 +8,24 @@ import {
|
|
|
8
8
|
saveSiteFormVersion,
|
|
9
9
|
updateSiteFormSettings,
|
|
10
10
|
} from "../forms-api.js";
|
|
11
|
-
import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition } from "../forms-contract.js";
|
|
11
|
+
import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition, validateNewsletterDefinition } from "../forms-contract.js";
|
|
12
12
|
import { readWorkspaceForms, recordedForm, siteSettingsBaseline, withRecordedForm } from "../forms-workspace.js";
|
|
13
13
|
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
14
14
|
import { ApiError } from "../transport.js";
|
|
15
15
|
import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
|
|
16
16
|
|
|
17
|
-
function changedOnTheSite(key, cause,
|
|
17
|
+
function changedOnTheSite(key, cause, applied) {
|
|
18
|
+
const outcome = applied.settings && applied.definition
|
|
19
|
+
? "Its settings and definition were applied."
|
|
20
|
+
: applied.settings
|
|
21
|
+
? "Its settings were applied; its definition was not."
|
|
22
|
+
: applied.definition
|
|
23
|
+
? "Its definition was applied; its settings were not."
|
|
24
|
+
: "This form was not changed.";
|
|
18
25
|
return new SiteAuthoringError(
|
|
19
26
|
"forms.concurrent_modification",
|
|
20
27
|
`${formFileName(key)}: the form changed on the site while it was being pushed. Run '${VERB_FORMS_PULL}', `
|
|
21
|
-
+ `reconcile the file, and retry. ${
|
|
22
|
-
settingsApplied ? "Its settings were applied; its definition was not." : "This form was not changed."
|
|
23
|
-
}`,
|
|
28
|
+
+ `reconcile the file, and retry. ${outcome}`,
|
|
24
29
|
{ field: formFileName(key), status: cause.status },
|
|
25
30
|
);
|
|
26
31
|
}
|
|
@@ -44,6 +49,7 @@ function planForm(form, remote, baseline, pages) {
|
|
|
44
49
|
const desired = projectFormForWorkspace({
|
|
45
50
|
...form.document,
|
|
46
51
|
retention_days: requestedRetention ?? remote?.form.retentionDays,
|
|
52
|
+
notify_owner: form.document.notify_owner ?? remote?.form.notifyOwner,
|
|
47
53
|
}, pages?.pathById);
|
|
48
54
|
if (remote === undefined) {
|
|
49
55
|
if (baseline !== undefined) {
|
|
@@ -66,28 +72,27 @@ function planForm(form, remote, baseline, pages) {
|
|
|
66
72
|
if (
|
|
67
73
|
baseline.id !== remote.form.id
|
|
68
74
|
|| baseline.version !== remote.version.version
|
|
69
|
-
//
|
|
75
|
+
// Operational settings change without a new version, so they are
|
|
70
76
|
// fenced separately; a stale file would otherwise revert them.
|
|
71
77
|
|| JSON.stringify(baseline.settings) !== JSON.stringify(siteSettings)
|
|
72
78
|
) {
|
|
73
79
|
throw new SiteAuthoringError(
|
|
74
80
|
"forms.concurrent_modification",
|
|
75
81
|
`${formFileName(form.key)}: the form changed on the site after this workspace read it (recorded version `
|
|
76
|
-
+ `${baseline.version}, site has ${remote.version.version}; its
|
|
82
|
+
+ `${baseline.version}, site has ${remote.version.version}; its settings may also have changed). Run '${VERB_FORMS_PULL}', reconcile the file, `
|
|
77
83
|
+ "and retry. Nothing was written.",
|
|
78
84
|
{ field: formFileName(form.key) },
|
|
79
85
|
);
|
|
80
86
|
}
|
|
81
87
|
const current = projectFormForWorkspace({
|
|
82
88
|
key: remote.form.key,
|
|
83
|
-
|
|
84
|
-
sink: remote.form.sink,
|
|
85
|
-
retention_days: remote.form.retentionDays,
|
|
89
|
+
...siteSettings,
|
|
86
90
|
definition: remote.version.definition,
|
|
87
91
|
}, pages?.pathById);
|
|
88
92
|
const settingsChanged = current.name !== desired.name
|
|
89
93
|
|| current.sink !== desired.sink
|
|
90
|
-
|| current.retention_days !== desired.retention_days
|
|
94
|
+
|| current.retention_days !== desired.retention_days
|
|
95
|
+
|| current.notify_owner !== desired.notify_owner;
|
|
91
96
|
const versionChanged = JSON.stringify(current.definition) !== JSON.stringify(desired.definition);
|
|
92
97
|
return {
|
|
93
98
|
form,
|
|
@@ -114,7 +119,7 @@ export async function formsPush(invocation) {
|
|
|
114
119
|
const session = await openSession(invocation);
|
|
115
120
|
const { client, config, siteId, onProgress } = session;
|
|
116
121
|
warnIfExternalWritesPaused(session, VERB_FORMS_PUSH);
|
|
117
|
-
const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys);
|
|
122
|
+
const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys, { deferNewsletterSemantics: true });
|
|
118
123
|
onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"}.`);
|
|
119
124
|
let manifest = await readManifest(config.workspaceDir, siteId);
|
|
120
125
|
|
|
@@ -132,6 +137,14 @@ export async function formsPush(invocation) {
|
|
|
132
137
|
pages = await withRefusalGuidance(onProgress, "forms push", async () => await getPageIndex(client, siteId, { onProgress }));
|
|
133
138
|
}
|
|
134
139
|
const plan = planForm(form, remote, recordedForm(manifest, form.key), pages);
|
|
140
|
+
// Legacy newsletter definitions may stay only after the baseline matches.
|
|
141
|
+
// New capture or a changed definition must satisfy today's admission rules.
|
|
142
|
+
if (
|
|
143
|
+
plan.desired.sink === "newsletter"
|
|
144
|
+
&& (plan.action === "create" || plan.remote.form.sink !== "newsletter" || plan.versionChanged)
|
|
145
|
+
) {
|
|
146
|
+
validateNewsletterDefinition(form.file, plan.desired.definition);
|
|
147
|
+
}
|
|
135
148
|
// A page_path the site does not have is refused here, with the rest of the checks, not partway
|
|
136
149
|
// through the writes. Only a definition that is going to be sent is resolved: a settings-only
|
|
137
150
|
// update of a form whose page was deleted never sends it.
|
|
@@ -151,7 +164,7 @@ export async function formsPush(invocation) {
|
|
|
151
164
|
// The baseline is what the site stored, not what the file said: the site
|
|
152
165
|
// normalizes some values, and the next push is fenced against its copy.
|
|
153
166
|
let settings = plan.remote === undefined ? undefined : siteSettingsBaseline(plan.remote.form);
|
|
154
|
-
|
|
167
|
+
const applied = { settings: false, definition: false };
|
|
155
168
|
try {
|
|
156
169
|
if (plan.action === "create") {
|
|
157
170
|
const created = await withRefusalGuidance(
|
|
@@ -166,43 +179,48 @@ export async function formsPush(invocation) {
|
|
|
166
179
|
// and normalized the name.
|
|
167
180
|
desired = projectFormForWorkspace({
|
|
168
181
|
...desired,
|
|
169
|
-
|
|
170
|
-
sink: created.form.sink,
|
|
171
|
-
retention_days: created.form.retentionDays,
|
|
182
|
+
...settings,
|
|
172
183
|
}, pages?.pathById);
|
|
173
184
|
} else if (plan.action === "update") {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
// Enabling newsletter capture checks the stored definition, so its new
|
|
186
|
+
// email and consent fields must land first. Disabling must do the reverse.
|
|
187
|
+
const definitionFirst = plan.settingsChanged && plan.versionChanged
|
|
188
|
+
&& settings.sink === "none" && desired.sink === "newsletter";
|
|
189
|
+
for (const mutation of definitionFirst ? ["definition", "settings"] : ["settings", "definition"]) {
|
|
190
|
+
if (mutation === "settings") {
|
|
191
|
+
if (!plan.settingsChanged) continue;
|
|
192
|
+
settings = siteSettingsBaseline(
|
|
193
|
+
await withRefusalGuidance(
|
|
194
|
+
onProgress,
|
|
195
|
+
"forms push",
|
|
196
|
+
async () => await updateSiteFormSettings(client, siteId, formId, desired, plan.baseline.settings),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
desired = projectFormForWorkspace({ ...desired, ...settings }, pages?.pathById);
|
|
200
|
+
} else {
|
|
201
|
+
if (!plan.versionChanged) continue;
|
|
202
|
+
version = (await withRefusalGuidance(
|
|
177
203
|
onProgress,
|
|
178
204
|
"forms push",
|
|
179
|
-
async () => await
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
//
|
|
205
|
+
async () => await saveSiteFormVersion(client, siteId, formId, { ...desired, definition: plan.wireDefinition }, plan.baseline.version),
|
|
206
|
+
)).version;
|
|
207
|
+
}
|
|
208
|
+
applied[mutation] = true;
|
|
209
|
+
// A retry must recognize either write as its own, even if the next
|
|
210
|
+
// mutation fails. Keep the other half of the baseline until it lands.
|
|
185
211
|
manifest = withRecordedForm(manifest, form.key, { id: formId, version, settings });
|
|
186
212
|
await writeManifest(config.workspaceDir, manifest);
|
|
187
213
|
}
|
|
188
|
-
if (plan.versionChanged) {
|
|
189
|
-
version = (await withRefusalGuidance(
|
|
190
|
-
onProgress,
|
|
191
|
-
"forms push",
|
|
192
|
-
async () => await saveSiteFormVersion(client, siteId, formId, { ...desired, definition: plan.wireDefinition }, plan.baseline.version),
|
|
193
|
-
)).version;
|
|
194
|
-
}
|
|
195
214
|
}
|
|
196
215
|
} catch (caught) {
|
|
197
216
|
// The site checks the recorded version and settings under its own lock,
|
|
198
217
|
// which is what closes the gap between the comparison above and the write.
|
|
199
218
|
const error = caught instanceof ApiError && (caught.hasField("ExpectedVersion") || caught.hasField("ExpectedSettings"))
|
|
200
|
-
? changedOnTheSite(form.key, caught,
|
|
219
|
+
? changedOnTheSite(form.key, caught, applied)
|
|
201
220
|
: caught;
|
|
202
221
|
if (error instanceof SiteAuthoringError) {
|
|
203
222
|
const completed = results.filter((item) => item.action !== "unchanged").map((item) => item.key);
|
|
204
|
-
|
|
205
|
-
if (settingsApplied) completed.push(form.key);
|
|
223
|
+
if (applied.settings || applied.definition) completed.push(form.key);
|
|
206
224
|
if (completed.length > 0) throw error.withCompletedWrites(completed);
|
|
207
225
|
}
|
|
208
226
|
throw error;
|
|
@@ -222,6 +240,6 @@ export async function formsPush(invocation) {
|
|
|
222
240
|
return successResult(VERB_FORMS_PUSH, siteId, {
|
|
223
241
|
forms: { count: results.length, items: results },
|
|
224
242
|
written: { items: written, count: written.length },
|
|
225
|
-
...(
|
|
243
|
+
...(plans.some((plan) => plan.action === "create" || plan.versionChanged) ? { nextStep: "deploy --staging" } : {}),
|
|
226
244
|
});
|
|
227
245
|
}
|
package/src/verbs/index.js
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
VERB_NAV_PUSH,
|
|
17
17
|
VERB_PAGES_META_SET,
|
|
18
18
|
VERB_PAGES_PUSH,
|
|
19
|
+
VERB_PLACES_CATEGORY_CLEAR,
|
|
20
|
+
VERB_PLACES_CATEGORY_LIST,
|
|
21
|
+
VERB_PLACES_CATEGORY_SET,
|
|
19
22
|
VERB_PLACES_SEARCH,
|
|
20
23
|
VERB_PLACES_SELECT,
|
|
21
24
|
VERB_PLAN,
|
|
@@ -48,7 +51,7 @@ import { mediaUpload } from "./media-upload.js";
|
|
|
48
51
|
import { navPush } from "./nav-push.js";
|
|
49
52
|
import { pagesMetaSet } from "./pages-meta-set.js";
|
|
50
53
|
import { pagesPush } from "./pages-push.js";
|
|
51
|
-
import { placesSearch, placesSelect } from "./places.js";
|
|
54
|
+
import { placesCategoryClear, placesCategoryList, placesCategorySet, placesSearch, placesSelect } from "./places.js";
|
|
52
55
|
import { apply, plan } from "./plan.js";
|
|
53
56
|
import { previewPage } from "./preview-page.js";
|
|
54
57
|
import { previewRevoke } from "./preview-revoke.js";
|
|
@@ -106,6 +109,8 @@ import { whoami } from "./whoami.js";
|
|
|
106
109
|
* // with none, every file under forms/
|
|
107
110
|
* placeQuery, // places search: the words of the query
|
|
108
111
|
* placeSelection, // places select: [googlePlaceId, sessionToken]
|
|
112
|
+
* placeCategory, // places category set: [placeId, category]
|
|
113
|
+
* placeId, // places category clear: [placeId]
|
|
109
114
|
* pagePaths, // approve: narrow to these page paths; with none, every
|
|
110
115
|
* // draft the workspace manifest tracks is staged
|
|
111
116
|
* authorHandle, // authors add: the handle of the site author to create
|
|
@@ -148,6 +153,9 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
148
153
|
[VERB_PLAN]: plan,
|
|
149
154
|
[VERB_PLACES_SEARCH]: placesSearch,
|
|
150
155
|
[VERB_PLACES_SELECT]: placesSelect,
|
|
156
|
+
[VERB_PLACES_CATEGORY_SET]: placesCategorySet,
|
|
157
|
+
[VERB_PLACES_CATEGORY_CLEAR]: placesCategoryClear,
|
|
158
|
+
[VERB_PLACES_CATEGORY_LIST]: placesCategoryList,
|
|
151
159
|
[VERB_APPLY]: apply,
|
|
152
160
|
[VERB_REDIRECTS_CHECK]: redirectsCheck,
|
|
153
161
|
[VERB_REDIRECTS_PULL]: redirectsPull,
|