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.
@@ -1,5 +1,3 @@
1
- import type { Value } from 'logisheets-web';
2
- import type { Violation } from '../validation/index.js';
3
1
  export type FieldTypeEnum = 'unspecified' | 'enum' | 'multiSelect' | 'datetime' | 'boolean' | 'string' | 'number' | 'image' | 'fieldRef' | 'multiSelectRef';
4
2
  export interface EnumValue {
5
3
  id: string;
@@ -25,27 +23,3 @@ export interface FieldSetting {
25
23
  refBlockId?: number;
26
24
  refFieldName?: string;
27
25
  }
28
- export interface CellRef {
29
- sheetIdx: number;
30
- row: number;
31
- col: number;
32
- }
33
- /** A field column: its constraints plus the data cells that belong to it. */
34
- export interface FieldColumn {
35
- field: Pick<FieldSetting, 'name' | 'required' | 'unique'>;
36
- cells: readonly CellRef[];
37
- /**
38
- * Allowed values for membership-constrained fields (enum / multiSelect /
39
- * fieldRef / multiSelectRef). When provided, every non-empty cell value
40
- * must appear here. The caller resolves the candidate set — for enums it
41
- * is the enum labels; for fieldRef it is the referenced field's values.
42
- * Leave undefined for fields with no membership constraint.
43
- */
44
- allowed?: readonly string[];
45
- }
46
- /**
47
- * Check `required` and `unique` across every field column and return all
48
- * violating cells. Pure: the caller supplies `getValue` (WorkbookOps wraps the
49
- * engine), so this runs identically in the browser and on Node.
50
- */
51
- export declare function checkFieldConstraints(columns: readonly FieldColumn[], getValue: (sheetIdx: number, row: number, col: number) => Value): Violation[];
@@ -1,70 +1,25 @@
1
- // Field model + non-formula field constraints (required / unique).
1
+ // The field AUTHORING model what a composer UI edits before it becomes a
2
+ // schema field.
2
3
  //
3
- // The field type model is lifted verbatim from the browser's block-composer so
4
- // the App, the data-gateway craft, and a Node runtime all share one source of
5
- // truth for what a field is. The constraint *checks* here are pure logic over
6
- // values read through an injected port — the engine just supplies the values.
4
+ // This is the shape the browser's block-composer works in, shared so the App,
5
+ // the data-gateway craft and a Node runtime agree on what a field is while it
6
+ // is being authored. It is NOT the source of truth for what a field IS: that is
7
+ // the engine schema, which carries the declaration and persists it (see
8
+ // design/block-field-semantics.md). This model is what you fill in to produce
9
+ // one.
7
10
  //
8
- // Formula-based validation lives in ../validation; this module covers the two
9
- // constraints that aren't formulas: `required` (no empty cells) and `unique`
10
- // (no duplicate values within a field column).
11
- import { isValueEmpty } from '../value/index.js';
12
- const isEmpty = isValueEmpty;
13
- /** Stable string key for duplicate detection. */
14
- function valueKey(v) {
15
- const x = v;
16
- return `${x.type}:${String(x.value)}`;
17
- }
18
- /** The cell's value as the plain text used for membership comparison. */
19
- function valueText(v) {
20
- const x = v;
21
- return String(x.value);
22
- }
23
- /**
24
- * Check `required` and `unique` across every field column and return all
25
- * violating cells. Pure: the caller supplies `getValue` (WorkbookOps wraps the
26
- * engine), so this runs identically in the browser and on Node.
27
- */
28
- export function checkFieldConstraints(columns, getValue) {
29
- const out = [];
30
- for (const { field, cells, allowed } of columns) {
31
- const seen = new Map();
32
- const allowedSet = allowed ? new Set(allowed) : undefined;
33
- for (const cell of cells) {
34
- const v = getValue(cell.sheetIdx, cell.row, cell.col);
35
- const empty = isEmpty(v);
36
- if (field.required && empty) {
37
- out.push({
38
- ...cell,
39
- kind: 'required',
40
- message: `Field "${field.name}" is required`,
41
- });
42
- continue;
43
- }
44
- if (empty)
45
- continue;
46
- if (allowedSet && !allowedSet.has(valueText(v))) {
47
- out.push({
48
- ...cell,
49
- kind: 'membership',
50
- message: `Value "${valueText(v)}" is not an allowed option for field "${field.name}"`,
51
- });
52
- continue;
53
- }
54
- if (field.unique) {
55
- const key = valueKey(v);
56
- if (seen.has(key)) {
57
- out.push({
58
- ...cell,
59
- kind: 'duplicate',
60
- message: `Duplicate value in unique field "${field.name}"`,
61
- });
62
- }
63
- else {
64
- seen.set(key, cell);
65
- }
66
- }
67
- }
68
- }
69
- return out;
70
- }
11
+ // Formula-based validation lives in ../validation.
12
+ export {};
13
+ // The `required` / `unique` / membership CHECKS used to live here as a second,
14
+ // independent implementation over an injected value port. They are gone: a
15
+ // field that declares `required`, `unique`, `enum`/`multiSelect` or `fieldRef`
16
+ // now has the matching rule generated by the engine from the declaration
17
+ // (crates/controller/src/block_manager/derived_rules.rs), installed as a
18
+ // per-record validation shadow, and reported through the same channel as every
19
+ // other rule — the warning marker, `list_violations`, and the
20
+ // `overrideValidation` write gate.
21
+ //
22
+ // Two implementations of one constraint is how this area went wrong: they did
23
+ // not even agree on what "the same value" meant (`type:value` here,
24
+ // COUNTIF's stringified value in the engine), so the number 1 and the text "1"
25
+ // collided in one and not the other. See design/block-field-semantics.md §2.3.
@@ -6,7 +6,7 @@ export interface FontStyle {
6
6
  color?: string;
7
7
  size?: number;
8
8
  strike?: boolean;
9
- /** Font family name, e.g. "Arial", "Times New Roman", "微软雅黑". */
9
+ /** Font family name, e.g. "Arial", "Times New Roman", "Microsoft YaHei". */
10
10
  name?: string;
11
11
  }
12
12
  export declare function generateFontPayload(sheetIdx: number, data: SelectedData, update: FontStyle): readonly Payload[];
@@ -1,7 +1,7 @@
1
1
  import type { Payload, ActionEffect, SelectedData, Alignment, StPatternType, Value, SheetCellId } from 'logisheets-web';
2
2
  import type { Client } from '../port.js';
3
3
  import { type ValidationRule, type Violation } from '../validation/index.js';
4
- import { type FieldColumn } from '../field/index.js';
4
+ import type { FieldTypeEnum } from '../field/index.js';
5
5
  import { type FontStyle, type BorderBatchUpdate } from '../format/index.js';
6
6
  /**
7
7
  * Tells WorkbookOps whether to mark transactions temp (speculative). The
@@ -17,10 +17,203 @@ export interface FormBlockField {
17
17
  renderId: string;
18
18
  /** Per-field value-formula template (#FIELD("X") / #KEY); '' if free-form. */
19
19
  valueFormula?: string;
20
+ /**
21
+ * Per-field validation template (#PLACEHOLDER for the value under test,
22
+ * #FIELD("X") for a same-row sibling); '' when the field has no rule.
23
+ *
24
+ * This goes into the schema rather than staying host-side so the engine
25
+ * installs the per-record shadow itself — on bind AND on every row added
26
+ * later — and so one answer serves every reader: the warning marker, the
27
+ * `overrideValidation` write gate, and any other host.
28
+ */
29
+ validationFormula?: string;
30
+ /**
31
+ * Per-field editability template — FALSE installs a `UserEditable` lock the
32
+ * host permission layer reads.
33
+ *
34
+ * Carried here because a schema re-bind replaces the field wholesale: the
35
+ * three bind sites below used to send `editabilityFormulas: []` ("all
36
+ * None"), so editing a block through this layer silently dropped any
37
+ * editability template it had.
38
+ */
39
+ editabilityFormula?: string;
20
40
  /** Whether the field renders via a host-drawn (DIY) overlay. */
21
41
  diyRender: boolean;
22
42
  /** Number format applied to the field's render info. */
23
43
  numFmt?: string;
44
+ /** Declared type. Omitted reads as unspecified (free-form). */
45
+ fieldType?: FieldTypeEnum;
46
+ /** Enum set backing a `fieldType` of 'enum' / 'multiSelect'. */
47
+ enumSetId?: string;
48
+ /** Target of a `fieldType` of 'fieldRef' / 'multiSelectRef'. */
49
+ refTarget?: {
50
+ sheetId: number;
51
+ blockId: number;
52
+ fieldName: string;
53
+ };
54
+ /** What the field means, in prose, for whoever reads the block next. */
55
+ description?: string;
56
+ /** Every record must carry a value here. */
57
+ required?: boolean;
58
+ /** No two records may carry the same value here. */
59
+ unique?: boolean;
60
+ /** What a newly-added record starts with. */
61
+ defaultValue?: string;
62
+ /**
63
+ * Who may write to this field's cells: `'inherit'` (the block's own owner
64
+ * rules) | `'ownerOnly'` | `'anyone'`.
65
+ *
66
+ * Declared on the schema so every host reads the same answer — it was the
67
+ * last field-level rule that lived only in the browser's own field store,
68
+ * as a tri-state `userEditable` boolean. The engine persists and answers;
69
+ * it does not enforce, because it does not know who is writing.
70
+ */
71
+ writePolicy?: 'inherit' | 'ownerOnly' | 'anyone';
72
+ }
73
+ /**
74
+ * An option list a field can draw from, as the engine stores it.
75
+ *
76
+ * Ids and labels only. A variant's COLOUR is presentation and stays in the
77
+ * host, keyed by variant id — the engine needs the options in order to judge a
78
+ * value and needs nothing else.
79
+ */
80
+ export interface EnumSetDecl {
81
+ id: string;
82
+ name?: string;
83
+ variants: ReadonlyArray<{
84
+ id: string;
85
+ label?: string;
86
+ }>;
87
+ }
88
+ /**
89
+ * How an analysis field aggregates the field it reads. Every one lowers to
90
+ * `FUNC(BLOCKREFSB(...))` in the engine, which is why adding one is a one-line
91
+ * change there and none here.
92
+ *
93
+ * `COUNT` counts numbers, `COUNTA` counts values that are simply THERE — over
94
+ * `10, 20, "n/a", <blank>` they say 2 and 3. In a pivot, COUNT counts matching
95
+ * records and COUNTA counts the ones whose measure is filled in.
96
+ */
97
+ export type AggFunc = 'SUM' | 'COUNT' | 'COUNTA' | 'AVERAGE' | 'MIN' | 'MAX';
98
+ /** One field of an analysis block: what it aggregates, and how. */
99
+ export interface AnalysisAggregate {
100
+ /** Field name of the SOURCE block. */
101
+ field: string;
102
+ func: AggFunc;
103
+ }
104
+ /** One column of the block being analysed, as the analysis needs to see it. */
105
+ export interface AnalysisSourceField {
106
+ name: string;
107
+ /**
108
+ * Whether the source DECLARES this field a number. Not whether its cells
109
+ * currently hold numbers: guessing from the data totals an id column, and
110
+ * leaves a still-empty column out of a total it belongs in.
111
+ */
112
+ isNumber: boolean;
113
+ /**
114
+ * The source column's number format, carried onto the total so a sum of
115
+ * currency reads as currency.
116
+ */
117
+ numFmt?: string;
118
+ }
119
+ /**
120
+ * Whether the result of `func` over a column is still measured in that
121
+ * column's units, and so should carry its number format.
122
+ *
123
+ * A sum, an average, a minimum or a maximum of a currency column is currency.
124
+ * The counts are not: they answer "how many", so formatting one like the
125
+ * column it counts would print "$3" for three orders. These are the aggregates
126
+ * whose result changes what is being measured.
127
+ */
128
+ export declare function aggregateKeepsFormat(func: AggFunc): boolean;
129
+ /** The block being analysed. */
130
+ export interface AnalysisSource {
131
+ sheetIdx: number;
132
+ blockId: number;
133
+ /** Its ref name — used in the new block's description, not in a formula. */
134
+ refName: string;
135
+ rowStart: number;
136
+ rowCnt: number;
137
+ colStart: number;
138
+ /** Fields in COLUMN order, so the analysis's columns line up with them. */
139
+ fields: readonly AnalysisSourceField[];
140
+ /** Which column is the key — where the label goes. */
141
+ keyIdx: number;
142
+ }
143
+ /**
144
+ * SUM over every field the source declares a number.
145
+ *
146
+ * A default is only possible because the declaration exists: this is what the
147
+ * field-type work in `design/block-field-semantics.md` bought — before it, a
148
+ * caller could only guess from the data.
149
+ */
150
+ export declare function defaultAnalysisAggregates(fields: readonly AnalysisSourceField[]): AnalysisAggregate[];
151
+ /**
152
+ * How a pivot orders the distinct values it turns into rows and columns.
153
+ *
154
+ * `custom` takes the sequence from `orderValues`; values it does not mention
155
+ * follow in ascending order rather than disappearing, because a hidden group
156
+ * is the failure a pivot must never commit silently.
157
+ */
158
+ export type DimOrder = 'ascending' | 'firstSeen' | 'custom';
159
+ /** One condition a source record must meet to be counted by a pivot. */
160
+ export interface PivotFilter {
161
+ field: string;
162
+ /** Spreadsheet condition syntax: `">100"`, `"East"`, `"<>closed"`. */
163
+ criteria: string;
164
+ }
165
+ /**
166
+ * A pivot column that is declared rather than derived.
167
+ *
168
+ * A plain cross-tab needs none of these: each column's name IS the dimension
169
+ * value and the block's recipe supplies the rest. These exist for the two
170
+ * things that cannot express — a ROW TOTAL (`colValue: null`, spanning every
171
+ * value) and a SECOND MEASURE (`measure` / `func` of its own).
172
+ *
173
+ * A refresh leaves declared columns alone: they were never derived from the
174
+ * data, so the data cannot justify removing them.
175
+ */
176
+ export interface PivotColumnSpec {
177
+ /** Column name in the pivot. */
178
+ name: string;
179
+ /** Which column-dimension value it filters on; `null` spans every one. */
180
+ colValue: string | null;
181
+ measure?: string;
182
+ func?: AggFunc;
183
+ }
184
+ /** The block a pivot analyses, as the create needs to see it. */
185
+ export interface PivotSource {
186
+ sheetIdx: number;
187
+ blockId: number;
188
+ /** Its ref name — used in the new block's description, not in a formula. */
189
+ refName: string;
190
+ rowStart: number;
191
+ rowCnt: number;
192
+ colStart: number;
193
+ /**
194
+ * Number format per SOURCE field name, for the pivot to inherit: a cell of
195
+ * a pivot is an aggregate of one source column, so a SUM of a currency
196
+ * column should read as currency rather than as a bare number.
197
+ *
198
+ * Optional — omit it and the pivot is left unformatted, which is what
199
+ * every caller got before this existed.
200
+ */
201
+ numFmts?: Readonly<Record<string, string | undefined>>;
202
+ }
203
+ /** What a refresh actually changed. */
204
+ export interface PivotRefresh {
205
+ /** Groups that appeared in the source and now have a row. */
206
+ addedKeys: string[];
207
+ /** Rows the source no longer justifies. */
208
+ removedKeys: string[];
209
+ addedFields: string[];
210
+ removedFields: string[];
211
+ /**
212
+ * Source records with a blank dimension. They are in NO cell of the pivot,
213
+ * so its grand total is short by their measure — a refresh cannot fix
214
+ * that, only report it.
215
+ */
216
+ unassignedRecords: number;
24
217
  }
