depa-actor 0.2.1 → 0.2.2

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/src/addressing.ts CHANGED
@@ -125,8 +125,6 @@ export type TargetedActorProcessor<TRuntime, TSelector, TInvocation, TConfig, TO
125
125
  config: TConfig,
126
126
  ) => TOutput | Promise<TOutput>;
127
127
 
128
- type UnknownRecord = Record<string, unknown>;
129
-
130
128
  const ADDRESS_KEYS = [
131
129
  'schemaVersion',
132
130
  'namespace',
@@ -151,6 +149,9 @@ const REGISTRATION_RECEIPT_KEYS = [
151
149
  ] as const;
152
150
  const REGISTER_INPUT_KEYS = ['address', 'ownerSnapshot', 'endpoint'] as const;
153
151
  const ENDPOINT_KEYS = ['handlerIdentity', 'dispatch'] as const;
152
+ const RUNTIME_CONFIG_KEYS = ['runtimeInstanceId', 'resolveAlias', 'registrationIdFactory'] as const;
153
+ const EXPECTED_REGISTRATION_ID_KEYS = ['expectedRegistrationId'] as const;
154
+ const DISPATCH_INVOCATION_REQUIRED_KEYS = ['input'] as const;
154
155
  const ADDRESS_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._~-]{0,126}[A-Za-z0-9])?$/;
155
156
  const OPAQUE_TOKEN = /^[A-Za-z0-9](?:[A-Za-z0-9._~:/-]{0,254}[A-Za-z0-9])?$/;
156
157
 
@@ -161,38 +162,126 @@ interface LocalRegistration<TInvocation, TOutput> {
161
162
  }
162
163
 
163
164
  interface LocalActorAddressingState<TInvocation, TOutput> {
164
- readonly registrations: Map<string, LocalRegistration<TInvocation, TOutput>>;
165
- readonly registrationIds: Set<string>;
165
+ registrations: Map<string, LocalRegistration<TInvocation, TOutput>>;
166
+ usedRegistrationIds: Set<string>;
167
+ readonly runtimeNonce: string;
166
168
  readonly resolveAlias?: (alias: string) => ActorAddress | undefined;
167
169
  readonly registrationIdFactory: NonNullable<
168
170
  LocalActorAddressingRuntimeConfig['registrationIdFactory']
169
171
  >;
170
172
  sequence: number;
173
+ mutationVersion: number;
174
+ mutationGuard: string | undefined;
171
175
  }
172
176
 
173
177
  const runtimeStates = new WeakMap<object, LocalActorAddressingState<unknown, unknown>>();
178
+ let runtimeNonceSequence = 0;
179
+
180
+ function closedRecordError(
181
+ code: ActorAddressingErrorCode,
182
+ label: string,
183
+ detail: string,
184
+ ): ActorAddressingError {
185
+ return new ActorAddressingError(code, `${label} ${detail}`);
186
+ }
187
+
188
+ function readClosedRecord(
189
+ value: unknown,
190
+ requiredKeys: readonly string[],
191
+ optionalKeys: readonly string[],
192
+ code: ActorAddressingErrorCode,
193
+ label: string,
194
+ ): ReadonlyMap<string, unknown> {
195
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
196
+ throw closedRecordError(code, label, 'must be an object');
197
+ }
198
+
199
+ const prototype = Reflect.getPrototypeOf(value);
200
+ if (prototype !== Object.prototype && prototype !== null) {
201
+ throw closedRecordError(code, label, 'must use Object.prototype or a null prototype');
202
+ }
203
+
204
+ const allowedKeys = new Set([...requiredKeys, ...optionalKeys]);
205
+ const values = new Map<string, unknown>();
206
+ for (const key of Reflect.ownKeys(value)) {
207
+ if (typeof key !== 'string' || !allowedKeys.has(key)) {
208
+ throw closedRecordError(
209
+ code,
210
+ label,
211
+ `must contain only: ${[...allowedKeys].sort().join(', ')}`,
212
+ );
213
+ }
214
+ const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
215
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
216
+ throw closedRecordError(code, label, `${key} must be an own enumerable data property`);
217
+ }
218
+ values.set(key, descriptor.value);
219
+ }
174
220
 
175
- function isRecord(value: unknown): value is UnknownRecord {
176
- return typeof value === 'object' && value !== null && !Array.isArray(value);
221
+ for (const key of requiredKeys) {
222
+ if (!values.has(key)) {
223
+ throw closedRecordError(code, label, `must contain required field: ${key}`);
224
+ }
225
+ }
226
+ return values;
177
227
  }
178
228
 
