@databricks/appkit 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CLAUDE.md +1 -0
  2. package/dist/agents/databricks.d.ts +25 -1
  3. package/dist/agents/databricks.d.ts.map +1 -1
  4. package/dist/agents/databricks.js +20 -1
  5. package/dist/agents/databricks.js.map +1 -1
  6. package/dist/app/index.d.ts +49 -2
  7. package/dist/app/index.d.ts.map +1 -1
  8. package/dist/app/index.js +87 -10
  9. package/dist/app/index.js.map +1 -1
  10. package/dist/appkit/package.js +1 -1
  11. package/dist/beta.d.ts +2 -2
  12. package/dist/cli/commands/generate-types.js +9 -4
  13. package/dist/cli/commands/generate-types.js.map +1 -1
  14. package/dist/core/agent/load-agents.d.ts.map +1 -1
  15. package/dist/core/agent/load-agents.js +52 -0
  16. package/dist/core/agent/load-agents.js.map +1 -1
  17. package/dist/core/agent/types.d.ts +11 -0
  18. package/dist/core/agent/types.d.ts.map +1 -1
  19. package/dist/core/agent/types.js.map +1 -1
  20. package/dist/core/appkit.d.ts.map +1 -1
  21. package/dist/core/appkit.js +2 -0
  22. package/dist/core/appkit.js.map +1 -1
  23. package/dist/core/lifecycle-manager.js +183 -0
  24. package/dist/core/lifecycle-manager.js.map +1 -0
  25. package/dist/core/plugin-context.d.ts +19 -1
  26. package/dist/core/plugin-context.d.ts.map +1 -1
  27. package/dist/core/plugin-context.js +9 -3
  28. package/dist/core/plugin-context.js.map +1 -1
  29. package/dist/plugin/plugin.d.ts.map +1 -1
  30. package/dist/plugin/plugin.js +2 -1
  31. package/dist/plugin/plugin.js.map +1 -1
  32. package/dist/plugins/agents/agents.d.ts.map +1 -1
  33. package/dist/plugins/agents/agents.js +3 -3
  34. package/dist/plugins/agents/agents.js.map +1 -1
  35. package/dist/plugins/analytics/analytics.d.ts +16 -0
  36. package/dist/plugins/analytics/analytics.d.ts.map +1 -1
  37. package/dist/plugins/analytics/analytics.js +162 -1
  38. package/dist/plugins/analytics/analytics.js.map +1 -1
  39. package/dist/plugins/analytics/metric.js +7 -0
  40. package/dist/plugins/analytics/mv/cache.js +51 -0
  41. package/dist/plugins/analytics/mv/cache.js.map +1 -0
  42. package/dist/plugins/analytics/mv/constants.js +72 -0
  43. package/dist/plugins/analytics/mv/constants.js.map +1 -0
  44. package/dist/plugins/analytics/mv/formatters.js +151 -0
  45. package/dist/plugins/analytics/mv/formatters.js.map +1 -0
  46. package/dist/plugins/analytics/mv/index.js +6 -0
  47. package/dist/plugins/analytics/mv/registry.js +55 -0
  48. package/dist/plugins/analytics/mv/registry.js.map +1 -0
  49. package/dist/plugins/analytics/mv/schemas.js +178 -0
  50. package/dist/plugins/analytics/mv/schemas.js.map +1 -0
  51. package/dist/plugins/analytics/types.js.map +1 -1
  52. package/dist/plugins/lakebase/lakebase.d.ts +10 -2
  53. package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
  54. package/dist/plugins/lakebase/lakebase.js +18 -7
  55. package/dist/plugins/lakebase/lakebase.js.map +1 -1
  56. package/dist/plugins/server/index.d.ts +51 -1
  57. package/dist/plugins/server/index.d.ts.map +1 -1
  58. package/dist/plugins/server/index.js +110 -23
  59. package/dist/plugins/server/index.js.map +1 -1
  60. package/dist/registry/resource-registry.d.ts +9 -1
  61. package/dist/registry/resource-registry.d.ts.map +1 -1
  62. package/dist/registry/resource-registry.js +22 -5
  63. package/dist/registry/resource-registry.js.map +1 -1
  64. package/dist/schemas/metric-fqn.js +14 -0
  65. package/dist/schemas/metric-fqn.js.map +1 -0
  66. package/dist/shared/src/execute.d.ts +1 -3
  67. package/dist/shared/src/execute.d.ts.map +1 -1
  68. package/dist/shared/src/plugin.d.ts +16 -0
  69. package/dist/shared/src/plugin.d.ts.map +1 -1
  70. package/dist/shared/src/schemas/metric-fqn.js +78 -46
  71. package/dist/shared/src/schemas/metric-fqn.js.map +1 -1
  72. package/dist/shared/src/schemas/metric-source.js +90 -0
  73. package/dist/shared/src/schemas/metric-source.js.map +1 -0
  74. package/dist/stream/buffers.js +1 -1
  75. package/dist/stream/buffers.js.map +1 -1
  76. package/dist/stream/defaults.js +0 -2
  77. package/dist/stream/defaults.js.map +1 -1
  78. package/dist/stream/stream-manager.d.ts +2 -2
  79. package/dist/stream/stream-manager.d.ts.map +1 -1
  80. package/dist/stream/stream-manager.js +26 -24
  81. package/dist/stream/stream-manager.js.map +1 -1
  82. package/dist/stream/stream-registry.js +30 -23
  83. package/dist/stream/stream-registry.js.map +1 -1
  84. package/dist/stream/timers.js +17 -0
  85. package/dist/stream/timers.js.map +1 -0
  86. package/dist/stream/types.js.map +1 -1
  87. package/dist/telemetry/telemetry-manager.js +19 -13
  88. package/dist/telemetry/telemetry-manager.js.map +1 -1
  89. package/dist/type-generator/index.js +14 -7
  90. package/dist/type-generator/index.js.map +1 -1
  91. package/dist/type-generator/mv-registry/config.js +13 -31
  92. package/dist/type-generator/mv-registry/config.js.map +1 -1
  93. package/dist/type-generator/mv-registry/describe.js +1 -31
  94. package/dist/type-generator/mv-registry/describe.js.map +1 -1
  95. package/dist/type-generator/mv-registry/sync.js +1 -1
  96. package/dist/type-generator/mv-registry/sync.js.map +1 -1
  97. package/dist/type-generator/vite-plugin.d.ts +5 -1
  98. package/dist/type-generator/vite-plugin.d.ts.map +1 -1
  99. package/dist/type-generator/vite-plugin.js +21 -4
  100. package/dist/type-generator/vite-plugin.js.map +1 -1
  101. package/dist/utils/safe-handler.js +28 -0
  102. package/dist/utils/safe-handler.js.map +1 -0
  103. package/docs/api/appkit/Class.Plugin.md +2 -0
  104. package/docs/api/appkit/Class.ResourceRegistry.md +1 -1
  105. package/docs/api/appkit/Interface.AgentDefinition.md +11 -0
  106. package/docs/api/appkit/Interface.GenerationParams.md +58 -0
  107. package/docs/api/appkit/Interface.RegisteredAgent.md +11 -0
  108. package/docs/api/appkit.md +1 -0
  109. package/docs/development/type-generation.md +3 -3
  110. package/docs/plugins/analytics.md +172 -23
  111. package/llms.txt +1 -0
  112. package/package.json +1 -1
  113. package/sbom.cdx.json +1 -1
@@ -1,10 +1,9 @@
1
1
  //#region ../shared/src/schemas/metric-fqn.ts
2
2
  /**
3
- * Unity Catalog object-name grammar - the single source of truth for metric
4
- * view FQN naming validation.
3
+ * Unity Catalog object-name grammar
4
+ * the single source of truth for metric view FQN (Fully Qualified Name).
5
5
  *
6
- * This module is deliberately **zod-free**. A metric view's `source` FQN is
7
- * validated in two places that must agree:
6
+ * A metric view's `source` FQN is validated in two places that must agree:
8
7
  *
9
8
  * 1. The canonical Zod schema (`./metric-source.ts`), which composes the
10
9
  * three-part FQN regex from {@link UC_FQN_PATTERN} for IDE/CI and the
@@ -13,18 +12,8 @@
13
12
  * which imports {@link UC_FQN_PATTERN} as a plain value to validate each
14
13
  * dot-split segment.
15
14
  *
16
- * The type-generator's runtime path must NOT pull the shared Zod schema package
17
- * in (locked dependency-graph ruling - see the comment in
18
- * `packages/appkit/src/type-generator/cache.ts`). Keeping the pattern in this
19
- * zod-free module lets the runtime import the regex without dragging zod into
20
- * its bundle, while still single-sourcing the grammar.
21
15
  *
22
- * -- UC delimited (quoted) object-name rules ------------------------------
23
- * The metric view FQN is always backtick-quoted before interpolation into SQL
24
- * (see `quoteFqnForSql` in the type-generator), so the **delimited identifier**
25
- * grammar is the one that applies - not the narrower unquoted-identifier rule.
26
- *
27
- * Per the Databricks SQL names reference, a Unity Catalog object name:
16
+ * A Unity Catalog object name:
28
17
  * - cannot exceed 255 characters ({@link MAX_UC_OBJECT_NAME_LENGTH}); and
29
18
  * - cannot contain any of these characters:
30
19
  * - period (`.`)
@@ -35,41 +24,21 @@
35
24
  *
36
25
  * Every other character is permitted in a quoted name, including non-ASCII
37
26
  * letters (the docs demonstrate Chinese/Russian/Portuguese names) and hyphens.
38
- * This is intentionally broader than the old hand-rolled allowlist
39
- * (`[a-zA-Z0-9_-]`), which was flagged in PR #433 review (pkosiec: "more
40
- * restrictive than UC"): the goal is to accept what UC accepts as a quoted
41
- * name and reject only what UC rejects.
42
- *
43
- * Verified against the Databricks docs on 2026-06-19:
44
- * https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-names
45
- * (the link cited in the PR #433 review). If the published rules change,
46
- * re-confirm against that page.
47
27
  *
48
- * @note The period is excluded here because it is the FQN segment separator -
49
- * a name containing a literal dot cannot be expressed in the dotted `source`
50
- * string at all. The dotted-source arity (exactly three segments) and the
51
- * 255-char-per-segment cap are enforced structurally by the callers; this
52
- * pattern only encodes the per-segment allowed character set.
28
+ * @note the regex is the per-segment character set; structure (3 parts) and length are intentionally left to the callers.
53
29
  */
