From 09d11436a61131ffb09abb551ddc9cd7ac8c9adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 17 Aug 2026 11:22:15 +0900 Subject: [PATCH] =?UTF-8?q?refactor(editing):=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=80=EC=A6=9D=20=EC=B1=85=EC=9E=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#414)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/json-document-editing/package.json | 3 +- .../scripts/check-responsibility-layout.mjs | 22 ++++++++ .../src/database-validation.ts | 38 +++++++++++++ .../json-document-editing/src/database.ts | 55 ++----------------- .../src/kanban-validation.ts | 17 ++++++ packages/json-document-editing/src/kanban.ts | 19 +------ .../src/object-validation.ts | 12 ++++ packages/json-document-editing/src/object.ts | 16 +----- .../src/order-validation.ts | 10 ++++ packages/json-document-editing/src/order.ts | 10 +--- .../src/sheet-validation.ts | 18 ++++++ packages/json-document-editing/src/sheet.ts | 24 +------- .../src/tree-validation.ts | 25 +++++++++ packages/json-document-editing/src/tree.ts | 31 +---------- 14 files changed, 154 insertions(+), 146 deletions(-) create mode 100644 packages/json-document-editing/scripts/check-responsibility-layout.mjs create mode 100644 packages/json-document-editing/src/database-validation.ts create mode 100644 packages/json-document-editing/src/kanban-validation.ts create mode 100644 packages/json-document-editing/src/object-validation.ts create mode 100644 packages/json-document-editing/src/order-validation.ts create mode 100644 packages/json-document-editing/src/sheet-validation.ts create mode 100644 packages/json-document-editing/src/tree-validation.ts diff --git a/packages/json-document-editing/package.json b/packages/json-document-editing/package.json index 3585b806..de126ea0 100644 --- a/packages/json-document-editing/package.json +++ b/packages/json-document-editing/package.json @@ -28,11 +28,12 @@ "clean": "rm -rf dist", "prebuild": "npm run build -w @interactive-os/json-document && npm run build -w @interactive-os/json-document-selection", "build": "npm run clean && tsc -p tsconfig.json", + "check:structure": "node scripts/check-responsibility-layout.mjs", "pretypecheck": "npm run build -w @interactive-os/json-document", "test": "vitest run --config vitest.config.ts", "perf": "npm run build && node benchmarks/editors.mjs", "typecheck": "tsc -p tsconfig.test.json --noEmit", - "verify": "npm run typecheck && npm test && npm run build" + "verify": "npm run check:structure && npm run typecheck && npm test && npm run build" }, "peerDependencies": { "@interactive-os/json-document": "^3.0.0", diff --git a/packages/json-document-editing/scripts/check-responsibility-layout.mjs b/packages/json-document-editing/scripts/check-responsibility-layout.mjs new file mode 100644 index 00000000..f7769148 --- /dev/null +++ b/packages/json-document-editing/scripts/check-responsibility-layout.mjs @@ -0,0 +1,22 @@ +import { readFile } from "node:fs/promises"; + +const domainsWithValidation = ["order", "object", "tree", "sheet", "database", "kanban"]; +const violations = []; + +for (const domain of domainsWithValidation) { + const [editor, validation] = await Promise.all([ + readFile(new URL(`../src/${domain}.ts`, import.meta.url), "utf8"), + readFile(new URL(`../src/${domain}-validation.ts`, import.meta.url), "utf8"), + ]); + const assertion = `assert${domain[0].toUpperCase()}${domain.slice(1)}Document`; + if (!editor.includes(`from "./${domain}-validation.js"`)) violations.push(`${domain}.ts must depend on its validation responsibility.`); + if (editor.includes(`function ${assertion}`)) violations.push(`${domain}.ts must not own document validation.`); + if (!validation.includes(`export function ${assertion}`)) violations.push(`${domain}-validation.ts must own ${assertion}.`); +} + +if (violations.length > 0) { + console.error(violations.join("\n")); + process.exitCode = 1; +} else { + console.log("Editing responsibility layout ok."); +} diff --git a/packages/json-document-editing/src/database-validation.ts b/packages/json-document-editing/src/database-validation.ts new file mode 100644 index 00000000..ab59420c --- /dev/null +++ b/packages/json-document-editing/src/database-validation.ts @@ -0,0 +1,38 @@ +import type { JSONValue } from "@interactive-os/json-document"; +import type { DatabaseDocument, DatabaseProperty, DatabaseTableView } from "./database.js"; + +export function assertDatabaseDocument(document: DatabaseDocument): void { + assertUnique(document.schema.properties.map((property) => property.id), "property"); + assertUnique(document.records.map((record) => record.id), "record"); + assertUnique(document.views.map((view) => view.id), "view"); + for (const property of document.schema.properties) if (property.type === "select") assertUnique(property.options.map((option) => option.id), "select option"); + for (const record of document.records) for (const property of document.schema.properties) { + if (!Object.prototype.hasOwnProperty.call(record.values, property.id)) throw new Error(`Database record ${JSON.stringify(record.id)} is missing property ${JSON.stringify(property.id)}.`); + if (!acceptsDatabaseValue(property, record.values[property.id]!)) throw new Error(`Database record ${JSON.stringify(record.id)} has an invalid ${property.type} value.`); + } + for (const view of document.views) assertDatabaseView(view, document.schema.properties); +} + +export function assertDatabaseView(view: DatabaseTableView, properties: ReadonlyArray): void { + const available = new Set(properties.map((property) => property.id)); + assertUnique(view.propertyOrder, "view property"); + if (view.propertyOrder.length !== properties.length || view.propertyOrder.some((id) => !available.has(id))) throw new Error(`Database view ${JSON.stringify(view.id)} must order every property exactly once.`); + for (const propertyId of Object.keys(view.propertyVisibility)) if (!available.has(propertyId)) throw new Error(`Database view references unknown property ${JSON.stringify(propertyId)}.`); + if (view.sort && !available.has(view.sort.propertyId)) throw new Error("Database sort property was not found."); + if (view.filter && !available.has(view.filter.propertyId)) throw new Error("Database filter property was not found."); +} + +export function acceptsDatabaseValue(property: DatabaseProperty, value: JSONValue): boolean { + if (property.type === "title" || property.type === "text") return typeof value === "string"; + if (property.type === "number") return typeof value === "number"; + if (property.type === "checkbox") return typeof value === "boolean"; + return typeof value === "string" && property.options.some((option) => option.id === value); +} + +function assertUnique(ids: ReadonlyArray, label: string): void { + const unique = new Set(); + for (const id of ids) { + if (id.length === 0 || unique.has(id)) throw new Error(`Database ${label} ids must be non-empty and unique.`); + unique.add(id); + } +} diff --git a/packages/json-document-editing/src/database.ts b/packages/json-document-editing/src/database.ts index 4b554ab5..3aa672b6 100644 --- a/packages/json-document-editing/src/database.ts +++ b/packages/json-document-editing/src/database.ts @@ -11,6 +11,7 @@ import { } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import { gridCellsInRange, gridPointIndex, gridRangeBounds } from "./topology.js"; +import { acceptsDatabaseValue, assertDatabaseDocument, assertDatabaseView } from "./database-validation.js"; import { collapsedRangeSelection, emptyRangeSelection, @@ -191,7 +192,7 @@ export function createDatabaseEditor(source: EditingDocumentSource property.id), "property"); - assertUnique(document.records.map((record) => record.id), "record"); - assertUnique(document.views.map((view) => view.id), "view"); - for (const property of document.schema.properties) { - if (property.type === "select") assertUnique(property.options.map((option) => option.id), "select option"); - } - for (const record of document.records) { - for (const property of document.schema.properties) { - if (!Object.prototype.hasOwnProperty.call(record.values, property.id)) { - throw new Error(`Database record ${JSON.stringify(record.id)} is missing property ${JSON.stringify(property.id)}.`); - } - if (!acceptsValue(property, record.values[property.id]!)) { - throw new Error(`Database record ${JSON.stringify(record.id)} has an invalid ${property.type} value.`); - } - } - } - for (const view of document.views) assertView(view, document.schema.properties); -} - -function assertView(view: DatabaseTableView, properties: ReadonlyArray): void { - const available = new Set(properties.map((property) => property.id)); - assertUnique(view.propertyOrder, "view property"); - if (view.propertyOrder.length !== properties.length || view.propertyOrder.some((id) => !available.has(id))) { - throw new Error(`Database view ${JSON.stringify(view.id)} must order every property exactly once.`); - } - for (const propertyId of Object.keys(view.propertyVisibility)) { - if (!available.has(propertyId)) throw new Error(`Database view references unknown property ${JSON.stringify(propertyId)}.`); - } - if (view.sort && !available.has(view.sort.propertyId)) throw new Error("Database sort property was not found."); - if (view.filter && !available.has(view.filter.propertyId)) throw new Error("Database filter property was not found."); -} - -function acceptsValue(property: DatabaseProperty, value: JSONValue): boolean { - if (property.type === "title" || property.type === "text") return typeof value === "string"; - if (property.type === "number") return typeof value === "number"; - if (property.type === "checkbox") return typeof value === "boolean"; - return typeof value === "string" && property.options.some((option) => option.id === value); -} - function defaultValue(property: DatabaseProperty): JSONValue { if (property.type === "number") return 0; if (property.type === "checkbox") return false; @@ -563,14 +524,6 @@ function samePoint(left: DatabasePoint, right: DatabasePoint): boolean { return left.recordId === right.recordId && left.propertyId === right.propertyId; } -function assertUnique(ids: ReadonlyArray, label: string): void { - const unique = new Set(); - for (const id of ids) { - if (id.length === 0 || unique.has(id)) throw new Error(`Database ${label} ids must be non-empty and unique.`); - unique.add(id); - } -} - function cellKey(recordId: string, propertyId: string): string { return `${recordId}\u0000${propertyId}`; } diff --git a/packages/json-document-editing/src/kanban-validation.ts b/packages/json-document-editing/src/kanban-validation.ts new file mode 100644 index 00000000..df463452 --- /dev/null +++ b/packages/json-document-editing/src/kanban-validation.ts @@ -0,0 +1,17 @@ +import type { KanbanDocument } from "./kanban.js"; + +export function assertKanbanDocument(document: KanbanDocument): void { + const cardIds = new Set(); + for (const card of document.cards) { + if (card.id.length === 0) throw new Error("Kanban card ids must not be empty."); + if (cardIds.has(card.id)) throw new Error(`Kanban card id must be unique: ${JSON.stringify(card.id)}.`); + cardIds.add(card.id); + } + const columnIds = new Set(); + for (const column of document.columns) { + if (column.id.length === 0) throw new Error("Kanban column ids must not be empty."); + if (columnIds.has(column.id)) throw new Error(`Kanban column id must be unique: ${JSON.stringify(column.id)}.`); + columnIds.add(column.id); + for (const cardId of column.cardIds) if (!cardIds.has(cardId)) throw new Error(`Kanban column references unknown card: ${JSON.stringify(cardId)}.`); + } +} diff --git a/packages/json-document-editing/src/kanban.ts b/packages/json-document-editing/src/kanban.ts index b3f5d181..568e86ee 100644 --- a/packages/json-document-editing/src/kanban.ts +++ b/packages/json-document-editing/src/kanban.ts @@ -14,6 +14,7 @@ import { type EditingSnapshot, } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import { assertKanbanDocument } from "./kanban-validation.js"; export interface KanbanCard extends Record { readonly id: string; @@ -215,21 +216,3 @@ function success(snapshot: EditingSnapshot): EditingResult { return { ok: false, code }; } - -function assertKanbanDocument(document: KanbanDocument): void { - const cardIds = new Set(); - for (const card of document.cards) { - if (card.id.length === 0) throw new Error("Kanban card ids must not be empty."); - if (cardIds.has(card.id)) throw new Error(`Kanban card id must be unique: ${JSON.stringify(card.id)}.`); - cardIds.add(card.id); - } - const columnIds = new Set(); - for (const column of document.columns) { - if (column.id.length === 0) throw new Error("Kanban column ids must not be empty."); - if (columnIds.has(column.id)) throw new Error(`Kanban column id must be unique: ${JSON.stringify(column.id)}.`); - columnIds.add(column.id); - for (const cardId of column.cardIds) { - if (!cardIds.has(cardId)) throw new Error(`Kanban column references unknown card: ${JSON.stringify(cardId)}.`); - } - } -} diff --git a/packages/json-document-editing/src/object-validation.ts b/packages/json-document-editing/src/object-validation.ts new file mode 100644 index 00000000..cac5fa94 --- /dev/null +++ b/packages/json-document-editing/src/object-validation.ts @@ -0,0 +1,12 @@ +import type { ObjectDocument } from "./object.js"; + +export function assertObjectDocument(document: ObjectDocument): void { + const ids = new Set(); + for (const object of document.objects) { + if (object.id.length === 0) throw new Error("Object ids must not be empty."); + if (ids.has(object.id)) throw new Error(`Object id must be unique: ${JSON.stringify(object.id)}.`); + if (![object.x, object.y, object.width, object.height].every(Number.isFinite)) throw new Error(`Object geometry must be finite: ${JSON.stringify(object.id)}.`); + if (object.width < 0 || object.height < 0) throw new Error(`Object dimensions must not be negative: ${JSON.stringify(object.id)}.`); + ids.add(object.id); + } +} diff --git a/packages/json-document-editing/src/object.ts b/packages/json-document-editing/src/object.ts index fe2504a4..a507b329 100644 --- a/packages/json-document-editing/src/object.ts +++ b/packages/json-document-editing/src/object.ts @@ -14,6 +14,7 @@ import { type EditingSnapshot, } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import { assertObjectDocument } from "./object-validation.js"; export interface DocumentObject extends Record { readonly id: string; @@ -250,21 +251,6 @@ function selectionFor( return { kind: "explicit", keys: [...keys], primaryKey }; } -function assertObjectDocument(document: ObjectDocument): void { - const ids = new Set(); - for (const object of document.objects) { - if (object.id.length === 0) throw new Error("Object ids must not be empty."); - if (ids.has(object.id)) throw new Error(`Object id must be unique: ${JSON.stringify(object.id)}.`); - if (![object.x, object.y, object.width, object.height].every(Number.isFinite)) { - throw new Error(`Object geometry must be finite: ${JSON.stringify(object.id)}.`); - } - if (object.width < 0 || object.height < 0) { - throw new Error(`Object dimensions must not be negative: ${JSON.stringify(object.id)}.`); - } - ids.add(object.id); - } -} - function success(snapshot: EditingSnapshot): EditingResult { return { ok: true, snapshot }; } diff --git a/packages/json-document-editing/src/order-validation.ts b/packages/json-document-editing/src/order-validation.ts new file mode 100644 index 00000000..c9cda814 --- /dev/null +++ b/packages/json-document-editing/src/order-validation.ts @@ -0,0 +1,10 @@ +import type { OrderDocument } from "./order.js"; + +export function assertOrderDocument(document: OrderDocument): void { + const ids = new Set(); + for (const item of document.items) { + if (item.id.length === 0) throw new Error("Order item ids must not be empty."); + if (ids.has(item.id)) throw new Error(`Order item id must be unique: ${JSON.stringify(item.id)}.`); + ids.add(item.id); + } +} diff --git a/packages/json-document-editing/src/order.ts b/packages/json-document-editing/src/order.ts index a2132fac..b33673a5 100644 --- a/packages/json-document-editing/src/order.ts +++ b/packages/json-document-editing/src/order.ts @@ -10,6 +10,7 @@ import { type RangeSelectionState, } from "./range-selection.js"; import { lineInterval, lineTopology } from "./topology.js"; +import { assertOrderDocument } from "./order-validation.js"; import { createEditingSession, type EditingResult, @@ -218,15 +219,6 @@ function cloneItemsWithUniqueIds( }); } -function assertOrderDocument(document: OrderDocument): void { - const ids = new Set(); - for (const item of document.items) { - if (item.id.length === 0) throw new Error("Order item ids must not be empty."); - if (ids.has(item.id)) throw new Error(`Order item id must be unique: ${JSON.stringify(item.id)}.`); - ids.add(item.id); - } -} - function success(snapshot: EditingSnapshot): EditingResult { return { ok: true, snapshot }; } diff --git a/packages/json-document-editing/src/sheet-validation.ts b/packages/json-document-editing/src/sheet-validation.ts new file mode 100644 index 00000000..bf285bdc --- /dev/null +++ b/packages/json-document-editing/src/sheet-validation.ts @@ -0,0 +1,18 @@ +import type { SheetDocument } from "./sheet.js"; + +export function assertSheetDocument(document: SheetDocument): void { + assertUniqueSheetIds(document.columns.map((column) => column.id), "column"); + assertUniqueSheetIds(document.rows.map((row) => row.id), "row"); + for (const row of document.rows) for (const column of document.columns) { + if (!Object.prototype.hasOwnProperty.call(row.cells, column.id)) throw new Error(`Sheet row ${JSON.stringify(row.id)} is missing column ${JSON.stringify(column.id)}.`); + } +} + +export function assertUniqueSheetIds(ids: ReadonlyArray, label: "row" | "column"): void { + const unique = new Set(); + for (const id of ids) { + if (id.length === 0) throw new Error(`Sheet ${label} ids must not be empty.`); + if (unique.has(id)) throw new Error(`Sheet ${label} id must be unique: ${JSON.stringify(id)}.`); + unique.add(id); + } +} diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index 7ff2931d..6b0ae0b1 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -11,6 +11,7 @@ import { } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import { gridCellsInRange, gridPointIndex, gridRangeBounds, type GridTopology } from "./topology.js"; +import { assertSheetDocument, assertUniqueSheetIds } from "./sheet-validation.js"; import { collapsedRangeSelection, emptyRangeSelection, @@ -361,7 +362,7 @@ function resolveTopology(document: SheetDocument, topology?: SheetTopology, inde } function assertTopologyAxis(ids: ReadonlyArray, available: { has(id: string): boolean }, label: "row" | "column"): void { - assertUniqueIds(ids, label); + assertUniqueSheetIds(ids, label); for (const id of ids) { if (!available.has(id)) throw new Error(`Sheet topology ${label} was not found: ${JSON.stringify(id)}.`); } @@ -397,27 +398,6 @@ function resolvePointWithIndices( return rowIndex === undefined || columnIndex === undefined ? null : { rowIndex, columnIndex }; } -function assertSheetDocument(document: SheetDocument): void { - assertUniqueIds(document.columns.map((column) => column.id), "column"); - assertUniqueIds(document.rows.map((row) => row.id), "row"); - for (const row of document.rows) { - for (const column of document.columns) { - if (!Object.prototype.hasOwnProperty.call(row.cells, column.id)) { - throw new Error(`Sheet row ${JSON.stringify(row.id)} is missing column ${JSON.stringify(column.id)}.`); - } - } - } -} - -function assertUniqueIds(ids: ReadonlyArray, label: "row" | "column"): void { - const unique = new Set(); - for (const id of ids) { - if (id.length === 0) throw new Error(`Sheet ${label} ids must not be empty.`); - if (unique.has(id)) throw new Error(`Sheet ${label} id must be unique: ${JSON.stringify(id)}.`); - unique.add(id); - } -} - function cellText(value: JSONValue): string { if (value === null) return ""; if (typeof value === "string") return value; diff --git a/packages/json-document-editing/src/tree-validation.ts b/packages/json-document-editing/src/tree-validation.ts new file mode 100644 index 00000000..b7e5bbc9 --- /dev/null +++ b/packages/json-document-editing/src/tree-validation.ts @@ -0,0 +1,25 @@ +import type { TreeDocument } from "./tree.js"; + +export function assertTreeDocument(document: TreeDocument): void { + const ids = new Set(); + const byId = new Map(document.nodes.map((node) => [node.id, node] as const)); + for (const node of document.nodes) { + if (node.id.length === 0) throw new Error("Tree node ids must not be empty."); + if (ids.has(node.id)) throw new Error(`Tree node id must be unique: ${JSON.stringify(node.id)}.`); + ids.add(node.id); + } + const ancestryState = new Map(); + for (const node of document.nodes) { + if (node.parentId !== null && !ids.has(node.parentId)) throw new Error(`Tree parent was not found: ${JSON.stringify(node.parentId)}.`); + if (ancestryState.has(node.id)) continue; + const path: string[] = []; + let currentId: string | null = node.id; + while (currentId !== null && !ancestryState.has(currentId)) { + ancestryState.set(currentId, 1); + path.push(currentId); + currentId = byId.get(currentId)?.parentId ?? null; + } + if (currentId !== null && ancestryState.get(currentId) === 1) throw new Error(`Tree hierarchy contains a cycle at ${JSON.stringify(node.id)}.`); + for (const id of path) ancestryState.set(id, 2); + } +} diff --git a/packages/json-document-editing/src/tree.ts b/packages/json-document-editing/src/tree.ts index bc87ad89..3dfc1da9 100644 --- a/packages/json-document-editing/src/tree.ts +++ b/packages/json-document-editing/src/tree.ts @@ -14,6 +14,7 @@ import { type RangeSelectionState, } from "./range-selection.js"; import { lineInterval, lineTopology } from "./topology.js"; +import { assertTreeDocument } from "./tree-validation.js"; import { createEditingSession, type EditingResult, @@ -379,36 +380,6 @@ function asTreeSelection(selection: RangeSelectionState): TreeSelecti }; } -function assertTreeDocument(document: TreeDocument): void { - const ids = new Set(); - const byId = createTreeNodeIndex(document.nodes).byId; - for (const node of document.nodes) { - if (node.id.length === 0) throw new Error("Tree node ids must not be empty."); - if (ids.has(node.id)) throw new Error(`Tree node id must be unique: ${JSON.stringify(node.id)}.`); - ids.add(node.id); - } - const ancestryState = new Map(); - for (const node of document.nodes) { - if (node.parentId !== null && !ids.has(node.parentId)) { - throw new Error(`Tree parent was not found: ${JSON.stringify(node.parentId)}.`); - } - if (ancestryState.has(node.id)) continue; - const path: string[] = []; - let currentId: string | null = node.id; - while (currentId !== null && !ancestryState.has(currentId)) { - ancestryState.set(currentId, 1); - path.push(currentId); - currentId = byId.get(currentId)?.parentId ?? null; - } - if (currentId !== null && ancestryState.get(currentId) === 1) { - throw new Error(`Tree hierarchy contains a cycle at ${JSON.stringify(node.id)}.`); - } - for (const id of path) { - ancestryState.set(id, 2); - } - } -} - function success(snapshot: EditingSnapshot): EditingResult { return { ok: true, snapshot }; }