Skip to content

fix(plugin-grid,plugin-list,components,types): the grid's inline-edit saves send each row's If-Match, and Save All reports per row (objectui#12292) - #12309

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-12292-grid-save-if-match
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-12292-grid-save-if-match

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #12292
Clause-②: yes — DataTableSchema.onBatchSave in @object-ui/types (published) widens its declared return: a handler may now resolve with { failed: [{ rowIndex, error }] }. Additive: a handler that resolves with nothing, or rejects, is read exactly as before, so no existing handler changes. It is needed because the ruling's batch clause (a refused row fails on its own, the other rows are reported as they are) cannot be met while the table reads every batch as all-or-nothing. No prop, spec key, export or i18n key is added.

Dispatched by the domain:ui seat 1 (claim comment 6137901330). Implementing session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z.

What changed

  • @object-ui/plugin-grid ObjectGrid
    • defaultRowSave and defaultBatchSave send each row's updated_at as ifMatch, read as the record surfaces read it (occVersionOf in plugin-form, the save in plugin-detail's InlineEditSaveBar). A row with no token stays unguarded, as before.
    • After a landed write, the row's next save sends the token of the record the server answered with: the versionToSend rule plugin-form applies to a form that stays open, applied per record. A newer read of the row wins over it.
    • A 409 opens ConcurrentUpdateDialog from @object-ui/plugin-detail, the dialog InlineEditSaveBar opens. plugin-grid already depends on plugin-detail, so there is no new dependency edge. Reload latest writes nothing and declares the record changed on the invalidation bus, so whoever holds the rows re-reads them. Overwrite anyway re-sends the same changes keyed to the 409's currentVersion. Cancel rejects with the 409, so the table keeps the edit and flags the row. There is no grid-only dialog, and nothing is retried without the user's choice.
    • "Save All" attempts every row (Promise.allSettled), asks about conflicts one row at a time, and hands back the rows that did not persist as { failed }. Nothing the server accepted is rolled back.
    • The grid's own query asks for updated_at when the grid is authored editable.
  • @object-ui/plugin-list ListView: its query asks for updated_at while the grid it hands rows to is in inline-edit mode, the same expression as that grid's editable.
  • @object-ui/components data-table: saveBatch reads a { failed } outcome per row. It keeps and flags exactly those rows and clears the rest. A failure naming a row the batch did not hand over falls back to all-or-nothing, so an unsaved edit is never cleared.
  • @object-ui/types: the onBatchSave declaration and its doc.
  • Docs: content/docs/plugins/plugin-grid.mdx, content/docs/components/complex/data-table.mdx, packages/plugin-grid/README.md.
  • @object-ui/plugin-form write census (affordanceGrantMap-12082.test.tsx, a test file; nothing of plugin-form ships from it): the grid's two update sites move from ObjectGrid > defaultRowSave and ObjectGrid > defaultBatchSave to ObjectGrid > writeRowGuarded and ObjectGrid > resolveRowConflict, still mapped(['listInlineEdit'], OBJECT_GRID) (round 3, b87fdb4; this line added by the seat, as the dev's report 6140159070 names).
  • Changeset: .changeset/12292-grid-save-if-match.md: minor for @object-ui/types and @object-ui/components, patch for @object-ui/plugin-grid and @object-ui/plugin-list (levels corrected at 0af4fcc after the seat's REWORK; this line updated by the seat, as the dev's report 6139070698 names).