30
+ const MAX_UC_OBJECT_NAME_LENGTH = 255;
54
31
  /**
55
- * Maximum length, in characters, of a single Unity Catalog object name
56
- * (catalog, schema, or metric view). UC rejects names longer than this.
32
+ * Basename of the metric-view declarations file, resolved inside a
33
+ * `config/metric-views/` folder. Single source of truth shared by the analytics
34
+ * runtime (`plugins/analytics/mv`), the type-generator, its Vite watcher, and
35
+ * the `generate-types` CLI. It lives in this zod-free module (rather than the
36
+ * canonical `metric-source.ts` schema) so the type-generator can import it
37
+ * without pulling zod into its locked dependency graph.
57
38
  */
58
- const MAX_UC_OBJECT_NAME_LENGTH = 255;
39
+ const METRIC_CONFIG_FILE = "definitions.json";
59
40
  /**
60
- * Matches a single, non-empty Unity Catalog object name as it may appear in a
61
- * backtick-quoted (delimited) identifier - one segment of a metric view FQN.
62
- *
63
- * Accepts any non-empty run of characters EXCEPT the UC-prohibited set:
64
- * period, space, forward slash, ASCII control characters (U+0000-U+001F), and
65
- * DELETE (U+007F). Length is NOT bounded here - callers enforce
66
- * {@link MAX_UC_OBJECT_NAME_LENGTH} separately so they can emit a precise
67
- * "segment too long" message distinct from a charset violation.
68
- *
69
- * The negated character class encodes the prohibited set as one contiguous
70
- * range plus singletons: U+0000-U+0020 (every ASCII control character plus the
71
- * space, which sits at U+0020 immediately after the control range), U+007F
72
- * (DELETE), `.` (period - also the FQN segment separator), and `/` (slash).
41
+ * Matches a single, non-empty Unity Catalog object name as it may appear in a backtick-quoted (delimited) identifier.
73
42
  *
74
43
  * @example
75
44
  * UC_FQN_PATTERN.test("revenue_metrics"); // true
@@ -79,7 +48,70 @@ const MAX_UC_OBJECT_NAME_LENGTH = 255;
79
48
  * UC_FQN_PATTERN.test("a/b"); // false (slash is prohibited)
80
49
  */
81
50
  const UC_FQN_PATTERN = /^[^\x00-\x20\x7f./]+$/;
51
+ /** A metric view FQN is exactly three segments: catalog.schema.metric_view. */
52
+ const FQN_SEGMENT_COUNT = 3;
53
+ /**
54
+ * Total predicate: is `fqn` a well-formed three-part UC metric view FQN?
55
+ * Well-formed = exactly three non-empty, dot-separated segments, each a valid Unity Catalog object name per {@link UC_FQN_PATTERN}.
56
+ * @example
57
+ * isValidFqn("main.analytics.revenue"); // true
58
+ * isValidFqn("prod-data.analytics.rev"); // true (hyphens are UC-legal)
59
+ * isValidFqn("main.analytics"); // false (only two segments)
60
+ */
61
+ function isValidFqn(fqn) {
62
+ const segments = fqn.split(".");
63
+ if (segments.length !== FQN_SEGMENT_COUNT) return false;
64
+ return segments.every((segment) => UC_FQN_PATTERN.test(segment));
65
+ }
66
+ /**
67
+ * Quote a dot-separated FQN for safe interpolation into a Spark/Databricks SQL
68
+ * statement.
69
+ *
70
+ * Each dot-split segment is wrapped in backtick-quoted-identifier syntax. The
71
+ * one character that can break out of a backtick-quoted identifier is the
72
+ * backtick itself, escaped by doubling (`` ` `` → `` `` ``) — so every backtick
73
+ * inside a segment is doubled before the segment is wrapped. Control characters
74
+ * and newlines have no valid escape inside a quoted identifier, so a segment
75
+ * containing one is rejected outright.
76
+ *
77
+ * This is a pure, standalone escaper: it is intentionally independent of FQN
78
+ * naming validation ({@link isValidFqn}). Naming validation decides whether an
79
+ * FQN is an acceptable metric source; this function only guarantees that
80
+ * whatever it is handed cannot break out of the quoted identifier it produces.
81
+ * Grammar and quoting live together here so a metric source is validated and
82
+ * escaped against one shared source of truth.
83
+ *
84
+ * An ordinary identifier is unchanged apart from the wrapping backticks:
85
+ * `catalog.schema.view` → `` `catalog`.`schema`.`view` ``.
86
+ *
87
+ * @param fqn - Dot-separated identifier (e.g. `catalog.schema.view`).
88
+ * @returns The backtick-quoted, escaped identifier ready for interpolation.
89
+ * @throws If any segment contains a control character or newline.
90
+ */
91
+ function quoteFqnForSql(fqn) {
92
+ return fqn.split(".").map(quoteIdentifier).join(".");
93
+ }
94
+ /**
95
+ * The Unicode "control" category (`\p{Cc}`): C0 (incl. `\n`, `\r`, `\t`), DEL,
96
+ * and C1 — every control character/newline. These have no valid escape inside
97
+ * a backtick-quoted identifier, so a name containing one cannot be safely
98
+ * quoted and is rejected.
99
+ */
100
+ const CONTROL_OR_NEWLINE = /\p{Cc}/u;
101
+ function isValidColumnName(name) {
102
+ return name.length > 0 && !CONTROL_OR_NEWLINE.test(name);
103
+ }
104
+ /**
105
+ * Quote a single identifier (one column/measure/dimension name, or one FQN
106
+ * segment) as a backtick-delimited identifier for safe SQL interpolation.
107
+ *
108
+ * @throws If `name` contains a control character or newline.
109
+ */
110
+ function quoteIdentifier(name) {
111
+ if (CONTROL_OR_NEWLINE.test(name)) throw new Error(`Cannot quote identifier "${name}" for SQL: it contains a control character or newline, which has no valid escape inside a backtick-quoted identifier.`);
112
+ return `\`${name.replace(/`/g, "``")}\``;
113
+ }
82
114
 
83
115
  //#endregion
