@blueprint-ts/core 4.0.0-beta.7 → 4.0.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## v4.0.0-beta.9 - 2026-02-28 (beta)
2
+
3
+ # [4.0.0-beta.9](/compare/v4.0.0-beta.8...v4.0.0-beta.9) (2026-02-28)
4
+ ## v4.0.0-beta.8 - 2026-02-28 (beta)
5
+
6
+ # [4.0.0-beta.8](/compare/v4.0.0-beta.7...v4.0.0-beta.8) (2026-02-28)
7
+
8
+
9
+ ### Features
10
+
11
+ * **requests:** add concurrency policy, stale-response handling, and docs 5feb49d
1
12
  ## v4.0.0-beta.7 - 2026-02-27 (beta)
2
13
 
3
14
  # [4.0.0-beta.7](/compare/v4.0.0-beta.6...v4.0.0-beta.7) (2026-02-27)
@@ -25,6 +25,7 @@ export default defineConfig({
25
25
  { text: 'Responses', link: '/services/requests/responses' },
26
26
  { text: 'Request Bodies', link: '/services/requests/request-bodies' },
27
27
  { text: 'Headers', link: '/services/requests/headers' },
28
+ { text: 'Concurrency', link: '/services/requests/concurrency' },
28
29
  { text: 'Aborting Requests', link: '/services/requests/abort-requests' },
29
30
  { text: 'Events', link: '/services/requests/events' },
30
31
  { text: 'Bulk Requests', link: '/services/requests/bulk-requests' },
@@ -1,6 +1,7 @@
1
1
  # Infinite Scroll
2
2
 
3
- Use `InfiniteScroller` when you want to append pages to the existing list:
3
+ Use `InfiniteScroller` when you want to append pages to the existing list. It extends `PageAwarePaginator`, so all
4
+ page-aware features and helpers are available.
4
5
 
5
6
  ```typescript
6
7
  import { InfiniteScroller } from '@blueprint-ts/core/pagination'
@@ -11,8 +12,8 @@ await scroller.load()
11
12
  await scroller.toNextPage()
12
13
  ```
13
14
 
14
- `InfiniteScroller` uses the same view driver factory as `PageAwarePaginator`. You can pass `{ flush: true }` or
15
- `{ replace: true }` to `load()` or `setPageNumber()` to control how data is merged.
15
+ You can pass `{ flush: true }` to clear existing data before loading a page. In addition, `InfiniteScroller` supports
16
+ `{ replace: true }` to replace the current list instead of appending.
16
17
 
17
18
  ## Scroll Detection Helper
18
19
 
@@ -40,6 +40,11 @@ await paginator.setPageSize(25).load()
40
40
  Page navigation helpers (`toNextPage`, `toPreviousPage`, `toFirstPage`, `toLastPage`) update the page number and load
41
41
  the new page in one call.
42
42
 
43
+ ## Concurrency
44
+
45
+ If the underlying request uses concurrency mode `LATEST` or `REPLACE_LATEST`, stale responses are ignored. In that case,
46
+ `load()` resolves with the current page data without updating the view, so older responses cannot overwrite newer ones.
47
+
43
48
 
44
49
  ## Updating Rows
45
50
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  Requests can be aborted by passing an `AbortSignal` to the request.
4
4
 
5
+ If you want the request library to abort previous in-flight requests automatically, see [Concurrency](/services/requests/concurrency).
6
+
5
7
  ## Using AbortController
6
8
 
7
9
  ```typescript
@@ -16,6 +18,8 @@ const promise = request.send()
16
18
  controller.abort()
17
19
  ```
18
20
 
21
+ Note: If you enable request concurrency with `REPLACE` or `REPLACE_LATEST`, the request will assign its own abort signal and override this one. See [Concurrency](/services/requests/concurrency) for details.
22
+
19
23
  ## Bulk Requests
20
24
 
21
25
  `BulkRequestSender` internally manages an `AbortController` for its requests. You can abort the entire bulk operation:
@@ -0,0 +1,58 @@
1
+ # Request Concurrency
2
+
3
+ Concurrent requests can cause two common problems:
4
+
5
+ 1. A slower, older response overwrites newer data ("stale results").
6
+ 2. Loading state flickers because earlier requests finish after later ones.
7
+
8
+ To solve this, `BaseRequest` supports an optional concurrency policy that can abort older requests and/or ignore stale responses.
9
+
10
+ ## Basic Usage
11
+
12
+ ```typescript
13
+ import { RequestConcurrencyMode } from '@blueprint-ts/core/requests'
14
+
15
+ const request = new ExpenseIndexRequest()
16
+
17
+ request.setConcurrency({
18
+ mode: RequestConcurrencyMode.REPLACE_LATEST,
19
+ key: 'expense-search'
20
+ })
21
+
22
+ request.send()
23
+ ```
24
+
25
+ ## Modes
26
+
27
+ - `ALLOW` (default): no aborts and no stale-response filtering.
28
+ - `REPLACE`: aborts any in-flight request with the same key.
29
+ - `LATEST`: ignores stale responses; only the most recent response is applied.
30
+ - `REPLACE_LATEST`: aborts older requests and ignores stale responses.
31
+
32
+ ## Abort Signals
33
+
34
+ When using `REPLACE` or `REPLACE_LATEST`, the request creates and assigns its own `AbortController` for the concurrency key. This replaces any previously configured abort signal on that request instance. If you need to preserve a custom abort signal, apply it per request without using replace modes.
35
+
36
+ ## Keys
37
+
38
+ The `key` lets you coordinate concurrency across multiple request instances. If you omit it, the request instance ID is used.
39
+
40
+ Use a shared key when multiple instances represent the same logical request stream (for example, a search box that creates new request objects).
41
+
42
+ ## Stale Responses
43
+
44
+ When `LATEST` or `REPLACE_LATEST` is used, stale responses raise a `StaleResponseException` so the caller can ignore them safely.
45
+
46
+ If you don't want to handle it explicitly, catch and ignore it:
47
+
48
+ ```typescript
49
+ import { StaleResponseException } from '@blueprint-ts/core/requests'
50
+
51
+ request.send().catch((error) => {
52
+ if (error instanceof StaleResponseException) {
53
+ return
54
+ }
55
+
56
+ throw error
57
+ })
58
+ ```
@@ -13,6 +13,22 @@ When a request fails, `BaseRequest.send()` routes error responses through the re
13
13
 
14
14
  ## Catching Errors
15
15
 
16
+ When using request concurrency (see [Concurrency](/services/requests/concurrency)), `BaseRequest` can throw a `StaleResponseException` for outdated responses. These should usually be ignored:
17
+
18
+ ```typescript
19
+ import { StaleResponseException } from '@blueprint-ts/core/requests'
20
+
21
+ try {
22
+ await request.send()
23
+ } catch (error: unknown) {
24
+ if (error instanceof StaleResponseException) {
25
+ return
26
+ }
27
+
28
+ throw error
29
+ }
30
+ ```
31
+
16
32
  - `400` -> `BadRequestException`
17
33
  - `401` -> `UnauthorizedException`
18
34
  - `403` -> `ForbiddenException`
@@ -170,6 +170,22 @@ export class MyConfirmOptions implements ConfirmDialogOptions {
170
170
  }
171
171
  ```
172
172
 
173
+ ## BaseRequestContract Requires `setConcurrency`
174
+
175
+ `BaseRequestContract` now includes `setConcurrency(...)` so requests can opt into concurrency handling (e.g., latest-wins or replace-latest).
176
+ If you implemented `BaseRequestContract` directly (instead of extending `BaseRequest`), TypeScript will now require this method.
177
+
178
+ ### How to Fix
179
+
180
+ Add the method to your custom request implementation:
181
+
182
+ ```typescript
183
+ setConcurrency(options?: RequestConcurrencyOptions): this {
184
+ // no-op by default; use options if your implementation needs them
185
+ return this
186
+ }
187
+ ```
188
+
173
189
  ## Laravel Packages Moved
174
190
 
175
191
  Laravel modules moved from `@blueprint-ts/core/service/laravel/*` to `@blueprint-ts/core/laravel/*`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueprint-ts/core",
3
- "version": "4.0.0-beta.7",
3
+ "version": "4.0.0-beta.9",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -74,8 +74,8 @@
74
74
  "vue-router": "^4.3.2"
75
75
  },
76
76
  "dependencies": {
77
- "lodash-es": "^4.17.21",
78
- "qs": "^6.12.0",
79
- "uuid": "^11.1.0"
77
+ "lodash-es": "^4.17.23",
78
+ "qs": "^6.15.0",
79
+ "uuid": "^13.0.0"
80
80
  }
81
81
  }
@@ -68,4 +68,8 @@ export abstract class BasePaginator<ResourceInterface, ViewDriver extends BaseVi
68
68
  this.viewDriver.setTotal(dto.getTotal())
69
69
  this.initialized = true
70
70
  }
71
+
72
+ protected handleStaleResponse(): PaginationDataDto<ResourceInterface[]> {
73
+ return new PaginationDataDto<ResourceInterface[]>(this.viewDriver.getData(), this.viewDriver.getTotal())
74
+ }
71
75
  }
@@ -4,6 +4,7 @@ import { type ViewDriverFactoryContract } from './contracts/ViewDriverFactoryCon
4
4
  import { type PaginatorLoadDataOptions } from './contracts/PaginatorLoadDataOptions'
5
5
  import { type PaginationDataDriverContract } from './contracts/PaginationDataDriverContract'
6
6
  import { BasePaginator } from './BasePaginator'
7
+ import { StaleResponseException } from '../requests/exceptions/StaleResponseException'
7
8
 
8
9
  export interface PageAwarePaginatorOptions {
9
10
  viewDriverFactory?: ViewDriverFactoryContract
@@ -113,10 +114,19 @@ export class PageAwarePaginator<ResourceInterface> extends BasePaginator<Resourc
113
114
  }
114
115
 
115
116
  protected loadData(pageNumber: number, pageSize: number, options?: PaginatorLoadDataOptions): Promise<PaginationDataDto<ResourceInterface[]>> {
116
- return this.dataDriver.get(pageNumber, pageSize).then((value: PaginationDataDto<ResourceInterface[]>) => {
117
- this.passDataToViewDriver(value, options)
118
-
119
- return value
120
- })
117
+ return this.dataDriver
118
+ .get(pageNumber, pageSize)
119
+ .then((value: PaginationDataDto<ResourceInterface[]>) => {
120
+ this.passDataToViewDriver(value, options)
121
+
122
+ return value
123
+ })
124
+ .catch((error) => {
125
+ if (error instanceof StaleResponseException) {
126
+ return this.handleStaleResponse()
127
+ }
128
+
129
+ throw error
130
+ })
121
131
  }
122
132
  }
@@ -4,6 +4,7 @@ import { RequestEvents } from './RequestEvents.enum'
4
4
  import { RequestMethodEnum } from './RequestMethod.enum'
5
5
  import { BaseResponse } from './responses/BaseResponse'
6
6
  import { ResponseException } from './exceptions/ResponseException'
7
+ import { StaleResponseException } from './exceptions/StaleResponseException'
7
8
  import { type DriverConfigContract } from './contracts/DriverConfigContract'
8
9
  import { type BodyFactoryContract } from './contracts/BodyFactoryContract'
9
10
  import { type RequestLoaderContract } from './contracts/RequestLoaderContract'
@@ -13,6 +14,8 @@ import { type BaseRequestContract, type EventHandlerCallback } from './contracts
13
14
  import { type HeadersContract } from './contracts/HeadersContract'
14
15
  import { type ResponseHandlerContract } from './drivers/contracts/ResponseHandlerContract'
15
16
  import { type ResponseContract } from './contracts/ResponseContract'
17
+ import { type RequestConcurrencyOptions } from './types/RequestConcurrencyOptions'
18
+ import { RequestConcurrencyMode } from './RequestConcurrencyMode.enum'
16
19
  import { mergeDeep } from '../support/helpers'
17
20
  import { v4 as uuidv4 } from 'uuid'
18
21
 
@@ -29,6 +32,7 @@ export abstract class BaseRequest<
29
32
  protected requestBody: RequestBodyInterface | undefined = undefined
30
33
  protected requestLoader: RequestLoaderContract<RequestLoaderLoadingType> | undefined = undefined
31
34
  protected abortSignal: AbortSignal | undefined = undefined
35
+ protected concurrencyOptions: RequestConcurrencyOptions | undefined = undefined
32
36
  /* @ts-expect-error Ignore generics */
33
37
  protected events: { [key in RequestEvents]?: EventHandlerCallback[] } = {}
34
38
 
@@ -36,6 +40,9 @@ export abstract class BaseRequest<
36
40
 
37
41
  protected static requestDriver: RequestDriverContract
38
42
  protected static requestLoaderFactory: RequestLoaderFactoryContract<unknown>
43
+ protected static concurrencySequenceByKey: Map<string, number> = new Map()
44
+ protected static concurrencyAbortControllerByKey: Map<string, AbortController> = new Map()
45
+ protected static concurrencyInFlightByKey: Map<string, number> = new Map()
39
46
 
40
47
  public constructor() {
41
48
  if (BaseRequest.requestLoaderFactory !== undefined) {
@@ -61,6 +68,12 @@ export abstract class BaseRequest<
61
68
  return this
62
69
  }
63
70
 
71
+ public setConcurrency(options?: RequestConcurrencyOptions): this {
72
+ this.concurrencyOptions = options
73
+
74
+ return this
75
+ }
76
+
64
77
  public getRequestId(): string {
65
78
  return this.requestId
66
79
  }
@@ -124,6 +137,25 @@ export abstract class BaseRequest<
124
137
  }
125
138
 
126
139
  public async send(): Promise<ResponseClass> {
140
+ const concurrencyMode: RequestConcurrencyMode = this.concurrencyOptions?.mode ?? RequestConcurrencyMode.ALLOW
141
+ const concurrencyKey = this.concurrencyOptions?.key ?? this.requestId
142
+ const useReplace = concurrencyMode === RequestConcurrencyMode.REPLACE || concurrencyMode === RequestConcurrencyMode.REPLACE_LATEST
143
+ const useLatest = concurrencyMode === RequestConcurrencyMode.LATEST || concurrencyMode === RequestConcurrencyMode.REPLACE_LATEST
144
+ const sequence = this.bumpConcurrencySequence(concurrencyKey)
145
+ this.incrementConcurrencyInFlight(concurrencyKey)
146
+
147
+ if (useReplace) {
148
+ const previousController = BaseRequest.concurrencyAbortControllerByKey.get(concurrencyKey)
149
+
150
+ if (previousController) {
151
+ previousController.abort()
152
+ }
153
+
154
+ const controller = new AbortController()
155
+ BaseRequest.concurrencyAbortControllerByKey.set(concurrencyKey, controller)
156
+ this.setAbortSignal(controller.signal)
157
+ }
158
+
127
159
  this.dispatch<boolean>(RequestEvents.LOADING, true)
128
160
 
129
161
  this.requestLoader?.setLoading(true)
@@ -144,11 +176,23 @@ export abstract class BaseRequest<
144
176
  this.getConfig()
145
177
  )
146
178
  .then(async (responseHandler: ResponseHandlerContract) => {
179
+ if (useLatest && !this.isLatestSequence(concurrencyKey, sequence)) {
180
+ throw new StaleResponseException()
181
+ }
182
+
147
183
  await responseSkeleton.setResponse(responseHandler)
148
184
 
149
185
  return responseSkeleton
150
186
  })
151
187
  .catch(async (error) => {
188
+ if (useLatest && !this.isLatestSequence(concurrencyKey, sequence)) {
189
+ throw new StaleResponseException('Stale response ignored', error)
190
+ }
191
+
192
+ if (error instanceof StaleResponseException) {
193
+ throw error
194
+ }
195
+
152
196
  if (error instanceof ResponseException) {
153
197
  const handler = new ErrorHandler<ResponseErrorBody>(error.getResponse())
154
198
 
@@ -160,8 +204,14 @@ export abstract class BaseRequest<
160
204
  throw error
161
205
  })
162
206
  .finally(() => {
163
- this.dispatch<boolean>(RequestEvents.LOADING, false)
164
- this.requestLoader?.setLoading(false)
207
+ const isStale = useLatest && !this.isLatestSequence(concurrencyKey, sequence)
208
+
209
+ if (!isStale) {
210
+ this.dispatch<boolean>(RequestEvents.LOADING, false)
211
+ this.requestLoader?.setLoading(false)
212
+ }
213
+
214
+ this.decrementConcurrencyInFlight(concurrencyKey)
165
215
  })
166
216
  }
167
217
 
@@ -185,6 +235,41 @@ export abstract class BaseRequest<
185
235
  return this
186
236
  }
187
237
 
238
+ protected bumpConcurrencySequence(key: string): number {
239
+ const next = (BaseRequest.concurrencySequenceByKey.get(key) ?? 0) + 1
240
+ BaseRequest.concurrencySequenceByKey.set(key, next)
241
+
242
+ return next
243
+ }
244
+
245
+ protected isLatestSequence(key: string, sequence: number): boolean {
246
+ return (BaseRequest.concurrencySequenceByKey.get(key) ?? 0) === sequence
247
+ }
248
+
249
+ protected incrementConcurrencyInFlight(key: string): void {
250
+ const next = (BaseRequest.concurrencyInFlightByKey.get(key) ?? 0) + 1
251
+ BaseRequest.concurrencyInFlightByKey.set(key, next)
252
+ }
253
+
254
+ protected decrementConcurrencyInFlight(key: string): void {
255
+ const current = BaseRequest.concurrencyInFlightByKey.get(key)
256
+
257
+ if (current === undefined) {
258
+ return
259
+ }
260
+
261
+ const next = current - 1
262
+
263
+ if (next <= 0) {
264
+ BaseRequest.concurrencyInFlightByKey.delete(key)
265
+ BaseRequest.concurrencySequenceByKey.delete(key)
266
+ BaseRequest.concurrencyAbortControllerByKey.delete(key)
267
+ return
268
+ }
269
+
270
+ BaseRequest.concurrencyInFlightByKey.set(key, next)
271
+ }
272
+
188
273
  protected baseUrl(): undefined {
189
274
  return undefined
190
275
  }
@@ -0,0 +1,6 @@
1
+ export enum RequestConcurrencyMode {
2
+ ALLOW = 'allow',
3
+ REPLACE = 'replace',
4
+ LATEST = 'latest',
5
+ REPLACE_LATEST = 'replace-latest'
6
+ }
@@ -2,6 +2,7 @@ import { RequestMethodEnum } from '../RequestMethod.enum'
2
2
  import { RequestEvents } from '../RequestEvents.enum'
3
3
  import { type BodyFactoryContract } from './BodyFactoryContract'
4
4
  import { type HeadersContract } from './HeadersContract'
5
+ import { type RequestConcurrencyOptions } from '../types/RequestConcurrencyOptions'
5
6
 
6
7
  export type EventHandlerCallback<T> = (value: T) => void
7
8
 
@@ -33,4 +34,6 @@ export interface BaseRequestContract<RequestLoaderLoadingType, RequestBodyInterf
33
34
  getResponse(): ResponseClass
34
35
 
35
36
  setAbortSignal(signal: AbortSignal): this
37
+
38
+ setConcurrency(options?: RequestConcurrencyOptions): this
36
39
  }
