@happyvertical/smrt-reports 0.37.2 → 0.37.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/refresh.js CHANGED
@@ -1,428 +1,344 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { buildReportDefinition, compileReportDefinition, getReportGroupingColumns } from "./compiler.js";
2
+ import { REPORT_LOCKS_TABLE, REPORT_RUNS_TABLE, REPORT_WATERMARKS_TABLE, assertReportTablesReady, scopeKeyForTenant } from "./state.js";
3
+ import { buildAggregate, buildWhere, tableExists, validateColumnName } from "@happyvertical/sql";
2
4
  import { ObjectRegistry } from "@happyvertical/smrt-core";
3
5
  import { toSnakeCase } from "@happyvertical/smrt-core/utils";
4
- import { withTenant, getTenantId } from "@happyvertical/smrt-tenancy";
5
- import { tableExists, buildAggregate, validateColumnName, buildWhere } from "@happyvertical/sql";
6
- import { getReportGroupingColumns, buildReportDefinition, compileReportDefinition } from "./compiler.js";
7
- import { assertReportTablesReady, REPORT_RUNS_TABLE, REPORT_WATERMARKS_TABLE, REPORT_LOCKS_TABLE, scopeKeyForTenant } from "./state.js";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { getTenantId, withTenant } from "@happyvertical/smrt-tenancy";
8
+ //#region src/refresh.ts
8
9
  function isSqlAdapterType(value) {
9
- return value === "sqlite" || value === "postgres" || value === "duckdb" || value === "json";
10
+ return value === "sqlite" || value === "postgres" || value === "duckdb" || value === "json";
10
11
  }
11
12
  function adapterTypeFromDb(db, fallback) {
12
- if (isSqlAdapterType(fallback)) return fallback;
13
- const url = String(db.url ?? "");
14
- if (url.startsWith("postgres://") || url.startsWith("postgresql://")) {
15
- return "postgres";
16
- }
17
- if (url.endsWith(".duckdb")) {
18
- return "duckdb";
19
- }
20
- if (url === ":memory:" || url.endsWith(".sqlite") || url.endsWith(".db")) {
21
- return "sqlite";
22
- }
23
- throw new Error(
24
- "Report refresh requires a database adapter type. Pass { adapterType }."
25
- );
13
+ if (isSqlAdapterType(fallback)) return fallback;
14
+ const url = String(db.url ?? "");
15
+ if (url.startsWith("postgres://") || url.startsWith("postgresql://")) return "postgres";
16
+ if (url.endsWith(".duckdb")) return "duckdb";
17
+ if (url === ":memory:" || url.endsWith(".sqlite") || url.endsWith(".db")) return "sqlite";
18
+ throw new Error("Report refresh requires a database adapter type. Pass { adapterType }.");
26
19
  }
27
20
  function stableReportId(values) {
28
- const hash = createHash("sha256").update(JSON.stringify(values)).digest("hex");
29
- const variant = (Number.parseInt(hash[16], 16) & 3 | 8).toString(16);
30
- return [
31
- hash.slice(0, 8),
32
- hash.slice(8, 12),
33
- `4${hash.slice(13, 16)}`,
34
- `${variant}${hash.slice(17, 20)}`,
35
- hash.slice(20, 32)
36
- ].join("-");
21
+ const hash = createHash("sha256").update(JSON.stringify(values)).digest("hex");
22
+ const variant = (Number.parseInt(hash[16], 16) & 3 | 8).toString(16);
23
+ return [
24
+ hash.slice(0, 8),
25
+ hash.slice(8, 12),
26
+ `4${hash.slice(13, 16)}`,
27
+ `${variant}${hash.slice(17, 20)}`,
28
+ hash.slice(20, 32)
29
+ ].join("-");
37
30
  }
38
31
  function getReportTableName(reportCtor) {
39
- const registered = ObjectRegistry.getClassByConstructor(reportCtor) ?? ObjectRegistry.getClass(reportCtor.name);
40
- const tableName = registered ? ObjectRegistry.getTableName(registered.qualifiedName ?? registered.name) : ObjectRegistry.getTableName(reportCtor.name);
41
- if (!tableName) {
42
- throw new Error(`No report table registered for ${reportCtor.name}`);
43
- }
44
- return tableName;
32
+ const registered = ObjectRegistry.getClassByConstructor(reportCtor) ?? ObjectRegistry.getClass(reportCtor.name);
33
+ const tableName = registered ? ObjectRegistry.getTableName(registered.qualifiedName ?? registered.name) : ObjectRegistry.getTableName(reportCtor.name);
34
+ if (!tableName) throw new Error(`No report table registered for ${reportCtor.name}`);
35
+ return tableName;
45
36
  }
46
37
  function getActualGroupingColumns(definition) {
47
- return definition.fields.filter(
48
- (field) => field.report?.kind === "group" || field.report?.kind === "bucket"
49
- ).map((field) => field.columnName ?? toSnakeCase(field.fieldName));
38
+ return definition.fields.filter((field) => field.report?.kind === "group" || field.report?.kind === "bucket").map((field) => field.columnName ?? toSnakeCase(field.fieldName));
50
39
  }
51
40
  function materializeRows(rows, definition, refreshedAt, scope) {
52
- const groupingColumns = getActualGroupingColumns(definition);
53
- const now = refreshedAt.toISOString();
54
- return rows.map((row, index) => {
55
- const groupingValues = groupingColumns.length === 0 ? [definition.reportClassName, scope.scopeKey, index] : [
56
- definition.reportClassName,
57
- scope.scopeKey,
58
- ...groupingColumns.map((column) => row[column])
59
- ];
60
- const id = stableReportId(groupingValues);
61
- const tenantFields = scope.reportTenantColumn ? { [scope.reportTenantColumn]: scope.tenantId } : {};
62
- return {
63
- id,
64
- slug: id,
65
- context: scope.scopeKey,
66
- ...tenantFields,
67
- ...row,
68
- refreshed_at: now,
69
- created_at: now,
70
- updated_at: now
71
- };
72
- });
41
+ const groupingColumns = getActualGroupingColumns(definition);
42
+ const now = refreshedAt.toISOString();
43
+ return rows.map((row, index) => {
44
+ const id = stableReportId(groupingColumns.length === 0 ? [
45
+ definition.reportClassName,
46
+ scope.scopeKey,
47
+ index
48
+ ] : [
49
+ definition.reportClassName,
50
+ scope.scopeKey,
51
+ ...groupingColumns.map((column) => row[column])
52
+ ]);
53
+ const tenantFields = scope.reportTenantColumn ? { [scope.reportTenantColumn]: scope.tenantId } : {};
54
+ return {
55
+ id,
56
+ slug: id,
57
+ context: scope.scopeKey,
58
+ ...tenantFields,
59
+ ...row,
60
+ refreshed_at: now,
61
+ created_at: now,
62
+ updated_at: now
63
+ };
64
+ });
73
65
  }
