@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.
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +11 -1
- package/dist/constants.js.map +1 -1
- package/dist/database-lifecycle/capacityPolicy.d.ts +8 -1
- package/dist/database-lifecycle/capacityPolicy.d.ts.map +1 -1
- package/dist/database-lifecycle/capacityPolicy.js +8 -1
- package/dist/database-lifecycle/capacityPolicy.js.map +1 -1
- package/dist/database-lifecycle/destructiveMigrations.d.ts +26 -0
- package/dist/database-lifecycle/destructiveMigrations.d.ts.map +1 -0
- package/dist/database-lifecycle/destructiveMigrations.js +89 -0
- package/dist/database-lifecycle/destructiveMigrations.js.map +1 -0
- package/dist/database-lifecycle/destructiveMigrations.test.d.ts +2 -0
- package/dist/database-lifecycle/destructiveMigrations.test.d.ts.map +1 -0
- package/dist/database-lifecycle/destructiveMigrations.test.js +100 -0
- package/dist/database-lifecycle/destructiveMigrations.test.js.map +1 -0
- package/dist/database-lifecycle/index.d.ts +6 -1
- package/dist/database-lifecycle/index.d.ts.map +1 -1
- package/dist/database-lifecycle/index.js +20 -1
- package/dist/database-lifecycle/index.js.map +1 -1
- package/dist/socket/protocol.d.ts +1 -0
- package/dist/socket/protocol.d.ts.map +1 -1
- package/dist/socket/socket.d.ts +12 -0
- package/dist/socket/socket.d.ts.map +1 -1
- package/dist/socket/socket.js +66 -9
- package/dist/socket/socket.js.map +1 -1
- package/dist/socket/socket.test.js +78 -0
- package/dist/socket/socket.test.js.map +1 -1
- package/dist/socket/socketWithClientAuth.d.ts +1 -0
- package/dist/socket/socketWithClientAuth.d.ts.map +1 -1
- package/dist/socket/socketWithClientAuth.js.map +1 -1
- package/dist/socket/tracedSocket.d.ts +1 -0
- package/dist/socket/tracedSocket.d.ts.map +1 -1
- package/dist/socket/tracedSocket.js.map +1 -1
- package/dist/types/audit/ocsf.d.ts.map +1 -1
- package/dist/types/audit/ocsf.js +14 -1
- package/dist/types/audit/ocsf.js.map +1 -1
- package/dist/types/billing/pricingModel.d.ts +29 -0
- package/dist/types/billing/pricingModel.d.ts.map +1 -1
- package/dist/types/billing/pricingModel.js +32 -0
- package/dist/types/billing/pricingModel.js.map +1 -1
- package/dist/types/billing/pricingModel.test.js +26 -0
- package/dist/types/billing/pricingModel.test.js.map +1 -1
- package/dist/types/datasource/auth.d.ts +12 -1
- package/dist/types/datasource/auth.d.ts.map +1 -1
- package/dist/types/datasource/auth.js +17 -1
- package/dist/types/datasource/auth.js.map +1 -1
- package/dist/types/datasource/auth.test.js +44 -0
- package/dist/types/datasource/auth.test.js.map +1 -1
- package/dist/types/plugin/form.d.ts +1 -0
- package/dist/types/plugin/form.d.ts.map +1 -1
- package/dist/types/plugin/form.js.map +1 -1
- package/dist/types/publishPipeline/displayNames.d.ts +14 -0
- package/dist/types/publishPipeline/displayNames.d.ts.map +1 -0
- package/dist/types/publishPipeline/displayNames.js +84 -0
- package/dist/types/publishPipeline/displayNames.js.map +1 -0
- package/dist/types/publishPipeline/displayNames.test.d.ts +2 -0
- package/dist/types/publishPipeline/displayNames.test.d.ts.map +1 -0
- package/dist/types/publishPipeline/displayNames.test.js +77 -0
- package/dist/types/publishPipeline/displayNames.test.js.map +1 -0
- package/dist/types/publishPipeline/index.d.ts +1 -0
- package/dist/types/publishPipeline/index.d.ts.map +1 -1
- package/dist/types/publishPipeline/index.js +1 -0
- package/dist/types/publishPipeline/index.js.map +1 -1
- package/dist/types/publishPipeline/plan.d.ts.map +1 -1
- package/dist/types/publishPipeline/plan.js +2 -9
- package/dist/types/publishPipeline/plan.js.map +1 -1
- package/dist/types/rbac/index.d.ts +2 -2
- package/dist/types/rbac/index.d.ts.map +1 -1
- package/dist/types/rbac/index.js +2 -2
- package/dist/types/rbac/index.js.map +1 -1
- package/dist/types/reviewPolicy/index.d.ts +39 -9
- package/dist/types/reviewPolicy/index.d.ts.map +1 -1
- package/dist/types/reviewPolicy/index.js +34 -5
- package/dist/types/reviewPolicy/index.js.map +1 -1
- package/dist/types/reviewPolicy/index.test.js +10 -0
- package/dist/types/reviewPolicy/index.test.js.map +1 -1
- package/dist/types/scim/index.d.ts +1 -0
- package/dist/types/scim/index.d.ts.map +1 -1
- package/dist/types/scim/index.js.map +1 -1
- package/dist-esm/constants.d.ts +3 -0
- package/dist-esm/constants.d.ts.map +1 -1
- package/dist-esm/constants.js +10 -0
- package/dist-esm/constants.js.map +1 -1
- package/dist-esm/database-lifecycle/capacityPolicy.d.ts +8 -1
- package/dist-esm/database-lifecycle/capacityPolicy.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/capacityPolicy.js +8 -1
- package/dist-esm/database-lifecycle/capacityPolicy.js.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.d.ts +26 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.d.ts.map +1 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.js +85 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.js.map +1 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.test.d.ts +2 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.test.d.ts.map +1 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.test.js +98 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.test.js.map +1 -0
- package/dist-esm/database-lifecycle/index.d.ts +6 -1
- package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/index.js +15 -0
- package/dist-esm/database-lifecycle/index.js.map +1 -1
- package/dist-esm/socket/protocol.d.ts +1 -0
- package/dist-esm/socket/protocol.d.ts.map +1 -1
- package/dist-esm/socket/socket.d.ts +12 -0
- package/dist-esm/socket/socket.d.ts.map +1 -1
- package/dist-esm/socket/socket.js +65 -8
- package/dist-esm/socket/socket.js.map +1 -1
- package/dist-esm/socket/socket.test.js +79 -1
- package/dist-esm/socket/socket.test.js.map +1 -1
- package/dist-esm/socket/socketWithClientAuth.d.ts +1 -0
- package/dist-esm/socket/socketWithClientAuth.d.ts.map +1 -1
- package/dist-esm/socket/socketWithClientAuth.js.map +1 -1
- package/dist-esm/socket/tracedSocket.d.ts +1 -0
- package/dist-esm/socket/tracedSocket.d.ts.map +1 -1
- package/dist-esm/socket/tracedSocket.js.map +1 -1
- package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
- package/dist-esm/types/audit/ocsf.js +14 -1
- package/dist-esm/types/audit/ocsf.js.map +1 -1
- package/dist-esm/types/billing/pricingModel.d.ts +29 -0
- package/dist-esm/types/billing/pricingModel.d.ts.map +1 -1
- package/dist-esm/types/billing/pricingModel.js +31 -0
- package/dist-esm/types/billing/pricingModel.js.map +1 -1
- package/dist-esm/types/billing/pricingModel.test.js +27 -1
- package/dist-esm/types/billing/pricingModel.test.js.map +1 -1
- package/dist-esm/types/datasource/auth.d.ts +12 -1
- package/dist-esm/types/datasource/auth.d.ts.map +1 -1
- package/dist-esm/types/datasource/auth.js +15 -1
- package/dist-esm/types/datasource/auth.js.map +1 -1
- package/dist-esm/types/datasource/auth.test.js +45 -1
- package/dist-esm/types/datasource/auth.test.js.map +1 -1
- package/dist-esm/types/plugin/form.d.ts +1 -0
- package/dist-esm/types/plugin/form.d.ts.map +1 -1
- package/dist-esm/types/plugin/form.js.map +1 -1
- package/dist-esm/types/publishPipeline/displayNames.d.ts +14 -0
- package/dist-esm/types/publishPipeline/displayNames.d.ts.map +1 -0
- package/dist-esm/types/publishPipeline/displayNames.js +80 -0
- package/dist-esm/types/publishPipeline/displayNames.js.map +1 -0
- package/dist-esm/types/publishPipeline/displayNames.test.d.ts +2 -0
- package/dist-esm/types/publishPipeline/displayNames.test.d.ts.map +1 -0
- package/dist-esm/types/publishPipeline/displayNames.test.js +75 -0
- package/dist-esm/types/publishPipeline/displayNames.test.js.map +1 -0
- package/dist-esm/types/publishPipeline/index.d.ts +1 -0
- package/dist-esm/types/publishPipeline/index.d.ts.map +1 -1
- package/dist-esm/types/publishPipeline/index.js +1 -0
- package/dist-esm/types/publishPipeline/index.js.map +1 -1
- package/dist-esm/types/publishPipeline/plan.d.ts.map +1 -1
- package/dist-esm/types/publishPipeline/plan.js +2 -9
- package/dist-esm/types/publishPipeline/plan.js.map +1 -1
- package/dist-esm/types/rbac/index.d.ts +2 -2
- package/dist-esm/types/rbac/index.d.ts.map +1 -1
- package/dist-esm/types/rbac/index.js +2 -2
- package/dist-esm/types/rbac/index.js.map +1 -1
- package/dist-esm/types/reviewPolicy/index.d.ts +39 -9
- package/dist-esm/types/reviewPolicy/index.d.ts.map +1 -1
- package/dist-esm/types/reviewPolicy/index.js +31 -4
- package/dist-esm/types/reviewPolicy/index.js.map +1 -1
- package/dist-esm/types/reviewPolicy/index.test.js +11 -1
- package/dist-esm/types/reviewPolicy/index.test.js.map +1 -1
- package/dist-esm/types/scim/index.d.ts +1 -0
- package/dist-esm/types/scim/index.d.ts.map +1 -1
- package/dist-esm/types/scim/index.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +11 -0
- package/src/database-lifecycle/capacityPolicy.ts +8 -1
- package/src/database-lifecycle/destructiveMigrations.test.ts +116 -0
- package/src/database-lifecycle/destructiveMigrations.ts +105 -0
- package/src/database-lifecycle/index.ts +27 -9
- package/src/socket/protocol.ts +7 -1
- package/src/socket/socket.test.ts +88 -1
- package/src/socket/socket.ts +81 -9
- package/src/socket/socketWithClientAuth.ts +1 -0
- package/src/socket/tracedSocket.ts +1 -0
- package/src/types/audit/ocsf.ts +14 -1
- package/src/types/billing/pricingModel.test.ts +31 -1
- package/src/types/billing/pricingModel.ts +33 -0
- package/src/types/datasource/auth.test.ts +60 -1
- package/src/types/datasource/auth.ts +24 -3
- package/src/types/plugin/form.ts +1 -0
- package/src/types/publishPipeline/displayNames.test.ts +88 -0
- package/src/types/publishPipeline/displayNames.ts +101 -0
- package/src/types/publishPipeline/index.ts +1 -0
- package/src/types/publishPipeline/plan.ts +2 -10
- package/src/types/rbac/index.ts +2 -2
- package/src/types/reviewPolicy/index.test.ts +19 -1
- package/src/types/reviewPolicy/index.ts +68 -13
- 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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
package/src/socket/protocol.ts
CHANGED
|
@@ -240,7 +240,13 @@ export interface ServerMethods {
|
|
|
240
240
|
targetTemplateName?: string;
|
|
241
241
|
migrationGeneration?: number;
|
|
242
242
|
},
|
|
243
|
-
{
|
|
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', () => {
|
package/src/socket/socket.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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(
|
|
410
|
+
clearTimeout(responseHandler.timeout);
|
|
340
411
|
}
|
|
341
412
|
// Set a new timeout for the next message
|
|
342
|
-
responseHandler.timeout = setTimeout(this.handleNoResponseTimeout(
|
|
413
|
+
responseHandler.timeout = setTimeout(this.handleNoResponseTimeout(requestId), this.timeouts?.noResponseTimeoutInSeconds * 1000);
|
|
343
414
|
}
|
|
344
415
|
|
|
345
|
-
private handleNoResponseTimeout(
|
|
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
|
-
|
|
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);
|
package/src/types/audit/ocsf.ts
CHANGED
|
@@ -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: [
|
|
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 {
|
|
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);
|