Skip to content

Commit ae8e3e8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19925-cli-non-array-packages-refusal
2 parents 5bac826 + 3cb84d0 commit ae8e3e8

56 files changed

Lines changed: 2434 additions & 404 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: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
feat(spec)!: the canon for "the version of a package or plugin" is SemVer 2.0.0 — nine carriers, one grammar
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: registered manifest-version-semver-2-0-0, plugin-version-semver-2-0-0, package-version-row-semver-2-0-0, package-manifest-version-grammar-enforced -->
12+
13+
**BREAKING** — four published accept sets converge on one, and the fringe each
14+
of them carried outside SemVer 2.0.0 is refused. The widening half needs no
15+
action from anyone; the narrowing half is listed per carrier below, with its
16+
FROM → TO.
17+
18+
One concept was judged by four different grammars across ten carriers in two
19+
repositories, and the strictest refused `2.0.0-beta.1` — the exact string a
20+
sibling declaration documented as an example of itself. The disagreement was
21+
observable between doors on the same resource, not merely between schema files:
22+
`os plugin build` refused a prerelease the publish door accepted, the Studio
23+
form refused it twice over, the `PATCH` door answered `400`, and the install
24+
door parsed nothing at all. An earlier change collapsed the eight regex literals
25+
onto three exported constants, which removed the drift but not the disagreement.
26+
27+
`@objectstack/spec/kernel` now exports ONE grammar —
28+
`SEMVER_2_0_0_VERSION_PATTERN`, semver.org's own published expression — and
29+
every carrier references it.
30+
31+
## What every author gains, with no edit
32+
33+
Prerelease and build suffixes are accepted on the five carriers that demanded a
34+
bare three-segment core, so `2.0.0-beta.1`, `17.0.0-rc.5`, `1.0.0+20230101` and
35+
`1.0.0-rc.1+exp.sha.5114f85` now pass a key that refused all of them. Identifiers
36+
are case-preserving everywhere, as the standard requires. This repository cuts
37+
prereleases of its own packages while the key describing a package could not
38+
express one; that ends here.
39+
40+
```
41+
FROM ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … })
42+
-> throws // and `os plugin build` exits 1
43+
44+
TO ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … })
45+
-> parses
46+
```
47+
48+
## What stops being accepted, per carrier
49+
50+
Eight strings, all of them forms SemVer 2.0.0 forbids and none of them a valid
51+
prerelease. What they have in common is that no precedence order exists for any
52+
of them — `dependency-resolver.ts` can place none in an order — so a package
53+
versioned this way could be published and never compared against its own
54+
successor.
55+
56+
```
57+
FROM version: '01.1.1' TO version: '1.1.1' // §2 no leading zero in
58+
FROM version: '1.01.1' TO version: '1.1.1' // a numeric identifier
59+
FROM version: '1.1.01' TO version: '1.1.1'
60+
FROM version: '1.0.0-0123' TO version: '1.0.0-123' // §9 no leading zero in a
61+
// numeric prerelease id
62+
FROM version: '1.0.0-alpha..1' TO version: '1.0.0-alpha.1' // §9 no empty
63+
FROM version: '1.0.0-alpha..' TO version: '1.0.0-alpha' // identifier
64+
FROM version: '1.0.0-.' TO version: '1.0.0'
65+
FROM version: '1.0.0+.' TO version: '1.0.0' // §10 no empty build id
66+
```
67+
68+
⛔ Each repair above is one defensible reading and not the only one, which is
69+
why they ship as ADR-0087 D3 semantic TODOs rather than as mechanical D2
70+
conversions: a version is how a release is addressed, so rewriting one
71+
re-points whatever already resolved the old string. Run
72+
`objectstack migrate meta --from <N>` for the per-site list.
73+
74+
Per carrier:
75+
76+
- `ManifestSchema.version` and its three sibling declarations
77+
(`MetadataPluginManifestSchema`, `PluginRegistryEntrySchema`,
78+
`PluginMetadataSchema`), plus the `PATCH /api/v1/packages/:id` door: gain the
79+
whole prerelease and build space; lose a leading zero in the numeric core.
80+
- `PluginSchema.version` and the plugin boot path in `@objectstack/core`: lose
81+
those eight and **nothing else**. ⭐ Every valid prerelease and build form the
82+
loader accepts today it still accepts, which is what keeps the widen-never-
83+
narrow ruling on that path honoured rather than reversed; both halves of that
84+
bound are pinned in `plugin.test.ts` and `plugin-loader.test.ts`.
85+
- `PackageVersionSchema.version`: gains case-preserving identifiers
86+
(`1.0.0-Beta.1`, `1.0.0+Build.5`), which the boot path has always accepted and
87+
this key alone refused; loses the same eight.
88+
- `PackageManifestSchema.version`: was a bare `z.string()` constraining nothing,
89+
so it is the one carrier where the grammar is entirely new. `latest`,
90+
`v1.0.0`, `1.0`, the empty string and `2.0.0-beta.1extra!` were accepted and
91+
frozen into a published manifest snapshot; each is refused now. A dist-tag
92+
becomes the version it pointed at, a `v`-prefix drops, a two-segment string
93+
gains its patch.
94+
95+
## The prose moved with the grammar
96+
97+
Every `.describe()` names SemVer 2.0.0 and the nine generated reference-doc rows
98+
follow; the `PATCH` door's refusal says so; `manifest.test.ts`'s
99+
「should enforce semantic versioning」 case stops listing `1.0.0-beta` among the
100+
invalid versions. `PluginLoader.isSemverShapedVersion` becomes `isSemverVersion`
101+
— a predicate named for a standard it does not implement gets misused by the
102+
next caller whatever its docblock says, and the name is true now.
103+
104+
Three exported constants are retired, each replaced by the one canon:
105+
106+
```
107+
FROM import { MAJOR_MINOR_PATCH_VERSION_PATTERN } from '@objectstack/spec/kernel'
108+
FROM import { SEMVER_SHAPED_VERSION_PATTERN } from '@objectstack/spec/kernel'
109+
FROM import { SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN } from '@objectstack/spec/kernel'
110+
TO import { SEMVER_2_0_0_VERSION_PATTERN } from '@objectstack/spec/kernel'
111+
```
112+
113+
⛔ They are not interchangeable with what they replaced — each named an accept
114+
set that no longer exists, which is why they are retired rather than aliased. A
115+
consumer that referenced one to REPRODUCE a verdict gets the canon's verdict
116+
now; one that referenced it to match a foreign grammar owns that grammar itself.
117+
118+
The accept set is pinned witness by witness in `version-grammar.test.ts`: move a
119+
cell there and you have moved a published accept set on nine carriers at once,
120+
in one visible edit.

