@enfyra/mcp-server 0.0.94 → 0.0.96
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 +1 -1
- package/src/lib/mcp-examples.js +178 -0
- package/src/lib/mcp-instructions.js +1 -0
- package/src/lib/response-format.js +117 -0
- package/src/mcp-server-entry.mjs +35 -10
package/package.json
CHANGED
package/src/lib/mcp-examples.js
CHANGED
|
@@ -47,6 +47,47 @@ export default nextConfig`,
|
|
|
47
47
|
'If you add Next middleware/proxy for auth gating, server-side checks may call the Enfyra API origin directly while forwarding the incoming Cookie header.',
|
|
48
48
|
],
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
name: 'Angular dev proxy for REST and Socket.IO',
|
|
52
|
+
code: `// src/proxy.conf.json
|
|
53
|
+
{
|
|
54
|
+
"/enfyra/**": {
|
|
55
|
+
"target": "https://demo.enfyra.io/api",
|
|
56
|
+
"secure": true,
|
|
57
|
+
"changeOrigin": true,
|
|
58
|
+
"pathRewrite": {
|
|
59
|
+
"^/enfyra": ""
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"/socket.io/**": {
|
|
63
|
+
"target": "https://demo.enfyra.io/api/ws",
|
|
64
|
+
"secure": true,
|
|
65
|
+
"changeOrigin": true,
|
|
66
|
+
"ws": true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// angular.json
|
|
71
|
+
{
|
|
72
|
+
"projects": {
|
|
73
|
+
"app": {
|
|
74
|
+
"architect": {
|
|
75
|
+
"serve": {
|
|
76
|
+
"options": {
|
|
77
|
+
"proxyConfig": "src/proxy.conf.json"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}`,
|
|
84
|
+
notes: [
|
|
85
|
+
'Browser code still calls /enfyra/login, /enfyra/me, /enfyra/logout, and /enfyra/<table>.',
|
|
86
|
+
'The /enfyra proxy strips the prefix before forwarding to the Enfyra API origin.',
|
|
87
|
+
'The /socket.io proxy forwards to the Enfyra app bridge /ws/socket.io while keeping the browser transport path as /socket.io.',
|
|
88
|
+
'Restart ng serve after changing proxy.conf.json.',
|
|
89
|
+
],
|
|
90
|
+
},
|
|
50
91
|
{
|
|
51
92
|
name: 'Password login and current user fetch',
|
|
52
93
|
code: `await fetch("/enfyra/login", {
|
|
@@ -153,6 +194,85 @@ onUnmounted(() => {
|
|
|
153
194
|
'Disconnect the singleton socket when the current user/session clears.',
|
|
154
195
|
],
|
|
155
196
|
},
|
|
197
|
+
{
|
|
198
|
+
name: 'Angular HttpClient auth service and route guard',
|
|
199
|
+
code: `// app.config.ts
|
|
200
|
+
import { ApplicationConfig, inject } from "@angular/core"
|
|
201
|
+
import { provideRouter, CanActivateFn, Router } from "@angular/router"
|
|
202
|
+
import { HttpInterceptorFn, provideHttpClient, withInterceptors } from "@angular/common/http"
|
|
203
|
+
import { catchError, map, of } from "rxjs"
|
|
204
|
+
|
|
205
|
+
import { routes } from "./app.routes"
|
|
206
|
+
import { EnfyraAuthService } from "./enfyra-auth.service"
|
|
207
|
+
|
|
208
|
+
export const enfyraCredentialsInterceptor: HttpInterceptorFn = (req, next) => {
|
|
209
|
+
if (!req.url.startsWith("/enfyra/")) return next(req)
|
|
210
|
+
return next(req.clone({ withCredentials: true }))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const requireUserGuard: CanActivateFn = () => {
|
|
214
|
+
const auth = inject(EnfyraAuthService)
|
|
215
|
+
const router = inject(Router)
|
|
216
|
+
|
|
217
|
+
return auth.loadMe().pipe(
|
|
218
|
+
map(user => user ? true : router.createUrlTree(["/login"])),
|
|
219
|
+
catchError(() => of(router.createUrlTree(["/login"])))
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const appConfig: ApplicationConfig = {
|
|
224
|
+
providers: [
|
|
225
|
+
provideHttpClient(withInterceptors([enfyraCredentialsInterceptor])),
|
|
226
|
+
provideRouter(routes)
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// enfyra-auth.service.ts
|
|
231
|
+
import { Injectable, signal } from "@angular/core"
|
|
232
|
+
import { HttpClient } from "@angular/common/http"
|
|
233
|
+
import { Observable, tap } from "rxjs"
|
|
234
|
+
|
|
235
|
+
type EnfyraUser = { id: string | number; email?: string }
|
|
236
|
+
|
|
237
|
+
@Injectable({ providedIn: "root" })
|
|
238
|
+
export class EnfyraAuthService {
|
|
239
|
+
readonly user = signal<EnfyraUser | null>(null)
|
|
240
|
+
|
|
241
|
+
constructor(private readonly http: HttpClient) {}
|
|
242
|
+
|
|
243
|
+
login(email: string, password: string): Observable<unknown> {
|
|
244
|
+
return this.http.post("/enfyra/login", { email, password, remember: true }).pipe(
|
|
245
|
+
tap(() => this.loadMe().subscribe())
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
loadMe(): Observable<EnfyraUser | null> {
|
|
250
|
+
return this.http.get<EnfyraUser | null>("/enfyra/me").pipe(
|
|
251
|
+
tap(user => this.user.set(user))
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
logout(): Observable<unknown> {
|
|
256
|
+
return this.http.post("/enfyra/logout", {}).pipe(
|
|
257
|
+
tap(() => this.user.set(null))
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
startGoogleOAuth(returnPath = "/") {
|
|
262
|
+
const redirect = new URL(returnPath, window.location.origin)
|
|
263
|
+
const url = new URL("/enfyra/auth/google", window.location.origin)
|
|
264
|
+
url.searchParams.set("redirect", redirect.toString())
|
|
265
|
+
url.searchParams.set("cookieBridgePrefix", "/enfyra")
|
|
266
|
+
window.location.href = url.toString()
|
|
267
|
+
}
|
|
268
|
+
}`,
|
|
269
|
+
notes: [
|
|
270
|
+
'Use HttpClient with a credentials interceptor for /enfyra/* calls so cookies are sent consistently.',
|
|
271
|
+
'The guard is only for user experience; Enfyra route permissions and server-side owner checks remain authoritative.',
|
|
272
|
+
'Keep the current user in an Angular service or store; do not read JWTs from cookies or URLs.',
|
|
273
|
+
'OAuth starts at the app proxy path and returns through the cookie bridge before the Angular route loads /enfyra/me.',
|
|
274
|
+
],
|
|
275
|
+
},
|
|
156
276
|
{
|
|
157
277
|
name: 'Next client provider for authenticated realtime',
|
|
158
278
|
code: `"use client"
|
|
@@ -243,6 +363,64 @@ export function useRealtime() {
|
|
|
243
363
|
'Disconnect the singleton socket when the current user/session clears.',
|
|
244
364
|
],
|
|
245
365
|
},
|
|
366
|
+
{
|
|
367
|
+
name: 'Angular singleton Socket.IO realtime service',
|
|
368
|
+
code: `// enfyra-realtime.service.ts
|
|
369
|
+
import { Injectable, computed, effect, signal } from "@angular/core"
|
|
370
|
+
import { io, Socket } from "socket.io-client"
|
|
371
|
+
|
|
372
|
+
import { EnfyraAuthService } from "./enfyra-auth.service"
|
|
373
|
+
|
|
374
|
+
@Injectable({ providedIn: "root" })
|
|
375
|
+
export class EnfyraRealtimeService {
|
|
376
|
+
private socket: Socket | null = null
|
|
377
|
+
private readonly connected = signal(false)
|
|
378
|
+
readonly isConnected = computed(() => this.connected())
|
|
379
|
+
|
|
380
|
+
constructor(private readonly auth: EnfyraAuthService) {
|
|
381
|
+
effect(() => {
|
|
382
|
+
const user = this.auth.user()
|
|
383
|
+
if (user) this.connect()
|
|
384
|
+
else this.disconnect()
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
connect() {
|
|
389
|
+
if (this.socket) return this.socket
|
|
390
|
+
|
|
391
|
+
this.socket = io("/chat", {
|
|
392
|
+
path: "/socket.io",
|
|
393
|
+
withCredentials: true,
|
|
394
|
+
reconnection: true,
|
|
395
|
+
reconnectionAttempts: Infinity,
|
|
396
|
+
reconnectionDelay: 2000,
|
|
397
|
+
reconnectionDelayMax: 30000
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
this.socket.on("connect", () => this.connected.set(true))
|
|
401
|
+
this.socket.on("disconnect", () => this.connected.set(false))
|
|
402
|
+
return this.socket
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
disconnect() {
|
|
406
|
+
this.socket?.disconnect()
|
|
407
|
+
this.socket = null
|
|
408
|
+
this.connected.set(false)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
onMessage(handler: (event: unknown) => void) {
|
|
412
|
+
const activeSocket = this.connect()
|
|
413
|
+
activeSocket.on("chat:message", handler)
|
|
414
|
+
return () => activeSocket.off("chat:message", handler)
|
|
415
|
+
}
|
|
416
|
+
}`,
|
|
417
|
+
notes: [
|
|
418
|
+
'Create one app-level Socket.IO connection after auth is known.',
|
|
419
|
+
'Use the websocket namespace path from live metadata, such as /chat, and keep the transport path as /socket.io.',
|
|
420
|
+
'Components subscribe with onMessage and call the returned cleanup function in ngOnDestroy.',
|
|
421
|
+
'Do not create a new socket per routed component.',
|
|
422
|
+
],
|
|
423
|
+
},
|
|
246
424
|
{
|
|
247
425
|
name: 'OAuth provider setup values',
|
|
248
426
|
code: `// Enfyra OAuth config row, stored in enfyra_oauth_config.
|
|
@@ -33,6 +33,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
33
33
|
'- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
|
|
34
34
|
'',
|
|
35
35
|
'### Core Contracts',
|
|
36
|
+
'- Tool JSON responses use `responseFormat: "json+columnar-v1"`. Any array of objects is encoded as `{ format: "columnar-v1", columns: [...], rows: [[...]], rowCount }`; read each row value by matching `columns[index]` to `rows[n][index]`. Do not guess object keys inside `rows`.',
|
|
36
37
|
'- `query_table` and `get_all_routes` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
|
|
37
38
|
'- Read tools are minimal by default. Pass explicit `fields`; use metadata inspection before guessing field/relation names. Field exclusion mode exists: `fields=-compiledCode`, and `fields=id,-compiledCode` still means all readable fields except `compiledCode`.',
|
|
38
39
|
'- Mutations return ids/status by default. Re-read with `find_one_record` or `query_table` and explicit `fields` when the saved row matters.',
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const RESPONSE_FORMAT = 'json+columnar-v1';
|
|
2
|
+
const COLUMNAR_FORMAT = 'columnar-v1';
|
|
3
|
+
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
if (!value || typeof value !== 'object') return false;
|
|
6
|
+
const proto = Object.getPrototypeOf(value);
|
|
7
|
+
return proto === Object.prototype || proto === null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function valueForColumn(record, column) {
|
|
11
|
+
return Object.prototype.hasOwnProperty.call(record, column) ? record[column] : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function collectColumns(records) {
|
|
15
|
+
const columns = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const record of records) {
|
|
18
|
+
for (const key of Object.keys(record)) {
|
|
19
|
+
if (seen.has(key)) continue;
|
|
20
|
+
seen.add(key);
|
|
21
|
+
columns.push(key);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return columns;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toColumnar(value, seen = new WeakSet()) {
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
if (value.length > 0 && value.every(isPlainObject)) {
|
|
30
|
+
const columns = collectColumns(value);
|
|
31
|
+
return {
|
|
32
|
+
format: COLUMNAR_FORMAT,
|
|
33
|
+
columns,
|
|
34
|
+
rows: value.map((record) => columns.map((column) => toColumnar(valueForColumn(record, column), seen))),
|
|
35
|
+
rowCount: value.length,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return value.map((item) => toColumnar(item, seen));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!isPlainObject(value)) return value;
|
|
42
|
+
if (seen.has(value)) return '[Circular]';
|
|
43
|
+
seen.add(value);
|
|
44
|
+
|
|
45
|
+
const output = {};
|
|
46
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
47
|
+
output[key] = toColumnar(entry, seen);
|
|
48
|
+
}
|
|
49
|
+
seen.delete(value);
|
|
50
|
+
return output;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatJsonPayload(payload) {
|
|
54
|
+
const formatted = toColumnar(payload);
|
|
55
|
+
if (!isPlainObject(formatted)) {
|
|
56
|
+
return {
|
|
57
|
+
responseFormat: RESPONSE_FORMAT,
|
|
58
|
+
value: formatted,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (formatted.responseFormat === RESPONSE_FORMAT) return formatted;
|
|
62
|
+
return {
|
|
63
|
+
responseFormat: RESPONSE_FORMAT,
|
|
64
|
+
...formatted,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function jsonContent(payload, { pretty = false } = {}) {
|
|
69
|
+
return {
|
|
70
|
+
content: [{
|
|
71
|
+
type: 'text',
|
|
72
|
+
text: JSON.stringify(formatJsonPayload(payload), null, pretty ? 2 : 0),
|
|
73
|
+
}],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function tryParseJson(text) {
|
|
78
|
+
if (typeof text !== 'string') return null;
|
|
79
|
+
const trimmed = text.trim();
|
|
80
|
+
if (!trimmed || !/^[\[{]/.test(trimmed)) return null;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(trimmed);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatContentItem(item) {
|
|
89
|
+
if (!item || item.type !== 'text') return item;
|
|
90
|
+
const parsed = tryParseJson(item.text);
|
|
91
|
+
if (!parsed) return item;
|
|
92
|
+
return {
|
|
93
|
+
...item,
|
|
94
|
+
text: JSON.stringify(formatJsonPayload(parsed)),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatToolResult(result) {
|
|
99
|
+
if (!result || !Array.isArray(result.content)) return result;
|
|
100
|
+
return {
|
|
101
|
+
...result,
|
|
102
|
+
content: result.content.map(formatContentItem),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function installColumnarToolFormatter(server) {
|
|
107
|
+
const registerTool = server.tool.bind(server);
|
|
108
|
+
server.tool = (name, description, schema, handler) => {
|
|
109
|
+
if (typeof handler !== 'function') {
|
|
110
|
+
return registerTool(name, description, schema, handler);
|
|
111
|
+
}
|
|
112
|
+
return registerTool(name, description, schema, async (...args) => {
|
|
113
|
+
const result = await handler(...args);
|
|
114
|
+
return formatToolResult(result);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
|
|
|
23
23
|
import { registerTableTools } from './lib/table-tools.js';
|
|
24
24
|
import { prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
|
|
25
25
|
import { validateMainTableRoutePath } from './lib/route-guards.js';
|
|
26
|
+
import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
|
|
26
27
|
import {
|
|
27
28
|
findRoutePermission,
|
|
28
29
|
mergeMethodNames,
|
|
@@ -192,6 +193,21 @@ function getMetadataDatabaseContext(metadata, tables) {
|
|
|
192
193
|
|
|
193
194
|
function summarizeTable(table) {
|
|
194
195
|
if (!table) return null;
|
|
196
|
+
const relationFkColumnNames = new Set((table.relations || []).flatMap((relation) => {
|
|
197
|
+
const propertyName = relation.propertyName;
|
|
198
|
+
return propertyName
|
|
199
|
+
? [
|
|
200
|
+
`${propertyName}Id`,
|
|
201
|
+
`${propertyName}_id`,
|
|
202
|
+
relation.fkCol,
|
|
203
|
+
relation.fkColumn,
|
|
204
|
+
relation.foreignKeyColumn,
|
|
205
|
+
].filter(Boolean).map((name) => String(name).toLowerCase())
|
|
206
|
+
: [];
|
|
207
|
+
}));
|
|
208
|
+
const modelFacingColumns = (table.columns || []).filter((column) => (
|
|
209
|
+
column.isPrimary || !relationFkColumnNames.has(String(column.name || '').toLowerCase())
|
|
210
|
+
));
|
|
195
211
|
return {
|
|
196
212
|
id: table.id ?? table._id,
|
|
197
213
|
name: table.name,
|
|
@@ -199,7 +215,7 @@ function summarizeTable(table) {
|
|
|
199
215
|
primaryKey: getPrimaryColumn(table)?.name || null,
|
|
200
216
|
validateBody: table.validateBody,
|
|
201
217
|
graphqlEnabled: table.graphqlEnabled,
|
|
202
|
-
columns:
|
|
218
|
+
columns: modelFacingColumns.map((column) => ({
|
|
203
219
|
id: column.id ?? column._id,
|
|
204
220
|
name: column.name,
|
|
205
221
|
type: column.type,
|
|
@@ -209,6 +225,7 @@ function summarizeTable(table) {
|
|
|
209
225
|
isUpdatable: column.isUpdatable !== false,
|
|
210
226
|
isEncrypted: column.isEncrypted === true,
|
|
211
227
|
})),
|
|
228
|
+
hiddenRelationColumnCount: (table.columns || []).length - modelFacingColumns.length,
|
|
212
229
|
relations: (table.relations || []).map((relation) => ({
|
|
213
230
|
id: relation.id ?? relation._id,
|
|
214
231
|
propertyName: relation.propertyName,
|
|
@@ -391,10 +408,6 @@ function collectPartialErrors(results) {
|
|
|
391
408
|
.map(([name, result]) => ({ name, error: result.error }));
|
|
392
409
|
}
|
|
393
410
|
|
|
394
|
-
function jsonContent(payload, { pretty = false } = {}) {
|
|
395
|
-
return { content: [{ type: 'text', text: JSON.stringify(payload, null, pretty ? 2 : 0) }] };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
411
|
async function getMetadataTables() {
|
|
399
412
|
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
400
413
|
return {
|
|
@@ -600,6 +613,7 @@ const server = new McpServer(
|
|
|
600
613
|
instructions: buildMcpServerInstructions(ENFYRA_API_URL),
|
|
601
614
|
},
|
|
602
615
|
);
|
|
616
|
+
installColumnarToolFormatter(server);
|
|
603
617
|
|
|
604
618
|
// ============================================================================
|
|
605
619
|
// METADATA TOOLS
|
|
@@ -844,12 +858,21 @@ server.tool(
|
|
|
844
858
|
tableName: z.string().optional().describe('Optional table name to summarize query fields and relation/deep capabilities.'),
|
|
845
859
|
},
|
|
846
860
|
async ({ tableName }) => {
|
|
847
|
-
const metadata =
|
|
848
|
-
|
|
849
|
-
|
|
861
|
+
const metadata = tableName
|
|
862
|
+
? await discoveryFetch(`/metadata/${encodeURIComponent(tableName)}`)
|
|
863
|
+
: null;
|
|
864
|
+
const routesResult = tableName
|
|
865
|
+
? await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000')
|
|
866
|
+
: { data: [] };
|
|
867
|
+
const tableFromMetadata = tableName && !metadata?.error
|
|
868
|
+
? metadata?.data?.table || metadata?.data || metadata?.table || metadata
|
|
869
|
+
: null;
|
|
870
|
+
const tables = tableName
|
|
871
|
+
? (tableFromMetadata ? [tableFromMetadata] : [])
|
|
872
|
+
: [];
|
|
850
873
|
const routes = summarizeRoutes(routesResult);
|
|
851
874
|
const table = tableName ? tables.find((item) => item.name === tableName) : null;
|
|
852
|
-
const primaryKey = table ? getPrimaryColumn(table)?.name || 'id' :
|
|
875
|
+
const primaryKey = table ? getPrimaryColumn(table)?.name || 'id' : 'id';
|
|
853
876
|
const tableRoutes = tableName
|
|
854
877
|
? routes.filter((route) => route.mainTable === tableName)
|
|
855
878
|
: [];
|
|
@@ -885,7 +908,9 @@ server.tool(
|
|
|
885
908
|
],
|
|
886
909
|
},
|
|
887
910
|
backendNotes: {
|
|
888
|
-
primaryKey:
|
|
911
|
+
primaryKey: tableName
|
|
912
|
+
? 'Use this table metadata primary column when available.'
|
|
913
|
+
: 'SQL commonly uses id; Mongo uses _id. Use table metadata primary column when available.',
|
|
889
914
|
relationNames: 'API relation operations use relation propertyName, not physical FK column names.',
|
|
890
915
|
relationCascadeFkContract: 'When creating relations through create_table/create_relation/enfyra_table PATCH, never provide fkCol/fkColumn/foreignKeyColumn/sourceColumn/targetColumn/junction*Column. These are physical implementation details derived by Enfyra and hidden from app schema/forms.',
|
|
891
916
|
graphql: 'GraphQL query args also accept filter/sort/page/limit, but GraphQL requires Bearer auth and table enablement via enfyra_graphql.',
|