From 88b980ad8908b199aaba522f92be00c7a2ebd687 Mon Sep 17 00:00:00 2001 From: Sal Rahman Date: Thu, 29 Oct 2026 11:24:39 -0700 Subject: [PATCH] pages: page property events, fold and reads (#132) A page can carry properties: a tree keyed by path, where each node holds a value, a set of items, both, or only what is under it. This adds the bottom layer of #84, all in @pbnotes/pages: five events, the fold over them, and the reads. Nothing writes these events yet and nothing shows them; the builders and commands are #133. properties.ts holds the events (page.property.set, .unset, .item.added, .item.removed and .removed, each at v: 1), the fold and the reads. The fold parses a payload into edits to single paths, or skips the event whole if it is malformed, then applies the edits. removed is several of the edits the single events make, applied in one go: it clears a seen value only if it is still the one seen, and the items it lists, so what another device wrote that the remover never saw survives. The fold trims path segments and puts every string in NFC; scalars are strings, finite numbers, booleans and null, and keep their type. No timestamps are kept. Pages gains properties beside blocks and titles, and pagesFold runs the block fold, then the property fold. block.created built its result from blocks and titles alone, which would have dropped properties; it now spreads the state. HANDLES is the block table plus the property table; the block half is BLOCK_HANDLES inside the package. getPages lists a day that has properties and no blocks. A property event spells its page's title the way block.created does. Closes #132. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/pages/README.md | 35 ++- packages/pages/blocks.test.ts | 9 +- packages/pages/blocks.ts | 20 +- packages/pages/pages.test.ts | 5 +- packages/pages/pages.ts | 64 ++++- packages/pages/properties.test.ts | 332 ++++++++++++++++++++++ packages/pages/properties.ts | 448 ++++++++++++++++++++++++++++++ 7 files changed, 891 insertions(+), 22 deletions(-) create mode 100644 packages/pages/properties.test.ts create mode 100644 packages/pages/properties.ts diff --git a/packages/pages/README.md b/packages/pages/README.md index 6f31705..2be501d 100644 --- a/packages/pages/README.md +++ b/packages/pages/README.md @@ -16,7 +16,7 @@ To write, hand `addBlock`, `editBlock`, `deleteBlock`, `indentBlock` or `outdent Each of those writes one event, and each event has a pure builder: `blockCreated`, `blockEdited`, `blockDeleted`, `blockIndented` and `blockOutdented`. A builder returns `{ v, type, payload }` with `v` from `HANDLES`, so a writer that holds no fold can still write a valid event, but it skips the checks. -The queries (`getPage`, `getPages`, `getReferences`, `getPreviews`, `searchPages` and `locateBlock`) take the folded pages alone, since they never write. +The queries (`getPage`, `getPages`, `getReferences`, `getPreviews`, `searchPages` and `locateBlock`) take the folded pages alone, since they never write. So do the property reads under [Page properties](#page-properties). ## What it does not depend on @@ -30,3 +30,36 @@ Every event type is `NAMESPACE` followed by a name, for example `github.com/shov - **A name and a `v` never get a new meaning.** Once a log holds an event, it stays on disk forever, and every reader of that folder will see it. A payload that changes shape gets a new `v`, and a new kind of event gets a new name. - **The namespace is an identifier, not a link.** Do not change its bytes, even if the repository moves. - **`pagesFold` is the gate.** It skips a type that is not in `HANDLES` and a `v` higher than `HANDLES` gives, so an older reader passes over what it cannot read and does not fold it wrongly. Do not call `reduce` on events from a log without the gate in front of it. + +## Page properties + +A page can carry properties: values about the page itself, such as a status, its aliases or a cover. They are a tree. A key is a **path**, a non-empty array of strings, and every node holds a value, a set of items, both, or only what is under it. The events give no key a meaning: what `["app", "cover"]` is, is a rule in whatever reads it. By convention the first segment says who owns a key: `["user", ...]` for what people type, `["app", ...]` for keys the app gives a meaning to. + +| Type | Payload | Meaning | +|---|---|---| +| `page.property.set` | `{ page, path, value }` | The value at `path` is now `value`. Its items are untouched. | +| `page.property.unset` | `{ page, path }` | `path` has no value. Its items are untouched. | +| `page.property.item.added` | `{ page, path, item }` | `item` is among the items at `path`. Adding one already there moves it to the end. The value is untouched. | +| `page.property.item.removed` | `{ page, path, item }` | `item` is no longer among them. The value is untouched. | +| `page.property.removed` | `{ page, seen: [{ path, value?, items }] }` | A property removed: for each path in `seen`, the value if it is still the one seen, and each item listed. What is not listed stays. | + +Each is `NAMESPACE` followed by its name, at `v: 1`. + +- **A scalar** is a string, a finite number, a boolean or `null`, and the four stay distinct: `1`, `"1"`, `true` and `null` are four values. `null` is a value; `unset` is how a path stops having one. +- **The fold trims each path segment and puts every string in NFC,** so `" status "` and `"status"` are one key, and `Café` typed two ways is one key, one value and one item. A writer doesn't need to; the fold does it on every read. +- **A malformed event is skipped whole:** a page that isn't a non-empty title, a path that isn't a non-empty array of strings, a segment that is empty once trimmed, a value or item that isn't a scalar, or a `removed` whose `seen` is empty or has one bad entry. +- **No timestamp in any payload,** and the fold keeps none. +- **No create and no delete by prefix.** A node exists while it or something under it holds something. A removal lists what the writer saw, so what another device added in the meantime survives it. +- **The later write in fold order wins,** for the value and for each item on its own, so two devices writing different paths, or different items on one path, never overwrite each other. +- **A property event spells its page's title** the way `block.created` does, and a page with properties and no blocks is still a page. + +The reads take the folded pages, a page title and a path. Each returns a snapshot. + +- `get(pages, page, path)`: `{ path, value, items }`, both slots, so no reader has to choose. A path with nothing at it reads as no value and no items. +- `getValue` and `getItems`: one slot each. `getItems` is never undefined, and its last item is the latest added. +- `list(pages, page, scope = [])`: every path at or under `scope` that holds something, sorted by path. `[]` is the page root. +- `children(pages, page, path)`: the next segment of every path under `path`, each once, sorted. +- `path("a/b/c")` and `showPath(["a", "b", "c"])`: a path from text and back, for code and UI. The events always carry the array. + +Sorting is by path segment, by codepoint, the same on every device. The order means nothing. + diff --git a/packages/pages/blocks.test.ts b/packages/pages/blocks.test.ts index 10e8eca..d5457dc 100644 --- a/packages/pages/blocks.test.ts +++ b/packages/pages/blocks.test.ts @@ -8,7 +8,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { - HANDLES, + BLOCK_HANDLES, indented, NAMESPACE, outdented, @@ -21,7 +21,7 @@ import type { Block, PageEvent, Pages } from "./blocks.ts"; const TODAY = "2026-09-21"; /** Nothing written yet. */ -const empty = (): Pages => ({ blocks: {}, titles: {} }); +const empty = (): Pages => ({ blocks: {}, titles: {}, properties: new Map() }); /** One event, straight into `reduce`, with no log or device around it. */ function event(type: string, v: number, payload: unknown) { @@ -96,6 +96,7 @@ test("an event is folded under its namespaced name and no other", () => { assert.deepEqual(reduce(none, event(`${NAMESPACE}block.created`, 1, payload)), { blocks: { [TODAY]: [{ id: "b1", text: "first", children: [] }] }, titles: { [TODAY]: TODAY }, + properties: new Map(), }); assert.equal(reduce(none, event("block.created", 1, payload)), none); // Only the exact prefix. A slash is what #37 first proposed and is not it. @@ -104,8 +105,8 @@ test("an event is folded under its namespaced name and no other", () => { none, ); - assert.equal(Object.keys(HANDLES).length, 5); - assert.equal(Object.keys(HANDLES).every((type) => type.startsWith(NAMESPACE)), true); + assert.equal(Object.keys(BLOCK_HANDLES).length, 5); + assert.equal(Object.keys(BLOCK_HANDLES).every((type) => type.startsWith(NAMESPACE)), true); }); test("a prediction is the fold, and folding it over a page that has it changes nothing", () => { diff --git a/packages/pages/blocks.ts b/packages/pages/blocks.ts index bf07918..b5e35ab 100644 --- a/packages/pages/blocks.ts +++ b/packages/pages/blocks.ts @@ -21,6 +21,7 @@ */ import { read } from "./reading.ts"; +import type { Properties } from "./properties.ts"; export type Block = { id: string; @@ -106,10 +107,15 @@ export function pageKey(title: string): string { * overwritten: editing the link away later leaves it as it was. A page can * have a spelling and no blocks yet — a link names it before anyone writes on * it — so every key in `blocks` is in `titles` but not the other way round. + * + * `properties` sits beside the blocks, not inside them, so nothing that reads + * blocks changes (`properties.ts`, #84). A property event spells its page in + * `titles` too, so every key in `properties` is in `titles` as well. */ export type Pages = { blocks: Record; titles: Record; + properties: Properties; }; /** @@ -134,9 +140,12 @@ export function titleOf(pages: Pages, title: string): string { /** * `titles` with a spelling for each of `names` whose key has none yet, in - * order, so the first of two spellings in one event is the one kept. + * order, so the first of two spellings in one event is the one kept. The + * same object back when every name already has one. + * + * Exported for the property fold, which spells a page the same way. */ -function spell(titles: Pages["titles"], names: string[]): Pages["titles"] { +export function spell(titles: Pages["titles"], names: string[]): Pages["titles"] { let next = titles; for (const name of names) { const key = pageKey(name); @@ -235,11 +244,12 @@ type Named = `${typeof NAMESPACE}${T}`; * new event type without a version here, or a version here for an event type * that does not exist, stops the build. * - * The gate itself is `pagesFold` in `pages.ts`. Its job is the case a + * The block events' half of `HANDLES` in `pages.ts`, which is the table the + * gate reads. The gate itself is `pagesFold` there. Its job is the case a * single-writer log could never produce: a newer build on another machine * writing something this one does not understand. Those events are skipped. */ -export const HANDLES: Record = { +export const BLOCK_HANDLES: Record = { [`${NAMESPACE}block.created`]: 1, // 2 since the page came off the payload. Nothing needed an upcast in this // direction — the field is simply unread — but an older build handed one of @@ -423,6 +433,7 @@ export function reduce( block, ]; return { + ...state, blocks: { ...state.blocks, [pageKey(page)]: next }, // The page before its links: it is where the user was writing. titles: spell(state.titles, [page, ...linksIn(text)]), @@ -568,6 +579,7 @@ export function predict(page: Page, events: PageEvent[]): Page { let state: Pages = { blocks: { [key]: page.blocks }, titles: { [key]: page.title }, + properties: new Map(), }; for (const event of events) { if (!landed(state, event)) state = reduce(state, event); diff --git a/packages/pages/pages.test.ts b/packages/pages/pages.test.ts index 65f3d70..39aa217 100644 --- a/packages/pages/pages.test.ts +++ b/packages/pages/pages.test.ts @@ -117,7 +117,7 @@ test("each builder is the event its command writes, and folds to the same pages" } }); -test("every builder writes the v its own gate reads, and there is one for every event", () => { +test("every builder writes the v its own gate reads, and there is one for every block event", () => { const built = [ blockCreated(TODAY, "x", undefined, "x"), blockEdited("x", "y"), @@ -126,9 +126,10 @@ test("every builder writes the v its own gate reads, and there is one for every blockOutdented({ id: "x", after: "y" }), ]; for (const event of built) assert.equal(event.v, HANDLES[event.type], event.type); + // Block events only: the property events' builders are #133. assert.deepEqual( built.map((event) => event.type).sort(), - Object.keys(HANDLES).sort(), + Object.keys(HANDLES).filter((type) => type.startsWith(`${NAMESPACE}block.`)).sort(), ); }); diff --git a/packages/pages/pages.ts b/packages/pages/pages.ts index 51d1f1e..4dc4bf2 100644 --- a/packages/pages/pages.ts +++ b/packages/pages/pages.ts @@ -14,9 +14,9 @@ * fold still gets wrong then, is `app/docs/block-sync.md`. */ import { + BLOCK_HANDLES, blocksOn, find, - HANDLES, indented, locate, NAMESPACE, @@ -34,12 +34,13 @@ import type { Pages, } from "./blocks.ts"; import { linking, read } from "./reading.ts"; +import { PROPERTY_HANDLES, reduceProperties } from "./properties.ts"; +import type { PropertyEvent } from "./properties.ts"; export { beside, blocksOn, find, - HANDLES, indented, lastLeaf, locate, @@ -64,6 +65,28 @@ export type { Pages, } from "./blocks.ts"; export { inCode, linking, read } from "./reading.ts"; +export { + children, + get, + getItems, + getValue, + list, + path, + showPath, +} from "./properties.ts"; +export type { + Entry, + Properties, + Property, + PropertyEvent, + PropertyItemAdded, + PropertyItemRemoved, + PropertyRemoved, + PropertySet, + PropertyUnset, + Scalar, + Seen, +} from "./properties.ts"; export { remarkPlugins, remarkWikilink } from "./wikilink.ts"; /** @@ -88,6 +111,16 @@ export function today(now: Date): string { return `${now.getFullYear()}-${month}-${day}`; } +/** + * Event type → the payload `v` this build folds, which is also the `v` it + * writes: the block events and the property events in one table, because + * the gate reads one. Public contract with `NAMESPACE`; see the README. + */ +export const HANDLES: Record = { + ...BLOCK_HANDLES, + ...PROPERTY_HANDLES, +}; + /** * The pages fold, for whoever runs it over a ledger: `ledger.onEvent( * pagesFold.initial, pagesFold.reduce)`. @@ -99,12 +132,14 @@ export function today(now: Date): string { * and the rest) take `v` from the same table. */ export const pagesFold = { + // The block fold, then the property fold. Each hands back the state it was + // given for an event that is not its own, so composing them is all it takes. reduce: (state: Pages, event: { type: string; v: number; payload: unknown }): Pages => Object.hasOwn(HANDLES, event.type) && - event.v <= HANDLES[event.type as PageEvent["type"]] - ? reduce(state, event) + event.v <= HANDLES[event.type as keyof typeof HANDLES] + ? reduceProperties(reduce(state, event), event) : state, - initial: { blocks: {}, titles: {} } as Pages, + initial: { blocks: {}, titles: {}, properties: new Map() } as Pages, }; /** @@ -137,7 +172,8 @@ export function getPage(pages: Pages, title: string): Page { * * A day that folds to nothing is left out. A date whose only block was * deleted is not a page the user wrote on any more, and a heading with - * nothing under it reads like something went missing. + * nothing under it reads like something went missing. A day that has + * properties and no blocks is still a day (#84). * * Today is not added here. Which day that is depends on the clock, and this * is a reading of the log — a caller that shows a journal puts today at the @@ -147,11 +183,17 @@ export function getPage(pages: Pages, title: string): Page { * dates themselves. That is the reason for the format. */ export function getPages(pages: Pages): Page[] { - const { blocks, titles } = pages; - return Object.entries(blocks) - .filter(([key, on]) => DATE_PATTERN.test(key) && on.length > 0) - .sort(([a], [b]) => b.localeCompare(a)) - .map(([key, on]) => ({ title: titles[key], blocks: on })); + const { blocks, titles, properties } = pages; + const on = (key: string): Block[] => + Object.hasOwn(blocks, key) ? blocks[key] : []; + const days = new Set([ + ...Object.keys(blocks).filter((key) => on(key).length > 0), + ...properties.keys(), + ]); + return [...days] + .filter((key) => DATE_PATTERN.test(key)) + .sort((a, b) => b.localeCompare(a)) + .map((key) => ({ title: titles[key], blocks: on(key) })); } /** diff --git a/packages/pages/properties.test.ts b/packages/pages/properties.test.ts new file mode 100644 index 0000000..e5b6454 --- /dev/null +++ b/packages/pages/properties.test.ts @@ -0,0 +1,332 @@ +/** + * Run with: npm test + * + * Page properties folded through the gate, with no log around them: each + * event is `{ type, v, payload }`, which is all the fold reads. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + children, + get, + getItems, + getPage, + getPages, + getValue, + HANDLES, + list, + NAMESPACE, + pagesFold, + path, + showPath, +} from "./pages.ts"; +import type { Pages } from "./pages.ts"; + +const PAGE = "Mira"; +const DAY = "2026-10-29"; + +type Event = { type: string; v: number; payload: unknown }; + +/** A property event of `name`, as written. */ +const ev = (name: string, payload: unknown, v = 1): Event => ({ + type: `${NAMESPACE}page.property.${name}`, + v, + payload, +}); + +const set = (path: unknown, value: unknown, page: unknown = PAGE) => + ev("set", { page, path, value }); +const unset = (path: unknown, page: unknown = PAGE) => ev("unset", { page, path }); +const added = (path: unknown, item: unknown, page: unknown = PAGE) => + ev("item.added", { page, path, item }); +const removedItem = (path: unknown, item: unknown, page: unknown = PAGE) => + ev("item.removed", { page, path, item }); +const removed = (seen: unknown, page: unknown = PAGE) => ev("removed", { page, seen }); + +/** `events` folded in order, from nothing. */ +function fold(...events: Event[]): Pages { + return events.reduce((state, event) => pagesFold.reduce(state, event), pagesFold.initial); +} + +/** What `path` holds on `page`, as plain data to compare. */ +function at(state: Pages, path: string[], page = PAGE) { + const { value, items } = get(state, page, path); + return { value, items: [...items] }; +} + +const STATUS = ["user", "status"]; + +test("the table has a v for each of the five property events", () => { + for (const name of ["set", "unset", "item.added", "item.removed", "removed"]) { + assert.equal(HANDLES[`${NAMESPACE}page.property.${name}` as keyof typeof HANDLES], 1, name); + } +}); + +test("set and unset touch the value, and leave the items alone", () => { + const state = fold(added(STATUS, "a"), set(STATUS, "draft")); + assert.deepEqual(at(state, STATUS), { value: "draft", items: ["a"] }); + assert.deepEqual(at(fold(added(STATUS, "a"), set(STATUS, "draft"), unset(STATUS)), STATUS), { + value: undefined, + items: ["a"], + }); +}); + +test("item.added and item.removed touch the items, and leave the value alone", () => { + const state = fold(set(STATUS, "draft"), added(STATUS, "a"), added(STATUS, "b")); + assert.deepEqual(at(state, STATUS), { value: "draft", items: ["a", "b"] }); + assert.deepEqual( + at(pagesFold.reduce(state, removedItem(STATUS, "a")), STATUS), + { value: "draft", items: ["b"] }, + ); +}); + +test("the later write in fold order wins, for the value and for each item", () => { + assert.equal(getValue(fold(set(STATUS, "a"), set(STATUS, "b")), PAGE, STATUS), "b"); + assert.equal(getValue(fold(set(STATUS, "b"), set(STATUS, "a")), PAGE, STATUS), "a"); + assert.equal(getValue(fold(set(STATUS, "a"), unset(STATUS)), PAGE, STATUS), undefined); + assert.equal(getValue(fold(unset(STATUS), set(STATUS, "a")), PAGE, STATUS), "a"); + + assert.deepEqual([...getItems(fold(added(STATUS, "x"), removedItem(STATUS, "x")), PAGE, STATUS)], []); + assert.deepEqual([...getItems(fold(removedItem(STATUS, "x"), added(STATUS, "x")), PAGE, STATUS)], ["x"]); +}); + +test("two writers adding different items to one path keep both, in either order", () => { + const one = getItems(fold(added(STATUS, "a"), added(STATUS, "b")), PAGE, STATUS); + const other = getItems(fold(added(STATUS, "b"), added(STATUS, "a")), PAGE, STATUS); + assert.deepEqual([...one].sort(), ["a", "b"]); + assert.deepEqual([...other].sort(), ["a", "b"]); +}); + +test("adding an item that is already there moves it to the end", () => { + const state = fold(added(STATUS, "a"), added(STATUS, "b"), added(STATUS, "a")); + assert.deepEqual([...getItems(state, PAGE, STATUS)], ["b", "a"]); +}); + +test("a path emptied by its last unset or item.removed is gone, and so is a page left with nothing", () => { + const note = ["user", "status", "note"]; + const state = fold(set(note, "x"), added(STATUS, "a")); + assert.deepEqual(children(state, PAGE, ["user"]), ["status"]); + + const emptied = fold(set(note, "x"), added(STATUS, "a"), unset(note), removedItem(STATUS, "a")); + assert.deepEqual(list(emptied, PAGE), []); + assert.deepEqual(children(emptied, PAGE, []), []); + assert.equal(emptied.properties.size, 0); + + // A parent that holds nothing itself goes with the last thing under it. + const parentless = fold(set(note, "x"), unset(note)); + assert.deepEqual(children(parentless, PAGE, ["user"]), []); +}); + +test("a page with properties and no blocks is still a page, under its first spelling", () => { + const state = fold(set(["user", "mood"], "good", DAY)); + assert.deepEqual(getPages(state).map((page) => page.title), [DAY]); + assert.deepEqual(getPages(state)[0].blocks, []); + + const spelled = fold(set(STATUS, "a", "MIRA"), set(STATUS, "b", "mira")); + assert.equal(getPage(spelled, "mira").title, "MIRA"); + const blockLater = pagesFold.reduce(spelled, { + type: `${NAMESPACE}block.created`, + v: 1, + payload: { page: "Mira", id: "b1", text: "hello" }, + }); + assert.equal(getPage(blockLater, "MIRA").title, "MIRA"); + assert.equal(getPage(blockLater, "mira").blocks.length, 1); + // The property is still there after the block event. + assert.equal(getValue(blockLater, "Mira", STATUS), "b"); +}); + +test("a malformed payload is skipped whole and not applied in part", () => { + const before = fold(set(STATUS, "kept")); + for (const event of [ + set([], "x"), + set("user/status", "x"), + set(["user", 3], "x"), + set(["user", ""], "x"), + set(["user", " "], "x"), + set(STATUS, undefined), + set(STATUS, Number.NaN), + set(STATUS, Number.POSITIVE_INFINITY), + set(STATUS, { nested: true }), + set(STATUS, ["a"]), + set(STATUS, "x", 7), + set(STATUS, "x", " "), + added(STATUS, undefined), + ev("set", null), + ev("set", "not an object"), + ev("set", { path: STATUS, value: "x" }), + removed([]), + removed("nope"), + removed([{ path: STATUS, items: ["a"] }, { path: [], items: [] }]), + removed([{ path: STATUS }]), + removed([{ path: STATUS, items: [Number.NaN] }]), + removed([{ path: STATUS, value: Number.NaN, items: [] }]), + ev("renamed", { page: PAGE, path: STATUS }), + ]) { + assert.equal(pagesFold.reduce(before, event), before, JSON.stringify(event.payload)); + } +}); + +test("the gate skips a property event with a v this build does not read", () => { + const before = pagesFold.initial; + assert.equal(pagesFold.reduce(before, ev("set", { page: PAGE, path: STATUS, value: "x" }, 2)), before); +}); + +test("__proto__ as a title, a segment or an item is an ordinary key", () => { + const proto = "__proto__"; + const state = fold( + set(["user", proto], "x", proto), + added(["user", proto], proto, proto), + set(["toString"], "y"), + ); + assert.deepEqual(at(state, ["user", proto], proto), { value: "x", items: [proto] }); + assert.equal(getValue(state, PAGE, ["toString"]), "y"); + assert.deepEqual(children(state, proto, ["user"]), [proto]); + assert.deepEqual(getPage(state, proto).blocks, []); +}); + +test("NFC and NFD spellings of one string are one segment, one value and one item", () => { + const nfc = "Café"; + const nfd = "Café"; + const state = fold(set(["user", nfd], nfd), added(["user", nfc], nfd), added(["user", nfd], nfc)); + assert.deepEqual(at(state, ["user", nfc]), { value: nfc, items: [nfc] }); + assert.deepEqual(children(state, PAGE, ["user"]), [nfc]); +}); + +test("scalars keep their type, segments are trimmed, and empty strings are values", () => { + const items = getItems(fold(added(STATUS, 1), added(STATUS, "1"), added(STATUS, true), added(STATUS, null)), PAGE, STATUS); + assert.deepEqual([...items], [1, "1", true, null]); + + assert.deepEqual(at(fold(set(STATUS, null)), STATUS), { value: null, items: [] }); + assert.equal(list(fold(set(STATUS, null)), PAGE).length, 1); + assert.deepEqual(list(fold(set(STATUS, null), unset(STATUS)), PAGE), []); + + const trimmed = fold(set([" user ", "\tstatus\n"], "x")); + assert.equal(getValue(trimmed, PAGE, STATUS), "x"); + assert.equal(getValue(trimmed, PAGE, [" user", "status "]), "x"); + + assert.deepEqual(at(fold(set(STATUS, ""), added(STATUS, "")), STATUS), { value: "", items: [""] }); + + // -0 is 0, as JSON writes it, and 1.0 is 1. + assert.deepEqual([...getItems(fold(added(STATUS, -0), added(STATUS, 0), added(STATUS, 1.0), added(STATUS, 1)), PAGE, STATUS)], [0, 1]); +}); + +test("removed clears what it lists, at a path and under it", () => { + const note = ["user", "status", "note"]; + const state = fold(set(STATUS, "draft"), added(STATUS, "a"), set(note, "x")); + const gone = pagesFold.reduce( + state, + removed([ + { path: STATUS, value: "draft", items: ["a"] }, + { path: note, value: "x", items: [] }, + ]), + ); + assert.deepEqual(list(gone, PAGE), []); + assert.deepEqual(children(gone, PAGE, ["user"]), []); +}); + +test("removed leaves what the writer did not see, in either fold order", () => { + const note = ["user", "status", "note"]; + const base = [set(STATUS, "draft"), added(STATUS, "a")]; + const removal = removed([{ path: STATUS, value: "draft", items: ["a"] }]); + const concurrent = [set(STATUS, "done"), added(STATUS, "b"), set(note, "y")]; + + for (const state of [fold(...base, ...concurrent, removal), fold(...base, removal, ...concurrent)]) { + assert.deepEqual(at(state, STATUS), { value: "done", items: ["b"] }); + assert.equal(getValue(state, PAGE, note), "y"); + } +}); + +test("removed takes a seen value that is still there, null included, and leaves the value for an absent one", () => { + assert.equal( + getValue(fold(set(STATUS, null), removed([{ path: STATUS, value: null, items: [] }])), PAGE, STATUS), + undefined, + ); + // A seen `null` is not a seen "nothing": a value of "x" stays. + assert.equal( + getValue(fold(set(STATUS, "x"), removed([{ path: STATUS, value: null, items: [] }])), PAGE, STATUS), + "x", + ); + // No `value`: the writer saw no value here, so whatever is there stays. + assert.deepEqual( + at(fold(set(STATUS, "x"), added(STATUS, "a"), removed([{ path: STATUS, items: ["a"] }])), STATUS), + { value: "x", items: [] }, + ); + // The seen value compares the way the fold does: NFD finds NFC, -0 finds 0. + assert.equal( + getValue(fold(set(STATUS, "Café"), removed([{ path: STATUS, value: "Café", items: [] }])), PAGE, STATUS), + undefined, + ); + assert.equal(getValue(fold(set(STATUS, 0), removed([{ path: STATUS, value: -0, items: [] }])), PAGE, STATUS), undefined); +}); + +test("list and children read the tree, sorted by codepoint", () => { + const state = fold( + set(["user", "foobar"], 2), + set(["user", "foo"], 1), + set(["user", "foo", "deep", "er"], 3), + added(["user", "foo", "list"], "i"), + set(["app", "cover"], "x"), + // U+FF5E sorts after U+1F600 by UTF-16 unit, before it by codepoint. + set(["user", "\u{1F600}"], "smile"), + set(["user", "~"], "tilde"), + ); + + assert.deepEqual(children(state, PAGE, []), ["app", "user"]); + assert.deepEqual(children(state, PAGE, ["user"]), ["foo", "foobar", "~", "\u{1F600}"]); + assert.deepEqual(children(state, PAGE, ["user", "foo"]), ["deep", "list"]); + assert.deepEqual(children(state, PAGE, ["user", "foo", "deep"]), ["er"]); + assert.deepEqual(children(state, PAGE, ["user", "foo", "deep", "er"]), []); + + // A scope that holds something itself comes first, then what is under it; + // ["foo"] is not a prefix of ["foobar"]. + assert.deepEqual( + list(state, PAGE, ["user", "foo"]).map((it) => showPath(it.path)), + ["user/foo", "user/foo/deep/er", "user/foo/list"], + ); + assert.deepEqual( + list(state, PAGE).map((it) => showPath(it.path)), + ["app/cover", "user/foo", "user/foo/deep/er", "user/foo/list", "user/foobar", "user/~", "user/\u{1F600}"], + ); + // Only paths that hold something: "user/foo/deep" is in neither list. + assert.equal(get(state, PAGE, ["user", "foo", "deep"]).value, undefined); + // The root holds nothing. + assert.deepEqual(at(state, []), { value: undefined, items: [] }); + // A page with nothing, and a scope that can't be a path, read as empty. + assert.deepEqual(list(state, "Nobody"), []); + assert.deepEqual(list(state, PAGE, [" "]), []); + assert.deepEqual(children(state, PAGE, ["nothing", "here"]), []); +}); + +test("reads are snapshots, and the fold never changes the state it is handed", () => { + const state = fold(set(STATUS, "draft"), added(STATUS, "a")); + const read = get(state, PAGE, STATUS); + read.path.push("changed"); + (read.items as Set).add("b"); + assert.deepEqual(at(state, STATUS), { value: "draft", items: ["a"] }); + + const page = state.properties.get("mira"); + const snapshot = structuredClone(state.properties); + pagesFold.reduce(state, set(STATUS, "done")); + pagesFold.reduce(state, added(STATUS, "c")); + pagesFold.reduce(state, removed([{ path: STATUS, value: "draft", items: ["a"] }])); + assert.equal(state.properties.get("mira"), page); + assert.deepEqual(state.properties, snapshot); +}); + +test("an event that changes nothing, and one that is not a property event, hand back the same state", () => { + const state = fold(set(STATUS, "draft")); + assert.equal(pagesFold.reduce(state, unset(["user", "other"])), state); + assert.equal(pagesFold.reduce(state, removedItem(STATUS, "absent")), state); + assert.equal(pagesFold.reduce(state, removed([{ path: STATUS, value: "other", items: [] }])), state); + const block = pagesFold.reduce(state, { + type: `${NAMESPACE}block.edited`, + v: 2, + payload: { id: "nope", text: "x" }, + }); + assert.equal(block, state); +}); + +test("path and showPath turn text into a path and back", () => { + assert.deepEqual(path("a/b/c"), ["a", "b", "c"]); + assert.equal(showPath(["a", "b", "c"]), "a/b/c"); +}); diff --git a/packages/pages/properties.ts b/packages/pages/properties.ts new file mode 100644 index 0000000..07ff0ff --- /dev/null +++ b/packages/pages/properties.ts @@ -0,0 +1,448 @@ +/** + * Page properties: values about a page itself, such as a status, its aliases + * or a cover (#84). The events, the fold over them, and the reads. + * + * A page's properties are a tree. A key is a path, a non-empty list of + * strings, and every node on it holds a value, a set of items, both, or + * nothing but what is under it. Nobody declares which a key is before writing + * to it, and the format gives no key a meaning: "the cover is the value at + * `["app", "cover"]`" is a rule in whatever reads it, not in the events. + * + * The fold is two steps, kept apart. `parse` takes a payload from a shared + * folder, which may be anything, and either turns it into edits or refuses + * it whole. `apply` takes edits it can trust and changes the state. Every + * event is a list of edits, each one a change to one path's entry, and edits + * compose: `removed` is nothing but several of the edits the four single + * events make, applied in one go. + * + * Like `blocks.ts`, nothing here touches a log, a clock or a window. + */ + +import { NAMESPACE, pageKey, spell } from "./blocks.ts"; + +/** + * A value or an item. The four types stay distinct: `1`, `"1"`, `true` and + * `null` are four values. `null` is a value, not an absence: `unset` is how + * a path stops having one. + * + * A number is a JSON number, so finite. A string is kept in NFC by the fold. + */ +export type Scalar = string | number | boolean | null; + +/** The value at `path` is now `value`. Its items are untouched. */ +export type PropertySet = { page: string; path: string[]; value: Scalar }; + +/** `path` has no value. Its items are untouched. */ +export type PropertyUnset = { page: string; path: string[] }; + +/** + * `item` is among the items at `path`. Adding one that is already there moves + * it to the end, which is what makes the last item the latest. The value is + * untouched. + */ +export type PropertyItemAdded = { page: string; path: string[]; item: Scalar }; + +/** `item` is no longer among the items at `path`. The value is untouched. */ +export type PropertyItemRemoved = { page: string; path: string[]; item: Scalar }; + +/** + * What a writer saw at one path when it removed a property. + * + * `value` absent: there was no value here, so the value is left alone. + * Present, `null` included: the value is removed only if it is still this + * one, so a value another device set that the remover never saw stays. + */ +export type Seen = { path: string[]; value?: Scalar; items: Scalar[] }; + +/** + * A property removed: for each path in `seen`, the value if it is still the + * one seen, and each item listed. What the writer did not see stays, which + * is why there is no "delete everything under this path": that would also + * remove what another device added in the meantime. + * + * One event for the whole removal, so a crash cannot leave half of it. + */ +export type PropertyRemoved = { page: string; seen: Seen[] }; + +/** An event type as it is written: under the namespace. */ +type Named = `${typeof NAMESPACE}${T}`; + +/** + * Every property event and its payload. No timestamp in any of them: the + * event's own `at` is when it was written, and this fold keeps no time. + */ +export type PropertyEvent = + | { type: Named<"page.property.set">; payload: PropertySet } + | { type: Named<"page.property.unset">; payload: PropertyUnset } + | { type: Named<"page.property.item.added">; payload: PropertyItemAdded } + | { type: Named<"page.property.item.removed">; payload: PropertyItemRemoved } + | { type: Named<"page.property.removed">; payload: PropertyRemoved }; + +/** Event type → the payload `v` this build reads and writes. See `HANDLES`. */ +export const PROPERTY_HANDLES: Record = { + [`${NAMESPACE}page.property.set`]: 1, + [`${NAMESPACE}page.property.unset`]: 1, + [`${NAMESPACE}page.property.item.added`]: 1, + [`${NAMESPACE}page.property.item.removed`]: 1, + [`${NAMESPACE}page.property.removed`]: 1, +}; + +/** + * What one path holds. Never empty: an entry with no value and no items is + * deleted, so a node with nothing in it is no node. + * + * The value is boxed so that `null`, a value, and no value stay apart. Items + * are keyed by their JSON, so `1` and `"1"` are two keys, and kept in the + * order they were last added. + */ +export type Entry = { + /** Trimmed and in NFC. */ + path: string[]; + value?: { v: Scalar }; + items: Map; +}; + +/** + * Every page's properties: page key (`pageKey`) → JSON of the path → entry. + * Flat, keyed by path, not a stored tree: an event touches only the entries + * it names, and the tree is a reading of this map that cannot disagree with + * it. `Map` throughout, so a title, a segment or an item of `__proto__` is an + * ordinary key (#62). + */ +export type Properties = Map>; + +/** One change to one path's entry. Returns the same entry when nothing changes. */ +type Edit = { path: string[]; change: (entry: Entry) => Entry }; + +const keyOf = (value: Scalar | string[]): string => JSON.stringify(value); + +const setValue = + (v: Scalar) => + (entry: Entry): Entry => ({ ...entry, value: { v } }); + +function unsetValue(entry: Entry): Entry { + if (!entry.value) return entry; + const { value: _, ...rest } = entry; + return rest; +} + +const unsetIf = + (v: Scalar) => + (entry: Entry): Entry => + entry.value && keyOf(entry.value.v) === keyOf(v) ? unsetValue(entry) : entry; + +const addItem = + (item: Scalar) => + (entry: Entry): Entry => { + const key = keyOf(item); + const items = new Map(entry.items); + items.delete(key); + items.set(key, item); + return { ...entry, items }; + }; + +const removeItem = + (item: Scalar) => + (entry: Entry): Entry => { + const key = keyOf(item); + if (!entry.items.has(key)) return entry; + const items = new Map(entry.items); + items.delete(key); + return { ...entry, items }; + }; + +/** A field the payload holds itself, never one every object inherits. */ +function field(payload: object, name: string): unknown { + return Object.hasOwn(payload, name) + ? (payload as Record)[name] + : undefined; +} + +/** + * A scalar as the fold keeps it, boxed so that `null` is not a refusal: + * strings in NFC, `-0` as `0`. Undefined for anything that is not a scalar, + * a non-finite number among them. + * + * `undefined` is refused, never read as "no value": `JSON.stringify` leaves a + * field holding it out, so on disk it is absent, and the event has to fold + * the same in memory as it will after a reopen. + */ +function scalar(value: unknown): { v: Scalar } | undefined { + if (value === null) return { v: null }; + if (typeof value === "boolean") return { v: value }; + if (typeof value === "string") return { v: value.normalize("NFC") }; + if (typeof value === "number" && Number.isFinite(value)) { + return { v: value === 0 ? 0 : value }; + } + return undefined; +} + +/** A segment as the fold keeps it: trimmed, then NFC. Undefined if that leaves nothing. */ +function segment(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim().normalize("NFC"); + return trimmed === "" ? undefined : trimmed; +} + +/** Every segment as the fold keeps it, or undefined if any one is refused. */ +function segments(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: string[] = []; + for (const it of value) { + const kept = segment(it); + if (kept === undefined) return undefined; + out.push(kept); + } + return out; +} + +/** A path for an event: segments, and at least one. */ +function pathOf(value: unknown): string[] | undefined { + const path = segments(value); + return path && path.length > 0 ? path : undefined; +} + +/** The edits one entry of a `removed` makes, or undefined if it is malformed. */ +function seenEdits(seen: unknown): Edit[] | undefined { + if (typeof seen !== "object" || seen === null) return undefined; + const path = pathOf(field(seen, "path")); + const listed = field(seen, "items"); + if (!path || !Array.isArray(listed)) return undefined; + const edits: Edit[] = []; + const raw = field(seen, "value"); + if (raw !== undefined) { + const value = scalar(raw); + if (!value) return undefined; + edits.push({ path, change: unsetIf(value.v) }); + } + for (const it of listed) { + const item = scalar(it); + if (!item) return undefined; + edits.push({ path, change: removeItem(item.v) }); + } + return edits; +} + +/** + * A property event as edits to one page, or undefined for anything else: an + * event that is not a property event, or one whose payload is malformed. A + * malformed event is skipped whole, never applied in part, and not reported + * (#104): it came from a shared folder, and may be anything. + */ +function parse( + type: string, + payload: unknown, +): { page: string; edits: Edit[] } | undefined { + if (!type.startsWith(NAMESPACE)) return undefined; + if (typeof payload !== "object" || payload === null) return undefined; + const page = field(payload, "page"); + if (typeof page !== "string" || pageKey(page) === "") return undefined; + + const named = type.slice(NAMESPACE.length); + if (named === "page.property.removed") { + const seen = field(payload, "seen"); + if (!Array.isArray(seen) || seen.length === 0) return undefined; + const edits: Edit[] = []; + for (const it of seen) { + const more = seenEdits(it); + if (!more) return undefined; + edits.push(...more); + } + return { page, edits }; + } + + const path = pathOf(field(payload, "path")); + if (!path) return undefined; + switch (named) { + case "page.property.set": { + const value = scalar(field(payload, "value")); + return value && { page, edits: [{ path, change: setValue(value.v) }] }; + } + case "page.property.unset": + return { page, edits: [{ path, change: unsetValue }] }; + case "page.property.item.added": { + const item = scalar(field(payload, "item")); + return item && { page, edits: [{ path, change: addItem(item.v) }] }; + } + case "page.property.item.removed": { + const item = scalar(field(payload, "item")); + return item && { page, edits: [{ path, change: removeItem(item.v) }] }; + } + default: + return undefined; + } +} + +/** + * `edits` applied in order to one page's entries. An entry left with nothing + * is deleted, then a page left with no entries. The same map back when no + * edit changed anything. + */ +function apply(properties: Properties, page: string, edits: Edit[]): Properties { + const key = pageKey(page); + const entries = new Map(properties.get(key)); + let changed = false; + for (const { path, change } of edits) { + const at = keyOf(path); + const current = entries.get(at) ?? { path, items: new Map() }; + const next = change(current); + if (next === current) continue; + changed = true; + if (next.value === undefined && next.items.size === 0) entries.delete(at); + else entries.set(at, next); + } + if (!changed) return properties; + const out = new Map(properties); + if (entries.size === 0) out.delete(key); + else out.set(key, entries); + return out; +} + +/** + * The property fold: applies one property event, and hands back `state` + * itself for every other event, so it composes with the block fold. + * + * Applied in the order the ledger folds, which is all "the later write wins" + * needs: each value and each item merges on its own, so two devices writing + * different paths, or different items on one path, never overwrite each + * other, and there are no tombstones. + * + * A property event spells its page's title the way `block.created` does: the + * first spelling the fold sees, from a block or a property, is the one kept. + * + * Pure, and it never changes what it is handed: the ledger keeps earlier + * states as checkpoints. Reads only what it declares, so any state with + * `titles` and `properties` folds through it. + */ +export function reduceProperties< + S extends { titles: Record; properties: Properties }, +>(state: S, event: { type: string; payload: unknown }): S { + const parsed = parse(event.type, event.payload); + if (!parsed) return state; + const properties = apply(state.properties, parsed.page, parsed.edits); + const titles = spell(state.titles, [parsed.page]); + return properties === state.properties && titles === state.titles + ? state + : { ...state, properties, titles }; +} + +/** What a read hands back about one path. A snapshot: changing it changes nothing. */ +export type Property = { + path: string[]; + value: Scalar | undefined; + items: ReadonlySet; +}; + +/** What the reads look at. `Pages` is one. */ +type Holds = { readonly properties: Properties }; + +const nothing = (path: string[]): Property => ({ + path, + value: undefined, + items: new Set(), +}); + +function snapshot(entry: Entry): Property { + return { + path: [...entry.path], + value: entry.value?.v, + items: new Set(entry.items.values()), + }; +} + +/** Whether `path` is `scope` or under it, segment by segment. */ +function within(path: string[], scope: string[]): boolean { + return scope.length <= path.length && scope.every((it, i) => path[i] === it); +} + +/** Two strings by codepoint, not by UTF-16 unit, so the order is the same everywhere. */ +function byCodepoint(a: string, b: string): number { + const x = Array.from(a, (c) => c.codePointAt(0) ?? 0); + const y = Array.from(b, (c) => c.codePointAt(0) ?? 0); + for (let i = 0; i < x.length && i < y.length; i++) { + if (x[i] !== y[i]) return x[i] - y[i]; + } + return x.length - y.length; +} + +/** Two paths segment by segment; a path sorts before what is under it. */ +function byPath(a: string[], b: string[]): number { + for (let i = 0; i < a.length && i < b.length; i++) { + const order = byCodepoint(a[i], b[i]); + if (order !== 0) return order; + } + return a.length - b.length; +} + +/** + * The entries of a page under `scope`, as the fold keeps them. A scope is + * read the way the fold reads a path, so `[" status "]` finds `["status"]`; + * one with a segment that is empty once trimmed finds nothing. + */ +function under(holds: Holds, page: string, scope: string[]): Entry[] { + const kept = segments(scope); + const entries = holds.properties.get(pageKey(page)); + if (!kept || !entries) return []; + return [...entries.values()].filter((entry) => within(entry.path, kept)); +} + +/** + * What `path` holds on `page`: its value and its items, both, so no reader + * has to choose. A path with nothing at it reads as no value and no items, + * the same as one never written. `[]`, the page root, holds nothing. + */ +export function get(holds: Holds, page: string, path: string[]): Property { + const kept = segments(path); + if (!kept) return nothing([...path]); + const entry = holds.properties.get(pageKey(page))?.get(keyOf(kept)); + return entry ? snapshot(entry) : nothing(kept); +} + +/** The value at `path` on `page`, or undefined for none. `null` is a value. */ +export function getValue(holds: Holds, page: string, path: string[]): Scalar | undefined { + return get(holds, page, path).value; +} + +/** The items at `path` on `page`, latest last. Empty, never undefined. */ +export function getItems(holds: Holds, page: string, path: string[]): ReadonlySet { + return get(holds, page, path).items; +} + +/** + * Every path on `page` at or under `scope` that holds a value or an item, + * sorted by path. `list(pages, page)` is every property the page has. + */ +export function list(holds: Holds, page: string, scope: string[] = []): Property[] { + return under(holds, page, scope) + .sort((a, b) => byPath(a.path, b.path)) + .map(snapshot); +} + +/** + * The next segment of every path on `page` strictly under `path`, each once, + * sorted. Calling it again on each name walks the whole tree; `[]` means + * nothing further down. + */ +export function children(holds: Holds, page: string, path: string[]): string[] { + const depth = path.length; + const names = new Set( + under(holds, page, path) + .filter((entry) => entry.path.length > depth) + .map((entry) => entry.path[depth]), + ); + return [...names].sort(byCodepoint); +} + +/** + * A path from text, `"a/b/c"` → `["a", "b", "c"]`, for code and UI that like + * typing strings. Never what the ledger holds: a segment may contain a `/`, + * and the events carry the array. + */ +export function path(text: string): string[] { + return text.split("/"); +} + +/** A path as text, `["a", "b", "c"]` → `"a/b/c"`, for display. */ +export function showPath(parts: string[]): string { + return parts.join("/"); +}