Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/json-document-editing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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.");
}
38 changes: 38 additions & 0 deletions packages/json-document-editing/src/database-validation.ts
Original file line number Diff line number Diff line change
@@ -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<DatabaseProperty>): 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<string>, label: string): void {
const unique = new Set<string>();
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);
}
}
55 changes: 4 additions & 51 deletions packages/json-document-editing/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -191,7 +192,7 @@ export function createDatabaseEditor(source: EditingDocumentSource<DatabaseDocum
const document = value();
const resolved = resolveCell(document, intent.recordId, intent.propertyId, index(document));
if (resolved === null) return failure("cell.not-found");
if (!acceptsValue(resolved.property, intent.value)) return failure("cell.invalid-value");
if (!acceptsDatabaseValue(resolved.property, intent.value)) return failure("cell.invalid-value");
if (Object.is(resolved.record.values[intent.propertyId], intent.value)) {
return success(session.snapshot);
}
Expand Down Expand Up @@ -328,7 +329,7 @@ function paste(
const resolved = resolveCell(document, recordId, propertyId, index);
if (resolved === null) return failure("cell.not-found");
const nextValue = clipboard.cells[rowOffset]![columnOffset]!;
if (!acceptsValue(resolved.property, nextValue)) return failure("cell.invalid-value");
if (!acceptsDatabaseValue(resolved.property, nextValue)) return failure("cell.invalid-value");
operations.push({
op: "replace",
path: buildPointer(["records", resolved.recordIndex, "values", propertyId]),
Expand Down Expand Up @@ -415,7 +416,7 @@ function configureView(
...(intent.sort === undefined ? {} : { sort: intent.sort }),
...(intent.filter === undefined ? {} : { filter: intent.filter }),
};
assertView(next, document.schema.properties);
assertDatabaseView(next, document.schema.properties);
return session.apply({
operations: [{ op: "replace", path: buildPointer(["views", viewIndex]), value: next }],
selectionAfter: session.snapshot.selection,
Expand Down Expand Up @@ -487,46 +488,6 @@ function resolveCell(document: DatabaseDocument, recordId: string, propertyId: s
return { recordIndex, record: document.records[recordIndex]!, property };
}

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 (!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<DatabaseProperty>): 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;
Expand Down Expand Up @@ -563,14 +524,6 @@ function samePoint(left: DatabasePoint, right: DatabasePoint): boolean {
return left.recordId === right.recordId && left.propertyId === right.propertyId;
}

function assertUnique(ids: ReadonlyArray<string>, label: string): void {
const unique = new Set<string>();
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}`;
}
Expand Down
17 changes: 17 additions & 0 deletions packages/json-document-editing/src/kanban-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { KanbanDocument } from "./kanban.js";

export function assertKanbanDocument(document: KanbanDocument): void {
const cardIds = new Set<string>();
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<string>();
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)}.`);
}
}
19 changes: 1 addition & 18 deletions packages/json-document-editing/src/kanban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JSONValue> {
readonly id: string;
Expand Down Expand Up @@ -215,21 +216,3 @@ function success(snapshot: EditingSnapshot<KanbanSelection>): EditingResult<Kanb
function failure(code: string): EditingResult<KanbanSelection> {
return { ok: false, code };
}

function assertKanbanDocument(document: KanbanDocument): void {
const cardIds = new Set<string>();
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<string>();
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)}.`);
}
}
}
12 changes: 12 additions & 0 deletions packages/json-document-editing/src/object-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ObjectDocument } from "./object.js";

export function assertObjectDocument(document: ObjectDocument): void {
const ids = new Set<string>();
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);
}
}
16 changes: 1 addition & 15 deletions packages/json-document-editing/src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JSONValue> {
readonly id: string;
Expand Down Expand Up @@ -250,21 +251,6 @@ function selectionFor(
return { kind: "explicit", keys: [...keys], primaryKey };
}

function assertObjectDocument(document: ObjectDocument): void {
const ids = new Set<string>();
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<ObjectSelection>): EditingResult<ObjectSelection> {
return { ok: true, snapshot };
}
Expand Down
10 changes: 10 additions & 0 deletions packages/json-document-editing/src/order-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { OrderDocument } from "./order.js";

export function assertOrderDocument(document: OrderDocument): void {
const ids = new Set<string>();
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);
}
}
10 changes: 1 addition & 9 deletions packages/json-document-editing/src/order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -218,15 +219,6 @@ function cloneItemsWithUniqueIds(
});
}

function assertOrderDocument(document: OrderDocument): void {
const ids = new Set<string>();
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<OrderSelection>): EditingResult<OrderSelection> {
return { ok: true, snapshot };
}
Expand Down
18 changes: 18 additions & 0 deletions packages/json-document-editing/src/sheet-validation.ts
Original file line number Diff line number Diff line change
@@ -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<string>, label: "row" | "column"): void {
const unique = new Set<string>();
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);
}
}
24 changes: 2 additions & 22 deletions packages/json-document-editing/src/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -361,7 +362,7 @@ function resolveTopology(document: SheetDocument, topology?: SheetTopology, inde
}

function assertTopologyAxis(ids: ReadonlyArray<string>, 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)}.`);
}
Expand Down Expand Up @@ -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<string>, label: "row" | "column"): void {
const unique = new Set<string>();
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;
Expand Down
25 changes: 25 additions & 0 deletions packages/json-document-editing/src/tree-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { TreeDocument } from "./tree.js";

export function assertTreeDocument(document: TreeDocument): void {
const ids = new Set<string>();
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<string, 1 | 2>();
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);
}
}
Loading