84
- export { MAX_UC_OBJECT_NAME_LENGTH, UC_FQN_PATTERN };
116
+ export { MAX_UC_OBJECT_NAME_LENGTH, METRIC_CONFIG_FILE, UC_FQN_PATTERN, isValidColumnName, isValidFqn, quoteFqnForSql, quoteIdentifier };
85
117
  //# sourceMappingURL=metric-fqn.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"metric-fqn.js","names":[],"sources":["../../../../../shared/src/schemas/metric-fqn.ts"],"sourcesContent":["/**\n * Unity Catalog object-name grammar - the single source of truth for metric\n * view FQN naming validation.\n *\n * This module is deliberately **zod-free**. A metric view's `source` FQN is\n * validated in two places that must agree:\n *\n * 1. The canonical Zod schema (`./metric-source.ts`), which composes the\n * three-part FQN regex from {@link UC_FQN_PATTERN} for IDE/CI and the\n * generated JSON schema (`docs/static/schemas/metric-source.schema.json`).\n * 2. The type-generator runtime (`packages/appkit/src/type-generator/mv-registry/config.ts`),\n * which imports {@link UC_FQN_PATTERN} as a plain value to validate each\n * dot-split segment.\n *\n * The type-generator's runtime path must NOT pull the shared Zod schema package\n * in (locked dependency-graph ruling - see the comment in\n * `packages/appkit/src/type-generator/cache.ts`). Keeping the pattern in this\n * zod-free module lets the runtime import the regex without dragging zod into\n * its bundle, while still single-sourcing the grammar.\n *\n * -- UC delimited (quoted) object-name rules ------------------------------\n * The metric view FQN is always backtick-quoted before interpolation into SQL\n * (see `quoteFqnForSql` in the type-generator), so the **delimited identifier**\n * grammar is the one that applies - not the narrower unquoted-identifier rule.\n *\n * Per the Databricks SQL names reference, a Unity Catalog object name:\n * - cannot exceed 255 characters ({@link MAX_UC_OBJECT_NAME_LENGTH}); and\n * - cannot contain any of these characters:\n * - period (`.`)\n * - space (U+0020)\n * - forward slash (`/`)\n * - all ASCII control characters (U+0000-U+001F)\n * - the DELETE character (U+007F)\n *\n * Every other character is permitted in a quoted name, including non-ASCII\n * letters (the docs demonstrate Chinese/Russian/Portuguese names) and hyphens.\n * This is intentionally broader than the old hand-rolled allowlist\n * (`[a-zA-Z0-9_-]`), which was flagged in PR #433 review (pkosiec: \"more\n * restrictive than UC\"): the goal is to accept what UC accepts as a quoted\n * name and reject only what UC rejects.\n *\n * Verified against the Databricks docs on 2026-06-19:\n * https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-names\n * (the link cited in the PR #433 review). If the published rules change,\n * re-confirm against that page.\n *\n * @note The period is excluded here because it is the FQN segment separator -\n * a name containing a literal dot cannot be expressed in the dotted `source`\n * string at all. The dotted-source arity (exactly three segments) and the\n * 255-char-per-segment cap are enforced structurally by the callers; this\n * pattern only encodes the per-segment allowed character set.\n */\n\n/**\n * Maximum length, in characters, of a single Unity Catalog object name\n * (catalog, schema, or metric view). UC rejects names longer than this.\n */\nexport const MAX_UC_OBJECT_NAME_LENGTH = 255;\n\n/**\n * Matches a single, non-empty Unity Catalog object name as it may appear in a\n * backtick-quoted (delimited) identifier - one segment of a metric view FQN.\n *\n * Accepts any non-empty run of characters EXCEPT the UC-prohibited set:\n * period, space, forward slash, ASCII control characters (U+0000-U+001F), and\n * DELETE (U+007F). Length is NOT bounded here - callers enforce\n * {@link MAX_UC_OBJECT_NAME_LENGTH} separately so they can emit a precise\n * \"segment too long\" message distinct from a charset violation.\n *\n * The negated character class encodes the prohibited set as one contiguous\n * range plus singletons: U+0000-U+0020 (every ASCII control character plus the\n * space, which sits at U+0020 immediately after the control range), U+007F\n * (DELETE), `.` (period - also the FQN segment separator), and `/` (slash).\n *\n * @example\n * UC_FQN_PATTERN.test(\"revenue_metrics\"); // true\n * UC_FQN_PATTERN.test(\"prod-data\"); // true (hyphens are UC-legal)\n * UC_FQN_PATTERN.test(\"cafe\\u0301\"); // true (non-ASCII is UC-legal)\n * UC_FQN_PATTERN.test(\"bad name\"); // false (space is prohibited)\n * UC_FQN_PATTERN.test(\"a/b\"); // false (slash is prohibited)\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: UC explicitly prohibits ASCII control characters in object names; this negated class encodes that rule.\nexport const UC_FQN_PATTERN = /^[^\\x00-\\x20\\x7f./]+$/;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyDA,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;;;AAyBzC,MAAa,iBAAiB"}
