@quereus/quereus 4.3.2 → 4.4.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.
Files changed (142) hide show
  1. package/dist/src/core/database-external-changes.d.ts +2 -2
  2. package/dist/src/core/database-external-changes.d.ts.map +1 -1
  3. package/dist/src/core/database-external-changes.js +12 -9
  4. package/dist/src/core/database-external-changes.js.map +1 -1
  5. package/dist/src/core/database-materialized-views-apply.d.ts +156 -14
  6. package/dist/src/core/database-materialized-views-apply.d.ts.map +1 -1
  7. package/dist/src/core/database-materialized-views-apply.js +370 -65
  8. package/dist/src/core/database-materialized-views-apply.js.map +1 -1
  9. package/dist/src/core/database-materialized-views-plan-builders.d.ts +65 -1
  10. package/dist/src/core/database-materialized-views-plan-builders.d.ts.map +1 -1
  11. package/dist/src/core/database-materialized-views-plan-builders.js +347 -8
  12. package/dist/src/core/database-materialized-views-plan-builders.js.map +1 -1
  13. package/dist/src/core/database-materialized-views-plans.d.ts +254 -30
  14. package/dist/src/core/database-materialized-views-plans.d.ts.map +1 -1
  15. package/dist/src/core/database-materialized-views.d.ts +96 -40
  16. package/dist/src/core/database-materialized-views.d.ts.map +1 -1
  17. package/dist/src/core/database-materialized-views.js +172 -116
  18. package/dist/src/core/database-materialized-views.js.map +1 -1
  19. package/dist/src/core/database.d.ts.map +1 -1
  20. package/dist/src/core/database.js +20 -17
  21. package/dist/src/core/database.js.map +1 -1
  22. package/dist/src/func/builtins/aggregate.d.ts.map +1 -1
  23. package/dist/src/func/builtins/aggregate.js +127 -21
  24. package/dist/src/func/builtins/aggregate.js.map +1 -1
  25. package/dist/src/func/registration.d.ts +6 -1
  26. package/dist/src/func/registration.d.ts.map +1 -1
  27. package/dist/src/func/registration.js +16 -0
  28. package/dist/src/func/registration.js.map +1 -1
  29. package/dist/src/planner/analysis/equi-correlation.d.ts +26 -0
  30. package/dist/src/planner/analysis/equi-correlation.d.ts.map +1 -0
  31. package/dist/src/planner/analysis/equi-correlation.js +62 -0
  32. package/dist/src/planner/analysis/equi-correlation.js.map +1 -0
  33. package/dist/src/planner/analysis/query-rewrite-matcher.d.ts +54 -18
  34. package/dist/src/planner/analysis/query-rewrite-matcher.d.ts.map +1 -1
  35. package/dist/src/planner/analysis/query-rewrite-matcher.js +86 -42
  36. package/dist/src/planner/analysis/query-rewrite-matcher.js.map +1 -1
  37. package/dist/src/planner/analysis/scalar-subqueries.d.ts +28 -0
  38. package/dist/src/planner/analysis/scalar-subqueries.d.ts.map +1 -0
  39. package/dist/src/planner/analysis/scalar-subqueries.js +60 -0
  40. package/dist/src/planner/analysis/scalar-subqueries.js.map +1 -0
  41. package/dist/src/planner/building/select.d.ts.map +1 -1
  42. package/dist/src/planner/building/select.js +15 -1
  43. package/dist/src/planner/building/select.js.map +1 -1
  44. package/dist/src/planner/building/with.d.ts.map +1 -1
  45. package/dist/src/planner/building/with.js +5 -8
  46. package/dist/src/planner/building/with.js.map +1 -1
  47. package/dist/src/planner/cache/materialization-advisory.d.ts +33 -0
  48. package/dist/src/planner/cache/materialization-advisory.d.ts.map +1 -1
  49. package/dist/src/planner/cache/materialization-advisory.js +164 -28
  50. package/dist/src/planner/cache/materialization-advisory.js.map +1 -1
  51. package/dist/src/planner/cache/reference-graph.d.ts +0 -4
  52. package/dist/src/planner/cache/reference-graph.d.ts.map +1 -1
  53. package/dist/src/planner/cache/reference-graph.js +5 -13
  54. package/dist/src/planner/cache/reference-graph.js.map +1 -1
  55. package/dist/src/planner/cost/index.d.ts +28 -7
  56. package/dist/src/planner/cost/index.d.ts.map +1 -1
  57. package/dist/src/planner/cost/index.js +26 -0
  58. package/dist/src/planner/cost/index.js.map +1 -1
  59. package/dist/src/planner/nodes/cache-node.d.ts +18 -2
  60. package/dist/src/planner/nodes/cache-node.d.ts.map +1 -1
  61. package/dist/src/planner/nodes/cache-node.js +42 -3
  62. package/dist/src/planner/nodes/cache-node.js.map +1 -1
  63. package/dist/src/planner/nodes/cte-node.d.ts +15 -1
  64. package/dist/src/planner/nodes/cte-node.d.ts.map +1 -1
  65. package/dist/src/planner/nodes/cte-node.js +12 -2
  66. package/dist/src/planner/nodes/cte-node.js.map +1 -1
  67. package/dist/src/planner/nodes/recursive-cte-node.d.ts +30 -1
  68. package/dist/src/planner/nodes/recursive-cte-node.d.ts.map +1 -1
  69. package/dist/src/planner/nodes/recursive-cte-node.js +30 -3
  70. package/dist/src/planner/nodes/recursive-cte-node.js.map +1 -1
  71. package/dist/src/planner/optimizer.d.ts.map +1 -1
  72. package/dist/src/planner/optimizer.js +129 -1
  73. package/dist/src/planner/optimizer.js.map +1 -1
  74. package/dist/src/planner/rules/cache/rule-cte-optimization.d.ts.map +1 -1
  75. package/dist/src/planner/rules/cache/rule-cte-optimization.js +8 -1
  76. package/dist/src/planner/rules/cache/rule-cte-optimization.js.map +1 -1
  77. package/dist/src/planner/rules/cache/rule-in-subquery-cache.d.ts +7 -0
  78. package/dist/src/planner/rules/cache/rule-in-subquery-cache.d.ts.map +1 -1
  79. package/dist/src/planner/rules/cache/rule-in-subquery-cache.js +14 -1
  80. package/dist/src/planner/rules/cache/rule-in-subquery-cache.js.map +1 -1
  81. package/dist/src/planner/rules/cache/rule-materialized-view-rewrite.d.ts.map +1 -1
  82. package/dist/src/planner/rules/cache/rule-materialized-view-rewrite.js +53 -54
  83. package/dist/src/planner/rules/cache/rule-materialized-view-rewrite.js.map +1 -1
  84. package/dist/src/planner/rules/cache/rule-mutating-subquery-cache.d.ts.map +1 -1
  85. package/dist/src/planner/rules/cache/rule-mutating-subquery-cache.js +6 -0
  86. package/dist/src/planner/rules/cache/rule-mutating-subquery-cache.js.map +1 -1
  87. package/dist/src/planner/rules/cache/rule-nested-loop-right-cache.d.ts +25 -0
  88. package/dist/src/planner/rules/cache/rule-nested-loop-right-cache.d.ts.map +1 -0
  89. package/dist/src/planner/rules/cache/rule-nested-loop-right-cache.js +176 -0
  90. package/dist/src/planner/rules/cache/rule-nested-loop-right-cache.js.map +1 -0
  91. package/dist/src/planner/rules/cache/rule-scalar-subquery-cache.d.ts +29 -0
  92. package/dist/src/planner/rules/cache/rule-scalar-subquery-cache.d.ts.map +1 -0
  93. package/dist/src/planner/rules/cache/rule-scalar-subquery-cache.js +66 -0
  94. package/dist/src/planner/rules/cache/rule-scalar-subquery-cache.js.map +1 -0
  95. package/dist/src/planner/rules/join/rule-fanout-lookup-join.d.ts.map +1 -1
  96. package/dist/src/planner/rules/join/rule-fanout-lookup-join.js +1 -54
  97. package/dist/src/planner/rules/join/rule-fanout-lookup-join.js.map +1 -1
  98. package/dist/src/planner/rules/subquery/rule-scalar-agg-decorrelation.d.ts +191 -0
  99. package/dist/src/planner/rules/subquery/rule-scalar-agg-decorrelation.d.ts.map +1 -0
  100. package/dist/src/planner/rules/subquery/rule-scalar-agg-decorrelation.js +623 -0
  101. package/dist/src/planner/rules/subquery/rule-scalar-agg-decorrelation.js.map +1 -0
  102. package/dist/src/planner/rules/subquery/rule-subquery-decorrelation.d.ts +10 -1
  103. package/dist/src/planner/rules/subquery/rule-subquery-decorrelation.d.ts.map +1 -1
  104. package/dist/src/planner/rules/subquery/rule-subquery-decorrelation.js +242 -51
  105. package/dist/src/planner/rules/subquery/rule-subquery-decorrelation.js.map +1 -1
  106. package/dist/src/runtime/cache/shared-cache.d.ts +7 -0
  107. package/dist/src/runtime/cache/shared-cache.d.ts.map +1 -1
  108. package/dist/src/runtime/cache/shared-cache.js +34 -1
  109. package/dist/src/runtime/cache/shared-cache.js.map +1 -1
  110. package/dist/src/runtime/emit/aggregate.d.ts +2 -3
  111. package/dist/src/runtime/emit/aggregate.d.ts.map +1 -1
  112. package/dist/src/runtime/emit/aggregate.js +6 -15
  113. package/dist/src/runtime/emit/aggregate.js.map +1 -1
  114. package/dist/src/runtime/emit/cache.d.ts +7 -3
  115. package/dist/src/runtime/emit/cache.d.ts.map +1 -1
  116. package/dist/src/runtime/emit/cache.js +23 -6
  117. package/dist/src/runtime/emit/cache.js.map +1 -1
  118. package/dist/src/runtime/emit/cte.d.ts.map +1 -1
  119. package/dist/src/runtime/emit/cte.js +43 -20
  120. package/dist/src/runtime/emit/cte.js.map +1 -1
  121. package/dist/src/runtime/emit/dml-executor.d.ts.map +1 -1
  122. package/dist/src/runtime/emit/dml-executor.js +68 -43
  123. package/dist/src/runtime/emit/dml-executor.js.map +1 -1
  124. package/dist/src/runtime/emit/materialized-view-helpers.d.ts.map +1 -1
  125. package/dist/src/runtime/emit/materialized-view-helpers.js +5 -4
  126. package/dist/src/runtime/emit/materialized-view-helpers.js.map +1 -1
  127. package/dist/src/runtime/emit/recursive-cte.d.ts.map +1 -1
  128. package/dist/src/runtime/emit/recursive-cte.js +59 -1
  129. package/dist/src/runtime/emit/recursive-cte.js.map +1 -1
  130. package/dist/src/runtime/parallel-driver.d.ts +15 -1
  131. package/dist/src/runtime/parallel-driver.d.ts.map +1 -1
  132. package/dist/src/runtime/parallel-driver.js +17 -1
  133. package/dist/src/runtime/parallel-driver.js.map +1 -1
  134. package/dist/src/runtime/types.d.ts +36 -0
  135. package/dist/src/runtime/types.d.ts.map +1 -1
  136. package/dist/src/runtime/types.js.map +1 -1
  137. package/dist/src/schema/function.d.ts +79 -0
  138. package/dist/src/schema/function.d.ts.map +1 -1
  139. package/dist/src/schema/function.js.map +1 -1
  140. package/dist/src/vtab/memory/layer/scan-layer.js +21 -2
  141. package/dist/src/vtab/memory/layer/scan-layer.js.map +1 -1
  142. package/package.json +3 -3