74
66
  function sourceClassName(source) {
75
- if (typeof source === "string") return source;
76
- return source.name;
67
+ if (typeof source === "string") return source;
68
+ return source.name;
77
69
  }
78
70
  async function fieldColumn(className, requested) {
79
- const fields = await ObjectRegistry.getAllFields(className);
80
- const requestedColumn = toSnakeCase(requested);
81
- for (const [fieldName] of fields.entries()) {
82
- if (fieldName === requested || toSnakeCase(fieldName) === requestedColumn) {
83
- return requestedColumn;
84
- }
85
- }
86
- return null;
71
+ const fields = await ObjectRegistry.getAllFields(className);
72
+ const requestedColumn = toSnakeCase(requested);
73
+ for (const [fieldName] of fields.entries()) if (fieldName === requested || toSnakeCase(fieldName) === requestedColumn) return requestedColumn;
74
+ return null;
87
75
  }
88
76
  async function tenantColumn(className) {
89
- const registered = ObjectRegistry.getClass(className);
90
- const configuredField = registered?.tenantScopedConfig?.field;
91
- if (configuredField) {
92
- return toSnakeCase(configuredField);
93
- }
94
- const fields = await ObjectRegistry.getAllFields(className);
95
- for (const [fieldName, field] of fields.entries()) {
96
- if (field?._meta?.__tenancy?.isTenantIdField) {
97
- return toSnakeCase(fieldName);
98
- }
99
- }
100
- const tenantIdField = await fieldColumn(className, "tenantId");
101
- return tenantIdField;
77
+ const configuredField = ObjectRegistry.getClass(className)?.tenantScopedConfig?.field;
78
+ if (configuredField) return toSnakeCase(configuredField);
79
+ const fields = await ObjectRegistry.getAllFields(className);
80
+ for (const [fieldName, field] of fields.entries()) if (field?._meta?.__tenancy?.isTenantIdField) return toSnakeCase(fieldName);
81
+ return await fieldColumn(className, "tenantId");
102
82
  }
103
83
  async function resolveScope(definition, options) {
104
- const tenantId = options.tenantId !== void 0 ? options.tenantId : getTenantId() ?? null;
105
- const sourceTenantColumn = await tenantColumn(definition.sourceClassName);
106
- const reportTenantColumn = await tenantColumn(definition.reportClassName);
107
- if (tenantId && !sourceTenantColumn) {
108
- throw new Error(
109
- `Tenant-scoped refresh for ${definition.reportClassName} requires source ${definition.sourceClassName} to expose a tenantId field.`
110
- );
111
- }
112
- if (tenantId && !reportTenantColumn) {
113
- throw new Error(
114
- `Tenant-scoped refresh for ${definition.reportClassName} requires the report table to expose a tenantId field.`
115
- );
116
- }
117
- return {
118
- tenantId,
119
- scopeKey: scopeKeyForTenant(tenantId),
120
- sourceTenantColumn,
121
- reportTenantColumn
122
- };
84
+ const tenantId = options.tenantId !== void 0 ? options.tenantId : getTenantId() ?? null;
85
+ const sourceTenantColumn = await tenantColumn(definition.sourceClassName);
86
+ const reportTenantColumn = await tenantColumn(definition.reportClassName);
87
+ if (tenantId && !sourceTenantColumn) throw new Error(`Tenant-scoped refresh for ${definition.reportClassName} requires source ${definition.sourceClassName} to expose a tenantId field.`);
88
+ if (tenantId && !reportTenantColumn) throw new Error(`Tenant-scoped refresh for ${definition.reportClassName} requires the report table to expose a tenantId field.`);
89
+ return {
90
+ tenantId,
91
+ scopeKey: scopeKeyForTenant(tenantId),
92
+ sourceTenantColumn,
93
+ reportTenantColumn
94
+ };
123
95
  }
124
96
  function tenantWhere(scope) {
125
- if (!scope.sourceTenantColumn) return {};
126
- return { [scope.sourceTenantColumn]: scope.tenantId };
97
+ if (!scope.sourceTenantColumn) return {};
98
+ return { [scope.sourceTenantColumn]: scope.tenantId };
127
99
  }
128
100
  function andWhere(base, extra) {
129
- if (Object.keys(extra).length === 0) return base;
130
- if (!base) return extra;
131
- if (Array.isArray(base)) {
132
- return base.map((andGroup) => [...andGroup, extra]);
133
- }
134
- return { ...base, ...extra };
101
+ if (Object.keys(extra).length === 0) return base;
102
+ if (!base) return extra;
103
+ if (Array.isArray(base)) return base.map((andGroup) => [...andGroup, extra]);
104
+ return {
105
+ ...base,
106
+ ...extra
107
+ };
135
108
  }
136
109
  function andHaving(base, extra) {
137
- if (Object.keys(extra).length === 0) return base;
138
- if (!base) return extra;
139
- if (Array.isArray(base)) {
140
- return base.map((andGroup) => [...andGroup, extra]);
141
- }
142
- return { ...base, ...extra };
110
+ if (Object.keys(extra).length === 0) return base;
111
+ if (!base) return extra;
112
+ if (Array.isArray(base)) return base.map((andGroup) => [...andGroup, extra]);
113
+ return {
114
+ ...base,
115
+ ...extra
116
+ };
143
117
  }
144
118
  function withRefreshFilters(spec, scope, extraWhere = {}) {
145
- return {
146
- ...spec,
147
- where: andWhere(spec.where, {
148
- ...tenantWhere(scope),
149
- ...extraWhere
150
- })
151
- };
119
+ return {
120
+ ...spec,
121
+ where: andWhere(spec.where, {
122
+ ...tenantWhere(scope),
123
+ ...extraWhere
124
+ })
125
+ };
152
126
  }
