Skip to content

Commit eadfeac

Browse files
committed
chore(flags): retire phoenix-optimistic-edits — inline the optimistic edit path (#3667)
The flag served on@100% in production (optimistic in-place content edits, #1675, epic #1637, released), so the gate and its flag-off fallback were dead weight. Rip both and keep only the served-on behavior (ADR 0136). - `useFlag(PHOENIX_OPTIMISTIC_EDITS)` removed from the three call sites (`PanoPostDetail` post.edit + CommentEditComposer, sozluk `DefinitionCard`); each now passes `optimistic` unconditionally instead of spreading it away. - `postEditOptimistic` / `bodyEditOptimistic` drop their `enabled` parameter and the `| undefined` return — the partial is always built. - IaC declaration deleted: the `flags/keys.ts` const + `DECLARED_FLAGS` entry, the `flagship/resources.ts` config/factory/import/re-export, and the `alchemy.run.ts` import + stack wiring. - `optimistic-edits.invariant.test.ts` deleted (it pinned the retired flag's default-off IaC record); the `optimisticEdit` unit test drops its gate cases. - Stale prose reworded in `.patterns/fate-mutations-client.md` and `.glossary/TERMS.md`. Closes #3667
1 parent fadba67 commit eadfeac

10 files changed

Lines changed: 41 additions & 157 deletions

File tree

.glossary/TERMS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ The vocab-free *mechanism* lives in `packages/authz`; the kamp.us *instances + s
123123
| LiveDO | The unified **void**-aligned live-view Durable Object — ONE class, two roles, `state.storage` KV. Fans out mutations' `live.*` events to subscribers over SSE (ADR 0037). | two classes named ConnectionDO + TopicDO (see below) |
124124
| ConnectionDO / TopicDO | The **former, superseded** two-class live-view design. Today there is ONE class, `LiveDO`, with two **roles** (connection, topic) dispatched via `resolveRole(state.id.name)` — not two Durable Object classes (ADR 0037). | live Durable Object class names (they are roles of LiveDO, not classes) |
125125
| void | The upstream live-streaming Durable Object reference (`VoidLiveStreamDurableObject`) that **LiveDO** mirrors; the design authority for LiveDO's `DEFAULT_LIMITS`, the queue-full→`410` behavior, the first-failed-probe reap, and the stale/replay model. | inventing LiveDO limits/reap semantics from scratch |
126-
| optimistic slice | One per-mutation optimistic-update unit (epic #1637): the client writes the mutation's effect into fate's normalized cache the instant the user acts, then **reconciles by canonical id** against the server frame (ADR 0125) — adds are **A1** client-append + canonical-id dedup (an optimistic temp node the server row replaces, no flash/dupe), deletes are **D1** reply-aware drop (a plain edge-drop for a leaf, a conservative `[silindi]` tombstone when replies are loaded), with rollback on rejection. Each slice ships dark behind its **own** default-off flag (`phoenix-optimistic-edits`, `pano-optimistic-submit`, `pano-optimistic-comment-add`/`-delete`, `phoenix-optimistic-definition-add`/`-delete`, `pano-optimistic-post-delete`); the DOM-free decision helpers live per page (`src/pages/*Optimistic*.ts`). | one shared epic-wide flag (each slice has an independent dark-ship lifecycle); a client-invented final id (the server id is canonical — the temp node reconciles to it) |
126+
| optimistic slice | One per-mutation optimistic-update unit (epic #1637): the client writes the mutation's effect into fate's normalized cache the instant the user acts, then **reconciles by canonical id** against the server frame (ADR 0125) — adds are **A1** client-append + canonical-id dedup (an optimistic temp node the server row replaces, no flash/dupe), deletes are **D1** reply-aware drop (a plain edge-drop for a leaf, a conservative `[silindi]` tombstone when replies are loaded), with rollback on rejection. Each slice ships dark behind its **own** default-off flag (`pano-optimistic-submit`, `pano-optimistic-comment-add`/`-delete`, `phoenix-optimistic-definition-add`/`-delete`, `pano-optimistic-post-delete`), retired once the slice releases (ADR 0136 — the in-place edit slice is retired, its optimistic path unconditional); the DOM-free decision helpers live per page (`src/pages/*Optimistic*.ts`). | one shared epic-wide flag (each slice has an independent dark-ship lifecycle); a client-invented final id (the server id is canonical — the temp node reconciles to it) |
127127
| role (connection / topic) | LiveDO's two roles: **connection** owns one client's SSE stream + subscriptions; **topic** owns a topic's subscriber registry, publish fan-out, and reap alarm. | |
128128
| shaper | Maps a DB row to an `Entity` field set (`shapers.ts`). | |
129129
| source | A `Fate.source(ViewClass, {id}, handlers)` loader entry delegating to Effect services (`sources.ts`); fate never queries D1. The loader half (silent reads). | a source that throws not-found |

.patterns/fate-mutations-client.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,17 @@ For inserts that create an entity, give the optimistic record a temporary id (`o
4646

4747
An **in-place field edit** (`post.edit`, `comment.edit`, `definition.edit`) is the simplest optimistic case: it's an entity-field write-back that already re-renders in place through its result `view`, so making it optimistic is *only* passing the edited fields (plus a fresh `updatedAt`) as the `optimistic` partial. No temp id, no membership change — the edited entity keeps its id, so the optimistic write and the server `live.update({changed:["body"|"title,body"]})` frame touch the **same fields on the same entity** and can't diverge. fate rolls the partial back and restores the prior text on a rejected edit (the boundary-class throw, [Errors](#errors)), so the existing inline error shows with no phantom-saved state.
4848

49-
The load-bearing pieces are hook-free and unit-testable, mirroring `voteOptimistic`: [`src/fate/optimisticEdit.ts`](../apps/web/src/fate/optimisticEdit.ts) builds the partial (`postEditOptimistic` / `bodyEditOptimistic`) and returns `undefined` when the gate is off, so the call site spreads it away under `exactOptionalPropertyTypes`:
49+
The load-bearing pieces are hook-free and unit-testable, mirroring `voteOptimistic`: [`src/fate/optimisticEdit.ts`](../apps/web/src/fate/optimisticEdit.ts) builds the partial (`postEditOptimistic` / `bodyEditOptimistic`):
5050

5151
```tsx
52-
const optimistic = bodyEditOptimistic(optimisticEdits, body); // undefined when the flag is off
5352
await fate.mutations.definition.edit({
5453
input: {id, body},
55-
...(optimistic ? {optimistic} : {}), // {body, updatedAt: <now>} when on
54+
optimistic: bodyEditOptimistic(body), // {body, updatedAt: <now>}
5655
view: DefinitionView,
5756
});
5857
```
5958

60-
The fresh `updatedAt` drives the "düzenlendi" indicator (`EditedIndicator`) instantly, consistently with the reconciled frame. Shipped dark behind the `phoenix-optimistic-edits` flag (default-off, ADR 0083; #1675, epic #1637) — off, the edit passes no `optimistic` and waits for the round-trip exactly as before.
59+
The fresh `updatedAt` drives the "düzenlendi" indicator (`EditedIndicator`) instantly, consistently with the reconciled frame. Shipped dark behind a per-slice flag (#1675, epic #1637), released, and the flag retired (ADR 0136) — the optimistic path is now unconditional.
6160

6261
## Connection membership — declarative, not imperative
6362

@@ -82,7 +81,7 @@ There is no hand-written updater enumerating connection keys. For connections th
8281
> - **add** to a nested connection: the new node is normalized into the cache, but joins the list only via a live `appendNode` or a re-read. sözlük's `definition.add` publishes no append, so its composer **reloads after a successful add**.
8382
> - **delete** of a node in a nested connection: `delete: true` is **wrong-entity** for sözlük's `definition.delete`, a `Term`-returning mutation (it re-resolves the parent for fresh counts), so `delete:true` would `deleteRecord("Term", definitionId)`. The card calls delete **without** `delete:true`; the server publishes `deleteEdge("Definition", id)` on `Term.definitions`.
8483
>
85-
> Entity-field mutations (vote, edit) are unaffected: they write back through the result `view` and re-render in place. Votes are fully optimistic; the in-place edits become optimistic behind the `phoenix-optimistic-edits` flag — see [Optimistic in-place edits](#optimistic-edits).
84+
> Entity-field mutations (vote, edit) are unaffected: they write back through the result `view` and re-render in place. Votes and the in-place edits are both fully optimistic — see [Optimistic in-place edits](#optimistic-edits).
8685
8786
### Optimistic nested-connection membership (add-dedup + reply-aware delete) {#optimistic-nested-membership}
8887

apps/web/alchemy.run.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ import {
5858
navIaFlag,
5959
optimisticDefinitionAddFlag,
6060
optimisticDefinitionDeleteFlag,
61-
optimisticEditsFlag,
6261
panoBaseFeedFlag,
6362
panoDraftSaveFlag,
6463
panoOptimisticPostDeleteFlag,
@@ -102,10 +101,6 @@ export default Alchemy.Stack(
102101
// The conversion-funnel readout dark-ship flag, default-off (#1589) — the
103102
// founder/mod tier-count surface gates behind this key until a human release.
104103
yield* funnelReadoutFlag(flagship.appId);
105-
// The optimistic in-place content-edit dark-ship flag, default-off (#1675,
106-
// epic #1637) — post/comment/definition edits pass an optimistic payload only
107-
// behind this key until a human release.
108-
yield* optimisticEditsFlag(flagship.appId);
109104
// The optimistic post.delete dark-ship flag, default-off (#1677, epic #1637) —
110105
// gates the instant-feed-removal delete flow until a human release.
111106
yield* panoOptimisticPostDeleteFlag(flagship.appId);

apps/web/src/components/sozluk/DefinitionCard.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {bodyEditOptimistic} from "../../fate/optimisticEdit";
1111
import {useDraftSubmit} from "../../fate/useDraftSubmit";
1212
import {codeOf, toIso} from "../../fate/wire";
1313
import {messageForCode, type WireMessageOverrides} from "../../fate/wireMessages";
14-
import {PHOENIX_OPTIMISTIC_DEFINITION_DELETE, PHOENIX_OPTIMISTIC_EDITS} from "../../flags/keys";
14+
import {PHOENIX_OPTIMISTIC_DEFINITION_DELETE} from "../../flags/keys";
1515
import {useFlag} from "../../flags/useFlag";
1616
import {formatAgoTR} from "../../lib/datetime";
1717
import {renderMarkdownInline, splitMarkdownBlocks} from "../../lib/markdown";
@@ -83,9 +83,6 @@ export function DefinitionCard(props: DefinitionCardProps) {
8383
const fate = useFateClient();
8484
const session = useSession();
8585
const navigate = useNavigate();
86-
// Dark-ship gate (#1675): with the flag off the edit passes no optimistic
87-
// payload and waits for the round-trip, exactly as before.
88-
const {value: optimisticEdits} = useFlag(PHOENIX_OPTIMISTIC_EDITS, false);
8986
// Dark-ship gate (#1681, ADR 0125 D1): off ⇒ the card drops only when the live
9087
// `deleteEdge` push / read-back lands, exactly as today.
9188
const {value: optimisticDelete} = useFlag(PHOENIX_OPTIMISTIC_DEFINITION_DELETE, false);
@@ -152,12 +149,11 @@ export function DefinitionCard(props: DefinitionCardProps) {
152149
setEditError(messageForCode("BODY_TOO_LONG", DEFINITION_OVERRIDES));
153150
return;
154151
}
155-
const optimistic = bodyEditOptimistic(optimisticEdits, editBody);
156152
await runEdit(
157153
() =>
158154
fate.mutations.definition.edit({
159155
input: {id: definition.id, body: editBody},
160-
...(optimistic ? {optimistic} : {}),
156+
optimistic: bodyEditOptimistic(editBody),
161157
view: DefinitionView,
162158
}),
163159
"tanım güncellenemedi",

apps/web/src/fate/optimisticEdit.test.ts

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,31 @@ import {bodyEditOptimistic, postEditOptimistic} from "./optimisticEdit";
44
/**
55
* Covers the load-bearing optimistic-edit core the three Class-A edits ship
66
* through (`post.edit`/`comment.edit`/`definition.edit` → `postEditOptimistic` /
7-
* `bodyEditOptimistic`, #1675): the dark-ship flag gate (off ⇒ no optimistic
8-
* payload, the pre-flag round-trip behavior; on ⇒ the edited field(s) plus a
9-
* fresh `updatedAt` that drives the "düzenlendi" indicator), inspected off the
10-
* REAL exported builders — the same functions the call sites route through, not a
11-
* re-implemented copy. fate's own apply/reconcile/rollback is exercised at the
12-
* integration tier; this pins the gate + payload shape hook-free.
7+
* `bodyEditOptimistic`, #1675): the edited field(s) plus a fresh `updatedAt` that
8+
* drives the "düzenlendi" indicator, inspected off the REAL exported builders —
9+
* the same functions the call sites route through, not a re-implemented copy.
10+
* fate's own apply/reconcile/rollback is exercised at the integration tier; this
11+
* pins the payload shape hook-free.
1312
*/
1413
const fixedNow = () => new Date("2026-07-02T12:00:00.000Z");
1514

16-
describe("postEditOptimistic — the flag gate + payload for post.edit", () => {
17-
it("returns undefined when the flag is off (pre-flag: wait for the round-trip)", () => {
18-
expect(postEditOptimistic(false, {title: "yeni", body: "gövde"}, fixedNow)).toBeUndefined();
19-
});
20-
21-
it("returns the edited title/body + a fresh updatedAt when the flag is on", () => {
22-
expect(postEditOptimistic(true, {title: "yeni", body: "gövde"}, fixedNow)).toEqual({
15+
describe("postEditOptimistic — the payload for post.edit", () => {
16+
it("returns the edited title/body + a fresh updatedAt", () => {
17+
expect(postEditOptimistic({title: "yeni", body: "gövde"}, fixedNow)).toEqual({
2318
title: "yeni",
2419
body: "gövde",
2520
updatedAt: fixedNow(),
2621
});
2722
});
2823

2924
it("stamps updatedAt from the injected clock (drives the edited indicator)", () => {
30-
const result = postEditOptimistic(true, {title: "t", body: "b"}, fixedNow);
31-
expect(result?.updatedAt).toEqual(fixedNow());
25+
expect(postEditOptimistic({title: "t", body: "b"}, fixedNow).updatedAt).toEqual(fixedNow());
3226
});
3327
});
3428

35-
describe("bodyEditOptimistic — the flag gate + payload for comment.edit / definition.edit", () => {
36-
it("returns undefined when the flag is off", () => {
37-
expect(bodyEditOptimistic(false, "gövde", fixedNow)).toBeUndefined();
38-
});
39-
40-
it("returns the edited body + a fresh updatedAt when the flag is on", () => {
41-
expect(bodyEditOptimistic(true, "gövde", fixedNow)).toEqual({
29+
describe("bodyEditOptimistic — the payload for comment.edit / definition.edit", () => {
30+
it("returns the edited body + a fresh updatedAt", () => {
31+
expect(bodyEditOptimistic("gövde", fixedNow)).toEqual({
4232
body: "gövde",
4333
updatedAt: fixedNow(),
4434
});
Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
/**
22
* Optimistic-edit payload builders for the three Class-A content edits
3-
* (`post.edit` / `comment.edit` / `definition.edit`), gated by the
4-
* `phoenix-optimistic-edits` dark-ship flag (#1675, epic #1637). These edits are
3+
* (`post.edit` / `comment.edit` / `definition.edit`). These edits are
54
* entity-field write-backs that already re-render in place through their result
6-
* `view` — the only gap is that no `optimistic` partial is passed, so the UI
7-
* waits for the round-trip. Passing the partial renders the edited body/title
8-
* instantly; fate rolls it back on a rejected mutation and reconciles it against
9-
* the server `live.update({changed:[…]})` frame (same field → no divergence).
5+
* `view`; passing the partial renders the edited body/title instantly, and fate
6+
* rolls it back on a rejected mutation and reconciles it against the server
7+
* `live.update({changed:[…]})` frame (same field → no divergence).
108
*
11-
* Pure + hook-free (mirroring `voteOptimistic` in `useVoteToggle`) so the flag
12-
* gate and the fresh-`updatedAt` — which drives the "düzenlendi" indicator
13-
* (`EditedIndicator`) consistently with the reconciled frame — are unit-testable
14-
* apart from the fate mutation and React. See `.patterns/fate-mutations-client.md`.
9+
* Pure + hook-free (mirroring `voteOptimistic` in `useVoteToggle`) so the
10+
* fresh-`updatedAt` — which drives the "düzenlendi" indicator (`EditedIndicator`)
11+
* consistently with the reconciled frame — is unit-testable apart from the fate
12+
* mutation and React. See `.patterns/fate-mutations-client.md`.
1513
*/
1614

1715
/** Injectable now-clock so the optimistic `updatedAt` is deterministic in tests. */
@@ -32,28 +30,15 @@ export interface BodyEditOptimistic {
3230
readonly updatedAt: Date;
3331
}
3432

35-
/**
36-
* The optimistic partial for a post edit, or `undefined` when the dark-ship flag
37-
* is off — the pre-flag behavior: no `optimistic` payload, the UI waits for the
38-
* round-trip. `undefined` lets the call site spread it away under
39-
* `exactOptionalPropertyTypes` (`...(optimistic ? {optimistic} : {})`).
40-
*/
33+
/** The optimistic partial for a post edit — the edited title/body + a fresh `updatedAt`. */
4134
export function postEditOptimistic(
42-
enabled: boolean,
4335
fields: {readonly title: string; readonly body: string},
4436
now: Now = defaultNow,
45-
): PostEditOptimistic | undefined {
46-
return enabled ? {title: fields.title, body: fields.body, updatedAt: now()} : undefined;
37+
): PostEditOptimistic {
38+
return {title: fields.title, body: fields.body, updatedAt: now()};
4739
}
4840

49-
/**
50-
* The optimistic partial for a comment/definition body edit, or `undefined` when
51-
* the dark-ship flag is off (see {@link postEditOptimistic}).
52-
*/
53-
export function bodyEditOptimistic(
54-
enabled: boolean,
55-
body: string,
56-
now: Now = defaultNow,
57-
): BodyEditOptimistic | undefined {
58-
return enabled ? {body, updatedAt: now()} : undefined;
41+
/** The optimistic partial for a comment/definition body edit (see {@link postEditOptimistic}). */
42+
export function bodyEditOptimistic(body: string, now: Now = defaultNow): BodyEditOptimistic {
43+
return {body, updatedAt: now()};
5944
}

apps/web/src/flags/keys.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,6 @@ export const PHOENIX_FUNNEL_READOUT = "phoenix-funnel-readout";
125125
*/
126126
export const PHOENIX_MOD_QUEUE = "phoenix-mod-queue";
127127

128-
/**
129-
* Optimistic in-place content-edit dark-ship flag (#1675, epic #1637). Gates the
130-
* three Class-A content edits (`post.edit`, `comment.edit`, `definition.edit`)
131-
* that render the edited body/title instantly by passing an `optimistic` payload
132-
* (the seam votes already use); default-off so the edits reach production dark
133-
* (waiting for the round-trip, exactly as today) until a human flips it at release
134-
* (ADR 0083). The add/delete slices of the epic ship behind their own gates.
135-
*/
136-
export const PHOENIX_OPTIMISTIC_EDITS = "phoenix-optimistic-edits";
137-
138128
/**
139129
* Optimistic `definition.add` (instant term-page insert) dark-ship flag (#1679,
140130
* epic #1637). Gates the A1 client-append into the *nested* `Term.definitions`
@@ -339,7 +329,6 @@ export const DECLARED_FLAGS: readonly FlagDeclaration[] = [
339329
{key: PHOENIX_BILDIRIM, defaultValue: false},
340330
{key: PHOENIX_FUNNEL_READOUT, defaultValue: false},
341331
{key: PHOENIX_MOD_QUEUE, defaultValue: false},
342-
{key: PHOENIX_OPTIMISTIC_EDITS, defaultValue: false},
343332
{key: PHOENIX_OPTIMISTIC_DEFINITION_ADD, defaultValue: false},
344333
{key: PHOENIX_OPTIMISTIC_DEFINITION_DELETE, defaultValue: false},
345334
{key: PHOENIX_REACTIONS, defaultValue: false},

0 commit comments

Comments
 (0)