1
+ {"version":3,"file":"metric-fqn.js","names":[],"sources":["../../../../../shared/src/schemas/metric-fqn.ts"],"sourcesContent":["/**\n * Unity Catalog object-name grammar\n * the single source of truth for metric view FQN (Fully Qualified Name).\n *\n * A metric view's `source` FQN is validated in two places that must agree:\n *\n * 1. The canonical Zod schema (`./metric-source.ts`), which composes the\n * three-part FQN regex from {@link UC_FQN_PATTERN} for IDE/CI and the\n * generated JSON schema (`docs/static/schemas/metric-source.schema.json`).\n * 2. The type-generator runtime (`packages/appkit/src/type-generator/mv-registry/config.ts`),\n * which imports {@link UC_FQN_PATTERN} as a plain value to validate each\n * dot-split segment.\n *\n *\n * A Unity Catalog object name:\n * - cannot exceed 255 characters ({@link MAX_UC_OBJECT_NAME_LENGTH}); and\n * - cannot contain any of these characters:\n * - period (`.`)\n * - space (U+0020)\n * - forward slash (`/`)\n * - all ASCII control characters (U+0000-U+001F)\n * - the DELETE character (U+007F)\n *\n * Every other character is permitted in a quoted name, including non-ASCII\n * letters (the docs demonstrate Chinese/Russian/Portuguese names) and hyphens.\n *\n * @note the regex is the per-segment character set; structure (3 parts) and length are intentionally left to the callers.\n */\n\nexport const MAX_UC_OBJECT_NAME_LENGTH = 255;\n\n/**\n * Basename of the metric-view declarations file, resolved inside a\n * `config/metric-views/` folder. Single source of truth shared by the analytics\n * runtime (`plugins/analytics/mv`), the type-generator, its Vite watcher, and\n * the `generate-types` CLI. It lives in this zod-free module (rather than the\n * canonical `metric-source.ts` schema) so the type-generator can import it\n * without pulling zod into its locked dependency graph.\n */\nexport const METRIC_CONFIG_FILE = \"definitions.json\";\n\n/**\n * Matches a single, non-empty Unity Catalog object name as it may appear in a backtick-quoted (delimited) identifier.\n *\n * @example\n * UC_FQN_PATTERN.test(\"revenue_metrics\"); // true\n * UC_FQN_PATTERN.test(\"prod-data\"); // true (hyphens are UC-legal)\n * UC_FQN_PATTERN.test(\"cafe\\u0301\"); // true (non-ASCII is UC-legal)\n * UC_FQN_PATTERN.test(\"bad name\"); // false (space is prohibited)\n * UC_FQN_PATTERN.test(\"a/b\"); // false (slash is prohibited)\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: UC explicitly prohibits ASCII control characters in object names; this negated class encodes that rule.\nexport const UC_FQN_PATTERN = /^[^\\x00-\\x20\\x7f./]+$/;\n\n/** A metric view FQN is exactly three segments: catalog.schema.metric_view. */\nconst FQN_SEGMENT_COUNT = 3;\n\n/**\n * Total predicate: is `fqn` a well-formed three-part UC metric view FQN?\n * Well-formed = exactly three non-empty, dot-separated segments, each a valid Unity Catalog object name per {@link UC_FQN_PATTERN}.\n * @example\n * isValidFqn(\"main.analytics.revenue\"); // true\n * isValidFqn(\"prod-data.analytics.rev\"); // true (hyphens are UC-legal)\n * isValidFqn(\"main.analytics\"); // false (only two segments)\n */\nexport function isValidFqn(fqn: string): boolean {\n const segments = fqn.split(\".\");\n if (segments.length !== FQN_SEGMENT_COUNT) {\n return false;\n }\n return segments.every((segment) => UC_FQN_PATTERN.test(segment));\n}\n\n/**\n * Quote a dot-separated FQN for safe interpolation into a Spark/Databricks SQL\n * statement.\n *\n * Each dot-split segment is wrapped in backtick-quoted-identifier syntax. The\n * one character that can break out of a backtick-quoted identifier is the\n * backtick itself, escaped by doubling (`` ` `` → `` `` ``) — so every backtick\n * inside a segment is doubled before the segment is wrapped. Control characters\n * and newlines have no valid escape inside a quoted identifier, so a segment\n * containing one is rejected outright.\n *\n * This is a pure, standalone escaper: it is intentionally independent of FQN\n * naming validation ({@link isValidFqn}). Naming validation decides whether an\n * FQN is an acceptable metric source; this function only guarantees that\n * whatever it is handed cannot break out of the quoted identifier it produces.\n * Grammar and quoting live together here so a metric source is validated and\n * escaped against one shared source of truth.\n *\n * An ordinary identifier is unchanged apart from the wrapping backticks:\n * `catalog.schema.view` → `` `catalog`.`schema`.`view` ``.\n *\n * @param fqn - Dot-separated identifier (e.g. `catalog.schema.view`).\n * @returns The backtick-quoted, escaped identifier ready for interpolation.\n * @throws If any segment contains a control character or newline.\n */\nexport function quoteFqnForSql(fqn: string): string {\n return fqn.split(\".\").map(quoteIdentifier).join(\".\");\n}\n\n/**\n * The Unicode \"control\" category (`\\p{Cc}`): C0 (incl. `\\n`, `\\r`, `\\t`), DEL,\n * and C1 — every control character/newline. These have no valid escape inside\n * a backtick-quoted identifier, so a name containing one cannot be safely\n * quoted and is rejected.\n */\nconst CONTROL_OR_NEWLINE = /\\p{Cc}/u;\n\nexport function isValidColumnName(name: string): boolean {\n return name.length > 0 && !CONTROL_OR_NEWLINE.test(name);\n}\n\n/**\n * Quote a single identifier (one column/measure/dimension name, or one FQN\n * segment) as a backtick-delimited identifier for safe SQL interpolation.\n *\n * @throws If `name` contains a control character or newline.\n */\nexport function quoteIdentifier(name: string): string {\n if (CONTROL_OR_NEWLINE.test(name)) {\n throw new Error(\n `Cannot quote identifier \"${name}\" for SQL: it contains a control character or newline, which has no valid escape inside a backtick-quoted identifier.`,\n );\n }\n // Double every backtick, then wrap in backticks.\n return `\\`${name.replace(/`/g, \"``\")}\\``;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,4BAA4B;;;;;;;;;AAUzC,MAAa,qBAAqB;;;;;;;;;;;AAalC,MAAa,iBAAiB;;AAG9B,MAAM,oBAAoB;;;;;;;;;AAU1B,SAAgB,WAAW,KAAsB;CAC/C,MAAM,WAAW,IAAI,MAAM,IAAI;AAC/B,KAAI,SAAS,WAAW,kBACtB,QAAO;AAET,QAAO,SAAS,OAAO,YAAY,eAAe,KAAK,QAAQ,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BlE,SAAgB,eAAe,KAAqB;AAClD,QAAO,IAAI,MAAM,IAAI,CAAC,IAAI,gBAAgB,CAAC,KAAK,IAAI;;;;;;;;AAStD,MAAM,qBAAqB;AAE3B,SAAgB,kBAAkB,MAAuB;AACvD,QAAO,KAAK,SAAS,KAAK,CAAC,mBAAmB,KAAK,KAAK;;;;;;;;AAS1D,SAAgB,gBAAgB,MAAsB;AACpD,KAAI,mBAAmB,KAAK,KAAK,CAC/B,OAAM,IAAI,MACR,4BAA4B,KAAK,uHAClC;AAGH,QAAO,KAAK,KAAK,QAAQ,MAAM,KAAK,CAAC"}
@@ -0,0 +1,90 @@
1
+ import { MAX_UC_OBJECT_NAME_LENGTH, UC_FQN_PATTERN } from "./metric-fqn.js";
2
+ import { z } from "zod";
3
+
4
+ //#region ../shared/src/schemas/metric-source.ts
5
+ /**
6
+ * AppKit metric-source schema.
7
+ *
8
+ * Single source of truth for `config/metric-views/definitions.json`
9
+ * the config that activates the Analytics' metric-view path.
10
+ *
11
+ * `definitions.json` declares UC Metric Views under a single `metricViews` map.
12
+ * Each entry binds a metric key to a UC metric view FQN plus the executor
13
+ * the query runs as:
14
+ * - `executor: "app_service_principal"` (default) — queried as the app service
15
+ * principal (cache scope shared across all users).
16
+ * - `executor: "user"` — queried as the requesting user (on-behalf-of;
17
+ * cache scope per-user).
18
+ *
19
+ * A single map (rather than per-executor sections) makes metric keys unique
20
+ * by construction — the same key cannot be declared twice with different
21
+ * executors.
22
+ */
23
+ /**
24
+ * Safety cap on the number of declared metric views — a typo / DoS guard, NOT
25
+ * a Unity Catalog limit. Mirrors the type-generator's `MAX_METRIC_VIEWS` so
26
+ * runtime config validation and type generation accept exactly the same
27
+ * configs (a config that fails generation must not silently pass at runtime).
28
+ */
29
+ const MAX_METRIC_VIEWS = 200;
30
+ /**
31
+ * Whole-FQN length cap: three max-length UC object names plus the two dots.
32
+ * The per-segment cap ({@link MAX_UC_OBJECT_NAME_LENGTH}) is enforced in the
33
+ * `superRefine` below; this whole-string bound is the declarative half (it
34
+ * serializes to a JSON-schema `maxLength`, whereas the per-segment check — like
35
+ * the entry-count cap — cannot be expressed declaratively and lives in the
36
+ * refinement, so it is a runtime/type-generator gate only).
37
+ */
38
+ const MAX_FQN_LENGTH = MAX_UC_OBJECT_NAME_LENGTH * 3 + 2;
39
+ /**
40
+ * Three-part Unity Catalog FQN matcher, composed from the single-segment
41
+ * {@link UC_FQN_PATTERN} so the per-segment grammar has exactly one source of
42
+ * truth (shared by the type-generator runtime, which imports the zod-free
43
+ * {@link UC_FQN_PATTERN} directly — see `./metric-fqn.ts`).
44
+ *
45
+ * `UC_FQN_PATTERN` is `^<segment>+$`; stripping its `^`/`$` anchors yields the
46
+ * per-segment sub-pattern, which is joined with literal dots into
47
+ * `^<segment>\.<segment>\.<segment>$`. Exactly three dot-separated segments,
48
+ * each a valid UC object name. Arity (and the per-segment length cap) is also
49
+ * enforced structurally by the type-generator's `resolveMetricConfig`.
50
+ */
51
+ const UC_FQN_SEGMENT_SOURCE = UC_FQN_PATTERN.source.replace(/^\^/, "").replace(/\$$/, "");
52
+ const UC_THREE_PART_FQN_PATTERN = new RegExp(`^${UC_FQN_SEGMENT_SOURCE}\\.${UC_FQN_SEGMENT_SOURCE}\\.${UC_FQN_SEGMENT_SOURCE}$`);
53
+ const metricKeySchema = z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/).describe("Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('<key>', ...), and the MetricRegistry augmentation key.");
54
+ const metricExecutorSchema = z.enum(["app_service_principal", "user"]).describe("Who the metric view is queried as. 'app_service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache.");
55
+ /**
56
+ * @note Entries are objects (rather than bare strings) at v1 so future per-entry
57
+ * options (cacheTtl, defaultFilter, allowlists) can ship as additive
58
+ * properties without a breaking change. `executor` is the first such option.
59
+ */
60
+ const metricEntrySchema = z.object({
61
+ source: z.string().regex(UC_THREE_PART_FQN_PATTERN).max(MAX_FQN_LENGTH).describe("Three-part Unity Catalog FQN of the metric view: <catalog>.<schema>.<metric_view>").meta({ examples: ["appkit_demo.public.revenue_metrics", "main.analytics.customer_metrics"] }),
62
+ executor: metricExecutorSchema.default("app_service_principal")
63
+ }).strict().describe("A single metric view source declaration: the UC FQN to query and the executor to query it as. Future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties.");
64
+ const metricSourceSchema = z.object({
65
+ $schema: z.string().optional().describe("Reference to the JSON Schema for validation"),
66
+ metricViews: z.record(metricKeySchema, metricEntrySchema).optional().describe("Metric view declarations, keyed by metric key. Each entry names the UC metric view to query and the executor it runs as.")
67
+ }).strict().describe("Schema for AppKit config/metric-views/definitions.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metricViews' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.").superRefine((value, ctx) => {
68
+ const entries = value.metricViews ? Object.entries(value.metricViews) : [];
69
+ if (entries.length > MAX_METRIC_VIEWS) ctx.addIssue({
70
+ code: "custom",
71
+ message: `too many metric views: ${entries.length} declared, exceeding the maximum of ${MAX_METRIC_VIEWS}`,
72
+ path: ["metricViews"]
73
+ });
74
+ for (const [key, entry] of entries) {
75
+ const segments = entry.source.split(".");
76
+ for (let i = 0; i < segments.length; i++) if (segments[i].length > MAX_UC_OBJECT_NAME_LENGTH) ctx.addIssue({
77
+ code: "custom",
78
+ message: `metric source segment ${i + 1} is ${segments[i].length} characters, exceeding the per-segment maximum of ${MAX_UC_OBJECT_NAME_LENGTH}`,
79
+ path: [
80
+ "metricViews",
81
+ key,
82
+ "source"
83
+ ]
84
+ });
85
+ }
86
+ });
87
+
88
+ //#endregion
89
+ export { metricSourceSchema };
90
+ //# sourceMappingURL=metric-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metric-source.js","names":[],"sources":["../../../../../shared/src/schemas/metric-source.ts"],"sourcesContent":["/**\n * AppKit metric-source schema.\n *\n * Single source of truth for `config/metric-views/definitions.json`\n * the config that activates the Analytics' metric-view path.\n *\n * `definitions.json` declares UC Metric Views under a single `metricViews` map.\n * Each entry binds a metric key to a UC metric view FQN plus the executor\n * the query runs as:\n * - `executor: \"app_service_principal\"` (default) — queried as the app service\n * principal (cache scope shared across all users).\n * - `executor: \"user\"` — queried as the requesting user (on-behalf-of;\n * cache scope per-user).\n *\n * A single map (rather than per-executor sections) makes metric keys unique\n * by construction — the same key cannot be declared twice with different\n * executors.\n */\n\nimport { z } from \"zod\";\nimport { MAX_UC_OBJECT_NAME_LENGTH, UC_FQN_PATTERN } from \"./metric-fqn\";\n\n/**\n * Safety cap on the number of declared metric views — a typo / DoS guard, NOT\n * a Unity Catalog limit. Mirrors the type-generator's `MAX_METRIC_VIEWS` so\n * runtime config validation and type generation accept exactly the same\n * configs (a config that fails generation must not silently pass at runtime).\n */\nconst MAX_METRIC_VIEWS = 200;\n\n/**\n * Whole-FQN length cap: three max-length UC object names plus the two dots.\n * The per-segment cap ({@link MAX_UC_OBJECT_NAME_LENGTH}) is enforced in the\n * `superRefine` below; this whole-string bound is the declarative half (it\n * serializes to a JSON-schema `maxLength`, whereas the per-segment check — like\n * the entry-count cap — cannot be expressed declaratively and lives in the\n * refinement, so it is a runtime/type-generator gate only).\n */\nconst MAX_FQN_LENGTH = MAX_UC_OBJECT_NAME_LENGTH * 3 + 2;\n\n/**\n * Three-part Unity Catalog FQN matcher, composed from the single-segment\n * {@link UC_FQN_PATTERN} so the per-segment grammar has exactly one source of\n * truth (shared by the type-generator runtime, which imports the zod-free\n * {@link UC_FQN_PATTERN} directly — see `./metric-fqn.ts`).\n *\n * `UC_FQN_PATTERN` is `^<segment>+$`; stripping its `^`/`$` anchors yields the\n * per-segment sub-pattern, which is joined with literal dots into\n * `^<segment>\\.<segment>\\.<segment>$`. Exactly three dot-separated segments,\n * each a valid UC object name. Arity (and the per-segment length cap) is also\n * enforced structurally by the type-generator's `resolveMetricConfig`.\n */\nconst UC_FQN_SEGMENT_SOURCE = UC_FQN_PATTERN.source\n .replace(/^\\^/, \"\")\n .replace(/\\$$/, \"\");\nconst UC_THREE_PART_FQN_PATTERN = new RegExp(\n `^${UC_FQN_SEGMENT_SOURCE}\\\\.${UC_FQN_SEGMENT_SOURCE}\\\\.${UC_FQN_SEGMENT_SOURCE}$`,\n);\n\nexport const metricKeySchema = z\n .string()\n .regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)\n .describe(\n \"Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('<key>', ...), and the MetricRegistry augmentation key.\",\n );\n\nexport const metricExecutorSchema = z\n .enum([\"app_service_principal\", \"user\"])\n .describe(\n \"Who the metric view is queried as. 'app_service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache.\",\n );\n\n/**\n * @note Entries are objects (rather than bare strings) at v1 so future per-entry\n * options (cacheTtl, defaultFilter, allowlists) can ship as additive\n * properties without a breaking change. `executor` is the first such option.\n */\nexport const metricEntrySchema = z\n .object({\n source: z\n .string()\n .regex(UC_THREE_PART_FQN_PATTERN)\n .max(MAX_FQN_LENGTH)\n .describe(\n \"Three-part Unity Catalog FQN of the metric view: <catalog>.<schema>.<metric_view>\",\n )\n .meta({\n examples: [\n \"appkit_demo.public.revenue_metrics\",\n \"main.analytics.customer_metrics\",\n ],\n }),\n executor: metricExecutorSchema.default(\"app_service_principal\"),\n })\n .strict()\n .describe(\n \"A single metric view source declaration: the UC FQN to query and the executor to query it as. Future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties.\",\n );\n\nexport const metricSourceSchema = z\n .object({\n $schema: z\n .string()\n .optional()\n .describe(\"Reference to the JSON Schema for validation\"),\n metricViews: z\n .record(metricKeySchema, metricEntrySchema)\n .optional()\n .describe(\n \"Metric view declarations, keyed by metric key. Each entry names the UC metric view to query and the executor it runs as.\",\n ),\n })\n .strict()\n .describe(\n \"Schema for AppKit config/metric-views/definitions.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metricViews' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.\",\n )\n // Caps that cannot be expressed declaratively (zod 4's `z.record` has no\n // `.max`, and a per-dot-segment length bound isn't a whole-string\n // `maxLength`). Enforced here so runtime config validation matches the\n // type-generator's `resolveMetricConfig` exactly — a config that fails type\n // generation must not silently pass at runtime. These refinements are\n // invisible to `z.toJSONSchema`, so the generated JSON schema carries only\n // the declarative `maxLength` on `source`; runtime + type-generator remain\n // the authoritative gates for the entry-count and per-segment caps.\n .superRefine((value, ctx) => {\n const entries = value.metricViews ? Object.entries(value.metricViews) : [];\n\n if (entries.length > MAX_METRIC_VIEWS) {\n ctx.addIssue({\n code: \"custom\",\n message: `too many metric views: ${entries.length} declared, exceeding the maximum of ${MAX_METRIC_VIEWS}`,\n path: [\"metricViews\"],\n });\n }\n\n for (const [key, entry] of entries) {\n const segments = entry.source.split(\".\");\n for (let i = 0; i < segments.length; i++) {\n if (segments[i].length > MAX_UC_OBJECT_NAME_LENGTH) {\n ctx.addIssue({\n code: \"custom\",\n message: `metric source segment ${i + 1} is ${segments[i].length} characters, exceeding the per-segment maximum of ${MAX_UC_OBJECT_NAME_LENGTH}`,\n path: [\"metricViews\", key, \"source\"],\n });\n }\n }\n }\n });\n\nexport type MetricKey = z.infer<typeof metricKeySchema>;\nexport type MetricExecutor = z.infer<typeof metricExecutorSchema>;\nexport type MetricEntry = z.infer<typeof metricEntrySchema>;\nexport type MetricSource = z.infer<typeof metricSourceSchema>;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAM,mBAAmB;;;;;;;;;AAUzB,MAAM,iBAAiB,4BAA4B,IAAI;;;;;;;;;;;;;AAcvD,MAAM,wBAAwB,eAAe,OAC1C,QAAQ,OAAO,GAAG,CAClB,QAAQ,OAAO,GAAG;AACrB,MAAM,4BAA4B,IAAI,OACpC,IAAI,sBAAsB,KAAK,sBAAsB,KAAK,sBAAsB,GACjF;AAED,MAAa,kBAAkB,EAC5B,QAAQ,CACR,MAAM,2BAA2B,CACjC,SACC,yPACD;AAEH,MAAa,uBAAuB,EACjC,KAAK,CAAC,yBAAyB,OAAO,CAAC,CACvC,SACC,mNACD;;;;;;AAOH,MAAa,oBAAoB,EAC9B,OAAO;CACN,QAAQ,EACL,QAAQ,CACR,MAAM,0BAA0B,CAChC,IAAI,eAAe,CACnB,SACC,oFACD,CACA,KAAK,EACJ,UAAU,CACR,sCACA,kCACD,EACF,CAAC;CACJ,UAAU,qBAAqB,QAAQ,wBAAwB;CAChE,CAAC,CACD,QAAQ,CACR,SACC,4LACD;AAEH,MAAa,qBAAqB,EAC/B,OAAO;CACN,SAAS,EACN,QAAQ,CACR,UAAU,CACV,SAAS,8CAA8C;CAC1D,aAAa,EACV,OAAO,iBAAiB,kBAAkB,CAC1C,UAAU,CACV,SACC,2HACD;CACJ,CAAC,CACD,QAAQ,CACR,SACC,4ZACD,CASA,aAAa,OAAO,QAAQ;CAC3B,MAAM,UAAU,MAAM,cAAc,OAAO,QAAQ,MAAM,YAAY,GAAG,EAAE;AAE1E,KAAI,QAAQ,SAAS,iBACnB,KAAI,SAAS;EACX,MAAM;EACN,SAAS,0BAA0B,QAAQ,OAAO,sCAAsC;EACxF,MAAM,CAAC,cAAc;EACtB,CAAC;AAGJ,MAAK,MAAM,CAAC,KAAK,UAAU,SAAS;EAClC,MAAM,WAAW,MAAM,OAAO,MAAM,IAAI;AACxC,OAAK,IAAI,IAAI,GAAG,IAAI,SAAS,QAAQ,IACnC,KAAI,SAAS,GAAG,SAAS,0BACvB,KAAI,SAAS;GACX,MAAM;GACN,SAAS,yBAAyB,IAAI,EAAE,MAAM,SAAS,GAAG,OAAO,oDAAoD;GACrH,MAAM;IAAC;IAAe;IAAK;IAAS;GACrC,CAAC;;EAIR"}
@@ -94,5 +94,5 @@ var EventRingBuffer = class {
94
94
  };
