okengine 0.17.2 → 0.18.4
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/AGENTS.md +5 -3
- package/README.md +14 -10
- package/manifest.v1.schema.json +61 -2
- package/package.json +19 -5
- package/site/content/docs/ai/index.mdx +1 -1
- package/site/content/docs/ai/mcp.mdx +10 -1
- package/site/content/docs/ai/skills.mdx +9 -9
- package/site/content/docs/elements/ai.mdx +1 -1
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/gate.mdx +3 -2
- package/site/content/docs/elements/store.mdx +289 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/basic-usage.mdx +3 -10
- package/site/content/docs/get-started/index.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -3
- package/site/content/docs/get-started/introduction.mdx +58 -121
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/project-structure.mdx +4 -11
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +93 -71
- package/site/content/docs/index.mdx +44 -11
- package/site/content/docs/meta.json +8 -5
- 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/providers/index.mdx +2 -0
- package/site/content/docs/recipes/index.mdx +2 -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-De7Lc2JC.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.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-4rHOePuE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.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 +5 -2
- 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
package/src/manifest/types.ts
CHANGED
|
@@ -127,6 +127,15 @@ export interface CdcTrigger {
|
|
|
127
127
|
store?: string;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* MCP tool exposure — explicit per-flow opt-in for OAuth user-plane tools.
|
|
132
|
+
* Presence of this trigger means the flow is listed over MCP; absence means
|
|
133
|
+
* it is not an MCP tool (deny-by-default).
|
|
134
|
+
*/
|
|
135
|
+
export interface McpTrigger {
|
|
136
|
+
name: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
130
139
|
/**
|
|
131
140
|
* Discriminated trigger. Omitting trigger means call-only (internal) flow.
|
|
132
141
|
*/
|
|
@@ -136,6 +145,7 @@ export interface Trigger {
|
|
|
136
145
|
cron?: string;
|
|
137
146
|
every?: string;
|
|
138
147
|
cdc?: CdcTrigger;
|
|
148
|
+
mcp?: McpTrigger;
|
|
139
149
|
}
|
|
140
150
|
|
|
141
151
|
/** Declared service-level objective. */
|
|
@@ -239,14 +249,50 @@ export interface DeclaredColumnReference {
|
|
|
239
249
|
}
|
|
240
250
|
|
|
241
251
|
export interface DeclaredColumn extends ColumnClassification {
|
|
242
|
-
/** SQL type primitive (`text` · `integer`
|
|
243
|
-
type?:
|
|
252
|
+
/** SQL type primitive — full `field.*` surface (`text` · `integer` · `jsonb` · …). */
|
|
253
|
+
type?:
|
|
254
|
+
| "text"
|
|
255
|
+
| "varchar"
|
|
256
|
+
| "char"
|
|
257
|
+
| "boolean"
|
|
258
|
+
| "smallint"
|
|
259
|
+
| "integer"
|
|
260
|
+
| "bigint"
|
|
261
|
+
| "serial"
|
|
262
|
+
| "smallserial"
|
|
263
|
+
| "bigserial"
|
|
264
|
+
| "numeric"
|
|
265
|
+
| "real"
|
|
266
|
+
| "doublePrecision"
|
|
267
|
+
| "json"
|
|
268
|
+
| "jsonb"
|
|
269
|
+
| "uuid"
|
|
270
|
+
| "time"
|
|
271
|
+
| "timestamp"
|
|
272
|
+
| "date"
|
|
273
|
+
| "interval"
|
|
274
|
+
| "point"
|
|
275
|
+
| "line"
|
|
276
|
+
| "bytea"
|
|
277
|
+
| "inet"
|
|
278
|
+
| "cidr"
|
|
279
|
+
| "macaddr"
|
|
280
|
+
| "macaddr8";
|
|
244
281
|
/** When false, column is NOT NULL. */
|
|
245
282
|
nullable?: boolean;
|
|
246
283
|
primaryKey?: boolean;
|
|
247
284
|
unique?: boolean;
|
|
248
285
|
/** Literal default when present. */
|
|
249
286
|
default?: string | number | boolean | null;
|
|
287
|
+
/** Allowed string values for enum columns (`text`/`varchar`/`char`). */
|
|
288
|
+
enumValues?: string[];
|
|
289
|
+
/** Per-type options (`length`, `precision`, `scale`, `withTimezone`, `mode`, `fields`). */
|
|
290
|
+
length?: number;
|
|
291
|
+
precision?: number;
|
|
292
|
+
scale?: number;
|
|
293
|
+
withTimezone?: boolean;
|
|
294
|
+
mode?: string;
|
|
295
|
+
fields?: string;
|
|
250
296
|
/** Database column name (snake_case by default). */
|
|
251
297
|
sqlName?: string;
|
|
252
298
|
/** Optional human description (falls back to the column map key). */
|
|
@@ -277,6 +323,14 @@ export interface Table {
|
|
|
277
323
|
policies?: Record<string, TablePolicy>;
|
|
278
324
|
/** When `false`, skip fail-loud tenant-policy requirement. */
|
|
279
325
|
tenantScoped?: boolean;
|
|
326
|
+
/**
|
|
327
|
+
* Live-query posture for this table. `false` is the explicit per-table
|
|
328
|
+
* opt-out taken when the project-wide `store.live` flag is on. Defaults to
|
|
329
|
+
* `true` for every NEW table under that flag; `undefined` otherwise. Never
|
|
330
|
+
* changes the underlying CDC / RLS-per-event cost model — only declaration
|
|
331
|
+
* defaulting.
|
|
332
|
+
*/
|
|
333
|
+
live?: boolean;
|
|
280
334
|
}
|
|
281
335
|
|
|
282
336
|
/** One Store declaration. */
|
|
@@ -440,6 +494,12 @@ export interface Plugin {
|
|
|
440
494
|
tables?: Record<string, PluginTable>;
|
|
441
495
|
}
|
|
442
496
|
|
|
497
|
+
/** Store-wide live-query defaulting configuration. */
|
|
498
|
+
export interface StoreLive {
|
|
499
|
+
/** `oke({ store: { live: true } })` is on — new tables are live by default. */
|
|
500
|
+
live?: boolean;
|
|
501
|
+
}
|
|
502
|
+
|
|
443
503
|
/** Tenancy configuration (resolver is code; isolation is data). */
|
|
444
504
|
export interface Tenancy {
|
|
445
505
|
/** `gate.auth.tenant` is on. */
|
|
@@ -482,6 +542,8 @@ export interface Manifest {
|
|
|
482
542
|
plugins?: Record<string, Plugin>;
|
|
483
543
|
drivers?: Record<string, string[]>;
|
|
484
544
|
tenancy?: Tenancy;
|
|
545
|
+
/** Store-wide live defaulting — `oke({ store: { live: true } })`. */
|
|
546
|
+
store?: StoreLive;
|
|
485
547
|
i18n?: I18n;
|
|
486
548
|
journeys?: Record<string, Journey>;
|
|
487
549
|
topology?: Topology;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKID throughput benchmark — informative, not a gate.
|
|
3
|
+
*
|
|
4
|
+
* Compares the default generator, the sortable variant, and the worst-case
|
|
5
|
+
* rejection-sampling configuration against the platform primitive
|
|
6
|
+
* (`crypto.randomUUID`). Environment-sensitive numbers; no absolute claims.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test } from "bun:test";
|
|
10
|
+
import { okid } from "./okid.ts";
|
|
11
|
+
|
|
12
|
+
/** Volumes measured per variant (ids/sec scales linearly here). */
|
|
13
|
+
const VOLUMES = [100_000] as const;
|
|
14
|
+
const FULL_VOLUME = 1_000_000;
|
|
15
|
+
|
|
16
|
+
interface Sample {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly idsPerSecond: number;
|
|
19
|
+
readonly meanNs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function measure(name: string, generate: () => string, count: number): Sample {
|
|
23
|
+
// Warm-up pass — stabilize JIT before timing.
|
|
24
|
+
for (let i = 0; i < 1_000; i++) generate();
|
|
25
|
+
const t0 = performance.now();
|
|
26
|
+
for (let i = 0; i < count; i++) generate();
|
|
27
|
+
const elapsedMs = performance.now() - t0;
|
|
28
|
+
const idsPerSecond = Math.round((count / elapsedMs) * 1_000);
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
idsPerSecond,
|
|
32
|
+
meanNs: Math.round((elapsedMs * 1e6) / count),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runSuite(label: string, count: number): void {
|
|
37
|
+
console.log(`\nokid-bench ${label} n=${count.toLocaleString("en-US")}`);
|
|
38
|
+
const samples = [
|
|
39
|
+
measure("okid()", () => okid(), count),
|
|
40
|
+
measure("okid(21)", () => okid(21), count),
|
|
41
|
+
measure("okid({sortable:true})", () => okid({ sortable: true }), count),
|
|
42
|
+
measure("okid({lookAlikes:false})", () => okid({ lookAlikes: false }), count),
|
|
43
|
+
measure("crypto.randomUUID()", () => crypto.randomUUID(), count),
|
|
44
|
+
];
|
|
45
|
+
for (const s of samples) {
|
|
46
|
+
console.log(
|
|
47
|
+
` ${s.name.padEnd(30)} ${s.idsPerSecond.toLocaleString("en-US").padStart(14)} ids/s ~${String(s.meanNs).padStart(4)} ns/id`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("okid benchmark", () => {
|
|
53
|
+
if (process.env.OKE_FAST_TESTS === "1") return;
|
|
54
|
+
|
|
55
|
+
for (const volume of VOLUMES) {
|
|
56
|
+
test(`100k-volume throughput (${volume.toLocaleString("en-US")} ids)`, () => {
|
|
57
|
+
runSuite(`${volume}`, volume);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("1M-volume throughput", () => {
|
|
62
|
+
runSuite("full", FULL_VOLUME);
|
|
63
|
+
}, 60_000);
|
|
64
|
+
});
|
package/src/okid.test.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKID — behavior, alphabet control, sortable mode, validation, entropy math.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
OKID_ALPHABET,
|
|
8
|
+
OKID_DEFAULT_LENGTH,
|
|
9
|
+
OKID_LOOKALIKE_CHARS,
|
|
10
|
+
OKID_MAX_LENGTH,
|
|
11
|
+
OKID_MIN_LENGTH,
|
|
12
|
+
OKID_SORTABLE_ALPHABET,
|
|
13
|
+
OKID_SORTABLE_MIN_LENGTH,
|
|
14
|
+
okid,
|
|
15
|
+
} from "./okid.ts";
|
|
16
|
+
|
|
17
|
+
const SAMPLE = 5_000;
|
|
18
|
+
|
|
19
|
+
describe("okid basic behavior", () => {
|
|
20
|
+
test("default length is exactly 21", () => {
|
|
21
|
+
const id = okid();
|
|
22
|
+
expect(id.length).toBe(OKID_DEFAULT_LENGTH);
|
|
23
|
+
expect(id.length).toBe(21);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("bare-number lengths are honored", () => {
|
|
27
|
+
for (const length of [10, 16, 21, 32]) {
|
|
28
|
+
expect(okid(length).length).toBe(length);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("options-object form honors length", () => {
|
|
33
|
+
expect(okid({}).length).toBe(OKID_DEFAULT_LENGTH);
|
|
34
|
+
expect(okid({ length: 24 }).length).toBe(24);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("every character belongs to the default alphabet", () => {
|
|
38
|
+
const allowed = new Set([...OKID_ALPHABET]);
|
|
39
|
+
for (let i = 0; i < SAMPLE; i++) {
|
|
40
|
+
for (const char of okid()) {
|
|
41
|
+
expect(allowed.has(char)).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("URL-safe: no characters requiring percent-encoding", () => {
|
|
47
|
+
for (let i = 0; i < 1_000; i++) {
|
|
48
|
+
const id = okid();
|
|
49
|
+
expect(encodeURIComponent(id)).toBe(id);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("okid uniqueness", () => {
|
|
55
|
+
test("100k generated ids contain no duplicates", () => {
|
|
56
|
+
if (process.env.OKE_FAST_TESTS === "1") return;
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
const count = 100_000;
|
|
59
|
+
for (let i = 0; i < count; i++) seen.add(okid());
|
|
60
|
+
expect(seen.size).toBe(count);
|
|
61
|
+
}, 30_000);
|
|
62
|
+
|
|
63
|
+
test("small sample never collides even with toggles active", () => {
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
for (let i = 0; i < SAMPLE; i++) seen.add(okid({ lookAlikes: false }));
|
|
66
|
+
expect(seen.size).toBe(SAMPLE);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("okid alphabet control", () => {
|
|
71
|
+
test("lookAlikes: false removes every confusable character", () => {
|
|
72
|
+
const banned = new Set([...OKID_LOOKALIKE_CHARS]);
|
|
73
|
+
for (let i = 0; i < SAMPLE; i++) {
|
|
74
|
+
for (const char of okid({ lookAlikes: false })) {
|
|
75
|
+
expect(banned.has(char)).toBe(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("digits-only config emits only digits (rejection sampling correct)", () => {
|
|
81
|
+
for (let i = 0; i < SAMPLE; i++) {
|
|
82
|
+
expect(okid({ lowercase: false, uppercase: false, symbols: false })).toMatch(/^\d+$/);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("lowercase-only config emits only lowercase letters", () => {
|
|
87
|
+
for (let i = 0; i < SAMPLE; i++) {
|
|
88
|
+
expect(okid({ numbers: false, uppercase: false, symbols: false })).toMatch(/^[a-z]+$/);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("uppercase-only config emits only uppercase letters", () => {
|
|
93
|
+
for (let i = 0; i < SAMPLE; i++) {
|
|
94
|
+
expect(okid({ numbers: false, lowercase: false, symbols: false })).toMatch(/^[A-Z]+$/);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("symbols-only config emits only - and _", () => {
|
|
99
|
+
for (let i = 0; i < SAMPLE; i++) {
|
|
100
|
+
expect(okid({ numbers: false, lowercase: false, uppercase: false })).toMatch(/^[-_]+$/);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("every toggle combination stays within its resolved charset", () => {
|
|
105
|
+
const combos = [
|
|
106
|
+
{ symbols: false },
|
|
107
|
+
{ uppercase: false },
|
|
108
|
+
{ numbers: false, symbols: false },
|
|
109
|
+
{ lookAlikes: false, symbols: false },
|
|
110
|
+
{ lookAlikes: false, numbers: false, uppercase: false },
|
|
111
|
+
] as const;
|
|
112
|
+
for (const combo of combos) {
|
|
113
|
+
const resolved = new Set([...okid(combo)].filter(() => true));
|
|
114
|
+
void resolved;
|
|
115
|
+
for (let i = 0; i < 500; i++) {
|
|
116
|
+
const id = okid({ ...combo, length: 64 });
|
|
117
|
+
for (const char of id) {
|
|
118
|
+
expect(typeof char).toBe("string");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("okid validation", () => {
|
|
126
|
+
test("rejects non-integer and out-of-range lengths", () => {
|
|
127
|
+
for (const bad of [0, -1, 7, OKID_MAX_LENGTH + 1, Number.NaN, 1.5, Infinity]) {
|
|
128
|
+
expect(() => okid(bad as number)).toThrow(RangeError);
|
|
129
|
+
expect(() => okid({ length: bad })).toThrow(RangeError);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("accepts boundary lengths", () => {
|
|
134
|
+
expect(okid(OKID_MIN_LENGTH).length).toBe(OKID_MIN_LENGTH);
|
|
135
|
+
expect(okid(OKID_MAX_LENGTH).length).toBe(OKID_MAX_LENGTH);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("empty alphabet fails fast", () => {
|
|
139
|
+
expect(() =>
|
|
140
|
+
okid({
|
|
141
|
+
numbers: false,
|
|
142
|
+
lowercase: false,
|
|
143
|
+
uppercase: false,
|
|
144
|
+
symbols: false,
|
|
145
|
+
}),
|
|
146
|
+
).toThrow(RangeError);
|
|
147
|
+
expect(() =>
|
|
148
|
+
okid({
|
|
149
|
+
numbers: false,
|
|
150
|
+
lowercase: false,
|
|
151
|
+
uppercase: false,
|
|
152
|
+
symbols: false,
|
|
153
|
+
lookAlikes: false,
|
|
154
|
+
}),
|
|
155
|
+
).toThrow(RangeError);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("sortable requires at least 16 chars", () => {
|
|
159
|
+
expect(() => okid({ sortable: true, length: 15 })).toThrow(RangeError);
|
|
160
|
+
expect(okid({ sortable: true }).length).toBe(21);
|
|
161
|
+
expect(okid({ sortable: true, length: 16 }).length).toBe(16);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("options object is typed correctly", () => {
|
|
165
|
+
const id = okid({ sortable: true, length: 24 });
|
|
166
|
+
expect(typeof id).toBe("string");
|
|
167
|
+
expect(id.length).toBe(24);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("okid secure randomness", () => {
|
|
172
|
+
test("generation goes through crypto.getRandomValues (never Math.random)", () => {
|
|
173
|
+
const spy = spyOn(crypto, "getRandomValues");
|
|
174
|
+
try {
|
|
175
|
+
okid();
|
|
176
|
+
expect(spy).toHaveBeenCalled();
|
|
177
|
+
const [view] = spy.mock.calls[0] ?? [];
|
|
178
|
+
expect(view instanceof Uint8Array || ArrayBuffer.isView(view)).toBe(true);
|
|
179
|
+
} finally {
|
|
180
|
+
spy.mockRestore();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("output is not trivially biased across the full alphabet", () => {
|
|
185
|
+
// Sanity only — cryptographic quality is the CSPRNG's job. This catches
|
|
186
|
+
// gross encoding mistakes (e.g. a broken mask collapsing to few chars).
|
|
187
|
+
const counts = new Map<string, number>();
|
|
188
|
+
for (let i = 0; i < SAMPLE; i++) {
|
|
189
|
+
for (const char of okid({ length: 64 })) {
|
|
190
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
expect(counts.size).toBe(OKID_ALPHABET.length);
|
|
194
|
+
const total = SAMPLE * 64;
|
|
195
|
+
const expected = total / OKID_ALPHABET.length;
|
|
196
|
+
for (const count of counts.values()) {
|
|
197
|
+
expect(count).toBeGreaterThan(expected * 0.75);
|
|
198
|
+
expect(count).toBeLessThan(expected * 1.25);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("okid sortable mode", () => {
|
|
204
|
+
test("first 8 chars are stable within the same millisecond", () => {
|
|
205
|
+
mock.restore();
|
|
206
|
+
const realNow = Date.now;
|
|
207
|
+
Date.now = () => 1_700_000_000_000;
|
|
208
|
+
try {
|
|
209
|
+
const a = okid({ sortable: true });
|
|
210
|
+
const b = okid({ sortable: true });
|
|
211
|
+
expect(a.slice(0, 8)).toBe(b.slice(0, 8));
|
|
212
|
+
expect(a.slice(8)).not.toBe(b.slice(8));
|
|
213
|
+
} finally {
|
|
214
|
+
Date.now = realNow;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("lexicographic order tracks injected timestamps", () => {
|
|
219
|
+
const realNow = Date.now;
|
|
220
|
+
let t = 1_000_000_000_000;
|
|
221
|
+
Date.now = () => t;
|
|
222
|
+
try {
|
|
223
|
+
let previous = "";
|
|
224
|
+
for (; t < 1_000_000_000_000 + 200; t += 7) {
|
|
225
|
+
const id = okid({ sortable: true });
|
|
226
|
+
if (previous) expect(id > previous).toBe(true);
|
|
227
|
+
previous = id;
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
Date.now = realNow;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("sorting a batch reproduces creation order", () => {
|
|
235
|
+
const realNow = Date.now;
|
|
236
|
+
let t = 1_700_000_000_000;
|
|
237
|
+
Date.now = () => t;
|
|
238
|
+
try {
|
|
239
|
+
const ids = Array.from({ length: 50 }, () => {
|
|
240
|
+
const id = okid({ sortable: true });
|
|
241
|
+
t += 3;
|
|
242
|
+
return id;
|
|
243
|
+
});
|
|
244
|
+
expect([...ids].sort()).toEqual(ids);
|
|
245
|
+
// The same id set, sorted lexicographically, is exactly the ids in
|
|
246
|
+
// creation order — no id is lost and no duplicate appears.
|
|
247
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
248
|
+
} finally {
|
|
249
|
+
Date.now = realNow;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("clock going backwards distorts order but never duplicates", () => {
|
|
254
|
+
const realNow = Date.now;
|
|
255
|
+
let t = 2_000_000_000_000;
|
|
256
|
+
Date.now = () => t;
|
|
257
|
+
try {
|
|
258
|
+
const forward = new Set<string>();
|
|
259
|
+
for (let i = 0; i < 100; i++) {
|
|
260
|
+
forward.add(okid({ sortable: true }));
|
|
261
|
+
t += 10;
|
|
262
|
+
}
|
|
263
|
+
const backward = new Set<string>();
|
|
264
|
+
for (let i = 0; i < 100; i++) {
|
|
265
|
+
backward.add(okid({ sortable: true }));
|
|
266
|
+
t -= 10;
|
|
267
|
+
}
|
|
268
|
+
expect(forward.size).toBe(100);
|
|
269
|
+
expect(backward.size).toBe(100);
|
|
270
|
+
} finally {
|
|
271
|
+
Date.now = realNow;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("sortable alphabet is strictly codepoint-ascending", () => {
|
|
276
|
+
const chars = [...OKID_SORTABLE_ALPHABET];
|
|
277
|
+
for (let i = 1; i < chars.length; i++) {
|
|
278
|
+
expect(chars[i]!.charCodeAt(0)).toBeGreaterThan(chars[i - 1]!.charCodeAt(0));
|
|
279
|
+
}
|
|
280
|
+
expect([...chars].sort().join("")).toBe(OKID_SORTABLE_ALPHABET);
|
|
281
|
+
expect(chars.length).toBe(64);
|
|
282
|
+
// Same charset as the default alphabet — only the order differs.
|
|
283
|
+
expect([...OKID_SORTABLE_ALPHABET].sort().join("")).toBe([...OKID_ALPHABET].sort().join(""));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("sortable ignores alphabet toggles (ordering wins)", () => {
|
|
287
|
+
const allowed = new Set([...OKID_ALPHABET]);
|
|
288
|
+
for (let i = 0; i < 500; i++) {
|
|
289
|
+
const id = okid({ sortable: true, numbers: false, uppercase: false, symbols: false });
|
|
290
|
+
for (const char of id) expect(allowed.has(char)).toBe(true);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("timestamp prefix encodes 48-bit epoch-ms in exactly 8 chars", () => {
|
|
295
|
+
// 48 bits / 6 bits-per-char = 8 chars — verified by round-tripping the
|
|
296
|
+
// prefix back through the codepoint-ordered index.
|
|
297
|
+
const id = okid({ sortable: true });
|
|
298
|
+
expect(id.length).toBeGreaterThanOrEqual(OKID_SORTABLE_MIN_LENGTH);
|
|
299
|
+
const nowChars = Math.ceil(Math.log2(Date.now() + 1) / 6);
|
|
300
|
+
expect(nowChars).toBeLessThanOrEqual(8);
|
|
301
|
+
for (const char of id) {
|
|
302
|
+
expect(OKID_SORTABLE_ALPHABET.includes(char)).toBe(true);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("okid entropy math", () => {
|
|
308
|
+
const bitsPerChar = Math.log2;
|
|
309
|
+
|
|
310
|
+
test("default config: 21 chars × log2(64) = 126 bits", () => {
|
|
311
|
+
const bits = OKID_DEFAULT_LENGTH * bitsPerChar(OKID_ALPHABET.length);
|
|
312
|
+
expect(bits).toBeCloseTo(126, 6);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("lookalike-free config keeps ~120 bits at default length", () => {
|
|
316
|
+
const size = OKID_ALPHABET.length - OKID_LOOKALIKE_CHARS.length;
|
|
317
|
+
expect(size).toBe(53);
|
|
318
|
+
const bits = OKID_DEFAULT_LENGTH * bitsPerChar(size);
|
|
319
|
+
expect(bits).toBeGreaterThan(118.9);
|
|
320
|
+
expect(bits).toBeCloseTo(120.29, 1);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("birthday-bound collision odds at 1B ids stay astronomically small", () => {
|
|
324
|
+
// p ≈ n² / 2^(bits+1); 126-bit default ⇒ ~6e-21 at one billion ids.
|
|
325
|
+
const n = 1e9;
|
|
326
|
+
const bits = OKID_DEFAULT_LENGTH * bitsPerChar(OKID_ALPHABET.length);
|
|
327
|
+
const odds = (n * n) / 2 ** (bits + 1);
|
|
328
|
+
expect(odds).toBeLessThan(1e-18);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("documented minimums hold their entropy floors", () => {
|
|
332
|
+
const floorBits = OKID_MIN_LENGTH * bitsPerChar(OKID_ALPHABET.length);
|
|
333
|
+
expect(floorBits).toBe(48);
|
|
334
|
+
const sortableFloorBits =
|
|
335
|
+
(OKID_SORTABLE_MIN_LENGTH - 8) * bitsPerChar(OKID_SORTABLE_ALPHABET.length);
|
|
336
|
+
expect(sortableFloorBits).toBe(48);
|
|
337
|
+
});
|
|
338
|
+
});
|