153
127
  async function resolveWatermarkConfig(definition) {
154
- const configured = definition.refresh ?? {};
155
- const watermarkField = configured.watermarkColumn ?? "updatedAt";
156
- const softDeleteField = configured.softDeleteColumn ?? "deletedAt";
157
- const watermarkColumn = await fieldColumn(
158
- definition.sourceClassName,
159
- watermarkField
160
- );
161
- const softDeleteColumn = await fieldColumn(
162
- definition.sourceClassName,
163
- softDeleteField
164
- );
165
- if (!watermarkColumn) {
166
- throw new Error(
167
- `Incremental report ${definition.reportClassName} requires source ${definition.sourceClassName} to define watermark column '${watermarkField}'.`
168
- );
169
- }
170
- if (!softDeleteColumn) {
171
- throw new Error(
172
- `Incremental report ${definition.reportClassName} requires source ${definition.sourceClassName} to define soft-delete column '${softDeleteField}'.`
173
- );
174
- }
175
- return { watermarkColumn, softDeleteColumn };
128
+ const configured = definition.refresh ?? {};
129
+ const watermarkField = configured.watermarkColumn ?? "updatedAt";
130
+ const softDeleteField = configured.softDeleteColumn ?? "deletedAt";
131
+ const watermarkColumn = await fieldColumn(definition.sourceClassName, watermarkField);
132
+ const softDeleteColumn = await fieldColumn(definition.sourceClassName, softDeleteField);
133
+ if (!watermarkColumn) throw new Error(`Incremental report ${definition.reportClassName} requires source ${definition.sourceClassName} to define watermark column '${watermarkField}'.`);
134
+ if (!softDeleteColumn) throw new Error(`Incremental report ${definition.reportClassName} requires source ${definition.sourceClassName} to define soft-delete column '${softDeleteField}'.`);
135
+ return {
136
+ watermarkColumn,
137
+ softDeleteColumn
138
+ };
176
139
  }
177
140
  function compileChangedGroupSpec(definition, scope, watermark, watermarkBefore) {
178
- const spec = compileReportDefinition(definition);
179
- const select = spec.select.filter(
180
- (expr) => !("fn" in expr)
181
- );
182
- return withRefreshFilters(
183
- {
184
- ...spec,
185
- // Ignore the report filter while finding changed groups so rows that
186
- // just stopped matching `report.where` still trigger a recompute/delete.
187
- where: void 0,
188
- select: select.length > 0 ? select : [{ fn: "count", as: "__smrt_changed_count" }],
189
- groupBy: select.length > 0 ? spec.groupBy : [],
190
- having: void 0
191
- },
192
- scope,
193
- { [`${watermark.watermarkColumn} >`]: watermarkBefore }
194
- );
141
+ const spec = compileReportDefinition(definition);
142
+ const select = spec.select.filter((expr) => !("fn" in expr));
143
+ return withRefreshFilters({
144
+ ...spec,
145
+ where: void 0,
146
+ select: select.length > 0 ? select : [{
147
+ fn: "count",
148
+ as: "__smrt_changed_count"
149
+ }],
150
+ groupBy: select.length > 0 ? spec.groupBy : [],
151
+ having: void 0
152
+ }, scope, { [`${watermark.watermarkColumn} >`]: watermarkBefore });
195
153
  }
196
154
  function compileAffectedGroupSpec(definition, scope, watermark, groupRow) {
197
- const groupingColumns = getActualGroupingColumns(definition);
198
- const groupHaving = Object.fromEntries(
199
- groupingColumns.map((column) => [column, groupRow[column]])
200
- );
201
- const spec = withRefreshFilters(compileReportDefinition(definition), scope, {
202
- [watermark.softDeleteColumn]: null
203
- });
204
- return {
205
- ...spec,
206
- having: andHaving(spec.having, groupHaving)
207
- };
155
+ const groupingColumns = getActualGroupingColumns(definition);
156
+ const groupHaving = Object.fromEntries(groupingColumns.map((column) => [column, groupRow[column]]));
157
+ const spec = withRefreshFilters(compileReportDefinition(definition), scope, { [watermark.softDeleteColumn]: null });
158
+ return {
159
+ ...spec,
160
+ having: andHaving(spec.having, groupHaving)
161
+ };
208
162
  }
209
163
  function conflictColumns(definition, scope) {
210
- const groupingColumns = getActualGroupingColumns(definition);
211
- if (groupingColumns.length === 0) return ["id"];
212
- return [
213
- ...scope.reportTenantColumn ? [scope.reportTenantColumn] : [],
214
- ...groupingColumns
215
- ];
164
+ const groupingColumns = getActualGroupingColumns(definition);
165
+ if (groupingColumns.length === 0) return ["id"];
166
+ return [...scope.reportTenantColumn ? [scope.reportTenantColumn] : [], ...groupingColumns];
216
167
  }
217
168
  function predicateSql(tableName, predicate) {
218
- validateColumnName(tableName);
219
- const clauses = [];
220
- const values = [];
221
- for (const [column, value] of Object.entries(predicate)) {
222
- validateColumnName(column);
223
- if (value === null || value === void 0) {
224
- clauses.push(`${column} IS NULL`);
225
- continue;
226
- }
227
- clauses.push(`${column} = ?`);
228
- values.push(value);
229
- }
230
- return {
231
- sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
232
- values
233
- };
169
+ validateColumnName(tableName);
170
+ const clauses = [];
171
+ const values = [];
172
+ for (const [column, value] of Object.entries(predicate)) {
173
+ validateColumnName(column);
174
+ if (value === null || value === void 0) {
175
+ clauses.push(`${column} IS NULL`);
176
+ continue;
177
+ }
178
+ clauses.push(`${column} = ?`);
179
+ values.push(value);
180
+ }
181
+ return {
182
+ sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
183
+ values
184
+ };
234
185
  }
235
186
  function dedupeGroupRows(definition, rows) {
236
- const groupingColumns = getActualGroupingColumns(definition);
237
- const groups = /* @__PURE__ */ new Map();
238
- for (const row of rows) {
239
- if (!row) continue;
240
- const groupRow = groupingColumns.length === 0 ? {} : Object.fromEntries(
241
- groupingColumns.map((column) => [column, row[column] ?? null])
242
- );
243
- const key = groupingColumns.length === 0 ? "__smrt_global_group__" : JSON.stringify(groupingColumns.map((column) => groupRow[column]));
244
- if (!groups.has(key)) {
245
- groups.set(key, groupRow);
246
- }
247
- }
248
- return [...groups.values()];
187
+ const groupingColumns = getActualGroupingColumns(definition);
188
+ const groups = /* @__PURE__ */ new Map();
189
+ for (const row of rows) {
190
+ if (!row) continue;
191
+ const groupRow = groupingColumns.length === 0 ? {} : Object.fromEntries(groupingColumns.map((column) => [column, row[column] ?? null]));
192
+ const key = groupingColumns.length === 0 ? "__smrt_global_group__" : JSON.stringify(groupingColumns.map((column) => groupRow[column]));
193
+ if (!groups.has(key)) groups.set(key, groupRow);
194
+ }
195
+ return [...groups.values()];
249
196
  }
