@company-semantics/contracts 1.7.0 → 1.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -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,
@@ -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
+ }