Skip to content

/import: inline mapping entries' transform (uppercase / lowercase / trim …) is accepted and silently ignored #25491

Closed

Description

@marchtian

What happens

ImportRequestSchema.mapping accepts the rich FieldMappingEntry[] form with transform: 'trim' | 'lowercase' | 'uppercase' | 'lookup' | 'date_format' | 'none', defaultValue and required. prepareImportRequest (packages/rest) flattens an inline array mapping to { sourceField: targetField } and drops everything else, so the request validates, the import runs, and no transform is applied.

Reproduction (spec / rest 18.0.0)

POST /api/v1/data/srm_supplier/import
{
  "format": "csv",
  "csv": "公司全称,国家 / 地区,统一社会信用代码\n示例公司,中国,91000000ma0trial02\n",
  "mapping": [
    { "sourceField": "公司全称", "targetField": "name" },
    { "sourceField": "国家 / 地区", "targetField": "country" },
    { "sourceField": "统一社会信用代码", "targetField": "credit_code", "transform": "uppercase" }
  ],
  "writeMode": "upsert", "matchFields": ["credit_code"]
}

The row fails the object's ^[0-9A-Z]{18}$ rule because the cell is still lowercase. Same for transform: 'lowercase' on an email column: ZHOU@X.EXAMPLE.COM and zhou@x.example.com are stored as two contacts, because matchFields: ['supplier', 'email'] compares them case-sensitively.

Expected

Either apply the entry's transform (and defaultValue / required) per cell before coercion, or refuse the request with a clear error that inline entries only support sourceField / targetField. Accepting it and doing nothing is the worst of the three.

Impact in hotsrm

Legacy supplier ledgers carry lowercase credit codes and mixed-case emails. Today the import guide tells operators to clean these columns by hand before importing (temporary measure).

Found during the trial import for objectstack-ai/hotsrm#10.

