logisheets-core 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ops/index.js CHANGED
@@ -18,13 +18,131 @@
18
18
  // host — only the engine-facing operation lives here.
19
19
  import { makeTransaction } from '../transaction/index.js';
20
20
  import { checkValidations as checkValidationsPure, interpretValidation, } from '../validation/index.js';
21
- import { checkFieldConstraints as checkFieldConstraintsPure, } from '../field/index.js';
22
21
  import { generateFontPayload, generateAlgnmentPayload, generateWrapTextPayload, generateNumFmtPayload, generatePatternFillPayload, generateBorderPayloads, } from '../format/index.js';
23
22
  function isErrorMessage(v) {
24
23
  return (typeof v === 'object' &&
25
24
  v !== null &&
26
25
  'msg' in v);
27
26
  }
27
+ /** The engine's flat `FieldTypeParts` shape, or undefined for unspecified. */
28
+ function fieldTypeParts(f) {
29
+ if (!f.fieldType || f.fieldType === 'unspecified')
30
+ return undefined;
31
+ return {
32
+ kind: f.fieldType,
33
+ enumSetId: f.enumSetId,
34
+ refSheetId: f.refTarget?.sheetId,
35
+ refBlockId: f.refTarget?.blockId,
36
+ refFieldName: f.refTarget?.fieldName,
37
+ };
38
+ }
39
+ /**
40
+ * `upsertEnumSet` payloads for the sets a block's fields reference.
41
+ *
42
+ * Emitted in the same transaction as the schema bind, so a field declaring
43
+ * `enum{setId}` and the set it names never land apart — the declaration is
44
+ * useless to any other host without the options beside it. A set with no
45
+ * variants is skipped rather than sent: the engine refuses it (it would allow
46
+ * nothing), and failing the whole bind over a half-authored option list is the
47
+ * wrong trade.
48
+ */
49
+ function enumSetPayloads(fields, sets) {
50
+ if (!sets || sets.length === 0)
51
+ return [];
52
+ const referenced = new Set(fields.map((f) => f.enumSetId).filter((id) => !!id));
53
+ return sets
54
+ .filter((s) => referenced.has(s.id) && s.variants.length > 0)
55
+ .map((s) => ({
56
+ type: 'upsertEnumSet',
57
+ value: {
58
+ id: s.id,
59
+ name: s.name,
60
+ variants: s.variants.map((v) => ({ id: v.id, label: v.label })),
61
+ },
62
+ }));
63
+ }
64
+ /**
65
+ * Whether the result of `func` over a column is still measured in that
66
+ * column's units, and so should carry its number format.
67
+ *
68
+ * A sum, an average, a minimum or a maximum of a currency column is currency.
69
+ * The counts are not: they answer "how many", so formatting one like the
70
+ * column it counts would print "$3" for three orders. These are the aggregates
71
+ * whose result changes what is being measured.
72
+ */
73
+ export function aggregateKeepsFormat(func) {
74
+ return func !== 'COUNT' && func !== 'COUNTA';
75
+ }
76
+ /**
77
+ * A pivot recipe as PLAIN data, whatever the caller handed over.
78
+ *
79
+ * A recipe read back off a block arrives through the worker boundary, and what
80
+ * comes out the other side is not necessarily structured-cloneable on the way
81
+ * back in — a host that passes a recipe straight from `BlockInfo.pivot` into a
82
+ * payload gets "could not be cloned" from `postMessage`, at the moment of the
83
+ * write, with nothing in any test to warn it. Rebuilding every field by value
84
+ * makes that impossible to hit.
85
+ */
86
+ function plainSpec(spec) {
87
+ return {
88
+ rowDim: String(spec.rowDim),
89
+ ...(spec.colDim === undefined ? {} : { colDim: String(spec.colDim) }),
90
+ measure: String(spec.measure),
91
+ func: spec.func,
92
+ order: spec.order,
93
+ orderValues: spec.orderValues.map((v) => String(v)),
94
+ filters: spec.filters.map((f) => ({
95
+ field: String(f.field),
96
+ criteria: String(f.criteria),
97
+ })),
98
+ };
99
+ }
100
+ /**
101
+ * A pivot's header is the FIRST line of its block, and its records follow.
102
+ *
103
+ * Named because three places have to agree about it: the geometry (a pivot is
104
+ * one line taller than it has groups), the bind (`headerIdx`), and the writes
105
+ * that fill it. It is first because Excel's `headerRowCount="1"` cannot mean
106
+ * anything else.
107
+ */
108
+ const PIVOT_HEADER_ROW = 0;
109
+ /** Block-relative row of a pivot's first RECORD, i.e. just after the header. */
110
+ const PIVOT_FIRST_RECORD = 1;
111
+ /**
112
+ * SUM over every field the source declares a number.
113
+ *
114
+ * A default is only possible because the declaration exists: this is what the
115
+ * field-type work in `design/block-field-semantics.md` bought — before it, a
116
+ * caller could only guess from the data.
117
+ */
118
+ export function defaultAnalysisAggregates(fields) {
119
+ return fields
120
+ .filter((f) => f.isNumber)
121
+ .map((f) => ({ field: f.name, func: 'SUM' }));
122
+ }
123
+ /**
124
+ * One field of a `bindFormSchema` payload.
125
+ *
126
+ * The payload used to take five positionally-aligned arrays (names, renderIds,
127
+ * and one per rule kind), which three call sites below each spelled out by
128
+ * hand. Carrying the declaration too would have made it eight — so the payload
129
+ * became a list of these, and the three call sites became one function.
130
+ */
131
+ function toSchemaFieldSpec(f) {
132
+ return {
133
+ name: f.name,
134
+ renderId: f.renderId,
135
+ valueFormula: f.valueFormula || undefined,
136
+ validationFormula: f.validationFormula || undefined,
137
+ editabilityFormula: f.editabilityFormula || undefined,
138
+ fieldType: fieldTypeParts(f),
139
+ description: f.description || undefined,
140
+ required: f.required,
141
+ unique: f.unique,
142
+ defaultValue: f.defaultValue || undefined,
143
+ writePolicy: f.writePolicy,
144
+ };
145
+ }
28
146
  /**
29
147
  * High-level workbook operations bound to one engine {@link Client}.
30
148
  * Construct one per workbook and share it across the host.
@@ -250,6 +368,7 @@ export class WorkbookOps {
250
368
  colCnt: fields.length,
251
369
  },
252
370
  },
371
+ ...enumSetPayloads(fields, opts.enumSets),
253
372
  {
254
373
  type: 'bindFormSchema',
255
374
  value: {
@@ -259,11 +378,7 @@ export class WorkbookOps {
259
378
  fieldFrom: 0,
260
379
  row: true,
261
380
  keyIdx: keyIdx < 0 ? 0 : keyIdx,
262
- fields: fields.map((f) => f.name),
263
- renderIds: fields.map((f) => f.renderId),
264
- fieldFormulas: fields.map((f) => f.valueFormula ?? ''),
265
- validationFormulas: [],
266
- editabilityFormulas: [],
381
+ fields: fields.map(toSchemaFieldSpec),
267
382
  },
268
383
  },
269
384
  ...fields.map((f) => ({
@@ -297,6 +412,7 @@ export class WorkbookOps {
297
412
  colCnt,
298
413
  },
299
414
  },
415
+ ...enumSetPayloads(fields, opts.enumSets),
300
416
  {
301
417
  type: 'bindFormSchema',
302
418
  value: {
@@ -306,11 +422,7 @@ export class WorkbookOps {
306
422
  fieldFrom: 0,
307
423
  row: true,
308
424
  keyIdx: keyIdx < 0 ? 0 : keyIdx,
309
- fields: fields.map((f) => f.name),
310
- renderIds: fields.map((f) => f.renderId),
311
- fieldFormulas: fields.map((f) => f.valueFormula ?? ''),
312
- validationFormulas: [],
313
- editabilityFormulas: [],
425
+ fields: fields.map(toSchemaFieldSpec),
314
426
  },
315
427
  },
316
428
  ...fields.map((f) => ({
@@ -363,6 +475,7 @@ export class WorkbookOps {
363
475
  },
364
476
  });
365
477
  }
478
+ payloads.push(...enumSetPayloads(fields, opts.enumSets));
366
479
  payloads.push({
367
480
  type: 'bindFormSchema',
368
481
  value: {
@@ -372,11 +485,7 @@ export class WorkbookOps {
372
485
  fieldFrom: 0,
373
486
  row: true,
374
487
  keyIdx: keyIdx < 0 ? 0 : keyIdx,
375
- fields: fields.map((f) => f.name),
376
- renderIds: fields.map((f) => f.renderId),
377
- fieldFormulas: fields.map((f) => f.valueFormula ?? ''),
378
- validationFormulas: [],
379
- editabilityFormulas: [],
488
+ fields: fields.map(toSchemaFieldSpec),
380
489
  },
381
490
  });
382
491
  payloads.push(...fields.map((f) => ({
@@ -389,6 +498,776 @@ export class WorkbookOps {
389
498
  })));
390
499
  await this.apply(payloads, true);
391
500
  }
501
+ /**
502
+ * Rewrite ONE kind of per-field rule on a block, leaving the others alone.
503
+ *
504
+ * `formulas` is one entry per field, in the schema's field order — a rule
505
+ * or `''` for none. The other two rule kinds are sent empty, which the
506
+ * engine reads as "don't touch these", so editing a validation rule cannot
507
+ * clear the value formulas standing next to it.
508
+ *
509
+ * Every existing row is re-materialized from the new rule, so this is also
510
+ * how a rule is removed: pass `''` for that field.
511
+ */
512
+ async setFieldRules(opts) {
513
+ const { sheetIdx, blockId, kind, formulas } = opts;
514
+ const forKind = (k) => kind === k ? formulas.map((f) => f ?? '') : [];
515
+ await this.apply([
516
+ {
517
+ type: 'upsertFieldFormulas',
518
+ value: {
519
+ sheetIdx,
520
+ blockId,
521
+ fieldFormulas: forKind('value'),
522
+ validationFormulas: forKind('validation'),
523
+ editabilityFormulas: forKind('editability'),
524
+ },
525
+ },
526
+ ], true);
527
+ }
528
+ // ---- analysis blocks ------------------------------------------------
529
+ /**
530
+ * Create the block that analyses `source`: a totals row placed directly
531
+ * below it, declaring what it analyses and how each of its fields
532
+ * aggregates. See `design/block-analysis.md`.
533
+ *
534
+ * **No formula is sent.** The engine generates each aggregate field's
535
+ * formula from the declaration, which is what makes renaming a source
536
+ * field rebuild the total instead of silently zeroing it. A field with no
537
+ * aggregate stays an ordinary cell — that is the label column, and the
538
+ * label is also the key the result is addressed by
539
+ * (`BLOCKREF(refName, label, field)`).
540
+ *
541
+ * One transaction, so it is one undo, and so a reader between two payloads
542
+ * never sees a one-row table it would mistake for a record.
543
+ *
544
+ * Returns what it aggregated, in column order, for the caller to report.
545
+ */
546
+ async createAnalysisBlock(opts) {
547
+ const { source, blockId, refName, label } = opts;
548
+ const chosen = new Map();
549
+ for (const a of opts.aggregates ??
550
+ defaultAnalysisAggregates(source.fields)) {
551
+ if (!source.fields.some((f) => f.name === a.field)) {
552
+ throw new Error(`Cannot aggregate "${a.field}": the block has no such field.`);
553
+ }
554
+ chosen.set(a.field, a.func);
555
+ }
556
+ if (chosen.size === 0) {
557
+ throw new Error('Nothing to aggregate: no field of this block is declared a ' +
558
+ 'number. Give a field the number type first, or choose ' +
559
+ 'what to aggregate explicitly.');
560
+ }
561
+ const row = source.rowStart + source.rowCnt;
562
+ const renderIdOf = (i) => `${refName}__agg${i}`;
563
+ await this.apply([
564
+ // Room first, or the block would land on whatever sits below
565
+ // the table. This is also what pushes the pair apart as the
566
+ // source grows, keeping them adjacent.
567
+ {
568
+ type: 'insertRows',
569
+ value: { sheetIdx: source.sheetIdx, start: row, count: 1 },
570
+ },
571
+ {
572
+ type: 'createBlock',
573
+ value: {
574
+ sheetIdx: source.sheetIdx,
575
+ id: blockId,
576
+ masterRow: row,
577
+ masterCol: source.colStart,
578
+ rowCnt: 1,
579
+ colCnt: source.fields.length,
580
+ analyzes: source.blockId,
581
+ description: `Analysis of "${source.refName}".`,
582
+ },
583
+ },
584
+ {
585
+ type: 'bindFormSchema',
586
+ value: {
587
+ refName,
588
+ sheetIdx: source.sheetIdx,
589
+ blockId,
590
+ fieldFrom: 0,
591
+ row: true,
592
+ keyIdx: source.keyIdx < 0 ? 0 : source.keyIdx,
593
+ fields: source.fields.map((f, i) => {
594
+ const func = chosen.get(f.name);
595
+ return {
596
+ name: f.name,
597
+ renderId: renderIdOf(i),
598
+ aggFunc: func,
599
+ aggField: func ? f.name : undefined,
600
+ };
601
+ }),
602
+ },
603
+ },
604
+ // The label, which is the key the result is addressed by.
605
+ {
606
+ type: 'blockInput',
607
+ value: {
608
+ sheetIdx: source.sheetIdx,
609
+ blockId,
610
+ row: 0,
611
+ col: source.keyIdx < 0 ? 0 : source.keyIdx,
612
+ input: label,
613
+ },
614
+ },
615
+ // Carry each source column's number format across, so a total
616
+ // is formatted like the column it totals rather than as a bare
617
+ // number. A COUNT is the exception — it counts records, not
618
+ // currency — so it is left plain.
619
+ ...source.fields.map((f, i) => {
620
+ const func = chosen.get(f.name);
621
+ const keep = func === undefined || aggregateKeepsFormat(func);
622
+ return {
623
+ type: 'upsertFieldRenderInfo',
624
+ value: {
625
+ renderId: renderIdOf(i),
626
+ diyRender: false,
627
+ styleUpdate: {
628
+ setNumFmt: (keep ? f.numFmt : undefined) ?? '',
629
+ },
630
+ },
631
+ };
632
+ }),
633
+ ], true);
634
+ return source.fields
635
+ .filter((f) => chosen.has(f.name))
636
+ .map((f) => ({ field: f.name, func: chosen.get(f.name) }));
637
+ }
638
+ /**
639
+ * Change WHAT an existing analysis block computes — which source fields it
640
+ * aggregates, with which function, and the label its row is addressed by.
641
+ *
642
+ * The shape never changes: an analysis block is one row with one column
643
+ * per source field, so an edit is a re-bind and nothing else. That is the
644
+ * whole difference from {@link editPivot}, where changing the recipe
645
+ * changes how many rows and columns there are.
646
+ *
647
+ * Exists so an analysis can be arrived at in STEPS. The first guess (sum
648
+ * every number) is often nearly right and occasionally wrong in one
649
+ * column, and without this the only remedy was to delete the block and
650
+ * build it again — losing its ref name, and with it every formula pointing
651
+ * at it.
652
+ */
653
+ async editAnalysisBlock(opts) {
654
+ const { sheetIdx, blockId, refName, source } = opts;
655
+ const chosen = new Map();
656
+ for (const a of opts.aggregates) {
657
+ if (!source.fields.some((f) => f.name === a.field)) {
658
+ throw new Error(`Cannot aggregate "${a.field}": the block has no such field.`);
659
+ }
660
+ chosen.set(a.field, a.func);
661
+ }
662
+ if (chosen.size === 0) {
663
+ throw new Error('Nothing to aggregate: choose at least one field and function.');
664
+ }
665
+ const renderIdOf = (i) => `${refName}__agg${i}`;
666
+ const payloads = [
667
+ {
668
+ type: 'bindFormSchema',
669
+ value: {
670
+ refName,
671
+ sheetIdx,
672
+ blockId,
673
+ fieldFrom: 0,
674
+ row: true,
675
+ keyIdx: source.keyIdx < 0 ? 0 : source.keyIdx,
676
+ fields: source.fields.map((f, i) => {
677
+ const func = chosen.get(f.name);
678
+ return {
679
+ name: f.name,
680
+ renderId: renderIdOf(i),
681
+ aggFunc: func,
682
+ aggField: func ? f.name : undefined,
683
+ };
684
+ }),
685
+ },
686
+ },
687
+ ];
688
+ const keyCol = source.keyIdx < 0 ? 0 : source.keyIdx;
689
+ if (opts.label !== undefined) {
690
+ payloads.push({
691
+ type: 'blockInput',
692
+ value: {
693
+ sheetIdx,
694
+ blockId,
695
+ row: 0,
696
+ col: keyCol,
697
+ input: opts.label,
698
+ },
699
+ });
700
+ }
701
+ // Blank the columns this edit STOPPED computing.
702
+ //
703
+ // The engine drops the formula on its own — a generated cell with no
704
+ // declaration must not keep one, or it would go on recomputing under a
705
+ // heading that no longer claims it. What it does not do is clear the
706
+ // VALUE the formula last produced: removing a formula normally leaves
707
+ // its value behind, which is right everywhere else and wrong here, and
708
+ // a re-bind gives the container no hook to say so. So the caller says
709
+ // it. Never the key column — that holds the label.
710
+ source.fields.forEach((f, i) => {
711
+ if (i === keyCol || chosen.has(f.name))
712
+ return;
713
+ payloads.push({
714
+ type: 'blockInput',
715
+ value: { sheetIdx, blockId, row: 0, col: i, input: '' },
716
+ });
717
+ });
718
+ // Formats last, for the same reason as everywhere else: they attach to
719
+ // the render ids the bind declares. A column that stopped being a SUM
720
+ // and became a COUNT stops being currency with it.
721
+ payloads.push(...source.fields.map((f, i) => {
722
+ const func = chosen.get(f.name);
723
+ const keep = func === undefined || aggregateKeepsFormat(func);
724
+ return {
725
+ type: 'upsertFieldRenderInfo',
726
+ value: {
727
+ renderId: renderIdOf(i),
728
+ diyRender: false,
729
+ styleUpdate: {
730
+ setNumFmt: (keep ? f.numFmt : undefined) ?? '',
731
+ },
732
+ },
733
+ };
734
+ }));
735
+ await this.apply(payloads, true);
736
+ return source.fields
737
+ .filter((f) => chosen.has(f.name))
738
+ .map((f) => ({ field: f.name, func: chosen.get(f.name) }));
739
+ }
740
+ // ---- pivots -----------------------------------------------------------
741
+ /**
742
+ * The label row a pivot carries directly above itself, as `cellInput`
743
+ * payloads.
744
+ *
745
+ * A pivot's column names are DATA — they are the column dimension's own
746
+ * values — but they live on the schema, which means the raw sheet shows a
747
+ * grid of numbers with nothing to say what the columns are. Everything
748
+ * that is not our own UI sees it that way: another tool, a person reading
749
+ * the file, and Excel, whose pivot tables require the labels to be in
750
+ * cells. So the pivot writes them there.
751
+ *
752
+ * The row sits OUTSIDE the block, immediately above it. Inside would break
753
+ * what a block is: every row of a block is a record addressed by its key,
754
+ * so a header row would become a record keyed "region" — captured by
755
+ * `#KEY`, counted by the key-uniqueness guard, and aggregated by anything
756
+ * reading the block.
757
+ *
758
+ * Rewritten in full whenever the column set can have changed, because a
759
+ * label left behind from a previous shape is worse than no label: it names
760
+ * a column that is now something else.
761
+ */
762
+ pivotHeaderRow(opts) {
763
+ // Block-relative, so nothing here depends on where the block sits.
764
+ // The header is one of the block's own lines — the schema says which —
765
+ // so it travels with the block, which a row above it did not.
766
+ //
767
+ // Exactly the columns the block now has, and no attempt to blank the
768
+ // ones a shrink dropped: those cells went with the columns. Reaching
769
+ // past the block's width is not a harmless no-op either — a
770
+ // `blockInput` outside the block fails the whole transaction, which is
771
+ // how the old absolute-coordinate version hid the mistake.
772
+ return opts.fieldNames.map((name, i) => ({
773
+ type: 'blockInput',
774
+ value: {
775
+ sheetIdx: opts.sheetIdx,
776
+ blockId: opts.blockId,
777
+ row: PIVOT_HEADER_ROW,
778
+ col: i,
779
+ input: name,
780
+ },
781
+ }));
782
+ }
783
+ /**
784
+ * The number format each of a pivot's columns should carry, in the order
785
+ * the columns are declared (`[key, ...value columns]`).
786
+ *
787
+ * Every column of a pivot aggregates ONE source column, so it is formatted
788
+ * like that column. Which source column differs per kind:
789
+ *
790
+ * - the key column holds the row dimension's own values;
791
+ * - a derived column is `func(measure)` from the recipe;
792
+ * - a declared column (a row total, a second measure) names its own, and
793
+ * falls back to the recipe's for whatever it leaves out.
794
+ *
795
+ * A COUNT column is deliberately plain — see {@link aggregateKeepsFormat}.
796
+ *
797
+ * Returned as `''` rather than `undefined` for "no format", because the
798
+ * payload is also how a format is CLEARED, and a refresh re-states every
799
+ * column: `refreshPivot` reassigns render ids by position, so a column
800
+ * that appears shifts the ids after it, and only restating all of them
801
+ * keeps each format on the column it belongs to.
802
+ */
803
+ pivotColumnFormats(opts) {
804
+ const { fieldNames, numFmts } = opts;
805
+ const fmt = (field, func) => (aggregateKeepsFormat(func) ? numFmts[field] : undefined) ?? '';
806
+ return fieldNames.map((name, i) => {
807
+ // Field 0 is the key column: the row dimension's values, named
808
+ // after the dimension itself.
809
+ if (i === 0)
810
+ return numFmts[name] ?? '';
811
+ const d = opts.declared?.(name);
812
+ if (d)
813
+ return fmt(d.measure ?? opts.measure, d.func ?? opts.func);
814
+ return fmt(opts.measure, opts.func);
815
+ });
816
+ }
817
+ /**
818
+ * Create a pivot of `source`: a cross-tab whose rows are the distinct
819
+ * values of `rowDim`, whose columns are the distinct values of `colDim`,
820
+ * and whose cells are `func` over `measure`.
821
+ *
822
+ * **One transaction**, so it is one undo — which is why it asks the engine
823
+ * for the shape BEFORE creating the block (`pivotPlanFor`). Creating first
824
+ * and reshaping after would leave an empty declared pivot as an
825
+ * intermediate state and take two undos to remove.
826
+ *
827
+ * No formula is sent. The engine generates every cell from the recipe plus
828
+ * the cell's own row key and field name, which is what makes renaming a
829
+ * source field rebuild the pivot instead of breaking it.
830
+ *
831
+ * Returns the shape it created, for the caller to report.
832
+ */
833
+ async createPivot(opts) {
834
+ const { source, blockId, refName, rowDim, colDim, measure, func } = opts;
835
+ const spec = plainSpec({
836
+ rowDim,
837
+ colDim,
838
+ measure,
839
+ func,
840
+ order: opts.order ?? 'ascending',
841
+ // Always sent, even empty: the plan and the cells must agree
842
+ // about what is counted, and an omitted list on one side only
843
+ // would be exactly that disagreement.
844
+ orderValues: opts.orderValues ?? [],
845
+ filters: opts.filters ?? [],
846
+ });
847
+ const plan = await this.client.pivotPlanFor({
848
+ sheetIdx: source.sheetIdx,
849
+ sourceBlock: source.blockId,
850
+ spec,
851
+ });
852
+ if (isErrorMessage(plan)) {
853
+ throw new Error(plan.msg);
854
+ }
855
+ if (plan.keys.length === 0) {
856
+ throw new Error(`Nothing to pivot: no record of "${source.refName}" has a value ` +
857
+ `for "${rowDim}", so the pivot would have no rows.`);
858
+ }
859
+ const extra = opts.extraColumns ?? [];
860
+ const valueFields = [
861
+ ...(colDim ? plan.fields : [opts.valueColumn ?? measure]),
862
+ ...extra.map((c) => c.name),
863
+ ];
864
+ // The block starts directly below the source and OWNS its header
865
+ // line, so it is one row taller than it has groups.
866
+ const row = source.rowStart + source.rowCnt;
867
+ // The key column is named after the row dimension: it holds that
868
+ // dimension's values, and the name is what a reader sees.
869
+ const fieldNames = [rowDim, ...valueFields];
870
+ const rowCnt = plan.keys.length + 1;
871
+ await this.apply([
872
+ // Room first, so the pivot does not land on whatever sits
873
+ // below the table.
874
+ {
875
+ type: 'insertRows',
876
+ value: {
877
+ sheetIdx: source.sheetIdx,
878
+ start: row,
879
+ count: rowCnt,
880
+ },
881
+ },
882
+ {
883
+ type: 'createBlock',
884
+ value: {
885
+ sheetIdx: source.sheetIdx,
886
+ id: blockId,
887
+ masterRow: row,
888
+ masterCol: source.colStart,
889
+ rowCnt,
890
+ colCnt: fieldNames.length,
891
+ analyzes: source.blockId,
892
+ // Declared as it is created, so it is never briefly a
893
+ // stray table a reader would take for records.
894
+ pivot: spec,
895
+ description: `Pivot of "${source.refName}": rows = ${rowDim}` +
896
+ (colDim ? `, columns = ${colDim}` : '') +
897
+ `, ${func} of ${measure}.`,
898
+ },
899
+ },
900
+ // The header names, and the keys, BOTH before the bind.
901
+ // `#KEY` is captured when the bind materializes each row, so a
902
+ // key written afterwards leaves that row filtering on "" — a
903
+ // whole grid of zeros, no error.
904
+ ...this.pivotHeaderRow({
905
+ sheetIdx: source.sheetIdx,
906
+ blockId,
907
+ fieldNames,
908
+ }),
909
+ ...plan.keys.map((key, i) => ({
910
+ type: 'blockInput',
911
+ value: {
912
+ sheetIdx: source.sheetIdx,
913
+ blockId,
914
+ row: PIVOT_FIRST_RECORD + i,
915
+ col: 0,
916
+ input: key,
917
+ },
918
+ })),
919
+ {
920
+ type: 'bindFormSchema',
921
+ value: {
922
+ refName,
923
+ sheetIdx: source.sheetIdx,
924
+ blockId,
925
+ fieldFrom: 0,
926
+ keyIdx: 0,
927
+ row: true,
928
+ // The first line holds the field names, not a record.
929
+ headerIdx: PIVOT_HEADER_ROW,
930
+ // Field names ARE the column dimension's values.
931
+ // Nothing per-field is declared; the engine derives
932
+ // every cell from the recipe.
933
+ fields: fieldNames.map((name, i) => {
934
+ const declared = extra.find((c) => c.name === name);
935
+ return {
936
+ name,
937
+ renderId: `${refName}__p${i}`,
938
+ // Only a DECLARED column carries these; a
939
+ // derived one says nothing and the engine
940
+ // reads its name as the dimension value.
941
+ ...(declared
942
+ ? {
943
+ pivotColValue: declared.colValue ?? '*',
944
+ pivotMeasure: declared.measure,
945
+ pivotFunc: declared.func,
946
+ }
947
+ : {}),
948
+ };
949
+ }),
950
+ },
951
+ },
952
+ // After the bind, which is what declares the render ids these
953
+ // attach to. A pivot of a currency column reads as currency.
954
+ ...this.pivotColumnFormats({
955
+ fieldNames,
956
+ measure,
957
+ func,
958
+ numFmts: source.numFmts ?? {},
959
+ declared: (name) => extra.find((c) => c.name === name),
960
+ }).map((numFmt, i) => ({
961
+ type: 'upsertFieldRenderInfo',
962
+ value: {
963
+ renderId: `${refName}__p${i}`,
964
+ diyRender: false,
965
+ styleUpdate: { setNumFmt: numFmt },
966
+ },
967
+ })),
968
+ ], true);
969
+ return {
970
+ keys: [...plan.keys],
971
+ fields: valueFields,
972
+ unassignedRecords: plan.unassignedRecords,
973
+ };
974
+ }
975
+ /**
976
+ * Bring a pivot's SHAPE back in line with its source: add rows for groups
977
+ * that appeared, drop rows for groups that are gone, same for columns.
978
+ *
979
+ * Its numbers were never stale — they are live formulas. Only the set of
980
+ * rows and columns needs this, because no formula can add a row.
981
+ *
982
+ * One transaction, and the payload order is not negotiable
983
+ * (`design/block-pivot.md` §6): grow, write the keys, bind, shrink. Keys
984
+ * before the bind because `#KEY` is captured at materialization; grow
985
+ * before the bind because a field cannot bind to a column that does not
986
+ * exist; shrink after, so nothing is left bound to a vanishing column.
987
+ *
988
+ * Returns what changed, or `null` when the pivot was already current — so
989
+ * a caller can say "nothing to do" instead of reporting an empty refresh.
990
+ */
991
+ async refreshPivot(opts) {
992
+ // No sheet coordinates: the keys and the header are written
993
+ // block-relative now, so a caller cannot get them wrong. It used to
994
+ // take `rowStart`, and a stale one wrote the keys outside the block —
995
+ // silently, because a key nobody reads just leaves the row filtering
996
+ // on the value it already had.
997
+ const { sheetIdx, blockId, refName, keyField } = opts;
998
+ const plan = await this.client.pivotPlan({ sheetIdx, blockId });
999
+ if (isErrorMessage(plan)) {
1000
+ throw new Error(plan.msg);
1001
+ }
1002
+ if (!plan.isStale)
1003
+ return null;
1004
+ // A grouped pivot plans no columns, so it keeps the ones it has.
1005
+ const fields = plan.fields.length > 0 ? [...plan.fields] : [...plan.currentFields];
1006
+ const fieldNames = [keyField, ...fields];
1007
+ // The block owns its header line, so it is one row taller than it has
1008
+ // groups.
1009
+ const newRowCnt = plan.keys.length + 1;
1010
+ const newColCnt = fieldNames.length;
1011
+ const payloads = [];
1012
+ // ONE resize, to the final size, BEFORE the bind — in both directions.
1013
+ //
1014
+ // Growing first is obvious: a field cannot bind to a column that does
1015
+ // not exist. Shrinking first is the part that cost something to learn:
1016
+ // a `ResizeBlock` sent AFTER a bind leaves the generated formulas
1017
+ // uncalculated, so a refresh that dropped a group left every surviving
1018
+ // row BLANK — the keys were right and the numbers were gone. It is the
1019
+ // same hazard as the no-op resize in
1020
+ // `refreshing_a_pivot_with_a_trailing_no_op_resize_loses_the_new_row`,
1021
+ // and the rule that covers both is: never resize a block after binding
1022
+ // it. Nothing is orphaned, because the bind that follows states the
1023
+ // surviving fields and only those.
1024
+ if (newRowCnt !== plan.currentKeys.length + 1 ||
1025
+ newColCnt !== plan.currentFields.length + 1) {
1026
+ payloads.push({
1027
+ type: 'resizeBlock',
1028
+ value: { sheetIdx, id: blockId, newRowCnt, newColCnt },
1029
+ });
1030
+ }
1031
+ payloads.push(
1032
+ // The labels, in full: a refresh can add or drop columns, and a
1033
+ // label left over from the old shape names a column that is now
1034
+ // something else.
1035
+ ...this.pivotHeaderRow({
1036
+ sheetIdx,
1037
+ blockId,
1038
+ fieldNames,
1039
+ }), ...plan.keys.map((key, i) => ({
1040
+ type: 'blockInput',
1041
+ value: {
1042
+ sheetIdx,
1043
+ blockId,
1044
+ row: PIVOT_FIRST_RECORD + i,
1045
+ col: 0,
1046
+ input: key,
1047
+ },
1048
+ })), {
1049
+ type: 'bindFormSchema',
1050
+ value: {
1051
+ refName,
1052
+ sheetIdx,
1053
+ blockId,
1054
+ fieldFrom: 0,
1055
+ keyIdx: 0,
1056
+ row: true,
1057
+ // Restated: the bind states the whole interpretation, so
1058
+ // omitting this would turn the header line into a record.
1059
+ headerIdx: PIVOT_HEADER_ROW,
1060
+ fields: fieldNames.map((name, i) => {
1061
+ const was = opts.currentFields?.find((f) => f.field === name);
1062
+ return {
1063
+ name,
1064
+ renderId: `${refName}__p${i}`,
1065
+ // Restated verbatim. A declared column is not
1066
+ // derived from the data, so a refresh has no
1067
+ // business reinterpreting it.
1068
+ ...(was?.pivotColValue !== undefined
1069
+ ? {
1070
+ pivotColValue: was.pivotColValue,
1071
+ pivotMeasure: was.pivotMeasure,
1072
+ pivotFunc: was.pivotFunc,
1073
+ }
1074
+ : {}),
1075
+ };
1076
+ }),
1077
+ },
1078
+ });
1079
+ // EVERY column is restated: render ids are assigned by position,
1080
+ // so a column that appears shifts the ids after it and each format
1081
+ // would otherwise stay behind on the wrong column.
1082
+ if (opts.formats) {
1083
+ payloads.push(...this.pivotColumnFormats({
1084
+ fieldNames,
1085
+ measure: opts.formats.measure,
1086
+ func: opts.formats.func,
1087
+ numFmts: opts.formats.numFmts,
1088
+ declared: (name) => {
1089
+ const was = opts.currentFields?.find((f) => f.field === name);
1090
+ return was?.pivotColValue !== undefined
1091
+ ? {
1092
+ measure: was.pivotMeasure,
1093
+ func: was.pivotFunc,
1094
+ }
1095
+ : undefined;
1096
+ },
1097
+ }).map((numFmt, i) => ({
1098
+ type: 'upsertFieldRenderInfo',
1099
+ value: {
1100
+ renderId: `${refName}__p${i}`,
1101
+ diyRender: false,
1102
+ styleUpdate: { setNumFmt: numFmt },
1103
+ },
1104
+ })));
1105
+ }
1106
+ await this.apply(payloads, true);
1107
+ return {
1108
+ addedKeys: [...plan.missingKeys],
1109
+ removedKeys: [...plan.extraKeys],
1110
+ addedFields: [...plan.missingFields],
1111
+ removedFields: [...plan.extraFields],
1112
+ unassignedRecords: plan.unassignedRecords,
1113
+ };
1114
+ }
1115
+ /**
1116
+ * Change a pivot's RECIPE — what it groups by, what it measures, how, in
1117
+ * what order, over which records — and reshape it to match, in one
1118
+ * transaction.
1119
+ *
1120
+ * A recipe is not editable in place by hand: a pivot's numbers are
1121
+ * generated from it, its column names ARE the column dimension's values,
1122
+ * and its key column holds the row dimension's. Changing any of those by
1123
+ * typing produces a table that says one thing and computes another —
1124
+ * editing a row label does not re-aim the row, it relabels a group.
1125
+ *
1126
+ * Deliberately does NOT ask for the current plan. The commonest reason to
1127
+ * edit is that the recipe has stopped resolving (a source field renamed
1128
+ * out from under it, say), and planning the OLD recipe would fail exactly
1129
+ * then. The caller states the block's present size instead, which it can
1130
+ * always see.
1131
+ *
1132
+ * Payload order, for the same reasons as §6 with one addition: the RECIPE
1133
+ * goes before the bind, because the bind is what regenerates every cell
1134
+ * from it — set it after and the block re-materializes from the old one.
1135
+ *
1136
+ * grow (if growing) → recipe → keys → bind → shrink (only if shrinking)
1137
+ *
1138
+ * Returns the shape it produced.
1139
+ */
1140
+ async editPivot(opts) {
1141
+ const { sheetIdx, blockId, refName, source, rowDim, colDim, measure, func, } = opts;
1142
+ // Rebuilt by value: an edit's recipe usually comes from the block
1143
+ // itself, and passing that back through the worker unchanged fails to
1144
+ // clone.
1145
+ const spec = plainSpec({
1146
+ rowDim,
1147
+ colDim,
1148
+ measure,
1149
+ func,
1150
+ order: opts.order ?? 'ascending',
1151
+ orderValues: opts.orderValues ?? [],
1152
+ filters: opts.filters ?? [],
1153
+ });
1154
+ const plan = await this.client.pivotPlanFor({
1155
+ sheetIdx,
1156
+ sourceBlock: source.blockId,
1157
+ spec,
1158
+ });
1159
+ if (isErrorMessage(plan)) {
1160
+ throw new Error(plan.msg);
1161
+ }
1162
+ if (plan.keys.length === 0) {
1163
+ throw new Error(`Nothing to pivot: no record of "${source.refName}" has a value ` +
1164
+ `for "${rowDim}", so the pivot would have no rows.`);
1165
+ }
1166
+ const extra = opts.extraColumns ?? [];
1167
+ const valueFields = [
1168
+ ...(colDim ? plan.fields : [opts.valueColumn ?? measure]),
1169
+ ...extra.map((c) => c.name),
1170
+ ];
1171
+ // The key column is named after the row dimension, which the edit may
1172
+ // have just changed.
1173
+ const fieldNames = [rowDim, ...valueFields];
1174
+ // One line taller than it has groups: the block owns its header.
1175
+ const newRowCnt = plan.keys.length + 1;
1176
+ const newColCnt = fieldNames.length;
1177
+ const payloads = [];
1178
+ // ONE resize, to the final size, BEFORE the bind — in both directions.
1179
+ //
1180
+ // A refresh has to grow before and shrink after, because it keeps the
1181
+ // old rows' keys and only adds to them. An edit rewrites every key, so
1182
+ // it can take the block to its final size first and then bind onto it
1183
+ // — which is the safer order: a `ResizeBlock` sent AFTER a bind leaves
1184
+ // the generated formulas uncalculated, showing the value each cell had
1185
+ // before the edit under its new formula. Resizing first also means the
1186
+ // rows a shrink drops are gone before the new keys are written, so a
1187
+ // key that already exists further down is not briefly a duplicate.
1188
+ if (newRowCnt !== opts.currentRowCnt ||
1189
+ newColCnt !== opts.currentColCnt) {
1190
+ payloads.push({
1191
+ type: 'resizeBlock',
1192
+ value: { sheetIdx, id: blockId, newRowCnt, newColCnt },
1193
+ });
1194
+ }
1195
+ payloads.push(
1196
+ // The labels, in full — an edit can change the whole column set.
1197
+ ...this.pivotHeaderRow({
1198
+ sheetIdx,
1199
+ blockId,
1200
+ fieldNames,
1201
+ }),
1202
+ // The new recipe, BEFORE the bind that regenerates from it.
1203
+ {
1204
+ type: 'setBlockAnalyzes',
1205
+ value: {
1206
+ sheetIdx,
1207
+ blockId,
1208
+ analyzes: source.blockId,
1209
+ pivot: spec,
1210
+ },
1211
+ },
1212
+ // Keys before the bind: `#KEY` is captured at materialization.
1213
+ ...plan.keys.map((key, i) => ({
1214
+ type: 'blockInput',
1215
+ value: {
1216
+ sheetIdx,
1217
+ blockId,
1218
+ row: PIVOT_FIRST_RECORD + i,
1219
+ col: 0,
1220
+ input: key,
1221
+ },
1222
+ })), {
1223
+ type: 'bindFormSchema',
1224
+ value: {
1225
+ refName,
1226
+ sheetIdx,
1227
+ blockId,
1228
+ fieldFrom: 0,
1229
+ keyIdx: 0,
1230
+ row: true,
1231
+ // Restated: the bind states the whole interpretation, so
1232
+ // omitting this would turn the header line into a record.
1233
+ headerIdx: PIVOT_HEADER_ROW,
1234
+ fields: fieldNames.map((name, i) => {
1235
+ const declared = extra.find((c) => c.name === name);
1236
+ return {
1237
+ name,
1238
+ renderId: `${refName}__p${i}`,
1239
+ ...(declared
1240
+ ? {
1241
+ pivotColValue: declared.colValue ?? '*',
1242
+ pivotMeasure: declared.measure,
1243
+ pivotFunc: declared.func,
1244
+ }
1245
+ : {}),
1246
+ };
1247
+ }),
1248
+ },
1249
+ });
1250
+ payloads.push(...this.pivotColumnFormats({
1251
+ fieldNames,
1252
+ measure,
1253
+ func,
1254
+ numFmts: source.numFmts ?? {},
1255
+ declared: (name) => extra.find((c) => c.name === name),
1256
+ }).map((numFmt, i) => ({
1257
+ type: 'upsertFieldRenderInfo',
1258
+ value: {
1259
+ renderId: `${refName}__p${i}`,
1260
+ diyRender: false,
1261
+ styleUpdate: { setNumFmt: numFmt },
1262
+ },
1263
+ })));
1264
+ await this.apply(payloads, true);
1265
+ return {
1266
+ keys: [...plan.keys],
1267
+ fields: valueFields,
1268
+ unassignedRecords: plan.unassignedRecords,
1269
+ };
1270
+ }
392
1271
  // ---- generic / temp-branch -----------------------------------------
393
1272
  /**
394
1273
  * Apply a caller-built payload list as one transaction (at the host's
@@ -521,28 +1400,4 @@ export class WorkbookOps {
521
1400
  }
522
1401
  return checkValidationsPure(rules, (_sheetIdx, formula) => values.get(formula));
523
1402
  }
524
- /** Check required / unique / membership field constraints. */
525
- async checkFieldConstraints(columns) {
526
- const values = new Map();
527
- for (const { cells } of columns) {
528
- for (const c of cells) {
529
- const key = `${c.sheetIdx}:${c.row}:${c.col}`;
530
- if (values.has(key))
531
- continue;
532
- const v = await this.client.getValue({
533
- sheetIdx: c.sheetIdx,
534
- row: c.row,
535
- col: c.col,
536
- });
537
- if (isErrorMessage(v)) {
538
- throw new Error('Failed to read cell value: ' + v.msg);
539
- }
540
- values.set(key, v);
541
- }
542
- }
543
- return checkFieldConstraintsPure(columns, (sheetIdx, row, col) => {
544
- const v = values.get(`${sheetIdx}:${row}:${col}`);
545
- return (v ?? 'empty');
546
- });
547
- }
548
1403
  }