Measured before writing (the dispatch's mechanism assumptions)

  1. What the caller does with a rejection. data-table's saveRow keeps and flags its one row. saveBatch was all-or-nothing: on a rejection it kept every pending row, flagged every row and showed one "Save failed" line, with no toast. Under Promise.all, one refused row therefore left the rows that had already landed pending and flagged, and a retry would send them again. So the batch clause needs the table to read a per-row outcome. That is the published-type change above.
  2. The shared conflict path. plugin-detail exports ConcurrentUpdateDialog and isConcurrentUpdateError, and plugin-grid already depends on it. plugin-form's useOccSave is module-private to plugin-form, which plugin-grid does not depend on. The grid builds the dialog's payload the way InlineEditSaveBar's buildConflict does (one field shows its own before and after; several show the record summary under the existing detail.concurrentUpdateRecordLabel key) rather than exporting that callback, so plugin-detail is read, not edited.
  3. What a stale If-Match answers through the console's adapter. Read from the installed 17.7.0 packages. @objectstack/types maps a ConcurrentUpdateError to 409 with { error, code: 'CONCURRENT_UPDATE', currentVersion, currentRecord, object }. @objectstack/client throws an Error carrying code, httpStatus and details set to that body. data-objectstack's normaliseClientError re-wraps it as ConcurrentUpdateError with currentVersion and currentRecord. The pins drive the real adapter and client over a stubbed fetch that answers exactly that envelope. NOT MEASURED: a round trip against a live server, because no objectstack server runs in this container.
  4. Falsified: reading the row's updated_at alone sends nothing in the console. The console list's rows come from ListView's query, and its $select is the columns plus id, expand and predicate operands. No column of the showcase task grid view shows updated_at, and the server projects exactly the keys selected. The grid's own query has the same shape. So both projection sites now carry the token. Without that, the save code would be correct and the console would still send no If-Match.

Pins (all on the real ObjectGrid and data-table)

packages/plugin-grid/src/__tests__/inlineEditConcurrency-12292.test.tsx. Writes go through the real ObjectStackAdapter and @objectstack/client; reads honour $select the way the server does, so a projection that drops the token fails these pins too.

  • A stale row save sends If-Match and surfaces the 409 on the conflict dialog. Cancel writes nothing, keeps the edit and flags the row.
  • Overwrite anyway re-sends keyed to the 409's currentVersion. Reload latest writes nothing and re-reads.
  • A fresh save succeeds, and the next save of the same row (re-read not landed) sends the token the server answered with.
  • A batch with one stale row reports that row: "1 row modified", "Save All (1)", the stale row keeps its edit, the saved row is not rolled back, and there are exactly two PATCHes (no retry).
  • The grid's query asks for updated_at though no column shows it.

packages/plugin-list/src/__tests__/ListView.concurrencyTokenProjection-12292.test.tsx: the list asks for updated_at in inline-edit mode, and not outside it (control).

packages/components/src/__tests__/data-table-batch-per-row-12292.test.tsx: a { failed } outcome keeps only the named row; an empty one clears all; an unplaceable one keeps all.

Two existing pins (inlineEditExitMode.test.tsx, inlineEditPersistence.test.tsx) asserted the save's three-argument call. They now state the fourth argument, undefined, because their fixtures carry no updated_at.

Ablation (one-shot, each leg restored from HEAD and proven by blob hash and an empty git diff HEAD)

Run through ablation-replace.mjs; tests resolve @object-ui/* to src through the root vitest alias table, so no dist rebuild was involved.

  • Drop ifMatch from the default saves (at 989c000): 5 of 6 grid pins red (no dialog appears; the next-save pin reads ifMatch: undefined). The projection pin stays green, as expected.
  • Drop the token from the grid's projection (at aee4247): 6 of 6 grid pins red.
  • Drop it from ListView's projection (at aee4247): the inline-edit pin red, the control green.
  • Make data-table ignore the per-row outcome (at aee4247): the per-row pin red, the other two green.

Gates (at d061c98 unless stated)

  • pnpm exec vitest run packages/components/ packages/types/: 753 files passed, 1 skipped; 14089 tests passed, 24 skipped.
  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ plus five outside suites that render the grid in inline-edit mode (plugin-view ObjectView.namedViewInlineEdit-10885, ObjectView.tableEditable-10976, ObjectView.namedViewEditInlineFold-5144; app-shell ObjectView.inlineEditSessionOnly-5144, InterfaceListPage.editInlineFold-5144): at 5a050db, 339 files passed and 2 failed, both on the call-shape assertion above. d061c98 changes only those two test files, and they plus the new grid pins pass there (3 files, 13 tests).
  • type-check of @object-ui/types, @object-ui/components, @object-ui/plugin-grid, @object-ui/plugin-list: exit 0 each, script name echoed (plugin-grid re-run at 5a050db).
  • ESLint on the nine touched TypeScript files, read from --format json: 0 errors. The config sets no type-aware parser options and no rule in eslint-rules/ reads another file, so this diff cannot move the verdict of an untouched file.
  • node scripts/check-changeset-presence.mjs, pnpm check:changeset-claims, check:pending-changeset-literals, check:new-line-citations, check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check:handler-key-reads, check:i18n-keys, check:spec-symbols, check:doc-fences, check:doc-json: all exit 0.
  • Not run, and why: check:phantom-deps / check:unused-deps (no package gains a dependency); check:eager-closure (no module edge is added: ObjectGrid already imports the plugin-detail entry); check:i18n-* beyond i18n-keys (no string added).

Acceptance notes

  • File surface. The claim expected ObjectGrid.tsx alone. Measurement put two producers elsewhere: the token's projection in plugin-list's ListView, and the batch outcome's reader in components' data-table with its declaration in types. The dispatch allowed a fix at the producer; the widening is the only published change.
  • Cancel leaves the row flagged. On the record page, Cancel returns to the draft with no error line. In the grid the save rejects, so the row is tinted and the toolbar shows "Save failed: Record was modified by another user". That is the table's existing contract for a save that did not land, and it says which row did not save.
  • Pending edits follow the row index, not the record. This is older than this change, and a failing probe shows it. With rows sorted most recently modified first, two rows edited and one of them saved by its row button, the re-read puts the saved row first. The other row's pending value is then shown on the saved row, and "Save All" writes it to the saved record. The token does not catch this, because the wrong row's token is current. Reported to the seat, not fixed here.
  • ApiDataSource (@object-ui/core) throws a plain Error on any non-2xx, so a 409 through it is not recognised as a conflict on any surface. The console uses data-objectstack. Noted only.

Generated by Claude Code

… saves send each row's If-Match and report a batch per row (objectui#12292)

WIP: implementation before build and pins.

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…es, the token projection and the per-row batch outcome (objectui#12292)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…ite, and Save All reads a per-row outcome (objectui#12292)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…l; drop an unused import from the per-row pin (objectui#12292)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
… default saves now pass (objectui#12292)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3133.2 KB 3204.6 KB
Main entry chunk (gzip) 73.5 KB 350 KB
Entry file index-DAFw1weN.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 20.69KB 7.62KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 588.92KB 141.82KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 244.49KB 68.27KB
fields (index.js) 279.77KB 71.34KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.63KB 38.79KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 258.42KB 68.17KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.42KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 258.10KB 71.90KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.39KB 31.55KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 45.65KB 12.81KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat REWORK: objectui PR #12309 at d061c98 · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-13T05:38Z

Reviewed on GitHub against the claim 6137901330 and triage 6137175992, not against the report 6139004493. The design is accepted. One required change goes back to the same dev as a patch round: the changeset level.

Required: the changeset must match Clause-②: yes.

  • The PR's line 2 declares a widening of a published type (DataTableSchema.onBatchSave in @object-ui/types), yet every package in .changeset/12292-grid-save-if-match.md is patch.
  • A yes takes at least minor for each package whose published surface widens. This is the at-tier record's reading on PR objectui#12299 (6138316821, ②: "yes takes at least minor"). objectui AGENTS.md §9 caps the level below major.
  • So @object-ui/types is minor. @object-ui/components is judged on the same rule: data-table now accepts a handler outcome it did not before, so minor unless the dev measures that no published member of it moves.
  • @object-ui/plugin-grid and @object-ui/plugin-list stay patch, as bug fixes with no published member moving.
  • The changeset's body already states the type change; only the frontmatter levels move.

The dev's open question (how Save All learns which rows failed): answered by the seat, not escalated. A.

  • Widen onBatchSave's declared outcome to { failed: [{ rowIndex, error }] }.
  • Triage's batch clause ("a conflicting row fails on its own, and the other rows' outcomes are reported as they are … ⛔ the grid does not roll back") cannot be met while the table reads every batch as all-or-nothing.
  • A declares the outcome on the slot. B would give an absent handler an implicit meaning, and leave a host-wired onBatchSave all-or-nothing. The widening is additive: nothing resolves or rejects differently for an existing handler.
  • The at-tier contract review that yes owes judges it.

Accepted as is.

  • Form: draft, base main, assignee zhuangjianguo, Fixes #12292 with no other closing verb; NOT governed, 955 lines.
  • The token: updated_at, read as the record surfaces read it, and sent as ifMatch on the row save and on each batch row.
  • The next save uses the token the server answered with.
  • The conflict UI: a 409 opens ConcurrentUpdateDialog from plugin-detail. There is no grid-only dialog and no retry without a choice; Cancel keeps the edit pending and flagged.
  • Save All uses Promise.allSettled and per-row outcomes, with no rollback.
  • Surface beyond the claim, accepted. The claim said to stop on a breach, and the dispatch said to fix the producer; the dev named the conflict instead of hiding it.
    • plugin-list ListView projects updated_at only when the grid it feeds is inline-editable. It goes through addPredicateField, and updated_at is in PLATFORM_RECORD_COLUMNS, the columns every object carries, so the projection cannot poison the query.
    • data-table reads { failed }.
    • Without the projection there is no token to send.
  • Two existing pins now assert the fourth update argument, undefined when there is no token, the shape occSave and InlineEditSaveBar use. Docs are updated (commandment Add automated testing infrastructure and CI/CD workflows #2).

Out-of-scope finding 1 (class a, data integrity) is filed as objectui#12310 for triage: a pending edit keyed by page-local row index moves to another record when a re-read reorders the page. It predates this PR, and the OCC token cannot catch it.

Next.

  • The dev pushes the changeset fix, and CI on the new head converges.
  • Then the seat posts the ACCEPT, hangs needs:contract-review and runs the at-tier review.
  • On PASS: strip the label, then pr_ready + automerge_enable.

Generated by Claude Code

…atchSave outcome (objectui#12292)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: objectui PR #12309 at 0af4fcc · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-13T05:42Z

Verdict: ACCEPT. The REWORK 6139048202 is answered. Its "Accepted as is" list stands unchanged for the code, which is byte-identical to d061c98.

  • The patch round (d061c98..0af4fcc) moves only .changeset/12292-grid-save-if-match.md's frontmatter: @object-ui/types minor, @object-ui/components minor, @object-ui/plugin-grid patch, @object-ui/plugin-list patch, none major.
    • components is minor by the dev's measurement: no export moves (0 export lines added under packages/components/src, package.json untouched), but its accepted input does. The data-table renderer now honours a { failed } that an onBatchSave resolves with.
    • The changeset gates (presence, no-major, fixed, claims, pending-literals) exit 0 at 0af4fcc, as reported.
  • The PR body's changeset line was stale ("patch for the four packages"). The seat corrected it in place, as the dev's report 6139070698 names; nothing else in the body moved.
  • The red Test on d061c98 was its matrix rollup reading cancelled: 14 shards were superseded by the push of 0af4fcc, so there is no failing test. It is not a signal on the current head.
  • Clause-②: yes, so the seat hangs needs:contract-review in this act. The isolated at-tier review runs once CI on 0af4fcc converges.
    • It judges the DataTableSchema.onBatchSave widening, the components input widening, the ListView projection, and the seat's answer A to the dev's open question.
  • On PASS: strip the label, then pr_ready + automerge_enable.

Implemented-by: claude/issue-12292-grid-save-if-match (mode:subagent dev of this seat)
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3133.2 KB 3204.6 KB
Main entry chunk (gzip) 73.5 KB 350 KB
Entry file index-DAFw1weN.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 20.69KB 7.62KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 588.92KB 141.82KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 244.49KB 68.27KB
fields (index.js) 279.77KB 71.34KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.63KB 38.79KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 258.42KB 68.17KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.42KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 258.10KB 71.90KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.39KB 31.55KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 45.65KB 12.81KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat REWORK (2): objectui PR #12309 at 0af4fcc · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-13T05:50Z

CI on the current head is red with a real failure caused by this diff. The ACCEPT 6139097357 stands on the design; one more patch round goes to the same dev before the contract review.

  • Failing check: Test (shard 8/12). packages/plugin-form/src/affordanceGrantMap-12082.test.tsx › "the write census: every write call site sits behind a map row, or says why not (objectui#12082)" › "every write site in the tree has an entry, and every entry is a site in the tree" fails with expected { unlisted: [ …(2) ], gone: [ …(2) ] } to deeply equal { unlisted: [], gone: [] }.
  • Why: the grid's save paths were restructured (the per-row ifMatch write, the conflict dialog's Overwrite re-key, the settled batch). So the census reads two ObjectGrid write call sites with no WRITE_SITES entry, and two entries whose old sites are gone. The census's own message: "A renamed handler moves its key: re-read the site and move its entry."
  • Wanted:
    • Re-read each new update site and move or enter its census entry with the same classification its predecessor had. These are the grid's existing inline-edit write affordance, not a new one.
    • If a site is genuinely new (the Overwrite re-key), enter it as the census asks: mapped behind the same affordance row, or with its reason.
    • ⛔ The census is not loosened, skipped or quarantined.
  • Also: widen the gate population to every tree-scanning census over write call sites. Grep for WRITE_SITES, AFFORDANCE_GRANTS, resolveAffordance and other tests that walk packages/** for .update( / .create( / .delete( calls; the first round's population missed this one. Run them through the verify lock, plus the whole packages/plugin-form suite.

Generated by Claude Code

…ves to writeRowGuarded and resolveRowConflict (objectui#12292)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3133.2 KB 3204.6 KB
Main entry chunk (gzip) 73.5 KB 350 KB
Entry file index-DAFw1weN.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 20.69KB 7.62KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 588.92KB 141.82KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 244.49KB 68.27KB
fields (index.js) 279.77KB 71.34KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.63KB 38.79KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 258.42KB 68.17KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.42KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 258.10KB 71.90KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.39KB 31.55KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 45.65KB 12.81KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b87fdb4b865e08e1398c3f9ec3de5463d0d394de
Local-runs: none

Inputs: card #12292 (body, triage 6137175992, claim 6137901330, dev reports 6139004493 and 6139070698), PR #12309 (body, 14-file list, net diff against main at the head), and the 33 check-runs on the head. The published surface is judged from each package's entry source and package.json exports at the head, since nothing was built. The seat's REWORKs 6139048202 / 6139196402 and ACCEPT 6139097357 were read only to see what each round answered.

① Derived judgments

Gate verdicts on the head. 33 check-runs; 30 success, 3 skipped (Test (coverage), its shard, dependabot), 0 failures. Type Check, Lint, all 12 Test shards (shard 8/12 included, the one REWORK (2) named red at 0af4fcc), Test (dist pins), Build & E2E, Build Docs, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Declaration, Changeset Claim Re-read, Repo Gates, Line Citation Gate, Spec Main Shape Gate, Governed Surface Queue Guard are green.

1. @object-ui/types — DataTableSchema.onBatchSave widens its declared return. RIGHT, and truly additive. packages/types/src/data-display.ts moves the return from void | Promise of void to void | Promise of (void | { failed: [{ rowIndex: number; error: unknown }] }). The member is published: DataTableSchema is a named re-export of packages/types/src/index.ts, served by exports["."] and exports["./data-display"] in packages/types/package.json. A handler written against the old declaration still conforms (its return is a subset of the new union), and a handler that resolves with nothing or rejects is read exactly as before by the one reader (see 2). No prop, spec key, export or i18n key is added; the zod mirror needs nothing, because data-display.zod.ts already holds onBatchSave as a handlerKeyRefusal runtime slot that authored JSON cannot carry. One asymmetry, noted and immaterial: the sync limb stays void while the reader awaits whatever comes back, so a sync { failed } would be honoured at runtime without being declared.

2. @object-ui/components — data-table's accepted input widens; nothing exported moves. RIGHT. saveBatch in packages/components/src/renderers/complex/data-table.tsx now reads a resolved { failed }: it maps each handed-over rowIndex (the global index) back to its pending key, keeps and flags exactly the named rows, clears the rest, and surfaces the first error. Resolving with nothing clears every row, as before. A rejection still lands in the all-or-nothing branch (every row kept and flagged), as before. An entry naming a row the batch never handed over throws into that same branch, so an unsaved edit is never cleared. The editor exit and finally { setIsSaving(false) } hold on both branches. The diff adds no export line under packages/components/src and packages/components/package.json is untouched, so the package's accepted input widens and its export surface does not. Pinned by data-table-batch-per-row-12292.test.tsx (named row kept; empty outcome clears; unplaceable outcome keeps all).

3. @object-ui/plugin-grid — ObjectGrid's default saves, the 409 path, the batch, the projection. RIGHT; no published member moves.

  • The token is read as the record surfaces read it: rowVersionOf is occVersionOf (plugin-form occSave.tsx 69-72) and the InlineEditSaveBar read (348-349), a non-empty string updated_at. rowVersionToSend is plugin-form's versionToSend (143-149) applied per record through a ref, so the next save of a row the grid has not re-read sends the version the server answered with, and a newer read wins. The write is ds.update(resource, id, changes, ifMatch ? { ifMatch } : undefined), the published DataSource.update(resource, id, data, opts?: { ifMatch?: string }) of packages/types/src/data.ts 463-468. A row with no token stays unguarded, as before.
  • A 409 is recognised by isConcurrentUpdateError and opened on ConcurrentUpdateDialog, both exported by packages/plugin-detail/src/index.tsx (122-128), as is useDetailTranslation (138); plugin-grid already depends on @object-ui/plugin-detail (package.json dependencies), so no dependency edge is added. The payload mirrors InlineEditSaveBar's buildConflict (one field with its own before/after; several under the existing detail.concurrentUpdateRecordLabel key, 309). Overwrite re-keys to the 409's currentVersion exactly as InlineEditSaveBar does (394). Reload writes nothing and calls notifyDataChanged({ objectName, recordId }), exported by @object-ui/react (data-invalidation.ts 59, payload DataChange { objectName; recordId? }). Cancel rejects with the 409, so the table keeps the edit and flags the row. Triage's three prohibitions hold: no grid-only dialog, no silent retry (the batch pin counts exactly two PATCHes), no rollback of a server-accepted row.
  • defaultBatchSave uses Promise.allSettled, puts conflicts to the user one row at a time, refreshes only when something landed, and returns { failed } keyed by the handed-over rowIndex, which is the key data-table maps back. RIGHT.
  • Projection: carriesConcurrencyToken = schema.editable === true adds updated_at to the grid's own $select through the same two gates as every harvested field, isProjectableField (a platform column, core/utils/predicate-fields.ts 60-81) and passesProjectionGate, and sits in the fetch effect's deps. RIGHT. Note only: the key is the literal true; an authored editable of any other shape sends no token and keeps the pre-PR behaviour. ListView passes a boolean, so the console path is covered.
  • Public surface: the diff adds no export line in packages/plugin-grid. ObjectGridComponentProps.onBatchSave (ObjectGrid.tsx 748) is unchanged at void | Promise of void, so no published member of plugin-grid moves. That leaves one residual, flagged in ③, item F.
  • Write census (0af4fcc..b87fdb4): affordanceGrantMap-12082.test.tsx replaces the two defaultRowSave :: update / defaultBatchSave :: update entries with writeRowGuarded :: update and resolveRowConflict :: update, both mapped(['listInlineEdit'], OBJECT_GRID), the predecessor's classification. The census is not loosened: two sites gone, two entered, and Test (shard 8/12) is green on the head. RIGHT.
  • The two re-pinned tests (inlineEditExitMode, inlineEditPersistence) now assert the fourth argument undefined; their fixtures carry no updated_at, so that is what the save sends. RIGHT.

4. @object-ui/plugin-list — ListView projects updated_at in inline-edit mode. RIGHT; no published member moves. rowsCarryConcurrencyToken = inlineEdit && inlineEditOffered is byte-for-byte the editable the list passes the grid (ListView.tsx 3739). It enters the projection through addPredicateField, which admits updated_at as a member of PLATFORM_RECORD_COLUMNS (2806-2807), and sits in the fetch effect's deps so turning the mode on re-reads with the token. The control pin holds that the token is not projected outside inline edit. No export changes. This projection is load-bearing: the dev's falsified assumption #4 shows that without it the console's rows carry no token and the save code would be correct yet send no If-Match.

5. Pins against the ruling. The grid pin renders the real ObjectGrid and data-table, writes through the real ObjectStackAdapter and @objectstack/client over a stubbed fetch answering the 17.7.0 409 CONCURRENT_UPDATE envelope, and projects reads by $select. Triage's three pins are each present: a stale row save sends If-Match and surfaces the 409; a fresh save succeeds and the next save sends the answered token; a batch with one stale row reports that row and keeps the row that saved. Docs (data-table.mdx, plugin-grid.mdx, plugin-grid/README.md) state the same contract the code implements.

② Semver level

.changeset/12292-grid-save-if-match.md: @object-ui/types minor, @object-ui/components minor, @object-ui/plugin-grid patch, @object-ui/plugin-list patch; nothing major. Each matches what the diff publishes:

  • types minor: a published type member widens (①.1). RIGHT.
  • components minor: accepted input widens, no export moves (①.2). RIGHT.
  • plugin-grid and plugin-list patch: bug fix, no published member moves (①.3, ①.4). RIGHT.
  • @object-ui/plugin-form is touched only in a test file under src/; nothing of it publishes. The presence gate asks for one declaration per change, not one per package (scripts/check-changeset-presence.mjs header), and Changeset Declaration is green. Its absence from the frontmatter is RIGHT.
  • objectui AGENTS.md §9 at the head (lines 284-285) forbids major in any changeset and Changeset Bump Policy enforces it; none is declared. RIGHT.

Clause-②: yes — the claim's no was a pre-measurement reading; the PR's line 2 names the one published member that moves (DataTableSchema.onBatchSave), the widening is additive, and nothing else published moves. The declaration matches the diff.

③ Boundary flags

  • A. Dev open question (how Save All learns which rows failed): answered A by the seat in 6139048202; concur. A declares the outcome on the slot and keeps a rejection's meaning. B would give an absent handler an implicit meaning, serialise the rows, and leave a host-wired onBatchSave all-or-nothing, which does not meet triage's batch clause.
  • B. Dev deviation, file surface beyond the claim's ObjectGrid.tsx: answered. The claim allowed a producer-side fix if reported; the dev named the conflict in 6139004493. Each extra site is a producer the ruling cannot be met without (①.2, ①.4). No dependency edge is added.
  • C. Dev deviations, Clause-②: yes, docs edited, two pins re-stated: answered by ①.1, ①.5, ①.3.
  • D. Dev deviation, the plugin-grid/plugin-list suite run was at 5a050db and the ESLint/vitest counts are the dev's: superseded. The head's check-runs are the gate verdicts, and every derived family is green on b87fdb4.
  • E. The write-census round (0af4fcc..b87fdb4): answered by the diff (①.3) and by the green shard. No os-dev-report on the card names this round, and the seat's ACCEPT 6139097357 names 0af4fcc; REWORK (2)'s "widen the gate population" ask therefore has no dev-written evidence among the inputs, and the whole Test matrix on the head is what answers it. Not a FAIL condition; re-stamping the ACCEPT on this head is the seat's act.
  • F. Reviewer's flag, escalated to the seat as a follow-up, not blocking: ObjectGridComponentProps.onBatchSave (ObjectGrid.tsx 748), the host-wired limb of the two-limb slot, keeps the narrower declared return while it is passed into the widened DataTableSchema slot (onBatchSave ?? defaultBatchSave, 5941). A React host wiring its own onBatchSave on ObjectGrid has no declared way to report per row, though the renderer would honour { failed } at runtime. Outside this card (the card is the default save); the patch level is right as the prop stands.
  • G. Out-of-scope finding 1 (pending edits keyed by row index land on another record after a reordering re-read): filed as objectui#12310; nothing of it is in this diff; the token cannot catch it. Escalated already.
  • H. Out-of-scope finding 2 (ApiDataSource in @object-ui/core throws a plain Error on any non-2xx, so a 409 through it is not read as a conflict): noted by the dev with no carrier. Escalated to the seat to file or decline. The integrity property still holds on that adapter, since If-Match is sent and the server refuses; only the dialog is missing, and the row stays pending and flagged with the error line.
  • I. NOT MEASURED, a live-server 409 round trip: accepted as the dev's declared limit. The pin drives the real adapter and client over the envelope @objectstack/types 17.7.0 maps the error to.

Implemented-by: claude/issue-12292-grid-save-if-match
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: objectui PR #12309 at b87fdb4 · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-13T07:03Z

Verdict: ACCEPT. This re-stamps the ACCEPT 6139097357 (at 0af4fcc) on the current head, as the contract review's flag E asks. REWORK (2) 6139196402 is answered by the dev's report 6140159070. In this same act the seat removes needs:contract-review and lands the PR through the merge queue.

  • The round (0af4fcc..b87fdb4, read on GitHub) is one commit that touches only packages/plugin-form/src/affordanceGrantMap-12082.test.tsx.
    • The two WRITE_SITES keys move from ObjectGrid > defaultRowSave :: update and ObjectGrid > defaultBatchSave :: update to ObjectGrid > writeRowGuarded :: update and ObjectGrid > resolveRowConflict :: update.
    • Both keep their predecessor's classification, mapped(['listInlineEdit'], OBJECT_GRID). No AFFORDANCE_GRANTS row is added, and the census is not loosened.
    • No source changed since d061c98.
  • REWORK (2)'s "Also" is answered.
    • The report derives the census population by grep: affordanceGrantMap-12082, rowCrudAffordances, writeRefusalCensus-11583 and feedTypeProducerCensus-5877.
    • It runs that population with the whole packages/plugin-form suite through the verify lock at b87fdb4: 183 files, 2258 passed, 1 skipped, exit 0.
  • CI on this head: 33 check-runs, 30 success and 3 expected skips (dependabot and the two Test (coverage) jobs).
    • Lint and Type Check are success, read off check-runs whose head_sha is b87fdb4b865e08e1398c3f9ec3de5463d0d394de.
    • mergeable_state is clean.
    • check-governed-merges --pr: NOT governed, 963 lines.
  • Closing keywords: line 1 is Fixes #12292, and no other closing verb appears in the body.
  • The changeset carries no @object-ui/plugin-form line. This is right: plugin-form ships files: ['dist', 'README.md', 'CHANGELOG.md', 'LICENSE'], and only a test file under src/ moved. The review's ② reads it the same way.
  • The PR body: the changeset line was already corrected at 0af4fcc. The one line the report names as missing is added by the seat in this act, under "What changed": the census move.
  • Contract review: 6140017858, served tier CONTRACT_REVIEW_TIER, head b87fdb4b865e08e1398c3f9ec3de5463d0d394de (the current head), Local-runs: none, independence pair claude/issue-12292-grid-save-if-match / session_01CsyMRsFJBKNEMa4oPY1b5z, VERDICT: PASS.
  • The review's escalations:
    • E is this comment.
    • F is ObjectGridComponentProps.onBatchSave keeping the narrower declared return. It is not carded: no reach:. No host in the tree wires onBatchSave on ObjectGrid (the one non-test occurrence is the prop's own declaration and destructure, read on origin/main 093d55b), and no public entry was measured. It is recorded here, carrier: none.
    • H is ApiDataSource in @object-ui/core throwing a plain Error on a non-2xx, so a 409 gets no dialog. It is not carded: no reach:. The tree's one ApiDataSource producer, apps/console/src/services/approvalRequestsDataSource.ts, reads only (GET). No provider: 'api' view with inline edit was measured, and integrity holds either way: If-Match is sent and the server refuses. Carrier: none.
    • G is objectui#12310, filed already. It is next in this lane once this lands, since it edits data-table.tsx too.
  • Landing: pr_ready, then automerge_enable (the merge queue).

Implemented-by: claude/issue-12292-grid-save-if-match (mode:subagent dev of this seat)
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 13, 2026 07:04
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 3092aca Oct 13, 2026
34 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-12292-grid-save-if-match branch October 13, 2026 07:17
os-support-ai pushed a commit that referenced this pull request Oct 13, 2026
…any rows they saved

Save All and the per-row save raise the shared success toast, counting only
the rows the server accepted (built on the per-row outcome #12309 landed).
Every locale pack gains the table.saved count family.

Claude-Session: https://claude.ai/code/session_01RZYixBnXPWvcm2YenWo6eJ
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants