okengine 0.17.2 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLiveQuery — reducer, race protocol, optimistic mutate, teardown.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
6
|
+
import { Window } from "happy-dom";
|
|
7
|
+
import { act, createElement } from "react";
|
|
8
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
9
|
+
import { createClient } from "../client/create.ts";
|
|
10
|
+
import {
|
|
11
|
+
applyOptimisticPatch,
|
|
12
|
+
clearOptimisticPatch,
|
|
13
|
+
isStaleUpsert,
|
|
14
|
+
reduceLiveQueryRows,
|
|
15
|
+
} from "../client/use-live-query.ts";
|
|
16
|
+
import { useLiveQuery } from "./use-live-query.ts";
|
|
17
|
+
|
|
18
|
+
type Task = { id: string; title: string; status: string; updatedAt: number };
|
|
19
|
+
|
|
20
|
+
function listFlowFrom(rows: Task[]) {
|
|
21
|
+
const calls: unknown[] = [];
|
|
22
|
+
const flow = ((input?: unknown) => {
|
|
23
|
+
calls.push(input);
|
|
24
|
+
return Promise.resolve({
|
|
25
|
+
data: rows,
|
|
26
|
+
error: null,
|
|
27
|
+
next: () => {},
|
|
28
|
+
prev: () => {},
|
|
29
|
+
[Symbol.asyncIterator]() {
|
|
30
|
+
return {
|
|
31
|
+
async next() {
|
|
32
|
+
return { done: true as const, value: undefined };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}) as unknown as Parameters<typeof useLiveQuery<Task>>[0]["listFlow"];
|
|
38
|
+
return { flow, calls };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const LIVE = { method: "GET", path: "/tasks/live" };
|
|
42
|
+
|
|
43
|
+
async function waitFor(pred: () => boolean, ms = 1_000): Promise<void> {
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
while (!pred()) {
|
|
46
|
+
if (Date.now() - start > ms) throw new Error("timeout");
|
|
47
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("live query row primitives", () => {
|
|
52
|
+
const idOf = (r: Task) => r.id;
|
|
53
|
+
|
|
54
|
+
test("reduce upsert merges by id", () => {
|
|
55
|
+
const rows: Task[] = [{ id: "1", title: "a", status: "open", updatedAt: 1 }];
|
|
56
|
+
const out = reduceLiveQueryRows(rows, idOf, {
|
|
57
|
+
kind: "upsert",
|
|
58
|
+
row: { id: "1", title: "a2", status: "open", updatedAt: 2 },
|
|
59
|
+
});
|
|
60
|
+
expect(out).toHaveLength(1);
|
|
61
|
+
expect(out[0]!.title).toBe("a2");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("reduce upsert appends unseen ids", () => {
|
|
65
|
+
const out = reduceLiveQueryRows([] as Task[], idOf, {
|
|
66
|
+
kind: "upsert",
|
|
67
|
+
row: { id: "9", title: "n", status: "open", updatedAt: 0 },
|
|
68
|
+
});
|
|
69
|
+
expect(out).toHaveLength(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("delete and revoked both remove", () => {
|
|
73
|
+
const rows: Task[] = [
|
|
74
|
+
{ id: "1", title: "a", status: "open", updatedAt: 1 },
|
|
75
|
+
{ id: "2", title: "b", status: "done", updatedAt: 1 },
|
|
76
|
+
];
|
|
77
|
+
expect(reduceLiveQueryRows(rows, idOf, { kind: "delete", id: "1" })).toHaveLength(1);
|
|
78
|
+
expect(
|
|
79
|
+
reduceLiveQueryRows(rows, idOf, { kind: "revoked", id: "2", reason: "rls" }),
|
|
80
|
+
).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("overrides re-apply over server upserts", () => {
|
|
84
|
+
const overrides = applyOptimisticPatch(undefined, "1", { status: "done" });
|
|
85
|
+
const rows: Task[] = [{ id: "1", title: "a", status: "open", updatedAt: 1 }];
|
|
86
|
+
const merged = reduceLiveQueryRows(
|
|
87
|
+
rows,
|
|
88
|
+
idOf,
|
|
89
|
+
{
|
|
90
|
+
kind: "upsert",
|
|
91
|
+
row: { id: "1", title: "a'", status: "closed", updatedAt: 5 },
|
|
92
|
+
},
|
|
93
|
+
overrides,
|
|
94
|
+
);
|
|
95
|
+
expect(merged[0]).toEqual({ id: "1", title: "a'", status: "done", updatedAt: 5 });
|
|
96
|
+
expect(clearOptimisticPatch(overrides, ["1"]).size).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("isStaleUpsert guards on version fields only when comparable", () => {
|
|
100
|
+
const held: Task = { id: "1", title: "a", status: "x", updatedAt: 10 };
|
|
101
|
+
const older: Task = { id: "1", title: "b", status: "y", updatedAt: 5 };
|
|
102
|
+
const newer: Task = { id: "1", title: "c", status: "z", updatedAt: 12 };
|
|
103
|
+
expect(isStaleUpsert(held, older)).toBe(true);
|
|
104
|
+
expect(isStaleUpsert(held, newer)).toBe(false);
|
|
105
|
+
// No comparable version → never stale.
|
|
106
|
+
expect(isStaleUpsert({ ...held, updatedAt: undefined as unknown as number }, newer)).toBe(
|
|
107
|
+
false,
|
|
108
|
+
);
|
|
109
|
+
expect(isStaleUpsert(held, newer, () => null)).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("useLiveQuery", () => {
|
|
114
|
+
let happy: Window;
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
118
|
+
happy = new Window({ url: "http://app.test/" });
|
|
119
|
+
Object.defineProperty(globalThis, "window", {
|
|
120
|
+
value: happy,
|
|
121
|
+
configurable: true,
|
|
122
|
+
writable: true,
|
|
123
|
+
});
|
|
124
|
+
Object.defineProperty(globalThis, "document", {
|
|
125
|
+
value: happy.document,
|
|
126
|
+
configurable: true,
|
|
127
|
+
writable: true,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
happy.close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("loads snapshot then applies SSE upserts; unmount stops requests", async () => {
|
|
136
|
+
const initialRows: Task[] = [{ id: "t1", title: "one", status: "open", updatedAt: 1 }];
|
|
137
|
+
const list = listFlowFrom(initialRows);
|
|
138
|
+
|
|
139
|
+
let sseOpened = 0;
|
|
140
|
+
let pushEvent: ((e: unknown) => void) | undefined;
|
|
141
|
+
const api = createClient("http://app.test", {
|
|
142
|
+
fetch: async (_url, init) => {
|
|
143
|
+
if (init?.method === "GET") {
|
|
144
|
+
sseOpened += 1;
|
|
145
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
146
|
+
start(controller) {
|
|
147
|
+
const enc = new TextEncoder();
|
|
148
|
+
pushEvent = (e: unknown) =>
|
|
149
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
150
|
+
// hold open — autoResubscribe must not create a second one
|
|
151
|
+
(controller as unknown as { _hold?: boolean })._hold = true;
|
|
152
|
+
},
|
|
153
|
+
cancel() {
|
|
154
|
+
pushEvent = undefined;
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
return new Response(stream, {
|
|
158
|
+
status: 200,
|
|
159
|
+
headers: { "content-type": "text/event-stream" },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`unexpected fetch ${String(init?.method)} ${String(_url)}`);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const host = happy.document.createElement("div");
|
|
167
|
+
happy.document.body.appendChild(host);
|
|
168
|
+
const seen: { data: readonly Task[] | null; isLoading: boolean; isConnected: boolean } = {
|
|
169
|
+
data: null,
|
|
170
|
+
isLoading: true,
|
|
171
|
+
isConnected: false,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
function Probe(): null {
|
|
175
|
+
const state = useLiveQuery<Task>({
|
|
176
|
+
api,
|
|
177
|
+
listFlow: list.flow,
|
|
178
|
+
query: undefined,
|
|
179
|
+
live: LIVE,
|
|
180
|
+
});
|
|
181
|
+
seen.data = state.data;
|
|
182
|
+
seen.isLoading = state.isLoading;
|
|
183
|
+
seen.isConnected = state.isConnected;
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let root: Root | undefined;
|
|
188
|
+
await act(async () => {
|
|
189
|
+
root = createRoot(host as unknown as Element);
|
|
190
|
+
root.render(createElement(Probe));
|
|
191
|
+
});
|
|
192
|
+
await waitFor(() => seen.data !== null && !seen.isLoading);
|
|
193
|
+
expect(seen.data!.map((t) => t.id)).toEqual(["t1"]);
|
|
194
|
+
expect(sseOpened).toBe(1);
|
|
195
|
+
expect(seen.isConnected).toBe(true);
|
|
196
|
+
|
|
197
|
+
await act(async () => {
|
|
198
|
+
pushEvent?.({
|
|
199
|
+
kind: "upsert",
|
|
200
|
+
row: { id: "t2", title: "two", status: "open", updatedAt: 2 },
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
await waitFor(() => seen.data!.some((t) => t.id === "t2"));
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
pushEvent?.({ kind: "delete", id: "t1" });
|
|
207
|
+
});
|
|
208
|
+
await waitFor(() => !seen.data!.some((t) => t.id === "t1"));
|
|
209
|
+
|
|
210
|
+
await act(async () => {
|
|
211
|
+
pushEvent?.({
|
|
212
|
+
kind: "upsert",
|
|
213
|
+
row: { id: "t2", title: "two!", status: "done", updatedAt: 3 },
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
await waitFor(() => seen.data![0]?.title === "two!");
|
|
217
|
+
|
|
218
|
+
await act(async () => {
|
|
219
|
+
root?.unmount();
|
|
220
|
+
});
|
|
221
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
222
|
+
expect(list.calls.length).toBeGreaterThanOrEqual(1);
|
|
223
|
+
host.remove();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("snapshot/SSE race: delete event before snapshot lands tombstones the row", async () => {
|
|
227
|
+
const initialRows: Task[] = [
|
|
228
|
+
{ id: "gone", title: "will vanish", status: "open", updatedAt: 1 },
|
|
229
|
+
{ id: "stay", title: "stays", status: "open", updatedAt: 1 },
|
|
230
|
+
];
|
|
231
|
+
// Delay the list response so the SSE delete arrives first.
|
|
232
|
+
let releaseList: ((v: void) => void) | undefined;
|
|
233
|
+
const gate = new Promise<void>((r) => (releaseList = r));
|
|
234
|
+
let ssePush: ((e: unknown) => void) | undefined;
|
|
235
|
+
|
|
236
|
+
const listFlow = ((input?: unknown) => {
|
|
237
|
+
void input;
|
|
238
|
+
return gate.then(() =>
|
|
239
|
+
Promise.resolve({
|
|
240
|
+
data: initialRows,
|
|
241
|
+
error: null,
|
|
242
|
+
next: () => {},
|
|
243
|
+
prev: () => {},
|
|
244
|
+
[Symbol.asyncIterator]() {
|
|
245
|
+
return {
|
|
246
|
+
async next() {
|
|
247
|
+
return { done: true as const, value: undefined };
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}) as unknown as Parameters<typeof useLiveQuery<Task>>[0]["listFlow"];
|
|
254
|
+
|
|
255
|
+
const api = createClient("http://app.test", {
|
|
256
|
+
fetch: async (_url, init) => {
|
|
257
|
+
if (init?.method === "GET") {
|
|
258
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
259
|
+
start(controller) {
|
|
260
|
+
const enc = new TextEncoder();
|
|
261
|
+
ssePush = (e: unknown) =>
|
|
262
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
return new Response(stream, {
|
|
266
|
+
status: 200,
|
|
267
|
+
headers: { "content-type": "text/event-stream" },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
throw new Error("unexpected");
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const host = happy.document.createElement("div");
|
|
275
|
+
happy.document.body.appendChild(host);
|
|
276
|
+
const seen: { data: readonly Task[] | null } = { data: null };
|
|
277
|
+
function Probe(): null {
|
|
278
|
+
const state = useLiveQuery<Task>({ api, listFlow, query: undefined, live: LIVE });
|
|
279
|
+
seen.data = state.data;
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
let root: Root | undefined;
|
|
283
|
+
await act(async () => {
|
|
284
|
+
root = createRoot(host as unknown as Element);
|
|
285
|
+
root.render(createElement(Probe));
|
|
286
|
+
});
|
|
287
|
+
await waitFor(() => ssePush !== undefined);
|
|
288
|
+
await act(async () => {
|
|
289
|
+
ssePush?.({ kind: "delete", id: "gone" });
|
|
290
|
+
});
|
|
291
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
292
|
+
await act(async () => {
|
|
293
|
+
releaseList?.();
|
|
294
|
+
});
|
|
295
|
+
await waitFor(() => seen.data !== null);
|
|
296
|
+
expect(seen.data!.map((t) => t.id)).toEqual(["stay"]);
|
|
297
|
+
await act(async () => {
|
|
298
|
+
root?.unmount();
|
|
299
|
+
});
|
|
300
|
+
host.remove();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("optimistic mutate patches locally, rolls back on failure", async () => {
|
|
304
|
+
const initialRows: Task[] = [{ id: "t1", title: "one", status: "open", updatedAt: 1 }];
|
|
305
|
+
const list = listFlowFrom(initialRows);
|
|
306
|
+
let mutationCalls = 0;
|
|
307
|
+
// Closure flag read per call: `true` → failure envelope.
|
|
308
|
+
let failMutate = true;
|
|
309
|
+
|
|
310
|
+
const updateFlow = ((input: unknown) => {
|
|
311
|
+
mutationCalls += 1;
|
|
312
|
+
void input;
|
|
313
|
+
return Promise.resolve(
|
|
314
|
+
failMutate
|
|
315
|
+
? {
|
|
316
|
+
data: null,
|
|
317
|
+
error: { code: "Forbidden", data: {} },
|
|
318
|
+
next: () => {},
|
|
319
|
+
prev: () => {},
|
|
320
|
+
}
|
|
321
|
+
: {
|
|
322
|
+
data: { id: "t1" },
|
|
323
|
+
error: null,
|
|
324
|
+
next: () => {},
|
|
325
|
+
prev: () => {},
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
}) as unknown as Parameters<ReturnType<typeof useLiveQuery<Task>>["mutate"]>[0];
|
|
329
|
+
|
|
330
|
+
const api = createClient("http://app.test", {
|
|
331
|
+
fetch: async (_url, init) => {
|
|
332
|
+
if (init?.method === "GET") {
|
|
333
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
334
|
+
start(controller) {
|
|
335
|
+
// Keep the SSE leg open; this test drives mutations only.
|
|
336
|
+
void controller;
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
return new Response(stream, {
|
|
340
|
+
status: 200,
|
|
341
|
+
headers: { "content-type": "text/event-stream" },
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
throw new Error("unexpected");
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const host = happy.document.createElement("div");
|
|
349
|
+
happy.document.body.appendChild(host);
|
|
350
|
+
const seen: { data: readonly Task[] | null } = { data: null };
|
|
351
|
+
let mutateRef: ReturnType<typeof useLiveQuery<Task>>["mutate"] | undefined;
|
|
352
|
+
function Probe(): null {
|
|
353
|
+
const state = useLiveQuery<Task>({ api, listFlow: list.flow, query: undefined, live: LIVE });
|
|
354
|
+
seen.data = state.data;
|
|
355
|
+
mutateRef = state.mutate;
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
let root: Root | undefined;
|
|
359
|
+
await act(async () => {
|
|
360
|
+
root = createRoot(host as unknown as Element);
|
|
361
|
+
root.render(createElement(Probe));
|
|
362
|
+
});
|
|
363
|
+
await waitFor(() => seen.data !== null);
|
|
364
|
+
|
|
365
|
+
// Failing mutation rolls back to the pre-mutate snapshot.
|
|
366
|
+
await act(async () => {
|
|
367
|
+
const result = await mutateRef!(
|
|
368
|
+
updateFlow,
|
|
369
|
+
{ id: "t1" },
|
|
370
|
+
{
|
|
371
|
+
optimistic: (rows) => rows.map((r) => (r.id === "t1" ? { ...r, status: "archived" } : r)),
|
|
372
|
+
pkOf: (i) => (i as { id: string }).id,
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
expect(result.error).not.toBeNull();
|
|
376
|
+
});
|
|
377
|
+
expect(mutationCalls).toBe(1);
|
|
378
|
+
expect(seen.data![0]?.status).toBe("open");
|
|
379
|
+
|
|
380
|
+
// Successful mutation patches locally and confirms via result PK.
|
|
381
|
+
failMutate = false;
|
|
382
|
+
await act(async () => {
|
|
383
|
+
const result = await mutateRef!(
|
|
384
|
+
updateFlow,
|
|
385
|
+
{ id: "t1" },
|
|
386
|
+
{
|
|
387
|
+
optimistic: (rows) => rows.map((r) => (r.id === "t1" ? { ...r, status: "done" } : r)),
|
|
388
|
+
pkOf: (i) => (i as { id: string }).id,
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
expect(result.error).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
expect(mutationCalls).toBe(2);
|
|
394
|
+
expect(seen.data![0]?.status).toBe("done");
|
|
395
|
+
|
|
396
|
+
await act(async () => {
|
|
397
|
+
root?.unmount();
|
|
398
|
+
});
|
|
399
|
+
host.remove();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("mutate sends X-Oke-Mutation-Id header per call", async () => {
|
|
403
|
+
const initialRows: Task[] = [{ id: "t1", title: "one", status: "open", updatedAt: 1 }];
|
|
404
|
+
const list = listFlowFrom(initialRows);
|
|
405
|
+
const seenMutationHeaders: (string | undefined)[] = [];
|
|
406
|
+
|
|
407
|
+
const api = createClient("http://app.test", {
|
|
408
|
+
$routes: {
|
|
409
|
+
tasks: {
|
|
410
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
411
|
+
update: { method: "POST", path: "/tasks/update" },
|
|
412
|
+
},
|
|
413
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
414
|
+
} as never,
|
|
415
|
+
fetch: async (_url, init) => {
|
|
416
|
+
if (init?.method === "GET") {
|
|
417
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
418
|
+
start(controller) {
|
|
419
|
+
void controller;
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
return new Response(stream, {
|
|
423
|
+
status: 200,
|
|
424
|
+
headers: { "content-type": "text/event-stream" },
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const headers = new Headers(init?.headers);
|
|
428
|
+
seenMutationHeaders.push(headers.get("x-oke-mutation-id") ?? undefined);
|
|
429
|
+
return new Response(JSON.stringify({ data: { id: "t1" }, error: null }), {
|
|
430
|
+
status: 200,
|
|
431
|
+
headers: { "content-type": "application/json" },
|
|
432
|
+
});
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const host = happy.document.createElement("div");
|
|
437
|
+
happy.document.body.appendChild(host);
|
|
438
|
+
let mutateRef: ReturnType<typeof useLiveQuery<Task>>["mutate"] | undefined;
|
|
439
|
+
function Probe(): null {
|
|
440
|
+
const state = useLiveQuery<Task>({ api, listFlow: list.flow, query: undefined, live: LIVE });
|
|
441
|
+
mutateRef = state.mutate;
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
let root: Root | undefined;
|
|
445
|
+
await act(async () => {
|
|
446
|
+
root = createRoot(host as unknown as Element);
|
|
447
|
+
root.render(createElement(Probe));
|
|
448
|
+
});
|
|
449
|
+
await waitFor(() => mutateRef !== undefined);
|
|
450
|
+
|
|
451
|
+
const updateFlow = (
|
|
452
|
+
api as unknown as {
|
|
453
|
+
tasks: { update: Parameters<ReturnType<typeof useLiveQuery<Task>>["mutate"]>[0] };
|
|
454
|
+
}
|
|
455
|
+
).tasks.update;
|
|
456
|
+
|
|
457
|
+
await act(async () => {
|
|
458
|
+
await mutateRef!(updateFlow, { id: "t1" }, {});
|
|
459
|
+
});
|
|
460
|
+
await act(async () => {
|
|
461
|
+
await mutateRef!(updateFlow, { id: "t1" }, {});
|
|
462
|
+
});
|
|
463
|
+
expect(seenMutationHeaders.length).toBe(2);
|
|
464
|
+
for (const h of seenMutationHeaders) {
|
|
465
|
+
expect(h).toBeDefined();
|
|
466
|
+
expect(h!.length).toBeGreaterThan(8);
|
|
467
|
+
}
|
|
468
|
+
expect(seenMutationHeaders[0]).not.toBe(seenMutationHeaders[1]);
|
|
469
|
+
|
|
470
|
+
await act(async () => {
|
|
471
|
+
root?.unmount();
|
|
472
|
+
});
|
|
473
|
+
host.remove();
|
|
474
|
+
});
|
|
475
|
+
});
|