179
- function assertExactRecord(
229
+ function readExactRecord(
180
230
  value: unknown,
181
231
  keys: readonly string[],
182
232
  code: ActorAddressingErrorCode,
183
233
  label: string,
184
- ): asserts value is UnknownRecord {
185
- if (!isRecord(value)) {
186
- throw new ActorAddressingError(code, `${label} must be an object`);
234
+ ): ReadonlyMap<string, unknown> {
235
+ return readClosedRecord(value, keys, [], code, label);
236
+ }
237
+
238
+ function readClosedArray(
239
+ value: unknown,
240
+ code: ActorAddressingErrorCode,
241
+ label: string,
242
+ ): readonly unknown[] {
243
+ if (!Array.isArray(value) || Reflect.getPrototypeOf(value) !== Array.prototype) {
244
+ throw closedRecordError(code, label, 'must be a plain array');
187
245
  }
188
- const actualKeys = Object.keys(value).sort();
189
- const expectedKeys = [...keys].sort();
246
+
247
+ const lengthDescriptor = Reflect.getOwnPropertyDescriptor(value, 'length');
248
+ const lengthValue =
249
+ lengthDescriptor && 'value' in lengthDescriptor ? lengthDescriptor.value : undefined;
190
250
  if (
191
- actualKeys.length !== expectedKeys.length ||
192
- actualKeys.some((key, index) => key !== expectedKeys[index])
251
+ !lengthDescriptor ||
252
+ !('value' in lengthDescriptor) ||
253
+ lengthDescriptor.enumerable ||
254
+ typeof lengthValue !== 'number' ||
255
+ !Number.isSafeInteger(lengthValue) ||
256
+ lengthValue < 0
193
257
  ) {
194
- throw new ActorAddressingError(code, `${label} must contain only: ${expectedKeys.join(', ')}`);
258
+ throw closedRecordError(code, label, 'must have the standard own data length property');
195
259
  }
260
+
261
+ const length = lengthValue;
262
+ const expectedKeys = new Set<string>(['length']);
263
+ for (let index = 0; index < length; index += 1) {
264
+ expectedKeys.add(String(index));
265
+ }
266
+
267
+ const ownKeys = Reflect.ownKeys(value);
268
+ if (
269
+ ownKeys.length !== expectedKeys.size ||
270
+ ownKeys.some((key) => typeof key !== 'string' || !expectedKeys.has(key))
271
+ ) {
272
+ throw closedRecordError(code, label, 'must be dense and contain no extra own keys');
273
+ }
274
+
275
+ const elements: unknown[] = [];
276
+ for (let index = 0; index < length; index += 1) {
277
+ const key = String(index);
278
+ const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
279
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
280
+ throw closedRecordError(code, label, `${key} must be an own enumerable data property`);
281
+ }
282
+ elements.push(descriptor.value);
283
+ }
284
+ return elements;
196
285
  }
197
286
 
198
287
  function parseAddressSegment(
@@ -229,16 +318,16 @@ function parseOpaqueToken(
229
318
  }
230
319
 
231
320
  export function parseActorAddress(value: unknown): ActorAddress {
232
- assertExactRecord(value, ADDRESS_KEYS, 'invalid-address', 'ActorAddress');
233
- if (value.schemaVersion !== ACTOR_ADDRESS_SCHEMA_VERSION) {
321
+ const record = readExactRecord(value, ADDRESS_KEYS, 'invalid-address', 'ActorAddress');
322
+ if (record.get('schemaVersion') !== ACTOR_ADDRESS_SCHEMA_VERSION) {
234
323
  throw new ActorAddressingError('invalid-address', 'Unsupported ActorAddress schemaVersion');
235
324
  }
236
325
  return Object.freeze({
237
326
  schemaVersion: ACTOR_ADDRESS_SCHEMA_VERSION,
238
- namespace: parseAddressSegment(value.namespace, 'namespace'),
239
- deploymentId: parseAddressSegment(value.deploymentId, 'deploymentId'),
240
- actorKind: parseAddressSegment(value.actorKind, 'actorKind'),
241
- logicalKey: parseAddressSegment(value.logicalKey, 'logicalKey'),
327
+ namespace: parseAddressSegment(record.get('namespace'), 'namespace'),
328
+ deploymentId: parseAddressSegment(record.get('deploymentId'), 'deploymentId'),
329
+ actorKind: parseAddressSegment(record.get('actorKind'), 'actorKind'),
330
+ logicalKey: parseAddressSegment(record.get('logicalKey'), 'logicalKey'),
242
331
  });
243
332
  }
244
333
 
@@ -254,19 +343,22 @@ export function actorAddressKey(address: ActorAddress): string {
254
343
  }
255
344
 
256
345
  export function parseActorSelector(value: unknown): ActorSelector {
257
- if (!isRecord(value)) {
258
- throw new ActorAddressingError('invalid-selector', 'ActorSelector must be an object');
259
- }
260
- const keys = Object.keys(value);
261
- if (keys.length !== 1 || (keys[0] !== 'byAddress' && keys[0] !== 'byAlias')) {
346
+ const record = readClosedRecord(
347
+ value,
348
+ [],
349
+ ['byAddress', 'byAlias'],
350
+ 'invalid-selector',
351
+ 'ActorSelector',
352
+ );
353
+ if (record.size !== 1) {
262
354
  throw new ActorAddressingError(
263
355
  'invalid-selector',
264
356
  'ActorSelector must contain exactly one of byAddress or byAlias',
265
357
  );
266
358
  }
267
- if (keys[0] === 'byAddress') {
359
+ if (record.has('byAddress')) {
268
360
  try {
269
- return Object.freeze({ byAddress: parseActorAddress(value.byAddress) });
361
+ return Object.freeze({ byAddress: parseActorAddress(record.get('byAddress')) });
270
362
  } catch (error) {
271
363
  if (error instanceof ActorAddressingError) {
272
364
  throw new ActorAddressingError('invalid-selector', `Invalid byAddress: ${error.message}`);
@@ -275,23 +367,30 @@ export function parseActorSelector(value: unknown): ActorSelector {
275
367
  }
276
368
  }
277
369
  return Object.freeze({
278
- byAlias: parseOpaqueToken(value.byAlias, 'byAlias', 'invalid-selector'),
370
+ byAlias: parseOpaqueToken(record.get('byAlias'), 'byAlias', 'invalid-selector'),
279
371
  });
280
372
  }
281
373
 
282
374
  export function parseActorOwnerSnapshot(value: unknown): ActorOwnerSnapshot {
283
- assertExactRecord(value, OWNER_SNAPSHOT_KEYS, 'invalid-owner-snapshot', 'ActorOwnerSnapshot');
284
- if (value.schemaVersion !== ACTOR_OWNER_SNAPSHOT_SCHEMA_VERSION) {
375
+ const record = readExactRecord(
376
+ value,
377
+ OWNER_SNAPSHOT_KEYS,
378
+ 'invalid-owner-snapshot',
379
+ 'ActorOwnerSnapshot',
380
+ );
381
+ if (record.get('schemaVersion') !== ACTOR_OWNER_SNAPSHOT_SCHEMA_VERSION) {
285
382
  throw new ActorAddressingError('invalid-owner-snapshot', 'Unsupported owner snapshot schemaVersion');
286
383
  }
287
- if (!Array.isArray(value.registrations)) {
288
- throw new ActorAddressingError('invalid-owner-snapshot', 'registrations must be an array');
289
- }
384
+ const sourceRegistrations = readClosedArray(
385
+ record.get('registrations'),
386
+ 'invalid-owner-snapshot',
387
+ 'registrations',
388
+ );
290
389
 
291
390
  const registrations: ActorOwnerSnapshotRegistration[] = [];
292
391
  const seen = new Set<string>();
293
- for (const registration of value.registrations) {
294
- assertExactRecord(
392
+ for (const registration of sourceRegistrations) {
393
+ const registrationRecord = readExactRecord(
295
394
  registration,
296
395
  OWNER_REGISTRATION_KEYS,
297
396
  'invalid-owner-snapshot',
@@ -299,7 +398,7 @@ export function parseActorOwnerSnapshot(value: unknown): ActorOwnerSnapshot {
299
398
  );
300
399
  let address: ActorAddress;
301
400
  try {
302
- address = parseActorAddress(registration.address);
401
+ address = parseActorAddress(registrationRecord.get('address'));
303
402
  } catch (error) {
304
403
  if (error instanceof ActorAddressingError) {
305
404
  throw new ActorAddressingError('invalid-owner-snapshot', error.message);
@@ -318,7 +417,7 @@ export function parseActorOwnerSnapshot(value: unknown): ActorOwnerSnapshot {
318
417
  Object.freeze({
319
418
  address,
320
419
  handlerIdentity: parseOpaqueToken(
321
- registration.handlerIdentity,
420
+ registrationRecord.get('handlerIdentity'),
322
421
  'handlerIdentity',
323
422
  'invalid-owner-snapshot',
324
423
  ),
@@ -329,12 +428,12 @@ export function parseActorOwnerSnapshot(value: unknown): ActorOwnerSnapshot {
329
428
  return Object.freeze({
330
429
  schemaVersion: ACTOR_OWNER_SNAPSHOT_SCHEMA_VERSION,
331
430
  ownerRevision: parseOpaqueToken(
332
- value.ownerRevision,
431
+ record.get('ownerRevision'),
333
432
  'ownerRevision',
334
433
  'invalid-owner-snapshot',
335
434
  ),
336
435
  snapshotReceipt: parseOpaqueToken(
337
- value.snapshotReceipt,
436
+ record.get('snapshotReceipt'),
338
437
  'snapshotReceipt',
339
438
  'invalid-owner-snapshot',
340
439
  ),
@@ -343,35 +442,35 @@ export function parseActorOwnerSnapshot(value: unknown): ActorOwnerSnapshot {
343
442
  }
344
443
 
345
444
  export function parseActorRegistrationReceipt(value: unknown): ActorRegistrationReceipt {
346
- assertExactRecord(
445
+ const record = readExactRecord(
347
446
  value,
348
447
  REGISTRATION_RECEIPT_KEYS,
349
448
  'registration-mismatch',
350
449
  'ActorRegistrationReceipt',
351
450
  );
352
- if (value.schemaVersion !== ACTOR_REGISTRATION_SCHEMA_VERSION) {
451
+ if (record.get('schemaVersion') !== ACTOR_REGISTRATION_SCHEMA_VERSION) {
353
452
  throw new ActorAddressingError('registration-mismatch', 'Unsupported registration schemaVersion');
354
453
  }
355
454
  return Object.freeze({
356
455
  schemaVersion: ACTOR_REGISTRATION_SCHEMA_VERSION,
357
- address: parseActorAddress(value.address),
456
+ address: parseActorAddress(record.get('address')),
358
457
  ownerRevision: parseOpaqueToken(
359
- value.ownerRevision,
458
+ record.get('ownerRevision'),
360
459
  'ownerRevision',
361
460
  'registration-mismatch',
362
461
  ),
363
462
  snapshotReceipt: parseOpaqueToken(
364
- value.snapshotReceipt,
463
+ record.get('snapshotReceipt'),
365
464
  'snapshotReceipt',
366
465
  'registration-mismatch',
367
466
  ),
368
467
  handlerIdentity: parseOpaqueToken(
369
- value.handlerIdentity,
468
+ record.get('handlerIdentity'),
370
469
  'handlerIdentity',
371
470
  'registration-mismatch',
372
471
  ),
373
472
  registrationId: parseOpaqueToken(
374
- value.registrationId,
473
+ record.get('registrationId'),
375
474
  'registrationId',
376
475
  'registration-mismatch',
377
476
  ),
@@ -379,9 +478,7 @@ export function parseActorRegistrationReceipt(value: unknown): ActorRegistration
379
478
  }
380
479
 
381
480
  function assertEmptyConfig(config: unknown): asserts config is ActorProcessorConfig {
382
- if (!isRecord(config) || Object.keys(config).length !== 0) {
383
- throw new ActorAddressingError('registration-mismatch', 'Processor config must be an empty object');
384
- }
481
+ readExactRecord(config, [], 'registration-mismatch', 'Processor config');
385
482
  }
386
483
 
387
484
  function runtimeState<TInvocation, TOutput>(
@@ -399,32 +496,60 @@ function runtimeState<TInvocation, TOutput>(
399
496
  return state;
400
497
  }
401
498
 
402
- export function createLocalActorAddressingRuntime<TInvocation, TOutput>(
403
- config: LocalActorAddressingRuntimeConfig = {},
404
- ): LocalActorAddressingRuntime<TInvocation, TOutput> {
405
- if (!isRecord(config)) {
406
- throw new ActorAddressingError('registration-mismatch', 'runtime config must be an object');
407
- }
408
- const allowedKeys = new Set(['runtimeInstanceId', 'resolveAlias', 'registrationIdFactory']);
409
- if (Object.keys(config).some((key) => !allowedKeys.has(key))) {
499
+ function createRuntimeNonce(): string {
500
+ runtimeNonceSequence += 1;
501
+ const randomPart =
502
+ typeof globalThis.crypto?.randomUUID === 'function'
503
+ ? globalThis.crypto.randomUUID().replaceAll('-', '')
504
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2) || '0'}`;
505
+ return `runtime-${randomPart}-${runtimeNonceSequence.toString(36)}`;
506
+ }
507
+
508
+ function beginMutation<TInvocation, TOutput>(
509
+ state: LocalActorAddressingState<TInvocation, TOutput>,
510
+ operation: string,
511
+ ): number {
512
+ if (state.mutationGuard !== undefined) {
410
513
  throw new ActorAddressingError(
411
514
  'registration-mismatch',
412
- 'runtime config contains an unsupported field',
515
+ `${operation} cannot mutate the runtime while ${state.mutationGuard} is in progress`,
413
516
  );
414
517
  }
415
- if (config.resolveAlias !== undefined && typeof config.resolveAlias !== 'function') {
518
+ state.mutationGuard = operation;
519
+ return state.mutationVersion;
520
+ }
521
+
522
+ function endMutation<TInvocation, TOutput>(
523
+ state: LocalActorAddressingState<TInvocation, TOutput>,
524
+ operation: string,
525
+ ): void {
526
+ if (state.mutationGuard === operation) {
527
+ state.mutationGuard = undefined;
528
+ }
529
+ }
530
+
531
+ export function createLocalActorAddressingRuntime<TInvocation, TOutput>(
532
+ config: LocalActorAddressingRuntimeConfig = {},
533
+ ): LocalActorAddressingRuntime<TInvocation, TOutput> {
534
+ const record = readClosedRecord(
535
+ config,
536
+ [],
537
+ RUNTIME_CONFIG_KEYS,
538
+ 'registration-mismatch',
539
+ 'runtime config',
540
+ );
541
+ const resolveAlias = record.get('resolveAlias');
542
+ const registrationIdFactory = record.get('registrationIdFactory');
543
+ if (resolveAlias !== undefined && typeof resolveAlias !== 'function') {
416
544
  throw new ActorAddressingError('registration-mismatch', 'resolveAlias must be a function');
417
545
  }
418
- if (
419
- config.registrationIdFactory !== undefined &&
420
- typeof config.registrationIdFactory !== 'function'
421
- ) {
546
+ if (registrationIdFactory !== undefined && typeof registrationIdFactory !== 'function') {
422
547
  throw new ActorAddressingError('registration-mismatch', 'registrationIdFactory must be a function');
423
548
  }
424
549
 
425
550
  const randomPart = Math.random().toString(36).slice(2) || '0';
426
551
  const runtimeInstanceId = parseOpaqueToken(
427
- config.runtimeInstanceId ?? `local-runtime-${Date.now().toString(36)}-${randomPart}`,
552
+ record.get('runtimeInstanceId') ?? `local-runtime-${Date.now().toString(36)}-${randomPart}`,
428
553
  'runtimeInstanceId',
429
554
  'registration-mismatch',
430
555
  );
@@ -434,12 +559,15 @@ export function createLocalActorAddressingRuntime<TInvocation, TOutput>(
434
559
  >;
435
560
  runtimeStates.set(runtime, {
436
561
  registrations: new Map(),
437
- registrationIds: new Set(),
438
- resolveAlias: config.resolveAlias,
562
+ usedRegistrationIds: new Set(),
563
+ runtimeNonce: createRuntimeNonce(),
564
+ resolveAlias: resolveAlias as LocalActorAddressingRuntimeConfig['resolveAlias'],
439
565
  registrationIdFactory:
440
- config.registrationIdFactory ??
566
+ (registrationIdFactory as LocalActorAddressingRuntimeConfig['registrationIdFactory']) ??
441
567
  ((input) => `${input.runtimeInstanceId}:registration-${input.sequence}`),
442
568
  sequence: 0,
569
+ mutationVersion: 0,
570
+ mutationGuard: undefined,
443
571
  } as LocalActorAddressingState<unknown, unknown>);
444
572
  return runtime;
445
573
  }
@@ -447,29 +575,40 @@ export function createLocalActorAddressingRuntime<TInvocation, TOutput>(
447
575
  function parseEndpoint<TInvocation, TOutput>(
448
576
  value: unknown,
449
577
  ): LocalActorEndpoint<TInvocation, TOutput> {
450
- assertExactRecord(value, ENDPOINT_KEYS, 'registration-mismatch', 'LocalActorEndpoint');
578
+ const record = readExactRecord(
579
+ value,
580
+ ENDPOINT_KEYS,
581
+ 'registration-mismatch',
582
+ 'LocalActorEndpoint',
583
+ );
451
584
  const handlerIdentity = parseOpaqueToken(
452
- value.handlerIdentity,
585
+ record.get('handlerIdentity'),
453
586
  'handlerIdentity',
454
587
  'registration-mismatch',
455
588
  );
456
- if (typeof value.dispatch !== 'function') {
589
+ const dispatch = record.get('dispatch');
590
+ if (typeof dispatch !== 'function') {
457
591
  throw new ActorAddressingError('registration-mismatch', 'endpoint.dispatch must be a function');
458
592
  }
459
593
  return Object.freeze({
460
594
  handlerIdentity,
461
- dispatch: value.dispatch as LocalActorEndpoint<TInvocation, TOutput>['dispatch'],
595
+ dispatch: dispatch as LocalActorEndpoint<TInvocation, TOutput>['dispatch'],
462
596
  });
463
597
  }
464
598
 
465
599
  function parseRegisterInput<TInvocation, TOutput>(
466
600
  input: unknown,
467
601
  ): RegisterActorInput<TInvocation, TOutput> {
468
- assertExactRecord(input, REGISTER_INPUT_KEYS, 'registration-mismatch', 'RegisterActorInput');
602
+ const record = readExactRecord(
603
+ input,
604
+ REGISTER_INPUT_KEYS,
605
+ 'registration-mismatch',
606
+ 'RegisterActorInput',
607
+ );
469
608
  return {
470
- address: parseActorAddress(input.address),
471
- ownerSnapshot: parseActorOwnerSnapshot(input.ownerSnapshot),
472
- endpoint: parseEndpoint<TInvocation, TOutput>(input.endpoint),
609
+ address: parseActorAddress(record.get('address')),
610
+ ownerSnapshot: parseActorOwnerSnapshot(record.get('ownerSnapshot')),
611
+ endpoint: parseEndpoint<TInvocation, TOutput>(record.get('endpoint')),
473
612
  };
474
613
  }
475
614
 
@@ -513,16 +652,17 @@ function resolveRegistration<TInvocation, TOutput>(
513
652
  }
514
653
 
515
654
  function expectedRegistrationId(value: unknown, required: boolean): string | undefined {
516
- if (!isRecord(value)) {
517
- throw new ActorAddressingError('stale-registration', 'invocation must be an object');
518
- }
519
- const allowedKeys = required ? ['expectedRegistrationId'] : ['expectedRegistrationId'];
520
- if (Object.keys(value).some((key) => !allowedKeys.includes(key))) {
521
- throw new ActorAddressingError('stale-registration', 'invocation contains an unsupported field');
522
- }
523
- if (value.expectedRegistrationId === undefined && !required) return undefined;
655
+ const record = readClosedRecord(
656
+ value,
657
+ required ? EXPECTED_REGISTRATION_ID_KEYS : [],
658
+ required ? [] : EXPECTED_REGISTRATION_ID_KEYS,
659
+ 'stale-registration',
660
+ 'invocation',
661
+ );
662
+ const valueToParse = record.get('expectedRegistrationId');
663
+ if (valueToParse === undefined && !required) return undefined;
524
664
  return parseOpaqueToken(
525
- value.expectedRegistrationId,
665
+ valueToParse,
526
666
  'expectedRegistrationId',
527
667
  'stale-registration',
528
668
  );
@@ -540,59 +680,105 @@ function assertCurrentRegistration(
540
680
  }
541
681
  }
542
682
 
543
- export function registerActor<TInvocation, TOutput>(
544
- runtime: LocalActorAddressingRuntime<TInvocation, TOutput>,
545
- input: RegisterActorInput<TInvocation, TOutput>,
546
- config: ActorProcessorConfig,
547
- ): ActorRegistrationReceipt {
548
- assertEmptyConfig(config);
549
- const parsed = parseRegisterInput<TInvocation, TOutput>(input);
550
- const state = runtimeState(runtime);
551
- const key = actorAddressKey(parsed.address);
552
- if (state.registrations.has(key)) {
553
- throw new ActorAddressingError('already-registered', `Actor is already registered: ${key}`);
554
- }
555
- const ownerEntry = parsed.ownerSnapshot.registrations.find(
556
- (entry) => actorAddressKey(entry.address) === key,
557
- );
558
- if (!ownerEntry || ownerEntry.handlerIdentity !== parsed.endpoint.handlerIdentity) {
559
- throw new ActorAddressingError(
560
- 'registration-mismatch',
561
- 'Address and handler identity must be authorized by the owner snapshot',
562
- );
683
+ function hashRegistrationIdComponent(value: string): string {
684
+ let hash = 0x811c9dc5;
685
+ for (let index = 0; index < value.length; index += 1) {
686
+ hash = Math.imul(hash ^ value.charCodeAt(index), 0x01000193);
563
687
  }
564
- const sequence = state.sequence + 1;
565
- const registrationId = parseOpaqueToken(
688
+ return (hash >>> 0).toString(36);
689
+ }
690
+
691
+ function mintRegistrationId<TInvocation, TOutput>(
692
+ runtime: LocalActorAddressingRuntime<TInvocation, TOutput>,
693
+ state: LocalActorAddressingState<TInvocation, TOutput>,
694
+ sequence: number,
695
+ address: ActorAddress,
696
+ additionallyUsed: ReadonlySet<string> = new Set(),
697
+ ): string {
698
+ const factoryValue = parseOpaqueToken(
566
699
  state.registrationIdFactory({
567
700
  runtimeInstanceId: runtime.runtimeInstanceId,
568
701
  sequence,
569
- address: parsed.address,
702
+ address,
570
703
  }),
704
+ 'registrationIdFactory result',
705
+ 'registration-mismatch',
706
+ );
707
+ const factoryComponent =
708
+ factoryValue.length <= 128
709
+ ? factoryValue
710
+ : `factory-${hashRegistrationIdComponent(factoryValue)}`;
711
+ const registrationId = parseOpaqueToken(
712
+ `${factoryComponent}:${state.runtimeNonce}:${sequence.toString(36)}`,
571
713
  'registrationId',
572
714
  'registration-mismatch',
573
715
  );
574
- if (state.registrationIds.has(registrationId)) {
716
+ if (state.usedRegistrationIds.has(registrationId) || additionallyUsed.has(registrationId)) {
575
717
  throw new ActorAddressingError(
576
718
  'registration-mismatch',
577
- `registrationIdFactory returned a duplicate id: ${registrationId}`,
719
+ `registrationIdFactory produced an already-used registration id: ${registrationId}`,
578
720
  );
579
721
  }
580
- state.sequence = sequence;
581
- const receipt = Object.freeze({
582
- schemaVersion: ACTOR_REGISTRATION_SCHEMA_VERSION,
583
- address: parsed.address,
584
- ownerRevision: parsed.ownerSnapshot.ownerRevision,
585
- snapshotReceipt: parsed.ownerSnapshot.snapshotReceipt,
586
- handlerIdentity: parsed.endpoint.handlerIdentity,
587
- registrationId,
588
- });
589
- state.registrations.set(key, {
590
- address: parsed.address,
591
- receipt,
592
- endpoint: parsed.endpoint,
593
- });
594
- state.registrationIds.add(registrationId);
595
- return receipt;
722
+ return registrationId;
723
+ }
724
+
725
+ export function registerActor<TInvocation, TOutput>(
726
+ runtime: LocalActorAddressingRuntime<TInvocation, TOutput>,
727
+ input: RegisterActorInput<TInvocation, TOutput>,
728
+ config: ActorProcessorConfig,
729
+ ): ActorRegistrationReceipt {
730
+ assertEmptyConfig(config);
731
+ const state = runtimeState(runtime);
732
+ const operation = 'registerActor';
733
+ const startingVersion = beginMutation(state, operation);
734
+ try {
735
+ const parsed = parseRegisterInput<TInvocation, TOutput>(input);
736
+ const key = actorAddressKey(parsed.address);
737
+ if (state.registrations.has(key)) {
738
+ throw new ActorAddressingError('already-registered', `Actor is already registered: ${key}`);
739
+ }
740
+ const ownerEntry = parsed.ownerSnapshot.registrations.find(
741
+ (entry) => actorAddressKey(entry.address) === key,
742
+ );
743
+ if (!ownerEntry || ownerEntry.handlerIdentity !== parsed.endpoint.handlerIdentity) {
744
+ throw new ActorAddressingError(
745
+ 'registration-mismatch',
746
+ 'Address and handler identity must be authorized by the owner snapshot',
747
+ );
748
+ }
749
+ const sequence = state.sequence + 1;
750
+ const registrationId = mintRegistrationId(
751
+ runtime,
752
+ state,
753
+ sequence,
754
+ parsed.address,
755
+ );
756
+ if (state.mutationVersion !== startingVersion) {
757
+ throw new ActorAddressingError(
758
+ 'registration-mismatch',
759
+ 'Runtime changed while registration was being constructed',
760
+ );
761
+ }
762
+ const receipt: ActorRegistrationReceipt = Object.freeze({
763
+ schemaVersion: ACTOR_REGISTRATION_SCHEMA_VERSION,
764
+ address: parsed.address,
765
+ ownerRevision: parsed.ownerSnapshot.ownerRevision,
766
+ snapshotReceipt: parsed.ownerSnapshot.snapshotReceipt,
767
+ handlerIdentity: parsed.endpoint.handlerIdentity,
768
+ registrationId,
769
+ });
770
+ state.sequence = sequence;
771
+ state.registrations.set(key, {
772
+ address: parsed.address,
773
+ receipt,
774
+ endpoint: parsed.endpoint,
775
+ });
776
+ state.usedRegistrationIds.add(registrationId);
777
+ state.mutationVersion = startingVersion + 1;
778
+ return receipt;
779
+ } finally {
780
+ endMutation(state, operation);
781
+ }
596
782
  }
597
783
 
598
784
  export function resolveActor<TInvocation, TOutput>(
@@ -618,27 +804,25 @@ export async function dispatchActor<TInvocation, TOutput>(
618
804
  config: ActorProcessorConfig,
619
805
  ): Promise<TOutput> {
620
806
  assertEmptyConfig(config);
621
- if (!isRecord(invocation)) {
622
- throw new ActorAddressingError('stale-registration', 'dispatch invocation must be an object');
623
- }
624
- const keys = Object.keys(invocation);
625
- if (!keys.includes('input') || keys.some((key) => key !== 'input' && key !== 'expectedRegistrationId')) {
626
- throw new ActorAddressingError(
627
- 'stale-registration',
628
- 'dispatch invocation must contain input and optional expectedRegistrationId',
629
- );
630
- }
807
+ const invocationRecord = readClosedRecord(
808
+ invocation,
809
+ DISPATCH_INVOCATION_REQUIRED_KEYS,
810
+ EXPECTED_REGISTRATION_ID_KEYS,
811
+ 'stale-registration',
812
+ 'dispatch invocation',
813
+ );
631
814
  const registration = resolveRegistration(runtime, selector);
815
+ const expectedRegistrationIdValue = invocationRecord.get('expectedRegistrationId');
632
816
  const expectedId =
633
- invocation.expectedRegistrationId === undefined
817
+ expectedRegistrationIdValue === undefined
634
818
  ? undefined
635
819
  : parseOpaqueToken(
636
- invocation.expectedRegistrationId,
820
+ expectedRegistrationIdValue,
637
821
  'expectedRegistrationId',
638
822
  'stale-registration',
639
823
  );
640
824
  assertCurrentRegistration(registration.receipt, expectedId);
641
- return await registration.endpoint.dispatch(invocation.input);
825
+ return await registration.endpoint.dispatch(invocationRecord.get('input') as TInvocation);
642
826
  }
643
827
 
644
828
  export function unregisterActor<TInvocation, TOutput>(
@@ -649,11 +833,24 @@ export function unregisterActor<TInvocation, TOutput>(
649
833
  ): ActorRegistrationReceipt {
650
834
  assertEmptyConfig(config);
651
835
  const expectedId = expectedRegistrationId(invocation, true);
652
- const registration = resolveRegistration(runtime, selector);
653
- assertCurrentRegistration(registration.receipt, expectedId);
654
- runtimeState(runtime).registrations.delete(actorAddressKey(registration.address));
655
- runtimeState(runtime).registrationIds.delete(registration.receipt.registrationId);
656
- return registration.receipt;
836
+ const state = runtimeState(runtime);
837
+ const operation = 'unregisterActor';
838
+ const startingVersion = beginMutation(state, operation);
839
+ try {
840
+ const registration = resolveRegistration(runtime, selector);
841
+ assertCurrentRegistration(registration.receipt, expectedId);
842
+ if (state.mutationVersion !== startingVersion) {
843
+ throw new ActorAddressingError(
844
+ 'registration-mismatch',
845
+ 'Runtime changed while unregister was being resolved',
846
+ );
847
+ }
848
+ state.registrations.delete(actorAddressKey(registration.address));
849
+ state.mutationVersion = startingVersion + 1;
850
+ return registration.receipt;
851
+ } finally {
852
+ endMutation(state, operation);
853
+ }
657
854
  }
658
855
 
659
856
  export function rebuildActorRegistrations<TInvocation, TOutput>(
@@ -662,78 +859,101 @@ export function rebuildActorRegistrations<TInvocation, TOutput>(
662
859
  config: RebuildActorRegistrationsConfig<TInvocation, TOutput>,
663
860
  ): readonly ActorRegistrationReceipt[] {
664
861
  const snapshot = parseActorOwnerSnapshot(input);
665
- assertExactRecord(
862
+ const configRecord = readExactRecord(
666
863
  config,
667
864
  ['endpointFor'],
668
865
  'registration-mismatch',
669
866
  'RebuildActorRegistrationsConfig',
670
867
  );
671
- if (typeof config.endpointFor !== 'function') {
868
+ const endpointFor = configRecord.get('endpointFor');
869
+ if (typeof endpointFor !== 'function') {
672
870
  throw new ActorAddressingError('registration-mismatch', 'endpointFor must be a function');
673
871
  }
674
872
  const state = runtimeState(runtime);
675
- if (state.registrations.size !== 0) {
676
- throw new ActorAddressingError(
677
- 'already-registered',
678
- 'Registration recovery requires a fresh empty local runtime',
679
- );
680
- }
873
+ const operation = 'rebuildActorRegistrations';
874
+ const startingVersion = beginMutation(state, operation);
875
+ try {
876
+ if (state.registrations.size !== 0) {
877
+ throw new ActorAddressingError(
878
+ 'already-registered',
879
+ 'Registration recovery requires a fresh empty local runtime',
880
+ );
881
+ }
882
+
883
+ const endpoints = snapshot.registrations.map((registration) => {
884
+ const endpoint = parseEndpoint<TInvocation, TOutput>(
885
+ endpointFor(registration) as LocalActorEndpoint<TInvocation, TOutput>,
886
+ );
887
+ if (endpoint.handlerIdentity !== registration.handlerIdentity) {
888
+ throw new ActorAddressingError(
889
+ 'registration-mismatch',
890
+ `Recovered endpoint identity does not match snapshot for ${actorAddressKey(registration.address)}`,
891
+ );
892
+ }
893
+ return endpoint;
894
+ });
681
895
 
682
- const endpoints = snapshot.registrations.map((registration) => {
683
- const endpoint = parseEndpoint<TInvocation, TOutput>(config.endpointFor(registration));
684
- if (endpoint.handlerIdentity !== registration.handlerIdentity) {
896
+ if (state.mutationVersion !== startingVersion || state.registrations.size !== 0) {
685
897
  throw new ActorAddressingError(
686
898
  'registration-mismatch',
687
- `Recovered endpoint identity does not match snapshot for ${actorAddressKey(registration.address)}`,
899
+ 'Runtime changed while recovery endpoints were being constructed',
688
900
  );
689
901
  }
690
- return endpoint;
691
- });
692
902
 
693
- let nextSequence = state.sequence;
694
- const plannedIds = new Set<string>();
695
- const planned = snapshot.registrations.map((registration, index) => {
696
- nextSequence += 1;
697
- const registrationId = parseOpaqueToken(
698
- state.registrationIdFactory({
699
- runtimeInstanceId: runtime.runtimeInstanceId,
700
- sequence: nextSequence,
903
+ let nextSequence = state.sequence;
904
+ const plannedIds = new Set<string>();
905
+ const planned = snapshot.registrations.map((registration, index) => {
906
+ nextSequence += 1;
907
+ const registrationId = mintRegistrationId(
908
+ runtime,
909
+ state,
910
+ nextSequence,
911
+ registration.address,
912
+ plannedIds,
913
+ );
914
+ plannedIds.add(registrationId);
915
+ const receipt: ActorRegistrationReceipt = Object.freeze({
916
+ schemaVersion: ACTOR_REGISTRATION_SCHEMA_VERSION,
701
917
  address: registration.address,
702
- }),
703
- 'registrationId',
704
- 'registration-mismatch',
705
- );
706
- if (state.registrationIds.has(registrationId) || plannedIds.has(registrationId)) {
918
+ ownerRevision: snapshot.ownerRevision,
919
+ snapshotReceipt: snapshot.snapshotReceipt,
920
+ handlerIdentity: registration.handlerIdentity,
921
+ registrationId,
922
+ });
923
+ return {
924
+ key: actorAddressKey(registration.address),
925
+ registration: {
926
+ address: registration.address,
927
+ receipt,
928
+ endpoint: endpoints[index]!,
929
+ },
930
+ };
931
+ });
932
+
933
+ if (state.mutationVersion !== startingVersion || state.registrations.size !== 0) {
707
934
  throw new ActorAddressingError(
708
935
  'registration-mismatch',
709
- `registrationIdFactory returned a duplicate id: ${registrationId}`,
936
+ 'Runtime changed while recovery was being planned',
710
937
  );
711
938
  }
712
- plannedIds.add(registrationId);
713
- const receipt: ActorRegistrationReceipt = Object.freeze({
714
- schemaVersion: ACTOR_REGISTRATION_SCHEMA_VERSION,
715
- address: registration.address,
716
- ownerRevision: snapshot.ownerRevision,
717
- snapshotReceipt: snapshot.snapshotReceipt,
718
- handlerIdentity: registration.handlerIdentity,
719
- registrationId,
720
- });
721
- return {
722
- key: actorAddressKey(registration.address),
723
- registration: {
724
- address: registration.address,
725
- receipt,
726
- endpoint: endpoints[index]!,
727
- },
728
- };
729
- });
730
939
 
731
- for (const item of planned) {
732
- state.registrations.set(item.key, item.registration);
733
- state.registrationIds.add(item.registration.receipt.registrationId);
940
+ const nextRegistrations = new Map(
941
+ planned.map((item) => [item.key, item.registration] as const),
942
+ );
943
+ const nextUsedRegistrationIds = new Set(state.usedRegistrationIds);
944
+ for (const registrationId of plannedIds) {
945
+ nextUsedRegistrationIds.add(registrationId);
946
+ }
947
+ const receipts = Object.freeze(planned.map((item) => item.registration.receipt));
948
+
949
+ state.registrations = nextRegistrations;
950
+ state.usedRegistrationIds = nextUsedRegistrationIds;
951
+ state.sequence = nextSequence;
952
+ state.mutationVersion = startingVersion + 1;
953
+ return receipts;
954
+ } finally {
955
+ endMutation(state, operation);
734
956
  }
735
- state.sequence = nextSequence;
736
- return Object.freeze(planned.map((item) => item.registration.receipt));
737
957
  }
738
958
 
739
959
  export function createActorRefEndpoint<