Repository navigation
feat(service-print): print a record on any print page of its object, not only its default record page - #26662
Conversation
…on the console's named record-page route A record prints on any record page of its object that declares `print`. The default record page keeps the console's record route; any other print page renders on `.../record/ID/page/PAGE` (RECORD_NAMED_PAGE_ROUTE). The page check reads a record page the way the console's `isRecordPageOf` does (type 'record' and that object), so the server prints exactly the pages the named route draws. Docs and a changeset follow. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5kGuMFreHR7gCGkpxL
…t record page Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5kGuMFreHR7gCGkpxL
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 220dfce3d4eccba6ffb43686df31eb3177432c9a && git checkout 220dfce3d4eccba6ffb43686df31eb3177432c9a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 079deea822a0bba4d7cfff58acb55eaaad58add0 7c5ee8b24eb602bec66f40b3c0113734c95b7cba && git checkout -B drift-repro 079deea822a0bba4d7cfff58acb55eaaad58add0 && git merge --no-ff 7c5ee8b24eb602bec66f40b3c0113734c95b7cba
node scripts/docs-audit/affected-docs.mjs --json 079deea822a0bba4d7cfff58acb55eaaad58add0
|
…not because only the default prints Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5kGuMFreHR7gCGkpxL
Contract reviewServed-tier: PR #26662 (card #26583, ruling 6439306670 letter A, claim 6440220432, dev report 6441342414), reviewed on the declaration face at 2026-10-27T12:12Z. Inputs: the card body and its five comments, the PR body and file list, Scope (TIER_DEFAULT, whole diff): Gate verdicts on this head, as read at 2026-10-27T12:12Z (47 check-runs): 21 ① Derived judgmentsEvery accept-set and public-surface change the diff implies, read in the cited code:
② Semver level
Clause-②: yes (widening) — measured on the diff; the raw-body shape in ③ flag 3 is the seat's to re-arm. ③ Boundary flags
Seat questions, one line each:
Implemented-by: VERDICT: PASS |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #26583 under ruling 6439306670 letter A. 8 files: print-service.ts and its unit test, two dogfood tests and a fixture, print-pages.mdx, a patch changeset for @objectstack/service-print, and one docblock in examples/app-showcase/src/ui/pages/invoice-document.page.ts. Clause-②: yes (widening) and Metadata-protocol: none appear in both the body and the changeset. No key is added, so the card reads p2, as the PR body says. |
| Fix | refuseUnlessPrintPage keeps the 404 for an unknown page and two 400s: not a record page of this object, and no print. The default page renders the plain record route; any other print page renders …/apps/APP/OBJECT/record/ID/page/PAGE (RECORD_NAMED_PAGE_ROUTE), each segment encoded. The server's isRecordPageOf is the console's own predicate (type === 'record' && object === OBJECT) over the same registry, so the server prints exactly the pages the named route draws. |
| Head delta | 7c5ee8b24e adds only the showcase docblock fix (4+/3−) on top of the reviewed 25f758a5e. The seat widened the claim surface by that comment, at the dev's own out-of-scope note: the old text said the default page is the only page a record prints on, which this PR makes untrue. The contract review does not depend on it. |
| Clause-② boundary (seat call) | The review escalated one shape: a page body with no type, marked isDefault: true and declaring print, can reach the registry through a raw code path (a code-built manifest, registry.registerItem, the metadata facade) though every authoring door fills type's schema default. main answered 201 for it but rendered the plain record route, where the console filters with the same isRecordPageOf and draws the synthesized default page, so that PDF was not the requested page. The head answers 400. The seat keeps yes (widening): the declared page contract always carries type (PageSchema defaults it), the refused body is outside that contract, and what it refuses was a misprint, not a print. |
| Evidence | CI green on head 7c5ee8b24e (37 success, 10 skipped, 0 failed). Governed check: NOT governed, 362 changed lines. Red on main, green at head: 3 unit rows and 3 dogfood legs, including a real Chromium render. 89 derived gates: 88 exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, which CI runs). The cancelled lanes on 25f758a5e were the concurrency cancel from the docblock push, not failures. |
| Out of scope | Every server print fails under the console's default CSP (the driver waits with a string predicate that needs 'unsafe-eval'). This is not this PR's: it fails the same on main's untouched invoice leg. Filed as #26664. hotcrm #2312 needs both. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #26583
Clause-②: yes (widening)
Metadata-protocol: none
No authorable key is added or changed (no
packages/spec/**in the diff; the request body already carriespage), so per triage 6440154784 this card grades p2.维护者速读
@objectstack/service-print)现在可以把记录打印在该对象的任意一个声明了print的记录页上,不再只限默认记录页。默认记录页照旧走记录路由;其它打印页走控制台已发布的具名记录页路由…/record/ID/page/PAGE。print仍然 400 拒绝,未知页名仍然 404。回滚即 revert 本 PR。packages/services/service-print/src/print-service.ts(refuseUnlessPrintPage、新的isRecordPageOf与printPageUrl);packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts。pnpm --filter @objectstack/service-print exec vitest run src/print-service.test.ts,预期 50/50 通过,其中「not the default record page prints on the console’s named record-page route」一条。What changes
PrintService.printRecordToFile(and soPOST /api/v1/print/records), step ③ "the page":print. The third refusal onmain, "is not the default record page of OBJECT (isDefault: true)", is gone.CONSOLE/apps/APP/OBJECT/record/ID. Any other print page renders on the console's named record-page route,CONSOLE/apps/APP/OBJECT/record/ID/page/PAGE(RECORD_NAMED_PAGE_ROUTE,ui/packages/app-shell/src/console/AppContent.tsx:280), every segment encoded on its own (printPageUrl).VALIDATION_ERROR/ 400, before anything is read, minted or launched: the page is not a record page of the object, or it declares noprint. An unknown page name staysRESOURCE_NOT_FOUND/ 404.The predicate both sides use. The console's named route renders the named page only when
isRecordPageOf(named, objectName)holds (ui/packages/app-shell/src/views/RecordDetailView.tsx:787–792), and that function isp.type === 'record' && p.object === objectName(ui/packages/react/src/hooks/usePageAssignment.ts:94–98); any other name gets a not-found state, never a fallback. The server's check is now that exact reading (isRecordPageOfinprint-service.ts, docblock names the console's), so the server prints exactly the set the console draws, no wider. It is a local copy becauseservice-printcannot import@object-ui/react, and adding a shared reading to@objectstack/specis outside this lane.content/docs/ui/print-pages.mdx: the "A print page that is not the default is refused" sentence is rewritten to say a record prints on any print page of its object (and on which route), the example page dropsisDefault: true, and the refusals table row reads "not a record page of the object (type: 'record', thatobject), or declares noprint". The repo carries no translation of this page (find content -name 'print-pages*'→ the en file only).Changeset:
@objectstack/service-printpatch, both declaration lines line-initial.Why
To print a quote, an app had to make the quote's everyday record page its printed document, and reps lost the status path, highlights, Details/Related tabs and the activity feed on every quote (objectstack-ai/hotcrm#2312, REQ-0008). The console's named record-page route (objectui#12715) already draws a named record page for a record and refuses any other name; only the server half refused. Ruling 6439306670, letter A.
Tests
Red first on
main(c739de755c), then green at head:mainservice-printsrc/print-service.test.ts(tests only changed)print-service-named-page.dogfood.test.ts+print-service-archive.dogfood.test.ts(stand-in browser, real showcase boot)print-service-render.dogfood.test.ts(real console bundle + real Chromium, both placements,OS_PAGE_REACT_ENABLED=on, see Acceptance notes)The
mainlegs ran withprint-service.tsrestored fromc739de755c(the dogfood imports the service fromsrc/), restored afterwards bygit checkout HEAD --and proved bygit diff HEADempty and the blob hash matching HEAD.Unit rows (
print-service.test.ts):'a print page that is not the default record page'moved from the refusal table to an accept: it renders…/record/inv_1/page/invoice_documentand archivesinvoice_document-inv_1.pdf./and a record id carrying a space are encoded as one segment each.print(default and non-default), a defaulttype: 'home'page, a non-defaulttype: 'app'print page, and a page with notype(default and non-default).Dogfood (new
print-service-named-page.dogfood.test.ts, fixture inprint-named-page.fixture.ts):showcase_projectkeeps its working default record pageshowcase_project_detail(slotted, noprint); a project brief that declaresprintis saved beside it through the metadata save door (protocol.saveMetaItem). A project prints on the brief through the named route and is archived on the project as the requester; the working page (noprint) and the brief asked for an invoice (another object) are refused 400 with nothing minted.What the real render put on paper. A one-off copy of the render leg wrote both PDFs out;
pdftotext -layout:Project brief/Brief/PROJECT NAME STATUS/Compliance Audit On Hold/START DATE END DATE/Oct 12 Nov 26/1/1, so the console drew the brief for that project, not a not-found state and not the default page;ObjectStack Showcase Ltd · 示例有限公司/Invoice/INV-RENDER-local Draft…, unchanged.Other local runs at head:
pnpm --filter @objectstack/service-print test8 files / 114 tests passed;pnpm --filter @objectstack/service-print typecheckexit 0 (itsincludeissrc, so the test files are compiled);pnpm --filter @objectstack/dogfood typecheckexit 0; eslint--no-inline-configon the touched service-print and dogfood files exit 0.Acceptance notes
Clause-②measured on the diff. The accept set widens: a request naming a non-default print page, refused 400 onmain, now prints. One shape moves the other way, stated rather than hidden: a page body in the registry with notypeat all that is the default record page and declaresprintwas accepted onmain(the old check readdefaultRecordPageObject, which treats an absenttypeas'record') and is now refused 400, because the console'sisRecordPageOfreadstypeexactly. Onmainsuch a print did not print that page: the console's record route filters with the sameisRecordPageOf, so it drew the synthesized default page instead. No authoring door produces that body (defineStackparses withPageSchema's declared default, and the metadata save door and stored-row hydration fill it, metadata: a page saved withouttypeis stored and served without it, soPageSchema's declaredtypedefault (record) never reaches a consumer that reads the served body #20101withDeclaredPageTypeDefault); only a plugin callingregistry.registerItemwith a raw body can. I declaredyes (widening); if the seat reads that one shape as a narrowing, the arm is the seat's to change.maintoo, for a reason outside this card. WithoutOS_PAGE_REACT_ENABLED, the console's policy isscript-src 'self' 'nonce-…' 'wasm-unsafe-eval'(no'unsafe-eval',packages/cli/src/utils/console.ts:851–852, off unless set), and the print driver'spage.waitForFunction(PRINT_READY_PREDICATE, …)(chromium-driver.ts:303) passes a string, which Chromium refuses to evaluate: all 6 legs failed, including the untouched invoice leg, withThe print engine failed at the local executable …: page.waitForFunction: EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive …. CI skips this file (no console bundle), somainhas not seen it. Reported to the seat as a defect for its own card; the readings above were taken withOS_PAGE_REACT_ENABLED=on.examples/app-showcase/src/ui/pages/invoice-document.page.ts:12–14still says the invoice's print page is its default record page "because the console renders a record on its default record page, so that is the page a record prints on". That reason is no longer the only option after this PR (the page may stay the default by choice). It is a comment in a private package, outside the claim's file surface: left for the seat.Verification
Head
25f758a5e. Local subset derived bynode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon this diff (89 commands), each run with its exit code captured before any pipe, reconciled with--ran:check-plugin-teardown-shape.mjs --self-test(positive control621a4876fetched into the shallow clone; then48 cases pass), andspec check:skill-examples(after building@objectstack/client-react; then266 prose examples type-check).pnpm check:dual-build-cjs-loads, reason: it needs every package'sdist(no packages/plugins/knowledge-memory/dist…) and this container built only the dogfood closure. CI runs it.pnpm lintrepo-wide (eslint on the touched files: exit 0),test:repo(the diff touches novitest.repo-tests.jsonproject), the full dogfood battery (no!and no(narrowing)arm).Every local command and its exit code
Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintis CI's).main, one pull request.packages/spectouched: n/a, not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code