Skip to content

Commit f036750

Browse files
os-zhuangclaude
andcommitted
Merge main into the wait-timeout retirement branch (2nd)
Same conflict, one paragraph later: #4286 appended the joins/windowFunctions sweep to the protocol-18 rationale while CI was running here. Both are kept and `conversionIds` stays the union. The incoming paragraph is appended last, and its opener drops the ordinal — "The fourth of the same kind" became "The same kind of retirement covers". Protocol 18 is collecting an active sweep of retirements (#3963, #4052, #4196, #4286, #4158), so a positional self-reference re-conflicts on every one of them; the ordinal-free opener does not, and matches the "The same major retires" idiom already in the block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 parents 4086e3e + 820eff9 commit f036750

32 files changed

Lines changed: 828 additions & 2031 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-approvals": patch
4+
---
5+
6+
fix(spec,plugin-approvals): the two approval vocabularies are derived, not hand-matched (#3786)
7+
8+
`sys_approval_request.status` and `sys_approval_action.action` spelled their
9+
option lists out — five values and twelve — each under a "Keep in sync with
10+
`ApprovalStatus` / `ApprovalActionKind` (spec/contracts)" comment, while the
11+
contract held the same sets as bare type unions. Seventeen strings matched by
12+
hand across a package boundary, with nothing checking them. They did all still
13+
agree; the sweep that found them (#3786) verified that verbatim before changing
14+
anything.
15+
16+
Agreeing is not the same as being held, and both directions of drift are quiet:
17+
18+
- a value the **column** accepts and the contract omits is invisible to every
19+
consumer typed against the contract — the row exists and nothing can narrow it;
20+
- a value the **contract** declares and the column rejects surfaces only at write
21+
time, on whichever tenant first reaches that transition.
22+
23+
An audit vocabulary is a bad place for either. So the contract now publishes the
24+
lists as values — `APPROVAL_STATUSES` and `APPROVAL_ACTION_KINDS` — with
25+
`ApprovalStatus` / `ApprovalActionKind` derived from them via
26+
`(typeof X)[number]`, and the two columns spread the constants. The per-entry
27+
rationale (which action kinds move the flow, which are thread-only, why
28+
`returned` differs from `recalled`) moved onto the constants, where the values
29+
live.
30+
31+
**New exports, no behaviour change.** The emitted option lists are byte-identical
32+
— verified against the built artifact before and after. Existing imports of the
33+
two types are unaffected; the types resolve to the same unions.
34+
35+
`approval-vocabularies.test.ts` pins the qualifier that derivation alone cannot:
36+
the columns agree with the contract *while the spread is there*, and the test
37+
fails if either is re-inlined as a literal that has drifted. It also guards the
38+
guard (an unresolvable import would compare two empty lists and pass) and asserts
39+
the two vocabularies stay distinct, since a copy-paste pointing one column at the
40+
other constant would satisfy "derived from the contract" while being the wrong
41+
vocabulary entirely.
42+
43+
Verified by mutation in both directions: adding a value to `APPROVAL_STATUSES`
44+
propagates into the built `sys_approval_request.status` options (the derivation
45+
is live, not a stale build), and re-inlining a drifted literal fails
46+
`sys_approval_request.status offers exactly the contract statuses, in order`.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/plugin-security": patch
4+
---
5+
6+
refactor(data)!: the QueryAST request surface stops declaring what no executor runs — `joins` and `windowFunctions` removed, six search flags and `aggregations[].filter` marked experimental, and the liveness ledger now governs the query surface (#4286)
7+
8+
#4196 removed one declared-but-inert member from `FieldNode`. Applying the same
9+
method to the rest of the request surface (#4286) found 12 more members of
10+
`QueryAST` that no executor runs — `packages/objectql`'s `engine.ts` contains
11+
zero reads of any of them on the query path. This change dispositions the
12+
mechanical tiers and closes the gate that let the class stay invisible.
13+
14+
**Removed (tombstoned): `query.joins` and `query.windowFunctions`.**
15+
16+
- `joins` — no engine or driver ever read it; a query carrying it silently ran
17+
as a single-table query. Related-record retrieval already has a live
18+
spelling: `expand`. The orphaned `JoinNode` / `JoinNodeInput` /
19+
`JoinNodeSchema` / `JoinType` / `JoinStrategy` exports are deleted with the
20+
key (`data/JoinNode`, `data/JoinType`, `data/JoinStrategy` leave the
21+
published JSON schemas).
22+
- `windowFunctions``find()` never applied it, so every OVER clause it
23+
declared was silently dropped. The one live door is the SQL driver's own
24+
`findWithWindowFunctions(object, query)` (driver-level, not on the
25+
`IDataDriver` contract), and its input is a flat driver shape the spec
26+
vocabulary never matched — `WindowFunctionNodeSchema` declared `field` /
27+
`over` / `frame` members that door never read. The `WindowFunction` /
28+
`WindowSpec` / `WindowFunctionNode` exports are deleted with the key.
29+
30+
**FROM → TO**
31+
32+
| Was | Now |
33+
| :--- | :--- |
34+
| `joins: [{ type: 'inner', object: 'customer', on: … }]` | `expand: { customer_id: { object: 'customer', fields: ['name'] } }` |
35+
| `joins` for one related column | `fields: ['customer_id.name']` (dotted path) |
36+
| `windowFunctions: [{ function: 'rank', … }]` in a query | `aggregations` + `groupBy`, or rankings in report/dashboard metadata |
37+
| OVER-clause SQL from an embedder | `sqlDriver.findWithWindowFunctions(object, { windowFunctions: [{ function, alias, partitionBy?, orderBy? }] })` |
38+
39+
The one-line fix: **delete the key**. Both are `retiredKey()` tombstones on the
40+
non-strict `BaseQuerySchema`, so authoring either fails `tsc` (input type
41+
`never`) and a query still carrying one — even as an empty array — fails to
42+
parse with the prescription itself. `QueryAST` is a request shape, never stored
43+
in stack metadata, so there is no `os migrate meta` step: the removals are
44+
registered as protocol-18 **semantic** migrations (`query-joins-retired`,
45+
`query-window-functions-retired`), the #4196 precedent.
46+
47+
Compat note for the REST boundary: both names remain **reserved** list-query
48+
parameters while the tombstones live (`retiredKey()` keeps a key in
49+
`keyof QueryAST`, which feeds `RESERVED_LIST_QUERY_PARAMS`), so nothing changes
50+
for objects with fields named `joins`/`windowFunctions` — the un-reservation
51+
happens when the tombstones age out, and is called out in
52+
`metadata-protocol`'s `QUERY_AST_KEYS` comment for whoever does it.
53+
54+
**Marked `[EXPERIMENTAL — not enforced]` (no wire or compat impact):**
55+
`search.fuzzy` / `operator` / `boost` / `minScore` / `language` / `highlight`
56+
(the ADR-0061 expansion reads only `query` + `fields`) and
57+
`AggregationNode.filter` (a SQL `FILTER (WHERE …)` affordance neither the SQL
58+
builders nor the in-memory fallback applies). Authoring one is now a
59+
declaration, not a silent no-op.
60+
61+
**Deliberately NOT dispositioned here** (they want a maintainer call, #4286
62+
steps 3–4): `having` (the strongest enforce candidate — `engine.aggregate()`
63+
currently rebuilds the driver AST without it), and `cursor` / `distinct`
64+
(shipped SDK producers `QueryBuilder.cursor()` / `.distinct()`; `distinct` is
65+
mis-wired — its only observable effect is suppressing the REST list count).
66+
All three are recorded `dead` with evidence in the new ledger.
67+
68+
**The gate:** `QuerySchema` joins the liveness ledger through the gate's
69+
`SPEC_ONLY_SCHEMAS` override (the `webhook` precedent) as governed type
70+
`query` — the first governance of what *callers* write into a query rather
71+
than what authors write into metadata files. `packages/spec/liveness/query.json`
72+
classifies all 27 walked members (15 live with evidence, 7 experimental via
73+
describe markers, 5 dead), so the next declared-but-inert request member fails
74+
CI instead of needing a person to notice it.
75+
76+
`@objectstack/plugin-security` (patch): the FLS predicate guard's
77+
`windowFunctions` walk is pruned — the clause no longer exists to leak through.
78+
The `having` and `aggregations[].filter` walks stay, deliberately: those
79+
members remain declared, and the guard being ready is what makes enforcing
80+
them later safe.

content/docs/automation/approvals.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,11 @@ On finalization the request row gets `status: 'approved'` (or `'rejected'`),
395395
mirrored to the same value, and the record unlocks. The parked run resumes down
396396
the `approve` or `reject` branch.
397397

398-
Statuses in full: `pending`, `approved`, `rejected`, `recalled`, `returned`.
398+
Statuses in full: `pending`, `approved`, `rejected`, `recalled`, `returned`
399+
the `APPROVAL_STATUSES` constant in `@objectstack/spec/contracts`, which is also
400+
what `sys_approval_request.status` offers and what `ApprovalStatus` is derived
401+
from. If you add a status there, this line is a fourth copy that nothing updates
402+
for you.
399403

400404
</Steps>
401405

content/docs/data-modeling/queries.mdx

Lines changed: 40 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Query Syntax Cheat Sheet
3-
description: One-page reference for ObjectStack QuerySchema — filters, sorts, pagination, aggregations, and joins
3+
description: One-page reference for ObjectStack QuerySchema — filters, sorts, pagination, aggregations, and expand
44
---
55

66
# Query Syntax Cheat Sheet
@@ -387,47 +387,27 @@ on it either. Post-filter grouped results on the client until this is wired up.
387387

388388
---
389389

390-
## Joins
390+
## Joins — removed
391391

392392
<Callout type="warn">
393-
`joins` is defined in `QuerySchema` (`JoinNodeSchema`) and the SQL driver advertises
394-
`supports.joins: true`, but no driver's `find()` actually executes a join today — the SQL,
395-
in-memory, and MongoDB drivers all ignore the `joins` array (there is no `.join()`/`.leftJoin()`
396-
call in the SQL driver's query builder). Use `expand` for relationship traversal instead.
393+
`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049
394+
enforce-or-remove): no driver's `find()` ever executed a join — the SQL, in-memory, and
395+
MongoDB drivers all ignored the array, so it only ever declared a capability that did
396+
not run. The key is tombstoned: authoring it is a `tsc` error, and a query carrying it
397+
(even `joins: []`) fails to parse with the upgrade prescription. The
398+
`JoinNode`/`JoinType`/`JoinStrategy` exports left with it.
397399
</Callout>
398400

399-
### Join Types
400-
401-
| Type | Description |
402-
|:---|:---|
403-
| `inner` | Records with matches in both objects |
404-
| `left` | All records from left + matching from right |
405-
| `right` | All records from right + matching from left |
406-
| `full` | All records from both objects |
407-
408-
### Join Strategies
409-
410-
| Strategy | Description |
411-
|:---|:---|
412-
| `auto` | Let the engine choose the best strategy |
413-
| `database` | Push join to the database level |
414-
| `hash` | In-memory hash join |
415-
| `loop` | Nested loop join |
416-
417-
### Join Example
401+
Use `expand` for relationship traversal — the live spelling for related records — or a
402+
dotted `fields` path (`'customer.name'`) for a single related column:
418403

419404
```typescript
420405
{
421406
object: 'order',
422-
fields: ['id', 'total', 'customer.name', 'customer.email'],
423-
joins: [
424-
{
425-
type: 'inner',
426-
object: 'customer',
427-
on: ['order.customer_id', '=', 'customer.id'],
428-
strategy: 'auto'
429-
}
430-
]
407+
fields: ['id', 'total'],
408+
expand: {
409+
customer_id: { object: 'customer', fields: ['name', 'email'] }
410+
}
431411
}
432412
```
433413

@@ -440,13 +420,7 @@ call in the SQL driver's query builder). Use `expand` for relationship traversal
440420
object: 'article',
441421
search: {
442422
query: 'kubernetes deployment',
443-
fields: ['title', 'body', 'tags'],
444-
fuzzy: true,
445-
operator: 'and',
446-
boost: { title: 2.0, body: 1.0 },
447-
minScore: 0.5,
448-
language: 'english',
449-
highlight: true
423+
fields: ['title', 'body', 'tags']
450424
}
451425
}
452426
```
@@ -455,19 +429,20 @@ call in the SQL driver's query builder). Use `expand` for relationship traversal
455429
|:---|:---|:---|
456430
| `query` | `string` | Search text |
457431
| `fields` | `string[]` | Fields to search (optional — defaults to all searchable) |
458-
| `fuzzy` | `boolean` | Enable fuzzy matching for typo tolerance |
459-
| `operator` | `'and' \| 'or'` | How to combine search terms |
460-
| `boost` | `Record<string, number>` | Field relevance weights |
461-
| `minScore` | `number` | Minimum relevance score (0–1) |
462-
| `language` | `string` | Language for stemming/stopwords |
463-
| `highlight` | `boolean` | Return highlighted matches |
432+
| `fuzzy` | `boolean` | `[EXPERIMENTAL — not enforced]` Fuzzy matching for typo tolerance |
433+
| `operator` | `'and' \| 'or'` | `[EXPERIMENTAL — not enforced]` How to combine search terms |
434+
| `boost` | `Record<string, number>` | `[EXPERIMENTAL — not enforced]` Field relevance weights |
435+
| `minScore` | `number` | `[EXPERIMENTAL — not enforced]` Minimum relevance score (0–1) |
436+
| `language` | `string` | `[EXPERIMENTAL — not enforced]` Language for stemming/stopwords |
437+
| `highlight` | `boolean` | `[EXPERIMENTAL — not enforced]` Return highlighted matches |
464438

465439
<Callout type="warn">
466440
Only `query` and `fields` are implemented. The engine expands `search` into a driver-agnostic
467441
`$and`-of-`$or`-of-`$contains` filter (ADR-0061) — `fuzzy`, `operator`, `boost`, `minScore`,
468442
`language`, and `highlight` are accepted by `QuerySchema` but read nowhere in
469-
`expandSearchToFilter()` / `normalizeSearch()`, so they have no effect. Multiple search terms
470-
are always AND-ed regardless of `operator`.
443+
`expandSearchToFilter()` / `normalizeSearch()`, so they have no effect; since #4286 their
444+
`.describe()` markers say so. Multiple search terms are always AND-ed regardless of
445+
`operator`.
471446
</Callout>
472447

473448
### Pinyin recall (Chinese deployments)
@@ -496,46 +471,36 @@ by `@objectstack/plugin-pinyin-search`) recomputes the column on demand.
496471

497472
---
498473

499-
## Window Functions
474+
## Window Functions — removed from the request surface
500475

501476
<Callout type="warn">
502-
`windowFunctions` is only reachable by calling the SQL driver's `findWithWindowFunctions()`
503-
method directly. `ObjectQL.find()` / `.aggregate()` and the `POST /api/v1/data/:object/query`
504-
route never call it, so sending `windowFunctions` through the standard client/REST query path
505-
has no effect.
477+
`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286):
478+
`ObjectQL.find()` / `.aggregate()` and the `POST /api/v1/data/:object/query` route never
479+
routed it anywhere, so sending it had no effect. The key is tombstoned — a query carrying
480+
it fails to parse with the upgrade prescription — and the
481+
`WindowFunction`/`WindowSpec`/`WindowFunctionNode` exports left with it.
506482
</Callout>
507483

508-
| Function | Description |
509-
|:---|:---|
510-
| `row_number` | Sequential row number within partition |
511-
| `rank` | Rank with gaps for ties |
512-
| `dense_rank` | Rank without gaps for ties |
513-
| `percent_rank` | Relative rank as percentage |
514-
| `lag` | Access previous row value |
515-
| `lead` | Access next row value |
516-
| `first_value` | First value in window |
517-
| `last_value` | Last value in window |
518-
| `sum` / `avg` / `count` / `min` / `max` | Running aggregations |
519-
520-
### Window Function Example
484+
Window functions remain a **SQL-driver door**: `findWithWindowFunctions()`, callable
485+
directly on a SQL driver instance (it is not on the `IDataDriver` contract). Its input
486+
is the driver's own flat shape:
521487

522488
```typescript
523-
{
524-
object: 'employee',
525-
fields: ['name', 'department', 'salary'],
489+
const ranked = await sqlDriver.findWithWindowFunctions('employee', {
526490
windowFunctions: [
527491
{
528492
function: 'rank',
529493
alias: 'salary_rank',
530-
over: {
531-
partitionBy: ['department'],
532-
orderBy: [{ field: 'salary', order: 'desc' }]
533-
}
494+
partitionBy: ['department'],
495+
orderBy: [{ field: 'salary', order: 'desc' }]
534496
}
535497
]
536-
}
498+
});
537499
```
538500

501+
For request-level analytics, use `aggregations` + `groupBy`, or model rankings in
502+
report/dashboard metadata.
503+
539504
---
540505

541506
## Distinct & Group By

content/docs/protocol/objectql/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,6 @@ See [Security Protocol](/docs/protocol/objectql/security) for details.
396396
<Card
397397
title="Query Syntax"
398398
href="/docs/protocol/objectql/query-syntax"
399-
description="Write database-agnostic queries with filters, joins, and aggregations"
399+
description="Write database-agnostic queries with filters, expand, and aggregations"
400400
/>
401401
</Cards>

0 commit comments

Comments
 (0)