@platforma-sdk/model 1.59.0 → 1.60.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.
Files changed (140) hide show
  1. package/dist/block_storage.cjs.map +1 -1
  2. package/dist/block_storage.d.ts +1 -11
  3. package/dist/block_storage.js.map +1 -1
  4. package/dist/block_storage_callbacks.cjs.map +1 -1
  5. package/dist/block_storage_callbacks.js.map +1 -1
  6. package/dist/columns/column_collection_builder.cjs +215 -0
  7. package/dist/columns/column_collection_builder.cjs.map +1 -0
  8. package/dist/columns/column_collection_builder.d.ts +112 -0
  9. package/dist/columns/column_collection_builder.js +214 -0
  10. package/dist/columns/column_collection_builder.js.map +1 -0
  11. package/dist/columns/column_selector.cjs +122 -0
  12. package/dist/columns/column_selector.cjs.map +1 -0
  13. package/dist/columns/column_selector.d.ts +41 -0
  14. package/dist/columns/column_selector.js +118 -0
  15. package/dist/columns/column_selector.js.map +1 -0
  16. package/dist/columns/column_snapshot.cjs +20 -0
  17. package/dist/columns/column_snapshot.cjs.map +1 -0
  18. package/dist/columns/column_snapshot.d.ts +39 -0
  19. package/dist/columns/column_snapshot.js +18 -0
  20. package/dist/columns/column_snapshot.js.map +1 -0
  21. package/dist/columns/column_snapshot_provider.cjs +112 -0
  22. package/dist/columns/column_snapshot_provider.cjs.map +1 -0
  23. package/dist/columns/column_snapshot_provider.d.ts +73 -0
  24. package/dist/columns/column_snapshot_provider.js +107 -0
  25. package/dist/columns/column_snapshot_provider.js.map +1 -0
  26. package/dist/columns/ctx_column_sources.cjs +84 -0
  27. package/dist/columns/ctx_column_sources.cjs.map +1 -0
  28. package/dist/columns/ctx_column_sources.d.ts +33 -0
  29. package/dist/columns/ctx_column_sources.js +82 -0
  30. package/dist/columns/ctx_column_sources.js.map +1 -0
  31. package/dist/columns/index.cjs +5 -0
  32. package/dist/columns/index.d.ts +5 -0
  33. package/dist/columns/index.js +5 -0
  34. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.cjs +111 -0
  35. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.cjs.map +1 -0
  36. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.d.ts +25 -0
  37. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.js +110 -0
  38. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.js.map +1 -0
  39. package/dist/components/PlDataTable/{table.cjs → createPlDataTable/createPlDataTableV3.cjs} +54 -54
  40. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -0
  41. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts +39 -0
  42. package/dist/components/PlDataTable/{table.js → createPlDataTable/createPlDataTableV3.js} +53 -53
  43. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -0
  44. package/dist/components/PlDataTable/createPlDataTable/index.cjs +12 -0
  45. package/dist/components/PlDataTable/createPlDataTable/index.cjs.map +1 -0
  46. package/dist/components/PlDataTable/createPlDataTable/index.d.ts +15 -0
  47. package/dist/components/PlDataTable/createPlDataTable/index.js +12 -0
  48. package/dist/components/PlDataTable/createPlDataTable/index.js.map +1 -0
  49. package/dist/components/PlDataTable/createPlDataTableSheet.cjs +18 -0
  50. package/dist/components/PlDataTable/createPlDataTableSheet.cjs.map +1 -0
  51. package/dist/components/PlDataTable/createPlDataTableSheet.d.ts +11 -0
  52. package/dist/components/PlDataTable/createPlDataTableSheet.js +17 -0
  53. package/dist/components/PlDataTable/createPlDataTableSheet.js.map +1 -0
  54. package/dist/components/PlDataTable/index.cjs +4 -1
  55. package/dist/components/PlDataTable/index.d.ts +5 -2
  56. package/dist/components/PlDataTable/index.js +4 -1
  57. package/dist/components/PlDataTable/state-migration.cjs.map +1 -1
  58. package/dist/components/PlDataTable/state-migration.d.ts +2 -2
  59. package/dist/components/PlDataTable/state-migration.js.map +1 -1
  60. package/dist/components/PlDataTable/{v4.d.ts → typesV4.d.ts} +2 -2
  61. package/dist/components/PlDataTable/{v5.d.ts → typesV5.d.ts} +2 -2
  62. package/dist/components/index.cjs +4 -1
  63. package/dist/components/index.d.ts +5 -2
  64. package/dist/components/index.js +4 -1
  65. package/dist/index.cjs +44 -16
  66. package/dist/index.d.ts +17 -5
  67. package/dist/index.js +15 -3
  68. package/dist/labels/derive_distinct_labels.cjs +156 -0
  69. package/dist/labels/derive_distinct_labels.cjs.map +1 -0
  70. package/dist/labels/derive_distinct_labels.d.ts +29 -0
  71. package/dist/labels/derive_distinct_labels.js +155 -0
  72. package/dist/labels/derive_distinct_labels.js.map +1 -0
  73. package/dist/labels/index.cjs +2 -0
  74. package/dist/labels/index.d.ts +2 -0
  75. package/dist/labels/index.js +2 -0
  76. package/dist/labels/write_labels_to_specs.cjs +15 -0
  77. package/dist/labels/write_labels_to_specs.cjs.map +1 -0
  78. package/dist/labels/write_labels_to_specs.d.ts +9 -0
  79. package/dist/labels/write_labels_to_specs.js +14 -0
  80. package/dist/labels/write_labels_to_specs.js.map +1 -0
  81. package/dist/package.cjs +1 -1
  82. package/dist/package.js +1 -1
  83. package/dist/render/api.cjs +11 -2
  84. package/dist/render/api.cjs.map +1 -1
  85. package/dist/render/api.d.ts +9 -5
  86. package/dist/render/api.js +12 -3
  87. package/dist/render/api.js.map +1 -1
  88. package/dist/render/index.d.ts +2 -1
  89. package/dist/render/index.js +1 -1
  90. package/dist/render/internal.cjs.map +1 -1
  91. package/dist/render/internal.d.ts +5 -2
  92. package/dist/render/internal.js.map +1 -1
  93. package/dist/render/util/column_collection.cjs +3 -3
  94. package/dist/render/util/column_collection.cjs.map +1 -1
  95. package/dist/render/util/column_collection.d.ts +3 -2
  96. package/dist/render/util/column_collection.js +4 -4
  97. package/dist/render/util/column_collection.js.map +1 -1
  98. package/dist/render/util/index.d.ts +2 -1
  99. package/dist/render/util/index.js +1 -1
  100. package/dist/render/util/label.cjs +7 -134
  101. package/dist/render/util/label.cjs.map +1 -1
  102. package/dist/render/util/label.d.ts +5 -50
  103. package/dist/render/util/label.js +8 -132
  104. package/dist/render/util/label.js.map +1 -1
  105. package/dist/render/util/split_selectors.d.ts +2 -2
  106. package/package.json +8 -6
  107. package/src/block_storage.ts +0 -11
  108. package/src/block_storage_callbacks.ts +1 -1
  109. package/src/columns/column_collection_builder.test.ts +427 -0
  110. package/src/columns/column_collection_builder.ts +455 -0
  111. package/src/columns/column_selector.test.ts +472 -0
  112. package/src/columns/column_selector.ts +212 -0
  113. package/src/columns/column_snapshot.ts +55 -0
  114. package/src/columns/column_snapshot_provider.ts +177 -0
  115. package/src/columns/ctx_column_sources.ts +107 -0
  116. package/src/columns/expand_by_partition.test.ts +289 -0
  117. package/src/columns/expand_by_partition.ts +187 -0
  118. package/src/columns/index.ts +5 -0
  119. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV2.ts +193 -0
  120. package/src/components/PlDataTable/{table.ts → createPlDataTable/createPlDataTableV3.ts} +134 -70
  121. package/src/components/PlDataTable/createPlDataTable/index.ts +27 -0
  122. package/src/components/PlDataTable/createPlDataTableSheet.ts +20 -0
  123. package/src/components/PlDataTable/index.ts +6 -4
  124. package/src/components/PlDataTable/state-migration.ts +2 -2
  125. package/src/index.ts +2 -1
  126. package/src/labels/derive_distinct_labels.test.ts +461 -0
  127. package/src/labels/derive_distinct_labels.ts +289 -0
  128. package/src/labels/index.ts +2 -0
  129. package/src/labels/write_labels_to_specs.ts +12 -0
  130. package/src/render/api.ts +25 -3
  131. package/src/render/internal.ts +20 -1
  132. package/src/render/util/column_collection.ts +9 -6
  133. package/src/render/util/label.test.ts +1 -1
  134. package/src/render/util/label.ts +19 -235
  135. package/src/render/util/split_selectors.ts +3 -3
  136. package/dist/components/PlDataTable/table.cjs.map +0 -1
  137. package/dist/components/PlDataTable/table.d.ts +0 -30
  138. package/dist/components/PlDataTable/table.js.map +0 -1
  139. /package/src/components/PlDataTable/{v4.ts → typesV4.ts} +0 -0
  140. /package/src/components/PlDataTable/{v5.ts → typesV5.ts} +0 -0
