@0xobelisk/graphql-server 1.2.0-pre.118 → 1.2.0-pre.120

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.
@@ -13,34 +13,133 @@ export interface DynamicTable {
13
13
  fields: TableField[];
14
14
  }
15
15
 
16
+ /**
17
+ * Maps system table name → store_dubhe_* view name.
18
+ * Views are created with store_dubhe_ prefix so PostGraphile discovers them
19
+ * automatically via the store_* scan, and SimpleNamingPlugin strips `store`
20
+ * to produce dubhe-prefixed GraphQL field names (e.g. dubheMarketplaceListings).
21
+ * The dubhe_ segment guarantees no collision with user-defined store_* tables.
22
+ */
23
+ const SYSTEM_TABLE_VIEWS: Record<string, string> = {
24
+ marketplace_listings: 'store_dubhe_marketplace_listings',
25
+ sessions: 'store_dubhe_sessions',
26
+ user_storages: 'store_dubhe_user_storages',
27
+ dapp_runtime_state: 'store_dubhe_dapp_runtime_state',
28
+ dapp_marketplace_fees: 'store_dubhe_dapp_marketplace_fees',
29
+ dapp_fee_state: 'store_dubhe_dapp_fee_state',
30
+ dapp_revenue_state: 'store_dubhe_dapp_revenue_state',
31
+ object_storages: 'store_dubhe_object_storages',
32
+ object_storage_fields: 'store_dubhe_object_storage_fields',
33
+ scene_storages: 'store_dubhe_scene_storages',
34
+ scene_storage_fields: 'store_dubhe_scene_storage_fields',
35
+ scene_permits: 'store_dubhe_scene_permits',
36
+ scene_permit_participants: 'store_dubhe_scene_permit_participants',
37
+ storage_schemas: 'store_dubhe_storage_schemas',
38
+ storage_schema_fields: 'store_dubhe_storage_schema_fields'
39
+ // table_fields is already discovered by getStoreTables via store_* but exposed
40
+ // differently; skip it here to avoid a duplicate view.
41
+ };
42
+
43
+ /**
44
+ * Pre-computed JOIN views that are already created by create_indexer_tables_sql().
45
+ * These views live in the database as first-class objects (not SELECT * aliases),
46
+ * so we only need to verify they exist and expose them to PostGraphile – no DDL here.
47
+ *
48
+ * Naming follows the same store_dubhe_* convention so PostGraphile picks them up
49
+ * automatically alongside the plain system-table views above.
50
+ */
51
+ const JOIN_VIEWS: string[] = [
52
+ 'store_dubhe_listing_with_fees', // marketplace_listings ⋈ dapp_marketplace_fees
53
+ 'store_dubhe_object_with_fields', // object_storages ⋈ object_storage_fields (JSONB)
54
+ 'store_dubhe_user_with_session', // user_storages ⋈ sessions (active only)
55
+ 'store_dubhe_scene_with_permit' // scene_storages ⋈ scene_permits
56
+ ];
57
+
16
58
  // Scan database table structure
