@tldraw/store 5.2.0-next.ee0fa4d6244f → 5.2.1

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 (64) hide show
  1. package/DOCS.md +790 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/ImmutableMap.js +0 -13
  5. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  6. package/dist-cjs/lib/RecordType.js +1 -1
  7. package/dist-cjs/lib/RecordType.js.map +2 -2
  8. package/dist-cjs/lib/RecordsDiff.js +26 -14
  9. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  10. package/dist-cjs/lib/Store.js +6 -5
  11. package/dist-cjs/lib/Store.js.map +2 -2
  12. package/dist-cjs/lib/StoreSchema.js +1 -1
  13. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  14. package/dist-cjs/lib/executeQuery.js +1 -1
  15. package/dist-cjs/lib/executeQuery.js.map +2 -2
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/ImmutableMap.mjs +0 -13
  18. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  19. package/dist-esm/lib/RecordType.mjs +1 -1
  20. package/dist-esm/lib/RecordType.mjs.map +2 -2
  21. package/dist-esm/lib/RecordsDiff.mjs +26 -14
  22. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  23. package/dist-esm/lib/Store.mjs +7 -6
  24. package/dist-esm/lib/Store.mjs.map +2 -2
  25. package/dist-esm/lib/StoreSchema.mjs +1 -1
  26. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  27. package/dist-esm/lib/executeQuery.mjs +1 -1
  28. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  29. package/package.json +9 -5
  30. package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
  31. package/src/lib/AtomSet.test.ts +116 -0
  32. package/src/lib/BaseRecord.test.ts +10 -22
  33. package/src/lib/ImmutableMap.test.ts +114 -71
  34. package/src/lib/ImmutableMap.ts +1 -0
  35. package/src/lib/IncrementalSetConstructor.test.ts +76 -81
  36. package/src/lib/RecordType.test.ts +216 -0
  37. package/src/lib/RecordType.ts +1 -1
  38. package/src/lib/RecordsDiff.test.ts +112 -106
  39. package/src/lib/RecordsDiff.ts +43 -18
  40. package/src/lib/Store.test.ts +570 -630
  41. package/src/lib/Store.ts +9 -10
  42. package/src/lib/StoreListeners.test.ts +462 -0
  43. package/src/lib/StoreQueries.test.ts +586 -434
  44. package/src/lib/StoreSchema.test.ts +1012 -174
  45. package/src/lib/StoreSchema.ts +1 -1
  46. package/src/lib/StoreSideEffects.test.ts +546 -158
  47. package/src/lib/devFreeze.test.ts +94 -124
  48. package/src/lib/executeQuery.test.ts +77 -31
  49. package/src/lib/executeQuery.ts +3 -1
  50. package/src/lib/migrate.test.ts +273 -296
  51. package/src/lib/setUtils.test.ts +38 -79
  52. package/src/lib/test/createMigrations.test.ts +0 -75
  53. package/src/lib/test/dependsOn.test.ts +0 -166
  54. package/src/lib/test/getMigrationsSince.test.ts +0 -121
  55. package/src/lib/test/migrate.test.ts +0 -118
  56. package/src/lib/test/migratePersistedRecord.test.ts +0 -265
  57. package/src/lib/test/migrationCaching.test.ts +0 -209
  58. package/src/lib/test/recordStore.test.ts +0 -1567
  59. package/src/lib/test/recordStoreQueries.test.ts +0 -814
  60. package/src/lib/test/recordType.test.ts +0 -19
  61. package/src/lib/test/sortMigrations.test.ts +0 -83
  62. package/src/lib/test/upgradeSchema.test.ts +0 -80
  63. package/src/lib/test/validate.test.ts +0 -178
  64. package/src/lib/test/validateMigrations.test.ts +0 -165
