@happyvertical/smrt-reports 0.49.2 → 0.49.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.
@@ -1,5 +1,8 @@
1
1
  import { DatabaseInterface } from '@happyvertical/sql';
2
+ import { DurableJobPayloadIntegrity } from '@happyvertical/smrt-jobs';
3
+ import { DurableJobPayloadSigner } from '@happyvertical/smrt-jobs';
2
4
  import { EventEmitter } from 'node:events';
5
+ import { JobExecutionContext } from '@happyvertical/smrt-jobs';
3
6
  import { SmrtJob } from '@happyvertical/smrt-jobs';
4
7
  import { SmrtObject } from '@happyvertical/smrt-core';
5
8
  import { SqlAdapterType } from '@happyvertical/sql';
@@ -15,6 +18,8 @@ export declare interface EnqueueReportRefreshOptions extends ReportRefreshJobArg
15
18
  timeout?: number;
16
19
  maxAttempts?: number;
17
20
  tenantJobCap?: number;
21
+ /** Server-only signer; register the same key in every worker process. */
22
+ integritySigner?: DurableJobPayloadSigner;
18
23
  }
19
24
 
20
25
  export declare function ensureReportRefreshSchedules(options: EnsureReportSchedulesOptions): Promise<void>;
@@ -28,10 +33,48 @@ export declare interface EnsureReportSchedulesOptions {
28
33
  timeout?: number;
29
34
  }
30
35
 
36
+ export declare function registerReportRefreshExecutionAuthorityHost(hostId: string, host: ReportRefreshExecutionAuthorityHost): () => void;
37
+
31
38
  export declare function registerReportRefreshInterceptor(options: ReportRefreshInterceptorOptions): () => boolean;
32
39
 
40
+ export declare function registerReportRefreshJobIntegritySigner(signer: DurableJobPayloadSigner): () => void;
41
+
33
42
  declare type ReportCtor = new (...args: any[]) => SmrtObject;
34
43
 
