Skip to content

Commit 7502263

Browse files
committed
feat(spec)!: retire the view-level api data source (#23082) — WIP
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
1 parent bcebd84 commit 7502263

15 files changed

Lines changed: 584 additions & 117 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: retire the view-level `api` data source — a view binds its object or static rows, and a metadata-declared API call is the page element's binding (#23082)
6+
7+
**BREAKING** — `provider: 'api'` on a view's `data` (`ViewDataSchema`, with its `read` / `write` request blocks) is now refused at parse. The arm took any `url` and any `headers`, so a credential or a cross-origin call written into a view was stored as metadata, served to every client that read the view and sent by the console as written. The page element's API binding (`ElementApiDataSourceSchema`, `dataSource` on a page component) is the one door for a metadata-declared API call: same-origin, no credential key, read with the console's own signed-in fetch. One operation with two implementations that behave differently keeps the governed one — the view-level source is re-homed on the page element and removed, not aligned.
8+
9+
Clause-②: no (narrowing)
10+
11+
Measured before removal: no producer wrote `provider: 'api'` on a view outside documentation in objectstack (`bcebd8409`), objectui (`origin/main` `f228d710` — the console's approval-requests source builds `ApiDataSource` in code from an `ApiDataSourceConfig`, never a `ViewData`) or hotcrm (`0f84c9c`), each with a lit `provider: 'object'` control. A renderer reader existed at the objectui pin (`ListView` routed the block to `ApiDataSource`; `ObjectGantt` built one from `read` / `write`) and is now unreachable from authored metadata.
12+
13+
## FROM → TO
14+
15+
| you wrote (17.7 and earlier) | write instead |
16+
| --- | --- |
17+
| `data: { provider: 'api', read: { url, method?, params?, headers? } }` on a list or form view | `data: { provider: 'object', object: '<object_name>' }` (or `{ provider: 'value', items: [...] }`), and the read as a page component's `dataSource: { provider: 'api', read: { url: '/api/...' }, path? }` |
18+
| `data: { provider: 'api', write: { url, method, body? } }` on a form view | nothing — a form submits to its object; a submit-to-endpoint is not a view concern |
19+
| `read.headers: { Authorization: '...' }` | nothing — a credential never lives in metadata; the page element binding refuses a credential key and the console sends its signed-in fetch |
20+
21+
**The one-line fix:** bind every `provider: 'api'` view to its object or to static rows, and move the endpoint read onto a page component's `dataSource`.
22+
23+
**What an author who still writes it sees.** `tsc` fails at the authoring site (`ViewData` no longer has the arm), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `data.provider` with the prescription:
24+
25+
> `view.data.provider: 'api'` was removed in @objectstack/spec 17.8.0 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '<object_name>' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch.
26+
27+
## The retirement kit
28+
29+
- **A value retirement, not a key**: `data` survives one arm lighter, so there is no `retiredKey()` tombstone and no `RETIRED_KEYS_BY_MAJOR` row. The prescription rides the discriminated union's own `error` map, keyed on the input's `provider === 'api'` — the retired bare-string `sort` clause's shape — so every other unknown provider keeps zod's own report, which lists the live arms (`object`, `value`, `schema`).
30+
- **No D2 conversion, deliberately.** The replacement lives on a different metadata type (a page element), and the only mechanical edit a conversion could make — deleting the view's `data` so the view falls back to querying its bound object — would change what the view shows without telling anyone. An authored source or a stored `sys_metadata` row still carrying the arm meets the prescription at parse instead.
31+
- **The D3 entry `view-data-api-provider-retired`** asks the author, per such view, which page composes the endpoint it read and what the view itself should now bind.
32+
- **`HttpRequestSchema` / `HttpMethodSubsetSchema` stay exported** from `@objectstack/spec/shared` and `@objectstack/spec/ui`, unchanged. After this change no schema in the package composes `HttpRequestSchema`; that orphan is recorded, not acted on here.
33+
- **Ledger:** `view.list.data` and `view.form.data` stay `live` (their surviving arms are read) and carry the arm's retirement record.
34+
- **Docs and the published skill** no longer teach a view-level `api` source; both point at the page element binding.
35+
- **No deprecation window**, per the project's startup-stage posture.
36+
37+
⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, and `objectstack-ai/cloud`'s own view metadata was not readable from this change's session; a cloud view carrying an `api` source is refused at parse with the prescription above.
38+
39+
<!-- adr-0087: registered view-data-api-provider-retired -->

‎content/docs/ui/views.mdx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,25 +294,26 @@ writing it by hand would smuggle an action definition past the action registry.
294294

295295
### Data Source
296296

297-
Views can load data from several sources (`object`, `api`, `value`, and `schema`):
297+
Views can load data from several sources (`object`, `value`, and `schema`):
298298

299299
```typescript
300300
// From an Object (most common)
301301
data: { provider: 'object', object: 'task' }
302302
303-
// From an external API
304-
data: {
305-
provider: 'api',
306-
read: { url: '/api/external/tasks', method: 'GET' },
307-
}
308-
309303
// Static values
310304
data: {
311305
provider: 'value',
312306
items: [{ id: '1', name: 'Item 1' }],
313307
}
314308
```
315309

310+
A view does not call an API. The view-level `api` data source — a `read` / `write`
311+
request block with its own `url` and `headers` — was retired: it let a credential or
312+
a cross-origin call be written into metadata and served with the view. A
313+
metadata-declared API call is a **page element's** `dataSource` binding, which is
314+
same-origin, carries no credentials and is read with the console's signed-in fetch —
315+
see [Element data sources](/docs/ui/pages#element-data-sources).
316+
316317
### Type-Specific Configuration
317318

318319
Each non-grid visualization reads its settings from a **nested config block

‎packages/lint/src/validate-list-view-field-refs.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -877,7 +877,9 @@ describe('#14107 — the skips', () => {
877877
});
878878

879879
it('skips a list view whose provider is not `object`', () => {
880-
const stack = stackWith(mutate({ data: { provider: 'api', endpoint: '/x' }, columns: ['nope'] }));
880+
// A `value` source: the one non-`object` provider a list view can still
881+
// bind (the `api` arm is retired, and `schema` is form-only).
882+
const stack = stackWith(mutate({ data: { provider: 'value', items: [] }, columns: ['nope'] }));
881883
expect(validateListViewFieldRefs(stack)).toEqual([]);
882884
});
883885

‎packages/lint/src/validate-preset-comparands.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,9 @@ describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / members
435435
],
436436
}],
437437
views: [
438-
// A non-`object` provider names no fields on any object graph.
439-
{ name: 'api', data: { provider: 'api', object: 'crm_opportunity' }, filter: [{ field: 'close_date', operator: 'equals', value: 'last_30_days' }] },
438+
// A non-`object` provider names no fields on any object graph (the
439+
// `value` arm; the view-level `api` arm is retired).
440+
{ name: 'static', data: { provider: 'value', items: [] }, filter: [{ field: 'close_date', operator: 'equals', value: 'last_30_days' }] },
440441
// No binding at all.
441442
{ name: 'unbound', filter: [{ field: 'close_date', operator: 'in', value: ['last_30_days'] }] },
442443
],

