@blueprint-ts/core 4.0.0-beta.1 → 4.0.0-beta.10

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,77 @@
1
+ ## v4.0.0-beta.10 - 2026-02-28 (beta)
2
+
3
+ # [4.0.0-beta.10](/compare/v4.0.0-beta.9...v4.0.0-beta.10) (2026-02-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * **pagination:** support local row removal without reload 3a66689
9
+ ## v4.0.0-beta.9 - 2026-02-28 (beta)
10
+
11
+ # [4.0.0-beta.9](/compare/v4.0.0-beta.8...v4.0.0-beta.9) (2026-02-28)
12
+ ## v4.0.0-beta.8 - 2026-02-28 (beta)
13
+
14
+ # [4.0.0-beta.8](/compare/v4.0.0-beta.7...v4.0.0-beta.8) (2026-02-28)
15
+
16
+
17
+ ### Features
18
+
19
+ * **requests:** add concurrency policy, stale-response handling, and docs 5feb49d
20
+ ## v4.0.0-beta.7 - 2026-02-27 (beta)
21
+
22
+ # [4.0.0-beta.7](/compare/v4.0.0-beta.6...v4.0.0-beta.7) (2026-02-27)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * Make cause public in InvalidJsonException 3aab192
28
+ ## v4.0.0-beta.6 - 2026-02-27 (beta)
29
+
30
+ # [4.0.0-beta.6](/compare/v4.0.0-beta.5...v4.0.0-beta.6) (2026-02-27)
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * bind BaseForm getters to preserve this 5e44296
36
+ * Type propertyAwareToRaw properly 1fe2f28
37
+
38
+
39
+ ### Features
40
+
41
+ * Make state protected readonly in BaseForm e6e2f81
42
+ * Removed deprecated Paginator alias 7f07617
43
+ ## v4.0.0-beta.5 - 2026-02-27 (beta)
44
+
45
+ # [4.0.0-beta.5](/compare/v4.0.0-beta.4...v4.0.0-beta.5) (2026-02-27)
46
+
47
+
48
+ ### Bug Fixes
49
+
50
+ * satisfy paginator updater typing and type-only import in VueRequestLoaderFactory 6468368
51
+ ## v4.0.0-beta.4 - 2026-02-26 (beta)
52
+
53
+ # [4.0.0-beta.4](/compare/v4.0.0-beta.3...v4.0.0-beta.4) (2026-02-26)
54
+
55
+
56
+ ### Bug Fixes
57
+
58
+ * Use WritableComputedRef for field models 3cdb7dc
59
+ ## v4.0.0-beta.3 - 2026-02-26 (beta)
60
+
61
+ # [4.0.0-beta.3](/compare/v4.0.0-beta.2...v4.0.0-beta.3) (2026-02-26)
62
+
63
+
64
+ ### Bug Fixes
65
+
66
+ * Make PropertyAwareArray nominal to avoid array type confusion b68b6fe
67
+ ## v4.0.0-beta.2 - 2026-02-26 (beta)
68
+
69
+ # [4.0.0-beta.2](/compare/v4.0.0-beta.1...v4.0.0-beta.2) (2026-02-26)
70
+
71
+
72
+ ### Bug Fixes
73
+
74
+ * Loosen typeing in FormDataFactory.ts fed4475
1
75
  ## v4.0.0-beta.1 - 2026-02-26 (beta)
2
76
 
3
77
  # [4.0.0-beta.1](/compare/v3.0.1...v4.0.0-beta.1) (2026-02-26)
@@ -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' },
@@ -38,7 +38,7 @@ If you are using Vue, the library provides `VuePaginationDriverFactory` and `Vue
38
38
  - `flush`: clears existing data before applying the next page
39
39
  - `replace`: replaces existing data instead of appending (useful for infinite scroll)
40
40
 
41
- `updateRows` is available on all paginators. See [Updating Rows](./updating-rows).
41
+ `updateRows` and `removeRows` are available on all paginators. See [Updating Rows](./updating-rows).
42
42
 
43
43
  ## Using Laravel Pagination
44
44
 
@@ -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
 
@@ -34,3 +34,19 @@ const updated = paginator.updateRows(
34
34
  (row) => ({ ...row, updatedAt: new Date().toISOString() })
35
35
  )
36
36
  ```
