@moloodev/table-sdk 0.0.0-stage → 0.1.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,29 @@
1
1
  {
2
2
  "name": "@moloodev/table-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "dependencies": {
9
+ "@moloodev/editor-sdk": "0.1.0"
10
+ },
11
+ "devDependencies": {
12
+ "@types/node": "^24.13.3",
13
+ "tsx": "^4.23.13",
14
+ "typescript": "^7.0.2",
15
+ "@moloodev/typescript-config": "0.1.0"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "!src/**/*.test.ts",
23
+ "!src/**/*.test.tsx"
24
+ ],
25
+ "scripts": {
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "tsx --test 'src/**/*.test.ts'"
28
+ }
6
29
  }
package/src/asset.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { isAssetColumnType } from "./types";
2
+ import type { AssetRef, Table } from "./types";
3
+
4
+ export function isAssetRef(value: unknown): value is AssetRef {
5
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
6
+ return false;
7
+ }
8
+ const ref = value as Record<string, unknown>;
9
+ return (
10
+ typeof ref.id === "string" &&
11
+ ref.id !== "" &&
12
+ typeof ref.name === "string"
13
+ );
14
+ }
15
+
16
+ export function assetRef(id: string, name = ""): AssetRef {
17
+ return { id, name };
18
+ }
19
+
20
+ export function refreshAssetNames(
21
+ document: Table,
22
+ names: ReadonlyMap<string, string>,
23
+ ): Table {
24
+ let changed = false;
25
+ const rows = document.rows.map((row) => {
26
+ let next = row;
27
+ for (const column of document.columns) {
28
+ if (!isAssetColumnType(column.type)) continue;
29
+ const cell = row.cells[column.id];
30
+ if (!isAssetRef(cell)) continue;
31
+ const name = names.get(cell.id);
32
+ if (name == null || cell.name === name) continue;
33
+ if (next === row) next = { ...row, cells: { ...row.cells } };
34
+ next.cells[column.id] = assetRef(cell.id, name);
35
+ changed = true;
36
+ }
37
+ return next;
38
+ });
39
+ return changed ? { ...document, rows } : document;
40
+ }
package/src/bind.ts ADDED
@@ -0,0 +1,306 @@
1
+ import {
2
+ createArray,
3
+ createDoc,
4
+ createMap,
5
+ isArray,
6
+ isMap,
7
+ rootMap,
8
+ stopCapturing,
9
+ transact,
10
+ writeJson,
11
+ type Doc,
12
+ type SharedArray,
13
+ type SharedMap,
14
+ } from "@moloodev/editor-sdk/crdt";
15
+ import { cellsEqual } from "./coerce";
16
+ import { createBlank, parse } from "./parse";
17
+ import {
18
+ INDEX_SORT_ASC,
19
+ normalizeSorting,
20
+ type ColumnSort,
21
+ type SortingState,
22
+ } from "./sort";
23
+ import {
24
+ TABLE_VERSION,
25
+ type CellValue,
26
+ type Table,
27
+ type TableColumn,
28
+ type TableRow,
29
+ } from "./types";
30
+
31
+ /** CRDT-only rank. Shared types cannot move; project sorts by this. */
32
+ const ORDER_KEY = "#ord";
33
+ const SORT_KEY = "#sort";
34
+ const PRIMARY_KEY = "primaryKeyColumnId";
35
+
36
+ export function projectTable(doc: Doc): Table {
37
+ const root = rootMap(doc).toJSON() as Record<string, unknown>;
38
+ // An unseeded document is editable; other malformed documents must stay intact.
39
+ if (Object.keys(root).every((key) => key === SORT_KEY)) return createBlank();
40
+ return parse({
41
+ ...root,
42
+ version: root.version === undefined ? TABLE_VERSION : root.version,
43
+ columns: Array.isArray(root.columns) ? listed(root.columns) : root.columns,
44
+ rows: Array.isArray(root.rows) ? listed(root.rows) : root.rows,
45
+ });
46
+ }
47
+
48
+ function listed(raw: unknown): unknown[] {
49
+ if (!Array.isArray(raw)) return [];
50
+ return raw
51
+ .map((item, index) => ({ item, index, key: orderKey(item, index) }))
52
+ .sort((left, right) => left.key - right.key || left.index - right.index)
53
+ .map((entry) => entry.item);
54
+ }
55
+
56
+ function orderKey(item: unknown, fallback: number): number {
57
+ if (!item || typeof item !== "object" || Array.isArray(item)) return fallback;
58
+ const value = (item as Record<string, unknown>)[ORDER_KEY];
59
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
60
+ }
61
+
62
+ export function projectSorting(
63
+ doc: Doc,
64
+ columnIds: readonly string[],
65
+ ): SortingState {
66
+ const raw = (rootMap(doc).toJSON() as Record<string, unknown>)[SORT_KEY];
67
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
68
+ return [INDEX_SORT_ASC];
69
+ }
70
+ const id = (raw as Record<string, unknown>).id;
71
+ const desc = (raw as Record<string, unknown>).desc;
72
+ if (typeof id !== "string") return [INDEX_SORT_ASC];
73
+ return normalizeSorting([{ id, desc: Boolean(desc) }], columnIds);
74
+ }
75
+
76
+ export function applySortingChange(
77
+ doc: Doc,
78
+ sorting: SortingState,
79
+ columnIds: readonly string[],
80
+ ): SortingState {
81
+ projectTable(doc);
82
+ const next = normalizeSorting(sorting, columnIds);
83
+ const current = projectSorting(doc, columnIds);
84
+ if (current[0]?.id === next[0]?.id && current[0]?.desc === next[0]?.desc) {
85
+ return next;
86
+ }
87
+ transact(doc, () => {
88
+ writeSorting(rootMap(doc), next[0] ?? INDEX_SORT_ASC);
89
+ });
90
+ stopCapturing(doc);
91
+ return next;
92
+ }
93
+
94
+ function writePrimaryKey(root: SharedMap, columnId: string | undefined): void {
95
+ if (columnId == null) {
96
+ if (root.has(PRIMARY_KEY)) root.delete(PRIMARY_KEY);
97
+ return;
98
+ }
99
+ if (root.get(PRIMARY_KEY) !== columnId) root.set(PRIMARY_KEY, columnId);
100
+ }
101
+
102
+ function writeSorting(root: SharedMap, sort: ColumnSort): void {
103
+ let map = root.get(SORT_KEY);
104
+ if (!isMap(map)) {
105
+ map = createMap();
106
+ root.set(SORT_KEY, map);
107
+ }
108
+ const sortMap = map as SharedMap;
109
+ if (sortMap.get("id") !== sort.id) sortMap.set("id", sort.id);
110
+ if (sortMap.get("desc") !== sort.desc) sortMap.set("desc", sort.desc);
111
+ }
112
+
113
+ export function seedTable(doc: Doc, table: Table): void {
114
+ projectTable(doc);
115
+ writeJson(doc, parse(table) as unknown as Record<string, unknown>);
116
+ }
117
+
118
+ export function tableDoc(table: Table): Doc {
119
+ const doc = createDoc();
120
+ seedTable(doc, table);
121
+ return doc;
122
+ }
123
+
124
+ function ensureArray(root: SharedMap, key: string): SharedArray {
125
+ const existing = root.get(key);
126
+ if (isArray(existing)) return existing;
127
+ const next = createArray();
128
+ root.set(key, next);
129
+ return next;
130
+ }
131
+
132
+ function asMap(value: unknown): SharedMap | null {
133
+ return isMap(value) ? value : null;
134
+ }
135
+
136
+ function writeColumn(
137
+ map: SharedMap,
138
+ column: TableColumn,
139
+ before?: TableColumn,
140
+ ): void {
141
+ for (const key of ["id", "type", "width"] as const) {
142
+ if (before && before[key] === column[key]) continue;
143
+ if (column[key] === undefined) map.delete(key);
144
+ else setIfChanged(map, key, column[key]);
145
+ }
146
+ }
147
+
148
+ function writeRow(map: SharedMap, row: TableRow, before?: TableRow): void {
149
+ if (!before) setIfChanged(map, "id", row.id);
150
+ let cells = map.get("cells");
151
+ if (!isMap(cells)) {
152
+ cells = createMap();
153
+ map.set("cells", cells);
154
+ }
155
+ const cellMap = cells as SharedMap;
156
+ // Only keys visible in the before state can express an intentional deletion.
157
+ for (const key of Object.keys(before?.cells ?? {})) {
158
+ if (!Object.hasOwn(row.cells, key)) cellMap.delete(key);
159
+ }
160
+ for (const [key, value] of Object.entries(row.cells)) {
161
+ if (
162
+ before &&
163
+ Object.hasOwn(before.cells, key) &&
164
+ cellsEqual(before.cells[key]!, value)
165
+ )
166
+ continue;
167
+ setIfChanged(cellMap, key, value);
168
+ }
169
+ }
170
+
171
+ function setIfChanged(map: SharedMap, key: string, value: unknown): void {
172
+ const current = map.get(key);
173
+ if (current === value) return;
174
+ if (
175
+ isMap(current) &&
176
+ cellsEqual(current.toJSON() as CellValue, value as CellValue)
177
+ ) {
178
+ return;
179
+ }
180
+ if (cellsEqual(current as CellValue, value as CellValue)) return;
181
+ map.set(key, value);
182
+ }
183
+
184
+ function idOf(value: unknown): string | null {
185
+ const map = asMap(value);
186
+ const id = map?.get("id");
187
+ return typeof id === "string" ? id : null;
188
+ }
189
+
190
+ function reconcile<T extends { id: string }>(
191
+ arr: SharedArray,
192
+ before: readonly T[],
193
+ next: readonly T[],
194
+ write: (map: SharedMap, item: T, before?: T) => void,
195
+ ): void {
196
+ const byId = new Map<string, SharedMap>();
197
+ for (let i = 0; i < arr.length; i += 1) {
198
+ const id = idOf(arr.get(i));
199
+ const map = asMap(arr.get(i));
200
+ if (id && map) byId.set(id, map);
201
+ }
202
+
203
+ const previous = new Map(before.map((item) => [item.id, item]));
204
+ const orderChanged =
205
+ before.length !== next.length ||
206
+ before.some((item, index) => item.id !== next[index]?.id);
207
+ const keep = new Set(next.map((item) => item.id));
208
+ for (let i = arr.length - 1; i >= 0; i -= 1) {
209
+ const id = idOf(arr.get(i));
210
+ if (id && previous.has(id) && !keep.has(id)) arr.delete(i, 1);
211
+ }
212
+
213
+ for (let i = 0; i < next.length; i += 1) {
214
+ const item = next[i]!;
215
+ let map = byId.get(item.id);
216
+ if (!map && previous.has(item.id)) continue;
217
+ if (!map) {
218
+ map = createMap();
219
+ arr.push([map]);
220
+ byId.set(item.id, map);
221
+ }
222
+ write(map, item, previous.get(item.id));
223
+ if (orderChanged && map.get(ORDER_KEY) !== i) map.set(ORDER_KEY, i);
224
+ }
225
+ }
226
+
227
+ function writeTablePatch(
228
+ doc: Doc,
229
+ before: Table,
230
+ next: Table,
231
+ sorting?: SortingState,
232
+ ): void {
233
+ const root = rootMap(doc);
234
+ if (!root.has("version")) root.set("version", TABLE_VERSION);
235
+ const columns = ensureArray(root, "columns");
236
+ reconcile(columns, before.columns, next.columns, writeColumn);
237
+ const liveColumnIds = new Set<string>();
238
+ for (let index = 0; index < columns.length; index += 1) {
239
+ const id = idOf(columns.get(index));
240
+ if (id) liveColumnIds.add(id);
241
+ }
242
+ const nextColumnIds = new Set(next.columns.map((column) => column.id));
243
+ reconcile(
244
+ ensureArray(root, "rows"),
245
+ before.rows,
246
+ next.rows,
247
+ (map, row, previous) => {
248
+ // A stale cell edit must not resurrect a column another replica removed.
249
+ const cells = Object.fromEntries(
250
+ Object.entries(row.cells).filter(([id]) => liveColumnIds.has(id)),
251
+ );
252
+ const previousRow = previous
253
+ ? {
254
+ ...previous,
255
+ cells: Object.fromEntries(
256
+ Object.entries(previous.cells).filter(
257
+ ([id]) => liveColumnIds.has(id) || !nextColumnIds.has(id),
258
+ ),
259
+ ),
260
+ }
261
+ : undefined;
262
+ writeRow(map, { ...row, cells }, previousRow);
263
+ },
264
+ );
265
+ if (before.primaryKeyColumnId !== next.primaryKeyColumnId) {
266
+ if (
267
+ next.primaryKeyColumnId === undefined ||
268
+ liveColumnIds.has(next.primaryKeyColumnId)
269
+ ) {
270
+ writePrimaryKey(root, next.primaryKeyColumnId);
271
+ }
272
+ }
273
+ if (sorting) {
274
+ writeSorting(
275
+ root,
276
+ normalizeSorting(sorting, [...liveColumnIds])[0] ?? INDEX_SORT_ASC,
277
+ );
278
+ }
279
+ }
280
+
281
+ /** Apply intent computed from a rendered snapshot to the current shared document. */
282
+ export function applyTablePatch(
283
+ doc: Doc,
284
+ before: Table,
285
+ after: Table,
286
+ sorting?: SortingState,
287
+ ): Table {
288
+ projectTable(doc);
289
+ parse(before);
290
+ parse(after);
291
+ transact(doc, () => writeTablePatch(doc, before, after, sorting));
292
+ stopCapturing(doc);
293
+ return projectTable(doc);
294
+ }
295
+
296
+ export function applyTableChange(
297
+ doc: Doc,
298
+ compute: (table: Table) => Table,
299
+ sorting?: SortingState,
300
+ ): Table {
301
+ const fresh = projectTable(doc);
302
+ // Keep an independent baseline even if the callback mutates its argument.
303
+ const before = structuredClone(fresh);
304
+ const next = compute(fresh);
305
+ return applyTablePatch(doc, before, next, sorting);
306
+ }
package/src/coerce.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { assetRef } from "./asset";
2
+ import { coerceDate, coerceTime, coerceTimestamp } from "./timestamp";
3
+ import { isAssetColumnType } from "./types";
4
+ import type { AssetRef, CellValue, ColumnType, JsonValue } from "./types";
5
+
6
+ export function coerceCell(type: ColumnType, value: unknown): CellValue {
7
+ if (value === null || value === undefined) return null;
8
+ if (isAssetColumnType(type)) return coerceAsset(value);
9
+ switch (type) {
10
+ case "text":
11
+ if (typeof value === "string") return value;
12
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
13
+ if (typeof value === "boolean") return value ? "true" : "false";
14
+ if (isJsonValue(value)) return stringifyJson(value);
15
+ return null;
16
+ case "integer":
17
+ return coerceInteger(value);
18
+ case "float":
19
+ return coerceFloat(value);
20
+ case "boolean":
21
+ if (typeof value === "boolean") return value;
22
+ if (value === "true" || value === 1) return true;
23
+ if (value === "false" || value === 0) return false;
24
+ return null;
25
+ case "json":
26
+ return coerceJson(value);
27
+ case "date":
28
+ return coerceDate(value);
29
+ case "time":
30
+ return coerceTime(value);
31
+ case "timestamp":
32
+ return coerceTimestamp(value);
33
+ }
34
+ }
35
+
36
+ export function cellsEqual(left: CellValue, right: CellValue): boolean {
37
+ if (left === right) return true;
38
+ if (left == null || right == null) return false;
39
+ if (typeof left !== "object" || typeof right !== "object") return false;
40
+ return stringifyJson(left) === stringifyJson(right);
41
+ }
42
+
43
+ export function stringifyJson(value: JsonValue): string {
44
+ return JSON.stringify(value);
45
+ }
46
+
47
+ function coerceInteger(value: unknown): number | null {
48
+ if (typeof value === "boolean") return value ? 1 : 0;
49
+ if (typeof value === "number" && Number.isFinite(value)) return Math.floor(value);
50
+ if (typeof value === "string" && value.trim() !== "") {
51
+ const parsed = Number(value);
52
+ return Number.isFinite(parsed) ? Math.floor(parsed) : null;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function coerceFloat(value: unknown): number | null {
58
+ if (typeof value === "boolean") return value ? 1 : 0;
59
+ if (typeof value === "number" && Number.isFinite(value)) return value;
60
+ if (typeof value === "string" && value.trim() !== "") {
61
+ const parsed = Number(value);
62
+ return Number.isFinite(parsed) ? parsed : null;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function coerceAsset(value: unknown): AssetRef | null {
68
+ if (typeof value === "string") {
69
+ const trimmed = value.trim();
70
+ if (trimmed === "") return null;
71
+ if (trimmed.startsWith("{")) {
72
+ try {
73
+ return coerceAsset(JSON.parse(trimmed));
74
+ } catch {
75
+ return assetRef(trimmed);
76
+ }
77
+ }
78
+ return assetRef(trimmed);
79
+ }
80
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
81
+ const raw = value as Record<string, unknown>;
82
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
83
+ if (id === "") return null;
84
+ return assetRef(id, typeof raw.name === "string" ? raw.name : "");
85
+ }
86
+
87
+ function coerceJson(value: unknown): CellValue {
88
+ if (typeof value === "string") {
89
+ const trimmed = value.trim();
90
+ if (trimmed === "") return null;
91
+ try {
92
+ return cloneJson(JSON.parse(trimmed));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+ if (isJsonValue(value)) return cloneJson(value);
98
+ return null;
99
+ }
100
+
101
+ function cloneJson(value: JsonValue): JsonValue {
102
+ return JSON.parse(JSON.stringify(value)) as JsonValue;
103
+ }
104
+
105
+ function isJsonValue(value: unknown): value is JsonValue {
106
+ if (typeof value === "string" || typeof value === "boolean") return true;
107
+ if (typeof value === "number") return Number.isFinite(value);
108
+ if (Array.isArray(value)) return value.every(isJsonValue);
109
+ if (value !== null && typeof value === "object") {
110
+ return Object.values(value).every(isJsonValue);
111
+ }
112
+ return false;
113
+ }
@@ -0,0 +1,177 @@
1
+ import { isAssetRef } from "./asset";
2
+ import { coerceDate, coerceTime, coerceTimestamp } from "./timestamp";
3
+ import {
4
+ assetKind,
5
+ isAssetColumnType,
6
+ isColumnType,
7
+ TABLE_VERSION,
8
+ type ColumnType,
9
+ } from "./types";
10
+ import { MAX_COLUMN_WIDTH, MIN_COLUMN_WIDTH } from "./width";
11
+
12
+ function record(value: unknown): value is Record<string, unknown> {
13
+ if (value === null || typeof value !== "object" || Array.isArray(value))
14
+ return false;
15
+ const prototype = Object.getPrototypeOf(value);
16
+ return prototype === Object.prototype || prototype === null;
17
+ }
18
+
19
+ function json(value: unknown, ancestors = new Set<object>()): boolean {
20
+ if (value === null || typeof value === "string" || typeof value === "boolean")
21
+ return true;
22
+ if (typeof value === "number") return Number.isFinite(value);
23
+ if (!Array.isArray(value) && !record(value)) return false;
24
+ if (ancestors.has(value)) return false;
25
+ ancestors.add(value);
26
+ const valid = Object.values(value).every((entry) => json(entry, ancestors));
27
+ ancestors.delete(value);
28
+ return valid;
29
+ }
30
+
31
+ function validCell(type: ColumnType, value: unknown): boolean {
32
+ if (value === null) return true;
33
+ if (isAssetColumnType(type)) return record(value) && isAssetRef(value);
34
+ switch (type) {
35
+ case "text":
36
+ return typeof value === "string";
37
+ case "integer":
38
+ return (
39
+ typeof value === "number" &&
40
+ Number.isFinite(value) &&
41
+ Number.isInteger(value)
42
+ );
43
+ case "float":
44
+ return typeof value === "number" && Number.isFinite(value);
45
+ case "boolean":
46
+ return typeof value === "boolean";
47
+ case "json":
48
+ return json(value);
49
+ case "date":
50
+ return typeof value === "string" && coerceDate(value) === value;
51
+ case "time":
52
+ return typeof value === "string" && coerceTime(value) === value;
53
+ case "timestamp":
54
+ return typeof value === "string" && coerceTimestamp(value) === value;
55
+ }
56
+ }
57
+
58
+ /** Inspects stored v1 content without applying the editor's permissive coercions. */
59
+ export const tableFormat = {
60
+ currentSchemaVersion: TABLE_VERSION,
61
+ inspect(input: unknown) {
62
+ const issues: { severity: "warning" | "error"; code: string; path: (string | number)[] }[] = [];
63
+ const references: { assetId: string; assetType: string; path: (string | number)[] }[] = [];
64
+ const issue = (
65
+ code: string,
66
+ path: (string | number)[],
67
+ severity: "warning" | "error" = "error",
68
+ ) => {
69
+ issues.push({ severity, code, path });
70
+ };
71
+ if (!record(input)) {
72
+ issue("invalid_document", []);
73
+ return { schemaVersion: null, issues, references };
74
+ }
75
+ const schemaVersion =
76
+ typeof input.version === "number" &&
77
+ Number.isInteger(input.version) &&
78
+ input.version > 0
79
+ ? input.version
80
+ : null;
81
+ if (input.version !== TABLE_VERSION) {
82
+ issue(
83
+ schemaVersion === null
84
+ ? "invalid_schema_version"
85
+ : "unsupported_schema",
86
+ ["version"],
87
+ );
88
+ return { schemaVersion, issues, references };
89
+ }
90
+ const columns = new Map<string, ColumnType>();
91
+ const ids = new Set<string>();
92
+ if (!Array.isArray(input.columns)) issue("invalid_columns", ["columns"]);
93
+ else
94
+ input.columns.forEach((column, index) => {
95
+ const path = ["columns", index];
96
+ if (!record(column)) {
97
+ issue("invalid_column", path);
98
+ return;
99
+ }
100
+ const validId = typeof column.id === "string" && column.id.length > 0;
101
+ if (!validId) issue("invalid_column_id", [...path, "id"]);
102
+ else if (ids.has(column.id as string))
103
+ issue("duplicate_column_id", [...path, "id"]);
104
+ const validType =
105
+ typeof column.type === "string" && isColumnType(column.type);
106
+ if (!validType) issue("invalid_column_type", [...path, "type"]);
107
+ if (validId) {
108
+ if (validType && !ids.has(column.id as string))
109
+ columns.set(column.id as string, column.type as ColumnType);
110
+ ids.add(column.id as string);
111
+ }
112
+ if (Object.hasOwn(column, "width")) {
113
+ if (
114
+ typeof column.width !== "number" ||
115
+ !Number.isFinite(column.width)
116
+ )
117
+ issue("invalid_column_width", [...path, "width"]);
118
+ else if (
119
+ !Number.isInteger(column.width) ||
120
+ column.width < MIN_COLUMN_WIDTH ||
121
+ column.width > MAX_COLUMN_WIDTH
122
+ )
123
+ issue("noncanonical_column_width", [...path, "width"], "warning");
124
+ }
125
+ });
126
+ if (
127
+ Object.hasOwn(input, "primaryKeyColumnId") &&
128
+ (typeof input.primaryKeyColumnId !== "string" ||
129
+ !ids.has(input.primaryKeyColumnId))
130
+ ) {
131
+ issue("invalid_primary_key_column", ["primaryKeyColumnId"]);
132
+ }
133
+ const rowIds = new Set<string>();
134
+ if (!Array.isArray(input.rows)) issue("invalid_rows", ["rows"]);
135
+ else
136
+ input.rows.forEach((row, index) => {
137
+ const path = ["rows", index];
138
+ if (!record(row)) {
139
+ issue("invalid_row", path);
140
+ return;
141
+ }
142
+ if (typeof row.id !== "string" || row.id.length === 0)
143
+ issue("invalid_row_id", [...path, "id"]);
144
+ else {
145
+ if (rowIds.has(row.id)) issue("duplicate_row_id", [...path, "id"]);
146
+ rowIds.add(row.id);
147
+ }
148
+ if (!record(row.cells)) {
149
+ issue("invalid_cells", [...path, "cells"]);
150
+ return;
151
+ }
152
+ for (const [id, type] of columns) {
153
+ const cellPath = [...path, "cells", id];
154
+ if (!Object.hasOwn(row.cells, id)) {
155
+ issue("missing_cell", cellPath, "warning");
156
+ continue;
157
+ }
158
+ const cell = row.cells[id];
159
+ if (!validCell(type, cell)) {
160
+ issue(
161
+ isAssetColumnType(type)
162
+ ? "invalid_asset_reference"
163
+ : "invalid_cell",
164
+ cellPath,
165
+ );
166
+ } else if (isAssetColumnType(type) && isAssetRef(cell)) {
167
+ references.push({
168
+ assetId: cell.id,
169
+ assetType: assetKind(type),
170
+ path: cellPath,
171
+ });
172
+ }
173
+ }
174
+ });
175
+ return { schemaVersion, issues, references };
176
+ },
177
+ };
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ export {
2
+ COLUMN_TYPES,
3
+ TABLE_VERSION,
4
+ ParseError,
5
+ assetColumnType,
6
+ assetKind,
7
+ isAssetColumnType,
8
+ isColumnType,
9
+ type AssetRef,
10
+ type CellValue,
11
+ type ColumnType,
12
+ type JsonValue,
13
+ type ScalarColumnType,
14
+ type Table,
15
+ type TableColumn,
16
+ type TableRow,
17
+ } from "./types";
18
+ export { cellsEqual, coerceCell, stringifyJson } from "./coerce";
19
+ export { assetRef, isAssetRef, refreshAssetNames } from "./asset";
20
+ export {
21
+ addDateUnit,
22
+ addTimeUnit,
23
+ addTimestampUnit,
24
+ coerceDate,
25
+ coerceTime,
26
+ coerceTimestamp,
27
+ formatDateLocal,
28
+ formatIsoUtc,
29
+ formatTimeLocal,
30
+ setDateUnit,
31
+ setTimeUnit,
32
+ setTimestampUnit,
33
+ timestampParts,
34
+ type TimestampUnit,
35
+ } from "./timestamp";
36
+ export { createBlank, parse } from "./parse";
37
+ export {
38
+ applySortingChange,
39
+ applyTableChange,
40
+ applyTablePatch,
41
+ projectSorting,
42
+ projectTable,
43
+ tableDoc,
44
+ } from "./bind";
45
+ export {
46
+ canApplyViewOrder,
47
+ INDEX_SORT_ASC,
48
+ INDEX_SORT_ID,
49
+ isIndexSort,
50
+ normalizeSorting,
51
+ type SortingState,
52
+ } from "./sort";
53
+ export { clampColumnWidth, columnWidth, displayColumnWidth } from "./width";
54
+ export { tableFormat } from "./contract";
package/src/parse.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { coerceCell } from "./coerce";
2
+ import {
3
+ TABLE_VERSION,
4
+ ParseError,
5
+ isColumnType,
6
+ type TableColumn,
7
+ type Table,
8
+ type TableRow,
9
+ } from "./types";
10
+ import { clampColumnWidth } from "./width";
11
+
12
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+
16
+ function parseColumn(raw: unknown, index: number, seen: Set<string>): TableColumn {
17
+ if (!isPlainObject(raw)) {
18
+ throw new ParseError(`columns[${index}] must be an object`);
19
+ }
20
+ const id = raw.id;
21
+ if (typeof id !== "string" || id.length === 0) {
22
+ throw new ParseError(
23
+ `columns[${index}].id must be a non-empty string`,
24
+ );
25
+ }
26
+ if (seen.has(id)) {
27
+ throw new ParseError(`duplicate column id "${id}"`);
28
+ }
29
+ seen.add(id);
30
+ const type = raw.type;
31
+ if (typeof type !== "string" || !isColumnType(type)) {
32
+ throw new ParseError(
33
+ `columns[${index}].type must be text, integer, float, boolean, json, timestamp, date, time, or asset/<kind>`,
34
+ );
35
+ }
36
+ const width =
37
+ typeof raw.width === "number" && Number.isFinite(raw.width)
38
+ ? clampColumnWidth(raw.width)
39
+ : undefined;
40
+ return width == null ? { id, type } : { id, type, width };
41
+ }
42
+
43
+ function parseRow(
44
+ raw: unknown,
45
+ index: number,
46
+ columns: readonly TableColumn[],
47
+ seen: Set<string>,
48
+ ): TableRow {
49
+ if (!isPlainObject(raw)) {
50
+ throw new ParseError(`rows[${index}] must be an object`);
51
+ }
52
+ const id = raw.id;
53
+ if (typeof id !== "string" || id.length === 0) {
54
+ throw new ParseError(
55
+ `rows[${index}].id must be a non-empty string`,
56
+ );
57
+ }
58
+ if (seen.has(id)) {
59
+ throw new ParseError(`duplicate row id "${id}"`);
60
+ }
61
+ seen.add(id);
62
+ if (!isPlainObject(raw.cells)) {
63
+ throw new ParseError(`rows[${index}].cells must be an object`);
64
+ }
65
+ const cells: TableRow["cells"] = {};
66
+ for (const column of columns) {
67
+ cells[column.id] = Object.hasOwn(raw.cells, column.id)
68
+ ? coerceCell(column.type, raw.cells[column.id])
69
+ : null;
70
+ }
71
+ return { id, cells };
72
+ }
73
+
74
+ export function createBlank(): Table {
75
+ return { version: TABLE_VERSION, columns: [], rows: [] };
76
+ }
77
+
78
+ export function parse(input: unknown): Table {
79
+ if (!isPlainObject(input)) {
80
+ throw new ParseError("document must be an object");
81
+ }
82
+ if (input.version !== TABLE_VERSION) {
83
+ throw new ParseError(
84
+ `version must be ${TABLE_VERSION}`,
85
+ );
86
+ }
87
+ if (!Array.isArray(input.columns)) {
88
+ throw new ParseError("columns must be an array");
89
+ }
90
+ if (!Array.isArray(input.rows)) {
91
+ throw new ParseError("rows must be an array");
92
+ }
93
+ const seenColumns = new Set<string>();
94
+ const columns = input.columns.map((column, index) =>
95
+ parseColumn(column, index, seenColumns),
96
+ );
97
+ const seenRows = new Set<string>();
98
+ const rows = input.rows.map((row, index) =>
99
+ parseRow(row, index, columns, seenRows),
100
+ );
101
+ const primaryKeyColumnId = parsePrimaryKeyColumnId(
102
+ input.primaryKeyColumnId,
103
+ columns,
104
+ );
105
+ return primaryKeyColumnId
106
+ ? { version: TABLE_VERSION, primaryKeyColumnId, columns, rows }
107
+ : { version: TABLE_VERSION, columns, rows };
108
+ }
109
+
110
+ function parsePrimaryKeyColumnId(
111
+ raw: unknown,
112
+ columns: readonly TableColumn[],
113
+ ): string | undefined {
114
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
115
+ return columns.some((column) => column.id === raw) ? raw : undefined;
116
+ }
package/src/sort.ts ADDED
@@ -0,0 +1,34 @@
1
+ export const INDEX_SORT_ID = "#index";
2
+
3
+ export type ColumnSort = {
4
+ id: string;
5
+ desc: boolean;
6
+ };
7
+
8
+ export type SortingState = ColumnSort[];
9
+
10
+ export const INDEX_SORT_ASC: ColumnSort = { id: INDEX_SORT_ID, desc: false };
11
+
12
+ export function normalizeSorting(
13
+ sorting: SortingState,
14
+ columnIds: readonly string[],
15
+ ): SortingState {
16
+ const sort = sorting[0];
17
+ if (!sort) return [INDEX_SORT_ASC];
18
+ if (sort.id === INDEX_SORT_ID) return [{ id: INDEX_SORT_ID, desc: sort.desc }];
19
+ if (columnIds.includes(sort.id)) return [{ id: sort.id, desc: sort.desc }];
20
+ return [INDEX_SORT_ASC];
21
+ }
22
+
23
+ export function isIndexSort(sorting: SortingState): boolean {
24
+ return (sorting[0] ?? INDEX_SORT_ASC).id === INDEX_SORT_ID;
25
+ }
26
+
27
+ function isIndexSortAsc(sorting: SortingState): boolean {
28
+ const sort = sorting[0] ?? INDEX_SORT_ASC;
29
+ return sort.id === INDEX_SORT_ID && !sort.desc;
30
+ }
31
+
32
+ export function canApplyViewOrder(sorting: SortingState): boolean {
33
+ return !isIndexSortAsc(sorting);
34
+ }
@@ -0,0 +1,328 @@
1
+ const TIMESTAMP_UNITS = [
2
+ "year",
3
+ "month",
4
+ "day",
5
+ "hour",
6
+ "minute",
7
+ "second",
8
+ ] as const;
9
+
10
+ export type TimestampUnit = (typeof TIMESTAMP_UNITS)[number];
11
+
12
+ type TimestampParts = {
13
+ year: number;
14
+ month: number;
15
+ day: number;
16
+ hour: number;
17
+ minute: number;
18
+ second: number;
19
+ };
20
+
21
+ export function formatIsoUtc(date: Date): string | null {
22
+ if (Number.isNaN(date.getTime())) return null;
23
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
24
+ }
25
+
26
+ function formatDateUtc(date: Date): string | null {
27
+ if (Number.isNaN(date.getTime())) return null;
28
+ const year = String(date.getUTCFullYear()).padStart(4, "0");
29
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
30
+ const day = String(date.getUTCDate()).padStart(2, "0");
31
+ return `${year}-${month}-${day}`;
32
+ }
33
+
34
+ function formatTimeUtc(date: Date): string | null {
35
+ if (Number.isNaN(date.getTime())) return null;
36
+ const hour = String(date.getUTCHours()).padStart(2, "0");
37
+ const minute = String(date.getUTCMinutes()).padStart(2, "0");
38
+ const second = String(date.getUTCSeconds()).padStart(2, "0");
39
+ return `${hour}:${minute}:${second}`;
40
+ }
41
+
42
+ export function formatDateLocal(date: Date): string | null {
43
+ if (Number.isNaN(date.getTime())) return null;
44
+ return formatDateUtc(
45
+ new Date(
46
+ Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
47
+ ),
48
+ );
49
+ }
50
+
51
+ export function formatTimeLocal(date: Date): string | null {
52
+ if (Number.isNaN(date.getTime())) return null;
53
+ return formatTimeUtc(
54
+ new Date(
55
+ Date.UTC(
56
+ 1970,
57
+ 0,
58
+ 1,
59
+ date.getHours(),
60
+ date.getMinutes(),
61
+ date.getSeconds(),
62
+ ),
63
+ ),
64
+ );
65
+ }
66
+
67
+ export function coerceTimestamp(value: unknown): string | null {
68
+ if (value == null || value === "") return null;
69
+ if (value instanceof Date) return formatIsoUtc(value);
70
+ if (typeof value === "number" && Number.isFinite(value)) {
71
+ return formatIsoUtc(new Date(value));
72
+ }
73
+ if (typeof value === "string" && value.trim() !== "") {
74
+ return formatIsoUtc(new Date(value));
75
+ }
76
+ return null;
77
+ }
78
+
79
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
80
+ const TIME_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
81
+
82
+ export function coerceDate(value: unknown): string | null {
83
+ if (value == null || value === "") return null;
84
+ if (value instanceof Date) return formatDateUtc(value);
85
+ if (typeof value === "number" && Number.isFinite(value)) {
86
+ return formatDateUtc(new Date(value));
87
+ }
88
+ if (typeof value === "string" && value.trim() !== "") {
89
+ const text = value.trim();
90
+ const civil = text.includes("T") ? text.slice(0, text.indexOf("T")) : text;
91
+ if (DATE_RE.test(civil)) {
92
+ const date = new Date(`${civil}T00:00:00Z`);
93
+ return Number.isNaN(date.getTime()) ? null : formatDateUtc(date);
94
+ }
95
+ return formatDateUtc(new Date(text));
96
+ }
97
+ return null;
98
+ }
99
+
100
+ export function coerceTime(value: unknown): string | null {
101
+ if (value == null || value === "") return null;
102
+ if (value instanceof Date) return formatTimeUtc(value);
103
+ if (typeof value === "number" && Number.isFinite(value)) {
104
+ return formatTimeUtc(new Date(value));
105
+ }
106
+ if (typeof value === "string" && value.trim() !== "") {
107
+ const text = value.trim();
108
+ const clock = text.includes("T")
109
+ ? text.slice(text.indexOf("T") + 1).replace(/[Zz]|[+-]\d{2}:?\d{2}$/, "")
110
+ : text;
111
+ const match = TIME_RE.exec(clock);
112
+ if (match) {
113
+ return formatTimeUtc(
114
+ new Date(
115
+ Date.UTC(
116
+ 1970,
117
+ 0,
118
+ 1,
119
+ Number(match[1]),
120
+ Number(match[2]),
121
+ Number(match[3] ?? 0),
122
+ ),
123
+ ),
124
+ );
125
+ }
126
+ const parsed = new Date(text);
127
+ return Number.isNaN(parsed.getTime()) ? null : formatTimeUtc(parsed);
128
+ }
129
+ return null;
130
+ }
131
+
132
+ export function timestampParts(iso: string): TimestampParts | null {
133
+ const date = parseUtc(iso);
134
+ if (!date) return null;
135
+ return {
136
+ year: date.getUTCFullYear(),
137
+ month: date.getUTCMonth() + 1,
138
+ day: date.getUTCDate(),
139
+ hour: date.getUTCHours(),
140
+ minute: date.getUTCMinutes(),
141
+ second: date.getUTCSeconds(),
142
+ };
143
+ }
144
+
145
+ export function addTimestampUnit(
146
+ iso: string | null,
147
+ unit: TimestampUnit,
148
+ delta: number,
149
+ fallback: Date = new Date(0),
150
+ ): string | null {
151
+ const date = dateFromIso(iso, fallback);
152
+ switch (unit) {
153
+ case "year":
154
+ date.setUTCFullYear(date.getUTCFullYear() + delta);
155
+ break;
156
+ case "month":
157
+ date.setUTCMonth(date.getUTCMonth() + delta);
158
+ break;
159
+ case "day":
160
+ date.setUTCDate(date.getUTCDate() + delta);
161
+ break;
162
+ case "hour":
163
+ date.setUTCHours(date.getUTCHours() + delta);
164
+ break;
165
+ case "minute":
166
+ date.setUTCMinutes(date.getUTCMinutes() + delta);
167
+ break;
168
+ case "second":
169
+ date.setUTCSeconds(date.getUTCSeconds() + delta);
170
+ break;
171
+ }
172
+ return formatIsoUtc(date);
173
+ }
174
+
175
+ export function setTimestampUnit(
176
+ iso: string | null,
177
+ unit: TimestampUnit,
178
+ value: number,
179
+ fallback: Date = new Date(0),
180
+ ): string | null {
181
+ if (!Number.isFinite(value)) {
182
+ return iso ? formatIsoUtc(new Date(iso)) : null;
183
+ }
184
+ const date = dateFromIso(iso, fallback);
185
+ const next = Math.trunc(value);
186
+ switch (unit) {
187
+ case "year":
188
+ date.setUTCFullYear(next);
189
+ break;
190
+ case "month":
191
+ date.setUTCMonth(next - 1);
192
+ break;
193
+ case "day":
194
+ date.setUTCDate(next);
195
+ break;
196
+ case "hour":
197
+ date.setUTCHours(next);
198
+ break;
199
+ case "minute":
200
+ date.setUTCMinutes(next);
201
+ break;
202
+ case "second":
203
+ date.setUTCSeconds(next);
204
+ break;
205
+ }
206
+ return formatIsoUtc(date);
207
+ }
208
+
209
+ export function addDateUnit(
210
+ value: string | null,
211
+ unit: TimestampUnit,
212
+ delta: number,
213
+ fallback: Date = new Date(Date.UTC(1970, 0, 1)),
214
+ ): string | null {
215
+ return formatDateUtc(shiftUnit(dateFromCivilDate(value, fallback), unit, delta));
216
+ }
217
+
218
+ export function setDateUnit(
219
+ value: string | null,
220
+ unit: TimestampUnit,
221
+ next: number,
222
+ fallback: Date = new Date(Date.UTC(1970, 0, 1)),
223
+ ): string | null {
224
+ if (!Number.isFinite(next)) return coerceDate(value);
225
+ return formatDateUtc(
226
+ writeUnit(dateFromCivilDate(value, fallback), unit, Math.trunc(next)),
227
+ );
228
+ }
229
+
230
+ export function addTimeUnit(
231
+ value: string | null,
232
+ unit: TimestampUnit,
233
+ delta: number,
234
+ fallback: Date = new Date(Date.UTC(1970, 0, 1)),
235
+ ): string | null {
236
+ return formatTimeUtc(shiftUnit(dateFromCivilTime(value, fallback), unit, delta));
237
+ }
238
+
239
+ export function setTimeUnit(
240
+ value: string | null,
241
+ unit: TimestampUnit,
242
+ next: number,
243
+ fallback: Date = new Date(Date.UTC(1970, 0, 1)),
244
+ ): string | null {
245
+ if (!Number.isFinite(next)) return coerceTime(value);
246
+ return formatTimeUtc(
247
+ writeUnit(dateFromCivilTime(value, fallback), unit, Math.trunc(next)),
248
+ );
249
+ }
250
+
251
+ function shiftUnit(date: Date, unit: TimestampUnit, delta: number): Date {
252
+ switch (unit) {
253
+ case "year":
254
+ date.setUTCFullYear(date.getUTCFullYear() + delta);
255
+ break;
256
+ case "month":
257
+ date.setUTCMonth(date.getUTCMonth() + delta);
258
+ break;
259
+ case "day":
260
+ date.setUTCDate(date.getUTCDate() + delta);
261
+ break;
262
+ case "hour":
263
+ date.setUTCHours(date.getUTCHours() + delta);
264
+ break;
265
+ case "minute":
266
+ date.setUTCMinutes(date.getUTCMinutes() + delta);
267
+ break;
268
+ case "second":
269
+ date.setUTCSeconds(date.getUTCSeconds() + delta);
270
+ break;
271
+ }
272
+ return date;
273
+ }
274
+
275
+ function writeUnit(date: Date, unit: TimestampUnit, next: number): Date {
276
+ switch (unit) {
277
+ case "year":
278
+ date.setUTCFullYear(next);
279
+ break;
280
+ case "month":
281
+ date.setUTCMonth(next - 1);
282
+ break;
283
+ case "day":
284
+ date.setUTCDate(next);
285
+ break;
286
+ case "hour":
287
+ date.setUTCHours(next);
288
+ break;
289
+ case "minute":
290
+ date.setUTCMinutes(next);
291
+ break;
292
+ case "second":
293
+ date.setUTCSeconds(next);
294
+ break;
295
+ }
296
+ return date;
297
+ }
298
+
299
+ function dateFromCivilDate(value: string | null, fallback: Date): Date {
300
+ const civil = value ? coerceDate(value) : null;
301
+ if (civil) {
302
+ const date = new Date(`${civil}T00:00:00Z`);
303
+ if (!Number.isNaN(date.getTime())) return date;
304
+ }
305
+ return new Date(fallback.getTime());
306
+ }
307
+
308
+ function dateFromCivilTime(value: string | null, fallback: Date): Date {
309
+ const clock = value ? coerceTime(value) : null;
310
+ if (clock) {
311
+ const date = new Date(`1970-01-01T${clock}Z`);
312
+ if (!Number.isNaN(date.getTime())) return date;
313
+ }
314
+ return new Date(fallback.getTime());
315
+ }
316
+
317
+ function parseUtc(iso: string): Date | null {
318
+ const date = new Date(iso);
319
+ return Number.isNaN(date.getTime()) ? null : date;
320
+ }
321
+
322
+ function dateFromIso(iso: string | null, fallback: Date): Date {
323
+ if (iso) {
324
+ const date = parseUtc(iso);
325
+ if (date) return date;
326
+ }
327
+ return new Date(fallback.getTime());
328
+ }
package/src/types.ts ADDED
@@ -0,0 +1,78 @@
1
+ export const TABLE_VERSION = 1 as const;
2
+
3
+ export const COLUMN_TYPES = [
4
+ "text",
5
+ "integer",
6
+ "float",
7
+ "boolean",
8
+ "json",
9
+ "timestamp",
10
+ "date",
11
+ "time",
12
+ ] as const;
13
+
14
+ export type ScalarColumnType = (typeof COLUMN_TYPES)[number];
15
+ type AssetColumnType = `asset/${string}`;
16
+ export type ColumnType = ScalarColumnType | AssetColumnType;
17
+
18
+ const ASSET_PREFIX = "asset/";
19
+
20
+ export function isAssetColumnType(
21
+ value: string,
22
+ ): value is AssetColumnType {
23
+ return value.startsWith(ASSET_PREFIX) && value.length > ASSET_PREFIX.length;
24
+ }
25
+
26
+ export function assetKind(type: AssetColumnType): string {
27
+ return type.slice(ASSET_PREFIX.length);
28
+ }
29
+
30
+ export function assetColumnType(kind: string): AssetColumnType {
31
+ return `${ASSET_PREFIX}${kind}`;
32
+ }
33
+
34
+ export type TableColumn = {
35
+ id: string;
36
+ type: ColumnType;
37
+ width?: number;
38
+ };
39
+
40
+ export type JsonValue =
41
+ | string
42
+ | number
43
+ | boolean
44
+ | JsonValue[]
45
+ | { [key: string]: JsonValue };
46
+
47
+ export type AssetRef = {
48
+ id: string;
49
+ name: string;
50
+ };
51
+
52
+ export type CellValue = JsonValue | null;
53
+
54
+ export type TableRow = {
55
+ id: string;
56
+ cells: Record<string, CellValue>;
57
+ };
58
+
59
+ export type Table = {
60
+ version: typeof TABLE_VERSION;
61
+ primaryKeyColumnId?: string;
62
+ columns: TableColumn[];
63
+ rows: TableRow[];
64
+ };
65
+
66
+ export class ParseError extends Error {
67
+ constructor(message: string) {
68
+ super(message);
69
+ this.name = "ParseError";
70
+ }
71
+ }
72
+
73
+ export function isColumnType(value: string): value is ColumnType {
74
+ return (
75
+ (COLUMN_TYPES as readonly string[]).includes(value) ||
76
+ isAssetColumnType(value)
77
+ );
78
+ }
package/src/width.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { TableColumn } from "./types";
2
+
3
+ const DEFAULT_COLUMN_WIDTH = 160;
4
+ export const MIN_COLUMN_WIDTH = 72;
5
+ export const MAX_COLUMN_WIDTH = 2000;
6
+
7
+ export function clampColumnWidth(px: number): number {
8
+ return Math.min(
9
+ MAX_COLUMN_WIDTH,
10
+ Math.max(MIN_COLUMN_WIDTH, Math.round(px)),
11
+ );
12
+ }
13
+
14
+ export function columnWidth(column: TableColumn): number {
15
+ return column.width == null
16
+ ? DEFAULT_COLUMN_WIDTH
17
+ : clampColumnWidth(column.width);
18
+ }
19
+
20
+ /** Live drag, then session overlay, then the stored column. */
21
+ export function displayColumnWidth(
22
+ column: TableColumn,
23
+ overrides: Readonly<Record<string, number>>,
24
+ live?: { id: string; width: number } | null,
25
+ ): number {
26
+ if (live?.id === column.id) return live.width;
27
+ const override = overrides[column.id];
28
+ if (override != null) return clampColumnWidth(override);
29
+ return columnWidth(column);
30
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.