‎packages/spec/liveness/view.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
},
3939
"data": {
4040
"status": "live",
41-
"note": "objectui: ListView.tsx:841-850 fetch effect supports ViewDataSchema provider modes ('value' inline, 'api'; gantt owns api-provided data at :476). Supersedes the 2026-06 audit's 'api/schema dead in list path'. The 'schema' provider remains form/metadata-admin-only — not honored on lists."
41+
"evidenceScope": "cross-repo",
42+
"verifiedAt": "2026-10-13",
43+
"evidence": "objectui @7578323a544e (the pin): packages/plugin-list/src/ListView.tsx reads `schema.data.provider` in its fetch effect (`value` rows are charted inline; a gantt is handed the whole `data` block) and packages/plugin-gantt/src/ObjectGantt.tsx resolves `data` through `resolveDataSource`; framework: packages/spec/src/ui/view.zod.ts#ViewDataSchema",
44+
"note": "objectui: ListView.tsx fetch effect supports the ViewDataSchema provider modes it is handed ('value' inline; the gantt owns its data block). Supersedes the 2026-06 audit's 'api/schema dead in list path'. The 'schema' provider remains form/metadata-admin-only — not honored on lists. RETIREMENT RECORD (2026-10-13, ADR-0049 enforce-or-remove, maintainer ruling 2026-10-12 letter C): the `api` provider VALUE left ViewDataSchema — refused by name on the union's own error map (`VIEW_DATA_API_PROVIDER_RETIRED`), no tombstone key, no D2 conversion, D3 `view-data-api-provider-retired`. A renderer reader EXISTED at the pin (ListView.tsx routed an `api` block to `ApiDataSource`; ObjectGantt.tsx built one from the block's `read`/`write`) and is now unreachable from authored metadata: zero producers wrote the value outside documentation in objectstack (bcebd8409), objectui (origin/main f228d710 — the console's approval-requests source constructs `ApiDataSource` in code from an `ApiDataSourceConfig`, never a `ViewData`) and hotcrm (0f84c9c), each with a lit `provider: 'object'` control. This key stays `live` because the surviving arms are read; the arm's record sits on this row because the walk never descends a union arm."
4245
},
4346
"columns": {
4447
"status": "live",
@@ -263,7 +266,7 @@
263266
"status": "live",
264267
"verifiedAt": "2026-07-30",
265268
"evidence": "packages/spec/src/ui/view.zod.ts defineForm (writes data.provider=schema + schemaId onto every metadata form); packages/metadata-protocol/src/protocol.ts:1580,1598 (serves schemaId with the form to the metadata-admin pipeline)",
266-
"note": "CORRECTED 2026-07-30: the #3896 sweep attempted removal on the prior dead verdict and the build itself refuted it — defineForm authors the key on every *.form.ts. The record-form provider VALUES (object/api/value) may still lack readers; that is a value-subset audit, not a key removal."
269+
"note": "CORRECTED 2026-07-30: the #3896 sweep attempted removal on the prior dead verdict and the build itself refuted it — defineForm authors the key on every *.form.ts. The record-form provider VALUES (object/value) may still lack readers; that is a value-subset audit, not a key removal. 2026-10-13: the `api` value is RETIRED on the union itself (see `list.data`'s retirement record) — a form's `data` no longer admits a request block; a submit-to-endpoint is not a view concern."
267270
},
268271
"type": {
269272
"status": "live",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// ADR-0049 enforce-or-remove; maintainer ruling 2026-10-12 (letter C: retire
6+
// the view-level `api` data source, not align it to the page element's rule).
7+
// The `provider: 'api'` arm of `ViewDataSchema` carried `read` / `write`
8+
// request blocks of any `url` and any `headers`, so a credential or a
9+
// cross-origin call written into a view was stored as metadata, served to
10+
// every client that read the view and sent by the console as written. The
11+
// page element's API binding is the one governed door for a metadata-declared
12+
// API call (same-origin, credential-free, the console's own signed-in fetch);
13+
// one operation with two implementations that behave differently keeps the
14+
// governed one. No D2 conversion: the replacement lives on a different surface
15+
// and the only mechanical edit — deleting the view's `data` — would silently
16+
// change what the view shows, so an author or a stored row still carrying the
17+
// arm is refused at parse with the prescription instead.
18+
export const entry: SemanticMigration = {
19+
id: 'view-data-api-provider-retired',
20+
surface: "view.data (`provider: 'api'`, with its `read` / `write` request blocks) — the view-level API data source",
21+
replacement: "A view binds its object (`provider: 'object'`) or static rows (`provider: 'value'`); the API read "
22+
+ "moves onto a page component's `dataSource` binding — `{ provider: 'api', read: { url }, path? }` under a "
23+
+ "page's `regions[].components[]` — which is same-origin, carries no credentials and is read with the "
24+
+ "console's signed-in fetch.",
25+
reason: 'There is no mechanical rewrite. The replacement is a binding on a different metadata type (a page '
26+
+ "element, not a view), and the one edit a conversion could make — deleting the view's `data` so the "
27+
+ 'view falls back to querying its bound object — would change what the view shows without telling '
28+
+ 'anyone. So the parse refuses the arm by name with the prescription, for an authored source and for a '
29+
+ 'stored row alike, and the author decides for each such view which page composes the endpoint it '
30+
+ "read and what the view itself should now bind. The arm's `headers` block is the part with no "
31+
+ "successor at all: a key an author had written there was a credential in metadata, and the page "
32+
+ 'element binding refuses the key; the console sends its own signed-in fetch instead.',
33+
acceptanceCriteria: "No view in `stack.views[]`, in any object's `listViews` / `formViews` map or in a stored "
34+
+ "`sys_metadata` view row carries `data.provider: 'api'`; the parse refuses one with the prescription. "
35+
+ "Every endpoint such a view read is either a page component's API binding on a same-origin path "
36+
+ 'with no credential key, or dropped on purpose; and every converted view binds its object or static '
37+
+ 'rows and loads.',
38+
};

‎packages/spec/src/migrations/registry.ts.template‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3110,6 +3110,24 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
31103110
+ 'offered a block `chart` input until this change); it is retired from the load path, so '
31113111
+ 'authors are refused at parse rather than rewritten.',
31123112
},
3113+
{
3114+
id: 'view-data-api-provider-retired',
3115+
order: 95,
3116+
text:
3117+
"It also retires the view-level `api` data source — the `provider: 'api'` arm of `ViewDataSchema`, "
3118+
+ 'with its `read` / `write` request blocks (ADR-0049 enforce-or-remove; maintainer ruling 2026-10-12, '
3119+
+ 'letter C). The arm took any `url` and any `headers`, so a credential or a cross-origin call written '
3120+
+ 'into a view was stored as metadata, served to every client that read the view and sent by the '
3121+
+ "console as written, while the page element's API binding — the newer door for a metadata-declared "
3122+
+ 'API call — is same-origin, credential-free and read with the console\'s own signed-in fetch. One '
3123+
+ 'operation with two implementations that behave differently keeps the governed one: a view now binds '
3124+
+ "its object or static rows, and an API read is a page component's `dataSource` binding. Measured "
3125+
+ 'before removal: no producer wrote the arm outside documentation in objectstack, objectui or hotcrm. '
3126+
+ 'The arm is refused by name on the union\'s own error map, with no `retiredKey()` tombstone (a value '
3127+
+ 'left, not a key) and no D2 conversion (the replacement lives on another surface, and deleting the '
3128+
+ "view's `data` would silently change what it shows); its D3 record is the semantic entry "
3129+
+ '`view-data-api-provider-retired`.',
3130+
},
31133131
{
31143132
id: 'view-item-owner-hidden-retired',
31153133
order: 37,

‎packages/spec/src/ui/component-props-unknown-members.pin.test.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,11 @@ const EXPRESSION_AST: Reason = {
202202
owner: 'shared/expression.zod.ts `ExpressionSchema.ast`',
203203
why: 'the engine-native AST `objectstack compile` fills beside `source` — opaque at the spec layer by design; each engine validates its own shape',
204204
};
205-
const HTTP_REQUEST: Reason = {
206-
kind: 'shared',
207-
owner: 'shared/http.zod.ts `HttpRequestSchema`',
208-
why: 'the query parameters and request body an `api` data source sends, forwarded verbatim to the endpoint the author names; their shape is that endpoint\'s',
209-
};
205+
// `HTTP_REQUEST` — the `data.read.params{}` / `data.read.body` /
206+
// `data.write.*` members `HttpRequestSchema` carried into every record-source
207+
// block — left this ledger with the view-level `api` data source (ADR-0049,
208+
// maintainer ruling 2026-10-12 letter C): the `api` arm of `ViewDataSchema` is
209+
// retired, so no `data` member reaches a request block any more.
210210
const INLINE_JSON_SCHEMA: Reason = {
211211
kind: 'shared',
212212
owner: 'ui/view.zod.ts `ViewDataSchema` (`provider: \'schema\'`)',
@@ -280,7 +280,6 @@ on(['action:button', 'action:icon'], ACTION_RUNNER_PATHS, RUNNER);
280280
on(['action:group', 'action:menu'], ACTION_RUNNER_PATHS.map((p) => `actions[].${p}`), RUNNER);
281281

282282
// The data-source binding every record-source block shares.
283-
on(VIEW_DATA_TYPES, ['data.read.params{}', 'data.read.body', 'data.write.params{}', 'data.write.body'], HTTP_REQUEST);
284283
on(VIEW_DATA_TYPES, ['data.items[]'], RECORDS);
285284
on(VIEW_DATA_TYPES, ['data.schema{}'], INLINE_JSON_SCHEMA);
286285

@@ -418,14 +417,15 @@ describe('§2 each recorded reason holds', () => {
418417
}
419418
});
420419

421-
it('a `shared` member really is the owner\'s: an `ast` beside a `source`, a request beside a `url`', () => {
420+
it('a `shared` member really is the owner\'s: an `ast` beside a `source`', () => {
422421
for (const [key, reason] of entries) {
423422
if (reason !== EXPRESSION_AST) continue;
424423
expect(key, 'an expression-AST line names the `ast` member').toMatch(/\.ast$/);
425424
}
426-
for (const [key, reason] of entries) {
427-
if (reason !== HTTP_REQUEST) continue;
428-
expect(key, 'an HTTP-request line names `params` or `body` under `read` / `write`').toMatch(/ data\.(read|write)\.(params\{\}|body)$/);
425+
// No `data.read.*` / `data.write.*` line may come back: the request block
426+
// left the record-source blocks with the retired view-level `api` source.
427+
for (const [key] of entries) {
428+
expect(key, 'a request-block member on a record-source block means the retired `api` arm is being undone').not.toMatch(/ data\.(read|write)\./);
429429
}
430430
});
431431
});

0 commit comments

Comments
 (0)