@contember/schema-migrations 2.2.0-alpha.6 → 2.2.0-alpha.8
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/development/src/modifications/acl/PatchAclSchemaModification.cjs +4 -6
- package/dist/development/src/modifications/acl/PatchAclSchemaModification.cjs.map +1 -1
- package/dist/development/src/modifications/acl/PatchAclSchemaModification.js +4 -6
- package/dist/development/src/modifications/acl/PatchAclSchemaModification.js.map +1 -1
- package/dist/development/src/modifications/columns/CreateColumnModification.cjs +2 -1
- package/dist/development/src/modifications/columns/CreateColumnModification.cjs.map +1 -1
- package/dist/development/src/modifications/columns/CreateColumnModification.js +2 -1
- package/dist/development/src/modifications/columns/CreateColumnModification.js.map +1 -1
- package/dist/development/src/utils/applyPatchCopyOnWrite.cjs +51 -0
- package/dist/development/src/utils/applyPatchCopyOnWrite.cjs.map +1 -0
- package/dist/development/src/utils/applyPatchCopyOnWrite.js +51 -0
- package/dist/development/src/utils/applyPatchCopyOnWrite.js.map +1 -0
- package/dist/production/src/modifications/acl/PatchAclSchemaModification.cjs +4 -6
- package/dist/production/src/modifications/acl/PatchAclSchemaModification.cjs.map +1 -1
- package/dist/production/src/modifications/acl/PatchAclSchemaModification.js +4 -6
- package/dist/production/src/modifications/acl/PatchAclSchemaModification.js.map +1 -1
- package/dist/production/src/modifications/columns/CreateColumnModification.cjs +2 -1
- package/dist/production/src/modifications/columns/CreateColumnModification.cjs.map +1 -1
- package/dist/production/src/modifications/columns/CreateColumnModification.js +2 -1
- package/dist/production/src/modifications/columns/CreateColumnModification.js.map +1 -1
- package/dist/production/src/utils/applyPatchCopyOnWrite.cjs +51 -0
- package/dist/production/src/utils/applyPatchCopyOnWrite.cjs.map +1 -0
- package/dist/production/src/utils/applyPatchCopyOnWrite.js +51 -0
- package/dist/production/src/utils/applyPatchCopyOnWrite.js.map +1 -0
- package/dist/tests/cases/unit/patchAclSchemaModification.test.d.ts +2 -0
- package/dist/tests/cases/unit/patchAclSchemaModification.test.d.ts.map +1 -0
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/dist/types/modifications/acl/PatchAclSchemaModification.d.ts.map +1 -1
- package/dist/types/modifications/columns/CreateColumnModification.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/utils/applyPatchCopyOnWrite.d.ts +10 -0
- package/dist/types/utils/applyPatchCopyOnWrite.d.ts.map +1 -0
- package/package.json +7 -7
- package/src/modifications/acl/PatchAclSchemaModification.ts +5 -6
- package/src/modifications/columns/CreateColumnModification.ts +2 -1
- package/src/utils/applyPatchCopyOnWrite.ts +63 -0
- package/tests/cases/integration/createColumn.test.ts +28 -0
- package/tests/cases/unit/patchAclSchemaModification.test.ts +144 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Operation } from 'rfc6902';
|
|
2
|
+
/**
|
|
3
|
+
* Applies the patch to a copy of `root`, leaving `root` untouched. Only the containers on the paths
|
|
4
|
+
* the patch writes to are copied; everything else stays shared with `root`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const applyPatchCopyOnWrite: <T extends object>(root: T, patch: Operation[]) => {
|
|
7
|
+
result: T;
|
|
8
|
+
errors: Error[];
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=applyPatchCopyOnWrite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"applyPatchCopyOnWrite.d.ts","sourceRoot":"","sources":["../../../src/utils/applyPatchCopyOnWrite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,SAAS,EAAW,MAAM,SAAS,CAAA;AAsBxD;;;GAGG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,SAAS,MAAM,QAAQ,CAAC,SAAS,SAAS,EAAE,KAAG;IAAE,MAAM,EAAE,CAAC,CAAC;IAAC,MAAM,EAAE,KAAK,EAAE,CAAA;CAiBjH,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contember/schema-migrations",
|
|
3
|
-
"version": "2.2.0-alpha.
|
|
3
|
+
"version": "2.2.0-alpha.8",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "./dist/production/index.js",
|
|
6
6
|
"typings": "./dist/types/index.d.ts",
|
|
@@ -23,16 +23,16 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@contember/database": "2.2.0-alpha.
|
|
27
|
-
"@contember/database-migrations": "2.2.0-alpha.
|
|
28
|
-
"@contember/engine-common": "2.2.0-alpha.
|
|
29
|
-
"@contember/schema": "2.2.0-alpha.
|
|
30
|
-
"@contember/schema-utils": "2.2.0-alpha.
|
|
26
|
+
"@contember/database": "2.2.0-alpha.8",
|
|
27
|
+
"@contember/database-migrations": "2.2.0-alpha.8",
|
|
28
|
+
"@contember/engine-common": "2.2.0-alpha.8",
|
|
29
|
+
"@contember/schema": "2.2.0-alpha.8",
|
|
30
|
+
"@contember/schema-utils": "2.2.0-alpha.8",
|
|
31
31
|
"fast-deep-equal": "^3.1.3",
|
|
32
32
|
"rfc6902": "^5.2.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@contember/schema-definition": "2.2.0-alpha.
|
|
35
|
+
"@contember/schema-definition": "2.2.0-alpha.8"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"pg": "^8.20.0"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SchemaUpdater } from '../utils/schemaUpdateUtils.js'
|
|
2
2
|
import { createModificationType, ModificationHandler } from '../ModificationHandler.js'
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import { Operation } from 'rfc6902'
|
|
4
|
+
import { applyPatchCopyOnWrite } from '../../utils/applyPatchCopyOnWrite.js'
|
|
5
5
|
|
|
6
6
|
export class PatchAclSchemaModificationHandler implements ModificationHandler<PatchAclSchemaModificationData> {
|
|
7
7
|
constructor(private readonly data: PatchAclSchemaModificationData) {}
|
|
@@ -10,10 +10,9 @@ export class PatchAclSchemaModificationHandler implements ModificationHandler<Pa
|
|
|
10
10
|
|
|
11
11
|
public getSchemaUpdater(): SchemaUpdater {
|
|
12
12
|
return ({ schema }) => {
|
|
13
|
-
const acl =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
throw result[0]
|
|
13
|
+
const { result: acl, errors } = applyPatchCopyOnWrite(schema.acl, this.data.patch)
|
|
14
|
+
if (errors.length > 0) {
|
|
15
|
+
throw errors[0]
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
return {
|
|
@@ -6,6 +6,7 @@ import { isColumn } from '@contember/schema-utils'
|
|
|
6
6
|
import { createFields } from '../utils/diffUtils.js'
|
|
7
7
|
import { getColumnSqlType } from '../utils/columnUtils.js'
|
|
8
8
|
import { fillSeed, formatSeedExpression } from './columnUtils.js'
|
|
9
|
+
import { wrapIdentifier } from '../../utils/dbHelpers.js'
|
|
9
10
|
|
|
10
11
|
export class CreateColumnModificationHandler implements ModificationHandler<CreateColumnModificationData> {
|
|
11
12
|
constructor(private readonly data: CreateColumnModificationData, private readonly schema: Schema) {}
|
|
@@ -32,7 +33,7 @@ export class CreateColumnModificationHandler implements ModificationHandler<Crea
|
|
|
32
33
|
type: columnType,
|
|
33
34
|
notNull: !column.nullable && seedExpression === null,
|
|
34
35
|
sequenceGenerated: column.sequence,
|
|
35
|
-
collation: column.collation,
|
|
36
|
+
collation: column.collation !== undefined ? wrapIdentifier(column.collation) : undefined,
|
|
36
37
|
},
|
|
37
38
|
})
|
|
38
39
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { applyPatch, Operation, Pointer } from 'rfc6902'
|
|
2
|
+
|
|
3
|
+
type JsonContainer = { [key: string]: unknown } | unknown[]
|
|
4
|
+
|
|
5
|
+
// Pointer.evaluate in rfc6902 skips these tokens, so the copy walk must skip them too.
|
|
6
|
+
const skippedTokens = new Set(['__proto__', 'constructor', 'prototype'])
|
|
7
|
+
|
|
8
|
+
const isContainer = (value: unknown): value is JsonContainer => typeof value === 'object' && value !== null
|
|
9
|
+
|
|
10
|
+
const shallowCopy = (value: JsonContainer): JsonContainer => Array.isArray(value) ? [...value] : { ...value }
|
|
11
|
+
|
|
12
|
+
const getMutatedPointers = (operation: Operation): string[] => {
|
|
13
|
+
switch (operation.op) {
|
|
14
|
+
case 'test':
|
|
15
|
+
return []
|
|
16
|
+
case 'move':
|
|
17
|
+
return [operation.from, operation.path]
|
|
18
|
+
default:
|
|
19
|
+
return [operation.path]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Applies the patch to a copy of `root`, leaving `root` untouched. Only the containers on the paths
|
|
25
|
+
* the patch writes to are copied; everything else stays shared with `root`.
|
|
26
|
+
*/
|
|
27
|
+
export const applyPatchCopyOnWrite = <T extends object>(root: T, patch: Operation[]): { result: T; errors: Error[] } => {
|
|
28
|
+
const result = { ...root }
|
|
29
|
+
const copies = new Set<unknown>([result])
|
|
30
|
+
const errors: Error[] = []
|
|
31
|
+
for (const operation of patch) {
|
|
32
|
+
// Each path is copied against the tree as the previous operations left it: array indices shift
|
|
33
|
+
// and `move` relocates an uncopied original, so a path resolved up front can miss the target.
|
|
34
|
+
for (const pointer of getMutatedPointers(operation)) {
|
|
35
|
+
copyParents(result, pointer, copies)
|
|
36
|
+
}
|
|
37
|
+
for (const error of applyPatch(result, [operation])) {
|
|
38
|
+
if (error !== null) {
|
|
39
|
+
errors.push(error)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { result, errors }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const copyParents = (root: object, pointer: string, copies: Set<unknown>): void => {
|
|
47
|
+
const tokens = Pointer.fromJSON(pointer).tokens.slice(1)
|
|
48
|
+
// rfc6902 writes into the node reached before the last token, unless that token is skipped.
|
|
49
|
+
const lastToken = tokens.at(-1)
|
|
50
|
+
const writtenTokens = lastToken !== undefined && skippedTokens.has(lastToken) ? tokens : tokens.slice(0, -1)
|
|
51
|
+
const parentTokens = writtenTokens.filter(token => !skippedTokens.has(token))
|
|
52
|
+
let node: object = root
|
|
53
|
+
for (const token of parentTokens) {
|
|
54
|
+
const child: unknown = Reflect.get(node, token)
|
|
55
|
+
if (!isContainer(child)) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const childCopy = copies.has(child) ? child : shallowCopy(child)
|
|
59
|
+
copies.add(childCopy)
|
|
60
|
+
Reflect.set(node, token, childCopy)
|
|
61
|
+
node = childCopy
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -229,3 +229,31 @@ describe('create numeric column', () =>
|
|
|
229
229
|
],
|
|
230
230
|
sql: SQL`ALTER TABLE "author" ADD "balance" numeric(20, 9);`,
|
|
231
231
|
}))
|
|
232
|
+
|
|
233
|
+
describe('create a column with a collation', () =>
|
|
234
|
+
testMigrations({
|
|
235
|
+
original: createSchema({
|
|
236
|
+
Author: class Author {
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
updated: createSchema({
|
|
240
|
+
Author: class Author {
|
|
241
|
+
name = def.stringColumn().collation('und-x-icu')
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
diff: [
|
|
245
|
+
{
|
|
246
|
+
modification: 'createColumn',
|
|
247
|
+
entityName: 'Author',
|
|
248
|
+
field: {
|
|
249
|
+
columnName: 'name',
|
|
250
|
+
name: 'name',
|
|
251
|
+
nullable: true,
|
|
252
|
+
type: Model.ColumnType.String,
|
|
253
|
+
columnType: 'text',
|
|
254
|
+
collation: 'und-x-icu',
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
sql: SQL`ALTER TABLE "author" ADD "name" text COLLATE "und-x-icu";`,
|
|
259
|
+
}))
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Acl, Schema } from '@contember/schema'
|
|
3
|
+
import { emptySchema } from '@contember/schema-utils'
|
|
4
|
+
import { createPatch } from 'rfc6902'
|
|
5
|
+
import { ModificationHandlerFactory, SchemaMigrator } from '../../../src/index.js'
|
|
6
|
+
|
|
7
|
+
const schemaMigrator = new SchemaMigrator(new ModificationHandlerFactory(ModificationHandlerFactory.defaultFactoryMap))
|
|
8
|
+
|
|
9
|
+
const role = (entityNames: string[]): Acl.RolePermissions => ({
|
|
10
|
+
variables: {},
|
|
11
|
+
stages: '*',
|
|
12
|
+
entities: Object.fromEntries(entityNames.map(name => [name, { predicates: {}, operations: { read: { id: true } } }])),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const schemaWithAcl = (): Schema => ({
|
|
16
|
+
...emptySchema,
|
|
17
|
+
acl: {
|
|
18
|
+
roles: {
|
|
19
|
+
editor: role(['Article', 'Tag']),
|
|
20
|
+
reader: role(['Article']),
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('patchAclSchema', () => {
|
|
26
|
+
test('leaves the input schema untouched and shares the branches the patch does not write to', () => {
|
|
27
|
+
const schema = schemaWithAcl()
|
|
28
|
+
const snapshot = structuredClone(schema)
|
|
29
|
+
|
|
30
|
+
const result = schemaMigrator.applyModifications(schema, [{
|
|
31
|
+
modification: 'patchAclSchema',
|
|
32
|
+
patch: [
|
|
33
|
+
{ op: 'replace', path: '/roles/editor/entities/Article/operations/read/id', value: false },
|
|
34
|
+
{ op: 'remove', path: '/roles/editor/entities/Tag' },
|
|
35
|
+
{ op: 'add', path: '/roles/writer', value: role([]) },
|
|
36
|
+
{ op: 'add', path: '/roles/writer/entities/Article', value: { predicates: {}, operations: {} } },
|
|
37
|
+
],
|
|
38
|
+
}], 6)
|
|
39
|
+
|
|
40
|
+
expect(schema).toStrictEqual(snapshot)
|
|
41
|
+
expect(result.acl.roles.editor.entities).toStrictEqual({
|
|
42
|
+
Article: { predicates: {}, operations: { read: { id: false } } },
|
|
43
|
+
})
|
|
44
|
+
expect(result.acl.roles.writer.entities).toStrictEqual({ Article: { predicates: {}, operations: {} } })
|
|
45
|
+
expect(result.acl.roles.reader).toBe(schema.acl.roles.reader)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('move copies both the source and the target parent', () => {
|
|
49
|
+
const schema = schemaWithAcl()
|
|
50
|
+
const snapshot = structuredClone(schema)
|
|
51
|
+
|
|
52
|
+
const result = schemaMigrator.applyModifications(schema, [{
|
|
53
|
+
modification: 'patchAclSchema',
|
|
54
|
+
patch: [{ op: 'move', from: '/roles/editor/entities/Tag', path: '/roles/reader/entities/Tag' }],
|
|
55
|
+
}], 6)
|
|
56
|
+
|
|
57
|
+
expect(schema).toStrictEqual(snapshot)
|
|
58
|
+
expect(Object.keys(result.acl.roles.editor.entities)).toStrictEqual(['Article'])
|
|
59
|
+
expect(Object.keys(result.acl.roles.reader.entities)).toStrictEqual(['Article', 'Tag'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('does not keep a reference to the patch values', () => {
|
|
63
|
+
const value = role(['Article'])
|
|
64
|
+
const modification = { modification: 'patchAclSchema', patch: [{ op: 'add', path: '/roles/writer', value }] }
|
|
65
|
+
|
|
66
|
+
const first = schemaMigrator.applyModifications(schemaWithAcl(), [modification], 6)
|
|
67
|
+
schemaMigrator.applyModifications(first, [{
|
|
68
|
+
modification: 'patchAclSchema',
|
|
69
|
+
patch: [{ op: 'remove', path: '/roles/writer/entities/Article' }],
|
|
70
|
+
}], 6)
|
|
71
|
+
|
|
72
|
+
expect(value).toStrictEqual(role(['Article']))
|
|
73
|
+
expect(first.acl.roles.writer.entities).toStrictEqual(role(['Article']).entities)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('follows array indices that earlier operations of the patch shifted', () => {
|
|
77
|
+
const withPredicate = (predicate: Acl.PredicateDefinition): Schema => ({
|
|
78
|
+
...emptySchema,
|
|
79
|
+
acl: { roles: { editor: { ...role(['Article']), entities: { Article: { predicates: { p: predicate }, operations: {} } } } } },
|
|
80
|
+
})
|
|
81
|
+
const schema = withPredicate({ or: [{ a: { eq: 1 } }, { b: { eq: 2 } }, { c: { eq: 3, isNull: false } }] })
|
|
82
|
+
const target = withPredicate({ or: [{ b: { eq: 2 } }, { c: { eq: 3 } }] })
|
|
83
|
+
const snapshot = structuredClone(schema)
|
|
84
|
+
|
|
85
|
+
const result = schemaMigrator.applyModifications(schema, [{
|
|
86
|
+
modification: 'patchAclSchema',
|
|
87
|
+
patch: createPatch(schema.acl, target.acl),
|
|
88
|
+
}], 6)
|
|
89
|
+
|
|
90
|
+
expect(schema).toStrictEqual(snapshot)
|
|
91
|
+
expect(result.acl).toStrictEqual(target.acl)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('copies a moved subtree before writing into it', () => {
|
|
95
|
+
const schema = schemaWithAcl()
|
|
96
|
+
const snapshot = structuredClone(schema)
|
|
97
|
+
|
|
98
|
+
const result = schemaMigrator.applyModifications(schema, [{
|
|
99
|
+
modification: 'patchAclSchema',
|
|
100
|
+
patch: [
|
|
101
|
+
{ op: 'move', from: '/roles/editor', path: '/roles/writer' },
|
|
102
|
+
{ op: 'remove', path: '/roles/writer/entities/Tag' },
|
|
103
|
+
],
|
|
104
|
+
}], 6)
|
|
105
|
+
|
|
106
|
+
expect(schema).toStrictEqual(snapshot)
|
|
107
|
+
expect(Object.keys(result.acl.roles.writer.entities)).toStrictEqual(['Article'])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('replaying the same migrations twice gives the same schema', () => {
|
|
111
|
+
const modifications = [
|
|
112
|
+
{ modification: 'updateAclSchema', schema: { roles: { editor: role(['Article', 'Tag']) } } },
|
|
113
|
+
{
|
|
114
|
+
modification: 'patchAclSchema',
|
|
115
|
+
patch: [
|
|
116
|
+
{ op: 'move', from: '/roles/editor', path: '/roles/writer' },
|
|
117
|
+
{ op: 'remove', path: '/roles/writer/entities/Tag' },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
const first = schemaMigrator.applyModifications(emptySchema, modifications, 6)
|
|
123
|
+
const second = schemaMigrator.applyModifications(emptySchema, modifications, 6)
|
|
124
|
+
|
|
125
|
+
expect(second).toStrictEqual(first)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('skips the tokens rfc6902 skips when copying', () => {
|
|
129
|
+
const schema = schemaWithAcl()
|
|
130
|
+
const snapshot = structuredClone(schema)
|
|
131
|
+
|
|
132
|
+
schemaMigrator.applyModifications(schema, [{
|
|
133
|
+
modification: 'patchAclSchema',
|
|
134
|
+
patch: [
|
|
135
|
+
{ op: 'replace', path: '/roles/editor/__proto__/entities/Article/operations/read/id', value: false },
|
|
136
|
+
{ op: 'add', path: '/roles/constructor/reader/entities/Tag', value: {} },
|
|
137
|
+
{ op: 'add', path: '/roles/reader/__proto__', value: { injected: true } },
|
|
138
|
+
],
|
|
139
|
+
}], 6)
|
|
140
|
+
|
|
141
|
+
expect(schema).toStrictEqual(snapshot)
|
|
142
|
+
expect(Object.getPrototypeOf(schema.acl.roles.reader)).toBe(Object.prototype)
|
|
143
|
+
})
|
|
144
|
+
})
|