@databricks/appkit 0.83.0 → 0.84.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/CLAUDE.md +15 -1
- package/dist/agents/databricks.d.ts +15 -3
- package/dist/agents/databricks.d.ts.map +1 -1
- package/dist/agents/databricks.js +25 -8
- package/dist/agents/databricks.js.map +1 -1
- package/dist/appkit/package.js +1 -1
- package/dist/beta.d.ts +2 -2
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +6 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/commands/agent/eval.js +1 -1
- package/dist/cli/commands/generate-types.js +1 -1
- package/dist/cli/commands/plugin/sync/sync.js +28 -15
- package/dist/cli/commands/plugin/sync/sync.js.map +1 -1
- package/dist/cli/commands/registry/add.js +3 -11
- package/dist/cli/commands/registry/add.js.map +1 -1
- package/dist/cli/commands/registry/config-writer.js +1 -1
- package/dist/connectors/lakebase/routing-pool.d.ts.map +1 -1
- package/dist/connectors/lakebase/routing-pool.js +4 -10
- package/dist/connectors/lakebase/routing-pool.js.map +1 -1
- package/dist/context/caller-context.d.ts +4 -1
- package/dist/context/caller-context.d.ts.map +1 -1
- package/dist/context/caller-context.js.map +1 -1
- package/dist/context/execution-context.d.ts +29 -1
- package/dist/context/execution-context.d.ts.map +1 -1
- package/dist/context/execution-context.js +60 -8
- package/dist/context/execution-context.js.map +1 -1
- package/dist/context/index.d.ts +2 -2
- package/dist/context/index.js +2 -1
- package/dist/context/request-scope.d.ts +2 -0
- package/dist/context/request-scope.js +39 -0
- package/dist/context/request-scope.js.map +1 -0
- package/dist/context/resource-capabilities.js +59 -0
- package/dist/context/resource-capabilities.js.map +1 -0
- package/dist/context/scoped-api.js +104 -0
- package/dist/context/scoped-api.js.map +1 -0
- package/dist/context/service-context.d.ts.map +1 -1
- package/dist/context/service-context.js +1 -1
- package/dist/context/service-context.js.map +1 -1
- package/dist/context/user-context.d.ts +16 -19
- package/dist/context/user-context.d.ts.map +1 -1
- package/dist/context/user-context.js +30 -3
- package/dist/context/user-context.js.map +1 -1
- package/dist/core/agent/load-agents.d.ts.map +1 -1
- package/dist/core/agent/load-agents.js +5 -2
- package/dist/core/agent/load-agents.js.map +1 -1
- package/dist/core/agent/run-agent.d.ts +19 -6
- package/dist/core/agent/run-agent.d.ts.map +1 -1
- package/dist/core/agent/run-agent.js +53 -17
- package/dist/core/agent/run-agent.js.map +1 -1
- package/dist/core/agent/types.d.ts +18 -1
- package/dist/core/agent/types.d.ts.map +1 -1
- package/dist/core/agent/types.js.map +1 -1
- package/dist/core/appkit.d.ts +4 -3
- package/dist/core/appkit.d.ts.map +1 -1
- package/dist/core/appkit.js +30 -9
- package/dist/core/appkit.js.map +1 -1
- package/dist/core/plugin-context.d.ts +14 -15
- package/dist/core/plugin-context.d.ts.map +1 -1
- package/dist/core/plugin-context.js +24 -14
- package/dist/core/plugin-context.js.map +1 -1
- package/dist/errors/base.d.ts +2 -2
- package/dist/errors/base.js +2 -2
- package/dist/errors/base.js.map +1 -1
- package/dist/errors/identity-expired.d.ts +14 -0
- package/dist/errors/identity-expired.d.ts.map +1 -0
- package/dist/errors/identity-expired.js +33 -0
- package/dist/errors/identity-expired.js.map +1 -0
- package/dist/errors/index.js +1 -0
- package/dist/index.d.ts +8 -5
- package/dist/index.js +5 -2
- package/dist/logging/logger.js +1 -1
- package/dist/plugin/execution-result.d.ts +4 -1
- package/dist/plugin/execution-result.d.ts.map +1 -1
- package/dist/plugin/interceptors/telemetry.js +6 -4
- package/dist/plugin/interceptors/telemetry.js.map +1 -1
- package/dist/plugin/plugin.d.ts +13 -21
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js +41 -122
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/plugins/agents/agents.d.ts +12 -0
- package/dist/plugins/agents/agents.d.ts.map +1 -1
- package/dist/plugins/agents/agents.js +65 -15
- package/dist/plugins/agents/agents.js.map +1 -1
- package/dist/plugins/agents/auth-mode.js +42 -0
- package/dist/plugins/agents/auth-mode.js.map +1 -0
- package/dist/plugins/agents/index.d.ts +1 -1
- package/dist/plugins/agents/mlflow.js +11 -3
- package/dist/plugins/agents/mlflow.js.map +1 -1
- package/dist/plugins/agents/tool-dispatch.js +9 -1
- package/dist/plugins/agents/tool-dispatch.js.map +1 -1
- package/dist/plugins/ai-search/ai-search.d.ts.map +1 -1
- package/dist/plugins/ai-search/ai-search.js +4 -4
- package/dist/plugins/ai-search/ai-search.js.map +1 -1
- package/dist/plugins/analytics/analytics.d.ts +2 -2
- package/dist/plugins/analytics/analytics.js +6 -6
- package/dist/plugins/analytics/analytics.js.map +1 -1
- package/dist/plugins/database/crud/contract.js +2 -2
- package/dist/plugins/database/crud/contract.js.map +1 -1
- package/dist/plugins/files/plugin.d.ts +10 -4
- package/dist/plugins/files/plugin.d.ts.map +1 -1
- package/dist/plugins/files/plugin.js +34 -13
- package/dist/plugins/files/plugin.js.map +1 -1
- package/dist/plugins/genie/genie.d.ts +9 -0
- package/dist/plugins/genie/genie.d.ts.map +1 -1
- package/dist/plugins/genie/genie.js +12 -3
- package/dist/plugins/genie/genie.js.map +1 -1
- package/dist/plugins/genie/manifest.js +1 -0
- package/dist/plugins/jobs/plugin.js +2 -2
- package/dist/plugins/jobs/plugin.js.map +1 -1
- package/dist/plugins/lakebase/lakebase.d.ts +10 -17
- package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
- package/dist/plugins/lakebase/lakebase.js +12 -17
- package/dist/plugins/lakebase/lakebase.js.map +1 -1
- package/dist/plugins/server/client-config-sanitizer.js +1 -4
- package/dist/plugins/server/client-config-sanitizer.js.map +1 -1
- package/dist/plugins/server/dev-obo-middleware.js +60 -0
- package/dist/plugins/server/dev-obo-middleware.js.map +1 -0
- package/dist/plugins/server/index.d.ts.map +1 -1
- package/dist/plugins/server/index.js +5 -2
- package/dist/plugins/server/index.js.map +1 -1
- package/dist/plugins/server/remote-tunnel/remote-tunnel-manager.js +3 -3
- package/dist/plugins/server/remote-tunnel/remote-tunnel-manager.js.map +1 -1
- package/dist/plugins/server/static-server.js +3 -3
- package/dist/plugins/server/static-server.js.map +1 -1
- package/dist/plugins/server/utils.js +3 -3
- package/dist/plugins/server/utils.js.map +1 -1
- package/dist/plugins/server/vite-dev-server.js +4 -4
- package/dist/plugins/server/vite-dev-server.js.map +1 -1
- package/dist/plugins/serving/manifest.js +1 -0
- package/dist/plugins/serving/serving.js +6 -6
- package/dist/plugins/serving/serving.js.map +1 -1
- package/dist/schemas/manifest.d.ts +35 -1
- package/dist/schemas/manifest.d.ts.map +1 -1
- package/dist/schemas/manifest.js +112 -4
- package/dist/schemas/manifest.js.map +1 -1
- package/dist/shared/src/dev-obo.js +86 -0
- package/dist/shared/src/dev-obo.js.map +1 -0
- package/dist/shared/src/index.d.ts +1 -1
- package/dist/shared/src/plugin.d.ts +12 -1
- package/dist/shared/src/plugin.d.ts.map +1 -1
- package/dist/shared/src/schemas/manifest.d.ts +41 -33
- package/dist/shared/src/schemas/manifest.d.ts.map +1 -1
- package/dist/shared/src/schemas/manifest.js +43 -4
- package/dist/shared/src/schemas/manifest.js.map +1 -1
- package/dist/stream/stream-manager.d.ts.map +1 -1
- package/dist/stream/stream-manager.js +5 -2
- package/dist/stream/stream-manager.js.map +1 -1
- package/dist/telemetry/execution-span-processor.js +21 -0
- package/dist/telemetry/execution-span-processor.js.map +1 -0
- package/dist/telemetry/telemetry-manager.js +2 -1
- package/dist/telemetry/telemetry-manager.js.map +1 -1
- package/dist/testing/create-test-app.d.ts +2 -2
- package/dist/testing/create-test-app.js.map +1 -1
- package/dist/testing/test-plugin-context.d.ts +3 -12
- package/dist/testing/test-plugin-context.d.ts.map +1 -1
- package/dist/testing/test-plugin-context.js +24 -16
- package/dist/testing/test-plugin-context.js.map +1 -1
- package/dist/type-generator/database/generate.js +3 -3
- package/dist/type-generator/database/generate.js.map +1 -1
- package/dist/type-generator/migration.js +2 -2
- package/dist/type-generator/migration.js.map +1 -1
- package/dist/type-generator/serving/server-file-extractor.js +3 -3
- package/dist/type-generator/serving/server-file-extractor.js.map +1 -1
- package/dist/utils/is-plain-object.js +11 -0
- package/dist/utils/is-plain-object.js.map +1 -0
- package/docs/api/appkit/Class.AppKitError.md +2 -1
- package/docs/api/appkit/Class.AuthenticationError.md +1 -1
- package/docs/api/appkit/Class.ConfigurationError.md +1 -1
- package/docs/api/appkit/Class.ConnectionError.md +1 -1
- package/docs/api/appkit/Class.DatabaseValidationError.md +1 -1
- package/docs/api/appkit/Class.ExecutionError.md +1 -1
- package/docs/api/appkit/Class.IdentityExpiredError.md +190 -0
- package/docs/api/appkit/Class.InitializationError.md +1 -1
- package/docs/api/appkit/Class.Plugin.md +8 -12
- package/docs/api/appkit/Class.ServerError.md +1 -1
- package/docs/api/appkit/Class.ServiceContext.md +169 -0
- package/docs/api/appkit/Class.TunnelError.md +1 -1
- package/docs/api/appkit/Class.ValidationError.md +1 -1
- package/docs/api/appkit/Function.createApp.md +12 -12
- package/docs/api/appkit/Function.getCallerContext.md +12 -0
- package/docs/api/appkit/Function.getCurrentUserId.md +14 -0
- package/docs/api/appkit/Function.getExecutionContext.md +1 -1
- package/docs/api/appkit/Function.getUserContext.md +16 -0
- package/docs/api/appkit/Function.isInUserContext.md +12 -0
- package/docs/api/appkit/Function.isUserContext.md +20 -0
- package/docs/api/appkit/Function.runAgent.md +1 -1
- package/docs/api/appkit/Function.runInCallerContext.md +27 -0
- package/docs/api/appkit/Function.runInUserContext.md +29 -0
- package/docs/api/appkit/Interface.AgentDefinition.md +11 -0
- package/docs/api/appkit/Interface.AgentsPluginConfig.md +11 -0
- package/docs/api/appkit/Interface.CallerContext.md +13 -0
- package/docs/api/appkit/Interface.IndexConfig.md +1 -1
- package/docs/api/appkit/Interface.PluginManifest.md +9 -1
- package/docs/api/appkit/Interface.RegisteredAgent.md +11 -0
- package/docs/api/appkit/Interface.RunAgentInput.md +45 -1
- package/docs/api/appkit/TypeAlias.AgentAuth.md +8 -0
- package/docs/api/appkit/TypeAlias.AppKitApi.md +35 -0
- package/docs/api/appkit/TypeAlias.ExecutionContext.md +4 -1
- package/docs/api/appkit/TypeAlias.ExecutionResult.md +65 -0
- package/docs/api/appkit/TypeAlias.ScopedPluginMap.md +12 -0
- package/docs/api/appkit/TypeAlias.UserContext.md +109 -0
- package/docs/api/appkit/TypeAlias.UserScopedApp.md +39 -0
- package/docs/api/appkit.md +14 -0
- package/docs/plugins/agents.md +45 -0
- package/docs/plugins/execution-context.md +101 -50
- package/docs/plugins/lakebase.md +6 -82
- package/llms.txt +15 -1
- package/package.json +1 -1
- package/sbom.cdx.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PluginConstructor, PluginData
|
|
1
|
+
import { AppKitApi, PluginConstructor, PluginData } from "../shared/src/plugin.js";
|
|
2
2
|
import { CacheConfig } from "../shared/src/cache.js";
|
|
3
3
|
import "../shared/src/index.js";
|
|
4
4
|
import { WorkspaceClient } from "../workspace-client/index.js";
|
|
@@ -60,7 +60,7 @@ interface TestApp<T extends Plugins> {
|
|
|
60
60
|
* Plugin exports by manifest name. Nested rather than spread because `get` and
|
|
61
61
|
* `delete` are plausible plugin names and would collide with the request methods.
|
|
62
62
|
*/
|
|
63
|
-
plugins:
|
|
63
|
+
plugins: AppKitApi<T>;
|
|
64
64
|
/** The same object a handler resolves at runtime. */
|
|
65
65
|
client: WorkspaceClient;
|
|
66
66
|
/** e.g. `http://127.0.0.1:54321`. Throws when `server: false`. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-test-app.js","names":[],"sources":["../../src/testing/create-test-app.ts"],"sourcesContent":["/**\n * Boot a real AppKit app with no workspace, credentials, or network, then call it\n * over real HTTP.\n */\n\nimport type { Server } from \"node:http\";\n\nimport type {\n CacheConfig,\n PluginConstructor,\n PluginData,\n PluginMap,\n} from \"shared\";\nimport { vi } from \"vitest\";\n\nimport { InMemoryStorage } from \"../cache/storage/memory\";\nimport { ServiceContext } from \"../context/service-context\";\nimport { AppKit, disposeApp } from \"../core/appkit\";\nimport type { WorkspaceClient } from \"../workspace-client\";\nimport type { OboOption } from \"./fixtures\";\nimport { fakeUserContext, oboHeaders, setupDatabricksEnv } from \"./fixtures\";\nimport type { CreateMockWorkspaceClientOptions } from \"./mock-workspace-client\";\nimport { createMockWorkspaceClient } from \"./mock-workspace-client\";\nimport { dropCoreSingletons } from \"./reset-singletons\";\n\n// Loose shapes are intentional here; `noExplicitAny` is off repo-wide (see\n// .oxlintrc.json), so a local alias keeps the intent readable.\ntype Any = any;\n\n/**\n * The env snapshot to restore on close, and whether an app currently holds it.\n *\n * One live app at a time (see the guard in `createTestApp`), so a flag suffices.\n */\nlet envBaseline: NodeJS.ProcessEnv | undefined;\nlet harnessAppLive = false;\n\n/** Take the baseline on boot. */\nfunction acquireEnvBaseline(): void {\n envBaseline = { ...process.env };\n harnessAppLive = true;\n}\n\n/** Restore the baseline on close. */\nfunction releaseEnvBaseline(): void {\n harnessAppLive = false;\n if (!envBaseline) return;\n\n const baseline = envBaseline;\n envBaseline = undefined;\n for (const key of Object.keys(process.env)) {\n if (!(key in baseline)) delete process.env[key];\n }\n Object.assign(process.env, baseline);\n}\n\n/** Plugin descriptors, exactly as `createApp` takes them. */\ntype Plugins = PluginData<PluginConstructor, unknown, string>[];\n\n/** Options for {@link createTestApp}. */\nexport interface CreateTestAppOptions<T extends Plugins> {\n /** The plugins under test, as `createApp` takes them. */\n plugins?: T;\n\n /** Dotted-path responses for the built-in mock. Refused when `client` is set. */\n responses?: CreateMockWorkspaceClientOptions[\"responses\"];\n\n /**\n * Make the built-in mock throw when a path with no declared response is\n * called, rather than resolving `undefined`. Refused when `client` is set —\n * configure it on your own client instead.\n */\n strict?: CreateMockWorkspaceClientOptions[\"strict\"];\n\n /**\n * Replaces the built-in mock. You then own `currentUser.me()` — boot reads\n * `currentUser.id` and fails without it.\n */\n client?: WorkspaceClient;\n\n /** Extra env for the boot, restored on `close()`; satisfies declared resources. */\n env?: Record<string, string>;\n\n /** No socket; setup, validation, and teardown still run, request methods throw. */\n server?: false;\n\n /**\n * Defaults to `\"test\"`. `\"development\"` is refused — it throws a `RangeError`\n * in `get-port` on `port: 0`, boots Vite, and relaxes validation.\n *\n * Beyond refusing `development`, this decides error-response redaction:\n * `errorHandlerMiddleware` returns the real message unless `NODE_ENV` is\n * `production`, where a 5xx becomes `\"Server error\"`. Pass `\"production\"` to\n * assert what a deployed app actually returns to a client.\n */\n nodeEnv?: string;\n\n /** Defaults to in-memory, which is what keeps boot offline. */\n cache?: CacheConfig;\n}\n\n/** Per-request options for the {@link TestApp} HTTP methods. */\nexport interface TestRequestOptions {\n /** A non-string value is JSON-encoded with `content-type: application/json`. */\n body?: unknown;\n /** Merged last, so they win over anything the harness sets. */\n headers?: Record<string, string>;\n /** Same convention as `createMockRequest({ obo })`. */\n obo?: OboOption;\n /** Forwarded to `fetch`. */\n signal?: AbortSignal;\n}\n\n/** A booted test app. */\nexport interface TestApp<T extends Plugins> {\n /**\n * Plugin exports by manifest name. Nested rather than spread because `get` and\n * `delete` are plausible plugin names and would collide with the request methods.\n */\n plugins: PluginMap<T>;\n /** The same object a handler resolves at runtime. */\n client: WorkspaceClient;\n /** e.g. `http://127.0.0.1:54321`. Throws when `server: false`. */\n baseUrl: string;\n /** The bound ephemeral port. Throws when `server: false`. */\n port: number;\n /** The underlying HTTP server, or `undefined` with `server: false`. */\n server?: Server;\n\n /** Release the app and restore env. Idempotent. */\n close(): Promise<void>;\n [Symbol.asyncDispose](): Promise<void>;\n\n get(path: string, options?: TestRequestOptions): Promise<Response>;\n post(path: string, options?: TestRequestOptions): Promise<Response>;\n put(path: string, options?: TestRequestOptions): Promise<Response>;\n patch(path: string, options?: TestRequestOptions): Promise<Response>;\n delete(path: string, options?: TestRequestOptions): Promise<Response>;\n}\n\n/**\n * Point `ServiceContext.createCallerContext` at the harness's mock so an `obo`\n * request does not construct a real SDK client from `DATABRICKS_HOST`.\n *\n * Mirrors the `createUserContextSpy` in `fixtures.ts`; returns its restore.\n */\nfunction stubUserContext(client: WorkspaceClient): () => void {\n const spy = vi\n .spyOn(ServiceContext, \"createCallerContext\")\n .mockImplementation((token, userId, userName, userEmail) =>\n fakeUserContext(client, ServiceContext.get())(\n token,\n userId,\n userName,\n userEmail,\n ),\n );\n return () => spy.mockRestore();\n}\n\n/**\n * Wait for a server to finish binding and return the port it landed on.\n *\n * Needed with `port: 0`: `start()` returns once `listen()` is invoked, before\n * the bind completes, so `address()` is null until the `listening` event fires.\n * `createTestApp` does this for you — reach for it when hand-rolling a server.\n */\nexport async function getListeningPort(server: Server): Promise<number> {\n const addr = server.address();\n if (addr && typeof addr === \"object\" && typeof addr.port === \"number\") {\n return addr.port;\n }\n await new Promise<void>((resolve, reject) => {\n server.once(\"listening\", () => resolve());\n server.once(\"error\", (err) => reject(err));\n });\n const ready = server.address();\n if (!ready || typeof ready !== \"object\") {\n throw new Error(\"Server is listening but address() returned null\");\n }\n return ready.port;\n}\n\n/**\n * Boot a real app — real Express wiring, routes, and resource validation — with\n * no workspace, credentials, or network. `createTestPluginContext` is cheaper\n * when you only need to unit-test wiring.\n *\n * Does **not** validate config values against `manifest.config.schema`; no\n * runtime validator exists for that.\n *\n * @example\n * ```ts\n * const app = await createTestApp({ plugins: [myPlugin()] });\n * try {\n * const res = await app.post(\"/api/my-plugin/thing\", { body: { q: 1 }, obo: true });\n * await expectStream(res).toEmit(\"status\", \"result\");\n * } finally {\n * await app.close();\n * }\n * ```\n */\nexport async function createTestApp<T extends Plugins>(\n options: CreateTestAppOptions<T> = {},\n): Promise<TestApp<T>> {\n const {\n plugins = [] as unknown as T,\n responses,\n strict,\n client: suppliedClient,\n env = {},\n server: serverOption,\n nodeEnv = \"test\",\n cache,\n } = options;\n\n if (nodeEnv === \"development\") {\n throw new Error(\n 'createTestApp: nodeEnv \"development\" is not supported. Dev mode routes ' +\n \"the harness's ephemeral `port: 0` through get-port, which throws a \" +\n \"RangeError, and it also boots a real Vite dev server, downgrades \" +\n \"resource validation to a warning, and stops filtering dev-only \" +\n \"plugins. Pin a port explicitly with your own server plugin if you \" +\n \"need dev behaviour.\",\n );\n }\n\n // Refused rather than half-supported: AppKit's workspace client, cache and\n // on-behalf-of fake are process-wide, so a second live app cannot own its\n // own. Checked before any mutation, so a refused boot leaves the live app\n // untouched.\n if (harnessAppLive) {\n throw new Error(\n \"createTestApp: a harness app is already open. AppKit's workspace \" +\n \"client, cache, and on-behalf-of fake are process-wide, so a second \" +\n \"app would not receive its own `client`/`responses`, and closing \" +\n \"either would un-fake the other's on-behalf-of path. Close the first \" +\n \"app before booting another — `await using`, or try/finally.\",\n );\n }\n\n // Wholesale rather than a whitelist: plugins read vars we cannot enumerate.\n acquireEnvBaseline();\n\n let app: Awaited<ReturnType<typeof AppKit._createApp>> | undefined;\n let restoreUserContext: (() => void) | undefined;\n\n // Runs the booted plugins' shutdown() hooks and closes the server it started;\n // the harness owns dropping the singletons and restoring env. The `as Any` is\n // the one escape hatch the symbol-keyed teardown forces on the harness.\n const disposeBooted = (a: unknown): Promise<void> => (a as Any)[disposeApp]();\n\n try {\n process.env.NODE_ENV = nodeEnv;\n\n // Redundant while NODE_ENV is pinned, but keeps the throw-on-missing-resource\n // contract if that pin ever changes. No opt-out: the warning path is\n // dev-only, and dev is refused.\n process.env.APPKIT_STRICT_VALIDATION = \"true\";\n\n // The workspace ID short-circuits getWorkspaceId's SCIM probe, which would\n // otherwise show up as an apiClient.request call.\n setupDatabricksEnv({\n DATABRICKS_WORKSPACE_ID: \"test-workspace-id\",\n ...env,\n });\n\n dropCoreSingletons();\n\n // `responses` only seeds the built-in mock, so alongside a caller-supplied\n // client it would silently do nothing. Refuse instead, matching the\n // `server: false` conflict below.\n if (suppliedClient && (responses !== undefined || strict !== undefined)) {\n throw new Error(\n \"createTestApp: `responses` and `strict` configure the built-in mock \" +\n \"client, so they do nothing when you also pass `client`. Drop them \" +\n \"and configure your own client instead.\",\n );\n }\n\n // Boot runs ServiceContext.createContext for real, which reads\n // currentUser.id — the mock's built-in default is what lets it through.\n const client =\n suppliedClient ?? createMockWorkspaceClient({ responses, strict });\n\n // createApp({ client }) installs only the service-principal client. An `obo`\n // request reaches ServiceContext.createCallerContext, which builds a *real*\n // client from process.env.DATABRICKS_HOST — so the user-scoped path is faked\n // here too, or \"no network\" is false the moment a handler calls asUser.\n restoreUserContext = stubUserContext(client);\n\n // createApp never auto-adds a server, so without this there is nothing to\n // fetch. Lazily imported: the plugin runs dotenv.config() at module load, so\n // a static import would mutate a consumer's env on import of this kit.\n const hasServer = plugins.some((p) => p?.name === \"server\");\n if (serverOption === false && hasServer) {\n // The plugin would still bind a socket while the handle denied one existed.\n throw new Error(\n \"createTestApp: `server: false` conflicts with the server plugin in \" +\n \"`plugins`. Drop one — omit `server: false` to use your plugin, or \" +\n \"remove the plugin to boot without a socket.\",\n );\n }\n const bootPlugins = [...plugins] as Plugins;\n if (serverOption !== false && !hasServer) {\n const { server: serverPlugin } = await import(\"../plugins/server\");\n bootPlugins.push(serverPlugin({ port: 0, host: \"127.0.0.1\" }));\n }\n\n // Both extras are required to stay offline: without explicit storage the\n // cache builds its own client and probes Lakebase, and without the opt-out\n // TelemetryReporter fires an apiClient.request on boot.\n app = await AppKit._createApp({\n plugins: bootPlugins as Any,\n client,\n cache: cache ?? {\n storage: new InMemoryStorage({ enabled: true } as Any),\n },\n disableInternalTelemetry: true,\n // The harness boots repeatedly in one process and runs its own teardown,\n // so it must not accumulate SIGTERM/SIGINT handlers across boots.\n installSignalHandlers: false,\n });\n\n const serverExports = (app as Any).server;\n const httpServer: Server | undefined =\n serverOption === false ? undefined : serverExports?.getServer?.();\n const port = httpServer ? await getListeningPort(httpServer) : undefined;\n const baseUrl = port === undefined ? undefined : `http://127.0.0.1:${port}`;\n\n const bootedApp = app;\n let closed: Promise<void> | undefined;\n\n /** Memoized, so repeated calls are safe in nested `finally`s. */\n const close = () => {\n closed ??= (async () => {\n try {\n await disposeBooted(bootedApp);\n } finally {\n dropCoreSingletons();\n restoreUserContext?.();\n releaseEnvBaseline();\n }\n })();\n return closed;\n };\n\n const request = async (\n method: string,\n path: string,\n reqOptions: TestRequestOptions = {},\n ): Promise<Response> => {\n if (baseUrl === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false), so \" +\n `${method} ${path} cannot be issued.`,\n );\n }\n\n const headers: Record<string, string> = {};\n if (reqOptions.obo) {\n Object.assign(headers, oboHeaders(reqOptions.obo));\n }\n\n let body: string | undefined;\n if (reqOptions.body !== undefined) {\n if (typeof reqOptions.body === \"string\") {\n body = reqOptions.body;\n } else {\n body = JSON.stringify(reqOptions.body);\n headers[\"content-type\"] = \"application/json\";\n }\n }\n\n // Caller headers last, so an explicit content-type or identity wins.\n // Lowercased first: `Headers` comma-joins case variants instead of\n // replacing, so a mixed-case override would corrupt the value into\n // \"alice, bob\" rather than win.\n for (const [name, value] of Object.entries(reqOptions.headers ?? {})) {\n headers[name.toLowerCase()] = value;\n }\n\n return fetch(new URL(path, baseUrl), {\n method,\n headers,\n body,\n signal: reqOptions.signal,\n });\n };\n\n return {\n plugins: bootedApp as unknown as PluginMap<T>,\n client,\n get baseUrl() {\n if (baseUrl === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false).\",\n );\n }\n return baseUrl;\n },\n get port() {\n if (port === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false).\",\n );\n }\n return port;\n },\n server: httpServer,\n close,\n [Symbol.asyncDispose]: close,\n get: (path, o) => request(\"GET\", path, o),\n post: (path, o) => request(\"POST\", path, o),\n put: (path, o) => request(\"PUT\", path, o),\n patch: (path, o) => request(\"PATCH\", path, o),\n delete: (path, o) => request(\"DELETE\", path, o),\n };\n } catch (err) {\n // Teardown must run from the failure path too, or the boot leaks env\n // mutations and singletons into every later test in the file.\n if (app) {\n try {\n await disposeBooted(app);\n } catch {\n // The boot error is the interesting one; don't let teardown mask it.\n }\n }\n // dispose() no longer drops the singletons, and a failure before boot may\n // have left a partial init — drop unconditionally either way.\n dropCoreSingletons();\n restoreUserContext?.();\n releaseEnvBaseline();\n throw err;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"create-test-app.js","names":[],"sources":["../../src/testing/create-test-app.ts"],"sourcesContent":["/**\n * Boot a real AppKit app with no workspace, credentials, or network, then call it\n * over real HTTP.\n */\n\nimport type { Server } from \"node:http\";\n\nimport type {\n CacheConfig,\n PluginConstructor,\n PluginData,\n AppKitApi,\n} from \"shared\";\nimport { vi } from \"vitest\";\n\nimport { InMemoryStorage } from \"../cache/storage/memory\";\nimport { ServiceContext } from \"../context/service-context\";\nimport { AppKit, disposeApp } from \"../core/appkit\";\nimport type { WorkspaceClient } from \"../workspace-client\";\nimport type { OboOption } from \"./fixtures\";\nimport { fakeUserContext, oboHeaders, setupDatabricksEnv } from \"./fixtures\";\nimport type { CreateMockWorkspaceClientOptions } from \"./mock-workspace-client\";\nimport { createMockWorkspaceClient } from \"./mock-workspace-client\";\nimport { dropCoreSingletons } from \"./reset-singletons\";\n\n// Loose shapes are intentional here; `noExplicitAny` is off repo-wide (see\n// .oxlintrc.json), so a local alias keeps the intent readable.\ntype Any = any;\n\n/**\n * The env snapshot to restore on close, and whether an app currently holds it.\n *\n * One live app at a time (see the guard in `createTestApp`), so a flag suffices.\n */\nlet envBaseline: NodeJS.ProcessEnv | undefined;\nlet harnessAppLive = false;\n\n/** Take the baseline on boot. */\nfunction acquireEnvBaseline(): void {\n envBaseline = { ...process.env };\n harnessAppLive = true;\n}\n\n/** Restore the baseline on close. */\nfunction releaseEnvBaseline(): void {\n harnessAppLive = false;\n if (!envBaseline) return;\n\n const baseline = envBaseline;\n envBaseline = undefined;\n for (const key of Object.keys(process.env)) {\n if (!(key in baseline)) delete process.env[key];\n }\n Object.assign(process.env, baseline);\n}\n\n/** Plugin descriptors, exactly as `createApp` takes them. */\ntype Plugins = PluginData<PluginConstructor, unknown, string>[];\n\n/** Options for {@link createTestApp}. */\nexport interface CreateTestAppOptions<T extends Plugins> {\n /** The plugins under test, as `createApp` takes them. */\n plugins?: T;\n\n /** Dotted-path responses for the built-in mock. Refused when `client` is set. */\n responses?: CreateMockWorkspaceClientOptions[\"responses\"];\n\n /**\n * Make the built-in mock throw when a path with no declared response is\n * called, rather than resolving `undefined`. Refused when `client` is set —\n * configure it on your own client instead.\n */\n strict?: CreateMockWorkspaceClientOptions[\"strict\"];\n\n /**\n * Replaces the built-in mock. You then own `currentUser.me()` — boot reads\n * `currentUser.id` and fails without it.\n */\n client?: WorkspaceClient;\n\n /** Extra env for the boot, restored on `close()`; satisfies declared resources. */\n env?: Record<string, string>;\n\n /** No socket; setup, validation, and teardown still run, request methods throw. */\n server?: false;\n\n /**\n * Defaults to `\"test\"`. `\"development\"` is refused — it throws a `RangeError`\n * in `get-port` on `port: 0`, boots Vite, and relaxes validation.\n *\n * Beyond refusing `development`, this decides error-response redaction:\n * `errorHandlerMiddleware` returns the real message unless `NODE_ENV` is\n * `production`, where a 5xx becomes `\"Server error\"`. Pass `\"production\"` to\n * assert what a deployed app actually returns to a client.\n */\n nodeEnv?: string;\n\n /** Defaults to in-memory, which is what keeps boot offline. */\n cache?: CacheConfig;\n}\n\n/** Per-request options for the {@link TestApp} HTTP methods. */\nexport interface TestRequestOptions {\n /** A non-string value is JSON-encoded with `content-type: application/json`. */\n body?: unknown;\n /** Merged last, so they win over anything the harness sets. */\n headers?: Record<string, string>;\n /** Same convention as `createMockRequest({ obo })`. */\n obo?: OboOption;\n /** Forwarded to `fetch`. */\n signal?: AbortSignal;\n}\n\n/** A booted test app. */\nexport interface TestApp<T extends Plugins> {\n /**\n * Plugin exports by manifest name. Nested rather than spread because `get` and\n * `delete` are plausible plugin names and would collide with the request methods.\n */\n plugins: AppKitApi<T>;\n /** The same object a handler resolves at runtime. */\n client: WorkspaceClient;\n /** e.g. `http://127.0.0.1:54321`. Throws when `server: false`. */\n baseUrl: string;\n /** The bound ephemeral port. Throws when `server: false`. */\n port: number;\n /** The underlying HTTP server, or `undefined` with `server: false`. */\n server?: Server;\n\n /** Release the app and restore env. Idempotent. */\n close(): Promise<void>;\n [Symbol.asyncDispose](): Promise<void>;\n\n get(path: string, options?: TestRequestOptions): Promise<Response>;\n post(path: string, options?: TestRequestOptions): Promise<Response>;\n put(path: string, options?: TestRequestOptions): Promise<Response>;\n patch(path: string, options?: TestRequestOptions): Promise<Response>;\n delete(path: string, options?: TestRequestOptions): Promise<Response>;\n}\n\n/**\n * Point `ServiceContext.createCallerContext` at the harness's mock so an `obo`\n * request does not construct a real SDK client from `DATABRICKS_HOST`.\n *\n * Mirrors the `createUserContextSpy` in `fixtures.ts`; returns its restore.\n */\nfunction stubUserContext(client: WorkspaceClient): () => void {\n const spy = vi\n .spyOn(ServiceContext, \"createCallerContext\")\n .mockImplementation((token, userId, userName, userEmail) =>\n fakeUserContext(client, ServiceContext.get())(\n token,\n userId,\n userName,\n userEmail,\n ),\n );\n return () => spy.mockRestore();\n}\n\n/**\n * Wait for a server to finish binding and return the port it landed on.\n *\n * Needed with `port: 0`: `start()` returns once `listen()` is invoked, before\n * the bind completes, so `address()` is null until the `listening` event fires.\n * `createTestApp` does this for you — reach for it when hand-rolling a server.\n */\nexport async function getListeningPort(server: Server): Promise<number> {\n const addr = server.address();\n if (addr && typeof addr === \"object\" && typeof addr.port === \"number\") {\n return addr.port;\n }\n await new Promise<void>((resolve, reject) => {\n server.once(\"listening\", () => resolve());\n server.once(\"error\", (err) => reject(err));\n });\n const ready = server.address();\n if (!ready || typeof ready !== \"object\") {\n throw new Error(\"Server is listening but address() returned null\");\n }\n return ready.port;\n}\n\n/**\n * Boot a real app — real Express wiring, routes, and resource validation — with\n * no workspace, credentials, or network. `createTestPluginContext` is cheaper\n * when you only need to unit-test wiring.\n *\n * Does **not** validate config values against `manifest.config.schema`; no\n * runtime validator exists for that.\n *\n * @example\n * ```ts\n * const app = await createTestApp({ plugins: [myPlugin()] });\n * try {\n * const res = await app.post(\"/api/my-plugin/thing\", { body: { q: 1 }, obo: true });\n * await expectStream(res).toEmit(\"status\", \"result\");\n * } finally {\n * await app.close();\n * }\n * ```\n */\nexport async function createTestApp<T extends Plugins>(\n options: CreateTestAppOptions<T> = {},\n): Promise<TestApp<T>> {\n const {\n plugins = [] as unknown as T,\n responses,\n strict,\n client: suppliedClient,\n env = {},\n server: serverOption,\n nodeEnv = \"test\",\n cache,\n } = options;\n\n if (nodeEnv === \"development\") {\n throw new Error(\n 'createTestApp: nodeEnv \"development\" is not supported. Dev mode routes ' +\n \"the harness's ephemeral `port: 0` through get-port, which throws a \" +\n \"RangeError, and it also boots a real Vite dev server, downgrades \" +\n \"resource validation to a warning, and stops filtering dev-only \" +\n \"plugins. Pin a port explicitly with your own server plugin if you \" +\n \"need dev behaviour.\",\n );\n }\n\n // Refused rather than half-supported: AppKit's workspace client, cache and\n // on-behalf-of fake are process-wide, so a second live app cannot own its\n // own. Checked before any mutation, so a refused boot leaves the live app\n // untouched.\n if (harnessAppLive) {\n throw new Error(\n \"createTestApp: a harness app is already open. AppKit's workspace \" +\n \"client, cache, and on-behalf-of fake are process-wide, so a second \" +\n \"app would not receive its own `client`/`responses`, and closing \" +\n \"either would un-fake the other's on-behalf-of path. Close the first \" +\n \"app before booting another — `await using`, or try/finally.\",\n );\n }\n\n // Wholesale rather than a whitelist: plugins read vars we cannot enumerate.\n acquireEnvBaseline();\n\n let app: Awaited<ReturnType<typeof AppKit._createApp>> | undefined;\n let restoreUserContext: (() => void) | undefined;\n\n // Runs the booted plugins' shutdown() hooks and closes the server it started;\n // the harness owns dropping the singletons and restoring env. The `as Any` is\n // the one escape hatch the symbol-keyed teardown forces on the harness.\n const disposeBooted = (a: unknown): Promise<void> => (a as Any)[disposeApp]();\n\n try {\n process.env.NODE_ENV = nodeEnv;\n\n // Redundant while NODE_ENV is pinned, but keeps the throw-on-missing-resource\n // contract if that pin ever changes. No opt-out: the warning path is\n // dev-only, and dev is refused.\n process.env.APPKIT_STRICT_VALIDATION = \"true\";\n\n // The workspace ID short-circuits getWorkspaceId's SCIM probe, which would\n // otherwise show up as an apiClient.request call.\n setupDatabricksEnv({\n DATABRICKS_WORKSPACE_ID: \"test-workspace-id\",\n ...env,\n });\n\n dropCoreSingletons();\n\n // `responses` only seeds the built-in mock, so alongside a caller-supplied\n // client it would silently do nothing. Refuse instead, matching the\n // `server: false` conflict below.\n if (suppliedClient && (responses !== undefined || strict !== undefined)) {\n throw new Error(\n \"createTestApp: `responses` and `strict` configure the built-in mock \" +\n \"client, so they do nothing when you also pass `client`. Drop them \" +\n \"and configure your own client instead.\",\n );\n }\n\n // Boot runs ServiceContext.createContext for real, which reads\n // currentUser.id — the mock's built-in default is what lets it through.\n const client =\n suppliedClient ?? createMockWorkspaceClient({ responses, strict });\n\n // createApp({ client }) installs only the service-principal client. An `obo`\n // request reaches ServiceContext.createCallerContext, which builds a *real*\n // client from process.env.DATABRICKS_HOST — so the user-scoped path is faked\n // here too, or \"no network\" is false the moment a handler calls asUser.\n restoreUserContext = stubUserContext(client);\n\n // createApp never auto-adds a server, so without this there is nothing to\n // fetch. Lazily imported: the plugin runs dotenv.config() at module load, so\n // a static import would mutate a consumer's env on import of this kit.\n const hasServer = plugins.some((p) => p?.name === \"server\");\n if (serverOption === false && hasServer) {\n // The plugin would still bind a socket while the handle denied one existed.\n throw new Error(\n \"createTestApp: `server: false` conflicts with the server plugin in \" +\n \"`plugins`. Drop one — omit `server: false` to use your plugin, or \" +\n \"remove the plugin to boot without a socket.\",\n );\n }\n const bootPlugins = [...plugins] as Plugins;\n if (serverOption !== false && !hasServer) {\n const { server: serverPlugin } = await import(\"../plugins/server\");\n bootPlugins.push(serverPlugin({ port: 0, host: \"127.0.0.1\" }));\n }\n\n // Both extras are required to stay offline: without explicit storage the\n // cache builds its own client and probes Lakebase, and without the opt-out\n // TelemetryReporter fires an apiClient.request on boot.\n app = await AppKit._createApp({\n plugins: bootPlugins as Any,\n client,\n cache: cache ?? {\n storage: new InMemoryStorage({ enabled: true } as Any),\n },\n disableInternalTelemetry: true,\n // The harness boots repeatedly in one process and runs its own teardown,\n // so it must not accumulate SIGTERM/SIGINT handlers across boots.\n installSignalHandlers: false,\n });\n\n const serverExports = (app as Any).server;\n const httpServer: Server | undefined =\n serverOption === false ? undefined : serverExports?.getServer?.();\n const port = httpServer ? await getListeningPort(httpServer) : undefined;\n const baseUrl = port === undefined ? undefined : `http://127.0.0.1:${port}`;\n\n const bootedApp = app;\n let closed: Promise<void> | undefined;\n\n /** Memoized, so repeated calls are safe in nested `finally`s. */\n const close = () => {\n closed ??= (async () => {\n try {\n await disposeBooted(bootedApp);\n } finally {\n dropCoreSingletons();\n restoreUserContext?.();\n releaseEnvBaseline();\n }\n })();\n return closed;\n };\n\n const request = async (\n method: string,\n path: string,\n reqOptions: TestRequestOptions = {},\n ): Promise<Response> => {\n if (baseUrl === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false), so \" +\n `${method} ${path} cannot be issued.`,\n );\n }\n\n const headers: Record<string, string> = {};\n if (reqOptions.obo) {\n Object.assign(headers, oboHeaders(reqOptions.obo));\n }\n\n let body: string | undefined;\n if (reqOptions.body !== undefined) {\n if (typeof reqOptions.body === \"string\") {\n body = reqOptions.body;\n } else {\n body = JSON.stringify(reqOptions.body);\n headers[\"content-type\"] = \"application/json\";\n }\n }\n\n // Caller headers last, so an explicit content-type or identity wins.\n // Lowercased first: `Headers` comma-joins case variants instead of\n // replacing, so a mixed-case override would corrupt the value into\n // \"alice, bob\" rather than win.\n for (const [name, value] of Object.entries(reqOptions.headers ?? {})) {\n headers[name.toLowerCase()] = value;\n }\n\n return fetch(new URL(path, baseUrl), {\n method,\n headers,\n body,\n signal: reqOptions.signal,\n });\n };\n\n return {\n plugins: bootedApp,\n client,\n get baseUrl() {\n if (baseUrl === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false).\",\n );\n }\n return baseUrl;\n },\n get port() {\n if (port === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false).\",\n );\n }\n return port;\n },\n server: httpServer,\n close,\n [Symbol.asyncDispose]: close,\n get: (path, o) => request(\"GET\", path, o),\n post: (path, o) => request(\"POST\", path, o),\n put: (path, o) => request(\"PUT\", path, o),\n patch: (path, o) => request(\"PATCH\", path, o),\n delete: (path, o) => request(\"DELETE\", path, o),\n };\n } catch (err) {\n // Teardown must run from the failure path too, or the boot leaks env\n // mutations and singletons into every later test in the file.\n if (app) {\n try {\n await disposeBooted(app);\n } catch {\n // The boot error is the interesting one; don't let teardown mask it.\n }\n }\n // dispose() no longer drops the singletons, and a failure before boot may\n // have left a partial init — drop unconditionally either way.\n dropCoreSingletons();\n restoreUserContext?.();\n releaseEnvBaseline();\n throw err;\n }\n}\n"],"mappings":"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"}
|
|
@@ -69,17 +69,8 @@ interface RecordedToolCall {
|
|
|
69
69
|
/** The abort signal `executeTool` composed (timeout ∘ caller). */
|
|
70
70
|
signal?: AbortSignal;
|
|
71
71
|
/**
|
|
72
|
-
* Whether
|
|
73
|
-
*
|
|
74
|
-
* for a tool reached through `executeTool` this is `true` — and, because the
|
|
75
|
-
* fake `asUser` enforces the same token precondition as the real
|
|
76
|
-
* {@link Plugin.asUser}, a request with no `x-forwarded-access-token` makes
|
|
77
|
-
* that call **throw** rather than record `asUser: true`. The meaningful
|
|
78
|
-
* assertions are therefore: a well-formed request records `asUser: true`
|
|
79
|
-
* with {@link userId} set, and a token-less request rejects.
|
|
80
|
-
*
|
|
81
|
-
* The fake replicates the token precondition only, not the real dev-mode
|
|
82
|
-
* OTel `isDevOboFallback()` marker — assert OBO here, not via that flag.
|
|
72
|
+
* Whether dispatch ran in a caller scope, inherited or established from
|
|
73
|
+
* the forwarded request credentials.
|
|
83
74
|
*/
|
|
84
75
|
asUser: boolean;
|
|
85
76
|
/**
|
|
@@ -161,7 +152,7 @@ interface TestPluginContext {
|
|
|
161
152
|
* workspace, no OpenTelemetry pipeline, no network.
|
|
162
153
|
*
|
|
163
154
|
* The context is the *real* class, so route buffering, the tool registry,
|
|
164
|
-
* timeout composition, and
|
|
155
|
+
* timeout composition, and ambient caller inheritance all run for real.
|
|
165
156
|
* Only three edges are faked, matching the seams the class actually has:
|
|
166
157
|
*
|
|
167
158
|
* - **Telemetry** is a mock provider injected into the context (the one
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-plugin-context.d.ts","names":[],"sources":["../../src/testing/test-plugin-context.ts"],"mappings":";;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"test-plugin-context.d.ts","names":[],"sources":["../../src/testing/test-plugin-context.ts"],"mappings":";;;;;;;;;;;;;;;AAe+C;;KAgB1C,aAAA,GACD,MAAA;;;AAcJ;;;;;KAAY,gBAAA,GACR,aAAA,KACE,IAAA,WAAe,MAAA,GAAS,WAAA;;;;;AAY9B;;;;;;KAAY,aAAA,GAAgB,MAAA,SAAe,MAAA,SAAe,gBAAA;;;;;;UAOzC,wBAAA;EAAA;;;;EAKf,SAAA,GAAY,MAAA;EAAA;;;;;EAMZ,GAAA,GAAM,MAAA;EASS;;;;EAJf,MAAA;AAAA;;UAIe,gBAAA;EAQN;EANT,MAAA;EAiBA;EAfA,IAAA;EAeM;EAbN,IAAA;EAiB4B;EAf5B,MAAA,GAAS,WAAA;EAwBuB;;;;EAnBhC,MAAA;EAmBkB;;;AAIpB;;EAjBE,MAAA;AAAA;;UAIe,aAAA;EACf,MAAA;EACA,IAAA;EAeO;;;AAOT;;;EAfE,QAAA,EAAU,OAAA,CAAQ,cAAA;AAAA;;UAIH,YAAA;EAgCQ;EA9BvB,cAAA,EAAgB,OAAA,CAAQ,OAAA;EAmC6B;EAjCrD,KAAA,EAAO,mBAAA;AAAA;;;;;UAOQ,iBAAA;EAEf;EAAA,GAAA,EAAK,aAAA;EAOL;;;;;;EAAA,SAAA,EAAW,UAAA;EAYA;;;;EAPX,SAAA,EAAW,gBAAA;EAY0C;;;;EAPrD,MAAA,EAAQ,aAAA;EAeyB;EAbjC,SAAA,EAAW,GAAA,SAAY,YAAA;EAac;;;;EARrC,gBAAA,CAAiB,IAAA,UAAc,KAAA,EAAO,MAAA,SAAe,gBAAA;EAwDvC;;;;;;;EAhDd,MAAA,WAAiB,MAAA,EAAQ,MAAA,EAAQ,CAAA,GAAI,OAAA,CAAQ,CAAA;EAiDtC;;;;;;;EAzCP,OAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCc,uBAAA,CACd,KAAA,GAAO,aAAA,EACP,OAAA,GAAU,wBAAA,GACT,iBAAA"}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import "../errors/index.js";
|
|
1
|
+
import { getCallerContext } from "../context/execution-context.js";
|
|
3
2
|
import { InMemoryStorage } from "../cache/storage/memory.js";
|
|
4
3
|
import "../cache/storage/index.js";
|
|
5
4
|
import { CacheManager } from "../cache/index.js";
|
|
5
|
+
import "../context/index.js";
|
|
6
|
+
import { createRequestScope } from "../context/request-scope.js";
|
|
6
7
|
import { PluginContext, isToolProvider } from "../core/plugin-context.js";
|
|
7
8
|
import { createMockWorkspaceClient } from "./mock-workspace-client.js";
|
|
8
|
-
import { applyEnv, createMockTelemetry, mockServiceContext } from "./fixtures.js";
|
|
9
|
+
import { applyEnv, createMockTelemetry, fakeUserContext, mockServiceContext } from "./fixtures.js";
|
|
9
10
|
import { afterEach, onTestFinished } from "vitest";
|
|
10
11
|
|
|
11
12
|
//#region src/testing/test-plugin-context.ts
|
|
@@ -14,7 +15,7 @@ import { afterEach, onTestFinished } from "vitest";
|
|
|
14
15
|
* workspace, no OpenTelemetry pipeline, no network.
|
|
15
16
|
*
|
|
16
17
|
* The context is the *real* class, so route buffering, the tool registry,
|
|
17
|
-
* timeout composition, and
|
|
18
|
+
* timeout composition, and ambient caller inheritance all run for real.
|
|
18
19
|
* Only three edges are faked, matching the seams the class actually has:
|
|
19
20
|
*
|
|
20
21
|
* - **Telemetry** is a mock provider injected into the context (the one
|
|
@@ -50,9 +51,13 @@ function createTestPluginContext(fakes = {}, options) {
|
|
|
50
51
|
if (!options) return createTestPluginContextSync(fakes);
|
|
51
52
|
return createTestPluginContextWithOptions(fakes, options);
|
|
52
53
|
}
|
|
53
|
-
function createTestPluginContextSync(fakes) {
|
|
54
|
+
function createTestPluginContextSync(fakes, client = createMockWorkspaceClient()) {
|
|
54
55
|
const telemetry = createMockTelemetry();
|
|
55
|
-
const
|
|
56
|
+
const createCallerContext = fakeUserContext(client, { workspaceId: Promise.resolve("test-workspace-id") });
|
|
57
|
+
const ctx = new PluginContext({
|
|
58
|
+
telemetry,
|
|
59
|
+
createCallerContext
|
|
60
|
+
});
|
|
56
61
|
const toolCalls = [];
|
|
57
62
|
const routes = [];
|
|
58
63
|
const providers = /* @__PURE__ */ new Map();
|
|
@@ -99,18 +104,20 @@ function createTestPluginContextSync(fakes) {
|
|
|
99
104
|
};
|
|
100
105
|
const base = {
|
|
101
106
|
getAgentTools: () => record.tools,
|
|
102
|
-
executeAgentTool: (toolName, args, signal) =>
|
|
107
|
+
executeAgentTool: (toolName, args, signal) => {
|
|
108
|
+
const caller = getCallerContext();
|
|
109
|
+
return resolve(toolName, args, signal, !!caller, caller?.principal.userId);
|
|
110
|
+
}
|
|
103
111
|
};
|
|
104
112
|
const asUser = (req) => {
|
|
105
113
|
record.asUserRequests.push(req);
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (token && !userId && !isDev) throw AuthenticationError.missingUserId();
|
|
114
|
+
const scope = createRequestScope(req, [], {
|
|
115
|
+
createCaller: createCallerContext,
|
|
116
|
+
legacy: true
|
|
117
|
+
});
|
|
111
118
|
return {
|
|
112
119
|
...base,
|
|
113
|
-
executeAgentTool: (toolName, args, signal) =>
|
|
120
|
+
executeAgentTool: (toolName, args, signal) => scope.run(() => base.executeAgentTool(toolName, args, signal))
|
|
114
121
|
};
|
|
115
122
|
};
|
|
116
123
|
const provider = {
|
|
@@ -143,12 +150,13 @@ function createTestPluginContextSync(fakes) {
|
|
|
143
150
|
}
|
|
144
151
|
function createTestPluginContextWithOptions(fakes, options) {
|
|
145
152
|
const { responses = {}, env: envVars = {}, strict = false } = options;
|
|
146
|
-
const
|
|
153
|
+
const client = createMockWorkspaceClient({
|
|
147
154
|
responses,
|
|
148
155
|
strict
|
|
149
|
-
})
|
|
156
|
+
});
|
|
157
|
+
const serviceContextMock = mockServiceContext({ serviceDatabricksClient: client });
|
|
150
158
|
const restoreEnv = applyEnv(envVars);
|
|
151
|
-
const base = createTestPluginContextSync(fakes);
|
|
159
|
+
const base = createTestPluginContextSync(fakes, client);
|
|
152
160
|
let hasRestored = false;
|
|
153
161
|
const restore = () => {
|
|
154
162
|
if (hasRestored) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-plugin-context.js","names":[],"sources":["../../src/testing/test-plugin-context.ts"],"sourcesContent":["import type express from \"express\";\nimport type {\n AgentToolDefinition,\n BasePlugin,\n IAppRequest,\n ToolProvider,\n} from \"shared\";\nimport { afterEach, onTestFinished } from \"vitest\";\n\nimport { CacheManager } from \"../cache\";\nimport { InMemoryStorage } from \"../cache/storage\";\nimport { isToolProvider, PluginContext } from \"../core/plugin-context\";\nimport { AuthenticationError } from \"../errors\";\nimport type { Plugin } from \"../plugin\";\nimport type { ITelemetry } from \"../telemetry\";\nimport { applyEnv, createMockTelemetry, mockServiceContext } from \"./fixtures\";\nimport { createMockWorkspaceClient } from \"./mock-workspace-client\";\n\n/**\n * A concrete (non-function) fake tool response — returned as-is. Covers the\n * JSON-serializable shapes a tool call yields (rows, objects, primitives,\n * nullish). A bare `unknown` is intentionally not used here: unioned with the\n * function form below it would collapse to `unknown` and strip contextual\n * types from the callback's parameters.\n */\ntype FakeToolValue =\n | Record<string, unknown>\n | unknown[]\n | string\n | number\n | boolean\n | null;\n\n/**\n * A canned tool response. Either a static {@link FakeToolValue} returned\n * as-is, or a function of the call arguments (and the abort signal\n * `PluginContext.executeTool` composes) so a fake can assert on inputs or\n * simulate slow/aborting work. Returning a promise is supported (the return\n * type is intentionally `unknown`, which also covers `Promise<...>`).\n */\nexport type FakeToolResponse =\n | FakeToolValue\n | ((args: unknown, signal?: AbortSignal) => unknown);\n\n/**\n * Fake connector responses, keyed by plugin name and then tool name:\n *\n * ```ts\n * createTestPluginContext({ analytics: { query: fixtureRows } });\n * ```\n *\n * Each top-level key registers a fake {@link ToolProvider} under that plugin\n * name; each inner key becomes a tool that returns the mapped response.\n */\nexport type FakeProviders = Record<string, Record<string, FakeToolResponse>>;\n\n/**\n * Options for {@link createTestPluginContext} when called with a second parameter.\n * When provided, `createTestPluginContext` installs a service context seeded\n * from a mock workspace client, plus optional environment variables.\n */\nexport interface TestPluginContextOptions {\n /**\n * Responses keyed by dotted path (`\"jobs.getRun\"`) for the mocked workspace\n * client. Passed directly to {@link createMockWorkspaceClient}.\n */\n responses?: Record<string, unknown>;\n /**\n * Environment variables to set for the test. Captured on entry, restored\n * (or deleted if they were unset) on exit via an `afterEach` hook and/or\n * explicit {@link TestPluginContext.restore}.\n */\n env?: Record<string, string>;\n /**\n * If `true`, throw when a workspace client path with no declared response is\n * called, instead of resolving `undefined`. Defaults to `false` (never crash).\n */\n strict?: boolean;\n}\n\n/** A single dispatch observed by a fake provider. */\nexport interface RecordedToolCall {\n /** Registered plugin name (the key in {@link FakeProviders}). */\n plugin: string;\n /** Tool name passed to `executeAgentTool`. */\n tool: string;\n /** Arguments the tool received. */\n args: unknown;\n /** The abort signal `executeTool` composed (timeout ∘ caller). */\n signal?: AbortSignal;\n /**\n * Whether the dispatch was resolved through the on-behalf-of (`asUser`)\n * path. `PluginContext.executeTool` always calls `provider.asUser(req)`, so\n * for a tool reached through `executeTool` this is `true` — and, because the\n * fake `asUser` enforces the same token precondition as the real\n * {@link Plugin.asUser}, a request with no `x-forwarded-access-token` makes\n * that call **throw** rather than record `asUser: true`. The meaningful\n * assertions are therefore: a well-formed request records `asUser: true`\n * with {@link userId} set, and a token-less request rejects.\n *\n * The fake replicates the token precondition only, not the real dev-mode\n * OTel `isDevOboFallback()` marker — assert OBO here, not via that flag.\n */\n asUser: boolean;\n /**\n * The user the on-behalf-of scope resolved to (from `x-forwarded-user`), or\n * `undefined` for a service-principal call (`asUser: false`). Lets a test\n * assert the tool ran as the expected end user, not just that OBO was used.\n */\n userId?: string;\n}\n\n/** A single route registered through the context's `addRoute`/`addMiddleware`. */\nexport interface RecordedRoute {\n method: string;\n path: string;\n /**\n * The raw handlers as passed to `addRoute` — before `PluginContext` wraps\n * them with `forwardAsyncErrors`. Recorded here so aliasing assertions\n * (\"both routes mount the same handler\") can compare the original\n * references, which the wrapped express-level handlers no longer share.\n */\n handlers: express.RequestHandler[];\n}\n\n/** A fake tool provider registered on a mock context. */\nexport interface FakeProvider {\n /** Every `asUser(req)` the context resolved for this provider. */\n asUserRequests: express.Request[];\n /** Definitions returned from `getAgentTools()`. */\n tools: AgentToolDefinition[];\n}\n\n/**\n * The result of {@link createTestPluginContext}: the real `PluginContext` plus the\n * seams a test needs to drive and inspect it.\n */\nexport interface TestPluginContext {\n /** The real {@link PluginContext}, constructed with mock telemetry. */\n ctx: PluginContext;\n /**\n * The mock telemetry provider injected into the {@link PluginContext}.\n * Captures the spans the *context* opens (notably `executeTool`) — not the\n * plugin's own spans: `attachContext` rebuilds the plugin's `this.telemetry`\n * from the real `TelemetryManager`, so plugin-internal spans do not land here.\n */\n telemetry: ITelemetry;\n /**\n * Tool dispatches observed across all fake providers, in call order. Live —\n * read it after the action under test runs.\n */\n toolCalls: RecordedToolCall[];\n /**\n * Routes registered through the context, in registration order. Live —\n * populated when the plugin calls `addRoute`/`addMiddleware`.\n */\n routes: RecordedRoute[];\n /** Fake providers by plugin name, for direct assertions. */\n providers: Map<string, FakeProvider>;\n /**\n * Register (or replace) a fake tool provider after construction.\n * Same shape as one {@link FakeProviders} entry.\n */\n registerProvider(name: string, tools: Record<string, FakeToolResponse>): void;\n /**\n * Attach this context to a plugin the production way: seed an in-memory\n * cache (if AppKit hasn't already), then call `plugin.attachContext`, which\n * also rebuilds the plugin's telemetry and flips `isReady` to `true`. Await\n * it before exercising handlers that read `this.context`, `this.cache`, or\n * gate on `isReady`. Returns the same plugin for chaining.\n */\n attach<P extends Plugin>(plugin: P): Promise<P>;\n /**\n * Restore the service context and environment variables to their pre-test state.\n * Called automatically via `afterEach` when options were provided to\n * `createTestPluginContext`. Can also be called explicitly for escape hatches\n * (e.g., cleanup inside a test body). Idempotent — safe to call multiple times.\n * Only present if the context was created with options.\n */\n restore?: () => void;\n}\n\n/**\n * Build a real {@link PluginContext} with faked edges for testing — no live\n * workspace, no OpenTelemetry pipeline, no network.\n *\n * The context is the *real* class, so route buffering, the tool registry,\n * timeout composition, and the on-behalf-of (`asUser`) path all run for real.\n * Only three edges are faked, matching the seams the class actually has:\n *\n * - **Telemetry** is a mock provider injected into the context (the one\n * injectable production seam); it records the context's own spans, not the\n * plugin's.\n * - **Tool providers** are fakes registered through the existing public\n * `registerToolProvider`; their `asUser`/`executeAgentTool` are recorded.\n * - **Routes** are captured by wrapping the public `addRoute`/`addMiddleware`.\n *\n * Nothing about `PluginContext` is reimplemented.\n *\n * @param fakes - Canned tool responses keyed by plugin then tool name.\n * @param options - When provided, installs a mock workspace client seeded from\n * `responses`, mocks the service context, and sets `env`. Omit it for the\n * original behavior.\n *\n * @example\n * ```ts\n * // No options\n * const mock = createTestPluginContext({ analytics: { query: fixtureRows } });\n * await mock.attach(agentsPlugin);\n * // ...exercise a handler that dispatches analytics.query...\n * expect(mock.toolCalls[0]).toMatchObject({ plugin: \"analytics\", asUser: true });\n *\n * // With options — installs service context + seeded client\n * const mock = createTestPluginContext(\n * {},\n * { responses: { \"jobs.getRun\": { state: \"DONE\" } } },\n * );\n * ```\n */\nexport function createTestPluginContext(\n fakes: FakeProviders = {},\n options?: TestPluginContextOptions,\n): TestPluginContext {\n // No options: original behavior.\n if (!options) {\n return createTestPluginContextSync(fakes);\n }\n\n // Options provided: install the seeded client, service context, and scoped env.\n return createTestPluginContextWithOptions(fakes, options);\n}\n\nfunction createTestPluginContextSync(fakes: FakeProviders): TestPluginContext {\n const telemetry = createMockTelemetry();\n const ctx = new PluginContext({ telemetry });\n\n const toolCalls: RecordedToolCall[] = [];\n const routes: RecordedRoute[] = [];\n const providers = new Map<string, FakeProvider>();\n\n // Wrap the public route API so raw (pre-wrap) handlers are inspectable while\n // the real buffering/flush path stays intact.\n const realAddRoute = ctx.addRoute.bind(ctx);\n ctx.addRoute = (\n method: string,\n path: string,\n ...handlers: express.RequestHandler[]\n ): void => {\n routes.push({ method, path, handlers });\n realAddRoute(method, path, ...handlers);\n };\n const realAddMiddleware = ctx.addMiddleware.bind(ctx);\n ctx.addMiddleware = (\n path: string,\n ...handlers: express.RequestHandler[]\n ): void => {\n routes.push({ method: \"use\", path, handlers });\n realAddMiddleware(path, ...handlers);\n };\n\n function registerProvider(\n name: string,\n tools: Record<string, FakeToolResponse>,\n ): void {\n const record: FakeProvider = {\n asUserRequests: [],\n tools: Object.keys(tools).map((toolName) => ({\n name: toolName,\n description: `Fake tool ${name}.${toolName}`,\n parameters: { type: \"object\" },\n })),\n };\n providers.set(name, record);\n\n const resolve = async (\n toolName: string,\n args: unknown,\n signal: AbortSignal | undefined,\n asUser: boolean,\n userId: string | undefined,\n ): Promise<unknown> => {\n toolCalls.push({\n plugin: name,\n tool: toolName,\n args,\n signal,\n asUser,\n userId,\n });\n // `Object.hasOwn`, not `tools[toolName] === undefined`: a tool named\n // \"constructor\"/\"toString\"/etc. would otherwise resolve to an inherited\n // Object.prototype method and be invoked instead of reported missing.\n if (!Object.hasOwn(tools, toolName)) {\n throw new Error(\n `createTestPluginContext: plugin \"${name}\" has no fake tool \"${toolName}\". ` +\n `Available: ${Object.keys(tools).join(\", \") || \"(none)\"}`,\n );\n }\n const response = tools[toolName];\n return typeof response === \"function\"\n ? await (response as (a: unknown, s?: AbortSignal) => unknown)(\n args,\n signal,\n )\n : response;\n };\n\n const base: ToolProvider = {\n getAgentTools: () => record.tools,\n executeAgentTool: (toolName, args, signal) =>\n resolve(toolName, args, signal, false, undefined),\n };\n\n // Mirror the real `Plugin.asUser` token precondition (plugin.ts) so the\n // recorded `asUser` flag reflects genuine user-scope resolution rather than\n // being unconditionally true: a request with no `x-forwarded-access-token`\n // throws `missingToken` (production behavior), except in development where\n // the real code skips impersonation. This is edge-faking of asUser's\n // *contract*, not a reimplementation of `runInUserContext`/`ServiceContext`.\n //\n // Deliberately NOT reproduced: the real dev-mode path sets an OTel\n // `DEV_OBO_FALLBACK_KEY` marker (read by `isDevOboFallback()`). That key is\n // module-private telemetry plumbing; assert OBO via the recorded\n // `asUser`/`userId` fields, not `isDevOboFallback()`.\n const asUser = (req: IAppRequest): ToolProvider => {\n record.asUserRequests.push(req as express.Request);\n const token = (req as express.Request)\n .header?.(\"x-forwarded-access-token\")\n ?.trim();\n const userId = (req as express.Request)\n .header?.(\"x-forwarded-user\")\n ?.trim();\n const isDev = process.env.NODE_ENV === \"development\";\n\n if (!token && !isDev) {\n throw AuthenticationError.missingToken(\"user token\");\n }\n if (token && !userId && !isDev) {\n throw AuthenticationError.missingUserId();\n }\n\n return {\n ...base,\n executeAgentTool: (toolName, args, signal) =>\n resolve(toolName, args, signal, true, userId),\n };\n };\n\n // `registerToolProvider` expects the full ToolProviderPlugin shape\n // (BasePlugin & ToolProvider & { asUser }). executeTool only ever calls\n // `asUser` and `executeAgentTool`; the remaining BasePlugin surface is\n // never touched for a registered provider, so a focused fake plus a cast\n // is sufficient and avoids reimplementing a plugin.\n const provider = {\n name,\n setup: async () => {},\n injectRoutes: () => {},\n getEndpoints: () => ({}),\n ...base,\n asUser,\n } as unknown as BasePlugin &\n ToolProvider & {\n asUser: (req: IAppRequest) => ToolProvider;\n };\n\n ctx.registerToolProvider(name, provider);\n }\n\n for (const [name, tools] of Object.entries(fakes)) {\n registerProvider(name, tools);\n }\n\n async function attach<P extends Plugin>(plugin: P): Promise<P> {\n // Seed a real in-memory cache if AppKit hasn't initialized one. Idempotent:\n // getInstance returns any existing singleton (e.g. one a suite already set\n // up) and ignores the storage argument in that case.\n if (!cacheReady()) {\n await CacheManager.getInstance({ storage: new InMemoryStorage({}) });\n }\n plugin.attachContext({ context: ctx });\n\n // Mirror what AppKit core does after attachContext (core/appkit.ts): put\n // the plugin in the registry so `getPlugins()`/`getPluginNames()`/\n // `hasPlugin()` and any sibling-plugin lookup behave as in production. Only\n // register it as a tool provider when it actually is one AND its name does\n // not collide with an injected fake — the fakes are the authored test\n // doubles and must not be overwritten by the plugin under test.\n ctx.registerPlugin(plugin.name, plugin as unknown as BasePlugin);\n if (isToolProvider(plugin) && !providers.has(plugin.name)) {\n ctx.registerToolProvider(\n plugin.name,\n plugin as unknown as Parameters<typeof ctx.registerToolProvider>[1],\n );\n }\n return plugin;\n }\n\n return {\n ctx,\n telemetry,\n toolCalls,\n routes,\n providers,\n registerProvider,\n attach,\n };\n}\n\nfunction createTestPluginContextWithOptions(\n fakes: FakeProviders,\n options: TestPluginContextOptions,\n): TestPluginContext {\n const { responses = {}, env: envVars = {}, strict = false } = options;\n\n // Build a mock workspace client seeded from responses\n const client = createMockWorkspaceClient({\n responses,\n strict,\n });\n\n // Install the mock service context with the seeded client\n const serviceContextMock = mockServiceContext({\n serviceDatabricksClient: client,\n });\n\n // Set env (captured for restore) via the shared helper.\n const restoreEnv = applyEnv(envVars);\n\n // Create the base context (without options this time, since we're handling everything)\n const base = createTestPluginContextSync(fakes);\n\n // Restore function: restores env and service context (idempotent)\n let hasRestored = false;\n const restore = () => {\n if (hasRestored) return;\n hasRestored = true;\n restoreEnv();\n serviceContextMock.restore();\n };\n\n // Auto-restore after the current test. This helper is documented and used\n // from inside a test body, where a runtime-registered `afterEach` does NOT run\n // for that test (Vitest only collects `afterEach` before the test runs) — the\n // reason the old `afterEach` here silently leaked. `onTestFinished` is the hook\n // built for runtime registration and fires after the creating test. If called\n // at collection scope instead, it throws, so fall back to `afterEach` there.\n try {\n onTestFinished(() => {\n restore();\n });\n } catch {\n afterEach(() => {\n restore();\n });\n }\n\n // Return the context with the restore method\n return {\n ...base,\n restore,\n };\n}\n\nfunction cacheReady(): boolean {\n try {\n CacheManager.getInstanceSync();\n return true;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"test-plugin-context.js","names":[],"sources":["../../src/testing/test-plugin-context.ts"],"sourcesContent":["import type express from \"express\";\nimport type {\n AgentToolDefinition,\n BasePlugin,\n IAppRequest,\n ToolProvider,\n} from \"shared\";\nimport { afterEach, onTestFinished } from \"vitest\";\n\nimport { CacheManager } from \"../cache\";\nimport { InMemoryStorage } from \"../cache/storage\";\nimport { getCallerContext } from \"../context\";\nimport { createRequestScope } from \"../context/request-scope\";\nimport { isToolProvider, PluginContext } from \"../core/plugin-context\";\nimport type { Plugin } from \"../plugin\";\nimport type { ITelemetry } from \"../telemetry\";\nimport {\n applyEnv,\n createMockTelemetry,\n fakeUserContext,\n mockServiceContext,\n} from \"./fixtures\";\nimport { createMockWorkspaceClient } from \"./mock-workspace-client\";\n\n/**\n * A concrete (non-function) fake tool response — returned as-is. Covers the\n * JSON-serializable shapes a tool call yields (rows, objects, primitives,\n * nullish). A bare `unknown` is intentionally not used here: unioned with the\n * function form below it would collapse to `unknown` and strip contextual\n * types from the callback's parameters.\n */\ntype FakeToolValue =\n | Record<string, unknown>\n | unknown[]\n | string\n | number\n | boolean\n | null;\n\n/**\n * A canned tool response. Either a static {@link FakeToolValue} returned\n * as-is, or a function of the call arguments (and the abort signal\n * `PluginContext.executeTool` composes) so a fake can assert on inputs or\n * simulate slow/aborting work. Returning a promise is supported (the return\n * type is intentionally `unknown`, which also covers `Promise<...>`).\n */\nexport type FakeToolResponse =\n | FakeToolValue\n | ((args: unknown, signal?: AbortSignal) => unknown);\n\n/**\n * Fake connector responses, keyed by plugin name and then tool name:\n *\n * ```ts\n * createTestPluginContext({ analytics: { query: fixtureRows } });\n * ```\n *\n * Each top-level key registers a fake {@link ToolProvider} under that plugin\n * name; each inner key becomes a tool that returns the mapped response.\n */\nexport type FakeProviders = Record<string, Record<string, FakeToolResponse>>;\n\n/**\n * Options for {@link createTestPluginContext} when called with a second parameter.\n * When provided, `createTestPluginContext` installs a service context seeded\n * from a mock workspace client, plus optional environment variables.\n */\nexport interface TestPluginContextOptions {\n /**\n * Responses keyed by dotted path (`\"jobs.getRun\"`) for the mocked workspace\n * client. Passed directly to {@link createMockWorkspaceClient}.\n */\n responses?: Record<string, unknown>;\n /**\n * Environment variables to set for the test. Captured on entry, restored\n * (or deleted if they were unset) on exit via an `afterEach` hook and/or\n * explicit {@link TestPluginContext.restore}.\n */\n env?: Record<string, string>;\n /**\n * If `true`, throw when a workspace client path with no declared response is\n * called, instead of resolving `undefined`. Defaults to `false` (never crash).\n */\n strict?: boolean;\n}\n\n/** A single dispatch observed by a fake provider. */\nexport interface RecordedToolCall {\n /** Registered plugin name (the key in {@link FakeProviders}). */\n plugin: string;\n /** Tool name passed to `executeAgentTool`. */\n tool: string;\n /** Arguments the tool received. */\n args: unknown;\n /** The abort signal `executeTool` composed (timeout ∘ caller). */\n signal?: AbortSignal;\n /**\n * Whether dispatch ran in a caller scope, inherited or established from\n * the forwarded request credentials.\n */\n asUser: boolean;\n /**\n * The user the on-behalf-of scope resolved to (from `x-forwarded-user`), or\n * `undefined` for a service-principal call (`asUser: false`). Lets a test\n * assert the tool ran as the expected end user, not just that OBO was used.\n */\n userId?: string;\n}\n\n/** A single route registered through the context's `addRoute`/`addMiddleware`. */\nexport interface RecordedRoute {\n method: string;\n path: string;\n /**\n * The raw handlers as passed to `addRoute` — before `PluginContext` wraps\n * them with `forwardAsyncErrors`. Recorded here so aliasing assertions\n * (\"both routes mount the same handler\") can compare the original\n * references, which the wrapped express-level handlers no longer share.\n */\n handlers: express.RequestHandler[];\n}\n\n/** A fake tool provider registered on a mock context. */\nexport interface FakeProvider {\n /** Every `asUser(req)` the context resolved for this provider. */\n asUserRequests: express.Request[];\n /** Definitions returned from `getAgentTools()`. */\n tools: AgentToolDefinition[];\n}\n\n/**\n * The result of {@link createTestPluginContext}: the real `PluginContext` plus the\n * seams a test needs to drive and inspect it.\n */\nexport interface TestPluginContext {\n /** The real {@link PluginContext}, constructed with mock telemetry. */\n ctx: PluginContext;\n /**\n * The mock telemetry provider injected into the {@link PluginContext}.\n * Captures the spans the *context* opens (notably `executeTool`) — not the\n * plugin's own spans: `attachContext` rebuilds the plugin's `this.telemetry`\n * from the real `TelemetryManager`, so plugin-internal spans do not land here.\n */\n telemetry: ITelemetry;\n /**\n * Tool dispatches observed across all fake providers, in call order. Live —\n * read it after the action under test runs.\n */\n toolCalls: RecordedToolCall[];\n /**\n * Routes registered through the context, in registration order. Live —\n * populated when the plugin calls `addRoute`/`addMiddleware`.\n */\n routes: RecordedRoute[];\n /** Fake providers by plugin name, for direct assertions. */\n providers: Map<string, FakeProvider>;\n /**\n * Register (or replace) a fake tool provider after construction.\n * Same shape as one {@link FakeProviders} entry.\n */\n registerProvider(name: string, tools: Record<string, FakeToolResponse>): void;\n /**\n * Attach this context to a plugin the production way: seed an in-memory\n * cache (if AppKit hasn't already), then call `plugin.attachContext`, which\n * also rebuilds the plugin's telemetry and flips `isReady` to `true`. Await\n * it before exercising handlers that read `this.context`, `this.cache`, or\n * gate on `isReady`. Returns the same plugin for chaining.\n */\n attach<P extends Plugin>(plugin: P): Promise<P>;\n /**\n * Restore the service context and environment variables to their pre-test state.\n * Called automatically via `afterEach` when options were provided to\n * `createTestPluginContext`. Can also be called explicitly for escape hatches\n * (e.g., cleanup inside a test body). Idempotent — safe to call multiple times.\n * Only present if the context was created with options.\n */\n restore?: () => void;\n}\n\n/**\n * Build a real {@link PluginContext} with faked edges for testing — no live\n * workspace, no OpenTelemetry pipeline, no network.\n *\n * The context is the *real* class, so route buffering, the tool registry,\n * timeout composition, and ambient caller inheritance all run for real.\n * Only three edges are faked, matching the seams the class actually has:\n *\n * - **Telemetry** is a mock provider injected into the context (the one\n * injectable production seam); it records the context's own spans, not the\n * plugin's.\n * - **Tool providers** are fakes registered through the existing public\n * `registerToolProvider`; their `asUser`/`executeAgentTool` are recorded.\n * - **Routes** are captured by wrapping the public `addRoute`/`addMiddleware`.\n *\n * Nothing about `PluginContext` is reimplemented.\n *\n * @param fakes - Canned tool responses keyed by plugin then tool name.\n * @param options - When provided, installs a mock workspace client seeded from\n * `responses`, mocks the service context, and sets `env`. Omit it for the\n * original behavior.\n *\n * @example\n * ```ts\n * // No options\n * const mock = createTestPluginContext({ analytics: { query: fixtureRows } });\n * await mock.attach(agentsPlugin);\n * // ...exercise a handler that dispatches analytics.query...\n * expect(mock.toolCalls[0]).toMatchObject({ plugin: \"analytics\", asUser: true });\n *\n * // With options — installs service context + seeded client\n * const mock = createTestPluginContext(\n * {},\n * { responses: { \"jobs.getRun\": { state: \"DONE\" } } },\n * );\n * ```\n */\nexport function createTestPluginContext(\n fakes: FakeProviders = {},\n options?: TestPluginContextOptions,\n): TestPluginContext {\n // No options: original behavior.\n if (!options) {\n return createTestPluginContextSync(fakes);\n }\n\n // Options provided: install the seeded client, service context, and scoped env.\n return createTestPluginContextWithOptions(fakes, options);\n}\n\nfunction createTestPluginContextSync(\n fakes: FakeProviders,\n client = createMockWorkspaceClient(),\n): TestPluginContext {\n const telemetry = createMockTelemetry();\n const createCallerContext = fakeUserContext(client, {\n workspaceId: Promise.resolve(\"test-workspace-id\"),\n });\n const ctx = new PluginContext({ telemetry, createCallerContext });\n\n const toolCalls: RecordedToolCall[] = [];\n const routes: RecordedRoute[] = [];\n const providers = new Map<string, FakeProvider>();\n\n // Wrap the public route API so raw (pre-wrap) handlers are inspectable while\n // the real buffering/flush path stays intact.\n const realAddRoute = ctx.addRoute.bind(ctx);\n ctx.addRoute = (\n method: string,\n path: string,\n ...handlers: express.RequestHandler[]\n ): void => {\n routes.push({ method, path, handlers });\n realAddRoute(method, path, ...handlers);\n };\n const realAddMiddleware = ctx.addMiddleware.bind(ctx);\n ctx.addMiddleware = (\n path: string,\n ...handlers: express.RequestHandler[]\n ): void => {\n routes.push({ method: \"use\", path, handlers });\n realAddMiddleware(path, ...handlers);\n };\n\n function registerProvider(\n name: string,\n tools: Record<string, FakeToolResponse>,\n ): void {\n const record: FakeProvider = {\n asUserRequests: [],\n tools: Object.keys(tools).map((toolName) => ({\n name: toolName,\n description: `Fake tool ${name}.${toolName}`,\n parameters: { type: \"object\" },\n })),\n };\n providers.set(name, record);\n\n const resolve = async (\n toolName: string,\n args: unknown,\n signal: AbortSignal | undefined,\n asUser: boolean,\n userId: string | undefined,\n ): Promise<unknown> => {\n toolCalls.push({\n plugin: name,\n tool: toolName,\n args,\n signal,\n asUser,\n userId,\n });\n // `Object.hasOwn`, not `tools[toolName] === undefined`: a tool named\n // \"constructor\"/\"toString\"/etc. would otherwise resolve to an inherited\n // Object.prototype method and be invoked instead of reported missing.\n if (!Object.hasOwn(tools, toolName)) {\n throw new Error(\n `createTestPluginContext: plugin \"${name}\" has no fake tool \"${toolName}\". ` +\n `Available: ${Object.keys(tools).join(\", \") || \"(none)\"}`,\n );\n }\n const response = tools[toolName];\n return typeof response === \"function\"\n ? await (response as (a: unknown, s?: AbortSignal) => unknown)(\n args,\n signal,\n )\n : response;\n };\n\n const base: ToolProvider = {\n getAgentTools: () => record.tools,\n executeAgentTool: (toolName, args, signal) => {\n const caller = getCallerContext();\n return resolve(\n toolName,\n args,\n signal,\n !!caller,\n caller?.principal.userId,\n );\n },\n };\n\n // Reuse production header validation and ALS. Only the client is faked.\n const asUser = (req: IAppRequest): ToolProvider => {\n record.asUserRequests.push(req as express.Request);\n const scope = createRequestScope(req as express.Request, [], {\n createCaller: createCallerContext,\n legacy: true,\n });\n\n return {\n ...base,\n executeAgentTool: (toolName, args, signal) =>\n scope.run(() => base.executeAgentTool(toolName, args, signal)),\n };\n };\n\n // executeTool calls executeAgentTool under the ambient caller scope.\n // Keep asUser for older tests that call the provider directly.\n // The remaining BasePlugin surface is\n // never touched for a registered provider, so a focused fake plus a cast\n // is sufficient and avoids reimplementing a plugin.\n const provider = {\n name,\n setup: async () => {},\n injectRoutes: () => {},\n getEndpoints: () => ({}),\n ...base,\n asUser,\n } as unknown as BasePlugin &\n ToolProvider & {\n asUser: (req: IAppRequest) => ToolProvider;\n };\n\n ctx.registerToolProvider(name, provider);\n }\n\n for (const [name, tools] of Object.entries(fakes)) {\n registerProvider(name, tools);\n }\n\n async function attach<P extends Plugin>(plugin: P): Promise<P> {\n // Seed a real in-memory cache if AppKit hasn't initialized one. Idempotent:\n // getInstance returns any existing singleton (e.g. one a suite already set\n // up) and ignores the storage argument in that case.\n if (!cacheReady()) {\n await CacheManager.getInstance({ storage: new InMemoryStorage({}) });\n }\n plugin.attachContext({ context: ctx });\n\n // Mirror what AppKit core does after attachContext (core/appkit.ts): put\n // the plugin in the registry so `getPlugins()`/`getPluginNames()`/\n // `hasPlugin()` and any sibling-plugin lookup behave as in production. Only\n // register it as a tool provider when it actually is one AND its name does\n // not collide with an injected fake — the fakes are the authored test\n // doubles and must not be overwritten by the plugin under test.\n ctx.registerPlugin(plugin.name, plugin as unknown as BasePlugin);\n if (isToolProvider(plugin) && !providers.has(plugin.name)) {\n ctx.registerToolProvider(\n plugin.name,\n plugin as unknown as Parameters<typeof ctx.registerToolProvider>[1],\n );\n }\n return plugin;\n }\n\n return {\n ctx,\n telemetry,\n toolCalls,\n routes,\n providers,\n registerProvider,\n attach,\n };\n}\n\nfunction createTestPluginContextWithOptions(\n fakes: FakeProviders,\n options: TestPluginContextOptions,\n): TestPluginContext {\n const { responses = {}, env: envVars = {}, strict = false } = options;\n\n // Build a mock workspace client seeded from responses\n const client = createMockWorkspaceClient({\n responses,\n strict,\n });\n\n // Install the mock service context with the seeded client\n const serviceContextMock = mockServiceContext({\n serviceDatabricksClient: client,\n });\n\n // Set env (captured for restore) via the shared helper.\n const restoreEnv = applyEnv(envVars);\n\n // Create the base context (without options this time, since we're handling everything)\n const base = createTestPluginContextSync(fakes, client);\n\n // Restore function: restores env and service context (idempotent)\n let hasRestored = false;\n const restore = () => {\n if (hasRestored) return;\n hasRestored = true;\n restoreEnv();\n serviceContextMock.restore();\n };\n\n // Auto-restore after the current test. This helper is documented and used\n // from inside a test body, where a runtime-registered `afterEach` does NOT run\n // for that test (Vitest only collects `afterEach` before the test runs) — the\n // reason the old `afterEach` here silently leaked. `onTestFinished` is the hook\n // built for runtime registration and fires after the creating test. If called\n // at collection scope instead, it throws, so fall back to `afterEach` there.\n try {\n onTestFinished(() => {\n restore();\n });\n } catch {\n afterEach(() => {\n restore();\n });\n }\n\n // Return the context with the restore method\n return {\n ...base,\n restore,\n };\n}\n\nfunction cacheReady(): boolean {\n try {\n CacheManager.getInstanceSync();\n return true;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
|
|
@@ -3,7 +3,7 @@ import { walkSchema } from "./walk-schema.js";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import
|
|
6
|
+
import fs$1 from "node:fs";
|
|
7
7
|
|
|
8
8
|
//#region src/type-generator/database/generate.ts
|
|
9
9
|
/** Safe diagnostic raised when database declarations cannot be generated. */
|
|
@@ -63,7 +63,7 @@ ${walkSchema(schema).map((entry) => ` ${JSON.stringify(entry.name)}: {
|
|
|
63
63
|
}
|
|
64
64
|
/** Atomically replace generated output only when its content changes. */
|
|
65
65
|
async function writeIfChanged(outFile, content) {
|
|
66
|
-
if (
|
|
66
|
+
if (fs$1.existsSync(outFile) && await fs.readFile(outFile, "utf8") === content) return;
|
|
67
67
|
await fs.mkdir(path.dirname(outFile), { recursive: true });
|
|
68
68
|
const temporary = path.join(path.dirname(outFile), `.${path.basename(outFile)}.${randomUUID()}.tmp`);
|
|
69
69
|
try {
|
|
@@ -80,7 +80,7 @@ async function importSchema(schemaFile) {
|
|
|
80
80
|
}
|
|
81
81
|
/** Generate current registry types or neutralize them when loading fails. */
|
|
82
82
|
async function generateDatabaseTypes(options) {
|
|
83
|
-
if (!
|
|
83
|
+
if (!fs$1.existsSync(options.schemaFile)) {
|
|
84
84
|
await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES);
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate.js","names":[],"sources":["../../../src/type-generator/database/generate.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport fsSync from \"node:fs\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport type { Schema } from \"../../database/schema-builder\";\nimport { assertFinalizedSchema } from \"../../database/schema-builder/define-schema\";\nimport { walkSchema } from \"./walk-schema\";\n\n/** Safe diagnostic raised when database declarations cannot be generated. */\nexport class DatabaseTypegenError extends Error {\n constructor(message = \"Database schema generation failed\") {\n super(message);\n this.name = \"DatabaseTypegenError\";\n }\n}\n\ninterface GenerateDatabaseTypesOptions {\n readonly schemaFile: string;\n readonly outFile: string;\n}\n\nexport const DATABASE_TYPES_FILE = \"database.d.ts\";\n\nfunction schemaLabel(schemaFile: string): string {\n return path.relative(process.cwd(), schemaFile) || schemaFile;\n}\n\nfunction importFailureReason(error: unknown): string {\n if (error instanceof SyntaxError) return \"could not be parsed\";\n let code: unknown;\n try {\n code =\n error && typeof error === \"object\"\n ? Reflect.get(error, \"code\")\n : undefined;\n } catch {\n code = undefined;\n }\n if (code === \"MODULE_NOT_FOUND\" || code === \"ERR_MODULE_NOT_FOUND\") {\n return \"contains an unresolved import\";\n }\n return \"threw while loading\";\n}\n\n/** Empty augmentation used when no valid database schema is available. */\nexport const NEUTRAL_DATABASE_TYPES = `// Auto-generated by AppKit - DO NOT EDIT\nimport \"@databricks/appkit\";\n\ndeclare module \"@databricks/appkit\" {\n interface DatabaseRegistry {}\n}\n`;\n\n/** Render one `DatabaseRegistry` augmentation from a finalized schema. */\nfunction render(schema: Schema): string {\n const entries = walkSchema(schema)\n .map(\n (entry) => ` ${JSON.stringify(entry.name)}: {\n row: ${entry.row};\n publicRow: ${entry.publicRow};\n insert: ${entry.insert};\n update: ${entry.update};\n filters: ${entry.filters};\n includes: ${entry.includes};\n hasPrimaryKey: ${entry.hasPrimaryKey};\n };`,\n )\n .join(\"\\n\");\n return `// Auto-generated by AppKit - DO NOT EDIT\nimport \"@databricks/appkit\";\n\ndeclare module \"@databricks/appkit\" {\n type DatabaseLogicalFilter<T> = T & {\n and?: readonly DatabaseLogicalFilter<T>[];\n or?: readonly DatabaseLogicalFilter<T>[];\n };\n\n interface DatabaseRegistry {\n${entries}\n }\n}\n`;\n}\n\n/** Atomically replace generated output only when its content changes. */\nasync function writeIfChanged(outFile: string, content: string): Promise<void> {\n if (\n fsSync.existsSync(outFile) &&\n (await fs.readFile(outFile, \"utf8\")) === content\n )\n return;\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n const temporary = path.join(\n path.dirname(outFile),\n `.${path.basename(outFile)}.${randomUUID()}.tmp`,\n );\n try {\n await fs.writeFile(temporary, content, \"utf8\");\n await fs.rename(temporary, outFile);\n } finally {\n await fs.rm(temporary, { force: true });\n }\n}\n\n/** Disable Jiti's module cache so watch-mode runs observe schema edits. */\nasync function importSchema(schemaFile: string): Promise<unknown> {\n const { createJiti } = await import(\"jiti\");\n return createJiti(import.meta.url, { moduleCache: false }).import(schemaFile);\n}\n\n/** Generate current registry types or neutralize them when loading fails. */\nexport async function generateDatabaseTypes(\n options: GenerateDatabaseTypesOptions,\n): Promise<void> {\n if (!fsSync.existsSync(options.schemaFile)) {\n await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES);\n return;\n }\n try {\n const module = (await importSchema(options.schemaFile)) as {\n schema?: Schema;\n };\n if (!(\"schema\" in module)) {\n throw new DatabaseTypegenError(\n `Database schema module \"${schemaLabel(options.schemaFile)}\" must export a named \"schema\"`,\n );\n }\n try {\n assertFinalizedSchema(module.schema);\n } catch {\n throw new DatabaseTypegenError(\n `Database schema \"${schemaLabel(options.schemaFile)}\" is not a finalized AppKit schema`,\n );\n }\n await writeIfChanged(options.outFile, render(module.schema));\n } catch (error) {\n // A failed run must not leave stale entities visible to TypeScript.\n await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES);\n throw error instanceof DatabaseTypegenError\n ? error\n : new DatabaseTypegenError(\n `Database schema \"${schemaLabel(options.schemaFile)}\" ${importFailureReason(error)}`,\n );\n }\n}\n"],"mappings":";;;;;;;;;AAUA,IAAa,uBAAb,cAA0C,MAAM;CAC9C,YAAY,UAAU,qCAAqC;AACzD,QAAM,QAAQ;AACd,OAAK,OAAO;;;AAShB,MAAa,sBAAsB;AAEnC,SAAS,YAAY,YAA4B;AAC/C,QAAO,KAAK,SAAS,QAAQ,KAAK,EAAE,WAAW,IAAI;;AAGrD,SAAS,oBAAoB,OAAwB;AACnD,KAAI,iBAAiB,YAAa,QAAO;CACzC,IAAI;AACJ,KAAI;AACF,SACE,SAAS,OAAO,UAAU,WACtB,QAAQ,IAAI,OAAO,OAAO,GAC1B;SACA;AACN,SAAO;;AAET,KAAI,SAAS,sBAAsB,SAAS,uBAC1C,QAAO;AAET,QAAO;;;AAIT,MAAa,yBAAyB;;;;;;;;AAStC,SAAS,OAAO,QAAwB;AActC,QAAO;;;;;;;;;;EAbS,WAAW,OAAO,CAC/B,KACE,UAAU,OAAO,KAAK,UAAU,MAAM,KAAK,CAAC;aACtC,MAAM,IAAI;mBACJ,MAAM,UAAU;gBACnB,MAAM,OAAO;gBACb,MAAM,OAAO;iBACZ,MAAM,QAAQ;kBACb,MAAM,SAAS;uBACV,MAAM,cAAc;QAEtC,CACA,KAAK,KAAK,CAWL;;;;;;AAOV,eAAe,eAAe,SAAiB,SAAgC;AAC7E,
|
|
1
|
+
{"version":3,"file":"generate.js","names":["fsSync"],"sources":["../../../src/type-generator/database/generate.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport fsSync from \"node:fs\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport type { Schema } from \"../../database/schema-builder\";\nimport { assertFinalizedSchema } from \"../../database/schema-builder/define-schema\";\nimport { walkSchema } from \"./walk-schema\";\n\n/** Safe diagnostic raised when database declarations cannot be generated. */\nexport class DatabaseTypegenError extends Error {\n constructor(message = \"Database schema generation failed\") {\n super(message);\n this.name = \"DatabaseTypegenError\";\n }\n}\n\ninterface GenerateDatabaseTypesOptions {\n readonly schemaFile: string;\n readonly outFile: string;\n}\n\nexport const DATABASE_TYPES_FILE = \"database.d.ts\";\n\nfunction schemaLabel(schemaFile: string): string {\n return path.relative(process.cwd(), schemaFile) || schemaFile;\n}\n\nfunction importFailureReason(error: unknown): string {\n if (error instanceof SyntaxError) return \"could not be parsed\";\n let code: unknown;\n try {\n code =\n error && typeof error === \"object\"\n ? Reflect.get(error, \"code\")\n : undefined;\n } catch {\n code = undefined;\n }\n if (code === \"MODULE_NOT_FOUND\" || code === \"ERR_MODULE_NOT_FOUND\") {\n return \"contains an unresolved import\";\n }\n return \"threw while loading\";\n}\n\n/** Empty augmentation used when no valid database schema is available. */\nexport const NEUTRAL_DATABASE_TYPES = `// Auto-generated by AppKit - DO NOT EDIT\nimport \"@databricks/appkit\";\n\ndeclare module \"@databricks/appkit\" {\n interface DatabaseRegistry {}\n}\n`;\n\n/** Render one `DatabaseRegistry` augmentation from a finalized schema. */\nfunction render(schema: Schema): string {\n const entries = walkSchema(schema)\n .map(\n (entry) => ` ${JSON.stringify(entry.name)}: {\n row: ${entry.row};\n publicRow: ${entry.publicRow};\n insert: ${entry.insert};\n update: ${entry.update};\n filters: ${entry.filters};\n includes: ${entry.includes};\n hasPrimaryKey: ${entry.hasPrimaryKey};\n };`,\n )\n .join(\"\\n\");\n return `// Auto-generated by AppKit - DO NOT EDIT\nimport \"@databricks/appkit\";\n\ndeclare module \"@databricks/appkit\" {\n type DatabaseLogicalFilter<T> = T & {\n and?: readonly DatabaseLogicalFilter<T>[];\n or?: readonly DatabaseLogicalFilter<T>[];\n };\n\n interface DatabaseRegistry {\n${entries}\n }\n}\n`;\n}\n\n/** Atomically replace generated output only when its content changes. */\nasync function writeIfChanged(outFile: string, content: string): Promise<void> {\n if (\n fsSync.existsSync(outFile) &&\n (await fs.readFile(outFile, \"utf8\")) === content\n )\n return;\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n const temporary = path.join(\n path.dirname(outFile),\n `.${path.basename(outFile)}.${randomUUID()}.tmp`,\n );\n try {\n await fs.writeFile(temporary, content, \"utf8\");\n await fs.rename(temporary, outFile);\n } finally {\n await fs.rm(temporary, { force: true });\n }\n}\n\n/** Disable Jiti's module cache so watch-mode runs observe schema edits. */\nasync function importSchema(schemaFile: string): Promise<unknown> {\n const { createJiti } = await import(\"jiti\");\n return createJiti(import.meta.url, { moduleCache: false }).import(schemaFile);\n}\n\n/** Generate current registry types or neutralize them when loading fails. */\nexport async function generateDatabaseTypes(\n options: GenerateDatabaseTypesOptions,\n): Promise<void> {\n if (!fsSync.existsSync(options.schemaFile)) {\n await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES);\n return;\n }\n try {\n const module = (await importSchema(options.schemaFile)) as {\n schema?: Schema;\n };\n if (!(\"schema\" in module)) {\n throw new DatabaseTypegenError(\n `Database schema module \"${schemaLabel(options.schemaFile)}\" must export a named \"schema\"`,\n );\n }\n try {\n assertFinalizedSchema(module.schema);\n } catch {\n throw new DatabaseTypegenError(\n `Database schema \"${schemaLabel(options.schemaFile)}\" is not a finalized AppKit schema`,\n );\n }\n await writeIfChanged(options.outFile, render(module.schema));\n } catch (error) {\n // A failed run must not leave stale entities visible to TypeScript.\n await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES);\n throw error instanceof DatabaseTypegenError\n ? error\n : new DatabaseTypegenError(\n `Database schema \"${schemaLabel(options.schemaFile)}\" ${importFailureReason(error)}`,\n );\n }\n}\n"],"mappings":";;;;;;;;;AAUA,IAAa,uBAAb,cAA0C,MAAM;CAC9C,YAAY,UAAU,qCAAqC;AACzD,QAAM,QAAQ;AACd,OAAK,OAAO;;;AAShB,MAAa,sBAAsB;AAEnC,SAAS,YAAY,YAA4B;AAC/C,QAAO,KAAK,SAAS,QAAQ,KAAK,EAAE,WAAW,IAAI;;AAGrD,SAAS,oBAAoB,OAAwB;AACnD,KAAI,iBAAiB,YAAa,QAAO;CACzC,IAAI;AACJ,KAAI;AACF,SACE,SAAS,OAAO,UAAU,WACtB,QAAQ,IAAI,OAAO,OAAO,GAC1B;SACA;AACN,SAAO;;AAET,KAAI,SAAS,sBAAsB,SAAS,uBAC1C,QAAO;AAET,QAAO;;;AAIT,MAAa,yBAAyB;;;;;;;;AAStC,SAAS,OAAO,QAAwB;AActC,QAAO;;;;;;;;;;EAbS,WAAW,OAAO,CAC/B,KACE,UAAU,OAAO,KAAK,UAAU,MAAM,KAAK,CAAC;aACtC,MAAM,IAAI;mBACJ,MAAM,UAAU;gBACnB,MAAM,OAAO;gBACb,MAAM,OAAO;iBACZ,MAAM,QAAQ;kBACb,MAAM,SAAS;uBACV,MAAM,cAAc;QAEtC,CACA,KAAK,KAAK,CAWL;;;;;;AAOV,eAAe,eAAe,SAAiB,SAAgC;AAC7E,KACEA,KAAO,WAAW,QAAQ,IACzB,MAAM,GAAG,SAAS,SAAS,OAAO,KAAM,QAEzC;AACF,OAAM,GAAG,MAAM,KAAK,QAAQ,QAAQ,EAAE,EAAE,WAAW,MAAM,CAAC;CAC1D,MAAM,YAAY,KAAK,KACrB,KAAK,QAAQ,QAAQ,EACrB,IAAI,KAAK,SAAS,QAAQ,CAAC,GAAG,YAAY,CAAC,MAC5C;AACD,KAAI;AACF,QAAM,GAAG,UAAU,WAAW,SAAS,OAAO;AAC9C,QAAM,GAAG,OAAO,WAAW,QAAQ;WAC3B;AACR,QAAM,GAAG,GAAG,WAAW,EAAE,OAAO,MAAM,CAAC;;;;AAK3C,eAAe,aAAa,YAAsC;CAChE,MAAM,EAAE,eAAe,MAAM,OAAO;AACpC,QAAO,WAAW,OAAO,KAAK,KAAK,EAAE,aAAa,OAAO,CAAC,CAAC,OAAO,WAAW;;;AAI/E,eAAsB,sBACpB,SACe;AACf,KAAI,CAACA,KAAO,WAAW,QAAQ,WAAW,EAAE;AAC1C,QAAM,eAAe,QAAQ,SAAS,uBAAuB;AAC7D;;AAEF,KAAI;EACF,MAAM,SAAU,MAAM,aAAa,QAAQ,WAAW;AAGtD,MAAI,EAAE,YAAY,QAChB,OAAM,IAAI,qBACR,2BAA2B,YAAY,QAAQ,WAAW,CAAC,gCAC5D;AAEH,MAAI;AACF,yBAAsB,OAAO,OAAO;UAC9B;AACN,SAAM,IAAI,qBACR,oBAAoB,YAAY,QAAQ,WAAW,CAAC,oCACrD;;AAEH,QAAM,eAAe,QAAQ,SAAS,OAAO,OAAO,OAAO,CAAC;UACrD,OAAO;AAEd,QAAM,eAAe,QAAQ,SAAS,uBAAuB;AAC7D,QAAM,iBAAiB,uBACnB,QACA,IAAI,qBACF,oBAAoB,YAAY,QAAQ,WAAW,CAAC,IAAI,oBAAoB,MAAM,GACnF"}
|
|
@@ -2,7 +2,7 @@ import { createLogger } from "../logging/logger.js";
|
|
|
2
2
|
import pc from "picocolors";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import
|
|
5
|
+
import fs$1 from "node:fs";
|
|
6
6
|
|
|
7
7
|
//#region src/type-generator/migration.ts
|
|
8
8
|
const logger = createLogger("type-generator:migration");
|
|
@@ -16,7 +16,7 @@ const logger = createLogger("type-generator:migration");
|
|
|
16
16
|
*/
|
|
17
17
|
function resolveProjectRoot(outFile) {
|
|
18
18
|
const root = path.resolve(path.dirname(outFile), "..", "..");
|
|
19
|
-
if (!
|
|
19
|
+
if (!fs$1.existsSync(path.join(root, "package.json"))) logger.warn("Resolved project root %s has no package.json — migration may target the wrong directory. Check your outFile path: %s", root, outFile);
|
|
20
20
|
return root;
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migration.js","names":[],"sources":["../../src/type-generator/migration.ts"],"sourcesContent":["import fsSync from \"node:fs\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport pc from \"picocolors\";\n\nimport { createLogger } from \"../logging/logger\";\n\nconst logger = createLogger(\"type-generator:migration\");\n\n/**\n * Derive project root from an outFile path.\n * outFile is always `<projectRoot>/shared/appkit-types/<file>` — both the Vite plugins\n * and the CLI construct it this way, so going up two levels is safe.\n *\n * Validates that the resolved root contains a package.json — if not, logs a warning\n * so custom outFile paths don't silently operate on the wrong directory.\n */\nexport function resolveProjectRoot(outFile: string): string {\n const root = path.resolve(path.dirname(outFile), \"..\", \"..\");\n if (!fsSync.existsSync(path.join(root, \"package.json\"))) {\n logger.warn(\n \"Resolved project root %s has no package.json — migration may target the wrong directory. \" +\n \"Check your outFile path: %s\",\n root,\n outFile,\n );\n }\n return root;\n}\n\n/**\n * Remove old generated types from client/src/ (pre-shared/ location).\n * Best-effort: silently ignores missing files.\n */\nexport async function removeOldGeneratedTypes(\n projectRoot: string,\n filename: string,\n): Promise<void> {\n const oldFile = path.join(projectRoot, \"client\", \"src\", filename);\n try {\n await fs.unlink(oldFile);\n logger.debug(\"Removed old types at %s\", oldFile);\n } catch {\n // File doesn't exist — nothing to clean up\n }\n}\n\n// ── Project config migration ────────────────────────────────────────────\n\nconst migratedProjects = new Set<string>();\n\n/**\n * One-time config migration: update tsconfig and package.json for shared/ types output.\n * Idempotent — each sub-migration checks current file state and skips if already migrated.\n * Deduplicates per project root so monorepo builds migrate each app independently.\n * Opt-out: set `\"appkit\": { \"autoMigrate\": false }` in package.json.\n */\nexport async function migrateProjectConfig(projectRoot: string): Promise<void> {\n const resolved = path.resolve(projectRoot);\n if (migratedProjects.has(resolved)) return;\n migratedProjects.add(resolved);\n\n if (await isAutoMigrateDisabled(projectRoot)) {\n logger.debug(\"Auto-migration disabled via package.json appkit.autoMigrate\");\n return;\n }\n\n const results: Array<{ file: string; action: string }> = [];\n\n results.push(...(await migrateTsconfigClient(projectRoot)));\n results.push(...(await migrateTsconfigServer(projectRoot)));\n results.push(...(await migratePackageJsonScripts(projectRoot)));\n\n if (results.length > 0) {\n printMigrationSummary(results);\n }\n}\n\n/** Exported for testing only. */\nexport function _resetMigrationState(): void {\n migratedProjects.clear();\n}\n\n// ── Helpers ─────────────────────────────────────────────────────────────\n\nasync function isAutoMigrateDisabled(projectRoot: string): Promise<boolean> {\n try {\n const raw = await fs.readFile(\n path.join(projectRoot, \"package.json\"),\n \"utf-8\",\n );\n const parsed = JSON.parse(raw);\n return parsed.appkit?.autoMigrate === false;\n } catch {\n return false;\n }\n}\n\n/** Strip JSONC comments (block and line) so JSON.parse can handle tsconfig files. */\nfunction stripJsonComments(text: string): string {\n // Match strings (to skip them) or comments (to remove them).\n // Strings must be matched first to avoid stripping comment-like patterns inside string values\n // (e.g. \"server/**/*\" contains /* which looks like a block comment start).\n return text.replace(/\"(?:[^\"\\\\]|\\\\.)*\"|\\/\\*[\\s\\S]*?\\*\\/|\\/\\/.*/g, (match) =>\n match.startsWith('\"') ? match : \"\",\n );\n}\n\ntype MigrationResult = Array<{ file: string; action: string }>;\n\n// ── tsconfig.client.json ────────────────────────────────────────────────\n\nasync function migrateTsconfigClient(\n projectRoot: string,\n): Promise<MigrationResult> {\n const results: MigrationResult = [];\n const filePath = path.join(projectRoot, \"tsconfig.client.json\");\n\n try {\n const raw = await fs.readFile(filePath, \"utf-8\");\n const parsed = JSON.parse(stripJsonComments(raw));\n\n if (!Array.isArray(parsed.include)) return results;\n if (parsed.include.includes(\"shared/appkit-types\")) return results;\n\n parsed.include.push(\"shared/appkit-types\");\n await fs.writeFile(\n filePath,\n `${JSON.stringify(parsed, null, 2)}\\n`,\n \"utf-8\",\n );\n results.push({\n file: \"tsconfig.client.json\",\n action: 'added \"shared/appkit-types\" to include',\n });\n } catch (err) {\n logger.warn(\n \"Failed to migrate tsconfig.client.json: %s\",\n (err as Error).message,\n );\n }\n\n return results;\n}\n\n// ── tsconfig.server.json ────────────────────────────────────────────────\n\nasync function migrateTsconfigServer(\n projectRoot: string,\n): Promise<MigrationResult> {\n const results: MigrationResult = [];\n const filePath = path.join(projectRoot, \"tsconfig.server.json\");\n\n try {\n const raw = await fs.readFile(filePath, \"utf-8\");\n const parsed = JSON.parse(stripJsonComments(raw));\n const opts = parsed.compilerOptions;\n\n if (!opts || !opts.outDir) return results; // already migrated or non-standard\n\n delete opts.outDir;\n delete opts.declaration;\n delete opts.declarationMap;\n delete opts.sourceMap;\n opts.noEmit = true;\n\n await fs.writeFile(\n filePath,\n `${JSON.stringify(parsed, null, 2)}\\n`,\n \"utf-8\",\n );\n results.push({\n file: \"tsconfig.server.json\",\n action: \"switched to noEmit mode\",\n });\n } catch (err) {\n logger.warn(\n \"Failed to migrate tsconfig.server.json: %s\",\n (err as Error).message,\n );\n }\n\n return results;\n}\n\n// ── package.json ────────────────────────────────────────────────────────\n\nconst SCRIPT_MIGRATIONS: Record<string, { old: string; new: string }> = {\n \"build:server\": {\n old: \"tsdown -c tsdown.server.config.ts\",\n new: \"tsc -b tsconfig.server.json && tsdown -c tsdown.server.config.ts\",\n },\n typecheck: {\n old: \"tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit\",\n new: \"tsc -b tsconfig.server.json && tsc -b tsconfig.client.json\",\n },\n};\n\nasync function migratePackageJsonScripts(\n projectRoot: string,\n): Promise<MigrationResult> {\n const results: MigrationResult = [];\n const filePath = path.join(projectRoot, \"package.json\");\n\n try {\n const raw = await fs.readFile(filePath, \"utf-8\");\n const parsed = JSON.parse(raw);\n const scripts = parsed.scripts;\n if (!scripts) return results;\n\n const updated: string[] = [];\n\n for (const [name, { old, new: replacement }] of Object.entries(\n SCRIPT_MIGRATIONS,\n )) {\n if (scripts[name] === old) {\n scripts[name] = replacement;\n updated.push(name);\n }\n }\n\n if (updated.length === 0) return results;\n\n const indent = raw.match(/^\\s+/m)?.[0]?.length === 4 ? 4 : 2;\n await fs.writeFile(\n filePath,\n `${JSON.stringify(parsed, null, indent)}\\n`,\n \"utf-8\",\n );\n results.push({\n file: \"package.json\",\n action: `updated ${updated.join(\" and \")} scripts`,\n });\n } catch (err) {\n logger.warn(\n \"Failed to migrate package.json scripts: %s\",\n (err as Error).message,\n );\n }\n\n return results;\n}\n\n// ── Summary ─────────────────────────────────────────────────────────────\n\nfunction printMigrationSummary(\n results: Array<{ file: string; action: string }>,\n) {\n const separator = pc.dim(\"─\".repeat(50));\n console.log(\"\");\n console.log(` ${pc.bold(\"Typegen Migration\")}`);\n console.log(` ${separator}`);\n for (const { file, action } of results) {\n console.log(` ${pc.green(\"✓\")} ${file.padEnd(24)} ${pc.dim(action)}`);\n }\n console.log(` ${separator}`);\n console.log(\"\");\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"migration.js","names":["fsSync"],"sources":["../../src/type-generator/migration.ts"],"sourcesContent":["import fsSync from \"node:fs\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport pc from \"picocolors\";\n\nimport { createLogger } from \"../logging/logger\";\n\nconst logger = createLogger(\"type-generator:migration\");\n\n/**\n * Derive project root from an outFile path.\n * outFile is always `<projectRoot>/shared/appkit-types/<file>` — both the Vite plugins\n * and the CLI construct it this way, so going up two levels is safe.\n *\n * Validates that the resolved root contains a package.json — if not, logs a warning\n * so custom outFile paths don't silently operate on the wrong directory.\n */\nexport function resolveProjectRoot(outFile: string): string {\n const root = path.resolve(path.dirname(outFile), \"..\", \"..\");\n if (!fsSync.existsSync(path.join(root, \"package.json\"))) {\n logger.warn(\n \"Resolved project root %s has no package.json — migration may target the wrong directory. \" +\n \"Check your outFile path: %s\",\n root,\n outFile,\n );\n }\n return root;\n}\n\n/**\n * Remove old generated types from client/src/ (pre-shared/ location).\n * Best-effort: silently ignores missing files.\n */\nexport async function removeOldGeneratedTypes(\n projectRoot: string,\n filename: string,\n): Promise<void> {\n const oldFile = path.join(projectRoot, \"client\", \"src\", filename);\n try {\n await fs.unlink(oldFile);\n logger.debug(\"Removed old types at %s\", oldFile);\n } catch {\n // File doesn't exist — nothing to clean up\n }\n}\n\n// ── Project config migration ────────────────────────────────────────────\n\nconst migratedProjects = new Set<string>();\n\n/**\n * One-time config migration: update tsconfig and package.json for shared/ types output.\n * Idempotent — each sub-migration checks current file state and skips if already migrated.\n * Deduplicates per project root so monorepo builds migrate each app independently.\n * Opt-out: set `\"appkit\": { \"autoMigrate\": false }` in package.json.\n */\nexport async function migrateProjectConfig(projectRoot: string): Promise<void> {\n const resolved = path.resolve(projectRoot);\n if (migratedProjects.has(resolved)) return;\n migratedProjects.add(resolved);\n\n if (await isAutoMigrateDisabled(projectRoot)) {\n logger.debug(\"Auto-migration disabled via package.json appkit.autoMigrate\");\n return;\n }\n\n const results: Array<{ file: string; action: string }> = [];\n\n results.push(...(await migrateTsconfigClient(projectRoot)));\n results.push(...(await migrateTsconfigServer(projectRoot)));\n results.push(...(await migratePackageJsonScripts(projectRoot)));\n\n if (results.length > 0) {\n printMigrationSummary(results);\n }\n}\n\n/** Exported for testing only. */\nexport function _resetMigrationState(): void {\n migratedProjects.clear();\n}\n\n// ── Helpers ─────────────────────────────────────────────────────────────\n\nasync function isAutoMigrateDisabled(projectRoot: string): Promise<boolean> {\n try {\n const raw = await fs.readFile(\n path.join(projectRoot, \"package.json\"),\n \"utf-8\",\n );\n const parsed = JSON.parse(raw);\n return parsed.appkit?.autoMigrate === false;\n } catch {\n return false;\n }\n}\n\n/** Strip JSONC comments (block and line) so JSON.parse can handle tsconfig files. */\nfunction stripJsonComments(text: string): string {\n // Match strings (to skip them) or comments (to remove them).\n // Strings must be matched first to avoid stripping comment-like patterns inside string values\n // (e.g. \"server/**/*\" contains /* which looks like a block comment start).\n return text.replace(/\"(?:[^\"\\\\]|\\\\.)*\"|\\/\\*[\\s\\S]*?\\*\\/|\\/\\/.*/g, (match) =>\n match.startsWith('\"') ? match : \"\",\n );\n}\n\ntype MigrationResult = Array<{ file: string; action: string }>;\n\n// ── tsconfig.client.json ────────────────────────────────────────────────\n\nasync function migrateTsconfigClient(\n projectRoot: string,\n): Promise<MigrationResult> {\n const results: MigrationResult = [];\n const filePath = path.join(projectRoot, \"tsconfig.client.json\");\n\n try {\n const raw = await fs.readFile(filePath, \"utf-8\");\n const parsed = JSON.parse(stripJsonComments(raw));\n\n if (!Array.isArray(parsed.include)) return results;\n if (parsed.include.includes(\"shared/appkit-types\")) return results;\n\n parsed.include.push(\"shared/appkit-types\");\n await fs.writeFile(\n filePath,\n `${JSON.stringify(parsed, null, 2)}\\n`,\n \"utf-8\",\n );\n results.push({\n file: \"tsconfig.client.json\",\n action: 'added \"shared/appkit-types\" to include',\n });\n } catch (err) {\n logger.warn(\n \"Failed to migrate tsconfig.client.json: %s\",\n (err as Error).message,\n );\n }\n\n return results;\n}\n\n// ── tsconfig.server.json ────────────────────────────────────────────────\n\nasync function migrateTsconfigServer(\n projectRoot: string,\n): Promise<MigrationResult> {\n const results: MigrationResult = [];\n const filePath = path.join(projectRoot, \"tsconfig.server.json\");\n\n try {\n const raw = await fs.readFile(filePath, \"utf-8\");\n const parsed = JSON.parse(stripJsonComments(raw));\n const opts = parsed.compilerOptions;\n\n if (!opts || !opts.outDir) return results; // already migrated or non-standard\n\n delete opts.outDir;\n delete opts.declaration;\n delete opts.declarationMap;\n delete opts.sourceMap;\n opts.noEmit = true;\n\n await fs.writeFile(\n filePath,\n `${JSON.stringify(parsed, null, 2)}\\n`,\n \"utf-8\",\n );\n results.push({\n file: \"tsconfig.server.json\",\n action: \"switched to noEmit mode\",\n });\n } catch (err) {\n logger.warn(\n \"Failed to migrate tsconfig.server.json: %s\",\n (err as Error).message,\n );\n }\n\n return results;\n}\n\n// ── package.json ────────────────────────────────────────────────────────\n\nconst SCRIPT_MIGRATIONS: Record<string, { old: string; new: string }> = {\n \"build:server\": {\n old: \"tsdown -c tsdown.server.config.ts\",\n new: \"tsc -b tsconfig.server.json && tsdown -c tsdown.server.config.ts\",\n },\n typecheck: {\n old: \"tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit\",\n new: \"tsc -b tsconfig.server.json && tsc -b tsconfig.client.json\",\n },\n};\n\nasync function migratePackageJsonScripts(\n projectRoot: string,\n): Promise<MigrationResult> {\n const results: MigrationResult = [];\n const filePath = path.join(projectRoot, \"package.json\");\n\n try {\n const raw = await fs.readFile(filePath, \"utf-8\");\n const parsed = JSON.parse(raw);\n const scripts = parsed.scripts;\n if (!scripts) return results;\n\n const updated: string[] = [];\n\n for (const [name, { old, new: replacement }] of Object.entries(\n SCRIPT_MIGRATIONS,\n )) {\n if (scripts[name] === old) {\n scripts[name] = replacement;\n updated.push(name);\n }\n }\n\n if (updated.length === 0) return results;\n\n const indent = raw.match(/^\\s+/m)?.[0]?.length === 4 ? 4 : 2;\n await fs.writeFile(\n filePath,\n `${JSON.stringify(parsed, null, indent)}\\n`,\n \"utf-8\",\n );\n results.push({\n file: \"package.json\",\n action: `updated ${updated.join(\" and \")} scripts`,\n });\n } catch (err) {\n logger.warn(\n \"Failed to migrate package.json scripts: %s\",\n (err as Error).message,\n );\n }\n\n return results;\n}\n\n// ── Summary ─────────────────────────────────────────────────────────────\n\nfunction printMigrationSummary(\n results: Array<{ file: string; action: string }>,\n) {\n const separator = pc.dim(\"─\".repeat(50));\n console.log(\"\");\n console.log(` ${pc.bold(\"Typegen Migration\")}`);\n console.log(` ${separator}`);\n for (const { file, action } of results) {\n console.log(` ${pc.green(\"✓\")} ${file.padEnd(24)} ${pc.dim(action)}`);\n }\n console.log(` ${separator}`);\n console.log(\"\");\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createLogger } from "../../logging/logger.js";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import fs from "node:fs";
|
|
4
4
|
import { Lang, parse } from "@ast-grep/napi";
|
|
5
5
|
|
|
6
6
|
//#region src/type-generator/serving/server-file-extractor.ts
|
|
@@ -20,7 +20,7 @@ const SERVER_FILE_CANDIDATES = ["server/index.ts", "server/server.ts"];
|
|
|
20
20
|
function findServerFile(basePath) {
|
|
21
21
|
for (const candidate of SERVER_FILE_CANDIDATES) {
|
|
22
22
|
const fullPath = path.join(basePath, candidate);
|
|
23
|
-
if (
|
|
23
|
+
if (fs.existsSync(fullPath)) return fullPath;
|
|
24
24
|
}
|
|
25
25
|
return null;
|
|
26
26
|
}
|
|
@@ -35,7 +35,7 @@ function findServerFile(basePath) {
|
|
|
35
35
|
function extractServingEndpoints(serverFilePath) {
|
|
36
36
|
let content;
|
|
37
37
|
try {
|
|
38
|
-
content =
|
|
38
|
+
content = fs.readFileSync(serverFilePath, "utf-8");
|
|
39
39
|
} catch {
|
|
40
40
|
logger.debug("Could not read server file: %s", serverFilePath);
|
|
41
41
|
return null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-file-extractor.js","names":[
|
|
1
|
+
{"version":3,"file":"server-file-extractor.js","names":[],"sources":["../../../src/type-generator/serving/server-file-extractor.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\n\nimport { Lang, parse, type SgNode } from \"@ast-grep/napi\";\n\nimport { createLogger } from \"../../logging/logger\";\nimport type { EndpointConfig } from \"../../plugins/serving/types\";\n\nconst logger = createLogger(\"type-generator:serving:extractor\");\n\n/**\n * Candidate paths for the server entry file, relative to the project root.\n * Checked in order; the first that exists is used.\n * Same convention as plugin sync (sync.ts SERVER_FILE_CANDIDATES).\n */\nconst SERVER_FILE_CANDIDATES = [\"server/index.ts\", \"server/server.ts\"];\n\n/**\n * Find the server entry file by checking candidate paths in order.\n *\n * @param basePath - Project root directory to search from\n * @returns Absolute path to the server file, or null if none found\n */\nexport function findServerFile(basePath: string): string | null {\n for (const candidate of SERVER_FILE_CANDIDATES) {\n const fullPath = path.join(basePath, candidate);\n if (fs.existsSync(fullPath)) {\n return fullPath;\n }\n }\n return null;\n}\n\n/**\n * Extract serving endpoint config from a server file by AST-parsing it.\n * Looks for `serving({ endpoints: { alias: { env: \"...\" }, ... } })` calls\n * and extracts the endpoint alias names and their environment variable mappings.\n *\n * @param serverFilePath - Absolute path to the server entry file\n * @returns Extracted endpoint config, or null if not found or not extractable\n */\nexport function extractServingEndpoints(\n serverFilePath: string,\n): Record<string, EndpointConfig> | null {\n let content: string;\n try {\n content = fs.readFileSync(serverFilePath, \"utf-8\");\n } catch {\n logger.debug(\"Could not read server file: %s\", serverFilePath);\n return null;\n }\n\n const lang = serverFilePath.endsWith(\".tsx\") ? Lang.Tsx : Lang.TypeScript;\n const ast = parse(lang, content);\n const root = ast.root();\n\n // Find serving(...) call expressions\n const servingCall = findServingCall(root);\n if (!servingCall) {\n logger.debug(\"No serving() call found in %s\", serverFilePath);\n return null;\n }\n\n // Get the first argument (the config object)\n const args = servingCall.field(\"arguments\");\n if (!args) {\n return null;\n }\n\n const configArg = args.children().find((child) => child.kind() === \"object\");\n if (!configArg) {\n // serving() called with no args or non-object arg\n return null;\n }\n\n // Find the \"endpoints\" property in the config object\n const endpointsPair = findProperty(configArg, \"endpoints\");\n if (!endpointsPair) {\n // Config object has no \"endpoints\" property (e.g. serving({ timeout: 5000 }))\n return null;\n }\n\n // Get the value of the endpoints property\n const endpointsValue = getPropertyValue(endpointsPair);\n if (!endpointsValue || endpointsValue.kind() !== \"object\") {\n // endpoints is a variable reference, not an inline object\n logger.debug(\n \"serving() endpoints is not an inline object literal in %s. \" +\n \"Pass endpoints explicitly via appKitServingTypesPlugin({ endpoints }) in vite.config.ts.\",\n serverFilePath,\n );\n return null;\n }\n\n // Extract each endpoint entry\n const endpoints: Record<string, EndpointConfig> = {};\n const pairs = endpointsValue\n .children()\n .filter((child) => child.kind() === \"pair\");\n\n for (const pair of pairs) {\n const entry = extractEndpointEntry(pair);\n if (entry) {\n endpoints[entry.alias] = entry.config;\n }\n }\n\n if (Object.keys(endpoints).length === 0) {\n return null;\n }\n\n logger.debug(\n \"Extracted %d endpoint(s) from %s: %s\",\n Object.keys(endpoints).length,\n serverFilePath,\n Object.keys(endpoints).join(\", \"),\n );\n\n return endpoints;\n}\n\n/**\n * Find the serving() call expression in the AST.\n * Looks for call expressions where the callee identifier is \"serving\".\n */\nfunction findServingCall(root: SgNode): SgNode | null {\n const callExpressions = root.findAll({\n rule: { kind: \"call_expression\" },\n });\n\n for (const call of callExpressions) {\n const callee = call.children()[0];\n if (callee?.kind() === \"identifier\" && callee.text() === \"serving\") {\n return call;\n }\n }\n\n return null;\n}\n\n/**\n * Find a property (pair node) with the given key name in an object expression.\n */\nfunction findProperty(objectNode: SgNode, propertyName: string): SgNode | null {\n const pairs = objectNode\n .children()\n .filter((child) => child.kind() === \"pair\");\n\n for (const pair of pairs) {\n const key = pair.children()[0];\n if (!key) continue;\n\n const keyText =\n key.kind() === \"property_identifier\"\n ? key.text()\n : key.kind() === \"string\"\n ? key.text().replace(/^['\"]|['\"]$/g, \"\")\n : null;\n\n if (keyText === propertyName) {\n return pair;\n }\n }\n\n return null;\n}\n\n/**\n * Get the value node from a pair (property: value).\n * The value is typically the last meaningful child after the colon.\n */\nfunction getPropertyValue(pairNode: SgNode): SgNode | null {\n const children = pairNode.children();\n // pair children: [key, \":\", value]\n return children.length >= 3 ? children[children.length - 1] : null;\n}\n\n/**\n * Extract a single endpoint entry from a pair node like:\n * `demo: { env: \"DATABRICKS_SERVING_ENDPOINT_NAME\", servedModel: \"my-model\" }`\n */\nfunction extractEndpointEntry(\n pair: SgNode,\n): { alias: string; config: EndpointConfig } | null {\n const children = pair.children();\n if (children.length < 3) return null;\n\n // Get alias name (the key)\n const keyNode = children[0];\n const alias =\n keyNode.kind() === \"property_identifier\"\n ? keyNode.text()\n : keyNode.kind() === \"string\"\n ? keyNode.text().replace(/^['\"]|['\"]$/g, \"\")\n : null;\n\n if (!alias) return null;\n\n // Get the value (should be an object like { env: \"...\" })\n const valueNode = children[children.length - 1];\n if (valueNode.kind() !== \"object\") return null;\n\n // Extract env field\n const envPair = findProperty(valueNode, \"env\");\n if (!envPair) return null;\n\n const envValue = getPropertyValue(envPair);\n if (!envValue || envValue.kind() !== \"string\") return null;\n\n const env = envValue.text().replace(/^['\"]|['\"]$/g, \"\");\n\n // Extract optional servedModel field\n const config: EndpointConfig = { env };\n const servedModelPair = findProperty(valueNode, \"servedModel\");\n if (servedModelPair) {\n const servedModelValue = getPropertyValue(servedModelPair);\n if (servedModelValue?.kind() === \"string\") {\n config.servedModel = servedModelValue.text().replace(/^['\"]|['\"]$/g, \"\");\n }\n }\n\n return { alias, config };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/utils/is-plain-object.ts
|
|
2
|
+
/** Test for an object whose direct prototype is Object.prototype or null. */
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
if (typeof value !== "object" || value === null) return false;
|
|
5
|
+
const prototype = Object.getPrototypeOf(value);
|
|
6
|
+
return prototype === Object.prototype || prototype === null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
export { isPlainObject };
|
|
11
|
+
//# sourceMappingURL=is-plain-object.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"is-plain-object.js","names":[],"sources":["../../src/utils/is-plain-object.ts"],"sourcesContent":["/** Test for an object whose direct prototype is Object.prototype or null. */\nexport function isPlainObject(\n value: unknown,\n): value is Record<string, unknown> {\n if (typeof value !== \"object\" || value === null) return false;\n const prototype = Object.getPrototypeOf(value);\n return prototype === Object.prototype || prototype === null;\n}\n"],"mappings":";;AACA,SAAgB,cACd,OACkC;AAClC,KAAI,OAAO,UAAU,YAAY,UAAU,KAAM,QAAO;CACxD,MAAM,YAAY,OAAO,eAAe,MAAM;AAC9C,QAAO,cAAc,OAAO,aAAa,cAAc"}
|
|
@@ -32,6 +32,7 @@ console.error(error.toJSON()); // Safe for logging, sensitive values redacted
|
|
|
32
32
|
* [`ConnectionError`](./docs/api/appkit/Class.ConnectionError.md)
|
|
33
33
|
* [`DatabaseValidationError`](./docs/api/appkit/Class.DatabaseValidationError.md)
|
|
34
34
|
* [`ExecutionError`](./docs/api/appkit/Class.ExecutionError.md)
|
|
35
|
+
* [`IdentityExpiredError`](./docs/api/appkit/Class.IdentityExpiredError.md)
|
|
35
36
|
* [`InitializationError`](./docs/api/appkit/Class.InitializationError.md)
|
|
36
37
|
* [`ServerError`](./docs/api/appkit/Class.ServerError.md)
|
|
37
38
|
* [`TunnelError`](./docs/api/appkit/Class.TunnelError.md)
|
|
@@ -80,7 +81,7 @@ protected readonly optional _clientMessage: string;
|
|
|
80
81
|
|
|
81
82
|
```
|
|
82
83
|
|
|
83
|
-
Client-safe error message. When set, callers serializing the error to a client (SSE, HTTP body) MUST prefer `clientMessage` over `message
|
|
84
|
+
Client-safe error message. When set, callers serializing the error to a client (SSE, HTTP body) MUST prefer `clientMessage` over `message`. `message` may contain raw upstream / SDK text including statement fragments, internal object names, and correlation IDs.
|
|
84
85
|
|
|
85
86
|
Subclasses can set this in their constructor for a fixed sanitized string. When unset, `clientMessage` defaults to a generic per-code string (see the getter), and the raw `message` is kept server-side only.
|
|
86
87
|
|