@company-semantics/contracts 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/org/__tests__/tree-ordering.test.ts +102 -0
- package/src/org/index.ts +6 -0
- package/src/org/org-units.ts +42 -0
- package/src/org/tree-ordering.ts +63 -0
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { orderTreeNodes, type TreeOrderableNode } from '../tree-ordering.js';
|
|
3
|
+
|
|
4
|
+
interface TestNode extends TreeOrderableNode {
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function shuffle<T>(input: readonly T[], seed: number): T[] {
|
|
9
|
+
const out = input.slice();
|
|
10
|
+
let s = seed >>> 0 || 1;
|
|
11
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
12
|
+
s = (s * 1664525 + 1013904223) >>> 0;
|
|
13
|
+
const j = s % (i + 1);
|
|
14
|
+
const tmp = out[i];
|
|
15
|
+
out[i] = out[j];
|
|
16
|
+
out[j] = tmp;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FIXTURE: TestNode[] = [
|
|
22
|
+
{ id: 'root-1', parentId: null, orderKey: '10' },
|
|
23
|
+
{ id: 'root-2', parentId: null, orderKey: '20' },
|
|
24
|
+
{ id: 'a', parentId: 'root-1', orderKey: '20' },
|
|
25
|
+
{ id: 'b', parentId: 'root-1', orderKey: '10' },
|
|
26
|
+
{ id: 'c', parentId: 'root-1', orderKey: '30' },
|
|
27
|
+
{ id: 'aa', parentId: 'a', orderKey: '10' },
|
|
28
|
+
{ id: 'ab', parentId: 'a', orderKey: '20' },
|
|
29
|
+
{ id: 'ba', parentId: 'b', orderKey: '10' },
|
|
30
|
+
{ id: 'x', parentId: 'root-2', orderKey: '10' },
|
|
31
|
+
{ id: 'y', parentId: 'root-2', orderKey: '10' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
describe('orderTreeNodes', () => {
|
|
35
|
+
it('emits a parent-before-children flat order sorted per parent', () => {
|
|
36
|
+
const ordered = orderTreeNodes(FIXTURE).map((n) => n.id);
|
|
37
|
+
expect(ordered).toEqual(['root-1', 'b', 'ba', 'a', 'aa', 'ab', 'c', 'root-2', 'x', 'y']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is idempotent — running twice produces the same result', () => {
|
|
41
|
+
const once = orderTreeNodes(FIXTURE);
|
|
42
|
+
const twice = orderTreeNodes(once);
|
|
43
|
+
expect(twice).toEqual(once);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('is stable under input shuffle — order depends only on (orderKey, id), not insertion order', () => {
|
|
47
|
+
const baseline = orderTreeNodes(FIXTURE).map((n) => n.id);
|
|
48
|
+
for (const seed of [1, 2, 3, 42, 1337, 999999]) {
|
|
49
|
+
const shuffled = shuffle(FIXTURE, seed);
|
|
50
|
+
const result = orderTreeNodes(shuffled).map((n) => n.id);
|
|
51
|
+
expect(result).toEqual(baseline);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('uses id as deterministic tiebreaker when orderKey is identical', () => {
|
|
56
|
+
const ties: TestNode[] = [
|
|
57
|
+
{ id: 'zeta', parentId: null, orderKey: '50' },
|
|
58
|
+
{ id: 'alpha', parentId: null, orderKey: '50' },
|
|
59
|
+
{ id: 'mike', parentId: null, orderKey: '50' },
|
|
60
|
+
];
|
|
61
|
+
const order = orderTreeNodes(ties).map((n) => n.id);
|
|
62
|
+
expect(order).toEqual(['alpha', 'mike', 'zeta']);
|
|
63
|
+
|
|
64
|
+
const shuffled = orderTreeNodes(shuffle(ties, 7)).map((n) => n.id);
|
|
65
|
+
expect(shuffled).toEqual(['alpha', 'mike', 'zeta']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('places nodes with null parentId in the root group, before any descendants', () => {
|
|
69
|
+
const nodes: TestNode[] = [
|
|
70
|
+
{ id: 'child-of-r2', parentId: 'r2', orderKey: '10' },
|
|
71
|
+
{ id: 'r1', parentId: null, orderKey: '20' },
|
|
72
|
+
{ id: 'r2', parentId: null, orderKey: '10' },
|
|
73
|
+
{ id: 'child-of-r1', parentId: 'r1', orderKey: '10' },
|
|
74
|
+
];
|
|
75
|
+
const ordered = orderTreeNodes(nodes).map((n) => n.id);
|
|
76
|
+
expect(ordered).toEqual(['r2', 'child-of-r2', 'r1', 'child-of-r1']);
|
|
77
|
+
|
|
78
|
+
const roots = orderTreeNodes(nodes).filter((n) => n.parentId === null).map((n) => n.id);
|
|
79
|
+
expect(roots).toEqual(['r2', 'r1']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('preserves all input fields on the output nodes', () => {
|
|
83
|
+
const rich: TestNode[] = [
|
|
84
|
+
{ id: 'only', parentId: null, orderKey: '1', label: 'hello' },
|
|
85
|
+
];
|
|
86
|
+
const [out] = orderTreeNodes(rich);
|
|
87
|
+
expect(out).toEqual({ id: 'only', parentId: null, orderKey: '1', label: 'hello' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns a new array without mutating the input list', () => {
|
|
91
|
+
const input = FIXTURE.slice();
|
|
92
|
+
const snapshotIds = input.map((n) => n.id);
|
|
93
|
+
orderTreeNodes(input);
|
|
94
|
+
expect(input.map((n) => n.id)).toEqual(snapshotIds);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('is strictly synchronous (no Promise return)', () => {
|
|
98
|
+
const result = orderTreeNodes(FIXTURE);
|
|
99
|
+
expect(Array.isArray(result)).toBe(true);
|
|
100
|
+
expect(typeof (result as unknown as { then?: unknown }).then).toBe('undefined');
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/org/index.ts
CHANGED
|
@@ -80,6 +80,10 @@ export type {
|
|
|
80
80
|
export type { AuthorizableView } from './view-scopes';
|
|
81
81
|
export { VIEW_SCOPE_MAP, getViewScope } from './view-scopes';
|
|
82
82
|
|
|
83
|
+
// Canonical OrgUnit tree ordering (PRD-00506)
|
|
84
|
+
export type { TreeOrderableNode } from './tree-ordering';
|
|
85
|
+
export { orderTreeNodes } from './tree-ordering';
|
|
86
|
+
|
|
83
87
|
// Company.md domain types (PRD-00173)
|
|
84
88
|
export type {
|
|
85
89
|
CompanyMdVisibility,
|
|
@@ -200,7 +204,9 @@ export type {
|
|
|
200
204
|
OrgUnitMembershipStatus,
|
|
201
205
|
OrgUnitMembershipSource,
|
|
202
206
|
OrgUnitErrorCode,
|
|
207
|
+
OrgTreeResponse,
|
|
203
208
|
} from './org-units';
|
|
209
|
+
export { ORG_UNITS_ROUTES } from './org-units';
|
|
204
210
|
export {
|
|
205
211
|
OrgUnitClassificationSchema,
|
|
206
212
|
OrgUnitSyncModeSchema,
|
package/src/org/org-units.ts
CHANGED
|
@@ -45,6 +45,48 @@ export type OrgUnitMembershipStatus = 'active' | 'pending' | 'removed';
|
|
|
45
45
|
/** External directory origin of a membership row. */
|
|
46
46
|
export type OrgUnitMembershipSource = 'manual' | 'google_groups' | 'scim' | 'hris';
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Canonical route path constants for the `/api/org-units` surface.
|
|
50
|
+
*
|
|
51
|
+
* Consumers (backend route handlers, app hooks, typed clients) MUST import
|
|
52
|
+
* these rather than inlining path literals so a single rename stays
|
|
53
|
+
* consistent across the system. Parameterised paths are exposed as
|
|
54
|
+
* functions so callers cannot forget to interpolate the id.
|
|
55
|
+
*
|
|
56
|
+
* `tree` returns an `OrgTreeResponse` (alias for `OrgUnitTreeResponse`,
|
|
57
|
+
* shape `{ nodes, levelConfig }` — see `./schemas`).
|
|
58
|
+
*
|
|
59
|
+
* Used by PRD-00511 to consolidate the legacy `/api/org-tree` handler
|
|
60
|
+
* into `/api/org-units/tree` without changing the response shape.
|
|
61
|
+
*/
|
|
62
|
+
export const ORG_UNITS_ROUTES = {
|
|
63
|
+
list: '/api/org-units',
|
|
64
|
+
tree: '/api/org-units/tree',
|
|
65
|
+
byId: (unitId: string) => `/api/org-units/${unitId}`,
|
|
66
|
+
children: (unitId: string) => `/api/org-units/${unitId}/children`,
|
|
67
|
+
ancestors: (unitId: string) => `/api/org-units/${unitId}/ancestors`,
|
|
68
|
+
descendants: (unitId: string) => `/api/org-units/${unitId}/descendants`,
|
|
69
|
+
archive: (unitId: string) => `/api/org-units/${unitId}/archive`,
|
|
70
|
+
reparent: (unitId: string) => `/api/org-units/${unitId}/reparent`,
|
|
71
|
+
reorder: (unitId: string) => `/api/org-units/${unitId}/reorder`,
|
|
72
|
+
relationships: (unitId: string) => `/api/org-units/${unitId}/relationships`,
|
|
73
|
+
memberships: (unitId: string) => `/api/org-units/${unitId}/memberships`,
|
|
74
|
+
membershipByUser: (unitId: string, userId: string) =>
|
|
75
|
+
`/api/org-units/${unitId}/memberships/${userId}`,
|
|
76
|
+
membershipRole: (unitId: string, userId: string) =>
|
|
77
|
+
`/api/org-units/${unitId}/memberships/${userId}/role`,
|
|
78
|
+
permissions: (unitId: string) => `/api/org-units/${unitId}/permissions`,
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Domain-level alias for the tree endpoint response type.
|
|
83
|
+
*
|
|
84
|
+
* The canonical schema lives in `./schemas` as `OrgUnitTreeResponseSchema`
|
|
85
|
+
* / `OrgUnitTreeResponse`. This alias gives consumers the shorter
|
|
86
|
+
* `OrgTreeResponse` name used by `GET /api/org-units/tree`.
|
|
87
|
+
*/
|
|
88
|
+
export type { OrgUnitTreeResponse as OrgTreeResponse } from './schemas';
|
|
89
|
+
|
|
48
90
|
/**
|
|
49
91
|
* Error codes returned by `POST /api/org-units/:id/reparent` and related
|
|
50
92
|
* mutation endpoints. Clients must handle these explicitly.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical per-parent sibling ordering for OrgUnit trees.
|
|
3
|
+
*
|
|
4
|
+
* Sort rule: ascending `orderKey`, with `id` as deterministic tiebreaker.
|
|
5
|
+
* Applied per parent. The output is a flat array produced by a depth-first
|
|
6
|
+
* traversal from the root set (nodes with `parentId === null`), emitting each
|
|
7
|
+
* parent before its children, with siblings in sorted order.
|
|
8
|
+
*
|
|
9
|
+
* This is the single source of truth for tree ordering. Backend and app
|
|
10
|
+
* consumers MUST import from this module rather than reimplementing the
|
|
11
|
+
* sibling sort. Keeping one implementation ensures server-rendered and
|
|
12
|
+
* client-rendered trees agree byte-for-byte.
|
|
13
|
+
*
|
|
14
|
+
* Invariants:
|
|
15
|
+
* - Pure and strictly synchronous (no Promise / async return type) — see
|
|
16
|
+
* feedback `sync_derivers_as_invariant`.
|
|
17
|
+
* - Per-parent ordering: siblings under the same parent are sorted together;
|
|
18
|
+
* cross-parent ordering is determined by traversal from the root set.
|
|
19
|
+
* - Stable tiebreaker: nodes with identical `orderKey` fall back to `id`
|
|
20
|
+
* ascending (string comparison). Never fall through to implementation-
|
|
21
|
+
* defined Array.prototype.sort behavior.
|
|
22
|
+
* - Generic over the input node type: the function preserves all input
|
|
23
|
+
* fields on output. Callers can extend `TreeOrderableNode` with their own
|
|
24
|
+
* fields without losing them.
|
|
25
|
+
* - Orphan nodes (non-null `parentId` that does not match any node's `id`)
|
|
26
|
+
* are dropped from the flat output. Callers that need orphan surfacing
|
|
27
|
+
* must handle that at their own layer (as `buildOrgTree` does in the app).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface TreeOrderableNode {
|
|
31
|
+
id: string;
|
|
32
|
+
parentId: string | null;
|
|
33
|
+
orderKey: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function orderTreeNodes<T extends TreeOrderableNode>(nodes: readonly T[]): T[] {
|
|
37
|
+
const byParent = new Map<string | null, T[]>();
|
|
38
|
+
for (const node of nodes) {
|
|
39
|
+
const bucket = byParent.get(node.parentId) ?? [];
|
|
40
|
+
bucket.push(node);
|
|
41
|
+
byParent.set(node.parentId, bucket);
|
|
42
|
+
}
|
|
43
|
+
for (const bucket of byParent.values()) {
|
|
44
|
+
bucket.sort(compareSiblings);
|
|
45
|
+
}
|
|
46
|
+
const result: T[] = [];
|
|
47
|
+
const emit = (parentId: string | null): void => {
|
|
48
|
+
const bucket = byParent.get(parentId);
|
|
49
|
+
if (!bucket) return;
|
|
50
|
+
for (const node of bucket) {
|
|
51
|
+
result.push(node);
|
|
52
|
+
emit(node.id);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
emit(null);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function compareSiblings(a: TreeOrderableNode, b: TreeOrderableNode): number {
|
|
60
|
+
if (a.orderKey !== b.orderKey) return a.orderKey < b.orderKey ? -1 : 1;
|
|
61
|
+
if (a.id === b.id) return 0;
|
|
62
|
+
return a.id < b.id ? -1 : 1;
|
|
63
|
+
}
|