kalup 0.5.0 → 0.6.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.
@@ -108,7 +108,7 @@ var ir_1_schema_default = {
108
108
  $schema: "https://json-schema.org/draft/2020-12/schema",
109
109
  $id: "https://kalup.dev/schemas/ir-1.schema.json",
110
110
  title: "Kalup IR, version 1",
111
- description: "What a Kalup project's config files mean, derived by `kalup ir`, or what a portal holds, captured by `kalup snapshot` with an observation block. Change inside a version is additive and readers keep unknown fields, so the document and each resource accept fields this schema does not list. Below a resource everything is closed except `x` and the definition of a resource type this schema does not describe, which stays open until a later ir/1 release describes that type.",
111
+ description: "What a Kalup project's config files mean, derived by `kalup ir`, or what a portal holds, captured by `kalup snapshot` with an observation block. Open for additions within ir/1 (docs/compatibility.md): the document, each resource and everything below it accept fields this schema does not list, and readers keep or ignore them; a later 1.x may add a resource type, and a reader reports an address of a type it does not handle as unknown. Closed, because a new value changes what a reader may conclude: generator, a $ref, a tombstone, the read statuses in coverage, lifecycle.options, and a target's drift and adopt.",
112
112
  type: "object",
113
113
  required: [
114
114
  "irVersion",
@@ -264,29 +264,14 @@ var ir_1_schema_default = {
264
264
  "properties": {
265
265
  "label": { "type": "string" },
266
266
  "group": { "$ref": "#/$defs/ref" },
267
- "type": { "enum": [
268
- "string",
269
- "number",
270
- "bool",
271
- "date",
272
- "datetime",
273
- "enumeration",
274
- "phone_number"
275
- ] },
276
- "fieldType": { "enum": [
277
- "text",
278
- "textarea",
279
- "file",
280
- "phonenumber",
281
- "number",
282
- "booleancheckbox",
283
- "select",
284
- "radio",
285
- "checkbox",
286
- "date",
287
- "html",
288
- "calculation_equation"
289
- ] },
267
+ "type": {
268
+ "type": "string",
269
+ "description": "A value HubSpot defines; known values: string, number, bool, date, datetime, enumeration, phone_number. A later version may record one HubSpot adds."
270
+ },
271
+ "fieldType": {
272
+ "type": "string",
273
+ "description": "A value HubSpot defines; known values: text, textarea, file, phonenumber, number, booleancheckbox, select, radio, checkbox, date, html, calculation_equation. A later version may record one HubSpot adds."
274
+ },
290
275
  "description": { "type": "string" },
291
276
  "options": {
292
277
  "type": "array",
@@ -299,36 +284,25 @@ var ir_1_schema_default = {
299
284
  "type": "integer",
300
285
  "minimum": -1
301
286
  },
302
- "numberDisplayHint": { "enum": [
303
- "currency",
304
- "duration",
305
- "formatted",
306
- "percentage",
307
- "probability",
308
- "unformatted"
309
- ] },
287
+ "numberDisplayHint": {
288
+ "type": "string",
289
+ "description": "A value HubSpot defines; known values: currency, duration, formatted, percentage, probability, unformatted. A later version may record one HubSpot adds."
290
+ },
310
291
  "showCurrencySymbol": { "type": "boolean" },
311
292
  "currencyPropertyName": { "type": "string" },
312
- "textDisplayHint": { "enum": [
313
- "domain_name",
314
- "email",
315
- "ip_address",
316
- "multi_line",
317
- "phone_number",
318
- "physical_address",
319
- "postal_code",
320
- "unformatted_single_line"
321
- ] },
293
+ "textDisplayHint": {
294
+ "type": "string",
295
+ "description": "A value HubSpot defines; known values: domain_name, email, ip_address, multi_line, phone_number, physical_address, postal_code, unformatted_single_line. A later version may record one HubSpot adds."
296
+ },
322
297
  "calculationFormula": { "type": "string" },
323
- "dataSensitivity": { "enum": [
324
- "non_sensitive",
325
- "sensitive",
326
- "highly_sensitive"
327
- ] },
298
+ "dataSensitivity": {
299
+ "type": "string",
300
+ "description": "A value HubSpot defines; known values: non_sensitive, sensitive, highly_sensitive. A later version may record one HubSpot adds."
301
+ },
328
302
  "externalOptions": { "const": true },
329
303
  "referencedObjectType": { "const": "OWNER" }
330
304
  },
331
- "additionalProperties": false
305
+ "additionalProperties": true
332
306
  },
333
307
  "option": {
334
308
  "description": "One enumeration option. Array index is display order. The app alias lives in binding.aliases, never here.",
@@ -340,13 +314,14 @@ var ir_1_schema_default = {
340
314
  "hidden": { "type": "boolean" },
341
315
  "description": { "type": "string" }
342
316
  },
343
- "additionalProperties": false
317
+ "additionalProperties": true,
318
+ "patternProperties": { "^as$": false }
344
319
  },
345
320
  "groupDefinition": {
346
321
  "type": "object",
347
322
  "required": ["label"],
348
323
  "properties": { "label": { "type": "string" } },
349
- "additionalProperties": false
324
+ "additionalProperties": true
350
325
  },
351
326
  "objectDefinition": {
352
327
  "description": "A custom object schema. Its properties and groups are resources of their own.",
@@ -360,7 +335,7 @@ var ir_1_schema_default = {
360
335
  "singular": { "type": "string" },
361
336
  "plural": { "type": "string" }
362
337
  },
363
- "additionalProperties": false
338
+ "additionalProperties": true
364
339
  },
365
340
  "description": { "type": "string" },
366
341
  "primaryDisplayProperty": { "type": "string" },
@@ -377,7 +352,7 @@ var ir_1_schema_default = {
377
352
  "items": { "type": "string" }
378
353
  }
379
354
  },
380
- "additionalProperties": false
355
+ "additionalProperties": true
381
356
  },
382
357
  "pipelineDefinition": {
383
358
  "description": "A pipeline. Its stages are resources of their own; stages lists their IDs in display order.",
@@ -398,7 +373,7 @@ var ir_1_schema_default = {
398
373
  "items": { "type": "string" }
399
374
  }
400
375
  },
401
- "additionalProperties": false
376
+ "additionalProperties": true
402
377
  },
403
378
  "stageDefinition": {
404
379
  "description": "A pipeline stage. Its metadata field depends on the object: probability on deals, ticketState on tickets, state on custom objects.",
@@ -411,10 +386,16 @@ var ir_1_schema_default = {
411
386
  "minimum": 0,
412
387
  "maximum": 1
413
388
  },
414
- "ticketState": { "enum": ["OPEN", "CLOSED"] },
415
- "state": { "enum": ["OPEN", "CLOSED"] }
389
+ "ticketState": {
390
+ "type": "string",
391
+ "description": "A value HubSpot defines; known values: OPEN, CLOSED. A later version may record one HubSpot adds."
392
+ },
393
+ "state": {
394
+ "type": "string",
395
+ "description": "A value HubSpot defines; known values: OPEN, CLOSED. A later version may record one HubSpot adds."
396
+ }
416
397
  },
417
- "additionalProperties": false
398
+ "additionalProperties": true
418
399
  },
419
400
  "associationDefinition": {
420
401
  "description": "An association label between two objects: label as the from object shows it, inverseLabel as the to object does. With neither, the plain association of a pair that has no HubSpot-defined one.",
@@ -423,26 +404,18 @@ var ir_1_schema_default = {
423
404
  "label": { "type": "string" },
424
405
  "inverseLabel": { "type": "string" }
425
406
  },
426
- "additionalProperties": false
407
+ "additionalProperties": true
427
408
  },
428
409
  "binding": {
429
410
  "description": "App terms, filled in by the loader. key and codec for properties, export for objects.",
430
411
  "type": "object",
431
412
  "properties": {
432
413
  "key": { "type": "string" },
433
- "codec": { "enum": [
434
- "string",
435
- "number",
436
- "boolean",
437
- "date",
438
- "datetime",
439
- "enum",
440
- "multiEnum",
441
- "stringArray",
442
- "json",
443
- "phoneNumber",
444
- "owner"
445
- ] },
414
+ "codec": {
415
+ "type": "string",
416
+ "pattern": "^[a-zA-Z]+$",
417
+ "description": "The builder. Known: string, number, boolean, date, datetime, enum, multiEnum, stringArray, json, phoneNumber, owner. A later version may add a builder."
418
+ },
446
419
  "aliases": {
447
420
  "type": "object",
448
421
  "additionalProperties": { "type": "string" }
@@ -452,7 +425,7 @@ var ir_1_schema_default = {
452
425
  "strict": { "type": "boolean" },
453
426
  "export": { "type": "string" }
454
427
  },
455
- "additionalProperties": false
428
+ "additionalProperties": true
456
429
  },
457
430
  "lifecycle": {
458
431
  "type": "object",
@@ -469,7 +442,7 @@ var ir_1_schema_default = {
469
442
  },
470
443
  "preventDestroy": { "type": "boolean" }
471
444
  },
472
- "additionalProperties": false
445
+ "additionalProperties": true
473
446
  },
474
447
  "provenance": {
475
448
  "description": "Merged from blueprints.lock.json in the folder of object files by the loader. Absent means authored by hand.",
@@ -488,7 +461,7 @@ var ir_1_schema_default = {
488
461
  "prefix": { "type": "string" },
489
462
  "hash": { "type": "string" }
490
463
  },
491
- "additionalProperties": false
464
+ "additionalProperties": true
492
465
  },
493
466
  "target": {
494
467
  "description": "One named portal. Credentials never enter the IR.",
@@ -521,7 +494,8 @@ var ir_1_schema_default = {
521
494
  "additionalProperties": false
522
495
  }
523
496
  },
524
- "additionalProperties": false
497
+ "additionalProperties": true,
498
+ "patternProperties": { "^credentials$": false }
525
499
  },
526
500
  "override": {
527
501
  "type": "object",
@@ -534,7 +508,7 @@ var ir_1_schema_default = {
534
508
  "additionalProperties": { "type": "string" }
535
509
  }
536
510
  },
537
- "additionalProperties": false
511
+ "additionalProperties": true
538
512
  },
539
513
  "observation": {
540
514
  "description": "What a snapshot read: the target, when, and how completely. A portal-frontend document requires it. observedAt is the one timestamp an ir/1 document carries.",
@@ -564,7 +538,7 @@ var ir_1_schema_default = {
564
538
  },
565
539
  "coverage": { "$ref": "#/$defs/coverage" }
566
540
  },
567
- "additionalProperties": false
541
+ "additionalProperties": true
568
542
  },
569
543
  "coverage": {
570
544
  "description": "What the read covered. Only a complete read proves that a resource is absent.",
@@ -589,44 +563,38 @@ var ir_1_schema_default = {
589
563
  "if": { "type": "string" },
590
564
  "then": { "const": "unknown" }
591
565
  },
566
+ "settling": {
567
+ "description": "Resources the read cannot be trusted on yet, by address: apply wrote them minutes ago and HubSpot still serves an older copy (stale), or does not list one Kalup created (missing). Unknown until the time given, never absent or drifted.",
568
+ "type": "object",
569
+ "additionalProperties": {
570
+ "type": "object",
571
+ "required": ["reason", "until"],
572
+ "properties": {
573
+ "reason": {
574
+ "type": "string",
575
+ "description": "Why it settles. Known: stale, missing. A later version may add a reason."
576
+ },
577
+ "until": { "type": "string" }
578
+ },
579
+ "additionalProperties": true
580
+ }
581
+ },
592
582
  "notCaptured": {
593
- "description": "Documented response fields Kalup does not capture, per resource type.",
583
+ "description": "Documented response fields Kalup drops, by resource type. A later version may add a type.",
594
584
  "type": "object",
595
585
  "required": [
596
586
  "property",
597
587
  "group",
598
588
  "object"
599
589
  ],
600
- "properties": {
601
- "property": {
602
- "type": "array",
603
- "items": { "type": "string" }
604
- },
605
- "group": {
606
- "type": "array",
607
- "items": { "type": "string" }
608
- },
609
- "object": {
610
- "type": "array",
611
- "items": { "type": "string" }
612
- },
613
- "pipeline": {
614
- "type": "array",
615
- "items": { "type": "string" }
616
- },
617
- "stage": {
618
- "type": "array",
619
- "items": { "type": "string" }
620
- },
621
- "association": {
622
- "type": "array",
623
- "items": { "type": "string" }
624
- }
625
- },
626
- "additionalProperties": false
590
+ "additionalProperties": false,
591
+ "patternProperties": { "^[a-z]+$": {
592
+ "type": "array",
593
+ "items": { "type": "string" }
594
+ } }
627
595
  }
628
596
  },
629
- "additionalProperties": false
597
+ "additionalProperties": true
630
598
  },
631
599
  "objectCoverage": {
632
600
  "type": "object",
@@ -697,7 +665,7 @@ var ir_1_schema_default = {
697
665
  "items": { "type": "string" }
698
666
  }
699
667
  },
700
- "additionalProperties": false
668
+ "additionalProperties": true
701
669
  },
702
670
  "excluded": {
703
671
  "description": "Addresses a skip override leaves out.",
@@ -718,7 +686,7 @@ var ir_1_schema_default = {
718
686
  "status": { "enum": ["read", "unreadable"] },
719
687
  "missingScope": { "type": "string" }
720
688
  },
721
- "additionalProperties": false
689
+ "additionalProperties": true
722
690
  },
723
691
  "associations": {
724
692
  "description": "The associations of the pairs this object is in, pair by pair: per other object, whether the pair was read, and the user-defined types of this direction no name reached; and the HubSpot type IDs of each association addressed from this object. Absent when no pair with this object is in scope.",
@@ -737,10 +705,10 @@ var ir_1_schema_default = {
737
705
  }
738
706
  }
739
707
  },
740
- "additionalProperties": false
708
+ "additionalProperties": true
741
709
  }
742
710
  },
743
- "additionalProperties": false
711
+ "additionalProperties": true
744
712
  },
