@orkestrel/form 0.0.8 → 0.0.10

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.
@@ -1268,6 +1268,11 @@ var Form = class {
1268
1268
  #touched = /* @__PURE__ */ new Set();
1269
1269
  #disabled = /* @__PURE__ */ new Map();
1270
1270
  #invalidations = /* @__PURE__ */ new Map();
1271
+ #revisions = /* @__PURE__ */ new Map();
1272
+ #captured = /* @__PURE__ */ new Map();
1273
+ #submission;
1274
+ #controller;
1275
+ #preparing;
1271
1276
  #resolvers = Promise.withResolvers();
1272
1277
  #errors = Object.freeze([]);
1273
1278
  #status = "editing";
@@ -1322,10 +1327,14 @@ var Form = class {
1322
1327
  for (const [name, value] of Object.entries(this.#values)) defineEntry(values, name, value);
1323
1328
  return Object.freeze(values);
1324
1329
  }
1325
- /** Holds the answers the form opened with. */
1330
+ /** Holds the acknowledged answers, initially the schema's defaults overlaid with seeded values. */
1326
1331
  get baseline() {
1327
1332
  return this.#baseline;
1328
1333
  }
1334
+ /** Holds the submission awaiting acknowledgement, or `undefined` when none is pending. */
1335
+ get pending() {
1336
+ return this.#submission;
1337
+ }
1329
1338
  /** Holds every error the last completed evaluation produced. */
1330
1339
  get errors() {
1331
1340
  return this.#errors;
@@ -1348,7 +1357,7 @@ var Form = class {
1348
1357
  get valid() {
1349
1358
  return this.#errors.length === 0;
1350
1359
  }
1351
- /** Reports whether any answer has moved since the form opened. */
1360
+ /** Reports whether any answer differs from its acknowledged baseline. */
1352
1361
  get dirty() {
1353
1362
  return !matchesValues(this.values, this.#baseline);
1354
1363
  }
@@ -1393,6 +1402,7 @@ var Form = class {
1393
1402
  this.#batch(() => {
1394
1403
  const moved = [];
1395
1404
  for (const [name, answer] of entries) {
1405
+ this.#revisions.set(name, (this.#revisions.get(name) ?? 0) + 1);
1396
1406
  if (this.#differs(name, answer)) moved.push(name);
1397
1407
  if (answer === void 0) delete this.#values[name];
1398
1408
  else defineEntry(this.#values, name, cloneValue(answer));
@@ -1415,26 +1425,34 @@ var Form = class {
1415
1425
  touch(name) {
1416
1426
  this.#gate();
1417
1427
  this.#requireField(name);
1418
- this.#touched.add(name);
1428
+ this.#batch(() => {
1429
+ if (this.#touched.has(name)) return;
1430
+ this.#touched.add(name);
1431
+ this.#emitter.emit("touch", name);
1432
+ });
1419
1433
  }
1420
1434
  /**
1421
1435
  * Fails a field from outside, for what the rules cannot see.
1422
1436
  *
1423
1437
  * @param name - The field's name.
1424
1438
  * @param message - What to tell the person.
1439
+ * @param submission - The active receipt to check against the field's captured revision.
1440
+ * @returns True if the failure was applied; false if the receipt or field revision is stale.
1425
1441
  * @remarks
1426
1442
  * One field holds one external failure: a second call replaces the first. The failure lasts
1427
1443
  * until that field is filled again or the form is cleared.
1428
1444
  * @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended, and
1429
1445
  * `FIELD` when the schema declares no such name.
1430
1446
  */
1431
- invalidate(name, message) {
1432
- this.#gate();
1447
+ invalidate(name, message, submission) {
1448
+ if (submission === void 0) this.#gate();
1433
1449
  this.#requireField(name);
1450
+ if (submission !== void 0 && (this.#pending || submission !== this.#submission || !this.#captured.has(name) || this.#captured.get(name) !== (this.#revisions.get(name) ?? 0))) return false;
1434
1451
  this.#batch(() => {
1435
1452
  this.#invalidations.set(name, message);
1436
1453
  if (this.#evaluate()) this.#emitter.emit("validate", this.#errors);
1437
1454
  });
1455
+ return true;
1438
1456
  }
1439
1457
  /**
1440
1458
  * Takes one or more fields out of the form.
@@ -1478,6 +1496,7 @@ var Form = class {
1478
1496
  */
1479
1497
  submit() {
1480
1498
  this.#gate();
1499
+ this.#gatePreparation();
1481
1500
  return this.#batch(() => {
1482
1501
  const changed = this.#evaluate();
1483
1502
  const checked = this.#errors;
@@ -1497,12 +1516,16 @@ var Form = class {
1497
1516
  const stopped = errors.length > 0;
1498
1517
  if (stopped) {
1499
1518
  const disabled = this.disabled;
1500
- for (const field of this.#schema.fields) if (!disabled.has(field.name)) this.#touched.add(field.name);
1519
+ for (const field of this.#schema.fields) if (!disabled.has(field.name) && !this.#touched.has(field.name)) {
1520
+ this.#touched.add(field.name);
1521
+ this.#emitter.emit("touch", field.name);
1522
+ }
1501
1523
  }
1502
1524
  if (stopped) return {
1503
1525
  success: false,
1504
1526
  error: errors
1505
1527
  };
1528
+ this.#gatePreparation();
1506
1529
  const answers = this.#snapshot();
1507
1530
  this.#status = "settled";
1508
1531
  this.#resolvers.resolve(answers);
@@ -1514,20 +1537,143 @@ var Form = class {
1514
1537
  });
1515
1538
  }
1516
1539
  /**
1517
- * Returns every answer to the ones the form opened with: the schema's defaults, overlaid with
1518
- * any seeded `values`. Reset the runtime disabled state to the schema's declarations.
1540
+ * Prepares an owned enabled snapshot for acknowledgement while leaving the form editable.
1541
+ *
1542
+ * @returns The submission on success, or the errors that prevented preparation.
1543
+ * @remarks
1544
+ * A failed check refuses its checked errors even if an observer repairs them. A passing check
1545
+ * drains observer mutations through one further check. The receipt always carries that check's
1546
+ * snapshot, including when its notifications produce later edits. A throwing validator creates
1547
+ * no receipt. The pending identity is installed before `prepare` listeners run.
1548
+ * @throws A {@link FormError} coded `PENDING` while another preparation or receipt is pending,
1549
+ * or when clear interrupts preparation; terminal forms retain their terminal refusals.
1550
+ */
1551
+ prepare() {
1552
+ this.#gate();
1553
+ this.#gatePreparation();
1554
+ const controller = new AbortController();
1555
+ this.#preparing = controller;
1556
+ return this.#batch(() => {
1557
+ try {
1558
+ let drained = false;
1559
+ while (true) {
1560
+ const values = this.values;
1561
+ const disabled = this.disabled;
1562
+ const captured = /* @__PURE__ */ new Map();
1563
+ const touched = [];
1564
+ for (const field of this.#schema.fields) {
1565
+ if (disabled.has(field.name)) continue;
1566
+ captured.set(field.name, this.#revisions.get(field.name) ?? 0);
1567
+ if (!this.#touched.has(field.name)) touched.push(field.name);
1568
+ this.#touched.add(field.name);
1569
+ }
1570
+ const before = this.#evaluation;
1571
+ for (const name of touched) {
1572
+ if (controller.signal.aborted || this.#pending) break;
1573
+ this.#emitter.emit("touch", name);
1574
+ }
1575
+ this.#requirePreparation(controller);
1576
+ const errors = this.#check(values, disabled);
1577
+ const mutated = this.#evaluation !== before;
1578
+ const changed = !mutated && this.#record(errors);
1579
+ const evaluation = this.#evaluation;
1580
+ if (changed) this.#emitter.emit("validate", this.#errors);
1581
+ this.#requirePreparation(controller);
1582
+ if (errors.length > 0) return {
1583
+ success: false,
1584
+ error: errors
1585
+ };
1586
+ if (!drained && (mutated || this.#evaluation !== evaluation)) {
1587
+ drained = true;
1588
+ continue;
1589
+ }
1590
+ const submission = Object.freeze({
1591
+ values: this.#snapshot(values, disabled),
1592
+ signal: controller.signal
1593
+ });
1594
+ this.#captured = captured;
1595
+ this.#controller = controller;
1596
+ this.#submission = submission;
1597
+ this.#preparing = void 0;
1598
+ this.#emitter.emit("prepare", submission);
1599
+ return {
1600
+ success: true,
1601
+ value: submission
1602
+ };
1603
+ }
1604
+ } finally {
1605
+ if (this.#preparing === controller) this.#preparing = void 0;
1606
+ }
1607
+ });
1608
+ }
1609
+ /**
1610
+ * Adopts the active submission's captured answers into the baseline without changing live state.
1611
+ *
1612
+ * @param submission - The receipt returned by preparation.
1613
+ * @returns True if this active receipt was accepted; false otherwise, including after teardown.
1614
+ */
1615
+ accept(submission) {
1616
+ if (this.#pending || submission !== this.#submission) return false;
1617
+ return this.#batch(() => {
1618
+ const baseline = { ...this.#baseline };
1619
+ for (const name of this.#captured.keys()) {
1620
+ const value = Object.hasOwn(submission.values, name) ? submission.values[name] : void 0;
1621
+ if (value === void 0) delete baseline[name];
1622
+ else defineEntry(baseline, name, value);
1623
+ }
1624
+ this.#baseline = Object.freeze(baseline);
1625
+ this.#submission = void 0;
1626
+ this.#controller = void 0;
1627
+ this.#captured = /* @__PURE__ */ new Map();
1628
+ this.#emitter.emit("accept", submission);
1629
+ return true;
1630
+ });
1631
+ }
1632
+ /**
1633
+ * Consumes the active submission and aborts its signal without changing answers or baseline.
1634
+ *
1635
+ * @param submission - The receipt returned by preparation.
1636
+ * @returns True if this active receipt was aborted; false otherwise, including after teardown.
1637
+ */
1638
+ abort(submission) {
1639
+ if (this.#pending || submission !== this.#submission) return false;
1640
+ this.#batch(() => {
1641
+ const controller = this.#controller;
1642
+ this.#submission = void 0;
1643
+ this.#controller = void 0;
1644
+ this.#captured = /* @__PURE__ */ new Map();
1645
+ controller?.abort();
1646
+ this.#emitter.emit("abort", submission);
1647
+ });
1648
+ return true;
1649
+ }
1650
+ /**
1651
+ * Returns every answer to its acknowledged baseline and aborts pending acknowledgement.
1519
1652
  *
1520
1653
  * @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended.
1521
1654
  */
1522
1655
  clear() {
1523
1656
  this.#gate();
1524
1657
  this.#batch(() => {
1658
+ const submission = this.#submission;
1659
+ const controller = this.#controller;
1660
+ this.#submission = void 0;
1661
+ this.#controller = void 0;
1662
+ this.#captured = /* @__PURE__ */ new Map();
1663
+ this.#preparing?.abort();
1525
1664
  for (const name of Object.keys(this.#values)) delete this.#values[name];
1526
1665
  for (const [name, value] of Object.entries(this.#baseline)) defineEntry(this.#values, name, value);
1527
1666
  this.#touched.clear();
1528
1667
  this.#disabled.clear();
1529
1668
  this.#invalidations.clear();
1530
- const changed = this.#evaluate();
1669
+ this.#revisions.clear();
1670
+ let changed = false;
1671
+ try {
1672
+ changed = this.#evaluate();
1673
+ } finally {
1674
+ controller?.abort();
1675
+ if (submission !== void 0) this.#emitter.emit("abort", submission);
1676
+ }
1531
1677
  this.#emitter.emit("clear");
1532
1678
  if (changed) this.#emitter.emit("validate", this.#errors);
1533
1679
  });
@@ -1550,6 +1696,13 @@ var Form = class {
1550
1696
  if (this.#status === "settled") throw new FormError("SETTLED", "The form has settled and cannot change");
1551
1697
  if (this.#status === "abandoned" || this.#pending) throw new FormError("ABANDONED", "The form was abandoned and cannot change");
1552
1698
  }
1699
+ #gatePreparation() {
1700
+ if (this.#preparing !== void 0 || this.#submission !== void 0) throw new FormError("PENDING", "The form has a pending preparation or submission");
1701
+ }
1702
+ #requirePreparation(controller) {
1703
+ this.#gate();
1704
+ if (controller.signal.aborted) throw new FormError("PENDING", "The preparation was interrupted by clear");
1705
+ }
1553
1706
  #requireField(name) {
1554
1707
  const field = this.field(name);
1555
1708
  if (field === void 0) throw new FormError("FIELD", `The schema declares no field named "${name}"`, { field: name });
@@ -1574,17 +1727,22 @@ var Form = class {
1574
1727
  }
1575
1728
  #evaluate() {
1576
1729
  const disabled = this.disabled;
1730
+ return this.#record(this.#check(this.values, disabled));
1731
+ }
1732
+ #check(values, disabled) {
1577
1733
  const options = this.#messages === void 0 ? { disabled } : {
1578
1734
  messages: this.#messages,
1579
1735
  disabled
1580
1736
  };
1581
- const errors = [...evaluateForm(this.#schema, this.values, options)];
1737
+ const errors = [...evaluateForm(this.#schema, values, options)];
1582
1738
  for (const [field, message] of this.#invalidations) if (!disabled.has(field)) errors.push(Object.freeze({
1583
1739
  field,
1584
1740
  message
1585
1741
  }));
1742
+ return Object.freeze(errors);
1743
+ }
1744
+ #record(next) {
1586
1745
  const previous = this.#errors;
1587
- const next = Object.freeze(errors);
1588
1746
  this.#errors = next;
1589
1747
  const changed = next.length !== previous.length || next.some((error, index) => {
1590
1748
  const before = previous[index];
@@ -1606,12 +1764,11 @@ var Form = class {
1606
1764
  };
1607
1765
  if (this.#status === "abandoned") this.#gate();
1608
1766
  }
1609
- #snapshot() {
1767
+ #snapshot(values = this.#values, disabled = this.disabled) {
1610
1768
  const answers = {};
1611
- const disabled = this.disabled;
1612
1769
  for (const field of this.#schema.fields) {
1613
- const value = Object.hasOwn(this.#values, field.name) ? this.#values[field.name] : void 0;
1614
- if (!disabled.has(field.name) && value !== void 0) defineEntry(answers, field.name, value);
1770
+ const value = Object.hasOwn(values, field.name) ? values[field.name] : void 0;
1771
+ if (!disabled.has(field.name) && value !== void 0) defineEntry(answers, field.name, cloneValue(value));
1615
1772
  }
1616
1773
  return Object.freeze(answers);
1617
1774
  }
@@ -1625,11 +1782,20 @@ var Form = class {
1625
1782
  }
1626
1783
  }
1627
1784
  #teardown() {
1628
- if (this.#status === "editing") {
1785
+ const submission = this.#submission;
1786
+ const controller = this.#controller;
1787
+ this.#submission = void 0;
1788
+ this.#controller = void 0;
1789
+ this.#captured = /* @__PURE__ */ new Map();
1790
+ this.#preparing?.abort();
1791
+ const abandoned = this.#status === "editing";
1792
+ if (abandoned) {
1629
1793
  this.#status = "abandoned";
1630
1794
  this.#resolvers.reject(new FormError("ABANDONED", "The form was destroyed before it settled"));
1631
- this.#emitter.emit("abandon");
1632
1795
  }
1796
+ controller?.abort();
1797
+ if (submission !== void 0) this.#emitter.emit("abort", submission);
1798
+ if (abandoned) this.#emitter.emit("abandon");
1633
1799
  this.#emitter.destroy();
1634
1800
  }
1635
1801
  };