diff --git a/.changeset/12055-upload-path-not-scope.md b/.changeset/12055-upload-path-not-scope.md index bdbfcab3db..768f93fe56 100644 --- a/.changeset/12055-upload-path-not-scope.md +++ b/.changeset/12055-upload-path-not-scope.md @@ -6,7 +6,7 @@ The adapter used to send `scope: opts.scope ?? options.path` in its presigned upload request, so an adapter built without a `scope` turned the `path` a caller passed to `upload(file, { path })` into the scope. A path is a key prefix for the S3 and Azure presign callbacks, not a scope: the ObjectStack storage service accepts only the values of the `scope` select on its `sys_file` object and refuses any other, so such an upload failed at the presign step. The adapter now sends only the `scope` it was built with, or none, and the server applies its default. `UploadResult.meta.scope` reports the same value. -The `scope` option's documentation no longer teaches free key prefixes ("avatars", "logos", "attachments/case"). It names the server's `sys_file` scope select as the vocabulary. The option's type is unchanged (`string`). +The `scope` option's documentation no longer teaches free key prefixes ("avatars", "logos", "attachments/case"). It names the server's `sys_file` scope select as the vocabulary. BREAKING (`@object-ui/providers`), for a caller that relied on `path` to choose the scope of an adapter built without one: the upload is now filed under the server's default scope. Remedy: pass one of the server's scopes as the adapter's `scope`, or none to take the server's default. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) diff --git a/.changeset/12055-upload-scope-type.md b/.changeset/12055-upload-scope-type.md new file mode 100644 index 0000000000..4b211858ae --- /dev/null +++ b/.changeset/12055-upload-scope-type.md @@ -0,0 +1,11 @@ +--- +'@object-ui/providers': minor +--- + +`createObjectStackUploadAdapter`'s `scope` option is typed from the spec's upload-scope vocabulary (objectui#12055). + +`ObjectStackUploadAdapterOptions.scope` was an open `string`, so a caller could build an adapter with a scope the ObjectStack storage service refuses with `400`. It is now `UploadScope` from `@objectstack/spec/api`, the one list the spec's upload request, the `scope` select on `sys_file` and the upload doors share. `@object-ui/providers` reaches it through the `API` namespace `@object-ui/types` already re-exports, so no dependency and no export is added. Omitting `scope` still sends none, and the server applies the default the spec's upload request declares. + +BREAKING (`@object-ui/providers`), TypeScript only, for a caller that builds the adapter with a scope outside the vocabulary (for example `'avatars'` or `'attachments/case'`): it no longer compiles. The server already refused such a request at runtime. Remedy: pass one of the vocabulary's values, or omit `scope` to take the server's default. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) + +**Clause-②: no (narrowing)**: the option's accepted values narrow from any string to the spec's upload scopes. No export, option or type member is added; the declaration file gains a type-only import of `API` from `@object-ui/types`, a package `@object-ui/providers` already depends on. diff --git a/packages/providers/src/UploadProvider.tsx b/packages/providers/src/UploadProvider.tsx index 75953289c0..67799d8331 100644 --- a/packages/providers/src/UploadProvider.tsx +++ b/packages/providers/src/UploadProvider.tsx @@ -27,6 +27,7 @@ import React, { useRef, type ReactNode, } from 'react'; +import type { API } from '@object-ui/types'; export interface UploadResult { /** Canonical, shareable URL (e.g. https://my-bucket.s3.amazonaws.com/avatars/abc.png). */ @@ -204,14 +205,17 @@ export interface ObjectStackUploadAdapterOptions { /** * The storage scope the server files every upload from this adapter * under, sent as the presigned upload request's `scope`. It is not a key - * prefix or folder. The server accepts only the values of the `scope` - * select on its `sys_file` object (`@objectstack/service-storage`) and - * refuses any other value; `attachments`, for example, is the scope of - * files joined to records through `sys_attachment`. Omit it and the - * request carries no scope, so the server applies its default - * (objectui#12055). + * prefix or folder. Its values are the spec's upload-scope vocabulary, + * `UploadScope` from `@objectstack/spec/api` (reached here as + * `API.UploadScope` from `@object-ui/types`): the one list the upload + * request, the `scope` select on `sys_file` and the upload doors share. + * A value outside it does not compile, and the server refuses it with + * `400`. `attachments`, for example, is the scope of files joined to + * records through `sys_attachment`. Omit it and the request carries no + * scope, so the server applies the default the spec's upload request + * declares (objectui#12055). */ - scope?: string; + scope?: API.UploadScope; /** Override `fetch` (tests, ServiceWorker queues). */ fetchImpl?: typeof fetch; /** diff --git a/packages/providers/src/__tests__/objectstack-upload-scope-12055.test.ts b/packages/providers/src/__tests__/objectstack-upload-scope-12055.test.ts index d430d585c4..0ca8947304 100644 --- a/packages/providers/src/__tests__/objectstack-upload-scope-12055.test.ts +++ b/packages/providers/src/__tests__/objectstack-upload-scope-12055.test.ts @@ -17,11 +17,17 @@ * * These pins read the presign request body the adapter actually sends, * through an injected `fetchImpl` (no global is replaced). + * + * The option's TYPE is the spec's upload-scope vocabulary, `UploadScope` from + * `@objectstack/spec/api`, reached through `@object-ui/types`'s `API` + * namespace. The compile-time pins at the end of this file are checked by + * `tsconfig.test.json`, which `type-check` chains after the source program. */ import { describe, it, expect } from 'vitest'; +import type { API } from '@object-ui/types'; -import { createObjectStackUploadAdapter } from '../UploadProvider'; +import { createObjectStackUploadAdapter, type ObjectStackUploadAdapterOptions } from '../UploadProvider'; const FILE_ID = 'f-12055'; @@ -105,3 +111,39 @@ describe('the in-repo upload shapes send the same request as before (objectui#12 }); }); }); + +/* -------------------------------------------------------------------------- */ +/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +describe("the scope option is typed from the spec's upload-scope vocabulary (objectui#12055)", () => { + it('is the spec type itself: not a free string, not a local copy', () => { + type Scope = NonNullable; + // The spec type resolved (an unresolved import would read as `any` and + // make every pin below vacuous). + type _SpecTypeIsReal = Assert, false>>; + // Exactly the spec's type: a member the spec adds or retires moves the + // option with it, and no consumer-side list can drift from it. + type _OptionIsTheSpecType = Assert>; + // Not widened back to an open string. + type _NotAnOpenString = Assert>; + + expect(true).toBe(true); + }); + + it("refuses a scope outside the vocabulary at the authoring site: 'avatars' does not compile", () => { + // The pin is this directive: with `scope` widened to `string` the line + // compiles, the directive goes unused, and `type-check` fails (TS2578). + // @ts-expect-error -- 'avatars' is a key prefix, not an upload scope; the server refuses it with 400. + const refused: ObjectStackUploadAdapterOptions = { scope: 'avatars' }; + void refused; + // The in-repo producer's value compiles (RecordAttachmentsPanel builds its adapter with it). + const accepted: ObjectStackUploadAdapterOptions = { scope: 'attachments' }; + + expect(accepted.scope).toBe('attachments'); + }); +});