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,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseList extraction parity (Realtime Round 2 gate):
|
|
3
|
+
*
|
|
4
|
+
* The PostgREST list grammar parser was extracted from the `store.resource`
|
|
5
|
+
* factory closure into `parseListQuery` (list-query.ts). This suite proves
|
|
6
|
+
* the extraction is behavior-identical:
|
|
7
|
+
*
|
|
8
|
+
* 1. Dual-path parity — the SAME query matrix runs through the resource's
|
|
9
|
+
* own parse path (`resourceR.page()` / thrown failure JSON) and through
|
|
10
|
+
* the standalone `parseListQuery(resolveListScope(...).query, ...)`;
|
|
11
|
+
* every compiled where/order/limit/cursor and every failure shape must
|
|
12
|
+
* deep-equal.
|
|
13
|
+
* 2. Committed golden — `OKE_MINT_LIST_GOLDEN=1 bun test` re-mints
|
|
14
|
+
* `list-query.golden.json` from the resource path; CI compares against
|
|
15
|
+
* the committed file, so any future drift in either path fails loudly.
|
|
16
|
+
* 3. Real-SQL e2e — identical URLs answered through both parse paths over
|
|
17
|
+
* pglite return identical rows and totals.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
21
|
+
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
22
|
+
import { writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { pgliteDriver } from "../../drivers/pglite.ts";
|
|
25
|
+
import type { SqlConnection } from "../../drivers/types.ts";
|
|
26
|
+
import { compileWhere } from "./sql-condition.ts";
|
|
27
|
+
import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
|
|
28
|
+
import { parseListQuery, resolveListScope, type ListQueryResult } from "./list-query.ts";
|
|
29
|
+
import { store } from "../store.ts";
|
|
30
|
+
|
|
31
|
+
const MINT = process.env.OKE_MINT_LIST_GOLDEN === "1";
|
|
32
|
+
const GOLDEN_PATH = join(import.meta.dir, "list-query.golden.json");
|
|
33
|
+
|
|
34
|
+
/** Shared table — offset + cursor configs exercise every grammar arm. */
|
|
35
|
+
const items = pgTable("parity_items", {
|
|
36
|
+
id: text("id").primaryKey(),
|
|
37
|
+
title: text("title").notNull(),
|
|
38
|
+
status: text("status").notNull(),
|
|
39
|
+
priority: integer("priority").notNull(),
|
|
40
|
+
createdAt: integer("created_at").notNull(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const OFFSET_CONFIG = {
|
|
44
|
+
mode: "offset",
|
|
45
|
+
search: [items.title],
|
|
46
|
+
filter: "all",
|
|
47
|
+
order: "all",
|
|
48
|
+
select: "all",
|
|
49
|
+
limit: 5,
|
|
50
|
+
maxLimit: 10,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
const CURSOR_CONFIG = {
|
|
54
|
+
mode: "cursor",
|
|
55
|
+
cursor: [items.createdAt, items.id],
|
|
56
|
+
direction: "desc",
|
|
57
|
+
filter: "all",
|
|
58
|
+
order: "all",
|
|
59
|
+
limit: 5,
|
|
60
|
+
maxLimit: 10,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
/** Cursor mode with NO cursor columns — `?cursor=` must fail "not configured". */
|
|
64
|
+
const CURSOR_UNCONFIGURED_CONFIG = {
|
|
65
|
+
mode: "cursor",
|
|
66
|
+
filter: "all",
|
|
67
|
+
order: "all",
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
/** Build a resource instance per config — the BEFORE path (internal parseList). */
|
|
71
|
+
function resourceFor(config: Record<string, unknown>) {
|
|
72
|
+
const db = store.sql("parity", { schema: { items } });
|
|
73
|
+
return store.resource(db, items, {
|
|
74
|
+
in: { unknown: true } as never,
|
|
75
|
+
out: { unknown: true } as never,
|
|
76
|
+
list: config as never,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const offsetR = resourceFor(OFFSET_CONFIG);
|
|
81
|
+
const cursorR = resourceFor(CURSOR_CONFIG);
|
|
82
|
+
const cursorUnconfiguredR = resourceFor(CURSOR_UNCONFIGURED_CONFIG);
|
|
83
|
+
|
|
84
|
+
const offsetScope = resolveListScope(items, OFFSET_CONFIG).query;
|
|
85
|
+
const cursorScope = resolveListScope(items, CURSOR_CONFIG).query;
|
|
86
|
+
const cursorUnconfiguredScope = resolveListScope(items, CURSOR_UNCONFIGURED_CONFIG).query;
|
|
87
|
+
|
|
88
|
+
/* ———————————————————— deterministic serializers ———————————————————— */
|
|
89
|
+
|
|
90
|
+
/** Serialize a drizzle-shaped SqlOp tree into stable text (columns by key). */
|
|
91
|
+
function serializeOp(op: unknown): string {
|
|
92
|
+
if (op === undefined || op === null) return "undefined";
|
|
93
|
+
const chunks = (op as { queryChunks?: readonly unknown[] }).queryChunks;
|
|
94
|
+
if (!Array.isArray(chunks)) return JSON.stringify(op);
|
|
95
|
+
const keyByColumn = new Map<unknown, string>();
|
|
96
|
+
for (const [key, col] of Object.entries(items)) keyByColumn.set(col, key);
|
|
97
|
+
return (
|
|
98
|
+
"[" +
|
|
99
|
+
chunks
|
|
100
|
+
.map((chunk) => {
|
|
101
|
+
if (chunk === null || chunk === undefined) return "nil";
|
|
102
|
+
if (typeof chunk !== "object") return String(chunk);
|
|
103
|
+
const c = chunk as { constructor?: { name?: string }; value?: unknown };
|
|
104
|
+
if (c.constructor?.name === "StringChunk" && Array.isArray(c.value)) {
|
|
105
|
+
return `T(${JSON.stringify(c.value[0])})`;
|
|
106
|
+
}
|
|
107
|
+
if ("value" in c && Array.isArray(c.value)) {
|
|
108
|
+
return `P(${c.value.map((v) => JSON.stringify(v)).join(",")})`;
|
|
109
|
+
}
|
|
110
|
+
const colKey = keyByColumn.get(chunk);
|
|
111
|
+
if (colKey !== undefined) return `C(${colKey})`;
|
|
112
|
+
if (Array.isArray((chunk as { queryChunks?: unknown }).queryChunks)) {
|
|
113
|
+
return serializeOp(chunk);
|
|
114
|
+
}
|
|
115
|
+
return `?${JSON.stringify(Object.keys(chunk as object))}`;
|
|
116
|
+
})
|
|
117
|
+
.join(", ") +
|
|
118
|
+
"]"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Serialize a where condition into stable JSON via the real SQL compiler. */
|
|
123
|
+
function whereSnapshot(where: unknown): unknown {
|
|
124
|
+
if (where === undefined) return undefined;
|
|
125
|
+
const compiled = compileWhere(where);
|
|
126
|
+
if (compiled.clause === "") return undefined;
|
|
127
|
+
// Compile per-chunk: map drizzle columns to stable JS keys.
|
|
128
|
+
const keyByColumn = new Map<unknown, string>();
|
|
129
|
+
for (const [key, col] of Object.entries(items)) keyByColumn.set(col, key);
|
|
130
|
+
return {
|
|
131
|
+
clause: compiled.clause.replace(/\?[a-zA-Z_][a-zA-Z0-9_]*/g, "?"),
|
|
132
|
+
params: compiled.params,
|
|
133
|
+
predicates: compiled.predicates.map((p) => ({
|
|
134
|
+
column:
|
|
135
|
+
keyByColumn.get(
|
|
136
|
+
Array.from(keyByColumn.keys()).find(
|
|
137
|
+
(col) => (col as { name?: string }).name === p.column,
|
|
138
|
+
),
|
|
139
|
+
) ?? p.column,
|
|
140
|
+
op: p.op,
|
|
141
|
+
value: p.value,
|
|
142
|
+
})),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Compact a parse result (ok or failure) into a JSON-stable snapshot node. */
|
|
147
|
+
function snapshotOf(result: ListQueryResult): unknown {
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
return { ok: false, failure: JSON.parse(JSON.stringify(result.failure)) as unknown };
|
|
150
|
+
}
|
|
151
|
+
const page = result.page as Record<string, unknown>;
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
where: whereSnapshot(page.where),
|
|
155
|
+
orderBy: Array.isArray(page.orderBy) ? page.orderBy.map(serializeOp) : undefined,
|
|
156
|
+
limit: page.limit,
|
|
157
|
+
offset: page.offset,
|
|
158
|
+
after: page.after === undefined ? undefined : serializeOp(page.after),
|
|
159
|
+
before: page.before === undefined ? undefined : serializeOp(page.before),
|
|
160
|
+
meta: result.meta,
|
|
161
|
+
select: result.select?.map((c) => c.key),
|
|
162
|
+
cursorDir: result.cursorDir,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Run one input through the resource's internal parse path (BEFORE side). */
|
|
167
|
+
function viaResource(r: ReturnType<typeof resourceFor>, input: unknown): unknown {
|
|
168
|
+
try {
|
|
169
|
+
const page = r.page(input) as unknown as Record<string, unknown>;
|
|
170
|
+
const { meta, ...rest } = page;
|
|
171
|
+
return snapshotOf({ ok: true, page: rest, meta: meta as Record<string, unknown> });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const message = error instanceof TypeError ? error.message : String(error);
|
|
174
|
+
const marker = "resource.page: invalid list input — ";
|
|
175
|
+
const inner = message.startsWith(marker) ? message.slice(marker.length) : message;
|
|
176
|
+
return { ok: false, failure: JSON.parse(inner) as unknown };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Drop side-only metadata (`def.page` does not surface select/cursorDir). */
|
|
181
|
+
function commonOf(node: unknown): unknown {
|
|
182
|
+
const n = node as Record<string, unknown>;
|
|
183
|
+
const { select: _select, cursorDir: _cursorDir, ...rest } = n;
|
|
184
|
+
return rest;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* —————————————————————————— the query matrix —————————————————————————— */
|
|
188
|
+
|
|
189
|
+
const OFFSET_CASES: ReadonlyArray<[string, Record<string, unknown>]> = [
|
|
190
|
+
["default (no params)", {}],
|
|
191
|
+
["eq", { status: "eq.open" }],
|
|
192
|
+
["neq", { status: "neq.open" }],
|
|
193
|
+
["not.eq inversion", { status: "not.eq.open" }],
|
|
194
|
+
["gte", { priority: "gte.3" }],
|
|
195
|
+
["lt", { priority: "lt.5" }],
|
|
196
|
+
["like pattern", { title: "like.*task*" }],
|
|
197
|
+
["ilike pattern", { title: "ilike.*URGENT*" }],
|
|
198
|
+
["in list", { status: 'in.(open,done,"in,review")' }],
|
|
199
|
+
["is null", { title: "is.null" }],
|
|
200
|
+
["is true", { title: "is.true" }],
|
|
201
|
+
["or group", { or: "(status.eq.open,priority.gt.5)" }],
|
|
202
|
+
["and group", { and: "(status.eq.open,title.ilike.*urgent*)" }],
|
|
203
|
+
["nested not.or group", { or: "(status.eq.open,not.or(priority.lt.1,title.eq.x))" }],
|
|
204
|
+
["search", { search: "task" }],
|
|
205
|
+
["q alias", { q: "task" }],
|
|
206
|
+
["order multi desc", { order: "priority.desc,createdAt.asc" }],
|
|
207
|
+
["select projection", { select: "id,title" }],
|
|
208
|
+
["limit", { limit: "3" }],
|
|
209
|
+
["offset", { offset: "4" }],
|
|
210
|
+
["offset cursor token", { cursor: btoa(JSON.stringify({ k: "off", o: 4 })) }],
|
|
211
|
+
[
|
|
212
|
+
"combined",
|
|
213
|
+
{ status: "eq.open", order: "priority.desc", limit: "3", offset: "2", select: "id,status" },
|
|
214
|
+
],
|
|
215
|
+
["headers/cookie ignored", { headers: "x", cookie: "y", status: "eq.open" }],
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
const CURSOR_CASES: ReadonlyArray<[string, Record<string, unknown>]> = [
|
|
219
|
+
["default (cursor order implied)", {}],
|
|
220
|
+
["keyset after", { cursor: btoa(JSON.stringify({ v: [100, "i5"], d: "after" })) }],
|
|
221
|
+
["keyset before (flipped)", { cursor: btoa(JSON.stringify({ v: [100, "i5"], d: "before" })) }],
|
|
222
|
+
["bare array cursor (legacy after)", { cursor: btoa(JSON.stringify([100, "i5"])) }],
|
|
223
|
+
[
|
|
224
|
+
"filter + cursor",
|
|
225
|
+
{ status: "eq.open", cursor: btoa(JSON.stringify({ v: [100, "i5"], d: "after" })) },
|
|
226
|
+
],
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
const OFFSET_FAILURES: ReadonlyArray<[string, Record<string, unknown>]> = [
|
|
230
|
+
["unknown list param (filter none)", { secret: "eq.x" }],
|
|
231
|
+
["unknown or unfilterable column", { ghost: "eq.x" }],
|
|
232
|
+
["unsupported filter op", { status: "regex.x" }],
|
|
233
|
+
["missing op.value", { status: "open" }],
|
|
234
|
+
["bad is value", { title: "is.maybe" }],
|
|
235
|
+
["in without parens", { status: "in.open" }],
|
|
236
|
+
["in empty", { status: "in.()" }],
|
|
237
|
+
["search + q both", { search: "a", q: "b" }],
|
|
238
|
+
["bad limit", { limit: "0" }],
|
|
239
|
+
["bad offset", { offset: "-1" }],
|
|
240
|
+
["invalid cursor", { cursor: "!!!" }],
|
|
241
|
+
["bad order term", { order: "priority..desc" }],
|
|
242
|
+
["unknown order column", { order: "ghost.asc" }],
|
|
243
|
+
["unknown select column", { select: "ghost" }],
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const CURSOR_FAILURES: ReadonlyArray<
|
|
247
|
+
[string, Record<string, unknown>, "unconfigured" | "configured"]
|
|
248
|
+
> = [
|
|
249
|
+
[
|
|
250
|
+
"cursor pagination not configured",
|
|
251
|
+
{ cursor: btoa(JSON.stringify({ v: [100, "i5"], d: "after" })) },
|
|
252
|
+
"unconfigured",
|
|
253
|
+
],
|
|
254
|
+
[
|
|
255
|
+
"cursor with wrong arity",
|
|
256
|
+
{ cursor: btoa(JSON.stringify({ v: [100], d: "after" })) },
|
|
257
|
+
"configured",
|
|
258
|
+
],
|
|
259
|
+
["garbage cursor", { cursor: "not-even-base64" }, "configured"],
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
/* ———————————————————————————— the parity suite ——————————————————————————— */
|
|
263
|
+
|
|
264
|
+
describe("parseListQuery — extraction parity vs resource's internal parseList", () => {
|
|
265
|
+
const golden: Record<string, unknown> = {};
|
|
266
|
+
|
|
267
|
+
test("offset-mode matrix — both paths agree, golden matches", () => {
|
|
268
|
+
for (const [name, input] of OFFSET_CASES) {
|
|
269
|
+
const before = viaResource(offsetR, input);
|
|
270
|
+
const after = snapshotOf(parseListQuery(input, offsetScope));
|
|
271
|
+
expect(commonOf(after)).toEqual(commonOf(before));
|
|
272
|
+
golden[`offset:${name}`] = after;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("cursor-mode matrix — both paths agree, golden matches", () => {
|
|
277
|
+
for (const [name, input] of CURSOR_CASES) {
|
|
278
|
+
const before = viaResource(cursorR, input);
|
|
279
|
+
const after = snapshotOf(parseListQuery(input, cursorScope));
|
|
280
|
+
expect(commonOf(after)).toEqual(commonOf(before));
|
|
281
|
+
golden[`cursor:${name}`] = after;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("offset-mode failure shapes — both paths agree, golden matches", () => {
|
|
286
|
+
for (const [name, input] of OFFSET_FAILURES) {
|
|
287
|
+
const before = viaResource(offsetR, input);
|
|
288
|
+
expect((before as { ok: boolean }).ok).toBe(false);
|
|
289
|
+
const after = snapshotOf(parseListQuery(input, offsetScope));
|
|
290
|
+
expect(after).toEqual(before);
|
|
291
|
+
golden[`offset-fail:${name}`] = after;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("cursor-mode failure shapes — both paths agree, golden matches", () => {
|
|
296
|
+
for (const [name, input, side] of CURSOR_FAILURES) {
|
|
297
|
+
const resource = side === "configured" ? cursorR : cursorUnconfiguredR;
|
|
298
|
+
const scope = side === "configured" ? cursorScope : cursorUnconfiguredScope;
|
|
299
|
+
const before = viaResource(resource, input);
|
|
300
|
+
expect((before as { ok: boolean }).ok).toBe(false);
|
|
301
|
+
const after = snapshotOf(parseListQuery(input, scope));
|
|
302
|
+
expect(after).toEqual(before);
|
|
303
|
+
golden[`cursor-fail:${name}`] = after;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("committed golden matches", () => {
|
|
308
|
+
if (MINT) {
|
|
309
|
+
writeFileSync(GOLDEN_PATH, `${JSON.stringify(golden, null, 2)}\n`);
|
|
310
|
+
expect(existsSync(GOLDEN_PATH)).toBe(true);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
expect(existsSync(GOLDEN_PATH)).toBe(true);
|
|
314
|
+
const committed = JSON.parse(readFileSync(GOLDEN_PATH, "utf8")) as Record<string, unknown>;
|
|
315
|
+
expect(golden).toEqual(committed);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("select + cursorDir metadata (standalone-side assertions)", () => {
|
|
319
|
+
const withSelect = parseListQuery({ select: "id,title" }, offsetScope);
|
|
320
|
+
expect(withSelect.ok).toBe(true);
|
|
321
|
+
if (withSelect.ok) {
|
|
322
|
+
expect(withSelect.select?.map((c) => c.key)).toEqual(["id", "title"]);
|
|
323
|
+
}
|
|
324
|
+
const withBefore = parseListQuery(
|
|
325
|
+
{ cursor: btoa(JSON.stringify({ v: [100, "i5"], d: "before" })) },
|
|
326
|
+
cursorScope,
|
|
327
|
+
);
|
|
328
|
+
expect(withBefore.ok).toBe(true);
|
|
329
|
+
if (withBefore.ok) expect(withBefore.cursorDir).toBe("before");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/* —————————————————————————— real-SQL e2e parity —————————————————————————— */
|
|
334
|
+
|
|
335
|
+
describe("parseListQuery — identical URLs, identical rows (pglite e2e)", () => {
|
|
336
|
+
let conn: SqlConnection;
|
|
337
|
+
let handle: SqlStoreHandle;
|
|
338
|
+
|
|
339
|
+
beforeAll(async () => {
|
|
340
|
+
conn = await pgliteDriver.connect({ url: "memory://parity-items", role: "primary" });
|
|
341
|
+
handle = createSqlStoreHandle("sql:parity", {
|
|
342
|
+
connection: conn,
|
|
343
|
+
classifications: new Map(),
|
|
344
|
+
routedRole: "primary",
|
|
345
|
+
domainDdl: "ensure",
|
|
346
|
+
});
|
|
347
|
+
await handle
|
|
348
|
+
.insert(items)
|
|
349
|
+
.values({ id: "i1", title: "alpha task", status: "open", priority: 1, createdAt: 100 });
|
|
350
|
+
await handle
|
|
351
|
+
.insert(items)
|
|
352
|
+
.values({ id: "i2", title: "beta task", status: "done", priority: 5, createdAt: 200 });
|
|
353
|
+
await handle
|
|
354
|
+
.insert(items)
|
|
355
|
+
.values({ id: "i3", title: "urgent task", status: "open", priority: 9, createdAt: 300 });
|
|
356
|
+
await handle
|
|
357
|
+
.insert(items)
|
|
358
|
+
.values({ id: "i4", title: "alpha follow-up", status: "open", priority: 3, createdAt: 400 });
|
|
359
|
+
await handle
|
|
360
|
+
.insert(items)
|
|
361
|
+
.values({ id: "i5", title: "zzz archive", status: "done", priority: 2, createdAt: 500 });
|
|
362
|
+
}, 15_000);
|
|
363
|
+
|
|
364
|
+
afterAll(async () => {
|
|
365
|
+
await conn.close();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("filter+order+limit URL — same rows and total through both paths", async () => {
|
|
369
|
+
const input = { status: "eq.open", order: "priority.desc", limit: "2", offset: "0" };
|
|
370
|
+
const resourcePage = offsetR.page(input) as unknown as Record<string, unknown>;
|
|
371
|
+
const standalone = parseListQuery(input, offsetScope);
|
|
372
|
+
expect(standalone.ok).toBe(true);
|
|
373
|
+
if (!standalone.ok) return;
|
|
374
|
+
|
|
375
|
+
const rowsA = await handle.page(items, resourcePage);
|
|
376
|
+
const rowsB = await handle.page(items, standalone.page);
|
|
377
|
+
const totalA = await handle.count(items, resourcePage.where);
|
|
378
|
+
const totalB = await handle.count(items, standalone.page.where);
|
|
379
|
+
expect(rowsB).toEqual(rowsA);
|
|
380
|
+
expect(totalB).toBe(totalA);
|
|
381
|
+
expect(rowsA.map((r) => r.id)).toEqual(["i3", "i4"]);
|
|
382
|
+
expect(totalA).toBe(3);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("search URL — same rows through both paths", async () => {
|
|
386
|
+
const input = { search: "alpha", order: "createdAt.asc" };
|
|
387
|
+
const resourcePage = offsetR.page(input) as unknown as Record<string, unknown>;
|
|
388
|
+
const standalone = parseListQuery(input, offsetScope);
|
|
389
|
+
expect(standalone.ok).toBe(true);
|
|
390
|
+
if (!standalone.ok) return;
|
|
391
|
+
const rowsA = await handle.page(items, resourcePage);
|
|
392
|
+
const rowsB = await handle.page(items, standalone.page);
|
|
393
|
+
expect(rowsB).toEqual(rowsA);
|
|
394
|
+
expect(rowsA.map((r) => r.id)).toEqual(["i1", "i4"]);
|
|
395
|
+
});
|
|
396
|
+
});
|