@@ -0,0 +1,13 @@
1
+ export class StaleResponseException extends Error {
2
+ public readonly cause: unknown
3
+
4
+ public constructor(message: string = 'Stale response ignored', cause?: unknown) {
5
+ super(message)
6
+ this.name = 'StaleResponseException'
7
+ this.cause = cause
8
+ }
9
+
10
+ public getCause(): unknown {
11
+ return this.cause
12
+ }
13
+ }
@@ -8,6 +8,7 @@ import { ErrorHandler } from './ErrorHandler'
8
8
  import { RequestErrorRouter } from './RequestErrorRouter'
9
9
  import { RequestEvents } from './RequestEvents.enum'
10
10
  import { RequestMethodEnum } from './RequestMethod.enum'
11
+ import { RequestConcurrencyMode } from './RequestConcurrencyMode.enum'
11
12
  import { JsonBodyFactory } from './factories/JsonBodyFactory'
12
13
  import { FormDataFactory } from './factories/FormDataFactory'
13
14
  import { type BodyContract } from './contracts/BodyContract'
@@ -19,7 +20,9 @@ import { type BodyFactoryContract } from './contracts/BodyFactoryContract'
19
20
  import { type ResponseHandlerContract } from './drivers/contracts/ResponseHandlerContract'
20
21
  import { type BaseRequestContract } from './contracts/BaseRequestContract'
