@servicelabsco/slabs-access-manager 1.0.69 → 1.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/access/controllers/report.view.controller.d.ts +30 -12
  2. package/dist/access/dtos/report.view.query.dto.d.ts +1 -0
  3. package/dist/access/dtos/report.view.query.dto.js +20 -0
  4. package/dist/access/dtos/report.view.query.dto.js.map +1 -1
  5. package/dist/access/entities/business.material.view.entity.d.ts +9 -0
  6. package/dist/access/entities/business.material.view.entity.js +44 -0
  7. package/dist/access/entities/business.material.view.entity.js.map +1 -0
  8. package/dist/access/entities/index.d.ts +1 -0
  9. package/dist/access/entities/index.js +1 -0
  10. package/dist/access/entities/index.js.map +1 -1
  11. package/dist/access/es6.classes.d.ts +10 -3
  12. package/dist/access/es6.classes.js +6 -0
  13. package/dist/access/es6.classes.js.map +1 -1
  14. package/dist/access/libraries/index.d.ts +1 -0
  15. package/dist/access/libraries/index.js +1 -0
  16. package/dist/access/libraries/index.js.map +1 -1
  17. package/dist/access/libraries/report.view.enablement.d.ts +11 -0
  18. package/dist/access/libraries/report.view.enablement.js +49 -0
  19. package/dist/access/libraries/report.view.enablement.js.map +1 -0
  20. package/dist/access/libraries/report.view.query.compiler.d.ts +35 -2
  21. package/dist/access/libraries/report.view.query.compiler.js +274 -46
  22. package/dist/access/libraries/report.view.query.compiler.js.map +1 -1
  23. package/dist/access/services/listing.preference.service.js +11 -1
  24. package/dist/access/services/listing.preference.service.js.map +1 -1
  25. package/dist/access/services/listing.service.d.ts +3 -1
  26. package/dist/access/services/listing.service.js +7 -6
  27. package/dist/access/services/listing.service.js.map +1 -1
  28. package/dist/access/services/report.view.service.d.ts +34 -12
  29. package/dist/access/services/report.view.service.js +80 -13
  30. package/dist/access/services/report.view.service.js.map +1 -1
  31. package/dist/access/subscribers/business.material.view.subscriber.d.ts +8 -0
  32. package/dist/{accessWorkflow/subscribers/performance.record.subscriber.js → access/subscribers/business.material.view.subscriber.js} +10 -13
  33. package/dist/access/subscribers/business.material.view.subscriber.js.map +1 -0
  34. package/dist/access/subscribers/index.d.ts +1 -0
  35. package/dist/access/subscribers/index.js +1 -0
  36. package/dist/access/subscribers/index.js.map +1 -1
  37. package/dist/accessWorkflow/dtos/index.d.ts +0 -1
  38. package/dist/accessWorkflow/dtos/index.js +0 -1
  39. package/dist/accessWorkflow/dtos/index.js.map +1 -1
  40. package/dist/accessWorkflow/entities/index.d.ts +0 -1
  41. package/dist/accessWorkflow/entities/index.js +0 -1
  42. package/dist/accessWorkflow/entities/index.js.map +1 -1
  43. package/dist/accessWorkflow/es6.classes.d.ts +3 -6
  44. package/dist/accessWorkflow/es6.classes.js +0 -8
  45. package/dist/accessWorkflow/es6.classes.js.map +1 -1
  46. package/dist/accessWorkflow/jobs/index.d.ts +0 -1
  47. package/dist/accessWorkflow/jobs/index.js +0 -1
  48. package/dist/accessWorkflow/jobs/index.js.map +1 -1
  49. package/dist/accessWorkflow/services/es6.jobs.service.d.ts +1 -3
  50. package/dist/accessWorkflow/services/es6.jobs.service.js +1 -5
  51. package/dist/accessWorkflow/services/es6.jobs.service.js.map +1 -1
  52. package/dist/accessWorkflow/subscribers/index.d.ts +0 -1
  53. package/dist/accessWorkflow/subscribers/index.js +0 -1
  54. package/dist/accessWorkflow/subscribers/index.js.map +1 -1
  55. package/dist/config/entity.constants.d.ts +0 -2
  56. package/dist/config/entity.constants.js +0 -2
  57. package/dist/config/entity.constants.js.map +1 -1
  58. package/dist/development/controllers/development.catalog.controller.d.ts +25 -0
  59. package/dist/development/controllers/development.catalog.controller.js +32 -0
  60. package/dist/development/controllers/development.catalog.controller.js.map +1 -1
  61. package/dist/development/dtos/material.view.catalog.attributes.dto.d.ts +16 -0
  62. package/dist/development/dtos/material.view.catalog.attributes.dto.js +63 -1
  63. package/dist/development/dtos/material.view.catalog.attributes.dto.js.map +1 -1
  64. package/dist/development/services/admin.report.view.service.d.ts +23 -0
  65. package/dist/development/services/admin.report.view.service.js +195 -13
  66. package/dist/development/services/admin.report.view.service.js.map +1 -1
  67. package/dist/mcp/admin/tools/admin.tool.registry.js +6 -0
  68. package/dist/mcp/admin/tools/admin.tool.registry.js.map +1 -1
  69. package/dist/mcp/admin/tools/catalog/admin.column.describe.tool.js +1 -1
  70. package/dist/mcp/admin/tools/catalog/admin.column.describe.tool.js.map +1 -1
  71. package/dist/mcp/admin/tools/catalog/admin.view.business.clear.tool.d.ts +18 -0
  72. package/dist/mcp/admin/tools/catalog/admin.view.business.clear.tool.js +27 -0
  73. package/dist/mcp/admin/tools/catalog/admin.view.business.clear.tool.js.map +1 -0
  74. package/dist/mcp/admin/tools/catalog/admin.view.business.set.tool.d.ts +20 -0
  75. package/dist/mcp/admin/tools/catalog/admin.view.business.set.tool.js +28 -0
  76. package/dist/mcp/admin/tools/catalog/admin.view.business.set.tool.js.map +1 -0
  77. package/dist/mcp/admin/tools/catalog/admin.view.businesses.tool.d.ts +17 -0
  78. package/dist/mcp/admin/tools/catalog/admin.view.businesses.tool.js +23 -0
  79. package/dist/mcp/admin/tools/catalog/admin.view.businesses.tool.js.map +1 -0
  80. package/dist/mcp/admin/tools/catalog/admin.view.columns.sync.tool.d.ts +3 -1
  81. package/dist/mcp/admin/tools/catalog/admin.view.columns.sync.tool.js +5 -1
  82. package/dist/mcp/admin/tools/catalog/admin.view.columns.sync.tool.js.map +1 -1
  83. package/dist/mcp/admin/tools/catalog/admin.view.update.tool.d.ts +1 -1
  84. package/dist/mcp/admin/tools/catalog/admin.view.update.tool.js +2 -2
  85. package/dist/mcp/admin/tools/catalog/admin.view.update.tool.js.map +1 -1
  86. package/dist/mcp/admin/tools/catalog/index.d.ts +3 -0
  87. package/dist/mcp/admin/tools/catalog/index.js +3 -0
  88. package/dist/mcp/admin/tools/catalog/index.js.map +1 -1
  89. package/dist/mcp/es6.classes.d.ts +4 -0
  90. package/dist/mcp/logging/mcp.call.log.interceptor.js +1 -0
  91. package/dist/mcp/logging/mcp.call.log.interceptor.js.map +1 -1
  92. package/dist/mcp/logging/sanitize.mcp.call.log.d.ts +25 -0
  93. package/dist/mcp/logging/sanitize.mcp.call.log.js +60 -1
  94. package/dist/mcp/logging/sanitize.mcp.call.log.js.map +1 -1
  95. package/dist/mcp/mentities/mcp.call.log.mentity.d.ts +1 -0
  96. package/dist/mcp/mentities/mcp.call.log.mentity.js +4 -0
  97. package/dist/mcp/mentities/mcp.call.log.mentity.js.map +1 -1
  98. package/dist/mcp/tools/view.describe.tool.d.ts +1 -1
  99. package/dist/mcp/tools/view.describe.tool.js +1 -1
  100. package/dist/mcp/tools/view.describe.tool.js.map +1 -1
  101. package/dist/mcp/tools/view.query.tool.d.ts +5 -1
  102. package/dist/mcp/tools/view.query.tool.js +11 -3
  103. package/dist/mcp/tools/view.query.tool.js.map +1 -1
  104. package/dist/migrations/1787174933444-CreateBusinessMaterialViewTable.d.ts +5 -0
  105. package/dist/migrations/1787174933444-CreateBusinessMaterialViewTable.js +21 -0
  106. package/dist/migrations/1787174933444-CreateBusinessMaterialViewTable.js.map +1 -0
  107. package/package.json +1 -1
  108. package/dist/accessWorkflow/dtos/performance.record.attributes.dto.d.ts +0 -3
  109. package/dist/accessWorkflow/dtos/performance.record.attributes.dto.js +0 -8
  110. package/dist/accessWorkflow/dtos/performance.record.attributes.dto.js.map +0 -1
  111. package/dist/accessWorkflow/entities/performance.record.entity.d.ts +0 -11
  112. package/dist/accessWorkflow/entities/performance.record.entity.js +0 -50
  113. package/dist/accessWorkflow/entities/performance.record.entity.js.map +0 -1
  114. package/dist/accessWorkflow/jobs/performance.record.job.d.ts +0 -7
  115. package/dist/accessWorkflow/jobs/performance.record.job.js +0 -29
  116. package/dist/accessWorkflow/jobs/performance.record.job.js.map +0 -1
  117. package/dist/accessWorkflow/subscribers/performance.record.subscriber.d.ts +0 -10
  118. package/dist/accessWorkflow/subscribers/performance.record.subscriber.js.map +0 -1