44
+ export declare interface ReportExecutionPrincipalReference {
45
+ version: 1;
46
+ actorUserId: string;
47
+ tenantId: string | null;
48
+ onBehalfOfUserId?: string | null;
49
+ actsAsProfileId?: string | null;
50
+ agentClass?: string | null;
51
+ }
52
+
53
+ export declare interface ReportRefreshExecutionAuditEvent extends ReportRefreshExecutionAuthorityContext {
54
+ outcome: 'allowed' | 'denied';
55
+ principal: ReportExecutionPrincipalReference;
56
+ reason?: string;
57
+ }
58
+
59
+ export declare interface ReportRefreshExecutionAuthority {
60
+ version: 1;
61
+ hostId: string;
62
+ principal: ReportExecutionPrincipalReference;
63
+ }
64
+
65
+ export declare interface ReportRefreshExecutionAuthorityContext {
66
+ phase: 'execute';
67
+ reportClass: string;
68
+ mode: ReportRefreshMode;
69
+ trigger: ReportRefreshTrigger;
70
+ tenantId: string | null;
71
+ }
72
+
73
+ export declare interface ReportRefreshExecutionAuthorityHost {
74
+ authorize(principal: Readonly<ReportExecutionPrincipalReference>, context: Readonly<ReportRefreshExecutionAuthorityContext>): Promise<void> | void;
75
+ audit(event: Readonly<ReportRefreshExecutionAuditEvent>): Promise<void> | void;
76
+ }
77
+
35
78
  export declare interface ReportRefreshInterceptorOptions {
36
79
  db: DatabaseInterface;
37
80
  reports: ReportCtor[];
@@ -41,6 +84,7 @@ export declare interface ReportRefreshInterceptorOptions {
41
84
  timeout?: number;
42
85
  tenantJobCap?: number;
43
86
  name?: string;
87
+ integritySigner?: DurableJobPayloadSigner;
44
88
  }
45
89
 
46
90
  export declare interface ReportRefreshJobArgs {
@@ -53,6 +97,8 @@ export declare interface ReportRefreshJobArgs {
53
97
  adapterType?: SqlAdapterType;
54
98
  changedRows?: Record<string, unknown>[];
55
99
  _scheduleId?: string;
100
+ executionAuthority?: ReportRefreshExecutionAuthority;
101
+ integrity?: DurableJobPayloadIntegrity;
56
102
  }
57
103
 
58
104
  declare type ReportRefreshMode = 'rebuild' | 'incremental';
@@ -88,6 +134,7 @@ export declare interface ReportScheduleRunnerConfig {
88
134
  id?: string;
89
135
  pollInterval?: number;
90
136
  batchSize?: number;
137
+ integritySigner?: DurableJobPayloadSigner;
91
138
  }
92
139
 
93
140
  export declare interface ReportScheduleRunnerEvents {
@@ -100,13 +147,18 @@ export declare interface ReportScheduleRunnerEvents {
100
147
  'runner:error': (error: Error) => void;
101
148
  }
102
149
 
150
+ /** Worker target whose authority requirement cannot be downgraded by job args. */
151
+ export declare class SmrtPrincipalReportRefreshTask extends SmrtReportRefreshTask {
152
+ run(args?: ReportRefreshJobArgs, context?: JobExecutionContext): Promise<unknown>;
153
+ }
154
+
103
155
  export declare class SmrtReportRefreshTask extends SmrtObject {
104
156
  tenantId: string | null;
105
157
  reportClass: string;
106
158
  mode: ReportRefreshMode;
107
159
  trigger: ReportRefreshTrigger;
108
160
  args: ReportRefreshJobArgs;
109
- run(args?: ReportRefreshJobArgs): Promise<unknown>;
161
+ run(args?: ReportRefreshJobArgs, context?: JobExecutionContext): Promise<unknown>;
110
162
  }
111
163
 
112
164
  export { }
package/dist/scheduler.js CHANGED
@@ -5,7 +5,7 @@ import { GlobalInterceptors, ObjectRegistry, SmrtObject, field, smrt } from "@ha
5
5
  import { TenantScoped, getTenantId, tenantId } from "@happyvertical/smrt-tenancy";
6
6
  import { createHash } from "node:crypto";
7
7
  import { EventEmitter } from "node:events";
8
- import { SmrtJobCollection, backgroundEligible, getNextCronDate, validateCronExpression } from "@happyvertical/smrt-jobs";
8
+ import { SmrtJobCollection, backgroundEligible, getActiveJobExecutionContext, getNextCronDate, isRunnerExecutionContext, validateCronExpression } from "@happyvertical/smrt-jobs";
9
9
  //#region src/scheduler.ts
10
10
  var __defProp = Object.defineProperty;
11
11
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -24,6 +24,45 @@ var INTERNAL_SURFACE = {
24
24
  },
25
25
  mcp: false
26
26
  };
27
+ var executionAuthorityHosts = /* @__PURE__ */ new Map();
28
+ var jobIntegritySigners = /* @__PURE__ */ new Map();
29
+ function registerReportRefreshJobIntegritySigner(signer) {
30
+ const existing = jobIntegritySigners.get(signer.keyId);
31
+ if (existing && existing !== signer) throw new Error(`Report refresh job integrity signer already registered: ${signer.keyId}`);
32
+ jobIntegritySigners.set(signer.keyId, signer);
33
+ return () => {
34
+ if (jobIntegritySigners.get(signer.keyId) === signer) jobIntegritySigners.delete(signer.keyId);
35
+ };
36
+ }
37
+ function resolveReportRefreshIntegritySigner(configured) {
38
+ if (configured) return configured;
39
+ if (jobIntegritySigners.size !== 1) return void 0;
40
+ return jobIntegritySigners.values().next().value;
41
+ }
42
+ function unsignedReportRefreshJobArgs(args) {
43
+ const { integrity: _integrity, _scheduleId: _internalScheduleId, ...unsigned } = args;
44
+ return unsigned;
45
+ }
46
+ function assertReportRefreshJobIntegrity(args) {
47
+ const integrity = args.integrity;
48
+ const signer = integrity ? jobIntegritySigners.get(integrity.keyId) : void 0;
49
+ if (!integrity || !signer?.verify(unsignedReportRefreshJobArgs(args), integrity)) throw new Error("Invalid durable report refresh job integrity binding");
50
+ }
51
+ function assertReportRefreshJobTarget(args, context) {
52
+ if (!context) return;
53
+ if (!isRunnerExecutionContext(context)) throw new Error("Invalid durable report refresh job context");
54
+ const expectedType = canonicalClassName((args.trigger ?? "job") === "manual" ? SmrtPrincipalReportRefreshTask : SmrtReportRefreshTask);
55
+ if (context.job.objectType !== expectedType || context.job.method !== "run") throw new Error("Invalid durable report refresh job target");
56
+ }
57
+ function registerReportRefreshExecutionAuthorityHost(hostId, host) {
58
+ if (!hostId || hostId.length > 256) throw new Error("Report refresh authority hostId must contain 1-256 characters");
59
+ const existing = executionAuthorityHosts.get(hostId);
60
+ if (existing && existing !== host) throw new Error(`Report refresh authority host already registered: ${hostId}`);
61
+ executionAuthorityHosts.set(hostId, host);
62
+ return () => {
63
+ if (executionAuthorityHosts.get(hostId) === host) executionAuthorityHosts.delete(hostId);
64
+ };
65
+ }
27
66
  function stableUuid(values) {
28
67
  const hash = createHash("sha256").update(JSON.stringify(values)).digest("hex");
29
68
  const variant = (Number.parseInt(hash[16], 16) & 3 | 8).toString(16);
@@ -70,20 +109,68 @@ function changedRowSnapshot(instance) {
70
109
  const serializable = instance.toJSON();
71
110
  return serializable && typeof serializable === "object" ? serializable : {};
72
111
  }
112
+ async function authorizeReportRefreshExecution(args, reportClass, jobTenantId) {
113
+ if ((args.tenantId ?? null) !== jobTenantId) throw new Error("Invalid report refresh execution tenant");
114
+ const authority = args.executionAuthority;
115
+ if ((args.trigger ?? "job") === "manual" && !authority) throw new Error("Manual report refresh execution authority is missing");
116
+ if (!authority) return;
117
+ const principal = authority.principal;
118
+ const tenantId2 = args.tenantId ?? null;
119
+ if (authority.version !== 1 || principal?.version !== 1 || !authority.hostId || !principal.actorUserId || principal.tenantId !== tenantId2 || principal.tenantId !== jobTenantId) throw new Error("Invalid report refresh execution authority");
120
+ const host = executionAuthorityHosts.get(authority.hostId);
121
+ if (!host) throw new Error(`No report refresh authority host registered for ${authority.hostId}`);
122
+ const context = {
123
+ phase: "execute",
124
+ reportClass,
125
+ mode: args.mode ?? "incremental",
126
+ trigger: args.trigger ?? "job",
127
+ tenantId: tenantId2
128
+ };
129
+ try {
130
+ await host.authorize(Object.freeze({ ...principal }), Object.freeze(context));
131
+ } catch {
132
+ await host.audit({
133
+ ...context,
134
+ outcome: "denied",
135
+ principal: Object.freeze({ ...principal }),
136
+ reason: "current_authority_denied"
137
+ });
138
+ throw new Error("Report refresh execution authority denied");
139
+ }
140
+ await host.audit({
141
+ ...context,
142
+ outcome: "allowed",
143
+ principal: Object.freeze({ ...principal })
144
+ });
145
+ }
73
146
  var SmrtReportRefreshTask = class extends SmrtObject {
74
147
  tenantId = null;
75
148
  reportClass = "";
76
149
  mode = "incremental";
77
150
  trigger = "job";
78
151
  args = {};
79
- async run(args = {}) {
80
- const reportClass = args.reportClass || this.reportClass;
152
+ async run(args = {}, context) {
153
+ assertReportRefreshJobIntegrity(args);
154
+ const executionContext = getActiveJobExecutionContext() ?? context;
155
+ assertReportRefreshJobTarget(args, executionContext);
156
+ const reportClass = args.reportClass;
81
157
  if (!reportClass) throw new Error("Report refresh job requires reportClass");
82
- return refreshReport(resolveReportClass(reportClass), {
158
+ const mode = args.mode ?? "incremental";
159
+ const trigger = args.trigger ?? "job";
160
+ const reportCtor = resolveReportClass(reportClass);
161
+ let jobTenantId;
162
+ if (executionContext) {
163
+ const runnerTenantId = executionContext.job.tenantId;
164
+ if (runnerTenantId === null) jobTenantId = null;
165
+ else if (typeof runnerTenantId === "string" && runnerTenantId.length > 0) jobTenantId = runnerTenantId;
166
+ else throw new Error("Invalid report refresh execution tenant");
167
+ } else jobTenantId = tenantIdFromInstance(this);
168
+ await authorizeReportRefreshExecution(args, reportClass, jobTenantId);
169
+ return refreshReport(reportCtor, {
83
170
  db: this.db,
84
- mode: args.mode ?? this.mode,
85
- trigger: args.trigger ?? this.trigger,
86
- tenantId: args.tenantId,
171
+ mode,
172
+ trigger,
173
+ tenantId: jobTenantId,
87
174
  tenantIds: args.tenantIds,
88
175
  adapterType: args.adapterType,
89
176
  scheduleId: args.scheduleId ?? args._scheduleId,
@@ -110,11 +197,41 @@ SmrtReportRefreshTask = __decorateClass([TenantScoped({ mode: "optional" }), smr
110
197
  tableName: "_smrt_report_refresh_tasks",
111
198
  ...INTERNAL_SURFACE
112
199
  })], SmrtReportRefreshTask);
200
+ var SmrtPrincipalReportRefreshTask = class extends SmrtReportRefreshTask {
201
+ async run(args = {}, context) {
202
+ return super.run({
203
+ ...args,
204
+ trigger: "manual"
205
+ }, context);
206
+ }
207
+ };
208
+ SmrtPrincipalReportRefreshTask = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
209
+ tableName: "_smrt_principal_report_refresh_tasks",
210
+ ...INTERNAL_SURFACE
211
+ })], SmrtPrincipalReportRefreshTask);
113
212
  async function enqueueReportRefresh(options) {
213
+ const integritySigner = resolveReportRefreshIntegritySigner(options.integritySigner);
214
+ if (!integritySigner) throw new Error("Report refresh queue requires a durable job integrity signer");
215
+ if (options.trigger === "manual" && !options.executionAuthority) throw new Error("Manual report refresh requires execution-time authority");
216
+ if (options.executionAuthority && (options.executionAuthority.version !== 1 || options.executionAuthority.principal?.version !== 1 || !options.executionAuthority.hostId || !options.executionAuthority.principal.actorUserId || options.executionAuthority.principal.tenantId !== (options.tenantId ?? null))) throw new Error("Invalid report refresh execution authority");
217
+ if (options.executionAuthority && (options.trigger !== "manual" || (options.tenantIds?.length ?? 0) > 0)) throw new Error("Principal-bound report refresh requires one manual tenant scope");
114
218
  await ObjectRegistry.ensureManifestLoaded("SmrtJob");
115
219
  const collection = await SmrtJobCollection.create({ db: options.db });
116
- const taskType = canonicalClassName(SmrtReportRefreshTask);
220
+ const taskType = canonicalClassName(options.trigger === "manual" ? SmrtPrincipalReportRefreshTask : SmrtReportRefreshTask);
117
221
  const scheduleId = options.scheduleId ?? options._scheduleId;
222
+ const unsignedArgs = {
223
+ reportClass: options.reportClass,
224
+ mode: options.mode ?? "incremental",
225
+ trigger: options.trigger ?? "job",
226
+ tenantId: options.tenantId,
227
+ tenantIds: options.tenantIds,
228
+ adapterType: options.adapterType,
229
+ changedRows: options.changedRows,
230
+ scheduleId,
231
+ executionAuthority: options.executionAuthority
232
+ };
233
+ const integrity = integritySigner.sign(unsignedArgs);
234
+ if (!integritySigner.verify(unsignedArgs, integrity)) throw new Error("Report refresh job integrity signer rejected its queued payload");
118
235
  return collection.enqueueJob({
119
236
  tenantId: options.tenantId ?? null,
120
237
  queue: options.queue ?? "reports",
@@ -122,15 +239,9 @@ async function enqueueReportRefresh(options) {
122
239
  objectId: null,
123
240
  method: "run",
124
241
  args: {
125
- reportClass: options.reportClass,
126
- mode: options.mode,
127
- trigger: options.trigger ?? "job",
128
- tenantId: options.tenantId,
129
- tenantIds: options.tenantIds,
130
- adapterType: options.adapterType,
131
- changedRows: options.changedRows,
132
- scheduleId,
133
- _scheduleId: scheduleId
242
+ ...unsignedArgs,
243
+ _scheduleId: scheduleId,
244
+ integrity
134
245
  },
135
246
  priority: options.priority ?? 70,
136
247
  timeout: options.timeout ?? 36e5,
@@ -214,7 +325,8 @@ var ReportScheduleRunner = class extends EventEmitter {
214
325
  this.config = {
215
326
  id: config.id || `reports_${stableUuid([Date.now()]).slice(0, 8)}`,
216
327
  pollInterval: config.pollInterval ?? 6e4,
217
- batchSize: config.batchSize ?? 50
328
+ batchSize: config.batchSize ?? 50,
329
+ integritySigner: config.integritySigner
218
330
  };
219
331
  this.id = this.config.id;
220
332
  }
@@ -315,7 +427,8 @@ var ReportScheduleRunner = class extends EventEmitter {
315
427
  scheduleId: schedule.id,
316
428
  queue: String(row.queue || "reports"),
317
429
  priority: Number(row.priority ?? 70),
318
- timeout: Number(row.timeout ?? 36e5)
430
+ timeout: Number(row.timeout ?? 36e5),
431
+ integritySigner: this.config.integritySigner
319
432
  });
320
433
  await this.db.query(`UPDATE ${REPORT_SCHEDULES_TABLE}
321
434
  SET running_count = COALESCE(running_count, 0) + 1,
@@ -374,11 +487,12 @@ async function triggerReportsForInstance(options, instance, context) {
374
487
  priority: options.priority,
375
488
  timeout: options.timeout,
376
489
  tenantJobCap: options.tenantJobCap,
377
- changedRows
490
+ changedRows,
491
+ integritySigner: options.integritySigner
378
492
  });
379
493
  }
380
494
  }
381
495
  //#endregion
382
- export { ReportScheduleRunner, SmrtReportRefreshTask, enqueueReportRefresh, ensureReportRefreshSchedules, registerReportRefreshInterceptor };
496
+ export { ReportScheduleRunner, SmrtPrincipalReportRefreshTask, SmrtReportRefreshTask, enqueueReportRefresh, ensureReportRefreshSchedules, registerReportRefreshExecutionAuthorityHost, registerReportRefreshInterceptor, registerReportRefreshJobIntegritySigner };
383
497
 
384
498
  //# sourceMappingURL=scheduler.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"scheduler.js","names":["tenantId"],"sources":["../src/scheduler.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport { EventEmitter } from 'node:events';\nimport {\n field,\n GlobalInterceptors,\n type InterceptorContext,\n ObjectRegistry,\n SmrtObject,\n smrt,\n} from '@happyvertical/smrt-core';\nimport {\n backgroundEligible,\n getNextCronDate,\n type SmrtJob,\n SmrtJobCollection,\n validateCronExpression,\n} from '@happyvertical/smrt-jobs';\nimport {\n getTenantId,\n TenantScoped,\n tenantId,\n} from '@happyvertical/smrt-tenancy';\nimport type { DatabaseInterface, SqlAdapterType } from '@happyvertical/sql';\nimport { buildReportDefinition } from './compiler.js';\nimport { refreshReport } from './refresh.js';\nimport {\n assertReportTablesReady,\n REPORT_SCHEDULER_TABLES,\n REPORT_SCHEDULES_TABLE,\n scopeKeyForTenant,\n} from './state.js';\nimport type {\n ReportDefinition,\n ReportRefreshMode,\n ReportRefreshTrigger,\n ReportSource,\n} from './types.js';\n\ntype ReportCtor = new (...args: any[]) => SmrtObject;\n\nexport interface ReportRefreshJobArgs {\n reportClass?: string;\n mode?: ReportRefreshMode;\n trigger?: ReportRefreshTrigger;\n tenantId?: string | null;\n tenantIds?: string[];\n scheduleId?: string;\n adapterType?: SqlAdapterType;\n changedRows?: Record<string, unknown>[];\n _scheduleId?: string;\n}\n\nexport interface EnqueueReportRefreshOptions extends ReportRefreshJobArgs {\n report?: ReportCtor;\n reportClass: string;\n db: DatabaseInterface;\n queue?: string;\n priority?: number;\n timeout?: number;\n maxAttempts?: number;\n tenantJobCap?: number;\n}\n\nexport interface EnsureReportSchedulesOptions {\n db: DatabaseInterface;\n reports: ReportCtor[];\n tenantIds?: string[];\n queue?: string;\n priority?: number;\n timeout?: number;\n}\n\nexport interface ReportScheduleRunnerConfig {\n id?: string;\n pollInterval?: number;\n batchSize?: number;\n}\n\nexport interface ReportScheduleInfo {\n id: string;\n reportClass: string;\n tenantId: string | null;\n cron: string;\n mode: ReportRefreshMode;\n}\n\nexport interface ReportScheduleRunnerEvents {\n 'schedule:triggered': (schedule: ReportScheduleInfo) => void;\n 'schedule:error': (schedule: ReportScheduleInfo, error: Error) => void;\n 'schedule:completed': (scheduleId: string) => void;\n 'schedule:failed': (scheduleId: string, error: string) => void;\n 'runner:started': () => void;\n 'runner:stopped': () => void;\n 'runner:error': (error: Error) => void;\n}\n\nexport interface ReportRefreshInterceptorOptions {\n db: DatabaseInterface;\n reports: ReportCtor[];\n enqueue?: boolean;\n queue?: string;\n priority?: number;\n timeout?: number;\n tenantJobCap?: number;\n name?: string;\n}\n\nconst INTERNAL_SURFACE = {\n api: false,\n cli: {\n include: ['list', 'get'],\n skipApiCheck: true,\n http: false,\n },\n mcp: false,\n};\n\nfunction stableUuid(values: unknown[]): string {\n const hash = createHash('sha256')\n .update(JSON.stringify(values))\n .digest('hex');\n const variant = ((Number.parseInt(hash[16], 16) & 0x3) | 0x8).toString(16);\n return [\n hash.slice(0, 8),\n hash.slice(8, 12),\n `4${hash.slice(13, 16)}`,\n `${variant}${hash.slice(17, 20)}`,\n hash.slice(20, 32),\n ].join('-');\n}\n\nfunction canonicalClassName(reportCtor: ReportCtor): string {\n const registered =\n ObjectRegistry.getClassByConstructor(reportCtor) ??\n ObjectRegistry.getClass(reportCtor.name);\n return registered?.qualifiedName ?? registered?.name ?? reportCtor.name;\n}\n\nfunction resolveReportClass(name: string): ReportCtor {\n const registered =\n ObjectRegistry.getClassByQualifiedName(name) ??\n ObjectRegistry.getClass(name);\n if (!registered) {\n throw new Error(`Unknown report class: ${name}`);\n }\n return registered.constructor as unknown as ReportCtor;\n}\n\nfunction reportSourceName(source: ReportSource): string {\n if (typeof source === 'string') return source;\n return source.name;\n}\n\nfunction sourceMatches(\n definition: ReportDefinition,\n instance: SmrtObject,\n context: InterceptorContext,\n): boolean {\n const configured = definition.refresh?.onChange;\n if (!configured || configured.length === 0) return false;\n\n const eventNames = new Set<string>([\n context.className,\n instance.constructor.name,\n ]);\n const registered = ObjectRegistry.getClassByConstructor(\n instance.constructor as ReportCtor,\n );\n if (registered?.qualifiedName) eventNames.add(registered.qualifiedName);\n if (registered?.name) eventNames.add(registered.name);\n\n for (const source of configured) {\n const name = reportSourceName(source);\n const registeredSource =\n ObjectRegistry.getClassByQualifiedName(name) ??\n ObjectRegistry.getClass(name);\n if (\n eventNames.has(name) ||\n (registeredSource?.name && eventNames.has(registeredSource.name)) ||\n (registeredSource?.qualifiedName &&\n eventNames.has(registeredSource.qualifiedName))\n ) {\n return true;\n }\n }\n\n return false;\n}\n\nfunction tenantIdFromInstance(instance: SmrtObject): string | null {\n const value = (instance as unknown as { tenantId?: unknown }).tenantId;\n return typeof value === 'string' && value.length > 0\n ? value\n : (getTenantId() ?? null);\n}\n\nfunction changedRowSnapshot(instance: SmrtObject): Record<string, unknown> {\n const serializable = instance.toJSON();\n return serializable && typeof serializable === 'object'\n ? (serializable as Record<string, unknown>)\n : {};\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: '_smrt_report_refresh_tasks',\n ...INTERNAL_SURFACE,\n})\nexport class SmrtReportRefreshTask extends SmrtObject {\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n @field({ type: 'text', required: true })\n reportClass: string = '';\n\n @field({ type: 'text', required: true })\n mode: ReportRefreshMode = 'incremental';\n\n @field({ type: 'text', required: true })\n trigger: ReportRefreshTrigger = 'job';\n\n @field({ type: 'json' })\n args: ReportRefreshJobArgs = {};\n\n @backgroundEligible()\n async run(args: ReportRefreshJobArgs = {}): Promise<unknown> {\n const reportClass = args.reportClass || this.reportClass;\n if (!reportClass) {\n throw new Error('Report refresh job requires reportClass');\n }\n\n const reportCtor = resolveReportClass(reportClass);\n return refreshReport(reportCtor, {\n db: this.db,\n mode: args.mode ?? this.mode,\n trigger: args.trigger ?? this.trigger,\n tenantId: args.tenantId,\n tenantIds: args.tenantIds,\n adapterType: args.adapterType,\n scheduleId: args.scheduleId ?? args._scheduleId,\n changedRows: args.changedRows,\n });\n }\n}\n\nexport async function enqueueReportRefresh(\n options: EnqueueReportRefreshOptions,\n): Promise<SmrtJob> {\n await ObjectRegistry.ensureManifestLoaded('SmrtJob');\n const collection = await SmrtJobCollection.create({ db: options.db });\n const taskType = canonicalClassName(SmrtReportRefreshTask);\n const scheduleId = options.scheduleId ?? options._scheduleId;\n\n return collection.enqueueJob(\n {\n tenantId: options.tenantId ?? null,\n queue: options.queue ?? 'reports',\n objectType: taskType,\n objectId: null,\n method: 'run',\n args: {\n reportClass: options.reportClass,\n mode: options.mode,\n trigger: options.trigger ?? 'job',\n tenantId: options.tenantId,\n tenantIds: options.tenantIds,\n adapterType: options.adapterType,\n changedRows: options.changedRows,\n scheduleId,\n _scheduleId: scheduleId,\n },\n priority: options.priority ?? 70,\n timeout: options.timeout ?? 3600000,\n maxAttempts: options.maxAttempts ?? 3,\n },\n { tenantJobCap: options.tenantJobCap },\n );\n}\n\nexport async function ensureReportRefreshSchedules(\n options: EnsureReportSchedulesOptions,\n): Promise<void> {\n await assertReportTablesReady(options.db, REPORT_SCHEDULER_TABLES);\n\n for (const reportCtor of options.reports) {\n const definition = await buildReportDefinition(reportCtor);\n const refresh = definition.refresh;\n if (!refresh || refresh.manual) continue;\n\n const reportClass = canonicalClassName(reportCtor);\n const targetTenants = refresh.tenantFanout\n ? options.tenantIds\n : [null as string | null];\n if (\n refresh.tenantFanout &&\n (!targetTenants || targetTenants.length === 0)\n ) {\n throw new Error(\n `${definition.reportClassName} refresh.tenantFanout requires tenantIds when creating schedules.`,\n );\n }\n\n const schedules = [\n refresh.schedule\n ? {\n cron: refresh.schedule,\n mode: refresh.mode ?? 'incremental',\n trigger: 'schedule' as const,\n }\n : null,\n refresh.fullRebuildSchedule\n ? {\n cron: refresh.fullRebuildSchedule,\n mode: 'rebuild' as const,\n trigger: 'schedule' as const,\n }\n : null,\n ].filter(Boolean) as Array<{\n cron: string;\n mode: ReportRefreshMode;\n trigger: ReportRefreshTrigger;\n }>;\n\n for (const schedule of schedules) {\n validateCronExpression(schedule.cron);\n for (const tenantId of targetTenants ?? []) {\n const scopeKey = scopeKeyForTenant(tenantId);\n const id = stableUuid([\n 'schedule',\n reportClass,\n scopeKey,\n schedule.cron,\n schedule.mode,\n ]);\n const now = new Date().toISOString();\n await options.db.upsert(\n REPORT_SCHEDULES_TABLE,\n ['report_class', 'scope_key', 'cron', 'mode'],\n {\n id,\n slug: id,\n context: scopeKey,\n tenant_id: tenantId,\n scope_key: scopeKey,\n report_class: reportClass,\n cron: schedule.cron,\n trigger: schedule.trigger,\n mode: schedule.mode,\n enabled: true,\n status: 'active',\n next_run: getNextCronDate(schedule.cron).toISOString(),\n last_run: null,\n last_status: null,\n last_error: null,\n run_count: 0,\n success_count: 0,\n failure_count: 0,\n running_count: 0,\n max_concurrent: 1,\n queue: options.queue ?? 'reports',\n priority: options.priority ?? 70,\n timeout: options.timeout ?? 3600000,\n created_at: now,\n updated_at: now,\n },\n );\n }\n }\n }\n}\n\nexport class ReportScheduleRunner extends EventEmitter {\n readonly id: string;\n private readonly config: Required<ReportScheduleRunnerConfig>;\n private db: DatabaseInterface | null = null;\n private running = false;\n private pollTimer: NodeJS.Timeout | null = null;\n\n constructor(config: ReportScheduleRunnerConfig = {}) {\n super();\n this.config = {\n id: config.id || `reports_${stableUuid([Date.now()]).slice(0, 8)}`,\n pollInterval: config.pollInterval ?? 60000,\n batchSize: config.batchSize ?? 50,\n };\n this.id = this.config.id;\n }\n\n async initialize(db: DatabaseInterface): Promise<void> {\n this.db = db;\n await assertReportTablesReady(db, REPORT_SCHEDULER_TABLES);\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n if (!this.db) {\n throw new Error(\n 'ReportScheduleRunner not initialized. Call initialize() first.',\n );\n }\n this.running = true;\n this.startPolling();\n this.emit('runner:started');\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.pollTimer) {\n clearTimeout(this.pollTimer);\n this.pollTimer = null;\n }\n this.emit('runner:stopped');\n }\n\n isRunning(): boolean {\n return this.running;\n }\n\n async handleJobCompletion(\n scheduleId: string,\n success: boolean,\n errorMessage?: string,\n ): Promise<void> {\n if (!this.db) return;\n const now = new Date().toISOString();\n if (success) {\n await this.db.query(\n `UPDATE ${REPORT_SCHEDULES_TABLE}\n SET running_count = CASE WHEN COALESCE(running_count, 0) > 0 THEN running_count - 1 ELSE 0 END,\n last_run = ?,\n last_status = 'success',\n last_error = NULL,\n run_count = COALESCE(run_count, 0) + 1,\n success_count = COALESCE(success_count, 0) + 1,\n updated_at = ?\n WHERE id = ?`,\n now,\n now,\n scheduleId,\n );\n this.emit('schedule:completed', scheduleId);\n return;\n }\n\n const safeError = errorMessage ?? 'Unknown error';\n await this.db.query(\n `UPDATE ${REPORT_SCHEDULES_TABLE}\n SET running_count = CASE WHEN COALESCE(running_count, 0) > 0 THEN running_count - 1 ELSE 0 END,\n last_run = ?,\n last_status = 'failed',\n last_error = ?,\n run_count = COALESCE(run_count, 0) + 1,\n failure_count = COALESCE(failure_count, 0) + 1,\n updated_at = ?\n WHERE id = ?`,\n now,\n safeError,\n now,\n scheduleId,\n );\n this.emit('schedule:failed', scheduleId, safeError);\n }\n\n private startPolling(): void {\n const poll = async () => {\n if (!this.running) return;\n try {\n await this.poll();\n } catch (error) {\n this.emit('runner:error', error as Error);\n }\n if (this.running) {\n this.pollTimer = setTimeout(poll, this.config.pollInterval);\n if (typeof this.pollTimer.unref === 'function') {\n this.pollTimer.unref();\n }\n }\n };\n poll();\n }\n\n async poll(): Promise<void> {\n if (!this.db) return;\n const result = await this.db.query(\n `SELECT * FROM ${REPORT_SCHEDULES_TABLE}\n WHERE enabled = true\n AND status = 'active'\n AND next_run <= ?\n AND COALESCE(running_count, 0) < COALESCE(max_concurrent, 1)\n ORDER BY next_run ASC\n LIMIT ?`,\n new Date().toISOString(),\n this.config.batchSize,\n );\n\n for (const row of result.rows) {\n await this.triggerSchedule(row as ReportScheduleRow);\n }\n }\n\n private async triggerSchedule(row: ReportScheduleRow): Promise<void> {\n if (!this.db) return;\n const schedule: ReportScheduleInfo = {\n id: String(row.id),\n reportClass: String(row.report_class),\n tenantId:\n typeof row.tenant_id === 'string' && row.tenant_id.length > 0\n ? row.tenant_id\n : null,\n cron: String(row.cron),\n mode: (row.mode as ReportRefreshMode) || 'incremental',\n };\n\n try {\n const nextRun = getNextCronDate(schedule.cron);\n await enqueueReportRefresh({\n db: this.db,\n reportClass: schedule.reportClass,\n mode: schedule.mode,\n trigger: (row.trigger as ReportRefreshTrigger) || 'schedule',\n tenantId: schedule.tenantId,\n scheduleId: schedule.id,\n queue: String(row.queue || 'reports'),\n priority: Number(row.priority ?? 70),\n timeout: Number(row.timeout ?? 3600000),\n });\n await this.db.query(\n `UPDATE ${REPORT_SCHEDULES_TABLE}\n SET running_count = COALESCE(running_count, 0) + 1,\n next_run = ?,\n updated_at = ?\n WHERE id = ?`,\n nextRun.toISOString(),\n new Date().toISOString(),\n schedule.id,\n );\n this.emit('schedule:triggered', schedule);\n } catch (error) {\n await this.db.query(\n `UPDATE ${REPORT_SCHEDULES_TABLE}\n SET last_error = ?,\n updated_at = ?\n WHERE id = ?`,\n error instanceof Error ? error.message : String(error),\n new Date().toISOString(),\n schedule.id,\n );\n this.emit('schedule:error', schedule, error as Error);\n }\n }\n}\n\ninterface ReportScheduleRow {\n id: unknown;\n tenant_id: unknown;\n report_class: unknown;\n cron: unknown;\n trigger: unknown;\n mode: unknown;\n queue: unknown;\n priority: unknown;\n timeout: unknown;\n}\n\nexport function registerReportRefreshInterceptor(\n options: ReportRefreshInterceptorOptions,\n): () => boolean {\n const name = options.name ?? 'smrt-reports-refresh';\n GlobalInterceptors.register({\n name,\n priority: -10,\n async afterSave(instance, context) {\n await triggerReportsForInstance(options, instance, context);\n },\n async afterDelete(instance, context) {\n await triggerReportsForInstance(options, instance, context);\n },\n });\n return () => GlobalInterceptors.unregister(name);\n}\n\nasync function triggerReportsForInstance(\n options: ReportRefreshInterceptorOptions,\n instance: SmrtObject,\n context: InterceptorContext,\n): Promise<void> {\n for (const reportCtor of options.reports) {\n const definition = await buildReportDefinition(reportCtor);\n if (definition.refresh?.manual) continue;\n if (!sourceMatches(definition, instance, context)) continue;\n\n const mode = definition.refresh?.mode ?? 'incremental';\n const tenantId = tenantIdFromInstance(instance);\n const changedRows = [changedRowSnapshot(instance)];\n if (options.enqueue === false) {\n await refreshReport(reportCtor, {\n db: options.db,\n mode,\n trigger: 'change',\n tenantId,\n changedRows,\n });\n continue;\n }\n\n await enqueueReportRefresh({\n db: options.db,\n reportClass: canonicalClassName(reportCtor),\n mode,\n trigger: 'change',\n tenantId,\n queue: options.queue,\n priority: options.priority,\n timeout: options.timeout,\n tenantJobCap: options.tenantJobCap,\n changedRows,\n });\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"scheduler.js","names":["tenantId"],"sources":["../src/scheduler.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport { EventEmitter } from 'node:events';\nimport {\n field,\n GlobalInterceptors,\n type InterceptorContext,\n ObjectRegistry,\n SmrtObject,\n smrt,\n} from '@happyvertical/smrt-core';\nimport {\n backgroundEligible,\n type DurableJobPayloadIntegrity,\n type DurableJobPayloadSigner,\n getActiveJobExecutionContext,\n getNextCronDate,\n isRunnerExecutionContext,\n type JobExecutionContext,\n type SmrtJob,\n SmrtJobCollection,\n validateCronExpression,\n} from '@happyvertical/smrt-jobs';\nimport {\n getTenantId,\n TenantScoped,\n tenantId,\n} from '@happyvertical/smrt-tenancy';\nimport type { DatabaseInterface, SqlAdapterType } from '@happyvertical/sql';\nimport { buildReportDefinition } from './compiler.js';\nimport { refreshReport } from './refresh.js';\nimport {\n assertReportTablesReady,\n REPORT_SCHEDULER_TABLES,\n REPORT_SCHEDULES_TABLE,\n scopeKeyForTenant,\n} from './state.js';\nimport type {\n ReportDefinition,\n ReportRefreshMode,\n ReportRefreshTrigger,\n ReportSource,\n} from './types.js';\n\ntype ReportCtor = new (...args: any[]) => SmrtObject;\n\nexport interface ReportRefreshJobArgs {\n reportClass?: string;\n mode?: ReportRefreshMode;\n trigger?: ReportRefreshTrigger;\n tenantId?: string | null;\n tenantIds?: string[];\n scheduleId?: string;\n adapterType?: SqlAdapterType;\n changedRows?: Record<string, unknown>[];\n _scheduleId?: string;\n executionAuthority?: ReportRefreshExecutionAuthority;\n integrity?: DurableJobPayloadIntegrity;\n}\n\nexport interface ReportExecutionPrincipalReference {\n version: 1;\n actorUserId: string;\n tenantId: string | null;\n onBehalfOfUserId?: string | null;\n actsAsProfileId?: string | null;\n agentClass?: string | null;\n}\n\nexport interface ReportRefreshExecutionAuthority {\n version: 1;\n hostId: string;\n principal: ReportExecutionPrincipalReference;\n}\n\nexport interface ReportRefreshExecutionAuthorityContext {\n phase: 'execute';\n reportClass: string;\n mode: ReportRefreshMode;\n trigger: ReportRefreshTrigger;\n tenantId: string | null;\n}\n\nexport interface ReportRefreshExecutionAuditEvent\n extends ReportRefreshExecutionAuthorityContext {\n outcome: 'allowed' | 'denied';\n principal: ReportExecutionPrincipalReference;\n reason?: string;\n}\n\nexport interface ReportRefreshExecutionAuthorityHost {\n authorize(\n principal: Readonly<ReportExecutionPrincipalReference>,\n context: Readonly<ReportRefreshExecutionAuthorityContext>,\n ): Promise<void> | void;\n audit(\n event: Readonly<ReportRefreshExecutionAuditEvent>,\n ): Promise<void> | void;\n}\n\nexport interface EnqueueReportRefreshOptions extends ReportRefreshJobArgs {\n report?: ReportCtor;\n reportClass: string;\n db: DatabaseInterface;\n queue?: string;\n priority?: number;\n timeout?: number;\n maxAttempts?: number;\n tenantJobCap?: number;\n /** Server-only signer; register the same key in every worker process. */\n integritySigner?: DurableJobPayloadSigner;\n}\n\nexport interface EnsureReportSchedulesOptions {\n db: DatabaseInterface;\n reports: ReportCtor[];\n tenantIds?: string[];\n queue?: string;\n priority?: number;\n timeout?: number;\n}\n\nexport interface ReportScheduleRunnerConfig {\n id?: string;\n pollInterval?: number;\n batchSize?: number;\n integritySigner?: DurableJobPayloadSigner;\n}\n\nexport interface ReportScheduleInfo {\n id: string;\n reportClass: string;\n tenantId: string | null;\n cron: string;\n mode: ReportRefreshMode;\n}\n\nexport interface ReportScheduleRunnerEvents {\n 'schedule:triggered': (schedule: ReportScheduleInfo) => void;\n 'schedule:error': (schedule: ReportScheduleInfo, error: Error) => void;\n 'schedule:completed': (scheduleId: string) => void;\n 'schedule:failed': (scheduleId: string, error: string) => void;\n 'runner:started': () => void;\n 'runner:stopped': () => void;\n 'runner:error': (error: Error) => void;\n}\n\nexport interface ReportRefreshInterceptorOptions {\n db: DatabaseInterface;\n reports: ReportCtor[];\n enqueue?: boolean;\n queue?: string;\n priority?: number;\n timeout?: number;\n tenantJobCap?: number;\n name?: string;\n integritySigner?: DurableJobPayloadSigner;\n}\n\nconst INTERNAL_SURFACE = {\n api: false,\n cli: {\n include: ['list', 'get'],\n skipApiCheck: true,\n http: false,\n },\n mcp: false,\n};\n\nconst executionAuthorityHosts = new Map<\n string,\n ReportRefreshExecutionAuthorityHost\n>();\nconst jobIntegritySigners = new Map<string, DurableJobPayloadSigner>();\n\nexport function registerReportRefreshJobIntegritySigner(\n signer: DurableJobPayloadSigner,\n): () => void {\n const existing = jobIntegritySigners.get(signer.keyId);\n if (existing && existing !== signer) {\n throw new Error(\n `Report refresh job integrity signer already registered: ${signer.keyId}`,\n );\n }\n jobIntegritySigners.set(signer.keyId, signer);\n return () => {\n if (jobIntegritySigners.get(signer.keyId) === signer) {\n jobIntegritySigners.delete(signer.keyId);\n }\n };\n}\n\n/**\n * Maintenance callers historically configured only their runner/interceptor;\n * they do not have a request host from which to obtain a signer. When one\n * worker-shared signer is registered, use it as the application default. More\n * than one key is ambiguous and remains fail-closed until the caller selects\n * one explicitly.\n */\nfunction resolveReportRefreshIntegritySigner(\n configured: DurableJobPayloadSigner | undefined,\n): DurableJobPayloadSigner | undefined {\n if (configured) return configured;\n if (jobIntegritySigners.size !== 1) return undefined;\n return jobIntegritySigners.values().next().value;\n}\n\nfunction unsignedReportRefreshJobArgs(\n args: ReportRefreshJobArgs,\n): Omit<ReportRefreshJobArgs, 'integrity'> {\n const {\n integrity: _integrity,\n _scheduleId: _internalScheduleId,\n ...unsigned\n } = args;\n return unsigned;\n}\n\nfunction assertReportRefreshJobIntegrity(args: ReportRefreshJobArgs): void {\n const integrity = args.integrity;\n const signer = integrity\n ? jobIntegritySigners.get(integrity.keyId)\n : undefined;\n if (\n !integrity ||\n !signer?.verify(unsignedReportRefreshJobArgs(args), integrity)\n ) {\n throw new Error('Invalid durable report refresh job integrity binding');\n }\n}\n\nfunction assertReportRefreshJobTarget(\n args: ReportRefreshJobArgs,\n context: JobExecutionContext | undefined,\n): void {\n if (!context) return;\n if (!isRunnerExecutionContext(context)) {\n throw new Error('Invalid durable report refresh job context');\n }\n const expectedType = canonicalClassName(\n (args.trigger ?? 'job') === 'manual'\n ? SmrtPrincipalReportRefreshTask\n : SmrtReportRefreshTask,\n );\n if (context.job.objectType !== expectedType || context.job.method !== 'run') {\n throw new Error('Invalid durable report refresh job target');\n }\n}\n\nexport function registerReportRefreshExecutionAuthorityHost(\n hostId: string,\n host: ReportRefreshExecutionAuthorityHost,\n): () => void {\n if (!hostId || hostId.length > 256) {\n throw new Error(\n 'Report refresh authority hostId must contain 1-256 characters',\n );\n }\n const existing = executionAuthorityHosts.get(hostId);\n if (existing && existing !== host) {\n throw new Error(\n `Report refresh authority host already registered: ${hostId}`,\n );\n }\n executionAuthorityHosts.set(hostId, host);\n return () => {\n if (executionAuthorityHosts.get(hostId) === host) {\n executionAuthorityHosts.delete(hostId);\n }\n };\n}\n\nfunction stableUuid(values: unknown[]): string {\n const hash = createHash('sha256')\n .update(JSON.stringify(values))\n .digest('hex');\n const variant = ((Number.parseInt(hash[16], 16) & 0x3) | 0x8).toString(16);\n return [\n hash.slice(0, 8),\n hash.slice(8, 12),\n `4${hash.slice(13, 16)}`,\n `${variant}${hash.slice(17, 20)}`,\n hash.slice(20, 32),\n ].join('-');\n}\n\nfunction canonicalClassName(reportCtor: ReportCtor): string {\n const registered =\n ObjectRegistry.getClassByConstructor(reportCtor) ??\n ObjectRegistry.getClass(reportCtor.name);\n return registered?.qualifiedName ?? registered?.name ?? reportCtor.name;\n}\n\nfunction resolveReportClass(name: string): ReportCtor {\n const registered =\n ObjectRegistry.getClassByQualifiedName(name) ??\n ObjectRegistry.getClass(name);\n if (!registered) {\n throw new Error(`Unknown report class: ${name}`);\n }\n return registered.constructor as unknown as ReportCtor;\n}\n\nfunction reportSourceName(source: ReportSource): string {\n if (typeof source === 'string') return source;\n return source.name;\n}\n\nfunction sourceMatches(\n definition: ReportDefinition,\n instance: SmrtObject,\n context: InterceptorContext,\n): boolean {\n const configured = definition.refresh?.onChange;\n if (!configured || configured.length === 0) return false;\n\n const eventNames = new Set<string>([\n context.className,\n instance.constructor.name,\n ]);\n const registered = ObjectRegistry.getClassByConstructor(\n instance.constructor as ReportCtor,\n );\n if (registered?.qualifiedName) eventNames.add(registered.qualifiedName);\n if (registered?.name) eventNames.add(registered.name);\n\n for (const source of configured) {\n const name = reportSourceName(source);\n const registeredSource =\n ObjectRegistry.getClassByQualifiedName(name) ??\n ObjectRegistry.getClass(name);\n if (\n eventNames.has(name) ||\n (registeredSource?.name && eventNames.has(registeredSource.name)) ||\n (registeredSource?.qualifiedName &&\n eventNames.has(registeredSource.qualifiedName))\n ) {\n return true;\n }\n }\n\n return false;\n}\n\nfunction tenantIdFromInstance(instance: SmrtObject): string | null {\n const value = (instance as unknown as { tenantId?: unknown }).tenantId;\n return typeof value === 'string' && value.length > 0\n ? value\n : (getTenantId() ?? null);\n}\n\nfunction changedRowSnapshot(instance: SmrtObject): Record<string, unknown> {\n const serializable = instance.toJSON();\n return serializable && typeof serializable === 'object'\n ? (serializable as Record<string, unknown>)\n : {};\n}\n\nasync function authorizeReportRefreshExecution(\n args: ReportRefreshJobArgs,\n reportClass: string,\n jobTenantId: string | null,\n): Promise<void> {\n if ((args.tenantId ?? null) !== jobTenantId) {\n throw new Error('Invalid report refresh execution tenant');\n }\n const authority = args.executionAuthority;\n if ((args.trigger ?? 'job') === 'manual' && !authority) {\n throw new Error('Manual report refresh execution authority is missing');\n }\n // Scheduled and on-change maintenance jobs predate user-bound actions and\n // intentionally run under the worker's system authority.\n if (!authority) return;\n const principal = authority.principal;\n const tenantId = args.tenantId ?? null;\n if (\n authority.version !== 1 ||\n principal?.version !== 1 ||\n !authority.hostId ||\n !principal.actorUserId ||\n principal.tenantId !== tenantId ||\n principal.tenantId !== jobTenantId\n ) {\n throw new Error('Invalid report refresh execution authority');\n }\n const host = executionAuthorityHosts.get(authority.hostId);\n if (!host) {\n throw new Error(\n `No report refresh authority host registered for ${authority.hostId}`,\n );\n }\n const context: ReportRefreshExecutionAuthorityContext = {\n phase: 'execute',\n reportClass,\n mode: args.mode ?? 'incremental',\n trigger: args.trigger ?? 'job',\n tenantId,\n };\n try {\n await host.authorize(\n Object.freeze({ ...principal }),\n Object.freeze(context),\n );\n } catch {\n await host.audit({\n ...context,\n outcome: 'denied',\n principal: Object.freeze({ ...principal }),\n reason: 'current_authority_denied',\n });\n throw new Error('Report refresh execution authority denied');\n }\n await host.audit({\n ...context,\n outcome: 'allowed',\n principal: Object.freeze({ ...principal }),\n });\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: '_smrt_report_refresh_tasks',\n ...INTERNAL_SURFACE,\n})\nexport class SmrtReportRefreshTask extends SmrtObject {\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n @field({ type: 'text', required: true })\n reportClass: string = '';\n\n @field({ type: 'text', required: true })\n mode: ReportRefreshMode = 'incremental';\n\n @field({ type: 'text', required: true })\n trigger: ReportRefreshTrigger = 'job';\n\n @field({ type: 'json' })\n args: ReportRefreshJobArgs = {};\n\n @backgroundEligible()\n async run(\n args: ReportRefreshJobArgs = {},\n context?: JobExecutionContext,\n ): Promise<unknown> {\n assertReportRefreshJobIntegrity(args);\n const executionContext = getActiveJobExecutionContext() ?? context;\n assertReportRefreshJobTarget(args, executionContext);\n const reportClass = args.reportClass;\n if (!reportClass) {\n throw new Error('Report refresh job requires reportClass');\n }\n const mode = args.mode ?? 'incremental';\n const trigger = args.trigger ?? 'job';\n\n const reportCtor = resolveReportClass(reportClass);\n // A runner context, including its explicit global `null` tenant, owns the\n // scope. Only direct callers without runner context may use instance scope.\n let jobTenantId: string | null;\n if (executionContext) {\n const runnerTenantId = executionContext.job.tenantId;\n if (runnerTenantId === null) {\n jobTenantId = null;\n } else if (\n typeof runnerTenantId === 'string' &&\n runnerTenantId.length > 0\n ) {\n jobTenantId = runnerTenantId;\n } else {\n throw new Error('Invalid report refresh execution tenant');\n }\n } else {\n jobTenantId = tenantIdFromInstance(this);\n }\n await authorizeReportRefreshExecution(args, reportClass, jobTenantId);\n return refreshReport(reportCtor, {\n db: this.db,\n mode,\n trigger,\n tenantId: jobTenantId,\n tenantIds: args.tenantIds,\n adapterType: args.adapterType,\n scheduleId: args.scheduleId ?? args._scheduleId,\n changedRows: args.changedRows,\n });\n }\n}\n\n/** Worker target whose authority requirement cannot be downgraded by job args. */\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: '_smrt_principal_report_refresh_tasks',\n ...INTERNAL_SURFACE,\n})\nexport class SmrtPrincipalReportRefreshTask extends SmrtReportRefreshTask {\n override async run(\n args: ReportRefreshJobArgs = {},\n context?: JobExecutionContext,\n ): Promise<unknown> {\n return super.run({ ...args, trigger: 'manual' }, context);\n }\n}\n\nexport async function enqueueReportRefresh(\n options: EnqueueReportRefreshOptions,\n): Promise<SmrtJob> {\n const integritySigner = resolveReportRefreshIntegritySigner(\n options.integritySigner,\n );\n if (!integritySigner) {\n throw new Error(\n 'Report refresh queue requires a durable job integrity signer',\n );\n }\n if (options.trigger === 'manual' && !options.executionAuthority) {\n throw new Error('Manual report refresh requires execution-time authority');\n }\n if (\n options.executionAuthority &&\n (options.executionAuthority.version !== 1 ||\n options.executionAuthority.principal?.version !== 1 ||\n !options.executionAuthority.hostId ||\n !options.executionAuthority.principal.actorUserId ||\n options.executionAuthority.principal.tenantId !==\n (options.tenantId ?? null))\n ) {\n throw new Error('Invalid report refresh execution authority');\n }\n if (\n options.executionAuthority &&\n (options.trigger !== 'manual' || (options.tenantIds?.length ?? 0) > 0)\n ) {\n throw new Error(\n 'Principal-bound report refresh requires one manual tenant scope',\n );\n }\n await ObjectRegistry.ensureManifestLoaded('SmrtJob');\n const collection = await SmrtJobCollection.create({ db: options.db });\n const taskType = canonicalClassName(\n options.trigger === 'manual'\n ? SmrtPrincipalReportRefreshTask\n : SmrtReportRefreshTask,\n );\n const scheduleId = options.scheduleId ?? options._scheduleId;\n\n const unsignedArgs: Omit<ReportRefreshJobArgs, 'integrity'> = {\n reportClass: options.reportClass,\n mode: options.mode ?? 'incremental',\n trigger: options.trigger ?? 'job',\n tenantId: options.tenantId,\n tenantIds: options.tenantIds,\n adapterType: options.adapterType,\n changedRows: options.changedRows,\n scheduleId,\n executionAuthority: options.executionAuthority,\n };\n const integrity = integritySigner.sign(unsignedArgs);\n if (!integritySigner.verify(unsignedArgs, integrity)) {\n throw new Error(\n 'Report refresh job integrity signer rejected its queued payload',\n );\n }\n return collection.enqueueJob(\n {\n tenantId: options.tenantId ?? null,\n queue: options.queue ?? 'reports',\n objectType: taskType,\n objectId: null,\n method: 'run',\n args: { ...unsignedArgs, _scheduleId: scheduleId, integrity },\n priority: options.priority ?? 70,\n timeout: options.timeout ?? 3600000,\n maxAttempts: options.maxAttempts ?? 3,\n },\n { tenantJobCap: options.tenantJobCap },\n );\n}\n\nexport async function ensureReportRefreshSchedules(\n options: EnsureReportSchedulesOptions,\n): Promise<void> {\n await assertReportTablesReady(options.db, REPORT_SCHEDULER_TABLES);\n\n for (const reportCtor of options.reports) {\n const definition = await buildReportDefinition(reportCtor);\n const refresh = definition.refresh;\n if (!refresh || refresh.manual) continue;\n\n const reportClass = canonicalClassName(reportCtor);\n const targetTenants = refresh.tenantFanout\n ? options.tenantIds\n : [null as string | null];\n if (\n refresh.tenantFanout &&\n (!targetTenants || targetTenants.length === 0)\n ) {\n throw new Error(\n `${definition.reportClassName} refresh.tenantFanout requires tenantIds when creating schedules.`,\n );\n }\n\n const schedules = [\n refresh.schedule\n ? {\n cron: refresh.schedule,\n mode: refresh.mode ?? 'incremental',\n trigger: 'schedule' as const,\n }\n : null,\n refresh.fullRebuildSchedule\n ? {\n cron: refresh.fullRebuildSchedule,\n mode: 'rebuild' as const,\n trigger: 'schedule' as const,\n }\n : null,\n ].filter(Boolean) as Array<{\n cron: string;\n mode: ReportRefreshMode;\n trigger: ReportRefreshTrigger;\n }>;\n\n for (const schedule of schedules) {\n validateCronExpression(schedule.cron);\n for (const tenantId of targetTenants ?? []) {\n const scopeKey = scopeKeyForTenant(tenantId);\n const id = stableUuid([\n 'schedule',\n reportClass,\n scopeKey,\n schedule.cron,\n schedule.mode,\n ]);\n const now = new Date().toISOString();\n await options.db.upsert(\n REPORT_SCHEDULES_TABLE,\n ['report_class', 'scope_key', 'cron', 'mode'],\n {\n id,\n slug: id,\n context: scopeKey,\n tenant_id: tenantId,\n scope_key: scopeKey,\n report_class: reportClass,\n cron: schedule.cron,\n trigger: schedule.trigger,\n mode: schedule.mode,\n enabled: true,\n status: 'active',\n next_run: getNextCronDate(schedule.cron).toISOString(),\n last_run: null,\n last_status: null,\n last_error: null,\n run_count: 0,\n success_count: 0,\n failure_count: 0,\n running_count: 0,\n max_concurrent: 1,\n queue: options.queue ?? 'reports',\n priority: options.priority ?? 70,\n timeout: options.timeout ?? 3600000,\n created_at: now,\n updated_at: now,\n },\n );\n }\n }\n }\n}\n\nexport class ReportScheduleRunner extends EventEmitter {\n readonly id: string;\n private readonly config: Required<\n Omit<ReportScheduleRunnerConfig, 'integritySigner'>\n > &\n Pick<ReportScheduleRunnerConfig, 'integritySigner'>;\n private db: DatabaseInterface | null = null;\n private running = false;\n private pollTimer: NodeJS.Timeout | null = null;\n\n constructor(config: ReportScheduleRunnerConfig = {}) {\n super();\n this.config = {\n id: config.id || `reports_${stableUuid([Date.now()]).slice(0, 8)}`,\n pollInterval: config.pollInterval ?? 60000,\n batchSize: config.batchSize ?? 50,\n integritySigner: config.integritySigner,\n };\n this.id = this.config.id;\n }\n\n async initialize(db: DatabaseInterface): Promise<void> {\n this.db = db;\n await assertReportTablesReady(db, REPORT_SCHEDULER_TABLES);\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n if (!this.db) {\n throw new Error(\n 'ReportScheduleRunner not initialized. Call initialize() first.',\n );\n }\n this.running = true;\n this.startPolling();\n this.emit('runner:started');\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.pollTimer) {\n clearTimeout(this.pollTimer);\n this.pollTimer = null;\n }\n this.emit('runner:stopped');\n }\n\n isRunning(): boolean {\n return this.running;\n }\n\n async handleJobCompletion(\n scheduleId: string,\n success: boolean,\n errorMessage?: string,\n ): Promise<void> {\n if (!this.db) return;\n const now = new Date().toISOString();\n if (success) {\n await this.db.query(\n `UPDATE ${REPORT_SCHEDULES_TABLE}\n SET running_count = CASE WHEN COALESCE(running_count, 0) > 0 THEN running_count - 1 ELSE 0 END,\n last_run = ?,\n last_status = 'success',\n last_error = NULL,\n run_count = COALESCE(run_count, 0) + 1,\n success_count = COALESCE(success_count, 0) + 1,\n updated_at = ?\n WHERE id = ?`,\n now,\n now,\n scheduleId,\n );\n this.emit('schedule:completed', scheduleId);\n return;\n }\n\n const safeError = errorMessage ?? 'Unknown error';\n await this.db.query(\n `UPDATE ${REPORT_SCHEDULES_TABLE}\n SET running_count = CASE WHEN COALESCE(running_count, 0) > 0 THEN running_count - 1 ELSE 0 END,\n last_run = ?,\n last_status = 'failed',\n last_error = ?,\n run_count = COALESCE(run_count, 0) + 1,\n failure_count = COALESCE(failure_count, 0) + 1,\n updated_at = ?\n WHERE id = ?`,\n now,\n safeError,\n now,\n scheduleId,\n );\n this.emit('schedule:failed', scheduleId, safeError);\n }\n\n private startPolling(): void {\n const poll = async () => {\n if (!this.running) return;\n try {\n await this.poll();\n } catch (error) {\n this.emit('runner:error', error as Error);\n }\n if (this.running) {\n this.pollTimer = setTimeout(poll, this.config.pollInterval);\n if (typeof this.pollTimer.unref === 'function') {\n this.pollTimer.unref();\n }\n }\n };\n poll();\n }\n\n async poll(): Promise<void> {\n if (!this.db) return;\n const result = await this.db.query(\n `SELECT * FROM ${REPORT_SCHEDULES_TABLE}\n WHERE enabled = true\n AND status = 'active'\n AND next_run <= ?\n AND COALESCE(running_count, 0) < COALESCE(max_concurrent, 1)\n ORDER BY next_run ASC\n LIMIT ?`,\n new Date().toISOString(),\n this.config.batchSize,\n );\n\n for (const row of result.rows) {\n await this.triggerSchedule(row as ReportScheduleRow);\n }\n }\n\n private async triggerSchedule(row: ReportScheduleRow): Promise<void> {\n if (!this.db) return;\n const schedule: ReportScheduleInfo = {\n id: String(row.id),\n reportClass: String(row.report_class),\n tenantId:\n typeof row.tenant_id === 'string' && row.tenant_id.length > 0\n ? row.tenant_id\n : null,\n cron: String(row.cron),\n mode: (row.mode as ReportRefreshMode) || 'incremental',\n };\n\n try {\n const nextRun = getNextCronDate(schedule.cron);\n await enqueueReportRefresh({\n db: this.db,\n reportClass: schedule.reportClass,\n mode: schedule.mode,\n trigger: (row.trigger as ReportRefreshTrigger) || 'schedule',\n tenantId: schedule.tenantId,\n scheduleId: schedule.id,\n queue: String(row.queue || 'reports'),\n priority: Number(row.priority ?? 70),\n timeout: Number(row.timeout ?? 3600000),\n integritySigner: this.config.integritySigner,\n });\n await this.db.query(\n `UPDATE ${REPORT_SCHEDULES_TABLE}\n SET running_count = COALESCE(running_count, 0) + 1,\n next_run = ?,\n updated_at = ?\n WHERE id = ?`,\n nextRun.toISOString(),\n new Date().toISOString(),\n schedule.id,\n );\n this.emit('schedule:triggered', schedule);\n } catch (error) {\n await this.db.query(\n `UPDATE ${REPORT_SCHEDULES_TABLE}\n SET last_error = ?,\n updated_at = ?\n WHERE id = ?`,\n error instanceof Error ? error.message : String(error),\n new Date().toISOString(),\n schedule.id,\n );\n this.emit('schedule:error', schedule, error as Error);\n }\n }\n}\n\ninterface ReportScheduleRow {\n id: unknown;\n tenant_id: unknown;\n report_class: unknown;\n cron: unknown;\n trigger: unknown;\n mode: unknown;\n queue: unknown;\n priority: unknown;\n timeout: unknown;\n}\n\nexport function registerReportRefreshInterceptor(\n options: ReportRefreshInterceptorOptions,\n): () => boolean {\n const name = options.name ?? 'smrt-reports-refresh';\n GlobalInterceptors.register({\n name,\n priority: -10,\n async afterSave(instance, context) {\n await triggerReportsForInstance(options, instance, context);\n },\n async afterDelete(instance, context) {\n await triggerReportsForInstance(options, instance, context);\n },\n });\n return () => GlobalInterceptors.unregister(name);\n}\n\nasync function triggerReportsForInstance(\n options: ReportRefreshInterceptorOptions,\n instance: SmrtObject,\n context: InterceptorContext,\n): Promise<void> {\n for (const reportCtor of options.reports) {\n const definition = await buildReportDefinition(reportCtor);\n if (definition.refresh?.manual) continue;\n if (!sourceMatches(definition, instance, context)) continue;\n\n const mode = definition.refresh?.mode ?? 'incremental';\n const tenantId = tenantIdFromInstance(instance);\n const changedRows = [changedRowSnapshot(instance)];\n if (options.enqueue === false) {\n await refreshReport(reportCtor, {\n db: options.db,\n mode,\n trigger: 'change',\n tenantId,\n changedRows,\n });\n continue;\n }\n\n await enqueueReportRefresh({\n db: options.db,\n reportClass: canonicalClassName(reportCtor),\n mode,\n trigger: 'change',\n tenantId,\n queue: options.queue,\n priority: options.priority,\n timeout: options.timeout,\n tenantJobCap: options.tenantJobCap,\n changedRows,\n integritySigner: options.integritySigner,\n });\n }\n}\n"],"mappings":"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"}
@@ -3,12 +3,12 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-reports",
6
- "packageVersion": "0.49.2",
6
+ "packageVersion": "0.49.4",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "bb5472a33e850ecd939196ba5836ae451ffa4acdbe6faf687300c802bab96e95",
11
- "packageJson": "d723dc5474c20a141ea6cdbd2dfeb5e7a6ff18ac17dc71561013c214bbaff947",
10
+ "manifest": "dbdbbf364cc13c70e36e709c73828630d926d32c18bf6be5736104620fa9491c",
11
+ "packageJson": "1f26f803797709740289cc00e8d77461e36c4da780b07d789393f34bd7ab69dd",
12
12
  "agents": "1328f1da3d58fba2369d696cb9fa42c9d324255257978fa3ae9d1086e9de5af6"
13
13
  },
14
14
  "exports": [
@@ -180,7 +180,8 @@
180
180
  "name": "run",
181
181
  "async": true,
182
182
  "params": [
183
- "args?: ReportRefreshJobArgs"
183
+ "args?: ReportRefreshJobArgs",
184
+ "context?: JobExecutionContext"
184
185
  ],
185
186
  "returns": "Promise<unknown>"
186
187
  }
@@ -224,6 +225,75 @@
224
225
  "tags": [],
225
226
  "risks": []
226
227
  },
228
+ {
229
+ "name": "SmrtPrincipalReportRefreshTask",
230
+ "qualifiedName": "@happyvertical/smrt-reports:SmrtPrincipalReportRefreshTask",
231
+ "collection": "smrtprincipalreportrefreshtasks",
232
+ "tableName": "_smrt_principal_report_refresh_tasks",
233
+ "packageName": "@happyvertical/smrt-reports",
234
+ "extends": "SmrtReportRefreshTask",
235
+ "fields": [
236
+ {
237
+ "name": "tenantId",
238
+ "type": "text",
239
+ "required": false,
240
+ "columnType": "UUID"
241
+ }
242
+ ],
243
+ "relationships": [],
244
+ "methods": [
245
+ "run"
246
+ ],
247
+ "methodSignatures": [
248
+ {
249
+ "name": "run",
250
+ "async": true,
251
+ "params": [
252
+ "args?: ReportRefreshJobArgs",
253
+ "context?: JobExecutionContext"
254
+ ],
255
+ "returns": "Promise<unknown>"
256
+ }
257
+ ],
258
+ "tenant": {
259
+ "scoped": true,
260
+ "mode": "optional",
261
+ "field": "tenantId"
262
+ },
263
+ "conflictColumns": [
264
+ "tenant_id",
265
+ "slug",
266
+ "context"
267
+ ],
268
+ "surfaces": [
269
+ {
270
+ "kind": "cli",
271
+ "name": "smrtprincipalreportrefreshtask_list",
272
+ "operation": "list",
273
+ "objectName": "@happyvertical/smrt-reports:SmrtPrincipalReportRefreshTask"
274
+ },
275
+ {
276
+ "kind": "cli",
277
+ "name": "smrtprincipalreportrefreshtask_get",
278
+ "operation": "get",
279
+ "objectName": "@happyvertical/smrt-reports:SmrtPrincipalReportRefreshTask"
280
+ }
281
+ ],
282
+ "withheldSurfaces": [
283
+ {
284
+ "kind": "api",
285
+ "operation": "run",
286
+ "code": "api-disabled",
287
+ "reason": "api is disabled",
288
+ "objectName": "@happyvertical/smrt-reports:SmrtPrincipalReportRefreshTask"
289
+ }
290
+ ],
291
+ "relationshipFeatures": [
292
+ "uuidColumns"
293
+ ],
294
+ "tags": [],
295
+ "risks": []
296
+ },
227
297
  {
228
298
  "name": "SmrtReportRun",
229
299
  "qualifiedName": "@happyvertical/smrt-reports:SmrtReportRun",
@@ -765,6 +835,18 @@
765
835
  "operation": "get",
766
836
  "objectName": "@happyvertical/smrt-reports:SmrtReportRefreshTask"
767
837
  },
838
+ {
839
+ "kind": "cli",
840
+ "name": "smrtprincipalreportrefreshtask_list",
841
+ "operation": "list",
842
+ "objectName": "@happyvertical/smrt-reports:SmrtPrincipalReportRefreshTask"
843
+ },
844
+ {
845
+ "kind": "cli",
846
+ "name": "smrtprincipalreportrefreshtask_get",
847
+ "operation": "get",
848
+ "objectName": "@happyvertical/smrt-reports:SmrtPrincipalReportRefreshTask"
849
+ },
768
850
  {
769
851
  "kind": "cli",
770
852
  "name": "smrtreportrun_list",
@@ -821,7 +903,7 @@
821
903
  "junctionCollections": 0,
822
904
  "hierarchicalObjects": 0,
823
905
  "polymorphicAssociations": 0,
824
- "uuidColumns": 14
906
+ "uuidColumns": 20
825
907
  },
826
908
  "agentDoc": "# @happyvertical/smrt-reports\n\nMaterialized aggregate report models for SMRT.\n\n## Key Pieces\n\n| Module | Purpose |\n| --- | --- |\n| `SmrtReport` | Abstract report row base with `refreshedAt`, `isStale()`, and manual `refresh()` |\n| decorators | `@report`, grouping decorators, time buckets, and aggregate measure decorators |\n| compiler | Pure `ReportDefinition -> AggregateSpec` compiler and ObjectRegistry adapter |\n| aggregate | Compatibility re-export of the SDK aggregate query builder |\n| refresh | Rebuild and incremental refresh engine with run tracking, watermarks, locks, and tenant scoping |\n| state | Internal `_smrt_report_*` system models for runs, watermarks, locks, schedules, and refresh tasks |\n| scheduler | Cron schedule runner, durable refresh job enqueueing, and `onChange` interceptor registration |\n| adapter | Transport-neutral report descriptor, canonical materialized-row reads, and stable `id` row identity |\n| lifecycle | Tenant-safe freshness, run, lock, failure, and manual refresh preview/apply surfaces |\n| views | Policy-revalidated saved views, snapshot-bound exports, bounded job handoff, and artifact metadata validation |\n\n## Adapter contract\n\n- `buildReportAdapterDescriptor()` returns deterministic, serializable metadata\n for a report surface: a stable resource id, typed persisted report columns,\n the canonical `DataQuerySchema`, and UI-neutral DataTable hints. It must not\n import `smrt-ui` or expose a report-domain class to the consumer.\n- `queryReportMaterializedRows()` owns only the bounded read slice for rows that\n are already materialized. It supports projection, offset/limit paging,\n validated filters, deterministic multi-sort with an `id` tie-breaker, exact\n totals, and dimension facets. At source-query compilation,\n dimension and bucket filters compile to `WHERE`, aggregate-measure filters\n compile to `HAVING`, and mixed `OR`/`NOT` filter scopes fail closed.\n- `id` is the only row identity. It must be a non-empty persisted string and is\n never replaced by a display index or page position.\n- The descriptor is an exposure boundary. Sensitive/secret fields, fields with\n `readPermission`, and transient, system, or non-column fields fail closed and\n do not become public columns when no principal is available.\n- `tenantScoped`/`tenantField` reflect actual registered tenant metadata. A\n `tenantScope` option only contributes to the stable resource id; it is not\n authorization. Pass the same `adapter` options to\n `queryReportMaterializedRows()` that were used to build the selected\n descriptor, so results and background tasks retain that stable resource id.\n The default query path resolves the registered collection via `ObjectRegistry`,\n so normal collection tenancy interceptors apply. An injected collection is\n application-owned and must preserve the same boundary.\n- `refresh` is a declaration, not execution. It describes configured mode,\n triggers, positive-TTL stale-read behavior, and a permissioned/audited action\n with preview/apply phases. The adapter remains read-only, while\n `getReportLifecycle()` provides an explicit, tenant-safe lifecycle snapshot\n and `previewReportRefresh()` / `applyReportRefresh()` delegate authorization,\n audit, and queueing through an application action host. Only a registered\n `SmrtReportCollection` may synchronously refresh stale reads, when its TTL is\n positive and the report is not manual.\n- `views` accepts no authority. Persisted views must be normalized again through\n the current descriptor before restoring them, so changed column policy or a\n changed report definition fails closed. Treat unversioned persisted layouts as\n legacy v0 and migrate them to v1 before normalization; unknown versions must\n fail rather than being inferred. Export snapshots retain the canonical\n query fingerprint, projection, sort, as-of/freshness state, exact row count,\n and the fixed principal/tenant/report-definition/field-policy inheritance\n contract. A snapshot needs an application-host-issued opaque binding and an\n explicit offset-page read plan: workers advance it from offset zero instead\n of reusing a visible page. Preview, apply, worker, and artifact-serving\n boundaries must call the validators, including the host's immutable-snapshot\n assertion; application hosts own authorization, audit records, durable\n storage, download tokens, and queue execution.\n\n## Conventions\n\n- Report cache tables are normal `@smrt()` tables. Runtime refresh must not create schema.\n- Store report metadata under field `_meta.__report`; scanner and runtime decorators must stay aligned.\n- Keep SQL generation portable. Use the SDK `buildAggregate()`/`bucketExpr()` helpers for time buckets and `$N` placeholders.\n- Do not add a local aggregate SQL builder here; the implementation lives in `@happyvertical/sql`.\n- Refresh runtime tables are schema-managed. Runtime refresh must fail clearly when `_smrt_report_runs`, `_smrt_report_watermarks`, or `_smrt_report_locks` have not been migrated.\n- Incremental refresh requires a source watermark column (default `updatedAt`) and soft-delete column (default `deletedAt`); it recomputes affected groups and deletes empty report groups instead of applying aggregate deltas.\n- Raw aggregate refreshes must explicitly filter `tenant_id`; the tenancy interceptor only protects normal collection reads.\n- Scheduled/on-change refreshes enqueue `SmrtReportRefreshTask.run()` through `@happyvertical/smrt-jobs`; do not add a separate report queue.\n"
827
909
  }