@databricks/appkit 0.72.0 → 0.73.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.
Files changed (117) hide show
  1. package/CLAUDE.md +17 -0
  2. package/NOTICE.md +1 -0
  3. package/dist/appkit/package.js +1 -1
  4. package/dist/beta.d.ts +7 -4
  5. package/dist/beta.js +3 -2
  6. package/dist/cli/commands/agent/eval.js +9 -1
  7. package/dist/cli/commands/agent/eval.js.map +1 -1
  8. package/dist/connectors/index.js +1 -1
  9. package/dist/connectors/mlflow/auth.d.ts +11 -1
  10. package/dist/connectors/mlflow/auth.d.ts.map +1 -1
  11. package/dist/connectors/mlflow/auth.js +22 -2
  12. package/dist/connectors/mlflow/auth.js.map +1 -1
  13. package/dist/connectors/mlflow/index.d.ts +2 -0
  14. package/dist/database/errors.js +15 -5
  15. package/dist/database/errors.js.map +1 -1
  16. package/dist/database/runtime/data-path.d.ts +7 -0
  17. package/dist/database/runtime/data-path.d.ts.map +1 -0
  18. package/dist/database/runtime/data-path.js.map +1 -1
  19. package/dist/database/runtime/engine/drizzle-data-path.js +7 -5
  20. package/dist/database/runtime/engine/drizzle-data-path.js.map +1 -1
  21. package/dist/database/schema-builder/define-schema.d.ts +1 -1
  22. package/dist/database/schema-builder/define-schema.js +1 -1
  23. package/dist/database/schema-builder/define-schema.js.map +1 -1
  24. package/dist/errors/database-validation.d.ts +23 -0
  25. package/dist/errors/database-validation.d.ts.map +1 -0
  26. package/dist/errors/database-validation.js +24 -0
  27. package/dist/errors/database-validation.js.map +1 -0
  28. package/dist/errors/index.js +1 -0
  29. package/dist/evals/dataset.d.ts +36 -0
  30. package/dist/evals/dataset.d.ts.map +1 -0
  31. package/dist/evals/dataset.js +36 -0
  32. package/dist/evals/dataset.js.map +1 -0
  33. package/dist/evals/http-driver.js +56 -51
  34. package/dist/evals/http-driver.js.map +1 -1
  35. package/dist/evals/index.d.ts +14 -0
  36. package/dist/evals/index.js +2 -1
  37. package/dist/evals/judge.d.ts +1 -0
  38. package/dist/evals/judge.d.ts.map +1 -1
  39. package/dist/evals/mlflow-report.d.ts +1 -0
  40. package/dist/evals/mlflow-report.d.ts.map +1 -1
  41. package/dist/evals/mlflow-run.d.ts +2 -0
  42. package/dist/evals/mlflow-run.d.ts.map +1 -1
  43. package/dist/evals/run-eval.d.ts +3 -0
  44. package/dist/evals/run-eval.d.ts.map +1 -1
  45. package/dist/evals/run-eval.js +10 -2
  46. package/dist/evals/run-eval.js.map +1 -1
  47. package/dist/evals/run-evals.d.ts +9 -0
  48. package/dist/evals/run-evals.d.ts.map +1 -1
  49. package/dist/evals/run-evals.js +101 -22
  50. package/dist/evals/run-evals.js.map +1 -1
  51. package/dist/evals/types.d.ts +40 -4
  52. package/dist/evals/types.d.ts.map +1 -1
  53. package/dist/index.d.ts +2 -1
  54. package/dist/index.js +2 -1
  55. package/dist/plugin/plugin.d.ts.map +1 -1
  56. package/dist/plugin/plugin.js +1 -1
  57. package/dist/plugin/plugin.js.map +1 -1
  58. package/dist/plugins/database/crud/contract.js +17 -8
  59. package/dist/plugins/database/crud/contract.js.map +1 -1
  60. package/dist/plugins/database/crud/exposure.js +63 -22
  61. package/dist/plugins/database/crud/exposure.js.map +1 -1
  62. package/dist/plugins/database/crud/request.js +50 -0
  63. package/dist/plugins/database/crud/request.js.map +1 -0
  64. package/dist/plugins/database/crud/response.js +77 -0
  65. package/dist/plugins/database/crud/response.js.map +1 -0
  66. package/dist/plugins/database/crud/routes.js +71 -52
  67. package/dist/plugins/database/crud/routes.js.map +1 -1
  68. package/dist/plugins/database/database.d.ts +6 -4
  69. package/dist/plugins/database/database.d.ts.map +1 -1
  70. package/dist/plugins/database/database.js +46 -16
  71. package/dist/plugins/database/database.js.map +1 -1
  72. package/dist/plugins/database/defaults.js +5 -1
  73. package/dist/plugins/database/defaults.js.map +1 -1
  74. package/dist/plugins/database/entity-client.js +143 -10
  75. package/dist/plugins/database/entity-client.js.map +1 -1
  76. package/dist/plugins/database/entity-types.d.ts +1 -1
  77. package/dist/plugins/database/hooks.d.ts +38 -0
  78. package/dist/plugins/database/hooks.d.ts.map +1 -0
  79. package/dist/plugins/database/index.d.ts +3 -2
  80. package/dist/plugins/database/lifecycle.js +67 -28
  81. package/dist/plugins/database/lifecycle.js.map +1 -1
  82. package/dist/plugins/database/scope.js +58 -0
  83. package/dist/plugins/database/scope.js.map +1 -0
  84. package/dist/plugins/database/types.d.ts +40 -12
  85. package/dist/plugins/database/types.d.ts.map +1 -1
  86. package/dist/shared/src/schemas/manifest.d.ts +33 -33
  87. package/docs/api/appkit/Class.AppKitError.md +1 -0
  88. package/docs/api/appkit/Class.DatabaseValidationError.md +191 -0
  89. package/docs/api/appkit/Function.defineSchema.md +1 -1
  90. package/docs/api/appkit/Function.readEvalDataset.md +21 -0
  91. package/docs/api/appkit/Function.resolveWorkspaceClient.md +18 -0
  92. package/docs/api/appkit/Interface.AssertionHandle.md +1 -1
  93. package/docs/api/appkit/Interface.DatabaseValidationIssue.md +21 -0
  94. package/docs/api/appkit/Interface.DatasetRow.md +21 -0
  95. package/docs/api/appkit/Interface.EntityMutationHooks.md +173 -0
  96. package/docs/api/appkit/Interface.EvalDefinition.md +28 -0
  97. package/docs/api/appkit/Interface.EvalDriver.md +16 -1
  98. package/docs/api/appkit/Interface.HookApp.md +12 -0
  99. package/docs/api/appkit/Interface.HookContext.md +21 -0
  100. package/docs/api/appkit/Interface.ReadEvalDatasetOptions.md +34 -0
  101. package/docs/api/appkit/Interface.ReadSerializerContext.md +21 -0
  102. package/docs/api/appkit/Interface.RunEvalOptions.md +11 -0
  103. package/docs/api/appkit/Interface.RunEvalsOptions.md +22 -0
  104. package/docs/api/appkit/Interface.TestContext.md +41 -4
  105. package/docs/api/appkit/TypeAlias.DatabaseApiConfig.md +53 -0
  106. package/docs/api/appkit/TypeAlias.DatabaseApiWriteOperation.md +8 -0
  107. package/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md +49 -0
  108. package/docs/api/appkit/TypeAlias.DatabaseExports.md +3 -3
  109. package/docs/api/appkit/TypeAlias.EntityHooks.md +25 -0
  110. package/docs/api/appkit/TypeAlias.IDatabaseConfig.md +16 -5
  111. package/docs/api/appkit/TypeAlias.ReadSerializer.md +19 -0
  112. package/docs/api/appkit/TypeAlias.TransactionClient.md +19 -0
  113. package/docs/api/appkit.md +135 -119
  114. package/docs/plugins/database.md +144 -0
  115. package/llms.txt +17 -0
  116. package/package.json +2 -2
  117. package/sbom.cdx.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.js","names":["otelContext","context"],"sources":["../../src/plugin/plugin.ts"],"sourcesContent":["import { createContextKey, context as otelContext } from \"@opentelemetry/api\";\nimport type express from \"express\";\nimport type {\n BasePlugin,\n BasePluginConfig,\n IAppResponse,\n PluginEndpointMap,\n PluginExecuteConfig,\n PluginExecutionSettings,\n PluginPhase,\n RouteConfig,\n StreamExecuteHandler,\n StreamExecutionSettings,\n} from \"shared\";\nimport { camelToKebab } from \"shared\";\n\nimport { AppManager } from \"../app\";\nimport { CacheManager } from \"../cache\";\nimport { getCurrentUserId, runInUserContext, ServiceContext } from \"../context\";\nimport type { PluginContext } from \"../core/plugin-context\";\nimport { AppKitError, AuthenticationError } from \"../errors\";\nimport { createLogger } from \"../logging/logger\";\nimport { StreamManager } from \"../stream\";\nimport {\n type ITelemetry,\n normalizeTelemetryOptions,\n TelemetryManager,\n} from \"../telemetry\";\nimport { deepMerge } from \"../utils\";\nimport { forwardAsyncErrors } from \"../utils/safe-handler\";\nimport { DevFileReader } from \"./dev-reader\";\nimport type { ExecutionResult } from \"./execution-result\";\nimport { CacheInterceptor } from \"./interceptors/cache\";\nimport { RetryInterceptor } from \"./interceptors/retry\";\nimport { TelemetryInterceptor } from \"./interceptors/telemetry\";\nimport { TimeoutInterceptor } from \"./interceptors/timeout\";\nimport type {\n ExecutionInterceptor,\n InterceptorContext,\n} from \"./interceptors/types\";\n\nconst logger = createLogger(\"plugin\");\n\n/**\n * OTel context key for marking OBO dev mode fallback.\n * Set when asUser() is called in development mode without a user token.\n */\nconst DEV_OBO_FALLBACK_KEY = createContextKey(\"appkit.devOboFallback\");\n\n/**\n * Returns true if `value` is a plain object literal (not an array, Date,\n * class instance, etc.). Used to decide whether to recurse into nested\n * export shapes when wrapping functions.\n *\n * @internal exported so the AppKit core can reuse the same predicate for\n * its `bindExportMethods` walk; not part of the public package surface.\n */\nexport function isPlainObject(\n value: unknown,\n): value is Record<string, unknown> {\n if (typeof value !== \"object\" || value === null) return false;\n const proto = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}\n\n/**\n * Returns a deep copy of `exports` where every function has been replaced\n * with `wrap(fn)`, walking into nested plain objects.\n *\n * Used by the asUser proxy to make the user context follow function\n * references that escape the proxy via `exports()`. The original input is\n * not mutated, so plugins that memoize `exports()` are safe — each call\n * through the proxy yields an independent, freshly wrapped view.\n */\nfunction wrapExportFunctions(\n exports: Record<string, unknown>,\n wrap: (fn: (...a: unknown[]) => unknown) => (...a: unknown[]) => unknown,\n): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n for (const key of Object.keys(exports)) {\n const val = exports[key];\n if (typeof val === \"function\") {\n result[key] = wrap(val as (...a: unknown[]) => unknown);\n } else if (isPlainObject(val)) {\n result[key] = wrapExportFunctions(val, wrap);\n } else {\n result[key] = val;\n }\n }\n return result;\n}\n\n/**\n * Returns true if the current execution is an OBO dev mode fallback\n * (asUser() was called but fell back to service principal due to missing token).\n */\nexport function isDevOboFallback(): boolean {\n return otelContext.active().getValue(DEV_OBO_FALLBACK_KEY) === true;\n}\n\n/**\n * Narrow an unknown thrown value to an Error that carries a numeric\n * `statusCode` property (e.g. `ApiError` from `@databricks/sdk-experimental`).\n */\nfunction hasHttpStatusCode(\n error: unknown,\n): error is Error & { statusCode: number } {\n return (\n error instanceof Error &&\n \"statusCode\" in error &&\n typeof (error as Record<string, unknown>).statusCode === \"number\"\n );\n}\n\n/**\n * Methods that should not be proxied by asUser().\n * These are lifecycle/internal methods that don't make sense\n * to execute in a user context.\n */\nconst EXCLUDED_FROM_PROXY = new Set([\n // Lifecycle methods\n \"setup\",\n \"shutdown\",\n \"attachContext\",\n \"injectRoutes\",\n \"getEndpoints\",\n \"getSkipBodyParsingPaths\",\n \"abortActiveOperations\",\n \"clientConfig\",\n // asUser itself - prevent chaining like .asUser().asUser()\n \"asUser\",\n // Internal methods\n \"constructor\",\n]);\n\n/**\n * Base abstract class for creating AppKit plugins.\n *\n * All plugins must declare a static `manifest` property with their metadata\n * and resource requirements. The manifest defines:\n * - `required` resources: Always needed for the plugin to function\n * - `optional` resources: May be needed depending on plugin configuration\n *\n * ## Static vs Runtime Resource Requirements\n *\n * The manifest is static and doesn't know the plugin's runtime configuration.\n * For resources that become required based on config options, plugins can\n * implement a static `getResourceRequirements(config)` method.\n *\n * At runtime, this method is called with the actual config to determine\n * which \"optional\" resources should be treated as \"required\".\n *\n * @example Basic plugin with static requirements\n * ```typescript\n * import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit';\n *\n * const myManifest: PluginManifest = {\n * name: 'myPlugin',\n * displayName: 'My Plugin',\n * description: 'Does something awesome',\n * resources: {\n * required: [\n * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... }\n * ],\n * optional: []\n * }\n * };\n *\n * class MyPlugin extends Plugin<MyConfig> {\n * static manifest = myManifest;\n * }\n * ```\n *\n * @example Plugin with config-dependent resources\n * ```typescript\n * interface MyConfig extends BasePluginConfig {\n * enableCaching?: boolean;\n * }\n *\n * const myManifest: PluginManifest = {\n * name: 'myPlugin',\n * resources: {\n * required: [\n * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... }\n * ],\n * optional: [\n * // Database is optional in the static manifest\n * { type: ResourceType.DATABASE, alias: 'cache', description: 'Required if caching enabled', ... }\n * ]\n * }\n * };\n *\n * class MyPlugin extends Plugin<MyConfig> {\n * static manifest = myManifest<\"myPlugin\">;\n *\n * // Runtime method: converts optional resources to required based on config\n * static getResourceRequirements(config: MyConfig) {\n * const resources = [];\n * if (config.enableCaching) {\n * // When caching is enabled, Database becomes required\n * resources.push({\n * type: ResourceType.DATABASE,\n * alias: 'cache',\n * resourceKey: 'database',\n * description: 'Cache storage for query results',\n * permission: 'CAN_CONNECT_AND_CREATE',\n * fields: {\n * instance_name: { env: 'DATABRICKS_CACHE_INSTANCE' },\n * database_name: { env: 'DATABRICKS_CACHE_DB' },\n * },\n * required: true // Mark as required at runtime\n * });\n * }\n * return resources;\n * }\n * }\n * ```\n */\nexport abstract class Plugin<\n TConfig extends BasePluginConfig = BasePluginConfig,\n> implements BasePlugin {\n protected isReady = false;\n protected cache!: CacheManager;\n protected app: AppManager;\n protected devFileReader: DevFileReader;\n protected streamManager: StreamManager;\n protected telemetry!: ITelemetry;\n protected context?: PluginContext;\n\n /** Registered endpoints for this plugin */\n private registeredEndpoints: PluginEndpointMap = {};\n\n /** Paths that opt out of JSON body parsing (e.g. file upload routes) */\n private skipBodyParsingPaths: Set<string> = new Set();\n\n /**\n * Plugin initialization phase.\n * - 'core': Initialized first (e.g., config plugins)\n * - 'normal': Initialized second (most plugins)\n * - 'deferred': Initialized last (e.g., server plugin)\n */\n static phase: PluginPhase = \"normal\";\n\n /**\n * Plugin name identifier.\n */\n name: string;\n\n constructor(protected config: TConfig) {\n this.name =\n config.name ??\n (this.constructor as { manifest?: { name: string } }).manifest?.name ??\n \"plugin\";\n this.streamManager = new StreamManager(config.streamConfig);\n this.app = new AppManager();\n this.devFileReader = DevFileReader.getInstance();\n this.context = (config as Record<string, unknown>).context as\n | PluginContext\n | undefined;\n\n // Eagerly bind telemetry + cache if the core services have already been\n // initialized (normal createApp path, or tests that mock CacheManager).\n // If they haven't, we leave these undefined and rely on `attachContext`\n // being called later — this lets factories eagerly construct plugin\n // instances at module top-level before `createApp` has run.\n this.tryAttachContext();\n }\n\n private tryAttachContext(): void {\n try {\n this.cache = CacheManager.getInstanceSync();\n } catch {\n return;\n }\n this.telemetry = TelemetryManager.getProvider(\n this.name,\n this.config.telemetry,\n );\n this.isReady = true;\n }\n\n /**\n * Binds runtime dependencies (telemetry provider, cache, plugin context) to\n * this plugin. Called by `AppKit._createApp` after construction and before\n * `setup()`. Idempotent: safe to call if the constructor already bound them\n * eagerly. Kept separate so factories can eagerly construct plugin instances\n * without running this before `TelemetryManager.initialize()` /\n * `CacheManager.getInstance()` have run.\n */\n attachContext(\n deps: {\n context?: unknown;\n telemetryConfig?: BasePluginConfig[\"telemetry\"];\n } = {},\n ): void {\n if (!this.cache) {\n this.cache = CacheManager.getInstanceSync();\n }\n this.telemetry = TelemetryManager.getProvider(\n this.name,\n deps.telemetryConfig ?? this.config.telemetry,\n );\n if (deps.context !== undefined) {\n this.context = deps.context as PluginContext;\n }\n this.isReady = true;\n }\n\n injectRoutes(_: express.Router) {\n return;\n }\n\n async setup() {}\n\n getEndpoints(): PluginEndpointMap {\n return this.registeredEndpoints;\n }\n\n getSkipBodyParsingPaths(): ReadonlySet<string> {\n return this.skipBodyParsingPaths;\n }\n\n abortActiveOperations(): void {\n this.streamManager.abortAll();\n }\n\n /**\n * Returns the public exports for this plugin.\n * Override this to define a custom public API.\n * By default, returns an empty object.\n *\n * The returned object becomes the plugin's public API on the AppKit instance\n * (e.g. `appkit.myPlugin.method()`). AppKit automatically binds method context\n * and adds `asUser(req)` for user-scoped execution.\n *\n * @example\n * ```ts\n * class MyPlugin extends Plugin {\n * private getData() { return []; }\n *\n * exports() {\n * return { getData: this.getData };\n * }\n * }\n *\n * // After registration:\n * const appkit = await createApp({ plugins: [myPlugin()] });\n * appkit.myPlugin.getData();\n * ```\n */\n exports(): unknown {\n return {};\n }\n\n /**\n * Returns startup config to expose to the client.\n * Override this to surface server-side values that are safe to publish to the\n * frontend, such as feature flags, resource IDs, or other app boot settings.\n *\n * This runs once when the server starts, so it should not depend on\n * request-scoped or user-specific state.\n *\n * String values that match non-public environment variables are redacted\n * unless you intentionally expose them via a matching `PUBLIC_APPKIT_` env var.\n *\n * Values must be JSON-serializable plain data (no functions, Dates, classes,\n * Maps, Sets, BigInts, or circular references).\n * By default returns an empty object (plugin contributes nothing to client config).\n *\n * On the client, read the config with the `usePluginClientConfig` hook\n * (React) or the `getPluginClientConfig` function (vanilla JS), both\n * from `@databricks/appkit-ui`.\n *\n * @example\n * ```ts\n * // Server — plugin definition\n * class MyPlugin extends Plugin<MyConfig> {\n * clientConfig() {\n * return {\n * warehouseId: this.config.warehouseId,\n * features: { darkMode: true },\n * };\n * }\n * }\n *\n * // Client — React component\n * import { usePluginClientConfig } from \"@databricks/appkit-ui/react\";\n *\n * interface MyPluginConfig { warehouseId: string; features: { darkMode: boolean } }\n *\n * const config = usePluginClientConfig<MyPluginConfig>(\"myPlugin\");\n * config.warehouseId; // \"abc-123\"\n *\n * // Client — vanilla JS\n * import { getPluginClientConfig } from \"@databricks/appkit-ui/js\";\n *\n * const config = getPluginClientConfig<MyPluginConfig>(\"myPlugin\");\n * ```\n */\n clientConfig(): Record<string, unknown> {\n return {};\n }\n\n /**\n * Resolve the effective user ID from a request.\n *\n * Returns the `x-forwarded-user` header when present. In development mode\n * (`NODE_ENV=development`) falls back to the current context user ID so\n * that callers outside an active `runInUserContext` scope still get a\n * consistent value.\n *\n * @throws AuthenticationError in production when no user header is present.\n */\n protected resolveUserId(req: express.Request): string {\n const userId = req.header(\"x-forwarded-user\")?.trim();\n if (userId) return userId;\n if (process.env.NODE_ENV === \"development\") return getCurrentUserId();\n throw AuthenticationError.missingUserId();\n }\n\n /**\n * Execute operations using the user's identity from the request.\n * Returns a proxy of this plugin where all method calls execute\n * with the user's Databricks credentials instead of the service principal.\n *\n * @param req - The Express request containing the user token in headers\n * @returns A proxied plugin instance that executes as the user\n * @throws AuthenticationError if user token is not available in request headers (production only).\n * In development mode (`NODE_ENV=development`), skips user impersonation instead of throwing.\n */\n asUser(req: express.Request): this {\n const token = req.header(\"x-forwarded-access-token\")?.trim();\n const userId = req.header(\"x-forwarded-user\")?.trim();\n const userEmail = req.header(\"x-forwarded-email\");\n const isDev = process.env.NODE_ENV === \"development\";\n\n // In local development, skip user impersonation since there's no user\n // token available. Mark execution as OBO dev fallback via OTel context\n // so telemetry can distinguish intended OBO calls from regular SP calls.\n if (!token && isDev) {\n logger.warn(\n \"asUser() called without user token in development mode. Skipping user impersonation.\",\n );\n\n return this._createAsUserProxy((fn) => (...args) => {\n const ctx = otelContext.active().setValue(DEV_OBO_FALLBACK_KEY, true);\n return otelContext.with(ctx, () => fn(...args));\n });\n }\n\n if (!token) {\n throw AuthenticationError.missingToken(\"user token\");\n }\n\n if (!userId && !isDev) {\n throw AuthenticationError.missingUserId();\n }\n\n const effectiveUserId = userId || \"dev-user\";\n\n const userContext = ServiceContext.createUserContext(\n token,\n effectiveUserId,\n undefined,\n userEmail ?? undefined,\n );\n\n return this._createAsUserProxy(\n (fn) =>\n (...args) =>\n runInUserContext(userContext, () => fn(...args)),\n );\n }\n\n /**\n * Creates a proxy of `this` where every method call — and every function\n * in the result of `exports()` — runs inside `wrapCall`.\n *\n * `wrapCall` decides the per-call scope. Two strategies are used today:\n * - real OBO: fn => (...args) => runInUserContext(userContext, () => fn(...args))\n * - dev fallback: fn => (...args) => otelContext.with(DEV_OBO_FALLBACK_KEY=true, () => fn(...args))\n *\n * `exports` is intercepted because methods captured in the returned\n * exports object never re-enter the proxy's `get` trap. Wrapping them\n * here is the only way to make the user context follow function\n * references back out of the plugin.\n */\n private _createAsUserProxy(\n wrapCall: (\n fn: (...a: unknown[]) => unknown,\n ) => (...a: unknown[]) => unknown,\n ): this {\n return new Proxy(this, {\n get: (target, prop, receiver) => {\n const value = Reflect.get(target, prop, receiver);\n\n if (typeof value !== \"function\") return value;\n if (typeof prop === \"string\" && EXCLUDED_FROM_PROXY.has(prop))\n return value;\n\n if (prop === \"exports\") {\n return () => {\n const raw = (value as () => unknown).call(target);\n if (raw == null) return {};\n // Callable exports (e.g. files, jobs) manage per-call asUser\n // themselves; leave them untouched.\n if (typeof raw === \"function\") return raw;\n if (isPlainObject(raw)) {\n return wrapExportFunctions(raw, wrapCall);\n }\n return raw;\n };\n }\n\n const fn = (value as (...a: unknown[]) => unknown).bind(target);\n return wrapCall(fn);\n },\n }) as this;\n }\n\n // streaming execution with interceptors\n protected async executeStream<T>(\n res: IAppResponse,\n fn: StreamExecuteHandler<T>,\n options: StreamExecutionSettings,\n userKey?: string,\n ) {\n // destructure options\n const {\n stream: streamConfig,\n default: defaultConfig,\n user: userConfig,\n } = options;\n\n // build execution options\n const executeConfig = this._buildExecutionConfig({\n default: defaultConfig,\n user: userConfig,\n });\n\n // get user key from context if not provided\n const effectiveUserKey = userKey ?? getCurrentUserId();\n\n const self = this;\n // capture the active OTel context (HTTP span) before entering the async generator,\n // where it would otherwise be lost across the async boundary\n const parentOtelContext = otelContext.active();\n\n // wrapper function to ensure it returns a generator\n const asyncWrapperFn = async function* (streamSignal?: AbortSignal) {\n // build execution context\n const context: InterceptorContext = {\n signal: streamSignal,\n metadata: new Map(),\n userKey: effectiveUserKey,\n };\n\n // build interceptors\n const interceptors = self._buildInterceptors(executeConfig);\n\n // wrap the function to ensure it returns a promise\n const wrappedFn = async () => {\n const result = await fn(context.signal);\n return result;\n };\n\n // execute the function with interceptors, restoring the parent OTel context\n // so telemetry spans are linked as children of the HTTP request span\n const result = await otelContext.with(parentOtelContext, () =>\n self._executeWithInterceptors(\n wrappedFn as (signal?: AbortSignal) => Promise<T>,\n interceptors,\n context,\n ),\n );\n\n // check if result is a generator\n if (self._checkIfGenerator(result)) {\n yield* result;\n } else {\n yield result;\n }\n };\n\n // stream the result to the client. The effective user key is forwarded\n // to the stream manager so that reconnections to existing streamIds are\n // bound to the original creator (prevents cross-user stream takeover via\n // guessed/leaked IDs).\n await this.streamManager.stream(\n res,\n asyncWrapperFn,\n streamConfig,\n effectiveUserKey,\n );\n }\n\n /**\n * Execute a function with the plugin's interceptor chain.\n *\n * Returns an {@link ExecutionResult} discriminated union:\n * - `{ ok: true, data: T }` on success\n * - `{ ok: false, status: number, message: string }` on failure\n *\n * Errors are never thrown — the method is production-safe.\n */\n protected async execute<T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n options: PluginExecutionSettings,\n userKey?: string,\n ): Promise<ExecutionResult<T>> {\n const executeConfig = this._buildExecutionConfig(options);\n\n const interceptors = this._buildInterceptors(executeConfig);\n\n // get user key from context if not provided\n const effectiveUserKey = userKey ?? getCurrentUserId();\n\n const context: InterceptorContext = {\n metadata: new Map(),\n userKey: effectiveUserKey,\n };\n\n try {\n const data = await this._executeWithInterceptors(\n fn,\n interceptors,\n context,\n );\n return { ok: true, data };\n } catch (error) {\n logger.error(\"Plugin execution failed\", { error, plugin: this.name });\n\n if (error instanceof AppKitError) {\n return {\n ok: false,\n status: error.statusCode,\n message: error.message,\n };\n }\n\n if (hasHttpStatusCode(error)) {\n const isDev = process.env.NODE_ENV !== \"production\";\n const isClientError = error.statusCode >= 400 && error.statusCode < 500;\n return {\n ok: false,\n status: error.statusCode,\n message: isDev || isClientError ? error.message : \"Server error\",\n };\n }\n\n const isDev = process.env.NODE_ENV !== \"production\";\n return {\n ok: false,\n status: 500,\n message:\n isDev && error instanceof Error ? error.message : \"Server error\",\n };\n }\n }\n\n protected registerEndpoint(name: string, path: string): void {\n this.registeredEndpoints[name] = path;\n }\n\n protected route<_TResponse>(\n router: express.Router,\n config: RouteConfig,\n ): void {\n const { name, method, path, handler } = config;\n\n router[method](path, forwardAsyncErrors(handler));\n\n const fullPath = `/api/${camelToKebab(this.name)}${path}`;\n this.registerEndpoint(name, fullPath);\n\n if (config.skipBodyParsing) {\n this.skipBodyParsingPaths.add(fullPath);\n }\n }\n\n // build execution options by merging defaults, plugin config, and user overrides\n private _buildExecutionConfig(\n options: PluginExecutionSettings,\n ): PluginExecuteConfig {\n const { default: methodDefaults, user: userOverride } = options;\n\n // Merge: method defaults <- plugin config <- user override (highest priority)\n return deepMerge(\n deepMerge(methodDefaults, this.config),\n userOverride ?? {},\n ) as PluginExecuteConfig;\n }\n\n // build interceptors based on execute options\n private _buildInterceptors(\n options: PluginExecuteConfig,\n ): ExecutionInterceptor[] {\n const interceptors: ExecutionInterceptor[] = [];\n\n // order matters: telemetry → timeout → retry → cache (innermost to outermost)\n\n const telemetryConfig = normalizeTelemetryOptions(this.config.telemetry);\n if (\n telemetryConfig.traces &&\n (options.telemetryInterceptor?.enabled ?? true)\n ) {\n interceptors.push(\n new TelemetryInterceptor(this.telemetry, options.telemetryInterceptor),\n );\n }\n\n if (options.timeout && options.timeout > 0) {\n interceptors.push(new TimeoutInterceptor(options.timeout));\n }\n\n if (\n options.retry?.enabled &&\n options.retry.attempts &&\n options.retry.attempts > 1\n ) {\n interceptors.push(new RetryInterceptor(options.retry));\n }\n\n if (options.cache?.enabled && options.cache.cacheKey?.length) {\n interceptors.push(new CacheInterceptor(this.cache, options.cache));\n }\n\n return interceptors;\n }\n\n // execute method wrapped with interceptors\n private async _executeWithInterceptors<T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n interceptors: ExecutionInterceptor[],\n context: InterceptorContext,\n ): Promise<T> {\n // no interceptors, execute directly\n if (interceptors.length === 0) {\n return fn(context.signal);\n }\n // build nested execution chain from interceptors\n let wrappedFn = () => fn(context.signal);\n\n // wrap each interceptor around the previous function\n for (const interceptor of interceptors) {\n const previousFn = wrappedFn;\n wrappedFn = () => interceptor.intercept(previousFn, context);\n }\n\n return wrappedFn();\n }\n\n private _checkIfGenerator(\n result: any,\n ): result is AsyncGenerator<any, void, unknown> {\n return (\n result && typeof result === \"object\" && Symbol.asyncIterator in result\n );\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"plugin.js","names":["otelContext","context"],"sources":["../../src/plugin/plugin.ts"],"sourcesContent":["import { createContextKey, context as otelContext } from \"@opentelemetry/api\";\nimport type express from \"express\";\nimport type {\n BasePlugin,\n BasePluginConfig,\n IAppResponse,\n PluginEndpointMap,\n PluginExecuteConfig,\n PluginExecutionSettings,\n PluginPhase,\n RouteConfig,\n StreamExecuteHandler,\n StreamExecutionSettings,\n} from \"shared\";\nimport { camelToKebab } from \"shared\";\n\nimport { AppManager } from \"../app\";\nimport { CacheManager } from \"../cache\";\nimport { getCurrentUserId, runInUserContext, ServiceContext } from \"../context\";\nimport type { PluginContext } from \"../core/plugin-context\";\nimport { AppKitError, AuthenticationError } from \"../errors\";\nimport { createLogger } from \"../logging/logger\";\nimport { StreamManager } from \"../stream\";\nimport {\n type ITelemetry,\n normalizeTelemetryOptions,\n TelemetryManager,\n} from \"../telemetry\";\nimport { deepMerge } from \"../utils\";\nimport { forwardAsyncErrors } from \"../utils/safe-handler\";\nimport { DevFileReader } from \"./dev-reader\";\nimport type { ExecutionResult } from \"./execution-result\";\nimport { CacheInterceptor } from \"./interceptors/cache\";\nimport { RetryInterceptor } from \"./interceptors/retry\";\nimport { TelemetryInterceptor } from \"./interceptors/telemetry\";\nimport { TimeoutInterceptor } from \"./interceptors/timeout\";\nimport type {\n ExecutionInterceptor,\n InterceptorContext,\n} from \"./interceptors/types\";\n\nconst logger = createLogger(\"plugin\");\n\n/**\n * OTel context key for marking OBO dev mode fallback.\n * Set when asUser() is called in development mode without a user token.\n */\nconst DEV_OBO_FALLBACK_KEY = createContextKey(\"appkit.devOboFallback\");\n\n/**\n * Returns true if `value` is a plain object literal (not an array, Date,\n * class instance, etc.). Used to decide whether to recurse into nested\n * export shapes when wrapping functions.\n *\n * @internal exported so the AppKit core can reuse the same predicate for\n * its `bindExportMethods` walk; not part of the public package surface.\n */\nexport function isPlainObject(\n value: unknown,\n): value is Record<string, unknown> {\n if (typeof value !== \"object\" || value === null) return false;\n const proto = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}\n\n/**\n * Returns a deep copy of `exports` where every function has been replaced\n * with `wrap(fn)`, walking into nested plain objects.\n *\n * Used by the asUser proxy to make the user context follow function\n * references that escape the proxy via `exports()`. The original input is\n * not mutated, so plugins that memoize `exports()` are safe — each call\n * through the proxy yields an independent, freshly wrapped view.\n */\nfunction wrapExportFunctions(\n exports: Record<string, unknown>,\n wrap: (fn: (...a: unknown[]) => unknown) => (...a: unknown[]) => unknown,\n): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n for (const key of Object.keys(exports)) {\n const val = exports[key];\n if (typeof val === \"function\") {\n result[key] = wrap(val as (...a: unknown[]) => unknown);\n } else if (isPlainObject(val)) {\n result[key] = wrapExportFunctions(val, wrap);\n } else {\n result[key] = val;\n }\n }\n return result;\n}\n\n/**\n * Returns true if the current execution is an OBO dev mode fallback\n * (asUser() was called but fell back to service principal due to missing token).\n */\nexport function isDevOboFallback(): boolean {\n return otelContext.active().getValue(DEV_OBO_FALLBACK_KEY) === true;\n}\n\n/**\n * Narrow an unknown thrown value to an Error that carries a numeric\n * `statusCode` property (e.g. `ApiError` from `@databricks/sdk-experimental`).\n */\nfunction hasHttpStatusCode(\n error: unknown,\n): error is Error & { statusCode: number } {\n return (\n error instanceof Error &&\n \"statusCode\" in error &&\n typeof (error as Record<string, unknown>).statusCode === \"number\"\n );\n}\n\n/**\n * Methods that should not be proxied by asUser().\n * These are lifecycle/internal methods that don't make sense\n * to execute in a user context.\n */\nconst EXCLUDED_FROM_PROXY = new Set([\n // Lifecycle methods\n \"setup\",\n \"shutdown\",\n \"attachContext\",\n \"injectRoutes\",\n \"getEndpoints\",\n \"getSkipBodyParsingPaths\",\n \"abortActiveOperations\",\n \"clientConfig\",\n // asUser itself - prevent chaining like .asUser().asUser()\n \"asUser\",\n // Internal methods\n \"constructor\",\n]);\n\n/**\n * Base abstract class for creating AppKit plugins.\n *\n * All plugins must declare a static `manifest` property with their metadata\n * and resource requirements. The manifest defines:\n * - `required` resources: Always needed for the plugin to function\n * - `optional` resources: May be needed depending on plugin configuration\n *\n * ## Static vs Runtime Resource Requirements\n *\n * The manifest is static and doesn't know the plugin's runtime configuration.\n * For resources that become required based on config options, plugins can\n * implement a static `getResourceRequirements(config)` method.\n *\n * At runtime, this method is called with the actual config to determine\n * which \"optional\" resources should be treated as \"required\".\n *\n * @example Basic plugin with static requirements\n * ```typescript\n * import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit';\n *\n * const myManifest: PluginManifest = {\n * name: 'myPlugin',\n * displayName: 'My Plugin',\n * description: 'Does something awesome',\n * resources: {\n * required: [\n * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... }\n * ],\n * optional: []\n * }\n * };\n *\n * class MyPlugin extends Plugin<MyConfig> {\n * static manifest = myManifest;\n * }\n * ```\n *\n * @example Plugin with config-dependent resources\n * ```typescript\n * interface MyConfig extends BasePluginConfig {\n * enableCaching?: boolean;\n * }\n *\n * const myManifest: PluginManifest = {\n * name: 'myPlugin',\n * resources: {\n * required: [\n * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... }\n * ],\n * optional: [\n * // Database is optional in the static manifest\n * { type: ResourceType.DATABASE, alias: 'cache', description: 'Required if caching enabled', ... }\n * ]\n * }\n * };\n *\n * class MyPlugin extends Plugin<MyConfig> {\n * static manifest = myManifest<\"myPlugin\">;\n *\n * // Runtime method: converts optional resources to required based on config\n * static getResourceRequirements(config: MyConfig) {\n * const resources = [];\n * if (config.enableCaching) {\n * // When caching is enabled, Database becomes required\n * resources.push({\n * type: ResourceType.DATABASE,\n * alias: 'cache',\n * resourceKey: 'database',\n * description: 'Cache storage for query results',\n * permission: 'CAN_CONNECT_AND_CREATE',\n * fields: {\n * instance_name: { env: 'DATABRICKS_CACHE_INSTANCE' },\n * database_name: { env: 'DATABRICKS_CACHE_DB' },\n * },\n * required: true // Mark as required at runtime\n * });\n * }\n * return resources;\n * }\n * }\n * ```\n */\nexport abstract class Plugin<\n TConfig extends BasePluginConfig = BasePluginConfig,\n> implements BasePlugin {\n protected isReady = false;\n protected cache!: CacheManager;\n protected app: AppManager;\n protected devFileReader: DevFileReader;\n protected streamManager: StreamManager;\n protected telemetry!: ITelemetry;\n protected context?: PluginContext;\n\n /** Registered endpoints for this plugin */\n private registeredEndpoints: PluginEndpointMap = {};\n\n /** Paths that opt out of JSON body parsing (e.g. file upload routes) */\n private skipBodyParsingPaths: Set<string> = new Set();\n\n /**\n * Plugin initialization phase.\n * - 'core': Initialized first (e.g., config plugins)\n * - 'normal': Initialized second (most plugins)\n * - 'deferred': Initialized last (e.g., server plugin)\n */\n static phase: PluginPhase = \"normal\";\n\n /**\n * Plugin name identifier.\n */\n name: string;\n\n constructor(protected config: TConfig) {\n this.name =\n config.name ??\n (this.constructor as { manifest?: { name: string } }).manifest?.name ??\n \"plugin\";\n this.streamManager = new StreamManager(config.streamConfig);\n this.app = new AppManager();\n this.devFileReader = DevFileReader.getInstance();\n this.context = (config as Record<string, unknown>).context as\n | PluginContext\n | undefined;\n\n // Eagerly bind telemetry + cache if the core services have already been\n // initialized (normal createApp path, or tests that mock CacheManager).\n // If they haven't, we leave these undefined and rely on `attachContext`\n // being called later — this lets factories eagerly construct plugin\n // instances at module top-level before `createApp` has run.\n this.tryAttachContext();\n }\n\n private tryAttachContext(): void {\n try {\n this.cache = CacheManager.getInstanceSync();\n } catch {\n return;\n }\n this.telemetry = TelemetryManager.getProvider(\n this.name,\n this.config.telemetry,\n );\n this.isReady = true;\n }\n\n /**\n * Binds runtime dependencies (telemetry provider, cache, plugin context) to\n * this plugin. Called by `AppKit._createApp` after construction and before\n * `setup()`. Idempotent: safe to call if the constructor already bound them\n * eagerly. Kept separate so factories can eagerly construct plugin instances\n * without running this before `TelemetryManager.initialize()` /\n * `CacheManager.getInstance()` have run.\n */\n attachContext(\n deps: {\n context?: unknown;\n telemetryConfig?: BasePluginConfig[\"telemetry\"];\n } = {},\n ): void {\n if (!this.cache) {\n this.cache = CacheManager.getInstanceSync();\n }\n this.telemetry = TelemetryManager.getProvider(\n this.name,\n deps.telemetryConfig ?? this.config.telemetry,\n );\n if (deps.context !== undefined) {\n this.context = deps.context as PluginContext;\n }\n this.isReady = true;\n }\n\n injectRoutes(_: express.Router) {\n return;\n }\n\n async setup() {}\n\n getEndpoints(): PluginEndpointMap {\n return this.registeredEndpoints;\n }\n\n getSkipBodyParsingPaths(): ReadonlySet<string> {\n return this.skipBodyParsingPaths;\n }\n\n abortActiveOperations(): void {\n this.streamManager.abortAll();\n }\n\n /**\n * Returns the public exports for this plugin.\n * Override this to define a custom public API.\n * By default, returns an empty object.\n *\n * The returned object becomes the plugin's public API on the AppKit instance\n * (e.g. `appkit.myPlugin.method()`). AppKit automatically binds method context\n * and adds `asUser(req)` for user-scoped execution.\n *\n * @example\n * ```ts\n * class MyPlugin extends Plugin {\n * private getData() { return []; }\n *\n * exports() {\n * return { getData: this.getData };\n * }\n * }\n *\n * // After registration:\n * const appkit = await createApp({ plugins: [myPlugin()] });\n * appkit.myPlugin.getData();\n * ```\n */\n exports(): unknown {\n return {};\n }\n\n /**\n * Returns startup config to expose to the client.\n * Override this to surface server-side values that are safe to publish to the\n * frontend, such as feature flags, resource IDs, or other app boot settings.\n *\n * This runs once when the server starts, so it should not depend on\n * request-scoped or user-specific state.\n *\n * String values that match non-public environment variables are redacted\n * unless you intentionally expose them via a matching `PUBLIC_APPKIT_` env var.\n *\n * Values must be JSON-serializable plain data (no functions, Dates, classes,\n * Maps, Sets, BigInts, or circular references).\n * By default returns an empty object (plugin contributes nothing to client config).\n *\n * On the client, read the config with the `usePluginClientConfig` hook\n * (React) or the `getPluginClientConfig` function (vanilla JS), both\n * from `@databricks/appkit-ui`.\n *\n * @example\n * ```ts\n * // Server — plugin definition\n * class MyPlugin extends Plugin<MyConfig> {\n * clientConfig() {\n * return {\n * warehouseId: this.config.warehouseId,\n * features: { darkMode: true },\n * };\n * }\n * }\n *\n * // Client — React component\n * import { usePluginClientConfig } from \"@databricks/appkit-ui/react\";\n *\n * interface MyPluginConfig { warehouseId: string; features: { darkMode: boolean } }\n *\n * const config = usePluginClientConfig<MyPluginConfig>(\"myPlugin\");\n * config.warehouseId; // \"abc-123\"\n *\n * // Client — vanilla JS\n * import { getPluginClientConfig } from \"@databricks/appkit-ui/js\";\n *\n * const config = getPluginClientConfig<MyPluginConfig>(\"myPlugin\");\n * ```\n */\n clientConfig(): Record<string, unknown> {\n return {};\n }\n\n /**\n * Resolve the effective user ID from a request.\n *\n * Returns the `x-forwarded-user` header when present. In development mode\n * (`NODE_ENV=development`) falls back to the current context user ID so\n * that callers outside an active `runInUserContext` scope still get a\n * consistent value.\n *\n * @throws AuthenticationError in production when no user header is present.\n */\n protected resolveUserId(req: express.Request): string {\n const userId = req.header(\"x-forwarded-user\")?.trim();\n if (userId) return userId;\n if (process.env.NODE_ENV === \"development\") return getCurrentUserId();\n throw AuthenticationError.missingUserId();\n }\n\n /**\n * Execute operations using the user's identity from the request.\n * Returns a proxy of this plugin where all method calls execute\n * with the user's Databricks credentials instead of the service principal.\n *\n * @param req - The Express request containing the user token in headers\n * @returns A proxied plugin instance that executes as the user\n * @throws AuthenticationError if user token is not available in request headers (production only).\n * In development mode (`NODE_ENV=development`), skips user impersonation instead of throwing.\n */\n asUser(req: express.Request): this {\n const token = req.header(\"x-forwarded-access-token\")?.trim();\n const userId = req.header(\"x-forwarded-user\")?.trim();\n const userEmail = req.header(\"x-forwarded-email\");\n const isDev = process.env.NODE_ENV === \"development\";\n\n // In local development, skip user impersonation since there's no user\n // token available. Mark execution as OBO dev fallback via OTel context\n // so telemetry can distinguish intended OBO calls from regular SP calls.\n if (!token && isDev) {\n logger.warn(\n \"asUser() called without user token in development mode. Skipping user impersonation.\",\n );\n\n return this._createAsUserProxy((fn) => (...args) => {\n const ctx = otelContext.active().setValue(DEV_OBO_FALLBACK_KEY, true);\n return otelContext.with(ctx, () => fn(...args));\n });\n }\n\n if (!token) {\n throw AuthenticationError.missingToken(\"user token\");\n }\n\n if (!userId && !isDev) {\n throw AuthenticationError.missingUserId();\n }\n\n const effectiveUserId = userId || \"dev-user\";\n\n const userContext = ServiceContext.createUserContext(\n token,\n effectiveUserId,\n undefined,\n userEmail ?? undefined,\n );\n\n return this._createAsUserProxy(\n (fn) =>\n (...args) =>\n runInUserContext(userContext, () => fn(...args)),\n );\n }\n\n /**\n * Creates a proxy of `this` where every method call — and every function\n * in the result of `exports()` — runs inside `wrapCall`.\n *\n * `wrapCall` decides the per-call scope. Two strategies are used today:\n * - real OBO: fn => (...args) => runInUserContext(userContext, () => fn(...args))\n * - dev fallback: fn => (...args) => otelContext.with(DEV_OBO_FALLBACK_KEY=true, () => fn(...args))\n *\n * `exports` is intercepted because methods captured in the returned\n * exports object never re-enter the proxy's `get` trap. Wrapping them\n * here is the only way to make the user context follow function\n * references back out of the plugin.\n */\n private _createAsUserProxy(\n wrapCall: (\n fn: (...a: unknown[]) => unknown,\n ) => (...a: unknown[]) => unknown,\n ): this {\n return new Proxy(this, {\n get: (target, prop, receiver) => {\n const value = Reflect.get(target, prop, receiver);\n\n if (typeof value !== \"function\") return value;\n if (typeof prop === \"string\" && EXCLUDED_FROM_PROXY.has(prop))\n return value;\n\n if (prop === \"exports\") {\n return () => {\n const raw = (value as () => unknown).call(target);\n if (raw == null) return {};\n // Callable exports (e.g. files, jobs) manage per-call asUser\n // themselves; leave them untouched.\n if (typeof raw === \"function\") return raw;\n if (isPlainObject(raw)) {\n return wrapExportFunctions(raw, (fn) =>\n wrapCall(fn.bind(target)),\n );\n }\n return raw;\n };\n }\n\n const fn = (value as (...a: unknown[]) => unknown).bind(target);\n return wrapCall(fn);\n },\n }) as this;\n }\n\n // streaming execution with interceptors\n protected async executeStream<T>(\n res: IAppResponse,\n fn: StreamExecuteHandler<T>,\n options: StreamExecutionSettings,\n userKey?: string,\n ) {\n // destructure options\n const {\n stream: streamConfig,\n default: defaultConfig,\n user: userConfig,\n } = options;\n\n // build execution options\n const executeConfig = this._buildExecutionConfig({\n default: defaultConfig,\n user: userConfig,\n });\n\n // get user key from context if not provided\n const effectiveUserKey = userKey ?? getCurrentUserId();\n\n const self = this;\n // capture the active OTel context (HTTP span) before entering the async generator,\n // where it would otherwise be lost across the async boundary\n const parentOtelContext = otelContext.active();\n\n // wrapper function to ensure it returns a generator\n const asyncWrapperFn = async function* (streamSignal?: AbortSignal) {\n // build execution context\n const context: InterceptorContext = {\n signal: streamSignal,\n metadata: new Map(),\n userKey: effectiveUserKey,\n };\n\n // build interceptors\n const interceptors = self._buildInterceptors(executeConfig);\n\n // wrap the function to ensure it returns a promise\n const wrappedFn = async () => {\n const result = await fn(context.signal);\n return result;\n };\n\n // execute the function with interceptors, restoring the parent OTel context\n // so telemetry spans are linked as children of the HTTP request span\n const result = await otelContext.with(parentOtelContext, () =>\n self._executeWithInterceptors(\n wrappedFn as (signal?: AbortSignal) => Promise<T>,\n interceptors,\n context,\n ),\n );\n\n // check if result is a generator\n if (self._checkIfGenerator(result)) {\n yield* result;\n } else {\n yield result;\n }\n };\n\n // stream the result to the client. The effective user key is forwarded\n // to the stream manager so that reconnections to existing streamIds are\n // bound to the original creator (prevents cross-user stream takeover via\n // guessed/leaked IDs).\n await this.streamManager.stream(\n res,\n asyncWrapperFn,\n streamConfig,\n effectiveUserKey,\n );\n }\n\n /**\n * Execute a function with the plugin's interceptor chain.\n *\n * Returns an {@link ExecutionResult} discriminated union:\n * - `{ ok: true, data: T }` on success\n * - `{ ok: false, status: number, message: string }` on failure\n *\n * Errors are never thrown — the method is production-safe.\n */\n protected async execute<T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n options: PluginExecutionSettings,\n userKey?: string,\n ): Promise<ExecutionResult<T>> {\n const executeConfig = this._buildExecutionConfig(options);\n\n const interceptors = this._buildInterceptors(executeConfig);\n\n // get user key from context if not provided\n const effectiveUserKey = userKey ?? getCurrentUserId();\n\n const context: InterceptorContext = {\n metadata: new Map(),\n userKey: effectiveUserKey,\n };\n\n try {\n const data = await this._executeWithInterceptors(\n fn,\n interceptors,\n context,\n );\n return { ok: true, data };\n } catch (error) {\n logger.error(\"Plugin execution failed\", { error, plugin: this.name });\n\n if (error instanceof AppKitError) {\n return {\n ok: false,\n status: error.statusCode,\n message: error.message,\n };\n }\n\n if (hasHttpStatusCode(error)) {\n const isDev = process.env.NODE_ENV !== \"production\";\n const isClientError = error.statusCode >= 400 && error.statusCode < 500;\n return {\n ok: false,\n status: error.statusCode,\n message: isDev || isClientError ? error.message : \"Server error\",\n };\n }\n\n const isDev = process.env.NODE_ENV !== \"production\";\n return {\n ok: false,\n status: 500,\n message:\n isDev && error instanceof Error ? error.message : \"Server error\",\n };\n }\n }\n\n protected registerEndpoint(name: string, path: string): void {\n this.registeredEndpoints[name] = path;\n }\n\n protected route<_TResponse>(\n router: express.Router,\n config: RouteConfig,\n ): void {\n const { name, method, path, handler } = config;\n\n router[method](path, forwardAsyncErrors(handler));\n\n const fullPath = `/api/${camelToKebab(this.name)}${path}`;\n this.registerEndpoint(name, fullPath);\n\n if (config.skipBodyParsing) {\n this.skipBodyParsingPaths.add(fullPath);\n }\n }\n\n // build execution options by merging defaults, plugin config, and user overrides\n private _buildExecutionConfig(\n options: PluginExecutionSettings,\n ): PluginExecuteConfig {\n const { default: methodDefaults, user: userOverride } = options;\n\n // Merge: method defaults <- plugin config <- user override (highest priority)\n return deepMerge(\n deepMerge(methodDefaults, this.config),\n userOverride ?? {},\n ) as PluginExecuteConfig;\n }\n\n // build interceptors based on execute options\n private _buildInterceptors(\n options: PluginExecuteConfig,\n ): ExecutionInterceptor[] {\n const interceptors: ExecutionInterceptor[] = [];\n\n // order matters: telemetry → timeout → retry → cache (innermost to outermost)\n\n const telemetryConfig = normalizeTelemetryOptions(this.config.telemetry);\n if (\n telemetryConfig.traces &&\n (options.telemetryInterceptor?.enabled ?? true)\n ) {\n interceptors.push(\n new TelemetryInterceptor(this.telemetry, options.telemetryInterceptor),\n );\n }\n\n if (options.timeout && options.timeout > 0) {\n interceptors.push(new TimeoutInterceptor(options.timeout));\n }\n\n if (\n options.retry?.enabled &&\n options.retry.attempts &&\n options.retry.attempts > 1\n ) {\n interceptors.push(new RetryInterceptor(options.retry));\n }\n\n if (options.cache?.enabled && options.cache.cacheKey?.length) {\n interceptors.push(new CacheInterceptor(this.cache, options.cache));\n }\n\n return interceptors;\n }\n\n // execute method wrapped with interceptors\n private async _executeWithInterceptors<T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n interceptors: ExecutionInterceptor[],\n context: InterceptorContext,\n ): Promise<T> {\n // no interceptors, execute directly\n if (interceptors.length === 0) {\n return fn(context.signal);\n }\n // build nested execution chain from interceptors\n let wrappedFn = () => fn(context.signal);\n\n // wrap each interceptor around the previous function\n for (const interceptor of interceptors) {\n const previousFn = wrappedFn;\n wrappedFn = () => interceptor.intercept(previousFn, context);\n }\n\n return wrappedFn();\n }\n\n private _checkIfGenerator(\n result: any,\n ): result is AsyncGenerator<any, void, unknown> {\n return (\n result && typeof result === \"object\" && Symbol.asyncIterator in result\n );\n }\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { filterOperatorsForKind } from "../../../database/schema-builder/types.js";