@@ -6,6 +6,7 @@ export declare const DEFAULT_ROW_CAP = 500;
6
6
  export declare const OFFSET_CAP = 10000;
7
7
  export declare const GROUP_BY_CAP = 3;
8
8
  export declare const STATEMENT_TIMEOUT_MS = 2000;
9
+ export declare const RELATIVE_DATE_TOKENS: readonly ["@today", "@today-Nd", "@today+Nd", "@today-Nw", "@today+Nw", "@today-Nm", "@today+Nm", "@today-Ny", "@today+Ny", "@month_start", "@quarter_start", "@fy_start"];
9
10
  export declare const REPORT_VIEW_GRAMMAR: {
10
11
  readonly operators: readonly [{
11
12
  readonly op: "eq";
@@ -48,10 +49,15 @@ export declare const REPORT_VIEW_GRAMMAR: {
48
49
  readonly buckets: readonly ["day", "week", "month", "quarter", "year"];
49
50
  readonly sort_dirs: readonly ["asc", "desc"];
50
51
  readonly relative_dates: {
51
- readonly tokens: readonly ["@today", "@today-Nd", "@today+Nd"];
52
+ readonly tokens: readonly ["@today", "@today-Nd", "@today+Nd", "@today-Nw", "@today+Nw", "@today-Nm", "@today+Nm", "@today-Ny", "@today+Ny", "@month_start", "@quarter_start", "@fy_start"];
52
53
  readonly example: "@today-30d";
53
54
  readonly timezone: "Asia/Kolkata";
54
- readonly note: "Resolved server-side to an ISO calendar date. Do not compute today in the client.";
55
+ readonly fy_anchor: "04-01";
56
+ readonly note: "Resolved server-side to an ISO calendar date in Asia/Kolkata. @fy_start is 1 April (Indian FY); in January–March that is last April. Do not compute today or period boundaries in the client.";
57
+ };
58
+ readonly caller: {
59
+ readonly tokens: readonly ["@me"];
60
+ readonly note: "Resolved server-side to the caller: user id on id-type columns, email on string columns. Incompatible columns return value_type_mismatch.";
55
61
  };
56
62
  readonly rails: {
57
63
  readonly tenancy: "injected";
@@ -60,6 +66,13 @@ export declare const REPORT_VIEW_GRAMMAR: {
60
66
  readonly offset_cap: 10000;
61
67
  readonly statement_timeout_ms: 2000;
62
68
  };
69
+ readonly join: {
70
+ readonly max: 1;
71
+ readonly cardinality: "many_to_one";
72
+ readonly columns: "<relationship>.<column>";
73
+ readonly semantics: "LEFT JOIN; unmatched rows yield nulls for <rel>.*";
74
+ readonly note: "Name a catalog-declared relationship from view_describe. Do not send ON conditions.";
75
+ };
63
76
  };
64
77
  export type ReportViewOp = (typeof OPS)[number];
65
78
  export type ReportViewAggFn = (typeof AGG_FNS)[number];
@@ -86,6 +99,9 @@ export type CatalogView = {
86
99
  grain?: string;
87
100
  domain?: string;
88
101
  tenancy_column: string;
102
+ user_scoped?: boolean;
103
+ user_column?: string;
104
+ view_kind?: 'view' | 'materialized';
89
105
  row_cap: number;
90
106
  last_run_time?: Date | string | null;
91
107
  example_queries?: Array<{
@@ -94,6 +110,7 @@ export type CatalogView = {
94
110
  }>;
95
111
  suggested_visualizations?: unknown[];
96
112
  columns: CatalogColumn[];
113
+ relationships?: CatalogRelationship[];
97
114
  };
98
115
  export type ReportViewFilter = {
99
116
  column?: string;
@@ -113,7 +130,17 @@ export type ReportViewSort = {
113
130
  by?: string;
114
131
  dir?: string;
115
132
  };
133
+ export type CatalogRelationship = {
134
+ name: string;
135
+ view: string;
136
+ on: Record<string, string>;
137
+ when?: Record<string, unknown>;
138
+ cardinality: string;
139
+ description?: string;
140
+ target?: CatalogView;
141
+ };
116
142
  export type ReportViewDsl = {
143
+ join?: string | string[];
117
144
  columns?: string[];
118
145
  filters?: ReportViewFilter[];
119
146
  group_by?: ReportViewGroupBy[];
@@ -140,6 +167,7 @@ export type CompiledQuery = {
140
167
  grain?: string;
141
168
  table_name: string;
142
169
  refreshed_at: Date | string | null;
170
+ live?: boolean;
143
171
  };
144
172
  };
145
173
  export declare class ReportViewQueryError extends Error {
@@ -148,6 +176,10 @@ export declare class ReportViewQueryError extends Error {
148
176
  }
149
177
  export type CompileOptions = {
150
178
  now?: Date;
179
+ user?: {
180
+ id: number;
181
+ email?: string;
182
+ };
151
183
  };
152
184
  export declare function compileReportViewQuery(view: CatalogView, dsl: ReportViewDsl, businessId: number, options?: CompileOptions): CompiledQuery;
153
185
  export declare function agentVisibleColumns(view: CatalogView): CatalogColumn[];
@@ -160,4 +192,5 @@ export declare const ReportViewQueryCompiler: {
160
192
  quoteIdent: typeof quoteIdent;
161
193
  resolveRelativeDate: typeof resolveRelativeDate;
162
194
  nearest: typeof nearest;
195
+ RELATIVE_DATE_TOKENS: readonly ["@today", "@today-Nd", "@today+Nd", "@today-Nw", "@today+Nw", "@today-Nm", "@today+Nm", "@today-Ny", "@today+Ny", "@month_start", "@quarter_start", "@fy_start"];
163
196
  };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ReportViewQueryCompiler = exports.ReportViewQueryError = exports.REPORT_VIEW_GRAMMAR = exports.STATEMENT_TIMEOUT_MS = exports.GROUP_BY_CAP = exports.OFFSET_CAP = exports.DEFAULT_ROW_CAP = exports.SORT_DIRS = exports.BUCKETS = exports.AGG_FNS = exports.OPS = void 0;
3
+ exports.ReportViewQueryCompiler = exports.ReportViewQueryError = exports.REPORT_VIEW_GRAMMAR = exports.RELATIVE_DATE_TOKENS = exports.STATEMENT_TIMEOUT_MS = exports.GROUP_BY_CAP = exports.OFFSET_CAP = exports.DEFAULT_ROW_CAP = exports.SORT_DIRS = exports.BUCKETS = exports.AGG_FNS = exports.OPS = void 0;
4
4
  exports.compileReportViewQuery = compileReportViewQuery;
5
5
  exports.agentVisibleColumns = agentVisibleColumns;
6
6
  exports.quoteIdent = quoteIdent;
@@ -16,8 +16,23 @@ exports.GROUP_BY_CAP = 3;
16
16
  exports.STATEMENT_TIMEOUT_MS = 2000;
17
17
  const IDENT = /^[a-z][a-z0-9_]*$/;
18
18
  const NUMERIC_AGG = new Set(['sum', 'avg', 'min', 'max']);
19
- const RELATIVE_DATE = /^@today(?:([+-])(\d+)d)?$/;
19
+ const RELATIVE_DATE = /^@(?:today(?:([+-])(\d+)([dwmy]))?|month_start|quarter_start|fy_start)$/;
20
20
  const RELATIVE_DATE_TZ = 'Asia/Kolkata';
21
+ const FY_START_MONTH = 4;
22
+ exports.RELATIVE_DATE_TOKENS = [
23
+ '@today',
24
+ '@today-Nd',
25
+ '@today+Nd',
26
+ '@today-Nw',
27
+ '@today+Nw',
28
+ '@today-Nm',
29
+ '@today+Nm',
30
+ '@today-Ny',
31
+ '@today+Ny',
32
+ '@month_start',
33
+ '@quarter_start',
34
+ '@fy_start',
35
+ ];
21
36
  exports.REPORT_VIEW_GRAMMAR = {
22
37
  operators: [
23
38
  { op: 'eq', value: 'scalar' },
@@ -40,10 +55,15 @@ exports.REPORT_VIEW_GRAMMAR = {
40
55
  buckets: [...exports.BUCKETS],
41
56
  sort_dirs: [...exports.SORT_DIRS],
42
57
  relative_dates: {
43
- tokens: ['@today', '@today-Nd', '@today+Nd'],
58
+ tokens: [...exports.RELATIVE_DATE_TOKENS],
44
59
  example: '@today-30d',
45
60
  timezone: RELATIVE_DATE_TZ,
46
- note: 'Resolved server-side to an ISO calendar date. Do not compute today in the client.',
61
+ fy_anchor: '04-01',
62
+ note: 'Resolved server-side to an ISO calendar date in Asia/Kolkata. @fy_start is 1 April (Indian FY); in January–March that is last April. Do not compute today or period boundaries in the client.',
63
+ },
64
+ caller: {
65
+ tokens: ['@me'],
66
+ note: 'Resolved server-side to the caller: user id on id-type columns, email on string columns. Incompatible columns return value_type_mismatch.',
47
67
  },
48
68
  rails: {
49
69
  tenancy: 'injected',
@@ -52,6 +72,13 @@ exports.REPORT_VIEW_GRAMMAR = {
52
72
  offset_cap: exports.OFFSET_CAP,
53
73
  statement_timeout_ms: exports.STATEMENT_TIMEOUT_MS,
54
74
  },
75
+ join: {
76
+ max: 1,
77
+ cardinality: 'many_to_one',
78
+ columns: '<relationship>.<column>',
79
+ semantics: 'LEFT JOIN; unmatched rows yield nulls for <rel>.*',
80
+ note: 'Name a catalog-declared relationship from view_describe. Do not send ON conditions.',
81
+ },
55
82
  };
56
83
  class ReportViewQueryError extends Error {
57
84
  constructor(code, message) {
@@ -68,7 +95,11 @@ function compileReportViewQuery(view, dsl, businessId, options = {}) {
68
95
  const agentColumns = agentVisibleColumns(view);
69
96
  const byName = new Map(agentColumns.map((column) => [column.name, column]));
70
97
  const names = agentColumns.map((column) => column.name);
71
- assertNoTenancyLeak(dsl, view, names);
98
+ const join = resolveJoin(view, dsl.join);
99
+ const targetColumns = join?.target ? agentVisibleColumns(join.target) : [];
100
+ const targetByName = new Map(targetColumns.map((column) => [column.name, column]));
101
+ const targetNames = targetColumns.map((column) => column.name);
102
+ assertNoInjectedColumnLeak(dsl, view, names, join);
72
103
  const aggregates = dsl.aggregates || [];
73
104
  const groupBy = dsl.group_by || [];
74
105
  const isAggregate = aggregates.length > 0 || groupBy.length > 0;
@@ -87,37 +118,47 @@ function compileReportViewQuery(view, dsl, businessId, options = {}) {
87
118
  throw new ReportViewQueryError('limit', 'limit must be a positive integer.');
88
119
  const limit = Math.min(requested, rowCap);
89
120
  const now = options.now || new Date();
121
+ const user = options.user;
90
122
  const params = [businessId];
123
+ const joinSql = join ? compileJoin(view, join, params, user) : '';
91
124
  const select = [];
92
125
  const output = [];
93
126
  const selectNames = new Set();
94
- const addOutput = (column) => {
127
+ const resolve = (name) => resolveColumn(name, view, join, byName, names, targetByName, targetNames);
128
+ const addOutput = (column, outputName = column.name) => {
95
129
  output.push({
96
- name: column.name,
97
- title: column.display_name || column.name,
130
+ name: outputName,
131
+ title: column.display_name || outputName,
98
132
  type: column.semantic_type || 'string',
99
133
  ...(column.format ? { format: column.format } : {}),
100
134
  ...(column.enum_map ? { enum_map: column.enum_map } : {}),
101
135
  });
102
- selectNames.add(column.name);
136
+ selectNames.add(outputName);
137
+ };
138
+ const selectExpr = (column, bucket) => {
139
+ if (bucket)
140
+ return `date_trunc('${bucket}', ${column.sqlIdent}) AS ${quoteAlias(column.outputName)}`;
141
+ if (column.joined)
142
+ return `${column.sqlIdent} AS ${quoteAlias(column.outputName)}`;
143
+ return column.sqlIdent;
103
144
  };
104
145
  if (isAggregate) {
105
146
  for (const group of groupBy) {
106
- const column = requireColumn(group.column, byName, names, view.catalog_name);
147
+ const column = resolve(group.column);
107
148
  if (column.groupable === false) {
108
- throw new ReportViewQueryError('not_groupable', `not_groupable: column '${column.name}' cannot be grouped.`);
149
+ throw new ReportViewQueryError('not_groupable', `not_groupable: column '${column.outputName}' cannot be grouped.`);
109
150
  }
110
151
  if (group.bucket) {
111
152
  if (column.bucketable !== true) {
112
- throw new ReportViewQueryError('not_bucketable', `not_bucketable: column '${column.name}' is not bucketable.`);
153
+ throw new ReportViewQueryError('not_bucketable', `not_bucketable: column '${column.outputName}' is not bucketable.`);
113
154
  }
114
155
  const bucket = requireAllowlisted(group.bucket, exports.BUCKETS, 'bucket');
115
- select.push(`date_trunc('${bucket}', ${quoteIdent(column.name)}) AS ${quoteIdent(column.name)}`);
156
+ select.push(selectExpr(column, bucket));
116
157
  }
117
158
  else {
118
- select.push(quoteIdent(column.name));
159
+ select.push(selectExpr(column));
119
160
  }
120
- addOutput(column);
161
+ addOutput(column, column.outputName);
121
162
  }
122
163
  for (const aggregate of aggregates) {
123
164
  const fn = requireAllowlisted(aggregate.fn, exports.AGG_FNS, 'aggregate');
@@ -129,14 +170,17 @@ function compileReportViewQuery(view, dsl, businessId, options = {}) {
129
170
  select.push(`COUNT(*) AS ${quoteIdent(alias)}`);
130
171
  }
131
172
  else {
132
- const column = requireColumn(aggregate.column, byName, names, view.catalog_name);
173
+ const column = resolve(aggregate.column);
174
+ if (column.joined) {
175
+ throw new ReportViewQueryError('join_aggregate_not_supported', `join_aggregate_not_supported: aggregating joined column '${column.outputName}' is not supported. Aggregate on the base view; filter or group by <rel>.* instead.`);
176
+ }
133
177
  assertAggregatable(column, fn);
134
178
  if (fn === 'count')
135
- select.push(`COUNT(${quoteIdent(column.name)}) AS ${quoteIdent(alias)}`);
179
+ select.push(`COUNT(${column.sqlIdent}) AS ${quoteIdent(alias)}`);
136
180
  else if (fn === 'count_distinct')
137
- select.push(`COUNT(DISTINCT ${quoteIdent(column.name)}) AS ${quoteIdent(alias)}`);
181
+ select.push(`COUNT(DISTINCT ${column.sqlIdent}) AS ${quoteIdent(alias)}`);
138
182
  else
139
- select.push(`${fn.toUpperCase()}(${quoteIdent(column.name)}) AS ${quoteIdent(alias)}`);
183
+ select.push(`${fn.toUpperCase()}(${column.sqlIdent}) AS ${quoteIdent(alias)}`);
140
184
  }
141
185
  addOutput({ name: alias, display_name: alias, semantic_type: 'number' });
142
186
  }
@@ -150,24 +194,39 @@ function compileReportViewQuery(view, dsl, businessId, options = {}) {
150
194
  else {
151
195
  const requestedCols = dsl.columns?.length ? dsl.columns : names;
152
196
  for (const name of requestedCols) {
153
- const column = requireColumn(name, byName, names, view.catalog_name);
154
- select.push(quoteIdent(column.name));
155
- addOutput(column);
197
+ const column = resolve(name);
198
+ select.push(selectExpr(column));
199
+ addOutput(column, column.outputName);
156
200
  }
157
201
  if (!select.length) {
158
202
  throw new ReportViewQueryError('unknown_column', `unknown_column: view '${view.catalog_name}' has no selectable columns.`);
159
203
  }
160
204
  }
161
- const where = [`${quoteIdent(view.tenancy_column)} = $1`];
205
+ const tenancyIdent = join ? `${quoteIdent(view.table_name)}.${quoteIdent(view.tenancy_column)}` : quoteIdent(view.tenancy_column);
206
+ const where = [`${tenancyIdent} = $1`];
207
+ if (view.user_scoped) {
208
+ const userCol = view.user_column;
209
+ if (!userCol || !IDENT.test(userCol)) {
210
+ throw new ReportViewQueryError('user_scope', 'user_scope: user_scoped views require a hidden user_column.');
211
+ }
212
+ if (!user?.id) {
213
+ throw new ReportViewQueryError('user_scope', 'user_scope: user_scoped views require an authenticated caller.');
214
+ }
215
+ const userIdent = join ? `${quoteIdent(view.table_name)}.${quoteIdent(userCol)}` : quoteIdent(userCol);
216
+ where.push(`${userIdent} = ${bind(params, user.id)}`);
217
+ }
162
218
  for (const filter of dsl.filters || []) {
163
- const column = requireColumn(filter.column, byName, names, view.catalog_name);
219
+ const column = resolve(filter.column);
164
220
  if (column.filterable === false) {
165
- throw new ReportViewQueryError('not_filterable', `not_filterable: column '${column.name}' cannot be filtered.`);
221
+ throw new ReportViewQueryError('not_filterable', `not_filterable: column '${column.outputName}' cannot be filtered.`);
166
222
  }
167
223
  const op = requireAllowlisted(filter.op, exports.OPS, 'op');
168
- where.push(compileFilter(column, op, filter.value, params, now));
224
+ where.push(compileFilter(column, column.sqlIdent, op, filter.value, params, now, user));
169
225
  }
170
- const sql = [`SELECT ${select.join(', ')}`, `FROM ${quoteIdent(view.table_name)}`, `WHERE ${where.join(' AND ')}`];
226
+ const sql = [`SELECT ${select.join(', ')}`, `FROM ${quoteIdent(view.table_name)}`];
227
+ if (joinSql)
228
+ sql.push(joinSql);
229
+ sql.push(`WHERE ${where.join(' AND ')}`);
171
230
  if (groupBy.length) {
172
231
  sql.push(`GROUP BY ${groupBy.map((_, index) => String(index + 1)).join(', ')}`);
173
232
  }
@@ -178,7 +237,7 @@ function compileReportViewQuery(view, dsl, businessId, options = {}) {
178
237
  throw unknownColumn(by, [...selectNames], view.catalog_name);
179
238
  }
180
239
  const dir = item.dir ? requireAllowlisted(String(item.dir).toLowerCase(), exports.SORT_DIRS, 'dir') : 'asc';
181
- return `${quoteIdent(by)} ${dir.toUpperCase()}`;
240
+ return `${quoteAlias(by)} ${dir.toUpperCase()}`;
182
241
  });
183
242
  sql.push(`ORDER BY ${order.join(', ')}`);
184
243
  }
@@ -195,7 +254,8 @@ function compileReportViewQuery(view, dsl, businessId, options = {}) {
195
254
  name: view.catalog_name,
196
255
  grain: view.grain,
197
256
  table_name: view.table_name,
198
- refreshed_at: view.last_run_time ?? null,
257
+ refreshed_at: view.view_kind === 'view' ? null : (view.last_run_time ?? null),
258
+ ...(view.view_kind === 'view' ? { live: true } : {}),
199
259
  },
200
260
  };
201
261
  }
@@ -203,6 +263,8 @@ function agentVisibleColumns(view) {
203
263
  return (view.columns || []).filter((column) => {
204
264
  if (!column?.name || column.name === view.tenancy_column || column.name === 'business_id')
205
265
  return false;
266
+ if (view.user_column && column.name === view.user_column)
267
+ return false;
206
268
  if (column.visibility === false)
207
269
  return false;
208
270
  if (column.sensitive === true)
@@ -215,17 +277,119 @@ function quoteIdent(name) {
215
277
  throw new ReportViewQueryError('unknown_column', `unknown_column: '${name}' is not a valid identifier.`);
216
278
  return `"${name}"`;
217
279
  }
218
- function requireColumn(name, byName, names, viewName) {
280
+ function quoteAlias(name) {
281
+ if (!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/.test(name)) {
282
+ throw new ReportViewQueryError('unknown_column', `unknown_column: '${name}' is not a valid identifier.`);
283
+ }
284
+ return `"${name}"`;
285
+ }
286
+ function declaredRelationshipNames(view) {
287
+ return (view.relationships || []).map((relationship) => relationship.name).filter(Boolean);
288
+ }
289
+ function resolveJoin(view, joinName) {
290
+ const name = normalizeJoinName(joinName);
291
+ if (name === undefined)
292
+ return undefined;
293
+ const declared = declaredRelationshipNames(view);
294
+ const listed = declared.length ? declared.join(', ') : '(none)';
295
+ const shown = IDENT.test(name) ? `'${name}'` : 'that name';
296
+ if (!IDENT.test(name)) {
297
+ throw new ReportViewQueryError('unknown_relationship', `unknown_relationship: ${shown} is not a declared relationship on ${view.catalog_name}. Declared: ${listed}.`);
298
+ }
299
+ const relationship = (view.relationships || []).find((candidate) => candidate.name === name);
300
+ if (!relationship) {
301
+ throw new ReportViewQueryError('unknown_relationship', `unknown_relationship: '${name}' is not a declared relationship on ${view.catalog_name}. Declared: ${listed}.`);
302
+ }
303
+ if (relationship.cardinality !== 'many_to_one' || !relationship.target) {
304
+ throw new ReportViewQueryError('relationship_unavailable', `relationship_unavailable: relationship '${relationship.name}' on ${view.catalog_name} is not available (target view '${relationship.view}' is not agent-visible or failed the catalog gate).`);
305
+ }
306
+ return relationship;
307
+ }
308
+ function normalizeJoinName(join) {
309
+ if (join === undefined || join === '')
310
+ return undefined;
311
+ if (Array.isArray(join)) {
312
+ if (!join.length)
313
+ return undefined;
314
+ if (join.length > 1) {
315
+ throw new ReportViewQueryError('join_limit', `join_limit: v1 supports exactly one relationship (got ${join.length}).`);
316
+ }
317
+ return join[0];
318
+ }
319
+ return join;
320
+ }
321
+ function compileJoin(view, relationship, params, user) {
322
+ const alias = quoteIdent(relationship.name);
323
+ const target = relationship.target;
324
+ const on = Object.entries(relationship.on || {});
325
+ if (!on.length) {
326
+ throw new ReportViewQueryError('relationship_unavailable', `relationship_unavailable: relationship '${relationship.name}' on ${view.catalog_name} is not available (target view '${relationship.view}' is not agent-visible or failed the catalog gate).`);
327
+ }
328
+ const conds = [];
329
+ for (const [sourceCol, targetCol] of on) {
330
+ conds.push(`${quoteIdent(view.table_name)}.${quoteIdent(sourceCol)} = ${alias}.${quoteIdent(String(targetCol))}`);
331
+ }
332
+ conds.push(`${alias}.${quoteIdent(target.tenancy_column || 'business_id')} = $1`);
333
+ if (target.user_scoped) {
334
+ const userCol = target.user_column;
335
+ if (!userCol || !IDENT.test(userCol) || !user?.id) {
336
+ throw new ReportViewQueryError('user_scope', `user_scope: joined view '${relationship.view}' is user-scoped and requires a hidden user_column plus an authenticated caller.`);
337
+ }
338
+ conds.push(`${alias}.${quoteIdent(userCol)} = ${bind(params, user.id)}`);
339
+ }
340
+ const byName = new Map((view.columns || []).map((column) => [column.name, column]));
341
+ for (const [sourceCol, value] of Object.entries(relationship.when || {})) {
342
+ if (sourceCol === view.tenancy_column || sourceCol === 'business_id')
343
+ continue;
344
+ const column = byName.get(sourceCol);
345
+ if (!column)
346
+ throw unknownColumn(sourceCol, [...byName.keys()], view.catalog_name);
347
+ conds.push(`${quoteIdent(view.table_name)}.${quoteIdent(sourceCol)} = ${bind(params, mapEnum(column, value))}`);
348
+ }
349
+ return `LEFT JOIN ${quoteIdent(target.table_name)} AS ${alias} ON ${conds.join(' AND ')}`;
350
+ }
351
+ function resolveColumn(name, view, join, byName, names, targetByName, targetNames) {
219
352
  const key = String(name || '');
220
- if (key === 'business_id')
221
- throw unknownColumn(key, names, viewName);
353
+ const parts = key.split('.');
354
+ if (parts.length > 2)
355
+ throw unknownColumn(key, names, view.catalog_name);
356
+ if (parts.length === 2) {
357
+ const [relName, columnName] = parts;
358
+ if (!join || join.name !== relName) {
359
+ const hint = join
360
+ ? ` Active join is '${join.name}'; address joined columns as ${join.name}.<column>.`
361
+ : ' Name a catalog-declared relationship with join first.';
362
+ throw new ReportViewQueryError('unknown_column', `unknown_column: '${key}' is not a column on ${view.catalog_name}.${hint}`);
363
+ }
364
+ if (columnName === 'business_id' || columnName === join.target?.tenancy_column || columnName === join.target?.user_column) {
365
+ throw unknownColumn(key, targetNames, join.view);
366
+ }
367
+ const column = targetByName.get(columnName);
368
+ if (!column)
369
+ throw unknownColumn(key, targetNames, join.view);
370
+ return {
371
+ ...column,
372
+ sqlIdent: `${quoteIdent(join.name)}.${quoteIdent(column.name)}`,
373
+ outputName: `${join.name}.${column.name}`,
374
+ joined: true,
375
+ };
376
+ }
377
+ if (key === 'business_id' || key === view.tenancy_column || (view.user_column && key === view.user_column)) {
378
+ throw unknownColumn(key, names, view.catalog_name);
379
+ }
222
380
  const column = byName.get(key);
223
381
  if (!column)
224
- throw unknownColumn(key, names, viewName);
225
- return column;
382
+ throw unknownColumn(key, names, view.catalog_name);
383
+ return {
384
+ ...column,
385
+ sqlIdent: join ? `${quoteIdent(view.table_name)}.${quoteIdent(column.name)}` : quoteIdent(column.name),
386
+ outputName: column.name,
387
+ joined: false,
388
+ };
226
389
  }
227
390
  function unknownColumn(name, names, viewName) {
228
- const suggestions = nearest(name, names, 3);
391
+ const needle = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name;
392
+ const suggestions = nearest(needle, names, 3);
229
393
  const hint = suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : '';
230
394
  return new ReportViewQueryError('unknown_column', `unknown_column: '${name}' is not a column on ${viewName}.${hint}`);
231
395
  }
@@ -265,8 +429,7 @@ function assertAggregatable(column, fn) {
265
429
  throw new ReportViewQueryError('aggregate_not_allowed', `aggregate_not_allowed: ${fn} on ${column.name} — did you mean count?`);
266
430
  }
267
431
  }
268
- function compileFilter(column, op, value, params, now) {
269
- const ident = quoteIdent(column.name);
432
+ function compileFilter(column, ident, op, value, params, now, user) {
270
433
  if (op === 'is_null')
271
434
  return `${ident} IS NULL`;
272
435
  if (op === 'not_null')
@@ -275,7 +438,7 @@ function compileFilter(column, op, value, params, now) {
275
438
  const list = Array.isArray(value) ? value : null;
276
439
  if (!list?.length)
277
440
  throw new ReportViewQueryError('value_type_mismatch', `value_type_mismatch: ${op} on ${column.name} needs a non-empty array.`);
278
- const placeholders = list.map((item) => bind(params, mapEnum(column, resolveRelativeDate(item, now))));
441
+ const placeholders = list.map((item) => bind(params, mapEnum(column, resolveValue(item, column, now, user))));
279
442
  return op === 'in' ? `${ident} IN (${placeholders.join(', ')})` : `${ident} NOT IN (${placeholders.join(', ')})`;
280
443
  }
281
444
  if (op === 'between') {
@@ -283,35 +446,69 @@ function compileFilter(column, op, value, params, now) {
283
446
  if (pair?.length !== 2) {
284
447
  throw new ReportViewQueryError('value_type_mismatch', `value_type_mismatch: between on ${column.name} needs [from, to].`);
285
448
  }
286
- return `${ident} BETWEEN ${bind(params, mapEnum(column, resolveRelativeDate(pair[0], now)))} AND ${bind(params, mapEnum(column, resolveRelativeDate(pair[1], now)))}`;
449
+ return `${ident} BETWEEN ${bind(params, mapEnum(column, resolveValue(pair[0], column, now, user)))} AND ${bind(params, mapEnum(column, resolveValue(pair[1], column, now, user)))}`;
287
450
  }
288
451
  if (op === 'contains') {
289
452
  if (typeof value !== 'string') {
290
453
  throw new ReportViewQueryError('value_type_mismatch', `value_type_mismatch: contains on ${column.name} needs a string.`);
291
454
  }
292
- return `${ident} ILIKE ${bind(params, `%${escapeLike(value)}%`)} ESCAPE '\\\\'`;
455
+ return `${ident} ILIKE ${bind(params, `%${escapeLike(value)}%`)} ESCAPE '\\'`;
293
456
  }
294
457
  if (value === undefined || value === null || Array.isArray(value)) {
295
458
  throw new ReportViewQueryError('value_type_mismatch', `value_type_mismatch: ${op} on ${column.name} needs a single value.`);
296
459
  }
297
- const mapped = mapEnum(column, resolveRelativeDate(value, now));
460
+ const mapped = mapEnum(column, resolveValue(value, column, now, user));
298
461
  const sqlOp = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' }[op];
299
462
  return `${ident} ${sqlOp} ${bind(params, mapped)}`;
300
463
  }
464
+ function resolveValue(value, column, now, user) {
465
+ if (value === '@me')
466
+ return resolveMe(column, user);
467
+ return resolveRelativeDate(value, now);
468
+ }
469
+ function resolveMe(column, user) {
470
+ if (!user?.id) {
471
+ throw new ReportViewQueryError('value_type_mismatch', 'value_type_mismatch: @me requires an authenticated caller.');
472
+ }
473
+ const type = column.semantic_type;
474
+ const idLike = type === 'id' || column.name === 'id' || /_id$/.test(column.name);
475
+ if (idLike)
476
+ return user.id;
477
+ if (type === 'money' || type === 'percent' || type === 'date' || type === 'enum') {
478
+ throw new ReportViewQueryError('value_type_mismatch', `value_type_mismatch: @me is not valid on ${column.name} (${type || 'this type'}). Use an id or string column.`);
479
+ }
480
+ if (!user.email) {
481
+ throw new ReportViewQueryError('value_type_mismatch', 'value_type_mismatch: @me on a string column needs the caller email.');
482
+ }
483
+ return user.email;
484
+ }
301
485
  function resolveRelativeDate(value, now) {
302
486
  if (typeof value !== 'string')
303
487
  return value;
304
488
  if (value.startsWith('@') && !RELATIVE_DATE.test(value)) {
305
- throw new ReportViewQueryError('value_type_mismatch', 'value_type_mismatch: relative dates must be @today or @today±Nd (e.g. @today-30d). Do not compute today in the client.');
489
+ throw new ReportViewQueryError('value_type_mismatch', 'value_type_mismatch: relative dates must be @today, @today±N[d|w|m|y], @month_start, @quarter_start, or @fy_start (e.g. @today-3m, @fy_start). Do not compute today in the client.');
306
490
  }
307
491
  const match = value.match(RELATIVE_DATE);
308
492
  if (!match)
309
493
  return value;
310
494
  const today = calendarDate(now, RELATIVE_DATE_TZ);
495
+ if (value === '@month_start')
496
+ return monthStart(today);
497
+ if (value === '@quarter_start')
498
+ return quarterStart(today);
499
+ if (value === '@fy_start')
500
+ return fyStart(today);
311
501
  if (!match[1])
312
502
  return today;
313
503
  const delta = Number(match[2]) * (match[1] === '-' ? -1 : 1);
314
- return addCalendarDays(today, delta);
504
+ const unit = match[3];
505
+ if (unit === 'd')
506
+ return addCalendarDays(today, delta);
507
+ if (unit === 'w')
508
+ return addCalendarDays(today, delta * 7);
509
+ if (unit === 'm')
510
+ return addCalendarMonths(today, delta);
511
+ return addCalendarMonths(today, delta * 12);
315
512
  }
316
513
  function calendarDate(now, timeZone) {
317
514
  return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
@@ -321,6 +518,28 @@ function addCalendarDays(isoDate, days) {
321
518
  const utc = new Date(Date.UTC(year, month - 1, day + days));
322
519
  return utc.toISOString().slice(0, 10);
323
520
  }
521
+ function addCalendarMonths(isoDate, months) {
522
+ const [year, month, day] = isoDate.split('-').map(Number);
523
+ const total = year * 12 + (month - 1) + months;
524
+ const nextYear = Math.floor(total / 12);
525
+ const nextMonth = ((total % 12) + 12) % 12;
526
+ const lastDay = new Date(Date.UTC(nextYear, nextMonth + 1, 0)).getUTCDate();
527
+ const clamped = Math.min(day, lastDay);
528
+ return `${nextYear}-${String(nextMonth + 1).padStart(2, '0')}-${String(clamped).padStart(2, '0')}`;
529
+ }
530
+ function monthStart(isoDate) {
531
+ return `${isoDate.slice(0, 7)}-01`;
532
+ }
533
+ function quarterStart(isoDate) {
534
+ const month = Number(isoDate.slice(5, 7));
535
+ const start = Math.floor((month - 1) / 3) * 3 + 1;
536
+ return `${isoDate.slice(0, 4)}-${String(start).padStart(2, '0')}-01`;
537
+ }
538
+ function fyStart(isoDate) {
539
+ const year = Number(isoDate.slice(0, 4));
540
+ const month = Number(isoDate.slice(5, 7));
541
+ return month >= FY_START_MONTH ? `${year}-04-01` : `${year - 1}-04-01`;
542
+ }
324
543
  function mapEnum(column, value) {
325
544
  const map = column.enum_map;
326
545
  if (!map || value === undefined || value === null || typeof value === 'object')
@@ -344,11 +563,19 @@ function assertInt(value, field) {
344
563
  throw new ReportViewQueryError('value_type_mismatch', `value_type_mismatch: ${field} must be an integer.`);
345
564
  return n;
346
565
  }
347
- function assertNoTenancyLeak(dsl, view, names) {
348
- const banned = new Set([view.tenancy_column, 'business_id']);
566
+ function assertNoInjectedColumnLeak(dsl, view, names, join) {
567
+ const banned = new Set([view.tenancy_column, 'business_id', view.user_column].filter(Boolean));
568
+ if (join?.target) {
569
+ banned.add(join.target.tenancy_column || 'business_id');
570
+ if (join.target.user_column)
571
+ banned.add(join.target.user_column);
572
+ }
349
573
  const check = (value) => {
350
574
  const key = String(value || '');
351
- if (banned.has(key))
575
+ if (!key)
576
+ return;
577
+ const column = key.includes('.') ? key.slice(key.lastIndexOf('.') + 1) : key;
578
+ if (banned.has(key) || banned.has(column))
352
579
  throw unknownColumn(key, names, view.catalog_name);
353
580
  };
354
581
  (dsl.columns || []).forEach(check);
@@ -395,5 +622,6 @@ exports.ReportViewQueryCompiler = {
395
622
  quoteIdent,
396
623
  resolveRelativeDate,
397
624
  nearest,
625
+ RELATIVE_DATE_TOKENS: exports.RELATIVE_DATE_TOKENS,
398
626
  };
399
627
  //# sourceMappingURL=report.view.query.compiler.js.map