@@ -6,13 +6,14 @@
6
6
  * the sole caller in the manager is `registerMaterializedView` → {@link buildMaintenancePlan}.
7
7
  */
8
8
  import { BlockNode } from '../planner/nodes/block.js';
9
+ import { type ScalarPlanNode } from '../planner/nodes/plan-node.js';
9
10
  import { TableReferenceNode } from '../planner/nodes/reference.js';
10
11
  import { Scheduler } from '../runtime/scheduler.js';
11
12
  import { type MaintenanceSourceStats } from '../planner/cost/index.js';
12
13
  import type { MaintainedTableSchema } from '../schema/derivation.js';
13
14
  import type { TableSchema } from '../schema/table.js';
14
15
  import { type AggregateLike } from './database-materialized-views-analysis.js';
15
- import type { MaterializedViewManagerContext, MaintenancePlan, FullRebuildPlan } from './database-materialized-views-plans.js';
16
+ import type { MaterializedViewManagerContext, MaintenancePlan, FullRebuildPlan, BackingPkColumn, DeltaAggregateDescriptor } from './database-materialized-views-plans.js';
16
17
  /**
17
18
  * Build the row-time maintenance plan for an MV — **cost-gated, with a floor, never a
18
19
  * shape allowlist**. The builder tries to match a bounded-delta arm by shape
@@ -86,6 +87,69 @@ export declare function buildInverseProjectionPlan(ctx: MaterializedViewManagerC
86
87
  * the backing is keyed by the group key regardless of whether it is a source key.
87
88
  */
