From e1809a0d8256e02b8ee517ba8d03573fbd2efadd Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 19:52:52 +0200 Subject: [PATCH 1/4] Reject complex in-memory sorts and omit their proxy helpers (#174) --- .../TypeScript/ProxyComparison/All.ts | 8 +- .../TypeScript/ProxyComparison/Observe.ts | 8 +- .../ProxyComparison/differences.json | 8 +- ContractTests/ProxyComparison/raw.diff | 80 ++++++++----------- ContractTests/ProxyComparison/runtime.mjs | 21 ++--- ContractTests/README.md | 4 +- .../proxy-generation/generated-code.md | 2 +- Documentation/proxy-generation/index.md | 2 +- Documentation/queries/model-bound/paging.md | 4 +- .../given/a_sorted_client_output_server.ts | 57 +++++++++++++ .../with_complex_values.ts | 47 +++++++++++ .../with_intercepted_client_output.ts | 29 +++++++ .../with_scalar_values.ts | 39 +++++++++ .../with_unreleased_client_output.ts | 27 +++++++ Source/Core/queries/queryRendering.ts | 11 ++- .../given/ScalarFields.ts | 37 +++++++++ .../for_renderSourceQuery/given/a_query.ts | 9 +++ .../with_complex_fields.ts | 37 +++++++++ .../with_scalar_fields.ts | 29 +++++++ .../Tools/ProxyGenerator/renderSourceQuery.ts | 7 +- 20 files changed, 385 insertions(+), 81 deletions(-) create mode 100644 Source/Core/queries/for_queryRendering/given/a_sorted_client_output_server.ts create mode 100644 Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_complex_values.ts create mode 100644 Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_intercepted_client_output.ts create mode 100644 Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_scalar_values.ts create mode 100644 Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_unreleased_client_output.ts create mode 100644 Source/Tools/ProxyGenerator/for_renderSourceQuery/given/ScalarFields.ts create mode 100644 Source/Tools/ProxyGenerator/for_renderSourceQuery/given/a_query.ts create mode 100644 Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_complex_fields.ts create mode 100644 Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_scalar_fields.ts diff --git a/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/All.ts b/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/All.ts index 0b5dad49..26f97c9c 100644 --- a/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/All.ts +++ b/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/All.ts @@ -1,4 +1,4 @@ -// @generated by Cratis. Source: ProxyComparison.Listing.All. Time: 2026-10-02T15:23:26.2840000Z. Hash: DDEC51C0B1FCDD19940A0B638DF8AC2402F8EA3BC633F8A17F60F25321244CEF +// @generated by Cratis. Source: ProxyComparison.Listing.All. Time: 2026-10-02T17:43:19.5340000Z. Hash: 62295E125CE69F80C53E5AF3B37B8C40039C964B91A67F637997C0F3976C4440 /*--------------------------------------------------------------------------------------------- * **DO NOT EDIT** - This file is an automatically generated file. *--------------------------------------------------------------------------------------------*/ @@ -13,20 +13,14 @@ import { Listing } from './Listing'; class AllSortBy { readonly name: SortingActionsForQuery; - readonly detail: SortingActionsForQuery; - readonly notice: SortingActionsForQuery; readonly status: SortingActionsForQuery; constructor(readonly query: All) { this.name = new SortingActionsForQuery('name', query); - this.detail = new SortingActionsForQuery('detail', query); - this.notice = new SortingActionsForQuery('notice', query); this.status = new SortingActionsForQuery('status', query); } } class AllSortByWithoutQuery { readonly name = new SortingActions('name'); - readonly detail = new SortingActions('detail'); - readonly notice = new SortingActions('notice'); readonly status = new SortingActions('status'); } diff --git a/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/Observe.ts b/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/Observe.ts index f3b15442..65363a1a 100644 --- a/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/Observe.ts +++ b/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/Observe.ts @@ -1,4 +1,4 @@ -// @generated by Cratis. Source: ProxyComparison.Listing.Observe. Time: 2026-10-02T15:23:26.2860000Z. Hash: 4B563871A5AF1853D3C85AB5D3A3AF5882D395039B02DCAC180B733C4E641B96 +// @generated by Cratis. Source: ProxyComparison.Listing.Observe. Time: 2026-10-02T17:43:19.5370000Z. Hash: FD4DF8F82D643D1270565EB583603EB5114841B48DD81B7BC715DB918083AAB6 /*--------------------------------------------------------------------------------------------- * **DO NOT EDIT** - This file is an automatically generated file. *--------------------------------------------------------------------------------------------*/ @@ -13,20 +13,14 @@ import { Listing } from './Listing'; class ObserveSortBy { readonly name: SortingActionsForObservableQuery; - readonly detail: SortingActionsForObservableQuery; - readonly notice: SortingActionsForObservableQuery; readonly status: SortingActionsForObservableQuery; constructor(readonly query: Observe) { this.name = new SortingActionsForObservableQuery('name', query); - this.detail = new SortingActionsForObservableQuery('detail', query); - this.notice = new SortingActionsForObservableQuery('notice', query); this.status = new SortingActionsForObservableQuery('status', query); } } class ObserveSortByWithoutQuery { readonly name = new SortingActions('name'); - readonly detail = new SortingActions('detail'); - readonly notice = new SortingActions('notice'); readonly status = new SortingActions('status'); } diff --git a/ContractTests/ProxyComparison/differences.json b/ContractTests/ProxyComparison/differences.json index 38182958..123c6171 100644 --- a/ContractTests/ProxyComparison/differences.json +++ b/ContractTests/ProxyComparison/differences.json @@ -19,12 +19,12 @@ "sorting": { "category": "knownDefect", "issue": "https://github.com/Cratis/Arc/issues/2998", - "description": ".NET 22.45.0 model-bound queries incorrectly emit sortBy.id from the query input rather than the response model. TypeScript's name/detail/notice/status helpers follow the documented read-model-field contract, as does .NET controller-based generation. The browser client sends the field unchanged; id is not a Listing field and fails on .NET IQueryable. This is a .NET defect, not an intentional API difference." + "description": ".NET 22.45.0 model-bound queries incorrectly emit sortBy.id from the query input rather than the response model. TypeScript's name/status helpers follow the documented read-model-field contract, as does .NET controller-based generation. The browser client sends the field unchanged; id is not a Listing field and fails on .NET IQueryable. This is a .NET defect, not an intentional API difference." }, "complex-sorting": { "category": "knownLimitation", "issue": "https://github.com/Cratis/Arc.TypeScript/issues/174", - "description": "Both generators emit helpers for complex result fields when generating result-oriented helpers (TypeScript here; .NET controller-based queries). Fields such as detail and notice are not sortable scalars: these fixture objects sort as no-ops on the TypeScript server and throw on .NET IQueryable. The model-bound .NET snapshot's query-parameter defect masks this separate limitation." + "description": "The .NET controller-based generator still emits helpers for complex result fields, which throw on .NET IQueryable; its model-bound snapshot's query-parameter defect masks that limitation. TypeScript emits only scalar helpers (name/status here), omits detail/notice, and rejects in-memory sorting on these complex fields with the existing malformedRequest validation result." }, "enum-names": { "category": "intentional", @@ -38,7 +38,7 @@ "limitations": ["complex-sorting"], "files": { "ProxyComparison/All.ts": { - "pairSha256": "7f6c29c8163e0a4f1f4cb891d3cbcc27cab70dd8b7a244c9c6851d1282854687", + "pairSha256": "f90f7636c86fdda4f832c5d2e662b474dd94227e287f9168931f3204d7db959c", "reasons": ["layout", "imports", "query-source", "sorting"] }, "ProxyComparison/Detail.ts": { @@ -50,7 +50,7 @@ "reasons": ["layout"] }, "ProxyComparison/Observe.ts": { - "pairSha256": "a2a3cf002cb13ab725443374c48b0b66d93bf380345af43869c136b54ab5c65e", + "pairSha256": "5993144694d6ca10f84d56ec3c7870f2b788757d00c2f02da942d58af92479f7", "reasons": ["layout", "imports", "query-source", "sorting"] }, "ProxyComparison/Register.ts": { diff --git a/ContractTests/ProxyComparison/raw.diff b/ContractTests/ProxyComparison/raw.diff index c0b18b52..a2ec0d43 100644 --- a/ContractTests/ProxyComparison/raw.diff +++ b/ContractTests/ProxyComparison/raw.diff @@ -1,52 +1,46 @@ --- DotNET/ProxyComparison/All.ts +++ TypeScript/ProxyComparison/All.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T15:23:25.9639250Z. Hash: 0E155A2217FA241EB7A3F167FC3C62571830874898B0B657D2366B8EA0B3AE01 -+// @generated by Cratis. Source: ProxyComparison.Listing.All. Time: 2026-10-02T15:23:26.2840000Z. Hash: DDEC51C0B1FCDD19940A0B638DF8AC2402F8EA3BC633F8A17F60F25321244CEF +-// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T17:43:19.2365230Z. Hash: 0E155A2217FA241EB7A3F167FC3C62571830874898B0B657D2366B8EA0B3AE01 ++// @generated by Cratis. Source: ProxyComparison.Listing.All. Time: 2026-10-02T17:43:19.5340000Z. Hash: 62295E125CE69F80C53E5AF3B37B8C40039C964B91A67F637997C0F3976C4440 @@ -8,2 +8,2 @@ -import { QueryFor, QueryResultWithState, Sorting, SortingActions, SortingActionsForQuery, Paging } from '@cratis/arc/queries'; -import { useQuery, useQueryWithPaging, useSuspenseQuery, useSuspenseQueryWithPaging, PerformQuery, SetSorting, SetPage, SetPageSize, QueryWhen } from '@cratis/arc.react/queries'; +import { QueryFor, type QueryResultWithState, type Sorting, Paging, SortingActions, SortingActionsForQuery } from '@cratis/arc/queries'; +import { useQuery, useSuspenseQuery, useQueryWithPaging, useSuspenseQueryWithPaging, type SetPage, type SetPageSize, type PerformQuery, type SetSorting, QueryWhen } from '@cratis/arc.react/queries'; -@@ -15,2 +15,4 @@ +@@ -15,2 +15,2 @@ - private _id: SortingActionsForQuery; - + readonly name: SortingActionsForQuery; -+ readonly detail: SortingActionsForQuery; -+ readonly notice: SortingActionsForQuery; + readonly status: SortingActionsForQuery; -@@ -18 +20,4 @@ +@@ -18 +18,2 @@ - this._id = new SortingActionsForQuery('id', query); + this.name = new SortingActionsForQuery('name', query); -+ this.detail = new SortingActionsForQuery('detail', query); -+ this.notice = new SortingActionsForQuery('notice', query); + this.status = new SortingActionsForQuery('status', query); -@@ -20,4 +24,0 @@ +@@ -20,4 +20,0 @@ - - get id(): SortingActionsForQuery { - return this._id; - } -@@ -25 +25,0 @@ +@@ -25 +21,0 @@ - -@@ -27,5 +27,4 @@ +@@ -27,5 +23,2 @@ - private _id: SortingActions = new SortingActions('id'); - - get id(): SortingActions { - return this._id; - } + readonly name = new SortingActions('name'); -+ readonly detail = new SortingActions('detail'); -+ readonly notice = new SortingActions('notice'); + readonly status = new SortingActions('status'); -@@ -38 +36,0 @@ +@@ -38 +30,0 @@ - -@@ -65,3 +63,2 @@ +@@ -65,3 +57,2 @@ - get sortBy(): AllSortBy { - return this._sortBy; - } + get sortBy(): AllSortBy { return this._sortBy; } + static get sortBy(): AllSortByWithoutQuery { return this._sortBy; } -@@ -69,4 +65,0 @@ +@@ -69,4 +59,0 @@ - static get sortBy(): AllSortByWithoutQuery { - return this._sortBy; - } @@ -54,8 +48,8 @@ --- DotNET/ProxyComparison/Detail.ts +++ TypeScript/ProxyComparison/Detail.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Detail. Time: 2026-10-02T15:23:25.9691550Z. Hash: 870244FDEBCE32A475902C261DDFF3AEADEFBA1A0B20D5FA623D6D1BD7473F94 -+// @generated by Cratis. Source: ProxyComparison.Detail. Time: 2026-10-02T15:23:26.2850000Z. Hash: EB083CE16EB7845B06C37F70EA02EC4D9BDB54179F8A3E64467299B7702BA917 +-// @generated by Cratis. Source: ProxyComparison.Detail. Time: 2026-10-02T17:43:19.2408080Z. Hash: 870244FDEBCE32A475902C261DDFF3AEADEFBA1A0B20D5FA623D6D1BD7473F94 ++// @generated by Cratis. Source: ProxyComparison.Detail. Time: 2026-10-02T17:43:19.5360000Z. Hash: EB083CE16EB7845B06C37F70EA02EC4D9BDB54179F8A3E64467299B7702BA917 @@ -8,2 +8 @@ -import { field } from '@cratis/fundamentals'; -import { Guid } from '@cratis/fundamentals'; @@ -65,8 +59,8 @@ --- DotNET/ProxyComparison/Listing.ts +++ TypeScript/ProxyComparison/Listing.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T15:23:25.9691550Z. Hash: 5E26E7FA2DA1134B949B271ED9019A0867B1FE2121E2BE76E9D4904F1F537957 -+// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T15:23:26.2850000Z. Hash: EAAC3EB0B31A551AE2BC36EFD4D8F8EC34FCB11785A63CB982B2BEA7F1ACFBF5 +-// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T17:43:19.2408080Z. Hash: 5E26E7FA2DA1134B949B271ED9019A0867B1FE2121E2BE76E9D4904F1F537957 ++// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T17:43:19.5360000Z. Hash: EAAC3EB0B31A551AE2BC36EFD4D8F8EC34FCB11785A63CB982B2BEA7F1ACFBF5 @@ -15,0 +16 @@ + @@ -17,0 +19 @@ @@ -76,59 +70,53 @@ --- DotNET/ProxyComparison/Notice.ts +++ TypeScript/ProxyComparison/Notice.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Notice. Time: 2026-10-02T15:23:25.9691550Z. Hash: C7BFC73EC13766A0F0B09DA0E21C97DFBF0018E89CB55A81E1C560094F91BA4E -+// @generated by Cratis. Source: ProxyComparison.Notice. Time: 2026-10-02T15:23:26.2860000Z. Hash: C7BFC73EC13766A0F0B09DA0E21C97DFBF0018E89CB55A81E1C560094F91BA4E +-// @generated by Cratis. Source: ProxyComparison.Notice. Time: 2026-10-02T17:43:19.2408080Z. Hash: C7BFC73EC13766A0F0B09DA0E21C97DFBF0018E89CB55A81E1C560094F91BA4E ++// @generated by Cratis. Source: ProxyComparison.Notice. Time: 2026-10-02T17:43:19.5370000Z. Hash: C7BFC73EC13766A0F0B09DA0E21C97DFBF0018E89CB55A81E1C560094F91BA4E --- DotNET/ProxyComparison/Observe.ts +++ TypeScript/ProxyComparison/Observe.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T15:23:25.9659790Z. Hash: 37AEFC99489891A80968EA738258F7C8759C9732BDCB0FD063B4C85F15651914 -+// @generated by Cratis. Source: ProxyComparison.Listing.Observe. Time: 2026-10-02T15:23:26.2860000Z. Hash: 4B563871A5AF1853D3C85AB5D3A3AF5882D395039B02DCAC180B733C4E641B96 +-// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T17:43:19.2380900Z. Hash: 37AEFC99489891A80968EA738258F7C8759C9732BDCB0FD063B4C85F15651914 ++// @generated by Cratis. Source: ProxyComparison.Listing.Observe. Time: 2026-10-02T17:43:19.5370000Z. Hash: FD4DF8F82D643D1270565EB583603EB5114841B48DD81B7BC715DB918083AAB6 @@ -8,2 +8,2 @@ -import { ObservableQueryFor, QueryResultWithState, Sorting, SortingActions, SortingActionsForObservableQuery, Paging, ChangeSet } from '@cratis/arc/queries'; -import { useObservableQuery, useObservableQueryWithPaging, useSuspenseObservableQuery, useSuspenseObservableQueryWithPaging, useChangeStream, SetSorting, SetPage, SetPageSize, ObservableQueryWhen } from '@cratis/arc.react/queries'; +import { ObservableQueryFor, type QueryResultWithState, type Sorting, Paging, SortingActions, SortingActionsForObservableQuery, type ChangeSet } from '@cratis/arc/queries'; +import { useObservableQuery, useSuspenseObservableQuery, useObservableQueryWithPaging, useSuspenseObservableQueryWithPaging, type SetPage, type SetPageSize, useChangeStream, type SetSorting, ObservableQueryWhen } from '@cratis/arc.react/queries'; -@@ -15,2 +15,4 @@ +@@ -15,2 +15,2 @@ - private _id: SortingActionsForObservableQuery; - + readonly name: SortingActionsForObservableQuery; -+ readonly detail: SortingActionsForObservableQuery; -+ readonly notice: SortingActionsForObservableQuery; + readonly status: SortingActionsForObservableQuery; -@@ -18 +20,4 @@ +@@ -18 +18,2 @@ - this._id = new SortingActionsForObservableQuery('id', query); + this.name = new SortingActionsForObservableQuery('name', query); -+ this.detail = new SortingActionsForObservableQuery('detail', query); -+ this.notice = new SortingActionsForObservableQuery('notice', query); + this.status = new SortingActionsForObservableQuery('status', query); -@@ -20,4 +24,0 @@ +@@ -20,4 +20,0 @@ - - get id(): SortingActionsForObservableQuery { - return this._id; - } -@@ -25 +25,0 @@ +@@ -25 +21,0 @@ - -@@ -27,5 +27,4 @@ +@@ -27,5 +23,2 @@ - private _id: SortingActions = new SortingActions('id'); - - get id(): SortingActions { - return this._id; - } + readonly name = new SortingActions('name'); -+ readonly detail = new SortingActions('detail'); -+ readonly notice = new SortingActions('notice'); + readonly status = new SortingActions('status'); -@@ -35 +33,0 @@ +@@ -35 +27,0 @@ - -@@ -39 +36,0 @@ +@@ -39 +30,0 @@ - -@@ -66,3 +63,2 @@ +@@ -66,3 +57,2 @@ - get sortBy(): ObserveSortBy { - return this._sortBy; - } + get sortBy(): ObserveSortBy { return this._sortBy; } + static get sortBy(): ObserveSortByWithoutQuery { return this._sortBy; } -@@ -70,4 +65,0 @@ +@@ -70,4 +59,0 @@ - static get sortBy(): ObserveSortByWithoutQuery { - return this._sortBy; - } @@ -136,8 +124,8 @@ --- DotNET/ProxyComparison/Register.ts +++ TypeScript/ProxyComparison/Register.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Register. Time: 2026-10-02T15:23:25.9473890Z. Hash: 1D6FE14590B481D913125C1B2BE21D724F94D8AABAB561D47058830A6CADC8E2 -+// @generated by Cratis. Source: ProxyComparison.Register. Time: 2026-10-02T15:23:26.2870000Z. Hash: 9213A3CE667705E870E1AA87B3482F81F37362E3416D33C9BE11229F6762AC41 +-// @generated by Cratis. Source: ProxyComparison.Register. Time: 2026-10-02T17:43:19.2226420Z. Hash: 1D6FE14590B481D913125C1B2BE21D724F94D8AABAB561D47058830A6CADC8E2 ++// @generated by Cratis. Source: ProxyComparison.Register. Time: 2026-10-02T17:43:19.5380000Z. Hash: 9213A3CE667705E870E1AA87B3482F81F37362E3416D33C9BE11229F6762AC41 @@ -10 +10 @@ -import { useCommand, SetCommandValues, ClearCommandValues } from '@cratis/arc.react/commands'; +import { useCommand, type SetCommandValues, type ClearCommandValues } from '@cratis/arc.react/commands'; @@ -157,8 +145,8 @@ --- DotNET/ProxyComparison/Status.ts +++ TypeScript/ProxyComparison/Status.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Status. Time: 2026-10-02T15:23:25.9713430Z. Hash: 2021C7DF28DABBEC728CC5649211601AC22C9E7848AABC7C394B1DBBE4D65D34 -+// @generated by Cratis. Source: ProxyComparison.Status. Time: 2026-10-02T15:23:26.2870000Z. Hash: D6BE2F44AEE3F50AB357446E1374EEC78C1ABC0A6A249AADA64C9DC7327939E2 +-// @generated by Cratis. Source: ProxyComparison.Status. Time: 2026-10-02T17:43:19.2427540Z. Hash: 2021C7DF28DABBEC728CC5649211601AC22C9E7848AABC7C394B1DBBE4D65D34 ++// @generated by Cratis. Source: ProxyComparison.Status. Time: 2026-10-02T17:43:19.5380000Z. Hash: D6BE2F44AEE3F50AB357446E1374EEC78C1ABC0A6A249AADA64C9DC7327939E2 @@ -8,2 +8,2 @@ - draft = 0, - published = 1, @@ -167,12 +155,12 @@ --- DotNET/ProxyComparison/UrgentNotice.ts +++ TypeScript/ProxyComparison/UrgentNotice.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.UrgentNotice. Time: 2026-10-02T15:23:25.9691550Z. Hash: B6DFD0F8172730462EE16C33D07A74E01C2C446E0DC8E33AF2A92DF22C4B1738 -+// @generated by Cratis. Source: ProxyComparison.UrgentNotice. Time: 2026-10-02T15:23:26.2880000Z. Hash: E68232937A2D329A758BE7A911099151B8188944D1CDE443CF39C4FF54CB4C7F +-// @generated by Cratis. Source: ProxyComparison.UrgentNotice. Time: 2026-10-02T17:43:19.2408080Z. Hash: B6DFD0F8172730462EE16C33D07A74E01C2C446E0DC8E33AF2A92DF22C4B1738 ++// @generated by Cratis. Source: ProxyComparison.UrgentNotice. Time: 2026-10-02T17:43:19.5380000Z. Hash: E68232937A2D329A758BE7A911099151B8188944D1CDE443CF39C4FF54CB4C7F @@ -8 +8 @@ -import { field, derivedType } from '@cratis/fundamentals'; +import { derivedType, field } from '@cratis/fundamentals'; --- DotNET/ProxyComparison/index.ts +++ TypeScript/ProxyComparison/index.ts @@ -1,0 +1 @@ -+// @generated by Cratis. Source: . Time: 2026-10-02T15:23:26.2880000Z. Hash: 47EA7CF2735A3A1C1FD910D8F0AD46FA94CC8FA45699573458B54238BD829218 ++// @generated by Cratis. Source: . Time: 2026-10-02T17:43:19.5390000Z. Hash: 47EA7CF2735A3A1C1FD910D8F0AD46FA94CC8FA45699573458B54238BD829218 diff --git a/ContractTests/ProxyComparison/runtime.mjs b/ContractTests/ProxyComparison/runtime.mjs index b9fdf334..fc23be72 100644 --- a/ContractTests/ProxyComparison/runtime.mjs +++ b/ContractTests/ProxyComparison/runtime.mjs @@ -35,7 +35,6 @@ const rows = [ { name: 'alpha', detail: { id, created: '2026-01-01T03:04:05Z' }, notice: { title: 'first' }, status: 1 }, { name: 'bravo', detail: { id, created: '2026-01-02T03:04:05Z' }, notice: { title: 'second' }, status: 0 } ]; -const originalOrder = rows.map(row => row.name); const typescript = new ArcServer({ introspection: { enabled: false }, queries: [[All, 'All'], [Observe, 'Observe']].map(([Query, name]) => defineQuery({ name, path: new Query().route, schema: z.object({ id: z.string() }), perform: () => structuredClone(rows) })) }); @@ -83,9 +82,13 @@ try { assert.equal(model.status, 1); assert.deepEqual(JSON.parse(JsonSerializer.serialize(model)), wire); // Arc#2998: .NET's query-parameter helper is a defect, not an intentional API difference. - const sortNames = family === 'DotNET' ? ['id'] : ['name', 'detail', 'notice', 'status']; + const sortNames = family === 'DotNET' ? ['id'] : ['name', 'status']; for (const name of sortNames) { assert.ok(query.sortBy[name]); assert.ok(Query.sortBy[name]); } assert.equal(query.sortBy[family === 'DotNET' ? 'name' : 'id'], undefined); + if (family === 'TypeScript') for (const name of ['detail', 'notice']) { + assert.equal(query.sortBy[name], undefined); + assert.equal(Query.sortBy[name], undefined); + } for (const hook of ['use', 'useWithPaging', 'useSuspense', 'useSuspenseWithPaging', 'when']) assert.equal(typeof Query[hook], 'function'); // Send the generated Sorting through the real browser client and TS HTTP query pipeline. @@ -110,27 +113,27 @@ try { assert.equal(status, 200, JSON.stringify(control)); assert.equal(control.isSuccess, true); assert.deepEqual(control.data.map(row => row.name), ['alpha', 'bravo', 'charlie']); - for (const name of sortNames) { + for (const name of [...sortNames, 'detail', 'notice']) { for (const direction of ['ascending', 'descending']) { - const sorting = Query.sortBy[name][direction]; + const sorting = (Query.sortBy[name] ?? new SortingActions(name))[direction]; assert.equal(sorting.field, name); - assert.deepEqual(query.sortBy[name][direction](), sorting, 'Instance and static helpers agree'); + if (query.sortBy[name]) assert.deepEqual(query.sortBy[name][direction](), sorting, 'Instance and static helpers agree'); query.sorting = sorting; const sorted = await query.perform({ id: Guid.parse(id) }); assert.equal(sentField, name, 'The browser client must send the field unchanged'); - if (name === 'id' || backend === 'DotNET' && ['detail', 'notice'].includes(name)) { + if (name === 'id' || ['detail', 'notice'].includes(name)) { // Arc#2998: id is not a result field. Arc.TypeScript#174: complex fields are not comparable. assert.equal(status, backend === 'DotNET' ? 500 : 400, JSON.stringify(sorted)); assert.equal(sorted.isSuccess, false); assert.equal(sorted.hasExceptions, backend === 'DotNET'); + if (backend === 'TypeScript') assert.deepEqual(sorted.validationResults.map(({ message, members, reason }) => ({ message, members, reason })), + [{ message: 'Malformed request', members: [], reason: 'malformedRequest' }]); } else { assert.equal(status, 200, JSON.stringify(sorted)); assert.equal(sorted.isSuccess, true); const expected = name === 'name' ? direction === 'ascending' ? ['alpha', 'bravo', 'charlie'] : ['charlie', 'bravo', 'alpha'] - : name === 'status' - ? direction === 'ascending' ? ['charlie', 'bravo', 'alpha'] : ['alpha', 'charlie', 'bravo'] - : originalOrder; // Arc.TypeScript#174: distinct complex values currently sort as a no-op. + : direction === 'ascending' ? ['charlie', 'bravo', 'alpha'] : ['alpha', 'charlie', 'bravo']; assert.deepEqual(sorted.data.map(row => row.name), expected, `${family}/${Query.name} ${backend} ${name} ${direction}`); } } diff --git a/ContractTests/README.md b/ContractTests/README.md index c4e8d6cd..4f3eccce 100644 --- a/ContractTests/README.md +++ b/ContractTests/README.md @@ -43,9 +43,9 @@ Review the inventory alongside `raw.diff`. Entries distinguish `intentional` dif Known sorting behavior is recorded separately: - **Known .NET defect — sorting API:** .NET 22.45.0 model-bound generation emits `sortBy.id` from the query parameter instead of the response model. TypeScript follows the documented read-model-field contract, as does .NET controller-based generation. The client sends the field unchanged; `id` is not a `Listing` field and fails on .NET `IQueryable`. This is not an intentional API difference ([Arc#2998](https://github.com/Cratis/Arc/issues/2998)). -- **Known limitation of both generators — complex fields:** result-oriented helpers include fields such as `detail` and `notice`, even though these objects sort as no-ops on the TypeScript server and throw on .NET `IQueryable`. The model-bound .NET defect masks this limitation in its captured helpers ([Arc.TypeScript#174](https://github.com/Cratis/Arc.TypeScript/issues/174)). Use scalar fields such as `name` and `status` instead. +- **Known .NET limitation — complex fields:** .NET result-oriented helpers still include fields such as `detail` and `notice`, which throw on .NET `IQueryable`; TypeScript omits those helpers and rejects complex-field in-memory sorting with `malformedRequest`. The model-bound .NET defect masks this limitation in its captured helpers ([Arc.TypeScript#174](https://github.com/Cratis/Arc.TypeScript/issues/174)). Use scalar fields such as `name` and `status` instead. -`Notice.ts` agrees after timestamp normalization. Routes, parameter/property descriptors, GUID/date/nested/derived hydration, numeric enum values, portable validation boundaries/messages and all command/query/observable hook signatures are asserted against the pinned client. Hydration uses controlled GET responses. Sorting checks send generated ascending/descending `Sorting` values through the client to the real TypeScript HTTP pipeline over three distinct rows, checking scalar order, complex-field no-ops and invalid-field rejection. Full regeneration additionally starts the locked .NET fixture: its `All` query returns `IQueryable` over the same rows, exposing invalid-field and complex-field exceptions as HTTP 500 results. Positive scalar controls distinguish those failures from an unavailable route or server. These known-behavior assertions must change when the linked issues are fixed; offline runs explicitly skip live .NET checks. +`Notice.ts` agrees after timestamp normalization. Routes, parameter/property descriptors, GUID/date/nested/derived hydration, numeric enum values, portable validation boundaries/messages and all command/query/observable hook signatures are asserted against the pinned client. Hydration uses controlled GET responses. Sorting checks send generated ascending/descending `Sorting` values through the client to the real TypeScript HTTP pipeline over three distinct rows, checking scalar order and invalid-field rejection, plus manually constructed complex-field sorts that TypeScript rejects. Full regeneration additionally starts the locked .NET fixture: its `All` query returns `IQueryable` over the same rows, exposing invalid-field and complex-field exceptions as HTTP 500 results. Positive scalar controls distinguish those failures from an unavailable route or server. These known-behavior assertions must change when the linked issues are fixed; offline runs explicitly skip live .NET checks. No React tree is mounted. Each output runs in a separate process so derived-type registration cannot leak from the other. A strict consumer includes negative type checks; bundling for the Node runtime resolves extensionless imports without modifying the captured sources. diff --git a/Documentation/proxy-generation/generated-code.md b/Documentation/proxy-generation/generated-code.md index 9edb4740..d5e518b0 100644 --- a/Documentation/proxy-generation/generated-code.md +++ b/Documentation/proxy-generation/generated-code.md @@ -77,7 +77,7 @@ export class BooksForAuthor extends ObservableQueryFor` for a snapshot, `ObservableQueryFor` when the method returns an observable source | | `queryName` | The fully qualified name the server uses for hub subscriptions | | `defaultValue` | What `result.data` holds before the first answer: `[]` for a list, `{} as TModel` for a single model | -| `sortBy` | For list results, one sort helper per model field, as a static and an instance property | +| `sortBy` | For list results, one sort helper per scalar model field, as a static and an instance property | | `parameterDescriptors`, `requiredRequestParameters` | The arguments, so the client can wait until required ones are set | | `validation` | A `QueryValidator` when the query's arguments have client-safe rules | diff --git a/Documentation/proxy-generation/index.md b/Documentation/proxy-generation/index.md index 8ab60d78..6879174d 100644 --- a/Documentation/proxy-generation/index.md +++ b/Documentation/proxy-generation/index.md @@ -48,7 +48,7 @@ The repository's [paired-generator comparison](https://github.com/Cratis/Arc.Typ This is a compatibility check, not a byte-equality promise. Intentional differences include type-only imports, source enum member names (rather than .NET's camel-cased names), formatting and provenance. The pinned .NET model-bound generator's query-parameter sorting helpers are a [known defect](https://github.com/Cratis/Arc/issues/2998), not an intentional API difference: TypeScript follows the documented contract that `sortBy` names a read-model field. -Both generators have a [known limitation](https://github.com/Cratis/Arc.TypeScript/issues/174) when emitting result-field helpers: complex fields such as `detail` and `notice` are included despite not being sortable scalars. These fixture objects sort as no-ops on the TypeScript server and throw on .NET `IQueryable`. Prefer scalar fields such as `name` and `status`. +TypeScript now emits `sortBy` helpers only for scalar fields (string, number, boolean, Date, Guid, DateOnly, TimeOnly, TimeSpan, enums, and concepts over those), omitting complex fields such as `detail` and `notice`; .NET's complex-field helpers remain a [known limitation](https://github.com/Cratis/Arc.TypeScript/issues/174). The inventory separates intentional differences, known defects and known limitations. Regeneration rejects unreviewed bytes, normalizing only the generated header's timestamp; offline checks also reject changed C# fixture or .NET option fingerprints. See the [contract-test guide](https://github.com/Cratis/Arc.TypeScript/blob/main/ContractTests/README.md#compare-proxy-generators) for the inventory, behavioral sorting checks and recapture instructions. diff --git a/Documentation/queries/model-bound/paging.md b/Documentation/queries/model-bound/paging.md index 0119d0cc..3f3443bf 100644 --- a/Documentation/queries/model-bound/paging.md +++ b/Documentation/queries/model-bound/paging.md @@ -52,7 +52,9 @@ With `Catalog` registered as a singleton, `GET /api/all?pageSize=2&sortBy=name&s Invalid directions answer 400 with `malformedRequest` and the `sortDirection` (GET) or `sorting.direction` (`QUERY`) member. Page offsets are clamped to the signed 32-bit maximum before slicing in memory, so large valid page and size values cannot overflow the offset. Providers that cut their own pages must apply equivalent bounds before using the offset in their data source. -In-memory sorting requires the field on every item, or the request answers 400. Dates compare by time, numbers and bigints numerically, `false` before `true`, and `null` or `undefined` before any value in ascending order. Other values compare as strings with `localeCompare`, which is not .NET invariant-culture collation; sort in the data source when a stable cross-platform order matters. +In-memory sorting requires the field on every item, or the request answers 400. Dates compare by time, numbers and bigints numerically, `false` before `true`, and `null` or `undefined` before any value in ascending order. Other scalar values compare as strings with `localeCompare`, which is not .NET invariant-culture collation; sort in the data source when a stable cross-platform order matters. + +In-memory array sorting rejects present, non-null complex values with the existing `malformedRequest` validation result (HTTP 400 for snapshots; a validation result if streaming has already started), without changing provider-owned sorting. A query that returns something other than an array answers 400 when the request asks for paging or sorting. diff --git a/Source/Core/queries/for_queryRendering/given/a_sorted_client_output_server.ts b/Source/Core/queries/for_queryRendering/given/a_sorted_client_output_server.ts new file mode 100644 index 00000000..4bfde6e3 --- /dev/null +++ b/Source/Core/queries/for_queryRendering/given/a_sorted_client_output_server.ts @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { z } from 'zod'; +import { ArcServer } from '../../../ArcServer.js'; +import { ServiceLifetime } from '../../../dependencyInjection/ServiceLifetime.js'; +import { serviceToken } from '../../../dependencyInjection/ServiceToken.js'; +import type { ClientContract } from '../../../introspection/ClientContract.js'; +import { defineQuery } from '../../defineQuery.js'; +import { defineObservableQuery } from '../../observable/defineObservableQuery.js'; +import { CurrentValueSubject } from '../../observable/CurrentValueSubject.js'; +import type { ReadModelInterceptor } from '../../ReadModelInterceptor.js'; +import { markRawReadModelDocument } from '../../rawReadModelDocuments.js'; + +class Secret { + constructor(readonly order: number, readonly name: string) {} +} + +export class a_sorted_client_output_server { + readonly seen: number[] = []; + readonly server: ArcServer; + constructor(raw: boolean, release: boolean) { + const released = new WeakSet(); + const token = serviceToken('protect sorted rows'); + const clientOutput: ClientContract = { output: { kind: 'array', element: { kind: 'dto', name: 'Secret', fields: [ + { name: 'order', type: { kind: 'number' } }, { name: 'name', type: { kind: 'string' } } + ] } } }; + const rows = (tenantId: string): object[] => [2, 1].map(order => raw + ? markRawReadModelDocument({ order, name: 'private' }, { model: Secret, tenantId, subject: String(order) }) + : new Secret(order, 'private')); + this.server = new ArcServer({ + introspection: { enabled: false }, + tenancy: { resolve: () => 'tenant-a' }, + services: [{ token, lifetime: ServiceLifetime.Scoped, factory: (): ReadModelInterceptor => ({ + model: Secret, + isReleased: (model: object) => released.has(model), + intercept: (model: object) => { + const row = model as Secret; + this.seen.push(row.order); + if (!release) return row; + const result = new Secret(row.order, 'masked'); + released.add(result); + return result; + }, + interceptRawDocument: (model: object, provenance) => { + const row = model as Secret; + this.seen.push(row.order); + return release ? { order: row.order, name: 'masked' } + : markRawReadModelDocument({ ...row }, provenance); + } + }) }], + readModelInterceptors: [token], + queries: [defineQuery({ name: 'All', schema: z.object({}), clientOutput, perform: (_, context) => rows(context.tenantId!) })], + observableQueries: [defineObservableQuery({ name: 'Watch', schema: z.object({}), clientOutput, + observe: (_, context) => CurrentValueSubject.of(rows(context.tenantId!)) })] + }); + } +} diff --git a/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_complex_values.ts b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_complex_values.ts new file mode 100644 index 00000000..5885d44a --- /dev/null +++ b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_complex_values.ts @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ConceptAs } from '@cratis/fundamentals'; +import { describe } from 'vitest'; +import { z } from 'zod'; +import { ArcServer } from '../../../ArcServer.js'; +import { defineQuery } from '../../defineQuery.js'; +import { CurrentValueSubject } from '../../observable/CurrentValueSubject.js'; +import { defineObservableQuery } from '../../observable/defineObservableQuery.js'; +import type { QueryResult } from '../../QueryResult.js'; + +class Detail { constructor(readonly name: string) {} } +class ComplexConcept extends ConceptAs {} +const values = [ + { kind: 'object', value: (name: string) => ({ name }) }, + { kind: 'array', value: (name: string) => [name] }, + { kind: 'map', value: (name: string) => new Map([['name', name]]) }, + { kind: 'class', value: (name: string) => new Detail(name) }, + { kind: 'concept', value: (name: string) => new ComplexConcept({ name }) } +]; + +describe.each(values)('when sorting an in-memory array with a $kind value', ({ value }) => { + let responses: { status: number; result: QueryResult }[]; + beforeEach(async () => { + const rows = ['bravo', 'alpha', 'charlie'].map(name => ({ name, detail: value(name) })); + const server = new ArcServer({ + queries: [defineQuery({ name: 'All', schema: z.object({}), perform: () => rows }), + defineQuery({ name: 'Single', schema: z.object({}), perform: () => rows.slice(0, 1) })], + observableQueries: [defineObservableQuery({ name: 'Watch', schema: z.object({}), observe: () => CurrentValueSubject.of(rows) })] + }); + try { + responses = []; + for (const path of ['all?sortBy=detail', 'all?sortBy=detail&pageSize=1', 'watch?sortBy=detail', 'single?sortBy=detail', 'all?sortBy=missing']) { + const response = (await server.handle(new Request(`http://localhost/api/${path}`)))!; + responses.push({ status: response.status, result: await response.json() as QueryResult }); + } + } finally { await server.dispose(); } + }); + it('should reject snapshot, paged array, and observable snapshot sorting like an unknown field', () => { + for (const response of responses) { + response.status.should.equal(400); + response.result.isSuccess.should.equal(false); + response.result.hasExceptions.should.equal(false); + response.result.validationResults.should.deep.equal([{ severity: 3, message: 'Malformed request', members: [], reason: 'malformedRequest' }]); + } + }); +}); diff --git a/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_intercepted_client_output.ts b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_intercepted_client_output.ts new file mode 100644 index 00000000..3a81ae03 --- /dev/null +++ b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_intercepted_client_output.ts @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { describe } from 'vitest'; +import type { QueryResult } from '../../QueryResult.js'; +import { a_sorted_client_output_server } from '../given/a_sorted_client_output_server.js'; + +describe.each([ + { path: 'all', raw: false }, { path: 'watch', raw: false }, + { path: 'all', raw: true }, { path: 'watch', raw: true } +])('when sorting $path client output with raw=$raw read models', ({ path, raw }) => { + let result: QueryResult; + let seen: number[]; + beforeEach(async () => { + const context = new a_sorted_client_output_server(raw, true); + try { + const response = (await context.server.handle(new Request(`http://localhost/api/${path}?sortBy=order&pageSize=1`)))!; + result = await response.json() as QueryResult; + seen = context.seen; + } finally { await context.server.dispose(); } + }); + it('should intercept the original selected row before serializing client output', () => { + seen.should.deep.equal([1]); + result.isSuccess.should.equal(true); + result.data!.should.deep.equal([{ order: 1, name: 'masked' }]); + }); + it('should preserve paging before interception', () => { + result.paging.totalItems.should.equal(2); + }); +}); diff --git a/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_scalar_values.ts b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_scalar_values.ts new file mode 100644 index 00000000..f8db640a --- /dev/null +++ b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_scalar_values.ts @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ConceptAs, DateOnly, Guid, TimeOnly, TimeSpan } from '@cratis/fundamentals'; +import { describe } from 'vitest'; +import { renderQueryData } from '../../queryRendering.js'; +import { SortDirection } from '../../SortDirection.js'; +import type { QueryResult } from '../../QueryResult.js'; +import { Severity } from '../../../validation/Severity.js'; + +class ScalarConcept extends ConceptAs {} +const cases = [ + { kind: 'string', values: ['b', 'a'], order: [1, 0] }, + { kind: 'number', values: [10, 2], order: [1, 0] }, + { kind: 'boolean', values: [true, false], order: [1, 0] }, + { kind: 'date', values: [new Date(1000), new Date(0)], order: [1, 0] }, + { kind: 'guid', values: [Guid.parse('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'), Guid.parse('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')], order: [1, 0] }, + { kind: 'date only', values: [DateOnly.parse('2026-02-01'), DateOnly.parse('2026-01-01')], order: [1, 0] }, + { kind: 'time only', values: [TimeOnly.parse('12:00:00'), TimeOnly.parse('08:00:00')], order: [1, 0] }, + { kind: 'time span', values: [TimeSpan.parse('12:00:00'), TimeSpan.parse('08:00:00')], order: [1, 0] }, + { kind: 'null and undefined', values: [2, null, undefined, 1], order: [1, 2, 3, 0] }, + { kind: 'mixed scalar kinds', values: [2, '10', 1], order: [2, 1, 0] }, + { kind: 'existing bigint comparison', values: [10n, 2n], order: [1, 0] } +]; +const concepts = cases.filter(item => !['null and undefined', 'mixed scalar kinds', 'existing bigint comparison', 'number'].includes(item.kind)) + .map(item => ({ ...item, kind: `concept over ${item.kind}`, values: item.values.map(value => new ScalarConcept(value)) })); +concepts.push({ kind: 'concept over number using the existing string fallback', values: [new ScalarConcept(2), new ScalarConcept(10)], order: [1, 0] }); + +describe.each([...cases, ...concepts])('when sorting in memory with $kind values', ({ values, order }) => { + let result: QueryResult; + beforeEach(() => { + result = renderQueryData({}, values.map((value, index) => ({ value, index })), { + tenantId: 'test', correlationId: 'test', principal: undefined, signal: new AbortController().signal, allowedSeverity: Severity.Warning + }, { sorting: { field: 'value', direction: SortDirection.Ascending } }); + }); + it('should preserve the existing comparison order', () => { + result.isSuccess.should.equal(true); + (result.data as { index: number }[]).map(item => item.index).should.deep.equal(order); + }); +}); diff --git a/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_unreleased_client_output.ts b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_unreleased_client_output.ts new file mode 100644 index 00000000..e38a1324 --- /dev/null +++ b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_unreleased_client_output.ts @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { describe } from 'vitest'; +import type { QueryResult } from '../../QueryResult.js'; +import { a_sorted_client_output_server } from '../given/a_sorted_client_output_server.js'; + +describe.each([ + { path: 'all', raw: false }, { path: 'watch', raw: false }, + { path: 'all', raw: true }, { path: 'watch', raw: true } +])('when sorting $path client output with unreleased raw=$raw read models', ({ path, raw }) => { + let result: QueryResult; + let status: number; + beforeEach(async () => { + const context = new a_sorted_client_output_server(raw, false); + try { + const response = (await context.server.handle(new Request(`http://localhost/api/${path}?sortBy=order`)))!; + status = response.status; + result = await response.json() as QueryResult; + } finally { await context.server.dispose(); } + }); + it('should apply the release guard before serializing client output', () => { + status.should.equal(500); + result.isSuccess.should.equal(false); + result.hasExceptions.should.equal(true); + JSON.stringify(result).should.not.contain('private'); + }); +}); diff --git a/Source/Core/queries/queryRendering.ts b/Source/Core/queries/queryRendering.ts index b0c16b20..8db8091d 100644 --- a/Source/Core/queries/queryRendering.ts +++ b/Source/Core/queries/queryRendering.ts @@ -1,5 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ConceptAs, DateOnly, Guid, TimeOnly, TimeSpan } from '@cratis/fundamentals'; import { SortDirection } from './SortDirection.js'; import type { ExecutionContext } from '../execution/ExecutionContext.js'; import type { DescriptorBase } from '../http/DescriptorBase.js'; @@ -18,6 +19,13 @@ function safeOffset(page: number, size: number): number | undefined { return size > 0 && page > Math.floor(maxInt32 / size) ? maxInt32 : page * size; } +function isScalarSortValue(value: unknown): boolean { + if (value instanceof ConceptAs) return isScalarSortValue(value.value); + return value == null || ['string', 'number', 'boolean', 'bigint'].includes(typeof value) || + value instanceof Date || value instanceof Guid || value instanceof DateOnly || + value instanceof TimeOnly || value instanceof TimeSpan; +} + function compareValues(left: unknown, right: unknown): number { if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime(); if (left == null || right == null) return left == null ? right == null ? 0 : -1 : 1; @@ -49,7 +57,8 @@ export function renderQueryData(definition: Pick !item || typeof item !== 'object' || !Object.hasOwn(item, field))) + if (sorted.some((item: unknown) => !item || typeof item !== 'object' || !Object.hasOwn(item, field) || + !isScalarSortValue(Reflect.get(item, field)))) return queryResult(context, { validationResults: malformed(context) }); sorted.sort((left: unknown, right: unknown) => { const first = left && typeof left === 'object' ? Reflect.get(left, field) as unknown : undefined; diff --git a/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/ScalarFields.ts b/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/ScalarFields.ts new file mode 100644 index 00000000..b2a5a9f0 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/ScalarFields.ts @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ConceptAs, DateOnly, field, Guid, TimeOnly, TimeSpan } from '@cratis/fundamentals'; + +export enum Status { Draft, Published } +export enum Label { First = 'first', Last = 'last' } +export class Name extends ConceptAs {} +export class Amount extends ConceptAs {} +export class Enabled extends ConceptAs {} +export class Timestamp extends ConceptAs {} +export class Identifier extends ConceptAs {} +export class Day extends ConceptAs {} +export class Time extends ConceptAs {} +export class Duration extends ConceptAs {} +export class State extends ConceptAs {} + +export class ScalarFields { + @field(String) name!: string; + @field(Number) amount!: number; + @field(Boolean) enabled!: boolean; + @field(Date) timestamp!: Date; + @field(Guid) identifier!: Guid; + @field(DateOnly) day!: DateOnly; + @field(TimeOnly) time!: TimeOnly; + @field(TimeSpan) duration!: TimeSpan; + @field(Number) status!: Status; + @field(String) label!: Label; + @field(Name) conceptName!: Name; + @field(Amount) conceptAmount!: Amount; + @field(Enabled) conceptEnabled!: Enabled; + @field(Timestamp) conceptTimestamp!: Timestamp; + @field(Identifier) conceptIdentifier!: Identifier; + @field(Day) conceptDay!: Day; + @field(Time) conceptTime!: Time; + @field(Duration) conceptDuration!: Duration; + @field(State) conceptStatus!: State; +} diff --git a/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/a_query.ts b/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/a_query.ts new file mode 100644 index 00000000..ba5c6df0 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/a_query.ts @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ClientOperationKind } from '@cratis/arc.core'; +import type { SourceOperation } from '../../SourceOperation.js'; + +export const a_query: SourceOperation = { + kind: ClientOperationKind.Query, name: 'All', namespace: '', owner: 'Listing', roles: [], fields: [], + result: { text: 'Listing[]', constructor: 'Listing', model: 'Listing', enumerable: true, nullable: false, void: false } +}; diff --git a/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_complex_fields.ts b/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_complex_fields.ts new file mode 100644 index 00000000..621cbe51 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_complex_fields.ts @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ClientOperationKind } from '@cratis/arc.core'; +import { renderSourceQuery } from '../../renderSourceQuery.js'; +import type { SourceType } from '../../SourceType.js'; +import { a_query } from '../given/a_query.js'; + +const type = (constructor: string, enumerable = false): SourceType => ({ text: constructor, constructor, enumerable, nullable: false, void: false }); +const fields = [ + { name: 'record', type: type('Object') }, + { name: 'nested', type: { ...type('Detail'), model: 'Detail' } }, + { name: 'array', type: type('String', true) }, + { name: 'map', type: type('Map') }, + { name: 'polymorphic', type: { ...type('Notice'), model: 'Notice' } }, + { name: 'derived', type: { ...type('UrgentNotice'), model: 'UrgentNotice' } }, + { name: 'complexConcept', type: type('Object') }, + { name: 'scalarNamedModel', type: { ...type('Guid'), model: 'Guid' } } +].map(field => ({ ...field, optional: false, nullable: false })); + +describe('when emitting sort helpers with complex fields', () => { + let outputs: string[]; + beforeEach(() => { + outputs = [ClientOperationKind.Query, ClientOperationKind.Observable].map(kind => renderSourceQuery({ ...a_query, kind }, + 'All.ts', new Map([['Listing', 'Listing.ts']]), '/api/all', { name: 'Listing', namespace: '', kind: 'model', fields })); + }); + for (const { name } of fields) { + it(`should omit static and instance sorting for ${name}`, () => { + for (const output of outputs) { + output.should.not.contain(`readonly ${name}`); + output.should.not.contain(`this.${name} =`); + } + }); + } + it('should omit unused sorting action imports', () => { + for (const output of outputs) output.should.not.contain('SortingActions'); + }); +}); diff --git a/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_scalar_fields.ts b/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_scalar_fields.ts new file mode 100644 index 00000000..5b22b2b1 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_scalar_fields.ts @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { resolve } from 'node:path'; +import ts from 'typescript'; +import { SourceTypeResolver } from '../../SourceTypeResolver.js'; +import { renderSourceQuery } from '../../renderSourceQuery.js'; +import { a_query } from '../given/a_query.js'; + +describe('when emitting sort helpers with scalar fields', () => { + let output: string; + beforeEach(() => { + const root = resolve('Source/Tools/ProxyGenerator/for_renderSourceQuery/given'); + const path = resolve(root, 'ScalarFields.ts'); + const program = ts.createProgram([path], { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, strict: true, experimentalDecorators: true }); + const checker = program.getTypeChecker(); + const declaration = program.getSourceFile(path)!.statements.find(statement => ts.isClassDeclaration(statement) && statement.name?.text === 'ScalarFields')!; + const resolver = new SourceTypeResolver(checker, root); + resolver.resolve(checker.getTypeAtLocation(declaration), declaration); + output = renderSourceQuery(a_query, 'All.ts', new Map([['Listing', 'Listing.ts']]), '/api/all', resolver.models.get('ScalarFields')); + }); + for (const name of ['name', 'amount', 'enabled', 'timestamp', 'identifier', 'day', 'time', 'duration', 'status', 'label', + 'conceptName', 'conceptAmount', 'conceptEnabled', 'conceptTimestamp', 'conceptIdentifier', 'conceptDay', 'conceptTime', 'conceptDuration', 'conceptStatus']) { + it(`should retain static and instance sorting for ${name}`, () => { + output.should.contain(`readonly ${name} = new SortingActions('${name}')`); + output.should.contain(`this.${name} = new SortingActionsForQuery('${name}', query)`); + }); + } +}); diff --git a/Source/Tools/ProxyGenerator/renderSourceQuery.ts b/Source/Tools/ProxyGenerator/renderSourceQuery.ts index 18f557ea..485e6636 100644 --- a/Source/Tools/ProxyGenerator/renderSourceQuery.ts +++ b/Source/Tools/ProxyGenerator/renderSourceQuery.ts @@ -46,14 +46,17 @@ export function renderSourceQuery(operation: SourceOperation, path: string, dest if (!skipReactHooks && observable && array) hooks.push(` static useChangeStream(${args}getKey?: (item: ${model}) => unknown, sorting?: Sorting): ChangeSet<${model}> {\n return useChangeStream<${model}, ${name}${parameterType ? `, ${parameterType}` : ''}>(${name}, ${hookArguments}, getKey, sorting);\n }`); const when = observable ? 'ObservableQueryWhen' : 'QueryWhen'; if (!skipReactHooks) hooks.push(` static when(condition: boolean): ${when}<${name}, ${generic}> {\n return new ${when}<${name}, ${generic}>(${name}, condition);\n }`); + const scalarConstructors = ['String', 'Number', 'Boolean', 'Date', 'Guid', 'DateOnly', 'TimeOnly', 'TimeSpan']; + const sortableFields = modelDefinition?.fields.filter(field => !field.type.enumerable && + field.type.model !== field.type.constructor && scalarConstructors.includes(field.type.constructor)) ?? []; const coreImports = [base, 'QueryResultWithState', ...(validation ? ['QueryValidator'] : []), ...(array ? ['Sorting', 'Paging'] : []), - ...(array && modelDefinition?.fields.length ? ['SortingActions', observable ? 'SortingActionsForObservableQuery' : 'SortingActionsForQuery'] : []), + ...(array && sortableFields.length ? ['SortingActions', observable ? 'SortingActionsForObservableQuery' : 'SortingActionsForQuery'] : []), ...(observable && array ? ['ChangeSet'] : []), ...(operation.httpMethod ? ['QueryHttpMethod'] : [])]; const reactImports = [hook, suspense, ...(array ? [`${hook}WithPaging`, `${suspense}WithPaging`, 'SetPage', 'SetPageSize'] : []), ...(observable && array ? ['useChangeStream'] : []), ...(!observable ? ['PerformQuery'] : []), ...(array || !observable ? ['SetSorting'] : []), when]; const sortingMembers = array ? `\n get sortBy(): ${name}SortBy { return this._sortBy; }\n static get sortBy(): ${name}SortByWithoutQuery { return this._sortBy; }\n` : ''; const sortingAction = observable ? 'SortingActionsForObservableQuery' : 'SortingActionsForQuery'; - const sortClasses = array ? `class ${name}SortBy {\n${modelDefinition?.fields.map(field => ` readonly ${field.name}: ${sortingAction}<${result.text}>;`).join('\n') ?? ''}\n constructor(readonly query: ${name}) {\n${modelDefinition?.fields.map(field => ` this.${field.name} = new ${sortingAction}<${result.text}>(${quote(field.name)}, query);`).join('\n') ?? ''}\n }\n}\nclass ${name}SortByWithoutQuery {\n${modelDefinition?.fields.map(field => ` readonly ${field.name} = new SortingActions(${quote(field.name)});`).join('\n') ?? ''}\n}\n\n` : ''; + const sortClasses = array ? `class ${name}SortBy {\n${sortableFields.map(field => ` readonly ${field.name}: ${sortingAction}<${result.text}>;`).join('\n')}\n constructor(readonly query: ${name}) {\n${sortableFields.map(field => ` this.${field.name} = new ${sortingAction}<${result.text}>(${quote(field.name)}, query);`).join('\n')}\n }\n}\nclass ${name}SortByWithoutQuery {\n${sortableFields.map(field => ` readonly ${field.name} = new SortingActions(${quote(field.name)});`).join('\n')}\n}\n\n` : ''; return `import { ${coreImports.map(item => ['QueryResultWithState', 'Sorting', 'ChangeSet'].includes(item) ? `type ${item}` : item).join(', ')} } from '@cratis/arc/queries';\n${skipReactHooks ? '' : `import { ${reactImports.map(item => ['SetPage', 'SetPageSize', 'PerformQuery', 'SetSorting'].includes(item) ? `type ${item}` : item).join(', ')} } from '@cratis/arc.react/queries';\n`}import { ParameterDescriptor } from '@cratis/arc/reflection';\n${imports.join('\n')}${imports.length ? '\n' : ''}\n${sortClasses}${params}${validation}export class ${name} extends ${base}<${generic}> {\n readonly route: string = ${quote(route)};\n readonly queryName: string = ${quote([operation.namespace, operation.owner, operation.name].filter(Boolean).join('.'))};\n${validation ? ` readonly validation: QueryValidator = new ${name}Validator();\n` : ''} readonly treatWarningsAsErrors: boolean = ${operation.treatWarningsAsErrors ?? false};\n readonly roles: string[] = [${operation.roles.map(quote).join(', ')}];\n readonly defaultValue: ${result.text} = ${array ? '[]' : `{} as ${result.text}`};\n${array ? ` private readonly _sortBy: ${name}SortBy;\n private static readonly _sortBy: ${name}SortByWithoutQuery = new ${name}SortByWithoutQuery();\n` : ''}\n constructor() {\n super(${options.emitInterfaces && result.model ? 'Object' : result.constructor}, ${array});${array ? `\n this._sortBy = new ${name}SortBy(this);` : ''}${operation.httpMethod ? `\n this.setHttpMethod(QueryHttpMethod.${Object.entries(QueryHttpMethod).find(([, value]) => value === operation.httpMethod)?.[0]});` : ''}\n }\n\n get requiredRequestParameters(): string[] {\n return [\n${request}\n ];\n }\n\n readonly parameterDescriptors: ParameterDescriptor[] = [\n${descriptors}\n ];\n${fields ? `\n${fields}\n` : ''}${sortingMembers}${hooks.length ? `\n${hooks.join('\n\n')}\n` : ''}}\n`; } From d9f132c65540299f978bf9b66ddf003131d1f80b Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 20:19:28 +0200 Subject: [PATCH 2/4] Preserve complex sort helpers and scalar compatibility (#174) --- .../TypeScript/ProxyComparison/All.ts | 12 ++- .../TypeScript/ProxyComparison/Observe.ts | 12 ++- .../ProxyComparison/differences.json | 12 +-- ContractTests/ProxyComparison/raw.diff | 88 ++++++++++++------- ContractTests/ProxyComparison/runtime.mjs | 8 +- ContractTests/README.md | 4 +- .../proxy-generation/generated-code.md | 2 +- Documentation/proxy-generation/index.md | 2 +- Documentation/queries/model-bound/paging.md | 2 +- .../with_scalar_values.ts | 10 ++- Source/Core/queries/queryRendering.ts | 2 + .../with_bson_scalar_fields.ts | 44 ++++++++++ .../ProxyGenerator/SourceTypeResolver.ts | 35 +++++--- .../given/ScalarFields.ts | 6 ++ .../with_complex_fields.ts | 14 +-- .../with_scalar_fields.ts | 3 +- .../Tools/ProxyGenerator/renderSourceQuery.ts | 11 ++- 17 files changed, 189 insertions(+), 78 deletions(-) create mode 100644 Source/MongoDB/for_MongoReadModels/when_sorting_raw_documents/with_bson_scalar_fields.ts diff --git a/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/All.ts b/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/All.ts index 26f97c9c..757c6ff4 100644 --- a/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/All.ts +++ b/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/All.ts @@ -1,4 +1,4 @@ -// @generated by Cratis. Source: ProxyComparison.Listing.All. Time: 2026-10-02T17:43:19.5340000Z. Hash: 62295E125CE69F80C53E5AF3B37B8C40039C964B91A67F637997C0F3976C4440 +// @generated by Cratis. Source: ProxyComparison.Listing.All. Time: 2026-10-02T18:11:17.1450000Z. Hash: 60B4AB9724378EB1EBFF52DBF33EFA71B421A6633AB7316132F18F9B79CACFB1 /*--------------------------------------------------------------------------------------------- * **DO NOT EDIT** - This file is an automatically generated file. *--------------------------------------------------------------------------------------------*/ @@ -13,14 +13,24 @@ import { Listing } from './Listing'; class AllSortBy { readonly name: SortingActionsForQuery; + /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ + readonly detail: SortingActionsForQuery; + /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ + readonly notice: SortingActionsForQuery; readonly status: SortingActionsForQuery; constructor(readonly query: All) { this.name = new SortingActionsForQuery('name', query); + this.detail = new SortingActionsForQuery('detail', query); + this.notice = new SortingActionsForQuery('notice', query); this.status = new SortingActionsForQuery('status', query); } } class AllSortByWithoutQuery { readonly name = new SortingActions('name'); + /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ + readonly detail = new SortingActions('detail'); + /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ + readonly notice = new SortingActions('notice'); readonly status = new SortingActions('status'); } diff --git a/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/Observe.ts b/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/Observe.ts index 65363a1a..de774a09 100644 --- a/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/Observe.ts +++ b/ContractTests/ProxyComparison/Snapshots/TypeScript/ProxyComparison/Observe.ts @@ -1,4 +1,4 @@ -// @generated by Cratis. Source: ProxyComparison.Listing.Observe. Time: 2026-10-02T17:43:19.5370000Z. Hash: FD4DF8F82D643D1270565EB583603EB5114841B48DD81B7BC715DB918083AAB6 +// @generated by Cratis. Source: ProxyComparison.Listing.Observe. Time: 2026-10-02T18:11:17.1480000Z. Hash: 24D10616E40D92A9E4CF9E4758F280A66CD87BA96AE162492F39CDFF4C267099 /*--------------------------------------------------------------------------------------------- * **DO NOT EDIT** - This file is an automatically generated file. *--------------------------------------------------------------------------------------------*/ @@ -13,14 +13,24 @@ import { Listing } from './Listing'; class ObserveSortBy { readonly name: SortingActionsForObservableQuery; + /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ + readonly detail: SortingActionsForObservableQuery; + /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ + readonly notice: SortingActionsForObservableQuery; readonly status: SortingActionsForObservableQuery; constructor(readonly query: Observe) { this.name = new SortingActionsForObservableQuery('name', query); + this.detail = new SortingActionsForObservableQuery('detail', query); + this.notice = new SortingActionsForObservableQuery('notice', query); this.status = new SortingActionsForObservableQuery('status', query); } } class ObserveSortByWithoutQuery { readonly name = new SortingActions('name'); + /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ + readonly detail = new SortingActions('detail'); + /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ + readonly notice = new SortingActions('notice'); readonly status = new SortingActions('status'); } diff --git a/ContractTests/ProxyComparison/differences.json b/ContractTests/ProxyComparison/differences.json index 123c6171..8df6b130 100644 --- a/ContractTests/ProxyComparison/differences.json +++ b/ContractTests/ProxyComparison/differences.json @@ -19,12 +19,12 @@ "sorting": { "category": "knownDefect", "issue": "https://github.com/Cratis/Arc/issues/2998", - "description": ".NET 22.45.0 model-bound queries incorrectly emit sortBy.id from the query input rather than the response model. TypeScript's name/status helpers follow the documented read-model-field contract, as does .NET controller-based generation. The browser client sends the field unchanged; id is not a Listing field and fails on .NET IQueryable. This is a .NET defect, not an intentional API difference." + "description": ".NET 22.45.0 model-bound queries incorrectly emit sortBy.id from the query input rather than the response model. TypeScript's name/detail/notice/status helpers follow the documented read-model-field contract, as does .NET controller-based generation. The browser client sends the field unchanged; id is not a Listing field and fails on .NET IQueryable. This is a .NET defect, not an intentional API difference." }, "complex-sorting": { "category": "knownLimitation", "issue": "https://github.com/Cratis/Arc.TypeScript/issues/174", - "description": "The .NET controller-based generator still emits helpers for complex result fields, which throw on .NET IQueryable; its model-bound snapshot's query-parameter defect masks that limitation. TypeScript emits only scalar helpers (name/status here), omits detail/notice, and rejects in-memory sorting on these complex fields with the existing malformedRequest validation result." + "description": "The .NET controller-based generator still emits helpers for complex result fields, which throw on .NET IQueryable; its model-bound snapshot's query-parameter defect masks that limitation. TypeScript retains detail/notice helpers for compatibility but deprecates them for removal in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177), and rejects in-memory sorting on these complex fields with the existing malformedRequest validation result. Database providers retain their own ordering; scalar helpers are unchanged." }, "enum-names": { "category": "intentional", @@ -38,8 +38,8 @@ "limitations": ["complex-sorting"], "files": { "ProxyComparison/All.ts": { - "pairSha256": "f90f7636c86fdda4f832c5d2e662b474dd94227e287f9168931f3204d7db959c", - "reasons": ["layout", "imports", "query-source", "sorting"] + "pairSha256": "d8e61bfa0dd8347d4b70abe98d60161de95cf1307046d02626e8bf2bd5c10592", + "reasons": ["layout", "imports", "query-source", "sorting", "complex-sorting"] }, "ProxyComparison/Detail.ts": { "pairSha256": "80d080af34fba46f349ebfc4821d5d6f3a82eeb57f48787bf7a1fc228ee29dfd", @@ -50,8 +50,8 @@ "reasons": ["layout"] }, "ProxyComparison/Observe.ts": { - "pairSha256": "5993144694d6ca10f84d56ec3c7870f2b788757d00c2f02da942d58af92479f7", - "reasons": ["layout", "imports", "query-source", "sorting"] + "pairSha256": "3cf3ce2d59571f12b9164791985f40f0a7bacbddf4f503955cd7f4547ccbdd76", + "reasons": ["layout", "imports", "query-source", "sorting", "complex-sorting"] }, "ProxyComparison/Register.ts": { "pairSha256": "a624987ea1833732cbc7169c96c1ca68f7159bd74bb44e3014a1f010e11d07dc", diff --git a/ContractTests/ProxyComparison/raw.diff b/ContractTests/ProxyComparison/raw.diff index a2ec0d43..58c2aabd 100644 --- a/ContractTests/ProxyComparison/raw.diff +++ b/ContractTests/ProxyComparison/raw.diff @@ -1,46 +1,56 @@ --- DotNET/ProxyComparison/All.ts +++ TypeScript/ProxyComparison/All.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T17:43:19.2365230Z. Hash: 0E155A2217FA241EB7A3F167FC3C62571830874898B0B657D2366B8EA0B3AE01 -+// @generated by Cratis. Source: ProxyComparison.Listing.All. Time: 2026-10-02T17:43:19.5340000Z. Hash: 62295E125CE69F80C53E5AF3B37B8C40039C964B91A67F637997C0F3976C4440 +-// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T18:11:16.8531660Z. Hash: 0E155A2217FA241EB7A3F167FC3C62571830874898B0B657D2366B8EA0B3AE01 ++// @generated by Cratis. Source: ProxyComparison.Listing.All. Time: 2026-10-02T18:11:17.1450000Z. Hash: 60B4AB9724378EB1EBFF52DBF33EFA71B421A6633AB7316132F18F9B79CACFB1 @@ -8,2 +8,2 @@ -import { QueryFor, QueryResultWithState, Sorting, SortingActions, SortingActionsForQuery, Paging } from '@cratis/arc/queries'; -import { useQuery, useQueryWithPaging, useSuspenseQuery, useSuspenseQueryWithPaging, PerformQuery, SetSorting, SetPage, SetPageSize, QueryWhen } from '@cratis/arc.react/queries'; +import { QueryFor, type QueryResultWithState, type Sorting, Paging, SortingActions, SortingActionsForQuery } from '@cratis/arc/queries'; +import { useQuery, useSuspenseQuery, useQueryWithPaging, useSuspenseQueryWithPaging, type SetPage, type SetPageSize, type PerformQuery, type SetSorting, QueryWhen } from '@cratis/arc.react/queries'; -@@ -15,2 +15,2 @@ +@@ -15,2 +15,6 @@ - private _id: SortingActionsForQuery; - + readonly name: SortingActionsForQuery; ++ /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ ++ readonly detail: SortingActionsForQuery; ++ /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ ++ readonly notice: SortingActionsForQuery; + readonly status: SortingActionsForQuery; -@@ -18 +18,2 @@ +@@ -18 +22,4 @@ - this._id = new SortingActionsForQuery('id', query); + this.name = new SortingActionsForQuery('name', query); ++ this.detail = new SortingActionsForQuery('detail', query); ++ this.notice = new SortingActionsForQuery('notice', query); + this.status = new SortingActionsForQuery('status', query); -@@ -20,4 +20,0 @@ +@@ -20,4 +26,0 @@ - - get id(): SortingActionsForQuery { - return this._id; - } -@@ -25 +21,0 @@ +@@ -25 +27,0 @@ - -@@ -27,5 +23,2 @@ +@@ -27,5 +29,6 @@ - private _id: SortingActions = new SortingActions('id'); - - get id(): SortingActions { - return this._id; - } + readonly name = new SortingActions('name'); ++ /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ ++ readonly detail = new SortingActions('detail'); ++ /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ ++ readonly notice = new SortingActions('notice'); + readonly status = new SortingActions('status'); -@@ -38 +30,0 @@ +@@ -38 +40,0 @@ - -@@ -65,3 +57,2 @@ +@@ -65,3 +67,2 @@ - get sortBy(): AllSortBy { - return this._sortBy; - } + get sortBy(): AllSortBy { return this._sortBy; } + static get sortBy(): AllSortByWithoutQuery { return this._sortBy; } -@@ -69,4 +59,0 @@ +@@ -69,4 +69,0 @@ - static get sortBy(): AllSortByWithoutQuery { - return this._sortBy; - } @@ -48,8 +58,8 @@ --- DotNET/ProxyComparison/Detail.ts +++ TypeScript/ProxyComparison/Detail.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Detail. Time: 2026-10-02T17:43:19.2408080Z. Hash: 870244FDEBCE32A475902C261DDFF3AEADEFBA1A0B20D5FA623D6D1BD7473F94 -+// @generated by Cratis. Source: ProxyComparison.Detail. Time: 2026-10-02T17:43:19.5360000Z. Hash: EB083CE16EB7845B06C37F70EA02EC4D9BDB54179F8A3E64467299B7702BA917 +-// @generated by Cratis. Source: ProxyComparison.Detail. Time: 2026-10-02T18:11:16.8573780Z. Hash: 870244FDEBCE32A475902C261DDFF3AEADEFBA1A0B20D5FA623D6D1BD7473F94 ++// @generated by Cratis. Source: ProxyComparison.Detail. Time: 2026-10-02T18:11:17.1470000Z. Hash: EB083CE16EB7845B06C37F70EA02EC4D9BDB54179F8A3E64467299B7702BA917 @@ -8,2 +8 @@ -import { field } from '@cratis/fundamentals'; -import { Guid } from '@cratis/fundamentals'; @@ -59,8 +69,8 @@ --- DotNET/ProxyComparison/Listing.ts +++ TypeScript/ProxyComparison/Listing.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T17:43:19.2408080Z. Hash: 5E26E7FA2DA1134B949B271ED9019A0867B1FE2121E2BE76E9D4904F1F537957 -+// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T17:43:19.5360000Z. Hash: EAAC3EB0B31A551AE2BC36EFD4D8F8EC34FCB11785A63CB982B2BEA7F1ACFBF5 +-// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T18:11:16.8573780Z. Hash: 5E26E7FA2DA1134B949B271ED9019A0867B1FE2121E2BE76E9D4904F1F537957 ++// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T18:11:17.1470000Z. Hash: EAAC3EB0B31A551AE2BC36EFD4D8F8EC34FCB11785A63CB982B2BEA7F1ACFBF5 @@ -15,0 +16 @@ + @@ -17,0 +19 @@ @@ -70,53 +80,63 @@ --- DotNET/ProxyComparison/Notice.ts +++ TypeScript/ProxyComparison/Notice.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Notice. Time: 2026-10-02T17:43:19.2408080Z. Hash: C7BFC73EC13766A0F0B09DA0E21C97DFBF0018E89CB55A81E1C560094F91BA4E -+// @generated by Cratis. Source: ProxyComparison.Notice. Time: 2026-10-02T17:43:19.5370000Z. Hash: C7BFC73EC13766A0F0B09DA0E21C97DFBF0018E89CB55A81E1C560094F91BA4E +-// @generated by Cratis. Source: ProxyComparison.Notice. Time: 2026-10-02T18:11:16.8573780Z. Hash: C7BFC73EC13766A0F0B09DA0E21C97DFBF0018E89CB55A81E1C560094F91BA4E ++// @generated by Cratis. Source: ProxyComparison.Notice. Time: 2026-10-02T18:11:17.1480000Z. Hash: C7BFC73EC13766A0F0B09DA0E21C97DFBF0018E89CB55A81E1C560094F91BA4E --- DotNET/ProxyComparison/Observe.ts +++ TypeScript/ProxyComparison/Observe.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T17:43:19.2380900Z. Hash: 37AEFC99489891A80968EA738258F7C8759C9732BDCB0FD063B4C85F15651914 -+// @generated by Cratis. Source: ProxyComparison.Listing.Observe. Time: 2026-10-02T17:43:19.5370000Z. Hash: FD4DF8F82D643D1270565EB583603EB5114841B48DD81B7BC715DB918083AAB6 +-// @generated by Cratis. Source: ProxyComparison.Listing. Time: 2026-10-02T18:11:16.8547150Z. Hash: 37AEFC99489891A80968EA738258F7C8759C9732BDCB0FD063B4C85F15651914 ++// @generated by Cratis. Source: ProxyComparison.Listing.Observe. Time: 2026-10-02T18:11:17.1480000Z. Hash: 24D10616E40D92A9E4CF9E4758F280A66CD87BA96AE162492F39CDFF4C267099 @@ -8,2 +8,2 @@ -import { ObservableQueryFor, QueryResultWithState, Sorting, SortingActions, SortingActionsForObservableQuery, Paging, ChangeSet } from '@cratis/arc/queries'; -import { useObservableQuery, useObservableQueryWithPaging, useSuspenseObservableQuery, useSuspenseObservableQueryWithPaging, useChangeStream, SetSorting, SetPage, SetPageSize, ObservableQueryWhen } from '@cratis/arc.react/queries'; +import { ObservableQueryFor, type QueryResultWithState, type Sorting, Paging, SortingActions, SortingActionsForObservableQuery, type ChangeSet } from '@cratis/arc/queries'; +import { useObservableQuery, useSuspenseObservableQuery, useObservableQueryWithPaging, useSuspenseObservableQueryWithPaging, type SetPage, type SetPageSize, useChangeStream, type SetSorting, ObservableQueryWhen } from '@cratis/arc.react/queries'; -@@ -15,2 +15,2 @@ +@@ -15,2 +15,6 @@ - private _id: SortingActionsForObservableQuery; - + readonly name: SortingActionsForObservableQuery; ++ /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ ++ readonly detail: SortingActionsForObservableQuery; ++ /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ ++ readonly notice: SortingActionsForObservableQuery; + readonly status: SortingActionsForObservableQuery; -@@ -18 +18,2 @@ +@@ -18 +22,4 @@ - this._id = new SortingActionsForObservableQuery('id', query); + this.name = new SortingActionsForObservableQuery('name', query); ++ this.detail = new SortingActionsForObservableQuery('detail', query); ++ this.notice = new SortingActionsForObservableQuery('notice', query); + this.status = new SortingActionsForObservableQuery('status', query); -@@ -20,4 +20,0 @@ +@@ -20,4 +26,0 @@ - - get id(): SortingActionsForObservableQuery { - return this._id; - } -@@ -25 +21,0 @@ +@@ -25 +27,0 @@ - -@@ -27,5 +23,2 @@ +@@ -27,5 +29,6 @@ - private _id: SortingActions = new SortingActions('id'); - - get id(): SortingActions { - return this._id; - } + readonly name = new SortingActions('name'); ++ /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ ++ readonly detail = new SortingActions('detail'); ++ /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */ ++ readonly notice = new SortingActions('notice'); + readonly status = new SortingActions('status'); -@@ -35 +27,0 @@ +@@ -35 +37,0 @@ - -@@ -39 +30,0 @@ +@@ -39 +40,0 @@ - -@@ -66,3 +57,2 @@ +@@ -66,3 +67,2 @@ - get sortBy(): ObserveSortBy { - return this._sortBy; - } + get sortBy(): ObserveSortBy { return this._sortBy; } + static get sortBy(): ObserveSortByWithoutQuery { return this._sortBy; } -@@ -70,4 +59,0 @@ +@@ -70,4 +69,0 @@ - static get sortBy(): ObserveSortByWithoutQuery { - return this._sortBy; - } @@ -124,8 +144,8 @@ --- DotNET/ProxyComparison/Register.ts +++ TypeScript/ProxyComparison/Register.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Register. Time: 2026-10-02T17:43:19.2226420Z. Hash: 1D6FE14590B481D913125C1B2BE21D724F94D8AABAB561D47058830A6CADC8E2 -+// @generated by Cratis. Source: ProxyComparison.Register. Time: 2026-10-02T17:43:19.5380000Z. Hash: 9213A3CE667705E870E1AA87B3482F81F37362E3416D33C9BE11229F6762AC41 +-// @generated by Cratis. Source: ProxyComparison.Register. Time: 2026-10-02T18:11:16.8399350Z. Hash: 1D6FE14590B481D913125C1B2BE21D724F94D8AABAB561D47058830A6CADC8E2 ++// @generated by Cratis. Source: ProxyComparison.Register. Time: 2026-10-02T18:11:17.1490000Z. Hash: 9213A3CE667705E870E1AA87B3482F81F37362E3416D33C9BE11229F6762AC41 @@ -10 +10 @@ -import { useCommand, SetCommandValues, ClearCommandValues } from '@cratis/arc.react/commands'; +import { useCommand, type SetCommandValues, type ClearCommandValues } from '@cratis/arc.react/commands'; @@ -145,8 +165,8 @@ --- DotNET/ProxyComparison/Status.ts +++ TypeScript/ProxyComparison/Status.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.Status. Time: 2026-10-02T17:43:19.2427540Z. Hash: 2021C7DF28DABBEC728CC5649211601AC22C9E7848AABC7C394B1DBBE4D65D34 -+// @generated by Cratis. Source: ProxyComparison.Status. Time: 2026-10-02T17:43:19.5380000Z. Hash: D6BE2F44AEE3F50AB357446E1374EEC78C1ABC0A6A249AADA64C9DC7327939E2 +-// @generated by Cratis. Source: ProxyComparison.Status. Time: 2026-10-02T18:11:16.8592410Z. Hash: 2021C7DF28DABBEC728CC5649211601AC22C9E7848AABC7C394B1DBBE4D65D34 ++// @generated by Cratis. Source: ProxyComparison.Status. Time: 2026-10-02T18:11:17.1490000Z. Hash: D6BE2F44AEE3F50AB357446E1374EEC78C1ABC0A6A249AADA64C9DC7327939E2 @@ -8,2 +8,2 @@ - draft = 0, - published = 1, @@ -155,12 +175,12 @@ --- DotNET/ProxyComparison/UrgentNotice.ts +++ TypeScript/ProxyComparison/UrgentNotice.ts @@ -1 +1 @@ --// @generated by Cratis. Source: ProxyComparison.UrgentNotice. Time: 2026-10-02T17:43:19.2408080Z. Hash: B6DFD0F8172730462EE16C33D07A74E01C2C446E0DC8E33AF2A92DF22C4B1738 -+// @generated by Cratis. Source: ProxyComparison.UrgentNotice. Time: 2026-10-02T17:43:19.5380000Z. Hash: E68232937A2D329A758BE7A911099151B8188944D1CDE443CF39C4FF54CB4C7F +-// @generated by Cratis. Source: ProxyComparison.UrgentNotice. Time: 2026-10-02T18:11:16.8573780Z. Hash: B6DFD0F8172730462EE16C33D07A74E01C2C446E0DC8E33AF2A92DF22C4B1738 ++// @generated by Cratis. Source: ProxyComparison.UrgentNotice. Time: 2026-10-02T18:11:17.1490000Z. Hash: E68232937A2D329A758BE7A911099151B8188944D1CDE443CF39C4FF54CB4C7F @@ -8 +8 @@ -import { field, derivedType } from '@cratis/fundamentals'; +import { derivedType, field } from '@cratis/fundamentals'; --- DotNET/ProxyComparison/index.ts +++ TypeScript/ProxyComparison/index.ts @@ -1,0 +1 @@ -+// @generated by Cratis. Source: . Time: 2026-10-02T17:43:19.5390000Z. Hash: 47EA7CF2735A3A1C1FD910D8F0AD46FA94CC8FA45699573458B54238BD829218 ++// @generated by Cratis. Source: . Time: 2026-10-02T18:11:17.1500000Z. Hash: 47EA7CF2735A3A1C1FD910D8F0AD46FA94CC8FA45699573458B54238BD829218 diff --git a/ContractTests/ProxyComparison/runtime.mjs b/ContractTests/ProxyComparison/runtime.mjs index fc23be72..1c1df21b 100644 --- a/ContractTests/ProxyComparison/runtime.mjs +++ b/ContractTests/ProxyComparison/runtime.mjs @@ -82,13 +82,9 @@ try { assert.equal(model.status, 1); assert.deepEqual(JSON.parse(JsonSerializer.serialize(model)), wire); // Arc#2998: .NET's query-parameter helper is a defect, not an intentional API difference. - const sortNames = family === 'DotNET' ? ['id'] : ['name', 'status']; + const sortNames = family === 'DotNET' ? ['id'] : ['name', 'detail', 'notice', 'status']; for (const name of sortNames) { assert.ok(query.sortBy[name]); assert.ok(Query.sortBy[name]); } assert.equal(query.sortBy[family === 'DotNET' ? 'name' : 'id'], undefined); - if (family === 'TypeScript') for (const name of ['detail', 'notice']) { - assert.equal(query.sortBy[name], undefined); - assert.equal(Query.sortBy[name], undefined); - } for (const hook of ['use', 'useWithPaging', 'useSuspense', 'useSuspenseWithPaging', 'when']) assert.equal(typeof Query[hook], 'function'); // Send the generated Sorting through the real browser client and TS HTTP query pipeline. @@ -113,7 +109,7 @@ try { assert.equal(status, 200, JSON.stringify(control)); assert.equal(control.isSuccess, true); assert.deepEqual(control.data.map(row => row.name), ['alpha', 'bravo', 'charlie']); - for (const name of [...sortNames, 'detail', 'notice']) { + for (const name of new Set([...sortNames, 'detail', 'notice'])) { for (const direction of ['ascending', 'descending']) { const sorting = (Query.sortBy[name] ?? new SortingActions(name))[direction]; assert.equal(sorting.field, name); diff --git a/ContractTests/README.md b/ContractTests/README.md index 4f3eccce..c4488684 100644 --- a/ContractTests/README.md +++ b/ContractTests/README.md @@ -43,9 +43,9 @@ Review the inventory alongside `raw.diff`. Entries distinguish `intentional` dif Known sorting behavior is recorded separately: - **Known .NET defect — sorting API:** .NET 22.45.0 model-bound generation emits `sortBy.id` from the query parameter instead of the response model. TypeScript follows the documented read-model-field contract, as does .NET controller-based generation. The client sends the field unchanged; `id` is not a `Listing` field and fails on .NET `IQueryable`. This is not an intentional API difference ([Arc#2998](https://github.com/Cratis/Arc/issues/2998)). -- **Known .NET limitation — complex fields:** .NET result-oriented helpers still include fields such as `detail` and `notice`, which throw on .NET `IQueryable`; TypeScript omits those helpers and rejects complex-field in-memory sorting with `malformedRequest`. The model-bound .NET defect masks this limitation in its captured helpers ([Arc.TypeScript#174](https://github.com/Cratis/Arc.TypeScript/issues/174)). Use scalar fields such as `name` and `status` instead. +- **Known .NET limitation — complex fields:** .NET result-oriented helpers still include fields such as `detail` and `notice`, which throw on .NET `IQueryable`; TypeScript keeps those helpers for compatibility but deprecates them until the [next major release](https://github.com/Cratis/Arc.TypeScript/issues/177), and rejects complex-field in-memory sorting with `malformedRequest` without changing database-provider ordering. The model-bound .NET defect masks this limitation in its captured helpers ([Arc.TypeScript#174](https://github.com/Cratis/Arc.TypeScript/issues/174)). Use scalar fields such as `name` and `status` instead. -`Notice.ts` agrees after timestamp normalization. Routes, parameter/property descriptors, GUID/date/nested/derived hydration, numeric enum values, portable validation boundaries/messages and all command/query/observable hook signatures are asserted against the pinned client. Hydration uses controlled GET responses. Sorting checks send generated ascending/descending `Sorting` values through the client to the real TypeScript HTTP pipeline over three distinct rows, checking scalar order and invalid-field rejection, plus manually constructed complex-field sorts that TypeScript rejects. Full regeneration additionally starts the locked .NET fixture: its `All` query returns `IQueryable` over the same rows, exposing invalid-field and complex-field exceptions as HTTP 500 results. Positive scalar controls distinguish those failures from an unavailable route or server. These known-behavior assertions must change when the linked issues are fixed; offline runs explicitly skip live .NET checks. +`Notice.ts` agrees after timestamp normalization. Routes, parameter/property descriptors, GUID/date/nested/derived hydration, numeric enum values, portable validation boundaries/messages and all command/query/observable hook signatures are asserted against the pinned client. Hydration uses controlled GET responses. Sorting checks send generated ascending/descending `Sorting` values through the client to the real TypeScript HTTP pipeline over three distinct rows, checking scalar order and invalid-field rejection, plus deprecated complex-field helpers that TypeScript's in-memory pipeline rejects. Full regeneration additionally starts the locked .NET fixture: its `All` query returns `IQueryable` over the same rows, exposing invalid-field and complex-field exceptions as HTTP 500 results. Positive scalar controls distinguish those failures from an unavailable route or server. These known-behavior assertions must change when the linked issues are fixed; offline runs explicitly skip live .NET checks. No React tree is mounted. Each output runs in a separate process so derived-type registration cannot leak from the other. A strict consumer includes negative type checks; bundling for the Node runtime resolves extensionless imports without modifying the captured sources. diff --git a/Documentation/proxy-generation/generated-code.md b/Documentation/proxy-generation/generated-code.md index d5e518b0..6d0ead22 100644 --- a/Documentation/proxy-generation/generated-code.md +++ b/Documentation/proxy-generation/generated-code.md @@ -77,7 +77,7 @@ export class BooksForAuthor extends ObservableQueryFor` for a snapshot, `ObservableQueryFor` when the method returns an observable source | | `queryName` | The fully qualified name the server uses for hub subscriptions | | `defaultValue` | What `result.data` holds before the first answer: `[]` for a list, `{} as TModel` for a single model | -| `sortBy` | For list results, one sort helper per scalar model field, as a static and an instance property | +| `sortBy` | For list results, one sort helper per model field, as a static and an instance property; complex-field helpers are deprecated for removal in the next major release | | `parameterDescriptors`, `requiredRequestParameters` | The arguments, so the client can wait until required ones are set | | `validation` | A `QueryValidator` when the query's arguments have client-safe rules | diff --git a/Documentation/proxy-generation/index.md b/Documentation/proxy-generation/index.md index 6879174d..de0da5b1 100644 --- a/Documentation/proxy-generation/index.md +++ b/Documentation/proxy-generation/index.md @@ -48,7 +48,7 @@ The repository's [paired-generator comparison](https://github.com/Cratis/Arc.Typ This is a compatibility check, not a byte-equality promise. Intentional differences include type-only imports, source enum member names (rather than .NET's camel-cased names), formatting and provenance. The pinned .NET model-bound generator's query-parameter sorting helpers are a [known defect](https://github.com/Cratis/Arc/issues/2998), not an intentional API difference: TypeScript follows the documented contract that `sortBy` names a read-model field. -TypeScript now emits `sortBy` helpers only for scalar fields (string, number, boolean, Date, Guid, DateOnly, TimeOnly, TimeSpan, enums, and concepts over those), omitting complex fields such as `detail` and `notice`; .NET's complex-field helpers remain a [known limitation](https://github.com/Cratis/Arc.TypeScript/issues/174). +TypeScript retains but deprecates `sortBy` helpers for record, nested-model, array, map, and polymorphic fields until the [next major release](https://github.com/Cratis/Arc.TypeScript/issues/177): in-memory sorting rejects them, database providers apply their own ordering, and you should sort on a scalar field instead; scalar helpers (string, number, boolean, Date, Guid, DateOnly, TimeOnly, TimeSpan, enums, and concepts over those) are unchanged. The inventory separates intentional differences, known defects and known limitations. Regeneration rejects unreviewed bytes, normalizing only the generated header's timestamp; offline checks also reject changed C# fixture or .NET option fingerprints. See the [contract-test guide](https://github.com/Cratis/Arc.TypeScript/blob/main/ContractTests/README.md#compare-proxy-generators) for the inventory, behavioral sorting checks and recapture instructions. diff --git a/Documentation/queries/model-bound/paging.md b/Documentation/queries/model-bound/paging.md index 3f3443bf..4df9e450 100644 --- a/Documentation/queries/model-bound/paging.md +++ b/Documentation/queries/model-bound/paging.md @@ -54,7 +54,7 @@ Invalid directions answer 400 with `malformedRequest` and the `sortDirection` (G In-memory sorting requires the field on every item, or the request answers 400. Dates compare by time, numbers and bigints numerically, `false` before `true`, and `null` or `undefined` before any value in ascending order. Other scalar values compare as strings with `localeCompare`, which is not .NET invariant-culture collation; sort in the data source when a stable cross-platform order matters. -In-memory array sorting rejects present, non-null complex values with the existing `malformedRequest` validation result (HTTP 400 for snapshots; a validation result if streaming has already started), without changing provider-owned sorting. +In-memory array sorting rejects present, non-null complex values with the existing `malformedRequest` validation result (HTTP 400 for snapshots; a validation result if streaming has already started), without changing provider-owned sorting; raw BSON scalars remain accepted with string comparison. A query that returns something other than an array answers 400 when the request asks for paging or sorting. diff --git a/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_scalar_values.ts b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_scalar_values.ts index f8db640a..a3e9d728 100644 --- a/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_scalar_values.ts +++ b/Source/Core/queries/for_queryRendering/when_sorting_in_memory/with_scalar_values.ts @@ -21,9 +21,8 @@ const cases = [ { kind: 'mixed scalar kinds', values: [2, '10', 1], order: [2, 1, 0] }, { kind: 'existing bigint comparison', values: [10n, 2n], order: [1, 0] } ]; -const concepts = cases.filter(item => !['null and undefined', 'mixed scalar kinds', 'existing bigint comparison', 'number'].includes(item.kind)) - .map(item => ({ ...item, kind: `concept over ${item.kind}`, values: item.values.map(value => new ScalarConcept(value)) })); -concepts.push({ kind: 'concept over number using the existing string fallback', values: [new ScalarConcept(2), new ScalarConcept(10)], order: [1, 0] }); +const concepts = cases.filter(item => !['null and undefined', 'mixed scalar kinds', 'existing bigint comparison'].includes(item.kind)) + .map(item => ({ kind: `concept over ${item.kind}`, values: item.values.map(value => new ScalarConcept(value)), order: undefined })); describe.each([...cases, ...concepts])('when sorting in memory with $kind values', ({ values, order }) => { let result: QueryResult; @@ -32,8 +31,11 @@ describe.each([...cases, ...concepts])('when sorting in memory with $kind values tenantId: 'test', correlationId: 'test', principal: undefined, signal: new AbortController().signal, allowedSeverity: Severity.Warning }, { sorting: { field: 'value', direction: SortDirection.Ascending } }); }); - it('should preserve the existing comparison order', () => { + it('should accept the scalar values', () => { result.isSuccess.should.equal(true); + }); + // Raw concept ordering is tracked separately in https://github.com/Cratis/Arc.TypeScript/issues/176. + if (order) it('should preserve the scalar comparison order', () => { (result.data as { index: number }[]).map(item => item.index).should.deep.equal(order); }); }); diff --git a/Source/Core/queries/queryRendering.ts b/Source/Core/queries/queryRendering.ts index 8db8091d..3f6d2a36 100644 --- a/Source/Core/queries/queryRendering.ts +++ b/Source/Core/queries/queryRendering.ts @@ -21,6 +21,8 @@ function safeOffset(page: number, size: number): number | undefined { function isScalarSortValue(value: unknown): boolean { if (value instanceof ConceptAs) return isScalarSortValue(value.value); + if (value !== null && typeof value === 'object' && '_bsontype' in value && typeof value._bsontype === 'string' && + ['ObjectId', 'Decimal128', 'Long', 'Int32', 'Double', 'Binary', 'UUID', 'Timestamp'].includes(value._bsontype)) return true; return value == null || ['string', 'number', 'boolean', 'bigint'].includes(typeof value) || value instanceof Date || value instanceof Guid || value instanceof DateOnly || value instanceof TimeOnly || value instanceof TimeSpan; diff --git a/Source/MongoDB/for_MongoReadModels/when_sorting_raw_documents/with_bson_scalar_fields.ts b/Source/MongoDB/for_MongoReadModels/when_sorting_raw_documents/with_bson_scalar_fields.ts new file mode 100644 index 00000000..38c20319 --- /dev/null +++ b/Source/MongoDB/for_MongoReadModels/when_sorting_raw_documents/with_bson_scalar_fields.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ArcServer, defineQuery, ServiceLifetime, serviceToken, SortDirection, type ReadModelInterceptor } from '@cratis/arc.core'; +import { Decimal128, ObjectId } from 'mongodb'; +import { beforeEach, describe, it, should } from 'vitest'; +import { z } from 'zod'; +import { a_typed_tenant_collection, Person } from '../given/a_typed_tenant_collection.js'; +import { executionContext } from '../given/a_tenant_collection.js'; + +should(); +describe.each(['identifier', 'amount'])('when sorting raw documents by a BSON scalar field %s', field => { + let result: Awaited>; + let intercepted: string[]; + beforeEach(async () => { + const context = new a_typed_tenant_collection(); + context.toArray.resolves([ + { ...context.doc, _id: 'second', identifier: new ObjectId('bbbbbbbbbbbbbbbbbbbbbbbb'), amount: Decimal128.fromString('2.5') }, + { ...context.doc, _id: 'first', identifier: new ObjectId('aaaaaaaaaaaaaaaaaaaaaaaa'), amount: Decimal128.fromString('1.5') } + ]); + intercepted = []; + const token = serviceToken('raw BSON scalar interceptor'); + const server = new ArcServer({ + introspection: { enabled: false }, + services: [{ token, lifetime: ServiceLifetime.Scoped, factory: (): ReadModelInterceptor => ({ + model: Person, + intercept: model => model, + interceptRawDocument: (document, provenance) => { intercepted.push(provenance.subject); return document; } + }) }], + readModelInterceptors: [token], + queries: [defineQuery({ name: 'People', schema: z.object({}), + perform: (_, execution) => context.typed.find(execution, 'alice') })] + }); + try { + result = await server.performQuery('People', {}, executionContext('tenant-a'), { + sorting: { field, direction: SortDirection.Ascending } + }); + } finally { await server.dispose(); await context.client.close(); } + }); + it('should preserve raw BSON string comparison before interception', () => { + result.isSuccess.should.equal(true); + (result.data as { _id: string }[]).map(row => row._id).should.deep.equal(['first', 'second']); + intercepted.should.deep.equal(['first', 'second']); + }); +}); diff --git a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts index e49383b3..72d2243a 100644 --- a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts +++ b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts @@ -71,17 +71,8 @@ export class SourceTypeResolver { if (resolved.enumerable || resolved.void || resolved.nullable) return this.unsupported(type, location); return { ...resolved, text: resolved.text.includes(' | ') ? `(${resolved.text})[]` : `${resolved.text}[]`, enumerable: true, nullable }; } - if (type.getBaseTypes()?.some(base => isTypeFrom(this.checker, base, 'ConceptAs', '@cratis/fundamentals')) || - isTypeFrom(this.checker, type, 'ConceptAs', '@cratis/fundamentals')) { - const base = type.getBaseTypes()?.find(candidate => isTypeFrom(this.checker, candidate, 'ConceptAs', '@cratis/fundamentals')); - const argument = this.checker.getTypeArguments(type as ts.TypeReference)[0] ?? - (base && this.checker.getTypeArguments(base as ts.TypeReference)[0]); - if (argument) { - const declaration = symbol?.declarations?.find(ts.isClassDeclaration); - if (declaration) this.contribute(declaration); - return this.resolve(argument, location, optional); - } - } + const conceptValue = this.conceptValueType(type, location); + if (conceptValue) return this.resolve(conceptValue, location, optional); const declaration = symbol?.declarations?.find(ts.isClassDeclaration); if (symbol?.declarations?.some(ts.isEnumDeclaration)) return this.resolveEnum(symbol, type, location, nullable); if (declaration && name && !declaration.getSourceFile().isDeclarationFile) { @@ -136,6 +127,28 @@ export class SourceTypeResolver { } return this.unsupported(type, location); } + private conceptValueType(type: ts.Type, location: ts.Node): ts.Type | undefined { + const pending = [type]; + const visited = new Set(); + while (pending.length) { + const candidate = pending.pop()!; + if (visited.has(candidate)) continue; + visited.add(candidate); + if (isTypeFrom(this.checker, candidate, 'ConceptAs', '@cratis/fundamentals')) { + for (const ancestor of visited) { + const declaration = ancestor.getSymbol()?.declarations?.find(ts.isClassDeclaration); + if (declaration && !declaration.getSourceFile().isDeclarationFile) this.contribute(declaration); + } + // Read from the original instantiated type so generic intermediate bases retain their substitutions. + const value = this.checker.getPropertyOfType(type, 'value'); + return value && this.checker.getTypeOfSymbolAtLocation(value, location); + } + const reference = candidate.flags & ts.TypeFlags.Object && (candidate as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference + ? candidate as ts.TypeReference : undefined; + pending.push(...candidate.getBaseTypes() ?? reference?.target.getBaseTypes() ?? []); + } + return undefined; + } private resolveEnum(symbol: ts.Symbol, type: ts.Type, location: ts.Node, nullable: boolean): SourceType { const declaration = symbol.declarations?.find(ts.isEnumDeclaration); if (!declaration) return this.unsupported(type, location); diff --git a/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/ScalarFields.ts b/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/ScalarFields.ts index b2a5a9f0..8b2f5c54 100644 --- a/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/ScalarFields.ts +++ b/Source/Tools/ProxyGenerator/for_renderSourceQuery/given/ScalarFields.ts @@ -5,6 +5,10 @@ import { ConceptAs, DateOnly, field, Guid, TimeOnly, TimeSpan } from '@cratis/fu export enum Status { Draft, Published } export enum Label { First = 'first', Last = 'last' } export class Name extends ConceptAs {} +export class DerivedName extends Name {} +export class GenericConcept extends ConceptAs {} +export class GenericIntermediate extends GenericConcept {} +export class GenericName extends GenericIntermediate {} export class Amount extends ConceptAs {} export class Enabled extends ConceptAs {} export class Timestamp extends ConceptAs {} @@ -26,6 +30,8 @@ export class ScalarFields { @field(Number) status!: Status; @field(String) label!: Label; @field(Name) conceptName!: Name; + @field(DerivedName) derivedName!: DerivedName; + @field(GenericName) genericName!: GenericName; @field(Amount) conceptAmount!: Amount; @field(Enabled) conceptEnabled!: Enabled; @field(Timestamp) conceptTimestamp!: Timestamp; diff --git a/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_complex_fields.ts b/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_complex_fields.ts index 621cbe51..f795a1bd 100644 --- a/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_complex_fields.ts +++ b/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_complex_fields.ts @@ -24,14 +24,18 @@ describe('when emitting sort helpers with complex fields', () => { 'All.ts', new Map([['Listing', 'Listing.ts']]), '/api/all', { name: 'Listing', namespace: '', kind: 'model', fields })); }); for (const { name } of fields) { - it(`should omit static and instance sorting for ${name}`, () => { + it(`should preserve and deprecate static and instance sorting for ${name}`, () => { for (const output of outputs) { - output.should.not.contain(`readonly ${name}`); - output.should.not.contain(`this.${name} =`); + output.should.match(new RegExp(`/\\*\\* @deprecated [^\\n]+\\*/\\n readonly ${name}: SortingActions`)); + output.should.match(new RegExp(`/\\*\\* @deprecated [^\\n]+\\*/\\n readonly ${name} = new SortingActions`)); + output.should.contain(`this.${name} =`); } }); } - it('should omit unused sorting action imports', () => { - for (const output of outputs) output.should.not.contain('SortingActions'); + it('should explain the provider distinction and migration', () => { + for (const output of outputs) { + output.should.contain('In-memory sorting on this field is rejected; database providers sort it by their own order.'); + output.should.contain('This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead.'); + } }); }); diff --git a/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_scalar_fields.ts b/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_scalar_fields.ts index 5b22b2b1..ef1be150 100644 --- a/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_scalar_fields.ts +++ b/Source/Tools/ProxyGenerator/for_renderSourceQuery/when_emitting_sort_helpers/with_scalar_fields.ts @@ -20,10 +20,11 @@ describe('when emitting sort helpers with scalar fields', () => { output = renderSourceQuery(a_query, 'All.ts', new Map([['Listing', 'Listing.ts']]), '/api/all', resolver.models.get('ScalarFields')); }); for (const name of ['name', 'amount', 'enabled', 'timestamp', 'identifier', 'day', 'time', 'duration', 'status', 'label', - 'conceptName', 'conceptAmount', 'conceptEnabled', 'conceptTimestamp', 'conceptIdentifier', 'conceptDay', 'conceptTime', 'conceptDuration', 'conceptStatus']) { + 'conceptName', 'derivedName', 'genericName', 'conceptAmount', 'conceptEnabled', 'conceptTimestamp', 'conceptIdentifier', 'conceptDay', 'conceptTime', 'conceptDuration', 'conceptStatus']) { it(`should retain static and instance sorting for ${name}`, () => { output.should.contain(`readonly ${name} = new SortingActions('${name}')`); output.should.contain(`this.${name} = new SortingActionsForQuery('${name}', query)`); + output.should.not.contain('@deprecated'); }); } }); diff --git a/Source/Tools/ProxyGenerator/renderSourceQuery.ts b/Source/Tools/ProxyGenerator/renderSourceQuery.ts index 485e6636..2f5348d9 100644 --- a/Source/Tools/ProxyGenerator/renderSourceQuery.ts +++ b/Source/Tools/ProxyGenerator/renderSourceQuery.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { QueryHttpMethod } from '@cratis/arc.core'; import type { SourceModel } from './SourceModel.js'; +import type { SourceField } from './SourceField.js'; import type { SourceOperation } from './SourceOperation.js'; import { aliasTypes, queryClassName, quote, typeImports, type SourceRenderOptions } from './renderSource.js'; import { renderRecordedRules } from './renderRecordedRules.js'; @@ -47,16 +48,18 @@ export function renderSourceQuery(operation: SourceOperation, path: string, dest const when = observable ? 'ObservableQueryWhen' : 'QueryWhen'; if (!skipReactHooks) hooks.push(` static when(condition: boolean): ${when}<${name}, ${generic}> {\n return new ${when}<${name}, ${generic}>(${name}, condition);\n }`); const scalarConstructors = ['String', 'Number', 'Boolean', 'Date', 'Guid', 'DateOnly', 'TimeOnly', 'TimeSpan']; - const sortableFields = modelDefinition?.fields.filter(field => !field.type.enumerable && - field.type.model !== field.type.constructor && scalarConstructors.includes(field.type.constructor)) ?? []; + const sortFields = modelDefinition?.fields ?? []; + const deprecation = (field: SourceField): string => !field.type.enumerable && + field.type.model !== field.type.constructor && scalarConstructors.includes(field.type.constructor) ? '' : + ' /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */\n'; const coreImports = [base, 'QueryResultWithState', ...(validation ? ['QueryValidator'] : []), ...(array ? ['Sorting', 'Paging'] : []), - ...(array && sortableFields.length ? ['SortingActions', observable ? 'SortingActionsForObservableQuery' : 'SortingActionsForQuery'] : []), + ...(array && sortFields.length ? ['SortingActions', observable ? 'SortingActionsForObservableQuery' : 'SortingActionsForQuery'] : []), ...(observable && array ? ['ChangeSet'] : []), ...(operation.httpMethod ? ['QueryHttpMethod'] : [])]; const reactImports = [hook, suspense, ...(array ? [`${hook}WithPaging`, `${suspense}WithPaging`, 'SetPage', 'SetPageSize'] : []), ...(observable && array ? ['useChangeStream'] : []), ...(!observable ? ['PerformQuery'] : []), ...(array || !observable ? ['SetSorting'] : []), when]; const sortingMembers = array ? `\n get sortBy(): ${name}SortBy { return this._sortBy; }\n static get sortBy(): ${name}SortByWithoutQuery { return this._sortBy; }\n` : ''; const sortingAction = observable ? 'SortingActionsForObservableQuery' : 'SortingActionsForQuery'; - const sortClasses = array ? `class ${name}SortBy {\n${sortableFields.map(field => ` readonly ${field.name}: ${sortingAction}<${result.text}>;`).join('\n')}\n constructor(readonly query: ${name}) {\n${sortableFields.map(field => ` this.${field.name} = new ${sortingAction}<${result.text}>(${quote(field.name)}, query);`).join('\n')}\n }\n}\nclass ${name}SortByWithoutQuery {\n${sortableFields.map(field => ` readonly ${field.name} = new SortingActions(${quote(field.name)});`).join('\n')}\n}\n\n` : ''; + const sortClasses = array ? `class ${name}SortBy {\n${sortFields.map(field => `${deprecation(field)} readonly ${field.name}: ${sortingAction}<${result.text}>;`).join('\n')}\n constructor(readonly query: ${name}) {\n${sortFields.map(field => ` this.${field.name} = new ${sortingAction}<${result.text}>(${quote(field.name)}, query);`).join('\n')}\n }\n}\nclass ${name}SortByWithoutQuery {\n${sortFields.map(field => `${deprecation(field)} readonly ${field.name} = new SortingActions(${quote(field.name)});`).join('\n')}\n}\n\n` : ''; return `import { ${coreImports.map(item => ['QueryResultWithState', 'Sorting', 'ChangeSet'].includes(item) ? `type ${item}` : item).join(', ')} } from '@cratis/arc/queries';\n${skipReactHooks ? '' : `import { ${reactImports.map(item => ['SetPage', 'SetPageSize', 'PerformQuery', 'SetSorting'].includes(item) ? `type ${item}` : item).join(', ')} } from '@cratis/arc.react/queries';\n`}import { ParameterDescriptor } from '@cratis/arc/reflection';\n${imports.join('\n')}${imports.length ? '\n' : ''}\n${sortClasses}${params}${validation}export class ${name} extends ${base}<${generic}> {\n readonly route: string = ${quote(route)};\n readonly queryName: string = ${quote([operation.namespace, operation.owner, operation.name].filter(Boolean).join('.'))};\n${validation ? ` readonly validation: QueryValidator = new ${name}Validator();\n` : ''} readonly treatWarningsAsErrors: boolean = ${operation.treatWarningsAsErrors ?? false};\n readonly roles: string[] = [${operation.roles.map(quote).join(', ')}];\n readonly defaultValue: ${result.text} = ${array ? '[]' : `{} as ${result.text}`};\n${array ? ` private readonly _sortBy: ${name}SortBy;\n private static readonly _sortBy: ${name}SortByWithoutQuery = new ${name}SortByWithoutQuery();\n` : ''}\n constructor() {\n super(${options.emitInterfaces && result.model ? 'Object' : result.constructor}, ${array});${array ? `\n this._sortBy = new ${name}SortBy(this);` : ''}${operation.httpMethod ? `\n this.setHttpMethod(QueryHttpMethod.${Object.entries(QueryHttpMethod).find(([, value]) => value === operation.httpMethod)?.[0]});` : ''}\n }\n\n get requiredRequestParameters(): string[] {\n return [\n${request}\n ];\n }\n\n readonly parameterDescriptors: ParameterDescriptor[] = [\n${descriptors}\n ];\n${fields ? `\n${fields}\n` : ''}${sortingMembers}${hooks.length ? `\n${hooks.join('\n\n')}\n` : ''}}\n`; } From d2dbff5f98287988a53944f5a290f7231a434e84 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 20:33:26 +0200 Subject: [PATCH 3/4] Limit concept inheritance classification to sort helpers (#174) --- Source/Tools/ProxyGenerator/SourceField.ts | 9 +++- .../ProxyGenerator/SourceTypeResolver.ts | 39 ++++++--------- .../with_sort_helpers.ts | 48 +++++++++++++++++++ .../ProxyGenerator/isScalarSortConcept.ts | 45 +++++++++++++++++ .../Tools/ProxyGenerator/renderSourceQuery.ts | 2 +- 5 files changed, 116 insertions(+), 27 deletions(-) create mode 100644 Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_concept_subclasses/with_sort_helpers.ts create mode 100644 Source/Tools/ProxyGenerator/isScalarSortConcept.ts diff --git a/Source/Tools/ProxyGenerator/SourceField.ts b/Source/Tools/ProxyGenerator/SourceField.ts index 9cb78f4d..8b03d780 100644 --- a/Source/Tools/ProxyGenerator/SourceField.ts +++ b/Source/Tools/ProxyGenerator/SourceField.ts @@ -1,4 +1,11 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { SourceType } from './SourceType.js'; -export interface SourceField { readonly name: string; readonly type: SourceType; readonly optional: boolean; readonly nullable?: boolean } +export interface SourceField { + readonly name: string; + readonly type: SourceType; + readonly optional: boolean; + readonly nullable?: boolean; + /** Sort-helper classification only; does not change generated field types or model discovery. */ + readonly scalarSortConcept?: boolean; +} diff --git a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts index 72d2243a..3a354cde 100644 --- a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts +++ b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts @@ -7,6 +7,7 @@ import type { SourceModel } from './SourceModel.js'; import type { SourceType } from './SourceType.js'; import { resolveTypeMappings, type ResolvedTypeMapping, type TypeMappings } from './typeMappings.js'; import { fieldName, isPackageSymbol, isStandardType, isTypeFrom, originalSymbol } from './sourceSymbols.js'; +import { isScalarSortConcept } from './isScalarSortConcept.js'; const fundamentals = new Set(['Guid', 'DateOnly', 'TimeOnly', 'TimeSpan']); const primitive = (text: string, constructor: string): SourceType => ({ text, constructor, enumerable: false, nullable: false, void: false }); @@ -71,8 +72,17 @@ export class SourceTypeResolver { if (resolved.enumerable || resolved.void || resolved.nullable) return this.unsupported(type, location); return { ...resolved, text: resolved.text.includes(' | ') ? `(${resolved.text})[]` : `${resolved.text}[]`, enumerable: true, nullable }; } - const conceptValue = this.conceptValueType(type, location); - if (conceptValue) return this.resolve(conceptValue, location, optional); + if (type.getBaseTypes()?.some(base => isTypeFrom(this.checker, base, 'ConceptAs', '@cratis/fundamentals')) || + isTypeFrom(this.checker, type, 'ConceptAs', '@cratis/fundamentals')) { + const base = type.getBaseTypes()?.find(candidate => isTypeFrom(this.checker, candidate, 'ConceptAs', '@cratis/fundamentals')); + const argument = this.checker.getTypeArguments(type as ts.TypeReference)[0] ?? + (base && this.checker.getTypeArguments(base as ts.TypeReference)[0]); + if (argument) { + const declaration = symbol?.declarations?.find(ts.isClassDeclaration); + if (declaration) this.contribute(declaration); + return this.resolve(argument, location, optional); + } + } const declaration = symbol?.declarations?.find(ts.isClassDeclaration); if (symbol?.declarations?.some(ts.isEnumDeclaration)) return this.resolveEnum(symbol, type, location, nullable); if (declaration && name && !declaration.getSourceFile().isDeclarationFile) { @@ -108,7 +118,8 @@ export class SourceTypeResolver { const propertyType = this.checker.getTypeAtLocation(member); const nullable = decorated('nullable') || this.generatedMetadata && propertyType.isUnion() && propertyType.types.some(part => !!(part.flags & ts.TypeFlags.Null)); - return { name, type: this.resolve(type, member, optional || nullable), optional, nullable }; + return { name, type: this.resolve(type, member, optional || nullable), optional, nullable, + ...(isScalarSortConcept(this.checker, type, member) ? { scalarSortConcept: true } : {}) }; }); const baseType = type.getBaseTypes()?.find(base => base.symbol?.declarations?.some(ts.isClassDeclaration) && !base.symbol.declarations.every(origin => origin.getSourceFile().isDeclarationFile)); @@ -127,28 +138,6 @@ export class SourceTypeResolver { } return this.unsupported(type, location); } - private conceptValueType(type: ts.Type, location: ts.Node): ts.Type | undefined { - const pending = [type]; - const visited = new Set(); - while (pending.length) { - const candidate = pending.pop()!; - if (visited.has(candidate)) continue; - visited.add(candidate); - if (isTypeFrom(this.checker, candidate, 'ConceptAs', '@cratis/fundamentals')) { - for (const ancestor of visited) { - const declaration = ancestor.getSymbol()?.declarations?.find(ts.isClassDeclaration); - if (declaration && !declaration.getSourceFile().isDeclarationFile) this.contribute(declaration); - } - // Read from the original instantiated type so generic intermediate bases retain their substitutions. - const value = this.checker.getPropertyOfType(type, 'value'); - return value && this.checker.getTypeOfSymbolAtLocation(value, location); - } - const reference = candidate.flags & ts.TypeFlags.Object && (candidate as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference - ? candidate as ts.TypeReference : undefined; - pending.push(...candidate.getBaseTypes() ?? reference?.target.getBaseTypes() ?? []); - } - return undefined; - } private resolveEnum(symbol: ts.Symbol, type: ts.Type, location: ts.Node, nullable: boolean): SourceType { const declaration = symbol.declarations?.find(ts.isEnumDeclaration); if (!declaration) return this.unsupported(type, location); diff --git a/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_concept_subclasses/with_sort_helpers.ts b/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_concept_subclasses/with_sort_helpers.ts new file mode 100644 index 00000000..07705d60 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_concept_subclasses/with_sort_helpers.ts @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { resolve } from 'node:path'; +import { ClientOperationKind } from '@cratis/arc.core'; +import ts from 'typescript'; +import { SourceTypeResolver } from '../../SourceTypeResolver.js'; +import { renderSource } from '../../renderSource.js'; +import type { SourceOperation } from '../../SourceOperation.js'; + +describe('when resolving concept subclasses with sort helpers', () => { + let output: ReadonlyMap; + beforeEach(() => { + const root = resolve('Source/Tools/ProxyGenerator/for_renderSourceQuery/given'); + const path = resolve(root, 'ScalarFields.ts'); + const program = ts.createProgram([path], { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, strict: true, experimentalDecorators: true }); + const checker = program.getTypeChecker(); + const declaration = program.getSourceFile(path)!.statements.find(statement => ts.isClassDeclaration(statement) && statement.name?.text === 'ScalarFields')!; + const resolver = new SourceTypeResolver(checker, root); + const result = resolver.resolve(checker.getTypeAtLocation(declaration), declaration); + const fields = resolver.models.get('ScalarFields')!.fields.filter(field => ['derivedName', 'genericName'].includes(field.name)); + const query: SourceOperation = { kind: ClientOperationKind.Query, name: 'All', owner: 'ScalarFields', namespace: '', roles: [], + fields, result: { ...result, text: 'ScalarFields[]', enumerable: true } }; + output = renderSource({ models: [...resolver.models.values()], operations: [query, + { ...query, kind: ClientOperationKind.Command, name: 'Register', result }] }); + }); + it('should retain the generated concept classes', () => { + output.get('DerivedName.ts')!.should.contain('export class DerivedName {'); + output.get('GenericIntermediate.ts')!.should.contain('export class GenericIntermediate {'); + output.get('GenericName.ts')!.should.contain('export class GenericName extends GenericIntermediate {'); + }); + it('should retain model field types and constructors', () => { + output.get('ScalarFields.ts')!.should.contain('@field(DerivedName)\n derivedName!: DerivedName;'); + output.get('ScalarFields.ts')!.should.contain('@field(GenericName)\n genericName!: GenericName;'); + }); + it('should retain command and query parameter types', () => { + output.get('Register.ts')!.should.contain('derivedName?: DerivedName;'); + output.get('Register.ts')!.should.contain('genericName?: GenericName;'); + output.get('All.ts')!.should.contain('derivedName: DerivedName;'); + output.get('All.ts')!.should.contain('genericName: GenericName;'); + }); + it('should classify inherited scalar concepts only for sort helpers', () => { + const query = output.get('All.ts')!; + query.should.contain("readonly derivedName = new SortingActions('derivedName')"); + query.should.contain("readonly genericName = new SortingActions('genericName')"); + query.should.not.contain('@deprecated'); + }); +}); diff --git a/Source/Tools/ProxyGenerator/isScalarSortConcept.ts b/Source/Tools/ProxyGenerator/isScalarSortConcept.ts new file mode 100644 index 00000000..27a8b6a7 --- /dev/null +++ b/Source/Tools/ProxyGenerator/isScalarSortConcept.ts @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import ts from 'typescript'; +import { isStandardType, isTypeFrom } from './sourceSymbols.js'; + +function conceptValue(checker: ts.TypeChecker, type: ts.Type, location: ts.Node): ts.Type | undefined { + const pending = [type]; + const visited = new Set(); + while (pending.length) { + const candidate = pending.pop()!; + if (visited.has(candidate)) continue; + visited.add(candidate); + if (isTypeFrom(checker, candidate, 'ConceptAs', '@cratis/fundamentals')) { + // The original instantiated property retains substitutions through generic intermediate bases. + const value = checker.getPropertyOfType(type, 'value'); + return value && checker.getTypeOfSymbolAtLocation(value, location); + } + const reference = candidate.flags & ts.TypeFlags.Object && (candidate as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference + ? candidate as ts.TypeReference : undefined; + pending.push(...candidate.getBaseTypes() ?? reference?.target.getBaseTypes() ?? []); + } + return undefined; +} + +/** Classify concepts for sorting without resolving client types or discovering generated models. */ +export function isScalarSortConcept(checker: ts.TypeChecker, type: ts.Type, location: ts.Node): boolean { + const scalar = (candidate: ts.Type, visited = new Set()): boolean => { + if (visited.has(candidate)) return false; + if (candidate.isUnion()) { + const defined = candidate.types.filter(part => !(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined))); + return defined.length > 0 && defined.every(part => scalar(part, new Set(visited))); + } + if (candidate.flags & (ts.TypeFlags.StringLike | ts.TypeFlags.NumberLike | ts.TypeFlags.BooleanLike)) return true; + if (isStandardType(candidate, 'Date') || ['Guid', 'DateOnly', 'TimeOnly', 'TimeSpan'].some(name => + isTypeFrom(checker, candidate, name, '@cratis/fundamentals'))) return true; + visited.add(candidate); + const value = conceptValue(checker, candidate, location); + return !!value && scalar(value, visited); + }; + const defined = (type.isUnion() ? type.types : [type]).filter(part => !(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined))); + return defined.length > 0 && defined.every(part => { + const value = conceptValue(checker, part, location); + return !!value && scalar(value); + }); +} diff --git a/Source/Tools/ProxyGenerator/renderSourceQuery.ts b/Source/Tools/ProxyGenerator/renderSourceQuery.ts index 2f5348d9..97dafcc4 100644 --- a/Source/Tools/ProxyGenerator/renderSourceQuery.ts +++ b/Source/Tools/ProxyGenerator/renderSourceQuery.ts @@ -49,7 +49,7 @@ export function renderSourceQuery(operation: SourceOperation, path: string, dest if (!skipReactHooks) hooks.push(` static when(condition: boolean): ${when}<${name}, ${generic}> {\n return new ${when}<${name}, ${generic}>(${name}, condition);\n }`); const scalarConstructors = ['String', 'Number', 'Boolean', 'Date', 'Guid', 'DateOnly', 'TimeOnly', 'TimeSpan']; const sortFields = modelDefinition?.fields ?? []; - const deprecation = (field: SourceField): string => !field.type.enumerable && + const deprecation = (field: SourceField): string => field.scalarSortConcept || !field.type.enumerable && field.type.model !== field.type.constructor && scalarConstructors.includes(field.type.constructor) ? '' : ' /** @deprecated In-memory sorting on this field is rejected; database providers sort it by their own order. This helper will be removed in the next major release (https://github.com/Cratis/Arc.TypeScript/issues/177). Sort on a scalar field instead. */\n'; const coreImports = [base, 'QueryResultWithState', ...(validation ? ['QueryValidator'] : []), ...(array ? ['Sorting', 'Paging'] : []), From 3b5040fe452b3f96dde2bef8ee10a2a47a14e3d1 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 20:33:58 +0200 Subject: [PATCH 4/4] Set version 0.57.1 --- ContractTests/Client/package.json | 2 +- Documentation/index.md | 2 +- Documentation/reference/packages.md | 2 +- README.md | 2 +- Source/Chronicle/package.json | 6 +++--- Source/CodeAnalysis/Version.ts | 2 +- Source/CodeAnalysis/package.json | 2 +- Source/Core/Version.ts | 2 +- Source/Core/package.json | 2 +- Source/Cratis/package.json | 2 +- Source/Drizzle/package.json | 4 ++-- Source/Express/package.json | 2 +- Source/Fastify/package.json | 2 +- Source/Hono/package.json | 2 +- Source/MongoDB/package.json | 4 ++-- Source/Testing/package.json | 2 +- Source/Tools/ProxyGenerator/package.json | 2 +- yarn.lock | 8 ++++---- 18 files changed, 25 insertions(+), 25 deletions(-) diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json index a593405a..78cb6287 100644 --- a/ContractTests/Client/package.json +++ b/ContractTests/Client/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core-client-contract", - "version": "0.57.0", + "version": "0.57.1", "private": true, "type": "module", "dependencies": { diff --git a/Documentation/index.md b/Documentation/index.md index 09d0f47f..2735de70 100644 --- a/Documentation/index.md +++ b/Documentation/index.md @@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source. :::caution[Source preview, no full parity] -No package is published to npm; the manifests are at version 0.57.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. +No package is published to npm; the manifests are at version 0.57.1 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. ::: ## What it looks like diff --git a/Documentation/reference/packages.md b/Documentation/reference/packages.md index 9ae6fe0e..ddfd28e0 100644 --- a/Documentation/reference/packages.md +++ b/Documentation/reference/packages.md @@ -3,7 +3,7 @@ title: Packages description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client. --- -Every package in this repository is at version 0.57.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: +Every package in this repository is at version 0.57.1, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: - **Inside the clone.** Put your application in a folder under `Samples/`, which the root `workspaces` list includes, and reference the packages with the `workspace:^` protocol, as [`Samples/Tasks/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/package.json) does. `workspace:^` resolves only inside this repository's Yarn workspace. - **In your own project.** Pack each package you need with `yarn workspace pack --out ` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers. diff --git a/README.md b/README.md index 46b5322c..f8830df4 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ export class TaskItem { | `@cratis/arc.chronicle` | [`Source/Chronicle`](Source/Chronicle) | **Experimental.** `builder.withChronicle` appends returned events and resolves registered read models by command key; nested command returns join one event-log batch. In-memory command assertions are available under `@cratis/arc.chronicle/testing`. SDK 6.19.0 imports natively and infers read models from projections/reducers; an opt-in kernel suite covers aggregate replay and reactor commands. Full .NET transaction parity remains unverified. | | `@cratis/cratis` | [`Source/Cratis`](Source/Cratis) | **Experimental source preview.** `CratisApplication.createBuilder()` and `builder.addCratis()` compose Arc and a Chronicle client without installing authentication; not yet published to npm. | -Every package manifest is at version 0.57.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. Core, host adapters, MongoDB, and Drizzle need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. +Every package manifest is at version 0.57.1. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. Core, host adapters, MongoDB, and Drizzle need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. ## Try it diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json index afe6ed75..afddbc51 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.chronicle", - "version": "0.57.0", + "version": "0.57.1", "publishConfig": { "access": "public" }, @@ -34,8 +34,8 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.57.0", - "@cratis/arc.testing": "^0.57.0", + "@cratis/arc.core": "^0.57.1", + "@cratis/arc.testing": "^0.57.1", "@cratis/chronicle": "^6.29.0", "@cratis/fundamentals": "^7.19.6", "rxjs": "^7.8.2", diff --git a/Source/CodeAnalysis/Version.ts b/Source/CodeAnalysis/Version.ts index 8e30aa66..8c2256c4 100644 --- a/Source/CodeAnalysis/Version.ts +++ b/Source/CodeAnalysis/Version.ts @@ -1,4 +1,4 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. // Generated by yarn set-version. Do not edit. -export const packageVersion = '0.57.0'; +export const packageVersion = '0.57.1'; diff --git a/Source/CodeAnalysis/package.json b/Source/CodeAnalysis/package.json index d33b194a..576f4f07 100644 --- a/Source/CodeAnalysis/package.json +++ b/Source/CodeAnalysis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-arc-core", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "description": "ESLint diagnostics for Arc for TypeScript server artifacts", diff --git a/Source/Core/Version.ts b/Source/Core/Version.ts index 8e30aa66..8c2256c4 100644 --- a/Source/Core/Version.ts +++ b/Source/Core/Version.ts @@ -1,4 +1,4 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. // Generated by yarn set-version. Do not edit. -export const packageVersion = '0.57.0'; +export const packageVersion = '0.57.1'; diff --git a/Source/Core/package.json b/Source/Core/package.json index 7781c918..9b79e298 100644 --- a/Source/Core/package.json +++ b/Source/Core/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index f12ccb22..b3f550d6 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/cratis", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "description": "Arc and experimental Chronicle composition for Node.js", diff --git a/Source/Drizzle/package.json b/Source/Drizzle/package.json index a615b69b..9b80714a 100644 --- a/Source/Drizzle/package.json +++ b/Source/Drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.drizzle", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.57.0", + "@cratis/arc.core": "^0.57.1", "@cratis/fundamentals": "^7.19.6", "drizzle-orm": "^0.45.0", "rxjs": "^7.8.2" diff --git a/Source/Express/package.json b/Source/Express/package.json index 57592883..5461796d 100644 --- a/Source/Express/package.json +++ b/Source/Express/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.express", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json index e1b65c6c..f59b197b 100644 --- a/Source/Fastify/package.json +++ b/Source/Fastify/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.fastify", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Hono/package.json b/Source/Hono/package.json index 2df26195..04967a0c 100644 --- a/Source/Hono/package.json +++ b/Source/Hono/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.hono", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json index c22d40f2..971922c7 100644 --- a/Source/MongoDB/package.json +++ b/Source/MongoDB/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.mongodb", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.57.0", + "@cratis/arc.core": "^0.57.1", "@cratis/fundamentals": "^7.19.6", "@opentelemetry/api": "^1.9.0", "mongodb": "^6.21.0", diff --git a/Source/Testing/package.json b/Source/Testing/package.json index 1df3f7a4..1dd4fb32 100644 --- a/Source/Testing/package.json +++ b/Source/Testing/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.testing", - "version": "0.57.0", + "version": "0.57.1", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json index c8fdb61f..5296678c 100644 --- a/Source/Tools/ProxyGenerator/package.json +++ b/Source/Tools/ProxyGenerator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.proxygenerator", - "version": "0.57.0", + "version": "0.57.1", "description": "TypeScript source analyzer and deterministic Arc client proxy generator", "repository": { "type": "git", diff --git a/yarn.lock b/yarn.lock index 584df661..8dc51c14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -120,8 +120,8 @@ __metadata: rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" peerDependencies: - "@cratis/arc.core": ^0.57.0 - "@cratis/arc.testing": ^0.57.0 + "@cratis/arc.core": ^0.57.1 + "@cratis/arc.testing": ^0.57.1 "@cratis/chronicle": ^6.29.0 "@cratis/fundamentals": ^7.19.6 rxjs: ^7.8.2 @@ -226,7 +226,7 @@ __metadata: rxjs: "npm:^7.8.2" sql.js: "npm:^1.14.2" peerDependencies: - "@cratis/arc.core": ^0.57.0 + "@cratis/arc.core": ^0.57.1 "@cratis/fundamentals": ^7.19.6 drizzle-orm: ^0.45.0 rxjs: ^7.8.2 @@ -292,7 +292,7 @@ __metadata: mongodb: "npm:^6.21.0" rxjs: "npm:^7.8.2" peerDependencies: - "@cratis/arc.core": ^0.57.0 + "@cratis/arc.core": ^0.57.1 "@cratis/fundamentals": ^7.19.6 "@opentelemetry/api": ^1.9.0 mongodb: ^6.21.0