b24api 2.4.2__tar.gz → 2.5.0__tar.gz
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.
- {b24api-2.4.2/b24api.egg-info → b24api-2.5.0}/PKG-INFO +11 -1
- {b24api-2.4.2 → b24api-2.5.0}/README.md +10 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/completion/closure.py +3 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/completion/gate.py +30 -1
- {b24api-2.4.2 → b24api-2.5.0}/b24api/completion/recorder.py +24 -4
- {b24api-2.4.2 → b24api-2.5.0}/b24api/completion/reference_recorder.py +9 -1
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/__init__.py +2 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/completion.py +2 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/reference.py +8 -1
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/traversal.py +46 -3
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/binding.py +65 -5
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/dispatch.py +3 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/facade.py +14 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/scheduler.py +4 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/support.py +11 -1
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/control_preflight.py +3 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/driver.py +13 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/facade.py +5 -2
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/offset_rules.py +42 -3
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/plans.py +32 -1
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/sequential.py +13 -1
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/stream.py +9 -2
- {b24api-2.4.2 → b24api-2.5.0/b24api.egg-info}/PKG-INFO +11 -1
- {b24api-2.4.2 → b24api-2.5.0}/LICENSE +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/MANIFEST.in +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/__init__.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/_client_traversal.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/_diagnostics.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/_error_types.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/_sources.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/batch/__init__.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/batch/engine.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/batch/facade.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/batch/logical.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/batch/outcome.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/batch/stream.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/cli.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/cli_contract.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/client.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/completion/__init__.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/completion/fast_recorder.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/completion/operation_stream.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/bounded_range.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/command.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/dispatch.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/error_base.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/evidence.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/identity_store.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/json.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/keyset_capability.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/keyset_execution.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/page.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/page_stop.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/policy.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/positional.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/report.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/request.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/request_summary.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/response.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/stream.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/v3_codes.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/violation.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/contracts/wire.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/encoding.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/errors.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/__init__.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/boundary.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/cleanup.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/context.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/executor.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/failure.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/lifecycle.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/rate.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/snapshot.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/execution/throttle.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/migration.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/py.typed +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/redaction.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/__init__.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/dispatch_plan.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/fanout.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/outcome.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/references/stream.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/settings.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/testing/__init__.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/testing/_isolation.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/testing/scripted.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/testing/transport.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/transport/__init__.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/transport/base.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/transport/decoding.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/transport/httpx.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/transport/log_records.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/transport/logging_shield.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/transport/protocol.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/__init__.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/counted.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/counted_batch.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/counted_rules.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/cursor.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/cursor_domain.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/facade_support.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/identity.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/identity_ledger.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_auto.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_capability.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_eligibility.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_fast_plan.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_fast_stream.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_geometry.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_observation.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_ordered_admission.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_page_validation.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_plan.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_reporting.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_runtime.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_step.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_transaction_contract.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_transactions.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/keyset_verifier.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/page_adaptation.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/sparse.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/strategy_context.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api/traversal/values.py +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api.egg-info/SOURCES.txt +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api.egg-info/dependency_links.txt +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api.egg-info/entry_points.txt +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api.egg-info/requires.txt +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/b24api.egg-info/top_level.txt +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/pyproject.toml +0 -0
- {b24api-2.4.2 → b24api-2.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: b24api
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Bitrix24 API
|
|
5
5
|
Author-email: Shkarupa Alex <shkarupa.alex@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -269,6 +269,12 @@ async with stream:
|
|
|
269
269
|
Without `identity`, successful exhaustion is reported as `MECHANICS_ONLY`: pagination completed,
|
|
270
270
|
but the client cannot prove that the portal did not duplicate or substitute rows.
|
|
271
271
|
|
|
272
|
+
An endpoint that honors `start` only at a fixed server window and signals its end only with a
|
|
273
|
+
shorter page (no usable `total`, no `next`) can declare that stop rule with
|
|
274
|
+
`OffsetSpec(short_page_termination=ShortPageTermination.DECLARED_TERMINAL)`; its successful
|
|
275
|
+
exhaustion is always `MECHANICS_ONLY`. See
|
|
276
|
+
[Declared short-page closure](https://github.com/shkarupa-alex/b24api/blob/master/docs/recipes.md#declared-short-page-closure).
|
|
277
|
+
|
|
272
278
|
Mapping-backed collections are explicit as well. `MAPPING_VALUES` yields values from a selected
|
|
273
279
|
mapping in insertion order; `MAPPING_VALUES_OR_EMPTY` additionally accepts only an empty terminal
|
|
274
280
|
sequence and records that degradation in the operation report.
|
|
@@ -469,6 +475,10 @@ async with stream:
|
|
|
469
475
|
record_completion(event.correlation, event.row_count)
|
|
470
476
|
```
|
|
471
477
|
|
|
478
|
+
With `KeysetTraversal`, a binding may also set simple constant fields directly inside the keyset
|
|
479
|
+
filter, such as `filter[=ownerId]`, while each binding keeps its own cursor; see
|
|
480
|
+
[Keyset references with per-owner filters](https://github.com/shkarupa-alex/b24api/blob/master/docs/recipes.md#keyset-references-with-per-owner-filters).
|
|
481
|
+
|
|
472
482
|
For messages across chats, use the same `iter_references()` shape: each binding updates the chat
|
|
473
483
|
parameter and carries the chat correlation; choose `CursorTraversal` when the message endpoint is
|
|
474
484
|
cursor-based. Identity tracking and completion remain scoped to each binding, so equal child IDs
|
|
@@ -238,6 +238,12 @@ async with stream:
|
|
|
238
238
|
Without `identity`, successful exhaustion is reported as `MECHANICS_ONLY`: pagination completed,
|
|
239
239
|
but the client cannot prove that the portal did not duplicate or substitute rows.
|
|
240
240
|
|
|
241
|
+
An endpoint that honors `start` only at a fixed server window and signals its end only with a
|
|
242
|
+
shorter page (no usable `total`, no `next`) can declare that stop rule with
|
|
243
|
+
`OffsetSpec(short_page_termination=ShortPageTermination.DECLARED_TERMINAL)`; its successful
|
|
244
|
+
exhaustion is always `MECHANICS_ONLY`. See
|
|
245
|
+
[Declared short-page closure](https://github.com/shkarupa-alex/b24api/blob/master/docs/recipes.md#declared-short-page-closure).
|
|
246
|
+
|
|
241
247
|
Mapping-backed collections are explicit as well. `MAPPING_VALUES` yields values from a selected
|
|
242
248
|
mapping in insertion order; `MAPPING_VALUES_OR_EMPTY` additionally accepts only an empty terminal
|
|
243
249
|
sequence and records that degradation in the operation report.
|
|
@@ -438,6 +444,10 @@ async with stream:
|
|
|
438
444
|
record_completion(event.correlation, event.row_count)
|
|
439
445
|
```
|
|
440
446
|
|
|
447
|
+
With `KeysetTraversal`, a binding may also set simple constant fields directly inside the keyset
|
|
448
|
+
filter, such as `filter[=ownerId]`, while each binding keeps its own cursor; see
|
|
449
|
+
[Keyset references with per-owner filters](https://github.com/shkarupa-alex/b24api/blob/master/docs/recipes.md#keyset-references-with-per-owner-filters).
|
|
450
|
+
|
|
441
451
|
For messages across chats, use the same `iter_references()` shape: each binding updates the chat
|
|
442
452
|
parameter and carries the chat correlation; choose `CursorTraversal` when the message endpoint is
|
|
443
453
|
cursor-based. Identity tracking and completion remain scoped to each binding, so equal child IDs
|
|
@@ -14,6 +14,7 @@ SINGLE_RESPONSE_COMPLETE = "single response complete"
|
|
|
14
14
|
QUALIFIED_TOTAL_REACHED = "qualified total reached"
|
|
15
15
|
SPARSE_RAW_RANGE_COVERED = "qualified sparse raw range covered"
|
|
16
16
|
ADMITTED_UPPER_BOUNDARY_REACHED = "exact admitted upper boundary reached"
|
|
17
|
+
DECLARED_SHORT_PAGE_REACHED = "declared short page reached"
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def qualified_closure(terminal_reason: str | None) -> BindingClosure | None:
|
|
@@ -27,5 +28,7 @@ def qualified_closure(terminal_reason: str | None) -> BindingClosure | None:
|
|
|
27
28
|
return BindingClosure.RAW_RANGE_COVERED
|
|
28
29
|
case "exact admitted upper boundary reached":
|
|
29
30
|
return BindingClosure.BOUNDARY_SEEN
|
|
31
|
+
case "declared short page reached":
|
|
32
|
+
return BindingClosure.DECLARED_SHORT_PAGE
|
|
30
33
|
case _:
|
|
31
34
|
return None
|
|
@@ -31,6 +31,7 @@ from b24api.contracts.report import (
|
|
|
31
31
|
Violation,
|
|
32
32
|
ViolationSeverity,
|
|
33
33
|
)
|
|
34
|
+
from b24api.contracts.traversal import DECLARED_SHORT_PAGE_MINIMUM_WIDTH
|
|
34
35
|
from b24api.contracts.violation import retain_violations
|
|
35
36
|
|
|
36
37
|
if TYPE_CHECKING:
|
|
@@ -114,8 +115,15 @@ def _binding_closure_violation(binding: _Binding, event: BindingTerminal) -> str
|
|
|
114
115
|
"completion_unknown_binding_claimed_known",
|
|
115
116
|
),
|
|
116
117
|
(
|
|
118
|
+
# A declared short-page closure reports a missing page as its own witness failure below.
|
|
117
119
|
binding.last_page_id < 0
|
|
118
|
-
and closure
|
|
120
|
+
and closure
|
|
121
|
+
not in {
|
|
122
|
+
BindingClosure.CALLER_STOP,
|
|
123
|
+
BindingClosure.FAILURE,
|
|
124
|
+
BindingClosure.UNKNOWN,
|
|
125
|
+
BindingClosure.DECLARED_SHORT_PAGE,
|
|
126
|
+
},
|
|
119
127
|
"completion_terminal_lacks_page_witness",
|
|
120
128
|
),
|
|
121
129
|
(
|
|
@@ -150,10 +158,31 @@ def _binding_closure_violation(binding: _Binding, event: BindingTerminal) -> str
|
|
|
150
158
|
),
|
|
151
159
|
"completion_missing_keyset_plan_witness",
|
|
152
160
|
),
|
|
161
|
+
(
|
|
162
|
+
closure is BindingClosure.DECLARED_SHORT_PAGE and not _declared_short_page_witnessed(binding, event),
|
|
163
|
+
"completion_invalid_declared_short_page_witness",
|
|
164
|
+
),
|
|
165
|
+
(
|
|
166
|
+
closure is not BindingClosure.DECLARED_SHORT_PAGE and event.declared_short_page_width is not None,
|
|
167
|
+
"completion_unexpected_declared_short_page_width",
|
|
168
|
+
),
|
|
153
169
|
)
|
|
154
170
|
return next((code for failed, code in checks if failed), None)
|
|
155
171
|
|
|
156
172
|
|
|
173
|
+
def _declared_short_page_witnessed(binding: _Binding, event: BindingTerminal) -> bool:
|
|
174
|
+
"""Require the last acknowledged page to be non-empty and shorter than the declared window."""
|
|
175
|
+
width = event.declared_short_page_width
|
|
176
|
+
rows = binding.last_acknowledged_rows
|
|
177
|
+
return (
|
|
178
|
+
type(width) is int
|
|
179
|
+
and width >= DECLARED_SHORT_PAGE_MINIMUM_WIDTH
|
|
180
|
+
and binding.acknowledged_pages > 0
|
|
181
|
+
and rows is not None
|
|
182
|
+
and 0 < rows < width
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
157
186
|
class CompletionGate:
|
|
158
187
|
"""Accept ordered evidence with O(active bindings + in-flight pages) memory."""
|
|
159
188
|
|
|
@@ -48,9 +48,14 @@ class CompletionSink(Protocol):
|
|
|
48
48
|
class CompletionRecorder:
|
|
49
49
|
"""Emit bounded correlated events in the physical traversal lifecycle."""
|
|
50
50
|
|
|
51
|
-
def __init__(self) -> None:
|
|
52
|
-
"""Admit the sole binding before its first possible page dispatch.
|
|
51
|
+
def __init__(self, *, declared_short_page_width: int | None = None) -> None:
|
|
52
|
+
"""Admit the sole binding before its first possible page dispatch.
|
|
53
|
+
|
|
54
|
+
``declared_short_page_width`` is the window of a caller-declared short-page closure; it is
|
|
55
|
+
witnessed only when the binding actually closes on such a page.
|
|
56
|
+
"""
|
|
53
57
|
self.gate = CompletionGate(uuid4().hex)
|
|
58
|
+
self._declared_short_page_width = declared_short_page_width
|
|
54
59
|
self._sequence = 0
|
|
55
60
|
self._next_page = 0
|
|
56
61
|
self._current: int | None = None
|
|
@@ -153,7 +158,14 @@ class CompletionRecorder:
|
|
|
153
158
|
)
|
|
154
159
|
self._current = None
|
|
155
160
|
|
|
156
|
-
def terminal(
|
|
161
|
+
def terminal(
|
|
162
|
+
self,
|
|
163
|
+
closure: BindingClosure,
|
|
164
|
+
stream: StreamClosure,
|
|
165
|
+
*,
|
|
166
|
+
qualified_total: int | None = None,
|
|
167
|
+
declared_short_page_width: int | None = None,
|
|
168
|
+
) -> None:
|
|
157
169
|
"""Settle binding and producer before owned-resource cleanup."""
|
|
158
170
|
self.gate.emit(
|
|
159
171
|
BindingTerminal(
|
|
@@ -162,6 +174,7 @@ class CompletionRecorder:
|
|
|
162
174
|
binding_id=0,
|
|
163
175
|
closure=closure,
|
|
164
176
|
qualified_total=qualified_total,
|
|
177
|
+
declared_short_page_width=declared_short_page_width,
|
|
165
178
|
)
|
|
166
179
|
)
|
|
167
180
|
self.gate.emit(
|
|
@@ -201,7 +214,14 @@ class CompletionRecorder:
|
|
|
201
214
|
if state is KernelState.CANCELLED
|
|
202
215
|
else StreamClosure.EARLY_CLOSE
|
|
203
216
|
)
|
|
204
|
-
self.terminal(
|
|
217
|
+
self.terminal(
|
|
218
|
+
closure,
|
|
219
|
+
stream,
|
|
220
|
+
qualified_total=qualified_total,
|
|
221
|
+
declared_short_page_width=(
|
|
222
|
+
self._declared_short_page_width if closure is BindingClosure.DECLARED_SHORT_PAGE else None
|
|
223
|
+
),
|
|
224
|
+
)
|
|
205
225
|
|
|
206
226
|
def cleanup(self, state: CleanupState) -> None:
|
|
207
227
|
"""Finish only after cleanup was observed."""
|
|
@@ -64,7 +64,14 @@ class ReferenceCompletionRecorder:
|
|
|
64
64
|
"""Return the active adapter for one admitted binding."""
|
|
65
65
|
return self._bindings[binding_id]
|
|
66
66
|
|
|
67
|
-
def terminal(
|
|
67
|
+
def terminal(
|
|
68
|
+
self,
|
|
69
|
+
binding_id: int,
|
|
70
|
+
closure: BindingClosure,
|
|
71
|
+
*,
|
|
72
|
+
qualified_total: int | None = None,
|
|
73
|
+
declared_short_page_width: int | None = None,
|
|
74
|
+
) -> None:
|
|
68
75
|
"""Retire one accounted binding after its final outcome is delivered."""
|
|
69
76
|
self.gate.emit(
|
|
70
77
|
BindingTerminal(
|
|
@@ -73,6 +80,7 @@ class ReferenceCompletionRecorder:
|
|
|
73
80
|
binding_id=binding_id,
|
|
74
81
|
closure=closure,
|
|
75
82
|
qualified_total=qualified_total,
|
|
83
|
+
declared_short_page_width=declared_short_page_width,
|
|
76
84
|
)
|
|
77
85
|
)
|
|
78
86
|
del self._bindings[binding_id]
|
|
@@ -136,6 +136,7 @@ from b24api.contracts.traversal import (
|
|
|
136
136
|
PageStride,
|
|
137
137
|
RawTotalSource,
|
|
138
138
|
SequentialTraversal,
|
|
139
|
+
ShortPageTermination,
|
|
139
140
|
SparseRawBound,
|
|
140
141
|
SplitOrderSpec,
|
|
141
142
|
TotalTermination,
|
|
@@ -265,6 +266,7 @@ __all__ = [
|
|
|
265
266
|
"RouteKind",
|
|
266
267
|
"SequentialKeysetExecution",
|
|
267
268
|
"SequentialTraversal",
|
|
269
|
+
"ShortPageTermination",
|
|
268
270
|
"SlotContract",
|
|
269
271
|
"SlotShape",
|
|
270
272
|
"SparseRawBound",
|
|
@@ -26,6 +26,7 @@ class BindingClosure(StrEnum):
|
|
|
26
26
|
SINGLE_RESPONSE = "single_response"
|
|
27
27
|
BOUNDARY_SEEN = "boundary_seen"
|
|
28
28
|
KEYSET_PLAN_COVERED = "keyset_plan_covered"
|
|
29
|
+
DECLARED_SHORT_PAGE = "declared_short_page"
|
|
29
30
|
CALLER_STOP = "caller_stop"
|
|
30
31
|
FAILURE = "failure"
|
|
31
32
|
UNKNOWN = "unknown"
|
|
@@ -142,6 +143,7 @@ class BindingTerminal(CompletionEvent):
|
|
|
142
143
|
closure: BindingClosure
|
|
143
144
|
qualified_total: int | None = None
|
|
144
145
|
qualified_witnesses: int | None = None
|
|
146
|
+
declared_short_page_width: int | None = None
|
|
145
147
|
|
|
146
148
|
|
|
147
149
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
|
|
13
13
|
from collections.abc import Iterable
|
|
14
14
|
|
|
15
15
|
from b24api.contracts.command import NotExecutedReason
|
|
16
|
+
from b24api.contracts.completion import BindingClosure
|
|
16
17
|
from b24api.contracts.error_base import B24ApiError
|
|
17
18
|
|
|
18
19
|
_SUMMARY_MAXIMUM = 256
|
|
@@ -83,13 +84,19 @@ class ReferenceItem[C]:
|
|
|
83
84
|
|
|
84
85
|
@dataclass(frozen=True, slots=True)
|
|
85
86
|
class ReferenceComplete[C]:
|
|
86
|
-
"""Successful binding terminal, with source-exhaustion evidence distinguished.
|
|
87
|
+
"""Successful binding terminal, with source-exhaustion evidence distinguished.
|
|
88
|
+
|
|
89
|
+
``closure`` names the witness that ended this binding (for example ``SOURCE_EMPTY`` or
|
|
90
|
+
``DECLARED_SHORT_PAGE``); it is excluded from equality and hashing, so comparisons by the
|
|
91
|
+
other fields keep their meaning, and it is ``None`` only on a value built without it.
|
|
92
|
+
"""
|
|
87
93
|
|
|
88
94
|
binding_index: int
|
|
89
95
|
correlation: C = field(repr=False)
|
|
90
96
|
row_count: int
|
|
91
97
|
exhausted: bool = True
|
|
92
98
|
stop_reason: str | None = None
|
|
99
|
+
closure: BindingClosure | None = field(default=None, compare=False)
|
|
93
100
|
|
|
94
101
|
|
|
95
102
|
@dataclass(frozen=True, slots=True)
|
|
@@ -23,6 +23,9 @@ _ORDER = ParameterPath(("order",))
|
|
|
23
23
|
_ROOT_SELECTOR = ResultSelector.root()
|
|
24
24
|
_SEQUENTIAL_KEYSET_EXECUTION = SequentialKeysetExecution()
|
|
25
25
|
_IDENTITY_PAGE_ADAPTER = IdentityPageAdapter()
|
|
26
|
+
# A one-row window has no short page: every non-empty page would be full. The contract, the offset plan and the
|
|
27
|
+
# completion gate all enforce this one bound, so they share this single definition.
|
|
28
|
+
DECLARED_SHORT_PAGE_MINIMUM_WIDTH = 2
|
|
26
29
|
|
|
27
30
|
|
|
28
31
|
class OffsetContinuation(StrEnum):
|
|
@@ -41,6 +44,22 @@ class TotalTermination(StrEnum):
|
|
|
41
44
|
EXACT_QUALIFIED = "exact_qualified"
|
|
42
45
|
|
|
43
46
|
|
|
47
|
+
class ShortPageTermination(StrEnum):
|
|
48
|
+
"""Whether a short page may end a fixed-step offset traversal.
|
|
49
|
+
|
|
50
|
+
Some endpoints honor ``start`` only at a fixed server window, report no usable total and send no
|
|
51
|
+
``next``, so the only end signal is a page shorter than that window. The client cannot prove
|
|
52
|
+
that from the wire: a short page may also mean a truncated or misbehaving response. ``DISABLED``
|
|
53
|
+
therefore keeps refusing an unexplained short page. ``DECLARED_TERMINAL`` is the caller's own
|
|
54
|
+
qualification of the endpoint: a non-empty page shorter than the declared window closes the
|
|
55
|
+
traversal as exhausted. That result proves the declared stop rule was met, not that the
|
|
56
|
+
snapshot is complete, so its assurance stays ``MECHANICS_ONLY``.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
DISABLED = "disabled"
|
|
60
|
+
DECLARED_TERMINAL = "declared_terminal"
|
|
61
|
+
|
|
62
|
+
|
|
44
63
|
class CursorDomain(StrEnum):
|
|
45
64
|
"""How a cursor control is interpreted by its qualified endpoint."""
|
|
46
65
|
|
|
@@ -161,6 +180,27 @@ def _validate_offset_extensions(spec: OffsetSpec) -> None: # noqa: C901 - close
|
|
|
161
180
|
raise ValueError("sparse raw bound requires its declared page_stride")
|
|
162
181
|
if spec.total_termination is not TotalTermination.DISABLED:
|
|
163
182
|
raise ValueError("sparse raw bound owns closure independently of selected count")
|
|
183
|
+
if spec.short_page_termination is ShortPageTermination.DECLARED_TERMINAL:
|
|
184
|
+
_validate_declared_short_page(spec)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _validate_declared_short_page(spec: OffsetSpec) -> None:
|
|
188
|
+
"""Admit a declared short-page closure only over one exact fixed wire window."""
|
|
189
|
+
if (
|
|
190
|
+
spec.continuation is not OffsetContinuation.FIXED_STEP
|
|
191
|
+
or spec.total_termination is not TotalTermination.DISABLED
|
|
192
|
+
):
|
|
193
|
+
raise ValueError("declared short-page termination requires fixed-step continuation without a total")
|
|
194
|
+
if spec.page_index is not None or spec.sparse_raw_bound is not None:
|
|
195
|
+
raise ValueError("declared short-page termination cannot combine with page_index or a sparse raw bound")
|
|
196
|
+
stride = spec.page_stride
|
|
197
|
+
if stride is None:
|
|
198
|
+
raise ValueError("declared short-page termination requires a qualified page_stride")
|
|
199
|
+
width = stride.max_decoded_rows
|
|
200
|
+
if not spec.step == stride.wire_increment == width or width < DECLARED_SHORT_PAGE_MINIMUM_WIDTH:
|
|
201
|
+
raise ValueError("declared short-page termination requires step equal to one decoded window of at least 2")
|
|
202
|
+
if stride.requested_wire_limit is not None and stride.requested_wire_limit != width:
|
|
203
|
+
raise ValueError("declared short-page termination requires a requested wire limit equal to the window")
|
|
164
204
|
|
|
165
205
|
|
|
166
206
|
@dataclass(frozen=True, slots=True)
|
|
@@ -176,12 +216,14 @@ class OffsetSpec:
|
|
|
176
216
|
page_index: PageIndex | None = None
|
|
177
217
|
page_stride: PageStride | None = None
|
|
178
218
|
sparse_raw_bound: SparseRawBound | None = None
|
|
219
|
+
short_page_termination: ShortPageTermination = ShortPageTermination.DISABLED
|
|
179
220
|
|
|
180
221
|
def __post_init__(self) -> None:
|
|
181
222
|
"""Validate offset mechanics and completion semantics."""
|
|
182
|
-
if
|
|
183
|
-
self.
|
|
184
|
-
TotalTermination
|
|
223
|
+
if (
|
|
224
|
+
not isinstance(self.continuation, OffsetContinuation)
|
|
225
|
+
or not isinstance(self.total_termination, TotalTermination)
|
|
226
|
+
or not isinstance(self.short_page_termination, ShortPageTermination)
|
|
185
227
|
):
|
|
186
228
|
raise TypeError("offset controls must use their declared enum types")
|
|
187
229
|
_validate_offset_extensions(self)
|
|
@@ -402,6 +444,7 @@ __all__ = [
|
|
|
402
444
|
"OffsetContinuation",
|
|
403
445
|
"OffsetSpec",
|
|
404
446
|
"SequentialTraversal",
|
|
447
|
+
"ShortPageTermination",
|
|
405
448
|
"SplitOrderSpec",
|
|
406
449
|
"TotalTermination",
|
|
407
450
|
"TraversalSpec",
|
|
@@ -9,7 +9,7 @@ from b24api._sources import OwnedSource
|
|
|
9
9
|
from b24api.contracts.command import NotExecutedReason
|
|
10
10
|
from b24api.contracts.json import _freeze_json
|
|
11
11
|
from b24api.contracts.reference import Binding
|
|
12
|
-
from b24api.contracts.traversal import CursorTraversal, TraversalSpec, traversal_control_paths
|
|
12
|
+
from b24api.contracts.traversal import CursorTraversal, KeysetTraversal, TraversalSpec, traversal_control_paths
|
|
13
13
|
from b24api.errors import CapabilityError, InputSourceError, PaginationError
|
|
14
14
|
from b24api.references.outcome import ReferenceRequest
|
|
15
15
|
from b24api.traversal.cursor_domain import validate_cursor_value
|
|
@@ -18,6 +18,7 @@ from b24api.traversal.values import _coerce_identity
|
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from b24api.contracts.json import JsonValue
|
|
21
|
+
from b24api.contracts.reference import ParameterUpdate
|
|
21
22
|
from b24api.contracts.report import Violation
|
|
22
23
|
from b24api.contracts.request import ParameterPath, Request
|
|
23
24
|
|
|
@@ -51,12 +52,71 @@ def _overlaps(left: tuple[str | int, ...], right: tuple[str | int, ...]) -> bool
|
|
|
51
52
|
return left[:shared] == right[:shared]
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
_CONTROL_COLLISION = "binding update collides with a traversal control path"
|
|
56
|
+
_FILTER_COMBINATORS = frozenset({"logic", "and", "or"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def binding_update_conflict(traversal: TraversalSpec, update: ParameterUpdate) -> str | None:
|
|
60
|
+
"""Return a stable local-validation reason, or None when this update may compose.
|
|
61
|
+
|
|
62
|
+
Every traversal control path stays exclusive, except that a keyset binding may set one simple
|
|
63
|
+
sibling field directly inside ``KeysetSpec.filter_path`` (for example ``=ownerId``): the
|
|
64
|
+
traversal writes only its own cursor key there, so a constant beside it composes with the
|
|
65
|
+
binding's own cursor. ``traversal_control_paths`` names the containers a traversal writes and
|
|
66
|
+
does not decide admission.
|
|
67
|
+
"""
|
|
68
|
+
update_path = _normalized(update.path)
|
|
69
|
+
filter_path = _normalized(traversal.keyset.filter_path) if isinstance(traversal, KeysetTraversal) else None
|
|
70
|
+
for control in traversal_control_paths(traversal):
|
|
71
|
+
normalized = _normalized(control)
|
|
72
|
+
if normalized != filter_path and _overlaps(update_path, normalized):
|
|
73
|
+
return _CONTROL_COLLISION
|
|
74
|
+
if not isinstance(traversal, KeysetTraversal) or filter_path is None or not _overlaps(update_path, filter_path):
|
|
75
|
+
return None
|
|
76
|
+
return _keyset_filter_conflict(traversal, update, len(filter_path))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _keyset_filter_conflict(traversal: KeysetTraversal, update: ParameterUpdate, depth: int) -> str | None:
|
|
80
|
+
"""Admit one scalar sibling field of the keyset filter whose name differs from the cursor field."""
|
|
81
|
+
parts = update.path.path
|
|
82
|
+
if len(parts) != depth + 1:
|
|
83
|
+
return "binding may set only a direct field inside the keyset filter"
|
|
84
|
+
cursor_field = traversal.identity.filter_key
|
|
85
|
+
if not cursor_field.isidentifier():
|
|
86
|
+
# An operator-bearing cursor key cannot be compared by field name, so nothing may sit beside it.
|
|
87
|
+
return "keyset filter key is not a simple field name"
|
|
88
|
+
field = _filter_field(parts[-1])
|
|
89
|
+
if field is None or field.casefold() in _FILTER_COMBINATORS:
|
|
90
|
+
return "binding keyset filter key is not a simple field"
|
|
91
|
+
if field.casefold() == cursor_field.casefold():
|
|
92
|
+
return "binding keyset filter key names the cursor field"
|
|
93
|
+
if not _flat_scalar(update.value):
|
|
94
|
+
return "binding keyset filter value is not a scalar or a flat scalar list"
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _filter_field(key: str | int) -> str | None:
|
|
99
|
+
"""Strip a leading filter operator and return the remaining simple field name, if any."""
|
|
100
|
+
if not isinstance(key, str):
|
|
101
|
+
return None
|
|
102
|
+
index = 0
|
|
103
|
+
while index < len(key) and not (key[index].isalnum() or key[index] == "_"):
|
|
104
|
+
index += 1
|
|
105
|
+
field = key[index:]
|
|
106
|
+
return field if field.isidentifier() else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _flat_scalar(value: JsonValue) -> bool:
|
|
110
|
+
if isinstance(value, list):
|
|
111
|
+
return all(not isinstance(item, dict | list) for item in value)
|
|
112
|
+
return not isinstance(value, dict)
|
|
113
|
+
|
|
114
|
+
|
|
54
115
|
def _validate_binding_controls(binding: Binding[object], traversal: TraversalSpec) -> None:
|
|
55
|
-
controls = tuple(_normalized(path) for path in traversal_control_paths(traversal))
|
|
56
116
|
for update in binding.updates:
|
|
57
|
-
|
|
58
|
-
if
|
|
59
|
-
raise ValueError(
|
|
117
|
+
reason = binding_update_conflict(traversal, update)
|
|
118
|
+
if reason is not None:
|
|
119
|
+
raise ValueError(reason)
|
|
60
120
|
|
|
61
121
|
|
|
62
122
|
def _matching_key(mapping: dict[str, JsonValue], requested: str) -> str | None:
|
|
@@ -25,6 +25,7 @@ from b24api.references.outcome import (
|
|
|
25
25
|
|
|
26
26
|
if TYPE_CHECKING:
|
|
27
27
|
from b24api.contracts.command import NotExecutedReason
|
|
28
|
+
from b24api.contracts.completion import BindingClosure
|
|
28
29
|
from b24api.contracts.json import FrozenJson, JsonValue
|
|
29
30
|
from b24api.contracts.report import PageRecord, Violation
|
|
30
31
|
from b24api.contracts.request import Request
|
|
@@ -83,6 +84,7 @@ class _DoneEvent:
|
|
|
83
84
|
stopped_reason: str | None = None
|
|
84
85
|
terminal_reason: str | None = None
|
|
85
86
|
qualified_total: int | None = None
|
|
87
|
+
declared_short_page_width: int | None = None
|
|
86
88
|
|
|
87
89
|
|
|
88
90
|
@dataclass(frozen=True, slots=True)
|
|
@@ -106,6 +108,7 @@ class _KernelReferenceComplete:
|
|
|
106
108
|
reference: ReferenceRequest
|
|
107
109
|
row_count: int
|
|
108
110
|
stopped_reason: str | None = None
|
|
111
|
+
closure: BindingClosure | None = None
|
|
109
112
|
|
|
110
113
|
|
|
111
114
|
@dataclass(frozen=True, slots=True)
|
|
@@ -23,6 +23,7 @@ from b24api.contracts.reference import (
|
|
|
23
23
|
ReferenceOutcome,
|
|
24
24
|
ReferenceOutcomeUnknown,
|
|
25
25
|
)
|
|
26
|
+
from b24api.contracts.report import TraversalAssurance
|
|
26
27
|
from b24api.contracts.request import (
|
|
27
28
|
IdentitySpec,
|
|
28
29
|
Request,
|
|
@@ -35,6 +36,7 @@ from b24api.contracts.traversal import (
|
|
|
35
36
|
CountedTraversal,
|
|
36
37
|
KeysetTraversal,
|
|
37
38
|
SequentialTraversal,
|
|
39
|
+
ShortPageTermination,
|
|
38
40
|
TraversalSpec,
|
|
39
41
|
)
|
|
40
42
|
from b24api.errors import (
|
|
@@ -221,6 +223,7 @@ class _ReferenceEventMapper:
|
|
|
221
223
|
event.row_count,
|
|
222
224
|
exhausted=event.stopped_reason is None,
|
|
223
225
|
stop_reason=event.stopped_reason,
|
|
226
|
+
closure=event.closure,
|
|
224
227
|
)
|
|
225
228
|
context = cast("_BindingContext", event.correlation)
|
|
226
229
|
self._item_indexes.pop(context.index, None)
|
|
@@ -256,6 +259,16 @@ class _ReferenceEventMapper:
|
|
|
256
259
|
)
|
|
257
260
|
|
|
258
261
|
|
|
262
|
+
def _reference_assurance(traversal: TraversalSpec) -> TraversalAssurance | None:
|
|
263
|
+
"""Declare mechanics only for a declared short-page closure: it proves the stop rule, not empty later windows."""
|
|
264
|
+
if (
|
|
265
|
+
isinstance(traversal, SequentialTraversal)
|
|
266
|
+
and traversal.offset.short_page_termination is ShortPageTermination.DECLARED_TERMINAL
|
|
267
|
+
):
|
|
268
|
+
return TraversalAssurance.MECHANICS_ONLY
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
259
272
|
def _reference_variant(outcome: ReferenceOutcome[object]) -> str:
|
|
260
273
|
if isinstance(outcome, ReferenceItem):
|
|
261
274
|
return "item"
|
|
@@ -377,6 +390,7 @@ def reference_stream[C](
|
|
|
377
390
|
source,
|
|
378
391
|
mapper,
|
|
379
392
|
operation="iter_reference_outcomes" if tolerant else "iter_references",
|
|
393
|
+
assurance=_reference_assurance(traversal),
|
|
380
394
|
classify=_reference_variant,
|
|
381
395
|
error_mapper=lambda error, report: _reference_error(error, report, mapper),
|
|
382
396
|
error_items=lambda error: _reference_error_items(error, mapper),
|
|
@@ -47,6 +47,8 @@ from b24api.references.outcome import (
|
|
|
47
47
|
)
|
|
48
48
|
from b24api.references.support import (
|
|
49
49
|
_active_limit,
|
|
50
|
+
_declared_short_page_width,
|
|
51
|
+
_done_closure,
|
|
50
52
|
_finish_done_completion,
|
|
51
53
|
_finish_task,
|
|
52
54
|
_new_page_records,
|
|
@@ -392,6 +394,7 @@ class ReferenceScheduler:
|
|
|
392
394
|
run.stopped_reason,
|
|
393
395
|
driver.terminal_reason,
|
|
394
396
|
driver.expected_total,
|
|
397
|
+
_declared_short_page_width(self.plan, driver.terminal_reason),
|
|
395
398
|
),
|
|
396
399
|
)
|
|
397
400
|
except asyncio.CancelledError:
|
|
@@ -652,6 +655,7 @@ class ReferenceScheduler:
|
|
|
652
655
|
event.work.reference,
|
|
653
656
|
event.row_count,
|
|
654
657
|
event.stopped_reason,
|
|
658
|
+
_done_closure(event),
|
|
655
659
|
)
|
|
656
660
|
return
|
|
657
661
|
request = event.work.reference.request
|
|
@@ -5,7 +5,7 @@ import asyncio
|
|
|
5
5
|
import contextlib
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from b24api.completion.closure import qualified_closure
|
|
8
|
+
from b24api.completion.closure import DECLARED_SHORT_PAGE_REACHED, qualified_closure
|
|
9
9
|
from b24api.contracts.completion import BindingClosure
|
|
10
10
|
from b24api.contracts.report import PageRecord, Violation, ViolationSeverity
|
|
11
11
|
from b24api.traversal.plans import (
|
|
@@ -33,6 +33,13 @@ def _done_closure(event: _DoneEvent) -> BindingClosure:
|
|
|
33
33
|
return qualified_closure(event.terminal_reason) or BindingClosure.SOURCE_EMPTY
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _declared_short_page_width(plan: ListPlan, terminal_reason: str | None) -> int | None:
|
|
37
|
+
"""Return the declared window a reference's short-page closure is witnessed against, else None."""
|
|
38
|
+
if isinstance(plan, OffsetSequentialPlan) and terminal_reason == DECLARED_SHORT_PAGE_REACHED:
|
|
39
|
+
return plan.short_page_width
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
36
43
|
def _finish_done_completion(completion: ReferenceCompletionRecorder, event: _DoneEvent) -> None:
|
|
37
44
|
"""Retire an acknowledged reference with its qualified closure evidence."""
|
|
38
45
|
completion.binding(event.work.index).complete_omitted_empty()
|
|
@@ -41,6 +48,9 @@ def _finish_done_completion(completion: ReferenceCompletionRecorder, event: _Don
|
|
|
41
48
|
event.work.index,
|
|
42
49
|
closure,
|
|
43
50
|
qualified_total=event.qualified_total if closure is BindingClosure.QUALIFIED_TOTAL else None,
|
|
51
|
+
declared_short_page_width=(
|
|
52
|
+
event.declared_short_page_width if closure is BindingClosure.DECLARED_SHORT_PAGE else None
|
|
53
|
+
),
|
|
44
54
|
)
|
|
45
55
|
|
|
46
56
|
|
|
@@ -27,6 +27,9 @@ def _aligned_initial_offset(driver: StrategyContext, plan: OffsetSequentialPlan)
|
|
|
27
27
|
if stride is not None and initial_offset % stride.server_granularity:
|
|
28
28
|
# The server would silently serve the floor window, repeating or skipping raw rows.
|
|
29
29
|
raise CapabilityError("initial offset must align with the qualified server page granularity")
|
|
30
|
+
if initial_offset != 0 and OffsetTerminalRule.DECLARED_SHORT_PAGE in plan.terminal:
|
|
31
|
+
# A declared short page closes only the collection read from its first window.
|
|
32
|
+
raise CapabilityError("declared short-page traversal must start at offset zero")
|
|
30
33
|
if initial_offset != plan.initial_control and OffsetTerminalRule.QUALIFIED_TOTAL in plan.terminal:
|
|
31
34
|
# The exact total counts the whole collection; a suffix never reaches it and would end incomplete.
|
|
32
35
|
raise CapabilityError(
|
|
@@ -14,6 +14,7 @@ from b24api.contracts.policy import (
|
|
|
14
14
|
ExecutionPolicy,
|
|
15
15
|
IdentityCoercion,
|
|
16
16
|
IdentityRequirement,
|
|
17
|
+
SnapshotRequirement,
|
|
17
18
|
TotalSemantics,
|
|
18
19
|
)
|
|
19
20
|
from b24api.contracts.report import (
|
|
@@ -68,6 +69,7 @@ from b24api.traversal.plans import (
|
|
|
68
69
|
KeysetTerminalRule,
|
|
69
70
|
ListPlan,
|
|
70
71
|
OffsetSequentialPlan,
|
|
72
|
+
OffsetTerminalRule,
|
|
71
73
|
SingleResponsePlan,
|
|
72
74
|
)
|
|
73
75
|
from b24api.traversal.sequential import CountedStrategy, OffsetStrategy, SingleResponseStrategy
|
|
@@ -391,6 +393,17 @@ class PaginationDriver:
|
|
|
391
393
|
)
|
|
392
394
|
if isinstance(plan, KeysetPlan) and plan.terminal is KeysetTerminalRule.BOUNDARY_ID_SEEN:
|
|
393
395
|
raise CapabilityError("boundary-id keyset requires an externally reviewed boundary contract")
|
|
396
|
+
if (
|
|
397
|
+
isinstance(plan, OffsetSequentialPlan)
|
|
398
|
+
and OffsetTerminalRule.DECLARED_SHORT_PAGE in plan.terminal
|
|
399
|
+
and (
|
|
400
|
+
consistency.total_semantics is not TotalSemantics.IGNORE
|
|
401
|
+
or consistency.confirmation_policy is not ConfirmationPolicy.NONE
|
|
402
|
+
or consistency.snapshot_requirement is not SnapshotRequirement.TRAVERSAL_ONLY
|
|
403
|
+
)
|
|
404
|
+
):
|
|
405
|
+
# A declared short page proves only the endpoint's stop rule, never a total, boundary or snapshot.
|
|
406
|
+
raise CapabilityError("declared short-page traversal supports only the traversal-only consistency policy")
|
|
394
407
|
return _EffectiveConsistency(
|
|
395
408
|
duplicate_policy,
|
|
396
409
|
total_semantics,
|
|
@@ -26,7 +26,7 @@ from b24api.contracts.policy import (
|
|
|
26
26
|
from b24api.contracts.report import KeysetSelectionSummary, TraversalAssurance, Violation
|
|
27
27
|
from b24api.contracts.request import IdentitySpec, RequestLike, ResultSelector, TraversalIdentity, canonical_request
|
|
28
28
|
from b24api.contracts.request_summary import RouteKind
|
|
29
|
-
from b24api.contracts.traversal import OffsetContinuation, TotalTermination
|
|
29
|
+
from b24api.contracts.traversal import OffsetContinuation, ShortPageTermination, TotalTermination
|
|
30
30
|
from b24api.contracts.wire import BodyEncoding
|
|
31
31
|
from b24api.errors import CapabilityError
|
|
32
32
|
from b24api.traversal.counted import CountedItemStream
|
|
@@ -105,7 +105,10 @@ def sequential_stream( # noqa: PLR0913
|
|
|
105
105
|
"""Compose conservative sequential offset/server-next traversal."""
|
|
106
106
|
ledger = _checked_identity_store(identity_store, identity)
|
|
107
107
|
plan = sequential_offset_plan(offset, page_size=page_size, duplicate_policy=DuplicatePolicy.REPORT)
|
|
108
|
-
if offset.
|
|
108
|
+
if offset.short_page_termination is ShortPageTermination.DECLARED_TERMINAL:
|
|
109
|
+
# A declared short page proves the endpoint's stop rule, not that later windows are empty.
|
|
110
|
+
assurance = TraversalAssurance.MECHANICS_ONLY
|
|
111
|
+
elif offset.total_termination is TotalTermination.EXACT_QUALIFIED:
|
|
109
112
|
assurance = (
|
|
110
113
|
TraversalAssurance.IDENTITY_AND_COUNT_MATCHED if identity is not None else TraversalAssurance.COUNT_MATCHED
|
|
111
114
|
)
|