88
89
  export declare function buildAggregateResidualPlan(ctx: MaterializedViewManagerContext, mv: MaintainedTableSchema, analyzed: BlockNode, tableRef: TableReferenceNode, sourceBase: string, aggregate: AggregateLike): MaintenancePlan | null;
90
+ /**
91
+ * Build the {@link DeltaAggregateDescriptor} for a single-source aggregate body, or
92
+ * return `undefined` when any column fails the delta eligibility gate (the arm then
93
+ * stays a plain residual — a gate failure is never an error). Function-generic by
94
+ * construction: every judgment reads the aggregate call node's registry-resolved
95
+ * schema and its DECLARED {@link AggregateAlgebra} — never an aggregate-name list.
96
+ *
97
+ * The gate, per stored backing column:
98
+ * - a **group-key passthrough** column must resolve (transitively) to a GROUP BY
99
+ * source column that also backs a backing-PK position (so its flush value is the
100
+ * group key itself);
101
+ * - a directly **delta-maintainable aggregate** column must be a plain call (`agg(x)` /
102
+ * `agg()` — no DISTINCT, FILTER, ORDER BY, multi-arg) whose declared algebra has
103
+ * `merge` + `negate` + `decode`, with a **bare source column** argument (the delta
104
+ * steps the raw value);
105
+ * - **exact value domain**: the aggregate's declared return type is INTEGER-physical
106
+ * (count-shaped — exact regardless of argument), OR its argument column's static
107
+ * type is INTEGER-physical (integer sum). A REAL/NUMERIC/TEXT sum drifts under
108
+ * repeated add/subtract and would diverge byte-exactly from the live re-evaluation
109
+ * the oracle compares against — such a column disqualifies the whole MV (residual);
110
+ * - a **tighten-only** column (min/max, and any UDAF whose merge is a join-semilattice
111
+ * like `bit_or`/`bool_or`) — a plain call declaring `merge` + `decode` but NO `negate`
112
+ * — is admitted with `deltaClass: 'tighten'`. It needs no exact-value-domain gate
113
+ * (merge is idempotent selection, not accumulating arithmetic → no drift) and is never
114
+ * retraction-safe: an insert `merge`s the new value in cheaply, but a retracted group
115
+ * re-derives wholesale from the residual (via the descriptor's `hasTighten`) rather
116
+ * than trusting the arithmetic;
117
+ * - a **decomposition-maintained** column (the avg class) — a plain call whose algebra
118
+ * declares `decompose` (a scalar formula over sibling partials, e.g. avg ≡ sum/count)
119
+ * but is not itself directly maintainable — is admitted only when EVERY partial it
120
+ * names ({@link resolveDecomposePartial}) is also stored as a sibling column of this
121
+ * same body and is itself delta-maintainable. It carries no independent accumulation;
122
+ * at flush its value is `combine([finalized(partial) …])`. If any partial is missing
123
+ * or not delta-maintainable the whole MV falls to residual (avg is the first client of
124
+ * this class, not a special case);
125
+ * - any other column shape (a constant, an expression over the group key, …) fails.
126
+ *
127
+ * Whole-body requirements:
128
+ * - **no post-aggregate filter** (HAVING, or an outer WHERE over the aggregate
129
+ * output): such a filter breaks the arithmetic invariant "stored row == the
130
+ * group's full accumulator" — a filtered-out group has contributions but no
131
+ * stored row, so a later delta would rebuild it from the identity and understate
132
+ * the aggregate. The residual re-evaluates the filter per group and stays exact;
133
+ * - a **count(*) multiplicity witness** — a zero-arg delta-maintainable column with
134
+ * `decodeExact` (its stored value must reconstruct exactly, since it decides group
135
+ * emptiness under retraction). Without it, emptiness cannot be told from an
136
+ * all-NULL sum;
137
+ * - **BINARY collations** on every backing-PK (group key) column: the flush's stored-
138
+ * row read is the host's `scanEffective` equality-prefix seek, whose prefix compare
139
+ * is binary (scan-layer.ts). Unlike the prefix-delete arm there is no source-PK
140
+ * uniqueness collapsing collation classes to one byte form here, so a non-BINARY
141
+ * group key could miss a collation-equal / byte-different stored row — residual
142
+ * instead;
143
+ * - a body WHERE (if any) must compile to a single-source-row predicate
144
+ * (`compilePredicate`) so per-row contributions can be scoped exactly.
145
+ *
146
+ * Per-column `retractionSafe` (see {@link DeltaAggregateColumn}) is recorded rather
147
+ * than gated: a not-retraction-safe descriptor still serves the bulk-insert fast path,
148
+ * and the flush re-derives only the retracted groups through the residual.
149
+ */
150
+ export declare function buildDeltaAggregateDescriptor(mv: MaintainedTableSchema, analyzed: BlockNode, tableRef: TableReferenceNode, groupColumns: readonly number[], backingPkDefinition: ReadonlyArray<BackingPkColumn>, backingPkSourceCols: readonly number[], rootAttrs: ReadonlyArray<{
151
+ id: number;
152
+ } | undefined>, sourceAttrToCol: Map<number, number>, producingByAttrId: Map<number, ScalarPlanNode>): DeltaAggregateDescriptor | undefined;
89
153
  /**
90
154
  * Build a `'join-residual'` plan for a provably-1:1 row-preserving **inner/cross join**
91
155
  * body (`select … from T join P on T.fk = P.id`), or return `null` on a shape mismatch
@@ -1 +1 @@
1
- {"version":3,"file":"database-materialized-views-plan-builders.d.ts","sourceRoot":"","sources":["../../../src/core/database-materialized-views-plan-builders.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"database-materialized-views-plan-builders.d.ts","sourceRoot":"","sources":["../../../src/core/database-materialized-views-plan-builders.ts"],"names":[],"mappings":"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"}
@@ -8,6 +8,9 @@
8
8
  import { QuereusError } from '../common/errors.js';
9
9
  import { StatusCode } from '../common/types.js';
10
10
  import { ColumnReferenceNode } from '../planner/nodes/reference.js';
11
+ import { AggregateFunctionCallNode } from '../planner/nodes/aggregate-function.js';
12
+ import { isAggregateFunctionSchema } from '../schema/function.js';
13
+ import { PhysicalType } from '../types/logical-type.js';
11
14
  import { checkDeterministic } from '../planner/validation/determinism-validator.js';
12
15
  import { emitPlanNode } from '../runtime/emitters.js';
13
16
  import { EmissionContext } from '../runtime/emission-context.js';
@@ -21,11 +24,17 @@ import { CapabilityDetectors } from '../planner/framework/characteristics.js';
21
24
  import { selectMaintenanceStrategy, isFullRebuildPathological, seqScanCost, filterCost, projectCost, } from '../planner/cost/index.js';
22
25
  import { tryResolveBackingHost } from '../runtime/emit/materialized-view-helpers.js';
23
26
  import { compilePredicate } from '../vtab/memory/utils/predicate.js';
24
- import { findNonReplicableFunction, findNonReplicableCollation, findNonDeterministic, normalizeCollation, findAggregate, containsNodeType, containsAnyJoin, countNodeType, countJoins, findTableFunctionCall, collectTableRefs, collectProducingExprs, resolveTransitiveSourceCol, bodyWhereReferencesLookup, bodyWhereIsNonDeterministic, relationalAttributes, rootRelationalNode, cannotMaterialize, nonReplicableDerivationError, nonReplicableCollationDerivationError, isSingleRowEvaluable, compileSourceRowEvaluator, } from './database-materialized-views-analysis.js';
27
+ import { findNonReplicableFunction, findNonReplicableCollation, findNonDeterministic, normalizeCollation, isBinaryCollation, findAggregate, containsNodeType, containsAnyJoin, countNodeType, countJoins, findTableFunctionCall, collectTableRefs, collectProducingExprs, resolveTransitiveSourceCol, bodyWhereReferencesLookup, bodyWhereIsNonDeterministic, relationalAttributes, rootRelationalNode, cannotMaterialize, nonReplicableDerivationError, nonReplicableCollationDerivationError, isSingleRowEvaluable, compileSourceRowEvaluator, } from './database-materialized-views-analysis.js';
25
28
  import { BINARY_COLLATION } from '../util/comparison.js';
26
29
  /** Fallback source row estimate when the StatsProvider has no count (mirrors the
27
30
  * optimizer's naive default). Only feeds the create-time maintenance cost gate. */
