@memberjunction/materialization 0.0.0 → 6.1.0-edge.4
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/LICENSE +183 -0
- package/README.md +25 -32
- package/dist/MaterializationFreshness.d.ts +55 -0
- package/dist/MaterializationFreshness.d.ts.map +1 -0
- package/dist/MaterializationFreshness.js +124 -0
- package/dist/MaterializationFreshness.js.map +1 -0
- package/dist/MaterializationRefresher.d.ts +621 -0
- package/dist/MaterializationRefresher.d.ts.map +1 -0
- package/dist/MaterializationRefresher.js +1558 -0
- package/dist/MaterializationRefresher.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -8
|
@@ -0,0 +1,1558 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { LogError, LogStatus, ExternalDataSourceReadRouter, RunView } from '@memberjunction/core';
|
|
3
|
+
import { MJGlobal, ResolveSingleEntityResourceTarget, UUIDsEqual } from '@memberjunction/global';
|
|
4
|
+
import { SQLParser } from '@memberjunction/sql-parser';
|
|
5
|
+
import { GetDialect } from '@memberjunction/sql-dialect';
|
|
6
|
+
/**
|
|
7
|
+
* Synthetic surrogate key column name for query-materialized tables. MUST match CodeGenLib's
|
|
8
|
+
* `MATERIALIZATION_SURROGATE_COLUMN` (materializationAnalysis.ts) — CodeGen creates the column with
|
|
9
|
+
* this name and the refresher must regenerate it on every rebuild. (A shared low-level home for this
|
|
10
|
+
* constant is a follow-up; duplicated deliberately to avoid a runtime dependency on the dev-time CodeGenLib.)
|
|
11
|
+
*/
|
|
12
|
+
export const MATERIALIZATION_SURROGATE_COLUMN = '__mj_MaterializedRowID';
|
|
13
|
+
/**
|
|
14
|
+
* Force a full rebuild after this many consecutive incremental (Incremental/DirtyGroupRecompute)
|
|
15
|
+
* refreshes. The incremental delete-detection guard only trips on a NET source row-count drop; a
|
|
16
|
+
* delete BALANCED by an insert in the same window (net-zero change) leaves the deleted row's group
|
|
17
|
+
* stale until another change touches it. This periodic full rebuild bounds that drift to at most
|
|
18
|
+
* this many refresh cycles without requiring the author to schedule a manual FullRebuild.
|
|
19
|
+
*/
|
|
20
|
+
export const FULL_REBUILD_EVERY_N_INCREMENTAL_REFRESHES = 10;
|
|
21
|
+
/**
|
|
22
|
+
* Safety lag subtracted from the probed `MAX(__mj_UpdatedAt)` before it is persisted as the incremental
|
|
23
|
+
* watermark. Closes a commit-ordering skew: a source row whose `__mj_UpdatedAt` was stamped at write-time T1
|
|
24
|
+
* but whose transaction COMMITS after the fingerprint probe (which already read a higher `MAX = T2 > T1`) would,
|
|
25
|
+
* without this lag, be permanently excluded by the strict `__mj_UpdatedAt > watermark` filter on the next pass.
|
|
26
|
+
* Storing `MAX - overlap` makes the next incremental RE-scan the last `overlap` window; the MERGE/`ON CONFLICT`
|
|
27
|
+
* upsert is idempotent so re-scanning already-applied rows is harmless. The overlap only needs to exceed the
|
|
28
|
+
* source's typical commit latency — longer skews are still backstopped by {@link FULL_REBUILD_EVERY_N_INCREMENTAL_REFRESHES}.
|
|
29
|
+
*/
|
|
30
|
+
export const WATERMARK_SAFETY_OVERLAP_MS = 10_000;
|
|
31
|
+
/**
|
|
32
|
+
* Runtime engine that refreshes materialized query/entity results (materialization plan §11).
|
|
33
|
+
*
|
|
34
|
+
* v1: **full rebuild** with an **atomic wrapper-view swap** — build a shadow table from the source,
|
|
35
|
+
* repoint the stable wrapper view at it, then drop the stale table and rename the shadow into the
|
|
36
|
+
* canonical name. Readers (via the wrapper view) never see a half-populated or locked result.
|
|
37
|
+
* Cross-engine: SQL Server and PostgreSQL — the swap statements differ per engine (see the two
|
|
38
|
+
* `buildFullRebuild*` methods), selected at runtime from the provider's `PlatformKey`.
|
|
39
|
+
*
|
|
40
|
+
* Invoked by the scheduled-job refresh driver, and reusable by a manual "refresh now" path.
|
|
41
|
+
*/
|
|
42
|
+
export class MaterializationRefresher {
|
|
43
|
+
constructor() {
|
|
44
|
+
/** Cached API-key row-filter targets for this refresher; `null` = not yet enumerated (see
|
|
45
|
+
* {@link loadAPIKeyRowFilterTargets}). `'unknown'` is a LOADED state meaning "assume restricted". */
|
|
46
|
+
this._apiKeyRowFilterTargets = null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Forced-full-rebuild cadence decision: should this refresh be forced to a full rebuild? True once the
|
|
50
|
+
* count of consecutive incremental refreshes since the last full rebuild has reached
|
|
51
|
+
* {@link FULL_REBUILD_EVERY_N_INCREMENTAL_REFRESHES}. Pure (no IO) so the cadence boundary is
|
|
52
|
+
* unit-testable without a provider/DB. Null-safe: an unset counter is treated as 0.
|
|
53
|
+
*/
|
|
54
|
+
static shouldForceFullRebuild(refreshesSinceFullRebuild) {
|
|
55
|
+
return (refreshesSinceFullRebuild ?? 0) >= FULL_REBUILD_EVERY_N_INCREMENTAL_REFRESHES;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Forced-full-rebuild cadence counter transition. Increments on a genuine incremental refresh; resets to
|
|
59
|
+
* 0 on any full rebuild — so the counter measures how many refreshes we've gone WITHOUT a full reconcile.
|
|
60
|
+
* Pure (no IO) so the increment/reset semantics are unit-testable. Null-safe: an unset counter is 0.
|
|
61
|
+
*/
|
|
62
|
+
static nextRefreshesSinceFullRebuild(current, ranIncremental) {
|
|
63
|
+
return ranIncremental ? (current ?? 0) + 1 : 0;
|
|
64
|
+
}
|
|
65
|
+
/** A plain, unquoted SQL identifier: leading letter/underscore, then letters/digits/underscores. */
|
|
66
|
+
static { this.SAFE_SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; }
|
|
67
|
+
/**
|
|
68
|
+
* Guards the schema/table/view identifiers that get interpolated into materialization DDL/DML — names
|
|
69
|
+
* read from the *writable* `MJ: Materialized Results` metadata row. A materialization's names are always
|
|
70
|
+
* CodeName-derived (`materialized_<CodeName>`, schema `__mj`), so a legitimate row always passes. The
|
|
71
|
+
* assertion exists so a tampered metadata row can never drive the privileged refresh job's
|
|
72
|
+
* `EXEC(...)` / `sp_rename` / `CREATE VIEW` / `RENAME TO` statements to run arbitrary DDL: a value that
|
|
73
|
+
* matches {@link SAFE_SQL_IDENTIFIER} cannot contain `]`, `"`, or `'`, so this one check closes BOTH the
|
|
74
|
+
* identifier-quoting and the T-SQL string-literal injection surfaces the swap builders would otherwise
|
|
75
|
+
* expose. Fails closed — throws (→ the refresh is reported as failed) rather than emitting a suspect
|
|
76
|
+
* statement. This is the refresh-path complement to the mint-path dialect quoting.
|
|
77
|
+
*/
|
|
78
|
+
static assertSafeObjectNames(schema, tableName, viewName) {
|
|
79
|
+
for (const [role, value] of [['schema', schema], ['table', tableName], ['view', viewName]]) {
|
|
80
|
+
if (!MaterializationRefresher.isSafeObjectName(value)) {
|
|
81
|
+
throw new Error(`Unsafe materialization ${role} identifier ${JSON.stringify(value)} — expected a plain SQL identifier (^[A-Za-z_][A-Za-z0-9_]*$); refusing to build DDL.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Non-throwing form of the identifier check. Needed by callers on the FAILURE path, which is precisely
|
|
87
|
+
* where {@link assertSafeObjectNames} may have just thrown — those callers must be able to re-check and
|
|
88
|
+
* decline quietly rather than re-enter (or bypass) the assertion that already rejected the value.
|
|
89
|
+
* @internal exposed for unit testing; not part of the supported surface.
|
|
90
|
+
*/
|
|
91
|
+
static isSafeObjectName(value) {
|
|
92
|
+
return typeof value === 'string' && MaterializationRefresher.SAFE_SQL_IDENTIFIER.test(value);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolves the SQL that the READ path would execute for `queryId` on the engine we are refreshing against,
|
|
96
|
+
* so the snapshot is built from the same statement live serves. Mirrors the read path's
|
|
97
|
+
* `QueryInfo.GetPlatformSQL(PlatformKey)`, whose precedence is: `MJ: Query SQLs` child row for the platform
|
|
98
|
+
* → legacy PlatformVariants → base SQL.
|
|
99
|
+
*
|
|
100
|
+
* `GetPlatformSQL` lives on the metadata `QueryInfo`, not on the generated `MJQueryEntity`, so the variant
|
|
101
|
+
* is resolved through the provider's query metadata. Falls back to the entity's own SQL when the query
|
|
102
|
+
* isn't present in that metadata (e.g. a provider whose cache hasn't loaded it), which reproduces exactly
|
|
103
|
+
* the previous behavior rather than failing the refresh.
|
|
104
|
+
*
|
|
105
|
+
* @returns the platform-resolved SQL, or null when neither source yields a non-empty statement.
|
|
106
|
+
* @internal exposed for unit testing; not part of the supported surface.
|
|
107
|
+
*/
|
|
108
|
+
static resolvePlatformQuerySQL(provider, queryId, entitySql, isPostgres) {
|
|
109
|
+
const platform = isPostgres ? 'postgresql' : 'sqlserver';
|
|
110
|
+
const info = provider.Queries?.find((q) => UUIDsEqual(q.ID, queryId));
|
|
111
|
+
const resolved = info ? info.GetPlatformSQL(platform) : null;
|
|
112
|
+
const chosen = resolved && resolved.trim().length > 0 ? resolved : entitySql;
|
|
113
|
+
return chosen && chosen.trim().length > 0 ? chosen : null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Builds the ordered SQL statements for a SQL Server full rebuild with atomic swap (plan §11.2).
|
|
117
|
+
* Pure (no IO) so the swap sequence is unit-testable. Each returned string runs as its own batch.
|
|
118
|
+
*
|
|
119
|
+
* - query case (`surrogateColumn` set): the synthetic IDENTITY surrogate is (re)generated via
|
|
120
|
+
* `SELECT IDENTITY(int,1,1) AS <surrogate>, src.* INTO <shadow>`;
|
|
121
|
+
* - base-view case (no surrogate): `SELECT * INTO <shadow>` copies the source shape (incl. its PK column).
|
|
122
|
+
*/
|
|
123
|
+
static buildFullRebuildStatementsSQLServer(opts) {
|
|
124
|
+
const { schema, tableName, viewName, sourceSelect, surrogateColumn, hashKeyColumns } = opts;
|
|
125
|
+
MaterializationRefresher.assertSafeObjectNames(schema, tableName, viewName);
|
|
126
|
+
const shadow = opts.shadowName ?? `${tableName}__shadow`;
|
|
127
|
+
const obj = (n) => `[${schema}].[${n}]`;
|
|
128
|
+
// Surrogate: a stable HASH of the key columns (Phase 3 — keyed/aggregation materializations, the
|
|
129
|
+
// match key for incremental refresh) when key columns are supplied; otherwise a synthetic IDENTITY.
|
|
130
|
+
const surrogateExpr = hashKeyColumns && hashKeyColumns.length
|
|
131
|
+
? MaterializationRefresher.buildHashKeyExpression(hashKeyColumns, false)
|
|
132
|
+
: 'IDENTITY(int, 1, 1)';
|
|
133
|
+
const selectInto = surrogateColumn
|
|
134
|
+
? `SELECT ${surrogateExpr} AS [${surrogateColumn}], src.* INTO ${obj(shadow)} FROM (${sourceSelect}) AS src`
|
|
135
|
+
: `SELECT * INTO ${obj(shadow)} FROM (${sourceSelect}) AS src`;
|
|
136
|
+
// The shadow (built by SELECT…INTO) carries no constraints, so restore the surrogate's UNIQUE index
|
|
137
|
+
// the query case relies on: it IS the minted entity's PK and the match key the Incremental MERGE
|
|
138
|
+
// upserts onto. Created INSIDE the swap transaction so no window exposes an un-indexed table.
|
|
139
|
+
// Fixed, SHORT index name: SQL Server index names must be unique only WITHIN the table (not the DB),
|
|
140
|
+
// so a per-table constant is safe and — unlike `UQ_<tableName>_surrogate` — can never exceed the
|
|
141
|
+
// 128-char sysname limit for a long materialized_<longName> table (which would throw inside the
|
|
142
|
+
// XACT_ABORT swap transaction and roll the whole refresh back). The PG path uses an unnamed index.
|
|
143
|
+
const indexLine = surrogateColumn
|
|
144
|
+
? ` CREATE UNIQUE INDEX [UQ_MJ_Materialized_Surrogate] ON ${obj(tableName)} ([${surrogateColumn}]);\n`
|
|
145
|
+
: '';
|
|
146
|
+
return [
|
|
147
|
+
// 1) Build a fresh shadow from the source (clear any leftover from a prior failed run). The
|
|
148
|
+
// expensive read happens OUTSIDE the swap transaction, taking no locks on the canonical table.
|
|
149
|
+
`IF OBJECT_ID('[${schema}].[${shadow}]', 'U') IS NOT NULL DROP TABLE ${obj(shadow)}`,
|
|
150
|
+
selectInto,
|
|
151
|
+
// 2) ATOMIC swap in a single transactional batch — drop the stale table, rename the shadow into
|
|
152
|
+
// the canonical name (kept stable for migration-reuse detection, §12), refresh the wrapper
|
|
153
|
+
// view's cached column list, restore the surrogate index. Wrapping in one transaction closes
|
|
154
|
+
// the window where a concurrent reader could hit the wrapper view mid-swap: the earlier design
|
|
155
|
+
// repointed the view at the shadow and then renamed the shadow away BEFORE repointing back, so
|
|
156
|
+
// a reader in between saw "Invalid object name …__shadow". Here the view is only ever pointed
|
|
157
|
+
// at the canonical name, and the Sch-M lock the transaction holds blocks readers until commit,
|
|
158
|
+
// so they see either the whole old snapshot or the whole new one — never a half-swapped view.
|
|
159
|
+
// CREATE VIEW must be the sole statement of its batch, so it runs via EXEC() inside the tran.
|
|
160
|
+
// SET XACT_ABORT ON so a mid-swap statement error rolls the transaction back rather than
|
|
161
|
+
// leaving it open on the pooled connection (which would poison the next reuse of it).
|
|
162
|
+
`SET XACT_ABORT ON;\n` +
|
|
163
|
+
`BEGIN TRANSACTION;\n` +
|
|
164
|
+
` IF OBJECT_ID('[${schema}].[${tableName}]', 'U') IS NOT NULL DROP TABLE ${obj(tableName)};\n` +
|
|
165
|
+
` EXEC sp_rename '${schema}.${shadow}', '${tableName}';\n` +
|
|
166
|
+
` EXEC('CREATE OR ALTER VIEW ${obj(viewName)} AS SELECT * FROM ${obj(tableName)}');\n` +
|
|
167
|
+
indexLine +
|
|
168
|
+
// Restore the connection default after the swap. SET options persist for the SESSION and the
|
|
169
|
+
// pool hands this same physical connection to unrelated requests, which would otherwise
|
|
170
|
+
// silently inherit XACT_ABORT ON — converting their recoverable statement-level errors into
|
|
171
|
+
// full transaction aborts, far from anything to do with materialization.
|
|
172
|
+
`COMMIT TRANSACTION;\nSET XACT_ABORT OFF;`,
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Builds the ordered SQL statements for a PostgreSQL full rebuild with atomic swap (plan §11.2) —
|
|
177
|
+
* the PG counterpart to {@link buildFullRebuildStatementsSQLServer}. Pure (no IO), unit-testable.
|
|
178
|
+
*
|
|
179
|
+
* Engine differences vs. SQL Server:
|
|
180
|
+
* - **Identifier quoting:** schema bare, object double-quoted (`__mj."materialized_x"`), matching the
|
|
181
|
+
* CodeGen provider's `QuoteSchema` convention so the view repoint references the same names.
|
|
182
|
+
* - **Surrogate (query case):** the synthetic surrogate is generated **as the first column** via
|
|
183
|
+
* `ROW_NUMBER() OVER ()` (a stable 1..N snapshot id; deterministic hashing is §5/Phase 3). It MUST
|
|
184
|
+
* be first because CodeGen prepends the surrogate, and PG's `CREATE OR REPLACE VIEW` is strict about
|
|
185
|
+
* column order (SQLSTATE 42P16) — an appended surrogate would break the repoint.
|
|
186
|
+
* - **Swap:** `CREATE OR REPLACE VIEW` (not `CREATE OR ALTER`), `ALTER TABLE ... RENAME TO` (not
|
|
187
|
+
* `sp_rename`), and `DROP TABLE IF EXISTS ... CASCADE` (PG blocks dropping a table a view depends on;
|
|
188
|
+
* CASCADE clears a transient wrapper-view dependency from a partially-failed prior run — the view is
|
|
189
|
+
* recreated within this sequence, so the stable contract is restored before the method returns).
|
|
190
|
+
*/
|
|
191
|
+
static buildFullRebuildStatementsPostgreSQL(opts) {
|
|
192
|
+
const { schema, tableName, viewName, sourceSelect, surrogateColumn, hashKeyColumns } = opts;
|
|
193
|
+
MaterializationRefresher.assertSafeObjectNames(schema, tableName, viewName);
|
|
194
|
+
const shadow = opts.shadowName ?? `${tableName}__shadow`;
|
|
195
|
+
const obj = (n) => `${schema}."${n}"`;
|
|
196
|
+
// Surrogate: a stable HASH of the key columns (Phase 3 keyed/aggregation materializations) when
|
|
197
|
+
// supplied; otherwise the synthetic ROW_NUMBER snapshot id. Kept FIRST for CREATE-OR-REPLACE-VIEW
|
|
198
|
+
// column-order stability (see the doc above).
|
|
199
|
+
const surrogateExpr = hashKeyColumns && hashKeyColumns.length
|
|
200
|
+
? MaterializationRefresher.buildHashKeyExpression(hashKeyColumns, true)
|
|
201
|
+
: 'ROW_NUMBER() OVER ()';
|
|
202
|
+
const createShadow = surrogateColumn
|
|
203
|
+
? `CREATE TABLE ${obj(shadow)} AS SELECT ${surrogateExpr} AS "${surrogateColumn}", src.* FROM (${sourceSelect}) AS src`
|
|
204
|
+
: `CREATE TABLE ${obj(shadow)} AS SELECT * FROM (${sourceSelect}) AS src`;
|
|
205
|
+
// CREATE TABLE AS carries no constraints, so restore the surrogate's UNIQUE index the query case relies
|
|
206
|
+
// on: PG's `INSERT … ON CONFLICT (surrogate)` (the Incremental upsert) REQUIRES a unique index on the
|
|
207
|
+
// conflict target — without it every rebuild would break the next incremental pass. Unnamed → PG
|
|
208
|
+
// auto-generates a collision-free name (avoids the 63-char identifier-truncation trap). Restored INSIDE
|
|
209
|
+
// the swap transaction below so a partial swap can never leave the canonical table un-indexed. (Base-view
|
|
210
|
+
// case has no surrogate + never runs incremental — nothing to add.)
|
|
211
|
+
const swapIndexLine = surrogateColumn
|
|
212
|
+
? ` CREATE UNIQUE INDEX ON ${obj(tableName)} ("${surrogateColumn}");\n`
|
|
213
|
+
: '';
|
|
214
|
+
return [
|
|
215
|
+
// 1) Build a fresh shadow (IF EXISTS clears a same-named leftover from a crashed prior run). There is
|
|
216
|
+
// deliberately NO interim view repoint here: the wrapper view stays pointed at the OLD canonical
|
|
217
|
+
// table until the atomic swap below, so readers see the COMPLETE old snapshot until commit — exactly
|
|
218
|
+
// matching the SQL Server path's atomicity. (An earlier design repointed the view at the shadow at
|
|
219
|
+
// this step, OUTSIDE the swap transaction; that statement auto-committed independently, so a
|
|
220
|
+
// rolled-back swap left the view pointing at the shadow, and the step-1 `DROP ... CASCADE` on a
|
|
221
|
+
// subsequent run could then take the wrapper view down with it.)
|
|
222
|
+
`DROP TABLE IF EXISTS ${obj(shadow)} CASCADE`,
|
|
223
|
+
createShadow,
|
|
224
|
+
// 2) ATOMIC swap in a SINGLE transaction (PG DDL is transactional) — drop the stale table, rename
|
|
225
|
+
// the shadow into the canonical name (kept stable for migration-reuse detection, §12), (re)create
|
|
226
|
+
// the wrapper view on the new table, and restore the surrogate index. The view is only ever
|
|
227
|
+
// created/repointed INSIDE this transaction, so readers see either the whole old snapshot or the
|
|
228
|
+
// whole new one. A mid-swap failure (lock timeout on RENAME, disk pressure on the index) rolls the
|
|
229
|
+
// ENTIRE swap back, leaving the OLD snapshot fully intact.
|
|
230
|
+
`BEGIN;\n` +
|
|
231
|
+
` DROP TABLE IF EXISTS ${obj(tableName)} CASCADE;\n` +
|
|
232
|
+
` ALTER TABLE ${obj(shadow)} RENAME TO "${tableName}";\n` +
|
|
233
|
+
` CREATE OR REPLACE VIEW ${obj(viewName)} AS SELECT * FROM ${obj(tableName)};\n` +
|
|
234
|
+
swapIndexLine +
|
|
235
|
+
`COMMIT;`,
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Selects the materializations due for refresh: those with no `NextRefreshAt` (never run) or whose
|
|
240
|
+
* `NextRefreshAt` is at/before `now`. Pure (unit-testable); the caller supplies the candidate rows
|
|
241
|
+
* (e.g. all non-disabled, scheduled materializations).
|
|
242
|
+
*/
|
|
243
|
+
static filterDue(rows, now) {
|
|
244
|
+
return rows.filter((r) => !r.NextRefreshAt || new Date(r.NextRefreshAt) <= now);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Full-rebuild refresh of a single materialized result, then updates LastRefreshedAt / RowCount /
|
|
248
|
+
* Status='Active' (and `NextRefreshAt` when provided via options). Returns a structured result
|
|
249
|
+
* rather than throwing (errors are logged + reported).
|
|
250
|
+
*/
|
|
251
|
+
async RefreshOne(matResult, contextUser, provider, options) {
|
|
252
|
+
// Run-unique shadow-table name so two concurrent refreshes of THIS materialization never collide on the
|
|
253
|
+
// shadow (see makeShadowTableName). Declared before the try so the catch below can best-effort drop it.
|
|
254
|
+
const runShadowName = MaterializationRefresher.makeShadowTableName();
|
|
255
|
+
try {
|
|
256
|
+
// Refuse to refresh a row held for review or disabled — a successful refresh below sets
|
|
257
|
+
// Status='Active', which would SILENTLY clear a DriftHold (§13/§17.2: a drifted materialization
|
|
258
|
+
// is held for human review, not auto-rebuilt) or re-activate a Disabled one. The scheduled sweep
|
|
259
|
+
// already filters these out; this guards a manual "refresh now" path from overriding the state.
|
|
260
|
+
if (matResult.Status === 'DriftHold' || matResult.Status === 'Disabled') {
|
|
261
|
+
return { Success: false, ErrorMessage: `Materialization ${matResult.ID} is ${matResult.Status} — refusing to refresh (resolve/re-enable it first to clear the status).` };
|
|
262
|
+
}
|
|
263
|
+
// Security: schema/table/view names are interpolated into refresh DDL (EXEC / sp_rename / CREATE VIEW
|
|
264
|
+
// / RENAME TO) and originate from the WRITABLE `MJ: Materialized Results` row. Validate them as plain
|
|
265
|
+
// SQL identifiers BEFORE any statement is built (all builders — full/incremental/external — dispatch
|
|
266
|
+
// from here), so a tampered row can never drive the privileged refresh job to run arbitrary DDL. The
|
|
267
|
+
// throw is caught below and reported as a refresh failure.
|
|
268
|
+
MaterializationRefresher.assertSafeObjectNames(matResult.SchemaName, matResult.TableName, matResult.ViewName);
|
|
269
|
+
const exec = provider;
|
|
270
|
+
const isPostgres = exec.PlatformKey === 'postgresql';
|
|
271
|
+
let rowCount;
|
|
272
|
+
// Full-rebuild source fingerprint (watermark + source count), captured BEFORE the rebuild reads the
|
|
273
|
+
// source but held in a LOCAL — it is applied to matResult ONLY on the success path below, NEVER on a
|
|
274
|
+
// failure path. Persisting an advanced watermark for a rebuild that then threw would leave the next
|
|
275
|
+
// incremental pass filtering `__mj_UpdatedAt > <watermark ahead of the actual data>`, permanently
|
|
276
|
+
// skipping every row that changed before it (silent staleness). null ⇒ not a single-__mj_UpdatedAt
|
|
277
|
+
// source ⇒ keep full-rebuilding (correct by construction).
|
|
278
|
+
let fullRebuildFingerprint = null;
|
|
279
|
+
// Forced-full-rebuild cadence: true only when the incremental path handled this refresh. Drives the
|
|
280
|
+
// RefreshesSinceFullRebuild counter in the success block (incremented on incremental, reset on any
|
|
281
|
+
// full rebuild — external, base-view, first-run, count-drop fallback, or the forced periodic rebuild).
|
|
282
|
+
let ranIncremental = false;
|
|
283
|
+
// Phase 1.5 (EDS composition): an EDS-backed source (external entity base view OR external
|
|
284
|
+
// query) can't be read via local SQL — fetch its rows through the EDS driver and persist
|
|
285
|
+
// them. Local sources take the SQL path below.
|
|
286
|
+
const externalEntity = this.resolveExternalEntity(matResult, provider);
|
|
287
|
+
// Query source: load the stored Query ONCE here, then reuse it below (external rebuild OR the
|
|
288
|
+
// local source-SELECT) instead of loading it a second time in resolveSourceSelect.
|
|
289
|
+
const sourceQuery = externalEntity ? null : await this.resolveSourceQuery(matResult, contextUser, provider);
|
|
290
|
+
if (externalEntity) {
|
|
291
|
+
// SECURITY (Leak 1 runtime gate): refuse to (re)populate a local mirror of an external
|
|
292
|
+
// read-RLS-protected entity. Its rows are read-REFUSED live under RLS (MJ can't enforce RLS on a
|
|
293
|
+
// remote system), and a local mirror is readable UNSCOPED via any raw query over the wrapper view.
|
|
294
|
+
// The CodeGen mint/drift gates also cover this, but they run only per codegen pass; this runtime
|
|
295
|
+
// refusal closes the window between an entity gaining RLS and the next codegen run, during which
|
|
296
|
+
// the scheduled sweep would otherwise keep refilling the mirror with the now-protected rows.
|
|
297
|
+
// Both fence layers, not just role RLS: an entity fenced ONLY by an API-key row filter is just
|
|
298
|
+
// as unsafe to mirror, and checking only the role layer would leave this window open for it —
|
|
299
|
+
// the very window this gate exists to close. Symmetric with CodeGen's mint/drift gates.
|
|
300
|
+
const apiKeyTargets = await this.loadAPIKeyRowFilterTargets(provider, contextUser);
|
|
301
|
+
if (MaterializationRefresher.entityHasRowLevelRestriction(externalEntity, apiKeyTargets)) {
|
|
302
|
+
return await this.failRefresh(matResult, provider, options, `Refusing to refresh base-view materialization of external row-restricted entity "${externalEntity.Name}" (role RLS and/or an API-key row filter): a local mirror would expose rows the live path refuses.`);
|
|
303
|
+
}
|
|
304
|
+
const ext = await this.rebuildFromExternalEntity(matResult, externalEntity, exec, isPostgres, contextUser, provider, runShadowName);
|
|
305
|
+
if (!ext.Success)
|
|
306
|
+
return await this.failRefresh(matResult, provider, options, ext.ErrorMessage ?? `External entity rebuild failed for materialization ${matResult.ID}`);
|
|
307
|
+
rowCount = ext.RowCount ?? 0;
|
|
308
|
+
}
|
|
309
|
+
else if (sourceQuery?.externalSql) {
|
|
310
|
+
const ext = await this.rebuildFromExternalQuery(matResult, sourceQuery.query, sourceQuery.externalSql, exec, isPostgres, contextUser, provider, runShadowName);
|
|
311
|
+
if (!ext.Success)
|
|
312
|
+
return await this.failRefresh(matResult, provider, options, ext.ErrorMessage ?? `External query rebuild failed for materialization ${matResult.ID}`);
|
|
313
|
+
rowCount = ext.RowCount ?? 0;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
const sourceSelect = await this.resolveSourceSelect(matResult, contextUser, provider, isPostgres, sourceQuery?.query);
|
|
317
|
+
if (!sourceSelect) {
|
|
318
|
+
return await this.failRefresh(matResult, provider, options, `Could not resolve a source SELECT for materialization ${matResult.ID} (${matResult.SourceType})`);
|
|
319
|
+
}
|
|
320
|
+
const surrogateColumn = matResult.SourceType === 'Query' ? MATERIALIZATION_SURROGATE_COLUMN : undefined;
|
|
321
|
+
// Phase 3: a keyed materialization (KeyColumns metadata set) hashes those columns into the
|
|
322
|
+
// surrogate (the stable incremental-refresh match key); otherwise the synthetic surrogate.
|
|
323
|
+
const hashKeyColumns = MaterializationRefresher.parseKeyColumns(matResult.KeyColumns);
|
|
324
|
+
// Phase 3/4: for an eligible keyed aggregation (RefreshStrategy = DirtyGroupRecompute or
|
|
325
|
+
// Incremental, with a watermark baseline), incrementally refresh only the changed groups IN
|
|
326
|
+
// PLACE. Falls back to a full rebuild on the first run (no baseline), a source-count drop
|
|
327
|
+
// (deletes can't be localized), or any ineligibility — the §10 refuse-under-uncertainty bias.
|
|
328
|
+
// Forced-full-rebuild cadence: after N consecutive incremental refreshes, force a full rebuild to
|
|
329
|
+
// reconcile any drift a balanced delete+insert (net-zero source count) left uncaught by the
|
|
330
|
+
// delete-detection guard. Signalled into tryRefreshIncremental so it declines → full rebuild here.
|
|
331
|
+
const forceFullRebuild = MaterializationRefresher.shouldForceFullRebuild(matResult.RefreshesSinceFullRebuild);
|
|
332
|
+
const incremental = await this.tryRefreshIncremental(matResult, sourceSelect, hashKeyColumns, surrogateColumn, exec, isPostgres, forceFullRebuild);
|
|
333
|
+
if (incremental.handled) {
|
|
334
|
+
rowCount = incremental.rowCount;
|
|
335
|
+
ranIncremental = true;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const buildOpts = {
|
|
339
|
+
schema: matResult.SchemaName,
|
|
340
|
+
tableName: matResult.TableName,
|
|
341
|
+
viewName: matResult.ViewName,
|
|
342
|
+
sourceSelect,
|
|
343
|
+
surrogateColumn,
|
|
344
|
+
hashKeyColumns,
|
|
345
|
+
shadowName: runShadowName,
|
|
346
|
+
};
|
|
347
|
+
const statements = isPostgres
|
|
348
|
+
? MaterializationRefresher.buildFullRebuildStatementsPostgreSQL(buildOpts)
|
|
349
|
+
: MaterializationRefresher.buildFullRebuildStatementsSQLServer(buildOpts);
|
|
350
|
+
// Compute the source fingerprint (watermark + row count) BEFORE the rebuild reads the
|
|
351
|
+
// source, so the NEXT refresh can go incremental. Order matters: computing AFTER the read
|
|
352
|
+
// would absorb a concurrent-update timestamp Tu into the watermark while the shadow still
|
|
353
|
+
// holds that row's OLD value — the strict `__mj_UpdatedAt > watermark` incremental filter
|
|
354
|
+
// would then exclude it forever, leaving its group's aggregate permanently stale. Computing
|
|
355
|
+
// before means the watermark can never exceed a value we actually persisted; at worst a row
|
|
356
|
+
// updated DURING the rebuild is harmlessly RE-processed next pass (recompute is idempotent).
|
|
357
|
+
// The source table is only read (never mutated) by the rebuild, so it's identical here.
|
|
358
|
+
// It is only APPLIED to matResult on success (below) — a thrown rebuild leaves it unpersisted.
|
|
359
|
+
fullRebuildFingerprint = await this.computeSourceFingerprint(matResult, sourceSelect, exec, isPostgres);
|
|
360
|
+
for (const sql of statements) {
|
|
361
|
+
await exec.ExecuteSQL(sql);
|
|
362
|
+
}
|
|
363
|
+
rowCount = await this.countMaterialized(matResult, exec, isPostgres);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
matResult.Status = 'Active';
|
|
367
|
+
matResult.LastRefreshedAt = new Date();
|
|
368
|
+
matResult.RowCount = rowCount;
|
|
369
|
+
// Apply the full-rebuild fingerprint ONLY here, after the rebuild succeeded (see the local's note).
|
|
370
|
+
if (fullRebuildFingerprint) {
|
|
371
|
+
matResult.Watermark = fullRebuildFingerprint.watermark;
|
|
372
|
+
matResult.SourceRowCount = fullRebuildFingerprint.count;
|
|
373
|
+
}
|
|
374
|
+
// Forced-full-rebuild cadence: count consecutive incremental refreshes, resetting to 0 on any full
|
|
375
|
+
// rebuild (so the counter measures how long we've gone WITHOUT a full reconcile). At the threshold the
|
|
376
|
+
// next refresh is forced to full-rebuild (see above), which resets it here.
|
|
377
|
+
matResult.RefreshesSinceFullRebuild = MaterializationRefresher.nextRefreshesSinceFullRebuild(matResult.RefreshesSinceFullRebuild, ranIncremental);
|
|
378
|
+
if (options && Object.prototype.hasOwnProperty.call(options, 'nextRefreshAt')) {
|
|
379
|
+
matResult.NextRefreshAt = options.nextRefreshAt ?? null;
|
|
380
|
+
}
|
|
381
|
+
// Persist the terminal state with a GUARDED conditional UPDATE rather than BaseEntity.Save(). Save
|
|
382
|
+
// cannot express "only if still Active": its generated spUpdate binds EVERY field (Status = ISNULL(
|
|
383
|
+
// @Status, Status)) with a PK-only WHERE, so the stale in-memory Status='Active' loaded at the start
|
|
384
|
+
// of this refresh would silently CLOBBER a DriftHold/Disabled that drift-detection set concurrently
|
|
385
|
+
// DURING the (possibly long) rebuild — defeating the "hold for human review" guarantee (§13/§17.2).
|
|
386
|
+
// The guarded UPDATE writes the whole success state atomically only while the row is still Active.
|
|
387
|
+
const setNextRefresh = !!(options && Object.prototype.hasOwnProperty.call(options, 'nextRefreshAt'));
|
|
388
|
+
const applied = await this.persistTerminalStateGuarded(matResult, exec, isPostgres, setNextRefresh);
|
|
389
|
+
if (!applied) {
|
|
390
|
+
// Lost the race: the row was set to DriftHold/Disabled during this refresh. The snapshot WAS
|
|
391
|
+
// rebuilt (the swap committed), but we respect the hold — the guarded UPDATE was a no-op, so
|
|
392
|
+
// Status/Watermark/bookkeeping stay as the concurrent writer left them (the next ALLOWED refresh
|
|
393
|
+
// re-scans from the unchanged watermark; recompute is idempotent). Report the work as done so the
|
|
394
|
+
// sweep doesn't treat it as a hard failure and back off.
|
|
395
|
+
LogStatus(`MaterializationRefresher: materialization ${matResult.ID} was concurrently held/disabled during refresh — snapshot rebuilt but NOT re-activated (respecting the hold).`);
|
|
396
|
+
return { Success: true, RowCount: rowCount };
|
|
397
|
+
}
|
|
398
|
+
return { Success: true, RowCount: rowCount };
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
402
|
+
LogError(`MaterializationRefresher.RefreshOne failed for materialization ${matResult.ID}: ${msg}`);
|
|
403
|
+
// Best-effort: drop this run's shadow so a failed rebuild leaves no orphan table. (On success the
|
|
404
|
+
// shadow is renamed INTO the canonical name, so there's nothing to drop.) Never let a cleanup error
|
|
405
|
+
// mask the original failure. `exec`/`isPostgres` are re-derived because they're scoped to the try.
|
|
406
|
+
await this.dropShadowTableBestEffort(provider, matResult.SchemaName, runShadowName);
|
|
407
|
+
// A batch that aborted mid-swap never reached its trailing `SET XACT_ABORT OFF`. Best-effort reset
|
|
408
|
+
// (no-op on PG, which has no such setting). Note this is a genuine BEST effort, not a guarantee:
|
|
409
|
+
// the pool hands out any idle connection, so this may well reset a different one than the batch
|
|
410
|
+
// poisoned. Harmless either way — OFF is the connection default, so resetting an innocent
|
|
411
|
+
// connection is a no-op. The trailing OFF inside each batch is the load-bearing half, since only
|
|
412
|
+
// that one is guaranteed to run on the same connection.
|
|
413
|
+
await this.resetXactAbortBestEffort(provider);
|
|
414
|
+
return await this.failRefresh(matResult, provider, options, msg);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Drops a run's shadow table if it exists, swallowing any error (used only on the RefreshOne failure path so
|
|
419
|
+
* a crashed/failed rebuild leaves no orphan). Uses IF EXISTS so it's a no-op when the shadow was never
|
|
420
|
+
* created or was already renamed into the canonical table on success.
|
|
421
|
+
*/
|
|
422
|
+
/**
|
|
423
|
+
* Restores `XACT_ABORT` to the connection default after a failed refresh, swallowing any error. SQL Server
|
|
424
|
+
* only — PostgreSQL has no equivalent session setting, so this is a no-op there. Needed because a batch
|
|
425
|
+
* that aborts mid-swap never reaches the trailing `SET XACT_ABORT OFF` in its own statement list.
|
|
426
|
+
*/
|
|
427
|
+
async resetXactAbortBestEffort(provider) {
|
|
428
|
+
try {
|
|
429
|
+
const exec = provider;
|
|
430
|
+
if (exec.PlatformKey === 'postgresql')
|
|
431
|
+
return;
|
|
432
|
+
await exec.ExecuteSQL('SET XACT_ABORT OFF');
|
|
433
|
+
}
|
|
434
|
+
catch (resetErr) {
|
|
435
|
+
LogError(`MaterializationRefresher: best-effort XACT_ABORT reset failed (ignored): ${resetErr instanceof Error ? resetErr.message : String(resetErr)}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async dropShadowTableBestEffort(provider, schema, shadowName) {
|
|
439
|
+
// Fail CLOSED. This runs from the RefreshOne catch block — which is exactly where
|
|
440
|
+
// assertSafeObjectNames may have just REJECTED these very names. Interpolating them here would let the
|
|
441
|
+
// guard's own rejection be the thing that routes a tampered SchemaName into privileged DDL, inverting
|
|
442
|
+
// the guarantee the guard exists to provide. Re-check and decline instead; declining drops nothing
|
|
443
|
+
// real, because the assertion fires before any shadow table could have been created.
|
|
444
|
+
if (!MaterializationRefresher.isSafeObjectName(schema) || !MaterializationRefresher.isSafeObjectName(shadowName)) {
|
|
445
|
+
LogError(`MaterializationRefresher: refusing best-effort shadow cleanup — unsafe identifier(s) schema=${JSON.stringify(schema)} shadow=${JSON.stringify(shadowName)}. No shadow table can exist under these names.`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const exec = provider;
|
|
450
|
+
const isPostgres = exec.PlatformKey === 'postgresql';
|
|
451
|
+
const sql = isPostgres
|
|
452
|
+
? `DROP TABLE IF EXISTS ${schema}."${shadowName}" CASCADE`
|
|
453
|
+
: `IF OBJECT_ID('[${schema}].[${shadowName}]', 'U') IS NOT NULL DROP TABLE [${schema}].[${shadowName}]`;
|
|
454
|
+
await exec.ExecuteSQL(sql);
|
|
455
|
+
}
|
|
456
|
+
catch (cleanupErr) {
|
|
457
|
+
LogError(`MaterializationRefresher: best-effort shadow cleanup for '${shadowName}' failed (ignored): ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Common failure exit for RefreshOne. Advances `NextRefreshAt` so a persistently-failing materialization
|
|
462
|
+
* backs off to its configured cadence instead of being retried on every sweep (the driver's filterDue
|
|
463
|
+
* treats an unchanged past/null NextRefreshAt as still-due — an unbounded rebuild/read storm against the
|
|
464
|
+
* source DB). Called only from PRE-SUCCESS failure paths: thrown errors caught in RefreshOne AND the
|
|
465
|
+
* returned {Success:false} paths for external-entity/query read failure and unresolvable source. (The
|
|
466
|
+
* post-success Save-failure path deliberately does NOT use this — see the comment there — because matResult
|
|
467
|
+
* would carry the full success state and re-Saving it would contradict the reported failure.)
|
|
468
|
+
*
|
|
469
|
+
* Only NextRefreshAt is written, via the SAME guarded conditional UPDATE the success path uses
|
|
470
|
+
* (`WHERE Status NOT IN ('DriftHold','Disabled')`) — so a concurrent hold/disable is genuinely never
|
|
471
|
+
* clobbered. (BaseEntity.Save could NOT guarantee this: its spUpdate binds every field with ISNULL + a
|
|
472
|
+
* PK-only WHERE, so the stale in-memory Status='Active' would have overwritten a concurrent hold even though
|
|
473
|
+
* this method never assigns Status — the previous "not dirtying Status keeps it safe" reasoning was wrong.)
|
|
474
|
+
* Best-effort and non-throwing: an update failure is logged, not thrown (the next sweep re-attempts). No-op
|
|
475
|
+
* when the caller supplied no schedule (a manual "refresh now" with no options).
|
|
476
|
+
*/
|
|
477
|
+
async failRefresh(matResult, provider, options, errorMessage) {
|
|
478
|
+
if (options && Object.prototype.hasOwnProperty.call(options, 'nextRefreshAt')) {
|
|
479
|
+
try {
|
|
480
|
+
const exec = provider;
|
|
481
|
+
const isPostgres = exec.PlatformKey === 'postgresql';
|
|
482
|
+
const q = (n) => MaterializationRefresher.quoteIdent(n, isPostgres);
|
|
483
|
+
const nextLit = options.nextRefreshAt ? MaterializationRefresher.sqlDateTimeLiteral(options.nextRefreshAt) : 'NULL';
|
|
484
|
+
await this.execGuardedMaterializedResultUpdate(matResult, exec, isPostgres, `${q('NextRefreshAt')} = ${nextLit}`);
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
LogError(`MaterializationRefresher: could not persist NextRefreshAt backoff for ${matResult.ID}: ${e instanceof Error ? e.message : String(e)}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { Success: false, ErrorMessage: errorMessage };
|
|
491
|
+
}
|
|
492
|
+
/** Core-schema reference for the `MJ: Materialized Results` metadata table. NOT `matResult.SchemaName` —
|
|
493
|
+
* that is the SNAPSHOT table's schema, which differs for a base-view materialization of a non-core entity.
|
|
494
|
+
* The metadata row lives where the entity is defined (`__mj`), read from the entity metadata. */
|
|
495
|
+
materializedResultTableRef(matResult, isPostgres) {
|
|
496
|
+
const schema = matResult.EntityInfo.SchemaName;
|
|
497
|
+
const table = matResult.EntityInfo.BaseTable;
|
|
498
|
+
return isPostgres ? `${schema}."${table}"` : `[${schema}].[${table}]`;
|
|
499
|
+
}
|
|
500
|
+
/** Persists the terminal SUCCESS state (Status='Active' + LastRefreshedAt/RowCount/Watermark/SourceRowCount/
|
|
501
|
+
* RefreshesSinceFullRebuild [+ NextRefreshAt]) via {@link execGuardedMaterializedResultUpdate}. Reads the
|
|
502
|
+
* values from `matResult` (RefreshOne has already assigned them in-memory). Returns false when the row was
|
|
503
|
+
* concurrently held/disabled (the UPDATE matched 0 rows). */
|
|
504
|
+
async persistTerminalStateGuarded(matResult, exec, isPostgres, setNextRefresh) {
|
|
505
|
+
const q = (n) => MaterializationRefresher.quoteIdent(n, isPostgres);
|
|
506
|
+
const dtLit = (d) => (d ? MaterializationRefresher.sqlDateTimeLiteral(d) : 'NULL');
|
|
507
|
+
const intLit = (n) => (n == null ? 'NULL' : String(Math.trunc(Number(n))));
|
|
508
|
+
const sets = [
|
|
509
|
+
`${q('Status')} = 'Active'`,
|
|
510
|
+
`${q('LastRefreshedAt')} = ${dtLit(matResult.LastRefreshedAt)}`,
|
|
511
|
+
`${q('RowCount')} = ${intLit(matResult.RowCount)}`,
|
|
512
|
+
`${q('Watermark')} = ${dtLit(matResult.Watermark)}`,
|
|
513
|
+
`${q('SourceRowCount')} = ${intLit(matResult.SourceRowCount)}`,
|
|
514
|
+
`${q('RefreshesSinceFullRebuild')} = ${intLit(matResult.RefreshesSinceFullRebuild)}`,
|
|
515
|
+
];
|
|
516
|
+
if (setNextRefresh)
|
|
517
|
+
sets.push(`${q('NextRefreshAt')} = ${dtLit(matResult.NextRefreshAt)}`);
|
|
518
|
+
return this.execGuardedMaterializedResultUpdate(matResult, exec, isPostgres, sets.join(', '));
|
|
519
|
+
}
|
|
520
|
+
/** Runs an UPDATE against the MaterializedResult metadata row guarded by `Status NOT IN ('DriftHold',
|
|
521
|
+
* 'Disabled')` — the atomic primitive that makes a status/state write unable to clobber a concurrently-set
|
|
522
|
+
* hold. Returns true iff exactly the target row was updated (i.e. it was still Active). The affected count is
|
|
523
|
+
* read back cross-engine: SQL Server via `@@ROWCOUNT`, PostgreSQL via a `RETURNING`-counting CTE. `matResult.ID`
|
|
524
|
+
* is a trusted entity PK (UUID), interpolated the same way the refresher interpolates its other metadata. */
|
|
525
|
+
async execGuardedMaterializedResultUpdate(matResult, exec, isPostgres, setClause) {
|
|
526
|
+
const tbl = this.materializedResultTableRef(matResult, isPostgres);
|
|
527
|
+
const q = (n) => MaterializationRefresher.quoteIdent(n, isPostgres);
|
|
528
|
+
const guard = `${q('ID')} = '${matResult.ID}' AND ${q('Status')} NOT IN ('DriftHold', 'Disabled')`;
|
|
529
|
+
const sql = isPostgres
|
|
530
|
+
? `WITH upd AS (UPDATE ${tbl} SET ${setClause} WHERE ${guard} RETURNING 1) SELECT COUNT(*)::int AS n FROM upd`
|
|
531
|
+
: `UPDATE ${tbl} SET ${setClause} WHERE ${guard}; SELECT @@ROWCOUNT AS n`;
|
|
532
|
+
const rows = await exec.ExecuteSQL(sql);
|
|
533
|
+
return Number(rows?.[0]?.n ?? 0) > 0;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Phase 3/4: attempt an incremental in-place refresh of a keyed aggregation, recomputing only the
|
|
537
|
+
* groups whose source rows changed since the watermark. Two strategies share ALL eligibility/guard
|
|
538
|
+
* logic and differ only in how the recomputed groups are applied:
|
|
539
|
+
* - `DirtyGroupRecompute` (Phase 3) — DELETE the dirty groups then INSERT their fresh values;
|
|
540
|
+
* - `Incremental` (Phase 4) — UPSERT (MERGE / INSERT…ON CONFLICT) the fresh values onto the
|
|
541
|
+
* surrogate key, updating a surviving group's row in place (no churn). CodeGen assigns this to
|
|
542
|
+
* keyed single-source ADDITIVE aggregations.
|
|
543
|
+
* Returns `{handled:true}` when it ran; `{handled:false}` (caller then full-rebuilds) on any
|
|
544
|
+
* ineligibility OR a tripped guard. Guards (conservative — §10 refuse-under-uncertainty):
|
|
545
|
+
* - opt-in strategy for a keyed Query aggregation with a surrogate;
|
|
546
|
+
* - a watermark baseline must exist (first run full-rebuilds to establish it);
|
|
547
|
+
* - the source must be a SINGLE table exposing `__mj_UpdatedAt` and all key columns;
|
|
548
|
+
* - the current source row count must not be LOWER than the last (a net decrease = deletes → full
|
|
549
|
+
* rebuild self-heals).
|
|
550
|
+
* On success it advances the watermark + source-count on `matResult` (persisted by the caller's Save).
|
|
551
|
+
*/
|
|
552
|
+
async tryRefreshIncremental(matResult, sourceSelect, hashKeyColumns, surrogateColumn, exec, isPostgres, forceFullRebuild) {
|
|
553
|
+
const notHandled = { handled: false, rowCount: 0 };
|
|
554
|
+
// Periodic full-rebuild reconcile (RefreshOne's forced-cadence): decline so the caller full-rebuilds,
|
|
555
|
+
// which reconciles any balanced-delete drift the incremental delete-detection guard can't catch.
|
|
556
|
+
if (forceFullRebuild)
|
|
557
|
+
return notHandled;
|
|
558
|
+
const strategy = matResult.RefreshStrategy;
|
|
559
|
+
if (strategy !== 'DirtyGroupRecompute' && strategy !== 'Incremental')
|
|
560
|
+
return notHandled;
|
|
561
|
+
if (matResult.SourceType !== 'Query')
|
|
562
|
+
return notHandled;
|
|
563
|
+
if (!hashKeyColumns || hashKeyColumns.length === 0 || !surrogateColumn)
|
|
564
|
+
return notHandled;
|
|
565
|
+
if (matResult.Watermark == null)
|
|
566
|
+
return notHandled; // no baseline yet → full rebuild establishes it
|
|
567
|
+
const src = this.resolveSingleSourceTable(sourceSelect, exec.PlatformKey);
|
|
568
|
+
if (!src)
|
|
569
|
+
return notHandled; // not a single-table source → can't localize with one watermark
|
|
570
|
+
const updatedAtColumn = '__mj_UpdatedAt';
|
|
571
|
+
const sourceCols = await this.getTableColumns(src.schema, src.table, exec);
|
|
572
|
+
const sourceColSet = new Set(sourceCols.map((c) => c.toLowerCase()));
|
|
573
|
+
if (!sourceColSet.has(updatedAtColumn.toLowerCase()))
|
|
574
|
+
return notHandled; // no watermark column on source
|
|
575
|
+
if (!hashKeyColumns.every((k) => sourceColSet.has(k.name.toLowerCase())))
|
|
576
|
+
return notHandled; // key col not a plain source column
|
|
577
|
+
// Delete-detection guard: a NET drop in source rows means deletes we can't localize → full rebuild.
|
|
578
|
+
// This catches only a net COUNT decrease. A delete BALANCED by an insert in the same window (delete
|
|
579
|
+
// from group A, insert into group B — count unchanged) does NOT trip the guard, and group A is not
|
|
580
|
+
// recomputed unless another change touches it, so A's aggregate would stay stale until then. That
|
|
581
|
+
// residual drift is now bounded by the forced-full-rebuild cadence (RefreshesSinceFullRebuild +
|
|
582
|
+
// FULL_REBUILD_EVERY_N_INCREMENTAL_REFRESHES, applied in RefreshOne): every N incremental refreshes a
|
|
583
|
+
// full rebuild reconciles the whole materialization, so a balanced-delete-stale group self-heals within
|
|
584
|
+
// at most N cycles. Authors of very delete-heavy sources can still pin RefreshStrategy='FullRebuild'.
|
|
585
|
+
const fp = await this.probeSourceFingerprint(src.schema, src.table, updatedAtColumn, exec, isPostgres);
|
|
586
|
+
// No baseline source count → we CAN'T run the delete-detection guard, so we can't rule out deletions
|
|
587
|
+
// since the last refresh → fall back to a full rebuild (which self-heals). This can happen if a
|
|
588
|
+
// Watermark was established before SourceRowCount existed/was populated (e.g. the column added NULL).
|
|
589
|
+
// Normally computeSourceFingerprint sets both together, so this is the defensive edge, not the norm.
|
|
590
|
+
if (matResult.SourceRowCount == null)
|
|
591
|
+
return notHandled;
|
|
592
|
+
if (fp.count < matResult.SourceRowCount)
|
|
593
|
+
return notHandled;
|
|
594
|
+
// Data columns = the materialized table's columns minus the surrogate, in ordinal order.
|
|
595
|
+
const matCols = await this.getTableColumns(matResult.SchemaName, matResult.TableName, exec);
|
|
596
|
+
const dataColumns = matCols.filter((c) => c.toLowerCase() !== surrogateColumn.toLowerCase());
|
|
597
|
+
if (dataColumns.length === 0)
|
|
598
|
+
return notHandled;
|
|
599
|
+
const opts = {
|
|
600
|
+
schema: matResult.SchemaName, tableName: matResult.TableName,
|
|
601
|
+
sourceSchema: src.schema, sourceTable: src.table,
|
|
602
|
+
keyColumns: hashKeyColumns, aggregationSelect: sourceSelect,
|
|
603
|
+
surrogateColumn, dataColumns, updatedAtColumn,
|
|
604
|
+
watermarkSql: MaterializationRefresher.sqlDateTimeLiteral(matResult.Watermark),
|
|
605
|
+
};
|
|
606
|
+
if (strategy === 'Incremental') {
|
|
607
|
+
// Single atomic UPSERT (MERGE / INSERT…ON CONFLICT) — no intermediate state for a reader to see.
|
|
608
|
+
const statements = isPostgres
|
|
609
|
+
? MaterializationRefresher.buildIncrementalMergeStatementsPostgreSQL(opts)
|
|
610
|
+
: MaterializationRefresher.buildIncrementalMergeStatementsSQLServer(opts);
|
|
611
|
+
for (const sql of statements) {
|
|
612
|
+
await exec.ExecuteSQL(sql);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
// DirtyGroupRecompute = DELETE the dirty groups, then INSERT their fresh values. Run BOTH inside
|
|
617
|
+
// ONE transaction so a concurrent reader of the wrapper view never lands in the deleted-but-not-
|
|
618
|
+
// yet-reinserted window (which would return those groups as missing / undercounted). SET XACT_ABORT
|
|
619
|
+
// ON (SQL Server) rolls the transaction back on a mid-batch error instead of leaving it open.
|
|
620
|
+
const dg = isPostgres
|
|
621
|
+
? MaterializationRefresher.buildDirtyGroupRecomputeStatementsPostgreSQL(opts)
|
|
622
|
+
: MaterializationRefresher.buildDirtyGroupRecomputeStatementsSQLServer(opts);
|
|
623
|
+
const batch = isPostgres
|
|
624
|
+
? `BEGIN;\n${dg.join(';\n')};\nCOMMIT;`
|
|
625
|
+
: `SET XACT_ABORT ON;\nBEGIN TRANSACTION;\n${dg.join(';\n')};\nCOMMIT TRANSACTION;\nSET XACT_ABORT OFF;`;
|
|
626
|
+
await exec.ExecuteSQL(batch);
|
|
627
|
+
}
|
|
628
|
+
// Count FIRST, then advance the fingerprint — so that if countMaterialized throws (transient DB error)
|
|
629
|
+
// the exception reaches RefreshOne's catch → failRefresh with matResult's Watermark/SourceRowCount
|
|
630
|
+
// still UNMUTATED. Advancing them before the count would let failRefresh persist a moved-forward
|
|
631
|
+
// watermark for a refresh reported {Success:false} (the same watermark-on-failure hazard the
|
|
632
|
+
// full-rebuild path avoids via a success-only local). The merge/dirty-group SQL has already committed,
|
|
633
|
+
// so a failed count merely defers the fingerprint advance to the next pass (an idempotent re-process).
|
|
634
|
+
const rowCount = await this.countMaterialized(matResult, exec, isPostgres);
|
|
635
|
+
// Advance the fingerprint (new high-water + source count) for the next incremental pass.
|
|
636
|
+
matResult.Watermark = fp.watermark;
|
|
637
|
+
matResult.SourceRowCount = fp.count;
|
|
638
|
+
return { handled: true, rowCount };
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Phase 3: compute a Query materialization's source fingerprint (watermark = MAX(__mj_UpdatedAt) + source
|
|
642
|
+
* row count) so a subsequent DirtyGroupRecompute pass has a baseline. Returns null — meaning no baseline,
|
|
643
|
+
* so the materialization keeps full-rebuilding (correct by construction) — unless the source is a single
|
|
644
|
+
* table exposing `__mj_UpdatedAt`. PURE w.r.t. matResult: it does NOT mutate the entity; the caller applies
|
|
645
|
+
* the returned fingerprint ONLY on the success path, so a failed rebuild never advances the persisted
|
|
646
|
+
* watermark past data it didn't actually materialize.
|
|
647
|
+
*/
|
|
648
|
+
async computeSourceFingerprint(matResult, sourceSelect, exec, isPostgres) {
|
|
649
|
+
if (matResult.SourceType !== 'Query')
|
|
650
|
+
return null;
|
|
651
|
+
const src = this.resolveSingleSourceTable(sourceSelect, exec.PlatformKey);
|
|
652
|
+
if (!src)
|
|
653
|
+
return null;
|
|
654
|
+
// The single source must be a BASE TABLE, not a VIEW. A view can expose a `__mj_UpdatedAt` column, but that
|
|
655
|
+
// value doesn't track changes in the view's UNDERLYING tables — so a watermark taken from a view would let
|
|
656
|
+
// the incremental pass MISS changed groups. Decline (→ null → keep full-rebuilding) unless it's a base table.
|
|
657
|
+
if (!(await this.sourceIsBaseTable(src.schema, src.table, exec)))
|
|
658
|
+
return null;
|
|
659
|
+
const updatedAtColumn = '__mj_UpdatedAt';
|
|
660
|
+
const cols = await this.getTableColumns(src.schema, src.table, exec);
|
|
661
|
+
if (!cols.some((c) => c.toLowerCase() === updatedAtColumn.toLowerCase()))
|
|
662
|
+
return null;
|
|
663
|
+
return await this.probeSourceFingerprint(src.schema, src.table, updatedAtColumn, exec, isPostgres);
|
|
664
|
+
}
|
|
665
|
+
/** Extracts the single source table of an aggregation SELECT, or null if it isn't exactly one table. */
|
|
666
|
+
resolveSingleSourceTable(sql, platformKey) {
|
|
667
|
+
const refs = SQLParser.ExtractTableRefs(sql, GetDialect(platformKey ?? 'sqlserver'));
|
|
668
|
+
if (!refs || refs.length !== 1 || !refs[0].TableName)
|
|
669
|
+
return null;
|
|
670
|
+
return { schema: refs[0].SchemaName, table: refs[0].TableName };
|
|
671
|
+
}
|
|
672
|
+
/** Column names of a table (ordinal order) via INFORMATION_SCHEMA (identical query on both engines). */
|
|
673
|
+
async getTableColumns(schema, table, exec) {
|
|
674
|
+
const esc = (s) => s.replace(/'/g, "''");
|
|
675
|
+
const rows = await exec.ExecuteSQL(`SELECT COLUMN_NAME AS cn FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='${esc(schema)}' AND TABLE_NAME='${esc(table)}' ORDER BY ORDINAL_POSITION`);
|
|
676
|
+
return (rows ?? []).map((r) => r.cn);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* True only if (schema, table) is a BASE TABLE (not a view) per INFORMATION_SCHEMA.TABLES — used to gate
|
|
680
|
+
* incremental eligibility, since a watermark is only meaningful on a real table whose `__mj_UpdatedAt` tracks
|
|
681
|
+
* its own row changes. `TABLE_TYPE = 'BASE TABLE'` is standard on both SQL Server and PostgreSQL. Returns
|
|
682
|
+
* false when the object isn't found (fail-safe → no incremental).
|
|
683
|
+
*/
|
|
684
|
+
async sourceIsBaseTable(schema, table, exec) {
|
|
685
|
+
const esc = (s) => s.replace(/'/g, "''");
|
|
686
|
+
const rows = await exec.ExecuteSQL(`SELECT TABLE_TYPE AS tt FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='${esc(schema)}' AND TABLE_NAME='${esc(table)}'`);
|
|
687
|
+
return (rows?.[0]?.tt ?? '').toUpperCase() === 'BASE TABLE';
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Probes the source fingerprint used for incremental refresh: MAX(__mj_UpdatedAt) + COUNT(*).
|
|
691
|
+
*
|
|
692
|
+
* PRECISION SAFETY: the source column is datetimeoffset (100ns) but JS Date is millisecond-precision, so
|
|
693
|
+
* `new Date(w)` TRUNCATES the sub-millisecond part — and per the ECMAScript spec Date always truncates
|
|
694
|
+
* toward the past, never rounds up. That direction is the safe one: the strict incremental filter
|
|
695
|
+
* `__mj_UpdatedAt > watermark` then treats the exact boundary row (the row whose timestamp WAS the max) as
|
|
696
|
+
* still `>` the truncated watermark, so it is harmlessly RE-processed next pass (recompute is idempotent)
|
|
697
|
+
* rather than skipped. A round-UP would be the dangerous case (permanently excluding that row) — which
|
|
698
|
+
* cannot happen with Date truncation.
|
|
699
|
+
*/
|
|
700
|
+
async probeSourceFingerprint(schema, table, updatedAtColumn, exec, isPostgres) {
|
|
701
|
+
const obj = isPostgres ? `${schema}."${table}"` : `[${schema}].[${table}]`;
|
|
702
|
+
const col = isPostgres ? `"${updatedAtColumn}"` : `[${updatedAtColumn}]`;
|
|
703
|
+
const rows = await exec.ExecuteSQL(`SELECT MAX(${col}) AS w, COUNT(*) AS c FROM ${obj}`);
|
|
704
|
+
const w = rows?.[0]?.w ?? null;
|
|
705
|
+
const rawMax = w == null ? null : w instanceof Date ? w : new Date(w);
|
|
706
|
+
// Persist MAX - overlap (see applyWatermarkSafetyOverlap / WATERMARK_SAFETY_OVERLAP_MS) so a row committed
|
|
707
|
+
// late (its __mj_UpdatedAt earlier than this MAX but its commit landing after this probe) is re-scanned by
|
|
708
|
+
// the next incremental pass rather than skipped forever.
|
|
709
|
+
const watermark = MaterializationRefresher.applyWatermarkSafetyOverlap(rawMax);
|
|
710
|
+
return { watermark, count: Number(rows?.[0]?.c ?? 0) };
|
|
711
|
+
}
|
|
712
|
+
/** Counts the rows currently in the materialized wrapper view. */
|
|
713
|
+
async countMaterialized(matResult, exec, isPostgres) {
|
|
714
|
+
const countTarget = isPostgres
|
|
715
|
+
? `${matResult.SchemaName}."${matResult.ViewName}"`
|
|
716
|
+
: `[${matResult.SchemaName}].[${matResult.ViewName}]`;
|
|
717
|
+
const rows = await exec.ExecuteSQL(`SELECT COUNT(*) AS n FROM ${countTarget}`);
|
|
718
|
+
return Number(rows?.[0]?.n ?? 0);
|
|
719
|
+
}
|
|
720
|
+
/** A SQL datetime literal (ISO-8601 UTC) parsed by both SQL Server and PostgreSQL. */
|
|
721
|
+
static sqlDateTimeLiteral(date) {
|
|
722
|
+
return `'${date.toISOString()}'`;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* A globally-unique, length-safe shadow-table name for one refresh run. Deliberately NOT derived from the
|
|
726
|
+
* materialized table name: two refreshes of the SAME materialization (a manual "refresh now" racing the
|
|
727
|
+
* scheduled sweep, or overlapping sweeps under `ConcurrencyMode=Concurrent`) must not share a shadow, or one
|
|
728
|
+
* run's `DROP TABLE …__shadow` would yank the table the other is mid-build. A fixed short prefix + a random
|
|
729
|
+
* token keeps it well under both engines' identifier limits (PG 63 / SQL Server 128) regardless of how long
|
|
730
|
+
* the canonical table name is. The shadow is renamed INTO the canonical name on success (so it leaves no
|
|
731
|
+
* residue), and dropped by RefreshOne's failure cleanup on a caught error; only a hard process crash between
|
|
732
|
+
* shadow creation and swap can leak one — a harmless orphan table with no dependents.
|
|
733
|
+
*/
|
|
734
|
+
static makeShadowTableName() {
|
|
735
|
+
return `mj_mat_shd_${randomUUID().replace(/-/g, '')}`;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Applies the incremental-watermark safety overlap: returns `rawMax - WATERMARK_SAFETY_OVERLAP_MS` (null
|
|
739
|
+
* passes through). Persisting the reduced value makes the next incremental pass RE-scan the last `overlap`
|
|
740
|
+
* window, so a source row whose transaction commits after the fingerprint probe — but whose `__mj_UpdatedAt`
|
|
741
|
+
* predates the probed MAX — is re-processed (idempotent MERGE) instead of being skipped forever. Pure and
|
|
742
|
+
* unit-testable; extracted from probeSourceFingerprint so the skew-safety math is verifiable in isolation.
|
|
743
|
+
*/
|
|
744
|
+
static applyWatermarkSafetyOverlap(rawMax) {
|
|
745
|
+
return rawMax == null ? null : new Date(rawMax.getTime() - WATERMARK_SAFETY_OVERLAP_MS);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* True if an entity is read-RLS-protected — any of its role permissions carries a non-empty `ReadRLSFilterID`.
|
|
749
|
+
* Matches CodeGenLib's `entityHasRowLevelSecurity` (and MJ's `GetUserRowLevelSecurityWhereClause`, which
|
|
750
|
+
* sources the read filter solely from `EntityPermission.ReadRLSFilterID`). Used by the runtime leak gate to
|
|
751
|
+
* refuse refreshing a local mirror of an EXTERNAL RLS-protected entity — a mirror can't reproduce remote RLS.
|
|
752
|
+
*/
|
|
753
|
+
static entityHasReadRLS(entity) {
|
|
754
|
+
return entity.Permissions.some((p) => !!p.ReadRLSFilterID && p.ReadRLSFilterID.trim().length > 0);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The composite row-restriction test the Leak-1 gate uses: role RLS **or** an API-key row filter.
|
|
758
|
+
*
|
|
759
|
+
* {@link entityHasReadRLS} covers only the ROLE layer. `EntityInfo`'s equivalent role-only accessor is
|
|
760
|
+
* deprecated precisely because it omits API-key row filters, so a gate built on it alone judges an
|
|
761
|
+
* entity fenced only by a key filter to be unrestricted. CodeGen's mint and drift gates compose both
|
|
762
|
+
* layers; this is the runtime half, kept deliberately symmetric with them.
|
|
763
|
+
*
|
|
764
|
+
* @param apiKeyRowFilterTargets lowercased entity names carrying an API-key row filter, or `'unknown'`
|
|
765
|
+
* when that layer could not be enumerated — in which case every entity is treated as restricted,
|
|
766
|
+
* because refusing to refresh is recoverable and mirroring restricted rows is not.
|
|
767
|
+
*/
|
|
768
|
+
static entityHasRowLevelRestriction(entity, apiKeyRowFilterTargets) {
|
|
769
|
+
if (MaterializationRefresher.entityHasReadRLS(entity))
|
|
770
|
+
return true;
|
|
771
|
+
if (apiKeyRowFilterTargets === 'unknown')
|
|
772
|
+
return true;
|
|
773
|
+
return apiKeyRowFilterTargets.has((entity.Name ?? '').trim().toLowerCase());
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Enumerates the entities carrying an API-key row filter, cached for this refresher's lifetime (the gate
|
|
777
|
+
* runs per materialization; the fence changes far more slowly than a sweep).
|
|
778
|
+
*
|
|
779
|
+
* Mirrors CodeGen's enumeration contract exactly, because it shares the rule rather than restating it:
|
|
780
|
+
* entity NAMES normalized by {@link ResolveSingleEntityResourceTarget}, taken from the `ResourcePattern`
|
|
781
|
+
* of scope rows that carry a `RowFilterID`. A pattern that function cannot resolve to one exact entity
|
|
782
|
+
* collapses the WHOLE set to `'unknown'`, since a rule we cannot map may well name the entity we are
|
|
783
|
+
* about to mirror. Any read failure does the same.
|
|
784
|
+
*/
|
|
785
|
+
async loadAPIKeyRowFilterTargets(provider, contextUser) {
|
|
786
|
+
if (this._apiKeyRowFilterTargets !== null)
|
|
787
|
+
return this._apiKeyRowFilterTargets;
|
|
788
|
+
try {
|
|
789
|
+
const targets = new Set();
|
|
790
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
791
|
+
for (const entityName of ['MJ: API Key Scopes', 'MJ: API Application Scopes']) {
|
|
792
|
+
// Skip a scope entity that has no RowFilterID field — the same guard CodeGen's enumeration
|
|
793
|
+
// makes with an INFORMATION_SCHEMA probe, expressed here in the metadata the refresher already
|
|
794
|
+
// has. Without it, a database predating that column fails the filtered read, which collapses
|
|
795
|
+
// the set to 'unknown' and stops EVERY external base-view refresh until someone migrates. The
|
|
796
|
+
// column's absence means the key-filter layer cannot exist there, so contributing nothing is
|
|
797
|
+
// correct rather than fail-open.
|
|
798
|
+
const scopeEntity = provider.EntityByName(entityName);
|
|
799
|
+
if (!scopeEntity || !scopeEntity.Fields.some((f) => f.Name === 'RowFilterID'))
|
|
800
|
+
continue;
|
|
801
|
+
const res = await rv.RunView({ EntityName: entityName, Fields: ['ResourcePattern'], ExtraFilter: 'RowFilterID IS NOT NULL', ResultType: 'simple' }, contextUser);
|
|
802
|
+
if (!res.Success)
|
|
803
|
+
throw new Error(`${entityName}: ${res.ErrorMessage}`);
|
|
804
|
+
for (const row of res.Results ?? []) {
|
|
805
|
+
const pattern = (row.ResourcePattern ?? '').trim();
|
|
806
|
+
// SHARED with CodeGen's identical gate (ResolveSingleEntityResourceTarget in
|
|
807
|
+
// @memberjunction/global) rather than copied. These two enumerations must agree exactly —
|
|
808
|
+
// a copy that drifts open in either one silently re-opens the leak the other closes, and
|
|
809
|
+
// nothing in the build would catch the divergence.
|
|
810
|
+
const target = ResolveSingleEntityResourceTarget(pattern);
|
|
811
|
+
if (target === null) {
|
|
812
|
+
LogError(`MaterializationRefresher: an API-key scope rule with a row filter has an unmappable ResourcePattern ("${pattern}") — it cannot be resolved to one entity, so EVERY entity is treated as row-restricted for refresh (fail closed). Fix the rule to name one exact entity.`);
|
|
813
|
+
this._apiKeyRowFilterTargets = 'unknown';
|
|
814
|
+
return this._apiKeyRowFilterTargets;
|
|
815
|
+
}
|
|
816
|
+
targets.add(target);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
this._apiKeyRowFilterTargets = targets;
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
LogError(`MaterializationRefresher: API-key row-filter enumeration FAILED — every entity is treated as row-restricted for refresh (fail closed): ${err instanceof Error ? err.message : String(err)}`);
|
|
823
|
+
this._apiKeyRowFilterTargets = 'unknown';
|
|
824
|
+
}
|
|
825
|
+
return this._apiKeyRowFilterTargets;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Resolves the source SELECT a refresh rebuilds from: the source entity's base view (base-view case)
|
|
829
|
+
* or the stored Query's SQL (query case). Returns null when the source can't be resolved.
|
|
830
|
+
*/
|
|
831
|
+
async resolveSourceSelect(matResult, contextUser, provider, isPostgres, preloadedQuery) {
|
|
832
|
+
if (matResult.SourceType === 'EntityBaseView') {
|
|
833
|
+
if (!matResult.SourceEntityID)
|
|
834
|
+
return null;
|
|
835
|
+
const entity = provider.EntityByID(matResult.SourceEntityID);
|
|
836
|
+
if (!entity || !entity.BaseView)
|
|
837
|
+
return null;
|
|
838
|
+
return isPostgres
|
|
839
|
+
? `SELECT * FROM ${entity.SchemaName}."${entity.BaseView}"`
|
|
840
|
+
: `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}]`;
|
|
841
|
+
}
|
|
842
|
+
// Query case. For a RowFilterBroad materialization (Phase 2d) the BROAD source SELECT — the
|
|
843
|
+
// query with its row-filter WHERE predicate(s) removed — is persisted on the row at
|
|
844
|
+
// materialization time; the refresh rebuilds it broad and the filter is re-applied at read
|
|
845
|
+
// (ExtraFilter on the materialized VE). Unparameterized queries use the static query SQL.
|
|
846
|
+
// The MR<->Query link lives in the MaterializedResultQuery join table (no SourceQueryID column).
|
|
847
|
+
// Reuse the preloaded query's ID when RefreshOne already resolved it; otherwise look it up via the join.
|
|
848
|
+
const sourceQueryId = preloadedQuery ? preloadedQuery.ID : await this.resolveSourceQueryId(matResult, provider, contextUser);
|
|
849
|
+
if (!sourceQueryId)
|
|
850
|
+
return null;
|
|
851
|
+
let rawSql;
|
|
852
|
+
if (matResult.ParamMode === 'RowFilterBroad') {
|
|
853
|
+
rawSql = matResult.BroadSQL && matResult.BroadSQL.trim().length > 0 ? matResult.BroadSQL : null;
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
// Reuse the query loaded by resolveSourceQuery (RefreshOne) when available; only Load if not passed.
|
|
857
|
+
let query = preloadedQuery;
|
|
858
|
+
if (!query) {
|
|
859
|
+
query = await provider.GetEntityObject('MJ: Queries', contextUser);
|
|
860
|
+
await query.Load(sourceQueryId);
|
|
861
|
+
}
|
|
862
|
+
// Snapshot the SAME statement the READ path executes. Reads resolve SQL via
|
|
863
|
+
// QueryInfo.GetPlatformSQL(PlatformKey) (GenericDatabaseProvider's ORDER BY gate and its query
|
|
864
|
+
// execution both do), which prefers a per-platform `MJ: Query SQLs` variant over the base SQL.
|
|
865
|
+
// Snapshotting the base `SQL` instead means a query carrying a variant for THIS engine is
|
|
866
|
+
// materialized from a different statement than the one live serves — either a hard refresh
|
|
867
|
+
// failure every cycle, or (worse) a snapshot whose contents silently disagree with live, which
|
|
868
|
+
// is precisely the invariant materialization exists to preserve.
|
|
869
|
+
rawSql = MaterializationRefresher.resolvePlatformQuerySQL(provider, sourceQueryId, query.SQL, isPostgres);
|
|
870
|
+
}
|
|
871
|
+
// Strip a top-level ORDER BY before this SELECT is wrapped in a derived table by the rebuild
|
|
872
|
+
// (SELECT … INTO shadow FROM (<sql>) AS src): SQL Server rejects ORDER BY inside a derived table /
|
|
873
|
+
// subquery without TOP/OFFSET (error 1033), so an analytics query ending in ORDER BY would fail
|
|
874
|
+
// every refresh. A materialized snapshot has no inherent row order (readers apply their own ORDER
|
|
875
|
+
// BY at read time), so dropping the source ordering is semantically safe.
|
|
876
|
+
return rawSql == null ? null : MaterializationRefresher.stripTopLevelOrderBy(rawSql, isPostgres);
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Remove a TOP-LEVEL ORDER BY from a source SELECT so it can be wrapped in a derived table for the rebuild.
|
|
880
|
+
* See resolveSourceSelect for why (SQL Server error 1033) and why it's safe (a snapshot is unordered).
|
|
881
|
+
*
|
|
882
|
+
* Two guards keep this from corrupting results:
|
|
883
|
+
* - **PostgreSQL is a no-op** — PG permits ORDER BY inside a derived table, so there's nothing to fix and
|
|
884
|
+
* we skip the parser round-trip entirely.
|
|
885
|
+
* - **A query with a row-LIMITING clause (SQL Server TOP or OFFSET/FETCH) is left UNCHANGED** — there the
|
|
886
|
+
* ORDER BY is both (a) LEGAL in a derived table and (b) SEMANTICALLY REQUIRED: it decides WHICH rows
|
|
887
|
+
* TOP/FETCH keep, so stripping it would materialize an arbitrary subset (a silent wrong-data bug). Only a
|
|
888
|
+
* BARE top-level ORDER BY (pure presentation sort, no limiting) is both illegal-in-derived-table and safe
|
|
889
|
+
* to drop.
|
|
890
|
+
*
|
|
891
|
+
* Uses the SQL parser; on any parse/shape surprise, or no top-level ORDER BY, returns the SQL unchanged
|
|
892
|
+
* (an ORDER BY nested inside a subquery is legal and left intact).
|
|
893
|
+
*/
|
|
894
|
+
static stripTopLevelOrderBy(sql, isPostgres) {
|
|
895
|
+
if (isPostgres)
|
|
896
|
+
return sql; // PG allows ORDER BY in a derived table → nothing to strip
|
|
897
|
+
try {
|
|
898
|
+
const dialect = GetDialect('sqlserver');
|
|
899
|
+
const parsed = SQLParser.Astify(sql, dialect);
|
|
900
|
+
if (!parsed.astParsed || parsed.ast == null)
|
|
901
|
+
return sql;
|
|
902
|
+
// Walk the AST opaquely (it's a discriminated union of many node shapes) via an unknown-typed
|
|
903
|
+
// intermediate + guarded property access — the standard generic-AST-walk pattern.
|
|
904
|
+
const stmtNode = Array.isArray(parsed.ast) ? (parsed.ast.length === 1 ? parsed.ast[0] : null) : parsed.ast;
|
|
905
|
+
if (stmtNode == null || typeof stmtNode !== 'object')
|
|
906
|
+
return sql;
|
|
907
|
+
const s = stmtNode;
|
|
908
|
+
if (s.type !== 'select' || s.orderby == null)
|
|
909
|
+
return sql; // no top-level ORDER BY → nothing to strip
|
|
910
|
+
// Keep the ORDER BY when a row-limiting clause is present (legal in a derived table + required to
|
|
911
|
+
// pick the right rows): SQL Server `TOP` (s.top set) or `OFFSET…/FETCH…` (s.limit.offset/fetch set).
|
|
912
|
+
const limit = s.limit;
|
|
913
|
+
const hasRowLimit = s.top != null || (limit != null && (limit.offset != null || limit.fetch != null));
|
|
914
|
+
if (hasRowLimit)
|
|
915
|
+
return sql;
|
|
916
|
+
s.orderby = null;
|
|
917
|
+
return SQLParser.SqlifyAST(parsed.ast, dialect);
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
return sql; // unparseable → leave as-is (no worse than before; a residual ORDER BY still errors → failRefresh)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Phase 1.5 (EDS composition): if this materialization is backed by an EXTERNAL entity base view
|
|
925
|
+
* (the source entity carries an `ExternalDataSourceID`), returns that entity — the signal to rebuild
|
|
926
|
+
* by fetching remote rows through the EDS driver rather than by local SQL. Returns null otherwise
|
|
927
|
+
* (local sources, and — for now — external *queries*, which fall through to the local path).
|
|
928
|
+
*/
|
|
929
|
+
resolveExternalEntity(matResult, provider) {
|
|
930
|
+
if (matResult.SourceType !== 'EntityBaseView' || !matResult.SourceEntityID)
|
|
931
|
+
return null;
|
|
932
|
+
const entity = provider.EntityByID(matResult.SourceEntityID);
|
|
933
|
+
return entity && entity.ExternalDataSourceID ? entity : null;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Phase 3: parse the materialization's `KeyColumns` metadata (JSON array of `{name, type}`) into the
|
|
937
|
+
* hash-key column list, or undefined when it isn't keyed. A null/empty/malformed value yields undefined
|
|
938
|
+
* — the caller then uses the synthetic IDENTITY/ROW_NUMBER surrogate (Phase 1/2 behavior).
|
|
939
|
+
*/
|
|
940
|
+
static parseKeyColumns(raw) {
|
|
941
|
+
if (!raw || raw.trim().length === 0)
|
|
942
|
+
return undefined;
|
|
943
|
+
try {
|
|
944
|
+
const parsed = JSON.parse(raw);
|
|
945
|
+
if (Array.isArray(parsed) && parsed.every((c) => c != null && typeof c.name === 'string' && typeof c.type === 'string')) {
|
|
946
|
+
return parsed;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
// Malformed metadata → treat as not keyed (fall through to the synthetic surrogate) rather than
|
|
951
|
+
// failing the refresh; a bad KeyColumns value is a config error, not a reason to block rebuilds.
|
|
952
|
+
}
|
|
953
|
+
return undefined;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Phase 1.5: rebuild a materialized result from an EXTERNAL entity — "mirror external → join locally".
|
|
957
|
+
* Remote rows can't be read via local SQL, so we fetch them through the EDS read router (read-only)
|
|
958
|
+
* and persist into the MJ-managed shadow table (CREATE + batched INSERT), then reuse the Phase-1
|
|
959
|
+
* atomic wrapper-view swap. Once persisted the data is an ordinary local MJ table, joinable with
|
|
960
|
+
* internal entities.
|
|
961
|
+
*
|
|
962
|
+
* RLS is NOT downgraded here (§6.1): base-view materialization REUSES the source entity (no new entity,
|
|
963
|
+
* no changed permissions), so its `ReadRLSFilterID` is enforced at read time by the standard read
|
|
964
|
+
* pipeline against the materialized wrapper view exactly as it would be against the live base view
|
|
965
|
+
* (`DataSource:'Materialized'` only swaps the FROM). The physical mirror holding all rows is correct —
|
|
966
|
+
* RLS filters at READ, like any base table. (This is why only the QUERY case — a new, differently-shaped
|
|
967
|
+
* entity that loses source RLS — carries a mint-time refusal gate, not the base-view case.)
|
|
968
|
+
*/
|
|
969
|
+
async rebuildFromExternalEntity(matResult, entity, exec, isPostgres, contextUser, provider, shadowName) {
|
|
970
|
+
const router = MJGlobal.Instance.ClassFactory.CreateInstance(ExternalDataSourceReadRouter);
|
|
971
|
+
if (!router) {
|
|
972
|
+
return { Success: false, ErrorMessage: 'No ExternalDataSourceReadRouter is registered — ensure @memberjunction/external-data-sources and its driver are loaded in the refresh process.' };
|
|
973
|
+
}
|
|
974
|
+
const view = await router.RunViewExternal(entity, { EntityName: entity.Name, ResultType: 'simple' }, contextUser, provider);
|
|
975
|
+
if (!view.Success) {
|
|
976
|
+
return { Success: false, ErrorMessage: `External read failed for '${entity.Name}': ${view.ErrorMessage}` };
|
|
977
|
+
}
|
|
978
|
+
const rows = view.Results ?? [];
|
|
979
|
+
// Mirror the external entity's non-virtual fields (name + SQL type) into a local table so the
|
|
980
|
+
// result becomes an ordinary joinable MJ table.
|
|
981
|
+
const columns = entity.Fields.filter((f) => !f.IsVirtual).map((f) => ({ name: f.Name, sqlType: f.SQLFullType }));
|
|
982
|
+
if (columns.length === 0) {
|
|
983
|
+
return { Success: false, ErrorMessage: `External entity '${entity.Name}' has no columns to materialize.` };
|
|
984
|
+
}
|
|
985
|
+
await MaterializationRefresher.executeExternalRebuildPlan(matResult, columns, rows, exec, isPostgres, undefined, shadowName);
|
|
986
|
+
return { Success: true, RowCount: rows.length };
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Runs a {@link buildExternalRebuildPlan}: DDL, then the parameterized insert batches (value binding —
|
|
990
|
+
* not literal inlining), then the atomic swap. Shared by the external-entity and external-query paths.
|
|
991
|
+
*/
|
|
992
|
+
static async executeExternalRebuildPlan(matResult, columns, rows, exec, isPostgres, surrogateColumn, shadowName) {
|
|
993
|
+
const plan = MaterializationRefresher.buildExternalRebuildPlan({
|
|
994
|
+
schema: matResult.SchemaName, tableName: matResult.TableName, viewName: matResult.ViewName,
|
|
995
|
+
columns, rows, isPostgres, surrogateColumn, shadowName,
|
|
996
|
+
});
|
|
997
|
+
for (const sql of plan.preStatements)
|
|
998
|
+
await exec.ExecuteSQL(sql);
|
|
999
|
+
for (const batch of plan.insertBatches)
|
|
1000
|
+
await exec.ExecuteSQL(batch.sql, batch.params);
|
|
1001
|
+
for (const sql of plan.postStatements)
|
|
1002
|
+
await exec.ExecuteSQL(sql);
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Loads the materialization's source stored Query ONCE (Query source type only) and classifies it:
|
|
1006
|
+
* returns the loaded `query` plus `externalSql` — the SQL to run remotely when the query is EXTERNAL
|
|
1007
|
+
* (carries an ExternalDataSourceID: BroadSQL for RowFilterBroad, else the static query SQL), or null
|
|
1008
|
+
* when it's a LOCAL query. The caller reuses this same loaded `query` to build the local source SELECT
|
|
1009
|
+
* (no second Load). Returns null when the source isn't a stored Query.
|
|
1010
|
+
*/
|
|
1011
|
+
/**
|
|
1012
|
+
* Resolve a materialization's source Query ID via the `MJ: Materialized Result Queries` join table.
|
|
1013
|
+
* The MR<->Query link lives in that join table (there is no MaterializedResult.SourceQueryID column —
|
|
1014
|
+
* the direct FK formed a circular dependency). Returns null when the materialization has no linked query.
|
|
1015
|
+
*/
|
|
1016
|
+
async resolveSourceQueryId(matResult, provider, contextUser) {
|
|
1017
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
1018
|
+
const res = await rv.RunView({
|
|
1019
|
+
EntityName: 'MJ: Materialized Result Queries',
|
|
1020
|
+
ExtraFilter: `MaterializedResultID='${matResult.ID}'`,
|
|
1021
|
+
Fields: ['QueryID'],
|
|
1022
|
+
ResultType: 'simple',
|
|
1023
|
+
MaxRows: 1,
|
|
1024
|
+
}, contextUser);
|
|
1025
|
+
return res.Success && res.Results.length > 0 ? res.Results[0].QueryID : null;
|
|
1026
|
+
}
|
|
1027
|
+
async resolveSourceQuery(matResult, contextUser, provider) {
|
|
1028
|
+
if (matResult.SourceType !== 'Query')
|
|
1029
|
+
return null;
|
|
1030
|
+
const sourceQueryId = await this.resolveSourceQueryId(matResult, provider, contextUser);
|
|
1031
|
+
if (!sourceQueryId)
|
|
1032
|
+
return null;
|
|
1033
|
+
const query = await provider.GetEntityObject('MJ: Queries', contextUser);
|
|
1034
|
+
await query.Load(sourceQueryId);
|
|
1035
|
+
if (!query.ExternalDataSourceID)
|
|
1036
|
+
return { query, externalSql: null };
|
|
1037
|
+
const sql = matResult.ParamMode === 'RowFilterBroad' ? (matResult.BroadSQL ?? '') : (query.SQL ?? '');
|
|
1038
|
+
return { query, externalSql: sql.trim().length > 0 ? sql : null };
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Phase 1.5: rebuild a materialized result from an EXTERNAL stored query. Runs the (broad, for
|
|
1042
|
+
* RowFilterBroad) query through the EDS native-query path, then persists the returned rows into the
|
|
1043
|
+
* MJ-managed shadow. Query results have no natural PK, so — mirroring the local query case — a synthetic
|
|
1044
|
+
* surrogate (MATERIALIZATION_SURROGATE_COLUMN) is prepended, populated by 1-based row index; column
|
|
1045
|
+
* types are inferred from the returned values. Row-filter re-application at read is the caller's
|
|
1046
|
+
* ExtraFilter (the Phase-2 convention), same as local RowFilterBroad materializations.
|
|
1047
|
+
*/
|
|
1048
|
+
async rebuildFromExternalQuery(matResult, query, sql, exec, isPostgres, contextUser, provider, shadowName) {
|
|
1049
|
+
if (!query.ExternalDataSourceID) {
|
|
1050
|
+
return { Success: false, ErrorMessage: `Query '${query.Name}' is not backed by an external data source.` };
|
|
1051
|
+
}
|
|
1052
|
+
const router = MJGlobal.Instance.ClassFactory.CreateInstance(ExternalDataSourceReadRouter);
|
|
1053
|
+
if (!router) {
|
|
1054
|
+
return { Success: false, ErrorMessage: 'No ExternalDataSourceReadRouter is registered — ensure @memberjunction/external-data-sources and its driver are loaded in the refresh process.' };
|
|
1055
|
+
}
|
|
1056
|
+
const rq = await router.RunQueryExternal(query.ExternalDataSourceID, query.ID, query.Name, sql, { QueryID: query.ID }, contextUser, provider);
|
|
1057
|
+
if (!rq.Success) {
|
|
1058
|
+
return { Success: false, ErrorMessage: `External query read failed for '${query.Name}': ${rq.ErrorMessage}` };
|
|
1059
|
+
}
|
|
1060
|
+
const rawRows = rq.Results ?? [];
|
|
1061
|
+
// Data columns from the union of returned row keys; types inferred from the values. The synthetic
|
|
1062
|
+
// surrogate is prepended and populated per row (1-based) so the shared external rebuild builder
|
|
1063
|
+
// (which reads row[column]) handles it uniformly. Column types are ALWAYS SQL-Server-style here —
|
|
1064
|
+
// buildExternalRebuildPlan maps them to the PG native type for a PG target (mapSqlTypeToPostgres);
|
|
1065
|
+
// feeding PG-native names in would double-convert everything down to `text` (broken sorts/joins).
|
|
1066
|
+
const surrogate = MATERIALIZATION_SURROGATE_COLUMN;
|
|
1067
|
+
const dataColNames = [...new Set(rawRows.flatMap((r) => Object.keys(r)))];
|
|
1068
|
+
// A zero-row result carries NO column information, so the rebuild below would emit a shadow table
|
|
1069
|
+
// holding only the surrogate, then DROP the canonical table and rename that one-column shell into its
|
|
1070
|
+
// place — permanently breaking every read of the minted entity ("Invalid column name") while reporting
|
|
1071
|
+
// Success. Refuse instead: the existing snapshot is left intact and serving (at worst slightly stale),
|
|
1072
|
+
// and failRefresh advances NextRefreshAt so this backs off to its cadence and stays visible in the log.
|
|
1073
|
+
// The alternative (truncate-in-place to preserve the shape) is the nicer semantic for a legitimately
|
|
1074
|
+
// empty source, but it cannot be done safely without first proving the canonical table's shape here.
|
|
1075
|
+
if (rawRows.length === 0) {
|
|
1076
|
+
return { Success: false, ErrorMessage: `External query '${query.Name}' returned zero rows, so the snapshot's column shape cannot be determined. Refusing to rebuild — the existing snapshot is preserved rather than replaced with an empty, unreadable table.` };
|
|
1077
|
+
}
|
|
1078
|
+
// Refuse if the external result already has a column named like the surrogate — prepending ours
|
|
1079
|
+
// would emit a duplicate column and the CREATE TABLE / INSERT would fail on every refresh. (Parity
|
|
1080
|
+
// with the local path's analyzeQueryForMaterialization shadow-check; here it's a runtime guard.)
|
|
1081
|
+
if (dataColNames.some((n) => n.trim().toLowerCase() === surrogate.toLowerCase())) {
|
|
1082
|
+
return { Success: false, ErrorMessage: `External query '${query.Name}' returns a column named "${surrogate}", which collides with the materialization surrogate key. Alias it in the query.` };
|
|
1083
|
+
}
|
|
1084
|
+
const columns = [
|
|
1085
|
+
// Surrogate is a 1-based row index whose column type MUST match the mint's surrogate PK type on
|
|
1086
|
+
// THIS engine (getMaterializedSurrogateColumnType): `int IDENTITY` on SQL Server, `bigint GENERATED
|
|
1087
|
+
// ALWAYS AS IDENTITY` on PostgreSQL. So feed the SS-style keyword that maps (via
|
|
1088
|
+
// buildExternalRebuildPlan → mapSqlTypeToPostgres for a PG target) to the mint type on each engine:
|
|
1089
|
+
// 'int' → SS int / 'bigint' → PG bigint. Using a fixed 'int' would leave the PG rebuild's surrogate
|
|
1090
|
+
// as `integer` while the minted entity's PK metadata says bigint — a mint-vs-refresh type mismatch.
|
|
1091
|
+
// (The value is a row index; on SS int caps at ~2.1B rows, but the EDS read materializes every row
|
|
1092
|
+
// in Node memory first, so such a set OOMs long before the surrogate could overflow.)
|
|
1093
|
+
{ name: surrogate, sqlType: isPostgres ? 'bigint' : 'int' },
|
|
1094
|
+
...dataColNames.map((name) => ({ name, sqlType: MaterializationRefresher.inferSqlType(rawRows.map((r) => r[name]), false) })),
|
|
1095
|
+
];
|
|
1096
|
+
const rows = rawRows.map((r, i) => ({ [surrogate]: i + 1, ...r }));
|
|
1097
|
+
// Pass the surrogate so the plan restores its UNIQUE index post-swap (the minted entity's PK).
|
|
1098
|
+
await MaterializationRefresher.executeExternalRebuildPlan(matResult, columns, rows, exec, isPostgres, surrogate, shadowName);
|
|
1099
|
+
return { Success: true, RowCount: rawRows.length };
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Infer a column's SQL type from its fetched values (external-query materialization, where no field
|
|
1103
|
+
* metadata is available). All-null → nvarchar(max)/text; ALL-numbers → int/integer (bigint when any
|
|
1104
|
+
* value exceeds signed-32-bit) else float/double precision; ALL-booleans → bit/boolean; ALL Date OBJECTS
|
|
1105
|
+
* → datetime2/timestamptz; ANYTHING ELSE, including a column whose values are HETEROGENEOUS across rows or
|
|
1106
|
+
* arrive as date STRINGS → nvarchar(max)/text.
|
|
1107
|
+
*
|
|
1108
|
+
* The type is decided from EVERY present value, not just the first: a loosely-typed source (REST/GraphQL)
|
|
1109
|
+
* can return a field that is a number in one row and a string in another; typing the column from row 1
|
|
1110
|
+
* would make later rows fail to bind. Falling back to text (which accepts any value) is the safe answer.
|
|
1111
|
+
*
|
|
1112
|
+
* Date-like STRINGS (ISO-8601 over JSON transport) are deliberately kept as text, NOT coerced to a
|
|
1113
|
+
* temporal column: coerceExternalParamValue binds the raw string and relies on implicit conversion, which
|
|
1114
|
+
* can reject offset-bearing / edge ISO forms and fail the entire rebuild. Text loses nothing that matters
|
|
1115
|
+
* here — fixed-format ISO-8601 strings sort and range-compare CHRONOLOGICALLY under lexicographic text
|
|
1116
|
+
* ordering, so ORDER BY / `> 'YYYY-MM-DD…'` filters stay correct. Only genuine Date objects, whose bind is
|
|
1117
|
+
* well-defined, are typed as datetime2/timestamptz.
|
|
1118
|
+
*/
|
|
1119
|
+
static inferSqlType(values, isPostgres) {
|
|
1120
|
+
const text = isPostgres ? 'text' : 'nvarchar(max)';
|
|
1121
|
+
const present = values.filter((v) => v !== null && v !== undefined);
|
|
1122
|
+
if (present.length === 0)
|
|
1123
|
+
return text;
|
|
1124
|
+
if (present.every((v) => typeof v === 'number')) {
|
|
1125
|
+
const nums = present;
|
|
1126
|
+
const allInt = nums.every((v) => Number.isInteger(v));
|
|
1127
|
+
if (!allInt)
|
|
1128
|
+
return isPostgres ? 'double precision' : 'float';
|
|
1129
|
+
// A JS number can exceed even signed-64-bit range (e.g. an epoch-nanosecond field or a synthetic
|
|
1130
|
+
// 1e19 id). Binding such a value to a bigint column overflows → the whole INSERT batch throws and
|
|
1131
|
+
// the refresh fails every pass. Fall to float/double precision (which holds the magnitude, if not
|
|
1132
|
+
// full integer precision — a value that large is already past JS's 2^53 exact-integer range anyway).
|
|
1133
|
+
const BIGINT_MAX = 9223372036854775807; // parsed by JS to the nearest double; fine for a >range guard
|
|
1134
|
+
if (nums.some((v) => v > BIGINT_MAX || v < -BIGINT_MAX - 1))
|
|
1135
|
+
return isPostgres ? 'double precision' : 'float';
|
|
1136
|
+
// Widen to bigint when any value exceeds signed 32-bit range (bigint IDs, row counts > 2.1B,
|
|
1137
|
+
// epoch-millisecond timestamps) — an `int`/`integer` column would overflow on INSERT.
|
|
1138
|
+
const needsBig = nums.some((v) => v > 2147483647 || v < -2147483648);
|
|
1139
|
+
return needsBig ? 'bigint' : (isPostgres ? 'integer' : 'int');
|
|
1140
|
+
}
|
|
1141
|
+
if (present.every((v) => typeof v === 'boolean'))
|
|
1142
|
+
return isPostgres ? 'boolean' : 'bit';
|
|
1143
|
+
if (present.every((v) => v instanceof Date && !Number.isNaN(v.getTime())))
|
|
1144
|
+
return isPostgres ? 'timestamptz' : 'datetime2';
|
|
1145
|
+
return text;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* External-source full rebuild PLAN (Phase 1.5), cross-engine and PARAMETERIZED. Pure (no IO) →
|
|
1149
|
+
* fully unit-testable (asserts on the emitted SQL + the params arrays). Three parts, run in order:
|
|
1150
|
+
*
|
|
1151
|
+
* - `preStatements` — DROP + CREATE the shadow table (pure DDL, no params).
|
|
1152
|
+
* - `insertBatches` — batched multi-row INSERTs as `{sql, params}`. NON-NULL values are bound as
|
|
1153
|
+
* positional parameters (`@pN` on SQL Server, `$N` on PostgreSQL) instead of inlined as literals;
|
|
1154
|
+
* NULLs are emitted as the literal `NULL` (no bind param — sidesteps driver null-typing quirks and
|
|
1155
|
+
* carries no injection risk). This keeps the SQL TEXT small and constant regardless of row width or
|
|
1156
|
+
* value size, so a large external mirror no longer builds enormous statements that pressure the Node
|
|
1157
|
+
* heap or blow the database's parser/packet limits (the prior inline-VALUES limitation). Batches are
|
|
1158
|
+
* sized by the engine's bind-parameter ceiling (SQL Server 2100 / PostgreSQL 65535, with headroom),
|
|
1159
|
+
* capped at 1000 rows/statement.
|
|
1160
|
+
* - `postStatements` — the atomic wrapper-view swap: transactional on SQL Server (the view only ever
|
|
1161
|
+
* points at the canonical name; the transaction's Sch-M lock keeps readers on the old snapshot until
|
|
1162
|
+
* commit — no "Invalid object name …__shadow" window; CREATE VIEW runs via EXEC() as its own batch),
|
|
1163
|
+
* CASCADE-repoint sequence on PostgreSQL.
|
|
1164
|
+
*
|
|
1165
|
+
* NOTE: the source rows are already fully materialized in memory by the EDS read (RunViewExternal /
|
|
1166
|
+
* RunQueryExternal return the complete result set), so this fixes the SQL-text/packet half of the scale
|
|
1167
|
+
* problem; true end-to-end streaming would require a streaming read API on the EDS router (future work).
|
|
1168
|
+
*/
|
|
1169
|
+
static buildExternalRebuildPlan(opts) {
|
|
1170
|
+
const { schema, tableName, viewName, columns, rows, isPostgres, surrogateColumn } = opts;
|
|
1171
|
+
const shadow = opts.shadowName ?? `${tableName}__shadow`;
|
|
1172
|
+
// Escape the engine's identifier delimiter so a hostile external column name (these come from the
|
|
1173
|
+
// remote result-set keys — untrusted) can't break out of its quoting: `]`→`]]` (SQL Server),
|
|
1174
|
+
// `"`→`""` (PostgreSQL). Applied to every interpolated identifier (columns especially).
|
|
1175
|
+
const escId = (n) => (isPostgres ? n.replace(/"/g, '""') : n.replace(/]/g, ']]'));
|
|
1176
|
+
const q = (n) => (isPostgres ? `"${escId(n)}"` : `[${escId(n)}]`);
|
|
1177
|
+
const obj = (n) => (isPostgres ? `${schema}."${escId(n)}"` : `[${schema}].[${escId(n)}]`);
|
|
1178
|
+
const colDefs = columns
|
|
1179
|
+
.map((c) => (isPostgres ? `${q(c.name)} ${MaterializationRefresher.mapSqlTypeToPostgres(c.sqlType)}` : `${q(c.name)} ${c.sqlType} NULL`))
|
|
1180
|
+
.join(', ');
|
|
1181
|
+
const colList = columns.map((c) => q(c.name)).join(', ');
|
|
1182
|
+
const preStatements = isPostgres
|
|
1183
|
+
? [`DROP TABLE IF EXISTS ${obj(shadow)} CASCADE`, `CREATE TABLE ${obj(shadow)} (${colDefs})`]
|
|
1184
|
+
: [`IF OBJECT_ID('[${schema}].[${shadow}]', 'U') IS NOT NULL DROP TABLE ${obj(shadow)}`, `CREATE TABLE ${obj(shadow)} (${colDefs})`];
|
|
1185
|
+
// Batch by the engine's bind-parameter ceiling (with headroom), capped at 1000 rows/statement.
|
|
1186
|
+
const maxParams = isPostgres ? 60000 : 2000;
|
|
1187
|
+
const rowsPerBatch = Math.max(1, Math.min(1000, Math.floor(maxParams / Math.max(1, columns.length))));
|
|
1188
|
+
const insertBatches = [];
|
|
1189
|
+
for (let i = 0; i < rows.length; i += rowsPerBatch) {
|
|
1190
|
+
const batch = rows.slice(i, i + rowsPerBatch);
|
|
1191
|
+
const params = [];
|
|
1192
|
+
const tuples = batch.map((row) => {
|
|
1193
|
+
const placeholders = columns.map((c) => {
|
|
1194
|
+
const v = MaterializationRefresher.coerceExternalParamValue(row[c.name]);
|
|
1195
|
+
if (v === null)
|
|
1196
|
+
return 'NULL'; // literal — no bind param (driver null-typing quirk + injection-safe)
|
|
1197
|
+
params.push(v);
|
|
1198
|
+
return isPostgres ? `$${params.length}` : `@p${params.length - 1}`;
|
|
1199
|
+
});
|
|
1200
|
+
return `(${placeholders.join(', ')})`;
|
|
1201
|
+
});
|
|
1202
|
+
insertBatches.push({ sql: `INSERT INTO ${obj(shadow)} (${colList}) VALUES ${tuples.join(', ')}`, params });
|
|
1203
|
+
}
|
|
1204
|
+
const postStatements = isPostgres
|
|
1205
|
+
? [
|
|
1206
|
+
// ATOMIC swap in a SINGLE transaction (PG DDL is transactional) — drop the stale table,
|
|
1207
|
+
// rename the shadow into the canonical name, (re)create the view on the new table, restore the
|
|
1208
|
+
// surrogate index. NO interim repoint outside the transaction (parity with
|
|
1209
|
+
// buildFullRebuildStatementsPostgreSQL): the wrapper view is only ever repointed INSIDE this
|
|
1210
|
+
// transaction, so readers see the whole old snapshot until commit and a mid-swap failure rolls
|
|
1211
|
+
// the ENTIRE swap back, leaving the OLD snapshot intact. The surrogate index is restored inside
|
|
1212
|
+
// the tran (unnamed → PG auto-names).
|
|
1213
|
+
`BEGIN;\n` +
|
|
1214
|
+
` DROP TABLE IF EXISTS ${obj(tableName)} CASCADE;\n` +
|
|
1215
|
+
` ALTER TABLE ${obj(shadow)} RENAME TO "${escId(tableName)}";\n` +
|
|
1216
|
+
` CREATE OR REPLACE VIEW ${obj(viewName)} AS SELECT * FROM ${obj(tableName)};\n` +
|
|
1217
|
+
(surrogateColumn ? ` CREATE UNIQUE INDEX ON ${obj(tableName)} (${q(surrogateColumn)});\n` : '') +
|
|
1218
|
+
`COMMIT;`,
|
|
1219
|
+
]
|
|
1220
|
+
: [
|
|
1221
|
+
// SET XACT_ABORT ON so a mid-swap error rolls the transaction back instead of leaving it
|
|
1222
|
+
// open on the pooled connection. Restore the surrogate UNIQUE index (query case) inside the
|
|
1223
|
+
// tran — parity with buildFullRebuildStatements* so the minted entity's PK is enforced.
|
|
1224
|
+
`SET XACT_ABORT ON;\n` +
|
|
1225
|
+
`BEGIN TRANSACTION;\n` +
|
|
1226
|
+
` IF OBJECT_ID('[${schema}].[${tableName}]', 'U') IS NOT NULL DROP TABLE ${obj(tableName)};\n` +
|
|
1227
|
+
` EXEC sp_rename '${schema}.${shadow}', '${tableName}';\n` +
|
|
1228
|
+
` EXEC('CREATE OR ALTER VIEW ${obj(viewName)} AS SELECT * FROM ${obj(tableName)}');\n` +
|
|
1229
|
+
// Fixed SHORT index name (unique per-table on SS) so a long materialized_<longName>
|
|
1230
|
+
// table can't overflow the 128-char sysname limit and roll back the swap — matches
|
|
1231
|
+
// buildFullRebuildStatementsSQLServer.
|
|
1232
|
+
(surrogateColumn ? ` CREATE UNIQUE INDEX [UQ_MJ_Materialized_Surrogate] ON ${obj(tableName)} (${q(surrogateColumn)});\n` : '') +
|
|
1233
|
+
// Restore the connection default after the swap. SET options persist for the SESSION and the
|
|
1234
|
+
// pool hands this same physical connection to unrelated requests, which would otherwise
|
|
1235
|
+
// silently inherit XACT_ABORT ON — converting their recoverable statement-level errors into
|
|
1236
|
+
// full transaction aborts, far from anything to do with materialization.
|
|
1237
|
+
`COMMIT TRANSACTION;\nSET XACT_ABORT OFF;`,
|
|
1238
|
+
];
|
|
1239
|
+
return { preStatements, insertBatches, postStatements };
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Coerce a JS value fetched from an external source into a driver-bindable parameter value:
|
|
1243
|
+
* null/undefined → null (the caller emits a literal `NULL` for these); non-finite numbers → null;
|
|
1244
|
+
* plain objects → JSON text (matches the `inferSqlType` text mapping for object columns); Date and
|
|
1245
|
+
* primitives (boolean/number/string) pass through — the driver binds them to the shadow column type.
|
|
1246
|
+
*/
|
|
1247
|
+
static coerceExternalParamValue(value) {
|
|
1248
|
+
if (value === null || value === undefined)
|
|
1249
|
+
return null;
|
|
1250
|
+
if (typeof value === 'number')
|
|
1251
|
+
return Number.isFinite(value) ? value : null;
|
|
1252
|
+
if (typeof value === 'object' && !(value instanceof Date))
|
|
1253
|
+
return JSON.stringify(value);
|
|
1254
|
+
return value; // boolean, Date, string
|
|
1255
|
+
}
|
|
1256
|
+
/** Map a SQL-Server-style `SQLFullType` (e.g. `nvarchar(255)`, `int`, `bit`) to a PostgreSQL column type. */
|
|
1257
|
+
static mapSqlTypeToPostgres(sqlFullType) {
|
|
1258
|
+
const base = sqlFullType.trim().toLowerCase().replace(/\(.*\)$/, '');
|
|
1259
|
+
switch (base) {
|
|
1260
|
+
case 'bit': return 'boolean';
|
|
1261
|
+
case 'tinyint':
|
|
1262
|
+
case 'smallint': return 'smallint';
|
|
1263
|
+
case 'int': return 'integer';
|
|
1264
|
+
case 'bigint': return 'bigint';
|
|
1265
|
+
case 'decimal':
|
|
1266
|
+
case 'numeric':
|
|
1267
|
+
case 'money':
|
|
1268
|
+
case 'smallmoney': return 'numeric';
|
|
1269
|
+
case 'float':
|
|
1270
|
+
case 'real': return 'double precision';
|
|
1271
|
+
case 'date': return 'date';
|
|
1272
|
+
case 'time': return 'time';
|
|
1273
|
+
case 'datetime':
|
|
1274
|
+
case 'datetime2':
|
|
1275
|
+
case 'smalldatetime': return 'timestamp';
|
|
1276
|
+
case 'datetimeoffset': return 'timestamptz';
|
|
1277
|
+
case 'uniqueidentifier': return 'uuid';
|
|
1278
|
+
case 'char':
|
|
1279
|
+
case 'nchar':
|
|
1280
|
+
case 'varchar':
|
|
1281
|
+
case 'nvarchar':
|
|
1282
|
+
case 'text':
|
|
1283
|
+
case 'ntext':
|
|
1284
|
+
case 'xml': return 'text';
|
|
1285
|
+
case 'varbinary':
|
|
1286
|
+
case 'binary':
|
|
1287
|
+
case 'image': return 'bytea';
|
|
1288
|
+
// Pass through already-PG-native type names — on a PostgreSQL deployment an external entity's
|
|
1289
|
+
// SQLFullType is already PG-native (e.g. mirroring a PG external source), so these must NOT fall
|
|
1290
|
+
// to the `text` default (which would silently stringify numbers/dates/uuids). Unknown → text.
|
|
1291
|
+
case 'integer': return 'integer';
|
|
1292
|
+
case 'boolean':
|
|
1293
|
+
case 'bool': return 'boolean';
|
|
1294
|
+
case 'double precision': return 'double precision';
|
|
1295
|
+
case 'timestamp':
|
|
1296
|
+
case 'timestamp without time zone': return 'timestamp';
|
|
1297
|
+
case 'timestamptz':
|
|
1298
|
+
case 'timestamp with time zone': return 'timestamptz';
|
|
1299
|
+
case 'uuid': return 'uuid';
|
|
1300
|
+
case 'bytea': return 'bytea';
|
|
1301
|
+
case 'json':
|
|
1302
|
+
case 'jsonb': return base;
|
|
1303
|
+
case 'character varying':
|
|
1304
|
+
case 'character': return 'text';
|
|
1305
|
+
default: return 'text';
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Quote a SQL identifier for the engine, ESCAPING the closing delimiter so a column name that contains
|
|
1310
|
+
* it can't break out of the quotes: `]`→`]]` (SQL Server), `"`→`""` (PostgreSQL). Column names in the
|
|
1311
|
+
* keyed/incremental builders are CodeGen-derived (entity field / KeyColumns names), so this is a
|
|
1312
|
+
* consistency + robustness guard (matching buildExternalRebuildPlan's escId), not a live-injection fix.
|
|
1313
|
+
*/
|
|
1314
|
+
static quoteIdent(name, isPostgres) {
|
|
1315
|
+
return isPostgres ? `"${name.replace(/"/g, '""')}"` : `[${name.replace(/]/g, ']]')}]`;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Phase 3: SQL expression producing the CANONICAL TEXT of one key column for the combined-key
|
|
1319
|
+
* surrogate hash (§17.1). Deterministic within an engine; a NULL is replaced by a control-char-wrapped
|
|
1320
|
+
* sentinel (CHAR(30)) so it can't collide with a literal value. `type` is the column's SQL-Server-style
|
|
1321
|
+
* type (EntityFieldInfo.SQLFullType); the base type drives the canonical cast.
|
|
1322
|
+
*/
|
|
1323
|
+
static canonicalKeyColumnSql(name, type, isPostgres) {
|
|
1324
|
+
const base = type.trim().toLowerCase().replace(/\(.*\)$/, '');
|
|
1325
|
+
const col = MaterializationRefresher.quoteIdent(name, isPostgres);
|
|
1326
|
+
const nullSentinel = isPostgres ? `chr(30) || 'NULL' || chr(30)` : `CHAR(30) + 'NULL' + CHAR(30)`;
|
|
1327
|
+
let canonical;
|
|
1328
|
+
if (isPostgres) {
|
|
1329
|
+
switch (base) {
|
|
1330
|
+
case 'uniqueidentifier':
|
|
1331
|
+
case 'uuid':
|
|
1332
|
+
canonical = `lower(${col}::text)`;
|
|
1333
|
+
break;
|
|
1334
|
+
// WHEN IS NULL → NULL first, so a NULL boolean flows to the COALESCE null-sentinel below
|
|
1335
|
+
// instead of colliding with false ('0'). Without it, `CASE WHEN col ...` returns '0' for NULL.
|
|
1336
|
+
case 'bit':
|
|
1337
|
+
case 'boolean':
|
|
1338
|
+
canonical = `(CASE WHEN ${col} IS NULL THEN NULL WHEN ${col} THEN '1' ELSE '0' END)`;
|
|
1339
|
+
break;
|
|
1340
|
+
case 'date':
|
|
1341
|
+
canonical = `to_char(${col}::date, 'YYYY-MM-DD')`;
|
|
1342
|
+
break;
|
|
1343
|
+
// TZ-AWARE: convert to UTC wall-clock deterministically (the value already carries a zone).
|
|
1344
|
+
case 'datetimeoffset':
|
|
1345
|
+
case 'timestamptz':
|
|
1346
|
+
canonical = `to_char((${col} AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
|
|
1347
|
+
break;
|
|
1348
|
+
// NAIVE (no zone): format the stored wall-clock AS-IS, appending a literal Z. Casting a naive
|
|
1349
|
+
// timestamp to timestamptz would interpret it in the SESSION TimeZone — a within-engine
|
|
1350
|
+
// determinism break if the session zone ever differs between refreshes. Mirror the SQL Server
|
|
1351
|
+
// plain-datetime branch (which appends Z without a tz shift).
|
|
1352
|
+
case 'datetime':
|
|
1353
|
+
case 'datetime2':
|
|
1354
|
+
case 'smalldatetime':
|
|
1355
|
+
case 'timestamp':
|
|
1356
|
+
canonical = `to_char(${col}::timestamp, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
|
|
1357
|
+
break;
|
|
1358
|
+
// Fixed-point numeric text. `money::text` is lc_monetary-dependent (currency symbol + group
|
|
1359
|
+
// separators) — route through ::numeric so the canonical form is locale-independent.
|
|
1360
|
+
case 'decimal':
|
|
1361
|
+
case 'numeric':
|
|
1362
|
+
case 'money':
|
|
1363
|
+
case 'smallmoney':
|
|
1364
|
+
canonical = `(${col}::numeric)::text`;
|
|
1365
|
+
break;
|
|
1366
|
+
default: canonical = `${col}::text`; // integers, strings
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
switch (base) {
|
|
1371
|
+
case 'uniqueidentifier':
|
|
1372
|
+
canonical = `LOWER(CONVERT(varchar(36), ${col}))`;
|
|
1373
|
+
break;
|
|
1374
|
+
// WHEN IS NULL → NULL first, so a NULL bit flows to the COALESCE null-sentinel below instead
|
|
1375
|
+
// of colliding with false ('0'). Without it, `col = 1` is UNKNOWN for NULL → ELSE '0'.
|
|
1376
|
+
case 'bit':
|
|
1377
|
+
canonical = `(CASE WHEN ${col} IS NULL THEN NULL WHEN ${col} = 1 THEN '1' ELSE '0' END)`;
|
|
1378
|
+
break;
|
|
1379
|
+
case 'date':
|
|
1380
|
+
canonical = `CONVERT(varchar(10), ${col}, 23)`;
|
|
1381
|
+
break;
|
|
1382
|
+
case 'datetimeoffset':
|
|
1383
|
+
canonical = `FORMAT(CAST(${col} AT TIME ZONE 'UTC' AS datetime2(3)), 'yyyy-MM-ddTHH:mm:ss.fffZ')`;
|
|
1384
|
+
break;
|
|
1385
|
+
case 'datetime':
|
|
1386
|
+
case 'datetime2':
|
|
1387
|
+
case 'smalldatetime':
|
|
1388
|
+
canonical = `FORMAT(CAST(${col} AS datetime2(3)), 'yyyy-MM-ddTHH:mm:ss.fffZ')`;
|
|
1389
|
+
break;
|
|
1390
|
+
case 'decimal':
|
|
1391
|
+
case 'numeric':
|
|
1392
|
+
case 'money':
|
|
1393
|
+
case 'smallmoney':
|
|
1394
|
+
canonical = `CONVERT(varchar(50), ${col})`;
|
|
1395
|
+
break;
|
|
1396
|
+
default: canonical = `CONVERT(nvarchar(max), ${col})`; // integers, strings
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
return `COALESCE(${canonical}, ${nullSentinel})`;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Phase 3: SQL expression computing the combined-key surrogate — `SHA2_256` (lowercase hex) over the
|
|
1403
|
+
* canonical key columns in declared key order (§17.1). Deterministic WITHIN an engine (the
|
|
1404
|
+
* incremental-MERGE / dirty-group match key); cross-engine identity is best-effort.
|
|
1405
|
+
*
|
|
1406
|
+
* COLLISION SAFETY: each canonical part is hashed to a FIXED-WIDTH 64-char hex string FIRST, and the
|
|
1407
|
+
* per-part hashes are what get delimited + hashed. A naive `part1 + CHAR(31) + part2` collides when a
|
|
1408
|
+
* key value itself contains the CHAR(31) delimiter (or the CHAR(30) NULL sentinel) — e.g. ('x\x1f','y')
|
|
1409
|
+
* and ('x','\x1fy') both flatten to `x\x1f\x1fy`. Hashing each part first makes every part pure hex
|
|
1410
|
+
* (0-9a-f), which can NEVER contain a control char, so the delimiter is unambiguous and distinct tuples
|
|
1411
|
+
* can no longer canonicalize to the same string. Each canonical part is NULL-free (COALESCE'd), so the
|
|
1412
|
+
* inner hash inputs are never NULL. (This feature is unreleased, so no existing surrogates need
|
|
1413
|
+
* migrating; a full rebuild regenerates them under the new scheme.)
|
|
1414
|
+
* NOTE: the PostgreSQL `digest()` used here requires the `pgcrypto` extension. MJ's PostgreSQL baseline
|
|
1415
|
+
* already runs `CREATE EXTENSION IF NOT EXISTS "pgcrypto"`, so keyed PG materializations get it for free;
|
|
1416
|
+
* a deployment that dropped that baseline step would see refreshes fail with a clear `function digest(...)
|
|
1417
|
+
* does not exist` — provision pgcrypto to resolve.
|
|
1418
|
+
*/
|
|
1419
|
+
static buildHashKeyExpression(keyColumns, isPostgres) {
|
|
1420
|
+
if (keyColumns.length === 0) {
|
|
1421
|
+
throw new Error('buildHashKeyExpression requires at least one key column.');
|
|
1422
|
+
}
|
|
1423
|
+
const canonical = keyColumns.map((c) => MaterializationRefresher.canonicalKeyColumnSql(c.name, c.type, isPostgres));
|
|
1424
|
+
if (isPostgres) {
|
|
1425
|
+
const hashedParts = canonical.map((p) => `encode(digest(convert_to(${p}, 'UTF8'), 'sha256'), 'hex')`);
|
|
1426
|
+
const joined = hashedParts.join(` || chr(31) || `);
|
|
1427
|
+
return `encode(digest(convert_to(${joined}, 'UTF8'), 'sha256'), 'hex')`;
|
|
1428
|
+
}
|
|
1429
|
+
const hashedParts = canonical.map((p) => `CONVERT(varchar(64), HASHBYTES('SHA2_256', ${p}), 2)`);
|
|
1430
|
+
const joined = hashedParts.join(` + CHAR(31) + `);
|
|
1431
|
+
return `LOWER(CONVERT(varchar(64), HASHBYTES('SHA2_256', ${joined}), 2))`;
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Phase 3 (DirtyGroupRecompute): a NULL-safe equality predicate matching the key columns of two
|
|
1435
|
+
* aliases (`(a.[k] = b.[k] OR (a.[k] IS NULL AND b.[k] IS NULL)) AND ...`). Two NULL keys are treated
|
|
1436
|
+
* as equal (a materialized aggregation can legitimately have a NULL grouping value — it's one group).
|
|
1437
|
+
* Portable across SQL Server and PostgreSQL (the `OR ... IS NULL` form works on both; we avoid
|
|
1438
|
+
* `IS NOT DISTINCT FROM`, which SQL Server lacks pre-2022). Pure/unit-testable.
|
|
1439
|
+
*/
|
|
1440
|
+
static buildKeyMatchPredicate(aliasA, aliasB, keyColumns, isPostgres) {
|
|
1441
|
+
const q = (n) => MaterializationRefresher.quoteIdent(n, isPostgres);
|
|
1442
|
+
return keyColumns
|
|
1443
|
+
.map((c) => {
|
|
1444
|
+
const a = `${aliasA}.${q(c.name)}`;
|
|
1445
|
+
const b = `${aliasB}.${q(c.name)}`;
|
|
1446
|
+
return `(${a} = ${b} OR (${a} IS NULL AND ${b} IS NULL))`;
|
|
1447
|
+
})
|
|
1448
|
+
.join(' AND ');
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Phase 3 (DirtyGroupRecompute): the ordered statements that incrementally refresh a keyed aggregation
|
|
1452
|
+
* IN PLACE (no shadow swap) by recomputing only the groups whose SOURCE rows changed since `watermarkSql`.
|
|
1453
|
+
* Pure (no IO), so the sequence is unit-testable. Engine-agnostic core; {@link buildDirtyGroupRecomputeStatementsSQLServer}
|
|
1454
|
+
* / {@link buildDirtyGroupRecomputeStatementsPostgreSQL} supply the per-engine quoting + DELETE syntax.
|
|
1455
|
+
*
|
|
1456
|
+
* Semantics (correct within the documented delete caveat — see RefreshOne):
|
|
1457
|
+
* 1. DELETE every materialized row whose group has ANY source row updated since the watermark
|
|
1458
|
+
* (removes stale values, AND removes a group that shrank/emptied among the changed groups);
|
|
1459
|
+
* 2. INSERT the freshly-computed values for exactly those dirty groups (from the aggregation SELECT,
|
|
1460
|
+
* filtered by the same "group has a changed source row" predicate), stamping the same hash surrogate
|
|
1461
|
+
* the full rebuild uses so the key stays stable.
|
|
1462
|
+
* A group whose rows were ALL deleted without any surviving-row update is NOT seen here (the deleted
|
|
1463
|
+
* rows are gone); that case is caught by the source-count-drop → full-rebuild guard in RefreshOne.
|
|
1464
|
+
*/
|
|
1465
|
+
static buildDirtyGroupRecomputeCore(opts) {
|
|
1466
|
+
const { matTable, sourceTable, deleteHead, keyColumns, aggregationSelect, surrogateColumn, dataColumns, updatedAtColumn, watermarkSql, isPostgres } = opts;
|
|
1467
|
+
const q = (n) => MaterializationRefresher.quoteIdent(n, isPostgres);
|
|
1468
|
+
const changedSince = `s.${q(updatedAtColumn)} > ${watermarkSql}`;
|
|
1469
|
+
const deleteMatch = MaterializationRefresher.buildKeyMatchPredicate('m', 's', keyColumns, isPostgres);
|
|
1470
|
+
const insertMatch = MaterializationRefresher.buildKeyMatchPredicate('agg', 's', keyColumns, isPostgres);
|
|
1471
|
+
const hashExpr = MaterializationRefresher.buildHashKeyExpression(keyColumns, isPostgres);
|
|
1472
|
+
const colList = [surrogateColumn, ...dataColumns].map(q).join(', ');
|
|
1473
|
+
const selectList = [hashExpr, ...dataColumns.map((c) => `agg.${q(c)}`)].join(', ');
|
|
1474
|
+
return [
|
|
1475
|
+
// 1) Remove all rows for the changed (dirty) groups.
|
|
1476
|
+
`${deleteHead} WHERE EXISTS (SELECT 1 FROM ${sourceTable} AS s WHERE ${changedSince} AND ${deleteMatch})`,
|
|
1477
|
+
// 2) Re-insert fresh values for the dirty groups that still exist.
|
|
1478
|
+
`INSERT INTO ${matTable} (${colList}) SELECT ${selectList} FROM (${aggregationSelect}) AS agg ` +
|
|
1479
|
+
`WHERE EXISTS (SELECT 1 FROM ${sourceTable} AS s WHERE ${changedSince} AND ${insertMatch})`,
|
|
1480
|
+
];
|
|
1481
|
+
}
|
|
1482
|
+
/** SQL Server dirty-group recompute (see {@link buildDirtyGroupRecomputeCore}). */
|
|
1483
|
+
static buildDirtyGroupRecomputeStatementsSQLServer(opts) {
|
|
1484
|
+
const matTable = `[${opts.schema}].[${opts.tableName}]`;
|
|
1485
|
+
const sourceTable = `[${opts.sourceSchema}].[${opts.sourceTable}]`;
|
|
1486
|
+
return MaterializationRefresher.buildDirtyGroupRecomputeCore({
|
|
1487
|
+
matTable, sourceTable,
|
|
1488
|
+
deleteHead: `DELETE m FROM ${matTable} AS m`,
|
|
1489
|
+
keyColumns: opts.keyColumns, aggregationSelect: opts.aggregationSelect,
|
|
1490
|
+
surrogateColumn: opts.surrogateColumn, dataColumns: opts.dataColumns,
|
|
1491
|
+
updatedAtColumn: opts.updatedAtColumn, watermarkSql: opts.watermarkSql, isPostgres: false,
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
/** PostgreSQL dirty-group recompute (see {@link buildDirtyGroupRecomputeCore}). */
|
|
1495
|
+
static buildDirtyGroupRecomputeStatementsPostgreSQL(opts) {
|
|
1496
|
+
const matTable = `${opts.schema}."${opts.tableName}"`;
|
|
1497
|
+
const sourceTable = `${opts.sourceSchema}."${opts.sourceTable}"`;
|
|
1498
|
+
return MaterializationRefresher.buildDirtyGroupRecomputeCore({
|
|
1499
|
+
matTable, sourceTable,
|
|
1500
|
+
deleteHead: `DELETE FROM ${matTable} AS m`,
|
|
1501
|
+
keyColumns: opts.keyColumns, aggregationSelect: opts.aggregationSelect,
|
|
1502
|
+
surrogateColumn: opts.surrogateColumn, dataColumns: opts.dataColumns,
|
|
1503
|
+
updatedAtColumn: opts.updatedAtColumn, watermarkSql: opts.watermarkSql, isPostgres: true,
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Phase 4 (RefreshStrategy = 'Incremental'): incrementally refresh a keyed ADDITIVE aggregation by
|
|
1508
|
+
* recomputing only the changed groups and UPSERTING them onto the surrogate key — an in-place MERGE
|
|
1509
|
+
* (SQL Server) / INSERT…ON CONFLICT (PostgreSQL) rather than the DirtyGroupRecompute delete-then-insert.
|
|
1510
|
+
* The recomputed source is identical (the aggregation restricted to groups with a source row changed
|
|
1511
|
+
* since the watermark); the difference is that a surviving group's row is UPDATED in place — no churn,
|
|
1512
|
+
* no transient absence, one atomic statement. Correct for insert/update; a net source-count drop
|
|
1513
|
+
* (deletes) still falls back to full rebuild via the RefreshOne guard. Requires the surrogate to be
|
|
1514
|
+
* unique (it is the materialized table's PK). Pure (no IO) / unit-testable.
|
|
1515
|
+
*/
|
|
1516
|
+
static buildIncrementalMergeStatementsSQLServer(opts) {
|
|
1517
|
+
const matTable = `[${opts.schema}].[${opts.tableName}]`;
|
|
1518
|
+
const sourceTable = `[${opts.sourceSchema}].[${opts.sourceTable}]`;
|
|
1519
|
+
const q = (n) => MaterializationRefresher.quoteIdent(n, false);
|
|
1520
|
+
const changedSince = `s.${q(opts.updatedAtColumn)} > ${opts.watermarkSql}`;
|
|
1521
|
+
const match = MaterializationRefresher.buildKeyMatchPredicate('agg', 's', opts.keyColumns, false);
|
|
1522
|
+
const hashExpr = MaterializationRefresher.buildHashKeyExpression(opts.keyColumns, false);
|
|
1523
|
+
const insertCols = [opts.surrogateColumn, ...opts.dataColumns].map(q).join(', ');
|
|
1524
|
+
const insertVals = [`src.${q(opts.surrogateColumn)}`, ...opts.dataColumns.map((c) => `src.${q(c)}`)].join(', ');
|
|
1525
|
+
const setList = opts.dataColumns.map((c) => `t.${q(c)} = src.${q(c)}`).join(', ');
|
|
1526
|
+
const selectList = [`${hashExpr} AS ${q(opts.surrogateColumn)}`, ...opts.dataColumns.map((c) => `agg.${q(c)}`)].join(', ');
|
|
1527
|
+
return [
|
|
1528
|
+
// WITH (HOLDLOCK) on the MERGE target takes a range lock so a concurrent MERGE can't slip between the
|
|
1529
|
+
// MATCHED probe and the INSERT — the classic SQL Server MERGE upsert race (duplicate-key / lost update).
|
|
1530
|
+
// This mirrors PostgreSQL's INSERT … ON CONFLICT, which is atomically race-safe by construction.
|
|
1531
|
+
`MERGE INTO ${matTable} WITH (HOLDLOCK) AS t ` +
|
|
1532
|
+
`USING (SELECT ${selectList} FROM (${opts.aggregationSelect}) AS agg ` +
|
|
1533
|
+
`WHERE EXISTS (SELECT 1 FROM ${sourceTable} AS s WHERE ${changedSince} AND ${match})) AS src ` +
|
|
1534
|
+
`ON t.${q(opts.surrogateColumn)} = src.${q(opts.surrogateColumn)} ` +
|
|
1535
|
+
`WHEN MATCHED THEN UPDATE SET ${setList} ` +
|
|
1536
|
+
`WHEN NOT MATCHED THEN INSERT (${insertCols}) VALUES (${insertVals});`,
|
|
1537
|
+
];
|
|
1538
|
+
}
|
|
1539
|
+
/** PostgreSQL incremental upsert — the INSERT…ON CONFLICT counterpart of the SQL Server MERGE above. */
|
|
1540
|
+
static buildIncrementalMergeStatementsPostgreSQL(opts) {
|
|
1541
|
+
const matTable = `${opts.schema}."${opts.tableName}"`;
|
|
1542
|
+
const sourceTable = `${opts.sourceSchema}."${opts.sourceTable}"`;
|
|
1543
|
+
const q = (n) => MaterializationRefresher.quoteIdent(n, true);
|
|
1544
|
+
const changedSince = `s.${q(opts.updatedAtColumn)} > ${opts.watermarkSql}`;
|
|
1545
|
+
const match = MaterializationRefresher.buildKeyMatchPredicate('agg', 's', opts.keyColumns, true);
|
|
1546
|
+
const hashExpr = MaterializationRefresher.buildHashKeyExpression(opts.keyColumns, true);
|
|
1547
|
+
const insertCols = [opts.surrogateColumn, ...opts.dataColumns].map(q).join(', ');
|
|
1548
|
+
const selectList = [hashExpr, ...opts.dataColumns.map((c) => `agg.${q(c)}`)].join(', ');
|
|
1549
|
+
const setList = opts.dataColumns.map((c) => `${q(c)} = EXCLUDED.${q(c)}`).join(', ');
|
|
1550
|
+
return [
|
|
1551
|
+
`INSERT INTO ${matTable} (${insertCols}) ` +
|
|
1552
|
+
`SELECT ${selectList} FROM (${opts.aggregationSelect}) AS agg ` +
|
|
1553
|
+
`WHERE EXISTS (SELECT 1 FROM ${sourceTable} AS s WHERE ${changedSince} AND ${match}) ` +
|
|
1554
|
+
`ON CONFLICT (${q(opts.surrogateColumn)}) DO UPDATE SET ${setList};`,
|
|
1555
|
+
];
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
//# sourceMappingURL=MaterializationRefresher.js.map
|