Skip to content

feat(datasource): import objects from an OData datasource's $metadata, and import several entity sets from Studio and MCP - #27880

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-kmqsti
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-kmqsti

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

epic:#27261#1 · marchtian · session_017ihL2c8AWH1pFbfa5XHrbg

Requested by 田騏 · project thread

Fable review owed. Developed on Opus while the Fable quota is out. This PR touches packages/spec (the type matrix) and is Clause-②: yes (widening), so it lands only after a full Fable review and a CONTRACT_REVIEW_TIER contract review record PASS on the current head.

Fixes #27745

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:OData 数据源可以从 $metadata 导入对象。EDM 类型经 spec 类型矩阵新增的 odata 方言映射为字段类型;PascalCase 属性生成 snake_case 字段名并用 external.columnMap 绑定远端名;带 ReferentialConstraint 且目标已导入的单值导航属性生成 lookup,其余导航属性不进草稿并在 review 里写明原因。Studio 数据源页可多选实体集一次导入(先导入被引用的目标),只有数据源允许写时才出现「允许写入」勾选,默认只读。MCP 新增 list_remote_tables / import_object,仅对带 metadata:write 的令牌注册,运行时按 REST 同样的两个权限拒绝。
  • 为什么改:epic [Decision] Ontology over external systems: virtual objects on ADR-0015 federation — two driver tiers (plugin code · declarative OpenAPI), pushdown/identity/catalog/cache rules, Studio and AI drafts (2026 ontology research + deep design) #27261 P2:不写代码接入的另一半,没有导入,管理员仍要手写对象。
  • 风险与回滚:spec 改动按 PM 裁决(A 方案,修订 6512853588 / 6512860835):类型矩阵 lookup / master_detail 加入 text / integer / bigint 行的精确集合,Gate 2 对 SQL 联邦也变宽松(以前通过的仍通过)。远端表名首字母大写时对象名从 _products 变为 products。回滚即 revert 本 PR。
  • 先看:packages/spec/src/data/type-compat.ts;external-datasource-service.ts 的 draftLookups;ui/.../external/api.ts 的 importRemoteTables。
  • 两分钟验证:pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/external-object-draft-odata-metadata.test.ts,预期 13 个用例全绿:真实 OData 驱动读取录制的 Northwind $metadata,草稿、导入、Gate 2 校验和 lookup 读取都走通。
  • 需要你做的:Fable 复审与契约复审 PASS 后落地本 PR。

What changes