28
31
  const DEFAULT_SOURCE_ROWS = 1000;
32
+ /** Create-time estimate of the fraction of changes to a **tighten-only** (min/max) delta
33
+ * aggregate that retract (a delete / update-old-image) and so re-derive their group from
34
+ * the key-filtered residual instead of pure arithmetic. Feeds the `'delta-aggregate'` cost
35
+ * blend ({@link maintenanceCost}); kept well below 1 so an insert-dominated body still
36
+ * prefers the delta arm, yet non-zero so a tighten body costs more than a pure-group one. */
37
+ const DELTA_TIGHTEN_FALLBACK_RATIO = 0.25;
29
38
  /**
30
39
  * Snapshot a backing table's physical primary key as the maintenance plans consume it: the
31
40
  * declared `(index, desc, collation)` triple plus the collation comparator that name resolves
@@ -123,6 +132,18 @@ export function buildMaintenancePlan(ctx, mv) {
123
132
  }
124
133
  // Try a bounded-delta arm; a shape that fits none falls through to the floor.
125
134
  const boundedDelta = tryBuildBoundedDeltaArm(ctx, mv, analyzed);
135
+ // A residual arm additionally carries the whole-body scheduler — the per-statement
136
+ // degrade-to-rebuild escape: when a statement's distinct affected-key count makes k
137
+ // residual runs cost more than one rebuild (`shouldDegradeToRebuild`), the flush runs
138
+ // this and applies a single `'replace-all'` diff instead. Compiled here (not per arm
139
+ // builder) so the three residual arms share one compilation site; inverse-projection
140
+ // never degrades (always the cheapest arm) and the floor has its own bodyScheduler.
141
+ // NOTE: adds one whole-body optimize+emit per residual-MV (re)registration; if
142
+ // registration latency ever matters (e.g. bulk catalog import), compile this lazily
143
+ // on the first degrade instead.
144
+ if (boundedDelta && boundedDelta.kind !== 'inverse-projection' && boundedDelta.kind !== 'full-rebuild') {
145
+ boundedDelta.fullRebuildScheduler = compileWholeBodyScheduler(ctx, analyzed);
146
+ }
126
147
  const plan = boundedDelta ?? buildFullRebuildPlan(ctx, mv, analyzed);
127
148
  // Precompute the NOT-NULL ordering-seeded PK skew guard once, here at plan build —
128
149
  // which `alter … drop not null` re-runs (it recompiles the row-time plan live), so the
@@ -471,17 +492,33 @@ export function buildAggregateResidualPlan(ctx, mv, analyzed, tableRef, sourceBa
471
492
  const residualScheduler = compileResidual(ctx, analyzed, relKey, groupColumns, 'gk');
472
493
  if (!residualScheduler)
473
494
  return null; // could not parameterize the residual → floor
495
+ // Delta-aggregate fast path: build the descriptor when EVERY stored aggregate
496
+ // column is delta-maintainable by its declared algebra (see the builder below).
497
+ // `undefined` on any gate failure — the arm then stays a plain residual, unchanged.
498
+ const delta = buildDeltaAggregateDescriptor(mv, analyzed, tableRef, groupColumns, backingPkDefinition, backingPkSourceCols, rootAttrs, sourceAttrToCol, producingByAttrId);
474
499
  // ── Cost gate ──
475
500
  // The residual is the structurally-sound incremental arm for an aggregate body;
476
501
  // 'full-rebuild' is the always-correct floor for shapes where the residual is NOT
477
- // sound, so (as with inverse-projection) it is not a competitor here. We still
478
- // record the chosen strategy + cost inputs for parity with the substrate.
479
- const soundStrategies = ['residual-recompute'];
502
+ // sound, so (as with inverse-projection) it is not a competitor here. When the delta
503
+ // descriptor built, 'delta-aggregate' joins the sound set an O(1)-per-change
504
+ // arithmetic RMW, strictly cheaper than the per-group residual under the cost model,
505
+ // so the argmin picks it. The descriptor is attached only when the gate chose it
506
+ // (cost-strategy decoupling: `kind` stays 'residual-recompute' either way).
507
+ const soundStrategies = delta
508
+ ? ['delta-aggregate', 'residual-recompute']
509
+ : ['residual-recompute'];
480
510
  const hasPredicate = mv.derivation.selectAst.type === 'select' && mv.derivation.selectAst.where !== undefined;
481
511
  const sourceStats = estimateMaintenanceStats(ctx, tableRef.tableSchema, backing.columns.length, hasPredicate);
512
+ // A tighten-only (min/max) column re-derives a retracted group from the residual, so the
513
+ // delta arm's cost is no longer pure arithmetic — feed the create-time expected-rescan
514
+ // fraction into the `'delta-aggregate'` cost blend (maintenanceCost). A tighten body then
515
+ // costs strictly more than a pure-group body but, for this insert-dominated estimate, still
516
+ // below the always-residual arm, so the argmin keeps the delta arm.
517
+ if (delta?.hasTighten)
518
+ sourceStats.deltaTightenFallbackRatio = DELTA_TIGHTEN_FALLBACK_RATIO;
482
519
  const estimatedChangeCardinality = Math.max(1, sourceStats.tableRows * 0.01);
483
520
  const chosenStrategy = selectMaintenanceStrategy(soundStrategies, estimatedChangeCardinality, sourceStats);
484
- if (chosenStrategy !== 'residual-recompute') {
521
+ if (!soundStrategies.includes(chosenStrategy)) {
485
522
  throw new QuereusError(`Internal error: cost gate selected unwired strategy '${chosenStrategy}' for materialized view '${mv.name}'`, StatusCode.INTERNAL);
486
523
  }
487
524
  return {
@@ -493,14 +530,306 @@ export function buildAggregateResidualPlan(ctx, mv, analyzed, tableRef, sourceBa
493
530
  chosenStrategy,
494
531
  sourceStats,
495
532
  binding: { kind: 'group', groupColumns: [...groupColumns] },
496
- degradeToRebuild: false,
497
533
  residualScheduler,
498
534
  bindParamPrefix: 'gk',
499
535
  bindColumns: groupColumns,
500
536
  backingPkDefinition,
501
537
  backingPkSourceCols,
538
+ delta: chosenStrategy === 'delta-aggregate' ? delta : undefined,
502
539
  };
503
540
  }
541
+ /**
542
+ * Build the {@link DeltaAggregateDescriptor} for a single-source aggregate body, or
543
+ * return `undefined` when any column fails the delta eligibility gate (the arm then
544
+ * stays a plain residual — a gate failure is never an error). Function-generic by
545
+ * construction: every judgment reads the aggregate call node's registry-resolved
546
+ * schema and its DECLARED {@link AggregateAlgebra} — never an aggregate-name list.
547
+ *
548
+ * The gate, per stored backing column:
549
+ * - a **group-key passthrough** column must resolve (transitively) to a GROUP BY
550
+ * source column that also backs a backing-PK position (so its flush value is the
551
+ * group key itself);
552
+ * - a directly **delta-maintainable aggregate** column must be a plain call (`agg(x)` /
553
+ * `agg()` — no DISTINCT, FILTER, ORDER BY, multi-arg) whose declared algebra has
554
+ * `merge` + `negate` + `decode`, with a **bare source column** argument (the delta
555
+ * steps the raw value);
556
+ * - **exact value domain**: the aggregate's declared return type is INTEGER-physical
557
+ * (count-shaped — exact regardless of argument), OR its argument column's static
558
+ * type is INTEGER-physical (integer sum). A REAL/NUMERIC/TEXT sum drifts under
559
+ * repeated add/subtract and would diverge byte-exactly from the live re-evaluation
560
+ * the oracle compares against — such a column disqualifies the whole MV (residual);
561
+ * - a **tighten-only** column (min/max, and any UDAF whose merge is a join-semilattice
562
+ * like `bit_or`/`bool_or`) — a plain call declaring `merge` + `decode` but NO `negate`
563
+ * — is admitted with `deltaClass: 'tighten'`. It needs no exact-value-domain gate
564
+ * (merge is idempotent selection, not accumulating arithmetic → no drift) and is never
565
+ * retraction-safe: an insert `merge`s the new value in cheaply, but a retracted group
566
+ * re-derives wholesale from the residual (via the descriptor's `hasTighten`) rather
567
+ * than trusting the arithmetic;
568
+ * - a **decomposition-maintained** column (the avg class) — a plain call whose algebra
569
+ * declares `decompose` (a scalar formula over sibling partials, e.g. avg ≡ sum/count)
570
+ * but is not itself directly maintainable — is admitted only when EVERY partial it
571
+ * names ({@link resolveDecomposePartial}) is also stored as a sibling column of this
572
+ * same body and is itself delta-maintainable. It carries no independent accumulation;
573
+ * at flush its value is `combine([finalized(partial) …])`. If any partial is missing
574
+ * or not delta-maintainable the whole MV falls to residual (avg is the first client of
575
+ * this class, not a special case);
576
+ * - any other column shape (a constant, an expression over the group key, …) fails.
577
+ *
578
+ * Whole-body requirements:
579
+ * - **no post-aggregate filter** (HAVING, or an outer WHERE over the aggregate
580
+ * output): such a filter breaks the arithmetic invariant "stored row == the
581
+ * group's full accumulator" — a filtered-out group has contributions but no
582
+ * stored row, so a later delta would rebuild it from the identity and understate
583
+ * the aggregate. The residual re-evaluates the filter per group and stays exact;
584
+ * - a **count(*) multiplicity witness** — a zero-arg delta-maintainable column with
585
+ * `decodeExact` (its stored value must reconstruct exactly, since it decides group
586
+ * emptiness under retraction). Without it, emptiness cannot be told from an
587
+ * all-NULL sum;
588
+ * - **BINARY collations** on every backing-PK (group key) column: the flush's stored-
589
+ * row read is the host's `scanEffective` equality-prefix seek, whose prefix compare
590
+ * is binary (scan-layer.ts). Unlike the prefix-delete arm there is no source-PK
591
+ * uniqueness collapsing collation classes to one byte form here, so a non-BINARY
592
+ * group key could miss a collation-equal / byte-different stored row — residual
593
+ * instead;
594
+ * - a body WHERE (if any) must compile to a single-source-row predicate
595
+ * (`compilePredicate`) so per-row contributions can be scoped exactly.
596
+ *
597
+ * Per-column `retractionSafe` (see {@link DeltaAggregateColumn}) is recorded rather
598
+ * than gated: a not-retraction-safe descriptor still serves the bulk-insert fast path,
599
+ * and the flush re-derives only the retracted groups through the residual.
600
+ */
601
+ export function buildDeltaAggregateDescriptor(mv, analyzed, tableRef, groupColumns, backingPkDefinition, backingPkSourceCols, rootAttrs, sourceAttrToCol, producingByAttrId) {
602
+ const sourceSchema = tableRef.tableSchema;
603
+ const groupColumnSet = new Set(groupColumns);
604
+ // Post-aggregate filter gate: a Filter whose subtree contains the aggregate is a
605
+ // HAVING (or an outer WHERE over the aggregate output). It breaks the arithmetic
606
+ // invariant "stored row == the group's full accumulator" — a filtered-out group has
607
+ // contributions but no stored row, so a later delta would rebuild it from the
608
+ // identity and understate the aggregate. (The body WHERE sits BELOW the aggregate —
609
+ // its Filter subtree contains no aggregate node — and stays delta-eligible via the
610
+ // compiled scope predicate below.)
611
+ if (hasFilterAboveAggregate(analyzed))
612
+ return undefined;
613
+ // Binary-collation gate on the backing PK (the flush's binary equality-prefix read).
614
+ for (const d of backingPkDefinition) {
615
+ if (!isBinaryCollation(d.collation))
616
+ return undefined;
617
+ }
618
+ // Body WHERE → single-row scope predicate (contributions outside it are dropped,
619
+ // mirroring the residual's own WHERE). An uncompilable WHERE shape → residual.
620
+ let predicate;
621
+ const bodyWhere = mv.derivation.selectAst.type === 'select' ? mv.derivation.selectAst.where : undefined;
622
+ if (bodyWhere) {
623
+ try {
624
+ predicate = compilePredicate(bodyWhere, sourceSchema.columns);
625
+ }
626
+ catch {
627
+ return undefined;
628
+ }
629
+ }
630
+ // Chase an output attr's producing chain to its terminal (non-ColumnReference)
631
+ // expression — the aggregate call node for an aggregate output column.
632
+ const resolveTerminalProducing = (attrId) => {
633
+ const seen = new Set();
634
+ let cur = attrId;
635
+ while (!seen.has(cur)) {
636
+ seen.add(cur);
637
+ const expr = producingByAttrId.get(cur);
638
+ if (expr instanceof ColumnReferenceNode) {
639
+ cur = expr.attributeId;
640
+ continue;
641
+ }
642
+ return expr;
643
+ }
644
+ return undefined;
645
+ };
646
+ const aggColumns = [];
647
+ const groupOut = [];
648
+ // Decomposition-maintained columns (the avg class), collected raw here and bound to
649
+ // their sibling stored partials in a second pass below — the partials may project
650
+ // before OR after the decompose column, so they cannot be resolved inline.
651
+ const pendingDecompose = [];
652
+ for (let outCol = 0; outCol < rootAttrs.length; outCol++) {
653
+ const attr = rootAttrs[outCol];
654
+ if (!attr)
655
+ return undefined;
656
+ const sourceCol = resolveTransitiveSourceCol(attr.id, sourceAttrToCol, producingByAttrId);
657
+ if (sourceCol !== undefined) {
658
+ // Group-key passthrough: its flush value is the group key at the matching
659
+ // backing-PK position (covers duplicate projections of a group column).
660
+ if (!groupColumnSet.has(sourceCol))
661
+ return undefined;
662
+ const pkPos = backingPkSourceCols.indexOf(sourceCol);
663
+ if (pkPos < 0)
664
+ return undefined;
665
+ groupOut.push({ backingCol: outCol, pkPos });
666
+ continue;
667
+ }
668
+ const producing = resolveTerminalProducing(attr.id);
669
+ if (!(producing instanceof AggregateFunctionCallNode))
670
+ return undefined;
671
+ // Plain call only: DISTINCT folds duplicates (delta cannot), FILTER/ORDER BY
672
+ // change the contribution set, multi-arg has no single raw value to step.
673
+ if (producing.isDistinct || producing.filter || (producing.orderBy && producing.orderBy.length > 0))
674
+ return undefined;
675
+ if (producing.args.length > 1)
676
+ return undefined;
677
+ const schema = producing.functionSchema;
678
+ if (!isAggregateFunctionSchema(schema))
679
+ return undefined;
680
+ const algebra = schema.algebra;
681
+ // A bare source column argument (both classes below step / decompose over the raw
682
+ // value); a zero-arg (count(*)-shaped) call carries none.
683
+ let argSourceCol;
684
+ if (producing.args.length === 1) {
685
+ const arg = producing.args[0];
686
+ if (!(arg instanceof ColumnReferenceNode))
687
+ return undefined; // bare column args only
688
+ argSourceCol = resolveTransitiveSourceCol(arg.attributeId, sourceAttrToCol, producingByAttrId);
689
+ if (argSourceCol === undefined)
690
+ return undefined;
691
+ }
692
+ // Class routing — declaration-driven, never an aggregate-name list:
693
+ // - **directly delta-maintainable** (`merge` + `negate` (retraction) + `decode` —
694
+ // the backing stores finalized values, not accumulators): accumulate it as an
695
+ // ordinary partial;
696
+ // - else a **decomposition-maintained** column whose value is a formula over
697
+ // sibling partials (`algebra.decompose`, e.g. avg ≡ sum/count) — deferred to the
698
+ // pass below that binds each partial to a stored sibling; it accumulates nothing;
699
+ // - anything else disqualifies the whole MV → residual.
700
+ if (algebra?.negate && algebra.decode) {
701
+ // Exact numeric domain: an INTEGER-physical declared result (count-shaped — the
702
+ // accumulator never holds a float) or an INTEGER-physical argument column
703
+ // (integer sum). Anything else may drift under repeated add/subtract.
704
+ // NOTE: if a REAL-domain sum ever needs the fast path, it needs compensated
705
+ // (Kahan) accumulation or a periodic-rescan discipline — a design change, not a
706
+ // relaxation of this gate.
707
+ const resultExact = schema.returnType.logicalType.physicalType === PhysicalType.INTEGER;
708
+ const argExact = argSourceCol !== undefined
709
+ && sourceSchema.columns[argSourceCol]?.logicalType.physicalType === PhysicalType.INTEGER;
710
+ if (!resultExact && !argExact)
711
+ return undefined;
712
+ const retractionSafe = algebra.decodeExact === true
713
+ || (argSourceCol !== undefined && sourceSchema.columns[argSourceCol]?.notNull === true);
714
+ aggColumns.push({
715
+ backingCol: outCol,
716
+ schema,
717
+ algebra,
718
+ deltaClass: 'group',
719
+ argSourceCol,
720
+ isMultiplicity: producing.args.length === 0,
721
+ retractionSafe,
722
+ });
723
+ continue;
724
+ }
725
+ // Tighten-only (join-semilattice): `merge` + `decode` but NO `negate` — min/max, and
726
+ // any UDAF whose merge is idempotent selection (bit_or/bool_or). An insert `merge`s the
727
+ // new value in cheaply (min/max tighten toward the new extreme); a retraction cannot be
728
+ // undone arithmetically, so a retracted group falls back to the residual
729
+ // (retractionSafe:false, and the descriptor's hasTighten routes the whole group there
730
+ // whether or not a row is stored). NO exact-numeric-domain gate: merge is idempotent
731
+ // selection, not accumulating arithmetic, so there is no float drift to guard — a tighten
732
+ // column over any comparable domain (REAL/TEXT included) stays byte-exact vs the live
733
+ // re-evaluation. A negate+decode column that failed the exact-domain gate above already
734
+ // returned undefined, so only a decode-without-negate aggregate reaches here.
735
+ if (algebra?.decode && !algebra.negate) {
736
+ aggColumns.push({
737
+ backingCol: outCol,
738
+ schema,
739
+ algebra,
740
+ deltaClass: 'tighten',
741
+ argSourceCol,
742
+ isMultiplicity: producing.args.length === 0,
743
+ retractionSafe: false,
744
+ });
745
+ continue;
746
+ }
747
+ if (algebra?.decompose) {
748
+ pendingDecompose.push({ backingCol: outCol, argSourceCol, decomposition: algebra.decompose });
749
+ continue;
750
+ }
751
+ return undefined; // neither directly maintainable, tighten-only, nor decomposable → residual
752
+ }
753
+ // Multiplicity witness: a zero-arg delta-maintainable column whose decode is exact
754
+ // (its merged value decides group emptiness, so the reconstruction must survive
755
+ // retraction). Structural — not name-based. A decompose column is never the witness
756
+ // (it accumulates nothing — the requirement is a real stored count(*)).
757
+ const multiplicityIndex = aggColumns.findIndex(c => c.isMultiplicity && c.algebra.decodeExact === true);
758
+ if (multiplicityIndex < 0)
759
+ return undefined;
760
+ if (aggColumns.length === 0)
761
+ return undefined; // defensive — findAggregate implies ≥1
762
+ // Bind each decomposition column's partials to sibling STORED, delta-maintained columns
763
+ // (every `aggColumns` entry already passed the accumulator gate, so it is itself delta-
764
+ // maintainable). A partial that maps to no such sibling disqualifies the whole MV →
765
+ // residual (honest and visible: the user wrote a body without the partials the formula
766
+ // names). The decompose column's `combine` recomputes exactly per group at flush.
767
+ const decomposeColumns = [];
768
+ for (const pd of pendingDecompose) {
769
+ const partialIndices = [];
770
+ for (const p of pd.decomposition.partials) {
771
+ const idx = resolveDecomposePartial(p, pd.argSourceCol, aggColumns, sourceSchema);
772
+ if (idx === undefined)
773
+ return undefined;
774
+ partialIndices.push(idx);
775
+ }
776
+ decomposeColumns.push({ backingCol: pd.backingCol, partialIndices, combine: pd.decomposition.combine });
777
+ }
778
+ return {
779
+ aggColumns,
780
+ decomposeColumns,
781
+ groupColumns: groupOut,
782
+ multiplicityIndex,
783
+ backingColumnCount: rootAttrs.length,
784
+ retractionSafe: aggColumns.every(c => c.retractionSafe),
785
+ hasTighten: aggColumns.some(c => c.deltaClass === 'tighten'),
786
+ predicate,
787
+ };
788
+ }
789
+ /**
790
+ * Resolve one {@link AggregateDecomposition} partial (e.g. avg's `sum` / `count`) to the
791
+ * index of a sibling stored, delta-maintainable aggregate column — an `aggColumns` entry
792
+ * matching the partial's function name and argument. Returns `undefined` when no such
793
+ * sibling is stored (the decompose column is then not maintainable → the MV falls to the
794
+ * residual). The write-side twin of `query-rewrite-matcher.resolveMergeablePartial`.
795
+ *
796
+ * `'same-arg'` targets a sibling over the decompose column's own argument (`f(thisArg)`);
797
+ * `'star'` targets a zero-arg (`count(*)`-shaped) sibling. The **count(*) fallback** mirrors
798
+ * the read-side relaxation from `feat-mv-agg-rollup-retarget`: a stored `count(*)` substitutes
799
+ * for a `count(x)` divisor only when `x` is declared **NOT NULL** — then `count(*)` excludes
800
+ * the same (zero) NULLs `count(x)` would, so the recombine stays exact.
801
+ */
802
+ function resolveDecomposePartial(p, ownerArgSourceCol, aggColumns, sourceSchema) {
803
+ // NOTE: a partial resolves only to an `aggColumns` entry, which already passed the
804
+ // accumulator gate — a BARE source column argument plus the INTEGER-exact-domain check.
805
+ // That is what keeps the decomposed value byte-exact: a partial like `sum(log x)` (a
806
+ // non-bare, float-producing argument) can never be a stored delta-maintainable sibling,
807
+ // so a true geometric mean is NOT delta-decomposable and correctly falls to the residual.
808
+ const targetArg = p.arg === 'star' ? undefined : ownerArgSourceCol;
809
+ const funcName = p.func.toLowerCase();
810
+ const find = (name, argCol) => aggColumns.findIndex(c => c.schema.name.toLowerCase() === name && c.argSourceCol === argCol);
811
+ let idx = find(funcName, targetArg);
812
+ if (idx < 0 && funcName === 'count' && targetArg !== undefined
813
+ && sourceSchema.columns[targetArg]?.notNull === true) {
814
+ idx = find('count', undefined); // count(*) qualifies as the count(x) divisor when x is NOT NULL
815
+ }
816
+ return idx < 0 ? undefined : idx;
817
+ }
818
+ /**
819
+ * True when any Filter node's subtree contains an aggregate node — i.e. the plan
820
+ * filters the AGGREGATE OUTPUT (HAVING, or an outer WHERE over a grouped subquery).
821
+ * The body WHERE is the complement case: its Filter sits below the aggregate, so its
822
+ * subtree contains none. Used by {@link buildDeltaAggregateDescriptor}'s gate.
823
+ */
824
+ function hasFilterAboveAggregate(node) {
825
+ if (node.nodeType === PlanNodeType.Filter && findAggregate(node) !== undefined)
826
+ return true;
827
+ for (const child of node.getChildren()) {
828
+ if (hasFilterAboveAggregate(child))
829
+ return true;
830
+ }
831
+ return false;
832
+ }
504
833
  /**
505
834
  * Build a `'join-residual'` plan for a provably-1:1 row-preserving **inner/cross join**
506
835
  * body (`select … from T join P on T.fk = P.id`), or return `null` on a shape mismatch
@@ -679,7 +1008,6 @@ export function buildJoinResidualPlan(ctx, mv, analyzed, tableRefs) {
679
1008
  chosenStrategy,
680
1009
  sourceStats,
681
1010
  binding: { kind: 'row', keyColumns: [...tPkCols] },
682
- degradeToRebuild: false,
683
1011
  residualScheduler: forwardResidual,
684
1012
  bindParamPrefix: 'pk',
685
1013
  bindColumns: tPkCols,
@@ -866,6 +1194,18 @@ export function compileResidual(ctx, analyzed, relKey, bindColumns, paramPrefix)
866
1194
  const instruction = emitPlanNode(optimized, new EmissionContext(db));
867
1195
  return new Scheduler(instruction);
868
1196
  }
1197
+ /**
1198
+ * Compile the WHOLE optimized body (no key filter, read-side rewrite suppressed) into a
1199
+ * reusable {@link Scheduler} — the residual arms' per-statement degrade-to-rebuild
1200
+ * escape ({@link ResidualArmCommon.fullRebuildScheduler}). Mirrors the floor's own
1201
+ * `bodyScheduler` compilation in {@link buildFullRebuildPlan}: run to completion against
1202
+ * live mid-transaction source state, its rows feed a single `'replace-all'` keyed diff.
1203
+ */
1204
+ function compileWholeBodyScheduler(ctx, analyzed) {
1205
+ const db = ctx;
1206
+ const optimized = db.schemaManager.withSuppressedMaterializedViewRewrite(() => ctx.optimizer.optimize(analyzed, db));
1207
+ return new Scheduler(emitPlanNode(optimized, new EmissionContext(db)));
1208
+ }
869
1209
  /**
870
1210
  * Build a `'prefix-delete'` plan for a single-source lateral-TVF fan-out body
871
1211
  * (`select T.pk…, …, f.* from T cross join lateral tvf(<args over T>) f`), or return
@@ -1025,7 +1365,6 @@ export function buildLateralTvfPrefixDeletePlan(ctx, mv, analyzed, tableRef, sou
1025
1365
  chosenStrategy,
1026
1366
  sourceStats,
1027
1367
  binding: { kind: 'row', keyColumns: [...sourcePkCols] },
1028
- degradeToRebuild: false,
1029
1368
  residualScheduler,
1030
1369
  bindParamPrefix: 'pk',
1031
1370
  bindColumns: sourcePkCols,