okengine 0.10.2 → 0.11.0
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/package.json +76 -10
- package/site/content/docs/ai/mcp.mdx +0 -1
- package/site/content/docs/deployment/docker-swarm.mdx +48 -111
- package/site/content/docs/deployment/docker.mdx +43 -36
- package/site/content/docs/deployment/index.mdx +9 -9
- package/site/content/docs/deployment/kubernetes.mdx +4 -4
- package/site/content/docs/elements/ai.mdx +7 -13
- package/site/content/docs/elements/channel.mdx +9 -13
- package/site/content/docs/elements/clock.mdx +8 -14
- package/site/content/docs/elements/flow.mdx +46 -54
- package/site/content/docs/elements/gate.mdx +6 -11
- package/site/content/docs/elements/signal.mdx +2 -8
- package/site/content/docs/elements/store.mdx +79 -93
- package/site/content/docs/elements/vault.mdx +89 -76
- package/site/content/docs/get-started/basic-usage.mdx +8 -14
- package/site/content/docs/get-started/installation.mdx +36 -36
- package/site/content/docs/get-started/introduction.mdx +11 -0
- package/site/content/docs/get-started/why.mdx +3 -4
- package/site/content/docs/index.mdx +0 -1
- package/site/content/docs/meta.json +0 -1
- package/site/content/docs/plugins/anonymous.mdx +1 -1
- package/site/content/docs/plugins/magic-link.mdx +11 -11
- package/site/content/docs/plugins/otp.mdx +2 -2
- package/site/content/docs/plugins/passkey.mdx +1 -1
- package/site/content/docs/plugins/two-factor.mdx +1 -1
- package/site/content/docs/plugins/username.mdx +1 -1
- package/site/content/docs/recipes/caddy.mdx +1 -1
- package/site/content/docs/recipes/cockroachdb.mdx +2 -2
- package/site/content/docs/recipes/dragonfly.mdx +1 -1
- package/site/content/docs/recipes/index.mdx +1 -2
- package/site/content/docs/recipes/llama-cpp.mdx +21 -23
- package/site/content/docs/recipes/mailpit.mdx +11 -11
- package/site/content/docs/recipes/meilisearch.mdx +10 -10
- package/site/content/docs/recipes/meta.json +0 -1
- package/site/content/docs/recipes/ollama.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +12 -9
- package/site/content/docs/recipes/postgres.mdx +3 -3
- package/site/content/docs/recipes/redis.mdx +2 -2
- package/site/content/docs/recipes/rustfs.mdx +10 -7
- package/site/content/docs/recipes/sglang.mdx +1 -1
- package/site/content/docs/recipes/supabase-docker.mdx +1 -1
- package/site/content/docs/recipes/timescale.mdx +2 -2
- package/site/content/docs/recipes/valkey.mdx +1 -1
- package/site/content/docs/recipes/vllm.mdx +1 -1
- package/site/content/docs/recipes/yugabytedb.mdx +2 -2
- package/site/content/docs/reference/cli.md +61 -38
- package/site/content/docs/reference/configuration.mdx +145 -70
- package/site/content/docs/reference/environment-variables.mdx +36 -28
- package/site/content/docs/reference/errors.mdx +28 -29
- package/site/content/docs/reference/fx.mdx +14 -7
- package/site/content/docs/reference/i18n.mdx +17 -6
- package/site/content/docs/reference/index.mdx +5 -0
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/migrating-environments.mdx +158 -0
- package/site/content/docs/reference/plugins.mdx +2 -2
- package/src/auth/gate-auth.test.ts +12 -10
- package/src/cli/ai-setup/ai-setup.test.ts +30 -16
- package/src/cli/ai-setup/apply.ts +111 -15
- package/src/cli/ai-setup/catalog.ts +3 -3
- package/src/cli/ai-setup/index.ts +1 -1
- package/src/cli/ai-setup/prompts.ts +1 -1
- package/src/cli/ai-setup/recommend.test.ts +1 -1
- package/src/cli/ask-seed.test.ts +3 -3
- package/src/cli/build.ts +8 -9
- package/src/cli/db-auto-push.test.ts +6 -3
- package/src/cli/db-auto-push.ts +7 -5
- package/src/cli/db-seed.ts +7 -8
- package/src/cli/db.test.ts +70 -71
- package/src/cli/db.ts +86 -12
- package/src/cli/dev-controller.test.ts +35 -0
- package/src/cli/dev-controller.ts +298 -0
- package/src/cli/dev-controls.ts +11 -65
- package/src/cli/dev-db-push.test.ts +47 -15
- package/src/cli/dev-schema-sync.test.ts +11 -11
- package/src/cli/dev-schema-sync.ts +10 -10
- package/src/cli/dev-session-lock.test.ts +106 -0
- package/src/cli/dev-session-lock.ts +209 -0
- package/src/cli/dev.test.ts +246 -114
- package/src/cli/dev.ts +153 -205
- package/src/cli/doc-staleness.test.ts +3 -6
- package/src/cli/docker-clean.test.ts +33 -1
- package/src/cli/docker-clean.ts +28 -33
- package/src/cli/docker-cli.test.ts +12 -11
- package/src/cli/docker.ts +95 -20
- package/src/cli/doctor.test.ts +40 -0
- package/src/cli/doctor.ts +142 -9
- package/src/cli/drizzle-env.test.ts +11 -13
- package/src/cli/drizzle-env.ts +18 -11
- package/src/cli/ensure-drizzle-config.ts +11 -14
- package/src/cli/hero-meta.test.ts +15 -15
- package/src/cli/hero-meta.ts +10 -9
- package/src/cli/index.ts +36 -6
- package/src/cli/load-config.images.test.ts +44 -10
- package/src/cli/load-config.source.test.ts +50 -0
- package/src/cli/load-config.ts +35 -29
- package/src/cli/meilisearch-local.ts +1 -2
- package/src/cli/mode.ts +7 -58
- package/src/cli/registry.ts +175 -19
- package/src/cli/resolve-dev-sql-env.test.ts +8 -16
- package/src/cli/resolve-dev-sql-env.ts +13 -15
- package/src/cli/safe-defaults.test.ts +29 -11
- package/src/cli/schema.ts +65 -16
- package/src/cli/test.ts +58 -0
- package/src/cli/tui/App.tsx +252 -0
- package/src/cli/tui/DevLive.tsx +116 -0
- package/src/cli/tui/components/ConfirmDialog.tsx +48 -0
- package/src/cli/tui/components/HeaderBar.tsx +67 -0
- package/src/cli/tui/components/LogStream.tsx +47 -0
- package/src/cli/tui/components/PanelCard.tsx +39 -0
- package/src/cli/tui/components/SelectRow.tsx +37 -0
- package/src/cli/tui/components/SlashPalette.tsx +172 -0
- package/src/cli/tui/components/Spinner.tsx +32 -0
- package/src/cli/tui/components/StatusBar.tsx +51 -0
- package/src/cli/tui/components/StatusDot.tsx +37 -0
- package/src/cli/tui/components/TabBar.tsx +49 -0
- package/src/cli/tui/components/Wordmark.tsx +43 -0
- package/src/cli/tui/format.ts +38 -0
- package/src/cli/tui/keys.test.ts +18 -0
- package/src/cli/tui/keys.ts +26 -0
- package/src/cli/tui/launch.test.ts +18 -0
- package/src/cli/tui/launch.ts +57 -0
- package/src/cli/tui/panels/Dashboard.tsx +201 -0
- package/src/cli/tui/panels/Database.tsx +198 -0
- package/src/cli/tui/panels/Dev.tsx +257 -0
- package/src/cli/tui/panels/Docker.tsx +357 -0
- package/src/cli/tui/panels/Navigator.test.ts +22 -0
- package/src/cli/tui/panels/Navigator.tsx +213 -0
- package/src/cli/tui/slash-catalog.test.ts +31 -0
- package/src/cli/tui/slash-catalog.ts +184 -0
- package/src/cli/tui/slash-run.ts +54 -0
- package/src/cli/tui/state/ports.ts +95 -0
- package/src/cli/tui/theme.ts +24 -0
- package/src/cli/vault-cmd.test.ts +402 -0
- package/src/cli/vault-cmd.ts +727 -69
- package/src/cli/vault-secure-input.test.ts +56 -0
- package/src/cli/vault-secure-input.ts +124 -0
- package/src/compiler/effects-infer.ts +7 -0
- package/src/compiler/extract.ts +12 -0
- package/src/compiler/fixtures/skyport/oke.config.ts +7 -5
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -1
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/generate-adopt.ts +27 -1
- package/src/compiler-entry.ts +7 -0
- package/src/config/define-config.test.ts +111 -47
- package/src/config/driver-defaults.test.ts +115 -0
- package/src/config/driver-defaults.ts +156 -0
- package/src/config/index.ts +246 -131
- package/src/config/resolve-driver.test.ts +14 -19
- package/src/console/server/app.ts +4 -2
- package/src/console/server/console.test.ts +58 -1
- package/src/console/server/flows.ts +22 -2
- package/src/console/server/index.ts +3 -0
- package/src/console/server/operator-db.test.ts +55 -13
- package/src/console/server/operator-db.ts +305 -263
- package/src/console/server/serve.ts +16 -7
- package/src/console/server/state.ts +31 -9
- package/src/console/server/vault.test.ts +120 -5
- package/src/console/server/vault.ts +208 -12
- package/src/console/ui/dist/assets/{index-D-9KFce5.js → index--Y0Uaz-m.js} +2 -2
- package/src/console/ui/dist/assets/panel-vault-BkZdJPX8.js +1 -0
- package/src/console/ui/dist/assets/style-C3L349iZ.css +3 -0
- package/src/console/ui/dist/index.html +2 -2
- package/src/console/ui/shell/client.ts +16 -0
- package/src/console/ui/shell/panels/vault/VaultPanel.tsx +54 -1
- package/src/console/ui/vault/VaultA11yView.tsx +33 -1
- package/src/console/ui/vault/backend.test.ts +75 -0
- package/src/console/ui/vault/backend.ts +117 -0
- package/src/console/ui/vault/fixture.ts +21 -2
- package/src/console/ui/vault/index.ts +11 -1
- package/src/console/ui/vault/types.ts +26 -0
- package/src/docker/compose.ts +164 -61
- package/src/docker/derive.ts +106 -12
- package/src/docker/docker.test.ts +156 -110
- package/src/docker/helpers.ts +98 -0
- package/src/docker/images-config.test.ts +163 -0
- package/src/docker/index.ts +30 -5
- package/src/docker/recipes/index.ts +0 -3
- package/src/docker/recipes/llama-cpp.ts +4 -4
- package/src/docker/recipes/pgdog.ts +9 -2
- package/src/docker/resources.test.ts +71 -0
- package/src/docker/resources.ts +176 -0
- package/src/docker/stack-id.test.ts +11 -4
- package/src/docker/stack-id.ts +42 -32
- package/src/docker/stack.integration.test.ts +4 -15
- package/src/docker/types.ts +35 -3
- package/src/drivers/ai-ollama.integration.test.ts +2 -2
- package/src/drivers/conformance.test.ts +11 -12
- package/src/drivers/drizzle-dialect.test.ts +0 -4
- package/src/drivers/drizzle-dialect.ts +7 -13
- package/src/drivers/index.ts +24 -7
- package/src/drivers/journal-postgres.test.ts +3 -1
- package/src/drivers/journal-postgres.ts +4 -2
- package/src/drivers/meilisearch.ts +1 -1
- package/src/drivers/pglite.ts +28 -5
- package/src/drivers/types.ts +9 -11
- package/src/drivers/vault-aws-secrets-manager.test.ts +159 -0
- package/src/drivers/vault-aws-secrets-manager.ts +333 -0
- package/src/drivers/vault-builtin.test.ts +110 -0
- package/src/drivers/vault-builtin.ts +184 -0
- package/src/drivers/vault-driver-removal.test.ts +13 -0
- package/src/drivers/vault-managed.test.ts +95 -0
- package/src/drivers/vault-managed.ts +101 -25
- package/src/drivers/vault-types.ts +25 -5
- package/src/elements/ai/runtime.ts +1 -1
- package/src/elements/clock/chaos.test.ts +4 -1
- package/src/elements/clock/durable.ts +57 -4
- package/src/elements/clock.test.ts +13 -14
- package/src/elements/gate/constants.ts +18 -0
- package/src/elements/gate.test.ts +1 -1
- package/src/elements/gate.ts +2 -2
- package/src/elements/store/domain-ddl.test.ts +5 -12
- package/src/elements/store/emit-drizzle.ts +44 -19
- package/src/elements/store/index-boot.test.ts +15 -116
- package/src/elements/store/load-plugin-tables.ts +1 -1
- package/src/elements/store/resource-list-docs.fixture.ts +12 -6
- package/src/elements/store/resource-list-docs.test.ts +64 -44
- package/src/elements/store/resource.test.ts +17 -10
- package/src/elements/store/runtime.ts +9 -12
- package/src/elements/store/schema-decl.test.ts +16 -32
- package/src/elements/store/seed.test.ts +1 -2
- package/src/elements/store/seed.ts +7 -8
- package/src/elements/store/sql-condition.test.ts +2 -2
- package/src/elements/store/sql-session.test.ts +8 -10
- package/src/elements/store/sql-session.ts +10 -2
- package/src/elements/store.test.ts +13 -13
- package/src/elements/vault/audit.ts +233 -0
- package/src/elements/vault/boot-chain.test.ts +78 -0
- package/src/elements/vault/boot-chain.ts +43 -39
- package/src/elements/vault/builtin-adapter.test.ts +479 -0
- package/src/elements/vault/builtin-adapter.ts +1184 -0
- package/src/elements/vault/crypto.test.ts +237 -0
- package/src/elements/vault/crypto.ts +431 -0
- package/src/elements/vault/declare.ts +164 -3
- package/src/elements/vault/errors.ts +56 -0
- package/src/elements/vault/kms-unseal.test.ts +76 -0
- package/src/elements/vault/kms-unseal.ts +181 -0
- package/src/elements/vault/path.ts +90 -0
- package/src/elements/vault/resilience.test.ts +102 -0
- package/src/elements/vault/resilience.ts +107 -0
- package/src/elements/vault/runtime.ts +36 -4
- package/src/elements/vault/security-checklist.test.ts +130 -0
- package/src/elements/vault/storage.ts +448 -0
- package/src/elements/vault/types.ts +288 -0
- package/src/elements/vault/unseal.ts +150 -0
- package/src/elements/vault.test.ts +72 -4
- package/src/elements/vault.ts +91 -2
- package/src/full.ts +248 -0
- package/src/http.ts +41 -0
- package/src/i18n/interpolate-simple.ts +37 -0
- package/src/i18n/messages.ts +51 -4
- package/src/i18n-entry.ts +7 -0
- package/src/index.ts +35 -145
- package/src/journal-entry.ts +20 -0
- package/src/kernel/adopt-barrel-fresh.test.ts +25 -10
- package/src/kernel/app.ts +40 -42
- package/src/kernel/auto-registry.test.ts +2 -2
- package/src/kernel/boot-bind/ai.test.ts +5 -5
- package/src/kernel/boot-bind/ai.ts +2 -1
- package/src/kernel/boot-bind/channel.test.ts +6 -6
- package/src/kernel/boot-bind/channel.ts +5 -4
- package/src/kernel/boot-bind/clock.ts +5 -5
- package/src/kernel/boot-bind/gate.ts +6 -7
- package/src/kernel/boot-bind/honor-config.test.ts +42 -42
- package/src/kernel/boot-bind/journal.ts +5 -5
- package/src/kernel/boot-bind/signal.ts +3 -1
- package/src/kernel/boot-bind/store.test.ts +55 -37
- package/src/kernel/boot-bind/store.ts +24 -34
- package/src/kernel/boot-bind/vault.ts +19 -4
- package/src/kernel/boot.test.ts +12 -4
- package/src/kernel/boot.ts +31 -19
- package/src/kernel/budget-entry.ts +1 -1
- package/src/kernel/compensate.test.ts +288 -1
- package/src/kernel/compensate.ts +126 -0
- package/src/kernel/concurrency.test.ts +10 -9
- package/src/kernel/effects-stamping.test.ts +27 -2
- package/src/kernel/element-registries.ts +5 -3
- package/src/kernel/flow.ts +4 -2
- package/src/kernel/fx.test.ts +119 -3
- package/src/kernel/fx.ts +215 -40
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal.ts +130 -7
- package/src/kernel/pipeline.test.ts +20 -6
- package/src/kernel/redacted.test.ts +35 -0
- package/src/kernel/redacted.ts +24 -2
- package/src/kernel/router/create-default.ts +16 -0
- package/src/kernel/router/create-edge.ts +18 -0
- package/src/kernel/router/create.ts +23 -0
- package/src/kernel/router/index.ts +23 -0
- package/src/kernel/router/linear.ts +88 -0
- package/src/kernel/router/regexp.ts +188 -0
- package/src/kernel/router/smart.ts +120 -0
- package/src/kernel/router/trie.ts +108 -0
- package/src/kernel/router/types.ts +56 -0
- package/src/kernel/router.ts +18 -532
- package/src/kernel-entry.ts +43 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/plugins/config-source.test.ts +7 -7
- package/src/release/build-lib.ts +58 -0
- package/src/release/exports.test.ts +4 -2
- package/src/release/exports.ts +30 -4
- package/src/release/measure.ts +101 -22
- package/src/runs/duckdb.ts +7 -2
- package/src/shared/optional-peer.ts +41 -0
- package/src/term.test.ts +3 -44
- package/src/test/create-test-app.ts +12 -0
- package/src/test/provisions.integration.test.ts +1 -1
- package/src/test/reset-element-registries.ts +2 -0
- package/src/upgrade/codemods.test.ts +7 -20
- package/src/upgrade/codemods.ts +93 -60
- package/site/content/docs/console/access.mdx +0 -29
- package/site/content/docs/console/ai.mdx +0 -35
- package/site/content/docs/console/architecture.mdx +0 -35
- package/site/content/docs/console/channels.mdx +0 -37
- package/site/content/docs/console/clock.mdx +0 -51
- package/site/content/docs/console/flows.mdx +0 -31
- package/site/content/docs/console/gates.mdx +0 -73
- package/site/content/docs/console/index.mdx +0 -54
- package/site/content/docs/console/manifest-diff.mdx +0 -34
- package/site/content/docs/console/meta.json +0 -24
- package/site/content/docs/console/overview.mdx +0 -40
- package/site/content/docs/console/plugins.mdx +0 -41
- package/site/content/docs/console/privacy.mdx +0 -32
- package/site/content/docs/console/runs.mdx +0 -50
- package/site/content/docs/console/signals.mdx +0 -40
- package/site/content/docs/console/store.mdx +0 -32
- package/site/content/docs/console/tenancy.mdx +0 -32
- package/site/content/docs/console/traces.mdx +0 -38
- package/site/content/docs/console/vault.mdx +0 -41
- package/site/content/docs/recipes/openbao.mdx +0 -141
- package/src/cli/ask-dev-mode.ts +0 -40
- package/src/cli/dev-mode.test.ts +0 -45
- package/src/cli/dev-mode.ts +0 -78
- package/src/cli/openbao-bootstrap.test.ts +0 -150
- package/src/cli/openbao-bootstrap.ts +0 -321
- package/src/cli/openbao-restart.integration.test.ts +0 -145
- package/src/console/ui/dist/assets/panel-vault-Za1GyJfM.js +0 -1
- package/src/console/ui/dist/assets/style-C8MxEWPd.css +0 -3
- package/src/docker/recipes/openbao.ts +0 -47
- package/src/drivers/libsql.ts +0 -179
- package/src/drivers/sqlite.ts +0 -44
- package/src/drivers/vault-openbao.test.ts +0 -97
- package/src/drivers/vault-openbao.ts +0 -143
package/src/kernel/journal.ts
CHANGED
|
@@ -14,7 +14,49 @@ import { JournalSuspend } from "./journal-suspend.ts";
|
|
|
14
14
|
export { JournalSuspend, isJournalSuspend } from "./journal-suspend.ts";
|
|
15
15
|
|
|
16
16
|
/** Status of a durable run. */
|
|
17
|
-
export type JournalRunStatus = "running" | "sleeping" | "completed" | "failed";
|
|
17
|
+
export type JournalRunStatus = "running" | "sleeping" | "compensating" | "completed" | "failed";
|
|
18
|
+
|
|
19
|
+
/** Reserved prefix for compensation undo steps — never use for forward work. */
|
|
20
|
+
export const JOURNAL_UNDO_PREFIX = "undo:";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Options for {@link JournalSession.step}.
|
|
24
|
+
*
|
|
25
|
+
* @typeParam T - Step result type
|
|
26
|
+
*/
|
|
27
|
+
export interface JournalStepOptions<T> {
|
|
28
|
+
/**
|
|
29
|
+
* Durable undo for this step — registered on persist and replay.
|
|
30
|
+
* Invoked with the journaled value on terminal failure (LIFO).
|
|
31
|
+
*/
|
|
32
|
+
readonly undo?: (value: T) => unknown | Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One registered per-step undo frame (in-memory; closures re-bound on resume). */
|
|
36
|
+
export interface JournalUndoFrame {
|
|
37
|
+
/** Forward step name (without {@link JOURNAL_UNDO_PREFIX}). */
|
|
38
|
+
readonly name: string;
|
|
39
|
+
/** Journaled step value passed to {@link undo}. */
|
|
40
|
+
readonly value: unknown;
|
|
41
|
+
/** Undo body. */
|
|
42
|
+
readonly undo: (value: unknown) => unknown | Promise<unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Thrown during a compensating registration pass when `do` would execute
|
|
47
|
+
* new forward work — stops re-entry without continuing the happy path.
|
|
48
|
+
*/
|
|
49
|
+
export class JournalRegistrationComplete extends Error {
|
|
50
|
+
constructor() {
|
|
51
|
+
super("journal: registration pass complete");
|
|
52
|
+
this.name = "JournalRegistrationComplete";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Type guard for {@link JournalRegistrationComplete}. */
|
|
57
|
+
export function isJournalRegistrationComplete(err: unknown): err is JournalRegistrationComplete {
|
|
58
|
+
return err instanceof JournalRegistrationComplete;
|
|
59
|
+
}
|
|
18
60
|
|
|
19
61
|
/** A completed named step. */
|
|
20
62
|
export interface JournalStepEntry {
|
|
@@ -98,8 +140,8 @@ export interface JournalLeaseStore {
|
|
|
98
140
|
*/
|
|
99
141
|
claimDueSleep(instanceId: string, now: number, leaseMs: number): Promise<JournalRun | undefined>;
|
|
100
142
|
/**
|
|
101
|
-
* Boot-time orphan discovery: `running` / `sleeping`
|
|
102
|
-
* (crashed holder or never claimed). Rows are never deleted.
|
|
143
|
+
* Boot-time orphan discovery: `running` / `sleeping` / `compensating` runs
|
|
144
|
+
* with no live lease (crashed holder or never claimed). Rows are never deleted.
|
|
103
145
|
*
|
|
104
146
|
* @param now - Epoch-ms
|
|
105
147
|
*/
|
|
@@ -212,7 +254,11 @@ function leaseMethods(
|
|
|
212
254
|
async listOrphans(now) {
|
|
213
255
|
const map = await load();
|
|
214
256
|
return [...map.values()]
|
|
215
|
-
.filter(
|
|
257
|
+
.filter(
|
|
258
|
+
(r) =>
|
|
259
|
+
(r.status === "running" || r.status === "sleeping" || r.status === "compensating") &&
|
|
260
|
+
!hasLiveLease(r, now),
|
|
261
|
+
)
|
|
216
262
|
.map(cloneRun);
|
|
217
263
|
},
|
|
218
264
|
};
|
|
@@ -325,8 +371,9 @@ export interface JournalSession {
|
|
|
325
371
|
*
|
|
326
372
|
* @param name - Step name
|
|
327
373
|
* @param fn - Step body
|
|
374
|
+
* @param opts - Optional per-step undo registration
|
|
328
375
|
*/
|
|
329
|
-
step<T>(name: string, fn: () => T | Promise<T>): Promise<T>;
|
|
376
|
+
step<T>(name: string, fn: () => T | Promise<T>, opts?: JournalStepOptions<T>): Promise<T>;
|
|
330
377
|
/**
|
|
331
378
|
* Durable sleep — journals wake time; suspends until elapsed.
|
|
332
379
|
*
|
|
@@ -346,9 +393,25 @@ export interface JournalSession {
|
|
|
346
393
|
/**
|
|
347
394
|
* Rewind the replay cursor to the start of the entry list.
|
|
348
395
|
* Used by flow-level retry so a re-entered `do` replays completed steps
|
|
349
|
-
* instead of treating the cursor as past them.
|
|
396
|
+
* instead of treating the cursor as past them. Clears the in-memory undo stack
|
|
397
|
+
* so frames re-bind on the next walk.
|
|
350
398
|
*/
|
|
351
399
|
rewind(): void;
|
|
400
|
+
/** Registered per-step undos in persist/replay order (LIFO compensate). */
|
|
401
|
+
undoStack(): readonly JournalUndoFrame[];
|
|
402
|
+
/**
|
|
403
|
+
* Enter registration-only mode — replay + re-bind undos; new forward work
|
|
404
|
+
* throws {@link JournalRegistrationComplete}.
|
|
405
|
+
*/
|
|
406
|
+
beginRegistrationPass(): void;
|
|
407
|
+
/** Exit registration-only mode. */
|
|
408
|
+
endRegistrationPass(): void;
|
|
409
|
+
/**
|
|
410
|
+
* Allow appending `undo:*` steps (compensation phase only).
|
|
411
|
+
*
|
|
412
|
+
* @param allowed - Whether undo-prefix steps may append
|
|
413
|
+
*/
|
|
414
|
+
setUndoExecution(allowed: boolean): void;
|
|
352
415
|
/** Persist current run status / output. */
|
|
353
416
|
commit(
|
|
354
417
|
status: JournalRunStatus,
|
|
@@ -389,6 +452,9 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
389
452
|
/** Next entry index to consume on replay. */
|
|
390
453
|
let cursor = 0;
|
|
391
454
|
let leaseHeld = leased;
|
|
455
|
+
let registrationPass = false;
|
|
456
|
+
let undoExecution = false;
|
|
457
|
+
const undos: JournalUndoFrame[] = [];
|
|
392
458
|
|
|
393
459
|
async function persist(): Promise<void> {
|
|
394
460
|
run.updatedAt = now();
|
|
@@ -407,18 +473,53 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
407
473
|
delete run.leaseExpiresAt;
|
|
408
474
|
}
|
|
409
475
|
|
|
476
|
+
function registerUndo<T>(name: string, value: T, opts?: JournalStepOptions<T>): void {
|
|
477
|
+
if (!opts?.undo) return;
|
|
478
|
+
const undo = opts.undo as (value: unknown) => unknown | Promise<unknown>;
|
|
479
|
+
const idx = undos.findIndex((f) => f.name === name);
|
|
480
|
+
const frame: JournalUndoFrame = { name, value, undo };
|
|
481
|
+
if (idx >= 0) undos[idx] = frame;
|
|
482
|
+
else undos.push(frame);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function assertCanAppendStep(name: string): void {
|
|
486
|
+
if (registrationPass) {
|
|
487
|
+
throw new JournalRegistrationComplete();
|
|
488
|
+
}
|
|
489
|
+
const isUndoStep = name.startsWith(JOURNAL_UNDO_PREFIX);
|
|
490
|
+
if (isUndoStep && !undoExecution) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`journal: step name "${name}" uses reserved prefix "${JOURNAL_UNDO_PREFIX}"`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (!isUndoStep && run.entries.some((e) => e.kind === "step" && e.name === name)) {
|
|
496
|
+
throw new Error(`journal: duplicate step name "${name}"`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
410
500
|
return {
|
|
411
501
|
runId: run.id,
|
|
412
502
|
run,
|
|
413
|
-
async step<T>(
|
|
503
|
+
async step<T>(
|
|
504
|
+
name: string,
|
|
505
|
+
fn: () => T | Promise<T>,
|
|
506
|
+
opts?: JournalStepOptions<T>,
|
|
507
|
+
): Promise<T> {
|
|
508
|
+
if (name.startsWith(JOURNAL_UNDO_PREFIX) && opts?.undo) {
|
|
509
|
+
throw new Error("journal: undo steps cannot register nested undo");
|
|
510
|
+
}
|
|
414
511
|
// Prefer name match among remaining entries (resume after crash).
|
|
415
512
|
for (let i = cursor; i < run.entries.length; i++) {
|
|
416
513
|
const e = run.entries[i]!;
|
|
417
514
|
if (e.kind === "step" && e.name === name) {
|
|
418
515
|
cursor = i + 1;
|
|
516
|
+
if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
|
|
517
|
+
registerUndo(name, e.value as T, opts);
|
|
518
|
+
}
|
|
419
519
|
return e.value as T;
|
|
420
520
|
}
|
|
421
521
|
}
|
|
522
|
+
assertCanAppendStep(name);
|
|
422
523
|
const value = await fn();
|
|
423
524
|
const entry: JournalStepEntry = {
|
|
424
525
|
kind: "step",
|
|
@@ -429,6 +530,9 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
429
530
|
run.entries.push(entry);
|
|
430
531
|
cursor = run.entries.length;
|
|
431
532
|
await persist();
|
|
533
|
+
if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
|
|
534
|
+
registerUndo(name, value, opts);
|
|
535
|
+
}
|
|
432
536
|
return value;
|
|
433
537
|
},
|
|
434
538
|
async sleep(label, duration, parseMs) {
|
|
@@ -446,6 +550,9 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
446
550
|
return;
|
|
447
551
|
}
|
|
448
552
|
}
|
|
553
|
+
if (registrationPass) {
|
|
554
|
+
throw new JournalRegistrationComplete();
|
|
555
|
+
}
|
|
449
556
|
const wakeAt = now() + parseMs(duration);
|
|
450
557
|
const entry: JournalSleepEntry = {
|
|
451
558
|
kind: "sleep",
|
|
@@ -477,6 +584,9 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
477
584
|
return e.value as T;
|
|
478
585
|
}
|
|
479
586
|
}
|
|
587
|
+
if (registrationPass) {
|
|
588
|
+
throw new JournalRegistrationComplete();
|
|
589
|
+
}
|
|
480
590
|
const value = await execute();
|
|
481
591
|
const entry: JournalEffectEntry = {
|
|
482
592
|
kind: "effect",
|
|
@@ -492,6 +602,19 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
492
602
|
},
|
|
493
603
|
rewind() {
|
|
494
604
|
cursor = 0;
|
|
605
|
+
undos.length = 0;
|
|
606
|
+
},
|
|
607
|
+
undoStack() {
|
|
608
|
+
return undos;
|
|
609
|
+
},
|
|
610
|
+
beginRegistrationPass() {
|
|
611
|
+
registrationPass = true;
|
|
612
|
+
},
|
|
613
|
+
endRegistrationPass() {
|
|
614
|
+
registrationPass = false;
|
|
615
|
+
},
|
|
616
|
+
setUndoExecution(allowed) {
|
|
617
|
+
undoExecution = allowed;
|
|
495
618
|
},
|
|
496
619
|
async commit(status, patch) {
|
|
497
620
|
run.status = status;
|
|
@@ -212,10 +212,10 @@ describe("pipeline — Bearer cryptographic verification", () => {
|
|
|
212
212
|
auth: { secret: "hmac-secret-for-tests", sessions, http: false },
|
|
213
213
|
policies: [member],
|
|
214
214
|
},
|
|
215
|
-
env: "
|
|
215
|
+
env: "test",
|
|
216
216
|
startScheduler: false,
|
|
217
217
|
});
|
|
218
|
-
await app.boot({ env: "
|
|
218
|
+
await app.boot({ env: "test", gates: [member], startScheduler: false });
|
|
219
219
|
|
|
220
220
|
const res = await app.fetch(
|
|
221
221
|
new Request("http://localhost/secure", {
|
|
@@ -267,12 +267,12 @@ describe("pipeline — Bearer cryptographic verification", () => {
|
|
|
267
267
|
auth: { secret, sessions, now: () => nowMs, http: false },
|
|
268
268
|
policies: [member],
|
|
269
269
|
},
|
|
270
|
-
env: "
|
|
270
|
+
env: "test",
|
|
271
271
|
elements: { clock: clockRt },
|
|
272
272
|
startScheduler: false,
|
|
273
273
|
});
|
|
274
274
|
await app.boot({
|
|
275
|
-
env: "
|
|
275
|
+
env: "test",
|
|
276
276
|
gates: [member],
|
|
277
277
|
startScheduler: false,
|
|
278
278
|
elements: { clock: clockRt },
|
|
@@ -317,10 +317,24 @@ describe("pipeline — Bearer cryptographic verification", () => {
|
|
|
317
317
|
const app = oke({
|
|
318
318
|
name: "no-inject",
|
|
319
319
|
gate: { policies: [member] },
|
|
320
|
-
env: "
|
|
320
|
+
env: "dev",
|
|
321
|
+
config: {
|
|
322
|
+
drivers: {
|
|
323
|
+
store: { kv: { dev: "memory", test: "memory", prod: "memory" } },
|
|
324
|
+
signal: { dev: "memory", test: "memory", prod: "memory" },
|
|
325
|
+
clock: { dev: "memory", test: "frozen", prod: "memory" },
|
|
326
|
+
journal: { dev: "memory", test: "memory", prod: "memory" },
|
|
327
|
+
channel: { email: { dev: "console", test: "console", prod: "console" } },
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
startScheduler: false,
|
|
331
|
+
});
|
|
332
|
+
await app.boot({
|
|
333
|
+
env: "dev",
|
|
334
|
+
gates: [member],
|
|
321
335
|
startScheduler: false,
|
|
336
|
+
config: app.$options.config,
|
|
322
337
|
});
|
|
323
|
-
await app.boot({ env: "local", gates: [member], startScheduler: false });
|
|
324
338
|
|
|
325
339
|
const result = await app.execute(
|
|
326
340
|
app.flow("orders.inject")!,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for {@link Redacted} serialization / inspect hygiene.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { inspect } from "node:util";
|
|
7
|
+
import { REDACTED_PLACEHOLDER, Redacted } from "./redacted.ts";
|
|
8
|
+
|
|
9
|
+
describe("Redacted", () => {
|
|
10
|
+
test("does not leak in JSON.stringify", () => {
|
|
11
|
+
const r = Redacted.of("secret");
|
|
12
|
+
expect(JSON.stringify({ r })).toBe(`{"r":"${REDACTED_PLACEHOLDER}"}`);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("does not leak in string coercion", () => {
|
|
16
|
+
const r = Redacted.of("secret");
|
|
17
|
+
expect(r.toString()).toBe(REDACTED_PLACEHOLDER);
|
|
18
|
+
expect(r.valueOf()).toBe(REDACTED_PLACEHOLDER);
|
|
19
|
+
expect(String(r)).toBe(REDACTED_PLACEHOLDER);
|
|
20
|
+
expect(`${r}`).toBe(REDACTED_PLACEHOLDER);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("does not leak in util.inspect", () => {
|
|
24
|
+
const r = Redacted.of("secret");
|
|
25
|
+
const inspected = inspect(r);
|
|
26
|
+
expect(inspected).toContain("Redacted");
|
|
27
|
+
expect(inspected).not.toContain("secret");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("reveal and map keep the cleartext only behind reveal", () => {
|
|
31
|
+
const r = Redacted.of("secret").map((v) => v.toUpperCase());
|
|
32
|
+
expect(r.reveal()).toBe("SECRET");
|
|
33
|
+
expect(JSON.stringify(r)).toBe(`"${REDACTED_PLACEHOLDER}"`);
|
|
34
|
+
});
|
|
35
|
+
});
|
package/src/kernel/redacted.ts
CHANGED
|
@@ -20,11 +20,29 @@ export class Redacted<T> {
|
|
|
20
20
|
this.#value = value;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Construct a {@link Redacted} without calling `new`.
|
|
25
|
+
*
|
|
26
|
+
* @param value - Wrapped secret
|
|
27
|
+
*/
|
|
28
|
+
static of<T>(value: T): Redacted<T> {
|
|
29
|
+
return new Redacted(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
/** The one explicit way to get the real value. */
|
|
24
33
|
reveal(): T {
|
|
25
34
|
return this.#value;
|
|
26
35
|
}
|
|
27
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Transform the wrapped value without exposing it to callers.
|
|
39
|
+
*
|
|
40
|
+
* @param fn - Mapper over the cleartext
|
|
41
|
+
*/
|
|
42
|
+
map<U>(fn: (value: T) => U): Redacted<U> {
|
|
43
|
+
return Redacted.of(fn(this.#value));
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
toString(): string {
|
|
29
47
|
return REDACTED_PLACEHOLDER;
|
|
30
48
|
}
|
|
@@ -33,13 +51,17 @@ export class Redacted<T> {
|
|
|
33
51
|
return REDACTED_PLACEHOLDER;
|
|
34
52
|
}
|
|
35
53
|
|
|
54
|
+
valueOf(): string {
|
|
55
|
+
return REDACTED_PLACEHOLDER;
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
[Symbol.toPrimitive](): string {
|
|
37
59
|
return REDACTED_PLACEHOLDER;
|
|
38
60
|
}
|
|
39
61
|
|
|
40
|
-
/** `util.inspect` / `console.log` — never the real value. */
|
|
62
|
+
/** `util.inspect` / `console.log` / Bun — never the real value. */
|
|
41
63
|
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
|
42
|
-
return
|
|
64
|
+
return `Redacted<${typeof this.#value}>`;
|
|
43
65
|
}
|
|
44
66
|
}
|
|
45
67
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default long-lived server router preset — RegExp → Trie.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { RegExpRouter } from "./regexp.ts";
|
|
6
|
+
import { SmartRouter } from "./smart.ts";
|
|
7
|
+
import { TrieRouter } from "./trie.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create the default SmartRouter (RegExp → Trie) for long-lived servers.
|
|
11
|
+
*/
|
|
12
|
+
export function createDefaultRouter<T>(): SmartRouter<T> {
|
|
13
|
+
return new SmartRouter<T>({
|
|
14
|
+
factories: [() => new RegExpRouter<T>(), () => new TrieRouter<T>()],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge / cold-start router preset — Linear → Trie (no RegExp compile).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { LinearRouter } from "./linear.ts";
|
|
6
|
+
import { SmartRouter } from "./smart.ts";
|
|
7
|
+
import { TrieRouter } from "./trie.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create an edge-oriented SmartRouter (Linear → Trie).
|
|
11
|
+
*
|
|
12
|
+
* Does not import `regexp.ts`, so edge bundles can tree-shake RegExpRouter.
|
|
13
|
+
*/
|
|
14
|
+
export function createEdgeRouter<T>(): SmartRouter<T> {
|
|
15
|
+
return new SmartRouter<T>({
|
|
16
|
+
factories: [() => new LinearRouter<T>(), () => new TrieRouter<T>()],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preset switch for {@link createRouter}.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createDefaultRouter } from "./create-default.ts";
|
|
6
|
+
import { createEdgeRouter } from "./create-edge.ts";
|
|
7
|
+
import type { SmartRouter } from "./smart.ts";
|
|
8
|
+
import type { RouterPreset } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a router for the given preset.
|
|
12
|
+
*
|
|
13
|
+
* - `default` — SmartRouter(RegExp → Trie), long-lived servers
|
|
14
|
+
* - `edge` — SmartRouter(Linear → Trie), cold-start / one-shot isolates
|
|
15
|
+
*
|
|
16
|
+
* @param preset - Preset name
|
|
17
|
+
*/
|
|
18
|
+
export function createRouter<T>(preset: RouterPreset = "default"): SmartRouter<T> {
|
|
19
|
+
if (preset === "edge") {
|
|
20
|
+
return createEdgeRouter<T>();
|
|
21
|
+
}
|
|
22
|
+
return createDefaultRouter<T>();
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP router — compiled RegExp matching with a Trie fallback, plus a linear
|
|
3
|
+
* preset for cold-start edge builds.
|
|
4
|
+
*
|
|
5
|
+
* Chosen at build/startup: try RegExp first; on unsupported patterns fall
|
|
6
|
+
* back to Trie. The `edge` preset uses LinearRouter for fast registration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { createRouter } from "./create.ts";
|
|
10
|
+
export { createDefaultRouter } from "./create-default.ts";
|
|
11
|
+
export { createEdgeRouter } from "./create-edge.ts";
|
|
12
|
+
export { LinearRouter } from "./linear.ts";
|
|
13
|
+
export { RegExpRouter } from "./regexp.ts";
|
|
14
|
+
export { SmartRouter } from "./smart.ts";
|
|
15
|
+
export { TrieRouter } from "./trie.ts";
|
|
16
|
+
export {
|
|
17
|
+
UnsupportedPathError,
|
|
18
|
+
isUnsupportedByRegExp,
|
|
19
|
+
type RouteMatch,
|
|
20
|
+
type Router,
|
|
21
|
+
type RouterFactory,
|
|
22
|
+
type RouterPreset,
|
|
23
|
+
} from "./types.ts";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear router — fast registration, O(n) string-scan match.
|
|
3
|
+
* Edge / cold-start preset (no compile phase).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { RouteMatch, Router } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
interface LinearRoute<T> {
|
|
9
|
+
readonly method: string;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly value: T;
|
|
12
|
+
readonly parts: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Linear router — fast registration, O(n) string-scan match.
|
|
17
|
+
* Edge / cold-start preset (no compile phase).
|
|
18
|
+
*/
|
|
19
|
+
export class LinearRouter<T> implements Router<T> {
|
|
20
|
+
readonly name = "LinearRouter";
|
|
21
|
+
readonly #routes: LinearRoute<T>[] = [];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param method - HTTP method
|
|
25
|
+
* @param path - Path pattern
|
|
26
|
+
* @param value - Route value
|
|
27
|
+
*/
|
|
28
|
+
add(method: string, path: string, value: T): void {
|
|
29
|
+
const parts = path.startsWith("/") ? path.split("/").slice(1) : path.split("/");
|
|
30
|
+
this.#routes.push({ method, path, value, parts });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param method - HTTP method
|
|
35
|
+
* @param path - Pathname
|
|
36
|
+
*/
|
|
37
|
+
match(method: string, path: string): RouteMatch<T> | undefined {
|
|
38
|
+
const pathParts = path.startsWith("/") ? path.split("/").slice(1) : path.split("/");
|
|
39
|
+
if (pathParts.length > 0 && pathParts[pathParts.length - 1] === "") {
|
|
40
|
+
pathParts.pop();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const route of this.#routes) {
|
|
44
|
+
if (route.method !== method) continue;
|
|
45
|
+
|
|
46
|
+
if (!route.path.includes(":") && !route.path.includes("*")) {
|
|
47
|
+
if (route.path === path || `${route.path}/` === path) {
|
|
48
|
+
return { value: route.value, params: {} };
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const params: Record<string, string> = {};
|
|
54
|
+
let pi = 0;
|
|
55
|
+
let matched = true;
|
|
56
|
+
|
|
57
|
+
for (let ri = 0; ri < route.parts.length; ri++) {
|
|
58
|
+
const part = route.parts[ri] ?? "";
|
|
59
|
+
if (part === "*") {
|
|
60
|
+
params["*"] = pathParts.slice(pi).join("/");
|
|
61
|
+
pi = pathParts.length;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
if (pi >= pathParts.length) {
|
|
65
|
+
matched = false;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const got = pathParts[pi] ?? "";
|
|
69
|
+
if (part.startsWith(":") && part.length > 1) {
|
|
70
|
+
const name = part.endsWith("?") ? part.slice(1, -1) : part.slice(1);
|
|
71
|
+
params[name] = got;
|
|
72
|
+
pi += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (part !== got) {
|
|
76
|
+
matched = false;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
pi += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (matched && pi === pathParts.length) {
|
|
83
|
+
return { value: route.value, params };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|