25
218
  /**
26
219
  * High-level workbook operations bound to one engine {@link Client}.
@@ -141,6 +334,8 @@ export declare class WorkbookOps {
141
334
  refName: string;
142
335
  keyIdx: number;
143
336
  fields: readonly FormBlockField[];
337
+ /** Option lists the fields reference; written in the same transaction. */
338
+ enumSets?: readonly EnumSetDecl[];
144
339
  }): Promise<void>;
145
340
  /**
146
341
  * Turn an EXISTING cell region into a form-backed block in place: like
@@ -158,6 +353,8 @@ export declare class WorkbookOps {
158
353
  refName: string;
159
354
  keyIdx: number;
160
355
  fields: readonly FormBlockField[];
356
+ /** Option lists the fields reference; written in the same transaction. */
357
+ enumSets?: readonly EnumSetDecl[];
161
358
  }): Promise<void>;
162
359
  /**
163
360
  * Edit an EXISTING form-backed block: rename it, re-type / re-formula its
@@ -187,7 +384,284 @@ export declare class WorkbookOps {
187
384
  refName: string;
188
385
  keyIdx: number;
189
386
  fields: readonly FormBlockField[];
387
+ /** Option lists the fields reference; written in the same transaction. */
388
+ enumSets?: readonly EnumSetDecl[];
389
+ }): Promise<void>;
390
+ /**
391
+ * Rewrite ONE kind of per-field rule on a block, leaving the others alone.
392
+ *
393
+ * `formulas` is one entry per field, in the schema's field order — a rule
394
+ * or `''` for none. The other two rule kinds are sent empty, which the
395
+ * engine reads as "don't touch these", so editing a validation rule cannot
396
+ * clear the value formulas standing next to it.
397
+ *
398
+ * Every existing row is re-materialized from the new rule, so this is also
399
+ * how a rule is removed: pass `''` for that field.
400
+ */
401
+ setFieldRules(opts: {
402
+ sheetIdx: number;
403
+ blockId: number;
404
+ kind: 'value' | 'validation' | 'editability';
405
+ formulas: readonly string[];
190
406
  }): Promise<void>;
