@objectstack/core 17.0.0 → 17.2.0
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.
- package/CHANGELOG.md +734 -0
- package/dist/index.cjs +553 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +452 -29
- package/dist/index.d.ts +452 -29
- package/dist/index.js +542 -126
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,739 @@
|
|
|
1
1
|
# @objectstack/core
|
|
2
2
|
|
|
3
|
+
## 17.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ee2ff45: `ObjectKernel` no longer pre-injects the in-memory `job` fallback for the `job` core-service slot — a fallback must not fake capability (#10746, maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a job and never fires it (it owns no timer), so pre-injecting it made every "prefer the platform job service, else own a timer" consumer take the job-service branch on a kernel without `@objectstack/service-job` and then silently never run: `plugin-reports` logged `dispatcher registered with job service` and dispatched nothing, ever.
|
|
8
|
+
|
|
9
|
+
Behavior change, FROM → TO: on an `ObjectKernel` without a registered `job` service, `getService('job')` FROM resolving a non-scheduling in-memory registry TO throwing `Service 'job' not found`. Consumers' documented no-job-service paths take over (`plugin-reports` falls through to its own `setInterval` and scheduled reports actually dispatch; schedule triggers and declarative jobs warn loudly instead of scheduling into the void), and the kernel says the absence out loud at boot: `Core service missing, functionality may be degraded: job`.
|
|
10
|
+
|
|
11
|
+
One-line fix if you relied on the old behavior: install `@objectstack/service-job` for real scheduling, or — if you deliberately want the manual-trigger in-memory registry — register it explicitly: `kernel.registerService('job', createMemoryJob())` (the factory is still exported from `@objectstack/core`).
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 3b2af5e: `resolveUserAuthzGrants` issues its five independent reads (sys_user, both sys_member reads, sys_user_position, sys_user_permission_set) concurrently (#10825) — 8 sequential round trips become 4 waves on the fullest path (2 on the lightest), with byte-identical rows, filters, limits and tenancy scoping, pinned by a differential golden suite captured from the sequential implementation. No caching; nothing survives a request; authorization semantics unchanged by construction.
|
|
16
|
+
- 47cd3ec: The kernel's two `Promise.race` timeout guards — the startup guard around each
|
|
17
|
+
plugin's `init`/`start`, and the shutdown guard around `performShutdown()` —
|
|
18
|
+
now reclaim **both** halves of the guard when the race settles: the timer is
|
|
19
|
+
cleared *and* the losing promise is settled (#10604).
|
|
20
|
+
|
|
21
|
+
Neither site settled its loser, so the timeout promise and the reaction
|
|
22
|
+
`Promise.race` held on it were retained for the life of the process — four
|
|
23
|
+
leaking promises per showcase test run under `vitest --detectAsyncLeaks`, now
|
|
24
|
+
zero. The two hand-rolled copies had also drifted into doing opposite halves of
|
|
25
|
+
the same cleanup: the startup site cleared its timer and never `unref`'d, the
|
|
26
|
+
shutdown site `unref`'d and never cleared. Both now go through one internal
|
|
27
|
+
`TimeoutGuard`, so they cannot drift apart again. No exported API changes.
|
|
28
|
+
|
|
29
|
+
**Behaviour change, at the shutdown guard:** the shutdown timer is no longer
|
|
30
|
+
`unref()`d. Two consequences for an embedding host (CLI, auth-proxy, test
|
|
31
|
+
runner):
|
|
32
|
+
|
|
33
|
+
- After a **successful** shutdown, no timer is left armed. Previously the guard
|
|
34
|
+
survived its own race and stayed scheduled to fire against a kernel already
|
|
35
|
+
`'stopped'`. That late rejection was *handled* — `Promise.race` had attached a
|
|
36
|
+
rejection handler to it — so this was never an unhandled-rejection risk; it
|
|
37
|
+
was retained work and a wakeup after teardown.
|
|
38
|
+
- When teardown **hangs**, the guard now actually fires. An unref'd timer does
|
|
39
|
+
not keep the event loop alive, so a process with nothing else to run could
|
|
40
|
+
exit silently — status 0, teardown incomplete — before `shutdownTimeout`
|
|
41
|
+
elapsed, leaving `Shutdown timed out — forcing exit` and its `exit(1)`
|
|
42
|
+
unreachable in exactly the case they exist for. Reclaiming on settle keeps the
|
|
43
|
+
guard ref'd exactly as long as the race is undecided, which is the guarantee
|
|
44
|
+
the startup guard already had (#4813).
|
|
45
|
+
|
|
46
|
+
If your host relied on a hung `shutdown()` letting the process fall out of the
|
|
47
|
+
event loop on its own, it will now wait up to `shutdownTimeout` (default 60s)
|
|
48
|
+
and then hard-exit with status 1. Lower `shutdownTimeout` in the kernel config
|
|
49
|
+
to shorten that window.
|
|
50
|
+
- 9d7d2de: `resolveLocalizationContext` now memoizes a FAILED read's fallback per `(ql, tenantId, userId)` for 30s (#10221).
|
|
51
|
+
|
|
52
|
+
On a fresh environment whose `sys_setting` table hasn't been created/migrated yet, every authenticated request re-ran the same `sys_setting` localization read, and every one of those reads failed the same way ("no such table"). The `#2409` batching had already collapsed the three per-key reads a single request used to issue into one query, but that one query still repeated on every subsequent request, and `driver-sql`'s `backendStatementFault` logs a `[sql-driver] DATABASE_ERROR` warning on every failed read — so the identical warning printed once per request and buried real errors in between.
|
|
53
|
+
|
|
54
|
+
Only the case where the underlying read genuinely fails (a backend fault, e.g. the missing table) is cached; a successful read — including a legitimate "nothing configured yet" empty result — is never cached and always re-reads on the next call, so a settings write takes effect immediately. (An earlier version of this fix cached every outcome, mirroring `packages/plugins/plugin-audit/src/audit-writers.ts`'s existing TTL cache of this same read — safe there because audit-trail enrichment is best-effort, but not safe for `@objectstack/rest`'s use of this function: analytics date-bucketing reads the org timezone on every query and `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` — the #1982/#2018 golden regression — asserts the very next read reflects a just-written timezone.) The `UTC` / `en-US` fallback behavior itself is unchanged; this only stops the failing query — and its log line — from re-running every request. The cache is keyed on the `ql` engine instance first, so two environments/tenants sharing one process never share a cached outcome, and self-heals within one TTL window once `sys_setting` exists.
|
|
55
|
+
- c815c50: `resolveLocalizationContext` prefers `settings.getMany` — one grouped namespace read instead of three per-key `get()`s (#10826); older services without `getMany` keep the three parallel gets, and a thrown `getMany` lands in the same direct `$in` fallback a thrown `get` did.
|
|
56
|
+
- 795ea05: A lapsed `sys_member` row now confers no org role either — one row, one answer (#10982)
|
|
57
|
+
|
|
58
|
+
`resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it:
|
|
59
|
+
`accessible_org_ids` (the `group` posture's read reach, ADR-0105 D2) and the
|
|
60
|
+
org-administration role projection into `positions` (ADR-0095 D3). Only the
|
|
61
|
+
first applied the ADR-0091 validity window. A membership outside
|
|
62
|
+
`[valid_from, valid_until)` was therefore excluded from org access while still
|
|
63
|
+
projecting its better-auth role — two answers from one read, and with
|
|
64
|
+
`role: 'owner'` the role reaches the `organization_admin` capability that
|
|
65
|
+
`derivePosture` reads for `TENANT_ADMIN`.
|
|
66
|
+
|
|
67
|
+
The role projection now drops out-of-window rows **before** the derivation, the
|
|
68
|
+
same shape `sys_user_permission_set` already had, so an expired membership can
|
|
69
|
+
no more yield `org_owner` than an expired `admin_full_access` can yield
|
|
70
|
+
`platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22
|
|
71
|
+
live session (item 2): a lapsed membership is *no membership*, not merely *no
|
|
72
|
+
org access*.
|
|
73
|
+
|
|
74
|
+
**Why `patch` and not a breaking bump, argued in the open.** This is a real
|
|
75
|
+
change of authorization semantics — a membership that used to confer a role
|
|
76
|
+
stops conferring it — so the direction is a tightening, and tightenings are the
|
|
77
|
+
kind of change that normally earns a major. It is nevertheless `patch` because
|
|
78
|
+
the population it can affect is provably empty: `sys_member` declares neither
|
|
79
|
+
`valid_from` nor `valid_until` (see `sys-member.object.ts`), and `isGrantActive`
|
|
80
|
+
reads an absent bound as unbounded, so **no row any deployment can currently
|
|
81
|
+
store is lapsed** and every existing membership resolves exactly as before. That
|
|
82
|
+
is asserted directly rather than reasoned about, in
|
|
83
|
+
`resolve-authz-context.test.ts` ("a membership with NO bounds is unbounded —
|
|
84
|
+
every shipped row is unaffected"), alongside the load-bearing leg that an
|
|
85
|
+
in-window membership still projects its role. Landing it now is the cheap
|
|
86
|
+
moment: once the columns exist, the same change becomes a migration carrying
|
|
87
|
+
live semantics.
|
|
88
|
+
|
|
89
|
+
**Not in scope, and deliberately so.** This does not add the validity columns to
|
|
90
|
+
`sys_member`, and it does not reach into `sys_user_permission_set` rows that
|
|
91
|
+
plugin-security's `reconcileOrgAdminGrant` provisioned from a membership role.
|
|
92
|
+
Such a grant is standing authority in its own right with its own ADR-0091
|
|
93
|
+
window; the role is only its provisioning source (ADR-0095 D3). The boundary is
|
|
94
|
+
pinned as a measured fact rather than left as an assumption.
|
|
95
|
+
- 504c8d5: Materialize the RBAC catalog **per organization**, so a walled deployment can
|
|
96
|
+
administer positions, permission sets and sharing rules again (#10103).
|
|
97
|
+
|
|
98
|
+
On a walled deployment (`group` / `isolated`) every principal — an organization
|
|
99
|
+
owner and a platform admin alike — listed **zero** positions, permission sets
|
|
100
|
+
and sharing rules while the tables held rows. Nothing could be bound through
|
|
101
|
+
Setup, and a declared `hierarchy-security` could never be armed by an operator
|
|
102
|
+
however loudly an app declared it.
|
|
103
|
+
|
|
104
|
+
Every row in those three tables was organization-less. plugin-security's Layer 0
|
|
105
|
+
composes a strict `organization_id = :tenant` for a walled posture and the
|
|
106
|
+
middleware ANDs it into the read AST over the driver's
|
|
107
|
+
`(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the
|
|
108
|
+
two is the strict equality alone, so the driver's null arm was annihilated on
|
|
109
|
+
every authenticated read.
|
|
110
|
+
|
|
111
|
+
**The wall is not changed, at either layer.** The rows get an owner instead:
|
|
112
|
+
|
|
113
|
+
- `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`,
|
|
114
|
+
`bootstrapDeclaredPermissions` (plugin-security) and
|
|
115
|
+
`bootstrapDeclaredSharingRules` (plugin-sharing) upsert by
|
|
116
|
+
`(name, organization_id)` and run **one pass per organization** under a walled
|
|
117
|
+
posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`,
|
|
118
|
+
`guest`) included, matching `sys_user_position`, which is already
|
|
119
|
+
per-organization, and matching both objects' own `unique: 'organization'` name
|
|
120
|
+
index.
|
|
121
|
+
- Seeding also fires on **organization creation**, not only at `kernel:ready`, so
|
|
122
|
+
a tenant created after startup does not administer an empty catalog until the
|
|
123
|
+
next restart.
|
|
124
|
+
- `single` posture is **unchanged**: exactly one organization-less pass, which is
|
|
125
|
+
the correct shape there.
|
|
126
|
+
|
|
127
|
+
An organization-less row is now invalid state under a walled posture. Nothing is
|
|
128
|
+
reaped — grants (`sys_user_position`, `sys_position_permission_set`,
|
|
129
|
+
`sys_user_permission_set`, `sys_record_share`) point at these rows by id, so
|
|
130
|
+
deleting them would revoke standing access with no signal at the moment of loss.
|
|
131
|
+
Instead a per-organization pass that meets pre-fix organization-less rows for
|
|
132
|
+
names it seeds **says so loudly**, naming the rows and the remedy, and still
|
|
133
|
+
creates that organization's own copies. The failure this closes is the silent
|
|
134
|
+
no-op: a tenant-threaded pass that sees the old row through the driver's
|
|
135
|
+
compatibility arm, reads the name as already represented, and creates nothing
|
|
136
|
+
while reporting success.
|
|
137
|
+
|
|
138
|
+
Two enforcement-plane reads are scoped in the same change, because the exposure
|
|
139
|
+
they carry only exists once per-organization copies exist:
|
|
140
|
+
|
|
141
|
+
- `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved
|
|
142
|
+
`sys_position` by name across **every** organization, so the junction read
|
|
143
|
+
behind it collected another organization's `everyone` binding — a cross-organization
|
|
144
|
+
grant bleed, and an O(organizations) read on the per-request path. It is now
|
|
145
|
+
threaded through the driver's tenant chokepoint, keeping per-request resolution
|
|
146
|
+
O(the caller's own organization's catalog).
|
|
147
|
+
- plugin-security's permission-set `dbLoader` resolved sets by name unscoped,
|
|
148
|
+
with a `limit` equal to the number of names — correct while one row existed per
|
|
149
|
+
name, a truncation the moment copies exist. It is now scoped to the caller's
|
|
150
|
+
organization and its bound widened.
|
|
151
|
+
|
|
152
|
+
Boot reconciliation is O(changed declarations): each pass reads what its
|
|
153
|
+
organization already has and writes only where a declaration actually differs, so
|
|
154
|
+
the common boot performs no writes at all. Steady state rides the
|
|
155
|
+
organization-creation hook.
|
|
156
|
+
|
|
157
|
+
Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization
|
|
158
|
+
sharing rules cheaper than the unscoped sweep they replace.
|
|
159
|
+
- Updated dependencies [6936d07]
|
|
160
|
+
- Updated dependencies [59eb04d]
|
|
161
|
+
- Updated dependencies [9f05b7d]
|
|
162
|
+
- Updated dependencies [7d2d112]
|
|
163
|
+
- Updated dependencies [5fa0d72]
|
|
164
|
+
- Updated dependencies [02b3b07]
|
|
165
|
+
- Updated dependencies [914c413]
|
|
166
|
+
- Updated dependencies [55809a0]
|
|
167
|
+
- Updated dependencies [52db1d1]
|
|
168
|
+
- Updated dependencies [5649efb]
|
|
169
|
+
- Updated dependencies [2306a76]
|
|
170
|
+
- Updated dependencies [e5ea701]
|
|
171
|
+
- Updated dependencies [a40dcc1]
|
|
172
|
+
- Updated dependencies [def0d3e]
|
|
173
|
+
- Updated dependencies [8d0bb79]
|
|
174
|
+
- Updated dependencies [5acb58d]
|
|
175
|
+
- Updated dependencies [2e3cf95]
|
|
176
|
+
- Updated dependencies [4c93387]
|
|
177
|
+
- Updated dependencies [a037f7c]
|
|
178
|
+
- Updated dependencies [3ee8ddf]
|
|
179
|
+
- Updated dependencies [16cef97]
|
|
180
|
+
- Updated dependencies [a79bd35]
|
|
181
|
+
- Updated dependencies [6ceaa4b]
|
|
182
|
+
- Updated dependencies [15ea214]
|
|
183
|
+
- Updated dependencies [de19489]
|
|
184
|
+
- Updated dependencies [c684d00]
|
|
185
|
+
- Updated dependencies [923c424]
|
|
186
|
+
- Updated dependencies [1ec36b7]
|
|
187
|
+
- Updated dependencies [5f2e54c]
|
|
188
|
+
- Updated dependencies [189373b]
|
|
189
|
+
- Updated dependencies [35ad101]
|
|
190
|
+
- Updated dependencies [ceb33a9]
|
|
191
|
+
- Updated dependencies [73d9795]
|
|
192
|
+
- Updated dependencies [8012960]
|
|
193
|
+
- Updated dependencies [f34f56b]
|
|
194
|
+
- Updated dependencies [f399618]
|
|
195
|
+
- Updated dependencies [75e9301]
|
|
196
|
+
- Updated dependencies [2810695]
|
|
197
|
+
- @objectstack/spec@17.2.0
|
|
198
|
+
|
|
199
|
+
## 17.1.0
|
|
200
|
+
|
|
201
|
+
### Minor Changes
|
|
202
|
+
|
|
203
|
+
- 2782805: feat(security): the REST 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9487)
|
|
204
|
+
|
|
205
|
+
Every other REST error family answers `{ error, code }`, with the machine code
|
|
206
|
+
in `code` — the 401 family was the one outlier, answering
|
|
207
|
+
`{ error: "UNAUTHENTICATED", message }` with no `code` key at all. A client
|
|
208
|
+
keying on `body.code` (the shape the other families teach, and the first read
|
|
209
|
+
of `@objectstack/client`'s `err.code`) read `undefined` for every
|
|
210
|
+
authentication failure.
|
|
211
|
+
|
|
212
|
+
`ANONYMOUS_DENY_BODY` now carries `code: "UNAUTHENTICATED"` as well.
|
|
213
|
+
**Additive only** (maintainer-ruled): no key is removed or moved — `error`
|
|
214
|
+
keeps holding the same code value it always has, so every existing reader
|
|
215
|
+
keeps working. The wire effect surfaces through `@objectstack/rest`'s
|
|
216
|
+
`enforceAuth`, which writes this constant verbatim on every `/data`, `/meta`
|
|
217
|
+
and `/reports` 401. This does not settle ADR-0112 D5 (flat vs nested envelope
|
|
218
|
+
convergence); both declared envelope families are unchanged in kind.
|
|
219
|
+
- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)
|
|
220
|
+
|
|
221
|
+
<!-- adr-0087: not-required (no-migration-prescription) One additive column on
|
|
222
|
+
an `isSystem` object declaring `protection: { lock: 'full' }`, which tenants
|
|
223
|
+
cannot author, so there is no consumer metadata to migrate and nothing
|
|
224
|
+
authorable is renamed, retired or tombstoned — no conversion to register. The
|
|
225
|
+
behavioural change is that a minted key now carries an organization, that a key
|
|
226
|
+
which cannot carry one is refused under the posture where it could never read
|
|
227
|
+
anything, and that an ex-member's key stops authenticating. -->
|
|
228
|
+
|
|
229
|
+
On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
|
|
230
|
+
read **nothing at all**. `sys_api_key` carried no organization column, so key
|
|
231
|
+
authentication established a user but no active organization — and the
|
|
232
|
+
`isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
|
|
233
|
+
no active organization matches no row. Every organization-scoped read answered
|
|
234
|
+
`200` with `total 0` while the console went on offering minting, so a tenant
|
|
235
|
+
admin could mint a valid-looking secret and discover only at call time that it
|
|
236
|
+
read nothing. (There was no cross-tenant leak — the failure was in the other
|
|
237
|
+
direction.)
|
|
238
|
+
|
|
239
|
+
**The column was absent by an inherited rule, not by oversight.**
|
|
240
|
+
`resolveInjectedSystemColumns` injects `organization_id` into every registered
|
|
241
|
+
object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
|
|
242
|
+
flag — even though better-auth's `apiKey` plugin is not loaded and the table is
|
|
243
|
+
hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
|
|
244
|
+
extension-field registration to stay consistent. The read side, by contrast,
|
|
245
|
+
was **already wired**: `resolveApiKeyPrincipal` already read an organization
|
|
246
|
+
into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
|
|
247
|
+
column no mint path ever wrote.
|
|
248
|
+
|
|
249
|
+
**What changes**
|
|
250
|
+
|
|
251
|
+
- `sys_api_key` declares `active_organization_id` (+ index, and the column is
|
|
252
|
+
shown in the "My Keys" and "All" list views, because the card's complaint was
|
|
253
|
+
a credential whose reach its owner could not see).
|
|
254
|
+
- `POST /api/v1/keys` **inherits** the caller's active organization — there is
|
|
255
|
+
deliberately no org parameter and no cross-org key — and **re-checks the
|
|
256
|
+
caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
|
|
257
|
+
windows. Under a walled posture it refuses (400) rather than minting a key
|
|
258
|
+
with no organization, and refuses (403) for an organization the caller is not
|
|
259
|
+
a member of. The mint response echoes the organization the key is pinned to.
|
|
260
|
+
- The verifier reads **one spelling** (Prime Directive #12): the
|
|
261
|
+
`row.organization_id ?? row.organizationId` chain it used to carry was a
|
|
262
|
+
consumer-side tolerance for a producer that did not exist.
|
|
263
|
+
- An **ex-member's key fails closed at verify time** — no principal, not a
|
|
264
|
+
degrade to a user-only principal, which would resurrect the same
|
|
265
|
+
`200 + total 0` silent-empty. Checked at verify rather than by revoking on
|
|
266
|
+
membership loss, because membership ends through many paths (better-auth org
|
|
267
|
+
endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
|
|
268
|
+
a hook must catch every one or it silently misses. It costs **zero extra
|
|
269
|
+
queries**: the resolver has already read `sys_member` for this user.
|
|
270
|
+
- **Pre-existing org-less keys are never backfilled** — that would silently
|
|
271
|
+
upgrade credentials minted under a different promise. They keep working under
|
|
272
|
+
`single` (no wall) and under `group` (whose wall derives from the owner's
|
|
273
|
+
memberships independently of the active organization, so they already work
|
|
274
|
+
there), and are **refused under `isolated`**, where they are provably dead
|
|
275
|
+
today.
|
|
276
|
+
|
|
277
|
+
**The column is deliberately named `active_organization_id`, not
|
|
278
|
+
`organization_id`** — the `sys_session` spelling, for the same concept: the
|
|
279
|
+
organization a credential makes *active*. `objectHasOrgIdField` tests for the
|
|
280
|
+
literal `organization_id`, and Layer 0 exempts objects without it, so the other
|
|
281
|
+
name would have made `sys_api_key` itself org-walled. Both walled postures
|
|
282
|
+
exclude NULL, so every pre-existing org-less row would have vanished from its
|
|
283
|
+
**own owner's** "My Keys" list while, under `group`, continuing to
|
|
284
|
+
authenticate — a live credential nobody could see or revoke, which is a fresh
|
|
285
|
+
instance of the very class this change removes.
|
|
286
|
+
- a38408a: fix(core): both kernels agree that a duplicate plugin registration OVERWRITES, and say so out loud (#9864)
|
|
287
|
+
|
|
288
|
+
Registering two plugins under the same `name` used to mean two different things
|
|
289
|
+
depending on which kernel was running:
|
|
290
|
+
|
|
291
|
+
| kernel | behaviour before |
|
|
292
|
+
|---|---|
|
|
293
|
+
| `ObjectKernel` (what `os serve` runs) | accepted and overwrote, with **no check and no distinguishing log line** — `Plugin registered: <name>@<version>` printed twice, reading as two plugins running |
|
|
294
|
+
| `LiteKernel` (tests, serverless, edge) | threw `[Kernel] Plugin '<name>' already registered` |
|
|
295
|
+
|
|
296
|
+
Under the maintainer's ruling (2026-08-19, option B) both kernels now apply one
|
|
297
|
+
declared contract: **duplicate registration by `name` overwrites — last-one-wins
|
|
298
|
+
— and emits a `warn` naming the plugin and both versions.**
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
WARN Plugin superseded: 'com.objectstack.audit' — the later registration (v2.0.0)
|
|
302
|
+
REPLACED the earlier one (v1.0.0). Only the later instance is initialized and
|
|
303
|
+
started; the earlier one is discarded without ever running init(). Duplicate
|
|
304
|
+
registration by name is last-one-wins on both kernels by declared contract
|
|
305
|
+
(#9864) — register the plugin once if that is not what you meant.
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**This declares and warns about behaviour that already shipped; it does not fix a
|
|
309
|
+
user-visible bug.** The overwrite is load-bearing today — it is exactly what lets
|
|
310
|
+
a stack's own `plugins` entry supersede a plugin the CLI auto-registered earlier
|
|
311
|
+
in the same boot (`AuditPlugin`, #9863) — and every boot path that worked before
|
|
312
|
+
works the same way now. What changes is that the behaviour is declared, audible,
|
|
313
|
+
and pinned against **both** kernels
|
|
314
|
+
(`packages/core/src/plugin-registration.contract.test.ts`) rather than being an
|
|
315
|
+
accident of whichever kernel a reader happened to open. This was the fourth
|
|
316
|
+
measured instance of one contract implemented twice across the two kernels
|
|
317
|
+
(#5170, #5282, #8357 adjacent).
|
|
318
|
+
|
|
319
|
+
**What this changes for a caller**
|
|
320
|
+
|
|
321
|
+
- `LiteKernel.use()` no longer throws on a duplicate name. FROM: catch
|
|
322
|
+
`[Kernel] Plugin '<name>' already registered` to detect a double registration.
|
|
323
|
+
TO: there is no throw to catch — a duplicate is a `warn` and the later instance
|
|
324
|
+
wins. Code that registered a plugin twice and relied on the refusal should
|
|
325
|
+
register it once instead.
|
|
326
|
+
- `ObjectKernel` emits one `warn` where it previously emitted nothing, and
|
|
327
|
+
**suppresses** its `Plugin registered:` line for the superseding registration,
|
|
328
|
+
so the count of those lines equals the number of plugins that actually boot.
|
|
329
|
+
- The level is part of the contract: `warn`, never `info`. The CLI's default
|
|
330
|
+
kernel level is `warn`, and its boot-quiet window replays `warn` while
|
|
331
|
+
discarding in-window `info` — an `info` notice would be invisible on exactly
|
|
332
|
+
the boot path where this was measured.
|
|
333
|
+
|
|
334
|
+
**Measured, not assumed:** the displaced instance holds nothing that needs
|
|
335
|
+
teardown. Registration is legal only while the kernel is `idle`, so a supersede
|
|
336
|
+
can only ever displace a plugin that has never been initialized; `init()`,
|
|
337
|
+
`start()` and `destroy()` all run later, over a registry the displaced entry has
|
|
338
|
+
already left. `PluginLoader.loadPlugin()` — which `ObjectKernel` runs first — is
|
|
339
|
+
pure validation plus a name-keyed map write of its own, and invokes nothing on
|
|
340
|
+
the plugin. Calling `destroy()` on the displaced instance would be the bug, not
|
|
341
|
+
the fix: it is the paired teardown for an `init()` that never ran.
|
|
342
|
+
- 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613)
|
|
343
|
+
|
|
344
|
+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
|
|
345
|
+
added, renamed or retired. `active` is a ROW property of a `sys_permission_set`
|
|
346
|
+
/ `sys_position` record, not a key on `PermissionSetSchema` (which is a strict
|
|
347
|
+
object in `packages/spec` and deliberately declares no such key — see
|
|
348
|
+
`permission-set-projection.ts`'s ROW_STATE_COLUMNS). No spec schema, export or
|
|
349
|
+
stored metadata shape changes, so there is no conversion to register and no
|
|
350
|
+
tombstone to write. The change is a runtime predicate at the authorization
|
|
351
|
+
resolution seam; the remedy for an affected deployment is operational
|
|
352
|
+
(re-activate rows that were switched off), not a metadata migration. -->
|
|
353
|
+
|
|
354
|
+
**BREAKING for deployments that already switched a permission set or position
|
|
355
|
+
off.** Both objects ship a Deactivate action whose confirmation dialog promises,
|
|
356
|
+
in all four locales, that access stops:
|
|
357
|
+
|
|
358
|
+
> Deactivate this permission set? Existing assignments stay in place but stop
|
|
359
|
+
> granting access until re-activated.
|
|
360
|
+
> Deactivate this position? Users keep their assignment but the position stops
|
|
361
|
+
> granting permissions until re-activated.
|
|
362
|
+
|
|
363
|
+
Nothing read the column. Measured on the real resolver: a position seeded
|
|
364
|
+
`active: false` still granted its permission sets, and a permission set seeded
|
|
365
|
+
`active: false` still returned `posture: PLATFORM_ADMIN` with its system
|
|
366
|
+
permissions. Deactivation moved a badge in Setup and nothing else — while the
|
|
367
|
+
admin who had just revoked a compromised or over-broad grant was told the
|
|
368
|
+
opposite, and whose likely next step was therefore *not* the action that would
|
|
369
|
+
have worked (delete the set, or remove the assignments).
|
|
370
|
+
|
|
371
|
+
**What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants`
|
|
372
|
+
(`@objectstack/core`) — the single seam every transport resolves authorization
|
|
373
|
+
through — now drop a deactivated row **before** any derivation:
|
|
374
|
+
|
|
375
|
+
- a deactivated `sys_position` no longer contributes its
|
|
376
|
+
`sys_position_permission_set` grants, and its name leaves `positions` (so the
|
|
377
|
+
name-reuse path cannot resolve the same grant one layer down);
|
|
378
|
+
- a deactivated `sys_permission_set` contributes no name, no
|
|
379
|
+
`system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN`
|
|
380
|
+
posture** — the flag is applied before the posture is derived, not after;
|
|
381
|
+
- the `plugin-security` DB loader applies the same predicate, which is what
|
|
382
|
+
judges a set reached by NAME through an active position of the same name.
|
|
383
|
+
|
|
384
|
+
Both tables were already read at that seam, so this costs **zero new hot-path
|
|
385
|
+
queries**.
|
|
386
|
+
|
|
387
|
+
**⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position`
|
|
388
|
+
row currently carrying `active: false` **stops granting the moment this
|
|
389
|
+
lands** — on live data, with no migration step to notice. That is the correct
|
|
390
|
+
direction (it is what the dialog said when someone clicked Deactivate), but on
|
|
391
|
+
an installation that used the switch believing it was inert it is a real
|
|
392
|
+
revocation. Before upgrading, list the deactivated rows and re-activate any that
|
|
393
|
+
are still meant to grant:
|
|
394
|
+
|
|
395
|
+
```
|
|
396
|
+
GET /api/v1/data/sys_permission_set?filters=[["active","=",false]]
|
|
397
|
+
GET /api/v1/data/sys_position?filters=[["active","=",false]]
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
A row whose `active` column is **absent or NULL** is unaffected: the predicate
|
|
401
|
+
is "explicitly deactivated", never "explicitly active", so rows that predate the
|
|
402
|
+
column keep granting exactly as before.
|
|
403
|
+
|
|
404
|
+
**Break-glass, closed in the same change** (`@objectstack/plugin-auth`).
|
|
405
|
+
Enforcing the flag opened a one-click, installation-wide lockout: deactivating
|
|
406
|
+
`admin_full_access` un-makes every platform admin at once, through a payload
|
|
407
|
+
that touches neither `name` nor any identity table, and re-activating requires
|
|
408
|
+
the permission the click just took away (the seeders deliberately never
|
|
409
|
+
reconcile `active`, so no restart restores it). The last-administrator guard now
|
|
410
|
+
judges that write like the delete and rename spellings it already refused, and
|
|
411
|
+
an environment whose break-glass set is *already* off is read as emptied rather
|
|
412
|
+
than as a bootstrap window — so it does not silently disarm the guard for every
|
|
413
|
+
other identity write. Re-activation itself stays permitted, or the refusal would
|
|
414
|
+
have no way out from inside the product.
|
|
415
|
+
- f8eb736: feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734)
|
|
416
|
+
|
|
417
|
+
`plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a
|
|
418
|
+
pending write can empty the administrator population by testing the payload
|
|
419
|
+
against three standing-key lists (`MEMBER_STANDING_KEYS`,
|
|
420
|
+
`GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none
|
|
421
|
+
of them is skipped without any reads — so a column `resolveAuthzContext` starts
|
|
422
|
+
reading that a list omits is a write class the guard **silently stops judging**,
|
|
423
|
+
on the one path whose failure mode is an installation-wide administrator lockout
|
|
424
|
+
with no in-product recovery.
|
|
425
|
+
|
|
426
|
+
Nothing bound the two together. The correspondence lived in a comment, and it
|
|
427
|
+
had already gone false once: #6084 wrote — naming `active` explicitly — that
|
|
428
|
+
everything a permission-set write touches other than `name` is invisible to "who
|
|
429
|
+
is an administrator". That was true when written; #8613 made `active` a
|
|
430
|
+
resolution-time predicate and the sentence became false. Nothing mechanical
|
|
431
|
+
would have caught it, because the guard's own tests stay green precisely when
|
|
432
|
+
the guard is never consulted.
|
|
433
|
+
|
|
434
|
+
**The mechanism is two links, and the first one is a measurement.**
|
|
435
|
+
|
|
436
|
+
- `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the
|
|
437
|
+
resolver, listing every table the administrator-derivation path reads, each
|
|
438
|
+
classified `derives` or `reads-only` with its reason, and for the deriving
|
|
439
|
+
tables every column read. It is asserted **equal** to what the real
|
|
440
|
+
`resolveAuthzContext` reads, observed at runtime through a recording engine
|
|
441
|
+
that records every property access and every `where` key per table. Observation
|
|
442
|
+
rather than source extraction because the reads that matter have moved into
|
|
443
|
+
helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds
|
|
444
|
+
by `isGrantActive(row, now)`, neither named at the resolver's own call site —
|
|
445
|
+
the exact shape #8613 had.
|
|
446
|
+
|
|
447
|
+
- `@objectstack/plugin-auth` now exports its standing-key lists plus
|
|
448
|
+
`STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires
|
|
449
|
+
every column of that measured surface to have an answer: it is standing-bearing
|
|
450
|
+
(in a list) or it is excluded with the reason it cannot empty the administrator
|
|
451
|
+
population. There is no third state — the third state is what `active` was
|
|
452
|
+
between #6084 and #8613.
|
|
453
|
+
|
|
454
|
+
So a resolver change that starts reading a new column fails at the first link
|
|
455
|
+
until the declaration is updated, and at the second until the guard has an
|
|
456
|
+
explicit answer for it. Landing #8613 green would have required writing down that
|
|
457
|
+
deactivating `admin_full_access` cannot empty the administrator population —
|
|
458
|
+
which is false, and which is what the old comment asserted by accident.
|
|
459
|
+
|
|
460
|
+
**No guard behaviour changes.** Every list keeps exactly the values it had; the
|
|
461
|
+
gate is one-directional by construction (it can only ever demand that the guard
|
|
462
|
+
judges *more*), because the other direction would put pressure on a break-glass
|
|
463
|
+
guard to fire less often.
|
|
464
|
+
|
|
465
|
+
The table-level half is covered too: a resolver that started deriving
|
|
466
|
+
administrator standing from a **new** table is invisible to any column-set
|
|
467
|
+
comparison, since the table is absent from both sides — so the surface enumerates
|
|
468
|
+
every table the path reads, and an unclassified one fails.
|
|
469
|
+
|
|
470
|
+
### Patch Changes
|
|
471
|
+
|
|
472
|
+
- 7ff3975: feat(spec): `IHttpServer` gains an optional `afterResponse` response-observing
|
|
473
|
+
hook so HTTP metrics are transport-agnostic instead of Hono-only (#9835)
|
|
474
|
+
|
|
475
|
+
The contract addition (additive — a new optional member plus the
|
|
476
|
+
`HttpResponseObservation` / `HttpResponseObserver` types and the reserved
|
|
477
|
+
`UNMATCHED_ROUTE_PATTERN` label): a transport invokes each registered observer
|
|
478
|
+
exactly once per answered request with `{ method, routePattern, status,
|
|
479
|
+
elapsedMs }`, after the response exists — the observation point the `use()`
|
|
480
|
+
middleware contract cannot express (it runs before dispatch and never sees a
|
|
481
|
+
status). `routePattern` is REQUIRED to be the registered route pattern
|
|
482
|
+
(`/api/v1/data/:id`), never the concrete path, so no adapter re-decides metric
|
|
483
|
+
cardinality. Optionality is feature-detected runtime-real
|
|
484
|
+
(`typeof server.afterResponse === 'function'`); a transport that does not
|
|
485
|
+
implement the seam reports **no** HTTP metrics — zero there means "not
|
|
486
|
+
instrumented", never "no traffic".
|
|
487
|
+
|
|
488
|
+
Implementations and consumers in the same change:
|
|
489
|
+
|
|
490
|
+
- `@objectstack/plugin-hono-server`: `HonoHttpServer` implements the seam (the
|
|
491
|
+
ruled #9650 raw-app middleware becomes its delivery path — same reach,
|
|
492
|
+
including `getRawApp()` mounts and middleware-refused 429s); unrouted
|
|
493
|
+
requests are now labelled with the reserved `unmatched` pattern (previously
|
|
494
|
+
they could surface as `/*`).
|
|
495
|
+
- `@objectstack/observability`: new `armHttpRequestCounter(server, metrics)`
|
|
496
|
+
arms the `http_requests_total` counter through the seam at most once per
|
|
497
|
+
server (first caller wins), which is what makes "exactly one counter per
|
|
498
|
+
server" structural.
|
|
499
|
+
- `@objectstack/runtime`: the dispatcher offers its `observability.metrics`
|
|
500
|
+
registry to the seam (a host that wires only the dispatcher now counts every
|
|
501
|
+
inbound surface) and suppresses its own per-route copy of
|
|
502
|
+
`http_requests_total` when the transport implements the seam — retiring the
|
|
503
|
+
#9833 double count. Request-id echo, the duration histogram, the error
|
|
504
|
+
counter and the error reporter are unchanged.
|
|
505
|
+
- `@objectstack/http-conformance`: `NodeHttpServer` implements the seam, and a
|
|
506
|
+
new cross-adapter conformance suite locks the semantics for both adapters.
|
|
507
|
+
- `@objectstack/core`: re-exports the new contract types/constant.
|
|
508
|
+
- 24173e9: fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485)
|
|
509
|
+
|
|
510
|
+
`parseDateCell` ended in `new Date(s)`. A spreadsheet cell like
|
|
511
|
+
`2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the
|
|
512
|
+
**process** timezone, and the instant bulk import stored became a property of
|
|
513
|
+
the deployment host:
|
|
514
|
+
|
|
515
|
+
```
|
|
516
|
+
TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z
|
|
517
|
+
TZ=UTC → 2026-08-01T06:00:00.000Z
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Same file, same tenant, same cell — eight hours apart, decided by a setting
|
|
521
|
+
nobody authoring the spreadsheet can see, and never consulting the business
|
|
522
|
+
timezone the route had already resolved one frame up
|
|
523
|
+
(`ExecutionContext.timezone`, the platform-default → global → tenant cascade).
|
|
524
|
+
|
|
525
|
+
Since the export renders `datetime` cells in that business timezone (#8373), the
|
|
526
|
+
advertised export → edit in a spreadsheet → re-import round trip was lossless
|
|
527
|
+
only where the host `TZ` happened to equal the business zone. `import-coerce.ts`
|
|
528
|
+
opens by calling itself "the inverse of `export-format.ts`"; it now is one, and
|
|
529
|
+
the regression proof asserts inverse-ness on the **pair** — every fixture under
|
|
530
|
+
a host `TZ` deliberately different from the business timezone, because a test
|
|
531
|
+
that runs only under a matching `TZ` cannot fail.
|
|
532
|
+
|
|
533
|
+
**An offset-free datetime cell is now read in the caller's business timezone**,
|
|
534
|
+
through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall
|
|
535
|
+
clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill
|
|
536
|
+
path) is now the midnight special case of. One implementation of zone
|
|
537
|
+
arithmetic, `Intl` offsets from the platform tz database, never hand-rolled;
|
|
538
|
+
generalising the existing one rather than hand-rolling a second in `rest` is
|
|
539
|
+
what keeps the export and import halves of this seam from drifting apart again.
|
|
540
|
+
Two wall clocks are not a bijection with instants, and both degenerate DST
|
|
541
|
+
readings resolve to the earlier candidate instant — a gap reading lands just
|
|
542
|
+
before the gap, an ambiguous reading on its first occurrence (pinned, measured).
|
|
543
|
+
|
|
544
|
+
Three things deliberately do **not** move:
|
|
545
|
+
|
|
546
|
+
- **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one
|
|
547
|
+
instant and is honoured exactly as written. This change affects naive cells
|
|
548
|
+
only.
|
|
549
|
+
- **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and
|
|
550
|
+
a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the
|
|
551
|
+
zoned handling to make the code look uniform would silently re-time every
|
|
552
|
+
date-only import to fix nothing.
|
|
553
|
+
- **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback
|
|
554
|
+
the export's cell path takes in the same case, so the round trip stays exact
|
|
555
|
+
for deployments that configure no zone — and a process-`TZ` fallback would
|
|
556
|
+
preserve the defect for exactly the deployments that cannot see it. This is
|
|
557
|
+
the one **behaviour change for existing deployments**: a host with a non-UTC
|
|
558
|
+
`TZ` and no resolved business timezone previously read naive cells in the host
|
|
559
|
+
clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved
|
|
560
|
+
zone, not a missing one.
|
|
561
|
+
|
|
562
|
+
Two adjacent legs of the same defect, both on the naive-cell path:
|
|
563
|
+
|
|
564
|
+
- **A naive cell landing in a `date` or `time` field** now takes the typed
|
|
565
|
+
components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`).
|
|
566
|
+
Those branches also read the process clock, so a host east of the cell stored
|
|
567
|
+
the *previous calendar day* for a `date` column.
|
|
568
|
+
- **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS
|
|
569
|
+
materialises it as a `Date` whose UTC components are the sheet's wall clock,
|
|
570
|
+
and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the
|
|
571
|
+
file never had. That fabricated offset then outranked the business timezone by
|
|
572
|
+
the very carve-out above, so every real date cell in a user-authored workbook
|
|
573
|
+
imported as UTC whatever the tenant's zone. It now flattens to the same
|
|
574
|
+
offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that
|
|
575
|
+
function's contract already claimed to produce.
|
|
576
|
+
- e1bb0ca: fix(qa): `HttpTestAdapter` resolves the Data Protocol mount from the server's `/discovery`, and falls back to the convention loudly (#7983)
|
|
577
|
+
|
|
578
|
+
The record-shaped `os test` action types (`create_record`, `read_record`,
|
|
579
|
+
`update_record`, `delete_record`, `query_records`) built their URLs from the
|
|
580
|
+
**defaults** of `RestApiConfigSchema.apiPath` and
|
|
581
|
+
`CrudEndpointsConfigSchema.dataPrefix`, because the adapter is handed an origin
|
|
582
|
+
and nothing else. A deployment that moved the mount got a 404 that reads like the
|
|
583
|
+
suite author's own URL mistake rather than a platform limitation.
|
|
584
|
+
|
|
585
|
+
The adapter now asks the server, following the `getRoute` precedent in
|
|
586
|
+
`@objectstack/client`: **one memoised `GET {apiBase}/discovery` per run** (`os
|
|
587
|
+
test` builds one adapter for the whole run), addressing whatever `routes.data`
|
|
588
|
+
advertises, with the schema-derived convention as the fallback. Measured on a
|
|
589
|
+
booted stack (REST route generator + dispatcher bridge), before and after:
|
|
590
|
+
|
|
591
|
+
| deployment | before | after |
|
|
592
|
+
|---|---|---|
|
|
593
|
+
| stock | created | created |
|
|
594
|
+
| `crud.dataPrefix: '/objects'` | `HTTP Error 404` | created |
|
|
595
|
+
| `api.apiPath: '/api/2026-01'` | `HTTP Error 404` | `HTTP Error 404`, now naming the mount |
|
|
596
|
+
|
|
597
|
+
The `apiPath` row is **not** closed, and the reason is structural: `apiPath`
|
|
598
|
+
moves the base that `/discovery` is itself mounted under, so the document that
|
|
599
|
+
would name the new mount sits behind the prefix that is missing. The one
|
|
600
|
+
discovery document at a fixed path does not rescue it — `/.well-known/objectstack`
|
|
601
|
+
advertises the **dispatcher's** `${prefix}/data`, measured as `/api/v1/data`
|
|
602
|
+
under all three configs above — so it is deliberately not probed: trusting it
|
|
603
|
+
would attach a false provenance ("discovery told us") to the same 404.
|
|
604
|
+
|
|
605
|
+
Instead that case degrades loudly. Falling back to the convention prints a
|
|
606
|
+
warning naming the mount it will address, the probe that failed and the remedy,
|
|
607
|
+
and every 404/405 from a record action now carries the mount it addressed and
|
|
608
|
+
where that mount came from. `api_call` is unchanged, issues no probe, and remains
|
|
609
|
+
the escape hatch for a host the probe cannot reach.
|
|
610
|
+
- 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690)
|
|
611
|
+
|
|
612
|
+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
|
|
613
|
+
renamed, retired or tombstoned — no spec schema is touched at all. The change
|
|
614
|
+
is a new runtime refusal at the engine's filter collection point, plus the
|
|
615
|
+
routing decline that stops the raw-SQL analytics path bypassing it. -->
|
|
616
|
+
|
|
617
|
+
A `datetime` / `date` / `time` field filtered with a bare string the platform
|
|
618
|
+
cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written**
|
|
619
|
+
all the way to the driver, where the comparison is false for every row. The
|
|
620
|
+
caller received `HTTP 200`, an empty result set, and nothing to indicate the
|
|
621
|
+
filter was meaningless. An unknown `{placeholder}` in the same position was
|
|
622
|
+
already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable
|
|
623
|
+
tokens), so one API answered two shapes of unusable comparand two different
|
|
624
|
+
ways.
|
|
625
|
+
|
|
626
|
+
It is concretely reachable rather than theoretical: `last_7_days` /
|
|
627
|
+
`last_30_days` / `last_90_days` are **declared preset names** in the dashboard
|
|
628
|
+
schema. The shipped console lowers them to `{N_days_ago}` macros before they
|
|
629
|
+
reach the API, so the console path was always safe — but a saved report, an
|
|
630
|
+
integration, an MCP client or an AI-authored query sends the preset name itself
|
|
631
|
+
and got a silent zero. An empty chart is the hardest failure to debug: it is
|
|
632
|
+
indistinguishable from "there is genuinely no data".
|
|
633
|
+
|
|
634
|
+
Such a comparand is now refused at the ObjectQL engine's single filter
|
|
635
|
+
collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the
|
|
636
|
+
field, the value, the key path and the spellings that would work. That seam is
|
|
637
|
+
the one place holding the caller's comparand and the field's **declared type**
|
|
638
|
+
at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate`
|
|
639
|
+
/ `update` / `delete`) and both filter spellings (the array sugar and the
|
|
640
|
+
lowered condition) pass through it, so all four backends inherit one answer
|
|
641
|
+
rather than four. `NativeSQLStrategy` additionally **declines** such a query so
|
|
642
|
+
the raw-SQL analytics path falls through to that door instead of binding the
|
|
643
|
+
value into its own statement.
|
|
644
|
+
|
|
645
|
+
Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing
|
|
646
|
+
refusal one layer down (the door runs before token resolution and steps around
|
|
647
|
+
them, so `{30_days_ago}` still resolves normally); non-string comparands are
|
|
648
|
+
untouched (a number is epoch milliseconds, a `Date` is an instant); and the
|
|
649
|
+
**empty string** keeps today's behaviour exactly — it binds as `''` and matches
|
|
650
|
+
every non-null row, which is a separate question that remains its own card.
|
|
651
|
+
- Updated dependencies [56656aa]
|
|
652
|
+
- Updated dependencies [07e630e]
|
|
653
|
+
- Updated dependencies [2f65b1b]
|
|
654
|
+
- Updated dependencies [720ee95]
|
|
655
|
+
- Updated dependencies [f287435]
|
|
656
|
+
- Updated dependencies [9aa8890]
|
|
657
|
+
- Updated dependencies [7c9c1dd]
|
|
658
|
+
- Updated dependencies [75b7c24]
|
|
659
|
+
- Updated dependencies [d5552ca]
|
|
660
|
+
- Updated dependencies [d9813a9]
|
|
661
|
+
- Updated dependencies [8640fb2]
|
|
662
|
+
- Updated dependencies [2420641]
|
|
663
|
+
- Updated dependencies [2ad91c3]
|
|
664
|
+
- Updated dependencies [f57fb38]
|
|
665
|
+
- Updated dependencies [00777a0]
|
|
666
|
+
- Updated dependencies [d491625]
|
|
667
|
+
- Updated dependencies [420804d]
|
|
668
|
+
- Updated dependencies [716ac9b]
|
|
669
|
+
- Updated dependencies [62b1427]
|
|
670
|
+
- Updated dependencies [7ea1372]
|
|
671
|
+
- Updated dependencies [23abe27]
|
|
672
|
+
- Updated dependencies [985a9cd]
|
|
673
|
+
- Updated dependencies [a8189ae]
|
|
674
|
+
- Updated dependencies [26e70fb]
|
|
675
|
+
- Updated dependencies [42b05af]
|
|
676
|
+
- Updated dependencies [2b292ce]
|
|
677
|
+
- Updated dependencies [abcf853]
|
|
678
|
+
- Updated dependencies [8b9eba5]
|
|
679
|
+
- Updated dependencies [d575779]
|
|
680
|
+
- Updated dependencies [94f7ef8]
|
|
681
|
+
- Updated dependencies [c5ac5e4]
|
|
682
|
+
- Updated dependencies [a777944]
|
|
683
|
+
- Updated dependencies [dd88e1c]
|
|
684
|
+
- Updated dependencies [856527c]
|
|
685
|
+
- Updated dependencies [870f710]
|
|
686
|
+
- Updated dependencies [79c46da]
|
|
687
|
+
- Updated dependencies [7ff3975]
|
|
688
|
+
- Updated dependencies [29d055b]
|
|
689
|
+
- Updated dependencies [65589d6]
|
|
690
|
+
- Updated dependencies [2c86fe3]
|
|
691
|
+
- Updated dependencies [e196c6a]
|
|
692
|
+
- Updated dependencies [4ab7523]
|
|
693
|
+
- Updated dependencies [19539b4]
|
|
694
|
+
- Updated dependencies [11b779e]
|
|
695
|
+
- Updated dependencies [739fe5b]
|
|
696
|
+
- Updated dependencies [4bfe1a5]
|
|
697
|
+
- Updated dependencies [2065e31]
|
|
698
|
+
- Updated dependencies [b69d0f5]
|
|
699
|
+
- Updated dependencies [4d47afe]
|
|
700
|
+
- Updated dependencies [e4e5c6e]
|
|
701
|
+
- Updated dependencies [9a56784]
|
|
702
|
+
- Updated dependencies [d00d2f6]
|
|
703
|
+
- Updated dependencies [df0c12d]
|
|
704
|
+
- Updated dependencies [d31785f]
|
|
705
|
+
- Updated dependencies [c308a4f]
|
|
706
|
+
- Updated dependencies [e2899f6]
|
|
707
|
+
- Updated dependencies [3851f87]
|
|
708
|
+
- Updated dependencies [2a29caa]
|
|
709
|
+
- Updated dependencies [09a6eee]
|
|
710
|
+
- Updated dependencies [1a7f907]
|
|
711
|
+
- Updated dependencies [cd455c8]
|
|
712
|
+
- Updated dependencies [30d3752]
|
|
713
|
+
- Updated dependencies [c80e7ae]
|
|
714
|
+
- Updated dependencies [09a9a8a]
|
|
715
|
+
- Updated dependencies [07026cf]
|
|
716
|
+
- Updated dependencies [5d4f3d5]
|
|
717
|
+
- Updated dependencies [4d80e8b]
|
|
718
|
+
- Updated dependencies [30b1c63]
|
|
719
|
+
- Updated dependencies [079b457]
|
|
720
|
+
- Updated dependencies [e43b211]
|
|
721
|
+
- Updated dependencies [890b38f]
|
|
722
|
+
- Updated dependencies [8bee54b]
|
|
723
|
+
- Updated dependencies [7a537ce]
|
|
724
|
+
- Updated dependencies [593c4bf]
|
|
725
|
+
- Updated dependencies [ff08691]
|
|
726
|
+
- Updated dependencies [60e0f90]
|
|
727
|
+
- Updated dependencies [90c5285]
|
|
728
|
+
- Updated dependencies [7901b2d]
|
|
729
|
+
- Updated dependencies [56bca91]
|
|
730
|
+
- Updated dependencies [79394d7]
|
|
731
|
+
- Updated dependencies [730fd9a]
|
|
732
|
+
- Updated dependencies [44bc51d]
|
|
733
|
+
- Updated dependencies [73cfddf]
|
|
734
|
+
- Updated dependencies [d634e66]
|
|
735
|
+
- @objectstack/spec@17.1.0
|
|
736
|
+
|
|
3
737
|
## 17.0.0
|
|
4
738
|
|
|
5
739
|
### Major Changes
|