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
4 changes: 2 additions & 2 deletions example-apps/dashnote/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
1 change: 0 additions & 1 deletion example-apps/dashnote/src/dash/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export const NOTE_SCHEMAS = {
},
message: {
type: "string",
maxLength: 10000,
position: 1,
},
},
Expand Down
4 changes: 3 additions & 1 deletion example-apps/dashnote/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions example-apps/dashnote/test/fieldLimits.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});