Skip to content

Commit 4d383da

Browse files
committed
Merge origin/main into claude/issue-20887-analytics-nested-relation
Brings the gate derivation onto a fresh tree (a derived-from gate script changed on main); no commit on main touched this branch's surface. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
2 parents 5bb7641 + 9509ea1 commit 4d383da

69 files changed

Lines changed: 1409 additions & 1806 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the `picklist` field key's description, two doc comments and the refusal of `picklist` with `options` no longer say that the reference is resolved and its options served (#19518)
6+
7+
Clause-②: no
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Notes in the `field` liveness ledger and sentences in the ledger README that cited a tracker number which no longer resolves now either cite a commit in this repository or say in words what the number stood for
6+
7+
Clause-②: no
8+
9+
Eleven citations in the `field` ledger's notes and twenty-nine in `liveness/README.md` pointed at
10+
a GitHub issue or pull request that no longer exists, so they led nowhere. Each one now either
11+
names the commit that did or recorded what the number pointed at or, where the number alone
12+
carried the meaning, says that meaning in words. The `liveness/` ledgers and their README ship in
13+
this package's tarball, which is why this is a release note at all. Prose only: no row's status,
14+
evidence, proof, producer or date changes, no README table row or heading is added, removed or
15+
renamed, and no schema, export or runtime behaviour changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/driver-memory': patch
3+
---
4+
5+
refactor(driver-memory): the cube face's own whole-day bound and the in-memory reference matcher are deleted; no answer a caller gets moves (#5930 step 4, #20822)
6+
7+
Clause-②: no
8+
9+
- **`MemoryAnalyticsService` (the cube face).** Its `where` door has run the shared `lowerFilterCondition` (`@objectstack/spec/data`) since #5930 step 3, on every column. A bare-day `$lte` therefore reaches the `lte` row already lowered: as `$lt` the next day, or as `$null: false` on `9999-12-31`. The row's own copy of that rule is deleted, and the `lte` row now compiles the comparison it is handed on both exits. The rows `query()` returns and the SQL `generateSql()` echoes are unchanged. An explicit `dateRange` end still widens a bare day through its own window arm (ADR-0053 D-D1 item 8).
10+
- **The reference matcher (`memory-matcher.ts`, `match()`) is retired** (ruling D6 on #5930). No production code called it and the package never exported it: the published `dist` exports are the same 33 names before and after. `InMemoryDriver` keeps `getValueByPath`, the one helper it imported from that module. The matcher's tests now assert the live query path (`InMemoryDriver.find`), the shared filter shape gate, or the spec predicate the matcher evaluated.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
feat(plugin-auth): a host declares its own sign-in handoff route with `hostSignInHandoff`, and the `no_sign_in_account_at_boot` boot report stops calling that deployment a dead end (#20861)
6+
7+
Clause-②: yes (widening)
8+
9+
`AuthPluginOptions` gains one option, `hostSignInHandoff?: boolean` (default
10+
`false`). The HOST that constructs the plugin sets it when it signs people in
11+
through a handoff route of its own: a route that is not a login-page provider
12+
and that creates the session without writing a `sys_account` row. A hosted
13+
kernel whose owner signs in through the control plane is the case it is for.
14+
That owner can still sign in when the login page shows no platform sign-in
15+
button:
16+
17+
```ts
18+
new AuthPlugin({ /* … */ hostSignInHandoff: true });
19+
```
20+
21+
With it declared, human `sys_user` rows and zero `sys_account` rows are that
22+
deployment's normal state. The boot report then logs the shape at `debug` and
23+
names `hostSignInHandoff` as the reason. It no longer logs an `error` saying
24+
nobody can sign in. The option's only reader is that boot report.
25+
26+
- It is a declaration, not a detection. The option is the only way to set it:
27+
there is no environment variable or setting. Nothing infers it from an
28+
environment's name, from a control plane's platform-SSO flag, or from missing
29+
rows.
30+
- The login page is not changed. `getPublicConfig()` returns the same value with
31+
or without the option, and no provider is registered.
32+
- Set it only where the host really serves such a route. On a deployment with
33+
no such route, the option turns the error for a deployment nobody can sign in
34+
to into a quiet `debug` line.
35+
- A deployment that does not declare it gets the same report as before. That
36+
includes every self-hosted deployment with no delegated sign-in path.

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,17 @@ first thing to look for — and note that it asks only whether *an* account row
540540
exists, so a row you wrote yourself silences it whether or not anyone can
541541
actually authenticate with it.
542542

543+
The same shape is logged at **debug** instead, under the same token and naming
544+
the reason, when the deployment has a sign-in path that needs no `sys_account`
545+
row of its own: SSO-only mode (`OS_AUTH_SSO_ONLY` or `ssoOnlyMode`), a
546+
configured social or OIDC provider, enterprise SSO with at least one registered
547+
identity provider, or a host that constructs `AuthPlugin` with
548+
`hostSignInHandoff: true` because it signs people in through a handoff route of
549+
its own. A deployment with none of these still reports at **error**.
550+
`hostSignInHandoff` is a statement by the code that serves such a route, not a
551+
way to quiet the report: set on a deployment without one, it hides exactly the
552+
dead end this section describes.
553+
543554
**The recovery is out of band, and it is one row.** Write a pending invitation
544555
directly against the store the deployment reads, then have that person register
545556
through the ordinary sign-up endpoint — the invitation carve-out admits that

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: ObjectQL query syntax — the full specification
33
navTitle: Query Syntax
4-
description: Database-agnostic query language with filtering, joins, aggregations, and sorting — aligned with the canonical @objectstack/spec QuerySchema
4+
description: Database-agnostic query language with filtering, expand, aggregations, and sorting — aligned with the canonical @objectstack/spec QuerySchema
55
---
66

77
import { Search, Filter, GitMerge, BarChart } from 'lucide-react';
@@ -600,29 +600,72 @@ have kept a `null`-valued field.
600600

601601
### Filtering Across Relationships
602602

603-
<Callout type="warn">
604-
**Relation traversal inside `where` is not supported.** Neither the nested form
605-
(`where: { account: { industry: 'tech' } }`) nor a dotted path
606-
(`where: { 'account.industry': 'tech' }`) is resolved. `SqlDriver.applyFilters()` only
607-
recognises a nested object as an operator map when its keys start with `$`; anything
608-
else is compiled as a comparison against a single column of the queried table, and a
609-
dotted key is emitted verbatim, so Knex renders it as `"account"."industry"` against a
610-
table that was never joined.
611-
</Callout>
603+
Filter on a related record's fields by nesting the condition beneath the relation
604+
field in `where`:
605+
606+
```typescript
607+
// Opportunities whose account is in the tech industry
608+
const opportunities = await engine.find('opportunity', {
609+
where: { account: { industry: 'tech' } },
610+
});
611+
```
612612

613-
Filter on the local foreign key, or run two queries:
613+
A condition on a related record's fields beneath a relation field (`lookup`,
614+
`master_detail`, `user`, `tree`) is served in `where`: the engine reads the
615+
related object with it **as the caller**, then matches the field against the
616+
ids it returns (`$in`; any member when `multiple: true`).
617+
618+
Limits — one level: every key a field the related object declares, no relation
619+
or dotted key inside; forward only: never a parent by its children; `where`
620+
only: an aggregation's `filter` and `having` refuse it, `INVALID_FILTER` / 400;
621+
at most 1000 related ids, refused past that, `INVALID_FILTER` / 400, never
622+
truncated; as the caller: the related object's row scope and field permissions
623+
apply, so a field the caller cannot read is refused, `PERMISSION_DENIED` / 403,
624+
never an empty result.
625+
626+
- **Every verb that takes a `where`** serves it — `find`, `findOne`, `count`,
627+
`aggregate`, `update` and `delete` — and so do the REST `GET /data/:object` and
628+
`POST /data/:object/query` doors, which read through the same engine calls. A
629+
single-valued relation matches with `$in` on the related ids; a multi-valued one
630+
(`multiple: true`) matches a record when **any** member qualifies, written as one
631+
`$contains` per id under an `$or`.
632+
- **A second level is refused.** `{ account: { owner: { region: 'NA' } } }` and
633+
`{ account: { 'owner.region': 'NA' } }` answer `INVALID_FILTER` / 400.
634+
- **The reverse direction is not served.** A parent filtered by its children is not
635+
written as `{ opportunities: { stage: 'won' } }`: the parent has no such field, so the
636+
REST doors answer `INVALID_FIELD` / 400.
637+
- **A dotted path is still refused.** `where: { 'account.industry': 'tech' }` answers
638+
`INVALID_FIELD` / 400 at the engine and at the REST doors; the message names the
639+
nested spelling to write instead.
640+
641+
Past the 1000-id cap, and for the reverse direction, run the two steps yourself —
642+
filter the related object, then `$in` its ids:
614643

615644
```typescript
645+
// Past the cap: read the related ids, then match the local key
616646
const techAccounts = await engine.find('account', {
617647
where: { industry: 'tech', annual_revenue: { $gt: 1000000 } },
618648
fields: ['id'],
619649
});
620650

621651
const opportunities = await engine.find('opportunity', {
622-
where: { account_id: { $in: techAccounts.map((a) => a.id) } },
652+
where: { account: { $in: techAccounts.map((a) => a.id) } },
653+
});
654+
655+
// Reverse: accounts that have a won opportunity
656+
const won = await engine.find('opportunity', {
657+
where: { stage: 'won' },
658+
fields: ['account'],
659+
});
660+
661+
const accounts = await engine.find('account', {
662+
where: { id: { $in: won.map((o) => o.account) } },
623663
});
624664
```
625665

666+
On a `multiple: true` relation, match the ids with one `$contains` per id under an
667+
`$or` instead of `$in`.
668+
626669
### Filtering on a `formula` field
627670

628671
<Callout type="warn">

‎content/docs/references/data/field.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const result = CurrencyConfigSchema.parse(data);
7575
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
7676
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
7777
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
78-
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the reference: the field clients read carries the resolved `options`. |
78+
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). |
7979
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. |
8080
| **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
8181
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |

‎content/docs/references/data/object.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ const result = ApiMethod.parse(data);
238238
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
239239
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
240240
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
241-
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the reference: the field clients read carries the resolved `options`. |
241+
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). |
242242
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. |
243243
| **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
244244
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
@@ -571,7 +571,7 @@ const result = ApiMethod.parse(data);
571571
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
572572
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
573573
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
574-
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the reference: the field clients read carries the resolved `options`. |
574+
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). |
575575
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. |
576576
| **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
577577
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |

0 commit comments

Comments
 (0)