@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.
@@ -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