95
95
 
96
96
  //#endregion
97
- export { EventRingBuffer, RingBuffer };
97
+ export { EventRingBuffer };
98
98
  //# sourceMappingURL=buffers.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"buffers.js","names":[],"sources":["../../src/stream/buffers.ts"],"sourcesContent":["import { ValidationError } from \"../errors\";\nimport type { BufferedEvent } from \"./types\";\n\n// generic ring buffer implementation\nexport class RingBuffer<T> {\n public buffer: (T | null)[];\n public capacity: number;\n private writeIndex: number;\n private size: number;\n private keyExtractor: (item: T) => string;\n private keyIndex: Map<string, number>;\n\n constructor(capacity: number, keyExtractor: (item: T) => string) {\n if (capacity <= 0) {\n throw ValidationError.invalidValue(\n \"capacity\",\n capacity,\n \"greater than 0\",\n );\n }\n\n this.capacity = capacity;\n this.buffer = new Array(capacity).fill(null);\n this.writeIndex = 0;\n this.size = 0;\n this.keyExtractor = keyExtractor;\n this.keyIndex = new Map();\n }\n\n // add an item to the buffer\n add(item: T): void {\n const key = this.keyExtractor(item);\n\n // check if item already exists\n const existingIndex = this.keyIndex.get(key);\n if (existingIndex !== undefined) {\n // update existing item\n this.buffer[existingIndex] = item;\n return;\n }\n\n // evict least recently used item if at capacity\n const evicted = this.buffer[this.writeIndex];\n if (evicted !== null) {\n const evictedKey = this.keyExtractor(evicted);\n this.keyIndex.delete(evictedKey);\n }\n\n // add new item\n this.buffer[this.writeIndex] = item;\n this.keyIndex.set(key, this.writeIndex);\n\n // update write index and size\n this.writeIndex = (this.writeIndex + 1) % this.capacity;\n this.size = Math.min(this.size + 1, this.capacity);\n }\n\n // get an item from the buffer\n get(key: string): T | null {\n const index = this.keyIndex.get(key);\n if (index === undefined) return null;\n\n return this.buffer[index];\n }\n\n // check if an item exists in the buffer\n has(key: string): boolean {\n return this.keyIndex.has(key);\n }\n\n // remove an item from the buffer\n remove(key: string): void {\n const index = this.keyIndex.get(key);\n if (index === undefined) return;\n\n // remove item from buffer\n this.buffer[index] = null;\n this.keyIndex.delete(key);\n\n // update size\n this.size = Math.max(this.size - 1, 0);\n }\n\n // get all items from the buffer\n getAll(): T[] {\n const result: T[] = [];\n\n // iterate over buffer in order of insertion\n for (let i = 0; i < this.size; i++) {\n // calculate index of item in buffer\n const index =\n (this.writeIndex - this.size + i + this.capacity) % this.capacity;\n // add item to result if not null\n const item = this.buffer[index];\n if (item !== null) {\n result.push(item);\n }\n }\n return result;\n }\n\n // get the size of the buffer\n getSize(): number {\n return this.size;\n }\n\n // clear the buffer\n clear(): void {\n this.buffer = new Array(this.capacity).fill(null);\n this.keyIndex.clear();\n this.writeIndex = 0;\n this.size = 0;\n }\n}\n\n// event ring buffer implementation\nexport class EventRingBuffer {\n private buffer: RingBuffer<BufferedEvent>;\n\n constructor(capacity: number = 100) {\n this.buffer = new RingBuffer<BufferedEvent>(capacity, (event) => event.id);\n }\n\n // add an event to the buffer\n add(event: BufferedEvent): void {\n this.buffer.add(event);\n }\n\n // check if an event exists in the buffer\n has(eventId: string): boolean {\n return this.buffer.has(eventId);\n }\n\n // get all events since a given event id\n getEventsSince(lastEventId: string): BufferedEvent[] {\n const allEvents = this.buffer.getAll();\n const result: BufferedEvent[] = [];\n // flag to track if we've found the last event\n let foundLastEvent = false;\n\n // iterate over all events\n for (const event of allEvents) {\n // if found, add to result\n if (foundLastEvent) {\n result.push(event);\n // if not found, check if it's the last event\n } else if (event.id === lastEventId) {\n foundLastEvent = true;\n }\n }\n return result;\n }\n\n clear(): void {\n this.buffer.clear();\n }\n}\n"],"mappings":";;;;AAIA,IAAa,aAAb,MAA2B;CACzB,AAAO;CACP,AAAO;CACP,AAAQ;CACR,AAAQ;CACR,AAAQ;CACR,AAAQ;CAER,YAAY,UAAkB,cAAmC;AAC/D,MAAI,YAAY,EACd,OAAM,gBAAgB,aACpB,YACA,UACA,iBACD;AAGH,OAAK,WAAW;AAChB,OAAK,SAAS,IAAI,MAAM,SAAS,CAAC,KAAK,KAAK;AAC5C,OAAK,aAAa;AAClB,OAAK,OAAO;AACZ,OAAK,eAAe;AACpB,OAAK,2BAAW,IAAI,KAAK;;CAI3B,IAAI,MAAe;EACjB,MAAM,MAAM,KAAK,aAAa,KAAK;EAGnC,MAAM,gBAAgB,KAAK,SAAS,IAAI,IAAI;AAC5C,MAAI,kBAAkB,QAAW;AAE/B,QAAK,OAAO,iBAAiB;AAC7B;;EAIF,MAAM,UAAU,KAAK,OAAO,KAAK;AACjC,MAAI,YAAY,MAAM;GACpB,MAAM,aAAa,KAAK,aAAa,QAAQ;AAC7C,QAAK,SAAS,OAAO,WAAW;;AAIlC,OAAK,OAAO,KAAK,cAAc;AAC/B,OAAK,SAAS,IAAI,KAAK,KAAK,WAAW;AAGvC,OAAK,cAAc,KAAK,aAAa,KAAK,KAAK;AAC/C,OAAK,OAAO,KAAK,IAAI,KAAK,OAAO,GAAG,KAAK,SAAS;;CAIpD,IAAI,KAAuB;EACzB,MAAM,QAAQ,KAAK,SAAS,IAAI,IAAI;AACpC,MAAI,UAAU,OAAW,QAAO;AAEhC,SAAO,KAAK,OAAO;;CAIrB,IAAI,KAAsB;AACxB,SAAO,KAAK,SAAS,IAAI,IAAI;;CAI/B,OAAO,KAAmB;EACxB,MAAM,QAAQ,KAAK,SAAS,IAAI,IAAI;AACpC,MAAI,UAAU,OAAW;AAGzB,OAAK,OAAO,SAAS;AACrB,OAAK,SAAS,OAAO,IAAI;AAGzB,OAAK,OAAO,KAAK,IAAI,KAAK,OAAO,GAAG,EAAE;;CAIxC,SAAc;EACZ,MAAM,SAAc,EAAE;AAGtB,OAAK,IAAI,IAAI,GAAG,IAAI,KAAK,MAAM,KAAK;GAElC,MAAM,SACH,KAAK,aAAa,KAAK,OAAO,IAAI,KAAK,YAAY,KAAK;GAE3D,MAAM,OAAO,KAAK,OAAO;AACzB,OAAI,SAAS,KACX,QAAO,KAAK,KAAK;;AAGrB,SAAO;;CAIT,UAAkB;AAChB,SAAO,KAAK;;CAId,QAAc;AACZ,OAAK,SAAS,IAAI,MAAM,KAAK,SAAS,CAAC,KAAK,KAAK;AACjD,OAAK,SAAS,OAAO;AACrB,OAAK,aAAa;AAClB,OAAK,OAAO;;;AAKhB,IAAa,kBAAb,MAA6B;CAC3B,AAAQ;CAER,YAAY,WAAmB,KAAK;AAClC,OAAK,SAAS,IAAI,WAA0B,WAAW,UAAU,MAAM,GAAG;;CAI5E,IAAI,OAA4B;AAC9B,OAAK,OAAO,IAAI,MAAM;;CAIxB,IAAI,SAA0B;AAC5B,SAAO,KAAK,OAAO,IAAI,QAAQ;;CAIjC,eAAe,aAAsC;EACnD,MAAM,YAAY,KAAK,OAAO,QAAQ;EACtC,MAAM,SAA0B,EAAE;EAElC,IAAI,iBAAiB;AAGrB,OAAK,MAAM,SAAS,UAElB,KAAI,eACF,QAAO,KAAK,MAAM;WAET,MAAM,OAAO,YACtB,kBAAiB;AAGrB,SAAO;;CAGT,QAAc;AACZ,OAAK,OAAO,OAAO"}
