From fc3982456deb4c541f2082305e4d006468589ad3 Mon Sep 17 00:00:00 2001 From: thephez Date: Thu, 21 May 2026 14:26:16 -0400 Subject: [PATCH 1/2] fix(dashnote): drop misleading message maxLength from note schema The network enforces a 5120-byte per-field cap via max_field_value_size; JSON Schema maxLength counts characters, so any value here misrepresents the real limit. UI already gates input to 5120 B. Co-Authored-By: Claude Opus 4.7 (1M context) --- example-apps/dashnote/CLAUDE.md | 2 +- example-apps/dashnote/src/dash/contract.ts | 1 - example-apps/dashnote/test/contract.test.ts | 4 +++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/example-apps/dashnote/CLAUDE.md b/example-apps/dashnote/CLAUDE.md index f547c96..64de864 100644 --- a/example-apps/dashnote/CLAUDE.md +++ b/example-apps/dashnote/CLAUDE.md @@ -35,7 +35,7 @@ React + TypeScript + Vite app for personal notes on Dash Platform testnet. Notes Schema lives in [src/dash/contract.ts](src/dash/contract.ts) as `NOTE_SCHEMAS`. One document type, `note`: - `title` — optional string, max 120 chars, position 0 -- `message` — required string, max 10000 chars, position 1 +- `message` — required string, no schema `maxLength`; the real cap is the system-level `max_field_value_size` (5120 bytes / 5 KiB), enforced by the network. The UI gates input to that byte limit via [src/lib/fieldLimits.ts](src/lib/fieldLimits.ts). - `$createdAt`, `$updatedAt` — required (Platform-managed) - Indices: `byOwnerUpdated` (`$ownerId`, `$updatedAt`) and `byOwnerCreated` (`$ownerId`, `$createdAt`) - `documentsMutable: true`, `canBeDeleted: true` — notes are editable and deletable diff --git a/example-apps/dashnote/src/dash/contract.ts b/example-apps/dashnote/src/dash/contract.ts index 8fba063..7421c4f 100644 --- a/example-apps/dashnote/src/dash/contract.ts +++ b/example-apps/dashnote/src/dash/contract.ts @@ -22,7 +22,6 @@ export const NOTE_SCHEMAS = { }, message: { type: "string", - maxLength: 10000, position: 1, }, }, diff --git a/example-apps/dashnote/test/contract.test.ts b/example-apps/dashnote/test/contract.test.ts index 091991c..8b13163 100644 --- a/example-apps/dashnote/test/contract.test.ts +++ b/example-apps/dashnote/test/contract.test.ts @@ -69,9 +69,11 @@ describe("NOTE_SCHEMAS", () => { }); expect(NOTE_SCHEMAS.note.properties.message).toMatchObject({ type: "string", - maxLength: 10000, position: 1, }); + expect(NOTE_SCHEMAS.note.properties.message).not.toHaveProperty( + "maxLength", + ); expect(NOTE_SCHEMAS.note.indices).toEqual([ { name: "byOwnerUpdated", From 9a4918e06ca35dee85caa3c6b0df3fbf7b81ffca Mon Sep 17 00:00:00 2001 From: thephez Date: Thu, 21 May 2026 14:42:43 -0400 Subject: [PATCH 2/2] test(dashnote): cover fieldLimits byte counting for ASCII, CJK, and emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds boundary cases at 5119/5120/5121 bytes plus 2-byte (é), 3-byte (CJK), 4-byte (emoji), and ZWJ-joined sequences to lock in that the byte gate catches multi-byte payloads a char-based maxLength would have let through. Co-Authored-By: Claude Opus 4.7 (1M context) --- example-apps/dashnote/CLAUDE.md | 2 +- .../dashnote/test/fieldLimits.test.ts | 91 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 example-apps/dashnote/test/fieldLimits.test.ts diff --git a/example-apps/dashnote/CLAUDE.md b/example-apps/dashnote/CLAUDE.md index 64de864..ec48bd1 100644 --- a/example-apps/dashnote/CLAUDE.md +++ b/example-apps/dashnote/CLAUDE.md @@ -78,7 +78,7 @@ Adjacent invariants that fall out of the same design: - Read-only mode (`session.status === "readonly"`) sets `keyManager` to `null`. Any write path (`createNote`, `updateNote`, `deleteNote`, `registerContract`) must guard for an authenticated session. - The notes cache in [src/lib/notesCache.ts](src/lib/notesCache.ts) is keyed by `identityId + contractId + network`. Switching identity, contract, or network invalidates the cache. Schema is versioned (`SCHEMA_VERSION = 1`); bumping it discards prior cached payloads. - Background revalidation runs every `BACKGROUND_REFRESH_MS` (30s); refocus revalidation is throttled to `FOCUS_REFRESH_MIN_MS` (10s). Both compare via `notesEqualByRevision` so identical results don't trigger re-renders. -- Title/message length is enforced in **bytes**, not chars — emoji and non-ASCII multi-byte sequences eat budget. [src/lib/fieldLimits.ts](src/lib/fieldLimits.ts) is the source of truth; the editor's progress bar and the contract `maxLength` should stay in sync. +- Title/message length is enforced in **bytes**, not chars — emoji and non-ASCII multi-byte sequences eat budget. [src/lib/fieldLimits.ts](src/lib/fieldLimits.ts) is the source of truth and matches the network's `max_field_value_size` (5120 B). JSON Schema `maxLength` counts characters not bytes, so it can't accurately mirror this — the message field intentionally has no `maxLength` to avoid promising a cap the network doesn't honor. - The `Logger` from [src/lib/logger.ts](src/lib/logger.ts) is plumbed through every dash helper. `level: "success"` and `level: "error"` also raise sonner toasts via `SessionContext.log`. - The Evo SDK WASM bundle is ~8MB; this is expected and not a build error. See the [Performance](#performance--load-anchor-rules-dont-unwind-these) section above for the load-anchor rules that keep it off the boot critical path. - `allowJs: true` in [tsconfig.app.json](tsconfig.app.json) so TypeScript can import the JSDoc-typed `.mjs` core at the host repo root. diff --git a/example-apps/dashnote/test/fieldLimits.test.ts b/example-apps/dashnote/test/fieldLimits.test.ts new file mode 100644 index 0000000..ad39355 --- /dev/null +++ b/example-apps/dashnote/test/fieldLimits.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; + +import { + FIELD_BYTE_LIMIT, + byteLength, + isOversize, +} from "../src/lib/fieldLimits"; + +describe("FIELD_BYTE_LIMIT", () => { + it("matches the network's max_field_value_size (5120 B / 5 KiB)", () => { + expect(FIELD_BYTE_LIMIT).toBe(5120); + }); +}); + +describe("byteLength", () => { + it("returns 0 for an empty string", () => { + expect(byteLength("")).toBe(0); + }); + + it("counts ASCII as 1 byte per character", () => { + expect(byteLength("hello")).toBe(5); + expect(byteLength("a".repeat(5120))).toBe(5120); + }); + + it("counts 2-byte UTF-8 sequences (Latin-1 supplement) as 2 bytes each", () => { + // "é" is U+00E9, 2 bytes in UTF-8 (0xC3 0xA9) + expect(byteLength("é")).toBe(2); + expect(byteLength("café")).toBe(5); + }); + + it("counts 3-byte UTF-8 sequences (CJK) as 3 bytes each", () => { + // "漢" is U+6F22, 3 bytes in UTF-8 + expect(byteLength("漢")).toBe(3); + expect(byteLength("漢字")).toBe(6); + }); + + it("counts 4-byte UTF-8 sequences (emoji outside the BMP) as 4 bytes each", () => { + // "🚀" is U+1F680, 4 bytes in UTF-8 — one code point, two UTF-16 units. + // string.length === 2 but UTF-8 byteLength === 4. + expect("🚀".length).toBe(2); + expect(byteLength("🚀")).toBe(4); + }); + + it("counts ZWJ-joined emoji sequences correctly", () => { + // "👨‍👩‍👧" is three 4-byte emoji joined by two ZWJ (U+200D, 3 bytes each) + // → 4 + 3 + 4 + 3 + 4 = 18 bytes, even though it renders as one glyph. + const family = "👨‍👩‍👧"; + expect(byteLength(family)).toBe(18); + }); +}); + +describe("isOversize", () => { + it("returns false at exactly the byte limit (5120 ASCII chars)", () => { + const exact = "a".repeat(FIELD_BYTE_LIMIT); + expect(byteLength(exact)).toBe(FIELD_BYTE_LIMIT); + expect(isOversize(exact)).toBe(false); + }); + + it("returns false one byte under the limit", () => { + expect(isOversize("a".repeat(FIELD_BYTE_LIMIT - 1))).toBe(false); + }); + + it("returns true one byte over the limit", () => { + expect(isOversize("a".repeat(FIELD_BYTE_LIMIT + 1))).toBe(true); + }); + + it("treats 2-byte chars as 2 bytes against the limit", () => { + // 2560 × "é" = 5120 bytes (at limit, not oversize) + expect(isOversize("é".repeat(2560))).toBe(false); + // 2561 × "é" = 5122 bytes (over) + expect(isOversize("é".repeat(2561))).toBe(true); + }); + + it("treats CJK as 3 bytes — string.length stays well below the limit while bytes blow past it", () => { + // 1707 × "漢" = 5121 bytes (over). string.length is only 1707, + // so a char-based maxLength of 5120 would have let this through. + const cjk = "漢".repeat(1707); + expect(cjk.length).toBeLessThan(FIELD_BYTE_LIMIT); + expect(byteLength(cjk)).toBe(5121); + expect(isOversize(cjk)).toBe(true); + }); + + it("treats 4-byte emoji as 4 bytes — string.length stays under the limit while bytes exceed it", () => { + // 1281 × "🚀" = 5124 bytes (over). string.length === 2562, + // still under a notional 5120-char cap. + const rockets = "🚀".repeat(1281); + expect(rockets.length).toBeLessThan(FIELD_BYTE_LIMIT); + expect(byteLength(rockets)).toBe(5124); + expect(isOversize(rockets)).toBe(true); + }); +});