250
197
  async function readMaterializedGroups(db, tableName, definition, scope) {
251
- const groupingColumns = getActualGroupingColumns(definition);
252
- if (groupingColumns.length === 0) {
253
- return [{}];
254
- }
255
- const where = scope.reportTenantColumn ? predicateSql(tableName, { [scope.reportTenantColumn]: scope.tenantId }) : { sql: "", values: [] };
256
- const columns = groupingColumns.map((column) => validateColumnName(column));
257
- const result = await db.query(
258
- `SELECT ${columns.join(", ")} FROM ${validateColumnName(tableName)} ${where.sql}`,
259
- ...where.values
260
- );
261
- return result.rows.map((row) => parseMaybeJson(row)).filter((row) => Boolean(row));
198
+ const groupingColumns = getActualGroupingColumns(definition);
199
+ if (groupingColumns.length === 0) return [{}];
200
+ const where = scope.reportTenantColumn ? predicateSql(tableName, { [scope.reportTenantColumn]: scope.tenantId }) : {
201
+ sql: "",
202
+ values: []
203
+ };
204
+ const columns = groupingColumns.map((column) => validateColumnName(column));
205
+ return (await db.query(`SELECT ${columns.join(", ")} FROM ${validateColumnName(tableName)} ${where.sql}`, ...where.values)).rows.map((row) => parseMaybeJson(row)).filter((row) => Boolean(row));
262
206
  }
263
207
  async function deleteMaterializedGroup(db, tableName, definition, scope, groupRow) {
264
- const predicate = {};
265
- if (scope.reportTenantColumn) {
266
- predicate[scope.reportTenantColumn] = scope.tenantId;
267
- }
268
- for (const column of getActualGroupingColumns(definition)) {
269
- predicate[column] = groupRow[column];
270
- }
271
- const where = predicateSql(tableName, predicate);
272
- await db.query(
273
- `DELETE FROM ${validateColumnName(tableName)} ${where.sql}`,
274
- ...where.values
275
- );
208
+ const predicate = {};
209
+ if (scope.reportTenantColumn) predicate[scope.reportTenantColumn] = scope.tenantId;
210
+ for (const column of getActualGroupingColumns(definition)) predicate[column] = groupRow[column];
211
+ const where = predicateSql(tableName, predicate);
212
+ await db.query(`DELETE FROM ${validateColumnName(tableName)} ${where.sql}`, ...where.values);
276
213
  }
277
214
  async function replaceRows(db, tableName, rows, scope) {
278
- validateColumnName(tableName);
279
- const run = async (tx) => {
280
- if (scope.reportTenantColumn) {
281
- const where = predicateSql(tableName, {
282
- [scope.reportTenantColumn]: scope.tenantId
283
- });
284
- await tx.query(`DELETE FROM ${tableName} ${where.sql}`, ...where.values);
285
- } else {
286
- await tx.query(`DELETE FROM ${tableName}`);
287
- }
288
- if (rows.length > 0) {
289
- await tx.insert(tableName, rows);
290
- }
291
- };
292
- if (db.transaction) {
293
- await db.transaction(run);
294
- return;
295
- }
296
- await run(db);
215
+ validateColumnName(tableName);
216
+ const run = async (tx) => {
217
+ if (scope.reportTenantColumn) {
218
+ const where = predicateSql(tableName, { [scope.reportTenantColumn]: scope.tenantId });
219
+ await tx.query(`DELETE FROM ${tableName} ${where.sql}`, ...where.values);
220
+ } else await tx.query(`DELETE FROM ${tableName}`);
221
+ if (rows.length > 0) await tx.insert(tableName, rows);
222
+ };
223
+ if (db.transaction) {
224
+ await db.transaction(run);
225
+ return;
226
+ }
227
+ await run(db);
297
228
  }
298
229
  function parseMaybeJson(value) {
299
- if (value == null) return null;
300
- if (typeof value !== "string") return value;
301
- try {
302
- return JSON.parse(value);
303
- } catch {
304
- return value;
305
- }
230
+ if (value == null) return null;
231
+ if (typeof value !== "string") return value;
232
+ try {
233
+ return JSON.parse(value);
234
+ } catch {
235
+ return value;
236
+ }
306
237
  }
307
238
  function watermarkValue(value) {
308
- if (value === null || value === void 0) return null;
309
- if (value instanceof Date) return value.toISOString();
310
- return String(value);
239
+ if (value === null || value === void 0) return null;
240
+ if (value instanceof Date) return value.toISOString();
241
+ return String(value);
311
242
  }
312
243
  async function readWatermark(db, definition, scope, watermarkColumn) {
313
- const result = await db.query(
314
- `SELECT watermark_value FROM ${REPORT_WATERMARKS_TABLE}
244
+ const row = (await db.query(`SELECT watermark_value FROM ${REPORT_WATERMARKS_TABLE}
315
245
  WHERE report_class = ?
316
246
  AND scope_key = ?
317
247
  AND source_class = ?
318
248
  AND watermark_column = ?
319
- LIMIT 1`,
320
- definition.reportClassName,
321
- scope.scopeKey,
322
- definition.sourceClassName,
323
- watermarkColumn
324
- );
325
- const row = result.rows[0];
326
- return watermarkValue(row?.watermark_value);
249
+ LIMIT 1`, definition.reportClassName, scope.scopeKey, definition.sourceClassName, watermarkColumn)).rows[0];
250
+ return watermarkValue(row?.watermark_value);
327
251
  }
328
252
  async function writeWatermark(db, definition, scope, watermarkColumn, value, runId) {
329
- if (value === null) return;
330
- const id = stableReportId([
331
- "watermark",
332
- definition.reportClassName,
333
- scope.scopeKey,
334
- definition.sourceClassName,
335
- watermarkColumn
336
- ]);
337
- const now = (/* @__PURE__ */ new Date()).toISOString();
338
- await db.upsert(
339
- REPORT_WATERMARKS_TABLE,
340
- ["report_class", "scope_key", "source_class", "watermark_column"],
341
- {
342
- id,
343
- slug: id,
344
- context: scope.scopeKey,
345
- tenant_id: scope.tenantId,
346
- scope_key: scope.scopeKey,
347
- report_class: definition.reportClassName,
348
- source_class: definition.sourceClassName,
349
- watermark_column: watermarkColumn,
350
- watermark_value: value,
351
- last_run_id: runId ?? null,
352
- created_at: now,
353
- updated_at: now
354
- }
355
- );
253
+ if (value === null) return;
254
+ const id = stableReportId([
255
+ "watermark",
256
+ definition.reportClassName,
257
+ scope.scopeKey,
258
+ definition.sourceClassName,
259
+ watermarkColumn
260
+ ]);
261
+ const now = (/* @__PURE__ */ new Date()).toISOString();
262
+ await db.upsert(REPORT_WATERMARKS_TABLE, [
263
+ "report_class",
264
+ "scope_key",
265
+ "source_class",
266
+ "watermark_column"
267
+ ], {
268
+ id,
269
+ slug: id,
270
+ context: scope.scopeKey,
271
+ tenant_id: scope.tenantId,
272
+ scope_key: scope.scopeKey,
273
+ report_class: definition.reportClassName,
274
+ source_class: definition.sourceClassName,
275
+ watermark_column: watermarkColumn,
276
+ watermark_value: value,
277
+ last_run_id: runId ?? null,
278
+ created_at: now,
279
+ updated_at: now
280
+ });
356
281
  }