17
59
  export class DatabaseIntrospector {
18
60
  constructor(private pool: Pool, private schema: string = 'public') {}
19
61
 
20
- // Get all dynamically created store_* tables
21
- async getStoreTables(): Promise<string[]> {
22
- const result = await this.pool.query(
23
- `
24
- SELECT table_name
25
- FROM information_schema.tables
26
- WHERE table_schema = $1
27
- AND table_name LIKE 'store_%'
28
- ORDER BY table_name
29
- `,
30
- [this.schema]
31
- );
62
+ /**
63
+ * Create store_dubhe_* views for all Dubhe system tables that exist in the DB.
64
+ * Safe to call on every startup (uses CREATE OR REPLACE VIEW).
65
+ * PostGraphile discovers these views exactly like regular store_* tables.
66
+ *
67
+ * Also verifies that the pre-computed JOIN views (created by create_indexer_tables_sql)
68
+ * are present, and logs a warning when any are missing so operators know to re-run
69
+ * the indexer migration.
70
+ */
71
+ async ensureSystemViews(): Promise<void> {
72
+ const created: string[] = [];
73
+ for (const [sourceTable, viewName] of Object.entries(SYSTEM_TABLE_VIEWS)) {
74
+ try {
75
+ const exists = await this.pool.query(
76
+ `SELECT 1 FROM information_schema.tables
77
+ WHERE table_schema = $1 AND table_name = $2`,
78
+ [this.schema, sourceTable]
79
+ );
80
+ if (exists.rows.length === 0) continue;
81
+
82
+ // Check if view already exists to avoid spammy CREATE OR REPLACE logs
83
+ const viewExists = await this.pool.query(
84
+ `SELECT 1 FROM information_schema.views
85
+ WHERE table_schema = $1 AND table_name = $2`,
86
+ [this.schema, viewName]
87
+ );
88
+ const isNew = viewExists.rows.length === 0;
89
+
90
+ await this.pool.query(
91
+ `CREATE OR REPLACE VIEW ${this.schema}.${viewName} AS SELECT * FROM ${this.schema}.${sourceTable}`
92
+ );
93
+
94
+ if (isNew) {
95
+ created.push(viewName);
96
+ console.log(`[introspector] ✨ new system view created: ${viewName} → ${sourceTable}`);
97
+ }
98
+ } catch (err) {
99
+ console.warn(`[introspector] could not create view ${viewName}:`, err);
100
+ }
101
+ }
102
+ if (created.length > 0) {
103
+ console.log(
104
+ `[introspector] watchPg will now auto-refresh GraphQL schema for: ${created.join(', ')}`
105
+ );
106
+ }
32
107
 
33
- return result.rows.map((row) => row.table_name);
108
+ // Verify pre-computed JOIN views (created by create_indexer_tables_sql, not here).
109
+ const missingJoinViews: string[] = [];
110
+ for (const viewName of JOIN_VIEWS) {
111
+ try {
112
+ const viewExists = await this.pool.query(
113
+ `SELECT 1 FROM information_schema.views
114
+ WHERE table_schema = $1 AND table_name = $2`,
115
+ [this.schema, viewName]
116
+ );
117
+ if (viewExists.rows.length === 0) {
118
+ missingJoinViews.push(viewName);
119
+ } else {
120
+ console.log(`[introspector] ✅ join view ready: ${viewName}`);
121
+ }
122
+ } catch (err) {
123
+ console.warn(`[introspector] could not verify join view ${viewName}:`, err);
124
+ }
125
+ }
126
+ if (missingJoinViews.length > 0) {
127
+ console.warn(
128
+ `[introspector] ⚠️ missing join views (re-run indexer migration to create them): ${missingJoinViews.join(
129
+ ', '
130
+ )}`
131
+ );
132
+ }
34
133
  }
35
134
 
36
- // Get system tables (dubhe related tables)
37
- async getSystemTables(): Promise<string[]> {
135
+ // Get all dynamically created store_* tables (includes store_dubhe_* views)
136
+ async getStoreTables(): Promise<string[]> {
38
137
  const result = await this.pool.query(
39
138
  `
40
139
  SELECT table_name
41
140
  FROM information_schema.tables
42
141
  WHERE table_schema = $1
43
- AND (table_name = 'table_fields')
142
+ AND table_name LIKE 'store_%'
44
143
  ORDER BY table_name
45
144
  `,
46
145
  [this.schema]
@@ -51,8 +150,8 @@ export class DatabaseIntrospector {
51
150
 
52
151
  // Get dynamic table field information from table_fields table
53
152
  async getDynamicTableFields(tableName: string): Promise<TableField[]> {
54
- // Extract table name (remove store_ prefix)
55
- const baseTableName = tableName.replace('store_', '');
153
+ // For store_dubhe_* views, look up the underlying table name
154
+ const baseTableName = tableName.replace(/^store_dubhe_/, '').replace(/^store_/, '');
56
155
 
57
156
  const result = await this.pool.query(
58
157
  `
@@ -64,10 +163,16 @@ export class DatabaseIntrospector {
64
163
  [baseTableName]
65
164
  );
66
165
 
166
+ // If no schema metadata found (system tables don't register in table_fields),
167
+ // fall back to reading columns from information_schema
168
+ if (result.rows.length === 0) {
169
+ return this.getSystemTableFields(tableName);
170
+ }
171
+
67
172
  return result.rows;
68
173
  }
69
174
 
70
- // Get field information from system tables
175
+ // Get field information directly from information_schema (used for system tables)
71
176
  async getSystemTableFields(tableName: string): Promise<TableField[]> {
72
177
  const result = await this.pool.query(
73
178
  `
@@ -89,25 +194,11 @@ export class DatabaseIntrospector {
89
194
  // Get complete information for all tables
90
195
  async getAllTables(): Promise<DynamicTable[]> {
91
196
  const storeTables = await this.getStoreTables();
92
- const systemTables = await this.getSystemTables();
93
197
  const allTables: DynamicTable[] = [];
94
198
 
95
- // Process dynamic tables
96
199
  for (const tableName of storeTables) {
97
200
  const fields = await this.getDynamicTableFields(tableName);
98
- allTables.push({
99
- table_name: tableName,
100
- fields
101
- });
102
- }
103
-
104
- // Process system tables
105
- for (const tableName of systemTables) {
106
- const fields = await this.getSystemTableFields(tableName);
107
- allTables.push({
108
- table_name: tableName,
109
- fields
110
- });
201
+ allTables.push({ table_name: tableName, fields });
111
202
  }
112
203
 
113
204
  return allTables;
@@ -68,8 +68,9 @@ export function createPostGraphileConfig(options: PostGraphileConfigOptions) {
68
68
  // Enable query execution plan explanation (development environment only)
69
69
  allowExplain: nodeEnv === 'development',
70
70
 
71
- // Monitor PostgreSQL changes (development environment only)
72
- watchPg: nodeEnv === 'development',
71
+ // Monitor PostgreSQL schema changes always enabled so PostGraphile auto-rebuilds
72
+ // its GraphQL schema when store_dubhe_* views are created by the background poller.
73
+ watchPg: true,
73
74
 
74
75
  // GraphQL query timeout setting
75
76
  queryTimeout: options.queryTimeout,
@@ -23,43 +23,44 @@ export const SimpleNamingPlugin: Plugin = (builder) => {
23
23
  originalFieldNames.forEach((fieldName) => {
24
24
  let newFieldName = fieldName;
25
25
 
26
- // Remove "all" prefix, but keep system fields
27
- if (
28
- fieldName.startsWith('all') &&
29
- !['allRows', 'allTableFields'].includes(fieldName) // Extend reserved list
30
- ) {
31
- // allStoreAccounts -> storeAccounts
32
- // allStoreEncounters -> storeEncounters
26
+ // Step 1: Remove "all" prefix
27
+ if (fieldName.startsWith('all') && !['allRows', 'allTableFields'].includes(fieldName)) {
33
28
  newFieldName = fieldName.replace(/^all/, '');
34
- // First letter to lowercase, maintain camelCase
35
29
  if (newFieldName.length > 0) {
36
30
  newFieldName = newFieldName.charAt(0).toLowerCase() + newFieldName.slice(1);
37
31
  }
38
32
  }
39
33
 
40
- // Remove "store" prefix (note lowercase s, because it's already processed above)
34
+ // Step 2: Routing by table origin
41
35
  if (newFieldName.startsWith('store') && newFieldName !== 'store') {
42
- // storeAccounts -> accounts
43
- // storeAccount -> account
44
- // storeEncounters -> encounters
45
- // storeEncounter -> encounter
36
+ // User-defined store_* tables: strip "store" prefix
37
+ // storeWheat -> wheat, storeFarmPlots -> farmPlots
46
38
  const withoutStore = newFieldName.replace(/^store/, '');
47
- // First letter to lowercase, maintain camelCase
48
39
  if (withoutStore.length > 0) {
49
- const finalName = withoutStore.charAt(0).toLowerCase() + withoutStore.slice(1);
50
-
51
- // Check if field name conflict will occur
52
- if (!renamedFields[finalName] && !originalFieldNames.includes(finalName)) {
53
- newFieldName = finalName;
40
+ const candidate = withoutStore.charAt(0).toLowerCase() + withoutStore.slice(1);
41
+ if (!renamedFields[candidate] && !originalFieldNames.includes(candidate)) {
42
+ newFieldName = candidate;
54
43
  }
55
- // If conflict, keep original name (remove all but keep store)
44
+ // If conflict, keep the name without store strip (keeps "storeXxx")
45
+ }
46
+ } else if (newFieldName.startsWith('dubhe') && newFieldName !== 'dubhe') {
47
+ // Tables already prefixed with dubhe_ keep their name as-is:
48
+ // allDubheEvents -> dubheEvents (no change needed)
49
+ } else if (newFieldName !== fieldName) {
50
+ // System tables (non store_*, non dubhe_*): add "dubhe" namespace prefix
51
+ // to prevent conflicts with any future user store_* table.
52
+ // sessions -> dubheSessions, userStorages -> dubheUserStorages,
53
+ // marketplaceListings -> dubheMarketplaceListings
54
+ const candidate = 'dubhe' + newFieldName.charAt(0).toUpperCase() + newFieldName.slice(1);
55
+ if (!renamedFields[candidate] && !originalFieldNames.includes(candidate)) {
56
+ newFieldName = candidate;
56
57
  }
57
58
  }
58
59
 
59
- // Check if final field name will conflict
60
+ // Guard: if the final name still collides, fall back to original
60
61
  if (renamedFields[newFieldName]) {
61
62
  console.warn(`⚠️ Field name conflict: ${newFieldName}, keeping original name ${fieldName}`);
62
- newFieldName = fieldName; // Keep original name to avoid conflict
63
+ newFieldName = fieldName;
63
64
  }
64
65
 
65
66
  renameMap[fieldName] = newFieldName;
@@ -94,7 +95,6 @@ export const SimpleNamingPlugin: Plugin = (builder) => {
94
95
  'Final:',
95
96
  finalFieldNames.length
96
97
  );
97
- // If fields are lost, return original fields to avoid breakage
98
98
  return fields;
99
99
  }
100
100
 
package/src/server.ts CHANGED
@@ -148,6 +148,9 @@ export const startServer = async (config: ServerConfig): Promise<void> => {
148
148
  }
149
149
  dbLogger.info('Database connection successful', { schema: PG_SCHEMA });
150
150
 
151
+ // Ensure store_dubhe_* views exist for all Dubhe system tables
152
+ await introspector.ensureSystemViews();
153
+
151
154
  const allTables = await introspector.getAllTables();
152
155
  const tableNames = allTables.map((t) => t.table_name);
153
156
 
@@ -243,6 +246,21 @@ export const startServer = async (config: ServerConfig): Promise<void> => {
243
246
  // 9. Start Express server
244
247
  await serverManager.startServer();
245
248
 
249
+ // 10. Background poller: re-run ensureSystemViews() every 30 s so that
250
+ // store_dubhe_* views are created as soon as the indexer creates the
251
+ // underlying system tables (marketplace_listings, sessions, etc.).
252
+ // watchPg (enabled above) then detects the new DDL and rebuilds the
253
+ // PostGraphile GraphQL schema automatically — no manual restart needed.
254
+ const VIEW_POLL_INTERVAL_MS = 30_000;
255
+ const viewPoller = setInterval(async () => {
256
+ try {
257
+ await introspector.ensureSystemViews();
258
+ } catch (err) {
259
+ dbLogger.warn('Background ensureSystemViews() failed (will retry)', { err });
260
+ }
261
+ }, VIEW_POLL_INTERVAL_MS);
262
+ viewPoller.unref(); // Don't keep the process alive solely for this timer
263
+
246
264
  logPerformance('Express server startup', startTime, {
247
265
  port: PORT,
248
266
  tableCount: allTables.length,