@enfyra/mcp-server 0.0.30 → 0.0.31

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.
@@ -0,0 +1,33 @@
1
+ # Enfyra MCP Performance And Debug Mode
2
+
3
+ Use this skill when designing, reviewing, or debugging Enfyra apps through MCP where performance, read/write shape, indexes, websocket latency, RLS filters, or query correctness matter.
4
+
5
+ ## Rules
6
+ - Verify capability with live metadata before claiming performance behavior. Use `inspect_table`, `inspect_route`, `discover_query_capabilities`, and `discover_runtime_context` first.
7
+ - Prefer indexed relation filters over scalar mirror columns. Relation property names can be used in `indexes` and `uniques`; Enfyra resolves them to physical FK columns.
8
+ - For hot read paths, design indexes with the most selective/user-scoped field first. Examples: `["member","is_read","conversation"]` for unread lookup and `["conversation","member","is_read"]` for mark-read updates.
9
+ - Use existence checks for UI dots and badges unless the user explicitly needs exact counts. Avoid count queries on every conversation row.
10
+ - Use `meta=filterCount` or MCP `count_records` only when count is the product requirement.
11
+ - For RLS hooks, mutate `@QUERY.filter` and preserve existing user filters with `_and`. `@QUERY.filter` is already `{}` when omitted.
12
+ - For dynamic scripts, keep runtime values in their original type. Do not wrap ids or payload values in `String(...)`, `Number(...)`, or `Boolean(...)`.
13
+ - For websocket apps, connect browsers through the Enfyra app/Nuxt bridge, not the hidden backend. Event handlers should be script-owned and use `@SOCKET` explicitly.
14
+
15
+ ## Debug Workflow
16
+ 1. Inspect the table schema, relations, indexes, and route permissions before changing code.
17
+ 2. Reproduce with the smallest real request or Socket.IO event. Prefer `test_rest_endpoint` or `run_admin_test` when available.
18
+ 3. If the problem is performance, state the exact query shape and expected index. Add or update `indexes` on `table_definition` through `create_table` or `update_table`.
19
+ 4. Reload metadata/cache after schema or script changes when the tool does not do it automatically.
20
+ 5. Retest the exact route/event and compare behavior before/after. Do not invent benchmark numbers.
21
+
22
+ ## Chat Read/Unread Pattern
23
+ - Use a join table, e.g. `chat_message_read`, with:
24
+ - `message` relation to `chat_message`
25
+ - `conversation` relation to `chat_conversation`
26
+ - `member` relation to `user_definition`
27
+ - `is_read` boolean
28
+ - `read_at` date nullable
29
+ - Add unique `["message","member"]`.
30
+ - Add indexes `["member","is_read","conversation"]` and `["conversation","member","is_read"]`.
31
+ - On message create, create read rows for conversation members. Sender rows start as read; other member rows start unread.
32
+ - On conversation open/read, update unread rows for `@USER` in that conversation to read.
33
+ - UI unread dots should check whether an unread row exists; count only when requested.
package/README.md CHANGED
@@ -201,11 +201,14 @@ The Enfyra backend is private infrastructure. MCP, browser code, SSR routes, Gra
201
201
 
202
202
  ### SSR app auth pattern
203
203
 