357
282
  async function maxWatermark(db, definition, scope, watermarkColumn, adapterType) {
358
- const where = andWhere(void 0, tenantWhere(scope));
359
- const builtWhere = where ? buildWhere(where, 1, adapterType) : { sql: "", values: [] };
360
- const result = await db.query(
361
- `SELECT MAX(${validateColumnName(watermarkColumn)}) AS watermark FROM ${validateColumnName(definition.sourceTable)} ${builtWhere.sql}`,
362
- ...builtWhere.values
363
- );
364
- const row = result.rows[0];
365
- return watermarkValue(row?.watermark);
283
+ const where = andWhere(void 0, tenantWhere(scope));
284
+ const builtWhere = where ? buildWhere(where, 1, adapterType) : {
285
+ sql: "",
286
+ values: []
287
+ };
288
+ const row = (await db.query(`SELECT MAX(${validateColumnName(watermarkColumn)}) AS watermark FROM ${validateColumnName(definition.sourceTable)} ${builtWhere.sql}`, ...builtWhere.values)).rows[0];
289
+ return watermarkValue(row?.watermark);
366
290
  }
367
291
  async function startRun(db, definition, scope, options, mode, watermarkBefore) {
368
- if (options.trackRuns === false) return void 0;
369
- const id = randomUUID();
370
- const now = (/* @__PURE__ */ new Date()).toISOString();
371
- await db.insert(REPORT_RUNS_TABLE, {
372
- id,
373
- slug: id,
374
- context: scope.scopeKey,
375
- tenant_id: scope.tenantId,
376
- scope_key: scope.scopeKey,
377
- report_class: definition.reportClassName,
378
- source_class: definition.sourceClassName,
379
- mode,
380
- trigger: options.trigger ?? "manual",
381
- status: "running",
382
- started_at: now,
383
- row_count: 0,
384
- changed_group_count: 0,
385
- watermark_before: null,
386
- watermark_after: null,
387
- error: null,
388
- metadata: {
389
- scheduleId: options.scheduleId
390
- },
391
- created_at: now,
392
- updated_at: now
393
- });
394
- return id;
292
+ if (options.trackRuns === false) return void 0;
293
+ const id = randomUUID();
294
+ const now = (/* @__PURE__ */ new Date()).toISOString();
295
+ await db.insert(REPORT_RUNS_TABLE, {
296
+ id,
297
+ slug: id,
298
+ context: scope.scopeKey,
299
+ tenant_id: scope.tenantId,
300
+ scope_key: scope.scopeKey,
301
+ report_class: definition.reportClassName,
302
+ source_class: definition.sourceClassName,
303
+ mode,
304
+ trigger: options.trigger ?? "manual",
305
+ status: "running",
306
+ started_at: now,
307
+ row_count: 0,
308
+ changed_group_count: 0,
309
+ watermark_before: watermarkBefore ?? null,
310
+ watermark_after: null,
311
+ error: null,
312
+ metadata: { scheduleId: options.scheduleId },
313
+ created_at: now,
314
+ updated_at: now
315
+ });
316
+ return id;
395
317
  }
396
318
  async function completeRun(db, runId, status, data = {}) {
397
- if (!runId) return;
398
- const error = data.error instanceof Error ? data.error.message : data.error ? String(data.error) : null;
399
- await db.update(
400
- REPORT_RUNS_TABLE,
401
- { id: runId },
402
- {
403
- status,
404
- completed_at: (/* @__PURE__ */ new Date()).toISOString(),
405
- row_count: data.rowCount ?? 0,
406
- changed_group_count: data.changedGroupCount ?? 0,
407
- watermark_after: data.watermarkAfter ?? null,
408
- error,
409
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
410
- }
411
- );
319
+ if (!runId) return;
320
+ const error = data.error instanceof Error ? data.error.message : data.error ? String(data.error) : null;
321
+ await db.update(REPORT_RUNS_TABLE, { id: runId }, {
322
+ status,
323
+ completed_at: (/* @__PURE__ */ new Date()).toISOString(),
324
+ row_count: data.rowCount ?? 0,
325
+ changed_group_count: data.changedGroupCount ?? 0,
326
+ watermark_after: data.watermarkAfter ?? null,
327
+ error,
328
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
329
+ });
412
330
  }
413
331
  async function acquireLock(db, definition, scope, ttlMs) {
414
- const now = /* @__PURE__ */ new Date();
415
- const nowIso = now.toISOString();
416
- const ownerId = randomUUID();
417
- const expiresAt = new Date(now.getTime() + ttlMs);
418
- const expiresAtIso = expiresAt.toISOString();
419
- const id = stableReportId([
420
- "lock",
421
- definition.reportClassName,
422
- scope.scopeKey
423
- ]);
424
- await db.query(
425
- `INSERT INTO ${REPORT_LOCKS_TABLE} (
332
+ const now = /* @__PURE__ */ new Date();
333
+ const nowIso = now.toISOString();
334
+ const ownerId = randomUUID();
335
+ const expiresAtIso = new Date(now.getTime() + ttlMs).toISOString();
336
+ const id = stableReportId([
337
+ "lock",
338
+ definition.reportClassName,
339
+ scope.scopeKey
340
+ ]);
341
+ await db.query(`INSERT INTO ${REPORT_LOCKS_TABLE} (
426
342
  id,
427
343
  slug,
428
344
  context,
@@ -446,333 +362,173 @@ async function acquireLock(db, definition, scope, ttlMs) {
446
362
  updated_at = excluded.updated_at
447
363
  WHERE ${REPORT_LOCKS_TABLE}.owner_id IS NULL
448
364
  OR ${REPORT_LOCKS_TABLE}.expires_at IS NULL
449
- OR ${REPORT_LOCKS_TABLE}.expires_at <= ?`,
450
- id,
451
- id,
452
- scope.scopeKey,
453
- scope.tenantId,
454
- scope.scopeKey,
455
- definition.reportClassName,
456
- ownerId,
457
- nowIso,
458
- nowIso,
459
- expiresAtIso,
460
- nowIso,
461
- nowIso,
462
- nowIso
463
- );
464
- const claimed = await db.query(
465
- `SELECT owner_id FROM ${REPORT_LOCKS_TABLE}
365
+ OR ${REPORT_LOCKS_TABLE}.expires_at <= ?`, id, id, scope.scopeKey, scope.tenantId, scope.scopeKey, definition.reportClassName, ownerId, nowIso, nowIso, expiresAtIso, nowIso, nowIso, nowIso);
366
+ if ((await db.query(`SELECT owner_id FROM _smrt_report_locks
466
367
  WHERE report_class = ? AND scope_key = ?
467
- LIMIT 1`,
468
- definition.reportClassName,
469
- scope.scopeKey
470
- );
471
- const row = claimed.rows[0];
472
- if (row?.owner_id !== ownerId) {
473
- return null;
474
- }
475
- return {
476
- ownerId,
477
- async release() {
478
- await db.query(
479
- `UPDATE ${REPORT_LOCKS_TABLE}
368
+ LIMIT 1`, definition.reportClassName, scope.scopeKey)).rows[0]?.owner_id !== ownerId) return null;
369
+ return {
370
+ ownerId,
371
+ async release() {
372
+ await db.query(`UPDATE ${REPORT_LOCKS_TABLE}
480
373
  SET owner_id = NULL,
481
374
  heartbeat_at = NULL,
482
375
  expires_at = NULL,
483
376
  updated_at = ?
484
377
  WHERE report_class = ?
485
378
  AND scope_key = ?
486
- AND owner_id = ?`,
487
- (/* @__PURE__ */ new Date()).toISOString(),
488
- definition.reportClassName,
489
- scope.scopeKey,
490
- ownerId
491
- );
492
- }
493
- };
379
+ AND owner_id = ?`, (/* @__PURE__ */ new Date()).toISOString(), definition.reportClassName, scope.scopeKey, ownerId);
380
+ }
381
+ };
494
382
  }
