@databricks/appkit 0.37.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CLAUDE.md +3 -2
  2. package/NOTICE.md +1 -2
  3. package/dist/appkit/package.js +1 -1
  4. package/dist/cache/index.d.ts +27 -5
  5. package/dist/cache/index.d.ts.map +1 -1
  6. package/dist/cache/index.js +88 -16
  7. package/dist/cache/index.js.map +1 -1
  8. package/dist/cache/storage/persistent.js +1 -2
  9. package/dist/cache/storage/persistent.js.map +1 -1
  10. package/dist/cli/commands/plugin/add-resource/add-resource.js.map +1 -1
  11. package/dist/cli/commands/plugin/create/resource-defaults.js +2 -1
  12. package/dist/cli/commands/plugin/create/resource-defaults.js.map +1 -1
  13. package/dist/cli/commands/plugin/schema-resources.js +34 -51
  14. package/dist/cli/commands/plugin/schema-resources.js.map +1 -1
  15. package/dist/cli/commands/plugin/sync/sync.js +20 -6
  16. package/dist/cli/commands/plugin/sync/sync.js.map +1 -1
  17. package/dist/cli/commands/plugin/validate/validate-manifest.js +71 -157
  18. package/dist/cli/commands/plugin/validate/validate-manifest.js.map +1 -1
  19. package/dist/cli/commands/plugin/validate/validate.js +2 -2
  20. package/dist/cli/commands/plugin/validate/validate.js.map +1 -1
  21. package/dist/cli/commands/setup.js +2 -2
  22. package/dist/cli/commands/setup.js.map +1 -1
  23. package/dist/connectors/index.js +0 -1
  24. package/dist/connectors/jobs/client.js +1 -2
  25. package/dist/connectors/jobs/client.js.map +1 -1
  26. package/dist/connectors/lakebase/routing-pool.js +1 -2
  27. package/dist/connectors/lakebase/routing-pool.js.map +1 -1
  28. package/dist/connectors/sql-warehouse/client.js +1 -2
  29. package/dist/connectors/sql-warehouse/client.js.map +1 -1
  30. package/dist/context/execution-context.js +9 -13
  31. package/dist/context/execution-context.js.map +1 -1
  32. package/dist/context/index.js +3 -13
  33. package/dist/context/service-context.js +137 -136
  34. package/dist/context/service-context.js.map +1 -1
  35. package/dist/context/user-context.js +1 -5
  36. package/dist/context/user-context.js.map +1 -1
  37. package/dist/core/agent/create-agent.js +1 -2
  38. package/dist/core/agent/create-agent.js.map +1 -1
  39. package/dist/core/appkit.js +1 -2
  40. package/dist/core/appkit.js.map +1 -1
  41. package/dist/errors/authentication.js +44 -40
  42. package/dist/errors/authentication.js.map +1 -1
  43. package/dist/errors/base.js +86 -70
  44. package/dist/errors/base.js.map +1 -1
  45. package/dist/errors/configuration.js +70 -66
  46. package/dist/errors/configuration.js.map +1 -1
  47. package/dist/errors/connection.js +50 -46
  48. package/dist/errors/connection.js.map +1 -1
  49. package/dist/errors/execution.js +47 -43
  50. package/dist/errors/execution.js.map +1 -1
  51. package/dist/errors/index.js +10 -27
  52. package/dist/errors/initialization.js +38 -34
  53. package/dist/errors/initialization.js.map +1 -1
  54. package/dist/errors/server.js +34 -31
  55. package/dist/errors/server.js.map +1 -1
  56. package/dist/errors/tunnel.js +47 -43
  57. package/dist/errors/tunnel.js.map +1 -1
  58. package/dist/errors/validation.js +41 -37
  59. package/dist/errors/validation.js.map +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +3 -9
  62. package/dist/plugin/dev-reader.js +1 -2
  63. package/dist/plugin/dev-reader.js.map +1 -1
  64. package/dist/plugin/interceptors/cache.js +13 -1
  65. package/dist/plugin/interceptors/cache.js.map +1 -1
  66. package/dist/plugin/interceptors/retry.js +1 -2
  67. package/dist/plugin/interceptors/retry.js.map +1 -1
  68. package/dist/plugin/interceptors/telemetry.js +1 -2
  69. package/dist/plugin/interceptors/telemetry.js.map +1 -1
  70. package/dist/plugin/plugin.js +2 -4
  71. package/dist/plugin/plugin.js.map +1 -1
  72. package/dist/plugins/analytics/analytics.js +1 -2
  73. package/dist/plugins/analytics/analytics.js.map +1 -1
  74. package/dist/plugins/analytics/manifest.js +9 -1
  75. package/dist/plugins/analytics/query.js +2 -4
  76. package/dist/plugins/analytics/query.js.map +1 -1
  77. package/dist/plugins/files/manifest.js +7 -1
  78. package/dist/plugins/files/plugin.js +3 -6
  79. package/dist/plugins/files/plugin.js.map +1 -1
  80. package/dist/plugins/genie/genie.js +1 -2
  81. package/dist/plugins/genie/genie.js.map +1 -1
  82. package/dist/plugins/genie/manifest.js +7 -1
  83. package/dist/plugins/jobs/plugin.js +2 -4
  84. package/dist/plugins/jobs/plugin.js.map +1 -1
  85. package/dist/plugins/lakebase/lakebase.js +1 -2
  86. package/dist/plugins/lakebase/lakebase.js.map +1 -1
  87. package/dist/plugins/lakebase/manifest.js +26 -4
  88. package/dist/plugins/server/index.js +1 -2
  89. package/dist/plugins/server/index.js.map +1 -1
  90. package/dist/plugins/server/vite-dev-server.js +1 -2
  91. package/dist/plugins/server/vite-dev-server.js.map +1 -1
  92. package/dist/plugins/serving/serving.js +1 -2
  93. package/dist/plugins/serving/serving.js.map +1 -1
  94. package/dist/registry/index.d.ts +1 -1
  95. package/dist/registry/manifest-loader.d.ts +4 -4
  96. package/dist/registry/manifest-loader.d.ts.map +1 -1
  97. package/dist/registry/manifest-loader.js +1 -2
  98. package/dist/registry/manifest-loader.js.map +1 -1
  99. package/dist/registry/resource-registry.js +1 -2
  100. package/dist/registry/resource-registry.js.map +1 -1
  101. package/dist/registry/types.d.ts +21 -7
  102. package/dist/registry/types.d.ts.map +1 -1
  103. package/dist/registry/types.generated.d.ts +1 -1
  104. package/dist/registry/types.generated.js +1 -1
  105. package/dist/registry/types.generated.js.map +1 -1
  106. package/dist/registry/types.js +3 -3
  107. package/dist/registry/types.js.map +1 -1
  108. package/dist/schemas/manifest.d.ts +1139 -0
  109. package/dist/schemas/manifest.d.ts.map +1 -0
  110. package/dist/schemas/manifest.js +524 -0
  111. package/dist/schemas/manifest.js.map +1 -0
  112. package/dist/shared/src/index.d.ts +1 -1
  113. package/dist/shared/src/plugin.d.ts +38 -7
  114. package/dist/shared/src/plugin.d.ts.map +1 -1
  115. package/dist/shared/src/schemas/manifest.d.ts +1110 -0
  116. package/dist/shared/src/schemas/manifest.d.ts.map +1 -0
  117. package/dist/stream/arrow-stream-processor.js +1 -2
  118. package/dist/stream/arrow-stream-processor.js.map +1 -1
  119. package/dist/stream/buffers.js +1 -2
  120. package/dist/stream/buffers.js.map +1 -1
  121. package/dist/stream/stream-manager.d.ts.map +1 -1
  122. package/dist/stream/stream-manager.js +3 -3
  123. package/dist/stream/stream-manager.js.map +1 -1
  124. package/docs/api/appkit/Enumeration.ResourceType.md +1 -1
  125. package/docs/api/appkit/Interface.PluginManifest.md +57 -3
  126. package/docs/api/appkit/Interface.ResourceEntry.md +6 -4
  127. package/docs/api/appkit/Interface.ResourceRequirement.md +7 -61
  128. package/docs/api/appkit/TypeAlias.ResourceFieldEntry.md +6 -0
  129. package/docs/api/appkit.md +6 -6
  130. package/docs/app-management.md +1 -1
  131. package/docs/development/ai-assisted-development.md +2 -2
  132. package/docs/development/local-development.md +1 -1
  133. package/docs/development/remote-bridge.md +1 -1
  134. package/docs/development/templates.md +118 -12
  135. package/docs/development.md +1 -1
  136. package/docs/plugins/custom-plugins.md +33 -23
  137. package/docs/plugins/lakebase.md +1 -1
  138. package/docs/plugins/manifest.md +293 -0
  139. package/docs.md +2 -2
  140. package/llms.txt +3 -2
  141. package/package.json +3 -3
  142. package/sbom.cdx.json +1 -1
  143. package/dist/_virtual/_rolldown/runtime.js +0 -7
  144. package/dist/connectors/lakebase-v1/client.js +0 -15
  145. package/dist/connectors/lakebase-v1/client.js.map +0 -1
  146. package/dist/connectors/lakebase-v1/index.js +0 -3
  147. package/dist/context/index.js.map +0 -1
  148. package/dist/errors/index.js.map +0 -1
  149. package/dist/index.js.map +0 -1
  150. package/dist/schemas/plugin-manifest.generated.d.ts +0 -182
  151. package/dist/schemas/plugin-manifest.generated.d.ts.map +0 -1
  152. package/dist/schemas/plugin-manifest.schema.json +0 -489
  153. package/dist/schemas/template-plugins.schema.json +0 -113
  154. package/dist/shared/src/schemas/plugin-manifest.generated.d.ts +0 -182
  155. package/dist/shared/src/schemas/plugin-manifest.generated.d.ts.map +0 -1
  156. package/docs/api/appkit/Interface.ResourceFieldEntry.md +0 -82
package/CLAUDE.md CHANGED
@@ -52,6 +52,7 @@ npx @databricks/appkit docs <query>
52
52
  - [Genie plugin](./docs/plugins/genie.md): Integrates Databricks AI/BI Genie spaces into your AppKit application, enabling natural language data queries via a conversational interface.
53
53
  - [Jobs plugin](./docs/plugins/jobs.md): Trigger and monitor Databricks Lakeflow Jobs from your AppKit application.
54
54
  - [Lakebase plugin](./docs/plugins/lakebase.md): Provides a PostgreSQL connection pool for Databricks Lakebase Autoscaling with automatic OAuth token refresh.
55
+ - [Plugin manifest](./docs/plugins/manifest.md): Every plugin ships a manifest.json next to its source code. The manifest declares plugin metadata, the Databricks resources the plugin needs, and any structured rules a scaffolding agent must honor when running databricks apps init. It is consumed at three stages:
55
56
  - [Model Serving plugin](./docs/plugins/model-serving.md): Provides an authenticated proxy to Databricks Model Serving endpoints, with invoke and streaming support.
56
57
  - [Plugin management](./docs/plugins/plugin-management.md): AppKit includes a CLI for managing plugins. All commands are available under npx @databricks/appkit plugin.
57
58
  - [Server plugin](./docs/plugins/server.md): Provides HTTP server capabilities with development and production modes.
@@ -76,7 +77,7 @@ npx @databricks/appkit docs <query>
76
77
  - [Class: TunnelError](./docs/api/appkit/Class.TunnelError.md): Error thrown when remote tunnel operations fail.
77
78
  - [Class: ValidationError](./docs/api/appkit/Class.ValidationError.md): Error thrown when input validation fails.
78
79
  - [Enumeration: RequestedClaimsPermissionSet](./docs/api/appkit/Enumeration.RequestedClaimsPermissionSet.md): Permission set for Unity Catalog table access
79
- - [Enumeration: ResourceType](./docs/api/appkit/Enumeration.ResourceType.md): Resource types from schema $defs.resourceType.enum
80
+ - [Enumeration: ResourceType](./docs/api/appkit/Enumeration.ResourceType.md): Resource types from resourceTypeSchema.options
80
81
  - [Function: agentIdFromMarkdownPath()](./docs/api/appkit/Function.agentIdFromMarkdownPath.md): Derives the logical agent id from a markdown path. When the file is named
81
82
  - [Function: appKitServingTypesPlugin()](./docs/api/appkit/Function.appKitServingTypesPlugin.md): Vite plugin to generate TypeScript types for AppKit serving endpoints.
82
83
  - [Function: appKitTypesPlugin()](./docs/api/appkit/Function.appKitTypesPlugin.md): Vite plugin to generate types for AppKit queries.
@@ -141,7 +142,6 @@ npx @databricks/appkit docs <query>
141
142
  - [Interface: RequestedClaims](./docs/api/appkit/Interface.RequestedClaims.md): Optional claims for fine-grained Unity Catalog table permissions
142
143
  - [Interface: RequestedResource](./docs/api/appkit/Interface.RequestedResource.md): Resource to request permissions for in Unity Catalog
143
144
  - [Interface: ResourceEntry](./docs/api/appkit/Interface.ResourceEntry.md): Internal representation of a resource in the registry.
144
- - [Interface: ResourceFieldEntry](./docs/api/appkit/Interface.ResourceFieldEntry.md): Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instancename, databasename or scope, key).
145
145
  - [Interface: ResourceRequirement](./docs/api/appkit/Interface.ResourceRequirement.md): Declares a resource requirement for a plugin.
146
146
  - [Interface: RunAgentInput](./docs/api/appkit/Interface.RunAgentInput.md): Properties
147
147
  - [Interface: RunAgentResult](./docs/api/appkit/Interface.RunAgentResult.md): Properties
@@ -174,6 +174,7 @@ npx @databricks/appkit docs <query>
174
174
  - [Type Alias: PluginData<T, U, N>](./docs/api/appkit/TypeAlias.PluginData.md): Tuple of plugin class, config, and name. Created by toPlugin() and passed to createApp().
175
175
  - [Type Alias: Plugins](./docs/api/appkit/TypeAlias.Plugins.md): Plugin map passed to the function form of AgentDefinition.tools.
176
176
  - [Type Alias: ResolvedToolEntry](./docs/api/appkit/TypeAlias.ResolvedToolEntry.md): Internal tool-index entry after a tool record has been resolved to a dispatchable form.
177
+ - [Type Alias: ResourceFieldEntry](./docs/api/appkit/TypeAlias.ResourceFieldEntry.md)
177
178
  - [Type Alias: ResourcePermission](./docs/api/appkit/TypeAlias.ResourcePermission.md): Union of all possible permission levels across all resource types.
178
179
  - [Type Alias: ServingFactory](./docs/api/appkit/TypeAlias.ServingFactory.md): Factory function returned by AppKit.serving.
179
180
  - [Type Alias: ToolRegistry](./docs/api/appkit/TypeAlias.ToolRegistry.md)
