@soulbatical/tetra-core 0.1.13 → 0.1.15

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.

Potentially problematic release.


This version of @soulbatical/tetra-core might be problematic. Click here for more details.

Files changed (77) hide show
  1. package/dist/generators/rls-auditor.d.ts +39 -0
  2. package/dist/generators/rls-auditor.d.ts.map +1 -0
  3. package/dist/generators/rls-auditor.js +505 -0
  4. package/dist/generators/rls-auditor.js.map +1 -0
  5. package/dist/generators/rls-checker.d.ts +94 -0
  6. package/dist/generators/rls-checker.d.ts.map +1 -0
  7. package/dist/generators/rls-checker.js +215 -0
  8. package/dist/generators/rls-checker.js.map +1 -0
  9. package/dist/generators/rls-generator.d.ts +77 -0
  10. package/dist/generators/rls-generator.d.ts.map +1 -0
  11. package/dist/generators/rls-generator.js +402 -0
  12. package/dist/generators/rls-generator.js.map +1 -0
  13. package/dist/generators/rpc/detail-rpc-generator.d.ts +58 -0
  14. package/dist/generators/rpc/detail-rpc-generator.d.ts.map +1 -0
  15. package/dist/generators/rpc/detail-rpc-generator.js +163 -0
  16. package/dist/generators/rpc/detail-rpc-generator.js.map +1 -0
  17. package/dist/generators/rpc/index.d.ts +24 -0
  18. package/dist/generators/rpc/index.d.ts.map +1 -0
  19. package/dist/generators/rpc/index.js +20 -0
  20. package/dist/generators/rpc/index.js.map +1 -0
  21. package/dist/generators/rpc/rpc-generator.d.ts +150 -0
  22. package/dist/generators/rpc/rpc-generator.d.ts.map +1 -0
  23. package/dist/generators/rpc/rpc-generator.js +743 -0
  24. package/dist/generators/rpc/rpc-generator.js.map +1 -0
  25. package/dist/generators/rpc/templates/array.d.ts +29 -0
  26. package/dist/generators/rpc/templates/array.d.ts.map +1 -0
  27. package/dist/generators/rpc/templates/array.js +40 -0
  28. package/dist/generators/rpc/templates/array.js.map +1 -0
  29. package/dist/generators/rpc/templates/auth.d.ts +85 -0
  30. package/dist/generators/rpc/templates/auth.d.ts.map +1 -0
  31. package/dist/generators/rpc/templates/auth.js +233 -0
  32. package/dist/generators/rpc/templates/auth.js.map +1 -0
  33. package/dist/generators/rpc/templates/column.d.ts +39 -0
  34. package/dist/generators/rpc/templates/column.d.ts.map +1 -0
  35. package/dist/generators/rpc/templates/column.js +97 -0
  36. package/dist/generators/rpc/templates/column.js.map +1 -0
  37. package/dist/generators/rpc/templates/enum.d.ts +33 -0
  38. package/dist/generators/rpc/templates/enum.d.ts.map +1 -0
  39. package/dist/generators/rpc/templates/enum.js +93 -0
  40. package/dist/generators/rpc/templates/enum.js.map +1 -0
  41. package/dist/generators/rpc/templates/nullable.d.ts +31 -0
  42. package/dist/generators/rpc/templates/nullable.d.ts.map +1 -0
  43. package/dist/generators/rpc/templates/nullable.js +50 -0
  44. package/dist/generators/rpc/templates/nullable.js.map +1 -0
  45. package/dist/generators/rpc/templates/related.d.ts +47 -0
  46. package/dist/generators/rpc/templates/related.d.ts.map +1 -0
  47. package/dist/generators/rpc/templates/related.js +182 -0
  48. package/dist/generators/rpc/templates/related.js.map +1 -0
  49. package/dist/generators/rpc/templates/search.d.ts +42 -0
  50. package/dist/generators/rpc/templates/search.d.ts.map +1 -0
  51. package/dist/generators/rpc/templates/search.js +81 -0
  52. package/dist/generators/rpc/templates/search.js.map +1 -0
  53. package/dist/generators/rpc/templates/time.d.ts +44 -0
  54. package/dist/generators/rpc/templates/time.d.ts.map +1 -0
  55. package/dist/generators/rpc/templates/time.js +143 -0
  56. package/dist/generators/rpc/templates/time.js.map +1 -0
  57. package/dist/generators/rpc/utils.d.ts +58 -0
  58. package/dist/generators/rpc/utils.d.ts.map +1 -0
  59. package/dist/generators/rpc/utils.js +92 -0
  60. package/dist/generators/rpc/utils.js.map +1 -0
  61. package/dist/generators/rpc/validator.d.ts +21 -0
  62. package/dist/generators/rpc/validator.d.ts.map +1 -0
  63. package/dist/generators/rpc/validator.js +398 -0
  64. package/dist/generators/rpc/validator.js.map +1 -0
  65. package/dist/index.d.ts +9 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +6 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/shared/auth/index.d.ts +1 -1
  70. package/dist/shared/auth/index.d.ts.map +1 -1
  71. package/dist/shared/auth/routes.d.ts +4 -1
  72. package/dist/shared/auth/routes.d.ts.map +1 -1
  73. package/dist/shared/auth/routes.js +83 -1
  74. package/dist/shared/auth/routes.js.map +1 -1
  75. package/dist/shared/auth/types.d.ts +24 -0
  76. package/dist/shared/auth/types.d.ts.map +1 -1
  77. package/package.json +1 -1