495
383
  async function rebuildReport(db, definition, reportTable, scope, adapterType, runId, watermark) {
496
- const explicitWatermarkColumn = definition.refresh?.watermarkColumn && await fieldColumn(
497
- definition.sourceClassName,
498
- definition.refresh.watermarkColumn
499
- );
500
- const explicitSoftDeleteColumn = definition.refresh?.softDeleteColumn && await fieldColumn(
501
- definition.sourceClassName,
502
- definition.refresh.softDeleteColumn
503
- );
504
- const watermarkColumn = watermark?.watermarkColumn ?? explicitWatermarkColumn ?? null;
505
- const softDeleteColumn = watermark?.softDeleteColumn ?? explicitSoftDeleteColumn ?? null;
506
- const deletedFilter = softDeleteColumn ? { [softDeleteColumn]: null } : {};
507
- const spec = withRefreshFilters(
508
- compileReportDefinition(definition),
509
- scope,
510
- deletedFilter
511
- );
512
- const aggregate = buildAggregate(spec, 1, adapterType);
513
- const result = await db.query(aggregate.sql, ...aggregate.values);
514
- const refreshedAt = /* @__PURE__ */ new Date();
515
- const rows = materializeRows(result.rows, definition, refreshedAt, scope);
516
- await replaceRows(db, reportTable, rows, scope);
517
- const watermarkAfter = watermarkColumn ? await maxWatermark(db, definition, scope, watermarkColumn, adapterType) : null;
518
- if (watermarkColumn) {
519
- await writeWatermark(
520
- db,
521
- definition,
522
- scope,
523
- watermarkColumn,
524
- watermarkAfter,
525
- runId
526
- );
527
- }
528
- return {
529
- rowCount: rows.length,
530
- changedGroupCount: rows.length,
531
- watermarkAfter,
532
- mode: "rebuild"
533
- };
384
+ const explicitWatermarkColumn = definition.refresh?.watermarkColumn && await fieldColumn(definition.sourceClassName, definition.refresh.watermarkColumn);
385
+ const explicitSoftDeleteColumn = definition.refresh?.softDeleteColumn && await fieldColumn(definition.sourceClassName, definition.refresh.softDeleteColumn);
386
+ const watermarkColumn = watermark?.watermarkColumn ?? explicitWatermarkColumn ?? null;
387
+ const softDeleteColumn = watermark?.softDeleteColumn ?? explicitSoftDeleteColumn ?? null;
388
+ const deletedFilter = softDeleteColumn ? { [softDeleteColumn]: null } : {};
389
+ const aggregate = buildAggregate(withRefreshFilters(compileReportDefinition(definition), scope, deletedFilter), 1, adapterType);
390
+ const result = await db.query(aggregate.sql, ...aggregate.values);
391
+ const refreshedAt = /* @__PURE__ */ new Date();
392
+ const rows = materializeRows(result.rows, definition, refreshedAt, scope);
393
+ await replaceRows(db, reportTable, rows, scope);
394
+ const watermarkAfter = watermarkColumn ? await maxWatermark(db, definition, scope, watermarkColumn, adapterType) : null;
395
+ if (watermarkColumn) await writeWatermark(db, definition, scope, watermarkColumn, watermarkAfter, runId);
396
+ return {
397
+ rowCount: rows.length,
398
+ changedGroupCount: rows.length,
399
+ watermarkAfter,
400
+ mode: "rebuild"
401
+ };
534
402
  }