745
713
  "unsupportedProperty": {
746
714
  "description": "A present property Kalup does not write: no builder carries its type or fieldType, or HubSpot fills its options (owner or externalOptions). It is compared like any property but is not a resource.",
@@ -768,7 +736,7 @@ var ir_1_schema_default = {
768
736
  "externalOptions": { "type": "boolean" },
769
737
  "referencedObjectType": { "type": "string" }
770
738
  },
771
- "additionalProperties": false
739
+ "additionalProperties": true
772
740
  },
773
741
  "tombstone": {
774
742
  "description": "Written by `kalup rm`. destroy archives or deletes in the portal; release stops managing and leaves it.",
@@ -801,7 +769,7 @@ var ir_1_schema_default = {
801
769
  }
802
770
  }
803
771
  },
804
- "additionalProperties": false
772
+ "additionalProperties": true
805
773
  }
806
774
  }
807
775
  };
@@ -825,6 +793,11 @@ function sortKeys(value) {
825
793
  return Object.fromEntries(Object.keys(record).sort().map((key) => [key, sortKeys(record[key])]));
826
794
  }
827
795
  const IR_SCHEMA = ir_1_schema_default;
796
+ /** The fields of a read's coverage and of an object's coverage this version knows: any other is a later version's. */
797
+ const COVERAGE_FIELDS = {
798
+ coverage: new Set(Object.keys((IR_SCHEMA.$defs?.coverage)?.properties ?? {})),
799
+ object: new Set(Object.keys((IR_SCHEMA.$defs?.objectCoverage)?.properties ?? {}))
800
+ };
828
801
  /** Checks a document against ir-1.schema.json. Empty when it conforms. */
