diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a302b5a..c95945521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Skills Module Structure Refactor** — Refactored all skills in `skills/` directory to follow shadcn-ui's fine-grained layering pattern. Each skill now has: + - **Concise `SKILL.md`** — High-level overview with decision trees and quick-start examples, referencing detailed rules + - **`rules/` directory** — Detailed implementation rules with incorrect/correct code examples for better AI comprehension + - **`evals/` directory** — Placeholder for future evaluation tests to validate AI assistant understanding + - **Skills refactored:** + - `objectstack-data` — Extracted rules for naming, relationships, validation, indexing, field types, and hooks (moved from objectstack-hooks) + - `objectstack-kernel` — Extracted rules for plugin lifecycle, service registry, and hooks/events system + - `objectstack-hooks` — **DEPRECATED** and consolidated into `objectstack-data/rules/hooks.md` (hooks are core to data operations) + - `objectstack-ui`, `objectstack-api`, `objectstack-automation`, `objectstack-ai`, `objectstack-i18n`, `objectstack-quickstart` — Added `rules/` and `evals/` structure with initial pattern documentation + - **Benefits:** + - Improved maintainability — Detailed rules are separated from high-level overview + - Better AI comprehension — Incorrect/correct examples make patterns clearer + - Enhanced testability — `evals/` directory ready for skill validation tests + - Reduced skill overlap — Hooks integrated into data skill where they belong + - Preserved skill independence — Each skill remains independently installable/referenceable (no global routing required) + ### Fixed - **CI: Replace `pnpm/action-setup@v6` with corepack** — Switched all GitHub Actions workflows (`ci.yml`, `lint.yml`, `release.yml`, `validate-deps.yml`, `pr-automation.yml`) from `pnpm/action-setup@v6` to `corepack enable` to fix persistent `ERR_PNPM_BROKEN_LOCKFILE` errors. Corepack reads the exact `packageManager` field from `package.json` (including SHA verification), ensuring the correct pnpm version is used in CI. Also bumped pnpm store cache keys to v3 and added a pnpm version verification step. - **Broken pnpm lockfile** — Regenerated `pnpm-lock.yaml` from scratch to fix `ERR_PNPM_BROKEN_LOCKFILE` ("expected a single document in the stream, but found more") that was causing all CI jobs to fail. The previous merge of PR #1117 only included workflow cache key changes but did not carry over the regenerated lockfile. diff --git a/skills/objectstack-ai/evals/README.md b/skills/objectstack-ai/evals/README.md new file mode 100644 index 000000000..d6e9bf631 --- /dev/null +++ b/skills/objectstack-ai/evals/README.md @@ -0,0 +1,46 @@ +# Evaluation Tests (evals/) + +This directory is reserved for future skill evaluation tests. + +## Purpose + +Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. + +## Structure + +When implemented, evals will follow this structure: + +``` +evals/ +├── naming/ +│ ├── test-object-names.md +│ ├── test-field-keys.md +│ └── test-option-values.md +├── relationships/ +│ ├── test-lookup-vs-master-detail.md +│ └── test-junction-patterns.md +├── validation/ +│ ├── test-script-inversion.md +│ └── test-state-machine.md +└── ... +``` + +## Format + +Each eval file will contain: +1. **Scenario** — Description of the task +2. **Expected Output** — Correct implementation +3. **Common Mistakes** — Incorrect patterns to avoid +4. **Validation Criteria** — How to score the output + +## Status + +⚠️ **Not yet implemented** — This is a placeholder for future development. + +## Contributing + +When adding evals: +1. Each eval should test a single, specific rule or pattern +2. Include both positive (correct) and negative (incorrect) examples +3. Reference the corresponding rule file in `rules/` +4. Use realistic scenarios from actual ObjectStack projects diff --git a/skills/objectstack-ai/rules/agent-patterns.md b/skills/objectstack-ai/rules/agent-patterns.md new file mode 100644 index 000000000..43d00e524 --- /dev/null +++ b/skills/objectstack-ai/rules/agent-patterns.md @@ -0,0 +1,69 @@ +# Agent Design Patterns + +Guide for designing AI agents in ObjectStack. + +## Agent Types + +- **Data Chat** — Natural language query interface for data +- **Metadata Assistant** — Schema design and modification helper +- **Custom Agents** — Domain-specific AI assistants + +## Agent Configuration + +```typescript +{ + name: 'customer_support_agent', + model: 'gpt-4', + systemPrompt: 'You are a helpful customer support agent...', + tools: ['query_records', 'create_record', 'send_email'], + context: { + objects: ['account', 'contact', 'case'], + }, +} +``` + +## Tool Definition + +```typescript +{ + name: 'query_records', + description: 'Query records from an object', + parameters: { + object: { type: 'string', required: true }, + filter: { type: 'object' }, + limit: { type: 'number', default: 10 }, + }, +} +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Vague Tool Description + +```typescript +{ + name: 'get_data', // ❌ Vague name + description: 'Gets data', // ❌ Vague description +} +``` + +### ✅ Correct — Clear Tool Definition + +```typescript +{ + name: 'query_account_records', // ✅ Specific name + description: 'Query account records with optional filters and pagination', // ✅ Clear description +} +``` + +## Best Practices + +1. **Use clear, descriptive tool names** — Agent must understand purpose +2. **Provide detailed tool descriptions** — Include examples +3. **Limit tool count** — 5-10 tools per agent max +4. **Define parameter schemas** — Validate input +5. **Handle errors gracefully** — Return user-friendly messages + +--- + +See parent skill for complete documentation: [../SKILL.md](../SKILL.md) diff --git a/skills/objectstack-api/evals/README.md b/skills/objectstack-api/evals/README.md new file mode 100644 index 000000000..d6e9bf631 --- /dev/null +++ b/skills/objectstack-api/evals/README.md @@ -0,0 +1,46 @@ +# Evaluation Tests (evals/) + +This directory is reserved for future skill evaluation tests. + +## Purpose + +Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. + +## Structure + +When implemented, evals will follow this structure: + +``` +evals/ +├── naming/ +│ ├── test-object-names.md +│ ├── test-field-keys.md +│ └── test-option-values.md +├── relationships/ +│ ├── test-lookup-vs-master-detail.md +│ └── test-junction-patterns.md +├── validation/ +│ ├── test-script-inversion.md +│ └── test-state-machine.md +└── ... +``` + +## Format + +Each eval file will contain: +1. **Scenario** — Description of the task +2. **Expected Output** — Correct implementation +3. **Common Mistakes** — Incorrect patterns to avoid +4. **Validation Criteria** — How to score the output + +## Status + +⚠️ **Not yet implemented** — This is a placeholder for future development. + +## Contributing + +When adding evals: +1. Each eval should test a single, specific rule or pattern +2. Include both positive (correct) and negative (incorrect) examples +3. Reference the corresponding rule file in `rules/` +4. Use realistic scenarios from actual ObjectStack projects diff --git a/skills/objectstack-api/rules/rest-patterns.md b/skills/objectstack-api/rules/rest-patterns.md new file mode 100644 index 000000000..a51637523 --- /dev/null +++ b/skills/objectstack-api/rules/rest-patterns.md @@ -0,0 +1,70 @@ +# REST API Patterns + +Guide for designing REST APIs in ObjectStack. + +## Auto-Generated APIs + +ObjectStack automatically generates REST APIs for all objects with `apiEnabled: true`: + +``` +GET /api/v1/objects/{object} # List records +GET /api/v1/objects/{object}/{id} # Get single record +POST /api/v1/objects/{object} # Create record +PATCH /api/v1/objects/{object}/{id} # Update record +DELETE /api/v1/objects/{object}/{id} # Delete record +``` + +## API Configuration + +```typescript +{ + enable: { + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + } +} +``` + +## Query Parameters + +- `filter` — JSON filter expression +- `sort` — Sort fields (e.g., `?sort=-created_at`) +- `limit` — Page size (default: 50, max: 200) +- `offset` — Pagination offset +- `fields` — Select specific fields + +## Incorrect vs Correct + +### ❌ Incorrect — Exposing Sensitive Objects + +```typescript +{ + name: 'user_password_reset', + enable: { + apiEnabled: true, // ❌ Sensitive data exposed + } +} +``` + +### ✅ Correct — Disable API for Sensitive Objects + +```typescript +{ + name: 'user_password_reset', + enable: { + apiEnabled: false, // ✅ Not exposed via API + } +} +``` + +## Best Practices + +1. **Disable APIs for internal objects** — System/sensitive objects +2. **Use apiMethods whitelist** — Limit operations (e.g., read-only) +3. **Implement rate limiting** — Protect against abuse +4. **Use field-level permissions** — Control data visibility +5. **Validate input** — Use validation rules + +--- + +See parent skill for complete documentation: [../SKILL.md](../SKILL.md) diff --git a/skills/objectstack-automation/evals/README.md b/skills/objectstack-automation/evals/README.md new file mode 100644 index 000000000..d6e9bf631 --- /dev/null +++ b/skills/objectstack-automation/evals/README.md @@ -0,0 +1,46 @@ +# Evaluation Tests (evals/) + +This directory is reserved for future skill evaluation tests. + +## Purpose + +Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. + +## Structure + +When implemented, evals will follow this structure: + +``` +evals/ +├── naming/ +│ ├── test-object-names.md +│ ├── test-field-keys.md +│ └── test-option-values.md +├── relationships/ +│ ├── test-lookup-vs-master-detail.md +│ └── test-junction-patterns.md +├── validation/ +│ ├── test-script-inversion.md +│ └── test-state-machine.md +└── ... +``` + +## Format + +Each eval file will contain: +1. **Scenario** — Description of the task +2. **Expected Output** — Correct implementation +3. **Common Mistakes** — Incorrect patterns to avoid +4. **Validation Criteria** — How to score the output + +## Status + +⚠️ **Not yet implemented** — This is a placeholder for future development. + +## Contributing + +When adding evals: +1. Each eval should test a single, specific rule or pattern +2. Include both positive (correct) and negative (incorrect) examples +3. Reference the corresponding rule file in `rules/` +4. Use realistic scenarios from actual ObjectStack projects diff --git a/skills/objectstack-automation/rules/flow-patterns.md b/skills/objectstack-automation/rules/flow-patterns.md new file mode 100644 index 000000000..50e26629d --- /dev/null +++ b/skills/objectstack-automation/rules/flow-patterns.md @@ -0,0 +1,88 @@ +# Flow Types and Patterns + +Guide for designing automation flows in ObjectStack. + +## Flow Types + +ObjectStack supports three flow types: + +- **Autolaunched** — Triggered by data events (insert/update/delete) +- **Screen** — User-initiated flows with UI components +- **Schedule** — Time-based/cron-triggered flows + +## Autolaunched Flow Pattern + +```typescript +{ + name: 'new_account_welcome', + type: 'autolaunched', + trigger: { + object: 'account', + event: 'afterInsert', + }, + actions: [ + { + type: 'send_email', + template: 'welcome_email', + to: '{!account.owner.email}', + }, + ], +} +``` + +## Schedule Flow Pattern + +```typescript +{ + name: 'daily_summary', + type: 'schedule', + schedule: '0 9 * * *', // Daily at 9 AM + actions: [ + { + type: 'query_records', + object: 'task', + filter: { due_date: 'TODAY()' }, + }, + { + type: 'send_email', + template: 'daily_tasks', + }, + ], +} +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Autolaunched Flow Without Trigger + +```typescript +{ + type: 'autolaunched', // ❌ No trigger specified + actions: [/* ... */], +} +``` + +### ✅ Correct — Complete Trigger Configuration + +```typescript +{ + type: 'autolaunched', + trigger: { + object: 'account', + event: 'afterInsert', // ✅ Trigger specified + }, + actions: [/* ... */], +} +``` + +## Best Practices + +1. **Use after* events for autolaunched flows** — Avoid blocking transactions +2. **Limit flow complexity** — Break complex flows into multiple smaller flows +3. **Handle errors gracefully** — Use try/catch, retry policies +4. **Test flows thoroughly** — Validate all paths and edge cases +5. **Monitor flow execution** — Track success/failure rates + +--- + +See parent skill for complete documentation: [../SKILL.md](../SKILL.md) diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 942048b30..1350470f0 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -8,27 +8,28 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec Zod schemas (v4+) metadata: author: objectstack-ai - version: "1.0" + version: "2.0" domain: data - tags: object, field, validation, index, relationship + tags: object, field, validation, index, relationship, hooks --- # Schema Design — ObjectStack Data Protocol Expert instructions for designing business data schemas using the ObjectStack specification. This skill covers Object definitions, Field type selection, -relationship modelling, validation rules, and index strategy. +relationship modelling, validation rules, index strategy, and lifecycle hooks. --- ## When to Use This Skill -- You are creating a **new business object** (e.g., `account`, `project_task`). -- You need to **choose the right field type** from the 48 supported types. -- You are configuring **lookup / master-detail relationships** between objects. -- You need to add **validation rules** (uniqueness, cross-field, state machine, etc.). -- You are optimising **query performance with indexes**. -- You are extending an existing object with new fields or capabilities. +- You are creating a **new business object** (e.g., `account`, `project_task`) +- You need to **choose the right field type** from the 48 supported types +- You are configuring **lookup / master-detail relationships** between objects +- You need to add **validation rules** (uniqueness, cross-field, state machine, etc.) +- You are optimising **query performance with indexes** +- You are extending an existing object with new fields or capabilities +- You need to **implement data lifecycle hooks** for business logic --- @@ -55,7 +56,7 @@ database table and exposes automatic CRUD APIs. | `namespace` | — | Domain prefix; auto-derives `tableName` as `{namespace}_{name}` | | `datasource` | `'default'` | Target datasource ID for virtualized data | | `displayNameField` | `'name'` | Field used as record display name | -| `enable` | — | Capability flags (see below) | +| `enable` | — | Capability flags (trackHistory, searchable, apiEnabled, etc.) | ### Object Capabilities (`enable`) @@ -76,188 +77,169 @@ Toggle system behaviours per object: --- -## Field Type Reference +## Quick Reference — Detailed Rules -ObjectStack supports **48 field types** organised into categories. +For comprehensive documentation with incorrect/correct examples: -### Text & Content +- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties +- **[Field Types](./rules/field-types.md)** — All 48 field types with decision tree and configs +- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors +- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels +- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes +- **[Lifecycle Hooks](./rules/hooks.md)** — Data lifecycle hooks, before/after patterns, side effects -| Type | When to Use | -|:-----|:------------| -| `text` | Single-line strings (names, codes, short values) | -| `textarea` | Multi-line plain text (notes, descriptions) | -| `email` | Email addresses — built-in format validation | -| `url` | Web URLs — built-in format validation | -| `phone` | Phone numbers | -| `password` | Masked / hashed input | -| `markdown` | Markdown-formatted content | -| `html` | Raw HTML content | -| `richtext` | WYSIWYG rich text editor | - -### Numbers - -| Type | When to Use | -|:-----|:------------| -| `number` | Generic numeric value | -| `currency` | Monetary amounts — supports `currencyConfig` with `precision`, `currencyMode`, `defaultCurrency` | -| `percent` | Percentage values | - -### Date & Time - -| Type | When to Use | -|:-----|:------------| -| `date` | Date only (no time component) | -| `datetime` | Full date + time | -| `time` | Time only (no date component) | - -### Logic - -| Type | When to Use | -|:-----|:------------| -| `boolean` | Standard checkbox | -| `toggle` | Toggle switch (distinct UI affordance from checkbox) | - -### Selection - -| Type | When to Use | -|:-----|:------------| -| `select` | Single-choice dropdown; define `options` array | -| `multiselect` | Tag-style multi-choice | -| `radio` | Radio button group (fewer choices, always visible) | -| `checkboxes` | Checkbox group | - -> **Critical:** Every option must have a lowercase machine `value` and a -> human-readable `label`. Optional `color` enables badge/chart styling. - -### Relational - -| Type | When to Use | Key Config | -|:-----|:------------|:-----------| -| `lookup` | Reference another object | `reference` (target object name) | -| `master_detail` | Parent–child with lifecycle control | `reference`, `deleteBehavior` (`cascade` / `restrict` / `set_null`) | -| `tree` | Hierarchical self-reference | `reference` | - -> Set `multiple: true` on a lookup to create a many-to-many junction. - -### Media - -`image`, `file`, `avatar`, `video`, `audio` — all support -`fileAttachmentConfig` for size limits, allowed types, virus scanning, and -storage provider. - -### Calculated - -| Type | When to Use | -|:-----|:------------| -| `formula` | Computed from an `expression` referencing other fields | -| `summary` | Roll-up aggregation from child records (`count`, `sum`, `min`, `max`, `avg`) | -| `autonumber` | Auto-incrementing display format (e.g., `"CASE-{0000}"`) | +--- -### Enhanced Types +## Quick-Start Template -`location`, `address`, `code`, `json`, `color`, `rating`, `slider`, -`signature`, `qrcode`, `progress`, `tags`, `vector` +```typescript +import { ObjectSchema } from '@objectstack/spec'; -> **`vector`** is for AI/ML embeddings (semantic search, RAG). Configure -> `vectorConfig` with `dimensions`, `distanceMetric`, and `indexType`. +export default ObjectSchema.create({ + name: 'support_case', + label: 'Support Case', + namespace: 'helpdesk', + enable: { + trackHistory: true, + feeds: true, + activities: true, + trash: true, + }, + fields: { + subject: { type: 'text', required: true, maxLength: 255 }, + description: { type: 'richtext' }, + status: { type: 'select', required: true, options: [ + { label: 'New', value: 'new', default: true }, + { label: 'Open', value: 'open' }, + { label: 'Escalated', value: 'escalated', color: '#e74c3c' }, + { label: 'Resolved', value: 'resolved', color: '#2ecc71' }, + { label: 'Closed', value: 'closed' }, + ]}, + priority: { type: 'select', options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium', default: true }, + { label: 'High', value: 'high', color: '#e67e22' }, + { label: 'Urgent', value: 'urgent', color: '#e74c3c' }, + ]}, + account: { type: 'lookup', reference: 'account', required: true }, + contact: { type: 'lookup', reference: 'contact' }, + assigned_to: { type: 'lookup', reference: 'user' }, + due_date: { type: 'datetime' }, + }, + validations: [ + { + name: 'status_flow', + type: 'state_machine', + field: 'status', + transitions: { + new: ['open'], + open: ['escalated', 'resolved'], + escalated: ['open', 'resolved'], + resolved: ['open', 'closed'], + closed: [], + }, + message: 'Invalid status transition.', + }, + ], + indexes: [ + { fields: ['status', 'priority'] }, + { fields: ['account'] }, + ], +}); +``` --- -## Naming Rules — Non-Negotiable +## Common Patterns + +### Naming Rules Summary | Context | Convention | Example | |:--------|:-----------|:--------| | Object `name` | `snake_case` | `project_task` | | Field keys | `snake_case` | `first_name`, `due_date` | -| Schema property keys (TS config) | `camelCase` | `maxLength`, `referenceFilters` | -| Option `value` | lowercase machine ID | `in_progress` | -| Option `label` | Any case | `"In Progress"` | +| Schema properties | `camelCase` | `maxLength`, `referenceFilters` | +| Option `value` | lowercase | `in_progress` | -> **Never** use `camelCase` or `PascalCase` for object names or field keys. -> **Always** use `camelCase` for TypeScript configuration property keys. +See [rules/naming.md](./rules/naming.md) for incorrect/correct examples. ---- +### Field Type Selection -## Relationship Modelling Guide +48 types available. Quick categories: -### When to Use `lookup` vs `master_detail` +- **Text:** `text`, `textarea`, `email`, `url`, `phone`, `markdown`, `html`, `richtext` +- **Numbers:** `number`, `currency`, `percent` +- **Date/Time:** `date`, `datetime`, `time` +- **Logic:** `boolean`, `toggle` +- **Selection:** `select`, `multiselect`, `radio`, `checkboxes` +- **Relational:** `lookup`, `master_detail`, `tree` +- **Media:** `image`, `file`, `avatar`, `video`, `audio` +- **Calculated:** `formula`, `summary`, `autonumber` +- **Enhanced:** `location`, `address`, `code`, `json`, `color`, `rating`, `slider`, `signature`, `qrcode`, `progress`, `tags`, `vector` -| Criteria | `lookup` | `master_detail` | -|:---------|:---------|:-----------------| -| Lifecycle coupling | Independent | Child deleted when parent deleted | -| Required? | Optional by default | Always required | -| Sharing | Independent | Inherits parent sharing model | -| Roll-up summaries | Not available | Supported via `summary` fields | -| Use case | "Related to" | "Owned by" (e.g., Invoice → Line Items) | +See [rules/field-types.md](./rules/field-types.md) for full reference. -### Many-to-Many Relationships +### Relationship Patterns -ObjectStack does not have a native many-to-many type. Model it as: +| Pattern | Implementation | +|:--------|:---------------| +| One-to-Many (independent) | `lookup` field on child | +| One-to-Many (owned) | `master_detail` field on child | +| Many-to-Many | Junction object with two `lookup` fields | +| Hierarchical | `tree` field (self-reference) | -``` -ObjectA ← junction_object → ObjectB -``` +See [rules/relationships.md](./rules/relationships.md) for detailed examples. -The junction object has two `lookup` fields, one to each side. +### Validation Patterns ---- - -## Validation Rules — Best Practices - -### Available Rule Types - -| Type | Purpose | -|:-----|:--------| -| `script` | Formula expression — validation **fails** when expression is `true` | -| `unique` | Composite uniqueness across multiple fields | -| `state_machine` | Legal state transitions (e.g., `draft → submitted → approved`) | -| `format` | Regex or built-in format (`email`, `url`, `phone`, `json`) | -| `cross_field` | Compare values across fields (e.g., `end_date > start_date`) | -| `json_schema` | Validate a JSON field against a JSON Schema | -| `async` | External API validation (with timeout and debounce) | -| `custom` | Registered validator function | -| `conditional` | Apply a rule only when a condition is met | - -### Common Patterns +**⚠️ Script validation is inverted:** Validation **fails** when expression is `true`. -1. **Prevent backdating:** `cross_field` with `condition: "start_date >= TODAY()"` -2. **Enforce status flow:** `state_machine` on `status` field -3. **Composite unique:** `unique` on `['tenant_id', 'email']` with `caseSensitive: false` +Common validation types: +- `script` — Formula expression (inverted logic) +- `unique` — Composite uniqueness +- `state_machine` — Legal state transitions +- `format` — Regex or built-in format +- `cross_field` — Compare values across fields -### Pitfalls +See [rules/validation.md](./rules/validation.md) for all types and examples. -- `script` condition is **inverted**: `true` means **invalid**. -- Always set `severity` (`error` | `warning` | `info`) — default is `error`. -- Set `events` to control when the rule fires (`insert`, `update`, `delete`). -- Lower `priority` numbers execute **first**. +### Index Patterns ---- - -## Index Strategy - -**Only declare non-default values.** `type` defaults to `'btree'` and `unique` -defaults to `false` — omit them when using the default. +**Omit default values:** `type` defaults to `'btree'`, `unique` defaults to `false`. ```typescript indexes: [ { fields: ['status', 'created_at'] }, // btree (default) { fields: ['email'], unique: true }, // btree + unique { fields: ['description'], type: 'fulltext' }, // non-default type - { fields: ['tags'], type: 'gin' }, // non-default type - { fields: ['location'], type: 'gist' }, // non-default type ] ``` -| Type | Default? | When to Use | -|:-----|:---------|:------------| -| `btree` | **Yes** | Equality and range queries — omit `type` | -| `hash` | No | Exact equality only (rare) | -| `fulltext` | No | Text search columns | -| `gin` | No | Array / JSONB containment | -| `gist` | No | Geospatial / range types | +See [rules/indexing.md](./rules/indexing.md) for composite/partial/gin/gist indexes. -> Use `partial` indexes to index only a subset of rows -> (e.g., `partial: "status = 'active'"`). +### Lifecycle Hooks + +Implement business logic at data operation lifecycle points: + +```typescript +import { Hook, HookContext } from '@objectstack/spec/data'; + +const accountHook: Hook = { + name: 'account_defaults', + object: 'account', + events: ['beforeInsert'], + handler: async (ctx: HookContext) => { + if (!ctx.input.industry) { + ctx.input.industry = 'Other'; + } + ctx.input.created_at = new Date().toISOString(); + }, +}; + +export default accountHook; +``` + +See [rules/hooks.md](./rules/hooks.md) for all 14 lifecycle events and patterns. --- @@ -274,8 +256,8 @@ When extending an object you do not own: } ``` -- `priority` controls merge order (default `200`; range `0–999`). -- Extensions can add fields, validations, and indexes — but cannot remove them. +- `priority` controls merge order (default `200`; range `0–999`) +- Extensions can add fields, validations, and indexes — but cannot remove them --- @@ -293,74 +275,16 @@ When extending an object you do not own: --- -## Quick-Start Template - -```typescript -import { ObjectSchema } from '@objectstack/spec'; - -export default ObjectSchema.create({ - name: 'support_case', - label: 'Support Case', - namespace: 'helpdesk', - enable: { - trackHistory: true, - feeds: true, - activities: true, - trash: true, - }, - fields: { - subject: { type: 'text', required: true, maxLength: 255 }, - description: { type: 'richtext' }, - status: { type: 'select', required: true, options: [ - { label: 'New', value: 'new', default: true }, - { label: 'Open', value: 'open' }, - { label: 'Escalated', value: 'escalated', color: '#e74c3c' }, - { label: 'Resolved', value: 'resolved', color: '#2ecc71' }, - { label: 'Closed', value: 'closed' }, - ]}, - priority: { type: 'select', options: [ - { label: 'Low', value: 'low' }, - { label: 'Medium', value: 'medium', default: true }, - { label: 'High', value: 'high', color: '#e67e22' }, - { label: 'Urgent', value: 'urgent', color: '#e74c3c' }, - ]}, - account: { type: 'lookup', reference: 'account', required: true }, - contact: { type: 'lookup', reference: 'contact' }, - assigned_to: { type: 'lookup', reference: 'user' }, - due_date: { type: 'datetime' }, - }, - validations: [ - { - name: 'status_flow', - type: 'state_machine', - field: 'status', - transitions: { - new: ['open'], - open: ['escalated', 'resolved'], - escalated: ['open', 'resolved'], - resolved: ['open', 'closed'], - closed: [], - }, - message: 'Invalid status transition.', - }, - ], - indexes: [ - { fields: ['status', 'priority'] }, - { fields: ['account'] }, - ], -}); -``` - ---- - ## References -- [field.zod.ts](./references/data/field.zod.ts) — FieldType enum, FieldSchema, option/currency/vector config -- [object.zod.ts](./references/data/object.zod.ts) — ObjectSchema, capabilities, extension model -- [validation.zod.ts](./references/data/validation.zod.ts) — Validation rule types -- [query.zod.ts](./references/data/query.zod.ts) — Query operations, pagination, sorting -- [filter.zod.ts](./references/data/filter.zod.ts) — Filter operators, compound conditions -- [datasource.zod.ts](./references/data/datasource.zod.ts) — Datasource config, driver capabilities -- [hook.zod.ts](./references/data/hook.zod.ts) — Lifecycle hooks (beforeInsert, afterUpdate, etc.) -- [permission.zod.ts](./references/security/permission.zod.ts) — Field/object-level permissions, CRUD access +- [rules/naming.md](./rules/naming.md) — Naming conventions with incorrect/correct examples +- [rules/field-types.md](./rules/field-types.md) — All 48 field types with decision tree +- [rules/relationships.md](./rules/relationships.md) — lookup vs master_detail, patterns +- [rules/validation.md](./rules/validation.md) — All validation types, script inversion +- [rules/indexing.md](./rules/indexing.md) — Index types, composite/partial strategies +- [rules/hooks.md](./rules/hooks.md) — Data lifecycle hooks, 14 events, patterns +- [references/data/field.zod.ts](./references/data/field.zod.ts) — FieldType enum, FieldSchema +- [references/data/object.zod.ts](./references/data/object.zod.ts) — ObjectSchema, capabilities +- [references/data/validation.zod.ts](./references/data/validation.zod.ts) — Validation rule types +- [references/data/hook.zod.ts](./references/data/hook.zod.ts) — Hook schema, HookContext - [Schema index](./references/_index.md) — All bundled schemas with dependency tree diff --git a/skills/objectstack-data/evals/README.md b/skills/objectstack-data/evals/README.md new file mode 100644 index 000000000..d6e9bf631 --- /dev/null +++ b/skills/objectstack-data/evals/README.md @@ -0,0 +1,46 @@ +# Evaluation Tests (evals/) + +This directory is reserved for future skill evaluation tests. + +## Purpose + +Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. + +## Structure + +When implemented, evals will follow this structure: + +``` +evals/ +├── naming/ +│ ├── test-object-names.md +│ ├── test-field-keys.md +│ └── test-option-values.md +├── relationships/ +│ ├── test-lookup-vs-master-detail.md +│ └── test-junction-patterns.md +├── validation/ +│ ├── test-script-inversion.md +│ └── test-state-machine.md +└── ... +``` + +## Format + +Each eval file will contain: +1. **Scenario** — Description of the task +2. **Expected Output** — Correct implementation +3. **Common Mistakes** — Incorrect patterns to avoid +4. **Validation Criteria** — How to score the output + +## Status + +⚠️ **Not yet implemented** — This is a placeholder for future development. + +## Contributing + +When adding evals: +1. Each eval should test a single, specific rule or pattern +2. Include both positive (correct) and negative (incorrect) examples +3. Reference the corresponding rule file in `rules/` +4. Use realistic scenarios from actual ObjectStack projects diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md new file mode 100644 index 000000000..9b8e426e2 --- /dev/null +++ b/skills/objectstack-data/rules/field-types.md @@ -0,0 +1,348 @@ +# Field Types Reference + +Quick reference for choosing the right field type from 48 available options. + +## Text & Content + +| Type | When to Use | Config | +|:-----|:------------|:-------| +| `text` | Single-line strings (names, codes, titles) | `maxLength`, `minLength`, `defaultValue` | +| `textarea` | Multi-line plain text (notes, descriptions) | `maxLength`, `rows` | +| `email` | Email addresses — built-in format validation | `required`, `unique` | +| `url` | Web URLs — built-in format validation | `required` | +| `phone` | Phone numbers | `format` (custom regex) | +| `password` | Masked / hashed input | `minLength`, `hashAlgorithm` | +| `markdown` | Markdown-formatted content | `maxLength` | +| `html` | Raw HTML content | `maxLength`, `sanitize` | +| `richtext` | WYSIWYG rich text editor | `maxLength` | + +## Numbers + +| Type | When to Use | Config | +|:-----|:------------|:-------| +| `number` | Generic numeric value | `min`, `max`, `precision`, `step` | +| `currency` | Monetary amounts | `currencyConfig` (precision, currencyMode, defaultCurrency) | +| `percent` | Percentage values (0-100) | `min`, `max`, `precision` | + +## Date & Time + +| Type | When to Use | Config | +|:-----|:------------|:-------| +| `date` | Date only (no time component) | `defaultValue`, `min`, `max` | +| `datetime` | Full date + time | `defaultValue`, `timezone` | +| `time` | Time only (no date component) | `defaultValue`, `format` | + +## Logic + +| Type | When to Use | Config | +|:-----|:------------|:-------| +| `boolean` | Standard checkbox | `defaultValue` | +| `toggle` | Toggle switch (distinct UI from checkbox) | `defaultValue` | + +## Selection + +| Type | When to Use | Config | +|:-----|:------------|:-------| +| `select` | Single-choice dropdown | `options` (value, label, color, default) | +| `multiselect` | Tag-style multi-choice | `options`, `max` | +| `radio` | Radio button group (fewer choices, always visible) | `options` | +| `checkboxes` | Checkbox group | `options` | + +**Critical:** Every option must have lowercase `value` and human-readable `label`. + +```typescript +options: [ + { label: 'In Progress', value: 'in_progress', color: '#3498db' }, + { label: 'Done', value: 'done', default: true }, +] +``` + +## Relational + +| Type | When to Use | Key Config | +|:-----|:------------|:-----------| +| `lookup` | Reference another object (independent) | `reference`, `referenceFilters`, `multiple` | +| `master_detail` | Parent–child with lifecycle control | `reference`, `deleteBehavior` (cascade/restrict/set_null) | +| `tree` | Hierarchical self-reference | `reference` | + +Set `multiple: true` on lookup for many-to-many via junction. + +## Media + +| Type | When to Use | Config | +|:-----|:------------|:-------| +| `image` | Image files (PNG, JPG, GIF, WebP) | `fileAttachmentConfig` (maxSize, allowedTypes, storage) | +| `file` | Generic file attachments | `fileAttachmentConfig`, `allowedExtensions` | +| `avatar` | User/profile picture | `fileAttachmentConfig`, `cropAspectRatio` | +| `video` | Video files | `fileAttachmentConfig`, `maxDuration` | +| `audio` | Audio files | `fileAttachmentConfig`, `maxDuration` | + +All use `fileAttachmentConfig` for size limits, allowed types, virus scanning, and storage provider. + +## Calculated + +| Type | When to Use | Config | +|:-----|:------------|:-------| +| `formula` | Computed from an expression referencing other fields | `expression`, `resultType` | +| `summary` | Roll-up aggregation from child records | `summaryType` (count/sum/min/max/avg), `summaryField`, `reference` | +| `autonumber` | Auto-incrementing display format | `format` (e.g., `"CASE-{0000}"`) | + +## Enhanced Types + +| Type | When to Use | Config | +|:-----|:------------|:-------| +| `location` | Geographic coordinates (lat/lng) | `defaultZoom`, `enableSearch` | +| `address` | Structured address (street, city, country) | `countryFilter`, `autocomplete` | +| `code` | Syntax-highlighted code editor | `language`, `theme` | +| `json` | JSON data | `schema` (JSON Schema for validation) | +| `color` | Color picker | `format` (hex/rgb/hsl), `alpha` | +| `rating` | Star/heart rating | `max` (default 5), `icon` | +| `slider` | Numeric slider | `min`, `max`, `step` | +| `signature` | Digital signature pad | `signatureConfig` | +| `qrcode` | QR code generator | `qrConfig` | +| `progress` | Progress bar | `min`, `max`, `showPercentage` | +| `tags` | Free-form tag input | `max`, `delimiter`, `caseSensitive` | +| `vector` | AI/ML embeddings (semantic search, RAG) | `vectorConfig` (dimensions, distanceMetric, indexType) | + +## Field Type Decision Tree + +``` +What kind of data? +│ +├── Text? +│ ├── Single line → text +│ ├── Multiple lines → textarea +│ ├── Formatted → richtext / markdown / html +│ ├── Email → email +│ ├── URL → url +│ ├── Phone → phone +│ └── Code → code +│ +├── Number? +│ ├── Money → currency +│ ├── Percentage → percent +│ └── Generic → number +│ +├── Date/Time? +│ ├── Date only → date +│ ├── Time only → time +│ └── Date + Time → datetime +│ +├── True/False? +│ ├── Checkbox → boolean +│ └── Switch → toggle +│ +├── Choose from list? +│ ├── Single choice, dropdown → select +│ ├── Single choice, always visible → radio +│ ├── Multiple choice, tags → multiselect +│ └── Multiple choice, checkboxes → checkboxes +│ +├── Reference another object? +│ ├── Independent → lookup +│ ├── Owned child → master_detail +│ └── Hierarchy → tree +│ +├── File/Media? +│ ├── Image → image +│ ├── Video → video +│ ├── Audio → audio +│ ├── User photo → avatar +│ └── Generic file → file +│ +├── Calculated? +│ ├── Formula → formula +│ ├── Roll-up → summary +│ └── Auto-number → autonumber +│ +└── Special? + ├── Location → location + ├── Address → address + ├── Color → color + ├── Rating → rating + ├── Signature → signature + ├── QR code → qrcode + ├── Progress → progress + ├── Tags → tags + ├── JSON data → json + └── AI embeddings → vector +``` + +## Common Field Configurations + +### Text with Max Length + +```typescript +{ + type: 'text', + maxLength: 255, + required: true, +} +``` + +### Email with Uniqueness + +```typescript +{ + type: 'email', + required: true, + unique: true, +} +``` + +### Currency with Precision + +```typescript +{ + type: 'currency', + currencyConfig: { + precision: 2, + currencyMode: 'multi', // or 'single' + defaultCurrency: 'USD', + }, +} +``` + +### Select with Default + +```typescript +{ + type: 'select', + required: true, + options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium', default: true }, + { label: 'High', value: 'high', color: '#e74c3c' }, + ], +} +``` + +### Lookup (One-to-Many) + +```typescript +{ + type: 'lookup', + reference: 'account', + required: true, + referenceFilters: { + status: 'active', + }, +} +``` + +### Lookup (Many-to-Many) + +```typescript +{ + type: 'lookup', + reference: 'tag', + multiple: true, + max: 10, +} +``` + +### Master-Detail with Cascade + +```typescript +{ + type: 'master_detail', + reference: 'invoice', + deleteBehavior: 'cascade', + required: true, +} +``` + +### Formula + +```typescript +{ + type: 'formula', + expression: 'amount * tax_rate', + resultType: 'currency', +} +``` + +### Summary (Roll-up) + +```typescript +{ + type: 'summary', + reference: 'invoice_line_item', + summaryType: 'sum', + summaryField: 'amount', +} +``` + +### Autonumber + +```typescript +{ + type: 'autonumber', + format: 'CASE-{0000}', +} +``` + +### Vector (AI Embeddings) + +```typescript +{ + type: 'vector', + vectorConfig: { + dimensions: 1536, // OpenAI ada-002 + distanceMetric: 'cosine', + indexType: 'hnsw', + }, +} +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Wrong Type for Email + +```typescript +{ + type: 'text', // ❌ No built-in email validation + maxLength: 255, +} +``` + +### ✅ Correct — Use email Type + +```typescript +{ + type: 'email', // ✅ Built-in validation + UI affordances +} +``` + +### ❌ Incorrect — Uppercase Option Value + +```typescript +options: [ + { label: 'Done', value: 'Done' }, // ❌ Uppercase +] +``` + +### ✅ Correct — Lowercase Option Value + +```typescript +options: [ + { label: 'Done', value: 'done' }, // ✅ Lowercase +] +``` + +### ❌ Incorrect — Missing Reference + +```typescript +{ + type: 'lookup', // ❌ No reference specified +} +``` + +### ✅ Correct — Specify Reference + +```typescript +{ + type: 'lookup', + reference: 'account', // ✅ Target object specified +} +``` diff --git a/skills/objectstack-data/rules/hooks.md b/skills/objectstack-data/rules/hooks.md new file mode 100644 index 000000000..bb503e1d6 --- /dev/null +++ b/skills/objectstack-data/rules/hooks.md @@ -0,0 +1,1038 @@ +--- +name: objectstack-hooks +description: > + Write ObjectStack data lifecycle hooks for third-party plugins and applications. + Use when implementing business logic, validation, side effects, or data transformations + during CRUD operations. Covers hook registration, handler patterns, context API, + error handling, async execution, and integration with the ObjectQL engine. +license: Apache-2.0 +compatibility: Requires @objectstack/spec v4+, @objectstack/objectql v4+ +metadata: + author: objectstack-ai + version: "1.0" + domain: hooks + tags: hooks, lifecycle, validation, business-logic, side-effects, data-enrichment +--- + +# Writing Hooks — ObjectStack Data Lifecycle + +Expert instructions for third-party developers to write data lifecycle hooks in ObjectStack. +Hooks are the primary extension point for adding custom business logic, validation rules, +side effects, and data transformations to CRUD operations. + +--- + +## When to Use This Skill + +- You need to **add custom validation** beyond declarative rules. +- You want to **enrich data** (set defaults, calculate fields, normalize values). +- You need to trigger **side effects** (send emails, update external systems, publish events). +- You want to **enforce business rules** that span multiple fields or objects. +- You need to **transform data** before or after database operations. +- You want to **integrate with external APIs** during data operations. +- You need to **implement audit trails** or compliance requirements. + +--- + +## Core Concepts + +### What Are Hooks? + +Hooks are **event handlers** that execute during the ObjectQL data access lifecycle. +They intercept operations at specific points (before/after) and can: + +- **Read** the operation context (user, session, input data) +- **Modify** input parameters or operation results +- **Validate** data and throw errors to abort operations +- **Trigger** side effects (notifications, integrations, logging) + +### Hook Lifecycle Events + +ObjectStack provides **14 lifecycle events** organized by operation type: + +| Event | When It Fires | Use Cases | +|:------|:--------------|:----------| +| **Read Operations** | | | +| `beforeFind` | Before querying multiple records | Filter queries by user context, log access | +| `afterFind` | After querying multiple records | Transform results, mask sensitive data | +| `beforeFindOne` | Before fetching a single record | Validate permissions, log access | +| `afterFindOne` | After fetching a single record | Enrich data, mask fields | +| `beforeCount` | Before counting records | Filter by context | +| `afterCount` | After counting records | Log metrics | +| `beforeAggregate` | Before aggregate operations | Validate aggregation rules | +| `afterAggregate` | After aggregate operations | Transform results | +| **Write Operations** | | | +| `beforeInsert` | Before creating a record | Set defaults, validate, normalize | +| `afterInsert` | After creating a record | Send notifications, create related records | +| `beforeUpdate` | Before updating a record | Validate changes, check permissions | +| `afterUpdate` | After updating a record | Trigger workflows, sync external systems | +| `beforeDelete` | Before deleting a record | Check dependencies, prevent deletion | +| `afterDelete` | After deleting a record | Clean up related data, notify users | + +### Before vs After Hooks + +| Aspect | `before*` Hooks | `after*` Hooks | +|:-------|:----------------|:---------------| +| **Purpose** | Validation, enrichment, transformation | Side effects, notifications, logging | +| **Can modify** | `ctx.input` (mutable) | `ctx.result` (mutable) | +| **Can abort** | Yes (throw error → rollback) | No (operation already committed) | +| **Transaction** | Within transaction | After transaction (unless async: false) | +| **Error handling** | Aborts operation by default | Logged by default (configurable) | + +--- + +## Hook Definition Schema + +Every hook must conform to the `HookSchema`: + +```typescript +import { Hook, HookContext } from '@objectstack/spec/data'; + +const myHook: Hook = { + // Required: Unique identifier (snake_case) + name: 'my_validation_hook', + + // Required: Target object(s) + object: 'account', // string | string[] | '*' + + // Required: Events to subscribe to + events: ['beforeInsert', 'beforeUpdate'], + + // Required: Handler function (inline or string reference) + handler: async (ctx: HookContext) => { + // Your logic here + }, + + // Optional: Execution priority (lower runs first) + priority: 100, // System: 0-99, App: 100-999, User: 1000+ + + // Optional: Run in background (after* events only) + async: false, + + // Optional: Conditional execution + condition: "status = 'active' AND amount > 1000", + + // Optional: Human-readable description + description: 'Validates account data before save', + + // Optional: Error handling strategy + onError: 'abort', // 'abort' | 'log' + + // Optional: Execution timeout (ms) + timeout: 5000, + + // Optional: Retry policy + retryPolicy: { + maxRetries: 3, + backoffMs: 1000, + }, +}; +``` + +### Key Properties Explained + +#### `object` — Target Scope + +```typescript +// Single object +object: 'account' + +// Multiple objects +object: ['account', 'contact', 'lead'] + +// All objects (use sparingly — performance impact) +object: '*' +``` + +#### `events` — Lifecycle Events + +```typescript +// Single event +events: ['beforeInsert'] + +// Multiple events (common pattern) +events: ['beforeInsert', 'beforeUpdate'] + +// After events for side effects +events: ['afterInsert', 'afterUpdate', 'afterDelete'] +``` + +#### `handler` — Implementation + +Handlers can be: + +1. **Inline functions** (recommended for simple hooks): + ```typescript + handler: async (ctx: HookContext) => { + if (!ctx.input.email) { + throw new Error('Email is required'); + } + } + ``` + +2. **String references** (for registered handlers): + ```typescript + handler: 'my_plugin.validateAccount' + ``` + +#### `priority` — Execution Order + +Lower numbers execute first: + +```typescript +// System hooks (framework internals) +priority: 50 + +// Application hooks (your app logic) +priority: 100 // default + +// User customizations +priority: 1000 +``` + +#### `async` — Background Execution + +Only applicable for `after*` events: + +```typescript +// Blocking (default) — runs within transaction +async: false + +// Fire-and-forget — runs in background +async: true +``` + +**When to use async: true:** +- Sending emails/notifications +- Calling slow external APIs +- Logging to external systems +- Non-critical side effects + +**When to use async: false:** +- Creating related records +- Updating dependent data +- Critical consistency requirements + +#### `condition` — Declarative Filtering + +Skip handler execution if condition is false: + +```typescript +// Only run for high-value accounts +condition: "annual_revenue > 1000000" + +// Only run for specific statuses +condition: "status IN ('pending', 'in_review')" + +// Complex conditions +condition: "type = 'enterprise' AND region = 'APAC' AND is_active = true" +``` + +#### `onError` — Error Handling + +```typescript +// Abort operation on error (default for before* hooks) +onError: 'abort' + +// Log error and continue (default for after* hooks) +onError: 'log' +``` + +--- + +## Hook Context API + +The `HookContext` passed to your handler provides: + +### Context Properties + +```typescript +interface HookContext { + // Immutable identifiers + id?: string; // Unique execution ID for tracing + object: string; // Target object name (e.g., 'account') + event: HookEventType; // Current event (e.g., 'beforeInsert') + + // Mutable data + input: Record; // Operation parameters (MUTABLE) + result?: unknown; // Operation result (MUTABLE, after* only) + previous?: Record; // Previous state (update/delete) + + // Execution context + session?: { + userId?: string; + tenantId?: string; + roles?: string[]; + accessToken?: string; + }; + + transaction?: unknown; // Database transaction handle + + // Engine access + ql: IDataEngine; // ObjectQL engine instance + api?: ScopedContext; // Cross-object CRUD API + + // User info shortcut + user?: { + id?: string; + name?: string; + email?: string; + }; +} +``` + +### `input` — Operation Parameters + +The structure of `ctx.input` varies by event: + +**Insert operations:** +```typescript +// beforeInsert, afterInsert +{ + // All field values being inserted + name: 'Acme Corp', + industry: 'Technology', + annual_revenue: 5000000, + ... +} +``` + +**Update operations:** +```typescript +// beforeUpdate, afterUpdate +{ + id: '123', // Record ID being updated + // Only fields being changed + status: 'active', + updated_at: '2026-04-13T10:00:00Z', +} +``` + +**Delete operations:** +```typescript +// beforeDelete, afterDelete +{ + id: '123', // Record ID being deleted +} +``` + +**Query operations:** +```typescript +// beforeFind, afterFind +{ + query: { + filter: { status: 'active' }, + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + offset: 0, + }, + options: { includeCount: true }, +} +``` + +### `result` — Operation Result + +Available in `after*` hooks: + +```typescript +// afterInsert +result: { id: '123', name: 'Acme Corp', ... } + +// afterUpdate +result: { id: '123', status: 'active', ... } + +// afterDelete +result: { success: true, id: '123' } + +// afterFind +result: { + records: [{ id: '1', ... }, { id: '2', ... }], + total: 150, +} +``` + +### `previous` — Previous State + +Available in update/delete hooks: + +```typescript +// beforeUpdate, afterUpdate +ctx.previous: { + id: '123', + status: 'pending', // Old value + updated_at: '2026-04-01T00:00:00Z', +} + +// beforeDelete, afterDelete +ctx.previous: { + id: '123', + name: 'Old Account', + // ... full record state +} +``` + +### Cross-Object API + +Access other objects within the same transaction: + +```typescript +handler: async (ctx: HookContext) => { + // Get API for another object + const users = ctx.api?.object('user'); + + // Query users + const admin = await users.findOne({ + filter: { role: 'admin' } + }); + + // Create related record + await ctx.api?.object('audit_log').insert({ + action: 'account_created', + user_id: ctx.session?.userId, + record_id: ctx.input.id, + }); +} +``` + +--- + +## Common Patterns + +### 1. Setting Default Values + +```typescript +const setAccountDefaults: Hook = { + name: 'account_defaults', + object: 'account', + events: ['beforeInsert'], + handler: async (ctx) => { + // Set default industry + if (!ctx.input.industry) { + ctx.input.industry = 'Other'; + } + + // Set created timestamp + ctx.input.created_at = new Date().toISOString(); + + // Set owner to current user + if (!ctx.input.owner_id && ctx.session?.userId) { + ctx.input.owner_id = ctx.session.userId; + } + }, +}; +``` + +### 2. Data Validation + +```typescript +const validateAccount: Hook = { + name: 'account_validation', + object: 'account', + events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx) => { + // Validate email format + if (ctx.input.email && !ctx.input.email.includes('@')) { + throw new Error('Invalid email format'); + } + + // Validate website URL + if (ctx.input.website && !ctx.input.website.startsWith('http')) { + throw new Error('Website must start with http or https'); + } + + // Check annual revenue + if (ctx.input.annual_revenue && ctx.input.annual_revenue < 0) { + throw new Error('Annual revenue cannot be negative'); + } + }, +}; +``` + +### 3. Preventing Deletion + +```typescript +const protectStrategicAccounts: Hook = { + name: 'protect_strategic_accounts', + object: 'account', + events: ['beforeDelete'], + handler: async (ctx) => { + // ctx.previous contains the record being deleted + if (ctx.previous?.type === 'Strategic') { + throw new Error('Cannot delete Strategic accounts'); + } + + // Check for active opportunities + const oppCount = await ctx.api?.object('opportunity').count({ + filter: { + account_id: ctx.input.id, + stage: { $in: ['Prospecting', 'Negotiation'] } + } + }); + + if (oppCount && oppCount > 0) { + throw new Error(`Cannot delete account with ${oppCount} active opportunities`); + } + }, +}; +``` + +### 4. Data Enrichment + +```typescript +const enrichLeadScore: Hook = { + name: 'lead_scoring', + object: 'lead', + events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx) => { + let score = 0; + + // Email domain scoring + if (ctx.input.email?.endsWith('@enterprise.com')) { + score += 50; + } + + // Phone number bonus + if (ctx.input.phone) { + score += 20; + } + + // Company size scoring + if (ctx.input.company_size === 'Enterprise') { + score += 30; + } + + // Industry scoring + if (ctx.input.industry === 'Technology') { + score += 25; + } + + ctx.input.score = score; + }, +}; +``` + +### 5. Triggering Workflows + +```typescript +const notifyOnStatusChange: Hook = { + name: 'notify_status_change', + object: 'opportunity', + events: ['afterUpdate'], + async: true, // Fire-and-forget + handler: async (ctx) => { + // Detect status change + const oldStatus = ctx.previous?.stage; + const newStatus = ctx.input.stage; + + if (oldStatus !== newStatus) { + // Send notification (async, doesn't block transaction) + console.log(`Opportunity ${ctx.input.id} moved from ${oldStatus} to ${newStatus}`); + + // Could trigger email, Slack notification, etc. + // await sendEmail({ + // to: ctx.user?.email, + // subject: `Opportunity stage changed to ${newStatus}`, + // body: `...` + // }); + } + }, +}; +``` + +### 6. Creating Related Records + +```typescript +const createAuditTrail: Hook = { + name: 'audit_trail', + object: ['account', 'contact', 'opportunity'], + events: ['afterInsert', 'afterUpdate', 'afterDelete'], + async: false, // Must run in transaction + handler: async (ctx) => { + const action = ctx.event.replace('after', '').toLowerCase(); + + await ctx.api?.object('audit_log').insert({ + object_type: ctx.object, + record_id: String(ctx.input.id || ''), + action, + user_id: ctx.session?.userId, + timestamp: new Date().toISOString(), + changes: ctx.event === 'afterUpdate' ? { + before: ctx.previous, + after: ctx.result, + } : undefined, + }); + }, +}; +``` + +### 7. External API Integration + +```typescript +const syncToExternalCRM: Hook = { + name: 'sync_external_crm', + object: 'account', + events: ['afterInsert', 'afterUpdate'], + async: true, // Don't block the main transaction + timeout: 10000, // 10 second timeout + retryPolicy: { + maxRetries: 3, + backoffMs: 2000, + }, + handler: async (ctx) => { + try { + // Call external API + // await fetch('https://external-crm.com/api/accounts', { + // method: 'POST', + // headers: { 'Authorization': 'Bearer ...' }, + // body: JSON.stringify(ctx.result), + // }); + + console.log(`Synced account ${ctx.input.id} to external CRM`); + } catch (error) { + // Error is logged but doesn't abort the operation + console.error('Failed to sync to external CRM', error); + } + }, +}; +``` + +### 8. Multi-Object Logic + +```typescript +const cascadeAccountUpdate: Hook = { + name: 'cascade_account_updates', + object: 'account', + events: ['afterUpdate'], + handler: async (ctx) => { + // If account industry changed, update all contacts + if (ctx.input.industry && ctx.previous?.industry !== ctx.input.industry) { + await ctx.api?.object('contact').updateMany({ + filter: { account_id: ctx.input.id }, + data: { account_industry: ctx.input.industry }, + }); + } + }, +}; +``` + +### 9. Conditional Execution + +```typescript +const highValueAccountAlert: Hook = { + name: 'high_value_alert', + object: 'account', + events: ['afterInsert'], + // Only run for high-value accounts + condition: "annual_revenue > 10000000", + async: true, + handler: async (ctx) => { + console.log(`🚨 High-value account created: ${ctx.result.name}`); + // Send alert to sales leadership + }, +}; +``` + +### 10. Data Masking (Read Operations) + +```typescript +const maskSensitiveData: Hook = { + name: 'mask_pii', + object: ['contact', 'lead'], + events: ['afterFind', 'afterFindOne'], + handler: async (ctx) => { + // Check user role + const isAdmin = ctx.session?.roles?.includes('admin'); + + if (!isAdmin) { + // Mask sensitive fields + const maskField = (record: any) => { + if (record.ssn) { + record.ssn = '***-**-' + record.ssn.slice(-4); + } + if (record.credit_card) { + record.credit_card = '**** **** **** ' + record.credit_card.slice(-4); + } + }; + + if (Array.isArray(ctx.result?.records)) { + ctx.result.records.forEach(maskField); + } else if (ctx.result) { + maskField(ctx.result); + } + } + }, +}; +``` + +--- + +## Registration Methods + +### Method 1: Declarative (Stack Definition) + +**Best for:** Application-level hooks defined in metadata. + +```typescript +// objectstack.config.ts +import { defineStack } from '@objectstack/spec'; +import taskHook from './objects/task.hook'; + +export default defineStack({ + manifest: { /* ... */ }, + objects: [/* ... */], + hooks: [taskHook], // Register hooks here +}); +``` + +### Method 2: Programmatic (Runtime) + +**Best for:** Plugin-provided hooks, dynamic registration. + +```typescript +// In your plugin's onEnable() +export const onEnable = async (ctx: { ql: ObjectQL }) => { + ctx.ql.registerHook('beforeInsert', async (hookCtx) => { + // Handler logic + }, { + object: 'account', + priority: 100, + }); +}; +``` + +### Method 3: Hook Files (Convention) + +**Best for:** Organized codebases, per-object hooks. + +```typescript +// src/objects/account.hook.ts +import { Hook, HookContext } from '@objectstack/spec/data'; + +const accountHook: Hook = { + name: 'account_logic', + object: 'account', + events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx: HookContext) => { + // Validation logic + }, +}; + +export default accountHook; + +// Then import and register in objectstack.config.ts +``` + +--- + +## Best Practices + +### ✅ DO + +1. **Use specific events** — Don't subscribe to all events if you only need one. +2. **Keep handlers focused** — One hook = one responsibility. +3. **Use `condition` for filtering** — Avoid unnecessary handler execution. +4. **Set appropriate priorities** — Ensure correct execution order. +5. **Use `async: true` for side effects** — Don't block transactions for non-critical operations. +6. **Validate early** — Use `before*` hooks for validation. +7. **Handle errors gracefully** — Provide meaningful error messages. +8. **Use `ctx.api` for cross-object operations** — Maintains transaction consistency. +9. **Document your hooks** — Use `description` and comments. +10. **Test thoroughly** — Unit test hooks in isolation. + +### ❌ DON'T + +1. **Don't mutate immutable properties** — `ctx.object`, `ctx.event`, `ctx.id` are read-only. +2. **Don't perform expensive operations in `before*` hooks** — Use `after*` + `async: true` instead. +3. **Don't create infinite loops** — Be careful when hooks modify data that triggers other hooks. +4. **Don't ignore `ctx.previous`** — Essential for detecting changes. +5. **Don't use `object: '*'` unless necessary** — Performance impact. +6. **Don't block on external APIs** — Use `async: true` and proper timeouts. +7. **Don't assume `ctx.session` exists** — System operations may have no user context. +8. **Don't throw in `after*` hooks unless critical** — Use `onError: 'log'` for non-critical errors. +9. **Don't duplicate validation** — Use declarative validation rules when possible. +10. **Don't forget transaction boundaries** — `async: true` runs outside transaction. + +--- + +## Error Handling + +### Throwing Errors (Abort Operation) + +```typescript +handler: async (ctx) => { + if (!ctx.input.email) { + // Aborts operation, rolls back transaction + throw new Error('Email is required'); + } +} +``` + +### Logging Errors (Continue) + +```typescript +{ + onError: 'log', // Log error, don't abort + handler: async (ctx) => { + try { + await sendEmail(ctx.input.email); + } catch (error) { + // Error is logged, operation continues + console.error('Failed to send email', error); + } + } +} +``` + +### Custom Error Messages + +```typescript +handler: async (ctx) => { + if (ctx.input.annual_revenue < 0) { + throw new Error('Annual revenue cannot be negative'); + } + + if (ctx.input.annual_revenue > 1000000000) { + throw new Error('Annual revenue exceeds maximum allowed value (1B)'); + } +} +``` + +--- + +## Testing Hooks + +### Unit Testing + +```typescript +import { describe, it, expect } from 'vitest'; +import { HookContext } from '@objectstack/spec/data'; +import accountHook from './account.hook'; + +describe('accountHook', () => { + it('sets default industry', async () => { + const ctx: Partial = { + object: 'account', + event: 'beforeInsert', + input: { name: 'Acme Corp' }, + }; + + await accountHook.handler(ctx as HookContext); + + expect(ctx.input.industry).toBe('Other'); + }); + + it('validates website URL', async () => { + const ctx: Partial = { + object: 'account', + event: 'beforeInsert', + input: { website: 'invalid-url' }, + }; + + await expect( + accountHook.handler(ctx as HookContext) + ).rejects.toThrow('Website must start with http'); + }); +}); +``` + +### Integration Testing + +```typescript +import { LiteKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { DriverPlugin } from '@objectstack/runtime'; +import { InMemoryDriver } from '@objectstack/driver-memory'; + +describe('Hook Integration', () => { + it('executes hook on insert', async () => { + const kernel = new LiteKernel(); + kernel.use(new ObjectQLPlugin()); + kernel.use(new DriverPlugin(new InMemoryDriver())); + + // Register hook + const ql = kernel.getService('objectql'); + ql.registerHook('beforeInsert', async (ctx) => { + ctx.input.created_at = '2026-04-13T10:00:00Z'; + }, { object: 'account' }); + + // Test insert + const result = await ql.object('account').insert({ + name: 'Test Account', + }); + + expect(result.created_at).toBe('2026-04-13T10:00:00Z'); + + await kernel.shutdown(); + }); +}); +``` + +--- + +## Performance Considerations + +### Hook Execution Overhead + +``` +Single Record Insert: +┌─────────────────┬──────────────┐ +│ Hook Count │ Overhead │ +├─────────────────┼──────────────┤ +│ 0 hooks │ ~1ms │ +│ 5 hooks │ ~5ms │ +│ 20 hooks │ ~20ms │ +└─────────────────┴──────────────┘ +``` + +### Optimization Tips + +1. **Use `condition` to filter** — Avoid executing handlers unnecessarily. +2. **Use `async: true` for non-critical side effects** — Don't block transactions. +3. **Batch operations in `after*` hooks** — Reduce database round-trips. +4. **Cache expensive lookups** — Use kernel cache service. +5. **Use specific `object` targets** — Avoid `object: '*'`. + +### Anti-Patterns + +```typescript +// ❌ BAD: Expensive synchronous operation +{ + events: ['beforeInsert'], + async: false, + handler: async (ctx) => { + await slowExternalAPI(ctx.input); // Blocks transaction + } +} + +// ✅ GOOD: Async background operation +{ + events: ['afterInsert'], + async: true, // Fire-and-forget + handler: async (ctx) => { + await slowExternalAPI(ctx.result); + } +} +``` + +--- + +## Advanced Topics + +### Dynamic Hook Registration + +```typescript +// Register hooks based on configuration +export const onEnable = async (ctx: { ql: ObjectQL }) => { + const config = await loadConfig(); + + config.objects.forEach(objectName => { + ctx.ql.registerHook('beforeInsert', async (hookCtx) => { + // Dynamic logic + }, { object: objectName }); + }); +}; +``` + +### Hook Composition + +```typescript +// Compose multiple validators +const validators = [ + validateEmail, + validatePhone, + validateWebsite, +]; + +const composedHook: Hook = { + name: 'validation_suite', + object: 'account', + events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx) => { + for (const validator of validators) { + await validator(ctx); + } + }, +}; +``` + +### Conditional Hook Execution + +```typescript +const conditionalHook: Hook = { + name: 'enterprise_only', + object: 'account', + events: ['afterInsert'], + handler: async (ctx) => { + // Check runtime condition + if (process.env.FEATURE_FLAG_ENTERPRISE !== 'true') { + return; // Skip execution + } + + // Enterprise-specific logic + }, +}; +``` + +--- + +## Troubleshooting + +### Common Issues + +**Issue:** Hook not executing + +**Solutions:** +1. Check `object` matches target object name +2. Verify `events` includes the expected event +3. Check `condition` doesn't filter out all records +4. Ensure hook is registered before operations + +**Issue:** Transaction rollback on `after*` hook error + +**Solution:** Set `onError: 'log'` or `async: true` + +**Issue:** Infinite loop (hook triggers itself) + +**Solution:** Use conditional checks, track execution state + +**Issue:** `ctx.api` is undefined + +**Solution:** Ensure ObjectQL engine is initialized with API support + +**Issue:** Performance degradation + +**Solutions:** +1. Use `async: true` for non-critical operations +2. Add `condition` to filter executions +3. Reduce number of global (`object: '*'`) hooks + +--- + +## References + +- [hook.zod.ts](./references/data/hook.zod.ts) — Hook schema definition, HookContext interface +- [Examples: app-todo](../../examples/app-todo/src/objects/task.hook.ts) — Simple task hook +- [Examples: app-crm](../../examples/app-crm/src/objects/) — Advanced CRM hooks + +--- + +## Summary + +Hooks are the **primary extension mechanism** in ObjectStack. They enable you to: + +- ✅ Add custom validation and business rules +- ✅ Enrich data with calculated fields +- ✅ Trigger side effects and integrations +- ✅ Enforce security and compliance +- ✅ Implement audit trails +- ✅ Transform data in/out + +**Golden Rules:** + +1. Use `before*` for validation, `after*` for side effects +2. Set `async: true` for non-critical background work +3. Use `ctx.api` for cross-object operations +4. Handle errors gracefully with meaningful messages +5. Test hooks in isolation and integration + +For more advanced patterns, see the **objectstack-automation** skill for Flows and Workflows. diff --git a/skills/objectstack-data/rules/indexing.md b/skills/objectstack-data/rules/indexing.md new file mode 100644 index 000000000..a439c06c9 --- /dev/null +++ b/skills/objectstack-data/rules/indexing.md @@ -0,0 +1,382 @@ +# Index Strategy + +Guide for creating efficient database indexes in ObjectStack. + +## Default Behavior + +ObjectStack automatically creates indexes for: +- Primary keys (`id`) +- Foreign keys (lookup/master_detail fields) +- Unique constraints + +**Only declare non-default values.** `type` defaults to `'btree'` and `unique` defaults to `false` — omit them when using defaults. + +## Index Types + +| Type | Default? | When to Use | Performance | +|:-----|:---------|:------------|:------------| +| `btree` | ✅ Yes | Equality and range queries (`=`, `<`, `>`, `BETWEEN`) | Excellent | +| `hash` | No | Exact equality only (`=`) — rare use case | Fast for `=`, poor for ranges | +| `fulltext` | No | Text search columns (descriptions, notes) | Text search only | +| `gin` | No | Array / JSONB containment, full-text search | JSONB, arrays, tags | +| `gist` | No | Geospatial / range types | Location, geometry | + +## Syntax + +```typescript +indexes: [ + { fields: ['status', 'created_at'] }, // btree (default) + { fields: ['email'], unique: true }, // btree + unique + { fields: ['description'], type: 'fulltext' }, // non-default type + { fields: ['tags'], type: 'gin' }, // non-default type + { fields: ['location'], type: 'gist' }, // non-default type +] +``` + +## When to Add Indexes + +### ✅ Always Index + +1. **Foreign keys** — Automatic, but verify +2. **Filter fields** — Columns used in WHERE clauses +3. **Sort fields** — Columns used in ORDER BY +4. **Unique constraints** — Enforce uniqueness at DB level +5. **Composite filters** — Fields commonly filtered together + +### ⚠️ Consider Indexing + +1. **Join columns** — Non-foreign-key join fields +2. **Frequent aggregations** — GROUP BY columns +3. **Range queries** — Date ranges, numeric ranges +4. **Partial data** — Use partial indexes for subset queries + +### ❌ Avoid Indexing + +1. **Low cardinality** — Boolean fields (unless combined with others) +2. **Rarely queried** — Fields almost never filtered/sorted +3. **High write volume** — Every insert/update maintains indexes +4. **Large text** — Full-text index only when needed +5. **Calculated fields** — Index source fields instead + +## Examples + +### Composite Index (Multi-Column) + +```typescript +indexes: [ + // Most specific first (status), then sort key + { fields: ['status', 'created_at'] }, + + // Can satisfy queries like: + // - WHERE status = 'active' + // - WHERE status = 'active' ORDER BY created_at DESC + // - WHERE status = 'active' AND created_at > '2026-01-01' +] +``` + +### Unique Index + +```typescript +indexes: [ + // Single column uniqueness + { fields: ['email'], unique: true }, + + // Composite uniqueness + { fields: ['tenant_id', 'username'], unique: true }, +] +``` + +### Partial Index + +```typescript +indexes: [ + // Only index active records + { + fields: ['created_at'], + partial: "status = 'active'", + }, + + // Only index non-deleted records + { + fields: ['email'], + unique: true, + partial: "deleted_at IS NULL", + }, +] +``` + +### Full-Text Index + +```typescript +indexes: [ + { + fields: ['description', 'notes'], + type: 'fulltext', + }, +] +``` + +### GIN Index (JSONB/Array) + +```typescript +indexes: [ + // JSONB field + { + fields: ['metadata'], + type: 'gin', + }, + + // Array field + { + fields: ['tags'], + type: 'gin', + }, +] +``` + +### Geospatial Index (GIST) + +```typescript +indexes: [ + { + fields: ['location'], + type: 'gist', + }, +] +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Redundant Default Values + +```typescript +indexes: [ + { fields: ['status'], type: 'btree', unique: false }, // ❌ Redundant defaults + { fields: ['email'], type: 'btree', unique: true }, // ❌ Redundant type +] +``` + +### ✅ Correct — Omit Defaults + +```typescript +indexes: [ + { fields: ['status'] }, // ✅ btree and unique: false are defaults + { fields: ['email'], unique: true }, // ✅ btree is default, only specify unique +] +``` + +### ❌ Incorrect — Over-Indexing + +```typescript +indexes: [ + { fields: ['is_active'] }, // ❌ Boolean, low cardinality + { fields: ['is_deleted'] }, // ❌ Boolean, low cardinality + { fields: ['is_verified'] }, // ❌ Boolean, low cardinality + { fields: ['status'] }, // ❌ Already indexed elsewhere + { fields: ['created_at'] }, // ❌ Already indexed elsewhere +] +``` + +### ✅ Correct — Strategic Indexing + +```typescript +indexes: [ + // Composite for common query pattern + { fields: ['is_active', 'created_at'] }, + + // Single index covers multiple queries + { fields: ['status', 'priority'] }, +] +``` + +### ❌ Incorrect — Wrong Order in Composite + +```typescript +indexes: [ + // Querying by created_at with status filter + { fields: ['created_at', 'status'] }, // ❌ Wrong order +] +``` + +### ✅ Correct — Most Selective First + +```typescript +indexes: [ + // Status is more selective (filters more), goes first + { fields: ['status', 'created_at'] }, // ✅ Correct order +] +``` + +## Composite Index Strategy + +### Order Matters + +```typescript +// Index: ['status', 'priority', 'created_at'] + +// ✅ Can use index +WHERE status = 'active' +WHERE status = 'active' AND priority = 'high' +WHERE status = 'active' AND priority = 'high' ORDER BY created_at + +// ❌ Cannot use index efficiently +WHERE priority = 'high' // Skips first column +WHERE created_at > '2026-01-01' // Skips first two columns +``` + +### Left-to-Right Rule + +Composite indexes are used **left-to-right**. Querying only the second or third column doesn't use the index. + +### Selectivity Rule + +Place most **selective** (unique) fields first, then range/sort fields last. + +```typescript +// Good order: selective → range +{ fields: ['tenant_id', 'status', 'created_at'] } + +// Bad order: range → selective +{ fields: ['created_at', 'status', 'tenant_id'] } +``` + +## Partial Indexes + +Use partial indexes to index only a subset of rows: + +```typescript +// Only index active records (common query) +{ + fields: ['created_at'], + partial: "status = 'active'", +} + +// Only index high-value accounts +{ + fields: ['annual_revenue'], + partial: "annual_revenue > 1000000", +} + +// Only index non-deleted records +{ + fields: ['email'], + unique: true, + partial: "deleted_at IS NULL", +} +``` + +**Benefits:** +- Smaller index size +- Faster writes (fewer rows to maintain) +- Faster queries (focused data subset) + +## Performance Trade-offs + +### Index Benefits +- ✅ Faster SELECT queries +- ✅ Faster ORDER BY operations +- ✅ Faster JOIN operations +- ✅ Enforce uniqueness at DB level + +### Index Costs +- ❌ Slower INSERT/UPDATE/DELETE (index maintenance) +- ❌ Increased storage (each index duplicates data) +- ❌ Query planner overhead (more indexes = more choices) + +### General Guidelines + +| Table Size | Max Indexes | Reasoning | +|:-----------|:------------|:----------| +| < 1K rows | 2-3 | Low volume, indexes may not help | +| 1K - 100K rows | 3-5 | Balance read/write performance | +| 100K - 1M rows | 5-8 | Read optimization critical | +| > 1M rows | 8-12 | Consider partitioning + indexes | + +## Index Naming Convention + +ObjectStack auto-generates index names. To specify custom names: + +```typescript +{ + name: 'idx_account_status_created', // Custom name + fields: ['status', 'created_at'], +} +``` + +**Auto-generated pattern:** `idx_{object}_{field1}_{field2}_{...}` + +## Monitoring Index Usage + +Use database tools to monitor index usage: + +```sql +-- PostgreSQL: Find unused indexes +SELECT + schemaname, tablename, indexname, idx_scan +FROM pg_stat_user_indexes +WHERE idx_scan = 0 +ORDER BY schemaname, tablename; + +-- MySQL: Check index cardinality +SHOW INDEX FROM your_table; +``` + +## Best Practices + +1. **Index foreign keys** — Always (automatic in ObjectStack) +2. **Composite for common queries** — Combine frequently filtered columns +3. **Order matters** — Most selective field first +4. **Partial for subsets** — Index only relevant rows +5. **Unique for constraints** — Enforce at DB level +6. **Monitor usage** — Remove unused indexes +7. **Limit total indexes** — Balance read/write performance +8. **Avoid over-indexing** — More indexes ≠ better performance +9. **Test with production data** — Index effectiveness depends on data volume +10. **Use EXPLAIN** — Verify query plans before deploying indexes + +## Common Query Patterns + +### Filter by Status + Sort by Date + +```typescript +// Query: WHERE status = 'active' ORDER BY created_at DESC LIMIT 50 +indexes: [ + { fields: ['status', 'created_at'] }, +] +``` + +### Multi-Tenant Queries + +```typescript +// Query: WHERE tenant_id = X AND ... +indexes: [ + { fields: ['tenant_id', 'status', 'created_at'] }, +] +``` + +### Text Search + +```typescript +// Query: WHERE description ILIKE '%keyword%' +indexes: [ + { fields: ['description'], type: 'fulltext' }, +] +``` + +### Array/JSONB Containment + +```typescript +// Query: WHERE tags @> ['urgent'] +indexes: [ + { fields: ['tags'], type: 'gin' }, +] +``` + +### Location-Based Queries + +```typescript +// Query: WHERE ST_DWithin(location, point, distance) +indexes: [ + { fields: ['location'], type: 'gist' }, +] +``` diff --git a/skills/objectstack-data/rules/naming.md b/skills/objectstack-data/rules/naming.md new file mode 100644 index 000000000..76ddad3b9 --- /dev/null +++ b/skills/objectstack-data/rules/naming.md @@ -0,0 +1,105 @@ +# Naming Conventions + +ObjectStack enforces strict naming conventions to ensure consistency and machine readability. + +## Rules + +| Context | Convention | Pattern | Example | +|:--------|:-----------|:--------|:--------| +| Object `name` | `snake_case` | `/^[a-z_][a-z0-9_]*$/` | `project_task` | +| Field keys | `snake_case` | `/^[a-z_][a-z0-9_]*$/` | `first_name`, `due_date` | +| Schema property keys (TS config) | `camelCase` | Standard JS | `maxLength`, `referenceFilters` | +| Option `value` | lowercase machine ID | lowercase | `in_progress` | +| Option `label` | Any case | — | `"In Progress"` | + +## Incorrect vs Correct + +### ❌ Incorrect — Object Name + +```typescript +export default ObjectSchema.create({ + name: 'ProjectTask', // ❌ PascalCase not allowed + fields: { /* ... */ } +}); +``` + +### ✅ Correct — Object Name + +```typescript +export default ObjectSchema.create({ + name: 'project_task', // ✅ snake_case + fields: { /* ... */ } +}); +``` + +### ❌ Incorrect — Field Keys + +```typescript +fields: { + firstName: { type: 'text' }, // ❌ camelCase not allowed + 'Due-Date': { type: 'datetime' }, // ❌ kebab-case not allowed + Status: { type: 'select' }, // ❌ PascalCase not allowed +} +``` + +### ✅ Correct — Field Keys + +```typescript +fields: { + first_name: { type: 'text' }, // ✅ snake_case + due_date: { type: 'datetime' }, // ✅ snake_case + status: { type: 'select' }, // ✅ snake_case +} +``` + +### ❌ Incorrect — Schema Properties + +```typescript +{ + type: 'text', + max_length: 255, // ❌ snake_case not allowed for TS config + reference_filters: {}, // ❌ snake_case not allowed for TS config +} +``` + +### ✅ Correct — Schema Properties + +```typescript +{ + type: 'text', + maxLength: 255, // ✅ camelCase for TS config + referenceFilters: {}, // ✅ camelCase for TS config +} +``` + +### ❌ Incorrect — Select Option Values + +```typescript +options: [ + { label: 'In Progress', value: 'In Progress' }, // ❌ space/caps in value + { label: 'Done', value: 'Done' }, // ❌ uppercase in value +] +``` + +### ✅ Correct — Select Option Values + +```typescript +options: [ + { label: 'In Progress', value: 'in_progress' }, // ✅ lowercase, snake_case + { label: 'Done', value: 'done' }, // ✅ lowercase +] +``` + +## Critical Rules + +1. **Never** use `camelCase` or `PascalCase` for object names or field keys +2. **Always** use `camelCase` for TypeScript configuration property keys +3. **Option values** must be lowercase machine identifiers (use snake_case for multi-word) +4. **Option labels** can use any case for display purposes +5. **Machine names are immutable** — changing them requires data migration + +## Rationale + +- **snake_case for data**: Database-friendly, SQL-compatible, cross-platform consistency +- **camelCase for config**: TypeScript/JavaScript convention for object properties +- **Lowercase option values**: Case-sensitive database comparisons, URL-safe, API-friendly diff --git a/skills/objectstack-data/rules/relationships.md b/skills/objectstack-data/rules/relationships.md new file mode 100644 index 000000000..5a16932d0 --- /dev/null +++ b/skills/objectstack-data/rules/relationships.md @@ -0,0 +1,258 @@ +# Relationship Patterns + +Guide for modeling relationships between objects using `lookup`, `master_detail`, and junction patterns. + +## Relationship Types + +| Type | Lifecycle | Required | Sharing | Roll-ups | Use Case | +|:-----|:----------|:---------|:--------|:---------|:---------| +| `lookup` | Independent | Optional by default | Independent | Not available | "Related to" | +| `master_detail` | Coupled (cascade delete) | Always required | Inherits parent | Supported via `summary` | "Owned by" | +| `tree` | Self-reference | Optional | N/A | Not available | Hierarchical | + +## When to Use lookup vs master_detail + +### Use `lookup` When: +- Child record can exist independently +- Parent deletion should not affect child +- No roll-up aggregations needed +- Relationship is optional +- **Example:** `task.assigned_to → user` (task can exist without assignment) + +### Use `master_detail` When: +- Child record is meaningless without parent +- Parent deletion should cascade to children +- Need roll-up summaries (count, sum, min, max, avg) +- Relationship is mandatory +- **Example:** `invoice_line_item.invoice_id → invoice` (line items belong to invoice) + +## Patterns + +### One-to-Many: lookup + +```typescript +// Parent: Account +export default ObjectSchema.create({ + name: 'account', + fields: { + name: { type: 'text', required: true }, + } +}); + +// Child: Contact (independent lifecycle) +export default ObjectSchema.create({ + name: 'contact', + fields: { + first_name: { type: 'text', required: true }, + account_id: { + type: 'lookup', + reference: 'account', + required: false, // Contact can exist without account + }, + } +}); +``` + +### One-to-Many: master_detail + +```typescript +// Parent: Invoice +export default ObjectSchema.create({ + name: 'invoice', + fields: { + invoice_number: { type: 'text', required: true }, + total: { + type: 'summary', + reference: 'invoice_line_item', + summaryType: 'sum', + summaryField: 'amount', + }, + } +}); + +// Child: Line Item (owned by parent) +export default ObjectSchema.create({ + name: 'invoice_line_item', + fields: { + invoice_id: { + type: 'master_detail', + reference: 'invoice', + deleteBehavior: 'cascade', // Delete line items when invoice deleted + required: true, + }, + product: { type: 'text', required: true }, + amount: { type: 'currency', required: true }, + } +}); +``` + +### Many-to-Many: Junction Object + +```typescript +// Side A: Project +export default ObjectSchema.create({ + name: 'project', + fields: { + name: { type: 'text', required: true }, + } +}); + +// Side B: Employee +export default ObjectSchema.create({ + name: 'employee', + fields: { + name: { type: 'text', required: true }, + } +}); + +// Junction: Project Assignment +export default ObjectSchema.create({ + name: 'project_assignment', + fields: { + project_id: { + type: 'lookup', + reference: 'project', + required: true, + }, + employee_id: { + type: 'lookup', + reference: 'employee', + required: true, + }, + role: { type: 'text' }, + hours_allocated: { type: 'number' }, + }, + validations: [ + { + name: 'unique_assignment', + type: 'unique', + fields: ['project_id', 'employee_id'], + message: 'Employee already assigned to this project', + }, + ], +}); +``` + +### Hierarchical: tree (Self-Reference) + +```typescript +export default ObjectSchema.create({ + name: 'category', + fields: { + name: { type: 'text', required: true }, + parent_category: { + type: 'tree', + reference: 'category', // Self-reference + required: false, + }, + } +}); +``` + +## Delete Behaviors + +Configure `deleteBehavior` on `master_detail` relationships: + +| Behavior | Effect | Use Case | +|:---------|:-------|:---------| +| `cascade` | Delete all child records | Invoice → Line Items | +| `restrict` | Prevent parent deletion if children exist | Department → Employees | +| `set_null` | Set child reference to null | Manager → Employees (manager leaves) | + +```typescript +{ + type: 'master_detail', + reference: 'parent_object', + deleteBehavior: 'cascade', // or 'restrict' or 'set_null' +} +``` + +## Roll-up Summaries + +Available only on `master_detail` relationships: + +```typescript +// On parent object +{ + type: 'summary', + reference: 'child_object', // Name of child object + summaryType: 'count', // 'count' | 'sum' | 'min' | 'max' | 'avg' + summaryField: 'amount', // Field to aggregate (not needed for count) + referenceFilters: { // Optional: filter which children to include + status: 'active', + }, +} +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Using lookup When master_detail is Needed + +```typescript +// Invoice line items should NOT be independent +export default ObjectSchema.create({ + name: 'invoice_line_item', + fields: { + invoice_id: { + type: 'lookup', // ❌ Child can exist without parent — wrong! + reference: 'invoice', + }, + } +}); +``` + +### ✅ Correct — Using master_detail for Owned Children + +```typescript +export default ObjectSchema.create({ + name: 'invoice_line_item', + fields: { + invoice_id: { + type: 'master_detail', // ✅ Child owned by parent + reference: 'invoice', + deleteBehavior: 'cascade', + required: true, + }, + } +}); +``` + +### ❌ Incorrect — Native Many-to-Many + +```typescript +// ObjectStack does not have native many-to-many type +{ + type: 'many_to_many', // ❌ Not a valid field type + reference: 'tag', +} +``` + +### ✅ Correct — Junction Object Pattern + +```typescript +// Create explicit junction object with two lookup fields +export default ObjectSchema.create({ + name: 'post_tag', + fields: { + post_id: { type: 'lookup', reference: 'post', required: true }, + tag_id: { type: 'lookup', reference: 'tag', required: true }, + }, +}); +``` + +## Best Practices + +1. **Use lookup by default** — Only use master_detail when lifecycle coupling is required +2. **Unique constraints on junctions** — Prevent duplicate many-to-many entries +3. **Meaningful junction names** — Use descriptive names like `project_assignment` not `project_employee` +4. **deleteBehavior on master_detail** — Always specify cascade/restrict/set_null +5. **Required on master_detail** — Child should always require parent +6. **Roll-ups for aggregation** — Use summary fields on parent for counts/sums +7. **referenceFilters for scoping** — Limit lookup options to relevant records + +## Performance Considerations + +- **Index foreign keys** — Always create indexes on lookup/master_detail fields +- **Avoid deep hierarchies** — tree relationships > 5 levels can impact query performance +- **Junction table indexes** — Composite index on both foreign keys in junction tables +- **Summary field caching** — Roll-up summaries are cached and updated on child changes diff --git a/skills/objectstack-data/rules/validation.md b/skills/objectstack-data/rules/validation.md new file mode 100644 index 000000000..8fd38cfce --- /dev/null +++ b/skills/objectstack-data/rules/validation.md @@ -0,0 +1,382 @@ +# Validation Rules + +Comprehensive guide for implementing validation rules in ObjectStack. + +## Available Rule Types + +| Type | Purpose | When Validation Fails | +|:-----|:--------|:---------------------| +| `script` | Formula expression | When expression evaluates to `true` | +| `unique` | Composite uniqueness | When duplicate found | +| `state_machine` | Legal state transitions | When transition not allowed | +| `format` | Regex or built-in format | When format doesn't match | +| `cross_field` | Compare values across fields | When comparison fails | +| `json_schema` | Validate JSON field | When JSON doesn't match schema | +| `async` | External API validation | When API returns error | +| `custom` | Registered validator function | When function returns false | +| `conditional` | Apply rule conditionally | When nested rule fails | + +## Script Validation + +**⚠️ CRITICAL:** Script condition is **inverted** — validation **fails** when expression is `true`. + +```typescript +validations: [ + { + name: 'prevent_past_dates', + type: 'script', + condition: 'due_date < TODAY()', // ❌ Fails when this is TRUE + message: 'Due date cannot be in the past', + severity: 'error', + events: ['insert', 'update'], + }, +] +``` + +### Common Script Patterns + +```typescript +// Prevent negative values +condition: 'amount < 0' + +// Require field when another field has value +condition: 'status = "approved" AND approver_id IS NULL' + +// Date range validation +condition: 'end_date < start_date' + +// Conditional required field +condition: 'type = "enterprise" AND account_manager IS NULL' +``` + +## Unique Validation + +```typescript +validations: [ + { + name: 'unique_email', + type: 'unique', + fields: ['email'], + caseSensitive: false, + message: 'Email address already exists', + }, + { + name: 'unique_tenant_email', + type: 'unique', + fields: ['tenant_id', 'email'], // Composite uniqueness + caseSensitive: false, + message: 'Email already exists in this tenant', + }, +] +``` + +## State Machine Validation + +```typescript +validations: [ + { + name: 'status_flow', + type: 'state_machine', + field: 'status', + transitions: { + draft: ['submitted', 'cancelled'], + submitted: ['in_review', 'cancelled'], + in_review: ['approved', 'rejected'], + approved: ['published'], + rejected: ['draft'], + published: [], // Terminal state + cancelled: [], // Terminal state + }, + message: 'Invalid status transition', + severity: 'error', + }, +] +``` + +## Format Validation + +```typescript +validations: [ + // Built-in formats + { + name: 'email_format', + type: 'format', + field: 'email', + format: 'email', // Built-in: email, url, phone, json, uuid + message: 'Invalid email format', + }, + + // Custom regex + { + name: 'sku_format', + type: 'format', + field: 'sku', + pattern: '^[A-Z]{3}-\\d{4}$', // e.g., ABC-1234 + message: 'SKU must be format: XXX-0000', + }, +] +``` + +## Cross-Field Validation + +```typescript +validations: [ + { + name: 'date_range', + type: 'cross_field', + condition: 'end_date > start_date', + message: 'End date must be after start date', + fields: ['start_date', 'end_date'], + }, + { + name: 'discount_limit', + type: 'cross_field', + condition: 'discount_amount <= subtotal * 0.5', + message: 'Discount cannot exceed 50% of subtotal', + fields: ['discount_amount', 'subtotal'], + }, +] +``` + +## JSON Schema Validation + +```typescript +validations: [ + { + name: 'config_schema', + type: 'json_schema', + field: 'config', + schema: { + type: 'object', + properties: { + timeout: { type: 'number', minimum: 0 }, + retries: { type: 'integer', minimum: 1, maximum: 5 }, + enabled: { type: 'boolean' }, + }, + required: ['timeout', 'enabled'], + additionalProperties: false, + }, + message: 'Invalid configuration format', + }, +] +``` + +## Async Validation + +```typescript +validations: [ + { + name: 'external_api_check', + type: 'async', + field: 'tax_id', + endpoint: 'https://api.example.com/validate/tax-id', + method: 'POST', + timeout: 5000, + debounce: 500, // Delay validation by 500ms + message: 'Invalid tax ID', + }, +] +``` + +## Conditional Validation + +```typescript +validations: [ + { + name: 'enterprise_requires_manager', + type: 'conditional', + condition: "type = 'enterprise'", + validations: [ + { + name: 'manager_required', + type: 'script', + condition: 'account_manager IS NULL', + message: 'Enterprise accounts must have an account manager', + }, + ], + }, +] +``` + +## Validation Properties + +### Severity Levels + +```typescript +severity: 'error' // Blocks save (default) +severity: 'warning' // Allows save, shows warning +severity: 'info' // Informational only +``` + +### Events + +```typescript +events: ['insert'] // Only on create +events: ['update'] // Only on update +events: ['insert', 'update'] // On create and update (default) +events: ['delete'] // Only on delete +``` + +### Priority + +```typescript +priority: 0 // System validations (run first) +priority: 100 // Application validations (default) +priority: 1000 // User validations (run last) +``` + +Lower numbers execute **first**. + +## Incorrect vs Correct + +### ❌ Incorrect — Script Logic Inverted + +```typescript +{ + type: 'script', + condition: 'amount > 0', // ❌ Fails when amount > 0 (inverted!) + message: 'Amount must be positive', +} +``` + +### ✅ Correct — Script Logic + +```typescript +{ + type: 'script', + condition: 'amount <= 0', // ✅ Fails when amount <= 0 + message: 'Amount must be positive', +} +``` + +### ❌ Incorrect — Missing Severity + +```typescript +{ + type: 'script', + condition: 'end_date < start_date', + message: 'End date must be after start date', + // ❌ No severity — defaults to 'error' which may be too strict +} +``` + +### ✅ Correct — Explicit Severity + +```typescript +{ + type: 'script', + condition: 'end_date < start_date', + message: 'End date must be after start date', + severity: 'warning', // ✅ Allow save but warn user +} +``` + +### ❌ Incorrect — Validation Fires Too Often + +```typescript +{ + type: 'script', + condition: 'status = "draft"', + message: 'Record is still in draft', + // ❌ No events — runs on all operations +} +``` + +### ✅ Correct — Validation Scoped to Events + +```typescript +{ + type: 'script', + condition: 'status = "draft"', + message: 'Cannot publish draft records', + events: ['update'], // ✅ Only validate on update +} +``` + +## Common Patterns + +### Prevent Backdating + +```typescript +{ + name: 'no_backdate', + type: 'script', + condition: 'created_at < TODAY()', + message: 'Cannot create records with past dates', + events: ['insert'], +} +``` + +### Require Approval for High Values + +```typescript +{ + name: 'high_value_approval', + type: 'conditional', + condition: 'amount > 10000', + validations: [ + { + type: 'script', + condition: 'approved_by IS NULL', + message: 'High-value transactions require approval', + }, + ], +} +``` + +### Email Domain Whitelist + +```typescript +{ + name: 'email_domain', + type: 'format', + field: 'email', + pattern: '^[a-zA-Z0-9._%+-]+@(company\\.com|partner\\.com)$', + message: 'Email must be from company.com or partner.com', +} +``` + +### Phone Number Format + +```typescript +{ + name: 'phone_format', + type: 'format', + field: 'phone', + pattern: '^\\+?[1-9]\\d{1,14}$', // E.164 format + message: 'Phone must be in international format (+1234567890)', +} +``` + +### Composite Unique (Tenant + Email) + +```typescript +{ + name: 'tenant_email_unique', + type: 'unique', + fields: ['tenant_id', 'email'], + caseSensitive: false, + message: 'Email already exists in this tenant', +} +``` + +## Best Practices + +1. **Use declarative validation first** — Only use script validation when declarative rules don't fit +2. **Severity matters** — Use `warning` for soft rules, `error` for hard rules +3. **Events scope** — Only validate on relevant operations to avoid overhead +4. **Priority order** — System validations first (0-99), app validations second (100-999), user validations last (1000+) +5. **Clear error messages** — Tell users exactly what's wrong and how to fix it +6. **Async validation debounce** — Use debounce to reduce API calls on fast typing +7. **State machine for workflows** — Use state_machine instead of complex script logic +8. **Unique constraints** — Always use unique validation, not script-based checks +9. **Cross-field for comparisons** — More efficient than script validation +10. **Test thoroughly** — Validate edge cases, nulls, empty strings + +## Performance Considerations + +- **Script validations are expensive** — Use sparingly, prefer declarative rules +- **Async validations add latency** — Use debounce and appropriate timeouts +- **Priority affects order** — Lower priority = runs first +- **Unique checks hit database** — Index the unique fields for performance +- **State machine is optimized** — Better than complex conditional logic diff --git a/skills/objectstack-hooks/SKILL.md b/skills/objectstack-hooks/SKILL.md index bb503e1d6..443b41afa 100644 --- a/skills/objectstack-hooks/SKILL.md +++ b/skills/objectstack-hooks/SKILL.md @@ -1,10 +1,9 @@ --- name: objectstack-hooks description: > - Write ObjectStack data lifecycle hooks for third-party plugins and applications. - Use when implementing business logic, validation, side effects, or data transformations - during CRUD operations. Covers hook registration, handler patterns, context API, - error handling, async execution, and integration with the ObjectQL engine. + ⚠️ DEPRECATED: This skill has been integrated into objectstack-data. + Please use the objectstack-data skill and refer to rules/hooks.md for + data lifecycle hook documentation. license: Apache-2.0 compatibility: Requires @objectstack/spec v4+, @objectstack/objectql v4+ metadata: @@ -12,1027 +11,57 @@ metadata: version: "1.0" domain: hooks tags: hooks, lifecycle, validation, business-logic, side-effects, data-enrichment + deprecated: true + replacement: objectstack-data --- -# Writing Hooks — ObjectStack Data Lifecycle +# ⚠️ DEPRECATED: Hooks Skill Migrated -Expert instructions for third-party developers to write data lifecycle hooks in ObjectStack. -Hooks are the primary extension point for adding custom business logic, validation rules, -side effects, and data transformations to CRUD operations. +This skill has been **deprecated** and integrated into the **objectstack-data** skill. ---- - -## When to Use This Skill - -- You need to **add custom validation** beyond declarative rules. -- You want to **enrich data** (set defaults, calculate fields, normalize values). -- You need to trigger **side effects** (send emails, update external systems, publish events). -- You want to **enforce business rules** that span multiple fields or objects. -- You need to **transform data** before or after database operations. -- You want to **integrate with external APIs** during data operations. -- You need to **implement audit trails** or compliance requirements. - ---- - -## Core Concepts - -### What Are Hooks? - -Hooks are **event handlers** that execute during the ObjectQL data access lifecycle. -They intercept operations at specific points (before/after) and can: - -- **Read** the operation context (user, session, input data) -- **Modify** input parameters or operation results -- **Validate** data and throw errors to abort operations -- **Trigger** side effects (notifications, integrations, logging) - -### Hook Lifecycle Events - -ObjectStack provides **14 lifecycle events** organized by operation type: - -| Event | When It Fires | Use Cases | -|:------|:--------------|:----------| -| **Read Operations** | | | -| `beforeFind` | Before querying multiple records | Filter queries by user context, log access | -| `afterFind` | After querying multiple records | Transform results, mask sensitive data | -| `beforeFindOne` | Before fetching a single record | Validate permissions, log access | -| `afterFindOne` | After fetching a single record | Enrich data, mask fields | -| `beforeCount` | Before counting records | Filter by context | -| `afterCount` | After counting records | Log metrics | -| `beforeAggregate` | Before aggregate operations | Validate aggregation rules | -| `afterAggregate` | After aggregate operations | Transform results | -| **Write Operations** | | | -| `beforeInsert` | Before creating a record | Set defaults, validate, normalize | -| `afterInsert` | After creating a record | Send notifications, create related records | -| `beforeUpdate` | Before updating a record | Validate changes, check permissions | -| `afterUpdate` | After updating a record | Trigger workflows, sync external systems | -| `beforeDelete` | Before deleting a record | Check dependencies, prevent deletion | -| `afterDelete` | After deleting a record | Clean up related data, notify users | - -### Before vs After Hooks - -| Aspect | `before*` Hooks | `after*` Hooks | -|:-------|:----------------|:---------------| -| **Purpose** | Validation, enrichment, transformation | Side effects, notifications, logging | -| **Can modify** | `ctx.input` (mutable) | `ctx.result` (mutable) | -| **Can abort** | Yes (throw error → rollback) | No (operation already committed) | -| **Transaction** | Within transaction | After transaction (unless async: false) | -| **Error handling** | Aborts operation by default | Logged by default (configurable) | - ---- - -## Hook Definition Schema - -Every hook must conform to the `HookSchema`: - -```typescript -import { Hook, HookContext } from '@objectstack/spec/data'; - -const myHook: Hook = { - // Required: Unique identifier (snake_case) - name: 'my_validation_hook', - - // Required: Target object(s) - object: 'account', // string | string[] | '*' - - // Required: Events to subscribe to - events: ['beforeInsert', 'beforeUpdate'], - - // Required: Handler function (inline or string reference) - handler: async (ctx: HookContext) => { - // Your logic here - }, - - // Optional: Execution priority (lower runs first) - priority: 100, // System: 0-99, App: 100-999, User: 1000+ - - // Optional: Run in background (after* events only) - async: false, - - // Optional: Conditional execution - condition: "status = 'active' AND amount > 1000", - - // Optional: Human-readable description - description: 'Validates account data before save', - - // Optional: Error handling strategy - onError: 'abort', // 'abort' | 'log' - - // Optional: Execution timeout (ms) - timeout: 5000, - - // Optional: Retry policy - retryPolicy: { - maxRetries: 3, - backoffMs: 1000, - }, -}; -``` - -### Key Properties Explained - -#### `object` — Target Scope - -```typescript -// Single object -object: 'account' - -// Multiple objects -object: ['account', 'contact', 'lead'] - -// All objects (use sparingly — performance impact) -object: '*' -``` - -#### `events` — Lifecycle Events - -```typescript -// Single event -events: ['beforeInsert'] - -// Multiple events (common pattern) -events: ['beforeInsert', 'beforeUpdate'] - -// After events for side effects -events: ['afterInsert', 'afterUpdate', 'afterDelete'] -``` - -#### `handler` — Implementation - -Handlers can be: - -1. **Inline functions** (recommended for simple hooks): - ```typescript - handler: async (ctx: HookContext) => { - if (!ctx.input.email) { - throw new Error('Email is required'); - } - } - ``` - -2. **String references** (for registered handlers): - ```typescript - handler: 'my_plugin.validateAccount' - ``` - -#### `priority` — Execution Order - -Lower numbers execute first: - -```typescript -// System hooks (framework internals) -priority: 50 - -// Application hooks (your app logic) -priority: 100 // default - -// User customizations -priority: 1000 -``` - -#### `async` — Background Execution - -Only applicable for `after*` events: - -```typescript -// Blocking (default) — runs within transaction -async: false - -// Fire-and-forget — runs in background -async: true -``` - -**When to use async: true:** -- Sending emails/notifications -- Calling slow external APIs -- Logging to external systems -- Non-critical side effects - -**When to use async: false:** -- Creating related records -- Updating dependent data -- Critical consistency requirements +## Migration -#### `condition` — Declarative Filtering +For data lifecycle hook documentation, please use: -Skip handler execution if condition is false: +**New location:** [`objectstack-data/rules/hooks.md`](../objectstack-data/rules/hooks.md) -```typescript -// Only run for high-value accounts -condition: "annual_revenue > 1000000" +## Rationale -// Only run for specific statuses -condition: "status IN ('pending', 'in_review')" +Hooks are a core part of data operations and are best documented alongside object definitions, field types, and validations. The objectstack-data skill now provides comprehensive coverage of: -// Complex conditions -condition: "type = 'enterprise' AND region = 'APAC' AND is_active = true" -``` +- Object and field schema design +- Validation rules +- Index strategy +- **Data lifecycle hooks** (before/after patterns, all 14 events) -#### `onError` — Error Handling +This consolidation reduces skill overlap and makes it easier for AI assistants to understand the complete data layer in ObjectStack. -```typescript -// Abort operation on error (default for before* hooks) -onError: 'abort' +## What Was Moved -// Log error and continue (default for after* hooks) -onError: 'log' -``` +All content from this skill is now available at: ---- - -## Hook Context API - -The `HookContext` passed to your handler provides: - -### Context Properties - -```typescript -interface HookContext { - // Immutable identifiers - id?: string; // Unique execution ID for tracing - object: string; // Target object name (e.g., 'account') - event: HookEventType; // Current event (e.g., 'beforeInsert') - - // Mutable data - input: Record; // Operation parameters (MUTABLE) - result?: unknown; // Operation result (MUTABLE, after* only) - previous?: Record; // Previous state (update/delete) - - // Execution context - session?: { - userId?: string; - tenantId?: string; - roles?: string[]; - accessToken?: string; - }; - - transaction?: unknown; // Database transaction handle - - // Engine access - ql: IDataEngine; // ObjectQL engine instance - api?: ScopedContext; // Cross-object CRUD API - - // User info shortcut - user?: { - id?: string; - name?: string; - email?: string; - }; -} -``` - -### `input` — Operation Parameters - -The structure of `ctx.input` varies by event: - -**Insert operations:** -```typescript -// beforeInsert, afterInsert -{ - // All field values being inserted - name: 'Acme Corp', - industry: 'Technology', - annual_revenue: 5000000, - ... -} -``` - -**Update operations:** -```typescript -// beforeUpdate, afterUpdate -{ - id: '123', // Record ID being updated - // Only fields being changed - status: 'active', - updated_at: '2026-04-13T10:00:00Z', -} -``` - -**Delete operations:** -```typescript -// beforeDelete, afterDelete -{ - id: '123', // Record ID being deleted -} -``` - -**Query operations:** -```typescript -// beforeFind, afterFind -{ - query: { - filter: { status: 'active' }, - sort: [{ field: 'created_at', order: 'desc' }], - limit: 50, - offset: 0, - }, - options: { includeCount: true }, -} -``` - -### `result` — Operation Result - -Available in `after*` hooks: - -```typescript -// afterInsert -result: { id: '123', name: 'Acme Corp', ... } - -// afterUpdate -result: { id: '123', status: 'active', ... } - -// afterDelete -result: { success: true, id: '123' } - -// afterFind -result: { - records: [{ id: '1', ... }, { id: '2', ... }], - total: 150, -} -``` - -### `previous` — Previous State - -Available in update/delete hooks: - -```typescript -// beforeUpdate, afterUpdate -ctx.previous: { - id: '123', - status: 'pending', // Old value - updated_at: '2026-04-01T00:00:00Z', -} - -// beforeDelete, afterDelete -ctx.previous: { - id: '123', - name: 'Old Account', - // ... full record state -} -``` - -### Cross-Object API +- **Full documentation:** [`../objectstack-data/rules/hooks.md`](../objectstack-data/rules/hooks.md) +- **Parent skill:** [`../objectstack-data/SKILL.md`](../objectstack-data/SKILL.md) -Access other objects within the same transaction: - -```typescript -handler: async (ctx: HookContext) => { - // Get API for another object - const users = ctx.api?.object('user'); - - // Query users - const admin = await users.findOne({ - filter: { role: 'admin' } - }); - - // Create related record - await ctx.api?.object('audit_log').insert({ - action: 'account_created', - user_id: ctx.session?.userId, - record_id: ctx.input.id, - }); -} -``` +The objectstack-data skill now includes: +- Hook definition schema +- Hook context API +- 14 lifecycle events (before/after for find/insert/update/delete, etc.) +- Common patterns (validation, defaults, audit logging, workflows) +- Performance considerations +- Testing hooks +- Best practices --- -## Common Patterns - -### 1. Setting Default Values - -```typescript -const setAccountDefaults: Hook = { - name: 'account_defaults', - object: 'account', - events: ['beforeInsert'], - handler: async (ctx) => { - // Set default industry - if (!ctx.input.industry) { - ctx.input.industry = 'Other'; - } - - // Set created timestamp - ctx.input.created_at = new Date().toISOString(); - - // Set owner to current user - if (!ctx.input.owner_id && ctx.session?.userId) { - ctx.input.owner_id = ctx.session.userId; - } - }, -}; -``` - -### 2. Data Validation - -```typescript -const validateAccount: Hook = { - name: 'account_validation', - object: 'account', - events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx) => { - // Validate email format - if (ctx.input.email && !ctx.input.email.includes('@')) { - throw new Error('Invalid email format'); - } - - // Validate website URL - if (ctx.input.website && !ctx.input.website.startsWith('http')) { - throw new Error('Website must start with http or https'); - } - - // Check annual revenue - if (ctx.input.annual_revenue && ctx.input.annual_revenue < 0) { - throw new Error('Annual revenue cannot be negative'); - } - }, -}; -``` - -### 3. Preventing Deletion - -```typescript -const protectStrategicAccounts: Hook = { - name: 'protect_strategic_accounts', - object: 'account', - events: ['beforeDelete'], - handler: async (ctx) => { - // ctx.previous contains the record being deleted - if (ctx.previous?.type === 'Strategic') { - throw new Error('Cannot delete Strategic accounts'); - } - - // Check for active opportunities - const oppCount = await ctx.api?.object('opportunity').count({ - filter: { - account_id: ctx.input.id, - stage: { $in: ['Prospecting', 'Negotiation'] } - } - }); - - if (oppCount && oppCount > 0) { - throw new Error(`Cannot delete account with ${oppCount} active opportunities`); - } - }, -}; -``` - -### 4. Data Enrichment - -```typescript -const enrichLeadScore: Hook = { - name: 'lead_scoring', - object: 'lead', - events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx) => { - let score = 0; - - // Email domain scoring - if (ctx.input.email?.endsWith('@enterprise.com')) { - score += 50; - } - - // Phone number bonus - if (ctx.input.phone) { - score += 20; - } - - // Company size scoring - if (ctx.input.company_size === 'Enterprise') { - score += 30; - } - - // Industry scoring - if (ctx.input.industry === 'Technology') { - score += 25; - } - - ctx.input.score = score; - }, -}; -``` - -### 5. Triggering Workflows - -```typescript -const notifyOnStatusChange: Hook = { - name: 'notify_status_change', - object: 'opportunity', - events: ['afterUpdate'], - async: true, // Fire-and-forget - handler: async (ctx) => { - // Detect status change - const oldStatus = ctx.previous?.stage; - const newStatus = ctx.input.stage; - - if (oldStatus !== newStatus) { - // Send notification (async, doesn't block transaction) - console.log(`Opportunity ${ctx.input.id} moved from ${oldStatus} to ${newStatus}`); - - // Could trigger email, Slack notification, etc. - // await sendEmail({ - // to: ctx.user?.email, - // subject: `Opportunity stage changed to ${newStatus}`, - // body: `...` - // }); - } - }, -}; -``` - -### 6. Creating Related Records - -```typescript -const createAuditTrail: Hook = { - name: 'audit_trail', - object: ['account', 'contact', 'opportunity'], - events: ['afterInsert', 'afterUpdate', 'afterDelete'], - async: false, // Must run in transaction - handler: async (ctx) => { - const action = ctx.event.replace('after', '').toLowerCase(); - - await ctx.api?.object('audit_log').insert({ - object_type: ctx.object, - record_id: String(ctx.input.id || ''), - action, - user_id: ctx.session?.userId, - timestamp: new Date().toISOString(), - changes: ctx.event === 'afterUpdate' ? { - before: ctx.previous, - after: ctx.result, - } : undefined, - }); - }, -}; -``` - -### 7. External API Integration - -```typescript -const syncToExternalCRM: Hook = { - name: 'sync_external_crm', - object: 'account', - events: ['afterInsert', 'afterUpdate'], - async: true, // Don't block the main transaction - timeout: 10000, // 10 second timeout - retryPolicy: { - maxRetries: 3, - backoffMs: 2000, - }, - handler: async (ctx) => { - try { - // Call external API - // await fetch('https://external-crm.com/api/accounts', { - // method: 'POST', - // headers: { 'Authorization': 'Bearer ...' }, - // body: JSON.stringify(ctx.result), - // }); - - console.log(`Synced account ${ctx.input.id} to external CRM`); - } catch (error) { - // Error is logged but doesn't abort the operation - console.error('Failed to sync to external CRM', error); - } - }, -}; -``` - -### 8. Multi-Object Logic - -```typescript -const cascadeAccountUpdate: Hook = { - name: 'cascade_account_updates', - object: 'account', - events: ['afterUpdate'], - handler: async (ctx) => { - // If account industry changed, update all contacts - if (ctx.input.industry && ctx.previous?.industry !== ctx.input.industry) { - await ctx.api?.object('contact').updateMany({ - filter: { account_id: ctx.input.id }, - data: { account_industry: ctx.input.industry }, - }); - } - }, -}; -``` - -### 9. Conditional Execution - -```typescript -const highValueAccountAlert: Hook = { - name: 'high_value_alert', - object: 'account', - events: ['afterInsert'], - // Only run for high-value accounts - condition: "annual_revenue > 10000000", - async: true, - handler: async (ctx) => { - console.log(`🚨 High-value account created: ${ctx.result.name}`); - // Send alert to sales leadership - }, -}; -``` - -### 10. Data Masking (Read Operations) - -```typescript -const maskSensitiveData: Hook = { - name: 'mask_pii', - object: ['contact', 'lead'], - events: ['afterFind', 'afterFindOne'], - handler: async (ctx) => { - // Check user role - const isAdmin = ctx.session?.roles?.includes('admin'); - - if (!isAdmin) { - // Mask sensitive fields - const maskField = (record: any) => { - if (record.ssn) { - record.ssn = '***-**-' + record.ssn.slice(-4); - } - if (record.credit_card) { - record.credit_card = '**** **** **** ' + record.credit_card.slice(-4); - } - }; - - if (Array.isArray(ctx.result?.records)) { - ctx.result.records.forEach(maskField); - } else if (ctx.result) { - maskField(ctx.result); - } - } - }, -}; -``` - ---- - -## Registration Methods - -### Method 1: Declarative (Stack Definition) - -**Best for:** Application-level hooks defined in metadata. - -```typescript -// objectstack.config.ts -import { defineStack } from '@objectstack/spec'; -import taskHook from './objects/task.hook'; - -export default defineStack({ - manifest: { /* ... */ }, - objects: [/* ... */], - hooks: [taskHook], // Register hooks here -}); -``` - -### Method 2: Programmatic (Runtime) - -**Best for:** Plugin-provided hooks, dynamic registration. - -```typescript -// In your plugin's onEnable() -export const onEnable = async (ctx: { ql: ObjectQL }) => { - ctx.ql.registerHook('beforeInsert', async (hookCtx) => { - // Handler logic - }, { - object: 'account', - priority: 100, - }); -}; -``` - -### Method 3: Hook Files (Convention) - -**Best for:** Organized codebases, per-object hooks. - -```typescript -// src/objects/account.hook.ts -import { Hook, HookContext } from '@objectstack/spec/data'; - -const accountHook: Hook = { - name: 'account_logic', - object: 'account', - events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx: HookContext) => { - // Validation logic - }, -}; - -export default accountHook; - -// Then import and register in objectstack.config.ts -``` - ---- - -## Best Practices - -### ✅ DO - -1. **Use specific events** — Don't subscribe to all events if you only need one. -2. **Keep handlers focused** — One hook = one responsibility. -3. **Use `condition` for filtering** — Avoid unnecessary handler execution. -4. **Set appropriate priorities** — Ensure correct execution order. -5. **Use `async: true` for side effects** — Don't block transactions for non-critical operations. -6. **Validate early** — Use `before*` hooks for validation. -7. **Handle errors gracefully** — Provide meaningful error messages. -8. **Use `ctx.api` for cross-object operations** — Maintains transaction consistency. -9. **Document your hooks** — Use `description` and comments. -10. **Test thoroughly** — Unit test hooks in isolation. - -### ❌ DON'T - -1. **Don't mutate immutable properties** — `ctx.object`, `ctx.event`, `ctx.id` are read-only. -2. **Don't perform expensive operations in `before*` hooks** — Use `after*` + `async: true` instead. -3. **Don't create infinite loops** — Be careful when hooks modify data that triggers other hooks. -4. **Don't ignore `ctx.previous`** — Essential for detecting changes. -5. **Don't use `object: '*'` unless necessary** — Performance impact. -6. **Don't block on external APIs** — Use `async: true` and proper timeouts. -7. **Don't assume `ctx.session` exists** — System operations may have no user context. -8. **Don't throw in `after*` hooks unless critical** — Use `onError: 'log'` for non-critical errors. -9. **Don't duplicate validation** — Use declarative validation rules when possible. -10. **Don't forget transaction boundaries** — `async: true` runs outside transaction. - ---- - -## Error Handling - -### Throwing Errors (Abort Operation) - -```typescript -handler: async (ctx) => { - if (!ctx.input.email) { - // Aborts operation, rolls back transaction - throw new Error('Email is required'); - } -} -``` - -### Logging Errors (Continue) - -```typescript -{ - onError: 'log', // Log error, don't abort - handler: async (ctx) => { - try { - await sendEmail(ctx.input.email); - } catch (error) { - // Error is logged, operation continues - console.error('Failed to send email', error); - } - } -} -``` - -### Custom Error Messages - -```typescript -handler: async (ctx) => { - if (ctx.input.annual_revenue < 0) { - throw new Error('Annual revenue cannot be negative'); - } - - if (ctx.input.annual_revenue > 1000000000) { - throw new Error('Annual revenue exceeds maximum allowed value (1B)'); - } -} -``` - ---- - -## Testing Hooks - -### Unit Testing - -```typescript -import { describe, it, expect } from 'vitest'; -import { HookContext } from '@objectstack/spec/data'; -import accountHook from './account.hook'; - -describe('accountHook', () => { - it('sets default industry', async () => { - const ctx: Partial = { - object: 'account', - event: 'beforeInsert', - input: { name: 'Acme Corp' }, - }; - - await accountHook.handler(ctx as HookContext); - - expect(ctx.input.industry).toBe('Other'); - }); - - it('validates website URL', async () => { - const ctx: Partial = { - object: 'account', - event: 'beforeInsert', - input: { website: 'invalid-url' }, - }; - - await expect( - accountHook.handler(ctx as HookContext) - ).rejects.toThrow('Website must start with http'); - }); -}); -``` - -### Integration Testing - -```typescript -import { LiteKernel } from '@objectstack/core'; -import { ObjectQLPlugin } from '@objectstack/objectql'; -import { DriverPlugin } from '@objectstack/runtime'; -import { InMemoryDriver } from '@objectstack/driver-memory'; - -describe('Hook Integration', () => { - it('executes hook on insert', async () => { - const kernel = new LiteKernel(); - kernel.use(new ObjectQLPlugin()); - kernel.use(new DriverPlugin(new InMemoryDriver())); - - // Register hook - const ql = kernel.getService('objectql'); - ql.registerHook('beforeInsert', async (ctx) => { - ctx.input.created_at = '2026-04-13T10:00:00Z'; - }, { object: 'account' }); - - // Test insert - const result = await ql.object('account').insert({ - name: 'Test Account', - }); - - expect(result.created_at).toBe('2026-04-13T10:00:00Z'); - - await kernel.shutdown(); - }); -}); -``` - ---- - -## Performance Considerations - -### Hook Execution Overhead - -``` -Single Record Insert: -┌─────────────────┬──────────────┐ -│ Hook Count │ Overhead │ -├─────────────────┼──────────────┤ -│ 0 hooks │ ~1ms │ -│ 5 hooks │ ~5ms │ -│ 20 hooks │ ~20ms │ -└─────────────────┴──────────────┘ -``` - -### Optimization Tips - -1. **Use `condition` to filter** — Avoid executing handlers unnecessarily. -2. **Use `async: true` for non-critical side effects** — Don't block transactions. -3. **Batch operations in `after*` hooks** — Reduce database round-trips. -4. **Cache expensive lookups** — Use kernel cache service. -5. **Use specific `object` targets** — Avoid `object: '*'`. - -### Anti-Patterns - -```typescript -// ❌ BAD: Expensive synchronous operation -{ - events: ['beforeInsert'], - async: false, - handler: async (ctx) => { - await slowExternalAPI(ctx.input); // Blocks transaction - } -} - -// ✅ GOOD: Async background operation -{ - events: ['afterInsert'], - async: true, // Fire-and-forget - handler: async (ctx) => { - await slowExternalAPI(ctx.result); - } -} -``` - ---- - -## Advanced Topics - -### Dynamic Hook Registration - -```typescript -// Register hooks based on configuration -export const onEnable = async (ctx: { ql: ObjectQL }) => { - const config = await loadConfig(); - - config.objects.forEach(objectName => { - ctx.ql.registerHook('beforeInsert', async (hookCtx) => { - // Dynamic logic - }, { object: objectName }); - }); -}; -``` - -### Hook Composition - -```typescript -// Compose multiple validators -const validators = [ - validateEmail, - validatePhone, - validateWebsite, -]; - -const composedHook: Hook = { - name: 'validation_suite', - object: 'account', - events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx) => { - for (const validator of validators) { - await validator(ctx); - } - }, -}; -``` - -### Conditional Hook Execution - -```typescript -const conditionalHook: Hook = { - name: 'enterprise_only', - object: 'account', - events: ['afterInsert'], - handler: async (ctx) => { - // Check runtime condition - if (process.env.FEATURE_FLAG_ENTERPRISE !== 'true') { - return; // Skip execution - } - - // Enterprise-specific logic - }, -}; -``` - ---- - -## Troubleshooting - -### Common Issues - -**Issue:** Hook not executing - -**Solutions:** -1. Check `object` matches target object name -2. Verify `events` includes the expected event -3. Check `condition` doesn't filter out all records -4. Ensure hook is registered before operations - -**Issue:** Transaction rollback on `after*` hook error - -**Solution:** Set `onError: 'log'` or `async: true` - -**Issue:** Infinite loop (hook triggers itself) - -**Solution:** Use conditional checks, track execution state - -**Issue:** `ctx.api` is undefined - -**Solution:** Ensure ObjectQL engine is initialized with API support - -**Issue:** Performance degradation - -**Solutions:** -1. Use `async: true` for non-critical operations -2. Add `condition` to filter executions -3. Reduce number of global (`object: '*'`) hooks - ---- - -## References - -- [hook.zod.ts](./references/data/hook.zod.ts) — Hook schema definition, HookContext interface -- [Examples: app-todo](../../examples/app-todo/src/objects/task.hook.ts) — Simple task hook -- [Examples: app-crm](../../examples/app-crm/src/objects/) — Advanced CRM hooks - ---- - -## Summary - -Hooks are the **primary extension mechanism** in ObjectStack. They enable you to: +## Quick Reference (for backwards compatibility) -- ✅ Add custom validation and business rules -- ✅ Enrich data with calculated fields -- ✅ Trigger side effects and integrations -- ✅ Enforce security and compliance -- ✅ Implement audit trails -- ✅ Transform data in/out +For hook lifecycle documentation, see: -**Golden Rules:** +- [objectstack-data/rules/hooks.md](../objectstack-data/rules/hooks.md) — Complete hook documentation +- [objectstack-data/SKILL.md](../objectstack-data/SKILL.md) — Data skill overview -1. Use `before*` for validation, `after*` for side effects -2. Set `async: true` for non-critical background work -3. Use `ctx.api` for cross-object operations -4. Handle errors gracefully with meaningful messages -5. Test hooks in isolation and integration +For kernel-level hooks (kernel:ready, kernel:shutdown, custom plugin events), see: -For more advanced patterns, see the **objectstack-automation** skill for Flows and Workflows. +- [objectstack-kernel/rules/hooks-events.md](../objectstack-kernel/rules/hooks-events.md) — Kernel hook system +- [objectstack-kernel/SKILL.md](../objectstack-kernel/SKILL.md) — Kernel skill overview diff --git a/skills/objectstack-i18n/evals/README.md b/skills/objectstack-i18n/evals/README.md new file mode 100644 index 000000000..d6e9bf631 --- /dev/null +++ b/skills/objectstack-i18n/evals/README.md @@ -0,0 +1,46 @@ +# Evaluation Tests (evals/) + +This directory is reserved for future skill evaluation tests. + +## Purpose + +Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. + +## Structure + +When implemented, evals will follow this structure: + +``` +evals/ +├── naming/ +│ ├── test-object-names.md +│ ├── test-field-keys.md +│ └── test-option-values.md +├── relationships/ +│ ├── test-lookup-vs-master-detail.md +│ └── test-junction-patterns.md +├── validation/ +│ ├── test-script-inversion.md +│ └── test-state-machine.md +└── ... +``` + +## Format + +Each eval file will contain: +1. **Scenario** — Description of the task +2. **Expected Output** — Correct implementation +3. **Common Mistakes** — Incorrect patterns to avoid +4. **Validation Criteria** — How to score the output + +## Status + +⚠️ **Not yet implemented** — This is a placeholder for future development. + +## Contributing + +When adding evals: +1. Each eval should test a single, specific rule or pattern +2. Include both positive (correct) and negative (incorrect) examples +3. Reference the corresponding rule file in `rules/` +4. Use realistic scenarios from actual ObjectStack projects diff --git a/skills/objectstack-i18n/rules/translation-bundles.md b/skills/objectstack-i18n/rules/translation-bundles.md new file mode 100644 index 000000000..916353ff1 --- /dev/null +++ b/skills/objectstack-i18n/rules/translation-bundles.md @@ -0,0 +1,67 @@ +# Translation Bundles + +Guide for i18n translation bundle structure in ObjectStack. + +## Translation Bundle Structure + +```typescript +{ + locale: 'en-US', + namespace: 'crm', + translations: { + 'account.label': 'Account', + 'account.pluralLabel': 'Accounts', + 'account.fields.name': 'Account Name', + 'account.fields.industry': 'Industry', + }, +} +``` + +## Coverage Detection + +ObjectStack automatically detects untranslated keys: + +- Objects without translated labels +- Fields without translated labels +- UI elements missing translations + +## Naming Conventions + +| Context | Pattern | Example | +|:--------|:--------|:--------| +| Object label | `{object}.label` | `account.label` | +| Object plural | `{object}.pluralLabel` | `account.pluralLabel` | +| Field label | `{object}.fields.{field}` | `account.fields.name` | +| UI element | `{namespace}.{component}.{key}` | `crm.dashboard.title` | + +## Incorrect vs Correct + +### ❌ Incorrect — Inconsistent Key Structure + +```typescript +{ + 'accountLabel': 'Account', // ❌ Wrong format + 'account_name': 'Account Name', // ❌ Wrong separator +} +``` + +### ✅ Correct — Consistent Dotted Keys + +```typescript +{ + 'account.label': 'Account', // ✅ Correct format + 'account.fields.name': 'Account Name', // ✅ Correct format +} +``` + +## Best Practices + +1. **Use dotted notation** — `object.fields.field_name` +2. **Group by namespace** — Organize by domain/module +3. **Provide context** — Use descriptive keys +4. **Check coverage** — Use coverage detection tools +5. **Test all locales** — Validate translations display correctly + +--- + +See parent skill for complete documentation: [../SKILL.md](../SKILL.md) diff --git a/skills/objectstack-kernel/SKILL.md b/skills/objectstack-kernel/SKILL.md index 6ec4131e0..5ac9344f6 100644 --- a/skills/objectstack-kernel/SKILL.md +++ b/skills/objectstack-kernel/SKILL.md @@ -12,7 +12,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/core v4+, @objectstack/spec v4+ metadata: author: objectstack-ai - version: "1.0" + version: "2.0" domain: kernel tags: plugin, kernel, service, hook, event, DI, lifecycle, bootstrap --- @@ -28,13 +28,23 @@ configuration. ## When to Use This Skill -- You are creating a **new plugin** (driver, server, service, app feature). -- You need to **register or consume services** via the DI container. -- You are using the **hook/event system** for inter-plugin communication. -- You need to choose between **ObjectKernel** and **LiteKernel**. -- You are debugging **plugin loading order** or dependency resolution. -- You need to configure **graceful shutdown**, timeouts, or health checks. -- You are implementing **service factories** with lifecycle management. +- You are creating a **new plugin** (driver, server, service, app feature) +- You need to **register or consume services** via the DI container +- You are using the **hook/event system** for inter-plugin communication +- You need to choose between **ObjectKernel** and **LiteKernel** +- You are debugging **plugin loading order** or dependency resolution +- You need to configure **graceful shutdown**, timeouts, or health checks +- You are implementing **service factories** with lifecycle management + +--- + +## Quick Reference — Detailed Rules + +For comprehensive documentation with incorrect/correct examples: + +- **[Plugin Lifecycle](./rules/plugin-lifecycle.md)** — 3-phase lifecycle (init/start/destroy), execution order, complete examples +- **[Service Registry](./rules/service-registry.md)** — DI container, factories, lifecycles (singleton/transient/scoped), core fallbacks +- **[Hooks & Events](./rules/hooks-events.md)** — 14 built-in hooks, custom events, handler patterns, performance tips --- @@ -83,24 +93,15 @@ What environment are you targeting? import { ObjectKernel } from '@objectstack/core'; const kernel = new ObjectKernel({ - // Logger configuration logger: { level: 'info', // 'debug' | 'info' | 'warn' | 'error' | 'fatal' format: 'json', // 'json' | 'text' | 'pretty' }, - - // Plugin startup timeout (ms) — per plugin - defaultStartupTimeout: 30000, // default: 30s - - // Graceful shutdown - gracefulShutdown: true, // default: true (registers SIGINT/SIGTERM) - shutdownTimeout: 60000, // default: 60s - - // Rollback all started plugins if one fails during bootstrap - rollbackOnFailure: true, // default: true - - // Skip system requirement validation (useful for tests) - skipSystemValidation: false, // default: false + defaultStartupTimeout: 30000, // Per plugin (ms) + gracefulShutdown: true, // Register SIGINT/SIGTERM handlers + shutdownTimeout: 60000, // Total shutdown timeout (ms) + rollbackOnFailure: true, // Rollback all plugins if one fails + skipSystemValidation: false, // Skip system checks (useful for tests) }); ``` @@ -116,113 +117,56 @@ const kernel = new LiteKernel({ --- -## Plugin Interface - -Every plugin must implement the `Plugin` interface from `@objectstack/core`: +## Plugin Interface — Quick Overview ```typescript import type { Plugin, PluginContext } from '@objectstack/core'; export interface Plugin { - /** Unique name (reverse domain recommended) */ - name: string; - - /** Semantic version */ - version?: string; - - /** Plugin type */ - type?: string; // 'standard' | 'ui' | 'driver' | 'server' | 'app' | 'theme' | 'agent' - - /** Plugins that must init before this one */ - dependencies?: string[]; + name: string; // Unique identifier (reverse domain recommended) + version?: string; // Semantic version + type?: string; // 'standard' | 'ui' | 'driver' | 'server' | 'app' + dependencies?: string[]; // Plugins that must init before this one - /** Phase 1: Register services — called during kernel init */ + // Phase 1: Register services init(ctx: PluginContext): Promise | void; - /** Phase 2: Execute business logic — called after ALL plugins init */ + // Phase 2: Execute business logic (optional) start?(ctx: PluginContext): Promise | void; - /** Phase 3: Cleanup — called during kernel shutdown */ + // Phase 3: Cleanup (optional) destroy?(): Promise | void; } ``` -### Lifecycle Phases - -``` -kernel.bootstrap() -│ -├── Phase 1: INIT (register services) -│ ├── PluginA.init(ctx) → ctx.registerService('db', dbInstance) -│ ├── PluginB.init(ctx) → ctx.registerService('cache', cacheInstance) -│ └── PluginC.init(ctx) → ctx.registerService('http', httpServer) -│ │ -│ └── [Core fallback injection — auto-fills missing 'core' services] -│ -├── Phase 2: START (business logic) -│ ├── PluginA.start(ctx) → connect to database -│ ├── PluginB.start(ctx) → warm cache -│ └── PluginC.start(ctx) → bind routes, listen on port -│ -└── Phase 3: READY - └── ctx.trigger('kernel:ready') - └── All hook handlers execute - -kernel.shutdown() -│ -├── ctx.trigger('kernel:shutdown') -├── PluginC.destroy() → close server -├── PluginB.destroy() → flush cache -└── PluginA.destroy() → disconnect DB -``` - -Key rules: -- `init()` is **required** — this is where you register services. -- `start()` is **optional** — only needed if your plugin has active behavior. -- `destroy()` is **optional** — only needed if you hold resources to release. -- Plugins init in **dependency order** (topological sort on `dependencies`). -- Plugins destroy in **reverse** order. -- Each phase completes for ALL plugins before the next phase begins. +See [rules/plugin-lifecycle.md](./rules/plugin-lifecycle.md) for complete examples. --- ## PluginContext API -The `ctx` parameter passed to `init()` and `start()` provides: - ### Service Registry ```typescript -// Register a service (typically in init phase) +// Register a service (in init phase) ctx.registerService('my-service', myServiceInstance); -// Get a service registered by another plugin +// Get a service (in start phase) const db = ctx.getService('objectql'); -const cache = ctx.getService('cache'); -// Replace an existing service (e.g., wrap with instrumentation) +// Replace a service ctx.replaceService('cache', new InstrumentedCache(existingCache)); -// Get all registered services +// Get all services const allServices: Map = ctx.getServices(); ``` -**Important:** `getService()` throws if the service doesn't exist. Check -availability before calling in optional integrations: - -```typescript -try { - const realtime = ctx.getService('realtime'); - realtime.publish('my-event', data); -} catch { - ctx.logger.debug('Realtime service not available — skipping'); -} -``` +See [rules/service-registry.md](./rules/service-registry.md) for factories and lifecycles. ### Hook / Event System ```typescript -// Register a hook handler (in init or start) +// Register a hook handler ctx.hook('kernel:ready', async () => { ctx.logger.info('System is ready!'); }); @@ -238,21 +182,7 @@ ctx.hook('data:beforeInsert', async (objectName, record) => { await ctx.trigger('my-plugin:initialized', { version: '1.0.0' }); ``` -### Built-in Hooks - -| Hook | Triggered When | Arguments | -|:-----|:---------------|:----------| -| `kernel:ready` | All plugins started, system validated | (none) | -| `kernel:shutdown` | Shutdown begins | (none) | -| `data:beforeInsert` | Before a record is created | `(objectName, record)` | -| `data:afterInsert` | After a record is created | `(objectName, record, result)` | -| `data:beforeUpdate` | Before a record is updated | `(objectName, id, record)` | -| `data:afterUpdate` | After a record is updated | `(objectName, id, record, result)` | -| `data:beforeDelete` | Before a record is deleted | `(objectName, id)` | -| `data:afterDelete` | After a record is deleted | `(objectName, id, result)` | -| `metadata:changed` | Metadata is registered or updated | `(type, name, metadata)` | - -Custom hooks follow the convention: `{plugin-namespace}:{event-name}`. +See [rules/hooks-events.md](./rules/hooks-events.md) for all 14 built-in hooks and patterns. ### Logger @@ -266,7 +196,6 @@ ctx.logger.error('Connection failed', error); ### Kernel Access ```typescript -// Advanced: get the kernel instance directly const kernel = ctx.getKernel(); const isRunning = kernel.isRunning(); const state = kernel.getState(); // 'idle' | 'initializing' | 'running' | 'stopping' | 'stopped' @@ -274,198 +203,8 @@ const state = kernel.getState(); // 'idle' | 'initializing' | 'running' | 'stopp --- -## Service Lifecycle (ObjectKernel Only) - -ObjectKernel supports factory-based DI with three lifecycle scopes: - -| Lifecycle | Behavior | Use Case | -|:----------|:---------|:---------| -| `SINGLETON` | One instance shared app-wide | Database connections, caches | -| `TRANSIENT` | New instance per `getService()` call | Stateless utilities, formatters | -| `SCOPED` | One instance per scope (e.g., per request) | Request-scoped contexts, transactions | - -### Factory Registration - -```typescript -import { ServiceLifecycle } from '@objectstack/core'; - -// In your plugin's init(): -async init(ctx: PluginContext) { - const kernel = ctx.getKernel(); - - // Singleton factory — created once, cached forever - kernel.registerServiceFactory( - 'db-pool', - (ctx) => createPool({ connectionString: process.env.DATABASE_URL }), - ServiceLifecycle.SINGLETON, - ); - - // Transient factory — new instance each time - kernel.registerServiceFactory( - 'request-logger', - (ctx) => new RequestLogger(ctx.logger), - ServiceLifecycle.TRANSIENT, - ); - - // Scoped factory — one instance per scope - kernel.registerServiceFactory( - 'unit-of-work', - (ctx) => new UnitOfWork(ctx.getService('db-pool')), - ServiceLifecycle.SCOPED, - ['db-pool'], // Dependencies — resolved before factory executes - ); -} -``` - -### Direct Registration vs Factory - -```typescript -// Direct: pass an already-created instance -ctx.registerService('config', { apiKey: '...' }); - -// Factory: let the kernel manage creation and lifecycle -kernel.registerServiceFactory('db', (ctx) => new Database(), ServiceLifecycle.SINGLETON); -``` - -Use **direct registration** for simple config objects or pre-existing instances. -Use **factories** when you need lazy initialization, lifecycle management, or -dependency injection between services. - ---- - -## Plugin Dependencies - -Declare dependencies to control initialization order: - -```typescript -const MyPlugin: Plugin = { - name: 'com.example.analytics', - version: '1.0.0', - dependencies: ['com.objectstack.engine.objectql'], // Must init first - - async init(ctx) { - // Safe to call — ObjectQL is guaranteed to be initialized - const engine = ctx.getService('objectql'); - ctx.registerService('analytics', new AnalyticsService(engine)); - }, -}; -``` - -The kernel performs **topological sort** on the dependency graph. If circular -dependencies are detected, ObjectKernel logs a warning (LiteKernel throws). - -### Well-Known Plugin Names - -| Name | Package | Service Key | -|:-----|:--------|:------------| -| `com.objectstack.engine.objectql` | `@objectstack/objectql` | `'objectql'` | -| `com.objectstack.driver.*` | `@objectstack/driver-*` | `'driver.{name}'` | -| `com.objectstack.auth` | `@objectstack/plugin-auth` | `'auth'` | -| `com.objectstack.rest` | `@objectstack/rest` | `'rest'` | -| `com.objectstack.metadata` | `@objectstack/metadata` | `'metadata'` | -| `com.objectstack.realtime` | `@objectstack/service-realtime` | `'realtime'` | -| `com.objectstack.cache` | `@objectstack/service-cache` | `'cache'` | - ---- - -## Core Fallback Injection - -ObjectKernel auto-injects in-memory fallbacks for `core`-criticality services -not registered by any plugin during Phase 1. This ensures services like -`metadata`, `cache`, `queue` are always resolvable in Phase 2. - -``` -Phase 1: init() completes for all plugins - ↓ -Kernel checks ServiceRequirementDef: - 'metadata' → core → auto-inject InMemoryMetadataService if missing - 'cache' → core → auto-inject InMemoryCache if missing - 'queue' → core → auto-inject InMemoryQueue if missing - 'objectql' → required → ERROR if missing (no fallback) - 'realtime' → optional → skip, plugins should check availability - ↓ -Phase 2: start() begins — all core services available -``` - -Service criticality levels: - -| Level | Behavior | -|:------|:---------| -| `required` | Kernel throws if missing — system cannot start | -| `core` | Auto-injected in-memory fallback if no plugin provides it | -| `optional` | Silently skipped — plugins must check before use | - ---- - -## Health Monitoring (ObjectKernel Only) - -Plugins can implement health checks: - -```typescript -const MyPlugin: Plugin & { healthCheck(): Promise } = { - name: 'com.example.db', - version: '1.0.0', - - async init(ctx) { /* ... */ }, - - async healthCheck() { - try { - await this.pool.query('SELECT 1'); - return { healthy: true, message: 'Database connected' }; - } catch (err) { - return { healthy: false, message: 'Database unreachable', details: { error: err.message } }; - } - }, -}; - -// Check health from kernel -const health = await kernel.checkPluginHealth('com.example.db'); -const allHealth = await kernel.checkAllPluginsHealth(); - -// Get startup performance metrics -const metrics = kernel.getPluginMetrics(); -// Map — plugin name → startup duration in ms -``` - ---- - -## Feature Flags - -Declare feature flags in your stack definition: - -```typescript -import { defineStack } from '@objectstack/spec'; - -export default defineStack({ - // ... other config - featureFlags: [ - { - name: 'experimental_ai_copilot', - label: 'AI Copilot', - enabled: true, - strategy: 'percentage', - conditions: { percentage: 25 }, // 25% of users - environment: ['production'], - }, - { - name: 'beta_kanban_view', - label: 'Kanban View', - enabled: true, - strategy: 'group', - conditions: { groups: ['beta_testers'] }, - }, - ], -}); -``` - -Strategies: `boolean` | `percentage` | `user_list` | `group` | `custom` - ---- - ## Complete Plugin Example -A working plugin that adds audit logging to all data operations: - ```typescript // packages/plugins/plugin-audit/src/plugin.ts import type { Plugin, PluginContext } from '@objectstack/core'; @@ -475,7 +214,6 @@ interface AuditEntry { operation: string; object: string; recordId?: string; - userId?: string; } class AuditService { @@ -497,11 +235,10 @@ const AuditPlugin: Plugin = { dependencies: ['com.objectstack.engine.objectql'], async init(ctx: PluginContext) { - // Phase 1: Register our service + // Phase 1: Register service and hooks const auditService = new AuditService(); ctx.registerService('audit', auditService); - // Register hooks for data events ctx.hook('data:afterInsert', async (objectName, _record, result) => { auditService.record({ timestamp: new Date().toISOString(), @@ -511,24 +248,6 @@ const AuditPlugin: Plugin = { }); }); - ctx.hook('data:afterUpdate', async (objectName, id) => { - auditService.record({ - timestamp: new Date().toISOString(), - operation: 'update', - object: objectName, - recordId: String(id), - }); - }); - - ctx.hook('data:afterDelete', async (objectName, id) => { - auditService.record({ - timestamp: new Date().toISOString(), - operation: 'delete', - object: objectName, - recordId: String(id), - }); - }); - ctx.logger.info('Audit plugin initialized'); }, @@ -538,14 +257,16 @@ const AuditPlugin: Plugin = { }, async destroy() { - // Phase 3: Flush remaining entries (if using external storage) + // Phase 3: Cleanup }, }; export default AuditPlugin; ``` -### Using the Plugin +--- + +## Using Plugins ```typescript import { ObjectKernel } from '@objectstack/core'; @@ -560,11 +281,13 @@ await kernel.use(new DriverPlugin(new InMemoryDriver())); await kernel.use(AuditPlugin); await kernel.bootstrap(); -// Audit service is now available +// Services are now available const audit = kernel.getService('audit'); ``` -### Testing with LiteKernel +--- + +## Testing Plugins ```typescript import { describe, it, expect } from 'vitest'; @@ -593,20 +316,87 @@ describe('AuditPlugin', () => { --- -## Zod Schema References +## Well-Known Plugin Names & Services + +| Plugin Name | Service Key | Package | +|:------------|:------------|:--------| +| `com.objectstack.engine.objectql` | `objectql` | `@objectstack/objectql` | +| `com.objectstack.driver.*` | `driver.{name}` | `@objectstack/driver-*` | +| `com.objectstack.auth` | `auth` | `@objectstack/plugin-auth` | +| `com.objectstack.rest` | `rest` | `@objectstack/rest` | +| `com.objectstack.metadata` | `metadata` | `@objectstack/metadata` | +| `com.objectstack.realtime` | `realtime` | `@objectstack/service-realtime` | +| `com.objectstack.cache` | `cache` | `@objectstack/service-cache` | + +--- + +## Health Monitoring (ObjectKernel Only) + +```typescript +const MyPlugin: Plugin & { healthCheck(): Promise } = { + name: 'com.example.db', + version: '1.0.0', + + async init(ctx) { /* ... */ }, + + async healthCheck() { + try { + await this.pool.query('SELECT 1'); + return { healthy: true, message: 'Database connected' }; + } catch (err) { + return { healthy: false, message: 'Database unreachable', details: { error: err.message } }; + } + }, +}; + +// Check health +const health = await kernel.checkPluginHealth('com.example.db'); +const allHealth = await kernel.checkAllPluginsHealth(); + +// Get startup metrics +const metrics = kernel.getPluginMetrics(); +// Map — plugin name → startup duration in ms +``` + +--- + +## Feature Flags -When you need precise type definitions for kernel protocols, read these -bundled reference files: +```typescript +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + featureFlags: [ + { + name: 'experimental_ai_copilot', + label: 'AI Copilot', + enabled: true, + strategy: 'percentage', + conditions: { percentage: 25 }, // 25% of users + environment: ['production'], + }, + { + name: 'beta_kanban_view', + label: 'Kanban View', + enabled: true, + strategy: 'group', + conditions: { groups: ['beta_testers'] }, + }, + ], +}); +``` + +Strategies: `boolean` | `percentage` | `user_list` | `group` | `custom` + +--- -| File | What It Contains | -|:-----|:-----------------| -| [`references/kernel/plugin.zod.ts`](./references/kernel/plugin.zod.ts) | PluginContext schema, lifecycle hooks, core plugin types | -| [`references/kernel/context.zod.ts`](./references/kernel/context.zod.ts) | RuntimeMode, KernelContext, TenantRuntimeContext | -| [`references/kernel/service-registry.zod.ts`](./references/kernel/service-registry.zod.ts) | Service scope types, registry config | -| [`references/kernel/plugin-lifecycle-events.zod.ts`](./references/kernel/plugin-lifecycle-events.zod.ts) | All plugin lifecycle event types | -| [`references/kernel/plugin-capability.zod.ts`](./references/kernel/plugin-capability.zod.ts) | Capability protocol, extension points | -| [`references/kernel/plugin-loading.zod.ts`](./references/kernel/plugin-loading.zod.ts) | Plugin loading config, compatibility | -| [`references/kernel/feature.zod.ts`](./references/kernel/feature.zod.ts) | Feature flag strategies and conditions | -| [`references/kernel/metadata-plugin.zod.ts`](./references/kernel/metadata-plugin.zod.ts) | MetadataType enum, type registry, events | +## References -Read `references/_index.md` for the complete list with descriptions. +- [rules/plugin-lifecycle.md](./rules/plugin-lifecycle.md) — 3-phase lifecycle, dependencies, complete examples +- [rules/service-registry.md](./rules/service-registry.md) — DI container, factories, core fallbacks +- [rules/hooks-events.md](./rules/hooks-events.md) — 14 built-in hooks, custom events, patterns +- [references/kernel/plugin.zod.ts](./references/kernel/plugin.zod.ts) — PluginContext schema, lifecycle hooks +- [references/kernel/context.zod.ts](./references/kernel/context.zod.ts) — RuntimeMode, KernelContext +- [references/kernel/service-registry.zod.ts](./references/kernel/service-registry.zod.ts) — Service scope types +- [references/kernel/feature.zod.ts](./references/kernel/feature.zod.ts) — Feature flag strategies +- [references/_index.md](./references/_index.md) — Complete schema index diff --git a/skills/objectstack-kernel/evals/README.md b/skills/objectstack-kernel/evals/README.md new file mode 100644 index 000000000..f6cc36ce4 --- /dev/null +++ b/skills/objectstack-kernel/evals/README.md @@ -0,0 +1,39 @@ +# Evaluation Tests (evals/) + +This directory is reserved for future skill evaluation tests. + +## Purpose + +Evaluation tests (evals) validate that AI assistants correctly understand and apply the kernel rules when developing plugins. + +## Structure + +When implemented, evals will follow this structure: + +``` +evals/ +├── plugin-lifecycle/ +│ ├── test-init-phase.md +│ ├── test-start-phase.md +│ └── test-destroy-phase.md +├── service-registry/ +│ ├── test-registration.md +│ ├── test-consumption.md +│ └── test-factories.md +├── hooks-events/ +│ ├── test-data-hooks.md +│ └── test-custom-events.md +└── ... +``` + +## Status + +⚠️ **Not yet implemented** — This is a placeholder for future development. + +## Contributing + +When adding evals: +1. Each eval should test a single, specific rule or pattern +2. Include both positive (correct) and negative (incorrect) examples +3. Reference the corresponding rule file in `rules/` +4. Use realistic scenarios from actual ObjectStack plugins diff --git a/skills/objectstack-kernel/rules/hooks-events.md b/skills/objectstack-kernel/rules/hooks-events.md new file mode 100644 index 000000000..c483c6266 --- /dev/null +++ b/skills/objectstack-kernel/rules/hooks-events.md @@ -0,0 +1,357 @@ +# Hook & Event System + +Complete guide for using hooks and events in ObjectStack plugins. + +## Hook Registration + +Register hook handlers in `init()` or `start()`: + +```typescript +async init(ctx: PluginContext) { + // Register a hook handler + ctx.hook('kernel:ready', async () => { + ctx.logger.info('System is ready!'); + }); + + // Register data lifecycle hooks + ctx.hook('data:beforeInsert', async (objectName, record) => { + if (objectName === 'task') { + record.created_at = new Date().toISOString(); + } + }); +} +``` + +## Triggering Events + +Trigger custom hooks to notify other plugins: + +```typescript +async start(ctx: PluginContext) { + // Trigger a custom event + await ctx.trigger('my-plugin:initialized', { version: '1.0.0' }); +} +``` + +## Built-in Hooks + +### Kernel Lifecycle Hooks + +| Hook | Triggered When | Arguments | +|:-----|:---------------|:----------| +| `kernel:ready` | All plugins started, system validated | (none) | +| `kernel:shutdown` | Shutdown begins | (none) | + +### Data Lifecycle Hooks + +| Hook | Triggered When | Arguments | +|:-----|:---------------|:----------| +| `data:beforeInsert` | Before a record is created | `(objectName, record)` | +| `data:afterInsert` | After a record is created | `(objectName, record, result)` | +| `data:beforeUpdate` | Before a record is updated | `(objectName, id, record)` | +| `data:afterUpdate` | After a record is updated | `(objectName, id, record, result)` | +| `data:beforeDelete` | Before a record is deleted | `(objectName, id)` | +| `data:afterDelete` | After a record is deleted | `(objectName, id, result)` | +| `data:beforeFind` | Before querying records | `(objectName, query)` | +| `data:afterFind` | After querying records | `(objectName, query, result)` | + +### Metadata Hooks + +| Hook | Triggered When | Arguments | +|:-----|:---------------|:----------| +| `metadata:changed` | Metadata is registered or updated | `(type, name, metadata)` | + +## Custom Hooks + +Create your own hooks following the convention: `{plugin-namespace}:{event-name}`. + +```typescript +// In your plugin +async start(ctx: PluginContext) { + await ctx.trigger('analytics:pageview', { + path: '/dashboard', + userId: '123', + }); +} + +// In another plugin +async init(ctx: PluginContext) { + ctx.hook('analytics:pageview', async (data) => { + console.log('Page viewed:', data.path); + }); +} +``` + +## Hook Handler Patterns + +### Simple Handler + +```typescript +ctx.hook('kernel:ready', async () => { + console.log('System ready'); +}); +``` + +### Handler with Data + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) => { + console.log(`Created ${objectName} record:`, result.id); +}); +``` + +### Handler with Context + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + // Access kernel context + const user = ctx.getService('auth').getCurrentUser(); + record.created_by = user.id; +}); +``` + +### Async Error Handling + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) => { + try { + await sendNotification(record); + } catch (error) { + ctx.logger.error('Failed to send notification', error); + // Don't throw — let other hooks continue + } +}); +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Blocking Hook with Slow Operation + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + // ❌ Blocks transaction + await sendEmail(record.email); + await callExternalAPI(record); +}); +``` + +### ✅ Correct — Use after* Hook for Side Effects + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) { + // ✅ Non-blocking, outside transaction + try { + await sendEmail(record.email); + await callExternalAPI(record); + } catch (error) { + ctx.logger.error('Side effect failed', error); + } +}); +``` + +### ❌ Incorrect — Throwing in after* Hook + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) { + throw new Error('Notification failed'); // ❌ Too late to abort +}); +``` + +### ✅ Correct — Logging Errors in after* Hook + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) { + try { + await sendNotification(result); + } catch (error) { + ctx.logger.error('Notification failed', error); // ✅ Log, don't throw + } +}); +``` + +### ❌ Incorrect — Modifying result in before* Hook + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + record.result = { id: '123' }; // ❌ result doesn't exist yet +}); +``` + +### ✅ Correct — Modifying input in before* Hook + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) { + record.created_at = new Date().toISOString(); // ✅ Modify input +}); +``` + +## Common Patterns + +### Setting Defaults + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + if (objectName === 'task') { + record.status = record.status || 'pending'; + record.priority = record.priority || 'medium'; + } +}); +``` + +### Audit Logging + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) => { + const audit = ctx.getService('audit'); + await audit.log({ + action: 'create', + object: objectName, + recordId: result.id, + timestamp: new Date().toISOString(), + }); +}); +``` + +### Triggering Workflows + +```typescript +ctx.hook('data:afterUpdate', async (objectName, id, record, result) => { + if (objectName === 'opportunity' && record.stage === 'won') { + await ctx.trigger('sales:opportunity-won', { id, record: result }); + } +}); +``` + +### Cross-Object Updates + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) => { + if (objectName === 'invoice_line_item') { + // Update invoice total + const engine = ctx.getService('objectql'); + await engine.object('invoice').update(record.invoice_id, { + updated_at: new Date().toISOString(), + }); + } +}); +``` + +### Validation + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + if (objectName === 'account') { + if (!record.email || !record.email.includes('@')) { + throw new Error('Valid email is required'); + } + } +}); +``` + +## Hook Execution Order + +Hooks are executed in **registration order** within each plugin, then by **plugin initialization order**. + +```typescript +// Plugin A (depends on nothing) +ctx.hook('kernel:ready', () => console.log('A')); + +// Plugin B (depends on A) +ctx.hook('kernel:ready', () => console.log('B')); + +// Output: A, B +``` + +## Performance Considerations + +### before* Hooks +- ⚠️ Block the operation — keep fast +- ⚠️ Run inside transaction — don't call slow APIs +- ✅ Use for validation and data enrichment +- ✅ Throw errors to abort operation + +### after* Hooks +- ⚠️ Still block by default — use sparingly +- ✅ Use for notifications and logging +- ✅ Use try/catch to prevent cascading failures +- ✅ Consider async execution (if supported) + +## Hook Naming Conventions + +Follow the pattern: `{namespace}:{event-name}` + +**Good names:** +- `auth:user-login` +- `sales:opportunity-created` +- `billing:invoice-paid` +- `analytics:event-tracked` + +**Bad names:** +- `userLogin` (no namespace) +- `auth.user.login` (use colons, not dots) +- `auth:USER_LOGIN` (use lowercase) + +## Testing Hooks + +```typescript +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import MyPlugin from './plugin'; + +describe('Hook System', () => { + it('executes hook handler', async () => { + const kernel = new LiteKernel(); + let hookCalled = false; + + kernel.use({ + name: 'test-plugin', + async init(ctx) { + ctx.hook('test:event', async () => { + hookCalled = true; + }); + }, + }); + + await kernel.bootstrap(); + await kernel.context.trigger('test:event'); + + expect(hookCalled).toBe(true); + + await kernel.shutdown(); + }); + + it('passes arguments to hook handler', async () => { + const kernel = new LiteKernel(); + let receivedData: any; + + kernel.use({ + name: 'test-plugin', + async init(ctx) { + ctx.hook('test:event', async (data) => { + receivedData = data; + }); + }, + }); + + await kernel.bootstrap(); + await kernel.context.trigger('test:event', { foo: 'bar' }); + + expect(receivedData).toEqual({ foo: 'bar' }); + + await kernel.shutdown(); + }); +}); +``` + +## Best Practices + +1. **Use before* for validation** — Abort operations early +2. **Use after* for side effects** — Notifications, logging, external API calls +3. **Keep hooks fast** — Especially before* hooks +4. **Use try/catch in after* hooks** — Don't let one failure cascade +5. **Use descriptive hook names** — Follow `{namespace}:{event-name}` convention +6. **Document custom hooks** — What they do, what arguments they pass +7. **Don't mutate arguments** — Except for `record` in before* hooks +8. **Test hook handlers** — Verify they execute and handle errors +9. **Limit hook count** — Too many hooks slow down operations +10. **Use specific object names** — Don't hook all objects unless necessary diff --git a/skills/objectstack-kernel/rules/plugin-lifecycle.md b/skills/objectstack-kernel/rules/plugin-lifecycle.md new file mode 100644 index 000000000..2132ee082 --- /dev/null +++ b/skills/objectstack-kernel/rules/plugin-lifecycle.md @@ -0,0 +1,379 @@ +# Plugin Lifecycle + +Complete guide for implementing plugin lifecycle phases in ObjectStack. + +## Three-Phase Lifecycle + +``` +kernel.bootstrap() +│ +├── Phase 1: INIT (register services) +│ ├── PluginA.init(ctx) → ctx.registerService('db', dbInstance) +│ ├── PluginB.init(ctx) → ctx.registerService('cache', cacheInstance) +│ └── PluginC.init(ctx) → ctx.registerService('http', httpServer) +│ │ +│ └── [Core fallback injection — auto-fills missing 'core' services] +│ +├── Phase 2: START (business logic) +│ ├── PluginA.start(ctx) → connect to database +│ ├── PluginB.start(ctx) → warm cache +│ └── PluginC.start(ctx) → bind routes, listen on port +│ +└── Phase 3: READY + └── ctx.trigger('kernel:ready') + └── All hook handlers execute + +kernel.shutdown() +│ +├── ctx.trigger('kernel:shutdown') +├── PluginC.destroy() → close server +├── PluginB.destroy() → flush cache +└── PluginA.destroy() → disconnect DB +``` + +## Plugin Interface + +```typescript +import type { Plugin, PluginContext } from '@objectstack/core'; + +export interface Plugin { + /** Unique name (reverse domain recommended) */ + name: string; + + /** Semantic version */ + version?: string; + + /** Plugin type */ + type?: string; // 'standard' | 'ui' | 'driver' | 'server' | 'app' | 'theme' | 'agent' + + /** Plugins that must init before this one */ + dependencies?: string[]; + + /** Phase 1: Register services — called during kernel init */ + init(ctx: PluginContext): Promise | void; + + /** Phase 2: Execute business logic — called after ALL plugins init */ + start?(ctx: PluginContext): Promise | void; + + /** Phase 3: Cleanup — called during kernel shutdown */ + destroy?(): Promise | void; +} +``` + +## Key Rules + +1. **`init()` is required** — This is where you register services +2. **`start()` is optional** — Only needed if your plugin has active behavior +3. **`destroy()` is optional** — Only needed if you hold resources to release +4. **Plugins init in dependency order** — Topological sort on `dependencies` +5. **Plugins destroy in reverse order** — LIFO cleanup +6. **Each phase completes for ALL plugins before the next phase begins** + +## Phase 1: init() — Service Registration + +**Purpose:** Register services in the DI container. + +**When to use:** +- Register database connections +- Register cache instances +- Register HTTP servers +- Register hook handlers +- Register factories + +**Do NOT:** +- Connect to databases (do in `start()`) +- Listen on ports (do in `start()`) +- Make external API calls + +### Example + +```typescript +async init(ctx: PluginContext) { + // Register a service + const pool = createPool({ /* config */ }); + ctx.registerService('db-pool', pool); + + // Register hook handlers + ctx.hook('kernel:ready', async () => { + ctx.logger.info('System ready'); + }); + + // Register data hooks + ctx.hook('data:beforeInsert', async (objectName, record) => { + if (objectName === 'task') { + record.created_at = new Date().toISOString(); + } + }); + + ctx.logger.info('Plugin initialized'); +} +``` + +## Phase 2: start() — Active Behavior + +**Purpose:** Execute business logic that requires all services to be available. + +**When to use:** +- Connect to databases +- Listen on HTTP ports +- Start background workers +- Warm caches +- Register routes + +**Safe to:** +- Call `ctx.getService()` — all services are registered +- Trigger events via `ctx.trigger()` +- Make external API calls + +### Example + +```typescript +async start(ctx: PluginContext) { + // All services are now available + const pool = ctx.getService('db-pool'); + await pool.connect(); + + const server = ctx.getService('http-server'); + await server.listen(3000); + + ctx.logger.info('Plugin started'); +} +``` + +## Phase 3: destroy() — Cleanup + +**Purpose:** Release resources held by the plugin. + +**When to use:** +- Close database connections +- Stop HTTP servers +- Flush caches +- Cancel background workers +- Release file handles + +**Runs in reverse order** — Last plugin to start is first to destroy. + +### Example + +```typescript +async destroy() { + if (this.pool) { + await this.pool.close(); + } + + if (this.server) { + await this.server.close(); + } + + console.log('Plugin destroyed'); +} +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Connecting in init() + +```typescript +async init(ctx: PluginContext) { + const pool = createPool({ /* config */ }); + await pool.connect(); // ❌ Don't connect in init() + ctx.registerService('db-pool', pool); +} +``` + +### ✅ Correct — Connecting in start() + +```typescript +async init(ctx: PluginContext) { + const pool = createPool({ /* config */ }); + ctx.registerService('db-pool', pool); // ✅ Just register +} + +async start(ctx: PluginContext) { + const pool = ctx.getService('db-pool'); + await pool.connect(); // ✅ Connect in start() +} +``` + +### ❌ Incorrect — Using getService() in init() + +```typescript +async init(ctx: PluginContext) { + const db = ctx.getService('db-pool'); // ❌ May not exist yet + ctx.registerService('cache', new Cache(db)); +} +``` + +### ✅ Correct — Using getService() in start() + +```typescript +async init(ctx: PluginContext) { + ctx.registerService('cache', null); // ✅ Register placeholder +} + +async start(ctx: PluginContext) { + const db = ctx.getService('db-pool'); // ✅ Safe — all services registered + const cache = new Cache(db); + ctx.replaceService('cache', cache); +} +``` + +### ❌ Incorrect — Missing destroy() + +```typescript +// Plugin opens file handles, database connections, but no destroy() +async start(ctx: PluginContext) { + this.db = await connectDatabase(); + this.fileHandle = fs.openSync('/tmp/data.log'); + // ❌ No cleanup — resources leak +} +``` + +### ✅ Correct — Implementing destroy() + +```typescript +async start(ctx: PluginContext) { + this.db = await connectDatabase(); + this.fileHandle = fs.openSync('/tmp/data.log'); +} + +async destroy() { + if (this.db) { + await this.db.close(); // ✅ Close connection + } + if (this.fileHandle) { + fs.closeSync(this.fileHandle); // ✅ Close file + } +} +``` + +## Dependency Management + +Declare dependencies to control initialization order: + +```typescript +const MyPlugin: Plugin = { + name: 'com.example.analytics', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], // Must init first + + async init(ctx) { + // Safe to call — ObjectQL is guaranteed to be initialized + const engine = ctx.getService('objectql'); + ctx.registerService('analytics', new AnalyticsService(engine)); + }, +}; +``` + +The kernel performs **topological sort** on the dependency graph. If circular dependencies are detected, ObjectKernel logs a warning (LiteKernel throws). + +## Complete Plugin Example + +```typescript +// packages/plugins/plugin-audit/src/plugin.ts +import type { Plugin, PluginContext } from '@objectstack/core'; + +interface AuditEntry { + timestamp: string; + operation: string; + object: string; + recordId?: string; +} + +class AuditService { + private log: AuditEntry[] = []; + + record(entry: AuditEntry) { + this.log.push(entry); + } + + getLog(): AuditEntry[] { + return [...this.log]; + } +} + +const AuditPlugin: Plugin = { + name: 'com.example.audit', + version: '1.0.0', + type: 'plugin', + dependencies: ['com.objectstack.engine.objectql'], + + // Phase 1: Register service and hooks + async init(ctx: PluginContext) { + const auditService = new AuditService(); + ctx.registerService('audit', auditService); + + ctx.hook('data:afterInsert', async (objectName, _record, result) => { + auditService.record({ + timestamp: new Date().toISOString(), + operation: 'insert', + object: objectName, + recordId: result?.id, + }); + }); + + ctx.logger.info('Audit plugin initialized'); + }, + + // Phase 2: Log that audit is active + async start(ctx: PluginContext) { + ctx.logger.info('Audit logging active'); + }, + + // Phase 3: Flush remaining entries (if using external storage) + async destroy() { + // Cleanup if needed + }, +}; + +export default AuditPlugin; +``` + +## Best Practices + +1. **Keep init() fast** — Only register services, don't do heavy work +2. **Use start() for connections** — Database, network, external services +3. **Always implement destroy()** — Release resources properly +4. **Declare dependencies explicitly** — Don't assume service availability +5. **Use try/catch in destroy()** — Cleanup should never throw +6. **Check service availability** — Use try/catch or hasService() for optional services +7. **Use ctx.logger** — Don't use console.log directly +8. **Avoid circular dependencies** — Design for linear dependency graph +9. **Version your plugin** — Use semantic versioning +10. **Use reverse domain names** — e.g., `com.example.plugin-name` + +## Testing Lifecycle + +```typescript +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import MyPlugin from './plugin'; + +describe('MyPlugin Lifecycle', () => { + it('registers service in init phase', async () => { + const kernel = new LiteKernel({ logger: { level: 'silent' } }); + kernel.use(MyPlugin); + await kernel.bootstrap(); + + const service = kernel.getService('my-service'); + expect(service).toBeDefined(); + + await kernel.shutdown(); + }); + + it('cleans up in destroy phase', async () => { + const kernel = new LiteKernel(); + kernel.use(MyPlugin); + await kernel.bootstrap(); + + // Verify resource is created + const service = kernel.getService('my-service'); + expect(service.isConnected()).toBe(true); + + await kernel.shutdown(); + + // Verify resource is cleaned up + expect(service.isConnected()).toBe(false); + }); +}); +``` diff --git a/skills/objectstack-kernel/rules/service-registry.md b/skills/objectstack-kernel/rules/service-registry.md new file mode 100644 index 000000000..7f0a7149f --- /dev/null +++ b/skills/objectstack-kernel/rules/service-registry.md @@ -0,0 +1,301 @@ +# Service Registry + +Guide for registering and consuming services via the kernel DI container. + +## Service Registry API + +The `PluginContext` provides three core methods: + +```typescript +// Register a service +ctx.registerService(name: string, instance: any): void; + +// Get a service (throws if not found) +ctx.getService(name: string): T; + +// Replace an existing service +ctx.replaceService(name: string, instance: any): void; + +// Get all services +ctx.getServices(): Map; +``` + +## Registration Patterns + +### Direct Registration + +Pass an already-created instance: + +```typescript +async init(ctx: PluginContext) { + const config = { apiKey: process.env.API_KEY }; + ctx.registerService('config', config); +} +``` + +**Best for:** +- Simple config objects +- Pre-existing instances +- No lazy initialization needed + +### Factory Registration (ObjectKernel Only) + +Let the kernel manage creation and lifecycle: + +```typescript +import { ServiceLifecycle } from '@objectstack/core'; + +async init(ctx: PluginContext) { + const kernel = ctx.getKernel(); + + kernel.registerServiceFactory( + 'db-pool', + (ctx) => createPool({ connectionString: process.env.DATABASE_URL }), + ServiceLifecycle.SINGLETON, + ); +} +``` + +**Best for:** +- Lazy initialization (created on first use) +- Lifecycle management +- Dependency injection between services + +## Service Lifecycles (ObjectKernel Only) + +| Lifecycle | Behavior | Use Case | +|:----------|:---------|:---------| +| `SINGLETON` | One instance shared app-wide | Database connections, caches | +| `TRANSIENT` | New instance per `getService()` call | Stateless utilities, formatters | +| `SCOPED` | One instance per scope (e.g., per request) | Request-scoped contexts, transactions | + +### Singleton Factory + +```typescript +kernel.registerServiceFactory( + 'db-pool', + (ctx) => createPool({ connectionString: process.env.DATABASE_URL }), + ServiceLifecycle.SINGLETON, +); +``` + +### Transient Factory + +```typescript +kernel.registerServiceFactory( + 'request-logger', + (ctx) => new RequestLogger(ctx.logger), + ServiceLifecycle.TRANSIENT, +); +``` + +### Scoped Factory + +```typescript +kernel.registerServiceFactory( + 'unit-of-work', + (ctx) => new UnitOfWork(ctx.getService('db-pool')), + ServiceLifecycle.SCOPED, + ['db-pool'], // Dependencies — resolved before factory executes +); +``` + +## Service Consumption + +### Basic Usage + +```typescript +async start(ctx: PluginContext) { + const db = ctx.getService('objectql'); + const cache = ctx.getService('cache'); + + // Use services + const result = await db.object('account').find(); + await cache.set('accounts', result); +} +``` + +### Optional Services + +Check availability before calling: + +```typescript +async start(ctx: PluginContext) { + try { + const realtime = ctx.getService('realtime'); + realtime.publish('my-event', data); + } catch { + ctx.logger.debug('Realtime service not available — skipping'); + } +} +``` + +### Service Replacement + +Wrap an existing service with instrumentation: + +```typescript +async start(ctx: PluginContext) { + const existingCache = ctx.getService('cache'); + const instrumentedCache = new InstrumentedCache(existingCache); + ctx.replaceService('cache', instrumentedCache); +} +``` + +## Well-Known Service Keys + +| Service Key | Plugin Name | Package | +|:------------|:------------|:--------| +| `objectql` | `com.objectstack.engine.objectql` | `@objectstack/objectql` | +| `driver.*` | `com.objectstack.driver.*` | `@objectstack/driver-*` | +| `auth` | `com.objectstack.auth` | `@objectstack/plugin-auth` | +| `rest` | `com.objectstack.rest` | `@objectstack/rest` | +| `metadata` | `com.objectstack.metadata` | `@objectstack/metadata` | +| `realtime` | `com.objectstack.realtime` | `@objectstack/service-realtime` | +| `cache` | `com.objectstack.cache` | `@objectstack/service-cache` | + +## Core Fallback Injection + +ObjectKernel auto-injects in-memory fallbacks for `core`-criticality services not registered by any plugin during Phase 1. + +``` +Phase 1: init() completes for all plugins + ↓ +Kernel checks ServiceRequirementDef: + 'metadata' → core → auto-inject InMemoryMetadataService if missing + 'cache' → core → auto-inject InMemoryCache if missing + 'queue' → core → auto-inject InMemoryQueue if missing + 'objectql' → required → ERROR if missing (no fallback) + 'realtime' → optional → skip, plugins should check availability + ↓ +Phase 2: start() begins — all core services available +``` + +### Service Criticality Levels + +| Level | Behavior | +|:------|:---------| +| `required` | Kernel throws if missing — system cannot start | +| `core` | Auto-injected in-memory fallback if no plugin provides it | +| `optional` | Silently skipped — plugins must check before use | + +## Incorrect vs Correct + +### ❌ Incorrect — Getting Service in init() + +```typescript +async init(ctx: PluginContext) { + const db = ctx.getService('objectql'); // ❌ May not exist yet + ctx.registerService('analytics', new Analytics(db)); +} +``` + +### ✅ Correct — Getting Service in start() + +```typescript +async init(ctx: PluginContext) { + // Just register placeholder or factory + ctx.registerService('analytics', null); +} + +async start(ctx: PluginContext) { + const db = ctx.getService('objectql'); // ✅ Safe — all services registered + const analytics = new Analytics(db); + ctx.replaceService('analytics', analytics); +} +``` + +### ❌ Incorrect — No Error Handling for Optional Service + +```typescript +async start(ctx: PluginContext) { + const realtime = ctx.getService('realtime'); // ❌ Throws if not available + realtime.publish('event', data); +} +``` + +### ✅ Correct — Error Handling for Optional Service + +```typescript +async start(ctx: PluginContext) { + try { + const realtime = ctx.getService('realtime'); + realtime.publish('event', data); + } catch { + ctx.logger.debug('Realtime service not available'); // ✅ Graceful fallback + } +} +``` + +### ❌ Incorrect — Duplicate Registration + +```typescript +async init(ctx: PluginContext) { + ctx.registerService('cache', new MemoryCache()); + ctx.registerService('cache', new RedisCache()); // ❌ Overwrites silently +} +``` + +### ✅ Correct — Use replaceService() for Updates + +```typescript +async init(ctx: PluginContext) { + ctx.registerService('cache', new MemoryCache()); +} + +async start(ctx: PluginContext) { + const oldCache = ctx.getService('cache'); + ctx.replaceService('cache', new RedisCache(oldCache)); // ✅ Explicit replacement +} +``` + +## Service Naming Conventions + +1. **Use lowercase, hyphen-separated names** — e.g., `db-pool`, `request-logger` +2. **Use namespaces for multiple instances** — e.g., `driver.postgres`, `driver.mysql` +3. **Use descriptive names** — e.g., `auth-service` not `as` +4. **Avoid abbreviations** — e.g., `database` not `db` (unless well-known like `db-pool`) + +## Testing Service Registration + +```typescript +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import MyPlugin from './plugin'; + +describe('Service Registration', () => { + it('registers service in init phase', async () => { + const kernel = new LiteKernel(); + kernel.use(MyPlugin); + await kernel.bootstrap(); + + const service = kernel.getService('my-service'); + expect(service).toBeDefined(); + expect(service.name).toBe('MyService'); + + await kernel.shutdown(); + }); + + it('throws when service not found', async () => { + const kernel = new LiteKernel(); + await kernel.bootstrap(); + + expect(() => kernel.getService('non-existent')).toThrow(); + + await kernel.shutdown(); + }); +}); +``` + +## Best Practices + +1. **Register in init()** — All service registration in Phase 1 +2. **Consume in start()** — Use getService() only in Phase 2 +3. **Use try/catch for optional services** — Don't assume availability +4. **Use descriptive service keys** — Clear, namespaced names +5. **Declare dependencies** — Let kernel handle initialization order +6. **Use factories for lazy init** — Defer expensive creation +7. **Use scoped services for requests** — Request-specific contexts +8. **Don't register null** — Register a real instance or factory +9. **Use replaceService() explicitly** — Don't re-register +10. **Document your services** — What they do, what they depend on diff --git a/skills/objectstack-quickstart/evals/README.md b/skills/objectstack-quickstart/evals/README.md new file mode 100644 index 000000000..d6e9bf631 --- /dev/null +++ b/skills/objectstack-quickstart/evals/README.md @@ -0,0 +1,46 @@ +# Evaluation Tests (evals/) + +This directory is reserved for future skill evaluation tests. + +## Purpose + +Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. + +## Structure + +When implemented, evals will follow this structure: + +``` +evals/ +├── naming/ +│ ├── test-object-names.md +│ ├── test-field-keys.md +│ └── test-option-values.md +├── relationships/ +│ ├── test-lookup-vs-master-detail.md +│ └── test-junction-patterns.md +├── validation/ +│ ├── test-script-inversion.md +│ └── test-state-machine.md +└── ... +``` + +## Format + +Each eval file will contain: +1. **Scenario** — Description of the task +2. **Expected Output** — Correct implementation +3. **Common Mistakes** — Incorrect patterns to avoid +4. **Validation Criteria** — How to score the output + +## Status + +⚠️ **Not yet implemented** — This is a placeholder for future development. + +## Contributing + +When adding evals: +1. Each eval should test a single, specific rule or pattern +2. Include both positive (correct) and negative (incorrect) examples +3. Reference the corresponding rule file in `rules/` +4. Use realistic scenarios from actual ObjectStack projects diff --git a/skills/objectstack-quickstart/rules/bootstrap-patterns.md b/skills/objectstack-quickstart/rules/bootstrap-patterns.md new file mode 100644 index 000000000..e52244575 --- /dev/null +++ b/skills/objectstack-quickstart/rules/bootstrap-patterns.md @@ -0,0 +1,80 @@ +# Project Bootstrap Patterns + +Guide for bootstrapping ObjectStack projects with defineStack(). + +## Basic Stack Configuration + +```typescript +import { defineStack } from '@objectstack/spec'; +import { DriverPlugin } from '@objectstack/runtime'; +import { TursoDriver } from '@objectstack/driver-turso'; + +export default defineStack({ + manifest: { + name: 'my-crm', + version: '1.0.0', + description: 'Customer relationship management system', + }, + driver: new DriverPlugin( + new TursoDriver({ + url: process.env.DATABASE_URL!, + authToken: process.env.DATABASE_AUTH_TOKEN!, + }) + ), + objects: [ + /* ... */ + ], +}); +``` + +## Driver Selection + +| Driver | Use Case | +|:-------|:---------| +| `InMemoryDriver` | Development, testing | +| `SQLiteDriver` | Local development, small deployments | +| `TursoDriver` | Production (edge database) | +| `PostgreSQLDriver` | Production (full-featured) | + +## Adapter Selection + +| Adapter | Framework | +|:--------|:----------| +| `@objectstack/adapter-express` | Express.js | +| `@objectstack/adapter-fastify` | Fastify | +| `@objectstack/adapter-hono` | Hono | +| `@objectstack/adapter-nextjs` | Next.js | + +## Incorrect vs Correct + +### ❌ Incorrect — Missing Driver + +```typescript +export default defineStack({ + manifest: { /* ... */ }, + // ❌ No driver specified + objects: [/* ... */], +}); +``` + +### ✅ Correct — Driver Configured + +```typescript +export default defineStack({ + manifest: { /* ... */ }, + driver: new DriverPlugin(new InMemoryDriver()), // ✅ Driver specified + objects: [/* ... */], +}); +``` + +## Best Practices + +1. **Choose appropriate driver** — Match to deployment environment +2. **Use environment variables** — Don't hardcode credentials +3. **Configure logging** — Set appropriate log level +4. **Enable features** — trackHistory, feeds, activities as needed +5. **Organize objects** — Group by domain/module + +--- + +See parent skill for complete documentation: [../SKILL.md](../SKILL.md) diff --git a/skills/objectstack-ui/evals/README.md b/skills/objectstack-ui/evals/README.md new file mode 100644 index 000000000..d6e9bf631 --- /dev/null +++ b/skills/objectstack-ui/evals/README.md @@ -0,0 +1,46 @@ +# Evaluation Tests (evals/) + +This directory is reserved for future skill evaluation tests. + +## Purpose + +Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. + +## Structure + +When implemented, evals will follow this structure: + +``` +evals/ +├── naming/ +│ ├── test-object-names.md +│ ├── test-field-keys.md +│ └── test-option-values.md +├── relationships/ +│ ├── test-lookup-vs-master-detail.md +│ └── test-junction-patterns.md +├── validation/ +│ ├── test-script-inversion.md +│ └── test-state-machine.md +└── ... +``` + +## Format + +Each eval file will contain: +1. **Scenario** — Description of the task +2. **Expected Output** — Correct implementation +3. **Common Mistakes** — Incorrect patterns to avoid +4. **Validation Criteria** — How to score the output + +## Status + +⚠️ **Not yet implemented** — This is a placeholder for future development. + +## Contributing + +When adding evals: +1. Each eval should test a single, specific rule or pattern +2. Include both positive (correct) and negative (incorrect) examples +3. Reference the corresponding rule file in `rules/` +4. Use realistic scenarios from actual ObjectStack projects diff --git a/skills/objectstack-ui/rules/view-types.md b/skills/objectstack-ui/rules/view-types.md new file mode 100644 index 000000000..ad625aa38 --- /dev/null +++ b/skills/objectstack-ui/rules/view-types.md @@ -0,0 +1,73 @@ +# View Types and Patterns + +Comprehensive guide for designing UI views in ObjectStack. + +## View Types + +ObjectStack supports multiple view types for different data presentation needs: + +- **Grid** — Tabular data display with sorting, filtering, pagination +- **Kanban** — Card-based workflow visualization (by status/stage) +- **Calendar** — Date-based event and task scheduling +- **Gantt** — Timeline/project planning visualization +- **Form** — Create/edit record interface +- **Detail** — Single record display with related lists +- **Dashboard** — Multiple components and widgets + +## Common Patterns + +### Grid View Configuration + +```typescript +{ + type: 'grid', + object: 'account', + columns: ['name', 'industry', 'annual_revenue', 'owner'], + filters: { status: 'active' }, + sort: [{ field: 'created_at', order: 'desc' }], +} +``` + +### Kanban View Configuration + +```typescript +{ + type: 'kanban', + object: 'opportunity', + groupBy: 'stage', + cardFields: ['name', 'amount', 'close_date'], +} +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Missing Required Fields + +```typescript +{ + type: 'grid', // ❌ No object specified + columns: ['name'], +} +``` + +### ✅ Correct — Complete View Definition + +```typescript +{ + type: 'grid', + object: 'account', // ✅ Object specified + columns: ['name', 'industry'], +} +``` + +## Best Practices + +1. **Limit columns in grid views** — 5-7 columns max for readability +2. **Use default filters** — Pre-filter to relevant records +3. **Choose appropriate view type** — Match view to data structure +4. **Configure search** — Enable search on key fields +5. **Set pagination limits** — Balance performance and UX + +--- + +See parent skill for complete documentation: [../SKILL.md](../SKILL.md)