204
- When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, use a same-origin proxy:
205
-
206
- - Browser code calls `{{ appOrigin }}/api/**`, never the raw Enfyra backend URL.
207
- - Cookie-managed password login is `POST {{ appOrigin }}/api/login`, not `/api/auth/login`. The SSR route calls backend `/auth/login` and stores Enfyra `accessToken`, `refreshToken`, and `expTime` as httpOnly cookies.
208
- - Cookie-managed OAuth should enable Enfyra OAuth cookie handling (`autoSetCookies` / set-cookies mode). Start OAuth at `{{ appOrigin }}/api/auth/:provider?redirect=...`; Enfyra redirects to `{{ appOrigin }}/api/auth/set-cookies`, then the SSR route sets cookies and redirects to the requested page.
204
+ When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, follow the Enfyra Cloud pattern:
205
+
206
+ - Browser code calls a same-origin proxy such as `{{ appOrigin }}/enfyra/**`, never the raw Enfyra backend URL.
207
+ - Nuxt can proxy it with `routeRules: { "/enfyra/**": { proxy: { to: `${API_URL}/**` } } }`.
208
+ - Generated apps should not create custom login/logout/me routes that manually set `accessToken`, `refreshToken`, or `expTime` cookies when the proxy is enough.
209
+ - Password login is `POST /enfyra/login`, not `/enfyra/auth/login`.
210
+ - Fetch the current user with `GET /enfyra/me` and logout with `POST /enfyra/logout`.
211
+ - OAuth starts through the same proxy prefix, for example `/enfyra/auth/google?redirect=...`.
209
212
  - Use token-query OAuth callback pages only for non-SSR/manual-token apps.
210
213
 
211
214
  ---
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "main": "src/index.mjs",
8
8
  "bin": "src/index.mjs",
9
9
  "files": [
10
- "src"
10
+ "src",
11
+ ".codex/skills"
11
12
  ],
12
13
  "scripts": {
13
14
  "start": "node src/index.mjs",
@@ -56,11 +56,12 @@ export function buildMcpServerInstructions(apiBaseUrl) {
56
56
  '',
57
57
  '### When the user asks how to connect a Nuxt/Next/SSR app to Enfyra',
58
58
  '- This is guidance for the assistant to answer users and generate app code. It is not a separate MCP tool workflow.',
59
- '- For real user-facing SSR apps, proxy all Enfyra traffic through the SSR app origin: **`{{ nuxtApp }}/api/**`** (or the equivalent Next route handler prefix). Browser code should call same-origin `/api/...`, not the raw Enfyra backend URL.',
60
- '- If the SSR app lets Enfyra manage httpOnly cookies, password login is **`POST {{ nuxtApp }}/api/login`**, not `{{ nuxtApp }}/api/auth/login`. The SSR route calls backend `/auth/login`, stores `accessToken`, `refreshToken`, and `expTime` cookies, and returns the backend login response.',
61
- '- After cookie-managed login, browser fetches use `/api/<resource>` with normal credentials/cookies; the SSR proxy/middleware attaches or refreshes the Bearer token server-side. Do not read JWTs in browser JavaScript for this mode.',
62
- '- For OAuth in SSR/cookie mode, enable cookie handling on the Enfyra OAuth config (`autoSetCookies` / set-cookies mode). Start OAuth at `{{ nuxtApp }}/api/auth/{provider}?redirect=<returnUrl>`. The proxy forwards to backend `/auth/{provider}` with the app origin, backend redirects to `{{ nuxtApp }}/api/auth/set-cookies`, the SSR route stores cookies, then redirects to `redirect`.',
63
- '- Token-query OAuth (`appCallbackUrl` receives `accessToken`, `refreshToken`, `expTime`) is only for non-SSR or manually managed token apps. Do not recommend it for Nuxt/Next SSR when Enfyra can set cookies.',
59
+ '- Follow the Enfyra Cloud app pattern by default: expose one same-origin proxy prefix such as **`/enfyra/**`** and route it to the hidden Enfyra API base, e.g. Nuxt `routeRules: { "/enfyra/**": { proxy: { to: `${API_URL}/**` } } }`. Browser/generated app code calls `/enfyra/...`, not the raw Enfyra backend URL.',
60
+ '- Do **not** generate custom login/logout/me server routes that manually set `accessToken`, `refreshToken`, or `expTime` cookies when a Cloud-style proxy is enough. Let the proxied Enfyra API own its auth response and cookies.',
61
+ '- Password login in generated Cloud-style Nuxt code is **`POST /enfyra/login`**, not `/enfyra/auth/login` and not a custom SSR `/api/login` wrapper.',
62
+ '- Fetch the current user with **`GET /enfyra/me`** and logout with **`POST /enfyra/logout`**. Browser fetches stay same-origin and credentials/cookies flow through the proxy. Do not read JWTs in browser JavaScript for this mode.',
63
+ '- OAuth starts on the same proxy prefix, e.g. **`GET /enfyra/auth/{provider}?redirect=<returnUrl>`**. Use token-query callback handling only when the app intentionally manages tokens itself.',
64
+ '- If a project explicitly standardizes on `/api/**` instead of `/enfyra/**`, keep the same Cloud-style behavior under that prefix: proxy to the Enfyra API and avoid generated cookie-management routes unless the user asks for a custom auth boundary.',
64
65
  '- If you are explaining MCP\'s own internal authentication, that is separate: this MCP server logs itself in with email/password against `{ENFYRA_API_URL}/auth/login`; for normal app work, `ENFYRA_API_URL` must still be the app proxy base such as `{{ nuxtApp }}/api`.',
65
66
  '',
66
67
  '### Routes vs tables (custom endpoints, handlers, hooks)',
@@ -75,15 +76,18 @@ export function buildMcpServerInstructions(apiBaseUrl) {
75
76
  '### After a new table is created',
76
77
  '- MCP **`create_table` supports creating columns and relations in the same call**: pass `columns` and `relations` as JSON arrays. Use `create_relation` only when adding a relation to an existing table later.',
77
78
  '- MCP **`create_table` supports `isSingleRecord` directly**. Set `isSingleRecord: true` in the create call for settings/config tables that should keep only one record; do not create first and then patch only for this flag.',
79
+ '- MCP **`create_table` and `update_table` support `indexes` and `uniques`** as JSON arrays of logical field groups. Use compound indexes for hot filters and unread/read state, e.g. `indexes: [["member","is_read","conversation"],["conversation","member","is_read"]]` and `uniques: [["message","member"]]`. Relation property names are allowed; Enfyra resolves them to physical FK columns for SQL and Mongo.',
78
80
  '- MCP **`create_table` does not accept `alias`**. Do not invent or send alias during table creation; default route/schema behavior is based on `name`. Use `update_table` later only when alias truly needs to change.',
79
81
  '- In `create_table.relations`, each relation uses `targetTable` (table id or `{id}`), `type`, `propertyName`, optional `inversePropertyName` or `mappedBy`, `isNullable`, `onDelete`, and `description`. The target table must already exist.',
80
82
  '- **Use `user_definition` as the only user table.** Do not create app-specific user/profile mapping tables such as `chat_profile`, `app_user`, `customer_user`, or tables that only mirror/link Enfyra users. If an app needs extra user fields or user relations, add columns/relations directly on `user_definition`.',
81
83
  '- When modeling features that involve users, relate domain tables directly to `user_definition` through real Enfyra relations. Examples: `chat_conversation_member.member` → `user_definition`, `chat_message.sender` → `user_definition`, `order.customer` → `user_definition`. Do not create duplicate scalar columns like `userId` or separate profile records just to point back to a user.',
84
+ '- **Do not create reverse relations on `user_definition` by default.** For domain records that point to users, create only the owning relation on the domain table, e.g. `chat_message.sender -> user_definition`, and omit `inversePropertyName` unless the user explicitly asks for a reverse user field. Reverse fields like `user_definition.chatMessages`, `user_definition.orders`, or `user_definition.memberships` bloat user metadata and make `fields=*` user queries heavy.',
82
85
  '- **Prefer real relations over relation-shaped columns.** If a field represents another record or list of records, model it as `relations`, not as columns such as `userId`, `author_id`, `categoryIds`, `teamIds`, `itemsJson`, or object/array JSON containing related records. Ask only when the user explicitly wants denormalized snapshot data.',
83
86
  '- Common mapping: one owner record → `many-to-one`; one record has many children → define the child `many-to-one` and use inverse/read deep relation; peer/tag lists → `many-to-many`; one profile/settings row per parent → `one-to-one` when supported by the model.',
84
87
  '- If the user asks to add a foreign key field, interpret it as a relation request unless they explicitly say they need a plain scalar column. Do not create both a relation and a duplicate scalar FK column for the same concept.',
85
88
  '- **Never ask for or provide physical FK column names** when creating/updating relations. Do not include `fkCol`, `fkColumn`, `foreignKeyColumn`, `sourceColumn`, `targetColumn`, `junctionSourceColumn`, or `junctionTargetColumn` in create/update payloads unless you are only displaying existing metadata. Enfyra relation cascade derives physical FK/junction names from `propertyName` and table metadata, then hides FK columns from app form/schema definition.',
86
89
  '- For relation CRUD payloads, the public interface is the relation `propertyName`: example create body uses `"author": {"id": 1}`, not `"authorId"` or a physical FK column. Query/deep/filter keys also use relation `propertyName`.',
90
+ '- **Realtime/chat unread modeling:** unread/read is per user and per message. Do not put `read` or `lastRead` on `chat_conversation` globally. Prefer a join table such as `chat_message_read` with relations `message`, `conversation`, `member`, boolean `is_read`, nullable `read_at`, unique `["message","member"]`, and indexes `["member","is_read","conversation"]` plus `["conversation","member","is_read"]`. The UI can render a dot by checking existence of unread rows instead of counting every unread message.',
87
91
  '- Enfyra creates a **default** route at `/{table_name}` using the table **name** from `create_table` (not the alias). Prefer **`create_route`** for additional or custom paths instead of new tables.',
88
92
  '- **Four REST HTTP operations** on that resource:',
89
93
  ` - **GET** \`${getList}\` — list / filter (query: filter, sort, page, limit, fields, meta).`,
@@ -119,6 +123,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
119
123
  '',
120
124
  '### Dynamic script syntax preference',
121
125
  '- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, and `@THROW400`–`@THROW503`.',
126
+ '- Do not coerce dynamic script values with `String(...)`, `Number(...)`, or `Boolean(...)`. Enfyra payloads, user ids, record ids, and relation ids should keep their runtime type; validate required values and pass them through directly.',
122
127
  '- Use raw `$ctx` only when there is no template macro for the field or helper you need.',
123
128
  '- Preferred: `const result = await @REPOS.main.create({ data: @BODY });`.',
124
129
  '- Avoid: `const result = await $ctx.$repos.main.create({ data: $ctx.$body });` unless the script truly needs an unmapped `$ctx` property.',
@@ -139,21 +144,23 @@ export function buildMcpServerInstructions(apiBaseUrl) {
139
144
  '- Enfyra exposes OAuth callback at **`{ENFYRA_API_URL}/auth/{provider}/callback`**. Example when `ENFYRA_API_URL` is `http://localhost:3000/api`: **Google** callback URL is **`http://localhost:3000/api/auth/google/callback`** — i.e. `{ENFYRA_API_URL}/auth/google/callback` (same pattern for Facebook/GitHub: `.../auth/facebook/callback`, `.../auth/github/callback`).',
140
145
  '- **Google Cloud Console** → OAuth client → **Authorized redirect URIs**: register **exactly** that URL (scheme + host + path, no typo, no extra slash).',
141
146
  '- **Enfyra** (`oauth_config_definition` / OAuth settings): field **`redirectUri`** must be the **same string** as in Google Console — byte-for-byte. If they differ, Google or the server will reject the flow.',
142
- '- **SSR/cookie mode:** enable `autoSetCookies` / set-cookies mode in Enfyra OAuth config. Enfyra redirects to the SSR app endpoint `/api/auth/set-cookies`, which stores httpOnly cookies and then redirects to the original `redirect` URL.',
147
+ '- **Cloud-style proxy mode:** generated Nuxt/Next apps should start OAuth through the same-origin proxy, e.g. `/enfyra/auth/google?redirect=...`, and let Enfyra handle the auth response. Do not generate a set-cookie route unless the user explicitly chooses a custom SSR auth boundary.',
143
148
  '- **Manual token mode only:** `appCallbackUrl` is the frontend URL where Enfyra redirects after OAuth with `accessToken`, `refreshToken`, etc. in query. Use this only when the app intentionally manages tokens itself; it is not the preferred Nuxt/Next SSR pattern.',
144
149
  '',
145
150
  '**Server flow (for answering users or designing FE):**',
146
- '1. **Start login (redirect user in browser):** SSR/cookie apps use `GET {{ nuxtApp }}/api/auth/{provider}?redirect=<URL_ENCODED>`; direct/manual apps may use `GET {base}/auth/{provider}?redirect=<URL_ENCODED>`. `redirect` is required and is where to send the user after the whole flow.',
151
+ '1. **Start login (redirect user in browser):** Cloud-style apps use `GET /enfyra/auth/{provider}?redirect=<URL_ENCODED>` from the app origin; direct/manual apps may use `GET {base}/auth/{provider}?redirect=<URL_ENCODED>`. `redirect` is required and is where to send the user after the whole flow.',
147
152
  '2. Server **302** to Google/Facebook/GitHub authorization page.',
148
153
  '3. Provider calls back: `GET {base}/auth/{provider}/callback?code=...&state=...` (server exchanges code, creates/links user, issues JWT).',
149
- '4. In SSR/cookie mode, backend redirects to `{{ nuxtApp }}/api/auth/set-cookies` with token query params; the SSR route stores httpOnly cookies and redirects to `redirect`. In manual token mode, backend redirects to `appCallbackUrl` with token query params. On failure, redirect includes `?error=...`.',
154
+ '4. In Cloud-style proxy mode, Enfyra owns the auth response behind the app proxy. In manual token mode, backend redirects to `appCallbackUrl` with token query params. On failure, redirect includes `?error=...`.',
150
155
  '',
151
156
  '**Frontend build checklist:**',
152
- '- Nuxt/Next SSR: implement same-origin API proxy at `/api/**`, a password login route at `/api/login`, and OAuth set-cookie route at `/api/auth/set-cookies`. Browser code never stores JWTs.',
153
- '- **“Login with Google” button in SSR/cookie apps:** `location.href = `/api/auth/google?redirect=${encodeURIComponent(returnUrl)}``.',
157
+ '- Nuxt/Next generated apps: implement a same-origin API proxy such as `/enfyra/**` to the Enfyra API. Browser code never stores JWTs.',
158
+ '- **Password login:** `$fetch("/enfyra/login", { method: "POST", body })`.',
159
+ '- **Fetch user/logout:** `$fetch("/enfyra/me")` and `$fetch("/enfyra/logout", { method: "POST" })`.',
160
+ '- **“Login with Google” button:** `location.href = `/enfyra/auth/google?redirect=${encodeURIComponent(returnUrl)}``.',
154
161
  '- Manual token apps only: register `appCallbackUrl`, read token query params there, strip the URL, store tokens, and use `Authorization: Bearer`.',
155
162
  '- **Error handling:** If redirected with `?error=`, show message to user.',
156
- '- **Do not confuse:** Google’s **Authorized redirect URI** = Enfyra **`redirectUri`** = backend/proxy `{API_BASE}/auth/google/callback`. SSR set-cookie route = app-owned `/api/auth/set-cookies`. `appCallbackUrl` is only for manual token mode.',
163
+ '- **Do not confuse:** Google’s **Authorized redirect URI** = Enfyra **`redirectUri`** = backend/proxy `{API_BASE}/auth/google/callback`. `appCallbackUrl` is only for manual token mode.',
157
164
  '',
158
165
  '### System tables — which have REST routes',
159
166
  '- **Not all system tables have a REST route.** `query_table`, `find_one_record`, `create_record`, etc. all go through the dynamic REST API and will return **404** if the table has no registered route.',
@@ -42,6 +42,16 @@ function parseJsonArrayParam(name, value) {
42
42
  return parsed;
43
43
  }
44
44
 
45
+ function normalizeConstraintGroups(name, groups) {
46
+ return groups.map((group, index) => {
47
+ const value = Array.isArray(group) ? group : group?.value;
48
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || !item.trim())) {
49
+ throw new Error(`${name}[${index}] must be a non-empty string array or { "value": [...] }.`);
50
+ }
51
+ return value;
52
+ });
53
+ }
54
+
45
55
  function normalizeRelationForTablePatch(relation) {
46
56
  const {
47
57
  sourceTable,
@@ -102,6 +112,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
102
112
  'Create a new table definition with an auto-included `id` primary key column.',
103
113
  '**Not** for adding a custom API path or handler only — for that use **`create_route`** with an existing `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
104
114
  'PREFERRED: pass `columns` and `relations` params as JSON arrays to create a table WITH columns and relations in one call (cascade). Only use create_column/create_relation separately when adding to an existing table later.',
115
+ 'Indexes and uniques are first-class table metadata. Use `indexes` for query performance and `uniques` for data integrity. Each entry is a logical field group such as [["member","is_read","conversation"]] or [{"value":["message","member"]}]. Relation property names are allowed; Enfyra resolves them to physical FK columns.',
105
116
  'Relations are supported in this same create_table call when the target table already exists. Each relation uses { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete? }; targetTable may be a table id or {id}.',
106
117
  'Do NOT provide physical FK/junction columns. Never include fkCol, fkColumn, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn. Enfyra derives and hides those physical columns from relation propertyName/table metadata.',
107
118
  'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
@@ -119,13 +130,19 @@ export function registerTableTools(server, ENFYRA_API_URL) {
119
130
  isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to table_definition create.'),
120
131
  columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, defaultValue?, description?, options? }. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"status","type":"enum","options":["draft","published"]}]'),
121
132
  relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id or {"id": <id>}. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Example: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
133
+ indexes: z.string().optional().describe('JSON array of logical index field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Relation property names are allowed. Example: [["member","is_read","conversation"],["conversation","member","is_read"]]'),
134
+ uniques: z.string().optional().describe('JSON array of logical unique field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Example: [["message","member"]]'),
122
135
  },
123
- async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson }) => {
136
+ async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson }) => {
124
137
  const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
125
138
  const userColumns = parseJsonArrayParam('columns', columnsJson);
126
139
  const userRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
140
+ const indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
141
+ const uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
127
142
  const body = { name, description, columns: [idColumn, ...userColumns], relations: userRelations };
128
143
  if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
144
+ if (indexesJson !== undefined) body.indexes = indexes;
145
+ if (uniquesJson !== undefined) body.uniques = uniques;
129
146
  const result = await fetchAPI(ENFYRA_API_URL, '/table_definition', {
130
147
  method: 'POST',
131
148
  body: JSON.stringify(body),
@@ -144,8 +161,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
144
161
  const relHint = userRelations.length
145
162
  ? `Relation(s) created in same call: ${userRelations.length}.`
146
163
  : `No relations were included in this create_table call.`;
164
+ const constraintHint = [
165
+ indexes.length ? `Index group(s): ${indexes.length}.` : null,
166
+ uniques.length ? `Unique group(s): ${uniques.length}.` : null,
167
+ ].filter(Boolean).join(' ');
147
168
  return {
148
- content: [{ type: 'text', text: `${colHint}\n${relHint}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
169
+ content: [{ type: 'text', text: `${colHint}\n${relHint}${constraintHint ? `\n${constraintHint}` : ''}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
149
170
  };
150
171
  }
151
172
  );
@@ -155,8 +176,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
155
176
  server.tool(
156
177
  'update_table',
157
178
  [
158
- 'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled.',
179
+ 'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled, indexes, and uniques.',
159
180
  'Does NOT modify columns or relations — use create_column, update_column, delete_column, create_relation for those.',
181
+ 'When passing `indexes` or `uniques`, pass the complete desired array of logical field groups; omitted fields are preserved. Relation property names are allowed and are resolved by Enfyra. Example indexes: [["member","is_read","conversation"],["conversation","member","is_read"]].',
160
182
  'Run schema changes sequentially — migration locks DB per operation.',
161
183
  ].join(' '),
162
184
  {
@@ -166,19 +188,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
166
188
  description: z.string().optional().describe('New description.'),
167
189
  isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
168
190
  graphqlEnabled: z.boolean().optional().describe('Enable or disable GraphQL for this table by syncing gql_definition.isEnabled. GraphQL still requires Bearer auth.'),
191
+ indexes: z.string().optional().describe('Complete JSON array of logical index field groups to store on table_definition.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear.'),
192
+ uniques: z.string().optional().describe('Complete JSON array of logical unique field groups to store on table_definition.uniques. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current uniques; pass [] to clear.'),
169
193
  },
170
- async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled }) => {
194
+ async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson }) => {
171
195
  const body = {};
172
196
  if (name !== undefined) body.name = name;
173
197
  if (alias !== undefined) body.alias = alias;
174
198
  if (description !== undefined) body.description = description;
175
199
  if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
176
200
  if (graphqlEnabled !== undefined) body.graphqlEnabled = graphqlEnabled;
201
+ if (indexesJson !== undefined) body.indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
202
+ if (uniquesJson !== undefined) body.uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
177
203
 
178
- const result = await fetchAPI(ENFYRA_API_URL, `/table_definition/${tableId}`, {
179
- method: 'PATCH',
180
- body: JSON.stringify(body),
181
- });
204
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, body);
182
205
  return {
183
206
  content: [{ type: 'text', text: `Table ${tableId} updated.\n\n${JSON.stringify(result, null, 2)}` }],
184
207
  };
@@ -591,6 +591,8 @@ server.tool(
591
591
  preHook: {
592
592
  runs: 'Before handler.',
593
593
  data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@CACHE', '@HELPERS', '@THROW*', '@SOCKET emit helpers'],
594
+ queryContract: '@QUERY.filter is initialized as an object. When adding RLS or tenant filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
595
+ rlsPattern: 'For relation-scoped reads, mutate @QUERY.filter instead of returning data. Example: const incomingFilter = @QUERY.filter; const scope = { memberships: { member: { id: { _eq: @USER.id } } } }; @QUERY.filter = Object.keys(incomingFilter).length ? { _and: [incomingFilter, scope] } : scope;',
594
596
  returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
595
597
  },
596
598
  handler: {