829
802
  function validateIR(document) {
830
803
  return validateSchema(IR_SCHEMA, document).map(({ path, message }) => ({
@@ -865,7 +838,11 @@ function validateSchema(root, document) {
865
838
  const own = ownEntry(schema.properties, name);
866
839
  const patterns = Object.entries(schema.patternProperties ?? {}).filter(([pattern]) => new RegExp(pattern, "u").test(name));
867
840
  if (own) check(own, field, path, out);
868
- for (const [, pattern] of patterns) check(pattern, field, path, out);
841
+ for (const [, pattern] of patterns) if (pattern === false) out.push({
842
+ path,
843
+ message: `unexpected field "${name}"`
844
+ });
845
+ else check(pattern, field, path, out);
869
846
  if (own || patterns.length > 0) return;
870
847
  if (schema.additionalProperties === false) out.push({
871
848
  path,
@@ -943,11 +920,14 @@ function assertSupported(value, at, root = false) {
943
920
  if (part !== void 0 && !(keyword === "additionalProperties" && typeof part === "boolean")) assertSupported(part, `${at}/${keyword}`);
944
921
  }
945
922
  for (const [index, part] of (schema.allOf ?? []).entries()) assertSupported(part, `${at}/allOf/${index}`);
923
+ assertNamed(schema, at);
924
+ }
925
+ function assertNamed(schema, at) {
946
926
  for (const keyword of [
947
927
  "$defs",
948
928
  "properties",
949
929
  "patternProperties"
950
- ]) for (const [name, part] of Object.entries(schema[keyword] ?? {})) assertSupported(part, `${at}/${keyword}/${name}`);
930
+ ]) for (const [name, part] of Object.entries(schema[keyword] ?? {})) if (!(keyword === "patternProperties" && part === false)) assertSupported(part, `${at}/${keyword}/${name}`);
951
931
  }
952
932
  function ownEntry(record, key) {
953
933
  return record && Object.hasOwn(record, key) ? record[key] : void 0;
@@ -2492,7 +2472,7 @@ function parseProperty(s, path, key, cs) {
2492
2472
  };
2493
2473
  if (kind === "json") {
2494
2474
  expect(s, "punct", ",", "add the validator as the second argument", path);
2495
- prop.json = { validatorSource: opaque(s, path) };
2475
+ prop.json = { validatorSource: opaque$1(s, path) };
2496
2476
  }
2497
2477
  if (skip(s, ",") && !is(peek(s), "punct", ")")) {
2498
2478
  prop.definition = definition(s, path);
@@ -2525,7 +2505,7 @@ function chainCall(s, m, path) {
2525
2505
  if (!is(next(s), "punct", ")")) bad(`.${flag}() takes ${managed ? "only false" : "no argument"}`, call);
2526
2506
  return flag;
2527
2507
  }
2528
- function opaque(s, path) {
2508
+ function opaque$1(s, path) {
2529
2509
  const t = next(s);
2530
2510
  if (t.kind !== "opaque" || !t.value) fail$1(s, "E_NOT_DATA", t, "p.json needs a validator", "write p.json(name, validator, {...})", path);
2531
2511
  return t.value;
@@ -4493,7 +4473,7 @@ var state_1_schema_default = {
4493
4473
  $schema: "https://json-schema.org/draft/2020-12/schema",
4494
4474
  $id: "https://kalup.dev/schemas/state-1.schema.json",
4495
4475
  title: "Kalup portal state, version 1",
4496
- description: ".kalup/state/portal-<portalId>.json. Describes one verified portal, not the code, and holds no target name. Covered by the compatibility policy (docs/compatibility.md): closed at every level apart from the free-form values in an entry's base and rewrites, so a new field is a new format version. No tokens, record data, unowned fields or unmanaged resources.",
4476
+ description: ".kalup/state/portal-<portalId>.json. Describes one verified portal, not the code, and holds no target name. Covered by the compatibility policy (docs/compatibility.md). Open for additions within kalup.state/1: a later 1.x may add top-level fields, resource entries of types this version does not plan, and fields on an entry. A reader keeps what it does not know: it never drops an entry of a type it does not handle or a top-level field it does not know, and it drops an entry's unknown fields only when it rewrites that entry. lastApply, an entry's attested and rewrites, and the base's option members stay closed. Kalup never writes a key, record data, unowned fields or unmanaged resources: its state writer refuses a file holding anything shaped like a key.",
4497
4477
  type: "object",
4498
4478
  required: [
4499
4479
  "format",
@@ -4554,20 +4534,16 @@ var state_1_schema_default = {
4554
4534
  "additionalProperties": false
4555
4535
  }
4556
4536
  },
4557
- additionalProperties: false,
4537
+ additionalProperties: true,
4558
4538
  $defs: {
4559
4539
  "resourceState": {
4560
4540
  "type": "object",
4561
4541
  "required": ["origin", "id"],
4562
4542
  "properties": {
4563
4543
  "origin": {
4564
- "description": "created and adopted are owned. pulled owns nothing: pull recorded the base of a resource no entry owned, and a plan still adopts it.",
4565
- "enum": [
4566
- "created",
4567
- "adopted",
4568
- "reference",
4569
- "pulled"
4570
- ]
4544
+ "description": "created and adopted are owned. pulled owns nothing: pull recorded the base of a resource no entry owned, and a plan still adopts it. reference owns nothing. Another value, from a later 1.x, owns nothing here.",
4545
+ "type": "string",
4546
+ "pattern": "^[a-z]+$"
4571
4547
  },
4572
4548
  "id": {
4573
4549
  "description": "The portal name the entry owns. It owns the resource only while this equals the name the address resolves to. null for runbook-only types.",
@@ -4612,9 +4588,18 @@ var state_1_schema_default = {
4612
4588
  "description": "An association's HubSpot type IDs in this portal, its direction's first: they name it while HubSpot's schema read does not list its name yet.",
4613
4589
  "type": "array",
4614
4590
  "items": { "type": "integer" }
4591
+ },
4592
+ "written": {
4593
+ "description": "The units apply wrote within the settling window before its last write, each with when it verified the write. For a few minutes HubSpot may serve an older copy, so a read that disagrees on such a unit is settling: unknown, never drift.",
4594
+ "type": "object",
4595
+ "additionalProperties": { "type": "string" }
4596
+ },
4597
+ "writtenAt": {
4598
+ "description": "When apply last verified a write to the resource, whatever units it named. A read that does not show the resource within the settling window after it is settling: unknown, never absence.",
4599
+ "type": "string"
4615
4600
  }
4616
4601
  },
4617
- "additionalProperties": false
4602
+ "additionalProperties": true
4618
4603
  },
4619
4604
  "base": {
4620
4605
  "description": "Per owned unit, the value config and portal last agreed on; possibly partial. Scalar units by field name. options: a map keyed by option value whose members hold the agreed label, hidden and description; a member with no fields means only its membership is agreed. optionsOrder: the agreed order of the members both sides held.",
@@ -4660,118 +4645,6 @@ var KalupError = class extends Error {
4660
4645
  this.exitCode = exitCode;
4661
4646
  }
4662
4647
  };
4663
- const ansi = /\u001b\[[0-?]*[ -/]*[@-~]/g;
4664
- const control = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
4665
- /** Strips ANSI sequences, control and bidirectional formatting characters and newlines, and caps the length. */
4666
- function sanitize(text, max = 120) {
4667
- const clean = text.replace(ansi, "").replace(control, "");
4668
- const chars = Array.from(clean);
4669
- return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : clean;
4670
- }
4671
- const STATE_SCHEMA = state_1_schema_default;
4672
- /** Checks a document against state-1.schema.json. Empty when it conforms. */
4673
- function validateState(document) {
4674
- return validateSchema(STATE_SCHEMA, document).map(({ path, message }) => ({
4675
- code: "E_STATE_SCHEMA",
4676
- message,
4677
- ...path ? { configPath: path } : {}
4678
- }));
4679
- }
4680
- const FORMAT = "kalup.state/1";
4681
- const notJson$3 = Symbol("not JSON");
4682
- /**
4683
- * The state of `portalId` from the text of `file`, where the host keeps it. E_STATE_INVALID when the text is not JSON,
4684
- * names a format other than kalup.state/1, does not match its schema, or describes another portal. `target` names the
4685
- * target in the fix.
4686
- */
4687
- function parseState(text, file, portalId, target) {
4688
- const document = parseJson$3(text);
4689
- if (document === notJson$3) throw invalid$1(file, "is not JSON", target);
4690
- const format = document?.format;
4691
- if (typeof format === "string" && format !== FORMAT) throw new KalupError({
4692
- code: "E_STATE_INVALID",
4693
- message: `${file} is ${sanitize(format)}, and this version of ${bin} reads ${FORMAT}.`,
4694
- file,
4695
- fix: `use the version of ${bin} that wrote it, or a newer one`
4696
- });
4697
- const issues = validateState(document);
4698
- if (issues.length > 0) {
4699
- const [first] = issues;
4700
- const at = first?.configPath ? ` at ${first.configPath}` : "";
4701
- throw invalid$1(file, `does not match ${FORMAT}${at}: ${first?.message}`, target);
4702
- }
4703
- const state = document;
4704
- if (state.portalId !== portalId) throw invalid$1(file, `describes portal ${state.portalId}, not portal ${portalId}`, target);
4705
- return state;
4706
- }
4707
- function parseJson$3(text) {
4708
- try {
4709
- return JSON.parse(text);
4710
- } catch {
4711
- return notJson$3;
4712
- }
4713
- }
4714
- function invalid$1(file, why, target = "<target>") {
4715
- const name = file.slice(Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + 1);
4716
- return new KalupError({
4717
- code: "E_STATE_INVALID",
4718
- message: `${file} ${why}.`,
4719
- file,
4720
- fix: `rename ${name}.bak, the state before its last save, into its place if it reads; else move the file away and run ${bin} state rebuild --target ${target}`
4721
- });
4722
- }
4723
- /** An association address written from the other side: `association:<b>/<a>/<name>` for `association:<a>/<b>/<name>`. */
4724
- function reversedAssociation(address) {
4725
- if (!address.startsWith("association:")) return;
4726
- const [from, to, ...name] = address.slice(12).split("/");
4727
- return `association:${to}/${from}/${name.join("/")}`;
4728
- }
4729
- /**
4730
- * State with each association entry under the address `held` gives the association. An association's identity is its
4731
- * name, and its direction is only how config writes it, so an entry at the reversed address moves to the held one, its
4732
- * labels and type IDs swapped; one the held address has an entry of the same name for already is dropped. Every save
4733
- * of the state so read keeps the move.
4734
- */
4735
- function followAssociations(state, held) {
4736
- if (state === null) return state;
4737
- const resources = { ...state.resources };
4738
- let moved = false;
4739
- for (const [address, entry] of Object.entries(state.resources)) {
4740
- const reversed = reversedAssociation(address);
4741
- if (reversed === void 0 || held(address) || !held(reversed)) continue;
4742
- const there = Object.hasOwn(resources, reversed) ? resources[reversed] : void 0;
4743
- if (there !== void 0 && there.id !== entry.id) continue;
4744
- delete resources[address];
4745
- resources[reversed] = there ?? reversedEntry(entry);
4746
- moved = true;
4747
- }
4748
- return moved ? {
4749
- ...state,
4750
- resources
4751
- } : state;
4752
- }
4753
- function reversedEntry(entry) {
4754
- const { base, rewrites, typeIds, ...rest } = entry;
4755
- const out = { ...rest };
4756
- if (base !== void 0) out.base = swapLabels(base);
4757
- if (rewrites !== void 0) out.rewrites = swapLabels(rewrites);
4758
- if (typeIds !== void 0) out.typeIds = [typeIds[1], typeIds[0]];
4759
- return out;
4760
- }
4761
- function swapLabels(units) {
4762
- const { label, inverseLabel, ...rest } = units;
4763
- return {
4764
- ...rest,
4765
- ...inverseLabel === void 0 ? {} : { label: inverseLabel },
4766
- ...label === void 0 ? {} : { inverseLabel: label }
4767
- };
4768
- }
4769
- /** The type IDs state records per association address, which name a label HubSpot's schema read does not list yet. */
4770
- function associationIds(state) {
4771
- const out = {};
4772
- for (const [address, entry] of Object.entries(state?.resources ?? {})) if (entry.typeIds !== void 0 && address.startsWith("association:")) out[address] = entry.typeIds;
4773
- return out;
4774
- }
4775
4648
  const registry = {
4776
4649
  property: {
4777
4650
  family: "crm.properties",
@@ -5076,6 +4949,14 @@ const NORM_VERSIONS = {
5076
4949
  association: 1
5077
4950
  };
5078
4951
  /**
4952
+ * Whether this version plans and applies resources of `type`: every such type has a normalizer version. A later 1.x
4953
+ * may add a type within the format versions; this version keeps such entries in state, reports them as not handled,
4954
+ * and refuses a plan step of one (docs/compatibility.md).
4955
+ */
4956
+ function handledType(type) {
4957
+ return Object.hasOwn(NORM_VERSIONS, type);
4958
+ }
4959
+ /**
5079
4960
  * Standard objects whose properties and groups read under a scope other than `crm.schemas.<object>.read`, checked
5080
4961
  * against HubSpot's scopes reference and the scope lists of the 2026-09 properties and property groups list paths.
5081
4962
  * Commerce payments drop the underscore. Products have no read scope on those lists but the legacy e-commerce, which
@@ -5114,51 +4995,218 @@ const standardName = /^[a-z_]+$/;
5114
4995
  function readScope(row, objectType = "") {
5115
4996
  return scopeFor$1(row.scopes?.read[0], scopeExceptions, objectType);
5116
4997
  }
5117
- function writeScope(row, objectType = "") {
5118
- return scopeFor$1(row.scopes?.write[0], writeScopeExceptions, objectType);
4998
+ function writeScope(row, objectType = "") {
4999
+ return scopeFor$1(row.scopes?.write[0], writeScopeExceptions, objectType);
5000
+ }
5001
+ /**
5002
+ * The scope one request needs: its row's for the object its path names. A labels path names two objects, so it needs
5003
+ * both their scopes, named in code-unit order: plan and apply name the same whichever direction they read.
5004
+ */
5005
+ function requestScope(type, params, mode) {
5006
+ const row = Object.hasOwn(registry, type) ? registry[type] : void 0;
5007
+ if (row === void 0) return;
5008
+ const scope = (objectType) => mode === "read" ? readScope(row, objectType) : writeScope(row, objectType);
5009
+ const { fromObjectType, toObjectType } = params ?? {};
5010
+ if (fromObjectType !== void 0 && toObjectType !== void 0) {
5011
+ const both = [scope(fromObjectType), scope(toObjectType)].filter((s) => s !== void 0);
5012
+ return [...new Set(both)].sort().join(" and ");
5013
+ }
5014
+ return scope(params?.objectType);
5015
+ }
5016
+ const LIMIT_OBJECTS = /* @__PURE__ */ new Set([
5017
+ "companies",
5018
+ "contacts",
5019
+ "deals"
5020
+ ]);
5021
+ /**
5022
+ * The crm.objects read scope Kalup recommends so plan can read the property limit: on the first of `objects` that is
5023
+ * companies, contacts or deals, else on companies. HubSpot's Limits Tracking custom-properties answered 403 to a key
5024
+ * with crm.schemas scopes only, and 200 once crm.objects.companies.read was added, on a developer test account (runs
5025
+ * 89b45da9 and fb6155db, 2026-09-29).
5026
+ */
5027
+ function limitScope(objects) {
5028
+ return `crm.objects.${objects.find((o) => LIMIT_OBJECTS.has(o)) ?? "companies"}.read`;
5029
+ }
5030
+ function scopeFor$1(template, exceptions, objectType) {
5031
+ if (!template?.includes("{object}")) return template;
5032
+ const object = standardName.test(objectType) ? objectType : "custom";
5033
+ if (Object.hasOwn(exceptions, object)) return exceptions[object];
5034
+ return template.replace("{object}", object);
5035
+ }
5036
+ /** Fills every `{placeholder}` in a path template, for example `{objectType}` and `{name}`. */
5037
+ function fillPath(template, params) {
5038
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
5039
+ const value = params[key];
5040
+ if (value === void 0) throw new Error(`Path ${template} needs {${key}}`);
5041
+ return encodeURIComponent(value);
5042
+ });
5043
+ }
5044
+ const ansi = /\u001b\[[0-?]*[ -/]*[@-~]/g;
5045
+ const control = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
5046
+ /** Strips ANSI sequences, control and bidirectional formatting characters and newlines, and caps the length. */
5047
+ function sanitize(text, max = 120) {
5048
+ const clean = text.replace(ansi, "").replace(control, "");
5049
+ const chars = Array.from(clean);
5050
+ return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : clean;
5051
+ }
5052
+ const KEY_SHAPE = /pat-[a-z0-9]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
5053
+ /** Whether `value` holds one of `keys`, the keys the run holds, or anything shaped like a HubSpot key. */
5054
+ function holdsKey(value, keys = []) {
5055
+ return KEY_SHAPE.test(value) || keys.some((key) => key !== "" && value.includes(key));
5056
+ }
5057
+ const STATE_SCHEMA = state_1_schema_default;
5058
+ const STATE_FIELDS = new Set(Object.keys(STATE_SCHEMA.properties ?? {}));
5059
+ const ENTRY_FIELDS = new Set(Object.keys((STATE_SCHEMA.$defs?.resourceState)?.properties ?? {}));
5060
+ /** An entry with only the fields this version knows: what a rewrite keeps of it. */
5061
+ function knownFields(entry) {
5062
+ return Object.fromEntries(Object.entries(entry).filter(([field]) => ENTRY_FIELDS.has(field)));
5063
+ }
5064
+ /** Checks a document against state-1.schema.json. Empty when it conforms. */
5065
+ function validateState(document) {
5066
+ return validateSchema(STATE_SCHEMA, document).map(({ path, message }) => ({
5067
+ code: "E_STATE_SCHEMA",
5068
+ message,
5069
+ ...path ? { configPath: path } : {}
5070
+ }));
5071
+ }
5072
+ const FORMAT = "kalup.state/1";
5073
+ const notJson$3 = Symbol("not JSON");
5074
+ /**
5075
+ * The state of `portalId` from the text of `file`, where the host keeps it. E_STATE_INVALID when the text is not JSON,
5076
+ * names a format other than kalup.state/1, does not match its schema, or describes another portal. `target` names the
5077
+ * target in the fix.
5078
+ */
5079
+ function parseState(text, file, portalId, target) {
5080
+ const document = parseJson$3(text);
5081
+ if (document === notJson$3) throw invalid$1(file, "is not JSON", target);
5082
+ const format = document?.format;
5083
+ if (typeof format === "string" && format !== FORMAT) throw new KalupError({
5084
+ code: "E_STATE_INVALID",
5085
+ message: `${file} is ${sanitize(format)}, and this version of ${bin} reads ${FORMAT}.`,
5086
+ file,
5087
+ fix: `use the version of ${bin} that wrote it, or a newer one`
5088
+ });
5089
+ const issues = validateState(document);
5090
+ if (issues.length > 0) {
5091
+ const [first] = issues;
5092
+ const at = first?.configPath ? ` at ${first.configPath}` : "";
5093
+ throw invalid$1(file, `does not match ${FORMAT}${at}: ${first?.message}`, target);
5094
+ }
5095
+ const state = document;
5096
+ if (state.portalId !== portalId) throw invalid$1(file, `describes portal ${state.portalId}, not portal ${portalId}`, target);
5097
+ return withLater(state);
5098
+ }
5099
+ function withLater(document) {
5100
+ const fields = Object.fromEntries(Object.entries(document).filter(([key]) => !STATE_FIELDS.has(key)));
5101
+ const entries = Object.entries(document.resources);
5102
+ const resources = Object.fromEntries(entries.filter(([address]) => !handled(address)));
5103
+ const state = {
5104
+ ...Object.fromEntries(Object.entries(document).filter(([key]) => STATE_FIELDS.has(key))),
5105
+ resources: Object.fromEntries(entries.filter(([a]) => handled(a)))
5106
+ };
5107
+ if (Object.keys(fields).length + Object.keys(resources).length > 0) state.later = {
5108
+ fields,
5109
+ resources
5110
+ };
5111
+ return state;
5112
+ }
5113
+ function handled(address) {
5114
+ return handledType(parseAddress(address).type);
5119
5115
  }
5120
5116
  /**
5121
- * The scope one request needs: its row's for the object its path names. A labels path names two objects, so it needs
5122
- * both their scopes, named in code-unit order: plan and apply name the same whichever direction they read.
5117
+ * The text a host writes for `state`: what a later version wrote merged back as it was, checked against kalup.state/1,
5118
+ * one JSON document and a newline. Throws, writing nothing, when the document does not match the schema or a string in
5119
+ * it, a key or a value, holds anything shaped like a HubSpot key.
5123
5120
  */
5124
- function requestScope(type, params, mode) {
5125
- const row = Object.hasOwn(registry, type) ? registry[type] : void 0;
5126
- if (row === void 0) return;
5127
- const scope = (objectType) => mode === "read" ? readScope(row, objectType) : writeScope(row, objectType);
5128
- const { fromObjectType, toObjectType } = params ?? {};
5129
- if (fromObjectType !== void 0 && toObjectType !== void 0) {
5130
- const both = [scope(fromObjectType), scope(toObjectType)].filter((s) => s !== void 0);
5131
- return [...new Set(both)].sort().join(" and ");
5121
+ function stateText(state) {
5122
+ const { later, ...known } = state;
5123
+ const document = {
5124
+ ...later?.fields,
5125
+ ...known,
5126
+ resources: {
5127
+ ...later?.resources,
5128
+ ...known.resources
5129
+ }
5130
+ };
5131
+ const problems = validateState(document);
5132
+ if (problems.length > 0) throw new Error(`refusing to save state that does not match kalup.state/1: ${problems[0]?.message}`);
5133
+ if (strings(document).some((value) => holdsKey(value))) throw new Error("refusing to save state that holds a key");
5134
+ return `${stableStringify(document)}\n`;
5135
+ }
5136
+ function strings(value) {
5137
+ if (typeof value === "string") return [value];
5138
+ if (Array.isArray(value)) return value.flatMap(strings);
5139
+ if (typeof value === "object" && value !== null) return Object.entries(value).flatMap(([key, field]) => [key, ...strings(field)]);
5140
+ return [];
5141
+ }
5142
+ function parseJson$3(text) {
5143
+ try {
5144
+ return JSON.parse(text);
5145
+ } catch {
5146
+ return notJson$3;
5132
5147
  }
5133
- return scope(params?.objectType);
5134
5148
  }
5135
- const LIMIT_OBJECTS = /* @__PURE__ */ new Set([
5136
- "companies",
5137
- "contacts",
5138
- "deals"
5139
- ]);
5149
+ function invalid$1(file, why, target = "<target>") {
5150
+ const name = file.slice(Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + 1);
5151
+ return new KalupError({
5152
+ code: "E_STATE_INVALID",
5153
+ message: `${file} ${why}.`,
5154
+ file,
5155
+ fix: `rename ${name}.bak, the state before its last save, into its place if it reads; else move the file away and run ${bin} state rebuild --target ${target}`
5156
+ });
5157
+ }
5158
+ /** An association address written from the other side: `association:<b>/<a>/<name>` for `association:<a>/<b>/<name>`. */
5159
+ function reversedAssociation(address) {
5160
+ if (!address.startsWith("association:")) return;
5161
+ const [from, to, ...name] = address.slice(12).split("/");
5162
+ return `association:${to}/${from}/${name.join("/")}`;
5163
+ }
5140
5164
  /**
5141
- * The crm.objects read scope Kalup recommends so plan can read the property limit: on the first of `objects` that is
5142
- * companies, contacts or deals, else on companies. HubSpot's Limits Tracking custom-properties answered 403 to a key
5143
- * with crm.schemas scopes only, and 200 once crm.objects.companies.read was added, on a developer test account (runs
5144
- * 89b45da9 and fb6155db, 2026-09-29).
5165
+ * State with each association entry under the address `held` gives the association. An association's identity is its
5166
+ * name, and its direction is only how config writes it, so an entry at the reversed address moves to the held one, its
5167
+ * labels and type IDs swapped; one the held address has an entry of the same name for already is dropped. Every save
5168
+ * of the state so read keeps the move.
5145
5169
  */
5146
- function limitScope(objects) {
5147
- return `crm.objects.${objects.find((o) => LIMIT_OBJECTS.has(o)) ?? "companies"}.read`;
5170
+ function followAssociations(state, held) {
5171
+ if (state === null) return state;
5172
+ const resources = { ...state.resources };
5173
+ let moved = false;
5174
+ for (const [address, entry] of Object.entries(state.resources)) {
5175
+ const reversed = reversedAssociation(address);
5176
+ if (reversed === void 0 || held(address) || !held(reversed)) continue;
5177
+ const there = Object.hasOwn(resources, reversed) ? resources[reversed] : void 0;
5178
+ if (there !== void 0 && there.id !== entry.id) continue;
5179
+ delete resources[address];
5180
+ resources[reversed] = there ?? reversedEntry(entry);
5181
+ moved = true;
5182
+ }
5183
+ return moved ? {
5184
+ ...state,
5185
+ resources
5186
+ } : state;
5148
5187
  }
5149
- function scopeFor$1(template, exceptions, objectType) {
5150
- if (!template?.includes("{object}")) return template;
5151
- const object = standardName.test(objectType) ? objectType : "custom";
5152
- if (Object.hasOwn(exceptions, object)) return exceptions[object];
5153
- return template.replace("{object}", object);
5188
+ function reversedEntry(entry) {
5189
+ const { base, rewrites, typeIds, written, ...rest } = entry;
5190
+ const out = { ...rest };
5191
+ if (base !== void 0) out.base = swapLabels(base);
5192
+ if (rewrites !== void 0) out.rewrites = swapLabels(rewrites);
5193
+ if (written !== void 0) out.written = swapLabels(written);
5194
+ if (typeIds !== void 0) out.typeIds = [typeIds[1], typeIds[0]];
5195
+ return out;
5154
5196
  }
5155
- /** Fills every `{placeholder}` in a path template, for example `{objectType}` and `{name}`. */
5156
- function fillPath(template, params) {
5157
- return template.replace(/\{(\w+)\}/g, (_, key) => {
5158
- const value = params[key];
5159
- if (value === void 0) throw new Error(`Path ${template} needs {${key}}`);
5160
- return encodeURIComponent(value);
5161
- });
5197
+ function swapLabels(units) {
5198
+ const { label, inverseLabel, ...rest } = units;
5199
+ return {
5200
+ ...rest,
5201
+ ...inverseLabel === void 0 ? {} : { label: inverseLabel },
5202
+ ...label === void 0 ? {} : { inverseLabel: label }
5203
+ };
5204
+ }
5205
+ /** The type IDs state records per association address, which name a label HubSpot's schema read does not list yet. */
5206
+ function associationIds(state) {
5207
+ const out = {};
5208
+ for (const [address, entry] of Object.entries(state?.resources ?? {})) if (entry.typeIds !== void 0 && address.startsWith("association:")) out[address] = entry.typeIds;
5209
+ return out;
5162
5210
  }
5163
5211
  const baseUrl = "https://api.hubapi.com";
5164
5212
  const rateHeaders$1 = {
@@ -7029,6 +7077,20 @@ function classify(base, desired, observed, rules) {
7029
7077
  if (desired.options && observed.options && !ignored.has("options")) units.push(...options(desired.options, observed.options, base, rules));
7030
7078
  return units.sort((a, b) => byCodeUnit(a.unit, b.unit));
7031
7079
  }
7080
+ /** A stored base as a Spec: its scalar units as fields, its members in the agreed order, then by value. */
7081
+ function specOfBase(base) {
7082
+ const { options: members, optionsOrder, ...fields } = base;
7083
+ if (members === void 0) return { fields };
7084
+ const order = optionsOrder ?? [];
7085
+ const rank = (value) => order.includes(value) ? order.indexOf(value) : order.length;
7086
+ return {
7087
+ fields,
7088
+ options: Object.entries(members).sort(([a], [b]) => rank(a) - rank(b) || byCodeUnit(a, b)).map(([value, member]) => ({
7089
+ value,
7090
+ ...member
7091
+ }))
7092
+ };
7093
+ }
7032
7094
  /**
7033
7095
  * The base after an apply: every owned unit of `approved`, only those `units` names when given, whose live value
7034
7096
  * equals the approved value takes that value; every other unit keeps its previous base value or stays absent, so a
@@ -7211,7 +7273,7 @@ const PLAN_SCHEMA = {
7211
7273
  $schema: "https://json-schema.org/draft/2020-12/schema",
7212
7274
  $id: "https://kalup.dev/schemas/plan-1.schema.json",
7213
7275
  title: "Kalup plan, version 1",
7214
- description: "What `kalup plan` would do to one target, with the context an approval binds to. Closed at every level, so a saved plan carries nothing this schema does not name, and a new field is a new format version.",
7276
+ description: "What `kalup plan` would do to one target, with the context an approval binds to. Closed at every level, apart from the free-form values in a step's desired and expect.values, so a saved plan carries nothing this schema does not name. A later 1.x may add fields, values and step types; an earlier 1.x refuses a plan that uses one, before any request (docs/compatibility.md).",
7215
7277
  type: "object",
7216
7278
  required: [
7217
7279
  "format",
@@ -7846,7 +7908,8 @@ const PLAN_SCHEMA = {
7846
7908
  "override",
7847
7909
  "unsupported",
7848
7910
  "not-owned",
7849
- "policy"
7911
+ "policy",
7912
+ "settling"
7850
7913
  ] },
7851
7914
  "detail": { "type": "string" },
7852
7915
  "blocks": {
@@ -8671,8 +8734,12 @@ function capturedSpec(resource) {
8671
8734
  const definition = resource.definition ?? {};
8672
8735
  if (resource.type !== "property") return observedSpec(CAPTURED[resource.type], definition);
8673
8736
  const options = definition.options ?? [];
8737
+ if (resource.managed && !buildable(definition.type, definition.fieldType)) return observedSpec(CAPTURED.unsupported, definition, options);
8674
8738
  return observedSpec(resource.managed ? propertyCaptured(definition.type) : [], definition, options);
8675
8739
  }
8740
+ function buildable(type, fieldType) {
8741
+ return Object.keys(HUBSPOT_TYPES).some((kind) => HUBSPOT_TYPES[kind] === type && FIELD_TYPES[kind].includes(fieldType));
8742
+ }
8676
8743
  /** The fields config owns on a resource that exists: those it states, less those ignoreChanges released on create. */
8677
8744
  function ownedFields(resource) {
8678
8745
  const ignored = new Set(resource.lifecycle?.ignoreChanges);
@@ -8688,7 +8755,8 @@ function specOf(fields) {
8688
8755
  }
8689
8756
  function objectOf(address) {
8690
8757
  const { type, path } = parseAddress(address);
8691
- return type === "object" ? path : path.slice(0, path.indexOf("/"));
8758
+ const at = path.indexOf("/");
8759
+ return type === "object" || at === -1 ? path : path.slice(0, at);
8692
8760
  }
8693
8761
  /** The path after the object: a stage's is `<pipeline>/<stage>`; see stageIdOf for its own ID. */
8694
8762
  function nameOf(address) {
@@ -8724,6 +8792,14 @@ function shownName(address) {
8724
8792
  const { type } = parseAddress(address);
8725
8793
  return type === "stage" || type === "association" ? ownId(address) : nameOf(address);
8726
8794
  }
8795
+ /**
8796
+ * The portal name an address resolves to on a target: its name override, else its own name. A stage's is its stage ID;
8797
+ * its pipeline's ID is the pipeline address's.
8798
+ */
8799
+ function resolvedName(overrides, address) {
8800
+ const override = Object.hasOwn(overrides, address) ? overrides[address] : void 0;
8801
+ return (override?.skip === true ? void 0 : override?.name) ?? shownName(address);
8802
+ }
8727
8803
  /** The resource types HubSpot purges on delete, with no archive and no restore (observed 2026-10-01 and 2026-10-05). */
8728
8804
  const PURGED_TYPES = /* @__PURE__ */ new Set([
8729
8805
  "pipeline",
@@ -8915,6 +8991,87 @@ function baseFor(entry, address, id) {
8915
8991
  if (!Object.hasOwn(NORM_VERSIONS, kind) || (entry.normVersion ?? NORM_VERSIONS[kind]) !== NORM_VERSIONS[kind]) return;
8916
8992
  return entry.base;
8917
8993
  }
8994
+ /** How long after a verified write a read that disagrees with it is not trusted: longer than every lag observed. */
8995
+ const SETTLE_MS = 3e5;
8996
+ /** The addresses the read cannot be trusted on yet, each with why and until when. Sorted. */
8997
+ function settlingOf(input) {
8998
+ const entries = Object.entries(input.state?.resources ?? {});
8999
+ const out = /* @__PURE__ */ new Map();
9000
+ for (const [address, entry] of entries) {
9001
+ const settling = settlingFor(input, address, entry);
9002
+ if (settling !== void 0) out.set(address, settling);
9003
+ }
9004
+ const unlisted = new Map([...out].filter(([a, s]) => s.reason === "missing" && parseAddress(a).type === "object").map(([a, s]) => [objectOf(a), s]));
9005
+ for (const [address, entry] of entries) {
9006
+ const [parent] = objectsOf$1(address).flatMap((key) => unlisted.get(key) ?? []);
9007
+ const unseen = UNSEEN.has(input.status(address));
9008
+ if (parent !== void 0 && !out.has(address) && unseen && owns(input, address, entry)) out.set(address, {
9009
+ reason: "missing",
9010
+ until: parent.until
9011
+ });
9012
+ }
9013
+ return Object.fromEntries([...out].sort(([a], [b]) => byCodeUnit(a, b)));
9014
+ }
9015
+ /**
9016
+ * The units an entry records apply wrote, each with when: `units` at `now`, and those of an earlier write whose window
9017
+ * is still open, since a read may still serve the copy from before them. Undefined when none is open.
9018
+ */
9019
+ function writtenAfter(entry, units, now) {
9020
+ const at = now.toISOString();
9021
+ const kept = Object.entries(entry?.written ?? {}).filter(([, when]) => settlesUntil(when, now) !== void 0);
9022
+ const written = new Map([...kept, ...units.map((unit) => [unit, at])]);
9023
+ return written.size === 0 ? void 0 : Object.fromEntries([...written].sort(([a], [b]) => byCodeUnit(a, b)));
9024
+ }
9025
+ function settlingFor(input, address, entry) {
9026
+ if (!owns(input, address, entry)) return;
9027
+ const status = input.status(address);
9028
+ if (status === "absent") {
9029
+ const until = entry.writtenAt === void 0 ? void 0 : settlesUntil(entry.writtenAt, input.now);
9030
+ return until === void 0 ? void 0 : {
9031
+ reason: "missing",
9032
+ until
9033
+ };
9034
+ }
9035
+ const open = Object.entries(entry.written ?? {}).flatMap(([unit, at]) => {
9036
+ const until = settlesUntil(at, input.now);
9037
+ return until === void 0 ? [] : [[unit, until]];
9038
+ });
9039
+ if (open.length === 0) return;
9040
+ const observed = Object.hasOwn(input.resources, address) ? input.resources[address] : void 0;
9041
+ const base = observed && baseFor(entry, address, entry.id);
9042
+ if (status !== "present" || !observed?.managed || base === void 0) return;
9043
+ const until = new Map(open);
9044
+ const moved = classify(base, specOfBase(base), capturedSpec(observed), { options: "additive" }).flatMap((u) => {
9045
+ const end = u.class === "converged" ? void 0 : until.get(u.unit);
9046
+ return end === void 0 ? [] : [end];
9047
+ });
9048
+ return moved.length === 0 ? void 0 : {
9049
+ reason: "stale",
9050
+ until: latest(moved)
9051
+ };
9052
+ }
9053
+ const UNSEEN = /* @__PURE__ */ new Set([
9054
+ "absent",
9055
+ "not-observed",
9056
+ "unreadable"
9057
+ ]);
9058
+ function owns(input, address, entry) {
9059
+ return (entry.origin === "created" || entry.origin === "adopted") && entry.id === resolvedName(input.overrides, address);
9060
+ }
9061
+ function objectsOf$1(address) {
9062
+ const { type } = parseAddress(address);
9063
+ if (type === "object") return [];
9064
+ return type === "association" ? pairOf(address) : [objectOf(address)];
9065
+ }
9066
+ function settlesUntil(at, now) {
9067
+ const written = Date.parse(at);
9068
+ if (!Number.isFinite(written) || written > now.getTime() + 3e5) return;
9069
+ const end = Math.min(written, now.getTime()) + SETTLE_MS;
9070
+ return now.getTime() < end ? new Date(end).toISOString() : void 0;
9071
+ }
9072
+ function latest(times) {
9073
+ return [...times].sort(byCodeUnit).at(-1);
9074
+ }
8918
9075
  /**
8919
9076
  * The fields the 2026-09 properties, groups and schemas lists document that no captured resource carries. `archived`
8920
9077
  * is not listed: an archived resource is not captured at all. `dateDisplayHint` is read-only in practice: HubSpot
@@ -8979,6 +9136,8 @@ const OBSERVED_TYPES = /* @__PURE__ */ new Set([
8979
9136
  /** The resource types read from an object's pipelines list. */
8980
9137
  const PIPELINE_TYPES = /* @__PURE__ */ new Set(["pipeline", "stage"]);
8981
9138
  const NAME_FIX = "rename it in HubSpot to a name without spaces";
9139
+ const SETTLING_SHOWN = 5;
9140
+ const TEXT_MAX$3 = 400;
8982
9141
  /**
8983
9142
  * The config IR as the side of a comparison, as target `target` sees it when one is given: its definition overrides
8984
9143
  * applied. It has no coverage: an address is present or absent.
@@ -8996,13 +9155,15 @@ function configObservation(loaded, target) {
8996
9155
  async function observeTarget(http, loaded, targetName, options = {}) {
8997
9156
  const target = loaded.config.targets[targetName];
8998
9157
  const issues = [];
8999
- return observePortal(await readPortal(http, loaded, target, issues, options), loaded, targetName, issues);
9158
+ return observePortal(await readPortal(http, loaded, target, issues, options), loaded, targetName, issues, options.settle);
9000
9159
  }
9001
9160
  /**
9002
9161
  * What a read of target `targetName` holds for the config's objects, as observeTarget records it. pull records its own
9003
9162
  * read through here, so it classifies against the base exactly as plan does. Issues about the read go to `issues`.
9163
+ * With `settle`, a resource apply wrote minutes ago that the read shows otherwise is settling: unreadable, never
9164
+ * absent.
9004
9165
  */
9005
- function observePortal(portal, loaded, targetName, issues) {
9166
+ function observePortal(portal, loaded, targetName, issues, settle) {
9006
9167
  const target = loaded.config.targets[targetName];
9007
9168
  const resources = [];
9008
9169
  const meta = [];
@@ -9028,27 +9189,66 @@ function observePortal(portal, loaded, targetName, issues) {
9028
9189
  });
9029
9190
  }
9030
9191
  const coverage = {
9031
- complete: Object.values(objects).every((o) => o.status !== "unreadable" && o.unaddressable === void 0 && o.pipelines?.status !== "unreadable" && Object.values(o.associations?.with ?? {}).every((p) => p.status !== "unreadable")),
9192
+ complete: listsRead(objects) && Object.values(objects).every((o) => Object.values(o.associations?.with ?? {}).every((p) => !p.unnamed)),
9032
9193
  objects,
9033
9194
  otherObjects: portal.customObjects === void 0 ? "unknown" : [...portal.otherObjects].sort(byCodeUnit),
9034
9195
  notCaptured: NOT_CAPTURED
9035
9196
  };
9036
- return {
9037
- observation: {
9038
- side: {
9039
- kind: "target",
9040
- name: targetName,
9041
- portalId: target.portalId
9042
- },
9043
- resources: Object.fromEntries(resources.sort(([a], [b]) => byCodeUnit(a, b))),
9044
- coverage,
9045
- meta: Object.fromEntries(meta.sort(([a], [b]) => byCodeUnit(a, b))),
9046
- members,
9047
- listed
9197
+ const observation = {
9198
+ side: {
9199
+ kind: "target",
9200
+ name: targetName,
9201
+ portalId: target.portalId
9048
9202
  },
9203
+ resources: Object.fromEntries(resources.sort(([a], [b]) => byCodeUnit(a, b))),
9204
+ coverage,
9205
+ meta: Object.fromEntries(meta.sort(([a], [b]) => byCodeUnit(a, b))),
9206
+ members,
9207
+ listed
9208
+ };
9209
+ issues.push(...unnamedIssues(objects));
9210
+ if (settle) {
9211
+ const followed = followAssociations(settle.state, (address) => Object.hasOwn(loaded.ir.resources, address));
9212
+ settleCoverage(observation, coverage, target.overrides ?? {}, {
9213
+ ...settle,
9214
+ state: followed
9215
+ }, issues);
9216
+ }
9217
+ return {
9218
+ observation,
9049
9219
  issues
9050
9220
  };
9051
9221
  }
9222
+ function unnamedIssues(objects) {
9223
+ const both = Object.entries(objects).flatMap(([key, o]) => Object.entries(o.associations?.with ?? {}).flatMap(([other, p]) => p.unnamed ? [[key, other].sort(byCodeUnit).join(" and ")] : []));
9224
+ const pairs = [...new Set(both)].sort(byCodeUnit);
9225
+ if (pairs.length === 0) return [];
9226
+ return [{
9227
+ code: "W_SETTLING",
9228
+ message: sanitize(`HubSpot lists association types between ${pairs.join(", ")} that its schema read does not name yet, so this read is not trusted on the associations there`, TEXT_MAX$3),
9229
+ fix: "run the command again in a few minutes: HubSpot names a new association in its schema read some minutes after it is made"
9230
+ }];
9231
+ }
9232
+ function settleCoverage(observation, coverage, overrides, settle, issues) {
9233
+ const settling = settlingOf({
9234
+ ...settle,
9235
+ overrides,
9236
+ resources: observation.resources,
9237
+ status: (address) => statusOf(observation, address)
9238
+ });
9239
+ const addresses = Object.keys(settling);
9240
+ if (addresses.length === 0) return;
9241
+ coverage.settling = settling;
9242
+ coverage.complete = false;
9243
+ const until = addresses.map((a) => settling[a].until).sort(byCodeUnit)[addresses.length - 1];
9244
+ const shown = sanitize(addresses.slice(0, SETTLING_SHOWN).join(", "), TEXT_MAX$3);
9245
+ const more = addresses.length > SETTLING_SHOWN ? ` and ${addresses.length - SETTLING_SHOWN} more` : "";
9246
+ issues.push({
9247
+ code: "W_SETTLING",
9248
+ message: `HubSpot still serves an older copy of ${plural(addresses.length, "resource")} apply wrote minutes ago, so this read is not trusted on ${addresses.length === 1 ? "it" : "them"} until ${until}: ${shown}${more}`,
9249
+ fix: `run the command again after ${until}`
9250
+ });
9251
+ }
9052
9252
  /**
9053
9253
  * Whether an observation holds an address. Config: present or absent. A target or snapshot: the object's status
9054
9254
  * first, then a skip override, then the resource itself, then an unsupported property, then one config names that its
@@ -9056,9 +9256,18 @@ function observePortal(portal, loaded, targetName, issues) {
9056
9256
  * so it is never out of scope.
9057
9257
  */
9058
9258
  function statusOf(observation, address) {
9259
+ const status = readStatus(observation, address);
9260
+ return status === "absent" && observation.opaque ? "unreadable" : status;
9261
+ }
9262
+ /** Whether `address` is unknown only because a later version's incomplete read may have missed it (opaque). */
9263
+ function opaqueGap(observation, address) {
9264
+ return observation.opaque === true && readStatus(observation, address) === "absent";
9265
+ }
9266
+ function readStatus(observation, address) {
9059
9267
  const { coverage, resources } = observation;
9060
9268
  const held = Object.hasOwn(resources, address);
9061
9269
  if (!coverage) return held ? "present" : "absent";
9270
+ if (settlingAt(coverage, address) !== void 0) return "unreadable";
9062
9271
  const { type } = parseAddress(address);
9063
9272
  if (type === "association") return associationStatus(coverage, address, held);
9064
9273
  const key = objectOf(address);
@@ -9104,6 +9313,46 @@ function associationStatus(coverage, address, held) {
9104
9313
  if (held) return "present";
9105
9314
  return unnamedOf(coverage, from, to).length > 0 ? "unreadable" : "absent";
9106
9315
  }
9316
+ /** Why and until when the read cannot be trusted on an address yet, or undefined when it can. */
9317
+ function settlingAt(coverage, address) {
9318
+ const settling = coverage.settling ?? {};
9319
+ return Object.hasOwn(settling, address) ? settling[address] : void 0;
9320
+ }
9321
+ /**
9322
+ * Whether a read covered every list it needed and captured every property config names: the read is complete but for
9323
+ * what waits on HubSpot (waitingOn). Takeover, state rebuild and target rebind refuse a read that is not.
9324
+ */
9325
+ function listsRead(objects) {
9326
+ return Object.values(objects).every((o) => o.status !== "unreadable" && o.unaddressable === void 0 && o.pipelines?.status !== "unreadable" && Object.values(o.associations?.with ?? {}).every((p) => p.status !== "unreadable"));
9327
+ }
9328
+ /**
9329
+ * What the read leaves unknown among `addresses` only for the minutes HubSpot takes to settle: those settling after an
9330
+ * apply, and the associations a type HubSpot's schema read does not name yet may be. A command acting on these
9331
+ * addresses alone waits on this, never on what the read leaves unknown elsewhere.
9332
+ */
9333
+ function waitingOn(observation, addresses) {
9334
+ const { coverage } = observation;
9335
+ const settling = [];
9336
+ const unnamed = /* @__PURE__ */ new Set();
9337
+ if (coverage === void 0) return {
9338
+ settling,
9339
+ unnamed: []
9340
+ };
9341
+ for (const address of [...new Set(addresses)].sort(byCodeUnit)) {
9342
+ const settles = settlingAt(coverage, address);
9343
+ if (settles !== void 0) {
9344
+ settling.push([address, settles]);
9345
+ continue;
9346
+ }
9347
+ const [from, to] = parseAddress(address).type === "association" ? pairOf(address) : [];
9348
+ const unknown = statusOf(observation, address) === "unreadable";
9349
+ if (from !== void 0 && to !== void 0 && unknown && unnamedOf(coverage, from, to).length > 0) unnamed.add([from, to].sort(byCodeUnit).join(" and "));
9350
+ }
9351
+ return {
9352
+ settling,
9353
+ unnamed: [...unnamed].sort(byCodeUnit)
9354
+ };
9355
+ }
9107
9356
  /** One object's coverage of its pair with `other`, or undefined when that pair was not in scope. */
9108
9357
  function pairRead(object, other) {
9109
9358
  const pairs = object?.associations?.with;
@@ -10134,6 +10383,8 @@ function stepFor(context, address, resource, status) {
10134
10383
  }, context.policy, void 0);
10135
10384
  }
10136
10385
  function unread$1(context, address, status) {
10386
+ const settling = settlingAt(context.coverage, address);
10387
+ if (settling !== void 0) return settlingStep(address, settling);
10137
10388
  const key = objectOf(address);
10138
10389
  const object = own$2(context.coverage.objects, key);
10139
10390
  const kind = kindOf$3(address);
@@ -10155,6 +10406,9 @@ function unread$1(context, address, status) {
10155
10406
  const fix = scope === void 0 ? "check the scopes of the key" : `add the scope ${scope} to the key`;
10156
10407
  return blocked(address, "unknown", "scope", `the key cannot read ${key}`, detail, fix);
10157
10408
  }
10409
+ function settlingStep(address, settling) {
10410
+ return blocked(address, "unknown", "settling", "HubSpot is still settling", settling.reason === "missing" ? "apply created it minutes ago, and HubSpot does not list it yet" : "apply wrote it minutes ago, and HubSpot still serves an older copy of it", `plan again after ${settling.until}`);
10411
+ }
10158
10412
  function blockedParents(context, address, resource) {
10159
10413
  if (kindOf$3(address) === "object") return [];
10160
10414
  const group = (resource.definition?.group)?.$ref;
@@ -10168,7 +10422,8 @@ function blockedParents(context, address, resource) {
10168
10422
  ].flatMap((parent) => {
10169
10423
  const found = parent === void 0 ? void 0 : context.decided.get(parent);
10170
10424
  const update = found?.action === "update" && kindOf$3(found.address) === "object";
10171
- return found?.risk === "blocked" && !update ? [found] : [];
10425
+ const stale = found !== void 0 && settlingAt(context.coverage, found.address)?.reason === "stale";
10426
+ return found?.risk === "blocked" && !update && !stale ? [found] : [];
10172
10427
  });
10173
10428
  }
10174
10429
  function missingGroup(context, resource) {
@@ -10299,7 +10554,9 @@ function unheldSteps(context) {
10299
10554
  const holds = (property) => {
10300
10555
  const address = `property:${key}/${property}`;
10301
10556
  const decided = context.decided.get(address);
10302
- return decided?.action === "create" && decided.risk !== "blocked" || statusOf(observation, address) === "present" || outOfScope.includes(property);
10557
+ const creates = decided?.action === "create" && decided.risk !== "blocked";
10558
+ const stale = settlingAt(context.coverage, address)?.reason === "stale";
10559
+ return creates || stale || statusOf(observation, address) === "present" || outOfScope.includes(property);
10303
10560
  };
10304
10561
  const fields = schemaWrites(step);
10305
10562
  for (const field of OBJECT_DISPLAY_FIELDS) {
@@ -10895,6 +11152,24 @@ function uncovered(address, tombstone, cover) {
10895
11152
  const fix = `set the action of ${cover} to release to keep it with what it holds, or this one's to destroy`;
10896
11153
  return blocked(address, "release", "unsupported", `${kindOf$3(cover)} deleted`, detail, fix);
10897
11154
  }
11155
+ function takeoverWait(context, key) {
11156
+ const { coverage } = context;
11157
+ if (!listsRead(coverage.objects)) return {
11158
+ reason: "scope",
11159
+ short: "read incomplete",
11160
+ why: `the read of target ${context.input.target} was incomplete`,
11161
+ fix: INCOMPLETE_FIX
11162
+ };
11163
+ const until = Object.entries(coverage.settling ?? {}).filter(([address]) => objectOf(address) === key && TAKEOVER_KINDS.has(kindOf$3(address))).map(([, settling]) => settling.until).sort(byCodeUnit).at(-1);
11164
+ if (until === void 0) return;
11165
+ return {
11166
+ reason: "settling",
11167
+ short: "HubSpot is still settling",
11168
+ why: `what apply wrote on ${key} minutes ago has not settled in HubSpot yet`,
11169
+ fix: `plan again after ${until}`
11170
+ };
11171
+ }
11172
+ const TAKEOVER_KINDS = /* @__PURE__ */ new Set(["property", "group"]);
10898
11173
  function takeoverOf$1(context, kind) {
10899
11174
  if (kind === "property") return context.takeover.properties;
10900
11175
  return kind === "group" ? context.takeover.groups : [];
@@ -10922,7 +11197,11 @@ function takeoverDelete(context, address, deleted) {
10922
11197
  ...refused,
10923
11198
  ...notes
10924
11199
  });
10925
- if (!context.coverage.complete) return noted(blocked(address, "delete", "scope", "read incomplete", `takeover would archive ${name}, and the read of target ${target} was incomplete, so takeover removes nothing there`, INCOMPLETE_FIX));
11200
+ const wait = takeoverWait(context, key);
11201
+ if (wait) {
11202
+ const detail = `takeover would archive ${name}, and ${wait.why}, so takeover removes nothing there`;
11203
+ return noted(blocked(address, "delete", wait.reason, wait.short, detail, wait.fix));
11204
+ }
10926
11205
  const members = kindOf$3(address) === "group" ? {
10927
11206
  active: own$2(observation.members?.[key] ?? {}, name) ?? [],
10928
11207
  deleted: deleted.get(key) ?? /* @__PURE__ */ new Set()
@@ -10968,10 +11247,14 @@ function optionList(units) {
10968
11247
  function takeoverBlock(context, address, action, units) {
10969
11248
  const { target } = context.input;
10970
11249
  const notes = { notes: [optionsNote(context, address, units)] };
10971
- if (!context.coverage.complete) return {
10972
- ...blocked(address, action, "scope", "read incomplete", `takeover would remove ${optionList(units)}, and the read of target ${target} was incomplete, so takeover removes nothing there`, INCOMPLETE_FIX),
10973
- ...notes
10974
- };
11250
+ const wait = takeoverWait(context, objectOf(address));
11251
+ if (wait) {
11252
+ const detail = `takeover would remove ${optionList(units)}, and ${wait.why}, so takeover removes nothing there`;
11253
+ return {
11254
+ ...blocked(address, action, wait.reason, wait.short, detail, wait.fix),
11255
+ ...notes
11256
+ };
11257
+ }
10975
11258
  if (!context.policy.allowDestroy) return {
10976
11259
  ...blocked(address, action, "policy", "option removals not allowed", `takeover removes ${optionList(units)}, which only the portal holds, and target ${target} does not allow deletes`, `keep them in config: run ${pullCommand(target, address)}; or keep them unmanaged: set lifecycle: { options: 'additive' } on ${nameOf(address)}; or remove them: set allowDestroy: true under targets.${target} in kalup.config.ts`),
10977
11260
  ...notes
@@ -11053,7 +11336,7 @@ function unreadAssociation(context, address) {
11053
11336
  }
11054
11337
  if (pair.status === "unreadable") return blocked(address, "unknown", "scope", "labels list refused", `HubSpot refused the labels list of ${from} and ${to} (${pair.issue ?? "E_HTTP"}), so what it holds there is unknown`, "check that HubSpot keeps associations between these two objects, then plan again");
11055
11338
  const unnamed = unnamedOf(context.coverage, from, to).length;
11056
- return blocked(address, "unknown", "scope", "types not named yet", `HubSpot lists ${plural(unnamed, "association type")} between ${from} and ${to} that its schema read does not name yet, and this association may be one of them`, "plan again in a few minutes: HubSpot names a new association in its schema read some minutes after it is made");
11339
+ return blocked(address, "unknown", "settling", "types not named yet", `HubSpot lists ${plural(unnamed, "association type")} between ${from} and ${to} that its schema read does not name yet, and this association may be one of them`, "plan again in a few minutes: HubSpot names a new association in its schema read some minutes after it is made");
11057
11340
  }
11058
11341
  function objectTakes(context, key) {
11059
11342
  const { observation } = context.input;
@@ -11115,7 +11398,13 @@ function release(address, title, expect) {
11115
11398
  function orphansOf(input, steps) {
11116
11399
  const { loaded, state, target } = input;
11117
11400
  const overrides = loaded.config.targets[target]?.overrides ?? {};
11118
- return Object.entries(state?.resources ?? {}).filter(([address, entry]) => (entry.origin === "created" || entry.origin === "adopted") && !Object.hasOwn(loaded.ir.resources, address) && !Object.hasOwn(loaded.ir.tombstones, address) && coverOf(loaded.ir.tombstones, address) === void 0 && !heldReversed(loaded.ir, address) && !steps.some((s) => s.address === address)).sort(([a], [b]) => byCodeUnit(a, b)).map(([address, entry]) => {
11401
+ const later = Object.keys(state?.later?.resources ?? {}).map((address) => {
11402
+ return {
11403
+ address,
11404
+ note: sanitize(`a later version of ${bin} manages ${parseAddress(address).type} resources: this version leaves the entry as it is`, TEXT_MAX$2)
11405
+ };
11406
+ });
11407
+ return [...Object.entries(state?.resources ?? {}).filter(([address, entry]) => (entry.origin === "created" || entry.origin === "adopted") && !Object.hasOwn(loaded.ir.resources, address) && !Object.hasOwn(loaded.ir.tombstones, address) && coverOf(loaded.ir.tombstones, address) === void 0 && !heldReversed(loaded.ir, address) && !steps.some((s) => s.address === address)).sort(([a], [b]) => byCodeUnit(a, b)).map(([address, entry]) => {
11119
11408
  const name = resolvedName(overrides, address);
11120
11409
  const rm = `${bin} rm ${shellWord(address)}`;
11121
11410
  let note = `no longer in config: run ${rm} to delete it in HubSpot, or ${rm} --release to stop managing it`;
@@ -11127,7 +11416,7 @@ function orphansOf(input, steps) {
11127
11416
  address,
11128
11417
  note: sanitize(note, TEXT_MAX$2)
11129
11418
  };
11130
- });
11419
+ }), ...later].sort((a, b) => byCodeUnit(a.address, b.address));
11131
11420
  }
11132
11421
  function heldReversed(ir, address) {
11133
11422
  const reversed = reversedAssociation(address);
@@ -11149,14 +11438,6 @@ function staleNote(context, address, stale) {
11149
11438
  function portalName$1(context, address) {
11150
11439
  return resolvedName(context.overrides, address);
11151
11440
  }
11152
- /**
11153
- * The portal name an address resolves to on a target: its name override, else its own name. A stage's is its stage ID;
11154
- * its pipeline's ID is the pipeline address's.
11155
- */
11156
- function resolvedName(overrides, address) {
11157
- const override = own$2(overrides, address);
11158
- return (override?.skip === true ? void 0 : override?.name) ?? shownName(address);
11159
- }
11160
11441
  function archivedOf(lists, key, name) {
11161
11442
  const list = own$2(lists, key);
11162
11443
  return list === void 0 ? null : list.find((p) => p.name === name);
@@ -11961,9 +12242,15 @@ function fromSnapshot$1(text, file) {
11961
12242
  observedAt
11962
12243
  },
11963
12244
  resources: sorted(resources),
11964
- coverage
12245
+ coverage,
12246
+ ...opaque(coverage) ? { opaque: true } : {}
11965
12247
  };
11966
12248
  }
12249
+ function opaque(coverage) {
12250
+ const unknown = (fields, known) => Object.keys(fields).some((f) => !known.has(f));
12251
+ const later = unknown(coverage, COVERAGE_FIELDS.coverage) || Object.values(coverage.objects).some((object) => unknown(object, COVERAGE_FIELDS.object));
12252
+ return !coverage.complete && later;
12253
+ }
11967
12254
  const PLAIN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
11968
12255
  const DEVICE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
11969
12256
  const UNSAFE = /[^a-z0-9_-]/gu;
@@ -11983,7 +12270,8 @@ function planPath(targetName, planId) {
11983
12270
  }
11984
12271
  /**
11985
12272
  * W_INCOMPLETE for a read that left objects unread, or config properties uncaptured because their portal group's name
11986
- * no address can hold, since what they hold is unknown. None for a complete read.
12273
+ * no address can hold, or that is not trusted yet on what settles after an apply or on a pair whose labels hold a type
12274
+ * HubSpot does not name yet, since what they hold is unknown. None for a complete read.
11987
12275
  */
11988
12276
  function incompleteIssues(coverage, target) {
11989
12277
  if (coverage.complete) return [];
@@ -11992,13 +12280,19 @@ function incompleteIssues(coverage, target) {
11992
12280
  const names = unread.map(([key]) => sanitize(key));
11993
12281
  const properties = objects.flatMap(([key, o]) => [...o.unaddressable ?? []].sort(byCodeUnit).map((name) => sanitize(`property:${key}/${name}`)));
11994
12282
  const scopes = [...new Set(unread.flatMap(([, o]) => o.missingScope === void 0 ? [] : [o.missingScope]))].sort(byCodeUnit).map((scope) => sanitize(scope));
11995
- const parts = [...names.length > 0 ? [`${listed(names)} ${names.length === 1 ? "was" : "were"} not read`] : [], ...properties.length > 0 ? [`${listed(properties)} ${properties.length === 1 ? "is" : "are"} in a portal group whose name no address can hold`] : []];
11996
- const one = names.length + properties.length === 1;
12283
+ const lists = objects.some(([, o]) => o.pipelines?.status === "unreadable" || Object.values(o.associations?.with ?? {}).some((p) => p.status === "unreadable"));
12284
+ const waiting = waitOf(coverage, objects);
12285
+ const parts = [
12286
+ ...names.length > 0 ? [`${listed(names)} ${names.length === 1 ? "was" : "were"} not read`] : [],
12287
+ ...properties.length > 0 ? [`${listed(properties)} ${properties.length === 1 ? "is" : "are"} in a portal group whose name no address can hold`] : [],
12288
+ ...waiting.parts
12289
+ ];
12290
+ const one = names.length + properties.length + waiting.count === 1;
11997
12291
  const which = parts.length > 0 ? `: ${parts.join(", and ")}, so what ${one ? "it holds" : "they hold"} is unknown` : "";
11998
12292
  const noun = scopes.length === 1 ? "the scope" : "the scopes";
11999
12293
  const fixes = [];
12000
12294
  if (scopes.length > 0) fixes.push(`add ${noun} ${listed(scopes)} to the read key of target ${sanitize(target)}`);
12001
- else if (names.length > 0 || properties.length === 0) fixes.push(`add the missing read scopes to the read key of target ${sanitize(target)}`);
12295
+ else if (names.length > 0 || lists || parts.length === 0) fixes.push(`add the missing read scopes to the read key of target ${sanitize(target)}`);
12002
12296
  if (properties.length > 0) {
12003
12297
  const many = properties.length > 1;
12004
12298
  fixes.push(`rename the group${many ? "s" : ""} of ${listed(properties)} in HubSpot to ${many ? "names" : "a name"} without spaces`);
@@ -12006,9 +12300,25 @@ function incompleteIssues(coverage, target) {
12006
12300
  return [{
12007
12301
  code: "W_INCOMPLETE",
12008
12302
  message: `the snapshot of target ${sanitize(target)} is incomplete${which}`,
12009
- fix: `${fixes.join(" and ")}, then take a new snapshot`
12303
+ fix: fixes.length > 0 ? `${fixes.join(" and ")}, then take a new snapshot${waiting.later}` : `take a new snapshot${waiting.later}`
12010
12304
  }];
12011
12305
  }
12306
+ function waitOf(coverage, objects) {
12307
+ const settling = Object.keys(coverage.settling ?? {}).sort(byCodeUnit).map((address) => sanitize(address));
12308
+ const until = Object.values(coverage.settling ?? {}).map((s) => s.until).sort(byCodeUnit).at(-1);
12309
+ const pairs = [...new Set(objects.flatMap(([key, o]) => Object.entries(o.associations?.with ?? {}).flatMap(([other, p]) => p.unnamed ? [[key, other].sort(byCodeUnit).join(" and ")] : [])))].sort(byCodeUnit).map((pair) => sanitize(pair));
12310
+ const parts = [];
12311
+ if (settling.length > 0) parts.push(`${listed(settling)} ${settling.length === 1 ? "is" : "are"} settling after an apply until ${until}`);
12312
+ if (pairs.length > 0) parts.push(`the associations between ${listed(pairs)} hold a type HubSpot's schema read does not name yet`);
12313
+ let later = "";
12314
+ if (until !== void 0) later = ` after ${until}`;
12315
+ else if (pairs.length > 0) later = " in a few minutes, once HubSpot names the new association";
12316
+ return {
12317
+ count: settling.length + 2 * pairs.length,
12318
+ later,
12319
+ parts
12320
+ };
12321
+ }
12012
12322
  function targetDir(name) {
12013
12323
  if (PLAIN.test(name) && !DEVICE.test(name)) return name;
12014
12324
  return `${name.toLowerCase().replace(UNSAFE, "_").replace(EDGES, "").slice(0, 40).replace(EDGES, "") || "target"}-${createHash("sha256").update(name).digest("hex").slice(0, 8)}`;
@@ -12066,6 +12376,7 @@ function parsePlan(text, file, running) {
12066
12376
  message: `${shown} ${other}. Nothing was sent.`,
12067
12377
  fix: `plan again with this version: run ${bin} plan --target <name> --out <file>, review it and apply that file`
12068
12378
  });
12379
+ laterStep(document, shown);
12069
12380
  const problems = validatePlan(document);
12070
12381
  if (problems.length > 0) {
12071
12382
  const [first] = problems;
@@ -12223,7 +12534,7 @@ function checkVersions(plan, now) {
12223
12534
  function normalizerProblems(plan) {
12224
12535
  const known = NORM_VERSIONS;
12225
12536
  const problems = [];
12226
- for (const kind of [.../* @__PURE__ */ new Set([...Object.keys(known), ...Object.keys(plan.normVersions)])].sort()) {
12537
+ for (const kind of [...new Set(plan.steps.map((s) => parseAddress(s.address).type))].sort()) {
12227
12538
  const planned = Object.hasOwn(plan.normVersions, kind) ? plan.normVersions[kind] : void 0;
12228
12539
  const current = Object.hasOwn(known, kind) ? known[kind] : void 0;
12229
12540
  if (planned !== current) problems.push(`the plan compared ${sanitize(kind)} under normalizer ${planned ?? "none"}, this version uses ${current ?? "none"}`);
@@ -12728,6 +13039,20 @@ function carriedDisagreement(step) {
12728
13039
  function sameValue(a, b) {
12729
13040
  return b !== void 0 && stableStringify(a) === stableStringify(b);
12730
13041
  }
13042
+ function laterStep(document, shown) {
13043
+ const steps = document?.steps;
13044
+ for (const step of Array.isArray(steps) ? steps : []) {
13045
+ const address = typeof step?.address === "string" ? step.address : "";
13046
+ if (!isAddress(address) || handledType(parseAddress(address).type)) continue;
13047
+ const { generator } = document;
13048
+ const made = typeof generator?.version === "string" ? ` (${bin} ${sanitize(generator.version, 40)})` : "";
13049
+ throw new KalupError({
13050
+ code: "E_PLAN_INVALID",
13051
+ message: `${shown}: step ${typeof step.id === "string" ? sanitize(step.id, 20) : "a step"} is a ${sanitize(parseAddress(address).type, 40)} step, which this version of ${bin} does not apply: a later version made the plan${made}. Nothing was sent.`,
13052
+ fix: "apply the plan with the version that made it, or a later one"
13053
+ });
13054
+ }
13055
+ }
12731
13056
  function invalid(message) {
12732
13057
  return new KalupError({
12733
13058
  code: "E_PLAN_INVALID",
@@ -13201,13 +13526,21 @@ function verified(run, step, seen) {
13201
13526
  if (step.action === "create") run.created.add(step.address);
13202
13527
  const typeId = seen.raw?.objectTypeId;
13203
13528
  if (createsObject(step) && typeof typeId === "string" && !run.typeIds.has(objectOf(step.address))) run.typeIds.set(objectOf(step.address), typeId);
13204
- const { rewrites: before, ...entry } = entryOf$1(run, step, verifiedBase(run, step, readBack));
13205
- const rewrites = rewritesAfter(before, writtenUnits(step, readBack), own);
13206
- const saved = rewrites === void 0 ? entry : {
13529
+ const { rewrites: before, written: _, writtenAt: earlier, ...entry } = entryOf$1(run, step, verifiedBase(run, step, readBack));
13530
+ const written = writtenUnits(step, readBack);
13531
+ const rewrites = rewritesAfter(before, written, own);
13532
+ const unsent = new Set(own.map((u) => u.unit));
13533
+ const prior = Object.hasOwn(run.state.resources, step.address) ? run.state.resources[step.address] : void 0;
13534
+ const now = run.deps.now();
13535
+ const times = writtenAfter(prior, written.filter((unit) => !unsent.has(unit)), now);
13536
+ const at = step.action === "create" || written.length > 0 ? now.toISOString() : earlier ?? prior?.writtenAt;
13537
+ const saved = {
13207
13538
  ...entry,
13208
- rewrites
13539
+ ...rewrites === void 0 ? {} : { rewrites },
13540
+ ...times === void 0 ? {} : { written: times },
13541
+ ...at === void 0 ? {} : { writtenAt: at }
13209
13542
  };
13210
- const also = carriedEntries(step, seen);
13543
+ const also = carriedEntries(step, seen, now);
13211
13544
  if (bad.length === 0) return {
13212
13545
  report: report$1(step, "done"),
13213
13546
  entry: saved,
@@ -13230,19 +13563,22 @@ function verified(run, step, seen) {
13230
13563
  issues: [issue]
13231
13564
  };
13232
13565
  }
13233
- function carriedEntries(step, seen) {
13566
+ function carriedEntries(step, seen, now) {
13234
13567
  return (step.stages ?? []).flatMap((st) => {
13235
13568
  const live = seen.stages?.[st.address];
13236
13569
  if (live === void 0) return [];
13237
13570
  const units = classify(void 0, specOf(st.desired), capturedSpec(live), { options: "additive" });
13238
13571
  const base = advanceBase(void 0, specOf(st.desired), capturedSpec(live));
13239
13572
  const rewrites = rewritesAfter(void 0, units.map((u) => u.unit), units.filter((u) => u.class !== "converged"));
13573
+ const sent = units.filter((u) => u.class === "converged").map((u) => u.unit);
13240
13574
  const entry = {
13241
13575
  origin: "created",
13242
13576
  id: ownId(st.address),
13243
13577
  normVersion: NORM_VERSIONS.stage,
13244
13578
  ...base === void 0 ? {} : { base },
13245
- ...rewrites === void 0 ? {} : { rewrites }
13579
+ ...rewrites === void 0 ? {} : { rewrites },
13580
+ ...sent.length === 0 ? {} : { written: writtenAfter(void 0, sent, now) },
13581
+ writtenAt: now.toISOString()
13246
13582
  };
13247
13583
  return [[st.address, entry]];
13248
13584
  });
@@ -13296,10 +13632,18 @@ function unsettled(run, step, acknowledged) {
13296
13632
  report: outcome,
13297
13633
  issues: [issue]
13298
13634
  };
13635
+ const desired = specOf(step.desired ?? {});
13636
+ const sent = classify(void 0, desired, desired, { options: "additive" }).map((u) => u.unit);
13637
+ const now = run.deps.now();
13638
+ const entry = {
13639
+ ...entryOf$1(run, step, void 0),
13640
+ ...sent.length === 0 ? {} : { written: writtenAfter(void 0, sent, now) },
13641
+ writtenAt: now.toISOString()
13642
+ };
13299
13643
  return {
13300
13644
  report: outcome,
13301
13645
  issues: [issue],
13302
- entry: entryOf$1(run, step, void 0)
13646
+ entry
13303
13647
  };
13304
13648
  }
13305
13649
  function uncertain(run, step, why) {
@@ -13945,7 +14289,7 @@ function entryOf$1(run, step, base) {
13945
14289
  const made = kind === "group" && madeByRun(run, step);
13946
14290
  origin = owning?.origin ?? (made ? "adopted" : "created");
13947
14291
  } else if (step.action === "update") origin = owning?.origin ?? "adopted";
13948
- const { base: _, ...kept } = step.action === "update" && owning ? owning : {};
14292
+ const { base: _, ...kept } = step.action === "update" && owning ? knownFields(owning) : {};
13949
14293
  const entry = {
13950
14294
  ...kept,
13951
14295
  origin,
@@ -14460,6 +14804,18 @@ function comparedSet(a, b) {
14460
14804
  return [...set].sort(byCodeUnit);
14461
14805
  }
14462
14806
  function compareAddress(address, a, b, options) {
14807
+ const { type } = parseAddress(address);
14808
+ if (!handledType(type)) {
14809
+ if (!(a.coverage && b.coverage)) return {
14810
+ address,
14811
+ status: "unmanaged"
14812
+ };
14813
+ return {
14814
+ address,
14815
+ status: "unknown",
14816
+ reason: sanitize(`a later version of ${bin} handles ${type} resources; this version does not compare them`)
14817
+ };
14818
+ }
14463
14819
  const statusA = statusOf(a, address);
14464
14820
  const statusB = statusOf(b, address);
14465
14821
  const unknown = [unknownReason(a, address, statusA, options.overridden?.a), unknownReason(b, address, statusB, options.overridden?.b)].filter((r) => r !== void 0);
@@ -14606,15 +14962,31 @@ function optionDefaults(spec) {
14606
14962
  function unknownReason(side, address, status, overridden) {
14607
14963
  const name = describeShort(side.side);
14608
14964
  if (status !== "excluded" && overridden?.includes(address)) return `${name} has a lookup override for it; this version manages no lookup resources`;
14965
+ if (opaqueGap(side, address)) return `${name} was taken by a later version of ${bin}, whose read was incomplete in parts this version cannot place`;
14609
14966
  const key = objectOf(address);
14967
+ const settling = settlingOn(side, address);
14968
+ if (settling !== void 0) return `${name} is settling after an apply until ${settling.until}`;
14610
14969
  if (status === "unreadable") {
14611
14970
  const object = coverageOf(side, key);
14612
- if (object?.status === "read") return `${name} could not capture it: its group's name in the portal holds whitespace`;
14971
+ if (object?.status === "read") return readObjectReason(side, address, name);
14613
14972
  const scope = object?.missingScope;
14614
14973
  return `${name} could not read ${key}${scope === void 0 ? "" : `: the key lacks ${scope}`}`;
14615
14974
  }
14616
14975
  return status === "not-observed" ? `${name} did not read ${key}` : void 0;
14617
14976
  }
14977
+ function readObjectReason(side, address, name) {
14978
+ const { type } = parseAddress(address);
14979
+ if (type === "association") {
14980
+ const pair = pairOf(address).sort(byCodeUnit);
14981
+ return unnamedOn(side, address) ? `${name} lists a type between ${pair.join(" and ")} that HubSpot's schema read does not name yet` : `${name} could not read the labels between ${pair.join(" and ")}`;
14982
+ }
14983
+ if (type === "pipeline" || type === "stage") return `${name} could not read the pipelines of ${objectOf(address)}`;
14984
+ return `${name} could not capture it: its group's name in the portal holds whitespace`;
14985
+ }
14986
+ function unnamedOn(side, address) {
14987
+ const [from, to] = pairOf(address);
14988
+ return statusOf(side, address) === "unreadable" && settlingOn(side, address) === void 0 && side.coverage !== void 0 && unnamedOf(side.coverage, from, to).length > 0;
14989
+ }
14618
14990
  function excludedReason(side, address, status) {
14619
14991
  if (status !== "excluded") return;
14620
14992
  const name = describeShort(side.side);
@@ -14665,6 +15037,10 @@ function incomplete(comparison, a, b) {
14665
15037
  const many = regroup.length > 1;
14666
15038
  fixes.push(`rename the group${many ? "s" : ""} of ${regroup.join(", ")} in HubSpot to ${many ? "names" : "a name"} without spaces, then read the portal again`);
14667
15039
  }
15040
+ fixes.push(...[a, b].flatMap((side) => waitFix(side, comparison)));
15041
+ const later = [...new Set(comparison.differences.map((d) => parseAddress(d.address).type).filter((type) => !handledType(type)))].sort(byCodeUnit);
15042
+ if (later.length > 0) fixes.push(`compare with a later version of ${bin}, which handles ${later.map((t) => `${t} resources`).join(", ")}`);
15043
+ else if (a.opaque || b.opaque) fixes.push(`compare with the later version of ${bin} that took the snapshot`);
14668
15044
  const issue = {
14669
15045
  code: "E_INCOMPLETE",
14670
15046
  message: `compare is incomplete: ${items.join("; ")}. Nothing there was compared.`
@@ -14673,7 +15049,23 @@ function incomplete(comparison, a, b) {
14673
15049
  return issue;
14674
15050
  }
14675
15051
  function uncaptured(side, address) {
14676
- return statusOf(side, address) === "unreadable" && coverageOf(side, objectOf(address))?.status === "read";
15052
+ const read = coverageOf(side, objectOf(address))?.status === "read";
15053
+ const property = parseAddress(address).type === "property";
15054
+ const unreadable = statusOf(side, address) === "unreadable" && !opaqueGap(side, address);
15055
+ return property && unreadable && read && settlingOn(side, address) === void 0;
15056
+ }
15057
+ function waitFix(side, comparison) {
15058
+ const unknown = comparison.differences.filter((d) => d.status === "unknown").map((d) => d.address);
15059
+ const until = unknown.flatMap((address) => settlingOn(side, address)?.until ?? []).sort(byCodeUnit).at(-1);
15060
+ let when;
15061
+ if (until !== void 0) when = `after ${until}`;
15062
+ else if (unknown.some((address) => parseAddress(address).type === "association" && unnamedOn(side, address))) when = "in a few minutes, once HubSpot names the new association";
15063
+ if (when === void 0) return [];
15064
+ const { side: which } = side;
15065
+ return [which.kind === "snapshot" ? `take a new snapshot of target ${sanitize(which.name)} ${when}` : `compare again ${when}`];
15066
+ }
15067
+ function settlingOn(side, address) {
15068
+ return side.coverage === void 0 ? void 0 : settlingAt(side.coverage, address);
14677
15069
  }
14678
15070
  function coverageOf(side, key) {
14679
15071
  const objects = side.coverage?.objects;
@@ -14795,6 +15187,11 @@ function dictionary(ir) {
14795
15187
  ""
14796
15188
  ];
14797
15189
  lines.push(...observation ? coverageLines(observation.coverage) : [CONFIG_COVERAGE]);
15190
+ const later = [...new Set(Object.keys(ir.resources).map((a) => parseAddress(a).type))].filter((t) => !handledType(t));
15191
+ if (later.length > 0) {
15192
+ const types = later.sort(byCodeUnit).map((t) => `${escapeMarkdown(t)} resources`);
15193
+ lines.push("", `Not described here: ${types.join(", ")}, which a later version of ${bin} handles.`);
15194
+ }
14798
15195
  for (const [key, resources] of byObject(ir.resources)) lines.push("", ...objectLines(key, resources, observation === void 0));
14799
15196
  if (!observation) lines.push(...overrideLines(ir.targets));
14800
15197
  return `${lines.join("\n")}\n`;
@@ -14888,6 +15285,7 @@ function byObject(resources) {
14888
15285
  const objects = /* @__PURE__ */ new Map();
14889
15286
  for (const address of Object.keys(resources).sort(byCodeUnit)) {
14890
15287
  const { type } = parseAddress(address);
15288
+ if (!handledType(type)) continue;
14891
15289
  const key = objectOf(address);
14892
15290
  const entry = objects.get(key) ?? {
14893
15291
  associations: [],
@@ -15192,9 +15590,11 @@ function rebuild(given) {
15192
15590
  }
15193
15591
  }
15194
15592
  excluded.sort((a, b) => byCodeUnit(a.address, b.address));
15593
+ const kept = Object.keys(input.state?.later?.resources ?? {}).sort(byCodeUnit);
15195
15594
  const loses = input.state ? lossesOf(input.state, resources) : void 0;
15196
15595
  return {
15197
15596
  found,
15597
+ kept,
15198
15598
  missing,
15199
15599
  stale: staleOf(input),
15200
15600
  excluded,
@@ -15234,15 +15634,19 @@ function adopt(address, resource, observed, id) {
15234
15634
  }
15235
15635
  };
15236
15636
  }
15237
- /** The new lineage's state: serial 1, as the first save of a new file has. */
15238
- function rebuiltState(portalId, lineage, resources) {
15239
- return {
15637
+ /** The new lineage's state: serial 1, as the first save of a new file has, with what a later version wrote kept. */
15638
+ function rebuiltState(portalId, lineage, resources, previous = null) {
15639
+ const state = {
15240
15640
  format: "kalup.state/1",
15241
15641
  lineage,
15242
15642
  serial: 1,
15243
15643
  portalId,
15244
15644
  resources
15245
15645
  };
15646
+ return previous?.later ? {
15647
+ ...state,
15648
+ later: previous.later
15649
+ } : state;
15246
15650
  }
15247
15651
  function staleOf(input) {
15248
15652
  const { loaded, observation, state, target } = input;
@@ -17806,9 +18210,7 @@ function FileStateStore(dir, options = {}) {
17806
18210
  file,
17807
18211
  fix: `another ${bin} command wrote state for portal ${next.portalId}; run ${bin} plan again`
17808
18212
  });
17809
- const problems = validateState(next);
17810
- if (problems.length > 0) throw new Error(`refusing to save state that does not match kalup.state/1: ${problems[0]?.message}`);
17811
- const bytes = `${stableStringify(next)}\n`;
18213
+ const bytes = stateText(next);
17812
18214
  if (bytes === current) return false;
17813
18215
  const temp = `${file}.tmp-${process.pid}`;
17814
18216
  try {
@@ -18663,6 +19065,7 @@ async function pullTarget(ctx, pulling) {
18663
19065
  const discovering = ctx.flags.discover === true;
18664
19066
  const store = openStateStore(root);
18665
19067
  const state = store.read(portalId, targetName);
19068
+ const now = /* @__PURE__ */ new Date();
18666
19069
  const portal = await readPortal(http, loaded, target, warnings, {
18667
19070
  schemas: discovering,
18668
19071
  pipelines: discovering,
@@ -18681,7 +19084,15 @@ async function pullTarget(ctx, pulling) {
18681
19084
  const { layout } = loaded;
18682
19085
  const files = readProjectFiles(root, layout);
18683
19086
  warnings.push(...unmovedState(root, portalId));
18684
- const only = addressMatcher(ctx.flags.only);
19087
+ const settle = {
19088
+ state,
19089
+ now
19090
+ };
19091
+ const seen = observePortal(portal, loaded, targetName, [], settle);
19092
+ const settling = seen.observation.coverage?.settling ?? {};
19093
+ warnings.push(...seen.issues.filter((i) => i.code === "W_SETTLING"));
19094
+ const selected = addressMatcher(ctx.flags.only);
19095
+ const only = (address) => selected(address) && !Object.hasOwn(settling, address);
18685
19096
  const { next, objects, overrides } = mergeFiles(files, portal, loaded, {
18686
19097
  only,
18687
19098
  overrides: target.overrides,
@@ -18705,7 +19116,7 @@ async function pullTarget(ctx, pulling) {
18705
19116
  if (index !== void 0) next[layout.barrel] = index;
18706
19117
  const changed = Object.keys(next).filter((file) => next[file] !== files[file]).sort();
18707
19118
  const after = loadFiles(next, { layout });
18708
- const { observation } = observePortal(portal, after, targetName, []);
19119
+ const { observation } = observePortal(portal, after, targetName, [], settle);
18709
19120
  const data = {
18710
19121
  target: targetName,
18711
19122
  portalId,
@@ -18727,7 +19138,8 @@ async function pullTarget(ctx, pulling) {
18727
19138
  created = state === null && data.state.serial !== null;
18728
19139
  }
18729
19140
  }
18730
- let exitCode = ctx.flags.check && ctx.flags.exitCode && (changed.length > 0 || differs(objects, warnings)) ? exitCodes.differences : exitCodes.done;
19141
+ const unsettled = seen.issues.some((i) => i.code === "W_SETTLING");
19142
+ let exitCode = ctx.flags.check && ctx.flags.exitCode && (changed.length > 0 || differs(objects, warnings) || unsettled) ? exitCodes.differences : exitCodes.done;
18731
19143
  if (incomplete) exitCode = exitCodes.error;
18732
19144
  const newState = created && data.state !== void 0 ? shownPath(ctx.cwd, data.state.path) : void 0;
18733
19145
  return {
@@ -19556,7 +19968,7 @@ function openJournal(stateDirectory, run, now = /* @__PURE__ */ new Date(), io =
19556
19968
  outcome: entry.outcome,
19557
19969
  ms: entry.ms
19558
19970
  };
19559
- if (Object.values(line).some((value) => typeof value === "string" && keys.some((key) => value.includes(key)))) throw new Error("refusing to journal a line that holds a key");
19971
+ if (Object.values(line).some((value) => typeof value === "string" && holdsKey(value, keys))) throw new Error("refusing to journal a line that holds a key");
19560
19972
  const fd = io.openSync(path, "a");
19561
19973
  try {
19562
19974
  writeAll(io.writeSync, fd, `${JSON.stringify(line)}\n`);
@@ -19620,7 +20032,13 @@ async function plan(ctx) {
19620
20032
  async function planTarget(http, input) {
19621
20033
  const { loaded, portal, root, take, target } = input;
19622
20034
  const state = openStateStore(root).read(portal.portalId, target);
19623
- const { observation, issues } = await observeTarget(http, loaded, target, { associationIds: associationIds(state) });
20035
+ const { observation, issues } = await observeTarget(http, loaded, target, {
20036
+ associationIds: associationIds(state),
20037
+ settle: {
20038
+ state,
20039
+ now: /* @__PURE__ */ new Date()
20040
+ }
20041
+ });
19624
20042
  issues.push(...unfinished(state?.lastApply), ...unmovedState(root, portal.portalId));
19625
20043
  const reads = planReads({
19626
20044
  loaded,
@@ -20325,7 +20743,14 @@ async function compare(ctx) {
20325
20743
  for (const side of opened) if ("http" in side) await guardPortal(side.http, side.guard);
20326
20744
  const observations = [];
20327
20745
  for (const side of opened) if ("http" in side) {
20328
- const read = await observeTarget(side.http, project?.loaded, side.name);
20746
+ const state = openStateStore(project.root).read(side.guard.portalId, side.name);
20747
+ const read = await observeTarget(side.http, project?.loaded, side.name, {
20748
+ associationIds: associationIds(state),
20749
+ settle: {
20750
+ state,
20751
+ now: /* @__PURE__ */ new Date()
20752
+ }
20753
+ });
20329
20754
  issues.push(...read.issues);
20330
20755
  observations.push(read.observation);
20331
20756
  } else observations.push(side.observation);
@@ -21186,7 +21611,14 @@ async function snapshot(ctx) {
21186
21611
  const { name: target, via } = await resolveTarget(ctx, loaded.config);
21187
21612
  const { http, guard } = connect(root, loaded, target, warnings);
21188
21613
  await guardPortal(http, guard);
21189
- const { observation, issues: read } = await observeTarget(http, loaded, target);
21614
+ const state = openStateStore(root).read(guard.portalId, target);
21615
+ const { observation, issues: read } = await observeTarget(http, loaded, target, {
21616
+ associationIds: associationIds(state),
21617
+ settle: {
21618
+ state,
21619
+ now: /* @__PURE__ */ new Date()
21620
+ }
21621
+ });
21190
21622
  const observedAt = (/* @__PURE__ */ new Date()).toISOString();
21191
21623
  const document = toSnapshot(observation, {
21192
21624
  generator: loaded.ir.generator,
@@ -21264,8 +21696,14 @@ async function stateRebuild(ctx) {
21264
21696
  });
21265
21697
  const store = openStateStore(root);
21266
21698
  const state = store.read(portalId, name);
21267
- const { observation, issues: read } = await observeTarget(http, loaded, name, { associationIds: associationIds(state) });
21268
- if (ctx.flags.write) requireComplete(observation, command, [...warnings, ...read]);
21699
+ const { observation, issues: read } = await observeTarget(http, loaded, name, {
21700
+ associationIds: associationIds(state),
21701
+ settle: {
21702
+ state,
21703
+ now: /* @__PURE__ */ new Date()
21704
+ }
21705
+ });
21706
+ if (ctx.flags.write) requireComplete(observation, command, [...warnings, ...read], Object.keys(loaded.ir.resources));
21269
21707
  const report = rebuild({
21270
21708
  loaded,
21271
21709
  observation,
@@ -21280,6 +21718,7 @@ async function stateRebuild(ctx) {
21280
21718
  missing: report.missing,
21281
21719
  stale: report.stale,
21282
21720
  excluded: report.excluded,
21721
+ ...report.kept.length > 0 ? { kept: report.kept } : {},
21283
21722
  ...report.loses ? { loses: report.loses } : {},
21284
21723
  written: false
21285
21724
  };
@@ -21299,7 +21738,7 @@ async function stateRebuild(ctx) {
21299
21738
  const lock = await acquirePortalLock(portalId, { command: "state rebuild --write" });
21300
21739
  try {
21301
21740
  sameState(state, store.read(portalId, name), portalId, command);
21302
- const written = replaceState(store, portalId, report.resources, command);
21741
+ const written = replaceState(store, portalId, report.resources, command, state);
21303
21742
  Object.assign(data, written, { written: true });
21304
21743
  } finally {
21305
21744
  lock.release();
@@ -21315,9 +21754,9 @@ async function stateRebuild(ctx) {
21315
21754
  * Archives the portal's state file, when it has one, and writes a new lineage holding `resources`. The caller holds the
21316
21755
  * portal lock and has checked the file is the one it showed. `command` is the one to run again after a failed save.
21317
21756
  */
21318
- function replaceState(store, portalId, resources, command) {
21757
+ function replaceState(store, portalId, resources, command, previous) {
21319
21758
  const archived = store.archive(portalId, "state rebuild");
21320
- const next = rebuiltState(portalId, store.newLineage(), resources);
21759
+ const next = rebuiltState(portalId, store.newLineage(), resources, previous);
21321
21760
  try {
21322
21761
  store.write(next, null);
21323
21762
  } catch (error) {
@@ -21353,12 +21792,35 @@ function sameState(shown, now, portalId, command) {
21353
21792
  });
21354
21793
  }
21355
21794
  /**
21356
- * E_INCOMPLETE when the read left out a list config needs: a rebuild would drop every entry there, created origins and
21357
- * bases included, though it could not check them. Nothing has been written. The report alone still runs.
21795
+ * E_INCOMPLETE when the read left out what config names: a list it needs, a property it could not capture, a resource
21796
+ * settling after an apply, or an association a type HubSpot does not name yet may be. A rebuild would drop every entry
21797
+ * there, created origins and bases included, though it could not check them. What settles or is unnamed where config
21798
+ * names nothing stops nothing. Nothing has been written. The report alone still runs.
21358
21799
  */
21359
- function requireComplete(observation, command, issues) {
21800
+ function requireComplete(observation, command, issues, named) {
21360
21801
  const { coverage } = observation;
21361
21802
  if (coverage === void 0 || coverage.complete) return;
21803
+ const { unread, scopes } = unreadOf(coverage);
21804
+ const { settling, unnamed } = waitingOn(observation, named);
21805
+ const until = settling.map(([, s]) => s.until).sort()[settling.length - 1];
21806
+ const items = [
21807
+ ...unread,
21808
+ ...settling.map(([address, s]) => `${sanitize(address)} (settling after an apply until ${s.until})`),
21809
+ ...unnamed.map((pair) => `the associations between ${sanitize(pair)} (a type HubSpot does not name yet)`)
21810
+ ];
21811
+ if (items.length === 0) return;
21812
+ const fixes = [];
21813
+ if (scopes.length > 0) fixes.push(`add the scope${scopes.length > 1 ? "s" : ""} ${scopes.join(", ")} to the write key, then run ${command} again`);
21814
+ else if (unread.length > 0) fixes.push(`fix what the issues that follow name, then run ${command} again`);
21815
+ if (until !== void 0) fixes.push(`run ${command} again after ${until}`);
21816
+ else if (unnamed.length > 0) fixes.push(`run ${command} again in a few minutes, once HubSpot names the new association`);
21817
+ throw new KalupError([{
21818
+ code: "E_INCOMPLETE",
21819
+ message: `the read did not cover everything config names: ${items.join("; ")}. A rebuild would drop what it could not check. Nothing was written.`,
21820
+ fix: fixes.join("; ")
21821
+ }, ...issues]);
21822
+ }
21823
+ function unreadOf(coverage) {
21362
21824
  const unread = [];
21363
21825
  const scopes = /* @__PURE__ */ new Set();
21364
21826
  for (const [object, c] of Object.entries(coverage.objects)) {
@@ -21367,13 +21829,13 @@ function requireComplete(observation, command, issues) {
21367
21829
  if (c.missingScope !== void 0) scopes.add(c.missingScope);
21368
21830
  }
21369
21831
  if (c.unaddressable !== void 0) unread.push(`${c.unaddressable.map((n) => `property:${object}/${sanitize(n)}`).join(", ")} (a group name no address can hold)`);
21832
+ if (c.pipelines?.status === "unreadable") unread.push(`the pipelines of ${object}`);
21833
+ for (const [other, pair] of Object.entries(c.associations?.with ?? {})) if (pair.status === "unreadable") unread.push(`the labels from ${object} to ${other}`);
21370
21834
  }
21371
- const add = scopes.size > 0 ? `add the scope${scopes.size > 1 ? "s" : ""} ${[...scopes].join(", ")} to the write key` : "fix what the issues that follow name";
21372
- throw new KalupError([{
21373
- code: "E_INCOMPLETE",
21374
- message: `the read did not cover everything config names: ${unread.join("; ")}. A rebuild would drop what it could not check. Nothing was written.`,
21375
- fix: `${add}, then run ${command} again`
21376
- }, ...issues]);
21835
+ return {
21836
+ unread,
21837
+ scopes: [...scopes]
21838
+ };
21377
21839
  }
21378
21840
  /** The report as lines a person reads. Every portal string is sanitized. */
21379
21841
  function reportLines(report, total, state, path) {
@@ -21387,6 +21849,7 @@ function reportLines(report, total, state, path) {
21387
21849
  };
21388
21850
  for (const s of report.stale) lines.push(` stale entry: ${s.address} (${why[s.reason]}${s.id === null ? "" : `, records ${sanitize(s.id)}`})`);
21389
21851
  for (const e of report.excluded) lines.push(` not adopted: ${e.address} (${e.reason})`);
21852
+ for (const address of report.kept) lines.push(` kept as it is: ${sanitize(address)} (a later version of ${bin} manages it)`);
21390
21853
  return lines;
21391
21854
  }
21392
21855
  /** The config resources a rebuild can adopt. */
@@ -21774,8 +22237,11 @@ async function targetRebind(ctx) {
21774
22237
  const store = openStateStore(root);
21775
22238
  store.read(from, name);
21776
22239
  const current = store.read(portalId, name);
21777
- const { observation, issues: observed } = await observeTarget(http, loaded, name);
21778
- requireComplete(observation, command, [...warnings, ...observed]);
22240
+ const { observation, issues: observed } = await observeTarget(http, loaded, name, { settle: {
22241
+ state: current,
22242
+ now: /* @__PURE__ */ new Date()
22243
+ } });
22244
+ requireComplete(observation, command, [...warnings, ...observed], Object.keys(loaded.ir.resources));
21779
22245
  const report = rebuild({
21780
22246
  loaded,
21781
22247
  observation,
@@ -21789,7 +22255,7 @@ async function targetRebind(ctx) {
21789
22255
  `${CONFIG} gets portalId ${portalId}; the state file of portal ${from} is archived; plans saved for portal ${from} no longer apply.`
21790
22256
  ]);
21791
22257
  await confirmTarget(ctx.prompt, name, "Type the target name to rebind it:");
21792
- const written = replaceState(store, portalId, report.resources, command);
22258
+ const written = replaceState(store, portalId, report.resources, command, current);
21793
22259
  writeStaged(root, { [CONFIG]: configText });
21794
22260
  const archived = store.archive(from, "target rebind");
21795
22261
  const data = {
@@ -21803,6 +22269,7 @@ async function targetRebind(ctx) {
21803
22269
  missing: report.missing,
21804
22270
  stale: report.stale,
21805
22271
  excluded: report.excluded,
22272
+ ...report.kept.length > 0 ? { kept: report.kept } : {},
21806
22273
  ...archived === null ? {} : { archived }
21807
22274
  };
21808
22275
  const lines = [