@company-semantics/contracts 1.6.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 +2 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +2 -5
- package/src/org/__tests__/org-units.test.ts +0 -1
- package/src/org/__tests__/tree-ordering.test.ts +102 -0
- package/src/org/index.ts +4 -0
- package/src/org/schemas.ts +0 -1
- package/src/org/tree-ordering.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"release": "npx tsx scripts/release.ts",
|
|
100
100
|
"prepublishOnly": "echo 'ERROR: Publishing is CI-only via tag push. Use pnpm release instead.' && exit 1",
|
|
101
101
|
"test": "vitest run",
|
|
102
|
+
"test:run": "vitest run",
|
|
102
103
|
"generate:spec-hash": "tsx scripts/generate-spec-hash.ts",
|
|
103
104
|
"generate:api": "pnpm generate:api-types && pnpm generate:spec-hash",
|
|
104
105
|
"generate:api-types": "openapi-typescript openapi/backend.yaml -o src/api/generated.ts",
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = '6dcd4788f96e' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '6dcd4788f96e0955ff7b5fcc0fa336cbaa88d3e8fced75e9f1cce650f8f8c0d7' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -1577,9 +1577,9 @@ export interface paths {
|
|
|
1577
1577
|
path?: never;
|
|
1578
1578
|
cookie?: never;
|
|
1579
1579
|
};
|
|
1580
|
-
/** List per-depth labels
|
|
1580
|
+
/** List per-depth labels */
|
|
1581
1581
|
get: operations["listOrgLevelConfig"];
|
|
1582
|
-
/** Replace per-depth labels
|
|
1582
|
+
/** Replace per-depth labels */
|
|
1583
1583
|
put: operations["upsertOrgLevelConfig"];
|
|
1584
1584
|
post?: never;
|
|
1585
1585
|
delete?: never;
|
|
@@ -3113,7 +3113,6 @@ export interface components {
|
|
|
3113
3113
|
orgId: string;
|
|
3114
3114
|
depth: number;
|
|
3115
3115
|
label: string;
|
|
3116
|
-
allowDoc: boolean;
|
|
3117
3116
|
createdAt: string;
|
|
3118
3117
|
updatedAt: string;
|
|
3119
3118
|
}[];
|
|
@@ -3124,7 +3123,6 @@ export interface components {
|
|
|
3124
3123
|
orgId: string;
|
|
3125
3124
|
depth: number;
|
|
3126
3125
|
label: string;
|
|
3127
|
-
allowDoc: boolean;
|
|
3128
3126
|
createdAt: string;
|
|
3129
3127
|
updatedAt: string;
|
|
3130
3128
|
}[];
|
|
@@ -5789,7 +5787,6 @@ export interface operations {
|
|
|
5789
5787
|
entries: {
|
|
5790
5788
|
depth: number;
|
|
5791
5789
|
label: string;
|
|
5792
|
-
allowDoc: boolean;
|
|
5793
5790
|
}[];
|
|
5794
5791
|
};
|
|
5795
5792
|
};
|
|
@@ -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,
|
package/src/org/schemas.ts
CHANGED
|
@@ -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
|
+
}
|