@dbx-tools/appkit 0.9.118 → 0.9.120

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.
@@ -1,28 +1,23 @@
1
1
  /**
2
- * AppKit-global filesystem cache with user-isolated process-local LRU values.
2
+ * AppKit-global filesystem cache with user-isolated process-local values.
3
3
  *
4
4
  * @module
5
5
  */
6
6
 
7
7
  import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
8
+ import { cacheKeys, closeCache, createCache, getOrSet } from "@dbx-tools/cache/cache";
8
9
  import { errorUtils, log, object } from "@dbx-tools/shared-core";
9
10
  import { posixPath, type CacheValue, type FileSystemCache } from "@dbx-tools/shared-fs";
10
- import { LRUCache } from "lru-cache";
11
11
 
12
12
  const DEFAULT_TTL_MS = 60_000;
13
13
  const DEFAULT_INVALIDATION_BATCH_MS = 25;
14
14
  const DEFAULT_MAX_ENTRIES = 1_000;
15
- const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
16
15
  const DEFAULT_MAX_SCOPES = 256;
17
16
  const logger = log.logger("files-cache");
18
17
 
19
- interface RetainedEntry {
20
- value: CacheValue;
21
- }
22
-
23
18
  interface ScopeState {
24
19
  cache: FileSystemCache;
25
- entries: LRUCache<string, RetainedEntry>;
20
+ entries: ReturnType<typeof createCache>;
26
21
  group: GroupState;
27
22
  scope: ResolvedFilesCacheScope;
28
23
  sources: Map<string, Promise<object>>;
@@ -36,7 +31,6 @@ interface GroupState {
36
31
 
37
32
  interface ResolvedFilesCacheConfig {
38
33
  invalidationBatchMs: number;
39
- maxBytes: number;
40
34
  maxEntries: number;
41
35
  maxScopes: number;
42
36
  ttlMs: number;
@@ -57,8 +51,6 @@ export interface FilesCachePluginConfig extends BasePluginConfig {
57
51
  invalidationBatchMs?: number;
58
52
  /** Maximum entries retained in each user L1. Defaults to 1,000. */
59
53
  maxEntries?: number;
60
- /** Maximum retained bytes in each user L1. Defaults to 64 MiB. */
61
- maxBytes?: number;
62
54
  /** Maximum active user scopes retained by the process. Defaults to 256. */
63
55
  maxScopes?: number;
64
56
  }
@@ -101,7 +93,7 @@ export interface FilesCacheExports {
101
93
  export class FilesCacheManager {
102
94
  private readonly config: ResolvedFilesCacheConfig;
103
95
  private readonly groups = new Map<string, GroupState>();
104
- private readonly scopes: LRUCache<string, ScopeState>;
96
+ private readonly scopes: ReturnType<typeof createCache>;
105
97
 
106
98
  constructor(config: FilesCachePluginConfig = {}) {
107
99
  this.config = {
@@ -112,15 +104,9 @@ export class FilesCacheManager {
112
104
  "invalidationBatchMs",
113
105
  ),
114
106
  maxEntries: positiveInteger(config.maxEntries, DEFAULT_MAX_ENTRIES, "maxEntries"),
115
- maxBytes: positiveInteger(config.maxBytes, DEFAULT_MAX_BYTES, "maxBytes"),
116
107
  maxScopes: positiveInteger(config.maxScopes, DEFAULT_MAX_SCOPES, "maxScopes"),
117
108
  };
118
- this.scopes = new LRUCache({
119
- max: this.config.maxScopes,
120
- dispose: (state) => {
121
- state.group.scopes.delete(state);
122
- },
123
- });
109
+ this.scopes = createCache({ maxEntries: this.config.maxScopes, useClone: false });
124
110
  }
125
111
 
126
112
  /** Return one user-isolated cache within a host/workspace invalidation group. */
@@ -158,12 +144,23 @@ export class FilesCacheManager {
158
144
  userKey: scope.userKey,
159
145
  workspaceId: scope.workspaceId,
160
146
  });
161
- let state = this.scopes.get(scopeKey);
147
+ let state = await this.scopes.get<ScopeState>(scopeKey);
162
148
  if (!state) {
149
+ if ([...cacheKeys(this.scopes)].length >= this.config.maxScopes) {
150
+ const oldestKey = cacheKeys(this.scopes).next().value;
151
+ if (oldestKey) {
152
+ const oldest = await this.scopes.get<ScopeState>(oldestKey);
153
+ if (oldest) {
154
+ oldest.group.scopes.delete(oldest);
155
+ await closeCache(oldest.entries);
156
+ }
157
+ await this.scopes.delete(oldestKey);
158
+ }
159
+ }
163
160
  const group = this.group(scope);
164
161
  state = this.createScope(scope, group);
165
162
  group.scopes.add(state);
166
- this.scopes.set(scopeKey, state);
163
+ await this.scopes.set(scopeKey, state);
167
164
  }
168
165
  return state;
169
166
  }
@@ -180,7 +177,11 @@ export class FilesCacheManager {
180
177
  group.timer = undefined;
181
178
  }
182
179
  await this.flush();
183
- this.scopes.clear();
180
+ for (const key of cacheKeys(this.scopes)) {
181
+ const state = await this.scopes.get<ScopeState>(key);
182
+ if (state) await closeCache(state.entries);
183
+ }
184
+ await closeCache(this.scopes);
184
185
  this.groups.clear();
185
186
  }
186
187
 
@@ -203,11 +204,7 @@ export class FilesCacheManager {
203
204
  const state = {} as ScopeState;
204
205
  state.scope = scope;
205
206
  state.group = group;
206
- state.entries = new LRUCache({
207
- max: this.config.maxEntries,
208
- maxSize: this.config.maxBytes,
209
- sizeCalculation: (entry, cacheKey) => retainedSize(cacheKey, entry),
210
- });
207
+ state.entries = createCache({ maxEntries: this.config.maxEntries, useClone: false });
211
208
  state.sources = new Map();
212
209
  state.cache = {
213
210
  read: <T extends CacheValue>(cacheKey: string, load: () => T | Promise<T>) =>
@@ -224,16 +221,21 @@ export class FilesCacheManager {
224
221
  load: () => T | Promise<T>,
225
222
  ): Promise<T> {
226
223
  if (!state.group.pending.has(key)) {
227
- const retained = state.entries.get(key);
228
- if (retained) return retained.value as T;
224
+ const retained = await state.entries.get<T>(key);
225
+ if (retained !== undefined) return retained;
229
226
  }
230
- const value = await load();
231
- state.entries.set(key, { value }, { ttl: state.scope.ttlMs });
232
- return value;
227
+ if (state.group.pending.has(key)) {
228
+ const value = await load();
229
+ await state.entries.set(key, value, { ttl: state.scope.ttlMs });
230
+ return value;
231
+ }
232
+ return (await getOrSet(state.entries, key, () => Promise.resolve(load()), {
233
+ ttl: state.scope.ttlMs,
234
+ })) as T;
233
235
  }
234
236
 
235
237
  private *keys(state: ScopeState): Iterable<string> {
236
- for (const key of state.entries.keys()) {
238
+ for (const key of cacheKeys(state.entries)) {
237
239
  if (!state.group.pending.has(key)) yield key;
238
240
  }
239
241
  }
@@ -245,7 +247,7 @@ export class FilesCacheManager {
245
247
  state.group.timer = undefined;
246
248
  void this.flushInvalidations(state.group).catch((error) => {
247
249
  logger.warn("filesystem cache invalidation batch failed", {
248
- error: errorUtils.errorMessage(error),
250
+ error: errorUtils.toError(error),
249
251
  });
250
252
  });
251
253
  }, this.config.invalidationBatchMs);
@@ -259,12 +261,12 @@ export class FilesCacheManager {
259
261
  const keys = [...group.pending];
260
262
  group.pending.clear();
261
263
  for (const state of group.scopes) {
262
- for (const key of keys) state.entries.delete(key);
264
+ await Promise.all(keys.map((key) => state.entries.delete(key)));
263
265
  }
264
266
  }
265
267
  }
266
268
 
267
- /** AppKit plugin that owns process-local filesystem LRU caches. */
269
+ /** AppKit plugin that owns process-local filesystem caches. */
268
270
  export class FilesCachePlugin extends Plugin<FilesCachePluginConfig> {
269
271
  static manifest = {
270
272
  name: "files-cache",
@@ -324,11 +326,3 @@ function positiveDuration(value: number | undefined, fallback: number, name: str
324
326
  function positiveInteger(value: number | undefined, fallback: number, name: string): number {
325
327
  return Math.floor(positiveDuration(value, fallback, name));
326
328
  }
327
-
328
- function retainedSize(key: string, entry: RetainedEntry): number {
329
- try {
330
- return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(entry.value));
331
- } catch {
332
- return Number.MAX_SAFE_INTEGER;
333
- }
334
- }
package/src/identity.ts CHANGED
@@ -52,9 +52,9 @@
52
52
  * @module
53
53
  */
54
54
 
55
- import { ConfigurationError } from "@databricks/appkit";
55
+ import { ConfigurationError, Plugin } from "@databricks/appkit";
56
56
  import { configUtils } from "@dbx-tools/core";
57
- import { stringUtils, token } from "@dbx-tools/shared-core";
57
+ import { log, stringUtils, token } from "@dbx-tools/shared-core";
58
58
 
59
59
  /**
60
60
  * Header the Databricks Apps front door forwards the caller's OBO token on.
@@ -158,3 +158,68 @@ export function useServicePrincipal(mode: IdentityMode, req?: HeaderBearing): bo
158
158
  if (mode === "user") return false;
159
159
  return requestAccessToken(req) === undefined;
160
160
  }
161
+
162
+ const logger = log.logger("appkit");
163
+ const skippedAsUser = new Set<string>();
164
+ const AS_USER_SKIP_WARN = "Skipping user impersonation";
165
+ const AS_USER_PATCHED = Symbol.for("dbx-tools.appkit.asUserDevLog");
166
+
167
+ /**
168
+ * In development, keep AppKit `asUser` behavior. The first missing-token skip
169
+ * for a forwarded email or user id uses AppKit's own warn; later skips for
170
+ * that user, and every skip with no forwarded identity, are silent. Request
171
+ * objects are not a stable key: each plugin call typically has its own `req`.
172
+ *
173
+ * No-op outside development, when `Plugin.asUser` is missing or not a
174
+ * function, or if the prototype cannot be rewritten. Failures never throw:
175
+ * AppKit keeps its original `asUser`. Extra arguments are forwarded so a
176
+ * later signature change still reaches the original method.
177
+ */
178
+ export function installAsUserDevelopmentPatch(): void {
179
+ if (process.env.NODE_ENV !== "development") return;
180
+ try {
181
+ const proto = Plugin.prototype as unknown as Record<PropertyKey, unknown>;
182
+ if (proto[AS_USER_PATCHED] === true) return;
183
+ const original = proto.asUser;
184
+ if (typeof original !== "function") return;
185
+ proto[AS_USER_PATCHED] = true;
186
+ proto.asUser = function patchedAsUser(this: unknown, req: unknown, ...rest: unknown[]) {
187
+ const headers = asHeaderBearing(req);
188
+ const args = [req, ...rest];
189
+ if (!headers || requestAccessToken(headers) !== undefined) {
190
+ return Function.prototype.apply.call(original, this, args);
191
+ }
192
+ return withAsUserSkipWarnSuppressed(headers, () =>
193
+ Function.prototype.apply.call(original, this, args),
194
+ );
195
+ };
196
+ } catch (error) {
197
+ logger.debug("asUser development patch skipped", { error: String(error) });
198
+ }
199
+ }
200
+
201
+ function asHeaderBearing(req: unknown): HeaderBearing | undefined {
202
+ if (req === null || typeof req !== "object") return undefined;
203
+ if (typeof (req as HeaderBearing).header !== "function") return undefined;
204
+ return req as HeaderBearing;
205
+ }
206
+
207
+ function withAsUserSkipWarnSuppressed<T>(req: HeaderBearing, fn: () => T): T {
208
+ const user = requestUserEmail(req) ?? requestUserId(req);
209
+ if (user && !skippedAsUser.has(user)) {
210
+ skippedAsUser.add(user);
211
+ return fn();
212
+ }
213
+ const warn = console.warn;
214
+ console.warn = (...args: unknown[]) => {
215
+ if (!log.isLevelEnabled("debug") && args.some((value) => typeof value === "string" && value.includes(AS_USER_SKIP_WARN))) {
216
+ return;
217
+ }
218
+ warn.apply(console, args as Parameters<typeof console.warn>);
219
+ };
220
+ try {
221
+ return fn();
222
+ } finally {
223
+ console.warn = warn;
224
+ }
225
+ }
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
23
- import { log } from "@dbx-tools/shared-core";
23
+ import { errorUtils, log } from "@dbx-tools/shared-core";
24
24
  import type { LakebaseConnection } from "./lakebase-resolver.ts";
25
25
 
26
26
  const logger = log.logger("interceptor");
@@ -120,7 +120,10 @@ export function createInterceptorContext(env: ResolvedAppEnv): InterceptorRuntim
120
120
  } catch (error) {
121
121
  // Match AppKit: a failing lifecycle handler is logged, never fatal, and
122
122
  // never blocks its siblings.
123
- logger.warn("lifecycle handler failed", { event, error });
123
+ logger.warn("lifecycle handler failed", {
124
+ event,
125
+ error: errorUtils.toError(error),
126
+ });
124
127
  }
125
128
  }
126
129
  };
@@ -40,11 +40,13 @@ import {
40
40
  ExecutionError,
41
41
  getUsernameWithApiLookup,
42
42
  getWorkspaceClient,
43
+ type LakebasePool,
43
44
  ValidationError,
44
45
  } from "@databricks/appkit";
45
46
  import { configUtils, projectUtils } from "@dbx-tools/core";
46
47
  import {
47
48
  asyncUtils,
49
+ errorUtils,
48
50
  log,
49
51
  object,
50
52
  options as sharedOptions,
@@ -113,6 +115,23 @@ export interface LakebaseConnection extends LakebaseConnectionInputs {
113
115
  sslMode: SslMode;
114
116
  }
115
117
 
118
+ /**
119
+ * Capture AppKit's existing service-principal Lakebase pool.
120
+ *
121
+ * Call this during `setup:complete`, outside any `asUser()` scope. AppKit's
122
+ * exported pool routes dynamically by request context, but its runtime
123
+ * `activePool()` method returns the already-created underlying pool selected in
124
+ * the current context. Capturing it once at startup pins application-owned
125
+ * storage to the service principal without creating another pool or connection.
126
+ *
127
+ * A future AppKit version may export a fixed pool directly; in that case the
128
+ * pool has no `activePool()` method and is returned unchanged.
129
+ */
130
+ export function captureServicePrincipalPool(pool: LakebasePool): LakebasePool {
131
+ const routingPool = pool as LakebasePool & { activePool?: () => LakebasePool };
132
+ return routingPool.activePool?.call(pool) ?? pool;
133
+ }
134
+
116
135
  /**
117
136
  * Every field of the Lakebase REST payloads is modelled as optional and
118
137
  * validated at the boundary rather than asserted with a cast: the pickers below
@@ -778,7 +797,10 @@ function isAlreadyExistsError(error: unknown): boolean {
778
797
  * echo request context, so it goes to the log instead.
779
798
  */
780
799
  function operationFailed(opName: string, opError: unknown): ExecutionError {
781
- logger.error("autopg: operation failed", { operation: opName, error: opError });
800
+ logger.error("autopg: operation failed", {
801
+ operation: opName,
802
+ error: errorUtils.toError(opError),
803
+ });
782
804
  const code = object.isRecord(opError) ? (opError.code ?? opError.reason) : undefined;
783
805
  return new ExecutionError(`Lakebase operation failed: ${opName}`, {
784
806
  context: {
package/src/migration.ts CHANGED
@@ -31,10 +31,8 @@ export function handleOwnershipMigrationError(
31
31
  const key = `${options.scope}\0${message}`;
32
32
  if (loggedOwnershipErrors.has(key)) return true;
33
33
  loggedOwnershipErrors.add(key);
34
- if (log.isLevelEnabled("debug")) {
35
- options.logger.error(options.event, cause);
36
- } else {
37
- options.logger.warn(options.event, { error: message, ...options.context });
38
- }
34
+ const error = errorUtils.toError(cause);
35
+ if (log.isLevelEnabled("debug")) options.logger.error(options.event, { error });
36
+ else options.logger.warn(options.event, { error, ...options.context });
39
37
  return true;
40
38
  }
@@ -1,7 +1,7 @@
1
1
  /** AppKit-specific adapters over the shared intercepted-execution contract. */
2
2
 
3
3
  import { ExecutionError } from "@databricks/appkit";
4
- import { execution, type Logger } from "@dbx-tools/shared-core";
4
+ import { errorUtils, execution, type Logger } from "@dbx-tools/shared-core";
5
5
 
6
6
  /** Inputs for one plugin operation routed through AppKit interceptors. */
7
7
  export interface RunPluginExecutionOptions<T, Settings> {
@@ -32,7 +32,7 @@ export function runPluginExecution<T, Settings>(
32
32
  options.logger.warn("execution-failed", {
33
33
  operation: failure.operation,
34
34
  status: failure.status,
35
- error: failure.message,
35
+ error: errorUtils.toError(failure.message),
36
36
  });
37
37
  return new ExecutionError(`${options.plugin}: ${failure.operation} failed`, {
38
38
  context: { operation: failure.operation, status: failure.status },
package/src/provision.ts CHANGED
@@ -128,7 +128,7 @@ export async function provisionCacheSchema(
128
128
  } catch (err) {
129
129
  logger.warn("autopg: cache provisioning failed (continuing)", {
130
130
  schema: CACHE_SCHEMA,
131
- error: errorUtils.errorMessage(err),
131
+ error: errorUtils.toError(err),
132
132
  });
133
133
  } finally {
134
134
  await pool.end().catch(() => {});
@@ -0,0 +1,80 @@
1
+ /** Zod-owned operation contracts converted into native AppKit agent tools. */
2
+
3
+ import { defineTool, type ToolRegistry } from "@databricks/appkit/beta";
4
+ import { z } from "zod";
5
+
6
+ /** Metadata stored under `appkitTool` by a Zod schema's `.meta()` value. */
7
+ export interface AppKitToolMeta {
8
+ /** Opt in to exposing this operation as an agent tool. */
9
+ readonly agentTool?: boolean;
10
+ /** Tool side-effect classification. Defaults to `write`. */
11
+ readonly effect?: "read" | "write" | "destructive";
12
+ /** Require human approval before execution. */
13
+ readonly approval?: boolean;
14
+ /** Whether execution requires an authenticated user context. */
15
+ readonly requiresUserContext?: boolean;
16
+ /** Whether repeated calls with the same input are safe. */
17
+ readonly idempotent?: boolean;
18
+ /** Whether AppKit may inherit the tool automatically. */
19
+ readonly autoInheritable?: boolean;
20
+ }
21
+
22
+ /** Shape placed in a Zod schema's `.meta()` value. */
23
+ export interface AppKitOperationSchemaMeta {
24
+ readonly appkitTool?: AppKitToolMeta;
25
+ }
26
+
27
+ /** One typed operation backed by a single Zod input contract. */
28
+ export interface AppKitOperation<TSchema extends z.ZodType = z.ZodType, TResult = unknown> {
29
+ readonly schema: TSchema;
30
+ readonly execute: (input: z.output<TSchema>, signal?: AbortSignal) => TResult | Promise<TResult>;
31
+ }
32
+
33
+ /** Preserve operation inference while declaring an exported operation map. */
34
+ export function defineOperations<
35
+ const TSchemas extends Readonly<Record<string, z.ZodType>>,
36
+ >(operations: {
37
+ readonly [TName in keyof TSchemas]: AppKitOperation<TSchemas[TName], unknown>;
38
+ }): typeof operations {
39
+ return operations;
40
+ }
41
+
42
+ /** Convert exported Zod operation contracts into a native AppKit tool registry. */
43
+ export function toolRegistryFromOperations(
44
+ operations: Readonly<Record<string, AppKitOperation<z.ZodType, unknown>>>,
45
+ ): ToolRegistry {
46
+ return Object.fromEntries(
47
+ Object.entries(operations).flatMap(([name, operation]) => {
48
+ const metadata = operation.schema.meta() as AppKitOperationSchemaMeta | undefined;
49
+ const tool = metadata?.appkitTool ?? {};
50
+ if (tool.agentTool !== true) return [];
51
+ const effect = tool.effect ?? "write";
52
+ const approval = tool.approval === true || effect === "destructive";
53
+ const description = operation.schema.description?.trim();
54
+ if (!description) {
55
+ throw new TypeError(`AppKit operation ${name} requires a Zod schema description`);
56
+ }
57
+ return [
58
+ [
59
+ name,
60
+ defineTool({
61
+ description,
62
+ schema: operation.schema,
63
+ annotations: {
64
+ effect,
65
+ ...(approval ? { destructive: true } : {}),
66
+ ...(tool.requiresUserContext === undefined
67
+ ? {}
68
+ : { requiresUserContext: tool.requiresUserContext }),
69
+ ...(tool.idempotent === undefined ? {} : { idempotent: tool.idempotent }),
70
+ },
71
+ ...(tool.autoInheritable === undefined
72
+ ? {}
73
+ : { autoInheritable: tool.autoInheritable }),
74
+ execute: (input, signal) => operation.execute(operation.schema.parse(input), signal),
75
+ }),
76
+ ],
77
+ ];
78
+ }),
79
+ );
80
+ }