revisium 2.1.0 → 2.3.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 (28) hide show
  1. package/dist/package.json +18 -3
  2. package/dist/src/commands/migration/apply-migrations.command.d.ts +2 -0
  3. package/dist/src/commands/migration/apply-migrations.command.js +16 -1
  4. package/dist/src/commands/migration/apply-migrations.command.js.map +1 -1
  5. package/dist/src/config/meta-schema.d.ts +3 -0
  6. package/dist/src/config/meta-schema.js +43 -1
  7. package/dist/src/config/meta-schema.js.map +1 -1
  8. package/dist/src/services/connection/connection-factory.service.d.ts +3 -0
  9. package/dist/src/services/connection/connection-factory.service.js +30 -5
  10. package/dist/src/services/connection/connection-factory.service.js.map +1 -1
  11. package/dist/src/services/connection/connection.service.d.ts +1 -0
  12. package/dist/src/services/connection/connection.service.js +3 -1
  13. package/dist/src/services/connection/connection.service.js.map +1 -1
  14. package/dist/src/services/url/auth-prompt.service.js +1 -1
  15. package/dist/src/services/url/auth-prompt.service.js.map +1 -1
  16. package/dist/tsconfig.build.tsbuildinfo +1 -1
  17. package/docs/authentication.md +3 -3
  18. package/docs/migrate-commands.md +6 -1
  19. package/docs/url-format.md +2 -2
  20. package/e2e/tests/03-migrate.e2e-spec.ts +49 -1
  21. package/package.json +18 -3
  22. package/src/commands/migration/apply-migrations.command.ts +14 -1
  23. package/src/config/meta-schema.ts +47 -0
  24. package/src/services/connection/__tests__/connection-factory.service.spec.ts +141 -0
  25. package/src/services/connection/__tests__/connection.service.spec.ts +1 -0
  26. package/src/services/connection/connection-factory.service.ts +47 -5
  27. package/src/services/connection/connection.service.ts +4 -1
  28. package/src/services/url/auth-prompt.service.ts +1 -1
@@ -8,8 +8,8 @@ JWT token from Revisium UI. Best for interactive use.
8
8
 
9
9
  ### Get Your Token
10
10
 
11
- - **Cloud:** <https://cloud.revisium.io/get-mcp-token>
12
- - **Self-hosted:** `https://your-host/get-mcp-token`
11
+ - **Cloud:** <https://cloud.revisium.io/get-token>
12
+ - **Self-hosted:** `https://your-host/get-token`
13
13
 
14
14
  ### Usage
15
15
 
@@ -57,7 +57,7 @@ If no credentials are provided, you'll be prompted:
57
57
 
58
58
  ```text
59
59
  Choose authentication method:
60
- > Token (copy from https://cloud.revisium.io/get-mcp-token)
60
+ > Token (copy from https://cloud.revisium.io/get-token)
61
61
  API Key (for automated access)
62
62
  Username & Password
63
63
 
@@ -39,7 +39,7 @@ revisium migrate save --file ./migrations.json \
39
39
  Apply migrations from a JSON file to Revisium.
40
40
 
41
41
  ```bash
42
- revisium migrate apply --file <path> [--commit]
42
+ revisium migrate apply --file <path> [--commit] [--create-project]
43
43
  ```
44
44
 
45
45
  ### Options
@@ -48,6 +48,7 @@ revisium migrate apply --file <path> [--commit]
48
48
  |--------|-------------|----------|
49
49
  | `-f, --file <path>` | Migrations file path | Yes |
50
50
  | `-c, --commit` | Create revision after applying | No |
51
+ | `--create-project` | Auto-create project if it doesn't exist | No |
51
52
  | `--url <url>` | Revisium URL (see [URL Format](./url-format.md)) | No* |
52
53
 
53
54
  *If `--url` is not provided, uses `REVISIUM_URL` environment variable or prompts interactively.
@@ -165,6 +166,10 @@ git commit -m "Add user phone field migration"
165
166
  export REVISIUM_URL=revisium://cloud.revisium.io/myorg/myproject/master
166
167
  export REVISIUM_TOKEN=$DEPLOY_TOKEN
167
168
  revisium migrate apply --file ./migrations.json --commit
169
+
170
+ # Apply in CI/CD with auto-create (fresh instance)
171
+ revisium migrate apply --file ./migrations.json --commit --create-project \
172
+ --url revisium://admin:admin@localhost:8888/admin/billing/master:draft
168
173
  ```
