Skip to content

Commit ec36ba8

Browse files
authored
feat(cli): lint the contradictory uniqueness double-declaration (#3991) (#4034)
Closes #3991. The authoring-side close-out of #3955/#3981: that PR stopped the runtime churn a column with two uniqueness declarations caused in drift detection; the metadata that produces the combination was still accepted silently. Field-level `unique: true` is tenant-scoped since #3696 — it materializes as `(organization_id, col)` — while a declared index is materialized over exactly its `fields`, i.e. platform-wide. Each is legitimate alone; together on one column exactly one takes effect. On a tenant-scoped object the global index wins and the per-tenant constraint is unreachable, which also masks the #3696 semantic change entirely: the switch has no observable effect while the declared index still enforces the old behaviour, so the author only finds out when a second tenant is rejected. On a tenancy-less object the two are the same index declared twice. New advisory rule `unique/double-declaration` in `lint/data-model-rules.ts`, so it reaches `os lint` and the metadata-generation scorer; exported standalone and wired into `os build` and `os validate`, which the #3782 build/validate parity gate enforces. Tenancy is deliberately not inferred at authoring time, so the message names both readings and the fix spells out the choice; `unique: 'global'` is exempt. Never fails a build. Also documents the distinction where an author actually meets it (`data-modeling/indexing.mdx`), which said nothing about the two spellings, the tenant scoping, or the autonumber trap — so the page led straight to the bug the lint now reports. Verified against the real HotCRM stack: fires on exactly the three sites a manual audit found and stays quiet on the three objects that declare only an index. The HotCRM-side fix is objectstack-ai/hotcrm#550.
1 parent e4c61a7 commit ec36ba8

6 files changed

Lines changed: 324 additions & 2 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
feat(cli): lint the contradictory uniqueness double-declaration (#3991)
6+
7+
New advisory rule `unique/double-declaration`, reported by `os lint` and
8+
`os build`. It fires when one column carries BOTH a field-level `unique: true`
9+
and an object-level single-column unique index:
10+
11+
```ts
12+
email: Field.email({ unique: true }), // per-tenant since #3696
13+
indexes: [{ fields: ['email'], unique: true }], // platform-wide, verbatim
14+
```
15+
16+
The two spellings deliberately mean different things (see `IndexSchema`), and
17+
each is legitimate alone. Together on one column they never are:
18+
19+
- On a **tenant-scoped** object they contradict. The stricter one wins
20+
physically, so the global index enforces uniqueness and the per-tenant
21+
composite becomes a constraint nothing can trip — one of the two authored
22+
intents is silently discarded. Worse, it hides the #3696 semantic change:
23+
the switch from global to per-tenant has *no observable effect* while the
24+
declared index still enforces the old behaviour, so the author never learns
25+
their tenancy model and their real constraint disagree — until a second
26+
tenant reuses the value and is rejected.
27+
- On a **tenancy-less** object they are the same index declared twice.
28+
29+
Tenancy is deliberately not inferred at authoring time (`organization_id` is
30+
injected by the kernel at registration, not authored), so the message names
31+
both readings and the fix spells out the choice: `unique: 'global'` plus
32+
dropping the index for platform-wide, or dropping the index for per-tenant
33+
(or writing it out as `fields: ['organization_id', 'email']`).
34+
35+
A field already declared `unique: 'global'` is exempt — the index restates
36+
that intent rather than losing it. Advisory only: the artifact is well-defined,
37+
so this never fails a build.

content/docs/data-modeling/indexing.mdx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,51 @@ indexes: [
2424
]
2525
```
2626

27+
## Two ways to say "unique" — and they mean different things
28+
29+
Uniqueness can be declared in two places, and the choice is not cosmetic:
30+
31+
| Declaration | Materializes as | Scope |
32+
|:---|:---|:---|
33+
| Field-level `unique: true` | `(organization_id, field)` | Unique **within** an organization |
34+
| Field-level `unique: 'global'` | `(field)` | Platform-wide |
35+
| Declared index `{ fields: ['email'], unique: true }` | `(email)` — exactly the listed columns | Platform-wide |
36+
37+
A field-level `unique: true` is **tenant-scoped**. It has no syntax for a
38+
composite, so the platform supplies the tenant column for you — which is what
39+
a multi-tenant application almost always wants: two organizations may each
40+
have a contact `john@acme.com`.
41+
42+
A **declared index is taken verbatim**. No tenant column is injected, because
43+
many declared indexes are legitimately platform-wide (a DNS hostname, a
44+
reserved slug, an external provider id). To scope one per tenant, list the
45+
column yourself: `{ fields: ['organization_id', 'email'], unique: true }`.
46+
47+
<Callout type="warn">
48+
**Do not declare both on the same column.** The stricter one wins physically,
49+
so the platform-wide index enforces uniqueness and the per-tenant constraint
50+
can never be reached — one of the two intents you wrote is silently discarded:
51+
52+
```typescript
53+
// ⚠️ contradictory — the global index wins, the per-tenant scope is dead
54+
email: Field.email({ unique: true }),
55+
indexes: [{ fields: ['email'], unique: true }],
56+
```
57+
58+
`os lint` / `os build` report this as `unique/double-declaration`. Pick one:
59+
set `unique: 'global'` on the field and drop the index for platform-wide
60+
uniqueness, or drop the index for per-tenant uniqueness (the field-level
61+
declaration already builds the composite).
62+
</Callout>
63+
64+
<Callout type="warn">
65+
**Never put a platform-wide unique index on an `autonumber` field.** The
66+
autonumber sequence is per tenant — every organization counts from `1` — so a
67+
global unique index rejects the second organization's `CASE-00001` on insert.
68+
Use `{ fields: ['organization_id', 'case_number'], unique: true }` so the
69+
constraint matches the sequence that feeds it.
70+
</Callout>
71+
2772
### When to Add Indexes
2873

2974
**Add indexes for:**

packages/cli/src/commands/compile.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, di
1919
import { validateReadonlyFlowWrites } from '@objectstack/lint';
2020
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
2121
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
22+
import { lintUniqueDeclarations } from '../lint/data-model-rules.js';
2223
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
2324
import { lintViewRefs } from '../utils/lint-view-refs.js';
2425
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
@@ -499,6 +500,28 @@ export default class Compile extends Command {
499500
}
500501
}
501502

503+
// 3d-quinquies. Contradictory uniqueness declarations (#3991). A column
504+
// carrying BOTH a field-level `unique: true` and a single-column
505+
// declared unique index has two intents, of which exactly one takes
506+
// effect: since #3696 the field-level form is per-tenant while a
507+
// declared index is platform-wide, so the global index wins and the
508+
// tenant composite becomes unreachable. Advisory — the artifact is
509+
// well-defined; the cost is a declaration that does nothing. Shares
510+
// `lintUniqueDeclarations` with `os lint` so both agree.
511+
const uniqueLint = lintUniqueDeclarations(
512+
Array.isArray((result.data as Record<string, unknown>).objects)
513+
? ((result.data as Record<string, unknown>).objects as any[])
514+
: [],
515+
);
516+
if (uniqueLint.length > 0 && !flags.json) {
517+
console.log('');
518+
for (const f of uniqueLint) {
519+
printWarning(`${f.path}: ${f.message}`);
520+
if (f.fix) console.log(chalk.dim(` ${f.fix}`));
521+
console.log(chalk.dim(` rule: ${f.rule}`));
522+
}
523+
}
524+
502525
// 3d-quater. View-reference lint (#2554) — resolves form action targets
503526
// and view-key collisions at build time. A `type:'form'` target that
504527
// names a missing view or a LIST view opens a broken/blank form at

packages/cli/src/commands/validate.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { validateFlowTriggerReadiness } from '@objectstack/lint';
2424
import { validateReadonlyFlowWrites } from '@objectstack/lint';
2525
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
2626
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
27+
import { lintUniqueDeclarations } from '../lint/data-model-rules.js';
2728
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
2829
import { lintViewRefs } from '../utils/lint-view-refs.js';
2930
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
@@ -592,12 +593,24 @@ export default class Validate extends Command {
592593
const viewRefErrors = viewRefLint.filter((f) => f.severity === 'error');
593594
const viewRefWarnings = viewRefLint.filter((f) => f.severity !== 'error');
594595

596+
// Contradictory uniqueness declarations (#3991) — a column carrying both a
597+
// field-level `unique: true` and a single-column declared unique index has
598+
// two intents, of which exactly one takes effect. Advisory. Mapped into the
599+
// `{ where, hint }` shape the shared renderer below expects; the rule lives
600+
// in `lint/data-model-rules.ts` so `os lint` reports the same finding.
601+
const uniqueLintWarnings = lintUniqueDeclarations(
602+
Array.isArray((result.data as Record<string, unknown>).objects)
603+
? ((result.data as Record<string, unknown>).objects as any[])
604+
: [],
605+
).map((f) => ({ where: f.path, message: f.message, hint: f.fix ?? '', rule: f.rule, severity: 'warning' as const }));
606+
595607
const authoringLintErrors = [...flowLintErrors, ...autonumberErrors, ...viewRefErrors];
596608
const authoringLintWarnings = [
597609
...flowLintWarnings,
598610
...livenessLint,
599611
...autonumberWarnings,
600612
...viewRefWarnings,
613+
...uniqueLintWarnings,
601614
];
602615
if (authoringLintErrors.length > 0) {
603616
if (flags.json) {

packages/cli/src/lint/data-model-rules.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,92 @@ function refOf(def: any): string | undefined {
6969
return def?.reference || def?.reference_to;
7070
}
7171

72+
// ─── Uniqueness declarations ────────────────────────────────────────
73+
74+
export const UNIQUE_DOUBLE_DECLARATION = 'unique/double-declaration';
75+
76+
/** Is `unique` declared at all? Mirrors `isUniqueDeclared` in @objectstack/spec/data. */
77+
function uniqueDeclared(u: unknown): boolean {
78+
return u === true || u === 'global';
79+
}
80+
81+
/**
82+
* R10 — the same column carries BOTH a field-level `unique: true` and an
83+
* object-level single-column unique index (#3991).
84+
*
85+
* The two spellings are deliberately different (see `IndexSchema`): field-level
86+
* `unique: true` is tenant-scoped since #3696 — it materializes as
87+
* `(organization_id, col)`, unique *within* the tenant — while a declared index
88+
* is materialized over exactly the columns listed, i.e. platform-wide. Both are
89+
* legitimate on their own; together on one column they are never right:
90+
*
91+
* - On a tenant-scoped object they CONTRADICT. The stricter one wins
92+
* physically, so the global index enforces uniqueness and the tenant
93+
* composite becomes a constraint nothing can ever trip. One of the two
94+
* intents the author wrote is silently discarded.
95+
* - On a tenancy-less object they are exactly REDUNDANT — both describe the
96+
* same single-column unique index, under the same generated name.
97+
*
98+
* Tenancy is deliberately NOT inferred here: `organization_id` is injected by
99+
* the kernel at registration rather than authored, so an authoring-time guess
100+
* would be wrong half the time. The combination is worth flagging either way,
101+
* and the message names both readings so the author picks the one they meant.
102+
*
103+
* A field declared `unique: 'global'` is exempt: it already says
104+
* platform-wide, so the declared index restates the same intent rather than
105+
* contradicting it (still redundant, but not a silent loss of meaning).
106+
*
107+
* Advisory. The resulting stack is well-defined — the cost is an intent that
108+
* never takes effect, not a broken artifact — so this never fails a build.
109+
*/
110+
export function lintUniqueDeclarations(objects: any[]): LintIssue[] {
111+
const issues: LintIssue[] = [];
112+
if (!Array.isArray(objects) || objects.length === 0) return issues;
113+
114+
for (let i = 0; i < objects.length; i++) {
115+
const obj = objects[i];
116+
if (!obj?.name) continue;
117+
const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
118+
if (declaredIndexes.length === 0) continue;
119+
120+
// Columns covered by a declared SINGLE-column unique index. A composite
121+
// (`['organization_id', 'email']`) is the explicit tenant-scoped spelling —
122+
// it agrees with the field-level default rather than fighting it.
123+
const singleColumnUniqueIndexes = new Map<string, any>();
124+
for (const idx of declaredIndexes) {
125+
if (!uniqueDeclared(idx?.unique)) continue;
126+
const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f: unknown) => typeof f === 'string') : [];
127+
if (cols.length !== 1) continue;
128+
if (!singleColumnUniqueIndexes.has(cols[0])) singleColumnUniqueIndexes.set(cols[0], idx);
129+
}
130+
if (singleColumnUniqueIndexes.size === 0) continue;
131+
132+
for (const { name, def } of fieldEntries(obj.fields)) {
133+
if (!uniqueDeclared(def?.unique)) continue;
134+
if (def.unique === 'global') continue; // already says platform-wide — no lost intent
135+
const idx = singleColumnUniqueIndexes.get(name);
136+
if (!idx) continue;
137+
const indexLabel = typeof idx?.name === 'string' && idx.name.trim() ? ` '${idx.name.trim()}'` : '';
138+
issues.push({
139+
severity: 'warning',
140+
rule: UNIQUE_DOUBLE_DECLARATION,
141+
message:
142+
`"${obj.name}.${name}" declares field-level \`unique: true\` AND a single-column unique index${indexLabel} on the same column. ` +
143+
`Since #3696 the field-level form is scoped per tenant — \`(tenant, ${name})\` — while a declared index is materialized ` +
144+
`over exactly its \`fields\`, i.e. platform-wide. On a tenant-scoped object the global index wins and the per-tenant ` +
145+
`constraint can never be reached; on a tenancy-less object the two are the same index declared twice. Either way one of ` +
146+
`the two declarations has no effect.`,
147+
path: `objects[${i}]`,
148+
fix:
149+
`Pick the intent: for platform-wide uniqueness set \`unique: 'global'\` on '${name}' and drop the duplicate index; ` +
150+
`for per-tenant uniqueness drop the index (the field-level declaration already builds the tenant composite), ` +
151+
`or spell the index out as \`fields: ['organization_id', '${name}']\` if you want it explicit.`,
152+
});
153+
}
154+
}
155+
return issues;
156+
}
157+
72158
// ─── Rule engine ────────────────────────────────────────────────────
73159