1
+ {"version":3,"file":"buffers.js","names":[],"sources":["../../src/stream/buffers.ts"],"sourcesContent":["import { ValidationError } from \"../errors\";\nimport type { BufferedEvent } from \"./types\";\n\n// generic ring buffer implementation\nclass RingBuffer<T> {\n public buffer: (T | null)[];\n public capacity: number;\n private writeIndex: number;\n private size: number;\n private keyExtractor: (item: T) => string;\n private keyIndex: Map<string, number>;\n\n constructor(capacity: number, keyExtractor: (item: T) => string) {\n if (capacity <= 0) {\n throw ValidationError.invalidValue(\n \"capacity\",\n capacity,\n \"greater than 0\",\n );\n }\n\n this.capacity = capacity;\n this.buffer = new Array(capacity).fill(null);\n this.writeIndex = 0;\n this.size = 0;\n this.keyExtractor = keyExtractor;\n this.keyIndex = new Map();\n }\n\n // add an item to the buffer\n add(item: T): void {\n const key = this.keyExtractor(item);\n\n // check if item already exists\n const existingIndex = this.keyIndex.get(key);\n if (existingIndex !== undefined) {\n // update existing item\n this.buffer[existingIndex] = item;\n return;\n }\n\n // evict least recently used item if at capacity\n const evicted = this.buffer[this.writeIndex];\n if (evicted !== null) {\n const evictedKey = this.keyExtractor(evicted);\n this.keyIndex.delete(evictedKey);\n }\n\n // add new item\n this.buffer[this.writeIndex] = item;\n this.keyIndex.set(key, this.writeIndex);\n\n // update write index and size\n this.writeIndex = (this.writeIndex + 1) % this.capacity;\n this.size = Math.min(this.size + 1, this.capacity);\n }\n\n // get an item from the buffer\n get(key: string): T | null {\n const index = this.keyIndex.get(key);\n if (index === undefined) return null;\n\n return this.buffer[index];\n }\n\n // check if an item exists in the buffer\n has(key: string): boolean {\n return this.keyIndex.has(key);\n }\n\n // remove an item from the buffer\n remove(key: string): void {\n const index = this.keyIndex.get(key);\n if (index === undefined) return;\n\n // remove item from buffer\n this.buffer[index] = null;\n this.keyIndex.delete(key);\n\n // update size\n this.size = Math.max(this.size - 1, 0);\n }\n\n // get all items from the buffer\n getAll(): T[] {\n const result: T[] = [];\n\n // iterate over buffer in order of insertion\n for (let i = 0; i < this.size; i++) {\n // calculate index of item in buffer\n const index =\n (this.writeIndex - this.size + i + this.capacity) % this.capacity;\n // add item to result if not null\n const item = this.buffer[index];\n if (item !== null) {\n result.push(item);\n }\n }\n return result;\n }\n\n // get the size of the buffer\n getSize(): number {\n return this.size;\n }\n\n // clear the buffer\n clear(): void {\n this.buffer = new Array(this.capacity).fill(null);\n this.keyIndex.clear();\n this.writeIndex = 0;\n this.size = 0;\n }\n}\n\n// event ring buffer implementation\nexport class EventRingBuffer {\n private buffer: RingBuffer<BufferedEvent>;\n\n constructor(capacity: number = 100) {\n this.buffer = new RingBuffer<BufferedEvent>(capacity, (event) => event.id);\n }\n\n // add an event to the buffer\n add(event: BufferedEvent): void {\n this.buffer.add(event);\n }\n\n // check if an event exists in the buffer\n has(eventId: string): boolean {\n return this.buffer.has(eventId);\n }\n\n // get all events since a given event id\n getEventsSince(lastEventId: string): BufferedEvent[] {\n const allEvents = this.buffer.getAll();\n const result: BufferedEvent[] = [];\n // flag to track if we've found the last event\n let foundLastEvent = false;\n\n // iterate over all events\n for (const event of allEvents) {\n // if found, add to result\n if (foundLastEvent) {\n result.push(event);\n // if not found, check if it's the last event\n } else if (event.id === lastEventId) {\n foundLastEvent = true;\n }\n }\n return result;\n }\n\n clear(): void {\n this.buffer.clear();\n }\n}\n"],"mappings":";;;;AAIA,IAAM,aAAN,MAAoB;CAClB,AAAO;CACP,AAAO;CACP,AAAQ;CACR,AAAQ;CACR,AAAQ;CACR,AAAQ;CAER,YAAY,UAAkB,cAAmC;AAC/D,MAAI,YAAY,EACd,OAAM,gBAAgB,aACpB,YACA,UACA,iBACD;AAGH,OAAK,WAAW;AAChB,OAAK,SAAS,IAAI,MAAM,SAAS,CAAC,KAAK,KAAK;AAC5C,OAAK,aAAa;AAClB,OAAK,OAAO;AACZ,OAAK,eAAe;AACpB,OAAK,2BAAW,IAAI,KAAK;;CAI3B,IAAI,MAAe;EACjB,MAAM,MAAM,KAAK,aAAa,KAAK;EAGnC,MAAM,gBAAgB,KAAK,SAAS,IAAI,IAAI;AAC5C,MAAI,kBAAkB,QAAW;AAE/B,QAAK,OAAO,iBAAiB;AAC7B;;EAIF,MAAM,UAAU,KAAK,OAAO,KAAK;AACjC,MAAI,YAAY,MAAM;GACpB,MAAM,aAAa,KAAK,aAAa,QAAQ;AAC7C,QAAK,SAAS,OAAO,WAAW;;AAIlC,OAAK,OAAO,KAAK,cAAc;AAC/B,OAAK,SAAS,IAAI,KAAK,KAAK,WAAW;AAGvC,OAAK,cAAc,KAAK,aAAa,KAAK,KAAK;AAC/C,OAAK,OAAO,KAAK,IAAI,KAAK,OAAO,GAAG,KAAK,SAAS;;CAIpD,IAAI,KAAuB;EACzB,MAAM,QAAQ,KAAK,SAAS,IAAI,IAAI;AACpC,MAAI,UAAU,OAAW,QAAO;AAEhC,SAAO,KAAK,OAAO;;CAIrB,IAAI,KAAsB;AACxB,SAAO,KAAK,SAAS,IAAI,IAAI;;CAI/B,OAAO,KAAmB;EACxB,MAAM,QAAQ,KAAK,SAAS,IAAI,IAAI;AACpC,MAAI,UAAU,OAAW;AAGzB,OAAK,OAAO,SAAS;AACrB,OAAK,SAAS,OAAO,IAAI;AAGzB,OAAK,OAAO,KAAK,IAAI,KAAK,OAAO,GAAG,EAAE;;CAIxC,SAAc;EACZ,MAAM,SAAc,EAAE;AAGtB,OAAK,IAAI,IAAI,GAAG,IAAI,KAAK,MAAM,KAAK;GAElC,MAAM,SACH,KAAK,aAAa,KAAK,OAAO,IAAI,KAAK,YAAY,KAAK;GAE3D,MAAM,OAAO,KAAK,OAAO;AACzB,OAAI,SAAS,KACX,QAAO,KAAK,KAAK;;AAGrB,SAAO;;CAIT,UAAkB;AAChB,SAAO,KAAK;;CAId,QAAc;AACZ,OAAK,SAAS,IAAI,MAAM,KAAK,SAAS,CAAC,KAAK,KAAK;AACjD,OAAK,SAAS,OAAO;AACrB,OAAK,aAAa;AAClB,OAAK,OAAO;;;AAKhB,IAAa,kBAAb,MAA6B;CAC3B,AAAQ;CAER,YAAY,WAAmB,KAAK;AAClC,OAAK,SAAS,IAAI,WAA0B,WAAW,UAAU,MAAM,GAAG;;CAI5E,IAAI,OAA4B;AAC9B,OAAK,OAAO,IAAI,MAAM;;CAIxB,IAAI,SAA0B;AAC5B,SAAO,KAAK,OAAO,IAAI,QAAQ;;CAIjC,eAAe,aAAsC;EACnD,MAAM,YAAY,KAAK,OAAO,QAAQ;EACtC,MAAM,SAA0B,EAAE;EAElC,IAAI,iBAAiB;AAGrB,OAAK,MAAM,SAAS,UAElB,KAAI,eACF,QAAO,KAAK,MAAM;WAET,MAAM,OAAO,YACtB,kBAAiB;AAGrB,SAAO;;CAGT,QAAc;AACZ,OAAK,OAAO,OAAO"}
@@ -3,8 +3,6 @@ const streamDefaults = {
3
3
  bufferSize: 100,
4
4
  maxEventSize: 1 * 1024 * 1024,
5
5
  bufferTTL: 600 * 1e3,
6
- cleanupInterval: 300 * 1e3,
7
- maxPersistentBuffers: 1e4,
8
6
  heartbeatInterval: 10 * 1e3,
9
7
  maxActiveStreams: 1e3,
10
8
  disconnectGraceMs: 15e3
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.js","names":[],"sources":["../../src/stream/defaults.ts"],"sourcesContent":["export const streamDefaults = {\n bufferSize: 100,\n // 1 MiB. SSE carries only short JSON control messages — JSON_ARRAY result\n // rows (already row-size-bounded by the warehouse) plus warehouse-readiness\n // and error events. ARROW_STREAM never uses SSE: the raw Arrow IPC bytes\n // stream back on the query response body (`_handleArrowStreamQuery`).\n maxEventSize: 1 * 1024 * 1024,\n bufferTTL: 10 * 60 * 1000, // 10 minutes\n cleanupInterval: 5 * 60 * 1000, // 5 minutes\n maxPersistentBuffers: 10000, // 10000 buffers\n heartbeatInterval: 10 * 1000, // 10 seconds\n maxActiveStreams: 1000, // 1000 streams\n disconnectGraceMs: 15_000, // 15 seconds\n} as const;\n"],"mappings":";AAAA,MAAa,iBAAiB;CAC5B,YAAY;CAKZ,cAAc,IAAI,OAAO;CACzB,WAAW,MAAU;CACrB,iBAAiB,MAAS;CAC1B,sBAAsB;CACtB,mBAAmB,KAAK;CACxB,kBAAkB;CAClB,mBAAmB;CACpB"}
1
+ {"version":3,"file":"defaults.js","names":[],"sources":["../../src/stream/defaults.ts"],"sourcesContent":["export const streamDefaults = {\n bufferSize: 100,\n // 1 MiB. SSE carries only short JSON control messages — JSON_ARRAY result\n // rows (already row-size-bounded by the warehouse) plus warehouse-readiness\n // and error events. ARROW_STREAM never uses SSE: the raw Arrow IPC bytes\n // stream back on the query response body (`_handleArrowStreamQuery`).\n maxEventSize: 1 * 1024 * 1024,\n bufferTTL: 10 * 60 * 1000, // 10 minutes\n heartbeatInterval: 10 * 1000, // 10 seconds\n maxActiveStreams: 1000, // 1000 streams\n disconnectGraceMs: 15_000, // 15 seconds\n} as const;\n"],"mappings":";AAAA,MAAa,iBAAiB;CAC5B,YAAY;CAKZ,cAAc,IAAI,OAAO;CACzB,WAAW,MAAU;CACrB,mBAAmB,KAAK;CACxB,kBAAkB;CAClB,mBAAmB;CACpB"}
@@ -20,10 +20,10 @@ declare class StreamManager {
20
20
  private _combineSignals;
21
21
  private _broadcastEventsToClients;
22
22
  private _broadcastErrorToClients;
23
+ private _finalizeStream;
23
24
  private _closeAllClients;
24
25
  private _scheduleGraceAbort;
25
- private _clearGraceTimer;
26
- private _cleanupStream;
26
+ private _scheduleRemovalAfterTTL;
27
27
  private _categorizeError;
28
28
  }
29
29
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"stream-manager.d.ts","names":[],"sources":["../../src/stream/stream-manager.ts"],"mappings":";;;;;cAgBa,aAAA;EAAA,QACH,gBAAA;EAAA,QACA,cAAA;EAAA,QACA,SAAA;EAAA,QACA,YAAA;EAAA,QACA,SAAA;EAAA,QACA,iBAAA;cAEI,OAAA,GAAU,YAAA;EAahB,MAAA,CACJ,GAAA,EAAK,YAAA,EACL,OAAA,GAAU,MAAA,EAAQ,WAAA,KAAgB,cAAA,sBAClC,OAAA,GAAU,YAAA,EACV,QAAA,YACC,OAAA;EAsCH,QAAA,CAAA;EAaA,cAAA,CAAA;EAAA,QAKc,uBAAA;EAAA,QA4EA,gBAAA;EAAA,QA4EA,6BAAA;EAAA,QAkHN,eAAA;EAAA,QA6BA,yBAAA;EAAA,QAaA,wBAAA;EAAA,QAyBA,gBAAA;EAAA,QASA,mBAAA;EAAA,QAmBA,gBAAA;EAAA,QAQA,cAAA;EAAA,QAUA,gBAAA;AAAA"}
1
+ {"version":3,"file":"stream-manager.d.ts","names":[],"sources":["../../src/stream/stream-manager.ts"],"mappings":";;;;;cAiBa,aAAA;EAAA,QACH,gBAAA;EAAA,QACA,cAAA;EAAA,QACA,SAAA;EAAA,QACA,YAAA;EAAA,QACA,SAAA;EAAA,QACA,iBAAA;cAEI,OAAA,GAAU,YAAA;EAahB,MAAA,CACJ,GAAA,EAAK,YAAA,EACL,OAAA,GAAU,MAAA,EAAQ,WAAA,KAAgB,cAAA,sBAClC,OAAA,GAAU,YAAA,EACV,QAAA,YACC,OAAA;EAsCH,QAAA,CAAA;EAaA,cAAA,CAAA;EAAA,QAKc,uBAAA;EAAA,QA+EA,gBAAA;EAAA,QAiFA,6BAAA;EAAA,QAwGN,eAAA;EAAA,QA6BA,yBAAA;EAAA,QAaA,wBAAA;EAAA,QAwBA,eAAA;EAAA,QAYA,gBAAA;EAAA,QAUA,mBAAA;EAAA,QAuBA,wBAAA;EAAA,QA8BA,gBAAA;AAAA"}
@@ -6,6 +6,7 @@ import { streamDefaults } from "./defaults.js";
6
6
  import { SSEErrorCode } from "./types.js";
7
7
  import { StreamValidator } from "./validator.js";
8
8
  import { SSEWriter } from "./sse-writer.js";
9
+ import { clearGraceTimer, clearRemovalTimer } from "./timers.js";
9
10
  import { StreamRegistry } from "./stream-registry.js";
10
11
  import { randomUUID } from "node:crypto";
11
12
  import { context } from "@opentelemetry/api";
@@ -67,7 +68,8 @@ var StreamManager = class {
67
68
  }
68
69
  } else this.sseWriter.writeBufferOverflowWarning(res, validEventId);
