1
1
import type { Context } from "../context.js" ;
2
2
import { slugify } from "../util/slugify.js" ;
3
- import { Self , type Bindings , type WorkerBindingSpec } from "./bindings.js" ;
4
- import type { DurableObjectNamespace } from "./durable-object-namespace.js" ;
3
+ import {
4
+ Self ,
5
+ type Bindings ,
6
+ type WorkerBindingDurableObjectNamespace ,
7
+ type WorkerBindingSpec ,
8
+ } from "./bindings.js" ;
9
+ import {
10
+ isDurableObjectNamespace ,
11
+ type DurableObjectNamespace ,
12
+ } from "./durable-object-namespace.js" ;
5
13
import { createAssetConfig , type AssetUploadResult } from "./worker-assets.js" ;
6
14
import type { SingleStepMigration } from "./worker-migration.js" ;
7
15
import type { AssetsConfig , Worker , WorkerProps } from "./worker.js" ;
8
- import type { Workflow } from "./workflow.js" ;
9
16
10
17
/**
11
18
* Metadata returned by Cloudflare API for a worker script
@@ -186,28 +193,84 @@ export interface WorkerMetadata {
186
193
187
194
export async function prepareWorkerMetadata < B extends Bindings > (
188
195
ctx : Context < Worker < B > > ,
189
- oldBindings : Bindings | undefined ,
196
+ oldBindings : WorkerBindingSpec [ ] | undefined ,
197
+ oldTags : string [ ] | undefined ,
190
198
props : WorkerProps & {
191
199
compatibilityDate : string ;
192
200
compatibilityFlags : string [ ] ;
193
201
workerName : string ;
194
202
} ,
195
203
assetUploadResult ?: AssetUploadResult ,
196
204
) : Promise < WorkerMetadata > {
197
- const deletedClasses = Object . entries ( oldBindings ?? { } )
198
- . filter ( ( [ key ] ) => ! props . bindings ?. [ key ] )
199
- . flatMap ( ( [ _ , binding ] ) => {
200
- if (
201
- binding &&
202
- typeof binding === "object" &&
203
- binding . type === "durable_object_namespace" &&
204
- ( binding . scriptName === undefined ||
205
- binding . scriptName === props . workerName )
206
- ) {
207
- return [ binding . className ] ;
205
+ // we use Cloudflare Worker tags to store a mapping between Alchemy's stable identifier and the binding name
206
+ // e.g.
207
+ // {
208
+ // BINDING_NAME: new DurableObjectNamespace("stable-id")
209
+ // }
210
+ // will be stored as alchemy:do:stable-id:BINDING_NAME
211
+ // TODO(sam): should we base64 encode to ensure no `:` collision risk?
212
+ const bindingNameToStableId = Object . fromEntries (
213
+ oldTags ?. flatMap ( ( tag ) => {
214
+ // alchemy:do:{stableId}:{bindingName}
215
+ if ( tag . startsWith ( "alchemy:do:" ) ) {
216
+ const [ , , stableId , bindingName ] = tag . split ( ":" ) ;
217
+ return [ [ bindingName , stableId ] ] ;
208
218
}
209
219
return [ ] ;
210
- } ) ;
220
+ } ) ?? [ ] ,
221
+ ) ;
222
+
223
+ const deletedClasses = oldBindings ?. flatMap ( ( oldBinding ) => {
224
+ if (
225
+ oldBinding . type === "durable_object_namespace" &&
226
+ ( oldBinding . script_name === undefined ||
227
+ // if this a cross-script binding, we don't need to do migrations in the remote worker
228
+ oldBinding . script_name === props . workerName )
229
+ ) {
230
+ // reverse the stableId from our tag-encoded metadata
231
+ const stableId = bindingNameToStableId [ oldBinding . name ] ;
232
+ if ( stableId ) {
233
+ if ( props . bindings === undefined ) {
234
+ // all classes are deleted
235
+ return [ oldBinding . class_name ] ;
236
+ }
237
+ // we created this worker on latest version, we can now intelligently determine migrations
238
+
239
+ // try and find the DO binding by stable id
240
+ const object = Object . values ( props . bindings ) . find (
241
+ ( binding ) : binding is DurableObjectNamespace < any > =>
242
+ isDurableObjectNamespace ( binding ) && binding . id === stableId ,
243
+ ) ;
244
+ if ( object ) {
245
+ // we found the corresponding object, it should not be deleted
246
+ return [ ] ;
247
+ } else {
248
+ // it was not found, we will now delete it
249
+ return [ oldBinding . class_name ] ;
250
+ }
251
+ } else {
252
+ // ok, we were unable to find the stableId, this worker must have been created by an old alchemy or outside of alchemy
253
+ // let's now apply a herusitic based on binding name (assume binding name is consistent)
254
+ // TODO(sam): this has a chance of being wrong, is that OK? Users should be encouraged to upgrade alchemy version and re-deploy
255
+ const object = props . bindings ?. [ oldBinding . name ] ;
256
+ if ( object && isDurableObjectNamespace ( object ) ) {
257
+ if ( object . className === oldBinding . class_name ) {
258
+ // this is relatively safe to assume is the right match, do not delete
259
+ return [ ] ;
260
+ } else {
261
+ // the class name has changed, this could indicate one of:
262
+ // 1. the user has changed the class name and we should migrate it
263
+ // 2. the user deleted the DO a long time ago and this is unrelated (we should just create a new one)
264
+ return [ oldBinding . class_name ] ;
265
+ }
266
+ } else {
267
+ // we didn't find it, so delete it
268
+ return [ oldBinding . class_name ] ;
269
+ }
270
+ }
271
+ }
272
+ return [ ] ;
273
+ } ) ;
211
274
212
275
// Prepare metadata with bindings
213
276
const meta : WorkerMetadata = {
@@ -218,11 +281,22 @@ export async function prepareWorkerMetadata<B extends Bindings>(
218
281
enabled : props . observability ?. enabled !== false ,
219
282
} ,
220
283
// TODO(sam): base64 encode instead? 0 collision risk vs readability.
221
- tags : [ `alchemy:id:${ slugify ( ctx . fqn ) } ` ] ,
284
+ tags : [
285
+ `alchemy:id:${ slugify ( ctx . fqn ) } ` ,
286
+ // encode a mapping table of Durable Object stable ID -> binding name
287
+ // we use this to reliably compute class migrations based on server-side state
288
+ ...Object . entries ( props . bindings ?? { } ) . flatMap (
289
+ ( [ bindingName , binding ] ) =>
290
+ isDurableObjectNamespace ( binding )
291
+ ? // TODO(sam): base64 encode if contains `:`?
292
+ [ `alchemy:do:${ binding . id } :${ bindingName } ` ]
293
+ : [ ] ,
294
+ ) ,
295
+ ] ,
222
296
migrations : {
223
297
new_classes : props . migrations ?. new_classes ?? [ ] ,
224
298
deleted_classes : [
225
- ...deletedClasses ,
299
+ ...( deletedClasses ?? [ ] ) ,
226
300
...( props . migrations ?. deleted_classes ?? [ ] ) ,
227
301
] ,
228
302
renamed_classes : props . migrations ?. renamed_classes ?? [ ] ,
@@ -303,7 +377,7 @@ export async function prepareWorkerMetadata<B extends Bindings>(
303
377
binding . scriptName === props . workerName
304
378
) {
305
379
// we do not need configure class migrations for cross-script bindings
306
- configureClassMigration ( binding , binding . id , binding . className ) ;
380
+ configureClassMigration ( bindingName , binding ) ;
307
381
}
308
382
} else if ( binding . type === "r2_bucket" ) {
309
383
meta . bindings . push ( {
@@ -393,29 +467,46 @@ export async function prepareWorkerMetadata<B extends Bindings>(
393
467
}
394
468
395
469
function configureClassMigration (
396
- binding : DurableObjectNamespace < any > | Workflow ,
397
- stableId : string ,
398
- className : string ,
470
+ bindingName : string ,
471
+ newBinding : DurableObjectNamespace < any > ,
399
472
) {
400
- const oldBinding : DurableObjectNamespace < any > | Workflow | undefined =
401
- Object . values ( oldBindings ?? { } )
402
- ?. filter (
403
- ( b ) =>
404
- typeof b === "object" &&
405
- ( b . type === "durable_object_namespace" || b . type === "workflow" ) ,
406
- )
407
- ?. find ( ( b ) => b . id === stableId ) ;
408
-
409
- if ( ! oldBinding ) {
410
- if ( binding . type === "durable_object_namespace" && binding . sqlite ) {
411
- meta . migrations ! . new_sqlite_classes ! . push ( className ) ;
473
+ let prevBinding : WorkerBindingDurableObjectNamespace | undefined ;
474
+ if ( oldBindings ) {
475
+ // try and find the prev binding for this
476
+ for ( const oldBinding of oldBindings ) {
477
+ if ( oldBinding . type === "durable_object_namespace" ) {
478
+ const stableId = bindingNameToStableId [ oldBinding . name ] ;
479
+ if ( stableId ) {
480
+ // (happy case)
481
+ // great, this Worker was created with Alchemy and we can map stable ids
482
+ if ( stableId === newBinding . id ) {
483
+ prevBinding = oldBinding ;
484
+ break ;
485
+ }
486
+ } else {
487
+ // (heuristic case)
488
+ // we were unable to find the stableId, this Worker must not have been created with Alchemy
489
+ // now, try and resolve by assuming 1:1 binding name correspondence
490
+ // WARNING: this is an imperfect assumption. Users are advised to upgrade alchemy and re-deploy
491
+ if ( oldBinding . name === bindingName ) {
492
+ prevBinding = oldBinding ;
493
+ break ;
494
+ }
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ if ( ! prevBinding ) {
501
+ if ( newBinding . sqlite ) {
502
+ meta . migrations ! . new_sqlite_classes ! . push ( newBinding . className ) ;
412
503
} else {
413
- meta . migrations ! . new_classes ! . push ( className ) ;
504
+ meta . migrations ! . new_classes ! . push ( newBinding . className ) ;
414
505
}
415
- } else if ( oldBinding . className !== className ) {
506
+ } else if ( prevBinding . class_name !== newBinding . className ) {
416
507
meta . migrations ! . renamed_classes ! . push ( {
417
- from : oldBinding . className ,
418
- to : className ,
508
+ from : prevBinding . class_name ,
509
+ to : newBinding . className ,
419
510
} ) ;
420
511
}
421
512
}
0 commit comments