535
403
  async function incrementalReport(db, definition, reportTable, scope, adapterType, runId, changedRows = []) {
536
- const watermark = await resolveWatermarkConfig(definition);
537
- const watermarkBefore = await readWatermark(
538
- db,
539
- definition,
540
- scope,
541
- watermark.watermarkColumn
542
- );
543
- if (!watermarkBefore) {
544
- const seeded = await rebuildReport(
545
- db,
546
- definition,
547
- reportTable,
548
- scope,
549
- adapterType,
550
- runId,
551
- watermark
552
- );
553
- return {
554
- ...seeded,
555
- watermarkBefore
556
- };
557
- }
558
- const changedSpec = compileChangedGroupSpec(
559
- definition,
560
- scope,
561
- watermark,
562
- watermarkBefore
563
- );
564
- const changedAggregate = buildAggregate(changedSpec, 1, adapterType);
565
- const changed = await db.query(
566
- changedAggregate.sql,
567
- ...changedAggregate.values
568
- );
569
- const changedGroups = changed.rows.map(
570
- (row) => parseMaybeJson(row)
571
- );
572
- const materializedGroups = changedGroups.length > 0 || changedRows.length > 0 ? await readMaterializedGroups(db, reportTable, definition, scope) : [];
573
- const affectedGroups = dedupeGroupRows(definition, [
574
- ...changedGroups,
575
- ...materializedGroups
576
- ]);
577
- if (affectedGroups.length === 0) {
578
- return {
579
- rowCount: 0,
580
- changedGroupCount: 0,
581
- watermarkBefore,
582
- watermarkAfter: watermarkBefore,
583
- mode: "incremental"
584
- };
585
- }
586
- let rowCount = 0;
587
- const conflicts = conflictColumns(definition, scope);
588
- for (const groupRow of affectedGroups) {
589
- const affectedSpec = compileAffectedGroupSpec(
590
- definition,
591
- scope,
592
- watermark,
593
- groupRow
594
- );
595
- const aggregate = buildAggregate(affectedSpec, 1, adapterType);
596
- const result = await db.query(aggregate.sql, ...aggregate.values);
597
- const refreshedAt = /* @__PURE__ */ new Date();
598
- const rows = materializeRows(result.rows, definition, refreshedAt, scope);
599
- if (rows.length === 0) {
600
- await deleteMaterializedGroup(
601
- db,
602
- reportTable,
603
- definition,
604
- scope,
605
- groupRow
606
- );
607
- continue;
608
- }
609
- for (const row of rows) {
610
- await db.upsert(reportTable, conflicts, row);
611
- rowCount += 1;
612
- }
613
- }
614
- const watermarkAfter = await maxWatermark(
615
- db,
616
- definition,
617
- scope,
618
- watermark.watermarkColumn,
619
- adapterType
620
- );
621
- await writeWatermark(
622
- db,
623
- definition,
624
- scope,
625
- watermark.watermarkColumn,
626
- watermarkAfter,
627
- runId
628
- );
629
- return {
630
- rowCount,
631
- changedGroupCount: affectedGroups.length,
632
- watermarkBefore,
633
- watermarkAfter,
634
- mode: "incremental"
635
- };
404
+ const watermark = await resolveWatermarkConfig(definition);
405
+ const watermarkBefore = await readWatermark(db, definition, scope, watermark.watermarkColumn);
406
+ if (!watermarkBefore) return {
407
+ ...await rebuildReport(db, definition, reportTable, scope, adapterType, runId, watermark),
408
+ watermarkBefore
409
+ };
410
+ const changedAggregate = buildAggregate(compileChangedGroupSpec(definition, scope, watermark, watermarkBefore), 1, adapterType);
411
+ const changedGroups = (await db.query(changedAggregate.sql, ...changedAggregate.values)).rows.map((row) => parseMaybeJson(row));
412
+ const materializedGroups = changedGroups.length > 0 || changedRows.length > 0 ? await readMaterializedGroups(db, reportTable, definition, scope) : [];
413
+ const affectedGroups = dedupeGroupRows(definition, [...changedGroups, ...materializedGroups]);
414
+ if (affectedGroups.length === 0) return {
415
+ rowCount: 0,
416
+ changedGroupCount: 0,
417
+ watermarkBefore,
418
+ watermarkAfter: watermarkBefore,
419
+ mode: "incremental"
420
+ };
421
+ let rowCount = 0;
422
+ const conflicts = conflictColumns(definition, scope);
423
+ for (const groupRow of affectedGroups) {
424
+ const aggregate = buildAggregate(compileAffectedGroupSpec(definition, scope, watermark, groupRow), 1, adapterType);
425
+ const result = await db.query(aggregate.sql, ...aggregate.values);
426
+ const refreshedAt = /* @__PURE__ */ new Date();
427
+ const rows = materializeRows(result.rows, definition, refreshedAt, scope);
428
+ if (rows.length === 0) {
429
+ await deleteMaterializedGroup(db, reportTable, definition, scope, groupRow);
430
+ continue;
431
+ }
432
+ for (const row of rows) {
433
+ await db.upsert(reportTable, conflicts, row);
434
+ rowCount += 1;
435
+ }
436
+ }
437
+ const watermarkAfter = await maxWatermark(db, definition, scope, watermark.watermarkColumn, adapterType);
438
+ await writeWatermark(db, definition, scope, watermark.watermarkColumn, watermarkAfter, runId);
439
+ return {
440
+ rowCount,
441
+ changedGroupCount: affectedGroups.length,
442
+ watermarkBefore,
443
+ watermarkAfter,
444
+ mode: "incremental"
445
+ };
636
446
  }
637
447
  function requiredReportRuntimeTables(mode, options) {
638
- const tables = /* @__PURE__ */ new Set();
639
- if (options.trackRuns !== false) tables.add(REPORT_RUNS_TABLE);
640
- if (mode === "incremental") tables.add(REPORT_WATERMARKS_TABLE);
641
- if (options.lock !== false) tables.add(REPORT_LOCKS_TABLE);
642
- return [...tables];
448
+ const tables = /* @__PURE__ */ new Set();
449
+ if (options.trackRuns !== false) tables.add(REPORT_RUNS_TABLE);
450
+ if (mode === "incremental") tables.add(REPORT_WATERMARKS_TABLE);
451
+ if (options.lock !== false) tables.add(REPORT_LOCKS_TABLE);
452
+ return [...tables];
643
453
  }
