diff --git a/.changeset/storage-slot-canonical-rename.md b/.changeset/storage-slot-canonical-rename.md new file mode 100644 index 0000000000..ee9d32d1b7 --- /dev/null +++ b/.changeset/storage-slot-canonical-rename.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-storage": patch +"@objectstack/runtime": patch +"@objectstack/metadata-protocol": patch +"@objectstack/cli": patch +"@objectstack/plugin-email": patch +"@objectstack/plugin-dev": patch +--- + +feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683) + + + +Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」. +The `file-storage` slot was the only `CoreServiceName` member whose spelling +diverged from its documented accessor (`services.storage`), with no recorded +reason anywhere in the tree. + +- `CoreServiceName` gains `storage` as the canonical member; `file-storage` + stays an accepted, deprecated alias within v17 (it is a published enum + member — existing `getService('file-storage')` callers keep working). + `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both. +- `@objectstack/service-storage` registers the **same instance** under both + names (the `http.server` / `http-server` pattern), pinned by an + alias-equivalence test. +- Every internal consumer resolves `storage`: the HTTP dispatcher, the email + plugin's attachment store, and `os migrate files-to-references`. Discovery + reports the service under the canonical `storage` key and mirrors the row + verbatim under the `file-storage` key for the alias's v17 lifetime, so + existing discovery readers (e.g. the console endpoint catalog) keep + working. +- Docs (`kernel/runtime-services`, `kernel/contracts`) now document the + canonical slot; a custom v17 provider for this slot should register both + names. diff --git a/content/docs/api/plugin-endpoints.mdx b/content/docs/api/plugin-endpoints.mdx index 0e10f5bda5..c0e209b5f7 100644 --- a/content/docs/api/plugin-endpoints.mdx +++ b/content/docs/api/plugin-endpoints.mdx @@ -179,7 +179,8 @@ the protocol above and never touched them. Both routes are retired. `/storage` is service-storage's surface — install that package to get it. Discovery advertises the route only when the occupant of the -`file-storage` slot actually mounts HTTP handlers, so an in-memory dev +`storage` slot (spelled `file-storage` before #9683; that spelling stays a +deprecated v17 alias) actually mounts HTTP handlers, so an in-memory dev implementation now reports `handlerReady: false` and no `routes.storage` instead of pointing at a path with nothing behind it. diff --git a/content/docs/kernel/contracts/storage-service.mdx b/content/docs/kernel/contracts/storage-service.mdx index ce69874759..f689456cce 100644 --- a/content/docs/kernel/contracts/storage-service.mdx +++ b/content/docs/kernel/contracts/storage-service.mdx @@ -9,7 +9,9 @@ The Storage Service provides a unified interface for **file management** — upl **Source:** `packages/spec/src/contracts/storage-service.ts` -**Service name:** `'file-storage'` — resolve via `kernel.getService('file-storage')`. +**Service name:** `'storage'` — resolve via `kernel.getService('storage')`. The +pre-rename spelling `'file-storage'` stays accepted as a deprecated alias within v17 +(#9683); both names resolve the same instance. --- diff --git a/content/docs/kernel/runtime-services/index.mdx b/content/docs/kernel/runtime-services/index.mdx index e2387fce74..b9e92f3711 100644 --- a/content/docs/kernel/runtime-services/index.mdx +++ b/content/docs/kernel/runtime-services/index.mdx @@ -16,11 +16,11 @@ framework today. Managed runtimes provide the `services.*` binding directly. **`services.*` is the accessor spelling, not necessarily the registry slot.** The name after `services.` is what this chapter documents each surface under; the string you pass to `ctx.getService(...)` is the slot the implementation is registered by. They are the -same word for every service below **except storage**, whose slot is `file-storage` -(there is no `storage` alias — see -[`services.storage`](/docs/kernel/runtime-services/storage-service)). Each page states -its own slot in a **Registry slot** bullet; `scripts/check-runtime-services-index.mjs` -holds those to a real `registerService` call. +same word for every service below — storage included, since the #9683 rename made +`storage` the canonical slot (its pre-rename spelling `file-storage` stays a deprecated +v17 alias — see [`services.storage`](/docs/kernel/runtime-services/storage-service)). +Each page states its own slot in a **Registry slot** bullet; +`scripts/check-runtime-services-index.mjs` holds those to a real `registerService` call. This chapter documents the runtime `services.*` APIs used in hook/action/flow/plugin code: diff --git a/content/docs/kernel/runtime-services/storage-service.mdx b/content/docs/kernel/runtime-services/storage-service.mdx index deaa75867c..0f9be1fad7 100644 --- a/content/docs/kernel/runtime-services/storage-service.mdx +++ b/content/docs/kernel/runtime-services/storage-service.mdx @@ -7,36 +7,34 @@ description: File/object storage contract for upload/download and presigned URL - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/storage-service.ts` -- **Registry slot:** `file-storage` — **not** `storage`; resolve with - `ctx.getService('file-storage')`. See [Accessor name vs registry - slot](#accessor-name-vs-registry-slot). +- **Registry slot:** `storage` — resolve with `ctx.getService('storage')`. + The pre-rename spelling `file-storage` stays accepted as a deprecated alias + within v17. See [Registry slot and its deprecated + alias](#registry-slot-and-its-deprecated-alias). -## Accessor name vs registry slot +## Registry slot and its deprecated alias -`services.storage` is this chapter's **accessor spelling** — the name the contract surface -is documented under. The **registry slot** is the string the kernel actually keys the -implementation by, and for storage the two differ: +`storage` is the canonical registry slot (maintainer ruling, 2026-08-18, issue +#9683): it is a member of `CoreServiceName` +(`packages/spec/src/system/core-services.zod.ts`), `CORE_SERVICE_PROVIDER` maps +it to `@objectstack/service-storage`, and it is the key the `/api/v1/discovery` +document reports this service's availability under. The accessor spelling and +the slot are now the same word, like every other service in this chapter. -| | value | -|:--|:--| -| Documented accessor | `services.storage` | -| Registry slot | `file-storage` | -| Resolve with | `ctx.getService('file-storage')` | - -This is the **only** service in this chapter where they differ; for the other seven the -accessor and the slot are the same word. There is no `storage` alias — nothing calls -`registerService('storage', ...)` anywhere in the platform — so resolving the accessor -spelling throws rather than returning an empty value: +The slot was spelled `file-storage` before the rename, and that spelling is a +published `CoreServiceName` member — so it stays accepted as a **deprecated +alias** within v17. `@objectstack/service-storage` registers the **same +instance** under both names: ```ts -ctx.getService('storage'); // ✗ throws: [Kernel] Service 'storage' not found -ctx.getService('file-storage'); // ✓ the IStorageService documented below +ctx.getService('storage'); // ✓ canonical — the IStorageService documented below +ctx.getService('file-storage'); // ✓ deprecated v17 alias — the SAME instance ``` -`file-storage` is the canonical spelling, not an implementation detail: it is the member -listed in `CoreServiceName` (`packages/spec/src/system/core-services.zod.ts`), -`CORE_SERVICE_PROVIDER` maps it to `@objectstack/service-storage`, and it is the key the -`/api/v1/discovery` document reports this service's availability under. +New code resolves `storage`. The alias is retired through the standard +retirement flow at the next major. If you ship a **custom** provider for this +slot in v17, register it under both names the way `service-storage` does, so +callers of either spelling keep resolving it. A second `storage` accessor exists and is easy to reach for by mistake. @@ -44,8 +42,8 @@ A second `storage` accessor exists and is easy to reach for by mistake. surface — `upload(file, scope)`, `getDownloadUrl(fileId)`, `getPresignedUrl(req)`, `initChunkedUpload(req)` — which calls `/api/v1/storage` over the wire. It is a different shape from the server-side `IStorageService` documented on this page, which takes storage -**keys** and returns `Buffer`s. Neither one is reachable through -`ctx.getService('storage')`. +**keys** and returns `Buffer`s. `ctx.getService('storage')` resolves the server-side +service documented here — never the client accessor. ## Core Methods diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index b2f5ff391b..2ab8b1cf8d 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -51,7 +51,7 @@ The ObjectStack protocol defines **15 kernel services** registered via the `Core │ Plugin Layer │ │ All other services: analytics, auth, automation, │ │ ui, realtime, notification, ai, i18n, │ -│ search, file-storage, cache, queue, job │ +│ search, storage, cache, queue, job │ │ │ │ Discovery API reports availability per service │ │ (available / degraded / stub / unavailable) so │ @@ -75,7 +75,7 @@ The ObjectStack protocol defines **15 kernel services** registered via the `Core | 8 | **notification** | `optional` | 7 | ❌ Plugin Required | `@objectstack/service-messaging` | | 9 | **ai** | `optional` | — | ❌ Nothing ships in this repo | `@objectstack/service-ai` (Cloud/EE — not installable, so the table entry is `null`) | | 10 | **i18n** | `core` | 3 | ✅ Built-in (in-memory fallback) | `@objectstack/service-i18n` | -| 11 | **file-storage** | `optional` | — | ❌ Plugin Required | `@objectstack/service-storage` | +| 11 | **storage** (deprecated v17 alias: `file-storage`, #9683) | `optional` | — | ❌ Plugin Required | `@objectstack/service-storage` | | 12 | **search** | `optional` | — | ❌ Nothing ships | — | | 13 | **cache** | `core` | — | ✅ Built-in (in-memory fallback) | `@objectstack/service-cache` | | 14 | **queue** | `core` | — | ✅ Built-in (in-memory fallback) | `@objectstack/service-queue` | @@ -480,11 +480,11 @@ AppPlugin will: ## 11–15. Infrastructure Services -`cache`, `queue`, and `job` are `core` services: like `i18n`, the kernel auto-injects an in-memory fallback when no plugin registers them (see `CORE_FALLBACK_FACTORIES` in `packages/core/src/fallbacks/`). The `optional` services (`file-storage`, `search`) stay disabled until a plugin provides them. +`cache`, `queue`, and `job` are `core` services: like `i18n`, the kernel auto-injects an in-memory fallback when no plugin registers them (see `CORE_FALLBACK_FACTORIES` in `packages/core/src/fallbacks/`). The `optional` services (`storage`, `search`) stay disabled until a plugin provides them. | Service | Description | |:--------|:------------| -| **file-storage** | Unified upload/download/delete via `@objectstack/service-storage`, which mounts `/api/v1/storage` itself. Adapters: local FS and S3 (the S3 adapter's `endpoint` + path-style options cover S3-compatible services such as MinIO and R2). | +| **storage** (deprecated v17 alias: `file-storage`, #9683) | Unified upload/download/delete via `@objectstack/service-storage`, which mounts `/api/v1/storage` itself. Adapters: local FS and S3 (the S3 adapter's `endpoint` + path-style options cover S3-compatible services such as MinIO and R2). | | **search** | **Nothing ships.** `ISearchService` and the engine enum (`elasticsearch`, `meilisearch`, …) exist in `@objectstack/spec`, but no package implements the contract or registers the `search` slot, so `CORE_SERVICE_PROVIDER.search` is `null`. | | **cache** | General-purpose cache. In-memory fallback; memory or Redis adapter via `@objectstack/service-cache`. | | **queue** | Message queue. In-memory fallback; durable DB-backed adapter (`sys_job_queue`) via `@objectstack/service-queue` (no BullMQ/Redis adapter is shipped). | diff --git a/content/docs/references/api/dispatcher.mdx b/content/docs/references/api/dispatcher.mdx index 76555052c1..6e934393dc 100644 --- a/content/docs/references/api/dispatcher.mdx +++ b/content/docs/references/api/dispatcher.mdx @@ -43,7 +43,7 @@ const result = DispatcherConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **routes** | `{ prefix: string; service: Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| …>; authRequired: boolean; criticality: Enum<'required' \| 'core' \| 'optional'>; … }[]` | ✅ | Route-to-service mappings | +| **routes** | `{ prefix: string; service: Enum<'metadata' \| 'data' \| 'auth' \| 'storage' \| 'file-storage' \| 'search' \| 'cache' \| …>; authRequired: boolean; criticality: Enum<'required' \| 'core' \| 'optional'>; … }[]` | ✅ | Route-to-service mappings | | **fallback** | `Enum<'404' \| 'proxy' \| 'custom'>` | optional (default: `"404"`) | Behavior when no route matches | | **proxyTarget** | `string` | optional | Proxy target URL when fallback is "proxy" | @@ -83,7 +83,7 @@ Route-resolution failure mode emitted in `error.code` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **prefix** | `string` | ✅ | URL path prefix for routing (e.g. /api/v1/data) | -| **service** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | Target core service name | +| **service** | `Enum<'metadata' \| 'data' \| 'auth' \| 'storage' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | Target core service name | | **authRequired** | `boolean` | optional (default: `true`) | Whether authentication is required | | **criticality** | `Enum<'required' \| 'core' \| 'optional'>` | optional (default: `"optional"`) | Service criticality level for unavailability handling | | **permissions** | `string[]` | optional | Required permissions for this route namespace | diff --git a/content/docs/references/system/core-services.mdx b/content/docs/references/system/core-services.mdx index 897bbc0827..aa068a2833 100644 --- a/content/docs/references/system/core-services.mdx +++ b/content/docs/references/system/core-services.mdx @@ -36,6 +36,7 @@ const result = CoreServiceName.parse(data); * `metadata` * `data` * `auth` +* `storage` * `file-storage` * `search` * `cache` @@ -65,7 +66,7 @@ const result = CoreServiceName.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | | +| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'storage' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | | | **enabled** | `boolean` | ✅ | | | **status** | `Enum<'running' \| 'stopped' \| 'degraded' \| 'initializing'>` | ✅ | | | **version** | `string` | optional | | @@ -82,7 +83,7 @@ const result = CoreServiceName.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | | -| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | | +| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'storage' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | | | **options** | `Record` | optional | | diff --git a/packages/cli/src/commands/migrate/files-to-references.ts b/packages/cli/src/commands/migrate/files-to-references.ts index 35ebb9a763..a7d02bf8f2 100644 --- a/packages/cli/src/commands/migrate/files-to-references.ts +++ b/packages/cli/src/commands/migrate/files-to-references.ts @@ -194,7 +194,9 @@ export default class MigrateFilesToReferences extends Command { } const getStorage = () => { try { - return stack.kernel.getService('file-storage'); + // Canonical slot since #9683 (service-storage also registers the + // deprecated `file-storage` alias with the same instance in v17). + return stack.kernel.getService('storage'); } catch { return null; } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 46c50520d5..dfd1c27a33 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2949,7 +2949,10 @@ const SERVICE_CONFIG: Record DISPATCHER_GATED_SERVICES.has(serviceName) @@ -4287,6 +4290,16 @@ export class ObjectStackProtocolImplementation implements } } + // [#9683] `file-storage` is the deprecated v17 alias of the `storage` + // slot, and it is a key existing consumers really read off this + // document (objectui's console endpoint catalog is keyed by it). The + // registry resolves both names to the same instance, so discovery + // mirrors the canonical row VERBATIM under the alias key until the + // alias retires at the next major — dropping the key would be the + // silent inside-a-major break the ruling forbids. Deliberately a + // byte-equal copy, not a second opinion: one slot, two spellings. + services['file-storage'] = { ...services['storage'] }; + // Build routes from services — a flat convenience map for client routing const serviceToRouteKey: Record = { analytics: 'analytics', @@ -4297,7 +4310,7 @@ export class ObjectStackProtocolImplementation implements notification: 'notifications', ai: 'ai', i18n: 'i18n', - 'file-storage': 'storage', + storage: 'storage', // [#6633] The package-management surface. `package` is NOT a // CoreServiceName slot, so it must not enter SERVICE_CONFIG — a // non-slot row there is the shape of the retired `graphql` defect, @@ -4415,7 +4428,7 @@ export class ObjectStackProtocolImplementation implements export: registeredServices.has('automation') || registeredServices.has('queue'), // [#5672] Serveability-gated, was presence-only. Two reasons, and // the second is the binding one: - // 1. `declared === enforced` — a self-declared stub file-storage + // 1. `declared === enforced` — a self-declared stub storage service // mounts no HTTP surface, so this builder already withholds // `routes.storage` from it; advertising chunked upload anyway // promised an upload endpoint that cannot exist. @@ -4424,7 +4437,7 @@ export class ObjectStackProtocolImplementation implements // would make the two producers give the SAME host opposite // answers for the SAME key — a new dialect inside the // vocabulary this issue exists to unify. - chunkedUpload: capabilityServed('file-storage'), + chunkedUpload: capabilityServed('storage'), // Atomic cross-object batch (#3298 / #1604 / ADR-0034 item 4): the // REST /batch endpoint runs its ops inside `engine.transaction()`, // which only opens a real (all-or-nothing) transaction when the @@ -4450,10 +4463,10 @@ export class ObjectStackProtocolImplementation implements // answer here, not a stand-in for one — and it matches the runtime // dispatcher's answer for the same reason, in the same words. websockets: false, - // Storage: the `file-storage` slot, gated on serveability rather + // Storage: the `storage` slot (#9683), gated on serveability rather // than presence — a self-declared stub mounts nothing, and this // builder already withholds `routes.storage` from it. - files: capabilityServed('file-storage'), + files: capabilityServed('storage'), analytics: capabilityServed('analytics'), ai: capabilityServed('ai'), // Slot is `notification` (singular, CoreServiceName); the capability diff --git a/packages/metadata/ROADMAP.md b/packages/metadata/ROADMAP.md index 4fb4a9ecb4..b187f61deb 100644 --- a/packages/metadata/ROADMAP.md +++ b/packages/metadata/ROADMAP.md @@ -188,7 +188,8 @@ Storage backend has moved out of `MetadataPlugin` and into the dedicated `IStorageService` contract (`@objectstack/spec/contracts/storage-service`). `@objectstack/service-storage` ships local-FS and S3 adapters; the cloud control plane (`packages/services/service-cloud/src/cloud-artifact-api-plugin.ts`) -uses the kernel-registered `file-storage` service for content-addressable +uses the kernel-registered storage service (slot `storage`; resolved there via +the deprecated v17 alias `file-storage`, #9683) for content-addressable artifact persistence (`artifacts/${projectId}/${commitId}.json`). - [x] Object-storage abstraction available via `StorageServicePlugin` diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 4af85770e7..3c5d002d54 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -298,14 +298,14 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => // dispatcher domain, now that those domains gate on `handlerReady` too. it('should not advertise routes for self-declared stubs in the other dispatcher-owned slots', async () => { const mockServices = new Map(); - for (const name of ['automation', 'notification', 'ai', 'i18n', 'file-storage']) { + for (const name of ['automation', 'notification', 'ai', 'i18n', 'storage']) { mockServices.set(name, { __serviceInfo: { status: 'stub', message: 'dev fake' } }); } protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - for (const name of ['automation', 'notification', 'ai', 'i18n', 'file-storage']) { + for (const name of ['automation', 'notification', 'ai', 'i18n', 'storage']) { expect(discovery.services[name].enabled, `${name}.enabled`).toBe(true); expect(discovery.services[name].status, `${name}.status`).toBe('stub'); expect(discovery.services[name].handlerReady, `${name}.handlerReady`).toBe(false); @@ -322,14 +322,14 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => // them. it('should keep advertising routes for degraded implementations that really serve', async () => { const mockServices = new Map(); - mockServices.set('file-storage', { __serviceInfo: { status: 'degraded', message: 'in-memory' } }); + mockServices.set('storage', { __serviceInfo: { status: 'degraded', message: 'in-memory' } }); mockServices.set('i18n', { __serviceInfo: { status: 'degraded', message: 'in-memory' } }); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - expect(discovery.services['file-storage'].status).toBe('degraded'); - expect(discovery.services['file-storage'].handlerReady).toBe(true); + expect(discovery.services['storage'].status).toBe('degraded'); + expect(discovery.services['storage'].handlerReady).toBe(true); expect(discovery.routes.storage).toBe('/api/v1/storage'); expect(discovery.routes.i18n).toBe('/api/v1/i18n'); }); @@ -396,19 +396,36 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => } }); - it('should map file-storage service to storage route', async () => { + it('should map the storage service slot to the storage route', async () => { const mockServices = new Map(); - mockServices.set('file-storage', {}); + mockServices.set('storage', {}); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - expect(discovery.services['file-storage'].enabled).toBe(true); - expect(discovery.services['file-storage'].status).toBe('available'); + expect(discovery.services['storage'].enabled).toBe(true); + expect(discovery.services['storage'].status).toBe('available'); expect(discovery.routes.storage).toBe('/api/v1/storage'); }); + // [#9683] `file-storage` is the deprecated v17 alias of the `storage` slot. + // Existing discovery readers key on it, so both producers mirror the + // canonical row verbatim under the alias key until it retires at the next + // major — filled or empty alike. + it('mirrors the storage row verbatim under the deprecated file-storage alias key', async () => { + const filled = new Map([['storage', {}]]); + protocol = new ObjectStackProtocolImplementation(engine, () => filled); + const withStorage = await protocol.getDiscovery(); + expect(withStorage.services['file-storage']).toEqual(withStorage.services['storage']); + expect(withStorage.services['file-storage'].enabled).toBe(true); + + protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); + const without = await protocol.getDiscovery(); + expect(without.services['file-storage']).toEqual(without.services['storage']); + expect(without.services['file-storage'].enabled).toBe(false); + }); + it('should use consistent /api/v1/ route prefix for all services', async () => { const mockServices = new Map(); mockServices.set('auth', {}); @@ -491,7 +508,7 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => const mockServices = new Map(); mockServices.set('automation', {}); mockServices.set('search', {}); - mockServices.set('file-storage', {}); + mockServices.set('storage', {}); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); diff --git a/packages/plugins/plugin-dev/README.md b/packages/plugins/plugin-dev/README.md index 2c63de0858..074f8f4b85 100644 --- a/packages/plugins/plugin-dev/README.md +++ b/packages/plugins/plugin-dev/README.md @@ -49,7 +49,7 @@ plugins: [ seedAdminUser: true, services: { dispatcher: false, // Skip extended API routes - 'file-storage': false, // Skip the storage service + storage: false, // Skip the storage service ('file-storage' also accepted as its deprecated v17 alias) }, }), ] @@ -67,7 +67,7 @@ plugins: [ | Hono Server | `@objectstack/plugin-hono-server` | HTTP server on configured port | | REST API | `@objectstack/rest` | Auto-generated CRUD + metadata endpoints | | Dispatcher | `@objectstack/runtime` | Auth routes, GraphQL, packages, storage bridges | -| Storage | `@objectstack/service-storage` | `file-storage` service (local-disk adapter, files under `./storage`) | +| Storage | `@objectstack/service-storage` | `storage` service (local-disk adapter, files under `./storage`; also registered under the deprecated `file-storage` alias) | | Realtime | `@objectstack/service-realtime` | `realtime` service (in-memory adapter) | | I18n | `@objectstack/service-i18n` | Auto-registered when the stack declares translations | diff --git a/packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts index 491013f267..799d9dbe7f 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts @@ -204,7 +204,7 @@ describe('DevPlugin — an optional service that is installed and fails to const expect(line).not.toContain('not installed'); expect(line).toContain('code: STORAGE_ADAPTER_MISCONFIGURED'); expect(line).toContain('OS_STORAGE_ROOT is not writable'); - expect(line).toContain('the file-storage slot stays empty'); + expect(line).toContain('the storage slot stays empty'); }); it('leaves the slot empty and still boots — this card changes the diagnosis, not the outcome', async () => { diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index a59d89a05f..f7b7309164 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.test.ts @@ -98,7 +98,7 @@ describe('DevPlugin', () => { 'security.permissions', 'security.rls', 'security.fieldMasker', 'ai', 'automation', 'notification', 'analytics', 'cache', 'queue', 'job', 'i18n', 'metadata', - 'file-storage', 'search', 'realtime', 'workflow', + 'storage', 'file-storage', 'search', 'realtime', 'workflow', ]) { expect(registeredServices.has(name), `${name} slot must stay empty`).toBe(false); } @@ -262,6 +262,7 @@ describe('DevPlugin', () => { .filter((line: any) => typeof line === 'string'); expect(logLines.some((l: string) => l.includes('service-storage not installed'))).toBe(true); expect(logLines.some((l: string) => l.includes('service-realtime not installed'))).toBe(true); + expect(registeredServices.has('storage')).toBe(false); expect(registeredServices.has('file-storage')).toBe(false); expect(registeredServices.has('realtime')).toBe(false); }); @@ -281,6 +282,8 @@ describe('DevPlugin', () => { dispatcher: false, security: false, i18n: false, + // Deliberately the deprecated alias spelling — pins that a v17 + // config written before the #9683 rename still skips the service. 'file-storage': false, realtime: false, }, diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 218be02bdc..cb05409182 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -47,7 +47,9 @@ export interface DevPluginOptions { * this plugin can wire is enabled. Set a name to `false` to skip it. * * Available toggles: 'objectql', 'driver', 'auth', 'server', 'rest', - * 'dispatcher', 'security', 'i18n', 'file-storage', 'realtime'. + * 'dispatcher', 'security', 'i18n', 'storage', 'realtime'. The storage + * toggle also accepts its deprecated v17 alias spelling 'file-storage' + * (#9683) — setting either to `false` skips the storage service. * * Toggles for the retired dev stubs (ADR-0115 — 'cache', 'queue', 'ai', * 'automation', …) are accepted and ignored: those slots are no longer @@ -351,7 +353,7 @@ function reportOptionalLoadFailure(ctx: PluginContext, err: unknown, spec: Optio * | REST API | `@objectstack/rest` | Auto-generated CRUD + metadata endpoints | * | Dispatcher | `@objectstack/runtime` | Auth, GraphQL, packages, storage, etc. | * | App/Metadata | `@objectstack/runtime` | Project metadata (objects, views, apps) | - * | Storage | `@objectstack/service-storage` | file-storage service (local-disk adapter) | + * | Storage | `@objectstack/service-storage` | storage service (local-disk adapter) | * | Realtime | `@objectstack/service-realtime` | realtime service (in-memory adapter) | * | I18n | `@objectstack/service-i18n` | When the stack declares translations | * @@ -553,11 +555,12 @@ export class DevPlugin implements Plugin { // 3d. Optional capability services (ADR-0115 D4) — the slots the retired // dev stubs used to fake are filled by the REAL service packages when // they are installed, following the same auto-detect pattern as 3b: - // `service-storage` registers `file-storage` (local-disk adapter, real + // `service-storage` registers `storage` (canonical since #9683) plus + // its deprecated `file-storage` alias (local-disk adapter, real // files under ./storage), `service-realtime` registers `realtime` // (its default in-memory adapter). Not installed → the slot stays - // empty, exactly as in production. - if (enabled('file-storage')) { + // empty, exactly as in production. Either toggle spelling skips it. + if (enabled('storage') && enabled('file-storage')) { try { const { StorageServicePlugin } = await import('@objectstack/service-storage') as any; this.childPlugins.push(new StorageServicePlugin()); @@ -565,9 +568,9 @@ export class DevPlugin implements Plugin { } catch (err) { reportOptionalLoadFailure(ctx, err, { packages: ['@objectstack/service-storage'], - absent: ' ℹ @objectstack/service-storage not installed — the file-storage slot stays empty', + absent: ' ℹ @objectstack/service-storage not installed — the storage slot stays empty', absentLevel: 'info', - outcome: 'the file-storage slot stays empty', + outcome: 'the storage slot stays empty', }); } } diff --git a/packages/plugins/plugin-email/src/attachment-reclaim.test.ts b/packages/plugins/plugin-email/src/attachment-reclaim.test.ts index a2408df0c8..d5f5f4f84c 100644 --- a/packages/plugins/plugin-email/src/attachment-reclaim.test.ts +++ b/packages/plugins/plugin-email/src/attachment-reclaim.test.ts @@ -249,7 +249,7 @@ describe('nothing is deleted early', () => { }); expect(out.kind).toBe('rearmed'); - expect((out as any).reason).toContain('file-storage capability is not mounted'); + expect((out as any).reason).toContain('storage capability is not mounted'); }); it('states the consequence at `error` when it cannot re-arm — those bytes are now permanent', async () => { diff --git a/packages/plugins/plugin-email/src/attachment-reclaim.ts b/packages/plugins/plugin-email/src/attachment-reclaim.ts index 4a6b690d56..c8d6820ee7 100644 --- a/packages/plugins/plugin-email/src/attachment-reclaim.ts +++ b/packages/plugins/plugin-email/src/attachment-reclaim.ts @@ -5,7 +5,7 @@ * * ## The one job this does * - * Attachment bytes written to the `file-storage` capability by + * Attachment bytes written to the `storage` capability by * `attachment-storage.ts` are a **delivery artifact**: once the `sys_email` * row they belong to is terminal and a grace window has passed, nothing will * ever need them again. This module deletes them and rewrites the row's @@ -186,7 +186,7 @@ export async function reclaimAttachmentContent( // job, and say so if nothing can. return rearmOrStall( opts, rowId, graceMs, - 'the file-storage capability is not mounted on the process running the reclaim job, so the content ' + 'the storage capability is not mounted on the process running the reclaim job, so the content ' + 'cannot be deleted here', ); } @@ -296,7 +296,7 @@ async function rearmOrStall( `EmailServicePlugin: out-of-row attachment content for sys_email row '${rowId}' could NOT be reclaimed and ` + `could NOT be rescheduled — ${reason}. Those storage objects will now stay in the backend forever unless ` + 'they are deleted by hand. Fix: keep the durable queue service (@objectstack/service-queue over an ' - + 'ObjectQL engine) and the file-storage capability (@objectstack/service-storage) mounted on the process ' + + 'ObjectQL engine) and the storage capability (@objectstack/service-storage) mounted on the process ' + `that consumes email jobs, then delete the leftovers under the row's key prefix.`, ); return { kind: 'stalled', rowId, reason }; diff --git a/packages/plugins/plugin-email/src/attachment-storage.test.ts b/packages/plugins/plugin-email/src/attachment-storage.test.ts index 8e4d021c3f..eb094faf3c 100644 --- a/packages/plugins/plugin-email/src/attachment-storage.test.ts +++ b/packages/plugins/plugin-email/src/attachment-storage.test.ts @@ -33,7 +33,7 @@ import { const sha = (s: string | Buffer) => `sha256:${createHash('sha256').update(s).digest('hex')}`; -/** In-memory stand-in for the `file-storage` capability. */ +/** In-memory stand-in for the `storage` capability. */ function fakeStore(opts: { failUploadAt?: number; failDelete?: Set } = {}) { const objects = new Map(); let uploads = 0; @@ -230,11 +230,11 @@ describe('reading content back — every failure is a refusal, never a stripped const store = fakeStore(); const res = await offloaded(store); await expect(decodeAttachmentsFromRowAsync(res.json, undefined)) - .rejects.toThrow(/no file-storage capability is mounted on this process/); + .rejects.toThrow(/no storage capability is mounted on this process/); // …and the synchronous entry point says the same thing rather than // silently returning a message with one fewer attachment. expect(() => decodeAttachmentsFromRow(res.json)) - .toThrow(/no file-storage capability to fetch it from/); + .toThrow(/no storage capability to fetch it from/); }); }); diff --git a/packages/plugins/plugin-email/src/attachment-storage.ts b/packages/plugins/plugin-email/src/attachment-storage.ts index 298f9f0050..49cbeb3164 100644 --- a/packages/plugins/plugin-email/src/attachment-storage.ts +++ b/packages/plugins/plugin-email/src/attachment-storage.ts @@ -9,7 +9,7 @@ * A message whose attachments exceed {@link SYS_EMAIL_ATTACHMENT_LIMIT_BYTES} * used to be pushed back onto inline delivery — whole, but with none of the * durability queue delivery exists to provide. Its content now goes to the - * `file-storage` capability, the row records a **reference** plus the audit + * `storage` capability, the row records a **reference** plus the audit * metadata, and the queue worker rebuilds the message by fetching the content * back. The messages most likely to matter (a signed contract, an exported * report) stop being the ones the platform is weakest about. @@ -35,7 +35,7 @@ * {@link EmailAttachmentStore} names the three `IStorageService` methods this * package calls and nothing else. Declared in the *consumer* (the #5210 * `LifecycleFloorRegistrar` shape) so the slot lookup carries a contract type: - * `getService('file-storage')` would switch off checking on the exact + * `getService('storage')` would switch off checking on the exact * calls whose failure is invisible — an upload that silently no-ops is a * message whose content is gone and whose row says it is there. * @@ -56,7 +56,7 @@ import { } from './sys-email-payload.js'; /** - * The slice of the `file-storage` capability (`IStorageService`) that + * The slice of the `storage` capability (`IStorageService`) that * out-of-row attachment content actually uses. * * Declared structurally in this package on purpose (#5210): a slot lookup that @@ -246,7 +246,7 @@ export async function offloadAttachmentsToStorage( return { kind: 'unavailable', detail: - `uploading attachment '${String(att.filename ?? '(unnamed)')}' to the file-storage capability failed ` + `uploading attachment '${String(att.filename ?? '(unnamed)')}' to the storage capability failed ` + `(${String(err?.message ?? err)})`, }; } @@ -316,7 +316,7 @@ export async function fetchAttachmentContent( // strict decoder downstream verifies size and digest, and this keeps the // failure at the layer that can name the key. throw new Error( - `the file-storage capability returned a non-Buffer value for attachment content key '${storageKey}'`, + `the storage capability returned a non-Buffer value for attachment content key '${storageKey}'`, ); } return bytes; diff --git a/packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts b/packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts index 3c45ffa076..b96ab1692b 100644 --- a/packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts @@ -100,7 +100,7 @@ function fakeEngine() { return engine; } -/** In-memory `file-storage` capability, shaped like `IStorageService`. */ +/** In-memory `storage` capability, shaped like `IStorageService`. */ function fakeStorage(opts: { failUpload?: boolean } = {}) { const objects = new Map(); return { @@ -165,7 +165,7 @@ async function boot(opts: BootOpts = {}) { objectql: engine, queue: adapter, }; - if (storage) services['file-storage'] = storage; + if (storage) services['storage'] = storage; const ctx = fakeCtx(services); const plugin = new EmailServicePlugin({ @@ -317,7 +317,7 @@ describe('a 300 KB attachment now gets the durability guarantee', () => { // ── the deployments that cannot store out of row ─────────────────────────── -describe('a deployment without the file-storage capability', () => { +describe('a deployment without the storage capability', () => { it('still delivers the message WHOLE, inline, and says what to mount', async () => { const h = await boot({ storage: null }); @@ -337,7 +337,7 @@ describe('a deployment without the file-storage capability', () => { expect(h.sysEmail()[0].attachments_json).toBeUndefined(); // Loud, with the obstacle and the fix. const info = h.infoLines(); - expect(info).toContain('no file-storage capability is mounted'); + expect(info).toContain('no storage capability is mounted'); expect(info).toContain('@objectstack/service-storage'); }); diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 717a4477d4..3f8308ba66 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -200,8 +200,10 @@ interface MailSettingsSurface { * (`__serviceInfo.status === 'degraded'`, ADR-0076 D12), so read the label. */ /** - * Resolve the `file-storage` capability that can hold out-of-row attachment - * content (#5172), or `undefined`. + * Resolve the `storage` capability that can hold out-of-row attachment + * content (#5172), or `undefined`. (`storage` is the canonical slot since + * #9683; `file-storage` is its deprecated v17 alias — service-storage + * registers the same instance under both names.) * * Typed at the lookup — `EmailAttachmentStore`, declared in this package — * rather than erased to `any` (#4127/#4251, the #5210 shape): the three calls @@ -211,7 +213,7 @@ interface MailSettingsSurface { * like "the operator has no storage", so mail would quietly stop being durable * with a log line blaming the deployment. * - * Unlike `queue`, the kernel injects no in-memory fallback for `file-storage`, + * Unlike `queue`, the kernel injects no in-memory fallback for `storage`, * so absence is a plain throw from `getService` and there is no `degraded` * label to read. The structural probe is still real: `SwappableStorageService` * forwards to whatever adapter the `storage` settings namespace names. @@ -220,7 +222,7 @@ export function resolveAttachmentStore( getService: (name: string) => unknown, ): EmailAttachmentStore | undefined { let storage: unknown; - try { storage = getService('file-storage'); } catch { return undefined; } + try { storage = getService('storage'); } catch { return undefined; } const candidate = storage as Partial | undefined; if ( !candidate @@ -607,7 +609,7 @@ export class EmailServicePlugin implements Plugin { ctx.logger.info('EmailServicePlugin: sys_email persistence + template loader enabled'); // Out-of-row attachment content (#5172). A thunk for the same reason the - // queue is one: `file-storage` is a SwappableStorageService whose + // queue is one: `storage` is a SwappableStorageService whose // adapter changes when the `storage` settings namespace does, and it may // register after this plugin. Resolving here once would pin the service // to the adapter that happened to exist at boot. diff --git a/packages/plugins/plugin-email/src/email-service.attachment-storage.test.ts b/packages/plugins/plugin-email/src/email-service.attachment-storage.test.ts index 4a6f0129f1..d946a9c731 100644 --- a/packages/plugins/plugin-email/src/email-service.attachment-storage.test.ts +++ b/packages/plugins/plugin-email/src/email-service.attachment-storage.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// EmailService — large attachments through the file-storage capability +// EmailService — large attachments through the storage capability // (objectstack#5172). // // The routing table these pin, exhaustively, because every cell of it used to @@ -188,7 +188,7 @@ describe('the 256 KiB boundary decides in-row vs in-storage, and includes equali // ── the loud fallbacks ───────────────────────────────────────────────────── describe('when the content cannot be stored, the message still goes out WHOLE — and it is said out loud', () => { - it('no file-storage capability: delivers inline, names what to mount, stores nothing', async () => { + it('no storage capability: delivers inline, names what to mount, stores nothing', async () => { const queue = makeQueue(); const { svc, rows, transport, log } = makeService({ queue }); // no store wired @@ -205,7 +205,7 @@ describe('when the content cannot be stored, the message still goes out WHOLE // Loud: the one line the operator sees names the obstacle and the fix. const info = lines(log.info); expect(info).toContain('queue delivery skipped for one message'); - expect(info).toContain('no file-storage capability is mounted'); + expect(info).toContain('no storage capability is mounted'); expect(info).toContain('@objectstack/service-storage'); expect(info).toContain(String(SYS_EMAIL_ATTACHMENT_LIMIT_BYTES)); }); @@ -348,7 +348,7 @@ describe('rebuilding a stored-content row for delivery', () => { const out = await h.svc.deliverPersistedRow(row, { maxAttempts: 1 }); expect(out.status).toBe('failed'); - expect(out.error).toContain('no file-storage capability is mounted on this process'); + expect(out.error).toContain('no storage capability is mounted on this process'); expect(h.transport.send).not.toHaveBeenCalled(); }); diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index f893650e15..326e9c16a0 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -104,7 +104,7 @@ export interface DeliverAttemptOptions { /** * Reconstructing a `sys_email` row into a sendable message needs the - * `file-storage` capability once a row's attachments live out of it (#5172), + * `storage` capability once a row's attachments live out of it (#5172), * and that is asynchronous — hence this async twin of {@link rowToNormalized}. * * `fetchContent` absent ⇒ a row that needs storage fails, loudly, exactly as @@ -459,7 +459,7 @@ export interface EmailServiceOptions { */ queueDelivery?: EmailQueueDelivery; /** - * Out-of-row attachment content through the `file-storage` capability + * Out-of-row attachment content through the `storage` capability * (#5172). Set ⇒ a message whose attachments exceed * {@link SYS_EMAIL_ATTACHMENT_LIMIT_BYTES} can still be queued, with its * content in storage and a reference on the row. Unset (or unresolvable) ⇒ @@ -635,7 +635,7 @@ export class EmailService implements IEmailService { const id = newId(); // ── OUT-OF-ROW ATTACHMENT CONTENT (#5172) ────────────────────────────── - // Over the in-row budget, in queue mode, with the file-storage capability + // Over the in-row budget, in queue mode, with the storage capability // mounted: the content goes to storage and the row carries a reference, so // this message gets the same durability as every other. Anything missing // here leaves `over-limit` standing — inline delivery, whole, with the @@ -788,9 +788,9 @@ export class EmailService implements IEmailService { `EmailService: queue delivery skipped for one message — its attachments total ` + `${encodedAttachments.totalBytes} bytes, over the ${SYS_EMAIL_ATTACHMENT_LIMIT_BYTES}-byte limit a ` + 'sys_email row carries, so the message was delivered inline (in-process retries only) rather than ' - + 'queued without them. Content that large is queueable through the file-storage capability (#5172), ' + + 'queued without them. Content that large is queueable through the storage capability (#5172), ' + `but ${encodedAttachments.storageDetail ?? 'that path was not attempted for this message'}. ` - + 'Fix: mount the file-storage capability (@objectstack/service-storage) so large attachments are ' + + 'Fix: mount the storage capability (@objectstack/service-storage) so large attachments are ' + 'stored out of the row and the message can be delivered durably.', ); return undefined; @@ -820,7 +820,7 @@ export class EmailService implements IEmailService { /** * Try to move an over-budget message's attachment content out of the row and - * into the `file-storage` capability (#5172). + * into the `storage` capability (#5172). * * Returns a `storage` verdict on success, and the ORIGINAL `over-limit` * verdict — annotated with why — on every failure. That asymmetry is the @@ -845,7 +845,7 @@ export class EmailService implements IEmailService { if (!storage) { return { ...overLimit, - storageDetail: 'no file-storage capability is mounted, so there is nowhere to put the content', + storageDetail: 'no storage capability is mounted, so there is nowhere to put the content', }; } @@ -883,7 +883,7 @@ export class EmailService implements IEmailService { + 'not be deleted again: ' + failed.map((f) => `'${f.key}' (${f.error})`).join('; ') + '. Nothing references those bytes and nothing will ever reclaim them. Fix: delete them by hand under ' - + 'the sys_email/attachments/ prefix, and check why the file-storage backend is refusing deletes.', + + 'the sys_email/attachments/ prefix, and check why the storage backend is refusing deletes.', ); } @@ -894,7 +894,7 @@ export class EmailService implements IEmailService { this.options.logger?.error?.( `EmailService: a message's attachments could not be stored out of row — ${detail}. The message was ` + 'still SENT, inline and whole, but it did not get durable queue delivery: a failure would be retried ' - + 'only in this process and lost if it dies. Fix: check the file-storage capability ' + + 'only in this process and lost if it dies. Fix: check the storage capability ' + '(@objectstack/service-storage — credentials, bucket, disk), or accept inline delivery for messages ' + `with attachments over ${SYS_EMAIL_ATTACHMENT_LIMIT_BYTES} bytes.`, ); @@ -1043,7 +1043,7 @@ export class EmailService implements IEmailService { this.options.logger?.error?.( `EmailService: ${keys.length} attachment storage object(s) for sys_email row '${rowId}' cannot be ` + 'scheduled for reclamation — no durable queue service is available now, although one was when the ' - + 'content was stored. Those bytes will stay in the file-storage backend until they are deleted by ' + + 'content was stored. Those bytes will stay in the storage backend until they are deleted by ' + 'hand. Fix: keep @objectstack/service-queue (over an ObjectQL engine) mounted for the life of the ' + 'process, then delete leftovers under the sys_email/attachments/ prefix.', ); @@ -1119,7 +1119,7 @@ export class EmailService implements IEmailService { const reclaimKeys = storageKeysInColumn(source.attachments_json); let normalized: NormalizedEmailMessage; try { - // Async because a row's attachments may live in the file-storage + // Async because a row's attachments may live in the storage // capability (#5172). A fetch that fails — outage, deleted object, no // capability mounted at all — throws and lands the row at `failed` with // the reason, which is the whole point: an unfetchable attachment must diff --git a/packages/plugins/plugin-email/src/sys-email-payload.test.ts b/packages/plugins/plugin-email/src/sys-email-payload.test.ts index 61d072dfe9..9450c76b47 100644 --- a/packages/plugins/plugin-email/src/sys-email-payload.test.ts +++ b/packages/plugins/plugin-email/src/sys-email-payload.test.ts @@ -218,7 +218,7 @@ describe('a column that lies is rejected, never partially delivered', () => { it('rejects a storageKey-only attachment, naming the capability it would need', () => { expect(decodeAtt(JSON.stringify([ { filename: 'a.txt', size: 2, hash: sha('hi'), contentForm: 'buffer', storageKey: 'blob/abc' }, - ]))).toThrow(/no file-storage capability to fetch it from/); + ]))).toThrow(/no storage capability to fetch it from/); }); it('rejects truncated content (size disagrees)', () => { diff --git a/packages/plugins/plugin-email/src/sys-email-payload.ts b/packages/plugins/plugin-email/src/sys-email-payload.ts index 1e90f2d572..6a55bee176 100644 --- a/packages/plugins/plugin-email/src/sys-email-payload.ts +++ b/packages/plugins/plugin-email/src/sys-email-payload.ts @@ -39,7 +39,7 @@ * Out-of-row storage for large attachments (`storageKey`) is phase 2 * (objectstack#5172), and it landed exactly as phase 1 predicted: a * *producer* for the key that was already declared, with no migration. Over - * the budget, the content goes to the `file-storage` capability and the row + * the budget, the content goes to the `storage` capability and the row * carries a reference plus the permanent audit metadata — see * `attachment-storage.ts`. Over the budget with no storage capability (or an * upload that fails), the pre-#5172 answer stands: inline delivery, whole, @@ -125,7 +125,7 @@ export interface PersistedEmailAttachment { /** Base64 of the raw content, when the row carries it (phase 1's only producer). */ inline?: string; /** - * Reference to content held outside the row, in the `file-storage` + * Reference to content held outside the row, in the `storage` * capability (objectstack#5172). * * Written instead of {@link inline} when the message is over @@ -162,14 +162,14 @@ export type EncodedAttachments = * Over {@link SYS_EMAIL_ATTACHMENT_LIMIT_BYTES}. * * Nothing is written to the row **by this encoder**. The caller may still - * offload the content to the `file-storage` capability (#5172); when it + * offload the content to the `storage` capability (#5172); when it * cannot, `storageDetail` carries the sentence explaining why, so the one * log line the operator gets names the actual obstacle instead of only the * byte count. */ | { kind: 'over-limit'; totalBytes: number; storageDetail?: string } /** - * Content held out of the row, in the `file-storage` capability (#5172). + * Content held out of the row, in the `storage` capability (#5172). * `json` goes into `attachments_json`; `keys` is what a later reclaim * deletes. */ @@ -299,7 +299,7 @@ function parseJson(column: string, value: unknown): unknown { export type AttachmentSource = /** Content is in the row; already base64-decoded. */ | { kind: 'inline'; element: PersistedEmailAttachment; bytes: Buffer } - /** Content is in the `file-storage` capability under `storageKey` (#5172). */ + /** Content is in the `storage` capability under `storageKey` (#5172). */ | { kind: 'storage'; element: PersistedEmailAttachment; storageKey: string }; /** @@ -428,8 +428,8 @@ export function decodeAttachmentsFromRow(value: unknown): EmailAttachment[] | un reject( 'attachments_json', `[${i}] ('${source.element.filename}') holds its content out of the row under storageKey ` - + `'${source.storageKey}', and this delivery path has no file-storage capability to fetch it from. ` - + 'Fix: mount the file-storage capability (@objectstack/service-storage) on the process that delivers ' + + `'${source.storageKey}', and this delivery path has no storage capability to fetch it from. ` + + 'Fix: mount the storage capability (@objectstack/service-storage) on the process that delivers ' + 'sys_email rows. Refusing rather than delivering the message without this attachment', ); } @@ -464,7 +464,7 @@ export async function decodeAttachmentsFromRowAsync( reject( 'attachments_json', `[${i}] ('${source.element.filename}') holds its content out of the row under storageKey ` - + `'${source.storageKey}', but no file-storage capability is mounted on this process. Fix: mount it ` + + `'${source.storageKey}', but no storage capability is mounted on this process. Fix: mount it ` + '(@objectstack/service-storage) wherever sys_email rows are delivered. Refusing rather than ' + 'delivering the message without this attachment', ); diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 11f73fd619..1e73b16eb7 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -337,7 +337,7 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => { /** * [#4087] `/storage` is no longer a dispatcher domain. The registry must - * not claim the prefix in either direction — with the `file-storage` slot + * not claim the prefix in either direction — with the `storage` slot * empty AND with it filled, since the retired bridge's whole reason for * existing was "a service is registered, so route to it". It called that * service off-contract (`upload(key, data, options?)` invoked as @@ -354,12 +354,12 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => { const upload = vi.fn(); const download = vi.fn(); - const filled = await makeDispatcher({ 'file-storage': { upload, download } }) + const filled = await makeDispatcher({ storage: { upload, download } }) .dispatch('POST', '/storage/upload', { some: 'file' }, {}, {} as any); expect(filled.response?.status).toBe(404); expect(upload).not.toHaveBeenCalled(); - const get = await makeDispatcher({ 'file-storage': { upload, download } }) + const get = await makeDispatcher({ storage: { upload, download } }) .dispatch('GET', '/storage/file/abc', undefined, {}, {} as any); expect(get.response?.status).toBe(404); expect(download).not.toHaveBeenCalled(); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 7694423efc..3964568e41 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1227,7 +1227,7 @@ describe('HttpDispatcher', () => { it('does not claim /storage — dispatch falls through to ROUTE_NOT_FOUND', async () => { const mockStorage = { upload: vi.fn(), download: vi.fn() }; (kernel as any).getService = vi.fn().mockImplementation((name: string) => { - if (name === 'file-storage') return Promise.resolve(mockStorage); + if (name === 'storage') return Promise.resolve(mockStorage); return null; }); @@ -2716,7 +2716,16 @@ describe('HttpDispatcher', () => { // name-derived guess can never get right. expect(info.services.notification.message).toBe('Install @objectstack/service-messaging to enable'); expect(info.services.auth.message).toBe('Install @objectstack/plugin-auth to enable'); - expect(info.services['file-storage'].message).toBe('Install @objectstack/service-storage to enable'); + expect(info.services.storage.message).toBe('Install @objectstack/service-storage to enable'); + }); + + it('mirrors the storage row verbatim under the deprecated file-storage alias key (#9683)', async () => { + // Existing consumers (objectui's console endpoint catalog) + // read the 'file-storage' services key; the alias key must + // stay byte-equal to the canonical row until it retires at the + // next major. + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services['file-storage']).toEqual(info.services.storage); }); it('says nothing ships rather than naming a package that does not exist', async () => { @@ -3267,7 +3276,8 @@ describe('HttpDispatcher', () => { // `upload(key, data, options?)` as `upload(file, { request })`. The // "degraded store keeps serving" case is the shape of the problem — // asserting a 200 off `upload` mocked to resolve `{ key }`, a return - // value the contract (`Promise`) does not have. `file-storage` + // value the contract (`Promise`) does not have. `storage` + // (spelled `file-storage` before #9683) // keeps its slot and its `handlerReady` gate on the ADVERTISEMENT // (`routes.storage`, pinned in the discovery block above); what it no // longer has is a dispatcher handler to gate. @@ -3346,7 +3356,7 @@ describe('HttpDispatcher', () => { // `unavailable` / "install a plugin" would. it('stops advertising routes/features for stub slots, while still reporting them as stubs', async () => { const stubs: Record = { - 'file-storage': stubbed({ upload: vi.fn() }), + storage: stubbed({ upload: vi.fn() }), automation: stubbed({ execute: vi.fn() }), notification: stubbed({ send: vi.fn() }), ai: stubbed({ chat: vi.fn() }), @@ -3362,7 +3372,7 @@ describe('HttpDispatcher', () => { for (const key of ['files', 'ai', 'notifications', 'i18n'] as const) { expect(info.capabilities[key].enabled, `capabilities.${key}.enabled`).toBe(false); } - for (const key of ['file-storage', 'automation', 'notification', 'ai', 'i18n'] as const) { + for (const key of ['storage', 'automation', 'notification', 'ai', 'i18n'] as const) { expect(info.services[key].enabled, `services.${key}.enabled`).toBe(true); expect(info.services[key].status, `services.${key}.status`).toBe('stub'); expect(info.services[key].handlerReady, `services.${key}.handlerReady`).toBe(false); @@ -3372,7 +3382,7 @@ describe('HttpDispatcher', () => { it('keeps advertising routes/features for degraded slots that really serve', async () => { const degradeds: Record = { - 'file-storage': degraded({ upload: vi.fn() }), + storage: degraded({ upload: vi.fn() }), automation: degraded({ listFlows: vi.fn() }), notification: degraded({ listInbox: vi.fn() }), ai: degraded({ chat: vi.fn() }), @@ -3389,8 +3399,8 @@ describe('HttpDispatcher', () => { expect(info.routes.i18n).toBe('/api/v1/i18n'); expect(info.capabilities.files.enabled).toBe(true); expect(info.capabilities.i18n.enabled).toBe(true); - expect(info.services['file-storage'].status).toBe('degraded'); - expect(info.services['file-storage'].handlerReady).toBe(true); + expect(info.services.storage.status).toBe('degraded'); + expect(info.services.storage.handlerReady).toBe(true); }); }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5c5b33e03a..780812b9c4 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1132,7 +1132,9 @@ export class HttpDispatcher { this.resolveService(kernel, CoreServiceName.enum.auth), this.resolveService(kernel, CoreServiceName.enum.search), this.resolveService(kernel, CoreServiceName.enum.realtime), - this.resolveService(kernel, CoreServiceName.enum['file-storage']), + // Canonical slot since #9683; service-storage registers the + // deprecated `file-storage` alias as the same instance in v17. + this.resolveService(kernel, CoreServiceName.enum.storage), this.resolveService(kernel, CoreServiceName.enum.analytics), this.resolveService(kernel, CoreServiceName.enum.ai), this.resolveService(kernel, CoreServiceName.enum.notification), @@ -1166,7 +1168,8 @@ export class HttpDispatcher { // Same predicate ⇒ same answer. #4000 did this for `analytics` alone; // #4058 extended it to the rest of the dispatcher-owned domains. // - // [#4087] `file-storage` is the one slot here whose surface is NOT a + // [#4087] `storage` (spelled `file-storage` before #9683) is the one + // slot here whose surface is NOT a // dispatcher domain any more — the `/storage` bridge was retired and // service-storage mounts `/api/v1/storage` itself. The predicate still // holds, for the same reason and one step removed: `handlerReady` is @@ -1485,7 +1488,7 @@ export class HttpDispatcher { // MEASURED: async export is driven by automation or by the // queue — the same disjunction `getDiscovery()` uses. export: { enabled: hasAutomation || hasQueue }, - // MEASURED: chunked upload rides the file-storage surface, so + // MEASURED: chunked upload rides the storage surface, so // it is exactly `files` on this host. Two vocabulary keys with // one answer is a fact about the host (one storage surface // serving both), not a copy-paste. @@ -1616,7 +1619,14 @@ export class HttpDispatcher { notification: notificationRegistered ? svcAvailable(routes.notifications, undefined, notificationSvc) : svcUnavailable('notification'), ai: aiRegistered ? svcAvailable(routes.ai, undefined, aiSvc) : svcUnavailable('ai'), i18n: i18nRegistered ? svcAvailable(routes.i18n, undefined, i18nSvc) : svcUnavailable('i18n'), - 'file-storage': filesRegistered ? svcAvailable(routes.storage, undefined, filesSvc) : svcUnavailable('file-storage'), + // Keyed by the canonical slot (#9683), and mirrored VERBATIM + // under the deprecated `file-storage` alias key below — + // existing consumers (objectui's console endpoint catalog) + // read that key off this document, so it stays until the + // alias retires at the next major. One slot, two spellings, + // byte-equal rows. + storage: filesRegistered ? svcAvailable(routes.storage, undefined, filesSvc) : svcUnavailable('storage'), + 'file-storage': filesRegistered ? svcAvailable(routes.storage, undefined, filesSvc) : svcUnavailable('storage'), // [#7602] Presence-gated, and correctly so: this map reports // what is REGISTERED, not what is served. The `route` argument // is already `undefined` — no `/search` is advertised on the diff --git a/packages/services/service-storage/README.md b/packages/services/service-storage/README.md index 2d07cae4d4..29747aa4c0 100644 --- a/packages/services/service-storage/README.md +++ b/packages/services/service-storage/README.md @@ -36,7 +36,7 @@ kernel.use(new StorageServicePlugin({ await kernel.bootstrap(); // Programmatic access -const storage = kernel.getService('file-storage'); +const storage = kernel.getService('storage'); await storage.upload('files/hello.txt', Buffer.from('hello')); ``` @@ -147,7 +147,9 @@ so administrators can switch adapter, configure S3 credentials, and tune TTL / max-upload limits from the Settings hub instead of restarting the process. -- Service key in the kernel: `file-storage` — registered as a +- Service key in the kernel: `storage` (canonical since the 2026-08-18 + maintainer ruling on #9683; `file-storage` stays registered as a + deprecated v17 alias of the same instance) — registered as a `SwappableStorageService` proxy at `init` time. The inner adapter (local FS or S3) is rebuilt and swapped in on every `settings:changed` event for `namespace=storage`. diff --git a/packages/services/service-storage/src/storage-service-plugin.metrics.test.ts b/packages/services/service-storage/src/storage-service-plugin.metrics.test.ts index a8ac95ec2f..c5546c406a 100644 --- a/packages/services/service-storage/src/storage-service-plugin.metrics.test.ts +++ b/packages/services/service-storage/src/storage-service-plugin.metrics.test.ts @@ -55,7 +55,7 @@ describe('StorageServicePlugin observability wiring', () => { }); await plugin.init(ctx); - const storage = ctx._services.get('file-storage'); + const storage = ctx._services.get('storage'); await storage.upload('k.txt', Buffer.from('hi')); await storage.download('k.txt'); @@ -75,7 +75,7 @@ describe('StorageServicePlugin observability wiring', () => { bindToSettings: false, }).init(ctx); - const storage = ctx._services.get('file-storage'); + const storage = ctx._services.get('storage'); await storage.upload('k.txt', Buffer.from('hi')); expect(metrics.samples.some((s) => s.name === SEMCONV.storageOperationsTotal)).toBe(true); }); @@ -98,7 +98,7 @@ describe('StorageServicePlugin observability wiring', () => { // `buildAdapterFromValues` returns a new adapter that should be wired // with the same metrics registry resolved during init(). const next = await (plugin as any).buildAdapterFromValues({ adapter: 'local', local_root: root2 }); - const storage = ctx._services.get('file-storage'); + const storage = ctx._services.get('storage'); storage.swap(next); const before = metrics.samples.length; @@ -114,7 +114,7 @@ describe('StorageServicePlugin observability wiring', () => { local: { rootDir: root }, bindToSettings: false, }).init(ctx); - const storage = ctx._services.get('file-storage'); + const storage = ctx._services.get('storage'); await storage.upload('k.txt', Buffer.from('hi')); const buf = await storage.download('k.txt'); expect(buf.toString('utf-8')).toBe('hi'); diff --git a/packages/services/service-storage/src/storage-service-plugin.test.ts b/packages/services/service-storage/src/storage-service-plugin.test.ts index d09ea0d952..ded8c658cf 100644 --- a/packages/services/service-storage/src/storage-service-plugin.test.ts +++ b/packages/services/service-storage/src/storage-service-plugin.test.ts @@ -68,7 +68,7 @@ function makeFakeSettings(initialValues: Record) { } describe('StorageServicePlugin: settings live-wire', () => { - it('registers a SwappableStorageService as file-storage', async () => { + it('registers a SwappableStorageService as storage', async () => { const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir: await fs.mkdtemp(join(tmpdir(), 'oss-')) }, @@ -76,10 +76,31 @@ describe('StorageServicePlugin: settings live-wire', () => { }); const ctx = makeCtx(); await plugin.init(ctx); - const svc = ctx.getService('file-storage'); + const svc = ctx.getService('storage'); expect(svc).toBeInstanceOf(SwappableStorageService); }); + // The alias-equivalence pin for the #9683 rename (maintainer ruling + // 2026-08-18): `storage` is canonical, `file-storage` stays accepted as a + // deprecated alias within v17, and both spellings MUST resolve the same + // instance — anything else would fork the slot into two services. + it('registers the deprecated file-storage alias as the SAME instance (#9683)', async () => { + const plugin = new StorageServicePlugin({ + adapter: 'local', + local: { rootDir: await fs.mkdtemp(join(tmpdir(), 'oss-')) }, + registerRoutes: false, + }); + const ctx = makeCtx(); + await plugin.init(ctx); + // Plain calls with casts, not `getService(...)` — the fake ctx's + // getService is untyped, and each type-argument call adds a frozen-debt + // TS2347 to this package's shrink-only type-check ledger. + const canonical = ctx.getService('storage') as IStorageService; + const alias = ctx.getService('file-storage') as IStorageService; + expect(alias).toBeInstanceOf(SwappableStorageService); + expect(alias).toBe(canonical); + }); + it('swaps the inner adapter when storage settings change', async () => { const dirA = await fs.mkdtemp(join(tmpdir(), 'oss-a-')); const dirB = await fs.mkdtemp(join(tmpdir(), 'oss-b-')); @@ -95,7 +116,7 @@ describe('StorageServicePlugin: settings live-wire', () => { await plugin.init(ctx); await plugin.start(ctx); - const proxy = ctx.getService('file-storage'); + const proxy = ctx.getService('storage'); const innerBefore = proxy.getInner(); await ctx._flushReady(); @@ -123,7 +144,7 @@ describe('StorageServicePlugin: settings live-wire', () => { await plugin.init(ctx); await plugin.start(ctx); - const proxy = ctx.getService('file-storage'); + const proxy = ctx.getService('storage'); const before = proxy.getInner(); await ctx._flushReady(); expect(proxy.getInner()).toBe(before); @@ -150,7 +171,7 @@ describe('StorageServicePlugin: settings live-wire', () => { await plugin.init(ctx); await plugin.start(ctx); - const proxy = ctx.getService('file-storage') as SwappableStorageService; + const proxy = ctx.getService('storage') as SwappableStorageService; const before = proxy.getInner(); await ctx._flushReady(); @@ -169,7 +190,7 @@ describe('StorageServicePlugin: settings live-wire', () => { await plugin.init(ctx); await plugin.start(ctx); - const proxy = ctx.getService('file-storage') as SwappableStorageService; + const proxy = ctx.getService('storage') as SwappableStorageService; const before = proxy.getInner(); await ctx._flushReady(); @@ -288,7 +309,7 @@ describe('StorageServicePlugin: settings live-wire', () => { await plugin.init(ctx); await plugin.start(ctx); - const proxy = ctx.getService('file-storage'); + const proxy = ctx.getService('storage'); const before = proxy.getInner(); await ctx._flushReady(); expect(proxy.getInner()).toBe(before); // no swap diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 2298ea7c8d..4d2e6e69e1 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -110,7 +110,7 @@ export interface StorageServicePluginOptions { * })); * await kernel.bootstrap(); * - * const storage = kernel.getService('file-storage'); + * const storage = kernel.getService('storage'); * await storage.upload('file.txt', Buffer.from('hello')); * ``` */ @@ -119,8 +119,14 @@ export class StorageServicePlugin implements Plugin { /** * Services init() registers on every path (ADR-0116, #4131) — lets the * kernel name this plugin when a consumer requires one before it inits. + * + * `storage` is the canonical slot (maintainer ruling 2026-08-18, #9683); + * `file-storage` is its deprecated v17 alias, registered as the SAME + * instance so existing callers of either spelling resolve identically — + * the `http.server` / `http-server` pattern. The alias registration is + * dropped through the standard retirement flow at the next major. */ - providesServices = ['file-storage']; + providesServices = ['storage', 'file-storage']; /** * init() registers sys_file / sys_upload_session / sys_attachment through * the `manifest` service ObjectQLPlugin provides — order-if-present so the @@ -234,6 +240,8 @@ export class StorageServicePlugin implements Plugin { ); }); + ctx.registerService('storage', this.storage); + // Deprecated v17 alias — same instance under the old spelling (#9683). ctx.registerService('file-storage', this.storage); ctx.logger.info( `StorageServicePlugin: registered ${adapter} storage adapter (swappable, metrics=${this.metrics.constructor?.name ?? 'unknown'})`, @@ -385,7 +393,7 @@ export class StorageServicePlugin implements Plugin { } else if (!httpServer) { ctx.logger.warn( 'StorageServicePlugin: no HTTP server available — REST routes not registered. ' + - 'File storage is still accessible programmatically via kernel.getService("file-storage").', + 'File storage is still accessible programmatically via kernel.getService("storage").', ); } } diff --git a/packages/services/service-storage/src/swappable-storage-service.ts b/packages/services/service-storage/src/swappable-storage-service.ts index a62f31fa1d..a7fba0a29a 100644 --- a/packages/services/service-storage/src/swappable-storage-service.ts +++ b/packages/services/service-storage/src/swappable-storage-service.ts @@ -16,7 +16,8 @@ import type { * inner adapter. * * Used by `StorageServicePlugin` so the kernel can register a stable - * `file-storage` reference at init time, while the underlying adapter + * `storage` reference (plus its deprecated v17 `file-storage` alias, #9683) + * at init time, while the underlying adapter * (local FS / S3) is rebuilt on every `settings:changed` event for * the `storage` namespace. * diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index ce6e25ec49..f75bffb67a 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -598,8 +598,8 @@ export const WellKnownCapabilitiesSchema = lazySchema(() => z.object({ ), /** * Whether a file-storage surface is served at all (upload / download / - * attachment handling), i.e. the `file-storage` slot is filled by something - * that really serves HTTP. + * attachment handling), i.e. the `storage` slot (deprecated v17 alias: + * `file-storage` — #9683) is filled by something that really serves HTTP. * * Related to but distinct from {@link WellKnownCapabilitiesSchema} `chunkedUpload`: * this one says "files work"; that one says "large files can be uploaded in diff --git a/packages/spec/src/contracts/core-service-contracts.test.ts b/packages/spec/src/contracts/core-service-contracts.test.ts index 340358a255..75039301d0 100644 --- a/packages/spec/src/contracts/core-service-contracts.test.ts +++ b/packages/spec/src/contracts/core-service-contracts.test.ts @@ -31,7 +31,8 @@ describe('CoreServiceName → contract map (#4127)', () => { // failing test and not only as a type error in an unrelated package. // ('workflow' left both lists with its slot, #4451 v17.) const mapped: Array = [ - 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', + 'metadata', 'data', 'auth', 'storage', 'file-storage', 'search', + 'cache', 'queue', 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', 'i18n', ]; @@ -43,7 +44,8 @@ describe('CoreServiceName → contract map (#4127)', () => { it('leaves exactly the slots with no written contract unmapped', () => { const mapped = new Set([ - 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', + 'metadata', 'data', 'auth', 'storage', 'file-storage', 'search', + 'cache', 'queue', 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', 'i18n', ]); diff --git a/packages/spec/src/contracts/core-service-contracts.ts b/packages/spec/src/contracts/core-service-contracts.ts index 6afaf338bf..f623e38d34 100644 --- a/packages/spec/src/contracts/core-service-contracts.ts +++ b/packages/spec/src/contracts/core-service-contracts.ts @@ -62,6 +62,15 @@ export interface CoreServiceContracts { /** `plugin-auth` registers its auth manager. */ auth: IAuthService; /** `service-storage` registers the storage driver; the slot #4087 was about. */ + storage: IStorageService; + /** + * **Deprecated alias** of {@link CoreServiceContracts.storage} — the same + * instance under the old compound spelling (maintainer ruling 2026-08-18, + * issue #9683: 「9683 file-storage 可以叫 storage」). `service-storage` + * registers both names, exactly the `http.server` / `http-server` pattern + * below. New code resolves `storage`; the alias is retired through the + * standard retirement flow at the next major. + */ 'file-storage': IStorageService; search: ISearchService; /** `service-cache`'s own error text names `ICacheService` as the slot's contract. */ diff --git a/packages/spec/src/contracts/storage-service.ts b/packages/spec/src/contracts/storage-service.ts index 55af13ab62..a511a9d435 100644 --- a/packages/spec/src/contracts/storage-service.ts +++ b/packages/spec/src/contracts/storage-service.ts @@ -10,7 +10,10 @@ * Follows Dependency Inversion Principle - plugins depend on this interface, * not on concrete storage implementations. * - * Aligned with CoreServiceName 'file-storage' in core-services.zod.ts. + * Aligned with CoreServiceName 'storage' in core-services.zod.ts — the + * canonical slot since the 2026-08-18 maintainer ruling on issue #9683 + * (「9683 file-storage 可以叫 storage」). 'file-storage' stays accepted as a + * deprecated alias within v17; service-storage registers both names. */ import type { StandardErrorCode } from '../api/errors.zod'; diff --git a/packages/spec/src/system/core-service-provider.test.ts b/packages/spec/src/system/core-service-provider.test.ts index d979eb90fb..27dfc0bdbf 100644 --- a/packages/spec/src/system/core-service-provider.test.ts +++ b/packages/spec/src/system/core-service-provider.test.ts @@ -81,6 +81,8 @@ describe('CORE_SERVICE_PROVIDER', () => { describe('serviceUnavailableMessage', () => { it('names the package to install when one exists', () => { expect(serviceUnavailableMessage('auth')).toBe('Install @objectstack/plugin-auth to enable'); + expect(serviceUnavailableMessage('storage')).toBe('Install @objectstack/service-storage to enable'); + // Deprecated v17 alias of `storage` (#9683) — same provider, same remedy. expect(serviceUnavailableMessage('file-storage')).toBe('Install @objectstack/service-storage to enable'); }); diff --git a/packages/spec/src/system/core-services.test.ts b/packages/spec/src/system/core-services.test.ts index 78b988c565..c38604b973 100644 --- a/packages/spec/src/system/core-services.test.ts +++ b/packages/spec/src/system/core-services.test.ts @@ -10,10 +10,12 @@ import { describe('CoreServiceName', () => { it('should accept all valid service names', () => { - // ('workflow' retired with its slot, #4451 v17.) + // ('workflow' retired with its slot, #4451 v17. 'storage' is canonical + // since the 2026-08-18 ruling on #9683; 'file-storage' stays accepted as + // its deprecated v17 alias.) const services = [ 'metadata', 'data', 'auth', - 'file-storage', 'search', 'cache', 'queue', + 'storage', 'file-storage', 'search', 'cache', 'queue', 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', 'i18n', 'ui', ]; @@ -60,6 +62,8 @@ describe('ServiceRequirementDef', () => { }); it('should define optional services', () => { + expect(ServiceRequirementDef.storage).toBe('optional'); + // Deprecated v17 alias of `storage` (#9683) — same criticality on purpose. expect(ServiceRequirementDef['file-storage']).toBe('optional'); expect(ServiceRequirementDef.search).toBe('optional'); expect(ServiceRequirementDef.automation).toBe('optional'); diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts index 6672eb715c..ef16568773 100644 --- a/packages/spec/src/system/core-services.zod.ts +++ b/packages/spec/src/system/core-services.zod.ts @@ -24,7 +24,16 @@ export const CoreServiceName = z.enum([ 'auth', // Authentication & Identity // Infrastructure - 'file-storage', // Storage Driver (Local/S3) + 'storage', // Storage Driver (Local/S3) — canonical slot name + // DEPRECATED alias of `storage` (maintainer ruling 2026-08-18, issue #9683: + // 「9683 file-storage 可以叫 storage」). The compound spelling had no + // recorded reason; the slot is now the bare noun like every sibling. + // Kept as an accepted member within v17 — it is a published enum member, + // and existing `getService(...)` callers of the old spelling must not + // silently break inside a major. `service-storage` registers the SAME + // instance under both names. Retirement goes through the standard + // retirement flow at the next major. + 'file-storage', 'search', // Search Engine (Elastic/Meili) 'cache', // Cache Driver (Redis/Memory) 'queue', // Job Queue (BullMQ/Redis) @@ -87,6 +96,9 @@ export const CORE_SERVICE_PROVIDER: Readonly> = { 'queue': '@objectstack/service-queue', 'job': '@objectstack/service-job', 'realtime': '@objectstack/service-realtime', + // Canonical slot and its deprecated v17 alias (see the enum note) — one + // provider, registered under both names. + 'storage': '@objectstack/service-storage', 'file-storage': '@objectstack/service-storage', 'i18n': '@objectstack/service-i18n', // The `notification` slot is filled by the messaging service — the one entry @@ -231,6 +243,9 @@ export const ServiceRequirementDef = { i18n: 'core', // Optional: Add-on capabilities + storage: 'optional', + // Deprecated v17 alias of `storage` (see the enum note). Both slots are + // filled by the same registration, so neither ever reports missing alone. 'file-storage': 'optional', search: 'optional', automation: 'optional', diff --git a/scripts/check-runtime-services-index.mjs b/scripts/check-runtime-services-index.mjs index 06685fbbe5..75724bdc85 100644 --- a/scripts/check-runtime-services-index.mjs +++ b/scripts/check-runtime-services-index.mjs @@ -48,11 +48,11 @@ // * the chapter's own binding note tells the reader that no literal // `services.*` object is injected and that plugin code goes through // `ctx.getService(...)`; -// * the registered slot is `file-storage` (`storage-service-plugin.ts:237`), -// and NOTHING registers `storage`. +// * the registered slot was `file-storage` (`storage-service-plugin.ts`), +// and at the time NOTHING registered `storage`. // // Following both instructions together produced `ctx.getService('storage')`, -// which throws `[Kernel] Service 'storage' not found`. Seven of the eight pages +// which threw `[Kernel] Service 'storage' not found`. Seven of the eight pages // were fine only because their accessor and their slot happened to be the same // word, so the chapter had exactly one unannounced exception and no way to // notice a second one. @@ -60,11 +60,13 @@ // So each page now declares `- **Registry slot:** \`\``, and this gate holds // that declaration to a production `registerService`/`registerServiceFactory` // call under `packages/`. Note what is deliberately NOT required: the slot does -// not have to equal the accessor. `file-storage` is canonical -- it is the -// `CoreServiceName` member, `CORE_SERVICE_PROVIDER` maps it, and the CLI, email -// plugin and HTTP dispatcher all resolve it -- so a docs defect must never be -// "fixed" by renaming the slot or adding a `registerService('storage', ...)` -// alias. The rule is only: say which key you mean, and be right. +// not have to equal the accessor -- the rule is only: say which key you mean, +// and be right. (History: at #9630 time `file-storage` was the canonical slot +// and this header warned against "fixing" the docs by renaming it; the +// 2026-08-18 maintainer ruling on #9683 then renamed the slot deliberately -- +// `storage` is canonical, `file-storage` stays registered as a deprecated v17 +// alias of the same instance -- so today BOTH keys are really registered and +// the storage page declares `storage`.) // // Test files are excluded from the sweep on purpose: a slot only a fixture // registers is not a platform surface a reader can resolve. The sweep is also @@ -299,16 +301,15 @@ export function check({ pages, metaOrder, chapterList, kernelTable, registeredSl // above hold page EXISTENCE and ORDER to each other; none of them reads a // single line of `packages/`, so a page could document a slot nothing has // ever registered and stay green -- which is exactly what shipped: - // `services.storage` was resolvable only as `file-storage`, the accessor - // and the slot differed on that one page alone, and the page never said - // so. A reader following the chapter's own binding note wrote - // `ctx.getService('storage')` and got a throw. + // `services.storage` was resolvable only as `file-storage` at the time, + // the accessor and the slot differed on that one page alone, and the page + // never said so. A reader following the chapter's own binding note wrote + // `ctx.getService('storage')` and got a throw. (#9683 later renamed the + // slot to `storage` by maintainer ruling, keeping `file-storage` as a + // deprecated v17 alias registration.) // - // The accessor is NOT required to equal the slot -- `file-storage` is - // canonical (`CoreServiceName`, `CORE_SERVICE_PROVIDER`, three internal - // consumers) and renaming it to satisfy a docs page would be backwards. - // What is required is that the page SAYS which one it is, and that what it - // says is true. + // The accessor is NOT required to equal the slot. What is required is + // that the page SAYS which one it is, and that what it says is true. if (registeredSlots) { for (const p of pages) { if (!p.slot) {