Skip to content

Commit 49a6a5c

Browse files
test(plugin-sharing): seam ObjectQL double holds the caller limit (#28670)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XE31U2cwvwmv9HjQt5Dax7Z)_ Fixes #28608 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:plugin-sharing 测试用的内存 ObjectQL 替身(`makeSeamQl().find`)现在会遵守调用方传入的 `limit`,先按 `where` 过滤、再按 `limit` 截断。 - 为什么改:真实引擎会遵守 `limit`,`resolveAuthzContext` 读这个替身时也确实传了 `limit`(如选默认组织时的 1 和 2);替身丢掉它,测试看到的行比运行时多,可能在运行时不会出现的结果上变绿。 - 风险与回滚:只改测试文件,`dist` 不含这个 testkit,不发版;回滚即 revert 本 PR。 - 先看:`packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts`(`find` 的 return 一行),`packages/plugins/plugin-sharing/src/exec-context-seam.limit.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run src/exec-context-seam.limit.test.ts`,预期 3 passed。 - 需要你做的:无,席位落地。 ## What changes - `makeSeamQl().find` in `exec-context-seam.testkit.ts` now applies the caller's bound after the `where` filter, by presence: `typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched`. That is the first of the two shapes `scripts/check-objectql-double-limit.mjs` prescribes for a compliant double. It was picked over "throw on a bound it does not implement" because `resolveAuthzContext` really does read through this double with bounds (`limit: 1`, `limit: 2`, `limit: 200` and others), so a throwing double would break every importer. `where` handling is unchanged. - `makeSeamQl` is now exported, only so its own pin can drive it directly. - New pin `exec-context-seam.limit.test.ts`: `limit: 1` over two matching rows answers one row; the bound applies after the filter, not before it; no `limit` answers every match. ## Why The double silently dropped `limit`, so a test running through the seam could see more rows than the real engine would hand the resolver. Card #28608 question 1 (triage record 6557807357). Question 2 (widening the two double gates to walk `*.testkit.ts`) is graded out and not touched: neither gate script is edited. ## Tests - Red first (export applied, fix not yet applied): `answers at most limit rows when more match` fails, `expected [ Array(2) ] to deeply equal [ { id: 'a', …(1) } ]`, Tests 1 failed, 2 passed (3). - Green on the fix: Tests 3 passed (3). - Ablation through `scripts/ablation-replace.mjs`: the limit line replaced by `return matched;` (anchor x1 to x0, blob d27c845 to 17d1ac7833f6), the same case reds (1 failed, 2 passed), then restored: blob after restore equals the HEAD blob d27c845 and `git diff HEAD` is empty. The testkit is source imported relatively by the test, so no dist leg applies. - Whole `@objectstack/plugin-sharing` suite on head e410091: Test Files 59 passed (59), Tests 1299 passed (1299). The three test files that import the seam (`sharing-service.test.ts`, `share-link-enforcement-context.test.ts`, `share-link-tenancy-posture-admission.test.ts`, plus the type-only import in `exec-context-annotation.pin.ts`) pass unchanged. - `pnpm --filter @objectstack/plugin-sharing typecheck`: exit 0 (test layer compiles, debt held). `eslint --no-inline-config packages/plugins/plugin-sharing`: exit 0. ## Acceptance notes - Gate-walk probe the card cites, re-run read-only: `check-objectql-double-limit.mjs` copied with its `testFilesUnder` regex widened to `(test|testkit).ts`, never committed and deleted after. On head: `OK ObjectQL double limit conformance holds: 643 double(s) graded`, no plugin-sharing row. On the base testkit blob, same probe: `exec-context-seam.testkit.ts ... (1 blind ...) line 58: BLIND`, exit 1. Restore proven by blob equality and an empty `git diff HEAD`. - `skip-changeset`: plugin-sharing's `files[]` is `dist`, `README.md`, `CHANGELOG.md`. After a build, `makeSeamQl` and `bootRequestContext` have 0 hits there, while the positive control `SharingService` has 4 files in `dist`. ## Verification Local subset on head e410091 (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 52 derived families), each exit captured before any pipe; reconciled with `--ran`: `52 derived famil(ies) accounted for — 51 run, 1 NOT-MEASURED`. - 51 families exit 0, including `check:objectql-double-limit`, `check:engine-double-contract`, `check:where-matcher`, `check:query-options-erasure`, `check:test-source-alias`, `check:type-check-debt`, `check:published-files` and `check:nul-bytes`. - `check-plugin-teardown-shape.mjs --self-test`: exit 3 on the shallow clone first, exit 0 after fetching its pinned fixture commit. - `check:i18n`: exit 3 (CLI not built) first, exit 0 after its one-command prerequisite build (`all bundles in sync`). - NOT MEASURED: `check:dual-build-cjs-loads`, exit 3 PREREQUISITE NOT MET (needs every package dist in this fresh worktree); CI runs it. - Full dogfood not run: no `!` in the title and no `(narrowing)` arm. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped to plugin-sharing). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (nothing publishes: `skip-changeset`) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the maintainer brief above is Chinese by the dispatch contract) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kA7pasGpR4Pf7mtNb --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kA7pasGpR4Pf7mtNb)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1404938 commit 49a6a5c

2 files changed

Lines changed: 49 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The seam's in-memory ObjectQL holds the caller's `limit`.
5+
*
6+
* `resolveAuthzContext` reads through this double with real bounds (for
7+
* example `limit: 1` and `limit: 2` when it picks a default organization). A
8+
* double that dropped the bound answered more rows than the real engine ever
9+
* would, so a test running through the seam could pass on a result the
10+
* runtime never produces.
11+
*/
12+
13+
import { describe, expect, it } from 'vitest';
14+
import { makeSeamQl } from './exec-context-seam.testkit.js';
15+
16+
describe('makeSeamQl().find holds the caller limit', () => {
17+
const rows = [
18+
{ id: 'a', organization_id: 'org_1' },
19+
{ id: 'b', organization_id: 'org_1' },
20+
{ id: 'c', organization_id: 'org_2' },
21+
];
22+
23+
it('answers at most `limit` rows when more match', async () => {
24+
const ql = makeSeamQl({ sys_member: rows });
25+
const page = await ql.find('sys_member', { where: { organization_id: 'org_1' }, limit: 1 });
26+
expect(page).toEqual([{ id: 'a', organization_id: 'org_1' }]);
27+
});
28+
29+
it('applies the bound after the filter, not before it', async () => {
30+
const ql = makeSeamQl({ sys_member: rows });
31+
const page = await ql.find('sys_member', { where: { organization_id: 'org_2' }, limit: 1 });
32+
expect(page).toEqual([{ id: 'c', organization_id: 'org_2' }]);
33+
});
34+
35+
it('answers every match when no limit is given', async () => {
36+
const ql = makeSeamQl({ sys_member: rows });
37+
const page = await ql.find('sys_member', { where: { organization_id: 'org_1' } });
38+
expect(page.map((r) => r.id)).toEqual(['a', 'b']);
39+
});
40+
});

‎packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,24 @@ export interface SeamPrincipal {
5252
memberships?: SeamMembership[];
5353
}
5454

55-
/** Minimal in-memory ObjectQL: `find(object, { where })` with `===` + `$in`. */
56-
function makeSeamQl(tables: Record<string, any[]>) {
55+
/**
56+
* Minimal in-memory ObjectQL: `find(object, { where, limit })` with `===` +
57+
* `$in`. Exported only so its own pin can drive it directly.
58+
*/
59+
export function makeSeamQl(tables: Record<string, any[]>) {
5760
return {
5861
async find(object: string, opts: any) {
5962
const rows = tables[object] ?? [];
6063
const where = opts?.where ?? {};
61-
return rows.filter((r) =>
64+
const matched = rows.filter((r) =>
6265
Object.entries(where).every(([k, v]) => {
6366
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
6467
return r[k] === v;
6568
}),
6669
);
70+
// The caller's bound, applied AFTER the filter and by PRESENCE, as the
71+
// real engine does: `resolveAuthzContext` reads with real limits.
72+
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
6773
},
6874
};
6975
}

0 commit comments

Comments
 (0)