Skip to content

Commit d353b39

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24950-sqlite-open-failure-no-stepdown
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
2 parents 05e9a8e + ea08ce5 commit d353b39

18 files changed

Lines changed: 1029 additions & 17 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(lint,spec): `os validate`, `os build` and `os lint` warn on a `user` field whose `reference` names an object other than `sys_user`, and `Field.user()` marks `reference` deprecated
7+
8+
Clause-②: yes (widening)
9+
10+
- **What was wrong.** The spec declares a `user` field's target fixed to `sys_user`, and nothing enforced it. `{ type: 'user', reference: 'probe_account' }` parsed, `os validate` passed it, and `referenceTargetOf` resolved it to `probe_account`. So every reader that follows the target (`$expand`, imports, seed loading, the object graph) treated the field as a link to `probe_account`, while every reader that branches on the type (the person picker, the `current_user` default, a sharing rule over the users a field holds) treated its values as users.
11+
- **What changed.** A new advisory, `user-field-foreign-reference`, fires on such a field on an object or an object extension. It names the field and the target, and its hint is the canonical spelling: `{ type: 'lookup', reference: '<target>' }` (`Field.lookup('<target>', …)`), or drop `reference` if a person was meant. It is a warning, so no command's exit code changes. `Field.user()`'s config now types `reference` as `@deprecated`. The accepted values are unchanged.
12+
- **Why only a warning in 18.x.** A narrowing ships its refusal in an 18.x minor only when an ADR-0087 conversion can rewrite the old shape. This one cannot, because the rewrite is not lossless. A `user` field is outside the referential delete walk, while a `lookup` gets `deleteBehavior: 'set_null'` and joins it. `deleteBehavior` has no value that means "do nothing", so a converted field would start clearing child columns when its target record is deleted, which a `user` field never does. The refusal is planned for the next major.
13+
- **Published.** `USER_FIELD_FOREIGN_REFERENCE` (`user-field-foreign-reference`) is exported from `@objectstack/lint` beside `OBJECT_REFERENCE_UNKNOWN`, and `RULE_EXPLANATIONS` gains its entry, so `os explain user-field-foreign-reference` prints why the field is half a link and half a user, and why the rewrite to a `lookup` is left to the author.
14+
- **Unchanged.** Parsing, the runtime's resolution of such a field (it still follows the authored `reference`), `Field.user()`'s output, and `user` fields written without `reference` or with `reference: 'sys_user'`.
15+
- **What to do.** Change a `user` field that points at another object to a `lookup` on that object. No in-repo app or package wrote this shape. Two test fixtures did: one is now a `lookup`, and the other deliberately pins the 18.x resolution.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): the authorization prelude's grant read names the columns it decides on, so no formula on `sys_user_permission_set` is computed in every request a grant holder makes
6+
7+
Clause-②: no (a read's projection: every resolved authz answer is unchanged and pinned so; no accept set, served shape or export moves)
8+
9+
`resolveAuthzContext` reads the principal's `sys_user_permission_set` grants on every authenticated request. That read named no columns, so the engine computed every formula field the grant table declares for it, the record title included, though the resolver reads none of them. A title that reads through a lookup (the person's name, one hop through `user_id`) would have cost each request by a principal holding a direct grant one more `sys_user` read and one more round trip: 9 reads in 5 round trips instead of 8 in 4. The read now names exactly the four columns the resolver decides on: `permission_set`, `organization_id`, `valid_from` and `valid_until`. The resolved authorization context is unchanged for every grant state (active, expired, not yet valid, organization-owned, deployment-wide, and the Default Organization's `admin_full_access` grant), and a grant holder's prelude stays at 8 reads in 4 round trips whatever display formula the grant table declares.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource): a datasource whose database client is not installed (`pg`, `mysql2`, `better-sqlite3`) is refused with the install of that client, not with "Run `pnpm install && pnpm build`"
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** When a boot-critical datasource could not connect because a module could not be resolved, the refusal always ended on the unbuilt-workspace remedy: "The driver package could not be LOADED at all … That is a build precondition … Run `pnpm install && pnpm build`, then start again." That is right for a monorepo checkout whose driver build output is missing. It is wrong for a project that points at PostgreSQL or MySQL without installing the database client: `pg` and `mysql2` are optional peers of `@objectstack/driver-sql` (and `better-sqlite3` an optional dependency) that the project installs itself, so neither command adds them, and a reader who followed the advice was refused again with the same text.
10+
- **Now.** When the module that could not be resolved is one of those three clients (knex reports it as `Cannot find module 'pg'`), the refusal ends on the install of that client instead: `npm install pg` (with the `pnpm add` and `yarn add` spellings beside it), and no build step. It names no environment variable.
11+
- **Unchanged.** A driver package whose build output is missing, or an `@objectstack/…` driver package that is not installed, still ends on `pnpm install && pnpm build`. A refused connection, a wrong DSN or a rejected password still ends on the same sentence as before. When the boot is refused, the error type, the retained connection state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` degraded boot do not change: only the closing sentence of the refusal is different.
12+
13+
What to do: install the client the refusal names next to the server that opens the datasource, then start again.

‎content/docs/getting-started/your-first-project.mdx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,16 +242,20 @@ framework surface you install:
242242
| Package | What it does |
243243
|:---|:---|
244244
| `@objectstack/spec` | The protocol: `defineStack`, `ObjectSchema`, `Field.*` builders, all Zod schemas. Your metadata imports only this. |
245-
| `@objectstack/runtime` | The kernel that interprets metadata at runtime. Brings the database drivers with it — you do not install one separately. |
245+
| `@objectstack/runtime` | The kernel that interprets metadata at runtime. Brings the database drivers with it — SQLite and MongoDB work as installed; PostgreSQL and MySQL also need their client package (below). |
246246
| `@objectstack/plugin-hono-server` | The HTTP server that mounts the generated REST API. |
247247
| `@objectstack/connector-rest` · `-openapi` · `-mcp` | The three generic connector executors — register the `rest` / `openapi` / `mcp` provider factories so declarative `connectors:` entries materialize ([ADR-0097](/docs/automation/flows)). |
248248
| `@objectstack/cli` *(dev)* | The `os` / `objectstack` CLI: `dev`, `validate`, `build`, `start`. |
249249
| `@objectstack/verify` *(dev)* | The test harness: `bootStack` boots this app's own stack in-process, on an in-memory database and with no server port, and returns the handle a test asserts through. `npm test` runs the starter test `note.test.ts` with it. |
250250

251251
No driver package is listed because none is installed directly: `@objectstack/runtime`
252-
depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory`, and the CLI carries
253-
them too. `objectstack dev` resolves **SQLite** by default; point `OS_DATABASE_URL` at
254-
Postgres / MySQL / MongoDB for production — [no code changes](/docs/data-modeling/drivers).
252+
depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory` and installs
253+
`driver-mongodb` as an optional dependency, and the CLI carries them too.
254+
`objectstack dev` resolves **SQLite** by default, which needs nothing more. For
255+
production, MongoDB needs only `OS_DATABASE_URL` pointed at it. Postgres and MySQL need
256+
one install first: their database client (`npm install pg`, or `npm install mysql2`) is
257+
an optional peer of `driver-sql` that your project adds itself, and without it the boot
258+
is refused. No code changes either way — see [Drivers](/docs/data-modeling/drivers).
255259

256260
## 3. Run it
257261

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The prelude's `sys_user_permission_set` read names the columns the resolver
5+
* decides on — the set, the organization and the validity window — so the
6+
* engine computes no formula field of the grant table for it. A formula a read
7+
* does not name is never computed, and one that reads through a lookup (a
8+
* record title naming the person) would otherwise cost a read and a round trip
9+
* in every request a grant holder makes.
10+
*
11+
* What is pinned: a projection is only right if it drops nothing the resolver
12+
* reads. The resolved envelope is therefore resolved twice per fixture, on the
13+
* recording double the batch-equivalence suite drives —
14+
*
15+
* - over FULL rows, every stored column plus a computed title, which is what
16+
* the read returned when it named no columns; and
17+
* - over PROJECTED rows, the named columns only and no `id`, which is what a
18+
* driver answers a projection with (driver-sql: exactly the named columns);
19+
*
20+
* and the two envelopes must be identical, whole. Each grant state the
21+
* resolver discriminates is a fixture of its own: an active grant, an expired
22+
* one, one not yet valid, one owned by the active organization, one owned by
23+
* another, a deployment-wide one (`organization_id` null) and an
24+
* `admin_full_access` grant owned by the Default Organization, which anchors
25+
* platform standing. Dropping any one of the four columns from the read turns
26+
* at least one fixture red. The batch-equivalence matrix runs through the same
27+
* comparison.
28+
*/
29+
30+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
31+
import { ADMIN_FULL_ACCESS } from '@objectstack/spec';
32+
import { resetPlatformAdminEmailMemo } from './platform-admin.js';
33+
import { resolveUserAuthzGrants } from './resolve-authz-context.js';
34+
import { FIXTURES, T0, makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js';
35+
import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js';
36+
37+
const ORG_A = 'org_a';
38+
const ORG_B = 'org_b';
39+
const DEFAULT_ORG = 'org_default';
40+
const DAY = 86_400_000;
41+
const past = new Date(T0 - DAY).toISOString();
42+
const future = new Date(T0 + DAY).toISOString();
43+
44+
const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL';
45+
const POSTURE_ENV = 'OS_TENANCY_POSTURE';
46+
let ambient: Record<string, string | undefined> = {};
47+
beforeAll(() => {
48+
ambient = { [OWNER_ENV]: process.env[OWNER_ENV], [POSTURE_ENV]: process.env[POSTURE_ENV] };
49+
delete process.env[OWNER_ENV];
50+
delete process.env[POSTURE_ENV];
51+
resetPlatformAdminEmailMemo();
52+
});
53+
afterAll(() => {
54+
for (const [k, v] of Object.entries(ambient)) {
55+
if (v === undefined) delete process.env[k];
56+
else process.env[k] = v;
57+
}
58+
resetPlatformAdminEmailMemo();
59+
});
60+
61+
/** The definitions the catalog holds; each grant fixture names one. */
62+
const definitions = {
63+
permissions: [
64+
{ name: ADMIN_FULL_ACCESS, systemPermissions: ['manage_users'] },
65+
{ name: 'tools', systemPermissions: ['cap_tools'], tabPermissions: { crm: 'visible' } },
66+
{ name: 'reports', systemPermissions: ['cap_reports'] },
67+
],
68+
positions: [] as Array<Record<string, unknown>>,
69+
};
70+
71+
/** A grant row as stored: every column the object declares, and the title a full read computes. */
72+
const grantRow = (id: string, cols: Record<string, unknown>) => ({
73+
id,
74+
user_id: 'u1',
75+
permission_set: null,
76+
organization_id: null,
77+
granted_by: 'u_granter',
78+
valid_from: null,
79+
valid_until: null,
80+
reason: 'pinned',
81+
created_at: past,
82+
...cols,
83+
display_title: `Ursula → ${String(cols.permission_set)}`,
84+
});
85+
86+
interface GrantFixture {
87+
name: string;
88+
grants: Array<Record<string, unknown>>;
89+
tenantId?: string;
90+
}
91+
92+
const GRANT_FIXTURES: GrantFixture[] = [
93+
{ name: 'an active grant owned by the active organization', tenantId: ORG_A,
94+
grants: [grantRow('g_active', { permission_set: 'tools', organization_id: ORG_A })] },
95+
{ name: 'an active grant owned by another organization', tenantId: ORG_A,
96+
grants: [grantRow('g_other', { permission_set: 'tools', organization_id: ORG_B })] },
97+
{ name: 'an expired grant', tenantId: ORG_A,
98+
grants: [grantRow('g_expired', { permission_set: 'tools', organization_id: ORG_A, valid_until: past })] },
99+
{ name: 'a grant not yet valid', tenantId: ORG_A,
100+
grants: [grantRow('g_pending', { permission_set: 'tools', organization_id: ORG_A, valid_from: future })] },
101+
{ name: 'a grant inside its window', tenantId: ORG_A,
102+
grants: [grantRow('g_window', { permission_set: 'tools', organization_id: ORG_A, valid_from: past, valid_until: future })] },
103+
{ name: 'a deployment-wide grant (organization_id null), in an organization', tenantId: ORG_A,
104+
grants: [grantRow('g_global', { permission_set: 'reports', organization_id: null })] },
105+
{ name: 'a deployment-wide grant (organization_id null), with no organization active',
106+
grants: [grantRow('g_global', { permission_set: 'reports', organization_id: null })] },
107+
{ name: 'an admin_full_access grant owned by the Default Organization, acting in another', tenantId: ORG_A,
108+
grants: [grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG })] },
109+
{ name: 'an admin_full_access grant owned by the Default Organization, with none active',
110+
grants: [grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG })] },
111+
{ name: 'an expired admin_full_access grant', tenantId: ORG_A,
112+
grants: [grantRow('g_admin_lapsed', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG, valid_until: past })] },
113+
{ name: 'an admin_full_access grant owned by another organization', tenantId: ORG_B,
114+
grants: [grantRow('g_admin_b', { permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B })] },
115+
{ name: 'every state at once', tenantId: ORG_A,
116+
grants: [
117+
grantRow('g_active', { permission_set: 'tools', organization_id: ORG_A }),
118+
grantRow('g_expired', { permission_set: 'reports', organization_id: ORG_A, valid_until: past }),
119+
grantRow('g_pending', { permission_set: 'reports', organization_id: ORG_A, valid_from: future }),
120+
grantRow('g_global', { permission_set: 'reports', organization_id: null }),
121+
grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }),
122+
] },
123+
];
124+
125+
const tablesFor = (grants: Array<Record<string, unknown>>): Record<string, unknown[]> => ({
126+
sys_user: [{ id: 'u1', email: 'u1@example.com', name: 'Ursula' }],
127+
sys_member: [
128+
{ user_id: 'u1', organization_id: ORG_A, role: 'member' },
129+
{ user_id: 'u1', organization_id: ORG_B, role: 'member' },
130+
],
131+
sys_user_position: [],
132+
sys_user_permission_set: grants,
133+
sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG_A, slug: 'a' }, { id: ORG_B, slug: 'b' }],
134+
sys_metadata_activation: [],
135+
});
136+
137+
/** The `fields` each read carried, by object, in issue order. */
138+
type Projections = Array<{ object: string; fields: unknown }>;
139+
140+
/**
141+
* The recording double, answering a projection the way a driver does — the
142+
* named columns and nothing else, `id` included only when named — or, with
143+
* `projecting: false`, ignoring it and answering every column as a read that
144+
* named none did.
145+
*/
146+
function engineOver(tables: Record<string, unknown[]>, projecting: boolean, catalog?: typeof definitions) {
147+
const ql: any = makeRecordingQl(tables);
148+
if (catalog) bindStaticSecurityCatalog(ql, catalog);
149+
const projections: Projections = [];
150+
const find = ql.find.bind(ql);
151+
ql.find = async (object: string, opts: any) => {
152+
projections.push({ object, fields: opts?.fields });
153+
const rows: Array<Record<string, unknown>> = await find(object, opts);
154+
if (!projecting || !Array.isArray(opts?.fields)) return rows;
155+
return rows.map((row) => Object.fromEntries(opts.fields.filter((f: string) => f in row).map((f: string) => [f, row[f]])));
156+
};
157+
return { ql, projections };
158+
}
159+
160+
async function resolveBoth(
161+
userId: string,
162+
tables: Record<string, unknown[]>,
163+
opts: Record<string, unknown>,
164+
catalog?: typeof definitions,
165+
) {
166+
const full = engineOver(tables, false, catalog);
167+
const projected = engineOver(tables, true, catalog);
168+
return {
169+
full: await resolveUserAuthzGrants(full.ql, userId, opts as never),
170+
projected: await resolveUserAuthzGrants(projected.ql, userId, opts as never),
171+
projections: projected.projections,
172+
};
173+
}
174+
175+
describe('the prelude\'s grant read names the columns the resolver decides on', () => {
176+
it('the grant read names exactly permission_set, organization_id, valid_from and valid_until — no other read names any', async () => {
177+
const { projections } = await resolveBoth('u1', tablesFor(GRANT_FIXTURES.at(-1)!.grants), { tenantId: ORG_A, nowMs: T0 }, definitions);
178+
const grantReads = projections.filter((p) => p.object === 'sys_user_permission_set');
179+
expect(grantReads).toHaveLength(1);
180+
expect([...(grantReads[0]!.fields as string[])].sort()).toEqual(['organization_id', 'permission_set', 'valid_from', 'valid_until']);
181+
// No other read in the prelude changes.
182+
expect(projections.filter((p) => p.object !== 'sys_user_permission_set' && p.fields !== undefined)).toEqual([]);
183+
});
184+
185+
describe.each(GRANT_FIXTURES)('$name', ({ grants, tenantId }) => {
186+
it('resolves the identical envelope from the projected rows as from the full rows', async () => {
187+
const opts = { ...(tenantId ? { tenantId } : {}), nowMs: T0 };
188+
const { full, projected } = await resolveBoth('u1', tablesFor(grants), opts, definitions);
189+
expect(projected).toEqual(full);
190+
});
191+
});
192+
193+
it('the fixtures discriminate: they resolve distinct envelopes, platform standing among them', async () => {
194+
const seen = new Set<string>();
195+
let platformAdmin = 0;
196+
for (const { grants, tenantId } of GRANT_FIXTURES) {
197+
const { projected } = await resolveBoth('u1', tablesFor(grants), { ...(tenantId ? { tenantId } : {}), nowMs: T0 }, definitions);
198+
seen.add(JSON.stringify(projected));
199+
if (projected.posture === 'PLATFORM_ADMIN') platformAdmin += 1;
200+
}
201+
expect(seen.size).toBeGreaterThanOrEqual(5);
202+
expect(platformAdmin).toBe(3);
203+
});
204+
205+
describe.each(FIXTURES)('batch-equivalence fixture $name', (fixture) => {
206+
it('resolves the identical envelope from the projected rows as from the full rows', async () => {
207+
const { full, projected } = await resolveBoth(fixture.userId, fixture.tables, fixture.opts as Record<string, unknown>);
208+
expect(projected).toEqual(full);
209+
});
210+
});
211+
});

0 commit comments

Comments
 (0)