69
70
  }
70
- this._clearGraceTimer(streamEntry);
71
+ clearGraceTimer(streamEntry);
72
+ clearRemovalTimer(streamEntry);
71
73
  streamEntry.clients.add(res);
72
74
  streamEntry.lastAccess = Date.now();
73
75
  const combinedSignal = this._combineSignals(streamEntry.abortController.signal, options?.userSignal);
@@ -83,14 +85,14 @@ var StreamManager = class {
83
85
  streamEntry.clients.delete(res);
84
86
  this.activeOperations.delete(streamOperation);
85
87
  if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) this._scheduleGraceAbort(streamEntry);
86
- if (streamEntry.isCompleted && streamEntry.clients.size === 0) setTimeout(() => {
87
- if (streamEntry.clients.size === 0) this.streamRegistry.remove(streamEntry.streamId);
88
- }, this.bufferTTL);
88
+ this._scheduleRemovalAfterTTL(streamEntry);
89
89
  });
90
90
  if (streamEntry.isCompleted) {
91
91
  res.end();
92
92
  this.activeOperations.delete(streamOperation);
93
93
  clearInterval(heartbeat);
94
+ streamEntry.clients.delete(res);
95
+ this._scheduleRemovalAfterTTL(streamEntry);
94
96
  }
95
97
  }