@@ -0,0 +1,55 @@
1
+ import type { PColumnSpec, PObjectId } from "@milaboratories/pl-model-common";
2
+ import type { PColumnDataUniversal } from "../render/internal";
3
+
4
+ // --- ColumnSnapshot ---
5
+
6
+ /** Data status of a column snapshot. */
7
+ export type ColumnDataStatus = "ready" | "computing" | "absent";
8
+
9
+ /**
10
+ * Immutable snapshot of a column: spec, data status, and lazy data accessor.
11
+ *
12
+ * - `dataStatus` is readable without marking the render context unstable.
13
+ * - `data` holds an active object when data exists (ready or computing),
14
+ * or `undefined` when data is permanently absent.
15
+ */
16
+ export interface ColumnSnapshot<Id extends PObjectId = PObjectId> {
17
+ readonly id: Id;
18
+ readonly spec: PColumnSpec;
19
+ readonly dataStatus: ColumnDataStatus;
20
+
21
+ /**
22
+ * Lazy data accessor.
23
+ * - `'ready'`: `data.get()` returns column data, context stays stable.
24
+ * - `'computing'`: `data.get()` returns `undefined`, marks context unstable.
25
+ * - `'absent'`: `data` is `undefined` — no active object, no instability.
26
+ */
27
+ readonly data: ColumnData | undefined;
28
+ }
29
+
30
+ // --- ColumnData ---
31
+
32
+ /**
33
+ * Active object wrapping lazy column data access.
34
+ * Accessing data on a computing column marks the render context unstable.
35
+ */
36
+ export interface ColumnData {
37
+ get(): PColumnDataUniversal | undefined;
38
+ }
39
+
40
+ /** Creates a ColumnData active object for a ready column. */
41
+ export function createReadyColumnData(getData: () => PColumnDataUniversal | undefined): ColumnData {
42
+ return { get: getData };
43
+ }
44
+
45
+ // --- Snapshot construction helpers ---
46
+
47
+ /** Creates a ColumnSnapshot from parts. */
48
+ export function createColumnSnapshot<Id extends PObjectId = PObjectId>(
49
+ id: Id,
50
+ spec: PColumnSpec,
51
+ dataStatus: ColumnDataStatus,
52
+ data: ColumnData | undefined,
53
+ ): ColumnSnapshot<Id> {
54
+ return { id, spec, dataStatus, data };
55
+ }
@@ -0,0 +1,177 @@
1
+ import { PColumn } from "@milaboratories/pl-model-common";
2
+ import { TreeNodeAccessor } from "../render/accessor";
3
+ import type { PColumnDataUniversal } from "../render/internal";
4
+ import type { ColumnDataStatus, ColumnSnapshot } from "./column_snapshot";
5
+
6
+ // --- ColumnProvider ---
7
+
8
+ /**
9
+ * Data source interface for column enumeration.
10
+ *
11
+ * Knows nothing about the render framework, stability tracking, labels,
12
+ * anchoring, or splitting. All that complexity lives in the collection layer.
13
+ */
14
+ export interface ColumnSnapshotProvider {
15
+ /** Returns all currently known columns. */
16
+ getAllColumns(): ColumnSnapshot[];
17
+
18
+ /** Whether the provider has finished enumerating all its columns.
19
+ * Calling this may mark the render context unstable — it touches
20
+ * the reactive tree to check field resolution state. */
21
+ isColumnListComplete(): boolean;
22
+ }
23
+
24
+ // --- ColumnSource ---
25
+
26
+ /**
27
+ * Union of types that can serve as column sources for helpers and builders.
28
+ * Does NOT include TreeNodeAccessor — call `.toColumnSource()` on it first.
29
+ */
30
+ export type ColumnSource =
31
+ | ColumnSnapshotProvider
32
+ | ColumnSnapshot[]
33
+ | PColumn<PColumnDataUniversal | undefined>[];
34
+
35
+ // --- ArrayColumnProvider ---
36
+
37
+ /**
38
+ * Simple provider wrapping an array of PColumns.
39
+ * Always complete, data status always 'ready'.
40
+ */
41
+ export class ArrayColumnProvider implements ColumnSnapshotProvider {
42
+ private readonly columns: ColumnSnapshot[];
43
+
44
+ constructor(columns: PColumn<PColumnDataUniversal | undefined>[]) {
45
+ this.columns = columns.map((col) => ({
46
+ id: col.id,
47
+ spec: col.spec,
48
+ dataStatus: "ready" as const,
49
+ data: { get: () => col.data },
50
+ }));
51
+ }
52
+
53
+ getAllColumns(): ColumnSnapshot[] {
54
+ return this.columns;
55
+ }
56
+
57
+ isColumnListComplete(): boolean {
58
+ return true;
59
+ }
60
+ }
61
+
62
+ // --- SnapshotColumnProvider ---
63
+
64
+ /**
65
+ * Provider wrapping an array of ColumnSnapshots.
66
+ * Always complete. Data status taken from each snapshot.
67
+ */
68
+ export class SnapshotColumnProvider implements ColumnSnapshotProvider {
69
+ constructor(private readonly snapshots: ColumnSnapshot[]) {}
70
+
71
+ getAllColumns(): ColumnSnapshot[] {
72
+ return this.snapshots;
73
+ }
74
+
75
+ isColumnListComplete(): boolean {
76
+ return true;
77
+ }
78
+ }
79
+
80
+ // --- OutputColumnProvider ---
81
+
82
+ export interface OutputColumnProviderOpts {
83
+ /** When true and the accessor is final, columns with no ready data get status 'absent'. */
84
+ allowPermanentAbsence?: boolean;
85
+ }
86
+
87
+ /**
88
+ * Provider wrapping a TreeNodeAccessor (output/prerun resolve result).
89
+ * Detects data status from accessor readiness state.
90
+ */
91
+ export class OutputColumnProvider implements ColumnSnapshotProvider {
92
+ constructor(
93
+ private readonly accessor: TreeNodeAccessor,
94
+ private readonly opts?: OutputColumnProviderOpts,
95
+ ) {}
96
+
97
+ getAllColumns(): ColumnSnapshot[] {
98
+ return this.getColumns();
99
+ }
100
+
101
+ isColumnListComplete(): boolean {
102
+ return this.accessor.getInputsLocked();
103
+ }
104
+
105
+ private getColumns(): ColumnSnapshot[] {
106
+ const pColumns = this.accessor.getPColumns();
107
+ if (pColumns === undefined) return [];
108
+
109
+ const isFinal = this.accessor.getIsFinal();
110
+ const allowAbsence = this.opts?.allowPermanentAbsence === true;
111
+
112
+ return pColumns.map((col) => {
113
+ const dataAccessor = col.data;
114
+ const isReady = dataAccessor.getIsReadyOrError();
115
+
116
+ let dataStatus: ColumnDataStatus;
117
+ if (isReady) {
118
+ dataStatus = "ready";
119
+ } else if (allowAbsence && isFinal) {
120
+ dataStatus = "absent";
121
+ } else {
122
+ dataStatus = "computing";
123
+ }
124
+
125
+ return {
126
+ id: col.id,
127
+ spec: col.spec,
128
+ dataStatus,
129
+ data: { get: () => (isReady ? dataAccessor : undefined) },
130
+ };
131
+ });
132
+ }
133
+ }
134
+
135
+ // --- Source normalization ---
136
+
137
+ /** Checks if a value is a ColumnSnapshotProvider (duck-typing). */
138
+ export function isColumnSnapshotProvider(source: unknown): source is ColumnSnapshotProvider {
139
+ return (
140
+ typeof source === "object" &&
141
+ source !== null &&
142
+ "getAllColumns" in source &&
143
+ "isColumnListComplete" in source &&
144
+ typeof (source as ColumnSnapshotProvider).getAllColumns === "function" &&
145
+ typeof (source as ColumnSnapshotProvider).isColumnListComplete === "function"
146
+ );
147
+ }
148
+
149
+ /** Checks if a value looks like a PColumn (has id, spec, data). */
150
+ function isPColumnArray(source: unknown): source is PColumn<PColumnDataUniversal | undefined>[] {
151
+ if (!Array.isArray(source)) return false;
152
+ if (source.length === 0) return true; // empty array — treat as PColumn[]
153
+ const first = source[0];
154
+ return "id" in first && "spec" in first && "data" in first && !("dataStatus" in first);
155
+ }
156
+
157
+ /** Checks if a value looks like a ColumnSnapshot array. */
158
+ function isColumnSnapshotArray(source: unknown): source is ColumnSnapshot[] {
159
+ if (!Array.isArray(source)) return false;
160
+ if (source.length === 0) return true; // empty array — treat as snapshots
161
+ const first = source[0];
162
+ return "id" in first && "spec" in first && "dataStatus" in first;
163
+ }
164
+
165
+ /**
166
+ * Normalize any ColumnSource into a ColumnSnapshotProvider.
167
+ * - ColumnSnapshotProvider → returned as-is
168
+ * - ColumnSnapshot[] → wrapped in SnapshotColumnProvider
169
+ * - PColumn[] → wrapped in ArrayColumnProvider
170
+ */
171
+ export function toColumnSnapshotProvider(source: ColumnSource): ColumnSnapshotProvider {
172
+ if (isColumnSnapshotProvider(source)) return source;
173
+ if (isColumnSnapshotArray(source)) return new SnapshotColumnProvider(source);
174
+ if (isPColumnArray(source)) return new ArrayColumnProvider(source);
175
+ // Should not reach here given the type union, but be safe
176
+ throw new Error("Unknown ColumnSource type");
177
+ }
@@ -0,0 +1,107 @@
1
+ import type { PColumnSpec, PObjectId } from "@milaboratories/pl-model-common";
2
+ import { TreeNodeAccessor } from "../render/accessor";
3
+ import type { RenderCtxBase, ResultPool } from "../render";
4
+ import type { ColumnSnapshot } from "./column_snapshot";
5
+ import type { ColumnDataStatus } from "./column_snapshot";
6
+ import type { ColumnSnapshotProvider } from "./column_snapshot_provider";
7
+ import { OutputColumnProvider } from "./column_snapshot_provider";
8
+ import { ResourceTypeName } from "@milaboratories/pl-model-common";
9
+ import type { ValueOf } from "@milaboratories/helpers";
10
+
11
+ /**
12
+ * Collect ColumnSnapshotProviders from all render context sources:
13
+ *
14
+ * - **resultPool** — all upstream columns (always included)
15
+ * - **outputs** — PFrame fields from block execution outputs
16
+ * - **prerun** — PFrame fields from prerun/staging results
17
+ *
18
+ * Returns an array of providers suitable for `ColumnCollectionBuilder.addSource()`.
19
+ */
20
+ export function collectCtxColumnSnapshotProviders<A, U>(
21
+ ctx: RenderCtxBase<A, U>,
22
+ ): ColumnSnapshotProvider[] {
23
+ const providers: ColumnSnapshotProvider[] = [];
24
+
25
+ // ResultPool — all upstream columns
26
+ providers.push(new ResultPoolColumnSnapshotProvider(ctx.resultPool));
27
+
28
+ // Outputs — each PFrame-like output field becomes a provider
29
+ const outputs = ctx.outputs;
30
+ if (outputs) {
31
+ providers.push(...collectPFrameProviders(outputs));
32
+ }
33
+
34
+ // Prerun — same treatment as outputs
35
+ const prerun = ctx.prerun;
36
+ if (prerun) {
37
+ providers.push(...collectPFrameProviders(prerun));
38
+ }
39
+
40
+ return providers;
41
+ }
42
+
43
+ /**
44
+ * Adapter wrapping ResultPool into the new ColumnSnapshotProvider interface.
45
+ *
46
+ * - `isColumnListComplete()` always returns true — the result pool
47
+ * is a stable snapshot within a single render cycle.
48
+ * - Data status is derived from the underlying TreeNodeAccessor:
49
+ * ready (getIsReadyOrError), computing, or absent (no data resource).
50
+ */
51
+ export class ResultPoolColumnSnapshotProvider implements ColumnSnapshotProvider {
52
+ constructor(private readonly pool: ResultPool) {}
53
+
54
+ getAllColumns(): ColumnSnapshot[] {
55
+ const pColumns = this.pool.selectColumns(() => true);
56
+ return pColumns.map((col) => toSnapshot(col.id, col.spec, col.data));
57
+ }
58
+
59
+ isColumnListComplete(): boolean {
60
+ return true;
61
+ }
62
+ }
63
+
64
+ function toSnapshot(
65
+ id: PObjectId,
66
+ spec: PColumnSpec,
67
+ accessor: TreeNodeAccessor | undefined,
68
+ ): ColumnSnapshot {
69
+ if (accessor === undefined) {
70
+ return { id, spec, dataStatus: "absent" as ColumnDataStatus, data: undefined };
71
+ }
72
+ const isReady = accessor.getIsReadyOrError();
73
+ return {
74
+ id,
75
+ spec,
76
+ dataStatus: (isReady ? "ready" : "computing") as ColumnDataStatus,
77
+ data: { get: () => (isReady ? accessor : undefined) },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Recursively walk the output tree starting from `accessor`.
83
+ * - If a node's resourceType is PFrame → wrap it as OutputColumnProvider.
84
+ * - If a node's resourceType is StdMap/std/map → recurse into its output fields.
85
+ * - Otherwise → skip (leaf of unknown type).
86
+ */
87
+ function collectPFrameProviders(accessor: TreeNodeAccessor): ColumnSnapshotProvider[] {
88
+ const out: ColumnSnapshotProvider[] = [];
89
+ walkTree(accessor, out);
90
+ return out;
91
+ }
92
+
93
+ function walkTree(node: TreeNodeAccessor, out: ColumnSnapshotProvider[]): void {
94
+ const typeName = node.resourceType.name as ValueOf<typeof ResourceTypeName>;
95
+
96
+ if (typeName === ResourceTypeName.PFrame) {
97
+ out.push(new OutputColumnProvider(node));
98
+ return;
99
+ }
100
+
101
+ if (typeName === ResourceTypeName.StdMap || typeName === ResourceTypeName.StdMapSlash) {
102
+ for (const field of node.listInputFields()) {
103
+ const child = node.resolve(field);
104
+ if (child) walkTree(child, out);
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,289 @@
1
+ import type {
2
+ AxisSpec,
3
+ JsonPartitionedDataInfoEntries,
4
+ PColumnSpec,
5
+ PObjectId,
6
+ } from "@milaboratories/pl-model-common";
7
+ import { canonicalizeAxisId, getAxisId } from "@milaboratories/pl-model-common";
8
+ import { describe, expect, test } from "vitest";
9
+ import type { PColumnDataUniversal } from "../render/internal";
10
+ import type { ColumnSnapshot } from "./column_snapshot";
11
+ import { expandByPartition } from "./expand_by_partition";
12
+
13
+ // --- Helpers ---
14
+
15
+ function createAxis(name: string, type = "String"): AxisSpec {
16
+ return { name, type } as AxisSpec;
17
+ }
18
+
19
+ function createSpec(name: string, axes: AxisSpec[]): PColumnSpec {
20
+ return {
21
+ kind: "PColumn",
22
+ name,
23
+ valueType: "Int",
24
+ axesSpec: axes,
25
+ annotations: {},
26
+ } as PColumnSpec;
27
+ }
28
+
29
+ /** Create a ready snapshot whose data.get() returns a JsonPartitioned DataInfoEntries. */
30
+ function createReadySnapshot(
31
+ id: string,
32
+ columnSpec: PColumnSpec,
33
+ partitionKeyLength: number,
34
+ parts: { key: (string | number)[]; value: unknown }[],
35
+ ): ColumnSnapshot {
36
+ const dataEntries: JsonPartitionedDataInfoEntries<unknown> = {
37
+ type: "JsonPartitioned",
38
+ partitionKeyLength,
39
+ parts,
40
+ };
41
+ return {
42
+ id: id as PObjectId,
43
+ spec: columnSpec,
44
+ dataStatus: "ready",
45
+ // convertOrParsePColumnData checks isDataInfoEntries first (duck-type),
46
+ // so this works at runtime despite the PColumnDataUniversal type
47
+ data: { get: () => dataEntries as unknown as PColumnDataUniversal },
48
+ };
49
+ }
50
+
51
+ function createComputingSnapshot(id: string, columnSpec: PColumnSpec): ColumnSnapshot {
52
+ return {
53
+ id: id as PObjectId,
54
+ spec: columnSpec,
55
+ dataStatus: "computing",
56
+ data: {
57
+ get: () => undefined,
58
+ },
59
+ };
60
+ }
61
+
62
+ function createAbsentSnapshot(id: string, columnSpec: PColumnSpec): ColumnSnapshot {
63
+ return {
64
+ id: id as PObjectId,
65
+ spec: columnSpec,
66
+ dataStatus: "absent",
67
+ data: undefined,
68
+ };
69
+ }
70
+
71
+ interface Trace {
72
+ type: string;
73
+ label: string;
74
+ importance: number;
75
+ }
76
+
77
+ function extractTrace(snapshot: ColumnSnapshot): Trace[] {
78
+ const raw = snapshot.spec.annotations?.["pl7.app/trace"];
79
+ return raw ? (JSON.parse(raw) as Trace[]) : [];
80
+ }
81
+
82
+ // --- Tests ---
83
+
84
+ describe("expandByPartition", () => {
85
+ test("no split axes returns snapshots as-is", () => {
86
+ const s = createReadySnapshot("col1", createSpec("c", [createAxis("a")]), 1, [
87
+ { key: ["x"], value: {} },
88
+ ]);
89
+ const result = expandByPartition([s], []);
90
+ expect(result.complete).toBe(true);
91
+ expect(result.items).toHaveLength(1);
92
+ expect(result.items[0]).toBe(s); // same reference
93
+ });
94
+
95
+ test("single axis split produces K snapshots", () => {
96
+ const s = createReadySnapshot(
97
+ "col1",
98
+ createSpec("c", [createAxis("sample"), createAxis("gene")]),
99
+ 2,
100
+ [
101
+ { key: ["s1", "g1"], value: {} },
102
+ { key: ["s1", "g2"], value: {} },
103
+ { key: ["s2", "g1"], value: {} },
104
+ ],
105
+ );
106
+
107
+ const result = expandByPartition([s], [{ idx: 0 }]);
108
+
109
+ expect(result.complete).toBe(true);
110
+ // unique values on axis 0: s1, s2 → 2 snapshots
111
+ expect(result.items).toHaveLength(2);
112
+
113
+ // split axis removed from axesSpec
114
+ for (const snap of result.items) {
115
+ expect(snap.spec.axesSpec).toHaveLength(1);
116
+ expect(snap.spec.axesSpec[0].name).toBe("gene");
117
+ }
118
+ });
119
+
120
+ test("multi-axis split produces K1 x K2 snapshots", () => {
121
+ const s = createReadySnapshot(
122
+ "col1",
123
+ createSpec("c", [createAxis("a"), createAxis("b"), createAxis("value")]),
124
+ 2,
125
+ [
126
+ { key: ["a1", "b1"], value: {} },
127
+ { key: ["a1", "b2"], value: {} },
128
+ { key: ["a2", "b1"], value: {} },
129
+ ],
130
+ );
131
+
132
+ const result = expandByPartition([s], [{ idx: 0 }, { idx: 1 }]);
133
+
134
+ expect(result.complete).toBe(true);
135
+ // a: a1, a2 (2) × b: b1, b2 (2) = 4
136
+ expect(result.items).toHaveLength(4);
137
+
138
+ // both split axes removed, only "value" remains
139
+ for (const snap of result.items) {
140
+ expect(snap.spec.axesSpec).toHaveLength(1);
141
+ expect(snap.spec.axesSpec[0].name).toBe("value");
142
+ }
143
+ });
144
+
145
+ test("trace annotations include split info", () => {
146
+ const s = createReadySnapshot(
147
+ "col1",
148
+ createSpec("c", [createAxis("sample"), createAxis("gene")]),
149
+ 1,
150
+ [
151
+ { key: ["s1"], value: {} },
152
+ { key: ["s2"], value: {} },
153
+ ],
154
+ );
155
+
156
+ const result = expandByPartition([s], [{ idx: 0 }]);
157
+ expect(result.complete).toBe(true);
158
+ expect(result.items).toHaveLength(2);
159
+
160
+ const trace0 = extractTrace(result.items[0]);
161
+ expect(trace0).toEqual([
162
+ {
163
+ type: `split:${canonicalizeAxisId(getAxisId(createAxis("sample")))}`,
164
+ label: "s1",
165
+ importance: 1_000_000,
166
+ },
167
+ ]);
168
+
169
+ const trace1 = extractTrace(result.items[1]);
170
+ expect(trace1).toEqual([
171
+ {
172
+ type: `split:${canonicalizeAxisId(getAxisId(createAxis("sample")))}`,
173
+ label: "s2",
174
+ importance: 1_000_000,
175
+ },
176
+ ]);
177
+ });
178
+
179
+ test("axisLabels option resolves labels", () => {
180
+ const sampleAxis = createAxis("sample");
181
+ const s = createReadySnapshot("col1", createSpec("c", [sampleAxis, createAxis("gene")]), 1, [
182
+ { key: ["s1"], value: {} },
183
+ { key: ["s2"], value: {} },
184
+ ]);
185
+
186
+ const labels: Record<string | number, string> = {
187
+ s1: "Sample One",
188
+ s2: "Sample Two",
189
+ };
190
+
191
+ const result = expandByPartition([s], [{ idx: 0 }], {
192
+ axisLabels: () => labels,
193
+ });
194
+
195
+ expect(result.complete).toBe(true);
196
+ const trace0 = extractTrace(result.items[0]);
197
+ expect(trace0[0].label).toBe("Sample One");
198
+ const trace1 = extractTrace(result.items[1]);
199
+ expect(trace1[0].label).toBe("Sample Two");
200
+ });
201
+
202
+ test("computing snapshot returns incomplete", () => {
203
+ const s = createComputingSnapshot("col1", createSpec("c", [createAxis("a")]));
204
+ const result = expandByPartition([s], [{ idx: 0 }]);
205
+ expect(result.complete).toBe(false);
206
+ expect(result.items).toHaveLength(0);
207
+ });
208
+
209
+ test("absent snapshot returns incomplete", () => {
210
+ const s = createAbsentSnapshot("col1", createSpec("c", [createAxis("a")]));
211
+ const result = expandByPartition([s], [{ idx: 0 }]);
212
+ expect(result.complete).toBe(false);
213
+ expect(result.items).toHaveLength(0);
214
+ });
215
+
216
+ test("empty unique keys for an axis produces no snapshots for that column", () => {
217
+ const s = createReadySnapshot(
218
+ "col1",
219
+ createSpec("c", [createAxis("a"), createAxis("b")]),
220
+ 2,
221
+ [], // no parts → no unique keys
222
+ );
223
+
224
+ const result = expandByPartition([s], [{ idx: 0 }]);
225
+ expect(result.complete).toBe(true);
226
+ expect(result.items).toHaveLength(0);
227
+ });
228
+
229
+ test("filtered data is accessible on expanded snapshots", () => {
230
+ const s = createReadySnapshot(
231
+ "col1",
232
+ createSpec("c", [createAxis("sample"), createAxis("gene")]),
233
+ 1,
234
+ [
235
+ { key: ["s1"], value: { payload: "data-s1" } },
236
+ { key: ["s2"], value: { payload: "data-s2" } },
237
+ ],
238
+ );
239
+
240
+ const result = expandByPartition([s], [{ idx: 0 }]);
241
+ expect(result.complete).toBe(true);
242
+ expect(result.items).toHaveLength(2);
243
+
244
+ // Each expanded snapshot should have accessible data
245
+ for (const snap of result.items) {
246
+ expect(snap.data).toBeDefined();
247
+ const data = snap.data!.get();
248
+ expect(data).toBeDefined();
249
+ }
250
+ });
251
+
252
+ test("multiple input snapshots are all expanded", () => {
253
+ const s1 = createReadySnapshot(
254
+ "col1",
255
+ createSpec("c1", [createAxis("sample"), createAxis("gene")]),
256
+ 1,
257
+ [
258
+ { key: ["s1"], value: {} },
259
+ { key: ["s2"], value: {} },
260
+ ],
261
+ );
262
+ const s2 = createReadySnapshot(
263
+ "col2",
264
+ createSpec("c2", [createAxis("sample"), createAxis("gene")]),
265
+ 1,
266
+ [
267
+ { key: ["x1"], value: {} },
268
+ { key: ["x2"], value: {} },
269
+ { key: ["x3"], value: {} },
270
+ ],
271
+ );
272
+
273
+ const result = expandByPartition([s1, s2], [{ idx: 0 }]);
274
+ expect(result.complete).toBe(true);
275
+ // s1: 2 unique + s2: 3 unique = 5 total
276
+ expect(result.items).toHaveLength(5);
277
+ });
278
+
279
+ test("throws when split axis exceeds partition key length", () => {
280
+ const s = createReadySnapshot(
281
+ "col1",
282
+ createSpec("c", [createAxis("a"), createAxis("b"), createAxis("c")]),
283
+ 1, // only 1 partition key
284
+ [{ key: ["x"], value: {} }],
285
+ );
286
+
287
+ expect(() => expandByPartition([s], [{ idx: 1 }])).toThrow(/Not enough partition keys/);
288
+ });
289
+ });