407
+ /**
408
+ * Create the block that analyses `source`: a totals row placed directly
409
+ * below it, declaring what it analyses and how each of its fields
410
+ * aggregates. See `design/block-analysis.md`.
411
+ *
412
+ * **No formula is sent.** The engine generates each aggregate field's
413
+ * formula from the declaration, which is what makes renaming a source
414
+ * field rebuild the total instead of silently zeroing it. A field with no
415
+ * aggregate stays an ordinary cell — that is the label column, and the
416
+ * label is also the key the result is addressed by
417
+ * (`BLOCKREF(refName, label, field)`).
418
+ *
419
+ * One transaction, so it is one undo, and so a reader between two payloads
420
+ * never sees a one-row table it would mistake for a record.
421
+ *
422
+ * Returns what it aggregated, in column order, for the caller to report.
423
+ */
424
+ createAnalysisBlock(opts: {
425
+ source: AnalysisSource;
426
+ blockId: number;
427
+ refName: string;
428
+ label: string;
429
+ /**
430
+ * Which source fields to aggregate and how. Omit for
431
+ * {@link defaultAnalysisAggregates} — SUM over every field the source
432
+ * DECLARES as a number.
433
+ */
434
+ aggregates?: readonly AnalysisAggregate[];
435
+ }): Promise<readonly AnalysisAggregate[]>;
436
+ /**
437
+ * Change WHAT an existing analysis block computes — which source fields it
438
+ * aggregates, with which function, and the label its row is addressed by.
439
+ *
440
+ * The shape never changes: an analysis block is one row with one column
441
+ * per source field, so an edit is a re-bind and nothing else. That is the
442
+ * whole difference from {@link editPivot}, where changing the recipe
443
+ * changes how many rows and columns there are.
444
+ *
445
+ * Exists so an analysis can be arrived at in STEPS. The first guess (sum
446
+ * every number) is often nearly right and occasionally wrong in one
447
+ * column, and without this the only remedy was to delete the block and
448
+ * build it again — losing its ref name, and with it every formula pointing
449
+ * at it.
450
+ */
451
+ editAnalysisBlock(opts: {
452
+ sheetIdx: number;
453
+ /** The analysis block being edited. */
454
+ blockId: number;
455
+ /** Its ref name, which the re-bind has to restate. */
456
+ refName: string;
457
+ /** The block it analyses, for its field list and number formats. */
458
+ source: AnalysisSource;
459
+ aggregates: readonly AnalysisAggregate[];
460
+ /**
461
+ * A new row label, or omit to leave it alone. Rewriting it re-aims
462
+ * nothing — unlike a pivot key, an analysis block's label is just the
463
+ * name its single row is addressed by.
464
+ */
465
+ label?: string;
466
+ /** Where the block sits, needed only when `label` is given. */
467
+ rowStart?: number;
468
+ colStart?: number;
469
+ }): Promise<readonly AnalysisAggregate[]>;
470
+ /**
471
+ * The label row a pivot carries directly above itself, as `cellInput`
472
+ * payloads.
473
+ *
474
+ * A pivot's column names are DATA — they are the column dimension's own
475
+ * values — but they live on the schema, which means the raw sheet shows a
476
+ * grid of numbers with nothing to say what the columns are. Everything
477
+ * that is not our own UI sees it that way: another tool, a person reading
478
+ * the file, and Excel, whose pivot tables require the labels to be in
479
+ * cells. So the pivot writes them there.
480
+ *
481
+ * The row sits OUTSIDE the block, immediately above it. Inside would break
482
+ * what a block is: every row of a block is a record addressed by its key,
483
+ * so a header row would become a record keyed "region" — captured by
484
+ * `#KEY`, counted by the key-uniqueness guard, and aggregated by anything
485
+ * reading the block.
486
+ *
487
+ * Rewritten in full whenever the column set can have changed, because a
488
+ * label left behind from a previous shape is worse than no label: it names
489
+ * a column that is now something else.
490
+ */
491
+ private pivotHeaderRow;
492
+ /**
493
+ * The number format each of a pivot's columns should carry, in the order
494
+ * the columns are declared (`[key, ...value columns]`).
495
+ *
496
+ * Every column of a pivot aggregates ONE source column, so it is formatted
497
+ * like that column. Which source column differs per kind:
498
+ *
499
+ * - the key column holds the row dimension's own values;
500
+ * - a derived column is `func(measure)` from the recipe;
501
+ * - a declared column (a row total, a second measure) names its own, and
502
+ * falls back to the recipe's for whatever it leaves out.
503
+ *
504
+ * A COUNT column is deliberately plain — see {@link aggregateKeepsFormat}.
505
+ *
506
+ * Returned as `''` rather than `undefined` for "no format", because the
507
+ * payload is also how a format is CLEARED, and a refresh re-states every
508
+ * column: `refreshPivot` reassigns render ids by position, so a column
509
+ * that appears shifts the ids after it, and only restating all of them
510
+ * keeps each format on the column it belongs to.
511
+ */
512
+ private pivotColumnFormats;
513
+ /**
514
+ * Create a pivot of `source`: a cross-tab whose rows are the distinct
515
+ * values of `rowDim`, whose columns are the distinct values of `colDim`,
516
+ * and whose cells are `func` over `measure`.
517
+ *
518
+ * **One transaction**, so it is one undo — which is why it asks the engine
519
+ * for the shape BEFORE creating the block (`pivotPlanFor`). Creating first
520
+ * and reshaping after would leave an empty declared pivot as an
521
+ * intermediate state and take two undos to remove.
522
+ *
523
+ * No formula is sent. The engine generates every cell from the recipe plus
524
+ * the cell's own row key and field name, which is what makes renaming a
525
+ * source field rebuild the pivot instead of breaking it.
526
+ *
527
+ * Returns the shape it created, for the caller to report.
528
+ */
529
+ createPivot(opts: {
530
+ source: PivotSource;
531
+ blockId: number;
532
+ refName: string;
533
+ rowDim: string;
534
+ /** Omit for a grouped pivot: one value column, no cross-tab. */
535
+ colDim?: string;
536
+ measure: string;
537
+ func: AggFunc;
538
+ order?: DimOrder;
539
+ /** The sequence for `order: 'custom'`. */
540
+ orderValues?: readonly string[];
541
+ /** Which source records to count at all. Omit to count every one. */
542
+ filters?: readonly PivotFilter[];
543
+ /**
544
+ * Columns beyond the derived ones — a row total, a second measure.
545
+ * Appended after the cross-tab's own columns.
546
+ */
547
+ extraColumns?: readonly PivotColumnSpec[];
548
+ /**
549
+ * Name of the pivot's single value column when `colDim` is omitted.
550
+ * Ignored for a real cross-tab, whose column names ARE the dimension's
551
+ * values.
552
+ */
553
+ valueColumn?: string;
554
+ }): Promise<{
555
+ keys: string[];
556
+ fields: string[];
557
+ unassignedRecords: number;
558
+ }>;
559
+ /**
560
+ * Bring a pivot's SHAPE back in line with its source: add rows for groups
561
+ * that appeared, drop rows for groups that are gone, same for columns.
562
+ *
563
+ * Its numbers were never stale — they are live formulas. Only the set of
564
+ * rows and columns needs this, because no formula can add a row.
565
+ *
566
+ * One transaction, and the payload order is not negotiable
567
+ * (`design/block-pivot.md` §6): grow, write the keys, bind, shrink. Keys
568
+ * before the bind because `#KEY` is captured at materialization; grow
569
+ * before the bind because a field cannot bind to a column that does not
570
+ * exist; shrink after, so nothing is left bound to a vanishing column.
571
+ *
572
+ * Returns what changed, or `null` when the pivot was already current — so
573
+ * a caller can say "nothing to do" instead of reporting an empty refresh.
574
+ */
575
+ refreshPivot(opts: {
576
+ sheetIdx: number;
577
+ blockId: number;
578
+ /** The pivot's ref name, which the re-bind has to restate. */
579
+ refName: string;
580
+ /** Its key field's name — the column holding the row dimension. */
581
+ keyField: string;
582
+ /**
583
+ * The pivot's CURRENT schema fields, so a re-bind can restate any
584
+ * column that was declared by hand.
585
+ *
586
+ * Without them the re-bind would rewrite every column as a derived
587
+ * one, silently turning a row total into a column filtering on the
588
+ * literal value "Total" — which matches nothing and reads 0.
589
+ */
590
+ currentFields?: ReadonlyArray<{
591
+ field: string;
592
+ pivotColValue?: string;
593
+ pivotMeasure?: string;
594
+ pivotFunc?: string;
595
+ }>;
596
+ /**
597
+ * Number format per SOURCE field name, and the recipe the pivot runs,
598
+ * so a column the refresh ADDS is formatted like the columns beside it
599
+ * instead of arriving as a bare number.
600
+ *
601
+ * Both or neither: without the recipe there is no way to know which
602
+ * source column a derived column aggregates. Omit to leave every
603
+ * format alone.
604
+ */
605
+ formats?: {
606
+ numFmts: Readonly<Record<string, string | undefined>>;
607
+ measure: string;
608
+ func: AggFunc;
609
+ };
610
+ }): Promise<PivotRefresh | null>;
611
+ /**
612
+ * Change a pivot's RECIPE — what it groups by, what it measures, how, in
613
+ * what order, over which records — and reshape it to match, in one
614
+ * transaction.
615
+ *
616
+ * A recipe is not editable in place by hand: a pivot's numbers are
617
+ * generated from it, its column names ARE the column dimension's values,
618
+ * and its key column holds the row dimension's. Changing any of those by
619
+ * typing produces a table that says one thing and computes another —
620
+ * editing a row label does not re-aim the row, it relabels a group.
621
+ *
622
+ * Deliberately does NOT ask for the current plan. The commonest reason to
623
+ * edit is that the recipe has stopped resolving (a source field renamed
624
+ * out from under it, say), and planning the OLD recipe would fail exactly
625
+ * then. The caller states the block's present size instead, which it can
626
+ * always see.
627
+ *
628
+ * Payload order, for the same reasons as §6 with one addition: the RECIPE
629
+ * goes before the bind, because the bind is what regenerates every cell
630
+ * from it — set it after and the block re-materializes from the old one.
631
+ *
632
+ * grow (if growing) → recipe → keys → bind → shrink (only if shrinking)
633
+ *
634
+ * Returns the shape it produced.
635
+ */
636
+ editPivot(opts: {
637
+ sheetIdx: number;
638
+ /** The pivot being edited. */
639
+ blockId: number;
640
+ /** Its ref name, which the re-bind has to restate. */
641
+ refName: string;
642
+ /** The block it analyses: what the new recipe is planned against. */
643
+ source: PivotSource;
644
+ /**
645
+ * Its present size, so the reshape knows whether it grows or shrinks.
646
+ * Asked for rather than planned, so a broken recipe can still be
647
+ * fixed — which is most of the point of this method.
648
+ */
649
+ currentRowCnt: number;
650
+ currentColCnt: number;
651
+ rowDim: string;
652
+ colDim?: string;
653
+ measure: string;
654
+ func: AggFunc;
655
+ order?: DimOrder;
656
+ orderValues?: readonly string[];
657
+ filters?: readonly PivotFilter[];
658
+ extraColumns?: readonly PivotColumnSpec[];
659
+ valueColumn?: string;
660
+ }): Promise<{
661
+ keys: string[];
662
+ fields: string[];
663
+ unassignedRecords: number;
664
+ }>;
191
665
  /**
192
666
  * Apply a caller-built payload list as one transaction (at the host's
193
667
  * temp-mode). Escape hatch for operations whose payload construction still
@@ -242,6 +716,4 @@ export declare class WorkbookOps {
242
716
  * interpretValidation instead.
243
717
  */
244
718
  checkValidations(rules: readonly ValidationRule[]): Promise<Violation[]>;
245
- /** Check required / unique / membership field constraints. */
246
- checkFieldConstraints(columns: readonly FieldColumn[]): Promise<Violation[]>;
247
719
  }