96
98
  async _createNewStream(res, handler, options, ownerKey) {
@@ -128,6 +130,7 @@ var StreamManager = class {
128
130
  this.activeOperations.delete(streamOperation);
129
131
  streamEntry.clients.delete(res);
130
132
  if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) this._scheduleGraceAbort(streamEntry);
133
+ this._scheduleRemovalAfterTTL(streamEntry);
131
134
  });
132
135
  await this._processGeneratorInBackground(streamEntry);
133
136
  clearInterval(heartbeat);
@@ -155,10 +158,7 @@ var StreamManager = class {
155
158
  this._broadcastEventsToClients(streamEntry, eventId, event);
156
159
  streamEntry.lastAccess = Date.now();
157
160
  }
158
- streamEntry.isCompleted = true;
159
- this._clearGraceTimer(streamEntry);
160
- this._closeAllClients(streamEntry);
161
- this._cleanupStream(streamEntry);
161
+ this._finalizeStream(streamEntry);
162
162
  } catch (error) {
163
163
  const rawMsg = error instanceof Error ? error.message : "Internal server error";
164
164
  const clientMsg = error instanceof AppKitError ? error.clientMessage : "Internal server error";
@@ -167,10 +167,7 @@ var StreamManager = class {
167
167
  const errorCode = this._categorizeError(error);
168
168
  if (errorCode === SSEErrorCode.STREAM_ABORTED) {
169
169
  logger.info("Stream aborted by client (code=%s)", errorCode);
170
- streamEntry.isCompleted = true;
171
- this._clearGraceTimer(streamEntry);
172
- this._closeAllClients(streamEntry);
173
- this._cleanupStream(streamEntry);
170
+ this._finalizeStream(streamEntry);
174
171
  return;
175
172
  }
176
173
  logger.error("Stream execution failed: %s (code=%s upstreamCode=%s)", rawMsg, errorCode, upstreamCode ?? "n/a");
@@ -186,8 +183,7 @@ var StreamManager = class {
186
183
  timestamp: Date.now()
187
184
  });
188
185
  this._broadcastErrorToClients(streamEntry, errorEventId, clientMsg, errorCode, true, upstreamCode);
189
- streamEntry.isCompleted = true;
190
- this._clearGraceTimer(streamEntry);
186
+ this._finalizeStream(streamEntry);
191
187
  }
192
188
  });
193
189
  }
@@ -215,11 +211,18 @@ var StreamManager = class {
215
211
  if (closeClients) client.end();
216
212
  }
217
213
  }
214
+ _finalizeStream(streamEntry) {
215
+ streamEntry.isCompleted = true;
216
+ clearGraceTimer(streamEntry);
217
+ this._closeAllClients(streamEntry);
218
+ this._scheduleRemovalAfterTTL(streamEntry);
219
+ }
218
220
  _closeAllClients(streamEntry) {
219
221
  for (const client of streamEntry.clients) if (!client.writableEnded) client.end();
222
+ streamEntry.clients.clear();
220
223
  }
221
224
  _scheduleGraceAbort(streamEntry) {
222
- this._clearGraceTimer(streamEntry);
225
+ clearGraceTimer(streamEntry);
223
226
  const timer = setTimeout(() => {
224
227
  streamEntry.disconnectGraceTimer = void 0;
225
228
  if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) streamEntry.abortController.abort(new DOMException("Client disconnected (grace expired)", "AbortError"));
@@ -227,16 +230,15 @@ var StreamManager = class {
227
230
  timer.unref?.();
228
231
  streamEntry.disconnectGraceTimer = timer;
229
232
  }
230
- _clearGraceTimer(streamEntry) {
231
- if (streamEntry.disconnectGraceTimer) {
232
- clearTimeout(streamEntry.disconnectGraceTimer);
233
- streamEntry.disconnectGraceTimer = void 0;
234
- }
235
- }
236
- _cleanupStream(streamEntry) {
237
- if (streamEntry.clients.size === 0) setTimeout(() => {
238
- if (streamEntry.clients.size === 0) this.streamRegistry.remove(streamEntry.streamId);
233
+ _scheduleRemovalAfterTTL(streamEntry) {
234
+ if (!streamEntry.isCompleted || streamEntry.clients.size > 0) return;
235
+ clearRemovalTimer(streamEntry);
236
+ streamEntry.lastAccess = Date.now();
237
+ streamEntry.removalTimer = setTimeout(() => {
238
+ streamEntry.removalTimer = void 0;
239
+ if (streamEntry.clients.size === 0 && Date.now() - streamEntry.lastAccess >= this.bufferTTL) this.streamRegistry.remove(streamEntry.streamId);
239
240
  }, this.bufferTTL);
241
+ streamEntry.removalTimer.unref?.();
240
242
  }
241
243
  _categorizeError(error) {
242
244
  if (error instanceof Error) {