74160
/**
@@ -77,7 +163,9 @@ function refOf(def: any): string | undefined {
77163
* metadata-generation scorer.
78164
*/
79165
export function lintDataModel(objects: any[]): LintIssue[] {
80-
const issues: LintIssue[] = [];
166+
// R10 lives in its own exported function so `os build` can run that ONE rule
167+
// without pulling in the whole best-practice sweep (#3991).
168+
const issues: LintIssue[] = lintUniqueDeclarations(objects);
81169
if (!Array.isArray(objects) || objects.length === 0) return issues;
82170

83171
// Index: parent object name → child relationships pointing at it.

packages/cli/test/data-model-rules.test.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { lintDataModel } from '../src/lint/data-model-rules';
2+
import { lintDataModel, lintUniqueDeclarations } from '../src/lint/data-model-rules';
33
import { lintConfig } from '../src/commands/lint';
44

55
const rulesOf = (issues: { rule: string }[]) => issues.map((i) => i.rule);
@@ -173,3 +173,119 @@ describe('lintConfig integration', () => {
173173
expect(dataModel.filter((i) => i.severity !== 'suggestion')).toEqual([]);
174174
});
175175
});
176+
177+
// #3991 — the same column declared unique twice, in two spellings that mean
178+
// different things. One of the two intents is always discarded.
179+
describe('lintUniqueDeclarations — contradictory uniqueness (#3991)', () => {
180+
const RULE = 'unique/double-declaration';
181+
182+
const withBoth = [
183+
{
184+
name: 'crm_contact',
185+
fields: { email: { type: 'email', unique: true } },
186+
indexes: [{ fields: ['email'], unique: true }],
187+
},
188+
];
189+
190+
it('returns [] for empty input', () => {
191+
expect(lintUniqueDeclarations([])).toEqual([]);
192+
expect(lintUniqueDeclarations(undefined as any)).toEqual([]);
193+
});
194+
195+
it('flags field-level unique + a single-column unique index on the same column', () => {
196+
const issues = lintUniqueDeclarations(withBoth);
197+
expect(issues).toHaveLength(1);
198+
expect(issues[0].rule).toBe(RULE);
199+
expect(issues[0].severity).toBe('warning'); // advisory — never fails a build
200+
expect(issues[0].message).toContain('crm_contact.email');
201+
// The message must name BOTH readings, since tenancy is not inferred here.
202+
expect(issues[0].message).toMatch(/per tenant|tenant/i);
203+
expect(issues[0].message).toMatch(/platform-wide/i);
204+
// And the fix must spell out both ways to resolve it.
205+
expect(issues[0].fix).toContain("unique: 'global'");
206+
expect(issues[0].fix).toContain('organization_id');
207+
});
208+
209+
it('surfaces through lintDataModel too, so `os lint` reports it', () => {
210+
expect(has(lintDataModel(withBoth), RULE)).toBe(true);
211+
});
212+
213+
// ── Shapes that must stay quiet ──────────────────────────────────────
214+
215+
it("exempts unique: 'global' — the index restates the intent, it does not lose it", () => {
216+
const issues = lintUniqueDeclarations([
217+
{
218+
name: 'runtime',
219+
fields: { hostname: { type: 'text', unique: 'global' } },
220+
indexes: [{ fields: ['hostname'], unique: true }],
221+
},
222+
]);
223+
expect(issues).toEqual([]);
224+
});
225+
226+
it('exempts an explicit tenant COMPOSITE index — that agrees with the field-level default', () => {
227+
const issues = lintUniqueDeclarations([
228+
{
229+
name: 'crm_contact',
230+
fields: { email: { type: 'email', unique: true } },
231+
indexes: [{ fields: ['organization_id', 'email'], unique: true }],
232+
},
233+
]);
234+
expect(issues).toEqual([]);
235+
});
236+
237+
it('ignores a NON-unique index on the same column', () => {
238+
const issues = lintUniqueDeclarations([
239+
{
240+
name: 'crm_contact',
241+
fields: { email: { type: 'email', unique: true } },
242+
indexes: [{ fields: ['email'] }],
243+
},
244+
]);
245+
expect(issues).toEqual([]);
246+
});
247+
248+
it('ignores a unique index on a DIFFERENT column', () => {
249+
const issues = lintUniqueDeclarations([
250+
{
251+
name: 'crm_contact',
252+
fields: { email: { type: 'email', unique: true }, code: { type: 'text' } },
253+
indexes: [{ fields: ['code'], unique: true }],
254+
},
255+
]);
256+
expect(issues).toEqual([]);
257+
});
258+
259+
it('is quiet when only one of the two spellings is used', () => {
260+
expect(lintUniqueDeclarations([
261+
{ name: 'a', fields: { email: { type: 'email', unique: true } } },
262+
])).toEqual([]);
263+
expect(lintUniqueDeclarations([
264+
{ name: 'b', fields: { email: { type: 'email' } }, indexes: [{ fields: ['email'], unique: true }] },
265+
])).toEqual([]);
266+
});
267+
268+
it('names the declared index when it carries an explicit name', () => {
269+
const issues = lintUniqueDeclarations([
270+
{
271+
name: 'crm_product',
272+
fields: { sku: { type: 'text', unique: true } },
273+
indexes: [{ name: 'uniq_product_sku', fields: ['sku'], unique: true }],
274+
},
275+
]);
276+
expect(issues[0].message).toContain("'uniq_product_sku'");
277+
});
278+
279+
it('reports each offending column once, across several objects', () => {
280+
const issues = lintUniqueDeclarations([
281+
...withBoth,
282+
{
283+
name: 'crm_lead',
284+
fields: { email: { type: 'email', unique: true }, sku: { type: 'text', unique: true } },
285+
indexes: [{ fields: ['email'], unique: true }, { fields: ['sku'], unique: true }],
286+
},
287+
]);
288+
expect(issues.map((i) => i.message.match(/"([^"]+)"/)?.[1]).sort())
289+
.toEqual(['crm_contact.email', 'crm_lead.email', 'crm_lead.sku']);
290+
});
291+
});

0 commit comments

Comments
 (0)