From d762194f6c4cf6677cdb2988f6aee72ef3fcc36e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 12:44:25 +0000 Subject: [PATCH 1/2] docs(services): five published READMEs document the real entry point, not a `.configure()` that never existed (#9532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `service-analytics`, `service-automation`, `service-cache`, `service-i18n` and `service-job` each told a reader to import a `Service…` class from its own package and call a static `.configure({...})` on it. Neither has ever existed: no class in this repo exposes a static `configure`, and none of those five identifiers is exported by anything. All five READMEs are in their packages' `files` arrays with `private` unset, so they are the pages npm renders. Each README is rewritten against the package's BUILT type surface — the entry point is a kernel plugin constructed with `new` in every case — and each package's entry is deleted from `scripts/published-readme-exports.baseline.json` in the same commit (that baseline is reconciled in both directions, so a stale entry fails too): 16 entries -> 10. A name swap alone would not have gone green, which is the point of the gate landed in #9546: substituting the genuine class while keeping `.configure(...)` turns the import finding into a call-site finding rather than into silence. Also removed as fabricated: nine analytics REST endpoints of which none exists, a five-endpoint automation REST list matching no mounted route, fourteen `ICacheService` methods on a six-member contract, an i18n dialect with namespaces/plurals/formatters over a synchronous `t(key, locale, params?)`, and ten `IJobService` methods on a three-required-member contract. Two capability claims are corrected rather than deleted, because the source decides: `RedisCacheAdapter` throws from every method and `adapter: 'redis'` throws at init, and `JobServicePlugin`'s `adapter: 'interval'` stores cron registrations that never fire. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --- .../service-readmes-document-real-exports.md | 76 +++ packages/services/service-analytics/README.md | 453 +++++------------- .../services/service-automation/README.md | 194 +++++--- packages/services/service-cache/README.md | 311 ++++-------- packages/services/service-i18n/README.md | 419 +++++----------- packages/services/service-job/README.md | 424 +++++----------- .../published-readme-exports.baseline.json | 24 - 7 files changed, 652 insertions(+), 1249 deletions(-) create mode 100644 .changeset/service-readmes-document-real-exports.md diff --git a/.changeset/service-readmes-document-real-exports.md b/.changeset/service-readmes-document-real-exports.md new file mode 100644 index 0000000000..99a43a7e71 --- /dev/null +++ b/.changeset/service-readmes-document-real-exports.md @@ -0,0 +1,76 @@ +--- +"@objectstack/service-analytics": patch +"@objectstack/service-automation": patch +"@objectstack/service-cache": patch +"@objectstack/service-i18n": patch +"@objectstack/service-job": patch +--- + +docs: five published service READMEs stop documenting an API that does not exist (#9532) + +A version bump is the point, not a side effect: these five READMEs are in their +packages' `files` arrays with `private` unset, so they are the pages npm renders — +and a docs-only fix with no bump never reaches npm at all. + +Each of the five told a reader to an import of a `Service…` class from its own package +and call a static `.configure({...})` on it. Neither has ever existed: no class in +this repo exposes a static `configure`, and none of `ServiceAnalytics`, +`ServiceAutomation`, `ServiceCache`, `ServiceI18n` or `ServiceJob` is exported by +anything. A reader following any of them wrote code that could not compile. The real +entry point in every case is a kernel plugin constructed with `new`: +`AnalyticsServicePlugin`, `AutomationServicePlugin`, `CacheServicePlugin`, +`I18nServicePlugin`, `JobServicePlugin`. + +⛔ A name swap alone would not have been enough, and the gate landed in #9546 is what +proves it: substituting the genuine class while keeping `.configure(...)` turns the +import finding into a call-site finding rather than into silence. Each README is +rewritten against the package's built type surface, and each package's entry is +deleted from `scripts/published-readme-exports.baseline.json` in the same change +(the baseline is reconciled in both directions, so a stale entry fails too). + +What was removed as fabricated, beyond the entry point: + +- **service-analytics** — a nine-endpoint REST surface (`/analytics/count`, `/sum`, + `/avg`, `/min`, `/max`, `/group-by`, `/time-series`, `/metrics`, `/metrics/:name`) + of which none exists; the real surface is `POST /analytics/query`, + `GET /analytics/meta`, `POST /analytics/sql` and `POST /analytics/dataset/query`. + Also removed: `defineMetric`, `getMetric`, `compare`, `funnel`, + `executeDashboard`, `invalidateCache`, and an `AnalyticsServiceConfig` block whose + four keys (`defaultDriver`, `enableCaching`, `cacheTTL`, `maxMemoryResults`) are + none of the real ones. +- **service-automation** — `executeFlow`/`getFlow`/`listFlows`/`getFlowHistory`/ + `registerTrigger` as the contract (the real contract is `execute(flowName, context?)` + plus `listFlows()` and a set of optional members), and a five-endpoint REST list that + matches no mounted route. The flow-authoring half of that README was already accurate + and is kept. +- **service-cache** — `mget`/`mset`/`del`/`delPattern`/`namespace`/`ttl`/`expire`/ + `persist`/`incr`/`incrby`/`decr`/`getOrSet`/`invalidateTag`/`resetStats`, none of + which exist; `ICacheService` has six members. `CacheStats.keys`/`hitRate` corrected to + `keyCount` (there is no `hitRate`), and `set(key, value, { ttl })` corrected to the + real positional `set(key, value, ttl?)` in seconds. +- **service-i18n** — an `await i18n.t('ns:key')` dialect with namespaces, plural + suffixes, `context`, `returnObjects`, `setLocale`/`getLocale`, `formatDate`/ + `formatNumber`/`formatRelative`, `addLocale`/`removeLocale`/`reload`, `getCoverage`/ + `getMissingKeys`, and a `{{lng}}/{{ns}}` file layout. The real `t()` is synchronous + and takes the locale positionally — `t(key, locale, params?)` — over one + `{locale}.json` file per locale. The `POST /i18n/translate` endpoint does not exist. +- **service-job** — `scheduleInterval`/`scheduleOnce`/`getJob`/`stopJob`/`resumeJob`/ + `deleteJob`/`runNow`/`getJobHistory`/`clearHistory`/`getLastExecution`, and a + `schedule({ name, schedule, handler })` options-object call. The real `schedule` is + positional — `schedule(name, schedule, handler, options?)` — and returns `void`. + Retry defaults corrected to the enforced ones (`maxRetries: 0`, + `backoffMultiplier: 1`). + +Two capability claims are corrected rather than deleted, because the source is what +decides: + +- **service-cache** advertised Redis as production support. `RedisCacheAdapter` throws + `RedisCacheAdapter not yet implemented` from every method, and + `new CacheServicePlugin({ adapter: 'redis' })` throws during `init` rather than + falling back to memory. The README now says so at the top and points at registering + a custom `ICacheService` under the slot instead. +- **service-job**'s `adapter: 'interval'` stores cron registrations that never fire. + That is now stated in the adapter table rather than left for a reader to discover. + +No compliance claim (SOC 2 / HIPAA / GDPR or similar) was found in any of the five — +the shape that raised `plugin-audit`'s severity in #9517 is absent here. diff --git a/packages/services/service-analytics/README.md b/packages/services/service-analytics/README.md index 6d3d783353..8a5622c107 100644 --- a/packages/services/service-analytics/README.md +++ b/packages/services/service-analytics/README.md @@ -1,18 +1,10 @@ # @objectstack/service-analytics -Analytics Service for ObjectStack — implements `IAnalyticsService` with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory). +The shipped provider for the kernel's **`analytics`** service slot — a cube/dataset +query engine implementing `IAnalyticsService` over a priority-ordered strategy chain. -## Features - -- **Multi-Driver Architecture**: Choose the right execution strategy for your analytics queries - - **NativeSQL**: Direct SQL execution for maximum performance on large datasets - - **ObjectQL**: Leverage ObjectStack's query engine for metadata-aware analytics - - **InMemory**: Fast aggregations on small datasets without database round-trips -- **Aggregation Functions**: SUM, COUNT, AVG, MIN, MAX, GROUP BY, HAVING -- **Time Series Analysis**: Time-based aggregations and grouping -- **Custom Metrics**: Define and track custom business metrics -- **Dashboard Integration**: Auto-generated REST endpoints for visualization -- **Type-Safe**: Full TypeScript support with inferred result types +Slot criticality: `optional` (`ServiceRequirementDef` in `@objectstack/spec/system`). +Without it, `/api/v1/analytics/*` answers 404 rather than degrading. ## Installation @@ -20,370 +12,177 @@ Analytics Service for ObjectStack — implements `IAnalyticsService` with multi- pnpm add @objectstack/service-analytics ``` -## Basic Usage - -```typescript -import { defineStack } from '@objectstack/spec'; -import { ServiceAnalytics } from '@objectstack/service-analytics'; - -const stack = defineStack({ - services: [ - ServiceAnalytics.configure({ - defaultDriver: 'objectql', // or 'sql', 'memory' - enableCaching: true, - }), - ], -}); -``` +## Usage -## Configuration +The entry point is the kernel plugin `AnalyticsServicePlugin`. Construct it and hand +it to the kernel; it registers the service under `'analytics'` during `init`. ```typescript -interface AnalyticsServiceConfig { - /** Default execution driver */ - defaultDriver?: 'sql' | 'objectql' | 'memory'; - - /** Enable query result caching */ - enableCaching?: boolean; - - /** Cache TTL in seconds (default: 300) */ - cacheTTL?: number; - - /** Maximum result set size for in-memory driver */ - maxMemoryResults?: number; -} -``` +import { LiteKernel } from '@objectstack/core'; +import type { Cube } from '@objectstack/spec/data'; +import type { IAnalyticsService } from '@objectstack/spec/contracts'; +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; + +const ordersCube: Cube = { + name: 'orders', + title: 'Orders', + sql: 'orders', + measures: { + count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + total_amount: { name: 'total_amount', label: 'Total Amount', type: 'sum', sql: 'amount' }, + }, + dimensions: { + status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + }, +}; -## Service API +const kernel = new LiteKernel(); +kernel.use(new AnalyticsServicePlugin({ cubes: [ordersCube] })); +await kernel.bootstrap(); -```typescript -// Get analytics service from kernel const analytics = kernel.getService('analytics'); +const result = await analytics.query({ cube: 'orders', measures: ['orders.count'] }); ``` -### Basic Aggregations +`LiteKernel.use()` is synchronous; `ObjectKernel.use()` returns a promise — await it there. -```typescript -// Count records -const totalOrders = await analytics.count({ - object: 'order', - filters: [{ field: 'status', operator: 'eq', value: 'completed' }], -}); +## Plugin options -// Sum field values -const totalRevenue = await analytics.sum({ - object: 'order', - field: 'amount', - filters: [{ field: 'created_at', operator: 'gte', value: '2024-01-01' }], -}); +Every field of `AnalyticsServicePluginOptions` is optional. The plugin bridges the +host's engine into `AnalyticsServiceConfig`; anything left unset falls back to what +the plugin can auto-discover from the kernel. -// Calculate average -const avgOrderValue = await analytics.avg({ - object: 'order', - field: 'amount', -}); +| Option | Type | Default | Purpose | +|:---|:---|:---|:---| +| `cubes` | `Cube[]` | none | Cube definitions registered at init. | +| `queryCapabilities` | `(cubeName: string) => AnalyticsDriverCapabilities` | in-memory only | Which execution paths a cube's backing driver supports. | +| `executeRawSql` | `(objectName, sql, params) => Promise[]>` | auto-bridged to the ObjectQL engine | Enables `NativeSQLStrategy`. | +| `executeAggregate` | `(objectName, options) => Promise[]>` | auto-bridged to the ObjectQL engine | Enables `ObjectQLStrategy`. | +| `getReadScope` | `(objectName, context?) => FilterCondition \| null \| undefined \| Promise<…>` | auto-bridges to a registered `'security'` service exposing `getReadFilter` | Per-object tenant/RLS read scope (ADR-0021 D-C). | +| `getAllowedRelationships` | `(cubeName: string) => Set \| undefined` | supplied by compiled datasets | Join allowlist per cube. | +| `debug` | `boolean` | `false` | Server-side log verbosity only. | +| `debugSql` | `boolean` | development only (`NODE_ENV === 'development'`) | Echo the executed statement back to callers in `AnalyticsResult.sql`. | -// Find min/max -const highestOrder = await analytics.max({ - object: 'order', - field: 'amount', -}); -``` - -### Group By Aggregations +`debug` and `debugSql` are deliberately separate: raising log verbosity must never +widen what travels to a tenant. -```typescript -// Revenue by product category -const revenueByCategory = await analytics.groupBy({ - object: 'order_item', - groupBy: ['product.category'], - aggregations: [ - { function: 'sum', field: 'total', as: 'revenue' }, - { function: 'count', as: 'order_count' }, - ], -}); - -// Result format: -// [ -// { category: 'Electronics', revenue: 125000, order_count: 342 }, -// { category: 'Clothing', revenue: 98000, order_count: 567 }, -// ] -``` +## Service API -### Time Series Analytics +`IAnalyticsService` (from `@objectstack/spec/contracts`) declares four members — two +required, two optional: ```typescript -// Daily revenue for the past 30 days -const dailyRevenue = await analytics.timeSeries({ - object: 'order', - dateField: 'created_at', - interval: 'day', - aggregations: [ - { function: 'sum', field: 'amount', as: 'revenue' }, - { function: 'count', as: 'orders' }, - ], - filters: [ - { - field: 'created_at', - operator: 'gte', - value: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - }, - ], -}); +import type { IAnalyticsService } from '@objectstack/spec/contracts'; -// Result format: -// [ -// { date: '2024-01-01', revenue: 12500, orders: 45 }, -// { date: '2024-01-02', revenue: 15200, orders: 52 }, -// ] +// query(query, context?) -> Promise (required) +// getMeta(cubeName?) -> Promise (required) +// generateSql?(query, context?) -> Promise<{ sql, params }> (optional) +// queryDataset?(dataset, selection, context?, options?) (optional) ``` -### Custom Metrics +This package implements all four. Pass the caller's `ExecutionContext` as the second +argument: without it the per-object read scope resolves to no filter and the query +runs unscoped. -```typescript -// Define a metric -analytics.defineMetric({ - name: 'monthly_recurring_revenue', - description: 'MRR from active subscriptions', - calculation: { - object: 'subscription', - aggregation: 'sum', - field: 'amount', - filters: [{ field: 'status', operator: 'eq', value: 'active' }], - }, -}); - -// Query the metric -const mrr = await analytics.getMetric('monthly_recurring_revenue'); -``` +### AnalyticsQuery -## Multi-Driver Strategy +`AnalyticsQuery` is a **strict** schema (`AnalyticsQuerySchema`, `@objectstack/spec/data`) +with exactly these fields; `measures` is the only required one, and an undeclared key +is rejected rather than dropped. -### When to Use Each Driver +| Field | Type | Notes | +|:---|:---|:---| +| `cube` | `string?` | Optional when supplied by the request wrapper. | +| `measures` | `string[]` | Required. | +| `dimensions` | `string[]?` | | +| `where` | `FilterCondition?` | Canonical Query DSL filter — the same shape `find()` takes. | +| `timeDimensions` | `{ dimension, granularity?, dateRange? }[]?` | Also strict per item. | +| `order` | `Record?` | | +| `limit` | `number?` | | +| `offset` | `number?` | | +| `timezone` | `string?` | IANA name. No default — an absent timezone means the engine resolves it. | -#### NativeSQL Driver -**Best for**: Large datasets, complex joins, database-specific optimizations +There is no `filters` key and no `aggregations` key. `filters` is rejected at the REST +door with a 400 naming `where`; per-metric filtering lives on the cube metric's own +`filters`. ```typescript -const result = await analytics.query({ - driver: 'sql', - object: 'order', - aggregations: [{ function: 'sum', field: 'amount' }], - groupBy: ['customer_id'], - having: [{ field: 'sum_amount', operator: 'gt', value: 10000 }], +const revenueByStatus = await analytics.query({ + cube: 'orders', + measures: ['orders.total_amount'], + dimensions: ['orders.status'], + where: { is_active: true }, + order: { 'orders.total_amount': 'desc' }, + limit: 10, }); +// result.rows — Record[] +// result.fields — column metadata (name, type, label?, format?, currency?, percentScale?) ``` -**Advantages:** -- Direct SQL execution for maximum performance -- Leverages database indexes and query optimization -- Handles millions of records efficiently +## Strategy chain -**Limitations:** -- Bypasses ObjectStack metadata layer -- May miss field-level transformations -- Less portable across databases +`AnalyticsService` delegates to a priority-ordered chain; the first strategy whose +`canHandle` returns true serves the query. -#### ObjectQL Driver -**Best for**: Metadata-aware analytics, cross-object aggregations +| Priority | Strategy | Condition | +|:---:|:---|:---| +| 10 | `NativeSQLStrategy` | driver supports raw SQL (`executeRawSql`) | +| 20 | `ObjectQLStrategy` | driver supports aggregate AST (`executeAggregate`) | +| 30 | custom strategies, or the internal delegate added when `fallbackService` is set | injected by the host | -```typescript -const result = await analytics.query({ - driver: 'objectql', - object: 'opportunity', - aggregations: [ - { function: 'sum', field: 'amount' }, - { function: 'count' }, - ], - groupBy: ['account.industry'], -}); -``` +`InMemoryStrategy` is **not** built in — it ships from `@objectstack/driver-memory` and +is injected through `AnalyticsServiceConfig.strategies` (or `fallbackService`). -**Advantages:** -- Respects object/field metadata and permissions -- Handles formula fields and computed values -- Consistent with ObjectQL query behavior +## REST API -**Limitations:** -- Slightly slower than direct SQL -- Additional abstraction layer - -#### InMemory Driver -**Best for**: Small datasets, pre-filtered results, real-time dashboards - -```typescript -const result = await analytics.query({ - driver: 'memory', - object: 'task', - aggregations: [{ function: 'count' }], - groupBy: ['status'], -}); -``` - -**Advantages:** -- Zero database round-trips for cached data -- Instant results for small datasets -- Useful for client-side analytics - -**Limitations:** -- Limited to `maxMemoryResults` (default: 10,000) -- Requires data to be loaded into memory first - -## REST API Endpoints - -When used with `@objectstack/rest`: +Served by the runtime dispatcher's `/analytics` domain when this service occupies the +slot. These four routes are the whole surface: ``` -POST /api/v1/analytics/count # Count records -POST /api/v1/analytics/sum # Sum field values -POST /api/v1/analytics/avg # Calculate average -POST /api/v1/analytics/min # Find minimum -POST /api/v1/analytics/max # Find maximum -POST /api/v1/analytics/group-by # Group by aggregation -POST /api/v1/analytics/time-series # Time series analysis -GET /api/v1/analytics/metrics # List custom metrics -GET /api/v1/analytics/metrics/:name # Get metric value +POST /api/v1/analytics/query # execute an AnalyticsQuery +GET /api/v1/analytics/meta[?cube=] # cube metadata for discovery +POST /api/v1/analytics/sql # generate SQL without executing (dry-run) +POST /api/v1/analytics/dataset/query # run a dataset selection (ADR-0021) ``` -## Dashboard Integration +`POST /analytics/sql` answers 404 when the slot's occupant does not implement the +optional `generateSql`. -```typescript -// Define a dashboard with multiple metrics -const salesDashboard = { - title: 'Sales Dashboard', - metrics: [ - { - title: 'Total Revenue', - query: { - object: 'order', - aggregation: 'sum', - field: 'amount', - }, - }, - { - title: 'Revenue by Region', - query: { - object: 'order', - aggregations: [{ function: 'sum', field: 'amount', as: 'revenue' }], - groupBy: ['account.billing_region'], - }, - }, - ], -}; - -// Execute all dashboard queries -const dashboardData = await analytics.executeDashboard(salesDashboard); -``` - -## Advanced Features - -### Query Caching +## Exports ```typescript -// Enable caching for expensive queries -const result = await analytics.query({ - object: 'order', - aggregations: [{ function: 'sum', field: 'amount' }], - cache: { - enabled: true, - ttl: 600, // 10 minutes - }, -}); - -// Invalidate cache when data changes -analytics.invalidateCache('order'); +import { + AnalyticsService, AnalyticsServicePlugin, CubeRegistry, DatasetExecutor, + NativeSQLStrategy, ObjectQLStrategy, + compileDataset, compileScopedFilterToSql, + combineFilters, evaluateDerivedMeasures, fillEmptyGroups, mergeByDimensions, shiftRange, + createOrderLabelResolver, pickDisplayField, resolveDimensionLabels, withLabelFetchCache, +} from '@objectstack/service-analytics'; ``` -### Comparative Analytics +Types: `AnalyticsServiceConfig`, `AnalyticsServicePluginOptions`, `AnalyticsStrategy`, +`StrategyContext`, `AnalyticsDriverCapabilities`, `CompiledDataset`, +`DatasetCompileOptions`, `DatasetSelection`, `CompareTo`, `DerivedMeasureSpec`, +`RelationshipResolver`, `RelationshipTarget`, `DimensionLabelDeps`, `FieldMetaLite`, +`OrderLabelResolver`. -```typescript -// Compare current vs. previous period -const comparison = await analytics.compare({ - object: 'order', - aggregation: 'sum', - field: 'amount', - currentPeriod: { - start: '2024-01-01', - end: '2024-01-31', - }, - comparisonPeriod: { - start: '2023-12-01', - end: '2023-12-31', - }, -}); +## Advanced: constructing the service directly -// Result: -// { -// current: 125000, -// comparison: 110000, -// change: 15000, -// percentChange: 13.64 -// } -``` - -### Funnel Analysis +`AnalyticsService` is exported for hosts that wire their own kernel integration. +`AnalyticsServiceConfig` is the wider surface the plugin builds — it adds `logger`, +`strategies`, `fallbackService`, `coerceTemporalFilterValue`, +`coerceTemporalFilterColumn`, `isExternalObject`, `getObjectDatasource`, +`isRegisteredObject` and the dataset resolvers on top of the plugin options above. ```typescript -// Define a conversion funnel -const funnel = await analytics.funnel({ - steps: [ - { object: 'lead', stage: 'new' }, - { object: 'lead', stage: 'qualified' }, - { object: 'opportunity', stage: 'proposal' }, - { object: 'opportunity', stage: 'closed_won' }, - ], - dateRange: { - start: '2024-01-01', - end: '2024-01-31', - }, -}); +import { AnalyticsService, CubeRegistry } from '@objectstack/service-analytics'; -// Result: -// { -// steps: [ -// { stage: 'new', count: 1000, percentage: 100 }, -// { stage: 'qualified', count: 450, percentage: 45 }, -// { stage: 'proposal', count: 200, percentage: 20 }, -// { stage: 'closed_won', count: 75, percentage: 7.5 }, -// ], -// overallConversion: 0.075 -// } -``` - -## Contract Implementation +const registry = new CubeRegistry(); +registry.registerAll([ordersCube]); -Implements `IAnalyticsService` from `@objectstack/spec/contracts`: - -```typescript -interface IAnalyticsService { - count(options: CountOptions): Promise; - sum(options: AggregationOptions): Promise; - avg(options: AggregationOptions): Promise; - min(options: AggregationOptions): Promise; - max(options: AggregationOptions): Promise; - groupBy(options: GroupByOptions): Promise; - timeSeries(options: TimeSeriesOptions): Promise; - defineMetric(metric: MetricDefinition): void; - getMetric(name: string): Promise; -} +const service = new AnalyticsService({ cubes: [ordersCube] }); ``` -## Performance Optimization - -1. **Choose the Right Driver**: Use SQL for large datasets, InMemory for small -2. **Enable Caching**: Cache expensive queries with appropriate TTL -3. **Optimize Filters**: Filter early to reduce dataset size -4. **Use Indexes**: Ensure database indexes on frequently queried fields -5. **Batch Queries**: Execute multiple metrics in a single dashboard query - -## Best Practices - -1. **Driver Selection**: Start with ObjectQL, optimize to SQL if needed -2. **Metric Definitions**: Define reusable metrics for consistency -3. **Cache Strategy**: Cache expensive queries, invalidate on data changes -4. **Time Series**: Use appropriate intervals (hour/day/week/month) -5. **Group By**: Limit grouping dimensions to avoid explosion of result sets - ## License Apache-2.0. See [LICENSING.md](../../../LICENSING.md). @@ -391,5 +190,5 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md). ## See Also - [@objectstack/objectql](../../objectql/) -- [@objectstack/spec/contracts](../../spec/src/contracts/) +- [@objectstack/driver-memory](../../drivers/driver-memory/) — ships `InMemoryStrategy` - [Analytics Guide](/content/docs/data-modeling/analytics.mdx) diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index 87baf215bd..1f3cfa5db2 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -1,16 +1,9 @@ # @objectstack/service-automation -Automation Service for ObjectStack — implements `IAutomationService` with plugin-based DAG (Directed Acyclic Graph) flow execution engine. +The shipped provider for the kernel's **`automation`** service slot — a DAG flow +execution engine implementing `IAutomationService`. -## Features - -- **Flow Execution Engine**: Execute multi-step automation flows with conditional logic -- **DAG-based Architecture**: Flows are represented as directed acyclic graphs for parallel execution -- **Trigger System**: Launch flows automatically on record changes, schedule, or manual invocation -- **Variable Management**: Pass data between flow steps with type-safe variables -- **Error Handling**: Built-in retry logic, error branches, and rollback support -- **Visual Flow Builder**: Compatible with Studio's visual flow designer -- **Type-Safe**: Full TypeScript support with flow definition validation +Slot criticality: `optional` (`ServiceRequirementDef` in `@objectstack/spec/system`). ## Installation @@ -18,17 +11,47 @@ Automation Service for ObjectStack — implements `IAutomationService` with plug pnpm add @objectstack/service-automation ``` -## Basic Usage +## Usage + +The entry point is the kernel plugin `AutomationServicePlugin`. It seeds every +built-in node executor, so it is the only plugin an automation capability needs. ```typescript -import { defineStack, defineFlow } from '@objectstack/spec'; -import { ServiceAutomation } from '@objectstack/service-automation'; +import { LiteKernel } from '@objectstack/core'; +import type { IAutomationService } from '@objectstack/spec/contracts'; +import { AutomationServicePlugin } from '@objectstack/service-automation'; + +const kernel = new LiteKernel(); +kernel.use(new AutomationServicePlugin()); +await kernel.bootstrap(); -const stack = defineStack({ - services: [ServiceAutomation.configure()], +const automation = kernel.getService('automation'); +await automation.execute('escalate_high_priority_case', { + object: 'crm_case', + record: { id: 'case_1', priority: 'high' }, }); ``` +`LiteKernel.use()` is synchronous; `ObjectKernel.use()` returns a promise — await it there. + +### Plugin options + +Every field of `AutomationServicePluginOptions` is optional. + +| Option | Type | Default | Purpose | +|:---|:---|:---|:---| +| `debug` | `boolean` | `false` | Debug logging for flow execution. | +| `armRuntime` | `boolean` | `true` | Bring up the runtime, not just the engine. `false` installs built-in nodes and fires `automation:ready`, then stops before anything is armed — no flow pull, no connector materialization, no wait-timer re-arm. | +| `suspendedRunStore` | `'auto' \| 'memory'` | `'auto'` | `'auto'` persists suspended runs to `sys_automation_run` when an ObjectQL engine is available; `'memory'` never persists. | +| `maxLogSize` | `number` | `DEFAULT_MAX_EXECUTION_LOG_SIZE` (1000) | In-memory execution-log ring buffer size. | +| `runSummaryLog` | `RunSummaryLogLevel` | `'info'` | Level for the one-line-per-terminal-run summary. Turning it down changes narration only — the summary is still computed, returned and persisted. | +| `runHistoryMaxPerFlow` | `number` | `DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW` (100) | Per-flow cap on terminal run-history rows; `0` disables the cap. | +| `credentialResolver` | `CredentialResolver` | env-var resolver | Resolves a declarative connector's `auth.credentialRef` at boot. | +| `packageRoot` | `string` | `process.cwd()` | Root that relative file refs in connector entries resolve against; reads are confined to it. | + +The AGE half of run retention is declarative, not an option here: `sys_automation_run` +declares `retention: { maxAge: '30d', … }` and the platform LifecycleService enforces it. + ## Flow Types `type` declares how a flow starts: @@ -184,72 +207,95 @@ parsed it. ## Service API +`IAutomationService` (from `@objectstack/spec/contracts`) declares two required members +and a set of optional ones; this package implements them all. + ```typescript -// Get automation service -const automation = kernel.getService('automation'); +import type { IAutomationService } from '@objectstack/spec/contracts'; + +// required +// execute(flowName, context?) -> Promise +// listFlows() -> Promise +// optional +// registerFlow?(name, definition) -> void +// unregisterFlow?(name) -> void +// getFlow?(name) -> Promise +// toggleFlow?(name, enabled) -> Promise +// listRuns?(...) -> run history +// getRun?(runId) -> Promise +// resume?(runId, signal?) -> Promise +// listSuspendedRuns?() -> suspended-run summaries +// getSuspendedScreen?(runId) -> Promise +// getActionDescriptors?() -> ActionDescriptor[] +// getConnectorDescriptors?() -> ConnectorDescriptor[] +// getFlowRuntimeStates?() -> FlowRuntimeState[] +// canonicalizeStoredFlow?(name, definition) ``` -### Execute Flow +### Execute a flow + +`execute` takes the flow's **machine name** and an optional `AutomationContext` — it +does not take an options object, and there is no `inputs` key. ```typescript -// Execute a flow manually -const result = await automation.executeFlow({ - flowName: 'create_opportunity', - inputs: { - account_id: '123', - amount: 50000, - }, +const result = await automation.execute('escalate_high_priority_case', { + record: { id: 'case_1', priority: 'high' }, + object: 'crm_case', + event: 'on_update', + userId: 'usr_123', }); -// Check execution status -if (result.status === 'success') { - console.log('Flow completed:', result.outputs); +if (result.success) { + console.log('output:', result.output, 'in', result.durationMs, 'ms'); } else { - console.error('Flow failed:', result.error); + console.error('failed:', result.error, result.code); } ``` -### Flow Management +`AutomationResult` fields: `success`, `output?`, `error?`, `durationMs?`, `code?`, +`status?`, `runId?`, `screen?`, `successMessage?`, `errorMessage?`, `summary?`. The +machine-readable classification is `code` (not `errorCode`) — resume refusals such as +`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, plus the trigger-time +`FLOW_DISABLED` / `FLOW_NO_START_NODE`. -```typescript -// Get flow definition -const flow = await automation.getFlow('welcome_email'); +⚠️ `runAs` on `AutomationContext` is derived by the engine from the flow definition — +callers do not set it. -// List all flows -const flows = await automation.listFlows(); +### Register and inspect flows -// Get flow execution history -const history = await automation.getFlowHistory({ - flowName: 'daily_report', - limit: 100, -}); +```typescript +automation.registerFlow?.('escalate_high_priority_case', escalateCase); + +const names = await automation.listFlows(); // string[] of machine names +const parsed = await automation.getFlow?.('escalate_high_priority_case'); +await automation.toggleFlow?.('escalate_high_priority_case', false); ``` -### Trigger Management +`registerFlow` validates against the live action registry and rejects unknown `config` +keys. There is no `registerTrigger` method — a flow's trigger is declared on its `start` +node (`record_change`) or by its `type`, and arming happens at registration. -```typescript -// Register a custom trigger -automation.registerTrigger({ - name: 'on_payment_received', - description: 'Triggered when a payment is received', - async handler(context) { - // Trigger logic - return { - record: context.payment, - timestamp: new Date(), - }; - }, -}); -``` +## REST API -## REST API Endpoints +Served by the runtime dispatcher's `/automation` domain when this service occupies the +slot (paths shown with the `/api/v1` wire prefix): ``` -POST /api/v1/automation/flows/:name/execute # Execute flow -GET /api/v1/automation/flows # List flows -GET /api/v1/automation/flows/:name # Get flow definition -GET /api/v1/automation/flows/:name/history # Get execution history -POST /api/v1/automation/triggers/:name # Trigger a flow +GET /api/v1/automation # list flows +POST /api/v1/automation # create a flow +GET /api/v1/automation/actions # action descriptors +GET /api/v1/automation/connectors # connector descriptors +GET /api/v1/automation/_status # runtime status +GET /api/v1/automation/:name # get one flow +PUT /api/v1/automation/:name # update a flow +DELETE /api/v1/automation/:name # delete a flow +POST /api/v1/automation/:name/trigger # execute a flow +POST /api/v1/automation/:name/toggle # enable / disable +GET /api/v1/automation/:name/runs # list runs +GET /api/v1/automation/:name/runs/:runId # run detail +GET /api/v1/automation/:name/runs/:runId/screen # screen spec of a parked run +POST /api/v1/automation/:name/runs/:runId/resume # resume a parked run +POST /api/v1/automation/trigger/:name # legacy execute shape ``` ## Advanced Features @@ -370,20 +416,26 @@ The node resumes down its ordinary out-edges; there is no `nextSteps` key. - **Query Optimization**: Filter queries early to reduce data volume - **Async Execution**: Long-running flows execute asynchronously -## Contract Implementation - -Implements `IAutomationService` from `@objectstack/spec/contracts`: +## Exports ```typescript -interface IAutomationService { - executeFlow(options: FlowExecutionOptions): Promise; - getFlow(name: string): Promise; - listFlows(filter?: FlowFilter): Promise; - getFlowHistory(options: FlowHistoryOptions): Promise; - registerTrigger(trigger: TriggerDefinition): void; -} +import { + AutomationEngine, AutomationServicePlugin, createPackageFileLoader, + InMemorySuspendedRunStore, ObjectStoreSuspendedRunStore, SysAutomationRun, + installBuiltinNodes, registerLogicNodes, registerCrudNodes, + registerScreenNodes, registerHttpNodes, registerConnectorNodes, + resolveRunDataContext, stampSystemInsertOwner, UnscopedRunDataAccessError, + summarizeRun, formatRunSummaryLine, + DEFAULT_MAX_EXECUTION_LOG_SIZE, DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW, + MAX_PERSISTED_HISTORY_STEPS, +} from '@objectstack/service-automation'; ``` +`AutomationEngine` is the engine underneath the plugin, exported for hosts that build +their own kernel integration; `AutomationServicePlugin` is the entry point for everyone +else. The built-in node installers are functions, not plugins — the platform's +foundational nodes are built in, not installed. + ## License Apache-2.0. See [LICENSING.md](../../../LICENSING.md). diff --git a/packages/services/service-cache/README.md b/packages/services/service-cache/README.md index f6d87b5f04..680739ec9e 100644 --- a/packages/services/service-cache/README.md +++ b/packages/services/service-cache/README.md @@ -1,16 +1,17 @@ # @objectstack/service-cache -Cache Service for ObjectStack — implements `ICacheService` with in-memory and Redis adapters. +The shipped provider for the kernel's **`cache`** service slot — an in-memory +`ICacheService` implementation with metrics instrumentation. -## Features +Slot criticality: `core` (`ServiceRequirementDef` in `@objectstack/spec/system`): the +kernel warns and degrades if the slot is empty, it does not fail to start. -- **Multiple Adapters**: In-memory (development) and Redis (production) support -- **Type-Safe**: Full TypeScript support with generic value types -- **TTL Support**: Automatic expiration with time-to-live -- **Namespace Support**: Organize cache keys by namespace -- **Pattern Matching**: Delete keys by pattern (e.g., `user:*`) -- **Statistics**: Track hit/miss rates and memory usage -- **JSON Serialization**: Automatic serialization of complex objects +> ⚠️ **The Redis adapter is a skeleton, not a shipped capability.** +> `RedisCacheAdapter` throws `RedisCacheAdapter not yet implemented` from every method, +> and `new CacheServicePlugin({ adapter: 'redis' })` throws during `init` rather than +> falling back. The only working adapter today is `MemoryCacheAdapter`. For a shared +> cache, register your own `ICacheService` implementation under the slot (see +> [Custom implementations](#custom-implementations)). ## Installation @@ -18,275 +19,133 @@ Cache Service for ObjectStack — implements `ICacheService` with in-memory and pnpm add @objectstack/service-cache ``` -For Redis adapter: -```bash -pnpm add ioredis -``` - -## Basic Usage +## Usage ```typescript -import { defineStack } from '@objectstack/spec'; -import { ServiceCache } from '@objectstack/service-cache'; - -const stack = defineStack({ - services: [ - ServiceCache.configure({ - adapter: 'memory', // or 'redis' - defaultTTL: 300, // 5 minutes - }), - ], -}); -``` +import { ObjectKernel } from '@objectstack/core'; +import type { ICacheService } from '@objectstack/spec/contracts'; +import { CacheServicePlugin } from '@objectstack/service-cache'; -## Configuration - -### In-Memory Adapter (Development) - -```typescript -ServiceCache.configure({ - adapter: 'memory', - defaultTTL: 300, - maxSize: 1000, // Maximum number of entries -}); -``` +const kernel = new ObjectKernel(); +await kernel.use(new CacheServicePlugin({ memory: { maxSize: 1000, defaultTtl: 300 } })); +await kernel.bootstrap(); -### Redis Adapter (Production) - -```typescript -ServiceCache.configure({ - adapter: 'redis', - redis: { - host: 'localhost', - port: 6379, - password: process.env.REDIS_PASSWORD, - db: 0, - }, - defaultTTL: 600, -}); -``` - -## Service API - -```typescript -// Get cache service const cache = kernel.getService('cache'); +await cache.set('user:123', { name: 'Alice' }, 60); // ttl in SECONDS, positional +const user = await cache.get<{ name: string }>('user:123'); ``` -### Set/Get Operations - -```typescript -// Set a value -await cache.set('user:123', { name: 'John', email: 'john@example.com' }); - -// Set with custom TTL (in seconds) -await cache.set('session:abc', sessionData, { ttl: 3600 }); // 1 hour - -// Get a value -const user = await cache.get('user:123'); - -// Get with type safety -const user = await cache.get('user:123'); - -// Get multiple keys -const users = await cache.mget(['user:123', 'user:456']); -``` - -### Existence & Deletion - -```typescript -// Check if key exists -const exists = await cache.has('user:123'); - -// Delete a key -await cache.del('user:123'); - -// Delete multiple keys -await cache.del(['session:abc', 'session:def']); - -// Delete by pattern -await cache.delPattern('user:*'); -``` - -### Namespaced Operations +## Plugin options -```typescript -// Create a namespaced cache instance -const userCache = cache.namespace('user'); - -// Set in namespace (key becomes 'user:123') -await userCache.set('123', userData); +`CacheServicePluginOptions` has exactly four fields, all optional. -// Get from namespace -const user = await userCache.get('123'); - -// Clear entire namespace -await userCache.clear(); -``` +| Option | Type | Default | Purpose | +|:---|:---|:---|:---| +| `adapter` | `'memory' \| 'redis'` | `'memory'` | `'redis'` throws at `init` — see the warning above. | +| `memory` | `MemoryCacheAdapterOptions` | `{}` | Forwarded to `MemoryCacheAdapter`. | +| `redisUrl` | `string` | none | Read by nothing today; kept for the unimplemented Redis path. | +| `metrics` | `MetricsRegistry` | resolved from the kernel | Explicit metrics backend; wins over the service-registry lookup. | -### TTL Management +`MemoryCacheAdapterOptions`: -```typescript -// Get remaining TTL (in seconds) -const ttl = await cache.ttl('session:abc'); +| Option | Type | Default | Purpose | +|:---|:---|:---|:---| +| `maxSize` | `number` | `0` (unlimited) | Entry cap. At the cap a `set` of a NEW key evicts the oldest-inserted entry (Map insertion order — reads do not refresh position). | +| `defaultTtl` | `number` | `0` (no expiry) | Default TTL in seconds. | +| `metrics` | `MetricsRegistry` | `NoopMetricsRegistry` | Instrumentation sink. | -// Update TTL -await cache.expire('session:abc', 7200); // 2 hours +Note the spelling: `defaultTtl`, not `defaultTTL`. -// Make key permanent (remove expiration) -await cache.persist('user:123'); -``` +## Service API -### Atomic Operations +`ICacheService` (from `@objectstack/spec/contracts`) is deliberately small — six +members, all required: ```typescript -// Increment (useful for counters) -await cache.incr('page:views:123'); // Returns new value - -// Increment by amount -await cache.incrby('score:user:123', 10); +import type { ICacheService, CacheStats } from '@objectstack/spec/contracts'; -// Decrement -await cache.decr('inventory:product:456'); +// get(key) -> Promise (undefined, not null, on a miss) +// set(key, value, ttl?) -> Promise (ttl in seconds, positional) +// delete(key) -> Promise (true when the key existed) +// has(key) -> Promise +// clear() -> Promise +// stats() -> Promise ``` -### Batch Operations - -```typescript -// Set multiple keys at once -await cache.mset({ - 'user:123': user1Data, - 'user:456': user2Data, - 'user:789': user3Data, -}); - -// Get multiple keys -const users = await cache.mget(['user:123', 'user:456', 'user:789']); -``` - -## Advanced Features - -### Cache Aside Pattern +There is no `mget` / `mset`, no `del`, no pattern deletion, no `namespace()`, no +`ttl()` / `expire()` / `persist()`, no `incr` / `decr`, no `getOrSet`, and no tagging. +Compose those on top of the six members above if you need them. ```typescript +// cache-aside, written against the real surface async function getUser(id: string): Promise { - // Try cache first const cached = await cache.get(`user:${id}`); - if (cached) return cached; - - // Load from database - const user = await db.findUser(id); - - // Store in cache - await cache.set(`user:${id}`, user, { ttl: 600 }); + if (cached !== undefined) return cached; + const user = await loadUser(id); + await cache.set(`user:${id}`, user, 600); return user; } ``` -### Cache-Through Pattern +### Statistics -```typescript -async function getUserCacheThrough(id: string): Promise { - return cache.getOrSet(`user:${id}`, async () => { - return await db.findUser(id); - }, { ttl: 600 }); -} -``` - -### Invalidation on Write +`CacheStats` has four fields — note `keyCount`, and that there is no `hitRate` +(compute it from `hits` and `misses`): ```typescript -async function updateUser(id: string, data: Partial) { - // Update database - await db.updateUser(id, data); - - // Invalidate cache - await cache.del(`user:${id}`); - - // Or update cache immediately - const updated = await db.findUser(id); - await cache.set(`user:${id}`, updated); -} +const s = await cache.stats(); +// { hits: number, misses: number, keyCount: number, memoryUsage?: number } ``` -### Tagging & Invalidation +`MemoryCacheAdapter` returns `hits`, `misses` and `keyCount`; it does not report +`memoryUsage` (the contract declares it optional). -```typescript -// Tag cache entries -await cache.set('product:123', productData, { - ttl: 600, - tags: ['products', 'category:electronics'], -}); - -// Invalidate by tag -await cache.invalidateTag('category:electronics'); -``` +## Metrics -## Statistics & Monitoring +`MemoryCacheAdapter` emits the `cache_lookups_total` and `cache_writes_total` counters +(`SEMCONV` in `@objectstack/observability`). The registry is resolved in this order: -```typescript -// Get cache statistics -const stats = await cache.stats(); -// { -// hits: 1250, -// misses: 325, -// hitRate: 0.794, -// keys: 450, -// memoryUsage: 1024000 // bytes -// } - -// Reset statistics -await cache.resetStats(); -``` +1. `options.metrics` (explicit constructor wiring) +2. `ctx.getService('observability:metrics')` — registered by `ObservabilityServicePlugin` +3. `NoopMetricsRegistry` (silent) -## No HTTP Surface +## No HTTP surface -This service is kernel-internal: it is consumed in-process via the service -registry (`kernel.getService('cache')`) and mounts **no** REST routes. -Discovery advertises no route for the `cache` slot and reports -`handlerReady: false` (ADR-0076 D12, #4318). +This service is kernel-internal: it is consumed in-process via the service registry +(`kernel.getService('cache')`) and mounts **no** REST routes. Discovery advertises no +route for the `cache` slot and reports `handlerReady: false` — for this slot that is +the fact itself, not a proxy for reduced capability (ADR-0076 D12). -## Best Practices +## Custom implementations -1. **Use Namespaces**: Organize cache keys with namespaces -2. **Set Appropriate TTLs**: Don't cache data longer than necessary -3. **Handle Misses**: Always have fallback logic when cache misses -4. **Invalidate on Write**: Clear stale cache after updates -5. **Monitor Hit Rates**: Track cache effectiveness with statistics -6. **Serialize Carefully**: Be mindful of what you serialize (avoid circular references) -7. **Use Redis in Production**: In-memory adapter is for development only +The slot is multi-provider. To back the cache with Redis, Memcached or anything else, +register an object satisfying `ICacheService` under `'cache'` from your own plugin: -## Performance Considerations +```typescript +import type { ICacheService } from '@objectstack/spec/contracts'; -- **In-Memory Adapter**: Fast but limited by server memory, not shared across instances -- **Redis Adapter**: Shared across instances, persistent, but network latency -- **TTL Strategy**: Balance between freshness and cache hit rate -- **Key Patterns**: Use consistent naming conventions for easier invalidation +class MyCache implements ICacheService { /* the six members above */ } -## Contract Implementation +// inside your plugin's init(ctx): +ctx.registerService('cache', new MyCache()); +``` -Implements `ICacheService` from `@objectstack/spec/contracts`: +## Exports ```typescript -interface ICacheService { - get(key: string): Promise; - set(key: string, value: T, options?: CacheOptions): Promise; - del(key: string | string[]): Promise; - has(key: string): Promise; - ttl(key: string): Promise; - expire(key: string, ttl: number): Promise; - clear(): Promise; - namespace(name: string): ICacheService; -} +import { + CacheServicePlugin, MemoryCacheAdapter, RedisCacheAdapter, +} from '@objectstack/service-cache'; ``` +Types: `CacheServicePluginOptions`, `MemoryCacheAdapterOptions`, `RedisCacheAdapterOptions`. + ## License Apache-2.0. See [LICENSING.md](../../../LICENSING.md). ## See Also -- [Redis Documentation](https://redis.io/documentation) - [@objectstack/spec/contracts](../../spec/src/contracts/) -- [Caching Best Practices](/content/docs/kernel/contracts/cache-service.mdx) +- [Cache Service](/content/docs/kernel/contracts/cache-service.mdx) diff --git a/packages/services/service-i18n/README.md b/packages/services/service-i18n/README.md index 22fadcfd01..87bcfd9eaf 100644 --- a/packages/services/service-i18n/README.md +++ b/packages/services/service-i18n/README.md @@ -1,17 +1,9 @@ # @objectstack/service-i18n -I18n Service for ObjectStack — implements `II18nService` with file-based locale loading and translation management. +The shipped provider for the kernel's **`i18n`** service slot — a file-based +`II18nService` implementation that also mounts the `/api/v1/i18n/*` routes. -## Features - -- **Multi-Language Support**: Manage translations for unlimited languages -- **File-Based Locales**: Load translations from JSON/YAML files -- **Namespace Support**: Organize translations by domain (e.g., `common`, `errors`, `ui`) -- **Interpolation**: Dynamic variable replacement in translations -- **Pluralization**: Language-specific plural rules -- **Fallback Chain**: Graceful fallback from dialect → base language → default -- **Type-Safe**: TypeScript support with type-safe translation keys -- **Hot Reload**: Reload translations without restarting (development) +Slot criticality: `core` (`ServiceRequirementDef` in `@objectstack/spec/system`). ## Installation @@ -19,350 +11,180 @@ I18n Service for ObjectStack — implements `II18nService` with file-based local pnpm add @objectstack/service-i18n ``` -## Basic Usage - -```typescript -import { defineStack } from '@objectstack/spec'; -import { ServiceI18n } from '@objectstack/service-i18n'; - -const stack = defineStack({ - services: [ - ServiceI18n.configure({ - defaultLocale: 'en-US', - supportedLocales: ['en-US', 'es-ES', 'fr-FR', 'de-DE'], - loadPath: './locales/{{lng}}/{{ns}}.json', - }), - ], -}); -``` - -## Configuration +## Usage ```typescript -interface I18nServiceConfig { - /** Default locale (e.g., 'en-US') */ - defaultLocale: string; - - /** List of supported locales */ - supportedLocales: string[]; - - /** Path template for locale files */ - loadPath: string; - - /** Fallback locale when translation is missing */ - fallbackLocale?: string; - - /** Enable hot reload in development */ - hotReload?: boolean; -} -``` - -## Directory Structure - -``` -locales/ -├── en-US/ -│ ├── common.json -│ ├── errors.json -│ └── ui.json -├── es-ES/ -│ ├── common.json -│ ├── errors.json -│ └── ui.json -└── fr-FR/ - ├── common.json - ├── errors.json - └── ui.json -``` +import { ObjectKernel } from '@objectstack/core'; +import type { II18nService } from '@objectstack/spec/contracts'; +import { I18nServicePlugin } from '@objectstack/service-i18n'; -Example `locales/en-US/common.json`: +const kernel = new ObjectKernel(); +await kernel.use(new I18nServicePlugin({ + defaultLocale: 'en', + localesDir: './i18n', + fallbackLocale: 'en', +})); +await kernel.bootstrap(); -```json -{ - "welcome": "Welcome to ObjectStack", - "greeting": "Hello, {{name}}!", - "item_count": "You have {{count}} item", - "item_count_plural": "You have {{count}} items", - "save_button": "Save", - "cancel_button": "Cancel" -} -``` - -## Service API - -```typescript -// Get i18n service const i18n = kernel.getService('i18n'); +i18n.t('objects.account.label', 'en'); // 'Account' +i18n.t('greeting', 'en', { name: 'Alice' }); // 'Hello, Alice!' ``` -### Basic Translation - -```typescript -// Simple translation -const text = await i18n.t('common:welcome'); -// "Welcome to ObjectStack" +⚠️ `t()` is **synchronous** and takes the locale as its **second positional argument** — +`t(key, locale, params?)`. It is not `await`-able and there is no ambient "current +locale" to set: every call names the locale it wants. -// With interpolation -const greeting = await i18n.t('common:greeting', { name: 'Alice' }); -// "Hello, Alice!" +## Plugin options -// With pluralization -const count1 = await i18n.t('common:item_count', { count: 1 }); -// "You have 1 item" +`I18nServicePluginOptions` has exactly five fields, all optional. -const count5 = await i18n.t('common:item_count', { count: 5 }); -// "You have 5 items" -``` +| Option | Type | Default | Purpose | +|:---|:---|:---|:---| +| `defaultLocale` | `string` | `'en'` | Reported by `getDefaultLocale()`; used as the adapter's default. | +| `localesDir` | `string` | none | Directory of `{locale}.json` files loaded at construction. | +| `fallbackLocale` | `string` | none | Consulted when a key is missing in the requested locale. | +| `registerRoutes` | `boolean` | `true` | Register the REST routes at `kernel:ready`. | +| `basePath` | `string` | `'/api/v1/i18n'` | Base path for those routes. | -### Change Locale +With `registerRoutes: false` — or when no `http-server` service is present — the plugin +logs a warning and the service stays available programmatically through +`kernel.getService('i18n')`. -```typescript -// Set locale for current context -await i18n.setLocale('es-ES'); +## Locale files -// Get current locale -const locale = i18n.getLocale(); -// "es-ES" +One JSON file per locale, named `{locale}.json`, in `localesDir`. Files may be flat or +nested; keys resolve by dot notation. There is no per-namespace file layout and no +`{{lng}}`/`{{ns}}` path template. -// Translate in specific locale (without changing context) -const text = await i18n.t('common:welcome', { locale: 'fr-FR' }); ``` - -### Namespaces - -```typescript -// Load translation from 'errors' namespace -const errorMsg = await i18n.t('errors:not_found'); - -// Load multiple namespaces -await i18n.loadNamespaces(['common', 'ui', 'errors']); - -// Check if namespace is loaded -const isLoaded = i18n.isNamespaceLoaded('common'); +i18n/ +├── en.json +├── zh-CN.json +└── ja-JP.json ``` -### Locale Management - -```typescript -// Get all supported locales -const locales = i18n.getSupportedLocales(); -// ['en-US', 'es-ES', 'fr-FR', 'de-DE'] - -// Check if locale is supported -const isSupported = i18n.isLocaleSupported('ja-JP'); -// false - -// Get locale metadata -const metadata = i18n.getLocaleMetadata('en-US'); -// { -// name: 'English (United States)', -// nativeName: 'English (United States)', -// direction: 'ltr', -// pluralRules: 'en' -// } -``` - -## Advanced Features - -### Nested Keys +`i18n/en.json`: ```json { - "user": { - "profile": { - "title": "User Profile", - "edit": "Edit Profile" - } + "greeting": "Hello, {{name}}!", + "objects": { + "account": { "label": "Account" } } } ``` -```typescript -await i18n.t('common:user.profile.title'); -// "User Profile" -``` +Interpolation is `{{paramName}}` only, substituted from the third argument of `t()`. A +parameter with no supplied value is left as the literal `{{name}}` placeholder. There is +no pluralization, no `context` suffix resolution, no `returnObjects`, and no date / +number / relative-time formatting in this package — use `Intl` for those. -### Arrays +## Service API -```json -{ - "days": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] -} -``` +`II18nService` (from `@objectstack/spec/contracts`) declares four required members plus +optional ones; `FileI18nAdapter` implements the required four and three of the optional. ```typescript -const days = await i18n.t('common:days', { returnObjects: true }); -// ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] -``` - -### Context-Based Translations +import type { II18nService } from '@objectstack/spec/contracts'; -```json -{ - "friend": "A friend", - "friend_male": "A boyfriend", - "friend_female": "A girlfriend" -} +// required +// t(key, locale, params?) -> string (the key itself when unresolved) +// getTranslations(locale) -> Record +// loadTranslations(locale, data) -> void (deep-merged into the locale) +// getLocales() -> string[] +// optional, implemented here +// getDefaultLocale() / setDefaultLocale(locale) +// setSupportedLocales(locales | undefined) ``` -```typescript -await i18n.t('common:friend', { context: 'male' }); -// "A boyfriend" - -await i18n.t('common:friend', { context: 'female' }); -// "A girlfriend" -``` +`t()` returns the **key itself** when nothing resolves — it never throws and never +returns `undefined`, so a missing translation surfaces as a visible key rather than an +empty string. -### Formatting +`loadTranslations` deep-merges, so several plugins can each contribute keys under the +same nested path (every platform plugin pushes its own bundle at `kernel:ready`). -```typescript -// Date formatting -const formatted = await i18n.formatDate(new Date(), { - locale: 'es-ES', - format: 'long', -}); -// "15 de enero de 2024" - -// Number formatting -const price = await i18n.formatNumber(1234.56, { - style: 'currency', - currency: 'EUR', - locale: 'fr-FR', -}); -// "1 234,56 €" - -// Relative time -const relative = await i18n.formatRelative(new Date('2024-01-01'), { - locale: 'en-US', -}); -// "3 months ago" -``` +### Which locales are reported -### Dynamic Loading +`getLocales()` reports what is **loaded**, narrowed by the app's declared +`i18n.supportedLocales` when the runtime injects them via `setSupportedLocales`. +The narrowing rules are contractual: -```typescript -// Add a new locale dynamically -await i18n.addLocale('ja-JP', { - loadPath: './locales/ja-JP/{{ns}}.json', -}); +- absent / empty / not an array ⇒ **no** narrowing (every loaded locale is reported); +- a declared locale with no loaded bundle is still reported (declared-but-unserved is + visible rather than silently intersected away); +- narrowing is applied at read time, never as a prune of what is stored — bundles keep + arriving after the app plugin has run. -// Remove a locale -await i18n.removeLocale('ja-JP'); +### Runtime-authored translations -// Reload translations (useful in development) -await i18n.reload(); -``` +Translations authored in Studio persist as `translation` metadata. The plugin wires the +shared core sync, which replaces the authored layer wholesale (`clear`-then-reload) at +`kernel:ready`, on `metadata:reloaded`, and on `translation` protocol mutations — so a +key deleted from an authored item stops resolving on the next sync, while the static +bundle layer underneath is untouched. -## Integration with Metadata +## REST API -Translate metadata labels automatically: +Registered by this plugin directly on the `http-server` service. These three routes are +the whole surface (shown at the default `basePath`): -```typescript -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -const contact = ObjectSchema.create({ - name: 'contact', - label: 'i18n:objects.contact.label', // References translation key - fields: { - name: Field.text({ - label: 'i18n:fields.contact.name', - }), - }, -}); - -// Translation file: locales/en-US/metadata.json -{ - "objects": { - "contact": { - "label": "Contact", - "label_plural": "Contacts" - } - }, - "fields": { - "contact": { - "name": "Full Name" - } - } -} ``` - -## REST API Endpoints - -``` -GET /api/v1/i18n/locales # Get supported locales -GET /api/v1/i18n/translations/:locale # Get all translations for locale -POST /api/v1/i18n/translate # Translate keys (batch) +GET /api/v1/i18n/locales # available locales +GET /api/v1/i18n/translations/:locale # all translations for one locale +GET /api/v1/i18n/labels/:object/:locale # field labels for one object ``` -## Client Integration +⚠️ The locale is a **path** segment, not a `?locale=` query parameter — the query +dialect was a wire-level 404 against every serving surface and was retired. Each route +is expressed by the SDK (`i18n.getLocales`, `i18n.getTranslations`, `i18n.getFieldLabels`); +`src/i18n-route-ledger.ts` is the audited list, and a conformance test fails when a +mounted route has no entry or an entry names a route that is no longer mounted. -### React Hook Example +## Client integration -```typescript -import { useTranslation } from '@objectstack/client-react'; +There is no `useTranslation` hook in this repo. On the client, `@objectstack/client-react` +carries the **active locale** so requests send a matching `Accept-Language`, and +translations are fetched through `@objectstack/client`: -function MyComponent() { - const { t, locale, setLocale } = useTranslation(); +```tsx +import { ObjectStackProvider, useObjectStackLocale } from '@objectstack/client-react'; +function App({ client, language }) { return ( -
-

{t('common:welcome')}

- -
+ + + ); } + +function Screen() { + const locale = useObjectStackLocale(); // string | undefined + return {locale}; +} ``` -## Best Practices +```typescript +import { ObjectStackClient } from '@objectstack/client'; -1. **Use Namespaces**: Organize translations by domain (common, ui, errors, metadata) -2. **Consistent Keys**: Use dot notation for nested keys (e.g., `user.profile.title`) -3. **Provide Context**: Use context for gender, formality, or pluralization variants -4. **Fallback Values**: Always provide fallback translations in default locale -5. **Avoid Hardcoding**: Never hardcode user-facing text; use translation keys -6. **Professional Translation**: Use professional translators for production -7. **Version Control**: Store translation files in version control +const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' }); +const locales = await client.i18n.getLocales(); +const bundle = await client.i18n.getTranslations('zh-CN'); +const labels = await client.i18n.getFieldLabels('crm_account', 'zh-CN'); +``` -## Locale Coverage Detection +## Exports ```typescript -// Get coverage statistics -const coverage = await i18n.getCoverage(); -// { -// 'en-US': { total: 245, missing: 0, percentage: 100 }, -// 'es-ES': { total: 245, missing: 12, percentage: 95.1 }, -// 'fr-FR': { total: 245, missing: 45, percentage: 81.6 } -// } - -// Get missing keys for a locale -const missing = await i18n.getMissingKeys('es-ES'); -// ['errors.validation.email', 'ui.dashboard.title', ...] +import { I18nServicePlugin, FileI18nAdapter } from '@objectstack/service-i18n'; ``` -## Performance Considerations - -- **Lazy Loading**: Namespaces are loaded on demand -- **Caching**: Translations are cached in memory -- **Hot Reload**: Only enable in development -- **Bundle Size**: Load only required locales on client - -## Contract Implementation +Types: `I18nServicePluginOptions`, `FileI18nAdapterOptions`. -Implements `II18nService` from `@objectstack/spec/contracts`: - -```typescript -interface II18nService { - t(key: string, options?: TranslationOptions): Promise; - setLocale(locale: string): Promise; - getLocale(): string; - getSupportedLocales(): string[]; - loadNamespaces(namespaces: string[]): Promise; - formatDate(date: Date, options?: FormatOptions): Promise; - formatNumber(value: number, options?: FormatOptions): Promise; -} -``` +`FileI18nAdapter` is the implementation behind the plugin, exported for hosts that wire +their own kernel integration. Beyond the contract it also exposes +`replaceAuthoredTranslations(byLocale)`, which the authored-translation sync uses. ## License @@ -370,6 +192,5 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md). ## See Also -- [i18next Documentation](https://www.i18next.com/) -- [@objectstack/spec/system (Translation schema)](../../spec/src/system/) -- [I18n Best Practices Guide](/content/docs/protocol/kernel/i18n-standard.mdx) +- [@objectstack/spec/system](../../spec/src/system/) — the `translation` metadata schema +- [I18n Standard](/content/docs/protocol/kernel/i18n-standard.mdx) diff --git a/packages/services/service-job/README.md b/packages/services/service-job/README.md index a86b2cb633..e539a67ff3 100644 --- a/packages/services/service-job/README.md +++ b/packages/services/service-job/README.md @@ -1,17 +1,10 @@ # @objectstack/service-job -Job Service for ObjectStack — implements `IJobService` with setInterval and cron scheduling. +The shipped provider for the kernel's **`job`** service slot — an `IJobService` +implementation with a durable ObjectQL-backed adapter, an in-memory timer adapter, and +a croner-backed cron adapter with cluster leader election. -## Features - -- **Cron Scheduling**: Schedule jobs with cron expressions -- **Interval Scheduling**: Run jobs at fixed intervals -- **Job Queue**: Manage job execution queue -- **Retry Logic**: Automatic retry on failure with exponential backoff -- **Job History**: Track execution history and status -- **Concurrency Control**: Limit concurrent job execution -- **Timezone Support**: Schedule jobs in specific timezones -- **Type-Safe**: Full TypeScript support +Slot criticality: `core` (`ServiceRequirementDef` in `@objectstack/spec/system`). ## Installation @@ -19,348 +12,175 @@ Job Service for ObjectStack — implements `IJobService` with setInterval and cr pnpm add @objectstack/service-job ``` -## Basic Usage - -```typescript -import { defineStack } from '@objectstack/spec'; -import { ServiceJob } from '@objectstack/service-job'; - -const stack = defineStack({ - services: [ - ServiceJob.configure({ - timezone: 'America/New_York', - maxConcurrent: 5, - }), - ], -}); -``` - -## Configuration +## Usage ```typescript -interface JobServiceConfig { - /** Default timezone for cron jobs (default: 'UTC') */ - timezone?: string; - - /** Maximum concurrent job executions (default: 10) */ - maxConcurrent?: number; +import { ObjectKernel } from '@objectstack/core'; +import type { IJobService } from '@objectstack/spec/contracts'; +import { JobServicePlugin } from '@objectstack/service-job'; - /** Enable job history tracking (default: true) */ - enableHistory?: boolean; +const kernel = new ObjectKernel(); +await kernel.use(new JobServicePlugin()); +await kernel.bootstrap(); - /** Maximum history entries per job (default: 100) */ - maxHistorySize?: number; -} -``` - -## Service API - -```typescript -// Get job service const jobs = kernel.getService('job'); -``` - -### Cron Jobs -```typescript -// Schedule a job with cron expression -const job = await jobs.schedule({ - name: 'daily_report', - schedule: '0 9 * * *', // Every day at 9 AM - handler: async (context) => { - console.log('Generating daily report...'); - // Your job logic here - }, - timezone: 'America/New_York', -}); - -// Common cron patterns: -// '*/5 * * * *' - Every 5 minutes -// '0 */2 * * *' - Every 2 hours -// '0 9 * * 1-5' - Weekdays at 9 AM -// '0 0 1 * *' - First day of every month at midnight -// '0 0 * * 0' - Every Sunday at midnight +await jobs.schedule( + 'daily_report', + { type: 'cron', expression: '0 9 * * *', timezone: 'America/New_York' }, + async ({ jobId }) => { await generateReport(jobId); }, + { retryPolicy: { maxRetries: 2, backoffMs: 1000 }, timeout: 60_000 }, +); ``` -### Interval Jobs - -```typescript -// Run every 30 seconds -const job = await jobs.scheduleInterval({ - name: 'health_check', - interval: 30000, // milliseconds - handler: async (context) => { - console.log('Running health check...'); - }, -}); - -// Run every 5 minutes -const job = await jobs.scheduleInterval({ - name: 'sync_data', - interval: 5 * 60 * 1000, // 5 minutes - handler: async (context) => { - // Sync data - }, -}); -``` +⚠️ `schedule` takes **four positional arguments** — `(name, schedule, handler, options?)` +— not a single options object. The schedule is a `JobSchedule` discriminated on `type`. -### One-Time Jobs +## Adapter selection -```typescript -// Schedule a one-time job -const job = await jobs.scheduleOnce({ - name: 'send_reminder', - runAt: new Date('2024-12-25T09:00:00Z'), - handler: async (context) => { - console.log('Sending holiday reminder...'); - }, -}); - -// Schedule to run after a delay -const job = await jobs.scheduleOnce({ - name: 'delayed_task', - delay: 3600000, // 1 hour from now - handler: async (context) => { - console.log('Executing delayed task...'); - }, -}); -``` +`JobServicePluginOptions` has exactly four fields, all optional. -### Job Management +| Option | Type | Default | Purpose | +|:---|:---|:---|:---| +| `adapter` | `'auto' \| 'db' \| 'interval' \| 'cron'` | `'auto'` | See the table below. | +| `interval` | `IntervalJobAdapterOptions` | `{}` | Forwarded to `IntervalJobAdapter`. | +| `db` | `DbJobAdapterOptions` | `{}` | Forwarded to `DbJobAdapter`. | +| `enableCron` | `boolean` | `true` | Route cron schedules to `CronJobAdapter` when available. | -```typescript -// List all jobs -const allJobs = await jobs.listJobs(); +| `adapter` | Behaviour | +|:---|:---| +| `'auto'` | Registers `IntervalJobAdapter` synchronously, then upgrades to `DbJobAdapter` at `kernel:ready` if an ObjectQL engine is present. Stays on the interval adapter otherwise. | +| `'db'` | Same upgrade path, but logs a warning when no engine turns up. | +| `'interval'` | In-memory timer adapter only. **Cron registrations are stored but never fire** — the adapter warns about each one. | +| `'cron'` | In-memory `CronJobAdapter` only (croner-backed, with cluster leader election). | -// Get job details -const job = await jobs.getJob('daily_report'); +The plugin registers the `sys_job` and `sys_job_run` platform objects through the +`manifest` service so Studio can see scheduled jobs and their runs; it warns and +continues when no manifest service is registered. -// Stop a job -await jobs.stopJob('daily_report'); +## Schedules -// Resume a stopped job -await jobs.resumeJob('daily_report'); +`JobSchedule` (from `@objectstack/spec/contracts`) is the runtime-value shape the +schedulers consume: -// Delete a job -await jobs.deleteJob('daily_report'); +| `type` | Fields read | +|:---|:---| +| `'cron'` | `expression` (a bare cron string), `timezone` | +| `'interval'` | `intervalMs` | +| `'once'` | `at` (ISO 8601 datetime) | -// Run a job immediately (ignoring schedule) -await jobs.runNow('daily_report'); +```typescript +{ type: 'cron', expression: '*/5 * * * *', timezone: 'UTC' } +{ type: 'interval', intervalMs: 30_000 } +{ type: 'once', at: '2026-12-25T09:00:00Z' } ``` -## Advanced Features +## Service API -### Job Context +`IJobService` declares three required members and four optional ones: ```typescript -const job = await jobs.schedule({ - name: 'process_orders', - schedule: '*/10 * * * *', - handler: async (context) => { - console.log('Job name:', context.jobName); - console.log('Execution ID:', context.executionId); - console.log('Scheduled time:', context.scheduledTime); - console.log('Execution count:', context.executionCount); - - // Access services - const db = context.kernel.getService('database'); - const orders = await db.find({ object: 'order', status: 'pending' }); - - // Process orders... - }, -}); -``` - -### Retry Configuration +import type { IJobService } from '@objectstack/spec/contracts'; -```typescript -const job = await jobs.schedule({ - name: 'api_sync', - schedule: '0 * * * *', // Every hour - retry: { - maxAttempts: 3, - backoff: 'exponential', // 'linear' or 'exponential' - initialDelay: 1000, // 1 second - maxDelay: 60000, // 1 minute - }, - handler: async (context) => { - // May fail and retry - await syncWithExternalAPI(); - }, -}); +// required +// schedule(name, schedule, handler, options?) -> Promise +// cancel(name) -> Promise +// trigger(name, data?) -> Promise +// optional +// getExecutions?(name, limit?) -> Promise +// listJobs?() -> Promise +// replay?(name, data?) -> Promise +// listExecutionsByStatus?(status, limit?) -> Promise ``` -### Concurrency Control +`schedule` resolves `void` — it does not return a job handle. There is no `getJob`, +`stopJob`, `resumeJob`, `deleteJob`, `runNow`, `scheduleInterval`, `scheduleOnce`, +`getJobHistory` or `clearHistory`: cancelling is `cancel(name)`, running it now is +`trigger(name)`, and history is `getExecutions(name, limit?)`. -```typescript -const job = await jobs.schedule({ - name: 'heavy_processing', - schedule: '*/5 * * * *', - concurrency: 1, // Only one instance can run at a time - handler: async (context) => { - // Long-running process - }, -}); -``` - -### Job History +## Handlers ```typescript -// Get execution history for a job -const history = await jobs.getJobHistory('daily_report', { - limit: 50, - status: 'success', // 'success', 'failed', 'running' -}); - -// Example history entry: -// { -// executionId: 'exec:abc123', -// jobName: 'daily_report', -// status: 'success', -// startedAt: '2024-01-15T09:00:00Z', -// completedAt: '2024-01-15T09:05:23Z', -// duration: 323000, // milliseconds -// error: null, -// result: { records: 1250 } -// } - -// Clear history for a job -await jobs.clearHistory('daily_report'); -``` - -### Job Data & Results +import type { JobHandler } from '@objectstack/spec/contracts'; -```typescript -const job = await jobs.schedule({ - name: 'data_export', - schedule: '0 0 * * *', - handler: async (context) => { - const records = await exportData(); - - // Return result data - return { - recordCount: records.length, - fileSize: calculateSize(records), - exportedAt: new Date(), - }; - }, -}); - -// Get last execution result -const lastRun = await jobs.getLastExecution('data_export'); -console.log('Last export:', lastRun.result); +const handler: JobHandler = async ({ jobId, data }) => { + // … +}; ``` -## Common Patterns +The handler receives `{ jobId, data? }` — there is no kernel reference, no execution +count and no scheduled-time field on it. Three outcomes: -### Database Cleanup Job +| The handler… | Means | Recorded as | +|:---|:---|:---| +| throws / rejects | the run **failed** | `failed` — the retry policy applies | +| resolves `undefined` (or `{ outcome: 'completed' }`) | the run **succeeded** | `success` | +| resolves `{ outcome: 'degraded', reason? }` | ran to completion, work did not happen | a status distinct from `success` | -```typescript -jobs.schedule({ - name: 'cleanup_old_records', - schedule: '0 2 * * *', // 2 AM daily - handler: async (context) => { - const db = context.kernel.getService('database'); - - // Delete records older than 90 days - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 90); - - await db.delete({ - object: 'audit_log', - filters: [{ field: 'created_at', operator: 'lt', value: cutoff }], - }); - }, -}); -``` - -### Report Generation Job - -```typescript -jobs.schedule({ - name: 'weekly_sales_report', - schedule: '0 8 * * 1', // Mondays at 8 AM - handler: async (context) => { - const analytics = context.kernel.getService('analytics'); - - const data = await analytics.query({ - object: 'order', - aggregations: [{ function: 'sum', field: 'amount' }], - groupBy: ['sales_rep'], - filters: [{ field: 'created_at', operator: 'last_week' }], - }); - - // Generate and email report - await sendReport(data); - }, -}); -``` +⚠️ `degraded` is **not** a failure and does **not** trigger a retry. Retry is driven +exclusively by a rejected promise; a handler that wants a re-run must throw. -### Cache Warming Job +## Retry and timeout -```typescript -jobs.scheduleInterval({ - name: 'warm_cache', - interval: 15 * 60 * 1000, // Every 15 minutes - handler: async (context) => { - const cache = context.kernel.getService('cache'); - - // Pre-load frequently accessed data - const popularProducts = await getPopularProducts(); - await cache.set('popular_products', popularProducts, { ttl: 900 }); - }, -}); -``` +`JobScheduleOptions` threads a per-job policy down to the executing adapter. Defaults +mirror `RetryPolicySchema` in `@objectstack/spec` — the declared default *is* the +enforced one: -## No HTTP Surface +| Field | Default | Notes | +|:---|:---|:---| +| `retryPolicy.maxRetries` | `0` | Attempts **after** the initial run; `0` means no retry. | +| `retryPolicy.backoffMs` | `1000` | Base delay before the first retry. | +| `retryPolicy.backoffMultiplier` | `1` | A flat delay by default. | +| `retryPolicy.maxRetryDelayMs` | `30000` | Ceiling for one backoff delay. | +| `retryPolicy.jitter` | `false` | Randomise each delay within [50%, 100%]. | +| `timeout` | none | Per-**attempt** limit in ms; an exceeded run is recorded `timeout` and rejects with `JobTimeoutError`. | -This service is kernel-internal: it is consumed in-process via the service -registry (`kernel.getService('job')`) and mounts **no** REST routes. Discovery -advertises no route for the `job` slot and reports `handlerReady: false` -(ADR-0076 D12, #4318). +JavaScript cannot forcibly cancel an in-flight handler: on timeout the attempt is +abandoned, not killed. -## Best Practices +`runWithPolicy(jobId, run, options?, recorder?)` is exported so a host building its own +adapter applies exactly these semantics instead of re-deriving them. -1. **Idempotent Handlers**: Job handlers should be idempotent (safe to run multiple times) -2. **Error Handling**: Always handle errors gracefully and log failures -3. **Timeout Limits**: Set reasonable timeout limits for long-running jobs -4. **Resource Limits**: Limit concurrent executions to avoid overloading the system -5. **Monitoring**: Monitor job execution times and failure rates -6. **Timezone Awareness**: Always specify timezone for cron jobs to avoid ambiguity -7. **Cleanup**: Periodically delete old job history to save storage +## Adapter options -## Performance Considerations +| Adapter | Option | Default | Purpose | +|:---|:---|:---|:---| +| `IntervalJobAdapter` | `maxExecutions` | `100` | Execution records retained per job. | +| | `logger` | none | Surfaces cron registrations this adapter cannot fire. | +| `CronJobAdapter` | `timezone` | `'UTC'` | Timezone for cron expressions. | +| | `maxExecutions` | `100` | Execution history per job. | +| | `cluster` | none | Cluster service for scheduler leader election — with a remote driver only ONE node fires each job. | +| | `leaseMs` | `60000` | Lease held while a scheduled fire runs. | +| | `namespace` | none | Cosmetic label in croner's process-global name registry. | +| | `logger` | none | Registry-level anomalies. | +| `DbJobAdapter` | `maxExecutions` | `100` | Executions kept in memory per job (forwarded to the inner `IntervalJobAdapter`). | +| | `recordRuns` | `true` | Whether each run writes a `sys_job_run` row. `false` keeps the in-memory history only. | -- **Concurrency**: Limit concurrent jobs based on system resources -- **Job Duration**: Keep job execution time reasonable (< 5 minutes ideal) -- **History Size**: Limit history entries to prevent memory bloat -- **Batch Processing**: Process records in batches for large datasets +## No HTTP surface -## Contract Implementation +This service is kernel-internal: it is consumed in-process via the service registry +(`kernel.getService('job')`) and mounts **no** REST routes. Discovery advertises no +route for the `job` slot and reports `handlerReady: false` — for this slot that is the +fact itself, not a proxy for reduced capability (ADR-0076 D12). -Implements `IJobService` from `@objectstack/spec/contracts`: +## Exports ```typescript -interface IJobService { - schedule(options: ScheduleOptions): Promise; - scheduleInterval(options: IntervalOptions): Promise; - scheduleOnce(options: OnceOptions): Promise; - getJob(name: string): Promise; - listJobs(filter?: JobFilter): Promise; - stopJob(name: string): Promise; - resumeJob(name: string): Promise; - deleteJob(name: string): Promise; - runNow(name: string): Promise; - getJobHistory(name: string, options?: HistoryOptions): Promise; -} +import { + JobServicePlugin, IntervalJobAdapter, CronJobAdapter, DbJobAdapter, + runWithPolicy, JobTimeoutError, +} from '@objectstack/service-job'; ``` +Types: `JobServicePluginOptions`, `IntervalJobAdapterOptions`, `CronJobAdapterOptions`, +`DbJobAdapterOptions`, `JobEngineLike`, `JobLoggerLike`. + ## License Apache-2.0. See [LICENSING.md](../../../LICENSING.md). ## See Also -- [Cron Expression Generator](https://crontab.guru/) - [@objectstack/spec/contracts](../../spec/src/contracts/) -- [Job Scheduling Guide](/content/docs/kernel/runtime-services/queue-service.mdx) +- [Cron Expression Generator](https://crontab.guru/) +- [Queue Service](/content/docs/kernel/runtime-services/queue-service.mdx) diff --git a/scripts/published-readme-exports.baseline.json b/scripts/published-readme-exports.baseline.json index 18d4c57db1..d876a11611 100644 --- a/scripts/published-readme-exports.baseline.json +++ b/scripts/published-readme-exports.baseline.json @@ -39,30 +39,6 @@ "id": "@objectstack/objectql|packages/objectql/README.md|member|@objectstack/objectql|SchemaRegistry.registerObject", "why": "registerObject is an INSTANCE method (`engine.registry.registerObject(...)`); the README calls it on the class. The import resolves, so only the call-site half can see it." }, - { - "id": "@objectstack/service-analytics|packages/services/service-analytics/README.md|import|@objectstack/service-analytics|ServiceAnalytics", - "why": "#9532 instance 1 of 5. Real exports: `AnalyticsService`, `AnalyticsServicePlugin`. The README then calls `ServiceAnalytics.configure({...})`." - }, - { - "id": "@objectstack/service-automation|packages/services/service-automation/README.md|import|@objectstack/service-automation|ServiceAutomation", - "why": "#9532 instance 2 of 5. Real export: `AutomationEngine`." - }, - { - "id": "@objectstack/service-cache|packages/services/service-cache/README.md|import|@objectstack/service-cache|ServiceCache", - "why": "#9532 instance 3 of 5. Real export: `CacheServicePlugin`." - }, - { - "id": "@objectstack/service-i18n|packages/services/service-i18n/README.md|import|@objectstack/service-i18n|ServiceI18n", - "why": "#9532 instance 4 of 5. Real export: `I18nServicePlugin`." - }, - { - "id": "@objectstack/service-i18n|packages/services/service-i18n/README.md|import|@objectstack/client-react|useTranslation", - "why": "A CROSS-PACKAGE claim: no `useTranslation` exists anywhere in this repo. @objectstack/client-react ships `useObjectStackLocale`. Found inside a #9532 package but pointing at a sibling — the rewrite of this README owns it." - }, - { - "id": "@objectstack/service-job|packages/services/service-job/README.md|import|@objectstack/service-job|ServiceJob", - "why": "#9532 instance 5 of 5. Real export: `JobServicePlugin`." - }, { "id": "@objectstack/spec|packages/spec/README.md|import|@objectstack/spec/ai|MCPServerConfigSchema", "why": "@objectstack/spec/ai exports `MCPServerRefSchema`; there is no `MCPServerConfigSchema`. This is the protocol package's own front page." From a5c5e3cff9cf78a2301e80ae146934ad8767e68c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 12:49:11 +0000 Subject: [PATCH 2/2] docs(service-automation): the Exports section covers the whole published surface (#9532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The value exports were listed but the 30 type exports were not, so the README's export list was a subset rather than the surface. A rerunnable set-equality check over the built `.d.ts` now reports 50/50 for this package (104/104 across all five), in BOTH directions — the gate proves every documented name resolves, it cannot prove nothing was omitted. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --- packages/services/service-automation/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index 1f3cfa5db2..191129440a 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -431,6 +431,19 @@ import { } from '@objectstack/service-automation'; ``` +Types: `AutomationEngineOptions`, `AutomationServicePluginOptions`, `RunSummaryLogLevel`, +`NodeExecutor`, `NodeExecutionResult`, `SuspensionRelease`, `SuspensionReleaseReason`, +`FlowTrigger`, `FlowTriggerBinding`, `RegisteredConnector`, `SuspendedRun`, +`SuspendedRunStore`, `SuspendedRunStoreEngine`, `ObjectStoreSuspendedRunStoreOptions`, +`RunRecord`, `StepLogEntry`, `UnknownNodeTypeAuditEntry`, `RunDataContext`, +`RunIdentityContext`, `RunProvenanceContext`, `ConnectorProviderFactory`, +`ConnectorProviderContext`, `ConnectorMaterialization`, `ConnectorMaterializationHandler`, +`ConnectorOrigin`, `ConnectorState`, `ConnectorDescriptor`, `ConnectorActionDescriptor`, +`ConnectorActionHandler`, `ConnectorActionContext`. + +The connector types are re-exports from `@objectstack/spec/integration` — connector +plugins should import them from there rather than coupling to this engine. + `AutomationEngine` is the engine underneath the plugin, exported for hosts that build their own kernel integration; `AutomationServicePlugin` is the entry point for everyone else. The built-in node installers are functions, not plugins — the platform's