@contember/migrations-client 2.1.0-rc.3 → 2.2.0-alpha.10

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 (137) hide show
  1. package/dist/development/index.cjs +4 -0
  2. package/dist/development/index.cjs.map +1 -1
  3. package/dist/development/index.js +4 -0
  4. package/dist/development/index.js.map +1 -1
  5. package/dist/development/src/JsLoader.cjs.map +1 -1
  6. package/dist/development/src/JsLoader.js.map +1 -1
  7. package/dist/development/src/JsonLoader.cjs.map +1 -1
  8. package/dist/development/src/JsonLoader.js.map +1 -1
  9. package/dist/development/src/MigrationCreator.cjs +3 -3
  10. package/dist/development/src/MigrationCreator.cjs.map +1 -1
  11. package/dist/development/src/MigrationCreator.js +3 -3
  12. package/dist/development/src/MigrationCreator.js.map +1 -1
  13. package/dist/development/src/MigrationExecuteHelper.cjs +14 -5
  14. package/dist/development/src/MigrationExecuteHelper.cjs.map +1 -1
  15. package/dist/development/src/MigrationExecuteHelper.js +14 -5
  16. package/dist/development/src/MigrationExecuteHelper.js.map +1 -1
  17. package/dist/development/src/MigrationFile.cjs.map +1 -1
  18. package/dist/development/src/MigrationFile.js.map +1 -1
  19. package/dist/development/src/MigrationFilesManager.cjs +11 -11
  20. package/dist/development/src/MigrationFilesManager.cjs.map +1 -1
  21. package/dist/development/src/MigrationFilesManager.js.map +1 -1
  22. package/dist/development/src/MigrationParser.cjs.map +1 -1
  23. package/dist/development/src/MigrationParser.js.map +1 -1
  24. package/dist/development/src/MigrationsResolver.cjs.map +1 -1
  25. package/dist/development/src/MigrationsResolver.js.map +1 -1
  26. package/dist/development/src/SchemaStateManager.cjs +82 -0
  27. package/dist/development/src/SchemaStateManager.cjs.map +1 -0
  28. package/dist/development/src/SchemaStateManager.js +64 -0
  29. package/dist/development/src/SchemaStateManager.js.map +1 -0
  30. package/dist/development/src/SchemaVersionBuilder.cjs +28 -4
  31. package/dist/development/src/SchemaVersionBuilder.cjs.map +1 -1
  32. package/dist/development/src/SchemaVersionBuilder.js +28 -4
  33. package/dist/development/src/SchemaVersionBuilder.js.map +1 -1
  34. package/dist/development/src/SnapshotManager.cjs +46 -0
  35. package/dist/development/src/SnapshotManager.cjs.map +1 -0
  36. package/dist/development/src/SnapshotManager.js +29 -0
  37. package/dist/development/src/SnapshotManager.js.map +1 -0
  38. package/dist/development/src/SystemClient.cjs +29 -4
  39. package/dist/development/src/SystemClient.cjs.map +1 -1
  40. package/dist/development/src/SystemClient.js +29 -4
  41. package/dist/development/src/SystemClient.js.map +1 -1
  42. package/dist/development/src/migrations.cjs.map +1 -1
  43. package/dist/development/src/migrations.js.map +1 -1
  44. package/dist/production/index.cjs +4 -0
  45. package/dist/production/index.cjs.map +1 -1
  46. package/dist/production/index.js +4 -0
  47. package/dist/production/index.js.map +1 -1
  48. package/dist/production/src/JsLoader.cjs.map +1 -1
  49. package/dist/production/src/JsLoader.js.map +1 -1
  50. package/dist/production/src/JsonLoader.cjs.map +1 -1
  51. package/dist/production/src/JsonLoader.js.map +1 -1
  52. package/dist/production/src/MigrationCreator.cjs +3 -3
  53. package/dist/production/src/MigrationCreator.cjs.map +1 -1
  54. package/dist/production/src/MigrationCreator.js +3 -3
  55. package/dist/production/src/MigrationCreator.js.map +1 -1
  56. package/dist/production/src/MigrationExecuteHelper.cjs +14 -5
  57. package/dist/production/src/MigrationExecuteHelper.cjs.map +1 -1
  58. package/dist/production/src/MigrationExecuteHelper.js +14 -5
  59. package/dist/production/src/MigrationExecuteHelper.js.map +1 -1
  60. package/dist/production/src/MigrationFile.cjs.map +1 -1
  61. package/dist/production/src/MigrationFile.js.map +1 -1
  62. package/dist/production/src/MigrationFilesManager.cjs +11 -11
  63. package/dist/production/src/MigrationFilesManager.cjs.map +1 -1
  64. package/dist/production/src/MigrationFilesManager.js.map +1 -1
  65. package/dist/production/src/MigrationParser.cjs.map +1 -1
  66. package/dist/production/src/MigrationParser.js.map +1 -1
  67. package/dist/production/src/MigrationsResolver.cjs.map +1 -1
  68. package/dist/production/src/MigrationsResolver.js.map +1 -1
  69. package/dist/production/src/SchemaStateManager.cjs +82 -0
  70. package/dist/production/src/SchemaStateManager.cjs.map +1 -0
  71. package/dist/production/src/SchemaStateManager.js +64 -0
  72. package/dist/production/src/SchemaStateManager.js.map +1 -0
  73. package/dist/production/src/SchemaVersionBuilder.cjs +28 -4
  74. package/dist/production/src/SchemaVersionBuilder.cjs.map +1 -1
  75. package/dist/production/src/SchemaVersionBuilder.js +28 -4
  76. package/dist/production/src/SchemaVersionBuilder.js.map +1 -1
  77. package/dist/production/src/SnapshotManager.cjs +46 -0
  78. package/dist/production/src/SnapshotManager.cjs.map +1 -0
  79. package/dist/production/src/SnapshotManager.js +29 -0
  80. package/dist/production/src/SnapshotManager.js.map +1 -0
  81. package/dist/production/src/SystemClient.cjs +29 -4
  82. package/dist/production/src/SystemClient.cjs.map +1 -1
  83. package/dist/production/src/SystemClient.js +29 -4
  84. package/dist/production/src/SystemClient.js.map +1 -1
  85. package/dist/production/src/migrations.cjs.map +1 -1
  86. package/dist/production/src/migrations.js.map +1 -1
  87. package/dist/types/JsLoader.d.ts +3 -3
  88. package/dist/types/JsLoader.d.ts.map +1 -1
  89. package/dist/types/JsonLoader.d.ts +4 -4
  90. package/dist/types/JsonLoader.d.ts.map +1 -1
  91. package/dist/types/MigrationCreator.d.ts +4 -2
  92. package/dist/types/MigrationCreator.d.ts.map +1 -1
  93. package/dist/types/MigrationExecuteHelper.d.ts +6 -4
  94. package/dist/types/MigrationExecuteHelper.d.ts.map +1 -1
  95. package/dist/types/MigrationFile.d.ts +2 -2
  96. package/dist/types/MigrationFile.d.ts.map +1 -1
  97. package/dist/types/MigrationFileLoader.d.ts +1 -1
  98. package/dist/types/MigrationFileLoader.d.ts.map +1 -1
  99. package/dist/types/MigrationFilesManager.d.ts +2 -2
  100. package/dist/types/MigrationFilesManager.d.ts.map +1 -1
  101. package/dist/types/MigrationParser.d.ts +2 -2
  102. package/dist/types/MigrationParser.d.ts.map +1 -1
  103. package/dist/types/MigrationsResolver.d.ts +2 -2
  104. package/dist/types/MigrationsResolver.d.ts.map +1 -1
  105. package/dist/types/SchemaStateManager.d.ts +19 -0
  106. package/dist/types/SchemaStateManager.d.ts.map +1 -0
  107. package/dist/types/SchemaVersionBuilder.d.ts +10 -2
  108. package/dist/types/SchemaVersionBuilder.d.ts.map +1 -1
  109. package/dist/types/SnapshotManager.d.ts +31 -0
  110. package/dist/types/SnapshotManager.d.ts.map +1 -0
  111. package/dist/types/SystemClient.d.ts +11 -3
  112. package/dist/types/SystemClient.d.ts.map +1 -1
  113. package/dist/types/index.d.ts +14 -12
  114. package/dist/types/index.d.ts.map +1 -1
  115. package/dist/types/migrations.d.ts +1 -1
  116. package/dist/types/migrations.d.ts.map +1 -1
  117. package/dist/types/tsconfig.tsbuildinfo +1 -1
  118. package/package.json +6 -6
  119. package/src/JsLoader.ts +3 -3
  120. package/src/JsonLoader.ts +3 -3
  121. package/src/MigrationCreator.ts +8 -5
  122. package/src/MigrationExecuteHelper.ts +19 -8
  123. package/src/MigrationFile.ts +2 -2
  124. package/src/MigrationFileLoader.ts +1 -1
  125. package/src/MigrationFilesManager.ts +2 -2
  126. package/src/MigrationParser.ts +2 -2
  127. package/src/MigrationsResolver.ts +2 -2
  128. package/src/SchemaStateManager.ts +75 -0
  129. package/src/SchemaVersionBuilder.ts +35 -10
  130. package/src/SnapshotManager.ts +52 -0
  131. package/src/SystemClient.ts +41 -4
  132. package/src/index.ts +14 -12
  133. package/src/migrations.ts +1 -1
  134. package/tests/cases/unit/MigrationCreator.test.ts +50 -0
  135. package/tests/cases/unit/SchemaStateManager.test.ts +96 -0
  136. package/tests/cases/unit/SchemaVersionBuilder.test.ts +125 -0
  137. package/tests/cases/unit/SnapshotManager.test.ts +64 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contember/migrations-client",