37
+
38
+ ## Removing Rows
39
+
40
+ Use `removeRows` to delete items from the current page data without reloading. By default it also decrements `total`
41
+ by the number of removed rows. Set `adjustTotal: false` to skip that behavior.
42
+
43
+ ```typescript
44
+ // Remove a single item
45
+ const removed = paginator.removeRows((row) => row.id === targetId)
46
+
47
+ // Remove all drafts without adjusting total
48
+ const removedDrafts = paginator.removeRows(
49
+ (row) => row.status === 'draft',
50
+ { adjustTotal: false }
51
+ )
52
+ ```
@@ -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`
@@ -78,6 +78,24 @@ Page navigation helpers (`toNextPage`, `toPreviousPage`, `toFirstPage`, `toLastP
78
78
  `PaginationParamsContract` was removed from the Laravel pagination exports because it was unused. Define your own params
79
79
  interface in your app instead.
80
80
 
81
+ ## Deprecated `Paginator` alias removed
82
+
83
+ `Paginator` (and the related `PaginatorOptions` alias) was an old alias for `PageAwarePaginator`. The alias file has been removed, so importing `Paginator` or `PaginatorOptions` now results in a build error.
84
+
85
+ ### How to Fix
86
+
87
+ - Replace `import { Paginator } from '@blueprint-ts/core/pagination'` with:
88
+
89
+ ```typescript
90
+ import { PageAwarePaginator } from '@blueprint-ts/core/pagination'
91
+ ```
92
+
93
+ - Replace `import { type PaginatorOptions } from '@blueprint-ts/core/pagination'` with:
94
+
95
+ ```typescript
96
+ import { type PageAwarePaginatorOptions } from '@blueprint-ts/core/pagination'
97
+ ```
98
+
81
99
  ## Core Modules Moved out of `service/`
82
100
 
83
101
  Core (non-framework) modules moved from `@blueprint-ts/core/service/*` to `@blueprint-ts/core/*`.
@@ -152,6 +170,22 @@ export class MyConfirmOptions implements ConfirmDialogOptions {
152
170
  }
