diff --git a/example/mail_cruncher/gmail/README.md b/example/mail_cruncher/gmail/README.md new file mode 100644 index 00000000..c67e944d --- /dev/null +++ b/example/mail_cruncher/gmail/README.md @@ -0,0 +1,294 @@ +# Gmail Resolver + +A comprehensive Agentlang resolver for Gmail integration, providing full CRUD operations and real-time subscriptions for emails, labels, and attachments. + +## Quick Start + +1. **Install dependencies**: +```bash +pnpm install +``` + +2. **Set environment variables**: + +**Option A: Direct Access Token (Testing)** +```bash +export GMAIL_ACCESS_TOKEN="your-access-token-here" +export GMAIL_POLL_INTERVAL_MINUTES="15" # Optional: Polling interval for subscriptions +export GMAIL_POLL_MINUTES="10" # Optional: How far back to poll emails (default: 10 minutes) +``` + +**Option B: OAuth2 Client Credentials (Production)** +```bash +export GMAIL_CLIENT_ID="your-client-id-here" +export GMAIL_CLIENT_SECRET="your-client-secret-here" +export GMAIL_REFRESH_TOKEN="your-refresh-token-here" +export GMAIL_POLL_INTERVAL_MINUTES="15" # Optional: Polling interval for subscriptions +export GMAIL_POLL_MINUTES="10" # Optional: How far back to poll emails (default: 10 minutes) +``` + +3. **Run the resolver**: +```bash +agent run +``` + +## Environment Variables + +The resolver supports two authentication methods: + +### Method 1: Direct Access Token (Recommended for testing) + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `GMAIL_ACCESS_TOKEN` | Gmail API access token | - | `ya29.a0AfH6SMC...` | +| `GMAIL_POLL_INTERVAL_MINUTES` | Polling interval for subscriptions | `15` | `10` | +| `GMAIL_POLL_MINUTES` | How far back to poll emails (in minutes) | `10` | `30` | + +### Method 2: OAuth2 Client Credentials (Recommended for production) + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `GMAIL_CLIENT_ID` | Google OAuth2 Client ID | - | `123456789.apps.googleusercontent.com` | +| `GMAIL_CLIENT_SECRET` | Google OAuth2 Client Secret | - | `GOCSPX-abcdef123456` | +| `GMAIL_REFRESH_TOKEN` | OAuth2 Refresh Token | - | `1//04abcdef123456` | +| `GMAIL_POLL_INTERVAL_MINUTES` | Polling interval for subscriptions | `15` | `10` | +| `GMAIL_POLL_MINUTES` | How far back to poll emails (in minutes) | `10` | `30` | + +### Getting Gmail Credentials + +#### For Direct Access Token: +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the Gmail API +4. Create credentials (OAuth 2.0 Client ID) +5. Generate an access token with the required scopes: + - `https://www.googleapis.com/auth/gmail.readonly` + - `https://www.googleapis.com/auth/gmail.send` + - `https://www.googleapis.com/auth/gmail.modify` + +#### For OAuth2 Client Credentials: +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the Gmail API +4. Go to **Credentials** → **Create Credentials** → **OAuth 2.0 Client ID** +5. Configure the OAuth consent screen +6. Set up OAuth 2.0 client ID with the following scopes: + - `https://www.googleapis.com/auth/gmail.readonly` + - `https://www.googleapis.com/auth/gmail.send` + - `https://www.googleapis.com/auth/gmail.modify` +7. Download the credentials and use the client ID, client secret, and refresh token + +### OAuth2 Authentication Flow + +The resolver automatically handles OAuth2 authentication when using client credentials: + +1. **Token Request**: When no direct access token is provided, the resolver makes a POST request to `https://oauth2.googleapis.com/token` +2. **Refresh Token**: Uses `grant_type=refresh_token` with your client ID, secret, and refresh token +3. **Token Caching**: Automatically caches the access token and refreshes it before expiry +4. **Error Handling**: Provides clear error messages if authentication fails + +## API Reference + +### Emails + +#### Create Email +```http +POST /gmail/Email +{ + "from": "sender@example.com", + "to": "recipient@example.com", + "subject": "Test Email", + "body": "This is a test email", + "headers": "{\"X-Custom-Header\": \"value\"}" +} +``` + +#### Query Emails +```http +GET /gmail/Email +GET /gmail/Email/{id} +``` + +#### Update Email (Add/Remove Labels) +```http +PATCH /gmail/Email/{id} +{ + "add_labels": "INBOX,IMPORTANT", + "remove_labels": "SPAM" +} +``` + +#### Delete Email +```http +DELETE /gmail/Email/{id} +``` + +### Labels + +#### Create Label +```http +POST /gmail/Label +{ + "name": "My Custom Label", + "message_list_visibility": "show", + "label_list_visibility": "labelShow", + "color": "{\"textColor\": \"#000000\", \"backgroundColor\": \"#ff0000\"}" +} +``` + +#### Query Labels +```http +GET /gmail/Label +GET /gmail/Label/{id} +``` + +#### Update Label +```http +PATCH /gmail/Label/{id} +{ + "name": "Updated Label Name", + "color": "{\"textColor\": \"#ffffff\", \"backgroundColor\": \"#000000\"}" +} +``` + +#### Delete Label +```http +DELETE /gmail/Label/{id} +``` + +### Attachments + +#### Query Attachment +```http +GET /gmail/Attachments?message_id={messageId}&attachment_id={attachmentId} +``` + +### Send Email + +#### Send Email +```http +POST /gmail/EmailInput +{ + "from": "sender@example.com", + "to": "recipient@example.com", + "subject": "Test Email", + "body": "This is a test email", + "headers": "{\"X-Custom-Header\": \"value\"}" +} +``` + +#### Query Sent Email +```http +GET /gmail/EmailSentOutput/{id} +``` + +### Fetch Attachment + +#### Fetch Attachment Data +```http +GET /gmail/DocumentInput?thread_id={threadId}&attachment_id={attachmentId} +``` + +## Data Models + +### Email +- `id`: String (unique identifier) +- `sender`: String (from address) +- `recipients`: String (to addresses) +- `date`: String (ISO date string) +- `subject`: String (email subject) +- `body`: String (email body content) +- `thread_id`: String (Gmail thread ID) +- `attachments`: Attachments (array of attachment objects) + +### Label +- `id`: String (unique identifier) +- `name`: String (label name) +- `message_list_visibility`: String (show/hide in message list) +- `label_list_visibility`: String (show/hide in label list) +- `type`: String (label type) +- `messages_total`: Number (total messages with this label) +- `messages_unread`: Number (unread messages with this label) +- `threads_total`: Number (total threads with this label) +- `threads_unread`: Number (unread threads with this label) +- `color`: LabelColor (label color settings) + +### Attachments +- `filename`: String (attachment filename) +- `mime_type`: String (MIME type) +- `size`: Number (file size in bytes) +- `attachment_id`: String (Gmail attachment ID) + +## Subscriptions + +The resolver supports real-time subscriptions for: +- **Emails**: Polls for new emails and updates +- **Labels**: Monitors label changes and updates + +### Email Polling Configuration + +When polling for emails, the resolver uses Gmail's search API to fetch emails from a specific time window: + +- **`GMAIL_POLL_MINUTES`**: Controls how far back in time to poll emails (default: 10 minutes) + - The resolver calculates a timestamp for N minutes ago and uses Gmail's `after:` search query + - Only emails received after this timestamp will be included in the subscription + - Example: If set to `30`, it will poll emails from the last 30 minutes + +- **`GMAIL_POLL_INTERVAL_MINUTES`**: Controls how often to poll for new emails (default: 15 minutes) + - This determines the frequency of subscription checks + - Example: If set to `5`, it will check for new emails every 5 minutes + +**Example Configuration:** +```bash +# Poll emails from the last 30 minutes, check every 5 minutes +export GMAIL_POLL_MINUTES="30" +export GMAIL_POLL_INTERVAL_MINUTES="5" +``` + +## Error Handling + +The resolver provides comprehensive error handling: +- **Authentication Errors**: Clear messages for OAuth2 failures +- **API Errors**: Detailed error information from Gmail API +- **Network Errors**: Timeout and connection error handling +- **Validation Errors**: Input validation with helpful messages + +## Logging + +All operations are logged with the `GMAIL RESOLVER:` prefix: +- Request/response logging +- Error logging with context +- Subscription activity logging +- Authentication status logging + +## Security + +- **Token Management**: Secure token caching and refresh +- **Environment Variables**: Sensitive data stored in environment variables +- **HTTPS Only**: All API calls use HTTPS +- **Scope Validation**: Proper OAuth2 scope validation + +## Setup + +1. **Clone the repository**: +```bash +git clone +cd gmail +``` + +2. **Install dependencies**: +```bash +pnpm install +``` + +3. **Set environment variables**: +```bash +export GMAIL_CLIENT_ID="your-client-id" +export GMAIL_CLIENT_SECRET="your-client-secret" +export GMAIL_REFRESH_TOKEN="your-refresh-token" +``` + +4. **Run the resolver**: +```bash +agent run +``` diff --git a/example/mail_cruncher/gmail/config.al b/example/mail_cruncher/gmail/config.al new file mode 100644 index 00000000..9abd6ffd --- /dev/null +++ b/example/mail_cruncher/gmail/config.al @@ -0,0 +1,11 @@ +{ + "agentlang": { + "service": { + "port": "#js parseInt(getLocalEnv('PORT', '8080'))" + }, + "store": { + "type": "sqlite", + "dbname": "gmail.db" + } + } +} diff --git a/example/mail_cruncher/gmail/package.json b/example/mail_cruncher/gmail/package.json new file mode 100644 index 00000000..82dea25e --- /dev/null +++ b/example/mail_cruncher/gmail/package.json @@ -0,0 +1,14 @@ +{ + "name": "gmail", + "version": "0.0.1", + "type": "module", + "description": "Browser-compatible Gmail resolver for agentlang", + "agentlang": { + "config": [ + "gmail/GmailConfig" + ] + }, + "dependencies": { + "agentlang": "file:../../../agentlang" + } +} diff --git a/example/mail_cruncher/gmail/src/gmail.al b/example/mail_cruncher/gmail/src/gmail.al new file mode 100644 index 00000000..ce7596c3 --- /dev/null +++ b/example/mail_cruncher/gmail/src/gmail.al @@ -0,0 +1,97 @@ +module gmail + +import "resolver.js" @as gmr + +entity GmailConfig { + id UUID @id @default(uuid()), + gmailClientId String, + gmailClientSecret String, + gmailRefreshToken String, + gmailPollIntervalMinutes Int @optional, + gmailPollMinutes Int @optional +} + +entity Attachments { + filename String @optional, + mime_type String @optional, + size Number @optional, + attachment_id String @optional +} + +entity Email { + id UUID @id @default(uuid()), + sender String @optional, + recipients String @optional, + date String @optional, + subject String @optional, + body String @optional, + thread_id String @optional, + attachments Attachments @optional +} + +entity LabelColor { + text_color String @optional, + background_color String @optional +} + +entity Label { + id UUID @id @default(uuid()), + name String @optional, + message_list_visibility String @optional, + label_list_visibility String @optional, + type String @optional, + messages_total Number @optional, + messages_unread Number @optional, + threads_total Number @optional, + threads_unread Number @optional, + color LabelColor @optional +} + +entity EmailInput { + from String @optional, + to String @optional, + headers String @optional, + subject String @optional, + body String @optional +} + +entity EmailSentOutput { + id String @optional, + thread_id String @optional +} + +entity DocumentInput { + thread_id String @optional, + attachment_id String @optional +} + +resolver gmail1 [gmail/Email] { + create gmr.createEmail, + query gmr.queryEmail, + update gmr.updateEmail, + delete gmr.deleteEmail + subscribe gmr.subsEmails +} + +resolver gmail2 [gmail/Label] { + create gmr.createLabel, + query gmr.queryLabel, + update gmr.updateLabel, + delete gmr.deleteLabel +} + +resolver gmail3 [gmail/Attachments] { + query gmr.queryAttachments +} + +resolver gmail4 [gmail/EmailInput] { + create gmr.sendEmail +} + +resolver gmail5 [gmail/EmailSentOutput] { + query gmr.queryEmailSent +} + +resolver gmail6 [gmail/DocumentInput] { + query gmr.fetchAttachment +} diff --git a/example/mail_cruncher/gmail/src/resolver.js b/example/mail_cruncher/gmail/src/resolver.js new file mode 100644 index 00000000..315d9dff --- /dev/null +++ b/example/mail_cruncher/gmail/src/resolver.js @@ -0,0 +1,694 @@ +// Import agentlang modules +import { makeInstance } from 'agentlang/out/runtime/module.js'; +import { getLocalEnv } from 'agentlang/out/runtime/auth/defs.js'; +import { createSubscriptionEnvelope } from 'agentlang/out/runtime/resolvers/interface.js'; + +// Mapper functions for Gmail API responses to Agentlang entities +function toEmail(messageDetail, headers) { + const parts = messageDetail.payload?.parts || []; + const bodyObj = { body: '' }; + const attachments = []; + + if (parts.length > 0) { + processParts(parts, bodyObj, attachments); + } else if (messageDetail.payload?.body?.data) { + // Handle simple API-sent emails with direct body data + bodyObj.body = Buffer.from(messageDetail.payload.body.data, 'base64').toString('utf8'); + } else if (messageDetail.snippet) { + bodyObj.body = messageDetail.snippet; + } + + return { + id: messageDetail.id, + sender: headers['From'], + recipients: headers['To'], + date: new Date(parseInt(messageDetail.internalDate)).toISOString(), + subject: headers['Subject'], + body: bodyObj.body, + thread_id: messageDetail.threadId, + attachments: attachments.length > 0 ? attachments : null, + }; +} + +function toLabel(label) { + return { + id: label.id, + name: label.name, + message_list_visibility: label.messageListVisibility, + label_list_visibility: label.labelListVisibility, + type: label.type, + messages_total: label.messagesTotal, + messages_unread: label.messagesUnread, + threads_total: label.threadsTotal, + threads_unread: label.threadsUnread, + color: label.color + ? { + text_color: label.color.textColor, + background_color: label.color.backgroundColor, + } + : null, + }; +} + +function processParts(parts, bodyObj, attachments) { + for (const part of parts) { + if (part.mimeType === 'text/plain' && part.body?.data && !bodyObj.body) { + bodyObj.body = Buffer.from(part.body.data, 'base64').toString('utf8'); + } else if (part.mimeType === 'text/html' && part.body?.data && !bodyObj.body) { + bodyObj.body = Buffer.from(part.body.data, 'base64').toString('utf8'); + } else if (part.filename && part.body?.attachmentId) { + if (part.mimeType && part.body?.size !== undefined && part.body?.size !== null) { + attachments.push({ + filename: part.filename, + mime_type: part.mimeType, + size: part.body.size, + attachment_id: part.body.attachmentId, + }); + } + } + if (part.parts?.length) { + processParts(part.parts, bodyObj, attachments); + } + } +} + +function asInstance(entity, entityType) { + const instanceMap = new Map(Object.entries(entity)); + return makeInstance('gmail', entityType, instanceMap); +} + +const getResponseBody = async response => { + try { + try { + return await response.json(); + } catch { + return await response.text(); + } + } catch (error) { + console.error('GMAIL RESOLVER: Error reading response body:', error); + return {}; + } +}; + +// OAuth2 token management +let accessToken = null; +let tokenExpiry = null; + +async function getAccessToken() { + // Return cached token if still valid + if (accessToken && tokenExpiry && Date.now() < tokenExpiry) { + return accessToken; + } + + const clientId = getLocalEnv('GMAIL_CLIENT_ID'); + const clientSecret = getLocalEnv('GMAIL_CLIENT_SECRET'); + const refreshToken = getLocalEnv('GMAIL_REFRESH_TOKEN'); + + if (!clientId || !clientSecret || !refreshToken) { + throw new Error( + 'Gmail OAuth2 configuration is required: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, and GMAIL_REFRESH_TOKEN' + ); + } + + try { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + }); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OAuth2 token request failed: ${response.status} - ${errorText}`); + } + + const tokenData = await response.json(); + + if (!tokenData.access_token) { + throw new Error('No access token received from Gmail OAuth2'); + } + + accessToken = tokenData.access_token; + // Set expiry time (subtract 5 minutes for safety) + tokenExpiry = Date.now() + ((tokenData.expires_in || 3600) - 300) * 1000; + + return accessToken; + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to get access token: ${error}`); + throw error; + } +} + +// Generic HTTP functions +const makeRequest = async (endpoint, options = {}) => { + let token = getLocalEnv('GMAIL_ACCESS_TOKEN'); + + // If no direct token provided, try to get one via OAuth2 + if (!token) { + try { + token = await getAccessToken(); + } catch (error) { + throw new Error(`Gmail authentication failed: ${error.message}`); + } + } + + if (!token) { + throw new Error('Gmail access token is required'); + } + + const baseUrl = 'https://gmail.googleapis.com'; + const url = `${baseUrl}${endpoint}`; + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }; + + const config = { ...defaultOptions, ...options }; + + // Remove Content-Type header for GET requests without body + if (config.method === 'GET') { + delete config.headers['Content-Type']; + } + + const timeoutMs = 30000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + console.error( + `GMAIL RESOLVER: Request timeout after ${timeoutMs}ms - ${url} - ${JSON.stringify(options)}` + ); + controller.abort(); + }, timeoutMs); + + try { + const response = await fetch(url, { + ...config, + signal: controller.signal, + }); + + const body = await getResponseBody(response); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.error( + `GMAIL RESOLVER: HTTP Error ${response.status} - ${url} - ${JSON.stringify(options)}` + ); + throw new Error(`HTTP Error: ${response.status} - ${JSON.stringify(body)}`); + } + + return body; + } catch (error) { + clearTimeout(timeoutId); + + if (error.name === 'AbortError') { + console.error(`GMAIL RESOLVER: Request timeout - ${url} - ${JSON.stringify(options)}`); + } else if ( + error.code === 'ENOTFOUND' || + error.code === 'ECONNREFUSED' || + error.code === 'EHOSTUNREACH' + ) { + console.error( + `GMAIL RESOLVER: Network unreachable (${error.code}) - ${url} - ${JSON.stringify(options)}` + ); + } else if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') { + console.error( + `GMAIL RESOLVER: Connection error (${error.code}) - ${url} - ${JSON.stringify(options)}` + ); + } else { + console.error( + `GMAIL RESOLVER: Request failed (${error.name}) - ${url} - ${JSON.stringify(options)}` + ); + } + + throw error; + } +}; + +const makeGetRequest = async endpoint => { + return await makeRequest(endpoint, { method: 'GET' }); +}; + +const makePostRequest = async (endpoint, body) => { + return await makeRequest(endpoint, { + method: 'POST', + body: JSON.stringify(body), + }); +}; + +const makePatchRequest = async (endpoint, body) => { + return await makeRequest(endpoint, { + method: 'PATCH', + body: JSON.stringify(body), + }); +}; + +const makeDeleteRequest = async endpoint => { + return await makeRequest(endpoint, { method: 'DELETE' }); +}; + +// Email functions +export const createEmail = async (env, attributes) => { + const from = attributes.attributes.get('sender'); + const to = attributes.attributes.get('recipients'); + const subject = attributes.attributes.get('subject'); + const body = attributes.attributes.get('body'); + const headers = attributes.attributes.get('headers'); + const threadId = attributes.attributes.get('thread_id'); + + let headerString = ''; + if (headers) { + try { + const headerObj = JSON.parse(headers); + Object.entries(headerObj).forEach(([key, value]) => { + headerString += `${key}: ${value}\n`; + }); + } catch { + console.warn('GMAIL RESOLVER: Invalid headers format, ignoring'); + } + } + + const email = `From: ${from}\nTo: ${to}\n${headerString}Subject: ${subject}\n\n${body}`; + const base64EncodedEmail = Buffer.from(email).toString('base64'); + + try { + const requestBody = { raw: base64EncodedEmail }; + if (threadId) { + requestBody.threadId = threadId; + } + + const result = await makePostRequest('/gmail/v1/users/me/messages/send', requestBody); + return { result: 'success', id: result.id, thread_id: result.threadId }; + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to create email: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +export const queryEmail = async (env, attrs) => { + const id = attrs.queryAttributeValues?.get('__path__')?.split('/')?.pop() ?? null; + + try { + if (id) { + const messageDetail = await makeGetRequest(`/gmail/v1/users/me/messages/${id}`); + const headers = + messageDetail.payload?.headers?.reduce((acc, current) => { + return { + ...acc, + [current.name]: current.value, + }; + }, {}) || {}; + const mappedData = toEmail(messageDetail, headers); + return [asInstance(mappedData, 'Email')]; + } else { + // Get list of messages + const response = await makeGetRequest('/gmail/v1/users/me/messages?maxResults=100'); + const messageList = response.messages || []; + const emails = []; + + for (const message of messageList) { + const messageDetail = await makeGetRequest(`/gmail/v1/users/me/messages/${message.id}`); + const headers = + messageDetail.payload?.headers?.reduce((acc, current) => { + return { + ...acc, + [current.name]: current.value, + }; + }, {}) || {}; + const mappedData = toEmail(messageDetail, headers); + emails.push(asInstance(mappedData, 'Email')); + } + return emails; + } + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to query emails: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +export const updateEmail = async (env, attributes, newAttrs) => { + const id = attributes.attributes.get('id'); + if (!id) { + return { result: 'error', message: 'Email ID is required for update' }; + } + + // Gmail doesn't support updating emails directly, but we can add/remove labels + const addLabels = newAttrs.get('add_labels'); + const removeLabels = newAttrs.get('remove_labels'); + + try { + const data = {}; + if (addLabels) { + data.addLabelIds = addLabels.split(',').map(label => label.trim()); + } + if (removeLabels) { + data.removeLabelIds = removeLabels.split(',').map(label => label.trim()); + } + + if (Object.keys(data).length === 0) { + return { + result: 'error', + message: 'No valid update operations provided', + }; + } + + const result = await makePostRequest(`/gmail/v1/users/me/messages/${id}/modify`, data); + return asInstance({ id: result.id }, 'Email'); + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to update email: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +export const deleteEmail = async (env, attributes) => { + const id = attributes.attributes.get('id'); + if (!id) { + return { result: 'error', message: 'Email ID is required for deletion' }; + } + + try { + await makeDeleteRequest(`/gmail/v1/users/me/messages/${id}`); + return { result: 'success' }; + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to delete email: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +// Label functions +export const createLabel = async (env, attributes) => { + const data = { + name: attributes.attributes.get('name'), + messageListVisibility: attributes.attributes.get('message_list_visibility') || 'show', + labelListVisibility: attributes.attributes.get('label_list_visibility') || 'labelShow', + color: attributes.attributes.get('color') + ? JSON.parse(attributes.attributes.get('color')) + : undefined, + }; + + try { + const result = await makePostRequest('/gmail/v1/users/me/labels', data); + return { result: 'success', id: result.id }; + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to create label: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +export const queryLabel = async (env, attrs) => { + const id = attrs.queryAttributeValues?.get('__path__')?.split('/')?.pop() ?? null; + + try { + let inst; + if (id) { + inst = await makeGetRequest(`/gmail/v1/users/me/labels/${id}`); + } else { + inst = await makeGetRequest('/gmail/v1/users/me/labels'); + inst = inst.labels || []; + } + if (!(inst instanceof Array)) { + inst = [inst]; + } + return inst.map(data => { + const mappedData = toLabel(data); + return asInstance(mappedData, 'Label'); + }); + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to query labels: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +export const updateLabel = async (env, attributes, newAttrs) => { + const id = attributes.attributes.get('id'); + if (!id) { + return { result: 'error', message: 'Label ID is required for update' }; + } + + const data = {}; + if (newAttrs.get('name')) { + data.name = newAttrs.get('name'); + } + if (newAttrs.get('message_list_visibility')) { + data.messageListVisibility = newAttrs.get('message_list_visibility'); + } + if (newAttrs.get('label_list_visibility')) { + data.labelListVisibility = newAttrs.get('label_list_visibility'); + } + if (newAttrs.get('color')) { + data.color = JSON.parse(newAttrs.get('color')); + } + + try { + const result = await makePatchRequest(`/gmail/v1/users/me/labels/${id}`, data); + return asInstance(toLabel(result), 'Label'); + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to update label: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +export const deleteLabel = async (env, attributes) => { + const id = attributes.attributes.get('id'); + if (!id) { + return { result: 'error', message: 'Label ID is required for deletion' }; + } + + try { + await makeDeleteRequest(`/gmail/v1/users/me/labels/${id}`); + return { result: 'success' }; + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to delete label: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +// Attachment functions +export const queryAttachments = async (env, attrs) => { + const messageId = attrs.queryAttributeValues?.get('message_id'); + const attachmentId = attrs.queryAttributeValues?.get('attachment_id'); + + if (!messageId || !attachmentId) { + return { + result: 'error', + message: 'Message ID and Attachment ID are required', + }; + } + + try { + const result = await makeGetRequest( + `/gmail/v1/users/me/messages/${messageId}/attachments/${attachmentId}` + ); + return asInstance( + { + data: result.data, + size: result.size, + }, + 'Attachments' + ); + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to query attachment: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +// Send email function +export const sendEmail = async (env, attributes) => { + const from = attributes.attributes.get('from'); + const to = attributes.attributes.get('to'); + const subject = attributes.attributes.get('subject'); + const body = attributes.attributes.get('body'); + const headers = attributes.attributes.get('headers'); + + let headerString = ''; + if (headers) { + try { + const headerObj = JSON.parse(headers); + Object.entries(headerObj).forEach(([key, value]) => { + headerString += `${key}: ${value}\n`; + }); + } catch { + console.warn('GMAIL RESOLVER: Invalid headers format, ignoring'); + } + } + + const email = `From: ${from}\nTo: ${to}\n${headerString}Subject: ${subject}\n\n${body}`; + const base64EncodedEmail = Buffer.from(email).toString('base64'); + + try { + const result = await makePostRequest('/gmail/v1/users/me/messages/send', { + raw: base64EncodedEmail, + }); + return asInstance( + { + id: result.id, + thread_id: result.threadId, + }, + 'EmailSentOutput' + ); + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to send email: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +// Query email sent +export const queryEmailSent = async (env, attrs) => { + const id = attrs.queryAttributeValues?.get('__path__')?.split('/')?.pop() ?? null; + + if (!id) { + return { result: 'error', message: 'Email ID is required' }; + } + + try { + const result = await makeGetRequest(`/gmail/v1/users/me/messages/${id}`); + return [ + asInstance( + { + id: result.id, + thread_id: result.threadId, + }, + 'EmailSentOutput' + ), + ]; + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to query email sent: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +// Fetch attachment +export const fetchAttachment = async (env, attributes) => { + const threadId = attributes.attributes.get('thread_id'); + const attachmentId = attributes.attributes.get('attachment_id'); + + if (!threadId || !attachmentId) { + return { + result: 'error', + message: 'Thread ID and Attachment ID are required', + }; + } + + try { + // First get the message to find the correct message ID + const messages = await makeGetRequest(`/gmail/v1/users/me/threads/${threadId}`); + const messageId = messages.messages?.[0]?.id; + + if (!messageId) { + return { result: 'error', message: 'No messages found in thread' }; + } + + const result = await makeGetRequest( + `/gmail/v1/users/me/messages/${messageId}/attachments/${attachmentId}` + ); + return result.data; // Return base64 encoded attachment data + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to fetch attachment: ${error}`); + return { result: 'error', message: error.message }; + } +}; + +// Subscription functions for real-time updates +async function getAndProcessRecords(resolver, entityType) { + try { + const tenantId = getLocalEnv('GMAIL_TENANT_ID'); + const userId = getLocalEnv('GMAIL_USER_ID'); + + if (!tenantId || !userId) { + console.error( + 'GMAIL RESOLVER: GMAIL_TENANT_ID and GMAIL_USER_ID are required for subscriptions' + ); + return; + } + + let endpoint; + switch (entityType) { + case 'emails': + const pollMinutes = parseInt(getLocalEnv('GMAIL_POLL_MINUTES')) || 10; + const pollSeconds = pollMinutes * 60; + const afterTimestamp = Math.floor((Date.now() - pollSeconds * 1000) / 1000); + + const searchQuery = `after:${afterTimestamp}`; + endpoint = `/gmail/v1/users/me/messages?maxResults=100&q=${encodeURIComponent(searchQuery)}`; + break; + case 'labels': + endpoint = '/gmail/v1/users/me/labels'; + break; + default: + console.error(`GMAIL RESOLVER: Unknown entity type: ${entityType}`); + return; + } + + const result = await makeGetRequest(endpoint); + + if (entityType === 'emails' && result.messages) { + for (let i = 0; i < result.messages.length; ++i) { + const message = result.messages[i]; + + // Get full message details + const messageDetail = await makeGetRequest(`/gmail/v1/users/me/messages/${message.id}`); + const headers = + messageDetail.payload?.headers?.reduce((acc, current) => { + return { + ...acc, + [current.name]: current.value, + }; + }, {}) || {}; + const mappedData = toEmail(messageDetail, headers); + const entityInstance = asInstance(mappedData, 'Email'); + const envelope = createSubscriptionEnvelope(tenantId, userId, entityInstance); + await resolver.onSubscription(envelope, true); + } + } else if (entityType === 'labels' && result.labels) { + for (let i = 0; i < result.labels.length; ++i) { + const label = result.labels[i]; + + const mappedData = toLabel(label); + const entityInstance = asInstance(mappedData, 'Label'); + const envelope = createSubscriptionEnvelope(tenantId, userId, entityInstance); + await resolver.onSubscription(envelope, true); + } + } + } catch (error) { + console.error(`GMAIL RESOLVER: Failed to process ${entityType} records: ${error}`); + } +} + +async function handleSubsEmails(resolver) { + await getAndProcessRecords(resolver, 'emails'); +} + +async function handleSubsLabels(resolver) { + await getAndProcessRecords(resolver, 'labels'); +} + +export async function subsEmails(resolver) { + await handleSubsEmails(resolver); + const intervalMinutes = parseInt(getLocalEnv('GMAIL_POLL_INTERVAL_MINUTES')) || 15; + const intervalMs = intervalMinutes * 60 * 1000; + + setInterval(async () => { + await handleSubsEmails(resolver); + }, intervalMs); +} + +export async function subsLabels(resolver) { + await handleSubsLabels(resolver); + const intervalMinutes = parseInt(getLocalEnv('GMAIL_POLL_INTERVAL_MINUTES')) || 15; + const intervalMs = intervalMinutes * 60 * 1000; + + setInterval(async () => { + await handleSubsLabels(resolver); + }, intervalMs); +} diff --git a/example/mail_cruncher/mail_cruncher/config.al b/example/mail_cruncher/mail_cruncher/config.al new file mode 100644 index 00000000..8130a1e1 --- /dev/null +++ b/example/mail_cruncher/mail_cruncher/config.al @@ -0,0 +1,11 @@ +{ + "agentlang": { + "service": { + "port": "#js parseInt(process.env.SERVICE_PORT || '8080')" + }, + "store": { + "type": "sqlite", + "dbname": "mail_cruncher.db" + } + } +} diff --git a/example/mail_cruncher/mail_cruncher/package.json b/example/mail_cruncher/mail_cruncher/package.json new file mode 100644 index 00000000..294fb50a --- /dev/null +++ b/example/mail_cruncher/mail_cruncher/package.json @@ -0,0 +1,8 @@ +{ + "name": "mail_cruncher", + "version": "0.0.1", + "dependencies": { + "agentlang": "file:../../../agentlang", + "gmail": "file:../gmail" + } +} diff --git a/example/mail_cruncher/mail_cruncher/src/core.al b/example/mail_cruncher/mail_cruncher/src/core.al new file mode 100644 index 00000000..83fe6719 --- /dev/null +++ b/example/mail_cruncher/mail_cruncher/src/core.al @@ -0,0 +1,32 @@ +module mail_cruncher.core + +import "helpers.js" @as helpers + +entity EmailDigest { + id UUID @id @default(uuid()), + sender String, + subject String, + receivedAt String, + snippet String, + processedAt DateTime @default(now()) +} + +// When a new email arrives via the gmail subscription, log it and save a digest +workflow @after create:gmail/Email { + helpers.printEmail(gmail/Email.sender, gmail/Email.subject, gmail/Email.date, gmail/Email.body); + {EmailDigest {sender gmail/Email.sender, + subject gmail/Email.subject, + receivedAt gmail/Email.date, + snippet gmail/Email.body}} +} + +// Manually fetch and print recent emails +@public workflow FetchEmails { + {gmail/Email? {}} @as emails; + helpers.printEmailList(emails) +} + +// Query saved digests +@public workflow ListDigests { + {EmailDigest? {}} +} diff --git a/example/mail_cruncher/mail_cruncher/src/helpers.js b/example/mail_cruncher/mail_cruncher/src/helpers.js new file mode 100644 index 00000000..8710dd0b --- /dev/null +++ b/example/mail_cruncher/mail_cruncher/src/helpers.js @@ -0,0 +1,35 @@ +// Helper functions for printing email data + +export async function printEmail(sender, subject, date, body) { + const divider = '─'.repeat(60); + console.log(divider); + console.log(` From: ${sender || '(unknown)'}`); + console.log(` Subject: ${subject || '(no subject)'}`); + console.log(` Date: ${date || '(no date)'}`); + console.log(divider); + const snippet = body ? body.substring(0, 200) : '(empty)'; + console.log(` ${snippet}`); + if (body && body.length > 200) { + console.log(` ... (${body.length - 200} more characters)`); + } + console.log(divider); + console.log(); + return { printed: true }; +} + +export async function printEmailList(emails) { + if (!emails || emails.length === 0) { + console.log('No emails found.'); + return []; + } + + console.log(`\n=== ${emails.length} email(s) found ===\n`); + for (const email of emails) { + const sender = email.lookup ? email.lookup('sender') : email.sender; + const subject = email.lookup ? email.lookup('subject') : email.subject; + const date = email.lookup ? email.lookup('date') : email.date; + const body = email.lookup ? email.lookup('body') : email.body; + await printEmail(sender, subject, date, body); + } + return emails; +} diff --git a/src/language/agentlang.langium b/src/language/agentlang.langium index 3ad3e1bc..5570e9c5 100644 --- a/src/language/agentlang.langium +++ b/src/language/agentlang.langium @@ -196,7 +196,7 @@ BackoffSpec: 'backoff' '{' (attributes+=SetAttribute (',' attributes+=SetAttribu AgentEvaluatorDefinition: 'eval' name=ID '{' '}' | '{' (attributes+=SetAttribute (',' attributes+=SetAttribute)*)+ '}'; ResolverDefinition: 'resolver' name=QualifiedName '[' (paths+=ResolverPathEntry (',' paths+=ResolverPathEntry)*)+ ']' -'{' (methods+=ResolverMethodSpec (',' methods+=ResolverMethodSpec)*)+ '}'; +'{' (methods+=ResolverMethodSpec (',' methods+=ResolverMethodSpec)*)+ (',' meta=MetaDefinition)? '}'; ResolverPathEntry returns string: QualifiedName | GenericName; diff --git a/src/language/generated/ast.ts b/src/language/generated/ast.ts index 690d55db..0c67250a 100644 --- a/src/language/generated/ast.ts +++ b/src/language/generated/ast.ts @@ -1112,7 +1112,7 @@ export function isMapLiteral(item: unknown): item is MapLiteral { } export interface MetaDefinition extends langium.AstNode { - readonly $container: RecordExtraDefinition; + readonly $container: RecordExtraDefinition | ResolverDefinition; readonly $type: 'MetaDefinition'; spec: MapLiteral; } @@ -1652,6 +1652,7 @@ export function isRelNodes(item: unknown): item is RelNodes { export interface ResolverDefinition extends langium.AstNode { readonly $container: ModuleDefinition; readonly $type: 'ResolverDefinition'; + meta?: MetaDefinition; methods: Array; name: QualifiedName; paths: Array; @@ -1659,6 +1660,7 @@ export interface ResolverDefinition extends langium.AstNode { export const ResolverDefinition = { $type: 'ResolverDefinition', + meta: 'meta', methods: 'methods', name: 'name', paths: 'paths' @@ -3312,6 +3314,9 @@ export class AgentlangAstReflection extends langium.AbstractAstReflection { ResolverDefinition: { name: ResolverDefinition.$type, properties: { + meta: { + name: ResolverDefinition.meta + }, methods: { name: ResolverDefinition.methods, defaultValue: [] diff --git a/src/language/generated/grammar.ts b/src/language/generated/grammar.ts index 4cf7c305..736169bd 100644 --- a/src/language/generated/grammar.ts +++ b/src/language/generated/grammar.ts @@ -5696,6 +5696,28 @@ export const AgentlangGrammar = (): Grammar => loadedAgentlangGrammar ?? (loaded ], "cardinality": "+" }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "," + }, + { + "$type": "Assignment", + "feature": "meta", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@30" + }, + "arguments": [] + } + } + ], + "cardinality": "?" + }, { "$type": "Keyword", "value": "}" diff --git a/src/runtime/interpreter.ts b/src/runtime/interpreter.ts index 4c215c11..3627bc2f 100644 --- a/src/runtime/interpreter.ts +++ b/src/runtime/interpreter.ts @@ -58,6 +58,7 @@ import { Workflow, } from './module.js'; import { JoinInfo, Resolver, WhereClause } from './resolvers/interface.js'; +import { SubscriptionEnvelope, envelopeToSessionInfo } from './resolvers/envelope.js'; import { ResolverAuthInfo } from './resolvers/authinfo.js'; import { SqlDbResolver } from './resolvers/sqldb/impl.js'; import { @@ -180,6 +181,7 @@ export class Environment extends Instance { private activeChatId: string | undefined; private activeUserData: any = undefined; + private activeTenantId: string | undefined; constructor(name?: string, parent?: Environment) { super( @@ -206,6 +208,7 @@ export class Environment extends Instance { this.monitor = parent.monitor; this.escalatedRole = parent.escalatedRole; this.activeChatId = parent.activeChatId; + this.activeTenantId = parent.activeTenantId; } else { this.activeModule = DefaultModuleName; this.activeResolvers = new Map(); @@ -555,6 +558,15 @@ export class Environment extends Instance { return this.activeUser; } + setActiveTenantId(id: string): Environment { + this.activeTenantId = id; + return this; + } + + getActiveTenantId(): string | undefined { + return this.activeTenantId; + } + setLastResult(result: Result): Environment { this.trashedResult = this.lastResult; this.lastResult = result; @@ -2692,10 +2704,16 @@ async function realizeMap(mapLiteral: MapLiteral, env: Environment): Promise { const localEnv = env === undefined; const newEnv = env ? env : new Environment('onSubs.env'); + if (envelope) { + newEnv.setActiveUser(envelope.userId); + newEnv.setActiveTenantId(envelope.tenantId); + inst.setAuthContext(envelopeToSessionInfo(envelope)); + } try { await runPrePostEvents(crudType, false, inst, newEnv); if (localEnv) { @@ -2730,7 +2748,7 @@ async function runPrePostEvents( p.getEntryName(), newInstanceAttributes().set(inst.record.name, inst) ); - const authContext = env.getActiveAuthContext(); + const authContext = env.getActiveAuthContext() || inst.getAuthContext(); if (authContext) eventInst.setAuthContext(authContext); const prefix = `${pre ? 'Pre' : 'Post'}-${CrudType[crudType]} ${inst.record.getFqName()}`; const callback = (value: Result) => { diff --git a/src/runtime/loader.ts b/src/runtime/loader.ts index 20978e02..9383a8a1 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -72,6 +72,7 @@ import { Retry, addGlobalRetry, AgentEvaluator, + normalizeMetaValue, } from './module.js'; import { asStringLiteralsMap, @@ -114,6 +115,14 @@ import { import { getDefaultLLMService } from './agents/registry.js'; import { GenericResolver, GenericResolverMethods } from './resolvers/interface.js'; import { registerResolver, setResolver } from './resolvers/registry.js'; +import { + ConnectionPolicy, + parseConnectionPolicy, + registerConnectionPolicy, + getConnectionPolicy, + PolicyResolver, +} from './resolvers/policy.js'; +import { persistConnectionPolicy } from './modules/policy.js'; import { Config, ConfigSchema, setAppConfig } from './state.js'; import { getModuleFn, importModule } from './jsmodules.js'; import { SetSubscription } from './defs.js'; @@ -1136,6 +1145,20 @@ function addResolverDefinition(def: ResolverDefinition, moduleName: string) { logger.warn(`Resolver has no associated paths - ${resolverName}`); return; } + + // Parse connection policy from @meta + let connectionPolicy: ConnectionPolicy | undefined; + if (def.meta) { + const metaMap = new Map(); + def.meta.spec.entries.forEach((entry: MapEntry) => { + if (entry.key.str) metaMap.set(entry.key.str, normalizeMetaValue(entry.value)); + }); + connectionPolicy = parseConnectionPolicy(resolverName, metaMap); + if (connectionPolicy) { + registerConnectionPolicy(resolverName, connectionPolicy); + } + } + registerInitFunction(() => { const methods = new Map(); let subsFn: Function | undefined; @@ -1151,14 +1174,22 @@ function addResolverDefinition(def: ResolverDefinition, moduleName: string) { } }); const methodsObj = Object.fromEntries(methods.entries()) as GenericResolverMethods; - const resolver = new GenericResolver(resolverName, methodsObj); + + // Persist policy to DB if present + if (connectionPolicy) { + persistConnectionPolicy(resolverName, connectionPolicy, new Environment()); + } + registerResolver(resolverName, () => { - return resolver; + const base = new GenericResolver(resolverName, methodsObj); + const currentPolicy = getConnectionPolicy(resolverName); + return currentPolicy ? new PolicyResolver(base, currentPolicy) : base; }); paths.forEach((path: string) => { setResolver(path, resolverName); }); if (subsFn) { + const resolver = new GenericResolver(resolverName, methodsObj); resolver.subs = { subscribe: subsFn, }; diff --git a/src/runtime/module.ts b/src/runtime/module.ts index b2528a91..b6535215 100644 --- a/src/runtime/module.ts +++ b/src/runtime/module.ts @@ -246,7 +246,7 @@ export enum RecordType { AGENT, } -function normalizeMetaValue(metaValue: any): any { +export function normalizeMetaValue(metaValue: any): any { if (!isLiteral(metaValue)) { throw new Error(`Invalid entry ${metaValue} in meta specification - expected a literal`); } diff --git a/src/runtime/modules/core.ts b/src/runtime/modules/core.ts index b37bffc8..2f82ba65 100644 --- a/src/runtime/modules/core.ts +++ b/src/runtime/modules/core.ts @@ -2,6 +2,7 @@ import { default as ai, normalizeGeneratedCode } from './ai.js'; import { default as auth } from './auth.js'; import { default as files } from './files.js'; import { default as mcp } from './mcp.js'; +import { default as policy, startPolicyRefreshTimer } from './policy.js'; import { DefaultModuleName, DefaultModules, @@ -41,6 +42,7 @@ import { import { getMonitor, getMonitorsForEvent, Monitor } from '../monitor.js'; import { registerResolver, setResolver } from '../resolvers/registry.js'; import { base64Encode, isNodeEnv } from '../../utils/runtime.js'; +import { AppConfig } from '../state.js'; const CoreModuleDefinition = `module ${DefaultModuleName} @@ -188,6 +190,7 @@ export function registerCoreModules() { { def: ai, name: makeCoreModuleName('ai') }, { def: files, name: makeCoreModuleName('files') }, { def: mcp, name: mcpn }, + { def: policy, name: makeCoreModuleName('policy') }, ]; coreModuleInfo.forEach(({ def, name }) => { @@ -662,6 +665,8 @@ export function initCoreModuleManager() { internPersistentModules(); }, 10000); } + + startPolicyRefreshTimer(AppConfig?.resolver?.connectionPolicy?.refreshIntervalSeconds); } const SqlSep = ';\n\n'; diff --git a/src/runtime/modules/policy.ts b/src/runtime/modules/policy.ts new file mode 100644 index 00000000..0f1a45fd --- /dev/null +++ b/src/runtime/modules/policy.ts @@ -0,0 +1,121 @@ +import { makeCoreModuleName } from '../util.js'; +import { makeEventEvaluator, Environment } from '../interpreter.js'; +import { logger } from '../logger.js'; +import { ConnectionPolicy, registerConnectionPolicy } from '../resolvers/policy.js'; + +export const CorePolicyModuleName = makeCoreModuleName('policy'); + +const evalEvent = makeEventEvaluator(CorePolicyModuleName); + +export default `module ${CorePolicyModuleName} + +import "./modules/policy.js" @as Policy + +entity ConnectionPolicy { + id String @id, + resolverName String @unique @indexed, + policyJson String, + @meta {"global": true} +} + +@public workflow UpsertConnectionPolicy { + {ConnectionPolicy {id UpsertConnectionPolicy.id, + resolverName UpsertConnectionPolicy.resolverName, + policyJson UpsertConnectionPolicy.policyJson}, @upsert} +} + +@public workflow FindConnectionPolicy { + {ConnectionPolicy {resolverName? FindConnectionPolicy.resolverName}} @as [p]; + p +} + +@public workflow ListConnectionPolicies { + {ConnectionPolicy? {}} +} +`; + +export async function persistConnectionPolicy( + resolverName: string, + policy: ConnectionPolicy, + env?: Environment +): Promise { + try { + if (!env) env = new Environment(); + const policyJson = JSON.stringify(policy.toJSON()); + await evalEvent( + 'UpsertConnectionPolicy', + { + id: resolverName, + resolverName: resolverName, + policyJson: policyJson, + }, + env + ); + } catch (reason: any) { + logger.warn(`Failed to persist connection policy for ${resolverName}: ${reason}`); + } +} + +export async function loadConnectionPolicy( + resolverName: string, + env?: Environment +): Promise { + try { + if (!env) env = new Environment(); + const result: any = await evalEvent( + 'FindConnectionPolicy', + { resolverName: resolverName }, + env + ); + if (result && result.policyJson) { + const json = JSON.parse(result.policyJson); + return ConnectionPolicy.fromJSON(resolverName, json); + } + } catch (reason: any) { + logger.warn(`Failed to load connection policy for ${resolverName}: ${reason}`); + } + return undefined; +} + +let refreshTimer: ReturnType | undefined; + +export function startPolicyRefreshTimer(intervalSeconds?: number): void { + const interval = (intervalSeconds ?? 300) * 1000; + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = setInterval(() => { + loadAllConnectionPolicies().catch(err => { + logger.warn(`Policy refresh failed: ${err}`); + }); + }, interval); +} + +export function stopPolicyRefreshTimer(): void { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = undefined; + } +} + +export async function loadAllConnectionPolicies(env?: Environment): Promise { + try { + if (!env) env = new Environment(); + const results: any = await evalEvent('ListConnectionPolicies', {}, env); + if (results instanceof Array) { + results.forEach((inst: any) => { + const name = inst.resolverName || (inst.lookup && inst.lookup('resolverName')); + const policyJsonStr = inst.policyJson || (inst.lookup && inst.lookup('policyJson')); + if (name && policyJsonStr) { + try { + const json = JSON.parse(policyJsonStr); + const policy = ConnectionPolicy.fromJSON(name, json); + registerConnectionPolicy(name, policy); + } catch (err: any) { + logger.warn(`Failed to parse policy for ${name}: ${err}`); + } + } + }); + } + } catch (reason: any) { + logger.warn(`Failed to load connection policies: ${reason}`); + } +} diff --git a/src/runtime/resolvers/envelope.ts b/src/runtime/resolvers/envelope.ts new file mode 100644 index 00000000..eb98d07e --- /dev/null +++ b/src/runtime/resolvers/envelope.ts @@ -0,0 +1,39 @@ +import { ActiveSessionInfo } from '../auth/defs.js'; + +export type SubscriptionEnvelope = { + tenantId: string; + userId: string; + data: T; +}; + +export function createSubscriptionEnvelope( + tenantId: string, + userId: string, + data: T +): SubscriptionEnvelope { + if (!tenantId || tenantId.trim().length === 0) { + throw new Error('SubscriptionEnvelope requires a non-empty tenantId'); + } + if (!userId || userId.trim().length === 0) { + throw new Error('SubscriptionEnvelope requires a non-empty userId'); + } + return { tenantId, userId, data }; +} + +export function isSubscriptionEnvelope(obj: any): obj is SubscriptionEnvelope { + return ( + obj !== null && + obj !== undefined && + typeof obj === 'object' && + typeof obj.tenantId === 'string' && + typeof obj.userId === 'string' && + 'data' in obj + ); +} + +export function envelopeToSessionInfo(envelope: SubscriptionEnvelope): ActiveSessionInfo { + return { + sessionId: crypto.randomUUID(), + userId: envelope.userId, + }; +} diff --git a/src/runtime/resolvers/interface.ts b/src/runtime/resolvers/interface.ts index b1fc3f66..e1d8cf9d 100644 --- a/src/runtime/resolvers/interface.ts +++ b/src/runtime/resolvers/interface.ts @@ -16,6 +16,14 @@ import { } from '../module.js'; import { CrudType, nameToPath, generateLoggerCallId } from '../util.js'; import { DefaultAuthInfo, ResolverAuthInfo } from './authinfo.js'; +import { SubscriptionEnvelope, isSubscriptionEnvelope, envelopeToSessionInfo } from './envelope.js'; + +export { + SubscriptionEnvelope, + isSubscriptionEnvelope, + envelopeToSessionInfo, + createSubscriptionEnvelope, +} from './envelope.js'; export type JoinInfo = { relationship: Relationship; @@ -129,7 +137,7 @@ export class Resolver { relationship: Relationship, connectedInstance: Instance, inst: Instance, - connectedAlias?: string + _connectedAlias?: string ): Promise { return this.notImpl(`queryConnectedInstances(${relationship}, ${connectedInstance}, ${inst})`); } @@ -202,8 +210,14 @@ export class Resolver { private async onOutOfBandCrud( inst: Instance, operation: CrudType, - env: Environment + env: Environment, + envelope?: SubscriptionEnvelope ): Promise { + if (envelope) { + env.setActiveUser(envelope.userId); + env.setActiveTenantId(envelope.tenantId); + inst.setAuthContext(envelopeToSessionInfo(envelope)); + } switch (operation) { case CrudType.CREATE: return await runPostCreateEvents(inst, env); @@ -216,24 +230,44 @@ export class Resolver { } } - public async onCreate(inst: Instance, env: Environment): Promise { - return this.onOutOfBandCrud(inst, CrudType.CREATE, env); + public async onCreate( + inst: Instance, + env: Environment, + envelope?: SubscriptionEnvelope + ): Promise { + return this.onOutOfBandCrud(inst, CrudType.CREATE, env, envelope); } - public async onUpdate(inst: Instance, env: Environment): Promise { - return this.onOutOfBandCrud(inst, CrudType.UPDATE, env); + public async onUpdate( + inst: Instance, + env: Environment, + envelope?: SubscriptionEnvelope + ): Promise { + return this.onOutOfBandCrud(inst, CrudType.UPDATE, env, envelope); } - public async onDelete(inst: Instance, env: Environment): Promise { - return this.onOutOfBandCrud(inst, CrudType.DELETE, env); + public async onDelete( + inst: Instance, + env: Environment, + envelope?: SubscriptionEnvelope + ): Promise { + return this.onOutOfBandCrud(inst, CrudType.DELETE, env, envelope); } public async onSubscription(result: any, callPostCrudEvent: boolean = false): Promise { if (result !== undefined) { + let envelope: SubscriptionEnvelope | undefined; + let actualResult = result; + + if (isSubscriptionEnvelope(result)) { + envelope = result; + actualResult = envelope.data; + } + try { if (callPostCrudEvent) { - const inst = result as Instance; - return await callPostEventOnSubscription(CrudType.CREATE, inst); + const inst = actualResult as Instance; + return await callPostEventOnSubscription(CrudType.CREATE, inst, undefined, envelope); } else { const eventName = getSubscriptionEvent(this.name); if (eventName) { @@ -241,8 +275,11 @@ export class Resolver { const inst = makeInstance( path.getModuleName(), path.getEntryName(), - newInstanceAttributes().set('data', result) + newInstanceAttributes().set('data', actualResult) ); + if (envelope) { + inst.setAuthContext(envelopeToSessionInfo(envelope)); + } const { evaluate } = await import('../interpreter.js'); return await evaluate(inst); } diff --git a/src/runtime/resolvers/policy.ts b/src/runtime/resolvers/policy.ts new file mode 100644 index 00000000..6bab19bc --- /dev/null +++ b/src/runtime/resolvers/policy.ts @@ -0,0 +1,627 @@ +import { JoinSpec } from '../../language/generated/ast.js'; +import { Instance, InstanceAttributes, Relationship } from '../module.js'; +import { logger } from '../logger.js'; +import { Resolver, JoinInfo, WhereClause } from './interface.js'; +import { Environment } from '../interpreter.js'; +import { ResolverAuthInfo } from './authinfo.js'; +import { AppConfig } from '../state.js'; + +// --------------------------------------------------------------------------- +// Part A: Policy Model & Parser +// --------------------------------------------------------------------------- + +export interface TimeoutPolicy { + connectTimeoutMs: number; + requestTimeoutMs: number; +} + +export interface RetryBackoff { + strategy: 'exponential' | 'linear' | 'constant'; + delayMs: number; + factor: number; + maxDelayMs: number; +} + +export interface RetryPolicy { + maxAttempts: number; + backoff: RetryBackoff; +} + +export interface CircuitBreakerPolicy { + failureThreshold: number; + resetTimeoutMs: number; + halfOpenMaxAttempts: number; +} + +// Hardcoded fallbacks (used when AppConfig has no connectionPolicy section) +const FALLBACK_TIMEOUT: TimeoutPolicy = { + connectTimeoutMs: 5000, + requestTimeoutMs: 30000, +}; + +const FALLBACK_BACKOFF: RetryBackoff = { + strategy: 'exponential', + delayMs: 1000, + factor: 2, + maxDelayMs: 30000, +}; + +const FALLBACK_RETRY: RetryPolicy = { + maxAttempts: 3, + backoff: { ...FALLBACK_BACKOFF }, +}; + +const FALLBACK_CIRCUIT_BREAKER: CircuitBreakerPolicy = { + failureThreshold: 5, + resetTimeoutMs: 60000, + halfOpenMaxAttempts: 1, +}; + +function getDefaultTimeout(): TimeoutPolicy { + const cfg = AppConfig?.resolver?.connectionPolicy?.timeout; + return { + connectTimeoutMs: cfg?.connectTimeoutMs ?? FALLBACK_TIMEOUT.connectTimeoutMs, + requestTimeoutMs: cfg?.requestTimeoutMs ?? FALLBACK_TIMEOUT.requestTimeoutMs, + }; +} + +function getDefaultBackoff(): RetryBackoff { + const cfg = AppConfig?.resolver?.connectionPolicy?.retry?.backoff; + return { + strategy: cfg?.strategy ?? FALLBACK_BACKOFF.strategy, + delayMs: cfg?.delayMs ?? FALLBACK_BACKOFF.delayMs, + factor: cfg?.factor ?? FALLBACK_BACKOFF.factor, + maxDelayMs: cfg?.maxDelayMs ?? FALLBACK_BACKOFF.maxDelayMs, + }; +} + +function getDefaultRetry(): RetryPolicy { + const cfg = AppConfig?.resolver?.connectionPolicy?.retry; + return { + maxAttempts: cfg?.maxAttempts ?? FALLBACK_RETRY.maxAttempts, + backoff: getDefaultBackoff(), + }; +} + +function getDefaultCircuitBreaker(): CircuitBreakerPolicy { + const cfg = AppConfig?.resolver?.connectionPolicy?.circuitBreaker; + return { + failureThreshold: cfg?.failureThreshold ?? FALLBACK_CIRCUIT_BREAKER.failureThreshold, + resetTimeoutMs: cfg?.resetTimeoutMs ?? FALLBACK_CIRCUIT_BREAKER.resetTimeoutMs, + halfOpenMaxAttempts: cfg?.halfOpenMaxAttempts ?? FALLBACK_CIRCUIT_BREAKER.halfOpenMaxAttempts, + }; +} + +export class ConnectionPolicy { + resolverName: string; + timeout?: TimeoutPolicy; + retry?: RetryPolicy; + circuitBreaker?: CircuitBreakerPolicy; + + constructor(resolverName: string) { + this.resolverName = resolverName; + } + + hasAnyPolicy(): boolean { + return ( + this.timeout !== undefined || this.retry !== undefined || this.circuitBreaker !== undefined + ); + } + + toJSON(): object { + const result: any = {}; + if (this.timeout) result.timeout = { ...this.timeout }; + if (this.retry) { + result.retry = { + maxAttempts: this.retry.maxAttempts, + backoff: { ...this.retry.backoff }, + }; + } + if (this.circuitBreaker) result.circuitBreaker = { ...this.circuitBreaker }; + return result; + } + + static fromJSON(resolverName: string, json: any): ConnectionPolicy { + const policy = new ConnectionPolicy(resolverName); + if (json.timeout) { + const defaults = getDefaultTimeout(); + policy.timeout = { + connectTimeoutMs: json.timeout.connectTimeoutMs ?? defaults.connectTimeoutMs, + requestTimeoutMs: json.timeout.requestTimeoutMs ?? defaults.requestTimeoutMs, + }; + } + if (json.retry) { + const retryDefaults = getDefaultRetry(); + const backoffDefaults = getDefaultBackoff(); + const backoffJson = json.retry.backoff || {}; + policy.retry = { + maxAttempts: json.retry.maxAttempts ?? retryDefaults.maxAttempts, + backoff: { + strategy: backoffJson.strategy ?? backoffDefaults.strategy, + delayMs: backoffJson.delayMs ?? backoffDefaults.delayMs, + factor: backoffJson.factor ?? backoffDefaults.factor, + maxDelayMs: backoffJson.maxDelayMs ?? backoffDefaults.maxDelayMs, + }, + }; + } + if (json.circuitBreaker) { + const defaults = getDefaultCircuitBreaker(); + policy.circuitBreaker = { + failureThreshold: json.circuitBreaker.failureThreshold ?? defaults.failureThreshold, + resetTimeoutMs: json.circuitBreaker.resetTimeoutMs ?? defaults.resetTimeoutMs, + halfOpenMaxAttempts: + json.circuitBreaker.halfOpenMaxAttempts ?? defaults.halfOpenMaxAttempts, + }; + } + return policy; + } +} + +function mapGet(map: Map, key: string): any { + // normalizeMetaValue produces Maps with MapKey objects as keys; + // the MapKey has a .str property for string keys + for (const [k, v] of map.entries()) { + const keyStr = typeof k === 'string' ? k : k?.str; + if (keyStr === key) return v; + } + return undefined; +} + +function resolveMapValue(val: any, key: string): any { + if (val instanceof Map) return mapGet(val, key); + if (val && typeof val === 'object' && !(val instanceof Map)) return val[key]; + return undefined; +} + +export function parseConnectionPolicy( + resolverName: string, + metaMap: Map +): ConnectionPolicy | undefined { + const cpRaw = mapGet(metaMap, 'connectionPolicy'); + if (!cpRaw) return undefined; + + const policy = new ConnectionPolicy(resolverName); + + // Parse timeout + const timeoutRaw = resolveMapValue(cpRaw, 'timeout'); + if (timeoutRaw) { + const defaults = getDefaultTimeout(); + policy.timeout = { + connectTimeoutMs: + resolveMapValue(timeoutRaw, 'connectTimeoutMs') ?? defaults.connectTimeoutMs, + requestTimeoutMs: + resolveMapValue(timeoutRaw, 'requestTimeoutMs') ?? defaults.requestTimeoutMs, + }; + } + + // Parse retry + const retryRaw = resolveMapValue(cpRaw, 'retry'); + if (retryRaw) { + const retryDefaults = getDefaultRetry(); + const backoffDefaults = getDefaultBackoff(); + const backoffRaw = resolveMapValue(retryRaw, 'backoff') || {}; + policy.retry = { + maxAttempts: resolveMapValue(retryRaw, 'maxAttempts') ?? retryDefaults.maxAttempts, + backoff: { + strategy: resolveMapValue(backoffRaw, 'strategy') ?? backoffDefaults.strategy, + delayMs: resolveMapValue(backoffRaw, 'delayMs') ?? backoffDefaults.delayMs, + factor: resolveMapValue(backoffRaw, 'factor') ?? backoffDefaults.factor, + maxDelayMs: resolveMapValue(backoffRaw, 'maxDelayMs') ?? backoffDefaults.maxDelayMs, + }, + }; + } + + // Parse circuit breaker + const cbRaw = resolveMapValue(cpRaw, 'circuitBreaker'); + if (cbRaw) { + const defaults = getDefaultCircuitBreaker(); + policy.circuitBreaker = { + failureThreshold: resolveMapValue(cbRaw, 'failureThreshold') ?? defaults.failureThreshold, + resetTimeoutMs: resolveMapValue(cbRaw, 'resetTimeoutMs') ?? defaults.resetTimeoutMs, + halfOpenMaxAttempts: + resolveMapValue(cbRaw, 'halfOpenMaxAttempts') ?? defaults.halfOpenMaxAttempts, + }; + } + + return policy.hasAnyPolicy() ? policy : undefined; +} + +// --------------------------------------------------------------------------- +// Policy Cache +// --------------------------------------------------------------------------- + +const policyCache = new Map(); + +export function registerConnectionPolicy(resolverName: string, policy: ConnectionPolicy): void { + policyCache.set(resolverName, policy); +} + +export function getConnectionPolicy(resolverName: string): ConnectionPolicy | undefined { + return policyCache.get(resolverName); +} + +export function resetPolicyCache(): void { + policyCache.clear(); +} + +// --------------------------------------------------------------------------- +// Part B: Policy Enforcement Engine +// --------------------------------------------------------------------------- + +export class TimeoutError extends Error { + constructor(operationName: string, timeoutMs: number) { + super(`Operation '${operationName}' timed out after ${timeoutMs}ms`); + this.name = 'TimeoutError'; + } +} + +export class CircuitOpenError extends Error { + constructor(resolverName: string) { + super(`Circuit breaker is open for resolver '${resolverName}'`); + this.name = 'CircuitOpenError'; + } +} + +export function withTimeout( + fn: () => Promise, + timeoutMs: number, + operationName: string +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + reject(new TimeoutError(operationName, timeoutMs)); + } + }, timeoutMs); + + fn() + .then(result => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(result); + } + }) + .catch(err => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(err); + } + }); + }); +} + +export function calculateDelay(attempt: number, backoff: RetryBackoff): number { + let delay: number; + switch (backoff.strategy) { + case 'exponential': + delay = backoff.delayMs * Math.pow(backoff.factor, attempt); + break; + case 'linear': + delay = backoff.delayMs * (attempt + 1); + break; + case 'constant': + delay = backoff.delayMs; + break; + default: + delay = backoff.delayMs; + } + return Math.min(delay, backoff.maxDelayMs); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function withRetry( + fn: () => Promise, + retryPolicy: RetryPolicy, + operationName: string +): Promise { + let lastError: any; + for (let attempt = 0; attempt < retryPolicy.maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (attempt < retryPolicy.maxAttempts - 1) { + const delay = calculateDelay(attempt, retryPolicy.backoff); + logger.debug( + `Retry ${attempt + 1}/${retryPolicy.maxAttempts} for '${operationName}' after ${delay}ms` + ); + await sleep(delay); + } + } + } + throw lastError; +} + +// Circuit breaker states +export enum CircuitState { + CLOSED = 'closed', + OPEN = 'open', + HALF_OPEN = 'half_open', +} + +export class CircuitBreakerState { + state: CircuitState = CircuitState.CLOSED; + failureCount: number = 0; + lastFailureTime: number = 0; + halfOpenAttempts: number = 0; +} + +const circuitStates = new Map(); + +export function getCircuitBreakerState(resolverName: string): CircuitBreakerState { + let state = circuitStates.get(resolverName); + if (!state) { + state = new CircuitBreakerState(); + circuitStates.set(resolverName, state); + } + return state; +} + +export function resetCircuitBreakerState(resolverName: string): void { + circuitStates.delete(resolverName); +} + +export function resetAllCircuitBreakerStates(): void { + circuitStates.clear(); +} + +export async function withCircuitBreaker( + fn: () => Promise, + cbPolicy: CircuitBreakerPolicy, + resolverName: string, + operationName: string +): Promise { + const cbState = getCircuitBreakerState(resolverName); + + // Check if circuit should transition from open to half-open + if (cbState.state === CircuitState.OPEN) { + const elapsed = Date.now() - cbState.lastFailureTime; + if (elapsed >= cbPolicy.resetTimeoutMs) { + cbState.state = CircuitState.HALF_OPEN; + cbState.halfOpenAttempts = 0; + logger.debug(`Circuit breaker for '${resolverName}' transitioning to half-open`); + } else { + throw new CircuitOpenError(resolverName); + } + } + + // In half-open state, only allow limited attempts + if (cbState.state === CircuitState.HALF_OPEN) { + if (cbState.halfOpenAttempts >= cbPolicy.halfOpenMaxAttempts) { + throw new CircuitOpenError(resolverName); + } + cbState.halfOpenAttempts++; + } + + try { + const result = await fn(); + // Success: reset to closed state + if (cbState.state === CircuitState.HALF_OPEN || cbState.state === CircuitState.CLOSED) { + cbState.state = CircuitState.CLOSED; + cbState.failureCount = 0; + cbState.halfOpenAttempts = 0; + } + return result; + } catch (err) { + cbState.failureCount++; + cbState.lastFailureTime = Date.now(); + + if (cbState.state === CircuitState.HALF_OPEN) { + // Failure in half-open: go back to open + cbState.state = CircuitState.OPEN; + logger.debug( + `Circuit breaker for '${resolverName}' reopened after half-open failure in '${operationName}'` + ); + } else if (cbState.failureCount >= cbPolicy.failureThreshold) { + cbState.state = CircuitState.OPEN; + logger.debug( + `Circuit breaker for '${resolverName}' opened after ${cbState.failureCount} failures in '${operationName}'` + ); + } + + throw err; + } +} + +export function applyPolicies( + policy: ConnectionPolicy, + operation: () => Promise, + operationName: string +): Promise { + // Build middleware chain: operation -> timeout -> retry -> circuit breaker (inner to outer) + let wrapped = operation; + + if (policy.timeout) { + const timeoutMs = policy.timeout.requestTimeoutMs; + const inner = wrapped; + wrapped = () => withTimeout(inner, timeoutMs, operationName); + } + + if (policy.retry) { + const retryPolicy = policy.retry; + const inner = wrapped; + wrapped = () => withRetry(inner, retryPolicy, operationName); + } + + if (policy.circuitBreaker) { + const cbPolicy = policy.circuitBreaker; + const resolverName = policy.resolverName; + const inner = wrapped; + wrapped = () => withCircuitBreaker(inner, cbPolicy, resolverName, operationName); + } + + return wrapped(); +} + +// --------------------------------------------------------------------------- +// Part C: PolicyResolver Decorator +// --------------------------------------------------------------------------- + +export class PolicyResolver extends Resolver { + private inner: Resolver; + private policy: ConnectionPolicy; + + constructor(inner: Resolver, policy: ConnectionPolicy) { + super(inner.getName()); + this.inner = inner; + this.policy = policy; + } + + // --- Pass-through methods (no policy wrapping) --- + + public override setAuthInfo(authInfo: ResolverAuthInfo): Resolver { + this.inner.setAuthInfo(authInfo); + return this; + } + + public override setEnvironment(env: Environment): Resolver { + this.inner.setEnvironment(env); + return this; + } + + public override getEnvironment(): Environment | undefined { + return this.inner.getEnvironment(); + } + + public override suspend(): Resolver { + this.inner.suspend(); + return this; + } + + public override onSetPath(moduleName: string, entryName: string): any { + return this.inner.onSetPath(moduleName, entryName); + } + + public override async startTransaction(): Promise { + return this.inner.startTransaction(); + } + + public override async commitTransaction(txnId: string): Promise { + return this.inner.commitTransaction(txnId); + } + + public override async rollbackTransaction(txnId: string): Promise { + return this.inner.rollbackTransaction(txnId); + } + + public override async subscribe(): Promise { + return this.inner.subscribe(); + } + + public override async onSubscription( + result: any, + callPostCrudEvent: boolean = false + ): Promise { + return this.inner.onSubscription(result, callPostCrudEvent); + } + + // --- Policy-wrapped methods --- + + public override async createInstance(inst: Instance): Promise { + return applyPolicies(this.policy, () => this.inner.createInstance(inst), 'createInstance'); + } + + public override async upsertInstance(inst: Instance): Promise { + return applyPolicies(this.policy, () => this.inner.upsertInstance(inst), 'upsertInstance'); + } + + public override async updateInstance(inst: Instance, newAttrs: InstanceAttributes): Promise { + return applyPolicies( + this.policy, + () => this.inner.updateInstance(inst, newAttrs), + 'updateInstance' + ); + } + + public override async queryInstances( + inst: Instance, + queryAll: boolean, + distinct: boolean = false + ): Promise { + return applyPolicies( + this.policy, + () => this.inner.queryInstances(inst, queryAll, distinct), + 'queryInstances' + ); + } + + public override async queryChildInstances(parentPath: string, inst: Instance): Promise { + return applyPolicies( + this.policy, + () => this.inner.queryChildInstances(parentPath, inst), + 'queryChildInstances' + ); + } + + public override async queryConnectedInstances( + relationship: Relationship, + connectedInstance: Instance, + inst: Instance, + connectedAlias?: string + ): Promise { + return applyPolicies( + this.policy, + () => + this.inner.queryConnectedInstances(relationship, connectedInstance, inst, connectedAlias), + 'queryConnectedInstances' + ); + } + + public override async queryByJoin( + inst: Instance, + joinInfo: JoinInfo[], + intoSpec: Map, + distinct: boolean = false, + rawJoinSpec?: JoinSpec[], + whereClauses?: WhereClause[] + ): Promise { + return applyPolicies( + this.policy, + () => this.inner.queryByJoin(inst, joinInfo, intoSpec, distinct, rawJoinSpec, whereClauses), + 'queryByJoin' + ); + } + + public override async deleteInstance(inst: Instance | Instance[], purge: boolean): Promise { + return applyPolicies( + this.policy, + () => this.inner.deleteInstance(inst, purge), + 'deleteInstance' + ); + } + + public override async handleInstancesLink( + node1: Instance, + otherNodeOrNodes: Instance | Instance[], + relEntry: Relationship, + orUpdate: boolean, + inDeleteMode: boolean + ): Promise { + return applyPolicies( + this.policy, + () => + this.inner.handleInstancesLink(node1, otherNodeOrNodes, relEntry, orUpdate, inDeleteMode), + 'handleInstancesLink' + ); + } + + public override async fullTextSearch( + entryName: string, + moduleName: string, + query: string, + options?: any + ): Promise { + return applyPolicies( + this.policy, + () => this.inner.fullTextSearch(entryName, moduleName, query, options), + 'fullTextSearch' + ); + } +} diff --git a/src/runtime/resolvers/registry.ts b/src/runtime/resolvers/registry.ts index 583f9ce6..10107c8f 100644 --- a/src/runtime/resolvers/registry.ts +++ b/src/runtime/resolvers/registry.ts @@ -31,4 +31,9 @@ export function getResolver(fqEntryName: string): Resolver { throw new Error(`No resolver registered for ${fqEntryName}`); } +export function resetResolverRegistry() { + resolverDb.clear(); + resolverPathMappings.clear(); +} + setSubscriptionFn(setSubscriptionEvent); diff --git a/src/runtime/state.ts b/src/runtime/state.ts index 4fb66328..eafe9602 100644 --- a/src/runtime/state.ts +++ b/src/runtime/state.ts @@ -130,6 +130,41 @@ export const ConfigSchema = z.object({ }) ) .optional(), + resolver: z + .object({ + connectionPolicy: z + .object({ + refreshIntervalSeconds: z.number().default(300), + timeout: z + .object({ + connectTimeoutMs: z.number().default(5000), + requestTimeoutMs: z.number().default(30000), + }) + .optional(), + retry: z + .object({ + maxAttempts: z.number().default(3), + backoff: z + .object({ + strategy: z.enum(['exponential', 'linear', 'constant']).default('exponential'), + delayMs: z.number().default(1000), + factor: z.number().default(2), + maxDelayMs: z.number().default(30000), + }) + .optional(), + }) + .optional(), + circuitBreaker: z + .object({ + failureThreshold: z.number().default(5), + resetTimeoutMs: z.number().default(60000), + halfOpenMaxAttempts: z.number().default(1), + }) + .optional(), + }) + .optional(), + }) + .optional(), }); export type Config = z.infer; diff --git a/test/runtime/resolvers/authinfo.test.ts b/test/runtime/resolvers/authinfo.test.ts new file mode 100644 index 00000000..91360251 --- /dev/null +++ b/test/runtime/resolvers/authinfo.test.ts @@ -0,0 +1,40 @@ +import { describe, test, assert } from 'vitest'; +import { ResolverAuthInfo, DefaultAuthInfo } from '../../../src/runtime/resolvers/authinfo.js'; + +describe('ResolverAuthInfo', () => { + test('construction with userId only - flags default to false', () => { + const info = new ResolverAuthInfo('user-123'); + assert.equal(info.userId, 'user-123'); + assert.equal(info.readForUpdate, false); + assert.equal(info.readForDelete, false); + }); + + test('construction with all flags', () => { + const info = new ResolverAuthInfo('user-456', true, true); + assert.equal(info.userId, 'user-456'); + assert.equal(info.readForUpdate, true); + assert.equal(info.readForDelete, true); + }); + + test('construction with mixed flags', () => { + const info1 = new ResolverAuthInfo('user-789', true, false); + assert.equal(info1.readForUpdate, true); + assert.equal(info1.readForDelete, false); + + const info2 = new ResolverAuthInfo('user-789', false, true); + assert.equal(info2.readForUpdate, false); + assert.equal(info2.readForDelete, true); + }); + + test('undefined flags do not override defaults', () => { + const info = new ResolverAuthInfo('user-abc', undefined, undefined); + assert.equal(info.readForUpdate, false); + assert.equal(info.readForDelete, false); + }); + + test('DefaultAuthInfo has expected test userId', () => { + assert.equal(DefaultAuthInfo.userId, '9459a305-5ee6-415d-986d-caaf6d6e2828'); + assert.equal(DefaultAuthInfo.readForUpdate, false); + assert.equal(DefaultAuthInfo.readForDelete, false); + }); +}); diff --git a/test/runtime/resolvers/base-generic.test.ts b/test/runtime/resolvers/base-generic.test.ts new file mode 100644 index 00000000..3a971df2 --- /dev/null +++ b/test/runtime/resolvers/base-generic.test.ts @@ -0,0 +1,252 @@ +import { describe, test, assert, vi } from 'vitest'; +// Import interpreter first to establish correct module loading order (breaks circular dep) +import '../../../src/runtime/interpreter.js'; +import { + Resolver, + GenericResolver, + GenericResolverMethods, +} from '../../../src/runtime/resolvers/interface.js'; +import { ResolverAuthInfo } from '../../../src/runtime/resolvers/authinfo.js'; +import { Instance, InstanceAttributes } from '../../../src/runtime/module.js'; + +describe('Resolver base class', () => { + test('Default static instance has name "default"', () => { + assert.equal(Resolver.Default.getName(), 'default'); + }); + + test('constructor without name uses "default"', () => { + const r = new Resolver(); + assert.equal(r.getName(), 'default'); + }); + + test('constructor with name uses provided name', () => { + const r = new Resolver('custom'); + assert.equal(r.getName(), 'custom'); + }); + + test('setAuthInfo returns self for chaining', () => { + const r = new Resolver('chain'); + const auth = new ResolverAuthInfo('user-1'); + const result = r.setAuthInfo(auth); + assert.equal(result, r); + }); + + test('setEnvironment/getEnvironment round-trip', () => { + const r = new Resolver('env-test'); + assert.equal(r.getEnvironment(), undefined); + const mockEnv = { suspend: vi.fn() } as any; + const result = r.setEnvironment(mockEnv); + assert.equal(result, r); + assert.equal(r.getEnvironment(), mockEnv); + }); + + test('base CRUD methods return undefined (notImpl)', async () => { + const r = new Resolver('base'); + const mockInst = {} as Instance; + const mockAttrs = new Map() as InstanceAttributes; + + assert.equal(await r.createInstance(mockInst), undefined); + assert.equal(await r.upsertInstance(mockInst), undefined); + assert.equal(await r.updateInstance(mockInst, mockAttrs), undefined); + assert.equal(await r.queryInstances(mockInst, false), undefined); + assert.equal(await r.deleteInstance(mockInst, false), undefined); + }); + + test('startTransaction returns 1', async () => { + const r = new Resolver('txn'); + const result = await r.startTransaction(); + assert.equal(result, 1); + }); + + test('subscribe returns undefined', async () => { + const r = new Resolver('sub'); + const result = await r.subscribe(); + assert.equal(result, undefined); + }); + + test('suspend calls env.suspend() when env is set', () => { + const r = new Resolver('susp'); + const mockEnv = { suspend: vi.fn() } as any; + r.setEnvironment(mockEnv); + r.suspend(); + assert.equal(mockEnv.suspend.mock.calls.length, 1); + }); + + test('suspend is no-op when env is not set', () => { + const r = new Resolver('susp2'); + const result = r.suspend(); + assert.equal(result, r); + }); +}); + +describe('GenericResolver delegation', () => { + function makeMethods(overrides: Partial = {}): GenericResolverMethods { + return { + create: overrides.create ?? undefined, + upsert: overrides.upsert ?? undefined, + update: overrides.update ?? undefined, + query: overrides.query ?? undefined, + delete: overrides.delete ?? undefined, + startTransaction: overrides.startTransaction ?? undefined, + commitTransaction: overrides.commitTransaction ?? undefined, + rollbackTransaction: overrides.rollbackTransaction ?? undefined, + }; + } + + test('createInstance delegates to implementation when provided', async () => { + const spy = vi.fn().mockResolvedValue('created'); + const gr = new GenericResolver('del', makeMethods({ create: spy })); + const mockInst = { attributes: new Map() } as unknown as Instance; + const result = await gr.createInstance(mockInst); + assert.equal(result, 'created'); + assert.equal(spy.mock.calls.length, 1); + assert.equal(spy.mock.calls[0][0], gr); + assert.equal(spy.mock.calls[0][1], mockInst); + }); + + test('createInstance falls back to base when implementation is undefined', async () => { + const gr = new GenericResolver('fb', makeMethods()); + const mockInst = { attributes: new Map() } as unknown as Instance; + const result = await gr.createInstance(mockInst); + assert.equal(result, undefined); + }); + + test('upsertInstance delegates to implementation when provided', async () => { + const spy = vi.fn().mockResolvedValue('upserted'); + const gr = new GenericResolver('del', makeMethods({ upsert: spy })); + const mockInst = {} as Instance; + const result = await gr.upsertInstance(mockInst); + assert.equal(result, 'upserted'); + assert.equal(spy.mock.calls[0][0], gr); + assert.equal(spy.mock.calls[0][1], mockInst); + }); + + test('upsertInstance falls back to base when implementation is undefined', async () => { + const gr = new GenericResolver('fb', makeMethods()); + const result = await gr.upsertInstance({} as Instance); + assert.equal(result, undefined); + }); + + test('updateInstance passes newAttrs as third argument', async () => { + const spy = vi.fn().mockResolvedValue('updated'); + const gr = new GenericResolver('del', makeMethods({ update: spy })); + const mockInst = { queryAttributes: null, queryAttributeValues: null } as unknown as Instance; + const newAttrs: InstanceAttributes = new Map([['name', 'new']]); + const result = await gr.updateInstance(mockInst, newAttrs); + assert.equal(result, 'updated'); + assert.equal(spy.mock.calls[0][0], gr); + assert.equal(spy.mock.calls[0][1], mockInst); + assert.equal(spy.mock.calls[0][2], newAttrs); + }); + + test('updateInstance falls back to base when implementation is undefined', async () => { + const gr = new GenericResolver('fb', makeMethods()); + const mockInst = { queryAttributes: null, queryAttributeValues: null } as unknown as Instance; + const result = await gr.updateInstance(mockInst, new Map()); + assert.equal(result, undefined); + }); + + test('queryInstances delegates to implementation when provided', async () => { + const spy = vi.fn().mockResolvedValue(['result']); + const gr = new GenericResolver('del', makeMethods({ query: spy })); + const mockInst = { queryAttributes: null, queryAttributeValues: null } as unknown as Instance; + const result = await gr.queryInstances(mockInst, true); + assert.deepEqual(result, ['result']); + assert.equal(spy.mock.calls[0][0], gr); + assert.equal(spy.mock.calls[0][1], mockInst); + assert.equal(spy.mock.calls[0][2], true); + }); + + test('queryInstances falls back to base when implementation is undefined', async () => { + const gr = new GenericResolver('fb', makeMethods()); + const mockInst = { queryAttributes: null, queryAttributeValues: null } as unknown as Instance; + const result = await gr.queryInstances(mockInst, false); + assert.equal(result, undefined); + }); + + test('deleteInstance delegates to implementation when provided', async () => { + const spy = vi.fn().mockResolvedValue('deleted'); + const gr = new GenericResolver('del', makeMethods({ delete: spy })); + const mockInsts = [] as Instance[]; + const result = await gr.deleteInstance(mockInsts, false); + assert.equal(result, 'deleted'); + assert.equal(spy.mock.calls[0][0], gr); + assert.equal(spy.mock.calls[0][1], mockInsts); + }); + + test('deleteInstance falls back to base when implementation is undefined', async () => { + const gr = new GenericResolver('fb', makeMethods()); + const result = await gr.deleteInstance([] as Instance[], false); + assert.equal(result, undefined); + }); + + test('startTransaction delegates to implementation', async () => { + const spy = vi.fn().mockResolvedValue('txn-42'); + const gr = new GenericResolver('txn', makeMethods({ startTransaction: spy })); + const result = await gr.startTransaction(); + assert.equal(result, 'txn-42'); + assert.equal(spy.mock.calls[0][0], gr); + }); + + test('startTransaction falls back to base (returns 1)', async () => { + const gr = new GenericResolver('fb', makeMethods()); + const result = await gr.startTransaction(); + assert.equal(result, 1); + }); + + test('commitTransaction delegates to implementation', async () => { + const spy = vi.fn().mockResolvedValue('committed'); + const gr = new GenericResolver('txn', makeMethods({ commitTransaction: spy })); + const result = await gr.commitTransaction('txn-42'); + assert.equal(result, 'committed'); + assert.equal(spy.mock.calls[0][0], gr); + assert.equal(spy.mock.calls[0][1], 'txn-42'); + }); + + test('rollbackTransaction delegates to implementation', async () => { + const spy = vi.fn().mockResolvedValue('rolledback'); + const gr = new GenericResolver('txn', makeMethods({ rollbackTransaction: spy })); + const result = await gr.rollbackTransaction('txn-42'); + assert.equal(result, 'rolledback'); + assert.equal(spy.mock.calls[0][0], gr); + assert.equal(spy.mock.calls[0][1], 'txn-42'); + }); +}); + +describe('GenericResolver subscription retry', () => { + test('succeeds on first try with no error', async () => { + const spy = vi.fn(); + const gr = new GenericResolver('sub-ok'); + gr.subs = { subscribe: spy }; + await gr.subscribe(); + assert.equal(spy.mock.calls.length, 1); + }); + + test('retries up to MaxErrors then stops', async () => { + const spy = vi.fn().mockRejectedValue(new Error('fail')); + const gr = new GenericResolver('sub-fail'); + gr.subs = { subscribe: spy }; + await gr.subscribe(); + // MaxErrors = 3: initial + 3 retries = 4 total calls before breaking + assert.equal(spy.mock.calls.length, 4); + }); + + test('recovers after transient error', async () => { + let callCount = 0; + const spy = vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) throw new Error('transient'); + // succeeds on second call + }); + const gr = new GenericResolver('sub-recover'); + gr.subs = { subscribe: spy }; + await gr.subscribe(); + assert.equal(spy.mock.calls.length, 2); + }); + + test('no-op when subs is undefined', async () => { + const gr = new GenericResolver('sub-none'); + // subs is undefined by default + await gr.subscribe(); // should not throw + }); +}); diff --git a/test/runtime/resolvers/edge-cases.test.ts b/test/runtime/resolvers/edge-cases.test.ts new file mode 100644 index 00000000..b8724297 --- /dev/null +++ b/test/runtime/resolvers/edge-cases.test.ts @@ -0,0 +1,235 @@ +import { assert, describe, test, beforeAll, afterAll, beforeEach } from 'vitest'; +import { doInternModule, doPreInit } from '../../util.js'; +import { + initDatabase, + resetDefaultDatabase, +} from '../../../src/runtime/resolvers/sqldb/database.js'; +import { parseAndEvaluateStatement } from '../../../src/runtime/interpreter.js'; +import { Instance } from '../../../src/runtime/module.js'; +import { + GenericResolver, + GenericResolverMethods, + setSubscriptionEvent, + getSubscriptionEvent, +} from '../../../src/runtime/resolvers/interface.js'; +import { + registerResolver, + setResolver, + resetResolverRegistry, +} from '../../../src/runtime/resolvers/registry.js'; + +describe('Resolver Edge Cases', () => { + let originalDbType: string | undefined; + + beforeAll(async () => { + originalDbType = process.env.AL_DB_TYPE; + process.env.AL_DB_TYPE = 'sqljs'; + await doPreInit(); + }); + + afterAll(async () => { + if (originalDbType !== undefined) { + process.env.AL_DB_TYPE = originalDbType; + } else { + delete process.env.AL_DB_TYPE; + } + await resetDefaultDatabase(); + }); + + beforeEach(async () => { + resetResolverRegistry(); + await resetDefaultDatabase(); + await initDatabase({ type: 'sqljs' }); + }); + + describe('Error handling', () => { + test('create method throwing error propagates to caller', async () => { + const methods: GenericResolverMethods = { + create: async () => { + throw new Error('create failed'); + }, + upsert: undefined, + update: undefined, + query: undefined, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('err-create', () => new GenericResolver('err-create', methods)); + + await doInternModule('ErrMod1', 'entity ErrEnt {id Int @id, name String}'); + setResolver('ErrMod1/ErrEnt', 'err-create'); + + try { + await parseAndEvaluateStatement('{ErrMod1/ErrEnt {id 1, name "fail"}}'); + assert.fail('Should have thrown'); + } catch (err: any) { + assert(err.message.includes('create failed')); + } + }); + + test('query method throwing error propagates to caller', async () => { + const methods: GenericResolverMethods = { + create: undefined, + upsert: undefined, + update: undefined, + query: async () => { + throw new Error('query failed'); + }, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('err-query', () => new GenericResolver('err-query', methods)); + + await doInternModule('ErrMod2', 'entity ErrQEnt {id Int @id, name String}'); + setResolver('ErrMod2/ErrQEnt', 'err-query'); + + try { + await parseAndEvaluateStatement('{ErrMod2/ErrQEnt? {}}'); + assert.fail('Should have thrown'); + } catch (err: any) { + assert(err.message.includes('query failed')); + } + }); + + test('resolver returning null from create', async () => { + const methods: GenericResolverMethods = { + create: async () => null, + upsert: undefined, + update: undefined, + query: undefined, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('null-create', () => new GenericResolver('null-create', methods)); + + await doInternModule('NullMod', 'entity NullEnt {id Int @id, name String}'); + setResolver('NullMod/NullEnt', 'null-create'); + + const result = await parseAndEvaluateStatement('{NullMod/NullEnt {id 1, name "test"}}'); + assert.equal(result, null); + }); + + test('resolver returning empty array from query', async () => { + const methods: GenericResolverMethods = { + create: undefined, + upsert: undefined, + update: undefined, + query: async () => [], + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('empty-query', () => new GenericResolver('empty-query', methods)); + + await doInternModule('EmMod', 'entity EmEnt {id Int @id, name String}'); + setResolver('EmMod/EmEnt', 'empty-query'); + + const results = (await parseAndEvaluateStatement('{EmMod/EmEnt? {}}')) as Instance[]; + assert.equal(results.length, 0); + }); + }); + + describe('Transaction methods', () => { + test('custom startTransaction returns custom transaction id', async () => { + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => inst, + upsert: undefined, + update: undefined, + query: undefined, + delete: undefined, + startTransaction: async () => 'custom-txn-99', + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + const resolver = new GenericResolver('txn-test', methods); + const txnId = await resolver.startTransaction(); + assert.equal(txnId, 'custom-txn-99'); + }); + + test('custom commitTransaction receives correct txnId', async () => { + const receivedTxnId: string[] = []; + const methods: GenericResolverMethods = { + create: undefined, + upsert: undefined, + update: undefined, + query: undefined, + delete: undefined, + startTransaction: undefined, + commitTransaction: async (_res: any, txnId: string) => { + receivedTxnId.push(txnId); + }, + rollbackTransaction: undefined, + }; + const resolver = new GenericResolver('commit-test', methods); + await resolver.commitTransaction('txn-42'); + assert.equal(receivedTxnId.length, 1); + assert.equal(receivedTxnId[0], 'txn-42'); + }); + + test('custom rollbackTransaction receives correct txnId', async () => { + const receivedTxnId: string[] = []; + const methods: GenericResolverMethods = { + create: undefined, + upsert: undefined, + update: undefined, + query: undefined, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: async (_res: any, txnId: string) => { + receivedTxnId.push(txnId); + }, + }; + const resolver = new GenericResolver('rollback-test', methods); + await resolver.rollbackTransaction('txn-99'); + assert.equal(receivedTxnId.length, 1); + assert.equal(receivedTxnId[0], 'txn-99'); + }); + }); + + describe('Auth info propagation', () => { + test('authInfo.userId is set on resolver during CRUD operations', async () => { + let capturedUserId: string | undefined; + const methods: GenericResolverMethods = { + create: async (resolver: any, inst: Instance) => { + capturedUserId = resolver.authInfo?.userId; + return inst; + }, + upsert: undefined, + update: undefined, + query: undefined, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('auth-test', () => new GenericResolver('auth-test', methods)); + + await doInternModule('AuthMod', 'entity AuthEnt {id Int @id, name String}'); + setResolver('AuthMod/AuthEnt', 'auth-test'); + + await parseAndEvaluateStatement('{AuthMod/AuthEnt {id 1, name "test"}}'); + assert(capturedUserId !== undefined, 'userId should be set'); + assert(typeof capturedUserId === 'string'); + assert(capturedUserId.length > 0); + }); + }); + + describe('Subscription events', () => { + test('setSubscriptionEvent/getSubscriptionEvent store and retrieve correctly', () => { + setSubscriptionEvent('MyModule/MyEvent', 'my-resolver'); + assert.equal(getSubscriptionEvent('my-resolver'), 'MyModule/MyEvent'); + }); + + test('getSubscriptionEvent returns undefined for unknown resolver', () => { + assert.equal(getSubscriptionEvent('nonexistent-resolver'), undefined); + }); + }); +}); diff --git a/test/runtime/resolvers/envelope.test.ts b/test/runtime/resolvers/envelope.test.ts new file mode 100644 index 00000000..ffdd264d --- /dev/null +++ b/test/runtime/resolvers/envelope.test.ts @@ -0,0 +1,241 @@ +import { describe, test, assert, vi } from 'vitest'; +// Import interpreter first to establish correct module loading order (breaks circular dep) +import '../../../src/runtime/interpreter.js'; +import { + createSubscriptionEnvelope, + isSubscriptionEnvelope, + envelopeToSessionInfo, +} from '../../../src/runtime/resolvers/envelope.js'; +import { Environment } from '../../../src/runtime/interpreter.js'; +import { Resolver } from '../../../src/runtime/resolvers/interface.js'; +import { createTestEnvelope } from './utils.js'; +import { AdminUserId } from '../../../src/runtime/auth/defs.js'; + +describe('SubscriptionEnvelope', () => { + describe('createSubscriptionEnvelope', () => { + test('returns correct shape', () => { + const envelope = createSubscriptionEnvelope('tenant-1', 'user-1', { key: 'value' }); + assert.equal(envelope.tenantId, 'tenant-1'); + assert.equal(envelope.userId, 'user-1'); + assert.deepEqual(envelope.data, { key: 'value' }); + }); + + test('throws on empty tenantId', () => { + assert.throws(() => createSubscriptionEnvelope('', 'user-1', {}), /non-empty tenantId/); + }); + + test('throws on whitespace-only tenantId', () => { + assert.throws(() => createSubscriptionEnvelope(' ', 'user-1', {}), /non-empty tenantId/); + }); + + test('throws on empty userId', () => { + assert.throws(() => createSubscriptionEnvelope('tenant-1', '', {}), /non-empty userId/); + }); + + test('throws on whitespace-only userId', () => { + assert.throws(() => createSubscriptionEnvelope('tenant-1', ' ', {}), /non-empty userId/); + }); + + test('preserves data of various types', () => { + const arrayData = [1, 2, 3]; + const e1 = createSubscriptionEnvelope('t', 'u', arrayData); + assert.deepEqual(e1.data, [1, 2, 3]); + + const e2 = createSubscriptionEnvelope('t', 'u', 'string-data'); + assert.equal(e2.data, 'string-data'); + + const e3 = createSubscriptionEnvelope('t', 'u', null); + assert.equal(e3.data, null); + }); + }); + + describe('isSubscriptionEnvelope', () => { + test('returns true for valid envelopes', () => { + const envelope = createSubscriptionEnvelope('tenant-1', 'user-1', { x: 1 }); + assert.isTrue(isSubscriptionEnvelope(envelope)); + }); + + test('returns true for manually constructed envelope-like objects', () => { + assert.isTrue(isSubscriptionEnvelope({ tenantId: 'a', userId: 'b', data: 123 })); + }); + + test('returns true when data is null', () => { + assert.isTrue(isSubscriptionEnvelope({ tenantId: 'a', userId: 'b', data: null })); + }); + + test('returns true when data is undefined', () => { + assert.isTrue(isSubscriptionEnvelope({ tenantId: 'a', userId: 'b', data: undefined })); + }); + + test('returns false for null', () => { + assert.isFalse(isSubscriptionEnvelope(null)); + }); + + test('returns false for undefined', () => { + assert.isFalse(isSubscriptionEnvelope(undefined)); + }); + + test('returns false for plain objects without required fields', () => { + assert.isFalse(isSubscriptionEnvelope({})); + assert.isFalse(isSubscriptionEnvelope({ foo: 'bar' })); + }); + + test('returns false for partial objects', () => { + assert.isFalse(isSubscriptionEnvelope({ tenantId: 'a' })); + assert.isFalse(isSubscriptionEnvelope({ userId: 'b' })); + assert.isFalse(isSubscriptionEnvelope({ data: 'c' })); + assert.isFalse(isSubscriptionEnvelope({ tenantId: 'a', userId: 'b' })); + assert.isFalse(isSubscriptionEnvelope({ tenantId: 'a', data: 'c' })); + assert.isFalse(isSubscriptionEnvelope({ userId: 'b', data: 'c' })); + }); + + test('returns false for non-string tenantId or userId', () => { + assert.isFalse(isSubscriptionEnvelope({ tenantId: 123, userId: 'b', data: null })); + assert.isFalse(isSubscriptionEnvelope({ tenantId: 'a', userId: 456, data: null })); + }); + + test('returns false for primitives', () => { + assert.isFalse(isSubscriptionEnvelope(42)); + assert.isFalse(isSubscriptionEnvelope('string')); + assert.isFalse(isSubscriptionEnvelope(true)); + }); + }); + + describe('envelopeToSessionInfo', () => { + test('creates valid ActiveSessionInfo', () => { + const envelope = createSubscriptionEnvelope('tenant-1', 'user-42', { key: 'val' }); + const session = envelopeToSessionInfo(envelope); + assert.equal(session.userId, 'user-42'); + assert.isString(session.sessionId); + assert.isTrue(session.sessionId.length > 0); + }); + + test('generates unique sessionIds across calls', () => { + const envelope = createSubscriptionEnvelope('t', 'u', {}); + const s1 = envelopeToSessionInfo(envelope); + const s2 = envelopeToSessionInfo(envelope); + assert.notEqual(s1.sessionId, s2.sessionId); + }); + }); + + describe('createTestEnvelope helper', () => { + test('creates envelope with defaults', () => { + const envelope = createTestEnvelope(); + assert.isTrue(isSubscriptionEnvelope(envelope)); + assert.isString(envelope.tenantId); + assert.isString(envelope.userId); + assert.deepEqual(envelope.data, { foo: 'bar' }); + }); + + test('creates envelope with custom values', () => { + const envelope = createTestEnvelope('my-user', 'my-tenant', [1, 2]); + assert.equal(envelope.userId, 'my-user'); + assert.equal(envelope.tenantId, 'my-tenant'); + assert.deepEqual(envelope.data, [1, 2]); + }); + }); + + describe('Environment.activeTenantId', () => { + test('defaults to undefined', () => { + const env = new Environment('test-env'); + assert.isUndefined(env.getActiveTenantId()); + }); + + test('set/get round-trip', () => { + const env = new Environment('test-env'); + env.setActiveTenantId('tenant-abc'); + assert.equal(env.getActiveTenantId(), 'tenant-abc'); + }); + + test('propagates to child environments', () => { + const parent = new Environment('parent'); + parent.setActiveTenantId('tenant-xyz'); + const child = new Environment('child', parent); + assert.equal(child.getActiveTenantId(), 'tenant-xyz'); + }); + + test('child can override parent tenantId', () => { + const parent = new Environment('parent'); + parent.setActiveTenantId('tenant-parent'); + const child = new Environment('child', parent); + child.setActiveTenantId('tenant-child'); + assert.equal(child.getActiveTenantId(), 'tenant-child'); + assert.equal(parent.getActiveTenantId(), 'tenant-parent'); + }); + }); + + describe('onSubscription with envelope', () => { + test('undefined result returns immediately', async () => { + const resolver = new Resolver('test-sub'); + const result = await resolver.onSubscription(undefined); + assert.isUndefined(result); + }); + + test('raw result (no envelope) with callPostCrudEvent=false works unchanged', async () => { + const resolver = new Resolver('test-sub'); + // Without a subscription event registered, this should just return undefined + const result = await resolver.onSubscription({ someData: 'value' }, false); + assert.isUndefined(result); + }); + }); + + describe('Resolver.onCreate/onUpdate/onDelete with envelope', () => { + function mockInstance(overrides?: Record) { + return { + requireAudit: () => false, + record: { + getPostTriggerInfo: () => undefined, + getPreTriggerInfo: () => undefined, + }, + setAuthContext: vi.fn().mockReturnThis(), + ...overrides, + } as any; + } + + test('without envelope, behavior is unchanged', async () => { + const resolver = new Resolver('test'); + const env = new Environment('test-env'); + const inst = mockInstance(); + + await resolver.onCreate(inst, env); + assert.equal(env.getActiveUser(), AdminUserId); + assert.isUndefined(env.getActiveTenantId()); + }); + + test('with envelope, env gets correct activeUser and activeTenantId', async () => { + const resolver = new Resolver('test'); + const env = new Environment('test-env'); + const inst = mockInstance(); + const envelope = createSubscriptionEnvelope('tenant-99', 'user-99', {}); + + await resolver.onCreate(inst, env, envelope); + assert.equal(env.getActiveUser(), 'user-99'); + assert.equal(env.getActiveTenantId(), 'tenant-99'); + assert.isTrue(inst.setAuthContext.mock.calls.length > 0); + const sessionArg = inst.setAuthContext.mock.calls[0][0]; + assert.equal(sessionArg.userId, 'user-99'); + }); + + test('onUpdate with envelope sets auth context', async () => { + const resolver = new Resolver('test'); + const env = new Environment('test-env'); + const inst = mockInstance(); + const envelope = createSubscriptionEnvelope('tenant-u', 'user-u', {}); + + await resolver.onUpdate(inst, env, envelope); + assert.equal(env.getActiveUser(), 'user-u'); + assert.equal(env.getActiveTenantId(), 'tenant-u'); + }); + + test('onDelete with envelope sets auth context', async () => { + const resolver = new Resolver('test'); + const env = new Environment('test-env'); + const inst = mockInstance(); + const envelope = createSubscriptionEnvelope('tenant-d', 'user-d', {}); + + await resolver.onDelete(inst, env, envelope); + assert.equal(env.getActiveUser(), 'user-d'); + assert.equal(env.getActiveTenantId(), 'tenant-d'); + }); + }); +}); diff --git a/test/runtime/resolvers/integration.test.ts b/test/runtime/resolvers/integration.test.ts new file mode 100644 index 00000000..366e959a --- /dev/null +++ b/test/runtime/resolvers/integration.test.ts @@ -0,0 +1,392 @@ +import { assert, describe, test, beforeAll, afterAll, beforeEach } from 'vitest'; +import { doInternModule, doPreInit } from '../../util.js'; +import { + initDatabase, + resetDefaultDatabase, +} from '../../../src/runtime/resolvers/sqldb/database.js'; +import { parseAndEvaluateStatement } from '../../../src/runtime/interpreter.js'; +import { Instance } from '../../../src/runtime/module.js'; +import { + GenericResolver, + GenericResolverMethods, +} from '../../../src/runtime/resolvers/interface.js'; +import { + registerResolver, + setResolver, + resetResolverRegistry, +} from '../../../src/runtime/resolvers/registry.js'; + +describe('Resolver Integration Tests', () => { + let originalDbType: string | undefined; + + beforeAll(async () => { + originalDbType = process.env.AL_DB_TYPE; + process.env.AL_DB_TYPE = 'sqljs'; + await doPreInit(); + }); + + afterAll(async () => { + if (originalDbType !== undefined) { + process.env.AL_DB_TYPE = originalDbType; + } else { + delete process.env.AL_DB_TYPE; + } + await resetDefaultDatabase(); + }); + + beforeEach(async () => { + resetResolverRegistry(); + await resetDefaultDatabase(); + await initDatabase({ type: 'sqljs' }); + }); + + describe('Custom resolver basic CRUD', () => { + test('create: custom resolver receives instance and returns it', async () => { + const received: Instance[] = []; + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => { + received.push(inst); + return inst; + }, + upsert: undefined, + update: undefined, + query: undefined, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('create-test', () => new GenericResolver('create-test', methods)); + + await doInternModule('CrMod', 'entity Item {id Int @id, name String}'); + setResolver('CrMod/Item', 'create-test'); + + const result = await parseAndEvaluateStatement('{CrMod/Item {id 1, name "hello"}}'); + assert(result instanceof Instance); + assert.equal(result.lookup('name'), 'hello'); + assert.equal(received.length, 1); + assert.equal(received[0].lookup('id'), 1); + }); + + test('query: custom resolver returns instances from in-memory store', async () => { + const store = new Map(); + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => { + store.set(String(inst.lookup('id')), inst); + return inst; + }, + upsert: undefined, + update: undefined, + query: async (_res: any, inst: Instance, queryAll: boolean) => { + if (queryAll) return Array.from(store.values()); + const id = inst.queryAttributeValues?.get('id'); + if (id !== undefined) { + const found = store.get(String(id)); + return found ? [found] : []; + } + return Array.from(store.values()); + }, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('query-test', () => new GenericResolver('query-test', methods)); + + await doInternModule('QrMod', 'entity Widget {id Int @id, label String}'); + setResolver('QrMod/Widget', 'query-test'); + + await parseAndEvaluateStatement('{QrMod/Widget {id 1, label "A"}}'); + await parseAndEvaluateStatement('{QrMod/Widget {id 2, label "B"}}'); + + const all = (await parseAndEvaluateStatement('{QrMod/Widget? {}}')) as Instance[]; + assert.equal(all.length, 2); + + const one = (await parseAndEvaluateStatement('{QrMod/Widget {id? 1}}')) as Instance[]; + assert.equal(one.length, 1); + assert.equal(one[0].lookup('label'), 'A'); + }); + + test('upsert through custom resolver', async () => { + const store = new Map(); + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => { + store.set(String(inst.lookup('id')), inst); + return inst; + }, + upsert: async (_res: any, inst: Instance) => { + store.set(String(inst.lookup('id')), inst); + return inst; + }, + update: undefined, + query: async (_res: any, inst: Instance, queryAll: boolean) => { + if (queryAll) return Array.from(store.values()); + const id = inst.queryAttributeValues?.get('id'); + if (id !== undefined) { + const found = store.get(String(id)); + return found ? [found] : []; + } + return Array.from(store.values()); + }, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('upsert-test', () => new GenericResolver('upsert-test', methods)); + + await doInternModule('UpMod', 'entity Config {id Int @id, val String}'); + setResolver('UpMod/Config', 'upsert-test'); + + await parseAndEvaluateStatement('{UpMod/Config {id 1, val "first"}, @upsert}'); + const first = (await parseAndEvaluateStatement('{UpMod/Config {id? 1}}')) as Instance[]; + assert.equal(first.length, 1); + assert.equal(first[0].lookup('val'), 'first'); + + await parseAndEvaluateStatement('{UpMod/Config {id 1, val "second"}, @upsert}'); + const second = (await parseAndEvaluateStatement('{UpMod/Config {id? 1}}')) as Instance[]; + assert.equal(second.length, 1); + assert.equal(second[0].lookup('val'), 'second'); + }); + }); + + describe('Data transformation', () => { + test('resolver transforms data before returning on create', async () => { + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => { + const name = inst.lookup('name'); + if (typeof name === 'string') { + inst.attributes.set('name', name.toUpperCase()); + } + return inst; + }, + upsert: undefined, + update: undefined, + query: undefined, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('transform-test', () => new GenericResolver('transform-test', methods)); + + await doInternModule('TrMod', 'entity Doc {id Int @id, name String}'); + setResolver('TrMod/Doc', 'transform-test'); + + const result = (await parseAndEvaluateStatement( + '{TrMod/Doc {id 1, name "hello"}}' + )) as Instance; + assert.equal(result.lookup('name'), 'HELLO'); + }); + + test('resolver returns pre-seeded data from closure on query', async () => { + const seededData: Instance[] = []; + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => { + seededData.push(inst); + return inst; + }, + upsert: undefined, + update: undefined, + query: async () => { + return seededData; + }, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('seed-test', () => new GenericResolver('seed-test', methods)); + + await doInternModule('SdMod', 'entity Note {id Int @id, text String}'); + setResolver('SdMod/Note', 'seed-test'); + + await parseAndEvaluateStatement('{SdMod/Note {id 1, text "first"}}'); + await parseAndEvaluateStatement('{SdMod/Note {id 2, text "second"}}'); + + const results = (await parseAndEvaluateStatement('{SdMod/Note? {}}')) as Instance[]; + assert.equal(results.length, 2); + }); + }); + + describe('Multiple resolvers', () => { + test('two entities use two different custom resolvers with isolated stores', async () => { + const storeA = new Map(); + const storeB = new Map(); + + function makeStoreMethods(store: Map): GenericResolverMethods { + return { + create: async (_res: any, inst: Instance) => { + store.set(String(inst.lookup('id')), inst); + return inst; + }, + upsert: undefined, + update: undefined, + query: async (_res: any, _inst: Instance, _queryAll: boolean) => { + return Array.from(store.values()); + }, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + } + + const methodsA = makeStoreMethods(storeA); + const methodsB = makeStoreMethods(storeB); + registerResolver('res-a', () => new GenericResolver('res-a', methodsA)); + registerResolver('res-b', () => new GenericResolver('res-b', methodsB)); + + await doInternModule( + 'IsoMod', + 'entity Alpha {id Int @id, val String}\nentity Beta {id Int @id, val String}' + ); + setResolver('IsoMod/Alpha', 'res-a'); + setResolver('IsoMod/Beta', 'res-b'); + + await parseAndEvaluateStatement('{IsoMod/Alpha {id 1, val "a1"}}'); + await parseAndEvaluateStatement('{IsoMod/Beta {id 1, val "b1"}}'); + await parseAndEvaluateStatement('{IsoMod/Beta {id 2, val "b2"}}'); + + const alphas = (await parseAndEvaluateStatement('{IsoMod/Alpha? {}}')) as Instance[]; + const betas = (await parseAndEvaluateStatement('{IsoMod/Beta? {}}')) as Instance[]; + + assert.equal(alphas.length, 1); + assert.equal(betas.length, 2); + assert.equal(storeA.size, 1); + assert.equal(storeB.size, 2); + }); + + test('one entity uses custom resolver, another uses default SQL resolver', async () => { + const customStore = new Map(); + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => { + customStore.set(String(inst.lookup('id')), inst); + return inst; + }, + upsert: undefined, + update: undefined, + query: async () => { + return Array.from(customStore.values()); + }, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('custom-only', () => new GenericResolver('custom-only', methods)); + + await doInternModule( + 'MixMod', + 'entity Custom {id Int @id, val String}\nentity SqlBacked {id Int @id, val String}' + ); + setResolver('MixMod/Custom', 'custom-only'); + // SqlBacked uses default SQL resolver (no setResolver call) + + await parseAndEvaluateStatement('{MixMod/Custom {id 1, val "custom"}}'); + await parseAndEvaluateStatement('{MixMod/SqlBacked {id 1, val "sql"}}'); + + const customs = (await parseAndEvaluateStatement('{MixMod/Custom? {}}')) as Instance[]; + assert.equal(customs.length, 1); + assert.equal(customs[0].lookup('val'), 'custom'); + + const sqls = (await parseAndEvaluateStatement('{MixMod/SqlBacked? {}}')) as Instance[]; + assert.equal(sqls.length, 1); + assert.equal(sqls[0].lookup('val'), 'sql'); + }); + + test('same resolver mapped to two entities, receives correct entity names', async () => { + const receivedEntities: string[] = []; + const store = new Map(); + + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => { + receivedEntities.push(`${inst.moduleName}/${inst.name}`); + store.set(`${inst.moduleName}/${inst.name}:${inst.lookup('id')}`, inst); + return inst; + }, + upsert: undefined, + update: undefined, + query: async (_res: any, inst: Instance) => { + return Array.from(store.values()).filter( + i => i.moduleName === inst.moduleName && i.name === inst.name + ); + }, + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('shared-res', () => new GenericResolver('shared-res', methods)); + + await doInternModule( + 'ShMod', + 'entity Foo {id Int @id, x String}\nentity Bar {id Int @id, y String}' + ); + setResolver('ShMod/Foo', 'shared-res'); + setResolver('ShMod/Bar', 'shared-res'); + + await parseAndEvaluateStatement('{ShMod/Foo {id 1, x "fx"}}'); + await parseAndEvaluateStatement('{ShMod/Bar {id 1, y "by"}}'); + + assert(receivedEntities.includes('ShMod/Foo')); + assert(receivedEntities.includes('ShMod/Bar')); + + const foos = (await parseAndEvaluateStatement('{ShMod/Foo? {}}')) as Instance[]; + const bars = (await parseAndEvaluateStatement('{ShMod/Bar? {}}')) as Instance[]; + assert.equal(foos.length, 1); + assert.equal(bars.length, 1); + }); + }); + + describe('Partial methods', () => { + test('resolver with only create and query defined', async () => { + const store = new Map(); + const methods: GenericResolverMethods = { + create: async (_res: any, inst: Instance) => { + store.set(String(inst.lookup('id')), inst); + return inst; + }, + upsert: undefined, + update: undefined, + query: async () => Array.from(store.values()), + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('partial-cq', () => new GenericResolver('partial-cq', methods)); + + await doInternModule('PtMod', 'entity Thing {id Int @id, info String}'); + setResolver('PtMod/Thing', 'partial-cq'); + + const created = (await parseAndEvaluateStatement( + '{PtMod/Thing {id 1, info "test"}}' + )) as Instance; + assert.equal(created.lookup('info'), 'test'); + + const queried = (await parseAndEvaluateStatement('{PtMod/Thing? {}}')) as Instance[]; + assert.equal(queried.length, 1); + }); + + test('resolver with only query defined (read-only pattern)', async () => { + const methods: GenericResolverMethods = { + create: undefined, + upsert: undefined, + update: undefined, + query: async () => [], + delete: undefined, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + registerResolver('read-only', () => new GenericResolver('read-only', methods)); + + await doInternModule('RoMod', 'entity ReadOnly {id Int @id, data String}'); + setResolver('RoMod/ReadOnly', 'read-only'); + + const results = (await parseAndEvaluateStatement('{RoMod/ReadOnly? {}}')) as Instance[]; + assert.equal(results.length, 0); + }); + }); +}); diff --git a/test/runtime/resolvers/policy.test.ts b/test/runtime/resolvers/policy.test.ts new file mode 100644 index 00000000..4aac7480 --- /dev/null +++ b/test/runtime/resolvers/policy.test.ts @@ -0,0 +1,674 @@ +import { describe, test, assert, beforeEach, vi } from 'vitest'; +// Import interpreter first to establish correct module loading order (breaks circular dep) +import '../../../src/runtime/interpreter.js'; +import { + ConnectionPolicy, + parseConnectionPolicy, + registerConnectionPolicy, + getConnectionPolicy, + resetPolicyCache, + withTimeout, + withRetry, + withCircuitBreaker, + applyPolicies, + calculateDelay, + TimeoutError, + CircuitOpenError, + CircuitState, + getCircuitBreakerState, + resetCircuitBreakerState, + resetAllCircuitBreakerStates, + PolicyResolver, +} from '../../../src/runtime/resolvers/policy.js'; +import { createInMemoryResolver } from './utils.js'; +import { Instance } from '../../../src/runtime/module.js'; +import { + startPolicyRefreshTimer, + stopPolicyRefreshTimer, +} from '../../../src/runtime/modules/policy.js'; + +function mockInstance(id: string): Instance { + const attrs = new Map([['id', id]]); + // Use Object.create so instanceof Instance returns true + const inst = Object.create(Instance.prototype) as Instance; + inst.attributes = attrs; + inst.moduleName = 'test'; + inst.name = 'Entity'; + (inst as any).lookup = (key: string) => attrs.get(key); + return inst; +} + +function mockQueryInstance(id?: string): Instance { + const inst = mockInstance(id || ''); + if (id) { + inst.queryAttributeValues = new Map([['id', id]]); + } + return inst; +} + +// --------------------------------------------------------------------------- +// ConnectionPolicy model +// --------------------------------------------------------------------------- +describe('ConnectionPolicy model', () => { + test('parse valid policy from Map with all fields', () => { + const meta = new Map(); + const timeoutMap = new Map(); + timeoutMap.set('connectTimeoutMs', 3000); + timeoutMap.set('requestTimeoutMs', 15000); + + const backoffMap = new Map(); + backoffMap.set('strategy', 'linear'); + backoffMap.set('delayMs', 500); + backoffMap.set('factor', 3); + backoffMap.set('maxDelayMs', 10000); + + const retryMap = new Map(); + retryMap.set('maxAttempts', 5); + retryMap.set('backoff', backoffMap); + + const cbMap = new Map(); + cbMap.set('failureThreshold', 10); + cbMap.set('resetTimeoutMs', 30000); + cbMap.set('halfOpenMaxAttempts', 2); + + const cpMap = new Map(); + cpMap.set('timeout', timeoutMap); + cpMap.set('retry', retryMap); + cpMap.set('circuitBreaker', cbMap); + + meta.set('connectionPolicy', cpMap); + + const policy = parseConnectionPolicy('test/resolver', meta); + assert.ok(policy); + assert.equal(policy!.resolverName, 'test/resolver'); + assert.deepEqual(policy!.timeout, { connectTimeoutMs: 3000, requestTimeoutMs: 15000 }); + assert.equal(policy!.retry!.maxAttempts, 5); + assert.equal(policy!.retry!.backoff.strategy, 'linear'); + assert.equal(policy!.retry!.backoff.delayMs, 500); + assert.equal(policy!.retry!.backoff.factor, 3); + assert.equal(policy!.retry!.backoff.maxDelayMs, 10000); + assert.deepEqual(policy!.circuitBreaker, { + failureThreshold: 10, + resetTimeoutMs: 30000, + halfOpenMaxAttempts: 2, + }); + }); + + test('parse partial policy - only timeout', () => { + const meta = new Map(); + const timeoutMap = new Map(); + timeoutMap.set('connectTimeoutMs', 2000); + + const cpMap = new Map(); + cpMap.set('timeout', timeoutMap); + meta.set('connectionPolicy', cpMap); + + const policy = parseConnectionPolicy('test/resolver', meta); + assert.ok(policy); + assert.ok(policy!.timeout); + assert.equal(policy!.timeout!.connectTimeoutMs, 2000); + assert.equal(policy!.timeout!.requestTimeoutMs, 30000); // default + assert.equal(policy!.retry, undefined); + assert.equal(policy!.circuitBreaker, undefined); + }); + + test('parse partial policy - only retry', () => { + const meta = new Map(); + const retryMap = new Map(); + retryMap.set('maxAttempts', 4); + + const cpMap = new Map(); + cpMap.set('retry', retryMap); + meta.set('connectionPolicy', cpMap); + + const policy = parseConnectionPolicy('test/resolver', meta); + assert.ok(policy); + assert.equal(policy!.timeout, undefined); + assert.ok(policy!.retry); + assert.equal(policy!.retry!.maxAttempts, 4); + // defaults for backoff + assert.equal(policy!.retry!.backoff.strategy, 'exponential'); + assert.equal(policy!.retry!.backoff.delayMs, 1000); + }); + + test('parse partial policy - only circuit breaker', () => { + const meta = new Map(); + const cbMap = new Map(); + cbMap.set('failureThreshold', 3); + + const cpMap = new Map(); + cpMap.set('circuitBreaker', cbMap); + meta.set('connectionPolicy', cpMap); + + const policy = parseConnectionPolicy('test/resolver', meta); + assert.ok(policy); + assert.equal(policy!.timeout, undefined); + assert.equal(policy!.retry, undefined); + assert.ok(policy!.circuitBreaker); + assert.equal(policy!.circuitBreaker!.failureThreshold, 3); + assert.equal(policy!.circuitBreaker!.resetTimeoutMs, 60000); // default + assert.equal(policy!.circuitBreaker!.halfOpenMaxAttempts, 1); // default + }); + + test('parse empty/missing connectionPolicy returns undefined', () => { + const meta = new Map(); + assert.equal(parseConnectionPolicy('test/resolver', meta), undefined); + + const meta2 = new Map(); + meta2.set('someOtherKey', 'value'); + assert.equal(parseConnectionPolicy('test/resolver', meta2), undefined); + }); + + test('default values applied correctly', () => { + const meta = new Map(); + const cpMap = new Map(); + // Empty sub-maps to trigger defaults + cpMap.set('timeout', new Map()); + cpMap.set('retry', new Map()); + cpMap.set('circuitBreaker', new Map()); + meta.set('connectionPolicy', cpMap); + + const policy = parseConnectionPolicy('test/resolver', meta); + assert.ok(policy); + assert.deepEqual(policy!.timeout, { connectTimeoutMs: 5000, requestTimeoutMs: 30000 }); + assert.equal(policy!.retry!.maxAttempts, 3); + assert.equal(policy!.retry!.backoff.strategy, 'exponential'); + assert.equal(policy!.retry!.backoff.delayMs, 1000); + assert.equal(policy!.retry!.backoff.factor, 2); + assert.equal(policy!.retry!.backoff.maxDelayMs, 30000); + assert.deepEqual(policy!.circuitBreaker, { + failureThreshold: 5, + resetTimeoutMs: 60000, + halfOpenMaxAttempts: 1, + }); + }); + + test('toJSON / fromJSON round-trip', () => { + const policy = new ConnectionPolicy('test/resolver'); + policy.timeout = { connectTimeoutMs: 3000, requestTimeoutMs: 15000 }; + policy.retry = { + maxAttempts: 5, + backoff: { strategy: 'linear', delayMs: 500, factor: 3, maxDelayMs: 10000 }, + }; + policy.circuitBreaker = { failureThreshold: 10, resetTimeoutMs: 30000, halfOpenMaxAttempts: 2 }; + + const json = policy.toJSON(); + const restored = ConnectionPolicy.fromJSON('test/resolver', json); + + assert.deepEqual(restored.timeout, policy.timeout); + assert.deepEqual(restored.retry, policy.retry); + assert.deepEqual(restored.circuitBreaker, policy.circuitBreaker); + assert.equal(restored.resolverName, 'test/resolver'); + }); + + test('hasAnyPolicy returns false for empty policy', () => { + const policy = new ConnectionPolicy('test/resolver'); + assert.equal(policy.hasAnyPolicy(), false); + }); + + test('hasAnyPolicy returns true when any policy is set', () => { + const p1 = new ConnectionPolicy('test/resolver'); + p1.timeout = { connectTimeoutMs: 5000, requestTimeoutMs: 30000 }; + assert.equal(p1.hasAnyPolicy(), true); + + const p2 = new ConnectionPolicy('test/resolver'); + p2.retry = { + maxAttempts: 3, + backoff: { strategy: 'exponential', delayMs: 1000, factor: 2, maxDelayMs: 30000 }, + }; + assert.equal(p2.hasAnyPolicy(), true); + + const p3 = new ConnectionPolicy('test/resolver'); + p3.circuitBreaker = { failureThreshold: 5, resetTimeoutMs: 60000, halfOpenMaxAttempts: 1 }; + assert.equal(p3.hasAnyPolicy(), true); + }); +}); + +// --------------------------------------------------------------------------- +// Policy Cache +// --------------------------------------------------------------------------- +describe('Policy cache', () => { + beforeEach(() => { + resetPolicyCache(); + }); + + test('register and retrieve policy', () => { + const policy = new ConnectionPolicy('test/resolver'); + policy.timeout = { connectTimeoutMs: 1000, requestTimeoutMs: 5000 }; + registerConnectionPolicy('test/resolver', policy); + const retrieved = getConnectionPolicy('test/resolver'); + assert.ok(retrieved); + assert.equal(retrieved!.resolverName, 'test/resolver'); + }); + + test('returns undefined for unregistered resolver', () => { + assert.equal(getConnectionPolicy('nonexistent'), undefined); + }); + + test('reset clears all entries', () => { + const policy = new ConnectionPolicy('test/resolver'); + policy.timeout = { connectTimeoutMs: 1000, requestTimeoutMs: 5000 }; + registerConnectionPolicy('test/resolver', policy); + resetPolicyCache(); + assert.equal(getConnectionPolicy('test/resolver'), undefined); + }); +}); + +// --------------------------------------------------------------------------- +// Timeout enforcer +// --------------------------------------------------------------------------- +describe('withTimeout', () => { + test('completes within limit', async () => { + const result = await withTimeout(async () => 'ok', 1000, 'test-op'); + assert.equal(result, 'ok'); + }); + + test('exceeds limit and rejects', async () => { + try { + await withTimeout( + () => new Promise(resolve => setTimeout(() => resolve('late'), 500)), + 50, + 'test-op' + ); + assert.fail('should have thrown'); + } catch (err: any) { + assert.ok(err instanceof TimeoutError); + assert.ok(err.message.includes('test-op')); + assert.ok(err.message.includes('50ms')); + } + }); +}); + +// --------------------------------------------------------------------------- +// Retry enforcer +// --------------------------------------------------------------------------- +describe('withRetry', () => { + const fastRetryPolicy = { + maxAttempts: 3, + backoff: { strategy: 'constant' as const, delayMs: 10, factor: 1, maxDelayMs: 100 }, + }; + + test('succeeds first try', async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls++; + return 'ok'; + }, + fastRetryPolicy, + 'test-op' + ); + assert.equal(result, 'ok'); + assert.equal(calls, 1); + }); + + test('fails then succeeds', async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls++; + if (calls < 3) throw new Error('fail'); + return 'ok'; + }, + fastRetryPolicy, + 'test-op' + ); + assert.equal(result, 'ok'); + assert.equal(calls, 3); + }); + + test('exhausts all attempts', async () => { + let calls = 0; + try { + await withRetry( + async () => { + calls++; + throw new Error('always fail'); + }, + fastRetryPolicy, + 'test-op' + ); + assert.fail('should have thrown'); + } catch (err: any) { + assert.equal(err.message, 'always fail'); + assert.equal(calls, 3); + } + }); +}); + +// --------------------------------------------------------------------------- +// Backoff delay calculation +// --------------------------------------------------------------------------- +describe('calculateDelay', () => { + test('exponential backoff', () => { + const backoff = { + strategy: 'exponential' as const, + delayMs: 1000, + factor: 2, + maxDelayMs: 30000, + }; + assert.equal(calculateDelay(0, backoff), 1000); // 1000 * 2^0 = 1000 + assert.equal(calculateDelay(1, backoff), 2000); // 1000 * 2^1 = 2000 + assert.equal(calculateDelay(2, backoff), 4000); // 1000 * 2^2 = 4000 + assert.equal(calculateDelay(3, backoff), 8000); // 1000 * 2^3 = 8000 + }); + + test('linear backoff', () => { + const backoff = { strategy: 'linear' as const, delayMs: 1000, factor: 2, maxDelayMs: 30000 }; + assert.equal(calculateDelay(0, backoff), 1000); // 1000 * 1 + assert.equal(calculateDelay(1, backoff), 2000); // 1000 * 2 + assert.equal(calculateDelay(2, backoff), 3000); // 1000 * 3 + }); + + test('constant backoff', () => { + const backoff = { strategy: 'constant' as const, delayMs: 500, factor: 2, maxDelayMs: 30000 }; + assert.equal(calculateDelay(0, backoff), 500); + assert.equal(calculateDelay(1, backoff), 500); + assert.equal(calculateDelay(5, backoff), 500); + }); + + test('respects maxDelayMs', () => { + const backoff = { + strategy: 'exponential' as const, + delayMs: 1000, + factor: 10, + maxDelayMs: 5000, + }; + assert.equal(calculateDelay(0, backoff), 1000); + assert.equal(calculateDelay(1, backoff), 5000); // capped at maxDelayMs + assert.equal(calculateDelay(2, backoff), 5000); // capped + }); +}); + +// --------------------------------------------------------------------------- +// Circuit breaker enforcer +// --------------------------------------------------------------------------- +describe('withCircuitBreaker', () => { + const cbPolicy = { failureThreshold: 3, resetTimeoutMs: 100, halfOpenMaxAttempts: 1 }; + const resolverName = 'test-cb-resolver'; + + beforeEach(() => { + resetCircuitBreakerState(resolverName); + }); + + test('closed state passes through', async () => { + const result = await withCircuitBreaker(async () => 'ok', cbPolicy, resolverName, 'test-op'); + assert.equal(result, 'ok'); + const state = getCircuitBreakerState(resolverName); + assert.equal(state.state, CircuitState.CLOSED); + assert.equal(state.failureCount, 0); + }); + + test('opens after threshold failures', async () => { + for (let i = 0; i < 3; i++) { + try { + await withCircuitBreaker( + async () => { + throw new Error('fail'); + }, + cbPolicy, + resolverName, + 'test-op' + ); + } catch {} + } + const state = getCircuitBreakerState(resolverName); + assert.equal(state.state, CircuitState.OPEN); + assert.equal(state.failureCount, 3); + }); + + test('rejects in open state', async () => { + // Force open + const state = getCircuitBreakerState(resolverName); + state.state = CircuitState.OPEN; + state.lastFailureTime = Date.now(); + + try { + await withCircuitBreaker(async () => 'ok', cbPolicy, resolverName, 'test-op'); + assert.fail('should have thrown'); + } catch (err: any) { + assert.ok(err instanceof CircuitOpenError); + } + }); + + test('transitions to half-open after reset timeout', async () => { + const state = getCircuitBreakerState(resolverName); + state.state = CircuitState.OPEN; + state.lastFailureTime = Date.now() - 200; // well past the 100ms reset timeout + + const result = await withCircuitBreaker(async () => 'ok', cbPolicy, resolverName, 'test-op'); + assert.equal(result, 'ok'); + assert.equal(state.state, CircuitState.CLOSED); // success closes it + }); + + test('closes on half-open success', async () => { + const state = getCircuitBreakerState(resolverName); + state.state = CircuitState.OPEN; + state.lastFailureTime = Date.now() - 200; + + await withCircuitBreaker(async () => 'ok', cbPolicy, resolverName, 'test-op'); + assert.equal(state.state, CircuitState.CLOSED); + assert.equal(state.failureCount, 0); + }); + + test('reopens on half-open failure', async () => { + const state = getCircuitBreakerState(resolverName); + state.state = CircuitState.OPEN; + state.lastFailureTime = Date.now() - 200; + + try { + await withCircuitBreaker( + async () => { + throw new Error('fail in half-open'); + }, + cbPolicy, + resolverName, + 'test-op' + ); + } catch {} + + assert.equal(state.state, CircuitState.OPEN); + }); +}); + +// --------------------------------------------------------------------------- +// applyPolicies +// --------------------------------------------------------------------------- +describe('applyPolicies', () => { + test('chains timeout + retry + circuit breaker correctly', async () => { + resetAllCircuitBreakerStates(); + const policy = new ConnectionPolicy('test/resolver'); + policy.timeout = { connectTimeoutMs: 5000, requestTimeoutMs: 1000 }; + policy.retry = { + maxAttempts: 2, + backoff: { strategy: 'constant', delayMs: 10, factor: 1, maxDelayMs: 100 }, + }; + policy.circuitBreaker = { failureThreshold: 5, resetTimeoutMs: 60000, halfOpenMaxAttempts: 1 }; + + let calls = 0; + const result = await applyPolicies( + policy, + async () => { + calls++; + if (calls < 2) throw new Error('transient'); + return 'success'; + }, + 'test-op' + ); + assert.equal(result, 'success'); + assert.equal(calls, 2); + }); + + test('applies only timeout when only timeout is set', async () => { + const policy = new ConnectionPolicy('test/resolver'); + policy.timeout = { connectTimeoutMs: 5000, requestTimeoutMs: 500 }; + + const result = await applyPolicies(policy, async () => 'ok', 'test-op'); + assert.equal(result, 'ok'); + }); + + test('applies no wrapping for empty policy', async () => { + const policy = new ConnectionPolicy('test/resolver'); + const result = await applyPolicies(policy, async () => 'ok', 'test-op'); + assert.equal(result, 'ok'); + }); +}); + +// --------------------------------------------------------------------------- +// PolicyResolver +// --------------------------------------------------------------------------- +describe('PolicyResolver', () => { + test('CRUD operations pass through with policies active', async () => { + const { resolver: inner, store } = createInMemoryResolver('test-inner'); + const policy = new ConnectionPolicy('test-inner'); + policy.timeout = { connectTimeoutMs: 5000, requestTimeoutMs: 5000 }; + const wrapped = new PolicyResolver(inner, policy); + + // Create + const inst = mockInstance('1'); + const created = await wrapped.createInstance(inst); + assert.ok(created); + assert.equal(store.size, 1); + + // Query + const queryInst = mockQueryInstance('1'); + const results = await wrapped.queryInstances(queryInst, false); + assert.ok(results instanceof Array); + assert.equal(results.length, 1); + + // Upsert + const upsertInst = mockInstance('2'); + await wrapped.upsertInstance(upsertInst); + assert.equal(store.size, 2); + + // Delete + const delInst = mockInstance('1'); + await wrapped.deleteInstance(delInst, false); + assert.equal(store.size, 1); + }); + + test('timeout causes failure propagation', async () => { + const { resolver: inner } = createInMemoryResolver('test-inner-timeout'); + const policy = new ConnectionPolicy('test-inner-timeout'); + policy.timeout = { connectTimeoutMs: 1000, requestTimeoutMs: 50 }; + const wrapped = new PolicyResolver(inner, policy); + + // Override the inner resolver's query to be slow + inner.implementation!.query = async () => { + return new Promise(resolve => setTimeout(() => resolve([]), 500)); + }; + + const queryInst = mockQueryInstance(); + try { + await wrapped.queryInstances(queryInst, true); + assert.fail('should have thrown'); + } catch (err: any) { + assert.ok(err instanceof TimeoutError); + } + }); + + test('transaction methods pass through without policy wrapping', async () => { + const { resolver: inner } = createInMemoryResolver('test-inner-txn'); + const policy = new ConnectionPolicy('test-inner-txn'); + policy.timeout = { connectTimeoutMs: 100, requestTimeoutMs: 100 }; + const wrapped = new PolicyResolver(inner, policy); + + // These should not throw even though they are "not implemented" - they just pass through + const txnId = await wrapped.startTransaction(); + await wrapped.commitTransaction(String(txnId)); + await wrapped.rollbackTransaction(String(txnId)); + }); + + test('setEnvironment and setAuthInfo pass through to inner', () => { + const { resolver: inner } = createInMemoryResolver('test-inner-env'); + const policy = new ConnectionPolicy('test-inner-env'); + const wrapped = new PolicyResolver(inner, policy); + + // These should not throw and should delegate to inner + wrapped.setAuthInfo({ user: 'test', role: 'admin' } as any); + assert.equal(wrapped.getEnvironment(), undefined); + }); +}); + +// --------------------------------------------------------------------------- +// Policy refresh timer +// --------------------------------------------------------------------------- +describe('Policy refresh timer', () => { + beforeEach(() => { + stopPolicyRefreshTimer(); + vi.restoreAllMocks(); + }); + + test('startPolicyRefreshTimer starts an interval', () => { + const spy = vi.spyOn(globalThis, 'setInterval'); + startPolicyRefreshTimer(60); + assert.equal(spy.mock.calls.length, 1); + assert.equal(spy.mock.calls[0][1], 60000); // 60 seconds in ms + stopPolicyRefreshTimer(); + }); + + test('startPolicyRefreshTimer uses default 300s when no arg', () => { + const spy = vi.spyOn(globalThis, 'setInterval'); + startPolicyRefreshTimer(); + assert.equal(spy.mock.calls.length, 1); + assert.equal(spy.mock.calls[0][1], 300000); // 300 seconds in ms + stopPolicyRefreshTimer(); + }); + + test('calling startPolicyRefreshTimer twice clears previous timer', () => { + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); + startPolicyRefreshTimer(10); + startPolicyRefreshTimer(20); + // Second call should have cleared the first timer + assert.equal(clearIntervalSpy.mock.calls.length, 1); + assert.equal(setIntervalSpy.mock.calls.length, 2); + stopPolicyRefreshTimer(); + }); + + test('stopPolicyRefreshTimer clears the timer', () => { + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); + startPolicyRefreshTimer(10); + stopPolicyRefreshTimer(); + assert.equal(clearIntervalSpy.mock.calls.length, 1); + // Calling stop again should be a no-op (no extra clearInterval) + stopPolicyRefreshTimer(); + assert.equal(clearIntervalSpy.mock.calls.length, 1); + }); +}); + +// --------------------------------------------------------------------------- +// Dynamic cache propagation to factory +// --------------------------------------------------------------------------- +describe('Dynamic cache propagation', () => { + beforeEach(() => { + resetPolicyCache(); + }); + + test('updating the policy cache changes what a factory would produce', () => { + // Simulate: factory reads from cache, not a closure variable + const resolverName = 'test/dynamic-resolver'; + + // No policy registered yet + assert.equal(getConnectionPolicy(resolverName), undefined); + + // Register a policy (simulates DB refresh) + const policy = new ConnectionPolicy(resolverName); + policy.timeout = { connectTimeoutMs: 1000, requestTimeoutMs: 5000 }; + registerConnectionPolicy(resolverName, policy); + + // Now the cache returns it + const fetched = getConnectionPolicy(resolverName); + assert.ok(fetched); + assert.equal(fetched!.timeout!.requestTimeoutMs, 5000); + + // Update the cache with a new policy (simulates next refresh cycle) + const updated = new ConnectionPolicy(resolverName); + updated.timeout = { connectTimeoutMs: 2000, requestTimeoutMs: 10000 }; + registerConnectionPolicy(resolverName, updated); + + const refetched = getConnectionPolicy(resolverName); + assert.ok(refetched); + assert.equal(refetched!.timeout!.requestTimeoutMs, 10000); + }); +}); diff --git a/test/runtime/resolvers/registry.test.ts b/test/runtime/resolvers/registry.test.ts new file mode 100644 index 00000000..454a67d5 --- /dev/null +++ b/test/runtime/resolvers/registry.test.ts @@ -0,0 +1,100 @@ +import { describe, test, assert, beforeEach } from 'vitest'; +// Import interpreter first to establish correct module loading order (breaks circular dep) +import '../../../src/runtime/interpreter.js'; +import { + registerResolver, + setResolver, + getResolver, + getResolverNameForPath, + resetResolverRegistry, +} from '../../../src/runtime/resolvers/registry.js'; +import { Resolver, GenericResolver } from '../../../src/runtime/resolvers/interface.js'; + +describe('Resolver Registry', () => { + beforeEach(() => { + resetResolverRegistry(); + }); + + test('registerResolver stores factory and returns name', () => { + const name = registerResolver('test-res', () => new Resolver('test-res')); + assert.equal(name, 'test-res'); + }); + + test('setResolver maps entity path to resolver name', () => { + registerResolver('myRes', () => new Resolver('myRes')); + setResolver('Mod/Entity', 'myRes'); + assert.equal(getResolverNameForPath('Mod/Entity'), 'myRes'); + }); + + test('setResolver throws when resolver name not registered', () => { + assert.throws(() => setResolver('Mod/Entity', 'nonexistent'), /Resolver not found/); + }); + + test('getResolver returns resolver instance for mapped path', () => { + registerResolver('gr', () => new GenericResolver('gr')); + setResolver('Mod/E', 'gr'); + const r = getResolver('Mod/E'); + assert(r instanceof GenericResolver); + assert.equal(r.getName(), 'gr'); + }); + + test('getResolver calls factory each time (fresh instance)', () => { + let callCount = 0; + registerResolver('counter', () => { + callCount++; + return new Resolver('counter'); + }); + setResolver('Mod/X', 'counter'); + + getResolver('Mod/X'); + getResolver('Mod/X'); + assert.equal(callCount, 2); + }); + + test('getResolver throws for unmapped path', () => { + assert.throws(() => getResolver('Unknown/Path'), /No resolver registered for/); + }); + + test('getResolverNameForPath returns undefined for unmapped path', () => { + assert.equal(getResolverNameForPath('Unknown/Path'), undefined); + }); + + test('path remapping to a different resolver', () => { + registerResolver('res1', () => new Resolver('res1')); + registerResolver('res2', () => new Resolver('res2')); + setResolver('Mod/E', 'res1'); + assert.equal(getResolverNameForPath('Mod/E'), 'res1'); + + setResolver('Mod/E', 'res2'); + assert.equal(getResolverNameForPath('Mod/E'), 'res2'); + + const r = getResolver('Mod/E'); + assert.equal(r.getName(), 'res2'); + }); + + test('multiple paths to same resolver', () => { + registerResolver('shared', () => new Resolver('shared')); + setResolver('Mod/A', 'shared'); + setResolver('Mod/B', 'shared'); + + assert.equal(getResolverNameForPath('Mod/A'), 'shared'); + assert.equal(getResolverNameForPath('Mod/B'), 'shared'); + + const r1 = getResolver('Mod/A'); + const r2 = getResolver('Mod/B'); + assert.equal(r1.getName(), 'shared'); + assert.equal(r2.getName(), 'shared'); + }); + + test('resetResolverRegistry clears all mappings', () => { + registerResolver('temp', () => new Resolver('temp')); + setResolver('Mod/T', 'temp'); + assert.equal(getResolverNameForPath('Mod/T'), 'temp'); + + resetResolverRegistry(); + + assert.equal(getResolverNameForPath('Mod/T'), undefined); + assert.throws(() => getResolver('Mod/T'), /No resolver registered for/); + assert.throws(() => setResolver('Mod/T', 'temp'), /Resolver not found/); + }); +}); diff --git a/test/runtime/resolvers/utils.ts b/test/runtime/resolvers/utils.ts new file mode 100644 index 00000000..56023a20 --- /dev/null +++ b/test/runtime/resolvers/utils.ts @@ -0,0 +1,90 @@ +import { + GenericResolver, + GenericResolverMethods, +} from '../../../src/runtime/resolvers/interface.js'; +import { registerResolver, setResolver } from '../../../src/runtime/resolvers/registry.js'; +import { Instance, InstanceAttributes } from '../../../src/runtime/module.js'; +import { + SubscriptionEnvelope, + createSubscriptionEnvelope, +} from '../../../src/runtime/resolvers/envelope.js'; + +/** + * Creates a GenericResolver backed by an in-memory Map with full CRUD support. + */ +export function createInMemoryResolver(name: string): { + resolver: GenericResolver; + store: Map; +} { + const store = new Map(); + + const methods: GenericResolverMethods = { + create: async (_resolver: any, inst: Instance) => { + const id = String(inst.lookup('id')); + store.set(id, inst); + return inst; + }, + upsert: async (_resolver: any, inst: Instance) => { + const id = String(inst.lookup('id')); + store.set(id, inst); + return inst; + }, + update: async (_resolver: any, inst: Instance, _newAttrs: InstanceAttributes) => { + const id = String(inst.lookup('id')); + store.set(id, inst); + return inst; + }, + query: async (_resolver: any, inst: Instance, queryAll: boolean) => { + if (queryAll) { + return Array.from(store.values()); + } + const id = inst.queryAttributeValues?.get('id'); + if (id !== undefined) { + const found = store.get(String(id)); + return found ? [found] : []; + } + return Array.from(store.values()); + }, + delete: async (_resolver: any, inst: Instance) => { + if (inst instanceof Instance) { + const id = String(inst.lookup('id')); + store.delete(id); + } + return inst; + }, + startTransaction: undefined, + commitTransaction: undefined, + rollbackTransaction: undefined, + }; + + const resolver = new GenericResolver(name, methods); + return { resolver, store }; +} + +const defaultTestUserId = '11111111-1111-1111-1111-111111111111'; +const defaultTestTenantId = '22222222-2222-2222-2222-222222222222'; + +/** + * Creates a SubscriptionEnvelope for testing. + */ +export function createTestEnvelope( + userId: string = defaultTestUserId, + tenantId: string = defaultTestTenantId, + data: any = { foo: 'bar' } +): SubscriptionEnvelope { + return createSubscriptionEnvelope(tenantId, userId, data); +} + +/** + * Registers a resolver factory and maps an entity path to it in one call. + */ +export function registerTestResolver( + resolverName: string, + entityPath: string, + methods: GenericResolverMethods +): GenericResolver { + const resolver = new GenericResolver(resolverName, methods); + registerResolver(resolverName, () => new GenericResolver(resolverName, methods)); + setResolver(entityPath, resolverName); + return resolver; +}