@everystack/admin 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Declarative admin dashboard framework for PostgREST APIs",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { sql, type SQL } from 'drizzle-orm';
18
+ import { toCamelCaseKeys } from '../utils/naming';
18
19
  import type { ResourceDashboardConfig, DimensionConfig, StatDialConfig } from '../types';
19
20
 
20
21
  interface DbExecutor {
@@ -413,6 +414,61 @@ function createEventListHandler(
413
414
  };
414
415
  }
415
416
 
417
+ // ────────────────────────────────────────────────────────────
418
+ // Event Detail RPC
419
+ //
420
+ // The show view's data source for detailVia: 'rpc' resources — one row
421
+ // by primary key, the static where still applied (an STI discriminator
422
+ // must gate detail exactly like it gates the list), keys camelCased to
423
+ // match field definitions. Keeps telemetry off the REST surface entirely.
424
+ // ────────────────────────────────────────────────────────────
425
+
426
+ const MAX_ID_LENGTH = 256;
427
+
428
+ function createEventDetailHandler(
429
+ tableName: string,
430
+ config: ResourceDashboardConfig,
431
+ db: DbExecutor,
432
+ ): (body: unknown) => Promise<unknown> {
433
+ const pkColumn = Array.isArray(config.primaryKey)
434
+ ? config.primaryKey[0]
435
+ : config.primaryKey ?? 'id';
436
+
437
+ return async (body: unknown) => {
438
+ const id = (body as { id?: unknown } | null)?.id;
439
+ if (
440
+ (typeof id !== 'string' && typeof id !== 'number') ||
441
+ String(id).length === 0 ||
442
+ String(id).length > MAX_ID_LENGTH
443
+ ) {
444
+ throw new Error('Invalid id');
445
+ }
446
+
447
+ const conditions: SQL[] = [sql`${sql.raw(pkColumn)} = ${String(id)}`];
448
+ for (const predicate of config.where ?? []) {
449
+ conditions.push(sql`${sql.raw(predicate.column)} = ${predicate.equals}`);
450
+ }
451
+ let where = conditions[0];
452
+ for (let i = 1; i < conditions.length; i++) {
453
+ where = sql`${where} AND ${conditions[i]}`;
454
+ }
455
+
456
+ const result = await db.execute(sql`
457
+ SELECT row_to_json(r) AS row
458
+ FROM (SELECT * FROM ${sql.raw(tableName)} WHERE ${where} LIMIT 1) r
459
+ `);
460
+
461
+ const rows = extractRows(result);
462
+ const first = rows[0] as { row: Record<string, unknown> | string } | undefined;
463
+ if (!first) {
464
+ throw new Error(`Record not found: ${id}`);
465
+ }
466
+
467
+ const raw = typeof first.row === 'string' ? JSON.parse(first.row) : first.row;
468
+ return toCamelCaseKeys(raw);
469
+ };
470
+ }
471
+
416
472
  // ────────────────────────────────────────────────────────────
417
473
  // Public API
418
474
  // ────────────────────────────────────────────────────────────
@@ -420,6 +476,7 @@ function createEventListHandler(
420
476
  export interface ResourceDashboardRpcs {
421
477
  dashboardData: (body: unknown) => Promise<unknown>;
422
478
  eventList: (body: unknown) => Promise<unknown>;
479
+ eventDetail: (body: unknown) => Promise<unknown>;
423
480
  }
424
481
 
425
482
  /**
@@ -438,6 +495,7 @@ export function createResourceDashboardRpcs(
438
495
  return {
439
496
  dashboardData: createDashboardDataHandler(tableName, config, db),
440
497
  eventList: createEventListHandler(tableName, config, db),
498
+ eventDetail: createEventDetailHandler(tableName, config, db),
441
499
  };
442
500
  }
443
501
 
@@ -464,6 +522,7 @@ export function createAllDashboardRpcs(
464
522
  const prefix = resource.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
465
523
  rpcs[`${prefix}DashboardData`] = handlers.dashboardData;
466
524
  rpcs[`${prefix}EventList`] = handlers.eventList;
525
+ rpcs[`${prefix}EventDetail`] = handlers.eventDetail;
467
526
  }
468
527
 
469
528
  return rpcs;
@@ -12,7 +12,8 @@ import { useDataAdapter } from '../hooks/useAdminData';
12
12
  import { LoadingState, ErrorState, Button, Card, Divider } from '@everystack/ui';
13
13
  import { ImageDisplay } from '../components/ImageDisplay';
14
14
  import { visibleRowActions } from '../utils/row-actions';
15
- import type { FieldDefinition, DataRecord, RowActionConfig } from '../types';
15
+ import { toCamelCase } from '../utils/naming';
16
+ import type { FieldDefinition, DataRecord, ResourceConfig, RowActionConfig } from '../types';
16
17
  import type { DataAdapter } from '../adapters/types';
17
18
 
18
19
  export interface ResourceShowProps {
@@ -47,14 +48,13 @@ export function ResourceShow({
47
48
  setLoading(true);
48
49
  setError(null);
49
50
  try {
50
- const result = await adapter.fetchOne(resource, id);
51
- setRecord(result);
51
+ setRecord(await fetchDetail(adapter, resource, id, resourceConfig));
52
52
  } catch (err) {
53
53
  setError(err instanceof Error ? err : new Error(String(err)));
54
54
  } finally {
55
55
  setLoading(false);
56
56
  }
57
- }, [adapter, resource, id]);
57
+ }, [adapter, resource, id, resourceConfig]);
58
58
 
59
59
  useEffect(() => {
60
60
  fetchRecord();
@@ -132,6 +132,30 @@ export function ResourceShow({
132
132
  );
133
133
  }
134
134
 
135
+ // =============================================================================
136
+ // Detail fetching
137
+ // =============================================================================
138
+
139
+ /**
140
+ * detailVia: 'rpc' fetches through the role-gated {prefix}EventDetail RPC —
141
+ * the resource never enters the REST surface (telemetry tables stay out of
142
+ * exposedTables). Everything else uses adapter.fetchOne as before.
143
+ */
144
+ async function fetchDetail(
145
+ adapter: DataAdapter,
146
+ resource: string,
147
+ id: string | number,
148
+ resourceConfig: ResourceConfig | undefined,
149
+ ): Promise<DataRecord> {
150
+ if (resourceConfig?.dashboard?.detailVia === 'rpc') {
151
+ if (!adapter.rpc) {
152
+ throw new Error(`Resource "${resource}" uses detailVia: 'rpc' but the adapter has no rpc()`);
153
+ }
154
+ return (await adapter.rpc(`${toCamelCase(resource)}EventDetail`, { id })) as DataRecord;
155
+ }
156
+ return adapter.fetchOne(resource, id);
157
+ }
158
+
135
159
  // =============================================================================
136
160
  // RowActionButton
137
161
  // =============================================================================
package/src/types.ts CHANGED
@@ -195,6 +195,12 @@ export interface ResourceDashboardConfig {
195
195
  searchColumns?: string[];
196
196
  /** Navigate to detail on row press (default: true) */
197
197
  linkToDetail?: boolean;
198
+ /**
199
+ * How the show view fetches the record. 'rpc' uses the role-gated
200
+ * {prefix}EventDetail RPC — the whole resource stays off the REST
201
+ * surface (telemetry tables never enter exposedTables). Default 'rest'.
202
+ */
203
+ detailVia?: 'rpc' | 'rest';
198
204
  }
199
205
 
200
206
  // =============================================================================