3
- "version": "2.1.0-rc.3",
3
+ "version": "2.2.0-alpha.10",
4
4
  "license": "Apache-2.0",
5
5
  "main": "./dist/production/index.js",
6
6
  "typings": "./dist/types/index.d.ts",
@@ -26,11 +26,11 @@
26
26
  "test": "echo 'No tests'"
27
27
  },
28
28
  "dependencies": {
29
- "@contember/engine-common": "2.1.0-rc.3",
30
- "@contember/graphql-client": "2.1.0-rc.3",
31
- "@contember/schema": "2.1.0-rc.3",
32
- "@contember/schema-migrations": "2.1.0-rc.3",
33
- "@contember/schema-utils": "2.1.0-rc.3"
29
+ "@contember/engine-common": "2.2.0-alpha.10",
30
+ "@contember/graphql-client": "2.2.0-alpha.10",
31
+ "@contember/schema": "2.2.0-alpha.10",
32
+ "@contember/schema-migrations": "2.2.0-alpha.10",
33
+ "@contember/schema-utils": "2.2.0-alpha.10"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^24.12.0"
package/src/JsLoader.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { MigrationFileLoader } from './MigrationFileLoader'
2
- import { MigrationParser } from './MigrationParser'
3
- import { ContentMigration, ContentMigrationFactoryArgs, MigrationContent, MigrationFile } from './MigrationFile'
1
+ import { MigrationFileLoader } from './MigrationFileLoader.js'
2
+ import { MigrationParser } from './MigrationParser.js'
3
+ import { ContentMigration, ContentMigrationFactoryArgs, MigrationContent, MigrationFile } from './MigrationFile.js'
4
4
 
5
5
  export class JsLoader implements MigrationFileLoader {
6
6
  constructor(
package/src/JsonLoader.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { MigrationFileLoader } from './MigrationFileLoader'
1
+ import { MigrationFileLoader } from './MigrationFileLoader.js'
2
2
  import * as fs from 'node:fs/promises'
3
- import { MigrationParser } from './MigrationParser'
4
- import { MigrationFile } from './MigrationFile'
3
+ import { MigrationParser } from './MigrationParser.js'
4
+ import { MigrationFile } from './MigrationFile.js'
5
5
 
6
6
  export class JsonLoader implements MigrationFileLoader {
7
7
  constructor(
@@ -1,4 +1,4 @@
1
- import { MigrationFilesManager } from './MigrationFilesManager'
1
+ import { MigrationFilesManager } from './MigrationFilesManager.js'
2
2
  import { Schema } from '@contember/schema'
3
3
  import { Migration, SchemaDiffer, VERSION_LATEST } from '@contember/schema-migrations'
4
4
  import { MigrationVersionHelper } from '@contember/engine-common'
@@ -25,11 +25,13 @@ export class MigrationCreator {
25
25
  initialSchema: Schema,
26
26
  newSchema: Schema,
27
27
  migrationName: string,
28
- { skipInitialSchemaValidation = false }: { skipInitialSchemaValidation?: boolean } = {},
28
+ { skipInitialSchemaValidation = false, skipNonModelDiffers = false, recreateViews = false }: {
29
+ skipInitialSchemaValidation?: boolean
30
+ skipNonModelDiffers?: boolean
31
+ recreateViews?: boolean
32
+ } = {},
29
33
  ): Promise<{ migration: Migration; initialSchema: Schema } | null> {
30
- await this.migrationFilesManager.createDirIfNotExist()
31
-
32
- const modifications = this.schemaDiffer.diffSchemas(initialSchema, newSchema, { skipInitialSchemaValidation })
34
+ const modifications = this.schemaDiffer.diffSchemas(initialSchema, newSchema, { skipInitialSchemaValidation, skipNonModelDiffers, recreateViews })
33
35
  if (modifications.length === 0) {
34
36
  return null
35
37
  }
@@ -40,6 +42,7 @@ export class MigrationCreator {
40
42
  }
41
43
 
42
44
  async saveMigration(migration: Migration): Promise<string> {
45
+ await this.migrationFilesManager.createDirIfNotExist()
43
46
  const jsonDiff = MigrationCreator.createContent(migration)
44
47
  const filename = await this.migrationFilesManager.createFile(jsonDiff, migration.name)
45
48
  return filename
@@ -1,7 +1,8 @@
1
- import { MigrationToExecuteOkStatus } from './migrations'
2
- import { ContentMigrationFactoryArgs, ResolvedMigrationContent } from './MigrationFile'
3
- import { assertNever } from './utils/assertNever'
4
- import { SystemClient } from './SystemClient'
1
+ import { MigrationToExecuteOkStatus } from './migrations.js'
2
+ import { ContentMigrationFactoryArgs, ResolvedMigrationContent } from './MigrationFile.js'
3
+ import { assertNever } from './utils/assertNever.js'
4
+ import { SystemClient } from './SystemClient.js'
5
+ import { SchemaState } from './SchemaStateManager.js'
5
6
 
6
7
  export class MigrationExecutor {
7
8
  async executeMigrations({
@@ -9,6 +10,7 @@ export class MigrationExecutor {
9
10
  migrations,
10
11
  contentMigrationFactoryArgs,
11
12
  force,
13
+ schemaState,
12
14
  log = () => null,
13
15
  }: {
14
16
  client: SystemClient
@@ -16,14 +18,16 @@ export class MigrationExecutor {
16
18
  contentMigrationFactoryArgs: Omit<ContentMigrationFactoryArgs, 'migration'>
17
19
  log: (message: string) => void
18
20
  force?: boolean
21
+ schemaState?: SchemaState
19
22
  }): Promise<void> {
20
- if (migrations.length === 0) {
23
+ if (migrations.length === 0 && !schemaState) {
21
24
  return
22
25
  }
23
26
  let migrationsToRun: ResolvedMigrationContent[] = []
27
+ let batchSchemaState: SchemaState | undefined
24
28
 
25
29
  const executeMigrations = async () => {
26
- if (migrationsToRun.length === 0) {
30
+ if (migrationsToRun.length === 0 && !batchSchemaState) {
27
31
  return
28
32
  }
29
33
  await client.migrate(
@@ -51,14 +55,18 @@ export class MigrationExecutor {
51
55
  return assertNever(it)
52
56
  }),
53
57
  force,
58
+ batchSchemaState,
54
59
  )
55
60
  migrationsToRun.forEach(it => {
56
61
  log(it.name)
57
62
  })
58
63
  migrationsToRun = []
64
+ batchSchemaState = undefined
59
65
  }
60
66
 
61
- log('Executing...')
67
+ if (migrations.length > 0) {
68
+ log('Executing...')
69
+ }
62
70
 
63
71
  for (const migration of migrations) {
64
72
  const migrationContent = await migration.localMigration.getContent()
@@ -74,7 +82,10 @@ export class MigrationExecutor {
74
82
  migrationsToRun.push(migrationContent)
75
83
  }
76
84
  }
85
+ batchSchemaState = schemaState
77
86
  await executeMigrations()
78
- log('Migration executed')
87
+ if (migrations.length > 0) {
88
+ log('Migration executed')
89
+ }
79
90
  }
80
91
  }
@@ -1,6 +1,6 @@
1
1
  import { Migration } from '@contember/schema-migrations'
2
- import { JSONObject } from './utils/json'
3
- import { SchemaVersionBuilder } from './SchemaVersionBuilder'
2
+ import { JSONObject } from './utils/json.js'
3
+ import { SchemaVersionBuilder } from './SchemaVersionBuilder.js'
4
4
 
5
5
  export interface MigrationFile {
6
6
  path: string
@@ -1,4 +1,4 @@
1
- import { MigrationContent, MigrationFile } from './MigrationFile'
1
+ import { MigrationContent, MigrationFile } from './MigrationFile.js'
2
2
 
3
3
  export interface MigrationFileLoader {
4
4
  load(file: MigrationFile): Promise<MigrationContent>
@@ -1,7 +1,7 @@
1
1
  import { MigrationVersionHelper } from '@contember/engine-common'
2
2
  import * as fs from 'node:fs/promises'
3
- import { MigrationFileLoader } from './MigrationFileLoader'
4
- import { MigrationContent, MigrationFile } from './MigrationFile'
3
+ import { MigrationFileLoader } from './MigrationFileLoader.js'
4
+ import { MigrationContent, MigrationFile } from './MigrationFile.js'
5
5
  import { join } from 'node:path'
6
6
  import { Dirent } from 'node:fs'
7
7
 
@@ -1,6 +1,6 @@
1
1
  import { Migration, VERSION_INITIAL } from '@contember/schema-migrations'
2
- import { ContentMigrationQuery, MigrationFile, ResolvedMigrationContent } from './MigrationFile'
3
- import { JSONValue } from './utils/json'
2
+ import { ContentMigrationQuery, MigrationFile, ResolvedMigrationContent } from './MigrationFile.js'
3
+ import { JSONValue } from './utils/json.js'
4
4
 
5
5
  export class MigrationParser {
6
6
  public parse(migrationFile: MigrationFile, content: JSONValue): ResolvedMigrationContent {
@@ -1,6 +1,6 @@
1
- import { MigrationFilesManager } from './MigrationFilesManager'
1
+ import { MigrationFilesManager } from './MigrationFilesManager.js'
2
2
  import { Migration } from '@contember/schema-migrations'
3
- import { isSchemaMigration, MigrationFile } from './MigrationFile'
3
+ import { isSchemaMigration, MigrationFile } from './MigrationFile.js'
4
4
  import { MigrationVersionHelper } from '@contember/engine-common'
5
5
 
6
6
  export class MigrationsResolver {
@@ -0,0 +1,75 @@
1
+ import { Acl, Actions, Schema, Settings, Validation } from '@contember/schema'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as path from 'node:path'
4
+
5
+ export type SchemaState = {
6
+ acl: Acl.Schema
7
+ validation: Validation.Schema
8
+ actions: Actions.Schema
9
+ settings: Settings.Schema
10
+ }
11
+
12
+ const stateFiles = ['acl', 'validation', 'actions', 'settings'] as const
13
+ type StateKey = typeof stateFiles[number]
14
+
15
+ export class SchemaStateManager {
16
+ constructor(private readonly stateDir: string) {}
17
+
18
+ async isStateMode(): Promise<boolean> {
19
+ try {
20
+ const stat = await fs.stat(this.stateDir)
21
+ return stat.isDirectory()
22
+ } catch {
23
+ return false
24
+ }
25
+ }
26
+
27
+ async readState(): Promise<SchemaState> {
28
+ const state: Partial<SchemaState> = {}
29
+ for (const key of stateFiles) {
30
+ const filePath = path.join(this.stateDir, `${key}.json`)
31
+ try {
32
+ const content = await fs.readFile(filePath, 'utf8')
33
+ state[key] = JSON.parse(content)
34
+ } catch (e) {
35
+ throw new Error(`Failed to read schema state file ${filePath}: ${e instanceof Error ? e.message : e}`)
36
+ }
37
+ }
38
+ return state as SchemaState
39
+ }
40
+
41
+ async writeState(state: SchemaState, { dryRun = false }: { dryRun?: boolean } = {}): Promise<boolean> {
42
+ let changed = false
43
+ for (const key of stateFiles) {
44
+ const filePath = path.join(this.stateDir, `${key}.json`)
45
+ const newContent = JSON.stringify(state[key], undefined, '\t') + '\n'
46
+ let currentContent: string | null = null
47
+ try {
48
+ currentContent = await fs.readFile(filePath, 'utf8')
49
+ } catch {
50
+ // file doesn't exist yet
51
+ }
52
+ if (currentContent !== newContent) {
53
+ if (!dryRun) {
54
+ await fs.writeFile(filePath, newContent, 'utf8')
55
+ }
56
+ changed = true
57
+ }
58
+ }
59
+ return changed
60
+ }
61
+
62
+ async extractState(schema: Schema): Promise<void> {
63
+ await fs.mkdir(this.stateDir, { recursive: true })
64
+ await this.writeState(SchemaStateManager.schemaStateFromSchema(schema))
65
+ }
66
+
67
+ static schemaStateFromSchema(schema: Schema): SchemaState {
68
+ return {
69
+ acl: schema.acl,
70
+ validation: schema.validation,
71
+ actions: schema.actions,
72
+ settings: schema.settings,
73
+ }
74
+ }
75
+ }
@@ -1,20 +1,42 @@
1
1
  import { Schema } from '@contember/schema'
2
2
  import { emptySchema } from '@contember/schema-utils'
3
- import { SchemaMigrator } from '@contember/schema-migrations'
4
- import { MigrationsResolver } from './MigrationsResolver'
3
+ import { Migration, SchemaMigrator } from '@contember/schema-migrations'
4
+ import { MigrationsResolver } from './MigrationsResolver.js'
5
+ import { SchemaStateManager } from './SchemaStateManager.js'
5
6
 
6
7
  export class SchemaVersionBuilder {
8
+ private lastBuildUntil: { migrations: Migration[]; schema: Schema } | null = null
9
+
7
10
  constructor(
8
11
  private readonly migrationsResolver: MigrationsResolver,
9
12
  private readonly schemaMigrator: SchemaMigrator,
13
+ private readonly schemaStateManager: SchemaStateManager,
10
14
  ) {}
11
15
 
12
16
  async buildSchema(targetVersion?: string): Promise<Schema> {
13
- return this.buildSchemaAdvanced(emptySchema, version => !targetVersion || version <= targetVersion)
17
+ let schema = await this.buildSchemaAdvanced(emptySchema, version => !targetVersion || version <= targetVersion)
18
+ if (!targetVersion && await this.schemaStateManager.isStateMode()) {
19
+ const state = await this.schemaStateManager.readState()
20
+ schema = { ...schema, ...state }
21
+ }
22
+ return schema
14
23
  }
15
24
 
25
+ /**
26
+ * Content migrations call this once each, with ascending versions. Continuing from the previous
27
+ * build keeps a run linear instead of replaying every schema migration from scratch each time.
28
+ */
16
29
  async buildSchemaUntil(targetVersion: string): Promise<Schema> {
17
- return this.buildSchemaAdvanced(emptySchema, version => version < targetVersion)
30
+ const migrations = (await this.migrationsResolver.getSchemaMigrations()).filter(({ version }) => version < targetVersion)
31
+ const previous = this.lastBuildUntil
32
+ const canContinue = previous !== null
33
+ && previous.migrations.length <= migrations.length
34
+ && previous.migrations.every((migration, index) => migration === migrations[index])
35
+ const start = canContinue ? previous : { migrations: [], schema: emptySchema }
36
+
37
+ const schema = this.applyMigrations(start.schema, migrations.slice(start.migrations.length))
38
+ this.lastBuildUntil = { migrations, schema }
39
+ return schema
18
40
  }
19
41
 
20
42
  async continue(schema: Schema, previousVersion: string | null, targetVersion: string): Promise<Schema> {
@@ -22,11 +44,14 @@ export class SchemaVersionBuilder {
22
44
  }
23
45
 
24
46
  public async buildSchemaAdvanced(initialSchema: Schema, condition: (version: string) => boolean): Promise<Schema> {
25
- return (await this.migrationsResolver.getSchemaMigrations())
26
- .filter(({ version }) => condition(version))
27
- .reduce<Schema>(
28
- (schema, { modifications, formatVersion }) => this.schemaMigrator.applyModifications(schema, modifications, formatVersion),
29
- initialSchema,
30
- )
47
+ const migrations = (await this.migrationsResolver.getSchemaMigrations()).filter(({ version }) => condition(version))
48
+ return this.applyMigrations(initialSchema, migrations)
49
+ }
50
+
51
+ private applyMigrations(initialSchema: Schema, migrations: Migration[]): Schema {
52
+ return migrations.reduce<Schema>(
53
+ (schema, { modifications, formatVersion }) => this.schemaMigrator.applyModifications(schema, modifications, formatVersion),
54
+ initialSchema,
55
+ )
31
56
  }
32
57
  }
@@ -0,0 +1,52 @@
1
+ import { Migration } from '@contember/schema-migrations'
2
+ import * as fs from 'node:fs/promises'
3
+
4
+ export type CoveredMigration = {
5
+ version: string
6
+ name: string
7
+ type: 'schema' | 'content'
8
+ /** schema migration checksum, or null for content migrations (mirrors the executed registry) */
9
+ checksum: string | null
10
+ }
11
+
12
+ export type SnapshotFile = {
13
+ /** last covered migration version (X) */
14
+ version: string
15
+ /** whether the project used schema state mode when the snapshot was created */
16
+ stateMode: boolean
17
+ /** collapsed schema (empty -> X), applied in a single step */
18
+ snapshot: {
19
+ formatVersion: number
20
+ modifications: Migration.Modification[]
21
+ }
22
+ /** migrations subsumed by the snapshot, recorded as executed without replaying them */
23
+ covers: CoveredMigration[]
24
+ /** versions of content migrations within the covered range whose data is NOT reproduced */
25
+ contentMigrations: string[]
26
+ }
27
+
28
+ export class SnapshotManager {
29
+ constructor(private readonly snapshotPath: string) {}
30
+
31
+ async exists(): Promise<boolean> {
32
+ try {
33
+ const stat = await fs.stat(this.snapshotPath)
34
+ return stat.isFile()
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ async read(): Promise<SnapshotFile> {
41
+ try {
42
+ const content = await fs.readFile(this.snapshotPath, 'utf8')
43
+ return JSON.parse(content) as SnapshotFile
44
+ } catch (e) {
45
+ throw new Error(`Failed to read snapshot file ${this.snapshotPath}: ${e instanceof Error ? e.message : e}`)
46
+ }
47
+ }
48
+
49
+ async write(snapshot: SnapshotFile): Promise<void> {
50
+ await fs.writeFile(this.snapshotPath, JSON.stringify(snapshot, undefined, '\t') + '\n', 'utf8')
51
+ }
52
+ }
@@ -1,5 +1,6 @@
1
1
  import { GraphQlClient } from '@contember/graphql-client'
2
- import { ExecutedMigration, ExecutedMigrationInfo } from './migrations'
2
+ import { ExecutedMigration, ExecutedMigrationInfo } from './migrations.js'
3
+ import { SchemaState } from './SchemaStateManager.js'
3
4
 
4
5
  export type MigrateError = {
5
6
  readonly code: MigrateErrorCode
@@ -13,6 +14,13 @@ export enum MigrateErrorCode {
13
14
  InvalidFormat = 'INVALID_FORMAT',
14
15
  InvalidSchema = 'INVALID_SCHEMA',
15
16
  MigrationFailed = 'MIGRATION_FAILED',
17
+ ProjectNotEmpty = 'PROJECT_NOT_EMPTY',
18
+ }
19
+
20
+ export type SnapshotInput = {
21
+ formatVersion: number
22
+ modifications: unknown[]
23
+ covers: unknown[]
16
24
  }
17
25
 
18
26
  export type MigrateResponse = {
@@ -28,10 +36,10 @@ export class SystemClient {
28
36
  return new SystemClient(graphqlClient)
29
37
  }
30
38
 
31
- public async migrate(migrations: any[], force = false): Promise<void> {
39
+ public async migrate(migrations: any[], force = false, schemaState?: SchemaState): Promise<void> {
32
40
  const query = `
33
- mutation($migrations: [Migration!]!) {
34
- migrate: ${force ? 'forceMigrate' : 'migrate'}(migrations: $migrations) {
41
+ mutation($migrations: [Migration!]!, $schemaState: SchemaStateInput) {
42
+ migrate: ${force ? 'forceMigrate' : 'migrate'}(migrations: $migrations, schemaState: $schemaState) {
35
43
  ok
36
44
  errors {
37
45
  code
@@ -47,6 +55,7 @@ mutation($migrations: [Migration!]!) {
47
55
  }>(query, {
48
56
  variables: {
49
57
  migrations,
58
+ schemaState: schemaState ?? null,
50
59
  },
51
60
  })
52
61
  ).migrate
@@ -55,6 +64,34 @@ mutation($migrations: [Migration!]!) {
55
64
  }
56
65
  }
57
66
 
67
+ public async migrateFromSnapshot(snapshot: SnapshotInput, schemaState?: SchemaState): Promise<void> {
68
+ const query = `
69
+ mutation($snapshot: SnapshotInput!, $schemaState: SchemaStateInput) {
70
+ migrateFromSnapshot(snapshot: $snapshot, schemaState: $schemaState) {
71
+ ok
72
+ errors {
73
+ code
74
+ migration
75
+ message: developerMessage
76
+ }
77
+ }
78
+ }
79
+ `
80
+ const result = (
81
+ await this.apiClient.execute<{
82
+ migrateFromSnapshot: { ok: boolean; errors: { code: MigrateErrorCode; migration: string; message: string }[] }
83
+ }>(query, {
84
+ variables: {
85
+ snapshot,
86
+ schemaState: schemaState ?? null,
87
+ },
88
+ })
89
+ ).migrateFromSnapshot
90
+ if (!result.ok) {
91
+ throw result.errors
92
+ }
93
+ }
94
+
58
95
  public async migrationDelete(version: string): Promise<void> {
59
96
  const query = `
60
97
  mutation($version: String!) {
package/src/index.ts CHANGED
@@ -7,15 +7,17 @@ export {
7
7
  VERSION_LATEST,
8
8
  } from '@contember/schema-migrations'
9
9
  export type { Migration, MigrationInfo } from '@contember/schema-migrations'
10
- export * from './JsLoader'
11
- export * from './JsonLoader'
12
- export * from './MigrationCreator'
13
- export * from './MigrationExecuteHelper'
14
- export * from './MigrationFile'
15
- export * from './MigrationFileLoader'
16
- export * from './MigrationFilesManager'
17
- export * from './MigrationParser'
18
- export * from './migrations'
19
- export * from './MigrationsResolver'
20
- export * from './SchemaVersionBuilder'
21
- export * from './SystemClient'
10
+ export * from './JsLoader.js'
11
+ export * from './JsonLoader.js'
12
+ export * from './MigrationCreator.js'
13
+ export * from './MigrationExecuteHelper.js'
14
+ export * from './MigrationFile.js'
15
+ export * from './MigrationFileLoader.js'
16
+ export * from './MigrationFilesManager.js'
17
+ export * from './MigrationParser.js'
18
+ export * from './migrations.js'
19
+ export * from './MigrationsResolver.js'
20
+ export * from './SchemaStateManager.js'
21
+ export * from './SchemaVersionBuilder.js'
22
+ export * from './SnapshotManager.js'
23
+ export * from './SystemClient.js'
package/src/migrations.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { calculateMigrationChecksum, Migration, MigrationInfo } from '@contember/schema-migrations'
2
- import { isSchemaMigration, MigrationFile } from './MigrationFile'
2
+ import { isSchemaMigration, MigrationFile } from './MigrationFile.js'
3
3
 
4
4
  export interface ExecutedMigrationInfo extends MigrationInfo {
5
5
  readonly executedAt: Date
@@ -0,0 +1,50 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as os from 'node:os'
4
+ import * as path from 'node:path'
5
+ import { c, createSchema } from '@contember/schema-definition'
6
+ import { emptySchema } from '@contember/schema-utils'
7
+ import {
8
+ JsonLoader,
9
+ MigrationCreator,
10
+ MigrationFilesManager,
11
+ MigrationParser,
12
+ ModificationHandlerFactory,
13
+ SchemaDiffer,
14
+ SchemaMigrator,
15
+ } from '../../../src/index.js'
16
+
17
+ namespace Model {
18
+ export class Article {
19
+ title = c.stringColumn()
20
+ }
21
+ }
22
+
23
+ let workDir: string
24
+ const exists = (file: string): Promise<boolean> => fs.stat(file).then(() => true, () => false)
25
+
26
+ beforeEach(async () => {
27
+ workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'contember-migration-creator-'))
28
+ })
29
+
30
+ afterEach(async () => {
31
+ await fs.rm(workDir, { recursive: true, force: true })
32
+ })
33
+
34
+ test('prepareMigration is filesystem-pure and saveMigration creates the directory', async () => {
35
+ const migrationsDir = path.join(workDir, 'migrations')
36
+ const filesManager = new MigrationFilesManager(migrationsDir, { json: new JsonLoader(new MigrationParser()) })
37
+ const migrator = new SchemaMigrator(new ModificationHandlerFactory(ModificationHandlerFactory.defaultFactoryMap))
38
+ const creator = new MigrationCreator(filesManager, new SchemaDiffer(migrator))
39
+
40
+ const prepared = await creator.prepareMigration(emptySchema, createSchema(Model), 'initial')
41
+
42
+ expect(prepared).not.toBeNull()
43
+ expect(await exists(migrationsDir)).toBe(false)
44
+ if (prepared === null) {
45
+ throw new Error('Expected a migration')
46
+ }
47
+ const file = await creator.saveMigration(prepared.migration)
48
+ expect(await exists(migrationsDir)).toBe(true)
49
+ expect(await exists(file)).toBe(true)
50
+ })
@@ -0,0 +1,96 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as os from 'node:os'
4
+ import * as path from 'node:path'
5
+ import { SchemaStateManager } from '../../../src/index.js'
6
+ import { emptySchema } from '@contember/schema-utils'
7
+ import { Schema } from '@contember/schema'
8
+
9
+ const schemaWithAcl: Schema = {
10
+ ...emptySchema,
11
+ acl: {
12
+ roles: {
13
+ admin: { variables: {}, stages: '*', entities: {} },
14
+ },
15
+ },
16
+ settings: { content: { useExistsInHasManyFilter: true } },
17
+ }
18
+
19
+ describe('SchemaStateManager', () => {
20
+ let baseDir: string
21
+ let stateDir: string
22
+ let manager: SchemaStateManager
23
+
24
+ beforeEach(async () => {
25
+ baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'contember-state-'))
26
+ stateDir = path.join(baseDir, 'state')
27
+ manager = new SchemaStateManager(stateDir)
28
+ })
29
+
30
+ afterEach(async () => {
31
+ await fs.rm(baseDir, { recursive: true, force: true })
32
+ })
33
+
34
+ test('isStateMode is false when the state directory does not exist', async () => {
35
+ expect(await manager.isStateMode()).toBe(false)
36
+ })
37
+
38
+ test('isStateMode is false when the path is a file, not a directory', async () => {
39
+ await fs.writeFile(stateDir, 'not a dir')
40
+ expect(await manager.isStateMode()).toBe(false)
41
+ })
42
+
43
+ test('extractState creates the directory and round-trips through readState', async () => {
44
+ await manager.extractState(schemaWithAcl)
45
+
46
+ expect(await manager.isStateMode()).toBe(true)
47
+
48
+ const state = await manager.readState()
49
+ expect(state.acl).toStrictEqual(schemaWithAcl.acl)
50
+ expect(state.validation).toStrictEqual(schemaWithAcl.validation)
51
+ expect(state.actions).toStrictEqual(schemaWithAcl.actions)
52
+ expect(state.settings).toStrictEqual(schemaWithAcl.settings)
53
+ })
54
+
55
+ test('extractState writes one JSON file per state part', async () => {
56
+ await manager.extractState(schemaWithAcl)
57
+
58
+ const files = (await fs.readdir(stateDir)).sort()
59
+ expect(files).toStrictEqual(['acl.json', 'actions.json', 'settings.json', 'validation.json'])
60
+ })
61
+
62
+ test('writeState in dryRun mode reports a change but does not touch the filesystem', async () => {
63
+ await fs.mkdir(stateDir, { recursive: true })
64
+
65
+ const changed = await manager.writeState(SchemaStateManager.schemaStateFromSchema(schemaWithAcl), { dryRun: true })
66
+
67
+ expect(changed).toBe(true)
68
+ expect(await fs.readdir(stateDir)).toStrictEqual([])
69
+ })
70
+
71
+ test('writeState is idempotent: a second identical write reports no change', async () => {
72
+ const state = SchemaStateManager.schemaStateFromSchema(schemaWithAcl)
73
+ await fs.mkdir(stateDir, { recursive: true })
74
+
75
+ expect(await manager.writeState(state)).toBe(true)
76
+ expect(await manager.writeState(state)).toBe(false)
77
+ })
78
+
79
+ test('writeState detects a change when a state part differs', async () => {
80
+ await manager.extractState(schemaWithAcl)
81
+
82
+ const modified = SchemaStateManager.schemaStateFromSchema({
83
+ ...schemaWithAcl,
84
+ acl: { roles: {} },
85
+ })
86
+ expect(await manager.writeState(modified, { dryRun: true })).toBe(true)
87
+ })
88
+
89
+ test('readState throws a descriptive error when a state file is missing', async () => {
90
+ await fs.mkdir(stateDir, { recursive: true })
91
+ await fs.writeFile(path.join(stateDir, 'acl.json'), JSON.stringify({ roles: {} }))
92
+ // validation.json / actions.json / settings.json are missing
93
+
94
+ await expect(manager.readState()).rejects.toThrow(/Failed to read schema state file/)
95
+ })
96
+ })