package/NOTICE.md CHANGED
@@ -49,10 +49,9 @@ This Software contains code from the following open source projects:
49
49
  | [@radix-ui/react-toggle](https://www.npmjs.com/package/@radix-ui/react-toggle) | 1.1.10 | MIT | https://radix-ui.com/primitives |
50
50
  | [@radix-ui/react-toggle-group](https://www.npmjs.com/package/@radix-ui/react-toggle-group) | 1.1.11 | MIT | https://radix-ui.com/primitives |
51
51
  | [@radix-ui/react-tooltip](https://www.npmjs.com/package/@radix-ui/react-tooltip) | 1.2.8 | MIT | https://radix-ui.com/primitives |
52
+ | [@standard-schema/spec](https://www.npmjs.com/package/@standard-schema/spec) | 1.1.0 | MIT | https://standardschema.dev |
52
53
  | [@tanstack/react-table](https://www.npmjs.com/package/@tanstack/react-table) | 8.21.3 | MIT | https://tanstack.com/table |
53
54
  | [@types/semver](https://www.npmjs.com/package/@types/semver) | 7.7.1 | MIT | https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/semver |
54
- | [ajv](https://www.npmjs.com/package/ajv) | 6.12.6, 8.17.1 | MIT | https://ajv.js.org |
55
- | [ajv-formats](https://www.npmjs.com/package/ajv-formats) | 2.1.1, 3.0.1 | MIT | https://github.com/ajv-validator/ajv-formats#readme |
56
55
  | [apache-arrow](https://www.npmjs.com/package/apache-arrow) | 21.1.0 | Apache-2.0 | https://arrow.apache.org/js/ |
57
56
  | [class-variance-authority](https://www.npmjs.com/package/class-variance-authority) | 0.7.1 | Apache-2.0 | https://github.com/joe-bell/cva#readme |
58
57
  | [clsx](https://www.npmjs.com/package/clsx) | 2.1.1 | MIT | https://github.com/lukeed/clsx#readme |
@@ -1,6 +1,6 @@
1
1
  //#region package.json
2
2
  var name = "@databricks/appkit";
3
- var version = "0.37.0";
3
+ var version = "0.38.1";
4
4
 
5
5
  //#endregion
6
6
  export { name, version };
@@ -18,6 +18,7 @@ import "../shared/src/index.js";
18
18
  */
19
19
  declare class CacheManager {
20
20
  private static readonly MIN_CLEANUP_INTERVAL_MS;
21
+ private static readonly ABORT_GRACE_PERIOD_MS;
21
22
  private readonly name;
22
23
  private static instance;
23
24
  private static initPromise;
@@ -58,16 +59,37 @@ declare class CacheManager {
58
59
  */
59
60
  private static create;
60
61
  /**
61
- * Get or execute a function and cache the result
62
- * @param key - Cache key
63
- * @param fn - Function to execute
64
- * @param userKey - User key
62
+ * Get or execute a function and cache the result.
63
+ *
64
+ * Multiple concurrent callers with the same `cacheKey` are deduplicated
65
+ * onto a single in-flight execution. Each caller may pass its own
66
+ * `callerSignal`; the underlying `fn()` is run with a shared, internally
67
+ * managed `AbortSignal` that aborts only when *all* callers have
68
+ * abandoned the request (reference counted). This decouples a single
69
+ * caller's cancellation (e.g. React StrictMode unmount) from the shared
70
+ * result, so other still-connected callers receive the cached value
71
+ * normally.
72
+ *
73
+ * @param key - Cache key parts
74
+ * @param fn - Function to execute. Receives the cache-owned shared signal;
75
+ * pass it through to the underlying I/O so the work is cancelled when
76
+ * no caller is left waiting.
77
+ * @param userKey - User key for cache namespacing
65
78
  * @param options - Options for the cache
66
79
  * @returns Promise of the result
67
80
  */
68
- getOrExecute<T>(key: (string | number | object)[], fn: () => Promise<T>, userKey: string, options?: {
81
+ getOrExecute<T>(key: (string | number | object)[], fn: (sharedSignal?: AbortSignal) => Promise<T>, userKey: string, options?: {
69
82
  ttl?: number;
83
+ callerSignal?: AbortSignal;
70
84
  }): Promise<T>;
85
+ /**
86
+ * Wait on an in-flight entry, racing the underlying promise against the
87
+ * caller's abort signal. When the caller aborts, the entry's refCount is
88
+ * decremented; if it hits zero the shared controller is aborted so the
89
+ * underlying `fn()` can stop. Other callers continue to await the same
90
+ * entry and receive the result when it arrives.
91
+ */
92
+ private _waitWithRefCount;
71
93
  /**
72
94
  * Get a cached value
73
95
  * @param key - Cache key
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/cache/index.ts"],"mappings":";;;;;;;AA4BA;;;;;;;;;;;cAAa,YAAA;EAAA,wBACa,uBAAA;EAAA,iBACP,IAAA;EAAA,eACF,QAAA;EAAA,eACA,WAAA;EAAA,QAEP,OAAA;EAAA,QACA,MAAA;EAAA,QACA,gBAAA;EAAA,QACA,iBAAA;EAAA,QACA,kBAAA;EAAA,QAEA,SAAA;EAAA,QACA,gBAAA;EAAA,QAKD,WAAA,CAAA;EAhBU;;;;;;EAAA,OA6CV,eAAA,CAAA,GAAmB,YAAA;EArClB;;;;;;;EAAA,OAuDK,WAAA,CACX,UAAA,GAAa,OAAA,CAAQ,WAAA,IACpB,OAAA,CAAQ,YAAA;EADY;;;;;;;;;;;;EAAA,eA8BF,MAAA;EA+DnB;;;;;;;;EAJI,YAAA,GAAA,CACJ,GAAA,gCACA,EAAA,QAAU,OAAA,CAAQ,CAAA,GAClB,OAAA,UACA,OAAA;IAAY,GAAA;EAAA,IACX,OAAA,CAAQ,CAAA;EA+KD;;;;;EA5DJ,GAAA,GAAA,CAAO,GAAA,WAAc,OAAA,CAAQ,CAAA;EAgEhC;;;;;;;EAAA,QA/CW,QAAA;EA4EU;EAAA,QAhEhB,YAAA;EA6EI;;;;;;;EA9CN,GAAA,GAAA,CACJ,GAAA,UACA,KAAA,EAAO,CAAA,EACP,OAAA;IAAY,GAAA;EAAA,IACX,OAAA;;;;;;EAaG,MAAA,CAAO,GAAA,WAAc,OAAA;;EAMrB,KAAA,CAAA,GAAS,OAAA;;;;;;EAUT,GAAA,CAAI,GAAA,WAAc,OAAA;;;;;;;EAaxB,WAAA,CAAY,KAAA,gCAAqC,OAAA;;EAO3C,KAAA,CAAA,GAAS,OAAA;;;;;EAQT,gBAAA,CAAA,GAAoB,OAAA;AAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/cache/index.ts"],"mappings":";;;;;;;AAmDA;;;;;;;;;;;cAAa,YAAA;EAAA,wBACa,uBAAA;EAAA,wBACA,qBAAA;EAAA,iBACP,IAAA;EAAA,eACF,QAAA;EAAA,eACA,WAAA;EAAA,QAEP,OAAA;EAAA,QACA,MAAA;EAAA,QACA,gBAAA;EAAA,QACA,iBAAA;EAAA,QACA,kBAAA;EAAA,QAEA,SAAA;EAAA,QACA,gBAAA;EAAA,QAKD,WAAA,CAAA;EAlBiB;;;;;;EAAA,OA+CjB,eAAA,CAAA,GAAmB,YAAA;EAvClB;;;;;;;EAAA,OAyDK,WAAA,CACX,UAAA,GAAa,OAAA,CAAQ,WAAA,IACpB,OAAA,CAAQ,YAAA;EAFE;;;;;;;;;;;;EAAA,eA+BQ,MAAA;EAyEyB;;;;;;;;;;;;;;;;;;;;EAFxC,YAAA,GAAA,CACJ,GAAA,gCACA,EAAA,GAAK,YAAA,GAAe,WAAA,KAAgB,OAAA,CAAQ,CAAA,GAC5C,OAAA,UACA,OAAA;IAAY,GAAA;IAAc,YAAA,GAAe,WAAA;EAAA,IACxC,OAAA,CAAQ,CAAA;EAkRR;;;;;;;EAAA,QA7HK,iBAAA;EA6JgB;;;;;EAhGlB,GAAA,GAAA,CAAO,GAAA,WAAc,OAAA,CAAQ,CAAA;EA4H7B;;;;;;;EAAA,QA3GQ,QAAA;;UAYN,YAAA;;;;;;;;EA+BF,GAAA,GAAA,CACJ,GAAA,UACA,KAAA,EAAO,CAAA,EACP,OAAA;IAAY,GAAA;EAAA,IACX,OAAA;;;;;;EAaG,MAAA,CAAO,GAAA,WAAc,OAAA;;EAMrB,KAAA,CAAA,GAAS,OAAA;;;;;;EAaT,GAAA,CAAI,GAAA,WAAc,OAAA;;;;;;;EAaxB,WAAA,CAAY,KAAA,gCAAqC,OAAA;;EAO3C,KAAA,CAAA,GAAS,OAAA;;;;;EAQT,gBAAA,CAAA,GAAoB,OAAA;AAAA"}
@@ -2,7 +2,7 @@ import { createLogger } from "../logging/logger.js";
2
2
  import { AppKitError } from "../errors/base.js";
3
3
  import { ExecutionError } from "../errors/execution.js";
4
4
  import { InitializationError } from "../errors/initialization.js";
5
- import { init_errors } from "../errors/index.js";
5
+ import "../errors/index.js";
6
6
  import { createLakebasePool } from "../connectors/lakebase/index.js";
7
7
  import { TelemetryManager } from "../telemetry/telemetry-manager.js";
8
8
  import { SpanStatusCode } from "../telemetry/index.js";
@@ -15,8 +15,11 @@ import { createHash } from "node:crypto";
15
15
  import { ApiError, WorkspaceClient } from "@databricks/sdk-experimental";
16
16
 
17
17
  //#region src/cache/index.ts
18
- init_errors();
19
18
  const logger = createLogger("cache");
19
+ function createAbortError(signal) {
20
+ if (signal.reason !== void 0) return signal.reason;
21
+ return new DOMException("The operation was aborted.", "AbortError");
22
+ }
20
23
  /**
21
24
  * Cache manager class to handle cache operations.
22
25
  * Can be used with in-memory storage or persistent storage (Lakebase).
@@ -33,6 +36,7 @@ const logger = createLogger("cache");
33
36
  */
34
37
  var CacheManager = class CacheManager {
35
38
  static MIN_CLEANUP_INTERVAL_MS = 6e4;
39
+ static ABORT_GRACE_PERIOD_MS = 100;
36
40
  name = "cache-manager";
37
41
  static instance = null;
38
42
  static initPromise = null;
@@ -130,15 +134,29 @@ var CacheManager = class CacheManager {
130
134
  return new CacheManager(new InMemoryStorage(config), config);
131
135
  }
132
136
  /**
133
- * Get or execute a function and cache the result
134
- * @param key - Cache key
135
- * @param fn - Function to execute
136
- * @param userKey - User key
137
+ * Get or execute a function and cache the result.
138
+ *
139
+ * Multiple concurrent callers with the same `cacheKey` are deduplicated
140
+ * onto a single in-flight execution. Each caller may pass its own
141
+ * `callerSignal`; the underlying `fn()` is run with a shared, internally
142
+ * managed `AbortSignal` that aborts only when *all* callers have
143
+ * abandoned the request (reference counted). This decouples a single
144
+ * caller's cancellation (e.g. React StrictMode unmount) from the shared
145
+ * result, so other still-connected callers receive the cached value
146
+ * normally.
147
+ *
148
+ * @param key - Cache key parts
149
+ * @param fn - Function to execute. Receives the cache-owned shared signal;
150
+ * pass it through to the underlying I/O so the work is cancelled when
151
+ * no caller is left waiting.
152
+ * @param userKey - User key for cache namespacing
137
153
  * @param options - Options for the cache
138
154
  * @returns Promise of the result
139
155
  */
140
156
  async getOrExecute(key, fn, userKey, options) {
141
- if (!this.config.enabled) return fn();
157
+ if (!this.config.enabled) return fn(options?.callerSignal);
158
+ const callerSignal = options?.callerSignal;
159
+ if (callerSignal?.aborted) throw createAbortError(callerSignal);
142
160
  const cacheKey = this.generateKey(key, userKey);
143
161
  return this.telemetry.startActiveSpan("cache.getOrExecute", { attributes: {
144
162
  "cache.key": cacheKey,
@@ -157,12 +175,16 @@ var CacheManager = class CacheManager {
157
175
  });
158
176
  return cached.value;
159
177
  }
160
- const inFlight = this.inFlightRequests.get(cacheKey);
161
- if (inFlight) {
178
+ const existing = this.inFlightRequests.get(cacheKey);
179
+ if (existing && !existing.sharedController.signal.aborted) {
180
+ existing.refCount++;
181
+ if (existing.abortTimer) {
182
+ clearTimeout(existing.abortTimer);
183
+ existing.abortTimer = void 0;
184
+ }
162
185
  span.setAttribute("cache.hit", true);
163
186
  span.setAttribute("cache.deduplication", true);
164
187
  span.addEvent("cache.deduplication_used", { "cache.key": cacheKey });
165
- span.setStatus({ code: SpanStatusCode.OK });
166
188
  this.telemetryMetrics.cacheHitCount.add(1, {
167
189
  "cache.key": cacheKey,
168
190
  "cache.deduplication": "true"
@@ -172,8 +194,7 @@ var CacheManager = class CacheManager {
172
194
  cache_key: cacheKey,
173
195
  cache_deduplication: true
174
196
  });
175
- span.end();
176
- return inFlight;
197
+ return await this._waitWithRefCount(existing, callerSignal);
177
198
  }
178
199
  span.setAttribute("cache.hit", false);
179
200
  span.addEvent("cache.miss", { "cache.key": cacheKey });
@@ -182,7 +203,13 @@ var CacheManager = class CacheManager {
182
203
  cache_hit: false,
183
204
  cache_key: cacheKey
184
205
  });
185
- const promise = fn().then(async (result) => {
206
+ const sharedController = new AbortController();
207
+ const entry = {
208
+ promise: void 0,
209
+ refCount: 1,
210
+ sharedController
211
+ };
212
+ entry.promise = fn(sharedController.signal).then(async (result) => {
186
213
  await this.set(cacheKey, result, options);
187
214
  span.addEvent("cache.value_stored", {
188
215
  "cache.key": cacheKey,
@@ -192,13 +219,15 @@ var CacheManager = class CacheManager {
192
219
  }).catch((error) => {
193
220
  span.recordException(error);
194
221
  span.setStatus({ code: SpanStatusCode.ERROR });
222
+ if (sharedController.signal.aborted) throw error;
195
223
  if (error instanceof AppKitError || error instanceof ApiError) throw error;
196
224
  throw ExecutionError.statementFailed(error instanceof Error ? error.message : String(error));
197
225
  }).finally(() => {
198
- this.inFlightRequests.delete(cacheKey);
226
+ if (this.inFlightRequests.get(cacheKey) === entry) this.inFlightRequests.delete(cacheKey);
199
227
  });
200
- this.inFlightRequests.set(cacheKey, promise);
201
- const result = await promise;
228
+ entry.promise.catch(() => {});
229
+ this.inFlightRequests.set(cacheKey, entry);
230
+ const result = await this._waitWithRefCount(entry, callerSignal);
202
231
  span.setStatus({ code: SpanStatusCode.OK });
203
232
  return result;
204
233
  } catch (error) {
@@ -214,6 +243,48 @@ var CacheManager = class CacheManager {
214
243
  });
215
244
  }
216
245
  /**
246
+ * Wait on an in-flight entry, racing the underlying promise against the
247
+ * caller's abort signal. When the caller aborts, the entry's refCount is
248
+ * decremented; if it hits zero the shared controller is aborted so the
249
+ * underlying `fn()` can stop. Other callers continue to await the same
250
+ * entry and receive the result when it arrives.
251
+ */
252
+ _waitWithRefCount(entry, callerSignal) {
253
+ if (!callerSignal) return entry.promise;
254
+ return new Promise((resolve, reject) => {
255
+ let settled = false;
256
+ const release = () => {
257
+ if (entry.refCount > 0) entry.refCount--;
258
+ if (entry.refCount <= 0 && !entry.sharedController.signal.aborted) entry.abortTimer = setTimeout(() => {
259
+ if (entry.refCount <= 0 && !entry.sharedController.signal.aborted) entry.sharedController.abort(callerSignal.reason ?? "all cache callers aborted");
260
+ }, CacheManager.ABORT_GRACE_PERIOD_MS);
261
+ };
262
+ const onAbort = () => {
263
+ if (settled) return;
264
+ settled = true;
265
+ callerSignal.removeEventListener("abort", onAbort);
266
+ release();
267
+ reject(createAbortError(callerSignal));
268
+ };
269
+ if (callerSignal.aborted) {
270
+ onAbort();
271
+ return;
272
+ }
273
+ callerSignal.addEventListener("abort", onAbort, { once: true });
274
+ entry.promise.then((value) => {
275
+ if (settled) return;
276
+ settled = true;
277
+ callerSignal.removeEventListener("abort", onAbort);
278
+ resolve(value);
279
+ }, (error) => {
280
+ if (settled) return;
281
+ settled = true;
282
+ callerSignal.removeEventListener("abort", onAbort);
283
+ reject(error);
284
+ });
285
+ });
286
+ }
287
+ /**
217
288
  * Get a cached value
218
289
  * @param key - Cache key
219
290
  * @returns Promise of the value or null if not found or expired
@@ -283,6 +354,7 @@ var CacheManager = class CacheManager {
283
354
  /** Clear the cache */
284
355
  async clear() {
285
356
  await this.storage.clear();
357
+ for (const entry of this.inFlightRequests.values()) if (entry.abortTimer) clearTimeout(entry.abortTimer);
286
358
  this.inFlightRequests.clear();
287
359
  }
288
360
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/cache/index.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport { ApiError, WorkspaceClient } from \"@databricks/sdk-experimental\";\nimport type { CacheConfig, CacheEntry, CacheStorage } from \"shared\";\nimport { createLakebasePool } from \"../connectors/lakebase\";\nimport { AppKitError, ExecutionError, InitializationError } from \"../errors\";\nimport { createLogger } from \"../logging/logger\";\nimport type { Counter, TelemetryProvider } from \"../telemetry\";\nimport { SpanStatusCode, TelemetryManager } from \"../telemetry\";\nimport { deepMerge } from \"../utils\";\nimport { cacheDefaults } from \"./defaults\";\nimport { InMemoryStorage, PersistentStorage } from \"./storage\";\n\nconst logger = createLogger(\"cache\");\n\n/**\n * Cache manager class to handle cache operations.\n * Can be used with in-memory storage or persistent storage (Lakebase).\n *\n * The cache is automatically initialized by AppKit. Use `getInstanceSync()` to access\n * the singleton instance after initialization.\n *\n * @internal\n * @example\n * ```typescript\n * const cache = CacheManager.getInstanceSync();\n * const result = await cache.getOrExecute([\"users\", userId], () => fetchUser(userId), userKey);\n * ```\n */\nexport class CacheManager {\n private static readonly MIN_CLEANUP_INTERVAL_MS = 60_000;\n private readonly name: string = \"cache-manager\";\n private static instance: CacheManager | null = null;\n private static initPromise: Promise<CacheManager> | null = null;\n\n private storage: CacheStorage;\n private config: CacheConfig;\n private inFlightRequests: Map<string, Promise<unknown>>;\n private cleanupInProgress: boolean;\n private lastCleanupAttempt: number;\n\n private telemetry: TelemetryProvider;\n private telemetryMetrics: {\n cacheHitCount: Counter;\n cacheMissCount: Counter;\n };\n\n private constructor(storage: CacheStorage, config: CacheConfig) {\n this.storage = storage;\n this.config = config;\n this.inFlightRequests = new Map();\n this.cleanupInProgress = false;\n this.lastCleanupAttempt = 0;\n\n this.telemetry = TelemetryManager.getProvider(\n this.name,\n this.config.telemetry,\n );\n this.telemetryMetrics = {\n cacheHitCount: this.telemetry.getMeter().createCounter(\"cache.hit\", {\n description: \"Total number of cache hits\",\n unit: \"1\",\n }),\n cacheMissCount: this.telemetry.getMeter().createCounter(\"cache.miss\", {\n description: \"Total number of cache misses\",\n unit: \"1\",\n }),\n };\n }\n\n /**\n * Get the singleton instance of the cache manager (sync version).\n *\n * Throws if not initialized - ensure AppKit.create() has completed first.\n * @returns CacheManager instance\n */\n static getInstanceSync(): CacheManager {\n if (!CacheManager.instance) {\n throw InitializationError.notInitialized(\n \"CacheManager\",\n \"Ensure AppKit.create() has completed before accessing the cache\",\n );\n }\n\n return CacheManager.instance;\n }\n\n /**\n * Initialize and get the singleton instance of the cache manager.\n * Called internally by AppKit - prefer `getInstanceSync()` for plugin access.\n * @param userConfig - User configuration for the cache manager\n * @returns CacheManager instance\n * @internal\n */\n static async getInstance(\n userConfig?: Partial<CacheConfig>,\n ): Promise<CacheManager> {\n if (CacheManager.instance) {\n return CacheManager.instance;\n }\n\n if (!CacheManager.initPromise) {\n CacheManager.initPromise = CacheManager.create(userConfig).then(\n (instance) => {\n CacheManager.instance = instance;\n return instance;\n },\n );\n }\n\n return CacheManager.initPromise;\n }\n\n /**\n * Create a new cache manager instance\n *\n * Storage selection logic:\n * 1. If `storage` provided and healthy → use provided storage\n * 2. If `storage` provided but unhealthy → fallback to InMemory (or disable if strictPersistence)\n * 3. If no `storage` provided and Lakebase available → use Lakebase\n * 4. If no `storage` provided and Lakebase unavailable → fallback to InMemory (or disable if strictPersistence)\n *\n * @param userConfig - User configuration for the cache manager\n * @returns CacheManager instance\n */\n private static async create(\n userConfig?: Partial<CacheConfig>,\n ): Promise<CacheManager> {\n const config = deepMerge(cacheDefaults, userConfig);\n\n if (config.storage) {\n const isHealthy = await config.storage.healthCheck();\n if (isHealthy) {\n return new CacheManager(config.storage, config);\n }\n\n if (config.strictPersistence) {\n const disabledConfig = { ...config, enabled: false };\n return new CacheManager(\n new InMemoryStorage(disabledConfig),\n disabledConfig,\n );\n }\n\n return new CacheManager(new InMemoryStorage(config), config);\n }\n\n // try to use lakebase storage\n try {\n const workspaceClient = new WorkspaceClient({});\n const pool = createLakebasePool({ workspaceClient });\n const persistentStorage = new PersistentStorage(config, pool);\n\n const isHealthy = await persistentStorage.healthCheck();\n if (isHealthy) {\n await persistentStorage.initialize();\n return new CacheManager(persistentStorage, config);\n }\n\n // Health check failed, close the pool and fallback\n await pool.end();\n } catch {\n // lakebase unavailable, continue with in-memory storage\n }\n\n if (config.strictPersistence) {\n const disabledConfig = { ...config, enabled: false };\n return new CacheManager(\n new InMemoryStorage(disabledConfig),\n disabledConfig,\n );\n }\n\n return new CacheManager(new InMemoryStorage(config), config);\n }\n\n /**\n * Get or execute a function and cache the result\n * @param key - Cache key\n * @param fn - Function to execute\n * @param userKey - User key\n * @param options - Options for the cache\n * @returns Promise of the result\n */\n async getOrExecute<T>(\n key: (string | number | object)[],\n fn: () => Promise<T>,\n userKey: string,\n options?: { ttl?: number },\n ): Promise<T> {\n if (!this.config.enabled) return fn();\n\n const cacheKey = this.generateKey(key, userKey);\n\n return this.telemetry.startActiveSpan(\n \"cache.getOrExecute\",\n {\n attributes: {\n \"cache.key\": cacheKey,\n \"cache.enabled\": this.config.enabled,\n \"cache.persistent\": this.storage.isPersistent(),\n },\n },\n async (span) => {\n try {\n const cached = await this.getValid<T>(cacheKey);\n if (cached !== null) {\n span.setAttribute(\"cache.hit\", true);\n span.setStatus({ code: SpanStatusCode.OK });\n this.telemetryMetrics.cacheHitCount.add(1, {\n \"cache.key\": cacheKey,\n });\n\n logger.event()?.setExecution({\n cache_hit: true,\n cache_key: cacheKey,\n });\n\n return cached.value;\n }\n\n // check if the value is being processed by another request\n const inFlight = this.inFlightRequests.get(cacheKey);\n if (inFlight) {\n span.setAttribute(\"cache.hit\", true);\n span.setAttribute(\"cache.deduplication\", true);\n span.addEvent(\"cache.deduplication_used\", {\n \"cache.key\": cacheKey,\n });\n span.setStatus({ code: SpanStatusCode.OK });\n this.telemetryMetrics.cacheHitCount.add(1, {\n \"cache.key\": cacheKey,\n \"cache.deduplication\": \"true\",\n });\n\n logger.event()?.setExecution({\n cache_hit: true,\n cache_key: cacheKey,\n cache_deduplication: true,\n });\n\n span.end();\n return inFlight as Promise<T>;\n }\n\n // cache miss - execute function\n span.setAttribute(\"cache.hit\", false);\n span.addEvent(\"cache.miss\", { \"cache.key\": cacheKey });\n this.telemetryMetrics.cacheMissCount.add(1, {\n \"cache.key\": cacheKey,\n });\n\n logger.event()?.setExecution({\n cache_hit: false,\n cache_key: cacheKey,\n });\n\n const promise = fn()\n .then(async (result) => {\n await this.set(cacheKey, result, options);\n span.addEvent(\"cache.value_stored\", {\n \"cache.key\": cacheKey,\n \"cache.ttl\": options?.ttl ?? this.config.ttl ?? 3600,\n });\n return result;\n })\n .catch((error) => {\n span.recordException(error);\n span.setStatus({ code: SpanStatusCode.ERROR });\n // Preserve AppKit errors and Databricks API errors (with status codes)\n // so route handlers can map them to proper HTTP responses.\n if (error instanceof AppKitError || error instanceof ApiError) {\n throw error;\n }\n throw ExecutionError.statementFailed(\n error instanceof Error ? error.message : String(error),\n );\n })\n .finally(() => {\n this.inFlightRequests.delete(cacheKey);\n });\n\n this.inFlightRequests.set(cacheKey, promise);\n\n const result = await promise;\n span.setStatus({ code: SpanStatusCode.OK });\n return result;\n } catch (error) {\n span.recordException(error as Error);\n span.setStatus({ code: SpanStatusCode.ERROR });\n throw error;\n } finally {\n span.end();\n }\n },\n { name: this.name, includePrefix: true },\n );\n }\n\n /**\n * Get a cached value\n * @param key - Cache key\n * @returns Promise of the value or null if not found or expired\n */\n async get<T>(key: string): Promise<T | null> {\n if (!this.config.enabled) return null;\n\n // probabilistic cleanup trigger\n this.maybeCleanup();\n\n const entry = await this.getValid<T>(key);\n return entry?.value ?? null;\n }\n\n /**\n * Get a cached entry only if it has not expired.\n * Returns null on miss or expired (and deletes the expired entry).\n *\n * Storage implementations return entries unconditionally — expiry handling\n * lives at the CacheManager layer.\n */\n private async getValid<T>(key: string): Promise<CacheEntry<T> | null> {\n const entry = await this.storage.get<T>(key);\n if (!entry) return null;\n\n if (Date.now() > entry.expiry) {\n await this.storage.delete(key);\n return null;\n }\n return entry;\n }\n\n /** Probabilistically trigger cleanup of expired entries (fire-and-forget) */\n private maybeCleanup(): void {\n if (this.cleanupInProgress) return;\n if (!this.storage.isPersistent()) return;\n const now = Date.now();\n if (now - this.lastCleanupAttempt < CacheManager.MIN_CLEANUP_INTERVAL_MS)\n return;\n\n const probability = this.config.cleanupProbability ?? 0.01;\n\n if (Math.random() > probability) return;\n\n this.lastCleanupAttempt = now;\n\n this.cleanupInProgress = true;\n (this.storage as PersistentStorage)\n .cleanupExpired()\n .catch((error) => {\n logger.debug(\"Error cleaning up expired entries: %O\", error);\n })\n .finally(() => {\n this.cleanupInProgress = false;\n });\n }\n\n /**\n * Set a value in the cache\n * @param key - Cache key\n * @param value - Value to set\n * @param options - Options for the cache\n * @returns Promise of the result\n */\n async set<T>(\n key: string,\n value: T,\n options?: { ttl?: number },\n ): Promise<void> {\n if (!this.config.enabled) return;\n\n const ttl = options?.ttl ?? this.config.ttl ?? 3600;\n const expiryTime = Date.now() + ttl * 1000;\n await this.storage.set(key, { value, expiry: expiryTime });\n }\n\n /**\n * Delete a value from the cache\n * @param key - Cache key\n * @returns Promise of the result\n */\n async delete(key: string): Promise<void> {\n if (!this.config.enabled) return;\n await this.storage.delete(key);\n }\n\n /** Clear the cache */\n async clear(): Promise<void> {\n await this.storage.clear();\n this.inFlightRequests.clear();\n }\n\n /**\n * Check if a value exists in the cache\n * @param key - Cache key\n * @returns Promise of true if the value exists, false otherwise\n */\n async has(key: string): Promise<boolean> {\n if (!this.config.enabled) return false;\n\n const entry = await this.getValid(key);\n return entry !== null;\n }\n\n /**\n * Generate a cache key\n * @param parts - Parts of the key\n * @param userKey - User key\n * @returns Cache key\n */\n generateKey(parts: (string | number | object)[], userKey: string): string {\n const allParts = [userKey, ...parts];\n const serialized = JSON.stringify(allParts);\n return createHash(\"sha256\").update(serialized).digest(\"hex\");\n }\n\n /** Close the cache */\n async close(): Promise<void> {\n await this.storage.close();\n }\n\n /**\n * Check if the storage is healthy\n * @returns Promise of true if the storage is healthy, false otherwise\n */\n async isStorageHealthy(): Promise<boolean> {\n return this.storage.healthCheck();\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/cache/index.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport { ApiError, WorkspaceClient } from \"@databricks/sdk-experimental\";\nimport type { CacheConfig, CacheEntry, CacheStorage } from \"shared\";\nimport { createLakebasePool } from \"../connectors/lakebase\";\nimport { AppKitError, ExecutionError, InitializationError } from \"../errors\";\nimport { createLogger } from \"../logging/logger\";\nimport type { Counter, TelemetryProvider } from \"../telemetry\";\nimport { SpanStatusCode, TelemetryManager } from \"../telemetry\";\nimport { deepMerge } from \"../utils\";\nimport { cacheDefaults } from \"./defaults\";\nimport { InMemoryStorage, PersistentStorage } from \"./storage\";\n\nconst logger = createLogger(\"cache\");\n\n/**\n * Reference-counted in-flight cache execution entry.\n *\n * `sharedController` decouples the cached `fn()` from any single caller's\n * abort signal. Callers join an in-flight entry by incrementing `refCount`;\n * when a caller aborts, refCount is decremented. The shared controller is\n * aborted only when refCount drops to 0 — i.e. all callers have abandoned\n * the request. This prevents one caller's cancellation (e.g. React\n * StrictMode unmount) from poisoning the in-flight result for other still-\n * connected awaiters.\n */\ninterface InFlightEntry<T> {\n promise: Promise<T>;\n refCount: number;\n sharedController: AbortController;\n abortTimer?: ReturnType<typeof setTimeout>;\n}\n\nfunction createAbortError(signal: AbortSignal): unknown {\n if (signal.reason !== undefined) return signal.reason;\n return new DOMException(\"The operation was aborted.\", \"AbortError\");\n}\n\n/**\n * Cache manager class to handle cache operations.\n * Can be used with in-memory storage or persistent storage (Lakebase).\n *\n * The cache is automatically initialized by AppKit. Use `getInstanceSync()` to access\n * the singleton instance after initialization.\n *\n * @internal\n * @example\n * ```typescript\n * const cache = CacheManager.getInstanceSync();\n * const result = await cache.getOrExecute([\"users\", userId], () => fetchUser(userId), userKey);\n * ```\n */\nexport class CacheManager {\n private static readonly MIN_CLEANUP_INTERVAL_MS = 60_000;\n private static readonly ABORT_GRACE_PERIOD_MS = 100;\n private readonly name: string = \"cache-manager\";\n private static instance: CacheManager | null = null;\n private static initPromise: Promise<CacheManager> | null = null;\n\n private storage: CacheStorage;\n private config: CacheConfig;\n private inFlightRequests: Map<string, InFlightEntry<unknown>>;\n private cleanupInProgress: boolean;\n private lastCleanupAttempt: number;\n\n private telemetry: TelemetryProvider;\n private telemetryMetrics: {\n cacheHitCount: Counter;\n cacheMissCount: Counter;\n };\n\n private constructor(storage: CacheStorage, config: CacheConfig) {\n this.storage = storage;\n this.config = config;\n this.inFlightRequests = new Map();\n this.cleanupInProgress = false;\n this.lastCleanupAttempt = 0;\n\n this.telemetry = TelemetryManager.getProvider(\n this.name,\n this.config.telemetry,\n );\n this.telemetryMetrics = {\n cacheHitCount: this.telemetry.getMeter().createCounter(\"cache.hit\", {\n description: \"Total number of cache hits\",\n unit: \"1\",\n }),\n cacheMissCount: this.telemetry.getMeter().createCounter(\"cache.miss\", {\n description: \"Total number of cache misses\",\n unit: \"1\",\n }),\n };\n }\n\n /**\n * Get the singleton instance of the cache manager (sync version).\n *\n * Throws if not initialized - ensure AppKit.create() has completed first.\n * @returns CacheManager instance\n */\n static getInstanceSync(): CacheManager {\n if (!CacheManager.instance) {\n throw InitializationError.notInitialized(\n \"CacheManager\",\n \"Ensure AppKit.create() has completed before accessing the cache\",\n );\n }\n\n return CacheManager.instance;\n }\n\n /**\n * Initialize and get the singleton instance of the cache manager.\n * Called internally by AppKit - prefer `getInstanceSync()` for plugin access.\n * @param userConfig - User configuration for the cache manager\n * @returns CacheManager instance\n * @internal\n */\n static async getInstance(\n userConfig?: Partial<CacheConfig>,\n ): Promise<CacheManager> {\n if (CacheManager.instance) {\n return CacheManager.instance;\n }\n\n if (!CacheManager.initPromise) {\n CacheManager.initPromise = CacheManager.create(userConfig).then(\n (instance) => {\n CacheManager.instance = instance;\n return instance;\n },\n );\n }\n\n return CacheManager.initPromise;\n }\n\n /**\n * Create a new cache manager instance\n *\n * Storage selection logic:\n * 1. If `storage` provided and healthy → use provided storage\n * 2. If `storage` provided but unhealthy → fallback to InMemory (or disable if strictPersistence)\n * 3. If no `storage` provided and Lakebase available → use Lakebase\n * 4. If no `storage` provided and Lakebase unavailable → fallback to InMemory (or disable if strictPersistence)\n *\n * @param userConfig - User configuration for the cache manager\n * @returns CacheManager instance\n */\n private static async create(\n userConfig?: Partial<CacheConfig>,\n ): Promise<CacheManager> {\n const config = deepMerge(cacheDefaults, userConfig);\n\n if (config.storage) {\n const isHealthy = await config.storage.healthCheck();\n if (isHealthy) {\n return new CacheManager(config.storage, config);\n }\n\n if (config.strictPersistence) {\n const disabledConfig = { ...config, enabled: false };\n return new CacheManager(\n new InMemoryStorage(disabledConfig),\n disabledConfig,\n );\n }\n\n return new CacheManager(new InMemoryStorage(config), config);\n }\n\n // try to use lakebase storage\n try {\n const workspaceClient = new WorkspaceClient({});\n const pool = createLakebasePool({ workspaceClient });\n const persistentStorage = new PersistentStorage(config, pool);\n\n const isHealthy = await persistentStorage.healthCheck();\n if (isHealthy) {\n await persistentStorage.initialize();\n return new CacheManager(persistentStorage, config);\n }\n\n // Health check failed, close the pool and fallback\n await pool.end();\n } catch {\n // lakebase unavailable, continue with in-memory storage\n }\n\n if (config.strictPersistence) {\n const disabledConfig = { ...config, enabled: false };\n return new CacheManager(\n new InMemoryStorage(disabledConfig),\n disabledConfig,\n );\n }\n\n return new CacheManager(new InMemoryStorage(config), config);\n }\n\n /**\n * Get or execute a function and cache the result.\n *\n * Multiple concurrent callers with the same `cacheKey` are deduplicated\n * onto a single in-flight execution. Each caller may pass its own\n * `callerSignal`; the underlying `fn()` is run with a shared, internally\n * managed `AbortSignal` that aborts only when *all* callers have\n * abandoned the request (reference counted). This decouples a single\n * caller's cancellation (e.g. React StrictMode unmount) from the shared\n * result, so other still-connected callers receive the cached value\n * normally.\n *\n * @param key - Cache key parts\n * @param fn - Function to execute. Receives the cache-owned shared signal;\n * pass it through to the underlying I/O so the work is cancelled when\n * no caller is left waiting.\n * @param userKey - User key for cache namespacing\n * @param options - Options for the cache\n * @returns Promise of the result\n */\n async getOrExecute<T>(\n key: (string | number | object)[],\n fn: (sharedSignal?: AbortSignal) => Promise<T>,\n userKey: string,\n options?: { ttl?: number; callerSignal?: AbortSignal },\n ): Promise<T> {\n if (!this.config.enabled) return fn(options?.callerSignal);\n\n const callerSignal = options?.callerSignal;\n if (callerSignal?.aborted) {\n throw createAbortError(callerSignal);\n }\n\n const cacheKey = this.generateKey(key, userKey);\n\n return this.telemetry.startActiveSpan(\n \"cache.getOrExecute\",\n {\n attributes: {\n \"cache.key\": cacheKey,\n \"cache.enabled\": this.config.enabled,\n \"cache.persistent\": this.storage.isPersistent(),\n },\n },\n async (span) => {\n try {\n const cached = await this.getValid<T>(cacheKey);\n if (cached !== null) {\n span.setAttribute(\"cache.hit\", true);\n span.setStatus({ code: SpanStatusCode.OK });\n this.telemetryMetrics.cacheHitCount.add(1, {\n \"cache.key\": cacheKey,\n });\n\n logger.event()?.setExecution({\n cache_hit: true,\n cache_key: cacheKey,\n });\n\n return cached.value;\n }\n\n // check if the value is being processed by another request — join\n // the existing in-flight entry under reference counting so this\n // caller's abort doesn't poison the shared result.\n const existing = this.inFlightRequests.get(cacheKey) as\n | InFlightEntry<T>\n | undefined;\n if (existing && !existing.sharedController.signal.aborted) {\n existing.refCount++;\n // Cancel any pending abort timer — a new caller has joined\n if (existing.abortTimer) {\n clearTimeout(existing.abortTimer);\n existing.abortTimer = undefined;\n }\n span.setAttribute(\"cache.hit\", true);\n span.setAttribute(\"cache.deduplication\", true);\n span.addEvent(\"cache.deduplication_used\", {\n \"cache.key\": cacheKey,\n });\n this.telemetryMetrics.cacheHitCount.add(1, {\n \"cache.key\": cacheKey,\n \"cache.deduplication\": \"true\",\n });\n\n logger.event()?.setExecution({\n cache_hit: true,\n cache_key: cacheKey,\n cache_deduplication: true,\n });\n\n return await this._waitWithRefCount(existing, callerSignal);\n }\n\n // cache miss - execute function under a shared abort controller\n span.setAttribute(\"cache.hit\", false);\n span.addEvent(\"cache.miss\", { \"cache.key\": cacheKey });\n this.telemetryMetrics.cacheMissCount.add(1, {\n \"cache.key\": cacheKey,\n });\n\n logger.event()?.setExecution({\n cache_hit: false,\n cache_key: cacheKey,\n });\n\n const sharedController = new AbortController();\n const entry: InFlightEntry<T> = {\n promise: undefined as unknown as Promise<T>,\n refCount: 1,\n sharedController,\n };\n\n entry.promise = fn(sharedController.signal)\n .then(async (result) => {\n await this.set(cacheKey, result, options);\n span.addEvent(\"cache.value_stored\", {\n \"cache.key\": cacheKey,\n \"cache.ttl\": options?.ttl ?? this.config.ttl ?? 3600,\n });\n return result;\n })\n .catch((error) => {\n span.recordException(error);\n span.setStatus({ code: SpanStatusCode.ERROR });\n // If the shared controller aborted, all callers have already\n // abandoned the request (or are about to via their own signals)\n // — propagate the original error without wrapping. No live\n // awaiter will observe this rejection.\n if (sharedController.signal.aborted) {\n throw error;\n }\n if (error instanceof AppKitError || error instanceof ApiError) {\n throw error;\n }\n throw ExecutionError.statementFailed(\n error instanceof Error ? error.message : String(error),\n );\n })\n .finally(() => {\n if (this.inFlightRequests.get(cacheKey) === entry) {\n this.inFlightRequests.delete(cacheKey);\n }\n });\n\n // Suppress unhandled rejection warnings when every caller bailed\n // before fn() resolved (their own promises rejected via\n // waitWithRefCount; the underlying entry.promise has no awaiter).\n entry.promise.catch(() => {});\n\n this.inFlightRequests.set(cacheKey, entry as InFlightEntry<unknown>);\n\n const result = await this._waitWithRefCount(entry, callerSignal);\n span.setStatus({ code: SpanStatusCode.OK });\n return result;\n } catch (error) {\n span.recordException(error as Error);\n span.setStatus({ code: SpanStatusCode.ERROR });\n throw error;\n } finally {\n span.end();\n }\n },\n { name: this.name, includePrefix: true },\n );\n }\n\n /**\n * Wait on an in-flight entry, racing the underlying promise against the\n * caller's abort signal. When the caller aborts, the entry's refCount is\n * decremented; if it hits zero the shared controller is aborted so the\n * underlying `fn()` can stop. Other callers continue to await the same\n * entry and receive the result when it arrives.\n */\n private _waitWithRefCount<T>(\n entry: InFlightEntry<T>,\n callerSignal?: AbortSignal,\n ): Promise<T> {\n if (!callerSignal) return entry.promise;\n\n return new Promise<T>((resolve, reject) => {\n let settled = false;\n\n const release = () => {\n if (entry.refCount > 0) entry.refCount--;\n if (entry.refCount <= 0 && !entry.sharedController.signal.aborted) {\n // Grace period: delay abort so a StrictMode remount can join\n // the in-flight entry before the shared execution is cancelled.\n entry.abortTimer = setTimeout(() => {\n if (entry.refCount <= 0 && !entry.sharedController.signal.aborted) {\n entry.sharedController.abort(\n callerSignal.reason ?? \"all cache callers aborted\",\n );\n }\n }, CacheManager.ABORT_GRACE_PERIOD_MS);\n }\n };\n\n const onAbort = () => {\n if (settled) return;\n settled = true;\n callerSignal.removeEventListener(\"abort\", onAbort);\n release();\n reject(createAbortError(callerSignal));\n };\n\n if (callerSignal.aborted) {\n onAbort();\n return;\n }\n\n callerSignal.addEventListener(\"abort\", onAbort, { once: true });\n\n entry.promise.then(\n (value) => {\n if (settled) return;\n settled = true;\n callerSignal.removeEventListener(\"abort\", onAbort);\n resolve(value);\n },\n (error) => {\n if (settled) return;\n settled = true;\n callerSignal.removeEventListener(\"abort\", onAbort);\n reject(error);\n },\n );\n });\n }\n\n /**\n * Get a cached value\n * @param key - Cache key\n * @returns Promise of the value or null if not found or expired\n */\n async get<T>(key: string): Promise<T | null> {\n if (!this.config.enabled) return null;\n\n // probabilistic cleanup trigger\n this.maybeCleanup();\n\n const entry = await this.getValid<T>(key);\n return entry?.value ?? null;\n }\n\n /**\n * Get a cached entry only if it has not expired.\n * Returns null on miss or expired (and deletes the expired entry).\n *\n * Storage implementations return entries unconditionally — expiry handling\n * lives at the CacheManager layer.\n */\n private async getValid<T>(key: string): Promise<CacheEntry<T> | null> {\n const entry = await this.storage.get<T>(key);\n if (!entry) return null;\n\n if (Date.now() > entry.expiry) {\n await this.storage.delete(key);\n return null;\n }\n return entry;\n }\n\n /** Probabilistically trigger cleanup of expired entries (fire-and-forget) */\n private maybeCleanup(): void {\n if (this.cleanupInProgress) return;\n if (!this.storage.isPersistent()) return;\n const now = Date.now();\n if (now - this.lastCleanupAttempt < CacheManager.MIN_CLEANUP_INTERVAL_MS)\n return;\n\n const probability = this.config.cleanupProbability ?? 0.01;\n\n if (Math.random() > probability) return;\n\n this.lastCleanupAttempt = now;\n\n this.cleanupInProgress = true;\n (this.storage as PersistentStorage)\n .cleanupExpired()\n .catch((error) => {\n logger.debug(\"Error cleaning up expired entries: %O\", error);\n })\n .finally(() => {\n this.cleanupInProgress = false;\n });\n }\n\n /**\n * Set a value in the cache\n * @param key - Cache key\n * @param value - Value to set\n * @param options - Options for the cache\n * @returns Promise of the result\n */\n async set<T>(\n key: string,\n value: T,\n options?: { ttl?: number },\n ): Promise<void> {\n if (!this.config.enabled) return;\n\n const ttl = options?.ttl ?? this.config.ttl ?? 3600;\n const expiryTime = Date.now() + ttl * 1000;\n await this.storage.set(key, { value, expiry: expiryTime });\n }\n\n /**\n * Delete a value from the cache\n * @param key - Cache key\n * @returns Promise of the result\n */\n async delete(key: string): Promise<void> {\n if (!this.config.enabled) return;\n await this.storage.delete(key);\n }\n\n /** Clear the cache */\n async clear(): Promise<void> {\n await this.storage.clear();\n for (const entry of this.inFlightRequests.values()) {\n if (entry.abortTimer) clearTimeout(entry.abortTimer);\n }\n this.inFlightRequests.clear();\n }\n\n /**\n * Check if a value exists in the cache\n * @param key - Cache key\n * @returns Promise of true if the value exists, false otherwise\n */\n async has(key: string): Promise<boolean> {\n if (!this.config.enabled) return false;\n\n const entry = await this.getValid(key);\n return entry !== null;\n }\n\n /**\n * Generate a cache key\n * @param parts - Parts of the key\n * @param userKey - User key\n * @returns Cache key\n */\n generateKey(parts: (string | number | object)[], userKey: string): string {\n const allParts = [userKey, ...parts];\n const serialized = JSON.stringify(allParts);\n return createHash(\"sha256\").update(serialized).digest(\"hex\");\n }\n\n /** Close the cache */\n async close(): Promise<void> {\n await this.storage.close();\n }\n\n /**\n * Check if the storage is healthy\n * @returns Promise of true if the storage is healthy, false otherwise\n */\n async isStorageHealthy(): Promise<boolean> {\n return this.storage.healthCheck();\n }\n}\n"],"mappings":"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"}
@@ -1,12 +1,11 @@
1
1
  import { createLogger } from "../../logging/logger.js";
2
2
  import { InitializationError } from "../../errors/initialization.js";
3
3
  import { ValidationError } from "../../errors/validation.js";
4
- import { init_errors } from "../../errors/index.js";
4
+ import "../../errors/index.js";
5
5
  import { lakebaseStorageDefaults } from "./defaults.js";
6
6
  import { createHash } from "node:crypto";
7
7
 
8
8
  //#region src/cache/storage/persistent.ts
9
- init_errors();
10
9
  const logger = createLogger("cache:persistent");
11
10
  /**
12
11
  * Persistent cache storage implementation. Uses a least recently used (LRU) eviction policy
@@ -1 +1 @@
1
- {"version":3,"file":"persistent.js","names":[],"sources":["../../../src/cache/storage/persistent.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type pg from \"pg\";\nimport type { CacheConfig, CacheEntry, CacheStorage } from \"shared\";\nimport { InitializationError, ValidationError } from \"../../errors\";\nimport { createLogger } from \"../../logging/logger\";\nimport { lakebaseStorageDefaults } from \"./defaults\";\n\nconst logger = createLogger(\"cache:persistent\");\n\n/**\n * Persistent cache storage implementation. Uses a least recently used (LRU) eviction policy\n * to manage memory usage and ensure efficient cache operations.\n *\n * @example\n * const pool = createLakebasePool({ workspaceClient });\n * const persistentStorage = new PersistentStorage(config, pool);\n * await persistentStorage.initialize();\n * await persistentStorage.get(\"my-key\");\n * await persistentStorage.set(\"my-key\", \"my-value\");\n * await persistentStorage.delete(\"my-key\");\n * await persistentStorage.clear();\n * await persistentStorage.has(\"my-key\");\n *\n */\nexport class PersistentStorage implements CacheStorage {\n private readonly pool: pg.Pool;\n private readonly schemaName: string;\n private readonly tableName: string;\n private readonly qualifiedTableName: string;\n private readonly maxBytes: number;\n private readonly maxEntryBytes: number;\n private readonly evictionBatchSize: number;\n private readonly evictionCheckProbability: number;\n private initialized: boolean;\n\n constructor(config: CacheConfig, pool: pg.Pool) {\n this.pool = pool;\n this.maxBytes = config.maxBytes ?? lakebaseStorageDefaults.maxBytes;\n this.maxEntryBytes =\n config.maxEntryBytes ?? lakebaseStorageDefaults.maxEntryBytes;\n this.evictionBatchSize = lakebaseStorageDefaults.evictionBatchSize;\n this.evictionCheckProbability =\n config.evictionCheckProbability ??\n lakebaseStorageDefaults.evictionCheckProbability;\n this.schemaName = lakebaseStorageDefaults.schemaName;\n this.tableName = lakebaseStorageDefaults.tableName;\n this.qualifiedTableName = `${this.schemaName}.${this.tableName}`;\n this.initialized = false;\n }\n\n /** Initialize the persistent storage and run migrations if necessary */\n async initialize(): Promise<void> {\n if (this.initialized) return;\n\n try {\n await this.runMigrations();\n this.initialized = true;\n } catch (error) {\n logger.error(\"Error in persistent storage initialization: %O\", error);\n throw error;\n }\n }\n\n /**\n * Get a cached value from the persistent storage\n * @param key - Cache key\n * @returns Promise of the cached value or null if not found\n */\n async get<T>(key: string): Promise<CacheEntry<T> | null> {\n await this.ensureInitialized();\n\n const keyHash = this.hashKey(key);\n\n const result = await this.pool.query<{\n value: Buffer;\n expiry: string;\n }>(\n `SELECT value, expiry FROM ${this.qualifiedTableName} WHERE key_hash = $1`,\n [keyHash],\n );\n\n if (result.rows.length === 0) return null;\n\n const entry = result.rows[0];\n\n // fire-and-forget update\n this.pool\n .query(\n `UPDATE ${this.qualifiedTableName} SET last_accessed = NOW() WHERE key_hash = $1`,\n [keyHash],\n )\n .catch(() => {\n logger.debug(\"Error updating last_accessed time for key: %s\", key);\n });\n\n return {\n value: this.deserializeValue<T>(entry.value),\n expiry: Number(entry.expiry),\n };\n }\n\n /**\n * Set a value in the persistent storage\n * @param key - Cache key\n * @param entry - Cache entry\n * @returns Promise of the result\n */\n async set<T>(key: string, entry: CacheEntry<T>): Promise<void> {\n await this.ensureInitialized();\n\n const keyHash = this.hashKey(key);\n const keyBytes = Buffer.from(key, \"utf-8\");\n const valueBytes = this.serializeValue(entry.value);\n const byteSize = keyBytes.length + valueBytes.length;\n\n if (byteSize > this.maxEntryBytes) {\n throw ValidationError.invalidValue(\n \"cache entry size\",\n byteSize,\n `maximum ${this.maxEntryBytes} bytes`,\n );\n }\n\n // probabilistic eviction check\n if (Math.random() < this.evictionCheckProbability) {\n const totalBytes = await this.totalBytes();\n if (totalBytes + byteSize > this.maxBytes) {\n await this.evictBySize(byteSize);\n }\n }\n\n await this.pool.query(\n `INSERT INTO ${this.qualifiedTableName} (key_hash, key, value, byte_size, expiry, created_at, last_accessed)\n VALUES ($1, $2, $3, $4, $5, NOW(), NOW())\n ON CONFLICT (key_hash)\n DO UPDATE SET value = $3, byte_size = $4, expiry = $5, last_accessed = NOW()\n `,\n [keyHash, keyBytes, valueBytes, byteSize, entry.expiry],\n );\n }\n\n /**\n * Delete a value from the persistent storage\n * @param key - Cache key\n * @returns Promise of the result\n */\n async delete(key: string): Promise<void> {\n await this.ensureInitialized();\n const keyHash = this.hashKey(key);\n await this.pool.query(\n `DELETE FROM ${this.qualifiedTableName} WHERE key_hash = $1`,\n [keyHash],\n );\n }\n\n /** Clear the persistent storage */\n async clear(): Promise<void> {\n await this.ensureInitialized();\n await this.pool.query(`TRUNCATE TABLE ${this.qualifiedTableName}`);\n }\n\n /**\n * Check if a value exists in the persistent storage\n * @param key - Cache key\n * @returns Promise of true if the value exists, false otherwise\n */\n async has(key: string): Promise<boolean> {\n await this.ensureInitialized();\n const keyHash = this.hashKey(key);\n\n const result = await this.pool.query<{ exists: boolean }>(\n `SELECT EXISTS(SELECT 1 FROM ${this.qualifiedTableName} WHERE key_hash = $1) as exists`,\n [keyHash],\n );\n\n return result.rows[0]?.exists ?? false;\n }\n\n /**\n * Get the size of the persistent storage\n * @returns Promise of the size of the storage\n */\n async size(): Promise<number> {\n await this.ensureInitialized();\n\n const result = await this.pool.query<{ count: string }>(\n `SELECT COUNT(*) as count FROM ${this.qualifiedTableName}`,\n );\n return parseInt(result.rows[0]?.count ?? \"0\", 10);\n }\n\n /** Get the total number of bytes in the persistent storage */\n async totalBytes(): Promise<number> {\n await this.ensureInitialized();\n\n const result = await this.pool.query<{ total: string }>(\n `SELECT COALESCE(SUM(byte_size), 0) as total FROM ${this.qualifiedTableName}`,\n );\n return parseInt(result.rows[0]?.total ?? \"0\", 10);\n }\n\n /**\n * Check if the persistent storage is persistent\n * @returns true if the storage is persistent, false otherwise\n */\n isPersistent(): boolean {\n return true;\n }\n\n /**\n * Check if the persistent storage is healthy\n * @returns Promise of true if the storage is healthy, false otherwise\n */\n async healthCheck(): Promise<boolean> {\n try {\n await this.pool.query(\"SELECT 1\");\n return true;\n } catch {\n return false;\n }\n }\n\n /** Close the persistent storage */\n async close(): Promise<void> {\n await this.pool.end();\n }\n\n /**\n * Cleanup expired entries from the persistent storage\n * @returns Promise of the number of expired entries\n */\n async cleanupExpired(): Promise<number> {\n await this.ensureInitialized();\n const result = await this.pool.query<{ count: string }>(\n `WITH deleted as (DELETE FROM ${this.qualifiedTableName} WHERE expiry < $1 RETURNING *) SELECT COUNT(*) as count FROM deleted`,\n [Date.now()],\n );\n return parseInt(result.rows[0]?.count ?? \"0\", 10);\n }\n\n /** Evict entries from the persistent storage by size */\n private async evictBySize(requiredBytes: number): Promise<void> {\n const freedByExpiry = await this.cleanupExpired();\n if (freedByExpiry > 0) {\n const currentBytes = await this.totalBytes();\n if (currentBytes + requiredBytes <= this.maxBytes) {\n return;\n }\n }\n\n await this.pool.query(\n `DELETE FROM ${this.qualifiedTableName} WHERE key_hash IN\n (SELECT key_hash FROM ${this.qualifiedTableName} ORDER BY last_accessed ASC LIMIT $1)`,\n [this.evictionBatchSize],\n );\n }\n\n /** Ensure the persistent storage is initialized */\n private async ensureInitialized(): Promise<void> {\n if (!this.initialized) {\n await this.initialize();\n }\n }\n\n /** Generate a 64-bit hash for the cache key using SHA256 */\n private hashKey(key: string): bigint {\n if (!key) throw ValidationError.missingField(\"key\");\n const hash = createHash(\"sha256\").update(key).digest();\n return hash.readBigInt64BE(0);\n }\n\n /** Serialize a value to a buffer */\n private serializeValue<T>(value: T): Buffer {\n return Buffer.from(JSON.stringify(value), \"utf-8\");\n }\n\n /** Deserialize a value from a buffer */\n private deserializeValue<T>(buffer: Buffer): T {\n return JSON.parse(buffer.toString(\"utf-8\")) as T;\n }\n\n /** Run migrations for the persistent storage */\n private async runMigrations(): Promise<void> {\n const steps = [\n {\n name: \"create schema\",\n query: `CREATE SCHEMA IF NOT EXISTS ${this.schemaName}`,\n },\n {\n name: \"create table\",\n query: `CREATE TABLE IF NOT EXISTS ${this.qualifiedTableName} (\n id BIGSERIAL PRIMARY KEY,\n key_hash BIGINT NOT NULL,\n key BYTEA NOT NULL,\n value BYTEA NOT NULL,\n byte_size INTEGER NOT NULL,\n expiry BIGINT NOT NULL,\n created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n last_accessed TIMESTAMP NOT NULL DEFAULT NOW()\n )`,\n },\n {\n name: \"create index (key_hash)\",\n query: `CREATE UNIQUE INDEX IF NOT EXISTS idx_${this.tableName}_key_hash ON ${this.qualifiedTableName} (key_hash)`,\n },\n {\n name: \"create index (expiry)\",\n query: `CREATE INDEX IF NOT EXISTS idx_${this.tableName}_expiry ON ${this.qualifiedTableName} (expiry)`,\n },\n {\n name: \"create index (last_accessed)\",\n query: `CREATE INDEX IF NOT EXISTS idx_${this.tableName}_last_accessed ON ${this.qualifiedTableName} (last_accessed)`,\n },\n {\n name: \"create index (byte_size)\",\n query: `CREATE INDEX IF NOT EXISTS idx_${this.tableName}_byte_size ON ${this.qualifiedTableName} (byte_size)`,\n },\n ];\n\n for (const step of steps) {\n try {\n await this.pool.query(step.query);\n } catch (error) {\n logger.error(\"Migration step '%s' failed: %O\", step.name, error);\n throw InitializationError.migrationFailed(error as Error);\n }\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"persistent.js","names":[],"sources":["../../../src/cache/storage/persistent.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type pg from \"pg\";\nimport type { CacheConfig, CacheEntry, CacheStorage } from \"shared\";\nimport { InitializationError, ValidationError } from \"../../errors\";\nimport { createLogger } from \"../../logging/logger\";\nimport { lakebaseStorageDefaults } from \"./defaults\";\n\nconst logger = createLogger(\"cache:persistent\");\n\n/**\n * Persistent cache storage implementation. Uses a least recently used (LRU) eviction policy\n * to manage memory usage and ensure efficient cache operations.\n *\n * @example\n * const pool = createLakebasePool({ workspaceClient });\n * const persistentStorage = new PersistentStorage(config, pool);\n * await persistentStorage.initialize();\n * await persistentStorage.get(\"my-key\");\n * await persistentStorage.set(\"my-key\", \"my-value\");\n * await persistentStorage.delete(\"my-key\");\n * await persistentStorage.clear();\n * await persistentStorage.has(\"my-key\");\n *\n */\nexport class PersistentStorage implements CacheStorage {\n private readonly pool: pg.Pool;\n private readonly schemaName: string;\n private readonly tableName: string;\n private readonly qualifiedTableName: string;\n private readonly maxBytes: number;\n private readonly maxEntryBytes: number;\n private readonly evictionBatchSize: number;\n private readonly evictionCheckProbability: number;\n private initialized: boolean;\n\n constructor(config: CacheConfig, pool: pg.Pool) {\n this.pool = pool;\n this.maxBytes = config.maxBytes ?? lakebaseStorageDefaults.maxBytes;\n this.maxEntryBytes =\n config.maxEntryBytes ?? lakebaseStorageDefaults.maxEntryBytes;\n this.evictionBatchSize = lakebaseStorageDefaults.evictionBatchSize;\n this.evictionCheckProbability =\n config.evictionCheckProbability ??\n lakebaseStorageDefaults.evictionCheckProbability;\n this.schemaName = lakebaseStorageDefaults.schemaName;\n this.tableName = lakebaseStorageDefaults.tableName;\n this.qualifiedTableName = `${this.schemaName}.${this.tableName}`;\n this.initialized = false;\n }\n\n /** Initialize the persistent storage and run migrations if necessary */\n async initialize(): Promise<void> {\n if (this.initialized) return;\n\n try {\n await this.runMigrations();\n this.initialized = true;\n } catch (error) {\n logger.error(\"Error in persistent storage initialization: %O\", error);\n throw error;\n }\n }\n\n /**\n * Get a cached value from the persistent storage\n * @param key - Cache key\n * @returns Promise of the cached value or null if not found\n */\n async get<T>(key: string): Promise<CacheEntry<T> | null> {\n await this.ensureInitialized();\n\n const keyHash = this.hashKey(key);\n\n const result = await this.pool.query<{\n value: Buffer;\n expiry: string;\n }>(\n `SELECT value, expiry FROM ${this.qualifiedTableName} WHERE key_hash = $1`,\n [keyHash],\n );\n\n if (result.rows.length === 0) return null;\n\n const entry = result.rows[0];\n\n // fire-and-forget update\n this.pool\n .query(\n `UPDATE ${this.qualifiedTableName} SET last_accessed = NOW() WHERE key_hash = $1`,\n [keyHash],\n )\n .catch(() => {\n logger.debug(\"Error updating last_accessed time for key: %s\", key);\n });\n\n return {\n value: this.deserializeValue<T>(entry.value),\n expiry: Number(entry.expiry),\n };\n }\n\n /**\n * Set a value in the persistent storage\n * @param key - Cache key\n * @param entry - Cache entry\n * @returns Promise of the result\n */\n async set<T>(key: string, entry: CacheEntry<T>): Promise<void> {\n await this.ensureInitialized();\n\n const keyHash = this.hashKey(key);\n const keyBytes = Buffer.from(key, \"utf-8\");\n const valueBytes = this.serializeValue(entry.value);\n const byteSize = keyBytes.length + valueBytes.length;\n\n if (byteSize > this.maxEntryBytes) {\n throw ValidationError.invalidValue(\n \"cache entry size\",\n byteSize,\n `maximum ${this.maxEntryBytes} bytes`,\n );\n }\n\n // probabilistic eviction check\n if (Math.random() < this.evictionCheckProbability) {\n const totalBytes = await this.totalBytes();\n if (totalBytes + byteSize > this.maxBytes) {\n await this.evictBySize(byteSize);\n }\n }\n\n await this.pool.query(\n `INSERT INTO ${this.qualifiedTableName} (key_hash, key, value, byte_size, expiry, created_at, last_accessed)\n VALUES ($1, $2, $3, $4, $5, NOW(), NOW())\n ON CONFLICT (key_hash)\n DO UPDATE SET value = $3, byte_size = $4, expiry = $5, last_accessed = NOW()\n `,\n [keyHash, keyBytes, valueBytes, byteSize, entry.expiry],\n );\n }\n\n /**\n * Delete a value from the persistent storage\n * @param key - Cache key\n * @returns Promise of the result\n */\n async delete(key: string): Promise<void> {\n await this.ensureInitialized();\n const keyHash = this.hashKey(key);\n await this.pool.query(\n `DELETE FROM ${this.qualifiedTableName} WHERE key_hash = $1`,\n [keyHash],\n );\n }\n\n /** Clear the persistent storage */\n async clear(): Promise<void> {\n await this.ensureInitialized();\n await this.pool.query(`TRUNCATE TABLE ${this.qualifiedTableName}`);\n }\n\n /**\n * Check if a value exists in the persistent storage\n * @param key - Cache key\n * @returns Promise of true if the value exists, false otherwise\n */\n async has(key: string): Promise<boolean> {\n await this.ensureInitialized();\n const keyHash = this.hashKey(key);\n\n const result = await this.pool.query<{ exists: boolean }>(\n `SELECT EXISTS(SELECT 1 FROM ${this.qualifiedTableName} WHERE key_hash = $1) as exists`,\n [keyHash],\n );\n\n return result.rows[0]?.exists ?? false;\n }\n\n /**\n * Get the size of the persistent storage\n * @returns Promise of the size of the storage\n */\n async size(): Promise<number> {\n await this.ensureInitialized();\n\n const result = await this.pool.query<{ count: string }>(\n `SELECT COUNT(*) as count FROM ${this.qualifiedTableName}`,\n );\n return parseInt(result.rows[0]?.count ?? \"0\", 10);\n }\n\n /** Get the total number of bytes in the persistent storage */\n async totalBytes(): Promise<number> {\n await this.ensureInitialized();\n\n const result = await this.pool.query<{ total: string }>(\n `SELECT COALESCE(SUM(byte_size), 0) as total FROM ${this.qualifiedTableName}`,\n );\n return parseInt(result.rows[0]?.total ?? \"0\", 10);\n }\n\n /**\n * Check if the persistent storage is persistent\n * @returns true if the storage is persistent, false otherwise\n */\n isPersistent(): boolean {\n return true;\n }\n\n /**\n * Check if the persistent storage is healthy\n * @returns Promise of true if the storage is healthy, false otherwise\n */\n async healthCheck(): Promise<boolean> {\n try {\n await this.pool.query(\"SELECT 1\");\n return true;\n } catch {\n return false;\n }\n }\n\n /** Close the persistent storage */\n async close(): Promise<void> {\n await this.pool.end();\n }\n\n /**\n * Cleanup expired entries from the persistent storage\n * @returns Promise of the number of expired entries\n */\n async cleanupExpired(): Promise<number> {\n await this.ensureInitialized();\n const result = await this.pool.query<{ count: string }>(\n `WITH deleted as (DELETE FROM ${this.qualifiedTableName} WHERE expiry < $1 RETURNING *) SELECT COUNT(*) as count FROM deleted`,\n [Date.now()],\n );\n return parseInt(result.rows[0]?.count ?? \"0\", 10);\n }\n\n /** Evict entries from the persistent storage by size */\n private async evictBySize(requiredBytes: number): Promise<void> {\n const freedByExpiry = await this.cleanupExpired();\n if (freedByExpiry > 0) {\n const currentBytes = await this.totalBytes();\n if (currentBytes + requiredBytes <= this.maxBytes) {\n return;\n }\n }\n\n await this.pool.query(\n `DELETE FROM ${this.qualifiedTableName} WHERE key_hash IN\n (SELECT key_hash FROM ${this.qualifiedTableName} ORDER BY last_accessed ASC LIMIT $1)`,\n [this.evictionBatchSize],\n );\n }\n\n /** Ensure the persistent storage is initialized */\n private async ensureInitialized(): Promise<void> {\n if (!this.initialized) {\n await this.initialize();\n }\n }\n\n /** Generate a 64-bit hash for the cache key using SHA256 */\n private hashKey(key: string): bigint {\n if (!key) throw ValidationError.missingField(\"key\");\n const hash = createHash(\"sha256\").update(key).digest();\n return hash.readBigInt64BE(0);\n }\n\n /** Serialize a value to a buffer */\n private serializeValue<T>(value: T): Buffer {\n return Buffer.from(JSON.stringify(value), \"utf-8\");\n }\n\n /** Deserialize a value from a buffer */\n private deserializeValue<T>(buffer: Buffer): T {\n return JSON.parse(buffer.toString(\"utf-8\")) as T;\n }\n\n /** Run migrations for the persistent storage */\n private async runMigrations(): Promise<void> {\n const steps = [\n {\n name: \"create schema\",\n query: `CREATE SCHEMA IF NOT EXISTS ${this.schemaName}`,\n },\n {\n name: \"create table\",\n query: `CREATE TABLE IF NOT EXISTS ${this.qualifiedTableName} (\n id BIGSERIAL PRIMARY KEY,\n key_hash BIGINT NOT NULL,\n key BYTEA NOT NULL,\n value BYTEA NOT NULL,\n byte_size INTEGER NOT NULL,\n expiry BIGINT NOT NULL,\n created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n last_accessed TIMESTAMP NOT NULL DEFAULT NOW()\n )`,\n },\n {\n name: \"create index (key_hash)\",\n query: `CREATE UNIQUE INDEX IF NOT EXISTS idx_${this.tableName}_key_hash ON ${this.qualifiedTableName} (key_hash)`,\n },\n {\n name: \"create index (expiry)\",\n query: `CREATE INDEX IF NOT EXISTS idx_${this.tableName}_expiry ON ${this.qualifiedTableName} (expiry)`,\n },\n {\n name: \"create index (last_accessed)\",\n query: `CREATE INDEX IF NOT EXISTS idx_${this.tableName}_last_accessed ON ${this.qualifiedTableName} (last_accessed)`,\n },\n {\n name: \"create index (byte_size)\",\n query: `CREATE INDEX IF NOT EXISTS idx_${this.tableName}_byte_size ON ${this.qualifiedTableName} (byte_size)`,\n },\n ];\n\n for (const step of steps) {\n try {\n await this.pool.query(step.query);\n } catch (error) {\n logger.error(\"Migration step '%s' failed: %O\", step.name, error);\n throw InitializationError.migrationFailed(error as Error);\n }\n }\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"add-resource.js","names":[],"sources":["../../../../../src/cli/commands/plugin/add-resource/add-resource.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport process from \"node:process\";\nimport { cancel, intro, outro } from \"@clack/prompts\";\nimport { Command } from \"commander\";\nimport { promptOneResource } from \"../create/prompt-resource\";\nimport {\n DEFAULT_PERMISSION_BY_TYPE,\n getDefaultFieldsForType,\n getValidResourceTypes,\n humanizeResourceType,\n resourceKeyFromType,\n} from \"../create/resource-defaults\";\nimport { resolveManifestInDir } from \"../manifest-resolve\";\nimport type { PluginManifest, ResourceRequirement } from \"../manifest-types\";\nimport { validateManifest } from \"../validate/validate-manifest\";\n\n/** Extended manifest type that preserves extra JSON fields (e.g. $schema, author, version) for round-trip writes. */\ninterface ManifestWithExtras extends PluginManifest {\n [key: string]: unknown;\n}\n\ninterface AddResourceOptions {\n path?: string;\n type?: string;\n required?: boolean;\n resourceKey?: string;\n description?: string;\n permission?: string;\n fieldsJson?: string;\n dryRun?: boolean;\n}\n\nfunction loadManifest(\n pluginDir: string,\n): { manifest: ManifestWithExtras; manifestPath: string } | null {\n const resolved = resolveManifestInDir(pluginDir, { allowJsManifest: true });\n\n if (!resolved) {\n console.error(\n `No manifest found in ${pluginDir}. This command requires manifest.json (manifest.js cannot be edited in place).`,\n );\n console.error(\n \" appkit plugin add-resource --path <dir-with-manifest.json>\",\n );\n process.exit(1);\n }\n\n if (resolved.type !== \"json\") {\n console.error(\n `Editable manifest not found. add-resource only supports plugin directories that contain manifest.json (found ${path.basename(resolved.path)}).`,\n );\n process.exit(1);\n }\n\n const manifestPath = resolved.path;\n\n try {\n const raw = fs.readFileSync(manifestPath, \"utf-8\");\n const parsed = JSON.parse(raw) as unknown;\n const result = validateManifest(parsed);\n if (!result.valid || !result.manifest) {\n console.error(\n \"Invalid manifest. Run `appkit plugin validate` for details.\",\n );\n process.exit(1);\n }\n return { manifest: parsed as ManifestWithExtras, manifestPath };\n } catch (err) {\n console.error(\n \"Failed to read or parse manifest.json:\",\n err instanceof Error ? err.message : err,\n );\n process.exit(1);\n }\n}\n\nfunction buildEntry(\n type: string,\n opts: AddResourceOptions,\n): { entry: ResourceRequirement; isRequired: boolean } {\n const alias = humanizeResourceType(type);\n const isRequired = opts.required !== false;\n\n let fields = getDefaultFieldsForType(type);\n if (opts.fieldsJson) {\n try {\n const parsed = JSON.parse(opts.fieldsJson) as Record<\n string,\n { env: string; description?: string }\n >;\n fields = { ...fields, ...parsed };\n } catch {\n console.error(\"Error: --fields-json must be valid JSON.\");\n console.error(\n ' Example: --fields-json \\'{\"id\":{\"env\":\"MY_WAREHOUSE_ID\"}}\\'',\n );\n process.exit(1);\n }\n }\n\n const entry: ResourceRequirement = {\n type: type as ResourceRequirement[\"type\"],\n alias,\n resourceKey: opts.resourceKey ?? resourceKeyFromType(type),\n description:\n opts.description ||\n `${isRequired ? \"Required\" : \"Optional\"} for ${alias} functionality.`,\n permission:\n opts.permission ?? DEFAULT_PERMISSION_BY_TYPE[type] ?? \"CAN_VIEW\",\n fields,\n };\n\n return { entry, isRequired };\n}\n\nfunction runNonInteractive(opts: AddResourceOptions): void {\n const cwd = process.cwd();\n const pluginDir = path.resolve(cwd, opts.path ?? \".\");\n const loaded = loadManifest(pluginDir);\n if (!loaded) return;\n const { manifest, manifestPath } = loaded;\n\n const type = opts.type as string;\n const validTypes = getValidResourceTypes();\n if (!validTypes.includes(type)) {\n console.error(`Error: Unknown resource type \"${type}\".`);\n console.error(` Valid types: ${validTypes.join(\", \")}`);\n process.exit(1);\n }\n const { entry, isRequired } = buildEntry(type, opts);\n\n if (isRequired) {\n manifest.resources.required.push(entry);\n } else {\n manifest.resources.optional.push(entry);\n }\n\n if (opts.dryRun) {\n console.log(JSON.stringify(manifest, null, 2));\n return;\n }\n\n fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\\n`);\n console.log(\n `Added ${entry.alias} as ${isRequired ? \"required\" : \"optional\"} to ${path.relative(cwd, manifestPath)}`,\n );\n}\n\nasync function runInteractive(opts: AddResourceOptions): Promise<void> {\n intro(\"Add resource to plugin manifest\");\n\n const cwd = process.cwd();\n const pluginDir = path.resolve(cwd, opts.path ?? \".\");\n const loaded = loadManifest(pluginDir);\n if (!loaded) return;\n const { manifest, manifestPath } = loaded;\n\n const spec = await promptOneResource();\n if (!spec) {\n cancel(\"Cancelled.\");\n process.exit(0);\n }\n\n const alias = humanizeResourceType(spec.type);\n const entry: ResourceRequirement = {\n type: spec.type as ResourceRequirement[\"type\"],\n alias,\n resourceKey: spec.resourceKey,\n description: spec.description || `Required for ${alias} functionality.`,\n permission: spec.permission,\n fields: spec.fields,\n };\n\n if (spec.required) {\n manifest.resources.required.push(entry);\n } else {\n manifest.resources.optional.push(entry);\n }\n\n fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\\n`);\n\n outro(\"Resource added.\");\n console.log(\n `\\nAdded ${alias} as ${spec.required ? \"required\" : \"optional\"} to ${path.relative(cwd, manifestPath)}`,\n );\n}\n\nasync function runPluginAddResource(opts: AddResourceOptions): Promise<void> {\n if (opts.type) {\n runNonInteractive(opts);\n } else {\n await runInteractive(opts);\n }\n}\n\nexport const pluginAddResourceCommand = new Command(\"add-resource\")\n .description(\n \"Add a resource requirement to an existing plugin manifest. Overwrites manifest.json in place.\",\n )\n .option(\n \"-p, --path <dir>\",\n \"Plugin directory containing manifest.json (default: .)\",\n )\n .option(\n \"-t, --type <resource_type>\",\n \"Resource type (e.g. sql_warehouse, volume). Enables non-interactive mode.\",\n )\n .option(\"--required\", \"Mark resource as required (default: true)\", true)\n .option(\"--no-required\", \"Mark resource as optional\")\n .option(\"--resource-key <key>\", \"Resource key (default: derived from type)\")\n .option(\"--description <text>\", \"Description of the resource requirement\")\n .option(\"--permission <perm>\", \"Permission level (default: from schema)\")\n .option(\n \"--fields-json <json>\",\n 'JSON object overriding field env vars (e.g. \\'{\"id\":{\"env\":\"MY_WAREHOUSE_ID\"}}\\')',\n )\n .option(\"--dry-run\", \"Preview the updated manifest without writing\")\n .addHelpText(\n \"after\",\n `\nExamples:\n $ appkit plugin add-resource\n $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse\n $ appkit plugin add-resource --path plugins/my-plugin --type volume --no-required --dry-run\n $ appkit plugin add-resource --type sql_warehouse --fields-json '{\"id\":{\"env\":\"MY_WAREHOUSE_ID\"}}'`,\n )\n .action((opts) =>\n runPluginAddResource(opts).catch((err) => {\n console.error(err);\n process.exit(1);\n }),\n );\n"],"mappings":"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"}
1
+ {"version":3,"file":"add-resource.js","names":[],"sources":["../../../../../src/cli/commands/plugin/add-resource/add-resource.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport process from \"node:process\";\nimport { cancel, intro, outro } from \"@clack/prompts\";\nimport { Command } from \"commander\";\nimport { promptOneResource } from \"../create/prompt-resource\";\nimport {\n DEFAULT_PERMISSION_BY_TYPE,\n getDefaultFieldsForType,\n getValidResourceTypes,\n humanizeResourceType,\n resourceKeyFromType,\n} from \"../create/resource-defaults\";\nimport { resolveManifestInDir } from \"../manifest-resolve\";\nimport type { PluginManifest, ResourceRequirement } from \"../manifest-types\";\nimport { validateManifest } from \"../validate/validate-manifest\";\n\n/** Extended manifest type that preserves extra JSON fields (e.g. $schema, author, version) for round-trip writes. */\ninterface ManifestWithExtras extends PluginManifest {\n [key: string]: unknown;\n}\n\ninterface AddResourceOptions {\n path?: string;\n type?: string;\n required?: boolean;\n resourceKey?: string;\n description?: string;\n permission?: string;\n fieldsJson?: string;\n dryRun?: boolean;\n}\n\nfunction loadManifest(\n pluginDir: string,\n): { manifest: ManifestWithExtras; manifestPath: string } | null {\n const resolved = resolveManifestInDir(pluginDir, { allowJsManifest: true });\n\n if (!resolved) {\n console.error(\n `No manifest found in ${pluginDir}. This command requires manifest.json (manifest.js cannot be edited in place).`,\n );\n console.error(\n \" appkit plugin add-resource --path <dir-with-manifest.json>\",\n );\n process.exit(1);\n }\n\n if (resolved.type !== \"json\") {\n console.error(\n `Editable manifest not found. add-resource only supports plugin directories that contain manifest.json (found ${path.basename(resolved.path)}).`,\n );\n process.exit(1);\n }\n\n const manifestPath = resolved.path;\n\n try {\n const raw = fs.readFileSync(manifestPath, \"utf-8\");\n const parsed = JSON.parse(raw) as unknown;\n const result = validateManifest(parsed);\n if (!result.valid || !result.manifest) {\n console.error(\n \"Invalid manifest. Run `appkit plugin validate` for details.\",\n );\n process.exit(1);\n }\n return { manifest: parsed as ManifestWithExtras, manifestPath };\n } catch (err) {\n console.error(\n \"Failed to read or parse manifest.json:\",\n err instanceof Error ? err.message : err,\n );\n process.exit(1);\n }\n}\n\nfunction buildEntry(\n type: string,\n opts: AddResourceOptions,\n): { entry: ResourceRequirement; isRequired: boolean } {\n const alias = humanizeResourceType(type);\n const isRequired = opts.required !== false;\n\n let fields = getDefaultFieldsForType(type);\n if (opts.fieldsJson) {\n try {\n const parsed = JSON.parse(opts.fieldsJson) as Record<\n string,\n { env: string; description?: string }\n >;\n fields = { ...fields, ...parsed };\n } catch {\n console.error(\"Error: --fields-json must be valid JSON.\");\n console.error(\n ' Example: --fields-json \\'{\"id\":{\"env\":\"MY_WAREHOUSE_ID\"}}\\'',\n );\n process.exit(1);\n }\n }\n\n // Constructed dynamically from a string `type` and string `permission`;\n // the per-type permission tightness from the discriminated union is\n // recovered at runtime by `validateManifest` before the entry is written.\n const entry = {\n type,\n alias,\n resourceKey: opts.resourceKey ?? resourceKeyFromType(type),\n description:\n opts.description ||\n `${isRequired ? \"Required\" : \"Optional\"} for ${alias} functionality.`,\n permission:\n opts.permission ?? DEFAULT_PERMISSION_BY_TYPE[type] ?? \"CAN_VIEW\",\n fields,\n } as ResourceRequirement;\n\n return { entry, isRequired };\n}\n\nfunction runNonInteractive(opts: AddResourceOptions): void {\n const cwd = process.cwd();\n const pluginDir = path.resolve(cwd, opts.path ?? \".\");\n const loaded = loadManifest(pluginDir);\n if (!loaded) return;\n const { manifest, manifestPath } = loaded;\n\n const type = opts.type as string;\n const validTypes = getValidResourceTypes();\n if (!validTypes.includes(type)) {\n console.error(`Error: Unknown resource type \"${type}\".`);\n console.error(` Valid types: ${validTypes.join(\", \")}`);\n process.exit(1);\n }\n const { entry, isRequired } = buildEntry(type, opts);\n\n if (isRequired) {\n manifest.resources.required.push(entry);\n } else {\n manifest.resources.optional.push(entry);\n }\n\n if (opts.dryRun) {\n console.log(JSON.stringify(manifest, null, 2));\n return;\n }\n\n fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\\n`);\n console.log(\n `Added ${entry.alias} as ${isRequired ? \"required\" : \"optional\"} to ${path.relative(cwd, manifestPath)}`,\n );\n}\n\nasync function runInteractive(opts: AddResourceOptions): Promise<void> {\n intro(\"Add resource to plugin manifest\");\n\n const cwd = process.cwd();\n const pluginDir = path.resolve(cwd, opts.path ?? \".\");\n const loaded = loadManifest(pluginDir);\n if (!loaded) return;\n const { manifest, manifestPath } = loaded;\n\n const spec = await promptOneResource();\n if (!spec) {\n cancel(\"Cancelled.\");\n process.exit(0);\n }\n\n const alias = humanizeResourceType(spec.type);\n // See note on the non-interactive variant in `buildEntry` — the entry is\n // assembled from prompt strings and validated by Zod at write time.\n const entry = {\n type: spec.type,\n alias,\n resourceKey: spec.resourceKey,\n description: spec.description || `Required for ${alias} functionality.`,\n permission: spec.permission,\n fields: spec.fields,\n } as ResourceRequirement;\n\n if (spec.required) {\n manifest.resources.required.push(entry);\n } else {\n manifest.resources.optional.push(entry);\n }\n\n fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\\n`);\n\n outro(\"Resource added.\");\n console.log(\n `\\nAdded ${alias} as ${spec.required ? \"required\" : \"optional\"} to ${path.relative(cwd, manifestPath)}`,\n );\n}\n\nasync function runPluginAddResource(opts: AddResourceOptions): Promise<void> {\n if (opts.type) {\n runNonInteractive(opts);\n } else {\n await runInteractive(opts);\n }\n}\n\nexport const pluginAddResourceCommand = new Command(\"add-resource\")\n .description(\n \"Add a resource requirement to an existing plugin manifest. Overwrites manifest.json in place.\",\n )\n .option(\n \"-p, --path <dir>\",\n \"Plugin directory containing manifest.json (default: .)\",\n )\n .option(\n \"-t, --type <resource_type>\",\n \"Resource type (e.g. sql_warehouse, volume). Enables non-interactive mode.\",\n )\n .option(\"--required\", \"Mark resource as required (default: true)\", true)\n .option(\"--no-required\", \"Mark resource as optional\")\n .option(\"--resource-key <key>\", \"Resource key (default: derived from type)\")\n .option(\"--description <text>\", \"Description of the resource requirement\")\n .option(\"--permission <perm>\", \"Permission level (default: from schema)\")\n .option(\n \"--fields-json <json>\",\n 'JSON object overriding field env vars (e.g. \\'{\"id\":{\"env\":\"MY_WAREHOUSE_ID\"}}\\')',\n )\n .option(\"--dry-run\", \"Preview the updated manifest without writing\")\n .addHelpText(\n \"after\",\n `\nExamples:\n $ appkit plugin add-resource\n $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse\n $ appkit plugin add-resource --path plugins/my-plugin --type volume --no-required --dry-run\n $ appkit plugin add-resource --type sql_warehouse --fields-json '{\"id\":{\"env\":\"MY_WAREHOUSE_ID\"}}'`,\n )\n .action((opts) =>\n runPluginAddResource(opts).catch((err) => {\n console.error(err);\n process.exit(1);\n }),\n );\n"],"mappings":"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"}
@@ -3,7 +3,8 @@ import { getResourceTypeOptions, getResourceTypePermissions } from "../schema-re
3
3
  //#region src/cli/commands/plugin/create/resource-defaults.ts
4
4
  /**
5
5
  * Resource type and permission defaults for plugin scaffolding.
6
- * Values are derived from plugin-manifest.schema.json via schema-resources.
6
+ * Values are sourced from the canonical Zod schemas in `schemas/manifest`,
7
+ * surfaced through the `schema-resources` module.
7
8
  */
8
9
  const MANIFEST_SCHEMA_ID = "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json";
9
10
  /** Resource types from schema resourceType enum (value, human label). */
@@ -1 +1 @@
1
- {"version":3,"file":"resource-defaults.js","names":[],"sources":["../../../../../src/cli/commands/plugin/create/resource-defaults.ts"],"sourcesContent":["/**\n * Resource type and permission defaults for plugin scaffolding.\n * Values are derived from plugin-manifest.schema.json via schema-resources.\n */\n\nimport {\n getResourceTypeOptions,\n getResourceTypePermissions,\n type ResourceTypeOption,\n} from \"../schema-resources\";\n\nexport const MANIFEST_SCHEMA_ID =\n \"https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json\";\n\nexport type { ResourceTypeOption };\n\n/** Resource types from schema resourceType enum (value, human label). */\nexport const RESOURCE_TYPE_OPTIONS: ResourceTypeOption[] =\n getResourceTypeOptions();\n\n/** All valid permissions per resource type, from schema allOf/if-then rules. */\nexport const PERMISSIONS_BY_TYPE: Record<string, string[]> =\n getResourceTypePermissions();\n\n/** Default (first) permission per resource type for scaffolding. */\nexport const DEFAULT_PERMISSION_BY_TYPE: Record<string, string> =\n Object.fromEntries(\n Object.entries(PERMISSIONS_BY_TYPE).map(([type, perms]) => [\n type,\n perms[0],\n ]),\n );\n\n/** Default fields per resource type: field key -> { env, description }. */\nexport const DEFAULT_FIELDS_BY_TYPE: Record<\n string,\n Record<string, { env: string; description?: string }>\n> = {\n sql_warehouse: {\n id: { env: \"DATABRICKS_WAREHOUSE_ID\", description: \"SQL Warehouse ID\" },\n },\n secret: {\n scope: { env: \"SECRET_SCOPE\", description: \"Secret scope name\" },\n key: { env: \"SECRET_KEY\", description: \"Secret key\" },\n },\n job: {\n id: { env: \"DATABRICKS_JOB_ID\", description: \"Job ID\" },\n },\n serving_endpoint: {\n id: {\n env: \"DATABRICKS_SERVING_ENDPOINT_ID\",\n description: \"Serving endpoint ID\",\n },\n },\n volume: {\n name: { env: \"VOLUME_NAME\", description: \"Volume name\" },\n },\n vector_search_index: {\n endpoint_name: {\n env: \"VECTOR_SEARCH_ENDPOINT_NAME\",\n description: \"Vector search endpoint name\",\n },\n index_name: {\n env: \"VECTOR_SEARCH_INDEX_NAME\",\n description: \"Vector search index name\",\n },\n },\n uc_function: {\n name: {\n env: \"UC_FUNCTION_NAME\",\n description: \"Unity Catalog function name\",\n },\n },\n uc_connection: {\n name: {\n env: \"UC_CONNECTION_NAME\",\n description: \"Unity Catalog connection name\",\n },\n },\n database: {\n instance_name: {\n env: \"DATABRICKS_INSTANCE_NAME\",\n description: \"Databricks instance name\",\n },\n database_name: {\n env: \"DATABASE_NAME\",\n description: \"Database name\",\n },\n },\n genie_space: {\n id: { env: \"GENIE_SPACE_ID\", description: \"Genie Space ID\" },\n },\n experiment: {\n id: { env: \"MLFLOW_EXPERIMENT_ID\", description: \"MLflow experiment ID\" },\n },\n app: {\n id: { env: \"DATABRICKS_APP_ID\", description: \"Databricks App ID\" },\n },\n};\n\n/** Valid resource type values from the schema. */\nexport function getValidResourceTypes(): string[] {\n return RESOURCE_TYPE_OPTIONS.map((o) => o.value);\n}\n\n/** Humanized alias from resource type (e.g. sql_warehouse -> \"SQL Warehouse\"). */\nexport function humanizeResourceType(type: string): string {\n const option = RESOURCE_TYPE_OPTIONS.find((o) => o.value === type);\n return option ? option.label : type.replace(/_/g, \" \");\n}\n\n/** Kebab-case resource key from type (e.g. sql_warehouse -> \"sql-warehouse\"). */\nexport function resourceKeyFromType(type: string): string {\n return type.replace(/_/g, \"-\");\n}\n\n/** Get default fields for a resource type; fallback to single id field. */\nexport function getDefaultFieldsForType(\n type: string,\n): Record<string, { env: string; description?: string }> {\n const known = DEFAULT_FIELDS_BY_TYPE[type];\n if (known) return known;\n const key = resourceKeyFromType(type);\n const envName = `DATABRICKS_${key.toUpperCase().replace(/-/g, \"_\")}_ID`;\n return {\n id: { env: envName, description: `${humanizeResourceType(type)} ID` },\n };\n}\n"],"mappings":";;;;;;;AAWA,MAAa,qBACX;;AAKF,MAAa,wBACX,wBAAwB;;AAG1B,MAAa,sBACX,4BAA4B;;AAG9B,MAAa,6BACX,OAAO,YACL,OAAO,QAAQ,oBAAoB,CAAC,KAAK,CAAC,MAAM,WAAW,CACzD,MACA,MAAM,GACP,CAAC,CACH;;AAGH,MAAa,yBAGT;CACF,eAAe,EACb,IAAI;EAAE,KAAK;EAA2B,aAAa;EAAoB,EACxE;CACD,QAAQ;EACN,OAAO;GAAE,KAAK;GAAgB,aAAa;GAAqB;EAChE,KAAK;GAAE,KAAK;GAAc,aAAa;GAAc;EACtD;CACD,KAAK,EACH,IAAI;EAAE,KAAK;EAAqB,aAAa;EAAU,EACxD;CACD,kBAAkB,EAChB,IAAI;EACF,KAAK;EACL,aAAa;EACd,EACF;CACD,QAAQ,EACN,MAAM;EAAE,KAAK;EAAe,aAAa;EAAe,EACzD;CACD,qBAAqB;EACnB,eAAe;GACb,KAAK;GACL,aAAa;GACd;EACD,YAAY;GACV,KAAK;GACL,aAAa;GACd;EACF;CACD,aAAa,EACX,MAAM;EACJ,KAAK;EACL,aAAa;EACd,EACF;CACD,eAAe,EACb,MAAM;EACJ,KAAK;EACL,aAAa;EACd,EACF;CACD,UAAU;EACR,eAAe;GACb,KAAK;GACL,aAAa;GACd;EACD,eAAe;GACb,KAAK;GACL,aAAa;GACd;EACF;CACD,aAAa,EACX,IAAI;EAAE,KAAK;EAAkB,aAAa;EAAkB,EAC7D;CACD,YAAY,EACV,IAAI;EAAE,KAAK;EAAwB,aAAa;EAAwB,EACzE;CACD,KAAK,EACH,IAAI;EAAE,KAAK;EAAqB,aAAa;EAAqB,EACnE;CACF;;AAGD,SAAgB,wBAAkC;AAChD,QAAO,sBAAsB,KAAK,MAAM,EAAE,MAAM;;;AAIlD,SAAgB,qBAAqB,MAAsB;CACzD,MAAM,SAAS,sBAAsB,MAAM,MAAM,EAAE,UAAU,KAAK;AAClE,QAAO,SAAS,OAAO,QAAQ,KAAK,QAAQ,MAAM,IAAI;;;AAIxD,SAAgB,oBAAoB,MAAsB;AACxD,QAAO,KAAK,QAAQ,MAAM,IAAI;;;AAIhC,SAAgB,wBACd,MACuD;CACvD,MAAM,QAAQ,uBAAuB;AACrC,KAAI,MAAO,QAAO;AAGlB,QAAO,EACL,IAAI;EAAE,KAFQ,cADJ,oBAAoB,KAAK,CACH,aAAa,CAAC,QAAQ,MAAM,IAAI,CAAC;EAE7C,aAAa,GAAG,qBAAqB,KAAK,CAAC;EAAM,EACtE"}
1
+ {"version":3,"file":"resource-defaults.js","names":[],"sources":["../../../../../src/cli/commands/plugin/create/resource-defaults.ts"],"sourcesContent":["/**\n * Resource type and permission defaults for plugin scaffolding.\n * Values are sourced from the canonical Zod schemas in `schemas/manifest`,\n * surfaced through the `schema-resources` module.\n */\n\nimport {\n getResourceTypeOptions,\n getResourceTypePermissions,\n type ResourceTypeOption,\n} from \"../schema-resources\";\n\nexport const MANIFEST_SCHEMA_ID =\n \"https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json\";\n\nexport type { ResourceTypeOption };\n\n/** Resource types from schema resourceType enum (value, human label). */\nexport const RESOURCE_TYPE_OPTIONS: ResourceTypeOption[] =\n getResourceTypeOptions();\n\n/** All valid permissions per resource type, from schema allOf/if-then rules. */\nexport const PERMISSIONS_BY_TYPE: Record<string, string[]> =\n getResourceTypePermissions();\n\n/** Default (first) permission per resource type for scaffolding. */\nexport const DEFAULT_PERMISSION_BY_TYPE: Record<string, string> =\n Object.fromEntries(\n Object.entries(PERMISSIONS_BY_TYPE).map(([type, perms]) => [\n type,\n perms[0],\n ]),\n );\n\n/** Default fields per resource type: field key -> { env, description }. */\nexport const DEFAULT_FIELDS_BY_TYPE: Record<\n string,\n Record<string, { env: string; description?: string }>\n> = {\n sql_warehouse: {\n id: { env: \"DATABRICKS_WAREHOUSE_ID\", description: \"SQL Warehouse ID\" },\n },\n secret: {\n scope: { env: \"SECRET_SCOPE\", description: \"Secret scope name\" },\n key: { env: \"SECRET_KEY\", description: \"Secret key\" },\n },\n job: {\n id: { env: \"DATABRICKS_JOB_ID\", description: \"Job ID\" },\n },\n serving_endpoint: {\n id: {\n env: \"DATABRICKS_SERVING_ENDPOINT_ID\",\n description: \"Serving endpoint ID\",\n },\n },\n volume: {\n name: { env: \"VOLUME_NAME\", description: \"Volume name\" },\n },\n vector_search_index: {\n endpoint_name: {\n env: \"VECTOR_SEARCH_ENDPOINT_NAME\",\n description: \"Vector search endpoint name\",\n },\n index_name: {\n env: \"VECTOR_SEARCH_INDEX_NAME\",\n description: \"Vector search index name\",\n },\n },\n uc_function: {\n name: {\n env: \"UC_FUNCTION_NAME\",\n description: \"Unity Catalog function name\",\n },\n },\n uc_connection: {\n name: {\n env: \"UC_CONNECTION_NAME\",\n description: \"Unity Catalog connection name\",\n },\n },\n database: {\n instance_name: {\n env: \"DATABRICKS_INSTANCE_NAME\",\n description: \"Databricks instance name\",\n },\n database_name: {\n env: \"DATABASE_NAME\",\n description: \"Database name\",\n },\n },\n genie_space: {\n id: { env: \"GENIE_SPACE_ID\", description: \"Genie Space ID\" },\n },\n experiment: {\n id: { env: \"MLFLOW_EXPERIMENT_ID\", description: \"MLflow experiment ID\" },\n },\n app: {\n id: { env: \"DATABRICKS_APP_ID\", description: \"Databricks App ID\" },\n },\n};\n\n/** Valid resource type values from the schema. */\nexport function getValidResourceTypes(): string[] {\n return RESOURCE_TYPE_OPTIONS.map((o) => o.value);\n}\n\n/** Humanized alias from resource type (e.g. sql_warehouse -> \"SQL Warehouse\"). */\nexport function humanizeResourceType(type: string): string {\n const option = RESOURCE_TYPE_OPTIONS.find((o) => o.value === type);\n return option ? option.label : type.replace(/_/g, \" \");\n}\n\n/** Kebab-case resource key from type (e.g. sql_warehouse -> \"sql-warehouse\"). */\nexport function resourceKeyFromType(type: string): string {\n return type.replace(/_/g, \"-\");\n}\n\n/** Get default fields for a resource type; fallback to single id field. */\nexport function getDefaultFieldsForType(\n type: string,\n): Record<string, { env: string; description?: string }> {\n const known = DEFAULT_FIELDS_BY_TYPE[type];\n if (known) return known;\n const key = resourceKeyFromType(type);\n const envName = `DATABRICKS_${key.toUpperCase().replace(/-/g, \"_\")}_ID`;\n return {\n id: { env: envName, description: `${humanizeResourceType(type)} ID` },\n };\n}\n"],"mappings":";;;;;;;;AAYA,MAAa,qBACX;;AAKF,MAAa,wBACX,wBAAwB;;AAG1B,MAAa,sBACX,4BAA4B;;AAG9B,MAAa,6BACX,OAAO,YACL,OAAO,QAAQ,oBAAoB,CAAC,KAAK,CAAC,MAAM,WAAW,CACzD,MACA,MAAM,GACP,CAAC,CACH;;AAGH,MAAa,yBAGT;CACF,eAAe,EACb,IAAI;EAAE,KAAK;EAA2B,aAAa;EAAoB,EACxE;CACD,QAAQ;EACN,OAAO;GAAE,KAAK;GAAgB,aAAa;GAAqB;EAChE,KAAK;GAAE,KAAK;GAAc,aAAa;GAAc;EACtD;CACD,KAAK,EACH,IAAI;EAAE,KAAK;EAAqB,aAAa;EAAU,EACxD;CACD,kBAAkB,EAChB,IAAI;EACF,KAAK;EACL,aAAa;EACd,EACF;CACD,QAAQ,EACN,MAAM;EAAE,KAAK;EAAe,aAAa;EAAe,EACzD;CACD,qBAAqB;EACnB,eAAe;GACb,KAAK;GACL,aAAa;GACd;EACD,YAAY;GACV,KAAK;GACL,aAAa;GACd;EACF;CACD,aAAa,EACX,MAAM;EACJ,KAAK;EACL,aAAa;EACd,EACF;CACD,eAAe,EACb,MAAM;EACJ,KAAK;EACL,aAAa;EACd,EACF;CACD,UAAU;EACR,eAAe;GACb,KAAK;GACL,aAAa;GACd;EACD,eAAe;GACb,KAAK;GACL,aAAa;GACd;EACF;CACD,aAAa,EACX,IAAI;EAAE,KAAK;EAAkB,aAAa;EAAkB,EAC7D;CACD,YAAY,EACV,IAAI;EAAE,KAAK;EAAwB,aAAa;EAAwB,EACzE;CACD,KAAK,EACH,IAAI;EAAE,KAAK;EAAqB,aAAa;EAAqB,EACnE;CACF;;AAGD,SAAgB,wBAAkC;AAChD,QAAO,sBAAsB,KAAK,MAAM,EAAE,MAAM;;;AAIlD,SAAgB,qBAAqB,MAAsB;CACzD,MAAM,SAAS,sBAAsB,MAAM,MAAM,EAAE,UAAU,KAAK;AAClE,QAAO,SAAS,OAAO,QAAQ,KAAK,QAAQ,MAAM,IAAI;;;AAIxD,SAAgB,oBAAoB,MAAsB;AACxD,QAAO,KAAK,QAAQ,MAAM,IAAI;;;AAIhC,SAAgB,wBACd,MACuD;CACvD,MAAM,QAAQ,uBAAuB;AACrC,KAAI,MAAO,QAAO;AAGlB,QAAO,EACL,IAAI;EAAE,KAFQ,cADJ,oBAAoB,KAAK,CACH,aAAa,CAAC,QAAQ,MAAM,IAAI,CAAC;EAE7C,aAAa,GAAG,qBAAqB,KAAK,CAAC;EAAM,EACtE"}