2
- import { DatabasePluginError, invalidDatabaseInput } from "../../../database/errors.js";
2
+ import { DatabasePluginError } from "../../../database/errors.js";
3
3
  import { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from "../defaults.js";
4
4
  import { compileColumn } from "./codecs.js";
5
5
 
@@ -70,6 +70,13 @@ function sanitizeRow(table, row, depth, state) {
70
70
  return out;
71
71
  });
72
72
  }
73
+ /** The budget a caller's own JSON has to fit, same as the one going back out. */
74
+ function boundedJson(value) {
75
+ return sanitizeJson(value, 0, {
76
+ nodes: 0,
77
+ ancestors: /* @__PURE__ */ new Set()
78
+ });
79
+ }
73
80
  /** Project an included row through its own table; absent to-one reads null. */
74
81
  function projectRelation(target, value) {
75
82
  if (value === null || value === void 0) return null;
@@ -94,6 +101,8 @@ function compileTable(table) {
94
101
  const columns = /* @__PURE__ */ new Map();
95
102
  const selectable = /* @__PURE__ */ new Set();
96
103
  const queryable = /* @__PURE__ */ new Set();
104
+ const creatable = /* @__PURE__ */ new Set();
105
+ const updatable = /* @__PURE__ */ new Set();
97
106
  let primaryKey;
98
107
  for (const meta of Object.values(table.$columns)) {
99
108
  const column = compileColumn(meta);
@@ -102,6 +111,10 @@ function compileTable(table) {
102
111
  if (meta.isPrivate) continue;
103
112
  selectable.add(meta.columnName);
104
113
  if (filterOperatorsForKind(meta.kind).length > 0) queryable.add(meta.columnName);
114
+ if (meta.serverGenerated || meta.primaryKey && meta.defaultRandom) continue;
115
+ creatable.add(meta.columnName);
116
+ if (meta.primaryKey || meta.defaultNow || meta.defaultRandom) continue;
117
+ updatable.add(meta.columnName);
105
118
  }
106
119
  const compiled = {
107
120
  name: table.$name,
@@ -109,13 +122,9 @@ function compileTable(table) {
109
122
  columns,
110
123
  selectable,
111
124
  queryable,
125
+ creatable,
126
+ updatable,
112
127
  relations: /* @__PURE__ */ new Map(),
113
- decodeId: (raw) => {
114
- if (!primaryKey) throw new DatabasePluginError("INTERNAL", "read");
115
- const value = primaryKey.decode(raw);
116
- if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") throw invalidDatabaseInput(["id"], "Not a valid identifier");
117
- return value;
118
- },
119
128
  projectPublicRow: (row) => projectRow(compiled, row),
120
129
  sanitizeSerializedRow: (row) => sanitizeRow(compiled, row, 0, {
121
130
  nodes: 0,
@@ -147,5 +156,5 @@ function compileCrudTables(tables) {
147
156
  }
148
157
 
149
158
  //#endregion
150
- export { compileCrudTables };
159
+ export { boundedJson, compileCrudTables, isPlainObject };
151
160
  //# sourceMappingURL=contract.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"contract.js","names":[],"sources":["../../../../src/plugins/database/crud/contract.ts"],"sourcesContent":["import {\n DatabasePluginError,\n invalidDatabaseInput,\n} from \"../../../database/errors\";\nimport type { IdValue, Row } from \"../../../database/runtime\";\nimport type { AppKitTable } from \"../../../database/schema-builder\";\nimport { filterOperatorsForKind } from \"../../../database/schema-builder/types\";\nimport { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from \"../defaults\";\nimport { type CompiledColumn, compileColumn, type JsonValue } from \"./codecs\";\n\n/** One relation edge wired to the contract of its target table. */\nexport interface CrudRelation {\n readonly cardinality: \"toOne\" | \"toMany\";\n readonly target: CrudTable;\n}\n\n/** Private HTTP contract compiled once for one explicitly exposed table. */\nexport interface CrudTable {\n readonly name: string;\n readonly primaryKey?: CompiledColumn;\n readonly columns: ReadonlyMap<string, CompiledColumn>;\n /** Public columns a request may project. */\n readonly selectable: ReadonlySet<string>;\n /** Public columns a request may filter or order by. */\n readonly queryable: ReadonlySet<string>;\n readonly relations: ReadonlyMap<string, CrudRelation>;\n decodeId(raw: string): IdValue;\n projectPublicRow(row: Row): JsonValue;\n sanitizeSerializedRow(row: unknown): JsonValue;\n}\n\ntype MutableCrudTable = Omit<CrudTable, \"relations\"> & {\n readonly relations: Map<string, CrudRelation>;\n};\n\ninterface SanitizeState {\n nodes: number;\n readonly ancestors: Set<object>;\n}\n\n/** A bare object literal; a `Date`, class instance, or `Map` is not JSON. */\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n return false;\n }\n const prototype = Object.getPrototypeOf(value);\n return prototype === Object.prototype || prototype === null;\n}\n\n/** A serializer that breaks its contract is trusted code failing, not input. */\nfunction serializerFault(): never {\n throw new DatabasePluginError(\"INTERNAL\", \"read\");\n}\n\n/** Charge one value against the output budget before descending into it. */\nfunction countNode(depth: number, state: SanitizeState): void {\n state.nodes += 1;\n if (state.nodes > MAX_SERIALIZED_NODES || depth > MAX_SERIALIZED_DEPTH) {\n serializerFault();\n }\n}\n\n/** Walk a container while its ancestors are tracked, so a cycle cannot pass. */\nfunction enterObject<T>(\n value: object,\n state: SanitizeState,\n visit: () => T,\n): T {\n if (state.ancestors.has(value)) serializerFault();\n state.ancestors.add(value);\n try {\n return visit();\n } finally {\n state.ancestors.delete(value);\n }\n}\n\n/** Accept a serializer's own added value only where it is already JSON. */\nfunction sanitizeJson(\n value: unknown,\n depth: number,\n state: SanitizeState,\n): JsonValue {\n countNode(depth, state);\n if (\n value === null ||\n typeof value === \"string\" ||\n typeof value === \"boolean\"\n ) {\n return value;\n }\n if (typeof value === \"number\") {\n if (!Number.isFinite(value)) serializerFault();\n return value;\n }\n if (Array.isArray(value)) {\n return enterObject(value, state, () =>\n value.map((item) => sanitizeJson(item, depth + 1, state)),\n );\n }\n if (!isPlainObject(value)) serializerFault();\n return enterObject(value, state, () => {\n // A null prototype keeps `__proto__` an ordinary key instead of a setter.\n const out: Record<string, JsonValue> = Object.create(null);\n for (const [key, child] of Object.entries(value)) {\n if (child === undefined) continue;\n out[key] = sanitizeJson(child, depth + 1, state);\n }\n return out;\n });\n}\n\n/** Keep an included row under its own table's policy, one row or many. */\nfunction sanitizeRelation(\n target: CrudTable,\n value: unknown,\n depth: number,\n state: SanitizeState,\n): JsonValue {\n if (value === null) return null;\n if (!Array.isArray(value)) return sanitizeRow(target, value, depth, state);\n countNode(depth, state);\n return enterObject(value, state, () =>\n value.map((row) => sanitizeRow(target, row, depth + 1, state)),\n );\n}\n\n/** Re-apply the private-column policy wherever the output stays contracted. */\nfunction sanitizeRow(\n table: CrudTable,\n row: unknown,\n depth: number,\n state: SanitizeState,\n): JsonValue {\n countNode(depth, state);\n if (!isPlainObject(row)) serializerFault();\n return enterObject(row, state, () => {\n const out: Record<string, JsonValue> = {};\n for (const [key, child] of Object.entries(row)) {\n if (child === undefined) continue;\n if (table.columns.get(key)?.meta.isPrivate) continue;\n const relation = table.relations.get(key);\n out[key] = relation\n ? sanitizeRelation(relation.target, child, depth + 1, state)\n : sanitizeJson(child, depth + 1, state);\n }\n return out;\n });\n}\n\n/** Project an included row through its own table; absent to-one reads null. */\nfunction projectRelation(target: CrudTable, value: unknown): JsonValue {\n if (value === null || value === undefined) return null;\n return Array.isArray(value)\n ? value.map((row) => target.projectPublicRow(row as Row))\n : target.projectPublicRow(value as Row);\n}\n\n/** Build the public JSON for one driver row, dropping anything uncontracted. */\nfunction projectRow(table: CrudTable, row: Row): JsonValue {\n const out: Record<string, JsonValue> = {};\n for (const [key, value] of Object.entries(row)) {\n const column = table.columns.get(key);\n if (column) {\n // Whatever the driver returned, only public contracted columns ship.\n if (!column.meta.isPrivate) out[key] = column.encode(value);\n continue;\n }\n const relation = table.relations.get(key);\n if (relation) out[key] = projectRelation(relation.target, value);\n }\n return out;\n}\n\n/** Compile one table's allowlists and codecs from its finalized metadata. */\nfunction compileTable(table: AppKitTable): MutableCrudTable {\n const columns = new Map<string, CompiledColumn>();\n const selectable = new Set<string>();\n const queryable = new Set<string>();\n let primaryKey: CompiledColumn | undefined;\n\n for (const meta of Object.values(table.$columns)) {\n const column = compileColumn(meta);\n columns.set(meta.columnName, column);\n // A private key must not power `GET /:table/:id`: per-id probing would\n // answer 200 or 404 on an identifier the schema hides, so over HTTP the\n // table is keyless — no detail route, and lists must name their own order.\n if (meta.primaryKey && !meta.isPrivate) primaryKey = column;\n if (meta.isPrivate) continue;\n selectable.add(meta.columnName);\n if (filterOperatorsForKind(meta.kind).length > 0) {\n queryable.add(meta.columnName);\n }\n }\n\n const compiled: MutableCrudTable = {\n name: table.$name,\n primaryKey,\n columns,\n selectable,\n queryable,\n relations: new Map(),\n decodeId: (raw) => {\n if (!primaryKey) throw new DatabasePluginError(\"INTERNAL\", \"read\");\n const value = primaryKey.decode(raw);\n if (\n typeof value !== \"string\" &&\n typeof value !== \"number\" &&\n typeof value !== \"bigint\"\n ) {\n throw invalidDatabaseInput([\"id\"], \"Not a valid identifier\");\n }\n return value;\n },\n projectPublicRow: (row) => projectRow(compiled, row),\n sanitizeSerializedRow: (row) =>\n sanitizeRow(compiled, row, 0, { nodes: 0, ancestors: new Set() }),\n };\n return compiled;\n}\n\n/**\n * Compile the HTTP contract for every exposed table and wire the relations\n * they share. A relation whose target is not exposed stays unreachable, so\n * enabling one table never widens another table's public surface.\n */\nexport function compileCrudTables(\n tables: Record<string, AppKitTable>,\n): Map<string, CrudTable> {\n const compiled = new Map<string, MutableCrudTable>();\n for (const table of Object.values(tables)) {\n compiled.set(table.$name, compileTable(table));\n }\n for (const table of Object.values(tables)) {\n const entry = compiled.get(table.$name);\n for (const relation of table.$relations) {\n const target = compiled.get(relation.targetTable);\n if (!entry || !target) continue;\n entry.relations.set(relation.name, {\n cardinality: relation.cardinality,\n target,\n });\n }\n }\n return compiled as Map<string, CrudTable>;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"contract.js","names":[],"sources":["../../../../src/plugins/database/crud/contract.ts"],"sourcesContent":["import { DatabasePluginError } from \"../../../database/errors\";\nimport type { Row } from \"../../../database/runtime\";\nimport type { AppKitTable } from \"../../../database/schema-builder\";\nimport { filterOperatorsForKind } from \"../../../database/schema-builder/types\";\nimport { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from \"../defaults\";\nimport { type CompiledColumn, compileColumn, type JsonValue } from \"./codecs\";\n\n/** One relation edge wired to the contract of its target table. */\nexport interface CrudRelation {\n readonly cardinality: \"toOne\" | \"toMany\";\n readonly target: CrudTable;\n}\n\n/** Private HTTP contract compiled once for one exposed table. */\nexport interface CrudTable {\n readonly name: string;\n readonly primaryKey?: CompiledColumn;\n readonly columns: ReadonlyMap<string, CompiledColumn>;\n /** Public columns a request may project. */\n readonly selectable: ReadonlySet<string>;\n /** Public columns a request may filter or order by. */\n readonly queryable: ReadonlySet<string>;\n /** Public columns a create body may set, including a caller-chosen key. */\n readonly creatable: ReadonlySet<string>;\n /** Public columns an update body may set; a key or a stamp is never one. */\n readonly updatable: ReadonlySet<string>;\n readonly relations: ReadonlyMap<string, CrudRelation>;\n projectPublicRow(row: Row): JsonValue;\n sanitizeSerializedRow(row: unknown): JsonValue;\n}\n\ntype MutableCrudTable = Omit<CrudTable, \"relations\"> & {\n readonly relations: Map<string, CrudRelation>;\n};\n\ninterface SanitizeState {\n nodes: number;\n readonly ancestors: Set<object>;\n}\n\n/** A bare object literal; a `Date`, class instance, or `Map` is not JSON. */\nexport function isPlainObject(\n value: unknown,\n): value is Record<string, unknown> {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n return false;\n }\n const prototype = Object.getPrototypeOf(value);\n return prototype === Object.prototype || prototype === null;\n}\n\n/** A serializer that breaks its contract is trusted code failing, not input. */\nfunction serializerFault(): never {\n throw new DatabasePluginError(\"INTERNAL\", \"read\");\n}\n\n/** Charge one value against the output budget before descending into it. */\nfunction countNode(depth: number, state: SanitizeState): void {\n state.nodes += 1;\n if (state.nodes > MAX_SERIALIZED_NODES || depth > MAX_SERIALIZED_DEPTH) {\n serializerFault();\n }\n}\n\n/** Walk a container while its ancestors are tracked, so a cycle cannot pass. */\nfunction enterObject<T>(\n value: object,\n state: SanitizeState,\n visit: () => T,\n): T {\n if (state.ancestors.has(value)) serializerFault();\n state.ancestors.add(value);\n try {\n return visit();\n } finally {\n state.ancestors.delete(value);\n }\n}\n\n/** Accept a serializer's own added value only where it is already JSON. */\nfunction sanitizeJson(\n value: unknown,\n depth: number,\n state: SanitizeState,\n): JsonValue {\n countNode(depth, state);\n if (\n value === null ||\n typeof value === \"string\" ||\n typeof value === \"boolean\"\n ) {\n return value;\n }\n if (typeof value === \"number\") {\n if (!Number.isFinite(value)) serializerFault();\n return value;\n }\n if (Array.isArray(value)) {\n return enterObject(value, state, () =>\n value.map((item) => sanitizeJson(item, depth + 1, state)),\n );\n }\n if (!isPlainObject(value)) serializerFault();\n return enterObject(value, state, () => {\n // A null prototype keeps `__proto__` an ordinary key instead of a setter.\n const out: Record<string, JsonValue> = Object.create(null);\n for (const [key, child] of Object.entries(value)) {\n if (child === undefined) continue;\n out[key] = sanitizeJson(child, depth + 1, state);\n }\n return out;\n });\n}\n\n/** Keep an included row under its own table's policy, one row or many. */\nfunction sanitizeRelation(\n target: CrudTable,\n value: unknown,\n depth: number,\n state: SanitizeState,\n): JsonValue {\n if (value === null) return null;\n if (!Array.isArray(value)) return sanitizeRow(target, value, depth, state);\n countNode(depth, state);\n return enterObject(value, state, () =>\n value.map((row) => sanitizeRow(target, row, depth + 1, state)),\n );\n}\n\n/** Re-apply the private-column policy wherever the output stays contracted. */\nfunction sanitizeRow(\n table: CrudTable,\n row: unknown,\n depth: number,\n state: SanitizeState,\n): JsonValue {\n countNode(depth, state);\n if (!isPlainObject(row)) serializerFault();\n return enterObject(row, state, () => {\n const out: Record<string, JsonValue> = {};\n for (const [key, child] of Object.entries(row)) {\n if (child === undefined) continue;\n if (table.columns.get(key)?.meta.isPrivate) continue;\n const relation = table.relations.get(key);\n out[key] = relation\n ? sanitizeRelation(relation.target, child, depth + 1, state)\n : sanitizeJson(child, depth + 1, state);\n }\n return out;\n });\n}\n\n/** The budget a caller's own JSON has to fit, same as the one going back out. */\nexport function boundedJson(value: unknown): JsonValue {\n return sanitizeJson(value, 0, { nodes: 0, ancestors: new Set() });\n}\n\n/** Project an included row through its own table; absent to-one reads null. */\nfunction projectRelation(target: CrudTable, value: unknown): JsonValue {\n if (value === null || value === undefined) return null;\n return Array.isArray(value)\n ? value.map((row) => target.projectPublicRow(row as Row))\n : target.projectPublicRow(value as Row);\n}\n\n/** Build the public JSON for one driver row, dropping anything uncontracted. */\nfunction projectRow(table: CrudTable, row: Row): JsonValue {\n const out: Record<string, JsonValue> = {};\n for (const [key, value] of Object.entries(row)) {\n const column = table.columns.get(key);\n if (column) {\n // Whatever the driver returned, only public contracted columns ship.\n if (!column.meta.isPrivate) out[key] = column.encode(value);\n continue;\n }\n const relation = table.relations.get(key);\n if (relation) out[key] = projectRelation(relation.target, value);\n }\n return out;\n}\n\n/** Compile one table's allowlists and codecs from its finalized metadata. */\nfunction compileTable(table: AppKitTable): MutableCrudTable {\n const columns = new Map<string, CompiledColumn>();\n const selectable = new Set<string>();\n const queryable = new Set<string>();\n const creatable = new Set<string>();\n const updatable = new Set<string>();\n let primaryKey: CompiledColumn | undefined;\n\n for (const meta of Object.values(table.$columns)) {\n const column = compileColumn(meta);\n columns.set(meta.columnName, column);\n // A private key must not power `GET /:table/:id`: per-id probing would\n // answer 200 or 404 on an identifier the schema hides, so over HTTP the\n // table is keyless — no detail route, and lists must name their own order.\n if (meta.primaryKey && !meta.isPrivate) primaryKey = column;\n if (meta.isPrivate) continue;\n selectable.add(meta.columnName);\n if (filterOperatorsForKind(meta.kind).length > 0) {\n queryable.add(meta.columnName);\n }\n // Database-generated identities belong to the server, never the caller.\n if (meta.serverGenerated || (meta.primaryKey && meta.defaultRandom))\n continue;\n creatable.add(meta.columnName);\n // Rewriting a key would move a row out from under every existing reference,\n // and rewriting a database-materialized stamp would rewrite history.\n if (meta.primaryKey || meta.defaultNow || meta.defaultRandom) continue;\n updatable.add(meta.columnName);\n }\n\n const compiled: MutableCrudTable = {\n name: table.$name,\n primaryKey,\n columns,\n selectable,\n queryable,\n creatable,\n updatable,\n relations: new Map(),\n projectPublicRow: (row) => projectRow(compiled, row),\n sanitizeSerializedRow: (row) =>\n sanitizeRow(compiled, row, 0, { nodes: 0, ancestors: new Set() }),\n };\n return compiled;\n}\n\n/**\n * Compile the HTTP contract for every exposed table and wire the relations\n * they share. A relation whose target is not exposed stays unreachable, so\n * enabling one table never widens another table's public surface.\n */\nexport function compileCrudTables(\n tables: Record<string, AppKitTable>,\n): Map<string, CrudTable> {\n const compiled = new Map<string, MutableCrudTable>();\n for (const table of Object.values(tables)) {\n compiled.set(table.$name, compileTable(table));\n }\n for (const table of Object.values(tables)) {\n const entry = compiled.get(table.$name);\n for (const relation of table.$relations) {\n const target = compiled.get(relation.targetTable);\n if (!entry || !target) continue;\n entry.relations.set(relation.name, {\n cardinality: relation.cardinality,\n target,\n });\n }\n }\n return compiled as Map<string, CrudTable>;\n}\n"],"mappings":"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"}
@@ -3,38 +3,79 @@ import { databaseSetupFailed } from "../../../database/errors.js";
3
3
  //#region src/plugins/database/crud/exposure.ts
4
4
  /** A table name also becomes a URL path segment, so keep it unambiguous. */
5
5
  const ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
6
+ const WRITE_OPERATIONS = [
7
+ "create",
8
+ "update",
9
+ "delete"
10
+ ];
6
11
  /** Refuse names that cannot address exactly one table over HTTP. */
7
12
  function assertRoutable(names) {
8
- const lowercased = /* @__PURE__ */ new Set();
13
+ const lowercased = /* @__PURE__ */ new Map();
9
14
  for (const name of names) {
10
- if (!ROUTABLE_TABLE.test(name) || lowercased.has(name.toLowerCase())) throw databaseSetupFailed();
11
- lowercased.add(name.toLowerCase());
15
+ if (!ROUTABLE_TABLE.test(name)) throw databaseSetupFailed(`Table ${JSON.stringify(name)} cannot be exposed through api. Route names must start with a letter, contain only letters, digits, "_", or "-", and be at most 64 characters. Rename the table, exclude it with api.tables, or set api: false.`);
16
+ const previous = lowercased.get(name.toLowerCase());
17
+ if (previous !== void 0) throw databaseSetupFailed(`Tables ${JSON.stringify(previous)} and ${JSON.stringify(name)} conflict in api because routes are case-insensitive. Rename a table, select only one with api.tables, or set api: false.`);
18
+ lowercased.set(name.toLowerCase(), name);
12
19
  }
13
20
  }
14
- /**
15
- * Resolve the tables whose generated reads are explicitly turned on. The
16
- * exposure value arrives untyped because a finalized schema widens its table
17
- * names to `string`, so every name is re-checked against the declared schema.
18
- */
19
- function resolveExposedTables(exposure, declared) {
20
- if (exposure === void 0 || exposure === false) return [];
21
- if (exposure === true) {
22
- assertRoutable(declared);
23
- return [...declared];
24
- }
25
- if (typeof exposure !== "object" || exposure === null) throw databaseSetupFailed();
26
- const requested = exposure.tables;
27
- if (!Array.isArray(requested)) throw databaseSetupFailed();
21
+ /** Validate a unique list drawn from an allowlist. */
22
+ function requestedNames(value, allowed, path) {
23
+ if (!Array.isArray(value)) throw databaseSetupFailed(`${path} must be an array of names.`);
28
24
  const names = [];
29
- for (const name of requested) {
30
- if (typeof name !== "string" || !declared.includes(name)) throw databaseSetupFailed();
31
- if (names.includes(name)) throw databaseSetupFailed();
25
+ for (const name of value) {
26
+ if (typeof name !== "string") throw databaseSetupFailed(`${path} must contain only string names.`);
27
+ if (!allowed.includes(name)) throw databaseSetupFailed(`${path} contains unsupported name ${JSON.stringify(name)}. Allowed names: ${allowed.map((entry) => JSON.stringify(entry)).join(", ") || "none"}.`);
28
+ if (names.includes(name)) throw databaseSetupFailed(`${path} contains duplicate name ${JSON.stringify(name)}. List each name once.`);
32
29
  names.push(name);
33
30
  }
34
- assertRoutable(names);
35
31
  return names;
36
32
  }
33
+ /** Reject misspelled restrictions instead of silently enabling all routes. */
34
+ function configuration(value, allowedKeys, path) {
35
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw databaseSetupFailed(`${path} must be true, false, or a configuration object.`);
36
+ const prototype = Object.getPrototypeOf(value);
37
+ if (prototype !== Object.prototype && prototype !== null) throw databaseSetupFailed(`${path} must be a plain configuration object.`);
38
+ for (const key of Object.keys(value)) if (!allowedKeys.includes(key)) throw databaseSetupFailed(`Unknown option ${JSON.stringify(`${path}.${key}`)}. Allowed options: ${allowedKeys.join(", ")}.`);
39
+ return value;
40
+ }
41
+ /** Enable CRUD by default, applying only the restrictions the caller supplies. */
42
+ function resolveCrudExposure(exposure, declared) {
43
+ if (exposure === false) return {
44
+ tables: [],
45
+ writes: /* @__PURE__ */ new Map()
46
+ };
47
+ let tables;
48
+ let writeConfig;
49
+ if (exposure === void 0 || exposure === true) tables = [...declared];
50
+ else {
51
+ const configured = configuration(exposure, ["tables", "writes"], "api");
52
+ tables = configured.tables === void 0 ? [...declared] : requestedNames(configured.tables, declared, "api.tables");
53
+ writeConfig = configured.writes;
54
+ }
55
+ assertRoutable(tables);
56
+ const writes = /* @__PURE__ */ new Map();
57
+ if (writeConfig === false) return {
58
+ tables,
59
+ writes
60
+ };
61
+ let writeTables;
62
+ let operations;
63
+ if (writeConfig === void 0 || writeConfig === true) {
64
+ writeTables = tables;
65
+ operations = [...WRITE_OPERATIONS];
66
+ } else {
67
+ const configured = configuration(writeConfig, ["tables", "operations"], "api.writes");
68
+ writeTables = configured.tables === void 0 ? tables : requestedNames(configured.tables, tables, "api.writes.tables");
69
+ operations = configured.operations === void 0 ? [...WRITE_OPERATIONS] : requestedNames(configured.operations, WRITE_OPERATIONS, "api.writes.operations");
70
+ }
71
+ const enabled = new Set(operations);
72
+ for (const table of writeTables) writes.set(table, enabled);
73
+ return {
74
+ tables,
75
+ writes
76
+ };
77
+ }
37
78
 
38
79
  //#endregion
39
- export { resolveExposedTables };
80
+ export { resolveCrudExposure };
40
81
  //# sourceMappingURL=exposure.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"exposure.js","names":[],"sources":["../../../../src/plugins/database/crud/exposure.ts"],"sourcesContent":["import { databaseSetupFailed } from \"../../../database/errors\";\n\n/** A table name also becomes a URL path segment, so keep it unambiguous. */\nconst ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;\n\n/** Refuse names that cannot address exactly one table over HTTP. */\nfunction assertRoutable(names: readonly string[]): void {\n const lowercased = new Set<string>();\n for (const name of names) {\n // Express matches paths case-insensitively, so near-duplicates would alias.\n if (!ROUTABLE_TABLE.test(name) || lowercased.has(name.toLowerCase())) {\n throw databaseSetupFailed();\n }\n lowercased.add(name.toLowerCase());\n }\n}\n\n/**\n * Resolve the tables whose generated reads are explicitly turned on. The\n * exposure value arrives untyped because a finalized schema widens its table\n * names to `string`, so every name is re-checked against the declared schema.\n */\nexport function resolveExposedTables(\n exposure: unknown,\n declared: readonly string[],\n): string[] {\n if (exposure === undefined || exposure === false) return [];\n if (exposure === true) {\n assertRoutable(declared);\n return [...declared];\n }\n if (typeof exposure !== \"object\" || exposure === null) {\n throw databaseSetupFailed();\n }\n const requested = (exposure as { tables?: unknown }).tables;\n if (!Array.isArray(requested)) throw databaseSetupFailed();\n\n const names: string[] = [];\n for (const name of requested) {\n // Unknown and duplicate names are configuration bugs, not empty routes.\n if (typeof name !== \"string\" || !declared.includes(name)) {\n throw databaseSetupFailed();\n }\n if (names.includes(name)) throw databaseSetupFailed();\n names.push(name);\n }\n assertRoutable(names);\n return names;\n}\n"],"mappings":";;;;AAGA,MAAM,iBAAiB;;AAGvB,SAAS,eAAe,OAAgC;CACtD,MAAM,6BAAa,IAAI,KAAa;AACpC,MAAK,MAAM,QAAQ,OAAO;AAExB,MAAI,CAAC,eAAe,KAAK,KAAK,IAAI,WAAW,IAAI,KAAK,aAAa,CAAC,CAClE,OAAM,qBAAqB;AAE7B,aAAW,IAAI,KAAK,aAAa,CAAC;;;;;;;;AAStC,SAAgB,qBACd,UACA,UACU;AACV,KAAI,aAAa,UAAa,aAAa,MAAO,QAAO,EAAE;AAC3D,KAAI,aAAa,MAAM;AACrB,iBAAe,SAAS;AACxB,SAAO,CAAC,GAAG,SAAS;;AAEtB,KAAI,OAAO,aAAa,YAAY,aAAa,KAC/C,OAAM,qBAAqB;CAE7B,MAAM,YAAa,SAAkC;AACrD,KAAI,CAAC,MAAM,QAAQ,UAAU,CAAE,OAAM,qBAAqB;CAE1D,MAAM,QAAkB,EAAE;AAC1B,MAAK,MAAM,QAAQ,WAAW;AAE5B,MAAI,OAAO,SAAS,YAAY,CAAC,SAAS,SAAS,KAAK,CACtD,OAAM,qBAAqB;AAE7B,MAAI,MAAM,SAAS,KAAK,CAAE,OAAM,qBAAqB;AACrD,QAAM,KAAK,KAAK;;AAElB,gBAAe,MAAM;AACrB,QAAO"}
1
+ {"version":3,"file":"exposure.js","names":[],"sources":["../../../../src/plugins/database/crud/exposure.ts"],"sourcesContent":["import { databaseSetupFailed } from \"../../../database/errors\";\nimport type { DatabaseApiWriteOperation } from \"../types\";\n\n/** A table name also becomes a URL path segment, so keep it unambiguous. */\nconst ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;\nconst WRITE_OPERATIONS: readonly DatabaseApiWriteOperation[] = [\n \"create\",\n \"update\",\n \"delete\",\n];\n\n/** Resolved generated routes for one plugin instance. */\nexport interface CrudExposure {\n readonly tables: readonly string[];\n readonly writes: ReadonlyMap<string, ReadonlySet<DatabaseApiWriteOperation>>;\n}\n\n/** Refuse names that cannot address exactly one table over HTTP. */\nfunction assertRoutable(names: readonly string[]): void {\n const lowercased = new Map<string, string>();\n for (const name of names) {\n if (!ROUTABLE_TABLE.test(name)) {\n throw databaseSetupFailed(\n `Table ${JSON.stringify(name)} cannot be exposed through api. Route names must start with a letter, contain only letters, digits, \"_\", or \"-\", and be at most 64 characters. Rename the table, exclude it with api.tables, or set api: false.`,\n );\n }\n // Express matches paths case-insensitively, so near-duplicates would alias.\n const previous = lowercased.get(name.toLowerCase());\n if (previous !== undefined) {\n throw databaseSetupFailed(\n `Tables ${JSON.stringify(previous)} and ${JSON.stringify(name)} conflict in api because routes are case-insensitive. Rename a table, select only one with api.tables, or set api: false.`,\n );\n }\n lowercased.set(name.toLowerCase(), name);\n }\n}\n\n/** Validate a unique list drawn from an allowlist. */\nfunction requestedNames(\n value: unknown,\n allowed: readonly string[],\n path: string,\n): string[] {\n if (!Array.isArray(value)) {\n throw databaseSetupFailed(`${path} must be an array of names.`);\n }\n const names: string[] = [];\n for (const name of value) {\n if (typeof name !== \"string\") {\n throw databaseSetupFailed(`${path} must contain only string names.`);\n }\n if (!allowed.includes(name)) {\n throw databaseSetupFailed(\n `${path} contains unsupported name ${JSON.stringify(name)}. Allowed names: ${allowed.map((entry) => JSON.stringify(entry)).join(\", \") || \"none\"}.`,\n );\n }\n if (names.includes(name)) {\n throw databaseSetupFailed(\n `${path} contains duplicate name ${JSON.stringify(name)}. List each name once.`,\n );\n }\n names.push(name);\n }\n return names;\n}\n\n/** Reject misspelled restrictions instead of silently enabling all routes. */\nfunction configuration(\n value: unknown,\n allowedKeys: readonly string[],\n path: string,\n): Record<string, unknown> {\n if (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n throw databaseSetupFailed(\n `${path} must be true, false, or a configuration object.`,\n );\n }\n const prototype = Object.getPrototypeOf(value);\n if (prototype !== Object.prototype && prototype !== null) {\n throw databaseSetupFailed(`${path} must be a plain configuration object.`);\n }\n for (const key of Object.keys(value)) {\n if (!allowedKeys.includes(key)) {\n throw databaseSetupFailed(\n `Unknown option ${JSON.stringify(`${path}.${key}`)}. Allowed options: ${allowedKeys.join(\", \")}.`,\n );\n }\n }\n return value as Record<string, unknown>;\n}\n\n/** Enable CRUD by default, applying only the restrictions the caller supplies. */\nexport function resolveCrudExposure(\n exposure: unknown,\n declared: readonly string[],\n): CrudExposure {\n if (exposure === false) {\n return { tables: [], writes: new Map() };\n }\n\n let tables: string[];\n let writeConfig: unknown;\n if (exposure === undefined || exposure === true) {\n tables = [...declared];\n } else {\n const configured = configuration(exposure, [\"tables\", \"writes\"], \"api\");\n tables =\n configured.tables === undefined\n ? [...declared]\n : requestedNames(configured.tables, declared, \"api.tables\");\n writeConfig = configured.writes;\n }\n assertRoutable(tables);\n\n const writes = new Map<string, ReadonlySet<DatabaseApiWriteOperation>>();\n if (writeConfig === false) return { tables, writes };\n\n let writeTables: string[];\n let operations: DatabaseApiWriteOperation[];\n if (writeConfig === undefined || writeConfig === true) {\n writeTables = tables;\n operations = [...WRITE_OPERATIONS];\n } else {\n const configured = configuration(\n writeConfig,\n [\"tables\", \"operations\"],\n \"api.writes\",\n );\n writeTables =\n configured.tables === undefined\n ? tables\n : requestedNames(configured.tables, tables, \"api.writes.tables\");\n operations =\n configured.operations === undefined\n ? [...WRITE_OPERATIONS]\n : (requestedNames(\n configured.operations,\n WRITE_OPERATIONS,\n \"api.writes.operations\",\n ) as DatabaseApiWriteOperation[]);\n }\n\n const enabled = new Set(operations);\n for (const table of writeTables) writes.set(table, enabled);\n return { tables, writes };\n}\n"],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ import { DatabasePluginError, invalidDatabaseInput } from "../../../database/errors.js";
2
+ import { boundedJson, isPlainObject } from "./contract.js";
3
+
4
+ //#region src/plugins/database/crud/request.ts
5
+ /**
6
+ * Decode a path identifier against the declared key type. A keyless table gets
7
+ * no `/:id` route, so arriving here without a key is a wiring fault, not input.
8
+ */
9
+ function decodeId(table, raw) {
10
+ const { primaryKey } = table;
11
+ if (!primaryKey) throw new DatabasePluginError("INTERNAL", "read");
12
+ const value = primaryKey.decode(raw);
13
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") throw invalidDatabaseInput(["id"], "Not a valid identifier");
14
+ return value;
15
+ }
16
+ /** Map one body value onto its column; `undefined` when it does not fit. */
17
+ function decodeWriteValue(column, raw) {
18
+ if (raw === null) return column.meta.notNull ? void 0 : null;
19
+ if (column.meta.kind !== "json") return column.decode(raw);
20
+ try {
21
+ return boundedJson(raw);
22
+ } catch {
23
+ return;
24
+ }
25
+ }
26
+ /** Decode one untrusted body against the columns this operation may set. */
27
+ function decodeBody(table, writable, raw) {
28
+ if (!isPlainObject(raw)) throw invalidDatabaseInput(["body"], "Expected a JSON object");
29
+ const values = {};
30
+ for (const [key, value] of Object.entries(raw)) {
31
+ const column = writable.has(key) ? table.columns.get(key) : void 0;
32
+ if (!column) throw invalidDatabaseInput(table.selectable.has(key) ? [key] : ["body"], "Unknown or read-only field");
33
+ const decoded = decodeWriteValue(column, value);
34
+ if (decoded === void 0) throw invalidDatabaseInput([key], "Does not match the column type");
35
+ values[key] = decoded;
36
+ }
37
+ return values;
38
+ }
39
+ /** Decode the body of `POST /:table`, which may carry a caller-chosen key. */
40
+ function decodeCreateBody(table, raw) {
41
+ return decodeBody(table, table.creatable, raw);
42
+ }
43
+ /** Decode the body of `PATCH /:table/:id`, which may not carry a key or stamp. */
44
+ function decodeUpdateBody(table, raw) {
45
+ return decodeBody(table, table.updatable, raw);
46
+ }
47
+
48
+ //#endregion
49
+ export { decodeCreateBody, decodeId, decodeUpdateBody };
50
+ //# sourceMappingURL=request.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.js","names":[],"sources":["../../../../src/plugins/database/crud/request.ts"],"sourcesContent":["import {\n DatabasePluginError,\n invalidDatabaseInput,\n} from \"../../../database/errors\";\nimport type { IdValue, Row, ScalarValue } from \"../../../database/runtime\";\nimport type { CompiledColumn, JsonValue } from \"./codecs\";\nimport { boundedJson, type CrudTable, isPlainObject } from \"./contract\";\n\n/**\n * Decode a path identifier against the declared key type. A keyless table gets\n * no `/:id` route, so arriving here without a key is a wiring fault, not input.\n */\nexport function decodeId(table: CrudTable, raw: string): IdValue {\n const { primaryKey } = table;\n if (!primaryKey) throw new DatabasePluginError(\"INTERNAL\", \"read\");\n const value = primaryKey.decode(raw);\n if (\n typeof value !== \"string\" &&\n typeof value !== \"number\" &&\n typeof value !== \"bigint\"\n ) {\n throw invalidDatabaseInput([\"id\"], \"Not a valid identifier\");\n }\n return value;\n}\n\n/** Map one body value onto its column; `undefined` when it does not fit. */\nfunction decodeWriteValue(\n column: CompiledColumn,\n raw: unknown,\n): ScalarValue | JsonValue | undefined {\n if (raw === null) return column.meta.notNull ? undefined : null;\n if (column.meta.kind !== \"json\") return column.decode(raw);\n try {\n // JSON columns accept any JSON the response budget can carry back.\n return boundedJson(raw);\n } catch {\n return undefined;\n }\n}\n\n/** Decode one untrusted body against the columns this operation may set. */\nfunction decodeBody(\n table: CrudTable,\n writable: ReadonlySet<string>,\n raw: unknown,\n): Row {\n if (!isPlainObject(raw)) {\n throw invalidDatabaseInput([\"body\"], \"Expected a JSON object\");\n }\n const values: Row = {};\n for (const [key, value] of Object.entries(raw)) {\n // Private, server-generated, and unknown fields are refused, not dropped.\n const column = writable.has(key) ? table.columns.get(key) : undefined;\n if (!column) {\n // Naming the field echoes caller input, so only a public name is named.\n throw invalidDatabaseInput(\n table.selectable.has(key) ? [key] : [\"body\"],\n \"Unknown or read-only field\",\n );\n }\n const decoded = decodeWriteValue(column, value);\n if (decoded === undefined) {\n throw invalidDatabaseInput([key], \"Does not match the column type\");\n }\n values[key] = decoded;\n }\n return values;\n}\n\n/** Decode the body of `POST /:table`, which may carry a caller-chosen key. */\nexport function decodeCreateBody(table: CrudTable, raw: unknown): Row {\n return decodeBody(table, table.creatable, raw);\n}\n\n/** Decode the body of `PATCH /:table/:id`, which may not carry a key or stamp. */\nexport function decodeUpdateBody(table: CrudTable, raw: unknown): Row {\n return decodeBody(table, table.updatable, raw);\n}\n"],"mappings":";;;;;;;;AAYA,SAAgB,SAAS,OAAkB,KAAsB;CAC/D,MAAM,EAAE,eAAe;AACvB,KAAI,CAAC,WAAY,OAAM,IAAI,oBAAoB,YAAY,OAAO;CAClE,MAAM,QAAQ,WAAW,OAAO,IAAI;AACpC,KACE,OAAO,UAAU,YACjB,OAAO,UAAU,YACjB,OAAO,UAAU,SAEjB,OAAM,qBAAqB,CAAC,KAAK,EAAE,yBAAyB;AAE9D,QAAO;;;AAIT,SAAS,iBACP,QACA,KACqC;AACrC,KAAI,QAAQ,KAAM,QAAO,OAAO,KAAK,UAAU,SAAY;AAC3D,KAAI,OAAO,KAAK,SAAS,OAAQ,QAAO,OAAO,OAAO,IAAI;AAC1D,KAAI;AAEF,SAAO,YAAY,IAAI;SACjB;AACN;;;;AAKJ,SAAS,WACP,OACA,UACA,KACK;AACL,KAAI,CAAC,cAAc,IAAI,CACrB,OAAM,qBAAqB,CAAC,OAAO,EAAE,yBAAyB;CAEhE,MAAM,SAAc,EAAE;AACtB,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,EAAE;EAE9C,MAAM,SAAS,SAAS,IAAI,IAAI,GAAG,MAAM,QAAQ,IAAI,IAAI,GAAG;AAC5D,MAAI,CAAC,OAEH,OAAM,qBACJ,MAAM,WAAW,IAAI,IAAI,GAAG,CAAC,IAAI,GAAG,CAAC,OAAO,EAC5C,6BACD;EAEH,MAAM,UAAU,iBAAiB,QAAQ,MAAM;AAC/C,MAAI,YAAY,OACd,OAAM,qBAAqB,CAAC,IAAI,EAAE,iCAAiC;AAErE,SAAO,OAAO;;AAEhB,QAAO;;;AAIT,SAAgB,iBAAiB,OAAkB,KAAmB;AACpE,QAAO,WAAW,OAAO,MAAM,WAAW,IAAI;;;AAIhD,SAAgB,iBAAiB,OAAkB,KAAmB;AACpE,QAAO,WAAW,OAAO,MAAM,WAAW,IAAI"}
@@ -0,0 +1,77 @@
1
+ import { DatabaseValidationError } from "../../../errors/database-validation.js";
2
+ import "../../../errors/index.js";
3
+ import { DatabasePluginError, classifyDatabaseError } from "../../../database/errors.js";
4
+ import { MAX_RESPONSE_BYTES } from "../defaults.js";
5
+
6
+ //#region src/plugins/database/crud/response.ts
7
+ /** Low-cardinality span outcome for one failed generated route. */
8
+ function routeOutcome(error) {
9
+ const statusCode = error instanceof DatabaseValidationError ? error.statusCode : classifyDatabaseError(error, "read").statusCode;
10
+ if (statusCode === 404) return "not_found";
11
+ return statusCode < 500 ? "rejected" : "failed";
12
+ }
13
+ /**
14
+ * Row data is never cacheable by a shared proxy or a browser: the same URL can
15
+ * answer differently once the underlying table or the caller's rights change.
16
+ */
17
+ function writeJson(res, status, payload) {
18
+ res.status(status);
19
+ res.type("application/json");
20
+ res.setHeader("Cache-Control", "no-store");
21
+ res.send(payload);
22
+ }
23
+ /** Measure the encoded body before sending so no partial response escapes. */
24
+ function sendJson(res, status, body) {
25
+ const payload = JSON.stringify(body);
26
+ if (Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES) throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read");
27
+ writeJson(res, status, payload);
28
+ }
29
+ /**
30
+ * Encode the list envelope one row at a time, charging each encoded row
31
+ * against the byte budget before the next row is shaped, so one response
32
+ * never costs more than the budget in memory.
33
+ */
34
+ function sendListPage(res, rows, encodeRow, limit, offset) {
35
+ const prefix = "{\"items\":[";
36
+ const suffix = `],"limit":${limit},"offset":${offset}}`;
37
+ let bytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
38
+ const items = [];
39
+ for (const row of rows) {
40
+ const encoded = JSON.stringify(encodeRow(row));
41
+ bytes += Buffer.byteLength(encoded, "utf8") + (items.length > 0 ? 1 : 0);
42
+ if (bytes > MAX_RESPONSE_BYTES) throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read");
43
+ items.push(encoded);
44
+ }
45
+ writeJson(res, 200, prefix + items.join(",") + suffix);
46
+ }
47
+ /** A `204` carries no body but owes the same cache promise as one that does. */
48
+ function sendEmpty(res, status) {
49
+ res.status(status);
50
+ res.setHeader("Cache-Control", "no-store");
51
+ res.send();
52
+ }
53
+ /**
54
+ * Convert a failure into its safe category. A hook's deliberate validation
55
+ * error is the one signal that reaches the caller, and only through the issues
56
+ * naming a public column of this table.
57
+ */
58
+ function safeError(table, phase, error) {
59
+ if (!(error instanceof DatabaseValidationError)) return classifyDatabaseError(error, phase);
60
+ return new DatabasePluginError("VALIDATION_FAILED", phase, void 0, error.issues.filter((issue) => issue.path.length > 0 && table.selectable.has(issue.path[0])).map((issue) => ({
61
+ path: [...issue.path],
62
+ message: issue.message
63
+ })));
64
+ }
65
+ /** Answer with the failure's safe category and the field it concerns. */
66
+ function sendError(res, table, phase, error) {
67
+ if (res.headersSent) return;
68
+ const safe = safeError(table, phase, error);
69
+ const body = { error: safe.clientMessage };
70
+ if (safe.details && safe.details.length > 0) body.details = safe.details;
71
+ const payload = JSON.stringify(body);
72
+ writeJson(res, safe.statusCode, Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES ? JSON.stringify({ error: safe.clientMessage }) : payload);
73
+ }
74
+
75
+ //#endregion
76
+ export { routeOutcome, sendEmpty, sendError, sendJson, sendListPage };
77
+ //# sourceMappingURL=response.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response.js","names":[],"sources":["../../../../src/plugins/database/crud/response.ts"],"sourcesContent":["import type { Response } from \"express\";\n\nimport {\n classifyDatabaseError,\n type DatabaseErrorDetail,\n DatabasePluginError,\n} from \"../../../database/errors\";\nimport type { Row } from \"../../../database/runtime\";\nimport { DatabaseValidationError } from \"../../../errors\";\nimport { MAX_RESPONSE_BYTES } from \"../defaults\";\nimport type { JsonValue } from \"./codecs\";\nimport type { CrudTable } from \"./contract\";\n\n/** Low-cardinality span outcome for one failed generated route. */\nexport function routeOutcome(\n error: unknown,\n): \"not_found\" | \"rejected\" | \"failed\" {\n const statusCode =\n error instanceof DatabaseValidationError\n ? error.statusCode\n : classifyDatabaseError(error, \"read\").statusCode;\n if (statusCode === 404) return \"not_found\";\n return statusCode < 500 ? \"rejected\" : \"failed\";\n}\n\n/**\n * Row data is never cacheable by a shared proxy or a browser: the same URL can\n * answer differently once the underlying table or the caller's rights change.\n */\nfunction writeJson(res: Response, status: number, payload: string): void {\n res.status(status);\n res.type(\"application/json\");\n res.setHeader(\"Cache-Control\", \"no-store\");\n res.send(payload);\n}\n\n/** Measure the encoded body before sending so no partial response escapes. */\nexport function sendJson(res: Response, status: number, body: JsonValue): void {\n const payload = JSON.stringify(body);\n if (Buffer.byteLength(payload, \"utf8\") > MAX_RESPONSE_BYTES) {\n throw new DatabasePluginError(\"PAYLOAD_TOO_LARGE\", \"read\");\n }\n writeJson(res, status, payload);\n}\n\n/**\n * Encode the list envelope one row at a time, charging each encoded row\n * against the byte budget before the next row is shaped, so one response\n * never costs more than the budget in memory.\n */\nexport function sendListPage(\n res: Response,\n rows: readonly Row[],\n encodeRow: (row: Row) => JsonValue,\n limit: number,\n offset: number,\n): void {\n const prefix = '{\"items\":[';\n const suffix = `],\"limit\":${limit},\"offset\":${offset}}`;\n let bytes =\n Buffer.byteLength(prefix, \"utf8\") + Buffer.byteLength(suffix, \"utf8\");\n const items: string[] = [];\n for (const row of rows) {\n const encoded = JSON.stringify(encodeRow(row));\n bytes += Buffer.byteLength(encoded, \"utf8\") + (items.length > 0 ? 1 : 0);\n if (bytes > MAX_RESPONSE_BYTES) {\n throw new DatabasePluginError(\"PAYLOAD_TOO_LARGE\", \"read\");\n }\n items.push(encoded);\n }\n writeJson(res, 200, prefix + items.join(\",\") + suffix);\n}\n\n/** A `204` carries no body but owes the same cache promise as one that does. */\nexport function sendEmpty(res: Response, status: number): void {\n res.status(status);\n res.setHeader(\"Cache-Control\", \"no-store\");\n res.send();\n}\n\n/**\n * Convert a failure into its safe category. A hook's deliberate validation\n * error is the one signal that reaches the caller, and only through the issues\n * naming a public column of this table.\n */\nfunction safeError(\n table: CrudTable,\n phase: \"read\" | \"write\",\n error: unknown,\n): DatabasePluginError {\n if (!(error instanceof DatabaseValidationError)) {\n return classifyDatabaseError(error, phase);\n }\n const details = error.issues\n .filter(\n (issue) => issue.path.length > 0 && table.selectable.has(issue.path[0]),\n )\n .map((issue) => ({ path: [...issue.path], message: issue.message }));\n return new DatabasePluginError(\n \"VALIDATION_FAILED\",\n phase,\n undefined,\n details,\n );\n}\n\n/** Answer with the failure's safe category and the field it concerns. */\nexport function sendError(\n res: Response,\n table: CrudTable,\n phase: \"read\" | \"write\",\n error: unknown,\n): void {\n if (res.headersSent) return;\n const safe = safeError(table, phase, error);\n const body: { error: string; details?: readonly DatabaseErrorDetail[] } = {\n error: safe.clientMessage,\n };\n if (safe.details && safe.details.length > 0) body.details = safe.details;\n const payload = JSON.stringify(body);\n // A failure owes the same byte budget, and its details are what can grow.\n writeJson(\n res,\n safe.statusCode,\n Buffer.byteLength(payload, \"utf8\") > MAX_RESPONSE_BYTES\n ? JSON.stringify({ error: safe.clientMessage })\n : payload,\n );\n}\n"],"mappings":";;;;;;;AAcA,SAAgB,aACd,OACqC;CACrC,MAAM,aACJ,iBAAiB,0BACb,MAAM,aACN,sBAAsB,OAAO,OAAO,CAAC;AAC3C,KAAI,eAAe,IAAK,QAAO;AAC/B,QAAO,aAAa,MAAM,aAAa;;;;;;AAOzC,SAAS,UAAU,KAAe,QAAgB,SAAuB;AACvE,KAAI,OAAO,OAAO;AAClB,KAAI,KAAK,mBAAmB;AAC5B,KAAI,UAAU,iBAAiB,WAAW;AAC1C,KAAI,KAAK,QAAQ;;;AAInB,SAAgB,SAAS,KAAe,QAAgB,MAAuB;CAC7E,MAAM,UAAU,KAAK,UAAU,KAAK;AACpC,KAAI,OAAO,WAAW,SAAS,OAAO,GAAG,mBACvC,OAAM,IAAI,oBAAoB,qBAAqB,OAAO;AAE5D,WAAU,KAAK,QAAQ,QAAQ;;;;;;;AAQjC,SAAgB,aACd,KACA,MACA,WACA,OACA,QACM;CACN,MAAM,SAAS;CACf,MAAM,SAAS,aAAa,MAAM,YAAY,OAAO;CACrD,IAAI,QACF,OAAO,WAAW,QAAQ,OAAO,GAAG,OAAO,WAAW,QAAQ,OAAO;CACvE,MAAM,QAAkB,EAAE;AAC1B,MAAK,MAAM,OAAO,MAAM;EACtB,MAAM,UAAU,KAAK,UAAU,UAAU,IAAI,CAAC;AAC9C,WAAS,OAAO,WAAW,SAAS,OAAO,IAAI,MAAM,SAAS,IAAI,IAAI;AACtE,MAAI,QAAQ,mBACV,OAAM,IAAI,oBAAoB,qBAAqB,OAAO;AAE5D,QAAM,KAAK,QAAQ;;AAErB,WAAU,KAAK,KAAK,SAAS,MAAM,KAAK,IAAI,GAAG,OAAO;;;AAIxD,SAAgB,UAAU,KAAe,QAAsB;AAC7D,KAAI,OAAO,OAAO;AAClB,KAAI,UAAU,iBAAiB,WAAW;AAC1C,KAAI,MAAM;;;;;;;AAQZ,SAAS,UACP,OACA,OACA,OACqB;AACrB,KAAI,EAAE,iBAAiB,yBACrB,QAAO,sBAAsB,OAAO,MAAM;AAO5C,QAAO,IAAI,oBACT,qBACA,OACA,QARc,MAAM,OACnB,QACE,UAAU,MAAM,KAAK,SAAS,KAAK,MAAM,WAAW,IAAI,MAAM,KAAK,GAAG,CACxE,CACA,KAAK,WAAW;EAAE,MAAM,CAAC,GAAG,MAAM,KAAK;EAAE,SAAS,MAAM;EAAS,EAAE,CAMrE;;;AAIH,SAAgB,UACd,KACA,OACA,OACA,OACM;AACN,KAAI,IAAI,YAAa;CACrB,MAAM,OAAO,UAAU,OAAO,OAAO,MAAM;CAC3C,MAAM,OAAoE,EACxE,OAAO,KAAK,eACb;AACD,KAAI,KAAK,WAAW,KAAK,QAAQ,SAAS,EAAG,MAAK,UAAU,KAAK;CACjE,MAAM,UAAU,KAAK,UAAU,KAAK;AAEpC,WACE,KACA,KAAK,YACL,OAAO,WAAW,SAAS,OAAO,GAAG,qBACjC,KAAK,UAAU,EAAE,OAAO,KAAK,eAAe,CAAC,GAC7C,QACL"}