@superblocksteam/shared 0.9604.2 → 0.9605.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 (185) hide show
  1. package/dist/constants.d.ts +3 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +11 -1
  4. package/dist/constants.js.map +1 -1
  5. package/dist/database-lifecycle/capacityPolicy.d.ts +8 -1
  6. package/dist/database-lifecycle/capacityPolicy.d.ts.map +1 -1
  7. package/dist/database-lifecycle/capacityPolicy.js +8 -1
  8. package/dist/database-lifecycle/capacityPolicy.js.map +1 -1
  9. package/dist/database-lifecycle/destructiveMigrations.d.ts +26 -0
  10. package/dist/database-lifecycle/destructiveMigrations.d.ts.map +1 -0
  11. package/dist/database-lifecycle/destructiveMigrations.js +89 -0
  12. package/dist/database-lifecycle/destructiveMigrations.js.map +1 -0
  13. package/dist/database-lifecycle/destructiveMigrations.test.d.ts +2 -0
  14. package/dist/database-lifecycle/destructiveMigrations.test.d.ts.map +1 -0
  15. package/dist/database-lifecycle/destructiveMigrations.test.js +100 -0
  16. package/dist/database-lifecycle/destructiveMigrations.test.js.map +1 -0
  17. package/dist/database-lifecycle/index.d.ts +6 -1
  18. package/dist/database-lifecycle/index.d.ts.map +1 -1
  19. package/dist/database-lifecycle/index.js +20 -1
  20. package/dist/database-lifecycle/index.js.map +1 -1
  21. package/dist/socket/protocol.d.ts +1 -0
  22. package/dist/socket/protocol.d.ts.map +1 -1
  23. package/dist/socket/socket.d.ts +12 -0
  24. package/dist/socket/socket.d.ts.map +1 -1
  25. package/dist/socket/socket.js +66 -9
  26. package/dist/socket/socket.js.map +1 -1
  27. package/dist/socket/socket.test.js +78 -0
  28. package/dist/socket/socket.test.js.map +1 -1
  29. package/dist/socket/socketWithClientAuth.d.ts +1 -0
  30. package/dist/socket/socketWithClientAuth.d.ts.map +1 -1
  31. package/dist/socket/socketWithClientAuth.js.map +1 -1
  32. package/dist/socket/tracedSocket.d.ts +1 -0
  33. package/dist/socket/tracedSocket.d.ts.map +1 -1
  34. package/dist/socket/tracedSocket.js.map +1 -1
  35. package/dist/types/audit/ocsf.d.ts.map +1 -1
  36. package/dist/types/audit/ocsf.js +14 -1
  37. package/dist/types/audit/ocsf.js.map +1 -1
  38. package/dist/types/billing/pricingModel.d.ts +29 -0
  39. package/dist/types/billing/pricingModel.d.ts.map +1 -1
  40. package/dist/types/billing/pricingModel.js +32 -0
  41. package/dist/types/billing/pricingModel.js.map +1 -1
  42. package/dist/types/billing/pricingModel.test.js +26 -0
  43. package/dist/types/billing/pricingModel.test.js.map +1 -1
  44. package/dist/types/datasource/auth.d.ts +12 -1
  45. package/dist/types/datasource/auth.d.ts.map +1 -1
  46. package/dist/types/datasource/auth.js +17 -1
  47. package/dist/types/datasource/auth.js.map +1 -1
  48. package/dist/types/datasource/auth.test.js +44 -0
  49. package/dist/types/datasource/auth.test.js.map +1 -1
  50. package/dist/types/plugin/form.d.ts +1 -0
  51. package/dist/types/plugin/form.d.ts.map +1 -1
  52. package/dist/types/plugin/form.js.map +1 -1
  53. package/dist/types/publishPipeline/displayNames.d.ts +14 -0
  54. package/dist/types/publishPipeline/displayNames.d.ts.map +1 -0
  55. package/dist/types/publishPipeline/displayNames.js +84 -0
  56. package/dist/types/publishPipeline/displayNames.js.map +1 -0
  57. package/dist/types/publishPipeline/displayNames.test.d.ts +2 -0
  58. package/dist/types/publishPipeline/displayNames.test.d.ts.map +1 -0
  59. package/dist/types/publishPipeline/displayNames.test.js +77 -0
  60. package/dist/types/publishPipeline/displayNames.test.js.map +1 -0
  61. package/dist/types/publishPipeline/index.d.ts +1 -0
  62. package/dist/types/publishPipeline/index.d.ts.map +1 -1
  63. package/dist/types/publishPipeline/index.js +1 -0
  64. package/dist/types/publishPipeline/index.js.map +1 -1
  65. package/dist/types/publishPipeline/plan.d.ts.map +1 -1
  66. package/dist/types/publishPipeline/plan.js +2 -9
  67. package/dist/types/publishPipeline/plan.js.map +1 -1
  68. package/dist/types/rbac/index.d.ts +2 -2
  69. package/dist/types/rbac/index.d.ts.map +1 -1
  70. package/dist/types/rbac/index.js +2 -2
  71. package/dist/types/rbac/index.js.map +1 -1
  72. package/dist/types/reviewPolicy/index.d.ts +39 -9
  73. package/dist/types/reviewPolicy/index.d.ts.map +1 -1
  74. package/dist/types/reviewPolicy/index.js +34 -5
  75. package/dist/types/reviewPolicy/index.js.map +1 -1
  76. package/dist/types/reviewPolicy/index.test.js +10 -0
  77. package/dist/types/reviewPolicy/index.test.js.map +1 -1
  78. package/dist/types/scim/index.d.ts +1 -0
  79. package/dist/types/scim/index.d.ts.map +1 -1
  80. package/dist/types/scim/index.js.map +1 -1
  81. package/dist-esm/constants.d.ts +3 -0
  82. package/dist-esm/constants.d.ts.map +1 -1
  83. package/dist-esm/constants.js +10 -0
  84. package/dist-esm/constants.js.map +1 -1
  85. package/dist-esm/database-lifecycle/capacityPolicy.d.ts +8 -1
  86. package/dist-esm/database-lifecycle/capacityPolicy.d.ts.map +1 -1
  87. package/dist-esm/database-lifecycle/capacityPolicy.js +8 -1
  88. package/dist-esm/database-lifecycle/capacityPolicy.js.map +1 -1
  89. package/dist-esm/database-lifecycle/destructiveMigrations.d.ts +26 -0
  90. package/dist-esm/database-lifecycle/destructiveMigrations.d.ts.map +1 -0
  91. package/dist-esm/database-lifecycle/destructiveMigrations.js +85 -0
  92. package/dist-esm/database-lifecycle/destructiveMigrations.js.map +1 -0
  93. package/dist-esm/database-lifecycle/destructiveMigrations.test.d.ts +2 -0
  94. package/dist-esm/database-lifecycle/destructiveMigrations.test.d.ts.map +1 -0
  95. package/dist-esm/database-lifecycle/destructiveMigrations.test.js +98 -0
  96. package/dist-esm/database-lifecycle/destructiveMigrations.test.js.map +1 -0
  97. package/dist-esm/database-lifecycle/index.d.ts +6 -1
  98. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  99. package/dist-esm/database-lifecycle/index.js +15 -0
  100. package/dist-esm/database-lifecycle/index.js.map +1 -1
  101. package/dist-esm/socket/protocol.d.ts +1 -0
  102. package/dist-esm/socket/protocol.d.ts.map +1 -1
  103. package/dist-esm/socket/socket.d.ts +12 -0
  104. package/dist-esm/socket/socket.d.ts.map +1 -1
  105. package/dist-esm/socket/socket.js +65 -8
  106. package/dist-esm/socket/socket.js.map +1 -1
  107. package/dist-esm/socket/socket.test.js +79 -1
  108. package/dist-esm/socket/socket.test.js.map +1 -1
  109. package/dist-esm/socket/socketWithClientAuth.d.ts +1 -0
  110. package/dist-esm/socket/socketWithClientAuth.d.ts.map +1 -1
  111. package/dist-esm/socket/socketWithClientAuth.js.map +1 -1
  112. package/dist-esm/socket/tracedSocket.d.ts +1 -0
  113. package/dist-esm/socket/tracedSocket.d.ts.map +1 -1
  114. package/dist-esm/socket/tracedSocket.js.map +1 -1
  115. package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
  116. package/dist-esm/types/audit/ocsf.js +14 -1
  117. package/dist-esm/types/audit/ocsf.js.map +1 -1
  118. package/dist-esm/types/billing/pricingModel.d.ts +29 -0
  119. package/dist-esm/types/billing/pricingModel.d.ts.map +1 -1
  120. package/dist-esm/types/billing/pricingModel.js +31 -0
  121. package/dist-esm/types/billing/pricingModel.js.map +1 -1
  122. package/dist-esm/types/billing/pricingModel.test.js +27 -1
  123. package/dist-esm/types/billing/pricingModel.test.js.map +1 -1
  124. package/dist-esm/types/datasource/auth.d.ts +12 -1
  125. package/dist-esm/types/datasource/auth.d.ts.map +1 -1
  126. package/dist-esm/types/datasource/auth.js +15 -1
  127. package/dist-esm/types/datasource/auth.js.map +1 -1
  128. package/dist-esm/types/datasource/auth.test.js +45 -1
  129. package/dist-esm/types/datasource/auth.test.js.map +1 -1
  130. package/dist-esm/types/plugin/form.d.ts +1 -0
  131. package/dist-esm/types/plugin/form.d.ts.map +1 -1
  132. package/dist-esm/types/plugin/form.js.map +1 -1
  133. package/dist-esm/types/publishPipeline/displayNames.d.ts +14 -0
  134. package/dist-esm/types/publishPipeline/displayNames.d.ts.map +1 -0
  135. package/dist-esm/types/publishPipeline/displayNames.js +80 -0
  136. package/dist-esm/types/publishPipeline/displayNames.js.map +1 -0
  137. package/dist-esm/types/publishPipeline/displayNames.test.d.ts +2 -0
  138. package/dist-esm/types/publishPipeline/displayNames.test.d.ts.map +1 -0
  139. package/dist-esm/types/publishPipeline/displayNames.test.js +75 -0
  140. package/dist-esm/types/publishPipeline/displayNames.test.js.map +1 -0
  141. package/dist-esm/types/publishPipeline/index.d.ts +1 -0
  142. package/dist-esm/types/publishPipeline/index.d.ts.map +1 -1
  143. package/dist-esm/types/publishPipeline/index.js +1 -0
  144. package/dist-esm/types/publishPipeline/index.js.map +1 -1
  145. package/dist-esm/types/publishPipeline/plan.d.ts.map +1 -1
  146. package/dist-esm/types/publishPipeline/plan.js +2 -9
  147. package/dist-esm/types/publishPipeline/plan.js.map +1 -1
  148. package/dist-esm/types/rbac/index.d.ts +2 -2
  149. package/dist-esm/types/rbac/index.d.ts.map +1 -1
  150. package/dist-esm/types/rbac/index.js +2 -2
  151. package/dist-esm/types/rbac/index.js.map +1 -1
  152. package/dist-esm/types/reviewPolicy/index.d.ts +39 -9
  153. package/dist-esm/types/reviewPolicy/index.d.ts.map +1 -1
  154. package/dist-esm/types/reviewPolicy/index.js +31 -4
  155. package/dist-esm/types/reviewPolicy/index.js.map +1 -1
  156. package/dist-esm/types/reviewPolicy/index.test.js +11 -1
  157. package/dist-esm/types/reviewPolicy/index.test.js.map +1 -1
  158. package/dist-esm/types/scim/index.d.ts +1 -0
  159. package/dist-esm/types/scim/index.d.ts.map +1 -1
  160. package/dist-esm/types/scim/index.js.map +1 -1
  161. package/package.json +1 -1
  162. package/src/constants.ts +11 -0
  163. package/src/database-lifecycle/capacityPolicy.ts +8 -1
  164. package/src/database-lifecycle/destructiveMigrations.test.ts +116 -0
  165. package/src/database-lifecycle/destructiveMigrations.ts +105 -0
  166. package/src/database-lifecycle/index.ts +27 -9
  167. package/src/socket/protocol.ts +7 -1
  168. package/src/socket/socket.test.ts +88 -1
  169. package/src/socket/socket.ts +81 -9
  170. package/src/socket/socketWithClientAuth.ts +1 -0
  171. package/src/socket/tracedSocket.ts +1 -0
  172. package/src/types/audit/ocsf.ts +14 -1
  173. package/src/types/billing/pricingModel.test.ts +31 -1
  174. package/src/types/billing/pricingModel.ts +33 -0
  175. package/src/types/datasource/auth.test.ts +60 -1
  176. package/src/types/datasource/auth.ts +24 -3
  177. package/src/types/plugin/form.ts +1 -0
  178. package/src/types/publishPipeline/displayNames.test.ts +88 -0
  179. package/src/types/publishPipeline/displayNames.ts +101 -0
  180. package/src/types/publishPipeline/index.ts +1 -0
  181. package/src/types/publishPipeline/plan.ts +2 -10
  182. package/src/types/rbac/index.ts +2 -2
  183. package/src/types/reviewPolicy/index.test.ts +19 -1
  184. package/src/types/reviewPolicy/index.ts +68 -13
  185. package/src/types/scim/index.ts +3 -0
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { analyzeDestructiveMigrations } from './destructiveMigrations.js';
4
+
5
+ describe('analyzeDestructiveMigrations', () => {
6
+ test('returns empty when SQL has no destructive statements', () => {
7
+ expect(
8
+ analyzeDestructiveMigrations([
9
+ {
10
+ fileName: '0001_init.sql',
11
+ sql: 'CREATE TABLE users (id uuid PRIMARY KEY);\nALTER TABLE users ADD COLUMN email text;'
12
+ }
13
+ ])
14
+ ).toEqual([]);
15
+ });
16
+
17
+ test('flags DROP TABLE', () => {
18
+ const findings = analyzeDestructiveMigrations([{ fileName: '0002_drop_users.sql', sql: 'DROP TABLE users;' }]);
19
+ expect(findings).toEqual([
20
+ {
21
+ fileName: '0002_drop_users.sql',
22
+ kind: 'drop_table',
23
+ excerpt: expect.stringMatching(/DROP TABLE users/i)
24
+ }
25
+ ]);
26
+ });
27
+
28
+ test('flags DROP DATABASE, DROP SCHEMA, TRUNCATE, and DROP COLUMN', () => {
29
+ const findings = analyzeDestructiveMigrations([
30
+ {
31
+ fileName: '0003_multi.sql',
32
+ sql: `
33
+ DROP DATABASE IF EXISTS old_db;
34
+ DROP SCHEMA public CASCADE;
35
+ TRUNCATE TABLE events;
36
+ ALTER TABLE users DROP COLUMN email;
37
+ `
38
+ }
39
+ ]);
40
+ expect(findings.map((f) => f.kind).sort()).toEqual(['drop_column', 'drop_database', 'drop_schema', 'truncate']);
41
+ });
42
+
43
+ test('flags ALTER TABLE … DROP col without the COLUMN keyword', () => {
44
+ // Postgres treats COLUMN as optional: ALTER TABLE t DROP c;
45
+ const findings = analyzeDestructiveMigrations([{ fileName: '0003b_drop_col_short.sql', sql: 'ALTER TABLE users DROP email;' }]);
46
+ expect(findings).toHaveLength(1);
47
+ expect(findings[0]?.kind).toBe('drop_column');
48
+ });
49
+
50
+ test('flags standalone DROP COLUMN without ALTER prefix', () => {
51
+ const findings = analyzeDestructiveMigrations([{ fileName: '0004_drop_col.sql', sql: 'DROP COLUMN orphaned;' }]);
52
+ expect(findings).toHaveLength(1);
53
+ expect(findings[0]?.kind).toBe('drop_column');
54
+ });
55
+
56
+ test('ignores DROP INDEX, DROP VIEW, DROP CONSTRAINT, and non-column ALTER', () => {
57
+ expect(
58
+ analyzeDestructiveMigrations([
59
+ {
60
+ fileName: '0005_safe.sql',
61
+ sql: `
62
+ DROP INDEX users_email_idx;
63
+ DROP VIEW user_summary;
64
+ ALTER TABLE users DROP CONSTRAINT users_email_key;
65
+ ALTER TABLE users DROP INDEX users_email_idx;
66
+ ALTER TABLE users ADD COLUMN name text;
67
+ `
68
+ }
69
+ ])
70
+ ).toEqual([]);
71
+ });
72
+
73
+ test('ignores ALTER COLUMN DROP NOT NULL / DROP DEFAULT / DROP EXPRESSION (not column drops)', () => {
74
+ expect(
75
+ analyzeDestructiveMigrations([
76
+ {
77
+ fileName: '0005b_safe_alter.sql',
78
+ sql: `
79
+ ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
80
+ ALTER TABLE users ALTER COLUMN status DROP DEFAULT;
81
+ ALTER TABLE users ALTER COLUMN full_name DROP EXPRESSION;
82
+ `
83
+ }
84
+ ])
85
+ ).toEqual([]);
86
+ });
87
+
88
+ test('does not flag DELETE FROM (data DML is out of the schema-destroying set)', () => {
89
+ expect(analyzeDestructiveMigrations([{ fileName: '0005c_delete.sql', sql: 'DELETE FROM users WHERE id = 1;' }])).toEqual([]);
90
+ });
91
+
92
+ test('strips line and block comments before matching', () => {
93
+ expect(
94
+ analyzeDestructiveMigrations([
95
+ {
96
+ fileName: '0006_commented.sql',
97
+ sql: `
98
+ -- DROP TABLE users;
99
+ /* DROP DATABASE doomed; */
100
+ CREATE TABLE users (id int);
101
+ `
102
+ }
103
+ ])
104
+ ).toEqual([]);
105
+ });
106
+
107
+ test('is case-insensitive and finds multiple statements across files', () => {
108
+ const findings = analyzeDestructiveMigrations([
109
+ { fileName: 'a.sql', sql: 'drop table IF EXISTS foo;' },
110
+ { fileName: 'b.sql', sql: 'Truncate events;' }
111
+ ]);
112
+ expect(findings).toHaveLength(2);
113
+ expect(findings[0]).toMatchObject({ fileName: 'a.sql', kind: 'drop_table' });
114
+ expect(findings[1]).toMatchObject({ fileName: 'b.sql', kind: 'truncate' });
115
+ });
116
+ });
@@ -0,0 +1,105 @@
1
+ export const DESTRUCTIVE_MIGRATION_ACK_REQUIRED = 'DESTRUCTIVE_MIGRATION_ACK_REQUIRED' as const;
2
+ /**
3
+ * Deploy rejection message for unacknowledged destructive migrations. The
4
+ * client only receives the message text (the classified error code does not
5
+ * survive HTTP error serialization), so the UI matches on this to know a
6
+ * blocked publish can be retried with an acknowledgment.
7
+ */
8
+ export const DESTRUCTIVE_MIGRATION_ACK_REQUIRED_MESSAGE =
9
+ 'Destructive schema changes require acknowledgment before migrations can run' as const;
10
+ /** Publish plan: migration files could not be scanned for destructive SQL. */
11
+ export const DESTRUCTIVE_MIGRATION_SCAN_UNAVAILABLE = 'DESTRUCTIVE_MIGRATION_SCAN_UNAVAILABLE' as const;
12
+
13
+ export type DestructiveMigrationKind = 'drop_table' | 'drop_database' | 'drop_schema' | 'truncate' | 'drop_column';
14
+
15
+ export type DestructiveMigrationFinding = {
16
+ fileName: string;
17
+ kind: DestructiveMigrationKind;
18
+ excerpt: string;
19
+ };
20
+
21
+ export type DestructiveMigrationFile = {
22
+ fileName: string;
23
+ sql: string;
24
+ };
25
+
26
+ const MAX_EXCERPT_LENGTH = 160;
27
+
28
+ /**
29
+ * Schema-destroying statements that require explicit user acknowledgment.
30
+ *
31
+ * Intentionally out of scope (do not expand without a product decision):
32
+ * - `DELETE FROM` / other DML data loss
33
+ * - `DROP MATERIALIZED VIEW` / `DROP SEQUENCE` / `DROP TYPE` / `DROP VIEW`
34
+ * - Dynamic SQL inside dollar-quoted function bodies
35
+ * - Comment/string-literal edge cases in `stripSqlComments` (markers inside
36
+ * string literals can hide a following statement on the same line)
37
+ */
38
+ /** Patterns are checked in order; first match wins per statement. */
39
+ const DESTRUCTIVE_PATTERNS: Array<{ kind: DestructiveMigrationKind; re: RegExp }> = [
40
+ { kind: 'drop_table', re: /^\s*DROP\s+TABLE\b/i },
41
+ { kind: 'drop_database', re: /^\s*DROP\s+DATABASE\b/i },
42
+ { kind: 'drop_schema', re: /^\s*DROP\s+SCHEMA\b/i },
43
+ { kind: 'truncate', re: /^\s*TRUNCATE\b/i },
44
+ // ALTER … DROP [COLUMN] col — COLUMN is optional in Postgres. Exclude
45
+ // DROP CONSTRAINT / INDEX / TRIGGER / RULE / POLICY / STATISTICS / IDENTITY
46
+ // and ALTER COLUMN … DROP NOT NULL / DEFAULT / EXPRESSION (not column drops).
47
+ {
48
+ kind: 'drop_column',
49
+ re: /^\s*ALTER\s+TABLE\b[\s\S]*?\bDROP\s+(?:COLUMN\s+)?(?!CONSTRAINT\b|INDEX\b|TRIGGER\b|RULE\b|POLICY\b|STATISTICS\b|IDENTITY\b|NOT\b|DEFAULT\b|EXPRESSION\b)["\w]/i
50
+ },
51
+ { kind: 'drop_column', re: /^\s*DROP\s+COLUMN\b/i }
52
+ ];
53
+
54
+ function stripSqlComments(sql: string): string {
55
+ // Block comments first, then line comments. Good enough for migration files;
56
+ // does not try to handle comment markers inside string literals.
57
+ return sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
58
+ }
59
+
60
+ function splitStatements(sql: string): string[] {
61
+ return sql
62
+ .split(';')
63
+ .map((part) => part.trim())
64
+ .filter((part) => part.length > 0);
65
+ }
66
+
67
+ function excerptFor(statement: string): string {
68
+ const collapsed = statement.replace(/\s+/g, ' ').trim();
69
+ if (collapsed.length <= MAX_EXCERPT_LENGTH) {
70
+ return collapsed;
71
+ }
72
+ return `${collapsed.slice(0, MAX_EXCERPT_LENGTH - 1)}…`;
73
+ }
74
+
75
+ function classifyStatement(statement: string): DestructiveMigrationKind | undefined {
76
+ for (const { kind, re } of DESTRUCTIVE_PATTERNS) {
77
+ if (re.test(statement)) {
78
+ return kind;
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ /**
85
+ * Scans migration SQL for schema-destroying statements that require an
86
+ * explicit user acknowledgment before apply/deploy.
87
+ */
88
+ export function analyzeDestructiveMigrations(files: DestructiveMigrationFile[]): DestructiveMigrationFinding[] {
89
+ const findings: DestructiveMigrationFinding[] = [];
90
+ for (const file of files) {
91
+ const statements = splitStatements(stripSqlComments(file.sql));
92
+ for (const statement of statements) {
93
+ const kind = classifyStatement(statement);
94
+ if (kind === undefined) {
95
+ continue;
96
+ }
97
+ findings.push({
98
+ fileName: file.fileName,
99
+ kind,
100
+ excerpt: excerptFor(statement)
101
+ });
102
+ }
103
+ }
104
+ return findings;
105
+ }
@@ -70,19 +70,22 @@ export type LifecycleNonTerminalState = (typeof LIFECYCLE_NON_TERMINAL_STATES)[n
70
70
  export type LifecycleState = (typeof LIFECYCLE_STATES)[number];
71
71
  export type LifecycleMigrationState = (typeof LIFECYCLE_MIGRATION_STATES)[number];
72
72
 
73
- export type LifecycleErrorCode =
74
- | 'unsupported_provider_capability'
75
- | 'backend_locked'
76
- | 'policy_blocked'
77
- | 'credential_resolution_failed'
78
- | 'terraform_failed'
79
- | 'migration_failed'
80
- | 'callback_failed'
73
+ export const LIFECYCLE_ERROR_CODES = [
74
+ 'unsupported_provider_capability',
75
+ 'backend_locked',
76
+ 'policy_blocked',
77
+ 'credential_resolution_failed',
78
+ 'terraform_failed',
79
+ 'migration_failed',
80
+ 'callback_failed',
81
81
  // Server-side classification for a terminal callback that arrived for a
82
82
  // request whose desiredSpecHash no longer matches the binding (binding has
83
83
  // moved to a newer spec). The callback's metadata/state is discarded and
84
84
  // the request is cancelled rather than applied to the current binding.
85
- | 'stale_dispatch';
85
+ 'stale_dispatch'
86
+ ] as const;
87
+
88
+ export type LifecycleErrorCode = (typeof LIFECYCLE_ERROR_CODES)[number];
86
89
 
87
90
  export type PhysicalMasterCredentialRef = {
88
91
  resolver: 'aws_secrets_manager';
@@ -170,6 +173,7 @@ export type PhysicalDatabaseInstance = {
170
173
  organizationId: string;
171
174
  region: string;
172
175
  engine: DatabaseEngine; // postgres-only in V1
176
+ provisionResourceKey: string;
173
177
  endpoint: string;
174
178
  masterCredentialRef: PhysicalMasterCredentialRef;
175
179
  capacityMax: number;
@@ -195,11 +199,25 @@ export type LifecycleMigration = {
195
199
  sql: string;
196
200
  };
197
201
 
202
+ export {
203
+ analyzeDestructiveMigrations,
204
+ DESTRUCTIVE_MIGRATION_ACK_REQUIRED,
205
+ DESTRUCTIVE_MIGRATION_ACK_REQUIRED_MESSAGE,
206
+ DESTRUCTIVE_MIGRATION_SCAN_UNAVAILABLE
207
+ } from './destructiveMigrations.js';
208
+ export type { DestructiveMigrationFile, DestructiveMigrationFinding, DestructiveMigrationKind } from './destructiveMigrations.js';
209
+
198
210
  export type LifecycleDispatchContinuation = {
199
211
  currentState?: string;
200
212
  physicalInstanceId?: string;
201
213
  physicalTerraformResourceKey?: string;
202
214
  reservationId?: string;
215
+ // An operator's assertion that the worker holding this binding's Terraform
216
+ // state lock is gone, which is the only thing that lets the next worker
217
+ // break that lock. Set by the admin retry endpoint and cleared by anything
218
+ // that reopens the request on a timer, which cannot tell a dead worker from
219
+ // a slow one.
220
+ stateLockRecoveryAuthorized?: boolean;
203
221
  };
204
222
 
205
223
  // Wizard-facing view of a physical pool provisioning run. Narrower than
@@ -240,7 +240,13 @@ export interface ServerMethods {
240
240
  targetTemplateName?: string;
241
241
  migrationGeneration?: number;
242
242
  },
243
- { hash: string; degradedMode?: DegradedMode; commitId?: string; checkpointSkipped?: CheckpointSkipReason }
243
+ {
244
+ hash: string;
245
+ degradedMode?: DegradedMode;
246
+ commitId?: string;
247
+ commitMessage?: string;
248
+ checkpointSkipped?: CheckpointSkipReason;
249
+ }
244
250
  >;
245
251
  };
246
252
  directoryContents: {
@@ -1,12 +1,58 @@
1
1
  import { trace } from '@opentelemetry/api';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
 
4
- import { INTENTIONAL_CLOSE_CODE, ISocket } from './socket.js';
4
+ import { INTENTIONAL_CLOSE_CODE, ISocket, REQUEST_ABORTED_SOCKET_ERROR } from './socket.js';
5
5
  import { ISocketWithClientAuth } from './socketWithClientAuth.js';
6
6
 
7
7
  const PING_TIMEOUT_CLOSE_REASON = 'Ping timeout';
8
8
 
9
9
  describe('ISocket', () => {
10
+ it('clears abort cleanup when a no-response timeout rejects the request', async () => {
11
+ vi.useFakeTimers();
12
+ try {
13
+ const controller = new AbortController();
14
+ const addEventListener = vi.spyOn(controller.signal, 'addEventListener');
15
+ const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener');
16
+ const ws = {
17
+ addEventListener: vi.fn(),
18
+ close: vi.fn(),
19
+ send: vi.fn()
20
+ };
21
+ const socket = new ISocket(ws as never, {}, [], {
22
+ onClose: vi.fn(),
23
+ getAbortSignal: () => controller.signal,
24
+ timeouts: { noResponseTimeoutInSeconds: 1 }
25
+ });
26
+ const pendingRequest = socket.request('editor.ping', {});
27
+ expect(addEventListener).toHaveBeenCalledWith('abort', expect.any(Function), { once: true });
28
+
29
+ const assertion = expect(pendingRequest).rejects.toMatchObject({ code: 7 });
30
+ await vi.advanceTimersByTimeAsync(1000);
31
+ await assertion;
32
+ expect(removeEventListener).toHaveBeenCalled();
33
+ } finally {
34
+ vi.useRealTimers();
35
+ }
36
+ });
37
+
38
+ it('rejects a pending request when getAbortSignal returns an aborted signal', async () => {
39
+ const ws = {
40
+ addEventListener: vi.fn(),
41
+ close: vi.fn(),
42
+ send: vi.fn()
43
+ };
44
+ const controller = new AbortController();
45
+ const socket = new ISocket(ws as never, {}, [], {
46
+ onClose: vi.fn(),
47
+ getAbortSignal: () => controller.signal
48
+ });
49
+ const pendingRequest = socket.request('editor.ping', {});
50
+
51
+ controller.abort();
52
+
53
+ await expect(pendingRequest).rejects.toMatchObject(REQUEST_ABORTED_SOCKET_ERROR);
54
+ });
55
+
10
56
  it('preserves the transport-close marker when rejecting pending requests with a reason', async () => {
11
57
  const ws = {
12
58
  addEventListener: vi.fn(),
@@ -24,6 +70,47 @@ describe('ISocket', () => {
24
70
  });
25
71
  expect(ws.close).toHaveBeenCalledWith(INTENTIONAL_CLOSE_CODE, PING_TIMEOUT_CLOSE_REASON);
26
72
  });
73
+
74
+ it('rejects a request whose signal is already aborted before it is sent', async () => {
75
+ const ws = {
76
+ addEventListener: vi.fn(),
77
+ close: vi.fn(),
78
+ send: vi.fn()
79
+ };
80
+ const controller = new AbortController();
81
+ const socket = new ISocket(ws as never, {}, [], { onClose: vi.fn(), getAbortSignal: () => controller.signal });
82
+ controller.abort();
83
+
84
+ await expect(socket.request('editor.ping', {})).rejects.toMatchObject(REQUEST_ABORTED_SOCKET_ERROR);
85
+ expect(ws.send).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('ignores a late response after an aborted request', async () => {
89
+ let messageHandler: ((event: { data: string }) => void) | undefined;
90
+ const ws = {
91
+ addEventListener: vi.fn((event: string, handler: (event: { data: string }) => void) => {
92
+ if (event === 'message') {
93
+ messageHandler = handler;
94
+ }
95
+ }),
96
+ close: vi.fn(),
97
+ send: vi.fn()
98
+ };
99
+ const controller = new AbortController();
100
+ const socket = new ISocket(ws as never, {}, [], { onClose: vi.fn(), getAbortSignal: () => controller.signal });
101
+ const pendingRequest = socket.request('editor.ping', {});
102
+
103
+ controller.abort();
104
+ await expect(pendingRequest).rejects.toMatchObject(REQUEST_ABORTED_SOCKET_ERROR);
105
+
106
+ expect(() => {
107
+ messageHandler?.({
108
+ data: JSON.stringify({
109
+ response: { id: 1, payload: 'too-late', error: null }
110
+ })
111
+ });
112
+ }).not.toThrow();
113
+ });
27
114
  });
28
115
 
29
116
  describe('ISocketWithClientAuth', () => {
@@ -10,6 +10,13 @@ import {
10
10
  type GenericMiddleware
11
11
  } from './types.js';
12
12
 
13
+ export const REQUEST_ABORTED_SOCKET_ERROR = {
14
+ code: 9,
15
+ message: '[internal] Request aborted',
16
+ /** Distinguishes a locally synthesized abort from a server SocketError with code 9. */
17
+ aborted: true as const
18
+ } as const;
19
+
13
20
  interface SocketRequest<Payload = unknown> {
14
21
  method: string;
15
22
  payload: Payload;
@@ -60,7 +67,12 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
60
67
  private readonly globalMiddlewares: GenericMiddleware<CallableMethods, RequestContext>[];
61
68
  private readonly responseHandler = new Map<
62
69
  number,
63
- { resolve: (data: unknown) => void; reject: (error: SocketError) => void; timeout?: NodeJS.Timeout }
70
+ {
71
+ resolve: (data: unknown) => void;
72
+ reject: (error: SocketError) => void;
73
+ timeout?: NodeJS.Timeout;
74
+ abortCleanup?: () => void;
75
+ }
64
76
  >();
65
77
  private peerAuthorization?: string;
66
78
  protected nxtRequestId: number;
@@ -69,6 +81,7 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
69
81
  protected logger: { error: (message?: string) => void; info: (message?: string) => void; warn: (message?: string) => void };
70
82
  private timeouts?: SocketTimeouts;
71
83
  private isClosed = false;
84
+ private readonly getAbortSignal?: () => AbortSignal | undefined;
72
85
 
73
86
  constructor(
74
87
  ws: WebSocket,
@@ -87,9 +100,12 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
87
100
  onClose: (event: WebSocket.CloseEvent) => void;
88
101
  timeouts?: SocketTimeouts;
89
102
  logger?: { error: (message?: string) => void; info: (message?: string) => void; warn: (message?: string) => void };
103
+ /** Read at request time so server-side callers can supply a per-call signal without Node ALS in this package. */
104
+ getAbortSignal?: () => AbortSignal | undefined;
90
105
  }
91
106
  ) {
92
107
  this.ws = ws;
108
+ this.getAbortSignal = options.getAbortSignal;
93
109
  this.requestHandlers = requestHandlers;
94
110
  this.globalMiddlewares = globalMiddlewares;
95
111
  this.nxtRequestId = 0;
@@ -122,6 +138,7 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
122
138
  // which shouldn't be possible
123
139
  this.responseHandler.forEach((handler, key) => {
124
140
  clearTimeout(handler.timeout);
141
+ handler.abortCleanup?.();
125
142
  this.logger.error(`Rejecting pending requestId ${key} due to connection close`);
126
143
  handler.reject({ code: 8, message: '[internal] Connection closed' });
127
144
  });
@@ -207,11 +224,12 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
207
224
  }
208
225
  if (message.response.error) {
209
226
  responseHandler.reject(message.response.error);
227
+ this.clearPendingRequestState(message.response.id);
228
+ return;
210
229
  }
211
230
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
231
  responseHandler.resolve(message.response.payload as any);
213
- clearTimeout(responseHandler.timeout);
214
- this.responseHandler.delete(message.response.id);
232
+ this.clearPendingRequestState(message.response.id);
215
233
  } else {
216
234
  return this.respondError(-1, {
217
235
  code: 3,
@@ -242,6 +260,10 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
242
260
  resolve: (result) => resolve(result as Result),
243
261
  reject: (error: SocketError) => reject(error)
244
262
  });
263
+ this.attachAbortListener(requestId, this.getAbortSignal?.());
264
+ if (!this.responseHandler.has(requestId)) {
265
+ return;
266
+ }
245
267
  let toSend: SocketMessage = { request: { method, payload: params, id: requestId, setAuthorization: authorization } };
246
268
  toSend = this.decorateToSend(toSend);
247
269
  this.ws.send(JSON.stringify(toSend));
@@ -265,6 +287,10 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
265
287
  resolve: (result) => resolve(result as Result),
266
288
  reject: (error: SocketError) => reject(error)
267
289
  });
290
+ this.attachAbortListener(requestId, this.getAbortSignal?.());
291
+ if (!this.responseHandler.has(requestId)) {
292
+ return;
293
+ }
268
294
  let toSend: SocketMessage = {
269
295
  request: {
270
296
  method,
@@ -281,6 +307,53 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
281
307
  });
282
308
  }
283
309
 
310
+ private attachAbortListener(requestId: number, abortSignal?: AbortSignal): void {
311
+ if (!abortSignal) {
312
+ return;
313
+ }
314
+
315
+ const onAbort = () => {
316
+ this.logger.warn(`Rejecting pending requestId ${requestId} due to abort`);
317
+ this.rejectPendingRequest(requestId, REQUEST_ABORTED_SOCKET_ERROR);
318
+ };
319
+
320
+ if (abortSignal.aborted) {
321
+ onAbort();
322
+ return;
323
+ }
324
+
325
+ const handler = this.responseHandler.get(requestId);
326
+ if (!handler) {
327
+ return;
328
+ }
329
+
330
+ abortSignal.addEventListener('abort', onAbort, { once: true });
331
+ handler.abortCleanup = () => {
332
+ abortSignal.removeEventListener('abort', onAbort);
333
+ };
334
+ }
335
+
336
+ private clearPendingRequestState(requestId: number): void {
337
+ const responseHandler = this.responseHandler.get(requestId);
338
+ if (!responseHandler) {
339
+ return;
340
+ }
341
+ clearTimeout(responseHandler.timeout);
342
+ responseHandler.abortCleanup?.();
343
+ this.responseHandler.delete(requestId);
344
+ }
345
+
346
+ private rejectPendingRequest(requestId: number, error: SocketError): void {
347
+ const responseHandler = this.responseHandler.get(requestId);
348
+ if (!responseHandler) {
349
+ return;
350
+ }
351
+ clearTimeout(responseHandler.timeout);
352
+ responseHandler.abortCleanup?.();
353
+ this.responseHandler.delete(requestId);
354
+ responseHandler.reject(error);
355
+ }
356
+
284
357
  protected decorateToSend(message: SocketMessage): SocketMessage {
285
358
  return message;
286
359
  }
@@ -333,20 +406,18 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
333
406
  if (!responseHandler) {
334
407
  return;
335
408
  }
336
- const noResponseTimeout = responseHandler.timeout;
337
- const reject = responseHandler.reject;
338
409
  if (responseHandler.timeout) {
339
- clearTimeout(noResponseTimeout);
410
+ clearTimeout(responseHandler.timeout);
340
411
  }
341
412
  // Set a new timeout for the next message
342
- responseHandler.timeout = setTimeout(this.handleNoResponseTimeout(reject), this.timeouts?.noResponseTimeoutInSeconds * 1000);
413
+ responseHandler.timeout = setTimeout(this.handleNoResponseTimeout(requestId), this.timeouts?.noResponseTimeoutInSeconds * 1000);
343
414
  }
344
415
 
345
- private handleNoResponseTimeout(reject: (error: SocketError) => void) {
416
+ private handleNoResponseTimeout(requestId: number) {
346
417
  return () => {
347
418
  const message = `Request timed out after ${this.timeouts?.noResponseTimeoutInSeconds} seconds`;
348
419
  this.logger.error(message);
349
- reject({ code: 7, message });
420
+ this.rejectPendingRequest(requestId, { code: 7, message });
350
421
  };
351
422
  }
352
423
 
@@ -359,6 +430,7 @@ export class ISocket<ImplementedMethods, CallableMethods, RequestContext extends
359
430
  public closeAndRejectPendingRequests(reason?: string): void {
360
431
  this.responseHandler.forEach((handler, key) => {
361
432
  clearTimeout(handler.timeout);
433
+ handler.abortCleanup?.();
362
434
  this.logger.error(`Rejecting pending requestId ${key} due to intentional connection close`);
363
435
  handler.reject({ code: 8, message: reason ? `${CONNECTION_CLOSED_MESSAGE}: ${reason}` : CONNECTION_CLOSED_MESSAGE });
364
436
  });
@@ -27,6 +27,7 @@ export class ISocketWithClientAuth<ImplementedMethods, CallableMethods, RequestC
27
27
  onClose: (event: WebSocket.CloseEvent) => void;
28
28
  timeouts?: SocketTimeouts;
29
29
  logger?: { error: (message?: string) => void; info: (message?: string) => void; warn: (message?: string) => void };
30
+ getAbortSignal?: () => AbortSignal | undefined;
30
31
  }
31
32
  ) {
32
33
  super(ws, requestHandlers, globalMiddlewares, tracer, options);
@@ -114,6 +114,7 @@ export class TracedSocket<ImplementedMethods, CallableMethods, RequestContext ex
114
114
  onClose: (event: WebSocket.CloseEvent) => void;
115
115
  timeouts?: SocketTimeouts;
116
116
  logger?: { error: (message?: string) => void; info: (message?: string) => void; warn: (message?: string) => void };
117
+ getAbortSignal?: () => AbortSignal | undefined;
117
118
  }
118
119
  ) {
119
120
  super(ws, requestHandlers, globalMiddlewares, options);
@@ -148,6 +148,14 @@ export const AUDIT_EVENT_TYPE_CATALOG: AuditEventTypeEntry[] = [
148
148
  resource_type: 'API',
149
149
  operations: ['api.bulk_delete', 'api.create', 'api.delete', 'api.deploy', 'api.metadata.update', 'api.settings.update']
150
150
  },
151
+ {
152
+ class_uid: OCSFClassUid.ENTITY_MANAGEMENT,
153
+ class_name: 'ENTITY_MANAGEMENT',
154
+ category_uid: OCSFCategoryUid.APPLICATION_ACTIVITY,
155
+ category_name: 'APPLICATION_ACTIVITY',
156
+ resource_type: 'AppDatabase',
157
+ operations: ['database.migrate', 'database.provision']
158
+ },
151
159
  {
152
160
  class_uid: OCSFClassUid.APPLICATION_LIFECYCLE,
153
161
  class_name: 'APPLICATION_LIFECYCLE',
@@ -277,7 +285,12 @@ export const AUDIT_EVENT_TYPE_CATALOG: AuditEventTypeEntry[] = [
277
285
  category_uid: OCSFCategoryUid.APPLICATION_ACTIVITY,
278
286
  category_name: 'APPLICATION_ACTIVITY',
279
287
  resource_type: 'ReviewPolicy',
280
- operations: ['reviewpolicy.allowlist.applications.added', 'reviewpolicy.allowlist.applications.removed']
288
+ operations: [
289
+ 'reviewpolicy.allowlist.applications.added',
290
+ 'reviewpolicy.allowlist.applications.removed',
291
+ 'reviewpolicy.create',
292
+ 'reviewpolicy.delete'
293
+ ]
281
294
  },
282
295
  {
283
296
  class_uid: OCSFClassUid.ENTITY_MANAGEMENT,
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { isCreditsPricing, isSeatsPricing, PricingModelEnum, resolvePricingModel } from './pricingModel.js';
3
+ import { BillingPlan } from './billing.js';
4
+ import { isCreditsPricing, isSeatlessOrg, isSeatsPricing, PricingModelEnum, resolvePricingModel } from './pricingModel.js';
4
5
 
5
6
  describe('PricingModelEnum', () => {
6
7
  it('uses stable string values for the billing.pricing_model column', () => {
@@ -39,6 +40,35 @@ describe('isSeatsPricing', () => {
39
40
  });
40
41
  });
41
42
 
43
+ describe('isSeatlessOrg', () => {
44
+ // `pricing_model` is stamped once at TRIAL signup and grandfathered forever
45
+ // (organization.ts: "Only TRIAL signups reach here"). A PLG org that later
46
+ // signs an enterprise contract therefore keeps `credits` — while its contract
47
+ // is seat-bearing. Gating seat surfaces on the raw stamp stranded exactly
48
+ // those orgs with no way to assign builder licenses.
49
+ it('is true for a PLG org on credits pricing', () => {
50
+ expect(isSeatlessOrg(PricingModelEnum.CREDITS, BillingPlan.TEAMS)).toBe(true);
51
+ expect(isSeatlessOrg(PricingModelEnum.CREDITS, BillingPlan.TRIAL)).toBe(true);
52
+ });
53
+
54
+ it('is false for every enterprise-like plan, whatever the stamp says', () => {
55
+ for (const plan of [BillingPlan.ENTERPRISE, BillingPlan.POC, BillingPlan.STARTER, BillingPlan.PRO]) {
56
+ expect(isSeatlessOrg(PricingModelEnum.CREDITS, plan)).toBe(false);
57
+ }
58
+ });
59
+
60
+ it('is false for seat-pricing and for an absent stamp', () => {
61
+ expect(isSeatlessOrg(PricingModelEnum.SEATS, BillingPlan.TEAMS)).toBe(false);
62
+ expect(isSeatlessOrg(undefined, BillingPlan.TEAMS)).toBe(false);
63
+ expect(isSeatlessOrg(null, BillingPlan.TEAMS)).toBe(false);
64
+ });
65
+
66
+ it('is false when the plan is unknown (fail closed to showing seats)', () => {
67
+ expect(isSeatlessOrg(PricingModelEnum.CREDITS, undefined)).toBe(false);
68
+ expect(isSeatlessOrg(PricingModelEnum.CREDITS, null)).toBe(false);
69
+ });
70
+ });
71
+
42
72
  describe('resolvePricingModel', () => {
43
73
  it('passes through recognized model values from the LD flag', () => {
44
74
  expect(resolvePricingModel('credits')).toBe(PricingModelEnum.CREDITS);