21
22
  import { ResponseException } from './exceptions/ResponseException'
23
+ import { StaleResponseException } from './exceptions/StaleResponseException'
22
24
  import { type HeadersContract } from './contracts/HeadersContract'
25
+ import { type RequestConcurrencyOptions } from './types/RequestConcurrencyOptions'
23
26
 
24
27
  export {
25
28
  FetchDriver,
@@ -32,7 +35,9 @@ export {
32
35
  RequestErrorRouter,
33
36
  RequestEvents,
34
37
  RequestMethodEnum,
38
+ RequestConcurrencyMode,
35
39
  ResponseException,
40
+ StaleResponseException,
36
41
  JsonBodyFactory,
37
42
  FormDataFactory
38
43
  }
@@ -46,5 +51,6 @@ export type {
46
51
  BodyFactoryContract,
47
52
  ResponseHandlerContract,
48
53
  BaseRequestContract,
49
- HeadersContract
54
+ HeadersContract,
55
+ RequestConcurrencyOptions
50
56
  }
@@ -0,0 +1,6 @@
1
+ import { RequestConcurrencyMode } from '../RequestConcurrencyMode.enum'
2
+
3
+ export type RequestConcurrencyOptions = {
4
+ mode?: RequestConcurrencyMode
5
+ key?: string
6
+ }
@@ -25,7 +25,6 @@ export type PropertyAware<T> = {
25
25
  export class PropertyAwareArray<T = unknown> extends Array<T> {
26
26
  // Private brand to prevent plain arrays from being assignable to PropertyAwareArray.
27
27
  // This keeps conditional types from treating normal arrays as property-aware.
28
- // eslint-disable-next-line @typescript-eslint/no-unused-private-class-members
29
28
  private readonly __propertyAwareArrayBrand!: void
30
29
  /**
31
30
  * Creates a new PropertyAwareArray instance