153
171
  ```
154
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
+
155
189
  ## Laravel Packages Moved
156
190
 
157
191
  Laravel modules moved from `@blueprint-ts/core/service/laravel/*` to `@blueprint-ts/core/laravel/*`.
@@ -169,3 +203,12 @@ with:
169
203
  ```typescript
170
204
  import { JsonBaseRequest } from '@blueprint-ts/core/laravel/requests'
171
205
  ```
206
+
207
+ ## BaseForm `state` is now protected
208
+
209
+ `BaseForm.state` was public in previous versions, but it is now `protected readonly`. That means external consumers can no longer import form instances and access `form.state`. Subclasses can still read/write state internally, so any code that depended on reading it from outside the form must switch to supported accessors such as `form.properties`.
210
+
211
+ ### How to Fix
212
+
213
+ - Replace direct `form.state` usage with the public helpers (`form.properties`, getters, or explicit payload builders).
214
+ - If you were creating helpers that accessed `state` from outside the form, move those helpers inside the form class so they can rely on the protected field.
@@ -1,5 +1,7 @@
1
1
  # Arrays
2
2
 
3
+ ## PropertyAwareArray
4
+
3
5
  Use `PropertyAwareArray` for arrays with per-item `v-model`, errors, and dirty state when your array contains objects.
4
6
 
5
7
  ```ts
@@ -43,3 +45,58 @@ Example component usage:
43
45
  ```
44
46
 
45
47
  Nested error keys like `positions.0.value` map into `position.value.errors`.
48
+
49
+ ## propertyAwareToRaw
50
+
51
+ `propertyAwareToRaw` converts a property-aware object (each field wrapped in `{ model: { value } }`) into a plain object by unwrapping every field's `model.value`. It is purely a transformation: metadata such as `errors`, `dirty`, and `touched` is dropped, while arrays and nested objects are processed recursively. Keys starting with `_` are omitted from the result, and arrays are mapped element-by-element via the same unwrapping.
52
+
53
+ ### Input shape (property-aware)
54
+
55
+ ```ts
56
+ const propertyAwarePosition = {
57
+ id: {
58
+ model: { value: 'pos-1' },
59
+ errors: [],
60
+ dirty: false,
61
+ touched: false
62
+ },
63
+ sort_order: {
64
+ model: { value: 10 },
65
+ errors: [],
66
+ dirty: false,
67
+ touched: false
68
+ },
69
+ description: {
70
+ model: { value: 'Service' },
71
+ errors: [],
72
+ dirty: false,
73
+ touched: false
74
+ },
75
+ net_amount: {
76
+ model: { value: 100 },
77
+ errors: [],
78
+ dirty: false,
79
+ touched: false
80
+ }
81
+ }
82
+ ```
83
+
84
+ Note that this is only an example of how the input shape looks When used in a form the model is a WritableComputedRef.
85
+
86
+ ### Output shape (raw)
87
+
88
+ ```ts
89
+ const rawPosition = {
90
+ id: 'pos-1',
91
+ sort_order: 10,
92
+ description: 'Service',
93
+ net_amount: 100
94
+ }
95
+ ```
96
+
97
+ ### Notes
98
+
99
+ - Arrays are mapped element-by-element.
100
+ - Nested objects are unwrapped recursively.
101
+ - Any keys starting with `_` are ignored.
102
+ - Only the `model.value` remains; form metadata is dropped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueprint-ts/core",
3
- "version": "4.0.0-beta.1",
3
+ "version": "4.0.0-beta.10",
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
  }
@@ -42,7 +42,7 @@ export abstract class BasePaginator<ResourceInterface, ViewDriver extends BaseVi
42
42
  const result = updater(row, i, data)
43
43
 
44
44
  if (result !== undefined) {
45
- data[i] = result
45
+ data[i] = result as ResourceInterface
46
46
  }
47
47
 
48
48
  updated++
@@ -55,6 +55,25 @@ export abstract class BasePaginator<ResourceInterface, ViewDriver extends BaseVi
55
55
  return updated
56
56
  }
57
57
 
58
+ public removeRows(
59
+ predicate: (row: ResourceInterface, index: number, data: ResourceInterface[]) => boolean,
60
+ options?: { adjustTotal?: boolean }
61
+ ): number {
62
+ const data = this.viewDriver.getData()
63
+ const next = data.filter((row, index) => !predicate(row, index, data))
64
+ const removed = data.length - next.length
65
+
66
+ if (removed > 0) {
67
+ this.viewDriver.setData(next)
68
+
69
+ if (options?.adjustTotal ?? true) {
70
+ this.viewDriver.setTotal(Math.max(0, this.viewDriver.getTotal() - removed))
71
+ }
72
+ }
73
+
74
+ return removed
75
+ }
76
+
58
77
  public getTotal(): number {
59
78
  return this.viewDriver.getTotal()
60
79
  }
@@ -68,4 +87,8 @@ export abstract class BasePaginator<ResourceInterface, ViewDriver extends BaseVi
68
87
  this.viewDriver.setTotal(dto.getTotal())
69
88
  this.initialized = true
70
89
  }
90
+
91
+ protected handleStaleResponse(): PaginationDataDto<ResourceInterface[]> {
92
+ return new PaginationDataDto<ResourceInterface[]>(this.viewDriver.getData(), this.viewDriver.getTotal())
93
+ }
71
94
  }
@@ -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
  }
@@ -2,7 +2,6 @@ import { PaginationDataDto } from './dtos/PaginationDataDto'
2
2
  import { StatePaginationDataDto } from './dtos/StatePaginationDataDto'
3
3
  import { VuePaginationDriver } from './frontendDrivers/VuePaginationDriver'
4
4
  import { VueBaseViewDriver } from './frontendDrivers/VueBaseViewDriver'
5
- import { Paginator } from './Paginator'
6
5
  import { BasePaginator } from './BasePaginator'
7
6
  import { PageAwarePaginator } from './PageAwarePaginator'
8
7
  import { StatePaginator } from './StatePaginator'
@@ -25,7 +24,6 @@ export {
25
24
  StatePaginationDataDto,
26
25
  VuePaginationDriver,
27
26
  VueBaseViewDriver,
28
- Paginator,
29
27
  BasePaginator,
30
28
  PageAwarePaginator,
31
29
  StatePaginator,
@@ -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
  }
@@ -4,7 +4,7 @@ import { type ResponseHandlerContract } from '../drivers/contracts/ResponseHandl
4
4
  export class InvalidJsonException extends ResponseException {
5
5
  public constructor(
6
6
  response: ResponseHandlerContract,
7
- protected cause: unknown
7
+ public cause: unknown
8
8
  ) {
9
9
  super(response)
10
10
  }
@@ -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
+ }
@@ -3,10 +3,13 @@ import { type BodyFactoryContract } from '../contracts/BodyFactoryContract'
3
3
  import { type BodyContract } from '../contracts/BodyContract'
4
4
 
5
5
  type FormDataPrimitive = string | number | boolean | null | Date | Blob
6
- type FormDataValue = FormDataPrimitive | FormDataValue[] | { [key: string]: FormDataValue }
6
+
7
+ export type FormDataValue = FormDataPrimitive | FormDataValue[] | { [key: string]: FormDataValue }
7
8
 
8
9
  export class FormDataFactory<
9
- RequestBodyInterface extends Record<string, FormDataValue | undefined>
10
+ RequestBodyInterface extends {
11
+ [K in keyof RequestBodyInterface]: FormDataValue | undefined
12
+ }
10
13
  > implements BodyFactoryContract<RequestBodyInterface> {
11
14
  public make(body: RequestBodyInterface): BodyContract {
12
15
  return new FormDataBody<RequestBodyInterface>(body)
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { reactive, computed, toRaw, type ComputedRef, watch } from 'vue'
1
+ import { reactive, computed, toRaw, type ComputedRef, type WritableComputedRef, watch } from 'vue'
2
2
  import { camelCase, upperFirst, cloneDeep, isEqual } from 'lodash-es'
3
3
  import isEqualWith from 'lodash-es/isEqualWith'
4
4
  import { type PersistedForm } from './types/PersistedForm'
@@ -21,12 +21,21 @@ type FieldErrors = ErrorMessages | ErrorObject | ErrorArray
21
21
  type ErrorBag = Record<string, FieldErrors>
22
22
 
23
23
  type FieldProperty<T> = {
24
- model: ComputedRef<T>
24
+ model: WritableComputedRef<T>
25
25
  errors: ErrorMessages
26
26
  dirty: boolean
27
27
  touched: boolean
28
28
  }
29
29
 
30
+ type PropertyAwareToRaw<T> =
31
+ T extends Array<infer U>
32
+ ? Array<PropertyAwareToRaw<U>>
33
+ : T extends { model: { value: infer V } }
34
+ ? V
35
+ : T extends object
36
+ ? { [K in keyof T]: PropertyAwareToRaw<T[K]> }
37
+ : T
38
+
30
39
  type FormKey<FormBody extends object> = Extract<keyof FormBody, string>
31
40
  type RequestKey<RequestBody extends object> = Extract<keyof RequestBody, string>
32
41
  type ArrayItem<T> = T extends Array<infer Item> ? Item : never
@@ -54,13 +63,13 @@ function isErrorObject(value: unknown): value is ErrorObject {
54
63
  return isRecord(value)
55
64
  }
56
65
 
57
- export function propertyAwareToRaw<T>(propertyAwareObject: T): T {
66
+ export function propertyAwareToRaw<T>(propertyAwareObject: T): PropertyAwareToRaw<T> {
58
67
  if (Array.isArray(propertyAwareObject)) {
59
- return propertyAwareObject.map((item) => propertyAwareToRaw(item)) as T
68
+ return propertyAwareObject.map((item) => propertyAwareToRaw(item)) as PropertyAwareToRaw<T>
60
69
  }
61
70
 
62
71
  if (!isRecord(propertyAwareObject)) {
63
- return propertyAwareObject
72
+ return propertyAwareObject as PropertyAwareToRaw<T>
64
73
  }
65
74
 
66
75
  const result: Record<string, unknown> = {}
@@ -87,7 +96,7 @@ export function propertyAwareToRaw<T>(propertyAwareObject: T): T {
87
96
  result[key] = value
88
97
  }
89
98
 
90
- return result as T
99
+ return result as PropertyAwareToRaw<T>
91
100
  }
92
101
 
93
102
  /** Helper: shallow-merge source object into target. */