Type matrix (packages/spec/src/data/type-compat.ts).

  • SqlDialect gains 'odata'. Its aliases map the EDM primitives: Edm.String to text, Edm.Byte / SByte / Int16 / Int32 to integer, Edm.Int64 to bigint, Edm.Decimal to decimal, Edm.Single / Double to float, Edm.Boolean, Edm.Date and Edm.TimeOfDay to boolean, date and time, Edm.DateTimeOffset to datetime, Edm.Guid to uuid, Edm.Binary / Stream to binary, and Collection(...) to array (the parameter strip leaves collection).
  • Edm.Duration, the geography and geometry families, and a service's own enum or complex types stay unknown, so a draft flags them for review.
  • lookup and master_detail join the exact sets of the text, integer and bigint rows, as they already sat under uuid. A reference stores the target's id, which on a federated object is the remote key a foreign-key column carries. Before this, Gate 2 refused a lookup over CustomerID int or a varchar key as a type_mismatch error. This loosens Gate 2 for SQL federation too. Nothing that passed before fails now.
  • Deviation from the ruling's wording, reported: the ruling said Edm.Int64 and Edm.Decimal would be marked lossy. The matrix decides lossiness per canonical row, and SQL's bigint and numeric rows mark number exact. Making the EDM pair lossy would need new canonical rows that only OData uses, so they take the SQL verdict. That was my misstatement in the option text.
  • Exhaustive consumers of SqlDialect: only the DIALECTS list in type-compat.test.ts (updated). ui/ holds no copy (grep for the union's members finds none).

Draft (packages/services/service-datasource/src/external-datasource-service.ts).

  • typeVocabulary(dialect) reads the driver's odata-4.0 as the odata vocabulary. Drafting, refreshCatalog and validateObject all go through it.
  • Field names: a column whose name is already a snake_case field name keeps it (the SQL path is unchanged). Otherwise it drafts as its snake_case spelling (CategoryID to category_id), and external.columnMap carries the remote name, which the driver binds through and Gate 2 inverts. rename overrides now land in columnMap too, so a renamed field stays bound to its column (before, it lost its column and Gate 2 reported it missing). When two columns spell one field name, the later one is numbered and named in review.
  • Object names go through the same rule. Products drafted as _products before, because the leading capital was prefixed before lower-casing.
  • draftLookups: a single-valued navigation (navigationProperties on the OData driver's introspected table, read structurally) becomes { type: 'lookup', reference } on its ReferentialConstraint's dependent column when all of these hold:
    • the constraint is a single pair;
    • the dependent column is in this draft;
    • the referenced property is the target's single key;
    • the column's type can back a lookup by the matrix (the verdict Gate 2 reaches);
    • the target is imported: an object on the same datasource bound to the target entity set, or the object itself (the override name on importObject).
  • Every other navigation is left out and named in review (remoteType navigation → ENTITY_SET) with the reason. A target imported later needs this table imported after it, because re-saving a scalar as a lookup is a retype the save door's destructive-change gate refuses.
  • The rendered source carries columnMap, the lookup's reference, review comments keyed by field, and left-out navigations as comments.

Driver (packages/drivers/driver-odata). introspectSchema() lists each navigation's referentialConstraints.

Docs. content/docs/data-modeling/odata-datasources.mdx §2 said nothing generates fields from $metadata (it landed in #27871 while this PR was open). The bullet now names the import (Studio's Tables tab, Import / Import selected, MCP list_remote_tables / import_object), what the draft does, the lookup rule, and the test that asserts it.

Studio (ui/packages/app-shell/.../metadata-admin/external).

  • The tables tab selects several entity sets (row and select-all checkboxes) and "Import selected" imports them through importRemoteTables. Each round drafts the pending tables and saves the ones whose draft waits on no other pending table. A cycle is saved as drafted.
  • A batch summary lists what landed and each refusal in the server's words.
  • The write opt-in shows only when the datasource has external.allowWrites. It is off by default, and the single-table dialog has it too, so an imported object is read-only unless the author ticks it.
  • New studio rows in en and zh.

MCP.

  • @objectstack/mcp adds McpFederationBridge and registerFederationTools: list_remote_tables (read-only) and import_object (a non-destructive write that saves a live object, not a draft, and says so).
  • Both register only when grantsAuthoringTools(scopes) holds (the token names metadata:write) and the bridge carries both members.
  • The SKILL.md names them only for that grant on a host that says it carries them (federationTools).
  • @objectstack/runtime's bridge reaches the external-datasource service. It refuses 403 PERMISSION_DENIED without FEDERATION_READ_CAPABILITY / FEDERATION_WRITE_CAPABILITY, asked first as the REST routes ask it. It answers 503 with no service.
  • @objectstack/rest exports the two constants.

Changeset: patch on spec, driver-odata, service-datasource, rest, runtime, mcp, @object-ui/app-shell, @object-ui/i18n.

Why

Epic #27261 P2, 田騏 at 2026-10-29T21:58Z 「现在启动全部 P2」. Tier B is declarative OData (ruling 6486145005). Without an import, an administrator still writes every object by hand. Spec scope per the epic PM's amendments 6512853588 and 6512860835 (option A, both parts).

Verification

Head 14e38cf6: the feature commit, a test-typing follow-up, a vitest alias that resolves @objectstack/driver-odata to its source (the test-source alias gate), a merge of origin/main at e320e36, and the guide edit. After pnpm install and builds of the touched packages:

  • @objectstack/service-datasource: 64 files, 1041 tests passed, including the new external-object-draft-odata-metadata.test.ts (13). It drives the real ODataDriver.introspectSchema() (resolved to its source by the vitest alias) over a recorded Northwind $metadata (__fixtures__/northwind-metadata.xml): EDM type mapping, snake_case fields plus columnMap, lookups only onto imported targets (per datasource, self-reference under an override name, composite-key source), left-out navigations named, Gate 2 validateObject clean on four imported objects with a negative control, and the driver reading category_id and finding the category by it (Categories?$filter=CategoryID eq 1).
  • @objectstack/driver-odata: 6 files, 439 passed. @objectstack/spec type-compat.test.ts: 27 passed. pnpm --filter @objectstack/spec check:generated: all 14 artifacts current.
  • @objectstack/mcp: 46 files, 595 passed, including mcp-federation-tools.test.ts (registration by grant and bridge, argument forwarding, unknown-key refusal, coded refusal, SKILL.md parity). @objectstack/runtime mcp-federation-bridge.test.ts (4: the capability asked before the service lookup, 503 without a service) plus the two existing MCP domain files (156).
  • ui/: metadata-admin/external and metadata-admin/previews, 154 files, 1722 passed, including SchemaBrowser.batchImport-27745.test.tsx (7) against a mocked federation service through the real REST client. @object-ui/app-shell type-check exit 0.
  • Typecheck: service-datasource, mcp exit 0. runtime tsc --noEmit exit 0; its test-layer step reports one error in organization-ownership-boot-guard.turso-remote.kernel.test.ts (@objectstack/driver-turso not built in this container), a file this PR does not touch.
  • eslint on every changed file: 0 errors. The warnings are the two pre-existing set-state-in-effect lines.
  • Local gates, derived after the merge: scripts/pm/dispatch-gates.mjs --ran reports 122 derived, 122 run, all exit 0, 0 NOT-MEASURED. The 7 that first exited 3 (prerequisites: the teardown fixture commit, a built spec / dist) were rerun after their prerequisites and pass.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ihL2c8AWH1pFbfa5XHrbg


Generated by Claude Code

EDM types map to field types through a new 'odata' arm of the spec's
type matrix, PascalCase properties draft as snake_case fields bound by
external.columnMap, and a single-valued navigation with a referential
constraint onto an imported target becomes a lookup; every other
navigation is left out of the draft and named in its review. The matrix
also accepts lookup/master_detail over text, integer and bigint key
columns, so Gate 2 no longer refuses a federated lookup over a foreign
key that is not a UUID.

Studio's datasource page imports several entity sets at once, targets
first, with the write opt-in offered only where the datasource allows
writes. MCP gains list_remote_tables / import_object behind
metadata:write, refused by the runtime bridge on the same federation
capabilities as the REST routes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2c8AWH1pFbfa5XHrbg
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/driver-odata, @objectstack/mcp, @objectstack/rest, @objectstack/runtime, @objectstack/service-datasource, @objectstack/spec, touching 56 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/mcp/src/index.ts, packages/services/service-datasource/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/api/error-catalog.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/api/index.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/automation/webhooks.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/data-modeling/external-datasources.mdx (via remoteName (literal, a string literal in registerFederationTools))
  • content/docs/data-modeling/odata-datasources.mdx (via import_object (literal, a string literal in registerFederationTools), list_remote_tables (literal, a string literal in registerFederationTools), remoteName (literal, a string literal in registerFederationTools))
  • content/docs/data-modeling/virtual-objects.mdx (via introspectSchema (symbol, a method of class ODataDriver), remoteName (literal, a string literal in registerFederationTools))
  • content/docs/permissions/authorization.mdx (via buildMcpBridge (symbol, a top-level function), SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/permissions/sharing-rules.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/protocol/kernel/error-handling.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/ui/print-pages.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))

⛔ 8 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/releases/v17/17-2.mdx (via introspectSchema (symbol, a method of class ODataDriver), datasources.external.import (sdk, the route ledger binds it to POST /api/v1/datasources/:name/external/tables/:remote/import, selected by route anchor /tables/:remote/import), /tables/:remote/import (route, a path literal in a comment in buildMcpBridge; a path literal in a comment on a changed line; bridged from symbol importObject — its route source's handler names it))
  • content/docs/releases/v17/17-3.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/releases/v17/17-4.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/releases/v17/17-7.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))
  • content/docs/releases/v18/18-0.mdx (via introspectSchema (symbol, a method of class ODataDriver))
  • content/docs/releases/v18/18-1.mdx (via introspectSchema (symbol, a method of class ODataDriver))
  • content/docs/releases/v18/18-2.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in federationService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/mcp/src/index.ts, packages/services/service-datasource/vitest.config.ts) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 50 pages), master_detail (literal, 39 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 179 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e320e366da668b65359bd48fff1923d227513faf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d431e3aea47a842fcf72a6e4becb2792911d5d94 — the merge of head 14e38cf674ddbc9d9b416b34cf468687bbcad88b into base e320e366da668b65359bd48fff1923d227513faf, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d431e3aea47a842fcf72a6e4becb2792911d5d94 && git checkout d431e3aea47a842fcf72a6e4becb2792911d5d94
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e320e366da668b65359bd48fff1923d227513faf 14e38cf674ddbc9d9b416b34cf468687bbcad88b && git checkout -B drift-repro e320e366da668b65359bd48fff1923d227513faf && git merge --no-ff 14e38cf674ddbc9d9b416b34cf468687bbcad88b

node scripts/docs-audit/affected-docs.mjs --json e320e366da668b65359bd48fff1923d227513faf

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e320e366da668b65359bd48fff1923d227513faf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ce for the OData draft pin

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2c8AWH1pFbfa5XHrbg
The guide said nothing generates fields from $metadata. Studio's tables
tab and MCP import_object now draft and save the objects, with lookups
onto imported targets, so the bullet says how and which test asserts it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2c8AWH1pFbfa5XHrbg
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (HTTP 429 rate_limit, 2026-10-30T03:21Z, request req_011CgXherv1cXbMoQJVgeAdW) · head 14e38cf

The seat's at-tier review call was refused by the model quota, and per contract-review.md the tier is not lowered. Owed on this head: one same-shape CONTRACT_REVIEW_TIER record and the independent full Fable review of the spec change. One record with a genuine ④ can serve both, as on #27715.

  • Faces, Clause-②: yes: packages/spec/src/data/type-compat.ts. The published SqlDialect union gains 'odata', and DIALECT_ALIASES.odata maps the EDM primitives. lookup and master_detail join the exact sets of the text, integer and bigint rows, which loosens Gate 2 for SQL federation too. Nothing that passes today fails. PM rulings: 6512853588 and 6512860835.
  • Reported deviation: Edm.Int64 and Edm.Decimal take SQL's exact verdict, not lossy. The matrix decides lossiness per canonical row, and the bigint / numeric rows mark number exact. The seat accepts it; making them lossy would need OData-only canonical rows.
  • The rest is no-face: draft and import through introspectSchema in service-datasource, the Studio Tables tab, MCP list_remote_tables / import_object, and the odata-datasources.mdx §2 rewrite. Card datasource import: generate object drafts from an OData $metadata document, and the Studio datasource page lists and imports its entity sets — epic #27261 P2 #27745, os-dev-report 6514133439.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 14e38cf674ddbc9d9b416b34cf468687bbcad88b · 2026-10-30T03:33Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 14e38cf674ddbc9d9b416b34cf468687bbcad88b
Local-runs: none
Scope: packages/spec/src/data/type-compat.ts + packages/spec/src/data/type-compat.test.ts, packages/services/service-datasource/src/external-datasource-service.ts (typeVocabulary, draftObject, draftLookups, refreshCatalog, validateObject), packages/drivers/driver-odata/src/odata-driver.ts (introspectSchema, ODataIntrospectedTable), packages/drivers/driver-odata/src/dialect-v4.ts (the CSDL parser's referentialConstraints and Collection(...) spelling), docs/adr/0015-external-datasource-federation.md §4.6, .changeset/27745-odata-metadata-import.md

Inputs: card #27745 (body, claim 6512798387, amendments 6512853588 and 6512860835, scope addition 6513671180, os-dev-report 6514133439), PR #27880 body and 26-file list, the net diff against main at e320e366, and the 75 check-runs on the head: every one success or skipped (opt-in and matrix-placeholder jobs), 0 failures. Check Changeset, PR-body guards, Governed Surface Queue Guard, Spec property liveness, TypeScript Type Check, Type Check UI, Test Core, Test UI and Lint & Repo Gates all green. The rest of the diff (service, driver, MCP, runtime bridge, rest exports, Studio tab, i18n, docs page, lockfile) is the lane seat's ACCEPT at TIER_DEFAULT; its code is read below only where the hits hunks cite it.

① Derived judgments

  1. SqlDialect (published type, @objectstack/spec/data, listed in api-surface/data.json as SqlDialect (type)) gains the member 'odata'. Widening of a published union; Clause-②: yes (widening) is the right declaration. Right. No generated artifact records the union's members, so no regeneration is owed; TypeScript Type Check (the job that holds every spec artifact gate) is green on the head.
  2. Exhaustive consumers of SqlDialect: git grep on the head finds exactly one, the DIALECTS pin in type-compat.test.ts (updated to seven members). ui/ holds none (no 'mongo' or dialect-list literal under ui/). driver-sql names the type only in comments. external-datasource-service.ts casts a string to it and is not exhaustive. The PR body's count is right.
  3. canonicalizeSqlType(raw, 'odata') accept set: seventeen new own-keys under DIALECT_ALIASES.odata (edm.string, edm.boolean, edm.guid, edm.byte, edm.sbyte, edm.int16, edm.int32, edm.int64, edm.decimal, edm.single, edm.double, edm.date, edm.timeofday, edm.datetimeoffset, edm.binary, edm.stream, collection). The dialect branch is reached only when dialect === 'odata', so no other dialect and no dialect-less call reads an EDM name (test pins Edm.Int32 as unknown with no dialect and with postgres). Right.
  4. collection to array: the driver spells a collection property Collection(Edm.String) (odata-driver.ts line 249, p.isCollection ? Collection(...) : p.type), the parameter strip leaves collection, so it is the live spelling, not a dead key. A collection of a complex type (Collection(Self.Location) in the TripPin fixture) also lands on array and drafts as multiselect; this is the same verdict the postgres text[] path already gives whatever the element type, and json is in the array row's exact set for the author to retype. Right.
  5. Left unknown on purpose: Edm.Duration, Edm.Geography* / Edm.Geometry*, and a service's own enum or complex types (never an edm.-prefixed name). suggestFieldTypeForSqlType returns undefined, and the draft (external-datasource-service.ts line 901 onward) defaults the field to text and pushes a review entry. Right; consistent with the ruling's "a draft flags them for review".
  6. suggestFieldTypeForSqlType(raw, 'odata') now returns a field type for the mapped primitives where before every EDM name canonicalised to unknown and undefined. No row's suggested value changes, so every SQL-dialect suggestion is identical to main. Right.
  7. isCompatible accept set: lookup and master_detail join the exact arrays of the text, integer and bigint rows (already present under uuid). Both were in no lossy array before, so the change is a pure false to true move for those two field types on those three rows and nothing else moves. Every dialect reaches these rows, so it loosens Gate 2 for SQL federation too, exactly as amendment 6512860835 ruled and the changeset says in plain words. Monotone: nothing that answered true or 'lossy' on main answers differently now. The claim "nothing that passes today fails" holds by construction. Right.
  8. Reported deviation: Edm.Int64 and Edm.Decimal are NOT marked lossy. Judged against main: the bigint row is exact: ['number', 'autonumber'], decimal is exact: ['number', 'currency', 'percent'], so the ruling's premise "lossy as their SQL kin are" was false when written; the SQL kin are exact. Lossiness is a property of the canonical row, not the alias, so marking the EDM pair lossy would need two OData-only canonical rows, which is the second type table option A was chosen to avoid. Taking the SQL verdict is the right reading of the ruling's intent. Right.
  9. ODataIntrospectedTable.navigationProperties[] (exported type of @objectstack/driver-odata) gains a required referentialConstraints array, filled from the parser's referentialConstraints that already existed on main (dialect.ts line 44). Additive on the read side; a consumer constructing the type by hand (tests) must add the key. Covered by the PR's Clause-②: yes (widening) and the driver's patch changeset; outside hits, named for completeness.
  10. Other public-surface additions outside hits, each a widening the declaration and changeset cover: @objectstack/rest exports FEDERATION_READ_CAPABILITY / FEDERATION_WRITE_CAPABILITY; @objectstack/mcp exports McpFederationBridge / registerFederationTools and registers list_remote_tables / import_object behind grantsAuthoringTools; @objectstack/runtime wires the bridge. Right as declared; the lane's ACCEPT judges their code.
  11. Metadata-protocol: none: no authorable key, accepted value or metadata type changes. SqlDialect is a TypeScript type, not a Zod schema; external.columnMap, external.remoteName and lookup.reference already exist on main. Gate 2's looser verdict is a runtime cross-check against remote introspection, not a change to what an author may write. Right.

② Semver level

Changeset .changeset/27745-odata-metadata-import.md: patch on @objectstack/spec, @objectstack/driver-odata, @objectstack/service-datasource, @objectstack/rest, @objectstack/runtime, @objectstack/mcp, @object-ui/app-shell, @object-ui/i18n. Those eight are exactly the published packages the 26-file diff moves (package names verified against each package.json). Both declaration lines are line-initial in the PR body and the changeset body and agree: Clause-②: yes (widening) and Metadata-protocol: none. Per AGENTS.md Post-Task Checklist §3 the level follows the protocol kind alone: none takes patch; a widening ships patch. Level right. The changeset body states the Gate 2 loosening for SQL federation in plain words, as amendment 6512860835 required. Check Changeset is green on the head.

Clause-②: yes (widening) is the correct declaration for this head.

③ Boundary flags

  • Dev flag, deviation (Edm.Int64 / Edm.Decimal exact, not lossy): answered in ①.8, accepted; no follow-up owed.
  • open_questions: empty in os-dev-report 6514133439. Nothing to answer.
  • out_of_scope_findings: @objectstack/driver-turso unresolved in a runtime test-layer typecheck inside a cloud container (package not built). TypeScript Type Check and the debt-ledger lanes are green on the head; the file is untouched by this PR. Nothing owed here.
  • surface_note: packages/spec/src/data/type-compat.ts was added to the file surface by amendment 6512853588, lifting the claim's "stop on breach" for packages/spec/src/**; the content/docs/data-modeling/odata-datasources.mdx §2 edit is the scope addition 6513671180 (prose face, lane's ACCEPT); the vitest.config.ts alias resolves @objectstack/driver-odata to source for the test-source alias gate. All inside the amended surface.
  • Serial constraint (claim 6512798387): datasource-connection-service.ts and the admin plugin/service are not in the file list; main was merged at e320e366, after docs(data-modeling): an OData datasources guide, and the three-way choice between declarative OData, a plugin driver and a synced real object #27871 landed, as the scope addition asked.
  • PR body flag "lands only after a full review at the reviewer tier and a CONTRACT_REVIEW_TIER record PASS": this record carries a genuine ④ on the spec change and serves both, per contract-review.md (one record, no second).
  • needs:contract-review is on the PR; on this PASS the seat that posts the record removes it in the same stroke. The PR is a draft; the lane marks it ready.
  • PR user.login is objectstack-fleet[bot]: no relay-author note owed.

④ Design judgment

packages/spec/src/data/type-compat.ts, judged against ADR-0015 §4.6 (the matrix is "dialect-aware ... new dialects extend the matrix"), amendments 6512853588 and 6512860835, and its first consumer external-datasource-service.ts on the head.

Holds:

  • One table for draft, catalog and Gate 2 (the point of option A): draftObject (line 862), refreshCatalog (line 1163) and validateObject (line 1216) all read typeVocabulary(schema.dialect), which maps the driver's odata-4.0 (odata-driver.ts line 264, odata-${version}) onto 'odata'. main cast schema.dialect as SqlDialect at the same three sites, so no reader was skipped. The CLI and every other matrix reader see EDM names through the same arm, as the ruling wanted.
  • Lower-casing: canonicalizeSqlType lower-cases before the own-property lookup and the alias keys are lower-case, so Edm.Int32, edm.int32 and EDM.INT32 all land. The Object.prototype guard is unchanged and the DIALECTS fall-through pin now includes odata.
  • The lookup rule: draftLookups (line 1061) asks isCompatible(column.type, 'lookup', dialect) !== true before it drafts a lookup, which is the very verdict validateObject (line 1267) reaches at boot. A drafted lookup therefore cannot be refused by Gate 2 for its type, and a column the matrix refuses is left out and named. With lookup exact under integer, the Northwind CategoryID Edm.Int32 reference drafts and validates; without it, as the amendment found, every non-UUID foreign key was a type_mismatch error.
  • Break attempts: an EDM name reaching a SQL dialect stays unknown (dialect branch gated on 'odata'); a leading-underscore or [] spelling cannot arise from CSDL; a complex-type column (NorthwindModel.Supplier) is unknown, drafts text, and is flagged; Edm.Guid keeps the uuid row so user fields still have a home; Edm.Date to date and Edm.DateTimeOffset to datetime keep the existing date/datetime lossy pairing. No path found where a value that validated on main fails on this head.

Findings:

  • Should-fix (docs-only, not this PR): ADR-0015 §4.6 lists the dialects as postgres, mysql, sqlite, snowflake, bigquery, mongo; the sentence "new dialects extend the matrix" keeps it true, but the list is now one short. A one-line ADR touch-up on its own governed-path card.
  • Should-fix (follow-up, out of ruling): user stays exact under uuid only. A federated user reference over an integer or text key will still be a type_mismatch; the ruling named lookup and master_detail only, so this is correctly not in this PR, but the same reasoning applies and a card should decide it.
  • No must-fix.

Implemented-by: claude/project-thread-kmqsti
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
ACCEPT · PR #27880 @ 14e38cf674ddbc9d9b416b34cf468687bbcad88b · 2026-10-30T03:45Z

维护者速读

  • 改了什么: OData 数据源可以从 $metadata 自动生成对象草稿并导入:EDM 类型映射为字段类型,实体键映射为 id,带引用约束的导航属性映射为 lookup(目标对象也已导入时)。字段名转成 snake_case,并写好 columnMap。Studio 数据源页的 Tables 标签支持逐个或批量导入;MCP 的 list_remote_tables / import_object 走同一条路径。spec 的类型兼容矩阵加了 odata 方言,并允许 lookup 落在文本或整数外键上。
  • 为什么改: epic [Decision] Ontology over external systems: virtual objects on ADR-0015 federation — two driver tiers (plugin code · declarative OpenAPI), pushdown/identity/catalog/cache rules, Studio and AI drafts (2026 ontology research + deep design) #27261 P2:SAP / Dynamics 365 的实体集动辄上百个字段,手写对象不现实。
  • 风险与回滚: 类型矩阵只放宽(SqlDialect 新增 'odata';lookup / master_detail 可落在 text、integer、bigint 列上,SQL 联邦也一样放宽),今天能过的都不会失败。changeset 写明了。还原即回滚。
  • 先看: packages/spec/src/data/type-compat.ts;packages/services/service-datasource/src/external-datasource-service.ts 的草稿生成。
  • 两分钟验证: pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/external-object-draft-odata-metadata.test.ts。
  • 需要你做的: 无,席位落地。

席位意见:ACCEPT。契约复核 PASS 在案(分诊 6514424141,CONTRACT_REVIEW_TIER,就是当前 head)。Fable 额度仍未恢复,按 2026-10-29 19:39Z 指示,这份记录兼作 spec 改动的独立完整复核。无 must-fix。

席位核对

  1. Shape: +1765/−46, 26 files: the type matrix arm, draft and import in service-datasource, driver-odata introspectSchema additions, the MCP tools and runtime bridge, the Studio Tables tab with batch import and i18n, the guide's §2 rewrite, and tests and fixtures (a Northwind $metadata).
  2. Premise measured: on main every Edm.* column canonicalised to unknown. Drafts came out all text, and Gate 2 refused an Edm.Int32 column mapped to number. The rulings are 6512853588 and 6512860835.
  3. Gate check-runs on 14e38cf6: 78 runs: 62 success, 15 skipped, 0 failed. Check Changeset is re-running after a label event, and the merge queue gates on it. Local: 122 derived gates, all exit 0.
  4. Accepted deviations: Edm.Int64 and Edm.Decimal take SQL's exact verdict, not lossy. The matrix decides lossiness per canonical row; the record accepted it. The PR also touches packages/mcp, packages/rest and packages/runtime (the MCP federation bridge), past the claim's "MCP path" wording; that is the path the tools take.
  5. Out of scope, routed: the record's should-fixes (ADR-0015 §4.6's dialect list omits odata; the user field type is exact only under uuid) go to a follow-up card, linked below once filed. Skills-docs: skills + docs: teach external.connector (D7) beside credentialsRef in the datasources skill rule and the two data-modeling guides — epic #27261 P2 #27786.
  6. Landing: not governed (check-governed-merges --pr 27880: 1811 lines ≤ 3000). A same-shape PASS stands on the current head, so needs:contract-review comes off. Landed by the seat through the relay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

2 participants