Repository navigation
Commit cfc3bcf
Part of #20116
Clause-②: no
## Summary
`FilterConditionSchema`, the save door behind every stored filter, now
refuses every comparand slot the query faces refuse. The dataset
`filter` and measure `filter` also refuse those slots inside a
nested-relation condition, through #20207's walk. This closes every
member the collector lists. The remainder named below is new members
this run found, so this PR says `Part of`, not `Fixes`.
The judge is the shared comparand-shape face itself
(`assertListComparandShapes`), called read-only per slot. So the save
door refuses exactly what the face refuses on every query, and passes
what it passes. The two boolean flags, which that face does not judge,
are refused on the predicate every flag face uses: the comparand is not
a boolean.
## Measured before (`origin/main` `af32cf9a`)
For every member, `FilterConditionSchema`, `DatasetSchema.filter`, a
dataset measure `filter`, a dashboard widget `filter` and a report
`runtimeFilter` all answered `success: true`. The query faces answered
as below. Probe run from the worktree; "face" is
`assertListComparandShapes`, "analytics" is `normalizeWhereComparands`.
| member | face (top / `$and`) | face (nested) | analytics (top / `$and`
/ nested) |
|:--|:--|:--|:--|
| `{ stage: { $null: 'x' } }`, `$exists: 'false'`, `$null: null`,
`$exists: 1` | accept (flags are not its arm) | accept |
`INVALID_FILTER` / 400, every position |
| `{ amount: { $gt: null } }`, `$lte: null` | 400 | accept | 400, every
position |
| `{ stage: { $in: 'won' } }`, `$nin: 'won'` | 400 | accept | 400, every
position |
| `{ stage: { $in: ['won', null] } }`, `$nin: [null]` | 400 | accept |
400, every position |
| `{ amount: { $between: [null, 5] } }`, `5`, `[1]`, `[1, 2, 3]`, `['',
5]`, `[{ $field: 'a' }, 5]` | 400 | accept | 400, every position |
| `{ stage: { $ne: ['won', 'lost'] } }`, `$ne: []` | 400 | accept | 400,
every position |
Controls answered accept on every door and face: `$null: true`,
`$exists: false`, `$ne: null`, `$eq: null`, `$gt: { $field }`, `$ne: {
$field }`, `$in: []`, `$nin: []`, `$between: [1, 5]`, `$between: [' ',
'M']`, `$in: [{ $field }]`, `$gt: true`.
## What changes
- **`packages/spec/src/data/filter-save-door-refusals.ts` (new, not in
the `data` barrel).** It holds `reportQueryFaceRefusals`, the one
function both walks call. It asks the face about one slot, `{ [field]:
comparand }` or `{ [field]: { [op]: comparand } }`, and reads only an
`INVALID_FILTER` throw as a verdict; anything else is rethrown. Then it
picks the words (below). It applies the flag rule to `$null` /
`$exists`. It is a module of its own because `data/index.ts` re-exports
`filter.zod.ts` whole, so an export there would be published API. It
cannot import `filter.zod.ts` without a cycle, so the enforced operator
slots (`FieldOperatorsSchema`) are passed in.
- **`FilterConditionSchema`'s walk (`checkFilterConditionComparands`)**
asks that function about every implicit comparand and every operator of
a field entry, at depth 0: this node's own field entries, and every
`$and` / `$or` / `$not` member through the schema's own re-parse. That
is the face's reach. The walk's hand-written equality-slot checks
(#19889) are now two of the face's arms, with the same sentence and the
same path.
- **#20207's carrier walk (`dataset.zod.ts`, renamed
`refuseNestedRelationEqualityLists` →
`refuseNestedRelationComparands`)** asks the same function about every
entry INSIDE a nested relation. That is the analytics door's reach,
which flattens a relation to dotted members. The walk still decides only
where; the verdict and the words are the shared function's. Its
equality-list refusals keep their sentence and path.
- **Zone 2 answer 2, measured:** the face's own text cannot be reused
without its location words. Its builders are module-private and put ` at
PATH` mid-sentence in seven different shapes (`at P.`, `(at P)`, `(at
P[i])`, `at P[i] of`, …). So the face is the JUDGE and not the text
source; the text is chosen per arm as listed next. The #19889 / #20204
precedent moved the text into a shared module first, which would edit
the face (⛔ in this order).
### The words, per arm (every new or changed refusal text, quoted)
| arm | issue path | sentence source |
|:--|:--|:--|
| array in the equality slot, implicit or `$eq` | `stage`, `stage.$eq` |
unchanged: `arrayEqualityComparandMessage`, shared with the face |
| array under `$ne` (route A) | `stage.$ne` |
`arrayInequalityComparandMessage` with the field, the face's sentence
less its location |
| `null` ordering comparand | `amount.$gt` | read off
`FieldOperatorsSchema`'s slot for the same comparand |
| `null` `$in` / `$nin` member | `stage.$in.1` | read off the slot |
| `null`, blank or `{ $field }` `$between` endpoint |
`amount.$between.0` | read off the slot |
| non-list `$in` / `$nin` | `stage.$in` | NEW: the face's
`nonListComparandError` sentence less ` at PATH`; the slot has only
zod's generic wording |
| `$between` that is not a pair | `amount.$between` | NEW: the face's
`malformedRangeComparandError` sentence less ` at PATH` |
| non-boolean `$null` / `$exists` | `stage.$null` | NEW: `driver-sql`'s
first sentence word for word, then the analytics door's reason and
prescription |
The texts as printed (`FilterConditionSchema.safeParse`):
```text
stage.$null ← { stage: { $null: "x" } }
Operator "$null" on field "stage" requires a boolean comparand (true or false). Received a string ("x"). @objectstack/spec FieldOperatorsSchema declares $null as a boolean, and a non-boolean is refused rather than coerced because the backends read one in OPPOSITE directions — one as IS NULL, another as IS NOT NULL. Write the boolean itself: "$null": true matches rows whose "stage" has no value, "$null": false rows whose "stage" has a value. The filter was NOT applied.
stage.$exists ← { stage: { $exists: "false" } }
Operator "$exists" on field "stage" requires a boolean comparand (true or false). Received a string ("false"). @objectstack/spec FieldOperatorsSchema declares $exists as a boolean, and a non-boolean is refused rather than coerced because the backends read one in OPPOSITE directions — one as IS NULL, another as IS NOT NULL. Write the boolean itself: "$exists": true matches rows whose "stage" has a value, "$exists": false rows whose "stage" has no value. The filter was NOT applied.
stage.$in ← { stage: { $in: "won" } }
Operator "$in" on field "stage" requires an ARRAY of values. Received string ("won"). "$in" tests membership of a list — write ["won"] for a single value, or use "=" ($eq) to compare against it. Authoring spellings: in. The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.
amount.$between ← { amount: { $between: 5 } }
Operator "$between" on field "amount" requires a [min, max] value array. Received number (5). A range needs exactly two bounds, in order; the authoring spelling that lowers to "$between" is "between". The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.
stage.$ne ← { stage: { $ne: ["won","lost"] } }
Operator "$ne" on field "stage" requires a single comparable value, but received an array (["won","lost"]). For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.
amount.$gt ← { amount: { $gt: null } } (the operator slot's existing sentence)
null is not a valid $gt comparand. null is not ordered, and no two evaluation faces agree on what an ordering against it matches (driver-memory's live path reads two absences as equal; its reference matcher compares through JS coercion). State absence with the null predicate instead: {"$eq": null} is "has no value", {"$ne": null} is "has a value". Ruled 2026-09-01: a null ordering comparand is refused at the validation entrance.
stage.$in.1 ← { stage: { $in: ["won", null] } } (the operator slot's existing sentence)
null is not a valid $in member at index 1. No two backends agree on what a null in a list-comparand position matches (the SQL family answers a NULL under NOT IN unconditionally; the JS matchers split over the two readings of "no value"). State absence explicitly with the null predicate instead: {"$or": [{"$in": […]}, {"$null": true}]} is "one of […] OR has no value", and {"$null": false} is the has-a-value half. Ruled 2026-08-31: a null list member is refused at the validation entrance.
amount.$between.0 ← { amount: { $between: [null, 5] } } (the operator slot's existing sentence)
null is not a valid $between endpoint at index 0. (… the same null-member sentence as above …)
amount.$between.0 ← { amount: { $between: ["", 5] } } (the operator slot's existing sentence)
A blank value is not a valid $between endpoint at index 0 (the MIN bound). A closed interval [min, max] requires BOTH endpoints present and non-empty: an empty string is not an interval endpoint at any backend — it is compared as a value, so the range stops bounding on that side while still reading as a complete range. Write the bound you meant; and if only ONE side is genuinely bounded, that is not a range at all — drop $between and write the side you have as a scalar comparison ({"$gte": min} for a lower bound, {"$lte": max} for an upper one). Ruled 2026-09-17: a blank $between bound is refused at the validation entrance.
amount.$between.0 ← { amount: { $between: [{ $field: "floor" }, 5] } } (the operator slot's existing sentence)
A { "$field": … } reference is not a valid $between endpoint at index 0. No evaluation path resolves a field reference inside a list: the in-memory evaluator (matchesFilter) leaves the list unresolved and compares the raw reference OBJECT, so it silently matches nothing, and both SQL drivers refuse the position with INVALID_FILTER / 400. Write a literal value here, or move the reference to a scalar comparison operator ($eq/$ne/$gt/$gte/$lt/$lte), whose WHOLE comparand a { $field } reference may be. Ruled 2026-08-11: declared = enforced (ADR-0049).
```
A nested member on a dataset carrier prints the same sentence as its
top-level form, with the leaf field named, at its own path, for example
`filter.acct.stage.$in.1`.
The changeset also carries a FROM → TO table for the HTTP doors. `POST
/analytics/dataset/query` answers a one-bound `$between` in
`selection.runtimeFilter` with `400 VALIDATION_FAILED`, located on the
member, instead of `400 INVALID_FILTER` from the normalizer, and `POST
/analytics/query` refuses the same shape in `where` at its request
schema. It also carries rows for the producer's two spellings, `$in:
[null, ""]` and `$nin: [null, ""]`.
### Changed docblocks
- `checkFilterConditionComparands`: a new section, "Every slot the query
faces refuse is refused on save (#20116)". The old bullet "⛔ `$ne` is
not judged by this walk" is replaced; it was made false.
- `inequalityComparandSchema`: the scope note "its own walk … does not
judge `$ne`" now says the walk refuses the same shape through the face.
- `refuseNestedRelationComparands`: the "⛔ Not judged here: `$ne` … and
the face's OTHER arms inside a nested relation" paragraph is replaced by
"Every slot the door refuses inside a relation (#20116)".
## What stays accepted (pinned, both doors)
The null predicate (`$eq: null`, `$ne: null`), `$null` / `$exists`
`true` and `false`, and a `{ $field }` reference as the whole comparand
of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`. Also a
column-to-column range as two bounds, `$in: []` / `$nin: []`, `$in` with
a `{ $field }` member (the face does not judge members), a whitespace or
falsy `$between` endpoint, and relation traversal with no operator. On
the shared schema (every carrier but the two dataset ones), any member
shape INSIDE a nested relation stays accepted: neither the face nor the
drivers' flag checks descend one.
## Remainder, named (why `Part of`)
Every member the collector lists (`5854575239`, `5854887743`, and this
card's own) is closed. This run measured two more positions of the same
family:
1. **Nested-relation forms on a dashboard widget `filter` and a report
`runtimeFilter`.** Both reach the analytics `where` door, which refuses
every member inside a relation: `dataset-executor.ts` sends
`combineFilters(compiled.filter, selection.runtimeFilter)` to it. Both
still save the shape, because #20207's carrier refinement sits on the
two dataset carriers only (triage record `5825670610`). The same gap
holds for #20080's own equality-list shape on these two carriers.
Closing it means applying the same refinement to
`DashboardWidgetSchema.filter` and the report `runtimeFilter`s, which
are outside this order's file surface.
2. **The comparand-TYPE face's refusals.**
`normalizeFilterComparandTypes` refuses a plain-object or `Map`
comparand, for example `{ stage: { $eq: { a: 1 } } }` or `{ stage: {
$in: [{ a: 1 }] } }`. The analytics door refuses both, in every
position, and every save door accepts both. The collector's enumeration
names the shape face's tables and the flag arm, not the type face, so
this PR does not move it.
## Producer census (Zone 2 answer 4)
A literal-comparand `git grep -P` for each member shape, with a lit
control per shape, over non-test, non-doc files:
| tree | member hits that are authored filters | control hits (`$null` /
`$exists` boolean, `$in: [`, `$between: [`) |
|:--|:--|:--|
| this repo `examples/**` at `af32cf9a` | 0 | 0 / 3 / 0 |
| this repo `packages/**` at `af32cf9a` | 0 (every hit is prose, a type
table or an operator map) | 87 / 230 / 40 |
| objectui at the pin `f8a9d0fb05` | **1 producer**:
`packages/fields/src/widgets/FilterConditionField.tsx:240-241` | 25 / 12
/ 2 |
| cloud `main` `48d70663ab` | 0 (one code comment) | 0 / 11 / 1 |
The producer is objectui's filter-condition widget. `condToMongo` writes
"is empty" as `{ [field]: { $in: [null, ''] } }` and "is not empty" as
`{ [field]: { $nin: [null, ''] } }`. Both are offered by default for
text, number, date, select and lookup fields. `field.form.ts` puts this
widget on `relatedListFilter` and on a rollup's
`summaryOperations.filter`, both `FilterConditionSchema` carriers, and
it also edits `sys_sharing_rule.criteria_json`. The face has refused a
`null` list member on every query since the 2026-08-31 ruling, so such a
filter already fails its related list or rollup. After this PR, the
Studio save is refused instead, at the slot, with the `$or` / `$null`
prescription. No D2 conversion: the 2026-08-31 ruling declined to give a
`null` list member any meaning, so a conversion would have to invent
one. The producer fix is objectui's and is reported to the PM for
routing. Blind spot: a multi-line literal or a runtime-built comparand
is not matched by a line grep.
## Tests
All heavy runs went through `scripts/pm/os-verify-lock.sh`. Each reading
names the tree it was taken on.
| run | tree | reading |
|:--|:--|:--|
| `pnpm --filter @objectstack/spec build`, `check:generated`,
`typecheck` | `c300e80e` (final) | exit 0, exit 0, exit 0 |
| `@objectstack/spec` full suite (`vitest run --maxWorkers=2`) |
`c300e80e` (final) | 579 files, 16731 passed, 2 todo |
| `@objectstack/spec` full suite | `70e9a610` plus the regenerated
registry (`31ddfa40`) | 577 files, 16700 passed, 2 todo |
| the two pin files (`filter-save-door-face-parity.test.ts`,
`dataset-filter-nested-relation-list.test.ts`) | `70e9a610`, after the
ablations were restored | 149 passed |
| consumer: `@objectstack/service-analytics` full suite (the `where`
door) | `70e9a610` | 128 files, 3022 passed |
| consumer: `@objectstack/lint` full suite (`validate-chart-bindings`
and the dataset readers) | `70e9a610` | 109 files, 4232 passed |
| derived gates (`dispatch-gates.mjs --commands`, 87 lines) | `c300e80e`
| 85 exit 0; 2 exit 3 PREREQUISITE NOT MET
(`check:dual-build-cjs-loads`, `check:type-check-debt`, both need the
whole-repo build); `--ran` reconciliation: 87 accounted, 0 unrun |
**Patch round (review 5856977110, CI `Test Core (4/6)` at `c300e80e`).**
`packages/rest/src/analytics-filter-refusal-envelope.test.ts` asserted
the one-bound `$between` as `400 INVALID_FILTER` from the normalizer.
`DatasetSelectionSchema.runtimeFilter` and
`AnalyticsQueryRequestSchema.where` are `FilterConditionSchema`, so the
route's schema door now answers first. Readings on `ab146a9a` (the tree
batch F ran on; the batch header reads `dfca00f0` because the last
commit, a test and changeset edit, landed before its spec step): rest
full suite (`--project local`) 201 files, 3576 passed, 1 skipped; the
flipped file 31 passed; service-analytics full suite 129 files, 3041
passed; spec full suite 581 files, 16770 passed, 1 todo (was 2: the
`$ne` §5 todo is now a pin); spec `check:generated` exit 0; derived
gates 87 derived, 85 exit 0, 2 exit 3 PREREQUISITE NOT MET
(`check:dual-build-cjs-loads`, `check:type-check-debt`), `--ran` 87
accounted. Consumer sweep: a `git grep -P` for every refused member
shape over all non-spec test files found 62 files. Only this one drives
a request or schema door (`DatasetSchema` or `DatasetSelectionSchema` on
`/analytics/dataset/query`, `AnalyticsQueryRequestSchema` on
`/analytics/query`, `ObjectSchema` at registration) with a refused
shape. Every other hit is an engine `where`, an RLS `scope`, a driver
input or a comment.
**Pin sweep.** Two published-behaviour pins flipped and were rewritten
to assert the new semantics with their substance:
- `filter.test.ts` "is not judged by the loose FilterConditionSchema —
and neither is the #7596 shape" became "is refused by
FilterConditionSchema too, at the endpoint, in the operator slot's
words". It asserts the path `age.$between.1` and equality with
`FieldOperatorsSchema`'s message for the same pair.
- #20207's §4 control row "`$ne` carrying a list … not yet at this save
door (#20116)" became a refusal test on both carriers. It asserts
`INVALID_FILTER` / 400 at the analytics door, then the carrier's message
equal to the door's less its location, and the `$nin` remedy.
- **Patch round:**
`packages/rest/src/analytics-filter-refusal-envelope.test.ts`. "a
$between with one bound → 400 INVALID_FILTER" moved from the normalizer
table to the `[#17551]` door table: it now asserts 400,
`VALIDATION_FAILED`, exactly one `details.fields[]` entry at
`selection.runtimeFilter.amount.$between`, and the face's sentence
without ` at where.`. The sibling-schema control now asserts the refusal
at `where.amount.$between` in the same sentence. The pass-list control
is three spellings, not four. Its #20010 note moved with the row and
still holds: the sentence is still the face's.
- **Patch round:**
`packages/spec/src/data/filter-ne-array-schema-door.test.ts` §5. Its
`it.todo` ("the carrier still saves the shape") is fulfilled by this
PR's `$ne` arm. It is now a real pin: path `stage.$ne`, message equal to
the face's less its location, the `$nin` remedy, a dataset carrier at
`filter.$or.0.stage.$ne`, and a null / scalar control.
**Ablation (reverse verification), one-off, no permanent file.** Each
leg went through `scripts/ablation-replace.mjs` WRAP mode from the
committed tree `70e9a610`. The anchor hit exactly 1 time (x1 → x0,
replacement x0 → x1), and the restore was proven by blob hash equal to
HEAD with an empty `git diff HEAD`:
| leg | mutation | result on the two pin files |
|:--|:--|:--|
| 1 | the face's verdict dropped (`if (face && false)`) | red: 95 failed
/ 54 passed, both files |
| 2 | the flag rule dropped | red: 18 failed / 131 passed (the flag rows
and the four §1 tables) |
| 3 | the shared walk's reach widened into nested relations (`depth ===
0` removed) | red: 30 failed / 119 passed. More diagnostics, not fewer:
nested slots are refused twice, and the shared reach pins go red |
| 4 | the carrier walk's operator-map arm dropped | red: 28 failed / 121
passed (nested `$eq` / §5 rows) |
| restore | none | 149 passed |
## Acceptance notes
- The face's docblock still names the schema door's twins as
"`nullOrderingComparandMessage` (`./filter.zod.ts`)" and so on. Those
builders did not move; the new module reads the same sentences off
`FieldOperatorsSchema`'s slots. No edit to the face was needed or made.
- `FieldOperatorsSchema.$in` / `$nin` / `$between` / `$null` / `$exists`
still print zod's generic wording for a non-list, a malformed range and
a non-boolean flag. `FilterConditionSchema` prints the pointed sentences
above. Pointing the operator slots too is polish, and is not done here.
- The four sentences read off the operator slot name the operator and
the index but not the field, because the slot cannot see the field. The
issue's path names it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent e462186 commit cfc3bcf
11 files changed
Lines changed: 1288 additions & 123 deletions
File tree
- .changeset
- packages
- rest/src
- spec/src
- data
- migrations
- entries/semantic
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
193 | 193 | | |
194 | 194 | | |
195 | 195 | | |
196 | | - | |
197 | | - | |
198 | | - | |
199 | | - | |
200 | | - | |
201 | | - | |
202 | | - | |
203 | | - | |
204 | | - | |
205 | | - | |
206 | | - | |
207 | | - | |
208 | | - | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
209 | 200 | | |
210 | 201 | | |
211 | 202 | | |
| |||
227 | 218 | | |
228 | 219 | | |
229 | 220 | | |
230 | | - | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
231 | 224 | | |
232 | 225 | | |
233 | 226 | | |
| |||
242 | 235 | | |
243 | 236 | | |
244 | 237 | | |
245 | | - | |
| 238 | + | |
246 | 239 | | |
247 | 240 | | |
248 | 241 | | |
249 | 242 | | |
250 | 243 | | |
251 | | - | |
252 | | - | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
253 | 249 | | |
254 | 250 | | |
255 | 251 | | |
256 | 252 | | |
257 | 253 | | |
258 | 254 | | |
259 | 255 | | |
260 | | - | |
| 256 | + | |
261 | 257 | | |
262 | 258 | | |
263 | 259 | | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
264 | 273 | | |
265 | 274 | | |
266 | 275 | | |
| |||
274 | 283 | | |
275 | 284 | | |
276 | 285 | | |
277 | | - | |
| 286 | + | |
278 | 287 | | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
279 | 297 | | |
280 | 298 | | |
281 | 299 | | |
| |||
288 | 306 | | |
289 | 307 | | |
290 | 308 | | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
291 | 318 | | |
292 | 319 | | |
293 | 320 | | |
294 | | - | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
295 | 325 | | |
296 | | - | |
| 326 | + | |
297 | 327 | | |
298 | 328 | | |
299 | 329 | | |
| |||
0 commit comments