169
174
 
170
175
  See [URL Format](./url-format.md) for complete URL syntax and [Authentication](./authentication.md) for auth options.
@@ -63,8 +63,8 @@ revisium migrate apply --file ./migrations.json \
63
63
  ```
64
64
 
65
65
  Get your token:
66
- - **Cloud:** https://cloud.revisium.io/get-mcp-token
67
- - **Self-hosted:** https://your-host/get-mcp-token
66
+ - **Cloud:** https://cloud.revisium.io/get-token
67
+ - **Self-hosted:** https://your-host/get-token
68
68
 
69
69
  ### API Key Authentication
70
70
 
@@ -2,7 +2,11 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as os from 'os';
4
4
  import { runCli, buildUrl } from '../utils/cli-runner';
5
- import { createTestProject } from '../utils/test-project';
5
+ import {
6
+ createTestProject,
7
+ deleteTestProject,
8
+ generateProjectName,
9
+ } from '../utils/test-project';
6
10
  import { api } from '../utils/api-client';
7
11
  import { FIXTURES_PATH } from '../utils/constants';
8
12
 
@@ -88,6 +92,50 @@ describe('Migrate Commands', () => {
88
92
  expect(updatedProject.rootBranch.headRevisionId).not.toBe(beforeHeadId);
89
93
  });
90
94
 
95
+ it('creates project with --create-project when project does not exist', async () => {
96
+ const projectName = generateProjectName('e2e-create');
97
+
98
+ try {
99
+ const result = await runCli([
100
+ 'migrate',
101
+ 'apply',
102
+ '--url',
103
+ buildUrl(projectName, { token }),
104
+ '--file',
105
+ path.join(FIXTURES_PATH, 'migrations.json'),
106
+ '--create-project',
107
+ ]);
108
+
109
+ expect(result.exitCode).toBe(0);
110
+ expect(result.stdout).toContain('creating automatically');
111
+ expect(result.stdout).toContain('Successfully applied');
112
+
113
+ const project = await api.getProject('admin', projectName);
114
+ expect(project.name).toBe(projectName);
115
+
116
+ const tables = await api.getTables(project.rootBranch.draftRevisionId);
117
+ expect(tables.length).toBe(14);
118
+ } finally {
119
+ await deleteTestProject(projectName);
120
+ }
121
+ });
122
+
123
+ it('fails with hint when project not found without --create-project', async () => {
124
+ const projectName = generateProjectName('e2e-no-create');
125
+
126
+ const result = await runCli([
127
+ 'migrate',
128
+ 'apply',
129
+ '--url',
130
+ buildUrl(projectName, { token }),
131
+ '--file',
132
+ path.join(FIXTURES_PATH, 'migrations.json'),
133
+ ]);
134
+
135
+ expect(result.exitCode).toBe(1);
136
+ expect(result.stdout + result.stderr).toContain('--create-project');
137
+ });
138
+
91
139
  it('skips already applied migrations', async () => {
92
140
  const project = await createTestProject();
93
141
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revisium",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "homepage": "https://revisium.io",
5
5
  "description": "A CLI tool for interacting with Revisium instances, providing migration management, schema export, and data export capabilities.",
6
6
  "author": "Anton Kashirov",
@@ -45,7 +45,7 @@
45
45
  "@revisium/client": "^0.4.0",
46
46
  "@revisium/schema-toolkit": "^0.4.1",
47
47
  "@types/object-hash": "^3.0.6",
48
- "ajv": "^8.17.1",
48
+ "ajv": "^8.18.0",
49
49
  "ajv-formats": "^3.0.1",
50
50
  "nest-commander": "^3.18.0",
51
51
  "object-hash": "^3.0.0",
@@ -69,7 +69,7 @@
69
69
  "eslint": "^9.18.0",
70
70
  "eslint-config-prettier": "^10.0.1",
71
71
  "eslint-plugin-prettier": "^5.2.2",
72
- "eslint-plugin-sonarjs": "^3.0.5",
72
+ "eslint-plugin-sonarjs": "^4.0.2",
73
73
  "globals": "^16.0.0",
74
74
  "jest": "^29.7.0",
75
75
  "nyc": "^17.1.0",
@@ -83,6 +83,21 @@
83
83
  "typescript": "^5.7.3",
84
84
  "typescript-eslint": "^8.20.0"
85
85
  },
86
+ "overrides": {
87
+ "hono": "^4.12.7",
88
+ "@hono/node-server": "^1.19.10",
89
+ "lodash": "^4.17.23",
90
+ "@angular-devkit/core": {
91
+ "ajv": "^8.18.0"
92
+ },
93
+ "schema-utils@3": {
94
+ "ajv": "^6.14.0"
95
+ },
96
+ "schema-utils@4": {
97
+ "ajv": "^8.18.0"
98
+ },
99
+ "file-type": "^21.3.2"
100
+ },
86
101
  "jest": {
87
102
  "modulePaths": [
88
103
  "<rootDir>"
@@ -11,6 +11,7 @@ import { parseBooleanOption } from 'src/utils/parse-boolean.utils';
11
11
  type Options = BaseOptions & {
12
12
  file: string;
13
13
  commit?: boolean;
14
+ createProject?: boolean;
14
15
  };
15
16
 
16
17
  @SubCommand({
@@ -36,7 +37,10 @@ export class ApplyMigrationsCommand extends BaseCommand {
36
37
  const jsonData = await this.validateJsonFile(options.file);
37
38
  this.logger.success('Migration file validation passed');
38
39
 
39
- await this.connectionService.connect(options);
40
+ await this.connectionService.connect({
41
+ url: options.url,
42
+ createProject: options.createProject,
43
+ });
40
44
 
41
45
  const countAppliedMigrations = await this.applyMigration(jsonData);
42
46
 
@@ -133,4 +137,13 @@ export class ApplyMigrationsCommand extends BaseCommand {
133
137
  parseCommit(value?: string): boolean {
134
138
  return parseBooleanOption(value);
135
139
  }
140
+
141
+ @Option({
142
+ flags: '--create-project [boolean]',
143
+ description:
144
+ 'Automatically create the project if it does not exist (for CI/CD)',
145
+ })
146
+ parseCreateProject(value?: string): boolean {
147
+ return parseBooleanOption(value);
148
+ }
136
149
  }
@@ -3,6 +3,45 @@ import { sharedFields } from 'src/config/shared-fields';
3
3
 
4
4
  // https://json-schema.org/specification#single-vocabulary-meta-schemas
5
5
 
6
+ export const xFormulaSchema: Schema = {
7
+ type: 'object',
8
+ properties: {
9
+ version: {
10
+ const: 1,
11
+ },
12
+ expression: {
13
+ type: 'string',
14
+ minLength: 1,
15
+ maxLength: 10000,
16
+ },
17
+ },
18
+ additionalProperties: false,
19
+ required: ['version', 'expression'],
20
+ };
21
+
22
+ // When x-formula is present, readOnly must be true
23
+ export const xFormulaRequiresReadOnly: Schema = {
24
+ if: {
25
+ properties: { 'x-formula': { type: 'object' } },
26
+ required: ['x-formula'],
27
+ },
28
+ then: {
29
+ properties: { readOnly: { const: true } },
30
+ required: ['readOnly'],
31
+ },
32
+ };
33
+
34
+ // foreignKey and x-formula are mutually exclusive
35
+ export const foreignKeyExcludesFormula: Schema = {
36
+ if: {
37
+ properties: { foreignKey: { type: 'string' } },
38
+ required: ['foreignKey'],
39
+ },
40
+ then: {
41
+ not: { required: ['x-formula'] },
42
+ },
43
+ };
44
+
6
45
  export const refMetaSchema: Schema = {
7
46
  type: 'object',
8
47
  properties: {
@@ -60,18 +99,22 @@ export const stringMetaSchema: Schema = {
60
99
  foreignKey: {
61
100
  type: 'string',
62
101
  },
102
+ 'x-formula': xFormulaSchema,
63
103
  },
64
104
  additionalProperties: false,
65
105
  required: ['type', 'default'],
106
+ allOf: [xFormulaRequiresReadOnly, foreignKeyExcludesFormula],
66
107
  };
67
108
 
68
109
  export const noForeignKeyStringMetaSchema: Schema = {
69
110
  type: 'object',
70
111
  properties: {
71
112
  ...baseStringFields,
113
+ 'x-formula': xFormulaSchema,
72
114
  },
73
115
  additionalProperties: false,
74
116
  required: ['type', 'default'],
117
+ ...xFormulaRequiresReadOnly,
75
118
  };
76
119
 
77
120
  export const numberMetaSchema: Schema = {
@@ -87,9 +130,11 @@ export const numberMetaSchema: Schema = {
87
130
  type: 'boolean',
88
131
  },
89
132
  ...sharedFields,
133
+ 'x-formula': xFormulaSchema,
90
134
  },
91
135
  additionalProperties: false,
92
136
  required: ['type', 'default'],
137
+ ...xFormulaRequiresReadOnly,
93
138
  };
94
139
 
95
140
  export const booleanMetaSchema: Schema = {
@@ -105,9 +150,11 @@ export const booleanMetaSchema: Schema = {
105
150
  type: 'boolean',
106
151
  },
107
152
  ...sharedFields,
153
+ 'x-formula': xFormulaSchema,
108
154
  },
109
155
  additionalProperties: false,
110
156
  required: ['type', 'default'],
157
+ ...xFormulaRequiresReadOnly,
111
158
  };
112
159
 
113
160
  export const objectMetaSchema: Schema = {
@@ -12,6 +12,7 @@ describe('ConnectionFactoryService', () => {
12
12
  connecting: jest.Mock;
13
13
  connected: jest.Mock;
14
14
  authenticated: jest.Mock;
15
+ info: jest.Mock;
15
16
  };
16
17
 
17
18
  const mockDraftScope = {
@@ -55,6 +56,7 @@ describe('ConnectionFactoryService', () => {
55
56
  connecting: jest.fn(),
56
57
  connected: jest.fn(),
57
58
  authenticated: jest.fn(),
59
+ info: jest.fn(),
58
60
  };
59
61
 
60
62
  const MockApiClient = RevisiumApiClient as jest.MockedClass<
@@ -114,4 +116,143 @@ describe('ConnectionFactoryService', () => {
114
116
  expect(result.revisionScope).toBe(mockExplicitScope);
115
117
  });
116
118
  });
119
+
120
+ describe('createProject option', () => {
121
+ let mockOrgScope: { createProject: jest.Mock };
122
+
123
+ beforeEach(() => {
124
+ mockOrgScope = {
125
+ createProject: jest.fn().mockResolvedValue({ id: 'new-project-id' }),
126
+ };
127
+ });
128
+
129
+ it('creates project and retries when createProject is true and project not found', async () => {
130
+ const projectNotFoundError = new Error(
131
+ 'A project with this name does not exist in the organization',
132
+ );
133
+
134
+ const MockApiClient = RevisiumApiClient as jest.MockedClass<
135
+ typeof RevisiumApiClient
136
+ >;
137
+ MockApiClient.mockImplementation(
138
+ () =>
139
+ ({
140
+ client: {
141
+ branch: jest
142
+ .fn()
143
+ .mockRejectedValueOnce(projectNotFoundError)
144
+ .mockResolvedValueOnce(mockBranchScope),
145
+ org: jest.fn().mockReturnValue(mockOrgScope),
146
+ },
147
+ authenticate: jest.fn().mockResolvedValue('test-user'),
148
+ }) as unknown as RevisiumApiClient,
149
+ );
150
+
151
+ service = new ConnectionFactoryService(
152
+ urlBuilderFake as unknown as UrlBuilderService,
153
+ loggerFake as unknown as LoggerService,
154
+ );
155
+
156
+ const result = await service.createConnection(baseUrl, {
157
+ createProject: true,
158
+ });
159
+
160
+ expect(mockOrgScope.createProject).toHaveBeenCalledWith({
161
+ projectName: 'test-project',
162
+ });
163
+ expect(result.revisionScope).toBe(mockDraftScope);
164
+ expect(loggerFake.info).toHaveBeenCalledWith(
165
+ 'Project "test-project" not found — creating automatically',
166
+ );
167
+ });
168
+
169
+ it('throws with hint when createProject is false and project not found', async () => {
170
+ const projectNotFoundError = new Error(
171
+ 'A project with this name does not exist in the organization',
172
+ );
173
+
174
+ const MockApiClient = RevisiumApiClient as jest.MockedClass<
175
+ typeof RevisiumApiClient
176
+ >;
177
+ MockApiClient.mockImplementation(
178
+ () =>
179
+ ({
180
+ client: {
181
+ branch: jest.fn().mockRejectedValue(projectNotFoundError),
182
+ },
183
+ authenticate: jest.fn().mockResolvedValue('test-user'),
184
+ }) as unknown as RevisiumApiClient,
185
+ );
186
+
187
+ service = new ConnectionFactoryService(
188
+ urlBuilderFake as unknown as UrlBuilderService,
189
+ loggerFake as unknown as LoggerService,
190
+ );
191
+
192
+ await expect(service.createConnection(baseUrl)).rejects.toThrow(
193
+ 'Use --create-project to auto-create',
194
+ );
195
+ });
196
+
197
+ it('creates project on generic not-found error when createProject is true', async () => {
198
+ const notFoundError = new Error('Resource not found');
199
+
200
+ const MockApiClient = RevisiumApiClient as jest.MockedClass<
201
+ typeof RevisiumApiClient
202
+ >;
203
+ MockApiClient.mockImplementation(
204
+ () =>
205
+ ({
206
+ client: {
207
+ branch: jest
208
+ .fn()
209
+ .mockRejectedValueOnce(notFoundError)
210
+ .mockResolvedValueOnce(mockBranchScope),
211
+ org: jest.fn().mockReturnValue(mockOrgScope),
212
+ },
213
+ authenticate: jest.fn().mockResolvedValue('test-user'),
214
+ }) as unknown as RevisiumApiClient,
215
+ );
216
+
217
+ service = new ConnectionFactoryService(
218
+ urlBuilderFake as unknown as UrlBuilderService,
219
+ loggerFake as unknown as LoggerService,
220
+ );
221
+
222
+ const result = await service.createConnection(baseUrl, {
223
+ createProject: true,
224
+ });
225
+
226
+ expect(mockOrgScope.createProject).toHaveBeenCalledWith({
227
+ projectName: 'test-project',
228
+ });
229
+ expect(result.revisionScope).toBe(mockDraftScope);
230
+ });
231
+
232
+ it('rethrows non-project errors even with createProject', async () => {
233
+ const networkError = new Error('ECONNREFUSED');
234
+
235
+ const MockApiClient = RevisiumApiClient as jest.MockedClass<
236
+ typeof RevisiumApiClient
237
+ >;
238
+ MockApiClient.mockImplementation(
239
+ () =>
240
+ ({
241
+ client: {
242
+ branch: jest.fn().mockRejectedValue(networkError),
243
+ },
244
+ authenticate: jest.fn().mockResolvedValue('test-user'),
245
+ }) as unknown as RevisiumApiClient,
246
+ );
247
+
248
+ service = new ConnectionFactoryService(
249
+ urlBuilderFake as unknown as UrlBuilderService,
250
+ loggerFake as unknown as LoggerService,
251
+ );
252
+
253
+ await expect(
254
+ service.createConnection(baseUrl, { createProject: true }),
255
+ ).rejects.toThrow('ECONNREFUSED');
256
+ });
257
+ });
117
258
  });
@@ -157,6 +157,7 @@ describe('ConnectionService', () => {
157
157
 
158
158
  expect(connectionFactoryFake.createConnection).toHaveBeenCalledWith(
159
159
  mockUrl,
160
+ { createProject: undefined },
160
161
  );
161
162
  });
162
163
  });
@@ -13,6 +13,7 @@ export interface ConnectionInfo {
13
13
 
14
14
  export interface ConnectOptions {
15
15
  label?: string;
16
+ createProject?: boolean;
16
17
  }
17
18
 
18
19
  @Injectable()
@@ -33,11 +34,7 @@ export class ConnectionFactoryService {
33
34
 
34
35
  const client = await this.createAuthenticatedClient(url);
35
36
 
36
- const branchScope = await client.client.branch({
37
- org: url.organization,
38
- project: url.project,
39
- branch: url.branch || 'master',
40
- });
37
+ const branchScope = await this.resolveBranchScope(client, url, options);
41
38
 
42
39
  const revisionScope = await this.resolveRevisionScope(
43
40
  url.revision,
@@ -74,6 +71,51 @@ export class ConnectionFactoryService {
74
71
  return client;
75
72
  }
76
73
 
74
+ private async resolveBranchScope(
75
+ client: RevisiumApiClient,
76
+ url: RevisiumUrlComplete,
77
+ options: ConnectOptions,
78
+ ): Promise<BranchScope> {
79
+ const branchOptions = {
80
+ org: url.organization,
81
+ project: url.project,
82
+ branch: url.branch || 'master',
83
+ };
84
+
85
+ try {
86
+ return await client.client.branch(branchOptions);
87
+ } catch (error) {
88
+ if (options.createProject && this.isProjectNotFoundError(error)) {
89
+ this.logger.info(
90
+ `Project "${url.project}" not found — creating automatically`,
91
+ );
92
+
93
+ await client.client
94
+ .org(url.organization)
95
+ .createProject({ projectName: url.project });
96
+
97
+ return client.client.branch(branchOptions);
98
+ }
99
+
100
+ if (!options.createProject && this.isProjectNotFoundError(error)) {
101
+ throw new Error(
102
+ `Project "${url.project}" not found in organization "${url.organization}". Use --create-project to auto-create`,
103
+ );
104
+ }
105
+
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ private isProjectNotFoundError(error: unknown): boolean {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return (
113
+ message.includes('does not exist') ||
114
+ message.includes('not found') ||
115
+ message.includes('Not Found')
116
+ );
117
+ }
118
+
77
119
  private async resolveRevisionScope(
78
120
  revision: string,
79
121
  branchScope: BranchScope,
@@ -11,6 +11,7 @@ export { ConnectionInfo } from './connection-factory.service';
11
11
 
12
12
  export interface ConnectionOptions {
13
13
  url?: string;
14
+ createProject?: boolean;
14
15
  }
15
16
 
16
17
  @Injectable()
@@ -38,7 +39,9 @@ export class ConnectionService {
38
39
  const env = this.getEnvConfig();
39
40
  const url = await this.urlBuilder.parseAndComplete(options.url, 'api', env);
40
41
 
41
- this._connection = await this.connectionFactory.createConnection(url);
42
+ this._connection = await this.connectionFactory.createConnection(url, {
43
+ createProject: options.createProject,
44
+ });
42
45
  }
43
46
 
44
47
  private getEnvConfig(): UrlEnvConfig {
@@ -68,6 +68,6 @@ export class AuthPromptService {
68
68
  const normalizedUrl = baseUrl.endsWith('/')
69
69
  ? baseUrl.slice(0, -1)
70
70
  : baseUrl;
71
- return `${normalizedUrl}/get-mcp-token`;
71
+ return `${normalizedUrl}/get-token`;
72
72
  }
73
73
  }