644
454
  async function refreshReportOnce(reportCtor, options) {
645
- if (!options.db) {
646
- throw new Error("refreshReport requires a database handle");
647
- }
648
- const definition = await buildReportDefinition(reportCtor);
649
- const reportTable = getReportTableName(reportCtor);
650
- const requestedMode = options.mode ?? definition.refresh?.mode ?? "rebuild";
651
- const exists = await tableExists(options.db, reportTable);
652
- if (!exists) {
653
- throw new Error(
654
- `Report table '${reportTable}' does not exist for ${reportCtor.name}. Run smrt db:migrate before refreshing reports.`
655
- );
656
- }
657
- const requiredTables = requiredReportRuntimeTables(requestedMode, options);
658
- if (requiredTables.length > 0) {
659
- await assertReportTablesReady(options.db, requiredTables);
660
- }
661
- const adapterType = adapterTypeFromDb(options.db, options.adapterType);
662
- const scope = await resolveScope(definition, options);
663
- const lock = options.lock === false ? null : await acquireLock(
664
- options.db,
665
- definition,
666
- scope,
667
- options.lockTtlMs ?? 15 * 60 * 1e3
668
- );
669
- if (options.lock !== false && !lock) {
670
- const runId2 = await startRun(
671
- options.db,
672
- definition,
673
- scope,
674
- options,
675
- requestedMode
676
- );
677
- await completeRun(options.db, runId2, "skipped");
678
- return {
679
- rowCount: 0,
680
- refreshedAt: /* @__PURE__ */ new Date(),
681
- mode: requestedMode,
682
- tenantId: scope.tenantId,
683
- runId: runId2,
684
- changedGroupCount: 0,
685
- skipped: true
686
- };
687
- }
688
- let runId;
689
- try {
690
- runId = await startRun(
691
- options.db,
692
- definition,
693
- scope,
694
- options,
695
- requestedMode
696
- );
697
- const work = requestedMode === "incremental" ? await incrementalReport(
698
- options.db,
699
- definition,
700
- reportTable,
701
- scope,
702
- adapterType,
703
- runId,
704
- options.changedRows
705
- ) : await rebuildReport(
706
- options.db,
707
- definition,
708
- reportTable,
709
- scope,
710
- adapterType,
711
- runId
712
- );
713
- await completeRun(options.db, runId, "success", {
714
- rowCount: work.rowCount,
715
- changedGroupCount: work.changedGroupCount,
716
- watermarkAfter: work.watermarkAfter
717
- });
718
- return {
719
- rowCount: work.rowCount,
720
- refreshedAt: /* @__PURE__ */ new Date(),
721
- mode: work.mode,
722
- tenantId: scope.tenantId,
723
- runId,
724
- changedGroupCount: work.changedGroupCount
725
- };
726
- } catch (error) {
727
- await completeRun(options.db, runId, "failed", { error });
728
- throw error;
729
- } finally {
730
- await lock?.release();
731
- }
455
+ if (!options.db) throw new Error("refreshReport requires a database handle");
456
+ const definition = await buildReportDefinition(reportCtor);
457
+ const reportTable = getReportTableName(reportCtor);
458
+ const requestedMode = options.mode ?? definition.refresh?.mode ?? "rebuild";
459
+ if (!await tableExists(options.db, reportTable)) throw new Error(`Report table '${reportTable}' does not exist for ${reportCtor.name}. Run smrt db:migrate before refreshing reports.`);
460
+ const requiredTables = requiredReportRuntimeTables(requestedMode, options);
461
+ if (requiredTables.length > 0) await assertReportTablesReady(options.db, requiredTables);
462
+ const adapterType = adapterTypeFromDb(options.db, options.adapterType);
463
+ const scope = await resolveScope(definition, options);
464
+ const lock = options.lock === false ? null : await acquireLock(options.db, definition, scope, options.lockTtlMs ?? 900 * 1e3);
465
+ if (options.lock !== false && !lock) {
466
+ const runId2 = await startRun(options.db, definition, scope, options, requestedMode);
467
+ await completeRun(options.db, runId2, "skipped");
468
+ return {
469
+ rowCount: 0,
470
+ refreshedAt: /* @__PURE__ */ new Date(),
471
+ mode: requestedMode,
472
+ tenantId: scope.tenantId,
473
+ runId: runId2,
474
+ changedGroupCount: 0,
475
+ skipped: true
476
+ };
477
+ }
478
+ let runId;
479
+ try {
480
+ runId = await startRun(options.db, definition, scope, options, requestedMode);
481
+ const work = requestedMode === "incremental" ? await incrementalReport(options.db, definition, reportTable, scope, adapterType, runId, options.changedRows) : await rebuildReport(options.db, definition, reportTable, scope, adapterType, runId);
482
+ await completeRun(options.db, runId, "success", {
483
+ rowCount: work.rowCount,
484
+ changedGroupCount: work.changedGroupCount,
485
+ watermarkAfter: work.watermarkAfter
486
+ });
487
+ return {
488
+ rowCount: work.rowCount,
489
+ refreshedAt: /* @__PURE__ */ new Date(),
490
+ mode: work.mode,
491
+ tenantId: scope.tenantId,
492
+ runId,
493
+ changedGroupCount: work.changedGroupCount
494
+ };
495
+ } catch (error) {
496
+ await completeRun(options.db, runId, "failed", { error });
497
+ throw error;
498
+ } finally {
499
+ await lock?.release();
500
+ }
732
501
  }
733
502
  async function refreshReport(reportCtor, options = {}) {
734
- if (options.tenantIds && options.tenantIds.length > 0) {
735
- if (!options.db) {
736
- throw new Error("refreshReport requires a database handle");
737
- }
738
- const results = [];
739
- for (const tenantId of options.tenantIds) {
740
- const result = await withTenant(
741
- { tenantId },
742
- () => refreshReportOnce(reportCtor, {
743
- ...options,
744
- tenantId,
745
- tenantIds: void 0
746
- })
747
- );
748
- results.push(result);
749
- }
750
- return {
751
- rowCount: results.reduce((sum, result) => sum + result.rowCount, 0),
752
- refreshedAt: /* @__PURE__ */ new Date(),
753
- mode: results[0]?.mode ?? options.mode ?? "rebuild",
754
- tenantId: null,
755
- changedGroupCount: results.reduce(
756
- (sum, result) => sum + (result.changedGroupCount ?? 0),
757
- 0
758
- ),
759
- tenantResults: results
760
- };
761
- }
762
- return refreshReportOnce(reportCtor, options);
503
+ if (options.tenantIds && options.tenantIds.length > 0) {
504
+ if (!options.db) throw new Error("refreshReport requires a database handle");
505
+ const results = [];
506
+ for (const tenantId of options.tenantIds) {
507
+ const result = await withTenant({ tenantId }, () => refreshReportOnce(reportCtor, {
508
+ ...options,
509
+ tenantId,
510
+ tenantIds: void 0
511
+ }));
512
+ results.push(result);
513
+ }
514
+ return {
515
+ rowCount: results.reduce((sum, result) => sum + result.rowCount, 0),
516
+ refreshedAt: /* @__PURE__ */ new Date(),
517
+ mode: results[0]?.mode ?? options.mode ?? "rebuild",
518
+ tenantId: null,
519
+ changedGroupCount: results.reduce((sum, result) => sum + (result.changedGroupCount ?? 0), 0),
520
+ tenantResults: results
521
+ };
522
+ }
523
+ return refreshReportOnce(reportCtor, options);
763
524
  }
764
525
  function reportRowIdentity(row, definition) {
765
- const groupingColumns = getReportGroupingColumns(definition);
766
- return stableReportId(
767
- groupingColumns.map((column) => row[toSnakeCase(column)])
768
- );
526
+ return stableReportId(getReportGroupingColumns(definition).map((column) => row[toSnakeCase(column)]));
769
527
  }
770
528
  function reportSourceClassName(source) {
771
- return sourceClassName(source);
772
- }
773
- export {
774
- refreshReport,
775
- reportRowIdentity,
776
- reportSourceClassName
777
- };
778
- //# sourceMappingURL=refresh.js.map
529
+ return sourceClassName(source);
530
+ }
531
+ //#endregion
532
+ export { refreshReport, reportRowIdentity, reportSourceClassName };
533
+
534
+ //# sourceMappingURL=refresh.js.map