@@ -3,8 +3,6 @@ import { UnknownRecord } from './BaseRecord'
3
3
  import {
4
4
  type Migration,
5
5
  type MigrationId,
6
- type MigrationSequence,
7
- type StandaloneDependsOn,
8
6
  createMigrationIds,
9
7
  createMigrationSequence,
10
8
  createRecordMigrationSequence,
@@ -14,50 +12,23 @@ import {
14
12
  } from './migrate'
15
13
  import { SerializedStore } from './Store'
16
14
 
17
- describe('createMigrationIds', () => {
18
- it('creates properly formatted migration IDs', () => {
19
- const ids = createMigrationIds('com.myapp.book', {
20
- addGenre: 1,
21
- addPublisher: 2,
22
- removeOldField: 3,
23
- })
24
-
25
- expect(ids).toEqual({
26
- addGenre: 'com.myapp.book/1',
27
- addPublisher: 'com.myapp.book/2',
28
- removeOldField: 'com.myapp.book/3',
29
- })
30
- })
31
- })
15
+ // Tests for SPEC.md §16 (migration authoring) and §17 (migration sorting).
16
+ // Rule IDs like [M2] in test names refer to that document.
32
17
 
33
- describe('parseMigrationId', () => {
34
- it('parses migration IDs correctly', () => {
35
- expect(parseMigrationId('com.myapp.book/5' as MigrationId)).toEqual({
36
- sequenceId: 'com.myapp.book',
37
- version: 5,
38
- })
39
- expect(parseMigrationId('test/1' as MigrationId)).toEqual({
40
- sequenceId: 'test',
41
- version: 1,
42
- })
43
- expect(parseMigrationId('com.example.app/42' as MigrationId)).toEqual({
44
- sequenceId: 'com.example.app',
45
- version: 42,
46
- })
47
- })
18
+ const m = (id: MigrationId, others?: { dependsOn?: MigrationId[] }): Migration => ({
19
+ ...others,
20
+ id,
21
+ scope: 'record',
22
+ up() {
23
+ // noop
24
+ },
48
25
  })
49
26
 
50
- describe('createMigrationSequence', () => {
51
- it('creates and validates migration sequences', () => {
52
- const migration: Migration = {
53
- id: 'test/1' as MigrationId,
54
- scope: 'record',
55
- up: (record: UnknownRecord) => ({ ...record, newField: 'default' }),
56
- }
57
-
27
+ describe('migration authoring (M)', () => {
28
+ it('[M1] createMigrationSequence returns the validated sequence', () => {
58
29
  const sequence = createMigrationSequence({
59
30
  sequenceId: 'test',
60
- sequence: [migration],
31
+ sequence: [m('test/1')],
61
32
  retroactive: false,
62
33
  })
63
34
 
@@ -66,39 +37,111 @@ describe('createMigrationSequence', () => {
66
37
  expect(sequence.sequence).toHaveLength(1)
67
38
  })
68
39
 
69
- it('squashes standalone dependsOn entries', () => {
70
- const dependsOn: StandaloneDependsOn = {
71
- dependsOn: ['other/1' as MigrationId, 'another/2' as MigrationId],
72
- }
40
+ it('[M1] retroactive defaults to true', () => {
41
+ const sequence = createMigrationSequence({
42
+ sequenceId: 'test',
43
+ sequence: [m('test/1')],
44
+ })
45
+ expect(sequence.retroactive).toBe(true)
46
+ })
47
+
48
+ it('[M1] createMigrationSequence validates the sequence', () => {
49
+ expect(() =>
50
+ createMigrationSequence({
51
+ sequenceId: 'test/invalid',
52
+ sequence: [],
53
+ })
54
+ ).toThrow()
55
+
56
+ expect(() =>
57
+ createMigrationSequence({
58
+ sequenceId: 'test',
59
+ sequence: [m('test/2')],
60
+ })
61
+ ).toThrow()
62
+ })
63
+
64
+ it('[M2] a standalone dependsOn entry squashes into the next migration', () => {
65
+ const result = createMigrationSequence({
66
+ sequenceId: 'foo',
67
+ retroactive: false,
68
+ sequence: [{ dependsOn: ['bar/1'] }, m('foo/1')],
69
+ })
73
70
 
74
- const migration: Migration = {
75
- id: 'test/1' as MigrationId,
76
- scope: 'record',
77
- up: (record: UnknownRecord) => record,
78
- dependsOn: ['existing/1' as MigrationId],
79
- }
71
+ expect(result.sequence).toHaveLength(1)
72
+ expect(result.sequence[0].dependsOn).toEqual(['bar/1'])
73
+ })
80
74
 
75
+ it('[M2] a squashed dependsOn prepends to the migration’s own dependsOn', () => {
81
76
  const sequence = createMigrationSequence({
82
77
  sequenceId: 'test',
83
- sequence: [dependsOn, migration],
78
+ sequence: [
79
+ { dependsOn: ['other/1' as MigrationId, 'another/2' as MigrationId] },
80
+ m('test/1', { dependsOn: ['existing/1' as MigrationId] }),
81
+ ],
84
82
  })
85
83
 
86
84
  expect(sequence.sequence).toHaveLength(1)
87
85
  expect(sequence.sequence[0].dependsOn).toEqual(['other/1', 'another/2', 'existing/1'])
88
86
  })
89
87
 
90
- it('validates the migration sequence', () => {
91
- expect(() => {
88
+ it('[M2] a standalone dependsOn between migrations attaches to the one after it', () => {
89
+ const result = createMigrationSequence({
90
+ sequenceId: 'foo',
91
+ retroactive: false,
92
+ sequence: [m('foo/1'), { dependsOn: ['bar/1'] }, m('foo/2')],
93
+ })
94
+
95
+ expect(result.sequence).toHaveLength(2)
96
+ expect(result.sequence[0].dependsOn ?? []).toEqual([])
97
+ expect(result.sequence[1].dependsOn).toEqual(['bar/1'])
98
+ })
99
+
100
+ it('[M2] a standalone dependsOn with no following migration is dropped', () => {
101
+ expect(
92
102
  createMigrationSequence({
93
- sequenceId: 'test/invalid',
94
- sequence: [],
103
+ sequenceId: 'foo',
104
+ retroactive: false,
105
+ sequence: [{ dependsOn: ['bar/1'] }],
106
+ }).sequence
107
+ ).toHaveLength(0)
108
+
109
+ const trailing = createMigrationSequence({
110
+ sequenceId: 'foo',
111
+ retroactive: false,
112
+ sequence: [m('foo/1'), { dependsOn: ['bar/1'] }],
113
+ })
114
+ expect(trailing.sequence).toHaveLength(1)
115
+ expect(trailing.sequence[0].dependsOn ?? []).toEqual([])
116
+ })
117
+
118
+ it('[M4] createMigrationIds formats sequenceId/version ids', () => {
119
+ expect(
120
+ createMigrationIds('com.myapp.book', {
121
+ addGenre: 1,
122
+ addPublisher: 2,
123
+ removeOldField: 3,
95
124
  })
96
- }).toThrow()
125
+ ).toEqual({
126
+ addGenre: 'com.myapp.book/1',
127
+ addPublisher: 'com.myapp.book/2',
128
+ removeOldField: 'com.myapp.book/3',
129
+ })
97
130
  })
98
- })
99
131
 
100
- describe('createRecordMigrationSequence', () => {
101
- it('creates record-scoped migrations with type filtering', () => {
132
+ it('[M4] parseMigrationId splits the sequence id and version', () => {
133
+ expect(parseMigrationId('com.myapp.book/5')).toEqual({
134
+ sequenceId: 'com.myapp.book',
135
+ version: 5,
136
+ })
137
+ expect(parseMigrationId('test/1')).toEqual({ sequenceId: 'test', version: 1 })
138
+ expect(parseMigrationId('com.example.app/42')).toEqual({
139
+ sequenceId: 'com.example.app',
140
+ version: 42,
141
+ })
142
+ })
143
+
144
+ it('[M5] createRecordMigrationSequence filters by record type', () => {
102
145
  const sequence = createRecordMigrationSequence({
103
146
  recordType: 'book',
104
147
  sequenceId: 'com.myapp.book',
@@ -114,16 +157,12 @@ describe('createRecordMigrationSequence', () => {
114
157
  expect(sequence.sequence).toHaveLength(1)
115
158
  expect(sequence.sequence[0].scope).toBe('record')
116
159
 
117
- // Test the filter function
118
160
  const migration = sequence.sequence[0] as Extract<Migration, { scope: 'record' }>
119
- const bookRecord = { id: 'book-1' as any, typeName: 'book' } as any as UnknownRecord
120
- const userRecord = { id: 'user-1' as any, typeName: 'user' } as any as UnknownRecord
121
-
122
- expect(migration.filter?.(bookRecord)).toBe(true)
123
- expect(migration.filter?.(userRecord)).toBe(false)
161
+ expect(migration.filter?.({ id: 'book-1', typeName: 'book' } as any)).toBe(true)
162
+ expect(migration.filter?.({ id: 'user-1', typeName: 'user' } as any)).toBe(false)
124
163
  })
125
164
 
126
- it('combines record type filter with custom filter', () => {
165
+ it('[M5] a sequence-level filter composes with the record type filter', () => {
127
166
  const sequence = createRecordMigrationSequence({
128
167
  recordType: 'shape',
129
168
  filter: (record) => (record as any).shapeType === 'rectangle',
@@ -137,264 +176,202 @@ describe('createRecordMigrationSequence', () => {
137
176
  })
138
177
 
139
178
  const migration = sequence.sequence[0] as Extract<Migration, { scope: 'record' }>
140
- const rectangleShape = {
141
- id: 'shape-1' as any,
142
- typeName: 'shape',
143
- shapeType: 'rectangle',
144
- } as any as UnknownRecord
145
- const circleShape = {
146
- id: 'shape-2' as any,
147
- typeName: 'shape',
148
- shapeType: 'circle',
149
- } as any as UnknownRecord
150
- const userRecord = { id: 'user-1' as any, typeName: 'user' } as any as UnknownRecord
151
-
152
- expect(migration.filter?.(rectangleShape)).toBe(true)
153
- expect(migration.filter?.(circleShape)).toBe(false)
154
- expect(migration.filter?.(userRecord)).toBe(false)
179
+ expect(
180
+ migration.filter?.({ id: 'shape-1', typeName: 'shape', shapeType: 'rectangle' } as any)
181
+ ).toBe(true)
182
+ expect(
183
+ migration.filter?.({ id: 'shape-2', typeName: 'shape', shapeType: 'circle' } as any)
184
+ ).toBe(false)
185
+ expect(migration.filter?.({ id: 'user-1', typeName: 'user' } as any)).toBe(false)
155
186
  })
156
187
  })
157
188
 
158
- describe('validateMigrations', () => {
159
- it('validates sequential migration versions', () => {
160
- const sequence: MigrationSequence = {
161
- sequenceId: 'test',
162
- retroactive: true,
163
- sequence: [
164
- {
165
- id: 'test/1' as MigrationId,
166
- scope: 'record',
167
- up: (record: UnknownRecord) => record,
168
- },
169
- {
170
- id: 'test/2' as MigrationId,
171
- scope: 'record',
172
- up: (record: UnknownRecord) => record,
173
- },
174
- {
175
- id: 'test/3' as MigrationId,
176
- scope: 'store',
177
- up: (store: SerializedStore<UnknownRecord>) => store,
178
- },
179
- ],
180
- }
189
+ describe('validateMigrations (M)', () => {
190
+ it('[M3] accepts well-formed sequences, including mixed scopes', () => {
191
+ expect(() =>
192
+ validateMigrations({
193
+ sequenceId: 'test',
194
+ retroactive: true,
195
+ sequence: [
196
+ m('test/1'),
197
+ m('test/2'),
198
+ {
199
+ id: 'test/3',
200
+ scope: 'store',
201
+ up: (store: SerializedStore<UnknownRecord>) => store,
202
+ },
203
+ ],
204
+ })
205
+ ).not.toThrow()
206
+ })
181
207
 
182
- expect(() => validateMigrations(sequence)).not.toThrow()
208
+ it('[M3] throws on an invalid sequence id', () => {
209
+ expect(() =>
210
+ validateMigrations({ retroactive: false, sequence: [], sequenceId: 'foo/bar' })
211
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: sequenceId cannot contain a '/', got foo/bar]`)
212
+
213
+ expect(() =>
214
+ validateMigrations({ retroactive: false, sequence: [], sequenceId: '' })
215
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: sequenceId must be a non-empty string]`)
183
216
  })
184
217
 
185
- it('throws on sequence ID with slash', () => {
186
- const sequence: MigrationSequence = {
187
- sequenceId: 'test/invalid',
188
- retroactive: true,
189
- sequence: [],
190
- }
218
+ it('[M3] throws on a migration id outside the sequence or in the wrong format', () => {
219
+ expect(() =>
220
+ validateMigrations({
221
+ retroactive: false,
222
+ sequence: [m('foo.1' as any)],
223
+ sequenceId: 'foo',
224
+ })
225
+ ).toThrowErrorMatchingInlineSnapshot(
226
+ `[Error: Every migration in sequence 'foo' must have an id starting with 'foo/'. Got invalid id: 'foo.1']`
227
+ )
228
+
229
+ expect(() =>
230
+ validateMigrations({
231
+ retroactive: false,
232
+ sequence: [m('foo/one' as any)],
233
+ sequenceId: 'foo',
234
+ })
235
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid migration id: 'foo/one']`)
191
236
 
192
- expect(() => validateMigrations(sequence)).toThrow(
193
- "sequenceId cannot contain a '/', got test/invalid"
237
+ expect(() =>
238
+ validateMigrations({
239
+ retroactive: false,
240
+ sequence: [m('foo/1'), m('foo.2' as any)],
241
+ sequenceId: 'foo',
242
+ })
243
+ ).toThrowErrorMatchingInlineSnapshot(
244
+ `[Error: Every migration in sequence 'foo' must have an id starting with 'foo/'. Got invalid id: 'foo.2']`
194
245
  )
246
+
247
+ expect(() =>
248
+ validateMigrations({
249
+ retroactive: false,
250
+ sequence: [m('foo/1'), m('foo/two' as any)],
251
+ sequenceId: 'foo',
252
+ })
253
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid migration id: 'foo/two']`)
195
254
  })
196
255
 
197
- it('throws on invalid migration ID format', () => {
198
- const sequence: MigrationSequence = {
199
- sequenceId: 'test',
200
- retroactive: true,
201
- sequence: [
202
- {
203
- id: 'invalid-id' as MigrationId,
204
- scope: 'record',
205
- up: (record: UnknownRecord) => record,
206
- },
207
- ],
208
- }
256
+ it('[M3] throws if the first version is not 1', () => {
257
+ expect(() =>
258
+ validateMigrations({
259
+ retroactive: false,
260
+ sequence: [m('foo/2')],
261
+ sequenceId: 'foo',
262
+ })
263
+ ).toThrowErrorMatchingInlineSnapshot(
264
+ `[Error: Expected the first migrationId to be 'foo/1' but got 'foo/2']`
265
+ )
266
+ })
209
267
 
210
- expect(() => validateMigrations(sequence)).toThrow(
211
- "Every migration in sequence 'test' must have an id starting with 'test/'. Got invalid id: 'invalid-id'"
268
+ it('[M3] throws if versions do not increase in increments of 1', () => {
269
+ expect(() =>
270
+ validateMigrations({
271
+ retroactive: false,
272
+ sequence: [m('foo/1'), m('foo/2'), m('foo/4')],
273
+ sequenceId: 'foo',
274
+ })
275
+ ).toThrowErrorMatchingInlineSnapshot(
276
+ `[Error: Migration id numbers must increase in increments of 1, expected foo/3 but got 'foo/4']`
212
277
  )
213
278
  })
279
+ })
214
280
 
215
- it('throws on first migration not starting at version 1', () => {
216
- const sequence: MigrationSequence = {
217
- sequenceId: 'test',
218
- retroactive: true,
219
- sequence: [
220
- {
221
- id: 'test/5' as MigrationId,
222
- scope: 'record',
223
- up: (record: UnknownRecord) => record,
224
- },
225
- ],
226
- }
281
+ describe('sortMigrations (MS)', () => {
282
+ const sort = (migrations: Migration[]) => sortMigrations(migrations).map((m) => m.id)
227
283
 
228
- expect(() => validateMigrations(sequence)).toThrow(
229
- "Expected the first migrationId to be 'test/1' but got 'test/5'"
230
- )
284
+ it('[MS1] sorts migrations within a sequence by version', () => {
285
+ expect(sort([m('foo/2'), m('foo/1')])).toEqual(['foo/1', 'foo/2'])
286
+ expect(sort([m('foo/1'), m('foo/2')])).toEqual(['foo/1', 'foo/2'])
231
287
  })
232
288
 
233
- it('throws on non-sequential migration versions', () => {
234
- const sequence: MigrationSequence = {
235
- sequenceId: 'test',
236
- retroactive: true,
237
- sequence: [
238
- {
239
- id: 'test/1' as MigrationId,
240
- scope: 'record',
241
- up: (record: UnknownRecord) => record,
242
- },
243
- {
244
- id: 'test/3' as MigrationId,
245
- scope: 'record',
246
- up: (record: UnknownRecord) => record,
247
- },
248
- ],
249
- }
289
+ it('[MS1] sorts each of several sequences by version', () => {
290
+ const result = sort([m('foo/2'), m('bar/2'), m('foo/1'), m('bar/1')])
291
+ expect(result.filter((id) => id.startsWith('foo/'))).toEqual(['foo/1', 'foo/2'])
292
+ expect(result.filter((id) => id.startsWith('bar/'))).toEqual(['bar/1', 'bar/2'])
293
+ })
250
294
 
251
- expect(() => validateMigrations(sequence)).toThrow(
252
- "Migration id numbers must increase in increments of 1, expected test/2 but got 'test/3'"
253
- )
295
+ it('[MS2] a migration sorts after its explicit dependencies', () => {
296
+ expect(
297
+ sort([m('foo/2'), m('bar/2'), m('foo/1'), m('bar/1', { dependsOn: ['foo/2'] })])
298
+ ).toEqual(['foo/1', 'foo/2', 'bar/1', 'bar/2'])
299
+
300
+ expect(
301
+ sort([m('foo/2'), m('bar/2'), m('foo/1', { dependsOn: ['bar/2'] }), m('bar/1')])
302
+ ).toEqual(['bar/1', 'bar/2', 'foo/1', 'foo/2'])
254
303
  })
255
- })
256
304
 
257
- describe('sortMigrations', () => {
258
- it('sorts migrations by sequence order', () => {
259
- const migration1: Migration = {
260
- id: 'app/1' as MigrationId,
261
- scope: 'record',
262
- up: (record: UnknownRecord) => record,
263
- }
264
- const migration2: Migration = {
265
- id: 'app/2' as MigrationId,
266
- scope: 'record',
267
- up: (record: UnknownRecord) => record,
268
- }
269
-
270
- expect(sortMigrations([migration2, migration1])).toEqual([migration1, migration2])
305
+ it('[MS2] handles chains of dependencies across sequences', () => {
306
+ const a1 = m('a/1')
307
+ const b1 = m('b/1', { dependsOn: ['a/1'] })
308
+ const c1 = m('c/1', { dependsOn: ['b/1'] })
309
+ const d1 = m('d/1', { dependsOn: ['a/1', 'c/1'] })
310
+
311
+ expect(sortMigrations([d1, c1, b1, a1])).toEqual([a1, b1, c1, d1])
271
312
  })
272
313
 
273
- it('respects explicit dependencies', () => {
274
- const libMigration: Migration = {
275
- id: 'lib/1' as MigrationId,
276
- scope: 'record',
277
- up: (record: UnknownRecord) => record,
278
- dependsOn: ['app/1' as MigrationId],
279
- }
280
- const appMigration: Migration = {
281
- id: 'app/1' as MigrationId,
282
- scope: 'record',
283
- up: (record: UnknownRecord) => record,
284
- }
285
-
286
- const sorted = sortMigrations([libMigration, appMigration])
287
- expect(sorted).toEqual([appMigration, libMigration])
314
+ it('[MS2] satisfies both sequence order and cross-sequence dependencies', () => {
315
+ const sorted = sort([
316
+ m('plugin/1', { dependsOn: ['app/2', 'lib/1'] }),
317
+ m('lib/2'),
318
+ m('lib/1', { dependsOn: ['app/1'] }),
319
+ m('app/2'),
320
+ m('app/1'),
321
+ ])
322
+
323
+ expect(sorted.indexOf('app/1')).toBeLessThan(sorted.indexOf('app/2'))
324
+ expect(sorted.indexOf('lib/1')).toBeLessThan(sorted.indexOf('lib/2'))
325
+ expect(sorted.indexOf('app/1')).toBeLessThan(sorted.indexOf('lib/1'))
326
+ expect(sorted.indexOf('app/2')).toBeLessThan(sorted.indexOf('plugin/1'))
327
+ expect(sorted.indexOf('lib/1')).toBeLessThan(sorted.indexOf('plugin/1'))
288
328
  })
289
329
 
290
- it('handles complex dependency chains', () => {
291
- const a1: Migration = {
292
- id: 'a/1' as MigrationId,
293
- scope: 'record',
294
- up: (record: UnknownRecord) => record,
295
- }
296
- const b1: Migration = {
297
- id: 'b/1' as MigrationId,
298
- scope: 'record',
299
- up: (record: UnknownRecord) => record,
300
- dependsOn: ['a/1' as MigrationId],
301
- }
302
- const c1: Migration = {
303
- id: 'c/1' as MigrationId,
304
- scope: 'record',
305
- up: (record: UnknownRecord) => record,
306
- dependsOn: ['b/1' as MigrationId],
307
- }
308
- const d1: Migration = {
309
- id: 'd/1' as MigrationId,
310
- scope: 'record',
311
- up: (record: UnknownRecord) => record,
312
- dependsOn: ['a/1' as MigrationId, 'c/1' as MigrationId],
313
- }
314
-
315
- const sorted = sortMigrations([d1, c1, b1, a1])
316
- expect(sorted).toEqual([a1, b1, c1, d1])
330
+ it('[MS3] schedules dependents close to their dependencies', () => {
331
+ // bar/3 depends on foo/1 — the bar sequence runs immediately after foo/1
332
+ expect(
333
+ sort([m('foo/2'), m('bar/3', { dependsOn: ['foo/1'] }), m('foo/1'), m('bar/1'), m('bar/2')])
334
+ ).toEqual(['foo/1', 'bar/1', 'bar/2', 'bar/3', 'foo/2'])
317
335
  })
318
336
 
319
- it('prioritizes explicit dependencies over sequence order', () => {
320
- const later: Migration = {
321
- id: 'z/1' as MigrationId,
322
- scope: 'record',
323
- up: (record: UnknownRecord) => record,
324
- }
325
- const earlier: Migration = {
326
- id: 'a/1' as MigrationId,
327
- scope: 'record',
328
- up: (record: UnknownRecord) => record,
329
- dependsOn: ['z/1' as MigrationId],
330
- }
331
-
332
- const sorted = sortMigrations([earlier, later])
333
- expect(sorted).toEqual([later, earlier])
337
+ it('[MS3] minimizes total distance for multiple explicit dependencies', () => {
338
+ expect(
339
+ sort([
340
+ m('foo/2'),
341
+ m('bar/2', { dependsOn: ['foo/1'] }),
342
+ m('foo/1'),
343
+ m('bar/1'),
344
+ m('baz/1', { dependsOn: ['foo/1'] }),
345
+ ])
346
+ ).toEqual(['foo/1', 'bar/1', 'bar/2', 'baz/1', 'foo/2'])
334
347
  })
335
348
 
336
- it('throws on circular dependencies', () => {
337
- const a: Migration = {
338
- id: 'a/1' as MigrationId,
339
- scope: 'record',
340
- up: (record: UnknownRecord) => record,
341
- dependsOn: ['b/1' as MigrationId],
342
- }
343
- const b: Migration = {
344
- id: 'b/1' as MigrationId,
345
- scope: 'record',
346
- up: (record: UnknownRecord) => record,
347
- dependsOn: ['a/1' as MigrationId],
348
- }
349
-
350
- expect(() => sortMigrations([a, b])).toThrow('Circular dependency in migrations: a/1')
349
+ it('[MS3] keeps chains of explicit dependencies consecutive', () => {
350
+ expect(
351
+ sort([
352
+ m('foo/2'),
353
+ m('bar/1', { dependsOn: ['foo/1'] }),
354
+ m('foo/1'),
355
+ m('baz/1', { dependsOn: ['bar/1'] }),
356
+ ])
357
+ ).toEqual(['foo/1', 'bar/1', 'baz/1', 'foo/2'])
351
358
  })
352
359
 
353
- it('handles multiple sequences with cross-dependencies', () => {
354
- const app1: Migration = {
355
- id: 'app/1' as MigrationId,
356
- scope: 'record',
357
- up: (record: UnknownRecord) => record,
358
- }
359
- const app2: Migration = {
360
- id: 'app/2' as MigrationId,
361
- scope: 'record',
362
- up: (record: UnknownRecord) => record,
363
- }
364
- const lib1: Migration = {
365
- id: 'lib/1' as MigrationId,
366
- scope: 'record',
367
- up: (record: UnknownRecord) => record,
368
- dependsOn: ['app/1' as MigrationId],
369
- }
370
- const lib2: Migration = {
371
- id: 'lib/2' as MigrationId,
372
- scope: 'record',
373
- up: (record: UnknownRecord) => record,
374
- }
375
- const plugin1: Migration = {
376
- id: 'plugin/1' as MigrationId,
377
- scope: 'record',
378
- up: (record: UnknownRecord) => record,
379
- dependsOn: ['app/2' as MigrationId, 'lib/1' as MigrationId],
380
- }
381
-
382
- const sorted = sortMigrations([plugin1, lib2, lib1, app2, app1])
383
-
384
- // Verify constraints are satisfied
385
- const app1Index = sorted.indexOf(app1)
386
- const app2Index = sorted.indexOf(app2)
387
- const lib1Index = sorted.indexOf(lib1)
388
- const lib2Index = sorted.indexOf(lib2)
389
- const plugin1Index = sorted.indexOf(plugin1)
390
-
391
- // Sequence dependencies
392
- expect(app1Index).toBeLessThan(app2Index)
393
- expect(lib1Index).toBeLessThan(lib2Index)
394
-
395
- // Explicit dependencies
396
- expect(app1Index).toBeLessThan(lib1Index) // lib/1 depends on app/1
397
- expect(app2Index).toBeLessThan(plugin1Index) // plugin/1 depends on app/2
398
- expect(lib1Index).toBeLessThan(plugin1Index) // plugin/1 depends on lib/1
360
+ it('[MS4] throws on circular dependencies', () => {
361
+ expect(() => sort([m('foo/1', { dependsOn: ['foo/1'] })])).toThrowErrorMatchingInlineSnapshot(
362
+ `[Error: Circular dependency in migrations: foo/1]`
363
+ )
364
+
365
+ expect(() =>
366
+ sort([m('foo/1', { dependsOn: ['foo/2'] }), m('foo/2')])
367
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: Circular dependency in migrations: foo/1]`)
368
+
369
+ expect(() =>
370
+ sort([m('foo/1', { dependsOn: ['bar/1'] }), m('bar/1', { dependsOn: ['foo/1'] })])
371
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: Circular dependency in migrations: foo/1]`)
372
+
373
+ expect(() =>
374
+ sort([m('bar/1', { dependsOn: ['foo/1'] }), m('foo/1', { dependsOn: ['bar/1'] })])
375
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: Circular dependency in migrations: bar/1]`)
399
376
  })
400
377
  })