Skip to content

Commit f494350

Browse files
committed
feat(platform): sandbox tx boundary, gantt tree schema, formula date math, RLS §7.3.1
Four platform enhancements surfaced by a real eHR/MES dogfood: - runtime/objectql/spec: ctx.api.transaction() — transaction boundary for sandboxed hook/action bodies (explicit handle threading across the deferred-promise pump; rollback on throw or timeout). New api.transaction capability. - spec: extend GanttConfigSchema with the 11 fields the renderer already supports (parentField tree, baselines, resourceView, quickFilters, …) so the metadata pipeline stops stripping them at the spec boundary. - formula: addDays/addMonths CEL stdlib functions (UTC, dyn-typed, addMonths clamps to month end). - spec/plugin-security: converge the RLS contract to the four expression forms the compiler implements (drop the over-promised subquery/LIKE/regex/ ANY/ALL surface) and wire §7.3.1 dynamic membership via a pre-resolved ExecutionContext.rlsMembership bag; recognize "1 = 1" as always-true so RLS.allowAllPolicy grants instead of failing closed.
1 parent 39603a8 commit f494350

18 files changed

Lines changed: 870 additions & 83 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/formula": minor
3+
---
4+
5+
Add `addDays(date, n)` and `addMonths(date, n)` to the CEL standard library — shift an arbitrary date by a (possibly negative) number of days or months. Unlike `daysFromNow`, these operate on a *given* date (the "next service date = last service + cycle" shape). `addMonths` clamps to the target month's last day (`addMonths(date('2026-01-31'), 1)` → Feb 28, never overflowing into March). Both coerce their inputs (Date | ISO string | epoch) and type `n` as `dyn` so a record number field arriving as a `double` doesn't fault `no such overload` (#1928).
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
Extend `GanttConfigSchema` (ListView `gantt` config) to expose the full set of fields the Gantt renderer already supports, so the metadata pipeline preserves them end-to-end instead of stripping them at the spec boundary.
6+
7+
New optional fields: `colorField`, `parentField` (multi-level row tree — 项目 → 产品 → 排产计划 → 派工单), `typeField` (task / summary-folder / milestone row shape), `tooltipFields`, `baselineStartField` / `baselineEndField` (planned-vs-actual), `groupByField`, `resourceView` + `assigneeField` / `effortField` / `capacity` (resource-workload histogram), `quickFilters`, and `autoZoomToFilter`. The original five fields are unchanged; all additions are optional so existing Gantt views validate as before.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
Converge the RLS contract with the reference compiler, and wire §7.3.1 dynamic membership.
7+
8+
- **spec (docs)**: narrow `rls.zod.ts` to the four expression forms the compiler actually implements — `field = current_user.<prop>`, `field = 'literal'`, `field IN (current_user.<array>)`, and `1 = 1`. Removed the over-promised surface (subqueries, `AND`/`OR`/`NOT`, `LIKE`/`ILIKE`, regex, `ANY`/`ALL`, `NOT IN`, `IS NULL`, `NOW()`/`CURRENT_DATE`) from the operator list, context-variable list, and `@example` policies, and documented the fail-closed behaviour explicitly.
9+
- **spec (schema)**: `ExecutionContext` gains `rlsMembership?: Record<string, string[]>` — a bag of pre-resolved dynamic-membership id arrays (team members, territory accounts, shared records) that the runtime stages so RLS can scope via `field IN (current_user.<key>)` without subquery support. Generalizes the previously hard-coded `org_user_ids`.
10+
- **plugin-security**: `RLSCompiler.compileFilter` merges `rlsMembership` keys into the user context (arrays only, never clobbering the named `id`/`organization_id`/`roles`/`org_user_ids` fields), so §7.3.1 hierarchy- and sharing-based policies compile. `compileExpression` now recognizes `1 = 1` as always-true (empty filter), making `RLS.allowAllPolicy` grant access instead of silently failing closed. Missing/empty membership sets still fail closed.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
Add a transaction boundary to sandboxed hook/action bodies: `ctx.api.transaction(async () => { … })`. Every `ctx.api` read/write inside the callback runs in one driver transaction — committed when the callback returns, rolled back if it throws (or if the body leaves the transaction open at timeout). Guarded by the new `api.transaction` capability.
8+
9+
- **spec**: new `api.transaction` capability token on `HookBodyCapability`.
10+
- **objectql**: `ScopedContext` gains discrete `beginTransaction()` / `commitTransaction(handle)` / `rollbackTransaction(handle)` primitives. The handle is threaded **explicitly** through a child context (`resolveTx` honors it ahead of the ambient `txStore`), because the sandbox drives the body across many host event-loop turns where AsyncLocalStorage context does not survive. Degrades to non-transactional execution when the driver has no transaction support.
11+
- **runtime**: the QuickJS runner wires `ctx.api.transaction` over three deferred-promise host leaves (begin/commit/rollback), routes in-transaction ops through the tx-scoped context, and rolls back a transaction the body left open before disposing the VM.

packages/formula/src/cel-engine.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,30 @@ describe('celEngine', () => {
4949
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T00:00:00.000Z');
5050
});
5151

52+
it('addDays(date, n) shifts an arbitrary date — the "next service date" shape', () => {
53+
// 下次维保日期 = 上次维保 + 周期天数. Operates on record.date, not now().
54+
const r = celEngine.evaluate(cel('addDays(record.last_service, record.cycle_days)'), {
55+
record: { last_service: '2026-06-18T00:00:00Z', cycle_days: 90 },
56+
});
57+
expect(r.ok).toBe(true);
58+
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-09-16T00:00:00.000Z');
59+
});
60+
61+
it('addDays accepts a negative offset', () => {
62+
const r = celEngine.evaluate(cel("addDays(date('2026-03-01'), -1)"), {});
63+
expect(r.ok && (r.value as Date).toISOString()).toBe('2026-02-28T00:00:00.000Z');
64+
});
65+
66+
it('addMonths clamps to the target month\'s last day (Jan 31 + 1mo → Feb 28)', () => {
67+
const r = celEngine.evaluate(cel("addMonths(date('2026-01-31'), 1)"), {});
68+
expect(r.ok && (r.value as Date).toISOString()).toBe('2026-02-28T00:00:00.000Z');
69+
});
70+
71+
it('addMonths handles year roll-over and the common 6-month cycle', () => {
72+
const r = celEngine.evaluate(cel("addMonths(date('2026-09-30'), 6)"), {});
73+
expect(r.ok && (r.value as Date).toISOString()).toBe('2027-03-30T00:00:00.000Z');
74+
});
75+
5276
it('today()/daysFromNow()/daysAgo() resolve the calendar day in the reference timezone', () => {
5377
// 2026-01-15T02:00Z is still Jan 14 in America/New_York (UTC-5).
5478
const pinned = new Date('2026-01-15T02:00:00Z');
@@ -368,6 +392,7 @@ describe('celEngine', () => {
368392
const call: Record<string, string> = {
369393
now: 'now()', today: 'today()', daysFromNow: 'daysFromNow(30)', daysAgo: 'daysAgo(7)',
370394
daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")',
395+
addDays: 'addDays(today(), 7)', addMonths: 'addMonths(today(), 3)',
371396
datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)',
372397
min: 'min(1, 2)', max: 'max(1, 2)', upper: 'upper("hi")', lower: 'lower("HI")',
373398
trim: 'trim(" x ")', contains: 'contains("hello", "ell")', startsWith: 'startsWith("hi", "h")',

packages/formula/src/stdlib.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ function addDaysUtc(d: Date, n: number): Date {
7373
return out;
7474
}
7575

76+
/**
77+
* Add `n` calendar months to a Date in UTC; returns a new Date. Clamps the day
78+
* to the target month's last day so `addMonths(date('2026-01-31'), 1)` yields
79+
* Feb 28, never an overflow into March — matching how authors expect a
80+
* "next service date = last + N months" rule to behave.
81+
*/
82+
function addMonthsUtc(d: Date, n: number): Date {
83+
const out = new Date(d.getTime());
84+
const day = out.getUTCDate();
85+
out.setUTCDate(1); // avoid roll-over while shifting the month
86+
out.setUTCMonth(out.getUTCMonth() + n);
87+
const lastDay = new Date(Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)).getUTCDate();
88+
out.setUTCDate(Math.min(day, lastDay));
89+
return out;
90+
}
91+
7692
/**
7793
* Register the ObjectStack standard library into a CEL environment.
7894
*
@@ -161,6 +177,22 @@ export function registerStdLib(
161177
(a: unknown, b: unknown) =>
162178
BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY)),
163179
)
180+
// Shift an arbitrary date by a (possibly negative) number of days/months.
181+
// Unlike `daysFromNow`, these operate on a *given* date — the shape behind
182+
// "next service date = last service + cycle". Args are coerced (Date | ISO
183+
// string | epoch) so a `Field.date` arriving as a string works directly.
184+
// `addMonths` clamps to the target month's last day (Jan 31 +1mo → Feb 28).
185+
// `n` is typed `dyn` (not `int`): a record number field arrives as cel-js
186+
// `double`, so an `int` overload would fault `no such overload` (#1928).
187+
// We coerce defensively with `Number(...)`.
188+
.registerFunction(
189+
'addDays(dyn, dyn): google.protobuf.Timestamp',
190+
(d: unknown, n: unknown) => addDaysUtc(toDate(d), Math.trunc(Number(n))),
191+
)
192+
.registerFunction(
193+
'addMonths(dyn, dyn): google.protobuf.Timestamp',
194+
(d: unknown, n: unknown) => addMonthsUtc(toDate(d), Math.trunc(Number(n))),
195+
)
164196
// Parse an ISO date / date-time string to a Timestamp. `date` and `datetime`
165197
// are aliases — both accept either form (the field's own type decides the
166198
// intent); kept distinct because authors reach for whichever reads clearer.

packages/formula/src/validate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
264264
*/
265265
export const CEL_STDLIB_FUNCTIONS: string[] = [
266266
// Dates (registered stdlib)
267-
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'date', 'datetime',
267+
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'addDays', 'addMonths', 'date', 'datetime',
268268
// Numbers (registered stdlib)
269269
'abs', 'round', 'min', 'max',
270270
// Strings (registered stdlib)

packages/objectql/src/engine.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3081,6 +3081,64 @@ export class ScopedContext {
30813081
}
30823082
}
30833083

3084+
/**
3085+
* Resolve the default driver, if it exposes transaction primitives.
3086+
* Shared by {@link transaction} and the discrete begin/commit/rollback trio.
3087+
*/
3088+
private txDriver(): any | undefined {
3089+
const engine = this.engine as any;
3090+
const driver = engine.defaultDriver
3091+
? engine.drivers?.get(engine.defaultDriver)
3092+
: undefined;
3093+
return driver?.beginTransaction ? driver : undefined;
3094+
}
3095+
3096+
/**
3097+
* Discrete transaction primitives — `begin` / `commit` / `rollback` as three
3098+
* separate calls, in contrast to {@link transaction}'s single-callback form.
3099+
*
3100+
* This trio exists for callers that cannot keep a JS closure on the stack for
3101+
* the lifetime of the transaction — chiefly the sandbox runner, where the
3102+
* hook/action body's `ctx.api.transaction(fn)` is driven across many host
3103+
* event-loop turns via deferred promises. Across those `setImmediate`
3104+
* boundaries the engine's ambient `txStore` (AsyncLocalStorage) does NOT
3105+
* survive, so the transaction handle is threaded **explicitly**: `begin`
3106+
* returns a child ScopedContext carrying `transaction: trx` in its execution
3107+
* context, and `resolveTx` honors that explicit handle ahead of the ambient
3108+
* store. Every `object(...)` op on the returned context therefore reuses the
3109+
* one connection without relying on ALS.
3110+
*
3111+
* Returns `null` when the driver has no transaction support — the caller then
3112+
* runs non-transactionally against `this` (same graceful degrade as
3113+
* {@link transaction}).
3114+
*/
3115+
async beginTransaction(): Promise<{ ctx: ScopedContext; handle: unknown } | null> {
3116+
const driver = this.txDriver();
3117+
if (!driver) return null;
3118+
const trx = await driver.beginTransaction();
3119+
const ctx = new ScopedContext(
3120+
{ ...this.executionContext, transaction: trx },
3121+
this.engine
3122+
);
3123+
return { ctx, handle: trx };
3124+
}
3125+
3126+
/** Commit a handle obtained from {@link beginTransaction}. */
3127+
async commitTransaction(handle: unknown): Promise<void> {
3128+
const driver = this.txDriver();
3129+
if (!driver) return;
3130+
if (driver.commit) await driver.commit(handle);
3131+
else if (driver.commitTransaction) await driver.commitTransaction(handle);
3132+
}
3133+
3134+
/** Roll back a handle obtained from {@link beginTransaction}. */
3135+
async rollbackTransaction(handle: unknown): Promise<void> {
3136+
const driver = this.txDriver();
3137+
if (!driver) return;
3138+
if (driver.rollback) await driver.rollback(handle);
3139+
else if (driver.rollbackTransaction) await driver.rollbackTransaction(handle);
3140+
}
3141+
30843142
get userId() { return this.executionContext.userId; }
30853143
get tenantId() { return this.executionContext.tenantId; }
30863144
/** Alias for tenantId — matches ObjectQLContext.spaceId convention */

packages/plugins/plugin-security/src/rls-compiler.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ interface RLSUserContext {
2323
* current user (incl. self). Pre-resolved by the runtime so RLS can
2424
* scope identity tables like `sys_user` via
2525
* `id IN (current_user.org_user_ids)` without needing subquery
26-
* support in the compiler.
26+
* support in the compiler. This is the one well-known membership set;
27+
* arbitrary §7.3.1 sets arrive via `ExecutionContext.rlsMembership`
28+
* and are merged in under their own keys (see {@link RLSCompiler.compileFilter}).
2729
*/
2830
org_user_ids?: string[];
2931
[key: string]: unknown;
@@ -81,6 +83,21 @@ export class RLSCompiler {
8183
org_user_ids: (executionContext as any)?.org_user_ids,
8284
};
8385

86+
// §7.3.1 dynamic membership: the runtime pre-resolves arbitrary
87+
// set-membership (team members, territory accounts, shared records)
88+
// into `ExecutionContext.rlsMembership`. Merge each set under its key
89+
// so `field IN (current_user.<key>)` resolves without subquery support.
90+
// Arrays only; a missing/empty set still fails closed downstream.
91+
// We never let a membership key clobber the named fields above.
92+
const membership = (executionContext as any)?.rlsMembership;
93+
if (membership && typeof membership === 'object') {
94+
for (const [key, value] of Object.entries(membership)) {
95+
if (Array.isArray(value) && userCtx[key] === undefined) {
96+
userCtx[key] = value;
97+
}
98+
}
99+
}
100+
84101
const filters: Record<string, unknown>[] = [];
85102

86103
for (const policy of policies) {
@@ -107,18 +124,33 @@ export class RLSCompiler {
107124

108125
/**
109126
* Compile a single RLS expression into a query filter.
110-
*
111-
* Supports simple expressions like:
112-
* - "field_name = current_user.property"
113-
* - "field_name IN (current_user.array_property)"
114-
* - "field_name = 'literal_value'"
127+
*
128+
* This reference compiler recognizes exactly four forms — anything else
129+
* returns `null` and (via {@link compileFilter}) fails closed:
130+
* - `field = current_user.property` → `{ field: <value> }`
131+
* - `field = 'literal_value'` → `{ field: 'literal_value' }`
132+
* - `field IN (current_user.array)` → `{ field: { $in: [...] } }`
133+
* (the array may be a §7.3.1 pre-resolved membership set)
134+
* - `1 = 1` → `{}` (always-true / no restriction)
135+
*
136+
* There is intentionally no support for subqueries, `LIKE`/`ILIKE`,
137+
* regex, `ANY`/`ALL`, `AND`/`OR`/`NOT`, or `NULL` checks — express those
138+
* needs as a `current_user.*` property the runtime pre-resolves instead.
115139
*/
116140
compileExpression(
117141
expression: string,
118142
userCtx: RLSUserContext
119143
): Record<string, unknown> | null {
120144
if (!expression) return null;
121145

146+
// Always-true literal: "1 = 1" → no restriction (match every row).
147+
// Lets RLS.allowAllPolicy ('1 = 1' for privileged roles) grant access
148+
// instead of silently failing closed. An empty filter AND's onto the
149+
// caller's where clause as a no-op.
150+
if (/^\s*1\s*=\s*1\s*$/.test(expression)) {
151+
return {};
152+
}
153+
122154
// Handle simple equality: "field = current_user.property"
123155
const eqMatch = expression.match(/^\s*(\w+)\s*=\s*current_user\.(\w+)\s*$/);
124156
if (eqMatch) {

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PermissionEvaluator } from './permission-evaluator.js';
66
import { FieldMasker } from './field-masker.js';
77
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
88
import type { PermissionSet } from '@objectstack/spec/security';
9+
import { RLS } from '@objectstack/spec/security';
910

1011
// ---------------------------------------------------------------------------
1112
// SecurityPlugin – basic metadata
@@ -1096,4 +1097,76 @@ describe('RLSCompiler', () => {
10961097
const contactFind = compiler.getApplicablePolicies('contact', 'find', policies);
10971098
expect(contactFind).toHaveLength(2); // contact all + * all
10981099
});
1100+
1101+
// §7.3.1 dynamic membership — arbitrary pre-resolved sets in rlsMembership
1102+
it('should resolve IN against a §7.3.1 pre-resolved rlsMembership set', () => {
1103+
// Manager hierarchy: the runtime resolved the manager's reports into
1104+
// ctx.rlsMembership.team_member_ids. The compiler merges that bag into
1105+
// the user context so `IN (current_user.team_member_ids)` resolves
1106+
// without any subquery support.
1107+
const compiler = new RLSCompiler();
1108+
const policy: any = {
1109+
object: 'task',
1110+
operation: 'select',
1111+
using: 'assigned_to_id IN (current_user.team_member_ids)',
1112+
};
1113+
const ctx: any = {
1114+
userId: 'mgr-1',
1115+
tenantId: 'org-1',
1116+
roles: ['manager'],
1117+
rlsMembership: { team_member_ids: ['u2', 'u3', 'u4'] },
1118+
};
1119+
const filter = compiler.compileFilter([policy], ctx);
1120+
expect(filter).toEqual({ assigned_to_id: { $in: ['u2', 'u3', 'u4'] } });
1121+
});
1122+
1123+
it('should fail-closed when a §7.3.1 membership set is empty', () => {
1124+
// A manager with no reports → empty team_member_ids → the IN policy
1125+
// drops out → sole policy → deny sentinel (zero rows). Never fail-open.
1126+
const compiler = new RLSCompiler();
1127+
const policy: any = {
1128+
object: 'task',
1129+
operation: 'select',
1130+
using: 'assigned_to_id IN (current_user.team_member_ids)',
1131+
};
1132+
const ctx: any = { userId: 'mgr-1', tenantId: 'org-1', roles: [], rlsMembership: { team_member_ids: [] } };
1133+
const filter = compiler.compileFilter([policy], ctx);
1134+
expect(filter).toEqual(RLS_DENY_FILTER);
1135+
});
1136+
1137+
it('should not let a rlsMembership key clobber a named context field', () => {
1138+
// roles is a first-class field; a hostile/misconfigured membership bag
1139+
// must not override it. The named `roles` (['real-role']) wins; the
1140+
// policy compiles against it, not the injected ['spoofed'].
1141+
const compiler = new RLSCompiler();
1142+
const policy: any = { object: 'x', operation: 'select', using: 'role_id IN (current_user.roles)' };
1143+
const ctx: any = {
1144+
userId: 'u1',
1145+
tenantId: 'org-1',
1146+
roles: ['real-role'],
1147+
rlsMembership: { roles: ['spoofed'] },
1148+
};
1149+
const filter = compiler.compileFilter([policy], ctx);
1150+
expect(filter).toEqual({ role_id: { $in: ['real-role'] } });
1151+
});
1152+
1153+
// Always-true literal — makes RLS.allowAllPolicy grant access instead of
1154+
// silently failing closed.
1155+
it('should compile "1 = 1" to an unrestricted (empty) filter', () => {
1156+
const compiler = new RLSCompiler();
1157+
const policy: any = { object: 'account', operation: 'all', using: '1 = 1', roles: ['ceo'] };
1158+
const ctx: any = { userId: 'u1', tenantId: 'org-1', roles: ['ceo'] };
1159+
const filter = compiler.compileFilter([policy], ctx);
1160+
expect(filter).toEqual({}); // matches every row
1161+
});
1162+
1163+
it('should grant allow-all via RLS.allowAllPolicy instead of denying', () => {
1164+
// Regression guard: '1 = 1' used to be unsupported → deny sentinel,
1165+
// making the helper do the opposite of its name. Now it grants.
1166+
const compiler = new RLSCompiler();
1167+
const policy = RLS.allowAllPolicy('account', ['ceo', 'cfo']) as any;
1168+
const filter = compiler.compileFilter([policy]);
1169+
expect(filter).toEqual({});
1170+
expect(filter).not.toEqual(RLS_DENY_FILTER);
1171+
});
10991172
});

0 commit comments

Comments
 (0)