diff --git a/content/docs/references/system/CronSchedule.mdx b/content/docs/references/system/CronSchedule.mdx new file mode 100644 index 0000000..805be32 --- /dev/null +++ b/content/docs/references/system/CronSchedule.mdx @@ -0,0 +1,12 @@ +--- +title: CronSchedule +description: CronSchedule Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | | +| **expression** | `string` | ✅ | Cron expression (e.g., "0 0 * * *" for daily at midnight) | +| **timezone** | `string` | optional | Timezone for cron execution (e.g., "America/New_York") | diff --git a/content/docs/references/system/Event.mdx b/content/docs/references/system/Event.mdx new file mode 100644 index 0000000..7ae738f --- /dev/null +++ b/content/docs/references/system/Event.mdx @@ -0,0 +1,12 @@ +--- +title: Event +description: Event Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Event name (snake_case with dots, e.g., user.created) | +| **payload** | `any` | optional | Event payload schema | +| **metadata** | `object` | ✅ | Event metadata | diff --git a/content/docs/references/system/EventHandler.mdx b/content/docs/references/system/EventHandler.mdx new file mode 100644 index 0000000..994cd54 --- /dev/null +++ b/content/docs/references/system/EventHandler.mdx @@ -0,0 +1,12 @@ +--- +title: EventHandler +description: EventHandler Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **eventName** | `string` | ✅ | Name of event to handle (supports wildcards like user.*) | +| **priority** | `integer` | optional | Execution priority (lower numbers execute first) | +| **async** | `boolean` | optional | Execute in background (true) or block (false) | diff --git a/content/docs/references/system/EventMetadata.mdx b/content/docs/references/system/EventMetadata.mdx new file mode 100644 index 0000000..86e4254 --- /dev/null +++ b/content/docs/references/system/EventMetadata.mdx @@ -0,0 +1,13 @@ +--- +title: EventMetadata +description: EventMetadata Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **source** | `string` | ✅ | Event source (e.g., plugin name, system component) | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when event was created | +| **userId** | `string` | optional | User who triggered the event | +| **tenantId** | `string` | optional | Tenant identifier for multi-tenant systems | diff --git a/content/docs/references/system/EventPersistence.mdx b/content/docs/references/system/EventPersistence.mdx new file mode 100644 index 0000000..eac9960 --- /dev/null +++ b/content/docs/references/system/EventPersistence.mdx @@ -0,0 +1,11 @@ +--- +title: EventPersistence +description: EventPersistence Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | optional | Enable event persistence | +| **retention** | `integer` | ✅ | Days to retain persisted events | diff --git a/content/docs/references/system/EventRoute.mdx b/content/docs/references/system/EventRoute.mdx new file mode 100644 index 0000000..39c4171 --- /dev/null +++ b/content/docs/references/system/EventRoute.mdx @@ -0,0 +1,11 @@ +--- +title: EventRoute +description: EventRoute Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **from** | `string` | ✅ | Source event pattern (supports wildcards, e.g., user.* or *.created) | +| **to** | `string[]` | ✅ | Target event names to route to | diff --git a/content/docs/references/system/IntervalSchedule.mdx b/content/docs/references/system/IntervalSchedule.mdx new file mode 100644 index 0000000..e73d6f8 --- /dev/null +++ b/content/docs/references/system/IntervalSchedule.mdx @@ -0,0 +1,11 @@ +--- +title: IntervalSchedule +description: IntervalSchedule Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | | +| **intervalMs** | `integer` | ✅ | Interval in milliseconds | diff --git a/content/docs/references/system/Job.mdx b/content/docs/references/system/Job.mdx new file mode 100644 index 0000000..43c9aff --- /dev/null +++ b/content/docs/references/system/Job.mdx @@ -0,0 +1,15 @@ +--- +title: Job +description: Job Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique job identifier | +| **name** | `string` | ✅ | Job name (snake_case) | +| **schedule** | `object \| object \| object` | ✅ | Job schedule configuration | +| **retryPolicy** | `object` | optional | Retry policy configuration | +| **timeout** | `integer` | optional | Timeout in milliseconds | +| **enabled** | `boolean` | optional | Whether the job is enabled | diff --git a/content/docs/references/system/JobExecution.mdx b/content/docs/references/system/JobExecution.mdx new file mode 100644 index 0000000..e192192 --- /dev/null +++ b/content/docs/references/system/JobExecution.mdx @@ -0,0 +1,15 @@ +--- +title: JobExecution +description: JobExecution Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **jobId** | `string` | ✅ | Job identifier | +| **startedAt** | `string` | ✅ | ISO 8601 datetime when execution started | +| **completedAt** | `string` | optional | ISO 8601 datetime when execution completed | +| **status** | `Enum<'running' \| 'success' \| 'failed' \| 'timeout'>` | ✅ | Execution status | +| **error** | `string` | optional | Error message if failed | +| **duration** | `integer` | optional | Execution duration in milliseconds | diff --git a/content/docs/references/system/JobExecutionStatus.mdx b/content/docs/references/system/JobExecutionStatus.mdx new file mode 100644 index 0000000..32b3eef --- /dev/null +++ b/content/docs/references/system/JobExecutionStatus.mdx @@ -0,0 +1,11 @@ +--- +title: JobExecutionStatus +description: JobExecutionStatus Schema Reference +--- + +## Allowed Values + +* `running` +* `success` +* `failed` +* `timeout` \ No newline at end of file diff --git a/content/docs/references/system/OnceSchedule.mdx b/content/docs/references/system/OnceSchedule.mdx new file mode 100644 index 0000000..f3b49aa --- /dev/null +++ b/content/docs/references/system/OnceSchedule.mdx @@ -0,0 +1,11 @@ +--- +title: OnceSchedule +description: OnceSchedule Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | | +| **at** | `string` | ✅ | ISO 8601 datetime when to execute | diff --git a/content/docs/references/system/Presence.mdx b/content/docs/references/system/Presence.mdx new file mode 100644 index 0000000..014e960 --- /dev/null +++ b/content/docs/references/system/Presence.mdx @@ -0,0 +1,13 @@ +--- +title: Presence +description: Presence Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **userId** | `string` | ✅ | User identifier | +| **status** | `Enum<'online' \| 'away' \| 'offline'>` | ✅ | Current presence status | +| **lastSeen** | `string` | ✅ | ISO 8601 datetime of last activity | +| **metadata** | `Record` | optional | Custom presence data (e.g., current page, custom status) | diff --git a/content/docs/references/system/PresenceStatus.mdx b/content/docs/references/system/PresenceStatus.mdx new file mode 100644 index 0000000..f884a53 --- /dev/null +++ b/content/docs/references/system/PresenceStatus.mdx @@ -0,0 +1,10 @@ +--- +title: PresenceStatus +description: PresenceStatus Schema Reference +--- + +## Allowed Values + +* `online` +* `away` +* `offline` \ No newline at end of file diff --git a/content/docs/references/system/RealtimeAction.mdx b/content/docs/references/system/RealtimeAction.mdx new file mode 100644 index 0000000..2f1af2e --- /dev/null +++ b/content/docs/references/system/RealtimeAction.mdx @@ -0,0 +1,10 @@ +--- +title: RealtimeAction +description: RealtimeAction Schema Reference +--- + +## Allowed Values + +* `created` +* `updated` +* `deleted` \ No newline at end of file diff --git a/content/docs/references/system/RealtimeEvent.mdx b/content/docs/references/system/RealtimeEvent.mdx new file mode 100644 index 0000000..46a1b1b --- /dev/null +++ b/content/docs/references/system/RealtimeEvent.mdx @@ -0,0 +1,16 @@ +--- +title: RealtimeEvent +description: RealtimeEvent Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique event identifier | +| **type** | `string` | ✅ | Event type (e.g., record.created, record.updated) | +| **object** | `string` | optional | Object name the event relates to | +| **action** | `Enum<'created' \| 'updated' \| 'deleted'>` | optional | Action performed | +| **payload** | `any` | optional | Event payload data | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when event occurred | +| **userId** | `string` | optional | User who triggered the event | diff --git a/content/docs/references/system/RealtimeEventType.mdx b/content/docs/references/system/RealtimeEventType.mdx new file mode 100644 index 0000000..56c877e --- /dev/null +++ b/content/docs/references/system/RealtimeEventType.mdx @@ -0,0 +1,11 @@ +--- +title: RealtimeEventType +description: RealtimeEventType Schema Reference +--- + +## Allowed Values + +* `record.created` +* `record.updated` +* `record.deleted` +* `field.changed` \ No newline at end of file diff --git a/content/docs/references/system/RetryPolicy.mdx b/content/docs/references/system/RetryPolicy.mdx new file mode 100644 index 0000000..5277b4d --- /dev/null +++ b/content/docs/references/system/RetryPolicy.mdx @@ -0,0 +1,12 @@ +--- +title: RetryPolicy +description: RetryPolicy Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **maxRetries** | `integer` | optional | Maximum number of retry attempts | +| **backoffMs** | `integer` | optional | Initial backoff delay in milliseconds | +| **backoffMultiplier** | `number` | optional | Multiplier for exponential backoff | diff --git a/content/docs/references/system/Schedule.mdx b/content/docs/references/system/Schedule.mdx new file mode 100644 index 0000000..87b18be --- /dev/null +++ b/content/docs/references/system/Schedule.mdx @@ -0,0 +1,5 @@ +--- +title: Schedule +description: Schedule Schema Reference +--- + diff --git a/content/docs/references/system/Subscription.mdx b/content/docs/references/system/Subscription.mdx new file mode 100644 index 0000000..31ad739 --- /dev/null +++ b/content/docs/references/system/Subscription.mdx @@ -0,0 +1,13 @@ +--- +title: Subscription +description: Subscription Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique subscription identifier | +| **events** | `object[]` | ✅ | Array of events to subscribe to | +| **transport** | `Enum<'websocket' \| 'sse' \| 'polling'>` | ✅ | Transport protocol to use | +| **channel** | `string` | optional | Optional channel name for grouping subscriptions | diff --git a/content/docs/references/system/SubscriptionEvent.mdx b/content/docs/references/system/SubscriptionEvent.mdx new file mode 100644 index 0000000..ff9133f --- /dev/null +++ b/content/docs/references/system/SubscriptionEvent.mdx @@ -0,0 +1,12 @@ +--- +title: SubscriptionEvent +description: SubscriptionEvent Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>` | ✅ | Type of event to subscribe to | +| **object** | `string` | optional | Object name to subscribe to | +| **filters** | `any` | optional | Filter conditions | diff --git a/content/docs/references/system/TransportProtocol.mdx b/content/docs/references/system/TransportProtocol.mdx new file mode 100644 index 0000000..a24a128 --- /dev/null +++ b/content/docs/references/system/TransportProtocol.mdx @@ -0,0 +1,10 @@ +--- +title: TransportProtocol +description: TransportProtocol Schema Reference +--- + +## Allowed Values + +* `websocket` +* `sse` +* `polling` \ No newline at end of file diff --git a/packages/spec/json-schema/CronSchedule.json b/packages/spec/json-schema/CronSchedule.json new file mode 100644 index 0000000..4dc3e22 --- /dev/null +++ b/packages/spec/json-schema/CronSchedule.json @@ -0,0 +1,29 @@ +{ + "$ref": "#/definitions/CronSchedule", + "definitions": { + "CronSchedule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "cron" + }, + "expression": { + "type": "string", + "description": "Cron expression (e.g., \"0 0 * * *\" for daily at midnight)" + }, + "timezone": { + "type": "string", + "default": "UTC", + "description": "Timezone for cron execution (e.g., \"America/New_York\")" + } + }, + "required": [ + "type", + "expression" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Event.json b/packages/spec/json-schema/Event.json new file mode 100644 index 0000000..517dcf2 --- /dev/null +++ b/packages/spec/json-schema/Event.json @@ -0,0 +1,52 @@ +{ + "$ref": "#/definitions/Event", + "definitions": { + "Event": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_.]*$", + "description": "Event name (snake_case with dots, e.g., user.created)" + }, + "payload": { + "description": "Event payload schema" + }, + "metadata": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Event source (e.g., plugin name, system component)" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when event was created" + }, + "userId": { + "type": "string", + "description": "User who triggered the event" + }, + "tenantId": { + "type": "string", + "description": "Tenant identifier for multi-tenant systems" + } + }, + "required": [ + "source", + "timestamp" + ], + "additionalProperties": false, + "description": "Event metadata" + } + }, + "required": [ + "name", + "metadata" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/EventHandler.json b/packages/spec/json-schema/EventHandler.json new file mode 100644 index 0000000..4af6439 --- /dev/null +++ b/packages/spec/json-schema/EventHandler.json @@ -0,0 +1,29 @@ +{ + "$ref": "#/definitions/EventHandler", + "definitions": { + "EventHandler": { + "type": "object", + "properties": { + "eventName": { + "type": "string", + "description": "Name of event to handle (supports wildcards like user.*)" + }, + "priority": { + "type": "integer", + "default": 0, + "description": "Execution priority (lower numbers execute first)" + }, + "async": { + "type": "boolean", + "default": true, + "description": "Execute in background (true) or block (false)" + } + }, + "required": [ + "eventName" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/EventMetadata.json b/packages/spec/json-schema/EventMetadata.json new file mode 100644 index 0000000..c4bfc6d --- /dev/null +++ b/packages/spec/json-schema/EventMetadata.json @@ -0,0 +1,33 @@ +{ + "$ref": "#/definitions/EventMetadata", + "definitions": { + "EventMetadata": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Event source (e.g., plugin name, system component)" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when event was created" + }, + "userId": { + "type": "string", + "description": "User who triggered the event" + }, + "tenantId": { + "type": "string", + "description": "Tenant identifier for multi-tenant systems" + } + }, + "required": [ + "source", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/EventPersistence.json b/packages/spec/json-schema/EventPersistence.json new file mode 100644 index 0000000..1b2c357 --- /dev/null +++ b/packages/spec/json-schema/EventPersistence.json @@ -0,0 +1,25 @@ +{ + "$ref": "#/definitions/EventPersistence", + "definitions": { + "EventPersistence": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable event persistence" + }, + "retention": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Days to retain persisted events" + } + }, + "required": [ + "retention" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/EventRoute.json b/packages/spec/json-schema/EventRoute.json new file mode 100644 index 0000000..0734b3e --- /dev/null +++ b/packages/spec/json-schema/EventRoute.json @@ -0,0 +1,27 @@ +{ + "$ref": "#/definitions/EventRoute", + "definitions": { + "EventRoute": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "Source event pattern (supports wildcards, e.g., user.* or *.created)" + }, + "to": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Target event names to route to" + } + }, + "required": [ + "from", + "to" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/IntervalSchedule.json b/packages/spec/json-schema/IntervalSchedule.json new file mode 100644 index 0000000..865d30d --- /dev/null +++ b/packages/spec/json-schema/IntervalSchedule.json @@ -0,0 +1,25 @@ +{ + "$ref": "#/definitions/IntervalSchedule", + "definitions": { + "IntervalSchedule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "interval" + }, + "intervalMs": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Interval in milliseconds" + } + }, + "required": [ + "type", + "intervalMs" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Job.json b/packages/spec/json-schema/Job.json new file mode 100644 index 0000000..796c054 --- /dev/null +++ b/packages/spec/json-schema/Job.json @@ -0,0 +1,127 @@ +{ + "$ref": "#/definitions/Job", + "definitions": { + "Job": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique job identifier" + }, + "name": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*$", + "description": "Job name (snake_case)" + }, + "schedule": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "cron" + }, + "expression": { + "type": "string", + "description": "Cron expression (e.g., \"0 0 * * *\" for daily at midnight)" + }, + "timezone": { + "type": "string", + "default": "UTC", + "description": "Timezone for cron execution (e.g., \"America/New_York\")" + } + }, + "required": [ + "type", + "expression" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "interval" + }, + "intervalMs": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Interval in milliseconds" + } + }, + "required": [ + "type", + "intervalMs" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "once" + }, + "at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when to execute" + } + }, + "required": [ + "type", + "at" + ], + "additionalProperties": false + } + ], + "description": "Job schedule configuration" + }, + "retryPolicy": { + "type": "object", + "properties": { + "maxRetries": { + "type": "integer", + "minimum": 0, + "default": 3, + "description": "Maximum number of retry attempts" + }, + "backoffMs": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 1000, + "description": "Initial backoff delay in milliseconds" + }, + "backoffMultiplier": { + "type": "number", + "exclusiveMinimum": 0, + "default": 2, + "description": "Multiplier for exponential backoff" + } + }, + "additionalProperties": false, + "description": "Retry policy configuration" + }, + "timeout": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Timeout in milliseconds" + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether the job is enabled" + } + }, + "required": [ + "id", + "name", + "schedule" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/JobExecution.json b/packages/spec/json-schema/JobExecution.json new file mode 100644 index 0000000..a4d630d --- /dev/null +++ b/packages/spec/json-schema/JobExecution.json @@ -0,0 +1,49 @@ +{ + "$ref": "#/definitions/JobExecution", + "definitions": { + "JobExecution": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "description": "Job identifier" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when execution started" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when execution completed" + }, + "status": { + "type": "string", + "enum": [ + "running", + "success", + "failed", + "timeout" + ], + "description": "Execution status" + }, + "error": { + "type": "string", + "description": "Error message if failed" + }, + "duration": { + "type": "integer", + "description": "Execution duration in milliseconds" + } + }, + "required": [ + "jobId", + "startedAt", + "status" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/JobExecutionStatus.json b/packages/spec/json-schema/JobExecutionStatus.json new file mode 100644 index 0000000..d6bfda6 --- /dev/null +++ b/packages/spec/json-schema/JobExecutionStatus.json @@ -0,0 +1,15 @@ +{ + "$ref": "#/definitions/JobExecutionStatus", + "definitions": { + "JobExecutionStatus": { + "type": "string", + "enum": [ + "running", + "success", + "failed", + "timeout" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/OnceSchedule.json b/packages/spec/json-schema/OnceSchedule.json new file mode 100644 index 0000000..150bb7d --- /dev/null +++ b/packages/spec/json-schema/OnceSchedule.json @@ -0,0 +1,25 @@ +{ + "$ref": "#/definitions/OnceSchedule", + "definitions": { + "OnceSchedule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "once" + }, + "at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when to execute" + } + }, + "required": [ + "type", + "at" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Presence.json b/packages/spec/json-schema/Presence.json new file mode 100644 index 0000000..92a2732 --- /dev/null +++ b/packages/spec/json-schema/Presence.json @@ -0,0 +1,40 @@ +{ + "$ref": "#/definitions/Presence", + "definitions": { + "Presence": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "status": { + "type": "string", + "enum": [ + "online", + "away", + "offline" + ], + "description": "Current presence status" + }, + "lastSeen": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last activity" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Custom presence data (e.g., current page, custom status)" + } + }, + "required": [ + "userId", + "status", + "lastSeen" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/PresenceStatus.json b/packages/spec/json-schema/PresenceStatus.json new file mode 100644 index 0000000..7248409 --- /dev/null +++ b/packages/spec/json-schema/PresenceStatus.json @@ -0,0 +1,14 @@ +{ + "$ref": "#/definitions/PresenceStatus", + "definitions": { + "PresenceStatus": { + "type": "string", + "enum": [ + "online", + "away", + "offline" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/RealtimeAction.json b/packages/spec/json-schema/RealtimeAction.json new file mode 100644 index 0000000..91ddffd --- /dev/null +++ b/packages/spec/json-schema/RealtimeAction.json @@ -0,0 +1,14 @@ +{ + "$ref": "#/definitions/RealtimeAction", + "definitions": { + "RealtimeAction": { + "type": "string", + "enum": [ + "created", + "updated", + "deleted" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/RealtimeEvent.json b/packages/spec/json-schema/RealtimeEvent.json new file mode 100644 index 0000000..7650cf1 --- /dev/null +++ b/packages/spec/json-schema/RealtimeEvent.json @@ -0,0 +1,51 @@ +{ + "$ref": "#/definitions/RealtimeEvent", + "definitions": { + "RealtimeEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique event identifier" + }, + "type": { + "type": "string", + "description": "Event type (e.g., record.created, record.updated)" + }, + "object": { + "type": "string", + "description": "Object name the event relates to" + }, + "action": { + "type": "string", + "enum": [ + "created", + "updated", + "deleted" + ], + "description": "Action performed" + }, + "payload": { + "description": "Event payload data" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when event occurred" + }, + "userId": { + "type": "string", + "description": "User who triggered the event" + } + }, + "required": [ + "id", + "type", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/RealtimeEventType.json b/packages/spec/json-schema/RealtimeEventType.json new file mode 100644 index 0000000..e1b8eeb --- /dev/null +++ b/packages/spec/json-schema/RealtimeEventType.json @@ -0,0 +1,15 @@ +{ + "$ref": "#/definitions/RealtimeEventType", + "definitions": { + "RealtimeEventType": { + "type": "string", + "enum": [ + "record.created", + "record.updated", + "record.deleted", + "field.changed" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/RetryPolicy.json b/packages/spec/json-schema/RetryPolicy.json new file mode 100644 index 0000000..c21b9fa --- /dev/null +++ b/packages/spec/json-schema/RetryPolicy.json @@ -0,0 +1,30 @@ +{ + "$ref": "#/definitions/RetryPolicy", + "definitions": { + "RetryPolicy": { + "type": "object", + "properties": { + "maxRetries": { + "type": "integer", + "minimum": 0, + "default": 3, + "description": "Maximum number of retry attempts" + }, + "backoffMs": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 1000, + "description": "Initial backoff delay in milliseconds" + }, + "backoffMultiplier": { + "type": "number", + "exclusiveMinimum": 0, + "default": 2, + "description": "Multiplier for exponential backoff" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Schedule.json b/packages/spec/json-schema/Schedule.json new file mode 100644 index 0000000..8efefca --- /dev/null +++ b/packages/spec/json-schema/Schedule.json @@ -0,0 +1,71 @@ +{ + "$ref": "#/definitions/Schedule", + "definitions": { + "Schedule": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "cron" + }, + "expression": { + "type": "string", + "description": "Cron expression (e.g., \"0 0 * * *\" for daily at midnight)" + }, + "timezone": { + "type": "string", + "default": "UTC", + "description": "Timezone for cron execution (e.g., \"America/New_York\")" + } + }, + "required": [ + "type", + "expression" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "interval" + }, + "intervalMs": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Interval in milliseconds" + } + }, + "required": [ + "type", + "intervalMs" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "once" + }, + "at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when to execute" + } + }, + "required": [ + "type", + "at" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Subscription.json b/packages/spec/json-schema/Subscription.json new file mode 100644 index 0000000..f8d9a6a --- /dev/null +++ b/packages/spec/json-schema/Subscription.json @@ -0,0 +1,65 @@ +{ + "$ref": "#/definitions/Subscription", + "definitions": { + "Subscription": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique subscription identifier" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "record.created", + "record.updated", + "record.deleted", + "field.changed" + ], + "description": "Type of event to subscribe to" + }, + "object": { + "type": "string", + "description": "Object name to subscribe to" + }, + "filters": { + "description": "Filter conditions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "description": "Array of events to subscribe to" + }, + "transport": { + "type": "string", + "enum": [ + "websocket", + "sse", + "polling" + ], + "description": "Transport protocol to use" + }, + "channel": { + "type": "string", + "description": "Optional channel name for grouping subscriptions" + } + }, + "required": [ + "id", + "events", + "transport" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/SubscriptionEvent.json b/packages/spec/json-schema/SubscriptionEvent.json new file mode 100644 index 0000000..58a0d03 --- /dev/null +++ b/packages/spec/json-schema/SubscriptionEvent.json @@ -0,0 +1,32 @@ +{ + "$ref": "#/definitions/SubscriptionEvent", + "definitions": { + "SubscriptionEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "record.created", + "record.updated", + "record.deleted", + "field.changed" + ], + "description": "Type of event to subscribe to" + }, + "object": { + "type": "string", + "description": "Object name to subscribe to" + }, + "filters": { + "description": "Filter conditions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/TransportProtocol.json b/packages/spec/json-schema/TransportProtocol.json new file mode 100644 index 0000000..8300986 --- /dev/null +++ b/packages/spec/json-schema/TransportProtocol.json @@ -0,0 +1,14 @@ +{ + "$ref": "#/definitions/TransportProtocol", + "definitions": { + "TransportProtocol": { + "type": "string", + "enum": [ + "websocket", + "sse", + "polling" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/src/system/events.test.ts b/packages/spec/src/system/events.test.ts new file mode 100644 index 0000000..2a3e20c --- /dev/null +++ b/packages/spec/src/system/events.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect } from 'vitest'; +import { + EventMetadataSchema, + EventSchema, + EventHandlerSchema, + EventRouteSchema, + EventPersistenceSchema, + type Event, + type EventHandler, + type EventRoute, + type EventPersistence, +} from './events.zod'; + +describe('EventMetadataSchema', () => { + it('should accept valid metadata', () => { + const metadata = { + source: 'user.plugin', + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => EventMetadataSchema.parse(metadata)).not.toThrow(); + }); + + it('should accept metadata with optional fields', () => { + const metadata = { + source: 'system.core', + timestamp: '2024-01-15T10:30:00Z', + userId: 'user-123', + tenantId: 'tenant-456', + }; + + const parsed = EventMetadataSchema.parse(metadata); + expect(parsed.userId).toBe('user-123'); + expect(parsed.tenantId).toBe('tenant-456'); + }); + + it('should validate datetime format', () => { + expect(() => EventMetadataSchema.parse({ + source: 'plugin', + timestamp: 'not-a-datetime', + })).toThrow(); + + expect(() => EventMetadataSchema.parse({ + source: 'plugin', + timestamp: '2024-01-15T10:30:00Z', + })).not.toThrow(); + }); + + it('should reject metadata without required fields', () => { + expect(() => EventMetadataSchema.parse({ + timestamp: '2024-01-15T10:30:00Z', + })).toThrow(); + + expect(() => EventMetadataSchema.parse({ + source: 'plugin', + })).toThrow(); + }); +}); + +describe('EventSchema', () => { + it('should accept valid minimal event', () => { + const event: Event = { + name: 'user.created', + payload: { id: '123', email: 'user@example.com' }, + metadata: { + source: 'auth.plugin', + timestamp: '2024-01-15T10:30:00Z', + }, + }; + + expect(() => EventSchema.parse(event)).not.toThrow(); + }); + + it('should validate event name format (snake_case with dots)', () => { + const validNames = [ + 'user.created', + 'account.updated', + 'opportunity.stage.changed', + 'payment.webhook.received', + 'data_import.completed', + ]; + + validNames.forEach(name => { + const event = { + name, + payload: {}, + metadata: { + source: 'system', + timestamp: '2024-01-15T10:30:00Z', + }, + }; + expect(() => EventSchema.parse(event)).not.toThrow(); + }); + }); + + it('should reject invalid event name formats', () => { + const invalidNames = [ + 'User.Created', // PascalCase + 'user-created', // kebab-case + 'userCreated', // camelCase + '123.invalid', // starts with number + '.invalid', // starts with dot + ]; + + invalidNames.forEach(name => { + expect(() => EventSchema.parse({ + name, + payload: {}, + metadata: { source: 'system', timestamp: '2024-01-15T10:30:00Z' }, + })).toThrow(); + }); + }); + + it('should accept various payload types', () => { + const payloads = [ + { id: '123', name: 'Test' }, + [1, 2, 3], + 'string payload', + 123, + true, + null, + ]; + + payloads.forEach(payload => { + const event = { + name: 'test.event', + payload, + metadata: { source: 'test', timestamp: '2024-01-15T10:30:00Z' }, + }; + expect(() => EventSchema.parse(event)).not.toThrow(); + }); + }); + + it('should accept event with complete metadata', () => { + const event = { + name: 'order.completed', + payload: { orderId: '789', total: 99.99 }, + metadata: { + source: 'ecommerce.plugin', + timestamp: '2024-01-15T10:30:00Z', + userId: 'user-123', + tenantId: 'tenant-456', + }, + }; + + const parsed = EventSchema.parse(event); + expect(parsed.metadata.userId).toBe('user-123'); + expect(parsed.metadata.tenantId).toBe('tenant-456'); + }); +}); + +describe('EventHandlerSchema', () => { + it('should accept valid minimal event handler', () => { + const handler: EventHandler = { + eventName: 'user.created', + handler: async () => {}, + }; + + expect(() => EventHandlerSchema.parse(handler)).not.toThrow(); + }); + + it('should apply default values', () => { + const handler = EventHandlerSchema.parse({ + eventName: 'user.updated', + handler: async () => {}, + }); + + expect(handler.priority).toBe(0); + expect(handler.async).toBe(true); + }); + + it('should accept handler with all fields', () => { + const handler = { + eventName: 'order.created', + handler: async (event: Event) => { + console.log(event.name); + }, + priority: 10, + async: false, + }; + + const parsed = EventHandlerSchema.parse(handler); + expect(parsed.priority).toBe(10); + expect(parsed.async).toBe(false); + }); + + it('should accept wildcard event patterns', () => { + const patterns = [ + 'user.*', + '*.created', + 'account.*.*', + '*', + ]; + + patterns.forEach(eventName => { + const handler = { + eventName, + handler: async () => {}, + }; + expect(() => EventHandlerSchema.parse(handler)).not.toThrow(); + }); + }); + + it('should accept different priority values', () => { + const priorities = [-10, -1, 0, 1, 10, 100]; + + priorities.forEach(priority => { + const handler = { + eventName: 'test.event', + handler: async () => {}, + priority, + }; + const parsed = EventHandlerSchema.parse(handler); + expect(parsed.priority).toBe(priority); + }); + }); + + it('should accept async and sync handlers', () => { + const asyncHandler = { + eventName: 'async.event', + handler: async () => {}, + async: true, + }; + + const syncHandler = { + eventName: 'sync.event', + handler: async () => {}, + async: false, + }; + + expect(() => EventHandlerSchema.parse(asyncHandler)).not.toThrow(); + expect(() => EventHandlerSchema.parse(syncHandler)).not.toThrow(); + }); +}); + +describe('EventRouteSchema', () => { + it('should accept valid minimal event route', () => { + const route: EventRoute = { + from: 'user.created', + to: ['notification.send', 'analytics.track'], + }; + + expect(() => EventRouteSchema.parse(route)).not.toThrow(); + }); + + it('should accept route with wildcard source', () => { + const routes = [ + { from: 'user.*', to: ['audit.log'] }, + { from: '*.created', to: ['analytics.track'] }, + { from: '*', to: ['logger.log'] }, + ]; + + routes.forEach(route => { + expect(() => EventRouteSchema.parse(route)).not.toThrow(); + }); + }); + + it('should accept route with transform function', () => { + const route = { + from: 'user.created', + to: ['notification.send'], + transform: (payload: any) => ({ ...payload, transformed: true }), + }; + + expect(() => EventRouteSchema.parse(route)).not.toThrow(); + }); + + it('should accept multiple target events', () => { + const route = { + from: 'order.completed', + to: [ + 'email.send', + 'sms.send', + 'analytics.track', + 'inventory.update', + 'accounting.record', + ], + }; + + const parsed = EventRouteSchema.parse(route); + expect(parsed.to).toHaveLength(5); + }); + + it('should accept single target event', () => { + const route = { + from: 'payment.received', + to: ['invoice.mark_paid'], + }; + + expect(() => EventRouteSchema.parse(route)).not.toThrow(); + }); + + it('should reject route without required fields', () => { + expect(() => EventRouteSchema.parse({ + to: ['target.event'], + })).toThrow(); + + expect(() => EventRouteSchema.parse({ + from: 'source.event', + })).toThrow(); + }); +}); + +describe('EventPersistenceSchema', () => { + it('should accept valid minimal persistence config', () => { + const config: EventPersistence = { + enabled: true, + retention: 30, + }; + + expect(() => EventPersistenceSchema.parse(config)).not.toThrow(); + }); + + it('should apply default values', () => { + const config = EventPersistenceSchema.parse({ + retention: 30, + }); + + expect(config.enabled).toBe(false); + }); + + it('should accept config with all fields', () => { + const config = { + enabled: true, + retention: 90, + filter: (event: Event) => event.name.startsWith('audit.'), + }; + + const parsed = EventPersistenceSchema.parse(config); + expect(parsed.enabled).toBe(true); + expect(parsed.retention).toBe(90); + expect(parsed.filter).toBeDefined(); + }); + + it('should accept different retention periods', () => { + const retentions = [1, 7, 30, 90, 365]; + + retentions.forEach(retention => { + const config = { enabled: true, retention }; + const parsed = EventPersistenceSchema.parse(config); + expect(parsed.retention).toBe(retention); + }); + }); + + it('should reject negative retention', () => { + expect(() => EventPersistenceSchema.parse({ + enabled: true, + retention: -1, + })).toThrow(); + + expect(() => EventPersistenceSchema.parse({ + enabled: true, + retention: 0, + })).toThrow(); + }); + + it('should accept filter function', () => { + const config = { + enabled: true, + retention: 60, + filter: (event: Event) => { + return event.name.startsWith('critical.') || + event.metadata.source === 'security.plugin'; + }, + }; + + expect(() => EventPersistenceSchema.parse(config)).not.toThrow(); + }); + + it('should handle disabled persistence', () => { + const config = { + enabled: false, + retention: 30, + }; + + const parsed = EventPersistenceSchema.parse(config); + expect(parsed.enabled).toBe(false); + }); +}); + +describe('Event System Integration', () => { + it('should handle complete event lifecycle', () => { + // Create an event + const event: Event = { + name: 'user.registered', + payload: { + userId: 'user-123', + email: 'user@example.com', + timestamp: Date.now(), + }, + metadata: { + source: 'auth.service', + timestamp: '2024-01-15T10:30:00Z', + userId: 'system', + }, + }; + + expect(() => EventSchema.parse(event)).not.toThrow(); + + // Create handlers for the event + const handlers: EventHandler[] = [ + { + eventName: 'user.registered', + handler: async () => { /* send welcome email */ }, + priority: 1, + }, + { + eventName: 'user.*', + handler: async () => { /* track analytics */ }, + priority: 2, + }, + ]; + + handlers.forEach(handler => { + expect(() => EventHandlerSchema.parse(handler)).not.toThrow(); + }); + + // Create routes + const route: EventRoute = { + from: 'user.registered', + to: ['email.welcome', 'analytics.track'], + }; + + expect(() => EventRouteSchema.parse(route)).not.toThrow(); + + // Configure persistence + const persistence: EventPersistence = { + enabled: true, + retention: 90, + filter: (e: Event) => e.name.startsWith('user.'), + }; + + expect(() => EventPersistenceSchema.parse(persistence)).not.toThrow(); + }); +}); diff --git a/packages/spec/src/system/events.zod.ts b/packages/spec/src/system/events.zod.ts new file mode 100644 index 0000000..a5d5dcc --- /dev/null +++ b/packages/spec/src/system/events.zod.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +/** + * Event Metadata Schema + * Metadata associated with every event + */ +export const EventMetadataSchema = z.object({ + source: z.string().describe('Event source (e.g., plugin name, system component)'), + timestamp: z.string().datetime().describe('ISO 8601 datetime when event was created'), + userId: z.string().optional().describe('User who triggered the event'), + tenantId: z.string().optional().describe('Tenant identifier for multi-tenant systems'), +}); + +/** + * Event Schema + * Base schema for all events in the system + */ +export const EventSchema = z.object({ + name: z.string().regex(/^[a-z_][a-z0-9_.]*$/).describe('Event name (snake_case with dots, e.g., user.created)'), + payload: z.any().describe('Event payload schema'), + metadata: EventMetadataSchema.describe('Event metadata'), +}); + +export type Event = z.infer; + +/** + * Event Handler Schema + * Defines how to handle a specific event + */ +export const EventHandlerSchema = z.object({ + eventName: z.string().describe('Name of event to handle (supports wildcards like user.*)'), + handler: z.function().args(EventSchema).returns(z.promise(z.void())).describe('Handler function'), + priority: z.number().int().default(0).describe('Execution priority (lower numbers execute first)'), + async: z.boolean().default(true).describe('Execute in background (true) or block (false)'), +}); + +export type EventHandler = z.infer; + +/** + * Event Route Schema + * Routes events from one pattern to multiple targets with optional transformation + */ +export const EventRouteSchema = z.object({ + from: z.string().describe('Source event pattern (supports wildcards, e.g., user.* or *.created)'), + to: z.array(z.string()).describe('Target event names to route to'), + transform: z.function().optional().describe('Optional function to transform payload'), +}); + +export type EventRoute = z.infer; + +/** + * Event Persistence Schema + * Configuration for persisting events to storage + */ +export const EventPersistenceSchema = z.object({ + enabled: z.boolean().default(false).describe('Enable event persistence'), + retention: z.number().int().positive().describe('Days to retain persisted events'), + filter: z.function().optional().describe('Optional filter function to select which events to persist'), +}); + +export type EventPersistence = z.infer; diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index ecd9de8..38922a7 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -24,5 +24,8 @@ export * from './translation.zod'; export * from './driver.zod'; export * from './discovery.zod'; export * from './plugin.zod'; +export * from './realtime.zod'; +export * from './events.zod'; +export * from './job.zod'; export * from './constants'; export * from './types'; diff --git a/packages/spec/src/system/job.test.ts b/packages/spec/src/system/job.test.ts new file mode 100644 index 0000000..73ea0e6 --- /dev/null +++ b/packages/spec/src/system/job.test.ts @@ -0,0 +1,582 @@ +import { describe, it, expect } from 'vitest'; +import { + CronScheduleSchema, + IntervalScheduleSchema, + OnceScheduleSchema, + ScheduleSchema, + RetryPolicySchema, + JobSchema, + JobExecutionStatus, + JobExecutionSchema, + type Schedule, + type CronSchedule, + type IntervalSchedule, + type OnceSchedule, + type RetryPolicy, + type Job, + type JobExecution, +} from './job.zod'; + +describe('CronScheduleSchema', () => { + it('should accept valid cron schedule', () => { + const schedule: CronSchedule = { + type: 'cron', + expression: '0 0 * * *', + }; + + expect(() => CronScheduleSchema.parse(schedule)).not.toThrow(); + }); + + it('should apply default timezone', () => { + const schedule = CronScheduleSchema.parse({ + type: 'cron', + expression: '0 0 * * *', + }); + + expect(schedule.timezone).toBe('UTC'); + }); + + it('should accept custom timezone', () => { + const schedule = CronScheduleSchema.parse({ + type: 'cron', + expression: '0 9 * * MON-FRI', + timezone: 'America/New_York', + }); + + expect(schedule.timezone).toBe('America/New_York'); + }); + + it('should accept various cron expressions', () => { + const expressions = [ + '0 0 * * *', // Daily at midnight + '*/15 * * * *', // Every 15 minutes + '0 9 * * MON-FRI', // Weekdays at 9 AM + '0 0 1 * *', // First day of month + '0 0 * * 0', // Every Sunday + '30 2 * * *', // Daily at 2:30 AM + ]; + + expressions.forEach(expression => { + const schedule = { type: 'cron' as const, expression }; + expect(() => CronScheduleSchema.parse(schedule)).not.toThrow(); + }); + }); +}); + +describe('IntervalScheduleSchema', () => { + it('should accept valid interval schedule', () => { + const schedule: IntervalSchedule = { + type: 'interval', + intervalMs: 60000, + }; + + expect(() => IntervalScheduleSchema.parse(schedule)).not.toThrow(); + }); + + it('should accept various intervals', () => { + const intervals = [ + 1000, // 1 second + 60000, // 1 minute + 3600000, // 1 hour + 86400000, // 1 day + ]; + + intervals.forEach(intervalMs => { + const schedule = { type: 'interval' as const, intervalMs }; + const parsed = IntervalScheduleSchema.parse(schedule); + expect(parsed.intervalMs).toBe(intervalMs); + }); + }); + + it('should reject zero or negative intervals', () => { + expect(() => IntervalScheduleSchema.parse({ + type: 'interval', + intervalMs: 0, + })).toThrow(); + + expect(() => IntervalScheduleSchema.parse({ + type: 'interval', + intervalMs: -1000, + })).toThrow(); + }); +}); + +describe('OnceScheduleSchema', () => { + it('should accept valid once schedule', () => { + const schedule: OnceSchedule = { + type: 'once', + at: '2024-12-31T23:59:59Z', + }; + + expect(() => OnceScheduleSchema.parse(schedule)).not.toThrow(); + }); + + it('should validate datetime format', () => { + expect(() => OnceScheduleSchema.parse({ + type: 'once', + at: 'not-a-datetime', + })).toThrow(); + + expect(() => OnceScheduleSchema.parse({ + type: 'once', + at: '2024-12-31T23:59:59Z', + })).not.toThrow(); + }); +}); + +describe('ScheduleSchema', () => { + it('should accept cron schedule', () => { + const schedule: Schedule = { + type: 'cron', + expression: '0 0 * * *', + }; + + expect(() => ScheduleSchema.parse(schedule)).not.toThrow(); + }); + + it('should accept interval schedule', () => { + const schedule: Schedule = { + type: 'interval', + intervalMs: 60000, + }; + + expect(() => ScheduleSchema.parse(schedule)).not.toThrow(); + }); + + it('should accept once schedule', () => { + const schedule: Schedule = { + type: 'once', + at: '2024-12-31T23:59:59Z', + }; + + expect(() => ScheduleSchema.parse(schedule)).not.toThrow(); + }); + + it('should discriminate based on type field', () => { + const cronSchedule = ScheduleSchema.parse({ + type: 'cron', + expression: '0 0 * * *', + }); + expect(cronSchedule.type).toBe('cron'); + + const intervalSchedule = ScheduleSchema.parse({ + type: 'interval', + intervalMs: 30000, + }); + expect(intervalSchedule.type).toBe('interval'); + + const onceSchedule = ScheduleSchema.parse({ + type: 'once', + at: '2024-12-31T23:59:59Z', + }); + expect(onceSchedule.type).toBe('once'); + }); +}); + +describe('RetryPolicySchema', () => { + it('should accept valid retry policy', () => { + const policy: RetryPolicy = { + maxRetries: 5, + backoffMs: 2000, + backoffMultiplier: 3, + }; + + expect(() => RetryPolicySchema.parse(policy)).not.toThrow(); + }); + + it('should apply default values', () => { + const policy = RetryPolicySchema.parse({}); + + expect(policy.maxRetries).toBe(3); + expect(policy.backoffMs).toBe(1000); + expect(policy.backoffMultiplier).toBe(2); + }); + + it('should accept zero retries', () => { + const policy = RetryPolicySchema.parse({ + maxRetries: 0, + }); + + expect(policy.maxRetries).toBe(0); + }); + + it('should reject negative retries', () => { + expect(() => RetryPolicySchema.parse({ + maxRetries: -1, + })).toThrow(); + }); + + it('should accept various backoff configurations', () => { + const configs = [ + { maxRetries: 3, backoffMs: 500, backoffMultiplier: 1.5 }, + { maxRetries: 5, backoffMs: 1000, backoffMultiplier: 2 }, + { maxRetries: 10, backoffMs: 2000, backoffMultiplier: 3 }, + ]; + + configs.forEach(config => { + const parsed = RetryPolicySchema.parse(config); + expect(parsed.maxRetries).toBe(config.maxRetries); + expect(parsed.backoffMs).toBe(config.backoffMs); + expect(parsed.backoffMultiplier).toBe(config.backoffMultiplier); + }); + }); + + it('should demonstrate exponential backoff', () => { + const policy = RetryPolicySchema.parse({ + maxRetries: 3, + backoffMs: 1000, + backoffMultiplier: 2, + }); + + // First retry: 1000ms + // Second retry: 2000ms + // Third retry: 4000ms + expect(policy.maxRetries).toBe(3); + expect(policy.backoffMs).toBe(1000); + expect(policy.backoffMultiplier).toBe(2); + }); +}); + +describe('JobSchema', () => { + it('should accept valid minimal job', () => { + const job: Job = { + id: 'job-123', + name: 'daily_cleanup', + schedule: { + type: 'cron', + expression: '0 0 * * *', + }, + handler: async () => {}, + }; + + expect(() => JobSchema.parse(job)).not.toThrow(); + }); + + it('should validate job name format (snake_case)', () => { + const validNames = [ + 'daily_cleanup', + 'send_emails', + 'process_payments', + 'backup_database', + ]; + + validNames.forEach(name => { + const job = { + id: 'job-123', + name, + schedule: { type: 'cron' as const, expression: '0 0 * * *' }, + handler: async () => {}, + }; + expect(() => JobSchema.parse(job)).not.toThrow(); + }); + }); + + it('should reject invalid job name formats', () => { + const invalidNames = [ + 'DailyCleanup', // PascalCase + 'daily-cleanup', // kebab-case + 'dailyCleanup', // camelCase + '123_invalid', // starts with number + ]; + + invalidNames.forEach(name => { + expect(() => JobSchema.parse({ + id: 'job-123', + name, + schedule: { type: 'cron', expression: '0 0 * * *' }, + handler: async () => {}, + })).toThrow(); + }); + }); + + it('should apply default enabled value', () => { + const job = JobSchema.parse({ + id: 'job-123', + name: 'test_job', + schedule: { type: 'interval', intervalMs: 60000 }, + handler: async () => {}, + }); + + expect(job.enabled).toBe(true); + }); + + it('should accept job with all fields', () => { + const job = { + id: 'job-456', + name: 'complex_job', + schedule: { + type: 'cron' as const, + expression: '0 9 * * MON-FRI', + timezone: 'America/New_York', + }, + handler: async () => { + console.log('Job executed'); + }, + retryPolicy: { + maxRetries: 5, + backoffMs: 2000, + backoffMultiplier: 2, + }, + timeout: 300000, + enabled: true, + }; + + const parsed = JobSchema.parse(job); + expect(parsed.timeout).toBe(300000); + expect(parsed.retryPolicy?.maxRetries).toBe(5); + }); + + it('should accept different schedule types', () => { + const schedules: Schedule[] = [ + { type: 'cron', expression: '0 0 * * *' }, + { type: 'interval', intervalMs: 60000 }, + { type: 'once', at: '2024-12-31T23:59:59Z' }, + ]; + + schedules.forEach(schedule => { + const job = { + id: 'job-789', + name: 'test_job', + schedule, + handler: async () => {}, + }; + expect(() => JobSchema.parse(job)).not.toThrow(); + }); + }); + + it('should accept job with timeout', () => { + const job = { + id: 'job-timeout', + name: 'long_running_job', + schedule: { type: 'cron' as const, expression: '0 0 * * *' }, + handler: async () => {}, + timeout: 600000, // 10 minutes + }; + + const parsed = JobSchema.parse(job); + expect(parsed.timeout).toBe(600000); + }); + + it('should accept disabled job', () => { + const job = { + id: 'job-disabled', + name: 'disabled_job', + schedule: { type: 'interval' as const, intervalMs: 30000 }, + handler: async () => {}, + enabled: false, + }; + + const parsed = JobSchema.parse(job); + expect(parsed.enabled).toBe(false); + }); +}); + +describe('JobExecutionStatus', () => { + it('should accept valid execution statuses', () => { + expect(() => JobExecutionStatus.parse('running')).not.toThrow(); + expect(() => JobExecutionStatus.parse('success')).not.toThrow(); + expect(() => JobExecutionStatus.parse('failed')).not.toThrow(); + expect(() => JobExecutionStatus.parse('timeout')).not.toThrow(); + }); + + it('should reject invalid execution statuses', () => { + expect(() => JobExecutionStatus.parse('pending')).toThrow(); + expect(() => JobExecutionStatus.parse('cancelled')).toThrow(); + expect(() => JobExecutionStatus.parse('')).toThrow(); + }); +}); + +describe('JobExecutionSchema', () => { + it('should accept valid minimal job execution', () => { + const execution: JobExecution = { + jobId: 'job-123', + startedAt: '2024-01-15T10:30:00Z', + status: 'running', + }; + + expect(() => JobExecutionSchema.parse(execution)).not.toThrow(); + }); + + it('should accept completed execution', () => { + const execution = { + jobId: 'job-123', + startedAt: '2024-01-15T10:30:00Z', + completedAt: '2024-01-15T10:35:00Z', + status: 'success', + duration: 300000, + }; + + const parsed = JobExecutionSchema.parse(execution); + expect(parsed.completedAt).toBe('2024-01-15T10:35:00Z'); + expect(parsed.duration).toBe(300000); + }); + + it('should accept failed execution', () => { + const execution = { + jobId: 'job-456', + startedAt: '2024-01-15T11:00:00Z', + completedAt: '2024-01-15T11:05:00Z', + status: 'failed', + error: 'Database connection timeout', + duration: 300000, + }; + + const parsed = JobExecutionSchema.parse(execution); + expect(parsed.status).toBe('failed'); + expect(parsed.error).toBe('Database connection timeout'); + }); + + it('should accept timeout execution', () => { + const execution = { + jobId: 'job-789', + startedAt: '2024-01-15T12:00:00Z', + completedAt: '2024-01-15T12:10:00Z', + status: 'timeout', + error: 'Job exceeded maximum execution time of 600000ms', + duration: 600000, + }; + + const parsed = JobExecutionSchema.parse(execution); + expect(parsed.status).toBe('timeout'); + }); + + it('should accept all execution statuses', () => { + const statuses: Array = ['running', 'success', 'failed', 'timeout']; + + statuses.forEach(status => { + const execution = { + jobId: 'job-test', + startedAt: '2024-01-15T10:00:00Z', + status, + }; + const parsed = JobExecutionSchema.parse(execution); + expect(parsed.status).toBe(status); + }); + }); + + it('should validate datetime formats', () => { + expect(() => JobExecutionSchema.parse({ + jobId: 'job-123', + startedAt: 'not-a-datetime', + status: 'running', + })).toThrow(); + + expect(() => JobExecutionSchema.parse({ + jobId: 'job-123', + startedAt: '2024-01-15T10:00:00Z', + completedAt: 'not-a-datetime', + status: 'success', + })).toThrow(); + }); + + it('should reject execution without required fields', () => { + expect(() => JobExecutionSchema.parse({ + startedAt: '2024-01-15T10:00:00Z', + status: 'running', + })).toThrow(); + + expect(() => JobExecutionSchema.parse({ + jobId: 'job-123', + status: 'running', + })).toThrow(); + + expect(() => JobExecutionSchema.parse({ + jobId: 'job-123', + startedAt: '2024-01-15T10:00:00Z', + })).toThrow(); + }); +}); + +describe('Job Scheduling Integration', () => { + it('should handle daily backup job', () => { + const job: Job = { + id: 'backup-daily', + name: 'daily_backup', + schedule: { + type: 'cron', + expression: '0 2 * * *', // 2 AM daily + timezone: 'America/New_York', + }, + handler: async () => { + // Perform backup + }, + retryPolicy: { + maxRetries: 3, + backoffMs: 5000, + backoffMultiplier: 2, + }, + timeout: 1800000, // 30 minutes + enabled: true, + }; + + expect(() => JobSchema.parse(job)).not.toThrow(); + }); + + it('should handle periodic cleanup job', () => { + const job: Job = { + id: 'cleanup-temp', + name: 'cleanup_temp_files', + schedule: { + type: 'interval', + intervalMs: 3600000, // 1 hour + }, + handler: async () => { + // Cleanup temp files + }, + timeout: 60000, // 1 minute + }; + + expect(() => JobSchema.parse(job)).not.toThrow(); + }); + + it('should handle one-time scheduled job', () => { + const job: Job = { + id: 'migration-2024', + name: 'data_migration', + schedule: { + type: 'once', + at: '2024-12-31T00:00:00Z', + }, + handler: async () => { + // Run migration + }, + retryPolicy: { + maxRetries: 0, // No retries for migrations + }, + timeout: 7200000, // 2 hours + }; + + expect(() => JobSchema.parse(job)).not.toThrow(); + }); + + it('should track job execution history', () => { + const executions: JobExecution[] = [ + { + jobId: 'backup-daily', + startedAt: '2024-01-15T02:00:00Z', + completedAt: '2024-01-15T02:15:00Z', + status: 'success', + duration: 900000, + }, + { + jobId: 'backup-daily', + startedAt: '2024-01-16T02:00:00Z', + completedAt: '2024-01-16T02:10:00Z', + status: 'success', + duration: 600000, + }, + { + jobId: 'backup-daily', + startedAt: '2024-01-17T02:00:00Z', + completedAt: '2024-01-17T02:35:00Z', + status: 'failed', + error: 'Insufficient disk space', + duration: 2100000, + }, + ]; + + executions.forEach(execution => { + expect(() => JobExecutionSchema.parse(execution)).not.toThrow(); + }); + }); +}); diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts new file mode 100644 index 0000000..4d196e0 --- /dev/null +++ b/packages/spec/src/system/job.zod.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; + +/** + * Cron Schedule Schema + * Schedule jobs using cron expressions + */ +export const CronScheduleSchema = z.object({ + type: z.literal('cron'), + expression: z.string().describe('Cron expression (e.g., "0 0 * * *" for daily at midnight)'), + timezone: z.string().optional().default('UTC').describe('Timezone for cron execution (e.g., "America/New_York")'), +}); + +/** + * Interval Schedule Schema + * Schedule jobs at fixed intervals + */ +export const IntervalScheduleSchema = z.object({ + type: z.literal('interval'), + intervalMs: z.number().int().positive().describe('Interval in milliseconds'), +}); + +/** + * Once Schedule Schema + * Schedule a job to run once at a specific time + */ +export const OnceScheduleSchema = z.object({ + type: z.literal('once'), + at: z.string().datetime().describe('ISO 8601 datetime when to execute'), +}); + +/** + * Schedule Schema + * Discriminated union of all schedule types + */ +export const ScheduleSchema = z.discriminatedUnion('type', [ + CronScheduleSchema, + IntervalScheduleSchema, + OnceScheduleSchema, +]); + +export type Schedule = z.infer; +export type CronSchedule = z.infer; +export type IntervalSchedule = z.infer; +export type OnceSchedule = z.infer; + +/** + * Retry Policy Schema + * Configuration for job retry behavior with exponential backoff + */ +export const RetryPolicySchema = z.object({ + maxRetries: z.number().int().min(0).default(3).describe('Maximum number of retry attempts'), + backoffMs: z.number().int().positive().default(1000).describe('Initial backoff delay in milliseconds'), + backoffMultiplier: z.number().positive().default(2).describe('Multiplier for exponential backoff'), +}); + +export type RetryPolicy = z.infer; + +/** + * Job Schema + * Defines a scheduled job + */ +export const JobSchema = z.object({ + id: z.string().describe('Unique job identifier'), + name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Job name (snake_case)'), + schedule: ScheduleSchema.describe('Job schedule configuration'), + handler: z.function().returns(z.promise(z.void())).describe('Job handler function'), + retryPolicy: RetryPolicySchema.optional().describe('Retry policy configuration'), + timeout: z.number().int().positive().optional().describe('Timeout in milliseconds'), + enabled: z.boolean().default(true).describe('Whether the job is enabled'), +}); + +export type Job = z.infer; + +/** + * Job Execution Status Enum + * Status of job execution + */ +export const JobExecutionStatus = z.enum([ + 'running', + 'success', + 'failed', + 'timeout', +]); + +export type JobExecutionStatus = z.infer; + +/** + * Job Execution Schema + * Logs for job execution + */ +export const JobExecutionSchema = z.object({ + jobId: z.string().describe('Job identifier'), + startedAt: z.string().datetime().describe('ISO 8601 datetime when execution started'), + completedAt: z.string().datetime().optional().describe('ISO 8601 datetime when execution completed'), + status: JobExecutionStatus.describe('Execution status'), + error: z.string().optional().describe('Error message if failed'), + duration: z.number().int().optional().describe('Execution duration in milliseconds'), +}); + +export type JobExecution = z.infer; diff --git a/packages/spec/src/system/realtime.test.ts b/packages/spec/src/system/realtime.test.ts new file mode 100644 index 0000000..995f2b0 --- /dev/null +++ b/packages/spec/src/system/realtime.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect } from 'vitest'; +import { + TransportProtocol, + RealtimeEventType, + SubscriptionEventSchema, + SubscriptionSchema, + PresenceStatus, + PresenceSchema, + RealtimeAction, + RealtimeEventSchema, + type Subscription, + type Presence, + type RealtimeEvent, +} from './realtime.zod'; + +describe('TransportProtocol', () => { + it('should accept valid transport protocols', () => { + expect(() => TransportProtocol.parse('websocket')).not.toThrow(); + expect(() => TransportProtocol.parse('sse')).not.toThrow(); + expect(() => TransportProtocol.parse('polling')).not.toThrow(); + }); + + it('should reject invalid transport protocols', () => { + expect(() => TransportProtocol.parse('http')).toThrow(); + expect(() => TransportProtocol.parse('grpc')).toThrow(); + expect(() => TransportProtocol.parse('')).toThrow(); + }); +}); + +describe('RealtimeEventType', () => { + it('should accept valid event types', () => { + expect(() => RealtimeEventType.parse('record.created')).not.toThrow(); + expect(() => RealtimeEventType.parse('record.updated')).not.toThrow(); + expect(() => RealtimeEventType.parse('record.deleted')).not.toThrow(); + expect(() => RealtimeEventType.parse('field.changed')).not.toThrow(); + }); + + it('should reject invalid event types', () => { + expect(() => RealtimeEventType.parse('record.inserted')).toThrow(); + expect(() => RealtimeEventType.parse('object.modified')).toThrow(); + expect(() => RealtimeEventType.parse('')).toThrow(); + }); +}); + +describe('SubscriptionEventSchema', () => { + it('should accept valid subscription event', () => { + const event = { + type: 'record.created', + object: 'account', + filters: { status: 'active' }, + }; + + expect(() => SubscriptionEventSchema.parse(event)).not.toThrow(); + }); + + it('should accept event without object', () => { + const event = { + type: 'record.created', + }; + + const parsed = SubscriptionEventSchema.parse(event); + expect(parsed.object).toBeUndefined(); + }); + + it('should accept event without filters', () => { + const event = { + type: 'record.updated', + object: 'contact', + }; + + const parsed = SubscriptionEventSchema.parse(event); + expect(parsed.filters).toBeUndefined(); + }); + + it('should accept various filter types', () => { + const events = [ + { type: 'record.created', filters: { status: 'active' } }, + { type: 'record.updated', filters: ['field1', 'field2'] }, + { type: 'field.changed', filters: 'name' }, + ]; + + events.forEach(event => { + expect(() => SubscriptionEventSchema.parse(event)).not.toThrow(); + }); + }); +}); + +describe('SubscriptionSchema', () => { + it('should accept valid minimal subscription', () => { + const subscription: Subscription = { + id: '550e8400-e29b-41d4-a716-446655440000', + events: [ + { type: 'record.created', object: 'account' }, + ], + transport: 'websocket', + }; + + expect(() => SubscriptionSchema.parse(subscription)).not.toThrow(); + }); + + it('should accept subscription with channel', () => { + const subscription = { + id: '550e8400-e29b-41d4-a716-446655440000', + events: [ + { type: 'record.updated', object: 'contact' }, + ], + transport: 'sse', + channel: 'user-notifications', + }; + + const parsed = SubscriptionSchema.parse(subscription); + expect(parsed.channel).toBe('user-notifications'); + }); + + it('should accept multiple events', () => { + const subscription = { + id: '550e8400-e29b-41d4-a716-446655440000', + events: [ + { type: 'record.created', object: 'account' }, + { type: 'record.updated', object: 'account' }, + { type: 'record.deleted', object: 'account' }, + ], + transport: 'websocket', + }; + + const parsed = SubscriptionSchema.parse(subscription); + expect(parsed.events).toHaveLength(3); + }); + + it('should accept different transport protocols', () => { + const transports: Array = ['websocket', 'sse', 'polling']; + + transports.forEach(transport => { + const subscription = { + id: '550e8400-e29b-41d4-a716-446655440000', + events: [{ type: 'record.created' }], + transport, + }; + + const parsed = SubscriptionSchema.parse(subscription); + expect(parsed.transport).toBe(transport); + }); + }); + + it('should validate UUID format', () => { + expect(() => SubscriptionSchema.parse({ + id: 'not-a-uuid', + events: [{ type: 'record.created' }], + transport: 'websocket', + })).toThrow(); + + expect(() => SubscriptionSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + events: [{ type: 'record.created' }], + transport: 'websocket', + })).not.toThrow(); + }); + + it('should accept events with filters', () => { + const subscription = { + id: '550e8400-e29b-41d4-a716-446655440000', + events: [ + { + type: 'record.updated', + object: 'opportunity', + filters: { stage: 'closed_won', amount: { $gt: 10000 } }, + }, + ], + transport: 'websocket', + }; + + expect(() => SubscriptionSchema.parse(subscription)).not.toThrow(); + }); + + it('should reject subscription without required fields', () => { + expect(() => SubscriptionSchema.parse({ + events: [{ type: 'record.created' }], + transport: 'websocket', + })).toThrow(); + + expect(() => SubscriptionSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + transport: 'websocket', + })).toThrow(); + + expect(() => SubscriptionSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + events: [{ type: 'record.created' }], + })).toThrow(); + }); +}); + +describe('PresenceStatus', () => { + it('should accept valid presence statuses', () => { + expect(() => PresenceStatus.parse('online')).not.toThrow(); + expect(() => PresenceStatus.parse('away')).not.toThrow(); + expect(() => PresenceStatus.parse('offline')).not.toThrow(); + }); + + it('should reject invalid presence statuses', () => { + expect(() => PresenceStatus.parse('busy')).toThrow(); + expect(() => PresenceStatus.parse('idle')).toThrow(); + expect(() => PresenceStatus.parse('')).toThrow(); + }); +}); + +describe('PresenceSchema', () => { + it('should accept valid minimal presence', () => { + const presence: Presence = { + userId: 'user-123', + status: 'online', + lastSeen: '2024-01-15T10:30:00Z', + }; + + expect(() => PresenceSchema.parse(presence)).not.toThrow(); + }); + + it('should accept presence with metadata', () => { + const presence = { + userId: 'user-456', + status: 'away', + lastSeen: '2024-01-15T10:30:00Z', + metadata: { + currentPage: '/dashboard', + customStatus: 'In a meeting', + device: 'mobile', + }, + }; + + const parsed = PresenceSchema.parse(presence); + expect(parsed.metadata).toBeDefined(); + expect(parsed.metadata?.currentPage).toBe('/dashboard'); + }); + + it('should accept all presence statuses', () => { + const statuses: Array = ['online', 'away', 'offline']; + + statuses.forEach(status => { + const presence = { + userId: 'user-789', + status, + lastSeen: '2024-01-15T10:30:00Z', + }; + + const parsed = PresenceSchema.parse(presence); + expect(parsed.status).toBe(status); + }); + }); + + it('should validate datetime format', () => { + expect(() => PresenceSchema.parse({ + userId: 'user-123', + status: 'online', + lastSeen: 'not-a-datetime', + })).toThrow(); + + expect(() => PresenceSchema.parse({ + userId: 'user-123', + status: 'online', + lastSeen: '2024-01-15T10:30:00Z', + })).not.toThrow(); + }); + + it('should reject presence without required fields', () => { + expect(() => PresenceSchema.parse({ + status: 'online', + lastSeen: '2024-01-15T10:30:00Z', + })).toThrow(); + + expect(() => PresenceSchema.parse({ + userId: 'user-123', + lastSeen: '2024-01-15T10:30:00Z', + })).toThrow(); + + expect(() => PresenceSchema.parse({ + userId: 'user-123', + status: 'online', + })).toThrow(); + }); +}); + +describe('RealtimeAction', () => { + it('should accept valid actions', () => { + expect(() => RealtimeAction.parse('created')).not.toThrow(); + expect(() => RealtimeAction.parse('updated')).not.toThrow(); + expect(() => RealtimeAction.parse('deleted')).not.toThrow(); + }); + + it('should reject invalid actions', () => { + expect(() => RealtimeAction.parse('inserted')).toThrow(); + expect(() => RealtimeAction.parse('modified')).toThrow(); + expect(() => RealtimeAction.parse('')).toThrow(); + }); +}); + +describe('RealtimeEventSchema', () => { + it('should accept valid minimal realtime event', () => { + const event: RealtimeEvent = { + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'record.created', + payload: { id: '123', name: 'Test Account' }, + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => RealtimeEventSchema.parse(event)).not.toThrow(); + }); + + it('should accept event with all fields', () => { + const event = { + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'record.updated', + object: 'account', + action: 'updated', + payload: { id: '123', name: 'Updated Account', status: 'active' }, + timestamp: '2024-01-15T10:30:00Z', + userId: 'user-456', + }; + + const parsed = RealtimeEventSchema.parse(event); + expect(parsed.object).toBe('account'); + expect(parsed.action).toBe('updated'); + expect(parsed.userId).toBe('user-456'); + }); + + it('should accept different actions', () => { + const actions: Array = ['created', 'updated', 'deleted']; + + actions.forEach(action => { + const event = { + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'record.created', + action, + payload: {}, + timestamp: '2024-01-15T10:30:00Z', + }; + + const parsed = RealtimeEventSchema.parse(event); + expect(parsed.action).toBe(action); + }); + }); + + it('should validate UUID format', () => { + expect(() => RealtimeEventSchema.parse({ + id: 'not-a-uuid', + type: 'record.created', + payload: {}, + timestamp: '2024-01-15T10:30:00Z', + })).toThrow(); + + expect(() => RealtimeEventSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'record.created', + payload: {}, + timestamp: '2024-01-15T10:30:00Z', + })).not.toThrow(); + }); + + it('should validate datetime format', () => { + expect(() => RealtimeEventSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'record.created', + payload: {}, + timestamp: 'not-a-datetime', + })).toThrow(); + + expect(() => RealtimeEventSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'record.created', + payload: {}, + timestamp: '2024-01-15T10:30:00Z', + })).not.toThrow(); + }); + + it('should accept various payload types', () => { + const payloads = [ + { id: '123', name: 'Account' }, + [1, 2, 3], + 'string payload', + 123, + true, + null, + ]; + + payloads.forEach(payload => { + const event = { + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'custom.event', + payload, + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => RealtimeEventSchema.parse(event)).not.toThrow(); + }); + }); + + it('should reject event without required fields', () => { + expect(() => RealtimeEventSchema.parse({ + type: 'record.created', + payload: {}, + timestamp: '2024-01-15T10:30:00Z', + })).toThrow(); + + expect(() => RealtimeEventSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + payload: {}, + timestamp: '2024-01-15T10:30:00Z', + })).toThrow(); + + expect(() => RealtimeEventSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'record.created', + })).toThrow(); + }); + + it('should handle field change event', () => { + const event = { + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'field.changed', + object: 'contact', + action: 'updated', + payload: { + recordId: 'contact-123', + field: 'email', + oldValue: 'old@example.com', + newValue: 'new@example.com', + }, + timestamp: '2024-01-15T10:30:00Z', + userId: 'user-789', + }; + + const parsed = RealtimeEventSchema.parse(event); + expect(parsed.type).toBe('field.changed'); + expect(parsed.payload.field).toBe('email'); + }); +}); diff --git a/packages/spec/src/system/realtime.zod.ts b/packages/spec/src/system/realtime.zod.ts new file mode 100644 index 0000000..54c193f --- /dev/null +++ b/packages/spec/src/system/realtime.zod.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; + +/** + * Transport Protocol Enum + * Defines the communication protocol for realtime data synchronization + */ +export const TransportProtocol = z.enum([ + 'websocket', // Full-duplex, low latency communication + 'sse', // Server-Sent Events, unidirectional push + 'polling', // Short polling, best compatibility +]); + +export type TransportProtocol = z.infer; + +/** + * Event Type Enum + * Types of realtime events that can be subscribed to + */ +export const RealtimeEventType = z.enum([ + 'record.created', + 'record.updated', + 'record.deleted', + 'field.changed', +]); + +export type RealtimeEventType = z.infer; + +/** + * Subscription Event Configuration + * Defines what events to subscribe to with optional filtering + */ +export const SubscriptionEventSchema = z.object({ + type: RealtimeEventType.describe('Type of event to subscribe to'), + object: z.string().optional().describe('Object name to subscribe to'), + filters: z.any().optional().describe('Filter conditions'), +}); + +/** + * Subscription Schema + * Configuration for subscribing to realtime events + */ +export const SubscriptionSchema = z.object({ + id: z.string().uuid().describe('Unique subscription identifier'), + events: z.array(SubscriptionEventSchema).describe('Array of events to subscribe to'), + transport: TransportProtocol.describe('Transport protocol to use'), + channel: z.string().optional().describe('Optional channel name for grouping subscriptions'), +}); + +export type Subscription = z.infer; + +/** + * Presence Status Enum + * User online/offline status + */ +export const PresenceStatus = z.enum([ + 'online', + 'away', + 'offline', +]); + +export type PresenceStatus = z.infer; + +/** + * Presence Schema + * Tracks user online status and metadata + */ +export const PresenceSchema = z.object({ + userId: z.string().describe('User identifier'), + status: PresenceStatus.describe('Current presence status'), + lastSeen: z.string().datetime().describe('ISO 8601 datetime of last activity'), + metadata: z.record(z.any()).optional().describe('Custom presence data (e.g., current page, custom status)'), +}); + +export type Presence = z.infer; + +/** + * Realtime Action Enum + * Actions that can occur on records + */ +export const RealtimeAction = z.enum([ + 'created', + 'updated', + 'deleted', +]); + +export type RealtimeAction = z.infer; + +/** + * Realtime Event Schema + * Represents a realtime synchronization event + */ +export const RealtimeEventSchema = z.object({ + id: z.string().uuid().describe('Unique event identifier'), + type: z.string().describe('Event type (e.g., record.created, record.updated)'), + object: z.string().optional().describe('Object name the event relates to'), + action: RealtimeAction.optional().describe('Action performed'), + payload: z.any().describe('Event payload data'), + timestamp: z.string().datetime().describe('ISO 8601 datetime when event occurred'), + userId: z.string().optional().describe('User who triggered the event'), +}); + +export type RealtimeEvent = z.infer;