@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "1.6.0",
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 = '7b6ec46c4d6a' as const;
3
- export const SPEC_HASH_FULL = '7b6ec46c4d6a112a9b133ad9e815f994f456ec3de2c4ff255fe12ce201445ea8' as const;
2
+ export const SPEC_HASH = '6dcd4788f96e' as const;
3
+ export const SPEC_HASH_FULL = '6dcd4788f96e0955ff7b5fcc0fa336cbaa88d3e8fced75e9f1cce650f8f8c0d7' as const;
@@ -1577,9 +1577,9 @@ export interface paths {
1577
1577
  path?: never;
1578
1578
  cookie?: never;
1579
1579
  };
1580
- /** List per-depth labels + allow_doc policy */
1580
+ /** List per-depth labels */
1581
1581
  get: operations["listOrgLevelConfig"];
1582
- /** Replace per-depth labels + allow_doc policy */
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
  };
@@ -133,7 +133,6 @@ describe('OrgLevelConfigSchema', () => {
133
133
  orgId: UUID_B,
134
134
  depth: 2,
135
135
  label: 'Department',
136
- allowDoc: true,
137
136
  createdAt: '2026-04-17T00:00:00Z',
138
137
  updatedAt: '2026-04-17T00:00:00Z',
139
138
  };
@@ -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,
@@ -711,7 +711,6 @@ export const OrgLevelConfigSchema = z.object({
711
711
  orgId: z.string().uuid(),
712
712
  depth: z.number().int().min(1).max(5),
713
713
  label: z.string().min(1),
714
- allowDoc: z.boolean(),
715
714
  createdAt: z.string(),
716
715
  updatedAt: z.string(),
717
716
  });
@@ -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
+ }