Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ See [STATUS.md](server/STATUS.md) to learn more about which features will remain

## UNRELEASED

- Class constraints: a property's entry in `constraints` can describe nested JSON values with `type` (including `["string", "null"]`), `const`, `properties`, `required`, `additionalProperties`, `items` and `anyOf`, up to 16 levels deep. Every keyword works at every level and errors name the failing value by JSON pointer (`... breaks maximum on class patch at /pad/tune: ...`). JSON Schema import keeps nested schemas, nullable types and `anyOf` below a property as `json` plus constraints instead of refusing them, and export writes them back (`docs/src/schema/classes.md`, `json-schema.md`). Older nodes reject commits that put the new keywords in a class, and skip every constraint of such a class (flat ones included) when it reaches them by sync.
- Loro documents: strings that start with `[`, `{`, `atomic:`, `did:ad:` or `http(s)://` are tagged as `string` in the `datatypes` map so they are no longer read back as nested resources or URLs, and JSON `null` is kept inside nested maps and lists.

## [v0.41.0-beta.8] - 2026-10-10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import {
canonicalizeScheme,
core,
forms,
parseConstraints,
Resource,
setClassConstraint,
Store,
type ConstraintPatch,
useResource,
useStore,
} from '@tomic/react';
Expand Down Expand Up @@ -105,13 +105,20 @@ async function moveClassConstraint(
return;
}

const entry = parseConstraints(raw).get(canonicalizeScheme(from));
// Move the stored JSON, not a parsed constraint: nested schemas and
// patterns have to arrive exactly as they were written.
const map: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw;
const key = canonicalizeScheme(from);
const entry =
typeof map === 'object' && map !== null
? Object.entries(map).find(([k]) => canonicalizeScheme(k) === key)?.[1]
: undefined;

if (!entry) {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
return;
}

await setClassConstraint(dataClass, to, entry);
await setClassConstraint(dataClass, to, entry as ConstraintPatch);
await setClassConstraint(dataClass, from, undefined);
}

Expand Down
48 changes: 48 additions & 0 deletions browser/lib/src/class-constraints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ interface FixtureCase {
value: unknown;
valid: boolean;
rule?: string;
/** JSON pointer of the failing value, when the case pins it. */
path?: string;
/** The constraint itself must be refused by the parser. */
rejected?: boolean;
}

const cases = JSON.parse(
Expand All @@ -31,6 +35,12 @@ describe('class constraints fixture', () => {
});

it.each(cases.map((c, i) => [i, c] as const))('case %i', (_i, c) => {
if (c.rejected) {
expect(() => parseConstraint(c.constraint)).toThrow();

return;
}

const constraint = parseConstraint(c.constraint);

if (c.valid) {
Expand All @@ -45,6 +55,9 @@ describe('class constraints fixture', () => {
} catch (e) {
expect(e).toBeInstanceOf(ConstraintError);
expect((e as ConstraintError).keyword).toBe(c.rule);

if (c.path !== undefined)
expect((e as ConstraintError).path).toBe(c.path);
}
});
});
Expand Down Expand Up @@ -109,6 +122,41 @@ describe('checkResourceConstraints', () => {
);
});

it('names the nested path in the message', () => {
const nested = new Map(store);

nested.set(classSubject, {
'https://atomicdata.dev/properties/shortname': 'task',
'https://atomicdata.dev/properties/constraints': {
[nameProp]: {
type: 'object',
properties: { tune: { type: 'number', maximum: 24 } },
},
},
});

const getNested = (subject: string) => {
const props = nested.get(subject);

return (
props && { subject, getClasses: () => [], get: (p: string) => props[p] }
);
};

const withValue = (value: unknown) => ({
subject: 'https://example.com/t1',
getClasses: () => [classSubject],
get: (p: string) => (p === nameProp ? value : undefined),
});

expect(() =>
checkResourceConstraints(withValue({ tune: 24 }), getNested),
).not.toThrow();
expect(() =>
checkResourceConstraints(withValue({ tune: 25 }), getNested),
).toThrow(/^Value for name breaks maximum on class task at \/tune: /);
});

it('skips classes that are not loaded', () => {
expect(() =>
checkResourceConstraints(
Expand Down
Loading
Loading