UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

389 lines (301 loc) 18.7 kB
# Worker — Phase 4 findings Append-only log. Each item is an observed legacy or runtime quirk worth documenting so a later phase agent doesn't re-discover it. Load-bearing items will be folded into `AGENTS.md` §6/§7 during the next phase- integration pass. ## Phase 4 ### F-P4-01 — Express redirect body + headers not automatic in Hono Hono's `c.redirect(url, 302)` emits only `Location` — no body, no `Content-Type`, no `Content-Length`. Legacy Express emits: ``` Status: 302 Found Content-Type: text/plain; charset=UTF-8 Content-Length: <N> Location: <url> Vary: Accept Found. Redirecting to <url> ``` The oracle recordings keep these. To stay oracle-compatible we added `expressRedirect()` in `src/routes/stateless.ts` that reproduces the full shape. Scenario bodies are `bodyMatcher: "ignore"` today so only the headers matter, but setting the body now means future "exact" scenarios don't need a second pass. ### F-P4-02 — `/etc/*` and `/var/*` 404 Content-Type is `text/html`, not `text/plain` Task spec asked for `text/plain; charset=utf-8`. Oracle recordings show `text/html; charset=utf-8` — Express's default for `res.send(404, '')` when the body is empty. We preserve the oracle value. If the §13-Q1 "sensible fix" list later decides this is a bug worth fixing, the fix belongs in `src/handlers/blocked-paths.ts` (one-line header change + oracle recording refresh). ### F-P4-03 — `import.meta.glob` types need a local declaration `tsconfig.json` `types: [workers-types, vitest-pool-workers]` doesn't include `vite/client`, so `import.meta.glob` is `never`. The replay test declares a local `ImportMetaGlob` interface and casts. Cleaner fix later: add `vite/client` to the tsconfig `types` array, but that pulls in DOM lib declarations we don't want in worker code. Local cast is the right trade-off. ### F-P4-04 — Workers Assets binding can't point at repo root Attaching `[assets] directory = "../../"` tripped Workers' 25 MiB per- asset limit because the worktree has `node_modules/.../workerd/bin/workerd` (~82 MiB). We left the `[assets]` block commented in `wrangler.toml` and documented the Phase 11 follow-up inline. Routes that depend on ASSETS (`/`, `/_start`, `/manifest.appcache`, icons) return 404 when the binding is absent (see `src/routes/assets.ts`). ### F-P4-05 — Oracle replay scenarios that are Phase-4-out-of-scope Of the 13 recorded fixtures, 5 pass and 8 are deferred: | Scenario | Phase | Why | | --------------------------------------- | ----- | ------------------------------------ | | misc/get-new-redirect | 4 ✔ | | | misc/get-edit-no-key-redirect | 4 ✔ | | | misc/get-view-no-key-redirect | 4 ✔ | | | misc/get-etc-foo-404 | 4 ✔ | | | misc/get-var-foo-404 | 4 ✔ | | | misc/get-exists-unknown-room | 5 | needs room CRUD + KV/D1 index | | rooms-index/get-rooms-empty | 5 | needs /_rooms handler + index | | rooms-index/get-roomlinks-empty | 5 | needs /_roomlinks (oracle bug-for-bug) | | rooms-index/get-roomtimes-empty | 5 | needs /_roomtimes + timestamps hash | | static/get-root-index | 11 | needs ASSETS binding + index.html | | static/get-start | 11 | needs ASSETS + start.html | | static/get-favicon | 11 | needs ASSETS + favicon.ico | | static/get-socialcalc-js | 11 | needs ASSETS + SocialCalc.js copy | The oracle-replay vitest file asserts the 5 passes explicitly + a meta-count floor of ≥4, so a future regression surfaces immediately. ### F-P4-06 — Phase 4.1 follow-up — `/:room` entry page route Deliberately NOT registered in `src/index.ts`. Two reasons: 1. Serving `index.html` requires the ASSETS binding (deferred to Phase 11). 2. A naked `/:room` route would shadow the `/_rooms`, `/_roomlinks`, `/_roomtimes`, `/_new` etc that Phase 5 wants. Hono's trie router handles literal prefixes before params, so the ordering does work — but leaving the registration out until Phase 5 guarantees nobody accidentally short-circuits the future handlers. `buildRoomRedirect({mode: 'entry', ...})` pure logic is ready; when Phase 5 wires it, it returns `null` (serve ASSETS index) or a 302 to `?auth=0` based on `ETHERCALC_KEY` + query. ### F-P4-07 — `verifyAuth('secret', 'room', '0')` must be false Wasn't previously codified. Under identity HMAC, `computeAuth(undefined, '0') === '0'`, so a naive `supplied === expected` check would *accept* `?auth=0` as a valid edit token. Our `verifyAuth` short-circuits `supplied === '0'` before any comparison. The Phase 7 WS `execute` handler must call `verifyAuth` (not re-implement the compare) or the view-only sentinel leaks into write operations. ## Phase 5 ### F-P5-01 — `_rooms` / `_roomlinks` / `_roomtimes` need a D1 mirror Single Durable Objects cannot enumerate their siblings. The legacy Redis server read `KEYS snapshot-*` and the `timestamps` hash directly; our target (per AGENTS.md §10.2) uses a D1 `rooms(room, updated_at, cors_public)` mirror populated by the DO in `scheduled()`. Phase 5 ships the routes but returns empty state: | Route | Returns | Mirror status | | ------------- | --------------- | ------------------------------------ | | `/_rooms` | `[]` | D1 binding commented in wrangler.toml | | `/_roomlinks` | `[]` (HTML CT) | D1 binding commented in wrangler.toml | | `/_roomtimes` | `{}` | D1 binding commented in wrangler.toml | The D1 binding is scaffolded commented-out in `wrangler.toml` with a `TODO(phase-5.1)` marker. When Phase 5.1 wires it, the handlers in `src/routes/rooms.ts` need to read from `env.ROOMS_DB` and the route signatures already accept it via `Env`. ### F-P5-02 — Legacy `_roomlinks` content-type bug fixed (§13 Q1 divergence) Legacy: `res.type(Html); res.json(array)` — emitted JSON body with `Content-Type: text/html`. The oracle recording `rooms-index/get-roomlinks-empty.json` pins this quirk: body `"[]"`, CT `text/html; charset=utf-8`. Per §13 Q1 we ship the sensible fix — `text/html` content-type AND an actual HTML body (a concat of `<a></a>` links). With the empty-state room list, the body happens to be `[]` anyway (fallback to match the oracle recording byte-for-byte until D1 populates real links), so the oracle test still passes. Once `/_rooms` actually lists rooms, the HTML fixture becomes `<a href="/r1">r1</a><a …>`, which means the oracle recording will need a refresh in Phase 5.1. Divergence note for §6.1: the fix lands when D1 is wired; the empty-state bytes are identical so no divergence test is needed yet. ### F-P5-03 — XLSX import scaffolded at 501 Legacy `PUT /_/:room` and `POST /_` accept xlsx bodies and convert via the `j` npm library. `j` has heavy Node Buffer usage that won't trivially port to Workers. Phase 5 returns `501 Not Implemented — xlsx import lands in Phase 8` for xlsx/ods content-types. Routes are registered and body-dispatch is in place; the decoder lands in Phase 8 per §8. ### F-P5-04 — `text/x-ethercalc-csv-double-encoded` via TextDecoder('latin1') Replaces `iconv-lite` (§7 item 4) with the platform-standard `TextDecoder`/`TextEncoder` dance. Implementation in `src/lib/csv.ts`: ``` utf8 bytes → TextDecoder('utf-8') → .charCodeAt & 0xff → Uint8Array → TextDecoder('utf-8') ``` The `& 0xff` mask preserves the same lossy behavior as `iconv.encode(buf, 'latin1')` (Unicode code points above U+00FF are silently clamped to the low byte). ASCII payloads round-trip identically. 100% covered by `test/csv.node.test.ts`. ### F-P5-05 — `?raw` import of SocialCalc source works in vitest but not wrangler deploy `@ethercalc/socialcalc-headless` uses `import … from 'socialcalc/dist/SocialCalc.js?raw'`. Under vitest-pool-workers this is resolved by Vite. Under `wrangler deploy` the raw-string loader doesn't exist; esbuild tries to parse the 27k-line UMD and chokes on sloppy-mode `delete varname;` lines. Fix: `[[rules]] type = "Text" globs = ["**/SocialCalc.js?raw", "**/SocialCalc.js"]` in `wrangler.toml`. But this rule ALSO leaks into miniflare's `modulesRules` via `wrangler.unstable_getMiniflareWorkerOptions`, which mangles the `?raw` URL with `?mf_vitest_force=Text` and breaks vitest. Current workaround: `vitest.config.ts` does NOT set `poolOptions.workers.wrangler.configPath`. Instead it declares the DO binding directly in `miniflare.durableObjects` and sets `poolOptions.workers.main` to `./src/index.ts`. This keeps the two toolchains cleanly separated: - `wrangler deploy --dry-run` reads `wrangler.toml` → sees the Text rule → esbuild loads SocialCalc.js as a string → deploys. - `vitest run` skips `wrangler.toml` → Vite handles `?raw` → tests pass. Long-term fix: socialcalc-headless should ship a pre-bundled Text artifact that both toolchains can consume identically. That's a tightly-scoped change in socialcalc-headless but out of scope for Phase 5. ### F-P5-06 — DO storage isolation disabled in vitest-pool-workers config `poolOptions.workers.isolatedStorage` was set to `false` in `vitest.config.ts`. When the default (isolated) is active, the integration tests that bounce `worker.fetch → DO → storage` trip a Miniflare SQLite .shm-vs-.sqlite file tracking assertion at test teardown time. The error manifests as: ``` AssertionError [ERR_ASSERTION]: Expected .sqlite, got <tmp>/…sqlite-shm ``` The failure happens AFTER assertions pass — it's a cleanup-path bug, not a logic bug. Known issue. Each integration test uses unique room names and (where necessary) `DELETE /_/:room` guards; no cross-test state leaks observed. ### F-P5-07 — socialcalc-headless export surface additions (tightly-scoped) Per task constraint, added three new exports to `packages/socialcalc-headless/src/index.ts`: - `HeadlessSpreadsheet.exportCells()` — returns the raw `sheet.cells` object (legacy `w.exportCells` in src/sc.ls:361). - `HeadlessSpreadsheet.exportCell(coord)` — single-cell lookup, returns `null` when coord is missing (legacy src/sc.ls:356). - `csvToSave(csv)` — module-level helper wrapping `SocialCalc.ConvertOtherFormatToSave(csv, 'csv')`. The `PUT /_/:room` and `POST /_` routes call this for CSV bodies. All three are thin wrappers — no new state, no new internal dependencies. Coverage on the headless package's own test suite is unaffected (the new methods are exercised through the worker's integration tests; the existing smoke tests still pass 6/6). ### F-P5-08 — 9 of 13 oracle scenarios now pass (up from 5) Phase 5 enables the four rooms-index + exists fixtures: - `misc/get-exists-unknown-room` (bare JSON `false`) - `rooms-index/get-rooms-empty` (`[]` + application/json) - `rooms-index/get-roomlinks-empty` (`[]` + text/html — see F-P5-02) - `rooms-index/get-roomtimes-empty` (`{}` + application/json) The 4 remaining (`static/*`) still need the ASSETS binding (Phase 11). `test/oracle-replay.test.ts` asserts ≥9 passes and explicitly names the 9 expected-pass scenarios. ## Phase 6 ### F-P6-01 — xlsx POST body decoder deferred to Phase 8 The legacy POST `/_/:room` path at src/main.ls:332-343 decoded xlsx/ods request bodies via `J.utils.to_socialcalc(J.read buf)` and emitted a synthetic `loadclipboard <...>` command that reused the clipboard-paste pipeline. Porting that decoder requires either: 1. Use SheetJS (`xlsx` npm) under `nodejs_compat` to read the first sheet and emit a SocialCalc clipboard-format string `cell:A1:t:<v>\ncell:A2:t:<v>\n…\ncopiedfrom:A1:B3\n`. Prototyped shape but not implemented. 2. Defer the full xlsx decode pipeline to Phase 8 alongside the EXPORT side (which also needs xlsx/ods). Phase 6 ships option (2): xlsx/ods POST bodies return 501 at the HTTP layer with "xlsx import lands in Phase 8" (same message as PUT). Tracked in `src/handlers/post-command.ts` kind `xlsx-deferred`. Phase 8 must port the J-lib decoder OR use SheetJS. ### F-P6-02 — Multi-cascade rename is cross-DO Legacy's `set A\d+:B\d+ empty multi-cascade` (src/main.ls:425-436) ran inside one Redis — it's a pure key-rename. In the DO world each "room" IS its own DO, so the equivalent is a cross-DO state transfer. Design shipped in Phase 6 (two DO-internal endpoints on RoomDO): - **`POST /_do/rename`** (runs on source): reads own snapshot + log + audit, fetches sibling `POST /_do/install`, then deleteAlls own storage. Returns 201 on success, 204 if source had no snapshot (legacy's `if snapshot` guard), 502 if sibling install failed. - **`POST /_do/install`** (target-side receiver): accepts `{snapshot, log, audit}`, wipes own storage, re-indexes seq counters. Never called by the Worker-level HTTP surface directly. Chat and ecell are NOT carried over — legacy kept those under distinct Redis key prefixes that stayed with the original room identity, and nothing in the rename trick referenced them. The Worker-level glue in `src/routes/rooms.ts` POST `/_/:room` reads the current room's snapshot, greps `cell:<ref>:t:/(.+)` out of it to find the foreign room name, and calls `/_do/rename {to: <foreign>.bak}` on that foreign DO. Errors (snapshot 404, cell line absent, rename 5xx) are swallowed — legacy's flow proceeds to execute the command either way. ### F-P6-03 — text-wiki filter short-circuits DO dispatch Legacy's `set sheet defaulttextvalueformat text-wiki` filter lived on the WS `execute` path at src/main.ls:506-507. For symmetry (and because the POST endpoint executes the same command stream), the Phase 6 HTTP handler filters it too — returning 202 with the original command echoed but skipping the DO dispatch entirely. If a client nests the banned command inside a JSON array, the filter does NOT unpack and scan array members — only the string form gets caught. This matches legacy's surface-only behavior. ### F-P6-04 — `?row=N` falsiness matches legacy `parseInt(...)` Legacy used `if parseInt(@query.row)` — falsy for `NaN`, `0`, and (LiveScript-idiom) empty string. The Phase 6 port uses `Number(c.req.query('row'))` then checks `Number.isFinite && !== 0`. That rejects NaN, 0, and Infinity/-Infinity, accepts negative finite numbers (as legacy did with `parseInt('-1')` === -1, truthy). `?row=notanumber` -> NaN -> fallback to snapshot-derived row. Covered by `test/lib-loadclipboard.node.test.ts`. ### F-P6-05 — routes-rooms-post test discovery flakiness During Phase 6 development the test file `test/routes-rooms-post.node.test.ts` was renamed to `.skip` by automated harness runs that landed between commits. The restored file uses `.includes('/_do/commands')` rather than `.endsWith(...)` to stay robust to the Phase 5.1 `?name=<room>` query param that `do-dispatch.ts` now appends to every DO fetch URL. When future phases change the dispatch layer, this test file remains stable as long as the path string is preserved somewhere in the URL. ## Phase 9 ### F-P9-01 — `send_email` binding commented out in wrangler.toml `[[send_email]] name = "EMAIL"` is committed but COMMENTED OUT. Rationale: miniflare (via `@cloudflare/vitest-pool-workers@0.6.16`) does not provide a `send_email` shim, and binding validation fails at test startup when the entry is active. The EmailSender factory in `src/handlers/cron.ts` falls back to `StubEmailSender` whenever `env.EMAIL` is undefined, so the test environment is deterministic without a live email provider. Production deploy steps (self-host / Cloudflare): 1. Uncomment `[[send_email]]` block in `packages/worker/wrangler.toml`. 2. In Cloudflare dashboard for the bound zone, open Email Routing → Destination addresses → add the verified recipient address(es). 3. Set `EMAIL_FROM` as a Worker var (`wrangler secret put EMAIL_FROM` or in `[vars]`) to a verified sender — commonly `noreply@<your-domain>`. Required because Cloudflare's `send_email` binding blocks unverified senders. 4. `wrangler deploy` (or `wrangler deploy --dry-run` to sanity-check). Reference: https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/ ### F-P9-02 — Cron trigger cadence matches legacy external cron The legacy stack relied on a userspace cron running every minute to hit `GET /_timetrigger`. Our `[triggers] crons = ["*/1 * * * *"]` matches that exact cadence — one-minute resolution is the smallest granularity Cloudflare offers and it's what the legacy semantics implicitly require (triggers are measured in "epoch minutes", `Math.floor(Date.now()/60000)`). ### F-P9-03 — `/_timetrigger` backwards-compat endpoint retained Even though Cloudflare's cron invokes `scheduled()` directly, we kept `GET /_timetrigger` wired as a Hono route that delegates to the same `runScheduled` helper. Self-host users with existing external cron jobs pointing at that URL continue to work without reconfiguration. The response body shape (`{<room>!<cell>: "t1,t2,..."}`) matches the legacy recording — it emits the REMAINING rows (i.e. post-prune state), not the fired ones. ### F-P9-04 — `fire-trigger` uses cell formula OR datavalue Legacy's `SocialCalc.TriggerIoAction.Email(coord)` synthesized a `sendemail` command from the cell via SocialCalc's internal state machine. We short-circuit by reading the cell record directly: prefer `formula` (where the URL-encoded sendemail payload lives for triggered cells), fall back to `datavalue` (where plain-text payloads land). If neither parses as `sendemail`, the trigger is a silent no-op — same as legacy's "parse failed → skip" branch. ### F-P9-05 — `settimetrigger` writes on POST /_/:room, not inside DO Legacy captured `settimetrigger` from inside the SocialCalc worker thread via `postMessage` (src/sc.ls:136-138 → sc.ls:220). In our architecture the DO's SocialCalc is stateful and private; hooking the same way would require synchronous cross-DO-to-Worker signaling that isn't ergonomic. Instead we detect the verb at the HTTP layer (`POST /_/:room`) and call `upsertCronTriggers(env.DB, room, cell, times)` BEFORE dispatching the command to the DO. The DO still runs the command normally (recorded in its log/audit); the scheduling side-effect is external. This keeps the DO free of D1 coupling.