okengine 0.21.0 → 0.21.1

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.
Files changed (45) hide show
  1. package/package.json +2 -7
  2. package/src/cli/dev.test.ts +105 -2
  3. package/src/cli/dev.ts +37 -2
  4. package/src/client/create.ts +13 -26
  5. package/src/client/live.ts +44 -101
  6. package/src/client/sse.ts +20 -66
  7. package/src/client/stream.ts +25 -67
  8. package/src/client/transport.ts +28 -73
  9. package/src/client/wire.ts +119 -0
  10. package/src/compiler/aot.ts +3 -32
  11. package/src/compiler/dynamic.ts +13 -11
  12. package/src/compiler/interpret.ts +45 -0
  13. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Bgt9bq2r.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{agent-disclosure-CSKumwS2.js → agent-disclosure-B43CXZZR.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-92uM5MO7.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-DtGVPtcs.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-DN7l6zmC.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-CwoV56jn.js → duration-tone-BmIR9FV8.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-BMHs-IzK.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-BlAEVgNW.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-BDf7OAHv.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-Cul17AcV.js → index-BKpaes3n.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-oY9vdYBk.js → observability-page-WnVLI-0j.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-B3GLNVfF.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-DMbnAe3f.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-CL0D9dOq.js → store-page-KvFDingJ.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Htk8Cm9t.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CFMPWX4f.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-1PlT19ft.js → units-page-C4NdNuxP.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-CBYl0LW_.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/elements/store/sql-session.ts +20 -4
  33. package/src/kernel/app.ts +9 -2
  34. package/src/kernel/client-descriptor.test.ts +78 -0
  35. package/src/kernel/client-descriptor.ts +23 -0
  36. package/src/kernel/errors-text.ts +99 -0
  37. package/src/kernel/errors.ts +90 -138
  38. package/src/kernel/fx-sql-handle.ts +305 -0
  39. package/src/kernel/fx.ts +37 -329
  40. package/src/kernel/json-result.ts +59 -0
  41. package/src/kernel/project-out.ts +6 -1
  42. package/src/okid-extended.ts +175 -0
  43. package/src/okid-shared.ts +103 -0
  44. package/src/okid.ts +30 -213
  45. package/src/release/build-lib.ts +9 -0
package/src/kernel/fx.ts CHANGED
@@ -9,10 +9,16 @@
9
9
  * for deterministic tests (§7.6).
10
10
  */
11
11
 
12
+ import {
13
+ isJsonResult,
14
+ isJsonStreamResult,
15
+ jsonResultBrand,
16
+ type JsonResult,
17
+ type JsonStreamResult,
18
+ } from "./json-result.ts";
12
19
  import type { Effects, ResourceRef, SignalResourceRef } from "../manifest/types.ts";
13
20
  import { isMcpToolRef } from "../manifest/mcp-ref.ts";
14
21
  import type { QueryPageSpec } from "./list-page.ts";
15
- import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
16
22
  import type {
17
23
  FilesStoreDecl,
18
24
  FilesStoreFxHandle,
@@ -20,8 +26,6 @@ import type {
20
26
  IndexStoreFxHandle,
21
27
  KvStoreDecl,
22
28
  KvStoreFxHandle,
23
- SelectFromBuilder,
24
- SelectOrderBuilder,
25
29
  SqlStoreDecl,
26
30
  StoreDecl,
27
31
  StoreHandle,
@@ -30,7 +34,6 @@ import type {
30
34
  } from "../elements/store.ts";
31
35
  import { rlsIdentityFromAuth } from "../elements/store.ts";
32
36
  import type { RlsIdentity } from "../drivers/pg-rls.ts";
33
- import type { SqlRow } from "../drivers/types.ts";
34
37
  import type { SignalDecl, SignalRuntime } from "../elements/signal.ts";
35
38
  import type { DeadLetter, SignalEmitOptions } from "../drivers/signal-types.ts";
36
39
  import type { VaultActor, VaultAdapter, VaultRuntime } from "../elements/vault.ts";
@@ -60,14 +63,7 @@ import {
60
63
  } from "./dry-run.ts";
61
64
  import type { FailFn } from "./errors.ts";
62
65
  import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
63
- import {
64
- fxAll,
65
- fxRace,
66
- fxRetry,
67
- fxUsing,
68
- type FxRetryOptions,
69
- type FxThunk,
70
- } from "./concurrency.ts";
66
+ import type { FxRetryOptions, FxThunk } from "./concurrency.ts";
71
67
  import { maskRedactedDeep, Redacted } from "./redacted.ts";
72
68
  import type { JournalSession, JournalStepOptions } from "./journal.ts";
73
69
 
@@ -90,8 +86,23 @@ function loadFail(): FailFn {
90
86
  }
91
87
 
92
88
  /** Lazy runs/window helpers — kept off the cold `oke` static graph. */
93
- async function loadRunsWindow(): Promise<typeof import("../runs/window.ts")> {
94
- return import("../runs/window.ts");
89
+ function loadRunsWindow(): typeof import("../runs/window.ts") {
90
+ const stem = ["win", "dow"].join("");
91
+ try {
92
+ return lazyRequire(`${import.meta.dir}/../runs`, stem);
93
+ } catch {
94
+ return lazyRequire(import.meta.dir, stem);
95
+ }
96
+ }
97
+
98
+ /** Driver-backed SQL query builder — off the edge profile until `fx.store` opens SQL. */
99
+ function loadFxSql(): typeof import("./fx-sql-handle.ts") {
100
+ return lazyRequire(import.meta.dir, ["fx", "sql", "handle"].join("-"));
101
+ }
102
+
103
+ /** `fx.all` / `race` / `retry` / `using` — off the edge profile until called. */
104
+ function loadConcurrency(): typeof import("./concurrency.ts") {
105
+ return lazyRequire(import.meta.dir, ["concur", "rency"].join(""));
95
106
  }
96
107
 
97
108
  /**
@@ -390,29 +401,8 @@ export interface FxSearchOptions {
390
401
  readonly topK?: number;
391
402
  }
392
403
 
393
- /** Brand for {@link JsonResult} (kept internal — flows never construct it). */
394
- export const jsonResultBrand: unique symbol = Symbol.for("oke.json");
395
-
396
- /** Carrier from {@link FxJson} — status + body read by the response encoder. */
397
- export interface JsonResult<T = unknown> {
398
- readonly [jsonResultBrand]: true;
399
- readonly status: number;
400
- readonly value?: T;
401
- readonly meta?: Record<string, unknown>;
402
- readonly kind?: undefined;
403
- }
404
-
405
- /** SSE carrier from {@link FxJson.stream} / {@link Fx.live}. */
406
- export interface JsonStreamResult {
407
- readonly [jsonResultBrand]: true;
408
- readonly kind: "stream";
409
- readonly status: 200;
410
- readonly chunks: AsyncIterable<unknown>;
411
- /** Awaited before the 200 SSE body; throws OKE1210 on a missing resume cursor. */
412
- ready?: () => Promise<void>;
413
- /** Set by the kernel to commit journal / Runs after the stream settles. */
414
- finalize?: () => Promise<void>;
415
- }
404
+ export { isJsonResult, isJsonStreamResult, jsonResultBrand };
405
+ export type { JsonResult, JsonStreamResult };
416
406
 
417
407
  const sseFrameBrand: unique symbol = Symbol.for("oke.sse.frame");
418
408
 
@@ -442,26 +432,6 @@ export function isSseFrame(value: unknown): value is SseFrame {
442
432
  return typeof value === "object" && value !== null && (value as SseFrame)[sseFrameBrand] === true;
443
433
  }
444
434
 
445
- /** True when `value` is an {@link FxJson} JSON-envelope carrier. */
446
- export function isJsonResult(value: unknown): value is JsonResult {
447
- return (
448
- typeof value === "object" &&
449
- value !== null &&
450
- (value as JsonResult)[jsonResultBrand] === true &&
451
- (value as JsonStreamResult).kind !== "stream"
452
- );
453
- }
454
-
455
- /** True when `value` is an SSE stream carrier from {@link FxJson.stream}. */
456
- export function isJsonStreamResult(value: unknown): value is JsonStreamResult {
457
- return (
458
- typeof value === "object" &&
459
- value !== null &&
460
- (value as JsonStreamResult)[jsonResultBrand] === true &&
461
- (value as JsonStreamResult).kind === "stream"
462
- );
463
- }
464
-
465
435
  /**
466
436
  * Paginated JSON page — `data` is Flow `out`; `meta` is the HTTP pager.
467
437
  *
@@ -1293,271 +1263,6 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1293
1263
  };
1294
1264
  }
1295
1265
 
1296
- /**
1297
- * Lazy, capability-gated proxy over a driver-backed {@link SqlStoreHandle}.
1298
- *
1299
- * @param decl - Store declaration
1300
- * @param open - Opens (and caches) the runtime handle
1301
- */
1302
- function gatedSqlHandle(decl: StoreDecl, open: () => Promise<SqlStoreHandle>): SqlStoreHandle {
1303
- const ref = decl.ref as `sql:${string}`;
1304
- let cached: SqlStoreHandle | undefined;
1305
- const ensure = async (): Promise<SqlStoreHandle> => {
1306
- if (!cached) cached = await open();
1307
- return cached;
1308
- };
1309
- /** Driver-backed SQL has no dry-run transaction — refuse writes. */
1310
- const refuseDryRunWrite = (): void => {
1311
- if (isDryRun()) {
1312
- throw new DryRunWriteIsolationError(
1313
- `Driver-backed store "${ref}" cannot isolate writes during dry-run; dry-run refused rather than risk a double-write.`,
1314
- );
1315
- }
1316
- };
1317
- /**
1318
- * Gate a table-scoped SQL operation. Prefers the precise `sql:<table>`
1319
- * ref (matches what the compiler's AST inference derives from the same
1320
- * call site — {@link "../manifest/sql-resource.ts"}); falls back to the
1321
- * store-level ref when the table ref isn't declared — every flow that
1322
- * hand-declared the older `effects: { writes: ["sql:<store>"] }`
1323
- * convention (every existing template, `upsert-app.test.ts`, …) must
1324
- * keep working unchanged. Ledger / journal record whichever ref the
1325
- * capability check actually matched, not always the coarser one.
1326
- *
1327
- * @param kind - Effect kind
1328
- * @param table - Table argument passed to a `SqlStoreHandle` method
1329
- * @param body - Work to run under the gate
1330
- */
1331
- const gatedTable = <T>(
1332
- kind: Parameters<CapabilityToken["assert"]>[0],
1333
- table: unknown,
1334
- body: () => T | Promise<T>,
1335
- ): Promise<T> => {
1336
- const externalOf = (): EffectExternal | undefined => cached?.external;
1337
- const name = schemaTableName(table);
1338
- if (name !== undefined) {
1339
- const perTable = sqlTableRef(name);
1340
- if (perTable !== ref && capability.allows(kind, perTable)) {
1341
- return gated(kind, perTable, body, externalOf);
1342
- }
1343
- }
1344
- return gated(kind, ref, body, externalOf);
1345
- };
1346
-
1347
- return {
1348
- ref,
1349
- get routedRole() {
1350
- return cached?.routedRole ?? "primary";
1351
- },
1352
- get driverId() {
1353
- return cached?.driverId ?? "memory";
1354
- },
1355
- select: ((columns?: unknown) => {
1356
- return {
1357
- from(table: unknown) {
1358
- const run = (plan: {
1359
- where?: unknown;
1360
- orders?: readonly unknown[];
1361
- limit?: number;
1362
- offset?: number;
1363
- }): Promise<SqlRow[]> =>
1364
- gatedTable("read", table, async () => {
1365
- const h = await ensure();
1366
- const from = h.select(columns).from(table) as SelectFromBuilder;
1367
- const filtered = plan.where === undefined ? from : from.where(plan.where);
1368
- const ordered =
1369
- plan.orders === undefined ? filtered : filtered.orderBy(...plan.orders);
1370
- if (plan.offset !== undefined) return ordered.offset(plan.offset);
1371
- return plan.limit === undefined ? ordered : ordered.limit(plan.limit);
1372
- });
1373
-
1374
- const tail = (plan: {
1375
- where?: unknown;
1376
- orders?: readonly unknown[];
1377
- }): SelectOrderBuilder => ({
1378
- limit(n) {
1379
- return run({ ...plan, limit: n });
1380
- },
1381
- offset(n) {
1382
- return run({ ...plan, offset: n });
1383
- },
1384
- then(onfulfilled, onrejected) {
1385
- return run(plan).then(onfulfilled, onrejected);
1386
- },
1387
- });
1388
-
1389
- return {
1390
- where(where: unknown) {
1391
- return {
1392
- ...tail({ where }),
1393
- orderBy: (...orders: readonly unknown[]) => tail({ where, orders }),
1394
- };
1395
- },
1396
- orderBy: (...orders: readonly unknown[]) => tail({ orders }),
1397
- limit(n: number) {
1398
- return run({ limit: n });
1399
- },
1400
- offset(n: number) {
1401
- return run({ offset: n });
1402
- },
1403
- then(
1404
- onfulfilled: (value: SqlRow[]) => unknown,
1405
- onrejected?: (reason: unknown) => unknown,
1406
- ) {
1407
- return run({}).then(onfulfilled, onrejected);
1408
- },
1409
- };
1410
- },
1411
- };
1412
- }) as SqlStoreHandle["select"],
1413
- insert(table) {
1414
- return {
1415
- values(row) {
1416
- const runExecute = () =>
1417
- gatedTable("write", table, async () => {
1418
- refuseDryRunWrite();
1419
- const h = await ensure();
1420
- await h.insert(table).values(row).execute();
1421
- });
1422
- return {
1423
- returning() {
1424
- return gatedTable("write", table, async () => {
1425
- refuseDryRunWrite();
1426
- const h = await ensure();
1427
- return h.insert(table).values(row).returning();
1428
- });
1429
- },
1430
- execute: runExecute,
1431
- then(onfulfilled, onrejected) {
1432
- return runExecute().then(onfulfilled, onrejected);
1433
- },
1434
- };
1435
- },
1436
- };
1437
- },
1438
- update(table) {
1439
- return {
1440
- set(row) {
1441
- return {
1442
- where(where) {
1443
- return gatedTable("write", table, async () => {
1444
- refuseDryRunWrite();
1445
- const h = await ensure();
1446
- return h.update(table).set(row).where(where);
1447
- });
1448
- },
1449
- };
1450
- },
1451
- };
1452
- },
1453
- findById(table, id) {
1454
- return gatedTable("read", table, async () => {
1455
- const h = await ensure();
1456
- return h.findById(table, id);
1457
- });
1458
- },
1459
- delete(table: Parameters<SqlStoreHandle["delete"]>[0], id?: string) {
1460
- if (id !== undefined) {
1461
- return gatedTable("write", table, async () => {
1462
- refuseDryRunWrite();
1463
- const h = await ensure();
1464
- return h.delete(table, id);
1465
- });
1466
- }
1467
- return {
1468
- where(where: unknown) {
1469
- return gatedTable("write", table, async () => {
1470
- refuseDryRunWrite();
1471
- const h = await ensure();
1472
- return h.delete(table).where(where);
1473
- });
1474
- },
1475
- };
1476
- },
1477
- exists(table, idOrWhere) {
1478
- return gatedTable("read", table, async () => {
1479
- const h = await ensure();
1480
- return h.exists(table, idOrWhere);
1481
- });
1482
- },
1483
- upsert(table, matchOn, values, upsertOptions) {
1484
- return gatedTable("write", table, async () => {
1485
- refuseDryRunWrite();
1486
- const h = await ensure();
1487
- return h.upsert(table, matchOn, values, upsertOptions);
1488
- });
1489
- },
1490
- increment(table, id, column, by) {
1491
- return gatedTable("write", table, async () => {
1492
- refuseDryRunWrite();
1493
- const h = await ensure();
1494
- return h.increment(table, id, column, by);
1495
- });
1496
- },
1497
- raw(sql, params) {
1498
- return gated("read", ref, async () => {
1499
- const h = await ensure();
1500
- return h.raw(sql, params);
1501
- });
1502
- },
1503
- count(table, where) {
1504
- return gatedTable("read", table, async () => {
1505
- const h = await ensure();
1506
- return h.count(table, where);
1507
- });
1508
- },
1509
- page(table, pageOptions) {
1510
- return gatedTable("read", table, async () => {
1511
- const h = await ensure();
1512
- return h.page(table, pageOptions);
1513
- });
1514
- },
1515
- search(table, searchOptions) {
1516
- return gatedTable("read", table, async () => {
1517
- const h = await ensure();
1518
- const result = await h.search(table, searchOptions);
1519
- // Optional rerank via fx.ask — only when explicitly requested.
1520
- if (
1521
- searchOptions.rerank &&
1522
- typeof searchOptions.rerank === "object" &&
1523
- searchOptions.rerank.model
1524
- ) {
1525
- const model = searchOptions.rerank.model;
1526
- const pk = "id";
1527
- const docs = result.data.map((row) => ({
1528
- id: String(row[pk] ?? ""),
1529
- text: Object.values(row)
1530
- .filter((v) => typeof v === "string")
1531
- .join("\n"),
1532
- score: 0,
1533
- }));
1534
- const out = (await fx.ask(model, {
1535
- query: searchOptions.query,
1536
- docs,
1537
- })) as { rankedIds?: string[] };
1538
- if (out.rankedIds && out.rankedIds.length > 0) {
1539
- const byId = new Map(result.data.map((r) => [String(r[pk] ?? ""), r]));
1540
- return {
1541
- ...result,
1542
- data: out.rankedIds
1543
- .map((id) => byId.get(id))
1544
- .filter((r): r is NonNullable<typeof r> => r !== undefined),
1545
- };
1546
- }
1547
- }
1548
- return result;
1549
- });
1550
- },
1551
- ensureTable(table) {
1552
- return gatedTable("write", table, async () => {
1553
- refuseDryRunWrite();
1554
- const h = await ensure();
1555
- return h.ensureTable(table);
1556
- });
1557
- },
1558
- } as SqlStoreHandle;
1559
- }
1560
-
1561
1266
  function loadFxTenantStore(): {
1562
1267
  kv: (
1563
1268
  mode: "in" | "out",
@@ -1617,9 +1322,12 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1617
1322
  };
1618
1323
 
1619
1324
  if (decl.facet === "sql") {
1620
- return gatedSqlHandle(decl, async () => {
1621
- const h = await open();
1622
- return h as SqlStoreHandle;
1325
+ return loadFxSql().createGatedSqlHandle({
1326
+ decl,
1327
+ open: async () => (await open()) as SqlStoreHandle,
1328
+ gated,
1329
+ capability,
1330
+ ask: (model, input) => fx.ask(model, input),
1623
1331
  });
1624
1332
  }
1625
1333
 
@@ -2292,16 +2000,16 @@ export function createFxContext(options: CreateFxOptions): FxContext {
2292
2000
  return rlsInvokeContext().rls;
2293
2001
  },
2294
2002
  all(thunks) {
2295
- return fxAll(thunks);
2003
+ return loadConcurrency().fxAll(thunks);
2296
2004
  },
2297
2005
  race(thunks) {
2298
- return fxRace(thunks);
2006
+ return loadConcurrency().fxRace(thunks);
2299
2007
  },
2300
2008
  retry(fn, opts) {
2301
- return fxRetry(fn, opts);
2009
+ return loadConcurrency().fxRetry(fn, opts);
2302
2010
  },
2303
2011
  using(acquire, release, use) {
2304
- return fxUsing(acquire, release, use);
2012
+ return loadConcurrency().fxUsing(acquire, release, use);
2305
2013
  },
2306
2014
  };
2307
2015
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `fx.json` carriers — brand and guards, with no `fx` / Store graph.
3
+ *
4
+ * `project-out` and the HTTP encoder need these on the cold-start path.
5
+ * Importing them from `fx.ts` would evaluate the Store barrel (Zod, hybrid
6
+ * search) before the server listens.
7
+ */
8
+
9
+ /** Brand for {@link JsonResult} (kept internal — flows never construct it). */
10
+ export const jsonResultBrand: unique symbol = Symbol.for("oke.json");
11
+
12
+ /** Carrier from `fx.json` — status + body read by the response encoder. */
13
+ export interface JsonResult<T = unknown> {
14
+ readonly [jsonResultBrand]: true;
15
+ readonly status: number;
16
+ readonly value?: T;
17
+ readonly meta?: Record<string, unknown>;
18
+ readonly kind?: undefined;
19
+ }
20
+
21
+ /** SSE carrier from `fx.json.stream` / `fx.live`. */
22
+ export interface JsonStreamResult {
23
+ readonly [jsonResultBrand]: true;
24
+ readonly kind: "stream";
25
+ readonly status: 200;
26
+ readonly chunks: AsyncIterable<unknown>;
27
+ /** Awaited before the 200 SSE body; throws OKE1210 on a missing resume cursor. */
28
+ ready?: () => Promise<void>;
29
+ /** Set by the kernel to commit journal / Runs after the stream settles. */
30
+ finalize?: () => Promise<void>;
31
+ }
32
+
33
+ /**
34
+ * True when `value` is an `fx.json` envelope carrier.
35
+ *
36
+ * @param value - Unknown handler output
37
+ */
38
+ export function isJsonResult(value: unknown): value is JsonResult {
39
+ return (
40
+ typeof value === "object" &&
41
+ value !== null &&
42
+ (value as JsonResult)[jsonResultBrand] === true &&
43
+ (value as JsonStreamResult).kind !== "stream"
44
+ );
45
+ }
46
+
47
+ /**
48
+ * True when `value` is an SSE stream carrier from `fx.json.stream`.
49
+ *
50
+ * @param value - Unknown handler output
51
+ */
52
+ export function isJsonStreamResult(value: unknown): value is JsonStreamResult {
53
+ return (
54
+ typeof value === "object" &&
55
+ value !== null &&
56
+ (value as JsonStreamResult)[jsonResultBrand] === true &&
57
+ (value as JsonStreamResult).kind === "stream"
58
+ );
59
+ }
@@ -12,7 +12,12 @@
12
12
 
13
13
  import type { SchemaInput } from "../validation/standard-schema.ts";
14
14
  import { validate } from "../validation/standard-schema.ts";
15
- import { isJsonResult, isJsonStreamResult, jsonResultBrand, type JsonResult } from "./fx.ts";
15
+ import {
16
+ isJsonResult,
17
+ isJsonStreamResult,
18
+ jsonResultBrand,
19
+ type JsonResult,
20
+ } from "./json-result.ts";
16
21
 
17
22
  /**
18
23
  * Project `output` through `schema` when the exposure declared `out`.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `okid({ … })` options path — sortable, prefix, and alphabet toggles.
3
+ *
4
+ * Kept off the kernel edge profile. `okid()` and `okid(length)` never load it.
5
+ */
6
+
7
+ import {
8
+ assertLength,
9
+ OKID_ALPHABET,
10
+ OKID_DEFAULT_LENGTH,
11
+ OKID_LOOKALIKE_CHARS,
12
+ OKID_MAX_PREFIX_LENGTH,
13
+ OKID_MIN_LENGTH,
14
+ OKID_SORTABLE_ALPHABET,
15
+ OKID_SORTABLE_MIN_LENGTH,
16
+ type OkidOptions,
17
+ } from "./okid-shared.ts";
18
+
19
+ /** Character groups addressable through {@link OkidOptions} toggles. */
20
+ const GROUPS = {
21
+ numbers: "0123456789",
22
+ lowercase: "abcdefghijklmnopqrstuvwxyz",
23
+ uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
24
+ symbols: "-_",
25
+ } as const;
26
+
27
+ /** Resolved alphabet + encoding metadata for one options combination. */
28
+ interface ResolvedAlphabet {
29
+ readonly chars: string;
30
+ readonly size: number;
31
+ /** Bitmask covering `size` values (`size` is always a power of two here). */
32
+ readonly mask: number;
33
+ }
34
+
35
+ /** Memoized resolutions keyed by the toggle bitmask (32 combinations max). */
36
+ const ALPHABET_CACHE = new Map<number, ResolvedAlphabet>();
37
+
38
+ /**
39
+ * Resolve a toggle combination to an alphabet and rejection-sampling mask.
40
+ *
41
+ * @param numbers - Include `0-9`
42
+ * @param lowercase - Include `a-z`
43
+ * @param uppercase - Include `A-Z`
44
+ * @param symbols - Include `-` and `_`
45
+ * @param lookAlikes - Include visually confusable characters
46
+ */
47
+ function resolveAlphabet(
48
+ numbers: boolean,
49
+ lowercase: boolean,
50
+ uppercase: boolean,
51
+ symbols: boolean,
52
+ lookAlikes: boolean,
53
+ ): ResolvedAlphabet {
54
+ const key =
55
+ (numbers ? 1 : 0) |
56
+ (lowercase ? 2 : 0) |
57
+ (uppercase ? 4 : 0) |
58
+ (symbols ? 8 : 0) |
59
+ (lookAlikes ? 0 : 16);
60
+ const cached = ALPHABET_CACHE.get(key);
61
+ if (cached) return cached;
62
+
63
+ let chars = "";
64
+ if (numbers) chars += GROUPS.numbers;
65
+ if (lowercase) chars += GROUPS.lowercase;
66
+ if (uppercase) chars += GROUPS.uppercase;
67
+ if (symbols) chars += GROUPS.symbols;
68
+ if (!chars) {
69
+ throw new RangeError("okid: alphabet is empty — enable at least one character group");
70
+ }
71
+ if (!lookAlikes) {
72
+ chars = [...chars].filter((c) => !OKID_LOOKALIKE_CHARS.includes(c)).join("");
73
+ }
74
+
75
+ // Round up to a power of two for mask-based rejection sampling: bytes below
76
+ // `size` map uniformly, bytes above are discarded and re-drawn — unbiased at
77
+ // every alphabet size, unlike naive modulo.
78
+ const rawSize = chars.length;
79
+ const size = 1 << Math.ceil(Math.log2(rawSize));
80
+ const resolved: ResolvedAlphabet = { chars, size: rawSize, mask: size - 1 };
81
+ ALPHABET_CACHE.set(key, resolved);
82
+ return resolved;
83
+ }
84
+
85
+ /**
86
+ * Encode one random byte stream into `length` characters of `alphabet`.
87
+ *
88
+ * @param alphabet - Resolved charset
89
+ * @param length - Output length
90
+ */
91
+ function encodeAlphabet(alphabet: ResolvedAlphabet, length: number): string {
92
+ const { chars, size, mask } = alphabet;
93
+ const bytes = new Uint8Array(length + Math.ceil(length >> 2));
94
+ crypto.getRandomValues(bytes.subarray(0, length));
95
+ let out = "";
96
+ let i = 0;
97
+ while (out.length < length && i < bytes.length) {
98
+ const byte = bytes[i++]!;
99
+ if ((byte & mask) < size) out += chars[byte & mask];
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Pack epoch-ms into exactly 8 codepoint-ordered characters (48 bits).
106
+ *
107
+ * @param nowMs - Epoch milliseconds
108
+ */
109
+ function encodeTimestamp(nowMs: number): string {
110
+ let t = nowMs % 2 ** 48;
111
+ let out = "";
112
+ for (let i = 0; i < 8; i++) {
113
+ out = OKID_SORTABLE_ALPHABET[t & 63]! + out;
114
+ t = Math.floor(t / 64);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Assert a semantic prefix is within bounds and stays on the URL-safe alphabet.
121
+ *
122
+ * @param prefix - Caller-supplied label
123
+ */
124
+ function assertPrefix(prefix: string): void {
125
+ if (prefix.length > OKID_MAX_PREFIX_LENGTH) {
126
+ throw new RangeError(
127
+ `okid: prefix length ${prefix.length} exceeds max ${OKID_MAX_PREFIX_LENGTH}`,
128
+ );
129
+ }
130
+ for (const char of prefix) {
131
+ if (!OKID_ALPHABET.includes(char)) {
132
+ throw new RangeError(
133
+ `okid: prefix contains invalid character ${JSON.stringify(char)} — use characters from OKID_ALPHABET`,
134
+ );
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Generate an id from an options object.
141
+ *
142
+ * @param options - Length, prefix, sortable, and alphabet toggles
143
+ */
144
+ export function okidWithOptions(options: OkidOptions): string {
145
+ const {
146
+ length = OKID_DEFAULT_LENGTH,
147
+ prefix = "",
148
+ sortable = false,
149
+ lowercase = true,
150
+ uppercase = true,
151
+ numbers = true,
152
+ symbols = true,
153
+ lookAlikes = true,
154
+ } = options;
155
+
156
+ if (prefix) assertPrefix(prefix);
157
+
158
+ let body: string;
159
+ if (sortable) {
160
+ assertLength(length, OKID_SORTABLE_MIN_LENGTH, "length");
161
+ // Time ordering requires lexicographic encoding, which requires the full
162
+ // codepoint-ordered alphabet — partial subsets cannot preserve both the
163
+ // caller's charset choice AND cross-ms ordering, so toggles are ignored.
164
+ const alphabet = resolveAlphabet(true, true, true, true, true);
165
+ body = encodeTimestamp(Date.now()) + encodeAlphabet(alphabet, length - 8);
166
+ } else {
167
+ assertLength(length, OKID_MIN_LENGTH, "length");
168
+ body = encodeAlphabet(
169
+ resolveAlphabet(numbers, lowercase, uppercase, symbols, lookAlikes),
170
+ length,
171
+ );
172
+ }
173
+
174
+ return prefix ? prefix + body : body;
175
+ }