Skip to content

Commit 5da80a8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-xn40qf
2 parents 120ed5b + 1bbb7bf commit 5da80a8

55 files changed

Lines changed: 1225 additions & 442 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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-email': patch
4+
---
5+
6+
fix(plugin-auth,plugin-email)!: the invitation mail says when its role label stayed English, and states when the invitation expires
7+
8+
**The role label.** The organization invitation mail names the invitee's role by its `sys_member.role` option label in the mail's locale (`成员`, `メンバー`, `Miembro`). When the i18n service answered without a translation for that locale, the mail named the English label (`Member`) inside a Chinese, Japanese or Spanish sentence, and logged nothing. Only a read that threw was reported. That happens when there is no i18n service, when the service holds no bundle for the locale, or when its reported locales (`i18n.supportedLocales`, when declared) do not name the locale as the bundles spell it. Now one `warn` is logged for the send: `[AuthManager] invitation mail: no 'zh-CN' label was found for the membership role(s) 'member'; …`. It names the locale, the roles and what to check. The mail is still sent as before. A mail in English, or a read that already warned because it threw, logs nothing extra.
9+
10+
**The expiry.** The `auth.invitation` mail never said when its link stops working (better-auth's window, 48 hours by default), so an invitation opened late failed at the accept page with no warning. The sender now passes `invitation.expiresAt` as a new template variable, `expiresAt`, as an ISO-8601 instant. Each of the four built-in bodies, HTML and text, ends with one sentence that renders it with the `datetime:long` formatter. The date reads in the mail's locale and the send's reference timezone, with the zone named: 「此邀请将于 2031年3月4日 UTC 05:06:07 过期。」 / "This invitation expires on March 4, 2031 at 5:06:07 AM UTC.". The template engine has no conditionals, so every body states the expiry, and the built-in rows declare `expiresAt` **required**. A send without it is refused (`MISSING_VARIABLES: expiresAt`) and is never sent with a dangling sentence. The built-in sender always has better-auth's expiry. If the value does not read as a date, the sender leaves it out and logs one `warn` naming the invitation id and saying the mail will be refused, so the invitation can be resent. A tenant or app override of `auth.invitation` keeps its own bodies and variables, and can use `{{ expiresAt | datetime:long }}`.
11+
12+
Clause-②: no (narrowing: the built-in `auth.invitation` rows declare a new required variable, `expiresAt`, so a `sendTemplate` of `auth.invitation` that names no `expiresAt` is now refused with `MISSING_VARIABLES` where it used to be sent)
13+
14+
Metadata-protocol: none (a built-in template row's declared variables, read by the email service at send time; no spec key, authorable spelling or stored shape moves)
15+
16+
**BREAKING** for code that sends `auth.invitation` itself (the built-in organization-invitation sender already passes the value).
17+
18+
- **Before.** `auth.invitation` declared no expiry, and a send naming `inviter`, `organization`, `role`, `acceptUrl` and `appName` was delivered.
19+
- **Now.** The same send is refused with `MISSING_VARIABLES: expiresAt`.
20+
- **Fix (one line).** Add the invitation's expiry instant to the send's `data` as `expiresAt`.
21+
22+
<!-- adr-0087: not-required (no-migration-prescription) a send-time refusal of an `auth.invitation` mail that names no `expiresAt`: no authorable key, spelling or stored shape moves, the email-template schema is unchanged, and no stored row is read, rewritten or converted. The repair is a value only the sender holds (the invitation's expiry), which no ledger entry can derive. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a template row's declaration, not a TS interface (not runtime-interface-only). -->
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/types': patch
3+
---
4+
5+
fix(types): the `@object-ui/types/zod` mirrors keep an unknown key instead of silently dropping it
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
- **What changed.** 145 objects in the zod mirrors were strip-mode `z.object()`: a misspelled key parsed green and vanished, so `FormFieldSchema.safeParse({ name, type, labell: 'oops' })` succeeded with `labell` gone while `@objectstack/spec` refused it by name. Every one is now `.passthrough()` (`z.looseObject`), like `BaseSchema` already was. The tolerant face keeps the key; the strict authoring face (`StrictAnyComponentSchema`, used by `objectui validate`) still refuses it by name, so the two faces disagree only on keep-or-refuse, never on silently dropping.
11+
- **What a caller sees.** Nothing new is refused: every document that parsed before parses now. A parsed result now carries the unknown keys its input carried, and the inferred `z.input` / `z.infer` types of those objects gain zod's `[k: string]: unknown` index signature. A declared `interface` is not assignable to a type with that signature; if you assign an interface-typed value to a variable typed by one of these mirrors' `z.input`, type it through the interface or pass it to `parse()`.
12+
- **Guard.** `zod-mirror-parity.test.ts` now fails on any strip-mode object in `ui/packages/types/src/zod/`.
13+
14+
What to do: nothing, unless you relied on a mirror dropping keys from its parse result; strip them yourself or read only the keys you need.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 6] The predicate-write hook dispatch bodies of `ObjectQL`, moved
5+
* out of `engine.ts` whole: the per-row `after*` contexts a `multi: true` write
6+
* dispatches (`buildPerRowAfterContexts`), the per-row `before*` dispatch with
7+
* its D3 accumulate and key-divergence halves (`dispatchPerRowBeforeHooks`),
8+
* the ceiling on that fan-out (`assertBulkPerRowHookBudget`), and the unit an
9+
* aborting `after*` chain runs in (`inAfterAbortUnit`).
10+
*
11+
* A MOVE, not a redesign. Each exported function below is the body of the
12+
* `ObjectQL` method of the same name as it stood, re-indented one level and
13+
* otherwise unedited: the same single-record context shape, the same one
14+
* payload carried by reference through every row, the same refusal before any
15+
* write, and the same commit-then-rethrow for `SummaryRecomputeError`. Each
16+
* method's docblock stays on the method in `engine.ts`, which is still the
17+
* contract.
18+
*
19+
* ## How the moved bodies reach the class
20+
*
21+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
22+
* header carries the full reasoning), applied per method as
23+
* `engine-secret-internal-fields.ts` applies it. The class keeps all four
24+
* methods — the published `.d.ts` lists each one's name and position — and
25+
* each body is one literal of exactly the {@link HookDispatchHost} members
26+
* that method reads, plus a `.call`. The one field the bodies read
27+
* (`_registry`) goes as a GETTER, so the read happens when the body makes it.
28+
* The two methods they call (`triggerHooks`, `transaction`) go as late-bound
29+
* closures over the instance, so a double or spy set on the engine is still
30+
* the one a call reaches. `assertBulkPerRowHookBudget` reads no member and is
31+
* called directly.
32+
*
33+
* The three other members of the same section (`hasHooksFor`,
34+
* `opensAfterAbortUnit`, `dispatchUnscopedMultiWriteHooks`) stay in the class:
35+
* each calls `hookMatchesObject`, a function `engine.ts` declares at module
36+
* level, and reaching it from here would be a value import of the engine.
37+
*
38+
* This module imports the engine only as a TYPE, so there is no module cycle
39+
* at run time, and nothing re-exports it, so the published surface does not
40+
* move.
41+
*/
42+
43+
import type { HookContext } from '@objectstack/spec/data';
44+
import { resolveBulkPerRowHookBudget } from '@objectstack/spec/data';
45+
import { SummaryRecomputeError } from './summary-errors.js';
46+
import { HookTargetRebindError } from './hook-target-rebind-errors.js';
47+
import { coerceBooleanFields } from './validation/record-validator.js';
48+
import { recordHookPayloadWrites } from './hook-write-provenance.js';
49+
import {
50+
divergingHookPayloadKeys,
51+
MultiUpdateHookKeyDivergenceError,
52+
} from './multi-update-hook-key-divergence.js';
53+
import type { ObjectQL } from './engine.js';
54+
55+
/**
56+
* The `ObjectQL` members the functions below read — every one of them, and no
57+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
58+
* typed AS the class member (an indexed access on the class), so a change to a
59+
* member's type on the class is a change here too, and naming a member the
60+
* class does not have is a compile error. See the module header for which
61+
* members are getters.
62+
*/
63+
export interface HookDispatchHost {
64+
readonly _registry: ObjectQL['_registry'];
65+
triggerHooks: ObjectQL['triggerHooks'];
66+
transaction: ObjectQL['transaction'];
67+
}
68+
69+
/** The body of `ObjectQL#inAfterAbortUnit`; the method's docblock there is its contract. */
70+
export async function inAfterAbortUnit<T>(
71+
this: Pick<HookDispatchHost, 'transaction'>,
72+
write: () => Promise<T>,
73+
): Promise<T> {
74+
let written: SummaryRecomputeError | undefined;
75+
const result = await this.transaction(async () => {
76+
try {
77+
return await write();
78+
} catch (e) {
79+
if (!(e instanceof SummaryRecomputeError)) throw e;
80+
written = e;
81+
return undefined as T;
82+
}
83+
});
84+
if (written) throw written;
85+
return result;
86+
}
87+
88+
/** The body of `ObjectQL#buildPerRowAfterContexts`; the method's docblock there is its contract. */
89+
export function buildPerRowAfterContexts(
90+
this: Pick<HookDispatchHost, '_registry'>,
91+
object: string,
92+
event: 'afterUpdate' | 'afterDelete',
93+
rows: Record<string, unknown>[],
94+
batchCtx: HookContext,
95+
payload?: Record<string, unknown>,
96+
): HookContext[] {
97+
const schema = this._registry.getObject(object);
98+
const options = (batchCtx.input as { options?: unknown } | undefined)?.options;
99+
return rows.map((row, index) => ({
100+
...batchCtx,
101+
event,
102+
input: payload
103+
? { id: (row as { id?: unknown }).id, data: { ...payload }, options }
104+
: { id: (row as { id?: unknown }).id, options },
105+
// [#6966] `mode` and `scope` ride over from the batch context; only the
106+
// position differs. Spreading `batchCtx` would carry index 0 onto every
107+
// row, which is the one member a per-row consumer keys "do this once" on.
108+
dispatch: { ...(batchCtx.dispatch as object), index } as HookContext['dispatch'],
109+
previous: coerceBooleanFields(schema as any, row as any),
110+
result: payload
111+
? coerceBooleanFields(schema as any, { ...row, ...payload } as any)
112+
: undefined,
113+
}) as unknown as HookContext);
114+
}
115+
116+
/** The body of `ObjectQL#dispatchPerRowBeforeHooks`; the method's docblock there is its contract. */
117+
export async function dispatchPerRowBeforeHooks(
118+
this: Pick<HookDispatchHost, '_registry' | 'triggerHooks'>,
119+
object: string,
120+
event: 'beforeUpdate' | 'beforeDelete',
121+
rows: Record<string, unknown>[],
122+
batchCtx: HookContext,
123+
): Promise<void> {
124+
const schema = this._registry.getObject(object);
125+
const carriesPayload = event === 'beforeUpdate';
126+
// ── [#14099] D3's ENFORCEMENT half: one observation window per row ──────
127+
//
128+
// The #14088 recorder, armed a SECOND time for this loop and NESTED over
129+
// the batch-scoped recording `update()` already armed at its entry. The
130+
// nesting is what makes both readings true at once: a write through this
131+
// view lands on the outer recording's view, which lands on the real
132+
// payload — so the outer record (the one the read-only strips read for
133+
// provenance) still sees every hook write, while `closeWindow()` gives
134+
// this loop what only it needs: the keys assigned by ONE row's chain.
135+
//
136+
// ⭐ ONE recording for the whole loop, not one per row, and that is a
137+
// contract point rather than a saving: every per-row context must carry
138+
// THE SAME payload object (D3, pinned by reference identity in
139+
// `bulk-write-per-row-hooks.test.ts`). A fresh view per row would still be
140+
// write-through onto one payload — no copy, nothing to reconcile — but it
141+
// would hand each row a DIFFERENT object, which is a difference an author
142+
// can observe and which this contract says is not there.
143+
const batchPayloadAtEntry = (batchCtx.input as { data?: unknown }).data;
144+
const hookWrites =
145+
carriesPayload && batchPayloadAtEntry !== null && typeof batchPayloadAtEntry === 'object'
146+
? recordHookPayloadWrites(batchPayloadAtEntry as Record<string, unknown>)
147+
: undefined;
148+
if (hookWrites) (batchCtx.input as { data?: unknown }).data = hookWrites.payload;
149+
const perRowHookWrittenKeys: ReadonlySet<string>[] = [];
150+
for (let index = 0; index < rows.length; index++) {
151+
const row = rows[index];
152+
const rowId = (row as { id?: unknown }).id;
153+
const options = (batchCtx.input as { options?: unknown }).options;
154+
const rowCtx = {
155+
...batchCtx,
156+
event,
157+
// [#6966] See `buildPerRowAfterContexts` — same rule. The `scope`
158+
// identity carried over from `batchCtx` is what lets a `before*`
159+
// handler leave something an `after*` handler can still find: a per-row
160+
// context is a fresh object, so a stash written on the context itself
161+
// dies with the row that held it.
162+
dispatch: { ...(batchCtx.dispatch as object), index } as HookContext['dispatch'],
163+
// D3: THE payload, read fresh so a previous row's REPLACEMENT is what
164+
// this row sees. Never a copy.
165+
input: carriesPayload
166+
? { id: rowId, data: (batchCtx.input as { data?: unknown }).data, options }
167+
: { id: rowId, options },
168+
previous: coerceBooleanFields(schema as any, row as any),
169+
// D2: no post-state in the before phase.
170+
result: undefined,
171+
} as unknown as HookContext;
172+
173+
await this.triggerHooks(event, rowCtx);
174+
175+
// D3, the accumulate half — see the class doc above.
176+
if (carriesPayload) {
177+
(batchCtx.input as { data?: unknown }).data = (rowCtx.input as { data?: unknown }).data;
178+
// [#14099] and the enforcement half: close THIS row's window, so the
179+
// next row opens a fresh one.
180+
if (hookWrites) perRowHookWrittenKeys.push(hookWrites.closeWindow());
181+
}
182+
// D4.
183+
const observed = (rowCtx.input as { id?: unknown }).id;
184+
if (observed !== rowId) {
185+
throw new HookTargetRebindError({
186+
object, event, path: 'per-row', expectedId: rowId, observedId: observed,
187+
});
188+
}
189+
}
190+
191+
// [#14099] Close this loop's recording and put the underlying payload back
192+
// in `batchCtx.input.data`, so no recording VIEW travels on to the outer
193+
// seal, the strips or a driver.
194+
//
195+
// `hookWrittenKeys` is `undefined` exactly when a hook REPLACED the payload
196+
// object somewhere in this batch (`hook-write-provenance.ts`'s KNOWN
197+
// LIMIT). Then the whole batch ABSTAINS: the windows collected before the
198+
// replacement describe writes the replacement discarded, so refusing on
199+
// them would be a verdict about a payload that is no longer the one being
200+
// written. Abstaining keeps the pre-#14099 behaviour for that shape, which
201+
// is the same fail-safe direction #14088 chose for the same limit.
202+
const sealedLoopWrites = hookWrites?.seal((batchCtx.input as { data?: unknown }).data);
203+
if (sealedLoopWrites) {
204+
(batchCtx.input as { data?: unknown }).data = sealedLoopWrites.data;
205+
}
206+
207+
// [#14099] The refusal, ruled 2026-09-02 (recommendation C). Placed after
208+
// the loop and not inside it, for two reasons that are both about the
209+
// envelope rather than about cost: the diverging set is `union` minus
210+
// `intersection` over EVERY row, so the message names every offending key
211+
// instead of the first pair to disagree, and it is order-independent — the
212+
// same batch answers the same way whatever order the driver returned the
213+
// matched rows in.
214+
//
215+
// ⭐ Still BEFORE any write, which is the load-bearing half. This method is
216+
// called from `update()`'s predicate branch ahead of the outer hook-write
217+
// seal, both readonly strips, `evaluateValidationRules` and every
218+
// `driver.updateMany` — and it runs outside `update()`'s own `try`, so the
219+
// envelope reaches the caller undecorated. Not "after the first row", not
220+
// "inside a transaction that then rolls back": nothing was written.
221+
if (sealedLoopWrites?.hookWrittenKeys !== undefined) {
222+
const diverging = divergingHookPayloadKeys(perRowHookWrittenKeys);
223+
if (diverging.length > 0) {
224+
throw new MultiUpdateHookKeyDivergenceError(object, diverging, rows.length);
225+
}
226+
}
227+
}
228+
229+
/** The body of `ObjectQL#assertBulkPerRowHookBudget`; the method's docblock there is its contract. */
230+
export function assertBulkPerRowHookBudget(object: string, event: string, matched: number): void {
231+
const verdict = resolveBulkPerRowHookBudget({ object, event, matched });
232+
if (verdict.kind === 'ok') return;
233+
throw Object.assign(new Error(verdict.message), {
234+
code: verdict.code,
235+
object: verdict.object,
236+
event: verdict.event,
237+
matched: verdict.matched,
238+
limit: verdict.limit,
239+
});
240+
}

0 commit comments

Comments
 (0)