@@ -160,11 +169,11 @@ function propertyAwareDeepEqual<T>(a: T, b: T): boolean {
160
169
  * (We assume that for every key in RequestBody there is a corresponding key in FormBody.)
161
170
  */
162
171
  export abstract class BaseForm<RequestBody extends object, FormBody extends object> {
163
- public readonly state: FormBody
172
+ protected readonly state: FormBody
164
173
  private readonly dirty: DirtyMap<FormBody>
165
174
  private readonly touched: Record<keyof FormBody, boolean>
166
175
  private readonly original: FormBody
167
- private readonly _model: { [K in keyof FormBody]: ComputedRef<FormBody[K]> }
176
+ private readonly _model: { [K in keyof FormBody]: WritableComputedRef<FormBody[K]> }
168
177
  private _errors: ErrorBag = reactive<ErrorBag>({})
169
178
  private _hasErrors: ComputedRef<boolean>
170
179
  protected append: string[] = []
@@ -375,7 +384,7 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
375
384
  this.buildFieldDependencies()
376
385
 
377
386
  this.state = reactive(initialData) as FormBody
378
- this._model = {} as { [K in keyof FormBody]: ComputedRef<FormBody[K]> }
387
+ this._model = {} as { [K in keyof FormBody]: WritableComputedRef<FormBody[K]> }
379
388
 
380
389
  for (const key in this.state) {
381
390
  const value = this.state[key]
@@ -795,12 +804,18 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
795
804
 
796
805
  private getValueGetter(name: string): ((value: unknown) => unknown) | undefined {
797
806
  const candidate = (this as Record<string, unknown>)[name]
798
- return typeof candidate === 'function' ? (candidate as (value: unknown) => unknown) : undefined
807
+ if (typeof candidate !== 'function') {
808
+ return undefined
809
+ }
810
+ return (candidate as (value: unknown) => unknown).bind(this)
799
811
  }
800
812
 
801
813
  private getNoArgGetter(name: string): (() => unknown) | undefined {
802
814
  const candidate = (this as Record<string, unknown>)[name]
803
- return typeof candidate === 'function' ? (candidate as () => unknown) : undefined
815
+ if (typeof candidate !== 'function') {
816
+ return undefined
817
+ }
818
+ return (candidate as () => unknown).bind(this)
804
819
  }
805
820
 
806
821
  private transformValue(value: unknown, parentKey?: string): unknown {
@@ -23,12 +23,16 @@ export type PropertyAware<T> = {
23
23
  * computed getters/setters, error tracking, and dirty flags.
24
24
  */
25
25
  export class PropertyAwareArray<T = unknown> extends Array<T> {
26
+ // Private brand to prevent plain arrays from being assignable to PropertyAwareArray.
27
+ // This keeps conditional types from treating normal arrays as property-aware.
28
+ private readonly __propertyAwareArrayBrand!: void
26
29
  /**
27
30
  * Creates a new PropertyAwareArray instance
28
31
  */
29
32
  public constructor(items: T[] = []) {
30
33
  // Call Array constructor with array length
31
34
  super()
35
+ void this.__propertyAwareArrayBrand
32
36
 
33
37
  // Add items to the array
34
38
  if (items && items.length) {
@@ -1,7 +1,7 @@
1
1
  import { VueRequestLoader } from '../loaders/VueRequestLoader'
2
2
  import { type RequestLoaderContract } from '../../../requests/contracts/RequestLoaderContract'
3
3
  import { type Ref } from 'vue'
4
- import { RequestLoaderFactoryContract } from '../../../requests'
4
+ import type { RequestLoaderFactoryContract } from '../../../requests'
5
5
 
6
6
  export class VueRequestLoaderFactory implements RequestLoaderFactoryContract<Ref<boolean>> {
7
7
  public make(): RequestLoaderContract<Ref<boolean>> {
@@ -1,11 +0,0 @@
1
- import { PageAwarePaginator, type PageAwarePaginatorOptions } from './PageAwarePaginator'
2
-
3
- /**
4
- * @deprecated Use PageAwarePaginator instead. This alias is kept for backward compatibility.
5
- */
6
- export const Paginator = PageAwarePaginator
7
-
8
- /**
9
- * @deprecated Use PageAwarePaginatorOptions instead. This alias is kept for backward compatibility.
10
- */
11
- export type PaginatorOptions = PageAwarePaginatorOptions