@@ -0,0 +1,743 @@
1
+ /**
2
+ * RPC Generator
3
+ * Auto-generates SQL RPC functions from FeatureConfig
4
+ *
5
+ * This is the core of the SQL Generator system. It takes a FeatureConfig
6
+ * and generates two SQL migration files:
7
+ * 1. get_<table>_results.sql - Results function (paginated, with includes)
8
+ * 2. get_<table>_counts.sql - Counts function (filter breakdowns)
9
+ *
10
+ * Usage:
11
+ * const generator = new RPCGenerator(ordersFeatureConfig);
12
+ * const { resultsSQL, countsSQL } = generator.generate();
13
+ * generator.writeMigrations();
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { generateTimestamp, getTableAlias, escapeIdentifier } from './utils.js';
18
+ import { validateConfig } from './validator.js';
19
+ // Import filter templates
20
+ import { generateEnumWhere, generateEnumCounts } from './templates/enum.js';
21
+ import { generateColumnWhere, generateColumnCounts } from './templates/column.js';
22
+ import { generateNullableWhere, generateNullableCounts } from './templates/nullable.js';
23
+ import { generateRelatedWhere, generateRelatedCounts } from './templates/related.js';
24
+ import { generateTimeWhere, generateTimeCounts } from './templates/time.js';
25
+ import { generateSearchWhere, generateSearchCounts } from './templates/search.js';
26
+ import { generateArrayWhere, generateArrayCounts } from './templates/array.js';
27
+ // Security template (v3.9 → v3.15: Service role + anon blocking + session_user fix)
28
+ import { generateAuthCheck, generateAuthWhereClause, generateAuthDeclarations } from './templates/auth.js';
29
+ export class RPCGenerator {
30
+ config;
31
+ tableName;
32
+ tableAlias;
33
+ aliasMap;
34
+ userIdColumn;
35
+ searchJoins;
36
+ GENERATOR_VERSION = '3.17'; // v3.17: Document public. prefix requirement for customWhereClause/sqlComputedFields (January 16, 2026)
37
+ ENABLE_RPC_INCLUDES = true; // Enabled - LATERAL JOINs for selective includes (pagination limits dataset!)
38
+ constructor(config, options) {
39
+ // Validate config first
40
+ const validation = validateConfig(config);
41
+ if (!validation.valid) {
42
+ throw new Error(`Invalid feature config:\n${this.formatValidationErrors(validation)}`);
43
+ }
44
+ this.config = config;
45
+ this.tableName = config.tableName;
46
+ this.aliasMap = options?.aliasMap;
47
+ this.tableAlias = getTableAlias(this.tableName, this.aliasMap);
48
+ this.userIdColumn = options?.userIdColumn || 'user_id';
49
+ this.searchJoins = options?.searchJoins || {
50
+ user: (alias) => `LEFT JOIN public.users_public u ON u.id = ${alias}.${this.userIdColumn}`,
51
+ order: (alias) => `LEFT JOIN public.orders o ON o.id = ${alias}.order_id`,
52
+ };
53
+ // Log warnings
54
+ if (validation.warnings.length > 0) {
55
+ console.warn('Warning: Validation warnings:');
56
+ validation.warnings.forEach(w => {
57
+ console.warn(` ${w.field}: ${w.message}`);
58
+ if (w.suggestion)
59
+ console.warn(` Suggestion: ${w.suggestion}`);
60
+ });
61
+ }
62
+ }
63
+ /**
64
+ * Format validation errors for display
65
+ */
66
+ formatValidationErrors(validation) {
67
+ const errors = validation.errors.map(e => ` - ${e.field}: ${e.message}${e.suggestion ? `\n Suggestion: ${e.suggestion}` : ''}`);
68
+ return errors.join('\n');
69
+ }
70
+ /**
71
+ * Parse filters from config
72
+ */
73
+ parseFilters() {
74
+ if (!this.config.filters)
75
+ return [];
76
+ return this.config.filters.map(filter => ({
77
+ ...filter,
78
+ paramName: `p_${filter.rpcParam || filter.name}`,
79
+ paramType: this.getParamType(filter)
80
+ }));
81
+ }
82
+ /**
83
+ * Get SQL parameter type for filter
84
+ */
85
+ getParamType(filter) {
86
+ switch (filter.type) {
87
+ case 'multiselect':
88
+ return 'text[]'; // Array for multiselect
89
+ case 'daterange':
90
+ return 'daterange'; // PostgreSQL daterange type
91
+ case 'array':
92
+ // Support custom array type via sqlGeneration.arrayType (e.g., 'text[]' for varchar columns)
93
+ return filter.sqlGeneration?.arrayType || 'uuid[]'; // Default: UUID[] for by-ids navigation
94
+ default:
95
+ return 'text'; // Most filters use text
96
+ }
97
+ }
98
+ /**
99
+ * Generate filter function signature
100
+ */
101
+ generateFilterSignature() {
102
+ const params = [
103
+ `p_org_id uuid`
104
+ ];
105
+ // Add all filter parameters
106
+ this.parseFilters().forEach(filter => {
107
+ params.push(`${filter.paramName} ${filter.paramType} DEFAULT NULL`);
108
+ });
109
+ // Add sort parameter
110
+ params.push(`p_sort_by text DEFAULT 'date-desc'`);
111
+ // Use filterRpcName if specified, otherwise generate from tableName
112
+ if (this.config.filterRpcName) {
113
+ return `${this.config.filterRpcName}(\n ${params.join(',\n ')}\n)`;
114
+ }
115
+ return `filter_${this.config.tableName}_simple(\n ${params.join(',\n ')}\n)`;
116
+ }
117
+ /**
118
+ * Generate org WHERE clause (includes system content where organization_id IS NULL)
119
+ */
120
+ generateOrgWhereClause(alias) {
121
+ const f = `${alias}.${this.config.organizationIdField}`;
122
+ return `(p_org_id IS NULL OR ${f} = p_org_id OR ${f} IS NULL)`;
123
+ }
124
+ /**
125
+ * Generate WHERE clause for a filter
126
+ */
127
+ generateWhereClause(filter) {
128
+ // Check for custom WHERE clause
129
+ if (filter.sqlGeneration?.customWhereClause) {
130
+ return `\n -- ${filter.name} (custom WHERE clause)\n AND ${filter.sqlGeneration.customWhereClause}`;
131
+ }
132
+ // Use template based on filter type
133
+ switch (filter.type) {
134
+ case 'enum':
135
+ return generateEnumWhere(filter, this.tableName, this.aliasMap);
136
+ case 'column':
137
+ return generateColumnWhere(filter, this.tableName, this.aliasMap);
138
+ case 'nullable':
139
+ return generateNullableWhere(filter, this.tableName, this.aliasMap);
140
+ case 'related':
141
+ return generateRelatedWhere(filter, this.tableName, this.aliasMap);
142
+ case 'time':
143
+ return generateTimeWhere(filter, this.tableName, this.aliasMap);
144
+ case 'search':
145
+ return generateSearchWhere(filter, this.tableName, this.aliasMap);
146
+ case 'array':
147
+ return generateArrayWhere(filter, this.tableName, this.aliasMap);
148
+ default:
149
+ throw new Error(`Unsupported filter type: ${filter.type}`);
150
+ }
151
+ }
152
+ /**
153
+ * Generate all WHERE clauses
154
+ */
155
+ generateWhereClauses() {
156
+ const filters = this.parseFilters();
157
+ if (filters.length === 0) {
158
+ return '';
159
+ }
160
+ return filters.map(filter => this.generateWhereClause(filter)).join('\n');
161
+ }
162
+ /**
163
+ * Generate ORDER BY clause
164
+ */
165
+ generateOrderBy() {
166
+ const alias = this.tableAlias;
167
+ const defaultSortField = this.config.defaultSortField || 'created_at';
168
+ const allowedSortFields = this.config.allowedSortFields || [defaultSortField];
169
+ // Use sortFields config to find the field for 'name' alias
170
+ let nameField = null;
171
+ let hasNameSort = false;
172
+ if (this.config.sortFields) {
173
+ // Find the sortField with alias: 'name'
174
+ const nameSortConfig = this.config.sortFields.find(sf => sf.alias === 'name');
175
+ if (nameSortConfig) {
176
+ nameField = nameSortConfig.field;
177
+ hasNameSort = true;
178
+ }
179
+ }
180
+ // If no sortFields config, fall back to old logic (auto-detect name field)
181
+ if (!this.config.sortFields) {
182
+ const detectedNameField = allowedSortFields.find(f => ['firstname', 'lastname', 'name', 'title', 'original_filename', 'filename'].includes(f.toLowerCase()));
183
+ if (detectedNameField) {
184
+ nameField = detectedNameField;
185
+ hasNameSort = true;
186
+ }
187
+ }
188
+ // Build CASE statements for each sort option
189
+ let orderBy = `
190
+ ORDER BY
191
+ CASE WHEN p_sort_by = 'date-desc' THEN ${alias}.${escapeIdentifier(defaultSortField)} END DESC NULLS LAST,
192
+ CASE WHEN p_sort_by = 'date-asc' THEN ${alias}.${escapeIdentifier(defaultSortField)} END ASC NULLS LAST,`;
193
+ // Only add name sorting if we have a valid name field
194
+ if (hasNameSort && nameField) {
195
+ const nameSortExpr = `LOWER(${alias}.${escapeIdentifier(nameField)})`;
196
+ orderBy += `
197
+ CASE WHEN p_sort_by = 'name-asc' THEN ${nameSortExpr} END ASC NULLS LAST,
198
+ CASE WHEN p_sort_by = 'name-desc' THEN ${nameSortExpr} END DESC NULLS LAST,`;
199
+ }
200
+ orderBy += `
201
+ ${alias}.${escapeIdentifier(defaultSortField)} DESC -- Fallback`;
202
+ return orderBy;
203
+ }
204
+ /**
205
+ * Detect required JOINs from searchFields
206
+ * v3.11: Add public. prefix for Postgres v17 compatibility
207
+ * v3.14: Also check filter-level searchFields (filter.sqlGeneration.searchFields)
208
+ */
209
+ detectJoins() {
210
+ // Collect searchFields from both root config and filter configs
211
+ const allSearchFields = [...(this.config.searchFields || [])];
212
+ // v3.14: Also check filter-level searchFields
213
+ if (this.config.filters) {
214
+ this.config.filters.forEach(filter => {
215
+ if (filter.type === 'search' && filter.sqlGeneration?.searchFields) {
216
+ allSearchFields.push(...filter.sqlGeneration.searchFields);
217
+ }
218
+ });
219
+ }
220
+ const joinSet = new Set();
221
+ allSearchFields.forEach(field => {
222
+ if (field.includes('.')) {
223
+ const [relation] = field.split('.');
224
+ const joinFn = this.searchJoins[relation];
225
+ if (joinFn) {
226
+ joinSet.add(joinFn(this.tableAlias));
227
+ }
228
+ }
229
+ });
230
+ return joinSet.size > 0 ? '\n ' + Array.from(joinSet).join('\n ') : '';
231
+ }
232
+ /**
233
+ * Generate complete filter function SQL
234
+ */
235
+ generateFilterFunction() {
236
+ const signature = this.generateFilterSignature();
237
+ const whereClauses = this.generateWhereClauses();
238
+ const orderBy = this.generateOrderBy();
239
+ const alias = this.tableAlias;
240
+ const joins = this.detectJoins();
241
+ // v3.11: Add public. prefix for Postgres v17 compatibility (empty search_path)
242
+ return `-- ============================================================================
243
+ -- Filter function for ${this.tableName}
244
+ -- ============================================================================
245
+ -- DO NOT EDIT MANUALLY! This file is auto-generated.
246
+ -- Changes will be OVERWRITTEN on next generation.
247
+ --
248
+ -- To modify this function:
249
+ -- 1. Edit config: /backend/src/features/${this.tableName}/config/${this.tableName}.config.ts
250
+ -- 2. Regenerate: npm run generate:rpc ${this.tableName}
251
+ -- 3. Apply: Use MCP apply_migration or supabase db push
252
+ --
253
+ -- Generator: SQL Generator v${this.GENERATOR_VERSION}
254
+ -- Generated: ${new Date().toISOString()}
255
+ -- ============================================================================
256
+
257
+ DROP FUNCTION IF EXISTS public.${signature.split('(')[0]};
258
+
259
+ CREATE OR REPLACE FUNCTION public.${signature}
260
+ RETURNS uuid[]
261
+ LANGUAGE plpgsql
262
+ STABLE
263
+ AS $$
264
+ BEGIN
265
+ RETURN ARRAY(
266
+ SELECT ${alias}.id
267
+ FROM public.${this.tableName} ${alias}${joins}
268
+ WHERE ${this.generateOrgWhereClause(alias)}${whereClauses}
269
+ ${orderBy}
270
+ );
271
+ END;
272
+ $$;
273
+ `;
274
+ }
275
+ /**
276
+ * Generate counts query for a filter
277
+ */
278
+ generateCountsQuery(filter) {
279
+ // Check for custom counts query
280
+ if (filter.sqlGeneration?.customCountsQuery) {
281
+ return filter.sqlGeneration.customCountsQuery;
282
+ }
283
+ // Search and array filters don't have counts
284
+ if (filter.type === 'search') {
285
+ return generateSearchCounts(filter);
286
+ }
287
+ if (filter.type === 'array') {
288
+ // Array filters (like 'ids') don't generate counts
289
+ return generateArrayCounts(filter) || '';
290
+ }
291
+ // Use template based on filter type
292
+ switch (filter.type) {
293
+ case 'enum':
294
+ return generateEnumCounts(filter);
295
+ case 'column':
296
+ return generateColumnCounts(filter);
297
+ case 'nullable':
298
+ return generateNullableCounts(filter);
299
+ case 'related':
300
+ return generateRelatedCounts(filter, this.tableName, this.aliasMap);
301
+ case 'time':
302
+ return generateTimeCounts(filter);
303
+ default:
304
+ return ''; // No counts for unsupported types
305
+ }
306
+ }
307
+ /**
308
+ * Generate all counts queries
309
+ */
310
+ generateCountsQueries() {
311
+ const filters = this.parseFilters();
312
+ const countsQueries = filters
313
+ .map(filter => this.generateCountsQuery(filter))
314
+ .filter(query => query !== '') // Remove empty queries (search filters)
315
+ .join(',\n');
316
+ return countsQueries;
317
+ }
318
+ /**
319
+ * Generate counts function signature
320
+ */
321
+ generateCountsSignature() {
322
+ const params = [
323
+ `p_org_id uuid DEFAULT NULL` // Optional for system-wide operations
324
+ ];
325
+ // Add all filter parameters (same as filter/results function)
326
+ this.parseFilters().forEach(filter => {
327
+ params.push(`${filter.paramName} ${filter.paramType} DEFAULT NULL`);
328
+ });
329
+ // NOTE: p_sort_by is NOT added to counts - counts don't need sorting!
330
+ // Only results function has p_sort_by parameter
331
+ // Use countsRpcName if specified, otherwise generate from tableName
332
+ const funcName = this.config.countsRpcName || `get_${this.config.tableName}_counts`;
333
+ return `${funcName}(\n ${params.join(',\n ')}\n)`;
334
+ }
335
+ /**
336
+ * Generate complete counts function SQL
337
+ */
338
+ generateCountsFunction() {
339
+ const signature = this.generateCountsSignature();
340
+ const whereClauses = this.generateWhereClauses();
341
+ const countsQueries = this.generateCountsQueries();
342
+ const alias = this.tableAlias;
343
+ const joins = this.detectJoins();
344
+ // v3.9 → v3.10: Generate auth check and WHERE clause based on accessLevel config
345
+ const accessLevel = (this.config.accessLevel || 'admin');
346
+ const authCheck = generateAuthCheck(accessLevel);
347
+ // v3.10: Generate auth WHERE clause for creator visibility filtering
348
+ // v3.11: Pass organizationIdField from config
349
+ const creatorVisibility = this.config.creatorVisibility;
350
+ const organizationIdField = this.config.organizationIdField || 'organization_id';
351
+ const authWhereClause = generateAuthWhereClause(accessLevel, alias, creatorVisibility, organizationIdField);
352
+ const securityNote = accessLevel === 'public' || accessLevel === 'system'
353
+ ? `-- Access Level: ${accessLevel} (no auth check)`
354
+ : accessLevel === 'creator' && creatorVisibility
355
+ ? `-- Access Level: ${accessLevel} (auth check + visibility: ${creatorVisibility.column})`
356
+ : `-- Access Level: ${accessLevel} (auth check enabled)`;
357
+ // Include joined table columns in CTE if we have joins
358
+ const selectClause = joins.includes('orders')
359
+ ? `${alias}.*, o.status as order_status, o.source as order_source`
360
+ : `${alias}.*`;
361
+ return `-- ============================================================================
362
+ -- Counts function for ${this.tableName}
363
+ -- ============================================================================
364
+ -- DO NOT EDIT MANUALLY! This file is auto-generated.
365
+ -- Changes will be OVERWRITTEN on next generation.
366
+ --
367
+ -- To modify this function:
368
+ -- 1. Edit config: /backend/src/features/${this.tableName}/config/${this.tableName}.config.ts
369
+ -- 2. Regenerate: npm run generate:rpc ${this.tableName}
370
+ -- 3. Apply: Use MCP apply_migration or supabase db push
371
+ --
372
+ -- Generator: SQL Generator v${this.GENERATOR_VERSION}
373
+ -- Generated: ${new Date().toISOString()}
374
+ -- ${securityNote}
375
+ -- ============================================================================
376
+
377
+ DROP FUNCTION IF EXISTS public.${signature.split('(')[0]}_simple;
378
+ DROP FUNCTION IF EXISTS public.${signature.split('(')[0]};
379
+
380
+ CREATE OR REPLACE FUNCTION public.${signature}
381
+ RETURNS jsonb
382
+ LANGUAGE plpgsql
383
+ STABLE
384
+ SECURITY DEFINER
385
+ SET search_path = ''
386
+ AS $$
387
+ DECLARE
388
+ result jsonb;${generateAuthDeclarations(accessLevel)}
389
+ BEGIN
390
+ ${authCheck}
391
+ -- Create filtered CTE for consistent counts
392
+ -- v3.11: Add public. prefix for Postgres v17 compatibility
393
+ WITH filtered_items AS (
394
+ SELECT ${selectClause}
395
+ FROM public.${this.tableName} ${alias}${joins}
396
+ WHERE ${this.generateOrgWhereClause(alias)}${whereClauses}
397
+ ${authWhereClause}
398
+ )
399
+ SELECT jsonb_build_object(
400
+ 'total', (SELECT COUNT(*)::int FROM filtered_items),${countsQueries}
401
+ ) INTO result;
402
+
403
+ RETURN result;
404
+ END;
405
+ $$;
406
+ `;
407
+ }
408
+ /**
409
+ * Generate JSONB aggregates for includes (v3.5)
410
+ * Returns LATERAL JOIN subqueries that aggregate related records as JSONB arrays
411
+ */
412
+ generateIncludeJoins() {
413
+ if (!this.ENABLE_RPC_INCLUDES) {
414
+ return '';
415
+ }
416
+ if (!this.config.includeMapping || Object.keys(this.config.includeMapping).length === 0) {
417
+ return '';
418
+ }
419
+ const joins = [];
420
+ const alias = this.tableAlias;
421
+ for (const [includeName, includeConfig] of Object.entries(this.config.includeMapping)) {
422
+ const includeAlias = includeName.substring(0, 3); // e.g., 'orders' → 'ord'
423
+ const fields = includeConfig.fields.map(f => `'${f}', ${includeAlias}.${f}`).join(', ');
424
+ // Check if this is a many-to-many relationship via junction table
425
+ // v3.11: Add public. prefix for Postgres v17 compatibility
426
+ if (includeConfig.junctionTable && includeConfig.junctionForeignKey && includeConfig.junctionTargetKey) {
427
+ // M:N relationship via junction table
428
+ const junctionAlias = 'j_' + includeAlias;
429
+ joins.push(`
430
+ LEFT JOIN LATERAL (
431
+ SELECT jsonb_agg(to_jsonb(${includeAlias}.*)) as ${includeName}_data
432
+ FROM public.${includeConfig.junctionTable} ${junctionAlias}
433
+ JOIN public.${includeConfig.tableName} ${includeAlias} ON ${includeAlias}.id = ${junctionAlias}.${includeConfig.junctionTargetKey}
434
+ WHERE ${junctionAlias}.${includeConfig.junctionForeignKey} = ${alias}.id
435
+ ) ${includeName}_agg ON true`);
436
+ continue;
437
+ }
438
+ // Get foreign key column - use explicit column if provided, otherwise auto-detect
439
+ const foreignKeyColumn = includeConfig.column || this.getForeignKeyColumn(includeConfig.foreignKey || '');
440
+ // Detect join direction: is this table referencing us (1:N) or are we referencing it (N:1)?
441
+ const isOneToMany = (includeConfig.foreignKey || '').includes(`${includeConfig.tableName}_`);
442
+ if (isOneToMany) {
443
+ // 1:N relationship
444
+ // Check for nested includes (v3.8, enhanced v3.9)
445
+ if (includeConfig.nested && Object.keys(includeConfig.nested).length > 0) {
446
+ // Build nested JOINs and fields
447
+ const nestedJoins = [];
448
+ const nestedFields = [];
449
+ let aliasCounter = 0;
450
+ for (const [nestedName, nestedConfig] of Object.entries(includeConfig.nested)) {
451
+ const nestedAlias = `n${aliasCounter++}`;
452
+ const nestedFKColumn = nestedConfig.column || this.getForeignKeyColumn(nestedConfig.foreignKey || '');
453
+ // v3.9: Use outputName if specified, otherwise use nestedName
454
+ const outputName = nestedConfig.outputName || nestedName;
455
+ // Add LEFT JOIN for nested relation (v3.11: public. prefix)
456
+ nestedJoins.push(`LEFT JOIN public.${nestedConfig.tableName} ${nestedAlias} ON ${nestedAlias}.id = ${includeAlias}.${nestedFKColumn}`);
457
+ // v3.9: Check for deepNested
458
+ const deepNested = nestedConfig.deepNested;
459
+ if (deepNested && Object.keys(deepNested).length > 0) {
460
+ // Build deep nested JOINs and CASE expression
461
+ const deepNestedParts = [];
462
+ for (const [deepName, deepConfig] of Object.entries(deepNested)) {
463
+ const deepAlias = `d${aliasCounter++}`;
464
+ const deepOutputName = deepConfig.outputName || deepName;
465
+ // Add deep JOIN (v3.11: public. prefix)
466
+ nestedJoins.push(`LEFT JOIN public.${deepConfig.tableName} ${deepAlias} ON ${deepAlias}.id = ${nestedAlias}.${deepConfig.joinColumn}`);
467
+ // Build deep nested object
468
+ const deepFields = deepConfig.fields.map((f) => `'${f}', ${deepAlias}.${f}`).join(', ');
469
+ deepNestedParts.push(`'${deepOutputName}', CASE WHEN ${deepAlias}.id IS NOT NULL THEN jsonb_build_object(${deepFields}) ELSE NULL END`);
470
+ }
471
+ // Build the nested object with deep nested data
472
+ const nestedFieldsList = nestedConfig.fields.map(f => `'${f}', ${nestedAlias}.${f}`).join(', ');
473
+ nestedFields.push(`'${outputName}', CASE WHEN ${nestedAlias}.id IS NOT NULL THEN jsonb_build_object(${nestedFieldsList}, ${deepNestedParts.join(', ')}) ELSE NULL END`);
474
+ }
475
+ else {
476
+ // Simple nested object (no deep nesting)
477
+ const nestedFieldsList = nestedConfig.fields.map(f => `'${f}', ${nestedAlias}.${f}`).join(', ');
478
+ nestedFields.push(`'${outputName}', CASE WHEN ${nestedAlias}.id IS NOT NULL THEN jsonb_build_object(${nestedFieldsList}) ELSE NULL END`);
479
+ }
480
+ }
481
+ // Combine base fields with nested fields
482
+ const allFields = `${fields}, ${nestedFields.join(', ')}`;
483
+ // v3.12: Use orderBy from config if specified
484
+ const orderByClause = includeConfig.orderBy
485
+ ? ` ORDER BY ${includeAlias}.${includeConfig.orderBy}`
486
+ : '';
487
+ // v3.11: Add public. prefix for Postgres v17 compatibility
488
+ joins.push(`
489
+ LEFT JOIN LATERAL (
490
+ SELECT COALESCE(jsonb_agg(jsonb_build_object(${allFields})${orderByClause}), '[]'::jsonb) as ${includeName}_data
491
+ FROM public.${includeConfig.tableName} ${includeAlias}
492
+ ${nestedJoins.join('\n ')}
493
+ WHERE ${includeAlias}.${foreignKeyColumn} = ${alias}.id
494
+ ) ${includeName}_agg ON true`);
495
+ }
496
+ else {
497
+ // Simple 1:N without nesting (v3.11: public. prefix)
498
+ joins.push(`
499
+ LEFT JOIN LATERAL (
500
+ SELECT COALESCE(jsonb_agg(jsonb_build_object(${fields})), '[]'::jsonb) as ${includeName}_data
501
+ FROM public.${includeConfig.tableName} ${includeAlias}
502
+ WHERE ${includeAlias}.${foreignKeyColumn} = ${alias}.id
503
+ ) ${includeName}_agg ON true`);
504
+ }
505
+ }
506
+ else {
507
+ // N:1 relationship
508
+ // v3.11: Add public. prefix for Postgres v17 compatibility
509
+ joins.push(`
510
+ LEFT JOIN LATERAL (
511
+ SELECT to_jsonb(${includeAlias}.*) as ${includeName}_data
512
+ FROM public.${includeConfig.tableName} ${includeAlias}
513
+ WHERE ${includeAlias}.id = ${alias}.${foreignKeyColumn}
514
+ ) ${includeName}_agg ON true`);
515
+ }
516
+ }
517
+ return joins.join('');
518
+ }
519
+ /**
520
+ * Extract column name from foreign key constraint name
521
+ * Example: 'orders_user_id_fkey' → 'user_id'
522
+ * 'users_public_active_organization_id_fkey' → 'active_organization_id'
523
+ */
524
+ getForeignKeyColumn(foreignKeyName) {
525
+ // Remove _fkey suffix first
526
+ let column = foreignKeyName.replace(/_fkey$/, '');
527
+ // Try removing related table name prefix
528
+ for (const [_includeName, includeConfig] of Object.entries(this.config.includeMapping || {})) {
529
+ const tablePrefix = includeConfig.tableName + '_';
530
+ if (column.startsWith(tablePrefix)) {
531
+ column = column.substring(tablePrefix.length);
532
+ return column;
533
+ }
534
+ }
535
+ // Try removing our table name prefix
536
+ const ourTablePrefix = this.config.tableName + '_';
537
+ if (column.startsWith(ourTablePrefix)) {
538
+ column = column.substring(ourTablePrefix.length);
539
+ }
540
+ return column;
541
+ }
542
+ /**
543
+ * Generate SELECT columns with includes as JSONB fields (v3.5)
544
+ */
545
+ generateIncludesCases() {
546
+ if (!this.ENABLE_RPC_INCLUDES) {
547
+ return '';
548
+ }
549
+ if (!this.config.includeMapping || Object.keys(this.config.includeMapping).length === 0) {
550
+ return '';
551
+ }
552
+ const cases = [];
553
+ for (const includeName of Object.keys(this.config.includeMapping)) {
554
+ cases.push(`${includeName}_agg.${includeName}_data as ${includeName}`);
555
+ }
556
+ return cases.join(',\n ');
557
+ }
558
+ /**
559
+ * Generate all table columns for SELECT clause
560
+ */
561
+ generateTableColumns() {
562
+ return `${this.tableAlias}.*`;
563
+ }
564
+ /**
565
+ * Generate SQL computed fields (v3.8)
566
+ * Returns computed field expressions for SELECT clause
567
+ */
568
+ generateSQLComputedFields() {
569
+ if (!this.config.sqlComputedFields || this.config.sqlComputedFields.length === 0) {
570
+ return '';
571
+ }
572
+ const fields = this.config.sqlComputedFields.map(field => {
573
+ // Replace {alias} placeholder with actual table alias
574
+ const sql = field.sql.replace(/\{alias\}/g, this.tableAlias);
575
+ return `${sql} as ${field.name}`;
576
+ });
577
+ return fields.join(',\n ');
578
+ }
579
+ /**
580
+ * Generate results function signature
581
+ */
582
+ generateResultsSignature() {
583
+ const params = [
584
+ `p_org_id uuid DEFAULT NULL` // Optional for system-wide cron jobs
585
+ ];
586
+ // Add all filter parameters
587
+ this.parseFilters().forEach(filter => {
588
+ params.push(`${filter.paramName} ${filter.paramType} DEFAULT NULL`);
589
+ });
590
+ // Add pagination parameters
591
+ params.push(`p_limit int DEFAULT 20`);
592
+ params.push(`p_offset int DEFAULT 0`);
593
+ params.push(`p_sort_by text DEFAULT 'date-desc'`);
594
+ const funcName = this.config.resultsRpcName || `get_${this.config.tableName}_results`;
595
+ return `${funcName}(\n ${params.join(',\n ')}\n)`;
596
+ }
597
+ /**
598
+ * Generate complete results function SQL (NEW - replaces filter + .in pattern!)
599
+ */
600
+ generateResultsFunction() {
601
+ const signature = this.generateResultsSignature();
602
+ const whereClauses = this.generateWhereClauses();
603
+ const orderBy = this.generateOrderBy();
604
+ const alias = this.tableAlias;
605
+ const searchJoins = this.detectJoins(); // JOINs for search
606
+ const includeJoins = this.generateIncludeJoins(); // JOINs for includes
607
+ const allJoins = searchJoins + includeJoins;
608
+ const tableColumns = this.generateTableColumns();
609
+ const includesCases = this.generateIncludesCases();
610
+ const computedFields = this.generateSQLComputedFields(); // v3.8: SQL computed fields
611
+ // v3.9 → v3.10: Generate auth check and WHERE clause based on accessLevel config
612
+ const accessLevel = (this.config.accessLevel || 'admin');
613
+ const authCheck = generateAuthCheck(accessLevel);
614
+ // v3.10: Generate auth WHERE clause for creator visibility filtering
615
+ const creatorVisibility = this.config.creatorVisibility;
616
+ const organizationIdField = this.config.organizationIdField || 'organization_id';
617
+ const authWhereClause = generateAuthWhereClause(accessLevel, alias, creatorVisibility, organizationIdField);
618
+ const securityNote = accessLevel === 'public' || accessLevel === 'system'
619
+ ? `-- Access Level: ${accessLevel} (no auth check)`
620
+ : accessLevel === 'creator' && creatorVisibility
621
+ ? `-- Access Level: ${accessLevel} (auth check + visibility: ${creatorVisibility.column})`
622
+ : `-- Access Level: ${accessLevel} (auth check enabled)`;
623
+ // Build SELECT clause with all parts
624
+ const selectParts = [tableColumns];
625
+ if (computedFields)
626
+ selectParts.push(computedFields);
627
+ if (includesCases)
628
+ selectParts.push(includesCases);
629
+ selectParts.push('COUNT(*) OVER() as total_count');
630
+ const selectClause = selectParts.join(',\n ');
631
+ return `-- ============================================================================
632
+ -- Results function for ${this.tableName}
633
+ -- ============================================================================
634
+ -- DO NOT EDIT MANUALLY! This file is auto-generated.
635
+ -- Changes will be OVERWRITTEN on next generation.
636
+ --
637
+ -- To modify this function:
638
+ -- 1. Edit config: /backend/src/features/${this.tableName}/config/${this.tableName}.config.ts
639
+ -- 2. Regenerate: npm run generate:rpc ${this.tableName}
640
+ -- 3. Apply: Use MCP apply_migration or supabase db push
641
+ --
642
+ -- Generator: SQL Generator v${this.GENERATOR_VERSION} (Window Function Pattern with JSONB Includes)
643
+ -- Generated: ${new Date().toISOString()}
644
+ --
645
+ -- Pattern: get_${this.tableName}_results() -> Returns full records + includes + total_count
646
+ -- ${securityNote}
647
+ -- ============================================================================
648
+
649
+ DROP FUNCTION IF EXISTS public.${signature.split('(')[0]};
650
+
651
+ CREATE OR REPLACE FUNCTION public.${signature}
652
+ RETURNS TABLE (
653
+ data jsonb,
654
+ total_count bigint
655
+ )
656
+ LANGUAGE plpgsql
657
+ STABLE
658
+ SECURITY DEFINER
659
+ SET search_path = ''
660
+ AS $$${accessLevel !== 'public' && accessLevel !== 'system' ? `
661
+ DECLARE${generateAuthDeclarations(accessLevel)}` : ''}
662
+ BEGIN
663
+ ${authCheck}
664
+ RETURN QUERY
665
+ -- v3.11: Add public. prefix for Postgres v17 compatibility
666
+ WITH results AS (
667
+ SELECT
668
+ ${selectClause}
669
+ FROM public.${this.tableName} ${alias}${allJoins}
670
+ WHERE ${this.generateOrgWhereClause(alias)}${whereClauses}
671
+ ${authWhereClause}
672
+ ${orderBy}
673
+ LIMIT p_limit
674
+ OFFSET p_offset
675
+ )
676
+ SELECT
677
+ to_jsonb(results.*) - 'total_count' as data,
678
+ results.total_count
679
+ FROM results;
680
+ END;
681
+ $$;
682
+ `;
683
+ }
684
+ /**
685
+ * Generate both results and counts functions (NEW API)
686
+ */
687
+ generate() {
688
+ return {
689
+ resultsSQL: this.generateResultsFunction(),
690
+ countsSQL: this.generateCountsFunction(),
691
+ filterSQL: this.generateFilterFunction() // For backwards compatibility
692
+ };
693
+ }
694
+ /**
695
+ * Write migrations to files (NEW - generates results + counts)
696
+ */
697
+ writeMigrations(options = {}) {
698
+ const { resultsSQL, countsSQL, filterSQL } = this.generate();
699
+ // Determine output directory
700
+ const outputDir = options.outputDir || path.join(process.cwd(), 'supabase', 'migrations');
701
+ // Ensure output directory exists
702
+ if (!fs.existsSync(outputDir)) {
703
+ fs.mkdirSync(outputDir, { recursive: true });
704
+ }
705
+ // Generate timestamps (1 second apart to ensure unique)
706
+ const resultsTimestamp = generateTimestamp(0);
707
+ const countsTimestamp = generateTimestamp(1);
708
+ // Generate filenames (use config names if provided, otherwise fallback to tableName)
709
+ const resultsRpcName = this.config.resultsRpcName || `get_${this.config.tableName}_results`;
710
+ const countsRpcName = this.config.countsRpcName || `get_${this.config.tableName}_counts`;
711
+ const resultsFile = path.join(outputDir, `${resultsTimestamp}_${resultsRpcName}.sql`);
712
+ const countsFile = path.join(outputDir, `${countsTimestamp}_${countsRpcName}.sql`);
713
+ // Check if files exist (unless force is true)
714
+ if (!options.force) {
715
+ if (fs.existsSync(resultsFile)) {
716
+ throw new Error(`Results migration already exists: ${resultsFile}\nUse --force to overwrite`);
717
+ }
718
+ if (fs.existsSync(countsFile)) {
719
+ throw new Error(`Counts migration already exists: ${countsFile}\nUse --force to overwrite`);
720
+ }
721
+ }
722
+ // Write files
723
+ fs.writeFileSync(resultsFile, resultsSQL, 'utf-8');
724
+ fs.writeFileSync(countsFile, countsSQL, 'utf-8');
725
+ const result = {
726
+ resultsFile,
727
+ countsFile
728
+ };
729
+ // Optionally write legacy filter function for backwards compatibility
730
+ if (options.legacy && filterSQL) {
731
+ const filterTimestamp = generateTimestamp(2);
732
+ const filterRpcName = `filter_${this.config.tableName}_simple`;
733
+ const filterFile = path.join(outputDir, `${filterTimestamp}_${filterRpcName}.sql`);
734
+ if (!options.force && fs.existsSync(filterFile)) {
735
+ throw new Error(`Filter migration already exists: ${filterFile}\nUse --force to overwrite`);
736
+ }
737
+ fs.writeFileSync(filterFile, filterSQL, 'utf-8');
738
+ result.filterFile = filterFile;
739
+ }
740
+ return result;
741
+ }
742
+ }
743
+ //# sourceMappingURL=rpc-generator.js.map