Skip to content

Commit 8389e65

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21853-retire-dry-run-hash-compat
2 parents f32c4b2 + 2df3d13 commit 8389e65

19 files changed

Lines changed: 609 additions & 15 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
Studio's object form offers `imageField`, the record's picture, as a text row beside `nameField`
7+
8+
Clause-②: no
9+
10+
The object form in the metadata form registry now has an `imageField` row, a plain text input placed beside `nameField`. Until now the only way to set the record picture from Studio was the Source tab's raw JSON. The help text says what the parse accepts: a field of this object whose type is `image` or `avatar`. Left empty, the object has no record picture and no placeholder is drawn. The row brings no picker and no validator of its own. A name that is not an `image` / `avatar` field of the object is refused when the object is saved, by the same parse rule as before.
11+
12+
`@objectstack/platform-objects` ships the row's label and help text in its metadata-form translation catalogs, translated for `zh-CN`, `ja-JP` and `es-ES`.
13+
14+
No schema key, accept set, refusal, error code or status changes, and you have nothing to re-author.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-datasource': minor
3+
---
4+
5+
fix(service-datasource)!: "Import as Object" saves the imported federated object through the metadata door's own save, so it is durable and reads from its remote table; federated validation stops reporting the platform's injected anchors as missing remote columns (#21788)
6+
7+
**BREAKING** — the import route now answers `400` to some re-imports that used to answer `201`.
8+
9+
Clause-②: no (narrowing)
10+
11+
- **The import was neither durable nor mapped.** `POST /api/v1/datasources/:name/external/tables/:remote/import` held the generated object in the metadata service's memory only. No `sys_metadata` row was written, the object's storage was not synced, and the driver was never told the object's remote table. An object imported under a name that differs from its remote table answered `201` and then `500 DATABASE_ERROR` (`no such table: <name>`) on its first read. Every import was gone after a restart (`404 OBJECT_NOT_FOUND`).
12+
- **It now saves like `PUT /api/v1/meta/object/:name`.** The import calls `saveMetaItem` on the `protocol` service with the request that door sends for an `object`. The object becomes a `sys_metadata` row the next boot binds, it is written through to the engine registry, and it is mapped onto its `external.remoteName` table. An import under a different name and one under the remote table's own name both serve the remote rows, before and after a restart. The save door is looked up when an import runs. A deployment with no metadata save door still refuses the import with "requires a writable metadata store", before any remote introspection.
13+
- **What narrows.** The metadata door's refusals now apply to the import, and the route relays each one as `400 EXTERNAL_IMPORT_ERROR` with the door's message. A re-import that would drop a field the stored object already has, or change its type, is refused as a destructive change. It used to answer `201` with an in-memory overwrite that a restart discarded. An import whose `name` collides with an object the metadata door will not overwrite is refused the same way. The route's request and response shapes are unchanged.
14+
- **If a re-import or a name is refused:** import the table again under a new `name`. To change an object you already imported, save its new definition through `PUT /api/v1/meta/object/:name?force=true`, which accepts the destructive change on purpose. Re-submitting the import with `?force=true` does not help, because the import route reads no `force`.
15+
- **Federated validation compares only what the remote owns.** A stored federated object is read back with the anchors the platform injects without storage (`organization_id`, `created_by`, `updated_by`, `owner_id`, `owning_business_unit_id`). The boot validation gate and `POST …/external/validate` reported each of them as a `missing_column` at error severity. So once a datasource with the default `external.validation.onMismatch: 'fail'` held such an object, it refused to boot. Validation now skips the columns `unprovisionedInjectedColumns` names. This closes the boot abort for objects saved through `PUT /api/v1/meta/object/:name`, not only for imports. A declared column the remote lacks is still a `missing_column` at error severity, and `fail` still aborts on it.
16+
17+
<!-- adr-0087: not-required (no-migration-prescription) a runtime refusal on one REST route: the external-table import now relays the metadata door's own save refusals (a destructive re-import, a name that door will not overwrite) as 400 where it answered 201 with an in-memory write no restart kept. No authorable metadata key, spelling, export, config field or stored shape moves: no schema changes, no sys_metadata row is read or rewritten, and which table or name an operator meant to re-import is not something a ledger entry can rewrite. The validation half refuses less, not more. The other categories are closed on facts: @objectstack/service-datasource publishes (not unpublished); no ADR-0087 id covers this route (not already-registered); and the change is a runtime verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
6060
label: "Name Field",
6161
helpText: "Field whose value titles each record (e.g. \"name\", \"subject\"). ADR-0079 canonical pointer — read by record display, ObjectQL search and related-record previews."
6262
},
63+
imageField: {
64+
label: "Image Field",
65+
helpText: "Field whose value is each record's picture, drawn beside the title in the record page header. Must name a field of this object whose type is image or avatar; any other name is refused when the object is saved. Empty: no record picture, and no placeholder is drawn."
66+
},
6367
isSystem: {
6468
label: "Is System",
6569
helpText: "System object (protected from deletion; defaults sharing to public)"

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
6060
label: "Campo de título",
6161
helpText: "Campo cuyo valor titula cada registro (p. ej. \"name\", \"subject\"). Puntero canónico de ADR-0079: lo leen la presentación del registro, la búsqueda de ObjectQL y las vistas previas de registros relacionados."
6262
},
63+
imageField: {
64+
label: "Campo de imagen",
65+
helpText: "Campo cuyo valor es la imagen de cada registro, mostrada junto al título en el encabezado de la página del registro. Debe nombrar un campo de este objeto de tipo image o avatar; cualquier otro nombre se rechaza al guardar el objeto. Vacío: sin imagen del registro y sin marcador de posición."
66+
},
6367
isSystem: {
6468
label: "Integrado del sistema",
6569
helpText: "Objeto de sistema (protegido contra eliminación; el uso compartido predeterminado es público)"

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
6060
label: "レコードタイトル項目",
6161
helpText: "各レコードのタイトルに使う項目(例: \"name\"、\"subject\")。ADR-0079 の正規ポインタで、レコード表示・ObjectQL 検索・関連レコードのプレビューが参照します。"
6262
},
63+
imageField: {
64+
label: "レコード画像項目",
65+
helpText: "各レコードの画像に使う項目で、レコードページのヘッダーでタイトルの横に表示されます。このオブジェクトの image 型または avatar 型の項目を指定してください。それ以外の名前はオブジェクトの保存時に拒否されます。空欄の場合、レコード画像は表示されず、プレースホルダーも描画されません。"
66+
},
6367
isSystem: {
6468
label: "システム組み込み",
6569
helpText: "システムオブジェクト(削除から保護。共有の既定は公開)"

‎packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,10 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape',
938938
// 114 since #19332 flight G2a gave `basics` the `fieldGroups` repeater — the
939939
// row and its six declared sub-rows, fourteen leaves, their forty-two
940940
// translated leaves authored by that flight, so `openEchoes` stays at zero.
941-
expect(OPEN_LEAVES.length).toBe(114);
941+
// 116 since #21765 gave `basics` the `imageField` row beside `nameField` —
942+
// the record-picture pointer, now live — whose label and help text are
943+
// authored in all three locales by that card, so `openEchoes` stays at zero.
944+
expect(OPEN_LEAVES.length).toBe(116);
942945
expect(PANEL_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(false);
943946
expect(OPEN_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(true);
944947
for (const path of ['name', 'label', 'fields', 'fields.valueDomain', 'fields.deleteBehavior', 'fields.expression']) {

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1167,7 +1167,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
11671167
// 659 since #21320: the agent form's `lifecycle` row left with its key
11681168
// (the conversation state machine was retired — nothing ever read it),
11691169
// taking its label — authored in all three locales — out of the catalog.
1170-
expect(translated.length, `${locale} positive control`).toBe(659);
1170+
// 660 since #21765: the object form offers `imageField` beside
1171+
// `nameField` — one new row label, authored in all three locales.
1172+
expect(translated.length, `${locale} positive control`).toBe(660);
11711173
}
11721174
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
11731175
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
6060
label: "记录标题字段",
6161
helpText: "用作每条记录标题的字段(例如 \"name\"、\"subject\")。ADR-0079 规定的规范指针——记录展示、ObjectQL 搜索和关联记录预览都读它。"
6262
},
63+
imageField: {
64+
label: "记录图片字段",
65+
helpText: "用作每条记录图片的字段,显示在记录页头的标题旁。必须是本对象中类型为 image 或 avatar 的字段,其他名称会在保存对象时被拒绝。留空则不显示记录图片,也不画占位图。"
66+
},
6367
isSystem: {
6468
label: "系统内置",
6569
helpText: "系统对象(受保护,不可删除;共享默认为公开)"

‎packages/qa/dogfood/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@objectstack/plugin-sharing": "workspace:*",
3131
"@objectstack/plugin-webhooks": "workspace:*",
3232
"@objectstack/service-analytics": "workspace:*",
33+
"@objectstack/service-datasource": "workspace:*",
3334
"@objectstack/service-messaging": "workspace:*",
3435
"@objectstack/service-storage": "workspace:*",
3536
"@objectstack/spec": "workspace:*",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21788, ADR-0015 Addendum] "Import as Object" keeps what it answers `201`
4+
// for — over the real showcase composition, across a cold boot on one
5+
// database file.
6+
//
7+
// ## What was broken
8+
//
9+
// `POST /datasources/:name/external/tables/:remote/import` persisted the
10+
// generated object with `metadata.register('object', …)`, which held it in the
11+
// metadata service's memory only: no `sys_metadata` row, no engine schema
12+
// sync, no external-object registration with the driver. Measured on the
13+
// showcase before the fix: an import under a name that differs from its remote
14+
// table answered `201` and then `500 DATABASE_ERROR` on its first read (the
15+
// SQL driver resolved the table by the object's name — `no such table`), an
16+
// import whose name equals the remote table answered `200` only because the
17+
// two names coincide, and after a restart on the same database both answered
18+
// `404 OBJECT_NOT_FOUND`. `PUT /meta/object/:name` with the same binding was
19+
// durable and served the rows. The import now saves through that door's save.
20+
//
21+
// ## Why a booted stack
22+
//
23+
// The seam pins sit beside the plugin
24+
// (`packages/services/service-datasource/src/__tests__/external-import-saves-through-metadata-door.test.ts`).
25+
// What they cannot answer is whether, on the stack an operator runs, the save
26+
// maps the object onto its remote table and lands a row the next boot binds.
27+
// Only a read through the data door and a restart on the same file show that.
28+
//
29+
// The verify harness does not mount the federation service, so this file
30+
// mounts `ExternalDatasourceServicePlugin` itself, as `objectstack dev` and
31+
// `serve` do. The working directory is a temporary one because the showcase's
32+
// external datasource and its fixture both name a cwd-relative SQLite file:
33+
// this file's remote database is its own, not one a parallel file writes.
34+
35+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
36+
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
37+
import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource';
38+
import { bootStack, type VerifyStack } from '@objectstack/verify';
39+
import { mkdtempSync, rmSync } from 'node:fs';
40+
import { tmpdir } from 'node:os';
41+
import { join } from 'node:path';
42+
43+
/** The showcase's external datasource and its two remote tables (`external-fixture.ts`). */
44+
const DATASOURCE = 'showcase_external';
45+
/** Imported under a name that differs from its remote table — the defect's shape. */
46+
const RENAMED = 'dogfood_ext_cust_21788';
47+
/** Imported under the remote table's own name — the shape that hid the defect. */
48+
const SAME_NAME = 'orders';
49+
50+
const importPath = (remote: string) => `/datasources/${DATASOURCE}/external/tables/${remote}/import`;
51+
52+
function recordsOf(json: unknown): Array<Record<string, unknown>> {
53+
const body = json as { records?: unknown[] } | undefined;
54+
return (body?.records ?? []) as Array<Record<string, unknown>>;
55+
}
56+
57+
async function boot(databaseFile: string): Promise<VerifyStack> {
58+
return bootStack(showcaseStack, { databaseFile, extraPlugins: [new ExternalDatasourceServicePlugin()] });
59+
}
60+
61+
describe('Import as Object persists like the metadata door (showcase, cold boot)', () => {
62+
let stack: VerifyStack;
63+
let token: string;
64+
let prevCwd: string;
65+
let dir: string;
66+
let dbFile: string;
67+
68+
const call = async (method: string, path: string, body?: unknown) => {
69+
const res = await stack.apiAs(token, method, path, body);
70+
const json: unknown = await res.json().catch(() => ({}));
71+
return { status: res.status, json };
72+
};
73+
74+
beforeAll(async () => {
75+
prevCwd = process.cwd();
76+
dir = mkdtempSync(join(tmpdir(), 'dogfood-21788-'));
77+
process.chdir(dir);
78+
dbFile = join(dir, 'showcase.db');
79+
// Provision the remote tables, exactly as `os dev` does at boot (the
80+
// harness imports only the stack's default export, so `onEnable` never
81+
// runs on its own).
82+
await onEnable({ logger: { info() {}, warn() {} } } as never);
83+
stack = await boot(dbFile);
84+
token = await stack.signIn();
85+
}, 180_000);
86+
87+
afterAll(async () => {
88+
await stack?.stop();
89+
if (prevCwd) process.chdir(prevCwd);
90+
if (dir) rmSync(dir, { recursive: true, force: true });
91+
});
92+
93+
it('an object imported under a name that differs from its remote table serves the remote rows', async () => {
94+
const imported = await call('POST', importPath('customers'), { name: RENAMED });
95+
expect(imported.status, JSON.stringify(imported.json)).toBe(201);
96+
97+
const read = await call('GET', `/data/${RENAMED}`);
98+
expect(read.status, JSON.stringify(read.json)).toBe(200);
99+
expect(recordsOf(read.json).map((r) => r.name)).toEqual(
100+
expect.arrayContaining(['Aurora Labs', 'Borealis GmbH', 'Cyan Pacific']),
101+
);
102+
});
103+
104+
it('control: an object imported under its remote table\'s own name serves the remote rows', async () => {
105+
const imported = await call('POST', importPath('orders'), { name: SAME_NAME });
106+
expect(imported.status, JSON.stringify(imported.json)).toBe(201);
107+
108+
const read = await call('GET', `/data/${SAME_NAME}`);
109+
expect(read.status, JSON.stringify(read.json)).toBe(200);
110+
expect(recordsOf(read.json)).toHaveLength(4);
111+
});
112+
113+
it('after a cold boot on the same database file, both imported objects still serve their rows', async () => {
114+
await stack.stop();
115+
stack = await boot(dbFile);
116+
token = await stack.signIn();
117+
118+
// Independent facts about one restart, so each is reported on its own.
119+
const renamed = await call('GET', `/data/${RENAMED}`);
120+
expect.soft(renamed.status, JSON.stringify(renamed.json)).toBe(200);
121+
expect.soft(recordsOf(renamed.json)).toHaveLength(3);
122+
123+
const sameName = await call('GET', `/data/${SAME_NAME}`);
124+
expect.soft(sameName.status, JSON.stringify(sameName.json)).toBe(200);
125+
expect.soft(recordsOf(sameName.json)).toHaveLength(4);
126+
}, 180_000);
127+
});

0 commit comments

Comments
 (0)