Skip to content

Commit e3e03f5

Browse files
committed
feat(plugin-sharing): backfill rule grants at boot (#2926 ③)
Sharing-rule grants are materialized by write hooks that deliberately skip isSystem writes, so seed-loader records never got sys_record_share rows — demo data with matching rules was broken on a fresh deploy until each record was touched at runtime. Reconcile every active rule once per boot (idempotent, best-effort per rule) right after the rule hooks bind. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
1 parent 7a04666 commit e3e03f5

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
fix(plugin-sharing): reconcile every active sharing rule once at boot (#2926 ③). Rule grants are materialized by write hooks, which deliberately skip `isSystem` writes — so seed-loader records never produced `sys_record_share` rows and demo data shipping with matching sharing rules was broken out of the box until each record was touched at runtime. The new boot backfill runs after the rule hooks bind, is idempotent (diff-based reconcile), and is best-effort per rule so one broken rule cannot block startup.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#2926 ③] Boot backfill of sharing-rule grants.
5+
*
6+
* Rule grants are materialized by write hooks, which deliberately skip
7+
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
8+
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
9+
* demo data shipping with matching sharing rules was broken out of the box
10+
* until an admin "touched" each record at runtime. `backfillRuleGrants`
11+
* reconciles every active rule once per boot, idempotently.
12+
*/
13+
14+
import { describe, it, expect, beforeEach, vi } from 'vitest';
15+
import { SharingService } from './sharing-service.js';
16+
import { SharingRuleService } from './sharing-rule-service.js';
17+
import { backfillRuleGrants } from './sharing-plugin.js';
18+
19+
interface Row { [k: string]: any }
20+
21+
const SYS = { isSystem: true } as any;
22+
23+
function makeEngine() {
24+
const tables: Record<string, Row[]> = {};
25+
const ensure = (n: string) => (tables[n] ??= []);
26+
function matches(row: Row, f: any): boolean {
27+
if (!f || typeof f !== 'object') return true;
28+
if (Array.isArray(f.$or)) return f.$or.some((x: any) => matches(row, x));
29+
if (Array.isArray(f.$and)) return f.$and.every((x: any) => matches(row, x));
30+
for (const [k, v] of Object.entries(f)) {
31+
if (k === '$or' || k === '$and') continue;
32+
const rv = row[k];
33+
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
34+
if (!(v as any).$in.includes(rv)) return false;
35+
continue;
36+
}
37+
if (v != null && typeof v === 'object' && '$gte' in (v as any)) {
38+
if (!(rv >= (v as any).$gte)) return false;
39+
continue;
40+
}
41+
if (rv !== v) return false;
42+
}
43+
return true;
44+
}
45+
return {
46+
_tables: tables,
47+
getSchema() { return undefined; },
48+
async find(o: string, opts?: any) {
49+
const f = opts?.filter ?? opts?.where;
50+
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
51+
},
52+
async insert(o: string, data: any) { const row = { ...data }; ensure(o).push(row); return row; },
53+
async update(o: string, idOrData: any, dataOrOpts?: any) {
54+
const data = typeof idOrData === 'object' ? idOrData : dataOrOpts;
55+
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
56+
const t = ensure(o); const i = t.findIndex((r) => r.id === id);
57+
if (i >= 0) t[i] = { ...t[i], ...data };
58+
return t[i];
59+
},
60+
async delete(o: string, opts?: any) {
61+
const t = ensure(o); const where = opts?.where ?? {};
62+
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
63+
return { ok: true };
64+
},
65+
};
66+
}
67+
68+
describe('backfillRuleGrants (#2926 ③ — seed rows materialize at boot)', () => {
69+
let engine: ReturnType<typeof makeEngine>;
70+
let sharing: SharingService;
71+
let rules: SharingRuleService;
72+
73+
beforeEach(async () => {
74+
engine = makeEngine();
75+
sharing = new SharingService({ engine: engine as any });
76+
rules = new SharingRuleService({ engine: engine as any, sharing });
77+
// Seed-loader analog: records written directly (isSystem path) — the
78+
// write hooks never ran, so sys_record_share is empty.
79+
engine._tables.showcase_inquiry = [
80+
{ id: 'inq_new', status: 'new', owner_id: 'priya' },
81+
{ id: 'inq_won', status: 'won', owner_id: 'priya' },
82+
];
83+
await rules.defineRule({
84+
name: 'new_inquiries_to_wes', label: 'New inquiries → wes', object: 'showcase_inquiry',
85+
criteria: { status: 'new' },
86+
recipientType: 'user', recipientId: 'wes', accessLevel: 'read',
87+
}, SYS);
88+
});
89+
90+
it('materializes grants for seed records that match an active rule', async () => {
91+
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
92+
const active = await rules.listRules({ activeOnly: true }, SYS);
93+
const reconciled = await backfillRuleGrants(rules, active);
94+
expect(reconciled).toBe(1);
95+
const shares = engine._tables.sys_record_share ?? [];
96+
expect(shares).toHaveLength(1);
97+
expect(shares[0]).toMatchObject({ record_id: 'inq_new', recipient_id: 'wes' });
98+
});
99+
100+
it('is idempotent across repeated boots (no duplicate grants)', async () => {
101+
const active = await rules.listRules({ activeOnly: true }, SYS);
102+
await backfillRuleGrants(rules, active);
103+
await backfillRuleGrants(rules, active);
104+
expect(engine._tables.sys_record_share ?? []).toHaveLength(1);
105+
});
106+
107+
it('one broken rule does not block the others (best-effort per rule)', async () => {
108+
await rules.defineRule({
109+
name: 'zzz_broken', label: 'Broken', object: 'showcase_inquiry',
110+
criteria: { status: 'new' },
111+
recipientType: 'user', recipientId: 'someone', accessLevel: 'read',
112+
}, SYS);
113+
const active = await rules.listRules({ activeOnly: true }, SYS);
114+
// Blow up evaluation of the broken rule only.
115+
const realEvaluate = rules.evaluateRule.bind(rules);
116+
vi.spyOn(rules, 'evaluateRule').mockImplementation(async (idOrName: string, context: any) => {
117+
const rule = await rules.getRule(idOrName, context);
118+
if (rule?.name === 'zzz_broken') throw new Error('boom');
119+
return realEvaluate(idOrName, context);
120+
});
121+
const warn = vi.fn();
122+
const reconciled = await backfillRuleGrants(rules, active, { warn });
123+
expect(reconciled).toBe(1);
124+
expect(warn).toHaveBeenCalledOnce();
125+
// The healthy rule still materialized.
126+
expect((engine._tables.sys_record_share ?? []).some((s) => s.record_id === 'inq_new')).toBe(true);
127+
});
128+
});

packages/plugins/plugin-sharing/src/sharing-plugin.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,44 @@ export interface SharingPluginOptions {
3636
shareLinkBasePath?: string;
3737
}
3838

39+
/**
40+
* [#2926 ③] Boot backfill: rule grants are materialized by the write hooks,
41+
* but seed rows are written with `isSystem` (which the hooks deliberately
42+
* skip — see rule-hooks.ts), so a fresh deploy's seed data carried no
43+
* `sys_record_share` rows until each record was touched at runtime.
44+
* Reconcile every active rule once per boot: `evaluateRule` is idempotent
45+
* (diff-based grant/update/revoke), so repeated boots are no-ops.
46+
* Best-effort per rule — one broken rule must not block startup or its
47+
* siblings. Returns the number of rules successfully reconciled.
48+
*/
49+
export async function backfillRuleGrants(
50+
ruleService: SharingRuleService,
51+
rules: Array<{ id?: string; name?: string }>,
52+
logger?: { info?: (msg: string, meta?: any) => void; warn?: (msg: string, meta?: any) => void },
53+
): Promise<number> {
54+
const start = Date.now();
55+
let reconciled = 0;
56+
for (const rule of rules) {
57+
try {
58+
await ruleService.evaluateRule((rule.id ?? rule.name) as string, { isSystem: true } as any);
59+
reconciled += 1;
60+
} catch (err: any) {
61+
logger?.warn?.('SharingServicePlugin: boot rule backfill failed for rule', {
62+
rule: rule.name ?? rule.id,
63+
error: err?.message,
64+
});
65+
}
66+
}
67+
if (rules.length > 0) {
68+
logger?.info?.('SharingServicePlugin: boot rule backfill done', {
69+
rules: rules.length,
70+
reconciled,
71+
ms: Date.now() - start,
72+
});
73+
}
74+
return reconciled;
75+
}
76+
3977
/**
4078
* SharingServicePlugin — registers `sys_record_share`, the `sharing`
4179
* service, and the engine middleware that enforces
@@ -262,6 +300,8 @@ export class SharingServicePlugin implements Plugin {
262300
unbindAllRuleHooks(engine);
263301
bindRuleHooks(engine, this.ruleService, rules, ctx.logger as any);
264302
this.bindRuleRebindTriggers(engine, ctx);
303+
304+
await backfillRuleGrants(this.ruleService, rules, ctx.logger as any);
265305
} else {
266306
ctx.logger.warn('SharingServicePlugin: engine has no hook API — sharing rule auto-evaluation disabled');
267307
}

0 commit comments

Comments
 (0)