‎.changeset/20176-aggregation-temporal-storage-rule.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ What the rule does, now in one place:
2828
- A value the rule cannot read is compared as written, and so is every non-temporal column, presence tests (`$exists`, `$null`), the text operators and a `{ $field }` reference.
2929
- An object whose declared fields the engine cannot see keeps the previous comparison.
3030

31-
`@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so no `where`, write or read answer of either driver changes. MySQL still binds a `datetime` in its own literal spelling.
31+
`@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so the lift itself changes no `where`, write or read answer of either driver (#20203, in the same release, then reads an epoch-millisecond number on a `date` field as its UTC calendar day). MySQL still binds a `datetime` in its own literal spelling.
3232

3333
`@objectstack/objectql`'s `applyInMemoryAggregation(rows, ast, timezone?, fields?)` takes the object's declared field map as an optional fourth argument, and a per-aggregation `filter` reads a temporal comparand by the rule only when it is given. Called without it, the function answers as before.
3434

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/driver-sql": patch
4+
"@objectstack/driver-memory": patch
5+
"@objectstack/objectql": patch
6+
---
7+
8+
fix(core): an epoch-millisecond number compared against a `date` field is read as the UTC calendar day of its instant, by every driver and at every position that compares it (#20203)
9+
10+
Clause-②: no — no key, export or operator moves, and no comparand that was accepted is now refused: a number was already an accepted comparand on every `date` position, and its answer moves to the storage rule's reading.
11+
12+
`temporalStorageForm(value, 'date')` in `@objectstack/core` returned a finite number unchanged, so each face compared it by its own type rules and they disagreed. Over six rows, with `1769940000000` (2026-02-01T10:00:00.000Z) against a `date` field:
13+
14+
| face | `$gt` | `$lt` | `$eq` | `$in` (with a Jan 10 member) | `$between` (from Jan 2) |
15+
|:--|:--|:--|:--|:--|:--|
16+
| `where` on `driver-memory` | 0 | 0 | 0 | 0 | 0 |
17+
| `where` on `driver-sql`, SQLite | 6 | 0 | 0 | 0 | 0 |
18+
| `where` on `driver-sql`, PostgreSQL | `DATABASE_ERROR`, a 500 at REST | the same | the same | the same | the same |
19+
| a per-aggregation `filter` on `engine.aggregate` | 0 | 0 | 0 | 0 | 6 |
20+
| **now, on every face above** | **1** | **3** | **2** | **3** | **5** |
21+
22+
The same holds through `engine.find` and `POST /data/:object/query`, and for `$gte`, `$lte`, `$ne`, `$nin` and implicit equality. PostgreSQL's server refused the bound number itself (`22008`, date/time field value out of range), on an empty table too. `having` over `max` of a `date` field kept no group for `$gt`, `$eq` or `$in`; it now keeps the groups whose day compares.
23+
24+
A finite number is now read as the `datetime` rule already reads it, as epoch milliseconds. It takes the UTC calendar day of that instant: the day `new Date(value)` names, through the same conversion a `Date` takes. So a number and its `Date` always answer alike. A time of day is dropped, never rounded, a negative number is a day before 1970, and a fraction truncates toward zero as the `Date` constructor does. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call this rule, so they now agree.
25+
26+
The rule is shared by the drivers' write and read paths too:
27+
28+
- `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before.
29+
- A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there.
30+
31+
Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field, and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`app.zod.ts` docblocks: the navigation target-exclusivity guard now cites the commit that decided it, and says what that commit decided
6+
7+
Two docblock sentences in `src/ui/app.zod.ts` (which ships as source through the
8+
package's `src/**/*.zod.ts` entry) cited a tracker number that no longer resolves
9+
on GitHub. They now carry the lesson in words and anchor to commit `4cfc93b802`
10+
in this repository's history: the `filters` docblock deliberately states no
11+
precedence order, because objectui's hand-written mirror copied one from this
12+
docblock and ended up accepting a combination the schema refuses; and
13+
`objectNavTargetExclusivity` is exported so a mirror chains the schema's own rule.
14+
15+
Docblock text only. No schema, guard, accept set, export or `.describe()` string
16+
changes.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: retire the view item's `owner` and `hidden` keys — declared, stored, and read by nothing (#20085)
6+
7+
**BREAKING** — `owner` and `hidden` are removed from the view item
8+
(`defineViewItem`, `ViewItemSchema`, and the `view` metadata door's ViewItem
9+
record `{ name, object, viewKind, config }`). ADR-0049 enforce-or-remove; triage
10+
direction, verbatim: 「retire both keys」.
11+
12+
Both keys were accepted by the strict authoring door and by the wire member the
13+
`PUT /api/v1/meta/view` door validates, and `saveMetaItem` stored them verbatim —
14+
but nothing ever read or wrote either. Measured before removal, each against a
15+
lit control: no reader or writer of the view-item keys in the framework, in
16+
objectui at its pinned commit and at `main`, or in cloud. Both view-switcher read
17+
paths (`GET /meta/view?object=` and `getViewsByObject`) filter on `viewKind` +
18+
`object` and sort on `order`. So `hidden: true` hid nothing, and a view with
19+
`owner` set was listed for every user who can read the object. The `owner` half
20+
was a visibility claim nothing enforced, which is the security shape ADR-0049 is
21+
about. Per-user view scoping is a parked direction (ADR-0017, amended
22+
2026-09-04), not a shipped mechanism.
23+
24+
### FROM → TO
25+
26+
| removed | what to write instead |
27+
| --- | --- |
28+
| view item `owner` | delete the key. Nothing restricts a view item to one user today; a view item is visible to everyone who can read its object. |
29+
| view item `hidden` | delete the key. To take a view out of the switcher, delete the view item (or stop shipping it from source). |
30+
31+
**The one-line fix: delete `owner:` and `hidden:` from every view item.**
32+
`os migrate meta --from 17` lists the mechanical edits for existing sources.
33+
34+
⚠️ Runtime behaviour is deliberately **unchanged**. Neither key ever changed what
35+
a view showed or to whom, so removing one removes no behaviour. What changes is
36+
the answer an author gets: a view item carrying either key is now refused at
37+
parse, with a prescription, instead of being saved with no effect.
38+
39+
### The retirement kit
40+
41+
- **Tombstones, on the shared shape.** Both keys are `retiredKey()` tombstones on
42+
the view-item base shape. That shape feeds two doors: the strict authoring door
43+
(`ViewItemSchema`) and the `.strip()` wire member (`ViewItemWireSchema`, which
44+
the `view` write door and the assembled-manifest `viewItems` channel both run).
45+
A bare deletion there would have been a silent strip (ADR-0104), so one
46+
tombstone serves both: `tsc` types the key `never` on `defineViewItem`'s input,
47+
and every parse raises the prescription.
48+
- **D2 conversion `view-item-owner-hidden-removed`** (step 18, retired from the
49+
load path). It strips both keys from the view item **record** spelling as a
50+
lossless delete, in both collections a record travels in: `views` (stack
51+
sources, and stored `sys_metadata` rows, which the rehydration seam replays as
52+
`{ views: [row] }`) and the assembled-manifest `viewItems` channel (package
53+
export and environment artifacts). Without the second, an artifact assembled
54+
before this release would fail its registration parse.
55+
- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewItem:owner`, `ui/ViewItem:hidden`,
56+
`ui/ViewItemWire:owner`, `ui/ViewItemWire:hidden`.
57+
- **No liveness row, and no authorable-surface line.** Both instruments read a
58+
def's top-level `properties`, and `ViewItem` / `ViewItemWire` are
59+
discriminated unions that have none. So the four surface ratchets stay
60+
byte-identical on this retirement, and that reading is expected on this route.
61+
- **No deprecation window**, per the project's startup-stage posture.
62+
63+
⛔ **Untouched: the flattened-overlay members' own `owner` / `hidden`.** Those
64+
two members of the `view` door (a lean personalization PUT carrying no `config`)
65+
declare the same names on a different door, which this change does not touch. A
66+
`{ object, viewKind, hidden: true }` overlay still parses. The conversion leaves
67+
overlays alone for the same reason.
68+
69+
⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec`
70+
is published, so this is breaking for consumers no telemetry was consulted for.
71+
72+
Clause-②: no
73+
74+
<!-- adr-0087: registered view-item-owner-hidden-removed -->

‎content/docs/references/api/package-api-assembled.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Installed package row whose manifest is the assembled package body
8585
| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) |
8686
| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") |
8787
| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package |
88-
| **version** | `string` | ✅ | Package version (semantic versioning) |
88+
| **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) |
8989
| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package |
9090
| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project |
9191
| **name** | `string` | ✅ | Human-readable package name |
@@ -264,7 +264,7 @@ Installed package with runtime lifecycle state
264264
| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) |
265265
| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") |
266266
| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package |
267-
| **version** | `string` | ✅ | Package version (semantic versioning) |
267+
| **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) |
268268
| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package |
269269
| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project |
270270
| **name** | `string` | ✅ | Human-readable package name |
@@ -327,7 +327,7 @@ Installed package row whose manifest is the assembled package body
327327
| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) |
328328
| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") |
329329
| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package |
330-
| **version** | `string` | ✅ | Package version (semantic versioning) |
330+
| **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) |
331331
| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package |
332332
| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project |
333333
| **name** | `string` | ✅ | Human-readable package name |

0 commit comments

Comments
 (0)