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) {