Activity

  1. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Oct 23, 2026
  2. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    Contributor

    Triage: lands in packages/rest/src/import-prepare.ts (prepareImportRequest, the inline-array mapping flattening); rationale: declared ≠ enforced: ImportRequestSchema.mapping accepts FieldMappingEntry.transform / defaultValue / required, and the inline path drops them silently. Apply them per cell before coercion (the named-mapping path already knows them), so the accepted contract is honoured rather than narrowed.
    Path: records · 把客户的存量数据搬进来 | records-forms.import-transform-matrix (P2) | step ⑥
    Grade: priority:p2 — inherits the P2 item; workaround (hand-cleaning) exists.
    Type: Bug
    Dedupe: checked against #25493 (cell coercion shapes, a different mechanism) and #25475/#25476 (import write modes) — distinct.

    Session: session_01AkmasAfXt3cu83xGzkuKhe (objectstack triage, run on the maintainer's request in the project thread)


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop · domain:cli seat 1 · p2 · 2026-10-23T18:22Z
    Session: session_01CxBKycaiUpA1yGx1XyoGqX
    Account: os-sales (the seat's linked user; always the card's assignee)
    Branch: claude/issue-25491-import-inline-transform
    Worktree: objectstack-issue-25491
    Domain: domain:server (this seat's side of the server lane under the coordinator's split: former cli and engine paths)
    Seat: domain:cli#1 (seat post #6024; 由Sales账户的项目负责)
    File surface (read on origin/main dc3e5b9d7b; stop on a breach): packages/rest/src/import-prepare.ts (prepareImportRequest, the inline-array mapping flattening), the shared transform it reuses if that lives in packages/rest, their tests, one changeset.
    Container & model: S, mode:subagent, model: default tier
    Clause-②: no
    Basis for the Clause-② line: honouring fields the schema already accepts (FieldMappingEntry.transform / defaultValue / required); revised with an arm if the measured diff refuses a request that is accepted today.
    Responsibility: n/a — reported from hotsrm#10, triaged 6370310580.
    Thread-read: 6370310580
    Serial constraints cleared: none of the 10 open PRs touches this surface (file lists read in this act).


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
      "issue": 25491,
      "status": "done",
      "branch": "claude/issue-25491-import-inline-transform",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25540",
      "session": "session_01CxBKycaiUpA1yGx1XyoGqX",
      "premise_still_valid": true,
      "summary": "prepareImportRequest (packages/rest/src/import-prepare.ts) now reads each inline FieldMappingEntry's transform, defaultValue and required and applies them per cell after the rows are built and before coercion (csv, json rows, xlsx; sync import and async job alike). trim/uppercase/lowercase rewrite a string cell, none/lookup copy it (lookup still resolved downstream, the named path's semantics), defaultValue fills a blank cell (core's isBlank), a still-blank required cell refuses the request 400 MISSING_REQUIRED_FIELD before any row naming column and rows, and date_format (or any undeclared name) refuses 400 UNSUPPORTED_TRANSFORM naming it. Mechanism assumption measured false: the named path (core applyMappingToRows) implements the mapping artifact's none/constant/map/split/join/lookup vocabulary, and trim/uppercase/lowercase/date_format/defaultValue/required are implemented nowhere in the tree, so there was nothing to hand the entries to; the three string transforms live in one table in import-prepare.ts (no second implementation exists), no spec change, no file outside packages/rest. Card assignee was os-sales (not touched).",
      "tests": "Head b5a3141e7f. New packages/rest/src/import-inline-mapping-entry.test.ts through the real POST /api/v1/data/:object/import route (ObjectQL + protocol + sqlite :memory:): card repro (uppercase credit code passes ^[0-9A-Z]{18}$ format rule, upsert on credit_code), lowercase email merge under matchFields [supplier, email] (created 1 then updated 1, one row stored), trim with trimWhitespace false, defaultValue (blank cell + missing column), required refusal on dry run and commit with 0 rows written, required satisfied by defaultValue, date_format 400 UNSUPPORTED_TRANSFORM, control plain {sourceField,targetField} unchanged: 'Tests 8 passed (8)', lock VERDICT command-exit 0. Whole package `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2`: 'Test Files 322 passed (322) / Tests 6616 passed | 344 skipped', VERDICT command-exit 0 (source unchanged after; later commits are changeset-only). Ablation via scripts/ablation-replace.mjs (suite resolves ./rest-server from src, no dist leg): anchor 'if (entryRules.length > 0) {' x1 -> x0, blob c170dc3885 -> ed352b27ee, 5 of 8 red (uppercase, lowercase, trim, defaultValue, required), 3 green as expected (parse-time date_format refusal, required+default, control); restored blob == HEAD c170dc3885, git diff HEAD empty.",
      "gates": "Head b5a3141e7f. `pnpm --filter '@objectstack/rest^...' build` VERDICT command-exit 0; `pnpm --filter @objectstack/rest build && pnpm --filter @objectstack/rest typecheck` (tsc + check:test-typecheck, 0 debt) VERDICT command-exit 0. `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran`: '67 derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)', exit 0; all 65 exit 0 (check-adr-0087-registration first red on the arrow in the changeset body, reworded, exit 0 at b5a3141e7f; check-changeset-no-major, check-empty-changeset, check-changeset-fixed rerun at b5a3141e7f exit 0). NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET exit 3 (whole-workspace build). NOT MEASURED: pnpm check:type-check-debt, reason: its closure build takes the verify lock and the wait budget expired behind a sibling's dogfood holder (exit 3). NOT MEASURED: pnpm lint (repo-wide), reason: three verify-lock queue-timeouts (VERDICT exit 99) behind pid 19029 (#25482's dogfood run, held 1673s+); declared narrowing: `pnpm exec eslint --no-inline-config packages/rest/src/import-prepare.ts packages/rest/src/import-inline-mapping-entry.test.ts` exit 0. Extra outside derivation: check-changeset-fixed exit 0, check:error-code-casing exit 0. CI check-runs on PR #25540: in_progress at report time (not awaited). origin/main moved 11 commits; none touches packages/rest, packages/core/src/utils or the spec import schema; merge-tree clean; no merge taken.",
      "dogfood_battery": "b5a3141e7f · os-verify-lock: VERDICT command-exit 0 · held the lock 2008s · Test Files 290 passed | 3 skipped (293) · Tests 2494 passed | 18 skipped (2512) · no failures",
      "line_budget": "+365 / -1 over 3 files (import-prepare.ts +119/-1, test +220, changeset +26): past the ~300-line S guide, almost all of it the route-level test file (8 cases with its own engine boot); implementation is ~120 lines.",
      "files_changed": [
        "packages/rest/src/import-prepare.ts",
        "packages/rest/src/import-inline-mapping-entry.test.ts",
        ".changeset/25491-import-inline-mapping-entry-rules.md"
      ],
      "deviations": [
        "Reuse route not available: the named-mapping path implements none of trim/uppercase/lowercase/date_format/defaultValue/required (it serves the mapping artifact's other vocabulary), so the string transforms were implemented once in import-prepare.ts (none exists elsewhere) and none/lookup keep the named path's copy semantics; blank test reuses core isBlank.",
        "date_format refused 400 UNSUPPORTED_TRANSFORM (no format exists on FieldMappingEntry); a transform name outside the declared set is refused the same way.",
        "required miss refuses the whole request (400 MISSING_REQUIRED_FIELD, rows named) instead of failing rows: the core runner has no pre-failed-row seam and core is outside the file surface.",
        "Clause-② moved from `no` to `no (narrowing)` (date_format and blank-required requests that ran are now refused); title carries `!`, changeset patch with BREAKING banner and adr-0087 not-required (no-migration-prescription) marker; Metadata-protocol: none.",
        "Repo-wide pnpm lint not run (lock contention); narrowed to the changed files and declared.",
        "PR body opens with the dispatch-mandated HTML attribution comment, conflicting with the role file's no-HTML-comment rule; followed the dispatch for that one platform marker, read back byte-identical (6825 sent / 6825 stored). `Fixes #25491` is line 4, after the two mandated lines."
      ],
      "mcp_calls": "0",
      "api_writes": "2 relay strokes via scripts/pm/fleet-write/dispatch.mjs (each POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls + POST /repos/objectstack-ai/objectstack/issues/25540/assignees; (2) comment = POST /repos/objectstack-ai/objectstack/issues/25491/comments (this report). Plus git push (not REST).",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed — packages/rest/src/import-mapping.ts docblock still calls the inline mapping a plain rename (accurate for the compact record form and plain entries); listed in PR Acceptance notes."
      ]
    }
  5. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    ACCEPT → PR #25540 comment 6382301638 · domain:cli seat 1 · session session_01CxBKycaiUpA1yGx1XyoGqX · 2026-10-24T09:02Z · contract review round 2 PASS (6382284309) · landing held by the release freeze · review finding filed as #25611


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    Landed · domain:cli seat 1 · session session_01CxBKycaiUpA1yGx1XyoGqX · 2026-10-24T10:11Z

    reading result
    PR #25540 object merged: true, merge commit 50af6fa3ca, merged 2026-10-24T10:10:39Z through the merge queue
    origin/main 50af6fa3ca is an ancestor of origin/main, single parent (squash)
    queue branch no gh-readonly-queue/main/* head for PR 25540 remains

    State: card closed completed by the PR's Fixes #25491. pm:dispatched stripped in this act. The round-2 review's finding is filed as #25611 and moves from pm:blocked to pm:queue in this act, for another domain:server seat (this seat claims no new cards).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions