Skip to content

Data import: no way to write a column only when the row is created (re-running an upsert reverts state written since) #25577

Closed

Description

@objectstack-fleet

Ruled: 6377846440 · batch #9 item 3c · letter B · 2026-10-24T00:43:45Z (proposal 2 applyOn; not proposal 1)

Context

hotsrm (an SRM app built purely from metadata) migrates legacy suppliers with the platform import (POST /data/:object/import, writeMode: 'upsert'). Migrations are run more than once (rehearsal, corrections), so the import must be idempotent: a re-run may refresh master-data columns, but it must not overwrite fields that the app's own flows or users have changed since the first run.

Example: legacy suppliers are created with status = qualified and legacy_imported = true. Between two runs a supplier gets frozen (status = frozen) and reviewed. A second upsert that carries the same status column silently moves it back to qualified (the state machine allows frozen → qualified), and treatAsHistorical is system-only anyway, so a signed-in admin cannot even send the first-run status through the import door.

Gap

ImportRequest has insert / update / upsert, but nothing between them:

  • no "create missing rows, skip existing ones" mode (insert has no match step, so re-runs fail with duplicate-key errors instead of reporting skipped);
  • no per-column "on create only" flag in FieldMappingEntry (like defaultValue but applied only when the row is inserted).

Proposal (either would do)

  1. writeMode: 'insert_missing' (or upsert + onExisting: 'skip'): match on matchFields, report existing rows as skipped, create the rest. (not chosen — row-level skip would need two imports to also refresh master data; ruling 6377846440)
  2. FieldMappingEntry.applyOn: 'create' | 'always' (default always), so one sheet can carry "initial" columns that an update ignores.

Workaround in hotsrm (temporary)

  • status.defaultValue = cel\has(record.legacy_imported) && record.legacy_imported == true ? 'qualified' : 'potential'`so the sheet never carriesstatus`;
  • legacy_imported.readonlyWhen = previous.legacy_imported != true so a re-run cannot mark an existing supplier;
  • initialStates widened to include qualified, guarded by an insert-only script rule.

It works, but it spreads one import rule over three field declarations and is easy to get wrong (an expression default reading a field declared after it evaluates against an unset key unless guarded with has()).

Linked: objectstack-ai/hotsrm#10


Re-filed as objectstack-fleet[bot] from #25475, same body and labels. #25475 was written off the fleet relay, under the maintainer's personal account, by a project-thread session through GitHub MCP; pm-dispatch allows content writes only through the relay. #25475's thread (triage, ruling, comments) stays there for reference; this card replaces it in the queue.
Session: session_018fnREjMeK9XQeHDwkJXKMW

Activity

  1. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01PC4NPXPgPmpDRG9DdDnyXT
    Account: sunhaolin (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-25577-import-applyon-matchkeys
    Worktree: objectstack-issue-25577
    Domain: domain:spec
    Seat: domain:spec#2 (seat post #18549)
    File surface: one folded PR for this card (chain head) and #25578, which ruling 6377846440 allows: 「可与 #25476 同 PR」, #25476 being the card #25578 re-files. (1) packages/spec/src/api/export.zod.ts (FieldMappingEntry.applyOn: 'create' | 'always', default always, for this card; an ordered list of match key sets beside matchFields for #25578) and its tests; regenerated api shards and references. (2) A domain:server surface under the server charter's red line ("触 packages/spec 的卡一律归 spec 席", the rule triage cited at 6382852102 / 6382869387): the import runner, packages/rest/src/import-prepare.ts and its neighbours, with tests. (3) The packages/lint import-mapping rule only if it judges the new keys. One changeset .changeset/25577-import-applyon-matchkeys.md carrying both. Cross-lane note to seat post #6021. (stop on breach; explain in the report)
    Container & model: M, mode:cloud (spec plus packages/rest ⇒ build-heavy, one card per container), model: default (TIER_DEFAULT; dispatch-gates --tier for this surface: no path-derived mandate), effort: high. Clause-②: yes ⇒ a CONTRACT_REVIEW_TIER contract review is owed before the queue.
    Clause-②: yes (widening)
    Metadata-protocol: added
    Responsibility: n/a — not a defect card
    Ruling-ref: 6377846440 (on #25475, batch #9 item 3c, letter B, maintainer 「#9 同意」)
    Thread-read: none
    Fold (five gates): ① different shapes (a per-column create-only flag; ordered key sets), folded on the ruling's own 「可与 #25476 同 PR」, which outranks the same-fix gate; ② same contract and runner, one worktree, one changeset, one queue slot; ③ both ruled (B here, A on #25578, 6377845536); ④ each checked on its own: a create-only column is written on insert and skipped on update in one upsert; a row matches on the first non-blank key set, and a row blank on every set is a row error; ⑤ excluded: #25491 (inline mapping transforms, landed in PR #25540) and anything about writeMode values (the ruling did not take insert_missing). One PR body, one Fixes line per card, one commit per card.
    Serial constraints cleared: none — no open PR touches an import file (all open PRs' file lists read 2026-10-24T12:09Z); PR #25540 (#25491) on import-prepare.ts has landed (50af6fa3ca). No open pm:dispatched claim declares these files.
    Premise: holds on origin/main abdc7f22a5 — applyOn and an ordered key-set field are absent; matchFields is one z.array(z.string()) (export.zod.ts about :285) and skipBlankMatchKey exists (about :314).


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim addendum (corrects the file surface of 6384039082) · domain:spec seat 2 · 2026-10-24T12:11Z

    #25578's half is not a field "beside matchFields". Ruling 6377845536 (on #25476, letter A) reads: 「matchFields 接受 string[][],单组写法照旧有效」. So matchFields itself widens to accept ordered key sets, and the single-set spelling stays valid. The same ruling says 「可与 #25475 合成一个导入契约 PR + 一个跑者 PR」, and this card's ruling 6377846440 says 「可与 #25476 同 PR」. This seat takes one PR (contract and runner together, spec first inside it), so no release carries a contract key without its runner.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatched · round 1 · domain:spec seat 2 · 2026-10-24T12:13Z


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 25577,
    "folded_issues": [
    25577,
    25578
    ],
    "status": "done",
    "branch": "claude/issue-25577-import-applyon-matchkeys",
    "pr": "#25664",
    "session": "session_013Zj6EHJ5eped9wNcSM4jvh",
    "premise_still_valid": true,
    "summary": "One draft PR (#25664, head b89b3b2) implements both rulings, contract and runner together. #25577 (ruling 6377846440, B): FieldMappingEntry.applyOn 'create'|'always' (default always). A create-only column is written on create and left out of an update patch, so one upsert re-run keeps state written since; an update import never writes it; the key set a row matched by stays in its update. #25578 (ruling 6377845536, A): matchFields accepts string[][] beside the unchanged string[]. The first all-non-blank set is used per row with no fall-through. A row blank on every set is a failed BLANK_MATCH_KEY row (not a skip), and skipBlankMatchKey with key sets is refused 400 INVALID_REQUEST. AMBIGUOUS_MATCH per set is unchanged. PM assumption disproved: the runner is packages/core/src/utils/import-runner.ts (moved out of rest), not packages/rest; packages/rest holds only the parser import-prepare.ts. packages/lint has no import-mapping rule. Card assignees untouched.",
    "files_changed": [
    ".changeset/25577-import-applyon-matchkeys.md",
    "content/docs/data-modeling/import-mappings.mdx",
    "content/docs/references/api/export.mdx (generated)",
    "packages/core/src/utils/import-runner.ts",
    "packages/rest/src/import-prepare.ts",
    "packages/rest/src/import-apply-on-match-key-sets.test.ts (new)",
    "packages/spec/src/api/export.zod.ts",
    "packages/spec/src/api/export.test.ts",
    "packages/spec/api-surface/api.json (generated)",
    "packages/spec/export-origins/api.json (generated)",
    "packages/spec/authorable-surface/api.json (generated)",
    "packages/spec/authorable-defaults/api.json (generated)"
    ],
    "commits": "f21019ef (#25578 key sets) then fb327b1 (#25577 applyOn, builds on the matched key set; carries the changeset) then b89b3b2 merge origin/main (os-regen-merge.sh; check:generated --fix after it: 14/14 up to date, no regen commit owed)",
    "tests": "rest import-apply-on-match-key-sets.test.ts 16/16 (real POST /data/:object/import on ObjectQL+sqlite; each refusal asserts status 400 + code INVALID_REQUEST, each row error asserts ok/action/code BLANK_MATCH_KEY or AMBIGUOUS_MATCH). spec export.test.ts 50/50. Package suites on fb327b1: @objectstack/spec 715 files/21721 passed; @objectstack/core 118/2757 passed; @objectstack/rest 321 files/6416 passed, 326 skipped. The merge brought only an approval spec change into these packages, rebuilt and regenerated. Typecheck exit 0: spec, core, rest, and downstream matchFields/runImport consumers client, service-automation, plugin-auth. Dogfood import-reference-exposure + import-user-field-target: 2 files/16 passed. Ablation through core dist (ablation-replace + dist-preflight marker present in 2 files, restore blob == HEAD 8b20cb7e, rebuild, marker --absent ok). applyOn leg: 3 red/13 green. Its first attempt was a no-op: the mutation left keySet unused, core build exit 90 before any test; rerun with a keySet-reading mutation. Key-set leg: 1 red/15 green. After restore 16/16.",
    "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack on b89b3b2: 116 derived, all run, exit captured before any pipe. --ran: 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN. Three first refused a prerequisite (exit 3) and were rerun green once it was met: check-plugin-teardown-shape --self-test (shallow clone; fetched pinned commit 621a487), spec check:skill-examples (built client-react), check:dual-build-cjs-loads (built dogfood closure). check:dev-prereqs exit 0. test:repo not run locally, declared to CI. PR check-runs on b89b3b2 at report time: 'No other open PR may claim the same issue' success; the rest in_progress (Type Check x4, Lint x9, Check Changeset, Governed Surface Queue Guard, Spec property liveness, etc.).",
    "dogfood_battery": "n/a — no ! in the PR title and Clause-② carries the (widening) arm; targeted dogfood import tests 16/16 listed under tests",
    "mcp_calls": "0 — no MCP GitHub tool used; reads went through gh api REST",
    "api_writes": "3 relay strokes via scripts/pm (fleet-write repository_dispatch, as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /issues/25664/assignees [sunhaolin], read back byte-identical (8723 bytes); (2) this report comment POST /issues/25577/comments; (3) pointer comment POST /issues/25578/comments. Plus 2 git pushes (not REST).",
    "deviations": [
    "The diff touches packages/core/src/utils/import-runner.ts, which the claim's file surface did not name: it named 'the import runner, packages/rest/src/import-prepare.ts and its neighbours'. The runner moved to packages/core in #20919, and both import doors call runImport from @objectstack/core. Implementing either ruling requires that file. The hunks are limited to the two keys (+~60 lines). The claim says 'stop on breach; explain in the report'. I read the claim as naming the runner by function, with the dispatch's 'disproving an assumption is a good result', so I did not stop. Seat: confirm or amend the claim's file surface.",
    "Commit trailers use AGENTS.md's model-free pair (Co-authored-by: Claude, Claude-Session) instead of the harness's model-named Co-Authored-By. AGENTS.md takes precedence, and the pre-push hook refuses a model identifier.",
    "Commit order is #25578 first, then #25577: applyOn's update patch keeps the matched key set. Still one commit per card.",
    "The worktree was created from this cloud session's platform-assigned local branch (the primary checkout was detached for it). The base is origin/main ca80f05 rather than the dispatch's abdc7f2; main had moved. It was removed after the PR opened, and the primary checkout is back on the branch at b89b3b2."
    ],
    "open_questions": [
    {
    "question": "skipBlankMatchKey together with ordered key sets: honour, ignore, or refuse? (decided, reported per the dispatch)",
    "options": [
    "A refuse with 400 INVALID_REQUEST at the door and a spec refinement (taken)",
    "B honour it: skip rows blank on every set as BLANK_MATCH_KEY",
    "C ignore it silently"
    ],
    "recommendation": "A, taken. B is the 「静默跳过」 the ruling retires. C accepts a flag the runtime would not keep. Nothing accepted before is refused: string[][] never parsed and was emptied by the old parser."
    },
    {
    "question": "Does the row error for a row blank on every set apply to the single-set spelling too?",
    "options": [
    "A only to the key-set spelling string[][], including a one-set [['x']] (taken)",
    "B also to string[]"
    ],
    "recommendation": "A, taken. B would change what the existing spelling means (blank upsert creates, update skips), against 「单组写法照旧有效」 and the claim's widening declaration."
    },
    {
    "question": "Error code for the blank-on-every-set row",
    "options": [
    "A reuse BLANK_MATCH_KEY with ok:false, action:'failed' (taken)",
    "B a new code, e.g. NO_MATCH_KEY"
    ],
    "recommendation": "A, taken. No new ledger code; consumers already tell a skip from a failure by ok/action."
    },
    {
    "question": "applyOn:'create' on a match-key column",
    "options": [
    "A read for matching on every row; on update, the fields of the set the row matched by stay in the patch (taken)",
    "B strip it from the patch too",
    "C refuse applyOn:'create' on a matchFields column"
    ],
    "recommendation": "A, taken. The value equals the one the row was found by, so the patch never empties because of applyOn. C is wrong with key sets, where a column can be the key for some rows and a plain create-only column for others."
    },
    {
    "question": "Door strictness for malformed key sets",
    "options": [
    "A refuse an empty set or name, a non-string name, and names mixed with sets with 400 INVALID_REQUEST; one key set is still read with the old lenient filter (taken)",
    "B filter silently as the single-set path does"
    ],
    "recommendation": "A, taken. A dropped set or name would silently change which record a row matches. The single-set leniency is unchanged, so nothing the old door accepted with meaning is refused. ['a',['b']] was off-spec and silently dropped ['b']."
    }
    ],
    "out_of_scope_findings": [
    "carrier: none, noted, not filed (Acceptance notes only): ExportImportTemplateSchema.mappings reuses FieldMappingEntry, so a template entry can now carry applyOn. No runtime reads export/import templates (git grep ExportImportTemplate outside packages/spec: 0 hits at b89b3b2), the same as its existing transform/required keys. Zero consumer, no reach.",
    "carrier: none, noted, not filed: the named mapping artifact (mappingName, upsertKey: string[]) gets neither key. Neither ruling names it; a request's matchFields key sets still override upsertKey."
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim addendum 2 (amends the file surface of 6384039082, as corrected by 6384054732) · domain:spec seat 2 · 2026-10-24T14:43Z

    PR #25664 declares the surface that followed. Added: packages/core/src/utils/import-runner.ts, the import runner itself. It moved out of packages/rest into @objectstack/core in #20919, and both import doors call runImport from there. The claim named the runner by function and placed it in packages/rest, which was wrong. The hunks are limited to the two keys. Also added: the new door pin packages/rest/src/import-apply-on-match-key-sets.test.ts, the hand-written content/docs/data-modeling/import-mappings.mdx, and the generated api family. packages/lint has no import-mapping rule, so it is untouched. Not governed (check-governed-merges --pr 25664).


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #25664 comment 6385545321 · domain:spec seat 2 · 2026-10-24T14:44Z · #25577 and #25578 land together (one fold, Fixes both). Waits for the CONTRACT_REVIEW_TIER review and green checks.

  7. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:spec seat 2 (seat post #18549) · sunhaolin · session session_01PC4NPXPgPmpDRG9DdDnyXT · 2026-10-24T17:18Z

    reading value
    PR #25664 (#25577 + #25578, folded) MERGED through the queue at 2026-10-24T17:17:52Z; merge commit b77bb37eb2. ACCEPT 6385545321; contract review PASS 6386765368 on head b89b3b242a (it judged the ['a', ['b']] 400 as owing no declaration: the protocol never accepted that shape)
    queue branch gh-readonly-queue/main/pr-25664-* gone (git ls-remote: 0 refs)
    origin/main carries b77bb37eb2; packages/spec/src/api/export.zod.ts holds isMatchKeySets (2 hits)

    State: both cards closed completed on the merge (one PR, a Fixes line each). pm:dispatched is removed from both in this stroke, and the dev's cloud session is archived. The two out-of-scope notes, ExportImportTemplateSchema.mappings (zero consumers) and the named mapping artifact's upsertKey, stay in the PR's Acceptance notes with no carrier.


    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, searchdomain:specenhancementNew feature or requestpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions